From 4fd4fcb36c78274be31b3cb12413cd93643e8bac Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 2 Apr 2026 03:28:22 -0500 Subject: [PATCH 1/4] feat: add collapsed diff mode Add a collapsed view (toggle with 'v') that hides removed lines and shows modifications inline with amber styling. Within collapsed mode, individual hunks can be expanded with '.' to reveal full context. Includes buildModifiedSet for pairing removed/added lines within hunks, cursor navigation that skips hidden lines, per-hunk expansion tracking, status bar hints, and configurable ModifyFg/ModifyBg colors. --- cmd/revdiff/main.go | 4 + ui/annotate.go | 21 + ui/diffview.go | 403 +++++++++++- ui/model.go | 58 +- ui/model_test.go | 1531 +++++++++++++++++++++++++++++++++++++++++++ ui/styles.go | 15 +- ui/styles_test.go | 30 + 7 files changed, 2024 insertions(+), 38 deletions(-) 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/ui/annotate.go b/ui/annotate.go index 727ce9d8..c42f83a8 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 { + 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/diffview.go b/ui/diffview.go index b3a2f50a..50115108 100644 --- a/ui/diffview.go +++ b/ui/diffview.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "github.com/charmbracelet/x/ansi" @@ -15,6 +16,10 @@ func (m Model) renderDiff() string { return " no changes" } + if m.collapsed { + return m.renderCollapsedDiff() + } + annotationMap, fileComment := m.buildAnnotationMap() var b strings.Builder m.renderFileAnnotationHeader(&b, fileComment) @@ -26,6 +31,140 @@ func (m Model) renderDiff() string { return b.String() } +// 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 + for i, dl := range m.diffLines { + hunkStart := m.hunkStartFor(i, hunks) + expanded := hunkStart >= 0 && m.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 +} + // buildAnnotationMap creates a lookup map of line annotations for the current file. // returns the annotation map and the file-level comment (empty if none). func (m Model) buildAnnotationMap() (annotations map[string]string, fileComment string) { @@ -137,12 +276,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 +292,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 +305,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 +320,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 +328,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 +402,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 +428,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 +446,51 @@ 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. +// 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 +} + +// 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 +503,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,32 +513,198 @@ 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 } } } +// 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 = !m.collapsed + m.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 { + return + } + hunkStart, ok := m.cursorHunkStart() + if !ok { + return + } + if m.expandedHunks[hunkStart] { + delete(m.expandedHunks, hunkStart) + m.cursorOnAnnotation = false // annotations on removed lines become invisible + m.adjustCursorIfHidden() + } else { + m.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 || 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.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 { + 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.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 || 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 + } + } +} + // centerViewportOnCursor scrolls the viewport to place the cursor in the middle of the page. func (m *Model) centerViewportOnCursor() { cursorY := m.cursorViewportY() diff --git a/ui/model.go b/ui/model.go index 8913513c..057776f8 100644 --- a/ui/model.go +++ b/ui/model.go @@ -68,6 +68,9 @@ 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 bool // true when viewing collapsed diff (final text only) + expandedHunks map[int]bool // hunks expanded inline in collapsed mode, key = diffLines start index + 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 +200,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 +243,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 +259,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 +354,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 +417,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.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 +524,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 { + viewModeHint = " [v] expand" + } + dotHint := "" + if m.collapsed { + if hs, ok := m.cursorHunkStart(); ok && m.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/model_test.go b/ui/model_test.go index f7d2122d..a5b660af 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -2893,3 +2893,1534 @@ func TestModel_StatusBarShowsDiscardHint(t *testing.T) { assert.Contains(t, status, "[q] quit") }) } + +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, "v should enable collapsed mode") + assert.NotNil(t, model.expandedHunks) + assert.Empty(t, model.expandedHunks, "expandedHunks should be reset on toggle") + }) + + t.Run("toggle off", func(t *testing.T) { + m.collapsed = true + m.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, "v should disable collapsed mode") + assert.Empty(t, model.expandedHunks, "expandedHunks should be reset on toggle") + }) + + t.Run("no-op in tree pane", func(t *testing.T) { + m.collapsed = false + m.focus = paneTree + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + model := result.(Model) + assert.False(t, model.collapsed, "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, "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 = true + m.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.expandedHunks[1], "hunk at index 1 should be expanded") + }) + + t.Run("collapse expanded hunk", func(t *testing.T) { + m.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.expandedHunks[1], "hunk should be collapsed after second dot") + }) + + t.Run("expand second hunk independently", func(t *testing.T) { + m.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.expandedHunks[4], "hunk 2 should be expanded") + assert.True(t, model.expandedHunks[1], "hunk 1 should remain expanded") + }) + + t.Run("no-op on context line", func(t *testing.T) { + m.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.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 = false + m.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.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 = true + model.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, "collapsed should persist across file switches") + assert.Empty(t, model.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 = true + m.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 = true + m.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 = 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.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 = true + m.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 = 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.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 = true + m.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 = true + m.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 = true + m.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 = true + m.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.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 = true + m.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 = true + m.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.expandedHunks[1] = true + assert.False(t, m.isDeleteOnlyPlaceholder(1, hunks), "expanded hunk should not be placeholder") + + // not collapsed mode + m.collapsed = false + m.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 = true + m.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 = true + m.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.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 = true + m.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 = 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 = 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.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 = 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.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 = 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 = 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 = true + model.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, "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 = 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 = true + m.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 = 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 = 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 = 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 = 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 = true + m.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 = true + m.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 = true + m.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 = 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 = 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 = 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 = true + m.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 = true + m.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 = true + m.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 = true + m.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 = 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 = true + m.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 = true + m.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 = true + m.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 = true + m.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 = false + assert.Equal(t, 1, m.firstVisibleInHunk(1, hunks)) + + // collapsed mode: skips hidden removes, lands on add + m.collapsed = true + assert.Equal(t, 3, m.firstVisibleInHunk(1, hunks)) + + // collapsed mode with expanded hunk: returns start + m.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 = 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.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 = 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 = 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 = 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 = 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 = 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 = 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) + 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 = true + m.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.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 = true + m.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 = true + m.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) + 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 = true + m.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 = true + m.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/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..f108037f 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,29 @@ 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 exist as no-op styles + assert.NotNil(t, s.LineModify) + assert.NotNil(t, s.LineModifyHighlight) + assert.NotEmpty(t, s.LineModify.Render("text")) + assert.NotEmpty(t, s.LineModifyHighlight.Render("text")) +} From 81aaa4ba0ccde863eac2fc04eef080b407d750cb Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 2 Apr 2026 03:28:29 -0500 Subject: [PATCH 2/4] docs: update documentation for collapsed diff mode Update README, CLAUDE.md, and plugin reference docs with collapsed mode keybindings, data flow, and configuration options. Move plan file to completed directory. --- .../skills/revdiff/references/config.md | 2 + .../skills/revdiff/references/usage.md | 2 + CLAUDE.md | 6 +- README.md | 5 + .../completed/20260402-collapsed-diff-mode.md | 208 ++++++++++++++++++ 5 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 docs/plans/completed/20260402-collapsed-diff-mode.md 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/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 From cc7e555a785e80168275d44fafaa50e22f6e2798 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 2 Apr 2026 04:04:41 -0500 Subject: [PATCH 3/4] refactor: extract collapsed diff mode into separate file with collapsedState struct --- ui/annotate.go | 2 +- ui/collapsed.go | 349 ++++++++++ ui/collapsed_test.go | 1543 ++++++++++++++++++++++++++++++++++++++++++ ui/diffview.go | 334 +-------- ui/model.go | 11 +- ui/model_test.go | 1531 ----------------------------------------- 6 files changed, 1899 insertions(+), 1871 deletions(-) create mode 100644 ui/collapsed.go create mode 100644 ui/collapsed_test.go diff --git a/ui/annotate.go b/ui/annotate.go index c42f83a8..834e94a5 100644 --- a/ui/annotate.go +++ b/ui/annotate.go @@ -239,7 +239,7 @@ func (m Model) cursorViewportY() int { annotationSet := m.buildAnnotationSet() var hunks []int - if m.collapsed { + if m.collapsed.enabled { hunks = m.findHunks() } diff --git a/ui/collapsed.go b/ui/collapsed.go new file mode 100644 index 00000000..acf69998 --- /dev/null +++ b/ui/collapsed.go @@ -0,0 +1,349 @@ +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 + for i, dl := range m.diffLines { + hunkStart := m.hunkStartFor(i, hunks) + 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 50115108..1aa290ee 100644 --- a/ui/diffview.go +++ b/ui/diffview.go @@ -1,7 +1,6 @@ package ui import ( - "fmt" "strings" "github.com/charmbracelet/x/ansi" @@ -16,7 +15,7 @@ func (m Model) renderDiff() string { return " no changes" } - if m.collapsed { + if m.collapsed.enabled { return m.renderCollapsedDiff() } @@ -31,140 +30,6 @@ func (m Model) renderDiff() string { return b.String() } -// 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 - for i, dl := range m.diffLines { - hunkStart := m.hunkStartFor(i, hunks) - expanded := hunkStart >= 0 && m.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 -} - // buildAnnotationMap creates a lookup map of line annotations for the current file. // returns the annotation map and the file-level comment (empty if none). func (m Model) buildAnnotationMap() (annotations map[string]string, fileComment string) { @@ -446,48 +311,6 @@ func (m Model) findHunks() []int { return hunks } -// 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 -} - // 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. @@ -550,161 +373,6 @@ func (m *Model) moveToPrevHunk() { } } -// 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 = !m.collapsed - m.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 { - return - } - hunkStart, ok := m.cursorHunkStart() - if !ok { - return - } - if m.expandedHunks[hunkStart] { - delete(m.expandedHunks, hunkStart) - m.cursorOnAnnotation = false // annotations on removed lines become invisible - m.adjustCursorIfHidden() - } else { - m.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 || 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.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 { - 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.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 || 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 - } - } -} - // centerViewportOnCursor scrolls the viewport to place the cursor in the middle of the page. func (m *Model) centerViewportOnCursor() { cursorY := m.cursorViewportY() diff --git a/ui/model.go b/ui/model.go index 057776f8..40eafe07 100644 --- a/ui/model.go +++ b/ui/model.go @@ -68,8 +68,7 @@ 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 bool // true when viewing collapsed diff (final text only) - expandedHunks map[int]bool // hunks expanded inline in collapsed mode, key = diffLines start index + 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 @@ -417,7 +416,7 @@ 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.expandedHunks = make(map[int]bool) + m.collapsed.expandedHunks = make(map[int]bool) m.skipInitialDividers() m.viewport.SetContent(m.renderDiff()) m.viewport.GotoTop() @@ -525,12 +524,12 @@ func (m Model) statusBarText(annotated map[string]bool) string { hunkHint = fmt.Sprintf(" [ ] hunk %d/%d", cur, total) } viewModeHint := " [v] collapse" - if m.collapsed { + if m.collapsed.enabled { viewModeHint = " [v] expand" } dotHint := "" - if m.collapsed { - if hs, ok := m.cursorHunkStart(); ok && m.expandedHunks[hs] { + if m.collapsed.enabled { + if hs, ok := m.cursorHunkStart(); ok && m.collapsed.expandedHunks[hs] { dotHint = " [.] collapse hunk" } else if ok { dotHint = " [.] expand hunk" diff --git a/ui/model_test.go b/ui/model_test.go index a5b660af..f7d2122d 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -2893,1534 +2893,3 @@ func TestModel_StatusBarShowsDiscardHint(t *testing.T) { assert.Contains(t, status, "[q] quit") }) } - -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, "v should enable collapsed mode") - assert.NotNil(t, model.expandedHunks) - assert.Empty(t, model.expandedHunks, "expandedHunks should be reset on toggle") - }) - - t.Run("toggle off", func(t *testing.T) { - m.collapsed = true - m.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, "v should disable collapsed mode") - assert.Empty(t, model.expandedHunks, "expandedHunks should be reset on toggle") - }) - - t.Run("no-op in tree pane", func(t *testing.T) { - m.collapsed = false - m.focus = paneTree - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) - model := result.(Model) - assert.False(t, model.collapsed, "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, "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 = true - m.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.expandedHunks[1], "hunk at index 1 should be expanded") - }) - - t.Run("collapse expanded hunk", func(t *testing.T) { - m.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.expandedHunks[1], "hunk should be collapsed after second dot") - }) - - t.Run("expand second hunk independently", func(t *testing.T) { - m.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.expandedHunks[4], "hunk 2 should be expanded") - assert.True(t, model.expandedHunks[1], "hunk 1 should remain expanded") - }) - - t.Run("no-op on context line", func(t *testing.T) { - m.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.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 = false - m.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.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 = true - model.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, "collapsed should persist across file switches") - assert.Empty(t, model.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 = true - m.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 = true - m.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 = 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.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 = true - m.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 = 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.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 = true - m.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 = true - m.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 = true - m.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 = true - m.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.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 = true - m.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 = true - m.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.expandedHunks[1] = true - assert.False(t, m.isDeleteOnlyPlaceholder(1, hunks), "expanded hunk should not be placeholder") - - // not collapsed mode - m.collapsed = false - m.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 = true - m.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 = true - m.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.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 = true - m.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 = 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 = 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.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 = 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.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 = 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 = 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 = true - model.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, "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 = 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 = true - m.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 = 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 = 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 = 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 = 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 = true - m.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 = true - m.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 = true - m.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 = 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 = 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 = 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 = true - m.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 = true - m.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 = true - m.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 = true - m.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 = 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 = true - m.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 = true - m.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 = true - m.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 = true - m.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 = false - assert.Equal(t, 1, m.firstVisibleInHunk(1, hunks)) - - // collapsed mode: skips hidden removes, lands on add - m.collapsed = true - assert.Equal(t, 3, m.firstVisibleInHunk(1, hunks)) - - // collapsed mode with expanded hunk: returns start - m.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 = 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.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 = 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 = 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 = 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 = 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 = 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 = 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) - 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 = true - m.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.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 = true - m.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 = true - m.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) - 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 = true - m.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 = true - m.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") -} From 880fae84b3f616676e599f65b7feb19d69546187 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 2 Apr 2026 16:13:10 -0500 Subject: [PATCH 4/4] fix: address Copilot review findings for collapsed diff mode Replace hunkStartFor() linear scan with inline hunk tracking in renderCollapsedDiff loop (O(1) per line). Remove no-op NotNil assertions on lipgloss.Style structs in styles test. --- ui/collapsed.go | 11 ++++++++++- ui/styles_test.go | 4 +--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/collapsed.go b/ui/collapsed.go index acf69998..a5ea45bf 100644 --- a/ui/collapsed.go +++ b/ui/collapsed.go @@ -29,8 +29,17 @@ func (m Model) renderCollapsedDiff() string { m.renderFileAnnotationHeader(&b, fileComment) hasVisibleContent := false + hunkIdx := 0 for i, dl := range m.diffLines { - hunkStart := m.hunkStartFor(i, hunks) + // 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 { diff --git a/ui/styles_test.go b/ui/styles_test.go index f108037f..99e34cb8 100644 --- a/ui/styles_test.go +++ b/ui/styles_test.go @@ -87,9 +87,7 @@ func TestNewStyles_ModifyStyles(t *testing.T) { func TestPlainStyles_ModifyStyles(t *testing.T) { s := plainStyles() - // verify modify styles exist as no-op styles - assert.NotNil(t, s.LineModify) - assert.NotNil(t, s.LineModifyHighlight) + // verify modify styles render correctly as no-op styles assert.NotEmpty(t, s.LineModify.Render("text")) assert.NotEmpty(t, s.LineModifyHighlight.Render("text")) }