mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-21 07:34:18 +02:00
Merge 267c6c47cc into e5cb757278
This commit is contained in:
commit
231abd5681
9 changed files with 1214 additions and 38 deletions
1
log.go
1
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
|
||||
}
|
||||
|
|
|
|||
1
main.go
1
main.go
|
|
@ -460,7 +460,6 @@ func tryLoadConfigFromDefaultPlaces() {
|
|||
}
|
||||
|
||||
if used := viper.ConfigFileUsed(); used != "" {
|
||||
log.Debug("Using configuration file", "path", viper.ConfigFileUsed())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
205
ui/incremental_search_test.go
Normal file
205
ui/incremental_search_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
460
ui/pager.go
460
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 {
|
||||
|
|
|
|||
168
ui/pager_message_test.go
Normal file
168
ui/pager_message_test.go
Normal 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
291
ui/pager_search_test.go
Normal 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
108
ui/search_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
16
ui/ui.go
16
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{}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue