diff --git a/go.mod b/go.mod index 366cfc3..b5bc137 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.5 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.6 github.com/mattn/go-runewidth v0.0.19 github.com/mattn/go-sqlite3 v1.14.47 github.com/muesli/termenv v0.16.0 @@ -15,7 +16,6 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect diff --git a/status/status.go b/status/status.go index 18d9fbe..7d79cc1 100644 --- a/status/status.go +++ b/status/status.go @@ -24,6 +24,7 @@ const ( Archive Key = "archive" Update Key = "update" Info Key = "info" + Search Key = "search" ) type entry struct { diff --git a/tui/compare.go b/tui/compare.go index 012d05f..dd2834e 100644 --- a/tui/compare.go +++ b/tui/compare.go @@ -19,11 +19,14 @@ const ( ) type compareCacheEntry struct { - lines [2][]string - kinds [2][]diffLineKind + docs [2]*renderedDoc } func (m *Model) buildCompareContent() { + // Whatever path this takes, the matches have to be recomputed + // against the documents it leaves behind. + defer m.rescanSearch() + colWidth := (m.width - 1) / 2 // 1 char for │ separator if colWidth < 20 { colWidth = 20 @@ -42,8 +45,7 @@ func (m *Model) buildCompareContent() { if m.compareDiffCache != nil { if entry, ok := m.compareDiffCache[cacheKey]; ok { for i := range m.compare { - m.compare[i].lines = entry.lines[i] - m.compare[i].kinds = entry.kinds[i] + m.compare[i].doc = entry.docs[i] } return } @@ -51,33 +53,32 @@ func (m *Model) buildCompareContent() { if !have[0] || !have[1] { // Fall back to non-diff rendering when content is missing. - var parts [2][3][]string + var parts [2][3]*renderedDoc for i := range m.compare { if have[i] { - h, b, d := FormatMboxParts(parsed[i], colWidth, collapse) - parts[i][0] = splitLines(h) - parts[i][1] = splitLines(b) - parts[i][2] = splitLines(d) - } else { - parts[i][0] = []string{"(no content)"} + h, b, d := formatMboxDoc(parsed[i], colWidth, collapse) + parts[i] = [3]*renderedDoc{h, b, d} + continue } + parts[i] = [3]*renderedDoc{ + docFromLines("(no content)"), {}, {}, + } + } + for s := 0; s < 3; s++ { + padDocsToEqual(parts[0][s], parts[1][s]) } - padToEqual(&parts[0][0], &parts[1][0]) - padToEqual(&parts[0][1], &parts[1][1]) - padToEqual(&parts[0][2], &parts[1][2]) for i := range m.compare { - m.compare[i].lines = concat(parts[i][0], parts[i][1], parts[i][2]) - m.compare[i].kinds = nil + doc := &renderedDoc{} + for _, part := range parts[i] { + doc.appendDoc(part) + } + m.compare[i].doc = doc } return } // Diff each section on raw lines, then style with bg tint. - type section struct { - lines [2][]string - kinds [2][]diffLineKind - } - sections := make([]section, 3) + sections := make([][2]*renderedDoc, 3) // Headers: compare raw values directly. sections[0] = m.diffHeaders(parsed, colWidth, collapse) @@ -85,28 +86,23 @@ func (m *Model) buildCompareContent() { // Body: diff raw lines, then wrap+style. leftBody := splitRawLines(replaceControlChars(parsed[0].Body)) rightBody := splitRawLines(replaceControlChars(parsed[1].Body)) - sections[1] = diffAndStyle(leftBody, rightBody, colWidth, styleBodyLine) + sections[1] = diffAndStyle(leftBody, rightBody, colWidth, appendBodyLineDoc) // Diff section: diff raw lines, then wrap+style. leftDiff := splitRawLines(replaceControlChars(parsed[0].Diff)) rightDiff := splitRawLines(replaceControlChars(parsed[1].Diff)) - sections[2] = diffAndStyle(leftDiff, rightDiff, colWidth, styleDiffLine) + sections[2] = diffAndStyle(leftDiff, rightDiff, colWidth, appendDiffLineDoc) // Pad headers and body for section alignment. - padToEqualWithKinds( - §ions[0].lines[0], §ions[0].kinds[0], - §ions[0].lines[1], §ions[0].kinds[1]) - padToEqualWithKinds( - §ions[1].lines[0], §ions[1].kinds[0], - §ions[1].lines[1], §ions[1].kinds[1]) + padDocsToEqual(sections[0][0], sections[0][1]) + padDocsToEqual(sections[1][0], sections[1][1]) for i := range m.compare { - m.compare[i].lines = nil - m.compare[i].kinds = nil + doc := &renderedDoc{} for _, sec := range sections { - m.compare[i].lines = append(m.compare[i].lines, sec.lines[i]...) - m.compare[i].kinds = append(m.compare[i].kinds, sec.kinds[i]...) + doc.appendDoc(sec[i]) } + m.compare[i].doc = doc } // Cache the result. @@ -114,8 +110,7 @@ func (m *Model) buildCompareContent() { m.compareDiffCache = map[[2]int]*compareCacheEntry{} } m.compareDiffCache[cacheKey] = &compareCacheEntry{ - lines: [2][]string{m.compare[0].lines, m.compare[1].lines}, - kinds: [2][]diffLineKind{m.compare[0].kinds, m.compare[1].kinds}, + docs: [2]*renderedDoc{m.compare[0].doc, m.compare[1].doc}, } } @@ -134,15 +129,8 @@ func compareParsedMbox( // diffHeaders compares header values directly and renders with bg tint. func (m *Model) diffHeaders( parsed [2]ParsedMbox, width int, collapse bool, -) struct { - lines [2][]string - kinds [2][]diffLineKind -} { - type section struct { - lines [2][]string - kinds [2][]diffLineKind - } - labelWidth := 9 +) [2]*renderedDoc { + labelWidth := headerLabelWidth valWidth := width - labelWidth if valWidth < 20 { valWidth = 20 @@ -161,7 +149,7 @@ func (m *Model) diffHeaders( {"Date: ", [2]string{parsed[0].Date, parsed[1].Date}, false}, } - var result section + docs := [2]*renderedDoc{{}, {}} for _, h := range headers { changed := h.vals[0] != h.vals[1] for i := 0; i < 2; i++ { @@ -177,29 +165,17 @@ func (m *Model) diffHeaders( kind = diffAdded } } - var b strings.Builder - writeHeader(&b, h.label, h.vals[i], h.coll, labelWidth, valWidth, kind) - rendered := b.String() - if rendered == "" { + if h.vals[i] == "" { if kind == diffPadding { - result.lines[i] = append(result.lines[i], - comparePaddingMarker()) - result.kinds[i] = append(result.kinds[i], diffPadding) + docs[i].addPaddingMarker() } continue } - hlines := splitLines(rendered) - // Remove trailing empty line from writeHeader's trailing \n. - if len(hlines) > 0 && hlines[len(hlines)-1] == "" { - hlines = hlines[:len(hlines)-1] - } - for _, l := range hlines { - result.lines[i] = append(result.lines[i], l) - result.kinds[i] = append(result.kinds[i], kind) - } + appendHeaderDoc(docs[i], h.label, h.vals[i], h.coll, + labelWidth, valWidth, kind) } } - return result + return docs } // splitRawLines splits text into lines, returning nil for empty text. @@ -270,7 +246,12 @@ func alignDiffLines( ) (alignedL, alignedR []string, kindsL, kindsR []diffLineKind) { if classes == "" { // No diff result — show side by side with no highlighting. - padToEqual(&left, &right) + for len(left) < len(right) { + left = append(left, "") + } + for len(right) < len(left) { + right = append(right, "") + } kindsL = make([]diffLineKind, len(left)) kindsR = make([]diffLineKind, len(right)) return left, right, kindsL, kindsR @@ -343,31 +324,24 @@ func alignDiffLines( return } -type lineStyler func(line string, width int, kind diffLineKind) []string +// lineStyler appends one raw line to a document, wrapped and styled. +type lineStyler func(d *renderedDoc, line string, width int, kind diffLineKind) // diffAndStyle diffs raw lines and produces styled output with -// background tinting for changed lines. The styleFn wraps and -// styles each raw line (e.g. styleBodyLine or styleDiffLine). -// When wrapping produces different line counts on each side, -// the shorter side is padded so both stay vertically aligned. +// background tinting for changed lines. The styleFn wraps and styles +// each raw line (e.g. appendBodyLineDoc or appendDiffLineDoc). +// When wrapping produces different row counts on each side, the +// shorter side is padded so both stay vertically aligned. func diffAndStyle( leftRaw, rightRaw []string, width int, styleFn lineStyler, -) struct { - lines [2][]string - kinds [2][]diffLineKind -} { - type result struct { - lines [2][]string - kinds [2][]diffLineKind - } - var res result +) [2]*renderedDoc { + docs := [2]*renderedDoc{{}, {}} if len(leftRaw) == 0 && len(rightRaw) == 0 { - return res + return docs } - // Leading blank line to match FormatMboxParts. + // Leading blank line to match formatMboxDoc. for i := 0; i < 2; i++ { - res.lines[i] = append(res.lines[i], "") - res.kinds[i] = append(res.kinds[i], diffUnchanged) + docs[i].addBlank() } classes := runCompareDiff(leftRaw, rightRaw) @@ -378,51 +352,25 @@ func diffAndStyle( raws := [2]string{alignedL[idx], alignedR[idx]} ks := [2]diffLineKind{kindsL[idx], kindsR[idx]} - var styled [2][]string + var added [2]int for i := 0; i < 2; i++ { + before := docs[i].Len() if ks[i] == diffPadding { - styled[i] = []string{comparePaddingMarker()} + docs[i].addPaddingMarker() } else { - styled[i] = styleFn(raws[i], width, ks[i]) + styleFn(docs[i], raws[i], width, ks[i]) } + added[i] = docs[i].Len() - before } - // Pad the shorter side so both produce equal rendered - // lines for this raw line pair (wrapping alignment). - for len(styled[0]) < len(styled[1]) { - styled[0] = append(styled[0], "") - } - for len(styled[1]) < len(styled[0]) { - styled[1] = append(styled[1], "") + // Pad the shorter side so both produce equal rendered rows + // for this raw line pair (wrapping alignment). The filler + // keeps the tint of the side it belongs to. + for ; added[0] < added[1]; added[0]++ { + docs[0].addPad(ks[0]) } - - for i := 0; i < 2; i++ { - for _, sl := range styled[i] { - res.lines[i] = append(res.lines[i], sl) - res.kinds[i] = append(res.kinds[i], ks[i]) - } + for ; added[1] < added[0]; added[1]++ { + docs[1].addPad(ks[1]) } } - return res -} - -func padToEqualWithKinds( - linesA *[]string, kindsA *[]diffLineKind, - linesB *[]string, kindsB *[]diffLineKind, -) { - diff := len(*linesA) - len(*linesB) - if diff < 0 { - *linesA = append(*linesA, make([]string, -diff)...) - *kindsA = append(*kindsA, make([]diffLineKind, -diff)...) - } else if diff > 0 { - *linesB = append(*linesB, make([]string, diff)...) - *kindsB = append(*kindsB, make([]diffLineKind, diff)...) - } -} - -func padToEqual(a, b *[]string) { - if len(*a) < len(*b) { - *a = append(*a, make([]string, len(*b)-len(*a))...) - } else if len(*b) < len(*a) { - *b = append(*b, make([]string, len(*a)-len(*b))...) - } + return docs } diff --git a/tui/compare_test.go b/tui/compare_test.go index f3574b7..9e27ea8 100644 --- a/tui/compare_test.go +++ b/tui/compare_test.go @@ -157,3 +157,129 @@ func TestAlignDiffLines_EmptyClasses(t *testing.T) { } } } + +// buildTestCompare renders two patches side by side at the given width. +func buildTestCompare(t *testing.T, left, right ParsedMbox, width int) *Model { + t.Helper() + m := &Model{width: width, height: 40} + for i, p := range [2]ParsedMbox{left, right} { + m.compare[i] = compareSide{ + idx: 0, + patches: []comparePatch{{id: i + 1, position: 1, parsed: p}}, + } + } + m.buildCompareContent() + return m +} + +func TestBuildCompareContent_RowsAligned(t *testing.T) { + skipIfNoDiff(t) + + left := ParsedMbox{ + Subject: "[PATCH v1 1/1] Lorem ipsum dolor sit amet consectetur", + From: "Lorem ", + To: "dev@ipsum.example", + Date: "Mon, 23 Mar 2026 12:00:19 -0400", + Body: "Shared opening line.\n\n" + + "A line only on the left that is long enough to wrap at the " + + "narrower widths under test.\n" + + "Shared closing line.\n", + Diff: "diff --git a/x.c b/x.c\n@@ -1,2 +1,2 @@\n context\n-old\n", + } + right := ParsedMbox{ + Subject: "[PATCH v2 1/1] Lorem ipsum dolor sit amet consectetur", + From: "Lorem ", + Cc: "reviewer@ipsum.example", + Date: "Tue, 24 Mar 2026 09:00:00 -0400", + Body: "Shared opening line.\n\n" + + "Shared closing line.\n", + Diff: "diff --git a/x.c b/x.c\n@@ -1,2 +1,2 @@\n context\n+new\n", + } + + for _, width := range []int{60, 80, 100, 160} { + m := buildTestCompare(t, left, right, width) + l, r := m.compare[0].doc, m.compare[1].doc + + if l.Len() != r.Len() { + t.Errorf("width=%d: columns have %d and %d rows, must match", + width, l.Len(), r.Len()) + continue + } + if l.Len() == 0 { + t.Errorf("width=%d: no rows produced", width) + continue + } + for row := 0; row < l.Len(); row++ { + lk, rk := l.kindAt(row), r.kindAt(row) + // A row can never be an addition on both sides at once; + // that would mean the two columns disagree about what + // changed. + if lk == diffAdded && rk == diffAdded { + t.Errorf("width=%d row %d: added on both sides", width, row) + } + if lk == diffRemoved && rk == diffRemoved { + t.Errorf("width=%d row %d: removed on both sides", width, row) + } + if lk == diffPadding && rk == diffPadding { + t.Errorf("width=%d row %d: padding on both sides", width, row) + } + } + } +} + +func TestDiffAndStyle_WrapPaddingKeepsRowsAligned(t *testing.T) { + skipIfNoDiff(t) + + // The left line wraps to several rows at this width, the right + // one to a single row. Both columns must still gain the same + // number of rows so that later content stays level. + long := "replaced with a considerably longer line that has to wrap " + + "more than once at this width" + docs := diffAndStyle( + []string{"shared", long, "shared tail"}, + []string{"shared", "short", "shared tail"}, + 30, appendBodyLineDoc, + ) + + if docs[0].Len() != docs[1].Len() { + t.Fatalf("columns have %d and %d rows, must match", + docs[0].Len(), docs[1].Len()) + } + if docs[0].Len() < 5 { + t.Fatalf("expected the long line to wrap, got %d rows total", + docs[0].Len()) + } + + // The filler rows carry no source text. + fillers := 0 + for row := 0; row < docs[1].Len(); row++ { + if docs[1].visual[row].logical < 0 { + fillers++ + } + } + if fillers == 0 { + t.Error("expected the short side to be padded with filler rows") + } +} + +func TestBuildCompareContent_MissingContentStillAligns(t *testing.T) { + m := &Model{width: 80, height: 40} + m.compare[0] = compareSide{ + idx: 0, + patches: []comparePatch{{id: 1, position: 1, parsed: ParsedMbox{ + Subject: "Lorem ipsum", From: "a@b.example", Body: "Body.\n", + }}}, + } + // The right side has nothing to show. + m.compare[1] = compareSide{idx: 0} + m.buildCompareContent() + + if m.compare[0].doc.Len() != m.compare[1].doc.Len() { + t.Errorf("columns have %d and %d rows, must match", + m.compare[0].doc.Len(), m.compare[1].doc.Len()) + } + if m.compare[1].doc.Line(0) != "(no content)" { + t.Errorf("right column starts with %q, want %q", + m.compare[1].doc.Line(0), "(no content)") + } +} diff --git a/tui/doc.go b/tui/doc.go new file mode 100644 index 0000000..7c1cc07 --- /dev/null +++ b/tui/doc.go @@ -0,0 +1,227 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// styledSpan is a run of plain text rendered with a single style. +// +// Synthetic spans are chrome the formatter invented: wrap arrows, +// header indents, column padding. They occupy screen columns but do +// not correspond to anything in the source, so a search never matches +// inside them. +type styledSpan struct { + text string + style lipgloss.Style + synthetic bool +} + +func plainSpan(text string, style lipgloss.Style) styledSpan { + return styledSpan{text: text, style: style} +} + +func chromeSpan(text string, style lipgloss.Style) styledSpan { + return styledSpan{text: text, style: style, synthetic: true} +} + +// visualLine is one terminal row. +type visualLine struct { + spans []styledSpan + // logical is the index of the source line this row displays, or + // -1 for a row that is pure chrome (a blank separator, alignment + // padding in the compare view). + logical int + // off is the rune offset within that source line at which this + // row's first non-synthetic span begins; -1 when there is none. + off int + // kind tints the row in the compare view; diffUnchanged elsewhere. + kind diffLineKind +} + +// renderedDoc is what a formatter produces: the logical text, meaning +// the lines as they read before wrapping and without any styling, plus +// the visual rows they were laid out into. +// +// Keeping both means a search can run against the logical text, where +// positions are stable across resizes and a match is never split by a +// wrap, while the renderer still knows which row to light up. +type renderedDoc struct { + logical []string + visual []visualLine + // first[i] is the index in visual of the first row showing + // logical line i, or -1 if the line produced no rows. + first []int +} + +// addLogical records a source line and returns its index. The rows +// that display it are added afterwards with addRow. +func (d *renderedDoc) addLogical(text string) int { + d.logical = append(d.logical, text) + d.first = append(d.first, -1) + return len(d.logical) - 1 +} + +// addRow appends a row showing the given source line from rune offset +// off onwards. +func (d *renderedDoc) addRow(logical, off int, spans ...styledSpan) { + if logical >= 0 && logical < len(d.first) && d.first[logical] < 0 { + d.first[logical] = len(d.visual) + } + d.visual = append(d.visual, visualLine{ + spans: spans, logical: logical, off: off, + }) +} + +// addChrome appends a row that shows no source text. +func (d *renderedDoc) addChrome(spans ...styledSpan) { + d.addRow(-1, -1, spans...) +} + +// addBlank appends an empty row. +func (d *renderedDoc) addBlank() { + d.addChrome() +} + +// addPad appends an empty row tinted as the given kind, used by the +// compare view to keep the two columns aligned. +func (d *renderedDoc) addPad(kind diffLineKind) { + d.addChrome() + d.visual[len(d.visual)-1].kind = kind +} + +// kindAt is the compare-view tint of a row. +func (d *renderedDoc) kindAt(row int) diffLineKind { + if d == nil || row < 0 || row >= len(d.visual) { + return diffUnchanged + } + return d.visual[row].kind +} + +// padToLen appends blank rows until the document is n rows long. +func (d *renderedDoc) padToLen(n int) { + for d.Len() < n { + d.addBlank() + } +} + +// padDocsToEqual pads the shorter of two documents so that both have +// the same number of rows. +func padDocsToEqual(a, b *renderedDoc) { + n := max(a.Len(), b.Len()) + a.padToLen(n) + b.padToLen(n) +} + +// addWrapped appends the rows of an already-wrapped source line, which +// must have been added with addLogical. style is applied to the text of +// every segment and preStyle to the continuation arrow in front of it. +func (d *renderedDoc) addWrapped( + logical int, segs []wrapSeg, style, preStyle lipgloss.Style, + kind diffLineKind, +) { + for _, seg := range segs { + var spans []styledSpan + if seg.pre != "" { + spans = append(spans, chromeSpan(seg.pre, preStyle)) + } + spans = append(spans, plainSpan(seg.text, style)) + d.addRow(logical, seg.off, spans...) + d.visual[len(d.visual)-1].kind = kind + } +} + +// appendDoc appends another document, renumbering its indices. +func (d *renderedDoc) appendDoc(o *renderedDoc) { + if o == nil { + return + } + logicalBase := len(d.logical) + visualBase := len(d.visual) + + d.logical = append(d.logical, o.logical...) + for _, f := range o.first { + if f < 0 { + d.first = append(d.first, -1) + continue + } + d.first = append(d.first, f+visualBase) + } + for _, v := range o.visual { + if v.logical >= 0 { + v.logical += logicalBase + } + d.visual = append(d.visual, v) + } +} + +// Len is the number of rows the document occupies on screen. +func (d *renderedDoc) Len() int { + if d == nil { + return 0 + } + return len(d.visual) +} + +// Line renders one row, without any search highlighting. +func (d *renderedDoc) Line(row int) string { + if d == nil || row < 0 || row >= len(d.visual) { + return "" + } + var b strings.Builder + for _, sp := range d.visual[row].spans { + b.WriteString(sp.style.Render(sp.text)) + } + return b.String() +} + +// String renders every row. Rows are newline-terminated rather than +// newline-separated, so that appending one document to another is the +// same as concatenating their strings. +func (d *renderedDoc) String() string { + if d == nil { + return "" + } + var b strings.Builder + for row := range d.visual { + b.WriteString(d.Line(row)) + b.WriteByte('\n') + } + return b.String() +} + +// rowOf returns the row displaying rune offset off of the given source +// line, or -1 if the line is not in the document. An offset past the +// end of the line resolves to its last row. +func (d *renderedDoc) rowOf(logical, off int) int { + if d == nil || logical < 0 || logical >= len(d.first) { + return -1 + } + row := d.first[logical] + if row < 0 { + return -1 + } + best := row + for i := row; i < len(d.visual) && d.visual[i].logical == logical; i++ { + if d.visual[i].off > off { + break + } + best = i + } + return best +} + +// docFromLines builds a document of unstyled lines, for placeholders +// such as "Fetching..." that are not worth running a formatter over. +func docFromLines(lines ...string) *renderedDoc { + d := &renderedDoc{} + for _, line := range lines { + i := d.addLogical(line) + d.addRow(i, 0, plainSpan(line, lipgloss.Style{})) + } + return d +} diff --git a/tui/doc_test.go b/tui/doc_test.go new file mode 100644 index 0000000..19f8383 --- /dev/null +++ b/tui/doc_test.go @@ -0,0 +1,191 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" +) + +func TestDocFromLinesIsUnstyled(t *testing.T) { + d := docFromLines("Fetching...", "second") + + if d.Len() != 2 { + t.Fatalf("Len() = %d, want 2", d.Len()) + } + if got := d.Line(0); got != "Fetching..." { + t.Errorf("Line(0) = %q, want %q", got, "Fetching...") + } + if got := d.String(); got != "Fetching...\nsecond\n" { + t.Errorf("String() = %q", got) + } + // A row out of range is empty rather than a panic. + if got := d.Line(7); got != "" { + t.Errorf("Line(7) = %q, want empty", got) + } +} + +func TestDocStringIsNewlineTerminated(t *testing.T) { + // Newline-terminated rows are what make appendDoc the same as + // string concatenation; newline-separated rows would not be. + a := docFromLines("one", "two") + b := docFromLines("three") + + want := a.String() + b.String() + a.appendDoc(b) + if got := a.String(); got != want { + t.Errorf("appendDoc gave %q, concatenation gave %q", got, want) + } + if got := a.String(); got != "one\ntwo\nthree\n" { + t.Errorf("String() = %q", got) + } +} + +func TestDocAppendRenumbersIndices(t *testing.T) { + a := docFromLines("a0", "a1") + b := docFromLines("b0", "b1") + a.appendDoc(b) + + if len(a.logical) != 4 { + t.Fatalf("logical count = %d, want 4", len(a.logical)) + } + for row, want := range []string{"a0", "a1", "b0", "b1"} { + if a.logical[a.visual[row].logical] != want { + t.Errorf("row %d points at %q, want %q", + row, a.logical[a.visual[row].logical], want) + } + if a.first[row] != row { + t.Errorf("first[%d] = %d, want %d", row, a.first[row], row) + } + } + // The appended document is untouched by the renumbering. + if b.visual[0].logical != 0 { + t.Errorf("source document was mutated: logical = %d", + b.visual[0].logical) + } +} + +// wrappedDoc lays one source line out at the given width, the way the +// formatters do. +func wrappedDoc(line string, width int) *renderedDoc { + d := &renderedDoc{} + i := d.addLogical(line) + d.addWrapped(i, wrapLineSegs(line, width), + plainTextStyle, wrapIndicatorStyle, diffUnchanged) + return d +} + +func TestDocRowOfWrappedLine(t *testing.T) { + line := "lorem ipsum dolor sit amet consectetur adipiscing elit sed" + d := wrappedDoc(line, 20) + + if d.Len() < 3 { + t.Fatalf("expected the line to wrap to 3+ rows, got %d", d.Len()) + } + if d.first[0] != 0 { + t.Errorf("first[0] = %d, want 0", d.first[0]) + } + + // Every offset in the line must resolve to the row that shows it. + for off := 0; off < len([]rune(line)); off++ { + row := d.rowOf(0, off) + if row < 0 { + t.Fatalf("offset %d resolved to no row", off) + } + v := d.visual[row] + if off < v.off || off >= v.off+v.runeLen() { + t.Errorf("offset %d landed on row %d spanning [%d,%d)", + off, row, v.off, v.off+v.runeLen()) + } + } + + // Offsets past the end clamp to the last row. + if got, want := d.rowOf(0, 1<<20), d.Len()-1; got != want { + t.Errorf("rowOf past end = %d, want %d", got, want) + } + if got := d.rowOf(3, 0); got != -1 { + t.Errorf("rowOf for an unknown line = %d, want -1", got) + } +} + +func TestDocWrappedRowsCoverTheSourceLine(t *testing.T) { + for _, line := range wrapTestCorpus() { + for _, width := range []int{20, 40, 80} { + d := wrappedDoc(line, width) + + // Concatenating the non-synthetic spans of every row + // must reproduce the source line exactly. + var b strings.Builder + for _, v := range d.visual { + for _, sp := range v.spans { + if !sp.synthetic { + b.WriteString(sp.text) + } + } + } + if got := b.String(); got != line { + t.Errorf("width=%d: rows spell %q, source is %q", + width, got, line) + } + } + } +} + +func TestDocLineConcatenatesSpans(t *testing.T) { + d := &renderedDoc{} + i := d.addLogical("hello world") + d.addRow(i, 0, + chromeSpan("> ", lipgloss.Style{}), + plainSpan("hello world", lipgloss.Style{}), + ) + + if got := d.Line(0); got != "> hello world" { + t.Errorf("Line(0) = %q", got) + } + if got := d.visual[0].runeLen(); got != len("hello world") { + t.Errorf("runeLen() = %d, want %d", got, len("hello world")) + } +} + +func TestDocChromeRowsHaveNoSource(t *testing.T) { + d := &renderedDoc{} + d.addBlank() + i := d.addLogical("text") + d.addRow(i, 0, plainSpan("text", lipgloss.Style{})) + d.addBlank() + + if d.Len() != 3 { + t.Fatalf("Len() = %d, want 3", d.Len()) + } + for _, row := range []int{0, 2} { + if d.visual[row].logical != -1 { + t.Errorf("row %d logical = %d, want -1", + row, d.visual[row].logical) + } + if d.Line(row) != "" { + t.Errorf("row %d = %q, want empty", row, d.Line(row)) + } + } + if d.first[0] != 1 { + t.Errorf("first[0] = %d, want 1", d.first[0]) + } + if got := d.String(); got != "\ntext\n\n" { + t.Errorf("String() = %q", got) + } +} + +// runeLen is the number of source runes a row displays. Only the +// tests need it; the renderer walks the spans directly. +func (v visualLine) runeLen() int { + n := 0 + for _, sp := range v.spans { + if !sp.synthetic { + n += utf8.RuneCountInString(sp.text) + } + } + return n +} diff --git a/tui/keys.go b/tui/keys.go index ee56b12..d5d72a2 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "unicode" tea "github.com/charmbracelet/bubbletea" @@ -23,7 +24,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.applyState != applyIdle { return m.handleApplyKey(msg) } - if key == "`" && !m.filterEditing { + if key == "`" && !m.filterEditing && !m.searchInput { m.logConsole = !m.logConsole m.logFocused = false if m.logConsole && m.LogBuf != nil { @@ -34,7 +35,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.invalidateAllCaches() return m, nil } - if m.logConsole && key == "tab" { + if m.logConsole && key == "tab" && !m.searchInput { m.logFocused = !m.logFocused return m, nil } @@ -392,16 +393,175 @@ func (m *Model) handleSelectorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleSearchInputKey handles keys typed at the "/" prompt, before the +// query has been committed. Every key goes to the prompt; none of them +// reach the bindings of the view underneath. +func (m *Model) handleSearchInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Dispatch on the key type rather than on its printed name. + // Bubbletea reports every rune it can read at once as a single + // KeyRunes message, so typing "up" quickly arrives as one message + // whose name is "up"; matching on names would take that for the + // arrow key and recall a query instead of inserting the text. + // Alt is how a terminal sends the readline word motions this + // prompt does not implement, so it is ignored -- except that + // urxvt reports ctrl+arrow with Alt set, and those do mean + // something here. + if msg.Alt && msg.Type != tea.KeyCtrlLeft && msg.Type != tea.KeyCtrlRight { + return m, nil + } + switch msg.Type { + case tea.KeyEnter: + m.commitSearch() + case tea.KeyEsc: + m.cancelSearch() + case tea.KeyUp: + if len(m.searchHistory) > 0 { + if m.searchHistoryIdx == -1 { + // Leaving the query being typed: keep it so that + // browsing back down restores it. + m.searchDraft = m.searchText + } + if m.searchHistoryIdx < len(m.searchHistory)-1 { + m.searchHistoryIdx++ + } + m.setSearchQuery(m.searchHistory[m.searchHistoryIdx]) + } + case tea.KeyDown: + if m.searchHistoryIdx > 0 { + m.searchHistoryIdx-- + m.setSearchQuery(m.searchHistory[m.searchHistoryIdx]) + } else if m.searchHistoryIdx == 0 { + m.searchHistoryIdx = -1 + m.setSearchQuery(m.searchDraft) + } + + // Moving the cursor leaves the query alone, so none of these + // re-run the search: updateSearchMatches would drop back to the + // first match and scroll the body out from under the reader. + case tea.KeyLeft: + m.moveSearchCursor(m.searchCursor - 1) + case tea.KeyRight: + m.moveSearchCursor(m.searchCursor + 1) + case tea.KeyCtrlLeft: + m.moveSearchCursor(wordStart(m.searchText, m.searchCursor)) + case tea.KeyCtrlRight: + m.moveSearchCursor(wordEnd(m.searchText, m.searchCursor)) + case tea.KeyHome, tea.KeyCtrlA: + m.moveSearchCursor(0) + case tea.KeyEnd, tea.KeyCtrlE: + m.moveSearchCursor(runeLen(m.searchText)) + + case tea.KeyBackspace: + // Backspace leaves the prompt only when there is nothing left + // to delete. At the left edge of a query that still has text + // it does nothing: the cursor only gets there by deliberate + // motion, and throwing the query away would be a surprise. + if m.searchText == "" { + m.cancelSearch() + break + } + m.editSearchText(deleteRange(m.searchText, + m.searchCursor-1, m.searchCursor)) + case tea.KeyDelete: + m.editSearchText(deleteRange(m.searchText, + m.searchCursor, m.searchCursor+1)) + case tea.KeyCtrlW: + m.editSearchText(deleteRange(m.searchText, + wordStart(m.searchText, m.searchCursor), m.searchCursor)) + case tea.KeyCtrlU: + m.editSearchText(deleteRange(m.searchText, 0, m.searchCursor)) + case tea.KeyCtrlK: + m.editSearchText(deleteRange(m.searchText, + m.searchCursor, runeLen(m.searchText))) + case tea.KeyRunes, tea.KeySpace: + // Typed characters and pasted text both arrive as runes; a + // paste just carries more than one of them. Anything that is + // not printable (control characters, newlines in a multi-line + // paste) has no place in a single-line query. + var b strings.Builder + for _, r := range msg.Runes { + if unicode.IsPrint(r) { + b.WriteRune(r) + } + } + if b.Len() == 0 { + break + } + m.editSearchText(insertAt(m.searchText, m.searchCursor, b.String())) + } + return m, nil +} + +// handleSearchNavKey handles the keys that open the search prompt and +// step through the matches of a committed query. It reports whether the +// key was consumed; a key that would do nothing is left for the view to +// bind to something else. +func (m *Model) handleSearchNavKey(key string) bool { + switch key { + case "/": + m.startSearch() + return true + case "n", "N": + switch { + case len(m.searchOrder) > 0: + if key == "n" { + m.nextSearchMatch() + } else { + m.prevSearchMatch() + } + case m.searchText != "": + // A search that matched nothing still owns n and N. + case len(m.searchHistory) > 0: + m.recallLastSearch(key == "N") + default: + return false + } + return true + } + return false +} + +// exitPatchView returns to the patch list, dropping everything that +// only describes the item being viewed. +func (m *Model) exitPatchView() { + m.clearSearch() + m.viewMode = viewTable + m.viewingPatchID = 0 + m.viewingCoverID = 0 + m.viewComments = nil + m.viewCommentIdx = -1 + m.setViewportDoc(nil) + m.viewExpanded = false +} + +// exitCompareView returns to the patch list and unmarks both sides. +func (m *Model) exitCompareView() { + m.clearSearch() + m.viewMode = viewTable + m.compareCount = 0 +} + func (m *Model) handleViewportKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc": - m.viewMode = viewTable - m.viewingPatchID = 0 - m.viewingCoverID = 0 - m.viewComments = nil - m.viewCommentIdx = -1 - m.viewportLines = nil - m.viewExpanded = false + key := msg.String() + + if m.searchInput { + return m.handleSearchInputKey(msg) + } + if m.handleSearchNavKey(key) { + return m, nil + } + + switch key { + case "q": + // First press of q drops a search that is showing matches; + // only the second one leaves the view. esc always leaves. + if m.searchText != "" && len(m.searchOrder) > 0 { + m.clearSearch() + return m, nil + } + m.exitPatchView() + case "esc": + m.exitPatchView() case "e": m.viewExpanded = !m.viewExpanded m.switchToComment() @@ -655,7 +815,7 @@ func (m *Model) viewportScroll(delta int) { if m.viewportOffset < 0 { m.viewportOffset = 0 } - maxOffset := len(m.viewportLines) - m.viewportVisibleLines() + maxOffset := m.viewportDoc.Len() - m.viewportVisibleLines() if maxOffset < 0 { maxOffset = 0 } @@ -665,7 +825,7 @@ func (m *Model) viewportScroll(delta int) { } func (m *Model) viewportScrollToEnd() { - m.viewportOffset = len(m.viewportLines) - m.viewportVisibleLines() + m.viewportOffset = m.viewportDoc.Len() - m.viewportVisibleLines() if m.viewportOffset < 0 { m.viewportOffset = 0 } @@ -674,12 +834,15 @@ func (m *Model) viewportScrollToEnd() { func (m *Model) buildViewportContent( parsed ParsedMbox, checks []CheckInfo, ) { - formatted := FormatMbox(parsed, m.width, !m.viewExpanded) - checksSection := FormatChecks(checks, m.width, !m.viewExpanded) - if checksSection != "" { - formatted = checksSection + "\n" + formatted - } - m.viewportLines = splitLines(formatted) + doc := formatChecksDoc(checks, m.width, !m.viewExpanded) + if doc.Len() > 0 { + doc.addBlank() + } + headers, body, diff := formatMboxDoc(parsed, m.width, !m.viewExpanded) + doc.appendDoc(headers) + doc.appendDoc(body) + doc.appendDoc(diff) + m.setViewportDoc(doc) } func (m *Model) nextComment() { @@ -734,9 +897,8 @@ func (m *Model) switchToComment() { } if m.viewCommentIdx < len(m.viewComments) { comment := m.viewComments[m.viewCommentIdx] - formatted := FormatComment( - comment, m.width, !m.viewExpanded, m.viewSourceLines) - m.viewportLines = splitLines(formatted) + m.setViewportDoc(formatCommentDoc( + comment, m.width, !m.viewExpanded, m.viewSourceLines)) } } @@ -760,7 +922,7 @@ func (m *Model) openSeriesView(item visibleItem) tea.Cmd { m.viewportOffset = 0 m.viewSourceLines = nil m.viewportLoading = true - m.viewportLines = []string{"Fetching series details..."} + m.setViewportDoc(docFromLines("Fetching series details...")) m.FetchSeriesDetail(seriesID) return nil } @@ -790,7 +952,7 @@ func (m *Model) openSeriesView(item visibleItem) tea.Cmd { m.buildViewportContent(parsed, nil) } else { m.viewportLoading = true - m.viewportLines = []string{"Fetching..."} + m.setViewportDoc(docFromLines("Fetching...")) if m.FetchCoverDetail != nil { m.FetchCoverDetail(cover.ID) } @@ -867,7 +1029,7 @@ func (m *Model) openPatchView(item visibleItem) tea.Cmd { log.Printf("TUI: detail not fetched for patch %d %q", patchID, row.Name) m.viewportLoading = true - m.viewportLines = []string{"Fetching..."} + m.setViewportDoc(docFromLines("Fetching...")) if m.FetchPatchDetail != nil { m.FetchPatchDetail(patchID) } @@ -983,10 +1145,25 @@ func (m *Model) compareMinIdx() int { func (m *Model) handleCompareKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() + if m.searchInput { + return m.handleSearchInputKey(msg) + } + if m.handleSearchNavKey(key) { + return m, nil + } + switch key { - case "q", "esc": - m.viewMode = viewTable - m.compareCount = 0 + case "q": + // First press of q drops a search that is showing matches; + // only the second one leaves the view. esc always leaves. + if m.searchText != "" && len(m.searchOrder) > 0 { + m.clearSearch() + return m, nil + } + m.exitCompareView() + return m, nil + case "esc": + m.exitCompareView() return m, nil case "up", "k": @@ -1059,7 +1236,7 @@ func (m *Model) clampCompareIdx(idx int, patches []comparePatch, cover *ParsedMb } func (m *Model) compareMaxLines() int { - return max(len(m.compare[0].lines), len(m.compare[1].lines)) + return max(m.compare[0].doc.Len(), m.compare[1].doc.Len()) } func (m *Model) compareScroll(delta int) { diff --git a/tui/mbox.go b/tui/mbox.go index 4c73964..40dfd4a 100644 --- a/tui/mbox.go +++ b/tui/mbox.go @@ -232,55 +232,141 @@ func extractHeader(headers, name string) string { return strings.Join(result, " ") } -// writeHeader renders a labeled header value, optionally collapsing +// headerLabelWidth is the width of a header label ("Subject: "). +const headerLabelWidth = 9 + +// appendHeaderDoc appends a labeled header value, optionally collapsing // long recipient lists to collapseHeaderMax lines with a count marker. -func writeHeader( - b *strings.Builder, label, value string, collapse bool, - labelWidth, valWidth int, kind ...diffLineKind, +// +// The label is part of the logical line, so that searching for +// "Subject: foo" matches the way the header reads on screen. The indent +// that lines up continuation rows underneath it is chrome. +func appendHeaderDoc( + d *renderedDoc, label, value string, collapse bool, + labelWidth, valWidth int, kind diffLineKind, ) { if value == "" { return } - k := diffUnchanged - if len(kind) > 0 { - k = kind[0] - } - labelStyle := withDiffBg(mboxHeaderLabel, k) - valueStyle := withDiffBg(mboxHeaderValue, k) - markerStyle := withDiffBg(collapseMarkerStyle, k) - - b.WriteString(labelStyle.Render(label)) - lines := wrapHeaderValue(value, valWidth) - if collapse && len(lines) > collapseHeaderMax { - for i := 0; i < collapseHeaderMax; i++ { - if i > 0 { - b.WriteString(strings.Repeat(" ", labelWidth)) - } - b.WriteString(valueStyle.Render(lines[i])) - b.WriteByte('\n') + labelStyle := withDiffBg(mboxHeaderLabel, kind) + valueStyle := withDiffBg(mboxHeaderValue, kind) + markerStyle := withDiffBg(collapseMarkerStyle, kind) + indent := strings.Repeat(" ", labelWidth) + + labelLen := utf8.RuneCountInString(label) + segs := wrapHeaderValueSegs(value, valWidth) + + collapsed := collapse && len(segs) > collapseHeaderMax + shown := value + if collapsed { + segs = segs[:collapseHeaderMax] + // The source line is what ends up on screen, so it stops + // where the last surviving row does. Keeping the whole value + // would leave the hidden tail searchable, and a match in it + // could be stepped to but never seen. + last := segs[len(segs)-1] + shown = string([]rune(value)[:last.off+ + utf8.RuneCountInString(last.text)]) + } + logical := d.addLogical(label + shown) + for i, seg := range segs { + var spans []styledSpan + // The label is source text and leads the first row, so that + // row starts at offset 0. The indent under it is chrome, so + // those rows start where their slice of the value does. + off := labelLen + seg.off + if i == 0 { + spans = append(spans, plainSpan(label, labelStyle)) + off = 0 + } else { + spans = append(spans, chromeSpan(indent, lipgloss.Style{})) } - total := strings.Count(value, ", ") + 1 - marker := fmt.Sprintf("··· %d total (e to expand) ···", total) - b.WriteString(strings.Repeat(" ", labelWidth)) - b.WriteString(markerStyle.Render(marker)) - b.WriteByte('\n') + spans = append(spans, plainSpan(seg.text, valueStyle)) + d.addRow(logical, off, spans...) + d.visual[len(d.visual)-1].kind = kind + } + if !collapsed { return } - for i, line := range lines { - if i > 0 { - b.WriteString(strings.Repeat(" ", labelWidth)) - } - b.WriteString(valueStyle.Render(line)) - b.WriteByte('\n') + + // The marker is text the formatter invented rather than part of + // the header, so it gets a logical line of its own and stays + // searchable. + total := strings.Count(value, ", ") + 1 + marker := fmt.Sprintf("··· %d total (e to expand) ···", total) + mi := d.addLogical(marker) + d.addRow(mi, 0, + chromeSpan(indent, lipgloss.Style{}), + plainSpan(marker, markerStyle), + ) + d.visual[len(d.visual)-1].kind = kind +} + +// appendBodyLineDoc appends one source line of message body, wrapped +// and styled according to how deeply it is quoted. +func appendBodyLineDoc( + d *renderedDoc, line string, width int, kind diffLineKind, +) { + line = expandTabs(line, 8) // widths must be final before wrapping + depth := quoteDepth(line) + + var style lipgloss.Style + switch { + case depth > 0 && depth%2 == 1: + style = quotedLineStyle + case depth > 0: + style = quotedLineEvenStyle + case strings.HasPrefix(line, " ···"): + style = collapseMarkerStyle + default: + style = plainTextStyle + } + style = withDiffBg(style, kind) + + // A quoted line carries its colour across the continuation arrow; + // everything else marks the arrow as chrome. + preStyle := style + if depth == 0 { + preStyle = withDiffBg(wrapIndicatorStyle, kind) + } + + logical := d.addLogical(line) + d.addWrapped(logical, wrapLineSegs(line, width), style, preStyle, kind) +} + +// appendDiffLineDoc appends one source line of a diff section, wrapped +// and styled according to its leading marker. +func appendDiffLineDoc( + d *renderedDoc, line string, width int, kind diffLineKind, +) { + line = expandTabs(line, 8) + var style lipgloss.Style + switch { + case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "): + style = diffHeaderStyle + case strings.HasPrefix(line, "+"): + style = diffAddStyle + case strings.HasPrefix(line, "-"): + style = diffDelStyle + case strings.HasPrefix(line, "@@"): + style = diffHunkStyle + case strings.HasPrefix(line, "diff --git"): + style = diffHeaderStyle + default: + style = plainTextStyle } + style = withDiffBg(style, kind) + + logical := d.addLogical(line) + d.addWrapped(logical, wrapLineSegs(line, width), style, style, kind) } func FormatMbox(p ParsedMbox, width int, collapse bool) string { - headers, body, diff := FormatMboxParts(p, width, collapse) + headers, body, diff := formatMboxDoc(p, width, collapse) var b strings.Builder - b.WriteString(headers) - b.WriteString(body) - b.WriteString(diff) + b.WriteString(headers.String()) + b.WriteString(body.String()) + b.WriteString(diff.String()) return b.String() } @@ -289,74 +375,57 @@ func FormatMbox(p ParsedMbox, width int, collapse bool) string { func FormatMboxParts( p ParsedMbox, width int, collapse bool, ) (headers, body, diff string) { - labelWidth := 9 // "Subject: " length + h, b, d := formatMboxDoc(p, width, collapse) + return h.String(), b.String(), d.String() +} + +// formatMboxDoc lays out the three sections of an mbox. +func formatMboxDoc( + p ParsedMbox, width int, collapse bool, +) (headers, body, diff *renderedDoc) { + labelWidth := headerLabelWidth valWidth := width - labelWidth if valWidth < 20 { valWidth = 20 } - var hb strings.Builder - writeHeader(&hb, "Subject: ", p.Subject, false, labelWidth, valWidth) - writeHeader(&hb, "From: ", p.From, false, labelWidth, valWidth) - writeHeader(&hb, "To: ", p.To, collapse, labelWidth, valWidth) - writeHeader(&hb, "Cc: ", p.Cc, collapse, labelWidth, valWidth) - writeHeader(&hb, "Date: ", p.Date, false, labelWidth, valWidth) - writeHeader(&hb, "URL: ", p.URL, false, labelWidth, valWidth) - writeHeader(&hb, "Series: ", p.SeriesURL, false, labelWidth, valWidth) - headers = hb.String() - + headers = &renderedDoc{} + for _, h := range []struct { + label string + value string + collapse bool + }{ + {"Subject: ", p.Subject, false}, + {"From: ", p.From, false}, + {"To: ", p.To, collapse}, + {"Cc: ", p.Cc, collapse}, + {"Date: ", p.Date, false}, + {"URL: ", p.URL, false}, + {"Series: ", p.SeriesURL, false}, + } { + appendHeaderDoc(headers, h.label, h.value, h.collapse, + labelWidth, valWidth, diffUnchanged) + } + + body = &renderedDoc{} if p.Body != "" { - var bb strings.Builder - bb.WriteByte('\n') - cleaned := replaceControlChars(p.Body) - for _, line := range strings.Split(cleaned, "\n") { - depth := quoteDepth(line) - for _, wl := range wrapLine(line, width) { - writeStyledLine(&bb, wl, depth) - bb.WriteByte('\n') - } + body.addBlank() + for _, line := range splitRawLines(replaceControlChars(p.Body)) { + appendBodyLineDoc(body, line, width, diffUnchanged) } - body = bb.String() } + diff = &renderedDoc{} if p.Diff != "" { - var df strings.Builder - df.WriteByte('\n') - df.WriteString(formatDiff(replaceControlChars(p.Diff), width)) - diff = df.String() + diff.addBlank() + for _, line := range splitRawLines(replaceControlChars(p.Diff)) { + appendDiffLineDoc(diff, line, width, diffUnchanged) + } } return headers, body, diff } -func formatDiff(diff string, width int) string { - var b strings.Builder - for _, line := range strings.Split(diff, "\n") { - line = expandTabs(line, 8) - var style lipgloss.Style - switch { - case strings.HasPrefix(line, "+++ ") || - strings.HasPrefix(line, "--- "): - style = diffHeaderStyle - case strings.HasPrefix(line, "+"): - style = diffAddStyle - case strings.HasPrefix(line, "-"): - style = diffDelStyle - case strings.HasPrefix(line, "@@"): - style = diffHunkStyle - case strings.HasPrefix(line, "diff --git"): - style = diffHeaderStyle - default: - style = plainTextStyle - } - for _, wl := range wrapLine(line, width) { - b.WriteString(style.Render(wl)) - b.WriteByte('\n') - } - } - return b.String() -} - // withDiffBg returns a copy of the style with the appropriate // background color for the given diff classification. func withDiffBg(style lipgloss.Style, kind diffLineKind) lipgloss.Style { @@ -369,105 +438,88 @@ func withDiffBg(style lipgloss.Style, kind diffLineKind) lipgloss.Style { return style } -// comparePaddingMarker returns a styled "~" marker for diff padding -// lines where content exists only on the other side. -func comparePaddingMarker() string { - return withDiffBg(collapseMarkerStyle, diffPadding).Render("~") -} - -// styleBodyLine wraps and styles a single raw body line, applying -// the diff background tint to all wrapped segments. -func styleBodyLine(line string, width int, kind diffLineKind) []string { - var result []string - depth := quoteDepth(line) - for _, wl := range wrapLine(line, width) { - wl = expandTabs(wl, 8) - var styled string - if depth > 0 { - if depth%2 == 1 { - styled = withDiffBg(quotedLineStyle, kind).Render(wl) - } else { - styled = withDiffBg(quotedLineEvenStyle, kind).Render(wl) - } - } else if strings.HasPrefix(wl, " ···") { - styled = withDiffBg(collapseMarkerStyle, kind).Render(wl) - } else if strings.HasPrefix(wl, "↳ ") { - styled = withDiffBg(wrapIndicatorStyle, kind).Render("↳ ") + - withDiffBg(plainTextStyle, kind).Render(wl[len("↳ "):]) - } else { - styled = withDiffBg(plainTextStyle, kind).Render(wl) - } - result = append(result, styled) - } - return result -} - -// styleDiffLine wraps and styles a single raw diff-section line, -// applying the diff background tint to all wrapped segments. -func styleDiffLine(line string, width int, kind diffLineKind) []string { - line = expandTabs(line, 8) - var style lipgloss.Style - switch { - case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "): - style = diffHeaderStyle - case strings.HasPrefix(line, "+"): - style = diffAddStyle - case strings.HasPrefix(line, "-"): - style = diffDelStyle - case strings.HasPrefix(line, "@@"): - style = diffHunkStyle - case strings.HasPrefix(line, "diff --git"): - style = diffHeaderStyle - default: - style = plainTextStyle - } - style = withDiffBg(style, kind) - var result []string - for _, wl := range wrapLine(line, width) { - result = append(result, style.Render(wl)) - } - return result +// addPaddingMarker appends the "~" marker shown on diff padding rows, +// where content exists only on the other side. The marker is chrome, +// not something the search should ever match. +func (d *renderedDoc) addPaddingMarker() { + d.addChrome(chromeSpan("~", withDiffBg(collapseMarkerStyle, diffPadding))) + d.visual[len(d.visual)-1].kind = diffPadding } // wrapHeaderValue wraps a header value to fit within the given width. // Splits on commas first (natural delimiter for email lists), then // falls back to spaces for long segments without commas. Accumulates // chunks until adding the next one would exceed the width. -func wrapHeaderValue(s string, width int) []string { - if len(s) <= width { - return []string{s} - } - // Split on ", " to get natural chunks (email addresses) +// wrapHeaderValueSegs wraps a header value to width, reporting where +// each segment starts in the original. Every segment is a contiguous +// run of the source: the comma a flushed line ends with is the source's +// own separator comma, only the space after it is dropped. +func wrapHeaderValueSegs(s string, width int) []wrapSeg { + if utf8.RuneCountInString(s) <= width { + return []wrapSeg{{text: s}} + } + // Split on ", " to get natural chunks (email addresses). parts := strings.Split(s, ", ") - var lines []string - var current string - for _, part := range parts { - if current == "" { - current = part - } else if len(current)+len(", ")+len(part) <= width-1 { + offs := make([]int, len(parts)) + at := 0 + for i, part := range parts { + offs[i] = at + at += utf8.RuneCountInString(part) + len(", ") + } + + var lines []wrapSeg + current, currentOff, currentLen := "", 0, 0 + for i, part := range parts { + partLen := utf8.RuneCountInString(part) + switch { + case current == "": + current, currentOff, currentLen = part, offs[i], partLen + case currentLen+len(", ")+partLen <= width-1: // -1 reserves space for trailing comma on flush current += ", " + part - } else { - lines = append(lines, current+",") - current = part + currentLen += len(", ") + partLen + default: + // The comma is the separator that precedes this part, + // so the flushed line is still a run of the source. + lines = append(lines, wrapSeg{ + text: current + ",", off: currentOff, + }) + current, currentOff, currentLen = part, offs[i], partLen } } if current != "" { - lines = append(lines, current) + lines = append(lines, wrapSeg{text: current, off: currentOff}) } + // Handle individual lines that are still too long (single - // very long email address or non-comma-separated value) - var result []string + // very long email address or non-comma-separated value). + var result []wrapSeg for _, line := range lines { - for len(line) > width { + runes := []rune(line.text) + off := line.off + for len(runes) > width { cut := width - if idx := strings.LastIndex(line[:width], " "); idx > width/2 { - cut = idx + for i := width - 1; i > width/2; i-- { + if runes[i] == ' ' { + cut = i + break + } + } + result = append(result, wrapSeg{ + text: string(runes[:cut]), off: off, + }) + runes, off = runes[cut:], off+cut + // Drop the whitespace the break landed on. + n := 0 + for n < len(runes) && unicode.IsSpace(runes[n]) { + n++ + } + runes, off = runes[n:], off+n + for len(runes) > 0 && unicode.IsSpace(runes[len(runes)-1]) { + runes = runes[:len(runes)-1] } - result = append(result, line[:cut]) - line = strings.TrimSpace(line[cut:]) } - result = append(result, line) + result = append(result, wrapSeg{text: string(runes), off: off}) } return result } @@ -532,24 +584,6 @@ func expandTabs(s string, tabWidth int) string { return buf.String() } -func writeStyledLine(b *strings.Builder, line string, depth int) { - line = expandTabs(line, 8) // position-aware tabs before lipgloss - if depth > 0 { - if depth%2 == 1 { - b.WriteString(quotedLineStyle.Render(line)) - } else { - b.WriteString(quotedLineEvenStyle.Render(line)) - } - } else if strings.HasPrefix(line, " ···") { - b.WriteString(collapseMarkerStyle.Render(line)) - } else if strings.HasPrefix(line, "↳ ") { - b.WriteString(wrapIndicatorStyle.Render("↳ ")) - b.WriteString(plainTextStyle.Render(line[len("↳ "):])) - } else { - b.WriteString(plainTextStyle.Render(line)) - } -} - const ( collapseMinBlock = 8 // don't collapse quotes shorter than this collapseHead = 3 // show first N lines of a collapsed quote @@ -632,20 +666,42 @@ func collapseTail(block []string, maxLines int, useAnchors bool) []string { return block[len(block)-maxLines:] } -func wrapLine(s string, width int) []string { +// wrapIndicator marks a line that is the continuation of the one above. +const wrapIndicator = "↳ " + +// wrapSeg is one visual segment of a wrapped source line. text is a +// contiguous run of the source line starting at rune offset off, and +// pre is the chrome the wrapper puts in front of it, which is not part +// of the source. Keeping the two apart lets callers map a position in +// the source line onto the segment that displays it. +// +// The one exception is a line too narrow to wrap at all (width < 5), +// which comes back as a single truncated segment. Callers that need +// source positions should treat that text as the source line. +type wrapSeg struct { + text string + pre string + off int +} + +// wrapLineSegs wraps a line to width, reporting where each segment +// starts in the original. +func wrapLineSegs(s string, width int) []wrapSeg { runes := []rune(s) if len(runes) <= width { - return []string{s} + return []wrapSeg{{text: s}} } if width < 5 { - return []string{truncateLine(s, width)} + return []wrapSeg{{text: truncateLine(s, width)}} } - var lines []string - first := true + var segs []wrapSeg + off := 0 for len(runes) > 0 { limit := width - if !first { - limit = width - 2 + pre := "" + if off > 0 { + limit = width - len([]rune(wrapIndicator)) + pre = wrapIndicator } if limit > len(runes) { limit = len(runes) @@ -659,15 +715,13 @@ func wrapLine(s string, width int) []string { } } } - seg := string(runes[:limit]) - if !first { - seg = "↳ " + seg - } - lines = append(lines, seg) + segs = append(segs, wrapSeg{ + text: string(runes[:limit]), pre: pre, off: off, + }) runes = runes[limit:] - first = false + off += limit } - return lines + return segs } func wrapLogLine(s string, width int) []string { @@ -721,11 +775,16 @@ type CheckInfo struct { Description string } -func FormatChecks( +func FormatChecks(checks []CheckInfo, width int, collapse bool) string { + return formatChecksDoc(checks, width, collapse).String() +} + +func formatChecksDoc( checks []CheckInfo, width int, collapse bool, -) string { +) *renderedDoc { + d := &renderedDoc{} if len(checks) == 0 { - return "" + return d } // Sort into groups, alphabetical by context within each. @@ -759,9 +818,12 @@ func FormatChecks( } } - var b strings.Builder - b.WriteString(mboxHeaderLabel.Render("Checks:")) - b.WriteByte('\n') + addLine := func(text string, style lipgloss.Style, pre ...styledSpan) { + i := d.addLogical(text) + d.addRow(i, 0, append(pre, plainSpan(text, style))...) + } + + addLine("Checks:", mboxHeaderLabel) indent := " " writeCheck := func(c CheckInfo) { @@ -778,26 +840,29 @@ func FormatChecks( icon = "!" style = checksWarnStyle } - prefix := fmt.Sprintf(" %s %-*s", icon, maxCtx, c.Context) - b.WriteString(style.Render(prefix)) + addLine(fmt.Sprintf(" %s %-*s", icon, maxCtx, c.Context), style) if c.TargetURL != "" { - b.WriteByte('\n') - url := indent + c.TargetURL - b.WriteString(plainTextStyle.Render( - truncateLine(url, width))) + // The ellipsis a truncated URL ends with is part of + // what is on screen, so it is part of the source line. + addLine(truncateLine(indent+c.TargetURL, width), plainTextStyle) } - b.WriteByte('\n') - if c.Description != "" { - for _, dl := range strings.Split(c.Description, "\n") { - dl = strings.TrimSpace(dl) - if dl == "" { - continue - } - for _, wl := range wrapLine(dl, width-len(indent)) { - b.WriteString( - plainTextStyle.Render(indent + wl)) - b.WriteByte('\n') + if c.Description == "" { + return + } + for _, dl := range strings.Split(c.Description, "\n") { + dl = strings.TrimSpace(dl) + if dl == "" { + continue + } + li := d.addLogical(dl) + for _, seg := range wrapLineSegs(dl, width-len(indent)) { + spans := []styledSpan{chromeSpan(indent, plainTextStyle)} + if seg.pre != "" { + spans = append(spans, + chromeSpan(seg.pre, plainTextStyle)) } + spans = append(spans, plainSpan(seg.text, plainTextStyle)) + d.addRow(li, seg.off, spans...) } } } @@ -810,16 +875,19 @@ func FormatChecks( writeCheck(c) } + addMarker := func(marker string) { + addLine(marker, collapseMarkerStyle, + chromeSpan(" ", lipgloss.Style{})) + } + // Successes — collapse to 3 when there are many. if collapse && len(succs) > collapseHeaderMax { for _, c := range succs[:collapseHeaderMax] { writeCheck(c) } - marker := fmt.Sprintf( + addMarker(fmt.Sprintf( "··· %d successful checks total (e to expand) ···", - len(succs)) - b.WriteString(" " + collapseMarkerStyle.Render(marker)) - b.WriteByte('\n') + len(succs))) } else { for _, c := range succs { writeCheck(c) @@ -828,18 +896,15 @@ func FormatChecks( // Pending — hide entirely when collapsed. if collapse && len(pends) > 0 { - marker := fmt.Sprintf( - "··· %d checks pending (e to expand) ···", - len(pends)) - b.WriteString(" " + collapseMarkerStyle.Render(marker)) - b.WriteByte('\n') + addMarker(fmt.Sprintf( + "··· %d checks pending (e to expand) ···", len(pends))) } else { for _, c := range pends { writeCheck(c) } } - return b.String() + return d } type CommentInfo struct { @@ -1004,19 +1069,31 @@ func rejoinQuoteContinuations(lines []string, sourceLines map[string]bool) []str return result } -func FormatComment(c CommentInfo, width int, collapse bool, sourceLines map[string]bool) string { - var b strings.Builder - labelWidth := 9 +func FormatComment( + c CommentInfo, width int, collapse bool, sourceLines map[string]bool, +) string { + return formatCommentDoc(c, width, collapse, sourceLines).String() +} + +func formatCommentDoc( + c CommentInfo, width int, collapse bool, sourceLines map[string]bool, +) *renderedDoc { + d := &renderedDoc{} + labelWidth := headerLabelWidth valWidth := width - labelWidth if valWidth < 20 { valWidth = 20 } + header := func(label, value string, coll bool) { + appendHeaderDoc(d, label, value, coll, + labelWidth, valWidth, diffUnchanged) + } subject := decodeHeader(compactHeader(extractHeader(c.Headers, "Subject"))) if subject == "" { subject = compactHeader(c.Subject) } - writeHeader(&b, "Subject: ", subject, false, labelWidth, valWidth) + header("Subject: ", subject, false) // Reply-To → API submitter → From. The raw From header is often // mangled by mailing lists ("Name via dev "), so @@ -1029,28 +1106,28 @@ func FormatComment(c CommentInfo, width int, collapse bool, sourceLines map[stri if from == "" { from = decodeHeader(compactHeader(extractHeader(c.Headers, "From"))) } - writeHeader(&b, "From: ", from, false, labelWidth, valWidth) + header("From: ", from, false) to := decodeHeader(compactHeader(extractHeader(c.Headers, "To"))) - writeHeader(&b, "To: ", to, collapse, labelWidth, valWidth) + header("To: ", to, collapse) cc := decodeHeader(compactHeader(extractHeader(c.Headers, "Cc"))) - writeHeader(&b, "Cc: ", cc, collapse, labelWidth, valWidth) + header("Cc: ", cc, collapse) date := compactHeader(extractHeader(c.Headers, "Date")) if date == "" { date = formatDate(c.Date) } - writeHeader(&b, "Date: ", date, false, labelWidth, valWidth) + header("Date: ", date, false) url := c.ListArchiveURL if url == "" { url = c.WebURL } - writeHeader(&b, "URL: ", upgradeHTTP(url), false, labelWidth, valWidth) + header("URL: ", upgradeHTTP(url), false) if c.Content != "" { - b.WriteByte('\n') + d.addBlank() content := replaceControlChars(c.Content) contentLines := strings.Split(content, "\n") contentLines = rejoinQuoteContinuations(contentLines, sourceLines) @@ -1058,13 +1135,9 @@ func FormatComment(c CommentInfo, width int, collapse bool, sourceLines map[stri contentLines = collapseQuotedBlocks(contentLines) } for _, line := range contentLines { - depth := quoteDepth(line) - for _, wl := range wrapLine(line, width) { - writeStyledLine(&b, wl, depth) - b.WriteByte('\n') - } + appendBodyLineDoc(d, line, width, diffUnchanged) } } - return b.String() + return d } diff --git a/tui/mbox_test.go b/tui/mbox_test.go index ffb5aad..8bfb6f2 100644 --- a/tui/mbox_test.go +++ b/tui/mbox_test.go @@ -8,6 +8,7 @@ import ( "reflect" "strings" "testing" + "unicode/utf8" "github.com/charmbracelet/lipgloss" @@ -1006,6 +1007,254 @@ func TestFormatMbox_LineWidths(t *testing.T) { } } +// checkSegsAreRunsOf asserts the wrapSeg invariant: every segment's +// text is a contiguous run of the source line starting at seg.off, and +// the segments advance through the source without going backwards. +// This is what lets a position in the source be mapped onto the +// segment that displays it. +func checkSegsAreRunsOf(t *testing.T, src string, segs []wrapSeg) { + t.Helper() + runes := []rune(src) + prev := -1 + for i, seg := range segs { + n := utf8.RuneCountInString(seg.text) + if seg.off < 0 || seg.off+n > len(runes) { + t.Errorf("src %q: seg %d spans [%d,%d), source has %d runes", + src, i, seg.off, seg.off+n, len(runes)) + continue + } + if got := string(runes[seg.off : seg.off+n]); got != seg.text { + t.Errorf("src %q: seg %d at off %d is %q, source has %q", + src, i, seg.off, seg.text, got) + } + if seg.off <= prev { + t.Errorf("src %q: seg %d offset %d did not advance past %d", + src, i, seg.off, prev) + } + prev = seg.off + } +} + +func wrapTestCorpus() []string { + return []string{ + "", + "short", + "lorem ipsum dolor sit amet consectetur adipiscing elit sed do", + strings.Repeat("averylongtokenwithoutanyspaces", 6), + "Lorem Ipsum , Dolor Sit , " + + "Consectetur Adipiscing ", + strings.Repeat("ünïcödé wörds with áccents ", 8), + "日本語のテキストが折り返される場合のテスト" + strings.Repeat("あ", 40), + "trailing spaces ", + " leading spaces and then a lot more text to force a wrap here", + "a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v", + // Non-ASCII either side of the ", " separators, so that a + // byte-counted offset drifts away from the rune-counted one. + "Ünïcödé Nâme , Åñother Përsön , " + + "Thïrd Üser , Fourth Nämé ", + "日本 太郎 , 山田 花子 , " + + "Suzuki Ichirō ", + "one-very-long-unbreakable-" + strings.Repeat("x", 90) + " tail", + } +} + +func TestWrapLineSegsAreRunsOfSource(t *testing.T) { + for _, src := range wrapTestCorpus() { + for _, width := range []int{5, 7, 13, 20, 40, 80, 120} { + checkSegsAreRunsOf(t, src, wrapLineSegs(src, width)) + } + } +} + +func TestWrapHeaderValueSegsAreRunsOfSource(t *testing.T) { + for _, src := range wrapTestCorpus() { + for _, width := range []int{20, 40, 60, 80, 120} { + checkSegsAreRunsOf(t, src, wrapHeaderValueSegs(src, width)) + } + } +} + +func TestWrapHeaderValueRespectsWidthWithMultiByte(t *testing.T) { + // Counting bytes instead of runes made the cut land mid-rune and + // produced lines far narrower than the available width. + src := strings.Repeat("ünïcödé-wörd ", 20) + for _, width := range []int{20, 40, 80} { + for i, line := range wrapHeaderValue(src, width) { + if n := utf8.RuneCountInString(line); n > width { + t.Errorf("width=%d line %d has %d runes: %q", + width, i, n, line) + } + if !utf8.ValidString(line) { + t.Errorf("width=%d line %d is not valid UTF-8: %q", + width, i, line) + } + } + } +} + +// checkDocRowsSliceLogical asserts that every row of a document shows +// the slice of its source line that it claims to: the non-synthetic +// span text of a row at offset off must equal the source line's runes +// from off onwards. This is what the renderer relies on to put a +// highlight over the right columns. +func checkDocRowsSliceLogical(t *testing.T, what string, d *renderedDoc) { + t.Helper() + for row, v := range d.visual { + var text strings.Builder + for _, sp := range v.spans { + if !sp.synthetic { + text.WriteString(sp.text) + } + } + if v.logical < 0 { + if text.Len() != 0 { + t.Errorf("%s: chrome row %d carries source text %q", + what, row, text.String()) + } + continue + } + if v.logical >= len(d.logical) { + t.Errorf("%s: row %d points at source line %d of %d", + what, row, v.logical, len(d.logical)) + continue + } + runes := []rune(d.logical[v.logical]) + n := utf8.RuneCountInString(text.String()) + if v.off < 0 || v.off+n > len(runes) { + t.Errorf("%s: row %d spans [%d,%d) of a %d-rune line", + what, row, v.off, v.off+n, len(runes)) + continue + } + if got := string(runes[v.off : v.off+n]); got != text.String() { + t.Errorf("%s: row %d at off %d shows %q, source has %q", + what, row, v.off, text.String(), got) + } + } + for i, first := range d.first { + if first < 0 { + continue + } + if d.visual[first].logical != i { + t.Errorf("%s: first[%d] = %d points at source line %d", + what, i, first, d.visual[first].logical) + continue + } + // The rows of a source line have to reach its end. Text the + // line claims but no row displays could be matched by a + // search and then never shown. + last := first + for last+1 < len(d.visual) && d.visual[last+1].logical == i { + last++ + } + v := d.visual[last] + if got, want := v.off+v.runeLen(), + utf8.RuneCountInString(d.logical[i]); got != want { + t.Errorf("%s: source line %d is %d runes but its rows stop "+ + "at %d: %q", what, i, want, got, d.logical[i]) + } + } +} + +func TestFormatMboxDocRowsSliceLogical(t *testing.T) { + var addrs []string + for i := 0; i < 12; i++ { + addrs = append(addrs, fmt.Sprintf( + "Lorem Ipsüm%d ", i, i)) + } + p := ParsedMbox{ + Subject: "[PATCH v2 1/3] Lorem ipsum dolor sit amet consectetur elit", + From: "Lorem Ipsum ", + To: "dev@ipsum.example", + Cc: strings.Join(addrs, ", "), + Date: "Mon, 23 Mar 2026 12:00:19 -0400", + URL: "https://lore.example/a/b/c", + Body: "Hello.\n\nA fairly long body line that has to wrap somewhere.\n" + + "> a quoted line that is also long enough to need wrapping\n" + + "\ttabbed\tline\twith\ttabs and enough text to overflow\n", + Diff: "diff --git a/x.c b/x.c\n@@ -1,3 +1,4 @@\n context\n" + + "-removed line long enough to wrap at the narrow widths\n" + + "+added line long enough to wrap at the narrow widths\n", + } + + for _, width := range []int{40, 60, 80, 120} { + for _, collapse := range []bool{true, false} { + h, b, d := formatMboxDoc(p, width, collapse) + what := fmt.Sprintf("width=%d collapse=%v", width, collapse) + checkDocRowsSliceLogical(t, what+" headers", h) + checkDocRowsSliceLogical(t, what+" body", b) + checkDocRowsSliceLogical(t, what+" diff", d) + + // The documents must flatten to what the string API returns. + if got, want := h.String()+b.String()+d.String(), + FormatMbox(p, width, collapse); got != want { + t.Errorf("%s: document flattens to %q, FormatMbox gives %q", + what, got, want) + } + } + } +} + +func TestFormatCommentDocRowsSliceLogical(t *testing.T) { + c := CommentInfo{ + Subject: "Re: [PATCH v2 1/3] Lorem ipsum", + Date: "2026-03-23T12:00:19", + Content: "Reply text.\n\n" + + strings.Repeat("> quoted line that needs to wrap somewhere\n", 12) + + "\tTabbed\treply\tline with enough text to overflow the width\n", + WebURL: "http://patches.example/comment/99/", + } + + for _, width := range []int{40, 80, 120} { + for _, collapse := range []bool{true, false} { + d := formatCommentDoc(c, width, collapse, nil) + what := fmt.Sprintf("width=%d collapse=%v", width, collapse) + checkDocRowsSliceLogical(t, what, d) + + if got, want := d.String(), + FormatComment(c, width, collapse, nil); got != want { + t.Errorf("%s: document and FormatComment disagree", what) + } + } + } +} + +func TestFormatMbox_BodyTabsWrapWithinWidth(t *testing.T) { + // Tabs grow to the next 8-column stop, so a line has to be + // expanded before it is wrapped. Wrapping the unexpanded line + // undercounts its width and lets the result overflow. + body := strings.Repeat("\tlorem ipsum dolor sit amet ", 6) + + for _, width := range []int{40, 60, 80, 120} { + p := ParsedMbox{ + Subject: "Lorem ipsum", + From: "Lorem ", + Body: body, + } + for i, line := range strings.Split(FormatMbox(p, width, false), "\n") { + if vw := lipgloss.Width(line); vw > width { + t.Errorf("width=%d line %d visual width %d > %d: %q", + width, i, vw, width, line) + } + } + } +} + +func TestFormatComment_ContentTabsWrapWithinWidth(t *testing.T) { + c := CommentInfo{ + Content: strings.Repeat("\tlorem ipsum dolor sit amet ", 6), + } + + for _, width := range []int{40, 60, 80, 120} { + result := FormatComment(c, width, false, nil) + for i, line := range strings.Split(result, "\n") { + if vw := lipgloss.Width(line); vw > width { + t.Errorf("width=%d line %d visual width %d > %d: %q", + width, i, vw, width, line) + } + } + } +} + func TestFormatMbox_CollapseLongCc(t *testing.T) { // Build a Cc with enough addresses to wrap to >3 lines at width 80 var addrs []string @@ -2409,3 +2658,36 @@ func TestFormatChecks_URLAlwaysOnNextLine(t *testing.T) { t.Errorf("URL should be on the next line, got %q", urlLine) } } + +// flattenSegs joins wrapped segments back into display lines. Only the +// tests need this; the formatters build documents from the segments +// instead. +func flattenSegs(segs []wrapSeg) []string { + lines := make([]string, len(segs)) + for i, seg := range segs { + lines[i] = seg.pre + seg.text + } + return lines +} + +func wrapLine(s string, width int) []string { + return flattenSegs(wrapLineSegs(s, width)) +} + +func wrapHeaderValue(s string, width int) []string { + return flattenSegs(wrapHeaderValueSegs(s, width)) +} + +func splitLines(content string) []string { + return strings.Split(content, "\n") +} + +// formatDiff renders a diff section on its own. Only the tests use +// it; the viewport builds the section into a document instead. +func formatDiff(diff string, width int) string { + d := &renderedDoc{} + for _, line := range strings.Split(diff, "\n") { + appendDiffLineDoc(d, line, width, diffUnchanged) + } + return d.String() +} diff --git a/tui/model.go b/tui/model.go index e0692d6..1e0786b 100644 --- a/tui/model.go +++ b/tui/model.go @@ -6,6 +6,7 @@ package tui import ( "fmt" "log" + "regexp" "strconv" "strings" "time" @@ -142,14 +143,22 @@ type compareMark struct { type compareSide struct { mark compareMark - lines []string - kinds []diffLineKind + doc *renderedDoc + matcher searchMatcher patches []comparePatch cover *ParsedMbox idx int // -1 = cover, 0+ = patch index ver string // "v1", "v2", etc. } +// searchMatch is one match in logical coordinates: a rune range +// inside an unwrapped source line of a renderedDoc. +type searchMatch struct { + line int + start int // rune offset, inclusive + end int // rune offset, exclusive +} + func highlightAnimTickCmd() tea.Cmd { return tea.Tick( time.Duration(highlightAnimInterval)*time.Millisecond, @@ -221,7 +230,7 @@ type Model struct { viewComments []CommentInfo viewCommentIdx int // -1 = patch/cover, 0+ = comment viewSourceLines map[string]bool - viewportLines []string + viewportDoc *renderedDoc viewportLoading bool viewportOffset int viewExpanded bool @@ -233,6 +242,24 @@ type Model struct { comparePrefix int // 0=none, 1=left, 2=right (for 1/2+arrow) compareDiffCache map[[2]int]*compareCacheEntry + // Search state, shared by the patch and compare views. + // searchInput is true only while the query is being typed; an + // active search is one with searchRegex != nil, which outlives + // the prompt and keeps the matches highlighted. searchCursor + // only means anything while the prompt is open: commitSearch + // leaves it stale and startSearch zeroes it again. + searchInput bool + searchText string + searchCursor int // rune index into searchText, where inserts land + searchRegex *regexp.Regexp + viewportMatcher searchMatcher + searchOrder []matchRef // every match, in reading order + searchIdx int // index into searchOrder, -1 when none + searchHistory []string + searchHistoryIdx int // -1 = typing new query, 0+ = browsing history + searchDraft string // query being typed, stashed while browsing + searchOrigin int // viewport offset when the prompt opened + logConsole bool logFocused bool LogBuf *LogBuffer @@ -283,6 +310,7 @@ func NewModel(d *db.DB, states []string, token string) *Model { highlightAnimating: true, cachedRenderedRows: map[int]*seriesRowCache{}, } + m.loadSearchHistory() m.reloadData() return m } @@ -696,6 +724,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.invalidateAllCaches() if m.viewMode == viewPatch { m.refreshViewport() + // Rewrapping moved every row, so the match the counter + // points at is no longer where it was. + m.scrollToMatch(m.searchIdx) } else if m.viewMode == viewCompare { m.compareDiffCache = nil m.buildCompareContent() @@ -706,6 +737,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.viewportOffset > maxOffset { m.viewportOffset = maxOffset } + m.scrollToMatch(m.searchIdx) } return m, nil @@ -716,6 +748,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// setViewportDoc replaces the content of the patch view. Going +// through here rather than assigning the field means the search +// cannot be left holding matches from a document that is no longer +// on screen. +func (m *Model) setViewportDoc(d *renderedDoc) { + m.viewportDoc = d + m.rescanSearch() +} + func (m *Model) refreshViewport() { if m.db == nil { return @@ -724,9 +765,8 @@ func (m *Model) refreshViewport() { // If viewing a comment, re-render at the new width if m.viewCommentIdx >= 0 && m.viewCommentIdx < len(m.viewComments) { comment := m.viewComments[m.viewCommentIdx] - formatted := FormatComment( - comment, m.width, !m.viewExpanded, m.viewSourceLines) - m.viewportLines = splitLines(formatted) + m.setViewportDoc(formatCommentDoc( + comment, m.width, !m.viewExpanded, m.viewSourceLines)) return } if m.viewingCoverID != 0 { @@ -796,10 +836,6 @@ func (m *Model) refreshViewportComments() { } } -func splitLines(content string) []string { - return strings.Split(content, "\n") -} - func (m *Model) getVisibleItems() []visibleItem { if m.cachedVisibleItemsValid && m.cachedVisibleItems != nil { return m.cachedVisibleItems diff --git a/tui/model_test.go b/tui/model_test.go index 01d036b..ee21326 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -1520,8 +1520,8 @@ func TestOpenSeriesView_CoverLetter(t *testing.T) { if m.viewingCoverID == 0 { t.Error("viewingCoverID should be set") } - if len(m.viewportLines) == 0 { - t.Error("viewportLines should have content") + if m.viewportDoc.Len() == 0 { + t.Error("viewportDoc should have content") } } @@ -1584,7 +1584,7 @@ func TestCommentNavigation(t *testing.T) { {ID: 2, Submitter: "Dolor", Date: "2026-03-14", Subject: "Re: patch", Content: "Agreed"}, } - m.viewportLines = []string{"patch content"} + m.viewportDoc = docFromLines("patch content") // Right → first comment m = pressSpecialKey(m, tea.KeyRight) @@ -1617,7 +1617,7 @@ func TestCommentNavigation_NoComments(t *testing.T) { m.viewingPatchID = 100 m.viewCommentIdx = -1 m.viewComments = nil - m.viewportLines = []string{"content"} + m.viewportDoc = docFromLines("content") // Right should do nothing m = pressSpecialKey(m, tea.KeyRight) @@ -2347,7 +2347,7 @@ func TestSeriesRowCache_StaleAge(t *testing.T) { func TestRenderPatchView_NoTrailingSpaces(t *testing.T) { m := testModel() - m.viewportLines = []string{ + m.viewportDoc = docFromLines( "Subject: Lorem ipsum dolor sit amet", "From: Lorem ", "", @@ -2356,7 +2356,7 @@ func TestRenderPatchView_NoTrailingSpaces(t *testing.T) { " indented context line", "+ added line", "- removed line", - } + ) output := m.renderPatchView() // The status bar is the last line — check all lines above it @@ -2387,10 +2387,11 @@ func TestRenderPatchView_LineCount(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m.viewportOffset = 0 - m.viewportLines = make([]string, tt.numLines) - for i := range m.viewportLines { - m.viewportLines[i] = fmt.Sprintf("line %d", i) + docLines := make([]string, tt.numLines) + for i := range docLines { + docLines[i] = fmt.Sprintf("line %d", i) } + m.viewportDoc = docFromLines(docLines...) output := m.renderPatchView() lines := strings.Split(output, "\n") @@ -2537,14 +2538,14 @@ func TestComparePrefixRight(t *testing.T) { } func TestCompareAlignSections(t *testing.T) { - a := []string{"line1", "line2"} - b := []string{"line1", "line2", "line3", "line4"} - padToEqual(&a, &b) - if len(a) != len(b) { - t.Errorf("len(a)=%d, len(b)=%d, should be equal", len(a), len(b)) + a := docFromLines("line1", "line2") + b := docFromLines("line1", "line2", "line3", "line4") + padDocsToEqual(a, b) + if a.Len() != b.Len() { + t.Errorf("a.Len()=%d, b.Len()=%d, should be equal", a.Len(), b.Len()) } - if len(a) != 4 { - t.Errorf("len(a)=%d, want 4", len(a)) + if a.Len() != 4 { + t.Errorf("a.Len()=%d, want 4", a.Len()) } } @@ -2766,14 +2767,14 @@ func TestResizePreservesCommentView(t *testing.T) { m.viewCommentIdx = 0 m.switchToComment() - contentBefore := strings.Join(m.viewportLines, "\n") + contentBefore := m.viewportDoc.String() if !strings.Contains(contentBefore, "Ipsum dolor") { t.Fatal("should be showing comment content") } m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - contentAfter := strings.Join(m.viewportLines, "\n") + contentAfter := m.viewportDoc.String() if !strings.Contains(contentAfter, "Ipsum dolor") { t.Error("resize should preserve comment view") } @@ -2798,11 +2799,11 @@ func TestResizePreservesCoverView(t *testing.T) { m.width = 40 // narrow — forces wrapping m.refreshViewport() - linesBefore := len(m.viewportLines) + linesBefore := m.viewportDoc.Len() if linesBefore == 0 { t.Fatal("should have viewport content") } - contentBefore := strings.Join(m.viewportLines, "\n") + contentBefore := m.viewportDoc.String() if !strings.Contains(contentBefore, "Amet consectetur") { t.Fatal("should be showing cover content") } @@ -2810,8 +2811,8 @@ func TestResizePreservesCoverView(t *testing.T) { // Resize wider — content should re-wrap with fewer lines m.Update(tea.WindowSizeMsg{Width: 200, Height: 40}) - linesAfter := len(m.viewportLines) - contentAfter := strings.Join(m.viewportLines, "\n") + linesAfter := m.viewportDoc.Len() + contentAfter := m.viewportDoc.String() if !strings.Contains(contentAfter, "Amet consectetur") { t.Error("resize should preserve cover view") } @@ -2877,10 +2878,10 @@ func TestOpenSeriesView_PlaceholderWhenNoDetail(t *testing.T) { if !m.viewportLoading { t.Error("viewportLoading should be true") } - if len(m.viewportLines) != 1 || - m.viewportLines[0] != "Fetching series details..." { - t.Errorf("viewport = %v, want placeholder", - m.viewportLines) + if m.viewportDoc.Len() != 1 || + m.viewportDoc.Line(0) != "Fetching series details..." { + t.Errorf("viewport = %q, want placeholder", + m.viewportDoc.String()) } if !fetchCalled { t.Error("FetchSeriesDetail should have been called") @@ -2893,7 +2894,7 @@ func TestSyncUpdateMsg_RefreshesLoadingViewport(t *testing.T) { m.viewMode = viewPatch m.viewingCoverID = 50 m.viewportLoading = true - m.viewportLines = []string{"Fetching series details..."} + m.viewportDoc = docFromLines("Fetching series details...") // Save a cover so refreshViewport can load it d.SaveCover(db.CoverRow{ @@ -2908,7 +2909,7 @@ func TestSyncUpdateMsg_RefreshesLoadingViewport(t *testing.T) { if m.viewportLoading { t.Error("viewportLoading should be cleared") } - content := strings.Join(m.viewportLines, "\n") + content := m.viewportDoc.String() if !strings.Contains(content, "Lorem cover content") { t.Error("viewport should show cover content after refresh") } @@ -2919,11 +2920,11 @@ func TestSyncUpdateMsg_NoRefreshWhenNotLoading(t *testing.T) { m.viewMode = viewPatch m.viewingPatchID = 100 m.viewportLoading = false - m.viewportLines = []string{"Existing content"} + m.viewportDoc = docFromLines("Existing content") m.Update(SyncUpdateMsg{SeriesIDs: []int{50}}) - if m.viewportLines[0] != "Existing content" { + if m.viewportDoc.Line(0) != "Existing content" { t.Error("viewport should not be refreshed when not loading") } } @@ -2955,14 +2956,14 @@ func TestCompareView_RefreshesOnSync(t *testing.T) { if m.viewMode != viewCompare { t.Fatal("should be in compare view") } - linesBefore := len(m.compare[0].lines) + linesBefore := m.compare[0].doc.Len() // Simulate fetching detail for a patch d.UpdatePatchDetail(100, "Lorem ipsum dolor sit amet.", "diff --git a/lorem b/lorem", "", "") m.Update(SyncUpdateMsg{SeriesIDs: []int{50}}) - linesAfter := len(m.compare[0].lines) + linesAfter := m.compare[0].doc.Len() if linesAfter <= linesBefore { t.Errorf("compare content should have more lines after detail fetch: %d -> %d", linesBefore, linesAfter) @@ -3032,22 +3033,23 @@ func TestCompareView_DiffKindsPopulated(t *testing.T) { if m.viewMode != viewCompare { t.Fatal("should be in compare view") } - // Both sides should have kinds populated. - if len(m.compare[0].kinds) == 0 { - t.Fatal("left side kinds should be populated") + // Both sides should have rows. + if m.compare[0].doc.Len() == 0 { + t.Fatal("left side should be populated") } - if len(m.compare[1].kinds) == 0 { - t.Fatal("right side kinds should be populated") + if m.compare[1].doc.Len() == 0 { + t.Fatal("right side should be populated") } - if len(m.compare[0].kinds) != len(m.compare[0].lines) { - t.Errorf("left kinds/lines mismatch: %d vs %d", - len(m.compare[0].kinds), len(m.compare[0].lines)) + // The two columns must stay row-aligned. + if m.compare[0].doc.Len() != m.compare[1].doc.Len() { + t.Errorf("column row counts differ: %d vs %d", + m.compare[0].doc.Len(), m.compare[1].doc.Len()) } // There should be at least some non-unchanged lines since // the body and diff sections differ. hasChanged := false - for _, k := range m.compare[0].kinds { - if k != diffUnchanged { + for row := 0; row < m.compare[0].doc.Len(); row++ { + if m.compare[0].doc.kindAt(row) != diffUnchanged { hasChanged = true break } @@ -3099,9 +3101,9 @@ func TestCompareView_DiffCacheHit(t *testing.T) { t.Fatal("cache should be populated after buildCompareContent") } // Rebuild should use cache (same idx pair). - linesBefore := m.compare[0].lines + linesBefore := m.compare[0].doc.Len() m.buildCompareContent() - if len(m.compare[0].lines) != len(linesBefore) { + if m.compare[0].doc.Len() != linesBefore { t.Error("cache rebuild should produce same result") } } @@ -3148,21 +3150,21 @@ func TestCompareView_CacheInvalidatedOnResize(t *testing.T) { t.Fatal("cache should be populated") } - linesBefore := make([]string, len(m.compare[0].lines)) - copy(linesBefore, m.compare[0].lines) + linesBefore := splitLines(m.compare[0].doc.String()) // Resize to a much narrower width — wrapping changes the output. result, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 20}) m = result.(*Model) - if len(m.compare[0].lines) == 0 { - t.Fatal("lines should be populated after resize") + if m.compare[0].doc.Len() == 0 { + t.Fatal("rows should be populated after resize") } // Content should differ: narrower width produces more wrapped lines. - same := len(linesBefore) == len(m.compare[0].lines) + linesAfter := splitLines(m.compare[0].doc.String()) + same := len(linesBefore) == len(linesAfter) if same { for i := range linesBefore { - if linesBefore[i] != m.compare[0].lines[i] { + if linesBefore[i] != linesAfter[i] { same = false break } diff --git a/tui/render.go b/tui/render.go index 75f58dd..448dc55 100644 --- a/tui/render.go +++ b/tui/render.go @@ -58,7 +58,7 @@ func (m *Model) viewportVisibleLines() int { func (m *Model) renderPatchView() string { visible := m.viewportVisibleLines() - total := len(m.viewportLines) + total := m.viewportDoc.Len() start := m.viewportOffset if start > total { @@ -69,10 +69,11 @@ func (m *Model) renderPatchView() string { end = total } - // Assemble exactly `visible` lines separated by newlines. + mt, cur := m.currentMatch(0) lines := make([]string, visible) for i := 0; i < end-start; i++ { - lines[i] = m.viewportLines[start+i] + lineIdx := start + i + lines[i] = renderDocRow(m.viewportDoc, lineIdx, mt, cur) } body := strings.Join(lines, "\n") @@ -85,47 +86,55 @@ func (m *Model) renderPatchView() string { bright, desc, sep := m.helpStyles() var status string - expandKey := func(hb *strings.Builder) { - hb.WriteString(helpSepStr(sep)) - if m.viewExpanded { - hb.WriteString(helpKey(bright, desc, "e", "collapse")) - } else { - hb.WriteString(helpKey(bright, desc, "e", "expand")) - } - } - if len(m.viewComments) > 0 { - var hb strings.Builder - hb.WriteString(sep.Render(" ")) - hb.WriteString(bright.Render("←/→")) - expandKey(&hb) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(bright.Render("↑/↓") + - sep.Render(" ") + bright.Render("pgup/dn")) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(bright.Render("esc")) - if m.logConsole { + + if m.searchInput { + status = m.renderSearchInputHelp(bright, desc, sep) + } else { + expandKey := func(hb *strings.Builder) { hb.WriteString(helpSepStr(sep)) - hb.WriteString(helpKey(bright, desc, "tab", "log")) + if m.viewExpanded { + hb.WriteString(helpKey(bright, desc, "e", "collapse")) + } else { + hb.WriteString(helpKey(bright, desc, "e", "expand")) + } } - hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - helpText := hb.String() - barWidth := m.width - lipgloss.Width(helpText) - commentBar := m.renderCommentBar(barWidth) - status = commentBar + helpText - } else { - var hb strings.Builder - expandKey(&hb) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(bright.Render("↑/↓") + - sep.Render(" ") + bright.Render("pgup/dn")) - hb.WriteString(helpSepStr(sep)) - hb.WriteString(helpKey(bright, desc, "esc", "back")) - if m.logConsole { + + if len(m.viewComments) > 0 { + var hb strings.Builder + hb.WriteString(sep.Render(" ")) + hb.WriteString(bright.Render("←/→")) + expandKey(&hb) + hb.WriteString(m.renderSearchKeyHelp(bright, desc, sep)) hb.WriteString(helpSepStr(sep)) - hb.WriteString(helpKey(bright, desc, "tab", "log")) + hb.WriteString(bright.Render("↑/↓") + + sep.Render(" ") + bright.Render("pgup/dn")) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(bright.Render("esc")) + if m.logConsole { + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "tab", "log")) + } + hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) + helpText := hb.String() + barWidth := m.width - lipgloss.Width(helpText) + commentBar := m.renderCommentBar(barWidth) + status = commentBar + helpText + } else { + var hb strings.Builder + expandKey(&hb) + hb.WriteString(m.renderSearchKeyHelp(bright, desc, sep)) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(bright.Render("↑/↓") + + sep.Render(" ") + bright.Render("pgup/dn")) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "esc", "back")) + if m.logConsole { + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "tab", "log")) + } + hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) + status = hb.String() } - hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - status = hb.String() } var statusLine string @@ -157,21 +166,20 @@ func (m *Model) renderCompareView() string { end = maxLines } + leftMt, leftCur := m.currentMatch(0) + rightMt, rightCur := m.currentMatch(1) lines := make([]string, visible) for i := 0; i < visible; i++ { idx := start + i - left := "" - if idx < len(m.compare[0].lines) { - left = m.compare[0].lines[idx] - } - right := "" - if idx < len(m.compare[1].lines) { - right = m.compare[1].lines[idx] - } - left = comparePadLine(left, leftWidth, - compareDiffKind(m.compare[0].kinds, idx)) - right = comparePadLine(right, rightWidth, - compareDiffKind(m.compare[1].kinds, idx)) + // Highlight first, pad second: the padding is not part of + // the line's text and must not shift the match. + left := comparePadLine( + renderDocRow(m.compare[0].doc, idx, leftMt, leftCur), + leftWidth, m.compare[0].doc.kindAt(idx)) + right := comparePadLine( + renderDocRow(m.compare[1].doc, idx, rightMt, rightCur), + rightWidth, m.compare[1].doc.kindAt(idx)) + lines[i] = left + sep + right } body := strings.Join(lines, "\n") @@ -183,29 +191,36 @@ func (m *Model) renderCompareView() string { } bright, desc, sepS := m.helpStyles() - var hb strings.Builder - labelL := comparePositionLabel( - m.compare[0].idx, len(m.compare[0].patches), m.compare[0].ver) - labelR := comparePositionLabel( - m.compare[1].idx, len(m.compare[1].patches), m.compare[1].ver) - hb.WriteString(desc.Render(labelL + " vs " + labelR)) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(bright.Render("←/→")) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(helpKey(bright, desc, "1/2+←/→", "single")) - hb.WriteString(helpSepStr(sepS)) - if m.viewExpanded { - hb.WriteString(helpKey(bright, desc, "e", "collapse")) + + var helpText string + if m.searchInput { + helpText = m.renderSearchInputHelp(bright, desc, sepS) } else { - hb.WriteString(helpKey(bright, desc, "e", "expand")) + var hb strings.Builder + labelL := comparePositionLabel( + m.compare[0].idx, len(m.compare[0].patches), m.compare[0].ver) + labelR := comparePositionLabel( + m.compare[1].idx, len(m.compare[1].patches), m.compare[1].ver) + hb.WriteString(desc.Render(labelL + " vs " + labelR)) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(bright.Render("←/→")) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(helpKey(bright, desc, "1/2+←/→", "single")) + hb.WriteString(helpSepStr(sepS)) + if m.viewExpanded { + hb.WriteString(helpKey(bright, desc, "e", "collapse")) + } else { + hb.WriteString(helpKey(bright, desc, "e", "expand")) + } + hb.WriteString(m.renderSearchKeyHelp(bright, desc, sepS)) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(bright.Render("↑/↓") + + sepS.Render(" ") + bright.Render("pgup/dn")) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(helpKey(bright, desc, "esc", "back")) + hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) + helpText = hb.String() } - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(bright.Render("↑/↓") + - sepS.Render(" ") + bright.Render("pgup/dn")) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(helpKey(bright, desc, "esc", "back")) - hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - helpText := hb.String() var statusLine string msg, spinning := m.Status.Active() @@ -228,13 +243,6 @@ func comparePositionLabel(idx, total int, ver string) string { return fmt.Sprintf("%s [%d/%d]", ver, idx+1, total) } -func compareDiffKind(kinds []diffLineKind, idx int) diffLineKind { - if idx < len(kinds) { - return kinds[idx] - } - return diffUnchanged -} - // comparePadLine pads a rendered line to the given width. When the // line has a diff background tint, the padding spaces also get the // background color so the tint extends to the column edge. diff --git a/tui/search.go b/tui/search.go new file mode 100644 index 0000000..f1aa7d8 --- /dev/null +++ b/tui/search.go @@ -0,0 +1,674 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "encoding/json" + "fmt" + "log" + "regexp" + "sort" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" + + "leadlight/status" +) + +// searchHistoryMax caps the number of remembered queries. The list is +// only ever walked one entry at a time, so a long tail is of no use to +// anyone and would just sit in the database. +const searchHistoryMax = 50 + +// searchHistoryKey is where the history lives in the sync_state table. +// It is stored in the project database rather than in the config, +// which leadlight only ever reads, and which is shared between +// projects where searches would mean little. +const searchHistoryKey = "search_history" + +// loadSearchHistory restores the history saved by a previous session. +// A value that cannot be read is dropped rather than reported: an +// unusable history is not worth interrupting startup over. +func (m *Model) loadSearchHistory() { + m.searchHistory = nil + if m.db == nil { + return + } + stored := m.db.GetSyncState(searchHistoryKey) + if stored == "" { + return + } + var history []string + if err := json.Unmarshal([]byte(stored), &history); err != nil { + log.Printf("search: discarding unreadable history: %v", err) + return + } + for _, q := range history { + if q != "" && len(m.searchHistory) < searchHistoryMax { + m.searchHistory = append(m.searchHistory, q) + } + } +} + +// saveSearchHistory persists the history for the next session. +func (m *Model) saveSearchHistory() { + if m.db == nil { + return + } + encoded, err := json.Marshal(m.searchHistory) + if err != nil { + log.Printf("search: cannot save history: %v", err) + return + } + m.db.SetSyncState(searchHistoryKey, string(encoded)) +} + +func (m *Model) startSearch() { + m.searchInput = true + m.searchOrigin = m.viewportOffset + m.searchText = "" + m.searchCursor = 0 + m.searchDraft = "" + m.searchHistoryIdx = -1 + m.searchRegex = nil + m.searchOrder = nil + m.searchIdx = -1 + m.rescanSearch() +} + +// cancelSearch abandons the query being typed. Searching is +// incremental, so the viewport has been scrolling along with it; put +// it back where the prompt found it. +func (m *Model) cancelSearch() { + m.viewportOffset = m.searchOrigin + m.clearSearch() +} + +func (m *Model) clearSearch() { + m.searchInput = false + m.searchText = "" + m.searchCursor = 0 + m.searchDraft = "" + m.searchRegex = nil + m.searchOrder = nil + m.searchIdx = -1 + m.rescanSearch() +} + +// The prompt edits its query by rune index, the same unit the rest of +// the search counts in. These four do the index arithmetic, clamping +// their own arguments so that no caller has to: a key pressed at the +// end of its reach asks for an empty range and gets the text back +// unchanged. + +// runeLen is the length of s in runes, the unit the cursor counts in. +func runeLen(s string) int { return utf8.RuneCountInString(s) } + +// insertAt splices ins into s at rune index cur and returns the text +// with the cursor just past what was inserted. +func insertAt(s string, cur int, ins string) (string, int) { + r := []rune(s) + cur = min(max(cur, 0), len(r)) + out := make([]rune, 0, len(r)+runeLen(ins)) + out = append(out, r[:cur]...) + out = append(out, []rune(ins)...) + out = append(out, r[cur:]...) + return string(out), cur + runeLen(ins) +} + +// deleteRange removes the runes in [from,to) and leaves the cursor +// where the removed text began. +func deleteRange(s string, from, to int) (string, int) { + r := []rune(s) + from = min(max(from, 0), len(r)) + to = min(max(to, 0), len(r)) + if from >= to { + return s, from + } + return string(append(append([]rune{}, r[:from]...), r[to:]...)), from +} + +// wordStart is where the whitespace-delimited word before cur begins: +// the run of spaces just before the cursor, then the run of non-spaces +// before them. This is readline's unix-word-rubout, which takes a +// whole "\.c$" off the end of a pattern in one press rather than +// stopping at the punctuation. +func wordStart(s string, cur int) int { + r := []rune(s) + i := min(max(cur, 0), len(r)) + for i > 0 && unicode.IsSpace(r[i-1]) { + i-- + } + for i > 0 && !unicode.IsSpace(r[i-1]) { + i-- + } + return i +} + +// wordEnd is where the word after cur ends, the mirror of wordStart: +// the run of spaces at the cursor, then the run of non-spaces after +// them. +func wordEnd(s string, cur int) int { + r := []rune(s) + i := min(max(cur, 0), len(r)) + for i < len(r) && unicode.IsSpace(r[i]) { + i++ + } + for i < len(r) && !unicode.IsSpace(r[i]) { + i++ + } + return i +} + +// editSearchText applies an edit to the query. An edit that changes +// nothing is dropped: updateSearchMatches puts the match cursor back +// on the first match and scrolls there, so a ctrl+w at the start of +// the line must not yank the view away from the match being read. +func (m *Model) editSearchText(text string, cursor int) { + m.searchCursor = cursor + if text == m.searchText { + return + } + m.searchText = text + m.searchHistoryIdx = -1 + m.updateSearchMatches() +} + +// setSearchQuery replaces the whole query, as recalling a history +// entry does, and leaves the cursor at its end where typing continues +// the recalled text. The history fields belong to the caller. +func (m *Model) setSearchQuery(text string) { + m.searchText = text + m.searchCursor = runeLen(text) + m.updateSearchMatches() +} + +// moveSearchCursor moves the insertion point without touching the +// query, so it neither recompiles the pattern nor scrolls the view. +func (m *Model) moveSearchCursor(to int) { + m.searchCursor = min(max(to, 0), runeLen(m.searchText)) +} + +// searchReportFor is how long the "no results" message stays up, and +// searchReportMax how much of the query it quotes back. +const ( + searchReportFor = 3 * time.Second + searchReportMax = 40 +) + +// reportNoResults puts a message on the status line for a few seconds +// when a committed query came back empty. A pattern that would not +// compile lands here too: the search found nothing either way, and +// the query is a regular expression whose syntax is the user's to +// get right. +func (m *Model) reportNoResults(query string) { + if query == "" || m.Status == nil { + return + } + m.Status.SetTimed(status.Search, + fmt.Sprintf("Search returned no results: %q", + truncateLine(query, searchReportMax)), + searchReportFor) +} + +func (m *Model) commitSearch() { + m.searchInput = false + query := m.searchText + m.searchDraft = "" + if m.searchText != "" { + // Deduplicate: remove previous occurrence so the latest is always at front. + for i, h := range m.searchHistory { + if h == m.searchText { + m.searchHistory = append(m.searchHistory[:i], m.searchHistory[i+1:]...) + break + } + } + m.searchHistory = append([]string{m.searchText}, m.searchHistory...) + if len(m.searchHistory) > searchHistoryMax { + m.searchHistory = m.searchHistory[:searchHistoryMax] + } + m.saveSearchHistory() + } + if m.dropSearchIfUnmatched() { + // Nothing was found, so the incremental scrolling done while + // the query was typed is undone too. + m.viewportOffset = m.searchOrigin + m.reportNoResults(query) + return + } + m.scrollToMatch(m.searchIdx) +} + +// dropSearchIfUnmatched leaves the search entirely when the query it +// was given found nothing, and reports whether it did. There is +// nothing to step through or highlight, so staying in it would only +// offer an n and N that do nothing and make q take two presses to +// leave the view. The query is in the history either way, so it can +// still be recalled and corrected. +func (m *Model) dropSearchIfUnmatched() bool { + if len(m.searchOrder) > 0 { + return false + } + m.clearSearch() + return true +} + +// recallLastSearch re-runs the most recent query, as vim does when n +// or N is pressed with no search active. backward puts the cursor on +// the last match rather than the first, so that N goes backwards from +// where the view is rather than jumping to the top. +func (m *Model) recallLastSearch(backward bool) { + if len(m.searchHistory) == 0 { + return + } + // No prompt was opened, so nothing has set the starting point + // this run: the recalled query resumes from where the view is. + m.searchOrigin = m.viewportOffset + m.setSearchQuery(m.searchHistory[0]) + if m.dropSearchIfUnmatched() { + return + } + if backward && len(m.searchOrder) > 0 { + m.searchIdx = len(m.searchOrder) - 1 + m.scrollToMatch(m.searchIdx) + } +} + +// searchMatcher holds every match of one pattern in one document. +// Matches are in logical coordinates: a rune range inside one of the +// document's unwrapped source lines. Those coordinates survive a +// resize, because re-wrapping changes the rows but not the source. +type searchMatcher struct { + matches []searchMatch + byLine map[int][]searchMatch +} + +func (s *searchMatcher) reset() { + s.matches = nil + s.byLine = nil +} + +// scan finds every match of re in the document's source lines. +func (s *searchMatcher) scan(d *renderedDoc, re *regexp.Regexp) { + s.reset() + if d == nil || re == nil { + return + } + for line, text := range d.logical { + found := re.FindAllStringIndex(text, -1) + if found == nil { + continue + } + if s.byLine == nil { + s.byLine = map[int][]searchMatch{} + } + for _, pos := range found { + if pos[0] == pos[1] { + // A zero-width match, as "x*" or "^" produces on + // every position. There is nothing to highlight and + // nothing to step to, and counting them would bury + // the real matches. + continue + } + // regexp reports byte offsets; everything downstream + // counts in runes. + match := searchMatch{ + line: line, + start: utf8.RuneCountInString(text[:pos[0]]), + end: utf8.RuneCountInString(text[:pos[1]]), + } + s.matches = append(s.matches, match) + s.byLine[line] = append(s.byLine[line], match) + } + } +} + +// onLine returns the matches on one source line, in order. +func (s *searchMatcher) onLine(line int) []searchMatch { + if s == nil || line < 0 { + return nil + } + return s.byLine[line] +} + +// searchTarget is one document the current view searches. +type searchTarget struct { + doc *renderedDoc + mt *searchMatcher +} + +// searchTargets returns the documents the current view searches, in +// the order their columns appear on screen. This is the only place +// that has to know the difference between the two views. +func (m *Model) searchTargets() []searchTarget { + if m.viewMode == viewCompare { + return []searchTarget{ + {m.compare[0].doc, &m.compare[0].matcher}, + {m.compare[1].doc, &m.compare[1].matcher}, + } + } + return []searchTarget{{m.viewportDoc, &m.viewportMatcher}} +} + +// matchRef points at one match across all of the view's documents. +// The refs are ordered the way the eye reads them: down the screen, +// and left column before right on the same row. +type matchRef struct { + target int + idx int + row int +} + +// rescanSearch recomputes the matches for the active query. It is +// called whenever the content changes, so that a document can never +// be displayed alongside matches that were found in an older one. +// The cursor keeps its place when it still points at a match, so +// that a resize does not lose track of which match you were on. +// Scrolling to it again is the caller's business, since a rebuild +// that replaces the content wants the top of it instead. +func (m *Model) rescanSearch() { + targets := m.searchTargets() + m.searchOrder = nil + + for ti, t := range targets { + t.mt.scan(t.doc, m.searchRegex) + for i, match := range t.mt.matches { + m.searchOrder = append(m.searchOrder, matchRef{ + target: ti, + idx: i, + row: t.doc.rowOf(match.line, match.start), + }) + } + } + sort.SliceStable(m.searchOrder, func(a, b int) bool { + x, y := m.searchOrder[a], m.searchOrder[b] + if x.row != y.row { + return x.row < y.row + } + return x.target < y.target + }) + + if len(m.searchOrder) == 0 { + m.searchIdx = -1 + return + } + if m.searchIdx < 0 || m.searchIdx >= len(m.searchOrder) { + m.searchIdx = 0 + } +} + +// matchFrom is the index of the first match at or after row, or 0 +// when every match is above it. searchOrder is kept sorted by row, +// so this is a binary search. +func (m *Model) matchFrom(row int) int { + if len(m.searchOrder) == 0 { + return -1 + } + i := sort.Search(len(m.searchOrder), func(i int) bool { + return m.searchOrder[i].row >= row + }) + if i == len(m.searchOrder) { + // Everything matched above the starting point; wrap. + return 0 + } + return i +} + +// updateSearchMatches recompiles the query and runs it over the +// current view, leaving the cursor on the first match at or after +// where the search started from. A search begun partway down a +// document runs to the end and then wraps to the top, rather than +// yanking the reader back to the first match in the file. +func (m *Model) updateSearchMatches() { + m.searchRegex = nil + if m.searchText != "" { + if re, err := parseVimSearchPattern(m.searchText); err == nil { + m.searchRegex = re + } + } + m.searchIdx = 0 + m.rescanSearch() + m.searchIdx = m.matchFrom(m.searchOrigin) + m.scrollToMatch(m.searchIdx) +} + +// currentMatch returns the matcher for one of the view's documents, +// and the match the cursor is on when it falls in that document. +func (m *Model) currentMatch(target int) (*searchMatcher, *searchMatch) { + targets := m.searchTargets() + if target < 0 || target >= len(targets) { + return nil, nil + } + mt := targets[target].mt + if m.searchIdx < 0 || m.searchIdx >= len(m.searchOrder) { + return mt, nil + } + ref := m.searchOrder[m.searchIdx] + if ref.target != target || ref.idx >= len(mt.matches) { + return mt, nil + } + cur := mt.matches[ref.idx] + return mt, &cur +} + +// searchMaxLines is the number of rows the current view can scroll. +func (m *Model) searchMaxLines() int { + n := 0 + for _, t := range m.searchTargets() { + n = max(n, t.doc.Len()) + } + return n +} + +func (m *Model) nextSearchMatch() { + if len(m.searchOrder) == 0 { + return + } + m.searchIdx = (m.searchIdx + 1) % len(m.searchOrder) + m.scrollToMatch(m.searchIdx) +} + +func (m *Model) prevSearchMatch() { + if len(m.searchOrder) == 0 { + return + } + m.searchIdx-- + if m.searchIdx < 0 { + m.searchIdx = len(m.searchOrder) - 1 + } + m.scrollToMatch(m.searchIdx) +} + +// scrollToMatch centres the viewport on a match. Because the ref +// records the row the match actually lands on, a match deep inside a +// wrapped line scrolls to the row showing it rather than to the top +// of the line. +func (m *Model) scrollToMatch(idx int) { + if idx < 0 || idx >= len(m.searchOrder) { + return + } + visible := m.viewportVisibleLines() + m.viewportOffset = m.searchOrder[idx].row - visible/2 + if m.viewportOffset < 0 { + m.viewportOffset = 0 + } + maxOffset := m.searchMaxLines() - visible + if maxOffset < 0 { + maxOffset = 0 + } + if m.viewportOffset > maxOffset { + m.viewportOffset = maxOffset + } +} + +// renderDocRow renders one row with the search highlight applied. The +// highlight is a different style on part of a span, so no escape +// sequence is ever inspected or edited. +func renderDocRow( + d *renderedDoc, row int, mt *searchMatcher, cur *searchMatch, +) string { + if d == nil || row < 0 || row >= len(d.visual) { + return "" + } + v := d.visual[row] + matches := mt.onLine(v.logical) + if len(matches) == 0 { + return d.Line(row) + } + + var b strings.Builder + off := v.off + for _, sp := range v.spans { + if sp.synthetic { + b.WriteString(sp.style.Render(sp.text)) + continue + } + runes := []rune(sp.text) + pos := 0 + for _, match := range matches { + start, end := match.start-off, match.end-off + if end <= pos || start >= len(runes) { + continue + } + start = max(start, pos) + end = min(end, len(runes)) + if start > pos { + b.WriteString(sp.style.Render(string(runes[pos:start]))) + } + style := searchOtherStyle + if cur != nil && *cur == match { + style = searchCurrentStyle + } + b.WriteString(style.Render(string(runes[start:end]))) + pos = end + } + if pos < len(runes) { + b.WriteString(sp.style.Render(string(runes[pos:]))) + } + off += len(runes) + } + return b.String() +} + +// parseVimSearchPattern parses a vim-style search pattern and returns a compiled regex. +// Supports \c for case-insensitive, \C for case-sensitive (default), and standard regex. +// A pattern that is not a valid regex is an error; the query is a +// regular expression and silently searching for it as literal text +// would answer a question the user did not ask. +func parseVimSearchPattern(pattern string) (*regexp.Regexp, error) { + if pattern == "" { + return nil, nil + } + + cleanPattern, caseInsensitive := extractCaseFlags(pattern) + if cleanPattern == "" { + return nil, nil + } + + prefix := "" + if caseInsensitive { + prefix = "(?i)" + } + + return regexp.Compile(prefix + cleanPattern) +} + +// extractCaseFlags strips the vim case flags \c and \C from a pattern and +// reports whether matching should be case-insensitive. Like vim, the flags +// may appear anywhere in the pattern and the last one wins. Only top-level +// flags count: an escaped backslash (\\c) and anything inside a bracket +// expression ([\c]) are left in the pattern untouched. +func extractCaseFlags(pattern string) (clean string, caseInsensitive bool) { + var b strings.Builder + b.Grow(len(pattern)) + + inClass := false + for i := 0; i < len(pattern); { + c := pattern[i] + if c == '\\' && i+1 < len(pattern) { + if n := pattern[i+1]; !inClass && (n == 'c' || n == 'C') { + caseInsensitive = n == 'c' + i += 2 + continue + } + // Copy the escape as a unit so that a "\\" pair cannot + // mask the character that follows it. + b.WriteString(pattern[i : i+2]) + i += 2 + continue + } + switch { + case c == '[' && !inClass: + inClass = true + case c == ']' && inClass: + inClass = false + } + b.WriteByte(c) + i++ + } + + return b.String(), caseInsensitive +} + +// renderSearchQuery draws the query with a block cursor on the rune +// the cursor is on. Past the end of the text there is no rune to +// invert, so the cursor gets a space of its own; an underscore there +// could not be told apart from one typed into the pattern. +// +// The cursor is clamped rather than trusted: tests and any future +// caller that sets searchText directly leave it stale, and a stale +// cursor should draw oddly rather than panic. +func renderSearchQuery(text string, cursor int) string { + r := []rune(text) + cursor = min(max(cursor, 0), len(r)) + at, after := " ", "" + if cursor < len(r) { + at = string(r[cursor]) + after = string(r[cursor+1:]) + } + var b strings.Builder + if cursor > 0 { + b.WriteString(normalOptionStyle.Render(string(r[:cursor]))) + } + b.WriteString(searchCursorStyle.Render(at)) + if after != "" { + b.WriteString(normalOptionStyle.Render(after)) + } + return b.String() +} + +func (m *Model) renderSearchInputHelp(bright, desc, sep lipgloss.Style) string { + var hb strings.Builder + hb.WriteString(desc.Render("Search: ")) + hb.WriteString(renderSearchQuery(m.searchText, m.searchCursor)) + if len(m.searchOrder) > 0 { + hb.WriteString(desc.Render(fmt.Sprintf(" [%d/%d]", + m.searchIdx+1, len(m.searchOrder)))) + } else if m.searchText != "" { + hb.WriteString(desc.Render(" [no matches]")) + } + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "enter", "done")) + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "esc", "cancel")) + return hb.String() +} + +func (m *Model) renderSearchKeyHelp(bright, desc, sep lipgloss.Style) string { + var hb strings.Builder + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "/", "search")) + // Offer n and N only when there is something to step through. + // A committed search that found nothing is dropped, but one can + // also be left with no matches by the content changing under it. + if len(m.searchOrder) > 0 { + hb.WriteString(helpSepStr(sep)) + hb.WriteString(helpKey(bright, desc, "n/N", "next/prev")) + hb.WriteString(sep.Render(fmt.Sprintf(" [%d]", len(m.searchOrder)))) + } + return hb.String() +} diff --git a/tui/search_test.go b/tui/search_test.go new file mode 100644 index 0000000..32d8887 --- /dev/null +++ b/tui/search_test.go @@ -0,0 +1,1861 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "path/filepath" + "reflect" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" + + "leadlight/db" + "leadlight/status" +) + +// withColor makes lipgloss emit escape sequences for the duration of +// a test. Tests run without a TTY, where every style renders as plain +// text and highlighting cannot be observed at all. +func withColor(t *testing.T) { + t.Helper() + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) +} + +// typeSearch feeds runes to the search prompt the way bubbletea +// delivers them for ordinary key presses. +func typeSearch(m *Model, s string) { + for _, r := range s { + key := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + if r == ' ' { + key.Type = tea.KeySpace + } + m.handleSearchInputKey(key) + } +} + +// pressKeys feeds a sequence of named keys to the prompt. +func pressKeys(m *Model, names ...string) { + for _, n := range names { + m.handleSearchInputKey(keySpecial(n)) + } +} + +func keyRunes(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func keySpecial(name string) tea.KeyMsg { + types := map[string]tea.KeyType{ + "esc": tea.KeyEsc, "enter": tea.KeyEnter, "up": tea.KeyUp, + "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight, + "home": tea.KeyHome, "end": tea.KeyEnd, + "backspace": tea.KeyBackspace, "delete": tea.KeyDelete, + "ctrl+a": tea.KeyCtrlA, "ctrl+e": tea.KeyCtrlE, + "ctrl+k": tea.KeyCtrlK, "ctrl+u": tea.KeyCtrlU, + "ctrl+w": tea.KeyCtrlW, "ctrl+g": tea.KeyCtrlG, + "ctrl+t": tea.KeyCtrlT, + "ctrl+left": tea.KeyCtrlLeft, "ctrl+right": tea.KeyCtrlRight, + } + if t, ok := types[name]; ok { + return tea.KeyMsg{Type: t} + } + // Alt-modified keys, which the prompt ignores except for the two + // ctrl+arrows that urxvt reports with the alt bit set. + switch name { + case "alt+x": + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}, Alt: true} + case "alt+left": + return tea.KeyMsg{Type: tea.KeyLeft, Alt: true} + case "alt+backspace": + return tea.KeyMsg{Type: tea.KeyBackspace, Alt: true} + case "alt+ctrl+left": + return tea.KeyMsg{Type: tea.KeyCtrlLeft, Alt: true} + case "alt+ctrl+right": + return tea.KeyMsg{Type: tea.KeyCtrlRight, Alt: true} + } + panic("unknown key " + name) +} + +// searchDoc lays a body out the way the patch view does, so that +// tests exercise real wrapping rather than one-line-per-row text. +func searchDoc(body string, width int) *renderedDoc { + d := &renderedDoc{} + for _, line := range strings.Split(body, "\n") { + appendBodyLineDoc(d, line, width, diffUnchanged) + } + return d +} + +// searchDocWithMatchesAt builds a document of n lines with "needle" +// on each of the given ones, so a test can say where the matches are +// rather than counting filler. +func searchDocWithMatchesAt(n int, lines ...int) *renderedDoc { + at := map[int]bool{} + for _, l := range lines { + at[l] = true + } + body := make([]string, n) + for i := range body { + body[i] = "filler" + if at[i] { + body[i] = "needle here" + } + } + return searchDoc(strings.Join(body, "\n"), 80) +} + +// searchModel opens a patch view over body and runs query over it. +// An empty query leaves the search idle. +func searchModel(body, query string) *Model { + return searchModelSize(body, query, 80, 24) +} + +func searchModelSize(body, query string, width, height int) *Model { + m := &Model{width: width, height: height, viewMode: viewPatch} + m.setViewportDoc(searchDoc(body, width)) + if query != "" { + m.searchText = query + m.updateSearchMatches() + } + return m +} + +// promptModel opens the search prompt with text already typed into it. +func promptModel(text string) *Model { + m := &Model{} + m.startSearch() + typeSearch(m, text) + return m +} + +// patchViewWithSearch opens a patch view over the given lines and runs +// a committed search, the way a user would. +func patchViewWithSearch(query string, lines ...string) *Model { + m := &Model{width: 80, height: 24, viewMode: viewPatch, + viewingPatchID: 100, viewCommentIdx: -1} + m.setViewportDoc(docFromLines(lines...)) + m.startSearch() + typeSearch(m, query) + m.handleSearchInputKey(keySpecial("enter")) + return m +} + +// searchHistoryDB opens an empty in-memory database for history tests. +func searchHistoryDB(t *testing.T) *db.DB { + t.Helper() + d, err := db.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return d +} + +// checkSearchOrder asserts that every match ref still points at the +// row that actually shows it. A stale ref can easily stay within +// range of the document, so comparing against rowOf is what catches +// matches left over from older content. +func checkSearchOrder(t *testing.T, m *Model) { + t.Helper() + targets := m.searchTargets() + for i, ref := range m.searchOrder { + if ref.target < 0 || ref.target >= len(targets) { + t.Errorf("match %d names column %d of %d", + i, ref.target, len(targets)) + continue + } + doc, mt := targets[ref.target].doc, targets[ref.target].mt + if ref.idx < 0 || ref.idx >= len(mt.matches) { + t.Errorf("match %d indexes %d of %d", + i, ref.idx, len(mt.matches)) + continue + } + match := mt.matches[ref.idx] + if want := doc.rowOf(match.line, match.start); ref.row != want { + t.Errorf("match %d is recorded on row %d, but source line %d "+ + "offset %d is shown on row %d", + i, ref.row, match.line, match.start, want) + } + if ref.row < 0 || ref.row >= doc.Len() { + t.Errorf("match %d points at row %d of %d", + i, ref.row, doc.Len()) + } + } +} + +// assertMatchOnScreen fails unless the match the cursor is on is +// inside the visible window. +func assertMatchOnScreen(t *testing.T, m *Model, what string) { + t.Helper() + if m.searchIdx < 0 || m.searchIdx >= len(m.searchOrder) { + t.Fatalf("%s: no current match", what) + } + row := m.searchOrder[m.searchIdx].row + visible := m.viewportVisibleLines() + if row < m.viewportOffset || row >= m.viewportOffset+visible { + t.Errorf("%s: match on row %d outside the window [%d,%d)", + what, row, m.viewportOffset, m.viewportOffset+visible) + } +} + +// --------------------------------------------------------------- +// Pattern parsing +// --------------------------------------------------------------- + +func TestExtractCaseFlags(t *testing.T) { + for _, tc := range []struct { + name string + pattern string + wantClean string + wantCI bool + }{ + {"no flags", "foo", "foo", false}, + {"leading \\c", `\cfoo`, "foo", true}, + {"trailing \\C", `foo\C`, "foo", false}, + {"flag in the middle", `fo\co`, "foo", true}, + {"last flag wins, \\c last", `\C\cfoo`, "foo", true}, + {"last flag wins, \\C last", `\c\Cfoo`, "foo", false}, + {"escaped backslash is not a flag", `foo\\cbar`, `foo\\cbar`, false}, + {"flag inside a bracket expression", `[\c]`, `[\c]`, false}, + {"flag outside a bracket expression", `a[\C]b\c`, `a[\C]b`, true}, + {"only a flag", `\c`, "", true}, + {"trailing lone backslash", `foo\`, `foo\`, false}, + {"spaces are preserved", " foo ", " foo ", false}, + } { + t.Run(tc.name, func(t *testing.T) { + clean, ci := extractCaseFlags(tc.pattern) + if clean != tc.wantClean || ci != tc.wantCI { + t.Errorf("extractCaseFlags(%q) = %q, %v; want %q, %v", + tc.pattern, clean, ci, tc.wantClean, tc.wantCI) + } + }) + } +} + +// The query is a regular expression, so what this function owns is the +// \c and \C handling on top of regexp and the refusal to quietly treat +// a pattern that will not compile as literal text. +func TestParseVimSearchPattern(t *testing.T) { + for _, tc := range []struct { + name string + pattern string + testString string + want bool + }{ + {"simple literal", "foo", "this is foo bar", true}, + {"case sensitive by default", "Foo", "this is foo bar", false}, + {"case insensitive with trailing \\c", `Foo\c`, "this is foo bar", true}, + {"case insensitive with leading \\c", `\cFoo`, "this is FOO bar", true}, + {"flag applies to the whole alternation", `\cfoo|bar`, "this is BAR", true}, + {"last flag wins, \\c after \\C", `Foo\C\c`, "this is foo bar", true}, + {"last flag wins, \\C after \\c", `Foo\c\C`, "this is foo bar", false}, + {"escaped backslash is not a flag", `foo\\cbar`, `foo\cbar`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + re, err := parseVimSearchPattern(tc.pattern) + if err != nil { + t.Fatalf("parseVimSearchPattern(%q) errored: %v", tc.pattern, err) + } + if re == nil { + t.Fatalf("parseVimSearchPattern(%q) returned no regex", tc.pattern) + } + if got := re.MatchString(tc.testString); got != tc.want { + t.Errorf("pattern %q against %q: match = %v, want %v", + tc.pattern, tc.testString, got, tc.want) + } + }) + } + + // An empty pattern is not an error, it just has no regex. + if re, err := parseVimSearchPattern(""); re != nil || err != nil { + t.Errorf(`parseVimSearchPattern("") = %v, %v; want nil, nil`, re, err) + } + + // A pattern that does not compile is an error rather than + // something quietly searched for as literal text, which would + // answer a different question than the one that was asked. "*.c" + // is the common reflex: a glob is not regex syntax, so it has to + // fail rather than appear to work by matching its characters. + for _, pattern := range []string{ + "foo(", "a[b", "*", `\cFOO(`, "x{2,1}", `a\`, "*.c", + } { + re, err := parseVimSearchPattern(pattern) + if err == nil { + t.Errorf("pattern %q compiled to %v, want an error", pattern, re) + } + if re != nil { + t.Errorf("pattern %q returned a regex alongside its error", pattern) + } + } +} + +// --------------------------------------------------------------- +// Prompt editing primitives +// --------------------------------------------------------------- + +// The prompt edits by rune index, so the primitives behind it have to +// stay right on multi-byte text and clamp rather than panic on the +// out-of-range indices that pressing a key at the end of its reach +// naturally produces. + +func TestInsertAt(t *testing.T) { + for _, tc := range []struct { + name string + s string + cur int + ins string + want string + wantCur int + }{ + {"at start", "bar", 0, "foo", "foobar", 3}, + {"at end", "foo", 3, "bar", "foobar", 6}, + {"mid string", "foobar", 3, "-", "foo-bar", 4}, + {"multi byte", "café", 4, "☕", "café☕", 5}, + {"into multi byte", "café", 2, "X", "caXfé", 3}, + {"empty insert", "foo", 1, "", "foo", 1}, + {"cursor past end clamps", "foo", 99, "!", "foo!", 4}, + {"negative cursor clamps", "foo", -3, "!", "!foo", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + got, gotCur := insertAt(tc.s, tc.cur, tc.ins) + if got != tc.want || gotCur != tc.wantCur { + t.Errorf("insertAt(%q, %d, %q) = %q, %d; want %q, %d", + tc.s, tc.cur, tc.ins, got, gotCur, tc.want, tc.wantCur) + } + }) + } +} + +func TestDeleteRange(t *testing.T) { + for _, tc := range []struct { + name string + s string + from, to int + want string + wantCur int + }{ + {"head", "foobar", 0, 3, "bar", 0}, + {"tail", "foobar", 3, 6, "foo", 3}, + {"middle", "foobar", 2, 4, "foar", 2}, + {"whole", "foobar", 0, 6, "", 0}, + {"multi byte", "café☕", 4, 5, "café", 4}, + // A key pressed at the end of its reach asks for an empty or + // inverted range; the text has to come back unchanged so that + // the caller can tell nothing happened. + {"empty range", "foobar", 3, 3, "foobar", 3}, + {"inverted range", "foobar", 4, 2, "foobar", 4}, + {"backspace at start", "foobar", -1, 0, "foobar", 0}, + {"delete at end", "foobar", 6, 7, "foobar", 6}, + {"both out of range", "foobar", 99, 99, "foobar", 6}, + } { + t.Run(tc.name, func(t *testing.T) { + got, gotCur := deleteRange(tc.s, tc.from, tc.to) + if got != tc.want || gotCur != tc.wantCur { + t.Errorf("deleteRange(%q, %d, %d) = %q, %d; want %q, %d", + tc.s, tc.from, tc.to, got, gotCur, tc.want, tc.wantCur) + } + }) + } +} + +func TestWordMotions(t *testing.T) { + for _, tc := range []struct { + name string + fn func(string, int) int + s string + cur int + want int + }{ + {"start from end of word", wordStart, "foo bar", 7, 4}, + {"start from mid word", wordStart, "foo bar", 6, 4}, + {"start at a word start", wordStart, "foo bar", 4, 0}, + {"start over a run of spaces", wordStart, "foo bar ", 9, 4}, + {"start at zero", wordStart, "foo bar", 0, 0}, + {"start in all spaces", wordStart, " ", 3, 0}, + {"start in empty text", wordStart, "", 0, 0}, + // Punctuation is not a word boundary, so a whole pattern + // fragment comes off in one press. + {"start past punctuation", wordStart, `foo \.c$`, 8, 4}, + // Rune indices, not bytes: "café ☕ naïve" is 12 runes. + {"start on multi byte", wordStart, "café ☕ naïve", 12, 7}, + {"end from start of word", wordEnd, "foo bar", 0, 3}, + {"end from mid word", wordEnd, "foo bar", 1, 3}, + {"end from a space", wordEnd, "foo bar", 3, 7}, + {"end over a run of spaces", wordEnd, "foo bar", 3, 8}, + {"end at the end", wordEnd, "foo bar", 7, 7}, + {"end in all spaces", wordEnd, " ", 0, 3}, + {"end on multi byte", wordEnd, "café ☕ naïve", 0, 4}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.fn(tc.s, tc.cur); got != tc.want { + t.Errorf("(%q, %d) = %d, want %d", tc.s, tc.cur, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------- +// Prompt key handling +// --------------------------------------------------------------- + +func TestSearchInputEditing(t *testing.T) { + for _, tc := range []struct { + name string + text string + keys []string + paste string + thenType string + want string + wantCur int + }{ + {name: "typing accepts multi-byte runes", + text: "café ☕", want: "café ☕", wantCur: 6}, + {name: "backspace removes a whole rune, not a byte", + text: "café ☕", keys: []string{"backspace", "backspace"}, + want: "café", wantCur: 4}, + {name: "a paste drops what is not printable", + paste: "foo\nbär\tbaz", want: "foobärbaz", wantCur: 9}, + {name: "typing inserts at the cursor", + text: "foobar", keys: []string{"left", "left", "left"}, + thenType: "X", want: "fooXbar", wantCur: 4}, + {name: "a paste lands at the cursor as a block", + text: "foobaz", keys: []string{"left", "left", "left"}, + paste: "ba\nr-", want: "foobar-baz", wantCur: 7}, + {name: "backspace deletes before the cursor", + text: "foobar", keys: []string{"left", "left", "left", "backspace"}, + want: "fobar", wantCur: 2}, + {name: "delete removes the rune under the cursor", + text: "foobar", keys: []string{"left", "left", "left", "delete"}, + want: "fooar", wantCur: 3}, + {name: "delete at the end has nothing to remove", + text: "foobar", keys: []string{"delete"}, want: "foobar", wantCur: 6}, + {name: "ctrl+w takes the word behind the cursor", + text: "foo bar baz", keys: []string{"ctrl+w"}, + want: "foo bar ", wantCur: 9}, + {name: "ctrl+w then takes the spaces and the word before them", + text: "foo bar baz", keys: []string{"ctrl+w", "ctrl+w"}, + want: "foo ", wantCur: 4}, + {name: "ctrl+w at column zero has no word behind it", + text: "foo bar", keys: []string{"home", "ctrl+w"}, + want: "foo bar", wantCur: 0}, + {name: "ctrl+k kills to the end of the line", + text: "foobar", keys: []string{"left", "left", "left", "ctrl+k"}, + want: "foo", wantCur: 3}, + {name: "ctrl+u discards back to the start", + text: "foobar", keys: []string{"left", "left", "left", "ctrl+u"}, + want: "bar", wantCur: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + m := promptModel(tc.text) + pressKeys(m, tc.keys...) + if tc.paste != "" { + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyRunes, + Paste: true, Runes: []rune(tc.paste)}) + } + typeSearch(m, tc.thenType) + + if m.searchText != tc.want || m.searchCursor != tc.wantCur { + t.Errorf("searchText = %q cursor %d, want %q and %d", + m.searchText, m.searchCursor, tc.want, tc.wantCur) + } + // Emptying the query is not the same as leaving: only + // backspace on an already-empty query closes the prompt. + if !m.searchInput { + t.Error("the prompt closed") + } + }) + } +} + +func TestSearchInputCursorMotion(t *testing.T) { + // "café ☕" is six runes and ten bytes; the cursor counts runes. + for _, tc := range []struct { + name string + text string + keys []string + want int + }{ + {"left moves back", "foobar", []string{"left", "left", "left"}, 3}, + {"right moves on", "foobar", []string{"left", "left", "right"}, 5}, + {"left clamps at the start", "ab", + []string{"left", "left", "left", "left"}, 0}, + {"right clamps at the end", "ab", []string{"right", "right"}, 2}, + {"home", "café ☕", []string{"home"}, 0}, + {"end", "café ☕", []string{"home", "end"}, 6}, + {"ctrl+a", "café ☕", []string{"ctrl+a"}, 0}, + {"ctrl+e", "café ☕", []string{"ctrl+a", "ctrl+e"}, 6}, + {"ctrl+left jumps a word", "foo bar baz", []string{"ctrl+left"}, 8}, + {"ctrl+left jumps two", "foo bar baz", + []string{"ctrl+left", "ctrl+left"}, 4}, + {"ctrl+left clamps", "foo bar baz", + []string{"ctrl+left", "ctrl+left", "ctrl+left", "ctrl+left"}, 0}, + {"ctrl+right jumps a word", "foo bar baz", + []string{"home", "ctrl+right"}, 3}, + {"ctrl+right clamps", "foo bar baz", + []string{"ctrl+right", "ctrl+right"}, 11}, + // urxvt reports ctrl+arrow with the alt bit set, so those two + // are let through by name where every other alt key is not. + {"urxvt alt-flagged ctrl+left", "foo bar", + []string{"alt+ctrl+left"}, 4}, + {"urxvt alt-flagged ctrl+right", "foo bar", + []string{"home", "alt+ctrl+right"}, 3}, + // Alt is how a terminal sends the readline word motions this + // prompt deliberately does not implement. They must stay inert + // rather than fall through to the unmodified key. + {"alt+x does not type an x", "foo", []string{"alt+x"}, 3}, + {"alt+left does not move", "foo", []string{"alt+left"}, 3}, + {"alt+backspace does not delete", "foo", []string{"alt+backspace"}, 3}, + {"unbound control keys do nothing", "foo", + []string{"ctrl+g", "ctrl+t"}, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + m := promptModel(tc.text) + pressKeys(m, tc.keys...) + if m.searchCursor != tc.want { + t.Errorf("searchCursor = %d, want %d", m.searchCursor, tc.want) + } + if m.searchText != tc.text { + t.Errorf("searchText = %q, want it unchanged at %q", + m.searchText, tc.text) + } + }) + } +} + +// Backspace leaves the prompt only when the query is already empty. +// At the left edge of a query that still has text it does nothing: +// the cursor only reaches column zero by deliberate motion, so +// discarding the query there would be a surprise. +func TestSearchInputBackspaceAtStartKeepsQuery(t *testing.T) { + m := promptModel("foo") + pressKeys(m, "home", "backspace") + if m.searchText != "foo" { + t.Errorf("searchText = %q, want %q", m.searchText, "foo") + } + if !m.searchInput { + t.Error("backspace at column 0 left the prompt, want it open") + } + + // Emptying the query and pressing it again does leave. + pressKeys(m, "ctrl+k") + if m.searchText != "" || !m.searchInput { + t.Fatalf("after ctrl+k: text %q input %v, want empty and open", + m.searchText, m.searchInput) + } + pressKeys(m, "backspace") + if m.searchInput { + t.Error("backspace on an empty query kept the prompt open") + } +} + +// Moving the cursor is not editing the query, so it must not re-run +// the search: updateSearchMatches drops back to the first match and +// scrolls there, which would yank the view away from the match being +// read. Edits that produce identical text have to be just as inert. +func TestSearchInputCursorMotionKeepsCurrentMatch(t *testing.T) { + m := searchModelSize(strings.Repeat("filler\n", 20)+ + "needle one\nfiller\nneedle two\nfiller\nneedle three", "", 80, 10) + m.startSearch() + typeSearch(m, "needle") + m.nextSearchMatch() + + wantIdx, wantOff := m.searchIdx, m.viewportOffset + if wantIdx == 0 { + t.Fatalf("test needs to be past the first match, searchIdx = 0") + } + + keys := []string{"left", "right", "home", "end", "ctrl+a", "ctrl+e", + "ctrl+left", "ctrl+right", + // An edit key that deletes nothing is still not an edit. + "home", "ctrl+w"} + for _, key := range keys { + pressKeys(m, key) + if m.searchIdx != wantIdx || m.viewportOffset != wantOff { + t.Errorf("%s moved to idx %d off %d, want %d and %d", + key, m.searchIdx, m.viewportOffset, wantIdx, wantOff) + } + } +} + +func TestSearchPromptTakesCoalescedRunes(t *testing.T) { + // Bubbletea hands over every rune it can read at once as a single + // KeyRunes message, so typing quickly can produce one whose name + // collides with a key name. + for _, typed := range []string{"up", "down", "enter", "esc", "tab"} { + m := searchModel("nothing of interest", "") + m.searchHistory = []string{"PREVIOUS"} + m.startSearch() + + m.handleSearchInputKey(keyRunes(typed)) + if m.searchText != typed { + t.Errorf("typing %q gave searchText %q", typed, m.searchText) + } + if !m.searchInput { + t.Errorf("typing %q closed the prompt", typed) + } + } +} + +func TestSearchPromptKeepsTheLogConsoleKeys(t *testing.T) { + // The log console toggles live above the view dispatch, so they + // used to fire while a query was being typed. + m := testModel() + m.viewMode = viewPatch + m.setViewportDoc(docFromLines("a `backtick` and a tab")) + m.startSearch() + + m = pressKey(m, "`") + if m.logConsole { + t.Error("a backtick typed at the prompt opened the log console") + } + if m.searchText != "`" { + t.Errorf("searchText = %q, want the backtick inserted", m.searchText) + } + + // With the console already open, tab must still reach the prompt + // rather than moving focus into it. + m.logConsole = true + m.logFocused = false + m = pressSpecialKey(m, tea.KeyTab) + if m.logFocused { + t.Error("tab at the prompt moved focus to the log console") + } + + // And outside the prompt the backtick still toggles. + m.logConsole = false + m.clearSearch() + m = pressKey(m, "`") + if !m.logConsole { + t.Error("the backtick should still toggle the console outside a search") + } +} + +// --------------------------------------------------------------- +// Leaving the prompt and the view +// --------------------------------------------------------------- + +func TestSearchPromptAndViewExitKeys(t *testing.T) { + for _, tc := range []struct { + name string + committed bool + keys []string + wantMode viewMode + wantInput bool + }{ + // esc at the prompt cancels the query being typed; it must not + // also close the patch view. + {name: "esc at the prompt keeps the view", + keys: []string{"esc"}, wantMode: viewPatch}, + {name: "backspacing past the start leaves the prompt only", + keys: []string{"backspace", "backspace", "backspace"}, + wantMode: viewPatch}, + // With a committed search, q clears it before it exits. + {name: "q clears a committed search first", + committed: true, keys: []string{"q"}, wantMode: viewPatch}, + {name: "a second q leaves", + committed: true, keys: []string{"q", "q"}, wantMode: viewTable}, + // esc is the shortcut: it leaves outright either way. + {name: "esc leaves a committed search outright", + committed: true, keys: []string{"esc"}, wantMode: viewTable}, + } { + t.Run(tc.name, func(t *testing.T) { + var m *Model + if tc.committed { + m = patchViewWithSearch("needle", "a needle here", "nothing") + if len(m.searchOrder) != 1 { + t.Fatalf("expected the search to match, got %d", + len(m.searchOrder)) + } + } else { + m = searchModel("a needle here", "") + m.startSearch() + typeSearch(m, "ne") + } + + for _, k := range tc.keys { + if k == "q" { + m.handleViewportKey(keyRunes("q")) + continue + } + m.handleViewportKey(keySpecial(k)) + } + + if m.viewMode != tc.wantMode { + t.Errorf("viewMode = %d, want %d", m.viewMode, tc.wantMode) + } + if m.searchInput != tc.wantInput { + t.Errorf("searchInput = %v, want %v", + m.searchInput, tc.wantInput) + } + if m.searchText != "" { + t.Errorf("searchText = %q, want it cleared", m.searchText) + } + if tc.wantMode == viewTable && + (m.viewportDoc != nil || m.viewingPatchID != 0) { + t.Error("leaving the view should drop the content it showed") + } + }) + } +} + +func TestCompareViewQClearsSearchThenExits(t *testing.T) { + skipIfNoDiff(t) + left := ParsedMbox{Subject: "Lorem", From: "a@b.example", + Body: "a needle here\n"} + right := ParsedMbox{Subject: "Lorem", From: "a@b.example", + Body: "something else\n"} + m := buildTestCompare(t, left, right, 80) + m.viewMode = viewCompare + m.compareCount = 2 + + m.startSearch() + typeSearch(m, "needle") + m.handleSearchInputKey(keySpecial("enter")) + if len(m.searchOrder) != 1 { + t.Fatalf("expected the search to match, got %d", len(m.searchOrder)) + } + + m.handleCompareKey(keyRunes("q")) + if m.viewMode != viewCompare { + t.Errorf("first q left the compare view, mode = %d", m.viewMode) + } + m.handleCompareKey(keyRunes("q")) + if m.viewMode != viewTable { + t.Errorf("second q did not leave, mode = %d", m.viewMode) + } + if m.compareCount != 0 { + t.Errorf("leaving compare should unmark both sides, count = %d", + m.compareCount) + } +} + +func TestSearchNavKeyFallsThroughWhenIdle(t *testing.T) { + m := &Model{viewportDoc: docFromLines("foo bar")} + + // Nothing searched yet and no history: n/N belong to the view. + for _, key := range []string{"n", "N"} { + if m.handleSearchNavKey(key) { + t.Errorf("%q was consumed with no search and no history", key) + } + } + + // "/" always opens the prompt. + if !m.handleSearchNavKey("/") { + t.Error(`"/" should always be consumed`) + } + m.clearSearch() + + // A query that matched nothing still owns n/N, so that the keys + // do not suddenly scroll instead. + m.setSearchQuery("nomatch") + if len(m.searchOrder) != 0 { + t.Fatalf("expected no matches, got %d", len(m.searchOrder)) + } + for _, key := range []string{"n", "N"} { + if !m.handleSearchNavKey(key) { + t.Errorf("%q should be consumed by an active search", key) + } + } + + // With history available, n recalls the last query. + m.clearSearch() + m.searchHistory = []string{"foo"} + if !m.handleSearchNavKey("n") { + t.Error(`"n" should be consumed when history can be recalled`) + } + if m.searchText != "foo" { + t.Errorf("searchText = %q, want %q", m.searchText, "foo") + } +} + +// --------------------------------------------------------------- +// Finding and ordering matches +// --------------------------------------------------------------- + +func TestSearchFindsEveryMatchInReadingOrder(t *testing.T) { + m := &Model{viewportDoc: docFromLines( + "foo bar foo baz foo", "no matches here", "another foo here")} + m.setSearchQuery("foo") + + if len(m.searchOrder) != 4 { + t.Fatalf("expected 4 matches, got %d", len(m.searchOrder)) + } + // Three on the first line at distinct offsets, one on the third. + found := m.viewportMatcher.matches + var gotLines, gotStarts []int + for _, f := range found { + gotLines = append(gotLines, f.line) + gotStarts = append(gotStarts, f.start) + } + if want := []int{0, 0, 0, 2}; !reflect.DeepEqual(gotLines, want) { + t.Errorf("matches on lines %v, want %v", gotLines, want) + } + if want := []int{0, 8, 16, 8}; !reflect.DeepEqual(gotStarts, want) { + t.Errorf("matches at offsets %v, want %v", gotStarts, want) + } + if m.searchIdx != 0 { + t.Errorf("a fresh search starts at %d, want 0", m.searchIdx) + } + + // n walks forward and wraps past the end; N walks back and wraps + // past the start. + for _, want := range []int{1, 2, 3, 0} { + m.nextSearchMatch() + if m.searchIdx != want { + t.Fatalf("after n: searchIdx = %d, want %d", m.searchIdx, want) + } + } + for _, want := range []int{3, 2, 1, 0} { + m.prevSearchMatch() + if m.searchIdx != want { + t.Fatalf("after N: searchIdx = %d, want %d", m.searchIdx, want) + } + } + + // Neither key does anything, or panics, with no matches at all. + m.clearSearch() + m.nextSearchMatch() + m.prevSearchMatch() + if m.searchIdx != -1 { + t.Errorf("searchIdx = %d with no matches, want -1", m.searchIdx) + } +} + +func TestSearchFindsTextAcrossAWrap(t *testing.T) { + withColor(t) + + // A long identifier with no spaces to break on, so the wrap falls + // in the middle of it. Searching the rendered rows could never + // match across that break. + line := "ovs_alpha_bravo_charlie_delta_echo_foxtrot_golf_function" + m := searchModelSize(line, "charlie_delta", 25, 20) + + if m.viewportDoc.Len() < 2 { + t.Fatalf("expected the line to wrap, got %d rows", m.viewportDoc.Len()) + } + if len(m.searchOrder) != 1 { + t.Fatalf("expected 1 match across the wrap, got %d", len(m.searchOrder)) + } + match := m.viewportMatcher.matches[0] + if got := string([]rune(line)[match.start:match.end]); got != m.searchText { + t.Errorf("match covers %q, want %q", got, m.searchText) + } + + // The match really has to straddle a row boundary, or this test + // is not testing anything. + boundary := m.viewportDoc.visual[1].off + if match.start >= boundary || match.end <= boundary { + t.Fatalf("match [%d,%d) does not straddle the row break at %d", + match.start, match.end, boundary) + } + + // It must be highlighted on both of the rows it spans. + mt, cur := m.currentMatch(0) + var highlighted []int + for row := 0; row < m.viewportDoc.Len(); row++ { + if renderDocRow(m.viewportDoc, row, mt, cur) != m.viewportDoc.Line(row) { + highlighted = append(highlighted, row) + } + } + if len(highlighted) != 2 { + t.Errorf("match highlighted on rows %v, want exactly 2", highlighted) + } +} + +func TestSearchRescansWhenContentChanges(t *testing.T) { + m := searchModelSize("needle one\nneedle two\nneedle three", "needle", 60, 20) + if len(m.searchOrder) != 3 { + t.Fatalf("expected 3 matches, got %d", len(m.searchOrder)) + } + + // Swapping in shorter content must not leave matches behind that + // point past the end of it. + m.setViewportDoc(searchDoc("needle only", 60)) + if len(m.searchOrder) != 1 { + t.Errorf("expected 1 match after the content changed, got %d", + len(m.searchOrder)) + } + if m.searchIdx < 0 || m.searchIdx >= len(m.searchOrder) { + t.Errorf("searchIdx = %d is out of range", m.searchIdx) + } + checkSearchOrder(t, m) + + // And content with no match at all must clear the cursor. + m.setViewportDoc(searchDoc("nothing here", 60)) + if len(m.searchOrder) != 0 { + t.Errorf("expected no matches, got %d", len(m.searchOrder)) + } + if m.searchIdx != -1 { + t.Errorf("searchIdx = %d, want -1", m.searchIdx) + } +} + +// A pattern can be valid and still have nothing to find. "\c" is all +// flag and no pattern once stripped; "x*" and "^" match at every +// position without covering any text. Neither can be highlighted or +// stepped to, and counting them would bury the real matches. +func TestSearchPatternsThatMatchNothing(t *testing.T) { + for _, tc := range []struct { + pattern string + want int + }{ + {`\c`, 0}, {`\C`, 0}, {`\c\C`, 0}, + {"x*", 0}, {"^", 0}, {"$", 0}, + // "a*" still matches the runs of "a" it can actually cover. + {"a*", 5}, {"alpha", 1}, + } { + t.Run(tc.pattern, func(t *testing.T) { + m := searchModelSize("alpha\nbeta\ngamma\n", tc.pattern, 60, 20) + if got := len(m.searchOrder); got != tc.want { + t.Errorf("pattern %q gave %d matches, want %d", + tc.pattern, got, tc.want) + } + for i, mt := range m.viewportMatcher.matches { + if mt.start == mt.end { + t.Errorf("pattern %q: match %d is zero-width at line %d", + tc.pattern, i, mt.line) + } + } + if tc.want == 0 && m.searchIdx != -1 { + t.Errorf("pattern %q: searchIdx = %d, want -1", + tc.pattern, m.searchIdx) + } + }) + } +} + +func TestSearchSkipsCollapsedHeaderText(t *testing.T) { + // A long recipient list is folded to a few rows with a "N total" + // marker. The addresses that were folded away are not on screen, + // so they must not be findable: a match there could be stepped to + // but never seen. + var addrs []string + for i := 0; i < 20; i++ { + addrs = append(addrs, fmt.Sprintf("person%02d@ipsum.example", i)) + } + addrs = append(addrs, "hidden-needle@ipsum.example") + p := ParsedMbox{Subject: "Lorem ipsum", From: "a@b.example", + Cc: strings.Join(addrs, ", ")} + + headers, body, diff := formatMboxDoc(p, 80, true) + doc := &renderedDoc{} + doc.appendDoc(headers) + doc.appendDoc(body) + doc.appendDoc(diff) + + if strings.Contains(doc.String(), "hidden-needle") { + t.Fatal("fixture is wrong: the address was not folded away") + } + if !strings.Contains(doc.String(), "total (e to expand)") { + t.Fatal("fixture is wrong: the header did not collapse") + } + + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(doc) + m.setSearchQuery("hidden-needle") + if len(m.searchOrder) != 0 { + t.Errorf("found %d matches in folded-away text, want none", + len(m.searchOrder)) + } + + // An address that is still on screen is found as normal, and the + // marker itself stays searchable. + for _, q := range []string{"person00", "to expand"} { + m.setSearchQuery(q) + if len(m.searchOrder) == 0 { + t.Errorf("query %q should still match", q) + continue + } + checkSearchOrder(t, m) + } +} + +// --------------------------------------------------------------- +// Highlighting +// --------------------------------------------------------------- + +func TestSearchHighlightPreservesText(t *testing.T) { + withColor(t) + line := "lorem needle ipsum needle dolor sit amet consectetur needle" + m := searchModelSize(line, "needle", 28, 20) + + mt, cur := m.currentMatch(0) + for row := 0; row < m.viewportDoc.Len(); row++ { + got := ansi.Strip(renderDocRow(m.viewportDoc, row, mt, cur)) + want := ansi.Strip(m.viewportDoc.Line(row)) + if got != want { + t.Errorf("row %d: highlighting changed the text\n got %q\nwant %q", + row, got, want) + } + } +} + +func TestSearchOnlyOneMatchIsCurrent(t *testing.T) { + withColor(t) + m := searchModelSize("needle one\nneedle two\nneedle three", "needle", 60, 20) + if len(m.searchOrder) != 3 { + t.Fatalf("expected 3 matches, got %d", len(m.searchOrder)) + } + + for idx := 0; idx < 3; idx++ { + m.searchIdx = idx + mt, cur := m.currentMatch(0) + if cur == nil { + t.Fatalf("idx %d: no current match", idx) + } + current := 0 + for row := 0; row < m.viewportDoc.Len(); row++ { + if strings.Contains(renderDocRow(m.viewportDoc, row, mt, cur), + searchCurrentStyle.Render("needle")) { + current++ + } + } + if current != 1 { + t.Errorf("idx %d: %d rows show the current style, want 1", idx, current) + } + } +} + +// The prompt draws a block cursor on the rune it sits on. Past the end +// of the query there is no rune to mark, so it takes a space of its +// own; the query text itself must come through untouched either way. +func TestRenderSearchQuery(t *testing.T) { + withColor(t) + for _, tc := range []struct { + name string + text string + cursor int + want string + on string + }{ + {"past the end", "foobar", 6, "foobar ", " "}, + {"inside the query", "foobar", 3, "foobar", "b"}, + {"at the start", "foobar", 0, "foobar", "f"}, + {"empty query", "", 0, " ", " "}, + // Nothing outside the prompt maintains searchCursor, so a + // stale one has to draw oddly rather than panic. + {"cursor past the text clamps", "foo", 99, "foo ", " "}, + {"negative cursor clamps", "foo", -5, "foo", "f"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := renderSearchQuery(tc.text, tc.cursor) + if plain := ansi.Strip(got); plain != tc.want { + t.Errorf("renderSearchQuery(%q, %d) = %q, want %q", + tc.text, tc.cursor, plain, tc.want) + } + if want := searchCursorStyle.Render(tc.on); !strings.Contains(got, want) { + t.Errorf("renderSearchQuery(%q, %d) has no cursor on %q", + tc.text, tc.cursor, tc.on) + } + }) + } +} + +func TestSearchHelpShowsMatchCounter(t *testing.T) { + bright, desc, sep := (&Model{}).helpStyles() + m := searchModel("needle one\nneedle two", "") + + // While typing, the prompt carries the query and the counter. + m.startSearch() + typeSearch(m, "needle") + got := ansi.Strip(m.renderSearchInputHelp(bright, desc, sep)) + for _, want := range []string{"Search: ", "needle ", "[1/2]", "enter", "esc"} { + if !strings.Contains(got, want) { + t.Errorf("input help %q missing %q", got, want) + } + } + + // Stepping forward updates it, and a query matching nothing says so. + m.nextSearchMatch() + if got := m.renderSearchInputHelp(bright, desc, sep); !strings.Contains( + got, "[2/2]") { + t.Errorf("input help after n = %q, want [2/2]", got) + } + m.setSearchQuery("nomatch") + if got := m.renderSearchInputHelp(bright, desc, sep); !strings.Contains( + got, "[no matches]") { + t.Errorf("input help = %q, want [no matches]", got) + } + + // Once committed, the key help carries the count instead. + m.setSearchQuery("needle") + m.commitSearch() + if got := m.renderSearchKeyHelp(bright, desc, sep); !strings.Contains( + got, "n/N") || !strings.Contains(got, "[2]") { + t.Errorf("key help %q should offer n/N and the count", got) + } + m.clearSearch() + if got := m.renderSearchKeyHelp(bright, desc, sep); strings.Contains( + got, "n/N") { + t.Errorf("key help %q should not offer n/N with no search", got) + } +} + +func TestSearchHelpDropsNavWhenContentLosesMatches(t *testing.T) { + // A search can end up with no matches because the content changed + // under it. It stays active, so that going back restores the + // highlights, but n/N must not be advertised meanwhile. + bright, desc, sep := (&Model{}).helpStyles() + m := searchModel("alpha needle\n", "needle") + if !strings.Contains(m.renderSearchKeyHelp(bright, desc, sep), "n/N") { + t.Fatal("expected n/N to be offered while matches exist") + } + + m.setViewportDoc(searchDoc("nothing of interest\n", 80)) + if got := m.renderSearchKeyHelp(bright, desc, sep); strings.Contains( + got, "n/N") { + t.Errorf("help = %q, should not offer n/N with no matches", got) + } + if m.searchText != "needle" { + t.Errorf("searchText = %q, the search itself should stay active", + m.searchText) + } + + // Going back to matching content brings the offer back. + m.setViewportDoc(searchDoc("alpha needle\n", 80)) + if !strings.Contains(m.renderSearchKeyHelp(bright, desc, sep), "n/N") { + t.Error("n/N should be offered again once matches return") + } +} + +// --------------------------------------------------------------- +// Rescanning when the content underneath changes +// --------------------------------------------------------------- + +func TestSearchRescansOnCommentSwitch(t *testing.T) { + m, _ := testModelWithDB(t) + m.width, m.height = 80, 24 + m.viewMode = viewPatch + m.viewingPatchID = 100 + m.viewComments = []CommentInfo{ + {ID: 1, Submitter: "Lorem", Date: "2026-01-01", + Subject: "Re: test", Content: "needle needle needle"}, + {ID: 2, Submitter: "Dolor", Date: "2026-01-02", + Subject: "Re: test", Content: "nothing of interest"}, + } + m.viewCommentIdx = 0 + m.switchToComment() + m.setSearchQuery("needle") + if len(m.searchOrder) != 3 { + t.Fatalf("expected 3 matches in the first comment, got %d", + len(m.searchOrder)) + } + + // Moving to a comment with no match must leave nothing behind. + m.nextComment() + if len(m.searchOrder) != 0 || m.searchIdx != -1 { + t.Errorf("after the switch: %d matches idx %d, want 0 and -1", + len(m.searchOrder), m.searchIdx) + } + + // And moving back finds them again. + m.prevComment() + if len(m.searchOrder) != 3 { + t.Errorf("expected 3 matches again, got %d", len(m.searchOrder)) + } + checkSearchOrder(t, m) +} + +func TestSearchRescansOnExpandToggle(t *testing.T) { + m, _ := testModelWithDB(t) + m.width, m.height = 80, 24 + m.viewMode = viewPatch + m.viewingPatchID = 100 + m.viewComments = []CommentInfo{{ + ID: 1, Submitter: "Lorem", Date: "2026-01-01", Subject: "Re: test", + Content: "reply\n" + strings.Repeat("> needle in a quoted line\n", 20), + }} + m.viewCommentIdx = 0 + m.viewExpanded = false + m.switchToComment() + m.setSearchQuery("needle") + collapsed := len(m.searchOrder) + + // "e" expands the collapsed quote block, bringing more text into + // view and more matches with it. + m.handleViewportKey(keyRunes("e")) + if expanded := len(m.searchOrder); expanded <= collapsed { + t.Errorf("expanding should reveal more matches: %d -> %d", + collapsed, expanded) + } + if m.searchIdx < 0 || m.searchIdx >= len(m.searchOrder) { + t.Errorf("searchIdx = %d is out of range after expanding", m.searchIdx) + } + checkSearchOrder(t, m) +} + +// Resizing re-renders the content at the new width through the real +// window-message path, and the matches have to follow it: the same +// count, on the same source lines, still on screen. +func TestSearchFollowsTheMatchAcrossAResize(t *testing.T) { + m, d := testModelWithDB(t) + d.UpdatePatchDetail(100, + strings.Repeat("lorem ipsum needle dolor sit amet consectetur\n", 40)+ + "the last needle is down here\n", "", "", "") + m.viewMode = viewPatch + m.viewingPatchID = 100 + m.viewCommentIdx = -1 + m.width, m.height = 100, 24 + m.refreshViewport() + + m.setSearchQuery("needle") + before := len(m.searchOrder) + if before < 2 { + t.Fatalf("expected several matches, got %d", before) + } + m.searchIdx = before - 1 + m.scrollToMatch(m.searchIdx) + wantLine := m.viewportMatcher.matches[m.searchOrder[m.searchIdx].idx].line + assertMatchOnScreen(t, m, "before resize") + + for _, width := range []int{40, 160} { + result, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + m = result.(*Model) + what := fmt.Sprintf("at width %d", width) + + if got := len(m.searchOrder); got != before { + t.Errorf("%s: match count changed %d -> %d", what, before, got) + } + if m.searchIdx != before-1 { + t.Errorf("%s: cursor moved to %d, want %d", + what, m.searchIdx, before-1) + } + got := m.viewportMatcher.matches[m.searchOrder[m.searchIdx].idx].line + if got != wantLine { + t.Errorf("%s: cursor is on source line %d, want %d", + what, got, wantLine) + } + checkSearchOrder(t, m) + assertMatchOnScreen(t, m, what) + } +} + +// --------------------------------------------------------------- +// Compare view +// --------------------------------------------------------------- + +func TestCompareSearchKeepsTheTwoSidesApart(t *testing.T) { + skipIfNoDiff(t) + + // "shared" is on both sides, "onlyleft" on one. The two columns + // are indexed separately, so an identical row cannot produce one + // match that appears to belong to both. + left := ParsedMbox{Subject: "Lorem", From: "a@b.example", + Body: "shared line\nonlyleft line\nshared tail\n"} + right := ParsedMbox{Subject: "Lorem", From: "a@b.example", + Body: "shared line\nshared tail\n"} + m := buildTestCompare(t, left, right, 80) + m.viewMode = viewCompare + + m.setSearchQuery("onlyleft") + if len(m.searchOrder) != 1 { + t.Fatalf("expected 1 match, got %d", len(m.searchOrder)) + } + if m.searchOrder[0].target != 0 { + t.Errorf("match attributed to column %d, want 0", m.searchOrder[0].target) + } + if mt, cur := m.currentMatch(1); cur != nil || len(mt.matches) != 0 { + t.Error("right column should have no matches for a left-only term") + } + + // A term on both sides gives one match per column, not one match + // counted twice. + m.setSearchQuery("shared") + leftN := len(m.compare[0].matcher.matches) + rightN := len(m.compare[1].matcher.matches) + if leftN == 0 || rightN == 0 { + t.Fatalf("expected matches on both sides, got %d and %d", leftN, rightN) + } + if len(m.searchOrder) != leftN+rightN { + t.Errorf("searchOrder has %d refs, want %d+%d", + len(m.searchOrder), leftN, rightN) + } + + // Exactly one match is current at a time and belongs to one column + // only, and n walks down the screen rather than down one column. + prev := -1 + for idx, ref := range m.searchOrder { + m.searchIdx = idx + _, lc := m.currentMatch(0) + _, rc := m.currentMatch(1) + if (lc != nil) == (rc != nil) { + t.Errorf("idx %d: current match is in %v/%v columns", + idx, lc != nil, rc != nil) + } + if ref.row < prev { + t.Errorf("match %d is on row %d, after row %d", idx, ref.row, prev) + } + prev = ref.row + } + checkSearchOrder(t, m) +} + +func TestCompareSearchMatchesAtEndOfLine(t *testing.T) { + skipIfNoDiff(t) + withColor(t) + + // The compare view pads each column out to its width after the row + // is rendered. A match at the very end of a line must not be + // disturbed by that padding. + left := ParsedMbox{Subject: "Lorem", From: "a@b.example", + Body: "ends with needle\n"} + right := ParsedMbox{Subject: "Lorem", From: "a@b.example", + Body: "different text\n"} + m := buildTestCompare(t, left, right, 80) + m.viewMode = viewCompare + + m.setSearchQuery("needle$") + if len(m.searchOrder) != 1 { + t.Fatalf("expected 1 match, got %d", len(m.searchOrder)) + } + + row := m.searchOrder[0].row + mt, cur := m.currentMatch(0) + rendered := renderDocRow(m.compare[0].doc, row, mt, cur) + if !strings.Contains(rendered, searchCurrentStyle.Render("needle")) { + t.Errorf("row %d did not highlight the trailing match: %q", row, rendered) + } + got, want := ansi.Strip(rendered), ansi.Strip(m.compare[0].doc.Line(row)) + if got != want { + t.Errorf("highlighting changed the text\n got %q\nwant %q", got, want) + } +} + +// Both of the compare view's rebuild paths — a resize and cycling one +// column to another patch — drop the documents an active search was +// scanning, so it has to be re-run against the new ones. +func TestCompareSearchRescansOnRebuild(t *testing.T) { + skipIfNoDiff(t) + + t.Run("resize", func(t *testing.T) { + body := strings.Repeat( + "lorem ipsum needle dolor sit amet consectetur adipiscing\n", 5) + left := ParsedMbox{Subject: "Lorem", From: "a@b.example", Body: body} + right := ParsedMbox{Subject: "Lorem", From: "a@b.example", + Body: body + "an extra needle line\n"} + m := buildTestCompare(t, left, right, 120) + m.viewMode = viewCompare + m.Status = status.NewRegistry(nil) + + m.setSearchQuery("needle") + before := len(m.searchOrder) + if before == 0 { + t.Fatal("expected matches before the resize") + } + checkSearchOrder(t, m) + + result, _ := m.Update(tea.WindowSizeMsg{Width: 50, Height: 24}) + m = result.(*Model) + if got := len(m.searchOrder); got != before { + t.Errorf("match count changed across resize: %d -> %d", before, got) + } + checkSearchOrder(t, m) + }) + + t.Run("patch cycle", func(t *testing.T) { + m := &Model{width: 100, height: 24, viewMode: viewCompare} + m.Status = status.NewRegistry(nil) + for i := range m.compare { + m.compare[i] = compareSide{idx: 0, patches: []comparePatch{ + {id: 1, position: 1, parsed: ParsedMbox{Subject: "Lorem", + From: "a@b.example", Body: "a needle here\n"}}, + {id: 2, position: 2, parsed: ParsedMbox{Subject: "Dolor", + From: "a@b.example", Body: "no match at all\n"}}, + }} + } + m.buildCompareContent() + + m.setSearchQuery("needle") + if len(m.searchOrder) != 2 { + t.Fatalf("expected a match in each column, got %d", len(m.searchOrder)) + } + checkSearchOrder(t, m) + + // Move the left column to the patch with no match. + m.compare[0].idx = 1 + m.buildCompareContent() + if len(m.searchOrder) != 1 { + t.Errorf("expected 1 match after the cycle, got %d", len(m.searchOrder)) + } + for _, ref := range m.searchOrder { + if ref.target != 1 { + t.Errorf("remaining match is in column %d, want 1", ref.target) + } + } + checkSearchOrder(t, m) + }) +} + +// --------------------------------------------------------------- +// Where a search starts and what it scrolls to +// --------------------------------------------------------------- + +func TestSearchScrollsToTheMatch(t *testing.T) { + // A match far down a long document has to bring the viewport with + // it rather than just moving an index, and a match near the bottom + // cannot be centred without scrolling past the end. + for _, tc := range []struct { + name string + filler int + matchAt int + wantZero bool + }{ + {"match far down", 200, 150, false}, + {"match on the last line", 61, 60, false}, + {"document shorter than the window", 3, 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + m := searchModelSize("", "", 80, 24) + m.setViewportDoc(searchDocWithMatchesAt(tc.filler, tc.matchAt)) + m.setSearchQuery("needle") + if len(m.searchOrder) != 1 { + t.Fatalf("expected 1 match, got %d", len(m.searchOrder)) + } + assertMatchOnScreen(t, m, tc.name) + + visible := m.viewportVisibleLines() + maxOffset := max(m.viewportDoc.Len()-visible, 0) + if m.viewportOffset > maxOffset { + t.Errorf("viewportOffset = %d, past the last page at %d", + m.viewportOffset, maxOffset) + } + if tc.wantZero && m.viewportOffset != 0 { + t.Errorf("viewportOffset = %d for a short document, want 0", + m.viewportOffset) + } + }) + } +} + +// A search begun partway down a document picks up from the top of the +// window rather than yanking the reader back to the first match in the +// file, and wraps to the top once it runs off the end. +func TestSearchStartsFromViewportNotTopOfFile(t *testing.T) { + for _, tc := range []struct { + name string + offset int + wantIdx int + wantRow int + thenRows []int // rows n steps through from there + }{ + {name: "at top of file", offset: 0, wantIdx: 0, wantRow: 5}, + {name: "above the first match", offset: 3, wantIdx: 0, wantRow: 5}, + {name: "exactly on a match", offset: 15, wantIdx: 1, wantRow: 15}, + {name: "between matches", offset: 10, wantIdx: 1, wantRow: 15, + // Stepping on still walks the whole document and wraps, so + // nothing above the starting point is lost. + thenRows: []int{25, 5, 15}}, + {name: "past the last match wraps", offset: 26, wantIdx: 0, wantRow: 5}, + } { + t.Run(tc.name, func(t *testing.T) { + m := searchModelSize("", "", 80, 10) + m.setViewportDoc(searchDocWithMatchesAt(40, 5, 15, 25)) + m.viewportOffset = tc.offset + m.startSearch() + typeSearch(m, "needle") + + if len(m.searchOrder) != 3 { + t.Fatalf("searchOrder has %d matches, want 3", len(m.searchOrder)) + } + if m.searchIdx != tc.wantIdx { + t.Errorf("searchIdx = %d, want %d", m.searchIdx, tc.wantIdx) + } + if row := m.searchOrder[m.searchIdx].row; row != tc.wantRow { + t.Errorf("landed on row %d, want %d", row, tc.wantRow) + } + for i, want := range tc.thenRows { + m.nextSearchMatch() + if row := m.searchOrder[m.searchIdx].row; row != want { + t.Errorf("step %d landed on row %d, want %d", i+1, row, want) + } + } + checkSearchOrder(t, m) + }) + } +} + +// The starting point is fixed when the prompt opens, so refining a +// query does not let it drift down the document as the view scrolls +// along with the incremental search. +func TestSearchStartPointDoesNotDriftWhileTyping(t *testing.T) { + m := searchModelSize("", "", 80, 10) + m.setViewportDoc(searchDocWithMatchesAt(40, 5, 15, 25)) + m.viewportOffset = 10 + m.startSearch() + typeSearch(m, "needle") + if row := m.searchOrder[m.searchIdx].row; row != 15 { + t.Fatalf("first match is row %d, want 15", row) + } + + // Backspacing to a shorter query that still matches the same three + // lines has to come back to the same place, not to wherever the + // view scrolled to in the meantime. + pressKeys(m, "backspace", "backspace", "backspace") + if m.searchText != "nee" { + t.Fatalf("searchText = %q, want %q", m.searchText, "nee") + } + if row := m.searchOrder[m.searchIdx].row; row != 15 { + t.Errorf("after backspacing, landed on row %d, want 15", row) + } +} + +// Searching is incremental, so the viewport moves while the query is +// typed. Anything that abandons the search has to undo that. +func TestSearchCancelRestoresTheViewport(t *testing.T) { + for _, tc := range []struct { + name string + query string + finish string + wantRestore bool + }{ + {"esc puts it back", "needle", "esc", true}, + {"a committed query that matched nothing puts it back", + "needleXX", "enter", true}, + {"a committed match stays where it landed", "needle", "enter", false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := searchModelSize("", "", 80, 24) + m.setViewportDoc(searchDocWithMatchesAt(200, 150)) + m.viewportOffset = 20 + + m.startSearch() + typeSearch(m, tc.query) + if tc.wantRestore && m.viewportOffset == 20 { + t.Fatal("expected the incremental search to scroll away from 20") + } + + pressKeys(m, tc.finish) + if restored := m.viewportOffset == 20; restored != tc.wantRestore { + t.Errorf("viewportOffset = %d, restored = %v, want %v", + m.viewportOffset, restored, tc.wantRestore) + } + }) + } +} + +// --------------------------------------------------------------- +// History +// --------------------------------------------------------------- + +func TestSearchHistoryCommitsRecallsAndBrowses(t *testing.T) { + m := &Model{viewportDoc: docFromLines("foo bar foo", "hello", "another foo")} + for _, q := range []string{"foo", "bar", "hello"} { + m.startSearch() + typeSearch(m, q) + m.commitSearch() + } + if want := []string{"hello", "bar", "foo"}; !reflect.DeepEqual( + m.searchHistory, want) { + t.Fatalf("history = %v, want %v", m.searchHistory, want) + } + + // Re-running an old query moves it to the front rather than adding + // a duplicate. + m.startSearch() + typeSearch(m, "foo") + m.commitSearch() + if want := []string{"foo", "hello", "bar"}; !reflect.DeepEqual( + m.searchHistory, want) { + t.Errorf("after a repeat, history = %v, want %v", m.searchHistory, want) + } + + // Up and down browse it, and the cursor lands at the end of each + // recalled query so that typing continues it. + m.startSearch() + typeSearch(m, "dr") + for _, tc := range []struct{ key, want string }{ + {"up", "foo"}, + {"up", "hello"}, + {"down", "foo"}, + // Back past the newest entry restores the stashed draft. + {"down", "dr"}, + } { + pressKeys(m, tc.key) + if m.searchText != tc.want { + t.Fatalf("%s gave searchText %q, want %q", + tc.key, m.searchText, tc.want) + } + if want := runeLen(m.searchText); m.searchCursor != want { + t.Errorf("searchCursor after %s = %d, want %d (end of %q)", + tc.key, m.searchCursor, want, m.searchText) + } + } + if m.searchHistoryIdx != -1 { + t.Errorf("searchHistoryIdx = %d back at the draft, want -1", + m.searchHistoryIdx) + } + + // n with no search running recalls the newest query; N recalls it + // but lands on the last match instead of the first. Browsing + // scrolled the view, and a recall resumes from where the view is, + // so put it back at the top to pin the first/last distinction. + m.clearSearch() + m.viewportOffset = 0 + m.recallLastSearch(false) + if m.searchText != "foo" || len(m.searchOrder) != 3 || m.searchIdx != 0 { + t.Errorf("n recalled %q with %d matches at idx %d, want foo, 3, 0", + m.searchText, len(m.searchOrder), m.searchIdx) + } + m.clearSearch() + m.recallLastSearch(true) + if m.searchIdx != 2 { + t.Errorf("N landed on match %d, want the last one", m.searchIdx) + } +} + +func TestSearchHistoryIsDeduplicatedAndCapped(t *testing.T) { + d := searchHistoryDB(t) + m := &Model{db: d} + m.loadSearchHistory() + for i := 0; i < searchHistoryMax+20; i++ { + m.startSearch() + typeSearch(m, fmt.Sprintf("q%d", i)) + m.commitSearch() + } + // Re-running an old query moves it to the front. + rerun := fmt.Sprintf("q%d", searchHistoryMax+5) + m.startSearch() + typeSearch(m, rerun) + m.commitSearch() + + restored := &Model{db: d} + restored.loadSearchHistory() + + if len(restored.searchHistory) != searchHistoryMax { + t.Errorf("restored %d entries, want %d", + len(restored.searchHistory), searchHistoryMax) + } + if got := restored.searchHistory[0]; got != rerun { + t.Errorf("newest entry = %q, want the re-run query %q", got, rerun) + } + seen := map[string]bool{} + for _, q := range restored.searchHistory { + if seen[q] { + t.Errorf("duplicate entry %q in restored history", q) + } + seen[q] = true + } + if seen["q0"] { + t.Error("oldest entry should have been dropped by the cap") + } +} + +func TestSearchHistoryPersists(t *testing.T) { + for _, tc := range []struct { + name string + seed string + noDB bool + queries []string + want []string + }{ + {name: "across sessions", + queries: []string{"alpha", "beta", "gamma"}, + want: []string{"gamma", "beta", "alpha"}}, + {name: "an unreadable stored value is discarded, not fatal", + seed: "{not json", queries: []string{"alpha"}, + want: []string{"alpha"}}, + // The views are usable without a database, in tests and in + // NewModelWithData; history just stays in memory. + {name: "without a database", noDB: true, + queries: []string{"alpha"}, want: []string{"alpha"}}, + {name: "awkward queries round-trip", + queries: []string{`needle`, `"quoted"`, `back\slash`, + `a|b.*[0-9]+`, `ünïcödé ☕`, ` leading and trailing `, + "tab\tseparated"}, + want: []string{"tab\tseparated", ` leading and trailing `, + `ünïcödé ☕`, `a|b.*[0-9]+`, `back\slash`, `"quoted"`, + `needle`}}, + } { + t.Run(tc.name, func(t *testing.T) { + m := &Model{} + if !tc.noDB { + d := searchHistoryDB(t) + if tc.seed != "" { + d.SetSyncState(searchHistoryKey, tc.seed) + } + m.db = d + } + m.loadSearchHistory() + if len(m.searchHistory) != 0 { + t.Fatalf("a fresh model should have no history, got %v", + m.searchHistory) + } + for _, q := range tc.queries { + m.searchText = q + m.commitSearch() + } + + // A second model over the same database, as a relaunch. + restored := &Model{db: m.db} + restored.loadSearchHistory() + if tc.noDB { + restored = m + } + if !reflect.DeepEqual(restored.searchHistory, tc.want) { + t.Errorf("restored = %q\n want = %q", + restored.searchHistory, tc.want) + } + }) + } +} + +func TestSearchHistorySurvivesDatabaseReopen(t *testing.T) { + // The in-memory tests share one connection. This is the real case: + // quit leadlight and start it again in the same directory. + path := filepath.Join(t.TempDir(), "leadlight.db") + + first, err := db.Open(path) + if err != nil { + t.Fatal(err) + } + m := &Model{db: first} + m.loadSearchHistory() + for _, q := range []string{"alpha", "beta"} { + m.startSearch() + typeSearch(m, q) + m.commitSearch() + } + first.Close() + + second, err := db.Open(path) + if err != nil { + t.Fatal(err) + } + defer second.Close() + + restored := &Model{db: second} + restored.loadSearchHistory() + if want := []string{"beta", "alpha"}; !reflect.DeepEqual( + restored.searchHistory, want) { + t.Errorf("after reopen history = %v, want %v", + restored.searchHistory, want) + } +} + +// --------------------------------------------------------------- +// Queries that find nothing +// --------------------------------------------------------------- + +func TestSearchWithNoMatchesIsDroppedOnCommit(t *testing.T) { + bright, desc, sep := (&Model{}).helpStyles() + m := searchModel("alpha\nbeta\n", "") + m.startSearch() + typeSearch(m, "nomatch") + + // While still typing, the prompt reports the failure. + if got := m.renderSearchInputHelp(bright, desc, sep); !strings.Contains( + got, "[no matches]") { + t.Errorf("prompt = %q, want it to report no matches", got) + } + + // Committing it leaves the search entirely: there is nothing to + // step through, so staying in it would offer an n and N that do + // nothing and make q take two presses to leave. + pressKeys(m, "enter") + if m.searchInput || m.searchText != "" || m.searchRegex != nil || + m.searchIdx != -1 { + t.Errorf("search not dropped: input=%v text=%q regex=%v idx=%d", + m.searchInput, m.searchText, m.searchRegex != nil, m.searchIdx) + } + + // So the help bar offers only "/", with no n/N and no counter. + got := m.renderSearchKeyHelp(bright, desc, sep) + if strings.Contains(got, "n/N") || strings.Contains(got, "[0]") || + !strings.Contains(got, "/") { + t.Errorf("help = %q, want only / with no n/N and no zero counter", got) + } + + // Dropping the search must not lose the query: a typo is exactly + // what you want to recall and correct. + if want := []string{"nomatch"}; !reflect.DeepEqual(m.searchHistory, want) { + t.Errorf("history = %v, want %v", m.searchHistory, want) + } + + // And q leaves the view at once, with no search to clear first. + m.handleViewportKey(keyRunes("q")) + if m.viewMode != viewTable { + t.Errorf("q did not leave the view, mode = %d", m.viewMode) + } +} + +func TestSearchRecallWithNoMatchesIsDropped(t *testing.T) { + // n recalls the last query; if it no longer matches anything in + // this content, it should not leave a dead search behind either. + m := patchViewWithSearch("needle", "alpha needle") + if len(m.searchOrder) != 1 { + t.Fatalf("expected the search to match, got %d", len(m.searchOrder)) + } + + // Move to content the query does not match, then clear and recall. + m.setViewportDoc(searchDoc("nothing of interest\n", 80)) + m.clearSearch() + m.handleSearchNavKey("n") + + if m.searchText != "" { + t.Errorf("searchText = %q, want a failed recall to leave nothing", + m.searchText) + } + if m.searchIdx != -1 { + t.Errorf("searchIdx = %d, want -1", m.searchIdx) + } +} + +func TestSearchReportsNoResultsOnCommit(t *testing.T) { + for _, tc := range []struct { + name string + query string + want string + }{ + // Nothing is said while the query is still being typed: every + // prefix of a real query is briefly matchless, and the prompt + // already shows a live "[no matches]" hint. + {"a query that finds nothing", "zzz", "no results"}, + // A pattern that will not compile is reported the same way, + // rather than quietly searching for its characters. + {"a pattern that will not compile", "*.c", "no results"}, + {"a query that matches", "alpha", ""}, + {"an empty query is not a failed search", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + m := searchModel("alpha\nbeta\n", "") + m.Status = status.NewRegistry(nil) + + m.startSearch() + typeSearch(m, tc.query) + if msg, _ := m.Status.Active(); msg != "" { + t.Errorf("status while typing = %q, want nothing", msg) + } + + pressKeys(m, "enter") + msg, _ := m.Status.Active() + if tc.want == "" { + if msg != "" { + t.Errorf("status = %q, want nothing", msg) + } + return + } + if !strings.Contains(msg, tc.want) { + t.Errorf("status = %q, want it to contain %q", msg, tc.want) + } + if !strings.Contains(msg, tc.query) { + t.Errorf("status = %q, want the query quoted back", msg) + } + if len(m.searchOrder) != 0 || m.searchText != "" { + t.Error("a failed search should leave nothing behind") + } + // It has to clear on its own; the registry prunes on read + // and the model schedules a redraw at NextExpiry. + if d := m.Status.NextExpiry(); d <= 0 || d > searchReportFor { + t.Errorf("NextExpiry = %v, want it within %v", d, searchReportFor) + } + }) + } +} diff --git a/tui/styles.go b/tui/styles.go index debb191..8aa8d68 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -54,6 +54,7 @@ var ( helpDimStyle lipgloss.Style statusStyle lipgloss.Style normalOptionStyle lipgloss.Style + searchCursorStyle lipgloss.Style highlightedOptionStyle lipgloss.Style optionNumStyle lipgloss.Style cellHighlightStyle lipgloss.Style @@ -87,6 +88,8 @@ var ( compareSepStyle lipgloss.Style compareAddBg lipgloss.Color compareDelBg lipgloss.Color + searchCurrentStyle lipgloss.Style + searchOtherStyle lipgloss.Style ) type gradientEntry struct{ bg, fg string } @@ -139,6 +142,11 @@ func buildStyles(t *theme) { helpDimStyle = fg(t.HelpDimFg) statusStyle = fg(t.StatusFg) normalOptionStyle = fg(t.NormalOptionFg) + // Underline rather than a colour of its own, so the cursor is the + // same mark the filter prompt appends as a literal "_": past the + // end of the query it underlines a space and looks identical, + // and inside the query it underlines the rune it sits on. + searchCursorStyle = normalOptionStyle.Underline(true) highlightedOptionStyle = hlStyle optionNumStyle = fg(t.OptionNumFg) cellHighlightStyle = hlStyle @@ -172,6 +180,13 @@ func buildStyles(t *theme) { compareSepStyle = fg(t.CompareSepFg) compareAddBg = lipgloss.Color(t.CompareAddBg) compareDelBg = lipgloss.Color(t.CompareDelBg) + searchCurrentStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(t.SearchCurrentBg)). + Foreground(lipgloss.Color(t.SearchCurrentFg)). + Bold(true) + searchOtherStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(t.SearchOtherBg)). + Foreground(lipgloss.Color(t.SearchOtherFg)) bgStyles = map[string]*cachedBgStyle{} gradientPalettes = map[string][256]gradientEntry{} diff --git a/tui/theme.go b/tui/theme.go index ffd4db4..150326c 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -45,6 +45,11 @@ type theme struct { CompareSepFg string // separator line between compare columns CompareAddBg string // background tint for lines added on this side CompareDelBg string // background tint for lines removed (only on other side) + + SearchCurrentBg string // background for the active search match + SearchCurrentFg string + SearchOtherBg string // background for non-active search matches + SearchOtherFg string } // Dark theme FG colors inspired by the Catppuccin Mocha palette. @@ -118,6 +123,11 @@ var darkTheme = theme{ CompareSepFg: "#585b70", // catppuccin Surface2 CompareAddBg: "#1a2e1a", // subtle dark green CompareDelBg: "#2e1a1a", // subtle dark red + + SearchCurrentBg: "#fdfd96", // bright pastel yellow + SearchCurrentFg: "#000000", + SearchOtherBg: "#5e5820", // muted olive + SearchOtherFg: "#f0e8c0", } var lightTheme = theme{ @@ -189,4 +199,9 @@ var lightTheme = theme{ CompareSepFg: "#9ca0b0", // catppuccin Overlay0 CompareAddBg: "#d0f0d0", // light green CompareDelBg: "#f0d0d0", // light rose + + SearchCurrentBg: "#ffbb30", // warm orange-yellow + SearchCurrentFg: "#000000", + SearchOtherBg: "#fffa69", // yellow + SearchOtherFg: "#333000", }