@fresh-editor/fresh-editor 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,26 +3,15 @@
3
3
  /// <reference path="./lib/virtual-buffer-factory.ts" />
4
4
 
5
5
  // Review Diff Plugin
6
- // Provides a unified workflow for reviewing code changes (diffs, conflicts, AI outputs).
7
- //
8
- // TODO: This plugin has incomplete/broken functionality:
9
- // - Uses editor.prompt() which doesn't exist in the API (needs event-based prompt)
10
- // - Uses VirtualBufferOptions.read_only (should be readOnly)
11
- // - References stop_review_diff which is undefined
6
+ // Magit-style split-panel UI for reviewing and staging code changes.
7
+ // Left panel: file list (staged/unstaged/untracked). Right panel: diff.
8
+ // Actions: stage/unstage/discard hunks or files, line comments, export.
12
9
  const editor = getEditor();
13
10
 
14
11
  import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
15
12
  const VirtualBufferFactory = createVirtualBufferFactory(editor);
16
13
 
17
- /**
18
- * Hunk status for staging
19
- */
20
- type HunkStatus = 'pending' | 'staged' | 'discarded';
21
14
 
22
- /**
23
- * Review status for a hunk
24
- */
25
- type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
26
15
 
27
16
  /**
28
17
  * A review comment attached to a specific line in a file
@@ -57,49 +46,101 @@ interface Hunk {
57
46
  oldRange: { start: number; end: number }; // old file line range
58
47
  type: 'add' | 'remove' | 'modify';
59
48
  lines: string[];
60
- status: HunkStatus;
61
- reviewStatus: ReviewStatus;
62
49
  contextHeader: string;
63
50
  byteOffset: number; // Position in the virtual buffer
51
+ gitStatus?: 'staged' | 'unstaged' | 'untracked';
52
+ }
53
+
54
+ /**
55
+ * A file entry from git status --porcelain
56
+ */
57
+ interface FileEntry {
58
+ path: string;
59
+ status: string; // 'M', 'A', 'D', 'R', 'C', '?'
60
+ category: 'staged' | 'unstaged' | 'untracked';
61
+ origPath?: string; // for renames/copies
64
62
  }
65
63
 
66
64
  /**
67
65
  * Review Session State
66
+ *
67
+ * Scrolling and cursor tracking inside the panel buffers is handled by the
68
+ * editor core natively — this state only mirrors what the plugin needs to
69
+ * know between events (selected file, focused panel, hunk header rows for
70
+ * `n`/`p` jumps).
68
71
  */
69
72
  interface ReviewState {
70
73
  hunks: Hunk[];
71
- hunkStatus: Record<string, HunkStatus>;
72
74
  comments: ReviewComment[];
73
- originalRequest?: string;
74
- overallFeedback?: string;
75
+ note: string;
75
76
  reviewBufferId: number | null;
77
+ // New magit-style state
78
+ files: FileEntry[];
79
+ selectedIndex: number;
80
+ viewportWidth: number;
81
+ viewportHeight: number;
82
+ focusPanel: 'files' | 'diff';
83
+ groupId: number | null;
84
+ panelBuffers: Record<string, number>;
85
+ // Caches populated each time the diff panel is rebuilt — used by `n`/`p`
86
+ // hunk navigation, to translate diff-panel row numbers into byte positions
87
+ // for `setBufferCursor`, and to draw the cursor-line highlight overlay.
88
+ // The array has length `(rowCount + 1)`: index `i` is the byte offset of
89
+ // row `i + 1`, and the final entry is the total buffer length (sentinel
90
+ // for the end of the last row).
91
+ hunkHeaderRows: number[]; // 1-indexed row numbers in the diff panel
92
+ diffLineByteOffsets: number[];
93
+ diffCursorRow: number; // 1-indexed, last known cursor row in diff panel
94
+ /** Cache of pre-built diff-panel entries keyed by `${file}\0${gitStatus}`,
95
+ * populated lazily by buildDiffPanelEntries. Cleared on refreshMagitData. */
96
+ diffCache: Record<string, CachedDiff>;
97
+ }
98
+
99
+ interface CachedDiff {
100
+ entries: TextPropertyEntry[];
101
+ hunkHeaderRows: number[];
102
+ diffLineByteOffsets: number[];
76
103
  }
77
104
 
78
105
  const state: ReviewState = {
79
106
  hunks: [],
80
- hunkStatus: {},
81
107
  comments: [],
108
+ note: '',
82
109
  reviewBufferId: null,
110
+ files: [],
111
+ selectedIndex: 0,
112
+ viewportWidth: 80,
113
+ viewportHeight: 24,
114
+ focusPanel: 'files',
115
+ groupId: null,
116
+ panelBuffers: {},
117
+ hunkHeaderRows: [],
118
+ diffLineByteOffsets: [],
119
+ diffCursorRow: 1,
120
+ diffCache: {},
83
121
  };
84
122
 
123
+ // Theme colour for the synthetic "cursor line" highlight in the panel
124
+ // buffers. Reintroduced after the per-line bg overlay was deleted from the
125
+ // builders — `applyCursorLineOverlay` writes it on every cursor_moved event.
126
+ const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
127
+ const CURSOR_LINE_NS = "review-cursor-line";
128
+
85
129
  // --- Refresh State ---
86
- let isUpdating = false;
87
130
 
88
131
  // --- Colors & Styles ---
89
- const STYLE_BORDER: [number, number, number] = [70, 70, 70];
90
- const STYLE_HEADER: [number, number, number] = [120, 120, 255];
91
- const STYLE_FILE_NAME: [number, number, number] = [220, 220, 100];
92
- const STYLE_ADD_BG: [number, number, number] = [40, 100, 40]; // Brighter Green BG
93
- const STYLE_REMOVE_BG: [number, number, number] = [100, 40, 40]; // Brighter Red BG
94
- const STYLE_ADD_TEXT: [number, number, number] = [150, 255, 150]; // Very Bright Green
95
- const STYLE_REMOVE_TEXT: [number, number, number] = [255, 150, 150]; // Very Bright Red
96
- const STYLE_STAGED: [number, number, number] = [100, 100, 100];
97
- const STYLE_DISCARDED: [number, number, number] = [120, 60, 60];
98
- const STYLE_COMMENT: [number, number, number] = [180, 180, 100]; // Yellow for comments
99
- const STYLE_COMMENT_BORDER: [number, number, number] = [100, 100, 60];
100
- const STYLE_APPROVED: [number, number, number] = [100, 200, 100]; // Green checkmark
101
- const STYLE_REJECTED: [number, number, number] = [200, 100, 100]; // Red X
102
- const STYLE_QUESTION: [number, number, number] = [200, 200, 100]; // Yellow ?
132
+ // Colors use theme keys where possible, falling back to direct values
133
+ const STYLE_BORDER: OverlayColorSpec = "ui.split_separator_fg";
134
+ const STYLE_HEADER: OverlayColorSpec = "syntax.keyword";
135
+ const STYLE_FILE_NAME: OverlayColorSpec = "syntax.string";
136
+ const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
137
+ const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
138
+ const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
139
+ const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
140
+
141
+ const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
142
+ const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
143
+
103
144
 
104
145
  /**
105
146
  * Calculate UTF-8 byte length of a string manually since TextEncoder is not available
@@ -124,53 +165,49 @@ interface DiffPart {
124
165
  type: 'added' | 'removed' | 'unchanged';
125
166
  }
126
167
 
168
+ /**
169
+ * Inline word-level diff between two changed lines.
170
+ *
171
+ * Used to highlight the *changed region* inside a -/+ pair, called once per
172
+ * adjacent pair while building a file's diff. The previous implementation
173
+ * was a full O(n*m) LCS that allocated an (n+1)*(m+1) DP table per pair —
174
+ * fast enough for short lines, but for files with hundreds of long-line
175
+ * changes (e.g. `audit_mode.ts` itself) it added hundreds of milliseconds
176
+ * to every diff rebuild and made file-list navigation visibly laggy.
177
+ *
178
+ * This O(n+m) scan finds the longest common prefix and suffix and reports
179
+ * everything in between as the changed region. It misses internal matches
180
+ * (e.g. it can't tell that "abc-xy-def" → "abc-zw-def" only changed the
181
+ * middle "xy"), but for inline highlighting that's fine — the human eye is
182
+ * already drawn to the line as a whole, the highlight just answers "where
183
+ * inside the line did the change happen?". The cost difference is dramatic:
184
+ * for two 200-char lines, ~400 char compares vs. ~40 000.
185
+ */
127
186
  function diffStrings(oldStr: string, newStr: string): DiffPart[] {
128
187
  const n = oldStr.length;
129
188
  const m = newStr.length;
130
- const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
131
-
132
- for (let i = 1; i <= n; i++) {
133
- for (let j = 1; j <= m; j++) {
134
- if (oldStr[i - 1] === newStr[j - 1]) {
135
- dp[i][j] = dp[i - 1][j - 1] + 1;
136
- } else {
137
- dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
138
- }
139
- }
189
+ let pre = 0;
190
+ const minLen = Math.min(n, m);
191
+ while (pre < minLen && oldStr.charCodeAt(pre) === newStr.charCodeAt(pre)) pre++;
192
+ let suf = 0;
193
+ while (
194
+ suf < n - pre &&
195
+ suf < m - pre &&
196
+ oldStr.charCodeAt(n - 1 - suf) === newStr.charCodeAt(m - 1 - suf)
197
+ ) {
198
+ suf++;
140
199
  }
141
200
 
142
- const result: DiffPart[] = [];
143
- let i = n, j = m;
144
- while (i > 0 || j > 0) {
145
- if (i > 0 && j > 0 && oldStr[i - 1] === newStr[j - 1]) {
146
- result.unshift({ text: oldStr[i - 1], type: 'unchanged' });
147
- i--; j--;
148
- } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
149
- result.unshift({ text: newStr[j - 1], type: 'added' });
150
- j--;
151
- } else {
152
- result.unshift({ text: oldStr[i - 1], type: 'removed' });
153
- i--;
154
- }
155
- }
156
-
157
- const coalesced: DiffPart[] = [];
158
- for (const part of result) {
159
- const last = coalesced[coalesced.length - 1];
160
- if (last && last.type === part.type) {
161
- last.text += part.text;
162
- } else {
163
- coalesced.push(part);
164
- }
165
- }
166
- return coalesced;
201
+ const parts: DiffPart[] = [];
202
+ if (pre > 0) parts.push({ text: oldStr.slice(0, pre), type: 'unchanged' });
203
+ if (pre < n - suf) parts.push({ text: oldStr.slice(pre, n - suf), type: 'removed' });
204
+ if (pre < m - suf) parts.push({ text: newStr.slice(pre, m - suf), type: 'added' });
205
+ if (suf > 0) parts.push({ text: oldStr.slice(n - suf), type: 'unchanged' });
206
+ return parts;
167
207
  }
168
208
 
