refactor: major code reorganization, file splitting, deduping

This commit is contained in:
2026-03-25 01:37:35 +02:00
parent c4e4d5d0de
commit 6b3abbb9d3
21 changed files with 3933 additions and 2799 deletions

View File

@@ -196,8 +196,8 @@ Configuration values are applied in this order (later sources override earlier o
| ------------------ | -------------------------------- |
| `r`, `Ctrl-r` | Reload (re-run command) |
| `R` | Reload & clear all lines |
| `Del` | Delete selected line |
| `Ctrl-Del` | Clear all lines (with confirm) |
| `d`, `Del` | Delete selected line |
| `D` | Clear all lines |
| `c` | Stop running command |
| `q`, `Esc` | Quit |
| `j`, `k` | Move down/up |

153
internal/ui/actions.go Normal file
View File

@@ -0,0 +1,153 @@
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.filterCursor = len(m.filter)
return m, nil
}
func (m *model) actionToggleRegexFilter() (tea.Model, tea.Cmd) {
m.filterMode = true
m.filterRegex = !m.filterRegex
m.filterRegexErr = nil
m.filterCursor = len(m.filter)
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.cmdPaletteFilter = ""
m.cmdPaletteCursor = 0
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.cmdPaletteFilter == "" {
return all
}
filter := strings.ToLower(m.cmdPaletteFilter)
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.cmdPaletteFilter = ""
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.cmdPaletteFilter = "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.cmdPaletteFilter = "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
}

230
internal/ui/keys.go Normal file
View File

@@ -0,0 +1,230 @@
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.cmdPaletteFilter = ""
m.cmdPaletteCursor = 0
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.cmdPaletteFilter = ""
m.cmdPaletteCursor = 0
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
case tea.KeyLeft:
if msg.Alt {
m.cmdPaletteCursor = wordBoundaryLeft(m.cmdPaletteFilter, m.cmdPaletteCursor)
} else if m.cmdPaletteCursor > 0 {
m.cmdPaletteCursor--
}
return m, nil
case tea.KeyRight:
if msg.Alt {
m.cmdPaletteCursor = wordBoundaryRight(m.cmdPaletteFilter, m.cmdPaletteCursor)
} else if m.cmdPaletteCursor < len(m.cmdPaletteFilter) {
m.cmdPaletteCursor++
}
return m, nil
case tea.KeyBackspace:
if msg.Alt {
m.cmdPaletteFilter, m.cmdPaletteCursor = textBackspaceWord(m.cmdPaletteFilter, m.cmdPaletteCursor)
} else {
m.cmdPaletteFilter, m.cmdPaletteCursor = textBackspace(m.cmdPaletteFilter, m.cmdPaletteCursor)
}
m.cmdPaletteSelected = 0
return m, nil
default:
if len(msg.Runes) > 0 {
m.cmdPaletteFilter, m.cmdPaletteCursor = textInsert(m.cmdPaletteFilter, string(msg.Runes), m.cmdPaletteCursor)
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.filter = ""
m.filterCursor = 0
m.filterRegex = false
m.filterRegexErr = nil
m.updateFiltered()
return m, nil
case tea.KeyEnter:
m.filterMode = false
return m, nil
case tea.KeyLeft:
if msg.Alt {
m.filterCursor = wordBoundaryLeft(m.filter, m.filterCursor)
} else if m.filterCursor > 0 {
m.filterCursor--
}
return m, nil
case tea.KeyRight:
if msg.Alt {
m.filterCursor = wordBoundaryRight(m.filter, m.filterCursor)
} else if m.filterCursor < len(m.filter) {
m.filterCursor++
}
return m, nil
case tea.KeyBackspace:
if msg.Alt {
m.filter, m.filterCursor = textBackspaceWord(m.filter, m.filterCursor)
} else {
m.filter, m.filterCursor = textBackspace(m.filter, m.filterCursor)
}
m.updateFiltered()
return m, nil
default:
if len(msg.Runes) > 0 {
s := string(msg.Runes)
if s == "/" && m.filter == "" {
m.filterRegex = !m.filterRegex
m.filterRegexErr = nil
} else {
m.filter, m.filterCursor = textInsert(m.filter, s, m.filterCursor)
}
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.filter != "" || m.filterRegex {
m.filter = ""
m.filterCursor = 0
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.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 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
}
})
}
// 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.cmdPaletteFilter != "r" {
t.Errorf("expected cmdPaletteFilter 'r', got %q", newModel.cmdPaletteFilter)
}
// 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.filter != "" {
re, err := regexp.Compile("(?i)" + m.filter)
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.filter)
for i, line := range m.lines {
if m.filter == "" || 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.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 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 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)
}
}

98
internal/ui/model.go Normal file
View File

@@ -0,0 +1,98 @@
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
filter string
filterCursor int // cursor position within filter string
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
cmdPaletteFilter string // current filter text
cmdPaletteCursor int // cursor position within filter string
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)
}
}

346
internal/ui/text.go Normal file
View File

