mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-18 01:29:05 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67de2606cd | ||
| c09d3e41dd | |||
| b8be28d92b | |||
| 69f8ed1ef0 | |||
|
|
d130becd99 | ||
| 5703a61ddb | |||
|
|
ca06d0d7c1 | ||
| f15b9e2559 | |||
| 7be6a03d6d | |||
|
|
574ef6abd3 | ||
| 66c6599506 | |||
| 9ecf9f74b7 | |||
| 4340aa1cc0 | |||
| d01944bfec | |||
| 9ac39a6472 | |||
| ed2f24c0e8 | |||
|
|
a3c46bfa7c | ||
| 5e1b807105 | |||
| 51b9e92ddd | |||
| 320874a61a | |||
| cdd895d19a | |||
|
|
b76ae3bd7e | ||
| 2619708244 | |||
| 2d76e43519 | |||
| ab94ceb228 | |||
| 6c677a0d29 | |||
| c9affe8375 | |||
|
|
5528f090d8 | ||
| 0727defa98 | |||
| 9353705f52 | |||
| c2b0bc51d0 |
4
.editorconfig
Normal file
4
.editorconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
20
.github/workflows/manual-homebrew-release.yml
vendored
20
.github/workflows/manual-homebrew-release.yml
vendored
@@ -15,12 +15,19 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get latest tag
|
||||
- name: Get latest release info
|
||||
id: latest
|
||||
run: |
|
||||
tag=$(gh release view --json tagName -q .tagName)
|
||||
echo "Latest release tag: $tag"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Get release body and escape for JSON
|
||||
body=$(gh release view --json body -q .body)
|
||||
# Use delimiter for multiline output
|
||||
echo "body<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$body" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -30,7 +37,16 @@ jobs:
|
||||
run: |
|
||||
tag="${{ steps.latest.outputs.tag }}"
|
||||
repo="${{ github.event.repository.name }}"
|
||||
data="{\"event_type\":\"trigger-from-release\",\"client_payload\":{\"tag\":\"$tag\",\"repo\":\"$repo\"}}"
|
||||
# Use jq to properly escape the body for JSON
|
||||
body=$(cat <<'BODY_EOF'
|
||||
${{ steps.latest.outputs.body }}
|
||||
BODY_EOF
|
||||
)
|
||||
data=$(jq -n \
|
||||
--arg tag "$tag" \
|
||||
--arg repo "$repo" \
|
||||
--arg body "$body" \
|
||||
'{event_type: "trigger-from-release", client_payload: {tag: $tag, repo: $repo, body: $body}}')
|
||||
echo "Dispatching tag $tag from $repo"
|
||||
echo "Data: $data"
|
||||
curl -X POST \
|
||||
|
||||
118
.github/workflows/release.yml
vendored
118
.github/workflows/release.yml
vendored
@@ -11,114 +11,10 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23'
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
||||
generate:
|
||||
name: Build for ${{ matrix.platform }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
label: linux-amd64
|
||||
- platform: darwin/amd64
|
||||
label: darwin-amd64
|
||||
- platform: darwin/arm64
|
||||
label: darwin-arm64
|
||||
- platform: windows/amd64
|
||||
label: windows-amd64
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build for ${{ matrix.label }}
|
||||
uses: chenasraf/go-cross-build@v1
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
package: ''
|
||||
name: 'watchr'
|
||||
compress: 'true'
|
||||
dest: dist
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: "dist-${{ matrix.label }}"
|
||||
path: dist
|
||||
|
||||
release-please:
|
||||
name: Release
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
needs:
|
||||
- test
|
||||
- generate
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all builds
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Verify Release Artifacts
|
||||
run: |
|
||||
ls -la dist
|
||||
for i in "linux-amd64" "darwin-amd64" "windows-amd64" "darwin-arm64"; do
|
||||
if [[ ! -f ./dist/dist-$i/watchr-$i.tar.gz ]]; then
|
||||
echo "File not found: ./dist/dist-$i/watchr-$i.tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
release-type: simple
|
||||
|
||||
- name: Upload Release Artifacts
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for i in "linux-amd64" "darwin-amd64" "darwin-arm64" "windows-amd64"; do
|
||||
gh release upload "${{ steps.release.outputs.tag_name }}" "./dist/dist-$i/watchr-$i.tar.gz"
|
||||
done
|
||||
|
||||
release-homebrew:
|
||||
name: Homebrew Release
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send dispatch to homebrew-tap
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
run: |
|
||||
repo="${{ github.event.repository.name }}"
|
||||
tag="${{ needs.release-please.outputs.tag_name }}"
|
||||
data="{\"event_type\":\"trigger-from-release\",\"client_payload\":{\"tag\":\"$tag\",\"repo\":\"$repo\"}}"
|
||||
echo "Dispatching tag $tag from $repo"
|
||||
echo "Data: $data"
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
https://api.github.com/repos/chenasraf/homebrew-tap/dispatches \
|
||||
-d "$data"
|
||||
echo "Dispatched tag $tag from $repo"
|
||||
echo "Created job on https://github.com/chenasraf/homebrew-tap/actions"
|
||||
release:
|
||||
uses: chenasraf/workflows/.github/workflows/go-release.yml@master
|
||||
with:
|
||||
name: watchr
|
||||
homebrew-tap-repo: chenasraf/homebrew-tap
|
||||
secrets:
|
||||
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
|
||||
12
.golangci.yml
Normal file
12
.golangci.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- staticcheck
|
||||
- govet
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- unused
|
||||
- gocritic
|
||||
- intrange
|
||||
- modernize
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,5 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
## [1.5.2](https://github.com/chenasraf/watchr/compare/v1.5.1...v1.5.2) (2026-01-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* tab characters breaking the layout ([c09d3e4](https://github.com/chenasraf/watchr/commit/c09d3e41ddd69c44ea0546cb0608abe8b0c50334))
|
||||
|
||||
## [1.5.1](https://github.com/chenasraf/watchr/compare/v1.5.0...v1.5.1) (2025-12-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* esc button behavior ([5703a61](https://github.com/chenasraf/watchr/commit/5703a61ddb3ee25e58470c537d53bf4a463bc632))
|
||||
|
||||
## [1.5.0](https://github.com/chenasraf/watchr/compare/v1.4.0...v1.5.0) (2025-12-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add more refresh duration options & formats ([f15b9e2](https://github.com/chenasraf/watchr/commit/f15b9e255988b5b754c377e3ae29f9eb4c8a1925))
|
||||
|
||||
## [1.4.0](https://github.com/chenasraf/watchr/compare/v1.3.0...v1.4.0) (2025-12-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add -i/--interactive mode ([9ecf9f7](https://github.com/chenasraf/watchr/commit/9ecf9f74b7e8271dc8f8d2e6e2fc562e87be9953))
|
||||
* add spinning loader animation ([ed2f24c](https://github.com/chenasraf/watchr/commit/ed2f24c0e820ebaa37f46cc2407648c3e8b1cbbd))
|
||||
* properly support streaming commands ([66c6599](https://github.com/chenasraf/watchr/commit/66c65995068181d4b4ae74087eb0c15e1ab0edb4))
|
||||
|
||||
## [1.3.0](https://github.com/chenasraf/watchr/compare/v1.2.0...v1.3.0) (2025-12-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add exit status to command header ([51b9e92](https://github.com/chenasraf/watchr/commit/51b9e92ddd9766d109429878cc706761e1f46e47))
|
||||
* help overlay window ([cdd895d](https://github.com/chenasraf/watchr/commit/cdd895d19ac78b3bc34ccde7da9f2136b6661abd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* current line style ([5e1b807](https://github.com/chenasraf/watchr/commit/5e1b8071054c9727a5a8c3db85c88e1ede8e27d0))
|
||||
|
||||
## [1.2.0](https://github.com/chenasraf/watchr/compare/v1.1.0...v1.2.0) (2025-12-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --config/-c argument ([ab94ceb](https://github.com/chenasraf/watchr/commit/ab94ceb228ec59135d103b4f4cf3c1e391e82304))
|
||||
* add --show-config/-C argument ([c9affe8](https://github.com/chenasraf/watchr/commit/c9affe8375c60531d7489f937ab5aec4d9c73811))
|
||||
* update ui with panes & borders ([2d76e43](https://github.com/chenasraf/watchr/commit/2d76e4351968670339e9518b9a74c207a5600de8))
|
||||
* yank line ([2619708](https://github.com/chenasraf/watchr/commit/261970824412b32cfbbe46f642fb25864fa07510))
|
||||
|
||||
## [1.1.0](https://github.com/chenasraf/watchr/compare/v1.0.1...v1.1.0) (2025-12-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* global/project-local config files ([9353705](https://github.com/chenasraf/watchr/commit/9353705f5291584452fd0403575211b16f3c0cd2))
|
||||
* support fixed/percent preview sizes ([c2b0bc5](https://github.com/chenasraf/watchr/commit/c2b0bc51d0f9bbc0f9bb8bd449c00c2cf9ad89b5))
|
||||
|
||||
## [1.0.1](https://github.com/chenasraf/watchr/compare/v1.0.0...v1.0.1) (2025-12-03)
|
||||
|
||||
|
||||
|
||||
22
Makefile
22
Makefile
@@ -1,12 +1,19 @@
|
||||
all: run
|
||||
BIN := $(notdir $(CURDIR))
|
||||
|
||||
all:
|
||||
@if [ ! -f ".git/hooks/pre-commit" ]; then \
|
||||
$(MAKE) precommit-install; \
|
||||
fi
|
||||
$(MAKE) build
|
||||
$(MAKE) run
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
go build
|
||||
go build -o $(BIN)
|
||||
|
||||
.PHONY: run
|
||||
run: build
|
||||
./watchr
|
||||
./$(BIN)
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@@ -14,11 +21,15 @@ test:
|
||||
|
||||
.PHONY: install
|
||||
install: build
|
||||
cp watchr ~/.local/bin
|
||||
cp $(BIN) ~/.local/bin/
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
rm -f ~/.local/bin/watchr
|
||||
rm -f ~/.local/bin/$(BIN)
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
.PHONY: precommit-install
|
||||
precommit-install:
|
||||
@@ -33,6 +44,7 @@ precommit:
|
||||
if [ -z "$$STAGED_FILES" ]; then \
|
||||
echo "No staged Go files to check."; \
|
||||
else \
|
||||
set -e; \
|
||||
echo "Running pre-commit checks..."; \
|
||||
echo "go fmt"; \
|
||||
go fmt ./...; \
|
||||
|
||||
103
README.md
103
README.md
@@ -16,6 +16,7 @@ provides vim-style navigation, filtering, and a preview pane—all without leavi
|
||||
- **Preview pane**: Toggle a preview panel (bottom, top, left, or right)
|
||||
- **Auto-refresh**: Optionally re-run commands at specified intervals
|
||||
- **Line numbers**: Optional line numbering with configurable width
|
||||
- **Config files**: YAML, TOML, or JSON config files for persistent settings
|
||||
- **Full-screen TUI**: Clean, distraction-free interface using your entire terminal
|
||||
|
||||
---
|
||||
@@ -75,6 +76,18 @@ watchr "ps aux"
|
||||
# Refresh every 2 seconds
|
||||
watchr -r 2 "docker ps"
|
||||
|
||||
# Refresh every 500 milliseconds
|
||||
watchr -r 500ms "date"
|
||||
|
||||
# Refresh every 1.5 seconds
|
||||
watchr -r 1.5s "kubectl get pods"
|
||||
|
||||
# Refresh every 5 minutes
|
||||
watchr -r 5m "df -h"
|
||||
|
||||
# Refresh every hour
|
||||
watchr -r 1h "curl -s https://api.example.com/status"
|
||||
|
||||
# Watch file changes
|
||||
watchr -r 5 "find . -name '*.go' -mmin -1"
|
||||
```
|
||||
@@ -85,19 +98,89 @@ watchr -r 5 "find . -name '*.go' -mmin -1"
|
||||
Usage: watchr [options] <command to run>
|
||||
|
||||
Options:
|
||||
-h, --help Show help
|
||||
-v, --version Show version
|
||||
-r, --refresh int Auto-refresh interval in seconds (0 = disabled)
|
||||
-p, --prompt string Prompt string (default "watchr> ")
|
||||
-s, --shell string Shell to use for executing commands (default "sh")
|
||||
-n, --no-line-numbers Disable line numbers
|
||||
-w, --line-width int Line number width (default 6)
|
||||
-P, --preview-height int Preview window height/width percentage (default 40)
|
||||
--preview-position string Preview position: bottom, top, left, right (default "bottom")
|
||||
-h, --help Show help
|
||||
-v, --version Show version
|
||||
-c, --config string Load config from specified path
|
||||
-C, --show-config Show loaded configuration and exit
|
||||
-r, --refresh string Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)
|
||||
-p, --prompt string Prompt string (default "watchr> ")
|
||||
-s, --shell string Shell to use for executing commands (default "sh")
|
||||
-n, --no-line-numbers Disable line numbers
|
||||
-w, --line-width int Line number width (default 6)
|
||||
-P, --preview-size string Preview size: number for lines/cols, or number% for percentage (default "40%")
|
||||
-o, --preview-position string Preview position: bottom, top, left, right (default "bottom")
|
||||
-i, --interactive Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Configuration File
|
||||
|
||||
`watchr` supports configuration files in YAML, TOML, or JSON format. Settings in config files serve as defaults that can be overridden by command-line flags.
|
||||
|
||||
### Config File Locations
|
||||
|
||||
Config files are searched in the following order (later files override earlier ones):
|
||||
|
||||
1. **XDG config directory** (Linux/macOS): `~/.config/watchr/watchr.{yaml,toml,json}`
|
||||
2. **Windows**: `%APPDATA%\watchr\watchr.{yaml,toml,json}`
|
||||
3. **Current directory** (project-local): `./watchr.{yaml,toml,json}`
|
||||
|
||||
### Example Configurations
|
||||
|
||||
**YAML** (`watchr.yaml`):
|
||||
```yaml
|
||||
shell: bash
|
||||
preview-size: "50%"
|
||||
preview-position: right
|
||||
line-numbers: true
|
||||
line-width: 4
|
||||
prompt: "> "
|
||||
refresh: 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
|
||||
interactive: false
|
||||
```
|
||||
|
||||
**TOML** (`watchr.toml`):
|
||||
```toml
|
||||
shell = "bash"
|
||||
preview-size = "50%"
|
||||
preview-position = "right"
|
||||
line-numbers = true
|
||||
line-width = 4
|
||||
prompt = "> "
|
||||
refresh = 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
|
||||
interactive = false
|
||||
```
|
||||
|
||||
**JSON** (`watchr.json`):
|
||||
```json
|
||||
{
|
||||
"shell": "bash",
|
||||
"preview-size": "50%",
|
||||
"preview-position": "right",
|
||||
"line-numbers": true,
|
||||
"line-width": 4,
|
||||
"prompt": "> ",
|
||||
"refresh": 0,
|
||||
"interactive": false
|
||||
}
|
||||
```
|
||||
|
||||
The `refresh` option accepts:
|
||||
- Numbers: `2` or `1.5` (interpreted as seconds)
|
||||
- Explicit units: `"500ms"`, `"2s"`, `"5m"`, `"1h"`
|
||||
|
||||
### Priority Order
|
||||
|
||||
Configuration values are applied in this order (later sources override earlier ones):
|
||||
|
||||
1. Built-in defaults
|
||||
2. XDG/system config file
|
||||
3. Project-local config file (current directory)
|
||||
4. Command-line flags
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Keybindings
|
||||
|
||||
| Key | Action |
|
||||
@@ -113,6 +196,8 @@ Options:
|
||||
| `p` | Toggle preview pane |
|
||||
| `/` | Enter filter mode |
|
||||
| `Esc` | Exit filter mode / clear filter |
|
||||
| `y` | Yank (copy) selected line |
|
||||
| `?` | Show help overlay |
|
||||
|
||||
---
|
||||
|
||||
|
||||
12
go.mod
12
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -17,6 +18,8 @@ require (
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -24,8 +27,15 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
|
||||
45
go.sum
45
go.sum
@@ -12,8 +12,22 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -28,18 +42,45 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
208
internal/config/config.go
Normal file
208
internal/config/config.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config keys
|
||||
const (
|
||||
KeyShell = "shell"
|
||||
KeyPreviewSize = "preview-size"
|
||||
KeyPreviewPosition = "preview-position"
|
||||
KeyLineNumbers = "line-numbers"
|
||||
KeyLineWidth = "line-width"
|
||||
KeyPrompt = "prompt"
|
||||
KeyRefresh = "refresh"
|
||||
KeyInteractive = "interactive"
|
||||
)
|
||||
|
||||
// setDefaults sets the default configuration values.
|
||||
func setDefaults() {
|
||||
viper.SetDefault(KeyShell, "sh")
|
||||
viper.SetDefault(KeyPreviewSize, "40%")
|
||||
viper.SetDefault(KeyPreviewPosition, "bottom")
|
||||
viper.SetDefault(KeyLineNumbers, true)
|
||||
viper.SetDefault(KeyLineWidth, 6)
|
||||
viper.SetDefault(KeyPrompt, "watchr> ")
|
||||
viper.SetDefault(KeyRefresh, "0")
|
||||
viper.SetDefault(KeyInteractive, false)
|
||||
}
|
||||
|
||||
// Init initializes Viper with config file paths and defaults.
|
||||
func Init() {
|
||||
setDefaults()
|
||||
|
||||
// Config file name (without extension)
|
||||
viper.SetConfigName("watchr")
|
||||
|
||||
// Add config paths in reverse priority order (last added = highest priority)
|
||||
// 1. XDG config dir (lowest priority for files)
|
||||
if configDir := getConfigDir(); configDir != "" {
|
||||
watchrConfigDir := filepath.Join(configDir, "watchr")
|
||||
viper.AddConfigPath(watchrConfigDir)
|
||||
}
|
||||
|
||||
// 2. Current directory (highest priority for files)
|
||||
viper.AddConfigPath(".")
|
||||
|
||||
// Try to read config file (errors are ignored if file doesn't exist)
|
||||
_ = viper.ReadInConfig()
|
||||
}
|
||||
|
||||
// InitWithFile initializes Viper with a specific config file path.
|
||||
func InitWithFile(path string) error {
|
||||
setDefaults()
|
||||
|
||||
viper.SetConfigFile(path)
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindFlags binds pflags to Viper. Should be called after flag definitions
|
||||
// but before accessing config values.
|
||||
func BindFlags(flags *pflag.FlagSet) {
|
||||
// Bind each flag to its viper key
|
||||
_ = viper.BindPFlag(KeyShell, flags.Lookup("shell"))
|
||||
_ = viper.BindPFlag(KeyPreviewSize, flags.Lookup("preview-size"))
|
||||
_ = viper.BindPFlag(KeyPreviewPosition, flags.Lookup("preview-position"))
|
||||
_ = viper.BindPFlag(KeyLineWidth, flags.Lookup("line-width"))
|
||||
_ = viper.BindPFlag(KeyPrompt, flags.Lookup("prompt"))
|
||||
_ = viper.BindPFlag(KeyRefresh, flags.Lookup("refresh"))
|
||||
_ = viper.BindPFlag(KeyInteractive, flags.Lookup("interactive"))
|
||||
|
||||
// line-numbers is inverted (no-line-numbers flag)
|
||||
_ = viper.BindPFlag("no-line-numbers", flags.Lookup("no-line-numbers"))
|
||||
}
|
||||
|
||||
// GetString returns a string config value.
|
||||
func GetString(key string) string {
|
||||
return viper.GetString(key)
|
||||
}
|
||||
|
||||
// GetInt returns an int config value.
|
||||
func GetInt(key string) int {
|
||||
return viper.GetInt(key)
|
||||
}
|
||||
|
||||
// GetBool returns a bool config value.
|
||||
func GetBool(key string) bool {
|
||||
return viper.GetBool(key)
|
||||
}
|
||||
|
||||
// ShowLineNumbers returns whether line numbers should be shown.
|
||||
// This handles the inverted no-line-numbers flag.
|
||||
func ShowLineNumbers() bool {
|
||||
// If no-line-numbers flag is set, don't show line numbers
|
||||
if viper.GetBool("no-line-numbers") {
|
||||
return false
|
||||
}
|
||||
// Otherwise use the line-numbers config value
|
||||
return viper.GetBool(KeyLineNumbers)
|
||||
}
|
||||
|
||||
// ConfigFileUsed returns the config file path if one was loaded.
|
||||
func ConfigFileUsed() string {
|
||||
return viper.ConfigFileUsed()
|
||||
}
|
||||
|
||||
// PrintConfig prints the current configuration to stdout.
|
||||
func PrintConfig() {
|
||||
configFile := ConfigFileUsed()
|
||||
if configFile != "" {
|
||||
fmt.Printf("Config file: %s\n\n", configFile)
|
||||
} else {
|
||||
fmt.Println("Config file: (none loaded)")
|
||||
}
|
||||
|
||||
fmt.Println("Current configuration:")
|
||||
fmt.Printf(" %-20s %s\n", KeyShell+":", GetString(KeyShell))
|
||||
fmt.Printf(" %-20s %s\n", KeyPreviewSize+":", GetString(KeyPreviewSize))
|
||||
fmt.Printf(" %-20s %s\n", KeyPreviewPosition+":", GetString(KeyPreviewPosition))
|
||||
fmt.Printf(" %-20s %v\n", KeyLineNumbers+":", GetBool(KeyLineNumbers))
|
||||
fmt.Printf(" %-20s %d\n", KeyLineWidth+":", GetInt(KeyLineWidth))
|
||||
fmt.Printf(" %-20s %q\n", KeyPrompt+":", GetString(KeyPrompt))
|
||||
fmt.Printf(" %-20s %s\n", KeyRefresh+":", GetString(KeyRefresh))
|
||||
fmt.Printf(" %-20s %v\n", KeyInteractive+":", GetBool(KeyInteractive))
|
||||
}
|
||||
|
||||
// getConfigDir returns the appropriate config directory for the OS.
|
||||
func getConfigDir() string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return os.Getenv("APPDATA")
|
||||
default:
|
||||
// Use XDG_CONFIG_HOME if set, otherwise ~/.config
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// durationRegex matches duration strings like "1", "1.5", "500ms", "1s", "1.5s", "5m", "1h"
|
||||
var durationRegex = regexp.MustCompile(`^(\d+(?:\.\d+)?)(ms|s|m|h)?$`)
|
||||
|
||||
// ParseDuration parses a duration string into a time.Duration.
|
||||
// Supported formats:
|
||||
// - "1", "1.5" - interpreted as seconds (default unit)
|
||||
// - "1s", "1.5s" - explicit seconds
|
||||
// - "500ms", "1500ms" - explicit milliseconds
|
||||
// - "5m", "1.5m" - explicit minutes
|
||||
// - "1h", "0.5h" - explicit hours
|
||||
//
|
||||
// Returns 0 if the input is empty or "0".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
if s == "" || s == "0" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
matches := durationRegex.FindStringSubmatch(s)
|
||||
if matches == nil {
|
||||
return 0, fmt.Errorf("invalid duration format: %q (expected number, Xms, Xs, Xm, or Xh)", s)
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration value: %q", matches[1])
|
||||
}
|
||||
|
||||
unit := matches[2]
|
||||
switch unit {
|
||||
case "ms":
|
||||
return time.Duration(value * float64(time.Millisecond)), nil
|
||||
case "s", "":
|
||||
// Default to seconds
|
||||
return time.Duration(value * float64(time.Second)), nil
|
||||
case "m":
|
||||
return time.Duration(value * float64(time.Minute)), nil
|
||||
case "h":
|
||||
return time.Duration(value * float64(time.Hour)), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid duration unit: %q", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDuration returns a duration config value by parsing the string value.
|
||||
// Returns 0 if parsing fails or value is empty.
|
||||
func GetDuration(key string) time.Duration {
|
||||
s := viper.GetString(key)
|
||||
d, err := ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
612
internal/config/config_test.go
Normal file
612
internal/config/config_test.go
Normal file
@@ -0,0 +1,612 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func resetViper() {
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
// isolateConfig sets up a clean environment for config tests by:
|
||||
// - Resetting viper
|
||||
// - Changing to a temp directory
|
||||
// - Setting XDG_CONFIG_HOME to the temp directory
|
||||
// Returns the temp directory path and a cleanup function.
|
||||
func isolateConfig(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
resetViper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to change directory: %v", err)
|
||||
}
|
||||
|
||||
oldXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.Chdir(oldWd)
|
||||
_ = os.Setenv("XDG_CONFIG_HOME", oldXDG)
|
||||
}
|
||||
|
||||
return tmpDir, cleanup
|
||||
}
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
Init()
|
||||
|
||||
// Check defaults are set
|
||||
if got := viper.GetString(KeyShell); got != "sh" {
|
||||
t.Errorf("expected default shell 'sh', got %q", got)
|
||||
}
|
||||
|
||||
if got := viper.GetString(KeyPreviewSize); got != "40%" {
|
||||
t.Errorf("expected default preview-size '40%%', got %q", got)
|
||||
}
|
||||
|
||||
if got := viper.GetString(KeyPreviewPosition); got != "bottom" {
|
||||
t.Errorf("expected default preview-position 'bottom', got %q", got)
|
||||
}
|
||||
|
||||
if got := viper.GetBool(KeyLineNumbers); got != true {
|
||||
t.Errorf("expected default line-numbers true, got %v", got)
|
||||
}
|
||||
|
||||
if got := viper.GetInt(KeyLineWidth); got != 6 {
|
||||
t.Errorf("expected default line-width 6, got %d", got)
|
||||
}
|
||||
|
||||
if got := viper.GetString(KeyPrompt); got != "watchr> " {
|
||||
t.Errorf("expected default prompt 'watchr> ', got %q", got)
|
||||
}
|
||||
|
||||
if got := viper.GetString(KeyRefresh); got != "0" {
|
||||
t.Errorf("expected default refresh '0', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetters(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
Init()
|
||||
|
||||
if got := GetString(KeyShell); got != "sh" {
|
||||
t.Errorf("GetString: expected 'sh', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetInt(KeyLineWidth); got != 6 {
|
||||
t.Errorf("GetInt: expected 6, got %d", got)
|
||||
}
|
||||
|
||||
if got := GetBool(KeyLineNumbers); got != true {
|
||||
t.Errorf("GetBool: expected true, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowLineNumbers(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
Init()
|
||||
|
||||
// Default: line numbers enabled
|
||||
if got := ShowLineNumbers(); got != true {
|
||||
t.Errorf("expected ShowLineNumbers() true by default, got %v", got)
|
||||
}
|
||||
|
||||
// When no-line-numbers is set
|
||||
viper.Set("no-line-numbers", true)
|
||||
if got := ShowLineNumbers(); got != false {
|
||||
t.Errorf("expected ShowLineNumbers() false when no-line-numbers=true, got %v", got)
|
||||
}
|
||||
|
||||
// Reset
|
||||
viper.Set("no-line-numbers", false)
|
||||
if got := ShowLineNumbers(); got != true {
|
||||
t.Errorf("expected ShowLineNumbers() true when no-line-numbers=false, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindFlags(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
Init()
|
||||
|
||||
// Create a new flag set
|
||||
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
flags.String("shell", "sh", "")
|
||||
flags.String("preview-size", "40%", "")
|
||||
flags.String("preview-position", "bottom", "")
|
||||
flags.Int("line-width", 6, "")
|
||||
flags.String("prompt", "watchr> ", "")
|
||||
flags.String("refresh", "0", "")
|
||||
flags.Bool("no-line-numbers", false, "")
|
||||
|
||||
// Parse with custom values
|
||||
err := flags.Parse([]string{"--shell=bash", "--preview-size=50%", "--line-width=8"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse flags: %v", err)
|
||||
}
|
||||
|
||||
// Bind flags
|
||||
BindFlags(flags)
|
||||
|
||||
// Check that flag values override defaults
|
||||
if got := GetString(KeyShell); got != "bash" {
|
||||
t.Errorf("expected shell 'bash' from flag, got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewSize); got != "50%" {
|
||||
t.Errorf("expected preview-size '50%%' from flag, got %q", got)
|
||||
}
|
||||
|
||||
if got := GetInt(KeyLineWidth); got != 8 {
|
||||
t.Errorf("expected line-width 8 from flag, got %d", got)
|
||||
}
|
||||
|
||||
// Non-overridden values should still be defaults
|
||||
if got := GetString(KeyPreviewPosition); got != "bottom" {
|
||||
t.Errorf("expected preview-position 'bottom' (default), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileLoading(t *testing.T) {
|
||||
tmpDir, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a config file in the temp directory
|
||||
configPath := filepath.Join(tmpDir, "watchr.yaml")
|
||||
configContent := `shell: zsh
|
||||
preview-size: "60%"
|
||||
preview-position: right
|
||||
line-numbers: true
|
||||
line-width: 4
|
||||
prompt: "test> "
|
||||
refresh: 5
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Initialize config
|
||||
Init()
|
||||
|
||||
// Check that config file values are loaded
|
||||
if got := GetString(KeyShell); got != "zsh" {
|
||||
t.Errorf("expected shell 'zsh' from config file, got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewSize); got != "60%" {
|
||||
t.Errorf("expected preview-size '60%%' from config file, got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewPosition); got != "right" {
|
||||
t.Errorf("expected preview-position 'right' from config file, got %q", got)
|
||||
}
|
||||
|
||||
if got := GetInt(KeyLineWidth); got != 4 {
|
||||
t.Errorf("expected line-width 4 from config file, got %d", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPrompt); got != "test> " {
|
||||
t.Errorf("expected prompt 'test> ' from config file, got %q", got)
|
||||
}
|
||||
|
||||
if got := GetDuration(KeyRefresh); got != 5*time.Second {
|
||||
t.Errorf("expected refresh 5s from config file, got %v", got)
|
||||
}
|
||||
|
||||
// ConfigFileUsed should return the path
|
||||
if used := ConfigFileUsed(); used == "" {
|
||||
t.Error("expected ConfigFileUsed() to return config file path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileWithFlags(t *testing.T) {
|
||||
tmpDir, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a config file in the temp directory
|
||||
configPath := filepath.Join(tmpDir, "watchr.yaml")
|
||||
configContent := `shell: zsh
|
||||
preview-size: "60%"
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Initialize config
|
||||
Init()
|
||||
|
||||
// Create flags and parse with override
|
||||
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
flags.String("shell", "sh", "")
|
||||
flags.String("preview-size", "40%", "")
|
||||
flags.String("preview-position", "bottom", "")
|
||||
flags.Int("line-width", 6, "")
|
||||
flags.String("prompt", "watchr> ", "")
|
||||
flags.String("refresh", "0", "")
|
||||
flags.Bool("no-line-numbers", false, "")
|
||||
|
||||
// Override shell via flag
|
||||
err := flags.Parse([]string{"--shell=bash"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse flags: %v", err)
|
||||
}
|
||||
|
||||
BindFlags(flags)
|
||||
|
||||
// Flag should override config file
|
||||
if got := GetString(KeyShell); got != "bash" {
|
||||
t.Errorf("expected shell 'bash' (flag override), got %q", got)
|
||||
}
|
||||
|
||||
// Config file value should be used when no flag override
|
||||
if got := GetString(KeyPreviewSize); got != "60%" {
|
||||
t.Errorf("expected preview-size '60%%' (from config file), got %q", got)
|
||||
}
|
||||
|
||||
// Default should be used when not in config file and no flag
|
||||
if got := GetString(KeyPreviewPosition); got != "bottom" {
|
||||
t.Errorf("expected preview-position 'bottom' (default), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigDir(t *testing.T) {
|
||||
dir := getConfigDir()
|
||||
if dir == "" {
|
||||
t.Log("getConfigDir returned empty string (may be expected in some environments)")
|
||||
} else {
|
||||
t.Logf("getConfigDir returned: %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithFile(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a config file
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "custom.yaml")
|
||||
configContent := `shell: fish
|
||||
preview-size: "75%"
|
||||
preview-position: left
|
||||
line-numbers: false
|
||||
line-width: 8
|
||||
prompt: "custom> "
|
||||
refresh: 10
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Initialize with specific file
|
||||
if err := InitWithFile(configPath); err != nil {
|
||||
t.Fatalf("InitWithFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Check values from config file
|
||||
if got := GetString(KeyShell); got != "fish" {
|
||||
t.Errorf("expected shell 'fish', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewSize); got != "75%" {
|
||||
t.Errorf("expected preview-size '75%%', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewPosition); got != "left" {
|
||||
t.Errorf("expected preview-position 'left', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetBool(KeyLineNumbers); got != false {
|
||||
t.Errorf("expected line-numbers false, got %v", got)
|
||||
}
|
||||
|
||||
if got := GetInt(KeyLineWidth); got != 8 {
|
||||
t.Errorf("expected line-width 8, got %d", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPrompt); got != "custom> " {
|
||||
t.Errorf("expected prompt 'custom> ', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetDuration(KeyRefresh); got != 10*time.Second {
|
||||
t.Errorf("expected refresh 10s, got %v", got)
|
||||
}
|
||||
|
||||
// ConfigFileUsed should return the specified path
|
||||
if used := ConfigFileUsed(); used != configPath {
|
||||
t.Errorf("expected ConfigFileUsed() = %q, got %q", configPath, used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithFileNotFound(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
err := InitWithFile("/nonexistent/path/config.yaml")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithFileTOML(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a TOML config file
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
configContent := `shell = "zsh"
|
||||
preview-size = "50%"
|
||||
preview-position = "top"
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
if err := InitWithFile(configPath); err != nil {
|
||||
t.Fatalf("InitWithFile failed for TOML: %v", err)
|
||||
}
|
||||
|
||||
if got := GetString(KeyShell); got != "zsh" {
|
||||
t.Errorf("expected shell 'zsh', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewPosition); got != "top" {
|
||||
t.Errorf("expected preview-position 'top', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithFileJSON(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a JSON config file
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
configContent := `{
|
||||
"shell": "bash",
|
||||
"preview-size": "30%",
|
||||
"preview-position": "right"
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
if err := InitWithFile(configPath); err != nil {
|
||||
t.Fatalf("InitWithFile failed for JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := GetString(KeyShell); got != "bash" {
|
||||
t.Errorf("expected shell 'bash', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewSize); got != "30%" {
|
||||
t.Errorf("expected preview-size '30%%', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithFileDefaults(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a config file with only some values
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "partial.yaml")
|
||||
configContent := `shell: fish
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
if err := InitWithFile(configPath); err != nil {
|
||||
t.Fatalf("InitWithFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Specified value should be loaded
|
||||
if got := GetString(KeyShell); got != "fish" {
|
||||
t.Errorf("expected shell 'fish', got %q", got)
|
||||
}
|
||||
|
||||
// Unspecified values should use defaults
|
||||
if got := GetString(KeyPreviewSize); got != "40%" {
|
||||
t.Errorf("expected default preview-size '40%%', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetString(KeyPreviewPosition); got != "bottom" {
|
||||
t.Errorf("expected default preview-position 'bottom', got %q", got)
|
||||
}
|
||||
|
||||
if got := GetInt(KeyLineWidth); got != 6 {
|
||||
t.Errorf("expected default line-width 6, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
// Empty and zero values
|
||||
{"", 0, false},
|
||||
{"0", 0, false},
|
||||
|
||||
// Plain numbers (default to seconds)
|
||||
{"1", 1 * time.Second, false},
|
||||
{"5", 5 * time.Second, false},
|
||||
{"60", 60 * time.Second, false},
|
||||
|
||||
// Decimal seconds
|
||||
{"0.5", 500 * time.Millisecond, false},
|
||||
{"1.5", 1500 * time.Millisecond, false},
|
||||
{"2.25", 2250 * time.Millisecond, false},
|
||||
{"0.1", 100 * time.Millisecond, false},
|
||||
{"0.001", 1 * time.Millisecond, false},
|
||||
|
||||
// Explicit seconds suffix
|
||||
{"1s", 1 * time.Second, false},
|
||||
{"5s", 5 * time.Second, false},
|
||||
{"0.5s", 500 * time.Millisecond, false},
|
||||
{"1.5s", 1500 * time.Millisecond, false},
|
||||
|
||||
// Milliseconds suffix
|
||||
{"100ms", 100 * time.Millisecond, false},
|
||||
{"500ms", 500 * time.Millisecond, false},
|
||||
{"1000ms", 1000 * time.Millisecond, false},
|
||||
{"1500ms", 1500 * time.Millisecond, false},
|
||||
{"50.5ms", 50500 * time.Microsecond, false},
|
||||
|
||||
// Minutes suffix
|
||||
{"1m", 1 * time.Minute, false},
|
||||
{"5m", 5 * time.Minute, false},
|
||||
{"0.5m", 30 * time.Second, false},
|
||||
{"1.5m", 90 * time.Second, false},
|
||||
|
||||
// Hours suffix
|
||||
{"1h", 1 * time.Hour, false},
|
||||
{"2h", 2 * time.Hour, false},
|
||||
{"0.5h", 30 * time.Minute, false},
|
||||
{"1.5h", 90 * time.Minute, false},
|
||||
|
||||
// Invalid formats
|
||||
{"abc", 0, true},
|
||||
{"1d", 0, true}, // days not supported
|
||||
{"1w", 0, true}, // weeks not supported
|
||||
{"-1", 0, true}, // negative not supported
|
||||
{"-1s", 0, true}, // negative not supported
|
||||
{"1.2.3", 0, true},
|
||||
{"s", 0, true},
|
||||
{"ms", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := ParseDuration(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParseDuration(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParseDuration(%q) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
if got != tt.expected {
|
||||
t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDuration(t *testing.T) {
|
||||
_, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
Init()
|
||||
|
||||
// Test default value
|
||||
if got := GetDuration(KeyRefresh); got != 0 {
|
||||
t.Errorf("expected default refresh 0, got %v", got)
|
||||
}
|
||||
|
||||
// Test with seconds value
|
||||
viper.Set(KeyRefresh, "5")
|
||||
if got := GetDuration(KeyRefresh); got != 5*time.Second {
|
||||
t.Errorf("expected refresh 5s, got %v", got)
|
||||
}
|
||||
|
||||
// Test with decimal value
|
||||
viper.Set(KeyRefresh, "0.5")
|
||||
if got := GetDuration(KeyRefresh); got != 500*time.Millisecond {
|
||||
t.Errorf("expected refresh 500ms, got %v", got)
|
||||
}
|
||||
|
||||
// Test with explicit seconds suffix
|
||||
viper.Set(KeyRefresh, "2s")
|
||||
if got := GetDuration(KeyRefresh); got != 2*time.Second {
|
||||
t.Errorf("expected refresh 2s, got %v", got)
|
||||
}
|
||||
|
||||
// Test with milliseconds
|
||||
viper.Set(KeyRefresh, "250ms")
|
||||
if got := GetDuration(KeyRefresh); got != 250*time.Millisecond {
|
||||
t.Errorf("expected refresh 250ms, got %v", got)
|
||||
}
|
||||
|
||||
// Test with invalid value (should return 0)
|
||||
viper.Set(KeyRefresh, "invalid")
|
||||
if got := GetDuration(KeyRefresh); got != 0 {
|
||||
t.Errorf("expected refresh 0 for invalid value, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshDurationFromConfigFile(t *testing.T) {
|
||||
tmpDir, cleanup := isolateConfig(t)
|
||||
defer cleanup()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
expected time.Duration
|
||||
}{
|
||||
{
|
||||
name: "integer seconds",
|
||||
yaml: "refresh: 5\n",
|
||||
expected: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "decimal seconds",
|
||||
yaml: "refresh: 0.5\n",
|
||||
expected: 500 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "string with s suffix",
|
||||
yaml: "refresh: \"2s\"\n",
|
||||
expected: 2 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "string with ms suffix",
|
||||
yaml: "refresh: \"500ms\"\n",
|
||||
expected: 500 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "string decimal with s suffix",
|
||||
yaml: "refresh: \"1.5s\"\n",
|
||||
expected: 1500 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetViper()
|
||||
|
||||
configPath := filepath.Join(tmpDir, "watchr.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(tt.yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
Init()
|
||||
|
||||
got := GetDuration(KeyRefresh)
|
||||
if got != tt.expected {
|
||||
t.Errorf("GetDuration(KeyRefresh) = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,22 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// sanitizeLine removes control sequences that can corrupt terminal rendering
|
||||
func sanitizeLine(s string) string {
|
||||
// Remove carriage returns
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
// Convert tabs to spaces (tabs cause width calculation issues)
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
return s
|
||||
}
|
||||
|
||||
// Line represents a single line of output with its line number
|
||||
type Line struct {
|
||||
Number int
|
||||
@@ -26,34 +37,109 @@ func (l Line) FormatLine(width int, showLineNum bool) string {
|
||||
|
||||
// Runner executes commands and captures output
|
||||
type Runner struct {
|
||||
Shell string
|
||||
Command string
|
||||
Shell string
|
||||
Command string
|
||||
Interactive bool
|
||||
}
|
||||
|
||||
// NewRunner creates a new Runner
|
||||
func NewRunner(shell, command string) *Runner {
|
||||
return &Runner{
|
||||
Shell: shell,
|
||||
Command: command,
|
||||
Shell: shell,
|
||||
Command: command,
|
||||
Interactive: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the command and returns output lines
|
||||
func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
|
||||
// NewInteractiveRunner creates a new Runner that sources shell rc files
|
||||
func NewInteractiveRunner(shell, command string) *Runner {
|
||||
return &Runner{
|
||||
Shell: shell,
|
||||
Command: command,
|
||||
Interactive: true,
|
||||
}
|
||||
}
|
||||
|
||||
// buildCommand returns the shell arguments for executing the command.
|
||||
// If Interactive is true, it wraps the command to source the appropriate rc file.
|
||||
func (r *Runner) buildCommand() []string {
|
||||
if !r.Interactive {
|
||||
return []string{"-c", r.Command}
|
||||
}
|
||||
|
||||
// For interactive mode, source the appropriate rc file before running the command
|
||||
rcFile := r.getRCFile()
|
||||
if rcFile != "" {
|
||||
// Source the rc file if it exists, then run the command
|
||||
wrappedCmd := fmt.Sprintf("[ -f %s ] && . %s; %s", rcFile, rcFile, r.Command)
|
||||
return []string{"-c", wrappedCmd}
|
||||
}
|
||||
|
||||
return []string{"-c", r.Command}
|
||||
}
|
||||
|
||||
// getRCFile returns the path to the shell's rc file based on the shell being used.
|
||||
func (r *Runner) getRCFile() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
shellBase := filepath.Base(r.Shell)
|
||||
switch shellBase {
|
||||
case "bash":
|
||||
// Prefer .bashrc for interactive settings, fall back to .bash_profile
|
||||
bashrc := filepath.Join(home, ".bashrc")
|
||||
if _, err := os.Stat(bashrc); err == nil {
|
||||
return bashrc
|
||||
}
|
||||
return filepath.Join(home, ".bash_profile")
|
||||
case "zsh":
|
||||
return filepath.Join(home, ".zshrc")
|
||||
case "fish":
|
||||
configDir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if configDir == "" {
|
||||
configDir = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(configDir, "fish", "config.fish")
|
||||
case "ksh":
|
||||
return filepath.Join(home, ".kshrc")
|
||||
case "sh":
|
||||
// POSIX sh uses ENV variable or .profile
|
||||
if env := os.Getenv("ENV"); env != "" {
|
||||
return env
|
||||
}
|
||||
return filepath.Join(home, ".profile")
|
||||
default:
|
||||
// Try common patterns for unknown shells
|
||||
return filepath.Join(home, "."+shellBase+"rc")
|
||||
}
|
||||
}
|
||||
|
||||
// Result contains the output and exit code of a command run
|
||||
type Result struct {
|
||||
Lines []Line
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// Run executes the command and returns output lines with exit code
|
||||
func (r *Runner) Run(ctx context.Context) (Result, error) {
|
||||
args := r.buildCommand()
|
||||
cmd := exec.CommandContext(ctx, r.Shell, args...)
|
||||
cmd.Env = append(os.Environ(), "WATCHR=1")
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start command: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
var lines []Line
|
||||
@@ -64,7 +150,7 @@ func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, Line{
|
||||
Number: lineNum,
|
||||
Content: scanner.Text(),
|
||||
Content: sanitizeLine(scanner.Text()),
|
||||
})
|
||||
lineNum++
|
||||
}
|
||||
@@ -74,70 +160,156 @@ func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
for stderrScanner.Scan() {
|
||||
lines = append(lines, Line{
|
||||
Number: lineNum,
|
||||
Content: stderrScanner.Text(),
|
||||
Content: sanitizeLine(stderrScanner.Text()),
|
||||
})
|
||||
lineNum++
|
||||
}
|
||||
|
||||
// Wait for command to finish (ignore exit code - we still want to show output)
|
||||
_ = cmd.Wait()
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// RunStreaming executes the command and streams output lines to the callback
|
||||
// The callback is called for each line as it arrives
|
||||
func (r *Runner) RunStreaming(ctx context.Context, lines *[]Line, mu *sync.RWMutex) error {
|
||||
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
lineNum := 1
|
||||
|
||||
// Read from both stdout and stderr concurrently
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
readPipe := func(pipe io.Reader) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(pipe)
|
||||
for scanner.Scan() {
|
||||
mu.Lock()
|
||||
*lines = append(*lines, Line{
|
||||
Number: lineNum,
|
||||
Content: scanner.Text(),
|
||||
})
|
||||
lineNum++
|
||||
mu.Unlock()
|
||||
// Wait for command to finish and get exit code
|
||||
exitCode := 0
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
go readPipe(stdout)
|
||||
go readPipe(stderr)
|
||||
return Result{Lines: lines, ExitCode: exitCode}, nil
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// StreamingResult holds the state of a streaming command
|
||||
type StreamingResult struct {
|
||||
Lines *[]Line
|
||||
ExitCode int
|
||||
Done bool
|
||||
Error error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Wait for command to finish (ignore exit code - we still want to show output)
|
||||
_ = cmd.Wait()
|
||||
// GetLines returns a copy of the current lines (thread-safe)
|
||||
func (s *StreamingResult) GetLines() []Line {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.Lines == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]Line, len(*s.Lines))
|
||||
copy(result, *s.Lines)
|
||||
return result
|
||||
}
|
||||
|
||||
return nil
|
||||
// LineCount returns the current number of lines (thread-safe)
|
||||
func (s *StreamingResult) LineCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.Lines == nil {
|
||||
return 0
|
||||
}
|
||||
return len(*s.Lines)
|
||||
}
|
||||
|
||||
// IsDone returns whether the command has finished (thread-safe)
|
||||
func (s *StreamingResult) IsDone() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.Done
|
||||
}
|
||||
|
||||
// RunStreaming executes the command and streams output lines in the background.
|
||||
// Returns a StreamingResult that can be polled for updates.
|
||||
// The command runs until ctx is cancelled or it completes naturally.
|
||||
func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult {
|
||||
result := &StreamingResult{
|
||||
Lines: &[]Line{},
|
||||
ExitCode: -1,
|
||||
Done: false,
|
||||
}
|
||||
|
||||
go func() {
|
||||
args := r.buildCommand()
|
||||
cmd := exec.CommandContext(ctx, r.Shell, args...)
|
||||
cmd.Env = append(os.Environ(), "WATCHR=1")
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
result.mu.Lock()
|
||||
result.Error = fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
result.Done = true
|
||||
result.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
result.mu.Lock()
|
||||
result.Error = fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
result.Done = true
|
||||
result.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
result.mu.Lock()
|
||||
result.Error = fmt.Errorf("failed to start command: %w", err)
|
||||
result.Done = true
|
||||
result.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
lineNum := 1
|
||||
var lineNumMu sync.Mutex
|
||||
|
||||
// Read from both stdout and stderr concurrently
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
readPipe := func(pipe io.Reader) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(pipe)
|
||||
for scanner.Scan() {
|
||||
lineNumMu.Lock()
|
||||
currentLineNum := lineNum
|
||||
lineNum++
|
||||
lineNumMu.Unlock()
|
||||
|
||||
result.mu.Lock()
|
||||
*result.Lines = append(*result.Lines, Line{
|
||||
Number: currentLineNum,
|
||||
Content: sanitizeLine(scanner.Text()),
|
||||
})
|
||||
result.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
go readPipe(stdout)
|
||||
go readPipe(stderr)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Wait for command to finish and get exit code
|
||||
exitCode := 0
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if ctx.Err() != nil {
|
||||
// Context was cancelled
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
|
||||
result.mu.Lock()
|
||||
result.ExitCode = exitCode
|
||||
result.Done = true
|
||||
result.mu.Unlock()
|
||||
}()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// RunSimple executes the command and returns output as string slice
|
||||
func (r *Runner) RunSimple(ctx context.Context) ([]string, error) {
|
||||
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
|
||||
args := r.buildCommand()
|
||||
cmd := exec.CommandContext(ctx, r.Shell, args...)
|
||||
cmd.Env = append(os.Environ(), "WATCHR=1")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Still return output even on error (non-zero exit)
|
||||
|
||||
@@ -14,6 +14,97 @@ func TestNewRunner(t *testing.T) {
|
||||
if r.Command != "echo hello" {
|
||||
t.Errorf("expected command 'echo hello', got %q", r.Command)
|
||||
}
|
||||
if r.Interactive {
|
||||
t.Errorf("expected Interactive to be false for NewRunner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInteractiveRunner(t *testing.T) {
|
||||
r := NewInteractiveRunner("bash", "my_func")
|
||||
if r.Shell != "bash" {
|
||||
t.Errorf("expected shell 'bash', got %q", r.Shell)
|
||||
}
|
||||
if r.Command != "my_func" {
|
||||
t.Errorf("expected command 'my_func', got %q", r.Command)
|
||||
}
|
||||
if !r.Interactive {
|
||||
t.Errorf("expected Interactive to be true for NewInteractiveRunner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_buildCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shell string
|
||||
command string
|
||||
interactive bool
|
||||
wantFirst string
|
||||
}{
|
||||
{
|
||||
name: "non-interactive",
|
||||
shell: "sh",
|
||||
command: "echo hello",
|
||||
interactive: false,
|
||||
wantFirst: "-c",
|
||||
},
|
||||
{
|
||||
name: "interactive bash",
|
||||
shell: "bash",
|
||||
command: "my_func",
|
||||
interactive: true,
|
||||
wantFirst: "-c",
|
||||
},
|
||||
{
|
||||
name: "interactive zsh",
|
||||
shell: "/bin/zsh",
|
||||
command: "my_alias",
|
||||
interactive: true,
|
||||
wantFirst: "-c",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var r *Runner
|
||||
if tt.interactive {
|
||||
r = NewInteractiveRunner(tt.shell, tt.command)
|
||||
} else {
|
||||
r = NewRunner(tt.shell, tt.command)
|
||||
}
|
||||
|
||||
args := r.buildCommand()
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected 2 args, got %d", len(args))
|
||||
}
|
||||
if args[0] != tt.wantFirst {
|
||||
t.Errorf("expected first arg %q, got %q", tt.wantFirst, args[0])
|
||||
}
|
||||
|
||||
// For interactive mode, the command should contain sourcing logic
|
||||
if tt.interactive {
|
||||
if !contains(args[1], tt.command) {
|
||||
t.Errorf("expected command %q to be in args[1] %q", tt.command, args[1])
|
||||
}
|
||||
} else {
|
||||
if args[1] != tt.command {
|
||||
t.Errorf("expected args[1] to be %q, got %q", tt.command, args[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestRunner_Run(t *testing.T) {
|
||||
@@ -52,17 +143,17 @@ func TestRunner_Run(t *testing.T) {
|
||||
r := NewRunner(tt.shell, tt.command)
|
||||
ctx := context.Background()
|
||||
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != tt.wantLines {
|
||||
t.Errorf("expected %d lines, got %d", tt.wantLines, len(lines))
|
||||
if len(result.Lines) != tt.wantLines {
|
||||
t.Errorf("expected %d lines, got %d", tt.wantLines, len(result.Lines))
|
||||
}
|
||||
|
||||
if tt.wantLines > 0 && lines[0].Content != tt.wantContent {
|
||||
t.Errorf("expected first line %q, got %q", tt.wantContent, lines[0].Content)
|
||||
if tt.wantLines > 0 && result.Lines[0].Content != tt.wantContent {
|
||||
t.Errorf("expected first line %q, got %q", tt.wantContent, result.Lines[0].Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -103,13 +194,17 @@ func TestRunner_RunWithFailingCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Should not return error for non-zero exit, just empty output
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("expected 0 lines for exit 1, got %d", len(lines))
|
||||
if len(result.Lines) != 0 {
|
||||
t.Errorf("expected 0 lines for exit 1, got %d", len(result.Lines))
|
||||
}
|
||||
|
||||
if result.ExitCode != 1 {
|
||||
t.Errorf("expected exit code 1, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,17 +212,21 @@ func TestRunner_RunWithOutputAndError(t *testing.T) {
|
||||
r := NewRunner("sh", "echo 'output'; exit 1")
|
||||
ctx := context.Background()
|
||||
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 line, got %d", len(lines))
|
||||
if len(result.Lines) != 1 {
|
||||
t.Fatalf("expected 1 line, got %d", len(result.Lines))
|
||||
}
|
||||
|
||||
if lines[0].Content != "output" {
|
||||
t.Errorf("expected 'output', got %q", lines[0].Content)
|
||||
if result.Lines[0].Content != "output" {
|
||||
t.Errorf("expected 'output', got %q", result.Lines[0].Content)
|
||||
}
|
||||
|
||||
if result.ExitCode != 1 {
|
||||
t.Errorf("expected exit code 1, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,3 +320,61 @@ func TestSplitLines(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "hello world",
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "tabs converted to spaces",
|
||||
input: "col1\tcol2\tcol3",
|
||||
want: "col1 col2 col3",
|
||||
},
|
||||
{
|
||||
name: "carriage returns removed",
|
||||
input: "line with\r\nwindows ending",
|
||||
want: "line with\nwindows ending",
|
||||
},
|
||||
{
|
||||
name: "carriage return only removed",
|
||||
input: "progress\roverwrite",
|
||||
want: "progressoverwrite",
|
||||
},
|
||||
{
|
||||
name: "ANSI color codes preserved",
|
||||
input: "\x1b[32mgreen text\x1b[0m",
|
||||
want: "\x1b[32mgreen text\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "mixed tabs and colors",
|
||||
input: "\x1b[1m?\x1b[0m\tpackage\t[no test files]",
|
||||
want: "\x1b[1m?\x1b[0m package [no test files]",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "multiple tabs",
|
||||
input: "\t\t\t",
|
||||
want: " ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitizeLine(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeLine(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,20 +2,22 @@ package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewHeight: 40,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
RefreshSeconds: 5,
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
RefreshInterval: 5 * time.Second,
|
||||
}
|
||||
|
||||
if cfg.Command != "echo test" {
|
||||
@@ -26,8 +28,12 @@ func TestConfig(t *testing.T) {
|
||||
t.Errorf("expected shell 'sh', got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewHeight != 40 {
|
||||
t.Errorf("expected preview height 40, got %d", cfg.PreviewHeight)
|
||||
if cfg.PreviewSize != 40 {
|
||||
t.Errorf("expected preview size 40, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if !cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be true")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != PreviewBottom {
|
||||
@@ -46,8 +52,8 @@ func TestConfig(t *testing.T) {
|
||||
t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt)
|
||||
}
|
||||
|
||||
if cfg.RefreshSeconds != 5 {
|
||||
t.Errorf("expected refresh seconds 5, got %d", cfg.RefreshSeconds)
|
||||
if cfg.RefreshInterval != 5*time.Second {
|
||||
t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +87,12 @@ func TestConfigDefaults(t *testing.T) {
|
||||
t.Errorf("expected empty shell, got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewHeight != 0 {
|
||||
t.Errorf("expected preview height 0, got %d", cfg.PreviewHeight)
|
||||
if cfg.PreviewSize != 0 {
|
||||
t.Errorf("expected preview size 0, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be false")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != "" {
|
||||
@@ -104,13 +114,14 @@ func TestConfigDefaults(t *testing.T) {
|
||||
|
||||
func TestInitialModel(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewHeight: 40,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
@@ -232,29 +243,44 @@ func TestModelMoveCursor(t *testing.T) {
|
||||
|
||||
func TestVisibleLines(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewHeight: 40,
|
||||
PreviewPosition: PreviewBottom,
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
m.height = 100
|
||||
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
|
||||
// Without preview
|
||||
m.showPreview = false
|
||||
visible := m.visibleLines()
|
||||
expected := 100 - 3 // height - header
|
||||
expected := 100 - fixedLines
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines without preview, got %d", expected, visible)
|
||||
}
|
||||
|
||||
// With preview at bottom
|
||||
// With preview at bottom (percentage)
|
||||
m.showPreview = true
|
||||
visible = m.visibleLines()
|
||||
previewHeight := 100 * 40 / 100 // 40%
|
||||
expected = 100 - 3 - previewHeight
|
||||
// Add 1 for the separator between content and preview
|
||||
expected = 100 - fixedLines - previewHeight - 1
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines with preview, got %d", expected, visible)
|
||||
}
|
||||
|
||||
// With preview using absolute size
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
m.config.PreviewSize = 10
|
||||
visible = m.visibleLines()
|
||||
// Add 1 for the separator between content and preview
|
||||
expected = 100 - fixedLines - 10 - 1
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible)
|
||||
}
|
||||
}
|
||||
|
||||
122
main.go
122
main.go
@@ -4,8 +4,10 @@ import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/config"
|
||||
"github.com/chenasraf/watchr/internal/ui"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
@@ -15,45 +17,46 @@ var version string
|
||||
|
||||
func main() {
|
||||
var (
|
||||
showVersion bool
|
||||
showHelp bool
|
||||
previewHeight int
|
||||
previewPosition string
|
||||
noLineNumbers bool
|
||||
lineNumWidth int
|
||||
prompt string
|
||||
shell string
|
||||
refreshSeconds int
|
||||
showVersion bool
|
||||
showHelp bool
|
||||
showConfig bool
|
||||
configFile string
|
||||
)
|
||||
|
||||
// Define flags (defaults shown in help, but actual defaults come from config)
|
||||
flag.BoolVarP(&showVersion, "version", "v", false, "Show version")
|
||||
flag.BoolVarP(&showHelp, "help", "h", false, "Show help")
|
||||
flag.IntVarP(&previewHeight, "preview-height", "P", 40, "Preview window height/width percentage (1-100)")
|
||||
flag.StringVar(&previewPosition, "preview-position", "bottom", "Preview position: bottom, top, left, right")
|
||||
flag.BoolVarP(&noLineNumbers, "no-line-numbers", "n", false, "Disable line numbers")
|
||||
flag.IntVarP(&lineNumWidth, "line-width", "w", 6, "Line number width")
|
||||
flag.StringVarP(&prompt, "prompt", "p", "watchr> ", "Prompt string")
|
||||
flag.StringVarP(&shell, "shell", "s", "sh", "Shell to use for executing commands")
|
||||
flag.IntVarP(&refreshSeconds, "refresh", "r", 0, "Auto-refresh interval in seconds (0 = disabled)")
|
||||
flag.BoolVarP(&showConfig, "show-config", "C", false, "Show loaded configuration and exit")
|
||||
flag.StringVarP(&configFile, "config", "c", "", "Load config from specified path")
|
||||
flag.StringP("preview-size", "P", "40%", "Preview size: number for lines/cols, or number% for percentage (e.g., 10 or 40%)")
|
||||
flag.StringP("preview-position", "o", "bottom", "Preview position: bottom, top, left, right")
|
||||
flag.BoolP("no-line-numbers", "n", false, "Disable line numbers")
|
||||
flag.IntP("line-width", "w", 6, "Line number width")
|
||||
flag.StringP("prompt", "p", "watchr> ", "Prompt string")
|
||||
flag.StringP("shell", "s", "sh", "Shell to use for executing commands")
|
||||
flag.StringP("refresh", "r", "0", "Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)")
|
||||
flag.BoolP("interactive", "i", false, "Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)")
|
||||
|
||||
printUsage := func(w *os.File) {
|
||||
fmt.Fprintf(w, "Usage: watchr [options] <command to run>\n\n")
|
||||
fmt.Fprintf(w, "A terminal UI for running and watching command output.\n\n")
|
||||
fmt.Fprintf(w, "Options:\n")
|
||||
_, _ = fmt.Fprintf(w, "Usage: watchr [options] <command to run>\n\n")
|
||||
_, _ = fmt.Fprintf(w, "A terminal UI for running and watching command output.\n\n")
|
||||
_, _ = fmt.Fprintf(w, "Options:\n")
|
||||
flag.CommandLine.SetOutput(w)
|
||||
flag.PrintDefaults()
|
||||
flag.CommandLine.SetOutput(os.Stderr)
|
||||
fmt.Fprintf(w, "\nKeybindings:\n")
|
||||
fmt.Fprintf(w, " r, Ctrl-r Reload (re-run command)\n")
|
||||
fmt.Fprintf(w, " q, Esc Quit\n")
|
||||
fmt.Fprintf(w, " j, k Move down/up\n")
|
||||
fmt.Fprintf(w, " g Go to first line\n")
|
||||
fmt.Fprintf(w, " G Go to last line\n")
|
||||
fmt.Fprintf(w, " Ctrl-d/u Half page down/up\n")
|
||||
fmt.Fprintf(w, " PgDn/Up, ^f/b Full page down/up\n")
|
||||
fmt.Fprintf(w, " p Toggle preview\n")
|
||||
fmt.Fprintf(w, " / Enter filter mode\n")
|
||||
fmt.Fprintf(w, " Esc Exit filter mode / clear filter\n")
|
||||
_, _ = fmt.Fprintf(w, "\nKeybindings:\n")
|
||||
_, _ = fmt.Fprintf(w, " r, Ctrl-r Reload (re-run command)\n")
|
||||
_, _ = fmt.Fprintf(w, " q, Esc Quit\n")
|
||||
_, _ = fmt.Fprintf(w, " j, k Move down/up\n")
|
||||
_, _ = fmt.Fprintf(w, " g Go to first line\n")
|
||||
_, _ = fmt.Fprintf(w, " G Go to last line\n")
|
||||
_, _ = fmt.Fprintf(w, " Ctrl-d/u Half page down/up\n")
|
||||
_, _ = fmt.Fprintf(w, " PgDn/Up, ^f/b Full page down/up\n")
|
||||
_, _ = fmt.Fprintf(w, " p Toggle preview\n")
|
||||
_, _ = fmt.Fprintf(w, " / Enter filter mode\n")
|
||||
_, _ = fmt.Fprintf(w, " Esc Exit filter mode / clear filter\n")
|
||||
_, _ = fmt.Fprintf(w, " y Yank (copy) selected line\n")
|
||||
_, _ = fmt.Fprintf(w, " ? Show help overlay\n")
|
||||
}
|
||||
|
||||
flag.Usage = func() {
|
||||
@@ -62,6 +65,19 @@ func main() {
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Initialize config (loads config files and sets defaults)
|
||||
if configFile != "" {
|
||||
if err := config.InitWithFile(configFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
config.Init()
|
||||
}
|
||||
|
||||
// Bind flags to config (CLI flags override config file values)
|
||||
config.BindFlags(flag.CommandLine)
|
||||
|
||||
if showHelp {
|
||||
printUsage(os.Stdout)
|
||||
os.Exit(0)
|
||||
@@ -72,6 +88,11 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if showConfig {
|
||||
config.PrintConfig()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error: No command provided")
|
||||
@@ -81,18 +102,39 @@ func main() {
|
||||
|
||||
cmdStr := strings.Join(args, " ")
|
||||
|
||||
config := ui.Config{
|
||||
Command: cmdStr,
|
||||
Shell: shell,
|
||||
PreviewHeight: previewHeight,
|
||||
PreviewPosition: ui.PreviewPosition(previewPosition),
|
||||
ShowLineNums: !noLineNumbers,
|
||||
LineNumWidth: lineNumWidth,
|
||||
Prompt: prompt,
|
||||
RefreshSeconds: refreshSeconds,
|
||||
// Get config values (merged from: defaults < config file < CLI flags)
|
||||
previewSize := config.GetString(config.KeyPreviewSize)
|
||||
previewPosition := config.GetString(config.KeyPreviewPosition)
|
||||
shell := config.GetString(config.KeyShell)
|
||||
lineNumWidth := config.GetInt(config.KeyLineWidth)
|
||||
prompt := config.GetString(config.KeyPrompt)
|
||||
refreshInterval := config.GetDuration(config.KeyRefresh)
|
||||
showLineNums := config.ShowLineNumbers()
|
||||
interactive := config.GetBool(config.KeyInteractive)
|
||||
|
||||
// Parse preview size (e.g., "40" for lines/cols, "40%" for percentage)
|
||||
previewSizeIsPercent := strings.HasSuffix(previewSize, "%")
|
||||
previewSizeStr := strings.TrimSuffix(previewSize, "%")
|
||||
previewSizeVal, err := strconv.Atoi(previewSizeStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: Invalid preview size: %s\n", previewSize)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := ui.Run(config); err != nil {
|
||||
uiConfig := ui.Config{
|
||||
Command: cmdStr,
|
||||
Shell: shell,
|
||||
PreviewSize: previewSizeVal,
|
||||
PreviewSizeIsPercent: previewSizeIsPercent,
|
||||
PreviewPosition: ui.PreviewPosition(previewPosition),
|
||||
ShowLineNums: showLineNums,
|
||||
LineNumWidth: lineNumWidth,
|
||||
Prompt: prompt,
|
||||
RefreshInterval: refreshInterval,
|
||||
Interactive: interactive,
|
||||
}
|
||||
|
||||
if err := ui.Run(uiConfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.1
|
||||
1.5.2
|
||||
|
||||
Reference in New Issue
Block a user