38 Commits

Author SHA1 Message Date
github-actions[bot]
aa0467c07b chore(master): release 1.9.0 2026-03-17 01:19:33 +02:00
85437c7ce0 feat: add copy as plain 2026-03-17 01:01:40 +02:00
github-actions[bot]
cb5b9bb0b7 chore(master): release 1.8.0 2026-03-15 00:00:45 +02:00
140574e512 feat: command pallete 2026-03-14 23:58:43 +02:00
0c6fc521bc feat: J/K to scroll preview pane 2026-03-14 23:47:53 +02:00
9e9e3cd3b3 docs: update README.md 2026-03-14 23:41:19 +02:00
5174073236 feat: resize preview pane with +/- 2026-03-14 23:39:35 +02:00
1bd37227c4 feat: preview window json syntax highlighting 2026-03-14 23:37:32 +02:00
561c98ae02 fix: continue existing filter when entering filter mode 2026-03-14 23:27:36 +02:00
31330513f8 feat: regex filtering + cursor filter navigation 2026-03-14 23:26:00 +02:00
0f67db342f fix: accept all characters when filtering 2026-03-14 23:14:21 +02:00
github-actions[bot]
68ea034ad8 chore(master): release 1.7.0 2026-01-26 12:12:34 +02:00
9df4fb8285 feat: add keybinding 'c' to stop running command 2026-01-26 10:22:37 +02:00
b641616e2c test: add tests 2026-01-25 00:59:35 +02:00
github-actions[bot]
70aa9a9ee2 chore(master): release 1.6.0 2026-01-25 00:53:31 +02:00
f520a8b4ed feat: add refresh-from-start setting 2026-01-25 00:50:58 +02:00
63b45309b7 feat: add refresh countdown timer 2026-01-25 00:47:42 +02:00
10a92082b6 feat: reset refresh timer after manual refresh 2026-01-25 00:45:38 +02:00
8aaf5148ab feat: stream new output in-place without resetting existing output 2026-01-25 00:42:51 +02:00
347ac34094 fix: streaming cursor position 2026-01-25 00:36:56 +02:00
c9cec52c78 docs: Add promotional image to README 2026-01-24 11:41:21 +02:00
1f89f76e74 build: update manual homebrew workflow 2026-01-24 02:07:33 +02:00
github-actions[bot]
67de2606cd chore(master): release 1.5.2 2026-01-24 02:05:28 +02:00
c09d3e41dd fix: tab characters breaking the layout 2026-01-24 02:03:50 +02:00
b8be28d92b build: use reusable go-release workflow 2026-01-22 23:21:49 +02:00
69f8ed1ef0 build: add changelog to homebrew workflows 2026-01-22 21:26:42 +02:00
github-actions[bot]
d130becd99 chore(master): release 1.5.1 2026-01-07 22:57:55 +02:00
5703a61ddb fix: esc button behavior 2025-12-31 11:28:08 +02:00
github-actions[bot]
ca06d0d7c1 chore(master): release 1.5.0 2025-12-14 18:06:39 +02:00
f15b9e2559 feat: add more refresh duration options & formats 2025-12-14 18:04:40 +02:00
7be6a03d6d docs: update README.md 2025-12-10 15:43:06 +02:00
github-actions[bot]
574ef6abd3 chore(master): release 1.4.0 2025-12-09 01:26:08 +02:00
66c6599506 feat: properly support streaming commands 2025-12-09 01:24:21 +02:00
9ecf9f74b7 feat: add -i/--interactive mode 2025-12-09 00:48:01 +02:00
4340aa1cc0 refactor: fix lint warnings 2025-12-04 17:26:13 +02:00
d01944bfec build: add lint configuration 2025-12-04 17:26:02 +02:00
9ac39a6472 build: add formatted files on commit 2025-12-04 17:10:26 +02:00
ed2f24c0e8 feat: add spinning loader animation 2025-12-04 17:08:38 +02:00
24 changed files with 2833 additions and 360 deletions

0
.editorconfig Normal file → Executable file
View File

0
.github/FUNDING.yml vendored Normal file → Executable file
View File

42
.github/workflows/manual-homebrew-release.yml vendored Normal file → Executable file
View File

@@ -3,40 +3,10 @@ name: Manual Homebrew Release
on:
workflow_dispatch:
permissions:
contents: read
jobs:
release-homebrew:
name: Trigger Homebrew Formula Update
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get latest tag
id: latest
run: |
tag=$(gh release view --json tagName -q .tagName)
echo "Latest release tag: $tag"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Send dispatch to homebrew-tap
env:
GH_TOKEN: ${{ secrets.REPO_DISPATCH_PAT }}
run: |
tag="${{ steps.latest.outputs.tag }}"
repo="${{ github.event.repository.name }}"
data="{\"event_type\":\"trigger-from-release\",\"client_payload\":{\"tag\":\"$tag\",\"repo\":\"$repo\"}}"
echo "Dispatching tag $tag from $repo"
echo "Data: $data"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/chenasraf/homebrew-tap/dispatches \
-d "$data"
echo "Dispatched tag $tag from $repo"
echo "Created job on https://github.com/chenasraf/homebrew-tap/actions"
homebrew:
uses: chenasraf/workflows/.github/workflows/manual-homebrew-release.yml@master
with:
homebrew-tap-repo: chenasraf/homebrew-tap
secrets:
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}

118
.github/workflows/release.yml vendored Normal file → Executable file
View File

@@ -11,114 +11,10 @@ permissions:
pull-requests: write
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Test
run: go test -v ./...
generate:
name: Build for ${{ matrix.platform }}
runs-on: ubuntu-latest
strategy:
matrix:
include:
- platform: linux/amd64
label: linux-amd64
- platform: darwin/amd64
label: darwin-amd64
- platform: darwin/arm64
label: darwin-arm64
- platform: windows/amd64
label: windows-amd64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build for ${{ matrix.label }}
uses: chenasraf/go-cross-build@v1
with:
platforms: ${{ matrix.platform }}
package: ''
name: 'watchr'
compress: 'true'
dest: dist
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: "dist-${{ matrix.label }}"
path: dist
release-please:
name: Release
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
needs:
- test
- generate
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all builds
uses: actions/download-artifact@v4
with:
path: dist
- name: Verify Release Artifacts
run: |
ls -la dist
for i in "linux-amd64" "darwin-amd64" "windows-amd64" "darwin-arm64"; do
if [[ ! -f ./dist/dist-$i/watchr-$i.tar.gz ]]; then
echo "File not found: ./dist/dist-$i/watchr-$i.tar.gz"
exit 1
fi
done
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
release-type: simple
- name: Upload Release Artifacts
if: ${{ steps.release.outputs.release_created }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for i in "linux-amd64" "darwin-amd64" "darwin-arm64" "windows-amd64"; do
gh release upload "${{ steps.release.outputs.tag_name }}" "./dist/dist-$i/watchr-$i.tar.gz"
done
release-homebrew:
name: Homebrew Release
needs: [release-please]
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
steps:
- name: Send dispatch to homebrew-tap
env:
GH_TOKEN: ${{ secrets.REPO_DISPATCH_PAT }}
run: |
repo="${{ github.event.repository.name }}"
tag="${{ needs.release-please.outputs.tag_name }}"
data="{\"event_type\":\"trigger-from-release\",\"client_payload\":{\"tag\":\"$tag\",\"repo\":\"$repo\"}}"
echo "Dispatching tag $tag from $repo"
echo "Data: $data"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/chenasraf/homebrew-tap/dispatches \
-d "$data"
echo "Dispatched tag $tag from $repo"
echo "Created job on https://github.com/chenasraf/homebrew-tap/actions"
release:
uses: chenasraf/workflows/.github/workflows/go-release.yml@master
with:
name: watchr
homebrew-tap-repo: chenasraf/homebrew-tap
secrets:
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}

0
.github/workflows/test.yml vendored Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

12
.golangci.yml Executable file
View File

@@ -0,0 +1,12 @@
version: "2"
linters:
enable:
- staticcheck
- govet
- errcheck
- ineffassign
- unused
- gocritic
- intrange
- modernize

0
.prettierrc Normal file → Executable file
View File

76
CHANGELOG.md Normal file → Executable file
View File