@@ -0,0 +1,346 @@
package ui
import (
"strings"
"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()
}
// wordBoundaryLeft returns the cursor position after jumping left to the previous word boundary.
func wordBoundaryLeft(s string, pos int) int {
for pos > 0 && s[pos-1] == ' ' {
pos--
}
for pos > 0 && s[pos-1] != ' ' {
pos--
}
return pos
}
// wordBoundaryRight returns the cursor position after jumping right to the next word boundary.
func wordBoundaryRight(s string, pos int) int {
for pos < len(s) && s[pos] != ' ' {
pos++
}
for pos < len(s) && s[pos] == ' ' {
pos++
}
return pos
}
// textInsert inserts a string at the cursor position, returning new text and cursor.
func textInsert(text, insert string, cursor int) (string, int) {
return text[:cursor] + insert + text[cursor:], cursor + len(insert)
}
// textBackspace deletes one character before the cursor, returning new text and cursor.
func textBackspace(text string, cursor int) (string, int) {
if cursor <= 0 {
return text, cursor
}
return text[:cursor-1] + text[cursor:], cursor - 1
}
// textBackspaceWord deletes the word before the cursor, returning new text and cursor.
func textBackspaceWord(text string, cursor int) (string, int) {
if cursor <= 0 {
return text, cursor
}
newPos := wordBoundaryLeft(text, cursor)
return text[:newPos] + text[cursor:], newPos
}
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")
}

205
internal/ui/text_test.go Normal file
View File

@@ -0,0 +1,205 @@
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 TestWordBoundaryLeft(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) {
got := wordBoundaryLeft(tt.s, tt.pos)
if got != tt.want {
t.Errorf("wordBoundaryLeft(%q, %d) = %d, want %d", tt.s, tt.pos, got, tt.want)
}
})
}
}
func TestWordBoundaryRight(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) {
got := wordBoundaryRight(tt.s, tt.pos)
if got != tt.want {
t.Errorf("wordBoundaryRight(%q, %d) = %d, want %d", tt.s, tt.pos, got, tt.want)
}
})
}
}
func TestTextInsert(t *testing.T) {
text, cursor := textInsert("helo", "l", 3)
if text != "hello" || cursor != 4 {
t.Errorf("got %q cursor %d, want 'hello' cursor 4", text, cursor)
}
}
func TestTextBackspace(t *testing.T) {
text, cursor := textBackspace("hello", 3)
if text != "helo" || cursor != 2 {
t.Errorf("got %q cursor %d, want 'helo' cursor 2", text, cursor)
}
// At start, no change
text, cursor = textBackspace("hello", 0)
if text != "hello" || cursor != 0 {
t.Errorf("got %q cursor %d, want 'hello' cursor 0", text, cursor)
}
}
func TestTextBackspaceWord(t *testing.T) {
text, cursor := textBackspaceWord("hello world", 11)
if text != "hello " || cursor != 6 {
t.Errorf("got %q cursor %d, want 'hello ' cursor 6", text, cursor)
}
// At start, no change
text, cursor = textBackspaceWord("hello", 0)
if text != "hello" || cursor != 0 {
t.Errorf("got %q cursor %d, want 'hello' cursor 0", text, 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,
filter: "",
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
m.lastLineCount = len(m.lines)
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
}
// Check for new lines
newLines := m.streamResult.GetLines()
newCount := len(newLines)
if newCount != m.lastLineCount {
m.lines = newLines
m.lastLineCount = newCount
m.updateFiltered()
// Auto-scroll to bottom if user hasn't manually scrolled
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)
}
}
}
// Check if command completed
if m.streamResult.IsDone() {
m.streaming = false
m.loading = false
m.exitCode = m.streamResult.ExitCode
if m.streamResult.Error != nil {
m.errorMsg = m.streamResult.Error.Error()
}
// Trim excess lines from previous run
currentCount := m.streamResult.GetCurrentLineCount()
if currentCount < len(m.lines) {
m.lines = m.lines[:currentCount]
m.updateFiltered()
}
// 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")
}
}

585
internal/ui/view.go Normal file
View File

@@ -0,0 +1,585 @@
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 := m.cmdPaletteFilter[:m.cmdPaletteCursor]
after := m.cmdPaletteFilter[m.cmdPaletteCursor:]
filterLine := filterStyle.Render(":"+before) + "█" + 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 := m.filter[:m.filterCursor]
after := m.filter[m.filterCursor:]
input := filterStyle.Render(before) + "█" + filterStyle.Render(after)
promptLine = label + input
if m.filterRegexErr != nil {
promptLine += " " + filterErrStyle.Render("(invalid regex)")
}
case m.filterMode:
before := m.filter[:m.filterCursor]
after := m.filter[m.filterCursor:]
promptLine = filterStyle.Render("/"+before) + "█" + filterStyle.Render(after)
case m.filter != "" && m.filterRegex:
promptLine = promptStyle.Render(fmt.Sprintf("%s (regex: %s)", m.config.Prompt, m.filter))
case m.filter != "":
promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filter))
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.cmdPaletteFilter = ""
m.cmdPaletteCursor = 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.cmdPaletteFilter = ""
m.cmdPaletteCursor = 0
view := m.View()
if !strings.Contains(view, "Reload command") {
t.Error("expected command palette in view")
}
}

View File

@@ -48,8 +48,8 @@ func main() {
_, _ = 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, " Del Delete selected line\n")
_, _ = fmt.Fprintf(w, " Ctrl-Del Clear all lines (with confirm)\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")