From 267c6c47cc7bb7ebed70c5c20468f75f06c874e5 Mon Sep 17 00:00:00 2001 From: Giorgos Logiotatidis Date: Fri, 16 Jan 2026 14:28:19 +0200 Subject: [PATCH] feat(pager): add incremental search functionality Add less-style search to the pager with the following features: - Press / to start searching, type query, Enter to confirm - n/N to navigate between matches - Smart case sensitivity (case-insensitive unless query has uppercase) - Search highlighting with current match distinction - Preserves ANSI formatting in highlighted lines Fixes #834, #420, #323, #183 --- log.go | 1 - main.go | 1 - ui/incremental_search_test.go | 205 +++++++++++++++ ui/pager.go | 460 ++++++++++++++++++++++++++++++++-- ui/pager_message_test.go | 168 +++++++++++++ ui/pager_search_test.go | 291 +++++++++++++++++++++ ui/search_test.go | 108 ++++++++ ui/stash.go | 2 - ui/ui.go | 16 +- 9 files changed, 1214 insertions(+), 38 deletions(-) create mode 100644 ui/incremental_search_test.go create mode 100644 ui/pager_message_test.go create mode 100644 ui/pager_search_test.go create mode 100644 ui/search_test.go diff --git a/log.go b/log.go index a864a71..32d2740 100644 --- a/log.go +++ b/log.go @@ -35,6 +35,5 @@ func setupLog() (func() error, error) { return func() error { return nil }, nil //nolint:nilerr } log.SetOutput(f) - log.SetLevel(log.DebugLevel) return f.Close, nil } diff --git a/main.go b/main.go index b0d7a77..53ed012 100644 --- a/main.go +++ b/main.go @@ -455,7 +455,6 @@ func tryLoadConfigFromDefaultPlaces() { } if used := viper.ConfigFileUsed(); used != "" { - log.Debug("Using configuration file", "path", viper.ConfigFileUsed()) return } diff --git a/ui/incremental_search_test.go b/ui/incremental_search_test.go new file mode 100644 index 0000000..914e0ac --- /dev/null +++ b/ui/incremental_search_test.go @@ -0,0 +1,205 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestIncrementalSearch(t *testing.T) { + config = Config{ + GlamourEnabled: false, + HighPerformancePager: false, + } + + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + testContent := `Line 1: nothing here +Line 2: apple is here +Line 3: more content +Line 4: another apple +Line 5: the end +` + + pager.setRenderedContent(testContent) + pager.viewport.SetContent(testContent) + + // Enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} + newPager, _ := pager.update(keyMsg) + pager = newPager + + if pager.state != pagerStateSearch { + t.Fatalf("expected pagerStateSearch, got %d", pager.state) + } + + // Type 'a' - should find matches incrementally + aMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}} + newPager, _ = pager.update(aMsg) + pager = newPager + + // After typing 'a', we should have matches (apple has 'a') + if pager.searchInput.Value() != "a" { + t.Errorf("expected searchInput 'a', got %q", pager.searchInput.Value()) + } + if !pager.searchActive { + t.Error("searchActive should be true after typing") + } + // 'a' appears in 'apple' (2 times in lines 2 and 4), plus 'another' and 'the end' 'a' + if len(pager.searchMatches) < 1 { + t.Errorf("expected at least 1 match for 'a', got %d", len(pager.searchMatches)) + } + + // Continue typing 'pple' to form 'apple' + for _, r := range "pple" { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + newPager, _ = pager.update(msg) + pager = newPager + } + + if pager.searchInput.Value() != "apple" { + t.Errorf("expected searchInput 'apple', got %q", pager.searchInput.Value()) + } + + // Should have exactly 2 matches for 'apple' + if len(pager.searchMatches) != 2 { + t.Errorf("expected 2 matches for 'apple', got %d", len(pager.searchMatches)) + } + + // Current match should be 0 (first match) + if pager.currentMatchIndex != 0 { + t.Errorf("expected currentMatchIndex 0, got %d", pager.currentMatchIndex) + } + + // Press Enter to confirm search + enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + newPager, _ = pager.update(enterMsg) + pager = newPager + + if pager.state != pagerStateBrowse { + t.Errorf("expected pagerStateBrowse after Enter, got %d", pager.state) + } + + // Search should still be active + if !pager.searchActive { + t.Error("searchActive should still be true after confirming") + } + + // Matches should still be there + if len(pager.searchMatches) != 2 { + t.Errorf("expected 2 matches after confirm, got %d", len(pager.searchMatches)) + } +} + +func TestIncrementalSearchEscCancels(t *testing.T) { + config = Config{ + GlamourEnabled: false, + HighPerformancePager: false, + } + + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + testContent := "content with apple here" + pager.setRenderedContent(testContent) + pager.viewport.SetContent(testContent) + + // Enter search mode + slashMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} + newPager, _ := pager.update(slashMsg) + pager = newPager + + // Type 'app' + for _, r := range "app" { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + newPager, _ = pager.update(msg) + pager = newPager + } + + // Should have a match + if len(pager.searchMatches) == 0 { + t.Error("expected matches for 'app'") + } + + // Press Esc to cancel + escMsg := tea.KeyMsg{Type: tea.KeyEscape} + newPager, _ = pager.update(escMsg) + pager = newPager + + // Should be back to browse state + if pager.state != pagerStateBrowse { + t.Errorf("expected pagerStateBrowse after Esc, got %d", pager.state) + } + + // Search should be cleared + if pager.searchActive { + t.Error("searchActive should be false after Esc") + } + if len(pager.searchMatches) != 0 { + t.Error("searchMatches should be empty after Esc") + } + if pager.searchInput.Value() != "" { + t.Errorf("searchInput should be empty after Esc, got %q", pager.searchInput.Value()) + } +} + +func TestIncrementalSearchEmptyQuery(t *testing.T) { + config = Config{ + GlamourEnabled: false, + HighPerformancePager: false, + } + + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + testContent := "test content" + pager.setRenderedContent(testContent) + pager.viewport.SetContent(testContent) + + // Enter search mode + slashMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} + newPager, _ := pager.update(slashMsg) + pager = newPager + + // Type something + for _, r := range "test" { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + newPager, _ = pager.update(msg) + pager = newPager + } + + if !pager.searchActive { + t.Error("searchActive should be true") + } + + // Delete all characters using backspace + for i := 0; i < 4; i++ { + bsMsg := tea.KeyMsg{Type: tea.KeyBackspace} + newPager, _ = pager.update(bsMsg) + pager = newPager + } + + // With empty query, search should be inactive + if pager.searchActive { + t.Error("searchActive should be false with empty query") + } + if len(pager.searchMatches) != 0 { + t.Error("searchMatches should be empty with empty query") + } +} diff --git a/ui/pager.go b/ui/pager.go index 5c522e7..0733f2c 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -4,10 +4,13 @@ import ( "fmt" "math" "path/filepath" + "regexp" "strings" "time" + "unicode" "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" @@ -75,6 +78,24 @@ var ( lineNumberStyle = lipgloss.NewStyle(). Foreground(lineNumberFg). Render + + // Search-related styles + searchHighlightStyle = lipgloss.NewStyle(). + Background(lipgloss.AdaptiveColor{Light: "#FFFF00", Dark: "#FFFF00"}). + Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#000000"}). + Bold(true) + + searchCurrentHighlightStyle = lipgloss.NewStyle(). + Background(lipgloss.AdaptiveColor{Light: "#FF6600", Dark: "#FF6600"}). + Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#000000"}). + Bold(true) + + searchInputPromptStyle = lipgloss.NewStyle(). + Foreground(yellowGreen). + MarginRight(1) + + searchInputCursorStyle = lipgloss.NewStyle(). + Foreground(fuchsia) ) type ( @@ -87,8 +108,16 @@ type pagerState int const ( pagerStateBrowse pagerState = iota pagerStateStatusMessage + pagerStateSearch ) +// searchMatch represents a match position in the content +type searchMatch struct { + lineIndex int // line number (0-based) + startCol int // start column in the line + endCol int // end column in the line +} + type pagerModel struct { common *commonModel viewport viewport.Model @@ -103,6 +132,17 @@ type pagerModel struct { currentDocument markdown watcher *fsnotify.Watcher + + // Search-related fields + searchInput textinput.Model + searchQuery string + searchMatches []searchMatch + currentMatchIndex int + renderedContent string // full rendered content (for searching) + renderedLines []string // cached split lines (avoid repeated splits) + plainLines []string // cached ANSI-stripped lines (avoid repeated stripAnsi) + searchActive bool // whether search results are being displayed + searchError string // error message for invalid pattern } func newPagerModel(common *commonModel) pagerModel { @@ -111,10 +151,19 @@ func newPagerModel(common *commonModel) pagerModel { vp.YPosition = 0 vp.HighPerformanceRendering = config.HighPerformancePager + // Init search input + si := textinput.New() + si.Prompt = "/" + si.PromptStyle = searchInputPromptStyle + si.Cursor.Style = searchInputCursorStyle + si.Placeholder = "search..." + m := pagerModel{ - common: common, - state: pagerStateBrowse, - viewport: vp, + common: common, + state: pagerStateBrowse, + viewport: vp, + searchInput: si, + currentMatchIndex: -1, } m.initWatcher() return m @@ -124,6 +173,9 @@ func (m *pagerModel) setSize(w, h int) { m.viewport.Width = w m.viewport.Height = h - statusBarHeight + // Set search input width + m.searchInput.Width = w - 4 // Account for prompt and padding + if m.showHelp { if pagerHelpHeight == 0 { pagerHelpHeight = strings.Count(m.helpView(), "\n") @@ -136,6 +188,17 @@ func (m *pagerModel) setContent(s string) { m.viewport.SetContent(s) } +// setRenderedContent updates the rendered content and caches processed lines. +func (m *pagerModel) setRenderedContent(content string) { + m.renderedContent = content + m.renderedLines = strings.Split(content, "\n") + // Pre-strip ANSI codes for faster searching + m.plainLines = make([]string, len(m.renderedLines)) + for i, line := range m.renderedLines { + m.plainLines[i] = stripAnsi(line) + } +} + func (m *pagerModel) toggleHelp() { m.showHelp = !m.showHelp m.setSize(m.common.width, m.common.height) @@ -165,7 +228,6 @@ func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd { } func (m *pagerModel) unload() { - log.Debug("unload") if m.showHelp { m.toggleHelp() } @@ -176,6 +238,20 @@ func (m *pagerModel) unload() { m.viewport.SetContent("") m.viewport.YOffset = 0 m.unwatchFile() + m.clearSearch() +} + +func (m *pagerModel) clearSearch() { + m.searchQuery = "" + m.searchMatches = nil + m.currentMatchIndex = -1 + m.searchActive = false + m.searchError = "" + m.searchInput.Reset() + // Restore original content if we have it + if m.renderedContent != "" { + m.viewport.SetContent(m.renderedContent) + } } func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { @@ -184,10 +260,28 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { cmds []tea.Cmd ) + // Handle search mode input + if m.state == pagerStateSearch { + return m.handleSearchInput(msg) + } + switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case "q", keyEsc: + case "q": + if m.state != pagerStateBrowse { + m.state = pagerStateBrowse + return m, nil + } + case keyEsc: + if m.searchActive { + // Clear search and restore original content + m.clearSearch() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + return m, tea.Batch(cmds...) + } if m.state != pagerStateBrowse { m.state = pagerStateBrowse return m, nil @@ -235,6 +329,7 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false})) case "r": + m.clearSearch() return m, loadLocalMarkdown(&m.currentDocument) case "?": @@ -242,13 +337,64 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { if m.viewport.HighPerformanceRendering { cmds = append(cmds, viewport.Sync(m.viewport)) } + + case "/": + // Enter search mode + m.state = pagerStateSearch + m.searchInput.Focus() + return m, textinput.Blink + + case "n": + // Next search match + if m.searchActive && len(m.searchMatches) > 0 { + m.currentMatchIndex = (m.currentMatchIndex + 1) % len(m.searchMatches) + m.jumpToCurrentMatch() + m.updateSearchHighlighting() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } + + case "N": + // Previous search match + if m.searchActive && len(m.searchMatches) > 0 { + m.currentMatchIndex-- + if m.currentMatchIndex < 0 { + m.currentMatchIndex = len(m.searchMatches) - 1 + } + m.jumpToCurrentMatch() + m.updateSearchHighlighting() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } } // Glow has rendered the content case contentRenderedMsg: log.Info("content rendered", "state", m.state) - m.setContent(string(msg)) + // Store the full rendered content and cache split lines for searching + m.setRenderedContent(string(msg)) + m.setContent(m.renderedContent) + + // Re-perform search if there's an active search query + // This handles terminal resizes which re-render the content + if m.searchActive && m.searchQuery != "" { + m.performSearch() + if len(m.searchMatches) > 0 { + // Clamp currentMatchIndex to valid range + if m.currentMatchIndex >= len(m.searchMatches) { + m.currentMatchIndex = len(m.searchMatches) - 1 + } + if m.currentMatchIndex < 0 { + m.currentMatchIndex = 0 + } + m.jumpToCurrentMatch() + m.updateSearchHighlighting() + } + } + if m.viewport.HighPerformanceRendering { cmds = append(cmds, viewport.Sync(m.viewport)) } @@ -283,8 +429,13 @@ func (m pagerModel) View() string { var b strings.Builder fmt.Fprint(&b, m.viewport.View()+"\n") - // Footer - m.statusBarView(&b) + // Show search input if in search mode + if m.state == pagerStateSearch { + m.searchBarView(&b) + } else { + // Footer + m.statusBarView(&b) + } if m.showHelp { fmt.Fprint(&b, "\n"+m.helpView()) @@ -293,6 +444,17 @@ func (m pagerModel) View() string { return b.String() } +func (m pagerModel) searchBarView(b *strings.Builder) { + // Search input + searchInput := m.searchInput.View() + + // Pad with spaces to fill the width + padding := max(0, m.common.width-ansi.PrintableRuneWidth(searchInput)) + paddedInput := searchInput + strings.Repeat(" ", padding) + + fmt.Fprint(b, statusBarNoteStyle(paddedInput)) +} + func (m pagerModel) statusBarView(b *strings.Builder) { const ( minPercent float64 = 0.0 @@ -326,6 +488,12 @@ func (m pagerModel) statusBarView(b *strings.Builder) { var note string if showStatusMessage { note = m.statusMessage + } else if m.searchActive && m.searchError != "" { + note = fmt.Sprintf("[%s] %s", m.searchError, m.searchQuery) + } else if m.searchActive && len(m.searchMatches) > 0 { + note = fmt.Sprintf("[%d/%d] %s", m.currentMatchIndex+1, len(m.searchMatches), m.searchQuery) + } else if m.searchActive && len(m.searchMatches) == 0 { + note = fmt.Sprintf("[no matches] %s", m.searchQuery) } else { note = m.currentDocument.Note } @@ -372,7 +540,9 @@ func (m pagerModel) helpView() (s string) { "c copy contents", "e edit this document", "r reload this document", - "esc back to files", + "/ search", + "n/N next/prev match", + "esc clear search / back", "q quit", } @@ -382,11 +552,10 @@ func (m pagerModel) helpView() (s string) { s += "b/pgup page up " + col1[2] + "\n" s += "f/pgdn page down " + col1[3] + "\n" s += "u ½ page up " + col1[4] + "\n" - s += "d ½ page down " - - if len(col1) > 5 { - s += col1[5] - } + s += "d ½ page down " + col1[5] + "\n" + s += " " + col1[6] + "\n" + s += " " + col1[7] + "\n" + s += " " + col1[8] s = indent(s, 2) @@ -405,6 +574,258 @@ func (m pagerModel) helpView() (s string) { return helpViewStyle(s) } +// SEARCH FUNCTIONS + +// ansiPattern matches ANSI escape sequences +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[PX^_][^\x1b]*\x1b\\|\x1b.`) + +// stripAnsi removes ANSI escape codes from a string +func stripAnsi(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +// highlightRenderedLine inserts search highlights into an ANSI-formatted line +// at the correct plain-text positions, preserving existing ANSI formatting. +func highlightRenderedLine(rendered string, matches []searchMatch, currentMatchIndex, lineIndex int) string { + if len(matches) == 0 { + return rendered + } + + // Filter matches for this line + var lineMatches []searchMatch + for _, m := range matches { + if m.lineIndex == lineIndex { + lineMatches = append(lineMatches, m) + } + } + if len(lineMatches) == 0 { + return rendered + } + + var result strings.Builder + plainPos := 0 // Current position in plain text (what user sees) + i := 0 // Current position in rendered string + matchIdx := 0 // Current match we're processing + inHighlight := false + + for i < len(rendered) { + // Check for ANSI escape sequence + if rendered[i] == '\x1b' { + // Find the end of the ANSI sequence + loc := ansiPattern.FindStringIndex(rendered[i:]) + if loc != nil && loc[0] == 0 { + // Copy the ANSI sequence as-is + result.WriteString(rendered[i : i+loc[1]]) + i += loc[1] + continue + } + } + + // Check if we need to start a highlight + if matchIdx < len(lineMatches) && plainPos == lineMatches[matchIdx].startCol && !inHighlight { + // Find if this match is the current one (for different highlight style) + isCurrent := false + for globalIdx, m := range matches { + if m.lineIndex == lineIndex && m.startCol == lineMatches[matchIdx].startCol { + isCurrent = (globalIdx == currentMatchIndex) + break + } + } + if isCurrent { + result.WriteString("\x1b[48;2;255;102;0m\x1b[38;2;0;0;0m\x1b[1m") // Orange bg, black fg, bold + } else { + result.WriteString("\x1b[48;2;255;255;0m\x1b[38;2;0;0;0m\x1b[1m") // Yellow bg, black fg, bold + } + inHighlight = true + } + + // Check if we need to end a highlight + if matchIdx < len(lineMatches) && plainPos == lineMatches[matchIdx].endCol && inHighlight { + result.WriteString("\x1b[0m") // Reset + inHighlight = false + matchIdx++ + } + + // Copy the current character + result.WriteByte(rendered[i]) + plainPos++ + i++ + } + + // Close any open highlight at end of line + if inHighlight { + result.WriteString("\x1b[0m") + } + + return result.String() +} + +func (m pagerModel) handleSearchInput(msg tea.Msg) (pagerModel, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case keyEsc: + // Cancel search and restore original content + m.state = pagerStateBrowse + m.searchInput.Reset() + m.clearSearch() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + return m, tea.Batch(cmds...) + + case keyEnter: + // Confirm search and exit search mode + m.state = pagerStateBrowse + query := m.searchInput.Value() + if query == "" { + m.clearSearch() + return m, nil + } + // Search is already done incrementally, just confirm it + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + return m, tea.Batch(cmds...) + } + } + + // Update the search input + var cmd tea.Cmd + previousValue := m.searchInput.Value() + m.searchInput, cmd = m.searchInput.Update(msg) + cmds = append(cmds, cmd) + + // Incremental search: if the input value changed, perform search + newValue := m.searchInput.Value() + if newValue != previousValue { + m.searchQuery = newValue + if newValue == "" { + // Clear search if input is empty + m.searchMatches = nil + m.searchActive = false + m.currentMatchIndex = -1 + // Restore original content + if m.renderedContent != "" { + m.viewport.SetContent(m.renderedContent) + } + } else { + // Perform incremental search + m.performSearch() + if len(m.searchMatches) > 0 { + m.currentMatchIndex = 0 + m.jumpToCurrentMatch() + m.updateSearchHighlighting() + } else { + // No matches - restore original content but keep search active + if m.renderedContent != "" { + m.viewport.SetContent(m.renderedContent) + } + } + } + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } + + return m, tea.Batch(cmds...) +} + +// performSearch finds all matches in the content using simple string matching. +func (m *pagerModel) performSearch() { + m.searchMatches = nil + m.searchError = "" + + if m.searchQuery == "" || len(m.plainLines) == 0 { + m.searchActive = false + return + } + + m.searchActive = true + + // Smart case: case-insensitive unless query has uppercase letters + caseSensitive := false + for _, r := range m.searchQuery { + if unicode.IsUpper(r) { + caseSensitive = true + break + } + } + + query := m.searchQuery + queryLen := len(query) + if !caseSensitive { + query = strings.ToLower(query) + } + + // Find all matches using simple string search + for lineIdx, plainLine := range m.plainLines { + searchLine := plainLine + if !caseSensitive { + searchLine = strings.ToLower(plainLine) + } + + // Find all occurrences in this line + offset := 0 + for { + idx := strings.Index(searchLine[offset:], query) + if idx == -1 { + break + } + startCol := offset + idx + m.searchMatches = append(m.searchMatches, searchMatch{ + lineIndex: lineIdx, + startCol: startCol, + endCol: startCol + queryLen, + }) + offset = startCol + queryLen + } + } +} + +func (m *pagerModel) jumpToCurrentMatch() { + if m.currentMatchIndex < 0 || m.currentMatchIndex >= len(m.searchMatches) { + return + } + + match := m.searchMatches[m.currentMatchIndex] + + // Calculate the target line, centering it in the viewport + targetLine := match.lineIndex - m.viewport.Height/2 + if targetLine < 0 { + targetLine = 0 + } + + m.viewport.YOffset = targetLine +} + +// updateSearchHighlighting applies highlighting to matched text while preserving ANSI formatting. +func (m *pagerModel) updateSearchHighlighting() { + if !m.searchActive || len(m.searchMatches) == 0 || len(m.renderedLines) == 0 { + return + } + + // Track which lines have matches + linesWithMatches := make(map[int]bool) + for _, match := range m.searchMatches { + linesWithMatches[match.lineIndex] = true + } + + // Copy lines and apply highlighting only to lines with matches + lines := make([]string, len(m.renderedLines)) + copy(lines, m.renderedLines) + + for lineIdx := range linesWithMatches { + if lineIdx < len(lines) { + lines[lineIdx] = highlightRenderedLine(m.renderedLines[lineIdx], m.searchMatches, m.currentMatchIndex, lineIdx) + } + } + + m.viewport.SetContent(strings.Join(lines, "\n")) +} + // COMMANDS func renderWithGlamour(m pagerModel, md string) tea.Cmd { @@ -508,13 +929,11 @@ func (m *pagerModel) watchFile() tea.Msg { continue } - log.Debug("fsnotify event", "file", event.Name, "event", event.Op) return reloadMsg{} - case err, ok := <-m.watcher.Errors: + case _, ok := <-m.watcher.Errors: if !ok { continue } - log.Debug("fsnotify error", "dir", dir, "error", err) } } } @@ -522,12 +941,7 @@ func (m *pagerModel) watchFile() tea.Msg { func (m *pagerModel) unwatchFile() { dir := m.localDir() - err := m.watcher.Remove(dir) - if err == nil { - log.Debug("fsnotify dir unwatched", "dir", dir) - } else { - log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err) - } + _ = m.watcher.Remove(dir) } func (m *pagerModel) localDir() string { diff --git a/ui/pager_message_test.go b/ui/pager_message_test.go new file mode 100644 index 0000000..445ab37 --- /dev/null +++ b/ui/pager_message_test.go @@ -0,0 +1,168 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestPagerMessageFlow(t *testing.T) { + // Initialize config (global variable used by pager) + config = Config{ + GlamourEnabled: false, // Disable glamour for simpler testing + HighPerformancePager: false, + } + + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + // Simulate receiving rendered content (what happens after file load) + testContent := `# Test Document + +This document contains the word apple. + +Another line with apple here. + +And one more apple at the end. +` + + // Simulate contentRenderedMsg + msg := contentRenderedMsg(testContent) + newPager, _ := pager.update(msg) + pager = newPager + + // Verify content was stored + if pager.renderedContent != testContent { + t.Error("renderedContent was not stored correctly") + t.Logf("expected length: %d, got: %d", len(testContent), len(pager.renderedContent)) + } + + // Simulate pressing '/' to enter search mode + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} + newPager, _ = pager.update(keyMsg) + pager = newPager + + if pager.state != pagerStateSearch { + t.Errorf("expected state pagerStateSearch, got %d", pager.state) + } + + // Simulate typing 'apple' + for _, r := range "apple" { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + newPager, _ = pager.update(keyMsg) + pager = newPager + } + + // Check that the search input has the correct value + if pager.searchInput.Value() != "apple" { + t.Errorf("expected searchInput value 'apple', got %q", pager.searchInput.Value()) + } + + // Simulate pressing Enter to execute search + enterMsg := tea.KeyMsg{Type: tea.KeyEnter} + newPager, _ = pager.update(enterMsg) + pager = newPager + + // Check search was executed + if pager.state != pagerStateBrowse { + t.Errorf("expected state pagerStateBrowse after Enter, got %d", pager.state) + } + + if !pager.searchActive { + t.Error("searchActive should be true after search") + } + + if pager.searchQuery != "apple" { + t.Errorf("expected searchQuery 'apple', got %q", pager.searchQuery) + } + + // Check matches were found + if len(pager.searchMatches) != 3 { + t.Errorf("expected 3 matches for 'apple', got %d", len(pager.searchMatches)) + } + + // Test 'n' to go to next match + if len(pager.searchMatches) > 0 { + pager.currentMatchIndex = 0 + nMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + newPager, _ = pager.update(nMsg) + pager = newPager + + if pager.currentMatchIndex != 1 { + t.Errorf("expected currentMatchIndex 1 after 'n', got %d", pager.currentMatchIndex) + } + } + + // Test 'N' to go to previous match + shiftNMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}} + newPager, _ = pager.update(shiftNMsg) + pager = newPager + + if pager.currentMatchIndex != 0 { + t.Errorf("expected currentMatchIndex 0 after 'N', got %d", pager.currentMatchIndex) + } + + // Test Esc to clear search + escMsg := tea.KeyMsg{Type: tea.KeyEscape} + newPager, _ = pager.update(escMsg) + pager = newPager + + if pager.searchActive { + t.Error("searchActive should be false after Esc") + } + + if len(pager.searchMatches) != 0 { + t.Error("searchMatches should be empty after Esc") + } +} + +func TestSearchInputKeyHandling(t *testing.T) { + config = Config{ + GlamourEnabled: false, + HighPerformancePager: false, + } + + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + pager.setRenderedContent("test content with apple") + pager.viewport.SetContent(pager.renderedContent) + + // Enter search mode + pager.state = pagerStateSearch + pager.searchInput.Focus() + + // Type some characters + for _, r := range "test" { + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + newPager, _ := pager.update(keyMsg) + pager = newPager + } + + if pager.searchInput.Value() != "test" { + t.Errorf("expected 'test', got %q", pager.searchInput.Value()) + } + + // Press Esc to cancel + escMsg := tea.KeyMsg{Type: tea.KeyEscape} + newPager, _ := pager.update(escMsg) + pager = newPager + + if pager.state != pagerStateBrowse { + t.Errorf("expected pagerStateBrowse after Esc, got %d", pager.state) + } + + // The search input should be reset + if pager.searchInput.Value() != "" { + t.Errorf("searchInput should be empty after Esc cancel, got %q", pager.searchInput.Value()) + } +} diff --git a/ui/pager_search_test.go b/ui/pager_search_test.go new file mode 100644 index 0000000..e9a4f34 --- /dev/null +++ b/ui/pager_search_test.go @@ -0,0 +1,291 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestPagerSearchIntegration(t *testing.T) { + // Create a mock common model + common := &commonModel{ + width: 80, + height: 24, + } + + // Create a new pager model + pager := newPagerModel(common) + pager.setSize(80, 24) + + // Simulate rendered content being set (like what happens after glamour rendering) + renderedContent := `# Search Test Document + +This is a test document for testing the search functionality. + +## Section One + +The quick brown fox jumps over the lazy dog. + +Here is the word apple in a sentence. + +## Section Two + +Another paragraph with apple mentioned again. + +- Item with apple +- Item with banana + +## Section Three + +Final apple reference here. +` + + // Set the rendered content (this normally happens via contentRenderedMsg) + pager.setRenderedContent(renderedContent) + pager.viewport.SetContent(renderedContent) + + // Test search for "apple" + pager.searchQuery = "apple" + pager.performSearch() + + // Verify matches were found + if len(pager.searchMatches) != 4 { + t.Errorf("expected 4 matches for 'apple', got %d", len(pager.searchMatches)) + for i, m := range pager.searchMatches { + t.Logf("match %d: line %d", i, m.lineIndex) + } + } + + if !pager.searchActive { + t.Error("searchActive should be true after search") + } + + // Test jumping to match + if len(pager.searchMatches) > 0 { + pager.currentMatchIndex = 0 + pager.jumpToCurrentMatch() + + // The viewport should have moved + match := pager.searchMatches[0] + t.Logf("first match at line %d, viewport offset: %d", match.lineIndex, pager.viewport.YOffset) + } + + // Test clear search + pager.clearSearch() + if pager.searchActive { + t.Error("searchActive should be false after clearSearch") + } + if len(pager.searchMatches) != 0 { + t.Error("searchMatches should be empty after clearSearch") + } + if pager.currentMatchIndex != -1 { + t.Error("currentMatchIndex should be -1 after clearSearch") + } +} + +func TestPagerSearchNoMatches(t *testing.T) { + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + pager.setRenderedContent("This is some content without the search term.") + pager.viewport.SetContent(pager.renderedContent) + + pager.searchQuery = "nonexistent" + pager.performSearch() + + if len(pager.searchMatches) != 0 { + t.Errorf("expected 0 matches, got %d", len(pager.searchMatches)) + } + + if !pager.searchActive { + t.Error("searchActive should be true even with no matches") + } +} + +func TestPagerSearchCaseInsensitive(t *testing.T) { + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + pager.setRenderedContent("Apple APPLE apple ApPlE") + pager.viewport.SetContent(pager.renderedContent) + + pager.searchQuery = "apple" + pager.performSearch() + + if len(pager.searchMatches) != 4 { + t.Errorf("expected 4 case-insensitive matches, got %d", len(pager.searchMatches)) + } +} + +func TestPagerSearchSmartCaseSensitive(t *testing.T) { + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + // Content with multiple case variations + pager.setRenderedContent("Apple APPLE apple ApPlE") + pager.viewport.SetContent(pager.renderedContent) + + // Search with uppercase "Apple" - smart case should make this case-sensitive + pager.searchQuery = "Apple" + pager.performSearch() + + // Should only match "Apple" (exact case), not "APPLE", "apple", or "ApPlE" + if len(pager.searchMatches) != 1 { + t.Errorf("expected 1 case-sensitive match for 'Apple', got %d", len(pager.searchMatches)) + } + + // Verify the match is at the correct position (start of string) + if len(pager.searchMatches) > 0 { + match := pager.searchMatches[0] + if match.startCol != 0 || match.endCol != 5 { + t.Errorf("expected match at columns 0-5, got %d-%d", match.startCol, match.endCol) + } + } +} + +func TestPagerSearchHighlightingMatchesSearch(t *testing.T) { + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + // Multi-line content with case variations + pager.setRenderedContent("Line1: Apple here\nLine2: APPLE here\nLine3: apple here\nLine4: ApPlE here") + pager.viewport.SetContent(pager.renderedContent) + + // Case-sensitive search for "Apple" + pager.searchQuery = "Apple" + pager.performSearch() + + // performSearch should find 1 match + if len(pager.searchMatches) != 1 { + t.Errorf("performSearch: expected 1 match, got %d", len(pager.searchMatches)) + } + + // Verify match is on line 0 (Line1: Apple here) + if len(pager.searchMatches) > 0 && pager.searchMatches[0].lineIndex != 0 { + t.Errorf("expected match on line 0, got line %d", pager.searchMatches[0].lineIndex) + } +} + +func TestPagerSearchWithAnsiCodes(t *testing.T) { + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + // Simulate content with ANSI color codes (like glamour output) + pager.setRenderedContent("\x1b[1m# Header\x1b[0m\n\nThis has \x1b[32mapple\x1b[0m in it.\n\nAnother \x1b[33mAPPLE\x1b[0m here.") + pager.viewport.SetContent(pager.renderedContent) + + pager.searchQuery = "apple" + pager.performSearch() + + if len(pager.searchMatches) != 2 { + t.Errorf("expected 2 matches with ANSI codes, got %d", len(pager.searchMatches)) + } +} + +func TestPagerNextPrevMatch(t *testing.T) { + common := &commonModel{ + width: 80, + height: 24, + } + + pager := newPagerModel(common) + pager.setSize(80, 24) + + pager.setRenderedContent("apple\napple\napple") + pager.viewport.SetContent(pager.renderedContent) + + pager.searchQuery = "apple" + pager.performSearch() + pager.currentMatchIndex = 0 + + // Test next + pager.currentMatchIndex = (pager.currentMatchIndex + 1) % len(pager.searchMatches) + if pager.currentMatchIndex != 1 { + t.Errorf("expected currentMatchIndex 1, got %d", pager.currentMatchIndex) + } + + // Test wrap around + pager.currentMatchIndex = (pager.currentMatchIndex + 1) % len(pager.searchMatches) + pager.currentMatchIndex = (pager.currentMatchIndex + 1) % len(pager.searchMatches) + if pager.currentMatchIndex != 0 { + t.Errorf("expected currentMatchIndex to wrap to 0, got %d", pager.currentMatchIndex) + } + + // Test prev + pager.currentMatchIndex-- + if pager.currentMatchIndex < 0 { + pager.currentMatchIndex = len(pager.searchMatches) - 1 + } + if pager.currentMatchIndex != 2 { + t.Errorf("expected currentMatchIndex 2 after prev from 0, got %d", pager.currentMatchIndex) + } +} + +func TestHighlightRenderedLinePreservesFormatting(t *testing.T) { + // Test that ANSI formatting is preserved in non-matched portions of the line + // This is the core fix for the bug where updateSearchHighlighting was + // reconstructing lines from plain text and losing all ANSI formatting + + // Line with ANSI formatting: "prefix " (green) + "word" (normal) + " suffix" (blue) + renderedLine := "\x1b[32mprefix \x1b[0mword\x1b[34m suffix\x1b[0m" + + // Match "word" which is at plain-text positions 7-11 + matches := []searchMatch{{lineIndex: 0, startCol: 7, endCol: 11}} + + result := highlightRenderedLine(renderedLine, matches, -1, 0) + + // The result should: + // 1. Preserve the green ANSI code before "word" + // 2. Have the highlight applied to "word" + // 3. Preserve the blue ANSI code after "word" + + // Check that green code is preserved at the start + if !strings.Contains(result, "\x1b[32m") { + t.Error("expected green ANSI code to be preserved in the result") + } + + // Check that blue code is preserved at the end + if !strings.Contains(result, "\x1b[34m") { + t.Error("expected blue ANSI code to be preserved in the result") + } + + // Check that "prefix " appears in the result + plainResult := stripAnsi(result) + if !strings.Contains(plainResult, "prefix ") { + t.Errorf("expected 'prefix ' in result, got: %s", plainResult) + } + + // Check that "word" appears in the result + if !strings.Contains(plainResult, "word") { + t.Errorf("expected 'word' in result, got: %s", plainResult) + } + + // Check that " suffix" appears in the result + if !strings.Contains(plainResult, " suffix") { + t.Errorf("expected ' suffix' in result, got: %s", plainResult) + } +} diff --git a/ui/search_test.go b/ui/search_test.go new file mode 100644 index 0000000..167e14f --- /dev/null +++ b/ui/search_test.go @@ -0,0 +1,108 @@ +package ui + +import ( + "regexp" + "strings" + "testing" +) + +func TestStripAnsi(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"\x1b[31mred\x1b[0m", "red"}, + {"\x1b[1;32mbold green\x1b[0m text", "bold green text"}, + {"no ansi here", "no ansi here"}, + } + + for _, tt := range tests { + result := stripAnsi(tt.input) + if result != tt.expected { + t.Errorf("stripAnsi(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestSearchInContent(t *testing.T) { + content := `# Test Document + +This is a test with apple in it. + +Another line with APPLE (uppercase). + +And one more apple here.` + + lines := strings.Split(content, "\n") + query := "apple" + escapedQuery := regexp.QuoteMeta(query) + re, err := regexp.Compile("(?i)" + escapedQuery) + if err != nil { + t.Fatalf("failed to compile regex: %v", err) + } + + var matches []searchMatch + for lineIdx, line := range lines { + plainLine := stripAnsi(line) + found := re.FindAllStringIndex(plainLine, -1) + for _, match := range found { + matches = append(matches, searchMatch{ + lineIndex: lineIdx, + startCol: match[0], + endCol: match[1], + }) + } + } + + // Should find 3 matches for "apple" + if len(matches) != 3 { + t.Errorf("expected 3 matches for 'apple', got %d", len(matches)) + for i, m := range matches { + t.Logf("match %d: line %d, col %d-%d", i, m.lineIndex, m.startCol, m.endCol) + } + } + + // Verify line numbers + expectedLines := []int{2, 4, 6} + for i, m := range matches { + if m.lineIndex != expectedLines[i] { + t.Errorf("match %d: expected line %d, got %d", i, expectedLines[i], m.lineIndex) + } + } +} + +func TestSearchWithAnsiContent(t *testing.T) { + // Simulate glamour-rendered content with ANSI codes + content := "\x1b[1m# Test\x1b[0m\n\nThis has \x1b[32mapple\x1b[0m in it.\n\nAnother \x1b[33mAPPLE\x1b[0m here." + + lines := strings.Split(content, "\n") + query := "apple" + escapedQuery := regexp.QuoteMeta(query) + re, err := regexp.Compile("(?i)" + escapedQuery) + if err != nil { + t.Fatalf("failed to compile regex: %v", err) + } + + var matches []searchMatch + for lineIdx, line := range lines { + plainLine := stripAnsi(line) + found := re.FindAllStringIndex(plainLine, -1) + for _, match := range found { + matches = append(matches, searchMatch{ + lineIndex: lineIdx, + startCol: match[0], + endCol: match[1], + }) + } + } + + // Should find 2 matches for "apple" even with ANSI codes + if len(matches) != 2 { + t.Errorf("expected 2 matches for 'apple', got %d", len(matches)) + for i, m := range matches { + plainLine := stripAnsi(lines[m.lineIndex]) + t.Logf("match %d: line %d (%q), col %d-%d", i, m.lineIndex, plainLine, m.startCol, m.endCol) + } + } +} diff --git a/ui/stash.go b/ui/stash.go index c3739b2..fa34955 100644 --- a/ui/stash.go +++ b/ui/stash.go @@ -13,7 +13,6 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/log" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" "github.com/sahilm/fuzzy" @@ -857,7 +856,6 @@ func loadLocalMarkdown(md *markdown) tea.Cmd { data, err := os.ReadFile(md.localPath) if err != nil { - log.Debug("error reading local file", "error", err) return errMsg{err} } md.Body = string(data) diff --git a/ui/ui.go b/ui/ui.go index 3537d3f..b451937 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -31,14 +31,6 @@ var ( // NewProgram returns a new Tea program. func NewProgram(cfg Config, content string) *tea.Program { - log.Debug( - "Starting glow", - "high_perf_pager", - cfg.HighPerformancePager, - "glamour", - cfg.GlamourEnabled, - ) - config = cfg opts := []tea.ProgramOption{tea.WithAltScreen()} if cfg.EnableMouse { @@ -216,6 +208,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc": + // If pager has active search or is in search mode, let pager handle Esc first + if m.state == stateShowDocument && (m.pager.searchActive || m.pager.state == pagerStateSearch) { + // Let the pager handle it - will be processed below + break + } if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument { batch := m.unloadDocument() return m, tea.Batch(batch...) @@ -378,8 +375,6 @@ func findLocalFiles(m commonModel) tea.Cmd { return errMsg{err} } - log.Debug("local directory is", "cwd", cwd) - // Switch between FindFiles and FindAllFiles to bypass .gitignore rules var ch chan gitcha.SearchResult if m.cfg.ShowAllFiles { @@ -406,7 +401,6 @@ func findNextLocalFile(m model) tea.Cmd { return foundLocalFileMsg(res) } // We're done - log.Debug("local file search finished") return localFileSearchFinished{} } }