This commit is contained in:
Giorgos Logiotatidis 2026-08-04 23:39:18 +07:00 committed by GitHub
commit 231abd5681
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1214 additions and 38 deletions

1
log.go
View file

@ -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
}

View file

@ -460,7 +460,6 @@ func tryLoadConfigFromDefaultPlaces() {
}
if used := viper.ConfigFileUsed(); used != "" {
log.Debug("Using configuration file", "path", viper.ConfigFileUsed())
return
}

View file

@ -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")
}
}

View file

@ -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 {

168
ui/pager_message_test.go Normal file
View file

@ -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())
}
}

291
ui/pager_search_test.go Normal file
View file

@ -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)
}
}

108
ui/search_test.go Normal file
View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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{}
}
}