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`, `Ctrl-r` | Reload (re-run command) |
|
||||||
| `R` | Reload & clear all lines |
|
| `R` | Reload & clear all lines |
|
||||||
| `Del` | Delete selected line |
|
| `d`, `Del` | Delete selected line |
|
||||||
| `Ctrl-Del` | Clear all lines (with confirm) |
|
| `D` | Clear all lines |
|
||||||
| `c` | Stop running command |
|
| `c` | Stop running command |
|
||||||
| `q`, `Esc` | Quit |
|
| `q`, `Esc` | Quit |
|
||||||
| `j`, `k` | Move down/up |
|
| `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, "\nKeybindings:\n")
|
||||||
_, _ = fmt.Fprintf(w, " r, Ctrl-r Reload (re-run command)\n")
|
_, _ = fmt.Fprintf(w, " r, Ctrl-r Reload (re-run command)\n")
|
||||||
_, _ = fmt.Fprintf(w, " R Reload & clear all lines\n")
|
_, _ = fmt.Fprintf(w, " R Reload & clear all lines\n")
|
||||||
_, _ = fmt.Fprintf(w, " Del Delete selected line\n")
|
_, _ = fmt.Fprintf(w, " d, Del Delete selected line\n")
|
||||||
_, _ = fmt.Fprintf(w, " Ctrl-Del Clear all lines (with confirm)\n")
|
_, _ = fmt.Fprintf(w, " D Clear all lines\n")
|
||||||
_, _ = fmt.Fprintf(w, " c Stop running command\n")
|
_, _ = fmt.Fprintf(w, " c Stop running command\n")
|
||||||
_, _ = fmt.Fprintf(w, " q, Esc Quit\n")
|
_, _ = fmt.Fprintf(w, " q, Esc Quit\n")
|
||||||
_, _ = fmt.Fprintf(w, " j, k Move down/up\n")
|
_, _ = fmt.Fprintf(w, " j, k Move down/up\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user