@fresh-editor/fresh-editor 0.2.22 → 0.2.24

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,23 @@
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";
12
+ import {
13
+ type GitCommit,
14
+ buildCommitDetailEntries,
15
+ buildCommitLogEntries,
16
+ buildDetailPlaceholderEntries,
17
+ fetchCommitShow,
18
+ fetchGitLog,
19
+ } from "./lib/git_history.ts";
15
20
  const VirtualBufferFactory = createVirtualBufferFactory(editor);
16
21
 
17
- /**
18
- * Hunk status for staging
19
- */
20
- type HunkStatus = 'pending' | 'staged' | 'discarded';
21
22
 
22
- /**
23
- * Review status for a hunk
24
- */
25
- type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
26
23
 
27
24
  /**
28
25
  * A review comment attached to a specific line in a file
@@ -57,8 +54,6 @@ interface Hunk {
57
54
  oldRange: { start: number; end: number }; // old file line range
58
55
  type: 'add' | 'remove' | 'modify';
59
56
  lines: string[];
60
- status: HunkStatus;
61
- reviewStatus: ReviewStatus;
62
57
  contextHeader: string;
63
58
  byteOffset: number; // Position in the virtual buffer
64
59
  gitStatus?: 'staged' | 'unstaged' | 'untracked';
@@ -76,38 +71,176 @@ interface FileEntry {
76
71
 
77
72
  /**
78
73
  * Review Session State
74
+ *
75
+ * Scrolling and cursor tracking inside the panel buffers is handled by the
76
+ * editor core natively — this state only mirrors what the plugin needs to
77
+ * know between events (selected file, focused panel, hunk header rows for
78
+ * `n`/`p` jumps).
79
+ */
80
+ /**
81
+ * Why the file list is empty. `null` means `state.files` has entries; the
82
+ * other two distinguish "cwd is not a git repo" from "repo is clean" so the
83
+ * panels can show a specific message instead of rendering byte-identically.
79
84
  */
85
+ type EmptyStateReason = 'not_git' | 'clean' | null;
86
+
87
+ /**
88
+ * Which slice of history the current review-diff session is inspecting.
89
+ *
90
+ * - `'worktree'`: the default mode — what `git status` reports right now.
91
+ * No single SHA fingerprints this mode (the working tree is volatile), so
92
+ * comments are keyed only by repo root and restored on a best-effort basis
93
+ * using `file`/`old_line`/`new_line`/`line_content`.
94
+ * - `'range'`: reviewing a static slice (single commit or `A..B` range).
95
+ * The diff is stable, so comments restore 1:1.
96
+ */
97
+ type ReviewMode = 'worktree' | 'range';
98
+
99
+ interface ReviewRange {
100
+ /** `git diff <from>` left-hand-side. */
101
+ from: string;
102
+ /** `git diff ... <to>` right-hand-side. */
103
+ to: string;
104
+ /** Human-readable label for status bar / layout name. */
105
+ label: string;
106
+ }
107
+
80
108
  interface ReviewState {
81
109
  hunks: Hunk[];
82
- hunkStatus: Record<string, HunkStatus>;
83
110
  comments: ReviewComment[];
84
- originalRequest?: string;
85
- overallFeedback?: string;
111
+ note: string;
86
112
  reviewBufferId: number | null;
87
- // New magit-style state
113
+ /** Review slice: working tree vs. static commit / range. */
114
+ mode: ReviewMode;
115
+ /** Populated when `mode === 'range'`. */
116
+ range: ReviewRange | null;
117
+ /** Absolute path to the git repo root — stable key for persistence. */
118
+ repoRoot: string;
119
+ /**
120
+ * Persistence key within the repo's review dir:
121
+ * `worktree` — `mode === 'worktree'`
122
+ * `range-<from>__<to>` — `mode === 'range'`
123
+ * Filename-safe characters only (see `sanitizeKeySegment`).
124
+ */
125
+ reviewKey: string;
126
+ // Files with changes (used for section grouping + headers in the
127
+ // unified stream). Order matches the order they appear in the diff.
88
128
  files: FileEntry[];
89
- selectedIndex: number;
90
- fileScrollOffset: number;
91
- diffScrollOffset: number;
129
+ emptyState: EmptyStateReason;
92
130
  viewportWidth: number;
93
131
  viewportHeight: number;
94
- focusPanel: 'files' | 'diff';
132
+ focusPanel: 'diff' | 'comments';
133
+ groupId: number | null;
134
+ panelBuffers: Record<string, number>;
135
+ // Caches populated each time the unified diff stream is rebuilt —
136
+ // used by `n`/`p` hunk navigation, to translate row numbers into byte
137
+ // positions for `setBufferCursor`, and to draw the cursor-line
138
+ // highlight overlay. `diffLineByteOffsets` has length `(rowCount + 1)`:
139
+ // index `i` is the byte offset of row `i + 1`, and the final entry is
140
+ // the total buffer length.
141
+ hunkHeaderRows: number[]; // 1-indexed row numbers in the unified buffer
142
+ diffLineByteOffsets: number[];
143
+ diffCursorRow: number; // 1-indexed, last known cursor row in diff buffer
144
+ // Maps file key (`${path}\0${category}`) -> 1-indexed row of the
145
+ // file-header row in the unified stream. Used by mouse/collapse/sticky.
146
+ fileHeaderRows: Record<string, number>;
147
+ // Files that are currently collapsed (`${path}\0${category}` keys).
148
+ // Persists across refreshes within a session; cleared on start_review_diff.
149
+ collapsedFiles: Set<string>;
150
+ // Sections (categories) that are currently collapsed. Same persistence
151
+ // rules as `collapsedFiles`.
152
+ collapsedSections: Set<string>;
153
+ // Hunks that are currently collapsed (`hunk.id` keys). When collapsed,
154
+ // only the hunk header row is emitted; the +/-/context lines are
155
+ // skipped. Same persistence rules as collapsedFiles.
156
+ collapsedHunks: Set<string>;
157
+ // Maps hunk-id -> 1-indexed row of its hunk-header row in the diff
158
+ // stream. Used by mouse + Tab to identify the nearest hunk.
159
+ hunkRowByHunkId: Record<string, number>;
160
+ // Maps comment-id -> 1-indexed row of the *diff line* the comment is
161
+ // attached to (not the comment-display row itself). Lets the comments
162
+ // panel jump the cursor straight to the source line.
163
+ diffLineRowByCommentId: Record<string, number>;
164
+ // Maps 1-indexed row -> the entry's properties. Lets handlers look up
165
+ // type / hunkId / fileKey / etc. by cursor row directly, bypassing
166
+ // editor.getTextPropertiesAtCursor (which can return the previous
167
+ // row's props when the cursor sits at a row-boundary byte).
168
+ entryPropsByRow: Record<number, Record<string, unknown>>;
169
+ // Byte ranges of collapsible bodies, captured at build time. Tab /
170
+ // mouse / z a / z r register these as host folds (see applyFolds)
171
+ // — no buffer rebuild on collapse / expand.
172
+ sectionBodyRange: Record<string, { start: number; end: number }>;
173
+ fileBodyRange: Record<string, { start: number; end: number }>;
174
+ hunkBodyRange: Record<string, { start: number; end: number }>;
175
+ // Maps a category name (`'staged'` etc.) -> 1-indexed row of its
176
+ // section-header row in the unified stream. Used by Tab toggle.
177
+ sectionHeaderRows: Record<string, number>;
178
+ // Maps a 1-indexed row in the comments panel -> comment id
179
+ commentsByRow: Record<number, string>;
180
+ // Current selection in the comments panel (1-indexed row, 0 means none)
181
+ commentsSelectedRow: number;
182
+ // Comment-id the diff cursor is sitting on / attached to. Drives the
183
+ // `>` follow-cursor marker in the comments panel.
184
+ commentsHighlightId: string | null;
185
+ // Sticky header current content (for Step 4)
186
+ stickyCurrentFile: string | null;
187
+ // Last known top-visible row in the diff viewport (1-indexed for
188
+ // consistency with hunkHeaderRows, even though the host event delivers
189
+ // 0-indexed). Updated from viewport_changed and cursor_moved.
190
+ diffViewportTopRow: number;
191
+ // Visual line-selection state. Active iff non-null. start and end are
192
+ // 1-indexed rows in the unified stream; hunkId pins the selection to
193
+ // a single hunk (selections that cross hunks are rejected).
194
+ lineSelection: { startRow: number; endRow: number; hunkId: string } | null;
95
195
  }
96
196
 
97
197
  const state: ReviewState = {
98
198
  hunks: [],
99
- hunkStatus: {},
100
199
  comments: [],
200
+ note: '',
101
201
  reviewBufferId: null,
202
+ mode: 'worktree',
203
+ range: null,
204
+ repoRoot: '',
205
+ reviewKey: 'worktree',
102
206
  files: [],
103
- selectedIndex: 0,
104
- fileScrollOffset: 0,
105
- diffScrollOffset: 0,
207
+ emptyState: null,
106
208
  viewportWidth: 80,
107
209
  viewportHeight: 24,
108
- focusPanel: 'files',
210
+ focusPanel: 'diff',
211
+ groupId: null,
212
+ panelBuffers: {},
213
+ hunkHeaderRows: [],
214
+ diffLineByteOffsets: [],
215
+ diffCursorRow: 1,
216
+ fileHeaderRows: {},
217
+ collapsedFiles: new Set(),
218
+ collapsedSections: new Set(),
219
+ collapsedHunks: new Set(),
220
+ hunkRowByHunkId: {},
221
+ diffLineRowByCommentId: {},
222
+ entryPropsByRow: {},
223
+ sectionBodyRange: {},
224
+ fileBodyRange: {},
225
+ hunkBodyRange: {},
226
+ sectionHeaderRows: {},
227
+ commentsByRow: {},
228
+ commentsSelectedRow: 0,
229
+ commentsHighlightId: null,
230
+ stickyCurrentFile: null,
231
+ diffViewportTopRow: 0,
232
+ lineSelection: null,
109
233
  };
110
234
 
235
+ function fileKey(f: FileEntry): string { return `${f.path}\0${f.category}`; }
236
+ function fileKeyOf(path: string, category: string): string { return `${path}\0${category}`; }
237
+
238
+ // Theme colour for the synthetic "cursor line" highlight in the panel
239
+ // buffers. Reintroduced after the per-line bg overlay was deleted from the
240
+ // builders — `applyCursorLineOverlay` writes it on every cursor_moved event.
241
+ const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
242
+ const CURSOR_LINE_NS = "review-cursor-line";
243
+
111
244
  // --- Refresh State ---
112
245
 
113
246
  // --- Colors & Styles ---
@@ -119,14 +252,46 @@ const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
119
252
  const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
120
253
  const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
121
254
  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";
255
+
124
256
  const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
125
257
  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";
258
+ // Subtle bg for file/section header rows. Uses `editor.current_line_bg`
259
+ // which is reliably a notch lighter than editor bg in every theme
260
+ // (line_number_bg matches editor bg in Dracula and would render
261
+ // invisibly; status_bar_bg is the toolbar accent and is hot pink in
262
+ // Dracula). selection_bg is reserved for the cursor-line overlay so
263
+ // using it here would blend the two highlights.
264
+ const STYLE_FILE_HEADER_BG: OverlayColorSpec = "editor.current_line_bg";
265
+ const STYLE_HUNK_HEADER_BG: OverlayColorSpec = "editor.current_line_bg";
266
+ // File-header foreground: brightest reliable foreground in any theme.
267
+ // `editor.fg` is white-ish on dark themes and black-ish on light, so it
268
+ // always reads as the most prominent text color. Bolded for extra weight.
269
+ const STYLE_FILE_HEADER_FG: OverlayColorSpec = "editor.fg";
270
+ // "Inverse" pair — swap of editor.bg/fg. Used for full-line-wide section
271
+ // dividers (STAGED / UNSTAGED / UNTRACKED) and the Comments panel
272
+ // header. Reads as an inverted band in every theme: dark text on light
273
+ // bg in dark themes, light text on dark bg in light themes.
274
+ const STYLE_INVERSE_FG: OverlayColorSpec = "editor.bg";
275
+ const STYLE_INVERSE_BG: OverlayColorSpec = "editor.fg";
276
+ // Dim foreground for the per-row old/new line-number gutter. Same key
277
+ // the editor uses for its own gutter — already chosen per-theme to be
278
+ // readable but visibly subordinate to content fg.
279
+ const STYLE_LINE_NUM_FG: OverlayColorSpec = "editor.line_number_fg";
280
+
281
+ // Width of each line-number column. 4 chars fits up to 9999 lines —
282
+ // past that we just let the number overflow rather than expanding the
283
+ // gutter (extremely rare in review-diff context).
284
+ const LINE_NUM_W = 4;
285
+
286
+ /** Format the per-row "OLD NEW " prefix (with trailing space). Either
287
+ * side passes `undefined` for blank — removed lines blank the new
288
+ * column, added lines blank the old column. */
289
+ function lineNumPrefix(oldNum: number | undefined, newNum: number | undefined): string {
290
+ const o = oldNum !== undefined ? String(oldNum).padStart(LINE_NUM_W) : ' '.repeat(LINE_NUM_W);
291
+ const n = newNum !== undefined ? String(newNum).padStart(LINE_NUM_W) : ' '.repeat(LINE_NUM_W);
292
+ return ` ${o} ${n} `;
293
+ }
294
+
130
295
 
