From dd4b21392159275dcb653c0603044fd9f00c03c3 Mon Sep 17 00:00:00 2001 From: Rosemarie O'Riorden Date: Thu, 9 Jul 2026 16:15:47 -0400 Subject: [PATCH 1/3] tui: search: Add vim-like expression search /. Allows search within patch files just like vim's search function (i.e. supports regex, same n/N controls, etc). Colors of the matches differ between dark and light mode. Assisted-by: Claude Sonnet 4.5, Claude Code Signed-off-by: Rosemarie O'Riorden --- tui/keys.go | 27 ++- tui/model.go | 12 ++ tui/render.go | 141 +++++++++------- tui/search.go | 404 +++++++++++++++++++++++++++++++++++++++++++++ tui/search_test.go | 166 +++++++++++++++++++ tui/styles.go | 9 + tui/theme.go | 15 ++ 7 files changed, 712 insertions(+), 62 deletions(-) create mode 100644 tui/search.go create mode 100644 tui/search_test.go diff --git a/tui/keys.go b/tui/keys.go index ee56b12..93d78ea 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -393,8 +393,21 @@ func (m *Model) handleSelectorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleViewportKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { + key := msg.String() + + if m.searching { + return m.handleSearchInputMode(msg, m.updateViewportSearchMatches) + } + if m.handleSearchNavigation(key) { + return m, nil + } + + switch key { case "q", "esc": + if m.searchText != "" { + m.clearSearch() + return m, nil + } m.viewMode = viewTable m.viewingPatchID = 0 m.viewingCoverID = 0 @@ -983,8 +996,19 @@ func (m *Model) compareMinIdx() int { func (m *Model) handleCompareKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() + if m.searching { + return m.handleSearchInputMode(msg, m.updateCompareSearchMatches) + } + if m.handleSearchNavigation(key) { + return m, nil + } + switch key { case "q", "esc": + if m.searchText != "" { + m.clearSearch() + return m, nil + } m.viewMode = viewTable m.compareCount = 0 return m, nil @@ -1082,3 +1106,4 @@ func (m *Model) compareScrollToEnd() { m.viewportOffset = 0 } } + diff --git a/tui/model.go b/tui/model.go index e0692d6..1f89ef9 100644 --- a/tui/model.go +++ b/tui/model.go @@ -6,6 +6,7 @@ package tui import ( "fmt" "log" + "regexp" "strconv" "strings" "time" @@ -150,6 +151,12 @@ type compareSide struct { ver string // "v1", "v2", etc. } +type searchMatch struct { + lineIdx int + start int + end int +} + func highlightAnimTickCmd() tea.Cmd { return tea.Tick( time.Duration(highlightAnimInterval)*time.Millisecond, @@ -225,6 +232,11 @@ type Model struct { viewportLoading bool viewportOffset int viewExpanded bool + searching bool + searchText string + searchRegex *regexp.Regexp + searchMatches []searchMatch + searchIdx int listPrefix string delegateNames map[string]string diff --git a/tui/render.go b/tui/render.go index 75f58dd..01ac881 100644 --- a/tui/render.go +++ b/tui/render.go @@ -69,10 +69,10 @@ func (m *Model) renderPatchView() string { end = total } - // Assemble exactly `visible` lines separated by newlines. lines := make([]string, visible) for i := 0; i < end-start; i++ { - lines[i] = m.viewportLines[start+i] + lineIdx := start + i + lines[i] = m.highlightLineIfMatched(m.viewportLines[lineIdx], lineIdx) } body := strings.Join(lines, "\n") @@ -85,47 +85,55 @@ func (m *Model) renderPatchView() string { bright, desc, sep := m.helpStyles() var status string - expandKey := func(hb *strings.Builder) { - hb.WriteString(helpSepStr(sep)) - if m.viewExpanded { - hb.WriteString(helpKey(bright, desc, "e", "collapse")) - } else { - hb.WriteString(helpKey(bright, desc, "e", "expand")) - } - } - if len(m.viewComments) > 0 { - var hb strings.Builder - hb.WriteString(sep.Render(" ")) - hb.WriteString(bright.Render("←/→")) - expandKey(&hb) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(bright.Render("↑/↓") + - sep.Render(" ") + bright.Render("pgup/dn")) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(bright.Render("esc")) - if m.logConsole { + + if m.searching { + status = m.renderSearchInputHelp(bright, desc, sep) + } else { + expandKey := func(hb *strings.Builder) { hb.WriteString(helpSepStr(sep)) - hb.WriteString(helpKey(bright, desc, "tab", "log")) + if m.viewExpanded { + hb.WriteString(helpKey(bright, desc, "e", "collapse")) + } else { + hb.WriteString(helpKey(bright, desc, "e", "expand")) + } } - hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - helpText := hb.String() - barWidth := m.width - lipgloss.Width(helpText) - commentBar := m.renderCommentBar(barWidth) - status = commentBar + helpText - } else { - var hb strings.Builder - expandKey(&hb) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(bright.Render("↑/↓") + - sep.Render(" ") + bright.Render("pgup/dn")) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(helpKey(bright, desc, "esc", "back")) - if m.logConsole { + + if len(m.viewComments) > 0 { + var hb strings.Builder + hb.WriteString(sep.Render(" ")) + hb.WriteString(bright.Render("←/→")) + expandKey(&hb) + hb.WriteString(m.renderSearchKeyHelp(bright, desc, sep)) hb.WriteString(helpSepStr(sep)) - hb.WriteString(helpKey(bright, desc, "tab", "log")) + hb.WriteString(bright.Render("↑/↓") + + sep.Render(" ") + bright.Render("pgup/dn")) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(bright.Render("esc")) + if m.logConsole { + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "tab", "log")) + } + hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) + helpText := hb.String() + barWidth := m.width - lipgloss.Width(helpText) + commentBar := m.renderCommentBar(barWidth) + status = commentBar + helpText + } else { + var hb strings.Builder + expandKey(&hb) + hb.WriteString(m.renderSearchKeyHelp(bright, desc, sep)) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(bright.Render("↑/↓") + + sep.Render(" ") + bright.Render("pgup/dn")) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "esc", "back")) + if m.logConsole { + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "tab", "log")) + } + hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) + status = hb.String() } - hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - status = hb.String() } var statusLine string @@ -172,6 +180,10 @@ func (m *Model) renderCompareView() string { compareDiffKind(m.compare[0].kinds, idx)) right = comparePadLine(right, rightWidth, compareDiffKind(m.compare[1].kinds, idx)) + + left = m.highlightLineIfMatched(left, idx) + right = m.highlightLineIfMatched(right, idx) + lines[i] = left + sep + right } body := strings.Join(lines, "\n") @@ -183,29 +195,36 @@ func (m *Model) renderCompareView() string { } bright, desc, sepS := m.helpStyles() - var hb strings.Builder - labelL := comparePositionLabel( - m.compare[0].idx, len(m.compare[0].patches), m.compare[0].ver) - labelR := comparePositionLabel( - m.compare[1].idx, len(m.compare[1].patches), m.compare[1].ver) - hb.WriteString(desc.Render(labelL + " vs " + labelR)) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(bright.Render("←/→")) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(helpKey(bright, desc, "1/2+←/→", "single")) - hb.WriteString(helpSepStr(sepS)) - if m.viewExpanded { - hb.WriteString(helpKey(bright, desc, "e", "collapse")) + + var helpText string + if m.searching { + helpText = m.renderSearchInputHelp(bright, desc, sepS) } else { - hb.WriteString(helpKey(bright, desc, "e", "expand")) - } - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(bright.Render("↑/↓") + - sepS.Render(" ") + bright.Render("pgup/dn")) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(helpKey(bright, desc, "esc", "back")) - hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - helpText := hb.String() + var hb strings.Builder + labelL := comparePositionLabel( + m.compare[0].idx, len(m.compare[0].patches), m.compare[0].ver) + labelR := comparePositionLabel( + m.compare[1].idx, len(m.compare[1].patches), m.compare[1].ver) + hb.WriteString(desc.Render(labelL + " vs " + labelR)) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(bright.Render("←/→")) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(helpKey(bright, desc, "1/2+←/→", "single")) + hb.WriteString(helpSepStr(sepS)) + if m.viewExpanded { + hb.WriteString(helpKey(bright, desc, "e", "collapse")) + } else { + hb.WriteString(helpKey(bright, desc, "e", "expand")) + } + hb.WriteString(m.renderSearchKeyHelp(bright, desc, sepS)) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(bright.Render("↑/↓") + + sepS.Render(" ") + bright.Render("pgup/dn")) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(helpKey(bright, desc, "esc", "back")) + hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) + helpText = hb.String() + } var statusLine string msg, spinning := m.Status.Active() diff --git a/tui/search.go b/tui/search.go new file mode 100644 index 0000000..20d0e7a --- /dev/null +++ b/tui/search.go @@ -0,0 +1,404 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) startSearch() { + m.searching = true + m.searchText = "" + m.searchMatches = nil + m.searchIdx = -1 +} + +func (m *Model) clearSearch() { + m.searching = false + m.searchText = "" + m.searchRegex = nil + m.searchMatches = nil + m.searchIdx = -1 +} + +func (m *Model) commitSearch() { + m.searching = false + if len(m.searchMatches) > 0 && m.searchIdx >= 0 { + m.scrollToMatch(m.searchIdx, m.searchMaxLines()) + } +} + +func (m *Model) handleSearchInputMode(msg tea.KeyMsg, updateMatches func()) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "enter": + m.commitSearch() + case "esc": + m.clearSearch() + case "backspace": + if len(m.searchText) > 0 { + m.searchText = m.searchText[:len(m.searchText)-1] + updateMatches() + } else { + m.clearSearch() + } + default: + if msg.Paste { + for _, r := range msg.Runes { + if r >= ' ' { + m.searchText += string(r) + } + } + updateMatches() + } else if len(key) == 1 && key[0] >= ' ' && key[0] <= '~' { + m.searchText += key + updateMatches() + } + } + return m, nil +} + +func (m *Model) handleSearchNavigation(key string) (handled bool) { + switch key { + case "/": + m.startSearch() + return true + case "n": + if len(m.searchMatches) > 0 { + m.nextSearchMatch() + } + return true + case "N": + if len(m.searchMatches) > 0 { + m.prevSearchMatch() + } + return true + } + return false +} + +// searchMaxLines returns the total line count for the current view mode. +func (m *Model) searchMaxLines() int { + if m.viewMode == viewCompare { + return m.compareMaxLines() + } + return len(m.viewportLines) +} + +func (m *Model) compileSearchPattern() (*regexp.Regexp, bool) { + if m.searchText == "" { + m.searchMatches = nil + m.searchIdx = -1 + m.searchRegex = nil + return nil, false + } + + re, err := parseVimSearchPattern(m.searchText) + if err != nil || re == nil { + m.searchMatches = nil + m.searchIdx = -1 + m.searchRegex = nil + return nil, false + } + + m.searchRegex = re + m.searchMatches = nil + return re, true +} + +func (m *Model) collectLineMatches(re *regexp.Regexp, lineIdx int, text string) { + plain := stripANSI(text) + for _, pos := range re.FindAllStringIndex(plain, -1) { + m.searchMatches = append(m.searchMatches, searchMatch{ + lineIdx: lineIdx, + start: pos[0], + end: pos[1], + }) + } +} + +func (m *Model) updateViewportSearchMatches() { + re, ok := m.compileSearchPattern() + if !ok { + return + } + + for i, line := range m.viewportLines { + m.collectLineMatches(re, i, line) + } + + m.finalizeSearchMatches() +} + +func (m *Model) updateCompareSearchMatches() { + re, ok := m.compileSearchPattern() + if !ok { + return + } + + maxLines := m.compareMaxLines() + for i := 0; i < maxLines; i++ { + if i < len(m.compare[0].lines) { + m.collectLineMatches(re, i, m.compare[0].lines[i]) + } + if i < len(m.compare[1].lines) { + m.collectLineMatches(re, i, m.compare[1].lines[i]) + } + } + + m.finalizeSearchMatches() +} + +func (m *Model) finalizeSearchMatches() { + if len(m.searchMatches) > 0 { + m.searchIdx = 0 + m.scrollToMatch(0, m.searchMaxLines()) + } else { + m.searchIdx = -1 + } +} + +func (m *Model) nextSearchMatch() { + if len(m.searchMatches) == 0 { + return + } + m.searchIdx = (m.searchIdx + 1) % len(m.searchMatches) + m.scrollToMatch(m.searchIdx, m.searchMaxLines()) +} + +func (m *Model) prevSearchMatch() { + if len(m.searchMatches) == 0 { + return + } + m.searchIdx-- + if m.searchIdx < 0 { + m.searchIdx = len(m.searchMatches) - 1 + } + m.scrollToMatch(m.searchIdx, m.searchMaxLines()) +} + +func (m *Model) scrollToMatch(matchIdx int, maxLines int) { + if matchIdx < 0 || matchIdx >= len(m.searchMatches) { + return + } + lineIdx := m.searchMatches[matchIdx].lineIdx + visible := m.viewportVisibleLines() + + m.viewportOffset = lineIdx - visible/2 + if m.viewportOffset < 0 { + m.viewportOffset = 0 + } + maxOffset := maxLines - visible + if maxOffset < 0 { + maxOffset = 0 + } + if m.viewportOffset > maxOffset { + m.viewportOffset = maxOffset + } +} + +// parseVimSearchPattern parses a vim-style search pattern and returns a compiled regex. +// Supports \c for case-insensitive, \C for case-sensitive (default), and standard regex. +func parseVimSearchPattern(pattern string) (*regexp.Regexp, error) { + if pattern == "" { + return nil, nil + } + + caseInsensitive := false + cleanPattern := pattern + + if strings.Contains(pattern, `\c`) { + caseInsensitive = true + cleanPattern = strings.ReplaceAll(cleanPattern, `\c`, "") + } + if strings.Contains(pattern, `\C`) { + caseInsensitive = false + cleanPattern = strings.ReplaceAll(cleanPattern, `\C`, "") + } + + cleanPattern = strings.TrimSpace(cleanPattern) + if cleanPattern == "" { + return nil, nil + } + + regexPattern := cleanPattern + if caseInsensitive { + regexPattern = "(?i)" + regexPattern + } + + re, err := regexp.Compile(regexPattern) + if err != nil { + literal := regexp.QuoteMeta(cleanPattern) + if caseInsensitive { + literal = "(?i)" + literal + } + re, err = regexp.Compile(literal) + } + + return re, err +} + +// highlightLineIfMatched applies search highlighting to a line if it contains matches. +func (m *Model) highlightLineIfMatched(line string, lineIdx int) string { + if m.searchRegex == nil { + return line + } + + hasMatch := false + for _, match := range m.searchMatches { + if match.lineIdx == lineIdx { + hasMatch = true + break + } + } + if !hasMatch { + return line + } + + var currentMatchPos *searchMatch + if m.searchIdx >= 0 && m.searchIdx < len(m.searchMatches) { + if m.searchMatches[m.searchIdx].lineIdx == lineIdx { + currentMatchPos = &m.searchMatches[m.searchIdx] + } + } + + return highlightSearchMatch(line, m.searchRegex, currentMatchPos) +} + +// highlightSearchMatch highlights regex matches in a line while preserving ANSI codes. +func highlightSearchMatch(line string, re *regexp.Regexp, currentMatch *searchMatch) string { + if re == nil { + return line + } + + plainLine := stripANSI(line) + allMatches := re.FindAllStringIndex(plainLine, -1) + if len(allMatches) == 0 { + return line + } + + var result strings.Builder + plainIdx := 0 + lineIdx := 0 + matchIdx := 0 + + for lineIdx < len(line) { + ch := line[lineIdx] + + if ch == '\x1b' { + end := skipANSIEscape(line, lineIdx) + result.WriteString(line[lineIdx:end]) + lineIdx = end + continue + } + + if matchIdx < len(allMatches) && plainIdx == allMatches[matchIdx][0] { + matchEnd := allMatches[matchIdx][1] + + style := searchOtherStyle + if currentMatch != nil && + currentMatch.start == allMatches[matchIdx][0] && + currentMatch.end == allMatches[matchIdx][1] { + style = searchCurrentStyle + } + + plainText := extractPlainSpan(line, &lineIdx, &plainIdx, matchEnd) + result.WriteString(style.Render(plainText)) + matchIdx++ + continue + } + + result.WriteByte(ch) + plainIdx++ + lineIdx++ + } + + return result.String() +} + +// extractPlainSpan advances through the styled line collecting plain text +// until plainIdx reaches plainEnd, skipping ANSI escapes. +func extractPlainSpan(line string, lineIdx *int, plainIdx *int, plainEnd int) string { + var buf strings.Builder + for *plainIdx < plainEnd && *lineIdx < len(line) { + ch := line[*lineIdx] + if ch == '\x1b' { + *lineIdx = skipANSIEscape(line, *lineIdx) + continue + } + buf.WriteByte(ch) + *lineIdx++ + *plainIdx++ + } + return buf.String() +} + +// skipANSIEscape returns the index past the end of an ANSI escape starting at pos. +func skipANSIEscape(s string, pos int) int { + i := pos + 1 + for i < len(s) { + ch := s[i] + i++ + if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') { + return i + } + } + return i +} + +func (m *Model) renderSearchInputHelp(bright, desc, sep lipgloss.Style) string { + var hb strings.Builder + hb.WriteString(desc.Render("Search: ")) + hb.WriteString(normalOptionStyle.Render(m.searchText + "_")) + if len(m.searchMatches) > 0 { + hb.WriteString(desc.Render(fmt.Sprintf(" [%d/%d]", + m.searchIdx+1, len(m.searchMatches)))) + } else if m.searchText != "" { + hb.WriteString(desc.Render(" [no matches]")) + } + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "enter", "done")) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "esc", "cancel")) + return hb.String() +} + +func (m *Model) renderSearchKeyHelp(bright, desc, sep lipgloss.Style) string { + var hb strings.Builder + hb.WriteString(helpSepStr(sep)) + if m.searchText != "" { + hb.WriteString(helpKey(bright, desc, "/", "search")) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "n/N", "next/prev")) + hb.WriteString(sep.Render(fmt.Sprintf(" [%d]", len(m.searchMatches)))) + } else { + hb.WriteString(helpKey(bright, desc, "/", "search")) + } + return hb.String() +} + +// stripANSI removes ANSI escape sequences from a string. +func stripANSI(s string) string { + var result strings.Builder + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + continue + } + if inEscape { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + inEscape = false + } + continue + } + result.WriteRune(r) + } + return result.String() +} diff --git a/tui/search_test.go b/tui/search_test.go new file mode 100644 index 0000000..a202549 --- /dev/null +++ b/tui/search_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" +) + +func TestMultipleOccurrencesOnSameLine(t *testing.T) { + m := &Model{ + viewportLines: []string{ + "foo bar foo baz foo", + "no matches here", + "another foo here", + }, + } + + // Search for "foo" + m.searchText = "foo" + m.updateViewportSearchMatches() + + // Should find 4 total matches: 3 on line 0, 1 on line 2 + if len(m.searchMatches) != 4 { + t.Errorf("Expected 4 matches, got %d", len(m.searchMatches)) + } + + // Check that the first 3 matches are on line 0 + for i := 0; i < 3; i++ { + if m.searchMatches[i].lineIdx != 0 { + t.Errorf("Match %d should be on line 0, got line %d", i, m.searchMatches[i].lineIdx) + } + } + + // Check that they have different positions + if m.searchMatches[0].start == m.searchMatches[1].start { + t.Error("First and second matches should have different positions") + } + if m.searchMatches[1].start == m.searchMatches[2].start { + t.Error("Second and third matches should have different positions") + } + + // Fourth match should be on line 2 + if m.searchMatches[3].lineIdx != 2 { + t.Errorf("Match 3 should be on line 2, got line %d", m.searchMatches[3].lineIdx) + } + + // Current index should be 0 + if m.searchIdx != 0 { + t.Errorf("Current index should be 0, got %d", m.searchIdx) + } + + // Navigate through all matches + m.nextSearchMatch() + if m.searchIdx != 1 { + t.Errorf("After next, index should be 1, got %d", m.searchIdx) + } + + m.nextSearchMatch() + if m.searchIdx != 2 { + t.Errorf("After next, index should be 2, got %d", m.searchIdx) + } + + m.nextSearchMatch() + if m.searchIdx != 3 { + t.Errorf("After next, index should be 3, got %d", m.searchIdx) + } + + // Should wrap around + m.nextSearchMatch() + if m.searchIdx != 0 { + t.Errorf("After wrapping, index should be 0, got %d", m.searchIdx) + } +} + +func TestParseVimSearchPattern(t *testing.T) { + tests := []struct { + name string + pattern string + testString string + shouldMatch bool + wantErr bool + }{ + { + name: "simple literal", + pattern: "foo", + testString: "this is foo bar", + shouldMatch: true, + }, + { + name: "case sensitive by default", + pattern: "Foo", + testString: "this is foo bar", + shouldMatch: false, + }, + { + name: "case insensitive with \\c", + pattern: `Foo\c`, + testString: "this is foo bar", + shouldMatch: true, + }, + { + name: "case insensitive with \\c at start", + pattern: `\cFoo`, + testString: "this is FOO bar", + shouldMatch: true, + }, + { + name: "alternation foo|bar", + pattern: "foo|bar", + testString: "this is bar", + shouldMatch: true, + }, + { + name: "alternation foo|bar matches first", + pattern: "foo|bar", + testString: "this is foo", + shouldMatch: true, + }, + { + name: "case insensitive alternation", + pattern: `\cfoo|bar`, + testString: "this is BAR", + shouldMatch: true, + }, + { + name: "regex character class", + pattern: "[0-9]+", + testString: "version 123", + shouldMatch: true, + }, + { + name: "regex with dot", + pattern: "f.o", + testString: "this is foo bar", + shouldMatch: true, + }, + { + name: "empty pattern", + pattern: "", + testString: "anything", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + re, err := parseVimSearchPattern(tt.pattern) + if (err != nil) != tt.wantErr { + t.Errorf("parseVimSearchPattern() error = %v, wantErr %v", err, tt.wantErr) + return + } + if re == nil && tt.pattern != "" { + t.Errorf("parseVimSearchPattern() returned nil regex for pattern %q", tt.pattern) + return + } + if re != nil { + matched := re.MatchString(tt.testString) + if matched != tt.shouldMatch { + t.Errorf("pattern %q against %q: got match=%v, want match=%v", + tt.pattern, tt.testString, matched, tt.shouldMatch) + } + } + }) + } +} diff --git a/tui/styles.go b/tui/styles.go index debb191..d4c284a 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -87,6 +87,8 @@ var ( compareSepStyle lipgloss.Style compareAddBg lipgloss.Color compareDelBg lipgloss.Color + searchCurrentStyle lipgloss.Style + searchOtherStyle lipgloss.Style ) type gradientEntry struct{ bg, fg string } @@ -172,6 +174,13 @@ func buildStyles(t *theme) { compareSepStyle = fg(t.CompareSepFg) compareAddBg = lipgloss.Color(t.CompareAddBg) compareDelBg = lipgloss.Color(t.CompareDelBg) + searchCurrentStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(t.SearchCurrentBg)). + Foreground(lipgloss.Color(t.SearchCurrentFg)). + Bold(true) + searchOtherStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(t.SearchOtherBg)). + Foreground(lipgloss.Color(t.SearchOtherFg)) bgStyles = map[string]*cachedBgStyle{} gradientPalettes = map[string][256]gradientEntry{} diff --git a/tui/theme.go b/tui/theme.go index ffd4db4..150326c 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -45,6 +45,11 @@ type theme struct { CompareSepFg string // separator line between compare columns CompareAddBg string // background tint for lines added on this side CompareDelBg string // background tint for lines removed (only on other side) + + SearchCurrentBg string // background for the active search match + SearchCurrentFg string + SearchOtherBg string // background for non-active search matches + SearchOtherFg string } // Dark theme FG colors inspired by the Catppuccin Mocha palette. @@ -118,6 +123,11 @@ var darkTheme = theme{ CompareSepFg: "#585b70", // catppuccin Surface2 CompareAddBg: "#1a2e1a", // subtle dark green CompareDelBg: "#2e1a1a", // subtle dark red + + SearchCurrentBg: "#fdfd96", // bright pastel yellow + SearchCurrentFg: "#000000", + SearchOtherBg: "#5e5820", // muted olive + SearchOtherFg: "#f0e8c0", } var lightTheme = theme{ @@ -189,4 +199,9 @@ var lightTheme = theme{ CompareSepFg: "#9ca0b0", // catppuccin Overlay0 CompareAddBg: "#d0f0d0", // light green CompareDelBg: "#f0d0d0", // light rose + + SearchCurrentBg: "#ffbb30", // warm orange-yellow + SearchCurrentFg: "#000000", + SearchOtherBg: "#fffa69", // yellow + SearchOtherFg: "#333000", } From 8884cc254d4f2ca6d0d620071300d22d05588374 Mon Sep 17 00:00:00 2001 From: Rosemarie O'Riorden Date: Wed, 15 Jul 2026 13:50:06 -0400 Subject: [PATCH 2/3] tui: search: Add search history. // search history just like in vim's search. Does not persist across sessions. Assisted-by: Claude Sonnet 4.5, Claude Code Signed-off-by: Rosemarie O'Riorden --- tui/model.go | 2 + tui/search.go | 45 +++++++++++++++++++++++ tui/search_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) diff --git a/tui/model.go b/tui/model.go index 1f89ef9..601930f 100644 --- a/tui/model.go +++ b/tui/model.go @@ -237,6 +237,8 @@ type Model struct { searchRegex *regexp.Regexp searchMatches []searchMatch searchIdx int + searchHistory []string + searchHistoryIdx int // -1 = typing new query, 0+ = browsing history listPrefix string delegateNames map[string]string diff --git a/tui/search.go b/tui/search.go index 20d0e7a..7da2b1e 100644 --- a/tui/search.go +++ b/tui/search.go @@ -15,6 +15,7 @@ import ( func (m *Model) startSearch() { m.searching = true m.searchText = "" + m.searchHistoryIdx = -1 m.searchMatches = nil m.searchIdx = -1 } @@ -29,6 +30,16 @@ func (m *Model) clearSearch() { func (m *Model) commitSearch() { m.searching = false + if m.searchText != "" { + // Deduplicate: remove previous occurrence so the latest is always at front. + for i, h := range m.searchHistory { + if h == m.searchText { + m.searchHistory = append(m.searchHistory[:i], m.searchHistory[i+1:]...) + break + } + } + m.searchHistory = append([]string{m.searchText}, m.searchHistory...) + } if len(m.searchMatches) > 0 && m.searchIdx >= 0 { m.scrollToMatch(m.searchIdx, m.searchMaxLines()) } @@ -41,9 +52,28 @@ func (m *Model) handleSearchInputMode(msg tea.KeyMsg, updateMatches func()) (tea m.commitSearch() case "esc": m.clearSearch() + case "up": + if len(m.searchHistory) > 0 { + if m.searchHistoryIdx < len(m.searchHistory)-1 { + m.searchHistoryIdx++ + } + m.searchText = m.searchHistory[m.searchHistoryIdx] + updateMatches() + } + case "down": + if m.searchHistoryIdx > 0 { + m.searchHistoryIdx-- + m.searchText = m.searchHistory[m.searchHistoryIdx] + updateMatches() + } else if m.searchHistoryIdx == 0 { + m.searchHistoryIdx = -1 + m.searchText = "" + updateMatches() + } case "backspace": if len(m.searchText) > 0 { m.searchText = m.searchText[:len(m.searchText)-1] + m.searchHistoryIdx = -1 updateMatches() } else { m.clearSearch() @@ -55,9 +85,11 @@ func (m *Model) handleSearchInputMode(msg tea.KeyMsg, updateMatches func()) (tea m.searchText += string(r) } } + m.searchHistoryIdx = -1 updateMatches() } else if len(key) == 1 && key[0] >= ' ' && key[0] <= '~' { m.searchText += key + m.searchHistoryIdx = -1 updateMatches() } } @@ -72,17 +104,30 @@ func (m *Model) handleSearchNavigation(key string) (handled bool) { case "n": if len(m.searchMatches) > 0 { m.nextSearchMatch() + } else if m.searchText == "" && len(m.searchHistory) > 0 { + m.recallLastSearch() } return true case "N": if len(m.searchMatches) > 0 { m.prevSearchMatch() + } else if m.searchText == "" && len(m.searchHistory) > 0 { + m.recallLastSearch() } return true } return false } +func (m *Model) recallLastSearch() { + m.searchText = m.searchHistory[0] + if m.viewMode == viewCompare { + m.updateCompareSearchMatches() + } else { + m.updateViewportSearchMatches() + } +} + // searchMaxLines returns the total line count for the current view mode. func (m *Model) searchMaxLines() int { if m.viewMode == viewCompare { diff --git a/tui/search_test.go b/tui/search_test.go index a202549..f989ff4 100644 --- a/tui/search_test.go +++ b/tui/search_test.go @@ -73,6 +73,98 @@ func TestMultipleOccurrencesOnSameLine(t *testing.T) { } } +func TestSearchHistory(t *testing.T) { + m := &Model{ + viewportLines: []string{ + "foo bar baz", + "hello world", + "foo again", + }, + } + + // Commit a few searches + m.searchText = "foo" + m.commitSearch() + m.searchText = "bar" + m.commitSearch() + m.searchText = "hello" + m.commitSearch() + + if len(m.searchHistory) != 3 { + t.Fatalf("Expected 3 history entries, got %d", len(m.searchHistory)) + } + if m.searchHistory[0] != "hello" || m.searchHistory[1] != "bar" || m.searchHistory[2] != "foo" { + t.Errorf("History order wrong: %v", m.searchHistory) + } + + // Duplicate should move to front, not add again + m.searchText = "foo" + m.commitSearch() + if len(m.searchHistory) != 3 { + t.Fatalf("Expected 3 history entries after dedup, got %d", len(m.searchHistory)) + } + if m.searchHistory[0] != "foo" { + t.Errorf("Expected 'foo' at front, got %q", m.searchHistory[0]) + } + + // Start a new search and browse history with up arrow + m.startSearch() + if m.searchHistoryIdx != -1 { + t.Errorf("Expected historyIdx -1 at start, got %d", m.searchHistoryIdx) + } + + // Simulate up arrow — should recall most recent ("foo") + m.searchHistoryIdx = 0 + m.searchText = m.searchHistory[0] + if m.searchText != "foo" { + t.Errorf("Expected 'foo' on first up, got %q", m.searchText) + } + + // Another up — should get "hello" + m.searchHistoryIdx = 1 + m.searchText = m.searchHistory[1] + if m.searchText != "hello" { + t.Errorf("Expected 'hello' on second up, got %q", m.searchText) + } + + // Down arrow — back to "foo" + m.searchHistoryIdx = 0 + m.searchText = m.searchHistory[0] + if m.searchText != "foo" { + t.Errorf("Expected 'foo' on down, got %q", m.searchText) + } +} + +func TestRecallLastSearch(t *testing.T) { + m := &Model{ + viewportLines: []string{ + "foo bar foo", + "no match", + "another foo", + }, + } + + // Search and commit + m.searchText = "foo" + m.updateViewportSearchMatches() + m.commitSearch() + + // Clear search + m.clearSearch() + if len(m.searchMatches) != 0 { + t.Fatal("Expected no matches after clear") + } + + // Recall via n + m.recallLastSearch() + if m.searchText != "foo" { + t.Errorf("Expected recalled text 'foo', got %q", m.searchText) + } + if len(m.searchMatches) != 3 { + t.Errorf("Expected 3 matches after recall, got %d", len(m.searchMatches)) + } +} + func TestParseVimSearchPattern(t *testing.T) { tests := []struct { name string From dd17c9246e2c93fe4da5e960b4db4a690d543ccf Mon Sep 17 00:00:00 2001 From: Rosemarie O'Riorden Date: Wed, 15 Jul 2026 13:50:23 -0400 Subject: [PATCH 3/3] tui: search: Make esc exit patch, q clear search then exit. Before this commit, esc and q had the same function inside the patch viewer during a search, which is that they would both exit from the search, and then with a second press they'd exit from the patch viewer and go back to the list of patches. With this commit, esc exits from the patch viewer even mid-search, and q exits from the serach upon the first press, and exits from the patch viewer upon the second press. However, if nothing was found in a search, q will exit from the patch viewer immediately as if an active search was not ongoing. Assisted-by: Claude Sonnet 4.5, Claude Code Signed-off-by: Rosemarie O'Riorden --- tui/keys.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tui/keys.go b/tui/keys.go index 93d78ea..528750a 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -403,11 +403,21 @@ func (m *Model) handleViewportKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch key { - case "q", "esc": - if m.searchText != "" { + case "q": + if m.searchText != "" && len(m.searchMatches) > 0 { m.clearSearch() return m, nil } + m.clearSearch() + m.viewMode = viewTable + m.viewingPatchID = 0 + m.viewingCoverID = 0 + m.viewComments = nil + m.viewCommentIdx = -1 + m.viewportLines = nil + m.viewExpanded = false + case "esc": + m.clearSearch() m.viewMode = viewTable m.viewingPatchID = 0 m.viewingCoverID = 0 @@ -1004,11 +1014,17 @@ func (m *Model) handleCompareKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch key { - case "q", "esc": - if m.searchText != "" { + case "q": + if m.searchText != "" && len(m.searchMatches) > 0 { m.clearSearch() return m, nil } + m.clearSearch() + m.viewMode = viewTable + m.compareCount = 0 + return m, nil + case "esc": + m.clearSearch() m.viewMode = viewTable m.compareCount = 0 return m, nil