mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-18 01:29:05 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa0467c07b | ||
| 85437c7ce0 | |||
|
|
cb5b9bb0b7 | ||
| 140574e512 | |||
| 0c6fc521bc | |||
| 9e9e3cd3b3 | |||
| 5174073236 | |||
| 1bd37227c4 | |||
| 561c98ae02 | |||
| 31330513f8 | |||
| 0f67db342f | |||
|
|
68ea034ad8 | ||
| 9df4fb8285 | |||
| b641616e2c | |||
|
|
70aa9a9ee2 | ||
| f520a8b4ed | |||
| 63b45309b7 | |||
| 10a92082b6 | |||
| 8aaf5148ab | |||
| 347ac34094 | |||
| c9cec52c78 | |||
| 1f89f76e74 |
0
.editorconfig
Normal file → Executable file
0
.editorconfig
Normal file → Executable file
0
.github/FUNDING.yml
vendored
Normal file → Executable file
0
.github/FUNDING.yml
vendored
Normal file → Executable file
58
.github/workflows/manual-homebrew-release.yml
vendored
Normal file → Executable file
58
.github/workflows/manual-homebrew-release.yml
vendored
Normal file → Executable file
@@ -3,56 +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 release info
|
||||
id: latest
|
||||
run: |
|
||||
tag=$(gh release view --json tagName -q .tagName)
|
||||
echo "Latest release tag: $tag"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Get release body and escape for JSON
|
||||
body=$(gh release view --json body -q .body)
|
||||
# Use delimiter for multiline output
|
||||
echo "body<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$body" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Send dispatch to homebrew-tap
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.REPO_DISPATCH_PAT }}
|
||||
run: |
|
||||
tag="${{ steps.latest.outputs.tag }}"
|
||||
repo="${{ github.event.repository.name }}"
|
||||
# Use jq to properly escape the body for JSON
|
||||
body=$(cat <<'BODY_EOF'
|
||||
${{ steps.latest.outputs.body }}
|
||||
BODY_EOF
|
||||
)
|
||||
data=$(jq -n \
|
||||
--arg tag "$tag" \
|
||||
--arg repo "$repo" \
|
||||
--arg body "$body" \
|
||||
'{event_type: "trigger-from-release", client_payload: {tag: $tag, repo: $repo, body: $body}}')
|
||||
echo "Dispatching tag $tag from $repo"
|
||||
echo "Data: $data"
|
||||
curl -X POST \
|
||||
-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 }}
|
||||
|
||||
0
.github/workflows/release.yml
vendored
Normal file → Executable file
0
.github/workflows/release.yml
vendored
Normal file → Executable file
0
.github/workflows/test.yml
vendored
Normal file → Executable file
0
.github/workflows/test.yml
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.golangci.yml
Normal file → Executable file
0
.golangci.yml
Normal file → Executable file
0
.prettierrc
Normal file → Executable file
0
.prettierrc
Normal file → Executable file
46
CHANGELOG.md
Normal file → Executable file
46
CHANGELOG.md
Normal file → Executable file
@@ -1,5 +1,51 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
99
README.md
Normal file → Executable file
99
README.md
Normal file → Executable file
@@ -7,13 +7,16 @@ provides vim-style navigation, filtering, and a preview pane—all without leavi
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🚀 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
|
||||
@@ -98,25 +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 string Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)
|
||||
-p, --prompt string Prompt string (default "watchr> ")
|
||||
-s, --shell string Shell to use for executing commands (default "sh")
|
||||
-n, --no-line-numbers Disable line numbers
|
||||
-w, --line-width int Line number width (default 6)
|
||||
-P, --preview-size string Preview size: number for lines/cols, or number% for percentage (default "40%")
|
||||
-o, --preview-position string Preview position: bottom, top, left, right (default "bottom")
|
||||
-i, --interactive Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)
|
||||
-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
|
||||
|
||||
@@ -129,18 +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 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
|
||||
prompt: '> '
|
||||
refresh: 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
|
||||
interactive: false
|
||||
```
|
||||
|
||||
**TOML** (`watchr.toml`):
|
||||
|
||||
```toml
|
||||
shell = "bash"
|
||||
preview-size = "50%"
|
||||
@@ -148,11 +155,12 @@ preview-position = "right"
|
||||
line-numbers = true
|
||||
line-width = 4
|
||||
prompt = "> "
|
||||
refresh = 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
|
||||
refresh = 0 # disabled; or use: 2, 1.5, "500ms", "2s", "5m", "1h"
|
||||
interactive = false
|
||||
```
|
||||
|
||||
**JSON** (`watchr.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": "bash",
|
||||
@@ -167,6 +175,7 @@ interactive = false
|
||||
```
|
||||
|
||||
The `refresh` option accepts:
|
||||
|
||||
- Numbers: `2` or `1.5` (interpreted as seconds)
|
||||
- Explicit units: `"500ms"`, `"2s"`, `"5m"`, `"1h"`
|
||||
|
||||
@@ -183,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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -208,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
2
go.mod
Normal file → Executable 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
4
go.sum
Normal file → Executable 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=
|
||||
|
||||
20
internal/config/config.go
Normal file → Executable file
20
internal/config/config.go
Normal file → Executable file
@@ -15,14 +15,15 @@ import (
|
||||
|
||||
// Config keys
|
||||
const (
|
||||
KeyShell = "shell"
|
||||
KeyPreviewSize = "preview-size"
|
||||
KeyPreviewPosition = "preview-position"
|
||||
KeyLineNumbers = "line-numbers"
|
||||
KeyLineWidth = "line-width"
|
||||
KeyPrompt = "prompt"
|
||||
KeyRefresh = "refresh"
|
||||
KeyInteractive = "interactive"
|
||||
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.
|
||||
@@ -34,6 +35,7 @@ func setDefaults() {
|
||||
viper.SetDefault(KeyLineWidth, 6)
|
||||
viper.SetDefault(KeyPrompt, "watchr> ")
|
||||
viper.SetDefault(KeyRefresh, "0")
|
||||
viper.SetDefault(KeyRefreshFromStart, false)
|
||||
viper.SetDefault(KeyInteractive, false)
|
||||
}
|
||||
|
||||
@@ -79,6 +81,7 @@ 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)
|
||||
@@ -133,6 +136,7 @@ func PrintConfig() {
|
||||
fmt.Printf(" %-20s %d\n", KeyLineWidth+":", GetInt(KeyLineWidth))
|
||||
fmt.Printf(" %-20s %q\n", KeyPrompt+":", GetString(KeyPrompt))
|
||||
fmt.Printf(" %-20s %s\n", KeyRefresh+":", GetString(KeyRefresh))
|
||||
fmt.Printf(" %-20s %v\n", KeyRefreshFromStart+":", GetBool(KeyRefreshFromStart))
|
||||
fmt.Printf(" %-20s %v\n", KeyInteractive+":", GetBool(KeyInteractive))
|
||||
}
|
||||
|
||||
|
||||
105
internal/config/config_test.go
Normal file → Executable file
105
internal/config/config_test.go
Normal file → Executable file
@@ -133,7 +133,9 @@ func TestBindFlags(t *testing.T) {
|
||||
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 custom values
|
||||
err := flags.Parse([]string{"--shell=bash", "--preview-size=50%", "--line-width=8"})
|
||||
@@ -239,7 +241,9 @@ preview-size: "60%"
|
||||
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, "")
|
||||
|
||||
// Override shell via flag
|
||||
err := flags.Parse([]string{"--shell=bash"})
|
||||
@@ -556,6 +560,107 @@ func TestGetDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
52
internal/runner/runner.go
Normal file → Executable file
52
internal/runner/runner.go
Normal file → Executable file
@@ -178,11 +178,13 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
|
||||
|
||||
// StreamingResult holds the state of a streaming command
|
||||
type StreamingResult struct {
|
||||
Lines *[]Line
|
||||
ExitCode int
|
||||
Done bool
|
||||
Error error
|
||||
mu sync.RWMutex
|
||||
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
|
||||
}
|
||||
|
||||
// GetLines returns a copy of the current lines (thread-safe)
|
||||
@@ -214,14 +216,27 @@ func (s *StreamingResult) IsDone() bool {
|
||||
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.
|
||||
func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult {
|
||||
// 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: &[]Line{},
|
||||
ExitCode: -1,
|
||||
Done: false,
|
||||
Lines: &lines,
|
||||
ExitCode: -1,
|
||||
Done: false,
|
||||
PrevLineCount: len(prevLines),
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -268,14 +283,27 @@ func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult {
|
||||
for scanner.Scan() {
|
||||
lineNumMu.Lock()
|
||||
currentLineNum := lineNum
|
||||
lineIdx := lineNum - 1 // 0-indexed
|
||||
lineNum++
|
||||
lineNumMu.Unlock()
|
||||
|
||||
result.mu.Lock()
|
||||
*result.Lines = append(*result.Lines, Line{
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
144
internal/runner/runner_test.go
Normal file → Executable file
144
internal/runner/runner_test.go
Normal file → Executable file
@@ -378,3 +378,147 @@ func TestSanitizeLine(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
97
internal/ui/highlight.go
Normal 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
|
||||
}
|
||||
164
internal/ui/highlight_test.go
Normal file
164
internal/ui/highlight_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
823
internal/ui/ui.go
Normal file → Executable file
823
internal/ui/ui.go
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
574
internal/ui/ui_test.go
Normal file → Executable file
574
internal/ui/ui_test.go
Normal file → Executable file
@@ -1,9 +1,11 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
@@ -284,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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
5
main.go
Normal file → Executable file
5
main.go
Normal file → Executable file
@@ -35,6 +35,7 @@ func main() {
|
||||
flag.StringP("prompt", "p", "watchr> ", "Prompt string")
|
||||
flag.StringP("shell", "s", "sh", "Shell to use for executing commands")
|
||||
flag.StringP("refresh", "r", "0", "Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)")
|
||||
flag.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) {
|
||||
@@ -46,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")
|
||||
@@ -56,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")
|
||||
}
|
||||
|
||||
@@ -109,6 +112,7 @@ func main() {
|
||||
lineNumWidth := config.GetInt(config.KeyLineWidth)
|
||||
prompt := config.GetString(config.KeyPrompt)
|
||||
refreshInterval := config.GetDuration(config.KeyRefresh)
|
||||
refreshFromStart := config.GetBool(config.KeyRefreshFromStart)
|
||||
showLineNums := config.ShowLineNumbers()
|
||||
interactive := config.GetBool(config.KeyInteractive)
|
||||
|
||||
@@ -131,6 +135,7 @@ func main() {
|
||||
LineNumWidth: lineNumWidth,
|
||||
Prompt: prompt,
|
||||
RefreshInterval: refreshInterval,
|
||||
RefreshFromStart: refreshFromStart,
|
||||
Interactive: interactive,
|
||||
}
|
||||
|
||||
|
||||
2
version.txt
Normal file → Executable file
2
version.txt
Normal file → Executable file
@@ -1 +1 @@
|
||||
1.5.2
|
||||
1.9.0
|
||||
|
||||
Reference in New Issue
Block a user