17 Commits

Author SHA1 Message Date
github-actions[bot]
a3c46bfa7c chore(master): release 1.3.0 2025-12-04 13:45:48 +02:00
5e1b807105 fix: current line style 2025-12-04 13:22:50 +02:00
51b9e92ddd feat: add exit status to command header 2025-12-04 13:05:26 +02:00
320874a61a build: add lint makefile target 2025-12-04 13:05:14 +02:00
cdd895d19a feat: help overlay window 2025-12-04 12:57:30 +02:00
github-actions[bot]
b76ae3bd7e chore(master): release 1.2.0 2025-12-03 23:56:00 +02:00
2619708244 feat: yank line 2025-12-03 23:53:50 +02:00
2d76e43519 feat: update ui with panes & borders 2025-12-03 23:42:36 +02:00
ab94ceb228 feat: add --config/-c argument 2025-12-03 23:03:14 +02:00
6c677a0d29 build: update Makefile 2025-12-03 23:03:14 +02:00
c9affe8375 feat: add --show-config/-C argument 2025-12-03 23:03:14 +02:00
github-actions[bot]
5528f090d8 chore(master): release 1.1.0 2025-12-03 18:05:24 +02:00
0727defa98 chore: fix lint errors 2025-12-03 18:01:53 +02:00
9353705f52 feat: global/project-local config files 2025-12-03 17:59:02 +02:00
c2b0bc51d0 feat: support fixed/percent preview sizes 2025-12-03 17:34:47 +02:00
github-actions[bot]
2c86fd042b chore(master): release 1.0.1 2025-12-03 14:54:02 +02:00
4d614f57dc fix: write help to correct output 2025-12-03 14:52:20 +02:00
14 changed files with 1645 additions and 190 deletions

4
.editorconfig Normal file
View File

@@ -0,0 +1,4 @@
[Makefile]
indent_style = tab
indent_size = 4
tab_width = 4

View File

@@ -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)

View File

@@ -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 ./...; \

View File

@@ -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
View File

@@ -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
View File

@@ -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
View 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 ""
}
}

View 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)
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
View File

@@ -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)
}

View File

@@ -1 +1 @@
1.0.0
1.3.0