6 Commits

Author SHA1 Message Date
github-actions[bot]
6740278feb chore(master): release 1.10.1 2026-05-04 22:22:06 +00:00
6a2df0a15d fix: command re-running caused early line truncating 2026-05-05 01:20:38 +03:00
github-actions[bot]
6dddf7fd2b chore(master): release 1.10.0 2026-03-25 01:59:41 +02:00
351aa0331b fix: unify all text input behavior, fix cursor navigations
fix: text cursor visual
fix: text delete word
fix: home/end navigation
2026-03-25 01:57:44 +02:00
6b3abbb9d3 refactor: major code reorganization, file splitting, deduping 2026-03-25 01:40:46 +02:00
c4e4d5d0de feat: add reload+clear, delete line, delete all lines 2026-03-25 01:40:46 +02:00
24 changed files with 4157 additions and 2696 deletions

View File

@@ -1,5 +1,27 @@
# 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)

View File

@@ -195,6 +195,9 @@ Configuration values are applied in this order (later sources override earlier o
| 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 |

152
internal/ui/actions.go Normal file
View 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
View 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
View 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()
}

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

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

180
internal/ui/keys.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,860 +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 testModel(cfg Config) *model {
m := initialModel(cfg)
return &m
}
func TestFilterCursorMovement(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "hello"
m.filterCursor = 5
// Left arrow moves cursor left
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4 after left, got %d", m.filterCursor)
}
// Left again
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 3 {
t.Errorf("expected filterCursor 3 after second left, got %d", m.filterCursor)
}
// Left doesn't go below 0
m.filterCursor = 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor)
}
// Right arrow moves cursor right
m.filterCursor = 2
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 3 {
t.Errorf("expected filterCursor 3 after right, got %d", m.filterCursor)
}
// Right doesn't go past end
m.filterCursor = 5
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 5 {
t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterCursor)
}
}
func TestFilterAltLeftRight(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
t.Run("alt+left jumps to previous word boundary", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar baz"
m.filterCursor = 11 // end
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 8 {
t.Errorf("expected filterCursor 8, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
// Already at start, stays at 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor)
}
})
t.Run("alt+right jumps to next word boundary", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar baz"
m.filterCursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 8 {
t.Errorf("expected filterCursor 8, got %d", m.filterCursor)
}
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 11 {
t.Errorf("expected filterCursor 11, got %d", m.filterCursor)
}
// Already at end, stays at 11
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 11 {
t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterCursor)
}
})
t.Run("alt+left skips trailing spaces", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar"
m.filterCursor = 6 // middle of spaces, before "bar"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
})
t.Run("alt+right skips trailing spaces", func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = "foo bar"
m.filterCursor = 3 // end of "foo"
keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterCursor != 6 {
t.Errorf("expected filterCursor 6, got %d", m.filterCursor)
}
})
}
func TestFilterInsertAtCursor(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "helo"
m.filterCursor = 3
// Insert 'l' at position 3 -> "hello"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "hello" {
t.Errorf("expected filter 'hello', got %q", m.filter)
}
if m.filterCursor != 4 {
t.Errorf("expected filterCursor 4, got %d", m.filterCursor)
}
}
func TestFilterBackspaceAtCursor(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "hello"
m.filterCursor = 3
// Backspace at position 3 -> "helo"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "helo" {
t.Errorf("expected filter 'helo', got %q", m.filter)
}
if m.filterCursor != 2 {
t.Errorf("expected filterCursor 2, got %d", m.filterCursor)
}
// Backspace at position 0 does nothing
m.filterCursor = 0
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "helo" {
t.Errorf("expected filter 'helo' (unchanged), got %q", m.filter)
}
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
}
func TestFilterAltBackspace(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
tests := []struct {
name string
filter string
cursor int
expectedFilter string
expectedCursor int
}{
{"delete last word", "hello world", 11, "hello ", 6},
{"delete middle word", "foo bar baz", 7, "foo baz", 4},
{"delete first word", "hello world", 5, " world", 0},
{"delete with trailing spaces", "hello ", 8, "", 0},
{"cursor at start", "hello", 0, "hello", 0},
{"single word", "hello", 5, "", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := testModel(cfg)
m.filterMode = true
m.filter = tt.filter
m.filterCursor = tt.cursor
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}
result, _ := m.handleKeyPress(keyMsg)
newModel := result.(*model)
if newModel.filter != tt.expectedFilter {
t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filter)
}
if newModel.filterCursor != tt.expectedCursor {
t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterCursor)
}
})
}
}
func TestFilterRegexToggle(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = ""
m.filterCursor = 0
// Type '/' on empty filter toggles regex mode on
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if !m.filterRegex {
t.Error("expected filterRegex to be true after typing /")
}
if m.filter != "" {
t.Errorf("expected empty filter, got %q", m.filter)
}
// Type '/' again on empty filter toggles regex mode off
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterRegex {
t.Error("expected filterRegex to be false after second /")
}
// Type '/' when filter is non-empty adds it to filter
m.filterRegex = true
m.filter = "abc"
m.filterCursor = 3
result, _ = m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filter != "abc/" {
t.Errorf("expected filter 'abc/', got %q", m.filter)
}
if !m.filterRegex {
t.Error("expected filterRegex to remain true")
}
}
func TestFilterRegexMatching(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := initialModel(cfg)
m.lines = []runner.Line{
{Number: 1, Content: "hello world"},
{Number: 2, Content: "foo bar"},
{Number: 3, Content: "hello foo"},
{Number: 4, Content: "baz 123 qux"},
}
// Regex filter matching
m.filterRegex = true
m.filter = "hello.*foo"
m.updateFiltered()
if len(m.filtered) != 1 {
t.Errorf("expected 1 match for regex 'hello.*foo', got %d", len(m.filtered))
}
if len(m.filtered) > 0 && m.filtered[0] != 2 {
t.Errorf("expected match at index 2, got %d", m.filtered[0])
}
// Regex with character class
m.filter = "\\d+"
m.updateFiltered()
if len(m.filtered) != 1 {
t.Errorf("expected 1 match for regex '\\d+', got %d", len(m.filtered))
}
// Regex is case insensitive
m.filter = "HELLO"
m.updateFiltered()
if len(m.filtered) != 2 {
t.Errorf("expected 2 matches for case-insensitive regex 'HELLO', got %d", len(m.filtered))
}
}
func TestFilterRegexInvalid(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := initialModel(cfg)
m.lines = []runner.Line{
{Number: 1, Content: "hello world"},
{Number: 2, Content: "foo bar"},
}
m.filterRegex = true
m.filter = "[invalid"
m.updateFiltered()
// Should have an error
if m.filterRegexErr == nil {
t.Error("expected filterRegexErr to be non-nil for invalid regex")
}
// Should show all lines when regex is invalid
if len(m.filtered) != 2 {
t.Errorf("expected all 2 lines shown for invalid regex, got %d", len(m.filtered))
}
// Valid regex clears the error
m.filter = "hello"
m.updateFiltered()
if m.filterRegexErr != nil {
t.Errorf("expected filterRegexErr to be nil for valid regex, got %v", m.filterRegexErr)
}
}
func TestFilterEscClearsRegex(t *testing.T) {
cfg := Config{Command: "echo test", Shell: "sh"}
m := testModel(cfg)
m.filterMode = true
m.filter = "test"
m.filterCursor = 4
m.filterRegex = true
// Esc in filter mode clears everything
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
result, _ := m.handleKeyPress(keyMsg)
m = result.(*model)
if m.filterMode {
t.Error("expected filterMode to be false")
}
if m.filter != "" {
t.Errorf("expected empty filter, got %q", m.filter)
}
if m.filterCursor != 0 {
t.Errorf("expected filterCursor 0, got %d", m.filterCursor)
}
if m.filterRegex {
t.Error("expected filterRegex to be false")
}
}
func TestStopCommandKeybinding(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
t.Run("stops running command when streaming", func(t *testing.T) {
m := initialModel(cfg)
// Set up a cancellable context to track if cancel was called
ctx, cancel := context.WithCancel(context.Background())
m.ctx = ctx
m.cancel = cancel
m.streaming = true
m.statusMsg = ""
// Simulate pressing 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}
result, cmd := m.handleKeyPress(keyMsg)
newModel := result.(*model)
// Should set status message
if newModel.statusMsg != "Command stopped" {
t.Errorf("expected statusMsg 'Command stopped', got %q", newModel.statusMsg)
}
// Should return a command (the tick for clearing status)
if cmd == nil {
t.Error("expected a command to be returned for status message timeout")
}
// Context should be cancelled
select {
case <-ctx.Done():
// Good, context was cancelled
default:
t.Error("expected context to be cancelled")
}
})
t.Run("does nothing when not streaming", func(t *testing.T) {
m := initialModel(cfg)
ctx, cancel := context.WithCancel(context.Background())
m.ctx = ctx
m.cancel = cancel
m.streaming = false
m.statusMsg = ""
// Simulate pressing 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}
result, cmd := m.handleKeyPress(keyMsg)
newModel := result.(*model)
// Should not set status message
if newModel.statusMsg != "" {
t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg)
}
// Should not return a command
if cmd != nil {
t.Error("expected no command to be returned when not streaming")
}
// Context should NOT be cancelled
select {
case <-ctx.Done():
t.Error("expected context to NOT be cancelled when not streaming")
default:
// Good, context is still active
}
})
}

223
internal/ui/update.go Normal file
View 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}
})
}

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

View File

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

View File

@@ -1 +1 @@
1.9.0
1.10.1