@fresh-editor/fresh-editor 0.2.20 → 0.2.22

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.
@@ -61,6 +61,17 @@ interface Hunk {
61
61
  reviewStatus: ReviewStatus;
62
62
  contextHeader: string;
63
63
  byteOffset: number; // Position in the virtual buffer
64
+ gitStatus?: 'staged' | 'unstaged' | 'untracked';
65
+ }
66
+
67
+ /**
68
+ * A file entry from git status --porcelain
69
+ */
70
+ interface FileEntry {
71
+ path: string;
72
+ status: string; // 'M', 'A', 'D', 'R', 'C', '?'
73
+ category: 'staged' | 'unstaged' | 'untracked';
74
+ origPath?: string; // for renames/copies
64
75
  }
65
76
 
66
77
  /**
@@ -73,6 +84,14 @@ interface ReviewState {
73
84
  originalRequest?: string;
74
85
  overallFeedback?: string;
75
86
  reviewBufferId: number | null;
87
+ // New magit-style state
88
+ files: FileEntry[];
89
+ selectedIndex: number;
90
+ fileScrollOffset: number;
91
+ diffScrollOffset: number;
92
+ viewportWidth: number;
93
+ viewportHeight: number;
94
+ focusPanel: 'files' | 'diff';
76
95
  }
77
96
 
78
97
  const state: ReviewState = {
@@ -80,26 +99,34 @@ const state: ReviewState = {
80
99
  hunkStatus: {},
81
100
  comments: [],
82
101
  reviewBufferId: null,
102
+ files: [],
103
+ selectedIndex: 0,
104
+ fileScrollOffset: 0,
105
+ diffScrollOffset: 0,
106
+ viewportWidth: 80,
107
+ viewportHeight: 24,
108
+ focusPanel: 'files',
83
109
  };
84
110
 
85
111
  // --- Refresh State ---
86
- let isUpdating = false;
87
112
 
88
113
  // --- 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 ?
114
+ // Colors use theme keys where possible, falling back to direct values
115
+ const STYLE_BORDER: OverlayColorSpec = "ui.split_separator_fg";
116
+ const STYLE_HEADER: OverlayColorSpec = "syntax.keyword";
117
+ const STYLE_FILE_NAME: OverlayColorSpec = "syntax.string";
118
+ const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
119
+ const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
120
+ const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
121
+ const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
122
+ const STYLE_STAGED: OverlayColorSpec = "editor.line_number_fg";
123
+ const STYLE_DISCARDED: OverlayColorSpec = "diagnostic.error_fg";
124
+ const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
125
+ const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
126
+ const STYLE_COMMENT_BORDER: OverlayColorSpec = "ui.split_separator_fg";
127
+ const STYLE_APPROVED: OverlayColorSpec = "diagnostic.info_fg";
128
+ const STYLE_REJECTED: OverlayColorSpec = "diagnostic.error_fg";
129
+ const STYLE_QUESTION: OverlayColorSpec = "diagnostic.warning_fg";
103
130
 
104
131
  /**
105
132
  * Calculate UTF-8 byte length of a string manually since TextEncoder is not available
@@ -166,11 +193,8 @@ function diffStrings(oldStr: string, newStr: string): DiffPart[] {
166
193
  return coalesced;
167
194
  }
168
195
 
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');
196
+ function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
197
+ const lines = stdout.split('\n');
174
198
  const hunks: Hunk[] = [];
175
199
  let currentFile = "";
176
200
  let currentHunk: Hunk | null = null;
@@ -189,7 +213,7 @@ async function getGitDiff(): Promise<Hunk[]> {
189
213
  const oldStart = parseInt(match[1]);
190
214
  const newStart = parseInt(match[2]);
191
215
  currentHunk = {
192
- id: `${currentFile}:${newStart}`,
216
+ id: `${currentFile}:${newStart}:${gitStatus}`,
193
217
  file: currentFile,
194
218
  range: { start: newStart, end: newStart },
195
219
  oldRange: { start: oldStart, end: oldStart },
@@ -198,7 +222,8 @@ async function getGitDiff(): Promise<Hunk[]> {
198
222
  status: 'pending',
199
223
  reviewStatus: 'pending',
200
224
  contextHeader: match[3]?.trim() || "",
201
- byteOffset: 0
225
+ byteOffset: 0,
226
+ gitStatus
202
227
  };
203
228
  hunks.push(currentHunk);
204
229
  }
@@ -211,440 +236,626 @@ async function getGitDiff(): Promise<Hunk[]> {
211
236
  return hunks;
212
237
  }
213
238
 
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;
239
+ // --- Git status detection ---
240
+
241
+ /**
242
+ * Parse `git status --porcelain -z` output into FileEntry[].
243
+ *
244
+ * Format: each entry is "XY path\0" where X = index status, Y = worktree status.
245
+ * Renames/copies add "origPath\0" after the entry.
246
+ * A file can appear in BOTH staged and unstaged if both X and Y are set.
247
+ */
248
+ function parseGitStatusPorcelain(raw: string): FileEntry[] {
249
+ const files: FileEntry[] = [];
250
+ if (!raw) return files;
251
+
252
+ // Split on null bytes
253
+ const parts = raw.split('\0');
254
+ let i = 0;
255
+ while (i < parts.length) {
256
+ const entry = parts[i];
257
+ if (entry.length < 3) { i++; continue; }
258
+
259
+ const x = entry[0]; // index (staged) status
260
+ const y = entry[1]; // worktree (unstaged) status
261
+ // entry[2] is a space
262
+ const path = entry.slice(3);
263
+
264
+ if (!path) { i++; continue; }
265
+
266
+ // Check for rename/copy — next part is the original path
267
+ let origPath: string | undefined;
268
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
269
+ i++;
270
+ origPath = parts[i];
271
+ }
272
+
273
+ // Untracked files: XY = '??'
274
+ if (x === '?' && y === '?') {
275
+ files.push({ path, status: '?', category: 'untracked' });
276
+ i++;
277
+ continue;
278
+ }
279
+
280
+ // Ignored files: XY = '!!' — skip
281
+ if (x === '!' && y === '!') {
282
+ i++;
283
+ continue;
284
+ }
285
+
286
+ // Staged changes: X is not ' ' and not '?'
287
+ if (x !== ' ' && x !== '?') {
288
+ files.push({ path, status: x, category: 'staged', origPath });
289
+ }
290
+
291
+ // Unstaged changes: Y is not ' ' and not '?'
292
+ if (y !== ' ' && y !== '?') {
293
+ files.push({ path, status: y, category: 'unstaged', origPath });
294
+ }
295
+
296
+ i++;
297
+ }
298
+
299
+ // Sort: staged → unstaged → untracked, then by filename
300
+ const categoryOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
301
+ files.sort((a, b) => {
302
+ const orderA = categoryOrder[a.category] ?? 2;
303
+ const orderB = categoryOrder[b.category] ?? 2;
304
+ if (orderA !== orderB) return orderA - orderB;
305
+ return a.path.localeCompare(b.path);
306
+ });
307
+
308
+ return files;
221
309
  }
