mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-18 01:29:05 +00:00
Compare commits
17 Commits
v1.7.0
...
release-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6740278feb | ||
| 6a2df0a15d | |||
|
|
6dddf7fd2b | ||
| 351aa0331b | |||
| 6b3abbb9d3 | |||
| c4e4d5d0de | |||
|
|
aa0467c07b | ||
| 85437c7ce0 | |||
|
|
cb5b9bb0b7 | ||
| 140574e512 | |||
| 0c6fc521bc | |||
| 9e9e3cd3b3 | |||
| 5174073236 | |||
| 1bd37227c4 | |||
| 561c98ae02 | |||
| 31330513f8 | |||
| 0f67db342f |
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## [1.10.1](https://github.com/chenasraf/watchr/compare/v1.10.0...v1.10.1) (2026-05-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* command re-running caused early line truncating ([6a2df0a](https://github.com/chenasraf/watchr/commit/6a2df0a15d4df8f3e002faef5f2f822c2e0dc772))
|
||||
|
||||
## [1.10.0](https://github.com/chenasraf/watchr/compare/v1.9.0...v1.10.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add reload+clear, delete line, delete all lines ([c4e4d5d](https://github.com/chenasraf/watchr/commit/c4e4d5d0de6bd31db1093c6dd7c67067c7277f23))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* home/end navigation ([351aa03](https://github.com/chenasraf/watchr/commit/351aa0331bfdab61b69d63b46911de21b1feea90))
|
||||
* text cursor visual ([351aa03](https://github.com/chenasraf/watchr/commit/351aa0331bfdab61b69d63b46911de21b1feea90))
|
||||
* text delete word ([351aa03](https://github.com/chenasraf/watchr/commit/351aa0331bfdab61b69d63b46911de21b1feea90))
|
||||
* unify all text input behavior, fix cursor navigations ([351aa03](https://github.com/chenasraf/watchr/commit/351aa0331bfdab61b69d63b46911de21b1feea90))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
100
README.md
100
README.md
@@ -14,8 +14,9 @@ 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
|
||||
@@ -100,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
|
||||
|
||||
@@ -131,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%"
|
||||
@@ -150,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",
|
||||
@@ -169,6 +175,7 @@ interactive = false
|
||||
```
|
||||
|
||||
The `refresh` option accepts:
|
||||
|
||||
- Numbers: `2` or `1.5` (interpreted as seconds)
|
||||
- Explicit units: `"500ms"`, `"2s"`, `"5m"`, `"1h"`
|
||||
|
||||
@@ -185,21 +192,44 @@ 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) |
|
||||
| `R` | Reload & clear all lines |
|
||||
| `d`, `Del` | Delete selected line |
|
||||
| `D` | Clear all lines |
|
||||
| `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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +240,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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
152
internal/ui/actions.go
Normal file
152
internal/ui/actions.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func (m *model) actionReload() (tea.Model, tea.Cmd) {
|
||||
m.refreshGeneration++
|
||||
cmd := m.startStreaming()
|
||||
return m, tea.Batch(cmd, m.spinnerTickCmd())
|
||||
}
|
||||
|
||||
func (m *model) actionReloadClear() (tea.Model, tea.Cmd) {
|
||||
m.lines = nil
|
||||
m.updateFiltered()
|
||||
return m.actionReload()
|
||||
}
|
||||
|
||||
func (m *model) actionDeleteLine() (tea.Model, tea.Cmd) {
|
||||
if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) {
|
||||
idx := m.filtered[m.cursor]
|
||||
if idx < len(m.lines) {
|
||||
m.lines = append(m.lines[:idx], m.lines[idx+1:]...)
|
||||
m.updateFiltered()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionClearAllLines() (tea.Model, tea.Cmd) {
|
||||
m.confirmMode = true
|
||||
m.confirmMessage = "Clear all lines? (y/N)"
|
||||
m.confirmAction = func(m *model) (tea.Model, tea.Cmd) {
|
||||
m.lines = nil
|
||||
m.updateFiltered()
|
||||
m.statusMsg = "All lines cleared"
|
||||
return m, m.statusTimeoutCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionStopCommand() (tea.Model, tea.Cmd) {
|
||||
if m.streaming {
|
||||
m.cancel()
|
||||
m.statusMsg = "Command stopped"
|
||||
return m, m.statusTimeoutCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionTogglePreview() (tea.Model, tea.Cmd) {
|
||||
m.showPreview = !m.showPreview
|
||||
m.adjustOffset()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionIncreasePreview() (tea.Model, tea.Cmd) {
|
||||
if m.showPreview {
|
||||
m.config.PreviewSize += previewSizeStep(m.config.PreviewSizeIsPercent)
|
||||
m.adjustOffset()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionDecreasePreview() (tea.Model, tea.Cmd) {
|
||||
if m.showPreview {
|
||||
step := previewSizeStep(m.config.PreviewSizeIsPercent)
|
||||
if m.config.PreviewSize > step {
|
||||
m.config.PreviewSize -= step
|
||||
m.adjustOffset()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionGoToFirst() (tea.Model, tea.Cmd) {
|
||||
m.userScrolled = true
|
||||
m.previewOffset = 0
|
||||
m.cursor = 0
|
||||
m.offset = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionGoToLast() (tea.Model, tea.Cmd) {
|
||||
m.userScrolled = false
|
||||
m.previewOffset = 0
|
||||
if len(m.filtered) > 0 {
|
||||
m.cursor = len(m.filtered) - 1
|
||||
m.adjustOffset()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionEnterFilter() (tea.Model, tea.Cmd) {
|
||||
m.filterMode = true
|
||||
m.filterInput.Cursor = len(m.filterInput.Text)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionToggleRegexFilter() (tea.Model, tea.Cmd) {
|
||||
m.filterMode = true
|
||||
m.filterRegex = !m.filterRegex
|
||||
m.filterRegexErr = nil
|
||||
m.filterInput.Cursor = len(m.filterInput.Text)
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionCopyLine(plain bool) (tea.Model, tea.Cmd) {
|
||||
if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) {
|
||||
idx := m.filtered[m.cursor]
|
||||
if idx < len(m.lines) {
|
||||
content := m.lines[idx].Content
|
||||
if plain {
|
||||
content = stripANSI(content)
|
||||
}
|
||||
if err := copyToClipboard(content); err != nil {
|
||||
m.statusMsg = "Failed to copy"
|
||||
} else if plain {
|
||||
m.statusMsg = "Copied to clipboard (plain)"
|
||||
} else {
|
||||
m.statusMsg = "Copied to clipboard"
|
||||
}
|
||||
return m, m.statusTimeoutCmd()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionShowHelp() (tea.Model, tea.Cmd) {
|
||||
m.showHelp = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) actionQuit() (tea.Model, tea.Cmd) {
|
||||
m.cancel()
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
func (m *model) actionOpenPalette() (tea.Model, tea.Cmd) {
|
||||
m.cmdPaletteMode = true
|
||||
m.cmdPaletteInput.clear()
|
||||
m.cmdPaletteSelected = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// statusTimeoutCmd returns a command that clears the status message after 2 seconds.
|
||||
func (m model) statusTimeoutCmd() tea.Cmd {
|
||||
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearStatusMsg{} })
|
||||
}
|
||||
197
internal/ui/actions_test.go
Normal file
197
internal/ui/actions_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestActionReload(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
_, cmd := m.actionReload()
|
||||
if m.refreshGeneration != 1 {
|
||||
t.Errorf("expected refreshGeneration 1, got %d", m.refreshGeneration)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected command to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionReloadClear(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
originalLines := len(m.lines)
|
||||
if originalLines == 0 {
|
||||
t.Fatal("expected lines to be populated")
|
||||
}
|
||||
|
||||
_, cmd := m.actionReloadClear()
|
||||
if m.lines != nil {
|
||||
t.Errorf("expected lines nil, got %d", len(m.lines))
|
||||
}
|
||||
if m.refreshGeneration != 1 {
|
||||
t.Errorf("expected refreshGeneration 1, got %d", m.refreshGeneration)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected command to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionDeleteLine(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 1 // "foo bar"
|
||||
m.actionDeleteLine()
|
||||
|
||||
for _, line := range m.lines {
|
||||
if line.Content == "foo bar" {
|
||||
t.Error("expected 'foo bar' to be deleted")
|
||||
}
|
||||
}
|
||||
if len(m.lines) != 3 {
|
||||
t.Errorf("expected 3 lines, got %d", len(m.lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionDeleteLineEmpty(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
// Should not panic
|
||||
m.actionDeleteLine()
|
||||
}
|
||||
|
||||
func TestActionClearAllLines(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionClearAllLines()
|
||||
|
||||
if !m.confirmMode {
|
||||
t.Error("expected confirmMode true")
|
||||
}
|
||||
if m.confirmMessage != "Clear all lines? (y/N)" {
|
||||
t.Errorf("expected confirm message, got %q", m.confirmMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionStopCommand(t *testing.T) {
|
||||
m := testModelWithCancel()
|
||||
m.streaming = true
|
||||
|
||||
_, cmd := m.actionStopCommand()
|
||||
if m.statusMsg != "Command stopped" {
|
||||
t.Errorf("expected 'Command stopped', got %q", m.statusMsg)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected timeout command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionStopCommandNotStreaming(t *testing.T) {
|
||||
m := testModelWithCancel()
|
||||
m.streaming = false
|
||||
|
||||
_, cmd := m.actionStopCommand()
|
||||
if m.statusMsg != "" {
|
||||
t.Errorf("expected empty status, got %q", m.statusMsg)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("expected nil command when not streaming")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionTogglePreview(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = false
|
||||
|
||||
m.actionTogglePreview()
|
||||
if !m.showPreview {
|
||||
t.Error("expected showPreview true")
|
||||
}
|
||||
|
||||
m.actionTogglePreview()
|
||||
if m.showPreview {
|
||||
t.Error("expected showPreview false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPreviewResize(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = true
|
||||
m.config.PreviewSize = 10
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
|
||||
m.actionIncreasePreview()
|
||||
if m.config.PreviewSize != 12 {
|
||||
t.Errorf("expected 12, got %d", m.config.PreviewSize)
|
||||
}
|
||||
|
||||
m.actionDecreasePreview()
|
||||
if m.config.PreviewSize != 10 {
|
||||
t.Errorf("expected 10, got %d", m.config.PreviewSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGoToFirstLast(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 2
|
||||
|
||||
m.actionGoToFirst()
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor 0, got %d", m.cursor)
|
||||
}
|
||||
|
||||
m.actionGoToLast()
|
||||
if m.cursor != len(m.filtered)-1 {
|
||||
t.Errorf("expected cursor %d, got %d", len(m.filtered)-1, m.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionCopyLine(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 0
|
||||
|
||||
// Test copy (may succeed or fail depending on clipboard availability)
|
||||
_, cmd := m.actionCopyLine(false)
|
||||
if m.statusMsg == "" {
|
||||
t.Error("expected statusMsg to be set")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected timeout command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionCopyLinePlain(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.lines = []runner.Line{{Number: 1, Content: "\x1b[31mred\x1b[0m"}}
|
||||
m.updateFiltered()
|
||||
m.cursor = 0
|
||||
|
||||
_, cmd := m.actionCopyLine(true)
|
||||
if m.statusMsg == "" {
|
||||
t.Error("expected statusMsg to be set")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected timeout command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionShowHelp(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionShowHelp()
|
||||
if !m.showHelp {
|
||||
t.Error("expected showHelp true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionOpenPalette(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionOpenPalette()
|
||||
if !m.cmdPaletteMode {
|
||||
t.Error("expected cmdPaletteMode true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionEnterFilter(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionEnterFilter()
|
||||
if !m.filterMode {
|
||||
t.Error("expected filterMode true")
|
||||
}
|
||||
}
|
||||
79
internal/ui/commands.go
Normal file
79
internal/ui/commands.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// command represents a command palette entry
|
||||
type command struct {
|
||||
name string // display name
|
||||
shortcut string // keybinding hint
|
||||
action func(m *model) (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
// commands returns the list of available command palette entries.
|
||||
func commands() []command {
|
||||
return []command{
|
||||
{"Reload command", "r / Ctrl+r", (*model).actionReload},
|
||||
{"Reload & clear lines", "R", (*model).actionReloadClear},
|
||||
{"Delete selected line", "d / Del", (*model).actionDeleteLine},
|
||||
{"Clear all lines", "D", (*model).actionClearAllLines},
|
||||
{"Stop running command", "c", (*model).actionStopCommand},
|
||||
{"Toggle preview pane", "p", (*model).actionTogglePreview},
|
||||
{"Increase preview size", "+", (*model).actionIncreasePreview},
|
||||
{"Decrease preview size", "-", (*model).actionDecreasePreview},
|
||||
{"Go to first line", "g", (*model).actionGoToFirst},
|
||||
{"Go to last line", "G", (*model).actionGoToLast},
|
||||
{"Enter filter mode", "/", (*model).actionEnterFilter},
|
||||
{"Toggle regex filter", "//", (*model).actionToggleRegexFilter},
|
||||
{"Copy line to clipboard", "y", func(m *model) (tea.Model, tea.Cmd) { return m.actionCopyLine(false) }},
|
||||
{"Copy line (plain text)", "Y", func(m *model) (tea.Model, tea.Cmd) { return m.actionCopyLine(true) }},
|
||||
{"Show help", "?", (*model).actionShowHelp},
|
||||
{"Quit", "q", (*model).actionQuit},
|
||||
}
|
||||
}
|
||||
|
||||
// filteredCommands returns commands matching the current palette filter.
|
||||
func (m *model) filteredCommands() []command {
|
||||
all := commands()
|
||||
if m.cmdPaletteInput.Text == "" {
|
||||
return all
|
||||
}
|
||||
filter := strings.ToLower(m.cmdPaletteInput.Text)
|
||||
var result []command
|
||||
for _, c := range all {
|
||||
if strings.Contains(strings.ToLower(c.name), filter) {
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// copyToClipboard copies text to the system clipboard using OS-specific commands
|
||||
func copyToClipboard(text string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("pbcopy")
|
||||
case "linux":
|
||||
// Try xclip first, fall back to xsel
|
||||
if _, err := exec.LookPath("xclip"); err == nil {
|
||||
cmd = exec.Command("xclip", "-selection", "clipboard")
|
||||
} else {
|
||||
cmd = exec.Command("xsel", "--clipboard", "--input")
|
||||
}
|
||||
case "windows":
|
||||
cmd = exec.Command("clip")
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
59
internal/ui/commands_test.go
Normal file
59
internal/ui/commands_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandsCount(t *testing.T) {
|
||||
cmds := commands()
|
||||
if len(cmds) != 16 {
|
||||
t.Errorf("expected 16 commands, got %d", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredCommandsNoFilter(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cmdPaletteInput.Text = ""
|
||||
filtered := m.filteredCommands()
|
||||
all := commands()
|
||||
if len(filtered) != len(all) {
|
||||
t.Errorf("expected %d commands with no filter, got %d", len(all), len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredCommandsWithFilter(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cmdPaletteInput.Text = "reload"
|
||||
filtered := m.filteredCommands()
|
||||
// "Reload command" and "Reload & clear lines"
|
||||
if len(filtered) != 2 {
|
||||
t.Errorf("expected 2 commands matching 'reload', got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredCommandsCaseInsensitive(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cmdPaletteInput.Text = "QUIT"
|
||||
filtered := m.filteredCommands()
|
||||
if len(filtered) != 1 {
|
||||
t.Errorf("expected 1 command matching 'QUIT', got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteTogglePreview(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = false
|
||||
|
||||
// Find and execute "Toggle preview pane" command
|
||||
cmds := commands()
|
||||
for _, cmd := range cmds {
|
||||
if cmd.name == "Toggle preview pane" {
|
||||
cmd.action(m)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !m.showPreview {
|
||||
t.Error("expected showPreview true after toggle command")
|
||||
}
|
||||
}
|
||||
36
internal/ui/helpers_test.go
Normal file
36
internal/ui/helpers_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func testModel(cfg Config) *model {
|
||||
m := initialModel(cfg)
|
||||
return &m
|
||||
}
|
||||
|
||||
func testModelWithLines() *model {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "hello world"},
|
||||
{Number: 2, Content: "foo bar"},
|
||||
{Number: 3, Content: "hello foo"},
|
||||
{Number: 4, Content: "baz qux"},
|
||||
}
|
||||
m.height = 30
|
||||
m.width = 80
|
||||
m.updateFiltered()
|
||||
return m
|
||||
}
|
||||
|
||||
func testModelWithCancel() *model {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := initialModel(cfg)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.ctx = ctx
|
||||
m.cancel = cancel
|
||||
return &m
|
||||
}
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
180
internal/ui/keys.go
Normal file
180
internal/ui/keys.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.showHelp {
|
||||
return m.handleHelpMode(msg)
|
||||
}
|
||||
if m.confirmMode {
|
||||
return m.handleConfirmMode(msg)
|
||||
}
|
||||
if m.cmdPaletteMode {
|
||||
return m.handleCmdPaletteMode(msg)
|
||||
}
|
||||
if m.filterMode {
|
||||
return m.handleFilterMode(msg)
|
||||
}
|
||||
return m.handleNormalMode(msg)
|
||||
}
|
||||
|
||||
func (m *model) handleHelpMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "?", "esc", "q", "enter":
|
||||
m.showHelp = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleConfirmMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
m.confirmMode = false
|
||||
if m.confirmAction != nil {
|
||||
return m.confirmAction(m)
|
||||
}
|
||||
default:
|
||||
m.confirmMode = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleCmdPaletteMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
m.cmdPaletteMode = false
|
||||
m.cmdPaletteInput.clear()
|
||||
m.cmdPaletteSelected = 0
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
filtered := m.filteredCommands()
|
||||
if len(filtered) > 0 && m.cmdPaletteSelected < len(filtered) {
|
||||
m.cmdPaletteMode = false
|
||||
cmd := filtered[m.cmdPaletteSelected]
|
||||
m.cmdPaletteInput.clear()
|
||||
m.cmdPaletteSelected = 0
|
||||
return cmd.action(m)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyUp:
|
||||
if m.cmdPaletteSelected > 0 {
|
||||
m.cmdPaletteSelected--
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyDown:
|
||||
filtered := m.filteredCommands()
|
||||
if m.cmdPaletteSelected < len(filtered)-1 {
|
||||
m.cmdPaletteSelected++
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
if m.cmdPaletteInput.handleKey(msg) {
|
||||
m.cmdPaletteSelected = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
m.filterMode = false
|
||||
m.filterInput.clear()
|
||||
m.filterRegex = false
|
||||
m.filterRegexErr = nil
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
m.filterMode = false
|
||||
return m, nil
|
||||
default:
|
||||
// Special case: "/" on empty filter toggles regex mode
|
||||
if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 && string(msg.Runes) == "/" && m.filterInput.Text == "" {
|
||||
m.filterRegex = !m.filterRegex
|
||||
m.filterRegexErr = nil
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
}
|
||||
m.filterInput.handleKey(msg)
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m.actionQuit()
|
||||
case "esc":
|
||||
if m.filterInput.Text != "" || m.filterRegex {
|
||||
m.filterInput.clear()
|
||||
m.filterRegex = false
|
||||
m.filterRegexErr = nil
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
}
|
||||
return m.actionQuit()
|
||||
|
||||
case "j", "down", "ctrl+n":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(1)
|
||||
case "k", "up", "ctrl+p":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(-1)
|
||||
case "g", "home":
|
||||
return m.actionGoToFirst()
|
||||
case "G", "end":
|
||||
return m.actionGoToLast()
|
||||
case "ctrl+d":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(m.visibleLines() / 2)
|
||||
case "ctrl+u":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(-m.visibleLines() / 2)
|
||||
case "J":
|
||||
if m.showPreview {
|
||||
m.previewOffset++
|
||||
m.clampPreviewOffset()
|
||||
}
|
||||
case "K":
|
||||
if m.showPreview && m.previewOffset > 0 {
|
||||
m.previewOffset--
|
||||
}
|
||||
case "pgdown", "ctrl+f":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(m.visibleLines())
|
||||
case "pgup", "ctrl+b":
|
||||
m.userScrolled = true
|
||||
m.moveCursor(-m.visibleLines())
|
||||
case "p":
|
||||
return m.actionTogglePreview()
|
||||
case "+", "=":
|
||||
return m.actionIncreasePreview()
|
||||
case "-":
|
||||
return m.actionDecreasePreview()
|
||||
case "r", "ctrl+r":
|
||||
return m.actionReload()
|
||||
case "R":
|
||||
return m.actionReloadClear()
|
||||
case "d", "delete":
|
||||
return m.actionDeleteLine()
|
||||
case "D":
|
||||
return m.actionClearAllLines()
|
||||
case "c":
|
||||
return m.actionStopCommand()
|
||||
case "/":
|
||||
return m.actionEnterFilter()
|
||||
case ":":
|
||||
return m.actionOpenPalette()
|
||||
case "?":
|
||||
return m.actionShowHelp()
|
||||
case "y":
|
||||
return m.actionCopyLine(false)
|
||||
case "Y":
|
||||
return m.actionCopyLine(true)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
793
internal/ui/keys_test.go
Normal file
793
internal/ui/keys_test.go
Normal file
@@ -0,0 +1,793 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestFilterCursorMovement(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = "hello"
|
||||
m.filterInput.Cursor = 5
|
||||
|
||||
// Left arrow moves cursor left
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 4 {
|
||||
t.Errorf("expected filterCursor 4 after left, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
// Left again
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 3 {
|
||||
t.Errorf("expected filterCursor 3 after second left, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
// Left doesn't go below 0
|
||||
m.filterInput.Cursor = 0
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 0 {
|
||||
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
// Right arrow moves cursor right
|
||||
m.filterInput.Cursor = 2
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 3 {
|
||||
t.Errorf("expected filterCursor 3 after right, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
// Right doesn't go past end
|
||||
m.filterInput.Cursor = 5
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 5 {
|
||||
t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterInput.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
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.filterInput.Text = "foo bar baz"
|
||||
m.filterInput.Cursor = 11 // end
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 8 {
|
||||
t.Errorf("expected filterCursor 8, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 4 {
|
||||
t.Errorf("expected filterCursor 4, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 0 {
|
||||
t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
// Already at start, stays at 0
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 0 {
|
||||
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterInput.Cursor)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alt+right jumps to next word boundary", func(t *testing.T) {
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = "foo bar baz"
|
||||
m.filterInput.Cursor = 0
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 4 {
|
||||
t.Errorf("expected filterCursor 4, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 8 {
|
||||
t.Errorf("expected filterCursor 8, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 11 {
|
||||
t.Errorf("expected filterCursor 11, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
// Already at end, stays at 11
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 11 {
|
||||
t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterInput.Cursor)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alt+left skips trailing spaces", func(t *testing.T) {
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = "foo bar"
|
||||
m.filterInput.Cursor = 6 // middle of spaces, before "bar"
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 0 {
|
||||
t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alt+right skips trailing spaces", func(t *testing.T) {
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = "foo bar"
|
||||
m.filterInput.Cursor = 3 // end of "foo"
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Cursor != 6 {
|
||||
t.Errorf("expected filterCursor 6, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterInsertAtCursor(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = "helo"
|
||||
m.filterInput.Cursor = 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.filterInput.Text != "hello" {
|
||||
t.Errorf("expected filter 'hello', got %q", m.filterInput.Text)
|
||||
}
|
||||
if m.filterInput.Cursor != 4 {
|
||||
t.Errorf("expected filterCursor 4, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterBackspaceAtCursor(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = "hello"
|
||||
m.filterInput.Cursor = 3
|
||||
|
||||
// Backspace at position 3 -> "helo"
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Text != "helo" {
|
||||
t.Errorf("expected filter 'helo', got %q", m.filterInput.Text)
|
||||
}
|
||||
if m.filterInput.Cursor != 2 {
|
||||
t.Errorf("expected filterCursor 2, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
|
||||
// Backspace at position 0 does nothing
|
||||
m.filterInput.Cursor = 0
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Text != "helo" {
|
||||
t.Errorf("expected filter 'helo' (unchanged), got %q", m.filterInput.Text)
|
||||
}
|
||||
if m.filterInput.Cursor != 0 {
|
||||
t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
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.filterInput.Text = tt.filter
|
||||
m.filterInput.Cursor = tt.cursor
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.filterInput.Text != tt.expectedFilter {
|
||||
t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filterInput.Text)
|
||||
}
|
||||
if newModel.filterInput.Cursor != tt.expectedCursor {
|
||||
t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterInput.Cursor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRegexToggle(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = ""
|
||||
m.filterInput.Cursor = 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.filterInput.Text != "" {
|
||||
t.Errorf("expected empty filter, got %q", m.filterInput.Text)
|
||||
}
|
||||
|
||||
// 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.filterInput.Text = "abc"
|
||||
m.filterInput.Cursor = 3
|
||||
result, _ = m.handleKeyPress(keyMsg)
|
||||
m = result.(*model)
|
||||
if m.filterInput.Text != "abc/" {
|
||||
t.Errorf("expected filter 'abc/', got %q", m.filterInput.Text)
|
||||
}
|
||||
if !m.filterRegex {
|
||||
t.Error("expected filterRegex to remain true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEscClearsRegex(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.filterInput.Text = "test"
|
||||
m.filterInput.Cursor = 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.filterInput.Text != "" {
|
||||
t.Errorf("expected empty filter, got %q", m.filterInput.Text)
|
||||
}
|
||||
if m.filterInput.Cursor != 0 {
|
||||
t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor)
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// New keybinding tests
|
||||
|
||||
func TestKeyReloadClear(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
if len(m.lines) == 0 {
|
||||
t.Fatal("expected lines to be populated")
|
||||
}
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'R'}}
|
||||
result, cmd := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.lines != nil {
|
||||
t.Errorf("expected lines to be nil after R, got %d lines", len(newModel.lines))
|
||||
}
|
||||
if newModel.refreshGeneration != 1 {
|
||||
t.Errorf("expected refreshGeneration 1, got %d", newModel.refreshGeneration)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyDelete(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
originalLen := len(m.lines)
|
||||
m.cursor = 1 // select "foo bar"
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyDelete}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if len(newModel.lines) != originalLen-1 {
|
||||
t.Errorf("expected %d lines after delete, got %d", originalLen-1, len(newModel.lines))
|
||||
}
|
||||
// The second line ("foo bar") should be gone
|
||||
for _, line := range newModel.lines {
|
||||
if line.Content == "foo bar" {
|
||||
t.Error("expected 'foo bar' to be deleted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyDeleteEmpty(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.height = 30
|
||||
m.width = 80
|
||||
|
||||
// No lines, should not panic
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyDelete}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if len(newModel.lines) != 0 {
|
||||
t.Errorf("expected 0 lines, got %d", len(newModel.lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyCtrlDelete(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
// ctrl+delete is hard to simulate via tea.KeyMsg, so test the confirm flow directly
|
||||
m.confirmMode = true
|
||||
m.confirmMessage = "Clear all lines? (y/N)"
|
||||
m.confirmAction = func(m *model) (tea.Model, tea.Cmd) {
|
||||
m.lines = nil
|
||||
m.updateFiltered()
|
||||
m.statusMsg = "All lines cleared"
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Test confirmation with 'y'
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if newModel.lines != nil {
|
||||
t.Errorf("expected lines to be nil after confirm, got %d", len(newModel.lines))
|
||||
}
|
||||
if newModel.statusMsg != "All lines cleared" {
|
||||
t.Errorf("expected status 'All lines cleared', got %q", newModel.statusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyConfirmDialog(t *testing.T) {
|
||||
t.Run("y confirms action", func(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
confirmed := false
|
||||
m.confirmMode = true
|
||||
m.confirmMessage = "Test?"
|
||||
m.confirmAction = func(m *model) (tea.Model, tea.Cmd) {
|
||||
confirmed = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if !confirmed {
|
||||
t.Error("expected confirm action to be called")
|
||||
}
|
||||
if newModel.confirmMode {
|
||||
t.Error("expected confirmMode to be false after confirm")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other key cancels", func(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
confirmed := false
|
||||
m.confirmMode = true
|
||||
m.confirmMessage = "Test?"
|
||||
m.confirmAction = func(m *model) (tea.Model, tea.Cmd) {
|
||||
confirmed = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if confirmed {
|
||||
t.Error("expected confirm action NOT to be called")
|
||||
}
|
||||
if newModel.confirmMode {
|
||||
t.Error("expected confirmMode to be false after cancel")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyGoToFirstLast(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 2
|
||||
|
||||
// Go to first line with 'g'
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.cursor != 0 {
|
||||
t.Errorf("expected cursor 0 after 'g', got %d", newModel.cursor)
|
||||
}
|
||||
if newModel.offset != 0 {
|
||||
t.Errorf("expected offset 0 after 'g', got %d", newModel.offset)
|
||||
}
|
||||
if !newModel.userScrolled {
|
||||
t.Error("expected userScrolled true after 'g'")
|
||||
}
|
||||
|
||||
// Go to last line with 'G'
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
|
||||
if newModel.cursor != len(newModel.filtered)-1 {
|
||||
t.Errorf("expected cursor at last line (%d), got %d", len(newModel.filtered)-1, newModel.cursor)
|
||||
}
|
||||
if newModel.userScrolled {
|
||||
t.Error("expected userScrolled false after 'G' (resume following)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPreviewScrollJK(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = true
|
||||
m.config.PreviewSize = 5
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
m.config.PreviewPosition = PreviewBottom
|
||||
|
||||
// J scrolls preview down
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
// previewOffset might be clamped to 0 for short content, but the code path runs
|
||||
if newModel.previewOffset < 0 {
|
||||
t.Errorf("expected previewOffset >= 0, got %d", newModel.previewOffset)
|
||||
}
|
||||
|
||||
// Set offset to 1 and test K scrolls up
|
||||
newModel.previewOffset = 1
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'K'}}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.previewOffset != 0 {
|
||||
t.Errorf("expected previewOffset 0 after K, got %d", newModel.previewOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPreviewScrollJKWithoutPreview(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = false
|
||||
|
||||
// J should not change previewOffset when preview is hidden
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if newModel.previewOffset != 0 {
|
||||
t.Errorf("expected previewOffset 0 when preview hidden, got %d", newModel.previewOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyTogglePreview(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
if m.showPreview {
|
||||
t.Fatal("expected showPreview false initially")
|
||||
}
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if !newModel.showPreview {
|
||||
t.Error("expected showPreview true after 'p'")
|
||||
}
|
||||
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.showPreview {
|
||||
t.Error("expected showPreview false after second 'p'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyResizePreview(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = true
|
||||
m.config.PreviewSize = 10
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
|
||||
// '+' increases
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if newModel.config.PreviewSize != 12 {
|
||||
t.Errorf("expected PreviewSize 12 after '+', got %d", newModel.config.PreviewSize)
|
||||
}
|
||||
|
||||
// '-' decreases
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.config.PreviewSize != 10 {
|
||||
t.Errorf("expected PreviewSize 10 after '-', got %d", newModel.config.PreviewSize)
|
||||
}
|
||||
|
||||
// '-' doesn't go below step
|
||||
newModel.config.PreviewSize = 2
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.config.PreviewSize != 2 {
|
||||
t.Errorf("expected PreviewSize 2 (minimum), got %d", newModel.config.PreviewSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyCmdPaletteOpenAndNav(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
|
||||
// ':' opens command palette
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if !newModel.cmdPaletteMode {
|
||||
t.Error("expected cmdPaletteMode true after ':'")
|
||||
}
|
||||
|
||||
// Down arrow moves selection
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.cmdPaletteSelected != 1 {
|
||||
t.Errorf("expected cmdPaletteSelected 1, got %d", newModel.cmdPaletteSelected)
|
||||
}
|
||||
|
||||
// Up arrow moves selection back
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.cmdPaletteSelected != 0 {
|
||||
t.Errorf("expected cmdPaletteSelected 0, got %d", newModel.cmdPaletteSelected)
|
||||
}
|
||||
|
||||
// Typing filters
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.cmdPaletteInput.Text != "r" {
|
||||
t.Errorf("expected cmdPaletteFilter 'r', got %q", newModel.cmdPaletteInput.Text)
|
||||
}
|
||||
|
||||
// Esc closes palette
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyEsc}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.cmdPaletteMode {
|
||||
t.Error("expected cmdPaletteMode false after Esc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyHelpToggle(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
|
||||
// '?' opens help
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if !newModel.showHelp {
|
||||
t.Error("expected showHelp true after '?'")
|
||||
}
|
||||
|
||||
// '?' closes help
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.showHelp {
|
||||
t.Error("expected showHelp false after second '?'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyQuit(t *testing.T) {
|
||||
m := testModelWithCancel()
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
|
||||
_, cmd := m.handleKeyPress(keyMsg)
|
||||
|
||||
if cmd == nil {
|
||||
t.Error("expected quit command to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFilterMode(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
|
||||
// '/' enters filter mode
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if !newModel.filterMode {
|
||||
t.Error("expected filterMode true after '/'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyResizePreviewNoEffect(t *testing.T) {
|
||||
// '+' and '-' do nothing when preview is not shown
|
||||
m := testModelWithLines()
|
||||
m.showPreview = false
|
||||
m.config.PreviewSize = 10
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if newModel.config.PreviewSize != 10 {
|
||||
t.Errorf("expected PreviewSize unchanged at 10, got %d", newModel.config.PreviewSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyNavigationJK(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 0
|
||||
|
||||
// 'j' moves down
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}
|
||||
result, _ := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
if newModel.cursor != 1 {
|
||||
t.Errorf("expected cursor 1 after 'j', got %d", newModel.cursor)
|
||||
}
|
||||
if !newModel.userScrolled {
|
||||
t.Error("expected userScrolled true after 'j'")
|
||||
}
|
||||
|
||||
// 'k' moves up
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}
|
||||
result, _ = newModel.handleKeyPress(keyMsg)
|
||||
newModel = result.(*model)
|
||||
if newModel.cursor != 0 {
|
||||
t.Errorf("expected cursor 0 after 'k', got %d", newModel.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyYank(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 0
|
||||
|
||||
// 'y' should set a status message (clipboard may or may not work in test env)
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}
|
||||
result, cmd := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
// Should have set some status message (either success or failure)
|
||||
if newModel.statusMsg == "" {
|
||||
t.Error("expected statusMsg to be set after 'y'")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command for status timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyYankPlain(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "\x1b[31mred text\x1b[0m"},
|
||||
}
|
||||
m.updateFiltered()
|
||||
m.cursor = 0
|
||||
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Y'}}
|
||||
result, cmd := m.handleKeyPress(keyMsg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.statusMsg == "" {
|
||||
t.Error("expected statusMsg to be set after 'Y'")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command for status timeout")
|
||||
}
|
||||
}
|
||||
161
internal/ui/layout.go
Normal file
161
internal/ui/layout.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m *model) moveCursor(delta int) {
|
||||
m.previewOffset = 0
|
||||
m.cursor += delta
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
if m.cursor >= len(m.filtered) {
|
||||
m.cursor = len(m.filtered) - 1
|
||||
}
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
m.adjustOffset()
|
||||
}
|
||||
|
||||
func (m *model) adjustOffset() {
|
||||
visible := m.visibleLines()
|
||||
if visible <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to center the cursor
|
||||
idealOffset := m.cursor - visible/2
|
||||
|
||||
// Clamp to valid range
|
||||
idealOffset = max(idealOffset, 0)
|
||||
maxOffset := max(len(m.filtered)-visible, 0)
|
||||
idealOffset = min(idealOffset, maxOffset)
|
||||
|
||||
m.offset = idealOffset
|
||||
}
|
||||
|
||||
func previewSizeStep(isPercent bool) int {
|
||||
if isPercent {
|
||||
return 5
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// clampPreviewOffset computes the actual preview content size and clamps
|
||||
// previewOffset so it can't exceed the scrollable range.
|
||||
func (m *model) clampPreviewOffset() {
|
||||
if !m.showPreview || m.cursor < 0 || m.cursor >= len(m.filtered) {
|
||||
m.previewOffset = 0
|
||||
return
|
||||
}
|
||||
idx := m.filtered[m.cursor]
|
||||
if idx >= len(m.lines) {
|
||||
m.previewOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
content := highlightJSON(m.lines[idx].Content)
|
||||
innerWidth := m.width - 2
|
||||
|
||||
var previewW, visibleH int
|
||||
switch m.config.PreviewPosition {
|
||||
case PreviewTop, PreviewBottom:
|
||||
previewW = innerWidth
|
||||
visibleH = m.previewSize()
|
||||
case PreviewLeft:
|
||||
previewW = m.previewSize()
|
||||
visibleH = m.visibleLines()
|
||||
case PreviewRight:
|
||||
previewW = m.previewSize()
|
||||
visibleH = m.visibleLines()
|
||||
}
|
||||
|
||||
previewLines := wrapPreviewContent(content, previewW)
|
||||
maxOffset := max(len(previewLines)-visibleH, 0)
|
||||
if m.previewOffset > maxOffset {
|
||||
m.previewOffset = maxOffset
|
||||
}
|
||||
}
|
||||
|
||||
// applyPreviewOffset slices previewLines based on the current preview scroll
|
||||
// offset, clamping the offset so it doesn't scroll past the content.
|
||||
func (m *model) applyPreviewOffset(previewLines []string, visibleH int) []string {
|
||||
maxOffset := max(len(previewLines)-visibleH, 0)
|
||||
if m.previewOffset > maxOffset {
|
||||
m.previewOffset = maxOffset
|
||||
}
|
||||
if m.previewOffset > 0 {
|
||||
previewLines = previewLines[m.previewOffset:]
|
||||
}
|
||||
return previewLines
|
||||
}
|
||||
|
||||
func (m model) previewSize() int {
|
||||
if m.config.PreviewSizeIsPercent {
|
||||
if m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight {
|
||||
return m.width * m.config.PreviewSize / 100
|
||||
}
|
||||
return m.height * m.config.PreviewSize / 100
|
||||
}
|
||||
return m.config.PreviewSize
|
||||
}
|
||||
|
||||
func (m model) visibleLines() int {
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
if m.showPreview && (m.config.PreviewPosition == PreviewTop || m.config.PreviewPosition == PreviewBottom) {
|
||||
// Add preview height + separator between content and preview
|
||||
return m.height - fixedLines - m.previewSize() - 1
|
||||
}
|
||||
return m.height - fixedLines
|
||||
}
|
||||
|
||||
func (m *model) updateFiltered() {
|
||||
m.filtered = []int{}
|
||||
m.filterRegexErr = nil
|
||||
|
||||
if m.filterRegex && m.filterInput.Text != "" {
|
||||
re, err := regexp.Compile("(?i)" + m.filterInput.Text)
|
||||
if err != nil {
|
||||
m.filterRegexErr = err
|
||||
// Show all lines when regex is invalid
|
||||
for i := range m.lines {
|
||||
m.filtered = append(m.filtered, i)
|
||||
}
|
||||
} else {
|
||||
for i, line := range m.lines {
|
||||
if re.MatchString(line.Content) {
|
||||
m.filtered = append(m.filtered, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filter := strings.ToLower(m.filterInput.Text)
|
||||
for i, line := range m.lines {
|
||||
if m.filterInput.Text == "" || strings.Contains(strings.ToLower(line.Content), filter) {
|
||||
m.filtered = append(m.filtered, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset cursor if out of bounds
|
||||
if m.cursor >= len(m.filtered) {
|
||||
m.cursor = len(m.filtered) - 1
|
||||
}
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
|
||||
// Clamp offset to valid bounds instead of resetting to 0
|
||||
// This preserves scroll position during streaming updates
|
||||
visible := m.visibleLines()
|
||||
if visible > 0 {
|
||||
maxOffset := max(len(m.filtered)-visible, 0)
|
||||
if m.offset > maxOffset {
|
||||
m.offset = maxOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
363
internal/ui/layout_test.go
Normal file
363
internal/ui/layout_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestModelUpdateFiltered(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
// Add some test lines
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "hello world"},
|
||||
{Number: 2, Content: "foo bar"},
|
||||
{Number: 3, Content: "hello foo"},
|
||||
{Number: 4, Content: "baz qux"},
|
||||
}
|
||||
|
||||
// Test with no filter
|
||||
m.filterInput.Text = ""
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 4 {
|
||||
t.Errorf("expected 4 filtered lines, got %d", len(m.filtered))
|
||||
}
|
||||
|
||||
// Test with filter
|
||||
m.filterInput.Text = "hello"
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 2 {
|
||||
t.Errorf("expected 2 filtered lines for 'hello', got %d", len(m.filtered))
|
||||
}
|
||||
|
||||
// Test case insensitive
|
||||
m.filterInput.Text = "HELLO"
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 2 {
|
||||
t.Errorf("expected 2 filtered lines for 'HELLO' (case insensitive), got %d", len(m.filtered))
|
||||
}
|
||||
|
||||
// Test no matches
|
||||
m.filterInput.Text = "xyz"
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 0 {
|
||||
t.Errorf("expected 0 filtered lines for 'xyz', got %d", len(m.filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelMoveCursor(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
m.filtered = []int{0, 1, 2, 3, 4}
|
||||
m.height = 100 // enough height for all lines
|
||||
|
||||
// Move down
|
||||
m.moveCursor(1)
|
||||
if m.cursor != 1 {
|
||||
t.Errorf("expected cursor at 1, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move down more
|
||||
m.moveCursor(2)
|
||||
if m.cursor != 3 {
|
||||
t.Errorf("expected cursor at 3, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move past end
|
||||
m.moveCursor(10)
|
||||
if m.cursor != 4 {
|
||||
t.Errorf("expected cursor at 4 (clamped), got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move up
|
||||
m.moveCursor(-2)
|
||||
if m.cursor != 2 {
|
||||
t.Errorf("expected cursor at 2, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move past beginning
|
||||
m.moveCursor(-10)
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor at 0 (clamped), got %d", m.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleLines(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
m.height = 100
|
||||
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
|
||||
// Without preview
|
||||
m.showPreview = false
|
||||
visible := m.visibleLines()
|
||||
expected := 100 - fixedLines
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines without preview, got %d", expected, visible)
|
||||
}
|
||||
|
||||
// With preview at bottom (percentage)
|
||||
m.showPreview = true
|
||||
visible = m.visibleLines()
|
||||
previewHeight := 100 * 40 / 100 // 40%
|
||||
// Add 1 for the separator between content and preview
|
||||
expected = 100 - fixedLines - previewHeight - 1
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines with preview, got %d", expected, visible)
|
||||
}
|
||||
|
||||
// With preview using absolute size
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
m.config.PreviewSize = 10
|
||||
visible = m.visibleLines()
|
||||
// Add 1 for the separator between content and preview
|
||||
expected = 100 - fixedLines - 10 - 1
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible)
|
||||
}
|
||||
}
|
||||
|
||||
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.filterInput.Text = ""
|
||||
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.filterInput.Text = ""
|
||||
m.updateFiltered()
|
||||
m.offset = 90
|
||||
m.cursor = 95
|
||||
|
||||
// Now filter to fewer lines
|
||||
m.filterInput.Text = "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 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.filterInput.Text = "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.filterInput.Text = "\\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.filterInput.Text = "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.filterInput.Text = "[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.filterInput.Text = "hello"
|
||||
m.updateFiltered()
|
||||
if m.filterRegexErr != nil {
|
||||
t.Errorf("expected filterRegexErr to be nil for valid regex, got %v", m.filterRegexErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewSizeStep(t *testing.T) {
|
||||
if previewSizeStep(true) != 5 {
|
||||
t.Errorf("expected 5 for percent mode, got %d", previewSizeStep(true))
|
||||
}
|
||||
if previewSizeStep(false) != 2 {
|
||||
t.Errorf("expected 2 for absolute mode, got %d", previewSizeStep(false))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampPreviewOffset(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = true
|
||||
m.config.PreviewPosition = PreviewBottom
|
||||
m.config.PreviewSize = 5
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
|
||||
// Set an excessively high offset
|
||||
m.previewOffset = 100
|
||||
m.clampPreviewOffset()
|
||||
|
||||
// Should be clamped to a valid range (0 for short content)
|
||||
if m.previewOffset > 0 {
|
||||
t.Errorf("expected previewOffset clamped to 0 for short content, got %d", m.previewOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampPreviewOffsetNoPreview(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = false
|
||||
m.previewOffset = 5
|
||||
|
||||
m.clampPreviewOffset()
|
||||
if m.previewOffset != 0 {
|
||||
t.Errorf("expected previewOffset reset to 0 when preview hidden, got %d", m.previewOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPreviewOffset(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
lines := []string{"a", "b", "c", "d", "e"}
|
||||
|
||||
// No offset
|
||||
m.previewOffset = 0
|
||||
result := m.applyPreviewOffset(lines, 3)
|
||||
if len(result) != 5 {
|
||||
t.Errorf("expected 5 lines with no offset, got %d", len(result))
|
||||
}
|
||||
|
||||
// With offset
|
||||
m.previewOffset = 2
|
||||
result = m.applyPreviewOffset(lines, 3)
|
||||
if len(result) != 3 || result[0] != "c" {
|
||||
t.Errorf("expected lines starting at 'c', got %v", result)
|
||||
}
|
||||
|
||||
// Offset clamped if too high
|
||||
m.previewOffset = 10
|
||||
_ = m.applyPreviewOffset(lines, 3)
|
||||
if m.previewOffset != 2 {
|
||||
t.Errorf("expected previewOffset clamped to 2, got %d", m.previewOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustOffset(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.height = 15 // visibleLines = 15 - 5 = 10
|
||||
|
||||
// Cursor near start - offset should be 0
|
||||
m.cursor = 0
|
||||
m.adjustOffset()
|
||||
if m.offset != 0 {
|
||||
t.Errorf("expected offset 0 for cursor at start, got %d", m.offset)
|
||||
}
|
||||
|
||||
// Cursor in middle of many lines
|
||||
for i := range 50 {
|
||||
m.filtered = append(m.filtered, i)
|
||||
}
|
||||
m.cursor = 25
|
||||
m.adjustOffset()
|
||||
// Should center the cursor
|
||||
expected := 25 - m.visibleLines()/2
|
||||
if m.offset != expected {
|
||||
t.Errorf("expected offset %d for centered cursor, got %d", expected, m.offset)
|
||||
}
|
||||
}
|
||||
96
internal/ui/model.go
Normal file
96
internal/ui/model.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
// PreviewPosition defines where the preview panel is displayed
|
||||
type PreviewPosition string
|
||||
|
||||
const (
|
||||
PreviewBottom PreviewPosition = "bottom"
|
||||
PreviewTop PreviewPosition = "top"
|
||||
PreviewLeft PreviewPosition = "left"
|
||||
PreviewRight PreviewPosition = "right"
|
||||
)
|
||||
|
||||
// Config holds the UI configuration
|
||||
type Config struct {
|
||||
Command string
|
||||
Shell string
|
||||
PreviewSize int
|
||||
PreviewSizeIsPercent bool
|
||||
PreviewPosition PreviewPosition
|
||||
ShowLineNums bool
|
||||
LineNumWidth int
|
||||
Prompt string
|
||||
RefreshInterval time.Duration
|
||||
RefreshFromStart bool // If true, refresh timer starts when command starts; if false, when command ends (default)
|
||||
Interactive bool
|
||||
}
|
||||
|
||||
// model represents the application state
|
||||
type model struct {
|
||||
config Config
|
||||
lines []runner.Line
|
||||
filtered []int // indices into lines that match filter
|
||||
cursor int // cursor position in filtered list
|
||||
offset int // scroll offset for visible window
|
||||
filterInput textInput // filter text and cursor
|
||||
filterMode bool
|
||||
filterRegex bool // true when filter is in regex mode
|
||||
filterRegexErr error // non-nil when regex pattern is invalid
|
||||
showPreview bool
|
||||
previewOffset int // scroll offset for preview pane
|
||||
showHelp bool // help overlay visible
|
||||
width int
|
||||
height int
|
||||
runner *runner.Runner
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
loading bool
|
||||
streaming bool // true while command is running (streaming output)
|
||||
streamResult *runner.StreamingResult // current streaming result
|
||||
lastLineCount int // track line count for updates
|
||||
userScrolled bool // true if user manually scrolled during streaming
|
||||
refreshGeneration int // incremented on manual refresh to reset timer
|
||||
refreshStartTime time.Time // when the refresh timer was started
|
||||
spinnerFrame int // current spinner animation frame
|
||||
errorMsg string
|
||||
statusMsg string // temporary status message (e.g., "Yanked!")
|
||||
exitCode int // last command exit code
|
||||
|
||||
cmdPaletteMode bool // whether command palette is open
|
||||
cmdPaletteInput textInput // palette filter text and cursor
|
||||
cmdPaletteSelected int // selected item index in filtered list
|
||||
|
||||
confirmMode bool // whether a confirmation dialog is visible
|
||||
confirmMessage string // message to display in confirmation dialog
|
||||
confirmAction func(m *model) (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
// messages
|
||||
type resultMsg struct {
|
||||
lines []runner.Line
|
||||
exitCode int
|
||||
}
|
||||
type errMsg struct{ err error }
|
||||
type tickMsg struct {
|
||||
generation int
|
||||
}
|
||||
type clearStatusMsg struct{}
|
||||
type spinnerTickMsg time.Time
|
||||
type streamTickMsg time.Time // periodic check for streaming updates
|
||||
type startStreamMsg struct{} // trigger to start streaming
|
||||
type countdownTickMsg struct { // periodic update for refresh countdown display
|
||||
generation int
|
||||
}
|
||||
|
||||
// Spinner frames for the loading animation
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
func (e errMsg) Error() string { return e.err.Error() }
|
||||
210
internal/ui/model_test.go
Normal file
210
internal/ui/model_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
RefreshInterval: 5 * time.Second,
|
||||
}
|
||||
|
||||
if cfg.Command != "echo test" {
|
||||
t.Errorf("expected command 'echo test', got %q", cfg.Command)
|
||||
}
|
||||
|
||||
if cfg.Shell != "sh" {
|
||||
t.Errorf("expected shell 'sh', got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewSize != 40 {
|
||||
t.Errorf("expected preview size 40, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if !cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be true")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != PreviewBottom {
|
||||
t.Errorf("expected preview position 'bottom', got %q", cfg.PreviewPosition)
|
||||
}
|
||||
|
||||
if !cfg.ShowLineNums {
|
||||
t.Error("expected ShowLineNums to be true")
|
||||
}
|
||||
|
||||
if cfg.LineNumWidth != 6 {
|
||||
t.Errorf("expected line num width 6, got %d", cfg.LineNumWidth)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "watchr> " {
|
||||
t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt)
|
||||
}
|
||||
|
||||
if cfg.RefreshInterval != 5*time.Second {
|
||||
t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewPositionConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
pos PreviewPosition
|
||||
want string
|
||||
}{
|
||||
{PreviewBottom, "bottom"},
|
||||
{PreviewTop, "top"},
|
||||
{PreviewLeft, "left"},
|
||||
{PreviewRight, "right"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if string(tt.pos) != tt.want {
|
||||
t.Errorf("PreviewPosition %v != %q", tt.pos, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
// Test with zero values
|
||||
cfg := Config{}
|
||||
|
||||
if cfg.Command != "" {
|
||||
t.Errorf("expected empty command, got %q", cfg.Command)
|
||||
}
|
||||
|
||||
if cfg.Shell != "" {
|
||||
t.Errorf("expected empty shell, got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewSize != 0 {
|
||||
t.Errorf("expected preview size 0, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be false")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != "" {
|
||||
t.Errorf("expected empty preview position, got %q", cfg.PreviewPosition)
|
||||
}
|
||||
|
||||
if cfg.ShowLineNums {
|
||||
t.Error("expected ShowLineNums to be false")
|
||||
}
|
||||
|
||||
if cfg.LineNumWidth != 0 {
|
||||
t.Errorf("expected line num width 0, got %d", cfg.LineNumWidth)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
t.Errorf("expected empty prompt, got %q", cfg.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialModel(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
if m.config.Command != cfg.Command {
|
||||
t.Errorf("expected command %q, got %q", cfg.Command, m.config.Command)
|
||||
}
|
||||
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor at 0, got %d", m.cursor)
|
||||
}
|
||||
|
||||
if m.offset != 0 {
|
||||
t.Errorf("expected offset at 0, got %d", m.offset)
|
||||
}
|
||||
|
||||
if m.filterMode {
|
||||
t.Error("expected filterMode to be false")
|
||||
}
|
||||
|
||||
if m.showPreview {
|
||||
t.Error("expected showPreview to be false")
|
||||
}
|
||||
|
||||
if !m.loading {
|
||||
t.Error("expected loading to be true initially")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
114
internal/ui/reload_test.go
Normal file
114
internal/ui/reload_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func waitDone(t *testing.T, m *model) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if m.streamResult != nil && m.streamResult.IsDone() {
|
||||
// fire one tick after done so model state syncs
|
||||
_, _ = m.Update(streamTickMsg(time.Now()))
|
||||
return
|
||||
}
|
||||
_, _ = m.Update(streamTickMsg(time.Now()))
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("stream did not complete")
|
||||
}
|
||||
|
||||
// TestReload_SameLineCountUpdatesInPlace verifies that when the new run
|
||||
// produces the same number of lines as the previous run, the new content
|
||||
// (delivered via in-place updates) is reflected in m.lines.
|
||||
func TestReload_SameLineCountUpdatesInPlace(t *testing.T) {
|
||||
cfg := Config{Command: "echo new1; echo new2; echo new3", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.height = 30
|
||||
m.width = 80
|
||||
|
||||
// Pretend a previous run left 3 lines of stale content
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "old1"},
|
||||
{Number: 2, Content: "old2"},
|
||||
{Number: 3, Content: "old3"},
|
||||
}
|
||||
m.lastLineCount = 3
|
||||
m.updateFiltered()
|
||||
|
||||
_, _ = m.actionReload()
|
||||
waitDone(t, m)
|
||||
|
||||
if len(m.lines) != 3 {
|
||||
t.Fatalf("expected 3 lines, got %d", len(m.lines))
|
||||
}
|
||||
for i, want := range []string{"new1", "new2", "new3"} {
|
||||
if m.lines[i].Content != want {
|
||||
t.Errorf("line %d: expected %q, got %q", i, want, m.lines[i].Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReload_FewerLinesTrimsToNewRun verifies that when the new run produces
|
||||
// fewer lines than the previous run, m.lines is trimmed AND shows the new
|
||||
// content in the surviving slots (not stale prev content).
|
||||
func TestReload_FewerLinesTrimsToNewRun(t *testing.T) {
|
||||
cfg := Config{Command: "echo new1; echo new2", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.height = 30
|
||||
m.width = 80
|
||||
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "old1"},
|
||||
{Number: 2, Content: "old2"},
|
||||
{Number: 3, Content: "old3"},
|
||||
{Number: 4, Content: "old4"},
|
||||
}
|
||||
m.lastLineCount = 4
|
||||
m.updateFiltered()
|
||||
|
||||
_, _ = m.actionReload()
|
||||
waitDone(t, m)
|
||||
|
||||
if len(m.lines) != 2 {
|
||||
t.Fatalf("expected 2 lines after reload, got %d", len(m.lines))
|
||||
}
|
||||
for i, want := range []string{"new1", "new2"} {
|
||||
if m.lines[i].Content != want {
|
||||
t.Errorf("line %d: expected %q, got %q", i, want, m.lines[i].Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReload_MoreLines verifies that when the new run produces more lines
|
||||
// than the previous run, all new lines are visible.
|
||||
func TestReload_MoreLines(t *testing.T) {
|
||||
cfg := Config{Command: "echo a; echo b; echo c; echo d; echo e", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.height = 30
|
||||
m.width = 80
|
||||
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "old1"},
|
||||
{Number: 2, Content: "old2"},
|
||||
{Number: 3, Content: "old3"},
|
||||
}
|
||||
m.lastLineCount = 3
|
||||
m.updateFiltered()
|
||||
|
||||
_, _ = m.actionReload()
|
||||
waitDone(t, m)
|
||||
|
||||
if len(m.lines) != 5 {
|
||||
t.Fatalf("expected 5 lines, got %d", len(m.lines))
|
||||
}
|
||||
for i, want := range []string{"a", "b", "c", "d", "e"} {
|
||||
if m.lines[i].Content != want {
|
||||
t.Errorf("line %d: expected %q, got %q", i, want, m.lines[i].Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
442
internal/ui/text.go
Normal file
442
internal/ui/text.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const ellipsis = "…"
|
||||
|
||||
// truncateToWidth truncates a string to fit within the given visual width,
|
||||
// adding an ellipsis if truncation occurs. Uses visual width, not byte count.
|
||||
func truncateToWidth(s string, maxWidth int) string {
|
||||
if maxWidth <= 0 {
|
||||
return ""
|
||||
}
|
||||
sw := lipgloss.Width(s)
|
||||
if sw <= maxWidth {
|
||||
return s
|
||||
}
|
||||
// Need to truncate - leave room for ellipsis (1 char wide)
|
||||
targetWidth := maxWidth - 1
|
||||
if targetWidth <= 0 {
|
||||
return ellipsis
|
||||
}
|
||||
|
||||
// Truncate rune by rune until we fit
|
||||
var result strings.Builder
|
||||
currentWidth := 0
|
||||
for _, r := range s {
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if currentWidth+runeWidth > targetWidth {
|
||||
break
|
||||
}
|
||||
result.WriteRune(r)
|
||||
currentWidth += runeWidth
|
||||
}
|
||||
return result.String() + ellipsis
|
||||
}
|
||||
|
||||
// wrapText wraps text to fit within the given width, returning multiple lines.
|
||||
// It is ANSI-aware: escape sequences are preserved intact and don't count
|
||||
// toward the visible width. When a line wraps, any active ANSI state is
|
||||
// carried over so colours continue on the next line.
|
||||
func wrapText(s string, width int) []string {
|
||||
if width <= 0 {
|
||||
return nil
|
||||
}
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
var currentLine strings.Builder
|
||||
currentWidth := 0
|
||||
// Track the last seen ANSI escape so we can re-apply it after a wrap
|
||||
var activeANSI string
|
||||
|
||||
i := 0
|
||||
runes := []rune(s)
|
||||
for i < len(runes) {
|
||||
// Check for ANSI escape sequence: ESC [ ... final_byte
|
||||
if runes[i] == '\033' && i+1 < len(runes) && runes[i+1] == '[' {
|
||||
// Consume entire escape sequence
|
||||
var seq strings.Builder
|
||||
seq.WriteRune(runes[i]) // ESC
|
||||
i++
|
||||
seq.WriteRune(runes[i]) // [
|
||||
i++
|
||||
for i < len(runes) {
|
||||
seq.WriteRune(runes[i])
|
||||
// Final byte of CSI sequence is in range 0x40-0x7E
|
||||
if runes[i] >= 0x40 && runes[i] <= 0x7E {
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
seqStr := seq.String()
|
||||
currentLine.WriteString(seqStr)
|
||||
// Track reset vs color sequences
|
||||
if seqStr == "\033[0m" || seqStr == "\033[m" {
|
||||
activeANSI = ""
|
||||
} else {
|
||||
activeANSI = seqStr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
r := runes[i]
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if currentWidth+runeWidth > width {
|
||||
// Close any active ANSI on this line before wrapping
|
||||
if activeANSI != "" {
|
||||
currentLine.WriteString("\033[0m")
|
||||
}
|
||||
lines = append(lines, currentLine.String())
|
||||
currentLine.Reset()
|
||||
currentWidth = 0
|
||||
// Re-apply active ANSI on the new line
|
||||
if activeANSI != "" {
|
||||
currentLine.WriteString(activeANSI)
|
||||
}
|
||||
}
|
||||
currentLine.WriteRune(r)
|
||||
currentWidth += runeWidth
|
||||
i++
|
||||
}
|
||||
// Don't forget the last line
|
||||
if currentLine.Len() > 0 {
|
||||
lines = append(lines, currentLine.String())
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// wrapPreviewContent splits multi-line content (e.g. pretty-printed JSON) by
|
||||
// newlines first, then wraps each line to fit within the given width.
|
||||
func wrapPreviewContent(s string, width int) []string {
|
||||
var result []string
|
||||
for line := range strings.SplitSeq(s, "\n") {
|
||||
if line == "" {
|
||||
result = append(result, "")
|
||||
continue
|
||||
}
|
||||
wrapped := wrapText(line, width)
|
||||
result = append(result, wrapped...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// splitAtVisualWidth splits a string at a visual width position, handling ANSI codes
|
||||
// Returns (left part, right part) where left has exactly targetWidth visual width
|
||||
func splitAtVisualWidth(s string, targetWidth int) (string, string) {
|
||||
var left, right strings.Builder
|
||||
visualWidth := 0
|
||||
inEscape := false
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Build left part up to targetWidth
|
||||
for i < len(runes) && visualWidth < targetWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// Start of ANSI escape sequence - include it in left part
|
||||
left.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
left.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
left.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if visualWidth+runeWidth <= targetWidth {
|
||||
left.WriteRune(r)
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pad left if needed
|
||||
for visualWidth < targetWidth {
|
||||
left.WriteRune(' ')
|
||||
visualWidth++
|
||||
}
|
||||
|
||||
// Skip runes in the "overlay zone" - we don't need them for right part calculation
|
||||
// The caller will handle inserting the overlay content
|
||||
|
||||
// Build right part from remaining
|
||||
for ; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
if r == '\x1b' {
|
||||
right.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
right.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
right.WriteRune(runes[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
right.WriteRune(r)
|
||||
}
|
||||
|
||||
_ = inEscape // unused but kept for clarity
|
||||
return left.String(), right.String()
|
||||
}
|
||||
|
||||
// skipVisualWidth skips a number of visual width units in a string, handling ANSI codes
|
||||
// It preserves and returns ANSI sequences encountered during skipping so styling can be restored
|
||||
func skipVisualWidth(s string, skipWidth int) string {
|
||||
var result strings.Builder
|
||||
var ansiState strings.Builder // collect ANSI codes while skipping
|
||||
visualWidth := 0
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Skip until we've passed skipWidth, but collect ANSI codes
|
||||
for i < len(runes) && visualWidth < skipWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// ANSI escape - collect it (don't count visual width)
|
||||
ansiState.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
ansiState.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
ansiState.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
}
|
||||
|
||||
// Prepend collected ANSI state to restore styling
|
||||
result.WriteString(ansiState.String())
|
||||
|
||||
// Output the rest
|
||||
for ; i < len(runes); i++ {
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// textInput is a reusable single-line text editor with cursor, word navigation,
|
||||
// and block-cursor rendering.
|
||||
type textInput struct {
|
||||
Text string
|
||||
Cursor int
|
||||
}
|
||||
|
||||
func (ti *textInput) clear() {
|
||||
ti.Text = ""
|
||||
ti.Cursor = 0
|
||||
}
|
||||
|
||||
// handleKey processes a key message for text editing (left/right, backspace,
|
||||
// character insert). Returns true if the key was handled.
|
||||
func (ti *textInput) handleKey(msg tea.KeyMsg) bool {
|
||||
switch msg.Type {
|
||||
case tea.KeyLeft:
|
||||
if msg.Alt {
|
||||
ti.wordLeft()
|
||||
} else if ti.Cursor > 0 {
|
||||
ti.Cursor--
|
||||
}
|
||||
case tea.KeyRight:
|
||||
if msg.Alt {
|
||||
ti.wordRight()
|
||||
} else if ti.Cursor < len(ti.Text) {
|
||||
ti.Cursor++
|
||||
}
|
||||
case tea.KeyBackspace:
|
||||
if msg.Alt {
|
||||
ti.backspaceWord()
|
||||
} else {
|
||||
ti.backspace()
|
||||
}
|
||||
case tea.KeyCtrlW:
|
||||
// Ctrl+W deletes word (also sent by some terminals for Alt+Backspace)
|
||||
ti.backspaceWord()
|
||||
case tea.KeyDelete:
|
||||
if msg.Alt {
|
||||
ti.deleteWord()
|
||||
} else {
|
||||
ti.delete()
|
||||
}
|
||||
case tea.KeyHome, tea.KeyCtrlA:
|
||||
ti.Cursor = 0
|
||||
case tea.KeyEnd, tea.KeyCtrlE:
|
||||
ti.Cursor = len(ti.Text)
|
||||
default:
|
||||
if len(msg.Runes) > 0 {
|
||||
ti.insert(string(msg.Runes))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (ti *textInput) delete() {
|
||||
if ti.Cursor < len(ti.Text) {
|
||||
ti.Text = ti.Text[:ti.Cursor] + ti.Text[ti.Cursor+1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (ti *textInput) deleteWord() {
|
||||
if ti.Cursor >= len(ti.Text) {
|
||||
return
|
||||
}
|
||||
pos := ti.Cursor
|
||||
for pos < len(ti.Text) && ti.Text[pos] == ' ' {
|
||||
pos++
|
||||
}
|
||||
for pos < len(ti.Text) && ti.Text[pos] != ' ' {
|
||||
pos++
|
||||
}
|
||||
ti.Text = ti.Text[:ti.Cursor] + ti.Text[pos:]
|
||||
}
|
||||
|
||||
func (ti *textInput) insert(s string) {
|
||||
ti.Text = ti.Text[:ti.Cursor] + s + ti.Text[ti.Cursor:]
|
||||
ti.Cursor += len(s)
|
||||
}
|
||||
|
||||
func (ti *textInput) backspace() {
|
||||
if ti.Cursor > 0 {
|
||||
ti.Text = ti.Text[:ti.Cursor-1] + ti.Text[ti.Cursor:]
|
||||
ti.Cursor--
|
||||
}
|
||||
}
|
||||
|
||||
func (ti *textInput) backspaceWord() {
|
||||
if ti.Cursor <= 0 {
|
||||
return
|
||||
}
|
||||
newPos := ti.wordBoundaryLeft()
|
||||
ti.Text = ti.Text[:newPos] + ti.Text[ti.Cursor:]
|
||||
ti.Cursor = newPos
|
||||
}
|
||||
|
||||
func (ti *textInput) wordLeft() {
|
||||
ti.Cursor = ti.wordBoundaryLeft()
|
||||
}
|
||||
|
||||
func (ti *textInput) wordRight() {
|
||||
pos := ti.Cursor
|
||||
for pos < len(ti.Text) && ti.Text[pos] != ' ' {
|
||||
pos++
|
||||
}
|
||||
for pos < len(ti.Text) && ti.Text[pos] == ' ' {
|
||||
pos++
|
||||
}
|
||||
ti.Cursor = pos
|
||||
}
|
||||
|
||||
func (ti *textInput) wordBoundaryLeft() int {
|
||||
pos := ti.Cursor
|
||||
for pos > 0 && ti.Text[pos-1] == ' ' {
|
||||
pos--
|
||||
}
|
||||
for pos > 0 && ti.Text[pos-1] != ' ' {
|
||||
pos--
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// render returns (before, cursor, after) where cursor is the character at the
|
||||
// cursor position styled with an inverted background so it remains visible.
|
||||
// At end of text, a space with inverted background is used.
|
||||
func (ti *textInput) render() (before, cursor, after string) {
|
||||
cursorStyle := lipgloss.NewStyle().Reverse(true)
|
||||
before = ti.Text[:ti.Cursor]
|
||||
if ti.Cursor < len(ti.Text) {
|
||||
cursor = cursorStyle.Render(string(ti.Text[ti.Cursor]))
|
||||
after = ti.Text[ti.Cursor+1:]
|
||||
} else {
|
||||
cursor = cursorStyle.Render(" ")
|
||||
}
|
||||
return before, cursor, after
|
||||
}
|
||||
|
||||
func isAnsiTerminator(r rune) bool {
|
||||
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
|
||||
}
|
||||
|
||||
// overlayBox composites an overlay box on top of a base view
|
||||
func overlayBox(base string, box string, boxWidth, boxHeight, screenWidth, screenHeight int) string {
|
||||
// ANSI reset sequence to stop any styling from bleeding into overlay
|
||||
const ansiReset = "\x1b[0m"
|
||||
|
||||
// Split base into lines
|
||||
baseLines := strings.Split(base, "\n")
|
||||
|
||||
// Ensure we have enough lines
|
||||
for len(baseLines) < screenHeight {
|
||||
baseLines = append(baseLines, "")
|
||||
}
|
||||
|
||||
// Split box into lines
|
||||
boxLines := strings.Split(box, "\n")
|
||||
|
||||
// Calculate center position
|
||||
startX := (screenWidth - boxWidth) / 2
|
||||
startY := (screenHeight - boxHeight) / 2
|
||||
|
||||
if startX < 0 {
|
||||
startX = 0
|
||||
}
|
||||
if startY < 0 {
|
||||
startY = 0
|
||||
}
|
||||
|
||||
// Overlay box onto base
|
||||
for i, boxLine := range boxLines {
|
||||
y := startY + i
|
||||
if y >= len(baseLines) {
|
||||
break
|
||||
}
|
||||
|
||||
baseLine := baseLines[y]
|
||||
baseVisualWidth := lipgloss.Width(baseLine)
|
||||
|
||||
// Get left part (before overlay)
|
||||
leftPart, _ := splitAtVisualWidth(baseLine, startX)
|
||||
|
||||
// Get right part (after overlay)
|
||||
endX := startX + boxWidth
|
||||
var rightPart string
|
||||
if endX < baseVisualWidth {
|
||||
rightPart = skipVisualWidth(baseLine, endX)
|
||||
}
|
||||
|
||||
// Combine: left + reset + box + right
|
||||
// Reset before overlay to stop highlight bleeding into overlay
|
||||
baseLines[y] = leftPart + ansiReset + boxLine + rightPart
|
||||
}
|
||||
|
||||
return strings.Join(baseLines, "\n")
|
||||
}
|
||||
250
internal/ui/text_test.go
Normal file
250
internal/ui/text_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateToWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxWidth int
|
||||
want string
|
||||
}{
|
||||
{"empty maxWidth", "hello", 0, ""},
|
||||
{"no truncation needed", "hello", 10, "hello"},
|
||||
{"exact fit", "hello", 5, "hello"},
|
||||
{"truncate with ellipsis", "hello world", 8, "hello w…"},
|
||||
{"maxWidth 1", "hello", 1, "…"},
|
||||
{"empty string", "", 10, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := truncateToWidth(tt.input, tt.maxWidth)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateToWidth(%q, %d) = %q, want %q", tt.input, tt.maxWidth, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAtVisualWidth(t *testing.T) {
|
||||
t.Run("plain text", func(t *testing.T) {
|
||||
left, right := splitAtVisualWidth("hello world", 5)
|
||||
if left != "hello" {
|
||||
t.Errorf("expected left 'hello', got %q", left)
|
||||
}
|
||||
if right != " world" {
|
||||
t.Errorf("expected right ' world', got %q", right)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pads if short", func(t *testing.T) {
|
||||
left, right := splitAtVisualWidth("hi", 5)
|
||||
if left != "hi " {
|
||||
t.Errorf("expected left 'hi ', got %q", left)
|
||||
}
|
||||
if right != "" {
|
||||
t.Errorf("expected empty right, got %q", right)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with ANSI codes", func(t *testing.T) {
|
||||
input := "\x1b[31mhello\x1b[0m world"
|
||||
left, _ := splitAtVisualWidth(input, 5)
|
||||
// Left should contain the ANSI code and "hello"
|
||||
if !strings.Contains(left, "\x1b[31m") {
|
||||
t.Error("expected left to contain ANSI color code")
|
||||
}
|
||||
if !strings.Contains(left, "hello") {
|
||||
t.Error("expected left to contain 'hello'")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkipVisualWidth(t *testing.T) {
|
||||
t.Run("plain text", func(t *testing.T) {
|
||||
result := skipVisualWidth("hello world", 6)
|
||||
if result != "world" {
|
||||
t.Errorf("expected 'world', got %q", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves ANSI state", func(t *testing.T) {
|
||||
input := "\x1b[31mhello world\x1b[0m"
|
||||
result := skipVisualWidth(input, 6)
|
||||
// Should preserve the ANSI code encountered during skip
|
||||
if !strings.Contains(result, "\x1b[31m") {
|
||||
t.Error("expected ANSI state to be preserved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsAnsiTerminator(t *testing.T) {
|
||||
// Letters are terminators
|
||||
if !isAnsiTerminator('m') {
|
||||
t.Error("expected 'm' to be terminator")
|
||||
}
|
||||
if !isAnsiTerminator('A') {
|
||||
t.Error("expected 'A' to be terminator")
|
||||
}
|
||||
if !isAnsiTerminator('z') {
|
||||
t.Error("expected 'z' to be terminator")
|
||||
}
|
||||
|
||||
// Digits are not terminators
|
||||
if isAnsiTerminator('0') {
|
||||
t.Error("expected '0' not to be terminator")
|
||||
}
|
||||
if isAnsiTerminator(';') {
|
||||
t.Error("expected ';' not to be terminator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInputWordLeft(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
pos int
|
||||
want int
|
||||
}{
|
||||
{"end of string", "foo bar baz", 11, 8},
|
||||
{"middle of word", "foo bar baz", 9, 8},
|
||||
{"at word boundary", "foo bar baz", 8, 4},
|
||||
{"at start", "foo bar", 0, 0},
|
||||
{"with multiple spaces", "foo bar", 9, 6},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ti := textInput{Text: tt.s, Cursor: tt.pos}
|
||||
ti.wordLeft()
|
||||
if ti.Cursor != tt.want {
|
||||
t.Errorf("wordLeft(%q, %d) = %d, want %d", tt.s, tt.pos, ti.Cursor, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInputWordRight(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
pos int
|
||||
want int
|
||||
}{
|
||||
{"start of string", "foo bar baz", 0, 4},
|
||||
{"middle of word", "foo bar baz", 1, 4},
|
||||
{"at word boundary", "foo bar baz", 4, 8},
|
||||
{"at end", "foo bar", 7, 7},
|
||||
{"with multiple spaces", "foo bar", 3, 6},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ti := textInput{Text: tt.s, Cursor: tt.pos}
|
||||
ti.wordRight()
|
||||
if ti.Cursor != tt.want {
|
||||
t.Errorf("wordRight(%q, %d) = %d, want %d", tt.s, tt.pos, ti.Cursor, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInputInsert(t *testing.T) {
|
||||
ti := textInput{Text: "helo", Cursor: 3}
|
||||
ti.insert("l")
|
||||
if ti.Text != "hello" || ti.Cursor != 4 {
|
||||
t.Errorf("got %q cursor %d, want 'hello' cursor 4", ti.Text, ti.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInputBackspace(t *testing.T) {
|
||||
ti := textInput{Text: "hello", Cursor: 3}
|
||||
ti.backspace()
|
||||
if ti.Text != "helo" || ti.Cursor != 2 {
|
||||
t.Errorf("got %q cursor %d, want 'helo' cursor 2", ti.Text, ti.Cursor)
|
||||
}
|
||||
|
||||
// At start, no change
|
||||
ti = textInput{Text: "hello", Cursor: 0}
|
||||
ti.backspace()
|
||||
if ti.Text != "hello" || ti.Cursor != 0 {
|
||||
t.Errorf("got %q cursor %d, want 'hello' cursor 0", ti.Text, ti.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInputBackspaceWord(t *testing.T) {
|
||||
ti := textInput{Text: "hello world", Cursor: 11}
|
||||
ti.backspaceWord()
|
||||
if ti.Text != "hello " || ti.Cursor != 6 {
|
||||
t.Errorf("got %q cursor %d, want 'hello ' cursor 6", ti.Text, ti.Cursor)
|
||||
}
|
||||
|
||||
// At start, no change
|
||||
ti = textInput{Text: "hello", Cursor: 0}
|
||||
ti.backspaceWord()
|
||||
if ti.Text != "hello" || ti.Cursor != 0 {
|
||||
t.Errorf("got %q cursor %d, want 'hello' cursor 0", ti.Text, ti.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInputRender(t *testing.T) {
|
||||
// Cursor in middle — character visible with inverted style
|
||||
ti := textInput{Text: "test", Cursor: 2}
|
||||
before, cursor, after := ti.render()
|
||||
if before != "te" {
|
||||
t.Errorf("before = %q, want 'te'", before)
|
||||
}
|
||||
if after != "t" {
|
||||
t.Errorf("after = %q, want 't'", after)
|
||||
}
|
||||
// Cursor should contain the character 's' (with ANSI styling)
|
||||
if !strings.Contains(cursor, "s") {
|
||||
t.Errorf("cursor should contain 's', got %q", cursor)
|
||||
}
|
||||
|
||||
// Cursor at end — styled space
|
||||
ti = textInput{Text: "test", Cursor: 4}
|
||||
before, cursor, after = ti.render()
|
||||
if before != "test" {
|
||||
t.Errorf("before = %q, want 'test'", before)
|
||||
}
|
||||
if after != "" {
|
||||
t.Errorf("after = %q, want ''", after)
|
||||
}
|
||||
// Cursor should contain a space (with ANSI styling)
|
||||
if !strings.Contains(cursor, " ") {
|
||||
t.Errorf("cursor should contain space, got %q", cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInputClear(t *testing.T) {
|
||||
ti := textInput{Text: "hello", Cursor: 3}
|
||||
ti.clear()
|
||||
if ti.Text != "" || ti.Cursor != 0 {
|
||||
t.Errorf("after clear: text=%q cursor=%d", ti.Text, ti.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayBox(t *testing.T) {
|
||||
base := "aaaaaaaaaa\naaaaaaaaaa\naaaaaaaaaa\naaaaaaaaaa\naaaaaaaaaa"
|
||||
box := "XX\nXX"
|
||||
|
||||
result := overlayBox(base, box, 2, 2, 10, 5)
|
||||
lines := strings.Split(result, "\n")
|
||||
|
||||
if len(lines) != 5 {
|
||||
t.Errorf("expected 5 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
// The overlay should appear in the middle rows
|
||||
// Center: startY = (5-2)/2 = 1, startX = (10-2)/2 = 4
|
||||
// Lines 1 and 2 should contain the overlay
|
||||
if !strings.Contains(lines[1], "XX") {
|
||||
t.Errorf("expected overlay in line 1, got %q", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[2], "XX") {
|
||||
t.Errorf("expected overlay in line 2, got %q", lines[2])
|
||||
}
|
||||
}
|
||||
1258
internal/ui/ui.go
1258
internal/ui/ui.go
File diff suppressed because it is too large
Load Diff
@@ -1,489 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
RefreshInterval: 5 * time.Second,
|
||||
}
|
||||
|
||||
if cfg.Command != "echo test" {
|
||||
t.Errorf("expected command 'echo test', got %q", cfg.Command)
|
||||
}
|
||||
|
||||
if cfg.Shell != "sh" {
|
||||
t.Errorf("expected shell 'sh', got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewSize != 40 {
|
||||
t.Errorf("expected preview size 40, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if !cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be true")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != PreviewBottom {
|
||||
t.Errorf("expected preview position 'bottom', got %q", cfg.PreviewPosition)
|
||||
}
|
||||
|
||||
if !cfg.ShowLineNums {
|
||||
t.Error("expected ShowLineNums to be true")
|
||||
}
|
||||
|
||||
if cfg.LineNumWidth != 6 {
|
||||
t.Errorf("expected line num width 6, got %d", cfg.LineNumWidth)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "watchr> " {
|
||||
t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt)
|
||||
}
|
||||
|
||||
if cfg.RefreshInterval != 5*time.Second {
|
||||
t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewPositionConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
pos PreviewPosition
|
||||
want string
|
||||
}{
|
||||
{PreviewBottom, "bottom"},
|
||||
{PreviewTop, "top"},
|
||||
{PreviewLeft, "left"},
|
||||
{PreviewRight, "right"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if string(tt.pos) != tt.want {
|
||||
t.Errorf("PreviewPosition %v != %q", tt.pos, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
// Test with zero values
|
||||
cfg := Config{}
|
||||
|
||||
if cfg.Command != "" {
|
||||
t.Errorf("expected empty command, got %q", cfg.Command)
|
||||
}
|
||||
|
||||
if cfg.Shell != "" {
|
||||
t.Errorf("expected empty shell, got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewSize != 0 {
|
||||
t.Errorf("expected preview size 0, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be false")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != "" {
|
||||
t.Errorf("expected empty preview position, got %q", cfg.PreviewPosition)
|
||||
}
|
||||
|
||||
if cfg.ShowLineNums {
|
||||
t.Error("expected ShowLineNums to be false")
|
||||
}
|
||||
|
||||
if cfg.LineNumWidth != 0 {
|
||||
t.Errorf("expected line num width 0, got %d", cfg.LineNumWidth)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
t.Errorf("expected empty prompt, got %q", cfg.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialModel(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
if m.config.Command != cfg.Command {
|
||||
t.Errorf("expected command %q, got %q", cfg.Command, m.config.Command)
|
||||
}
|
||||
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor at 0, got %d", m.cursor)
|
||||
}
|
||||
|
||||
if m.offset != 0 {
|
||||
t.Errorf("expected offset at 0, got %d", m.offset)
|
||||
}
|
||||
|
||||
if m.filterMode {
|
||||
t.Error("expected filterMode to be false")
|
||||
}
|
||||
|
||||
if m.showPreview {
|
||||
t.Error("expected showPreview to be false")
|
||||
}
|
||||
|
||||
if !m.loading {
|
||||
t.Error("expected loading to be true initially")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelUpdateFiltered(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
// Add some test lines
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "hello world"},
|
||||
{Number: 2, Content: "foo bar"},
|
||||
{Number: 3, Content: "hello foo"},
|
||||
{Number: 4, Content: "baz qux"},
|
||||
}
|
||||
|
||||
// Test with no filter
|
||||
m.filter = ""
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 4 {
|
||||
t.Errorf("expected 4 filtered lines, got %d", len(m.filtered))
|
||||
}
|
||||
|
||||
// Test with filter
|
||||
m.filter = "hello"
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 2 {
|
||||
t.Errorf("expected 2 filtered lines for 'hello', got %d", len(m.filtered))
|
||||
}
|
||||
|
||||
// Test case insensitive
|
||||
m.filter = "HELLO"
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 2 {
|
||||
t.Errorf("expected 2 filtered lines for 'HELLO' (case insensitive), got %d", len(m.filtered))
|
||||
}
|
||||
|
||||
// Test no matches
|
||||
m.filter = "xyz"
|
||||
m.updateFiltered()
|
||||
|
||||
if len(m.filtered) != 0 {
|
||||
t.Errorf("expected 0 filtered lines for 'xyz', got %d", len(m.filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelMoveCursor(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
m.filtered = []int{0, 1, 2, 3, 4}
|
||||
m.height = 100 // enough height for all lines
|
||||
|
||||
// Move down
|
||||
m.moveCursor(1)
|
||||
if m.cursor != 1 {
|
||||
t.Errorf("expected cursor at 1, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move down more
|
||||
m.moveCursor(2)
|
||||
if m.cursor != 3 {
|
||||
t.Errorf("expected cursor at 3, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move past end
|
||||
m.moveCursor(10)
|
||||
if m.cursor != 4 {
|
||||
t.Errorf("expected cursor at 4 (clamped), got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move up
|
||||
m.moveCursor(-2)
|
||||
if m.cursor != 2 {
|
||||
t.Errorf("expected cursor at 2, got %d", m.cursor)
|
||||
}
|
||||
|
||||
// Move past beginning
|
||||
m.moveCursor(-10)
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor at 0 (clamped), got %d", m.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleLines(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
m.height = 100
|
||||
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
|
||||
// Without preview
|
||||
m.showPreview = false
|
||||
visible := m.visibleLines()
|
||||
expected := 100 - fixedLines
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines without preview, got %d", expected, visible)
|
||||
}
|
||||
|
||||
// With preview at bottom (percentage)
|
||||
m.showPreview = true
|
||||
visible = m.visibleLines()
|
||||
previewHeight := 100 * 40 / 100 // 40%
|
||||
// Add 1 for the separator between content and preview
|
||||
expected = 100 - fixedLines - previewHeight - 1
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines with preview, got %d", expected, visible)
|
||||
}
|
||||
|
||||
// With preview using absolute size
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
m.config.PreviewSize = 10
|
||||
visible = m.visibleLines()
|
||||
// Add 1 for the separator between content and preview
|
||||
expected = 100 - fixedLines - 10 - 1
|
||||
if visible != expected {
|
||||
t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible)
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
})
|
||||
}
|
||||
223
internal/ui/update.go
Normal file
223
internal/ui/update.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func initialModel(cfg Config) model {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var r *runner.Runner
|
||||
if cfg.Interactive {
|
||||
r = runner.NewInteractiveRunner(cfg.Shell, cfg.Command)
|
||||
} else {
|
||||
r = runner.NewRunner(cfg.Shell, cfg.Command)
|
||||
}
|
||||
|
||||
return model{
|
||||
config: cfg,
|
||||
lines: []runner.Line{},
|
||||
filtered: []int{},
|
||||
cursor: 0,
|
||||
offset: 0,
|
||||
filterMode: false,
|
||||
showPreview: false,
|
||||
runner: r,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd {
|
||||
// Send a message to start streaming (handled in Update with pointer receiver)
|
||||
return func() tea.Msg {
|
||||
return startStreamMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) spinnerTickCmd() tea.Cmd {
|
||||
return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return spinnerTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) streamTickCmd() tea.Cmd {
|
||||
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return streamTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) countdownTickCmd() tea.Cmd {
|
||||
gen := m.refreshGeneration
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return countdownTickMsg{generation: gen}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *model) startStreaming() tea.Cmd {
|
||||
// Cancel any existing context and create a new one
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.ctx, m.cancel = context.WithCancel(context.Background())
|
||||
|
||||
// Pass previous lines for in-place updates
|
||||
m.streamResult = m.runner.RunStreaming(m.ctx, m.lines)
|
||||
m.streaming = true
|
||||
m.loading = true
|
||||
// lastLineCount tracks the streamResult's CurrentLineCount (lines produced
|
||||
// by the current run), which starts at 0 for a fresh streaming result.
|
||||
m.lastLineCount = 0
|
||||
m.exitCode = -1
|
||||
m.errorMsg = ""
|
||||
m.userScrolled = false
|
||||
|
||||
cmds := []tea.Cmd{m.streamTickCmd()}
|
||||
|
||||
// Start refresh timer from command start if configured
|
||||
if m.config.RefreshFromStart && m.config.RefreshInterval > 0 {
|
||||
m.refreshStartTime = time.Now()
|
||||
cmds = append(cmds, m.tickCmd())
|
||||
if m.config.RefreshInterval > time.Second {
|
||||
cmds = append(cmds, m.countdownTickCmd())
|
||||
}
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKeyPress(msg)
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
|
||||
case startStreamMsg:
|
||||
cmd := m.startStreaming()
|
||||
return m, tea.Batch(cmd, m.spinnerTickCmd())
|
||||
|
||||
case resultMsg:
|
||||
m.lines = msg.lines
|
||||
m.exitCode = msg.exitCode
|
||||
m.loading = false
|
||||
m.streaming = false
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
|
||||
case streamTickMsg:
|
||||
if m.streamResult == nil {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
isDone := m.streamResult.IsDone()
|
||||
currentCount := m.streamResult.GetCurrentLineCount()
|
||||
|
||||
// Sync m.lines whenever the stream has produced new line writes since
|
||||
// the last tick (in-place edits and appends both bump CurrentLineCount),
|
||||
// or once on completion so the trim-to-currentCount path runs.
|
||||
if currentCount != m.lastLineCount || isDone {
|
||||
newLines := m.streamResult.GetLines()
|
||||
// On completion, drop any leftover slots that the new run never
|
||||
// wrote into — they still hold previous-run content.
|
||||
if isDone && currentCount < len(newLines) {
|
||||
newLines = newLines[:currentCount]
|
||||
}
|
||||
m.lines = newLines
|
||||
m.lastLineCount = currentCount
|
||||
m.updateFiltered()
|
||||
|
||||
if !m.userScrolled {
|
||||
visible := m.visibleLines()
|
||||
if visible > 0 {
|
||||
m.cursor = max(len(m.filtered)-1, 0)
|
||||
m.offset = max(len(m.filtered)-visible, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isDone {
|
||||
m.streaming = false
|
||||
m.loading = false
|
||||
m.exitCode = m.streamResult.ExitCode
|
||||
if m.streamResult.Error != nil {
|
||||
m.errorMsg = m.streamResult.Error.Error()
|
||||
}
|
||||
|
||||
// If auto-refresh is enabled and timer starts from end, schedule the next run
|
||||
if m.config.RefreshInterval > 0 && !m.config.RefreshFromStart {
|
||||
m.refreshStartTime = time.Now()
|
||||
cmds := []tea.Cmd{m.tickCmd()}
|
||||
// Start countdown display updates if interval > 1s
|
||||
if m.config.RefreshInterval > time.Second {
|
||||
cmds = append(cmds, m.countdownTickCmd())
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Continue streaming
|
||||
return m, m.streamTickCmd()
|
||||
|
||||
case tickMsg:
|
||||
// Ignore ticks from before a manual refresh
|
||||
if msg.generation != m.refreshGeneration {
|
||||
return m, nil
|
||||
}
|
||||
if m.config.RefreshInterval > 0 && !m.streaming {
|
||||
// Restart streaming for refresh
|
||||
cmd := m.startStreaming()
|
||||
return m, tea.Batch(cmd, m.spinnerTickCmd())
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case errMsg:
|
||||
m.errorMsg = msg.Error()
|
||||
m.loading = false
|
||||
m.streaming = false
|
||||
return m, nil
|
||||
|
||||
case clearStatusMsg:
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
|
||||
case spinnerTickMsg:
|
||||
if m.loading || m.streaming {
|
||||
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
|
||||
return m, m.spinnerTickCmd()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case countdownTickMsg:
|
||||
// Ignore ticks from before a manual refresh
|
||||
if msg.generation != m.refreshGeneration {
|
||||
return m, nil
|
||||
}
|
||||
// Continue ticking if waiting for auto-refresh
|
||||
if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() {
|
||||
elapsed := time.Since(m.refreshStartTime)
|
||||
if elapsed < m.config.RefreshInterval {
|
||||
return m, m.countdownTickCmd()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) tickCmd() tea.Cmd {
|
||||
gen := m.refreshGeneration
|
||||
return tea.Tick(m.config.RefreshInterval, func(t time.Time) tea.Msg {
|
||||
return tickMsg{generation: gen}
|
||||
})
|
||||
}
|
||||
87
internal/ui/update_test.go
Normal file
87
internal/ui/update_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestUpdateWindowSize(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
msg := tea.WindowSizeMsg{Width: 120, Height: 40}
|
||||
result, _ := m.Update(msg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.width != 120 {
|
||||
t.Errorf("expected width 120, got %d", newModel.width)
|
||||
}
|
||||
if newModel.height != 40 {
|
||||
t.Errorf("expected height 40, got %d", newModel.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClearStatusMsg(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.statusMsg = "some status"
|
||||
|
||||
result, _ := m.Update(clearStatusMsg{})
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.statusMsg != "" {
|
||||
t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSpinnerTick(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.loading = true
|
||||
m.spinnerFrame = 0
|
||||
|
||||
result, cmd := m.Update(spinnerTickMsg{})
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.spinnerFrame != 1 {
|
||||
t.Errorf("expected spinnerFrame 1, got %d", newModel.spinnerFrame)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command for next spinner tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSpinnerTickNotLoading(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.loading = false
|
||||
m.streaming = false
|
||||
m.spinnerFrame = 3
|
||||
|
||||
result, cmd := m.Update(spinnerTickMsg{})
|
||||
newModel := result.(*model)
|
||||
|
||||
// Frame should not advance
|
||||
if newModel.spinnerFrame != 3 {
|
||||
t.Errorf("expected spinnerFrame 3 (unchanged), got %d", newModel.spinnerFrame)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("expected no command when not loading/streaming")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateErrMsg(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.loading = true
|
||||
m.streaming = true
|
||||
|
||||
result, _ := m.Update(errMsg{err: fmt.Errorf("test error")})
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.errorMsg != "test error" {
|
||||
t.Errorf("expected errorMsg 'test error', got %q", newModel.errorMsg)
|
||||
}
|
||||
if newModel.loading {
|
||||
t.Error("expected loading false after error")
|
||||
}
|
||||
if newModel.streaming {
|
||||
t.Error("expected streaming false after error")
|
||||
}
|
||||
}
|
||||
582
internal/ui/view.go
Normal file
582
internal/ui/view.go
Normal file
@@ -0,0 +1,582 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// renderCmdPaletteOverlay creates the command palette overlay box
|
||||
func (m model) renderCmdPaletteOverlay() (box string, boxWidth, boxHeight int) {
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")) // dim
|
||||
|
||||
nameStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252"))
|
||||
|
||||
selectedNameStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
|
||||
selectedKeyStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("241"))
|
||||
|
||||
filterStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("11"))
|
||||
|
||||
borderColor := lipgloss.Color("12")
|
||||
|
||||
allCommands := commands()
|
||||
filtered := m.filteredCommands()
|
||||
|
||||
// Compute column width
|
||||
const paletteWidth = 40
|
||||
totalSlots := len(allCommands) // fixed height so box doesn't move
|
||||
|
||||
var content strings.Builder
|
||||
|
||||
// Filter input with bottom border
|
||||
before, block, after := m.cmdPaletteInput.render()
|
||||
filterLine := filterStyle.Render(":"+before) + block + filterStyle.Render(after)
|
||||
// Pad filter line to full width
|
||||
filterVisual := lipgloss.Width(filterLine)
|
||||
if filterVisual < paletteWidth {
|
||||
filterLine += strings.Repeat(" ", paletteWidth-filterVisual)
|
||||
}
|
||||
content.WriteString(filterLine + "\n")
|
||||
content.WriteString(lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", paletteWidth)) + "\n")
|
||||
|
||||
// Command list (fixed number of rows)
|
||||
for i := range totalSlots {
|
||||
if i < len(filtered) {
|
||||
cmd := filtered[i]
|
||||
gap := max(paletteWidth-lipgloss.Width(cmd.name)-lipgloss.Width(cmd.shortcut), 2)
|
||||
if i == m.cmdPaletteSelected {
|
||||
line := selectedNameStyle.Render(cmd.name+strings.Repeat(" ", gap)) + selectedKeyStyle.Render(cmd.shortcut)
|
||||
content.WriteString(line + "\n")
|
||||
} else {
|
||||
content.WriteString(nameStyle.Render(cmd.name) + strings.Repeat(" ", gap) + keyStyle.Render(cmd.shortcut) + "\n")
|
||||
}
|
||||
} else {
|
||||
content.WriteString(strings.Repeat(" ", paletteWidth) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(borderColor)
|
||||
|
||||
box = boxStyle.Render(content.String())
|
||||
boxWidth = lipgloss.Width(box)
|
||||
boxHeight = lipgloss.Height(box)
|
||||
|
||||
return box, boxWidth, boxHeight
|
||||
}
|
||||
|
||||
// renderHelpOverlay creates the help box content (without positioning)
|
||||
func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) {
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("10")) // green
|
||||
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")) // light gray
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("12")) // blue
|
||||
|
||||
// Define keybindings
|
||||
bindings := []struct {
|
||||
key string
|
||||
desc string
|
||||
}{
|
||||
{"j / k", "Move down / up"},
|
||||
{"g / G", "Go to first / last line"},
|
||||
{"Ctrl+d / Ctrl+u", "Half page down / up"},
|
||||
{"PgDn / PgUp", "Full page down / up"},
|
||||
{"Ctrl+f / Ctrl+b", "Full page down / up"},
|
||||
{"", ""},
|
||||
{"p", "Toggle preview pane"},
|
||||
{"+/-", "Resize preview pane"},
|
||||
{"J / K", "Scroll preview down / up"},
|
||||
{"/", "Enter filter mode"},
|
||||
{"//", "Toggle regex filter mode"},
|
||||
{"Esc", "Exit filter / clear"},
|
||||
{"", ""},
|
||||
{"r / Ctrl+r", "Reload command"},
|
||||
{"R", "Reload & clear lines"},
|
||||
{"d / Del", "Delete selected line"},
|
||||
{"D", "Clear all lines"},
|
||||
{"c", "Stop running command"},
|
||||
{"y", "Copy line to clipboard"},
|
||||
{"Y", "Copy line (plain text)"},
|
||||
{":", "Open command palette"},
|
||||
{"q / Esc", "Quit"},
|
||||
{"?", "Toggle this help"},
|
||||
}
|
||||
|
||||
// Build content
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("Keybindings"))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
for _, b := range bindings {
|
||||
if b.key == "" {
|
||||
content.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
key := keyStyle.Render(fmt.Sprintf("%-18s", b.key))
|
||||
desc := descStyle.Render(b.desc)
|
||||
fmt.Fprintf(&content, " %s %s\n", key, desc)
|
||||
}
|
||||
|
||||
content.WriteString("\n")
|
||||
content.WriteString(descStyle.Render("Press any key to close"))
|
||||
|
||||
// Create box style
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("12")).
|
||||
Padding(1, 2)
|
||||
|
||||
box = boxStyle.Render(content.String())
|
||||
boxWidth = lipgloss.Width(box)
|
||||
boxHeight = lipgloss.Height(box)
|
||||
|
||||
return box, boxWidth, boxHeight
|
||||
}
|
||||
|
||||
// renderConfirmOverlay creates a confirmation dialog overlay
|
||||
func (m model) renderConfirmOverlay() (box string, boxWidth, boxHeight int) {
|
||||
msgStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252"))
|
||||
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("11")).
|
||||
Padding(1, 2)
|
||||
|
||||
content := msgStyle.Render(m.confirmMessage)
|
||||
box = boxStyle.Render(content)
|
||||
boxWidth = lipgloss.Width(box)
|
||||
boxHeight = lipgloss.Height(box)
|
||||
|
||||
return box, boxWidth, boxHeight
|
||||
}
|
||||
|
||||
func (m *model) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return spinnerFrames[m.spinnerFrame] + " Running command…"
|
||||
}
|
||||
|
||||
// Render the main UI
|
||||
mainView := m.renderMainView()
|
||||
|
||||
// Overlay help if active
|
||||
if m.showHelp {
|
||||
box, boxWidth, boxHeight := m.renderHelpOverlay()
|
||||
return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height)
|
||||
}
|
||||
|
||||
// Overlay command palette if active
|
||||
if m.cmdPaletteMode {
|
||||
box, boxWidth, boxHeight := m.renderCmdPaletteOverlay()
|
||||
return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height)
|
||||
}
|
||||
|
||||
// Overlay confirmation dialog if active
|
||||
if m.confirmMode {
|
||||
box, boxWidth, boxHeight := m.renderConfirmOverlay()
|
||||
return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height)
|
||||
}
|
||||
|
||||
return mainView
|
||||
}
|
||||
|
||||
// Box drawing characters (rounded)
|
||||
const (
|
||||
boxTopLeft = "╭"
|
||||
boxTopRight = "╮"
|
||||
boxBottomLeft = "╰"
|
||||
boxBottomRight = "╯"
|
||||
boxHorizontal = "─"
|
||||
boxVertical = "│"
|
||||
boxLeftT = "├"
|
||||
boxRightT = "┤"
|
||||
boxTopT = "┬"
|
||||
boxBottomT = "┴"
|
||||
)
|
||||
|
||||
// viewContext holds shared rendering state for a single View() call.
|
||||
type viewContext struct {
|
||||
innerWidth int
|
||||
borderStyle lipgloss.Style
|
||||
}
|
||||
|
||||
func (vc viewContext) hLine(left, right string, splitPos int, junction string) string {
|
||||
if splitPos > 0 && splitPos < vc.innerWidth {
|
||||
return vc.borderStyle.Render(left + strings.Repeat(boxHorizontal, splitPos) + junction + strings.Repeat(boxHorizontal, vc.innerWidth-splitPos-1) + right)
|
||||
}
|
||||
return vc.borderStyle.Render(left + strings.Repeat(boxHorizontal, vc.innerWidth) + right)
|
||||
}
|
||||
|
||||
func (vc viewContext) padLine(content string) string {
|
||||
contentWidth := lipgloss.Width(content)
|
||||
if contentWidth < vc.innerWidth {
|
||||
content += strings.Repeat(" ", vc.innerWidth-contentWidth)
|
||||
} else if contentWidth > vc.innerWidth {
|
||||
content = lipgloss.NewStyle().MaxWidth(vc.innerWidth-1).Render(content) + ellipsis
|
||||
}
|
||||
return vc.borderStyle.Render(boxVertical) + content + vc.borderStyle.Render(boxVertical)
|
||||
}
|
||||
|
||||
func (m model) renderMainView() string {
|
||||
borderColor := lipgloss.Color("240")
|
||||
vc := viewContext{
|
||||
innerWidth: m.width - 2,
|
||||
borderStyle: lipgloss.NewStyle().Foreground(borderColor),
|
||||
}
|
||||
|
||||
commandLine := m.renderHeaderLine(vc.innerWidth)
|
||||
promptLine := m.renderPromptLine()
|
||||
listHeight, listWidth := m.listDimensions(vc.innerWidth)
|
||||
listLines := m.renderListLines(listHeight, listWidth)
|
||||
|
||||
// Preview content
|
||||
var previewContent string
|
||||
if m.showPreview && len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) {
|
||||
idx := m.filtered[m.cursor]
|
||||
if idx < len(m.lines) {
|
||||
previewContent = highlightJSON(m.lines[idx].Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
if m.errorMsg != "" {
|
||||
listLines = append(listLines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("Error: "+m.errorMsg))
|
||||
}
|
||||
|
||||
// Vertical split position for left/right preview
|
||||
var vSplitPos int
|
||||
if m.showPreview {
|
||||
switch m.config.PreviewPosition {
|
||||
case PreviewLeft:
|
||||
vSplitPos = m.previewSize()
|
||||
case PreviewRight:
|
||||
vSplitPos = vc.innerWidth - m.previewSize() - 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build the unified box
|
||||
var lines []string
|
||||
lines = append(lines, vc.hLine(boxTopLeft, boxTopRight, 0, boxTopT))
|
||||
lines = append(lines, vc.padLine(commandLine))
|
||||
lines = append(lines, vc.hLine(boxLeftT, boxRightT, vSplitPos, boxTopT))
|
||||
|
||||
// Content area
|
||||
if !m.showPreview {
|
||||
lines = append(lines, m.renderContentNoPreview(vc, listLines, listHeight)...)
|
||||
} else {
|
||||
lines = append(lines, m.renderContentWithPreview(vc, listLines, listHeight, previewContent)...)
|
||||
}
|
||||
|
||||
lines = append(lines, vc.hLine(boxBottomLeft, boxBottomRight, vSplitPos, boxBottomT))
|
||||
|
||||
return strings.Join(lines, "\n") + "\n" + promptLine
|
||||
}
|
||||
|
||||
func (m model) renderHeaderLine(innerWidth int) string {
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true)
|
||||
prefix := titleStyle.Render("watchr") + " • "
|
||||
|
||||
var commandLine string
|
||||
switch {
|
||||
case m.streaming:
|
||||
streamStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
|
||||
commandLine = prefix + streamStyle.Render("◉ "+m.config.Command)
|
||||
case m.loading:
|
||||
commandLine = prefix + m.config.Command
|
||||
case m.exitCode == 0:
|
||||
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
commandLine = prefix + successStyle.Render("✓ "+m.config.Command)
|
||||
default:
|
||||
failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command))
|
||||
}
|
||||
|
||||
if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() {
|
||||
elapsed := time.Since(m.refreshStartTime)
|
||||
remaining := m.config.RefreshInterval - elapsed
|
||||
if remaining > 0 {
|
||||
countdownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
countdown := countdownStyle.Render(fmt.Sprintf("(%ds)", int(remaining.Seconds())+1))
|
||||
cmdWidth := lipgloss.Width(commandLine)
|
||||
countdownWidth := lipgloss.Width(countdown)
|
||||
gap := innerWidth - cmdWidth - countdownWidth
|
||||
if gap > 0 {
|
||||
commandLine += strings.Repeat(" ", gap) + countdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commandLine
|
||||
}
|
||||
|
||||
func (m model) renderPromptLine() string {
|
||||
promptStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
|
||||
filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
|
||||
filterRegexStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("13"))
|
||||
filterErrStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
|
||||
var promptLine string
|
||||
switch {
|
||||
case m.filterMode && m.filterRegex:
|
||||
label := filterRegexStyle.Render("regex/")
|
||||
before, block, after := m.filterInput.render()
|
||||
input := filterStyle.Render(before) + block + filterStyle.Render(after)
|
||||
promptLine = label + input
|
||||
if m.filterRegexErr != nil {
|
||||
promptLine += " " + filterErrStyle.Render("(invalid regex)")
|
||||
}
|
||||
case m.filterMode:
|
||||
before, block, after := m.filterInput.render()
|
||||
promptLine = filterStyle.Render("/"+before) + block + filterStyle.Render(after)
|
||||
case m.filterInput.Text != "" && m.filterRegex:
|
||||
promptLine = promptStyle.Render(fmt.Sprintf("%s (regex: %s)", m.config.Prompt, m.filterInput.Text))
|
||||
case m.filterInput.Text != "":
|
||||
promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filterInput.Text))
|
||||
default:
|
||||
promptLine = promptStyle.Render(m.config.Prompt)
|
||||
}
|
||||
|
||||
if m.streaming {
|
||||
promptLine += " " + spinnerFrames[m.spinnerFrame] + " Streaming…"
|
||||
} else if m.loading {
|
||||
promptLine += " " + spinnerFrames[m.spinnerFrame] + " Running command…"
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
promptLine += " " + statusStyle.Render(m.statusMsg)
|
||||
}
|
||||
|
||||
helpHint := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("? for help")
|
||||
promptWidth := lipgloss.Width(promptLine)
|
||||
hintWidth := lipgloss.Width(helpHint)
|
||||
gap := m.width - promptWidth - hintWidth
|
||||
if gap > 0 {
|
||||
promptLine += strings.Repeat(" ", gap) + helpHint
|
||||
}
|
||||
|
||||
return promptLine
|
||||
}
|
||||
|
||||
func (m model) listDimensions(innerWidth int) (height, width int) {
|
||||
height = m.visibleLines()
|
||||
width = innerWidth - 1
|
||||
if m.showPreview && (m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight) {
|
||||
width = innerWidth - m.previewSize() - 2
|
||||
}
|
||||
return height, width
|
||||
}
|
||||
|
||||
func (m model) renderListLines(listHeight, listWidth int) []string {
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
|
||||
var listLines []string
|
||||
for i := range listHeight {
|
||||
lineIdx := m.offset + i
|
||||
if lineIdx >= len(m.filtered) {
|
||||
listLines = append(listLines, "")
|
||||
continue
|
||||
}
|
||||
|
||||
idx := m.filtered[lineIdx]
|
||||
if idx >= len(m.lines) {
|
||||
listLines = append(listLines, "")
|
||||
continue
|
||||
}
|
||||
line := m.lines[idx]
|
||||
isSelected := lineIdx == m.cursor
|
||||
fullWidth := listWidth + 1
|
||||
|
||||
var lineText string
|
||||
if m.config.ShowLineNums {
|
||||
lineNumStr := fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number)
|
||||
lineNumWidth := len(lineNumStr)
|
||||
contentWidth := listWidth - lineNumWidth
|
||||
content := truncateToWidth(line.Content, contentWidth)
|
||||
|
||||
if isSelected {
|
||||
plainContent := stripANSI(content)
|
||||
selectedLineNumStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("241"))
|
||||
selectedContentStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
contentPadded := plainContent
|
||||
padding := fullWidth - lineNumWidth - len(plainContent)
|
||||
if padding > 0 {
|
||||
contentPadded = plainContent + strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedLineNumStyle.Render(lineNumStr) + selectedContentStyle.Render(contentPadded)
|
||||
} else {
|
||||
lineText = lineNumStyle.Render(lineNumStr) + content
|
||||
}
|
||||
} else {
|
||||
lineText = truncateToWidth(line.Content, listWidth)
|
||||
if isSelected {
|
||||
lineText = stripANSI(lineText)
|
||||
padding := fullWidth - len(lineText)
|
||||
if padding > 0 {
|
||||
lineText += strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedStyle.Render(lineText)
|
||||
}
|
||||
}
|
||||
|
||||
listLines = append(listLines, lineText)
|
||||
}
|
||||
return listLines
|
||||
}
|
||||
|
||||
func (m model) renderContentNoPreview(vc viewContext, listLines []string, listHeight int) []string {
|
||||
var lines []string
|
||||
for i := range listHeight {
|
||||
if i < len(listLines) {
|
||||
lines = append(lines, vc.padLine(listLines[i]))
|
||||
} else {
|
||||
lines = append(lines, vc.padLine(""))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m model) renderContentWithPreview(vc viewContext, listLines []string, listHeight int, previewContent string) []string {
|
||||
switch m.config.PreviewPosition {
|
||||
case PreviewTop, PreviewBottom:
|
||||
return m.renderVerticalPreview(vc, listLines, listHeight, previewContent)
|
||||
case PreviewLeft, PreviewRight:
|
||||
return m.renderHorizontalPreview(vc, listLines, listHeight, previewContent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) renderVerticalPreview(vc viewContext, listLines []string, listHeight int, previewContent string) []string {
|
||||
previewH := m.previewSize()
|
||||
|
||||
var previewLines []string
|
||||
if previewContent != "" {
|
||||
previewLines = wrapPreviewContent(previewContent, vc.innerWidth)
|
||||
}
|
||||
previewLines = m.applyPreviewOffset(previewLines, previewH)
|
||||
for len(previewLines) < previewH {
|
||||
previewLines = append(previewLines, "")
|
||||
}
|
||||
|
||||
paddedList := m.renderContentNoPreview(vc, listLines, listHeight)
|
||||
var paddedPreview []string
|
||||
for _, line := range previewLines[:previewH] {
|
||||
paddedPreview = append(paddedPreview, vc.padLine(line))
|
||||
}
|
||||
|
||||
separator := vc.hLine(boxLeftT, boxRightT, 0, boxTopT)
|
||||
|
||||
if m.config.PreviewPosition == PreviewTop {
|
||||
result := paddedPreview
|
||||
result = append(result, separator)
|
||||
result = append(result, paddedList...)
|
||||
return result
|
||||
}
|
||||
// PreviewBottom
|
||||
result := paddedList
|
||||
result = append(result, separator)
|
||||
result = append(result, paddedPreview...)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *model) renderHorizontalPreview(vc viewContext, listLines []string, listHeight int, previewContent string) []string {
|
||||
var leftW, rightW int
|
||||
if m.config.PreviewPosition == PreviewLeft {
|
||||
leftW = m.previewSize()
|
||||
rightW = vc.innerWidth - leftW - 1
|
||||
} else {
|
||||
rightW = m.previewSize()
|
||||
leftW = vc.innerWidth - rightW - 1
|
||||
}
|
||||
|
||||
var previewLines []string
|
||||
if previewContent != "" {
|
||||
previewW := leftW
|
||||
if m.config.PreviewPosition == PreviewRight {
|
||||
previewW = rightW
|
||||
}
|
||||
previewLines = wrapPreviewContent(previewContent, previewW)
|
||||
}
|
||||
previewLines = m.applyPreviewOffset(previewLines, listHeight)
|
||||
for len(previewLines) < listHeight {
|
||||
previewLines = append(previewLines, "")
|
||||
}
|
||||
|
||||
fitToWidth := func(s string, w int, isPreview bool) string {
|
||||
sw := lipgloss.Width(s)
|
||||
if sw > w {
|
||||
if isPreview {
|
||||
return s + strings.Repeat(" ", w-sw)
|
||||
}
|
||||
return lipgloss.NewStyle().MaxWidth(w-1).Render(s) + ellipsis
|
||||
}
|
||||
return s + strings.Repeat(" ", w-sw)
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i := range listHeight {
|
||||
var leftContent, rightContent string
|
||||
var leftIsPreview, rightIsPreview bool
|
||||
|
||||
if m.config.PreviewPosition == PreviewLeft {
|
||||
leftContent = previewLines[i]
|
||||
leftIsPreview = true
|
||||
if i < len(listLines) {
|
||||
rightContent = listLines[i]
|
||||
}
|
||||
} else {
|
||||
if i < len(listLines) {
|
||||
leftContent = listLines[i]
|
||||
}
|
||||
rightContent = previewLines[i]
|
||||
rightIsPreview = true
|
||||
}
|
||||
|
||||
leftContent = fitToWidth(leftContent, leftW, leftIsPreview)
|
||||
rightContent = fitToWidth(rightContent, rightW, rightIsPreview)
|
||||
|
||||
line := vc.borderStyle.Render(boxVertical) + leftContent + vc.borderStyle.Render(boxVertical) + rightContent + vc.borderStyle.Render(boxVertical)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// Run starts the UI
|
||||
func Run(cfg Config) error {
|
||||
if cfg.PreviewPosition == "" {
|
||||
cfg.PreviewPosition = PreviewBottom
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
p := tea.NewProgram(&m, tea.WithAltScreen())
|
||||
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
104
internal/ui/view_test.go
Normal file
104
internal/ui/view_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderHelpOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
box, boxWidth, boxHeight := m.renderHelpOverlay()
|
||||
|
||||
if boxWidth == 0 || boxHeight == 0 {
|
||||
t.Error("expected non-zero overlay dimensions")
|
||||
}
|
||||
|
||||
// Should contain some keybinding text
|
||||
if !strings.Contains(box, "Keybindings") {
|
||||
t.Error("expected help overlay to contain 'Keybindings'")
|
||||
}
|
||||
if !strings.Contains(box, "Reload command") {
|
||||
t.Error("expected help overlay to contain 'Reload command'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderConfirmOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.confirmMessage = "Delete everything?"
|
||||
|
||||
box, boxWidth, boxHeight := m.renderConfirmOverlay()
|
||||
|
||||
if boxWidth == 0 || boxHeight == 0 {
|
||||
t.Error("expected non-zero overlay dimensions")
|
||||
}
|
||||
|
||||
if !strings.Contains(box, "Delete everything?") {
|
||||
t.Error("expected confirm overlay to contain the message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCmdPaletteOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cmdPaletteMode = true
|
||||
m.cmdPaletteInput.Text = ""
|
||||
m.cmdPaletteInput.Cursor = 0
|
||||
m.cmdPaletteSelected = 0
|
||||
|
||||
box, boxWidth, boxHeight := m.renderCmdPaletteOverlay()
|
||||
|
||||
if boxWidth == 0 || boxHeight == 0 {
|
||||
t.Error("expected non-zero overlay dimensions")
|
||||
}
|
||||
|
||||
// Should contain command names
|
||||
if !strings.Contains(box, "Reload command") {
|
||||
t.Error("expected palette to contain 'Reload command'")
|
||||
}
|
||||
if !strings.Contains(box, "Quit") {
|
||||
t.Error("expected palette to contain 'Quit'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewInitialLoading(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.width = 0
|
||||
m.height = 0
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "Running command") {
|
||||
t.Errorf("expected loading view, got %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewWithHelpOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showHelp = true
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "Keybindings") {
|
||||
t.Error("expected help overlay in view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewWithConfirmOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.confirmMode = true
|
||||
m.confirmMessage = "Are you sure?"
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "Are you sure?") {
|
||||
t.Error("expected confirm overlay in view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewWithCmdPalette(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cmdPaletteMode = true
|
||||
m.cmdPaletteInput.Text = ""
|
||||
m.cmdPaletteInput.Cursor = 0
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "Reload command") {
|
||||
t.Error("expected command palette in view")
|
||||
}
|
||||
}
|
||||
4
main.go
4
main.go
@@ -47,6 +47,9 @@ 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, " R Reload & clear all lines\n")
|
||||
_, _ = fmt.Fprintf(w, " d, Del Delete selected line\n")
|
||||
_, _ = fmt.Fprintf(w, " D Clear all lines\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")
|
||||
@@ -58,6 +61,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")
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.7.0
|
||||
1.10.1
|
||||
|
||||
Reference in New Issue
Block a user