@fresh-editor/fresh-editor 0.2.23 → 0.2.25

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.
@@ -9,6 +9,14 @@
9
9
  const editor = getEditor();
10
10
 
11
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";
12
20
  const VirtualBufferFactory = createVirtualBufferFactory(editor);
13
21
 
14
22
 
@@ -69,37 +77,121 @@ interface FileEntry {
69
77
  * know between events (selected file, focused panel, hunk header rows for
70
78
  * `n`/`p` jumps).
71
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.
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
+
72
108
  interface ReviewState {
73
109
  hunks: Hunk[];
74
110
  comments: ReviewComment[];
75
111
  note: string;
76
112
  reviewBufferId: number | null;
77
- // 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.
78
128
  files: FileEntry[];
79
- selectedIndex: number;
129
+ emptyState: EmptyStateReason;
80
130
  viewportWidth: number;
81
131
  viewportHeight: number;
82
- focusPanel: 'files' | 'diff';
132
+ focusPanel: 'diff' | 'comments';
83
133
  groupId: number | null;
84
134
  panelBuffers: Record<string, number>;
85
- // Caches populated each time the diff panel is rebuilt — used by `n`/`p`
86
- // hunk navigation, to translate diff-panel row numbers into byte positions
87
- // for `setBufferCursor`, and to draw the cursor-line highlight overlay.
88
- // The array has length `(rowCount + 1)`: index `i` is the byte offset of
89
- // row `i + 1`, and the final entry is the total buffer length (sentinel
90
- // for the end of the last row).
91
- hunkHeaderRows: number[]; // 1-indexed row numbers in the diff panel
92
- diffLineByteOffsets: number[];
93
- diffCursorRow: number; // 1-indexed, last known cursor row in diff panel
94
- /** Cache of pre-built diff-panel entries keyed by `${file}\0${gitStatus}`,
95
- * populated lazily by buildDiffPanelEntries. Cleared on refreshMagitData. */
96
- diffCache: Record<string, CachedDiff>;
97
- }
98
-
99
- interface CachedDiff {
100
- entries: TextPropertyEntry[];
101
- hunkHeaderRows: number[];
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
102
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;
103
195
  }
104
196
 
105
197
  const state: ReviewState = {
@@ -107,19 +199,42 @@ const state: ReviewState = {
107
199
  comments: [],
108
200
  note: '',
109
201
  reviewBufferId: null,
202
+ mode: 'worktree',
203
+ range: null,
204
+ repoRoot: '',
205
+ reviewKey: 'worktree',
110
206
  files: [],
111
- selectedIndex: 0,
207
+ emptyState: null,
112
208
  viewportWidth: 80,
113
209
  viewportHeight: 24,
114
- focusPanel: 'files',
210
+ focusPanel: 'diff',
115
211
  groupId: null,
116
212
  panelBuffers: {},
117
213
  hunkHeaderRows: [],
118
214
  diffLineByteOffsets: [],
119
215
  diffCursorRow: 1,
120
- diffCache: {},
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,
121
233
  };
122
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
+
123
238
  // Theme colour for the synthetic "cursor line" highlight in the panel
124
239
  // buffers. Reintroduced after the per-line bg overlay was deleted from the
125
240
  // builders — `applyCursorLineOverlay` writes it on every cursor_moved event.
@@ -140,6 +255,42 @@ const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
140
255
 
141
256
  const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
142
257
  const STYLE_COMMENT: 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
+ }
143
294
 
144
295
 
145
296
  /**
@@ -158,6 +309,152 @@ function getByteLength(str: string): number {
158
309
  return s;
159
310
  }
160
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
+
161
458
  // --- Diff Logic ---
162
459
 
163
460
  interface DiffPart {
@@ -322,11 +619,28 @@ function parseGitStatusPorcelain(raw: string): FileEntry[] {
322
619
 
323
620
  /**
324
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.
325
628
  */
326
- async function getGitStatus(): Promise<FileEntry[]> {
629
+ interface GitStatusResult {
630
+ files: FileEntry[];
631
+ emptyReason: EmptyStateReason;
632
+ }
633
+
634
+ async function getGitStatus(): Promise<GitStatusResult> {
327
635
  const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
328
- if (result.exit_code !== 0) return [];
329
- 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
+ };
330
644
  }
331
645
 
332
646
  /**
@@ -400,7 +714,10 @@ interface ListLine {
400
714
 
401
715
  interface DiffLine {
402
716
  text: string;
403
- type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment';
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
404
721
  style?: Partial<OverlayOptions>;
405
722
  inlineOverlays?: InlineOverlay[];
406
723
  // Line metadata for comment attachment
@@ -413,67 +730,19 @@ interface DiffLine {
413
730
  commentId?: string;
414
731
  }
415
732
 
416
- /**
417
- * Build the file list lines for the left panel.
418
- * Returns section headers (not selectable) and file entries.
419
- */
420
- function buildFileListLines(leftWidth?: number): ListLine[] {
421
- const lines: ListLine[] = [];
422
- let lastCategory: string | undefined;
423
-
424
- for (let i = 0; i < state.files.length; i++) {
425
- const f = state.files[i];
426
- // Section headers
427
- if (f.category !== lastCategory) {
428
- lastCategory = f.category;
429
- let label = '';
430
- if (f.category === 'staged') label = editor.t("section.staged") || "Staged";
431
- else if (f.category === 'unstaged') label = editor.t("section.unstaged") || "Changes";
432
- else if (f.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
433
- lines.push({
434
- text: `▸ ${label}`,
435
- type: 'section-header',
436
- style: { fg: STYLE_SECTION_HEADER, bold: true },
437
- });
438
- }
439
-
440
- // Status icon + selection prefix.
441
- const statusIcon = f.status === '?' ? 'A' : f.status;
442
- const prefix = i === state.selectedIndex ? '>' : ' ';
443
- const filename = f.origPath ? `${f.origPath} → ${f.path}` : f.path;
444
- lines.push({
445
- text: `${prefix}${statusIcon} ${filename}`,
446
- type: 'file',
447
- fileIndex: i,
448
- });
449
- }
450
-
451
- // Show session note at the bottom of the file list, word-wrapped
452
- if (state.note) {
453
- lines.push({ text: '', type: 'section-header' }); // blank separator
454
- lines.push({
455
- text: `▸ Note`,
456
- type: 'section-header',
457
- style: { fg: STYLE_COMMENT, bold: true },
458
- });
459
- // Wrap note text to fit left panel (minus 3 for " " prefix + padding)
460
- const wrapWidth = Math.max(20, (leftWidth || 40) - 3);
461
- const words = state.note.split(' ');
462
- let line = '';
463
- for (const word of words) {
464
- if (line && (line.length + 1 + word.length) > wrapWidth) {
465
- lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
466
- line = word;
467
- } else {
468
- line = line ? `${line} ${word}` : word;
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++;
469
742
  }
470
743
  }
471
- if (line) {
472
- lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
473
- }
474
744
  }
475
-
476
- return lines;
745
+ return { added, removed };
477
746
  }
478
747
 
479
748
  /**
@@ -491,6 +760,9 @@ function pushLineComments(
491
760
  (c.line_type === 'context' && c.new_line === newLine)
492
761
  )
493
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);
494
766
  for (const comment of lineComments) {
495
767
  const lineRef = comment.line_type === 'add'
496
768
  ? `+${comment.new_line}`
@@ -498,7 +770,7 @@ function pushLineComments(
498
770
  ? `-${comment.old_line}`
499
771
  : `${comment.new_line}`;
500
772
  lines.push({
501
- text: ` \u00bb [${lineRef}] ${comment.text}`,
773
+ text: `${commentIndent}\u00bb [${lineRef}] ${comment.text}`,
502
774
  type: 'comment',
503
775
  commentId: comment.id,
504
776
  style: { fg: STYLE_COMMENT, italic: true },
@@ -507,62 +779,139 @@ function pushLineComments(
507
779
  }
508
780
 
509
781
  /**
510
- * 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.
511
785
  */
512
- function buildDiffLines(rightWidth: number): DiffLine[] {
786
+ function buildDiffLines(_rightWidth: number): DiffLine[] {
513
787
  const lines: DiffLine[] = [];
514
- 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
+ }
515
804
 
516
- const selectedFile = state.files[state.selectedIndex];
517
- 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
+ }
518
846
 
519
- // Find hunks matching the selected file and category
520
- const fileHunks = state.hunks.filter(
521
- h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
522
- );
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
+ });
523
867
 