222
310
 
223
311
  /**
224
- * Render the Review Stream buffer content and return highlight tasks
312
+ * Single source of truth for changed files using `git status --porcelain -z`.
225
313
  */
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
- }
405
- }
314
+ async function getGitStatus(): Promise<FileEntry[]> {
315
+ const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
316
+ if (result.exit_code !== 0) return [];
317
+ return parseGitStatusPorcelain(result.stdout);
318
+ }
406
319
 
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
- }
320
+ /**
321
+ * Fetch unified diffs for the given file entries.
322
+ * Groups by category to minimize git invocations.
323
+ */
324
+ async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
325
+ const allHunks: Hunk[] = [];
326
+
327
+ const hasStaged = files.some(f => f.category === 'staged');
328
+ const hasUnstaged = files.some(f => f.category === 'unstaged');
329
+ const untrackedFiles = files.filter(f => f.category === 'untracked');
330
+
331
+ // Staged diffs
332
+ if (hasStaged) {
333
+ const result = await editor.spawnProcess("git", ["diff", "--cached", "--unified=3"]);
334
+ if (result.exit_code === 0 && result.stdout.trim()) {
335
+ allHunks.push(...parseDiffOutput(result.stdout, 'staged'));
336
+ }
337
+ }
338
+
339
+ // Unstaged diffs
340
+ if (hasUnstaged) {
341
+ const result = await editor.spawnProcess("git", ["diff", "--unified=3"]);
342
+ if (result.exit_code === 0 && result.stdout.trim()) {
343
+ allHunks.push(...parseDiffOutput(result.stdout, 'unstaged'));
344
+ }
345
+ }
346
+
347
+ // Untracked file diffs
348
+ for (const f of untrackedFiles) {
349
+ const result = await editor.spawnProcess("git", [
350
+ "diff", "--no-index", "--unified=3", "/dev/null", f.path
351
+ ]);
352
+ if (result.stdout.trim()) {
353
+ const hunks = parseDiffOutput(result.stdout, 'untracked');
354
+ for (const h of hunks) {
355
+ h.file = f.path;
356
+ h.id = `${f.path}:${h.range.start}:untracked`;
357
+ h.type = 'add';
423
358
  }
359
+ allHunks.push(...hunks);
360
+ }
361
+ }
424
362
 
