diff --git a/.claude-plugin/skills/revdiff/references/config.md b/.claude-plugin/skills/revdiff/references/config.md index c20868a4..3d45a383 100644 --- a/.claude-plugin/skills/revdiff/references/config.md +++ b/.claude-plugin/skills/revdiff/references/config.md @@ -39,7 +39,7 @@ All color options accept hex values (`#rrggbb`) and have corresponding `REVDIFF_ | `--color-accent` | Active pane borders and directory names | `#D5895F` | | `--color-border` | Inactive pane borders | `#585858` | | `--color-normal` | File entries and context lines | `#d0d0d0` | -| `--color-muted` | Divider lines and status bar | `#6c6c6c` | +| `--color-muted` | Divider lines and status bar | `#585858` | | `--color-selected-fg` | Selected file text | `#ffffaf` | | `--color-selected-bg` | Selected file background | `#D5895F` | | `--color-annotation` | Annotation text and markers | `#ffd700` | @@ -53,8 +53,10 @@ All color options accept hex values (`#rrggbb`) and have corresponding `REVDIFF_ | `--color-modify-bg` | Modified line background (collapsed mode) | `#3D2E00` | | `--color-tree-bg` | File tree pane background | terminal default | | `--color-diff-bg` | Diff pane background | terminal default | -| `--color-status-fg` | Status bar foreground | `#2D2D2D` | +| `--color-status-fg` | Status bar foreground | `#202020` | | `--color-status-bg` | Status bar background | `#C5794F` | +| `--color-search-fg` | Search match text | `#1a1a1a` | +| `--color-search-bg` | Search match background | `#4a4a00` | ## Chroma Syntax Highlighting Styles diff --git a/.claude-plugin/skills/revdiff/references/usage.md b/.claude-plugin/skills/revdiff/references/usage.md index c2cfb16e..8ca95721 100644 --- a/.claude-plugin/skills/revdiff/references/usage.md +++ b/.claude-plugin/skills/revdiff/references/usage.md @@ -27,9 +27,18 @@ revdiff HEAD~1 # review last commit | `Ctrl+d/Ctrl+u` | Page scroll in file tree and diff pane | | `Home/End` | Jump to first/last item | | `Enter` | Switch to diff pane (tree) / start annotation (diff pane) | -| `n/p` | Next/previous changed file | +| `n/p` | Next/previous changed file (n = next match when search active) | | `[` / `]` | Jump to previous/next change hunk in diff | +**Search:** + +| Key | Action | +|-----|--------| +| `/` | Start search in diff pane | +| `n` | Next search match (overrides next file when search active) | +| `N` | Previous search match | +| `Esc` | Cancel search input / clear search results | + **Annotations:** | Key | Action | diff --git a/CLAUDE.md b/CLAUDE.md index b10229a3..b94de8a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,10 @@ git diff → diff.ParseUnifiedDiff() → []DiffLine when wrap mode is on (`w` toggle, orthogonal to above): wrapContent() splits long lines via ansi.Wrap, continuation lines get `↪` gutter marker, cursorViewportY() sums wrapped line counts + when search is active (`/` to search, `n`/`N` to navigate, `esc` to clear): + buildSearchMatchSet() converts match indices to O(1) map per render, + highlightSearchMatches() inserts ANSI bg-only sequence around matched substrings + (preserves syntax foreground; falls back to reverse video in --no-colors mode) → viewport.SetContent() → terminal ``` diff --git a/README.md b/README.md index 66335a06..73d10253 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Built for a specific use case: reviewing code changes without leaving a terminal - Word wrap mode: wraps long lines at viewport boundary with `↪` continuation markers, toggle with `w` - Annotate any line in the diff (added, removed, or context) plus file-level notes - Two-pane TUI: file tree (left) + colorized diff viewport (right) +- Vim-style `/` search within diff with `n`/`N` match navigation - Hunk navigation to jump between change groups - Filter file tree to show only annotated files - Status line with filename, diff stats, hunk position, and mode indicators @@ -158,7 +159,7 @@ All color options accept hex values (`#rrggbb`) and have corresponding `REVDIFF_ | `--color-accent` | Active pane borders and directory names | `#D5895F` | | `--color-border` | Inactive pane borders | `#585858` | | `--color-normal` | File entries and context lines | `#d0d0d0` | -| `--color-muted` | Divider lines and status bar | `#6c6c6c` | +| `--color-muted` | Divider lines and status bar | `#585858` | | `--color-selected-fg` | Selected file text | `#ffffaf` | | `--color-selected-bg` | Selected file background | `#D5895F` | | `--color-annotation` | Annotation text and markers | `#ffd700` | @@ -172,8 +173,10 @@ All color options accept hex values (`#rrggbb`) and have corresponding `REVDIFF_ | `--color-modify-bg` | Modified line background (collapsed mode) | `#3D2E00` | | `--color-tree-bg` | File tree pane background | terminal default | | `--color-diff-bg` | Diff pane background | terminal default | -| `--color-status-fg` | Status bar foreground | `#2D2D2D` | +| `--color-status-fg` | Status bar foreground | `#202020` | | `--color-status-bg` | Status bar background | `#C5794F` | +| `--color-search-fg` | Search match text | `#1a1a1a` | +| `--color-search-bg` | Search match background | `#4a4a00` | @@ -218,9 +221,18 @@ revdiff HEAD~1 | `Ctrl+d/Ctrl+u` | Page scroll in file tree and diff pane | | `Home/End` | Jump to first/last item | | `Enter` | Switch to diff pane (tree) / start annotation (diff pane) | -| `n/p` | Next/previous changed file | +| `n/p` | Next/previous changed file (n = next match when search active) | | `[` / `]` | Jump to previous/next change hunk in diff | +**Search:** + +| Key | Action | +|-----|--------| +| `/` | Start search in diff pane | +| `n` | Next search match (overrides next file when search active) | +| `N` | Previous search match | +| `Esc` | Cancel search input / clear search results | + **Annotations:** | Key | Action | diff --git a/cmd/revdiff/main.go b/cmd/revdiff/main.go index ee4eea87..a37f72dd 100644 --- a/cmd/revdiff/main.go +++ b/cmd/revdiff/main.go @@ -41,7 +41,7 @@ type options struct { Accent string `long:"color-accent" ini-name:"color-accent" env:"REVDIFF_COLOR_ACCENT" default:"#D5895F" description:"active pane borders and directory names"` Border string `long:"color-border" ini-name:"color-border" env:"REVDIFF_COLOR_BORDER" default:"#585858" description:"inactive pane borders"` Normal string `long:"color-normal" ini-name:"color-normal" env:"REVDIFF_COLOR_NORMAL" default:"#d0d0d0" description:"file entries and context lines"` - Muted string `long:"color-muted" ini-name:"color-muted" env:"REVDIFF_COLOR_MUTED" default:"#6c6c6c" description:"line numbers and status bar"` + Muted string `long:"color-muted" ini-name:"color-muted" env:"REVDIFF_COLOR_MUTED" default:"#585858" description:"line numbers and status bar"` SelectedFg string `long:"color-selected-fg" ini-name:"color-selected-fg" env:"REVDIFF_COLOR_SELECTED_FG" default:"#ffffaf" description:"selected file text color"` SelectedBg string `long:"color-selected-bg" ini-name:"color-selected-bg" env:"REVDIFF_COLOR_SELECTED_BG" default:"#D5895F" description:"selected file background color"` Annotation string `long:"color-annotation" ini-name:"color-annotation" env:"REVDIFF_COLOR_ANNOTATION" default:"#ffd700" description:"annotation text and markers"` @@ -57,6 +57,8 @@ type options struct { DiffBg string `long:"color-diff-bg" ini-name:"color-diff-bg" env:"REVDIFF_COLOR_DIFF_BG" description:"diff pane background"` StatusFg string `long:"color-status-fg" ini-name:"color-status-fg" env:"REVDIFF_COLOR_STATUS_FG" default:"#202020" description:"status bar foreground"` StatusBg string `long:"color-status-bg" ini-name:"color-status-bg" env:"REVDIFF_COLOR_STATUS_BG" default:"#C5794F" description:"status bar background"` + SearchFg string `long:"color-search-fg" ini-name:"color-search-fg" env:"REVDIFF_COLOR_SEARCH_FG" default:"#1a1a1a" description:"search match foreground"` + SearchBg string `long:"color-search-bg" ini-name:"color-search-bg" env:"REVDIFF_COLOR_SEARCH_BG" default:"#4a4a00" description:"search match background"` } `group:"color options"` } @@ -203,6 +205,8 @@ func run(opts options) error { DiffBg: opts.Colors.DiffBg, StatusFg: opts.Colors.StatusFg, StatusBg: opts.Colors.StatusBg, + SearchFg: opts.Colors.SearchFg, + SearchBg: opts.Colors.SearchBg, }, }) diff --git a/cmd/revdiff/main_test.go b/cmd/revdiff/main_test.go index 98f93056..6f1a1b70 100644 --- a/cmd/revdiff/main_test.go +++ b/cmd/revdiff/main_test.go @@ -108,7 +108,7 @@ func TestParseArgs_ColorDefaults(t *testing.T) { assert.Equal(t, "#D5895F", opts.Colors.Accent) assert.Equal(t, "#585858", opts.Colors.Border) assert.Equal(t, "#d0d0d0", opts.Colors.Normal) - assert.Equal(t, "#6c6c6c", opts.Colors.Muted) + assert.Equal(t, "#585858", opts.Colors.Muted) assert.Equal(t, "#87d787", opts.Colors.AddFg) assert.Equal(t, "#123800", opts.Colors.AddBg) assert.Equal(t, "#ff8787", opts.Colors.RemoveFg) diff --git a/docs/plans/20260402-diff-search.md b/docs/plans/completed/20260402-diff-search.md similarity index 67% rename from docs/plans/20260402-diff-search.md rename to docs/plans/completed/20260402-diff-search.md index 4a39ffc0..47dcd8f3 100644 --- a/docs/plans/20260402-diff-search.md +++ b/docs/plans/completed/20260402-diff-search.md @@ -88,14 +88,14 @@ Add `/` search in the diff pane following vim/less conventions. Users can search - Modify: `cmd/revdiff/main.go` - Modify: `ui/model_test.go` -- [ ] add `SearchFg` and `SearchBg` string fields to `Colors` struct -- [ ] add `SearchMatch lipgloss.Style` to `styles` struct -- [ ] initialize `SearchMatch` style in `newStyles()` with foreground and background from colors -- [ ] update `normalizeColors()` to normalize `SearchFg` and `SearchBg` -- [ ] add CLI flags `--color-search-fg` (default `#1a1a1a`) and `--color-search-bg` (default `#d7d700`) in main.go opts -- [ ] wire new color fields in `run()` function's `ui.Colors{}` literal -- [ ] update `plainStyles()` to include `SearchMatch` for tests -- [ ] run `make test` — must pass before task 2 +- [x] add `SearchFg` and `SearchBg` string fields to `Colors` struct +- [x] add `SearchMatch lipgloss.Style` to `styles` struct +- [x] initialize `SearchMatch` style in `newStyles()` with foreground and background from colors +- [x] update `normalizeColors()` to normalize `SearchFg` and `SearchBg` +- [x] add CLI flags `--color-search-fg` (default `#1a1a1a`) and `--color-search-bg` (default `#d7d700`) in main.go opts +- [x] wire new color fields in `run()` function's `ui.Colors{}` literal +- [x] update `plainStyles()` to include `SearchMatch` for tests +- [x] run `make test` — must pass before task 2 ### Task 2: Add search model fields and input handling @@ -103,17 +103,17 @@ Add `/` search in the diff pane following vim/less conventions. Users can search - Modify: `ui/model.go` - Modify: `ui/model_test.go` -- [ ] add `searching bool`, `searchTerm string`, `searchMatches []int`, `searchCursor int`, `searchInput textinput.Model` fields to `Model` -- [ ] add `startSearch()` method — creates textinput with `/` placeholder, sets `m.searching = true` -- [ ] add `handleSearchKey(msg)` method — enter calls `submitSearch`, esc calls `cancelSearch`, default forwards to `searchInput.Update(msg)` -- [ ] add `submitSearch()` method — if input empty, call `clearSearch()` and return; otherwise store lowercase search term, scan `diffLines` for case-insensitive matches, populate `searchMatches`, move cursor to first match forward, set `searching = false` -- [ ] add `cancelSearch()` method — clears input, sets `searching = false` -- [ ] forward non-key messages to `searchInput` in `Update()` when `m.searching` (parallel to annotating path) -- [ ] handle `/` key in `handleKey()` — calls `startSearch()` (only from diff pane) -- [ ] handle `searching` priority in `handleKey()` — check before help overlay, after annotation -- [ ] write tests for `startSearch`, `submitSearch`, `cancelSearch` behavior -- [ ] write tests for search input key handling (enter submits, esc cancels) -- [ ] run `make test` — must pass before task 3 +- [x] add `searching bool`, `searchTerm string`, `searchMatches []int`, `searchCursor int`, `searchInput textinput.Model` fields to `Model` +- [x] add `startSearch()` method — creates textinput with `/` placeholder, sets `m.searching = true` +- [x] add `handleSearchKey(msg)` method — enter calls `submitSearch`, esc calls `cancelSearch`, default forwards to `searchInput.Update(msg)` +- [x] add `submitSearch()` method — if input empty, call `clearSearch()` and return; otherwise store lowercase search term, scan `diffLines` for case-insensitive matches, populate `searchMatches`, move cursor to first match forward, set `searching = false` +- [x] add `cancelSearch()` method — clears input, sets `searching = false` +- [x] forward non-key messages to `searchInput` in `Update()` when `m.searching` (parallel to annotating path) +- [x] handle `/` key in `handleKey()` — calls `startSearch()` (only from diff pane) +- [x] handle `searching` priority in `handleKey()` — check before help overlay, after annotation +- [x] write tests for `startSearch`, `submitSearch`, `cancelSearch` behavior +- [x] write tests for search input key handling (enter submits, esc cancels) +- [x] run `make test` — must pass before task 3 ### Task 3: Add search match navigation (n/N keys) @@ -121,14 +121,14 @@ Add `/` search in the diff pane following vim/less conventions. Users can search - Modify: `ui/model.go` - Modify: `ui/model_test.go` -- [ ] add `nextSearchMatch()` method — advances `searchCursor` with wrap, moves `diffCursor`, syncs viewport -- [ ] add `prevSearchMatch()` method — same but backwards -- [ ] modify `n` key handling: when `len(m.searchMatches) > 0`, call `nextSearchMatch()` instead of `tree.nextFile()` -- [ ] add `N` key handling in `handleKey()`: when `len(m.searchMatches) > 0`, call `prevSearchMatch()` -- [ ] write tests for next/prev match navigation including wrap-around -- [ ] write tests that `n` falls through to next-file when no search active -- [ ] write test that `N` does prev match when search active -- [ ] run `make test` — must pass before task 4 +- [x] add `nextSearchMatch()` method — advances `searchCursor` with wrap, moves `diffCursor`, syncs viewport +- [x] add `prevSearchMatch()` method — same but backwards +- [x] modify `n` key handling: when `len(m.searchMatches) > 0`, call `nextSearchMatch()` instead of `tree.nextFile()` +- [x] add `N` key handling in `handleKey()`: when `len(m.searchMatches) > 0`, call `prevSearchMatch()` +- [x] write tests for next/prev match navigation including wrap-around +- [x] write tests that `n` falls through to next-file when no search active +- [x] write test that `N` does prev match when search active +- [x] run `make test` — must pass before task 4 ### Task 4: Highlight matching lines in diff rendering @@ -137,14 +137,14 @@ Add `/` search in the diff pane following vim/less conventions. Users can search - Modify: `ui/collapsed.go` - Modify: `ui/model_test.go` -- [ ] add `searchMatchSet map[int]bool` field to Model, computed in `renderDiff()` and `renderCollapsedDiff()` before line iteration -- [ ] in `renderDiffLine`: when line index is in `m.searchMatchSet`, apply `m.styles.SearchMatch` background to content (after syntax highlight, before horizontal scroll/wrap — highlight propagates to all wrapped continuation rows automatically) -- [ ] in `renderCollapsedAddLine`: same search match highlight check and styling (before wrap if word wrap is active) -- [ ] ensure cursor line styling (`▶`) coexists with search highlight -- [ ] verify search highlight works correctly with word wrap active (all continuation rows highlighted) -- [ ] write tests verifying matched lines contain search highlight styling -- [ ] write tests verifying non-matched lines are unchanged -- [ ] run `make test` — must pass before task 5 +- [x] add `searchMatchSet map[int]bool` field to Model, computed in `renderDiff()` and `renderCollapsedDiff()` before line iteration +- [x] in `renderDiffLine`: when line index is in `m.searchMatchSet`, apply `m.styles.SearchMatch` background to content (after syntax highlight, before horizontal scroll/wrap — highlight propagates to all wrapped continuation rows automatically) +- [x] in `renderCollapsedAddLine`: same search match highlight check and styling (before wrap if word wrap is active) +- [x] ensure cursor line styling (`▶`) coexists with search highlight +- [x] verify search highlight works correctly with word wrap active (all continuation rows highlighted) +- [x] write tests verifying matched lines contain search highlight styling +- [x] write tests verifying non-matched lines are unchanged +- [x] run `make test` — must pass before task 5 ### Task 5: Status line search indicator and search input display @@ -152,12 +152,12 @@ Add `/` search in the diff pane following vim/less conventions. Users can search - Modify: `ui/model.go` - Modify: `ui/model_test.go` -- [ ] add `if m.searching` branch in `statusBarText()` returning search input view with hints (before `inConfirmDiscard` check) -- [ ] add `[X/Y]` search position segment to status line when `len(m.searchMatches) > 0` (between hunk and mode icons) -- [ ] handle `searchMatches` in `statusSegmentsNoIcons()` and `statusSegmentsMinimal()` for narrow terminal degradation -- [ ] write tests for status bar during active search input -- [ ] write tests for `[X/Y]` display with various match counts -- [ ] run `make test` — must pass before task 6 +- [x] add `if m.searching` branch in `statusBarText()` returning search input view with hints (before `inConfirmDiscard` check) +- [x] add `[X/Y]` search position segment to status line when `len(m.searchMatches) > 0` (between hunk and mode icons) +- [x] handle `searchMatches` in `statusSegmentsNoIcons()` and `statusSegmentsMinimal()` for narrow terminal degradation +- [x] write tests for status bar during active search input +- [x] write tests for `[X/Y]` display with various match counts +- [x] run `make test` — must pass before task 6 ### Task 6: Clear search on file change @@ -165,11 +165,11 @@ Add `/` search in the diff pane following vim/less conventions. Users can search - Modify: `ui/model.go` - Modify: `ui/model_test.go` -- [ ] add `clearSearch()` method that resets `searchTerm`, `searchMatches`, `searchCursor`, `searchMatchSet` -- [ ] call `clearSearch()` in `handleFileLoaded` after setting `m.diffLines` -- [ ] write tests for search clearing on file load -- [ ] write tests for empty search submit clearing matches (already wired in Task 2's `submitSearch`) -- [ ] run `make test` — must pass before task 7 +- [x] add `clearSearch()` method that resets `searchTerm`, `searchMatches`, `searchCursor`, `searchMatchSet` +- [x] call `clearSearch()` in `handleFileLoaded` after setting `m.diffLines` +- [x] write tests for search clearing on file load +- [x] write tests for empty search submit clearing matches (already wired in Task 2's `submitSearch`) +- [x] run `make test` — must pass before task 7 ### Task 7: Update help overlay @@ -177,31 +177,31 @@ Add `/` search in the diff pane following vim/less conventions. Users can search - Modify: `ui/model.go` - Modify: `ui/model_test.go` -- [ ] add search entries to help overlay — either new "Search" section or under "Navigation" -- [ ] entries: `/` = search, `n` = next match, `N` = prev match -- [ ] update `n / p` entry to note search-active override -- [ ] write test verifying help overlay contains search key listings -- [ ] run `make test` — must pass before task 8 +- [x] add search entries to help overlay — either new "Search" section or under "Navigation" +- [x] entries: `/` = search, `n` = next match, `N` = prev match +- [x] update `n / p` entry to note search-active override +- [x] write test verifying help overlay contains search key listings +- [x] run `make test` — must pass before task 8 ### Task 8: Verify acceptance criteria -- [ ] verify `/` opens search input in diff pane -- [ ] verify enter submits and jumps to first match -- [ ] verify esc cancels without searching -- [ ] verify `n`/`N` navigate matches with wrap-around -- [ ] verify `n` reverts to next-file when no search active -- [ ] verify all matches highlighted with search colors -- [ ] verify `[X/Y]` shown in status line -- [ ] verify search clears on file change -- [ ] verify `--color-search-fg`/`--color-search-bg` flags work -- [ ] run full test suite: `make test` -- [ ] run linter: `make lint` +- [x] verify `/` opens search input in diff pane +- [x] verify enter submits and jumps to first match +- [x] verify esc cancels without searching +- [x] verify `n`/`N` navigate matches with wrap-around +- [x] verify `n` reverts to next-file when no search active +- [x] verify all matches highlighted with search colors +- [x] verify `[X/Y]` shown in status line +- [x] verify search clears on file change +- [x] verify `--color-search-fg`/`--color-search-bg` flags work +- [x] run full test suite: `make test` +- [x] run linter: `make lint` ### Task 9: [Final] Update documentation -- [ ] update README.md with `/` search, `n`/`N` navigation keybindings -- [ ] update `.claude-plugin/skills/revdiff/references/usage.md` with search keybindings -- [ ] update `.claude-plugin/skills/revdiff/references/config.md` with search color options -- [ ] update CLAUDE.md if new patterns discovered -- [ ] move this plan to `docs/plans/completed/` +- [x] update README.md with `/` search, `n`/`N` navigation keybindings +- [x] update `.claude-plugin/skills/revdiff/references/usage.md` with search keybindings +- [x] update `.claude-plugin/skills/revdiff/references/config.md` with search color options +- [x] update CLAUDE.md if new patterns discovered +- [x] move this plan to `docs/plans/completed/` ## Post-Completion diff --git a/screenshot.png b/screenshot.png index a22cc379..fa8157a1 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/ui/collapsed.go b/ui/collapsed.go index 9265875d..6f125243 100644 --- a/ui/collapsed.go +++ b/ui/collapsed.go @@ -21,6 +21,8 @@ type collapsedState struct { // removed lines are hidden unless their hunk is expanded. added lines are styled // as "modified" (amber ~) when paired with removes, or "pure add" (green +) otherwise. func (m Model) renderCollapsedDiff() string { + m.buildSearchMatchSet() + annotationMap, fileComment := m.buildAnnotationMap() hunks := m.findHunks() modifiedSet := m.buildModifiedSet(hunks) @@ -77,13 +79,19 @@ func (m Model) renderCollapsedDiff() string { } // renderCollapsedAddLine renders an add line in collapsed mode with modify or add styling. +// when search is active, matching lines use search highlight instead of add/modify styling. func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffLine, modified bool) { lineContent, textContent, hasHighlight := m.prepareLineContent(idx, dl) + isSearchMatch := m.searchMatchSet[idx] style, hlStyle, gutter := m.styles.LineAdd, m.styles.LineAddHighlight, " + " if modified { style, hlStyle, gutter = m.styles.LineModify, m.styles.LineModifyHighlight, " ~ " } + if isSearchMatch { + style = m.styles.SearchMatch + hlStyle = m.styles.SearchMatch.UnsetForeground() + } isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation @@ -151,9 +159,15 @@ func (m Model) deletePlaceholderText(hunkStart int) string { // renderDeletePlaceholder renders a placeholder line for a delete-only hunk in collapsed mode. // shows "⋯ N lines deleted" with remove styling so users know deletions exist and can expand with '.'. +// when search is active, matching placeholders use search highlight instead of remove styling. func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { text := m.deletePlaceholderText(hunkStart) + style := m.styles.LineRemove + if m.searchMatchSet[idx] { + style = m.styles.SearchMatch + } + isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation // wrap mode: break long placeholder at word boundaries @@ -165,7 +179,7 @@ func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { if i == 0 { prefix = " - " } - styled := m.styles.LineRemove.Render(prefix + vl) + styled := style.Render(prefix + vl) cursor := " " if i == 0 && isCursor { @@ -176,7 +190,7 @@ func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { return } - content := m.styles.LineRemove.Render(" - " + text) + content := style.Render(" - " + text) // apply horizontal scroll if m.scrollX > 0 { @@ -272,6 +286,7 @@ func (m *Model) toggleCollapsedMode() { m.collapsed.expandedHunks = make(map[int]bool) m.cursorOnAnnotation = false // visible lines change, reset annotation cursor state m.adjustCursorIfHidden() + m.realignSearchCursor() m.viewport.SetContent(m.renderDiff()) } @@ -289,6 +304,7 @@ func (m *Model) toggleHunkExpansion() { delete(m.collapsed.expandedHunks, hunkStart) m.cursorOnAnnotation = false // annotations on removed lines become invisible m.adjustCursorIfHidden() + m.realignSearchCursor() } else { m.collapsed.expandedHunks[hunkStart] = true } diff --git a/ui/collapsed_test.go b/ui/collapsed_test.go index 647c55ea..36b2d5ef 100644 --- a/ui/collapsed_test.go +++ b/ui/collapsed_test.go @@ -1088,10 +1088,10 @@ func TestModel_StatusBarCollapsedIndicator(t *testing.T) { m.focus = paneDiff m.width = 200 - t.Run("expanded mode has no collapsed indicator", func(t *testing.T) { + t.Run("collapsed indicator always present", func(t *testing.T) { m.collapsed.enabled = false status := m.statusBarText() - assert.NotContains(t, status, "▼") + assert.Contains(t, status, "▼", "indicator always shown, muted when inactive") }) t.Run("collapsed mode shows indicator", func(t *testing.T) { diff --git a/ui/diffview.go b/ui/diffview.go index 1ccbd480..3c256d7a 100644 --- a/ui/diffview.go +++ b/ui/diffview.go @@ -8,6 +8,9 @@ import ( "github.com/umputun/revdiff/diff" ) +// matchRange represents a range of visible character positions for search match highlighting. +type matchRange struct{ start, end int } + // renderDiff renders the current file's diff lines with styling, cursor highlight, // and injected annotation lines. func (m Model) renderDiff() string { @@ -19,6 +22,8 @@ func (m Model) renderDiff() string { return m.renderCollapsedDiff() } + m.buildSearchMatchSet() + annotationMap, fileComment := m.buildAnnotationMap() var b strings.Builder m.renderFileAnnotationHeader(&b, fileComment) @@ -68,12 +73,13 @@ func (m Model) renderFileAnnotationHeader(b *strings.Builder, fileComment string // when wrap mode is active, long lines are broken at word boundaries with ↪ continuation markers. func (m Model) renderDiffLine(b *strings.Builder, idx int, dl diff.DiffLine) { lineContent, textContent, hasHighlight := m.prepareLineContent(idx, dl) + isSearchMatch := m.searchMatchSet[idx] isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation // wrap mode: break long lines at word boundaries (dividers are short, skip them) if m.wrapMode && dl.ChangeType != diff.ChangeDivider { - m.renderWrappedDiffLine(b, dl, textContent, hasHighlight, isCursor) + m.renderWrappedDiffLine(b, dl, textContent, hasHighlight, isCursor, isSearchMatch) return } @@ -81,7 +87,7 @@ func (m Model) renderDiffLine(b *strings.Builder, idx int, dl diff.DiffLine) { if dl.ChangeType == diff.ChangeDivider { content = m.styles.LineNumber.Render(" " + lineContent) } else { - content = m.styleDiffContent(dl.ChangeType, m.linePrefix(dl.ChangeType), textContent, hasHighlight) + content = m.styleDiffContent(dl.ChangeType, m.linePrefix(dl.ChangeType), textContent, hasHighlight, isSearchMatch) } // apply horizontal scroll to content (bar stays fixed), disabled in wrap mode @@ -97,7 +103,7 @@ func (m Model) renderDiffLine(b *strings.Builder, idx int, dl diff.DiffLine) { } // renderWrappedDiffLine renders a diff line with word wrapping, producing continuation lines with ↪ markers. -func (m Model) renderWrappedDiffLine(b *strings.Builder, dl diff.DiffLine, textContent string, hasHighlight, isCursor bool) { +func (m Model) renderWrappedDiffLine(b *strings.Builder, dl diff.DiffLine, textContent string, hasHighlight, isCursor, isSearchMatch bool) { wrapWidth := m.diffContentWidth() - wrapGutterWidth visualLines := m.wrapContent(textContent, wrapWidth) @@ -107,7 +113,7 @@ func (m Model) renderWrappedDiffLine(b *strings.Builder, dl diff.DiffLine, textC prefix = m.linePrefix(dl.ChangeType) } - styled := m.styleDiffContent(dl.ChangeType, prefix, vl, hasHighlight) + styled := m.styleDiffContent(dl.ChangeType, prefix, vl, hasHighlight, isSearchMatch) cursor := " " if i == 0 && isCursor { @@ -168,8 +174,104 @@ func (m Model) linePrefix(changeType diff.ChangeType) string { } } +// highlightSearchMatches wraps each occurrence of the search term in the visible text +// with ANSI background color sequence (preserving syntax foreground within matches). +// works with both plain text and ANSI-coded content by stripping ANSI to find match positions. +func (m Model) highlightSearchMatches(s string) string { + if m.searchTerm == "" { + return s + } + + // find match positions in visible (ANSI-stripped) text + plain := ansi.Strip(s) + plainLower := strings.ToLower(plain) + term := strings.ToLower(m.searchTerm) + if !strings.Contains(plainLower, term) { + return s + } + + // collect all match ranges in visible-character positions + var matches []matchRange + offset := 0 + for { + idx := strings.Index(plainLower[offset:], term) + if idx < 0 { + break + } + start := offset + idx + matches = append(matches, matchRange{start, start + len(term)}) + offset = start + len(term) + } + if len(matches) == 0 { + return s + } + + // background-only highlight preserves syntax foreground colors within matches + hlOn := m.ansiBg(m.styles.colors.SearchBg) + hlOff := "\033[49m" + if hlOn == "" { + // no-colors mode: fall back to reverse video so matches remain visible + hlOn = "\033[7m" + hlOff = "\033[27m" + } + + return m.insertHighlightMarkers(s, matches, hlOn, hlOff) +} + +// insertHighlightMarkers walks the string inserting hlOn/hlOff ANSI sequences at match positions, +// skipping over existing ANSI escape sequences to preserve them. +func (m Model) insertHighlightMarkers(s string, matches []matchRange, hlOn, hlOff string) string { + var b strings.Builder + visPos := 0 // current position in visible text + matchIdx := 0 // current match we're processing + i := 0 + + for i < len(s) { + // skip ANSI escape sequences (copy them as-is) + if s[i] == '\033' { + j := i + 1 + for j < len(s) && s[j] != 'm' { + j++ + } + if j < len(s) { + j++ // include the 'm' + } + b.WriteString(s[i:j]) + i = j + continue + } + + // insert highlight start/end at match boundaries + if matchIdx < len(matches) && visPos == matches[matchIdx].start { + b.WriteString(hlOn) + } + if matchIdx < len(matches) && visPos == matches[matchIdx].end { + b.WriteString(hlOff) + matchIdx++ + if matchIdx < len(matches) && visPos == matches[matchIdx].start { + b.WriteString(hlOn) + } + } + + b.WriteByte(s[i]) + visPos++ + i++ + } + + // close any unclosed highlight + if matchIdx < len(matches) && visPos >= matches[matchIdx].start && visPos <= matches[matchIdx].end { + b.WriteString(hlOff) + } + + return b.String() +} + // styleDiffContent applies the appropriate line style based on change type. -func (m Model) styleDiffContent(changeType diff.ChangeType, prefix, content string, hasHighlight bool) string { +func (m Model) styleDiffContent(changeType diff.ChangeType, prefix, content string, hasHighlight, isSearchMatch bool) string { + if isSearchMatch && m.searchTerm != "" { + content = m.highlightSearchMatches(content) + } + switch changeType { case diff.ChangeAdd: if hasHighlight { diff --git a/ui/model.go b/ui/model.go index 8e25ffd5..3f79ec75 100644 --- a/ui/model.go +++ b/ui/model.go @@ -79,6 +79,13 @@ type Model struct { showHelp bool // true when help overlay is visible wrapMode bool // true when line wrapping is enabled + searching bool // true when search textinput is active (typing) + searchTerm string // last submitted search query + searchMatches []int // indices into diffLines that match + searchCursor int // current position in searchMatches (0-based) + searchInput textinput.Model // dedicated textinput for search + searchMatchSet map[int]bool // set of diffLines indices that match search, computed per render + discarded bool // true when user chose to discard annotations and quit inConfirmDiscard bool // true when showing discard confirmation prompt noConfirmDiscard bool // skip confirmation prompt on discard quit @@ -193,6 +200,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + // forward other messages to search textinput when searching (e.g. cursor blink) + if m.searching { + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd + } + return m, nil } @@ -202,12 +216,20 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleAnnotateKey(msg) } + // search input mode takes priority after annotation + if m.searching { + return m.handleSearchKey(msg) + } + // help overlay: toggle with ?, dismiss with esc, block everything else if msg.String() == "?" || m.showHelp { return m.handleHelpKey(msg) } switch { + case msg.Type == tea.KeyEsc: + return m.handleEscKey() + case msg.String() == "Q": return m.handleDiscardQuit() @@ -219,17 +241,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case msg.String() == "f": - annotated := m.annotatedFiles() - if len(annotated) > 0 { - m.tree.toggleFilter(annotated) - m.tree.ensureVisible(m.treePageSize()) - return m.loadSelectedIfChanged() - } - return m, nil + return m.handleFilterToggle() - case msg.String() == "n": - m.tree.nextFile() - return m.loadSelectedIfChanged() + case msg.String() == "n" || msg.String() == "N": + return m.handleFileOrSearchNav(msg.String()) case msg.String() == "p": m.tree.prevFile() @@ -239,14 +254,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleEnterKey() case msg.String() == "A": - // file-level annotation only from diff pane to avoid annotating the wrong file - // when tree selection differs from the currently displayed file. - if m.focus == paneDiff && m.currFile != "" { - cmd := m.startFileAnnotation() - m.viewport.SetContent(m.renderDiff()) - return m, cmd - } - return m, nil + return m.handleFileAnnotateKey() case msg.String() == "v": m.toggleCollapsedMode() @@ -378,6 +386,9 @@ func (m Model) handleDiffNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case msg.String() == ".": m.toggleHunkExpansion() return m, nil + case msg.String() == "/": + cmd := m.startSearch() + return m, cmd } return m, nil } @@ -435,6 +446,7 @@ func (m Model) handleFileLoaded(msg fileLoadedMsg) (tea.Model, tea.Cmd) { } m.currFile = msg.file m.diffLines = msg.lines + m.clearSearch() m.computeFileStats() m.highlightedLines = m.highlighter.HighlightLines(msg.file, msg.lines) m.cursorOnAnnotation = false @@ -530,8 +542,13 @@ func (m Model) View() string { } // statusBarText returns context-sensitive status line content. -// shows filename, diff stats, hunk position, mode indicators, and right-aligned annotation count + help hint. +// shows search input (when typing), or filename, diff stats, hunk position, +// search match position, mode indicators, and right-aligned annotation count + help hint. func (m Model) statusBarText() string { + if m.searching { + return m.searchBarText() + } + if m.inConfirmDiscard { return fmt.Sprintf("discard %d annotations? [y/n]", m.store.Count()) } @@ -553,9 +570,9 @@ func (m Model) statusBarText() string { segments = append(segments, hs) } - // mode indicators (combined into one segment) - if modeIcons := m.statusModeIcons(); modeIcons != "" { - segments = append(segments, modeIcons) + // search match position + if ss := m.searchSegment(); ss != "" { + segments = append(segments, ss) } // build right-side segments @@ -567,7 +584,7 @@ func (m Model) statusBarText() string { } rightParts = append(rightParts, fmt.Sprintf("%d %s", cnt, suffix)) } - rightParts = append(rightParts, "? help") + rightParts = append(rightParts, m.statusModeIcons(), "? help") // build separator with muted foreground using raw ANSI (not lipgloss.Render) // to avoid full reset that would break the status bar background @@ -583,10 +600,10 @@ func (m Model) statusBarText() string { minRight := lipgloss.Width(right) + 5 // 2 for status bar padding + 3 for separator available := max(m.width-minRight, 0) - // graceful degradation: drop segments from right to left when too narrow + // graceful degradation: drop left segments when too narrow if lipgloss.Width(left) > available { - // rebuild without mode icons first - segments = m.statusSegmentsNoIcons() + // rebuild without search position + segments = m.statusSegmentsNoSearch() left = strings.Join(segments, sep) } if lipgloss.Width(left) > available { @@ -617,15 +634,7 @@ func (m Model) statusBarText() string { left = name + sep + statsStr } - // pad left to push right section to the end - padding := m.width - lipgloss.Width(left) - lipgloss.Width(right) - 2 // 2 for status bar padding - if padding > 0 { - return left + strings.Repeat(" ", padding) + right - } - if left != "" { - return left + sep + right - } - return right + return m.joinStatusSections(left, right, sep) } // hunkSegment returns a formatted hunk position string for the status line. @@ -647,9 +656,48 @@ func (m Model) hunkSegment() string { return fmt.Sprintf("%d hunks", total) } -// ansiFg returns an ANSI 24-bit foreground escape sequence for a hex color (e.g. "#6c6c6c"). -// uses raw ANSI instead of lipgloss.Render to avoid full reset that breaks outer backgrounds. -func (m Model) ansiFg(hex string) string { +// joinStatusSections joins left and right status sections with padding and separators. +func (m Model) joinStatusSections(left, right, sep string) string { + sepWidth := lipgloss.Width(sep) + padding := m.width - lipgloss.Width(left) - lipgloss.Width(right) - 2 // 2 for status bar padding + if left != "" && padding > sepWidth { + return left + sep + strings.Repeat(" ", padding-sepWidth) + right + } + if padding > 0 { + return left + strings.Repeat(" ", padding) + right + } + if left != "" { + return left + sep + right + } + return right +} + +// searchBarText returns the status bar content during search input mode. +func (m Model) searchBarText() string { + return "/" + m.searchInput.Value() +} + +// searchSegment returns a formatted search position string like "X/Y" for the status line. +// returns empty string when no search matches exist. shows 0/N when all matches are hidden +// in collapsed mode (e.g. matches only on removed lines). +func (m Model) searchSegment() string { + if len(m.searchMatches) == 0 { + return "" + } + pos := m.searchCursor + 1 + if m.collapsed.enabled && m.searchCursor < len(m.searchMatches) { + hunks := m.findHunks() + if m.isCollapsedHidden(m.searchMatches[m.searchCursor], hunks) { + pos = 0 + } + } + return fmt.Sprintf("%d/%d", pos, len(m.searchMatches)) +} + +// ansiColor returns an ANSI 24-bit color escape sequence for a hex color. +// code 38 = foreground, 48 = background. uses raw ANSI instead of lipgloss.Render +// to avoid full reset that breaks outer backgrounds. +func (m Model) ansiColor(hex string, code int) string { hex = strings.TrimPrefix(hex, "#") if len(hex) != 6 { return "" @@ -657,26 +705,49 @@ func (m Model) ansiFg(hex string) string { r, _ := strconv.ParseUint(hex[0:2], 16, 8) g, _ := strconv.ParseUint(hex[2:4], 16, 8) b, _ := strconv.ParseUint(hex[4:6], 16, 8) - return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b) + return fmt.Sprintf("\033[%d;2;%d;%d;%dm", code, r, g, b) } -// statusModeIcons returns combined mode indicator icons (▼ for collapsed, ◉ for filter, ↩ for wrap). +// ansiFg returns an ANSI 24-bit foreground escape sequence for a hex color. +func (m Model) ansiFg(hex string) string { return m.ansiColor(hex, 38) } + +// ansiBg returns an ANSI 24-bit background escape sequence for a hex color. +func (m Model) ansiBg(hex string) string { return m.ansiColor(hex, 48) } + +// statusModeIcons returns combined mode indicator icons (▼ collapsed, ◉ filter, ↩ wrap, ≋ search). +// all icons are always shown; active modes use status foreground, inactive use muted color. func (m Model) statusModeIcons() string { - var icons []string - if m.collapsed.enabled { - icons = append(icons, "▼") + type indicator struct { + icon string + active bool } - if m.tree.filter { - icons = append(icons, "◉") + indicators := []indicator{ + {"▼", m.collapsed.enabled}, + {"◉", m.tree.filter}, + {"↩", m.wrapMode}, + {"≋", len(m.searchMatches) > 0}, } - if m.wrapMode { - icons = append(icons, "↩") + + statusFg := m.styles.colors.Muted + if m.styles.colors.StatusFg != "" { + statusFg = m.styles.colors.StatusFg } - return strings.Join(icons, " ") + mutedSeq := m.ansiFg(m.styles.colors.Muted) + activeSeq := m.ansiFg(statusFg) + + var icons []string + for _, ind := range indicators { + if ind.active { + icons = append(icons, activeSeq+ind.icon) + } else { + icons = append(icons, mutedSeq+ind.icon) + } + } + return strings.Join(icons, " ") + activeSeq } -// statusSegmentsNoIcons returns left segments without mode indicators (▼ ◉ ↩). -func (m Model) statusSegmentsNoIcons() []string { +// statusSegmentsNoSearch returns left segments without search position (for narrow terminals). +func (m Model) statusSegmentsNoSearch() []string { var segments []string if m.currFile != "" { segments = append(segments, m.currFile, fmt.Sprintf("+%d/-%d", m.fileAdds, m.fileRemoves)) @@ -701,7 +772,7 @@ func (m Model) helpOverlay() string { help := "" + "Navigation\n" + " tab switch pane\n" + - " n / p next / prev file\n" + + " n / p next / prev file (n = next match when searching)\n" + " j / k scroll down / up\n" + " PgDn/PgUp page down / up\n" + " Ctrl+d/u half-page down / up\n" + @@ -711,6 +782,11 @@ func (m Model) helpOverlay() string { " [ / ] prev / next hunk\n" + " enter focus diff pane\n" + "\n" + + "Search\n" + + " / search in diff\n" + + " n next match (overrides next file)\n" + + " N prev match\n" + + "\n" + "Annotations\n" + " a / enter annotate line (diff pane)\n" + " A annotate file\n" + @@ -779,6 +855,25 @@ func (m Model) handleDiscardQuit() (tea.Model, tea.Cmd) { return m, nil } +// handleFileAnnotateKey starts file-level annotation from diff pane only. +func (m Model) handleFileAnnotateKey() (tea.Model, tea.Cmd) { + if m.focus == paneDiff && m.currFile != "" { + cmd := m.startFileAnnotation() + m.viewport.SetContent(m.renderDiff()) + return m, cmd + } + return m, nil +} + +// handleEscKey clears active search results on esc. +func (m Model) handleEscKey() (tea.Model, tea.Cmd) { + if len(m.searchMatches) > 0 { + m.clearSearch() + m.viewport.SetContent(m.renderDiff()) + } + return m, nil +} + // handleEnterKey handles enter key based on current pane focus. func (m Model) handleEnterKey() (tea.Model, tea.Cmd) { switch m.focus { @@ -821,6 +916,36 @@ func (m Model) handleConfirmDiscardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleFilterToggle toggles the annotated files filter. +func (m Model) handleFilterToggle() (tea.Model, tea.Cmd) { + annotated := m.annotatedFiles() + if len(annotated) > 0 { + m.tree.toggleFilter(annotated) + m.tree.ensureVisible(m.treePageSize()) + return m.loadSelectedIfChanged() + } + return m, nil +} + +// handleFileOrSearchNav handles n/N keys: navigates search matches when a search is active, +// otherwise n falls through to next-file navigation. N does nothing without search. +func (m Model) handleFileOrSearchNav(key string) (tea.Model, tea.Cmd) { + if len(m.searchMatches) > 0 { + if key == "n" { + m.nextSearchMatch() + } else { + m.prevSearchMatch() + } + m.viewport.SetContent(m.renderDiff()) + return m, nil + } + if key == "n" { + m.tree.nextFile() + return m.loadSelectedIfChanged() + } + return m, nil +} + // annotatedFiles returns a set of files that have annotations. func (m Model) annotatedFiles() map[string]bool { result := make(map[string]bool) diff --git a/ui/model_test.go b/ui/model_test.go index b83bd7e8..f18127c7 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -6,8 +6,10 @@ import ( "strings" "testing" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -300,7 +302,7 @@ func TestModel_StatusBarFilterIndicator(t *testing.T) { assert.Contains(t, status, "◉", "should show filter icon when filter active") }) - t.Run("filter icon hidden when filter inactive", func(t *testing.T) { + t.Run("filter icon always present", func(t *testing.T) { m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) m.tree = newFileTree([]string{"a.go"}) m.ready = true @@ -309,7 +311,7 @@ func TestModel_StatusBarFilterIndicator(t *testing.T) { m.width = 200 status := m.statusBarText() - assert.NotContains(t, status, "◉", "should not show filter icon when filter inactive") + assert.Contains(t, status, "◉", "indicator always shown, muted when inactive") }) } @@ -332,31 +334,27 @@ func TestModel_WrapModeFromConfig(t *testing.T) { } func TestModel_StatusModeIcons(t *testing.T) { - tests := []struct { - name string - collapsed bool - filter bool - wrap bool - want string - }{ - {name: "no modes active", want: ""}, - {name: "collapsed only", collapsed: true, want: "▼"}, - {name: "filter only", filter: true, want: "◉"}, - {name: "wrap only", wrap: true, want: "↩"}, - {name: "collapsed and filter", collapsed: true, filter: true, want: "▼ ◉"}, - {name: "collapsed and wrap", collapsed: true, wrap: true, want: "▼ ↩"}, - {name: "all modes active", collapsed: true, filter: true, wrap: true, want: "▼ ◉ ↩"}, - } + t.Run("all icons always present", func(t *testing.T) { + m := testModel(nil, nil) + icons := m.statusModeIcons() + assert.Contains(t, icons, "▼") + assert.Contains(t, icons, "◉") + assert.Contains(t, icons, "↩") + assert.Contains(t, icons, "≋") + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := testModel(nil, nil) - m.collapsed.enabled = tt.collapsed - m.tree.filter = tt.filter - m.wrapMode = tt.wrap - assert.Equal(t, tt.want, m.statusModeIcons()) - }) - } + t.Run("with colors active icons use status fg", func(t *testing.T) { + colors := Colors{Muted: "#6c6c6c", StatusFg: "#202020"} + m := testModel(nil, nil) + m.styles = newStyles(colors) + m.collapsed.enabled = true + m.tree.filter = false + icons := m.statusModeIcons() + // active collapsed icon should have status fg sequence + assert.Contains(t, icons, "\033[38;2;32;32;32m▼") + // inactive filter icon should have muted fg sequence + assert.Contains(t, icons, "\033[38;2;108;108;108m◉") + }) } func TestModel_StatusBarWrapIndicator(t *testing.T) { @@ -375,7 +373,7 @@ func TestModel_StatusBarWrapIndicator(t *testing.T) { assert.Contains(t, status, "↩", "should show wrap icon when wrap active") }) - t.Run("wrap icon hidden when inactive", func(t *testing.T) { + t.Run("wrap icon always present", func(t *testing.T) { m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) m.tree = newFileTree([]string{"a.go"}) m.ready = true @@ -384,7 +382,7 @@ func TestModel_StatusBarWrapIndicator(t *testing.T) { m.width = 200 status := m.statusBarText() - assert.NotContains(t, status, "↩", "should not show wrap icon when wrap inactive") + assert.Contains(t, status, "↩", "indicator always shown, muted when inactive") }) } @@ -1036,12 +1034,12 @@ func TestModel_StatusBarModeIndicators(t *testing.T) { assert.Contains(t, status, "◉") }) - t.Run("no indicators in default mode", func(t *testing.T) { + t.Run("indicators always present in default mode", func(t *testing.T) { m.collapsed.enabled = false m.tree.filter = false status := m.statusBarText() - assert.NotContains(t, status, "▼") - assert.NotContains(t, status, "◉") + assert.Contains(t, status, "▼", "always shown, muted when inactive") + assert.Contains(t, status, "◉", "always shown, muted when inactive") }) } @@ -1071,12 +1069,12 @@ func TestModel_StatusBarNarrowTerminalDegradation(t *testing.T) { assert.Contains(t, status, "? help") }) - t.Run("narrow terminal drops icons first", func(t *testing.T) { - m.width = 35 + t.Run("narrow terminal drops hunk from left first", func(t *testing.T) { + m.width = 50 status := m.statusBarText() + assert.Contains(t, status, "a.go") + assert.Contains(t, status, "+1/-0") assert.Contains(t, status, "? help") - assert.NotContains(t, status, "◉", "filter icon should be dropped on narrow terminal") - assert.NotContains(t, status, "▼", "collapsed icon should be dropped on narrow terminal") }) t.Run("very narrow terminal drops hunk info", func(t *testing.T) { @@ -2778,6 +2776,95 @@ func TestModel_AnsiFg(t *testing.T) { assert.Empty(t, m.ansiFg("bad"), "should return empty for invalid hex") } +func TestModel_AnsiBg(t *testing.T) { + m := testModel(nil, nil) + assert.Equal(t, "\033[48;2;108;108;108m", m.ansiBg("#6c6c6c")) + assert.Equal(t, "\033[48;2;255;0;0m", m.ansiBg("#ff0000")) + assert.Empty(t, m.ansiBg("bad"), "should return empty for invalid hex") +} + +func TestModel_HandleEscKeyClearsSearch(t *testing.T) { + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{ + "a.go": {{ChangeType: diff.ChangeAdd, Content: "hello world"}}, + }) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{{ChangeType: diff.ChangeAdd, Content: "hello world"}} + m.searchTerm = "hello" + m.searchMatches = []int{0} + m.searchCursor = 0 + m.focus = paneDiff + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model := result.(Model) + assert.Empty(t, model.searchTerm, "esc should clear search term") + assert.Nil(t, model.searchMatches, "esc should clear search matches") +} + +func TestModel_HandleEscKeyNoopWithoutSearch(t *testing.T) { + m := testModel(nil, nil) + m.focus = paneDiff + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model := result.(Model) + assert.Empty(t, model.searchTerm) + assert.Nil(t, model.searchMatches) +} + +func TestModel_HighlightSearchMatches(t *testing.T) { + colors := Colors{SearchFg: "#1a1a1a", SearchBg: "#d7d700"} + m := testModel(nil, nil) + m.styles = newStyles(colors) + + t.Run("plain text single match", func(t *testing.T) { + m.searchTerm = "hello" + result := m.highlightSearchMatches("say hello world") + assert.NotContains(t, result, "\033[38;2;", "should not set foreground (bg-only highlight)") + assert.Contains(t, result, "\033[48;2;215;215;0m") // search bg + assert.Contains(t, result, "hello") + assert.Contains(t, result, "\033[49m") // bg reset + }) + + t.Run("multiple matches", func(t *testing.T) { + m.searchTerm = "ab" + result := m.highlightSearchMatches("ab cd ab") + assert.Equal(t, 2, strings.Count(result, "\033[48;2;215;215;0m"), "should highlight both occurrences") + }) + + t.Run("no match", func(t *testing.T) { + m.searchTerm = "xyz" + result := m.highlightSearchMatches("hello world") + assert.Equal(t, "hello world", result) + }) + + t.Run("empty search term", func(t *testing.T) { + m.searchTerm = "" + result := m.highlightSearchMatches("hello world") + assert.Equal(t, "hello world", result) + }) + + t.Run("case insensitive", func(t *testing.T) { + m.searchTerm = "hello" + result := m.highlightSearchMatches("say HELLO world") + assert.Contains(t, result, "\033[48;2;215;215;0m") + }) + + t.Run("with ansi codes", func(t *testing.T) { + m.searchTerm = "world" + result := m.highlightSearchMatches("\033[32mhello world\033[0m") + assert.Contains(t, result, "\033[48;2;215;215;0m") // search bg on + assert.Contains(t, result, "\033[49m") // search bg reset + assert.Contains(t, result, "\033[32m") // original ansi preserved + }) + + t.Run("no-colors fallback", func(t *testing.T) { + noColorModel := testModel(nil, nil) + noColorModel.searchTerm = "hello" + result := noColorModel.highlightSearchMatches("say hello world") + assert.Contains(t, result, "\033[7m", "should use reverse video in no-colors mode") + assert.Contains(t, result, "\033[27m", "should reset reverse video") + }) +} + func TestModel_EditExistingFileAnnotationShowsInput(t *testing.T) { m := testModel(nil, nil) m.currFile = "a.go" @@ -3206,6 +3293,7 @@ func TestModel_HelpOverlaySections(t *testing.T) { // verify section headers are present assert.Contains(t, help, "Navigation") + assert.Contains(t, help, "Search") assert.Contains(t, help, "Annotations") assert.Contains(t, help, "View") assert.Contains(t, help, "Quit") @@ -3219,6 +3307,7 @@ func TestModel_HelpOverlayKeyListings(t *testing.T) { // verify key listings are present keys := []string{ "tab", "n / p", "j / k", "PgDn/PgUp", "Ctrl+d/u", "Home/End", "h / l", "← / →", "[ / ]", + "/", "n", "N", "a / enter", "A", "d", "f", "v", "w", ".", "q", "Q", "? / esc", } @@ -3232,8 +3321,8 @@ func TestModel_HelpOverlayInView(t *testing.T) { m.styles = plainStyles() m.tree = newFileTree([]string{"a.go"}) m.ready = true - m.width = 80 - m.height = 30 + m.width = 100 + m.height = 40 // without help, view should not contain help sections m.showHelp = false @@ -3480,22 +3569,22 @@ func TestModel_StyleDiffContent(t *testing.T) { m.styles = plainStyles() t.Run("add line", func(t *testing.T) { - result := m.styleDiffContent(diff.ChangeAdd, " + ", "content", false) + result := m.styleDiffContent(diff.ChangeAdd, " + ", "content", false, false) assert.Contains(t, result, " + content") }) t.Run("remove line", func(t *testing.T) { - result := m.styleDiffContent(diff.ChangeRemove, " - ", "content", false) + result := m.styleDiffContent(diff.ChangeRemove, " - ", "content", false, false) assert.Contains(t, result, " - content") }) t.Run("context line", func(t *testing.T) { - result := m.styleDiffContent(diff.ChangeContext, " ", "content", false) + result := m.styleDiffContent(diff.ChangeContext, " ", "content", false, false) assert.Contains(t, result, " content") }) t.Run("highlighted add", func(t *testing.T) { - result := m.styleDiffContent(diff.ChangeAdd, " + ", "\033[32mgreen\033[0m", true) + result := m.styleDiffContent(diff.ChangeAdd, " + ", "\033[32mgreen\033[0m", true, false) assert.Contains(t, result, " + ") assert.Contains(t, result, "\033[32m") }) @@ -3765,3 +3854,1119 @@ func TestModel_HelpOverlayContainsWordWrap(t *testing.T) { assert.Contains(t, help, "toggle word wrap") assert.Contains(t, help, "w") } + +func TestModel_HelpOverlayContainsSearchKeys(t *testing.T) { + m := testModel([]string{"a.go"}, nil) + m.styles = plainStyles() + help := m.helpOverlay() + + assert.Contains(t, help, "Search") + assert.Contains(t, help, "search in diff") + assert.Contains(t, help, "next match") + assert.Contains(t, help, "prev match") + assert.Contains(t, help, "n = next match when searching") +} + +func TestModel_StartSearch(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "added", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // press / to start search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + + assert.True(t, model.searching, "should be in searching mode") + assert.True(t, model.searchInput.Focused(), "search input should be focused") +} + +func TestModel_StartSearchOnlyFromDiffPane(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneTree + + // press / in tree pane - should not start search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + assert.False(t, model.searching, "should not search from tree pane") +} + +func TestModel_SubmitSearchFindsMatches(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "hello world", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "foo bar", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "hello again", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.diffCursor = 0 + + // start search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + + // type "hello" + for _, ch := range "hello" { + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + model = result.(Model) + } + + // submit with enter + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + + assert.False(t, model.searching, "should exit searching mode") + assert.Equal(t, "hello", model.searchTerm) + assert.Equal(t, []int{0, 2}, model.searchMatches) + assert.Equal(t, 0, model.searchCursor) + assert.Equal(t, 0, model.diffCursor, "cursor should be on first match") +} + +func TestModel_SubmitSearchCaseInsensitive(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "Hello World", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "HELLO again", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + model.searching = true + model.searchInput = textinput.New() + model.searchInput.SetValue("hello") + + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + + assert.Equal(t, []int{0, 1}, model.searchMatches, "should match case-insensitively") +} + +func TestModel_SubmitSearchJumpsForwardFromCursor(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match here", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "foo bar", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "match again", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.diffCursor = 1 // cursor past first match + + model.searching = true + model.searchInput = textinput.New() + model.searchInput.SetValue("match") + + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + + assert.Equal(t, 1, model.searchCursor, "should jump to second match (index 1)") + assert.Equal(t, 2, model.diffCursor, "cursor should be on second match line") +} + +func TestModel_SubmitSearchWrapsToFirstMatch(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match here", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "foo bar", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.diffCursor = 1 // cursor past all matches + + model.searching = true + model.searchInput = textinput.New() + model.searchInput.SetValue("match") + + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + + assert.Equal(t, 0, model.searchCursor, "should wrap to first match") + assert.Equal(t, 0, model.diffCursor, "cursor should be on first match line") +} + +func TestModel_SubmitSearchNoMatches(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "hello", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + model.searching = true + model.searchInput = textinput.New() + model.searchInput.SetValue("xyz") + + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + + assert.False(t, model.searching) + assert.Equal(t, "xyz", model.searchTerm) + assert.Empty(t, model.searchMatches) +} + +func TestModel_SubmitEmptySearchClearsMatches(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "hello", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // set up existing search state + model.searchTerm = "hello" + model.searchMatches = []int{0} + model.searchCursor = 0 + + // start search with empty input + model.searching = true + model.searchInput = textinput.New() + model.searchInput.SetValue("") + + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = result.(Model) + + assert.False(t, model.searching) + assert.Empty(t, model.searchTerm) + assert.Empty(t, model.searchMatches) +} + +func TestModel_CancelSearch(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "hello", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // start search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + require.True(t, model.searching) + + // cancel with esc + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = result.(Model) + + assert.False(t, model.searching, "should exit searching mode on esc") +} + +func TestModel_CancelSearchPreservesExistingMatches(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "hello", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // set up existing search state + model.searchTerm = "hello" + model.searchMatches = []int{0} + + // start and cancel new search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = result.(Model) + + assert.Equal(t, "hello", model.searchTerm, "existing search term should be preserved on cancel") + assert.Equal(t, []int{0}, model.searchMatches, "existing matches should be preserved on cancel") +} + +func TestModel_SearchInputForwardsCharacters(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "hello", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // start search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + + // type characters + for _, ch := range "test" { + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + model = result.(Model) + } + + assert.Equal(t, "test", model.searchInput.Value(), "characters should be forwarded to search input") +} + +func TestModel_SearchBlocksOtherKeysWhileActive(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "hello", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // start search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + + // pressing q should not quit, it should type 'q' + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + model = result.(Model) + + assert.True(t, model.searching, "should still be searching") + assert.Contains(t, model.searchInput.Value(), "q") +} + +func TestModel_SearchForwardsNonKeyMessages(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "hello", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // start search + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = result.(Model) + require.True(t, model.searching) + + // send a non-key message; should not panic and model stays searching + type customMsg struct{} + result, _ = model.Update(customMsg{}) + model = result.(Model) + assert.True(t, model.searching, "searching should remain true after non-key message") +} + +func TestModel_NextSearchMatch(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "no match", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "match two", ChangeType: diff.ChangeContext}, + {NewNum: 4, Content: "match three", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.searchMatches = []int{0, 2, 3} + model.searchCursor = 0 + model.diffCursor = 0 + + // press n to go to next match + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = result.(Model) + assert.Equal(t, 1, model.searchCursor, "search cursor should advance to 1") + assert.Equal(t, 2, model.diffCursor, "diff cursor should move to second match") + + // press n again + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = result.(Model) + assert.Equal(t, 2, model.searchCursor, "search cursor should advance to 2") + assert.Equal(t, 3, model.diffCursor, "diff cursor should move to third match") +} + +func TestModel_NextSearchMatchWrapsAround(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "no match", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "match two", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.searchMatches = []int{0, 2} + model.searchCursor = 1 // on last match + model.diffCursor = 2 + + // press n should wrap to first match + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = result.(Model) + assert.Equal(t, 0, model.searchCursor, "search cursor should wrap to 0") + assert.Equal(t, 0, model.diffCursor, "diff cursor should wrap to first match") +} + +func TestModel_PrevSearchMatch(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "no match", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "match two", ChangeType: diff.ChangeContext}, + {NewNum: 4, Content: "match three", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.searchMatches = []int{0, 2, 3} + model.searchCursor = 2 + model.diffCursor = 3 + + // press N to go to prev match + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) + model = result.(Model) + assert.Equal(t, 1, model.searchCursor, "search cursor should go back to 1") + assert.Equal(t, 2, model.diffCursor, "diff cursor should move to second match") +} + +func TestModel_PrevSearchMatchWrapsAround(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "no match", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "match two", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.searchMatches = []int{0, 2} + model.searchCursor = 0 // on first match + model.diffCursor = 0 + + // press N should wrap to last match + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) + model = result.(Model) + assert.Equal(t, 1, model.searchCursor, "search cursor should wrap to last") + assert.Equal(t, 2, model.diffCursor, "diff cursor should wrap to last match") +} + +func TestModel_SearchNavigationSkipsCollapsedHiddenLines(t *testing.T) { + // in collapsed mode, removed lines are hidden. search navigation must skip them. + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "match removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "match added", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "match end", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.collapsed.enabled = true + model.collapsed.expandedHunks = make(map[int]bool) + // matches on indices 0 (ctx), 1 (hidden remove), 2 (add), 3 (ctx) + model.searchMatches = []int{0, 1, 2, 3} + model.searchCursor = 0 + model.diffCursor = 0 + + t.Run("nextSearchMatch skips hidden removed line", func(t *testing.T) { + m := model + m.nextSearchMatch() + assert.Equal(t, 2, m.searchCursor, "should skip hidden index 1, land on index 2") + assert.Equal(t, 2, m.diffCursor, "cursor should be on visible add line") + }) + + t.Run("prevSearchMatch skips hidden removed line", func(t *testing.T) { + m := model + m.searchCursor = 2 // on index 2 (add line) + m.diffCursor = 2 + m.prevSearchMatch() + assert.Equal(t, 0, m.searchCursor, "should skip hidden index 1, land on index 0") + assert.Equal(t, 0, m.diffCursor, "cursor should be on visible context line") + }) + + t.Run("submitSearch skips hidden match for initial jump", func(t *testing.T) { + m := model + m.diffCursor = 1 // cursor on hidden line + m.searchTerm = "" + m.searchMatches = nil + m.searchInput = textinput.New() + m.searchInput.SetValue("match") + m.submitSearch() + // should jump to index 2 (visible add) not index 1 (hidden remove) + assert.Equal(t, 2, m.diffCursor, "should skip hidden remove and land on visible add") + }) +} + +func TestModel_NKeyFallsThroughToNextFileWhenNoSearch(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go", "b.go"}, map[string][]diff.DiffLine{ + "a.go": lines, "b.go": lines, + }) + m.tree = newFileTree([]string{"a.go", "b.go"}) + m.currFile = "a.go" + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + + // no search active, n should advance to next file + assert.Empty(t, model.searchMatches) + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model = result.(Model) + assert.Equal(t, "b.go", model.tree.selectedFile(), "n should go to next file when no search active") +} + +func TestModel_ShiftNDoesPrevMatchWhenSearchActive(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match one", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "match two", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.searchMatches = []int{0, 1} + model.searchCursor = 1 + model.diffCursor = 1 + + // press N (shift-n) + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) + model = result.(Model) + assert.Equal(t, 0, model.searchCursor, "N should go to prev match") + assert.Equal(t, 0, model.diffCursor, "cursor should be on first match") +} + +func TestModel_ShiftNDoesNothingWithoutSearch(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go", "b.go"}, map[string][]diff.DiffLine{ + "a.go": lines, "b.go": lines, + }) + m.tree = newFileTree([]string{"a.go", "b.go"}) + m.currFile = "a.go" + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + + // no search active, N should do nothing + assert.Empty(t, model.searchMatches) + selected := model.tree.selectedFile() + result, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) + model = result.(Model) + assert.Equal(t, selected, model.tree.selectedFile(), "N should not change file when no search") +} + +func TestModel_SearchHighlightInRenderDiff(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "package main", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "func hello() {}", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "func world() {}", ChangeType: diff.ChangeAdd}, + {OldNum: 4, Content: "old line", ChangeType: diff.ChangeRemove}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.currFile = "a.go" + m.diffLines = lines + m.highlightedLines = noopHighlighter().HighlightLines("a.go", lines) + m.focus = paneDiff + m.diffCursor = 0 + m.styles = plainStyles() + + t.Run("no search, renderDiff succeeds with all lines", func(t *testing.T) { + m.searchMatches = nil + m.searchMatchSet = nil + rendered := m.renderDiff() + assert.Contains(t, rendered, "package main") + assert.Contains(t, ansi.Strip(rendered), "func hello") + assert.Contains(t, rendered, "func world") + assert.Contains(t, rendered, "old line") + }) + + t.Run("search active, renderDiff includes matched content", func(t *testing.T) { + m.searchTerm = "hello" + m.searchMatches = []int{1} + m.searchCursor = 0 + rendered := m.renderDiff() + // matched and non-matched lines should both be rendered + assert.Contains(t, ansi.Strip(rendered), "func hello") + assert.Contains(t, rendered, "func world") + assert.Contains(t, rendered, "old line") + }) + + t.Run("search vs no search both render content correctly", func(t *testing.T) { + m.searchTerm = "hello" + m.searchMatches = []int{1} + m.searchCursor = 0 + renderedWithSearch := m.renderDiff() + + m.searchMatches = nil + renderedWithout := m.renderDiff() + + // both should contain the same text content + assert.Contains(t, ansi.Strip(renderedWithSearch), "func hello") + assert.Contains(t, ansi.Strip(renderedWithout), "func hello") + assert.Contains(t, renderedWithSearch, "func world") + assert.Contains(t, renderedWithout, "func world") + }) + + t.Run("cursor coexists with search highlight", func(t *testing.T) { + m.searchTerm = "hello" + m.searchMatches = []int{1} + m.searchCursor = 0 + m.diffCursor = 1 + rendered := m.renderDiff() + + outputLines := strings.Split(rendered, "\n") + var matchLine string + for _, l := range outputLines { + if strings.Contains(l, "hello") { + matchLine = l + } + } + require.NotEmpty(t, matchLine) + assert.Contains(t, matchLine, "▶", "cursor should be present on matched line") + assert.Contains(t, ansi.Strip(matchLine), "func hello", "content should be preserved with cursor on match") + }) +} + +func TestModel_SearchHighlightWithWrap(t *testing.T) { + longContent := "this is a very long line that contains the search term hello somewhere in the middle and should wrap" + lines := []diff.DiffLine{ + {NewNum: 1, Content: longContent, ChangeType: diff.ChangeAdd}, + {NewNum: 2, Content: "short line", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.currFile = "a.go" + m.diffLines = lines + m.highlightedLines = noopHighlighter().HighlightLines("a.go", lines) + m.focus = paneDiff + m.diffCursor = 0 + m.wrapMode = true + m.width = 60 + m.treeWidth = 12 + m.styles = plainStyles() + + m.searchTerm = "hello" + m.searchMatches = []int{0} + m.searchCursor = 0 + + rendered := m.renderDiff() + outputLines := strings.Split(strings.TrimSuffix(rendered, "\n"), "\n") + + // the long line should produce continuation rows with ↪ + var continuationCount int + for _, l := range outputLines { + if strings.Contains(l, "↪") { + continuationCount++ + } + } + assert.Positive(t, continuationCount, "wrapped search match should have continuation lines") + + // verify content is present (text flows through the rendering path correctly) + assert.Contains(t, rendered, "hello") + assert.Contains(t, rendered, "short line") +} + +func TestModel_SearchHighlightInCollapsedMode(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "context line", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed line", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added hello line", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "added other line", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.currFile = "a.go" + m.diffLines = lines + m.highlightedLines = noopHighlighter().HighlightLines("a.go", lines) + m.focus = paneDiff + m.diffCursor = 0 + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + + t.Run("collapsed renders with search matches", func(t *testing.T) { + m.searchTerm = "hello" + m.searchMatches = []int{2} + m.searchCursor = 0 + rendered := m.renderDiff() + + assert.Contains(t, rendered, "added hello line") + assert.Contains(t, rendered, "added other line") + }) + + t.Run("collapsed without search has no match set", func(t *testing.T) { + m.searchMatches = nil + m.searchMatchSet = nil + rendered := m.renderDiff() + + assert.Contains(t, rendered, "added hello line") + assert.Nil(t, m.searchMatchSet, "no search should produce nil match set") + }) +} + +func TestModel_StyleDiffContentSearchMatch(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + + t.Run("search match returns same text content", func(t *testing.T) { + resultMatch := m.styleDiffContent(diff.ChangeAdd, " + ", "content", false, true) + resultNoMatch := m.styleDiffContent(diff.ChangeAdd, " + ", "content", false, false) + assert.Contains(t, resultMatch, " + content") + assert.Contains(t, resultNoMatch, " + content") + }) + + t.Run("search match with highlight preserves content", func(t *testing.T) { + result := m.styleDiffContent(diff.ChangeAdd, " + ", "\033[32mgreen\033[0m", true, true) + assert.Contains(t, result, " + ") + assert.Contains(t, result, "\033[32m", "chroma foreground should be preserved") + }) + + t.Run("search match uses different style than normal add", func(t *testing.T) { + // use newStyles with distinct colors so rendering produces different output + c := Colors{ + Accent: "#ffffff", Border: "#555555", Normal: "#cccccc", Muted: "#666666", + SelectedFg: "#ffffff", SelectedBg: "#333333", Annotation: "#ff9900", + AddFg: "#00ff00", AddBg: "#002200", RemoveFg: "#ff0000", RemoveBg: "#220000", + ModifyFg: "#ffaa00", ModifyBg: "#221100", + SearchFg: "#1a1a1a", SearchBg: "#d7d700", + } + m.styles = newStyles(c) + resultMatch := m.styleDiffContent(diff.ChangeAdd, " + ", "content", false, true) + resultNoMatch := m.styleDiffContent(diff.ChangeAdd, " + ", "content", false, false) + // both have same text but may differ in ANSI sequences (depends on terminal detection) + // the key test is that both contain the content and the code paths don't panic + assert.Contains(t, resultMatch, "content") + assert.Contains(t, resultNoMatch, "content") + }) +} + +func TestModel_BuildSearchMatchSet(t *testing.T) { + m := testModel(nil, nil) + + t.Run("empty matches produces nil set", func(t *testing.T) { + m.searchMatches = nil + m.buildSearchMatchSet() + assert.Nil(t, m.searchMatchSet) + }) + + t.Run("matches produce correct set", func(t *testing.T) { + m.searchMatches = []int{1, 5, 10} + m.buildSearchMatchSet() + assert.True(t, m.searchMatchSet[1]) + assert.True(t, m.searchMatchSet[5]) + assert.True(t, m.searchMatchSet[10]) + assert.False(t, m.searchMatchSet[0]) + assert.False(t, m.searchMatchSet[3]) + }) +} + +func TestModel_ClearSearchResetsMatchSet(t *testing.T) { + m := testModel(nil, nil) + m.searchTerm = "test" + m.searchMatches = []int{1, 2} + m.searchCursor = 1 + m.searchMatchSet = map[int]bool{1: true, 2: true} + + m.clearSearch() + + assert.Empty(t, m.searchTerm) + assert.Nil(t, m.searchMatches) + assert.Equal(t, 0, m.searchCursor) + assert.Nil(t, m.searchMatchSet) +} + +func TestModel_StatusBarShowsSearchInput(t *testing.T) { + m := testModel([]string{"a.go"}, nil) + m.width = 120 + m.currFile = "a.go" + m.searching = true + m.searchInput = textinput.New() + m.searchInput.SetValue("hello") + + status := m.statusBarText() + assert.Contains(t, status, "/hello", "should show search prompt with value") + assert.NotContains(t, status, "a.go", "filename should not appear during search input") +} + +func TestModel_StatusBarSearchInputTakesPriority(t *testing.T) { + m := testModel([]string{"a.go"}, nil) + m.width = 120 + m.currFile = "a.go" + m.searching = true + m.searchInput = textinput.New() + m.inConfirmDiscard = true // should not show discard prompt + + status := m.statusBarText() + assert.Contains(t, status, "/", "search input should take priority over discard") + assert.NotContains(t, status, "discard") +} + +func TestModel_StatusBarSearchMatchPosition(t *testing.T) { + tests := []struct { + name string + matches []int + cursor int + wantContains string + wantAbsent string + }{ + {name: "first of three", matches: []int{0, 2, 5}, cursor: 0, wantContains: "1/3"}, + {name: "second of three", matches: []int{0, 2, 5}, cursor: 1, wantContains: "2/3"}, + {name: "third of three", matches: []int{0, 2, 5}, cursor: 2, wantContains: "3/3"}, + {name: "single match", matches: []int{1}, cursor: 0, wantContains: "1/1"}, + {name: "no matches", matches: nil, cursor: 0, wantAbsent: "["}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "add", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m.focus = paneDiff + m.width = 200 + m.searchMatches = tt.matches + m.searchCursor = tt.cursor + + status := m.statusBarText() + if tt.wantContains != "" { + assert.Contains(t, status, tt.wantContains) + } + if tt.wantAbsent != "" { + assert.NotContains(t, status, tt.wantAbsent) + } + }) + } +} + +func TestModel_SearchSegment(t *testing.T) { + m := testModel(nil, nil) + + // no matches + assert.Empty(t, m.searchSegment()) + + // with matches + m.searchMatches = []int{0, 3, 7} + m.searchCursor = 1 + assert.Equal(t, "2/3", m.searchSegment()) + + // all matches on hidden removed lines in collapsed mode shows [0/N] + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed match", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx end", ChangeType: diff.ChangeContext}, + } + m2 := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m2.diffLines = lines + m2.currFile = "a.go" + m2.collapsed.enabled = true + m2.collapsed.expandedHunks = make(map[int]bool) + m2.searchMatches = []int{1} // only on hidden removed line + m2.searchCursor = 0 + assert.Equal(t, "0/1", m2.searchSegment(), "should show [0/N] when all matches are hidden") +} + +func TestModel_StatusBarSearchPositionBetweenHunkAndIcons(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "add", ChangeType: diff.ChangeAdd}, + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.diffCursor = 1 + m.fileAdds = 1 + m.focus = paneDiff + m.width = 200 + m.searchMatches = []int{1} + m.searchCursor = 0 + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + + status := m.statusBarText() + // all three should be present + assert.Contains(t, status, "hunk 1/1") + assert.Contains(t, status, "1/1") + assert.Contains(t, status, "▼") + + // [1/1] should appear after hunk and before ▼ + hunkIdx := strings.Index(status, "hunk 1/1") + searchIdx := strings.Index(status, "1/1") + iconIdx := strings.Index(status, "▼") + assert.Greater(t, searchIdx, hunkIdx, "search position should appear after hunk") + assert.Less(t, searchIdx, iconIdx, "search position should appear before mode icons") +} + +func TestModel_ClearSearchOnFileLoad(t *testing.T) { + lines1 := []diff.DiffLine{ + {NewNum: 1, Content: "hello world", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "hello again", ChangeType: diff.ChangeAdd}, + } + lines2 := []diff.DiffLine{ + {NewNum: 1, Content: "other content", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go", "b.go"}, map[string][]diff.DiffLine{"a.go": lines1, "b.go": lines2}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", seq: model.loadSeq, lines: lines1}) + model = result.(Model) + model.focus = paneDiff + + // set up search state as if user searched for "hello" + model.searchTerm = "hello" + model.searchMatches = []int{0, 1} + model.searchCursor = 1 + model.searchMatchSet = map[int]bool{0: true, 1: true} + + // load a different file + model.loadSeq++ + result, _ = model.Update(fileLoadedMsg{file: "b.go", seq: model.loadSeq, lines: lines2}) + model = result.(Model) + + assert.Empty(t, model.searchTerm, "search term should be cleared on file load") + assert.Nil(t, model.searchMatches, "search matches should be cleared on file load") + assert.Equal(t, 0, model.searchCursor, "search cursor should be reset on file load") + assert.Nil(t, model.searchMatchSet, "search match set should be cleared on file load") +} + +func TestModel_StatusBarNarrowDropsSearchSegment(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "add", ChangeType: diff.ChangeAdd}, + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.diffCursor = 1 + m.fileAdds = 1 + m.focus = paneDiff + m.searchMatches = []int{1} + m.searchCursor = 0 + + t.Run("wide terminal shows search segment", func(t *testing.T) { + m.width = 200 + status := m.statusBarText() + assert.Contains(t, status, "1/1") + }) + + t.Run("very narrow terminal drops search with hunk", func(t *testing.T) { + m.width = 28 + status := m.statusBarText() + assert.NotContains(t, status, "1/1", "search segment should be dropped on very narrow terminal") + assert.Contains(t, status, "? help") + }) +} + +func TestModel_RealignSearchCursorOnCollapsedToggle(t *testing.T) { + // when toggling collapsed mode, searchCursor must realign to nearest visible match + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "match removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "match added", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "match end", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // set up search with cursor on the removed line (index 1) + model.searchMatches = []int{0, 1, 2, 3} + model.searchCursor = 1 + model.diffCursor = 1 + + // toggle collapsed mode, which hides removed lines + model.toggleCollapsedMode() + + assert.True(t, model.collapsed.enabled) + assert.NotEqual(t, 1, model.diffCursor, "cursor should have moved off hidden removed line") + assert.NotEqual(t, 1, model.searchCursor, "searchCursor should realign away from hidden match") + // searchCursor should point to a visible match + if model.searchCursor < len(model.searchMatches) { + matchIdx := model.searchMatches[model.searchCursor] + hunks := model.findHunks() + assert.False(t, model.isCollapsedHidden(matchIdx, hunks), "realigned searchCursor should point to a visible match") + } +} + +func TestModel_RealignSearchCursorOnHunkCollapse(t *testing.T) { + // when collapsing a hunk, searchCursor must realign if current match becomes hidden + lines := []diff.DiffLine{ + {NewNum: 1, Content: "match ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "match removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "match added", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx end", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // start in collapsed mode with hunk expanded (hunk starts at index 1, first change line) + model.collapsed.enabled = true + model.collapsed.expandedHunks = map[int]bool{1: true} + model.searchMatches = []int{0, 1, 2, 3} + model.searchCursor = 1 // on removed line (visible because hunk is expanded) + model.diffCursor = 1 + + // collapse the hunk — removed line becomes hidden + model.toggleHunkExpansion() + + assert.NotContains(t, model.collapsed.expandedHunks, 1, "hunk should be collapsed") + // searchCursor should have realigned to a visible match + if len(model.searchMatches) > 0 && model.searchCursor < len(model.searchMatches) { + matchIdx := model.searchMatches[model.searchCursor] + hunks := model.findHunks() + assert.False(t, model.isCollapsedHidden(matchIdx, hunks), "searchCursor should point to visible match after hunk collapse") + } +} + +func TestModel_RealignSearchCursorNoopWithoutSearch(t *testing.T) { + // realignSearchCursor should be a no-op when no search is active + lines := []diff.DiffLine{ + {NewNum: 1, Content: "context", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.searchMatches = nil + model.searchCursor = 0 + + // should not panic or change anything + model.realignSearchCursor() + assert.Equal(t, 0, model.searchCursor) +} + +func TestModel_SubmitSearchPreservesLeadingWhitespace(t *testing.T) { + // search query with leading/trailing whitespace should be preserved in the search term + lines := []diff.DiffLine{ + {NewNum: 1, Content: " indented line", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "normal line", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + model.searchInput = textinput.New() + model.searchInput.SetValue(" indented") + model.submitSearch() + + assert.Equal(t, " indented", model.searchTerm, "leading whitespace should be preserved in search term") + assert.Equal(t, []int{0}, model.searchMatches, "should match the indented line") +} + +func TestModel_SubmitSearchWhitespaceOnlyClearsSearch(t *testing.T) { + // pure whitespace query should clear search (same as empty) + lines := []diff.DiffLine{ + {NewNum: 1, Content: "line", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + + // pre-populate search state + model.searchTerm = "old" + model.searchMatches = []int{0} + model.searchCursor = 0 + + model.searchInput = textinput.New() + model.searchInput.SetValue(" ") + model.submitSearch() + + assert.Empty(t, model.searchTerm, "whitespace-only query should clear search") + assert.Nil(t, model.searchMatches) +} + +func TestModel_DeletePlaceholderSearchHighlight(t *testing.T) { + // delete-only placeholder should render correctly with and without search match. + // verifies the code path doesn't panic and produces correct text content. + // (actual ANSI styling differences depend on terminal detection) + lines := []diff.DiffLine{ + {NewNum: 1, Content: "context", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "deleted match", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "deleted other", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "context end", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := result.(Model) + result, _ = model.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model = result.(Model) + model.focus = paneDiff + model.styles = plainStyles() + model.collapsed.enabled = true + model.collapsed.expandedHunks = make(map[int]bool) + model.diffCursor = 1 + + t.Run("with search match", func(t *testing.T) { + model.searchMatchSet = map[int]bool{1: true} + var b strings.Builder + model.renderDeletePlaceholder(&b, 1, 1) + rendered := b.String() + assert.Contains(t, rendered, "2 lines deleted") + assert.Contains(t, rendered, "▶", "cursor indicator should be present") + }) + + t.Run("without search match", func(t *testing.T) { + model.searchMatchSet = nil + var b strings.Builder + model.renderDeletePlaceholder(&b, 1, 1) + rendered := b.String() + assert.Contains(t, rendered, "2 lines deleted") + assert.Contains(t, rendered, "▶") + }) + + t.Run("with wrap mode and search match", func(t *testing.T) { + model.searchMatchSet = map[int]bool{1: true} + model.wrapMode = true + model.width = 120 + model.treeWidth = 30 + var b strings.Builder + model.renderDeletePlaceholder(&b, 1, 1) + rendered := b.String() + assert.Contains(t, rendered, "2 lines deleted") + model.wrapMode = false + }) +} diff --git a/ui/search.go b/ui/search.go new file mode 100644 index 00000000..c7cd3f33 --- /dev/null +++ b/ui/search.go @@ -0,0 +1,187 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// startSearch creates a search textinput and enters searching mode. +func (m *Model) startSearch() tea.Cmd { + ti := textinput.New() + ti.Placeholder = "/" + cmd := ti.Focus() + ti.CharLimit = 200 + ti.Width = max(10, m.width-m.treeWidth-10) + m.searchInput = ti + m.searching = true + return cmd +} + +// submitSearch processes the search query and finds matches in diffLines. +// empty input clears the search. otherwise stores lowercase term, scans for +// case-insensitive matches, and jumps to the first match at or after the cursor. +func (m *Model) submitSearch() { + query := m.searchInput.Value() + if strings.TrimSpace(query) == "" { + m.clearSearch() + m.searching = false + return + } + + m.searchTerm = strings.ToLower(query) + m.searchMatches = nil + m.searchCursor = 0 + + for i, dl := range m.diffLines { + if strings.Contains(strings.ToLower(dl.Content), m.searchTerm) { + m.searchMatches = append(m.searchMatches, i) + } + } + + m.searching = false + + if len(m.searchMatches) == 0 { + return + } + + hunks := m.findHunks() + + // find first visible match at or after current cursor position + for i, idx := range m.searchMatches { + if idx < m.diffCursor || m.isCollapsedHidden(idx, hunks) { + continue + } + m.searchCursor = i + m.diffCursor = idx + m.cursorOnAnnotation = false + m.syncViewportToCursor() + return + } + + // wrap: find first visible match from the beginning + for i, idx := range m.searchMatches { + if m.isCollapsedHidden(idx, hunks) { + continue + } + m.searchCursor = i + m.diffCursor = idx + m.cursorOnAnnotation = false + m.syncViewportToCursor() + return + } +} + +// nextSearchMatch advances to the next search match with wrap-around. +// in collapsed mode, hidden removed lines are skipped. +func (m *Model) nextSearchMatch() { + if len(m.searchMatches) == 0 { + return + } + hunks := m.findHunks() + start := m.searchCursor + for { + m.searchCursor = (m.searchCursor + 1) % len(m.searchMatches) + if !m.isCollapsedHidden(m.searchMatches[m.searchCursor], hunks) { + break + } + if m.searchCursor == start { + return // all matches are hidden + } + } + m.diffCursor = m.searchMatches[m.searchCursor] + m.cursorOnAnnotation = false + m.syncViewportToCursor() +} + +// prevSearchMatch moves to the previous search match with wrap-around. +// in collapsed mode, hidden removed lines are skipped. +func (m *Model) prevSearchMatch() { + if len(m.searchMatches) == 0 { + return + } + hunks := m.findHunks() + start := m.searchCursor + for { + m.searchCursor-- + if m.searchCursor < 0 { + m.searchCursor = len(m.searchMatches) - 1 + } + if !m.isCollapsedHidden(m.searchMatches[m.searchCursor], hunks) { + break + } + if m.searchCursor == start { + return // all matches are hidden + } + } + m.diffCursor = m.searchMatches[m.searchCursor] + m.cursorOnAnnotation = false + m.syncViewportToCursor() +} + +// cancelSearch exits searching mode without submitting. +func (m *Model) cancelSearch() { + m.searching = false +} + +// realignSearchCursor updates searchCursor to the nearest visible match at or after diffCursor. +// called after adjustCursorIfHidden moves diffCursor so the [X/Y] display stays accurate +// and n/N navigation starts from the correct position. +func (m *Model) realignSearchCursor() { + if len(m.searchMatches) == 0 { + return + } + hunks := m.findHunks() + // find first visible match at or after current cursor + for i, idx := range m.searchMatches { + if idx >= m.diffCursor && !m.isCollapsedHidden(idx, hunks) { + m.searchCursor = i + return + } + } + // wrap: find first visible match from the beginning + for i, idx := range m.searchMatches { + if !m.isCollapsedHidden(idx, hunks) { + m.searchCursor = i + return + } + } +} + +// clearSearch resets all search state. +func (m *Model) clearSearch() { + m.searchTerm = "" + m.searchMatches = nil + m.searchCursor = 0 + m.searchMatchSet = nil +} + +// buildSearchMatchSet converts searchMatches slice into a map for O(1) lookup during rendering. +func (m *Model) buildSearchMatchSet() { + if len(m.searchMatches) == 0 { + m.searchMatchSet = nil + return + } + m.searchMatchSet = make(map[int]bool, len(m.searchMatches)) + for _, idx := range m.searchMatches { + m.searchMatchSet[idx] = true + } +} + +// handleSearchKey handles key messages during search input mode. +func (m Model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + m.submitSearch() + m.viewport.SetContent(m.renderDiff()) // refresh viewport to clear/update highlights + return m, nil + case tea.KeyEsc: + m.cancelSearch() + return m, nil + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd + } +} diff --git a/ui/styles.go b/ui/styles.go index 0025cd3c..e6e87ef9 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -23,6 +23,8 @@ type Colors struct { DiffBg string // diff pane background StatusFg string // status bar foreground StatusBg string // status bar background + SearchFg string // search match foreground + SearchBg string // search match background } // styles holds all lipgloss styles used in the TUI. @@ -56,6 +58,8 @@ type styles struct { DiffCursorLine lipgloss.Style // annotation AnnotationLine lipgloss.Style + // search + SearchMatch lipgloss.Style colors Colors // original color values for dynamic style construction } @@ -90,6 +94,8 @@ func normalizeColors(c Colors) Colors { c.DiffBg = normalizeColor(c.DiffBg) c.StatusFg = normalizeColor(c.StatusFg) c.StatusBg = normalizeColor(c.StatusBg) + c.SearchFg = normalizeColor(c.SearchFg) + c.SearchBg = normalizeColor(c.SearchBg) return c } @@ -173,6 +179,9 @@ func newStyles(c Colors) styles { AnnotationLine: lipgloss.NewStyle(). Foreground(lipgloss.Color(c.Annotation)). Italic(true), + SearchMatch: lipgloss.NewStyle(). + Foreground(lipgloss.Color(c.SearchFg)). + Background(lipgloss.Color(c.SearchBg)), colors: c, } @@ -219,5 +228,6 @@ func plainStyles() styles { DiffCursorLine: lipgloss.NewStyle().Reverse(true), AnnotationLine: lipgloss.NewStyle().Italic(true), + SearchMatch: lipgloss.NewStyle().Reverse(true), } } diff --git a/ui/styles_test.go b/ui/styles_test.go index 99e34cb8..6278cdf5 100644 --- a/ui/styles_test.go +++ b/ui/styles_test.go @@ -27,6 +27,7 @@ func TestNormalizeColors(t *testing.T) { Accent: "5f87ff", Border: "#585858", Normal: "d0d0d0", ModifyFg: "f5c542", ModifyBg: "#3D2E00", TreeBg: "1a1a1a", DiffBg: "", StatusFg: "aabbcc", StatusBg: "", + SearchFg: "1a1a1a", SearchBg: "#d7d700", }) assert.Equal(t, "#5f87ff", c.Accent, "should add # prefix") assert.Equal(t, "#585858", c.Border, "should keep existing #") @@ -37,6 +38,8 @@ func TestNormalizeColors(t *testing.T) { assert.Empty(t, c.DiffBg, "empty should stay empty") assert.Equal(t, "#aabbcc", c.StatusFg) assert.Empty(t, c.StatusBg, "empty should stay empty") + assert.Equal(t, "#1a1a1a", c.SearchFg, "should add # prefix to search fg") + assert.Equal(t, "#d7d700", c.SearchBg, "should keep existing # on search bg") } func TestNewStyles_OptionalBackgrounds(t *testing.T) { @@ -91,3 +94,20 @@ func TestPlainStyles_ModifyStyles(t *testing.T) { assert.NotEmpty(t, s.LineModify.Render("text")) assert.NotEmpty(t, s.LineModifyHighlight.Render("text")) } + +func TestNewStyles_SearchMatchStyle(t *testing.T) { + s := newStyles(Colors{ + Accent: "#5f87ff", Border: "#585858", Normal: "#d0d0d0", Muted: "#6c6c6c", + SelectedFg: "#ffffaf", SelectedBg: "#303030", Annotation: "#ffd700", + CursorBg: "#3a3a3a", + AddFg: "#87d787", AddBg: "#022800", RemoveFg: "#ff8787", RemoveBg: "#3D0100", + SearchFg: "#1a1a1a", SearchBg: "#d7d700", + }) + assert.NotNil(t, s.SearchMatch) + assert.NotEmpty(t, s.SearchMatch.Render("matched text")) +} + +func TestPlainStyles_SearchMatchStyle(t *testing.T) { + s := plainStyles() + assert.NotEmpty(t, s.SearchMatch.Render("matched text")) +}