524
- if (fileHunks.length === 0) {
525
- if (selectedFile.status === 'R' && selectedFile.origPath) {
526
- lines.push({ text: `Renamed from ${selectedFile.origPath}`, type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
527
- } else if (selectedFile.status === 'D') {
528
- lines.push({ text: "(file deleted)", type: 'empty' });
529
- } else if (selectedFile.status === 'T') {
530
- lines.push({ text: "(type change: file symlink)", type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
531
- } else if (selectedFile.status === '?' && selectedFile.path.endsWith('/')) {
532
- lines.push({ text: "(untracked directory)", type: 'empty' });
533
- } else {
534
- 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;
535
887
  }
536
- return lines;
537
- }
538
888
 
539
- for (const hunk of fileHunks) {
540
- // Hunk header with review status indicator
541
- 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
542
893
  ? `@@ ${hunk.contextHeader} @@`
543
894
  : `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
895
+ const header = ` ▾ ${headerInner}`;
544
896
 
545
897
  lines.push({
546
898
  text: header,
547
899
  type: 'hunk-header',
548
900
  hunkId: hunk.id,
549
901
  file: hunk.file,
550
- style: { fg: STYLE_HUNK_HEADER, bold: true },
902
+ style: {
903
+ fg: STYLE_HUNK_HEADER,
904
+ bg: STYLE_HUNK_HEADER_BG,
905
+ bold: true,
906
+ extendToLineEnd: true,
907
+ },
551
908
  });
552
909
 
553
- // Render hunk-level comments (those with no line_type) right
554
- // after the hunk header so they are visible in the diff view.
555
- const hunkComments = state.comments.filter(c =>
556
- c.hunk_id === hunk.id && !c.line_type
557
- );
558
- for (const comment of hunkComments) {
559
- lines.push({
560
- text: ` \u00bb [hunk] ${comment.text}`,
561
- type: 'comment',
562
- commentId: comment.id,
563
- style: { fg: STYLE_COMMENT, italic: true },
564
- });
565
- }
910
+ // (Body always emitted collapse 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.)
566
915
 
567
916
  // Track actual file line numbers as we iterate
568
917
  let oldLineNum = hunk.oldRange.start;
@@ -584,9 +933,14 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
584
933
  const newContent = nextLine.substring(1);
585
934
  const parts = diffStrings(oldContent, newContent);
586
935
 
587
- // Build inline overlays for removed line
588
- const removeOverlays: InlineOverlay[] = [];
589
- let rOffset = getByteLength(line[0]); // skip prefix
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
590
944
  for (const part of parts) {
591
945
  const pLen = getByteLength(part.text);
592
946
  if (part.type === 'removed') {
@@ -595,19 +949,24 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
595
949
  if (part.type !== 'added') rOffset += pLen;
596
950
  }
597
951
  lines.push({
598
- text: line, type: 'remove',
952
+ text: removeText, type: 'remove',
599
953
  style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
600
954
  hunkId: hunk.id, file: hunk.file,
601
955
  lineType: 'remove', oldLine: curOldLine, newLine: undefined, lineContent: line,
602
- inlineOverlays: removeOverlays.length > 0 ? removeOverlays : undefined,
956
+ inlineOverlays: removeOverlays,
603
957
  });
604
958
  // Inline comments for the removed line
605
959
  pushLineComments(lines, hunk, 'remove', curOldLine, undefined);
606
960
  oldLineNum++;
607
961
 
608
- // Build inline overlays for added line
609
- const addOverlays: InlineOverlay[] = [];
610
- let aOffset = getByteLength(nextLine[0]);
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]);
611
970
  for (const part of parts) {
612
971
  const pLen = getByteLength(part.text);
613
972
  if (part.type === 'added') {
@@ -616,11 +975,11 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
616
975
  if (part.type !== 'removed') aOffset += pLen;
617
976
  }
618
977
  lines.push({
619
- text: nextLine, type: 'add',
978
+ text: addText, type: 'add',
620
979
  style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
621
980
  hunkId: hunk.id, file: hunk.file,
622
981
  lineType: 'add', oldLine: undefined, newLine: newLineNum, lineContent: nextLine,
623
- inlineOverlays: addOverlays.length > 0 ? addOverlays : undefined,
982
+ inlineOverlays: addOverlays,
624
983
  });
625
984
  pushLineComments(lines, hunk, 'add', undefined, newLineNum);
626
985
  newLineNum++;
@@ -628,27 +987,37 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
628
987
  continue;
629
988
  }
630
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
+
631
997
  if (prefix === '+') {
632
998
  lines.push({
633
- text: line, type: 'add',
999
+ text: decoratedText, type: 'add',
634
1000
  style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
635
1001
  hunkId: hunk.id, file: hunk.file,
636
1002
  lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
1003
+ inlineOverlays: [dimNumOverlay],
637
1004
  });
638
1005
  newLineNum++;
639
1006
  } else if (prefix === '-') {
640
1007
  lines.push({
641
- text: line, type: 'remove',
1008
+ text: decoratedText, type: 'remove',
642
1009
  style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
643
1010
  hunkId: hunk.id, file: hunk.file,
644
1011
  lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
1012
+ inlineOverlays: [dimNumOverlay],
645
1013
  });
646
1014
  oldLineNum++;
647
1015
  } else {
648
1016
  lines.push({
649
- text: line, type: 'context',
1017
+ text: decoratedText, type: 'context',
650
1018
  hunkId: hunk.id, file: hunk.file,
651
1019
  lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
1020
+ inlineOverlays: [dimNumOverlay],
652
1021
  });
653
1022
  oldLineNum++;
654
1023
  newLineNum++;
@@ -657,6 +1026,10 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
657
1026
  // Render inline comments attached to this line
658
1027
  pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
659
1028
  }
1029
+ }
1030
+
1031
+ // Blank separator between files
1032
+ lines.push({ text: '', type: 'empty' });
660
1033
  }
661
1034
 
662
1035
  return lines;
@@ -671,10 +1044,15 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
671
1044
  */
672
1045
 
673
1046
  // Theme colors for toolbar key hints
674
- const STYLE_KEY_FG: OverlayColorSpec = "syntax.keyword";
675
- const STYLE_KEY_BG: OverlayColorSpec = "editor.selection_bg";
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";
676
1054
  const STYLE_HINT_FG: OverlayColorSpec = "editor.line_number_fg";
677
- const STYLE_TOOLBAR_BG: OverlayColorSpec = "ui.status_bar_bg";
1055
+ const STYLE_TOOLBAR_BG: OverlayColorSpec = "editor.bg";
678
1056
  const STYLE_TOOLBAR_SEP: OverlayColorSpec = "ui.split_separator_fg";
679
1057
 
680
1058
  interface HintItem {
@@ -686,24 +1064,7 @@ interface HintItem {
686
1064
  * Build a styled toolbar entry with highlighted key hints.
687
1065
  * Keys get bold + keyword color; labels get dim text; groups separated by │.
688
1066
  */
689
- function buildToolbar(W: number): TextPropertyEntry {
690
- // Items within each group are ordered by importance so that when the
691
- // viewport is narrow, the most useful hints get full labels while
692
- // less discoverable ones are truncated to key-only or dropped.
693
- const groups: HintItem[][] = state.focusPanel === 'files'
694
- ? [
695
- [{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
696
- [{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
697
- [{ key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "↵", label: "Open" }, { key: "Tab", label: "Switch" }, { key: "r", label: "Refresh" }],
698
- ]
699
- : [
700
- [{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
701
- [{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
702
- [{ key: "n", label: "Next" }, { key: "p", label: "Prev" }, { key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "Tab", label: "Switch" }],
703
- ];
704
-
705
- // Build text and collect overlay ranges, gracefully dropping labels
706
- // when the viewport is too narrow to fit everything.
1067
+ function buildToolbarRow(W: number, groups: HintItem[][]): TextPropertyEntry {
707
1068
  const overlays: InlineOverlay[] = [];
708
1069
  let text = " ";
709
1070
  let bytePos = getByteLength(" ");
@@ -720,15 +1081,21 @@ function buildToolbar(W: number): TextPropertyEntry {
720
1081
  for (let h = 0; h < groups[g].length && !done; h++) {
721
1082
  const item = groups[g][h];
722
1083
  const gap = h > 0 ? " " : "";
723
- const fullLen = gap.length + item.key.length + 1 + item.label.length;
724
- const keyOnlyLen = gap.length + item.key.length;
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;
725
1093
 
726
1094
  if (text.length + fullLen <= W) {
727
- // Full item: gap + key + " " + label
728
1095
  if (gap) { text += gap; bytePos += getByteLength(gap); }
729
- const keyLen = getByteLength(item.key);
730
- overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
731
- text += item.key;
1096
+ const keyLen = getByteLength(keyDisplay);
1097
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
1098
+ text += keyDisplay;
732
1099
  bytePos += keyLen;
733
1100
  const labelText = " " + item.label;
734
1101
  const labelLen = getByteLength(labelText);
@@ -736,11 +1103,10 @@ function buildToolbar(W: number): TextPropertyEntry {
736
1103
  text += labelText;
737
1104
  bytePos += labelLen;
738
1105
  } else if (text.length + keyOnlyLen <= W) {
739
- // Key only (no label) when space is tight
740
1106
  if (gap) { text += gap; bytePos += getByteLength(gap); }
741
- const keyLen = getByteLength(item.key);
742
- overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
743
- text += item.key;
1107
+ const keyLen = getByteLength(keyDisplay);
1108
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
1109
+ text += keyDisplay;
744
1110
  bytePos += keyLen;
745
1111
  } else {
746
1112
  done = true;
@@ -757,75 +1123,82 @@ function buildToolbar(W: number): TextPropertyEntry {
757
1123
  };
758
1124
  }
759
1125
 
760
- // --- Buffer Group panel content builders ---
761
-
762
- function buildToolbarPanelEntries(): TextPropertyEntry[] {
763
- // Reuse buildToolbar returns one entry with the full toolbar line
764
- return [buildToolbar(state.viewportWidth)];
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)];
765
1157
  }
766
1158
 
767
- function buildFilesPanelEntries(): TextPropertyEntry[] {
768
- const entries: TextPropertyEntry[] = [];
769
- const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
770
-
771
- // Header row: "GIT STATUS" — emphasized when the files panel has focus.
772
- const focusLeft = state.focusPanel === 'files';
773
- const headerStyle: Partial<OverlayOptions> = focusLeft
774
- ? { fg: STYLE_HEADER, bold: true, underline: true }
775
- : { fg: STYLE_DIVIDER };
776
- entries.push({
777
- text: " GIT STATUS\n",
778
- style: headerStyle,
779
- properties: { type: "header" },
780
- });
1159
+ // --- Buffer Group panel content builders ---
781
1160
 
782
- const lines = buildFileListLines(leftWidth);
783
- for (const line of lines) {
784
- // Selection is plugin-managed: draw a bg highlight on the row whose
785
- // fileIndex matches state.selectedIndex. The native cursor is hidden
786
- // for the files panel (show_cursors stays false).
787
- const isSelected = line.type === 'file' && line.fileIndex === state.selectedIndex;
788
- const baseStyle = line.style;
789
- const style: Partial<OverlayOptions> | undefined = isSelected
790
- ? { ...(baseStyle || {}), bg: STYLE_SELECTED_BG, bold: true, extendToLineEnd: true }
791
- : baseStyle;
792
- entries.push({
793
- text: (line.text || "") + "\n",
794
- style,
795
- inlineOverlays: line.inlineOverlays,
796
- properties: { type: line.type, fileIndex: line.fileIndex },
797
- });
798
- }
799
- return entries;
1161
+ function buildToolbarPanelEntries(): TextPropertyEntry[] {
1162
+ // Two-row toolbar: navigation hints on row 1, actions on row 2.
1163
+ return buildToolbar(state.viewportWidth);
800
1164
  }
801
1165
 
802
1166
  /**
803
- * Build (or fetch from cache) the diff-panel entries for the currently
804
- * selected file. The cache is keyed by `${file}\0${gitStatus}` and is cleared
805
- * in `refreshMagitData`. As a side effect, populates `state.hunkHeaderRows`
806
- * and `state.diffLineByteOffsets` for the cached entry — these back `n`/`p`
807
- * hunk navigation and the cursor-line overlay.
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.
808
1173
  */
809
1174
  function buildDiffPanelEntries(): TextPropertyEntry[] {
810
- const selectedFile = state.files[state.selectedIndex];
811
- const cacheKey = selectedFile
812
- ? `${selectedFile.path}\0${selectedFile.category}`
813
- : "\0";
814
- const cached = state.diffCache[cacheKey];
815
- if (cached) {
816
- state.hunkHeaderRows = cached.hunkHeaderRows;
817
- state.diffLineByteOffsets = cached.diffLineByteOffsets;
818
- return cached.entries;
819
- }
820
-
821
1175
  const entries: TextPropertyEntry[] = [];
822
- const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
823
- const rightWidth = state.viewportWidth - leftWidth - 1;
824
1176
 
825
1177
  const hunkHeaderRows: number[] = [];
826
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
+
827
1199
  let runningByte = 0;
828
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
829
1202
 
830
1203
  const pushEntry = (entry: TextPropertyEntry) => {
831
1204
  diffLineByteOffsets.push(runningByte);
@@ -834,23 +1207,8 @@ function buildDiffPanelEntries(): TextPropertyEntry[] {
834
1207
  row++;
835
1208
  };
836
1209
 
837
- // Header row: "DIFF FOR <file>". Always rendered as focused (the panel
838
- // is the only place this header appears) so the cached entries can be
839
- // reused regardless of which panel currently has focus.
840
- const rightHeader = selectedFile
841
- ? ` DIFF FOR ${selectedFile.path}`
842
- : " DIFF";
843
- pushEntry({
844
- text: rightHeader + "\n",
845
- style: { fg: STYLE_HEADER, bold: true, underline: true },
846
- properties: { type: "header" },
847
- });
848
-
849
- const lines = buildDiffLines(rightWidth);
1210
+ const lines = buildDiffLines(state.viewportWidth);
850
1211
  for (const line of lines) {
851
- // Embed the full DiffLine metadata as text properties so action
852
- // handlers (`s`, `u`, `d`, `c`, `x`) can read it back via
853
- // getTextPropertiesAtCursor without re-walking state.hunks.
854
1212
  const props: Record<string, unknown> = { type: line.type };
855
1213
  if (line.hunkId !== undefined) props.hunkId = line.hunkId;
856
1214
  if (line.file !== undefined) props.file = line.file;
@@ -859,26 +1217,179 @@ function buildDiffPanelEntries(): TextPropertyEntry[] {
859
1217
  if (line.newLine !== undefined) props.newLine = line.newLine;
860
1218
  if (line.lineContent !== undefined) props.lineContent = line.lineContent;
861
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
+ }
862
1246
 
863
1247
  if (line.type === 'hunk-header') {
864
- // 1-indexed row of this hunk header in the diff buffer.
865
1248
  hunkHeaderRows.push(row + 1);
1249
+ if (line.hunkId) hunkRowByHunkId[line.hunkId] = row + 1;
1250
+ }
1251
+ if (line.type === 'file-header' && line.fileKey) {
1252
+ fileHeaderRows[line.fileKey] = row + 1;
1253
+ }
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);
866
1262
  }
867
1263
 
1264
+ entryPropsByRow[row + 1] = props;
1265
+
868
1266
  pushEntry({
869
1267
  text: (line.text || "") + "\n",
870
1268
  style: line.style,
871
1269
  inlineOverlays: line.inlineOverlays,
872
1270
  properties: props,
873
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;
874
1278
  }
875
1279
 
876
- // Sentinel: total buffer length, used as the end of the last row.
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
+
877
1285
  diffLineByteOffsets.push(runningByte);
878
1286
 
879
- state.diffCache[cacheKey] = { entries, hunkHeaderRows, diffLineByteOffsets };
880
1287
  state.hunkHeaderRows = hunkHeaderRows;
881
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
+ }
1299
+
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();
1312
+ entries.push({
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" },
1321
+ });
1322
+
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;
1338
+ }
1339
+ return Number.MAX_SAFE_INTEGER;
1340
+ };
1341
+
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
+ });
1353
+
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;
1385
+ entries.push({
1386
+ text: display + "\n",
1387
+ style,
1388
+ inlineOverlays,
1389
+ properties: { type: "comment-nav", commentId: c.id, file: c.file, line: lineRef },
1390
+ });
1391
+ }
1392
+
882
1393
  return entries;
883
1394
  }
884
1395
 
@@ -891,21 +1402,320 @@ function updateMagitDisplay(): void {
891
1402
  refreshViewportDimensions();
892
1403
  if (state.groupId === null) return;
893
1404
  editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
894
- editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
895
1405
  editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
896
- // setPanelContent wipes the buffer's overlays — re-paint the diff
897
- // cursor-line highlight (the files panel doesn't have one; selection
898
- // there is rendered as part of the entry style).
1406
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
1407
+ refreshStickyHeader(0);
1408
+ applyFolds();
899
1409
  applyCursorLineOverlay('diff');
900
1410
  }
901
1411
 
902
1412
  /**
903
- * Rebuild only the diff panel. Called when the selected file changes.
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.
904
1424
  */
905
- function refreshDiffPanelOnly(): void {
1425
+ function applyFolds(): void {
906
1426
  if (state.groupId === null) return;
907
- editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
908
- applyCursorLineOverlay('diff');
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;
1454
+
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
+ }
1470
+
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 };
1489
+ }
1490
+ } else {
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);
1525
+ updateMagitDisplay();
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);
1550
+ }
1551
+
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
+ }
1585
+ }
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;
1641
+ }
1642
+ }
1643
+ registerHandler("on_review_mouse_click", on_review_mouse_click);
1644
+
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;
1666
+ }
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);
1690
+ }
1691
+
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;
909
1719
  }
910
1720
 
911
1721
  /**
@@ -935,120 +1745,486 @@ function applyCursorLineOverlay(panel: 'diff'): void {
935
1745
  function review_refresh() { refreshMagitData(); }
936
1746
  registerHandler("review_refresh", review_refresh);
937
1747
 
938
- // --- Focus and cursor-driven navigation ---
939
- //
940
- // Cursor keys (j/k/Up/Down/PageUp/PageDown/Home/End) are bound to plugin
941
- // handlers that branch on which panel is focused:
1748
+ // --- Cursor-driven navigation ---
942
1749
  //
943
- // * Files panel: selection is plugin-managed (`state.selectedIndex` with
944
- // a `>` prefix + bg highlight). The handler updates the index, repaints
945
- // the files panel, and swaps the diff panel content from cache. The
946
- // native cursor stays hidden in the files panel.
947
- //
948
- // * Diff panel: motion is delegated to the editor's built-in actions
949
- // (`move_up`, `move_down`, etc.) via `executeAction`. The cursor moves
950
- // natively, the editor handles viewport scrolling, and `cursor_moved`
951
- // fires so the cursor-line overlay follows along.
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;
1770
+ }
1771
+ }
1772
+ return bestFile;
1773
+ }
1774
+
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;
1781
+ }
952
1782
 
953
- function isFilesFocused(): boolean {
954
- return state.focusPanel === 'files';
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;
955
1788
  }
956
1789
 
957
- function refreshFilesPanelOnly(): void {
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() {
958
1801
  if (state.groupId === null) return;
959
- editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
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;
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);
1852
+ }
1853
+ registerHandler("review_toggle_file_collapse", review_toggle_file_collapse);
1854
+
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
+ });
960
1873
  }
961
1874
 
962
- function selectFile(newIndex: number) {
963
- if (newIndex < 0 || newIndex >= state.files.length) return;
964
- if (newIndex === state.selectedIndex) return;
965
- state.selectedIndex = newIndex;
966
- state.diffCursorRow = 1; // diff panel cursor returns to the top of the new file
967
- refreshFilesPanelOnly();
968
- refreshDiffPanelOnly();
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());
1884
+ }
969
1885
  }
970
1886
 
971
- function review_nav_up() {
972
- if (isFilesFocused()) {
973
- selectFile(state.selectedIndex - 1);
974
- } else {
975
- editor.executeAction("move_up");
1887
+ function review_next_comment() {
1888
+ if (state.comments.length === 0) {
1889
+ editor.setStatus(editor.t("status.no_comments") || "No comments");
1890
+ return;
976
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]);
977
1899
  }
978
- registerHandler("review_nav_up", review_nav_up);
1900
+ registerHandler("review_next_comment", review_next_comment);
979
1901
 
980
- function review_nav_down() {
981
- if (isFilesFocused()) {
982
- selectFile(state.selectedIndex + 1);
983
- } else {
984
- editor.executeAction("move_down");
1902
+ function review_prev_comment() {
1903
+ if (state.comments.length === 0) {
1904
+ editor.setStatus(editor.t("status.no_comments") || "No comments");
1905
+ return;
985
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]);
986
1912
  }
987
- registerHandler("review_nav_down", review_nav_down);
1913
+ registerHandler("review_prev_comment", review_prev_comment);
988
1914
 
989
- function review_page_up() {
990
- if (isFilesFocused()) {
991
- const step = Math.max(1, state.viewportHeight - 2);
992
- selectFile(Math.max(0, state.selectedIndex - step));
993
- } else {
994
- editor.executeAction("move_page_up");
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;
995
1926
  }
1927
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
996
1928
  }
997
- registerHandler("review_page_up", review_page_up);
1929
+ registerHandler("review_focus_comments", review_focus_comments);
998
1930
 
999
- function review_page_down() {
1000
- if (isFilesFocused()) {
1001
- const step = Math.max(1, state.viewportHeight - 2);
1002
- selectFile(Math.min(state.files.length - 1, state.selectedIndex + step));
1003
- } else {
1004
- editor.executeAction("move_page_down");
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();
1005
1975
  }
1006
1976
  }
1007
- registerHandler("review_page_down", review_page_down);
1977
+ registerHandler("review_enter_dispatch", review_enter_dispatch);
1008
1978
 
1009
- function review_nav_home() {
1010
- if (isFilesFocused()) {
1011
- selectFile(0);
1012
- } else {
1013
- editor.executeAction("move_document_start");
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;
1014
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");
1015
2011
  }
1016
- registerHandler("review_nav_home", review_nav_home);
2012
+ registerHandler("review_visual_start", review_visual_start);
1017
2013
 
1018
- function review_nav_end() {
1019
- if (isFilesFocused()) {
1020
- selectFile(state.files.length - 1);
1021
- } else {
1022
- editor.executeAction("move_document_end");
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");
1023
2019
  }
2020
+ applyCursorLineOverlay('diff');
1024
2021
  }
1025
- registerHandler("review_nav_end", review_nav_end);
2022
+ registerHandler("review_visual_cancel", review_visual_cancel);
2023
+
2024
+ const LINE_SELECTION_NS = "review-line-selection";
1026
2025
 
1027
- function review_toggle_focus() {
2026
+ function paintLineSelectionOverlay() {
1028
2027
  if (state.groupId === null) return;
1029
- const newPanel: 'files' | 'diff' = state.focusPanel === 'files' ? 'diff' : 'files';
1030
- state.focusPanel = newPanel;
1031
- editor.focusBufferGroupPanel(state.groupId, newPanel);
1032
- // Refresh the toolbar so its hint set matches the new focus.
1033
- editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
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
+ });
2045
+ }
1034
2046
  }
1035
- registerHandler("review_toggle_focus", review_toggle_focus);
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.
1036
2173
 
1037
2174
  // --- Real git stage/unstage/discard actions (Step 4) ---
1038
2175
 
1039
2176
  /**
1040
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.
1041
2183
  */
1042
- function buildHunkPatch(filePath: string, hunk: Hunk): string {
1043
- const oldCount = hunk.lines.filter(l => l[0] === '-' || l[0] === ' ').length;
1044
- const newCount = hunk.lines.filter(l => l[0] === '+' || l[0] === ' ').length;
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
+
1045
2221
  const header = `@@ -${hunk.oldRange.start},${oldCount} +${hunk.range.start},${newCount} @@`;
1046
2222
  return [
1047
2223
  `diff --git a/${filePath} b/${filePath}`,
1048
2224
  `--- a/${filePath}`,
1049
2225
  `+++ b/${filePath}`,
1050
2226
  header,
1051
- ...hunk.lines,
2227
+ ...filtered,
1052
2228
  ''
1053
2229
  ].join('\n');
1054
2230
  }
@@ -1095,73 +2271,170 @@ function readPropsAtCursor(panel: 'files' | 'diff'): Record<string, unknown> | n
1095
2271
  * something useful.
1096
2272
  */
1097
2273
  function getHunkAtDiffCursor(): Hunk | null {
1098
- const props = readPropsAtCursor('diff');
2274
+ const props = propsAtCursorRow();
1099
2275
  const hunkId = props ? props["hunkId"] : undefined;
1100
2276
  if (typeof hunkId === 'string') {
1101
2277
  const found = state.hunks.find(h => h.id === hunkId);
1102
2278
  if (found) return found;
1103
2279
  }
1104
- // Fallback: first hunk for the currently-selected file.
1105
- const selectedFile = state.files[state.selectedIndex];
1106
- if (!selectedFile) return null;
2280
+ // Fallback: first hunk for the file under the cursor (if any).
2281
+ const cur = currentFileFromCursor();
2282
+ if (!cur) return null;
1107
2283
  return state.hunks.find(
1108
- h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
2284
+ h => h.file === cur.path && h.gitStatus === cur.category
1109
2285
  ) || null;
1110
2286
  }
1111
2287
 
1112
- async function review_stage_file() {
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() {
1113
2312
  if (state.files.length === 0) return;
1114
- if (state.focusPanel === 'diff') {
1115
- // Hunk-level staging
1116
- const hunk = getHunkAtDiffCursor();
1117
- if (!hunk || !hunk.file) return;
1118
- if (hunk.gitStatus === 'untracked') {
1119
- await editor.spawnProcess("git", ["add", "--", hunk.file]);
1120
- } else {
1121
- const patch = buildHunkPatch(hunk.file, hunk);
1122
- const ok = await applyHunkPatch(patch, ["--cached"]);
1123
- if (!ok) return;
1124
- }
1125
- editor.setStatus(editor.t("status.hunk_staged") || "Hunk staged");
1126
- await refreshMagitData();
2313
+ if (state.lineSelection) { await applyLineSelection('stage'); return; }
2314
+ const headerFile = fileHeaderUnderCursor();
2315
+ if (headerFile) {
2316
+ await stageFileEntry(headerFile);
1127
2317
  return;
1128
2318
  }
1129
- const f = state.files[state.selectedIndex];
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
+ */
2339
+ async function review_stage_file() {
2340
+ if (state.files.length === 0) return;
2341
+ const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
1130
2342
  if (!f) return;
1131
- await editor.spawnProcess("git", ["add", "--", f.path]);
1132
- await refreshMagitData();
2343
+ await stageFileEntry(f);
1133
2344
  }
1134
2345
  registerHandler("review_stage_file", review_stage_file);
1135
2346
 
1136
2347
  async function review_unstage_file() {
1137
2348
  if (state.files.length === 0) return;
1138
- if (state.focusPanel === 'diff') {
1139
- // Hunk-level unstaging
1140
- const hunk = getHunkAtDiffCursor();
1141
- if (!hunk || !hunk.file || hunk.gitStatus !== 'staged') {
1142
- editor.setStatus("Can only unstage staged hunks");
1143
- return;
1144
- }
2349
+ const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
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);
2363
+ await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
2364
+ await refreshMagitData();
2365
+ }
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 {
1145
2373
  const patch = buildHunkPatch(hunk.file, hunk);
1146
- const ok = await applyHunkPatch(patch, ["--cached", "--reverse"]);
2374
+ const ok = await applyHunkPatch(patch, ["--cached"]);
1147
2375
  if (!ok) return;
1148
- editor.setStatus(editor.t("status.hunk_unstaged") || "Hunk unstaged");
1149
- await refreshMagitData();
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");
1150
2384
  return;
1151
2385
  }
1152
- const f = state.files[state.selectedIndex];
1153
- if (!f) return;
1154
- await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
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");
1155
2391
  await refreshMagitData();
1156
2392
  }
1157
- registerHandler("review_unstage_file", review_unstage_file);
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);
1158
2427
 
1159
2428
  function review_discard_file() {
1160
2429
  if (state.files.length === 0) return;
1161
- if (state.focusPanel === 'diff') {
1162
- // Hunk-level discard — show confirmation
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
1163
2435
  const hunk = getHunkAtDiffCursor();
1164
2436
  if (!hunk || !hunk.file) return;
2437
+ rememberPendingHunkAnchor(hunk.id);
1165
2438
  editor.startPrompt(
1166
2439
  editor.t("prompt.discard_hunk", { file: hunk.file }) ||
1167
2440
  `Discard this hunk in "${hunk.file}"? This cannot be undone.`,
@@ -1174,10 +2447,11 @@ function review_discard_file() {
1174
2447
  editor.setPromptSuggestions(suggestions);
1175
2448
  return;
1176
2449
  }
1177
- const f = state.files[state.selectedIndex];
1178
2450
  if (!f) return;
1179
2451
 
1180
2452
  // Show confirmation prompt — discard is destructive and irreversible
2453
+ pendingDiscardFile = f;
2454
+ rememberPendingHunkAnchor(null);
1181
2455
  const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
1182
2456
  editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
1183
2457
  const suggestions: PromptSuggestion[] = [
@@ -1213,7 +2487,7 @@ async function on_review_discard_confirm(args: { prompt_type: string; input: str
1213
2487
 
1214
2488
  const response = args.input.trim().toLowerCase();
1215
2489
  if (response === "discard" || args.selected_index === 0) {
1216
- const f = state.files[state.selectedIndex];
2490
+ const f = pendingDiscardFile;
1217
2491
  if (f) {
1218
2492
  if (f.category === 'untracked') {
1219
2493
  await editor.spawnProcess("rm", ["--", f.path]);
@@ -1226,6 +2500,7 @@ async function on_review_discard_confirm(args: { prompt_type: string; input: str
1226
2500
  } else {
1227
2501
  editor.setStatus("Discard cancelled");
1228
2502
  }
2503
+ pendingDiscardFile = null;
1229
2504
  return false;
1230
2505
  }
1231
2506
  registerHandler("on_review_discard_confirm", on_review_discard_confirm);
@@ -1234,16 +2509,51 @@ registerHandler("on_review_discard_confirm", on_review_discard_confirm);
1234
2509
  * Refresh file list and diffs using the new git status approach, then re-render.
1235
2510
  */
1236
2511
  async function refreshMagitData() {
1237
- const files = await getGitStatus();
1238
- state.files = files;
1239
- state.hunks = await fetchDiffsForFiles(files);
1240
- // Clamp selectedIndex
1241
- if (state.selectedIndex >= state.files.length) {
1242
- state.selectedIndex = Math.max(0, state.files.length - 1);
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);
2522
+ }
2523
+ state.diffCursorRow = 1;
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
+ }
1243
2546
  }
1244
- state.diffCursorRow = 1;
1245
- state.diffCache = {}; // git state may have changed invalidate cached diffs
1246
- updateMagitDisplay();
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);
1247
2557
  }
1248
2558
 
1249
2559
  // --- Resize handler ---
@@ -1267,8 +2577,6 @@ function refreshViewportDimensions(): boolean {
1267
2577
  function onReviewDiffResize(_data: { width: number; height: number }): void {
1268
2578
  if (state.reviewBufferId === null) return;
1269
2579
  refreshViewportDimensions();
1270
- // Invalidate cached diff entries — they were built for the old viewport width
1271
- state.diffCache = {};
1272
2580
  updateMagitDisplay();
1273
2581
  }
1274
2582
  registerHandler("onReviewDiffResize", onReviewDiffResize);
@@ -1546,15 +2854,15 @@ function generateDiffPaneContent(
1546
2854
  // Line number color
1547
2855
  highlights.push({
1548
2856
  range: [currentByte + 2, currentByte + 6],
1549
- fg: [120, 120, 120] // Gray line numbers
2857
+ fg: "editor.line_number_fg",
1550
2858
  });
1551
2859
 
1552
2860
  if (isFiller) {
1553
2861
  // Filler styling - extend to full line width
1554
2862
  highlights.push({
1555
2863
  range: [currentByte + prefixLen, currentByte + lineLen - 1],
1556
- fg: [60, 60, 60],
1557
- bg: [30, 30, 30],
2864
+ fg: "editor.line_number_fg",
2865
+ bg: "editor.line_number_bg",
1558
2866
  extend_to_line_end: true
1559
2867
  });
1560
2868
  } else if (line.changeType === 'added' && side === 'new') {
@@ -1563,7 +2871,7 @@ function generateDiffPaneContent(
1563
2871
  highlights.push({
1564
2872
  range: [currentByte + prefixLen, currentByte + lineLen - 1],
1565
2873
  fg: STYLE_ADD_TEXT,
1566
- bg: [30, 50, 30],
2874
+ bg: STYLE_ADD_BG,
1567
2875
  extend_to_line_end: true
1568
2876
  });
1569
2877
  } else if (line.changeType === 'removed' && side === 'old') {
@@ -1572,7 +2880,7 @@ function generateDiffPaneContent(
1572
2880
  highlights.push({
1573
2881
  range: [currentByte + prefixLen, currentByte + lineLen - 1],
1574
2882
  fg: STYLE_REMOVE_TEXT,
1575
- bg: [50, 30, 30],
2883
+ bg: STYLE_REMOVE_BG,
1576
2884
  extend_to_line_end: true
1577
2885
  });
1578
2886
  } else if (line.changeType === 'modified') {
@@ -1661,9 +2969,9 @@ interface CompositeDiffState {
1661
2969
  let activeCompositeDiffState: CompositeDiffState | null = null;
1662
2970
 
1663
2971
  async function review_drill_down() {
1664
- // 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)
1665
2973
  if (state.files.length === 0) return;
1666
- const selectedFile = state.files[state.selectedIndex];
2974
+ const selectedFile = currentFileFromCursor();
1667
2975
  if (!selectedFile) return;
1668
2976
 
1669
2977
  // Create a minimal hunk-like reference for the rest of the function
@@ -1848,58 +3156,121 @@ function jumpDiffCursorToRow(row: number): void {
1848
3156
  const idx = row - 1;
1849
3157
  if (idx < 0 || idx >= state.diffLineByteOffsets.length) return;
1850
3158
 
1851
- if (state.focusPanel === 'diff') {
1852
- // When the diff panel is focused, use executeAction so that the
1853
- // normal cursor event flow fires and the status bar line number
1854
- // updates correctly. This is O(delta) but necessary because
1855
- // setBufferCursor doesn't trigger line-index refresh in the
1856
- // virtual buffer's piece tree.
1857
- const delta = row - state.diffCursorRow;
1858
- const action = delta > 0 ? "move_down" : "move_up";
1859
- for (let i = 0, n = Math.abs(delta); i < n; i++) editor.executeAction(action);
1860
- } else {
1861
- // When unfocused, setBufferCursor is safe since the cursor
1862
- // position isn't displayed in the status bar.
1863
- const byteOffset = state.diffLineByteOffsets[idx];
1864
- editor.setBufferCursor(diffId, byteOffset);
1865
- editor.scrollBufferToLine(diffId, idx);
1866
- }
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);
1867
3167
  state.diffCursorRow = row;
1868
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
+ }
3204
+ }
3205
+
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);
1869
3252
  }
1870
3253
 
1871
3254
  function review_next_hunk() {
1872
- // Magit review-mode diff panel: jump to the next hunk header row.
1873
- if (state.groupId !== null && state.focusPanel === 'diff') {
1874
- for (const row of state.hunkHeaderRows) {
1875
- if (row > state.diffCursorRow) {
1876
- jumpDiffCursorToRow(row);
1877
- return;
1878
- }
1879
- }
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);
1880
3261
  return;
1881
3262
  }
1882
- // Composite diff-view hunk navigation is handled by the Action system
1883
- // (CompositeNextHunk) via CompositeBuffer context keybindings, so no
1884
- // plugin fallback is needed here.
3263
+ if (cur + 1 >= state.hunks.length) return;
3264
+ jumpToGlobalHunk(cur + 1);
1885
3265
  }
1886
3266
  registerHandler("review_next_hunk", review_next_hunk);
1887
3267
 
1888
3268
  function review_prev_hunk() {
1889
- // Magit review-mode diff panel: jump to the previous hunk header row.
1890
- if (state.groupId !== null && state.focusPanel === 'diff') {
1891
- for (let i = state.hunkHeaderRows.length - 1; i >= 0; i--) {
1892
- const row = state.hunkHeaderRows[i];
1893
- if (row < state.diffCursorRow) {
1894
- jumpDiffCursorToRow(row);
1895
- return;
1896
- }
1897
- }
1898
- return;
1899
- }
1900
- // Composite diff-view hunk navigation is handled by the Action system
1901
- // (CompositePrevHunk) via CompositeBuffer context keybindings, so no
1902
- // plugin fallback is needed here.
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);
1903
3274
  }
1904
3275
  registerHandler("review_prev_hunk", review_prev_hunk);
1905
3276
 
@@ -1919,16 +3290,7 @@ editor.defineMode("diff-view", [
1919
3290
 
1920
3291
  function getCurrentHunkId(): string | null {
1921
3292
  if (state.files.length === 0) return null;
1922
- if (state.focusPanel === 'diff') {
1923
- const hunk = getHunkAtDiffCursor();
1924
- return hunk?.id || null;
1925
- }
1926
- // File panel: return first hunk for selected file
1927
- const selectedFile = state.files[state.selectedIndex];
1928
- if (!selectedFile) return null;
1929
- const hunk = state.hunks.find(
1930
- h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1931
- );
3293
+ const hunk = getHunkAtDiffCursor();
1932
3294
  return hunk?.id || null;
1933
3295
  }
1934
3296
 
@@ -1943,28 +3305,24 @@ interface PendingCommentInfo {
1943
3305
  lineContent?: string;
1944
3306
  }
1945
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
+ */
1946
3313
  function getCurrentLineInfo(): PendingCommentInfo | null {
1947
3314
  if (state.files.length === 0) return null;
1948
- const selectedFile = state.files[state.selectedIndex];
1949
- if (!selectedFile) return null;
1950
-
1951
- const props = readPropsAtCursor('diff');
1952
- const hunkId = props ? props["hunkId"] : undefined;
1953
- if (typeof hunkId !== 'string') {
1954
- // Fallback: first hunk for the selected file.
1955
- const hunk = state.hunks.find(
1956
- h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1957
- );
1958
- if (!hunk) return null;
1959
- return { hunkId: hunk.id, file: hunk.file };
1960
- }
1961
-
1962
- const file = typeof props!["file"] === 'string' ? props!["file"] as string : selectedFile.path;
1963
- const lineType = props!["lineType"] as ('add' | 'remove' | 'context' | undefined);
1964
- const oldLine = typeof props!["oldLine"] === 'number' ? props!["oldLine"] as number : undefined;
1965
- const newLine = typeof props!["newLine"] === 'number' ? props!["newLine"] as number : undefined;
1966
- const lineContent = typeof props!["lineContent"] === 'string' ? props!["lineContent"] as string : undefined;
1967
- return { hunkId, file, lineType, oldLine, newLine, lineContent };
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 };
1968
3326
  }
1969
3327
 
1970
3328
  // Pending prompt state for event-based prompt handling
@@ -1976,7 +3334,7 @@ let editingCommentId: string | null = null; // non-null when editing an existing
1976
3334
  * comment display line itself or on the diff line it's attached to.
1977
3335
  */
1978
3336
  function findCommentAtCursor(): ReviewComment | null {
1979
- const props = readPropsAtCursor('diff');
3337
+ const props = propsAtCursorRow();
1980
3338
  if (!props) return null;
1981
3339
 
1982
3340
  // Cursor sits directly on a comment display line.
@@ -2002,19 +3360,51 @@ function findCommentAtCursor(): ReviewComment | null {
2002
3360
  }
2003
3361
 
2004
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
+
2005
3392
  const info = getCurrentLineInfo();
2006
3393
  if (!info) {
2007
- 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
+ );
2008
3398
  return;
2009
3399
  }
2010
3400
 
2011
- // Check for existing comment to edit
3401
+ // Check for existing comment on this diff line to edit
2012
3402
  const existing = findCommentAtCursor();
2013
3403
 
2014
3404
  pendingCommentInfo = info;
2015
3405
  editingCommentId = existing?.id || null;
2016
3406
 
2017
- let lineRef = 'hunk';
3407
+ let lineRef = 'line';
2018
3408
  if (info.lineType === 'add' && info.newLine) {
2019
3409
  lineRef = `+${info.newLine}`;
2020
3410
  } else if (info.lineType === 'remove' && info.oldLine) {
@@ -2040,28 +3430,7 @@ registerHandler("review_add_comment", review_add_comment);
2040
3430
  let pendingDeleteCommentId: string | null = null;
2041
3431
 
2042
3432
  async function review_delete_comment() {
2043
- let target: ReviewComment | null = null;
2044
-
2045
- if (state.focusPanel === 'diff') {
2046
- target = findCommentAtCursor();
2047
- } else {
2048
- // File panel: target the last note
2049
- const notes = state.comments.filter(c => c.hunk_id === '__overall__');
2050
- if (notes.length > 0) target = notes[notes.length - 1];
2051
- }
2052
-
2053
- // File panel: delete note
2054
- if (!target && state.focusPanel === 'files' && state.note) {
2055
- pendingDeleteCommentId = '__note__';
2056
- const preview = state.note.length > 40 ? state.note.substring(0, 37) + '...' : state.note;
2057
- editor.startPrompt(`Delete note "${preview}"?`, "review-delete-comment-confirm");
2058
- const suggestions: PromptSuggestion[] = [
2059
- { text: "Delete", description: "Remove this note", value: "delete" },
2060
- { text: "Cancel", description: "Keep the note", value: "cancel" },
2061
- ];
2062
- editor.setPromptSuggestions(suggestions);
2063
- return;
2064
- }
3433
+ const target: ReviewComment | null = findCommentAtCursor();
2065
3434
 
2066
3435
  if (!target) {
2067
3436
  editor.setStatus("No comment to delete");
@@ -2088,7 +3457,7 @@ function on_review_delete_comment_confirm(args: { prompt_type: string; input: st
2088
3457
  } else {
2089
3458
  state.comments = state.comments.filter(c => c.id !== pendingDeleteCommentId);
2090
3459
  }
2091
- state.diffCache = {}; // comment changed
3460
+ persistReview();
2092
3461
  updateMagitDisplay();
2093
3462
  editor.setStatus("Deleted");
2094
3463
  } else {
@@ -2105,6 +3474,13 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
2105
3474
  return true;
2106
3475
  }
2107
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
+
2108
3484
  if (editingCommentId) {
2109
3485
  // Edit mode: update existing comment (empty text keeps the comment unchanged)
2110
3486
  if (args.input && args.input.trim()) {
@@ -2112,8 +3488,9 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
2112
3488
  if (existing) {
2113
3489
  existing.text = args.input.trim();
2114
3490
  existing.timestamp = new Date().toISOString();
2115
- state.diffCache = {}; // comment changed
3491
+ persistReview();
2116
3492
  updateMagitDisplay();
3493
+ jumpDiffCursorToRow(cursorRowBeforeRebuild);
2117
3494
  editor.setStatus("Comment updated");
2118
3495
  }
2119
3496
  } else {
@@ -2138,8 +3515,9 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
2138
3515
  line_type: pendingCommentInfo.lineType
2139
3516
  };
2140
3517
  state.comments.push(comment);
2141
- state.diffCache = {}; // comment changed — invalidate cached diff entries
3518
+ persistReview();
2142
3519
  updateMagitDisplay();
3520
+ jumpDiffCursorToRow(cursorRowBeforeRebuild);
2143
3521
  let lineRef = 'hunk';
2144
3522
  if (comment.line_type === 'add' && comment.new_line) {
2145
3523
  lineRef = `line +${comment.new_line}`;
@@ -2189,6 +3567,7 @@ function on_review_edit_note_confirm(args: { prompt_type: string; input: string
2189
3567
  if (args.prompt_type !== "review-edit-note") return true;
2190
3568
  if (args.input && args.input.trim()) {
2191
3569
  state.note = args.input.trim();
3570
+ persistReview();
2192
3571
  updateMagitDisplay();
2193
3572
  editor.setStatus(state.note ? "Note saved" : "Note cleared");
2194
3573
  } else {
@@ -2285,73 +3664,143 @@ async function review_export_json() {
2285
3664
  }
2286
3665
  registerHandler("review_export_json", review_export_json);
2287
3666
 
2288
- async function start_review_diff() {
2289
- editor.setStatus(editor.t("status.generating"));
2290
- editor.setContext("review-mode", true);
2291
-
2292
- // Get viewport size
2293
- const viewport = editor.getViewport();
2294
- if (viewport) {
2295
- state.viewportWidth = viewport.width;
2296
- state.viewportHeight = viewport.height;
2297
- }
2298
-
2299
- // Fetch data using new git status approach
2300
- state.files = await getGitStatus();
2301
- state.hunks = await fetchDiffsForFiles(state.files);
2302
- state.comments = [];
2303
- state.note = '';
2304
- state.selectedIndex = 0;
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 {
2305
3673
  state.diffCursorRow = 1;
2306
3674
  state.hunkHeaderRows = [];
2307
3675
  state.diffLineByteOffsets = [];
2308
- state.focusPanel = 'files';
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
+ }
2309
3687
 
2310
- // Create buffer group with layout:
2311
- // vertical: [toolbar(fixed 1), horizontal: [files, diff]]
2312
- const layout = JSON.stringify({
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: {
2313
3694
  type: "split",
2314
- direction: "v",
2315
- ratio: 0.05,
2316
- first: { type: "fixed", id: "toolbar", height: 1 },
2317
- second: {
3695
+ direction: "h",
3696
+ ratio: 0.75,
3697
+ first: {
2318
3698
  type: "split",
2319
- direction: "h",
2320
- ratio: 0.3,
2321
- first: { type: "scrollable", id: "files" },
3699
+ direction: "v",
3700
+ ratio: 0.05,
3701
+ first: { type: "fixed", id: "sticky", height: 1 },
2322
3702
  second: { type: "scrollable", id: "diff" },
2323
3703
  },
2324
- });
3704
+ second: { type: "scrollable", id: "comments" },
3705
+ },
3706
+ });
2325
3707
 
2326
- const groupResult = await editor.createBufferGroup("*Review Diff*", "review-mode", layout);
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> {
3714
+ const viewport = editor.getViewport();
3715
+ if (viewport) {
3716
+ state.viewportWidth = viewport.width;
3717
+ state.viewportHeight = viewport.height;
3718
+ }
3719
+ editor.setContext("review-mode", true);
3720
+ const groupResult = await editor.createBufferGroup(groupName, "review-mode", REVIEW_LAYOUT);
2327
3721
  state.groupId = groupResult.groupId;
2328
3722
  state.panelBuffers = groupResult.panels;
2329
- state.reviewBufferId = groupResult.panels["files"];
2330
-
2331
- // Diff panel uses the editor's native cursor for scrolling. Buffer-group
2332
- // panels default to `show_cursors = false`, which also blocks all native
2333
- // movement actions in `action_to_events`, so flip the flag for the diff
2334
- // panel only. The files panel keeps its hidden cursor — selection there
2335
- // is plugin-managed (state.selectedIndex with a `>` prefix + bg highlight),
2336
- // and j/k/Up/Down are dispatched through the `review_nav_*` handlers.
3723
+ state.reviewBufferId = groupResult.panels["diff"];
3724
+
2337
3725
  if (state.panelBuffers["diff"] !== undefined) {
2338
3726
  (editor as any).setBufferShowCursors(state.panelBuffers["diff"], true);
2339
3727
  }
2340
3728
 
2341
- // Set initial content for all panels
2342
3729
  updateMagitDisplay();
2343
3730
 
2344
- // Ensure the files panel has focus (moves focus away from File Explorer
2345
- // if it was open, so review-mode keybindings work immediately)
2346
- editor.focusBufferGroupPanel(state.groupId, "files");
3731
+ editor.focusBufferGroupPanel(state.groupId, "diff");
2347
3732
 
2348
- // Register resize handler
2349
3733
  editor.on("resize", "onReviewDiffResize");
2350
-
2351
- editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
3734
+ updateReviewStatus();
2352
3735
  editor.on("buffer_activated", "on_review_buffer_activated");
2353
3736
  editor.on("buffer_closed", "on_review_buffer_closed");
2354
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*");
2355
3804
  }
2356
3805
  registerHandler("start_review_diff", start_review_diff);
2357
3806
 
@@ -2367,10 +3816,200 @@ function stop_review_diff() {
2367
3816
  editor.off("buffer_activated", "on_review_buffer_activated");
2368
3817
  editor.off("buffer_closed", "on_review_buffer_closed");
2369
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");
2370
3821
  editor.setStatus(editor.t("status.stopped"));
2371
3822
  }
2372
3823
  registerHandler("stop_review_diff", stop_review_diff);
2373
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
+
2374
4013
 
2375
4014
  /**
2376
4015
  * React to a buffer becoming active. Used here purely to track which review
@@ -2384,23 +4023,57 @@ registerHandler("stop_review_diff", stop_review_diff);
2384
4023
  */
2385
4024
  function on_review_buffer_activated(data: { buffer_id: number }): void {
2386
4025
  if (state.groupId === null) return;
2387
- const filesId = state.panelBuffers["files"];
2388
4026
  const diffId = state.panelBuffers["diff"];
2389
- let newPanel: 'files' | 'diff' | null = null;
2390
- if (data.buffer_id === filesId) newPanel = 'files';
2391
- else if (data.buffer_id === diffId) newPanel = 'diff';
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';
2392
4031
  if (newPanel === null || newPanel === state.focusPanel) return;
2393
4032
  state.focusPanel = newPanel;
2394
- editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
4033
+ // Re-render the comments panel so the selection highlight follows focus.
4034
+ editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
2395
4035
  }
2396
4036
  registerHandler("on_review_buffer_activated", on_review_buffer_activated);
2397
4037
 
2398
4038
  /**
2399
- * React to native cursor movement inside the diff panel — the only panel
2400
- * that has a visible native cursor. The handler keeps `state.diffCursorRow`
2401
- * in sync (used by `n`/`p` hunk navigation) and re-paints the cursor-line
2402
- * highlight overlay.
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.
2403
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
+
2404
4077
  function on_review_cursor_moved(data: {
2405
4078
  buffer_id: number;
2406
4079
  cursor_id: number;
@@ -2410,9 +4083,28 @@ function on_review_cursor_moved(data: {
2410
4083
  text_properties: Array<Record<string, unknown>>;
2411
4084
  }): void {
2412
4085
  if (state.groupId === null) return;
2413
- if (data.buffer_id !== state.panelBuffers["diff"]) return;
2414
- state.diffCursorRow = data.line;
2415
- applyCursorLineOverlay('diff');
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
+ }
2416
4108
  }
2417
4109
  registerHandler("on_review_cursor_moved", on_review_cursor_moved);
2418
4110
 
@@ -2663,8 +4355,314 @@ async function side_by_side_diff_current_file() {
2663
4355
  }
2664
4356
  registerHandler("side_by_side_diff_current_file", side_by_side_diff_current_file);
2665
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
+
2666
4661
  // Register Modes and Commands
2667
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");
2668
4666
  editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode");
2669
4667
  editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode");
2670
4668
  editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", null);
@@ -2709,23 +4707,42 @@ registerHandler("on_buffer_closed", on_buffer_closed);
2709
4707
  editor.on("buffer_closed", "on_buffer_closed");
2710
4708
 
2711
4709
  editor.defineMode("review-mode", [
2712
- // Cursor motion goes through plugin handlers that branch on focus —
2713
- // files panel updates `state.selectedIndex` (plugin-managed selection
2714
- // with the `>` prefix + bg highlight); diff panel delegates to native
2715
- // editor motion via executeAction so scrolling stays fast.
4710
+ // Native cursor motion in the unified diff stream.
2716
4711
  ["Up", "review_nav_up"], ["Down", "review_nav_down"],
2717
4712
  ["k", "review_nav_up"], ["j", "review_nav_down"],
2718
4713
  ["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
2719
- ["Home", "review_nav_home"], ["End", "review_nav_end"],
2720
- // Focus toggle between panels
2721
- ["Tab", "review_toggle_focus"],
2722
- // Hunk navigation (diff panel) — jumps the native cursor between hunks.
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.
2723
4720
  ["n", "review_next_hunk"], ["p", "review_prev_hunk"],
2724
- // Drill-down
2725
- ["Enter", "review_drill_down"],
2726
- // Git actions (context-sensitive: file-level or hunk-level based on focus)
2727
- ["s", "review_stage_file"], ["u", "review_unstage_file"],
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"],
2728
4743
  ["d", "review_discard_file"],
4744
+ ["S", "review_stage_file"], ["U", "review_unstage_file"],
4745
+ ["D", "review_discard_file_only"],
2729
4746
  ["r", "review_refresh"],
2730
4747
  // Comments
2731
4748
  ["c", "review_add_comment"],