docs/internal/review-diff-feature-restoration-plan.md
Status: Planned Issue: #1503 — Review diff comments degradation Scope: Restore features lost in the v0.2.22 magit rewrite, adapted to the new split-panel UI
The v0.2.22 magit-style rewrite (7c33606) replaced the old single-column
hunk view with a split-panel UI. Several features were accidentally dropped:
x (reject), ! (needs changes),
? (question), O (overall feedback) lost their keybindings.← APPROVED. Now invisible.q to close — standard magit close key was removed.u reassigned — was "clear review status", now "unstage file".Line-specific comments (#1503) have already been fixed in a prior commit.
All changes follow the existing Fresh UX conventions and NN/g usability heuristics:
q closes, s/u stage/unstage, actions
apply to whatever is under the cursor.s) does the natural action at the current
granularity level.The core UX insight: actions apply to whatever the cursor is on. When the file list is focused, actions apply to the whole file. When the diff panel is focused, actions apply to the hunk containing the selected line.
┌── GIT STATUS ──────────────┐┌── DIFF FOR main.rs ────────────────────────┐
│ ▸ Staged ││ @@ fn main() @@ ✓ APPROVED │
│ >M hello.c ││ fn main() { │
│ ││ - println!("Hello"); │
│ ▸ Changes ││ + println!("Hello, world!"); │
│ M main.rs ││ + let x = 42; │
│ ││ } │
│ ▸ Untracked ││ » [+4] Consider splitting statements │
│ A notes.txt ││ │
│ ││ @@ fn helper() @@ (pending) │
│ ││ fn helper() { │
│ ││ - todo!() │
│ ││ + 42 │
│ ││ } │
├──────────────────────────────┴┴───────────────────────────────────────────┤
│ [s]tage [u]nstage [d]iscard [c]omment [a]pprove [x]reject [Enter]drill │
└───────────────────────────────────────────────────────────────────────────┘
Both panels share the same keys. The action scope depends on which panel has focus:
| Key | File panel focused | Diff panel focused |
|---|---|---|
s | Stage entire file | Stage current hunk |
u | Unstage entire file | Unstage current hunk |
d | Discard entire file | Discard current hunk |
a | Approve all hunks | Approve current hunk |
x | Reject all hunks | Reject current hunk |
! | — | Mark hunk needs changes |
? | — | Mark hunk with question |
c | Comment (file-level) | Comment on selected line |
O | Set overall feedback | Set overall feedback |
n/p | — | Jump to next/prev hunk header |
e | Export to markdown | Export to markdown |
q/Esc | Close review diff | Close review diff |
| Key | File panel | Diff panel |
|---|---|---|
Up/k | Previous file | Previous diff line |
Down/j | Next file | Next diff line |
n | — | Jump to next hunk header |
p | — | Jump to prev hunk header |
PageUp | Page up in file list | Page up in diff |
PageDown | Page down in file list | Page down in diff |
Home | First file | Top of diff |
End | Last file | Bottom of diff |
Tab | Switch to diff panel | Switch to file panel |
Left | Focus file panel | Focus file panel |
Right | Focus diff panel | Focus diff panel |
Enter | Drill down to side-by-side | Drill down to side-by-side |
Hunk headers show the current review status with a colored icon and label:
@@ fn main() @@ ✓ APPROVED ← green
@@ helper() @@ ✗ REJECTED ← red
@@ parse() @@ ! NEEDS CHANGES ← yellow
@@ validate() @@ ? QUESTION ← yellow
@@ render() @@ (pending) ← dim, only when others have status
When all hunks are pending, no status suffix is shown (clean default).
Adjacent -/+ line pairs in the diff panel get character-level highlighting
using the existing diffStrings() function:
- println!("Hello"); ← "Hello" has bold red bg
+ println!("Hello, world!"); ← "Hello, world!" has bold green bg
The unchanged portions (println!(" and ");) use the normal diff line
foreground without bold/bg emphasis. This reuses the same approach as the
side-by-side drill-down view but applies it to the magit panel's right column.
Implementation: buildDiffLines() detects adjacent remove/add pairs and
attaches inlineOverlays to the DiffLine entries. The
buildMagitDisplayEntries() renderer already passes inlineOverlays through.
Comments appear inline below the line they reference (already implemented):
+ let x = 42;
» [+5] Consider splitting into two statements
Format: » [±line] text with STYLE_COMMENT (yellow/warning color).
The toolbar adapts to show the most relevant actions for the current focus:
File panel: [Tab] Switch [s] Stage [u] Unstage [d] Discard [Enter] Drill-Down [r] Refresh
Diff panel: [Tab] Switch [s] Stage Hunk [c] Comment [a] Approve [x] Reject [n/p] Next/Prev Hunk
This follows NN/g's "recognition over recall" heuristic — users see exactly which actions apply to their current context.
Follow existing patterns (past tense, interpolated with %{var}):
| Action | Message |
|---|---|
| Stage hunk | "Hunk staged (1 of 3 in main.rs)" |
| Unstage hunk | "Hunk unstaged" |
| Discard hunk | "Hunk discarded" |
| Approve | "Hunk approved" (existing) |
| Reject | "Hunk rejected" (existing) |
| Stage file | "File staged: main.rs" |
| Discard file | "Changes discarded: main.rs" |
Destructive actions require confirmation (existing pattern from
review_discard_file):
Discard this hunk in "main.rs"? This cannot be undone.
with suggestions: [Discard hunk] / [Cancel]Non-destructive actions (stage, unstage, review status) execute immediately with no confirmation.
git apply --cached with temp fileThis is the canonical method used by git itself (add-patch.c), magit, and
lazygit. It is stable across git versions and handles all edge cases.
| Operation | Command |
|---|---|
| Stage hunk | git apply --cached <patchfile> |
| Unstage hunk | git apply --cached --reverse <patchfile> |
| Discard hunk | git apply --reverse <patchfile> |
| Validate | git apply --cached --check <patchfile> |
git apply --cached is what git add -p uses internally in C code
(add-patch.c). It constructs a patch from selected hunks and applies it.git apply --cached - with patch piped
via stdin.git apply --cached.Alternatives considered and rejected:
git update-index — blob-level, cannot target individual hunks without
reimplementing patch application.git add -p with scripted y/n — fragile across git versions, and
spawnProcess has no stdin support.Fresh's spawnProcess API (process.rs) only pipes stdout/stderr. Stdin is
not available. Therefore we use the lazygit approach:
editor.writeFile()git apply --cached <path>editor.getTempDir() for the temp directoryfunction buildHunkPatch(filePath: string, hunk: Hunk): string {
const oldCount = hunk.lines.filter(
l => l[0] === '-' || l[0] === ' '
).length;
const newCount = hunk.lines.filter(
l => l[0] === '+' || l[0] === ' '
).length;
const header = `@@ -${hunk.oldRange.start},${oldCount} `
+ `+${hunk.range.start},${newCount} @@`;
return [
`diff --git a/${filePath} b/${filePath}`,
`--- a/${filePath}`,
`+++ b/${filePath}`,
header,
...hunk.lines,
'' // trailing newline
].join('\n');
}
| Case | Handling |
|---|---|
| Untracked files | git add directly (no patch needed) |
| Binary files | Stage/unstage whole file only |
| New file (all additions) | Patch header: --- /dev/null, new file mode 100644 |
| Deleted file (all removals) | Patch header: +++ /dev/null, deleted file mode 100644 |
| CRLF | Use raw git diff output lines (already normalized by git) |
| No trailing newline | Preserve \ No newline at end of file marker |
| Partial staging | File appears in both staged and unstaged — already handled by porcelain parser |
Always dry-run before applying:
const check = await editor.spawnProcess("git", [
"apply", "--cached", "--check", patchPath
]);
if (check.exit_code !== 0) {
editor.setStatus("Patch failed: " + check.stderr.trim());
return;
}
// Safe to apply
await editor.spawnProcess("git", ["apply", "--cached", patchPath]);
When the diff panel is focused, diffSelectedLine (added in the #1503 fix)
identifies which diff line the cursor is on. Each DiffLine carries hunkId.
Look up the Hunk object from state.hunks to get the full line content
for patch construction.
When the user stages a hunk, it may cause the file to appear in both "Staged"
and "Changes" sections. After any git operation, call refreshMagitData() to
re-query git status --porcelain -z and rebuild the view.
File: plugins/audit_mode.ts
New functions:
function buildHunkPatch(filePath: string, hunk: Hunk): string
async function applyHunkPatch(patch: string, flags: string[]): Promise<boolean>
async function review_stage_hunk(): Promise<void>
async function review_unstage_hunk(): Promise<void>
async function review_discard_hunk(): Promise<void>
The stage/unstage/discard handlers check state.focusPanel:
'files' → delegate to existing file-level handlers'diff' → build patch from current hunk, apply via temp file, refreshAdd missing keybindings back to defineMode("review-mode", ...):
["x", "review_reject_hunk"],
["!", "review_needs_changes"],
["?", "review_question_hunk"],
["O", "review_set_overall_feedback"],
["q", "close"],
Make review status handlers context-aware: when diff panel is focused, apply
to the hunk at diffSelectedLine. When file panel is focused, apply to all
hunks of the selected file.
Update buildDiffLines() to append status to hunk-header lines:
if (hunk.reviewStatus !== 'pending') {
const icon = { approved: '✓', rejected: '✗',
needs_changes: '!', question: '?' }[hunk.reviewStatus];
const label = hunk.reviewStatus.toUpperCase().replace('_', ' ');
header += ` ${icon} ${label}`;
}
Add inlineOverlays to color the status portion using the existing
STYLE_APPROVED/STYLE_REJECTED/STYLE_QUESTION constants (currently
defined but unused).
Update buildDiffLines() to detect adjacent -/+ line pairs and compute
inlineOverlays using diffStrings():
// When processing hunk.lines, detect pairs:
if (line[0] === '-' && nextLine && nextLine[0] === '+') {
const parts = diffStrings(line.slice(1), nextLine.slice(1));
// Build inlineOverlays for the removed line (highlight 'removed' parts)
// Build inlineOverlays for the added line (highlight 'added' parts)
}
The buildMagitDisplayEntries() renderer already passes inlineOverlays
through to TextPropertyEntry, so no rendering changes needed.
n/p)New handlers:
function review_next_hunk() {
const diffLines = buildDiffLines(...);
for (let i = state.diffSelectedLine + 1; i < diffLines.length; i++) {
if (diffLines[i].type === 'hunk-header') {
state.diffSelectedLine = i;
scrollDiffToSelected();
updateMagitDisplay();
return;
}
}
}
Same pattern for review_prev_hunk() scanning backward.
Update buildMagitDisplayEntries() to show different toolbar text based on
state.focusPanel:
const toolbar = state.focusPanel === 'files'
? " [Tab] Switch [s] Stage [u] Unstage [d] Discard [Enter] Drill-Down [r] Refresh"
: " [Tab] Switch [s] Stage Hunk [c] Comment [a] Approve [x] Reject [n/p] Hunk Nav";
Add to audit_mode.i18n.json:
"status.hunk_staged": "Hunk staged",
"status.hunk_unstaged": "Hunk unstaged",
"status.hunk_discarded": "Hunk discarded",
"prompt.discard_hunk": "Discard this hunk in \"%{file}\"? This cannot be undone."
File: tests/e2e/plugins/audit_mode.rs
New tests:
test_review_diff_hunk_stage — stage a single hunk, verify file appears
in both staged and unstaged sectionstest_review_diff_hunk_discard — discard a hunk, verify it disappears
from difftest_review_diff_review_status_display — approve a hunk, verify status
icon appears in hunk headertest_review_diff_hunk_navigation — press n/p, verify cursor jumps
between hunk headerstest_review_diff_context_sensitive_toolbar — verify toolbar changes
when switching panels| File | Change |
|---|---|
plugins/audit_mode.ts | Hunk operations, status rendering, word-level diff, keybindings |
plugins/audit_mode.i18n.json | New status/prompt keys |
tests/e2e/plugins/audit_mode.rs | New e2e tests |
docs/internal/review-diff-feature-restoration-plan.md | This document |
git apply --cached is the standard mechanism used by git
itself, magit, and lazygit. Well-tested across git versions.--check
validation before every apply.diffStrings()
function that already works in the side-by-side drill-down view.