169
- async function getGitDiff(): Promise<Hunk[]> {
170
- const result = await editor.spawnProcess("git", ["diff", "HEAD", "--unified=3"]);
171
- if (result.exit_code !== 0) return [];
172
-
173
- const lines = result.stdout.split('\n');
209
+ function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
210
+ const lines = stdout.split('\n');
174
211
  const hunks: Hunk[] = [];
175
212
  let currentFile = "";
176
213
  let currentHunk: Hunk | null = null;
@@ -189,16 +226,16 @@ async function getGitDiff(): Promise<Hunk[]> {
189
226
  const oldStart = parseInt(match[1]);
190
227
  const newStart = parseInt(match[2]);
191
228
  currentHunk = {
192
- id: `${currentFile}:${newStart}`,
229
+ id: `${currentFile}:${newStart}:${gitStatus}`,
193
230
  file: currentFile,
194
231
  range: { start: newStart, end: newStart },
195
232
  oldRange: { start: oldStart, end: oldStart },
196
233
  type: 'modify',
197
234
  lines: [],
198
235
  status: 'pending',
199
- reviewStatus: 'pending',
200
236
  contextHeader: match[3]?.trim() || "",
201
- byteOffset: 0
237
+ byteOffset: 0,
238
+ gitStatus
202
239
  };
203
240
  hunks.push(currentHunk);
204
241
  }
@@ -211,440 +248,1030 @@ async function getGitDiff(): Promise<Hunk[]> {
211
248
  return hunks;
212
249
  }
213
250
 
214
- interface HighlightTask {
215
- range: [number, number];
216
- fg: [number, number, number];
217
- bg?: [number, number, number];
218
- bold?: boolean;
219
- italic?: boolean;
220
- extend_to_line_end?: boolean;
251
+ // --- Git status detection ---
252
+
253
+ /**
254
+ * Parse `git status --porcelain -z` output into FileEntry[].
255
+ *
256
+ * Format: each entry is "XY path\0" where X = index status, Y = worktree status.
257
+ * Renames/copies add "origPath\0" after the entry.
258
+ * A file can appear in BOTH staged and unstaged if both X and Y are set.
259
+ */
260
+ function parseGitStatusPorcelain(raw: string): FileEntry[] {
261
+ const files: FileEntry[] = [];
262
+ if (!raw) return files;
263
+
264
+ // Split on null bytes
265
+ const parts = raw.split('\0');
266
+ let i = 0;
267
+ while (i < parts.length) {
268
+ const entry = parts[i];
269
+ if (entry.length < 3) { i++; continue; }
270
+
271
+ const x = entry[0]; // index (staged) status
272
+ const y = entry[1]; // worktree (unstaged) status
273
+ // entry[2] is a space
274
+ const path = entry.slice(3);
275
+
276
+ if (!path) { i++; continue; }
277
+
278
+ // Check for rename/copy — next part is the original path
279
+ let origPath: string | undefined;
280
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
281
+ i++;
282
+ origPath = parts[i];
283
+ }
284
+
285
+ // Untracked files: XY = '??'
286
+ if (x === '?' && y === '?') {
287
+ files.push({ path, status: '?', category: 'untracked' });
288
+ i++;
289
+ continue;
290
+ }
291
+
292
+ // Ignored files: XY = '!!' — skip
293
+ if (x === '!' && y === '!') {
294
+ i++;
295
+ continue;
296
+ }
297
+
298
+ // Staged changes: X is not ' ' and not '?'
299
+ if (x !== ' ' && x !== '?') {
300
+ files.push({ path, status: x, category: 'staged', origPath });
301
+ }
302
+
303
+ // Unstaged changes: Y is not ' ' and not '?'
304
+ if (y !== ' ' && y !== '?') {
305
+ files.push({ path, status: y, category: 'unstaged', origPath });
306
+ }
307
+
308
+ i++;
309
+ }
310
+
311
+ // Sort: staged → unstaged → untracked, then by filename
312
+ const categoryOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
313
+ files.sort((a, b) => {
314
+ const orderA = categoryOrder[a.category] ?? 2;
315
+ const orderB = categoryOrder[b.category] ?? 2;
316
+ if (orderA !== orderB) return orderA - orderB;
317
+ return a.path.localeCompare(b.path);
318
+ });
319
+
320
+ return files;
221
321
  }
222
322
 
223
323
  /**
224
- * Render the Review Stream buffer content and return highlight tasks
324
+ * Single source of truth for changed files using `git status --porcelain -z`.
225
325
  */
226
- async function renderReviewStream(): Promise<{ entries: TextPropertyEntry[], highlights: HighlightTask[] }> {
227
- const entries: TextPropertyEntry[] = [];
228
- const highlights: HighlightTask[] = [];
229
- let currentFile = "";
230
- let currentByte = 0;
231
-
232
- // Add help header with keybindings at the TOP
233
- const helpHeader = "╔" + "═".repeat(74) + "╗\n";
234
- const helpLen0 = getByteLength(helpHeader);
235
- entries.push({ text: helpHeader, properties: { type: "help" } });
236
- highlights.push({ range: [currentByte, currentByte + helpLen0], fg: STYLE_COMMENT_BORDER });
237
- currentByte += helpLen0;
238
-
239
- const helpLine1 = "║ " + editor.t("panel.help_review").padEnd(72) + " ║\n";
240
- const helpLen1 = getByteLength(helpLine1);
241
- entries.push({ text: helpLine1, properties: { type: "help" } });
242
- highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_COMMENT });
243
- currentByte += helpLen1;
244
-
245
- const helpLine2 = "║ " + editor.t("panel.help_stage").padEnd(72) + " ║\n";
246
- const helpLen2 = getByteLength(helpLine2);
247
- entries.push({ text: helpLine2, properties: { type: "help" } });
248
- highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
249
- currentByte += helpLen2;
250
-
251
- const helpLine3 = "║ " + editor.t("panel.help_export").padEnd(72) + " ║\n";
252
- const helpLen3 = getByteLength(helpLine3);
253
- entries.push({ text: helpLine3, properties: { type: "help" } });
254
- highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
255
- currentByte += helpLen3;
256
-
257
- const helpFooter = "╚" + "═".repeat(74) + "╝\n\n";
258
- const helpLen4 = getByteLength(helpFooter);
259
- entries.push({ text: helpFooter, properties: { type: "help" } });
260
- highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT_BORDER });
261
- currentByte += helpLen4;
262
-
263
- for (let hunkIndex = 0; hunkIndex < state.hunks.length; hunkIndex++) {
264
- const hunk = state.hunks[hunkIndex];
265
- if (hunk.file !== currentFile) {
266
- // Header & Border
267
- const titlePrefix = "┌─ ";
268
- const titleLine = `${titlePrefix}${hunk.file} ${"─".repeat(Math.max(0, 60 - hunk.file.length))}\n`;
269
- const titleLen = getByteLength(titleLine);
270
- entries.push({ text: titleLine, properties: { type: "banner", file: hunk.file } });
271
- highlights.push({ range: [currentByte, currentByte + titleLen], fg: STYLE_BORDER });
272
- const prefixLen = getByteLength(titlePrefix);
273
- highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + getByteLength(hunk.file)], fg: STYLE_FILE_NAME, bold: true });
274
- currentByte += titleLen;
275
- currentFile = hunk.file;
276
- }
277
-
278
- hunk.byteOffset = currentByte;
279
-
280
- // Status icons: staging (left) and review (right)
281
- const stagingIcon = hunk.status === 'staged' ? '✓' : (hunk.status === 'discarded' ? '✗' : ' ');
282
- const reviewIcon = hunk.reviewStatus === 'approved' ? '✓' :
283
- hunk.reviewStatus === 'rejected' ? '✗' :
284
- hunk.reviewStatus === 'needs_changes' ? '!' :
285
- hunk.reviewStatus === 'question' ? '?' : ' ';
286
- const reviewLabel = hunk.reviewStatus !== 'pending' ? ` ← ${hunk.reviewStatus.toUpperCase()}` : '';
287
-
288
- const headerPrefix = "│ ";
289
- const headerText = `${headerPrefix}${stagingIcon} ${reviewIcon} [ ${hunk.contextHeader} ]${reviewLabel}\n`;
290
- const headerLen = getByteLength(headerText);
291
-
292
- let hunkColor = STYLE_HEADER;
293
- if (hunk.status === 'staged') hunkColor = STYLE_STAGED;
294
- else if (hunk.status === 'discarded') hunkColor = STYLE_DISCARDED;
295
-
296
- let reviewColor = STYLE_HEADER;
297
- if (hunk.reviewStatus === 'approved') reviewColor = STYLE_APPROVED;
298
- else if (hunk.reviewStatus === 'rejected') reviewColor = STYLE_REJECTED;
299
- else if (hunk.reviewStatus === 'needs_changes') reviewColor = STYLE_QUESTION;
300
- else if (hunk.reviewStatus === 'question') reviewColor = STYLE_QUESTION;
301
-
302
- entries.push({ text: headerText, properties: { type: "header", hunkId: hunk.id, index: hunkIndex } });
303
- highlights.push({ range: [currentByte, currentByte + headerLen], fg: STYLE_BORDER });
304
- const headerPrefixLen = getByteLength(headerPrefix);
305
- // Staging icon
306
- highlights.push({ range: [currentByte + headerPrefixLen, currentByte + headerPrefixLen + getByteLength(stagingIcon)], fg: hunkColor, bold: true });
307
- // Review icon
308
- highlights.push({ range: [currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1, currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon)], fg: reviewColor, bold: true });
309
- // Context header
310
- const contextStart = currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon) + 3;
311
- highlights.push({ range: [contextStart, currentByte + headerLen - getByteLength(reviewLabel) - 2], fg: hunkColor });
312
- // Review label
313
- if (reviewLabel) {
314
- highlights.push({ range: [currentByte + headerLen - getByteLength(reviewLabel) - 1, currentByte + headerLen - 1], fg: reviewColor, bold: true });
315
- }
316
- currentByte += headerLen;
317
-
318
- // Track actual file line numbers as we iterate
319
- let oldLineNum = hunk.oldRange.start;
320
- let newLineNum = hunk.range.start;
321
-
322
- for (let i = 0; i < hunk.lines.length; i++) {
323
- const line = hunk.lines[i];
324
- const nextLine = hunk.lines[i + 1];
325
- const marker = line[0];
326
- const content = line.substring(1);
327
- const linePrefix = "│ ";
328
- const lineText = `${linePrefix}${marker} ${content}\n`;
329
- const lineLen = getByteLength(lineText);
330
- const prefixLen = getByteLength(linePrefix);
331
-
332
- // Determine line type and which line numbers apply
333
- const lineType: 'add' | 'remove' | 'context' =
334
- marker === '+' ? 'add' : marker === '-' ? 'remove' : 'context';
335
- const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
336
- const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
337
-
338
- if (line.startsWith('-') && nextLine && nextLine.startsWith('+') && hunk.status === 'pending') {
339
- const oldContent = line.substring(1);
340
- const newContent = nextLine.substring(1);
341
- const diffParts = diffStrings(oldContent, newContent);
342
-
343
- // Removed
344
- entries.push({ text: lineText, properties: {
345
- type: "content", hunkId: hunk.id, file: hunk.file,
346
- lineType: 'remove', oldLine: curOldLine, lineContent: line
347
- } });
348
- highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
349
- highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
350
-
351
- let cbOffset = currentByte + prefixLen + 2;
352
- diffParts.forEach(p => {
353
- const pLen = getByteLength(p.text);
354
- if (p.type === 'removed') {
355
- highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true });
356
- cbOffset += pLen;
357
- } else if (p.type === 'unchanged') {
358
- highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT });
359
- cbOffset += pLen;
360
- }
361
- });
362
- currentByte += lineLen;
363
-
364
- // Added (increment old line for the removed line we just processed)
365
- oldLineNum++;
366
- const nextLineText = `${linePrefix}+ ${nextLine.substring(1)}\n`;
367
- const nextLineLen = getByteLength(nextLineText);
368
- entries.push({ text: nextLineText, properties: {
369
- type: "content", hunkId: hunk.id, file: hunk.file,
370
- lineType: 'add', newLine: newLineNum, lineContent: nextLine
371
- } });
372
- newLineNum++;
373
- highlights.push({ range: [currentByte, currentByte + nextLineLen], fg: STYLE_BORDER });
374
- highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
375
-
376
- cbOffset = currentByte + prefixLen + 2;
377
- diffParts.forEach(p => {
378
- const pLen = getByteLength(p.text);
379
- if (p.type === 'added') {
380
- highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true });
381
- cbOffset += pLen;
382
- } else if (p.type === 'unchanged') {
383
- highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT });
384
- cbOffset += pLen;
385
- }
386
- });
387
- currentByte += nextLineLen;
388
-
389
- // Render comments for the removed line (curOldLine before increment)
390
- const removedLineComments = state.comments.filter(c =>
391
- c.hunk_id === hunk.id && c.line_type === 'remove' && c.old_line === curOldLine
392
- );
393
- for (const comment of removedLineComments) {
394
- const commentPrefix = `│ » [-${comment.old_line}] `;
395
- const commentLines = comment.text.split('\n');
396
- for (let ci = 0; ci < commentLines.length; ci++) {
397
- const prefix = ci === 0 ? commentPrefix : "│ ";
398
- const commentLine = `${prefix}${commentLines[ci]}\n`;
399
- const commentLineLen = getByteLength(commentLine);
400
- entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
401
- highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
402
- highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
403
- currentByte += commentLineLen;
404
- }
326
+ async function getGitStatus(): Promise<FileEntry[]> {
327
+ const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
328
+ if (result.exit_code !== 0) return [];
329
+ return parseGitStatusPorcelain(result.stdout);
330
+ }
331
+
332
+ /**
333
+ * Fetch unified diffs for the given file entries.
334
+ * Groups by category to minimize git invocations.
335
+ */
336
+ async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
337
+ const allHunks: Hunk[] = [];
338
+
339
+ const hasStaged = files.some(f => f.category === 'staged');
340
+ const hasUnstaged = files.some(f => f.category === 'unstaged');
341
+ const untrackedFiles = files.filter(f => f.category === 'untracked');
342
+
343
+ // Staged diffs
344
+ if (hasStaged) {
345
+ const result = await editor.spawnProcess("git", ["diff", "--cached", "--unified=3"]);
346
+ if (result.exit_code === 0 && result.stdout.trim()) {
347
+ allHunks.push(...parseDiffOutput(result.stdout, 'staged'));
348
+ }
349
+ }
350
+
351
+ // Unstaged diffs
352
+ if (hasUnstaged) {
353
+ const result = await editor.spawnProcess("git", ["diff", "--unified=3"]);
354
+ if (result.exit_code === 0 && result.stdout.trim()) {
355
+ allHunks.push(...parseDiffOutput(result.stdout, 'unstaged'));
356
+ }
357
+ }
358
+
359
+ // Untracked file diffs
360
+ for (const f of untrackedFiles) {
361
+ const result = await editor.spawnProcess("git", [
362
+ "diff", "--no-index", "--unified=3", "/dev/null", f.path
363
+ ]);
364
+ if (result.stdout.trim()) {
365
+ const hunks = parseDiffOutput(result.stdout, 'untracked');
366
+ for (const h of hunks) {
367
+ h.file = f.path;
368
+ h.id = `${f.path}:${h.range.start}:untracked`;
369
+ h.type = 'add';
405
370
  }
371
+ allHunks.push(...hunks);
372
+ }
373
+ }
406
374
 
407
- // Render comments for the added line (newLineNum - 1, since we already incremented)
408
- const addedLineComments = state.comments.filter(c =>
409
- c.hunk_id === hunk.id && c.line_type === 'add' && c.new_line === (newLineNum - 1)
410
- );
411
- for (const comment of addedLineComments) {
412
- const commentPrefix = `│ » [+${comment.new_line}] `;
413
- const commentLines = comment.text.split('\n');
414
- for (let ci = 0; ci < commentLines.length; ci++) {
415
- const prefix = ci === 0 ? commentPrefix : "│ ";
416
- const commentLine = `${prefix}${commentLines[ci]}\n`;
417
- const commentLineLen = getByteLength(commentLine);
418
- entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
419
- highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
420
- highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
421
- currentByte += commentLineLen;
422
- }
375
+ // Sort: staged unstaged untracked, then by filename
376
+ const statusOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
377
+ allHunks.sort((a, b) => {
378
+ const orderA = statusOrder[a.gitStatus || 'unstaged'];
379
+ const orderB = statusOrder[b.gitStatus || 'unstaged'];
380
+ if (orderA !== orderB) return orderA - orderB;
381
+ return a.file.localeCompare(b.file);
382
+ });
383
+
384
+ return allHunks;
385
+ }
386
+
387
+ // --- New magit-style rendering (Step 2 of rewrite) ---
388
+
389
+ const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
390
+ const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
391
+ const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
392
+
393
+ interface ListLine {
394
+ text: string;
395
+ type: 'section-header' | 'file';
396
+ fileIndex?: number; // index into state.files[]
397
+ style?: Partial<OverlayOptions>;
398
+ inlineOverlays?: InlineOverlay[];
399
+ }
400
+
401
+ interface DiffLine {
402
+ text: string;
403
+ type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment';
404
+ style?: Partial<OverlayOptions>;
405
+ inlineOverlays?: InlineOverlay[];
406
+ // Line metadata for comment attachment
407
+ hunkId?: string;
408
+ file?: string;
409
+ lineType?: 'add' | 'remove' | 'context';
410
+ oldLine?: number;
411
+ newLine?: number;
412
+ lineContent?: string;
413
+ commentId?: string;
414
+ }
415
+
416
+ /**
417
+ * Build the file list lines for the left panel.
418
+ * Returns section headers (not selectable) and file entries.
419
+ */
420
+ function buildFileListLines(leftWidth?: number): ListLine[] {
421
+ const lines: ListLine[] = [];
422
+ let lastCategory: string | undefined;
423
+
424
+ for (let i = 0; i < state.files.length; i++) {
425
+ const f = state.files[i];
426
+ // Section headers
427
+ if (f.category !== lastCategory) {
428
+ lastCategory = f.category;
429
+ let label = '';
430
+ if (f.category === 'staged') label = editor.t("section.staged") || "Staged";
431
+ else if (f.category === 'unstaged') label = editor.t("section.unstaged") || "Changes";
432
+ else if (f.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
433
+ lines.push({
434
+ text: `▸ ${label}`,
435
+ type: 'section-header',
436
+ style: { fg: STYLE_SECTION_HEADER, bold: true },
437
+ });
438
+ }
439
+
440
+ // Status icon + selection prefix.
441
+ const statusIcon = f.status === '?' ? 'A' : f.status;
442
+ const prefix = i === state.selectedIndex ? '>' : ' ';
443
+ const filename = f.origPath ? `${f.origPath} → ${f.path}` : f.path;
444
+ lines.push({
445
+ text: `${prefix}${statusIcon} ${filename}`,
446
+ type: 'file',
447
+ fileIndex: i,
448
+ });
449
+ }
450
+
451
+ // Show session note at the bottom of the file list, word-wrapped
452
+ if (state.note) {
453
+ lines.push({ text: '', type: 'section-header' }); // blank separator
454
+ lines.push({
455
+ text: `▸ Note`,
456
+ type: 'section-header',
457
+ style: { fg: STYLE_COMMENT, bold: true },
458
+ });
459
+ // Wrap note text to fit left panel (minus 3 for " " prefix + padding)
460
+ const wrapWidth = Math.max(20, (leftWidth || 40) - 3);
461
+ const words = state.note.split(' ');
462
+ let line = '';
463
+ for (const word of words) {
464
+ if (line && (line.length + 1 + word.length) > wrapWidth) {
465
+ lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
466
+ line = word;
467
+ } else {
468
+ line = line ? `${line} ${word}` : word;
423
469
  }
470
+ }
471
+ if (line) {
472
+ lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
473
+ }
474
+ }
424
475
 
425
- i++;
476
+ return lines;
477
+ }
478
+
479
+ /**
480
+ * Push inline comment lines for a given diff line into the lines array.
481
+ */
482
+ function pushLineComments(
483
+ lines: DiffLine[], hunk: Hunk,
484
+ lineType: 'add' | 'remove' | 'context',
485
+ oldLine: number | undefined, newLine: number | undefined
486
+ ) {
487
+ const lineComments = state.comments.filter(c =>
488
+ c.hunk_id === hunk.id && (
489
+ (c.line_type === 'add' && c.new_line === newLine) ||
490
+ (c.line_type === 'remove' && c.old_line === oldLine) ||
491
+ (c.line_type === 'context' && c.new_line === newLine)
492
+ )
493
+ );
494
+ for (const comment of lineComments) {
495
+ const lineRef = comment.line_type === 'add'
496
+ ? `+${comment.new_line}`
497
+ : comment.line_type === 'remove'
498
+ ? `-${comment.old_line}`
499
+ : `${comment.new_line}`;
500
+ lines.push({
501
+ text: ` \u00bb [${lineRef}] ${comment.text}`,
502
+ type: 'comment',
503
+ commentId: comment.id,
504
+ style: { fg: STYLE_COMMENT, italic: true },
505
+ });
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Build the diff lines for the right panel based on currently selected file.
511
+ */
512
+ function buildDiffLines(rightWidth: number): DiffLine[] {
513
+ const lines: DiffLine[] = [];
514
+ if (state.files.length === 0) return lines;
515
+
516
+ const selectedFile = state.files[state.selectedIndex];
517
+ if (!selectedFile) return lines;
518
+
519
+ // Find hunks matching the selected file and category
520
+ const fileHunks = state.hunks.filter(
521
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
522
+ );
523
+
524
+ if (fileHunks.length === 0) {
525
+ if (selectedFile.status === 'R' && selectedFile.origPath) {
526
+ lines.push({ text: `Renamed from ${selectedFile.origPath}`, type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
527
+ } else if (selectedFile.status === 'D') {
528
+ lines.push({ text: "(file deleted)", type: 'empty' });
529
+ } else if (selectedFile.status === 'T') {
530
+ lines.push({ text: "(type change: file ↔ symlink)", type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
531
+ } else if (selectedFile.status === '?' && selectedFile.path.endsWith('/')) {
532
+ lines.push({ text: "(untracked directory)", type: 'empty' });
426
533
  } else {
427
- entries.push({ text: lineText, properties: {
428
- type: "content", hunkId: hunk.id, file: hunk.file,
429
- lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line
430
- } });
431
- highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
432
- if (hunk.status === 'pending') {
433
- if (line.startsWith('+')) {
434
- highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
435
- highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_ADD_TEXT });
436
- } else if (line.startsWith('-')) {
437
- highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
438
- highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_REMOVE_TEXT });
534
+ lines.push({ text: "(no diff available)", type: 'empty' });
535
+ }
536
+ return lines;
537
+ }
538
+
539
+ for (const hunk of fileHunks) {
540
+ // Hunk header with review status indicator
541
+ const header = hunk.contextHeader
542
+ ? `@@ ${hunk.contextHeader} @@`
543
+ : `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
544
+
545
+ lines.push({
546
+ text: header,
547
+ type: 'hunk-header',
548
+ hunkId: hunk.id,
549
+ file: hunk.file,
550
+ style: { fg: STYLE_HUNK_HEADER, bold: true },
551
+ });
552
+
553
+ // Render hunk-level comments (those with no line_type) right
554
+ // after the hunk header so they are visible in the diff view.
555
+ const hunkComments = state.comments.filter(c =>
556
+ c.hunk_id === hunk.id && !c.line_type
557
+ );
558
+ for (const comment of hunkComments) {
559
+ lines.push({
560
+ text: ` \u00bb [hunk] ${comment.text}`,
561
+ type: 'comment',
562
+ commentId: comment.id,
563
+ style: { fg: STYLE_COMMENT, italic: true },
564
+ });
565
+ }
566
+
567
+ // Track actual file line numbers as we iterate
568
+ let oldLineNum = hunk.oldRange.start;
569
+ let newLineNum = hunk.range.start;
570
+
571
+ // Diff content lines with word-level highlighting for adjacent -/+ pairs
572
+ for (let li = 0; li < hunk.lines.length; li++) {
573
+ const line = hunk.lines[li];
574
+ const nextLine = hunk.lines[li + 1];
575
+ const prefix = line[0];
576
+ const lineType: 'add' | 'remove' | 'context' =
577
+ prefix === '+' ? 'add' : prefix === '-' ? 'remove' : 'context';
578
+ const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
579
+ const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
580
+
581
+ // Detect adjacent -/+ pair for word-level diff
582
+ if (prefix === '-' && nextLine && nextLine[0] === '+') {
583
+ const oldContent = line.substring(1);
584
+ const newContent = nextLine.substring(1);
585
+ const parts = diffStrings(oldContent, newContent);
586
+
587
+ // Build inline overlays for removed line
588
+ const removeOverlays: InlineOverlay[] = [];
589
+ let rOffset = getByteLength(line[0]); // skip prefix
590
+ for (const part of parts) {
591
+ const pLen = getByteLength(part.text);
592
+ if (part.type === 'removed') {
593
+ removeOverlays.push({ start: rOffset, end: rOffset + pLen, style: { fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true } });
594
+ }
595
+ if (part.type !== 'added') rOffset += pLen;
439
596
  }
440
- } else {
441
- highlights.push({ range: [currentByte + prefixLen, currentByte + lineLen], fg: hunkColor });
442
- }
443
- currentByte += lineLen;
444
-
445
- // Increment line counters based on line type
446
- if (lineType === 'remove') oldLineNum++;
447
- else if (lineType === 'add') newLineNum++;
448
- else { oldLineNum++; newLineNum++; } // context
449
-
450
- // Render any comments attached to this specific line
451
- const lineComments = state.comments.filter(c =>
452
- c.hunk_id === hunk.id && (
453
- (lineType === 'remove' && c.old_line === curOldLine) ||
454
- (lineType === 'add' && c.new_line === curNewLine) ||
455
- (lineType === 'context' && (c.old_line === curOldLine || c.new_line === curNewLine))
456
- )
457
- );
458
- for (const comment of lineComments) {
459
- const lineRef = comment.line_type === 'add'
460
- ? `+${comment.new_line}`
461
- : comment.line_type === 'remove'
462
- ? `-${comment.old_line}`
463
- : `${comment.new_line}`;
464
- const commentPrefix = `│ » [${lineRef}] `;
465
- const commentLines = comment.text.split('\n');
466
- for (let ci = 0; ci < commentLines.length; ci++) {
467
- const prefix = ci === 0 ? commentPrefix : "│ ";
468
- const commentLine = `${prefix}${commentLines[ci]}\n`;
469
- const commentLineLen = getByteLength(commentLine);
470
- entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
471
- highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
472
- highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
473
- currentByte += commentLineLen;
597
+ lines.push({
598
+ text: line, type: 'remove',
599
+ style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
600
+ hunkId: hunk.id, file: hunk.file,
601
+ lineType: 'remove', oldLine: curOldLine, newLine: undefined, lineContent: line,
602
+ inlineOverlays: removeOverlays.length > 0 ? removeOverlays : undefined,
603
+ });
604
+ // Inline comments for the removed line
605
+ pushLineComments(lines, hunk, 'remove', curOldLine, undefined);
606
+ oldLineNum++;
607
+
608
+ // Build inline overlays for added line
609
+ const addOverlays: InlineOverlay[] = [];
610
+ let aOffset = getByteLength(nextLine[0]);
611
+ for (const part of parts) {
612
+ const pLen = getByteLength(part.text);
613
+ if (part.type === 'added') {
614
+ addOverlays.push({ start: aOffset, end: aOffset + pLen, style: { fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true } });
615
+ }
616
+ if (part.type !== 'removed') aOffset += pLen;
474
617
  }
618
+ lines.push({
619
+ text: nextLine, type: 'add',
620
+ style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
621
+ hunkId: hunk.id, file: hunk.file,
622
+ lineType: 'add', oldLine: undefined, newLine: newLineNum, lineContent: nextLine,
623
+ inlineOverlays: addOverlays.length > 0 ? addOverlays : undefined,
624
+ });
625
+ pushLineComments(lines, hunk, 'add', undefined, newLineNum);
626
+ newLineNum++;
627
+ li++; // skip the + line we already processed
628
+ continue;
475
629
  }
630
+
631
+ if (prefix === '+') {
632
+ lines.push({
633
+ text: line, type: 'add',
634
+ style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
635
+ hunkId: hunk.id, file: hunk.file,
636
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
637
+ });
638
+ newLineNum++;
639
+ } else if (prefix === '-') {
640
+ lines.push({
641
+ text: line, type: 'remove',
642
+ style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
643
+ hunkId: hunk.id, file: hunk.file,
644
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
645
+ });
646
+ oldLineNum++;
647
+ } else {
648
+ lines.push({
649
+ text: line, type: 'context',
650
+ hunkId: hunk.id, file: hunk.file,
651
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
652
+ });
653
+ oldLineNum++;
654
+ newLineNum++;
655
+ }
656
+
657
+ // Render inline comments attached to this line
658
+ pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
476
659
  }
477
660
  }
478
661
 
479
- // Render any comments without specific line info at the end of hunk
480
- const orphanComments = state.comments.filter(c =>
481
- c.hunk_id === hunk.id && !c.old_line && !c.new_line
482
- );
483
- if (orphanComments.length > 0) {
484
- const commentBorder = "│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄\n";
485
- const borderLen = getByteLength(commentBorder);
486
- entries.push({ text: commentBorder, properties: { type: "comment-border" } });
487
- highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
488
- currentByte += borderLen;
489
-
490
- for (const comment of orphanComments) {
491
- const commentPrefix = "│ » ";
492
- const commentLines = comment.text.split('\n');
493
- for (let ci = 0; ci < commentLines.length; ci++) {
494
- const prefix = ci === 0 ? commentPrefix : "";
495
- const commentLine = `${prefix}${commentLines[ci]}\n`;
496
- const commentLineLen = getByteLength(commentLine);
497
- entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
498
- highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
499
- highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
500
- currentByte += commentLineLen;
662
+ return lines;
663
+ }
664
+
665
+ /**
666
+ * Build the full display as exactly viewportHeight lines.
667
+ * Layout:
668
+ * Row 0: Toolbar (shortcuts)
669
+ * Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
670
+ * Rows 2..H-1: Main content (left file list, divider, right diff)
671
+ */
672
+
673
+ // Theme colors for toolbar key hints
674
+ const STYLE_KEY_FG: OverlayColorSpec = "syntax.keyword";
675
+ const STYLE_KEY_BG: OverlayColorSpec = "editor.selection_bg";
676
+ const STYLE_HINT_FG: OverlayColorSpec = "editor.line_number_fg";
677
+ const STYLE_TOOLBAR_BG: OverlayColorSpec = "ui.status_bar_bg";
678
+ const STYLE_TOOLBAR_SEP: OverlayColorSpec = "ui.split_separator_fg";
679
+
680
+ interface HintItem {
681
+ key: string;
682
+ label: string;
683
+ }
684
+
685
+ /**
686
+ * Build a styled toolbar entry with highlighted key hints.
687
+ * Keys get bold + keyword color; labels get dim text; groups separated by │.
688
+ */
689
+ function buildToolbar(W: number): TextPropertyEntry {
690
+ // Items within each group are ordered by importance so that when the
691
+ // viewport is narrow, the most useful hints get full labels while
692
+ // less discoverable ones are truncated to key-only or dropped.
693
+ const groups: HintItem[][] = state.focusPanel === 'files'
694
+ ? [
695
+ [{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
696
+ [{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
697
+ [{ key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "↵", label: "Open" }, { key: "Tab", label: "Switch" }, { key: "r", label: "Refresh" }],
698
+ ]
699
+ : [
700
+ [{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
701
+ [{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
702
+ [{ key: "n", label: "Next" }, { key: "p", label: "Prev" }, { key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "Tab", label: "Switch" }],
703
+ ];
704
+
705
+ // Build text and collect overlay ranges, gracefully dropping labels
706
+ // when the viewport is too narrow to fit everything.
707
+ const overlays: InlineOverlay[] = [];
708
+ let text = " ";
709
+ let bytePos = getByteLength(" ");
710
+ let done = false;
711
+
712
+ for (let g = 0; g < groups.length && !done; g++) {
713
+ if (g > 0) {
714
+ const sep = " │ ";
715
+ if (text.length + sep.length > W) { done = true; break; }
716
+ overlays.push({ start: bytePos, end: bytePos + getByteLength(sep), style: { fg: STYLE_TOOLBAR_SEP } });
717
+ text += sep;
718
+ bytePos += getByteLength(sep);
501
719
  }
502
- }
503
-
504
- entries.push({ text: commentBorder, properties: { type: "comment-border" } });
505
- highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
506
- currentByte += borderLen;
507
- }
508
-
509
- const isLastOfFile = hunkIndex === state.hunks.length - 1 || state.hunks[hunkIndex + 1].file !== hunk.file;
510
- if (isLastOfFile) {
511
- const bottomLine = `└${"─".repeat(64)}\n`;
512
- const bottomLen = getByteLength(bottomLine);
513
- entries.push({ text: bottomLine, properties: { type: "border" } });
514
- highlights.push({ range: [currentByte, currentByte + bottomLen], fg: STYLE_BORDER });
515
- currentByte += bottomLen;
516
- }
517
- }
518
-
519
- if (entries.length === 0) {
520
- entries.push({ text: editor.t("panel.no_changes") + "\n", properties: {} });
521
- } else {
522
- // Add help footer with keybindings
523
- const helpSeparator = "\n" + "─".repeat(70) + "\n";
524
- const helpLen1 = getByteLength(helpSeparator);
525
- entries.push({ text: helpSeparator, properties: { type: "help" } });
526
- highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_BORDER });
527
- currentByte += helpLen1;
528
-
529
- const helpLine1 = editor.t("panel.help_review_footer") + "\n";
530
- const helpLen2 = getByteLength(helpLine1);
531
- entries.push({ text: helpLine1, properties: { type: "help" } });
532
- highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
533
- currentByte += helpLen2;
534
-
535
- const helpLine2 = editor.t("panel.help_stage_footer") + "\n";
536
- const helpLen3 = getByteLength(helpLine2);
537
- entries.push({ text: helpLine2, properties: { type: "help" } });
538
- highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
539
- currentByte += helpLen3;
540
-
541
- const helpLine3 = editor.t("panel.help_export_footer") + "\n";
542
- const helpLen4 = getByteLength(helpLine3);
543
- entries.push({ text: helpLine3, properties: { type: "help" } });
544
- highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT });
545
- currentByte += helpLen4;
546
- }
547
- return { entries, highlights };
720
+ for (let h = 0; h < groups[g].length && !done; h++) {
721
+ const item = groups[g][h];
722
+ const gap = h > 0 ? " " : "";
723
+ const fullLen = gap.length + item.key.length + 1 + item.label.length;
724
+ const keyOnlyLen = gap.length + item.key.length;
725
+
726
+ if (text.length + fullLen <= W) {
727
+ // Full item: gap + key + " " + label
728
+ if (gap) { text += gap; bytePos += getByteLength(gap); }
729
+ const keyLen = getByteLength(item.key);
730
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
731
+ text += item.key;
732
+ bytePos += keyLen;
733
+ const labelText = " " + item.label;
734
+ const labelLen = getByteLength(labelText);
735
+ overlays.push({ start: bytePos, end: bytePos + labelLen, style: { fg: STYLE_HINT_FG } });
736
+ text += labelText;
737
+ bytePos += labelLen;
738
+ } else if (text.length + keyOnlyLen <= W) {
739
+ // Key only (no label) when space is tight
740
+ if (gap) { text += gap; bytePos += getByteLength(gap); }
741
+ const keyLen = getByteLength(item.key);
742
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
743
+ text += item.key;
744
+ bytePos += keyLen;
745
+ } else {
746
+ done = true;
747
+ }
748
+ }
749
+ }
750
+
751
+ const padded = text.padEnd(W) + "\n";
752
+ return {
753
+ text: padded,
754
+ properties: { type: "toolbar" },
755
+ style: { bg: STYLE_TOOLBAR_BG, extendToLineEnd: true },
756
+ inlineOverlays: overlays,
757
+ };
758
+ }
759
+
760
+ // --- Buffer Group panel content builders ---
761
+
762
+ function buildToolbarPanelEntries(): TextPropertyEntry[] {
763
+ // Reuse buildToolbar — returns one entry with the full toolbar line
764
+ return [buildToolbar(state.viewportWidth)];
765
+ }
766
+
767
+ function buildFilesPanelEntries(): TextPropertyEntry[] {
768
+ const entries: TextPropertyEntry[] = [];
769
+ const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
770
+
771
+ // Header row: "GIT STATUS" — emphasized when the files panel has focus.
772
+ const focusLeft = state.focusPanel === 'files';
773
+ const headerStyle: Partial<OverlayOptions> = focusLeft
774
+ ? { fg: STYLE_HEADER, bold: true, underline: true }
775
+ : { fg: STYLE_DIVIDER };
776
+ entries.push({
777
+ text: " GIT STATUS\n",
778
+ style: headerStyle,
779
+ properties: { type: "header" },
780
+ });
781
+
782
+ const lines = buildFileListLines(leftWidth);
783
+ for (const line of lines) {
784
+ // Selection is plugin-managed: draw a bg highlight on the row whose
785
+ // fileIndex matches state.selectedIndex. The native cursor is hidden
786
+ // for the files panel (show_cursors stays false).
787
+ const isSelected = line.type === 'file' && line.fileIndex === state.selectedIndex;
788
+ const baseStyle = line.style;
789
+ const style: Partial<OverlayOptions> | undefined = isSelected
790
+ ? { ...(baseStyle || {}), bg: STYLE_SELECTED_BG, bold: true, extendToLineEnd: true }
791
+ : baseStyle;
792
+ entries.push({
793
+ text: (line.text || "") + "\n",
794
+ style,
795
+ inlineOverlays: line.inlineOverlays,
796
+ properties: { type: line.type, fileIndex: line.fileIndex },
797
+ });
798
+ }
799
+ return entries;
548
800
  }
549
801
 
550
802
  /**
551
- * Updates the buffer UI (text and highlights) based on current state.hunks
803
+ * Build (or fetch from cache) the diff-panel entries for the currently
804
+ * selected file. The cache is keyed by `${file}\0${gitStatus}` and is cleared
805
+ * in `refreshMagitData`. As a side effect, populates `state.hunkHeaderRows`
806
+ * and `state.diffLineByteOffsets` for the cached entry — these back `n`/`p`
807
+ * hunk navigation and the cursor-line overlay.
552
808
  */
553
- async function updateReviewUI() {
554
- if (state.reviewBufferId != null) {
555
- const { entries, highlights } = await renderReviewStream();
556
- editor.setVirtualBufferContent(state.reviewBufferId, entries);
557
-
558
- editor.clearNamespace(state.reviewBufferId, "review-diff");
559
- highlights.forEach((h) => {
560
- editor.addOverlay(state.reviewBufferId!, "review-diff", h.range[0], h.range[1], {
561
- fg: h.fg,
562
- bg: h.bg,
563
- bold: h.bold || false,
564
- italic: h.italic || false,
809
+ function buildDiffPanelEntries(): TextPropertyEntry[] {
810
+ const selectedFile = state.files[state.selectedIndex];
811
+ const cacheKey = selectedFile
812
+ ? `${selectedFile.path}\0${selectedFile.category}`
813
+ : "\0";
814
+ const cached = state.diffCache[cacheKey];
815
+ if (cached) {
816
+ state.hunkHeaderRows = cached.hunkHeaderRows;
817
+ state.diffLineByteOffsets = cached.diffLineByteOffsets;
818
+ return cached.entries;
819
+ }
820
+
821
+ const entries: TextPropertyEntry[] = [];
822
+ const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
823
+ const rightWidth = state.viewportWidth - leftWidth - 1;
824
+
825
+ const hunkHeaderRows: number[] = [];
826
+ const diffLineByteOffsets: number[] = [];
827
+ let runningByte = 0;
828
+ let row = 0; // 0-indexed counter; row + 1 is the 1-indexed line number
829
+
830
+ const pushEntry = (entry: TextPropertyEntry) => {
831
+ diffLineByteOffsets.push(runningByte);
832
+ runningByte += getByteLength(entry.text);
833
+ entries.push(entry);
834
+ row++;
835
+ };
836
+
837
+ // Header row: "DIFF FOR <file>". Always rendered as focused (the panel
838
+ // is the only place this header appears) so the cached entries can be
839
+ // reused regardless of which panel currently has focus.
840
+ const rightHeader = selectedFile
841
+ ? ` DIFF FOR ${selectedFile.path}`
842
+ : " DIFF";
843
+ pushEntry({
844
+ text: rightHeader + "\n",
845
+ style: { fg: STYLE_HEADER, bold: true, underline: true },
846
+ properties: { type: "header" },
847
+ });
848
+
849
+ const lines = buildDiffLines(rightWidth);
850
+ for (const line of lines) {
851
+ // Embed the full DiffLine metadata as text properties so action
852
+ // handlers (`s`, `u`, `d`, `c`, `x`) can read it back via
853
+ // getTextPropertiesAtCursor without re-walking state.hunks.
854
+ const props: Record<string, unknown> = { type: line.type };
855
+ if (line.hunkId !== undefined) props.hunkId = line.hunkId;
856
+ if (line.file !== undefined) props.file = line.file;
857
+ if (line.lineType !== undefined) props.lineType = line.lineType;
858
+ if (line.oldLine !== undefined) props.oldLine = line.oldLine;
859
+ if (line.newLine !== undefined) props.newLine = line.newLine;
860
+ if (line.lineContent !== undefined) props.lineContent = line.lineContent;
861
+ if (line.commentId !== undefined) props.commentId = line.commentId;
862
+
863
+ if (line.type === 'hunk-header') {
864
+ // 1-indexed row of this hunk header in the diff buffer.
865
+ hunkHeaderRows.push(row + 1);
866
+ }
867
+
868
+ pushEntry({
869
+ text: (line.text || "") + "\n",
870
+ style: line.style,
871
+ inlineOverlays: line.inlineOverlays,
872
+ properties: props,
565
873
  });
874
+ }
875
+
876
+ // Sentinel: total buffer length, used as the end of the last row.
877
+ diffLineByteOffsets.push(runningByte);
878
+
879
+ state.diffCache[cacheKey] = { entries, hunkHeaderRows, diffLineByteOffsets };
880
+ state.hunkHeaderRows = hunkHeaderRows;
881
+ state.diffLineByteOffsets = diffLineByteOffsets;
882
+ return entries;
883
+ }
884
+
885
+ /**
886
+ * Full refresh — rebuild all three panels. Called on data changes
887
+ * (refreshMagitData, comment add/edit, note edit, resize). NOT called on
888
+ * scroll: scrolling is handled natively by the editor in the panel buffers.
889
+ */
890
+ function updateMagitDisplay(): void {
891
+ refreshViewportDimensions();
892
+ if (state.groupId === null) return;
893
+ editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
894
+ editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
895
+ editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
896
+ // setPanelContent wipes the buffer's overlays — re-paint the diff
897
+ // cursor-line highlight (the files panel doesn't have one; selection
898
+ // there is rendered as part of the entry style).
899
+ applyCursorLineOverlay('diff');
900
+ }
901
+
902
+ /**
903
+ * Rebuild only the diff panel. Called when the selected file changes.
904
+ */
905
+ function refreshDiffPanelOnly(): void {
906
+ if (state.groupId === null) return;
907
+ editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
908
+ applyCursorLineOverlay('diff');
909
+ }
910
+
911
+ /**
912
+ * Repaint the synthetic "cursor line" highlight in the diff panel.
913
+ *
914
+ * The diff panel buffer is created with show_cursors=true so the editor
915
+ * moves the cursor natively, but a single-line bg overlay on the cursor row
916
+ * gives a much more visible "you are here" indicator than the bare caret —
917
+ * which matches the magit-style aesthetic and is what the user expects.
918
+ */
919
+ function applyCursorLineOverlay(panel: 'diff'): void {
920
+ const bufId = state.panelBuffers[panel];
921
+ if (bufId === undefined) return;
922
+ editor.clearNamespace(bufId, CURSOR_LINE_NS);
923
+ const offsets = state.diffLineByteOffsets;
924
+ if (offsets.length < 2) return;
925
+ const idx = Math.max(0, Math.min(state.diffCursorRow - 1, offsets.length - 2));
926
+ const start = offsets[idx];
927
+ const end = offsets[idx + 1];
928
+ if (end <= start) return;
929
+ editor.addOverlay(bufId, CURSOR_LINE_NS, start, end, {
930
+ bg: STYLE_SELECTED_BG,
931
+ extendToLineEnd: true,
566
932
  });
567
- }
933
+ }
934
+
935
+ function review_refresh() { refreshMagitData(); }
936
+ registerHandler("review_refresh", review_refresh);
937
+
938
+ // --- Focus and cursor-driven navigation ---
939
+ //
940
+ // Cursor keys (j/k/Up/Down/PageUp/PageDown/Home/End) are bound to plugin
941
+ // handlers that branch on which panel is focused:
942
+ //
943
+ // * Files panel: selection is plugin-managed (`state.selectedIndex` with
944
+ // a `>` prefix + bg highlight). The handler updates the index, repaints
945
+ // the files panel, and swaps the diff panel content from cache. The
946
+ // native cursor stays hidden in the files panel.
947
+ //
948
+ // * Diff panel: motion is delegated to the editor's built-in actions
949
+ // (`move_up`, `move_down`, etc.) via `executeAction`. The cursor moves
950
+ // natively, the editor handles viewport scrolling, and `cursor_moved`
951
+ // fires so the cursor-line overlay follows along.
952
+
953
+ function isFilesFocused(): boolean {
954
+ return state.focusPanel === 'files';
955
+ }
956
+
957
+ function refreshFilesPanelOnly(): void {
958
+ if (state.groupId === null) return;
959
+ editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
960
+ }
961
+
962
+ function selectFile(newIndex: number) {
963
+ if (newIndex < 0 || newIndex >= state.files.length) return;
964
+ if (newIndex === state.selectedIndex) return;
965
+ state.selectedIndex = newIndex;
966
+ state.diffCursorRow = 1; // diff panel cursor returns to the top of the new file
967
+ refreshFilesPanelOnly();
968
+ refreshDiffPanelOnly();
969
+ }
970
+
971
+ function review_nav_up() {
972
+ if (isFilesFocused()) {
973
+ selectFile(state.selectedIndex - 1);
974
+ } else {
975
+ editor.executeAction("move_up");
976
+ }
977
+ }
978
+ registerHandler("review_nav_up", review_nav_up);
979
+
980
+ function review_nav_down() {
981
+ if (isFilesFocused()) {
982
+ selectFile(state.selectedIndex + 1);
983
+ } else {
984
+ editor.executeAction("move_down");
985
+ }
986
+ }
987
+ registerHandler("review_nav_down", review_nav_down);
988
+
989
+ function review_page_up() {
990
+ if (isFilesFocused()) {
991
+ const step = Math.max(1, state.viewportHeight - 2);
992
+ selectFile(Math.max(0, state.selectedIndex - step));
993
+ } else {
994
+ editor.executeAction("move_page_up");
995
+ }
996
+ }
997
+ registerHandler("review_page_up", review_page_up);
998
+
999
+ function review_page_down() {
1000
+ if (isFilesFocused()) {
1001
+ const step = Math.max(1, state.viewportHeight - 2);
1002
+ selectFile(Math.min(state.files.length - 1, state.selectedIndex + step));
1003
+ } else {
1004
+ editor.executeAction("move_page_down");
1005
+ }
1006
+ }
1007
+ registerHandler("review_page_down", review_page_down);
1008
+
1009
+ function review_nav_home() {
1010
+ if (isFilesFocused()) {
1011
+ selectFile(0);
1012
+ } else {
1013
+ editor.executeAction("move_document_start");
1014
+ }
1015
+ }
1016
+ registerHandler("review_nav_home", review_nav_home);
1017
+
1018
+ function review_nav_end() {
1019
+ if (isFilesFocused()) {
1020
+ selectFile(state.files.length - 1);
1021
+ } else {
1022
+ editor.executeAction("move_document_end");
1023
+ }
1024
+ }
1025
+ registerHandler("review_nav_end", review_nav_end);
1026
+
1027
+ function review_toggle_focus() {
1028
+ if (state.groupId === null) return;
1029
+ const newPanel: 'files' | 'diff' = state.focusPanel === 'files' ? 'diff' : 'files';
1030
+ state.focusPanel = newPanel;
1031
+ editor.focusBufferGroupPanel(state.groupId, newPanel);
1032
+ // Refresh the toolbar so its hint set matches the new focus.
1033
+ editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
1034
+ }
1035
+ registerHandler("review_toggle_focus", review_toggle_focus);
1036
+
1037
+ // --- Real git stage/unstage/discard actions (Step 4) ---
1038
+
1039
+ /**
1040
+ * Build a minimal unified diff patch for a single hunk.
1041
+ */
1042
+ function buildHunkPatch(filePath: string, hunk: Hunk): string {
1043
+ const oldCount = hunk.lines.filter(l => l[0] === '-' || l[0] === ' ').length;
1044
+ const newCount = hunk.lines.filter(l => l[0] === '+' || l[0] === ' ').length;
1045
+ const header = `@@ -${hunk.oldRange.start},${oldCount} +${hunk.range.start},${newCount} @@`;
1046
+ return [
1047
+ `diff --git a/${filePath} b/${filePath}`,
1048
+ `--- a/${filePath}`,
1049
+ `+++ b/${filePath}`,
1050
+ header,
1051
+ ...hunk.lines,
1052
+ ''
1053
+ ].join('\n');
568
1054
  }
569
1055
 
570
1056
  /**
571
- * Fetches latest diff data and refreshes the UI
1057
+ * Write a patch to a temp file and apply it with the given flags.
1058
+ * Returns true on success.
572
1059
  */
573
- async function refreshReviewData() {
574
- if (isUpdating) return;
575
- isUpdating = true;
576
- editor.setStatus(editor.t("status.refreshing"));
577
- try {
578
- const newHunks = await getGitDiff();
579
- newHunks.forEach(h => h.status = state.hunkStatus[h.id] || 'pending');
580
- state.hunks = newHunks;
581
- await updateReviewUI();
582
- editor.setStatus(editor.t("status.updated", { count: String(state.hunks.length) }));
583
- } catch (e) {
584
- editor.debug(`ReviewDiff Error: ${e}`);
585
- } finally {
586
- isUpdating = false;
1060
+ async function applyHunkPatch(patch: string, flags: string[]): Promise<boolean> {
1061
+ const tmpDir = editor.getTempDir();
1062
+ const patchPath = editor.pathJoin(tmpDir, `fresh-review-${Date.now()}.patch`);
1063
+ editor.writeFile(patchPath, patch);
1064
+ // Validate first
1065
+ const check = await editor.spawnProcess("git", ["apply", "--check", ...flags, patchPath]);
1066
+ if (check.exit_code !== 0) {
1067
+ editor.setStatus("Patch failed: " + (check.stderr || "").trim());
1068
+ return false;
587
1069
  }
1070
+ const result = await editor.spawnProcess("git", ["apply", ...flags, patchPath]);
1071
+ return result.exit_code === 0;
588
1072
  }
589
1073
 
590
- // --- Actions ---
1074
+ /**
1075
+ * Merge all text-property records at the cursor of the given panel buffer
1076
+ * into a single object. There's typically only one record covering each
1077
+ * cursor position; merging keeps callers simple.
1078
+ */
1079
+ function readPropsAtCursor(panel: 'files' | 'diff'): Record<string, unknown> | null {
1080
+ const bufId = state.panelBuffers[panel];
1081
+ if (bufId === undefined) return null;
1082
+ const records = editor.getTextPropertiesAtCursor(bufId);
1083
+ if (!records || records.length === 0) return null;
1084
+ const merged: Record<string, unknown> = {};
1085
+ for (const r of records) Object.assign(merged, r);
1086
+ return merged;
1087
+ }
591
1088
 
592
- async function review_stage_hunk() {
593
- const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
594
- if (props.length > 0 && props[0].hunkId) {
595
- const id = props[0].hunkId as string;
596
- state.hunkStatus[id] = 'staged';
597
- const h = state.hunks.find(x => x.id === id);
598
- if (h) h.status = 'staged';
599
- await updateReviewUI();
1089
+ /**
1090
+ * Get the hunk under the cursor in the diff panel, or null.
1091
+ *
1092
+ * Reads the `hunkId` text property embedded by `buildDiffPanelEntries`. Falls
1093
+ * back to the first hunk of the selected file when the cursor is somewhere
1094
+ * without a hunkId (e.g. the panel header) so commands like `s` still do
1095
+ * something useful.
1096
+ */
1097
+ function getHunkAtDiffCursor(): Hunk | null {
1098
+ const props = readPropsAtCursor('diff');
1099
+ const hunkId = props ? props["hunkId"] : undefined;
1100
+ if (typeof hunkId === 'string') {
1101
+ const found = state.hunks.find(h => h.id === hunkId);
1102
+ if (found) return found;
600
1103
  }
1104
+ // Fallback: first hunk for the currently-selected file.
1105
+ const selectedFile = state.files[state.selectedIndex];
1106
+ if (!selectedFile) return null;
1107
+ return state.hunks.find(
1108
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1109
+ ) || null;
601
1110
  }
602
- registerHandler("review_stage_hunk", review_stage_hunk);
603
1111
 
604
- async function review_discard_hunk() {
605
- const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
606
- if (props.length > 0 && props[0].hunkId) {
607
- const id = props[0].hunkId as string;
608
- state.hunkStatus[id] = 'discarded';
609
- const h = state.hunks.find(x => x.id === id);
610
- if (h) h.status = 'discarded';
611
- await updateReviewUI();
1112
+ async function review_stage_file() {
1113
+ if (state.files.length === 0) return;
1114
+ if (state.focusPanel === 'diff') {
1115
+ // Hunk-level staging
1116
+ const hunk = getHunkAtDiffCursor();
1117
+ if (!hunk || !hunk.file) return;
1118
+ if (hunk.gitStatus === 'untracked') {
1119
+ await editor.spawnProcess("git", ["add", "--", hunk.file]);
1120
+ } else {
1121
+ const patch = buildHunkPatch(hunk.file, hunk);
1122
+ const ok = await applyHunkPatch(patch, ["--cached"]);
1123
+ if (!ok) return;
1124
+ }
1125
+ editor.setStatus(editor.t("status.hunk_staged") || "Hunk staged");
1126
+ await refreshMagitData();
1127
+ return;
1128
+ }
1129
+ const f = state.files[state.selectedIndex];
1130
+ if (!f) return;
1131
+ await editor.spawnProcess("git", ["add", "--", f.path]);
1132
+ await refreshMagitData();
1133
+ }
1134
+ registerHandler("review_stage_file", review_stage_file);
1135
+
1136
+ async function review_unstage_file() {
1137
+ if (state.files.length === 0) return;
1138
+ if (state.focusPanel === 'diff') {
1139
+ // Hunk-level unstaging
1140
+ const hunk = getHunkAtDiffCursor();
1141
+ if (!hunk || !hunk.file || hunk.gitStatus !== 'staged') {
1142
+ editor.setStatus("Can only unstage staged hunks");
1143
+ return;
1144
+ }
1145
+ const patch = buildHunkPatch(hunk.file, hunk);
1146
+ const ok = await applyHunkPatch(patch, ["--cached", "--reverse"]);
1147
+ if (!ok) return;
1148
+ editor.setStatus(editor.t("status.hunk_unstaged") || "Hunk unstaged");
1149
+ await refreshMagitData();
1150
+ return;
1151
+ }
1152
+ const f = state.files[state.selectedIndex];
1153
+ if (!f) return;
1154
+ await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
1155
+ await refreshMagitData();
1156
+ }
1157
+ registerHandler("review_unstage_file", review_unstage_file);
1158
+
1159
+ function review_discard_file() {
1160
+ if (state.files.length === 0) return;
1161
+ if (state.focusPanel === 'diff') {
1162
+ // Hunk-level discard — show confirmation
1163
+ const hunk = getHunkAtDiffCursor();
1164
+ if (!hunk || !hunk.file) return;
1165
+ editor.startPrompt(
1166
+ editor.t("prompt.discard_hunk", { file: hunk.file }) ||
1167
+ `Discard this hunk in "${hunk.file}"? This cannot be undone.`,
1168
+ "review-discard-hunk-confirm"
1169
+ );
1170
+ const suggestions: PromptSuggestion[] = [
1171
+ { text: "Discard hunk", description: "Permanently lose this change", value: "discard" },
1172
+ { text: "Cancel", description: "Keep the hunk as-is", value: "cancel" },
1173
+ ];
1174
+ editor.setPromptSuggestions(suggestions);
1175
+ return;
612
1176
  }
1177
+ const f = state.files[state.selectedIndex];
1178
+ if (!f) return;
1179
+
1180
+ // Show confirmation prompt — discard is destructive and irreversible
1181
+ const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
1182
+ editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
1183
+ const suggestions: PromptSuggestion[] = [
1184
+ { text: `${action} file`, description: "Permanently lose changes", value: "discard" },
1185
+ { text: "Cancel", description: "Keep the file as-is", value: "cancel" },
1186
+ ];
1187
+ editor.setPromptSuggestions(suggestions);
613
1188
  }
614
- registerHandler("review_discard_hunk", review_discard_hunk);
1189
+ registerHandler("review_discard_file", review_discard_file);
1190
+
1191
+ async function on_review_discard_hunk_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
1192
+ if (args.prompt_type !== "review-discard-hunk-confirm") return true;
1193
+ const response = args.input.trim().toLowerCase();
1194
+ if (response === "discard" || args.selected_index === 0) {
1195
+ const hunk = getHunkAtDiffCursor();
1196
+ if (hunk && hunk.file) {
1197
+ const patch = buildHunkPatch(hunk.file, hunk);
1198
+ const ok = await applyHunkPatch(patch, ["--reverse"]);
1199
+ if (ok) {
1200
+ editor.setStatus(editor.t("status.hunk_discarded") || "Hunk discarded");
1201
+ await refreshMagitData();
1202
+ }
1203
+ }
1204
+ } else {
1205
+ editor.setStatus("Discard cancelled");
1206
+ }
1207
+ return false;
1208
+ }
1209
+ registerHandler("on_review_discard_hunk_confirm", on_review_discard_hunk_confirm);
1210
+
1211
+ async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
1212
+ if (args.prompt_type !== "review-discard-confirm") return true;
615
1213
 
616
- async function review_undo_action() {
617
- const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
618
- if (props.length > 0 && props[0].hunkId) {
619
- const id = props[0].hunkId as string;
620
- state.hunkStatus[id] = 'pending';
621
- const h = state.hunks.find(x => x.id === id);
622
- if (h) h.status = 'pending';
623
- await updateReviewUI();
1214
+ const response = args.input.trim().toLowerCase();
1215
+ if (response === "discard" || args.selected_index === 0) {
1216
+ const f = state.files[state.selectedIndex];
1217
+ if (f) {
1218
+ if (f.category === 'untracked') {
1219
+ await editor.spawnProcess("rm", ["--", f.path]);
1220
+ } else {
1221
+ await editor.spawnProcess("git", ["checkout", "--", f.path]);
1222
+ }
1223
+ await refreshMagitData();
1224
+ editor.setStatus(`Discarded: ${f.path}`);
1225
+ }
1226
+ } else {
1227
+ editor.setStatus("Discard cancelled");
624
1228
  }
1229
+ return false;
625
1230
  }
626
- registerHandler("review_undo_action", review_undo_action);
1231
+ registerHandler("on_review_discard_confirm", on_review_discard_confirm);
627
1232
 
628
- function review_next_hunk() {
629
- const bid = editor.getActiveBufferId();
630
- const props = editor.getTextPropertiesAtCursor(bid);
631
- let cur = -1;
632
- if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
633
- if (cur + 1 < state.hunks.length) editor.setBufferCursor(bid, state.hunks[cur + 1].byteOffset);
1233
+ /**
1234
+ * Refresh file list and diffs using the new git status approach, then re-render.
1235
+ */
1236
+ async function refreshMagitData() {
1237
+ const files = await getGitStatus();
1238
+ state.files = files;
1239
+ state.hunks = await fetchDiffsForFiles(files);
1240
+ // Clamp selectedIndex
1241
+ if (state.selectedIndex >= state.files.length) {
1242
+ state.selectedIndex = Math.max(0, state.files.length - 1);
1243
+ }
1244
+ state.diffCursorRow = 1;
1245
+ state.diffCache = {}; // git state may have changed — invalidate cached diffs
1246
+ updateMagitDisplay();
634
1247
  }
635
- registerHandler("review_next_hunk", review_next_hunk);
636
1248
 
637
- function review_prev_hunk() {
638
- const bid = editor.getActiveBufferId();
639
- const props = editor.getTextPropertiesAtCursor(bid);
640
- let cur = state.hunks.length;
641
- if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
642
- if (cur - 1 >= 0) editor.setBufferCursor(bid, state.hunks[cur - 1].byteOffset);
1249
+ // --- Resize handler ---
1250
+
1251
+ /**
1252
+ * Refresh viewport dimensions from the actual split viewport.
1253
+ * This accounts for sidebars (file explorer) that reduce available width,
1254
+ * unlike the terminal-level resize event which reports full terminal size.
1255
+ */
1256
+ function refreshViewportDimensions(): boolean {
1257
+ const viewport = editor.getViewport();
1258
+ if (viewport) {
1259
+ const changed = viewport.width !== state.viewportWidth || viewport.height !== state.viewportHeight;
1260
+ state.viewportWidth = viewport.width;
1261
+ state.viewportHeight = viewport.height;
1262
+ return changed;
1263
+ }
1264
+ return false;
643
1265
  }
644
- registerHandler("review_prev_hunk", review_prev_hunk);
645
1266
 
646
- function review_refresh() { refreshReviewData(); }
647
- registerHandler("review_refresh", review_refresh);
1267
+ function onReviewDiffResize(_data: { width: number; height: number }): void {
1268
+ if (state.reviewBufferId === null) return;
1269
+ refreshViewportDimensions();
1270
+ // Invalidate cached diff entries — they were built for the old viewport width
1271
+ state.diffCache = {};
1272
+ updateMagitDisplay();
1273
+ }
1274
+ registerHandler("onReviewDiffResize", onReviewDiffResize);
648
1275
 
649
1276
  let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
650
1277
 
@@ -848,6 +1475,15 @@ function computeFullFileAlignedDiff(oldContent: string, newContent: string, hunk
848
1475
  return aligned;
849
1476
  }
850
1477
 
1478
+ interface HighlightTask {
1479
+ range: [number, number];
1480
+ fg: OverlayColorSpec;
1481
+ bg?: OverlayColorSpec;
1482
+ bold?: boolean;
1483
+ italic?: boolean;
1484
+ extend_to_line_end?: boolean;
1485
+ }
1486
+
851
1487
  /**
852
1488
  * Generate virtual buffer content with diff highlighting for one side.
853
1489
  * Returns entries, highlight tasks, and line byte offsets for scroll sync.
@@ -1011,187 +1647,261 @@ interface SideBySideDiffState {
1011
1647
  scrollSyncGroupId: number | null; // Core scroll sync group ID
1012
1648
  }
1013
1649
 
1014
- let activeSideBySideState: SideBySideDiffState | null = null;
1015
- let nextScrollSyncGroupId = 1;
1650
+ let activeSideBySideState: SideBySideDiffState | null = null;
1651
+ let nextScrollSyncGroupId = 1;
1652
+
1653
+ // State for composite buffer-based diff view
1654
+ interface CompositeDiffState {
1655
+ compositeBufferId: number;
1656
+ oldBufferId: number;
1657
+ newBufferId: number;
1658
+ filePath: string;
1659
+ }
1660
+
1661
+ let activeCompositeDiffState: CompositeDiffState | null = null;
1662
+
1663
+ async function review_drill_down() {
1664
+ // Use selected file from magit state instead of cursor properties
1665
+ if (state.files.length === 0) return;
1666
+ const selectedFile = state.files[state.selectedIndex];
1667
+ if (!selectedFile) return;
1668
+
1669
+ // Create a minimal hunk-like reference for the rest of the function
1670
+ const h = { file: selectedFile.path, gitStatus: selectedFile.category };
1671
+
1672
+ editor.setStatus(editor.t("status.loading_diff"));
1673
+
1674
+ // Get all hunks for this file
1675
+ const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
1676
+ if (fileHunks.length === 0) return;
1677
+
1678
+ // Get git root to construct absolute path
1679
+ const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
1680
+ if (gitRootResult.exit_code !== 0) {
1681
+ editor.setStatus(editor.t("status.not_git_repo"));
1682
+ return;
1683
+ }
1684
+ const gitRoot = gitRootResult.stdout.trim();
1685
+ const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
1686
+
1687
+ // Get old (HEAD) and new (working) file content
1688
+ let oldContent: string;
1689
+ const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
1690
+ if (gitShow.exit_code !== 0) {
1691
+ oldContent = "";
1692
+ } else {
1693
+ oldContent = gitShow.stdout;
1694
+ }
1695
+
1696
+ // Read new file content (use absolute path for readFile)
1697
+ // For deleted files the path no longer exists — use empty content
1698
+ let newContent: string;
1699
+ if (selectedFile.status === 'D') {
1700
+ newContent = "";
1701
+ } else {
1702
+ const readResult = await editor.readFile(absoluteFilePath);
1703
+ if (readResult === null) {
1704
+ editor.setStatus(editor.t("status.failed_new_version"));
1705
+ return;
1706
+ }
1707
+ newContent = readResult;
1708
+ }
1016
1709
 
1017
- // State for composite buffer-based diff view
1018
- interface CompositeDiffState {
1019
- compositeBufferId: number;
1020
- oldBufferId: number;
1021
- newBufferId: number;
1022
- filePath: string;
1023
- }
1710
+ // Close any existing side-by-side views (old split-based approach)
1711
+ if (activeSideBySideState) {
1712
+ try {
1713
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1714
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1715
+ }
1716
+ editor.closeBuffer(activeSideBySideState.oldBufferId);
1717
+ editor.closeBuffer(activeSideBySideState.newBufferId);
1718
+ } catch {}
1719
+ activeSideBySideState = null;
1720
+ }
1024
1721
 
1025
- let activeCompositeDiffState: CompositeDiffState | null = null;
1722
+ // Close any existing composite diff view
1723
+ if (activeCompositeDiffState) {
1724
+ try {
1725
+ editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId);
1726
+ editor.closeBuffer(activeCompositeDiffState.oldBufferId);
1727
+ editor.closeBuffer(activeCompositeDiffState.newBufferId);
1728
+ } catch {}
1729
+ activeCompositeDiffState = null;
1730
+ }
1026
1731
 
1027
- async function review_drill_down() {
1028
- const bid = editor.getActiveBufferId();
1029
- const props = editor.getTextPropertiesAtCursor(bid);
1030
- if (props.length > 0 && props[0].hunkId) {
1031
- const id = props[0].hunkId as string;
1032
- const h = state.hunks.find(x => x.id === id);
1033
- if (!h) return;
1732
+ // Create virtual buffers for old and new content
1733
+ const oldLines = oldContent.split('\n');
1734
+ const newLines = newContent.split('\n');
1034
1735
 
1035
- editor.setStatus(editor.t("status.loading_diff"));
1736
+ const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
1737
+ text: line + '\n',
1738
+ properties: { type: 'line', lineNum: idx + 1 }
1739
+ }));
1036
1740
 
1037
- // Get all hunks for this file
1038
- const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
1741
+ const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
1742
+ text: line + '\n',
1743
+ properties: { type: 'line', lineNum: idx + 1 }
1744
+ }));
1039
1745
 
1040
- // Get git root to construct absolute path
1041
- const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
1042
- if (gitRootResult.exit_code !== 0) {
1043
- editor.setStatus(editor.t("status.not_git_repo"));
1044
- return;
1045
- }
1046
- const gitRoot = gitRootResult.stdout.trim();
1047
- const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
1746
+ // Create source buffers (hidden from tabs, used by composite)
1747
+ const oldResult = await editor.createVirtualBuffer({
1748
+ name: `*OLD:${h.file}*`,
1749
+ mode: "normal",
1750
+ readOnly: true,
1751
+ entries: oldEntries,
1752
+ showLineNumbers: true,
1753
+ editingDisabled: true,
1754
+ hiddenFromTabs: true
1755
+ });
1756
+ const oldBufferId = oldResult.bufferId;
1048
1757
 
1049
- // Get old (HEAD) and new (working) file content
1050
- const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
1051
- if (gitShow.exit_code !== 0) {
1052
- editor.setStatus(editor.t("status.failed_old_version"));
1053
- return;
1054
- }
1055
- const oldContent = gitShow.stdout;
1758
+ const newResult = await editor.createVirtualBuffer({
1759
+ name: `*NEW:${h.file}*`,
1760
+ mode: "normal",
1761
+ readOnly: true,
1762
+ entries: newEntries,
1763
+ showLineNumbers: true,
1764
+ editingDisabled: true,
1765
+ hiddenFromTabs: true
1766
+ });
1767
+ const newBufferId = newResult.bufferId;
1056
1768
 
1057
- // Read new file content (use absolute path for readFile)
1058
- const newContent = await editor.readFile(absoluteFilePath);
1059
- if (newContent === null) {
1060
- editor.setStatus(editor.t("status.failed_new_version"));
1061
- return;
1769
+ // Convert hunks to composite buffer format (parse counts from git diff)
1770
+ const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => {
1771
+ let oldCount = 0, newCount = 0;
1772
+ for (const line of fh.lines) {
1773
+ if (line.startsWith('-')) oldCount++;
1774
+ else if (line.startsWith('+')) newCount++;
1775
+ else if (line.startsWith(' ')) { oldCount++; newCount++; }
1062
1776
  }
1777
+ return {
1778
+ oldStart: Math.max(0, fh.oldRange.start - 1),
1779
+ oldCount: oldCount || 1,
1780
+ newStart: Math.max(0, fh.range.start - 1),
1781
+ newCount: newCount || 1
1782
+ };
1783
+ });
1063
1784
 
1064
- // Close any existing side-by-side views (old split-based approach)
1065
- if (activeSideBySideState) {
1066
- try {
1067
- if (activeSideBySideState.scrollSyncGroupId !== null) {
1068
- (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1785
+ // Create composite buffer with side-by-side layout
1786
+ const compositeBufferId = await editor.createCompositeBuffer({
1787
+ name: `*Diff: ${h.file}*`,
1788
+ mode: "diff-view",
1789
+ layout: {
1790
+ type: "side-by-side",
1791
+ ratios: [0.5, 0.5],
1792
+ showSeparator: true
1793
+ },
1794
+ sources: [
1795
+ {
1796
+ bufferId: oldBufferId,
1797
+ label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
1798
+ editable: false,
1799
+ style: {
1800
+ gutterStyle: "diff-markers"
1069
1801
  }
1070
- editor.closeBuffer(activeSideBySideState.oldBufferId);
1071
- editor.closeBuffer(activeSideBySideState.newBufferId);
1072
- } catch {}
1073
- activeSideBySideState = null;
1074
- }
1802
+ },
1803
+ {
1804
+ bufferId: newBufferId,
1805
+ label: "NEW (Working)",
1806
+ editable: false,
1807
+ style: {
1808
+ gutterStyle: "diff-markers"
1809
+ }
1810
+ }
1811
+ ],
1812
+ hunks: compositeHunks.length > 0 ? compositeHunks : null,
1813
+ initialFocusHunk: compositeHunks.length > 0 ? 0 : undefined
1814
+ });
1075
1815
 
1076
- // Close any existing composite diff view
1077
- if (activeCompositeDiffState) {
1078
- try {
1079
- editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId);
1080
- editor.closeBuffer(activeCompositeDiffState.oldBufferId);
1081
- editor.closeBuffer(activeCompositeDiffState.newBufferId);
1082
- } catch {}
1083
- activeCompositeDiffState = null;
1084
- }
1816
+ // Store state for cleanup
1817
+ activeCompositeDiffState = {
1818
+ compositeBufferId,
1819
+ oldBufferId,
1820
+ newBufferId,
1821
+ filePath: h.file
1822
+ };
1085
1823
 
1086
- // Create virtual buffers for old and new content
1087
- const oldLines = oldContent.split('\n');
1088
- const newLines = newContent.split('\n');
1089
-
1090
- const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
1091
- text: line + '\n',
1092
- properties: { type: 'line', lineNum: idx + 1 }
1093
- }));
1094
-
1095
- const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
1096
- text: line + '\n',
1097
- properties: { type: 'line', lineNum: idx + 1 }
1098
- }));
1099
-
1100
- // Create source buffers (hidden from tabs, used by composite)
1101
- const oldResult = await editor.createVirtualBuffer({
1102
- name: `*OLD:${h.file}*`,
1103
- mode: "normal",
1104
- readOnly: true,
1105
- entries: oldEntries,
1106
- showLineNumbers: true,
1107
- editingDisabled: true,
1108
- hiddenFromTabs: true
1109
- });
1110
- const oldBufferId = oldResult.bufferId;
1111
-
1112
- const newResult = await editor.createVirtualBuffer({
1113
- name: `*NEW:${h.file}*`,
1114
- mode: "normal",
1115
- readOnly: true,
1116
- entries: newEntries,
1117
- showLineNumbers: true,
1118
- editingDisabled: true,
1119
- hiddenFromTabs: true
1120
- });
1121
- const newBufferId = newResult.bufferId;
1122
-
1123
- // Convert hunks to composite buffer format (parse counts from git diff)
1124
- const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => {
1125
- // Parse actual counts from the hunk lines
1126
- let oldCount = 0, newCount = 0;
1127
- for (const line of fh.lines) {
1128
- if (line.startsWith('-')) oldCount++;
1129
- else if (line.startsWith('+')) newCount++;
1130
- else if (line.startsWith(' ')) { oldCount++; newCount++; }
1131
- }
1132
- return {
1133
- oldStart: fh.oldRange.start - 1, // Convert to 0-indexed
1134
- oldCount: oldCount || 1,
1135
- newStart: fh.range.start - 1, // Convert to 0-indexed
1136
- newCount: newCount || 1
1137
- };
1138
- });
1824
+ // Show the composite buffer (replaces the review diff buffer)
1825
+ editor.showBuffer(compositeBufferId);
1139
1826
 
1140
- // Create composite buffer with side-by-side layout
1141
- const compositeBufferId = await editor.createCompositeBuffer({
1142
- name: `*Diff: ${h.file}*`,
1143
- mode: "diff-view",
1144
- layout: {
1145
- type: "side-by-side",
1146
- ratios: [0.5, 0.5],
1147
- showSeparator: true
1148
- },
1149
- sources: [
1150
- {
1151
- bufferId: oldBufferId,
1152
- label: "OLD (HEAD)",
1153
- editable: false,
1154
- style: {
1155
- removeBg: [80, 40, 40],
1156
- gutterStyle: "diff-markers"
1157
- }
1158
- },
1159
- {
1160
- bufferId: newBufferId,
1161
- label: "NEW (Working)",
1162
- editable: false,
1163
- style: {
1164
- addBg: [40, 80, 40],
1165
- gutterStyle: "diff-markers"
1166
- }
1167
- }
1168
- ],
1169
- hunks: compositeHunks.length > 0 ? compositeHunks : null
1170
- });
1827
+ const addedCount = fileHunks.reduce((sum, fh) => {
1828
+ return sum + fh.lines.filter(l => l.startsWith('+')).length;
1829
+ }, 0);
1830
+ const removedCount = fileHunks.reduce((sum, fh) => {
1831
+ return sum + fh.lines.filter(l => l.startsWith('-')).length;
1832
+ }, 0);
1833
+ const modifiedCount = Math.min(addedCount, removedCount);
1171
1834
 
1172
- // Store state for cleanup
1173
- activeCompositeDiffState = {
1174
- compositeBufferId,
1175
- oldBufferId,
1176
- newBufferId,
1177
- filePath: h.file
1178
- };
1835
+ editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
1836
+ }
1837
+ registerHandler("review_drill_down", review_drill_down);
1179
1838
 
1180
- // Show the composite buffer (replaces the review diff buffer)
1181
- editor.showBuffer(compositeBufferId);
1839
+ // --- Hunk navigation for side-by-side diff view ---
1182
1840
 
1183
- const addedCount = fileHunks.reduce((sum, fh) => {
1184
- return sum + fh.lines.filter(l => l.startsWith('+')).length;
1185
- }, 0);
1186
- const removedCount = fileHunks.reduce((sum, fh) => {
1187
- return sum + fh.lines.filter(l => l.startsWith('-')).length;
1188
- }, 0);
1189
- const modifiedCount = Math.min(addedCount, removedCount);
1841
+ /**
1842
+ * Move the diff panel's native cursor to the given 1-indexed row, scrolling
1843
+ * the viewport so the row is visible.
1844
+ */
1845
+ function jumpDiffCursorToRow(row: number): void {
1846
+ const diffId = state.panelBuffers["diff"];
1847
+ if (diffId === undefined) return;
1848
+ const idx = row - 1;
1849
+ if (idx < 0 || idx >= state.diffLineByteOffsets.length) return;
1850
+
1851
+ if (state.focusPanel === 'diff') {
1852
+ // When the diff panel is focused, use executeAction so that the
1853
+ // normal cursor event flow fires and the status bar line number
1854
+ // updates correctly. This is O(delta) but necessary because
1855
+ // setBufferCursor doesn't trigger line-index refresh in the
1856
+ // virtual buffer's piece tree.
1857
+ const delta = row - state.diffCursorRow;
1858
+ const action = delta > 0 ? "move_down" : "move_up";
1859
+ for (let i = 0, n = Math.abs(delta); i < n; i++) editor.executeAction(action);
1860
+ } else {
1861
+ // When unfocused, setBufferCursor is safe since the cursor
1862
+ // position isn't displayed in the status bar.
1863
+ const byteOffset = state.diffLineByteOffsets[idx];
1864
+ editor.setBufferCursor(diffId, byteOffset);
1865
+ editor.scrollBufferToLine(diffId, idx);
1866
+ }
1867
+ state.diffCursorRow = row;
1868
+ applyCursorLineOverlay('diff');
1869
+ }
1870
+
1871
+ function review_next_hunk() {
1872
+ // Magit review-mode diff panel: jump to the next hunk header row.
1873
+ if (state.groupId !== null && state.focusPanel === 'diff') {
1874
+ for (const row of state.hunkHeaderRows) {
1875
+ if (row > state.diffCursorRow) {
1876
+ jumpDiffCursorToRow(row);
1877
+ return;
1878
+ }
1879
+ }
1880
+ return;
1881
+ }
1882
+ // Composite diff-view hunk navigation is handled by the Action system
1883
+ // (CompositeNextHunk) via CompositeBuffer context keybindings, so no
1884
+ // plugin fallback is needed here.
1885
+ }
1886
+ registerHandler("review_next_hunk", review_next_hunk);
1190
1887
 
1191
- editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
1888
+ function review_prev_hunk() {
1889
+ // Magit review-mode diff panel: jump to the previous hunk header row.
1890
+ if (state.groupId !== null && state.focusPanel === 'diff') {
1891
+ for (let i = state.hunkHeaderRows.length - 1; i >= 0; i--) {
1892
+ const row = state.hunkHeaderRows[i];
1893
+ if (row < state.diffCursorRow) {
1894
+ jumpDiffCursorToRow(row);
1895
+ return;
1896
+ }
1897
+ }
1898
+ return;
1192
1899
  }
1900
+ // Composite diff-view hunk navigation is handled by the Action system
1901
+ // (CompositePrevHunk) via CompositeBuffer context keybindings, so no
1902
+ // plugin fallback is needed here.
1193
1903
  }
1194
- registerHandler("review_drill_down", review_drill_down);
1904
+ registerHandler("review_prev_hunk", review_prev_hunk);
1195
1905
 
1196
1906
  // Define the diff-view mode - inherits from "normal" for all standard navigation/selection/copy
1197
1907
  // Only adds diff-specific keybindings (close, hunk navigation)
@@ -1208,12 +1918,22 @@ editor.defineMode("diff-view", [
1208
1918
  // --- Review Comment Actions ---
1209
1919
 
1210
1920
  function getCurrentHunkId(): string | null {
1211
- const bid = editor.getActiveBufferId();
1212
- const props = editor.getTextPropertiesAtCursor(bid);
1213
- if (props.length > 0 && props[0].hunkId) return props[0].hunkId as string;
1214
- return null;
1921
+ if (state.files.length === 0) return null;
1922
+ if (state.focusPanel === 'diff') {
1923
+ const hunk = getHunkAtDiffCursor();
1924
+ return hunk?.id || null;
1925
+ }
1926
+ // File panel: return first hunk for selected file
1927
+ const selectedFile = state.files[state.selectedIndex];
1928
+ if (!selectedFile) return null;
1929
+ const hunk = state.hunks.find(
1930
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1931
+ );
1932
+ return hunk?.id || null;
1215
1933
  }
1216
1934
 
1935
+
1936
+
1217
1937
  interface PendingCommentInfo {
1218
1938
  hunkId: string;
1219
1939
  file: string;
@@ -1224,24 +1944,62 @@ interface PendingCommentInfo {
1224
1944
  }
1225
1945
 
1226
1946
  function getCurrentLineInfo(): PendingCommentInfo | null {
1227
- const bid = editor.getActiveBufferId();
1228
- const props = editor.getTextPropertiesAtCursor(bid);
1229
- if (props.length > 0 && props[0].hunkId) {
1230
- const hunk = state.hunks.find(h => h.id === props[0].hunkId);
1231
- return {
1232
- hunkId: props[0].hunkId as string,
1233
- file: (props[0].file as string) || hunk?.file || '',
1234
- lineType: props[0].lineType as 'add' | 'remove' | 'context' | undefined,
1235
- oldLine: props[0].oldLine as number | undefined,
1236
- newLine: props[0].newLine as number | undefined,
1237
- lineContent: props[0].lineContent as string | undefined
1238
- };
1947
+ if (state.files.length === 0) return null;
1948
+ const selectedFile = state.files[state.selectedIndex];
1949
+ if (!selectedFile) return null;
1950
+
1951
+ const props = readPropsAtCursor('diff');
1952
+ const hunkId = props ? props["hunkId"] : undefined;
1953
+ if (typeof hunkId !== 'string') {
1954
+ // Fallback: first hunk for the selected file.
1955
+ const hunk = state.hunks.find(
1956
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1957
+ );
1958
+ if (!hunk) return null;
1959
+ return { hunkId: hunk.id, file: hunk.file };
1239
1960
  }
1240
- return null;
1961
+
1962
+ const file = typeof props!["file"] === 'string' ? props!["file"] as string : selectedFile.path;
1963
+ const lineType = props!["lineType"] as ('add' | 'remove' | 'context' | undefined);
1964
+ const oldLine = typeof props!["oldLine"] === 'number' ? props!["oldLine"] as number : undefined;
1965
+ const newLine = typeof props!["newLine"] === 'number' ? props!["newLine"] as number : undefined;
1966
+ const lineContent = typeof props!["lineContent"] === 'string' ? props!["lineContent"] as string : undefined;
1967
+ return { hunkId, file, lineType, oldLine, newLine, lineContent };
1241
1968
  }
1242
1969
 
1243
1970
  // Pending prompt state for event-based prompt handling
1244
1971
  let pendingCommentInfo: PendingCommentInfo | null = null;
1972
+ let editingCommentId: string | null = null; // non-null when editing an existing comment
1973
+
1974
+ /**
1975
+ * Find an existing comment at the current diff cursor position, either on the
1976
+ * comment display line itself or on the diff line it's attached to.
1977
+ */
1978
+ function findCommentAtCursor(): ReviewComment | null {
1979
+ const props = readPropsAtCursor('diff');
1980
+ if (!props) return null;
1981
+
1982
+ // Cursor sits directly on a comment display line.
1983
+ const commentId = props["commentId"];
1984
+ if (typeof commentId === 'string') {
1985
+ return state.comments.find(c => c.id === commentId) || null;
1986
+ }
1987
+
1988
+ // Cursor sits on a diff line — match by hunk + line type + line number.
1989
+ const hunkId = props["hunkId"];
1990
+ const lineType = props["lineType"];
1991
+ if (typeof hunkId !== 'string') return null;
1992
+ if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
1993
+ const oldLine = typeof props["oldLine"] === 'number' ? props["oldLine"] as number : undefined;
1994
+ const newLine = typeof props["newLine"] === 'number' ? props["newLine"] as number : undefined;
1995
+ return state.comments.find(c =>
1996
+ c.hunk_id === hunkId && (
1997
+ (c.line_type === 'add' && c.new_line === newLine) ||
1998
+ (c.line_type === 'remove' && c.old_line === oldLine) ||
1999
+ (c.line_type === 'context' && c.new_line === newLine)
2000
+ )
2001
+ ) || null;
2002
+ }
1245
2003
 
1246
2004
  async function review_add_comment() {
1247
2005
  const info = getCurrentLineInfo();
@@ -1249,9 +2007,13 @@ async function review_add_comment() {
1249
2007
  editor.setStatus(editor.t("status.no_hunk_selected"));
1250
2008
  return;
1251
2009
  }
2010
+
2011
+ // Check for existing comment to edit
2012
+ const existing = findCommentAtCursor();
2013
+
1252
2014
  pendingCommentInfo = info;
2015
+ editingCommentId = existing?.id || null;
1253
2016
 
1254
- // Show line context in prompt (if on a specific line)
1255
2017
  let lineRef = 'hunk';
1256
2018
  if (info.lineType === 'add' && info.newLine) {
1257
2019
  lineRef = `+${info.newLine}`;
@@ -1262,15 +2024,107 @@ async function review_add_comment() {
1262
2024
  } else if (info.oldLine) {
1263
2025
  lineRef = `L${info.oldLine}`;
1264
2026
  }
1265
- editor.startPrompt(editor.t("prompt.comment", { line: lineRef }), "review-comment");
2027
+
2028
+ const label = existing
2029
+ ? (editor.t("prompt.edit_comment", { line: lineRef }) || `Edit comment on ${lineRef}: `)
2030
+ : editor.t("prompt.comment", { line: lineRef });
2031
+
2032
+ if (existing) {
2033
+ editor.startPromptWithInitial(label, "review-comment", existing.text);
2034
+ } else {
2035
+ editor.startPrompt(label, "review-comment");
2036
+ }
1266
2037
  }
1267
2038
  registerHandler("review_add_comment", review_add_comment);
1268
2039
 
2040
+ let pendingDeleteCommentId: string | null = null;
2041
+
2042
+ async function review_delete_comment() {
2043
+ let target: ReviewComment | null = null;
2044
+
2045
+ if (state.focusPanel === 'diff') {
2046
+ target = findCommentAtCursor();
2047
+ } else {
2048
+ // File panel: target the last note
2049
+ const notes = state.comments.filter(c => c.hunk_id === '__overall__');
2050
+ if (notes.length > 0) target = notes[notes.length - 1];
2051
+ }
2052
+
2053
+ // File panel: delete note
2054
+ if (!target && state.focusPanel === 'files' && state.note) {
2055
+ pendingDeleteCommentId = '__note__';
2056
+ const preview = state.note.length > 40 ? state.note.substring(0, 37) + '...' : state.note;
2057
+ editor.startPrompt(`Delete note "${preview}"?`, "review-delete-comment-confirm");
2058
+ const suggestions: PromptSuggestion[] = [
2059
+ { text: "Delete", description: "Remove this note", value: "delete" },
2060
+ { text: "Cancel", description: "Keep the note", value: "cancel" },
2061
+ ];
2062
+ editor.setPromptSuggestions(suggestions);
2063
+ return;
2064
+ }
2065
+
2066
+ if (!target) {
2067
+ editor.setStatus("No comment to delete");
2068
+ return;
2069
+ }
2070
+
2071
+ pendingDeleteCommentId = target.id;
2072
+ const preview = target.text.length > 40 ? target.text.substring(0, 37) + '...' : target.text;
2073
+ editor.startPrompt(`Delete "${preview}"?`, "review-delete-comment-confirm");
2074
+ const suggestions: PromptSuggestion[] = [
2075
+ { text: "Delete", description: "Remove this comment", value: "delete" },
2076
+ { text: "Cancel", description: "Keep the comment", value: "cancel" },
2077
+ ];
2078
+ editor.setPromptSuggestions(suggestions);
2079
+ }
2080
+ registerHandler("review_delete_comment", review_delete_comment);
2081
+
2082
+ function on_review_delete_comment_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): boolean {
2083
+ if (args.prompt_type !== "review-delete-comment-confirm") return true;
2084
+ const response = args.input.trim().toLowerCase();
2085
+ if ((response === "delete" || args.selected_index === 0) && pendingDeleteCommentId) {
2086
+ if (pendingDeleteCommentId === '__note__') {
2087
+ state.note = '';
2088
+ } else {
2089
+ state.comments = state.comments.filter(c => c.id !== pendingDeleteCommentId);
2090
+ }
2091
+ state.diffCache = {}; // comment changed
2092
+ updateMagitDisplay();
2093
+ editor.setStatus("Deleted");
2094
+ } else {
2095
+ editor.setStatus("Delete cancelled");
2096
+ }
2097
+ pendingDeleteCommentId = null;
2098
+ return false;
2099
+ }
2100
+ registerHandler("on_review_delete_comment_confirm", on_review_delete_comment_confirm);
2101
+
1269
2102
  // Prompt event handlers
1270
2103
  function on_review_prompt_confirm(args: { prompt_type: string; input: string }): boolean {
1271
2104
  if (args.prompt_type !== "review-comment") {
1272
- return true; // Not our prompt
2105
+ return true;
2106
+ }
2107
+
2108
+ if (editingCommentId) {
2109
+ // Edit mode: update existing comment (empty text keeps the comment unchanged)
2110
+ if (args.input && args.input.trim()) {
2111
+ const existing = state.comments.find(c => c.id === editingCommentId);
2112
+ if (existing) {
2113
+ existing.text = args.input.trim();
2114
+ existing.timestamp = new Date().toISOString();
2115
+ state.diffCache = {}; // comment changed
2116
+ updateMagitDisplay();
2117
+ editor.setStatus("Comment updated");
2118
+ }
2119
+ } else {
2120
+ editor.setStatus("Comment unchanged (use x to delete)");
2121
+ }
2122
+ editingCommentId = null;
2123
+ pendingCommentInfo = null;
2124
+ return true;
1273
2125
  }
2126
+
2127
+ // New comment mode
1274
2128
  if (pendingCommentInfo && args.input && args.input.trim()) {
1275
2129
  const comment: ReviewComment = {
1276
2130
  id: `comment-${Date.now()}`,
@@ -1284,7 +2138,8 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
1284
2138
  line_type: pendingCommentInfo.lineType
1285
2139
  };
1286
2140
  state.comments.push(comment);
1287
- updateReviewUI();
2141
+ state.diffCache = {}; // comment changed — invalidate cached diff entries
2142
+ updateMagitDisplay();
1288
2143
  let lineRef = 'hunk';
1289
2144
  if (comment.line_type === 'add' && comment.new_line) {
1290
2145
  lineRef = `line +${comment.new_line}`;
@@ -1305,6 +2160,7 @@ registerHandler("on_review_prompt_confirm", on_review_prompt_confirm);
1305
2160
  function on_review_prompt_cancel(args: { prompt_type: string }): boolean {
1306
2161
  if (args.prompt_type === "review-comment") {
1307
2162
  pendingCommentInfo = null;
2163
+ editingCommentId = null;
1308
2164
  editor.setStatus(editor.t("status.comment_cancelled"));
1309
2165
  }
1310
2166
  return true;
@@ -1313,145 +2169,92 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
1313
2169
 
1314
2170
  // Register prompt event handlers
1315
2171
  editor.on("prompt_confirmed", "on_review_prompt_confirm");
2172
+ editor.on("prompt_confirmed", "on_review_discard_confirm");
2173
+ editor.on("prompt_confirmed", "on_review_discard_hunk_confirm");
2174
+ editor.on("prompt_confirmed", "on_review_edit_note_confirm");
2175
+ editor.on("prompt_confirmed", "on_review_delete_comment_confirm");
1316
2176
  editor.on("prompt_cancelled", "on_review_prompt_cancel");
1317
2177
 
1318
- async function review_approve_hunk() {
1319
- const hunkId = getCurrentHunkId();
1320
- if (!hunkId) return;
1321
- const h = state.hunks.find(x => x.id === hunkId);
1322
- if (h) {
1323
- h.reviewStatus = 'approved';
1324
- await updateReviewUI();
1325
- editor.setStatus(editor.t("status.hunk_approved"));
1326
- }
1327
- }
1328
- registerHandler("review_approve_hunk", review_approve_hunk);
1329
-
1330
- async function review_reject_hunk() {
1331
- const hunkId = getCurrentHunkId();
1332
- if (!hunkId) return;
1333
- const h = state.hunks.find(x => x.id === hunkId);
1334
- if (h) {
1335
- h.reviewStatus = 'rejected';
1336
- await updateReviewUI();
1337
- editor.setStatus(editor.t("status.hunk_rejected"));
1338
- }
1339
- }
1340
- registerHandler("review_reject_hunk", review_reject_hunk);
1341
-
1342
- async function review_needs_changes() {
1343
- const hunkId = getCurrentHunkId();
1344
- if (!hunkId) return;
1345
- const h = state.hunks.find(x => x.id === hunkId);
1346
- if (h) {
1347
- h.reviewStatus = 'needs_changes';
1348
- await updateReviewUI();
1349
- editor.setStatus(editor.t("status.hunk_needs_changes"));
1350
- }
1351
- }
1352
- registerHandler("review_needs_changes", review_needs_changes);
1353
-
1354
- async function review_question_hunk() {
1355
- const hunkId = getCurrentHunkId();
1356
- if (!hunkId) return;
1357
- const h = state.hunks.find(x => x.id === hunkId);
1358
- if (h) {
1359
- h.reviewStatus = 'question';
1360
- await updateReviewUI();
1361
- editor.setStatus(editor.t("status.hunk_question"));
1362
- }
1363
- }
1364
- registerHandler("review_question_hunk", review_question_hunk);
1365
-
1366
- async function review_clear_status() {
1367
- const hunkId = getCurrentHunkId();
1368
- if (!hunkId) return;
1369
- const h = state.hunks.find(x => x.id === hunkId);
1370
- if (h) {
1371
- h.reviewStatus = 'pending';
1372
- await updateReviewUI();
1373
- editor.setStatus(editor.t("status.hunk_status_cleared"));
2178
+ async function review_edit_note() {
2179
+ const label = editor.t("prompt.overall_comment") || "Note: ";
2180
+ if (state.note) {
2181
+ editor.startPromptWithInitial(label, "review-edit-note", state.note);
2182
+ } else {
2183
+ editor.startPrompt(label, "review-edit-note");
1374
2184
  }
1375
2185
  }
1376
- registerHandler("review_clear_status", review_clear_status);
1377
-
1378
- async function review_set_overall_feedback() {
1379
- const text = await editor.prompt(editor.t("prompt.overall_feedback"), state.overallFeedback || "");
1380
- if (text !== null) {
1381
- state.overallFeedback = text.trim();
1382
- editor.setStatus(text.trim() ? editor.t("status.feedback_set") : editor.t("status.feedback_cleared"));
2186
+ registerHandler("review_edit_note", review_edit_note);
2187
+
2188
+ function on_review_edit_note_confirm(args: { prompt_type: string; input: string }): boolean {
2189
+ if (args.prompt_type !== "review-edit-note") return true;
2190
+ if (args.input && args.input.trim()) {
2191
+ state.note = args.input.trim();
2192
+ updateMagitDisplay();
2193
+ editor.setStatus(state.note ? "Note saved" : "Note cleared");
2194
+ } else {
2195
+ // Empty submission: keep existing note unchanged (use x to delete)
2196
+ if (state.note) {
2197
+ editor.setStatus("Note unchanged (use x to delete)");
2198
+ }
1383
2199
  }
2200
+ return true;
1384
2201
  }
1385
- registerHandler("review_set_overall_feedback", review_set_overall_feedback);
2202
+ registerHandler("on_review_edit_note_confirm", on_review_edit_note_confirm);
1386
2203
 
1387
2204
  async function review_export_session() {
1388
2205
  const cwd = editor.getCwd();
1389
2206
  const reviewDir = editor.pathJoin(cwd, ".review");
1390
2207
 
1391
- // Generate markdown content (writeFile creates parent directories)
1392
2208
  let md = `# Code Review Session\n`;
1393
2209
  md += `Date: ${new Date().toISOString()}\n\n`;
1394
2210
 
1395
- if (state.originalRequest) {
1396
- md += `## Original Request\n${state.originalRequest}\n\n`;
2211
+ if (state.note) {
2212
+ md += `## Note\n${state.note}\n\n`;
1397
2213
  }
1398
2214
 
1399
- if (state.overallFeedback) {
1400
- md += `## Overall Feedback\n${state.overallFeedback}\n\n`;
2215
+ // Summary
2216
+ const filesWithComments = new Set(state.comments.map(c => c.file)).size;
2217
+ md += `## Summary\n`;
2218
+ md += `- Files: ${state.files.length}\n`;
2219
+ md += `- Hunks: ${state.hunks.length}\n`;
2220
+ if (filesWithComments > 0) {
2221
+ md += `- Files with comments: ${filesWithComments}\n`;
2222
+ }
2223
+ md += `\n`;
2224
+
2225
+ // Group comments by file
2226
+ const fileComments: Record<string, ReviewComment[]> = {};
2227
+ for (const c of state.comments) {
2228
+ const file = c.file || 'unknown';
2229
+ if (!fileComments[file]) fileComments[file] = [];
2230
+ fileComments[file].push(c);
1401
2231
  }
1402
2232
 
1403
- // Stats
1404
- const approved = state.hunks.filter(h => h.reviewStatus === 'approved').length;
1405
- const rejected = state.hunks.filter(h => h.reviewStatus === 'rejected').length;
1406
- const needsChanges = state.hunks.filter(h => h.reviewStatus === 'needs_changes').length;
1407
- const questions = state.hunks.filter(h => h.reviewStatus === 'question').length;
1408
- md += `## Summary\n`;
1409
- md += `- Total hunks: ${state.hunks.length}\n`;
1410
- md += `- Approved: ${approved}\n`;
1411
- md += `- Rejected: ${rejected}\n`;
1412
- md += `- Needs changes: ${needsChanges}\n`;
1413
- md += `- Questions: ${questions}\n\n`;
1414
-
1415
- // Group by file
1416
- const fileGroups: Record<string, Hunk[]> = {};
1417
- for (const hunk of state.hunks) {
1418
- if (!fileGroups[hunk.file]) fileGroups[hunk.file] = [];
1419
- fileGroups[hunk.file].push(hunk);
1420
- }
1421
-
1422
- for (const [file, hunks] of Object.entries(fileGroups)) {
1423
- md += `## File: ${file}\n\n`;
1424
- for (const hunk of hunks) {
1425
- const statusStr = hunk.reviewStatus.toUpperCase();
1426
- md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
1427
- md += `**Status**: ${statusStr}\n\n`;
1428
-
1429
- const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1430
- if (hunkComments.length > 0) {
1431
- md += `**Comments:**\n`;
1432
- for (const c of hunkComments) {
1433
- // Format line reference
1434
- let lineRef = '';
1435
- if (c.line_type === 'add' && c.new_line) {
1436
- lineRef = `[+${c.new_line}]`;
1437
- } else if (c.line_type === 'remove' && c.old_line) {
1438
- lineRef = `[-${c.old_line}]`;
1439
- } else if (c.new_line) {
1440
- lineRef = `[L${c.new_line}]`;
1441
- } else if (c.old_line) {
1442
- lineRef = `[L${c.old_line}]`;
1443
- }
1444
- md += `> 💬 ${lineRef} ${c.text}\n`;
1445
- if (c.line_content) {
1446
- md += `> \`${c.line_content.trim()}\`\n`;
1447
- }
1448
- md += `\n`;
1449
- }
2233
+ for (const [file, comments] of Object.entries(fileComments)) {
2234
+ md += `## ${file}\n\n`;
2235
+ for (const c of comments) {
2236
+ let lineRef = '';
2237
+ if (c.line_type === 'add' && c.new_line) {
2238
+ lineRef = `line +${c.new_line}`;
2239
+ } else if (c.line_type === 'remove' && c.old_line) {
2240
+ lineRef = `line -${c.old_line}`;
2241
+ } else if (c.new_line) {
2242
+ lineRef = `line ${c.new_line}`;
2243
+ } else if (c.old_line) {
2244
+ lineRef = `line ${c.old_line}`;
2245
+ }
2246
+ if (lineRef) {
2247
+ md += `- **${lineRef}**: ${c.text}\n`;
2248
+ } else {
2249
+ md += `- ${c.text}\n`;
2250
+ }
2251
+ if (c.line_content) {
2252
+ md += ` \`${c.line_content.trim()}\`\n`;
1450
2253
  }
1451
2254
  }
2255
+ md += `\n`;
1452
2256
  }
1453
2257
 
1454
- // Write file
1455
2258
  const filePath = editor.pathJoin(reviewDir, "session.md");
1456
2259
  await editor.writeFile(filePath, md);
1457
2260
  editor.setStatus(editor.t("status.exported", { path: filePath }));
@@ -1461,34 +2264,21 @@ registerHandler("review_export_session", review_export_session);
1461
2264
  async function review_export_json() {
1462
2265
  const cwd = editor.getCwd();
1463
2266
  const reviewDir = editor.pathJoin(cwd, ".review");
1464
- // writeFile creates parent directories
1465
2267
 
1466
2268
  const session = {
1467
- version: "1.0",
2269
+ version: "2.0",
1468
2270
  timestamp: new Date().toISOString(),
1469
- original_request: state.originalRequest || null,
1470
- overall_feedback: state.overallFeedback || null,
1471
- files: {} as Record<string, any>
2271
+ note: state.note || null,
2272
+ comments: state.comments.map(c => ({
2273
+ file: c.file,
2274
+ text: c.text,
2275
+ line_type: c.line_type || null,
2276
+ old_line: c.old_line || null,
2277
+ new_line: c.new_line || null,
2278
+ line_content: c.line_content || null
2279
+ }))
1472
2280
  };
1473
2281
 
1474
- for (const hunk of state.hunks) {
1475
- if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
1476
- const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1477
- session.files[hunk.file].hunks.push({
1478
- context: hunk.contextHeader,
1479
- old_lines: [hunk.oldRange.start, hunk.oldRange.end],
1480
- new_lines: [hunk.range.start, hunk.range.end],
1481
- status: hunk.reviewStatus,
1482
- comments: hunkComments.map(c => ({
1483
- text: c.text,
1484
- line_type: c.line_type || null,
1485
- old_line: c.old_line || null,
1486
- new_line: c.new_line || null,
1487
- line_content: c.line_content || null
1488
- }))
1489
- });
1490
- }
1491
-
1492
2282
  const filePath = editor.pathJoin(reviewDir, "session.json");
1493
2283
  await editor.writeFile(filePath, JSON.stringify(session, null, 2));
1494
2284
  editor.setStatus(editor.t("status.exported", { path: filePath }));
@@ -1499,39 +2289,133 @@ async function start_review_diff() {
1499
2289
  editor.setStatus(editor.t("status.generating"));
1500
2290
  editor.setContext("review-mode", true);
1501
2291
 
1502
- // Initial data fetch
1503
- const newHunks = await getGitDiff();
1504
- state.hunks = newHunks;
1505
- state.comments = []; // Reset comments for new session
2292
+ // Get viewport size
2293
+ const viewport = editor.getViewport();
2294
+ if (viewport) {
2295
+ state.viewportWidth = viewport.width;
2296
+ state.viewportHeight = viewport.height;
2297
+ }
1506
2298
 
1507
- const bufferId = await VirtualBufferFactory.create({
1508
- name: "*Review Diff*", mode: "review-mode", readOnly: true,
1509
- entries: (await renderReviewStream()).entries, showLineNumbers: false
2299
+ // Fetch data using new git status approach
2300
+ state.files = await getGitStatus();
2301
+ state.hunks = await fetchDiffsForFiles(state.files);
2302
+ state.comments = [];
2303
+ state.note = '';
2304
+ state.selectedIndex = 0;
2305
+ state.diffCursorRow = 1;
2306
+ state.hunkHeaderRows = [];
2307
+ state.diffLineByteOffsets = [];
2308
+ state.focusPanel = 'files';
2309
+
2310
+ // Create buffer group with layout:
2311
+ // vertical: [toolbar(fixed 1), horizontal: [files, diff]]
2312
+ const layout = JSON.stringify({
2313
+ type: "split",
2314
+ direction: "v",
2315
+ ratio: 0.05,
2316
+ first: { type: "fixed", id: "toolbar", height: 1 },
2317
+ second: {
2318
+ type: "split",
2319
+ direction: "h",
2320
+ ratio: 0.3,
2321
+ first: { type: "scrollable", id: "files" },
2322
+ second: { type: "scrollable", id: "diff" },
2323
+ },
1510
2324
  });
1511
- state.reviewBufferId = bufferId;
1512
- await updateReviewUI(); // Apply initial highlights
2325
+
2326
+ const groupResult = await editor.createBufferGroup("*Review Diff*", "review-mode", layout);
2327
+ state.groupId = groupResult.groupId;
2328
+ state.panelBuffers = groupResult.panels;
2329
+ state.reviewBufferId = groupResult.panels["files"];
2330
+
2331
+ // Diff panel uses the editor's native cursor for scrolling. Buffer-group
2332
+ // panels default to `show_cursors = false`, which also blocks all native
2333
+ // movement actions in `action_to_events`, so flip the flag for the diff
2334
+ // panel only. The files panel keeps its hidden cursor — selection there
2335
+ // is plugin-managed (state.selectedIndex with a `>` prefix + bg highlight),
2336
+ // and j/k/Up/Down are dispatched through the `review_nav_*` handlers.
2337
+ if (state.panelBuffers["diff"] !== undefined) {
2338
+ (editor as any).setBufferShowCursors(state.panelBuffers["diff"], true);
2339
+ }
2340
+
2341
+ // Set initial content for all panels
2342
+ updateMagitDisplay();
2343
+
2344
+ // Ensure the files panel has focus (moves focus away from File Explorer
2345
+ // if it was open, so review-mode keybindings work immediately)
2346
+ editor.focusBufferGroupPanel(state.groupId, "files");
2347
+
2348
+ // Register resize handler
2349
+ editor.on("resize", "onReviewDiffResize");
1513
2350
 
1514
2351
  editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
1515
2352
  editor.on("buffer_activated", "on_review_buffer_activated");
1516
2353
  editor.on("buffer_closed", "on_review_buffer_closed");
2354
+ editor.on("cursor_moved", "on_review_cursor_moved");
1517
2355
  }
1518
2356
  registerHandler("start_review_diff", start_review_diff);
1519
2357
 
1520
2358
  function stop_review_diff() {
2359
+ if (state.groupId !== null) {
2360
+ editor.closeBufferGroup(state.groupId);
2361
+ state.groupId = null;
2362
+ state.panelBuffers = {};
2363
+ }
1521
2364
  state.reviewBufferId = null;
1522
2365
  editor.setContext("review-mode", false);
2366
+ editor.off("resize", "onReviewDiffResize");
1523
2367
  editor.off("buffer_activated", "on_review_buffer_activated");
1524
2368
  editor.off("buffer_closed", "on_review_buffer_closed");
2369
+ editor.off("cursor_moved", "on_review_cursor_moved");
1525
2370
  editor.setStatus(editor.t("status.stopped"));
1526
2371
  }
1527
2372
  registerHandler("stop_review_diff", stop_review_diff);
1528
2373
 
1529
2374
 
1530
- function on_review_buffer_activated(data: any) {
1531
- if (data.buffer_id === state.reviewBufferId) refreshReviewData();
2375
+ /**
2376
+ * React to a buffer becoming active. Used here purely to track which review
2377
+ * panel currently has focus (Tab and mouse clicks both fire buffer_activated).
2378
+ * The focus state drives toolbar hint rendering and the `review_nav_*`
2379
+ * handlers' files-vs-diff branching.
2380
+ *
2381
+ * Note: this used to call `refreshMagitData()` on every activation, which
2382
+ * spawned several `git` subprocesses every time the user switched panels.
2383
+ * The user has a dedicated `r` key for that — auto-refresh was too aggressive.
2384
+ */
2385
+ function on_review_buffer_activated(data: { buffer_id: number }): void {
2386
+ if (state.groupId === null) return;
2387
+ const filesId = state.panelBuffers["files"];
2388
+ const diffId = state.panelBuffers["diff"];
2389
+ let newPanel: 'files' | 'diff' | null = null;
2390
+ if (data.buffer_id === filesId) newPanel = 'files';
2391
+ else if (data.buffer_id === diffId) newPanel = 'diff';
2392
+ if (newPanel === null || newPanel === state.focusPanel) return;
2393
+ state.focusPanel = newPanel;
2394
+ editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
1532
2395
  }
1533
2396
  registerHandler("on_review_buffer_activated", on_review_buffer_activated);
1534
2397
 
2398
+ /**
2399
+ * React to native cursor movement inside the diff panel — the only panel
2400
+ * that has a visible native cursor. The handler keeps `state.diffCursorRow`
2401
+ * in sync (used by `n`/`p` hunk navigation) and re-paints the cursor-line
2402
+ * highlight overlay.
2403
+ */
2404
+ function on_review_cursor_moved(data: {
2405
+ buffer_id: number;
2406
+ cursor_id: number;
2407
+ old_position: number;
2408
+ new_position: number;
2409
+ line: number;
2410
+ text_properties: Array<Record<string, unknown>>;
2411
+ }): void {
2412
+ if (state.groupId === null) return;
2413
+ if (data.buffer_id !== state.panelBuffers["diff"]) return;
2414
+ state.diffCursorRow = data.line;
2415
+ applyCursorLineOverlay('diff');
2416
+ }
2417
+ registerHandler("on_review_cursor_moved", on_review_cursor_moved);
2418
+
1535
2419
  function on_review_buffer_closed(data: any) {
1536
2420
  if (data.buffer_id === state.reviewBufferId) stop_review_diff();
1537
2421
  }
@@ -1579,15 +2463,29 @@ async function side_by_side_diff_current_file() {
1579
2463
  }
1580
2464
  }
1581
2465
 
1582
- // Get hunks for this specific file (use -C gitRoot since filePath is relative to git root)
1583
- const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "HEAD", "--unified=3", "--", filePath]);
1584
- if (result.exit_code !== 0) {
1585
- editor.setStatus(editor.t("status.failed_git_diff"));
1586
- return;
2466
+ // Check if the file is untracked
2467
+ const isTrackedResult = await editor.spawnProcess("git", ["-C", gitRoot, "ls-files", "--", filePath]);
2468
+ const isUntracked = isTrackedResult.exit_code !== 0 || !isTrackedResult.stdout.trim();
2469
+
2470
+ // Get hunks for this specific file
2471
+ let diffOutput: string;
2472
+ if (isUntracked) {
2473
+ // For untracked files, use --no-index to diff against /dev/null
2474
+ const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "--no-index", "--unified=3", "--", "/dev/null", filePath]);
2475
+ // git diff --no-index exits with 1 when there are differences, which is expected
2476
+ diffOutput = result.stdout || "";
2477
+ } else {
2478
+ // For tracked files, use normal diff against HEAD
2479
+ const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "HEAD", "--unified=3", "--", filePath]);
2480
+ if (result.exit_code !== 0) {
2481
+ editor.setStatus(editor.t("status.failed_git_diff"));
2482
+ return;
2483
+ }
2484
+ diffOutput = result.stdout;
1587
2485
  }
1588
2486
 
1589
2487
  // Parse hunks from diff output
1590
- const lines = result.stdout.split('\n');
2488
+ const lines = diffOutput.split('\n');
1591
2489
  const fileHunks: Hunk[] = [];
1592
2490
  let currentHunk: Hunk | null = null;
1593
2491
 
@@ -1604,10 +2502,9 @@ async function side_by_side_diff_current_file() {
1604
2502
  file: filePath,
1605
2503
  range: { start: newStart, end: newStart + newCount - 1 },
1606
2504
  oldRange: { start: oldStart, end: oldStart + oldCount - 1 },
1607
- type: 'modify',
2505
+ type: isUntracked ? 'add' : 'modify',
1608
2506
  lines: [],
1609
2507
  status: 'pending',
1610
- reviewStatus: 'pending',
1611
2508
  contextHeader: match[5]?.trim() || "",
1612
2509
  byteOffset: 0
1613
2510
  };
@@ -1626,12 +2523,18 @@ async function side_by_side_diff_current_file() {
1626
2523
  }
1627
2524
 
1628
2525
  // Get old (HEAD) and new (working) file content (use -C gitRoot since filePath is relative to git root)
1629
- const gitShow = await editor.spawnProcess("git", ["-C", gitRoot, "show", `HEAD:${filePath}`]);
1630
- if (gitShow.exit_code !== 0) {
1631
- editor.setStatus(editor.t("status.failed_old_new_file"));
1632
- return;
2526
+ let oldContent: string;
2527
+ if (isUntracked) {
2528
+ // For untracked files, old content is empty (file didn't exist before)
2529
+ oldContent = "";
2530
+ } else {
2531
+ const gitShow = await editor.spawnProcess("git", ["-C", gitRoot, "show", `HEAD:${filePath}`]);
2532
+ if (gitShow.exit_code !== 0) {
2533
+ editor.setStatus(editor.t("status.failed_old_new_file"));
2534
+ return;
2535
+ }
2536
+ oldContent = gitShow.stdout;
1633
2537
  }
1634
- const oldContent = gitShow.stdout;
1635
2538
 
1636
2539
  // Read new file content (use absolute path for readFile)
1637
2540
  const newContent = await editor.readFile(absolutePath);
@@ -1701,9 +2604,9 @@ async function side_by_side_diff_current_file() {
1701
2604
 
1702
2605
  // Convert hunks to composite buffer format
1703
2606
  const compositeHunks: TsCompositeHunk[] = fileHunks.map(h => ({
1704
- oldStart: h.oldRange.start - 1, // Convert to 0-indexed
1705
- oldCount: h.oldRange.end - h.oldRange.start + 1,
1706
- newStart: h.range.start - 1, // Convert to 0-indexed
2607
+ oldStart: Math.max(0, h.oldRange.start - 1), // Convert to 0-indexed (0 for new files)
2608
+ oldCount: Math.max(1, h.oldRange.end - h.oldRange.start + 1),
2609
+ newStart: Math.max(0, h.range.start - 1), // Convert to 0-indexed
1707
2610
  newCount: h.range.end - h.range.start + 1
1708
2611
  }));
1709
2612
 
@@ -1719,10 +2622,9 @@ async function side_by_side_diff_current_file() {
1719
2622
  sources: [
1720
2623
  {
1721
2624
  bufferId: oldBufferId,
1722
- label: "OLD (HEAD)",
2625
+ label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
1723
2626
  editable: false,
1724
2627
  style: {
1725
- removeBg: [80, 40, 40],
1726
2628
  gutterStyle: "diff-markers"
1727
2629
  }
1728
2630
  },
@@ -1731,7 +2633,6 @@ async function side_by_side_diff_current_file() {
1731
2633
  label: "NEW (Working)",
1732
2634
  editable: false,
1733
2635
  style: {
1734
- addBg: [40, 80, 40],
1735
2636
  gutterStyle: "diff-markers"
1736
2637
  }
1737
2638
  }
@@ -1770,12 +2671,7 @@ editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc",
1770
2671
 
1771
2672
  // Review Comment Commands
1772
2673
  editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
1773
- editor.registerCommand("%cmd.approve_hunk", "%cmd.approve_hunk_desc", "review_approve_hunk", "review-mode");
1774
- editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
1775
- editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
1776
- editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
1777
- editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
1778
- editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "review_set_overall_feedback", "review-mode");
2674
+ editor.registerCommand("%cmd.edit_note", "%cmd.edit_note_desc", "review_edit_note", "review-mode");
1779
2675
  editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
1780
2676
  editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
1781
2677
 
@@ -1813,21 +2709,31 @@ registerHandler("on_buffer_closed", on_buffer_closed);
1813
2709
  editor.on("buffer_closed", "on_buffer_closed");
1814
2710
 
1815
2711
  editor.defineMode("review-mode", [
1816
- // Staging actions
1817
- ["s", "review_stage_hunk"], ["d", "review_discard_hunk"],
1818
- // Navigation
1819
- ["n", "review_next_hunk"], ["p", "review_prev_hunk"], ["r", "review_refresh"],
1820
- ["Enter", "review_drill_down"], ["q", "close"],
1821
- // Review actions
2712
+ // Cursor motion goes through plugin handlers that branch on focus —
2713
+ // files panel updates `state.selectedIndex` (plugin-managed selection
2714
+ // with the `>` prefix + bg highlight); diff panel delegates to native
2715
+ // editor motion via executeAction so scrolling stays fast.
2716
+ ["Up", "review_nav_up"], ["Down", "review_nav_down"],
2717
+ ["k", "review_nav_up"], ["j", "review_nav_down"],
2718
+ ["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
2719
+ ["Home", "review_nav_home"], ["End", "review_nav_end"],
2720
+ // Focus toggle between panels
2721
+ ["Tab", "review_toggle_focus"],
2722
+ // Hunk navigation (diff panel) — jumps the native cursor between hunks.
2723
+ ["n", "review_next_hunk"], ["p", "review_prev_hunk"],
2724
+ // Drill-down
2725
+ ["Enter", "review_drill_down"],
2726
+ // Git actions (context-sensitive: file-level or hunk-level based on focus)
2727
+ ["s", "review_stage_file"], ["u", "review_unstage_file"],
2728
+ ["d", "review_discard_file"],
2729
+ ["r", "review_refresh"],
2730
+ // Comments
1822
2731
  ["c", "review_add_comment"],
1823
- ["a", "review_approve_hunk"],
1824
- ["x", "review_reject_hunk"],
1825
- ["!", "review_needs_changes"],
1826
- ["?", "review_question_hunk"],
1827
- ["u", "review_clear_status"],
1828
- ["O", "review_set_overall_feedback"],
1829
- // Export
1830
- ["E", "review_export_session"],
2732
+ ["N", "review_edit_note"],
2733
+ ["x", "review_delete_comment"],
2734
+ // Close & export
2735
+ ["q", "close"],
2736
+ ["e", "review_export_session"],
1831
2737
  ], true);
1832
2738
 
1833
2739
  editor.debug("Review Diff plugin loaded with review comments support");