Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ TUI for reviewing diffs, files, and documents with inline annotations, built wit
- Overlay popups managed by `overlay.Manager`. `Compose()` uses ANSI-aware compositing via `charmbracelet/x/ansi.Cut`. `HandleKey()` returns `Outcome` — Model switches on `OutcomeKind` for side effects (file jumps, theme apply/persist). Overlay kinds: help, annot-list, theme-select, commit-info. One overlay at a time — opening any overlay auto-closes whichever was previously open
- Overlay mouse routing: `Manager.HandleMouse` mirrors `HandleKey` (wheel + left-click). `app/ui/mouse.go::handleOverlayMouse` delegates when any overlay is active — wheel scrolls the overlay's own state, clicks in annotlist/themeselect select, clicks in commitinfo/help are no-ops, clicks outside the popup are swallowed (no dismiss). `Close()` must reset `Manager.bounds = popupBounds{}` or a stale rectangle from the previous overlay will satisfy click hit-tests. `overlayCenter()` records the popup rectangle as a side effect of rendering — if a new Open* path bypasses Compose, clicks will be treated as outside. Row offsets are hardcoded per overlay (annotlist `contentTop=2`, themeselect `entriesTop=4`, both `horizChromeCols=2`); any change to lipgloss `Padding(...)` on the overlay box style or to the number of lines rendered before the first entry requires a matching update in that overlay's `handleLeftClick`. `overlay.WheelStep` (shared with `app/ui.wheelStep`) defines the wheel notch size across panes.
- Reload (`R` key): `reloadState` on `Model` holds `pending bool` (waiting for y/cancel), `hint string` (transient status-bar message), and `applicable bool` (false in `--stdin` mode — stream consumed). `ReloadApplicable` is wired at the composition root in `main.go`, following the same pattern as `CommitsApplicable`. The reload method is named `triggerReload()` — not `reload()` — because Go forbids a method and a field with the same name on the same type (`Model.reload` is the state field). Reload resets the diff cursor to the top of the file; tree selection (which file) is restored by `SelectByPath` in `handleFilesLoaded`.
- Compact mode (`C` key, `--compact` / `--compact-context=N`): shrinks the VCS diff at generation time (not at render time) by passing a `contextLines int` parameter through every `Renderer.FileDiff()` callsite. VCS renderers (Git, Hg, Jj) translate it via per-renderer helpers — `gitContextArg` / `hgContextArg` produce `-U<N>`, `jjContextArg` produces `--context=<N>`. Sentinel for full-file: `contextLines <= 0` or `>= 1000000` → use the full-file arg (`-U1000000` / `--context=1000000`). Context-only sources (FileReader, DirectoryReader, StdinReader) accept the parameter but ignore it — there are no changes to contextualize. `CompactApplicable` is computed at the composition root (`compactApplicable()` in `main.go`) via type assertion on the renderer chain (same approach as `CommitLogger`, no new interface); `false` for `--stdin`, `--all-files`, and file-only modes without VCS. Toggle re-fetches only the current file via `reloadCurrentFile()` — a deliberately lightweight sibling of `triggerReload()` that bumps `file.loadSeq` and returns `loadFileDiff(m.file.name)` without re-fetching the files list or commit log. Cursor reset to first hunk happens naturally via `skipInitialDividers()` in `handleFileLoaded` after the reload completes. `m.currentContextLines()` is the helper every `FileDiff` callsite must use — returns `m.modes.compactContext` if `m.modes.compact && m.compact.applicable`, else `0`. Runtime state (applicability + transient hint) lives on `compactState` alongside `reloadState` / `commitsState`; user-toggled state (on/off, context size) stays on `modeState` with the other view toggles. Composes cleanly with `--collapsed` (different layers: compact shrinks diff before parsing, collapsed hides removed lines during rendering).
- Compact mode (`C` key, `--compact` / `--compact-context=N`): shrinks the VCS diff at generation time (not at render time) by passing a `contextLines int` parameter through every `Renderer.FileDiff()` callsite. VCS renderers (Git, Hg, Jj) translate it via per-renderer helpers — `gitContextArg` / `hgContextArg` produce `-U<N>`, `jjContextArg` produces `--context=<N>`. Sentinel for full-file: `contextLines <= 0` or `>= 1000000` → use the full-file arg (`-U1000000` / `--context=1000000`). Context-only sources (FileReader, DirectoryReader, StdinReader) accept the parameter but ignore it — there are no changes to contextualize. `CompactApplicable` is computed at the composition root (`compactApplicable()` in `main.go`) via type assertion on the renderer chain (same approach as `CommitLogger`, no new interface); `false` for `--stdin`, `--all-files`, and file-only modes without VCS. Toggle re-fetches only the current file via `reloadCurrentFile()` — a deliberately lightweight sibling of `triggerReload()` that bumps `file.loadSeq` and returns `loadFileDiff(m.file.name)` without re-fetching the files list or commit log. Cursor reset to first hunk happens naturally via `skipInitialDividers()` in `handleFileLoaded` after the reload completes. `m.currentContextLines()` is the helper every `FileDiff` callsite must use — returns `m.modes.compactContext` if `m.modes.compact && m.compact.applicable`, else `0`. Runtime state (applicability + transient hint) lives on `compactState` alongside `reloadState` / `commitsState`; user-toggled state (on/off, context size) stays on `modeState` with the other view toggles. Composes cleanly with `--collapsed` (different layers: compact shrinks diff before parsing, collapsed hides removed lines during rendering). Divider lines now carry line-count labels: `parseUnifiedDiff` emits `⋯ N line[s] ⋯` dividers at three positions — leading (first hunk does not start at line 1), between non-adjacent hunks (computed from `prevOldEnd` tracked via hunk-header metadata, handles insertion-only `@@ -K,0 ...` hunks where `oldNum` does not advance on `+` lines), and trailing (last hunk does not reach EOF). Trailing requires `totalOldLines` passed by the VCS impl; Git/Hg/Jj `FileDiff` fetch it via `git show <old-ref>:<file>` / `hg cat` / `jj file show` and only when `contextLines > 0 && contextLines < fullContextSentinel` — full-file mode always reaches EOF so the probe is skipped.
- `diff.DiffLine.Content` for `ChangeDivider` rows is a human-readable `⋯ N line[s] ⋯` label produced by `parseUnifiedDiff`. Never pattern-match the string — dispatch on `ChangeType == diff.ChangeDivider`. Test fixtures may construct `ChangeDivider` rows with arbitrary `Content` (e.g. `"..."`, `"@@..."`); that is a test-only shortcut and NOT the parser's contract.
- Commit info overlay (`i` key) uses the `diff.CommitLogger` capability interface (additive to `diff.Renderer`). Model resolves a `commitLogSource` at construction: explicit `ModelConfig.CommitLog` wins, else type-asserts the renderer for `CommitLogger`, else the feature is unavailable and `i` is a no-op with a transient status-bar hint. `CommitsApplicable` is computed at the composition root by `commitsApplicable()` in `main.go` (using the `commitLogger` field populated by `setupVCSRenderer` in `renderer_setup.go`) — Model copies it, does not re-derive. Data is fetched eagerly at startup and on `R` reload: `Init()` and `triggerReload()` both return `tea.Batch(m.loadFiles(), m.loadCommits())`, running files and commits loads in parallel as independent goroutines. `loadCommits()` captures `m.commits.loadSeq` at invocation time and tags the resulting `commitsLoadedMsg` with it; `handleCommitsLoaded` drops any message whose seq no longer matches (stale-result guard, mirrors the files-load pattern). Eager parallel fetch shrinks the window where overlay and diff can disagree from "time until first `i` press" to "skew between two parallel goroutine starts" (milliseconds). Not a strict snapshot guarantee — the two subprocesses each resolve `HEAD` independently — but the practical race window is tens of ms instead of minutes. If the user presses `i` before `commitsLoadedMsg` arrives, `handleCommitInfo` sets a transient `loading commits…` hint instead of opening the overlay — a second press after load succeeds. Hg cannot use literal NUL in argv templates — use ASCII US/RS (`\x1f`/`\x1e`) as field/record separators for hg only
- **ANSI nesting with lipgloss**: `lipgloss.Render()` emits `\033[0m` (full reset) which breaks outer style backgrounds. For styled substrings inside a lipgloss container (status bar separators, search highlights, diff cursor, annotation lines), use raw ANSI sequences via the style sub-package (`style.AnsiFg`, `resolver.Color`), or dedicated `Renderer` methods. Never use `lipgloss.NewStyle().Render()` for inline elements within a lipgloss-rendered parent.
- **Background fill for themed panes**: lipgloss pane `Render()` and viewport internal padding emit plain spaces after reset, causing terminal default bg. Workarounds: (1) `extendLineBg()` pads lines to full width, (2) `padContentBg()` re-pads pane content, (3) `BorderBackground()` on border styles. **Ordering**: `extendLineBg()` must be called AFTER `applyHorizontalScroll()`.
Expand Down
124 changes: 110 additions & 14 deletions app/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const (
ChangeAdd ChangeType = "+"
ChangeRemove ChangeType = "-"
ChangeContext ChangeType = " "
ChangeDivider ChangeType = "~" // separates non-adjacent hunks
ChangeDivider ChangeType = "~" // marks a skipped unchanged region (leading, between-hunk, or trailing)

// fullContextSentinel is the numeric threshold that callers use to request
// full-file diff context. contextLines <= 0 or >= fullContextSentinel causes
Expand All @@ -45,7 +45,7 @@ const (
type DiffLine struct {
OldNum int // line number in old version (0 for additions)
NewNum int // line number in new version (0 for removals)
Content string // line content without the +/- prefix
Content string // line content without the +/- prefix; for ChangeDivider rows it is a human-readable "⋯ N line[s] ⋯" label — never pattern-match it, dispatch on ChangeType
ChangeType ChangeType // changeAdd, ChangeRemove, ChangeContext, or ChangeDivider
IsBinary bool // true when this line is a binary file placeholder
IsPlaceholder bool // true for non-content placeholders (broken symlink, non-regular file, too-long lines)
Expand Down Expand Up @@ -391,7 +391,13 @@ func (g *Git) FileDiff(ref, file string, staged bool, contextLines int) ([]DiffL
return nil, fmt.Errorf("get file diff for %s: %w", file, err)
}

lines, err := parseUnifiedDiff(out)
// trailing divider is only meaningful in compact mode — full-file mode always
// reaches EOF, so probing the old-file size would be a wasted subprocess.
total := 0
if contextLines > 0 && contextLines < fullContextSentinel {
total = g.totalOldLines(ref, file, staged)
}
lines, err := parseUnifiedDiff(out, total)
if err != nil {
return nil, err
}
Expand All @@ -406,6 +412,39 @@ func (g *Git) FileDiff(ref, file string, staged bool, contextLines int) ([]DiffL
return lines, nil
}

// totalOldLines returns the line count of the pre-change version of file, used by
// parseUnifiedDiff to emit a trailing divider. Returns 0 when the old-side file is
// unavailable (new files, bad refs, etc.) — the parser treats 0 as "unknown" and
// skips the trailing divider.
//
// Old-side resolution:
// - ref empty + staged → HEAD (git diff --cached compares HEAD against index)
// - ref empty + not staged → index via `git show :path`
// - ref contains ".." or "..." → left operand (triple-dot checked first so A...B
// is not mis-split on the leading "..")
// - single ref → use as-is
//
// For triple-dot ranges the left operand is an approximation of the true old side
// (merge-base(A,B)); accurate enough for the informational trailing-divider count.
func (g *Git) totalOldLines(ref, file string, staged bool) int {
oldRef := ref
if left, _, ok := strings.Cut(ref, "..."); ok {
oldRef = left
}
if left, _, ok := strings.Cut(oldRef, ".."); ok {
oldRef = left
}
if oldRef == "" && staged {
oldRef = "HEAD"
}
// `git show :path` (empty oldRef) shows the index version of the file
out, err := g.runGit("show", oldRef+":"+file)
if err != nil {
return 0
}
return countLines(out)
}

// gitContextArg returns the -U argument for git diff given the caller's requested
// context size. A non-positive contextLines or one at or above fullContextSentinel
// returns the full-file arg; any other value returns -U<contextLines>.
Expand Down Expand Up @@ -557,8 +596,37 @@ func (g *Git) formatSize(bytes int64) string {
}
}

// hunkHeaderRe matches unified diff hunk headers like @@ -1,5 +1,7 @@
var hunkHeaderRe = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
// hunkHeaderRe matches unified diff hunk headers like @@ -1,5 +1,7 @@.
// Lengths are optional per git's spec (omitted means length 1) and are captured
// so the parser can compute the old-side end of each hunk.
var hunkHeaderRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)

// countLines returns the number of lines in s, counting a final non-newline-terminated
// line as one additional line. Empty input returns 0. Used by the per-VCS totalOldLines
// methods to translate file contents into a line count for the trailing divider.
func countLines(s string) int {
if s == "" {
return 0
}
n := strings.Count(s, "\n")
if !strings.HasSuffix(s, "\n") {
n++
}
return n
}

// appendGapDivider appends a "⋯ N lines ⋯" divider to lines when gap is positive.
// Used for leading, between-hunks, and trailing dividers — same format, different
// source of the gap count. Returns lines unchanged when gap <= 0 (nothing to show).
func appendGapDivider(lines []DiffLine, gap int) []DiffLine {
switch {
case gap == 1:
return append(lines, DiffLine{ChangeType: ChangeDivider, Content: "⋯ 1 line ⋯"})
case gap > 1:
return append(lines, DiffLine{ChangeType: ChangeDivider, Content: fmt.Sprintf("⋯ %d lines ⋯", gap)})
}
return lines
}

// binaryFilesRe matches git's "Binary files ... differ" line for binary diffs.
// Assumes English locale; non-English git may localize this message.
Expand All @@ -568,15 +636,26 @@ var binaryFilesRe = regexp.MustCompile(`^Binary files .+ and .+ differ$`)
// it handles the diff header, hunk headers, and content lines.
// for binary diffs ("Binary files ... differ"), it returns a single placeholder DiffLine.
// intended for single-file diffs; multi-file diffs are not fully supported.
func parseUnifiedDiff(raw string) ([]DiffLine, error) {
//
// totalOldLines is the total line count of the pre-change file, used to emit a
// trailing "⋯ N lines ⋯" divider after the last hunk when it does not reach EOF.
// Pass 0 when unknown (context-only sources, tests, or any case where the caller
// cannot cheaply determine the old file's size) — trailing divider is then skipped.
func parseUnifiedDiff(raw string, totalOldLines int) ([]DiffLine, error) {
var lines []DiffLine
scanner := bufio.NewScanner(strings.NewReader(raw))
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), MaxLineLength)

// skip diff header lines (---, +++, diff --git, index, etc.)
inHeader := true
var oldNum, newNum int
firstHunk := true
// prevOldEnd = next untouched old-side line. Initialized to 1 so the first hunk's
// leading divider uses the same `oldStart - prevOldEnd` formula as between-hunks gaps.
prevOldEnd := 1
// sawHunk tracks whether any hunk header was parsed — used as the guard for the
// trailing divider so that insertion-at-start hunks (@@ -0,0 ...) don't collide
// with the prevOldEnd==1 initialization sentinel.
var sawHunk bool
var isNewFile, isDeletedFile bool

for scanner.Scan() {
Expand Down Expand Up @@ -608,16 +687,25 @@ func parseUnifiedDiff(raw string) ([]DiffLine, error) {
// parse hunk header
if m := hunkHeaderRe.FindStringSubmatch(line); m != nil {
oldStart, errOld := strconv.Atoi(m[1])
newStart, errNew := strconv.Atoi(m[2])
newStart, errNew := strconv.Atoi(m[3])
if errOld != nil || errNew != nil {
return nil, fmt.Errorf("parse hunk header %q: old=%w new=%w", line, errOld, errNew)
}

// add divider between non-adjacent hunks (when using normal context, not -U1000000)
if !firstHunk {
lines = append(lines, DiffLine{ChangeType: ChangeDivider, Content: "..."})
}
firstHunk = false
// Atoi("") returns 0 with error; regex guarantees m[2] is digits when non-empty.
// Both the omitted-length case (git spec: implicit 1) and the literal `,0` (insertion-only)
// end up at oldLen=0 here, and max(oldLen,1) below resolves both to the same advance.
oldLen, _ := strconv.Atoi(m[2])

// emit divider representing unchanged lines BEFORE this hunk.
// Leading divider (first hunk) uses prevOldEnd=1 initialization; between-hunks use
// prevOldEnd from prior iteration. Gap uses hunk-header metadata not oldNum, so
// insertion-only hunks (@@ -K,0 ...) compute correctly; oldNum stays put on `+` lines.
lines = appendGapDivider(lines, oldStart-prevOldEnd)
sawHunk = true
// prevOldEnd = line number AFTER the current hunk on the old side. Normal hunks
// (oldLen>0) cover [oldStart, oldStart+oldLen). Insertion-only hunks (oldLen==0,
// e.g. @@ -K,0 ...) insert between old lines K and K+1 — handled by max(oldLen,1).
prevOldEnd = oldStart + max(oldLen, 1)

oldNum = oldStart
newNum = newStart
Expand Down Expand Up @@ -663,6 +751,14 @@ func parseUnifiedDiff(raw string) ([]DiffLine, error) {
return nil, fmt.Errorf("scan diff: %w", err)
}

// trailing divider: unchanged lines after the last hunk on the old side.
// Emitted only when the caller supplied totalOldLines AND at least one hunk
// was processed. sawHunk (not prevOldEnd > 1) is the correct "processed" flag
// — insertion-at-start hunks (@@ -0,0 ...) leave prevOldEnd at 1 but still count.
if totalOldLines > 0 && sawHunk {
lines = appendGapDivider(lines, totalOldLines-prevOldEnd+1)
}
Comment on lines +754 to +760
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing divider emission is currently gated by prevOldEnd > 1, but prevOldEnd stays at 1 for an insertion-only hunk at the start of an existing file (@@ -0,0 ...@@). In that case at least one hunk was processed and, when totalOldLines > 0, a trailing divider should still be emitted for the unchanged old-side lines after the insertion. Consider tracking an explicit sawHunk boolean (set when a hunk header is parsed) and gating on that instead of prevOldEnd > 1 (or adjust the sentinel/initialization so insertion-at-start counts as processed).

Copilot uses AI. Check for mistakes.

return lines, nil
}

Expand Down
Loading