@@ -1,5 +1,81 @@
# Changelog
## [1.9.0](https://github.com/chenasraf/watchr/compare/v1.8.0...v1.9.0) (2026-03-16)
### Features
* add copy as plain ([85437c7](https://github.com/chenasraf/watchr/commit/85437c7ce0432a1bfce07e51bda90f4800d2059a))
## [1.8.0](https://github.com/chenasraf/watchr/compare/v1.7.0...v1.8.0) (2026-03-14)
### Features
* command pallete ([140574e](https://github.com/chenasraf/watchr/commit/140574e512a03e5ddaa70af03c66d83a0d00d5fb))
* J/K to scroll preview pane ([0c6fc52](https://github.com/chenasraf/watchr/commit/0c6fc521bca0d4781caa2a26ae16bc802a2164d9))
* preview window json syntax highlighting ([1bd3722](https://github.com/chenasraf/watchr/commit/1bd37227c48593994298cf572edc2a79c8f88ee9))
* regex filtering + cursor filter navigation ([3133051](https://github.com/chenasraf/watchr/commit/31330513f812c148370d6cdc4fb94dadc5884411))
* resize preview pane with +/- ([5174073](https://github.com/chenasraf/watchr/commit/51740732362d7a74235ee3ffbc66abd3ca4f43d8))
### Bug Fixes
* accept all characters when filtering ([0f67db3](https://github.com/chenasraf/watchr/commit/0f67db342f6d7aa01caba1a89124305204cf7c4a))
* continue existing filter when entering filter mode ([561c98a](https://github.com/chenasraf/watchr/commit/561c98ae02563482aec054ef4deb30ba3ff8c9f1))
## [1.7.0](https://github.com/chenasraf/watchr/compare/v1.6.0...v1.7.0) (2026-01-26)
### Features
* add keybinding 'c' to stop running command ([9df4fb8](https://github.com/chenasraf/watchr/commit/9df4fb8285a0181d29cfc6034165ce7cb21ab14b))
## [1.6.0](https://github.com/chenasraf/watchr/compare/v1.5.2...v1.6.0) (2026-01-24)
### Features
* add refresh countdown timer ([63b4530](https://github.com/chenasraf/watchr/commit/63b45309b76def1b5be9c11d5228d97bb7ab0a6d))
* add refresh-from-start setting ([f520a8b](https://github.com/chenasraf/watchr/commit/f520a8b4ed665c3187bab88707df9e5efdc779bc))
* reset refresh timer after manual refresh ([10a9208](https://github.com/chenasraf/watchr/commit/10a92082b6c11fcfba2bd835ed2336d1f33d1c04))
* stream new output in-place without resetting existing output ([8aaf514](https://github.com/chenasraf/watchr/commit/8aaf5148ab17b33a72ff3f946c57029c33bdb4d0))
### Bug Fixes
* streaming cursor position ([347ac34](https://github.com/chenasraf/watchr/commit/347ac340942c3ee8d2731432f808ac2b9dd39060))
## [1.5.2](https://github.com/chenasraf/watchr/compare/v1.5.1...v1.5.2) (2026-01-24)
### Bug Fixes
* tab characters breaking the layout ([c09d3e4](https://github.com/chenasraf/watchr/commit/c09d3e41ddd69c44ea0546cb0608abe8b0c50334))
## [1.5.1](https://github.com/chenasraf/watchr/compare/v1.5.0...v1.5.1) (2025-12-31)
### Bug Fixes
* esc button behavior ([5703a61](https://github.com/chenasraf/watchr/commit/5703a61ddb3ee25e58470c537d53bf4a463bc632))
## [1.5.0](https://github.com/chenasraf/watchr/compare/v1.4.0...v1.5.0) (2025-12-14)
### Features
* add more refresh duration options & formats ([f15b9e2](https://github.com/chenasraf/watchr/commit/f15b9e255988b5b754c377e3ae29f9eb4c8a1925))
## [1.4.0](https://github.com/chenasraf/watchr/compare/v1.3.0...v1.4.0) (2025-12-08)
### Features
* add -i/--interactive mode ([9ecf9f7](https://github.com/chenasraf/watchr/commit/9ecf9f74b7e8271dc8f8d2e6e2fc562e87be9953))
* add spinning loader animation ([ed2f24c](https://github.com/chenasraf/watchr/commit/ed2f24c0e820ebaa37f46cc2407648c3e8b1cbbd))
* properly support streaming commands ([66c6599](https://github.com/chenasraf/watchr/commit/66c65995068181d4b4ae74087eb0c15e1ab0edb4))
## [1.3.0](https://github.com/chenasraf/watchr/compare/v1.2.0...v1.3.0) (2025-12-04)

0
LICENSE Normal file → Executable file
View File

7
Makefile Normal file → Executable file
View File

@@ -1,6 +1,11 @@
BIN := $(notdir $(CURDIR))
all: run
all:
@if [ ! -f ".git/hooks/pre-commit" ]; then \
$(MAKE) precommit-install; \
fi
$(MAKE) build
$(MAKE) run
.PHONY: build
build:

119
README.md Normal file → Executable file
View File

@@ -7,13 +7,16 @@ provides vim-style navigation, filtering, and a preview pane—all without leavi
![Downloads](https://img.shields.io/github/downloads/chenasraf/watchr/total)
![License](https://img.shields.io/github/license/chenasraf/watchr)
![Promo](https://github.com/user-attachments/assets/ec5ab94b-ef91-40d8-a604-9047212a8faf)
---
## 🚀 Features
- **Interactive output viewer**: Browse command output with vim-style keybindings
- **Live filtering**: Press `/` to filter output lines in real-time
- **Preview pane**: Toggle a preview panel (bottom, top, left, or right)
- **Live filtering**: Press `/` to filter output lines in real-time, with regex support (`//`)
- **Preview pane**: Toggle a resizable preview panel (bottom, top, left, or right) with JSON syntax
highlighting
- **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
@@ -76,6 +79,18 @@ watchr "ps aux"
# Refresh every 2 seconds
watchr -r 2 "docker ps"
# Refresh every 500 milliseconds
watchr -r 500ms "date"
# Refresh every 1.5 seconds
watchr -r 1.5s "kubectl get pods"
# Refresh every 5 minutes
watchr -r 5m "df -h"
# Refresh every hour
watchr -r 1h "curl -s https://api.example.com/status"
# Watch file changes
watchr -r 5 "find . -name '*.go' -mmin -1"
```
@@ -86,24 +101,27 @@ watchr -r 5 "find . -name '*.go' -mmin -1"
Usage: watchr [options] <command to run>
Options:
-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")
-c, --config string Load config from specified path
-h, --help Show help
-i, --interactive Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)
-w, --line-width int Line number width (default 6)
-n, --no-line-numbers Disable line numbers
-o, --preview-position string Preview position: bottom, top, left, right (default "bottom")
-P, --preview-size string Preview size: number for lines/cols, or number% for percentage (e.g., 10 or 40%) (default "40%")
-p, --prompt string Prompt string (default "watchr> ")
-r, --refresh string Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled) (default "0")
--refresh-from-start Start refresh timer when command starts (default: when command ends)
-s, --shell string Shell to use for executing commands (default "sh")
-C, --show-config Show loaded configuration and exit
-v, --version Show version
```
---
## 📁 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.
`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
@@ -116,17 +134,20 @@ Config files are searched in the following order (later files override earlier o
### Example Configurations
**YAML** (`watchr.yaml`):
```yaml
shell: bash
preview-size: "50%"
preview-size: '50%'
preview-position: right
line-numbers: true
line-width: 4
prompt: "> "
refresh: 0
prompt: '> '
refresh: 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
interactive: false
```
**TOML** (`watchr.toml`):
```toml
shell = "bash"
preview-size = "50%"
@@ -134,10 +155,12 @@ preview-position = "right"
line-numbers = true
line-width = 4
prompt = "> "
refresh = 0
refresh = 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
interactive = false
```
**JSON** (`watchr.json`):
```json
{
"shell": "bash",
@@ -146,10 +169,16 @@ refresh = 0
"line-numbers": true,
"line-width": 4,
"prompt": "> ",
"refresh": 0
"refresh": 0,
"interactive": false
}
```
The `refresh` option accepts:
- Numbers: `2` or `1.5` (interpreted as seconds)
- Explicit units: `"500ms"`, `"2s"`, `"5m"`, `"1h"`
### Priority Order
Configuration values are applied in this order (later sources override earlier ones):
@@ -163,21 +192,41 @@ Configuration values are applied in this order (later sources override earlier o
## ⌨️ Keybindings
| Key | Action |
| ------------------ | ------------------------------- |
| `r`, `Ctrl-r` | Reload (re-run command) |
| `q`, `Esc` | Quit |
| `j`, `k` | Move down/up |
| `g` | Go to first line |
| `G` | Go to last line |
| `Ctrl-d`, `Ctrl-u` | Half page down/up |
| `PgDn`, `Ctrl-f` | Full page down |
| `PgUp`, `Ctrl-b` | Full page up |
| `p` | Toggle preview pane |
| `/` | Enter filter mode |
| `Esc` | Exit filter mode / clear filter |
| `y` | Yank (copy) selected line |
| `?` | Show help overlay |
| Key | Action |
| ------------------ | -------------------------------- |
| `r`, `Ctrl-r` | Reload (re-run command) |
| `c` | Stop running command |
| `q`, `Esc` | Quit |
| `j`, `k` | Move down/up |
| `g` | Go to first line |
| `G` | Go to last line |
| `Ctrl-d`, `Ctrl-u` | Half page down/up |
| `PgDn`, `Ctrl-f` | Full page down |
| `PgUp`, `Ctrl-b` | Full page up |
| `p` | Toggle preview pane |
| `+` / `-` | Increase / decrease preview size |
| `J` / `K` | Scroll preview down / up |
| `/` | Enter filter mode |
| `//` | Toggle regex filter mode |
| `Esc` | Exit filter mode / clear filter |
| `y` | Yank (copy) selected line |
| `Y` | Yank selected line (plain text) |
| `:` | Open command palette |
| `?` | Show help overlay |
### Filter mode
When in filter mode (`/`), the following keys are available:
| Key | Action |
| ------------------------ | ---------------------------------------- |
| `Enter` | Confirm filter |
| `Esc` | Cancel and clear filter |
| `Left` / `Right` | Move cursor within filter |
| `Alt-Left` / `Alt-Right` | Move cursor by word |
| `Backspace` | Delete character before cursor |
| `Alt-Backspace` | Delete word before cursor |
| `/` | Toggle regex mode (when filter is empty) |
---
@@ -188,7 +237,7 @@ very helpful to sustaining its life. If you are feeling incredibly generous and
just a small amount to help sustain this project, I would be very very thankful!
<a href='https://ko-fi.com/casraf' target='_blank'>
<img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3' alt='Buy Me a Coffee at ko-fi.com' />
<img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3' alt='Buy Me a Coffee at ko-fi.com' />
</a>
I welcome any issues or pull requests on GitHub. If you find a bug, or would like a new feature,

2
go.mod Normal file → Executable file
View File

@@ -12,11 +12,13 @@ require (
)
require (
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // 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

4
go.sum Normal file → Executable file
View File

@@ -1,3 +1,5 @@
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -14,6 +16,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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=

83
internal/config/config.go Normal file → Executable file
View File

@@ -4,7 +4,10 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
@@ -12,13 +15,15 @@ import (
// Config keys
const (
KeyShell = "shell"
KeyPreviewSize = "preview-size"
KeyPreviewPosition = "preview-position"
KeyLineNumbers = "line-numbers"
KeyLineWidth = "line-width"
KeyPrompt = "prompt"
KeyRefresh = "refresh"
KeyShell = "shell"
KeyPreviewSize = "preview-size"
KeyPreviewPosition = "preview-position"
KeyLineNumbers = "line-numbers"
KeyLineWidth = "line-width"
KeyPrompt = "prompt"
KeyRefresh = "refresh"
KeyRefreshFromStart = "refresh-from-start"
KeyInteractive = "interactive"
)
// setDefaults sets the default configuration values.
@@ -29,7 +34,9 @@ func setDefaults() {
viper.SetDefault(KeyLineNumbers, true)
viper.SetDefault(KeyLineWidth, 6)
viper.SetDefault(KeyPrompt, "watchr> ")
viper.SetDefault(KeyRefresh, 0)
viper.SetDefault(KeyRefresh, "0")
viper.SetDefault(KeyRefreshFromStart, false)
viper.SetDefault(KeyInteractive, false)
}
// Init initializes Viper with config file paths and defaults.
@@ -74,6 +81,8 @@ func BindFlags(flags *pflag.FlagSet) {
_ = viper.BindPFlag(KeyLineWidth, flags.Lookup("line-width"))
_ = viper.BindPFlag(KeyPrompt, flags.Lookup("prompt"))
_ = viper.BindPFlag(KeyRefresh, flags.Lookup("refresh"))
_ = viper.BindPFlag(KeyRefreshFromStart, flags.Lookup("refresh-from-start"))
_ = viper.BindPFlag(KeyInteractive, flags.Lookup("interactive"))
// line-numbers is inverted (no-line-numbers flag)
_ = viper.BindPFlag("no-line-numbers", flags.Lookup("no-line-numbers"))
@@ -126,7 +135,9 @@ func PrintConfig() {
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))
fmt.Printf(" %-20s %s\n", KeyRefresh+":", GetString(KeyRefresh))
fmt.Printf(" %-20s %v\n", KeyRefreshFromStart+":", GetBool(KeyRefreshFromStart))
fmt.Printf(" %-20s %v\n", KeyInteractive+":", GetBool(KeyInteractive))
}
// getConfigDir returns the appropriate config directory for the OS.
@@ -145,3 +156,57 @@ func getConfigDir() string {
return ""
}
}
// durationRegex matches duration strings like "1", "1.5", "500ms", "1s", "1.5s", "5m", "1h"
var durationRegex = regexp.MustCompile(`^(\d+(?:\.\d+)?)(ms|s|m|h)?$`)
// ParseDuration parses a duration string into a time.Duration.
// Supported formats:
// - "1", "1.5" - interpreted as seconds (default unit)
// - "1s", "1.5s" - explicit seconds
// - "500ms", "1500ms" - explicit milliseconds
// - "5m", "1.5m" - explicit minutes
// - "1h", "0.5h" - explicit hours
//
// Returns 0 if the input is empty or "0".
func ParseDuration(s string) (time.Duration, error) {
if s == "" || s == "0" {
return 0, nil
}
matches := durationRegex.FindStringSubmatch(s)
if matches == nil {
return 0, fmt.Errorf("invalid duration format: %q (expected number, Xms, Xs, Xm, or Xh)", s)
}
value, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %q", matches[1])
}
unit := matches[2]
switch unit {
case "ms":
return time.Duration(value * float64(time.Millisecond)), nil
case "s", "":
// Default to seconds
return time.Duration(value * float64(time.Second)), nil
case "m":
return time.Duration(value * float64(time.Minute)), nil
case "h":
return time.Duration(value * float64(time.Hour)), nil
default:
return 0, fmt.Errorf("invalid duration unit: %q", unit)
}
}
// GetDuration returns a duration config value by parsing the string value.
// Returns 0 if parsing fails or value is empty.
func GetDuration(key string) time.Duration {
s := viper.GetString(key)
d, err := ParseDuration(s)
if err != nil {
return 0
}
return d
}

297
internal/config/config_test.go Normal file → Executable file
View File

@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
@@ -70,8 +71,8 @@ func TestInit(t *testing.T) {
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)
if got := viper.GetString(KeyRefresh); got != "0" {
t.Errorf("expected default refresh '0', got %q", got)
}
}
@@ -131,8 +132,10 @@ func TestBindFlags(t *testing.T) {
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.Int("refresh", 0, "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
// Parse with custom values
err := flags.Parse([]string{"--shell=bash", "--preview-size=50%", "--line-width=8"})
@@ -204,8 +207,8 @@ refresh: 5
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)
if got := GetDuration(KeyRefresh); got != 5*time.Second {
t.Errorf("expected refresh 5s from config file, got %v", got)
}
// ConfigFileUsed should return the path
@@ -237,8 +240,10 @@ preview-size: "60%"
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.Int("refresh", 0, "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
// Override shell via flag
err := flags.Parse([]string{"--shell=bash"})
@@ -322,8 +327,8 @@ refresh: 10
t.Errorf("expected prompt 'custom> ', got %q", got)
}
if got := GetInt(KeyRefresh); got != 10 {
t.Errorf("expected refresh 10, got %d", got)
if got := GetDuration(KeyRefresh); got != 10*time.Second {
t.Errorf("expected refresh 10s, got %v", got)
}
// ConfigFileUsed should return the specified path
@@ -434,3 +439,279 @@ func TestInitWithFileDefaults(t *testing.T) {
t.Errorf("expected default line-width 6, got %d", got)
}
}
func TestParseDuration(t *testing.T) {
tests := []struct {
input string
expected time.Duration
wantErr bool
}{
// Empty and zero values
{"", 0, false},
{"0", 0, false},
// Plain numbers (default to seconds)
{"1", 1 * time.Second, false},
{"5", 5 * time.Second, false},
{"60", 60 * time.Second, false},
// Decimal seconds
{"0.5", 500 * time.Millisecond, false},
{"1.5", 1500 * time.Millisecond, false},
{"2.25", 2250 * time.Millisecond, false},
{"0.1", 100 * time.Millisecond, false},
{"0.001", 1 * time.Millisecond, false},
// Explicit seconds suffix
{"1s", 1 * time.Second, false},
{"5s", 5 * time.Second, false},
{"0.5s", 500 * time.Millisecond, false},
{"1.5s", 1500 * time.Millisecond, false},
// Milliseconds suffix
{"100ms", 100 * time.Millisecond, false},
{"500ms", 500 * time.Millisecond, false},
{"1000ms", 1000 * time.Millisecond, false},
{"1500ms", 1500 * time.Millisecond, false},
{"50.5ms", 50500 * time.Microsecond, false},
// Minutes suffix
{"1m", 1 * time.Minute, false},
{"5m", 5 * time.Minute, false},
{"0.5m", 30 * time.Second, false},
{"1.5m", 90 * time.Second, false},
// Hours suffix
{"1h", 1 * time.Hour, false},
{"2h", 2 * time.Hour, false},
{"0.5h", 30 * time.Minute, false},
{"1.5h", 90 * time.Minute, false},
// Invalid formats
{"abc", 0, true},
{"1d", 0, true}, // days not supported
{"1w", 0, true}, // weeks not supported
{"-1", 0, true}, // negative not supported
{"-1s", 0, true}, // negative not supported
{"1.2.3", 0, true},
{"s", 0, true},
{"ms", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseDuration(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("ParseDuration(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("ParseDuration(%q) unexpected error: %v", tt.input, err)
return
}
if got != tt.expected {
t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestGetDuration(t *testing.T) {
_, cleanup := isolateConfig(t)
defer cleanup()
Init()
// Test default value
if got := GetDuration(KeyRefresh); got != 0 {
t.Errorf("expected default refresh 0, got %v", got)
}
// Test with seconds value
viper.Set(KeyRefresh, "5")
if got := GetDuration(KeyRefresh); got != 5*time.Second {
t.Errorf("expected refresh 5s, got %v", got)
}
// Test with decimal value
viper.Set(KeyRefresh, "0.5")
if got := GetDuration(KeyRefresh); got != 500*time.Millisecond {
t.Errorf("expected refresh 500ms, got %v", got)
}
// Test with explicit seconds suffix
viper.Set(KeyRefresh, "2s")
if got := GetDuration(KeyRefresh); got != 2*time.Second {
t.Errorf("expected refresh 2s, got %v", got)
}
// Test with milliseconds
viper.Set(KeyRefresh, "250ms")
if got := GetDuration(KeyRefresh); got != 250*time.Millisecond {
t.Errorf("expected refresh 250ms, got %v", got)
}
// Test with invalid value (should return 0)
viper.Set(KeyRefresh, "invalid")
if got := GetDuration(KeyRefresh); got != 0 {
t.Errorf("expected refresh 0 for invalid value, got %v", got)
}
}
func TestRefreshFromStartDefault(t *testing.T) {
_, cleanup := isolateConfig(t)
defer cleanup()
Init()
// Default should be false
if got := GetBool(KeyRefreshFromStart); got != false {
t.Errorf("expected default refresh-from-start false, got %v", got)
}
}
func TestRefreshFromStartFromConfigFile(t *testing.T) {
tmpDir, cleanup := isolateConfig(t)
defer cleanup()
// Create config file with refresh-from-start: true
configPath := filepath.Join(tmpDir, "watchr.yaml")
configContent := `refresh-from-start: true
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
Init()
if got := GetBool(KeyRefreshFromStart); got != true {
t.Errorf("expected refresh-from-start true from config file, got %v", got)
}
}
func TestRefreshFromStartFromFlag(t *testing.T) {
_, cleanup := isolateConfig(t)
defer cleanup()
Init()
// Create flags and parse
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.String("shell", "sh", "")
flags.String("preview-size", "40%", "")
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
// Parse with refresh-from-start=true
err := flags.Parse([]string{"--refresh-from-start=true"})
if err != nil {
t.Fatalf("failed to parse flags: %v", err)
}
BindFlags(flags)
if got := GetBool(KeyRefreshFromStart); got != true {
t.Errorf("expected refresh-from-start true from flag, got %v", got)
}
}
func TestRefreshFromStartFlagOverridesConfig(t *testing.T) {
tmpDir, cleanup := isolateConfig(t)
defer cleanup()
// Create config file with refresh-from-start: true
configPath := filepath.Join(tmpDir, "watchr.yaml")
configContent := `refresh-from-start: true
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
Init()
// Create flags and parse with refresh-from-start=false
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.String("shell", "sh", "")
flags.String("preview-size", "40%", "")
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
err := flags.Parse([]string{"--refresh-from-start=false"})
if err != nil {
t.Fatalf("failed to parse flags: %v", err)
}
BindFlags(flags)
// Flag should override config
if got := GetBool(KeyRefreshFromStart); got != false {
t.Errorf("expected refresh-from-start false (flag override), got %v", got)
}
}
func TestRefreshDurationFromConfigFile(t *testing.T) {
tmpDir, cleanup := isolateConfig(t)
defer cleanup()
tests := []struct {
name string
yaml string
expected time.Duration
}{
{
name: "integer seconds",
yaml: "refresh: 5\n",
expected: 5 * time.Second,
},
{
name: "decimal seconds",
yaml: "refresh: 0.5\n",
expected: 500 * time.Millisecond,
},
{
name: "string with s suffix",
yaml: "refresh: \"2s\"\n",
expected: 2 * time.Second,
},
{
name: "string with ms suffix",
yaml: "refresh: \"500ms\"\n",
expected: 500 * time.Millisecond,
},
{
name: "string decimal with s suffix",
yaml: "refresh: \"1.5s\"\n",
expected: 1500 * time.Millisecond,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetViper()
configPath := filepath.Join(tmpDir, "watchr.yaml")
if err := os.WriteFile(configPath, []byte(tt.yaml), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
Init()
got := GetDuration(KeyRefresh)
if got != tt.expected {
t.Errorf("GetDuration(KeyRefresh) = %v, want %v", got, tt.expected)
}
})
}
}

283
internal/runner/runner.go Normal file → Executable file
View File

@@ -5,11 +5,22 @@ import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
// sanitizeLine removes control sequences that can corrupt terminal rendering
func sanitizeLine(s string) string {
// Remove carriage returns
s = strings.ReplaceAll(s, "\r", "")
// Convert tabs to spaces (tabs cause width calculation issues)
s = strings.ReplaceAll(s, "\t", " ")
return s
}
// Line represents a single line of output with its line number
type Line struct {
Number int
@@ -26,15 +37,82 @@ func (l Line) FormatLine(width int, showLineNum bool) string {
// Runner executes commands and captures output
type Runner struct {
Shell string
Command string
Shell string
Command string
Interactive bool
}
// NewRunner creates a new Runner
func NewRunner(shell, command string) *Runner {
return &Runner{
Shell: shell,
Command: command,
Shell: shell,
Command: command,
Interactive: false,
}
}
// NewInteractiveRunner creates a new Runner that sources shell rc files
func NewInteractiveRunner(shell, command string) *Runner {
return &Runner{
Shell: shell,
Command: command,
Interactive: true,
}
}
// buildCommand returns the shell arguments for executing the command.
// If Interactive is true, it wraps the command to source the appropriate rc file.
func (r *Runner) buildCommand() []string {
if !r.Interactive {
return []string{"-c", r.Command}
}
// For interactive mode, source the appropriate rc file before running the command
rcFile := r.getRCFile()
if rcFile != "" {
// Source the rc file if it exists, then run the command
wrappedCmd := fmt.Sprintf("[ -f %s ] && . %s; %s", rcFile, rcFile, r.Command)
return []string{"-c", wrappedCmd}
}
return []string{"-c", r.Command}
}
// getRCFile returns the path to the shell's rc file based on the shell being used.
func (r *Runner) getRCFile() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
shellBase := filepath.Base(r.Shell)
switch shellBase {
case "bash":
// Prefer .bashrc for interactive settings, fall back to .bash_profile
bashrc := filepath.Join(home, ".bashrc")
if _, err := os.Stat(bashrc); err == nil {
return bashrc
}
return filepath.Join(home, ".bash_profile")
case "zsh":
return filepath.Join(home, ".zshrc")
case "fish":
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
configDir = filepath.Join(home, ".config")
}
return filepath.Join(configDir, "fish", "config.fish")
case "ksh":
return filepath.Join(home, ".kshrc")
case "sh":
// POSIX sh uses ENV variable or .profile
if env := os.Getenv("ENV"); env != "" {
return env
}
return filepath.Join(home, ".profile")
default:
// Try common patterns for unknown shells
return filepath.Join(home, "."+shellBase+"rc")
}
}
@@ -46,7 +124,9 @@ type Result struct {
// 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)
args := r.buildCommand()
cmd := exec.CommandContext(ctx, r.Shell, args...)
cmd.Env = append(os.Environ(), "WATCHR=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -70,7 +150,7 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
for scanner.Scan() {
lines = append(lines, Line{
Number: lineNum,
Content: scanner.Text(),
Content: sanitizeLine(scanner.Text()),
})
lineNum++
}
@@ -80,7 +160,7 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
for stderrScanner.Scan() {
lines = append(lines, Line{
Number: lineNum,
Content: stderrScanner.Text(),
Content: sanitizeLine(stderrScanner.Text()),
})
lineNum++
}
@@ -96,59 +176,168 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
return Result{Lines: lines, ExitCode: exitCode}, nil
}
// RunStreaming executes the command and streams output lines to the callback
// The callback is called for each line as it arrives
func (r *Runner) RunStreaming(ctx context.Context, lines *[]Line, mu *sync.RWMutex) error {
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
// StreamingResult holds the state of a streaming command
type StreamingResult struct {
Lines *[]Line
ExitCode int
Done bool
Error error
PrevLineCount int // Number of lines from previous run (for trimming)
CurrentLineCount int // Number of lines written by current run
mu sync.RWMutex
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
// GetLines returns a copy of the current lines (thread-safe)
func (s *StreamingResult) GetLines() []Line {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Lines == nil {
return nil
}
result := make([]Line, len(*s.Lines))
copy(result, *s.Lines)
return result
}
// LineCount returns the current number of lines (thread-safe)
func (s *StreamingResult) LineCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Lines == nil {
return 0
}
return len(*s.Lines)
}
// IsDone returns whether the command has finished (thread-safe)
func (s *StreamingResult) IsDone() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.Done
}
// GetCurrentLineCount returns the number of lines written by the current run (thread-safe)
func (s *StreamingResult) GetCurrentLineCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.CurrentLineCount
}
// RunStreaming executes the command and streams output lines in the background.
// Returns a StreamingResult that can be polled for updates.
// The command runs until ctx is cancelled or it completes naturally.
// If prevLines is provided, lines are updated in place rather than starting fresh.
func (r *Runner) RunStreaming(ctx context.Context, prevLines []Line) *StreamingResult {
// Copy previous lines to allow in-place updates
lines := make([]Line, len(prevLines))
copy(lines, prevLines)
result := &StreamingResult{
Lines: &lines,
ExitCode: -1,
Done: false,
PrevLineCount: len(prevLines),
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
go func() {
args := r.buildCommand()
cmd := exec.CommandContext(ctx, r.Shell, args...)
cmd.Env = append(os.Environ(), "WATCHR=1")
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start command: %w", err)
}
lineNum := 1
// Read from both stdout and stderr concurrently
var wg sync.WaitGroup
wg.Add(2)
readPipe := func(pipe io.Reader) {
defer wg.Done()
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
mu.Lock()
*lines = append(*lines, Line{
Number: lineNum,
Content: scanner.Text(),
})
lineNum++
mu.Unlock()
stdout, err := cmd.StdoutPipe()
if err != nil {
result.mu.Lock()
result.Error = fmt.Errorf("failed to create stdout pipe: %w", err)
result.Done = true
result.mu.Unlock()
return
}
}
go readPipe(stdout)
go readPipe(stderr)
stderr, err := cmd.StderrPipe()
if err != nil {
result.mu.Lock()
result.Error = fmt.Errorf("failed to create stderr pipe: %w", err)
result.Done = true
result.mu.Unlock()
return
}
wg.Wait()
if err := cmd.Start(); err != nil {
result.mu.Lock()
result.Error = fmt.Errorf("failed to start command: %w", err)
result.Done = true
result.mu.Unlock()
return
}
// Wait for command to finish (ignore exit code - we still want to show output)
_ = cmd.Wait()
lineNum := 1
var lineNumMu sync.Mutex
return nil
// Read from both stdout and stderr concurrently
var wg sync.WaitGroup
wg.Add(2)
readPipe := func(pipe io.Reader) {
defer wg.Done()
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
lineNumMu.Lock()
currentLineNum := lineNum
lineIdx := lineNum - 1 // 0-indexed
lineNum++
lineNumMu.Unlock()
newLine := Line{
Number: currentLineNum,
Content: sanitizeLine(scanner.Text()),
}
result.mu.Lock()
if lineIdx < len(*result.Lines) {
// Update existing line in place
(*result.Lines)[lineIdx] = newLine
} else {
// Append new line
*result.Lines = append(*result.Lines, newLine)
}
// Track how many lines this run has produced
if currentLineNum > result.CurrentLineCount {
result.CurrentLineCount = currentLineNum
}
result.mu.Unlock()
}
}
go readPipe(stdout)
go readPipe(stderr)
wg.Wait()
// Wait for command to finish and get exit code
exitCode := 0
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if ctx.Err() != nil {
// Context was cancelled
exitCode = -1
}
}
result.mu.Lock()
result.ExitCode = exitCode
result.Done = true
result.mu.Unlock()
}()
return result
}
// RunSimple executes the command and returns output as string slice
func (r *Runner) RunSimple(ctx context.Context) ([]string, error) {
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
args := r.buildCommand()
cmd := exec.CommandContext(ctx, r.Shell, args...)
cmd.Env = append(os.Environ(), "WATCHR=1")
output, err := cmd.CombinedOutput()
if err != nil {
// Still return output even on error (non-zero exit)

293
internal/runner/runner_test.go Normal file → Executable file
View File

@@ -14,6 +14,97 @@ func TestNewRunner(t *testing.T) {
if r.Command != "echo hello" {
t.Errorf("expected command 'echo hello', got %q", r.Command)
}
if r.Interactive {
t.Errorf("expected Interactive to be false for NewRunner")
}
}
func TestNewInteractiveRunner(t *testing.T) {
r := NewInteractiveRunner("bash", "my_func")
if r.Shell != "bash" {
t.Errorf("expected shell 'bash', got %q", r.Shell)
}
if r.Command != "my_func" {
t.Errorf("expected command 'my_func', got %q", r.Command)
}
if !r.Interactive {
t.Errorf("expected Interactive to be true for NewInteractiveRunner")
}
}
func TestRunner_buildCommand(t *testing.T) {
tests := []struct {
name string
shell string
command string
interactive bool
wantFirst string
}{
{
name: "non-interactive",
shell: "sh",
command: "echo hello",
interactive: false,
wantFirst: "-c",
},
{
name: "interactive bash",
shell: "bash",
command: "my_func",
interactive: true,
wantFirst: "-c",
},
{
name: "interactive zsh",
shell: "/bin/zsh",
command: "my_alias",
interactive: true,
wantFirst: "-c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var r *Runner
if tt.interactive {
r = NewInteractiveRunner(tt.shell, tt.command)
} else {
r = NewRunner(tt.shell, tt.command)
}
args := r.buildCommand()
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d", len(args))
}
if args[0] != tt.wantFirst {
t.Errorf("expected first arg %q, got %q", tt.wantFirst, args[0])
}
// For interactive mode, the command should contain sourcing logic
if tt.interactive {
if !contains(args[1], tt.command) {
t.Errorf("expected command %q to be in args[1] %q", tt.command, args[1])
}
} else {
if args[1] != tt.command {
t.Errorf("expected args[1] to be %q, got %q", tt.command, args[1])
}
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestRunner_Run(t *testing.T) {
@@ -229,3 +320,205 @@ func TestSplitLines(t *testing.T) {
})
}
}
func TestSanitizeLine(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "plain text unchanged",
input: "hello world",
want: "hello world",
},
{
name: "tabs converted to spaces",
input: "col1\tcol2\tcol3",
want: "col1 col2 col3",
},
{
name: "carriage returns removed",
input: "line with\r\nwindows ending",
want: "line with\nwindows ending",
},
{
name: "carriage return only removed",
input: "progress\roverwrite",
want: "progressoverwrite",
},
{
name: "ANSI color codes preserved",
input: "\x1b[32mgreen text\x1b[0m",
want: "\x1b[32mgreen text\x1b[0m",
},
{
name: "mixed tabs and colors",
input: "\x1b[1m?\x1b[0m\tpackage\t[no test files]",
want: "\x1b[1m?\x1b[0m package [no test files]",
},
{
name: "empty string",
input: "",
want: "",
},
{
name: "multiple tabs",
input: "\t\t\t",
want: " ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeLine(tt.input)
if got != tt.want {
t.Errorf("sanitizeLine(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestRunStreaming(t *testing.T) {
r := NewRunner("sh", "echo 'line1'; echo 'line2'; echo 'line3'")
ctx := context.Background()
result := r.RunStreaming(ctx, nil)
// Wait for completion
for !result.IsDone() {
time.Sleep(10 * time.Millisecond)
}
lines := result.GetLines()
if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d", len(lines))
}
if lines[0].Content != "line1" {
t.Errorf("expected first line 'line1', got %q", lines[0].Content)
}
if result.GetCurrentLineCount() != 3 {
t.Errorf("expected CurrentLineCount 3, got %d", result.GetCurrentLineCount())
}
}
func TestRunStreamingWithPreviousLines(t *testing.T) {
// Previous lines that should be overwritten
prevLines := []Line{
{Number: 1, Content: "old1"},
{Number: 2, Content: "old2"},
{Number: 3, Content: "old3"},
{Number: 4, Content: "old4"},
{Number: 5, Content: "old5"},
}
r := NewRunner("sh", "echo 'new1'; echo 'new2'; echo 'new3'")
ctx := context.Background()
result := r.RunStreaming(ctx, prevLines)
// Verify PrevLineCount is set
if result.PrevLineCount != 5 {
t.Errorf("expected PrevLineCount 5, got %d", result.PrevLineCount)
}
// Wait for completion
for !result.IsDone() {
time.Sleep(10 * time.Millisecond)
}
lines := result.GetLines()
// Should still have 5 lines (3 new + 2 old remaining)
if len(lines) != 5 {
t.Fatalf("expected 5 lines (in-place update), got %d", len(lines))
}
// First 3 lines should be overwritten
if lines[0].Content != "new1" {
t.Errorf("expected line 0 'new1', got %q", lines[0].Content)
}
if lines[1].Content != "new2" {
t.Errorf("expected line 1 'new2', got %q", lines[1].Content)
}
if lines[2].Content != "new3" {
t.Errorf("expected line 2 'new3', got %q", lines[2].Content)
}
// Remaining lines should be old (not touched)
if lines[3].Content != "old4" {
t.Errorf("expected line 3 'old4', got %q", lines[3].Content)
}
if lines[4].Content != "old5" {
t.Errorf("expected line 4 'old5', got %q", lines[4].Content)
}
// CurrentLineCount should be 3 (only new lines written)
if result.GetCurrentLineCount() != 3 {
t.Errorf("expected CurrentLineCount 3, got %d", result.GetCurrentLineCount())
}
}
func TestRunStreamingMoreLinesThanPrevious(t *testing.T) {
// Previous lines (fewer than new output)
prevLines := []Line{
{Number: 1, Content: "old1"},
{Number: 2, Content: "old2"},
}
r := NewRunner("sh", "echo 'new1'; echo 'new2'; echo 'new3'; echo 'new4'")
ctx := context.Background()
result := r.RunStreaming(ctx, prevLines)
// Wait for completion
for !result.IsDone() {
time.Sleep(10 * time.Millisecond)
}
lines := result.GetLines()
// Should have 4 lines (2 overwritten + 2 appended)
if len(lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(lines))
}
// All lines should be new
for i, expected := range []string{"new1", "new2", "new3", "new4"} {
if lines[i].Content != expected {
t.Errorf("expected line %d %q, got %q", i, expected, lines[i].Content)
}
}
if result.GetCurrentLineCount() != 4 {
t.Errorf("expected CurrentLineCount 4, got %d", result.GetCurrentLineCount())
}
}
func TestStreamingResultThreadSafety(t *testing.T) {
r := NewRunner("sh", "for i in $(seq 1 100); do echo line$i; done")
ctx := context.Background()
result := r.RunStreaming(ctx, nil)
// Concurrently read while streaming
done := make(chan bool)
go func() {
for !result.IsDone() {
_ = result.GetLines()
_ = result.LineCount()
_ = result.GetCurrentLineCount()
time.Sleep(5 * time.Millisecond)
}
done <- true
}()
<-done
// Should complete without race conditions
if result.LineCount() != 100 {
t.Errorf("expected 100 lines, got %d", result.LineCount())
}
}

97
internal/ui/highlight.go Normal file
View File

@@ -0,0 +1,97 @@
package ui
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
// ansiEscPattern matches ANSI escape sequences (CSI and simple ESC sequences).
var ansiEscPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]|\x1b[^\[]`)
// stripANSI removes all ANSI escape sequences from a string.
func stripANSI(s string) string {
return ansiEscPattern.ReplaceAllString(s, "")
}
// extractJSON finds the first JSON object or array in a string,
// skipping any leading non-JSON content (e.g. ANSI codes, log prefixes).
// Returns the JSON substring and any prefix before it.
func extractJSON(s string) (prefix, jsonStr string, ok bool) {
// Find first { or [
idx := strings.IndexAny(s, "{[")
if idx < 0 {
return "", "", false
}
return s[:idx], s[idx:], true
}
// highlightJSON attempts to detect JSON content, pretty-print it, and apply
// syntax highlighting for terminal output. Returns the original string
// unchanged if the content is not valid JSON.
func highlightJSON(s string) string {
trimmed := strings.TrimSpace(s)
if len(trimmed) == 0 {
return s
}
// Strip ANSI codes for JSON detection and parsing
clean := stripANSI(trimmed)
// Extract JSON from the string (may have a non-JSON prefix)
prefix, jsonStr, ok := extractJSON(clean)
if !ok {
return s
}
// Try to pretty-print the JSON portion
var buf bytes.Buffer
if err := json.Indent(&buf, []byte(jsonStr), "", " "); err != nil {
return s
}
pretty := buf.String()
// Highlight with chroma (only the JSON portion)
lexer := lexers.Get("json")
if lexer == nil {
return pretty
}
lexer = chroma.Coalesce(lexer)
style := styles.Get("monokai")
if style == nil {
style = styles.Fallback
}
formatter := formatters.Get("terminal256")
if formatter == nil {
formatter = formatters.Fallback
}
iterator, err := lexer.Tokenise(nil, pretty)
if err != nil {
return pretty
}
var out bytes.Buffer
if err := formatter.Format(&out, style, iterator); err != nil {
return pretty
}
// Re-attach any non-JSON prefix (stripped of ANSI)
result := out.String()
if prefix != "" {
prefix = strings.TrimSpace(prefix)
if prefix != "" {
result = prefix + "\n" + result
}
}
return result
}

View File

@@ -0,0 +1,164 @@
package ui
import (
"strings"
"testing"
)
func TestWrapTextANSI(t *testing.T) {
t.Run("ANSI sequences are not split", func(t *testing.T) {
// Red "ab" then reset: \033[31mab\033[0m
input := "\033[31mabcdef\033[0m"
lines := wrapText(input, 3)
// Should wrap into "abc" and "def", each with proper ANSI
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), lines)
}
// First line should have the color and a reset
if !strings.Contains(lines[0], "\033[31m") {
t.Errorf("first line missing color code: %q", lines[0])
}
if !strings.Contains(lines[0], "abc") {
t.Errorf("first line missing 'abc': %q", lines[0])
}
// Second line should re-apply the color
if !strings.Contains(lines[1], "\033[31m") {
t.Errorf("second line missing re-applied color code: %q", lines[1])
}
if !strings.Contains(lines[1], "def") {
t.Errorf("second line missing 'def': %q", lines[1])
}
})
t.Run("no ANSI still works", func(t *testing.T) {
lines := wrapText("abcdef", 3)
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(lines))
}
if lines[0] != "abc" {
t.Errorf("expected 'abc', got %q", lines[0])
}
if lines[1] != "def" {
t.Errorf("expected 'def', got %q", lines[1])
}
})
t.Run("ANSI reset clears active state", func(t *testing.T) {
// Color "ab", reset, then "cd"
input := "\033[31mab\033[0mcd"
lines := wrapText(input, 2)
// "ab" on first line (with color), "cd" on second (no color re-applied)
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), lines)
}
// Second line should NOT have the red color re-applied
if strings.Contains(lines[1], "\033[31m") {
t.Errorf("second line should not have color after reset: %q", lines[1])
}
})
}
func TestWrapPreviewContentANSI(t *testing.T) {
// Multi-line input with ANSI codes should survive split + wrap
input := "\033[31mhello\033[0m\n\033[32mworld\033[0m"
lines := wrapPreviewContent(input, 80)
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(lines))
}
if !strings.Contains(lines[0], "\033[31m") || !strings.Contains(lines[0], "hello") {
t.Errorf("first line missing color or content: %q", lines[0])
}
if !strings.Contains(lines[1], "\033[32m") || !strings.Contains(lines[1], "world") {
t.Errorf("second line missing color or content: %q", lines[1])
}
}
func TestHighlightJSON(t *testing.T) {
t.Run("valid JSON object is pretty-printed and highlighted", func(t *testing.T) {
input := `{"name":"test","count":42}`
result := highlightJSON(input)
// Should be pretty-printed (multi-line)
if !strings.Contains(result, "\n") {
t.Error("expected multi-line pretty-printed output")
}
// Should contain the key and value
if !strings.Contains(result, "name") {
t.Error("expected output to contain 'name'")
}
if !strings.Contains(result, "test") {
t.Error("expected output to contain 'test'")
}
if !strings.Contains(result, "42") {
t.Error("expected output to contain '42'")
}
// Should contain ANSI escape codes (syntax highlighting)
if !strings.Contains(result, "\033[") {
t.Error("expected ANSI color codes in output")
}
})
t.Run("valid JSON array", func(t *testing.T) {
input := `[1, 2, 3]`
result := highlightJSON(input)
if !strings.Contains(result, "\033[") {
t.Error("expected ANSI color codes for JSON array")
}
})
t.Run("non-JSON returns unchanged", func(t *testing.T) {
input := "hello world"
result := highlightJSON(input)
if result != input {
t.Errorf("expected unchanged output for non-JSON, got %q", result)
}
})
t.Run("invalid JSON returns unchanged", func(t *testing.T) {
input := `{"broken": }`
result := highlightJSON(input)
if result != input {
t.Errorf("expected unchanged output for invalid JSON, got %q", result)
}
})
t.Run("empty string returns unchanged", func(t *testing.T) {
result := highlightJSON("")
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
})
t.Run("whitespace-only returns unchanged", func(t *testing.T) {
input := " "
result := highlightJSON(input)
if result != input {
t.Errorf("expected unchanged output, got %q", result)
}
})
t.Run("JSON with leading ANSI escape sequences", func(t *testing.T) {
input := "\x1b[34h\x1b[?25h{\"key\":\"value\"}"
result := highlightJSON(input)
if !strings.Contains(result, "key") {
t.Error("expected output to contain 'key'")
}
if !strings.Contains(result, "value") {
t.Error("expected output to contain 'value'")
}
if !strings.Contains(result, "\033[") {
t.Error("expected ANSI color codes in output")
}
})
t.Run("JSON with non-JSON prefix text", func(t *testing.T) {
input := `some prefix {"key":"value"}`
result := highlightJSON(input)
if !strings.Contains(result, "key") {
t.Error("expected output to contain 'key'")
}
if !strings.Contains(result, "some prefix") {
t.Error("expected output to preserve prefix")
}
})
}

999
internal/ui/ui.go Normal file → Executable file

File diff suppressed because it is too large Load Diff

581
internal/ui/ui_test.go Normal file → Executable file
View File

@@ -1,8 +1,11 @@
package ui
import (
"context"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/chenasraf/watchr/internal/runner"
)
@@ -16,7 +19,7 @@ func TestConfig(t *testing.T) {
ShowLineNums: true,
LineNumWidth: 6,
Prompt: "watchr> ",
RefreshSeconds: 5,
RefreshInterval: 5 * time.Second,
}
if cfg.Command != "echo test" {
@@ -51,8 +54,8 @@ func TestConfig(t *testing.T) {
t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt)
}
if cfg.RefreshSeconds != 5 {
t.Errorf("expected refresh seconds 5, got %d", cfg.RefreshSeconds)
if cfg.RefreshInterval != 5*time.Second {
t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval)
}
}
@@ -283,3 +286,575 @@ func TestVisibleLines(t *testing.T) {
t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible)
}
}
func TestUpdateFilteredPreservesOffset(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
m.height = 20 // Enough for visibleLines to return > 0
// Add many test lines
for i := 1; i <= 100; i++ {
m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"})
}
// Set initial state with offset
m.filter = ""
m.updateFiltered()
m.offset = 50
m.cursor = 55
// Simulate streaming update - add more lines without changing filter
m.lines = append(m.lines, runner.Line{Number: 101, Content: "new line"})
m.updateFiltered()
// Offset should be preserved (or clamped if necessary)
if m.offset < 50 {
t.Errorf("expected offset to be preserved (>= 50), got %d", m.offset)
}
// Cursor should be preserved
if m.cursor != 55 {
t.Errorf("expected cursor to be preserved at 55, got %d", m.cursor)
}
}
func TestUpdateFilteredClampsOffsetWhenNeeded(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
m.height = 20
// Add test lines
for i := 1; i <= 100; i++ {
m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"})
}
m.filter = ""
m.updateFiltered()
m.offset = 90
m.cursor = 95
// Now filter to fewer lines
m.filter = "xyz" // No matches
m.updateFiltered()
// Offset should be clamped to valid range
if m.offset != 0 {
t.Errorf("expected offset to be clamped to 0, got %d", m.offset)
}
// Cursor should be clamped
if m.cursor != 0 {
t.Errorf("expected cursor to be clamped to 0, got %d", m.cursor)
}
}
func TestConfigRefreshFromStart(t *testing.T) {
// Test with RefreshFromStart false (default)
cfg := Config{
Command: "echo test",
Shell: "sh",
RefreshInterval: 5 * time.Second,
RefreshFromStart: false,
}
if cfg.RefreshFromStart {
t.Error("expected RefreshFromStart to be false by default")
}
// Test with RefreshFromStart true
cfg.RefreshFromStart = true
if !cfg.RefreshFromStart {
t.Error("expected RefreshFromStart to be true after setting")
}
}
func TestModelUserScrolled(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
// Initially should be false
if m.userScrolled {
t.Error("expected userScrolled to be false initially")
}
// After setting, should be true
m.userScrolled = true
if !m.userScrolled {
t.Error("expected userScrolled to be true after setting")
}
}
func TestModelRefreshGeneration(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
// Initially should be 0
if m.refreshGeneration != 0 {
t.Errorf("expected refreshGeneration to be 0 initially, got %d", m.refreshGeneration)
}
// After incrementing
m.refreshGeneration++
if m.refreshGeneration != 1 {
t.Errorf("expected refreshGeneration to be 1 after increment, got %d", m.refreshGeneration)
}
}
func testModel(cfg Config) *model {
m := initialModel(cfg)
return &m
}
func TestFilterCursorMovement(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "hello"
m.filterCursor = 5
// Left arrow moves cursor left
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4 after left, got %d", m.filterCursor)
}
// Left again
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 3 {
t.Errorf("expected filterCursor 3 after second left, got %d", m.filterCursor)
}
// Left doesn't go below 0
m.filterCursor = 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor)
}
// Right arrow moves cursor right
m.filterCursor = 2
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 3 {
t.Errorf("expected filterCursor 3 after right, got %d", m.filterCursor)
}
// Right doesn't go past end
m.filterCursor = 5
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 5 {
t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterCursor)
}
}
func TestFilterAltLeftRight(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
t.Run("alt+left jumps to previous word boundary", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar baz"
m.filterCursor = 11 // end
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 8 {
t.Errorf("expected filterCursor 8, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
// Already at start, stays at 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor)
}
})
t.Run("alt+right jumps to next word boundary", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar baz"
m.filterCursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 8 {
t.Errorf("expected filterCursor 8, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 11 {
t.Errorf("expected filterCursor 11, got %d", m.filterCursor)
}
// Already at end, stays at 11
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 11 {
t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterCursor)
}
})
t.Run("alt+left skips trailing spaces", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar"
m.filterCursor = 6 // middle of spaces, before "bar"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
})
t.Run("alt+right skips trailing spaces", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar"
m.filterCursor = 3 // end of "foo"
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 6 {
t.Errorf("expected filterCursor 6, got %d", m.filterCursor)
}
})
}
func TestFilterInsertAtCursor(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "helo"
m.filterCursor = 3
// Insert 'l' at position 3 -> "hello"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "hello" {
t.Errorf("expected filter 'hello', got %q", m.filter)
}
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
}
func TestFilterBackspaceAtCursor(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "hello"
m.filterCursor = 3
// Backspace at position 3 -> "helo"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "helo" {
t.Errorf("expected filter 'helo', got %q", m.filter)
}
if m.filterCursor != 2 {
t.Errorf("expected filterCursor 2, got %d", m.filterCursor)
}
// Backspace at position 0 does nothing
m.filterCursor = 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "helo" {
t.Errorf("expected filter 'helo' (unchanged), got %q", m.filter)
}
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
}
func TestFilterAltBackspace(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
tests := []struct {
name string
filter string
cursor int
expectedFilter string
expectedCursor int
}{
{"delete last word", "hello world", 11, "hello ", 6},
{"delete middle word", "foo bar baz", 7, "foo baz", 4},
{"delete first word", "hello world", 5, " world", 0},
{"delete with trailing spaces", "hello ", 8, "", 0},
{"cursor at start", "hello", 0, "hello", 0},
{"single word", "hello", 5, "", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = tt.filter
m.filterCursor = tt.cursor
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
newModel := result.(*model)
if newModel.filter != tt.expectedFilter {
t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filter)
}
if newModel.filterCursor != tt.expectedCursor {
t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterCursor)
}
})
}
}
func TestFilterRegexToggle(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = ""
m.filterCursor = 0
// Type '/' on empty filter toggles regex mode on
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if !m.filterRegex {
t.Error("expected filterRegex to be true after typing /")
}
if m.filter != "" {
t.Errorf("expected empty filter, got %q", m.filter)
}
// Type '/' again on empty filter toggles regex mode off
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterRegex {
t.Error("expected filterRegex to be false after second /")
}
// Type '/' when filter is non-empty adds it to filter
m.filterRegex = true
m.filter = "abc"
m.filterCursor = 3
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "abc/" {
t.Errorf("expected filter 'abc/', got %q", m.filter)
}
if !m.filterRegex {
t.Error("expected filterRegex to remain true")
}
}
func TestFilterRegexMatching(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := initialModel(cfg)
m.lines = []runner.Line{
{Number: 1, Content: "hello world"},
{Number: 2, Content: "foo bar"},
{Number: 3, Content: "hello foo"},
{Number: 4, Content: "baz 123 qux"},
}
// Regex filter matching
m.filterRegex = true
m.filter = "hello.*foo"
m.updateFiltered()
if len(m.filtered) != 1 {
t.Errorf("expected 1 match for regex 'hello.*foo', got %d", len(m.filtered))
}
if len(m.filtered) > 0 && m.filtered[0] != 2 {
t.Errorf("expected match at index 2, got %d", m.filtered[0])
}
// Regex with character class
m.filter = "\\d+"
m.updateFiltered()
if len(m.filtered) != 1 {
t.Errorf("expected 1 match for regex '\\d+', got %d", len(m.filtered))
}
// Regex is case insensitive
m.filter = "HELLO"
m.updateFiltered()
if len(m.filtered) != 2 {
t.Errorf("expected 2 matches for case-insensitive regex 'HELLO', got %d", len(m.filtered))
}
}
func TestFilterRegexInvalid(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := initialModel(cfg)
m.lines = []runner.Line{
{Number: 1, Content: "hello world"},
{Number: 2, Content: "foo bar"},
}
m.filterRegex = true
m.filter = "[invalid"
m.updateFiltered()
// Should have an error
if m.filterRegexErr == nil {
t.Error("expected filterRegexErr to be non-nil for invalid regex")
}
// Should show all lines when regex is invalid
if len(m.filtered) != 2 {
t.Errorf("expected all 2 lines shown for invalid regex, got %d", len(m.filtered))
}
// Valid regex clears the error
m.filter = "hello"
m.updateFiltered()
if m.filterRegexErr != nil {
t.Errorf("expected filterRegexErr to be nil for valid regex, got %v", m.filterRegexErr)
}
}
func TestFilterEscClearsRegex(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "test"
m.filterCursor = 4
m.filterRegex = true
// Esc in filter mode clears everything
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterMode {
t.Error("expected filterMode to be false")
}
if m.filter != "" {
t.Errorf("expected empty filter, got %q", m.filter)
}
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
if m.filterRegex {
t.Error("expected filterRegex to be false")
}
}
func TestStopCommandKeybinding(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
t.Run("stops running command when streaming", func(t *testing.T) {
m := initialModel(cfg)
// Set up a cancellable context to track if cancel was called
ctx, cancel := context.WithCancel(context.Background())
m.ctx = ctx
m.cancel = cancel
m.streaming = true
m.statusMsg = ""
// Simulate pressing 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}
result, cmd := m.handleKeyPress(keyMsg)
newModel := result.(*model)
// Should set status message
if newModel.statusMsg != "Command stopped" {
t.Errorf("expected statusMsg 'Command stopped', got %q", newModel.statusMsg)
}
// Should return a command (the tick for clearing status)
if cmd == nil {
t.Error("expected a command to be returned for status message timeout")
}
// Context should be cancelled
select {
case <-ctx.Done():
// Good, context was cancelled
default:
t.Error("expected context to be cancelled")
}
})
t.Run("does nothing when not streaming", func(t *testing.T) {
m := initialModel(cfg)
ctx, cancel := context.WithCancel(context.Background())
m.ctx = ctx
m.cancel = cancel
m.streaming = false
m.statusMsg = ""
// Simulate pressing 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}
result, cmd := m.handleKeyPress(keyMsg)
newModel := result.(*model)
// Should not set status message
if newModel.statusMsg != "" {
t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg)
}
// Should not return a command
if cmd != nil {
t.Error("expected no command to be returned when not streaming")
}
// Context should NOT be cancelled
select {
case <-ctx.Done():
t.Error("expected context to NOT be cancelled when not streaming")
default:
// Good, context is still active
}
})
}

14
main.go Normal file → Executable file
View File

@@ -34,7 +34,9 @@ func main() {
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)")
flag.StringP("refresh", "r", "0", "Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)")
flag.Bool("refresh-from-start", false, "Start refresh timer when command starts (default: when command ends)")
flag.BoolP("interactive", "i", false, "Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)")
printUsage := func(w *os.File) {
_, _ = fmt.Fprintf(w, "Usage: watchr [options] <command to run>\n\n")
@@ -45,6 +47,7 @@ func main() {
flag.CommandLine.SetOutput(os.Stderr)
_, _ = fmt.Fprintf(w, "\nKeybindings:\n")
_, _ = fmt.Fprintf(w, " r, Ctrl-r Reload (re-run command)\n")
_, _ = fmt.Fprintf(w, " c Stop running 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")
@@ -55,6 +58,7 @@ func main() {
_, _ = 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, " Y Yank selected line (plain text)\n")
_, _ = fmt.Fprintf(w, " ? Show help overlay\n")
}
@@ -107,8 +111,10 @@ func main() {
shell := config.GetString(config.KeyShell)
lineNumWidth := config.GetInt(config.KeyLineWidth)
prompt := config.GetString(config.KeyPrompt)
refreshSeconds := config.GetInt(config.KeyRefresh)
refreshInterval := config.GetDuration(config.KeyRefresh)
refreshFromStart := config.GetBool(config.KeyRefreshFromStart)
showLineNums := config.ShowLineNumbers()
interactive := config.GetBool(config.KeyInteractive)
// Parse preview size (e.g., "40" for lines/cols, "40%" for percentage)
previewSizeIsPercent := strings.HasSuffix(previewSize, "%")
@@ -128,7 +134,9 @@ func main() {
ShowLineNums: showLineNums,
LineNumWidth: lineNumWidth,
Prompt: prompt,
RefreshSeconds: refreshSeconds,
RefreshInterval: refreshInterval,
RefreshFromStart: refreshFromStart,
Interactive: interactive,
}
if err := ui.Run(uiConfig); err != nil {

2
version.txt Normal file → Executable file
View File

@@ -1 +1 @@
1.3.0
1.9.0