From 3b8b884edbf6ef259625d0d6960f2ac5343f06b8 Mon Sep 17 00:00:00 2001 From: Rosemarie O'Riorden Date: Sun, 11 Oct 2026 23:18:25 -0400 Subject: [PATCH 1/4] tui: mbox: Expand tabs before wrapping body lines. The diff path expands tabs and then wraps, but the body path wrapped first and expanded each segment afterwards. A tab grows to the next 8-column stop, so wrapping an unexpanded line undercounts its width: the styled result can come out wider than the terminal, which then wraps it a second time and throws off the viewport's line accounting. A tabbed body line at width 40 overflowed by up to 12 columns. Expand on the whole line before wrapping, in both the mbox body and the comment content, and drop the per-segment expansion. Expanding a segment was wrong on its own terms too, since tab stops are counted from the start of the line, not from the start of a segment. Assisted-by: Claude Opus 5 Signed-off-by: Rosemarie O'Riorden --- tui/mbox.go | 8 ++++++-- tui/mbox_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/tui/mbox.go b/tui/mbox.go index 4c73964..5b00fdd 100644 --- a/tui/mbox.go +++ b/tui/mbox.go @@ -310,6 +310,7 @@ func FormatMboxParts( bb.WriteByte('\n') cleaned := replaceControlChars(p.Body) for _, line := range strings.Split(cleaned, "\n") { + line = expandTabs(line, 8) // widths must be final before wrapping depth := quoteDepth(line) for _, wl := range wrapLine(line, width) { writeStyledLine(&bb, wl, depth) @@ -379,9 +380,9 @@ func comparePaddingMarker() string { // the diff background tint to all wrapped segments. func styleBodyLine(line string, width int, kind diffLineKind) []string { var result []string + line = expandTabs(line, 8) // widths must be final before wrapping depth := quoteDepth(line) for _, wl := range wrapLine(line, width) { - wl = expandTabs(wl, 8) var styled string if depth > 0 { if depth%2 == 1 { @@ -532,8 +533,10 @@ func expandTabs(s string, tabWidth int) string { return buf.String() } +// writeStyledLine styles one already-wrapped segment. Callers expand +// tabs on the whole line before wrapping it, so that wrapping sees the +// final widths. 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)) @@ -1058,6 +1061,7 @@ func FormatComment(c CommentInfo, width int, collapse bool, sourceLines map[stri contentLines = collapseQuotedBlocks(contentLines) } for _, line := range contentLines { + line = expandTabs(line, 8) // widths must be final before wrapping depth := quoteDepth(line) for _, wl := range wrapLine(line, width) { writeStyledLine(&b, wl, depth) diff --git a/tui/mbox_test.go b/tui/mbox_test.go index ffb5aad..43437e8 100644 --- a/tui/mbox_test.go +++ b/tui/mbox_test.go @@ -1006,6 +1006,43 @@ func TestFormatMbox_LineWidths(t *testing.T) { } } +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 From 2b4a8041685889cb84e92d4df727e19ec336fb82 Mon Sep 17 00:00:00 2001 From: Rosemarie O'Riorden Date: Mon, 12 Oct 2026 10:15:35 -0400 Subject: [PATCH 2/4] tui: Render views through a structured document. The patch and compare views stored their content as a []string of finished terminal rows: already wrapped to the width and with the lipgloss escape sequences baked into the text. That is a lossy representation. Once a row has been built there is no way to tell which source line it came from, where in that line it starts, or which parts of it are text as opposed to colour, so anything that wants to reason about the content has no choice but to parse the rendering back apart. This is groundwork for the search that follows. That search has to match against the text as it was written rather than as it was drawn, and has to know which row on screen shows a given position in it; neither question can be answered from a finished row. Nothing here changes what ends up on the terminal. Introduce renderedDoc, which keeps both halves: the logical text, the lines as they read before wrapping and without styling, and the visual rows they were laid out into, along with the mapping between them. A row is a list of (text, style) spans rather than a string, and spans that are chrome the formatter invented rather than source text, such as the wrap arrow or a column's padding, are marked as such. Escape sequences are now produced at the very last moment, for the rows actually on screen, instead of for the whole document up front. The formatters are rewritten to build documents. FormatMbox, FormatMboxParts, FormatComment and FormatChecks keep their signatures and become flatteners over them, so the existing output assertions go on covering them unchanged. writeHeader, writeStyledLine, styleBodyLine and styleDiffLine fold into appendHeaderDoc, appendBodyLineDoc and appendDiffLineDoc, which both views now share. In the compare view the per-row diff tint moves from a parallel []diffLineKind onto the row itself, which retires compareDiffKind and padToEqualWithKinds. wrapLine and wrapHeaderValue gain variants that report the rune offset each segment starts at. The offsets are exact because neither wrapper rewrites text, only cuts it: a segment is always a contiguous run of its source line. That holds for header values too, where the comma a folded line ends with is the source's own separator and only the space after it is dropped. Verified against a corpus of 180 header, body, diff, comment and check renderings at five widths with collapsing both ways: the visible text is byte for byte what it was. Two styling differences are deliberate. A continuation arrow is emitted as its own span, so a wrapped line carries one more escape pair than before, with the same colours and the same text; the arrow has to be separable from the source text for anything to reason about which columns hold what. And a body line that literally starts with the arrow character is no longer mistaken for a continuation and styled as one, now that continuations are structural rather than guessed at from a prefix. Two latent bugs turned up while doing this and are fixed here. wrapHeaderValue's overflow path sliced by byte offset, so a multi-byte character in a long header could be cut in half and the lines it produced were narrower than the terminal in proportion to how much non-ASCII they held. And documents do not carry the trailing empty row that splitLines left behind, which removes one blank line from the end of the patch viewport and of the fallback compare rendering. The renderer reads rows straight out of the document with Line, and setViewportDoc is a plain setter. Both are the shapes the search needs later: it wraps the row rendering to apply a highlight, and hooks setViewportDoc so that the matches are recomputed whenever the content is replaced. Neither hook exists yet because there is nothing to hook. Assisted-by: Claude Opus 5 Signed-off-by: Rosemarie O'Riorden --- tui/compare.go | 184 +++++--------- tui/compare_test.go | 126 ++++++++++ tui/doc.go | 227 +++++++++++++++++ tui/doc_test.go | 191 +++++++++++++++ tui/keys.go | 74 +++--- tui/mbox.go | 579 +++++++++++++++++++++++++------------------- tui/mbox_test.go | 245 +++++++++++++++++++ tui/model.go | 21 +- tui/model_test.go | 102 ++++---- tui/render.go | 36 +-- tui/styles.go | 9 + tui/theme.go | 15 ++ 12 files changed, 1324 insertions(+), 485 deletions(-) create mode 100644 tui/doc.go create mode 100644 tui/doc_test.go diff --git a/tui/compare.go b/tui/compare.go index 012d05f..f55507e 100644 --- a/tui/compare.go +++ b/tui/compare.go @@ -19,8 +19,7 @@ const ( ) type compareCacheEntry struct { - lines [2][]string - kinds [2][]diffLineKind + docs [2]*renderedDoc } func (m *Model) buildCompareContent() { @@ -42,8 +41,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 +49,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)"), {}, {}, } } - padToEqual(&parts[0][0], &parts[1][0]) - padToEqual(&parts[0][1], &parts[1][1]) - padToEqual(&parts[0][2], &parts[1][2]) + for s := 0; s < 3; s++ { + padDocsToEqual(parts[0][s], parts[1][s]) + } 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 +82,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 +106,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 +125,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 +145,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 +161,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 +242,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 +320,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 +348,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..bf1f6c4 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -392,16 +392,32 @@ func (m *Model) handleSelectorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// exitPatchView returns to the patch list, dropping everything that +// only describes the item being viewed. +func (m *Model) exitPatchView() { + 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.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() + + switch key { + case "q": + m.exitPatchView() + case "esc": + m.exitPatchView() case "e": m.viewExpanded = !m.viewExpanded m.switchToComment() @@ -655,7 +671,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 +681,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 +690,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 +753,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 +778,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 +808,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 +885,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) } @@ -984,9 +1002,11 @@ func (m *Model) handleCompareKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() switch key { - case "q", "esc": - m.viewMode = viewTable - m.compareCount = 0 + case "q": + m.exitCompareView() + return m, nil + case "esc": + m.exitCompareView() return m, nil case "up", "k": @@ -1059,7 +1079,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 5b00fdd..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,75 +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") { - line = expandTabs(line, 8) // widths must be final before wrapping - 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 { @@ -370,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 - line = expandTabs(line, 8) // widths must be final before wrapping - depth := quoteDepth(line) - for _, wl := range wrapLine(line, width) { - 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 } @@ -533,26 +584,6 @@ func expandTabs(s string, tabWidth int) string { return buf.String() } -// writeStyledLine styles one already-wrapped segment. Callers expand -// tabs on the whole line before wrapping it, so that wrapping sees the -// final widths. -func writeStyledLine(b *strings.Builder, line string, depth int) { - 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 @@ -635,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) @@ -662,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 { @@ -724,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. @@ -762,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) { @@ -781,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...) } } } @@ -813,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) @@ -831,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 { @@ -1007,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 @@ -1032,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) @@ -1061,14 +1135,9 @@ func FormatComment(c CommentInfo, width int, collapse bool, sourceLines map[stri contentLines = collapseQuotedBlocks(contentLines) } for _, line := range contentLines { - line = expandTabs(line, 8) // widths must be final before wrapping - 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 43437e8..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,217 @@ 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 @@ -2446,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..7b3d0e6 100644 --- a/tui/model.go +++ b/tui/model.go @@ -142,8 +142,7 @@ type compareMark struct { type compareSide struct { mark compareMark - lines []string - kinds []diffLineKind + doc *renderedDoc patches []comparePatch cover *ParsedMbox idx int // -1 = cover, 0+ = patch index @@ -221,7 +220,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 @@ -716,6 +715,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// setViewportDoc replaces the content of the patch view. It exists +// so that there is one place to hook anything that has to follow the +// content around; the search added later rescans from here. +func (m *Model) setViewportDoc(d *renderedDoc) { + m.viewportDoc = d +} + func (m *Model) refreshViewport() { if m.db == nil { return @@ -724,9 +730,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 +801,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..5cc7899 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,10 @@ func (m *Model) renderPatchView() string { end = total } - // Assemble exactly `visible` lines separated by newlines. lines := make([]string, visible) for i := 0; i < end-start; i++ { - lines[i] = m.viewportLines[start+i] + lineIdx := start + i + lines[i] = m.viewportDoc.Line(lineIdx) } body := strings.Join(lines, "\n") @@ -85,6 +85,7 @@ 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 { @@ -93,6 +94,7 @@ func (m *Model) renderPatchView() string { hb.WriteString(helpKey(bright, desc, "e", "expand")) } } + if len(m.viewComments) > 0 { var hb strings.Builder hb.WriteString(sep.Render(" ")) @@ -160,18 +162,11 @@ func (m *Model) renderCompareView() string { 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)) + left := comparePadLine(m.compare[0].doc.Line(idx), leftWidth, + m.compare[0].doc.kindAt(idx)) + right := comparePadLine(m.compare[1].doc.Line(idx), rightWidth, + m.compare[1].doc.kindAt(idx)) + lines[i] = left + sep + right } body := strings.Join(lines, "\n") @@ -183,6 +178,8 @@ func (m *Model) renderCompareView() string { } bright, desc, sepS := m.helpStyles() + + var helpText string var hb strings.Builder labelL := comparePositionLabel( m.compare[0].idx, len(m.compare[0].patches), m.compare[0].ver) @@ -205,7 +202,7 @@ func (m *Model) renderCompareView() string { hb.WriteString(helpSepStr(sepS)) hb.WriteString(helpKey(bright, desc, "esc", "back")) hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - helpText := hb.String() + helpText = hb.String() var statusLine string msg, spinning := m.Status.Active() @@ -228,13 +225,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/styles.go b/tui/styles.go index debb191..d4c284a 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -87,6 +87,8 @@ var ( compareSepStyle lipgloss.Style compareAddBg lipgloss.Color compareDelBg lipgloss.Color + searchCurrentStyle lipgloss.Style + searchOtherStyle lipgloss.Style ) type gradientEntry struct{ bg, fg string } @@ -172,6 +174,13 @@ func buildStyles(t *theme) { compareSepStyle = fg(t.CompareSepFg) compareAddBg = lipgloss.Color(t.CompareAddBg) compareDelBg = lipgloss.Color(t.CompareDelBg) + searchCurrentStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(t.SearchCurrentBg)). + Foreground(lipgloss.Color(t.SearchCurrentFg)). + Bold(true) + searchOtherStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(t.SearchOtherBg)). + Foreground(lipgloss.Color(t.SearchOtherFg)) bgStyles = map[string]*cachedBgStyle{} gradientPalettes = map[string][256]gradientEntry{} diff --git a/tui/theme.go b/tui/theme.go index ffd4db4..150326c 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -45,6 +45,11 @@ type theme struct { CompareSepFg string // separator line between compare columns CompareAddBg string // background tint for lines added on this side CompareDelBg string // background tint for lines removed (only on other side) + + SearchCurrentBg string // background for the active search match + SearchCurrentFg string + SearchOtherBg string // background for non-active search matches + SearchOtherFg string } // Dark theme FG colors inspired by the Catppuccin Mocha palette. @@ -118,6 +123,11 @@ var darkTheme = theme{ CompareSepFg: "#585b70", // catppuccin Surface2 CompareAddBg: "#1a2e1a", // subtle dark green CompareDelBg: "#2e1a1a", // subtle dark red + + SearchCurrentBg: "#fdfd96", // bright pastel yellow + SearchCurrentFg: "#000000", + SearchOtherBg: "#5e5820", // muted olive + SearchOtherFg: "#f0e8c0", } var lightTheme = theme{ @@ -189,4 +199,9 @@ var lightTheme = theme{ CompareSepFg: "#9ca0b0", // catppuccin Overlay0 CompareAddBg: "#d0f0d0", // light green CompareDelBg: "#f0d0d0", // light rose + + SearchCurrentBg: "#ffbb30", // warm orange-yellow + SearchCurrentFg: "#000000", + SearchOtherBg: "#fffa69", // yellow + SearchOtherFg: "#333000", } From 09575a56d1f39ef4f578b015b17805d9acdd96e5 Mon Sep 17 00:00:00 2001 From: Rosemarie O'Riorden Date: Mon, 12 Oct 2026 11:49:33 -0400 Subject: [PATCH 3/4] tui: search: Add vim-like expression search /. Search within the patch and compare views the way vim does: "/" opens a prompt, the query is a regular expression, and n and N step forward and backward through the matches with the current one highlighted differently from the rest. The \c and \C flags force case-insensitive and case-sensitive matching and, as in vim, may appear anywhere in the pattern with the last one winning. A pattern that is not a valid regular expression is matched literally, so an unfinished one still finds the text that was typed. Match colours differ between the dark and light themes. The search runs against the logical text of a document, not against the rows on screen. A match is recorded as a rune range inside an unwrapped source line, which has two consequences worth stating. A term is found even when a wrap falls in the middle of it, and it is then highlighted on both of the rows it spans. And the coordinates do not move when the window is resized, because re-wrapping changes the rows and not the source. Highlighting happens as a row is rendered, by giving part of a span a different style. Nothing inspects or edits an escape sequence, and the compare view applies the highlight before the column padding, so a match at the end of a line is not thrown off by the padding after it. Each document gets its own matcher: one for the patch viewport and one for each compare column. The columns therefore do not share a row index, and a match belongs to exactly one of them, so only that column lights up. searchOrder merges the matches of every column into reading order, down the screen and left column before right, which is what n and N walk; the view mode is consulted in exactly one place, searchTargets, which answers which documents are being searched. The matches are recomputed whenever the content changes. Every assignment to the patch document goes through setViewportDoc and buildCompareContent defers the rescan, so a document cannot be shown beside matches that were found in a previous one. That is what keeps the highlighting correct across a resize, a comment switch, a sync or an expand toggle, rather than remembering to re-run the search at each of those call sites. Matches are indexed by source line, so rendering a row is a map lookup rather than a scan of the whole match list. A query that finds nothing is dropped rather than left active, since there would be nothing to step through or highlight, and the status line says so for a few seconds. While the query is still being typed the prompt reports "[no matches]" instead; saying it out loud on every keystroke would fire on almost every prefix of a real query. A pattern that is not a valid regular expression is treated the same way: no matches, and the message. It is tempting to fall back to searching for the pattern as literal text, but that answers a question the user did not ask and does it silently, so "/*.c" looks simply absent rather than rejected. Glob syntax is not regular expression syntax and is not accepted as such; ".c" files are found with /\.c and not with /*.c, exactly as in vim. n and N are offered in the help bar only when there is something to step to, and fall through to the view's own bindings when there is no search at all. The prompt takes any printable rune, including non-ASCII, and backspace removes a whole rune rather than a byte. Searching is also how q and esc differ in these views. esc leaves outright. q drops an active search on the first press and leaves on the second, so that a search can be dismissed without losing your place; when nothing matched there is no search to dismiss and q leaves at once. Two things this touches rather than adds. The patch and compare renderers stop reading rows straight out of the document and go through renderDocRow instead, which is the same thing plus the highlight. And setViewportDoc, which until now only assigned the field, gains the rescan. Both hooks had nowhere to attach before there was a search; neither replaces anything that was wrong. Assisted-by: Claude Opus 5 Signed-off-by: Rosemarie O'Riorden --- status/status.go | 1 + tui/compare.go | 4 + tui/keys.go | 109 ++- tui/model.go | 34 +- tui/render.go | 146 ++-- tui/search.go | 439 ++++++++++++ tui/search_test.go | 1610 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 2274 insertions(+), 69 deletions(-) create mode 100644 tui/search.go create mode 100644 tui/search_test.go 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 f55507e..dd2834e 100644 --- a/tui/compare.go +++ b/tui/compare.go @@ -23,6 +23,10 @@ type compareCacheEntry struct { } 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 diff --git a/tui/keys.go b/tui/keys.go index bf1f6c4..d1a1ffd 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "time" + "unicode" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" @@ -23,7 +25,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 +36,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,9 +394,85 @@ 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. + if msg.Alt { + return m, nil + } + switch msg.Type { + case tea.KeyEnter: + m.commitSearch() + case tea.KeyEsc: + m.cancelSearch() + case tea.KeyBackspace: + if m.searchText == "" { + m.cancelSearch() + break + } + _, size := utf8.DecodeLastRuneInString(m.searchText) + m.searchText = m.searchText[:len(m.searchText)-size] + m.updateSearchMatches() + 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 + b.WriteString(m.searchText) + added := 0 + for _, r := range msg.Runes { + if unicode.IsPrint(r) { + b.WriteRune(r) + added++ + } + } + if added == 0 { + break + } + m.searchText = b.String() + m.updateSearchMatches() + } + 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. + 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 @@ -406,6 +484,7 @@ func (m *Model) exitPatchView() { // exitCompareView returns to the patch list and unmarks both sides. func (m *Model) exitCompareView() { + m.clearSearch() m.viewMode = viewTable m.compareCount = 0 } @@ -413,8 +492,21 @@ func (m *Model) exitCompareView() { func (m *Model) handleViewportKey(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": + // 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() @@ -1001,8 +1093,21 @@ 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": + // 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": diff --git a/tui/model.go b/tui/model.go index 7b3d0e6..e37b739 100644 --- a/tui/model.go +++ b/tui/model.go @@ -6,6 +6,7 @@ package tui import ( "fmt" "log" + "regexp" "strconv" "strings" "time" @@ -143,12 +144,21 @@ type compareMark struct { type compareSide struct { mark compareMark 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, @@ -232,6 +242,18 @@ 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. + searchInput bool + searchText string + searchRegex *regexp.Regexp + viewportMatcher searchMatcher + searchOrder []matchRef // every match, in reading order + searchIdx int // index into searchOrder, -1 when none + searchOrigin int // viewport offset when the prompt opened + logConsole bool logFocused bool LogBuf *LogBuffer @@ -695,6 +717,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() @@ -705,6 +730,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 @@ -715,11 +741,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// setViewportDoc replaces the content of the patch view. It exists -// so that there is one place to hook anything that has to follow the -// content around; the search added later rescans from here. +// 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() { diff --git a/tui/render.go b/tui/render.go index 5cc7899..448dc55 100644 --- a/tui/render.go +++ b/tui/render.go @@ -69,10 +69,11 @@ func (m *Model) renderPatchView() string { end = total } + mt, cur := m.currentMatch(0) lines := make([]string, visible) for i := 0; i < end-start; i++ { lineIdx := start + i - lines[i] = m.viewportDoc.Line(lineIdx) + lines[i] = renderDocRow(m.viewportDoc, lineIdx, mt, cur) } body := strings.Join(lines, "\n") @@ -86,48 +87,54 @@ func (m *Model) renderPatchView() string { 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 m.searchInput { + status = m.renderSearchInputHelp(bright, desc, sep) + } else { + 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 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(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 { + 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, "tab", "log")) + 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 @@ -159,13 +166,19 @@ 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 := comparePadLine(m.compare[0].doc.Line(idx), leftWidth, - m.compare[0].doc.kindAt(idx)) - right := comparePadLine(m.compare[1].doc.Line(idx), rightWidth, - m.compare[1].doc.kindAt(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 } @@ -180,29 +193,34 @@ func (m *Model) renderCompareView() string { bright, desc, sepS := m.helpStyles() var helpText string - var hb strings.Builder - labelL := comparePositionLabel( - m.compare[0].idx, len(m.compare[0].patches), m.compare[0].ver) - labelR := comparePositionLabel( - m.compare[1].idx, len(m.compare[1].patches), m.compare[1].ver) - hb.WriteString(desc.Render(labelL + " vs " + labelR)) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(bright.Render("←/→")) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(helpKey(bright, desc, "1/2+←/→", "single")) - hb.WriteString(helpSepStr(sepS)) - if m.viewExpanded { - hb.WriteString(helpKey(bright, desc, "e", "collapse")) + if m.searchInput { + helpText = m.renderSearchInputHelp(bright, desc, sepS) } else { - hb.WriteString(helpKey(bright, desc, "e", "expand")) - } - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(bright.Render("↑/↓") + - sepS.Render(" ") + bright.Render("pgup/dn")) - hb.WriteString(helpSepStr(sepS)) - hb.WriteString(helpKey(bright, desc, "esc", "back")) - hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) - helpText = hb.String() + var hb strings.Builder + labelL := comparePositionLabel( + m.compare[0].idx, len(m.compare[0].patches), m.compare[0].ver) + labelR := comparePositionLabel( + m.compare[1].idx, len(m.compare[1].patches), m.compare[1].ver) + hb.WriteString(desc.Render(labelL + " vs " + labelR)) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(bright.Render("←/→")) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(helpKey(bright, desc, "1/2+←/→", "single")) + hb.WriteString(helpSepStr(sepS)) + if m.viewExpanded { + hb.WriteString(helpKey(bright, desc, "e", "collapse")) + } else { + hb.WriteString(helpKey(bright, desc, "e", "expand")) + } + hb.WriteString(m.renderSearchKeyHelp(bright, desc, sepS)) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(bright.Render("↑/↓") + + sepS.Render(" ") + bright.Render("pgup/dn")) + hb.WriteString(helpSepStr(sepS)) + hb.WriteString(helpKey(bright, desc, "esc", "back")) + hb.WriteString(desc.Render(fmt.Sprintf(" %d%%", pct))) + helpText = hb.String() + } var statusLine string msg, spinning := m.Status.Active() diff --git a/tui/search.go b/tui/search.go new file mode 100644 index 0000000..597cd16 --- /dev/null +++ b/tui/search.go @@ -0,0 +1,439 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" + + "leadlight/status" +) + +func (m *Model) startSearch() { + m.searchInput = true + m.searchOrigin = m.viewportOffset + m.searchText = "" + 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.searchRegex = nil + m.searchOrder = nil + m.searchIdx = -1 + m.rescanSearch() +} + +// 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 + 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. +func (m *Model) dropSearchIfUnmatched() bool { + if len(m.searchOrder) > 0 { + return false + } + m.clearSearch() + return true +} + +// 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 + } +} + +// updateSearchMatches recompiles the query and runs it over the +// current view, leaving the cursor on the first match. +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.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 +} + +func (m *Model) renderSearchInputHelp(bright, desc, sep lipgloss.Style) string { + var hb strings.Builder + hb.WriteString(desc.Render("Search: ")) + hb.WriteString(normalOptionStyle.Render(m.searchText + "_")) + if len(m.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..c933bcc --- /dev/null +++ b/tui/search_test.go @@ -0,0 +1,1610 @@ +// Copyright 2026 Leadlight Authors +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" + + "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) + } +} + +func TestSearchInputAcceptsMultiByteRunes(t *testing.T) { + m := &Model{} + m.startSearch() + + typeSearch(m, "café ☕") + if m.searchText != "café ☕" { + t.Errorf("searchText = %q, want %q", m.searchText, "café ☕") + } + + // Backspace must remove a whole rune, not a single byte. + bs := tea.KeyMsg{Type: tea.KeyBackspace} + m.handleSearchInputKey(bs) + if m.searchText != "café " { + t.Errorf("after backspace searchText = %q, want %q", m.searchText, "café ") + } + m.handleSearchInputKey(bs) + m.handleSearchInputKey(bs) + if m.searchText != "caf" { + t.Errorf("after backspaces searchText = %q, want %q", m.searchText, "caf") + } +} + +func TestSearchInputPasteDropsControlChars(t *testing.T) { + m := &Model{} + m.startSearch() + + paste := tea.KeyMsg{ + Type: tea.KeyRunes, + Paste: true, + Runes: []rune("foo\nbär\tbaz"), + } + m.handleSearchInputKey(paste) + + if want := "foobärbaz"; m.searchText != want { + t.Errorf("searchText = %q, want %q", m.searchText, want) + } +} + +func TestSearchInputIgnoresAltAndControlKeys(t *testing.T) { + m := &Model{} + m.startSearch() + typeSearch(m, "foo") + + for _, msg := range []tea.KeyMsg{ + {Type: tea.KeyRunes, Runes: []rune{'x'}, Alt: true}, + {Type: tea.KeyCtrlA}, + {Type: tea.KeyHome}, + } { + m.handleSearchInputKey(msg) + } + + if m.searchText != "foo" { + t.Errorf("searchText = %q, want %q", m.searchText, "foo") + } +} + +func TestMultipleOccurrencesOnSameLine(t *testing.T) { + m := &Model{ + viewportDoc: docFromLines( + "foo bar foo baz foo", + "no matches here", + "another foo here", + ), + } + + // Search for "foo" + m.searchText = "foo" + m.updateSearchMatches() + + // Should find 4 total matches: 3 on line 0, 1 on line 2 + if len(m.searchOrder) != 4 { + t.Errorf("Expected 4 matches, got %d", len(m.searchOrder)) + } + + found := m.viewportMatcher.matches + + // Check that the first 3 matches are on line 0 + for i := 0; i < 3; i++ { + if found[i].line != 0 { + t.Errorf("Match %d should be on line 0, got line %d", i, found[i].line) + } + } + + // Check that they have different positions + if found[0].start == found[1].start { + t.Error("First and second matches should have different positions") + } + if found[1].start == found[2].start { + t.Error("Second and third matches should have different positions") + } + + // Fourth match should be on line 2 + if found[3].line != 2 { + t.Errorf("Match 3 should be on line 2, got line %d", found[3].line) + } + + // Current index should be 0 + if m.searchIdx != 0 { + t.Errorf("Current index should be 0, got %d", m.searchIdx) + } + + // Navigate through all matches + m.nextSearchMatch() + if m.searchIdx != 1 { + t.Errorf("After next, index should be 1, got %d", m.searchIdx) + } + + m.nextSearchMatch() + if m.searchIdx != 2 { + t.Errorf("After next, index should be 2, got %d", m.searchIdx) + } + + m.nextSearchMatch() + if m.searchIdx != 3 { + t.Errorf("After next, index should be 3, got %d", m.searchIdx) + } + + // Should wrap around + m.nextSearchMatch() + if m.searchIdx != 0 { + t.Errorf("After wrapping, index should be 0, got %d", m.searchIdx) + } +} + +func 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.searchText = "nomatch" + m.updateSearchMatches() + 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) + } + } + +} + +func TestExtractCaseFlags(t *testing.T) { + tests := []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}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clean, ci := extractCaseFlags(tt.pattern) + if clean != tt.wantClean { + t.Errorf("extractCaseFlags(%q) clean = %q, want %q", + tt.pattern, clean, tt.wantClean) + } + if ci != tt.wantCI { + t.Errorf("extractCaseFlags(%q) caseInsensitive = %v, want %v", + tt.pattern, ci, tt.wantCI) + } + }) + } +} + +func TestParseVimSearchPattern(t *testing.T) { + tests := []struct { + name string + pattern string + testString string + shouldMatch bool + wantErr bool + }{ + { + name: "simple literal", + pattern: "foo", + testString: "this is foo bar", + shouldMatch: true, + }, + { + name: "case sensitive by default", + pattern: "Foo", + testString: "this is foo bar", + shouldMatch: false, + }, + { + name: "case insensitive with \\c", + pattern: `Foo\c`, + testString: "this is foo bar", + shouldMatch: true, + }, + { + name: "case insensitive with \\c at start", + pattern: `\cFoo`, + testString: "this is FOO bar", + shouldMatch: true, + }, + { + name: "alternation foo|bar", + pattern: "foo|bar", + testString: "this is bar", + shouldMatch: true, + }, + { + name: "alternation foo|bar matches first", + pattern: "foo|bar", + testString: "this is foo", + shouldMatch: true, + }, + { + name: "case insensitive alternation", + pattern: `\cfoo|bar`, + testString: "this is BAR", + shouldMatch: true, + }, + { + name: "regex character class", + pattern: "[0-9]+", + testString: "version 123", + shouldMatch: true, + }, + { + name: "regex with dot", + pattern: "f.o", + testString: "this is foo bar", + shouldMatch: true, + }, + { + name: "empty pattern", + pattern: "", + testString: "anything", + shouldMatch: false, + }, + { + name: "last case flag wins, \\c after \\C", + pattern: `Foo\C\c`, + testString: "this is foo bar", + shouldMatch: true, + }, + { + name: "last case flag wins, \\C after \\c", + pattern: `Foo\c\C`, + testString: "this is foo bar", + shouldMatch: false, + }, + { + name: "escaped backslash is not a case flag", + pattern: `foo\\cbar`, + testString: `foo\cbar`, + shouldMatch: true, + }, + { + name: "literal space", + pattern: " ", + testString: "a b", + shouldMatch: true, + }, + { + name: "trailing space is significant", + pattern: "foo ", + testString: "a foo bar", + shouldMatch: true, + }, + { + name: "trailing space does not match end of line", + pattern: "foo ", + testString: "ends with foo", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + re, err := parseVimSearchPattern(tt.pattern) + if (err != nil) != tt.wantErr { + t.Errorf("parseVimSearchPattern() error = %v, wantErr %v", err, tt.wantErr) + return + } + if re == nil && tt.pattern != "" { + t.Errorf("parseVimSearchPattern() returned nil regex for pattern %q", tt.pattern) + return + } + if re != nil { + matched := re.MatchString(tt.testString) + if matched != tt.shouldMatch { + t.Errorf("pattern %q against %q: got match=%v, want match=%v", + tt.pattern, tt.testString, matched, tt.shouldMatch) + } + } + }) + } +} + +// 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 +} + +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" + const width = 25 + m := &Model{width: width, height: 20} + m.setViewportDoc(searchDoc(line, width)) + + if m.viewportDoc.Len() < 2 { + t.Fatalf("expected the line to wrap, got %d rows", m.viewportDoc.Len()) + } + + m.searchText = "charlie_delta" + m.updateSearchMatches() + + 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 TestSearchSurvivesResize(t *testing.T) { + body := strings.Repeat( + "lorem ipsum needle dolor sit amet consectetur adipiscing\n", 8) + + m := &Model{width: 80, height: 20} + m.setViewportDoc(searchDoc(body, 80)) + m.searchText = "needle" + m.updateSearchMatches() + + before := len(m.searchOrder) + if before != 8 { + t.Fatalf("expected 8 matches, got %d", before) + } + m.searchIdx = 3 + wantLine := m.viewportMatcher.matches[m.searchOrder[3].idx].line + + // Re-lay the same text out at a much narrower width. + m.width = 30 + m.setViewportDoc(searchDoc(body, 30)) + + if got := len(m.searchOrder); got != before { + t.Errorf("match count changed across resize: %d -> %d", before, got) + } + if m.searchIdx != 3 { + t.Errorf("cursor moved across resize: %d, want 3", m.searchIdx) + } + gotLine := m.viewportMatcher.matches[m.searchOrder[3].idx].line + if gotLine != wantLine { + t.Errorf("cursor is on source line %d, want %d", gotLine, wantLine) + } + checkSearchOrder(t, m) +} + +func TestSearchRescansWhenContentChanges(t *testing.T) { + m := &Model{width: 60, height: 20} + m.setViewportDoc(searchDoc("needle one\nneedle two\nneedle three", 60)) + m.searchText = "needle" + m.updateSearchMatches() + 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)) + } + for _, ref := range m.searchOrder { + if ref.row >= m.viewportDoc.Len() { + t.Errorf("stale row %d past the end of %d rows", + ref.row, m.viewportDoc.Len()) + } + } + if m.searchIdx < 0 || m.searchIdx >= len(m.searchOrder) { + t.Errorf("searchIdx = %d is out of range", m.searchIdx) + } + + // 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) + } +} + +func TestSearchHighlightPreservesText(t *testing.T) { + withColor(t) + line := "lorem needle ipsum needle dolor sit amet consectetur needle" + m := &Model{width: 28, height: 20} + m.setViewportDoc(searchDoc(line, 28)) + m.searchText = "needle" + m.updateSearchMatches() + + 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 := &Model{width: 60, height: 20} + m.setViewportDoc(searchDoc("needle one\nneedle two\nneedle three", 60)) + m.searchText = "needle" + m.updateSearchMatches() + + 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) + } + } +} + +func TestCompareSearchKeepsTheTwoSidesApart(t *testing.T) { + skipIfNoDiff(t) + withColor(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.searchText = "onlyleft" + m.updateSearchMatches() + 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) + } + // The right column must not light up for it. + 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.searchText = "shared" + m.updateSearchMatches() + 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 it belongs to one + // column only. + for idx := 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) + } + } + + // n walks down the screen, left column before right on a row. + prev := -1 + for idx, ref := range m.searchOrder { + if ref.row < prev { + t.Errorf("match %d is on row %d, after row %d", idx, ref.row, prev) + } + prev = ref.row + } +} + +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.searchText = "needle$" + m.updateSearchMatches() + 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) + } +} + +func TestSearchNavigationWrapsBothWays(t *testing.T) { + m := &Model{width: 60, height: 20} + m.setViewportDoc(searchDoc("needle one\nneedle two\nneedle three", 60)) + m.searchText = "needle" + m.updateSearchMatches() + + if len(m.searchOrder) != 3 { + t.Fatalf("expected 3 matches, got %d", len(m.searchOrder)) + } + if m.searchIdx != 0 { + t.Fatalf("a fresh search should start at 0, got %d", m.searchIdx) + } + + // n walks forward and wraps past the end. + for _, want := range []int{1, 2, 0, 1} { + m.nextSearchMatch() + if m.searchIdx != want { + t.Fatalf("after n: searchIdx = %d, want %d", m.searchIdx, want) + } + } + // N walks back and wraps past the start. + for _, want := range []int{0, 2, 1, 0} { + m.prevSearchMatch() + if m.searchIdx != want { + t.Fatalf("after N: searchIdx = %d, want %d", m.searchIdx, want) + } + } + + // Each step moves the match to a different row. + rows := map[int]bool{} + for idx := range m.searchOrder { + rows[m.searchOrder[idx].row] = true + } + if len(rows) != 3 { + t.Errorf("matches land on %d distinct rows, want 3", len(rows)) + } + + // 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 TestSearchNavigationScrollsToTheMatch(t *testing.T) { + // A match far down a long document has to bring the viewport + // with it rather than just moving an index. + var b strings.Builder + for i := 0; i < 200; i++ { + if i == 150 { + b.WriteString("the needle is here\n") + continue + } + b.WriteString(fmt.Sprintf("filler line %d\n", i)) + } + m := &Model{width: 60, height: 24} + m.setViewportDoc(searchDoc(b.String(), 60)) + m.searchText = "needle" + m.updateSearchMatches() + + if len(m.searchOrder) != 1 { + t.Fatalf("expected 1 match, got %d", len(m.searchOrder)) + } + row := m.searchOrder[0].row + visible := m.viewportVisibleLines() + if m.viewportOffset > row || row >= m.viewportOffset+visible { + t.Errorf("match on row %d is outside the visible window [%d,%d)", + row, m.viewportOffset, m.viewportOffset+visible) + } +} + +func TestSearchHelpShowsMatchCounter(t *testing.T) { + bright, desc, sep := (&Model{}).helpStyles() + + m := &Model{width: 80, height: 20} + m.setViewportDoc(searchDoc("needle one\nneedle two", 80)) + + // While typing, with matches. + m.startSearch() + typeSearch(m, "needle") + got := 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 the counter. + 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) + } + + // A query that matches nothing says so. + m.searchText = "nomatch" + m.updateSearchMatches() + 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. + m.searchText = "needle" + m.updateSearchMatches() + m.commitSearch() + got = m.renderSearchKeyHelp(bright, desc, sep) + for _, want := range []string{"/", "n/N", "[2]"} { + if !strings.Contains(got, want) { + t.Errorf("key help %q missing %q", got, want) + } + } + + // With no search active it offers only "/". + m.clearSearch() + got = m.renderSearchKeyHelp(bright, desc, sep) + if strings.Contains(got, "n/N") { + t.Errorf("key help %q should not offer n/N with no search", got) + } +} + +func TestParseVimSearchPatternRejectsInvalidRegex(t *testing.T) { + // The query is a regular expression. 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. + for _, pattern := range []string{ + "foo(", "a[b", "*", `\cFOO(`, "x{2,1}", `a\`, + } { + 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) + } + } + + // A glob is not regex syntax, so it does not find files either. + // "/*.c" is a common reflex; it has to fail rather than appear + // to work by matching the literal characters. + if _, err := parseVimSearchPattern("*.c"); err == nil { + t.Error(`"*.c" should be rejected, not treated as literal text`) + } + // What does work for the same job. + for _, pattern := range []string{`\.c`, `[a-z-]+\.c`, `\.c$`} { + re, err := parseVimSearchPattern(pattern) + if err != nil { + t.Errorf("pattern %q: unexpected error %v", pattern, err) + continue + } + if !re.MatchString("diff --git a/lib/flow.c b/lib/flow.c") { + t.Errorf("pattern %q did not match a .c diff header", pattern) + } + } +} + +// 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(tea.KeyMsg{Type: tea.KeyEnter}) + return m +} + +// 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()) + } + } +} + +func TestPatchViewQClearsSearchThenExits(t *testing.T) { + m := patchViewWithSearch("needle", "a needle here", "nothing") + if len(m.searchOrder) != 1 { + t.Fatalf("expected the search to match, got %d", len(m.searchOrder)) + } + + // First q drops the search but stays in the patch view. + m.handleViewportKey(keyRunes("q")) + if m.viewMode != viewPatch { + t.Errorf("first q left the patch view, mode = %d", m.viewMode) + } + if m.searchText != "" || len(m.searchOrder) != 0 { + t.Errorf("first q did not clear the search: text=%q matches=%d", + m.searchText, len(m.searchOrder)) + } + + // Second q leaves. + m.handleViewportKey(keyRunes("q")) + if m.viewMode != viewTable { + t.Errorf("second q did not leave the patch view, mode = %d", m.viewMode) + } + if m.viewportDoc != nil || m.viewingPatchID != 0 { + t.Error("leaving the view should drop the content it was showing") + } +} + +func TestPatchViewQExitsAtOnceWhenNothingMatched(t *testing.T) { + // A search that found nothing has nothing to clear, so q should + // behave as if no search were running. + m := patchViewWithSearch("nomatch", "a needle here", "nothing") + if len(m.searchOrder) != 0 { + t.Fatalf("expected no matches, got %d", len(m.searchOrder)) + } + + m.handleViewportKey(keyRunes("q")) + if m.viewMode != viewTable { + t.Errorf("q should have left the view at once, mode = %d", m.viewMode) + } +} + +func TestPatchViewEscExitsEvenDuringSearch(t *testing.T) { + m := patchViewWithSearch("needle", "a needle here", "nothing") + if len(m.searchOrder) != 1 { + t.Fatalf("expected the search to match, got %d", len(m.searchOrder)) + } + + m.handleViewportKey(keySpecial("esc")) + if m.viewMode != viewTable { + t.Errorf("esc should leave the view outright, mode = %d", m.viewMode) + } + if m.searchText != "" { + t.Errorf("esc should clear the search, text = %q", m.searchText) + } +} + +func TestSearchPromptEscKeepsThePatchView(t *testing.T) { + // esc at the "/" prompt cancels the query being typed; it must + // not also close the patch view. + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(docFromLines("a needle here")) + m.startSearch() + typeSearch(m, "need") + + m.handleViewportKey(keySpecial("esc")) + if m.viewMode != viewPatch { + t.Errorf("esc at the prompt left the view, mode = %d", m.viewMode) + } + if m.searchInput || m.searchText != "" { + t.Errorf("prompt should be cancelled: input=%v text=%q", + m.searchInput, m.searchText) + } +} + +func TestSearchPromptBackspacePastStartCancels(t *testing.T) { + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(docFromLines("a needle here")) + m.startSearch() + typeSearch(m, "ab") + + bs := tea.KeyMsg{Type: tea.KeyBackspace} + for i := 0; i < 3; i++ { + m.handleViewportKey(bs) + } + if m.searchInput { + t.Error("backspacing past the start should leave the prompt") + } + if m.viewMode != viewPatch { + t.Errorf("it should not leave the patch view, mode = %d", m.viewMode) + } +} + +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(tea.KeyMsg{Type: tea.KeyEnter}) + 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 TestSearchRescansOnWindowResize(t *testing.T) { + // The real resize path, not just a second setViewportDoc: the + // window message re-renders the content at the new width and the + // matches have to follow it. + m, d := testModelWithDB(t) + d.UpdatePatchDetail(100, + strings.Repeat("lorem ipsum needle dolor sit amet consectetur\n", 6), + "", "", "") + + m.viewMode = viewPatch + m.viewingPatchID = 100 + m.viewCommentIdx = -1 + m.width = 100 + m.refreshViewport() + + m.searchText = "needle" + m.updateSearchMatches() + before := len(m.searchOrder) + if before == 0 { + t.Fatal("expected matches before the resize") + } + + result, _ := m.Update(tea.WindowSizeMsg{Width: 40, 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) +} + +func TestSearchRescansOnCommentSwitch(t *testing.T) { + m, _ := testModelWithDB(t) + m.width = 80 + m.height = 24 + m.viewMode = viewPatch + m.viewingPatchID = 100 + m.viewCommentIdx = -1 + 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.searchText = "needle" + m.updateSearchMatches() + 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 { + t.Errorf("expected no matches after the switch, got %d", + len(m.searchOrder)) + } + if m.searchIdx != -1 { + t.Errorf("searchIdx = %d, want -1", 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 = 80 + m.height = 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.searchText = "needle" + m.updateSearchMatches() + collapsed := len(m.searchOrder) + + // "e" expands the collapsed quote block, which brings more of + // the text into view. + m.handleViewportKey(keyRunes("e")) + expanded := len(m.searchOrder) + + if 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) +} + +func keyRunes(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func keySpecial(name string) tea.KeyMsg { + switch name { + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + } + panic("unknown key " + name) +} + +func TestCompareSearchRescansOnResize(t *testing.T) { + skipIfNoDiff(t) + + // Resizing the compare view rebuilds both columns from scratch. + // A search running at the time has to be re-run against them. + 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.searchText = "needle" + m.updateSearchMatches() + 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) +} + +func TestCompareSearchRescansOnPatchCycle(t *testing.T) { + skipIfNoDiff(t) + + // Cycling one column to a different patch also rebuilds the + // documents underneath an active search. + 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.searchText = "needle" + m.updateSearchMatches() + 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) +} + +func TestSearchScrollClampsAtTheEnd(t *testing.T) { + // A match near the bottom cannot be centred without scrolling + // past the end of the document. + var b strings.Builder + for i := 0; i < 60; i++ { + b.WriteString(fmt.Sprintf("filler line %d\n", i)) + } + b.WriteString("the needle is on the last line\n") + + m := &Model{width: 60, height: 24, viewMode: viewPatch} + m.setViewportDoc(searchDoc(b.String(), 60)) + m.searchText = "needle" + m.updateSearchMatches() + + if len(m.searchOrder) != 1 { + t.Fatalf("expected 1 match, got %d", len(m.searchOrder)) + } + visible := m.viewportVisibleLines() + maxOffset := m.viewportDoc.Len() - visible + if m.viewportOffset > maxOffset { + t.Errorf("viewportOffset = %d, past the last page at %d", + m.viewportOffset, maxOffset) + } + // The match still has to be on screen. + row := m.searchOrder[0].row + if m.viewportOffset > row || row >= m.viewportOffset+visible { + t.Errorf("match on row %d is outside the window [%d,%d)", + row, m.viewportOffset, m.viewportOffset+visible) + } + + // A document shorter than the window never scrolls at all. + m.setViewportDoc(searchDoc("a needle here\n", 60)) + m.updateSearchMatches() + if m.viewportOffset != 0 { + t.Errorf("viewportOffset = %d for a short document, want 0", + m.viewportOffset) + } +} + +func TestSearchPatternOfOnlyACaseFlag(t *testing.T) { + // "/\c" leaves nothing to match once the flag is stripped. It + // should behave as an empty query rather than matching every + // position in the document. + m := &Model{width: 60, height: 20, viewMode: viewPatch} + m.setViewportDoc(searchDoc("alpha\nbeta\n", 60)) + + for _, pattern := range []string{`\c`, `\C`, `\c\C`} { + re, err := parseVimSearchPattern(pattern) + if err != nil { + t.Errorf("pattern %q: unexpected error %v", pattern, err) + } + if re != nil { + t.Errorf("pattern %q compiled to %v, want no regex", pattern, re) + } + + m.searchText = pattern + m.updateSearchMatches() + if len(m.searchOrder) != 0 { + t.Errorf("pattern %q produced %d matches, want none", + pattern, len(m.searchOrder)) + } + if m.searchIdx != -1 { + t.Errorf("pattern %q: searchIdx = %d, want -1", + pattern, m.searchIdx) + } + } +} + +func TestSearchWithNoMatchesIsDroppedOnCommit(t *testing.T) { + bright, desc, sep := (&Model{}).helpStyles() + + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(searchDoc("alpha\nbeta\n", 80)) + + 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) + } + + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + + // Committing it leaves the search entirely. + if m.searchInput { + t.Error("prompt should be closed") + } + if m.searchText != "" { + t.Errorf("searchText = %q, want it cleared", m.searchText) + } + if m.searchRegex != nil { + t.Error("searchRegex should be cleared") + } + if m.searchIdx != -1 { + t.Errorf("searchIdx = %d, want -1", 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") { + t.Errorf("help = %q, should not offer n/N with nothing to step to", got) + } + if strings.Contains(got, "[0]") { + t.Errorf("help = %q, should not show a zero counter", got) + } + if !strings.Contains(got, "/") { + t.Errorf("help = %q, should still offer /", got) + } + + // 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 TestSearchHelpDropsNavWhenContentLosesMatches(t *testing.T) { + // A search can also 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 := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(searchDoc("alpha needle\n", 80)) + m.searchText = "needle" + m.updateSearchMatches() + 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)) + got := m.renderSearchKeyHelp(bright, desc, sep) + if 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") + } +} + +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.searchText = "hidden-needle" + m.updateSearchMatches() + + 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.searchText = q + m.updateSearchMatches() + if len(m.searchOrder) == 0 { + t.Errorf("query %q should still match", q) + continue + } + checkSearchOrder(t, m) + } +} + +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") + } +} + +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 := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(docFromLines("nothing of interest")) + m.startSearch() + + m.handleSearchInputKey(tea.KeyMsg{ + Type: tea.KeyRunes, Runes: []rune(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 TestSearchCancelRestoresTheViewport(t *testing.T) { + // Searching is incremental, so the viewport moves while the query + // is typed. Cancelling has to undo that. + var b strings.Builder + for i := 0; i < 200; i++ { + if i == 150 { + b.WriteString("the needle is here\n") + continue + } + b.WriteString(fmt.Sprintf("filler line %d\n", i)) + } + m := &Model{width: 60, height: 24, viewMode: viewPatch} + m.setViewportDoc(searchDoc(b.String(), 60)) + m.viewportOffset = 20 + + m.startSearch() + typeSearch(m, "needle") + if m.viewportOffset == 20 { + t.Fatal("expected the incremental search to scroll away from 20") + } + + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEsc}) + if m.viewportOffset != 20 { + t.Errorf("after esc viewportOffset = %d, want 20", m.viewportOffset) + } + + // Committing a query that matched nothing also puts it back. + m.viewportOffset = 20 + m.startSearch() + typeSearch(m, "needle") + typeSearch(m, "XX") + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + if m.viewportOffset != 20 { + t.Errorf("after a failed search viewportOffset = %d, want 20", + m.viewportOffset) + } + + // But a successful one stays on its match. + m.viewportOffset = 20 + m.startSearch() + typeSearch(m, "needle") + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + if m.viewportOffset == 20 { + t.Error("committing a match should leave the viewport on it") + } +} + +func TestSearchFollowsTheMatchAcrossAResize(t *testing.T) { + m, d := testModelWithDB(t) + d.UpdatePatchDetail(100, + strings.Repeat("lorem ipsum dolor sit amet consectetur adipiscing\n", 40)+ + "the needle is down here\n", "", "", "") + m.viewMode = viewPatch + m.viewingPatchID = 100 + m.viewCommentIdx = -1 + m.width = 100 + m.height = 24 + m.refreshViewport() + + m.searchText = "needle" + m.updateSearchMatches() + if len(m.searchOrder) != 1 { + t.Fatalf("expected 1 match, got %d", len(m.searchOrder)) + } + + onScreen := func(what string) { + t.Helper() + 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) + } + } + onScreen("before resize") + + result, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + m = result.(*Model) + checkSearchOrder(t, m) + onScreen("after narrowing") + + result, _ = m.Update(tea.WindowSizeMsg{Width: 160, Height: 24}) + m = result.(*Model) + checkSearchOrder(t, m) + onScreen("after widening") +} + +func TestSearchIgnoresZeroWidthMatches(t *testing.T) { + // "x*" and "^" match at every position without covering any + // text. They cannot be highlighted or stepped to, and counting + // them would bury whatever real matches there are. + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(searchDoc("alpha\nbeta\ngamma\n", 80)) + + for _, q := range []string{"x*", "^", "$", "a*"} { + m.searchText = q + m.updateSearchMatches() + for i, mt := range m.viewportMatcher.matches { + if mt.start == mt.end { + t.Errorf("query %q: match %d is zero-width at line %d off %d", + q, i, mt.line, mt.start) + } + } + } + + // A pattern whose only matches are empty finds nothing at all. + m.searchText = "x*" + m.updateSearchMatches() + if len(m.searchOrder) != 0 { + t.Errorf(`"x*" gave %d matches, want none`, len(m.searchOrder)) + } + // One that can match real text still does. + m.searchText = "a*" + m.updateSearchMatches() + if len(m.searchOrder) == 0 { + t.Error(`"a*" should still match the runs of "a"`) + } +} + +func TestSearchReportsNoResultsOnCommit(t *testing.T) { + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.Status = status.NewRegistry(nil) + m.setViewportDoc(searchDoc("alpha\nbeta\n", 80)) + + // 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. + m.startSearch() + typeSearch(m, "zzz") + if msg, _ := m.Status.Active(); msg != "" { + t.Errorf("status while typing = %q, want nothing", msg) + } + + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + msg, _ := m.Status.Active() + if !strings.Contains(msg, "no results") { + t.Errorf("status = %q, want it to report no results", msg) + } + if !strings.Contains(msg, "zzz") { + t.Errorf("status = %q, want the query quoted back", msg) + } + + // A pattern that will not compile is reported the same way, + // rather than quietly searching for its characters. + m.Status = status.NewRegistry(nil) + m.startSearch() + typeSearch(m, "*.c") + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + if msg, _ := m.Status.Active(); !strings.Contains(msg, "no results") { + t.Errorf("status for an invalid pattern = %q", msg) + } + if len(m.searchOrder) != 0 || m.searchText != "" { + t.Error("an invalid pattern should leave no search behind") + } + + // A query that matches says nothing at all. + m.Status = status.NewRegistry(nil) + m.startSearch() + typeSearch(m, "alpha") + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + if msg, _ := m.Status.Active(); msg != "" { + t.Errorf("status after a successful search = %q, want nothing", msg) + } + + // Pressing enter on an empty prompt is not a failed search. + m.Status = status.NewRegistry(nil) + m.startSearch() + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + if msg, _ := m.Status.Active(); msg != "" { + t.Errorf("status after an empty query = %q, want nothing", msg) + } +} + +func TestSearchNoResultsMessageExpires(t *testing.T) { + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.Status = status.NewRegistry(nil) + m.setViewportDoc(searchDoc("alpha\n", 80)) + + m.startSearch() + typeSearch(m, "zzz") + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + if msg, _ := m.Status.Active(); msg == "" { + t.Fatal("expected the message to be up") + } + // 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) + } +} From e7a3c099793387764c5ba738e9f20adbac21d1b8 Mon Sep 17 00:00:00 2001 From: Rosemarie O'Riorden Date: Mon, 12 Oct 2026 11:49:58 -0400 Subject: [PATCH 4/4] tui: search: Add search history. Remember the queries that have been run, so that a search can be repeated without retyping it. At the "/" prompt the up and down arrows walk the list, most recent first, the way they do in a shell. A query being typed is stashed on the way into the history and restored on the way back out, so browsing never loses it. Re-running an earlier query moves it to the front rather than adding a second copy of it. Pressing n or N with no search active re-runs the most recent query outright, without going through the prompt. N puts the cursor on the last match rather than the first, since it means "go backwards". If the recalled query no longer matches anything in the content now being viewed it is dropped, the same as a query that is typed and matches nothing. The history is kept in the project database, in the sync_state table that already holds durable state of this kind, so it outlives the session. That also makes it per-project, which is what you want: a query that is useful on one list rarely is on another. The config is not an option, since leadlight only ever reads it and it is shared between projects anyway. Fifty entries are kept. The list is only ever walked one entry at a time, with no way to search within it, so a longer tail would just sit in the database unread. A query that matched nothing is remembered too, since a typo is exactly what you want to recall and correct. A stored value that cannot be decoded is dropped and logged rather than reported, as an unusable history is not worth interrupting startup over; the next committed search overwrites it. Everything still works with no database at all, which is how NewModelWithData and much of the test suite build a model. Assisted-by: Claude Opus 5 Signed-off-by: Rosemarie O'Riorden --- tui/keys.go | 27 +++ tui/model.go | 18 +- tui/search.go | 90 +++++++++- tui/search_test.go | 411 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 538 insertions(+), 8 deletions(-) diff --git a/tui/keys.go b/tui/keys.go index d1a1ffd..c88f26b 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -411,6 +411,29 @@ func (m *Model) handleSearchInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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.searchText = m.searchHistory[m.searchHistoryIdx] + m.updateSearchMatches() + } + case tea.KeyDown: + if m.searchHistoryIdx > 0 { + m.searchHistoryIdx-- + m.searchText = m.searchHistory[m.searchHistoryIdx] + m.updateSearchMatches() + } else if m.searchHistoryIdx == 0 { + m.searchHistoryIdx = -1 + m.searchText = m.searchDraft + m.updateSearchMatches() + } case tea.KeyBackspace: if m.searchText == "" { m.cancelSearch() @@ -418,6 +441,7 @@ func (m *Model) handleSearchInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } _, size := utf8.DecodeLastRuneInString(m.searchText) m.searchText = m.searchText[:len(m.searchText)-size] + m.searchHistoryIdx = -1 m.updateSearchMatches() case tea.KeyRunes, tea.KeySpace: // Typed characters and pasted text both arrive as runes; a @@ -437,6 +461,7 @@ func (m *Model) handleSearchInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { break } m.searchText = b.String() + m.searchHistoryIdx = -1 m.updateSearchMatches() } return m, nil @@ -461,6 +486,8 @@ func (m *Model) handleSearchNavKey(key string) bool { } 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 } diff --git a/tui/model.go b/tui/model.go index e37b739..70970e3 100644 --- a/tui/model.go +++ b/tui/model.go @@ -246,13 +246,16 @@ type Model struct { // 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. - searchInput bool - searchText string - searchRegex *regexp.Regexp - viewportMatcher searchMatcher - searchOrder []matchRef // every match, in reading order - searchIdx int // index into searchOrder, -1 when none - searchOrigin int // viewport offset when the prompt opened + searchInput bool + searchText string + 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 @@ -304,6 +307,7 @@ func NewModel(d *db.DB, states []string, token string) *Model { highlightAnimating: true, cachedRenderedRows: map[int]*seriesRowCache{}, } + m.loadSearchHistory() m.reloadData() return m } diff --git a/tui/search.go b/tui/search.go index 597cd16..157f9a6 100644 --- a/tui/search.go +++ b/tui/search.go @@ -4,7 +4,9 @@ package tui import ( + "encoding/json" "fmt" + "log" "regexp" "sort" "strings" @@ -16,10 +18,60 @@ import ( "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.searchDraft = "" + m.searchHistoryIdx = -1 m.searchRegex = nil m.searchOrder = nil m.searchIdx = -1 @@ -37,6 +89,7 @@ func (m *Model) cancelSearch() { func (m *Model) clearSearch() { m.searchInput = false m.searchText = "" + m.searchDraft = "" m.searchRegex = nil m.searchOrder = nil m.searchIdx = -1 @@ -68,6 +121,21 @@ func (m *Model) reportNoResults(query string) { 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. @@ -82,7 +150,8 @@ func (m *Model) commitSearch() { // 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. +// 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 @@ -91,6 +160,25 @@ func (m *Model) dropSearchIfUnmatched() bool { 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 + } + m.searchText = m.searchHistory[0] + m.updateSearchMatches() + 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 diff --git a/tui/search_test.go b/tui/search_test.go index c933bcc..3d4b41e 100644 --- a/tui/search_test.go +++ b/tui/search_test.go @@ -5,6 +5,8 @@ package tui import ( "fmt" + "path/filepath" + "reflect" "strings" "testing" @@ -13,6 +15,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/muesli/termenv" + "leadlight/db" "leadlight/status" ) @@ -162,6 +165,111 @@ func TestMultipleOccurrencesOnSameLine(t *testing.T) { } } +func TestSearchHistory(t *testing.T) { + m := &Model{ + viewportDoc: docFromLines( + "foo bar baz", + "hello world", + "foo again", + ), + } + + // Commit a few searches + m.searchText = "foo" + m.commitSearch() + m.searchText = "bar" + m.commitSearch() + m.searchText = "hello" + m.commitSearch() + + if len(m.searchHistory) != 3 { + t.Fatalf("Expected 3 history entries, got %d", len(m.searchHistory)) + } + if m.searchHistory[0] != "hello" || m.searchHistory[1] != "bar" || m.searchHistory[2] != "foo" { + t.Errorf("History order wrong: %v", m.searchHistory) + } + + // Duplicate should move to front, not add again + m.searchText = "foo" + m.commitSearch() + if len(m.searchHistory) != 3 { + t.Fatalf("Expected 3 history entries after dedup, got %d", len(m.searchHistory)) + } + if m.searchHistory[0] != "foo" { + t.Errorf("Expected 'foo' at front, got %q", m.searchHistory[0]) + } + + // Start a new search and browse history with up arrow + m.startSearch() + if m.searchHistoryIdx != -1 { + t.Errorf("Expected historyIdx -1 at start, got %d", m.searchHistoryIdx) + } + + // Simulate up arrow — should recall most recent ("foo") + m.searchHistoryIdx = 0 + m.searchText = m.searchHistory[0] + if m.searchText != "foo" { + t.Errorf("Expected 'foo' on first up, got %q", m.searchText) + } + + // Another up — should get "hello" + m.searchHistoryIdx = 1 + m.searchText = m.searchHistory[1] + if m.searchText != "hello" { + t.Errorf("Expected 'hello' on second up, got %q", m.searchText) + } + + // Down arrow — back to "foo" + m.searchHistoryIdx = 0 + m.searchText = m.searchHistory[0] + if m.searchText != "foo" { + t.Errorf("Expected 'foo' on down, got %q", m.searchText) + } +} + +func TestRecallLastSearch(t *testing.T) { + m := &Model{ + viewportDoc: docFromLines( + "foo bar foo", + "no match", + "another foo", + ), + } + + // Search and commit + m.searchText = "foo" + m.updateSearchMatches() + m.commitSearch() + + // Clear search + m.clearSearch() + if len(m.searchOrder) != 0 { + t.Fatal("Expected no matches after clear") + } + + // Recall via n + m.recallLastSearch(false) + if m.searchText != "foo" { + t.Errorf("Expected recalled text 'foo', got %q", m.searchText) + } + if len(m.searchOrder) != 3 { + t.Errorf("Expected 3 matches after recall, got %d", len(m.searchOrder)) + } + if m.searchIdx != 0 { + t.Errorf("n should land on the first match, got %d", m.searchIdx) + } + + // Recall via N goes to the last match instead. + m.clearSearch() + m.recallLastSearch(true) + if len(m.searchOrder) != 3 { + t.Fatalf("Expected 3 matches after recall, got %d", len(m.searchOrder)) + } + if m.searchIdx != 2 { + t.Errorf("N should land on the last match, got %d", m.searchIdx) + } +} + func TestSearchNavKeyFallsThroughWhenIdle(t *testing.T) { m := &Model{viewportDoc: docFromLines("foo bar")} @@ -191,6 +299,74 @@ func TestSearchNavKeyFallsThroughWhenIdle(t *testing.T) { } } + // 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") + } +} + +func TestSearchHistoryKeepsTypedDraft(t *testing.T) { + m := &Model{} + for _, q := range []string{"older", "newer"} { + m.startSearch() + typeSearch(m, q) + m.commitSearch() + } + + up := tea.KeyMsg{Type: tea.KeyUp} + down := tea.KeyMsg{Type: tea.KeyDown} + + m.startSearch() + typeSearch(m, "fo") + + m.handleSearchInputKey(up) + if m.searchText != "newer" { + t.Fatalf("after up searchText = %q, want %q", m.searchText, "newer") + } + m.handleSearchInputKey(up) + if m.searchText != "older" { + t.Fatalf("after second up searchText = %q, want %q", m.searchText, "older") + } + + m.handleSearchInputKey(down) + if m.searchText != "newer" { + t.Fatalf("after down searchText = %q, want %q", m.searchText, "newer") + } + m.handleSearchInputKey(down) + if m.searchText != "fo" { + t.Errorf("returning to the draft gave %q, want %q", m.searchText, "fo") + } + if m.searchHistoryIdx != -1 { + t.Errorf("searchHistoryIdx = %d, want -1", m.searchHistoryIdx) + } +} + +func TestSearchHistoryIsCapped(t *testing.T) { + m := &Model{} + for i := 0; i < searchHistoryMax+10; i++ { + m.startSearch() + typeSearch(m, fmt.Sprintf("q%d", i)) + m.commitSearch() + } + + if len(m.searchHistory) != searchHistoryMax { + t.Errorf("history length = %d, want %d", + len(m.searchHistory), searchHistoryMax) + } + newest := fmt.Sprintf("q%d", searchHistoryMax+9) + if m.searchHistory[0] != newest { + t.Errorf("newest entry = %q, want %q", m.searchHistory[0], newest) + } + for _, h := range m.searchHistory { + if h == "q0" { + t.Error("oldest entry should have been dropped") + } + } } func TestExtractCaseFlags(t *testing.T) { @@ -639,6 +815,192 @@ func TestCompareSearchMatchesAtEndOfLine(t *testing.T) { } } +// 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 +} + +func TestSearchHistoryPersistsAcrossSessions(t *testing.T) { + d := searchHistoryDB(t) + + // First session: run a few searches. + first := &Model{db: d} + first.loadSearchHistory() + if len(first.searchHistory) != 0 { + t.Fatalf("a fresh database should have no history, got %v", + first.searchHistory) + } + for _, q := range []string{"alpha", "beta", "gamma"} { + first.startSearch() + typeSearch(first, q) + first.commitSearch() + } + + // Second session over the same database. + second := &Model{db: d} + second.loadSearchHistory() + + want := []string{"gamma", "beta", "alpha"} + if !reflect.DeepEqual(second.searchHistory, want) { + t.Errorf("restored history = %v, want %v", second.searchHistory, want) + } + + // And it is usable, not just present. + second.viewportDoc = docFromLines("alpha beta gamma") + if !second.handleSearchNavKey("n") { + t.Fatal("n should recall the restored history") + } + if second.searchText != "gamma" { + t.Errorf("recalled %q, want %q", second.searchText, "gamma") + } +} + +func TestSearchHistoryPersistsDeduplicatedAndCapped(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 rather than + // adding a duplicate. + m.startSearch() + typeSearch(m, fmt.Sprintf("q%d", searchHistoryMax+5)) + 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 != + fmt.Sprintf("q%d", searchHistoryMax+5) { + t.Errorf("newest entry = %q, want the re-run query", got) + } + 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 TestSearchHistoryHandlesUnusableStoredValue(t *testing.T) { + d := searchHistoryDB(t) + d.SetSyncState(searchHistoryKey, "{not json") + + m := &Model{db: d} + m.loadSearchHistory() + if len(m.searchHistory) != 0 { + t.Errorf("history = %v, want empty after an unreadable value", + m.searchHistory) + } + + // A new search still saves, overwriting the bad value. + m.startSearch() + typeSearch(m, "alpha") + m.commitSearch() + + restored := &Model{db: d} + restored.loadSearchHistory() + if !reflect.DeepEqual(restored.searchHistory, []string{"alpha"}) { + t.Errorf("restored = %v, want [alpha]", restored.searchHistory) + } +} + +func TestSearchHistoryWithoutDatabase(t *testing.T) { + // The compare and patch views are usable without a database in + // tests and in NewModelWithData; history just stays in memory. + m := &Model{} + m.loadSearchHistory() + m.startSearch() + typeSearch(m, "alpha") + m.commitSearch() + + if !reflect.DeepEqual(m.searchHistory, []string{"alpha"}) { + t.Errorf("history = %v, want [alpha]", m.searchHistory) + } +} + +func TestSearchHistoryRoundTripsAwkwardQueries(t *testing.T) { + d := searchHistoryDB(t) + + queries := []string{ + `needle`, + `"quoted"`, + `back\slash`, + `a|b.*[0-9]+`, + `ünïcödé ☕`, + ` leading and trailing `, + `tab separated`, + } + m := &Model{db: d} + m.loadSearchHistory() + for _, q := range queries { + m.searchText = q + m.commitSearch() + } + + restored := &Model{db: d} + restored.loadSearchHistory() + + want := make([]string, len(queries)) + for i, q := range queries { + want[len(queries)-1-i] = q + } + if !reflect.DeepEqual(restored.searchHistory, want) { + t.Errorf("restored = %q\n want = %q", restored.searchHistory, 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) + } +} + func TestSearchNavigationWrapsBothWays(t *testing.T) { m := &Model{width: 60, height: 20} m.setViewportDoc(searchDoc("needle one\nneedle two\nneedle three", 60)) @@ -1280,6 +1642,54 @@ func TestSearchWithNoMatchesIsDroppedOnCommit(t *testing.T) { } } +func TestSearchWithNoMatchesIsStillRemembered(t *testing.T) { + // Dropping the search must not lose the query: a typo is exactly + // what you want to recall and correct. + m := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(searchDoc("alpha\nbeta\n", 80)) + + m.startSearch() + typeSearch(m, "nomatch") + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + + if want := []string{"nomatch"}; !reflect.DeepEqual(m.searchHistory, want) { + t.Errorf("history = %v, want %v", m.searchHistory, want) + } + + // Arrowing up at the prompt brings it back for editing. + m.startSearch() + m.handleSearchInputKey(keySpecial("up")) + if m.searchText != "nomatch" { + t.Errorf("recalled %q, want %q", m.searchText, "nomatch") + } +} + +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 := &Model{width: 80, height: 24, viewMode: viewPatch} + m.setViewportDoc(searchDoc("alpha needle\n", 80)) + m.startSearch() + typeSearch(m, "needle") + m.handleSearchInputKey(tea.KeyMsg{Type: tea.KeyEnter}) + 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 TestSearchHelpDropsNavWhenContentLosesMatches(t *testing.T) { // A search can also end up with no matches because the content // changed under it. It stays active, so that going back restores @@ -1404,6 +1814,7 @@ func TestSearchPromptTakesCoalescedRunes(t *testing.T) { for _, typed := range []string{"up", "down", "enter", "esc", "tab"} { m := &Model{width: 80, height: 24, viewMode: viewPatch} m.setViewportDoc(docFromLines("nothing of interest")) + m.searchHistory = []string{"PREVIOUS"} m.startSearch() m.handleSearchInputKey(tea.KeyMsg{