mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-18 01:29:05 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c46bfa7c | ||
| 5e1b807105 | |||
| 51b9e92ddd | |||
| 320874a61a | |||
| cdd895d19a | |||
|
|
b76ae3bd7e | ||
| 2619708244 | |||
| 2d76e43519 | |||
| ab94ceb228 | |||
| 6c677a0d29 | |||
| c9affe8375 | |||
|
|
5528f090d8 | ||
| 0727defa98 | |||
| 9353705f52 | |||
| c2b0bc51d0 | |||
|
|
2c86fd042b | ||
| 4d614f57dc |
4
.editorconfig
Normal file
4
.editorconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* write help to correct output ([4d614f5](https://github.com/chenasraf/watchr/commit/4d614f57dcc1c5720caa27873d6da98a0f365cf3))
|
||||
|
||||
## 1.0.0 (2025-12-03)
|
||||
|
||||
|
||||
|
||||
15
Makefile
15
Makefile
@@ -1,12 +1,14 @@
|
||||
BIN := $(notdir $(CURDIR))
|
||||
|
||||
all: run
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
go build
|
||||
go build -o $(BIN)
|
||||
|
||||
.PHONY: run
|
||||
run: build
|
||||
./watchr
|
||||
./$(BIN)
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@@ -14,11 +16,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 +39,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 ./...; \
|
||||
|
||||
83
README.md
83
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
|
||||
|
||||
---
|
||||
@@ -85,19 +86,81 @@ 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 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-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")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 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
|
||||
```
|
||||
|
||||
**TOML** (`watchr.toml`):
|
||||
```toml
|
||||
shell = "bash"
|
||||
preview-size = "50%"
|
||||
preview-position = "right"
|
||||
line-numbers = true
|
||||
line-width = 4
|
||||
prompt = "> "
|
||||
refresh = 0
|
||||
```
|
||||
|
||||
**JSON** (`watchr.json`):
|
||||
```json
|
||||
{
|
||||
"shell": "bash",
|
||||
"preview-size": "50%",
|
||||
"preview-position": "right",
|
||||
"line-numbers": true,
|
||||
"line-width": 4,
|
||||
"prompt": "> ",
|
||||
"refresh": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 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 +176,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=
|
||||
|
||||
147
internal/config/config.go
Normal file
147
internal/config/config.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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"))
|
||||
|
||||
// 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 %d\n", KeyRefresh+":", GetInt(KeyRefresh))
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
}
|
||||
436
internal/config/config_test.go
Normal file
436
internal/config/config_test.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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.GetInt(KeyRefresh); got != 0 {
|
||||
t.Errorf("expected default refresh 0, got %d", 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.Int("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 := GetInt(KeyRefresh); got != 5 {
|
||||
t.Errorf("expected refresh 5 from config file, got %d", 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.Int("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 := GetInt(KeyRefresh); got != 10 {
|
||||
t.Errorf("expected refresh 10, got %d", 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)
|
||||
}
|
||||
}
|
||||
@@ -38,22 +38,28 @@ func NewRunner(shell, command string) *Runner {
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the command and returns output lines
|
||||
func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
// 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) {
|
||||
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
|
||||
|
||||
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
|
||||
@@ -79,10 +85,15 @@ func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
lineNum++
|
||||
}
|
||||
|
||||
// Wait for command to finish (ignore exit code - we still want to show output)
|
||||
_ = cmd.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()
|
||||
}
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
return Result{Lines: lines, ExitCode: exitCode}, nil
|
||||
}
|
||||
|
||||
// RunStreaming executes the command and streams output lines to the callback
|
||||
|
||||
@@ -52,17 +52,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 +103,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 +121,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package ui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,14 +25,15 @@ const (
|
||||
|
||||
// Config holds the UI configuration
|
||||
type Config struct {
|
||||
Command string
|
||||
Shell string
|
||||
PreviewHeight int
|
||||
PreviewPosition PreviewPosition
|
||||
ShowLineNums bool
|
||||
LineNumWidth int
|
||||
Prompt string
|
||||
RefreshSeconds int
|
||||
Command string
|
||||
Shell string
|
||||
PreviewSize int
|
||||
PreviewSizeIsPercent bool
|
||||
PreviewPosition PreviewPosition
|
||||
ShowLineNums bool
|
||||
LineNumWidth int
|
||||
Prompt string
|
||||
RefreshSeconds int
|
||||
}
|
||||
|
||||
// model represents the application state
|
||||
@@ -43,6 +46,7 @@ type model struct {
|
||||
filter string
|
||||
filterMode bool
|
||||
showPreview bool
|
||||
showHelp bool // help overlay visible
|
||||
width int
|
||||
height int
|
||||
runner *runner.Runner
|
||||
@@ -50,15 +54,45 @@ type model struct {
|
||||
cancel context.CancelFunc
|
||||
loading bool
|
||||
errorMsg string
|
||||
statusMsg string // temporary status message (e.g., "Yanked!")
|
||||
exitCode int // last command exit code
|
||||
}
|
||||
|
||||
// messages
|
||||
type linesMsg []runner.Line
|
||||
type resultMsg struct {
|
||||
lines []runner.Line
|
||||
exitCode int
|
||||
}
|
||||
type errMsg struct{ err error }
|
||||
type tickMsg time.Time
|
||||
type clearStatusMsg struct{}
|
||||
|
||||
func (e errMsg) Error() string { return e.err.Error() }
|
||||
|
||||
// copyToClipboard copies text to the system clipboard using OS-specific commands
|
||||
func copyToClipboard(text string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("pbcopy")
|
||||
case "linux":
|
||||
// Try xclip first, fall back to xsel
|
||||
if _, err := exec.LookPath("xclip"); err == nil {
|
||||
cmd = exec.Command("xclip", "-selection", "clipboard")
|
||||
} else {
|
||||
cmd = exec.Command("xsel", "--clipboard", "--input")
|
||||
}
|
||||
case "windows":
|
||||
cmd = exec.Command("clip")
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func initialModel(cfg Config) model {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return model{
|
||||
@@ -85,11 +119,11 @@ func (m model) runCommand() tea.Cmd {
|
||||
r := m.runner
|
||||
ctx := m.ctx
|
||||
return func() tea.Msg {
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return linesMsg(lines)
|
||||
return resultMsg{lines: result.Lines, exitCode: result.ExitCode}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +137,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
|
||||
case linesMsg:
|
||||
m.lines = []runner.Line(msg)
|
||||
case resultMsg:
|
||||
m.lines = msg.lines
|
||||
m.exitCode = msg.exitCode
|
||||
m.loading = false
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
@@ -122,6 +157,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.errorMsg = msg.Error()
|
||||
m.loading = false
|
||||
return m, nil
|
||||
|
||||
case clearStatusMsg:
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -134,6 +173,15 @@ func (m model) tickCmd() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// In help mode, any key closes it
|
||||
if m.showHelp {
|
||||
switch msg.String() {
|
||||
case "?", "esc", "q", "enter":
|
||||
m.showHelp = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// In filter mode, handle text input
|
||||
if m.filterMode {
|
||||
switch msg.Type {
|
||||
@@ -195,6 +243,24 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "/":
|
||||
m.filterMode = true
|
||||
m.filter = ""
|
||||
case "?":
|
||||
m.showHelp = true
|
||||
case "y":
|
||||
// Yank (copy) selected line to clipboard
|
||||
if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) {
|
||||
idx := m.filtered[m.cursor]
|
||||
if idx < len(m.lines) {
|
||||
content := m.lines[idx].Content
|
||||
if err := copyToClipboard(content); err != nil {
|
||||
m.statusMsg = "Failed to copy"
|
||||
} else {
|
||||
m.statusMsg = "Copied to clipboard"
|
||||
}
|
||||
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||
return clearStatusMsg{}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -238,16 +304,91 @@ func (m *model) adjustOffset() {
|
||||
m.offset = idealOffset
|
||||
}
|
||||
|
||||
func (m model) previewSize() int {
|
||||
if m.config.PreviewSizeIsPercent {
|
||||
if m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight {
|
||||
return m.width * m.config.PreviewSize / 100
|
||||
}
|
||||
return m.height * m.config.PreviewSize / 100
|
||||
}
|
||||
return m.config.PreviewSize
|
||||
}
|
||||
|
||||
func (m model) visibleLines() int {
|
||||
// header (1) + command (1) + prompt at bottom (1) = 3 fixed lines
|
||||
fixedLines := 3
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
if m.showPreview && (m.config.PreviewPosition == PreviewTop || m.config.PreviewPosition == PreviewBottom) {
|
||||
previewHeight := m.height * m.config.PreviewHeight / 100
|
||||
return m.height - fixedLines - previewHeight
|
||||
// Add preview height + separator between content and preview
|
||||
return m.height - fixedLines - m.previewSize() - 1
|
||||
}
|
||||
return m.height - fixedLines
|
||||
}
|
||||
|
||||
const ellipsis = "…"
|
||||
|
||||
// truncateToWidth truncates a string to fit within the given visual width,
|
||||
// adding an ellipsis if truncation occurs. Uses visual width, not byte count.
|
||||
func truncateToWidth(s string, maxWidth int) string {
|
||||
if maxWidth <= 0 {
|
||||
return ""
|
||||
}
|
||||
sw := lipgloss.Width(s)
|
||||
if sw <= maxWidth {
|
||||
return s
|
||||
}
|
||||
// Need to truncate - leave room for ellipsis (1 char wide)
|
||||
targetWidth := maxWidth - 1
|
||||
if targetWidth <= 0 {
|
||||
return ellipsis
|
||||
}
|
||||
|
||||
// Truncate rune by rune until we fit
|
||||
result := ""
|
||||
currentWidth := 0
|
||||
for _, r := range s {
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if currentWidth+runeWidth > targetWidth {
|
||||
break
|
||||
}
|
||||
result += string(r)
|
||||
currentWidth += runeWidth
|
||||
}
|
||||
return result + ellipsis
|
||||
}
|
||||
|
||||
// wrapText wraps text to fit within the given width, returning multiple lines.
|
||||
func wrapText(s string, width int) []string {
|
||||
if width <= 0 {
|
||||
return nil
|
||||
}
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
currentLine := ""
|
||||
currentWidth := 0
|
||||
|
||||
for _, r := range s {
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if currentWidth+runeWidth > width {
|
||||
// Start new line
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = string(r)
|
||||
currentWidth = runeWidth
|
||||
} else {
|
||||
currentLine += string(r)
|
||||
currentWidth += runeWidth
|
||||
}
|
||||
}
|
||||
// Don't forget the last line
|
||||
if currentLine != "" {
|
||||
lines = append(lines, currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m *model) updateFiltered() {
|
||||
m.filtered = []int{}
|
||||
|
||||
@@ -268,40 +409,349 @@ func (m *model) updateFiltered() {
|
||||
m.offset = 0
|
||||
}
|
||||
|
||||
// renderHelpOverlay creates the help box content (without positioning)
|
||||
func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) {
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("10")) // green
|
||||
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")) // light gray
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("12")) // blue
|
||||
|
||||
// Define keybindings
|
||||
bindings := []struct {
|
||||
key string
|
||||
desc string
|
||||
}{
|
||||
{"j / k", "Move down / up"},
|
||||
{"g / G", "Go to first / last line"},
|
||||
{"Ctrl+d / Ctrl+u", "Half page down / up"},
|
||||
{"PgDn / PgUp", "Full page down / up"},
|
||||
{"Ctrl+f / Ctrl+b", "Full page down / up"},
|
||||
{"", ""},
|
||||
{"p", "Toggle preview pane"},
|
||||
{"/", "Enter filter mode"},
|
||||
{"Esc", "Exit filter / clear"},
|
||||
{"", ""},
|
||||
{"r / Ctrl+r", "Reload command"},
|
||||
{"y", "Copy line to clipboard"},
|
||||
{"q / Esc", "Quit"},
|
||||
{"?", "Toggle this help"},
|
||||
}
|
||||
|
||||
// Build content
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("Keybindings"))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
for _, b := range bindings {
|
||||
if b.key == "" {
|
||||
content.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
key := keyStyle.Render(fmt.Sprintf("%-18s", b.key))
|
||||
desc := descStyle.Render(b.desc)
|
||||
content.WriteString(fmt.Sprintf(" %s %s\n", key, desc))
|
||||
}
|
||||
|
||||
content.WriteString("\n")
|
||||
content.WriteString(descStyle.Render("Press any key to close"))
|
||||
|
||||
// Create box style
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("12")).
|
||||
Padding(1, 2)
|
||||
|
||||
box = boxStyle.Render(content.String())
|
||||
boxWidth = lipgloss.Width(box)
|
||||
boxHeight = lipgloss.Height(box)
|
||||
|
||||
return box, boxWidth, boxHeight
|
||||
}
|
||||
|
||||
// splitAtVisualWidth splits a string at a visual width position, handling ANSI codes
|
||||
// Returns (left part, right part) where left has exactly targetWidth visual width
|
||||
func splitAtVisualWidth(s string, targetWidth int) (string, string) {
|
||||
var left, right strings.Builder
|
||||
visualWidth := 0
|
||||
inEscape := false
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Build left part up to targetWidth
|
||||
for i < len(runes) && visualWidth < targetWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// Start of ANSI escape sequence - include it in left part
|
||||
left.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
left.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
left.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if visualWidth+runeWidth <= targetWidth {
|
||||
left.WriteRune(r)
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pad left if needed
|
||||
for visualWidth < targetWidth {
|
||||
left.WriteRune(' ')
|
||||
visualWidth++
|
||||
}
|
||||
|
||||
// Skip runes in the "overlay zone" - we don't need them for right part calculation
|
||||
// The caller will handle inserting the overlay content
|
||||
|
||||
// Build right part from remaining
|
||||
for ; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
if r == '\x1b' {
|
||||
right.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
right.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
right.WriteRune(runes[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
right.WriteRune(r)
|
||||
}
|
||||
|
||||
_ = inEscape // unused but kept for clarity
|
||||
return left.String(), right.String()
|
||||
}
|
||||
|
||||
// skipVisualWidth skips a number of visual width units in a string, handling ANSI codes
|
||||
// It preserves and returns ANSI sequences encountered during skipping so styling can be restored
|
||||
func skipVisualWidth(s string, skipWidth int) string {
|
||||
var result strings.Builder
|
||||
var ansiState strings.Builder // collect ANSI codes while skipping
|
||||
visualWidth := 0
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Skip until we've passed skipWidth, but collect ANSI codes
|
||||
for i < len(runes) && visualWidth < skipWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// ANSI escape - collect it (don't count visual width)
|
||||
ansiState.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
ansiState.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
ansiState.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
}
|
||||
|
||||
// Prepend collected ANSI state to restore styling
|
||||
result.WriteString(ansiState.String())
|
||||
|
||||
// Output the rest
|
||||
for ; i < len(runes); i++ {
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func isAnsiTerminator(r rune) bool {
|
||||
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
|
||||
}
|
||||
|
||||
// overlayBox composites an overlay box on top of a base view
|
||||
func overlayBox(base string, box string, boxWidth, boxHeight, screenWidth, screenHeight int) string {
|
||||
// ANSI reset sequence to stop any styling from bleeding into overlay
|
||||
const ansiReset = "\x1b[0m"
|
||||
|
||||
// Split base into lines
|
||||
baseLines := strings.Split(base, "\n")
|
||||
|
||||
// Ensure we have enough lines
|
||||
for len(baseLines) < screenHeight {
|
||||
baseLines = append(baseLines, "")
|
||||
}
|
||||
|
||||
// Split box into lines
|
||||
boxLines := strings.Split(box, "\n")
|
||||
|
||||
// Calculate center position
|
||||
startX := (screenWidth - boxWidth) / 2
|
||||
startY := (screenHeight - boxHeight) / 2
|
||||
|
||||
if startX < 0 {
|
||||
startX = 0
|
||||
}
|
||||
if startY < 0 {
|
||||
startY = 0
|
||||
}
|
||||
|
||||
// Overlay box onto base
|
||||
for i, boxLine := range boxLines {
|
||||
y := startY + i
|
||||
if y >= len(baseLines) {
|
||||
break
|
||||
}
|
||||
|
||||
baseLine := baseLines[y]
|
||||
baseVisualWidth := lipgloss.Width(baseLine)
|
||||
|
||||
// Get left part (before overlay)
|
||||
leftPart, _ := splitAtVisualWidth(baseLine, startX)
|
||||
|
||||
// Get right part (after overlay)
|
||||
endX := startX + boxWidth
|
||||
var rightPart string
|
||||
if endX < baseVisualWidth {
|
||||
rightPart = skipVisualWidth(baseLine, endX)
|
||||
}
|
||||
|
||||
// Combine: left + reset + box + right
|
||||
// Reset before overlay to stop highlight bleeding into overlay
|
||||
baseLines[y] = leftPart + ansiReset + boxLine + rightPart
|
||||
}
|
||||
|
||||
return strings.Join(baseLines, "\n")
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
// Styles
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("12"))
|
||||
// Render the main UI
|
||||
mainView := m.renderMainView()
|
||||
|
||||
// Overlay help if active
|
||||
if m.showHelp {
|
||||
box, boxWidth, boxHeight := m.renderHelpOverlay()
|
||||
return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height)
|
||||
}
|
||||
|
||||
return mainView
|
||||
}
|
||||
|
||||
func (m model) renderMainView() string {
|
||||
// Box drawing characters (rounded)
|
||||
const (
|
||||
topLeft = "╭"
|
||||
topRight = "╮"
|
||||
bottomLeft = "╰"
|
||||
bottomRight = "╯"
|
||||
horizontal = "─"
|
||||
vertical = "│"
|
||||
leftT = "├"
|
||||
rightT = "┤"
|
||||
topT = "┬"
|
||||
bottomT = "┴"
|
||||
)
|
||||
|
||||
borderColor := lipgloss.Color("240")
|
||||
borderStyle := lipgloss.NewStyle().Foreground(borderColor)
|
||||
|
||||
// Styles
|
||||
promptStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("14"))
|
||||
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
|
||||
lineNumStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
|
||||
previewStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
Padding(0, 1)
|
||||
|
||||
filterStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("11"))
|
||||
|
||||
// Build header (2 lines at top)
|
||||
var headerLines []string
|
||||
header := "r reload • q quit • j/k move • g/G first/last • ^d/u/f/b scroll • p preview • / filter"
|
||||
headerLines = append(headerLines, headerStyle.Render(header))
|
||||
headerLines = append(headerLines, fmt.Sprintf("Command: %s", m.config.Command))
|
||||
// Inner width (excluding border characters)
|
||||
innerWidth := m.width - 2
|
||||
|
||||
// Helper to create a horizontal line (optionally with a T-junction for vertical split)
|
||||
hLine := func(left, right string, splitPos int) string {
|
||||
if splitPos > 0 && splitPos < innerWidth {
|
||||
return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + topT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right)
|
||||
}
|
||||
return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right)
|
||||
}
|
||||
|
||||
// Helper for header separator line with T junction pointing down (for vertical split below)
|
||||
hLineMid := func(left, right string, splitPos int) string {
|
||||
if splitPos > 0 && splitPos < innerWidth {
|
||||
return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + topT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right)
|
||||
}
|
||||
return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right)
|
||||
}
|
||||
|
||||
// Helper for bottom line with vertical split
|
||||
hLineBottom := func(left, right string, splitPos int) string {
|
||||
if splitPos > 0 && splitPos < innerWidth {
|
||||
return borderStyle.Render(left + strings.Repeat(horizontal, splitPos) + bottomT + strings.Repeat(horizontal, innerWidth-splitPos-1) + right)
|
||||
}
|
||||
return borderStyle.Render(left + strings.Repeat(horizontal, innerWidth) + right)
|
||||
}
|
||||
|
||||
// Helper to pad content to inner width
|
||||
padLine := func(content string) string {
|
||||
contentWidth := lipgloss.Width(content)
|
||||
if contentWidth < innerWidth {
|
||||
content += strings.Repeat(" ", innerWidth-contentWidth)
|
||||
} else if contentWidth > innerWidth {
|
||||
// Use lipgloss style with MaxWidth for ANSI-safe truncation
|
||||
content = lipgloss.NewStyle().MaxWidth(innerWidth-1).Render(content) + ellipsis
|
||||
}
|
||||
return borderStyle.Render(vertical) + content + borderStyle.Render(vertical)
|
||||
}
|
||||
|
||||
// Build header content with status indicator
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) // blue
|
||||
prefix := titleStyle.Render("watchr") + " • "
|
||||
|
||||
var commandLine string
|
||||
if m.loading {
|
||||
// Still loading - no status yet
|
||||
commandLine = prefix + m.config.Command
|
||||
} else if m.exitCode == 0 {
|
||||
// Success - green checkmark and green command
|
||||
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
|
||||
commandLine = prefix + successStyle.Render("✓ "+m.config.Command)
|
||||
} else {
|
||||
// Failure - red cross with exit code and red command
|
||||
failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red
|
||||
commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command))
|
||||
}
|
||||
|
||||
// Build prompt line (will go at bottom)
|
||||
var promptLine string
|
||||
@@ -315,13 +765,29 @@ func (m model) View() string {
|
||||
if m.loading {
|
||||
promptLine += " [loading...]"
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
|
||||
promptLine += " " + statusStyle.Render(m.statusMsg)
|
||||
}
|
||||
|
||||
// Add help hint on the right
|
||||
helpHint := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("? for help")
|
||||
promptWidth := lipgloss.Width(promptLine)
|
||||
hintWidth := lipgloss.Width(helpHint)
|
||||
gap := m.width - promptWidth - hintWidth
|
||||
if gap > 0 {
|
||||
promptLine += strings.Repeat(" ", gap) + helpHint
|
||||
}
|
||||
|
||||
// Calculate layout
|
||||
listHeight := m.visibleLines()
|
||||
listWidth := m.width
|
||||
// listWidth is content area minus 1 for padding before border
|
||||
listWidth := innerWidth - 1
|
||||
|
||||
if m.showPreview && (m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight) {
|
||||
listWidth = m.width / 2
|
||||
// For horizontal split: innerWidth = leftW + 1 (middle border) + rightW
|
||||
// List gets the non-preview side width, minus 1 for padding
|
||||
listWidth = innerWidth - m.previewSize() - 2
|
||||
}
|
||||
|
||||
// Build lines view
|
||||
@@ -341,24 +807,54 @@ func (m model) View() string {
|
||||
}
|
||||
line := m.lines[idx]
|
||||
|
||||
lineText := line.Content
|
||||
var lineText string
|
||||
isSelected := lineIdx == m.cursor
|
||||
|
||||
// Full width including the padding space before border
|
||||
fullWidth := listWidth + 1
|
||||
|
||||
if m.config.ShowLineNums {
|
||||
lineNum := lineNumStyle.Render(fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number))
|
||||
lineText = lineNum + line.Content
|
||||
}
|
||||
// Calculate widths without ANSI codes first
|
||||
lineNumStr := fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number)
|
||||
lineNumWidth := len(lineNumStr) // Plain ASCII, so len() == visual width
|
||||
contentWidth := listWidth - lineNumWidth
|
||||
|
||||
// Truncate if too long
|
||||
if len(lineText) > listWidth-2 && listWidth > 5 {
|
||||
lineText = lineText[:listWidth-5] + "..."
|
||||
}
|
||||
// Truncate content (no ANSI codes yet)
|
||||
content := truncateToWidth(line.Content, contentWidth)
|
||||
|
||||
if lineIdx == m.cursor {
|
||||
// Pad to full width for selection highlight
|
||||
padding := listWidth - lipgloss.Width(lineText)
|
||||
if padding > 0 {
|
||||
lineText = lineText + strings.Repeat(" ", padding)
|
||||
if isSelected {
|
||||
// For selected line: gray line number + black content, both on white background
|
||||
selectedLineNumStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("241"))
|
||||
selectedContentStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
|
||||
// Pad content to fill remaining width
|
||||
contentPadded := content
|
||||
padding := fullWidth - lineNumWidth - lipgloss.Width(content)
|
||||
if padding > 0 {
|
||||
contentPadded = content + strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedLineNumStyle.Render(lineNumStr) + selectedContentStyle.Render(contentPadded)
|
||||
} else {
|
||||
// Normal line - style line numbers differently
|
||||
lineText = lineNumStyle.Render(lineNumStr) + content
|
||||
}
|
||||
} else {
|
||||
// No line numbers, just truncate content
|
||||
lineText = truncateToWidth(line.Content, listWidth)
|
||||
|
||||
if isSelected {
|
||||
// Pad to full width for selection highlight
|
||||
padding := fullWidth - lipgloss.Width(lineText)
|
||||
if padding > 0 {
|
||||
lineText = lineText + strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedStyle.Render(lineText)
|
||||
}
|
||||
lineText = selectedStyle.Render(lineText)
|
||||
}
|
||||
|
||||
listLines = append(listLines, lineText)
|
||||
@@ -373,45 +869,167 @@ func (m model) View() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Compose final view
|
||||
var contentSection string
|
||||
|
||||
if !m.showPreview {
|
||||
contentSection = strings.Join(listLines, "\n")
|
||||
} else {
|
||||
previewH := m.height * m.config.PreviewHeight / 100
|
||||
previewW := m.width - 4
|
||||
|
||||
if m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight {
|
||||
previewW = m.width/2 - 4
|
||||
}
|
||||
|
||||
styledPreview := previewStyle.
|
||||
Width(previewW).
|
||||
Height(previewH - 2).
|
||||
Render(previewContent)
|
||||
|
||||
listContent := strings.Join(listLines, "\n")
|
||||
|
||||
switch m.config.PreviewPosition {
|
||||
case PreviewTop:
|
||||
contentSection = styledPreview + "\n" + listContent
|
||||
case PreviewBottom:
|
||||
contentSection = listContent + "\n" + styledPreview
|
||||
case PreviewLeft:
|
||||
contentSection = lipgloss.JoinHorizontal(lipgloss.Top, styledPreview, " ", listContent)
|
||||
case PreviewRight:
|
||||
contentSection = lipgloss.JoinHorizontal(lipgloss.Top, listContent, " ", styledPreview)
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
if m.errorMsg != "" {
|
||||
contentSection += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("Error: "+m.errorMsg)
|
||||
listLines = append(listLines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("Error: "+m.errorMsg))
|
||||
}
|
||||
|
||||
// Combine: header at top, content in middle, prompt at bottom
|
||||
fullView := strings.Join(headerLines, "\n") + "\n" + contentSection + "\n" + promptLine
|
||||
// Calculate vertical split position for left/right preview
|
||||
// This must match where the middle vertical bar falls in content lines
|
||||
var vSplitPos int
|
||||
if m.showPreview {
|
||||
switch m.config.PreviewPosition {
|
||||
case PreviewLeft:
|
||||
vSplitPos = m.previewSize()
|
||||
case PreviewRight:
|
||||
// leftW = innerWidth - previewSize - 1, so split is at leftW
|
||||
vSplitPos = innerWidth - m.previewSize() - 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build the unified box
|
||||
var lines []string
|
||||
|
||||
// Top border (no junction - vertical split starts at header separator)
|
||||
lines = append(lines, hLine(topLeft, topRight, 0))
|
||||
|
||||
// Header line (command only)
|
||||
lines = append(lines, padLine(commandLine))
|
||||
|
||||
// Separator between header and content (T junction if vertical split)
|
||||
lines = append(lines, hLineMid(leftT, rightT, vSplitPos))
|
||||
|
||||
// Content area (with optional preview)
|
||||
if !m.showPreview {
|
||||
// Just content lines, padded to fill height
|
||||
for i := 0; i < listHeight; i++ {
|
||||
if i < len(listLines) {
|
||||
lines = append(lines, padLine(listLines[i]))
|
||||
} else {
|
||||
lines = append(lines, padLine(""))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
previewH := m.previewSize()
|
||||
|
||||
switch m.config.PreviewPosition {
|
||||
case PreviewTop, PreviewBottom:
|
||||
// Vertical split - preview above or below content
|
||||
var previewLines []string
|
||||
// Wrap preview content to fit width
|
||||
if previewContent != "" {
|
||||
previewLines = wrapText(previewContent, innerWidth)
|
||||
}
|
||||
// Pad preview to height
|
||||
for len(previewLines) < previewH {
|
||||
previewLines = append(previewLines, "")
|
||||
}
|
||||
|
||||
if m.config.PreviewPosition == PreviewTop {
|
||||
// Preview first
|
||||
for _, line := range previewLines[:previewH] {
|
||||
lines = append(lines, padLine(line))
|
||||
}
|
||||
// Separator (no vertical split for top/bottom preview)
|
||||
lines = append(lines, hLine(leftT, rightT, 0))
|
||||
// Then content, padded to fill height
|
||||
for i := 0; i < listHeight; i++ {
|
||||
if i < len(listLines) {
|
||||
lines = append(lines, padLine(listLines[i]))
|
||||
} else {
|
||||
lines = append(lines, padLine(""))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Content first, padded to fill height
|
||||
for i := 0; i < listHeight; i++ {
|
||||
if i < len(listLines) {
|
||||
lines = append(lines, padLine(listLines[i]))
|
||||
} else {
|
||||
lines = append(lines, padLine(""))
|
||||
}
|
||||
}
|
||||
// Separator (no vertical split for top/bottom preview)
|
||||
lines = append(lines, hLine(leftT, rightT, 0))
|
||||
// Then preview
|
||||
for _, line := range previewLines[:previewH] {
|
||||
lines = append(lines, padLine(line))
|
||||
}
|
||||
}
|
||||
|
||||
case PreviewLeft, PreviewRight:
|
||||
// Horizontal split: |leftContent|rightContent|
|
||||
// innerWidth = leftW + 1 (middle border) + rightW
|
||||
var leftW, rightW int
|
||||
if m.config.PreviewPosition == PreviewLeft {
|
||||
leftW = m.previewSize()
|
||||
rightW = innerWidth - leftW - 1
|
||||
} else {
|
||||
rightW = m.previewSize()
|
||||
leftW = innerWidth - rightW - 1
|
||||
}
|
||||
|
||||
// Prepare preview lines (wrap text instead of truncating)
|
||||
var previewLines []string
|
||||
if previewContent != "" {
|
||||
// Determine preview width for wrapping
|
||||
previewW := leftW
|
||||
if m.config.PreviewPosition == PreviewRight {
|
||||
previewW = rightW
|
||||
}
|
||||
previewLines = wrapText(previewContent, previewW)
|
||||
}
|
||||
for len(previewLines) < listHeight {
|
||||
previewLines = append(previewLines, "")
|
||||
}
|
||||
|
||||
// Helper to truncate/pad to width
|
||||
fitToWidth := func(s string, w int, isPreview bool) string {
|
||||
sw := lipgloss.Width(s)
|
||||
if sw > w {
|
||||
if isPreview {
|
||||
// Preview is already wrapped, just pad
|
||||
return s + strings.Repeat(" ", w-sw)
|
||||
}
|
||||
// List content may have ANSI codes, use lipgloss for safe truncation
|
||||
return lipgloss.NewStyle().MaxWidth(w-1).Render(s) + ellipsis
|
||||
}
|
||||
return s + strings.Repeat(" ", w-sw)
|
||||
}
|
||||
|
||||
// Build combined lines
|
||||
for i := 0; i < listHeight; i++ {
|
||||
var leftContent, rightContent string
|
||||
var leftIsPreview, rightIsPreview bool
|
||||
|
||||
if m.config.PreviewPosition == PreviewLeft {
|
||||
leftContent = previewLines[i]
|
||||
leftIsPreview = true
|
||||
if i < len(listLines) {
|
||||
rightContent = listLines[i]
|
||||
}
|
||||
} else {
|
||||
if i < len(listLines) {
|
||||
leftContent = listLines[i]
|
||||
}
|
||||
rightContent = previewLines[i]
|
||||
rightIsPreview = true
|
||||
}
|
||||
|
||||
leftContent = fitToWidth(leftContent, leftW, leftIsPreview)
|
||||
rightContent = fitToWidth(rightContent, rightW, rightIsPreview)
|
||||
|
||||
line := borderStyle.Render(vertical) + leftContent + borderStyle.Render(vertical) + rightContent + borderStyle.Render(vertical)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
lines = append(lines, hLineBottom(bottomLeft, bottomRight, vSplitPos))
|
||||
|
||||
// Combine box with prompt
|
||||
fullView := strings.Join(lines, "\n") + "\n" + promptLine
|
||||
|
||||
return fullView
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
|
||||
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> ",
|
||||
RefreshSeconds: 5,
|
||||
}
|
||||
|
||||
if cfg.Command != "echo test" {
|
||||
@@ -26,8 +27,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 {
|
||||
@@ -81,8 +86,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 +113,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 +242,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)
|
||||
}
|
||||
}
|
||||
|
||||
129
main.go
129
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,49 +17,68 @@ 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.IntP("refresh", "r", 0, "Auto-refresh interval in seconds (0 = disabled)")
|
||||
|
||||
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")
|
||||
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, " y Yank (copy) selected line\n")
|
||||
_, _ = fmt.Fprintf(w, " ? Show help overlay\n")
|
||||
}
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: watchr [options] <command to run>\n\n")
|
||||
fmt.Fprintf(os.Stderr, "A terminal UI for running and watching command output.\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nKeybindings:\n")
|
||||
fmt.Fprintf(os.Stderr, " r, Ctrl-r Reload (re-run command)\n")
|
||||
fmt.Fprintf(os.Stderr, " q, Esc Quit\n")
|
||||
fmt.Fprintf(os.Stderr, " j, k Move down/up\n")
|
||||
fmt.Fprintf(os.Stderr, " g Go to first line\n")
|
||||
fmt.Fprintf(os.Stderr, " G Go to last line\n")
|
||||
fmt.Fprintf(os.Stderr, " Ctrl-d/u Half page down/up\n")
|
||||
fmt.Fprintf(os.Stderr, " PgDn/Up, ^f/b Full page down/up\n")
|
||||
fmt.Fprintf(os.Stderr, " p Toggle preview\n")
|
||||
fmt.Fprintf(os.Stderr, " / Enter filter mode\n")
|
||||
fmt.Fprintf(os.Stderr, " Esc Exit filter mode / clear filter\n")
|
||||
printUsage(os.Stderr)
|
||||
}
|
||||
|
||||
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 {
|
||||
flag.Usage()
|
||||
printUsage(os.Stdout)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -66,6 +87,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")
|
||||
@@ -75,18 +101,37 @@ 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)
|
||||
refreshSeconds := config.GetInt(config.KeyRefresh)
|
||||
showLineNums := config.ShowLineNumbers()
|
||||
|
||||
// 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,
|
||||
RefreshSeconds: refreshSeconds,
|
||||
}
|
||||
|
||||
if err := ui.Run(uiConfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.3.0
|
||||
|
||||
Reference in New Issue
Block a user