mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-17 17:28:06 +00:00
refactor: major code reorganization, file splitting, deduping
This commit is contained in:
@@ -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
153
internal/ui/actions.go
Normal 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
197
internal/ui/actions_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestActionReload(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
_, cmd := m.actionReload()
|
||||
if m.refreshGeneration != 1 {
|
||||
t.Errorf("expected refreshGeneration 1, got %d", m.refreshGeneration)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected command to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionReloadClear(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
originalLines := len(m.lines)
|
||||
if originalLines == 0 {
|
||||
t.Fatal("expected lines to be populated")
|
||||
}
|
||||
|
||||
_, cmd := m.actionReloadClear()
|
||||
if m.lines != nil {
|
||||
t.Errorf("expected lines nil, got %d", len(m.lines))
|
||||
}
|
||||
if m.refreshGeneration != 1 {
|
||||
t.Errorf("expected refreshGeneration 1, got %d", m.refreshGeneration)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected command to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionDeleteLine(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 1 // "foo bar"
|
||||
m.actionDeleteLine()
|
||||
|
||||
for _, line := range m.lines {
|
||||
if line.Content == "foo bar" {
|
||||
t.Error("expected 'foo bar' to be deleted")
|
||||
}
|
||||
}
|
||||
if len(m.lines) != 3 {
|
||||
t.Errorf("expected 3 lines, got %d", len(m.lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionDeleteLineEmpty(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
// Should not panic
|
||||
m.actionDeleteLine()
|
||||
}
|
||||
|
||||
func TestActionClearAllLines(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionClearAllLines()
|
||||
|
||||
if !m.confirmMode {
|
||||
t.Error("expected confirmMode true")
|
||||
}
|
||||
if m.confirmMessage != "Clear all lines? (y/N)" {
|
||||
t.Errorf("expected confirm message, got %q", m.confirmMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionStopCommand(t *testing.T) {
|
||||
m := testModelWithCancel()
|
||||
m.streaming = true
|
||||
|
||||
_, cmd := m.actionStopCommand()
|
||||
if m.statusMsg != "Command stopped" {
|
||||
t.Errorf("expected 'Command stopped', got %q", m.statusMsg)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected timeout command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionStopCommandNotStreaming(t *testing.T) {
|
||||
m := testModelWithCancel()
|
||||
m.streaming = false
|
||||
|
||||
_, cmd := m.actionStopCommand()
|
||||
if m.statusMsg != "" {
|
||||
t.Errorf("expected empty status, got %q", m.statusMsg)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("expected nil command when not streaming")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionTogglePreview(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = false
|
||||
|
||||
m.actionTogglePreview()
|
||||
if !m.showPreview {
|
||||
t.Error("expected showPreview true")
|
||||
}
|
||||
|
||||
m.actionTogglePreview()
|
||||
if m.showPreview {
|
||||
t.Error("expected showPreview false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPreviewResize(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.showPreview = true
|
||||
m.config.PreviewSize = 10
|
||||
m.config.PreviewSizeIsPercent = false
|
||||
|
||||
m.actionIncreasePreview()
|
||||
if m.config.PreviewSize != 12 {
|
||||
t.Errorf("expected 12, got %d", m.config.PreviewSize)
|
||||
}
|
||||
|
||||
m.actionDecreasePreview()
|
||||
if m.config.PreviewSize != 10 {
|
||||
t.Errorf("expected 10, got %d", m.config.PreviewSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGoToFirstLast(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 2
|
||||
|
||||
m.actionGoToFirst()
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor 0, got %d", m.cursor)
|
||||
}
|
||||
|
||||
m.actionGoToLast()
|
||||
if m.cursor != len(m.filtered)-1 {
|
||||
t.Errorf("expected cursor %d, got %d", len(m.filtered)-1, m.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionCopyLine(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cursor = 0
|
||||
|
||||
// Test copy (may succeed or fail depending on clipboard availability)
|
||||
_, cmd := m.actionCopyLine(false)
|
||||
if m.statusMsg == "" {
|
||||
t.Error("expected statusMsg to be set")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected timeout command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionCopyLinePlain(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.lines = []runner.Line{{Number: 1, Content: "\x1b[31mred\x1b[0m"}}
|
||||
m.updateFiltered()
|
||||
m.cursor = 0
|
||||
|
||||
_, cmd := m.actionCopyLine(true)
|
||||
if m.statusMsg == "" {
|
||||
t.Error("expected statusMsg to be set")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected timeout command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionShowHelp(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionShowHelp()
|
||||
if !m.showHelp {
|
||||
t.Error("expected showHelp true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionOpenPalette(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionOpenPalette()
|
||||
if !m.cmdPaletteMode {
|
||||
t.Error("expected cmdPaletteMode true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionEnterFilter(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.actionEnterFilter()
|
||||
if !m.filterMode {
|
||||
t.Error("expected filterMode true")
|
||||
}
|
||||
}
|
||||
79
internal/ui/commands.go
Normal file
79
internal/ui/commands.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// command represents a command palette entry
|
||||
type command struct {
|
||||
name string // display name
|
||||
shortcut string // keybinding hint
|
||||
action func(m *model) (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
// commands returns the list of available command palette entries.
|
||||
func commands() []command {
|
||||
return []command{
|
||||
{"Reload command", "r / Ctrl+r", (*model).actionReload},
|
||||
{"Reload & clear lines", "R", (*model).actionReloadClear},
|
||||
{"Delete selected line", "d / Del", (*model).actionDeleteLine},
|
||||
{"Clear all lines", "D", (*model).actionClearAllLines},
|
||||
{"Stop running command", "c", (*model).actionStopCommand},
|
||||
{"Toggle preview pane", "p", (*model).actionTogglePreview},
|
||||
{"Increase preview size", "+", (*model).actionIncreasePreview},
|
||||
{"Decrease preview size", "-", (*model).actionDecreasePreview},
|
||||
{"Go to first line", "g", (*model).actionGoToFirst},
|
||||
{"Go to last line", "G", (*model).actionGoToLast},
|
||||
{"Enter filter mode", "/", (*model).actionEnterFilter},
|
||||
{"Toggle regex filter", "//", (*model).actionToggleRegexFilter},
|
||||
{"Copy line to clipboard", "y", func(m *model) (tea.Model, tea.Cmd) { return m.actionCopyLine(false) }},
|
||||
{"Copy line (plain text)", "Y", func(m *model) (tea.Model, tea.Cmd) { return m.actionCopyLine(true) }},
|
||||
{"Show help", "?", (*model).actionShowHelp},
|
||||
{"Quit", "q", (*model).actionQuit},
|
||||
}
|
||||
}
|
||||
|
||||
// filteredCommands returns commands matching the current palette filter.
|
||||
func (m *model) filteredCommands() []command {
|
||||
all := commands()
|
||||
if m.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()
|
||||
}
|
||||
59
internal/ui/commands_test.go
Normal file
59
internal/ui/commands_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandsCount(t *testing.T) {
|
||||
cmds := commands()
|
||||
if len(cmds) != 16 {
|
||||
t.Errorf("expected 16 commands, got %d", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredCommandsNoFilter(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.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")
|
||||
}
|
||||
}
|
||||
36
internal/ui/helpers_test.go
Normal file
36
internal/ui/helpers_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func testModel(cfg Config) *model {
|
||||
m := initialModel(cfg)
|
||||
return &m
|
||||
}
|
||||
|
||||
func testModelWithLines() *model {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "hello world"},
|
||||
{Number: 2, Content: "foo bar"},
|
||||
{Number: 3, Content: "hello foo"},
|
||||
{Number: 4, Content: "baz qux"},
|
||||
}
|
||||
m.height = 30
|
||||
m.width = 80
|
||||
m.updateFiltered()
|
||||
return m
|
||||
}
|
||||
|
||||
func testModelWithCancel() *model {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := initialModel(cfg)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.ctx = ctx
|
||||
m.cancel = cancel
|
||||
return &m
|
||||
}
|
||||
230
internal/ui/keys.go
Normal file
230
internal/ui/keys.go
Normal 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
793
internal/ui/keys_test.go
Normal file
@@ -0,0 +1,793 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestFilterCursorMovement(t *testing.T) {
|
||||
cfg := Config{Command: "echo test", Shell: "sh"}
|
||||
m := testModel(cfg)
|
||||
m.filterMode = true
|
||||
m.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
161
internal/ui/layout.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m *model) moveCursor(delta int) {
|
||||
m.previewOffset = 0
|
||||
m.cursor += delta
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
if m.cursor >= len(m.filtered) {
|
||||
m.cursor = len(m.filtered) - 1
|
||||
}
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
m.adjustOffset()
|
||||
}
|
||||
|
||||
func (m *model) adjustOffset() {
|
||||
visible := m.visibleLines()
|
||||
if visible <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to center the cursor
|
||||
idealOffset := m.cursor - visible/2
|
||||
|
||||
// Clamp to valid range
|
||||
idealOffset = max(idealOffset, 0)
|
||||
maxOffset := max(len(m.filtered)-visible, 0)
|
||||
idealOffset = min(idealOffset, maxOffset)
|
||||
|
||||
m.offset = idealOffset
|
||||
}
|
||||
|
||||
func previewSizeStep(isPercent bool) int {
|
||||
if isPercent {
|
||||
return 5
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// clampPreviewOffset computes the actual preview content size and clamps
|
||||
// previewOffset so it can't exceed the scrollable range.
|
||||
func (m *model) clampPreviewOffset() {
|
||||
if !m.showPreview || m.cursor < 0 || m.cursor >= len(m.filtered) {
|
||||
m.previewOffset = 0
|
||||
return
|
||||
}
|
||||
idx := m.filtered[m.cursor]
|
||||
if idx >= len(m.lines) {
|
||||
m.previewOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
content := highlightJSON(m.lines[idx].Content)
|
||||
innerWidth := m.width - 2
|
||||
|
||||
var previewW, visibleH int
|
||||
switch m.config.PreviewPosition {
|
||||
case PreviewTop, PreviewBottom:
|
||||
previewW = innerWidth
|
||||
visibleH = m.previewSize()
|
||||
case PreviewLeft:
|
||||
previewW = m.previewSize()
|
||||
visibleH = m.visibleLines()
|
||||
case PreviewRight:
|
||||
previewW = m.previewSize()
|
||||
visibleH = m.visibleLines()
|
||||
}
|
||||
|
||||
previewLines := wrapPreviewContent(content, previewW)
|
||||
maxOffset := max(len(previewLines)-visibleH, 0)
|
||||
if m.previewOffset > maxOffset {
|
||||
m.previewOffset = maxOffset
|
||||
}
|
||||
}
|
||||
|
||||
// applyPreviewOffset slices previewLines based on the current preview scroll
|
||||
// offset, clamping the offset so it doesn't scroll past the content.
|
||||
func (m *model) applyPreviewOffset(previewLines []string, visibleH int) []string {
|
||||
maxOffset := max(len(previewLines)-visibleH, 0)
|
||||
if m.previewOffset > maxOffset {
|
||||
m.previewOffset = maxOffset
|
||||
}
|
||||
if m.previewOffset > 0 {
|
||||
previewLines = previewLines[m.previewOffset:]
|
||||
}
|
||||
return previewLines
|
||||
}
|
||||
|
||||
func (m model) previewSize() int {
|
||||
if m.config.PreviewSizeIsPercent {
|
||||
if m.config.PreviewPosition == PreviewLeft || m.config.PreviewPosition == PreviewRight {
|
||||
return m.width * m.config.PreviewSize / 100
|
||||
}
|
||||
return m.height * m.config.PreviewSize / 100
|
||||
}
|
||||
return m.config.PreviewSize
|
||||
}
|
||||
|
||||
func (m model) visibleLines() int {
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
if m.showPreview && (m.config.PreviewPosition == PreviewTop || m.config.PreviewPosition == PreviewBottom) {
|
||||
// Add preview height + separator between content and preview
|
||||
return m.height - fixedLines - m.previewSize() - 1
|
||||
}
|
||||
return m.height - fixedLines
|
||||
}
|
||||
|
||||
func (m *model) updateFiltered() {
|
||||
m.filtered = []int{}
|
||||
m.filterRegexErr = nil
|
||||
|
||||
if m.filterRegex && m.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
363
internal/ui/layout_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func TestModelUpdateFiltered(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
// Add some test lines
|
||||
m.lines = []runner.Line{
|
||||
{Number: 1, Content: "hello world"},
|
||||
{Number: 2, Content: "foo bar"},
|
||||
{Number: 3, Content: "hello foo"},
|
||||
{Number: 4, Content: "baz qux"},
|
||||
}
|
||||
|
||||
// Test with no filter
|
||||
m.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
98
internal/ui/model.go
Normal 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
210
internal/ui/model_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
RefreshInterval: 5 * time.Second,
|
||||
}
|
||||
|
||||
if cfg.Command != "echo test" {
|
||||
t.Errorf("expected command 'echo test', got %q", cfg.Command)
|
||||
}
|
||||
|
||||
if cfg.Shell != "sh" {
|
||||
t.Errorf("expected shell 'sh', got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewSize != 40 {
|
||||
t.Errorf("expected preview size 40, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if !cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be true")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != PreviewBottom {
|
||||
t.Errorf("expected preview position 'bottom', got %q", cfg.PreviewPosition)
|
||||
}
|
||||
|
||||
if !cfg.ShowLineNums {
|
||||
t.Error("expected ShowLineNums to be true")
|
||||
}
|
||||
|
||||
if cfg.LineNumWidth != 6 {
|
||||
t.Errorf("expected line num width 6, got %d", cfg.LineNumWidth)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "watchr> " {
|
||||
t.Errorf("expected prompt 'watchr> ', got %q", cfg.Prompt)
|
||||
}
|
||||
|
||||
if cfg.RefreshInterval != 5*time.Second {
|
||||
t.Errorf("expected refresh interval 5s, got %v", cfg.RefreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewPositionConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
pos PreviewPosition
|
||||
want string
|
||||
}{
|
||||
{PreviewBottom, "bottom"},
|
||||
{PreviewTop, "top"},
|
||||
{PreviewLeft, "left"},
|
||||
{PreviewRight, "right"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if string(tt.pos) != tt.want {
|
||||
t.Errorf("PreviewPosition %v != %q", tt.pos, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
// Test with zero values
|
||||
cfg := Config{}
|
||||
|
||||
if cfg.Command != "" {
|
||||
t.Errorf("expected empty command, got %q", cfg.Command)
|
||||
}
|
||||
|
||||
if cfg.Shell != "" {
|
||||
t.Errorf("expected empty shell, got %q", cfg.Shell)
|
||||
}
|
||||
|
||||
if cfg.PreviewSize != 0 {
|
||||
t.Errorf("expected preview size 0, got %d", cfg.PreviewSize)
|
||||
}
|
||||
|
||||
if cfg.PreviewSizeIsPercent {
|
||||
t.Error("expected PreviewSizeIsPercent to be false")
|
||||
}
|
||||
|
||||
if cfg.PreviewPosition != "" {
|
||||
t.Errorf("expected empty preview position, got %q", cfg.PreviewPosition)
|
||||
}
|
||||
|
||||
if cfg.ShowLineNums {
|
||||
t.Error("expected ShowLineNums to be false")
|
||||
}
|
||||
|
||||
if cfg.LineNumWidth != 0 {
|
||||
t.Errorf("expected line num width 0, got %d", cfg.LineNumWidth)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
t.Errorf("expected empty prompt, got %q", cfg.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialModel(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
PreviewSize: 40,
|
||||
PreviewSizeIsPercent: true,
|
||||
PreviewPosition: PreviewBottom,
|
||||
ShowLineNums: true,
|
||||
LineNumWidth: 6,
|
||||
Prompt: "watchr> ",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
if m.config.Command != cfg.Command {
|
||||
t.Errorf("expected command %q, got %q", cfg.Command, m.config.Command)
|
||||
}
|
||||
|
||||
if m.cursor != 0 {
|
||||
t.Errorf("expected cursor at 0, got %d", m.cursor)
|
||||
}
|
||||
|
||||
if m.offset != 0 {
|
||||
t.Errorf("expected offset at 0, got %d", m.offset)
|
||||
}
|
||||
|
||||
if m.filterMode {
|
||||
t.Error("expected filterMode to be false")
|
||||
}
|
||||
|
||||
if m.showPreview {
|
||||
t.Error("expected showPreview to be false")
|
||||
}
|
||||
|
||||
if !m.loading {
|
||||
t.Error("expected loading to be true initially")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRefreshFromStart(t *testing.T) {
|
||||
// Test with RefreshFromStart false (default)
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
RefreshInterval: 5 * time.Second,
|
||||
RefreshFromStart: false,
|
||||
}
|
||||
|
||||
if cfg.RefreshFromStart {
|
||||
t.Error("expected RefreshFromStart to be false by default")
|
||||
}
|
||||
|
||||
// Test with RefreshFromStart true
|
||||
cfg.RefreshFromStart = true
|
||||
if !cfg.RefreshFromStart {
|
||||
t.Error("expected RefreshFromStart to be true after setting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelUserScrolled(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
// Initially should be false
|
||||
if m.userScrolled {
|
||||
t.Error("expected userScrolled to be false initially")
|
||||
}
|
||||
|
||||
// After setting, should be true
|
||||
m.userScrolled = true
|
||||
if !m.userScrolled {
|
||||
t.Error("expected userScrolled to be true after setting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRefreshGeneration(t *testing.T) {
|
||||
cfg := Config{
|
||||
Command: "echo test",
|
||||
Shell: "sh",
|
||||
}
|
||||
|
||||
m := initialModel(cfg)
|
||||
|
||||
// Initially should be 0
|
||||
if m.refreshGeneration != 0 {
|
||||
t.Errorf("expected refreshGeneration to be 0 initially, got %d", m.refreshGeneration)
|
||||
}
|
||||
|
||||
// After incrementing
|
||||
m.refreshGeneration++
|
||||
if m.refreshGeneration != 1 {
|
||||
t.Errorf("expected refreshGeneration to be 1 after increment, got %d", m.refreshGeneration)
|
||||
}
|
||||
}
|
||||
346
internal/ui/text.go
Normal file
346
internal/ui/text.go
Normal 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
205
internal/ui/text_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
1935
internal/ui/ui.go
1935
internal/ui/ui.go
File diff suppressed because it is too large
Load Diff
@@ -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
223
internal/ui/update.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/chenasraf/watchr/internal/runner"
|
||||
)
|
||||
|
||||
func initialModel(cfg Config) model {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var r *runner.Runner
|
||||
if cfg.Interactive {
|
||||
r = runner.NewInteractiveRunner(cfg.Shell, cfg.Command)
|
||||
} else {
|
||||
r = runner.NewRunner(cfg.Shell, cfg.Command)
|
||||
}
|
||||
|
||||
return model{
|
||||
config: cfg,
|
||||
lines: []runner.Line{},
|
||||
filtered: []int{},
|
||||
cursor: 0,
|
||||
offset: 0,
|
||||
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}
|
||||
})
|
||||
}
|
||||
87
internal/ui/update_test.go
Normal file
87
internal/ui/update_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestUpdateWindowSize(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
msg := tea.WindowSizeMsg{Width: 120, Height: 40}
|
||||
result, _ := m.Update(msg)
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.width != 120 {
|
||||
t.Errorf("expected width 120, got %d", newModel.width)
|
||||
}
|
||||
if newModel.height != 40 {
|
||||
t.Errorf("expected height 40, got %d", newModel.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClearStatusMsg(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.statusMsg = "some status"
|
||||
|
||||
result, _ := m.Update(clearStatusMsg{})
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.statusMsg != "" {
|
||||
t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSpinnerTick(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.loading = true
|
||||
m.spinnerFrame = 0
|
||||
|
||||
result, cmd := m.Update(spinnerTickMsg{})
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.spinnerFrame != 1 {
|
||||
t.Errorf("expected spinnerFrame 1, got %d", newModel.spinnerFrame)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a command for next spinner tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSpinnerTickNotLoading(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.loading = false
|
||||
m.streaming = false
|
||||
m.spinnerFrame = 3
|
||||
|
||||
result, cmd := m.Update(spinnerTickMsg{})
|
||||
newModel := result.(*model)
|
||||
|
||||
// Frame should not advance
|
||||
if newModel.spinnerFrame != 3 {
|
||||
t.Errorf("expected spinnerFrame 3 (unchanged), got %d", newModel.spinnerFrame)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("expected no command when not loading/streaming")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateErrMsg(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.loading = true
|
||||
m.streaming = true
|
||||
|
||||
result, _ := m.Update(errMsg{err: fmt.Errorf("test error")})
|
||||
newModel := result.(*model)
|
||||
|
||||
if newModel.errorMsg != "test error" {
|
||||
t.Errorf("expected errorMsg 'test error', got %q", newModel.errorMsg)
|
||||
}
|
||||
if newModel.loading {
|
||||
t.Error("expected loading false after error")
|
||||
}
|
||||
if newModel.streaming {
|
||||
t.Error("expected streaming false after error")
|
||||
}
|
||||
}
|
||||
585
internal/ui/view.go
Normal file
585
internal/ui/view.go
Normal 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
104
internal/ui/view_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderHelpOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
box, boxWidth, boxHeight := m.renderHelpOverlay()
|
||||
|
||||
if boxWidth == 0 || boxHeight == 0 {
|
||||
t.Error("expected non-zero overlay dimensions")
|
||||
}
|
||||
|
||||
// Should contain some keybinding text
|
||||
if !strings.Contains(box, "Keybindings") {
|
||||
t.Error("expected help overlay to contain 'Keybindings'")
|
||||
}
|
||||
if !strings.Contains(box, "Reload command") {
|
||||
t.Error("expected help overlay to contain 'Reload command'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderConfirmOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.confirmMessage = "Delete everything?"
|
||||
|
||||
box, boxWidth, boxHeight := m.renderConfirmOverlay()
|
||||
|
||||
if boxWidth == 0 || boxHeight == 0 {
|
||||
t.Error("expected non-zero overlay dimensions")
|
||||
}
|
||||
|
||||
if !strings.Contains(box, "Delete everything?") {
|
||||
t.Error("expected confirm overlay to contain the message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCmdPaletteOverlay(t *testing.T) {
|
||||
m := testModelWithLines()
|
||||
m.cmdPaletteMode = true
|
||||
m.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")
|
||||
}
|
||||
}
|
||||
4
main.go
4
main.go
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user