From 31330513f812c148370d6cdc4fb94dadc5884411 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sat, 14 Mar 2026 23:26:00 +0200 Subject: [PATCH] feat: regex filtering + cursor filter navigation --- internal/ui/ui.go | 124 ++++++++++++-- internal/ui/ui_test.go | 371 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 485 insertions(+), 10 deletions(-) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 0fc9f5d..9d3647e 100755 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os/exec" + "regexp" "runtime" "strings" "time" @@ -46,7 +47,10 @@ type model struct { 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 showHelp bool // help overlay visible width int @@ -343,21 +347,79 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 { + // Alt+Left: move to previous word boundary + pos := m.filterCursor + for pos > 0 && m.filter[pos-1] == ' ' { + pos-- + } + for pos > 0 && m.filter[pos-1] != ' ' { + pos-- + } + m.filterCursor = pos + } else if m.filterCursor > 0 { + m.filterCursor-- + } + return m, nil + case tea.KeyRight: + if msg.Alt { + // Alt+Right: move to next word boundary + pos := m.filterCursor + for pos < len(m.filter) && m.filter[pos] != ' ' { + pos++ + } + for pos < len(m.filter) && m.filter[pos] == ' ' { + pos++ + } + m.filterCursor = pos + } else if m.filterCursor < len(m.filter) { + m.filterCursor++ + } + return m, nil case tea.KeyBackspace: - if len(m.filter) > 0 { - m.filter = m.filter[:len(m.filter)-1] + if msg.Alt { + // Alt+Backspace: delete word behind cursor + if m.filterCursor > 0 { + pos := m.filterCursor + // Skip trailing spaces + for pos > 0 && m.filter[pos-1] == ' ' { + pos-- + } + // Skip word characters + for pos > 0 && m.filter[pos-1] != ' ' { + pos-- + } + m.filter = m.filter[:pos] + m.filter[m.filterCursor:] + m.filterCursor = pos + m.updateFiltered() + } + } else if m.filterCursor > 0 { + m.filter = m.filter[:m.filterCursor-1] + m.filter[m.filterCursor:] + m.filterCursor-- m.updateFiltered() } return m, nil default: if len(msg.Runes) > 0 { - m.filter += string(msg.Runes) - m.updateFiltered() + s := string(msg.Runes) + if s == "/" && m.filter == "" { + m.filterRegex = !m.filterRegex + m.filterRegexErr = nil + m.updateFiltered() + } else { + m.filter = m.filter[:m.filterCursor] + s + m.filter[m.filterCursor:] + m.filterCursor += len(s) + m.updateFiltered() + } } return m, nil } @@ -370,8 +432,11 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case "esc": // Clear filter if active, otherwise quit - if m.filter != "" { + if m.filter != "" || m.filterRegex { m.filter = "" + m.filterCursor = 0 + m.filterRegex = false + m.filterRegexErr = nil m.updateFiltered() return m, nil } @@ -426,6 +491,7 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "/": m.filterMode = true m.filter = "" + m.filterCursor = 0 case "?": m.showHelp = true case "y": @@ -567,11 +633,29 @@ func wrapText(s string, width int) []string { func (m *model) updateFiltered() { m.filtered = []int{} + m.filterRegexErr = nil - 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) + 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) + } } } @@ -620,6 +704,7 @@ func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) { {"", ""}, {"p", "Toggle preview pane"}, {"/", "Enter filter mode"}, + {"//", "Toggle regex filter mode"}, {"Esc", "Exit filter / clear"}, {"", ""}, {"r / Ctrl+r", "Reload command"}, @@ -882,6 +967,12 @@ func (m model) renderMainView() string { filterStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("11")) + filterRegexStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("13")) + + filterErrStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("9")) + // Inner width (excluding border characters) innerWidth := m.width - 2 @@ -963,8 +1054,21 @@ func (m model) renderMainView() string { // Build prompt line (will go at bottom) 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: - promptLine = filterStyle.Render(fmt.Sprintf("/%sā–ˆ", m.filter)) + 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: diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index c0c93b1..2503492 100755 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -416,6 +416,377 @@ func TestModelRefreshGeneration(t *testing.T) { } } +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",