425
- i++;
363
+ // Sort: staged → unstaged → untracked, then by filename
364
+ const statusOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
365
+ allHunks.sort((a, b) => {
366
+ const orderA = statusOrder[a.gitStatus || 'unstaged'];
367
+ const orderB = statusOrder[b.gitStatus || 'unstaged'];
368
+ if (orderA !== orderB) return orderA - orderB;
369
+ return a.file.localeCompare(b.file);
370
+ });
371
+
372
+ return allHunks;
373
+ }
374
+
375
+ // --- New magit-style rendering (Step 2 of rewrite) ---
376
+
377
+ const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
378
+ const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
379
+ const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
380
+ const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
381
+
382
+ interface ListLine {
383
+ text: string;
384
+ type: 'section-header' | 'file';
385
+ fileIndex?: number; // index into state.files[]
386
+ style?: Partial<OverlayOptions>;
387
+ inlineOverlays?: InlineOverlay[];
388
+ }
389
+
390
+ interface DiffLine {
391
+ text: string;
392
+ type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty';
393
+ style?: Partial<OverlayOptions>;
394
+ inlineOverlays?: InlineOverlay[];
395
+ }
396
+
397
+ /**
398
+ * Build the file list lines for the left panel.
399
+ * Returns section headers (not selectable) and file entries.
400
+ */
401
+ function buildFileListLines(): ListLine[] {
402
+ const lines: ListLine[] = [];
403
+ let lastCategory: string | undefined;
404
+
405
+ for (let i = 0; i < state.files.length; i++) {
406
+ const f = state.files[i];
407
+ // Section headers
408
+ if (f.category !== lastCategory) {
409
+ lastCategory = f.category;
410
+ let label = '';
411
+ if (f.category === 'staged') label = editor.t("section.staged") || "Staged";
412
+ else if (f.category === 'unstaged') label = editor.t("section.unstaged") || "Changes";
413
+ else if (f.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
414
+ lines.push({
415
+ text: `▸ ${label}`,
416
+ type: 'section-header',
417
+ style: { fg: STYLE_SECTION_HEADER, bold: true },
418
+ });
419
+ }
420
+
421
+ // Status icon
422
+ const statusIcon = f.status === '?' ? 'A' : f.status;
423
+ const prefix = i === state.selectedIndex ? '>' : ' ';
424
+ const filename = f.origPath ? `${f.origPath} → ${f.path}` : f.path;
425
+ lines.push({
426
+ text: `${prefix}${statusIcon} ${filename}`,
427
+ type: 'file',
428
+ fileIndex: i,
429
+ });
430
+ }
431
+
432
+ return lines;
433
+ }
434
+
435
+ /**
436
+ * Build the diff lines for the right panel based on currently selected file.
437
+ */
438
+ function buildDiffLines(rightWidth: number): DiffLine[] {
439
+ const lines: DiffLine[] = [];
440
+ if (state.files.length === 0) return lines;
441
+
442
+ const selectedFile = state.files[state.selectedIndex];
443
+ if (!selectedFile) return lines;
444
+
445
+ // Find hunks matching the selected file and category
446
+ const fileHunks = state.hunks.filter(
447
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
448
+ );
449
+
450
+ if (fileHunks.length === 0) {
451
+ if (selectedFile.status === 'R' && selectedFile.origPath) {
452
+ lines.push({ text: `Renamed from ${selectedFile.origPath}`, type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
453
+ } else if (selectedFile.status === 'D') {
454
+ lines.push({ text: "(file deleted)", type: 'empty' });
455
+ } else if (selectedFile.status === 'T') {
456
+ lines.push({ text: "(type change: file ↔ symlink)", type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
457
+ } else if (selectedFile.status === '?' && selectedFile.path.endsWith('/')) {
458
+ lines.push({ text: "(untracked directory)", type: 'empty' });
426
459
  } 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 });
439
- }
460
+ lines.push({ text: "(no diff available)", type: 'empty' });
461
+ }
462
+ return lines;
463
+ }
464
+
465
+ for (const hunk of fileHunks) {
466
+ // Hunk header
467
+ const header = hunk.contextHeader
468
+ ? `@@ ${hunk.contextHeader} @@`
469
+ : `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
470
+ lines.push({
471
+ text: header,
472
+ type: 'hunk-header',
473
+ style: { fg: STYLE_HUNK_HEADER, bold: true },
474
+ });
475
+
476
+ // Diff content lines — only set background color so the normal editor
477
+ // foreground stays readable across all themes. The bg uses theme-aware
478
+ // diff colors that each theme can customize.
479
+ for (const line of hunk.lines) {
480
+ const prefix = line[0];
481
+ if (prefix === '+') {
482
+ lines.push({
483
+ text: line,
484
+ type: 'add',
485
+ style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
486
+ });
487
+ } else if (prefix === '-') {
488
+ lines.push({
489
+ text: line,
490
+ type: 'remove',
491
+ style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
492
+ });
440
493
  } 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;
474
- }
494
+ lines.push({
495
+ text: line,
496
+ type: 'context',
497
+ });
475
498
  }
476
499
  }
477
500
  }
478
501
 
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;
501
- }
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 };
502
+ return lines;
548
503
  }
549
504
 
550
505
  /**
551
- * Updates the buffer UI (text and highlights) based on current state.hunks
506
+ * Build the full display as exactly viewportHeight lines.
507
+ * Layout:
508
+ * Row 0: Toolbar (shortcuts)
509
+ * Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
510
+ * Rows 2..H-1: Main content (left file list, │ divider, right diff)
552
511
  */
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,
565
- });
512
+ function buildMagitDisplayEntries(): TextPropertyEntry[] {
513
+ const entries: TextPropertyEntry[] = [];
514
+ const H = state.viewportHeight;
515
+ const W = state.viewportWidth;
516
+ const leftWidth = Math.max(28, Math.floor(W * 0.3));
517
+ const rightWidth = W - leftWidth - 1; // 1 for divider
518
+
519
+ const allFileLines = buildFileListLines();
520
+ const diffLines = buildDiffLines(rightWidth);
521
+
522
+ const mainRows = H - 2; // rows 2..H-1
523
+
524
+ // --- File list scrolling ---
525
+ let selectedLineIdx = -1;
526
+ for (let i = 0; i < allFileLines.length; i++) {
527
+ if (allFileLines[i].type === 'file' && allFileLines[i].fileIndex === state.selectedIndex) {
528
+ selectedLineIdx = i;
529
+ break;
530
+ }
531
+ }
532
+ if (selectedLineIdx >= 0) {
533
+ if (selectedLineIdx < state.fileScrollOffset) {
534
+ state.fileScrollOffset = selectedLineIdx;
535
+ }
536
+ if (selectedLineIdx >= state.fileScrollOffset + mainRows) {
537
+ state.fileScrollOffset = selectedLineIdx - mainRows + 1;
538
+ }
539
+ }
540
+ const maxFileOffset = Math.max(0, allFileLines.length - mainRows);
541
+ if (state.fileScrollOffset > maxFileOffset) state.fileScrollOffset = maxFileOffset;
542
+ if (state.fileScrollOffset < 0) state.fileScrollOffset = 0;
543
+
544
+ const visibleFileLines = allFileLines.slice(state.fileScrollOffset, state.fileScrollOffset + mainRows);
545
+
546
+ // --- Diff scrolling ---
547
+ const maxDiffOffset = Math.max(0, diffLines.length - mainRows);
548
+ if (state.diffScrollOffset > maxDiffOffset) state.diffScrollOffset = maxDiffOffset;
549
+ if (state.diffScrollOffset < 0) state.diffScrollOffset = 0;
550
+
551
+ const visibleDiffLines = diffLines.slice(state.diffScrollOffset, state.diffScrollOffset + mainRows);
552
+
553
+ // --- Row 0: Toolbar ---
554
+ const toolbar = " [Tab] Switch Panel [s] Stage [u] Unstage [d] Discard [Enter] Drill-Down [r] Refresh";
555
+ entries.push({
556
+ text: toolbar.substring(0, W).padEnd(W) + "\n",
557
+ style: { fg: STYLE_FOOTER, bg: "ui.status_bar_bg" as OverlayColorSpec, extendToLineEnd: true },
558
+ properties: { type: "toolbar" },
566
559
  });
567
- }
560
+
561
+ // --- Row 1: Header ---
562
+ const selectedFile = state.files[state.selectedIndex];
563
+ const focusLeft = state.focusPanel === 'files';
564
+ const leftHeader = " GIT STATUS";
565
+ const rightHeader = selectedFile
566
+ ? ` DIFF FOR ${selectedFile.path}`
567
+ : " DIFF";
568
+ const leftHeaderPadded = leftHeader.padEnd(leftWidth).substring(0, leftWidth);
569
+ const rightHeaderPadded = rightHeader.substring(0, rightWidth);
570
+
571
+ const leftHeaderStyle: Partial<OverlayOptions> = focusLeft
572
+ ? { fg: STYLE_HEADER, bold: true, underline: true }
573
+ : { fg: STYLE_DIVIDER };
574
+ const rightHeaderStyle: Partial<OverlayOptions> = focusLeft
575
+ ? { fg: STYLE_DIVIDER }
576
+ : { fg: STYLE_HEADER, bold: true, underline: true };
577
+
578
+ entries.push({ text: leftHeaderPadded, style: leftHeaderStyle, properties: { type: "header" } });
579
+ entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
580
+ entries.push({ text: rightHeaderPadded, style: rightHeaderStyle, properties: { type: "header" } });
581
+ entries.push({ text: "\n", properties: { type: "newline" } });
582
+
583
+ // --- Rows 2..H-1: Main content ---
584
+ for (let i = 0; i < mainRows; i++) {
585
+ const fileItem = visibleFileLines[i];
586
+ const diffItem = visibleDiffLines[i];
587
+
588
+ // Left panel
589
+ const leftText = fileItem ? (" " + fileItem.text) : "";
590
+ const leftPadded = leftText.padEnd(leftWidth).substring(0, leftWidth);
591
+ const isSelected = fileItem?.type === 'file' && fileItem.fileIndex === state.selectedIndex;
592
+
593
+ const leftEntry: TextPropertyEntry = {
594
+ text: leftPadded,
595
+ properties: {
596
+ type: fileItem?.type || "blank",
597
+ fileIndex: fileItem?.fileIndex,
598
+ },
599
+ style: fileItem?.style,
600
+ inlineOverlays: fileItem?.inlineOverlays,
601
+ };
602
+ if (isSelected) {
603
+ leftEntry.style = { ...(leftEntry.style || {}), bg: STYLE_SELECTED_BG, bold: true };
604
+ }
605
+ entries.push(leftEntry);
606
+
607
+ // Divider
608
+ entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
609
+
610
+ // Right panel — when diff panel is focused, highlight the top line as cursor
611
+ const rightText = diffItem ? (" " + diffItem.text) : "";
612
+ const rightTruncated = rightText.substring(0, rightWidth);
613
+ const isDiffCursorLine = !focusLeft && i === 0 && diffItem != null;
614
+ const rightStyle = isDiffCursorLine
615
+ ? { ...(diffItem?.style || {}), bg: STYLE_SELECTED_BG, extendToLineEnd: true }
616
+ : diffItem?.style;
617
+ entries.push({
618
+ text: rightTruncated,
619
+ properties: { type: diffItem?.type || "blank" },
620
+ style: rightStyle,
621
+ inlineOverlays: diffItem?.inlineOverlays,
622
+ });
623
+
624
+ // Newline
625
+ entries.push({ text: "\n", properties: { type: "newline" } });
626
+ }
627
+
628
+ return entries;
568
629
  }
569
630
 
570
631
  /**
571
- * Fetches latest diff data and refreshes the UI
632
+ * Refresh the display rebuild entries and set buffer content.
633
+ * Always re-queries viewport dimensions to handle sidebar toggles and splits.
572
634
  */
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;
635
+ function updateMagitDisplay(): void {
636
+ if (state.reviewBufferId === null) return;
637
+ refreshViewportDimensions();
638
+ const entries = buildMagitDisplayEntries();
639
+ editor.clearNamespace(state.reviewBufferId, "review-diff");
640
+ editor.setVirtualBufferContent(state.reviewBufferId, entries);
641
+ }
642
+
643
+ function review_refresh() { refreshMagitData(); }
644
+ registerHandler("review_refresh", review_refresh);
645
+
646
+ // --- New magit navigation handlers (Step 3) ---
647
+
648
+ function review_nav_up() {
649
+ if (state.focusPanel === 'files') {
650
+ if (state.files.length === 0) return;
651
+ if (state.selectedIndex > 0) {
652
+ state.selectedIndex--;
653
+ state.diffScrollOffset = 0;
654
+ updateMagitDisplay();
655
+ }
656
+ } else {
657
+ state.diffScrollOffset = Math.max(0, state.diffScrollOffset - 1);
658
+ updateMagitDisplay();
659
+ }
660
+ }
661
+ registerHandler("review_nav_up", review_nav_up);
662
+
663
+ function review_nav_down() {
664
+ if (state.focusPanel === 'files') {
665
+ if (state.files.length === 0) return;
666
+ if (state.selectedIndex < state.files.length - 1) {
667
+ state.selectedIndex++;
668
+ state.diffScrollOffset = 0;
669
+ updateMagitDisplay();
670
+ }
671
+ } else {
672
+ state.diffScrollOffset++;
673
+ updateMagitDisplay();
674
+ }
675
+ }
676
+ registerHandler("review_nav_down", review_nav_down);
677
+
678
+ function review_page_up() {
679
+ const mainRows = state.viewportHeight - 2;
680
+ if (state.focusPanel === 'files') {
681
+ if (state.selectedIndex > 0) {
682
+ state.selectedIndex = Math.max(0, state.selectedIndex - mainRows);
683
+ state.diffScrollOffset = 0;
684
+ updateMagitDisplay();
685
+ }
686
+ } else {
687
+ state.diffScrollOffset = Math.max(0, state.diffScrollOffset - mainRows);
688
+ updateMagitDisplay();
587
689
  }
588
690
  }
691
+ registerHandler("review_page_up", review_page_up);
692
+
693
+ function review_page_down() {
694
+ const mainRows = state.viewportHeight - 2;
695
+ if (state.focusPanel === 'files') {
696
+ if (state.selectedIndex < state.files.length - 1) {
697
+ state.selectedIndex = Math.min(state.files.length - 1, state.selectedIndex + mainRows);
698
+ state.diffScrollOffset = 0;
699
+ updateMagitDisplay();
700
+ }
701
+ } else {
702
+ state.diffScrollOffset += mainRows;
703
+ updateMagitDisplay();
704
+ }
705
+ }
706
+ registerHandler("review_page_down", review_page_down);
589
707
 
590
- // --- Actions ---
708
+ function review_toggle_focus() {
709
+ state.focusPanel = state.focusPanel === 'files' ? 'diff' : 'files';
710
+ updateMagitDisplay();
711
+ }
712
+ registerHandler("review_toggle_focus", review_toggle_focus);
591
713
 
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();
714
+ function review_focus_files() {
715
+ if (state.focusPanel !== 'files') {
716
+ state.focusPanel = 'files';
717
+ updateMagitDisplay();
600
718
  }
601
719
  }
602
- registerHandler("review_stage_hunk", review_stage_hunk);
720
+ registerHandler("review_focus_files", review_focus_files);
603
721
 
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();
722
+ function review_focus_diff() {
723
+ if (state.focusPanel !== 'diff') {
724
+ state.focusPanel = 'diff';
725
+ updateMagitDisplay();
726
+ }
727
+ }
728
+ registerHandler("review_focus_diff", review_focus_diff);
729
+
730
+ function review_nav_home() {
731
+ if (state.focusPanel === 'files') {
732
+ if (state.files.length === 0) return;
733
+ state.selectedIndex = 0;
734
+ state.diffScrollOffset = 0;
735
+ updateMagitDisplay();
736
+ } else {
737
+ state.diffScrollOffset = 0;
738
+ updateMagitDisplay();
739
+ }
740
+ }
741
+ registerHandler("review_nav_home", review_nav_home);
742
+
743
+ function review_nav_end() {
744
+ if (state.focusPanel === 'files') {
745
+ if (state.files.length === 0) return;
746
+ state.selectedIndex = state.files.length - 1;
747
+ state.diffScrollOffset = 0;
748
+ updateMagitDisplay();
749
+ } else {
750
+ // Scroll diff to bottom
751
+ const mainRows = state.viewportHeight - 2;
752
+ const selectedFile = state.files[state.selectedIndex];
753
+ if (selectedFile) {
754
+ const diffLines = buildDiffLines(state.viewportWidth - Math.max(28, Math.floor(state.viewportWidth * 0.3)) - 1);
755
+ state.diffScrollOffset = Math.max(0, diffLines.length - mainRows);
756
+ }
757
+ updateMagitDisplay();
612
758
  }
613
759
  }
614
- registerHandler("review_discard_hunk", review_discard_hunk);
760
+ registerHandler("review_nav_end", review_nav_end);
761
+
762
+ // --- Real git stage/unstage/discard actions (Step 4) ---
763
+
764
+ async function review_stage_file() {
765
+ if (state.files.length === 0) return;
766
+ const f = state.files[state.selectedIndex];
767
+ if (!f) return;
768
+ await editor.spawnProcess("git", ["add", "--", f.path]);
769
+ await refreshMagitData();
770
+ }
771
+ registerHandler("review_stage_file", review_stage_file);
772
+
773
+ async function review_unstage_file() {
774
+ if (state.files.length === 0) return;
775
+ const f = state.files[state.selectedIndex];
776
+ if (!f) return;
777
+ await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
778
+ await refreshMagitData();
779
+ }
780
+ registerHandler("review_unstage_file", review_unstage_file);
781
+
782
+ function review_discard_file() {
783
+ if (state.files.length === 0) return;
784
+ const f = state.files[state.selectedIndex];
785
+ if (!f) return;
786
+
787
+ // Show confirmation prompt — discard is destructive and irreversible
788
+ const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
789
+ editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
790
+ const suggestions: PromptSuggestion[] = [
791
+ { text: `${action} file`, description: "Permanently lose changes", value: "discard" },
792
+ { text: "Cancel", description: "Keep the file as-is", value: "cancel" },
793
+ ];
794
+ editor.setPromptSuggestions(suggestions);
795
+ }
796
+ registerHandler("review_discard_file", review_discard_file);
615
797
 
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();
798
+ async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
799
+ if (args.prompt_type !== "review-discard-confirm") return true;
800
+
801
+ const response = args.input.trim().toLowerCase();
802
+ if (response === "discard" || args.selected_index === 0) {
803
+ const f = state.files[state.selectedIndex];
804
+ if (f) {
805
+ if (f.category === 'untracked') {
806
+ await editor.spawnProcess("rm", ["--", f.path]);
807
+ } else {
808
+ await editor.spawnProcess("git", ["checkout", "--", f.path]);
809
+ }
810
+ await refreshMagitData();
811
+ editor.setStatus(`Discarded: ${f.path}`);
812
+ }
813
+ } else {
814
+ editor.setStatus("Discard cancelled");
624
815
  }
816
+ return false;
625
817
  }
626
- registerHandler("review_undo_action", review_undo_action);
818
+ registerHandler("on_review_discard_confirm", on_review_discard_confirm);
627
819
 
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);
820
+ /**
821
+ * Refresh file list and diffs using the new git status approach, then re-render.
822
+ */
823
+ async function refreshMagitData() {
824
+ const files = await getGitStatus();
825
+ state.files = files;
826
+ state.hunks = await fetchDiffsForFiles(files);
827
+ // Clamp selectedIndex
828
+ if (state.selectedIndex >= state.files.length) {
829
+ state.selectedIndex = Math.max(0, state.files.length - 1);
830
+ }
831
+ state.diffScrollOffset = 0;
832
+ updateMagitDisplay();
634
833
  }
635
- registerHandler("review_next_hunk", review_next_hunk);
636
834
 
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);
835
+ // --- Resize handler ---
836
+
837
+ /**
838
+ * Refresh viewport dimensions from the actual split viewport.
839
+ * This accounts for sidebars (file explorer) that reduce available width,
840
+ * unlike the terminal-level resize event which reports full terminal size.
841
+ */
842
+ function refreshViewportDimensions(): boolean {
843
+ const viewport = editor.getViewport();
844
+ if (viewport) {
845
+ const changed = viewport.width !== state.viewportWidth || viewport.height !== state.viewportHeight;
846
+ state.viewportWidth = viewport.width;
847
+ state.viewportHeight = viewport.height;
848
+ return changed;
849
+ }
850
+ return false;
643
851
  }
644
- registerHandler("review_prev_hunk", review_prev_hunk);
645
852
 
646
- function review_refresh() { refreshReviewData(); }
647
- registerHandler("review_refresh", review_refresh);
853
+ function onReviewDiffResize(_data: { width: number; height: number }): void {
854
+ if (state.reviewBufferId === null) return;
855
+ refreshViewportDimensions();
856
+ updateMagitDisplay();
857
+ }
858
+ registerHandler("onReviewDiffResize", onReviewDiffResize);
648
859
 
649
860
  let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
650
861
 
@@ -848,6 +1059,15 @@ function computeFullFileAlignedDiff(oldContent: string, newContent: string, hunk
848
1059
  return aligned;
849
1060
  }
850
1061
 
1062
+ interface HighlightTask {
1063
+ range: [number, number];
1064
+ fg: OverlayColorSpec;
1065
+ bg?: OverlayColorSpec;
1066
+ bold?: boolean;
1067
+ italic?: boolean;
1068
+ extend_to_line_end?: boolean;
1069
+ }
1070
+
851
1071
  /**
852
1072
  * Generate virtual buffer content with diff highlighting for one side.
853
1073
  * Returns entries, highlight tasks, and line byte offsets for scroll sync.
@@ -1025,174 +1245,188 @@ interface CompositeDiffState {
1025
1245
  let activeCompositeDiffState: CompositeDiffState | null = null;
1026
1246
 
1027
1247
  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;
1248
+ // Use selected file from magit state instead of cursor properties
1249
+ if (state.files.length === 0) return;
1250
+ const selectedFile = state.files[state.selectedIndex];
1251
+ if (!selectedFile) return;
1034
1252
 
1035
- editor.setStatus(editor.t("status.loading_diff"));
1253
+ // Create a minimal hunk-like reference for the rest of the function
1254
+ const h = { file: selectedFile.path, gitStatus: selectedFile.category };
1036
1255
 
1037
- // Get all hunks for this file
1038
- const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
1039
-
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);
1256
+ editor.setStatus(editor.t("status.loading_diff"));
1048
1257
 
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;
1258
+ // Get all hunks for this file
1259
+ const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
1260
+ if (fileHunks.length === 0) return;
1056
1261
 
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;
1062
- }
1262
+ // Get git root to construct absolute path
1263
+ const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
1264
+ if (gitRootResult.exit_code !== 0) {
1265
+ editor.setStatus(editor.t("status.not_git_repo"));
1266
+ return;
1267
+ }
1268
+ const gitRoot = gitRootResult.stdout.trim();
1269
+ const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
1063
1270
 
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);
1069
- }
1070
- editor.closeBuffer(activeSideBySideState.oldBufferId);
1071
- editor.closeBuffer(activeSideBySideState.newBufferId);
1072
- } catch {}
1073
- activeSideBySideState = null;
1074
- }
1271
+ // Get old (HEAD) and new (working) file content
1272
+ let oldContent: string;
1273
+ const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
1274
+ if (gitShow.exit_code !== 0) {
1275
+ oldContent = "";
1276
+ } else {
1277
+ oldContent = gitShow.stdout;
1278
+ }
1075
1279
 
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
- }
1280
+ // Read new file content (use absolute path for readFile)
1281
+ const newContent = await editor.readFile(absoluteFilePath);
1282
+ if (newContent === null) {
1283
+ editor.setStatus(editor.t("status.failed_new_version"));
1284
+ return;
1285
+ }
1085
1286
 
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++; }
1287
+ // Close any existing side-by-side views (old split-based approach)
1288
+ if (activeSideBySideState) {
1289
+ try {
1290
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1291
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1131
1292
  }
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
- });
1293
+ editor.closeBuffer(activeSideBySideState.oldBufferId);
1294
+ editor.closeBuffer(activeSideBySideState.newBufferId);
1295
+ } catch {}
1296
+ activeSideBySideState = null;
1297
+ }
1298
+
1299
+ // Close any existing composite diff view
1300
+ if (activeCompositeDiffState) {
1301
+ try {
1302
+ editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId);
1303
+ editor.closeBuffer(activeCompositeDiffState.oldBufferId);
1304
+ editor.closeBuffer(activeCompositeDiffState.newBufferId);
1305
+ } catch {}
1306
+ activeCompositeDiffState = null;
1307
+ }
1308
+
1309
+ // Create virtual buffers for old and new content
1310
+ const oldLines = oldContent.split('\n');
1311
+ const newLines = newContent.split('\n');
1312
+
1313
+ const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
1314
+ text: line + '\n',
1315
+ properties: { type: 'line', lineNum: idx + 1 }
1316
+ }));
1317
+
1318
+ const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
1319
+ text: line + '\n',
1320
+ properties: { type: 'line', lineNum: idx + 1 }
1321
+ }));
1322
+
1323
+ // Create source buffers (hidden from tabs, used by composite)
1324
+ const oldResult = await editor.createVirtualBuffer({
1325
+ name: `*OLD:${h.file}*`,
1326
+ mode: "normal",
1327
+ readOnly: true,
1328
+ entries: oldEntries,
1329
+ showLineNumbers: true,
1330
+ editingDisabled: true,
1331
+ hiddenFromTabs: true
1332
+ });
1333
+ const oldBufferId = oldResult.bufferId;
1334
+
1335
+ const newResult = await editor.createVirtualBuffer({
1336
+ name: `*NEW:${h.file}*`,
1337
+ mode: "normal",
1338
+ readOnly: true,
1339
+ entries: newEntries,
1340
+ showLineNumbers: true,
1341
+ editingDisabled: true,
1342
+ hiddenFromTabs: true
1343
+ });
1344
+ const newBufferId = newResult.bufferId;
1345
+
1346
+ // Convert hunks to composite buffer format (parse counts from git diff)
1347
+ const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => {
1348
+ let oldCount = 0, newCount = 0;
1349
+ for (const line of fh.lines) {
1350
+ if (line.startsWith('-')) oldCount++;
1351
+ else if (line.startsWith('+')) newCount++;
1352
+ else if (line.startsWith(' ')) { oldCount++; newCount++; }
1353
+ }
1354
+ return {
1355
+ oldStart: Math.max(0, fh.oldRange.start - 1),
1356
+ oldCount: oldCount || 1,
1357
+ newStart: Math.max(0, fh.range.start - 1),
1358
+ newCount: newCount || 1
1359
+ };
1360
+ });
1139
1361
 
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
1362
+ // Create composite buffer with side-by-side layout
1363
+ const compositeBufferId = await editor.createCompositeBuffer({
1364
+ name: `*Diff: ${h.file}*`,
1365
+ mode: "diff-view",
1366
+ layout: {
1367
+ type: "side-by-side",
1368
+ ratios: [0.5, 0.5],
1369
+ showSeparator: true
1370
+ },
1371
+ sources: [
1372
+ {
1373
+ bufferId: oldBufferId,
1374
+ label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
1375
+ editable: false,
1376
+ style: {
1377
+ gutterStyle: "diff-markers"
1378
+ }
1148
1379
  },
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
- }
1380
+ {
1381
+ bufferId: newBufferId,
1382
+ label: "NEW (Working)",
1383
+ editable: false,
1384
+ style: {
1385
+ gutterStyle: "diff-markers"
1167
1386
  }
1168
- ],
1169
- hunks: compositeHunks.length > 0 ? compositeHunks : null
1170
- });
1387
+ }
1388
+ ],
1389
+ hunks: compositeHunks.length > 0 ? compositeHunks : null,
1390
+ initialFocusHunk: compositeHunks.length > 0 ? 0 : undefined
1391
+ });
1171
1392
 
1172
- // Store state for cleanup
1173
- activeCompositeDiffState = {
1174
- compositeBufferId,
1175
- oldBufferId,
1176
- newBufferId,
1177
- filePath: h.file
1178
- };
1393
+ // Store state for cleanup
1394
+ activeCompositeDiffState = {
1395
+ compositeBufferId,
1396
+ oldBufferId,
1397
+ newBufferId,
1398
+ filePath: h.file
1399
+ };
1179
1400
 
1180
- // Show the composite buffer (replaces the review diff buffer)
1181
- editor.showBuffer(compositeBufferId);
1401
+ // Show the composite buffer (replaces the review diff buffer)
1402
+ editor.showBuffer(compositeBufferId);
1182
1403
 
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);
1404
+ const addedCount = fileHunks.reduce((sum, fh) => {
1405
+ return sum + fh.lines.filter(l => l.startsWith('+')).length;
1406
+ }, 0);
1407
+ const removedCount = fileHunks.reduce((sum, fh) => {
1408
+ return sum + fh.lines.filter(l => l.startsWith('-')).length;
1409
+ }, 0);
1410
+ const modifiedCount = Math.min(addedCount, removedCount);
1190
1411
 
1191
- editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
1192
- }
1412
+ editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
1193
1413
  }
1194
1414
  registerHandler("review_drill_down", review_drill_down);
1195
1415
 
1416
+ // --- Hunk navigation for side-by-side diff view ---
1417
+
1418
+ function review_next_hunk() {
1419
+ if (!activeCompositeDiffState) return;
1420
+ editor.compositeNextHunk(activeCompositeDiffState.compositeBufferId);
1421
+ }
1422
+ registerHandler("review_next_hunk", review_next_hunk);
1423
+
1424
+ function review_prev_hunk() {
1425
+ if (!activeCompositeDiffState) return;
1426
+ editor.compositePrevHunk(activeCompositeDiffState.compositeBufferId);
1427
+ }
1428
+ registerHandler("review_prev_hunk", review_prev_hunk);
1429
+
1196
1430
  // Define the diff-view mode - inherits from "normal" for all standard navigation/selection/copy
1197
1431
  // Only adds diff-specific keybindings (close, hunk navigation)
1198
1432
  editor.defineMode("diff-view", [
@@ -1208,10 +1442,14 @@ editor.defineMode("diff-view", [
1208
1442
  // --- Review Comment Actions ---
1209
1443
 
1210
1444
  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;
1445
+ // In magit mode, get the first hunk of the selected file
1446
+ if (state.files.length === 0) return null;
1447
+ const selectedFile = state.files[state.selectedIndex];
1448
+ if (!selectedFile) return null;
1449
+ const hunk = state.hunks.find(
1450
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1451
+ );
1452
+ return hunk?.id || null;
1215
1453
  }
1216
1454
 
1217
1455
  interface PendingCommentInfo {
@@ -1224,20 +1462,22 @@ interface PendingCommentInfo {
1224
1462
  }
1225
1463
 
1226
1464
  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
- };
1239
- }
1240
- return null;
1465
+ // In magit mode, get info from the selected file's first hunk
1466
+ if (state.files.length === 0) return null;
1467
+ const selectedFile = state.files[state.selectedIndex];
1468
+ if (!selectedFile) return null;
1469
+ const hunk = state.hunks.find(
1470
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1471
+ );
1472
+ if (!hunk) return null;
1473
+ return {
1474
+ hunkId: hunk.id,
1475
+ file: hunk.file,
1476
+ lineType: undefined,
1477
+ oldLine: undefined,
1478
+ newLine: undefined,
1479
+ lineContent: undefined
1480
+ };
1241
1481
  }
1242
1482
 
1243
1483
  // Pending prompt state for event-based prompt handling
@@ -1284,7 +1524,7 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
1284
1524
  line_type: pendingCommentInfo.lineType
1285
1525
  };
1286
1526
  state.comments.push(comment);
1287
- updateReviewUI();
1527
+ updateMagitDisplay();
1288
1528
  let lineRef = 'hunk';
1289
1529
  if (comment.line_type === 'add' && comment.new_line) {
1290
1530
  lineRef = `line +${comment.new_line}`;
@@ -1313,6 +1553,7 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
1313
1553
 
1314
1554
  // Register prompt event handlers
1315
1555
  editor.on("prompt_confirmed", "on_review_prompt_confirm");
1556
+ editor.on("prompt_confirmed", "on_review_discard_confirm");
1316
1557
  editor.on("prompt_cancelled", "on_review_prompt_cancel");
1317
1558
 
1318
1559
  async function review_approve_hunk() {
@@ -1321,7 +1562,7 @@ async function review_approve_hunk() {
1321
1562
  const h = state.hunks.find(x => x.id === hunkId);
1322
1563
  if (h) {
1323
1564
  h.reviewStatus = 'approved';
1324
- await updateReviewUI();
1565
+ updateMagitDisplay();
1325
1566
  editor.setStatus(editor.t("status.hunk_approved"));
1326
1567
  }
1327
1568
  }
@@ -1333,7 +1574,7 @@ async function review_reject_hunk() {
1333
1574
  const h = state.hunks.find(x => x.id === hunkId);
1334
1575
  if (h) {
1335
1576
  h.reviewStatus = 'rejected';
1336
- await updateReviewUI();
1577
+ updateMagitDisplay();
1337
1578
  editor.setStatus(editor.t("status.hunk_rejected"));
1338
1579
  }
1339
1580
  }
@@ -1345,7 +1586,7 @@ async function review_needs_changes() {
1345
1586
  const h = state.hunks.find(x => x.id === hunkId);
1346
1587
  if (h) {
1347
1588
  h.reviewStatus = 'needs_changes';
1348
- await updateReviewUI();
1589
+ updateMagitDisplay();
1349
1590
  editor.setStatus(editor.t("status.hunk_needs_changes"));
1350
1591
  }
1351
1592
  }
@@ -1357,7 +1598,7 @@ async function review_question_hunk() {
1357
1598
  const h = state.hunks.find(x => x.id === hunkId);
1358
1599
  if (h) {
1359
1600
  h.reviewStatus = 'question';
1360
- await updateReviewUI();
1601
+ updateMagitDisplay();
1361
1602
  editor.setStatus(editor.t("status.hunk_question"));
1362
1603
  }
1363
1604
  }
@@ -1369,7 +1610,7 @@ async function review_clear_status() {
1369
1610
  const h = state.hunks.find(x => x.id === hunkId);
1370
1611
  if (h) {
1371
1612
  h.reviewStatus = 'pending';
1372
- await updateReviewUI();
1613
+ updateMagitDisplay();
1373
1614
  editor.setStatus(editor.t("status.hunk_status_cleared"));
1374
1615
  }
1375
1616
  }
@@ -1499,17 +1740,33 @@ async function start_review_diff() {
1499
1740
  editor.setStatus(editor.t("status.generating"));
1500
1741
  editor.setContext("review-mode", true);
1501
1742
 
1502
- // Initial data fetch
1503
- const newHunks = await getGitDiff();
1504
- state.hunks = newHunks;
1505
- state.comments = []; // Reset comments for new session
1743
+ // Get viewport size
1744
+ const viewport = editor.getViewport();
1745
+ if (viewport) {
1746
+ state.viewportWidth = viewport.width;
1747
+ state.viewportHeight = viewport.height;
1748
+ }
1749
+
1750
+ // Fetch data using new git status approach
1751
+ state.files = await getGitStatus();
1752
+ state.hunks = await fetchDiffsForFiles(state.files);
1753
+ state.comments = [];
1754
+ state.selectedIndex = 0;
1755
+ state.fileScrollOffset = 0;
1756
+ state.diffScrollOffset = 0;
1757
+ state.focusPanel = 'files';
1758
+
1759
+ // Build initial display
1760
+ const initialEntries = buildMagitDisplayEntries();
1506
1761
 
1507
1762
  const bufferId = await VirtualBufferFactory.create({
1508
1763
  name: "*Review Diff*", mode: "review-mode", readOnly: true,
1509
- entries: (await renderReviewStream()).entries, showLineNumbers: false
1764
+ entries: initialEntries, showLineNumbers: false, showCursors: false
1510
1765
  });
1511
1766
  state.reviewBufferId = bufferId;
1512
- await updateReviewUI(); // Apply initial highlights
1767
+
1768
+ // Register resize handler
1769
+ editor.on("resize", "onReviewDiffResize");
1513
1770
 
1514
1771
  editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
1515
1772
  editor.on("buffer_activated", "on_review_buffer_activated");
@@ -1520,6 +1777,7 @@ registerHandler("start_review_diff", start_review_diff);
1520
1777
  function stop_review_diff() {
1521
1778
  state.reviewBufferId = null;
1522
1779
  editor.setContext("review-mode", false);
1780
+ editor.off("resize", "onReviewDiffResize");
1523
1781
  editor.off("buffer_activated", "on_review_buffer_activated");
1524
1782
  editor.off("buffer_closed", "on_review_buffer_closed");
1525
1783
  editor.setStatus(editor.t("status.stopped"));
@@ -1528,7 +1786,7 @@ registerHandler("stop_review_diff", stop_review_diff);
1528
1786
 
1529
1787
 
1530
1788
  function on_review_buffer_activated(data: any) {
1531
- if (data.buffer_id === state.reviewBufferId) refreshReviewData();
1789
+ if (data.buffer_id === state.reviewBufferId) refreshMagitData();
1532
1790
  }
1533
1791
  registerHandler("on_review_buffer_activated", on_review_buffer_activated);
1534
1792
 
@@ -1579,15 +1837,29 @@ async function side_by_side_diff_current_file() {
1579
1837
  }
1580
1838
  }
1581
1839
 
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;
1840
+ // Check if the file is untracked
1841
+ const isTrackedResult = await editor.spawnProcess("git", ["-C", gitRoot, "ls-files", "--", filePath]);
1842
+ const isUntracked = isTrackedResult.exit_code !== 0 || !isTrackedResult.stdout.trim();
1843
+
1844
+ // Get hunks for this specific file
1845
+ let diffOutput: string;
1846
+ if (isUntracked) {
1847
+ // For untracked files, use --no-index to diff against /dev/null
1848
+ const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "--no-index", "--unified=3", "--", "/dev/null", filePath]);
1849
+ // git diff --no-index exits with 1 when there are differences, which is expected
1850
+ diffOutput = result.stdout || "";
1851
+ } else {
1852
+ // For tracked files, use normal diff against HEAD
1853
+ const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "HEAD", "--unified=3", "--", filePath]);
1854
+ if (result.exit_code !== 0) {
1855
+ editor.setStatus(editor.t("status.failed_git_diff"));
1856
+ return;
1857
+ }
1858
+ diffOutput = result.stdout;
1587
1859
  }
1588
1860
 
1589
1861
  // Parse hunks from diff output
1590
- const lines = result.stdout.split('\n');
1862
+ const lines = diffOutput.split('\n');
1591
1863
  const fileHunks: Hunk[] = [];
1592
1864
  let currentHunk: Hunk | null = null;
1593
1865
 
@@ -1604,7 +1876,7 @@ async function side_by_side_diff_current_file() {
1604
1876
  file: filePath,
1605
1877
  range: { start: newStart, end: newStart + newCount - 1 },
1606
1878
  oldRange: { start: oldStart, end: oldStart + oldCount - 1 },
1607
- type: 'modify',
1879
+ type: isUntracked ? 'add' : 'modify',
1608
1880
  lines: [],
1609
1881
  status: 'pending',
1610
1882
  reviewStatus: 'pending',
@@ -1626,12 +1898,18 @@ async function side_by_side_diff_current_file() {
1626
1898
  }
1627
1899
 
1628
1900
  // 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;
1901
+ let oldContent: string;
1902
+ if (isUntracked) {
1903
+ // For untracked files, old content is empty (file didn't exist before)
1904
+ oldContent = "";
1905
+ } else {
1906
+ const gitShow = await editor.spawnProcess("git", ["-C", gitRoot, "show", `HEAD:${filePath}`]);
1907
+ if (gitShow.exit_code !== 0) {
1908
+ editor.setStatus(editor.t("status.failed_old_new_file"));
1909
+ return;
1910
+ }
1911
+ oldContent = gitShow.stdout;
1633
1912
  }
1634
- const oldContent = gitShow.stdout;
1635
1913
 
1636
1914
  // Read new file content (use absolute path for readFile)
1637
1915
  const newContent = await editor.readFile(absolutePath);
@@ -1701,9 +1979,9 @@ async function side_by_side_diff_current_file() {
1701
1979
 
1702
1980
  // Convert hunks to composite buffer format
1703
1981
  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
1982
+ oldStart: Math.max(0, h.oldRange.start - 1), // Convert to 0-indexed (0 for new files)
1983
+ oldCount: Math.max(1, h.oldRange.end - h.oldRange.start + 1),
1984
+ newStart: Math.max(0, h.range.start - 1), // Convert to 0-indexed
1707
1985
  newCount: h.range.end - h.range.start + 1
1708
1986
  }));
1709
1987
 
@@ -1719,10 +1997,9 @@ async function side_by_side_diff_current_file() {
1719
1997
  sources: [
1720
1998
  {
1721
1999
  bufferId: oldBufferId,
1722
- label: "OLD (HEAD)",
2000
+ label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
1723
2001
  editable: false,
1724
2002
  style: {
1725
- removeBg: [80, 40, 40],
1726
2003
  gutterStyle: "diff-markers"
1727
2004
  }
1728
2005
  },
@@ -1731,7 +2008,6 @@ async function side_by_side_diff_current_file() {
1731
2008
  label: "NEW (Working)",
1732
2009
  editable: false,
1733
2010
  style: {
1734
- addBg: [40, 80, 40],
1735
2011
  gutterStyle: "diff-markers"
1736
2012
  }
1737
2013
  }
@@ -1813,21 +2089,24 @@ registerHandler("on_buffer_closed", on_buffer_closed);
1813
2089
  editor.on("buffer_closed", "on_buffer_closed");
1814
2090
 
1815
2091
  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"],
2092
+ // Navigation (arrow keys, vim keys, page keys)
2093
+ ["Up", "review_nav_up"], ["Down", "review_nav_down"],
2094
+ ["k", "review_nav_up"], ["j", "review_nav_down"],
2095
+ ["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
2096
+ ["Home", "review_nav_home"], ["End", "review_nav_end"],
2097
+ ["Tab", "review_toggle_focus"],
2098
+ ["Left", "review_focus_files"], ["Right", "review_focus_diff"],
2099
+ // Drill-down
2100
+ ["Enter", "review_drill_down"],
2101
+ // Git actions (plain letter keys — safe because buffer is read-only with cursors hidden)
2102
+ ["s", "review_stage_file"], ["u", "review_unstage_file"],
2103
+ ["d", "review_discard_file"],
2104
+ ["r", "review_refresh"],
1821
2105
  // Review actions
1822
- ["c", "review_add_comment"],
1823
2106
  ["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"],
2107
+ ["c", "review_add_comment"],
1829
2108
  // Export
1830
- ["E", "review_export_session"],
2109
+ ["e", "review_export_session"],
1831
2110
  ], true);
1832
2111
 
1833
2112
  editor.debug("Review Diff plugin loaded with review comments support");