131
296
  /**
132
297
  * Calculate UTF-8 byte length of a string manually since TextEncoder is not available
@@ -144,6 +309,152 @@ function getByteLength(str: string): number {
144
309
  return s;
145
310
  }
146
311
 
312
+ // --- Persistence ---
313
+ //
314
+ // Review comments for a given repo are persisted under:
315
+ //
316
+ // <data_dir>/audit/<sanitized-repo-root>/<review-key>.json
317
+ //
318
+ // Where:
319
+ // - `<data_dir>` is the host's `DirectoryContext::data_dir` (exposed via
320
+ // the `getDataDir()` API added for this feature).
321
+ // - `<review-key>` captures the *kind* of review — not every git state is
322
+ // a fingerprint:
323
+ // - `worktree` for `start_review_diff` (working tree review). There
324
+ // is no single fingerprint for the working tree so we just reuse a
325
+ // single slot per repo; line-content + line-number matching on
326
+ // restore prunes comments that no longer apply.
327
+ // - `range-<from>__<to>` for `start_review_range` (commit / branch
328
+ // review). The range is stable, so comments survive re-opening.
329
+ //
330
+ // Design notes / alternatives that were considered:
331
+ // - Keying worktree comments by the index or HEAD SHA: rejected — the
332
+ // working tree is volatile so the key would change constantly and you
333
+ // couldn't get your comments back after a single edit.
334
+ // - Storing under `.review/` in the working tree: rejected — that bakes
335
+ // the reviewer's state into the repo, which leaks into `git status`.
336
+ // - One big JSON with all review keys per repo: rejected — concurrent
337
+ // edits across review windows could clobber each other. Per-key files
338
+ // keep each review's writes independent.
339
+
340
+ interface PersistedReview {
341
+ version: number;
342
+ mode: ReviewMode;
343
+ range: ReviewRange | null;
344
+ note: string;
345
+ comments: ReviewComment[];
346
+ updated_at: string;
347
+ }
348
+
349
+ const REVIEW_STORAGE_VERSION = 1;
350
+
351
+ /**
352
+ * Make a string safe for use as a filename / directory name on all host
353
+ * OSes. Forbidden characters (`/`, `\`, `:`, etc.) collapse to `_`; long
354
+ * tails hash-truncate so path length stays sane.
355
+ */
356
+ function sanitizeKeySegment(raw: string): string {
357
+ const replaced = raw.replace(/[^A-Za-z0-9._-]+/g, '_');
358
+ if (replaced.length <= 120) return replaced;
359
+ // Cheap 32-bit FNV-1a so different long segments don't alias after
360
+ // truncation.
361
+ let h = 0x811c9dc5 >>> 0;
362
+ for (let i = 0; i < raw.length; i++) {
363
+ h ^= raw.charCodeAt(i);
364
+ h = Math.imul(h, 0x01000193) >>> 0;
365
+ }
366
+ return replaced.slice(0, 100) + '__' + h.toString(16);
367
+ }
368
+
369
+ /**
370
+ * Build the review-key portion of the storage filename (without the
371
+ * `.json` extension) for the current mode / range.
372
+ */
373
+ function buildReviewKey(mode: ReviewMode, range: ReviewRange | null): string {
374
+ if (mode === 'range' && range) {
375
+ return `range-${sanitizeKeySegment(range.from)}__${sanitizeKeySegment(range.to)}`;
376
+ }
377
+ return 'worktree';
378
+ }
379
+
380
+ /** Directory that stores all review files for a given repo. */
381
+ function reviewStorageDirFor(repoRoot: string): string | null {
382
+ try {
383
+ const dataDir = (editor as any).getDataDir?.() as string | undefined;
384
+ if (!dataDir) return null;
385
+ return editor.pathJoin(dataDir, "audit", sanitizeKeySegment(repoRoot));
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+
391
+ /** Absolute path of the JSON file backing a review key. */
392
+ function reviewStoragePathFor(repoRoot: string, reviewKey: string): string | null {
393
+ const dir = reviewStorageDirFor(repoRoot);
394
+ if (!dir) return null;
395
+ return editor.pathJoin(dir, `${reviewKey}.json`);
396
+ }
397
+
398
+ /**
399
+ * Resolve the git top-level for `editor.getCwd()`. Returns `''` when the
400
+ * cwd isn't inside a repo — callers then skip persistence.
401
+ */
402
+ async function detectRepoRoot(): Promise<string> {
403
+ try {
404
+ const result = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
405
+ if (result.exit_code === 0) {
406
+ return result.stdout.trim();
407
+ }
408
+ } catch {
409
+ // fall through
410
+ }
411
+ return '';
412
+ }
413
+
414
+ /**
415
+ * Persist the current `state.comments` / `state.note` to disk. Best-effort:
416
+ * filesystem errors never surface to the user — the UI is the source of
417
+ * truth during the session and writes are just a cache for restore.
418
+ */
419
+ function persistReview(): void {
420
+ if (!state.repoRoot) return;
421
+ const path = reviewStoragePathFor(state.repoRoot, state.reviewKey);
422
+ if (!path) return;
423
+ const dir = reviewStorageDirFor(state.repoRoot);
424
+ if (dir) {
425
+ try { editor.createDir(dir); } catch {}
426
+ }
427
+ const payload: PersistedReview = {
428
+ version: REVIEW_STORAGE_VERSION,
429
+ mode: state.mode,
430
+ range: state.range,
431
+ note: state.note,
432
+ comments: state.comments,
433
+ updated_at: new Date().toISOString(),
434
+ };
435
+ try {
436
+ editor.writeFile(path, JSON.stringify(payload, null, 2));
437
+ } catch {}
438
+ }
439
+
440
+ /** Read back a persisted review (if any). Returns null on any failure. */
441
+ function loadPersistedReview(repoRoot: string, reviewKey: string): PersistedReview | null {
442
+ if (!repoRoot) return null;
443
+ const path = reviewStoragePathFor(repoRoot, reviewKey);
444
+ if (!path) return null;
445
+ if (!editor.fileExists(path)) return null;
446
+ try {
447
+ const raw = editor.readFile(path);
448
+ if (!raw) return null;
449
+ const parsed = JSON.parse(raw) as PersistedReview;
450
+ if (!parsed || typeof parsed !== 'object') return null;
451
+ if (!Array.isArray(parsed.comments)) return null;
452
+ return parsed;
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
147
458
  // --- Diff Logic ---
148
459
 
149
460
  interface DiffPart {
@@ -151,46 +462,45 @@ interface DiffPart {
151
462
  type: 'added' | 'removed' | 'unchanged';
152
463
  }
153
464
 
465
+ /**
466
+ * Inline word-level diff between two changed lines.
467
+ *
468
+ * Used to highlight the *changed region* inside a -/+ pair, called once per
469
+ * adjacent pair while building a file's diff. The previous implementation
470
+ * was a full O(n*m) LCS that allocated an (n+1)*(m+1) DP table per pair —
471
+ * fast enough for short lines, but for files with hundreds of long-line
472
+ * changes (e.g. `audit_mode.ts` itself) it added hundreds of milliseconds
473
+ * to every diff rebuild and made file-list navigation visibly laggy.
474
+ *
475
+ * This O(n+m) scan finds the longest common prefix and suffix and reports
476
+ * everything in between as the changed region. It misses internal matches
477
+ * (e.g. it can't tell that "abc-xy-def" → "abc-zw-def" only changed the
478
+ * middle "xy"), but for inline highlighting that's fine — the human eye is
479
+ * already drawn to the line as a whole, the highlight just answers "where
480
+ * inside the line did the change happen?". The cost difference is dramatic:
481
+ * for two 200-char lines, ~400 char compares vs. ~40 000.
482
+ */
154
483
  function diffStrings(oldStr: string, newStr: string): DiffPart[] {
155
484
  const n = oldStr.length;
156
485
  const m = newStr.length;
157
- const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
158
-
159
- for (let i = 1; i <= n; i++) {
160
- for (let j = 1; j <= m; j++) {
161
- if (oldStr[i - 1] === newStr[j - 1]) {
162
- dp[i][j] = dp[i - 1][j - 1] + 1;
163
- } else {
164
- dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
165
- }
166
- }
167
- }
168
-
169
- const result: DiffPart[] = [];
170
- let i = n, j = m;
171
- while (i > 0 || j > 0) {
172
- if (i > 0 && j > 0 && oldStr[i - 1] === newStr[j - 1]) {
173
- result.unshift({ text: oldStr[i - 1], type: 'unchanged' });
174
- i--; j--;
175
- } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
176
- result.unshift({ text: newStr[j - 1], type: 'added' });
177
- j--;
178
- } else {
179
- result.unshift({ text: oldStr[i - 1], type: 'removed' });
180
- i--;
181
- }
486
+ let pre = 0;
487
+ const minLen = Math.min(n, m);
488
+ while (pre < minLen && oldStr.charCodeAt(pre) === newStr.charCodeAt(pre)) pre++;
489
+ let suf = 0;
490
+ while (
491
+ suf < n - pre &&
492
+ suf < m - pre &&
493
+ oldStr.charCodeAt(n - 1 - suf) === newStr.charCodeAt(m - 1 - suf)
494
+ ) {
495
+ suf++;
182
496
  }
183
497
 
184
- const coalesced: DiffPart[] = [];
185
- for (const part of result) {
186
- const last = coalesced[coalesced.length - 1];
187
- if (last && last.type === part.type) {
188
- last.text += part.text;
189
- } else {
190
- coalesced.push(part);
191
- }
192
- }
193
- return coalesced;
498
+ const parts: DiffPart[] = [];
499
+ if (pre > 0) parts.push({ text: oldStr.slice(0, pre), type: 'unchanged' });
500
+ if (pre < n - suf) parts.push({ text: oldStr.slice(pre, n - suf), type: 'removed' });
501
+ if (pre < m - suf) parts.push({ text: newStr.slice(pre, m - suf), type: 'added' });
502
+ if (suf > 0) parts.push({ text: oldStr.slice(n - suf), type: 'unchanged' });
503
+ return parts;
194
504
  }
195
505
 
196
506
  function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
@@ -220,7 +530,6 @@ function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'unt
220
530
  type: 'modify',
221
531
  lines: [],
222
532
  status: 'pending',
223
- reviewStatus: 'pending',
224
533
  contextHeader: match[3]?.trim() || "",
225
534
  byteOffset: 0,
226
535
  gitStatus
@@ -310,11 +619,28 @@ function parseGitStatusPorcelain(raw: string): FileEntry[] {
310
619
 
311
620
  /**
312
621
  * Single source of truth for changed files using `git status --porcelain -z`.
622
+ *
623
+ * `emptyReason` distinguishes the two no-content cases so the UI can explain
624
+ * itself instead of rendering a blank pane:
625
+ * - `'not_git'`: `git status` failed (no repo at cwd).
626
+ * - `'clean'`: `git status` succeeded but returned no entries.
627
+ * - `null`: files were found; render them normally.
313
628
  */
314
- async function getGitStatus(): Promise<FileEntry[]> {
629
+ interface GitStatusResult {
630
+ files: FileEntry[];
631
+ emptyReason: EmptyStateReason;
632
+ }
633
+
634
+ async function getGitStatus(): Promise<GitStatusResult> {
315
635
  const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
316
- if (result.exit_code !== 0) return [];
317
- return parseGitStatusPorcelain(result.stdout);
636
+ if (result.exit_code !== 0) {
637
+ return { files: [], emptyReason: 'not_git' };
638
+ }
639
+ const files = parseGitStatusPorcelain(result.stdout);
640
+ return {
641
+ files,
642
+ emptyReason: files.length === 0 ? 'clean' : null,
643
+ };
318
644
  }
319
645
 
320
646
  /**
@@ -374,7 +700,6 @@ async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
374
700
 
375
701
  // --- New magit-style rendering (Step 2 of rewrite) ---
376
702
 
377
- const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
378
703
  const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
379
704
  const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
380
705
  const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
@@ -389,114 +714,322 @@ interface ListLine {
389
714
 
390
715
  interface DiffLine {
391
716
  text: string;
392
- type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty';
717
+ type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment' | 'file-header' | 'section-header';
718
+ filePath?: string; // for file-header rows
719
+ fileKey?: string; // for file-header rows
720
+ fileIndex?: number; // for file-header rows
393
721
  style?: Partial<OverlayOptions>;
394
722
  inlineOverlays?: InlineOverlay[];
723
+ // Line metadata for comment attachment
724
+ hunkId?: string;
725
+ file?: string;
726
+ lineType?: 'add' | 'remove' | 'context';
727
+ oldLine?: number;
728
+ newLine?: number;
729
+ lineContent?: string;
730
+ commentId?: string;
395
731
  }
396
732
 
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
- });
733
+ /** Compute +N / -M line counts for a file. */
734
+ function fileChangeCounts(file: FileEntry): { added: number; removed: number } {
735
+ let added = 0;
736
+ let removed = 0;
737
+ for (const h of state.hunks) {
738
+ if (h.file === file.path && h.gitStatus === file.category) {
739
+ for (const line of h.lines) {
740
+ if (line[0] === '+') added++;
741
+ else if (line[0] === '-') removed++;
742
+ }
419
743
  }
744
+ }
745
+ return { added, removed };
746
+ }
420
747
 
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;
748
+ /**
749
+ * Push inline comment lines for a given diff line into the lines array.
750
+ */
751
+ function pushLineComments(
752
+ lines: DiffLine[], hunk: Hunk,
753
+ lineType: 'add' | 'remove' | 'context',
754
+ oldLine: number | undefined, newLine: number | undefined
755
+ ) {
756
+ const lineComments = state.comments.filter(c =>
757
+ c.hunk_id === hunk.id && (
758
+ (c.line_type === 'add' && c.new_line === newLine) ||
759
+ (c.line_type === 'remove' && c.old_line === oldLine) ||
760
+ (c.line_type === 'context' && c.new_line === newLine)
761
+ )
762
+ );
763
+ // Indent the comment so its `»` glyph aligns with the diff content
764
+ // column (just past the OLD/NEW number gutter and the +/- indicator).
765
+ const commentIndent = ' '.repeat(LINE_NUM_W + 1 + LINE_NUM_W + 1 + 1 + 1);
766
+ for (const comment of lineComments) {
767
+ const lineRef = comment.line_type === 'add'
768
+ ? `+${comment.new_line}`
769
+ : comment.line_type === 'remove'
770
+ ? `-${comment.old_line}`
771
+ : `${comment.new_line}`;
425
772
  lines.push({
426
- text: `${prefix}${statusIcon} ${filename}`,
427
- type: 'file',
428
- fileIndex: i,
773
+ text: `${commentIndent}\u00bb [${lineRef}] ${comment.text}`,
774
+ type: 'comment',
775
+ commentId: comment.id,
776
+ style: { fg: STYLE_COMMENT, italic: true },
429
777
  });
430
778
  }
431
-
432
- return lines;
433
779
  }
434
780
 
435
781
  /**
436
- * Build the diff lines for the right panel based on currently selected file.
782
+ * Build the diff lines for the unified stream.
783
+ * Emits one file-header row per file, followed by its hunks inline.
784
+ * When the file is collapsed, only the header is emitted.
437
785
  */
438
- function buildDiffLines(rightWidth: number): DiffLine[] {
786
+ function buildDiffLines(_rightWidth: number): DiffLine[] {
439
787
  const lines: DiffLine[] = [];
440
- if (state.files.length === 0) return lines;
788
+ if (state.files.length === 0) {
789
+ if (state.emptyState === 'not_git') {
790
+ lines.push({
791
+ text: editor.t("status.not_git_repo") || "Not a git repository",
792
+ type: 'empty',
793
+ style: { fg: STYLE_SECTION_HEADER, italic: true },
794
+ });
795
+ } else if (state.emptyState === 'clean') {
796
+ lines.push({
797
+ text: editor.t("panel.no_changes") || "No changes to review.",
798
+ type: 'empty',
799
+ style: { fg: STYLE_SECTION_HEADER, italic: true },
800
+ });
801
+ }
802
+ return lines;
803
+ }
441
804
 
442
- const selectedFile = state.files[state.selectedIndex];
443
- if (!selectedFile) return lines;
805
+ let lastCategory: string | undefined;
806
+ for (let fi = 0; fi < state.files.length; fi++) {
807
+ const file = state.files[fi];
808
+
809
+ // Section header — full-line-wide INVERSE band, uppercase, bold.
810
+ // The strong inverse coloring (editor.bg as fg / editor.fg as bg)
811
+ // makes the band read as a hard divider between Staged /
812
+ // Unstaged / Untracked sections regardless of theme.
813
+ if (file.category !== lastCategory) {
814
+ lastCategory = file.category;
815
+ let label: string = file.category;
816
+ // Range mode reuses the `unstaged` bucket for every hunk as
817
+ // an impl shortcut — surface the range label so the user
818
+ // isn't told their commit review is "Unstaged".
819
+ if (state.mode === 'range' && state.range) {
820
+ label = state.range.label;
821
+ } else if (file.category === 'staged') label = editor.t("section.staged") || "Staged";
822
+ else if (file.category === 'unstaged') label = editor.t("section.unstaged") || "Unstaged";
823
+ else if (file.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
824
+ const sectionCount = state.files.filter(f => f.category === file.category).length;
825
+ // Always render expanded triangle (▾). Collapse state is
826
+ // shown by overlaying a `▸` replacement-conceal on the
827
+ // triangle byte range — the buffer text never changes, so
828
+ // toggling collapse never has to rebuild.
829
+ // Range labels (e.g. `main..HEAD`) carry case already — don't
830
+ // mangle them with the section uppercase; worktree category
831
+ // names are lowercase words and need the uppercase.
832
+ const displayLabel = state.mode === 'range' ? label : label.toUpperCase();
833
+ lines.push({
834
+ text: ` ▾ ${displayLabel} (${sectionCount})`,
835
+ type: 'section-header',
836
+ file: file.category, // store category in 'file' field for reuse
837
+ filePath: file.category,
838
+ style: {
839
+ fg: STYLE_INVERSE_FG,
840
+ bg: STYLE_INVERSE_BG,
841
+ bold: true,
842
+ extendToLineEnd: true,
843
+ },
844
+ });
845
+ }
444
846
 
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
- );
847
+ // File header always emit the expanded triangle; conceal
848
+ // overlays handle the collapsed view.
849
+ const counts = fileChangeCounts(file);
850
+ const key = fileKey(file);
851
+ const filename = file.origPath ? `${file.origPath} → ${file.path}` : file.path;
852
+ const headerText = ` ▾ ${filename} +${counts.added} / -${counts.removed}`;
853
+ lines.push({
854
+ text: headerText,
855
+ type: 'file-header',
856
+ file: file.path,
857
+ filePath: file.path,
858
+ fileKey: key,
859
+ fileIndex: fi,
860
+ style: {
861
+ fg: STYLE_FILE_HEADER_FG,
862
+ bg: STYLE_FILE_HEADER_BG,
863
+ bold: true,
864
+ extendToLineEnd: true,
865
+ },
866
+ });
449
867
 
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' });
459
- } else {
460
- lines.push({ text: "(no diff available)", type: 'empty' });
868
+ // Find hunks for this file
869
+ const fileHunks = state.hunks.filter(
870
+ h => h.file === file.path && h.gitStatus === file.category
871
+ );
872
+
873
+ if (fileHunks.length === 0) {
874
+ if (file.status === 'R' && file.origPath) {
875
+ lines.push({ text: ` Renamed from ${file.origPath}`, type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
876
+ } else if (file.status === 'D') {
877
+ lines.push({ text: " (file deleted)", type: 'empty' });
878
+ } else if (file.status === 'T') {
879
+ lines.push({ text: " (type change: file ↔ symlink)", type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
880
+ } else if (file.status === '?' && file.path.endsWith('/')) {
881
+ lines.push({ text: " (untracked directory)", type: 'empty' });
882
+ } else {
883
+ lines.push({ text: " (no diff available)", type: 'empty' });
884
+ }
885
+ lines.push({ text: '', type: 'empty' });
886
+ continue;
461
887
  }
462
- return lines;
463
- }
464
888
 
465
- for (const hunk of fileHunks) {
466
- // Hunk header
467
- const header = hunk.contextHeader
889
+ for (const hunk of fileHunks) {
890
+ // Hunk header — always emit expanded triangle; collapse
891
+ // overlays a `▸` replacement-conceal.
892
+ const headerInner = hunk.contextHeader
468
893
  ? `@@ ${hunk.contextHeader} @@`
469
894
  : `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
895
+ const header = ` ▾ ${headerInner}`;
896
+
470
897
  lines.push({
471
898
  text: header,
472
899
  type: 'hunk-header',
473
- style: { fg: STYLE_HUNK_HEADER, bold: true },
900
+ hunkId: hunk.id,
901
+ file: hunk.file,
902
+ style: {
903
+ fg: STYLE_HUNK_HEADER,
904
+ bg: STYLE_HUNK_HEADER_BG,
905
+ bold: true,
906
+ extendToLineEnd: true,
907
+ },
474
908
  });
475
909
 
476
- // Diff content linesonly 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) {
910
+ // (Body always emittedcollapse is handled by overlay
911
+ // conceals on the body's byte range.)
912
+
913
+ // (Comments are line-based they appear under their attached
914
+ // diff line via pushLineComments below, never as hunk-level.)
915
+
916
+ // Track actual file line numbers as we iterate
917
+ let oldLineNum = hunk.oldRange.start;
918
+ let newLineNum = hunk.range.start;
919
+
920
+ // Diff content lines with word-level highlighting for adjacent -/+ pairs
921
+ for (let li = 0; li < hunk.lines.length; li++) {
922
+ const line = hunk.lines[li];
923
+ const nextLine = hunk.lines[li + 1];
480
924
  const prefix = line[0];
925
+ const lineType: 'add' | 'remove' | 'context' =
926
+ prefix === '+' ? 'add' : prefix === '-' ? 'remove' : 'context';
927
+ const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
928
+ const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
929
+
930
+ // Detect adjacent -/+ pair for word-level diff
931
+ if (prefix === '-' && nextLine && nextLine[0] === '+') {
932
+ const oldContent = line.substring(1);
933
+ const newContent = nextLine.substring(1);
934
+ const parts = diffStrings(oldContent, newContent);
935
+
936
+ // Removed-side line: " OLD -content"
937
+ const removePrefix = lineNumPrefix(curOldLine, undefined);
938
+ const removeText = removePrefix + line;
939
+ const removePrefixLen = getByteLength(removePrefix);
940
+ const removeOverlays: InlineOverlay[] = [
941
+ { start: 0, end: removePrefixLen, style: { fg: STYLE_LINE_NUM_FG } },
942
+ ];
943
+ let rOffset = removePrefixLen + getByteLength(line[0]); // skip diff prefix
944
+ for (const part of parts) {
945
+ const pLen = getByteLength(part.text);
946
+ if (part.type === 'removed') {
947
+ removeOverlays.push({ start: rOffset, end: rOffset + pLen, style: { fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true } });
948
+ }
949
+ if (part.type !== 'added') rOffset += pLen;
950
+ }
951
+ lines.push({
952
+ text: removeText, type: 'remove',
953
+ style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
954
+ hunkId: hunk.id, file: hunk.file,
955
+ lineType: 'remove', oldLine: curOldLine, newLine: undefined, lineContent: line,
956
+ inlineOverlays: removeOverlays,
957
+ });
958
+ // Inline comments for the removed line
959
+ pushLineComments(lines, hunk, 'remove', curOldLine, undefined);
960
+ oldLineNum++;
961
+
962
+ // Added-side line: " NEW +content"
963
+ const addPrefix = lineNumPrefix(undefined, newLineNum);
964
+ const addText = addPrefix + nextLine;
965
+ const addPrefixLen = getByteLength(addPrefix);
966
+ const addOverlays: InlineOverlay[] = [
967
+ { start: 0, end: addPrefixLen, style: { fg: STYLE_LINE_NUM_FG } },
968
+ ];
969
+ let aOffset = addPrefixLen + getByteLength(nextLine[0]);
970
+ for (const part of parts) {
971
+ const pLen = getByteLength(part.text);
972
+ if (part.type === 'added') {
973
+ addOverlays.push({ start: aOffset, end: aOffset + pLen, style: { fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true } });
974
+ }
975
+ if (part.type !== 'removed') aOffset += pLen;
976
+ }
977
+ lines.push({
978
+ text: addText, type: 'add',
979
+ style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
980
+ hunkId: hunk.id, file: hunk.file,
981
+ lineType: 'add', oldLine: undefined, newLine: newLineNum, lineContent: nextLine,
982
+ inlineOverlays: addOverlays,
983
+ });
984
+ pushLineComments(lines, hunk, 'add', undefined, newLineNum);
985
+ newLineNum++;
986
+ li++; // skip the + line we already processed
987
+ continue;
988
+ }
989
+
990
+ const numPrefix = lineNumPrefix(curOldLine, curNewLine);
991
+ const decoratedText = numPrefix + line;
992
+ const numPrefixLen = getByteLength(numPrefix);
993
+ const dimNumOverlay: InlineOverlay = {
994
+ start: 0, end: numPrefixLen, style: { fg: STYLE_LINE_NUM_FG },
995
+ };
996
+
481
997
  if (prefix === '+') {
482
998
  lines.push({
483
- text: line,
484
- type: 'add',
999
+ text: decoratedText, type: 'add',
485
1000
  style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
1001
+ hunkId: hunk.id, file: hunk.file,
1002
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
1003
+ inlineOverlays: [dimNumOverlay],
486
1004
  });
1005
+ newLineNum++;
487
1006
  } else if (prefix === '-') {
488
1007
  lines.push({
489
- text: line,
490
- type: 'remove',
1008
+ text: decoratedText, type: 'remove',
491
1009
  style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
1010
+ hunkId: hunk.id, file: hunk.file,
1011
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
1012
+ inlineOverlays: [dimNumOverlay],
492
1013
  });
1014
+ oldLineNum++;
493
1015
  } else {
494
1016
  lines.push({
495
- text: line,
496
- type: 'context',
1017
+ text: decoratedText, type: 'context',
1018
+ hunkId: hunk.id, file: hunk.file,
1019
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
1020
+ inlineOverlays: [dimNumOverlay],
497
1021
  });
1022
+ oldLineNum++;
1023
+ newLineNum++;
498
1024
  }
1025
+
1026
+ // Render inline comments attached to this line
1027
+ pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
499
1028
  }
1029
+ }
1030
+
1031
+ // Blank separator between files
1032
+ lines.push({ text: '', type: 'empty' });
500
1033
  }
501
1034
 
502
1035
  return lines;
@@ -509,282 +1042,1416 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
509
1042
  * Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
510
1043
  * Rows 2..H-1: Main content (left file list, │ divider, right diff)
511
1044
  */
512
- function buildMagitDisplayEntries(): TextPropertyEntry[] {
1045
+
1046
+ // Theme colors for toolbar key hints
1047
+ // Toolbar styling — explicitly NOT using `ui.status_bar_bg` because that
1048
+ // key is a saturated accent in some themes (Dracula's hot pink). Instead
1049
+ // we paint the toolbar with `editor.bg` so it visually matches the
1050
+ // editor content and keys/labels get reliable contrast against it.
1051
+ // * Keys: `editor.fg` + bold (white-bold on dark, etc.).
1052
+ // * Labels: `editor.line_number_fg` (dim foreground in every theme).
1053
+ const STYLE_KEY_FG: OverlayColorSpec = "editor.fg";
1054
+ const STYLE_HINT_FG: OverlayColorSpec = "editor.line_number_fg";
1055
+ const STYLE_TOOLBAR_BG: OverlayColorSpec = "editor.bg";
1056
+ const STYLE_TOOLBAR_SEP: OverlayColorSpec = "ui.split_separator_fg";
1057
+
1058
+ interface HintItem {
1059
+ key: string;
1060
+ label: string;
1061
+ }
1062
+
1063
+ /**
1064
+ * Build a styled toolbar entry with highlighted key hints.
1065
+ * Keys get bold + keyword color; labels get dim text; groups separated by │.
1066
+ */
1067
+ function buildToolbarRow(W: number, groups: HintItem[][]): TextPropertyEntry {
1068
+ const overlays: InlineOverlay[] = [];
1069
+ let text = " ";
1070
+ let bytePos = getByteLength(" ");
1071
+ let done = false;
1072
+
1073
+ for (let g = 0; g < groups.length && !done; g++) {
1074
+ if (g > 0) {
1075
+ const sep = " │ ";
1076
+ if (text.length + sep.length > W) { done = true; break; }
1077
+ overlays.push({ start: bytePos, end: bytePos + getByteLength(sep), style: { fg: STYLE_TOOLBAR_SEP } });
1078
+ text += sep;
1079
+ bytePos += getByteLength(sep);
1080
+ }
1081
+ for (let h = 0; h < groups[g].length && !done; h++) {
1082
+ const item = groups[g][h];
1083
+ const gap = h > 0 ? " " : "";
1084
+ // Bracket-style key hint: "[key] label" — the brackets make
1085
+ // the keys legible without a saturated bg, which works in
1086
+ // every theme (no Dracula hot-pink toolbar problem). When
1087
+ // the key itself is `[` or `]`, drop the brackets so we
1088
+ // don't render `[[]` / `[]]`.
1089
+ const isBracket = item.key === '[' || item.key === ']';
1090
+ const keyDisplay = isBracket ? item.key : `[${item.key}]`;
1091
+ const fullLen = gap.length + keyDisplay.length + 1 + item.label.length;
1092
+ const keyOnlyLen = gap.length + keyDisplay.length;
1093
+
1094
+ if (text.length + fullLen <= W) {
1095
+ if (gap) { text += gap; bytePos += getByteLength(gap); }
1096
+ const keyLen = getByteLength(keyDisplay);
1097
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
1098
+ text += keyDisplay;
1099
+ bytePos += keyLen;
1100
+ const labelText = " " + item.label;
1101
+ const labelLen = getByteLength(labelText);
1102
+ overlays.push({ start: bytePos, end: bytePos + labelLen, style: { fg: STYLE_HINT_FG } });
1103
+ text += labelText;
1104
+ bytePos += labelLen;
1105
+ } else if (text.length + keyOnlyLen <= W) {
1106
+ if (gap) { text += gap; bytePos += getByteLength(gap); }
1107
+ const keyLen = getByteLength(keyDisplay);
1108
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
1109
+ text += keyDisplay;
1110
+ bytePos += keyLen;
1111
+ } else {
1112
+ done = true;
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ const padded = text.padEnd(W) + "\n";
1118
+ return {
1119
+ text: padded,
1120
+ properties: { type: "toolbar" },
1121
+ style: { bg: STYLE_TOOLBAR_BG, extendToLineEnd: true },
1122
+ inlineOverlays: overlays,
1123
+ };
1124
+ }
1125
+
1126
+ /**
1127
+ * Build the (two-row) toolbar with all review-diff shortcuts.
1128
+ * Row 1 — navigation; row 2 — actions. Identical regardless of which
1129
+ * panel currently has focus (no more files-pane vs diff-pane variants).
1130
+ */
1131
+ function buildToolbar(W: number): TextPropertyEntry[] {
1132
+ // In range mode, stage / unstage / discard are meaningless (there is
1133
+ // no working tree to mutate), so hide them from the hint bar to keep
1134
+ // the toolbar honest. The key-bindings themselves are harmless if
1135
+ // pressed — `review_stage_scope` no-ops on range-mode hunks because
1136
+ // their gitStatus is 'unstaged' and the git commands it invokes
1137
+ // target the working tree, which isn't what the user intended. The
1138
+ // toolbar is the user-facing surface, so pruning here is the
1139
+ // cheapest honest thing to do.
1140
+ const inRange = state.mode === 'range';
1141
+ const row1: HintItem[][] = [
1142
+ [{ key: "n", label: "next hunk" }, { key: "p", label: "prev hunk" },
1143
+ { key: "]", label: "next cmt" }, { key: "[", label: "prev cmt" }],
1144
+ inRange
1145
+ ? [{ key: "v", label: "select" }, { key: "c", label: "comment" }]
1146
+ : [{ key: "s", label: "stage" }, { key: "u", label: "unstage" }, { key: "d", label: "discard" },
1147
+ { key: "v", label: "select" }, { key: "c", label: "comment" }],
1148
+ ];
1149
+ const row2: HintItem[][] = [
1150
+ [{ key: "Tab", label: "fold" }, { key: "z a", label: "fold all" }, { key: "z r", label: "unfold all" }],
1151
+ inRange
1152
+ ? [{ key: "Enter", label: "jump" }, { key: "e", label: "export" }, { key: "q", label: "close" }]
1153
+ : [{ key: "S U D", label: "file-level" }, { key: "Enter", label: "jump" },
1154
+ { key: "e", label: "export" }, { key: "q", label: "close" }],
1155
+ ];
1156
+ return [buildToolbarRow(W, row1), buildToolbarRow(W, row2)];
1157
+ }
1158
+
1159
+ // --- Buffer Group panel content builders ---
1160
+
1161
+ function buildToolbarPanelEntries(): TextPropertyEntry[] {
1162
+ // Two-row toolbar: navigation hints on row 1, actions on row 2.
1163
+ return buildToolbar(state.viewportWidth);
1164
+ }
1165
+
1166
+ /**
1167
+ * Build the unified-diff stream entries. Emits one row per file header
1168
+ * followed by all of that file's hunks inline, plus inline comments and
1169
+ * a blank separator between files. As a side effect, populates
1170
+ * `state.hunkHeaderRows`, `state.diffLineByteOffsets`, and
1171
+ * `state.fileHeaderRows` so the rest of the plugin can map cursor rows
1172
+ * back to hunks/files.
1173
+ */
1174
+ function buildDiffPanelEntries(): TextPropertyEntry[] {
513
1175
  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
1176
 
519
- const allFileLines = buildFileListLines();
520
- const diffLines = buildDiffLines(rightWidth);
1177
+ const hunkHeaderRows: number[] = [];
1178
+ const diffLineByteOffsets: number[] = [];
1179
+ const fileHeaderRows: Record<string, number> = {};
1180
+ const sectionHeaderRows: Record<string, number> = {};
1181
+ const hunkRowByHunkId: Record<string, number> = {};
1182
+ const diffLineRowByCommentId: Record<string, number> = {};
1183
+ const entryPropsByRow: Record<number, Record<string, unknown>> = {};
1184
+ // Byte ranges of collapsible bodies, captured in this same single
1185
+ // pass so collapse later just registers a host fold (no rebuild).
1186
+ // The "body" of an entity is the byte range from the byte after
1187
+ // its header's newline up to the byte before the next header that
1188
+ // ends it.
1189
+ const sectionBodyRange: Record<string, { start: number; end: number }> = {};
1190
+ const fileBodyRange: Record<string, { start: number; end: number }> = {};
1191
+ const hunkBodyRange: Record<string, { start: number; end: number }> = {};
1192
+ let curSection: string | null = null;
1193
+ let curFile: string | null = null;
1194
+ let curHunk: string | null = null;
1195
+ let sectionBodyStart = 0;
1196
+ let fileBodyStart = 0;
1197
+ let hunkBodyStart = 0;
1198
+
1199
+ let runningByte = 0;
1200
+ let row = 0; // 0-indexed counter; row + 1 is the 1-indexed line number
1201
+ let lastDiffLineRow = 0; // 1-indexed row of the most recent +/-/context line
1202
+
1203
+ const pushEntry = (entry: TextPropertyEntry) => {
1204
+ diffLineByteOffsets.push(runningByte);
1205
+ runningByte += getByteLength(entry.text);
1206
+ entries.push(entry);
1207
+ row++;
1208
+ };
521
1209
 
522
- const mainRows = H - 2; // rows 2..H-1
1210
+ const lines = buildDiffLines(state.viewportWidth);
1211
+ for (const line of lines) {
1212
+ const props: Record<string, unknown> = { type: line.type };
1213
+ if (line.hunkId !== undefined) props.hunkId = line.hunkId;
1214
+ if (line.file !== undefined) props.file = line.file;
1215
+ if (line.lineType !== undefined) props.lineType = line.lineType;
1216
+ if (line.oldLine !== undefined) props.oldLine = line.oldLine;
1217
+ if (line.newLine !== undefined) props.newLine = line.newLine;
1218
+ if (line.lineContent !== undefined) props.lineContent = line.lineContent;
1219
+ if (line.commentId !== undefined) props.commentId = line.commentId;
1220
+ if (line.filePath !== undefined) props.filePath = line.filePath;
1221
+ if (line.fileKey !== undefined) props.fileKey = line.fileKey;
1222
+ if (line.fileIndex !== undefined) props.fileIndex = line.fileIndex;
1223
+
1224
+ const entryStart = runningByte;
1225
+
1226
+ // Header bookkeeping — close any in-progress body for the
1227
+ // entities about to be replaced, then open a new body range.
1228
+ if (line.type === 'section-header' && line.filePath) {
1229
+ if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: entryStart };
1230
+ if (curFile) fileBodyRange[curFile] = { start: fileBodyStart, end: entryStart };
1231
+ if (curSection) sectionBodyRange[curSection] = { start: sectionBodyStart, end: entryStart };
1232
+ curSection = line.filePath;
1233
+ curFile = null;
1234
+ curHunk = null;
1235
+ }
1236
+ if (line.type === 'file-header' && line.fileKey) {
1237
+ if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: entryStart };
1238
+ if (curFile) fileBodyRange[curFile] = { start: fileBodyStart, end: entryStart };
1239
+ curFile = line.fileKey;
1240
+ curHunk = null;
1241
+ }
1242
+ if (line.type === 'hunk-header' && line.hunkId) {
1243
+ if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: entryStart };
1244
+ curHunk = line.hunkId;
1245
+ }
523
1246
 
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;
1247
+ if (line.type === 'hunk-header') {
1248
+ hunkHeaderRows.push(row + 1);
1249
+ if (line.hunkId) hunkRowByHunkId[line.hunkId] = row + 1;
530
1250
  }
531
- }
532
- if (selectedLineIdx >= 0) {
533
- if (selectedLineIdx < state.fileScrollOffset) {
534
- state.fileScrollOffset = selectedLineIdx;
1251
+ if (line.type === 'file-header' && line.fileKey) {
1252
+ fileHeaderRows[line.fileKey] = row + 1;
535
1253
  }
536
- if (selectedLineIdx >= state.fileScrollOffset + mainRows) {
537
- state.fileScrollOffset = selectedLineIdx - mainRows + 1;
1254
+ if (line.type === 'section-header' && line.filePath) {
1255
+ sectionHeaderRows[line.filePath] = row + 1;
1256
+ }
1257
+ if (line.type === 'add' || line.type === 'remove' || line.type === 'context') {
1258
+ lastDiffLineRow = row + 1;
1259
+ }
1260
+ if (line.type === 'comment' && line.commentId) {
1261
+ diffLineRowByCommentId[line.commentId] = lastDiffLineRow || (row + 1);
538
1262
  }
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
1263
 
544
- const visibleFileLines = allFileLines.slice(state.fileScrollOffset, state.fileScrollOffset + mainRows);
1264
+ entryPropsByRow[row + 1] = props;
545
1265
 
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;
1266
+ pushEntry({
1267
+ text: (line.text || "") + "\n",
1268
+ style: line.style,
1269
+ inlineOverlays: line.inlineOverlays,
1270
+ properties: props,
1271
+ });
1272
+
1273
+ // After the header is pushed, runningByte points to the first
1274
+ // byte of the body that follows.
1275
+ if (line.type === 'section-header') sectionBodyStart = runningByte;
1276
+ if (line.type === 'file-header') fileBodyStart = runningByte;
1277
+ if (line.type === 'hunk-header') hunkBodyStart = runningByte;
1278
+ }
550
1279
 
551
- const visibleDiffLines = diffLines.slice(state.diffScrollOffset, state.diffScrollOffset + mainRows);
1280
+ // Close trailing bodies.
1281
+ if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: runningByte };
1282
+ if (curFile) fileBodyRange[curFile] = { start: fileBodyStart, end: runningByte };
1283
+ if (curSection) sectionBodyRange[curSection] = { start: sectionBodyStart, end: runningByte };
1284
+
1285
+ diffLineByteOffsets.push(runningByte);
1286
+
1287
+ state.hunkHeaderRows = hunkHeaderRows;
1288
+ state.diffLineByteOffsets = diffLineByteOffsets;
1289
+ state.fileHeaderRows = fileHeaderRows;
1290
+ state.sectionHeaderRows = sectionHeaderRows;
1291
+ state.hunkRowByHunkId = hunkRowByHunkId;
1292
+ state.diffLineRowByCommentId = diffLineRowByCommentId;
1293
+ state.entryPropsByRow = entryPropsByRow;
1294
+ state.sectionBodyRange = sectionBodyRange;
1295
+ state.fileBodyRange = fileBodyRange;
1296
+ state.hunkBodyRange = hunkBodyRange;
1297
+ return entries;
1298
+ }
552
1299
 
553
- // --- Row 0: Toolbar ---
554
- const toolbar = " [Tab] Switch Panel [s] Stage [u] Unstage [d] Discard [Enter] Drill-Down [r] Refresh";
1300
+ /**
1301
+ * Build the comments navigation panel. Flat list of comments in the
1302
+ * order they appear in the unified diff stream. Each row reads
1303
+ * "path:line snippet"
1304
+ * truncated to fit the panel width. Empty state shows a dim "No comments
1305
+ * yet." line. Read-only in this step (interaction lands in Step 5/6).
1306
+ */
1307
+ function buildCommentsPanelEntries(): TextPropertyEntry[] {
1308
+ const entries: TextPropertyEntry[] = [];
1309
+ state.commentsByRow = {};
1310
+
1311
+ const headerLabel = (editor.t("panel.comments") || "Comments").toUpperCase();
555
1312
  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" },
1313
+ text: ` ${headerLabel}\n`,
1314
+ style: {
1315
+ fg: STYLE_INVERSE_FG,
1316
+ bg: STYLE_INVERSE_BG,
1317
+ bold: true,
1318
+ extendToLineEnd: true,
1319
+ },
1320
+ properties: { type: "header" },
559
1321
  });
560
1322
 
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 };
1323
+ if (state.comments.length === 0) {
1324
+ entries.push({
1325
+ text: ` ${editor.t("panel.no_comments") || "No comments yet."}\n`,
1326
+ style: { fg: STYLE_SECTION_HEADER, italic: true },
1327
+ properties: { type: "empty" },
1328
+ });
1329
+ return entries;
1330
+ }
1331
+
1332
+ // Order comments by their position in the unified stream. We approximate
1333
+ // by sorting by (file index, line number, removed/added preference).
1334
+ const fileIndex = (file: string, category: string | undefined): number => {
1335
+ for (let i = 0; i < state.files.length; i++) {
1336
+ const f = state.files[i];
1337
+ if (f.path === file) return i;
604
1338
  }
605
- entries.push(leftEntry);
1339
+ return Number.MAX_SAFE_INTEGER;
1340
+ };
606
1341
 
607
- // Divider
608
- entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
1342
+ const sortedComments = [...state.comments].sort((a, b) => {
1343
+ // Look up via hunk's file
1344
+ const hunkA = state.hunks.find(h => h.id === a.hunk_id);
1345
+ const hunkB = state.hunks.find(h => h.id === b.hunk_id);
1346
+ const fa = fileIndex(a.file, hunkA?.gitStatus);
1347
+ const fb = fileIndex(b.file, hunkB?.gitStatus);
1348
+ if (fa !== fb) return fa - fb;
1349
+ const la = a.new_line ?? a.old_line ?? 0;
1350
+ const lb = b.new_line ?? b.old_line ?? 0;
1351
+ return la - lb;
1352
+ });
609
1353
 
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;
1354
+ let rowIdx = 1; // header is row 0 (0-indexed); comments start at row 1
1355
+ for (const c of sortedComments) {
1356
+ rowIdx++;
1357
+ const lineRef = c.new_line ?? c.old_line ?? 0;
1358
+ const path = c.file.split('/').pop() || c.file;
1359
+ const snippet = c.text.replace(/\s+/g, ' ').trim();
1360
+ // Leading marker: ">" when this comment is the diff cursor's
1361
+ // current target (cursor is on the comment row itself, or on
1362
+ // the line the comment is attached to). Otherwise a space.
1363
+ const marker = c.id === state.commentsHighlightId ? '>' : ' ';
1364
+ const text = `${marker} ${path}:${lineRef} ${snippet}`;
1365
+
1366
+ // Truncate to fit panel width (estimate).
1367
+ const panelWidth = Math.max(20, Math.floor(state.viewportWidth * 0.25) - 2);
1368
+ const display = text.length > panelWidth ? text.slice(0, panelWidth - 1) + '…' : text;
1369
+
1370
+ const isSelected = rowIdx === state.commentsSelectedRow && state.focusPanel === 'comments';
1371
+ const isCursorMarked = c.id === state.commentsHighlightId;
1372
+ const style: Partial<OverlayOptions> | undefined = isSelected
1373
+ ? { bg: STYLE_SELECTED_BG, bold: true, extendToLineEnd: true }
1374
+ : isCursorMarked
1375
+ ? { bold: true }
1376
+ : undefined;
1377
+
1378
+ // Color the path:line prefix in keyword color (skip the marker).
1379
+ const prefixLen = getByteLength(`${marker} ${path}:${lineRef}`);
1380
+ const inlineOverlays: InlineOverlay[] = [
1381
+ { start: 2, end: prefixLen, style: { fg: STYLE_KEY_FG } },
1382
+ ];
1383
+
1384
+ state.commentsByRow[rowIdx] = c.id;
617
1385
  entries.push({
618
- text: rightTruncated,
619
- properties: { type: diffItem?.type || "blank" },
620
- style: rightStyle,
621
- inlineOverlays: diffItem?.inlineOverlays,
1386
+ text: display + "\n",
1387
+ style,
1388
+ inlineOverlays,
1389
+ properties: { type: "comment-nav", commentId: c.id, file: c.file, line: lineRef },
622
1390
  });
623
-
624
- // Newline
625
- entries.push({ text: "\n", properties: { type: "newline" } });
626
1391
  }
627
1392
 
628
1393
  return entries;
629
1394
  }
630
1395
 
631
1396
  /**
632
- * Refresh the display — rebuild entries and set buffer content.
633
- * Always re-queries viewport dimensions to handle sidebar toggles and splits.
1397
+ * Full refresh — rebuild all three panels. Called on data changes
1398
+ * (refreshMagitData, comment add/edit, note edit, resize). NOT called on
1399
+ * scroll: scrolling is handled natively by the editor in the panel buffers.
634
1400
  */
635
1401
  function updateMagitDisplay(): void {
636
- if (state.reviewBufferId === null) return;
637
1402
  refreshViewportDimensions();
638
- const entries = buildMagitDisplayEntries();
639
- editor.clearNamespace(state.reviewBufferId, "review-diff");
640
- editor.setVirtualBufferContent(state.reviewBufferId, entries);
1403
+ if (state.groupId === null) return;
1404
+ editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
1405
+ editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
1406
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1407
+ refreshStickyHeader(0);
1408
+ applyFolds();
1409
+ applyCursorLineOverlay('diff');
641
1410
  }
642
1411
 
643
- function review_refresh() { refreshMagitData(); }
644
- registerHandler("review_refresh", review_refresh);
1412
+ /**
1413
+ * Apply collapse state via the host's folding infrastructure. Folds
1414
+ * are designed exactly for "header line stays visible, body lines
1415
+ * skipped by the renderer". A fold range covers `[bodyStart, bodyEnd)`
1416
+ * — the line containing `bodyStart - 1` (the header) stays visible,
1417
+ * everything inside the range gets elided. The host renders its own
1418
+ * "..." indicator on the collapsed header line, which is sufficient
1419
+ * visual feedback (no need for a triangle swap).
1420
+ *
1421
+ * Toggling collapse on a 5000-line diff is now O(collapsed_set_size)
1422
+ * `addFold` calls. `clearFolds` drops the entire set in one host call
1423
+ * so re-applying after a state change is also cheap.
1424
+ */
1425
+ function applyFolds(): void {
1426
+ if (state.groupId === null) return;
1427
+ const diffId = state.panelBuffers["diff"];
1428
+ if (diffId === undefined) return;
1429
+ editor.clearFolds(diffId);
1430
+ for (const cat of state.collapsedSections) {
1431
+ const body = state.sectionBodyRange[cat];
1432
+ if (body && body.end > body.start) editor.addFold(diffId, body.start, body.end);
1433
+ }
1434
+ for (const key of state.collapsedFiles) {
1435
+ const body = state.fileBodyRange[key];
1436
+ if (body && body.end > body.start) editor.addFold(diffId, body.start, body.end);
1437
+ }
1438
+ for (const id of state.collapsedHunks) {
1439
+ const body = state.hunkBodyRange[id];
1440
+ if (body && body.end > body.start) editor.addFold(diffId, body.start, body.end);
1441
+ }
1442
+ }
1443
+
1444
+ /**
1445
+ * Render the sticky panel for `topVisibleRow` (0-indexed line at the top
1446
+ * of the diff viewport). Shows the file whose header row is the largest
1447
+ * ≤ topVisibleRow, with its category as a dim prefix. Falls back to a
1448
+ * neutral summary when nothing is above the cursor.
1449
+ */
1450
+ function refreshStickyHeader(topVisibleRow: number): void {
1451
+ if (state.groupId === null) return;
1452
+ const stickyId = state.panelBuffers["sticky"];
1453
+ if (stickyId === undefined) return;
645
1454
 
646
- // --- New magit navigation handlers (Step 3) ---
1455
+ const W = state.viewportWidth;
1456
+ let text: string;
1457
+ let style: Partial<OverlayOptions> = { fg: STYLE_HEADER, bold: true };
1458
+
1459
+ // topVisibleRow is 0-indexed; fileHeaderRows are 1-indexed.
1460
+ const top1 = topVisibleRow + 1;
1461
+ let bestFile: FileEntry | null = null;
1462
+ let bestRow = 0;
1463
+ for (const f of state.files) {
1464
+ const row = state.fileHeaderRows[fileKey(f)];
1465
+ if (row !== undefined && row <= top1 && row > bestRow) {
1466
+ bestRow = row;
1467
+ bestFile = f;
1468
+ }
1469
+ }
647
1470
 
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();
1471
+ if (!bestFile) {
1472
+ if (state.files.length === 0) {
1473
+ text = ` ${editor.t("status.review_empty") || "Review Diff"}`;
1474
+ } else {
1475
+ const totals = state.files.reduce(
1476
+ (acc, f) => {
1477
+ const c = fileChangeCounts(f);
1478
+ acc.added += c.added;
1479
+ acc.removed += c.removed;
1480
+ return acc;
1481
+ },
1482
+ { added: 0, removed: 0 }
1483
+ );
1484
+ const rangeSuffix = state.mode === 'range' && state.range
1485
+ ? ` (${state.range.label})`
1486
+ : '';
1487
+ text = ` Review Diff${rangeSuffix} — ${state.files.length} files, +${totals.added} / -${totals.removed}`;
1488
+ style = { fg: STYLE_SECTION_HEADER, italic: true };
655
1489
  }
656
1490
  } else {
657
- state.diffScrollOffset = Math.max(0, state.diffScrollOffset - 1);
1491
+ const counts = fileChangeCounts(bestFile);
1492
+ let section: string = bestFile.category;
1493
+ // In range mode every hunk is bucketed as 'unstaged' as an impl
1494
+ // detail; "UNSTAGED" would be misleading, so display the range
1495
+ // label instead.
1496
+ if (state.mode === 'range' && state.range) {
1497
+ section = state.range.label;
1498
+ } else if (bestFile.category === 'staged') section = (editor.t("section.staged") || "Staged").toUpperCase();
1499
+ else if (bestFile.category === 'unstaged') section = (editor.t("section.unstaged") || "Changes").toUpperCase();
1500
+ else if (bestFile.category === 'untracked') section = (editor.t("section.untracked") || "Untracked").toUpperCase();
1501
+ const filename = bestFile.origPath ? `${bestFile.origPath} → ${bestFile.path}` : bestFile.path;
1502
+ text = ` ${section} · ${filename} +${counts.added} / -${counts.removed}`;
1503
+ }
1504
+
1505
+ const padded = (text.length > W ? text.slice(0, W) : text).padEnd(W) + "\n";
1506
+ editor.setPanelContent(state.groupId, "sticky", [{
1507
+ text: padded,
1508
+ // Same band-bg as file/section headers — keeps the sticky visually
1509
+ // tied to the headers it summarizes and avoids the toolbar's
1510
+ // status_bar_bg, which is a saturated accent in some themes
1511
+ // (Dracula's is hot pink — clashes badly with the diff content).
1512
+ style: { ...style, bg: STYLE_FILE_HEADER_BG, extendToLineEnd: true },
1513
+ properties: { type: "sticky-header" },
1514
+ }]);
1515
+ }
1516
+
1517
+ /**
1518
+ * Helper: jump the diff cursor to the file's first hunk (or its file
1519
+ * header if it has no hunks). Auto-expands the file if collapsed.
1520
+ */
1521
+ function jumpToFile(file: FileEntry): void {
1522
+ const key = fileKey(file);
1523
+ if (state.collapsedFiles.has(key)) {
1524
+ state.collapsedFiles.delete(key);
658
1525
  updateMagitDisplay();
659
1526
  }
1527
+ // Prefer first hunk row; fall back to the file-header row.
1528
+ const fileIdx = state.files.indexOf(file);
1529
+ if (fileIdx >= 0) {
1530
+ // Compute visible hunk index of the first hunk for this file.
1531
+ let visibleIdx = 0;
1532
+ let foundGlobal = -1;
1533
+ for (let i = 0; i < state.hunks.length; i++) {
1534
+ const h = state.hunks[i];
1535
+ const hKey = fileKeyOf(h.file, h.gitStatus || 'unstaged');
1536
+ if (state.collapsedFiles.has(hKey)) continue;
1537
+ if (h.file === file.path && h.gitStatus === file.category) {
1538
+ foundGlobal = i;
1539
+ break;
1540
+ }
1541
+ visibleIdx++;
1542
+ }
1543
+ if (foundGlobal >= 0) {
1544
+ const row = state.hunkHeaderRows[visibleIdx];
1545
+ if (row !== undefined) { jumpDiffCursorToRow(row); return; }
1546
+ }
1547
+ }
1548
+ const headerRow = state.fileHeaderRows[key];
1549
+ if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
660
1550
  }
661
- registerHandler("review_nav_up", review_nav_up);
662
1551
 
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();
1552
+ /**
1553
+ * Mouse click handler. Routes clicks to the appropriate behavior:
1554
+ * * Diff buffer file-header row → toggle that file's collapse state.
1555
+ * * Sticky panel → jump to the currently-pinned file's first hunk.
1556
+ * * Comments panel row → jump diff cursor to that comment's location
1557
+ * (auto-expanding the file when collapsed) and select the row.
1558
+ */
1559
+ function on_review_mouse_click(data: {
1560
+ column: number; row: number; button: string; modifiers: string;
1561
+ content_x: number; content_y: number;
1562
+ buffer_id: number | null; buffer_row: number | null; buffer_col: number | null;
1563
+ }): void {
1564
+ if (state.groupId === null) return;
1565
+ if (data.buffer_id === null || data.buffer_row === null) return;
1566
+
1567
+ const diffId = state.panelBuffers["diff"];
1568
+ const stickyId = state.panelBuffers["sticky"];
1569
+ const commentsId = state.panelBuffers["comments"];
1570
+
1571
+ // Click in the diff buffer: section headers and file headers are
1572
+ // both interactive — clicking either toggles its fold state.
1573
+ if (data.buffer_id === diffId) {
1574
+ const targetRow1 = data.buffer_row + 1;
1575
+ // Section header click: toggle the whole category.
1576
+ for (const cat of Object.keys(state.sectionHeaderRows)) {
1577
+ if (state.sectionHeaderRows[cat] === targetRow1) {
1578
+ if (state.collapsedSections.has(cat)) state.collapsedSections.delete(cat);
1579
+ else state.collapsedSections.add(cat);
1580
+ applyFolds();
1581
+ const sectionRow = state.sectionHeaderRows[cat];
1582
+ if (sectionRow !== undefined) jumpDiffCursorToRow(sectionRow);
1583
+ return;
1584
+ }
670
1585
  }
671
- } else {
672
- state.diffScrollOffset++;
673
- updateMagitDisplay();
1586
+ // File header click: toggle the single file.
1587
+ for (const f of state.files) {
1588
+ if (state.fileHeaderRows[fileKey(f)] === targetRow1) {
1589
+ const key = fileKey(f);
1590
+ if (state.collapsedFiles.has(key)) state.collapsedFiles.delete(key);
1591
+ else state.collapsedFiles.add(key);
1592
+ applyFolds();
1593
+ const headerRow = state.fileHeaderRows[key];
1594
+ if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
1595
+ return;
1596
+ }
1597
+ }
1598
+ // Hunk header click: toggle the single hunk.
1599
+ for (const hunkId of Object.keys(state.hunkRowByHunkId)) {
1600
+ if (state.hunkRowByHunkId[hunkId] === targetRow1) {
1601
+ if (state.collapsedHunks.has(hunkId)) state.collapsedHunks.delete(hunkId);
1602
+ else state.collapsedHunks.add(hunkId);
1603
+ applyFolds();
1604
+ const hunkRow = state.hunkRowByHunkId[hunkId];
1605
+ if (hunkRow !== undefined) jumpDiffCursorToRow(hunkRow);
1606
+ return;
1607
+ }
1608
+ }
1609
+ return;
1610
+ }
1611
+
1612
+ // Click on the sticky pinned-header: jump to the pinned file's first hunk.
1613
+ if (data.buffer_id === stickyId) {
1614
+ // Re-derive the pinned file from current viewport top.
1615
+ const top1 = state.diffCursorRow; // approximation; sticky tracks topmost visible
1616
+ let bestFile: FileEntry | null = null;
1617
+ let bestRow = 0;
1618
+ for (const f of state.files) {
1619
+ const row = state.fileHeaderRows[fileKey(f)];
1620
+ if (row !== undefined && row <= top1 && row > bestRow) {
1621
+ bestRow = row;
1622
+ bestFile = f;
1623
+ }
1624
+ }
1625
+ if (bestFile) jumpToFile(bestFile);
1626
+ return;
1627
+ }
1628
+
1629
+ // Click in the comments panel: jump to the comment's location and
1630
+ // hand focus to the diff so the user can immediately keep navigating.
1631
+ if (data.buffer_id === commentsId) {
1632
+ const targetRow1 = data.buffer_row + 1;
1633
+ const commentId = state.commentsByRow[targetRow1];
1634
+ if (commentId) {
1635
+ state.commentsSelectedRow = targetRow1;
1636
+ jumpToComment(commentId);
1637
+ editor.focusBufferGroupPanel(state.groupId, "diff");
1638
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1639
+ }
1640
+ return;
674
1641
  }
675
1642
  }
676
- registerHandler("review_nav_down", review_nav_down);
1643
+ registerHandler("on_review_mouse_click", on_review_mouse_click);
677
1644
 
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();
1645
+ /**
1646
+ * Jump the diff cursor to the line associated with a comment, auto-
1647
+ * expanding the comment's file if it is currently collapsed.
1648
+ */
1649
+ function jumpToComment(commentId: string): void {
1650
+ const comment = state.comments.find(c => c.id === commentId);
1651
+ if (!comment) return;
1652
+ const hunk = state.hunks.find(h => h.id === comment.hunk_id);
1653
+ if (!hunk) return;
1654
+ // Auto-expand whatever's between the cursor and this comment.
1655
+ let needRebuild = false;
1656
+ if (hunk.gitStatus && state.collapsedSections.has(hunk.gitStatus)) {
1657
+ state.collapsedSections.delete(hunk.gitStatus);
1658
+ needRebuild = true;
1659
+ }
1660
+ const file = state.files.find(f => f.path === hunk.file && f.category === hunk.gitStatus);
1661
+ if (file) {
1662
+ const key = fileKey(file);
1663
+ if (state.collapsedFiles.has(key)) {
1664
+ state.collapsedFiles.delete(key);
1665
+ needRebuild = true;
685
1666
  }
686
- } else {
687
- state.diffScrollOffset = Math.max(0, state.diffScrollOffset - mainRows);
688
- updateMagitDisplay();
689
1667
  }
1668
+ if (state.collapsedHunks.has(hunk.id)) {
1669
+ state.collapsedHunks.delete(hunk.id);
1670
+ needRebuild = true;
1671
+ }
1672
+ if (needRebuild) updateMagitDisplay();
1673
+ // Pin this comment as the highlighted one BEFORE jumping. Any
1674
+ // subsequent cursor_moved event that re-derives the highlight
1675
+ // will recompute the same id; doing it eagerly avoids a flicker
1676
+ // (and works even when the cursor lands on a row whose props
1677
+ // don't directly carry a comment id).
1678
+ const prevHighlight = state.commentsHighlightId;
1679
+ state.commentsHighlightId = commentId;
1680
+ if (state.groupId !== null && prevHighlight !== commentId) {
1681
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1682
+ }
1683
+ // Prefer the diff line the comment is anchored to (line-based);
1684
+ // fall back to the hunk header if the lookup hasn't seen the
1685
+ // comment yet (race / first render).
1686
+ const lineRow = state.diffLineRowByCommentId[commentId];
1687
+ if (lineRow !== undefined) { jumpDiffCursorToRow(lineRow); return; }
1688
+ const hunkRow = state.hunkRowByHunkId[hunk.id];
1689
+ if (hunkRow !== undefined) jumpDiffCursorToRow(hunkRow);
690
1690
  }
691
- registerHandler("review_page_up", review_page_up);
692
1691
 
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();
1692
+ function on_review_viewport_changed(data: { split_id: number; buffer_id: number; top_byte: number; top_line: number | null; width: number; height: number }): void {
1693
+ if (state.groupId === null) return;
1694
+ if (data.buffer_id !== state.panelBuffers["diff"]) return;
1695
+ // Prefer top_line when the host provides it. Virtual buffers may not
1696
+ // have line metadata, in which case top_line is null — fall back to
1697
+ // converting top_byte using our own row-byte index.
1698
+ const topRow = data.top_line ?? rowFromByte(data.top_byte);
1699
+ state.diffViewportTopRow = topRow;
1700
+ refreshStickyHeader(topRow);
1701
+ }
1702
+ registerHandler("on_review_viewport_changed", on_review_viewport_changed);
1703
+
1704
+ /**
1705
+ * Binary-search `state.diffLineByteOffsets` for the 0-indexed row
1706
+ * whose byte offset is the largest one ≤ topByte.
1707
+ */
1708
+ function rowFromByte(topByte: number): number {
1709
+ const offs = state.diffLineByteOffsets;
1710
+ if (offs.length === 0) return 0;
1711
+ let lo = 0;
1712
+ let hi = offs.length - 1;
1713
+ while (lo < hi) {
1714
+ const mid = (lo + hi + 1) >> 1;
1715
+ if (offs[mid] <= topByte) lo = mid;
1716
+ else hi = mid - 1;
1717
+ }
1718
+ return lo;
1719
+ }
1720
+
1721
+ /**
1722
+ * Repaint the synthetic "cursor line" highlight in the diff panel.
1723
+ *
1724
+ * The diff panel buffer is created with show_cursors=true so the editor
1725
+ * moves the cursor natively, but a single-line bg overlay on the cursor row
1726
+ * gives a much more visible "you are here" indicator than the bare caret —
1727
+ * which matches the magit-style aesthetic and is what the user expects.
1728
+ */
1729
+ function applyCursorLineOverlay(panel: 'diff'): void {
1730
+ const bufId = state.panelBuffers[panel];
1731
+ if (bufId === undefined) return;
1732
+ editor.clearNamespace(bufId, CURSOR_LINE_NS);
1733
+ const offsets = state.diffLineByteOffsets;
1734
+ if (offsets.length < 2) return;
1735
+ const idx = Math.max(0, Math.min(state.diffCursorRow - 1, offsets.length - 2));
1736
+ const start = offsets[idx];
1737
+ const end = offsets[idx + 1];
1738
+ if (end <= start) return;
1739
+ editor.addOverlay(bufId, CURSOR_LINE_NS, start, end, {
1740
+ bg: STYLE_SELECTED_BG,
1741
+ extendToLineEnd: true,
1742
+ });
1743
+ }
1744
+
1745
+ function review_refresh() { refreshMagitData(); }
1746
+ registerHandler("review_refresh", review_refresh);
1747
+
1748
+ // --- Cursor-driven navigation ---
1749
+ //
1750
+ // In the unified-stream layout the diff panel owns the editor's native
1751
+ // cursor; j/k/Up/Down/PageUp/PageDown/Home/End delegate directly to the
1752
+ // editor's built-in motion actions via `executeAction`. The plugin only
1753
+ // observes `cursor_moved` events to repaint the cursor-line overlay and
1754
+ // keep `state.diffCursorRow` in sync.
1755
+
1756
+ /**
1757
+ * Derive the "current file" (FileEntry) from the cursor row in the unified
1758
+ * diff stream — the file whose header row is the largest one ≤ the cursor
1759
+ * row. Returns null if no file header is at or above the cursor (cursor
1760
+ * sits in the empty preamble or there are no files).
1761
+ */
1762
+ function currentFileFromCursor(): FileEntry | null {
1763
+ let bestFile: FileEntry | null = null;
1764
+ let bestRow = 0;
1765
+ for (const f of state.files) {
1766
+ const row = state.fileHeaderRows[fileKey(f)];
1767
+ if (row !== undefined && row <= state.diffCursorRow && row > bestRow) {
1768
+ bestRow = row;
1769
+ bestFile = f;
700
1770
  }
701
- } else {
702
- state.diffScrollOffset += mainRows;
703
- updateMagitDisplay();
704
1771
  }
1772
+ return bestFile;
705
1773
  }
706
- registerHandler("review_page_down", review_page_down);
707
1774
 
708
- function review_toggle_focus() {
709
- state.focusPanel = state.focusPanel === 'files' ? 'diff' : 'files';
710
- updateMagitDisplay();
1775
+ /** Look up the entry's properties for the cursor's current row. Uses
1776
+ * the per-row props map populated during build, which is exact —
1777
+ * unlike `editor.getTextPropertiesAtCursor`, which can return the
1778
+ * previous row's properties when the cursor sits at a row boundary. */
1779
+ function propsAtCursorRow(): Record<string, unknown> | null {
1780
+ return state.entryPropsByRow[state.diffCursorRow] || null;
711
1781
  }
712
- registerHandler("review_toggle_focus", review_toggle_focus);
713
1782
 
714
- function review_focus_files() {
715
- if (state.focusPanel !== 'files') {
716
- state.focusPanel = 'files';
717
- updateMagitDisplay();
1783
+ function sectionUnderCursor(): string | null {
1784
+ const props = propsAtCursorRow();
1785
+ if (!props || props["type"] !== 'section-header') return null;
1786
+ const filePath = props["filePath"];
1787
+ return typeof filePath === 'string' ? filePath : null;
1788
+ }
1789
+
1790
+ /**
1791
+ * Tab dispatches to the *nearest ancestor* of the cursor's row:
1792
+ * * Section header → toggle the section.
1793
+ * * File header → toggle the file.
1794
+ * * Anywhere inside a hunk (header, body, inline comment) → toggle
1795
+ * the hunk.
1796
+ * * Blank line above any file header (i.e. cursor inside a file's
1797
+ * diff before its first hunk) → toggle that file.
1798
+ * * Cursor in the comments panel → swap focus back to the diff.
1799
+ */
1800
+ function review_toggle_file_collapse() {
1801
+ if (state.groupId === null) return;
1802
+ if (state.focusPanel === 'comments') {
1803
+ editor.focusBufferGroupPanel(state.groupId, "diff");
1804
+ return;
1805
+ }
1806
+ if (state.files.length === 0) return;
1807
+
1808
+ // Section header → toggle whole section.
1809
+ const section = sectionUnderCursor();
1810
+ if (section) {
1811
+ if (state.collapsedSections.has(section)) state.collapsedSections.delete(section);
1812
+ else state.collapsedSections.add(section);
1813
+ applyFolds();
1814
+ const sectionRow = state.sectionHeaderRows[section];
1815
+ if (sectionRow !== undefined) jumpDiffCursorToRow(sectionRow);
1816
+ return;
1817
+ }
1818
+
1819
+ // File header → toggle whole file.
1820
+ const headerFile = fileHeaderUnderCursor();
1821
+ if (headerFile) {
1822
+ const key = fileKey(headerFile);
1823
+ if (state.collapsedFiles.has(key)) state.collapsedFiles.delete(key);
1824
+ else state.collapsedFiles.add(key);
1825
+ applyFolds();
1826
+ const headerRow = state.fileHeaderRows[key];
1827
+ if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
1828
+ return;
1829
+ }
1830
+
1831
+ // Hunk (header / body / inline comment) → toggle that hunk.
1832
+ const hunk = getHunkAtDiffCursor();
1833
+ if (hunk) {
1834
+ if (state.collapsedHunks.has(hunk.id)) state.collapsedHunks.delete(hunk.id);
1835
+ else state.collapsedHunks.add(hunk.id);
1836
+ applyFolds();
1837
+ const hunkRow = state.hunkRowByHunkId[hunk.id];
1838
+ if (hunkRow !== undefined) jumpDiffCursorToRow(hunkRow);
1839
+ return;
718
1840
  }
1841
+
1842
+ // Fall back to the parent file if cursor is in a no-man's-land (e.g.
1843
+ // blank separator after the last hunk of a file).
1844
+ const fallbackFile = currentFileFromCursor();
1845
+ if (!fallbackFile) return;
1846
+ const key = fileKey(fallbackFile);
1847
+ if (state.collapsedFiles.has(key)) state.collapsedFiles.delete(key);
1848
+ else state.collapsedFiles.add(key);
1849
+ applyFolds();
1850
+ const headerRow = state.fileHeaderRows[key];
1851
+ if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
719
1852
  }
720
- registerHandler("review_focus_files", review_focus_files);
1853
+ registerHandler("review_toggle_file_collapse", review_toggle_file_collapse);
721
1854
 
722
- function review_focus_diff() {
723
- if (state.focusPanel !== 'diff') {
724
- state.focusPanel = 'diff';
725
- updateMagitDisplay();
1855
+ /**
1856
+ * Order comments the same way the comments panel does — by file order
1857
+ * in the unified stream, then by line number. Keeping the ordering
1858
+ * here in sync with `buildCommentsPanelEntries` is important so that
1859
+ * keyboard navigation lands on the same row the user sees.
1860
+ *
1861
+ * Builds an O(F) path -> index map once per call instead of doing a
1862
+ * linear scan of state.files for every comment in the sort comparator.
1863
+ */
1864
+ function commentsInPanelOrder(): ReviewComment[] {
1865
+ const fileIdx: Record<string, number> = {};
1866
+ for (let i = 0; i < state.files.length; i++) fileIdx[state.files[i].path] = i;
1867
+ return [...state.comments].sort((a, b) => {
1868
+ const fa = fileIdx[a.file] ?? Number.MAX_SAFE_INTEGER;
1869
+ const fb = fileIdx[b.file] ?? Number.MAX_SAFE_INTEGER;
1870
+ if (fa !== fb) return fa - fb;
1871
+ return (a.new_line ?? a.old_line ?? 0) - (b.new_line ?? b.old_line ?? 0);
1872
+ });
1873
+ }
1874
+
1875
+ function selectAndJumpToComment(c: ReviewComment) {
1876
+ if (state.groupId === null) return;
1877
+ jumpToComment(c.id);
1878
+ // Find the comment's row in the panel (header is row 1, comments start at 2).
1879
+ const sorted = commentsInPanelOrder();
1880
+ const idx = sorted.findIndex(x => x.id === c.id);
1881
+ if (idx >= 0) {
1882
+ state.commentsSelectedRow = idx + 2;
1883
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
726
1884
  }
727
1885
  }
728
- registerHandler("review_focus_diff", review_focus_diff);
729
1886
 
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();
1887
+ function review_next_comment() {
1888
+ if (state.comments.length === 0) {
1889
+ editor.setStatus(editor.t("status.no_comments") || "No comments");
1890
+ return;
739
1891
  }
1892
+ const sorted = commentsInPanelOrder();
1893
+ // Determine the comment-id currently under the diff cursor (if any).
1894
+ const currentRow = state.commentsSelectedRow;
1895
+ const currentIdx = currentRow >= 2 ? currentRow - 2 : -1;
1896
+ const nextIdx = Math.min(sorted.length - 1, currentIdx + 1);
1897
+ if (nextIdx === currentIdx && currentIdx >= 0) return;
1898
+ selectAndJumpToComment(sorted[nextIdx >= 0 ? nextIdx : 0]);
740
1899
  }
741
- registerHandler("review_nav_home", review_nav_home);
1900
+ registerHandler("review_next_comment", review_next_comment);
742
1901
 
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();
1902
+ function review_prev_comment() {
1903
+ if (state.comments.length === 0) {
1904
+ editor.setStatus(editor.t("status.no_comments") || "No comments");
1905
+ return;
1906
+ }
1907
+ const sorted = commentsInPanelOrder();
1908
+ const currentRow = state.commentsSelectedRow;
1909
+ const currentIdx = currentRow >= 2 ? currentRow - 2 : sorted.length;
1910
+ const prevIdx = Math.max(0, currentIdx - 1);
1911
+ selectAndJumpToComment(sorted[prevIdx]);
1912
+ }
1913
+ registerHandler("review_prev_comment", review_prev_comment);
1914
+
1915
+ /**
1916
+ * Focus the comments panel. Uses native focus-swap so the buffer's
1917
+ * native cursor takes the keystrokes (j/k/Enter handled by the
1918
+ * comments-mode keybindings).
1919
+ */
1920
+ function review_focus_comments() {
1921
+ if (state.groupId === null) return;
1922
+ editor.focusBufferGroupPanel(state.groupId, "comments");
1923
+ // Ensure the selection highlight shows immediately.
1924
+ if (state.commentsSelectedRow < 2 && state.comments.length > 0) {
1925
+ state.commentsSelectedRow = 2;
1926
+ }
1927
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1928
+ }
1929
+ registerHandler("review_focus_comments", review_focus_comments);
1930
+
1931
+ /**
1932
+ * Activate the currently-selected comment in the comments panel:
1933
+ * jump the diff cursor to it (auto-expanding the file if collapsed).
1934
+ */
1935
+ function review_open_selected_comment() {
1936
+ if (state.commentsSelectedRow < 2) return;
1937
+ const commentId = state.commentsByRow[state.commentsSelectedRow];
1938
+ if (!commentId) return;
1939
+ jumpToComment(commentId);
1940
+ }
1941
+ registerHandler("review_open_selected_comment", review_open_selected_comment);
1942
+
1943
+ function review_comments_select_next() {
1944
+ if (state.groupId === null) return;
1945
+ if (state.comments.length === 0) return;
1946
+ const total = state.comments.length;
1947
+ const currentIdx = Math.max(0, state.commentsSelectedRow - 2);
1948
+ const nextIdx = Math.min(total - 1, currentIdx + 1);
1949
+ state.commentsSelectedRow = nextIdx + 2;
1950
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1951
+ }
1952
+ registerHandler("review_comments_select_next", review_comments_select_next);
1953
+
1954
+ function review_enter_dispatch() {
1955
+ if (state.focusPanel === 'comments') {
1956
+ review_open_selected_comment();
1957
+ return;
1958
+ }
1959
+ const props = propsAtCursorRow();
1960
+ if (!props) return;
1961
+ const t = props["type"];
1962
+ // On a file or section header, Enter doubles as Tab: toggle the
1963
+ // header's collapse state. Matches the intuition that a header is a
1964
+ // disclosure widget — pressing the primary key on it should expand
1965
+ // or fold the thing it owns, not drill down.
1966
+ if (t === 'file-header' || t === 'section-header') {
1967
+ review_toggle_file_collapse();
1968
+ return;
1969
+ }
1970
+ // Inside a file's diff content, drill down to side-by-side view.
1971
+ // Blank separators and comment rows are quietly ignored to avoid
1972
+ // drilling into whatever file the cursor happens to be adjacent to.
1973
+ if (t === 'add' || t === 'remove' || t === 'context' || t === 'hunk-header') {
1974
+ review_drill_down();
1975
+ }
1976
+ }
1977
+ registerHandler("review_enter_dispatch", review_enter_dispatch);
1978
+
1979
+ function review_comments_select_prev() {
1980
+ if (state.groupId === null) return;
1981
+ if (state.comments.length === 0) return;
1982
+ const currentIdx = Math.max(0, state.commentsSelectedRow - 2);
1983
+ const prevIdx = Math.max(0, currentIdx - 1);
1984
+ state.commentsSelectedRow = prevIdx + 2;
1985
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1986
+ }
1987
+ registerHandler("review_comments_select_prev", review_comments_select_prev);
1988
+
1989
+ /**
1990
+ * Visual line-selection mode. Activates a multi-row selection rooted
1991
+ * at the cursor's hunk; j/k extend it; Esc cancels. The selection is
1992
+ * rendered as an inverted background overlay across the selected rows.
1993
+ */
1994
+ function review_visual_start() {
1995
+ if (state.groupId === null) return;
1996
+ const props = propsAtCursorRow();
1997
+ if (!props) return;
1998
+ const hunkId = props["hunkId"];
1999
+ const lineType = props["lineType"];
2000
+ if (typeof hunkId !== 'string' || (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context')) {
2001
+ editor.setStatus(editor.t("status.visual_no_diff_line") || "Visual selection requires a diff line");
2002
+ return;
2003
+ }
2004
+ state.lineSelection = {
2005
+ startRow: state.diffCursorRow,
2006
+ endRow: state.diffCursorRow,
2007
+ hunkId,
2008
+ };
2009
+ paintLineSelectionOverlay();
2010
+ editor.setStatus(editor.t("status.visual_started") || "Visual: j/k extend, s/u/d apply, Esc cancel");
2011
+ }
2012
+ registerHandler("review_visual_start", review_visual_start);
2013
+
2014
+ function review_visual_cancel() {
2015
+ state.lineSelection = null;
2016
+ if (state.groupId !== null) {
2017
+ const diffId = state.panelBuffers["diff"];
2018
+ if (diffId !== undefined) editor.clearNamespace(diffId, "review-line-selection");
2019
+ }
2020
+ applyCursorLineOverlay('diff');
2021
+ }
2022
+ registerHandler("review_visual_cancel", review_visual_cancel);
2023
+
2024
+ const LINE_SELECTION_NS = "review-line-selection";
2025
+
2026
+ function paintLineSelectionOverlay() {
2027
+ if (state.groupId === null) return;
2028
+ const diffId = state.panelBuffers["diff"];
2029
+ if (diffId === undefined) return;
2030
+ editor.clearNamespace(diffId, LINE_SELECTION_NS);
2031
+ if (!state.lineSelection) return;
2032
+ const { startRow, endRow } = state.lineSelection;
2033
+ const lo = Math.min(startRow, endRow);
2034
+ const hi = Math.max(startRow, endRow);
2035
+ for (let r = lo; r <= hi; r++) {
2036
+ const idx = r - 1;
2037
+ if (idx < 0 || idx + 1 >= state.diffLineByteOffsets.length) continue;
2038
+ const start = state.diffLineByteOffsets[idx];
2039
+ const end = state.diffLineByteOffsets[idx + 1];
2040
+ if (end <= start) continue;
2041
+ editor.addOverlay(diffId, LINE_SELECTION_NS, start, end, {
2042
+ bg: STYLE_SELECTED_BG,
2043
+ extendToLineEnd: true,
2044
+ });
758
2045
  }
759
2046
  }
760
- registerHandler("review_nav_end", review_nav_end);
2047
+
2048
+ /**
2049
+ * Translate the active line-selection's (startRow, endRow) into a
2050
+ * lineRange (inclusive 0-indexed indices into `hunk.lines`) by walking
2051
+ * the rows of the unified stream that belong to the selection's hunk.
2052
+ *
2053
+ * Returns `null` if the selection crosses out of its hunk (which can't
2054
+ * happen given how j/k extend, but defensively guarded), or the hunk
2055
+ * can't be found, or the selection contains only context lines (which
2056
+ * makes stage/unstage a no-op).
2057
+ */
2058
+ function selectionLineRange(): { hunk: Hunk; range: { start: number; end: number } } | null {
2059
+ if (!state.lineSelection) return null;
2060
+ const sel = state.lineSelection;
2061
+ const hunk = state.hunks.find(h => h.id === sel.hunkId);
2062
+ if (!hunk) return null;
2063
+ // Find the row of this hunk's header in the unified stream.
2064
+ const hunkIdx = state.hunks.indexOf(hunk);
2065
+ let visibleIdx = 0;
2066
+ for (let i = 0; i < hunkIdx; i++) {
2067
+ const h = state.hunks[i];
2068
+ if (state.collapsedFiles.has(fileKeyOf(h.file, h.gitStatus || 'unstaged'))) continue;
2069
+ visibleIdx++;
2070
+ }
2071
+ const headerRow = state.hunkHeaderRows[visibleIdx];
2072
+ if (headerRow === undefined) return null;
2073
+
2074
+ const lo = Math.min(sel.startRow, sel.endRow);
2075
+ const hi = Math.max(sel.startRow, sel.endRow);
2076
+ const startInHunk = lo - headerRow - 1; // -1 because the header row itself is not in hunk.lines
2077
+ const endInHunk = hi - headerRow - 1;
2078
+ if (startInHunk < 0 || endInHunk >= hunk.lines.length) return null;
2079
+
2080
+ // Reject context-only selections.
2081
+ let hasChange = false;
2082
+ for (let i = startInHunk; i <= endInHunk; i++) {
2083
+ const ch = hunk.lines[i][0];
2084
+ if (ch === '+' || ch === '-') { hasChange = true; break; }
2085
+ }
2086
+ if (!hasChange) return null;
2087
+
2088
+ return { hunk, range: { start: startInHunk, end: endInHunk } };
2089
+ }
2090
+
2091
+ async function applyLineSelection(action: 'stage' | 'unstage' | 'discard') {
2092
+ const sel = selectionLineRange();
2093
+ if (!sel) {
2094
+ editor.setStatus(editor.t("status.visual_invalid") || "Selection has no add/remove lines or crosses hunk boundary");
2095
+ return;
2096
+ }
2097
+ const { hunk, range } = sel;
2098
+ const patch = buildHunkPatch(hunk.file, hunk, range);
2099
+ let flags: string[];
2100
+ if (action === 'stage') flags = ["--cached", "--unidiff-zero"];
2101
+ else if (action === 'unstage') flags = ["--cached", "--reverse", "--unidiff-zero"];
2102
+ else flags = ["--reverse", "--unidiff-zero"];
2103
+
2104
+ rememberPendingHunkAnchor(hunk.id);
2105
+ const ok = await applyHunkPatch(patch, flags);
2106
+ if (!ok) return;
2107
+ review_visual_cancel();
2108
+ editor.setStatus(editor.t(`status.lines_${action}d`) || `Lines ${action}d`);
2109
+ await refreshMagitData();
2110
+ }
2111
+
2112
+ function review_collapse_all() {
2113
+ // Remember which file the cursor is in so we can land on its
2114
+ // header row after every file collapses.
2115
+ const cur = currentFileFromCursor();
2116
+ state.collapsedFiles = new Set(state.files.map(fileKey));
2117
+ applyFolds();
2118
+ if (cur) {
2119
+ const headerRow = state.fileHeaderRows[fileKey(cur)];
2120
+ if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
2121
+ }
2122
+ }
2123
+ registerHandler("review_collapse_all", review_collapse_all);
2124
+
2125
+ function review_expand_all() {
2126
+ // Same intuition for unfold-all: keep the cursor on the file it was
2127
+ // in (rows shift as collapsed files/hunks re-emit their content).
2128
+ const cur = currentFileFromCursor();
2129
+ state.collapsedFiles.clear();
2130
+ state.collapsedSections.clear();
2131
+ state.collapsedHunks.clear();
2132
+ applyFolds();
2133
+ if (cur) {
2134
+ const headerRow = state.fileHeaderRows[fileKey(cur)];
2135
+ if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
2136
+ }
2137
+ }
2138
+ registerHandler("review_expand_all", review_expand_all);
2139
+
2140
+ function review_nav_up() {
2141
+ if (state.focusPanel === 'comments') { review_comments_select_prev(); return; }
2142
+ editor.executeAction("move_up");
2143
+ if (state.lineSelection) {
2144
+ // executeAction has already moved the cursor; sync the selection.
2145
+ // Ensure we don't extend out of the hunk.
2146
+ const newRow = Math.max(1, state.lineSelection.endRow - 1);
2147
+ state.lineSelection.endRow = newRow;
2148
+ paintLineSelectionOverlay();
2149
+ }
2150
+ }
2151
+ registerHandler("review_nav_up", review_nav_up);
2152
+
2153
+ function review_nav_down() {
2154
+ if (state.focusPanel === 'comments') { review_comments_select_next(); return; }
2155
+ editor.executeAction("move_down");
2156
+ if (state.lineSelection) {
2157
+ state.lineSelection.endRow = state.lineSelection.endRow + 1;
2158
+ paintLineSelectionOverlay();
2159
+ }
2160
+ }
2161
+ registerHandler("review_nav_down", review_nav_down);
2162
+
2163
+ function review_page_up() { editor.executeAction("move_page_up"); }
2164
+ registerHandler("review_page_up", review_page_up);
2165
+
2166
+ function review_page_down() { editor.executeAction("move_page_down"); }
2167
+ registerHandler("review_page_down", review_page_down);
2168
+ // Home / End intentionally NOT overridden — the editor's native
2169
+ // "move to start/end of line" is exactly what we want here. Mapping
2170
+ // them to move_document_start/end (as the old layout did when Home/
2171
+ // End served as files-pane shortcuts) made them useless on a unified
2172
+ // stream.
761
2173
 
762
2174
  // --- Real git stage/unstage/discard actions (Step 4) ---
763
2175
 
2176
+ /**
2177
+ * Build a minimal unified diff patch for a single hunk.
2178
+ *
2179
+ * When `lineRange` is provided, only the +/- lines whose indices fall
2180
+ * inside the inclusive range are kept; +/- lines outside the range are
2181
+ * converted to context lines so that the patch still applies cleanly
2182
+ * to the file. Context lines are always preserved.
2183
+ */
2184
+ function buildHunkPatch(filePath: string, hunk: Hunk, lineRange?: { start: number; end: number }): string {
2185
+ const filtered: string[] = [];
2186
+ let oldCount = 0;
2187
+ let newCount = 0;
2188
+
2189
+ for (let i = 0; i < hunk.lines.length; i++) {
2190
+ const line = hunk.lines[i];
2191
+ const ch = line[0];
2192
+ const inRange = !lineRange || (i >= lineRange.start && i <= lineRange.end);
2193
+ if (ch === '+') {
2194
+ if (inRange) {
2195
+ filtered.push(line);
2196
+ newCount++;
2197
+ } else {
2198
+ // An out-of-range '+' line means: this addition isn't being
2199
+ // applied, so it shouldn't appear in either side. Drop it
2200
+ // entirely (don't convert to context — there's nothing to
2201
+ // match in the source file).
2202
+ }
2203
+ } else if (ch === '-') {
2204
+ if (inRange) {
2205
+ filtered.push(line);
2206
+ oldCount++;
2207
+ } else {
2208
+ // An out-of-range '-' line: this deletion isn't applied,
2209
+ // so the line still exists on both sides — render as context.
2210
+ filtered.push(' ' + line.substring(1));
2211
+ oldCount++;
2212
+ newCount++;
2213
+ }
2214
+ } else {
2215
+ filtered.push(line);
2216
+ oldCount++;
2217
+ newCount++;
2218
+ }
2219
+ }
2220
+
2221
+ const header = `@@ -${hunk.oldRange.start},${oldCount} +${hunk.range.start},${newCount} @@`;
2222
+ return [
2223
+ `diff --git a/${filePath} b/${filePath}`,
2224
+ `--- a/${filePath}`,
2225
+ `+++ b/${filePath}`,
2226
+ header,
2227
+ ...filtered,
2228
+ ''
2229
+ ].join('\n');
2230
+ }
2231
+
2232
+ /**
2233
+ * Write a patch to a temp file and apply it with the given flags.
2234
+ * Returns true on success.
2235
+ */
2236
+ async function applyHunkPatch(patch: string, flags: string[]): Promise<boolean> {
2237
+ const tmpDir = editor.getTempDir();
2238
+ const patchPath = editor.pathJoin(tmpDir, `fresh-review-${Date.now()}.patch`);
2239
+ editor.writeFile(patchPath, patch);
2240
+ // Validate first
2241
+ const check = await editor.spawnProcess("git", ["apply", "--check", ...flags, patchPath]);
2242
+ if (check.exit_code !== 0) {
2243
+ editor.setStatus("Patch failed: " + (check.stderr || "").trim());
2244
+ return false;
2245
+ }
2246
+ const result = await editor.spawnProcess("git", ["apply", ...flags, patchPath]);
2247
+ return result.exit_code === 0;
2248
+ }
2249
+
2250
+ /**
2251
+ * Merge all text-property records at the cursor of the given panel buffer
2252
+ * into a single object. There's typically only one record covering each
2253
+ * cursor position; merging keeps callers simple.
2254
+ */
2255
+ function readPropsAtCursor(panel: 'files' | 'diff'): Record<string, unknown> | null {
2256
+ const bufId = state.panelBuffers[panel];
2257
+ if (bufId === undefined) return null;
2258
+ const records = editor.getTextPropertiesAtCursor(bufId);
2259
+ if (!records || records.length === 0) return null;
2260
+ const merged: Record<string, unknown> = {};
2261
+ for (const r of records) Object.assign(merged, r);
2262
+ return merged;
2263
+ }
2264
+
2265
+ /**
2266
+ * Get the hunk under the cursor in the diff panel, or null.
2267
+ *
2268
+ * Reads the `hunkId` text property embedded by `buildDiffPanelEntries`. Falls
2269
+ * back to the first hunk of the selected file when the cursor is somewhere
2270
+ * without a hunkId (e.g. the panel header) so commands like `s` still do
2271
+ * something useful.
2272
+ */
2273
+ function getHunkAtDiffCursor(): Hunk | null {
2274
+ const props = propsAtCursorRow();
2275
+ const hunkId = props ? props["hunkId"] : undefined;
2276
+ if (typeof hunkId === 'string') {
2277
+ const found = state.hunks.find(h => h.id === hunkId);
2278
+ if (found) return found;
2279
+ }
2280
+ // Fallback: first hunk for the file under the cursor (if any).
2281
+ const cur = currentFileFromCursor();
2282
+ if (!cur) return null;
2283
+ return state.hunks.find(
2284
+ h => h.file === cur.path && h.gitStatus === cur.category
2285
+ ) || null;
2286
+ }
2287
+
2288
+ /**
2289
+ * Determine if the cursor is on a file-header row. Returns the FileEntry
2290
+ * if so, otherwise null.
2291
+ *
2292
+ * Looks up by `fileKey` (path + category) — looking up by `path` alone
2293
+ * is wrong when the same file appears in both Staged and Unstaged: the
2294
+ * `state.files.find(... === path)` would always return the first
2295
+ * matching entry (typically the staged one), so Tab on the unstaged
2296
+ * file header would silently act on the staged file instead.
2297
+ */
2298
+ function fileHeaderUnderCursor(): FileEntry | null {
2299
+ const props = propsAtCursorRow();
2300
+ if (!props || props["type"] !== 'file-header') return null;
2301
+ const key = props["fileKey"];
2302
+ if (typeof key !== 'string') return null;
2303
+ return state.files.find(f => fileKey(f) === key) || null;
2304
+ }
2305
+
2306
+ /**
2307
+ * Stage at the appropriate scope based on cursor context:
2308
+ * * file header → stage the whole file
2309
+ * * hunk → stage just that hunk
2310
+ */
2311
+ async function review_stage_scope() {
2312
+ if (state.files.length === 0) return;
2313
+ if (state.lineSelection) { await applyLineSelection('stage'); return; }
2314
+ const headerFile = fileHeaderUnderCursor();
2315
+ if (headerFile) {
2316
+ await stageFileEntry(headerFile);
2317
+ return;
2318
+ }
2319
+ await stageHunk(getHunkAtDiffCursor());
2320
+ }
2321
+ registerHandler("review_stage_scope", review_stage_scope);
2322
+
2323
+ async function review_unstage_scope() {
2324
+ if (state.files.length === 0) return;
2325
+ if (state.lineSelection) { await applyLineSelection('unstage'); return; }
2326
+ const headerFile = fileHeaderUnderCursor();
2327
+ if (headerFile) {
2328
+ await unstageFileEntry(headerFile);
2329
+ return;
2330
+ }
2331
+ await unstageHunk(getHunkAtDiffCursor());
2332
+ }
2333
+ registerHandler("review_unstage_scope", review_unstage_scope);
2334
+
2335
+ /**
2336
+ * Always-file-level staging (S / U). Acts on the file the cursor is
2337
+ * currently inside, regardless of whether it's on a header or a hunk.
2338
+ */
764
2339
  async function review_stage_file() {
765
2340
  if (state.files.length === 0) return;
766
- const f = state.files[state.selectedIndex];
2341
+ const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
767
2342
  if (!f) return;
768
- await editor.spawnProcess("git", ["add", "--", f.path]);
769
- await refreshMagitData();
2343
+ await stageFileEntry(f);
770
2344
  }
771
2345
  registerHandler("review_stage_file", review_stage_file);
772
2346
 
773
2347
  async function review_unstage_file() {
774
2348
  if (state.files.length === 0) return;
775
- const f = state.files[state.selectedIndex];
2349
+ const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
776
2350
  if (!f) return;
2351
+ await unstageFileEntry(f);
2352
+ }
2353
+ registerHandler("review_unstage_file", review_unstage_file);
2354
+
2355
+ async function stageFileEntry(f: FileEntry) {
2356
+ rememberPendingHunkAnchor(null);
2357
+ await editor.spawnProcess("git", ["add", "--", f.path]);
2358
+ await refreshMagitData();
2359
+ }
2360
+
2361
+ async function unstageFileEntry(f: FileEntry) {
2362
+ rememberPendingHunkAnchor(null);
777
2363
  await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
778
2364
  await refreshMagitData();
779
2365
  }
780
- registerHandler("review_unstage_file", review_unstage_file);
2366
+
2367
+ async function stageHunk(hunk: Hunk | null) {
2368
+ if (!hunk || !hunk.file) return;
2369
+ rememberPendingHunkAnchor(hunk.id);
2370
+ if (hunk.gitStatus === 'untracked') {
2371
+ await editor.spawnProcess("git", ["add", "--", hunk.file]);
2372
+ } else {
2373
+ const patch = buildHunkPatch(hunk.file, hunk);
2374
+ const ok = await applyHunkPatch(patch, ["--cached"]);
2375
+ if (!ok) return;
2376
+ }
2377
+ editor.setStatus(editor.t("status.hunk_staged") || "Hunk staged");
2378
+ await refreshMagitData();
2379
+ }
2380
+
2381
+ async function unstageHunk(hunk: Hunk | null) {
2382
+ if (!hunk || !hunk.file || hunk.gitStatus !== 'staged') {
2383
+ editor.setStatus("Can only unstage staged hunks");
2384
+ return;
2385
+ }
2386
+ rememberPendingHunkAnchor(hunk.id);
2387
+ const patch = buildHunkPatch(hunk.file, hunk);
2388
+ const ok = await applyHunkPatch(patch, ["--cached", "--reverse"]);
2389
+ if (!ok) return;
2390
+ editor.setStatus(editor.t("status.hunk_unstaged") || "Hunk unstaged");
2391
+ await refreshMagitData();
2392
+ }
2393
+
2394
+ /**
2395
+ * Cursor continuity: remember the hunk-id we just acted on so that
2396
+ * after the rebuild we can land the cursor back on the same hunk
2397
+ * (which may have moved between sections), or on the nearest survivor.
2398
+ */
2399
+ let pendingHunkAnchor: { hunkId: string | null; section: string | null; row: number } | null = null;
2400
+ function rememberPendingHunkAnchor(hunkId: string | null) {
2401
+ const cur = getHunkAtDiffCursor();
2402
+ pendingHunkAnchor = {
2403
+ hunkId,
2404
+ section: cur?.gitStatus ?? null,
2405
+ row: state.diffCursorRow,
2406
+ };
2407
+ }
2408
+
2409
+ let pendingDiscardFile: FileEntry | null = null;
2410
+
2411
+ /** Always-file-level discard (D). Acts on the file the cursor is in. */
2412
+ function review_discard_file_only() {
2413
+ if (state.files.length === 0) return;
2414
+ const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
2415
+ if (!f) return;
2416
+ pendingDiscardFile = f;
2417
+ rememberPendingHunkAnchor(null);
2418
+ const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
2419
+ editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
2420
+ const suggestions: PromptSuggestion[] = [
2421
+ { text: `${action} file`, description: "Permanently lose changes", value: "discard" },
2422
+ { text: "Cancel", description: "Keep the file as-is", value: "cancel" },
2423
+ ];
2424
+ editor.setPromptSuggestions(suggestions);
2425
+ }
2426
+ registerHandler("review_discard_file_only", review_discard_file_only);
781
2427
 
782
2428
  function review_discard_file() {
783
2429
  if (state.files.length === 0) return;
784
- const f = state.files[state.selectedIndex];
2430
+ if (state.lineSelection) { void applyLineSelection('discard'); return; }
2431
+ const headerFile = fileHeaderUnderCursor();
2432
+ const f = headerFile ?? currentFileFromCursor();
2433
+ if (!headerFile) {
2434
+ // No file-header under cursor → hunk-level discard
2435
+ const hunk = getHunkAtDiffCursor();
2436
+ if (!hunk || !hunk.file) return;
2437
+ rememberPendingHunkAnchor(hunk.id);
2438
+ editor.startPrompt(
2439
+ editor.t("prompt.discard_hunk", { file: hunk.file }) ||
2440
+ `Discard this hunk in "${hunk.file}"? This cannot be undone.`,
2441
+ "review-discard-hunk-confirm"
2442
+ );
2443
+ const suggestions: PromptSuggestion[] = [
2444
+ { text: "Discard hunk", description: "Permanently lose this change", value: "discard" },
2445
+ { text: "Cancel", description: "Keep the hunk as-is", value: "cancel" },
2446
+ ];
2447
+ editor.setPromptSuggestions(suggestions);
2448
+ return;
2449
+ }
785
2450
  if (!f) return;
786
2451
 
787
2452
  // Show confirmation prompt — discard is destructive and irreversible
2453
+ pendingDiscardFile = f;
2454
+ rememberPendingHunkAnchor(null);
788
2455
  const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
789
2456
  editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
790
2457
  const suggestions: PromptSuggestion[] = [
@@ -795,12 +2462,32 @@ function review_discard_file() {
795
2462
  }
796
2463
  registerHandler("review_discard_file", review_discard_file);
797
2464
 
2465
+ async function on_review_discard_hunk_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
2466
+ if (args.prompt_type !== "review-discard-hunk-confirm") return true;
2467
+ const response = args.input.trim().toLowerCase();
2468
+ if (response === "discard" || args.selected_index === 0) {
2469
+ const hunk = getHunkAtDiffCursor();
2470
+ if (hunk && hunk.file) {
2471
+ const patch = buildHunkPatch(hunk.file, hunk);
2472
+ const ok = await applyHunkPatch(patch, ["--reverse"]);
2473
+ if (ok) {
2474
+ editor.setStatus(editor.t("status.hunk_discarded") || "Hunk discarded");
2475
+ await refreshMagitData();
2476
+ }
2477
+ }
2478
+ } else {
2479
+ editor.setStatus("Discard cancelled");
2480
+ }
2481
+ return false;
2482
+ }
2483
+ registerHandler("on_review_discard_hunk_confirm", on_review_discard_hunk_confirm);
2484
+
798
2485
  async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
799
2486
  if (args.prompt_type !== "review-discard-confirm") return true;
800
2487
 
801
2488
  const response = args.input.trim().toLowerCase();
802
2489
  if (response === "discard" || args.selected_index === 0) {
803
- const f = state.files[state.selectedIndex];
2490
+ const f = pendingDiscardFile;
804
2491
  if (f) {
805
2492
  if (f.category === 'untracked') {
806
2493
  await editor.spawnProcess("rm", ["--", f.path]);
@@ -813,6 +2500,7 @@ async function on_review_discard_confirm(args: { prompt_type: string; input: str
813
2500
  } else {
814
2501
  editor.setStatus("Discard cancelled");
815
2502
  }
2503
+ pendingDiscardFile = null;
816
2504
  return false;
817
2505
  }
818
2506
  registerHandler("on_review_discard_confirm", on_review_discard_confirm);
@@ -821,15 +2509,51 @@ registerHandler("on_review_discard_confirm", on_review_discard_confirm);
821
2509
  * Refresh file list and diffs using the new git status approach, then re-render.
822
2510
  */
823
2511
  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);
2512
+ if (state.mode === 'range' && state.range) {
2513
+ const { hunks, files } = await fetchRangeDiff(state.range);
2514
+ state.hunks = hunks;
2515
+ state.files = files;
2516
+ state.emptyState = null;
2517
+ } else {
2518
+ const status = await getGitStatus();
2519
+ state.files = status.files;
2520
+ state.emptyState = status.emptyReason;
2521
+ state.hunks = await fetchDiffsForFiles(status.files);
830
2522
  }
831
- state.diffScrollOffset = 0;
2523
+ state.diffCursorRow = 1;
832
2524
  updateMagitDisplay();
2525
+ restoreCursorAfterRebuild();
2526
+ updateReviewStatus();
2527
+ }
2528
+
2529
+ /**
2530
+ * After a rebuild caused by stage/unstage/discard, try to land the cursor
2531
+ * back on the same hunk (now possibly in a different section), or the
2532
+ * nearest survivor in the original section, or the first hunk overall.
2533
+ */
2534
+ function restoreCursorAfterRebuild() {
2535
+ const anchor = pendingHunkAnchor;
2536
+ pendingHunkAnchor = null;
2537
+ if (!anchor) return;
2538
+ if (anchor.hunkId) {
2539
+ // Find the hunk by id in the new state.
2540
+ const found = state.hunks.findIndex(h => h.id === anchor.hunkId);
2541
+ if (found >= 0) {
2542
+ // Compute its visible row (auto-expanding if needed).
2543
+ jumpToGlobalHunk(found);
2544
+ return;
2545
+ }
2546
+ }
2547
+ // Hunk vanished — fall back to the next hunk in the same section,
2548
+ // else the previous one, else the first hunk overall.
2549
+ if (anchor.section) {
2550
+ const idx = state.hunks.findIndex(h => h.gitStatus === anchor.section);
2551
+ if (idx >= 0) {
2552
+ jumpToGlobalHunk(idx);
2553
+ return;
2554
+ }
2555
+ }
2556
+ if (state.hunks.length > 0) jumpToGlobalHunk(0);
833
2557
  }
834
2558
 
835
2559
  // --- Resize handler ---
@@ -1130,15 +2854,15 @@ function generateDiffPaneContent(
1130
2854
  // Line number color
1131
2855
  highlights.push({
1132
2856
  range: [currentByte + 2, currentByte + 6],
1133
- fg: [120, 120, 120] // Gray line numbers
2857
+ fg: "editor.line_number_fg",
1134
2858
  });
1135
2859
 
1136
2860
  if (isFiller) {
1137
2861
  // Filler styling - extend to full line width
1138
2862
  highlights.push({
1139
2863
  range: [currentByte + prefixLen, currentByte + lineLen - 1],
1140
- fg: [60, 60, 60],
1141
- bg: [30, 30, 30],
2864
+ fg: "editor.line_number_fg",
2865
+ bg: "editor.line_number_bg",
1142
2866
  extend_to_line_end: true
1143
2867
  });
1144
2868
  } else if (line.changeType === 'added' && side === 'new') {
@@ -1147,7 +2871,7 @@ function generateDiffPaneContent(
1147
2871
  highlights.push({
1148
2872
  range: [currentByte + prefixLen, currentByte + lineLen - 1],
1149
2873
  fg: STYLE_ADD_TEXT,
1150
- bg: [30, 50, 30],
2874
+ bg: STYLE_ADD_BG,
1151
2875
  extend_to_line_end: true
1152
2876
  });
1153
2877
  } else if (line.changeType === 'removed' && side === 'old') {
@@ -1156,7 +2880,7 @@ function generateDiffPaneContent(
1156
2880
  highlights.push({
1157
2881
  range: [currentByte + prefixLen, currentByte + lineLen - 1],
1158
2882
  fg: STYLE_REMOVE_TEXT,
1159
- bg: [50, 30, 30],
2883
+ bg: STYLE_REMOVE_BG,
1160
2884
  extend_to_line_end: true
1161
2885
  });
1162
2886
  } else if (line.changeType === 'modified') {
@@ -1245,9 +2969,9 @@ interface CompositeDiffState {
1245
2969
  let activeCompositeDiffState: CompositeDiffState | null = null;
1246
2970
 
1247
2971
  async function review_drill_down() {
1248
- // Use selected file from magit state instead of cursor properties
2972
+ // Use the file under the cursor (the file whose section the cursor is in)
1249
2973
  if (state.files.length === 0) return;
1250
- const selectedFile = state.files[state.selectedIndex];
2974
+ const selectedFile = currentFileFromCursor();
1251
2975
  if (!selectedFile) return;
1252
2976
 
1253
2977
  // Create a minimal hunk-like reference for the rest of the function
@@ -1278,10 +3002,17 @@ async function review_drill_down() {
1278
3002
  }
1279
3003
 
1280
3004
  // 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;
3005
+ // For deleted files the path no longer exists — use empty content
3006
+ let newContent: string;
3007
+ if (selectedFile.status === 'D') {
3008
+ newContent = "";
3009
+ } else {
3010
+ const readResult = await editor.readFile(absoluteFilePath);
3011
+ if (readResult === null) {
3012
+ editor.setStatus(editor.t("status.failed_new_version"));
3013
+ return;
3014
+ }
3015
+ newContent = readResult;
1285
3016
  }
1286
3017
 
1287
3018
  // Close any existing side-by-side views (old split-based approach)
@@ -1409,21 +3140,137 @@ async function review_drill_down() {
1409
3140
  }, 0);
1410
3141
  const modifiedCount = Math.min(addedCount, removedCount);
1411
3142
 
1412
- editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
3143
+ editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
3144
+ }
3145
+ registerHandler("review_drill_down", review_drill_down);
3146
+
3147
+ // --- Hunk navigation for side-by-side diff view ---
3148
+
3149
+ /**
3150
+ * Move the diff panel's native cursor to the given 1-indexed row, scrolling
3151
+ * the viewport so the row is visible.
3152
+ */
3153
+ function jumpDiffCursorToRow(row: number): void {
3154
+ const diffId = state.panelBuffers["diff"];
3155
+ if (diffId === undefined) return;
3156
+ const idx = row - 1;
3157
+ if (idx < 0 || idx >= state.diffLineByteOffsets.length) return;
3158
+
3159
+ // Set the cursor by absolute byte offset + scroll the viewport.
3160
+ // Trust setBufferCursor — the previous N × executeAction("move_down")
3161
+ // walk was O(target_row) round-trips into the editor and made
3162
+ // collapsing big diffs visibly slow (thousands of round trips just
3163
+ // to land the cursor on a header).
3164
+ const byteOffset = state.diffLineByteOffsets[idx];
3165
+ editor.setBufferCursor(diffId, byteOffset);
3166
+ editor.scrollBufferToLine(diffId, idx);
3167
+ state.diffCursorRow = row;
3168
+ applyCursorLineOverlay('diff');
3169
+ refreshStickyHeader(idx);
3170
+ updateReviewStatus();
3171
+ }
3172
+
3173
+ /**
3174
+ * Compute the 1-indexed global hunk number that corresponds to the current
3175
+ * diff-panel cursor row. Returns null when no hunk is "current".
3176
+ */
3177
+ function currentGlobalHunkIndex(): number | null {
3178
+ if (state.hunkHeaderRows.length === 0) return null;
3179
+ let within = -1;
3180
+ for (let i = 0; i < state.hunkHeaderRows.length; i++) {
3181
+ if (state.hunkHeaderRows[i] <= state.diffCursorRow) within = i;
3182
+ else break;
3183
+ }
3184
+ if (within < 0) return null;
3185
+ return within + 1;
3186
+ }
3187
+
3188
+ /**
3189
+ * Refresh the status-bar summary for review-diff mode. Shows "Hunk N of M"
3190
+ * when a current hunk is known, falls back to the bare hunk count otherwise.
3191
+ */
3192
+ function updateReviewStatus(): void {
3193
+ if (state.groupId === null) return;
3194
+ const total = state.hunkHeaderRows.length;
3195
+ const current = currentGlobalHunkIndex();
3196
+ if (current !== null) {
3197
+ editor.setStatus(editor.t("status.review_summary_indexed", {
3198
+ current: String(current),
3199
+ count: String(total),
3200
+ }));
3201
+ } else {
3202
+ editor.setStatus(editor.t("status.review_summary", { count: String(total) }));
3203
+ }
1413
3204
  }
1414
- registerHandler("review_drill_down", review_drill_down);
1415
3205
 
1416
- // --- Hunk navigation for side-by-side diff view ---
3206
+ /**
3207
+ * Find the global index in `state.hunks` of the hunk currently visible
3208
+ * at the cursor row, scanning the *visible* hunks (i.e. hunks whose
3209
+ * file is not collapsed). Returns -1 if no hunk is at or before cursor.
3210
+ */
3211
+ function visibleHunkIndexAtCursor(): number {
3212
+ let visibleIdx = -1;
3213
+ for (let i = 0; i < state.hunkHeaderRows.length; i++) {
3214
+ if (state.hunkHeaderRows[i] <= state.diffCursorRow) visibleIdx = i;
3215
+ else break;
3216
+ }
3217
+ if (visibleIdx < 0) return -1;
3218
+ // Map back to the global state.hunks index.
3219
+ let visited = 0;
3220
+ for (let i = 0; i < state.hunks.length; i++) {
3221
+ const h = state.hunks[i];
3222
+ if (state.collapsedFiles.has(fileKeyOf(h.file, h.gitStatus || 'unstaged'))) continue;
3223
+ if (visited === visibleIdx) return i;
3224
+ visited++;
3225
+ }
3226
+ return -1;
3227
+ }
3228
+
3229
+ function jumpToGlobalHunk(globalIdx: number) {
3230
+ if (globalIdx < 0 || globalIdx >= state.hunks.length) return;
3231
+ const target = state.hunks[globalIdx];
3232
+ const targetFileKey = fileKeyOf(target.file, target.gitStatus || 'unstaged');
3233
+ let needRebuild = false;
3234
+ // Auto-expand the section, file, AND hunk containing the target so
3235
+ // n/p never silently lands on an invisible row.
3236
+ if (target.gitStatus && state.collapsedSections.has(target.gitStatus)) {
3237
+ state.collapsedSections.delete(target.gitStatus);
3238
+ needRebuild = true;
3239
+ }
3240
+ if (state.collapsedFiles.has(targetFileKey)) {
3241
+ state.collapsedFiles.delete(targetFileKey);
3242
+ needRebuild = true;
3243
+ }
3244
+ if (state.collapsedHunks.has(target.id)) {
3245
+ state.collapsedHunks.delete(target.id);
3246
+ needRebuild = true;
3247
+ }
3248
+ if (needRebuild) updateMagitDisplay();
3249
+ // Look up the target hunk's row directly — much simpler than counting.
3250
+ const row = state.hunkRowByHunkId[target.id];
3251
+ if (row !== undefined) jumpDiffCursorToRow(row);
3252
+ }
1417
3253
 
1418
3254
  function review_next_hunk() {
1419
- if (!activeCompositeDiffState) return;
1420
- editor.compositeNextHunk(activeCompositeDiffState.compositeBufferId);
3255
+ if (state.groupId === null) return;
3256
+ if (state.hunks.length === 0) return;
3257
+ const cur = visibleHunkIndexAtCursor();
3258
+ // Find next hunk in global order — auto-expanding its file if needed.
3259
+ if (cur < 0) {
3260
+ jumpToGlobalHunk(0);
3261
+ return;
3262
+ }
3263
+ if (cur + 1 >= state.hunks.length) return;
3264
+ jumpToGlobalHunk(cur + 1);
1421
3265
  }
1422
3266
  registerHandler("review_next_hunk", review_next_hunk);
1423
3267
 
1424
3268
  function review_prev_hunk() {
1425
- if (!activeCompositeDiffState) return;
1426
- editor.compositePrevHunk(activeCompositeDiffState.compositeBufferId);
3269
+ if (state.groupId === null) return;
3270
+ if (state.hunks.length === 0) return;
3271
+ const cur = visibleHunkIndexAtCursor();
3272
+ if (cur <= 0) return;
3273
+ jumpToGlobalHunk(cur - 1);
1427
3274
  }
1428
3275
  registerHandler("review_prev_hunk", review_prev_hunk);
1429
3276
 
@@ -1442,16 +3289,13 @@ editor.defineMode("diff-view", [
1442
3289
  // --- Review Comment Actions ---
1443
3290
 
1444
3291
  function getCurrentHunkId(): string | null {
1445
- // In magit mode, get the first hunk of the selected file
1446
3292
  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
- );
3293
+ const hunk = getHunkAtDiffCursor();
1452
3294
  return hunk?.id || null;
1453
3295
  }
1454
3296
 
3297
+
3298
+
1455
3299
  interface PendingCommentInfo {
1456
3300
  hunkId: string;
1457
3301
  file: string;
@@ -1461,38 +3305,106 @@ interface PendingCommentInfo {
1461
3305
  lineContent?: string;
1462
3306
  }
1463
3307
 
3308
+ /**
3309
+ * Get the line under the cursor for comment attachment. Returns null
3310
+ * unless the cursor is on a real diff line (`add` / `remove` / `context`)
3311
+ * — comments are always line-based, never hunk-level.
3312
+ */
1464
3313
  function getCurrentLineInfo(): PendingCommentInfo | null {
1465
- // In magit mode, get info from the selected file's first hunk
1466
3314
  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
- };
3315
+ const props = propsAtCursorRow();
3316
+ if (!props) return null;
3317
+ const hunkId = props["hunkId"];
3318
+ const lineType = props["lineType"];
3319
+ if (typeof hunkId !== 'string') return null;
3320
+ if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
3321
+ const file = typeof props["file"] === 'string' ? props["file"] as string : '';
3322
+ const oldLine = typeof props["oldLine"] === 'number' ? props["oldLine"] as number : undefined;
3323
+ const newLine = typeof props["newLine"] === 'number' ? props["newLine"] as number : undefined;
3324
+ const lineContent = typeof props["lineContent"] === 'string' ? props["lineContent"] as string : undefined;
3325
+ return { hunkId, file, lineType: lineType as 'add' | 'remove' | 'context', oldLine, newLine, lineContent };
1481
3326
  }
1482
3327
 
1483
3328
  // Pending prompt state for event-based prompt handling
1484
3329
  let pendingCommentInfo: PendingCommentInfo | null = null;
3330
+ let editingCommentId: string | null = null; // non-null when editing an existing comment
3331
+
3332
+ /**
3333
+ * Find an existing comment at the current diff cursor position, either on the
3334
+ * comment display line itself or on the diff line it's attached to.
3335
+ */
3336
+ function findCommentAtCursor(): ReviewComment | null {
3337
+ const props = propsAtCursorRow();
3338
+ if (!props) return null;
3339
+
3340
+ // Cursor sits directly on a comment display line.
3341
+ const commentId = props["commentId"];
3342
+ if (typeof commentId === 'string') {
3343
+ return state.comments.find(c => c.id === commentId) || null;
3344
+ }
3345
+
3346
+ // Cursor sits on a diff line — match by hunk + line type + line number.
3347
+ const hunkId = props["hunkId"];
3348
+ const lineType = props["lineType"];
3349
+ if (typeof hunkId !== 'string') return null;
3350
+ if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
3351
+ const oldLine = typeof props["oldLine"] === 'number' ? props["oldLine"] as number : undefined;
3352
+ const newLine = typeof props["newLine"] === 'number' ? props["newLine"] as number : undefined;
3353
+ return state.comments.find(c =>
3354
+ c.hunk_id === hunkId && (
3355
+ (c.line_type === 'add' && c.new_line === newLine) ||
3356
+ (c.line_type === 'remove' && c.old_line === oldLine) ||
3357
+ (c.line_type === 'context' && c.new_line === newLine)
3358
+ )
3359
+ ) || null;
3360
+ }
1485
3361
 
1486
3362
  async function review_add_comment() {
3363
+ // If the cursor is sitting on an existing comment row, edit it
3364
+ // directly — `c` doubles as "edit this comment" so the user
3365
+ // doesn't have to first move back to the diff line.
3366
+ const props = propsAtCursorRow();
3367
+ if (props && props["type"] === 'comment' && typeof props["commentId"] === 'string') {
3368
+ const existing = state.comments.find(c => c.id === props["commentId"]);
3369
+ if (existing) {
3370
+ editingCommentId = existing.id;
3371
+ pendingCommentInfo = {
3372
+ hunkId: existing.hunk_id,
3373
+ file: existing.file,
3374
+ lineType: existing.line_type,
3375
+ oldLine: existing.old_line,
3376
+ newLine: existing.new_line,
3377
+ lineContent: existing.line_content,
3378
+ };
3379
+ const lineRef =
3380
+ existing.line_type === 'add' && existing.new_line ? `+${existing.new_line}`
3381
+ : existing.line_type === 'remove' && existing.old_line ? `-${existing.old_line}`
3382
+ : existing.new_line ? `L${existing.new_line}`
3383
+ : existing.old_line ? `L${existing.old_line}` : 'line';
3384
+ const label =
3385
+ editor.t("prompt.edit_comment", { line: lineRef }) ||
3386
+ `Edit comment on ${lineRef}: `;
3387
+ editor.startPromptWithInitial(label, "review-comment", existing.text);
3388
+ return;
3389
+ }
3390
+ }
3391
+
1487
3392
  const info = getCurrentLineInfo();
1488
3393
  if (!info) {
1489
- editor.setStatus(editor.t("status.no_hunk_selected"));
3394
+ editor.setStatus(
3395
+ editor.t("status.comment_needs_line") ||
3396
+ "Position cursor on a diff line to add a comment"
3397
+ );
1490
3398
  return;
1491
3399
  }
3400
+
3401
+ // Check for existing comment on this diff line to edit
3402
+ const existing = findCommentAtCursor();
3403
+
1492
3404
  pendingCommentInfo = info;
3405
+ editingCommentId = existing?.id || null;
1493
3406
 
1494
- // Show line context in prompt (if on a specific line)
1495
- let lineRef = 'hunk';
3407
+ let lineRef = 'line';
1496
3408
  if (info.lineType === 'add' && info.newLine) {
1497
3409
  lineRef = `+${info.newLine}`;
1498
3410
  } else if (info.lineType === 'remove' && info.oldLine) {
@@ -1502,15 +3414,94 @@ async function review_add_comment() {
1502
3414
  } else if (info.oldLine) {
1503
3415
  lineRef = `L${info.oldLine}`;
1504
3416
  }
1505
- editor.startPrompt(editor.t("prompt.comment", { line: lineRef }), "review-comment");
3417
+
3418
+ const label = existing
3419
+ ? (editor.t("prompt.edit_comment", { line: lineRef }) || `Edit comment on ${lineRef}: `)
3420
+ : editor.t("prompt.comment", { line: lineRef });
3421
+
3422
+ if (existing) {
3423
+ editor.startPromptWithInitial(label, "review-comment", existing.text);
3424
+ } else {
3425
+ editor.startPrompt(label, "review-comment");
3426
+ }
1506
3427
  }
1507
3428
  registerHandler("review_add_comment", review_add_comment);
1508
3429
 
3430
+ let pendingDeleteCommentId: string | null = null;
3431
+
3432
+ async function review_delete_comment() {
3433
+ const target: ReviewComment | null = findCommentAtCursor();
3434
+
3435
+ if (!target) {
3436
+ editor.setStatus("No comment to delete");
3437
+ return;
3438
+ }
3439
+
3440
+ pendingDeleteCommentId = target.id;
3441
+ const preview = target.text.length > 40 ? target.text.substring(0, 37) + '...' : target.text;
3442
+ editor.startPrompt(`Delete "${preview}"?`, "review-delete-comment-confirm");
3443
+ const suggestions: PromptSuggestion[] = [
3444
+ { text: "Delete", description: "Remove this comment", value: "delete" },
3445
+ { text: "Cancel", description: "Keep the comment", value: "cancel" },
3446
+ ];
3447
+ editor.setPromptSuggestions(suggestions);
3448
+ }
3449
+ registerHandler("review_delete_comment", review_delete_comment);
3450
+
3451
+ function on_review_delete_comment_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): boolean {
3452
+ if (args.prompt_type !== "review-delete-comment-confirm") return true;
3453
+ const response = args.input.trim().toLowerCase();
3454
+ if ((response === "delete" || args.selected_index === 0) && pendingDeleteCommentId) {
3455
+ if (pendingDeleteCommentId === '__note__') {
3456
+ state.note = '';
3457
+ } else {
3458
+ state.comments = state.comments.filter(c => c.id !== pendingDeleteCommentId);
3459
+ }
3460
+ persistReview();
3461
+ updateMagitDisplay();
3462
+ editor.setStatus("Deleted");
3463
+ } else {
3464
+ editor.setStatus("Delete cancelled");
3465
+ }
3466
+ pendingDeleteCommentId = null;
3467
+ return false;
3468
+ }
3469
+ registerHandler("on_review_delete_comment_confirm", on_review_delete_comment_confirm);
3470
+
1509
3471
  // Prompt event handlers
1510
3472
  function on_review_prompt_confirm(args: { prompt_type: string; input: string }): boolean {
1511
3473
  if (args.prompt_type !== "review-comment") {
1512
- return true; // Not our prompt
3474
+ return true;
3475
+ }
3476
+
3477
+ // Remember the cursor row from before the rebuild so we can put the
3478
+ // user back where they were. Inserting a comment row shifts later
3479
+ // rows down by one, but the line the user was on keeps its row
3480
+ // number — so saving the row pre-rebuild and restoring it after
3481
+ // lands the cursor on the same diff line.
3482
+ const cursorRowBeforeRebuild = state.diffCursorRow;
3483
+
3484
+ if (editingCommentId) {
3485
+ // Edit mode: update existing comment (empty text keeps the comment unchanged)
3486
+ if (args.input && args.input.trim()) {
3487
+ const existing = state.comments.find(c => c.id === editingCommentId);
3488
+ if (existing) {
3489
+ existing.text = args.input.trim();
3490
+ existing.timestamp = new Date().toISOString();
3491
+ persistReview();
3492
+ updateMagitDisplay();
3493
+ jumpDiffCursorToRow(cursorRowBeforeRebuild);
3494
+ editor.setStatus("Comment updated");
3495
+ }
3496
+ } else {
3497
+ editor.setStatus("Comment unchanged (use x to delete)");
3498
+ }
3499
+ editingCommentId = null;
3500
+ pendingCommentInfo = null;
3501
+ return true;
1513
3502
  }
3503
+
3504
+ // New comment mode
1514
3505
  if (pendingCommentInfo && args.input && args.input.trim()) {
1515
3506
  const comment: ReviewComment = {
1516
3507
  id: `comment-${Date.now()}`,
@@ -1524,7 +3515,9 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
1524
3515
  line_type: pendingCommentInfo.lineType
1525
3516
  };
1526
3517
  state.comments.push(comment);
3518
+ persistReview();
1527
3519
  updateMagitDisplay();
3520
+ jumpDiffCursorToRow(cursorRowBeforeRebuild);
1528
3521
  let lineRef = 'hunk';
1529
3522
  if (comment.line_type === 'add' && comment.new_line) {
1530
3523
  lineRef = `line +${comment.new_line}`;
@@ -1545,6 +3538,7 @@ registerHandler("on_review_prompt_confirm", on_review_prompt_confirm);
1545
3538
  function on_review_prompt_cancel(args: { prompt_type: string }): boolean {
1546
3539
  if (args.prompt_type === "review-comment") {
1547
3540
  pendingCommentInfo = null;
3541
+ editingCommentId = null;
1548
3542
  editor.setStatus(editor.t("status.comment_cancelled"));
1549
3543
  }
1550
3544
  return true;
@@ -1554,145 +3548,92 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
1554
3548
  // Register prompt event handlers
1555
3549
  editor.on("prompt_confirmed", "on_review_prompt_confirm");
1556
3550
  editor.on("prompt_confirmed", "on_review_discard_confirm");
3551
+ editor.on("prompt_confirmed", "on_review_discard_hunk_confirm");
3552
+ editor.on("prompt_confirmed", "on_review_edit_note_confirm");
3553
+ editor.on("prompt_confirmed", "on_review_delete_comment_confirm");
1557
3554
  editor.on("prompt_cancelled", "on_review_prompt_cancel");
1558
3555
 
1559
- async function review_approve_hunk() {
1560
- const hunkId = getCurrentHunkId();
1561
- if (!hunkId) return;
1562
- const h = state.hunks.find(x => x.id === hunkId);
1563
- if (h) {
1564
- h.reviewStatus = 'approved';
1565
- updateMagitDisplay();
1566
- editor.setStatus(editor.t("status.hunk_approved"));
1567
- }
1568
- }
1569
- registerHandler("review_approve_hunk", review_approve_hunk);
1570
-
1571
- async function review_reject_hunk() {
1572
- const hunkId = getCurrentHunkId();
1573
- if (!hunkId) return;
1574
- const h = state.hunks.find(x => x.id === hunkId);
1575
- if (h) {
1576
- h.reviewStatus = 'rejected';
1577
- updateMagitDisplay();
1578
- editor.setStatus(editor.t("status.hunk_rejected"));
1579
- }
1580
- }
1581
- registerHandler("review_reject_hunk", review_reject_hunk);
1582
-
1583
- async function review_needs_changes() {
1584
- const hunkId = getCurrentHunkId();
1585
- if (!hunkId) return;
1586
- const h = state.hunks.find(x => x.id === hunkId);
1587
- if (h) {
1588
- h.reviewStatus = 'needs_changes';
1589
- updateMagitDisplay();
1590
- editor.setStatus(editor.t("status.hunk_needs_changes"));
1591
- }
1592
- }
1593
- registerHandler("review_needs_changes", review_needs_changes);
1594
-
1595
- async function review_question_hunk() {
1596
- const hunkId = getCurrentHunkId();
1597
- if (!hunkId) return;
1598
- const h = state.hunks.find(x => x.id === hunkId);
1599
- if (h) {
1600
- h.reviewStatus = 'question';
1601
- updateMagitDisplay();
1602
- editor.setStatus(editor.t("status.hunk_question"));
3556
+ async function review_edit_note() {
3557
+ const label = editor.t("prompt.overall_comment") || "Note: ";
3558
+ if (state.note) {
3559
+ editor.startPromptWithInitial(label, "review-edit-note", state.note);
3560
+ } else {
3561
+ editor.startPrompt(label, "review-edit-note");
1603
3562
  }
1604
3563
  }
1605
- registerHandler("review_question_hunk", review_question_hunk);
3564
+ registerHandler("review_edit_note", review_edit_note);
1606
3565
 
1607
- async function review_clear_status() {
1608
- const hunkId = getCurrentHunkId();
1609
- if (!hunkId) return;
1610
- const h = state.hunks.find(x => x.id === hunkId);
1611
- if (h) {
1612
- h.reviewStatus = 'pending';
3566
+ function on_review_edit_note_confirm(args: { prompt_type: string; input: string }): boolean {
3567
+ if (args.prompt_type !== "review-edit-note") return true;
3568
+ if (args.input && args.input.trim()) {
3569
+ state.note = args.input.trim();
3570
+ persistReview();
1613
3571
  updateMagitDisplay();
1614
- editor.setStatus(editor.t("status.hunk_status_cleared"));
1615
- }
1616
- }
1617
- registerHandler("review_clear_status", review_clear_status);
1618
-
1619
- async function review_set_overall_feedback() {
1620
- const text = await editor.prompt(editor.t("prompt.overall_feedback"), state.overallFeedback || "");
1621
- if (text !== null) {
1622
- state.overallFeedback = text.trim();
1623
- editor.setStatus(text.trim() ? editor.t("status.feedback_set") : editor.t("status.feedback_cleared"));
3572
+ editor.setStatus(state.note ? "Note saved" : "Note cleared");
3573
+ } else {
3574
+ // Empty submission: keep existing note unchanged (use x to delete)
3575
+ if (state.note) {
3576
+ editor.setStatus("Note unchanged (use x to delete)");
3577
+ }
1624
3578
  }
3579
+ return true;
1625
3580
  }
1626
- registerHandler("review_set_overall_feedback", review_set_overall_feedback);
3581
+ registerHandler("on_review_edit_note_confirm", on_review_edit_note_confirm);
1627
3582
 
1628
3583
  async function review_export_session() {
1629
3584
  const cwd = editor.getCwd();
1630
3585
  const reviewDir = editor.pathJoin(cwd, ".review");
1631
3586
 
1632
- // Generate markdown content (writeFile creates parent directories)
1633
3587
  let md = `# Code Review Session\n`;
1634
3588
  md += `Date: ${new Date().toISOString()}\n\n`;
1635
3589
 
1636
- if (state.originalRequest) {
1637
- md += `## Original Request\n${state.originalRequest}\n\n`;
3590
+ if (state.note) {
3591
+ md += `## Note\n${state.note}\n\n`;
1638
3592
  }
1639
3593
 
1640
- if (state.overallFeedback) {
1641
- md += `## Overall Feedback\n${state.overallFeedback}\n\n`;
3594
+ // Summary
3595
+ const filesWithComments = new Set(state.comments.map(c => c.file)).size;
3596
+ md += `## Summary\n`;
3597
+ md += `- Files: ${state.files.length}\n`;
3598
+ md += `- Hunks: ${state.hunks.length}\n`;
3599
+ if (filesWithComments > 0) {
3600
+ md += `- Files with comments: ${filesWithComments}\n`;
3601
+ }
3602
+ md += `\n`;
3603
+
3604
+ // Group comments by file
3605
+ const fileComments: Record<string, ReviewComment[]> = {};
3606
+ for (const c of state.comments) {
3607
+ const file = c.file || 'unknown';
3608
+ if (!fileComments[file]) fileComments[file] = [];
3609
+ fileComments[file].push(c);
1642
3610
  }
1643
3611
 
1644
- // Stats
1645
- const approved = state.hunks.filter(h => h.reviewStatus === 'approved').length;
1646
- const rejected = state.hunks.filter(h => h.reviewStatus === 'rejected').length;
1647
- const needsChanges = state.hunks.filter(h => h.reviewStatus === 'needs_changes').length;
1648
- const questions = state.hunks.filter(h => h.reviewStatus === 'question').length;
1649
- md += `## Summary\n`;
1650
- md += `- Total hunks: ${state.hunks.length}\n`;
1651
- md += `- Approved: ${approved}\n`;
1652
- md += `- Rejected: ${rejected}\n`;
1653
- md += `- Needs changes: ${needsChanges}\n`;
1654
- md += `- Questions: ${questions}\n\n`;
1655
-
1656
- // Group by file
1657
- const fileGroups: Record<string, Hunk[]> = {};
1658
- for (const hunk of state.hunks) {
1659
- if (!fileGroups[hunk.file]) fileGroups[hunk.file] = [];
1660
- fileGroups[hunk.file].push(hunk);
1661
- }
1662
-
1663
- for (const [file, hunks] of Object.entries(fileGroups)) {
1664
- md += `## File: ${file}\n\n`;
1665
- for (const hunk of hunks) {
1666
- const statusStr = hunk.reviewStatus.toUpperCase();
1667
- md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
1668
- md += `**Status**: ${statusStr}\n\n`;
1669
-
1670
- const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1671
- if (hunkComments.length > 0) {
1672
- md += `**Comments:**\n`;
1673
- for (const c of hunkComments) {
1674
- // Format line reference
1675
- let lineRef = '';
1676
- if (c.line_type === 'add' && c.new_line) {
1677
- lineRef = `[+${c.new_line}]`;
1678
- } else if (c.line_type === 'remove' && c.old_line) {
1679
- lineRef = `[-${c.old_line}]`;
1680
- } else if (c.new_line) {
1681
- lineRef = `[L${c.new_line}]`;
1682
- } else if (c.old_line) {
1683
- lineRef = `[L${c.old_line}]`;
1684
- }
1685
- md += `> 💬 ${lineRef} ${c.text}\n`;
1686
- if (c.line_content) {
1687
- md += `> \`${c.line_content.trim()}\`\n`;
1688
- }
1689
- md += `\n`;
1690
- }
3612
+ for (const [file, comments] of Object.entries(fileComments)) {
3613
+ md += `## ${file}\n\n`;
3614
+ for (const c of comments) {
3615
+ let lineRef = '';
3616
+ if (c.line_type === 'add' && c.new_line) {
3617
+ lineRef = `line +${c.new_line}`;
3618
+ } else if (c.line_type === 'remove' && c.old_line) {
3619
+ lineRef = `line -${c.old_line}`;
3620
+ } else if (c.new_line) {
3621
+ lineRef = `line ${c.new_line}`;
3622
+ } else if (c.old_line) {
3623
+ lineRef = `line ${c.old_line}`;
3624
+ }
3625
+ if (lineRef) {
3626
+ md += `- **${lineRef}**: ${c.text}\n`;
3627
+ } else {
3628
+ md += `- ${c.text}\n`;
3629
+ }
3630
+ if (c.line_content) {
3631
+ md += ` \`${c.line_content.trim()}\`\n`;
1691
3632
  }
1692
3633
  }
3634
+ md += `\n`;
1693
3635
  }
1694
3636
 
1695
- // Write file
1696
3637
  const filePath = editor.pathJoin(reviewDir, "session.md");
1697
3638
  await editor.writeFile(filePath, md);
1698
3639
  editor.setStatus(editor.t("status.exported", { path: filePath }));
@@ -1702,94 +3643,471 @@ registerHandler("review_export_session", review_export_session);
1702
3643
  async function review_export_json() {
1703
3644
  const cwd = editor.getCwd();
1704
3645
  const reviewDir = editor.pathJoin(cwd, ".review");
1705
- // writeFile creates parent directories
1706
3646
 
1707
3647
  const session = {
1708
- version: "1.0",
3648
+ version: "2.0",
1709
3649
  timestamp: new Date().toISOString(),
1710
- original_request: state.originalRequest || null,
1711
- overall_feedback: state.overallFeedback || null,
1712
- files: {} as Record<string, any>
3650
+ note: state.note || null,
3651
+ comments: state.comments.map(c => ({
3652
+ file: c.file,
3653
+ text: c.text,
3654
+ line_type: c.line_type || null,
3655
+ old_line: c.old_line || null,
3656
+ new_line: c.new_line || null,
3657
+ line_content: c.line_content || null
3658
+ }))
1713
3659
  };
1714
3660
 
1715
- for (const hunk of state.hunks) {
1716
- if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
1717
- const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1718
- session.files[hunk.file].hunks.push({
1719
- context: hunk.contextHeader,
1720
- old_lines: [hunk.oldRange.start, hunk.oldRange.end],
1721
- new_lines: [hunk.range.start, hunk.range.end],
1722
- status: hunk.reviewStatus,
1723
- comments: hunkComments.map(c => ({
1724
- text: c.text,
1725
- line_type: c.line_type || null,
1726
- old_line: c.old_line || null,
1727
- new_line: c.new_line || null,
1728
- line_content: c.line_content || null
1729
- }))
1730
- });
1731
- }
1732
-
1733
3661
  const filePath = editor.pathJoin(reviewDir, "session.json");
1734
3662
  await editor.writeFile(filePath, JSON.stringify(session, null, 2));
1735
3663
  editor.setStatus(editor.t("status.exported", { path: filePath }));
1736
3664
  }
1737
3665
  registerHandler("review_export_json", review_export_json);
1738
3666
 
1739
- async function start_review_diff() {
1740
- editor.setStatus(editor.t("status.generating"));
1741
- editor.setContext("review-mode", true);
3667
+ /**
3668
+ * Reset the slice of `state` that tracks per-session cursor / fold / row
3669
+ * indices. Keeps `state.comments` and `state.note` untouched so the
3670
+ * caller can populate them (either freshly, or from disk).
3671
+ */
3672
+ function resetPerSessionState(): void {
3673
+ state.diffCursorRow = 1;
3674
+ state.hunkHeaderRows = [];
3675
+ state.diffLineByteOffsets = [];
3676
+ state.fileHeaderRows = {};
3677
+ state.collapsedFiles = new Set();
3678
+ state.collapsedSections = new Set();
3679
+ state.collapsedHunks = new Set();
3680
+ state.commentsByRow = {};
3681
+ state.commentsSelectedRow = 0;
3682
+ state.focusPanel = 'diff';
3683
+ state.commentsHighlightId = null;
3684
+ state.stickyCurrentFile = null;
3685
+ state.lineSelection = null;
3686
+ }
3687
+
3688
+ const REVIEW_LAYOUT = JSON.stringify({
3689
+ type: "split",
3690
+ direction: "v",
3691
+ ratio: 0.05,
3692
+ first: { type: "fixed", id: "toolbar", height: 2 },
3693
+ second: {
3694
+ type: "split",
3695
+ direction: "h",
3696
+ ratio: 0.75,
3697
+ first: {
3698
+ type: "split",
3699
+ direction: "v",
3700
+ ratio: 0.05,
3701
+ first: { type: "fixed", id: "sticky", height: 1 },
3702
+ second: { type: "scrollable", id: "diff" },
3703
+ },
3704
+ second: { type: "scrollable", id: "comments" },
3705
+ },
3706
+ });
1742
3707
 
1743
- // Get viewport size
3708
+ /**
3709
+ * Create the review-diff buffer group (toolbar / sticky / diff / comments)
3710
+ * and wire up the standard review-mode event listeners. Returns true if
3711
+ * the panels were created, false on failure.
3712
+ */
3713
+ async function openReviewPanels(groupName: string): Promise<boolean> {
1744
3714
  const viewport = editor.getViewport();
1745
3715
  if (viewport) {
1746
3716
  state.viewportWidth = viewport.width;
1747
3717
  state.viewportHeight = viewport.height;
1748
3718
  }
3719
+ editor.setContext("review-mode", true);
3720
+ const groupResult = await editor.createBufferGroup(groupName, "review-mode", REVIEW_LAYOUT);
3721
+ state.groupId = groupResult.groupId;
3722
+ state.panelBuffers = groupResult.panels;
3723
+ state.reviewBufferId = groupResult.panels["diff"];
1749
3724
 
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';
3725
+ if (state.panelBuffers["diff"] !== undefined) {
3726
+ (editor as any).setBufferShowCursors(state.panelBuffers["diff"], true);
3727
+ }
1758
3728
 
1759
- // Build initial display
1760
- const initialEntries = buildMagitDisplayEntries();
3729
+ updateMagitDisplay();
1761
3730
 
1762
- const bufferId = await VirtualBufferFactory.create({
1763
- name: "*Review Diff*", mode: "review-mode", readOnly: true,
1764
- entries: initialEntries, showLineNumbers: false, showCursors: false
1765
- });
1766
- state.reviewBufferId = bufferId;
3731
+ editor.focusBufferGroupPanel(state.groupId, "diff");
1767
3732
 
1768
- // Register resize handler
1769
3733
  editor.on("resize", "onReviewDiffResize");
1770
-
1771
- editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
3734
+ updateReviewStatus();
1772
3735
  editor.on("buffer_activated", "on_review_buffer_activated");
1773
3736
  editor.on("buffer_closed", "on_review_buffer_closed");
3737
+ editor.on("cursor_moved", "on_review_cursor_moved");
3738
+ editor.on("viewport_changed", "on_review_viewport_changed");
3739
+ editor.on("mouse_click", "on_review_mouse_click");
3740
+ return true;
3741
+ }
3742
+
3743
+ /**
3744
+ * Drop any comments whose anchor lines can no longer be found in the
3745
+ * current hunks. Applied on restore so stale worktree-mode comments from
3746
+ * a long-since-rewritten file don't pile up. For range mode this is a
3747
+ * no-op because comments should always match.
3748
+ */
3749
+ function pruneOrphanComments(comments: ReviewComment[], hunks: Hunk[]): ReviewComment[] {
3750
+ const byHunk = new Map<string, Hunk>();
3751
+ for (const h of hunks) byHunk.set(h.id, h);
3752
+ const fileSet = new Set(hunks.map(h => h.file));
3753
+ return comments.filter(c => {
3754
+ // Keep comments whose hunk still exists or whose file is still
3755
+ // part of the diff and whose anchor line is present in some hunk.
3756
+ if (byHunk.has(c.hunk_id)) return true;
3757
+ if (!fileSet.has(c.file)) return false;
3758
+ const fileHunks = hunks.filter(h => h.file === c.file);
3759
+ for (const h of fileHunks) {
3760
+ const lt = c.line_type;
3761
+ if (!lt) continue;
3762
+ let oldN = h.oldRange.start - 1;
3763
+ let newN = h.range.start - 1;
3764
+ for (const raw of h.lines) {
3765
+ if (raw.startsWith('+')) {
3766
+ newN++;
3767
+ if (lt === 'add' && c.new_line === newN) return true;
3768
+ } else if (raw.startsWith('-')) {
3769
+ oldN++;
3770
+ if (lt === 'remove' && c.old_line === oldN) return true;
3771
+ } else {
3772
+ oldN++; newN++;
3773
+ if (lt === 'context' && c.new_line === newN) return true;
3774
+ }
3775
+ }
3776
+ }
3777
+ return false;
3778
+ });
3779
+ }
3780
+
3781
+ async function start_review_diff() {
3782
+ editor.setStatus(editor.t("status.generating"));
3783
+
3784
+ // Fetch data using the git status approach.
3785
+ const status = await getGitStatus();
3786
+ state.files = status.files;
3787
+ state.emptyState = status.emptyReason;
3788
+ state.hunks = await fetchDiffsForFiles(status.files);
3789
+
3790
+ // Persistence setup: worktree mode keyed by repo root.
3791
+ state.mode = 'worktree';
3792
+ state.range = null;
3793
+ state.repoRoot = await detectRepoRoot();
3794
+ state.reviewKey = buildReviewKey(state.mode, state.range);
3795
+
3796
+ // Restore persisted comments (if any). We drop orphans so the UI
3797
+ // doesn't display comments that no longer point at visible lines.
3798
+ const loaded = loadPersistedReview(state.repoRoot, state.reviewKey);
3799
+ state.comments = loaded ? pruneOrphanComments(loaded.comments, state.hunks) : [];
3800
+ state.note = loaded?.note ?? '';
3801
+
3802
+ resetPerSessionState();
3803
+ await openReviewPanels("*Review Diff*");
1774
3804
  }
1775
3805
  registerHandler("start_review_diff", start_review_diff);
1776
3806
 
1777
3807
  function stop_review_diff() {
3808
+ if (state.groupId !== null) {
3809
+ editor.closeBufferGroup(state.groupId);
3810
+ state.groupId = null;
3811
+ state.panelBuffers = {};
3812
+ }
1778
3813
  state.reviewBufferId = null;
1779
3814
  editor.setContext("review-mode", false);
1780
3815
  editor.off("resize", "onReviewDiffResize");
1781
3816
  editor.off("buffer_activated", "on_review_buffer_activated");
1782
3817
  editor.off("buffer_closed", "on_review_buffer_closed");
3818
+ editor.off("cursor_moved", "on_review_cursor_moved");
3819
+ editor.off("viewport_changed", "on_review_viewport_changed");
3820
+ editor.off("mouse_click", "on_review_mouse_click");
1783
3821
  editor.setStatus(editor.t("status.stopped"));
1784
3822
  }
1785
3823
  registerHandler("stop_review_diff", stop_review_diff);
1786
3824
 
3825
+ // =============================================================================
3826
+ // Range / commit review (Task 2)
3827
+ // =============================================================================
3828
+ //
3829
+ // `start_review_diff` reviews the working tree. `start_review_range` reviews
3830
+ // a flattened diff between two git refs — the user types:
3831
+ //
3832
+ // HEAD~3..HEAD (a span of commits)
3833
+ // main..HEAD (a whole branch)
3834
+ // <sha> (a single commit — rewritten to `<sha>^..<sha>`)
3835
+ //
3836
+ // Alternatives considered for the picker UI:
3837
+ // - A dedicated two-panel picker (from / to). Clean but adds a big new
3838
+ // UI surface for a small benefit.
3839
+ // - The existing `start_review_branch` commit list (inline, Enter-to-
3840
+ // select). Rejected because that view is commit-by-commit and we
3841
+ // specifically want a *flattened* diff for batch commenting.
3842
+ // - Single prompt with a small suggestion list. Chosen — matches the
3843
+ // tone of the existing `start_review_branch` prompt and lets power
3844
+ // users type arbitrary revspecs without a multi-step UI.
3845
+
3846
+ /**
3847
+ * Parse a range string typed into the picker. Accepts:
3848
+ * `A..B`, `A...B` — two-dot / three-dot ranges.
3849
+ * `<ref>` — single commit, rewritten to `<ref>^..<ref>`.
3850
+ *
3851
+ * Returns `null` on invalid input (empty string).
3852
+ */
3853
+ function parseRangeInput(input: string): ReviewRange | null {
3854
+ const raw = input.trim();
3855
+ if (!raw) return null;
3856
+ const threeDot = raw.indexOf("...");
3857
+ if (threeDot > 0) {
3858
+ const from = raw.slice(0, threeDot).trim();
3859
+ const to = raw.slice(threeDot + 3).trim();
3860
+ if (!from || !to) return null;
3861
+ return { from, to, label: `${from}...${to}` };
3862
+ }
3863
+ const twoDot = raw.indexOf("..");
3864
+ if (twoDot > 0) {
3865
+ const from = raw.slice(0, twoDot).trim();
3866
+ const to = raw.slice(twoDot + 2).trim();
3867
+ if (!from || !to) return null;
3868
+ return { from, to, label: `${from}..${to}` };
3869
+ }
3870
+ // Single ref -> single-commit review.
3871
+ return { from: `${raw}^`, to: raw, label: raw };
3872
+ }
3873
+
3874
+ /**
3875
+ * Fetch a flattened unified diff for the given range and convert it to
3876
+ * the same Hunk + FileEntry shape the worktree path produces. All hunks
3877
+ * are assigned `gitStatus: 'unstaged'` so the existing section grouping
3878
+ * still works; untracked / staged categories are meaningless here.
3879
+ */
3880
+ async function fetchRangeDiff(range: ReviewRange): Promise<{ hunks: Hunk[]; files: FileEntry[] }> {
3881
+ const result = await editor.spawnProcess("git", [
3882
+ "diff", "--unified=3", `${range.from}..${range.to}`,
3883
+ ]);
3884
+ if (result.exit_code !== 0) {
3885
+ return { hunks: [], files: [] };
3886
+ }
3887
+ const hunks = parseDiffOutput(result.stdout, 'unstaged');
3888
+ // Rewrite hunk ids so they include the range — avoids id collisions
3889
+ // when a user opens multiple range reviews in the same session.
3890
+ for (const h of hunks) {
3891
+ h.id = `${range.label}|${h.file}:${h.range.start}`;
3892
+ }
3893
+ // Derive a FileEntry list from the hunks, preserving first-seen order.
3894
+ const seen = new Set<string>();
3895
+ const files: FileEntry[] = [];
3896
+ for (const h of hunks) {
3897
+ if (!seen.has(h.file)) {
3898
+ seen.add(h.file);
3899
+ files.push({ path: h.file, status: 'M', category: 'unstaged' });
3900
+ }
3901
+ }
3902
+ return { hunks, files };
3903
+ }
3904
+
3905
+ /**
3906
+ * Build a short list of revspec suggestions to prefill the picker. Falls
3907
+ * back gracefully if any of the helper git calls fail — the prompt still
3908
+ * accepts arbitrary input.
3909
+ */
3910
+ async function buildRangeSuggestions(): Promise<PromptSuggestion[]> {
3911
+ const suggestions: PromptSuggestion[] = [];
3912
+ // HEAD last commit.
3913
+ suggestions.push({ text: "HEAD", description: "Review last commit", value: "HEAD" });
3914
+ // Current-branch-vs-main style ranges.
3915
+ const tryRange = async (base: string) => {
3916
+ const exists = await editor.spawnProcess("git", ["rev-parse", "--verify", base]);
3917
+ if (exists.exit_code === 0) {
3918
+ suggestions.push({
3919
+ text: `${base}..HEAD`,
3920
+ description: `Review all commits on current branch vs ${base}`,
3921
+ value: `${base}..HEAD`,
3922
+ });
3923
+ }
3924
+ };
3925
+ await tryRange("main");
3926
+ await tryRange("master");
3927
+ // Recent commits for one-off review.
3928
+ try {
3929
+ const log = await editor.spawnProcess("git", [
3930
+ "log", "-n", "5", "--pretty=format:%h %s",
3931
+ ]);
3932
+ if (log.exit_code === 0) {
3933
+ for (const line of log.stdout.split('\n')) {
3934
+ const m = line.match(/^([0-9a-f]+)\s+(.*)$/);
3935
+ if (m) {
3936
+ suggestions.push({
3937
+ text: m[1],
3938
+ description: `Review commit: ${m[2]}`,
3939
+ value: m[1],
3940
+ });
3941
+ }
3942
+ }
3943
+ }
3944
+ } catch {}
3945
+ return suggestions;
3946
+ }
3947
+
3948
+ async function start_review_range(): Promise<void> {
3949
+ // If a review is already open, swap it out rather than stacking two.
3950
+ if (state.groupId !== null) {
3951
+ stop_review_diff();
3952
+ }
3953
+
3954
+ const suggestions = await buildRangeSuggestions();
3955
+ const label = editor.t("prompt.review_range") || "Review range (A..B or commit):";
3956
+ editor.startPromptWithInitial(label, "review-range", "HEAD");
3957
+ if (suggestions.length > 0) {
3958
+ editor.setPromptSuggestions(suggestions);
3959
+ }
3960
+ }
3961
+ registerHandler("start_review_range", start_review_range);
3962
+
3963
+ function on_review_range_confirm(args: { prompt_type: string; input: string }): boolean {
3964
+ if (args.prompt_type !== "review-range") return true;
3965
+ const range = parseRangeInput(args.input);
3966
+ if (!range) {
3967
+ editor.setStatus(editor.t("status.cancelled") || "Cancelled");
3968
+ return true;
3969
+ }
3970
+ // Kick off the async bootstrap; the prompt is already dismissed so we
3971
+ // can return immediately.
3972
+ bootstrapRangeReview(range);
3973
+ return true;
3974
+ }
3975
+ registerHandler("on_review_range_confirm", on_review_range_confirm);
3976
+ editor.on("prompt_confirmed", "on_review_range_confirm");
3977
+
3978
+ async function bootstrapRangeReview(range: ReviewRange): Promise<void> {
3979
+ editor.setStatus(editor.t("status.generating") || "Generating diff…");
3980
+ const { hunks, files } = await fetchRangeDiff(range);
3981
+ if (hunks.length === 0) {
3982
+ editor.setStatus(
3983
+ editor.t("status.review_range_empty", { range: range.label }) ||
3984
+ `No changes in ${range.label}`,
3985
+ );
3986
+ return;
3987
+ }
3988
+ state.mode = 'range';
3989
+ state.range = range;
3990
+ state.hunks = hunks;
3991
+ state.files = files;
3992
+ state.emptyState = null;
3993
+ state.repoRoot = await detectRepoRoot();
3994
+ state.reviewKey = buildReviewKey(state.mode, state.range);
3995
+
3996
+ // Load persisted comments for this exact range — the diff is static
3997
+ // so they always line up.
3998
+ const loaded = loadPersistedReview(state.repoRoot, state.reviewKey);
3999
+ state.comments = loaded ? loaded.comments : [];
4000
+ state.note = loaded?.note ?? '';
4001
+
4002
+ resetPerSessionState();
4003
+ await openReviewPanels(`*Review ${range.label}*`);
4004
+ }
4005
+
4006
+ editor.registerCommand(
4007
+ "%cmd.review_range",
4008
+ "%cmd.review_range_desc",
4009
+ "start_review_range",
4010
+ null,
4011
+ );
4012
+
1787
4013
 
1788
- function on_review_buffer_activated(data: any) {
1789
- if (data.buffer_id === state.reviewBufferId) refreshMagitData();
4014
+ /**
4015
+ * React to a buffer becoming active. Used here purely to track which review
4016
+ * panel currently has focus (Tab and mouse clicks both fire buffer_activated).
4017
+ * The focus state drives toolbar hint rendering and the `review_nav_*`
4018
+ * handlers' files-vs-diff branching.
4019
+ *
4020
+ * Note: this used to call `refreshMagitData()` on every activation, which
4021
+ * spawned several `git` subprocesses every time the user switched panels.
4022
+ * The user has a dedicated `r` key for that — auto-refresh was too aggressive.
4023
+ */
4024
+ function on_review_buffer_activated(data: { buffer_id: number }): void {
4025
+ if (state.groupId === null) return;
4026
+ const diffId = state.panelBuffers["diff"];
4027
+ const commentsId = state.panelBuffers["comments"];
4028
+ let newPanel: 'diff' | 'comments' | null = null;
4029
+ if (data.buffer_id === diffId) newPanel = 'diff';
4030
+ else if (data.buffer_id === commentsId) newPanel = 'comments';
4031
+ if (newPanel === null || newPanel === state.focusPanel) return;
4032
+ state.focusPanel = newPanel;
4033
+ // Re-render the comments panel so the selection highlight follows focus.
4034
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1790
4035
  }
1791
4036
  registerHandler("on_review_buffer_activated", on_review_buffer_activated);
1792
4037
 
4038
+ /**
4039
+ * React to native cursor movement inside review panels.
4040
+ *
4041
+ * Diff panel: keeps `state.diffCursorRow` in sync and re-paints the
4042
+ * cursor-line highlight overlay.
4043
+ *
4044
+ * Files panel: when the cursor moves (e.g. via mouse click), read the
4045
+ * `fileIndex` text property at the new position and select that file.
4046
+ * This makes click-to-select work even though the files panel hides its
4047
+ * native cursor (`show_cursors = false` blocks keyboard-driven movement
4048
+ * but mouse clicks still move the cursor).
4049
+ */
4050
+ /**
4051
+ * Determine the "current comment" — the one the diff cursor is sitting
4052
+ * on (a comment-display row) or attached to (a +/-/context line).
4053
+ * Returns null if the cursor is not associated with any comment.
4054
+ */
4055
+ function currentCommentIdAtCursor(): string | null {
4056
+ const props = propsAtCursorRow();
4057
+ if (!props) return null;
4058
+ if (props["type"] === 'comment' && typeof props["commentId"] === 'string') {
4059
+ return props["commentId"] as string;
4060
+ }
4061
+ const hunkId = props["hunkId"];
4062
+ const lineType = props["lineType"];
4063
+ if (typeof hunkId !== 'string') return null;
4064
+ if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
4065
+ const oldLine = typeof props["oldLine"] === 'number' ? (props["oldLine"] as number) : undefined;
4066
+ const newLine = typeof props["newLine"] === 'number' ? (props["newLine"] as number) : undefined;
4067
+ const found = state.comments.find(c =>
4068
+ c.hunk_id === hunkId && (
4069
+ (c.line_type === 'add' && c.new_line === newLine) ||
4070
+ (c.line_type === 'remove' && c.old_line === oldLine) ||
4071
+ (c.line_type === 'context' && c.new_line === newLine)
4072
+ )
4073
+ );
4074
+ return found ? found.id : null;
4075
+ }
4076
+
4077
+ function on_review_cursor_moved(data: {
4078
+ buffer_id: number;
4079
+ cursor_id: number;
4080
+ old_position: number;
4081
+ new_position: number;
4082
+ line: number;
4083
+ text_properties: Array<Record<string, unknown>>;
4084
+ }): void {
4085
+ if (state.groupId === null) return;
4086
+
4087
+ // Diff panel: track cursor row + repaint the cursor-line overlay.
4088
+ if (data.buffer_id === state.panelBuffers["diff"]) {
4089
+ const prevHighlight = state.commentsHighlightId;
4090
+ state.diffCursorRow = data.line;
4091
+ applyCursorLineOverlay('diff');
4092
+ // Use the cursor row as a sticky-header anchor too — viewport_changed
4093
+ // doesn't always fire reliably for plugin-managed virtual buffers
4094
+ // (top_line can be null). Tracking the cursor row gives a snappy
4095
+ // "what file am I in" indicator regardless.
4096
+ refreshStickyHeader(Math.max(0, data.line - 1));
4097
+ updateReviewStatus();
4098
+ // Re-render the comments panel only when the highlighted comment
4099
+ // actually changes — avoids re-emitting the panel on every
4100
+ // cursor tick.
4101
+ const newHighlight = currentCommentIdAtCursor();
4102
+ if (newHighlight !== prevHighlight) {
4103
+ state.commentsHighlightId = newHighlight;
4104
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
4105
+ }
4106
+ return;
4107
+ }
4108
+ }
4109
+ registerHandler("on_review_cursor_moved", on_review_cursor_moved);
4110
+
1793
4111
  function on_review_buffer_closed(data: any) {
1794
4112
  if (data.buffer_id === state.reviewBufferId) stop_review_diff();
1795
4113
  }
@@ -1879,7 +4197,6 @@ async function side_by_side_diff_current_file() {
1879
4197
  type: isUntracked ? 'add' : 'modify',
1880
4198
  lines: [],
1881
4199
  status: 'pending',
1882
- reviewStatus: 'pending',
1883
4200
  contextHeader: match[5]?.trim() || "",
1884
4201
  byteOffset: 0
1885
4202
  };
@@ -2038,20 +4355,321 @@ async function side_by_side_diff_current_file() {
2038
4355
  }
2039
4356
  registerHandler("side_by_side_diff_current_file", side_by_side_diff_current_file);
2040
4357
 
4358
+ // =============================================================================
4359
+ // Review PR Branch
4360
+ //
4361
+ // A companion view to `start_review_diff` for reviewing the full set of
4362
+ // commits on a PR branch (rather than just the working-tree changes). It
4363
+ // opens a buffer group with the commit history on the left (rendered by
4364
+ // the shared `lib/git_history.ts` helpers the git_log plugin uses) and a
4365
+ // live-updating `git show` of the selected commit on the right. This reuses
4366
+ // the same rendering pipeline so both plugins stay visually consistent and
4367
+ // respect theme keys in one place.
4368
+ // =============================================================================
4369
+
4370
+ interface ReviewBranchState {
4371
+ isOpen: boolean;
4372
+ groupId: number | null;
4373
+ logBufferId: number | null;
4374
+ detailBufferId: number | null;
4375
+ commits: GitCommit[];
4376
+ selectedIndex: number;
4377
+ baseRef: string;
4378
+ detailCache: { hash: string; output: string } | null;
4379
+ pendingDetailId: number;
4380
+ /** Byte offset of each row in the log panel; final entry = buffer length. */
4381
+ logRowByteOffsets: number[];
4382
+ }
4383
+
4384
+ const branchState: ReviewBranchState = {
4385
+ isOpen: false,
4386
+ groupId: null,
4387
+ logBufferId: null,
4388
+ detailBufferId: null,
4389
+ commits: [],
4390
+ selectedIndex: 0,
4391
+ baseRef: "main",
4392
+ detailCache: null,
4393
+ pendingDetailId: 0,
4394
+ logRowByteOffsets: [],
4395
+ };
4396
+
4397
+ // UTF-8 byte length helper, local copy so audit_mode doesn't pull in the one
4398
+ // from git_history (keeps the import list tiny).
4399
+ function branchUtf8Len(s: string): number {
4400
+ let b = 0;
4401
+ for (let i = 0; i < s.length; i++) {
4402
+ const c = s.charCodeAt(i);
4403
+ if (c <= 0x7f) b += 1;
4404
+ else if (c <= 0x7ff) b += 2;
4405
+ else if (c >= 0xd800 && c <= 0xdfff) { b += 4; i++; }
4406
+ else b += 3;
4407
+ }
4408
+ return b;
4409
+ }
4410
+
4411
+ function branchRowFromByte(bytePos: number): number {
4412
+ const offs = branchState.logRowByteOffsets;
4413
+ if (offs.length === 0) return 0;
4414
+ let lo = 0;
4415
+ let hi = offs.length - 1;
4416
+ while (lo < hi) {
4417
+ const mid = (lo + hi + 1) >> 1;
4418
+ if (offs[mid] <= bytePos) lo = mid;
4419
+ else hi = mid - 1;
4420
+ }
4421
+ return lo;
4422
+ }
4423
+
4424
+ function branchIndexFromCursor(bytePos: number): number {
4425
+ const row = branchRowFromByte(bytePos);
4426
+ const idx = row - 1; // row 0 is the header
4427
+ if (idx < 0) return 0;
4428
+ if (idx >= branchState.commits.length) return branchState.commits.length - 1;
4429
+ return idx;
4430
+ }
4431
+
4432
+ function branchRenderLog(): void {
4433
+ if (branchState.groupId === null) return;
4434
+ const rawHeader = editor.t("panel.review_branch_header", { base: branchState.baseRef });
4435
+ const header = (rawHeader && !rawHeader.startsWith("panel.")) ? rawHeader : `Commits (${branchState.baseRef}..HEAD)`;
4436
+ const rawFooter = editor.t("panel.review_branch_footer");
4437
+ const footer = (rawFooter && !rawFooter.startsWith("panel.")) ? rawFooter : "j/k: navigate · Enter: focus detail · r: refresh · q: close";
4438
+ const entries = buildCommitLogEntries(branchState.commits, {
4439
+ selectedIndex: branchState.selectedIndex,
4440
+ header,
4441
+ footer,
4442
+ propertyType: "branch-commit",
4443
+ });
4444
+ const offsets: number[] = [];
4445
+ let running = 0;
4446
+ for (const e of entries) {
4447
+ offsets.push(running);
4448
+ running += branchUtf8Len(e.text);
4449
+ }
4450
+ offsets.push(running);
4451
+ branchState.logRowByteOffsets = offsets;
4452
+ editor.setPanelContent(branchState.groupId, "log", entries);
4453
+ }
4454
+
4455
+ function branchByteOffsetOfFirstCommit(): number {
4456
+ return branchState.logRowByteOffsets.length > 1 ? branchState.logRowByteOffsets[1] : 0;
4457
+ }
4458
+
4459
+ async function branchRefreshDetail(): Promise<void> {
4460
+ if (branchState.groupId === null) return;
4461
+ if (branchState.commits.length === 0) {
4462
+ const msg = editor.t("status.review_branch_empty") || "No commits in the selected range.";
4463
+ editor.setPanelContent(
4464
+ branchState.groupId,
4465
+ "detail",
4466
+ buildDetailPlaceholderEntries(msg),
4467
+ );
4468
+ return;
4469
+ }
4470
+ const idx = Math.max(0, Math.min(branchState.selectedIndex, branchState.commits.length - 1));
4471
+ const commit = branchState.commits[idx];
4472
+ if (!commit) return;
4473
+
4474
+ if (branchState.detailCache && branchState.detailCache.hash === commit.hash) {
4475
+ const entries = buildCommitDetailEntries(commit, branchState.detailCache.output, {});
4476
+ editor.setPanelContent(branchState.groupId, "detail", entries);
4477
+ return;
4478
+ }
4479
+ const myId = ++branchState.pendingDetailId;
4480
+ editor.setPanelContent(
4481
+ branchState.groupId,
4482
+ "detail",
4483
+ buildDetailPlaceholderEntries(
4484
+ editor.t("status.loading_commit", { hash: commit.shortHash }) || `Loading ${commit.shortHash}…`,
4485
+ ),
4486
+ );
4487
+ const output = await fetchCommitShow(editor, commit.hash);
4488
+ if (myId !== branchState.pendingDetailId) return;
4489
+ if (branchState.groupId === null) return;
4490
+ branchState.detailCache = { hash: commit.hash, output };
4491
+ editor.setPanelContent(
4492
+ branchState.groupId,
4493
+ "detail",
4494
+ buildCommitDetailEntries(commit, output, {}),
4495
+ );
4496
+ }
4497
+
4498
+ async function start_review_branch(): Promise<void> {
4499
+ if (branchState.isOpen) {
4500
+ editor.setStatus(editor.t("status.already_open") || "Review branch already open");
4501
+ return;
4502
+ }
4503
+ // Prompt for the base ref so the user can review any PR, not just
4504
+ // one branched off main.
4505
+ const input = await editor.prompt(
4506
+ editor.t("prompt.branch_base") || "Base ref (default: main):",
4507
+ branchState.baseRef,
4508
+ );
4509
+ if (input === null) {
4510
+ editor.setStatus(editor.t("status.cancelled") || "Cancelled");
4511
+ return;
4512
+ }
4513
+ const base = input.trim() || "main";
4514
+ branchState.baseRef = base;
4515
+
4516
+ editor.setStatus(editor.t("status.loading") || "Loading commits…");
4517
+ branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 });
4518
+ if (branchState.commits.length === 0) {
4519
+ editor.setStatus(
4520
+ editor.t("status.review_branch_empty", { base }) ||
4521
+ `No commits in ${base}..HEAD — nothing to review.`,
4522
+ );
4523
+ return;
4524
+ }
4525
+
4526
+ const layout = JSON.stringify({
4527
+ type: "split",
4528
+ direction: "h",
4529
+ ratio: 0.4,
4530
+ first: { type: "scrollable", id: "log" },
4531
+ second: { type: "scrollable", id: "detail" },
4532
+ });
4533
+ // `createBufferGroup` is a runtime-only binding (not in the generated
4534
+ // EditorAPI type); cast to `any` so the type-checker doesn't complain.
4535
+ const group = await (editor as any).createBufferGroup(
4536
+ `*Review Branch ${base}..HEAD*`,
4537
+ "review-branch",
4538
+ layout,
4539
+ );
4540
+ branchState.groupId = group.groupId as number;
4541
+ branchState.logBufferId = (group.panels["log"] as number | undefined) ?? null;
4542
+ branchState.detailBufferId = (group.panels["detail"] as number | undefined) ?? null;
4543
+ branchState.selectedIndex = 0;
4544
+ branchState.detailCache = null;
4545
+ branchState.isOpen = true;
4546
+
4547
+ if (branchState.logBufferId !== null) {
4548
+ editor.setBufferShowCursors(branchState.logBufferId, true);
4549
+ }
4550
+ if (branchState.detailBufferId !== null) {
4551
+ editor.setBufferShowCursors(branchState.detailBufferId, true);
4552
+ }
4553
+
4554
+ branchRenderLog();
4555
+ if (branchState.logBufferId !== null && branchState.commits.length > 0) {
4556
+ editor.setBufferCursor(branchState.logBufferId, branchByteOffsetOfFirstCommit());
4557
+ }
4558
+ await branchRefreshDetail();
4559
+
4560
+ if (branchState.groupId !== null) {
4561
+ editor.focusBufferGroupPanel(branchState.groupId, "log");
4562
+ }
4563
+ editor.on("cursor_moved", "on_review_branch_cursor_moved");
4564
+
4565
+ editor.setStatus(
4566
+ editor.t("status.review_branch_ready", {
4567
+ count: String(branchState.commits.length),
4568
+ base,
4569
+ }) || `Reviewing ${branchState.commits.length} commits in ${base}..HEAD`,
4570
+ );
4571
+ }
4572
+ registerHandler("start_review_branch", start_review_branch);
4573
+
4574
+ function stop_review_branch(): void {
4575
+ if (!branchState.isOpen) return;
4576
+ if (branchState.groupId !== null) editor.closeBufferGroup(branchState.groupId);
4577
+ editor.off("cursor_moved", "on_review_branch_cursor_moved");
4578
+ branchState.isOpen = false;
4579
+ branchState.groupId = null;
4580
+ branchState.logBufferId = null;
4581
+ branchState.detailBufferId = null;
4582
+ branchState.commits = [];
4583
+ branchState.selectedIndex = 0;
4584
+ branchState.detailCache = null;
4585
+ editor.setStatus(editor.t("status.closed") || "Review branch closed");
4586
+ }
4587
+ registerHandler("stop_review_branch", stop_review_branch);
4588
+
4589
+ async function review_branch_refresh(): Promise<void> {
4590
+ if (!branchState.isOpen) return;
4591
+ const base = branchState.baseRef;
4592
+ branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 });
4593
+ branchState.detailCache = null;
4594
+ if (branchState.selectedIndex >= branchState.commits.length) {
4595
+ branchState.selectedIndex = Math.max(0, branchState.commits.length - 1);
4596
+ }
4597
+ branchRenderLog();
4598
+ await branchRefreshDetail();
4599
+ }
4600
+ registerHandler("review_branch_refresh", review_branch_refresh);
4601
+
4602
+ /** Enter: focus the detail panel (so the user can scroll/click within it). */
4603
+ function review_branch_enter(): void {
4604
+ if (branchState.groupId === null) return;
4605
+ editor.focusBufferGroupPanel(branchState.groupId, "detail");
4606
+ }
4607
+ registerHandler("review_branch_enter", review_branch_enter);
4608
+
4609
+ /** q/Escape: focus-back from detail, or close when already on log. */
4610
+ function review_branch_close_or_back(): void {
4611
+ if (branchState.groupId === null) return;
4612
+ const active = editor.getActiveBufferId();
4613
+ if (branchState.detailBufferId !== null && active === branchState.detailBufferId) {
4614
+ editor.focusBufferGroupPanel(branchState.groupId, "log");
4615
+ return;
4616
+ }
4617
+ stop_review_branch();
4618
+ }
4619
+ registerHandler("review_branch_close_or_back", review_branch_close_or_back);
4620
+
4621
+ function on_review_branch_cursor_moved(data: {
4622
+ buffer_id: number;
4623
+ cursor_id: number;
4624
+ old_position: number;
4625
+ new_position: number;
4626
+ }): void {
4627
+ if (!branchState.isOpen) return;
4628
+ if (data.buffer_id !== branchState.logBufferId) return;
4629
+ const idx = branchIndexFromCursor(data.new_position);
4630
+ if (idx === branchState.selectedIndex) return;
4631
+ branchState.selectedIndex = idx;
4632
+ branchRenderLog();
4633
+ branchRefreshDetail();
4634
+ }
4635
+ registerHandler("on_review_branch_cursor_moved", on_review_branch_cursor_moved);
4636
+
4637
+ editor.defineMode(
4638
+ "review-branch",
4639
+ [
4640
+ // Mode bindings replace globals, so we re-bind the editor's built-in
4641
+ // motion actions here explicitly — without this, j/k and Up/Down
4642
+ // do nothing in the commit list.
4643
+ ["Up", "move_up"],
4644
+ ["Down", "move_down"],
4645
+ ["k", "move_up"],
4646
+ ["j", "move_down"],
4647
+ ["PageUp", "page_up"],
4648
+ ["PageDown", "page_down"],
4649
+ ["Home", "move_line_start"],
4650
+ ["End", "move_line_end"],
4651
+ // Enter: focus the right-hand detail panel.
4652
+ ["Return", "review_branch_enter"],
4653
+ ["Tab", "review_branch_enter"],
4654
+ ["r", "review_branch_refresh"],
4655
+ ["q", "review_branch_close_or_back"],
4656
+ ["Escape", "review_branch_close_or_back"],
4657
+ ],
4658
+ true,
4659
+ );
4660
+
2041
4661
  // Register Modes and Commands
2042
4662
  editor.registerCommand("%cmd.review_diff", "%cmd.review_diff_desc", "start_review_diff", null);
4663
+ editor.registerCommand("%cmd.review_branch", "%cmd.review_branch_desc", "start_review_branch", null);
4664
+ editor.registerCommand("%cmd.stop_review_branch", "%cmd.stop_review_branch_desc", "stop_review_branch", "review-branch");
4665
+ editor.registerCommand("%cmd.refresh_review_branch", "%cmd.refresh_review_branch_desc", "review_branch_refresh", "review-branch");
2043
4666
  editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode");
2044
4667
  editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode");
2045
4668
  editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", null);
2046
4669
 
2047
4670
  // Review Comment Commands
2048
4671
  editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
2049
- editor.registerCommand("%cmd.approve_hunk", "%cmd.approve_hunk_desc", "review_approve_hunk", "review-mode");
2050
- editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
2051
- editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
2052
- editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
2053
- editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
2054
- editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "review_set_overall_feedback", "review-mode");
4672
+ editor.registerCommand("%cmd.edit_note", "%cmd.edit_note_desc", "review_edit_note", "review-mode");
2055
4673
  editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
2056
4674
  editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
2057
4675
 
@@ -2089,23 +4707,49 @@ registerHandler("on_buffer_closed", on_buffer_closed);
2089
4707
  editor.on("buffer_closed", "on_buffer_closed");
2090
4708
 
2091
4709
  editor.defineMode("review-mode", [
2092
- // Navigation (arrow keys, vim keys, page keys)
4710
+ // Native cursor motion in the unified diff stream.
2093
4711
  ["Up", "review_nav_up"], ["Down", "review_nav_down"],
2094
4712
  ["k", "review_nav_up"], ["j", "review_nav_down"],
2095
4713
  ["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"],
4714
+ // Home / End — match the editor's normal-mode defaults so users
4715
+ // get the same start-of-line / end-of-line behavior they're used
4716
+ // to. Mode bindings replace globals, so we must bind these
4717
+ // explicitly even though the actions are built-in.
4718
+ ["Home", "move_line_start"], ["End", "move_line_end"],
4719
+ // Hunk navigation across the unified stream.
4720
+ ["n", "review_next_hunk"], ["p", "review_prev_hunk"],
4721
+ // Per-file collapse: Tab toggles the file under the cursor;
4722
+ // `z a` collapses every file; `z r` reveals (expands) every file.
4723
+ ["Tab", "review_toggle_file_collapse"],
4724
+ ["z a", "review_collapse_all"],
4725
+ ["z r", "review_expand_all"],
4726
+ // Visual line-selection mode for line-level stage/unstage/discard.
4727
+ ["v", "review_visual_start"],
4728
+ ["Esc", "review_visual_cancel"],
4729
+ // Drill-down to side-by-side view of the file under the cursor —
4730
+ // unless focus is in the comments panel, in which case Enter opens
4731
+ // the selected comment.
4732
+ ["Enter", "review_enter_dispatch"],
4733
+ // Comments-nav: cycle through comments, jump diff cursor, expand
4734
+ // the file if needed. Works regardless of which panel has focus.
4735
+ ["]", "review_next_comment"],
4736
+ ["[", "review_prev_comment"],
4737
+ // Focus the comments panel (use j/k/Enter inside).
4738
+ ["`", "review_focus_comments"],
4739
+ // Stage/unstage/discard — context-sensitive. s/u/d act on the file
4740
+ // (when cursor is on a file header) or the hunk under the cursor.
4741
+ // Capital S/U/D always act on the enclosing file.
4742
+ ["s", "review_stage_scope"], ["u", "review_unstage_scope"],
2103
4743
  ["d", "review_discard_file"],
4744
+ ["S", "review_stage_file"], ["U", "review_unstage_file"],
4745
+ ["D", "review_discard_file_only"],
2104
4746
  ["r", "review_refresh"],
2105
- // Review actions
2106
- ["a", "review_approve_hunk"],
4747
+ // Comments
2107
4748
  ["c", "review_add_comment"],
2108
- // Export
4749
+ ["N", "review_edit_note"],
4750
+ ["x", "review_delete_comment"],
4751
+ // Close & export
4752
+ ["q", "close"],
2109
4753
  ["e", "review_export_session"],
2110
4754
  ], true);
2111
4755