docs/internal/review-diff-rewrite-plan.md
Rewrite plugins/audit_mode.ts to use a magit-style split-panel UI and
git status --porcelain -z as the single source of truth for changed files.
File detection uses three separate git commands (git diff --cached,
git diff, git ls-files --others) instead of a single authoritative
source. Edge cases (renames, copies, files in both staged and unstaged)
can fall through the cracks.
Rendering produces a variable-length buffer that can exceed the viewport, causing buffer-wide scrolling. The review panel should be a fixed-height viewport-clamped UI like theme_editor.
Layout is a single column of hunks. The target is a two-panel magit style: file list on left, diff on right, action hints at bottom.
┌── GIT STATUS ──────────────┐┌── DIFF FOR file.rs ───────────────────────┐
│ @ (Staged) ││ @@ fn main() @@ │
│ + M hello.c M ││ fn main() { │
│ ││ - println!("Hello"); │
│ @ [Unstaged] ││ + println!("Hello, world!"); │
│ >M flake.nix M ││ + let x = 42; │
│ ││ } │
│ @ (Untracked) ││ │
│ o A shimen.md ││ │
│ ││ │
├─────────────────────────────┴┴───────────────────────────────────────────┤
│ [esc] Close [s] Stage [u] Unstage [d] Discard [Enter] Drill-Down │
└──────────────────────────────────────────────────────────────────────────┘
Selected file is highlighted with a background color. Diff panel shows the diff for the selected file.
editingDisabled: true, showCursors: false.viewportHeight lines — never more, never less.
This prevents buffer-wide scrolling entirely.leftPadded + "│" + rightPadded + "\n".
Bottom rows (separator, hints) are full-width.addOverlay
tracking needed.updateDisplay() which rebuilds
entries and calls editor.setVirtualBufferContent().resize event to update viewportWidth/viewportHeight and
re-render.Given viewport height H and width W:
Row 0 : Header row (left: "GIT STATUS", right: "DIFF FOR <file>")
Rows 1..H-3 : Main content (left: file list, right: diff) — H-3 rows
Row H-2 : Separator (full width "─...─")
Row H-1 : Hints bar (full width keybinding hints)
max(28, floor(W * 0.3)).W - leftWidth - 1 (1 for divider).Each panel has its own scroll offset stored in state:
| Panel | Scroll var | Nav keys |
|---|---|---|
| File list | fileScrollOffset | Up/Down, j/k |
| Diff | diffScrollOffset | PageUp/PageDown |
When selectedIndex changes, fileScrollOffset is auto-adjusted to keep the
selected file visible (same pattern as theme_editor's treeScrollOffset).
The total rendered output is always exactly H lines, so buffer-wide scroll
never engages.
interface FileEntry {
path: string;
status: string; // 'M', 'A', 'D', 'R', 'C', '?'
category: 'staged' | 'unstaged' | 'untracked';
origPath?: string; // for renames/copies
}
interface ReviewState {
files: FileEntry[];
hunks: Hunk[];
selectedIndex: number; // index into files[]
fileScrollOffset: number;
diffScrollOffset: number;
viewportWidth: number;
viewportHeight: number;
reviewBufferId: number | null;
comments: ReviewComment[];
hunkStatus: Record<string, HunkStatus>;
overallFeedback?: string;
}
git status --porcelain -zFile: plugins/audit_mode.ts — replace getGitDiff()
New function getGitStatus():
git status --porcelain -z.XY<space>path\0
(renames add old_path\0 after).FileEntry[].New function fetchDiffsForFiles(files):
git diff --cached --unified=3 → parseDiffOutput(stdout, 'staged').git diff --unified=3 → parseDiffOutput(stdout, 'unstaged').git diff --no-index --unified=3 /dev/null <file>.Hunk[].Key improvement: single source of truth for file states; handles renames, copies, partial staging (MM), deleted files correctly.
Replace: renderReviewStream(), updateReviewUI(), HighlightTask
New functions:
buildFileListLines(): ListLine[] — section headers + file entries,
with inline overlays for icons/colors. Section headers not selectable.
buildDiffLines(rightWidth): DiffLine[] — diff content for
state.files[state.selectedIndex]. Includes hunk headers (@@),
context/add/remove lines with character-level diff highlighting
(reuses existing diffStrings()).
buildDisplayEntries(): TextPropertyEntry[] — composites the above into
exactly viewportHeight lines using the theme_editor left+divider+right
pattern. Handles scroll offsets, selection highlighting, header/footer.
updateDisplay() — calls buildDisplayEntries(), sets buffer content,
no separate overlay pass needed (all styling via inline overlays).
onResize(data) — updates viewportWidth/viewportHeight, calls
updateDisplay(). Registered on resize event.
Replace: review_next_hunk, review_prev_hunk
New handlers:
| Handler | Action |
|---|---|
review_nav_up | selectedIndex--, reset diffScrollOffset, updateDisplay |
review_nav_down | selectedIndex++, reset diffScrollOffset, updateDisplay |
review_page_up | diffScrollOffset -= pageSize, updateDisplay |
review_page_down | diffScrollOffset += pageSize, updateDisplay |
review_file_page_up | selectedIndex -= pageSize, updateDisplay |
review_file_page_down | selectedIndex += pageSize, updateDisplay |
Navigation wraps/clamps at boundaries. When selectedIndex changes,
diffScrollOffset resets to 0.
New handlers (run git commands and refresh):
| Handler | Git command |
|---|---|
review_stage_file | git add -- <file> |
review_unstage_file | git reset HEAD -- <file> |
review_discard_file | git checkout -- <file> (tracked) or rm (untracked) |
After each action: re-run getGitStatus() + fetchDiffsForFiles(),
clamp selectedIndex, call updateDisplay().
start_review_diff() entry pointeditor.getViewport().getGitStatus() + fetchDiffsForFiles().editingDisabled: true, showCursors: false.resize handler.updateDisplay().review_drill_down() currently reads hunk from cursor text properties.
Change to use state.files[state.selectedIndex] to determine which file
to drill into. Rest of composite buffer logic stays the same.
editor.defineMode("review-mode", [
["Up", "review_nav_up"],
["Down", "review_nav_down"],
["k", "review_nav_up"],
["j", "review_nav_down"],
["PageUp", "review_page_up"],
["PageDown", "review_page_down"],
["s", "review_stage_file"],
["u", "review_unstage_file"],
["d", "review_discard_file"],
["Enter", "review_drill_down"],
["r", "review_refresh"],
["q", "close"],
["Escape", "close"],
// Review actions (apply to all hunks of selected file)
["a", "review_approve_hunk"],
["x", "review_reject_hunk"],
["c", "review_add_comment"],
["E", "review_export_session"],
], true);
Update audit_mode.i18n.json — remove old panel.help_* keys, add new
section header keys if needed. Keep status.* keys.
Remove functions no longer called:
computeFullFileAlignedDiff() (~140 lines)generateDiffPaneContent() (~145 lines)AlignedLine interfaceSideBySideDiffState, activeSideBySideStateon_viewport_changed(), findLineForByte()HighlightTask interfacerenderReviewStream(), review_next_hunk, review_prev_hunkEstimated removal: ~400 lines of dead code.
File: tests/e2e/plugins/audit_mode.rs
Test test_review_diff_scrolling_many_files:
.gitignore for plugins/.80x15 — main area is ~12 rows, so file list overflows.This validates that:
| File | Change |
|---|---|
plugins/audit_mode.ts | Major rewrite (~60% of file) |
plugins/audit_mode.i18n.json | Update keys |
tests/e2e/plugins/audit_mode.rs | Add scrolling test |
These features are kept unchanged:
review_drill_down)side_by_side_diff_current_file command