diff --git a/.claude-plugin/skills/revdiff/references/config.md b/.claude-plugin/skills/revdiff/references/config.md index 3182ae89..fa556fa9 100644 --- a/.claude-plugin/skills/revdiff/references/config.md +++ b/.claude-plugin/skills/revdiff/references/config.md @@ -48,6 +48,8 @@ All color options accept hex values (`#rrggbb`) and have corresponding `REVDIFF_ | `--color-add-bg` | Added line background | `#123800` | | `--color-remove-fg` | Removed line text | `#ff8787` | | `--color-remove-bg` | Removed line background | `#4D1100` | +| `--color-modify-fg` | Modified line text (collapsed mode) | `#f5c542` | +| `--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` | diff --git a/.claude-plugin/skills/revdiff/references/usage.md b/.claude-plugin/skills/revdiff/references/usage.md index 1718ba9d..0a388b50 100644 --- a/.claude-plugin/skills/revdiff/references/usage.md +++ b/.claude-plugin/skills/revdiff/references/usage.md @@ -43,6 +43,8 @@ revdiff HEAD~1 # review last commit | Key | Action | |-----|--------| +| `v` | Toggle collapsed diff mode (shows final text with change markers) | +| `.` | Expand/collapse individual hunk under cursor (collapsed mode only) | | `f` | Toggle filter: all files / annotated only | | `q` | Quit, output annotations to stdout | | `Q` | Discard all annotations and quit (confirms if annotations exist) | diff --git a/CLAUDE.md b/CLAUDE.md index 0eecccb4..bc418d17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,11 @@ Terminal UI diff viewer with inline annotations, built with bubbletea. ``` git diff → diff.ParseUnifiedDiff() → []DiffLine → highlight.HighlightLines() → []string (ANSI foreground-only) - → ui.renderDiffLine() → lipgloss styles (background) + chroma (foreground) + → ui.renderDiff() dispatches: + expanded (default): renderDiffLine() for each line + collapsed (`v` toggle): renderCollapsedDiff() → skips removed lines, + uses buildModifiedSet() to style adds as modify (amber ~) or pure add (green +) + expanded hunks (`.` toggle) show all lines inline → viewport.SetContent() → terminal ``` diff --git a/README.md b/README.md index 264228d9..05943ee2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Built for a specific use case: reviewing code changes without leaving a terminal - Structured annotation output to stdout - pipe into AI agents, scripts, or other tools - Full-file diff view with syntax highlighting +- Collapsed diff mode: shows final text with change markers, toggle with `v` - Annotate any line in the diff (added, removed, or context) plus file-level notes - Two-pane TUI: file tree (left) + colorized diff viewport (right) - Hunk navigation to jump between change groups @@ -163,6 +164,8 @@ All color options accept hex values (`#rrggbb`) and have corresponding `REVDIFF_ | `--color-add-bg` | Added line background | `#123800` | | `--color-remove-fg` | Removed line text | `#ff8787` | | `--color-remove-bg` | Removed line background | `#4D1100` | +| `--color-modify-fg` | Modified line text (collapsed mode) | `#f5c542` | +| `--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` | @@ -227,6 +230,8 @@ revdiff HEAD~1 | Key | Action | |-----|--------| +| `v` | Toggle collapsed diff mode (shows final text with change markers) | +| `.` | Expand/collapse individual hunk under cursor (collapsed mode only) | | `f` | Toggle filter: all files / annotated only (shown when annotations exist) | | `q` | Quit, output annotations to stdout | | `Q` | Discard all annotations and quit (confirms if annotations exist) | diff --git a/cmd/revdiff/main.go b/cmd/revdiff/main.go index 267944cb..457f7aec 100644 --- a/cmd/revdiff/main.go +++ b/cmd/revdiff/main.go @@ -50,6 +50,8 @@ type options struct { AddBg string `long:"color-add-bg" ini-name:"color-add-bg" env:"REVDIFF_COLOR_ADD_BG" default:"#123800" description:"added line background color"` RemoveFg string `long:"color-remove-fg" ini-name:"color-remove-fg" env:"REVDIFF_COLOR_REMOVE_FG" default:"#ff8787" description:"removed line text color"` RemoveBg string `long:"color-remove-bg" ini-name:"color-remove-bg" env:"REVDIFF_COLOR_REMOVE_BG" default:"#4D1100" description:"removed line background color"` + ModifyFg string `long:"color-modify-fg" ini-name:"color-modify-fg" env:"REVDIFF_COLOR_MODIFY_FG" default:"#f5c542" description:"modified line text color (collapsed mode)"` + ModifyBg string `long:"color-modify-bg" ini-name:"color-modify-bg" env:"REVDIFF_COLOR_MODIFY_BG" default:"#3D2E00" description:"modified line background color (collapsed mode)"` TreeBg string `long:"color-tree-bg" ini-name:"color-tree-bg" env:"REVDIFF_COLOR_TREE_BG" description:"file tree pane background"` 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:"#2D2D2D" description:"status bar foreground"` @@ -193,6 +195,8 @@ func run(opts options) error { AddBg: opts.Colors.AddBg, RemoveFg: opts.Colors.RemoveFg, RemoveBg: opts.Colors.RemoveBg, + ModifyFg: opts.Colors.ModifyFg, + ModifyBg: opts.Colors.ModifyBg, TreeBg: opts.Colors.TreeBg, DiffBg: opts.Colors.DiffBg, StatusFg: opts.Colors.StatusFg, diff --git a/docs/plans/completed/20260402-collapsed-diff-mode.md b/docs/plans/completed/20260402-collapsed-diff-mode.md new file mode 100644 index 00000000..b5d72185 --- /dev/null +++ b/docs/plans/completed/20260402-collapsed-diff-mode.md @@ -0,0 +1,208 @@ +# Collapsed Diff Mode + +## Overview +Add a "collapsed" diff view mode that shows the final text (post-change state) with color markers on changed lines, instead of the traditional expanded remove+add diff. Users toggle between modes with `v`, and expand/collapse individual hunks with `.` to see the traditional diff inline. + +- **Problem**: expanded diff view with interleaved remove/add lines can be noisy when reviewing large changes. A collapsed view shows the end result with change indicators, letting reviewers focus on what the code looks like after changes. +- **Integration**: adds a new rendering path alongside the existing expanded diff. All existing features (annotations, hunk navigation, syntax highlighting) work in both modes. + +## Context (from discovery) +- **Files involved**: `ui/styles.go`, `ui/model.go`, `ui/diffview.go`, `ui/annotate.go`, `cmd/revdiff/main.go` +- **Related patterns**: existing `findHunks()` returns hunk start indices into `diffLines`; `renderDiffLine()` dispatches on `ChangeType`; `cursorViewportY()` counts visual rows including annotation sub-lines +- **Dependencies**: annotations reference lines by `(lineNum, changeType)` — collapsed mode must preserve this mapping for annotation interop +- **Test patterns**: `model_test.go` uses `testModel()` helper, drives via `m.Update(msg)`, asserts struct fields directly + +## Solution Overview + +**Two-mode rendering**: `collapsed bool` on Model controls which render path is used. `v` toggles it. Default is expanded (current behavior). + +**Collapsed view**: +- Shows only final text: context lines + added lines. Removed lines are hidden. +- Added lines get green markers (existing `Add` colors, gutter `+`) +- Modified lines (add paired with remove in same hunk) get amber/yellow markers (new `Modify` colors, gutter `~`) +- Context lines are unchanged + +**Hunk expand/collapse**: `.` key toggles inline expansion of the hunk under cursor when in collapsed mode. Expanded hunks show full remove+add lines with existing styling. Multiple hunks can be expanded independently. Tracked via `expandedHunks map[int]bool`. + +**Hunk pairing**: to distinguish "modified" from "pure add", analyze each hunk's composition. If a hunk contains both removes and adds, the adds are "modified". If a hunk contains only adds, they are "pure adds". + +## Technical Details + +### New model fields +- `collapsed bool` — current view mode (false = expanded, true = collapsed) +- `expandedHunks map[int]bool` — which hunks are inline-expanded in collapsed mode. Key = `diffLines` start index returned by `findHunks()` (e.g., if `findHunks()` returns `[5, 20, 45]`, then `expandedHunks[20] = true` means the hunk starting at `diffLines[20]` is expanded) + +### New Colors fields +- `ModifyFg string` — modified line foreground (default: `#f5c542`, warm yellow) +- `ModifyBg string` — modified line background (default: `#3D2E00`, dark amber) + +### New styles +- `LineModify lipgloss.Style` — non-highlighted modified line +- `LineModifyHighlight lipgloss.Style` — syntax-highlighted modified line (background only) + +### Collapsed rendering flow +1. Call `findHunks()` to get hunk start indices (into `diffLines`) +2. Build a `modifiedLines map[int]bool` via `buildModifiedSet()` — uses `findHunks()` to get contiguous change groups; within each group, if both `ChangeRemove` and `ChangeAdd` lines exist, the add-line indices are marked as "modified" +3. For each `diffLines` index, determine which hunk it belongs to (find the largest hunk start ≤ index). Check `expandedHunks[hunkStart]` for expansion state. +4. Iterate `diffLines` (using original indices, so annotation lookups remain correct): + - `ChangeRemove` lines: skip unless the line's hunk is expanded + - `ChangeAdd` lines: render with modify or add style based on `modifiedLines` + - `ChangeContext` lines: render normally + - `ChangeDivider` lines: render normally +5. For expanded hunks: render all lines (remove + add) with existing expanded styling +6. Annotations on removed lines are only visible when their hunk is expanded (collapsed mode hides removed lines and their annotations together). Annotations on added/context lines render normally via `renderAnnotationOrInput` using the original `diffLines` index. + +### Cursor and viewport in collapsed mode +- `diffCursor` still indexes into `diffLines`, but removed lines are skipped during cursor movement when not in an expanded hunk +- `cursorViewportY()` must account for hidden removed lines in collapsed mode +- Hunk navigation (`]`/`[`) works unchanged — hunks exist in both modes + +### State reset on file switch +- `handleFileLoaded` resets `expandedHunks` to empty map (same as existing `scrollX` reset) +- `collapsed` persists across file switches (mode is a user preference, not per-file) + +## Development Approach +- **Testing approach**: regular (code first, then tests) +- Complete each task fully before moving to the next +- Make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** +- **CRITICAL: all tests must pass before starting next task** +- Run tests after each change +- Maintain backward compatibility — expanded mode must remain unchanged + +## Testing Strategy +- **Unit tests**: required for every task — test both expanded and collapsed rendering paths +- Key test scenarios: collapsed rendering (removes hidden, adds shown, modified vs pure-add distinction), hunk expand/collapse, cursor skip logic, viewport Y calculation in collapsed mode, `v` and `.` key handling, state reset on file switch + +## Progress Tracking +- Mark completed items with `[x]` immediately when done +- Add newly discovered tasks with ➕ prefix +- Document issues/blockers with ⚠️ prefix + +## Implementation Steps + +### Task 1: Add ModifyFg/ModifyBg colors and styles + +**Files:** +- Modify: `ui/styles.go` +- Modify: `cmd/revdiff/main.go` +- Modify: `ui/styles_test.go` + +- [x] add `ModifyFg` and `ModifyBg` fields to `Colors` struct in `ui/styles.go` +- [x] add `LineModify` and `LineModifyHighlight` fields to `styles` struct +- [x] wire `LineModify` and `LineModifyHighlight` in `newStyles()` (parallel to add/remove pattern) +- [x] wire them in `plainStyles()` as no-op styles +- [x] add `ModifyFg`/`ModifyBg` to `normalizeColors()` +- [x] add default values in `cmd/revdiff/main.go` options struct (`ModifyFg: #f5c542`, `ModifyBg: #3D2E00`) +- [x] write tests in `ui/styles_test.go` for normalize and style creation with modify colors +- [x] run `go test ./ui/...` — must pass before task 2 + +### Task 2: Add collapsed mode state and key handling + +**Files:** +- Modify: `ui/model.go` + +- [x] add `collapsed bool` field to Model struct +- [x] add `expandedHunks map[int]bool` field to Model struct +- [x] handle `v` key in `handleKey()` — toggle `collapsed`, reset `expandedHunks`, re-render +- [x] handle `.` key in `handleDiffNav()` — toggle current hunk in `expandedHunks` when collapsed (no-op in expanded mode), re-render. Key is `findHunks()` start index for the hunk containing cursor. +- [x] reset `expandedHunks` in `handleFileLoaded()` (collapsed persists across files) +- [x] write tests for `v` key toggling collapsed mode +- [x] write tests for `.` key expanding/collapsing hunks in collapsed mode +- [x] write tests for `.` key as no-op in expanded mode +- [x] write tests for file switch resetting expandedHunks but preserving collapsed +- [x] run `go test ./ui/...` — must pass before task 3 + +### Task 3: Hunk pairing — identify modified vs pure-add lines + +**Files:** +- Modify: `ui/diffview.go` + +- [x] add `buildModifiedSet() map[int]bool` method — uses `findHunks()` to get contiguous change groups, then for each group scans from start to next context/divider; if both `ChangeRemove` and `ChangeAdd` exist in the group, marks all add-line `diffLines` indices as modified +- [x] write tests for `buildModifiedSet()` with various hunk compositions: pure adds, pure removes, mixed, multiple hunks +- [x] run `go test ./ui/...` — must pass before task 4 + +### Task 4: Collapsed diff rendering + +**Files:** +- Modify: `ui/diffview.go` + +- [x] add `renderCollapsedDiff() string` method on Model +- [x] modify `renderDiff()` to dispatch: if `m.collapsed`, call `renderCollapsedDiff()` +- [x] in collapsed rendering: skip `ChangeRemove` lines (unless hunk is expanded) +- [x] render `ChangeAdd` lines with modify or add style based on `buildModifiedSet()` +- [x] use gutter markers: `+` for pure adds, `~` for modified lines +- [x] for expanded hunks: render all lines with existing expanded styling (reuse `renderDiffLine`) +- [x] preserve annotation rendering in collapsed mode (reuse `renderAnnotationOrInput`) +- [x] write tests for collapsed rendering: removes hidden, adds shown with correct style +- [x] write tests for modified vs pure-add distinction in rendered output +- [x] write tests for expanded hunk inline rendering in collapsed mode (all lines visible) +- [x] write tests for annotations on removed lines hidden in collapsed mode, visible when hunk expanded +- [x] write tests for empty diffLines and divider-only diffLines in collapsed mode +- [x] write tests verifying expanded mode is unchanged (regression) +- [x] run `go test ./ui/...` — must pass before task 5 + +### Task 5: Cursor movement in collapsed mode + +**Files:** +- Modify: `ui/diffview.go` + +- [x] modify `moveDiffCursorDown()` to skip removed lines in collapsed mode (unless in expanded hunk) +- [x] modify `moveDiffCursorUp()` to skip removed lines in collapsed mode (unless in expanded hunk) +- [x] modify `skipInitialDividers()` to also skip initial removed lines in collapsed mode +- [x] write tests for cursor down skipping removed lines in collapsed mode +- [x] write tests for cursor up skipping removed lines in collapsed mode +- [x] write tests for cursor movement within expanded hunks (should not skip) +- [x] write tests for cursor movement in expanded mode (unchanged behavior) +- [x] run `go test ./ui/...` — must pass before task 6 + +### Task 6: Viewport Y calculation in collapsed mode + +**Files:** +- Modify: `ui/annotate.go` (where `cursorViewportY` lives) + +- [x] modify `cursorViewportY()` to skip hidden removed lines when counting visual rows in collapsed mode +- [x] account for expanded hunks showing all lines in Y calculation +- [x] write tests for `cursorViewportY` in collapsed mode (removed lines not counted) +- [x] write tests for `cursorViewportY` with expanded hunks (all lines counted) +- [x] write tests for page-up/page-down behavior in collapsed mode (implicitly uses fixed `cursorViewportY` + cursor skip from Task 5) +- [x] run `go test ./ui/...` — must pass before task 7 + +### Task 7: Status bar and help text updates + +**Files:** +- Modify: `ui/model.go` + +- [x] update `statusBarText()` to show `[v] expand` hint in collapsed mode (or `[v] collapse` in expanded mode) +- [x] show `[.] expand hunk` / `[.] collapse hunk` hint in collapsed diff pane +- [x] write tests for status bar text in both modes +- [x] run `go test ./ui/...` — must pass before task 8 + +### Task 8: Verify acceptance criteria + +- [x] verify `v` toggles between expanded and collapsed views +- [x] verify `.` expands/collapses individual hunks in collapsed mode +- [x] verify modified lines (amber) are distinguished from pure adds (green) +- [x] verify removed lines are hidden in collapsed mode +- [x] verify annotations work in both modes +- [x] verify hunk navigation (`]`/`[`) works in both modes +- [x] verify file switching resets expanded hunks +- [x] run full test suite: `go test ./...` +- [x] run linter: `golangci-lint run` +- [x] verify test coverage for new code meets 80%+ + +### Task 9: [Final] Update documentation + +- [x] update README.md with collapsed mode documentation (keybindings, description) +- [x] update CLAUDE.md if new patterns discovered +- [x] update `.claude-plugin/skills/revdiff/references/usage.md` with new keybindings +- [x] update `.claude-plugin/skills/revdiff/references/config.md` with ModifyFg/ModifyBg color options +- [x] move this plan to `docs/plans/completed/` + +## Post-Completion + +**Manual verification:** +- test with real git diffs of various sizes (small edits, large refactors, pure additions, pure deletions) +- test with syntax highlighting enabled and disabled +- test with `--no-colors` flag +- verify annotation export format is unaffected by view mode diff --git a/ui/annotate.go b/ui/annotate.go index 727ce9d8..834e94a5 100644 --- a/ui/annotate.go +++ b/ui/annotate.go @@ -26,6 +26,14 @@ func (m *Model) startAnnotation() tea.Cmd { if !ok || dl.ChangeType == diff.ChangeDivider { return nil } + // prevent annotating hidden or placeholder removed lines in collapsed mode + hunks := m.findHunks() + if m.isCollapsedHidden(m.diffCursor, hunks) { + return nil + } + if m.isDeleteOnlyPlaceholder(m.diffCursor, hunks) { + return nil + } ti, cmd := m.newAnnotationInput("annotation...") @@ -212,6 +220,7 @@ func (m Model) diffLineNum(dl diff.DiffLine) int { // cursorViewportY computes the actual viewport Y position of the cursor, // accounting for injected annotation lines and the file-level annotation line. +// in collapsed mode, hidden removed lines (those in non-expanded hunks) are not counted. func (m Model) cursorViewportY() int { if m.currFile == "" || len(m.diffLines) == 0 { return max(0, m.diffCursor) @@ -229,10 +238,22 @@ func (m Model) cursorViewportY() int { } annotationSet := m.buildAnnotationSet() + var hunks []int + if m.collapsed.enabled { + hunks = m.findHunks() + } y := fileAnnotationOffset for i := 0; i < m.diffCursor && i < len(m.diffLines); i++ { + // skip hidden removed lines in collapsed mode + if m.isCollapsedHidden(i, hunks) { + continue + } y++ // the diff line itself + // delete-only placeholders don't render annotations, skip counting them + if m.isDeleteOnlyPlaceholder(i, hunks) { + continue + } dl := m.diffLines[i] if dl.ChangeType != diff.ChangeDivider { key := m.annotationKey(m.diffLineNum(dl), string(dl.ChangeType)) diff --git a/ui/collapsed.go b/ui/collapsed.go new file mode 100644 index 00000000..a5ea45bf --- /dev/null +++ b/ui/collapsed.go @@ -0,0 +1,358 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/umputun/revdiff/diff" +) + +// collapsedState holds the state for collapsed diff view mode. +// collapsed mode shows the final text with color markers on changed lines, +// hiding removed lines unless their hunk is explicitly expanded. +type collapsedState struct { + enabled bool // true when viewing collapsed diff (final text only) + expandedHunks map[int]bool // hunks expanded inline, key = diffLines start index from findHunks() +} + +// renderCollapsedDiff renders the collapsed diff view showing only final text. +// 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 { + annotationMap, fileComment := m.buildAnnotationMap() + hunks := m.findHunks() + modifiedSet := m.buildModifiedSet(hunks) + + var b strings.Builder + m.renderFileAnnotationHeader(&b, fileComment) + + hasVisibleContent := false + hunkIdx := 0 + for i, dl := range m.diffLines { + // advance hunk tracker to the last hunk that starts at or before i + for hunkIdx+1 < len(hunks) && hunks[hunkIdx+1] <= i { + hunkIdx++ + } + hunkStart := -1 + isChange := dl.ChangeType == diff.ChangeAdd || dl.ChangeType == diff.ChangeRemove + if isChange && len(hunks) > 0 && hunks[hunkIdx] <= i { + hunkStart = hunks[hunkIdx] + } + expanded := hunkStart >= 0 && m.collapsed.expandedHunks[hunkStart] + + switch dl.ChangeType { + case diff.ChangeRemove: + switch { + case expanded: + m.renderDiffLine(&b, i, dl) + case i == hunkStart && hunkStart >= 0 && m.isDeleteOnlyHunk(hunkStart): + m.renderDeletePlaceholder(&b, i, hunkStart) + hasVisibleContent = true + continue // placeholder is synthetic, skip annotation rendering + default: + continue // hide removed lines in collapsed mode + } + + case diff.ChangeAdd: + if expanded { + m.renderDiffLine(&b, i, dl) // use standard add styling when hunk is expanded + } else { + m.renderCollapsedAddLine(&b, i, dl, modifiedSet[i]) + } + + default: // context and divider lines render normally + m.renderDiffLine(&b, i, dl) + } + hasVisibleContent = true + + m.renderAnnotationOrInput(&b, i, annotationMap) + } + + if !hasVisibleContent { + b.WriteString(" (file deleted)\n") + } + return b.String() +} + +// renderCollapsedAddLine renders an add line in collapsed mode with modify or add styling. +func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffLine, modified bool) { + hasHighlight := idx < len(m.highlightedLines) + hlContent := "" + if hasHighlight { + hlContent = strings.ReplaceAll(m.highlightedLines[idx], "\t", m.tabSpaces) + } + lineContent := strings.ReplaceAll(dl.Content, "\t", m.tabSpaces) + + style, hlStyle, gutter := m.styles.LineAdd, m.styles.LineAddHighlight, " + " + if modified { + style, hlStyle, gutter = m.styles.LineModify, m.styles.LineModifyHighlight, " ~ " + } + + content := style.Render(gutter + lineContent) + if hasHighlight { + content = hlStyle.Render(gutter + hlContent) + } + + // apply horizontal scroll + if m.scrollX > 0 { + content = ansi.Cut(content, m.scrollX, m.scrollX+m.diffContentWidth()) + } + + isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation + cursor := " " + if isCursor { + cursor = m.styles.DiffCursorLine.Render("▶") + } + b.WriteString(cursor + content + "\n") +} + +// 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 '.'. +func (m Model) renderDeletePlaceholder(b *strings.Builder, idx, hunkStart int) { + count := 0 + for i := hunkStart; i < len(m.diffLines); i++ { + ct := m.diffLines[i].ChangeType + if ct == diff.ChangeContext || ct == diff.ChangeDivider { + break + } + if ct == diff.ChangeRemove { + count++ + } + } + + text := fmt.Sprintf("⋯ %d lines deleted", count) + if count == 1 { + text = "⋯ 1 line deleted" + } + content := m.styles.LineRemove.Render(" - " + text) + + // apply horizontal scroll + if m.scrollX > 0 { + content = ansi.Cut(content, m.scrollX, m.scrollX+m.diffContentWidth()) + } + + isCursor := idx == m.diffCursor && m.focus == paneDiff && !m.cursorOnAnnotation + cursor := " " + if isCursor { + cursor = m.styles.DiffCursorLine.Render("▶") + } + b.WriteString(cursor + content + "\n") +} + +// hunkStartFor returns the findHunks() start index for the hunk containing diffLines[idx]. +// returns -1 if the index is not inside any hunk (context or divider line). +func (m Model) hunkStartFor(idx int, hunks []int) int { + if len(hunks) == 0 || idx < 0 || idx >= len(m.diffLines) { + return -1 + } + dl := m.diffLines[idx] + if dl.ChangeType != diff.ChangeAdd && dl.ChangeType != diff.ChangeRemove { + return -1 + } + best := -1 + for _, start := range hunks { + if start <= idx { + best = start + } + } + return best +} + +// buildModifiedSet returns a set of diffLines indices for add lines that are "modified" +// (paired with removes in the same hunk). pure-add lines (hunk has no removes) are not included. +func (m Model) buildModifiedSet(hunks []int) map[int]bool { + result := make(map[int]bool) + n := len(m.diffLines) + + for hi, start := range hunks { + // find the end of this hunk: next hunk start or first non-change line + end := n + if hi+1 < len(hunks) { + end = hunks[hi+1] + } + // scan only contiguous change lines from start + for end > start && (m.diffLines[end-1].ChangeType != diff.ChangeAdd && + m.diffLines[end-1].ChangeType != diff.ChangeRemove) { + end-- + } + + // check if hunk has both removes and adds + hasRemove, hasAdd := false, false + var addIndices []int + for i := start; i < end; i++ { + switch m.diffLines[i].ChangeType { + case diff.ChangeRemove: + hasRemove = true + case diff.ChangeAdd: + hasAdd = true + addIndices = append(addIndices, i) + case diff.ChangeContext, diff.ChangeDivider: + // context and divider lines are not part of the hunk's change set + } + } + + if hasRemove && hasAdd { + for _, idx := range addIndices { + result[idx] = true + } + } + } + return result +} + +// cursorHunkStart returns the findHunks() start index for the hunk containing the cursor. +// returns false if the cursor is not inside any hunk. +func (m Model) cursorHunkStart() (int, bool) { + hunks := m.findHunks() + best := m.hunkStartFor(m.diffCursor, hunks) + if best < 0 { + return 0, false + } + return best, true +} + +// toggleCollapsedMode switches between collapsed and expanded diff view. +// only operates when the diff pane is focused and a file is loaded. +func (m *Model) toggleCollapsedMode() { + if m.focus != paneDiff || m.currFile == "" { + return + } + m.collapsed.enabled = !m.collapsed.enabled + m.collapsed.expandedHunks = make(map[int]bool) + m.cursorOnAnnotation = false // visible lines change, reset annotation cursor state + m.adjustCursorIfHidden() + m.viewport.SetContent(m.renderDiff()) +} + +// toggleHunkExpansion toggles the expansion state of the hunk under the cursor. +// only operates in collapsed mode; no-op in expanded mode or when cursor is not on a hunk. +func (m *Model) toggleHunkExpansion() { + if !m.collapsed.enabled { + return + } + hunkStart, ok := m.cursorHunkStart() + if !ok { + return + } + if m.collapsed.expandedHunks[hunkStart] { + delete(m.collapsed.expandedHunks, hunkStart) + m.cursorOnAnnotation = false // annotations on removed lines become invisible + m.adjustCursorIfHidden() + } else { + m.collapsed.expandedHunks[hunkStart] = true + } + m.viewport.SetContent(m.renderDiff()) +} + +// isCollapsedHidden returns true if the line at idx is hidden in collapsed mode. +// a line is hidden when collapsed mode is active, the line is a remove line, +// and its hunk is not expanded. the first line of a delete-only hunk is kept +// visible as a placeholder so users can navigate to it and expand with '.'. +func (m Model) isCollapsedHidden(idx int, hunks []int) bool { + if !m.collapsed.enabled || idx < 0 || idx >= len(m.diffLines) { + return false + } + if m.diffLines[idx].ChangeType != diff.ChangeRemove { + return false + } + hunkStart := m.hunkStartFor(idx, hunks) + if hunkStart < 0 { + return true + } + if m.collapsed.expandedHunks[hunkStart] { + return false + } + // first line of a delete-only hunk serves as the visible placeholder + if idx == hunkStart && m.isDeleteOnlyHunk(hunkStart) { + return false + } + return true +} + +// isDeleteOnlyPlaceholder returns true if the line at idx is rendered as a synthetic +// delete-only placeholder (⋯ N lines deleted) in collapsed mode. these lines should not +// display or accept annotations — annotations become visible when the hunk is expanded. +func (m Model) isDeleteOnlyPlaceholder(idx int, hunks []int) bool { + if !m.collapsed.enabled { + return false + } + if idx < 0 || idx >= len(m.diffLines) || m.diffLines[idx].ChangeType != diff.ChangeRemove { + return false + } + hunkStart := m.hunkStartFor(idx, hunks) + return hunkStart >= 0 && idx == hunkStart && !m.collapsed.expandedHunks[hunkStart] && m.isDeleteOnlyHunk(hunkStart) +} + +// isDeleteOnlyHunk returns true if the hunk starting at hunkStart contains only remove lines. +func (m Model) isDeleteOnlyHunk(hunkStart int) bool { + for i := hunkStart; i < len(m.diffLines); i++ { + ct := m.diffLines[i].ChangeType + if ct == diff.ChangeContext || ct == diff.ChangeDivider { + break + } + if ct == diff.ChangeAdd { + return false + } + } + return true +} + +// firstVisibleInHunk returns the first visible line index starting from hunkStart. +// in collapsed mode, this skips hidden removed lines. in expanded mode, returns hunkStart unchanged. +// returns -1 if the hunk has no visible lines (delete-only hunk in collapsed mode). +func (m Model) firstVisibleInHunk(hunkStart int, hunks []int) int { + if !m.isCollapsedHidden(hunkStart, hunks) { + return hunkStart + } + for i := hunkStart + 1; i < len(m.diffLines); i++ { + if m.diffLines[i].ChangeType == diff.ChangeDivider || m.diffLines[i].ChangeType == diff.ChangeContext { + break // past the hunk boundary + } + if !m.isCollapsedHidden(i, hunks) { + return i + } + } + return -1 // no visible lines in this hunk (delete-only, not expanded) +} + +// adjustCursorIfHidden moves the cursor to the nearest visible line if it is currently +// on a hidden removed line in collapsed mode. searches forward first, then backward. +// falls back to nearest divider if no content line is visible (delete-only file). +func (m *Model) adjustCursorIfHidden() { + if !m.collapsed.enabled || m.diffCursor < 0 || m.diffCursor >= len(m.diffLines) { + return + } + hunks := m.findHunks() + if !m.isCollapsedHidden(m.diffCursor, hunks) { + return + } + // search forward for nearest visible non-divider line + for i := m.diffCursor + 1; i < len(m.diffLines); i++ { + if m.diffLines[i].ChangeType != diff.ChangeDivider && !m.isCollapsedHidden(i, hunks) { + m.diffCursor = i + return + } + } + // search backward for nearest visible non-divider line + for i := m.diffCursor - 1; i >= 0; i-- { + if m.diffLines[i].ChangeType != diff.ChangeDivider && !m.isCollapsedHidden(i, hunks) { + m.diffCursor = i + return + } + } + // no visible content line found (delete-only file); fall back to nearest divider + for i := m.diffCursor + 1; i < len(m.diffLines); i++ { + if m.diffLines[i].ChangeType == diff.ChangeDivider { + m.diffCursor = i + return + } + } + for i := m.diffCursor - 1; i >= 0; i-- { + if m.diffLines[i].ChangeType == diff.ChangeDivider { + m.diffCursor = i + return + } + } +} diff --git a/ui/collapsed_test.go b/ui/collapsed_test.go new file mode 100644 index 00000000..5acf8b64 --- /dev/null +++ b/ui/collapsed_test.go @@ -0,0 +1,1543 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/umputun/revdiff/annotation" + "github.com/umputun/revdiff/diff" +) + +func TestModel_VKeyTogglesCollapsedMode(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.diffLines = lines + m.currFile = "a.go" + m.focus = paneDiff + m.viewport.Height = 20 + + t.Run("toggle on", func(t *testing.T) { + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + model := result.(Model) + assert.True(t, model.collapsed.enabled, "v should enable collapsed mode") + assert.NotNil(t, model.collapsed.expandedHunks) + assert.Empty(t, model.collapsed.expandedHunks, "expandedHunks should be reset on toggle") + }) + + t.Run("toggle off", func(t *testing.T) { + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + model := result.(Model) + assert.False(t, model.collapsed.enabled, "v should disable collapsed mode") + assert.Empty(t, model.collapsed.expandedHunks, "expandedHunks should be reset on toggle") + }) + + t.Run("no-op in tree pane", func(t *testing.T) { + m.collapsed.enabled = false + m.focus = paneTree + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + model := result.(Model) + assert.False(t, model.collapsed.enabled, "v should be no-op in tree pane") + }) + + t.Run("no-op when no file loaded", func(t *testing.T) { + m.focus = paneDiff + m.currFile = "" + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + model := result.(Model) + assert.False(t, model.collapsed.enabled, "v should be no-op when no file loaded") + }) +} + +func TestModel_DotKeyExpandsHunkInCollapsedMode(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "old", ChangeType: diff.ChangeRemove}, // 1 - hunk start + {NewNum: 2, Content: "new", ChangeType: diff.ChangeAdd}, // 2 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, // 3 + {NewNum: 4, Content: "add2", ChangeType: diff.ChangeAdd}, // 4 - hunk 2 start + } + m := testModel(nil, nil) + m.diffLines = lines + m.currFile = "a.go" + m.focus = paneDiff + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.viewport.Height = 20 + + t.Run("expand hunk at cursor", func(t *testing.T) { + m.diffCursor = 2 // on add line in hunk 1 (start=1) + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'.'}}) + model := result.(Model) + assert.True(t, model.collapsed.expandedHunks[1], "hunk at index 1 should be expanded") + }) + + t.Run("collapse expanded hunk", func(t *testing.T) { + m.collapsed.expandedHunks = map[int]bool{1: true} + m.diffCursor = 1 // on remove line in hunk 1 + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'.'}}) + model := result.(Model) + assert.False(t, model.collapsed.expandedHunks[1], "hunk should be collapsed after second dot") + }) + + t.Run("expand second hunk independently", func(t *testing.T) { + m.collapsed.expandedHunks = map[int]bool{1: true} + m.diffCursor = 4 // on add line in hunk 2 (start=4) + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'.'}}) + model := result.(Model) + assert.True(t, model.collapsed.expandedHunks[4], "hunk 2 should be expanded") + assert.True(t, model.collapsed.expandedHunks[1], "hunk 1 should remain expanded") + }) + + t.Run("no-op on context line", func(t *testing.T) { + m.collapsed.expandedHunks = make(map[int]bool) + m.diffCursor = 0 // on context line + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'.'}}) + model := result.(Model) + assert.Empty(t, model.collapsed.expandedHunks, "dot on context line should be no-op") + }) +} + +func TestModel_DotKeyNoOpInExpandedMode(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "add", ChangeType: diff.ChangeAdd}, + } + m := testModel(nil, nil) + m.diffLines = lines + m.currFile = "a.go" + m.focus = paneDiff + m.collapsed.enabled = false + m.collapsed.expandedHunks = make(map[int]bool) + m.diffCursor = 0 + m.viewport.Height = 20 + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'.'}}) + model := result.(Model) + assert.Empty(t, model.collapsed.expandedHunks, "dot should be no-op in expanded mode") +} + +func TestModel_FileSwitchResetsExpandedHunksPreservesCollapsed(t *testing.T) { + linesA := []diff.DiffLine{ + {NewNum: 1, Content: "a-ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "a-add", ChangeType: diff.ChangeAdd}, + } + linesB := []diff.DiffLine{ + {NewNum: 1, Content: "b-ctx", ChangeType: diff.ChangeContext}, + } + fileDiffs := map[string][]diff.DiffLine{"a.go": linesA, "b.go": linesB} + m := testModel([]string{"a.go", "b.go"}, fileDiffs) + m.tree = newFileTree([]string{"a.go", "b.go"}) + + // simulate loading first file + result, _ := m.Update(fileLoadedMsg{file: "a.go", lines: linesA}) + model := result.(Model) + + // set collapsed mode and expand a hunk + model.collapsed.enabled = true + model.collapsed.expandedHunks = map[int]bool{1: true} + + // load second file + result, _ = model.Update(fileLoadedMsg{file: "b.go", seq: model.loadSeq, lines: linesB}) + model = result.(Model) + + assert.True(t, model.collapsed.enabled, "collapsed should persist across file switches") + assert.Empty(t, model.collapsed.expandedHunks, "expandedHunks should be reset on file switch") + assert.Equal(t, "b.go", model.currFile) +} + +func TestModel_BuildModifiedSet(t *testing.T) { + tests := []struct { + name string + lines []diff.DiffLine + expect map[int]bool + }{ + {name: "empty lines", lines: nil, expect: map[int]bool{}}, + {name: "all context", lines: []diff.DiffLine{ + {NewNum: 1, Content: "a", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "b", ChangeType: diff.ChangeContext}, + }, expect: map[int]bool{}}, + {name: "pure adds only", lines: []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "new2", ChangeType: diff.ChangeAdd}, + {NewNum: 4, Content: "ctx", ChangeType: diff.ChangeContext}, + }, expect: map[int]bool{}}, + {name: "pure removes only", lines: []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx", ChangeType: diff.ChangeContext}, + }, expect: map[int]bool{}}, + {name: "mixed hunk marks adds as modified", lines: []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx", ChangeType: diff.ChangeContext}, + }, expect: map[int]bool{2: true}}, + {name: "mixed hunk multiple adds", lines: []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "new2", ChangeType: diff.ChangeAdd}, + {NewNum: 4, Content: "new3", ChangeType: diff.ChangeAdd}, + {NewNum: 5, Content: "ctx", ChangeType: diff.ChangeContext}, + }, expect: map[int]bool{3: true, 4: true, 5: true}}, + {name: "two hunks one mixed one pure add", lines: []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old", ChangeType: diff.ChangeRemove}, // 1 + {NewNum: 2, Content: "new", ChangeType: diff.ChangeAdd}, // 2 - modified + {NewNum: 3, Content: "ctx", ChangeType: diff.ChangeContext}, // 3 + {NewNum: 4, Content: "added", ChangeType: diff.ChangeAdd}, // 4 - pure add + {NewNum: 5, Content: "ctx", ChangeType: diff.ChangeContext}, // 5 + }, expect: map[int]bool{2: true}}, + {name: "two hunks both mixed", lines: []diff.DiffLine{ + {OldNum: 1, Content: "old1", ChangeType: diff.ChangeRemove}, // 0 + {NewNum: 1, Content: "new1", ChangeType: diff.ChangeAdd}, // 1 - modified + {NewNum: 2, Content: "ctx", ChangeType: diff.ChangeContext}, // 2 + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, // 3 + {NewNum: 3, Content: "new2", ChangeType: diff.ChangeAdd}, // 4 - modified + {NewNum: 4, Content: "ctx2", ChangeType: diff.ChangeContext}, // 5 + }, expect: map[int]bool{1: true, 4: true}}, + {name: "hunks separated by divider", lines: []diff.DiffLine{ + {OldNum: 1, Content: "old", ChangeType: diff.ChangeRemove}, // 0 - hunk 1 + {NewNum: 1, Content: "new", ChangeType: diff.ChangeAdd}, // 1 - modified + {Content: "...", ChangeType: diff.ChangeDivider}, // 2 + {NewNum: 10, Content: "added", ChangeType: diff.ChangeAdd}, // 3 - pure add (hunk 2) + }, expect: map[int]bool{1: true}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := testModel(nil, nil) + m.diffLines = tc.lines + assert.Equal(t, tc.expect, m.buildModifiedSet(m.findHunks())) + }) + } +} + +func TestModel_CollapsedRenderHidesRemovedLines(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "context line", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed line", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added line", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "another context", ChangeType: diff.ChangeContext}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, "context line") + assert.NotContains(t, rendered, "removed line", "removed lines should be hidden in collapsed mode") + assert.Contains(t, rendered, "added line") + assert.Contains(t, rendered, "another context") +} + +func TestModel_CollapsedRenderModifiedVsPureAdd(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old", ChangeType: diff.ChangeRemove}, // hunk 1: mixed + {NewNum: 2, Content: "modified line", ChangeType: diff.ChangeAdd}, // modified (paired with remove) + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + {NewNum: 4, Content: "pure add line", ChangeType: diff.ChangeAdd}, // hunk 2: pure add + {NewNum: 5, Content: "ctx3", ChangeType: diff.ChangeContext}, + } + + rendered := m.renderDiff() + // modified lines get ~ gutter + assert.Contains(t, rendered, " ~ modified line", "modified add should have ~ gutter") + // pure adds get + gutter + assert.Contains(t, rendered, " + pure add line", "pure add should have + gutter") + // removed lines are hidden + assert.NotContains(t, rendered, "old", "removed lines should be hidden") +} + +func TestModel_CollapsedRenderExpandedHunkShowsAllLines(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + // expand the hunk at index 1 + m.collapsed.expandedHunks = map[int]bool{1: true} + + rendered := m.renderDiff() + assert.Contains(t, rendered, "removed", "removed line should be visible in expanded hunk") + assert.Contains(t, rendered, "added", "added line should be visible in expanded hunk") + // expanded hunk uses standard styling: + for add, - for remove + assert.Contains(t, rendered, " - removed", "expanded hunk should use - gutter for removes") + assert.Contains(t, rendered, " + added", "expanded hunk should use + gutter for adds") +} + +func TestModel_CollapsedRenderAnnotationsOnRemovedLinesHidden(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added", ChangeType: diff.ChangeAdd}, + } + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "annotation on removed"}) + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "+", Comment: "annotation on added"}) + + rendered := m.renderDiff() + assert.NotContains(t, rendered, "annotation on removed", "annotation on removed line should be hidden in collapsed mode") + assert.Contains(t, rendered, "annotation on added", "annotation on added line should be visible") +} + +func TestModel_CollapsedRenderAnnotationsVisibleWhenHunkExpanded(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added", ChangeType: diff.ChangeAdd}, + } + m.collapsed.expandedHunks = map[int]bool{1: true} + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "annotation on removed"}) + + rendered := m.renderDiff() + assert.Contains(t, rendered, "annotation on removed", "annotation on removed line should be visible when hunk expanded") +} + +func TestModel_CollapsedRenderEmptyDiffLines(t *testing.T) { + m := testModel(nil, nil) + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = nil + + rendered := m.renderDiff() + assert.Contains(t, rendered, "no changes") +} + +func TestModel_CollapsedRenderDividerOnlyLines(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {Content: "...", ChangeType: diff.ChangeDivider}, + {Content: "~~~", ChangeType: diff.ChangeDivider}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, "...") + assert.Contains(t, rendered, "~~~") +} + +func TestModel_CollapsedRenderAllRemovesShowsPlaceholder(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {OldNum: 1, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 2, Content: "old2", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old3", ChangeType: diff.ChangeRemove}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, "3 lines deleted", "all-removes file should show delete placeholder in collapsed mode") + assert.NotContains(t, rendered, "old1", "removed lines content should be hidden") +} + +func TestModel_CollapsedDeleteOnlyPlaceholderHidesAnnotations(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // placeholder line + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "note on deleted line"}) + + rendered := m.renderDiff() + assert.Contains(t, rendered, "2 lines deleted", "placeholder should be shown") + assert.NotContains(t, rendered, "note on deleted line", "annotation on placeholder should be hidden") + + // expand hunk, annotation should appear + m.collapsed.expandedHunks[1] = true + rendered = m.renderDiff() + assert.Contains(t, rendered, "note on deleted line", "annotation should be visible when hunk is expanded") +} + +func TestModel_CollapsedDeleteOnlyPlaceholderBlocksAnnotation(t *testing.T) { + m := testModel(nil, nil) + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m.diffCursor = 1 // on placeholder + + cmd := m.startAnnotation() + assert.Nil(t, cmd, "should not allow annotating delete-only placeholder") + assert.False(t, m.annotating, "annotating mode should not be active") +} + +func TestModel_IsDeleteOnlyPlaceholder(t *testing.T) { + m := testModel(nil, nil) + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // idx 1 + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, // idx 2 + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + hunks := m.findHunks() + + assert.True(t, m.isDeleteOnlyPlaceholder(1, hunks), "first line of delete-only hunk should be placeholder") + assert.False(t, m.isDeleteOnlyPlaceholder(2, hunks), "second line of delete-only hunk is not placeholder") + assert.False(t, m.isDeleteOnlyPlaceholder(0, hunks), "context line is not placeholder") + + // expanded hunk is not a placeholder + m.collapsed.expandedHunks[1] = true + assert.False(t, m.isDeleteOnlyPlaceholder(1, hunks), "expanded hunk should not be placeholder") + + // not collapsed mode + m.collapsed.enabled = false + m.collapsed.expandedHunks = make(map[int]bool) + assert.False(t, m.isDeleteOnlyPlaceholder(1, hunks), "should return false when not in collapsed mode") +} + +func TestModel_CollapsedRenderDeleteOnlyHunkInMixedFile(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // delete-only hunk + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, + {OldNum: 5, Content: "old", ChangeType: diff.ChangeRemove}, // mixed hunk + {NewNum: 3, Content: "new", ChangeType: diff.ChangeAdd}, + } + + rendered := m.renderDiff() + assert.Contains(t, rendered, "2 lines deleted", "delete-only hunk should show placeholder") + assert.NotContains(t, rendered, "del1", "removed line content should be hidden") + assert.NotContains(t, rendered, "del2", "removed line content should be hidden") + assert.Contains(t, rendered, "new", "add line from mixed hunk should be visible") +} + +func TestModel_CollapsedExpandDeleteOnlyHunkWithDot(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // delete-only hunk start + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m.diffCursor = 1 // on placeholder + + // verify placeholder is shown and content is hidden + rendered := m.renderDiff() + assert.Contains(t, rendered, "2 lines deleted") + assert.NotContains(t, rendered, "del1") + + // expand the hunk with '.' + m.toggleHunkExpansion() + assert.True(t, m.collapsed.expandedHunks[1], "hunk should be expanded") + + // after expansion, removed lines should be visible + rendered = m.renderDiff() + assert.Contains(t, rendered, "del1", "expanded hunk should show removed lines") + assert.Contains(t, rendered, "del2", "expanded hunk should show all removed lines") + assert.NotContains(t, rendered, "lines deleted", "placeholder should not appear when expanded") +} + +func TestModel_CollapsedCursorMovementIncludesDeleteOnlyPlaceholder(t *testing.T) { + m := testModel(nil, nil) + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // 1 - placeholder + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, // 2 - hidden + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, // 3 + } + m.diffCursor = 0 + + // move down should land on placeholder (idx 1), not skip to ctx2 (idx 3) + m.moveDiffCursorDown() + assert.Equal(t, 1, m.diffCursor, "should land on delete-only hunk placeholder") + + // move down again should skip hidden idx 2 and land on ctx2 (idx 3) + m.moveDiffCursorDown() + assert.Equal(t, 3, m.diffCursor, "should skip hidden remove and land on context") + + // move up should go back to placeholder + m.moveDiffCursorUp() + assert.Equal(t, 1, m.diffCursor, "should go back to placeholder") +} + +func TestModel_IsDeleteOnlyHunk(t *testing.T) { + m := testModel(nil, nil) + + t.Run("delete-only hunk", func(t *testing.T) { + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx", ChangeType: diff.ChangeContext}, + } + hunks := m.findHunks() + assert.True(t, m.isDeleteOnlyHunk(hunks[0])) + }) + + t.Run("mixed hunk", func(t *testing.T) { + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "add", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx", ChangeType: diff.ChangeContext}, + } + hunks := m.findHunks() + assert.False(t, m.isDeleteOnlyHunk(hunks[0])) + }) + + t.Run("add-only hunk", func(t *testing.T) { + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {NewNum: 2, Content: "add", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx", ChangeType: diff.ChangeContext}, + } + hunks := m.findHunks() + assert.False(t, m.isDeleteOnlyHunk(hunks[0])) + }) +} + +func TestModel_ExpandedModeUnchangedRegression(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = false + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + + rendered := m.renderDiff() + // in expanded mode, all lines are visible + assert.Contains(t, rendered, "old", "removed lines should be visible in expanded mode") + assert.Contains(t, rendered, "new", "added lines should be visible in expanded mode") + assert.Contains(t, rendered, " - old", "expanded mode should use - gutter for removes") + assert.Contains(t, rendered, " + new", "expanded mode should use + gutter for adds") +} + +func TestModel_HunkStartFor(t *testing.T) { + m := testModel(nil, nil) + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "old", ChangeType: diff.ChangeRemove}, // 1 + {NewNum: 2, Content: "new", ChangeType: diff.ChangeAdd}, // 2 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, // 3 + {NewNum: 4, Content: "added", ChangeType: diff.ChangeAdd}, // 4 + {NewNum: 5, Content: "ctx3", ChangeType: diff.ChangeContext}, // 5 + } + hunks := m.findHunks() // should be [1, 4] + assert.Equal(t, []int{1, 4}, hunks) + + // context line returns -1 + assert.Equal(t, -1, m.hunkStartFor(0, hunks)) + // first hunk lines + assert.Equal(t, 1, m.hunkStartFor(1, hunks)) + assert.Equal(t, 1, m.hunkStartFor(2, hunks)) + // context between hunks + assert.Equal(t, -1, m.hunkStartFor(3, hunks)) + // second hunk + assert.Equal(t, 4, m.hunkStartFor(4, hunks)) + // trailing context + assert.Equal(t, -1, m.hunkStartFor(5, hunks)) + // out of bounds + assert.Equal(t, -1, m.hunkStartFor(-1, hunks)) + assert.Equal(t, -1, m.hunkStartFor(10, hunks)) + // empty hunks + assert.Equal(t, -1, m.hunkStartFor(0, nil)) +} + +func TestModel_CollapsedRenderMultipleExpandedHunks(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // hunk at 1 + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + {OldNum: 4, Content: "old2", ChangeType: diff.ChangeRemove}, // hunk at 4 + {NewNum: 4, Content: "new2", ChangeType: diff.ChangeAdd}, + {NewNum: 5, Content: "ctx3", ChangeType: diff.ChangeContext}, + } + // expand both hunks + m.collapsed.expandedHunks = map[int]bool{1: true, 4: true} + + rendered := m.renderDiff() + assert.Contains(t, rendered, "old1", "first expanded hunk should show removed line") + assert.Contains(t, rendered, "old2", "second expanded hunk should show removed line") + assert.Contains(t, rendered, "new1") + assert.Contains(t, rendered, "new2") +} + +func TestModel_CollapsedRenderMixedExpandedAndCollapsedHunks(t *testing.T) { + m := testModel(nil, nil) + m.styles = plainStyles() + m.collapsed.enabled = true + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // hunk at 1 + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + {OldNum: 4, Content: "old2", ChangeType: diff.ChangeRemove}, // hunk at 4 + {NewNum: 4, Content: "new2", ChangeType: diff.ChangeAdd}, + {NewNum: 5, Content: "ctx3", ChangeType: diff.ChangeContext}, + } + // expand only first hunk + m.collapsed.expandedHunks = map[int]bool{1: true} + + rendered := m.renderDiff() + assert.Contains(t, rendered, "old1", "expanded hunk should show removed line") + assert.NotContains(t, rendered, "old2", "collapsed hunk should hide removed line") + assert.Contains(t, rendered, " ~ new2", "collapsed mixed hunk should use ~ gutter") +} + +func TestModel_CollapsedCursorDownSkipsRemovedLines(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.focus = paneDiff + + result, _ := m.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model := result.(Model) + model.collapsed.enabled = true + assert.Equal(t, 0, model.diffCursor, "starts on ctx1") + + // move down should skip removed lines (indices 1,2) and land on add line (index 3) + model.moveDiffCursorDown() + assert.Equal(t, 3, model.diffCursor, "should skip removed lines and land on add line") + + // move down again lands on ctx2 + model.moveDiffCursorDown() + assert.Equal(t, 4, model.diffCursor, "should land on ctx2") +} + +func TestModel_CollapsedCursorUpSkipsRemovedLines(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.focus = paneDiff + + result, _ := m.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model := result.(Model) + model.collapsed.enabled = true + model.diffCursor = 4 // start on ctx2 + + // move up should skip removed lines (indices 2,1) and land on add line (index 3) + model.moveDiffCursorUp() + assert.Equal(t, 3, model.diffCursor, "should land on add line") + + // move up again skips removed lines and lands on ctx1 + model.moveDiffCursorUp() + assert.Equal(t, 0, model.diffCursor, "should skip removed lines and land on ctx1") +} + +func TestModel_CollapsedCursorMovementInExpandedHunk(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.focus = paneDiff + + result, _ := m.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model := result.(Model) + model.collapsed.enabled = true + model.collapsed.expandedHunks = map[int]bool{1: true} // expand the hunk starting at index 1 + + // cursor on ctx1, move down should land on removed line since hunk is expanded + model.moveDiffCursorDown() + assert.Equal(t, 1, model.diffCursor, "should land on removed line in expanded hunk") + + // move down lands on add line + model.moveDiffCursorDown() + assert.Equal(t, 2, model.diffCursor, "should land on add line") + + // move down lands on ctx2 + model.moveDiffCursorDown() + assert.Equal(t, 3, model.diffCursor, "should land on ctx2") + + // now move up through the expanded hunk + model.moveDiffCursorUp() + assert.Equal(t, 2, model.diffCursor, "should land on add line") + + model.moveDiffCursorUp() + assert.Equal(t, 1, model.diffCursor, "should land on removed line in expanded hunk") + + model.moveDiffCursorUp() + assert.Equal(t, 0, model.diffCursor, "should land on ctx1") +} + +func TestModel_ExpandedModeCursorMovementUnchanged(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.focus = paneDiff + + result, _ := m.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model := result.(Model) + assert.False(t, model.collapsed.enabled, "should be in expanded mode by default") + assert.Equal(t, 0, model.diffCursor) + + // move down lands on removed line in expanded mode + model.moveDiffCursorDown() + assert.Equal(t, 1, model.diffCursor, "expanded mode should visit removed line") + + model.moveDiffCursorDown() + assert.Equal(t, 2, model.diffCursor, "expanded mode should visit add line") + + model.moveDiffCursorDown() + assert.Equal(t, 3, model.diffCursor, "expanded mode should visit ctx2") + + // move back up visits all lines + model.moveDiffCursorUp() + assert.Equal(t, 2, model.diffCursor) + + model.moveDiffCursorUp() + assert.Equal(t, 1, model.diffCursor) + + model.moveDiffCursorUp() + assert.Equal(t, 0, model.diffCursor) +} + +func TestModel_CollapsedSkipInitialDividers(t *testing.T) { + t.Run("skips divider and removed lines", func(t *testing.T) { + lines := []diff.DiffLine{ + {Content: "@@...", ChangeType: diff.ChangeDivider}, + {OldNum: 1, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 1, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 2, Content: "ctx1", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.collapsed.enabled = true + m.diffLines = lines + m.skipInitialDividers() + assert.Equal(t, 2, m.diffCursor, "should skip divider and removed line, land on add") + }) + + t.Run("expanded mode skips only dividers", func(t *testing.T) { + lines := []diff.DiffLine{ + {Content: "@@...", ChangeType: diff.ChangeDivider}, + {OldNum: 1, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 1, Content: "new1", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.diffLines = lines + m.skipInitialDividers() + assert.Equal(t, 1, m.diffCursor, "expanded mode should land on removed line after divider") + }) + + t.Run("collapsed with expanded hunk allows removed lines", func(t *testing.T) { + lines := []diff.DiffLine{ + {Content: "@@...", ChangeType: diff.ChangeDivider}, + {OldNum: 1, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 1, Content: "new1", ChangeType: diff.ChangeAdd}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} // hunk starts at index 1 + m.diffLines = lines + m.skipInitialDividers() + assert.Equal(t, 1, m.diffCursor, "expanded hunk should allow landing on removed line") + }) +} + +func TestModel_CollapsedCursorDownMultipleHunks(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // hunk 1 at idx 1 + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + {OldNum: 4, Content: "old2", ChangeType: diff.ChangeRemove}, // hunk 2 at idx 4 + {OldNum: 5, Content: "old3", ChangeType: diff.ChangeRemove}, + {NewNum: 4, Content: "new2", ChangeType: diff.ChangeAdd}, + {NewNum: 5, Content: "ctx3", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.focus = paneDiff + + result, _ := m.Update(fileLoadedMsg{file: "a.go", lines: lines}) + model := result.(Model) + model.collapsed.enabled = true + + // traverse all lines with cursor down + positions := []int{model.diffCursor} + for range 10 { + prev := model.diffCursor + model.moveDiffCursorDown() + if model.diffCursor == prev { + break + } + positions = append(positions, model.diffCursor) + } + // should visit: ctx1(0), new1(2), ctx2(3), new2(6), ctx3(7) + assert.Equal(t, []int{0, 2, 3, 6, 7}, positions, "cursor should skip all removed lines across hunks") +} + +func TestModel_CursorViewportYCollapsedMode(t *testing.T) { + t.Run("removed lines not counted", func(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // idx 0 + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 1 - hidden + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, // idx 2 - hidden + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, // idx 3 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, // idx 4 + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.collapsed.enabled = true + + m.diffCursor = 0 + assert.Equal(t, 0, m.cursorViewportY(), "ctx1 at Y=0") + + // cursor at idx 3 (add line), but removed lines at 1,2 are hidden, so Y=1 + m.diffCursor = 3 + assert.Equal(t, 1, m.cursorViewportY(), "add line should be at Y=1, removed lines skipped") + + m.diffCursor = 4 + assert.Equal(t, 2, m.cursorViewportY(), "ctx2 should be at Y=2, removed lines skipped") + }) + + t.Run("expanded mode counts all lines", func(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + + // expanded mode (default) counts all lines + m.diffCursor = 3 + assert.Equal(t, 3, m.cursorViewportY(), "expanded mode should count all lines including removes") + + m.diffCursor = 4 + assert.Equal(t, 4, m.cursorViewportY(), "expanded mode Y=4 for idx 4") + }) + + t.Run("collapsed with annotations on visible lines", func(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.collapsed.enabled = true + + // add annotation on ctx1 (line 1, context type) + m.store.Add(annotation.Annotation{File: "a.go", Line: 1, Type: " ", Comment: "note"}) + + // cursor at idx 2 (add): ctx1(1 row) + annotation(1 row) = 2 preceding visual rows + m.diffCursor = 2 + assert.Equal(t, 2, m.cursorViewportY(), "annotation on ctx1 adds a visual row") + + // cursor at idx 3 (ctx2): ctx1(1) + annotation(1) + add(1) = 3 + m.diffCursor = 3 + assert.Equal(t, 3, m.cursorViewportY(), "ctx2 after annotated ctx1 and add line") + }) + + t.Run("collapsed with annotation on removed line hidden", func(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.collapsed.enabled = true + + // annotation on the removed line - both line and annotation are hidden + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: string(diff.ChangeRemove), Comment: "old note"}) + + // cursor at idx 2 (add): only ctx1 visible before it, removed line+annotation skipped + m.diffCursor = 2 + assert.Equal(t, 1, m.cursorViewportY(), "removed line and its annotation should not count") + }) +} + +func TestModel_CursorViewportYCollapsedExpandedHunks(t *testing.T) { + t.Run("expanded hunk shows all lines in Y calculation", func(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} // hunk starts at index 1 + + // all lines are now visible because the hunk is expanded + m.diffCursor = 3 + assert.Equal(t, 3, m.cursorViewportY(), "expanded hunk: Y=3 counting all lines") + + m.diffCursor = 4 + assert.Equal(t, 4, m.cursorViewportY(), "expanded hunk: Y=4 for ctx2") + }) + + t.Run("mixed expanded and collapsed hunks", func(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // idx 0 + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 1 - hunk1 (expanded) + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, // idx 2 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, // idx 3 + {OldNum: 4, Content: "old2", ChangeType: diff.ChangeRemove}, // idx 4 - hunk2 (collapsed) + {NewNum: 4, Content: "new2", ChangeType: diff.ChangeAdd}, // idx 5 + {NewNum: 5, Content: "ctx3", ChangeType: diff.ChangeContext}, // idx 6 + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} // only hunk1 expanded + + // hunk1 expanded: ctx1(0), old1(1), new1(2), ctx2(3) all visible + m.diffCursor = 3 + assert.Equal(t, 3, m.cursorViewportY(), "hunk1 expanded: ctx2 at Y=3") + + // hunk2 collapsed: old2 at idx 4 hidden, so idx 5 (new2) is at Y=4 + m.diffCursor = 5 + assert.Equal(t, 4, m.cursorViewportY(), "hunk2 collapsed: new2 at Y=4, old2 hidden") + + // ctx3 at idx 6: Y=5 + m.diffCursor = 6 + assert.Equal(t, 5, m.cursorViewportY(), "ctx3 at Y=5") + }) + + t.Run("expanded hunk with annotation on removed line", func(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + } + m := testModel(nil, nil) + m.currFile = "a.go" + m.diffLines = lines + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} + + // annotation on the removed line - visible because hunk is expanded + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: string(diff.ChangeRemove), Comment: "old note"}) + + // cursor at idx 2 (add): ctx1(1) + old1(1) + annotation(1) = 3 + m.diffCursor = 2 + assert.Equal(t, 3, m.cursorViewportY(), "expanded hunk: annotation on removed line is counted") + }) +} + +func TestModel_CollapsedPageDownSkipsRemovedLines(t *testing.T) { + // create enough lines so page movement is meaningful + var lines []diff.DiffLine + for i := 1; i <= 50; i++ { + lines = append(lines, diff.DiffLine{NewNum: i, Content: "ctx", ChangeType: diff.ChangeContext}) + // add a remove+add hunk every 5 lines + if i%5 == 0 { + lines = append(lines, + diff.DiffLine{OldNum: i + 100, Content: "old", ChangeType: diff.ChangeRemove}, + diff.DiffLine{NewNum: i + 1, Content: "new", 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.collapsed.enabled = true + + pageHeight := model.viewport.Height + require.Positive(t, pageHeight) + + startCursor := model.diffCursor + startY := model.cursorViewportY() + + // page down + model.moveDiffCursorPageDown() + + assert.Greater(t, model.diffCursor, startCursor, "cursor should advance") + assert.GreaterOrEqual(t, model.cursorViewportY()-startY, pageHeight, "should move at least one page") + + // verify cursor did not land on a hidden removed line + dl := model.diffLines[model.diffCursor] + assert.NotEqual(t, diff.ChangeRemove, dl.ChangeType, "cursor should not land on hidden removed line") +} + +func TestModel_CollapsedPageUpSkipsRemovedLines(t *testing.T) { + var lines []diff.DiffLine + for i := 1; i <= 50; i++ { + lines = append(lines, diff.DiffLine{NewNum: i, Content: "ctx", ChangeType: diff.ChangeContext}) + if i%5 == 0 { + lines = append(lines, + diff.DiffLine{OldNum: i + 100, Content: "old", ChangeType: diff.ChangeRemove}, + diff.DiffLine{NewNum: i + 1, Content: "new", 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.collapsed.enabled = true + + // move cursor to near the end + model.diffCursor = len(lines) - 1 + startY := model.cursorViewportY() + + // page up + model.moveDiffCursorPageUp() + + assert.Less(t, model.diffCursor, len(lines)-1, "cursor should move back") + assert.GreaterOrEqual(t, startY-model.cursorViewportY(), model.viewport.Height, "should move at least one page up") + + // verify cursor did not land on a hidden removed line + dl := model.diffLines[model.diffCursor] + assert.NotEqual(t, diff.ChangeRemove, dl.ChangeType, "cursor should not land on hidden removed line") +} + +func TestModel_StatusBarViewModeHint(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.diffLines = lines + m.currFile = "a.go" + m.focus = paneDiff + m.width = 200 + + t.Run("expanded mode shows collapse hint", func(t *testing.T) { + m.collapsed.enabled = false + status := m.statusBarText(m.annotatedFiles()) + assert.Contains(t, status, "[v] collapse") + assert.NotContains(t, status, "[v] expand") + }) + + t.Run("collapsed mode shows expand hint", func(t *testing.T) { + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + status := m.statusBarText(m.annotatedFiles()) + assert.Contains(t, status, "[v] expand") + assert.NotContains(t, status, "[v] collapse") + }) + + t.Run("tree pane does not show view mode hint", func(t *testing.T) { + m.focus = paneTree + status := m.statusBarText(m.annotatedFiles()) + assert.NotContains(t, status, "[v]") + }) +} + +func TestModel_StatusBarDotHint(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "added", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel(nil, nil) + m.diffLines = lines + m.currFile = "a.go" + m.focus = paneDiff + m.width = 200 + + t.Run("collapsed mode on hunk shows expand hunk hint", func(t *testing.T) { + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffCursor = 2 // on add line in hunk + status := m.statusBarText(m.annotatedFiles()) + assert.Contains(t, status, "[.] expand hunk") + assert.NotContains(t, status, "[.] collapse hunk") + }) + + t.Run("collapsed mode on expanded hunk shows collapse hunk hint", func(t *testing.T) { + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} // hunk starts at index 1 + m.diffCursor = 2 // on add line in expanded hunk + status := m.statusBarText(m.annotatedFiles()) + assert.Contains(t, status, "[.] collapse hunk") + assert.NotContains(t, status, "[.] expand hunk") + }) + + t.Run("collapsed mode on context line hides dot hint", func(t *testing.T) { + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffCursor = 0 // on context line + status := m.statusBarText(m.annotatedFiles()) + assert.NotContains(t, status, "[.]") + }) + + t.Run("expanded mode hides dot hint", func(t *testing.T) { + m.collapsed.enabled = false + m.diffCursor = 2 // on changed line, but not collapsed + status := m.statusBarText(m.annotatedFiles()) + assert.NotContains(t, status, "[.]") + }) +} + +func TestModel_CollapsedCursorToEndSkipsRemovedLines(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, // last lines are removes + {OldNum: 4, Content: "old3", ChangeType: diff.ChangeRemove}, + } + m := testModel(nil, nil) + m.diffLines = lines + m.currFile = "a.go" + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffCursor = 0 + + m.moveDiffCursorToEnd() + assert.Equal(t, 2, m.diffCursor, "should land on add line, not hidden removed lines") +} + +func TestModel_CollapsedHunkNavigationSkipsRemovedLines(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // 1 - hunk 1 start (remove) + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, // 2 + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, // 3 - first visible in hunk 1 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, // 4 + {OldNum: 5, Content: "old3", ChangeType: diff.ChangeRemove}, // 5 - hunk 2 start (remove) + {NewNum: 4, Content: "new2", ChangeType: diff.ChangeAdd}, // 6 - first visible in hunk 2 + {NewNum: 5, Content: "ctx3", ChangeType: diff.ChangeContext}, // 7 + } + m := testModel(nil, nil) + m.diffLines = lines + m.currFile = "a.go" + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffCursor = 0 + m.viewport.Height = 20 + + // next hunk should skip hidden removes and land on add line + m.moveToNextHunk() + assert.Equal(t, 3, m.diffCursor, "should land on first visible line in hunk 1") + + m.moveToNextHunk() + assert.Equal(t, 6, m.diffCursor, "should land on first visible line in hunk 2") + + // prev hunk back + m.moveToPrevHunk() + assert.Equal(t, 3, m.diffCursor, "should land on first visible line in hunk 1") +} + +func TestModel_CollapsedHunkNavigationExpandedHunk(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // 1 - hunk 1 start + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, // 2 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, // 3 + } + m := testModel(nil, nil) + m.diffLines = lines + m.currFile = "a.go" + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} // hunk at index 1 is expanded + m.diffCursor = 0 + m.viewport.Height = 20 + + // expanded hunk: should land on hunk start (remove line is visible) + m.moveToNextHunk() + assert.Equal(t, 1, m.diffCursor, "expanded hunk should land on remove line") +} + +func TestModel_CollapsedHunkNavigationDeleteOnly(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // 1 - hunk 1 (delete-only) + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, // 2 + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, // 3 + {OldNum: 5, Content: "old3", ChangeType: diff.ChangeRemove}, // 4 - hunk 2 (mixed) + {NewNum: 3, Content: "new3", ChangeType: diff.ChangeAdd}, // 5 + {NewNum: 4, Content: "ctx3", ChangeType: diff.ChangeContext}, // 6 + } + m := testModel(nil, nil) + m.diffLines = lines + m.currFile = "a.go" + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.diffCursor = 0 + m.viewport.Height = 20 + + // next hunk lands on delete-only hunk 1's placeholder (first remove line) + m.moveToNextHunk() + assert.Equal(t, 1, m.diffCursor, "should land on delete-only hunk placeholder") + + // next hunk from hunk 1 lands on hunk 2's visible add line + m.moveToNextHunk() + assert.Equal(t, 5, m.diffCursor, "should land on mixed hunk's add line") + + // prev hunk from hunk 2 goes back to delete-only hunk 1's placeholder + m.moveToPrevHunk() + assert.Equal(t, 1, m.diffCursor, "should go back to delete-only hunk placeholder") +} + +func TestModel_FirstVisibleInHunk(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel(nil, nil) + m.diffLines = lines + hunks := m.findHunks() // [1] + + // expanded mode: returns start unchanged + m.collapsed.enabled = false + assert.Equal(t, 1, m.firstVisibleInHunk(1, hunks)) + + // collapsed mode: skips hidden removes, lands on add + m.collapsed.enabled = true + assert.Equal(t, 3, m.firstVisibleInHunk(1, hunks)) + + // collapsed mode with expanded hunk: returns start + m.collapsed.expandedHunks = map[int]bool{1: true} + assert.Equal(t, 1, m.firstVisibleInHunk(1, hunks)) +} + +func TestModel_FirstVisibleInHunk_AllRemoves(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 1 + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, // idx 2 + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, // idx 3 + } + m := testModel(nil, nil) + m.diffLines = lines + m.collapsed.enabled = true + hunks := m.findHunks() // [1] + + // all-removes hunk: placeholder line is visible, returns hunkStart + assert.Equal(t, 1, m.firstVisibleInHunk(1, hunks)) + + // expanded hunk: also returns hunkStart + m.collapsed.expandedHunks = map[int]bool{1: true} + assert.Equal(t, 1, m.firstVisibleInHunk(1, hunks)) +} + +func TestModel_AdjustCursorIfHidden(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // idx 0 + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 1 - hidden + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, // idx 2 - hidden + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, // idx 3 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, // idx 4 + } + + t.Run("cursor on hidden line moves forward", func(t *testing.T) { + m := testModel(nil, nil) + m.diffLines = lines + m.collapsed.enabled = true + m.diffCursor = 1 // on hidden removed line + m.adjustCursorIfHidden() + assert.Equal(t, 3, m.diffCursor, "should move forward to add line") + }) + + t.Run("cursor on visible line stays put", func(t *testing.T) { + m := testModel(nil, nil) + m.diffLines = lines + m.collapsed.enabled = true + m.diffCursor = 0 // on context line + m.adjustCursorIfHidden() + assert.Equal(t, 0, m.diffCursor, "should stay on context line") + }) + + t.Run("not collapsed mode is no-op", func(t *testing.T) { + m := testModel(nil, nil) + m.diffLines = lines + m.collapsed.enabled = false + m.diffCursor = 1 + m.adjustCursorIfHidden() + assert.Equal(t, 1, m.diffCursor, "should not adjust in expanded mode") + }) + + t.Run("cursor on hidden line moves backward to placeholder", func(t *testing.T) { + onlyRemoves := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // idx 0 + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 1 - placeholder (visible) + {OldNum: 3, Content: "old2", ChangeType: diff.ChangeRemove}, // idx 2 - hidden + } + m := testModel(nil, nil) + m.diffLines = onlyRemoves + m.collapsed.enabled = true + m.diffCursor = 2 // on hidden removed line (not placeholder) + m.adjustCursorIfHidden() + assert.Equal(t, 1, m.diffCursor, "should move backward to delete-only hunk placeholder") + }) + + t.Run("cursor on delete-only hunk placeholder stays put", func(t *testing.T) { + // cursor on delete-only hunk's first line (placeholder) is already visible + deleteOnly := []diff.DiffLine{ + {Content: "...", ChangeType: diff.ChangeDivider}, // idx 0 - divider + {OldNum: 1, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 1 - placeholder (visible) + {OldNum: 2, Content: "old2", ChangeType: diff.ChangeRemove}, // idx 2 - hidden + {OldNum: 3, Content: "old3", ChangeType: diff.ChangeRemove}, // idx 3 - hidden + } + m := testModel(nil, nil) + m.diffLines = deleteOnly + m.collapsed.enabled = true + m.diffCursor = 1 // on placeholder (not hidden) + m.adjustCursorIfHidden() + assert.Equal(t, 1, m.diffCursor, "placeholder line is visible, cursor should stay") + }) + + t.Run("single hunk all removes placeholder at start", func(t *testing.T) { + // real single-hunk deleted file: first line is the visible placeholder + allRemoves := []diff.DiffLine{ + {OldNum: 1, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 0 - placeholder (visible) + {OldNum: 2, Content: "old2", ChangeType: diff.ChangeRemove}, // idx 1 - hidden + {OldNum: 3, Content: "old3", ChangeType: diff.ChangeRemove}, // idx 2 - hidden + } + m := testModel(nil, nil) + m.diffLines = allRemoves + m.collapsed.enabled = true + m.diffCursor = 0 // on placeholder, not hidden + m.adjustCursorIfHidden() + assert.Equal(t, 0, m.diffCursor, "placeholder is visible, cursor stays") + }) +} + +func TestModel_ToggleCollapsedModeAdjustsCursor(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.focus = paneDiff + m.currFile = "a.go" + m.diffLines = lines + m.diffCursor = 1 // on removed line + + // toggle to collapsed mode + m.toggleCollapsedMode() + assert.True(t, m.collapsed.enabled) + assert.Equal(t, 2, m.diffCursor, "cursor should move to add line, not stay on hidden removed line") +} + +func TestModel_ToggleHunkExpansionAdjustsCursor(t *testing.T) { + lines := []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "old1", ChangeType: diff.ChangeRemove}, // idx 1 + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, // idx 2 + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = newFileTree([]string{"a.go"}) + m.focus = paneDiff + m.currFile = "a.go" + m.diffLines = lines + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} // hunk expanded + m.diffCursor = 1 // on removed line (visible because expanded) + + // collapse the hunk - cursor on removed line should move + m.toggleHunkExpansion() + assert.False(t, m.collapsed.expandedHunks[1], "hunk should be collapsed") + assert.Equal(t, 2, m.diffCursor, "cursor should move to add line after hunk collapse") +} + +func TestModel_CollapsedCursorDownSkipsPlaceholderAnnotation(t *testing.T) { + // cursor moving down through a delete-only placeholder with an annotation should NOT + // stop on the invisible annotation sub-line + m := testModel(nil, nil) + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // 1 - placeholder + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, // 2 - hidden + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, // 3 + } + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "hidden note"}) + m.diffCursor = 0 + m.focus = paneDiff + + // move down lands on placeholder (idx 1) + m.moveDiffCursorDown() + assert.Equal(t, 1, m.diffCursor) + assert.False(t, m.cursorOnAnnotation, "should not stop on invisible annotation of placeholder") + + // move down again goes to ctx2 (idx 3), skipping the annotation + m.moveDiffCursorDown() + assert.Equal(t, 3, m.diffCursor) + assert.False(t, m.cursorOnAnnotation) +} + +func TestModel_CollapsedCursorUpSkipsPlaceholderAnnotation(t *testing.T) { + // cursor moving up onto a delete-only placeholder with an annotation should NOT + // land on the annotation sub-line + m := testModel(nil, nil) + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, // 0 + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, // 1 - placeholder + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, // 2 - hidden + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, // 3 + } + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "hidden note"}) + m.diffCursor = 3 + m.focus = paneDiff + + // move up should land on placeholder (idx 1), NOT on its annotation + m.moveDiffCursorUp() + assert.Equal(t, 1, m.diffCursor) + assert.False(t, m.cursorOnAnnotation, "should not land on invisible annotation of placeholder") +} + +func TestModel_CollapsedToggleClearsAnnotationState(t *testing.T) { + // toggling collapsed mode should clear cursorOnAnnotation + m := testModel(nil, nil) + m.focus = paneDiff + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "some note"}) + m.diffCursor = 1 + m.cursorOnAnnotation = true // simulating cursor on annotation in expanded mode + + m.toggleCollapsedMode() + assert.True(t, m.collapsed.enabled) + assert.False(t, m.cursorOnAnnotation, "cursorOnAnnotation should be cleared when toggling mode") +} + +func TestModel_CollapsedHunkCollapseClearsAnnotationState(t *testing.T) { + // collapsing a hunk should clear cursorOnAnnotation for annotations on removed lines + m := testModel(nil, nil) + m.focus = paneDiff + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "new1", ChangeType: diff.ChangeAdd}, + {NewNum: 3, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "note"}) + m.collapsed.enabled = true + m.collapsed.expandedHunks = map[int]bool{1: true} + m.diffCursor = 1 + m.cursorOnAnnotation = true // on annotation of expanded remove line + + m.toggleHunkExpansion() + assert.False(t, m.cursorOnAnnotation, "cursorOnAnnotation should be cleared when hunk collapses") +} + +func TestModel_CollapsedDeleteAnnotationBlockedOnPlaceholder(t *testing.T) { + // pressing 'd' on a delete-only placeholder should not delete the invisible annotation + m := testModel(nil, nil) + m.collapsed.enabled = true + m.collapsed.expandedHunks = make(map[int]bool) + m.currFile = "a.go" + m.diffLines = []diff.DiffLine{ + {NewNum: 1, Content: "ctx1", ChangeType: diff.ChangeContext}, + {OldNum: 2, Content: "del1", ChangeType: diff.ChangeRemove}, + {OldNum: 3, Content: "del2", ChangeType: diff.ChangeRemove}, + {NewNum: 2, Content: "ctx2", ChangeType: diff.ChangeContext}, + } + m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "-", Comment: "keep this"}) + m.diffCursor = 1 + m.focus = paneDiff + + // cursor should not be on annotation (placeholder) + assert.False(t, m.cursorOnAnnotation) + + // attempt delete - should be no-op since cursorOnAnnotation is false + m.deleteAnnotation() + assert.True(t, m.store.Has("a.go", 2, "-"), "annotation should not be deleted from placeholder") +} diff --git a/ui/diffview.go b/ui/diffview.go index b3a2f50a..1aa290ee 100644 --- a/ui/diffview.go +++ b/ui/diffview.go @@ -15,6 +15,10 @@ func (m Model) renderDiff() string { return " no changes" } + if m.collapsed.enabled { + return m.renderCollapsedDiff() + } + annotationMap, fileComment := m.buildAnnotationMap() var b strings.Builder m.renderFileAnnotationHeader(&b, fileComment) @@ -137,12 +141,15 @@ func (m Model) cursorDiffLine() (diff.DiffLine, bool) { // moveDiffCursorDown moves the diff cursor to the next non-divider line. // if the current line has an annotation and cursor is on the diff line, stops on the annotation first. +// in collapsed mode, also skips removed lines unless their hunk is expanded. func (m *Model) moveDiffCursorDown() { + hunks := m.findHunks() + // if currently on annotation sub-line, move to the next diff line if m.cursorOnAnnotation { m.cursorOnAnnotation = false for i := m.diffCursor + 1; i < len(m.diffLines); i++ { - if m.diffLines[i].ChangeType != diff.ChangeDivider { + if m.diffLines[i].ChangeType != diff.ChangeDivider && !m.isCollapsedHidden(i, hunks) { m.diffCursor = i return } @@ -150,10 +157,11 @@ func (m *Model) moveDiffCursorDown() { return } - // if current line has an annotation, stop on it first + // if current line has an annotation, stop on it first. + // skip for delete-only placeholders — their annotations are only visible when expanded. if m.diffCursor >= 0 && m.diffCursor < len(m.diffLines) { dl := m.diffLines[m.diffCursor] - if dl.ChangeType != diff.ChangeDivider { + if dl.ChangeType != diff.ChangeDivider && !m.isDeleteOnlyPlaceholder(m.diffCursor, hunks) { lineNum := m.diffLineNum(dl) if m.store.Has(m.currFile, lineNum, string(dl.ChangeType)) { m.cursorOnAnnotation = true @@ -162,13 +170,13 @@ func (m *Model) moveDiffCursorDown() { } } - // move to next non-divider diff line + // move to next non-divider diff line, skipping collapsed hidden lines start := m.diffCursor + 1 if m.diffCursor == -1 { start = 0 } for i := start; i < len(m.diffLines); i++ { - if m.diffLines[i].ChangeType != diff.ChangeDivider { + if m.diffLines[i].ChangeType != diff.ChangeDivider && !m.isCollapsedHidden(i, hunks) { m.diffCursor = i return } @@ -177,6 +185,7 @@ func (m *Model) moveDiffCursorDown() { // moveDiffCursorUp moves the diff cursor to the previous non-divider line. // when moving up from a diff line, if the previous line has an annotation, lands on the annotation first. +// in collapsed mode, also skips removed lines unless their hunk is expanded. func (m *Model) moveDiffCursorUp() { // if currently on annotation sub-line, move up to the diff line itself if m.cursorOnAnnotation { @@ -184,15 +193,16 @@ func (m *Model) moveDiffCursorUp() { return } + hunks := m.findHunks() for i := m.diffCursor - 1; i >= 0; i-- { - if m.diffLines[i].ChangeType == diff.ChangeDivider { + if m.diffLines[i].ChangeType == diff.ChangeDivider || m.isCollapsedHidden(i, hunks) { continue } m.diffCursor = i - // if this line has an annotation, land on it + // if this line has an annotation, land on it (skip for delete-only placeholders) dl := m.diffLines[i] lineNum := m.diffLineNum(dl) - if m.store.Has(m.currFile, lineNum, string(dl.ChangeType)) { + if m.store.Has(m.currFile, lineNum, string(dl.ChangeType)) && !m.isDeleteOnlyPlaceholder(i, hunks) { m.cursorOnAnnotation = true } return @@ -257,11 +267,13 @@ func (m *Model) moveDiffCursorToStart() { m.syncViewportToCursor() } -// moveDiffCursorToEnd moves the diff cursor to the last non-divider line. +// moveDiffCursorToEnd moves the diff cursor to the last visible non-divider line. +// in collapsed mode, skips hidden removed lines. func (m *Model) moveDiffCursorToEnd() { m.cursorOnAnnotation = false + hunks := m.findHunks() for i := len(m.diffLines) - 1; i >= 0; i-- { - if m.diffLines[i].ChangeType != diff.ChangeDivider { + if m.diffLines[i].ChangeType != diff.ChangeDivider && !m.isCollapsedHidden(i, hunks) { m.diffCursor = i break } @@ -281,8 +293,8 @@ func (m *Model) syncViewportToCursor() { m.viewport.SetContent(m.renderDiff()) } -// findHunks scans diffLines and returns a slice of chunk start indices. -// a chunk is a contiguous group of added/removed lines. the returned index +// findHunks scans diffLines and returns a slice of hunk start indices. +// a hunk is a contiguous group of added/removed lines. the returned index // is the first line of each such group. func (m Model) findHunks() []int { var hunks []int @@ -299,9 +311,9 @@ func (m Model) findHunks() []int { return hunks } -// currentHunk returns the 1-based chunk index and total chunk count. -// returns non-zero chunk index only when the cursor is on a changed line (add/remove). -// returns (0, total) when cursor is not inside any chunk. +// currentHunk returns the 1-based hunk index and total hunk count. +// returns non-zero hunk index only when the cursor is on a changed line (add/remove). +// returns (0, total) when cursor is not inside any hunk. func (m Model) currentHunk() (int, int) { hunks := m.findHunks() if len(hunks) == 0 { @@ -314,7 +326,7 @@ func (m Model) currentHunk() (int, int) { if dl.ChangeType != diff.ChangeAdd && dl.ChangeType != diff.ChangeRemove { return 0, len(hunks) } - // cursor is on a changed line, find which chunk + // cursor is on a changed line, find which hunk cur := 0 for i, start := range hunks { if m.diffCursor >= start { @@ -324,26 +336,37 @@ func (m Model) currentHunk() (int, int) { return cur, len(hunks) } -// moveToNextHunk moves the diff cursor to the start of the next change chunk. +// moveToNextHunk moves the diff cursor to the start of the next change hunk. +// in collapsed mode, advances past hidden removed lines to the first visible line in the hunk. func (m *Model) moveToNextHunk() { m.cursorOnAnnotation = false hunks := m.findHunks() for _, start := range hunks { - if start > m.diffCursor { - m.diffCursor = start - m.centerViewportOnCursor() - return + if start <= m.diffCursor { + continue } + target := m.firstVisibleInHunk(start, hunks) + if target < 0 { + continue // skip delete-only hunks in collapsed mode + } + m.diffCursor = target + m.centerViewportOnCursor() + return } } -// moveToPrevHunk moves the diff cursor to the start of the previous change chunk. +// moveToPrevHunk moves the diff cursor to the start of the previous change hunk. +// in collapsed mode, advances past hidden removed lines to the first visible line in the hunk. func (m *Model) moveToPrevHunk() { m.cursorOnAnnotation = false hunks := m.findHunks() for i := len(hunks) - 1; i >= 0; i-- { - if hunks[i] < m.diffCursor { - m.diffCursor = hunks[i] + target := m.firstVisibleInHunk(hunks[i], hunks) + if target < 0 { + continue // skip delete-only hunks in collapsed mode + } + if target < m.diffCursor { + m.diffCursor = target m.centerViewportOnCursor() return } diff --git a/ui/model.go b/ui/model.go index 8913513c..40eafe07 100644 --- a/ui/model.go +++ b/ui/model.go @@ -68,6 +68,8 @@ type Model struct { cursorOnAnnotation bool // true when cursor is on the annotation sub-line (not the diff line) annotateInput textinput.Model // text input for annotations + collapsed collapsedState // collapsed diff view state + 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 @@ -197,14 +199,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case msg.String() == "tab": - // switch panes: tree <-> diff (only switch to diff if a file is loaded) - if m.focus != paneTree { - m.focus = paneTree - return m, nil - } - if m.currFile != "" { - m.focus = paneDiff - } + m.togglePane() return m, nil case msg.String() == "f": @@ -247,6 +242,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } return m, nil + + case msg.String() == "v": + m.toggleCollapsedMode() + return m, nil } // pane-specific navigation @@ -259,6 +258,18 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// togglePane switches focus between tree and diff panes. +// only switches to diff pane when a file is loaded. +func (m *Model) togglePane() { + if m.focus != paneTree { + m.focus = paneTree + return + } + if m.currFile != "" { + m.focus = paneDiff + } +} + // loadSelectedIfChanged ensures the tree is visible and loads the selected file if it changed. func (m Model) loadSelectedIfChanged() (tea.Model, tea.Cmd) { m.tree.ensureVisible(m.treePageSize()) @@ -342,6 +353,9 @@ func (m Model) handleDiffNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case msg.String() == "d": cmd := m.deleteAnnotation() return m, cmd + case msg.String() == ".": + m.toggleHunkExpansion() + return m, nil } return m, nil } @@ -402,20 +416,25 @@ func (m Model) handleFileLoaded(msg fileLoadedMsg) (tea.Model, tea.Cmd) { m.highlightedLines = m.highlighter.HighlightLines(msg.file, msg.lines) m.cursorOnAnnotation = false m.scrollX = 0 + m.collapsed.expandedHunks = make(map[int]bool) m.skipInitialDividers() m.viewport.SetContent(m.renderDiff()) m.viewport.GotoTop() return m, nil } -// skipInitialDividers positions diffCursor on the first non-divider line. +// skipInitialDividers positions diffCursor on the first visible line. +// skips divider lines, and in collapsed mode also skips removed lines +// unless their hunk is expanded. func (m *Model) skipInitialDividers() { m.diffCursor = 0 + hunks := m.findHunks() for i, dl := range m.diffLines { - if dl.ChangeType != diff.ChangeDivider { - m.diffCursor = i - break + if dl.ChangeType == diff.ChangeDivider || m.isCollapsedHidden(i, hunks) { + continue } + m.diffCursor = i + return } } @@ -504,7 +523,19 @@ func (m Model) statusBarText(annotated map[string]bool) string { if cur, total := m.currentHunk(); total > 0 { hunkHint = fmt.Sprintf(" [ ] hunk %d/%d", cur, total) } - hints = "[j/k] scroll [h/tab] files [enter/a] annotate" + deleteHint + hunkHint + filterHint + fileNoteHint + " [n/p] next/prev [Q] discard [q] quit" + viewModeHint := " [v] collapse" + if m.collapsed.enabled { + viewModeHint = " [v] expand" + } + dotHint := "" + if m.collapsed.enabled { + if hs, ok := m.cursorHunkStart(); ok && m.collapsed.expandedHunks[hs] { + dotHint = " [.] collapse hunk" + } else if ok { + dotHint = " [.] expand hunk" + } + } + hints = "[j/k] scroll [h/tab] files [enter/a] annotate" + deleteHint + hunkHint + viewModeHint + dotHint + filterHint + fileNoteHint + " [n/p] next/prev [Q] discard [q] quit" } if countHint != "" { diff --git a/ui/styles.go b/ui/styles.go index 21dcc17a..cab0197e 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -17,6 +17,8 @@ type Colors struct { AddBg string // added line background RemoveFg string // removed line foreground RemoveBg string // removed line background + ModifyFg string // modified line foreground (collapsed mode) + ModifyBg string // modified line background (collapsed mode) TreeBg string // file tree pane background DiffBg string // diff pane background StatusFg string // status bar foreground @@ -44,9 +46,11 @@ type styles struct { // status bar StatusBar lipgloss.Style - // syntax-highlighted add/remove lines (background only, chroma owns foreground) + // syntax-highlighted add/remove/modify lines (background only, chroma owns foreground) LineAddHighlight lipgloss.Style LineRemoveHighlight lipgloss.Style + LineModify lipgloss.Style // modified line (collapsed mode, non-highlighted) + LineModifyHighlight lipgloss.Style // modified line (collapsed mode, syntax-highlighted) // diff cursor DiffCursorLine lipgloss.Style @@ -78,6 +82,8 @@ func normalizeColors(c Colors) Colors { c.AddBg = normalizeColor(c.AddBg) c.RemoveFg = normalizeColor(c.RemoveFg) c.RemoveBg = normalizeColor(c.RemoveBg) + c.ModifyFg = normalizeColor(c.ModifyFg) + c.ModifyBg = normalizeColor(c.ModifyBg) c.TreeBg = normalizeColor(c.TreeBg) c.DiffBg = normalizeColor(c.DiffBg) c.StatusFg = normalizeColor(c.StatusFg) @@ -155,6 +161,11 @@ func newStyles(c Colors) styles { Background(lipgloss.Color(c.AddBg)), LineRemoveHighlight: lipgloss.NewStyle(). Background(lipgloss.Color(c.RemoveBg)), + LineModify: lipgloss.NewStyle(). + Background(lipgloss.Color(c.ModifyBg)). + Foreground(lipgloss.Color(c.ModifyFg)), + LineModifyHighlight: lipgloss.NewStyle(). + Background(lipgloss.Color(c.ModifyBg)), DiffCursorLine: cursorLineStyle(c), AnnotationLine: lipgloss.NewStyle(). @@ -199,6 +210,8 @@ func plainStyles() styles { LineAddHighlight: lipgloss.NewStyle(), LineRemoveHighlight: lipgloss.NewStyle(), + LineModify: lipgloss.NewStyle(), + LineModifyHighlight: lipgloss.NewStyle(), DiffCursorLine: lipgloss.NewStyle().Reverse(true), AnnotationLine: lipgloss.NewStyle().Italic(true), diff --git a/ui/styles_test.go b/ui/styles_test.go index 60e6b009..99e34cb8 100644 --- a/ui/styles_test.go +++ b/ui/styles_test.go @@ -25,11 +25,14 @@ func TestNormalizeColor(t *testing.T) { func TestNormalizeColors(t *testing.T) { c := normalizeColors(Colors{ Accent: "5f87ff", Border: "#585858", Normal: "d0d0d0", + ModifyFg: "f5c542", ModifyBg: "#3D2E00", TreeBg: "1a1a1a", DiffBg: "", StatusFg: "aabbcc", StatusBg: "", }) assert.Equal(t, "#5f87ff", c.Accent, "should add # prefix") assert.Equal(t, "#585858", c.Border, "should keep existing #") assert.Equal(t, "#d0d0d0", c.Normal) + assert.Equal(t, "#f5c542", c.ModifyFg, "should add # prefix to modify fg") + assert.Equal(t, "#3D2E00", c.ModifyBg, "should keep existing # on modify bg") assert.Equal(t, "#1a1a1a", c.TreeBg) assert.Empty(t, c.DiffBg, "empty should stay empty") assert.Equal(t, "#aabbcc", c.StatusFg) @@ -56,6 +59,7 @@ func TestNewStyles_OptionalBackgrounds(t *testing.T) { SelectedFg: "#ffffaf", SelectedBg: "#303030", Annotation: "#ffd700", CursorBg: "#3a3a3a", AddFg: "#87d787", AddBg: "#022800", RemoveFg: "#ff8787", RemoveBg: "#3D0100", + ModifyFg: "#f5c542", ModifyBg: "#3D2E00", TreeBg: "#111111", DiffBg: "#222222", StatusFg: "#cccccc", StatusBg: "#333333", }) assert.NotNil(t, s.TreePane) @@ -63,3 +67,27 @@ func TestNewStyles_OptionalBackgrounds(t *testing.T) { assert.NotNil(t, s.StatusBar) }) } + +func TestNewStyles_ModifyStyles(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", + ModifyFg: "#f5c542", ModifyBg: "#3D2E00", + }) + // verify modify styles are created with correct colors + assert.NotNil(t, s.LineModify) + assert.NotNil(t, s.LineModifyHighlight) + + // verify modify styles render text without panics + assert.NotEmpty(t, s.LineModify.Render("modified line")) + assert.NotEmpty(t, s.LineModifyHighlight.Render("modified line")) +} + +func TestPlainStyles_ModifyStyles(t *testing.T) { + s := plainStyles() + // verify modify styles render correctly as no-op styles + assert.NotEmpty(t, s.LineModify.Render("text")) + assert.NotEmpty(t, s.LineModifyHighlight.Render("text")) +}