@fresh-editor/fresh-editor 0.2.22 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,26 +3,15 @@
3
3
  /// <reference path="./lib/virtual-buffer-factory.ts" />
4
4
 
5
5
  // Review Diff Plugin
6
- // Provides a unified workflow for reviewing code changes (diffs, conflicts, AI outputs).
7
- //
8
- // TODO: This plugin has incomplete/broken functionality:
9
- // - Uses editor.prompt() which doesn't exist in the API (needs event-based prompt)
10
- // - Uses VirtualBufferOptions.read_only (should be readOnly)
11
- // - References stop_review_diff which is undefined
6
+ // Magit-style split-panel UI for reviewing and staging code changes.
7
+ // Left panel: file list (staged/unstaged/untracked). Right panel: diff.
8
+ // Actions: stage/unstage/discard hunks or files, line comments, export.
12
9
  const editor = getEditor();
13
10
 
14
11
  import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
15
12
  const VirtualBufferFactory = createVirtualBufferFactory(editor);
16
13
 
17
- /**
18
- * Hunk status for staging
19
- */
20
- type HunkStatus = 'pending' | 'staged' | 'discarded';
21
14
 
22
- /**
23
- * Review status for a hunk
24
- */
25
- type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
26
15
 
27
16
  /**
28
17
  * A review comment attached to a specific line in a file
@@ -57,8 +46,6 @@ interface Hunk {
57
46
  oldRange: { start: number; end: number }; // old file line range
58
47
  type: 'add' | 'remove' | 'modify';
59
48
  lines: string[];
60
- status: HunkStatus;
61
- reviewStatus: ReviewStatus;
62
49
  contextHeader: string;
63
50
  byteOffset: number; // Position in the virtual buffer
64
51
  gitStatus?: 'staged' | 'unstaged' | 'untracked';
@@ -76,38 +63,69 @@ interface FileEntry {
76
63
 
77
64
  /**
78
65
  * Review Session State
66
+ *
67
+ * Scrolling and cursor tracking inside the panel buffers is handled by the
68
+ * editor core natively — this state only mirrors what the plugin needs to
69
+ * know between events (selected file, focused panel, hunk header rows for
70
+ * `n`/`p` jumps).
79
71
  */
80
72
  interface ReviewState {
81
73
  hunks: Hunk[];
82
- hunkStatus: Record<string, HunkStatus>;
83
74
  comments: ReviewComment[];
84
- originalRequest?: string;
85
- overallFeedback?: string;
75
+ note: string;
86
76
  reviewBufferId: number | null;
87
77
  // New magit-style state
88
78
  files: FileEntry[];
89
79
  selectedIndex: number;
90
- fileScrollOffset: number;
91
- diffScrollOffset: number;
92
80
  viewportWidth: number;
93
81
  viewportHeight: number;
94
82
  focusPanel: 'files' | 'diff';
83
+ groupId: number | null;
84
+ panelBuffers: Record<string, number>;
85
+ // Caches populated each time the diff panel is rebuilt — used by `n`/`p`
86
+ // hunk navigation, to translate diff-panel row numbers into byte positions
87
+ // for `setBufferCursor`, and to draw the cursor-line highlight overlay.
88
+ // The array has length `(rowCount + 1)`: index `i` is the byte offset of
89
+ // row `i + 1`, and the final entry is the total buffer length (sentinel
90
+ // for the end of the last row).
91
+ hunkHeaderRows: number[]; // 1-indexed row numbers in the diff panel
92
+ diffLineByteOffsets: number[];
93
+ diffCursorRow: number; // 1-indexed, last known cursor row in diff panel
94
+ /** Cache of pre-built diff-panel entries keyed by `${file}\0${gitStatus}`,
95
+ * populated lazily by buildDiffPanelEntries. Cleared on refreshMagitData. */
96
+ diffCache: Record<string, CachedDiff>;
97
+ }
98
+
99
+ interface CachedDiff {
100
+ entries: TextPropertyEntry[];
101
+ hunkHeaderRows: number[];
102
+ diffLineByteOffsets: number[];
95
103
  }
96
104
 
97
105
  const state: ReviewState = {
98
106
  hunks: [],
99
- hunkStatus: {},
100
107
  comments: [],
108
+ note: '',
101
109
  reviewBufferId: null,
102
110
  files: [],
103
111
  selectedIndex: 0,
104
- fileScrollOffset: 0,
105
- diffScrollOffset: 0,
106
112
  viewportWidth: 80,
107
113
  viewportHeight: 24,
108
114
  focusPanel: 'files',
115
+ groupId: null,
116
+ panelBuffers: {},
117
+ hunkHeaderRows: [],
118
+ diffLineByteOffsets: [],
119
+ diffCursorRow: 1,
120
+ diffCache: {},
109
121
  };
110
122
 
123
+ // Theme colour for the synthetic "cursor line" highlight in the panel
124
+ // buffers. Reintroduced after the per-line bg overlay was deleted from the
125
+ // builders — `applyCursorLineOverlay` writes it on every cursor_moved event.
126
+ const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
127
+ const CURSOR_LINE_NS = "review-cursor-line";
128
+
111
129
  // --- Refresh State ---
112
130
 
113
131
  // --- Colors & Styles ---
@@ -119,14 +137,10 @@ const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
119
137
  const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
120
138
  const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
121
139
  const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
122
- const STYLE_STAGED: OverlayColorSpec = "editor.line_number_fg";
123
- const STYLE_DISCARDED: OverlayColorSpec = "diagnostic.error_fg";
140
+
124
141
  const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
125
142
  const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
126
- const STYLE_COMMENT_BORDER: OverlayColorSpec = "ui.split_separator_fg";
127
- const STYLE_APPROVED: OverlayColorSpec = "diagnostic.info_fg";
128
- const STYLE_REJECTED: OverlayColorSpec = "diagnostic.error_fg";
129
- const STYLE_QUESTION: OverlayColorSpec = "diagnostic.warning_fg";
143
+
130
144
 
131
145
  /**
132
146
  * Calculate UTF-8 byte length of a string manually since TextEncoder is not available
@@ -151,46 +165,45 @@ interface DiffPart {
151
165
  type: 'added' | 'removed' | 'unchanged';
152
166
  }
153
167
 
168
+ /**
169
+ * Inline word-level diff between two changed lines.
170
+ *
171
+ * Used to highlight the *changed region* inside a -/+ pair, called once per
172
+ * adjacent pair while building a file's diff. The previous implementation
173
+ * was a full O(n*m) LCS that allocated an (n+1)*(m+1) DP table per pair —
174
+ * fast enough for short lines, but for files with hundreds of long-line
175
+ * changes (e.g. `audit_mode.ts` itself) it added hundreds of milliseconds
176
+ * to every diff rebuild and made file-list navigation visibly laggy.
177
+ *
178
+ * This O(n+m) scan finds the longest common prefix and suffix and reports
179
+ * everything in between as the changed region. It misses internal matches
180
+ * (e.g. it can't tell that "abc-xy-def" → "abc-zw-def" only changed the
181
+ * middle "xy"), but for inline highlighting that's fine — the human eye is
182
+ * already drawn to the line as a whole, the highlight just answers "where
183
+ * inside the line did the change happen?". The cost difference is dramatic:
184
+ * for two 200-char lines, ~400 char compares vs. ~40 000.
185
+ */
154
186
  function diffStrings(oldStr: string, newStr: string): DiffPart[] {
155
187
  const n = oldStr.length;
156
188
  const m = newStr.length;
157
- const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
158
-
159
- for (let i = 1; i <= n; i++) {
160
- for (let j = 1; j <= m; j++) {
161
- if (oldStr[i - 1] === newStr[j - 1]) {
162
- dp[i][j] = dp[i - 1][j - 1] + 1;
163
- } else {
164
- dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
165
- }
166
- }
167
- }
168
-
169
- const result: DiffPart[] = [];
170
- let i = n, j = m;
171
- while (i > 0 || j > 0) {
172
- if (i > 0 && j > 0 && oldStr[i - 1] === newStr[j - 1]) {
173
- result.unshift({ text: oldStr[i - 1], type: 'unchanged' });
174
- i--; j--;
175
- } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
176
- result.unshift({ text: newStr[j - 1], type: 'added' });
177
- j--;
178
- } else {
179
- result.unshift({ text: oldStr[i - 1], type: 'removed' });
180
- i--;
181
- }
182
- }
183
-
184
- const coalesced: DiffPart[] = [];
185
- for (const part of result) {
186
- const last = coalesced[coalesced.length - 1];
187
- if (last && last.type === part.type) {
188
- last.text += part.text;
189
- } else {
190
- coalesced.push(part);
191
- }
192
- }
193
- return coalesced;
189
+ let pre = 0;
190
+ const minLen = Math.min(n, m);
191
+ while (pre < minLen && oldStr.charCodeAt(pre) === newStr.charCodeAt(pre)) pre++;
192
+ let suf = 0;
193
+ while (
194
+ suf < n - pre &&
195
+ suf < m - pre &&
196
+ oldStr.charCodeAt(n - 1 - suf) === newStr.charCodeAt(m - 1 - suf)
197
+ ) {
198
+ suf++;
199
+ }
200
+
201
+ const parts: DiffPart[] = [];
202
+ if (pre > 0) parts.push({ text: oldStr.slice(0, pre), type: 'unchanged' });
203
+ if (pre < n - suf) parts.push({ text: oldStr.slice(pre, n - suf), type: 'removed' });
204
+ if (pre < m - suf) parts.push({ text: newStr.slice(pre, m - suf), type: 'added' });
205
+ if (suf > 0) parts.push({ text: oldStr.slice(n - suf), type: 'unchanged' });
206
+ return parts;
194
207
  }
195
208
 
196
209
  function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
@@ -220,7 +233,6 @@ function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'unt
220
233
  type: 'modify',
221
234
  lines: [],
222
235
  status: 'pending',
223
- reviewStatus: 'pending',
224
236
  contextHeader: match[3]?.trim() || "",
225
237
  byteOffset: 0,
226
238
  gitStatus
@@ -374,7 +386,6 @@ async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
374
386
 
375
387
  // --- New magit-style rendering (Step 2 of rewrite) ---
376
388
 
377
- const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
378
389
  const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
379
390
  const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
380
391
  const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
@@ -389,16 +400,24 @@ interface ListLine {
389
400
 
390
401
  interface DiffLine {
391
402
  text: string;
392
- type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty';
403
+ type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment';
393
404
  style?: Partial<OverlayOptions>;
394
405
  inlineOverlays?: InlineOverlay[];
406
+ // Line metadata for comment attachment
407
+ hunkId?: string;
408
+ file?: string;
409
+ lineType?: 'add' | 'remove' | 'context';
410
+ oldLine?: number;
411
+ newLine?: number;
412
+ lineContent?: string;
413
+ commentId?: string;
395
414
  }
396
415
 
397
416
  /**
398
417
  * Build the file list lines for the left panel.
399
418
  * Returns section headers (not selectable) and file entries.
400
419
  */
401
- function buildFileListLines(): ListLine[] {
420
+ function buildFileListLines(leftWidth?: number): ListLine[] {
402
421
  const lines: ListLine[] = [];
403
422
  let lastCategory: string | undefined;
404
423
 
@@ -418,7 +437,7 @@ function buildFileListLines(): ListLine[] {
418
437
  });
419
438
  }
420
439
 
421
- // Status icon
440
+ // Status icon + selection prefix.
422
441
  const statusIcon = f.status === '?' ? 'A' : f.status;
423
442
  const prefix = i === state.selectedIndex ? '>' : ' ';
424
443
  const filename = f.origPath ? `${f.origPath} → ${f.path}` : f.path;
@@ -429,9 +448,64 @@ function buildFileListLines(): ListLine[] {
429
448
  });
430
449
  }
431
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;
469
+ }
470
+ }
471
+ if (line) {
472
+ lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
473
+ }
474
+ }
475
+
432
476
  return lines;
433
477
  }
434
478
 
479
+ /**
480
+ * Push inline comment lines for a given diff line into the lines array.
481
+ */
482
+ function pushLineComments(
483
+ lines: DiffLine[], hunk: Hunk,
484
+ lineType: 'add' | 'remove' | 'context',
485
+ oldLine: number | undefined, newLine: number | undefined
486
+ ) {
487
+ const lineComments = state.comments.filter(c =>
488
+ c.hunk_id === hunk.id && (
489
+ (c.line_type === 'add' && c.new_line === newLine) ||
490
+ (c.line_type === 'remove' && c.old_line === oldLine) ||
491
+ (c.line_type === 'context' && c.new_line === newLine)
492
+ )
493
+ );
494
+ for (const comment of lineComments) {
495
+ const lineRef = comment.line_type === 'add'
496
+ ? `+${comment.new_line}`
497
+ : comment.line_type === 'remove'
498
+ ? `-${comment.old_line}`
499
+ : `${comment.new_line}`;
500
+ lines.push({
501
+ text: ` \u00bb [${lineRef}] ${comment.text}`,
502
+ type: 'comment',
503
+ commentId: comment.id,
504
+ style: { fg: STYLE_COMMENT, italic: true },
505
+ });
506
+ }
507
+ }
508
+
435
509
  /**
436
510
  * Build the diff lines for the right panel based on currently selected file.
437
511
  */
@@ -463,39 +537,125 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
463
537
  }
464
538
 
465
539
  for (const hunk of fileHunks) {
466
- // Hunk header
540
+ // Hunk header with review status indicator
467
541
  const header = hunk.contextHeader
468
542
  ? `@@ ${hunk.contextHeader} @@`
469
543
  : `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
544
+
470
545
  lines.push({
471
546
  text: header,
472
547
  type: 'hunk-header',
548
+ hunkId: hunk.id,
549
+ file: hunk.file,
473
550
  style: { fg: STYLE_HUNK_HEADER, bold: true },
474
551
  });
475
552
 
476
- // Diff content lines only set background color so the normal editor
477
- // foreground stays readable across all themes. The bg uses theme-aware
478
- // diff colors that each theme can customize.
479
- for (const line of hunk.lines) {
553
+ // Render hunk-level comments (those with no line_type) right
554
+ // after the hunk header so they are visible in the diff view.
555
+ const hunkComments = state.comments.filter(c =>
556
+ c.hunk_id === hunk.id && !c.line_type
557
+ );
558
+ for (const comment of hunkComments) {
559
+ lines.push({
560
+ text: ` \u00bb [hunk] ${comment.text}`,
561
+ type: 'comment',
562
+ commentId: comment.id,
563
+ style: { fg: STYLE_COMMENT, italic: true },
564
+ });
565
+ }
566
+
567
+ // Track actual file line numbers as we iterate
568
+ let oldLineNum = hunk.oldRange.start;
569
+ let newLineNum = hunk.range.start;
570
+
571
+ // Diff content lines with word-level highlighting for adjacent -/+ pairs
572
+ for (let li = 0; li < hunk.lines.length; li++) {
573
+ const line = hunk.lines[li];
574
+ const nextLine = hunk.lines[li + 1];
480
575
  const prefix = line[0];
576
+ const lineType: 'add' | 'remove' | 'context' =
577
+ prefix === '+' ? 'add' : prefix === '-' ? 'remove' : 'context';
578
+ const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
579
+ const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
580
+
581
+ // Detect adjacent -/+ pair for word-level diff
582
+ if (prefix === '-' && nextLine && nextLine[0] === '+') {
583
+ const oldContent = line.substring(1);
584
+ const newContent = nextLine.substring(1);
585
+ const parts = diffStrings(oldContent, newContent);
586
+
587
+ // Build inline overlays for removed line
588
+ const removeOverlays: InlineOverlay[] = [];
589
+ let rOffset = getByteLength(line[0]); // skip prefix
590
+ for (const part of parts) {
591
+ const pLen = getByteLength(part.text);
592
+ if (part.type === 'removed') {
593
+ removeOverlays.push({ start: rOffset, end: rOffset + pLen, style: { fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true } });
594
+ }
595
+ if (part.type !== 'added') rOffset += pLen;
596
+ }
597
+ lines.push({
598
+ text: line, type: 'remove',
599
+ style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
600
+ hunkId: hunk.id, file: hunk.file,
601
+ lineType: 'remove', oldLine: curOldLine, newLine: undefined, lineContent: line,
602
+ inlineOverlays: removeOverlays.length > 0 ? removeOverlays : undefined,
603
+ });
604
+ // Inline comments for the removed line
605
+ pushLineComments(lines, hunk, 'remove', curOldLine, undefined);
606
+ oldLineNum++;
607
+
608
+ // Build inline overlays for added line
609
+ const addOverlays: InlineOverlay[] = [];
610
+ let aOffset = getByteLength(nextLine[0]);
611
+ for (const part of parts) {
612
+ const pLen = getByteLength(part.text);
613
+ if (part.type === 'added') {
614
+ addOverlays.push({ start: aOffset, end: aOffset + pLen, style: { fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true } });
615
+ }
616
+ if (part.type !== 'removed') aOffset += pLen;
617
+ }
618
+ lines.push({
619
+ text: nextLine, type: 'add',
620
+ style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
621
+ hunkId: hunk.id, file: hunk.file,
622
+ lineType: 'add', oldLine: undefined, newLine: newLineNum, lineContent: nextLine,
623
+ inlineOverlays: addOverlays.length > 0 ? addOverlays : undefined,
624
+ });
625
+ pushLineComments(lines, hunk, 'add', undefined, newLineNum);
626
+ newLineNum++;
627
+ li++; // skip the + line we already processed
628
+ continue;
629
+ }
630
+
481
631
  if (prefix === '+') {
482
632
  lines.push({
483
- text: line,
484
- type: 'add',
633
+ text: line, type: 'add',
485
634
  style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
635
+ hunkId: hunk.id, file: hunk.file,
636
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
486
637
  });
638
+ newLineNum++;
487
639
  } else if (prefix === '-') {
488
640
  lines.push({
489
- text: line,
490
- type: 'remove',
641
+ text: line, type: 'remove',
491
642
  style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
643
+ hunkId: hunk.id, file: hunk.file,
644
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
492
645
  });
646
+ oldLineNum++;
493
647
  } else {
494
648
  lines.push({
495
- text: line,
496
- type: 'context',
649
+ text: line, type: 'context',
650
+ hunkId: hunk.id, file: hunk.file,
651
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
497
652
  });
653
+ oldLineNum++;
654
+ newLineNum++;
498
655
  }
656
+
657
+ // Render inline comments attached to this line
658
+ pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
499
659
  }
500
660
  }
501
661
 
@@ -509,260 +669,463 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
509
669
  * Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
510
670
  * Rows 2..H-1: Main content (left file list, │ divider, right diff)
511
671
  */
512
- function buildMagitDisplayEntries(): TextPropertyEntry[] {
513
- const entries: TextPropertyEntry[] = [];
514
- const H = state.viewportHeight;
515
- const W = state.viewportWidth;
516
- const leftWidth = Math.max(28, Math.floor(W * 0.3));
517
- const rightWidth = W - leftWidth - 1; // 1 for divider
518
672
 
519
- const allFileLines = buildFileListLines();
520
- const diffLines = buildDiffLines(rightWidth);
673
+ // Theme colors for toolbar key hints
674
+ const STYLE_KEY_FG: OverlayColorSpec = "syntax.keyword";
675
+ const STYLE_KEY_BG: OverlayColorSpec = "editor.selection_bg";
676
+ const STYLE_HINT_FG: OverlayColorSpec = "editor.line_number_fg";
677
+ const STYLE_TOOLBAR_BG: OverlayColorSpec = "ui.status_bar_bg";
678
+ const STYLE_TOOLBAR_SEP: OverlayColorSpec = "ui.split_separator_fg";
521
679
 
522
- const mainRows = H - 2; // rows 2..H-1
680
+ interface HintItem {
681
+ key: string;
682
+ label: string;
683
+ }
523
684
 
524
- // --- File list scrolling ---
525
- let selectedLineIdx = -1;
526
- for (let i = 0; i < allFileLines.length; i++) {
527
- if (allFileLines[i].type === 'file' && allFileLines[i].fileIndex === state.selectedIndex) {
528
- selectedLineIdx = i;
529
- break;
530
- }
531
- }
532
- if (selectedLineIdx >= 0) {
533
- if (selectedLineIdx < state.fileScrollOffset) {
534
- state.fileScrollOffset = selectedLineIdx;
685
+ /**
686
+ * Build a styled toolbar entry with highlighted key hints.
687
+ * Keys get bold + keyword color; labels get dim text; groups separated by │.
688
+ */
689
+ function buildToolbar(W: number): TextPropertyEntry {
690
+ // Items within each group are ordered by importance so that when the
691
+ // viewport is narrow, the most useful hints get full labels while
692
+ // less discoverable ones are truncated to key-only or dropped.
693
+ const groups: HintItem[][] = state.focusPanel === 'files'
694
+ ? [
695
+ [{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
696
+ [{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
697
+ [{ key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "↵", label: "Open" }, { key: "Tab", label: "Switch" }, { key: "r", label: "Refresh" }],
698
+ ]
699
+ : [
700
+ [{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
701
+ [{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
702
+ [{ key: "n", label: "Next" }, { key: "p", label: "Prev" }, { key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "Tab", label: "Switch" }],
703
+ ];
704
+
705
+ // Build text and collect overlay ranges, gracefully dropping labels
706
+ // when the viewport is too narrow to fit everything.
707
+ const overlays: InlineOverlay[] = [];
708
+ let text = " ";
709
+ let bytePos = getByteLength(" ");
710
+ let done = false;
711
+
712
+ for (let g = 0; g < groups.length && !done; g++) {
713
+ if (g > 0) {
714
+ const sep = " │ ";
715
+ if (text.length + sep.length > W) { done = true; break; }
716
+ overlays.push({ start: bytePos, end: bytePos + getByteLength(sep), style: { fg: STYLE_TOOLBAR_SEP } });
717
+ text += sep;
718
+ bytePos += getByteLength(sep);
535
719
  }
536
- if (selectedLineIdx >= state.fileScrollOffset + mainRows) {
537
- state.fileScrollOffset = selectedLineIdx - mainRows + 1;
720
+ for (let h = 0; h < groups[g].length && !done; h++) {
721
+ const item = groups[g][h];
722
+ const gap = h > 0 ? " " : "";
723
+ const fullLen = gap.length + item.key.length + 1 + item.label.length;
724
+ const keyOnlyLen = gap.length + item.key.length;
725
+
726
+ if (text.length + fullLen <= W) {
727
+ // Full item: gap + key + " " + label
728
+ if (gap) { text += gap; bytePos += getByteLength(gap); }
729
+ const keyLen = getByteLength(item.key);
730
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
731
+ text += item.key;
732
+ bytePos += keyLen;
733
+ const labelText = " " + item.label;
734
+ const labelLen = getByteLength(labelText);
735
+ overlays.push({ start: bytePos, end: bytePos + labelLen, style: { fg: STYLE_HINT_FG } });
736
+ text += labelText;
737
+ bytePos += labelLen;
738
+ } else if (text.length + keyOnlyLen <= W) {
739
+ // Key only (no label) when space is tight
740
+ if (gap) { text += gap; bytePos += getByteLength(gap); }
741
+ const keyLen = getByteLength(item.key);
742
+ overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
743
+ text += item.key;
744
+ bytePos += keyLen;
745
+ } else {
746
+ done = true;
747
+ }
538
748
  }
539
749
  }
540
- const maxFileOffset = Math.max(0, allFileLines.length - mainRows);
541
- if (state.fileScrollOffset > maxFileOffset) state.fileScrollOffset = maxFileOffset;
542
- if (state.fileScrollOffset < 0) state.fileScrollOffset = 0;
543
750
 
544
- const visibleFileLines = allFileLines.slice(state.fileScrollOffset, state.fileScrollOffset + mainRows);
751
+ const padded = text.padEnd(W) + "\n";
752
+ return {
753
+ text: padded,
754
+ properties: { type: "toolbar" },
755
+ style: { bg: STYLE_TOOLBAR_BG, extendToLineEnd: true },
756
+ inlineOverlays: overlays,
757
+ };
758
+ }
759
+
760
+ // --- Buffer Group panel content builders ---
545
761
 
546
- // --- Diff scrolling ---
547
- const maxDiffOffset = Math.max(0, diffLines.length - mainRows);
548
- if (state.diffScrollOffset > maxDiffOffset) state.diffScrollOffset = maxDiffOffset;
549
- if (state.diffScrollOffset < 0) state.diffScrollOffset = 0;
762
+ function buildToolbarPanelEntries(): TextPropertyEntry[] {
763
+ // Reuse buildToolbar returns one entry with the full toolbar line
764
+ return [buildToolbar(state.viewportWidth)];
765
+ }
550
766
 
551
- const visibleDiffLines = diffLines.slice(state.diffScrollOffset, state.diffScrollOffset + mainRows);
767
+ function buildFilesPanelEntries(): TextPropertyEntry[] {
768
+ const entries: TextPropertyEntry[] = [];
769
+ const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
552
770
 
553
- // --- Row 0: Toolbar ---
554
- const toolbar = " [Tab] Switch Panel [s] Stage [u] Unstage [d] Discard [Enter] Drill-Down [r] Refresh";
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 };
555
776
  entries.push({
556
- text: toolbar.substring(0, W).padEnd(W) + "\n",
557
- style: { fg: STYLE_FOOTER, bg: "ui.status_bar_bg" as OverlayColorSpec, extendToLineEnd: true },
558
- properties: { type: "toolbar" },
777
+ text: " GIT STATUS\n",
778
+ style: headerStyle,
779
+ properties: { type: "header" },
559
780
  });
560
781
 
561
- // --- Row 1: Header ---
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;
800
+ }
801
+
802
+ /**
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.
808
+ */
809
+ function buildDiffPanelEntries(): TextPropertyEntry[] {
562
810
  const selectedFile = state.files[state.selectedIndex];
563
- const focusLeft = state.focusPanel === 'files';
564
- const leftHeader = " GIT STATUS";
811
+ const cacheKey = selectedFile
812
+ ? `${selectedFile.path}\0${selectedFile.category}`
813
+ : "\0";
814
+ const cached = state.diffCache[cacheKey];
815
+ if (cached) {
816
+ state.hunkHeaderRows = cached.hunkHeaderRows;
817
+ state.diffLineByteOffsets = cached.diffLineByteOffsets;
818
+ return cached.entries;
819
+ }
820
+
821
+ const entries: TextPropertyEntry[] = [];
822
+ const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
823
+ const rightWidth = state.viewportWidth - leftWidth - 1;
824
+
825
+ const hunkHeaderRows: number[] = [];
826
+ const diffLineByteOffsets: number[] = [];
827
+ let runningByte = 0;
828
+ let row = 0; // 0-indexed counter; row + 1 is the 1-indexed line number
829
+
830
+ const pushEntry = (entry: TextPropertyEntry) => {
831
+ diffLineByteOffsets.push(runningByte);
832
+ runningByte += getByteLength(entry.text);
833
+ entries.push(entry);
834
+ row++;
835
+ };
836
+
837
+ // Header row: "DIFF FOR <file>". Always rendered as focused (the panel
838
+ // is the only place this header appears) so the cached entries can be
839
+ // reused regardless of which panel currently has focus.
565
840
  const rightHeader = selectedFile
566
841
  ? ` DIFF FOR ${selectedFile.path}`
567
842
  : " DIFF";
568
- const leftHeaderPadded = leftHeader.padEnd(leftWidth).substring(0, leftWidth);
569
- const rightHeaderPadded = rightHeader.substring(0, rightWidth);
843
+ pushEntry({
844
+ text: rightHeader + "\n",
845
+ style: { fg: STYLE_HEADER, bold: true, underline: true },
846
+ properties: { type: "header" },
847
+ });
570
848
 
571
- const leftHeaderStyle: Partial<OverlayOptions> = focusLeft
572
- ? { fg: STYLE_HEADER, bold: true, underline: true }
573
- : { fg: STYLE_DIVIDER };
574
- const rightHeaderStyle: Partial<OverlayOptions> = focusLeft
575
- ? { fg: STYLE_DIVIDER }
576
- : { fg: STYLE_HEADER, bold: true, underline: true };
577
-
578
- entries.push({ text: leftHeaderPadded, style: leftHeaderStyle, properties: { type: "header" } });
579
- entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
580
- entries.push({ text: rightHeaderPadded, style: rightHeaderStyle, properties: { type: "header" } });
581
- entries.push({ text: "\n", properties: { type: "newline" } });
582
-
583
- // --- Rows 2..H-1: Main content ---
584
- for (let i = 0; i < mainRows; i++) {
585
- const fileItem = visibleFileLines[i];
586
- const diffItem = visibleDiffLines[i];
587
-
588
- // Left panel
589
- const leftText = fileItem ? (" " + fileItem.text) : "";
590
- const leftPadded = leftText.padEnd(leftWidth).substring(0, leftWidth);
591
- const isSelected = fileItem?.type === 'file' && fileItem.fileIndex === state.selectedIndex;
592
-
593
- const leftEntry: TextPropertyEntry = {
594
- text: leftPadded,
595
- properties: {
596
- type: fileItem?.type || "blank",
597
- fileIndex: fileItem?.fileIndex,
598
- },
599
- style: fileItem?.style,
600
- inlineOverlays: fileItem?.inlineOverlays,
601
- };
602
- if (isSelected) {
603
- leftEntry.style = { ...(leftEntry.style || {}), bg: STYLE_SELECTED_BG, bold: true };
849
+ const lines = buildDiffLines(rightWidth);
850
+ for (const line of lines) {
851
+ // Embed the full DiffLine metadata as text properties so action
852
+ // handlers (`s`, `u`, `d`, `c`, `x`) can read it back via
853
+ // getTextPropertiesAtCursor without re-walking state.hunks.
854
+ const props: Record<string, unknown> = { type: line.type };
855
+ if (line.hunkId !== undefined) props.hunkId = line.hunkId;
856
+ if (line.file !== undefined) props.file = line.file;
857
+ if (line.lineType !== undefined) props.lineType = line.lineType;
858
+ if (line.oldLine !== undefined) props.oldLine = line.oldLine;
859
+ if (line.newLine !== undefined) props.newLine = line.newLine;
860
+ if (line.lineContent !== undefined) props.lineContent = line.lineContent;
861
+ if (line.commentId !== undefined) props.commentId = line.commentId;
862
+
863
+ if (line.type === 'hunk-header') {
864
+ // 1-indexed row of this hunk header in the diff buffer.
865
+ hunkHeaderRows.push(row + 1);
604
866
  }
605
- entries.push(leftEntry);
606
-
607
- // Divider
608
- entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
609
-
610
- // Right panel — when diff panel is focused, highlight the top line as cursor
611
- const rightText = diffItem ? (" " + diffItem.text) : "";
612
- const rightTruncated = rightText.substring(0, rightWidth);
613
- const isDiffCursorLine = !focusLeft && i === 0 && diffItem != null;
614
- const rightStyle = isDiffCursorLine
615
- ? { ...(diffItem?.style || {}), bg: STYLE_SELECTED_BG, extendToLineEnd: true }
616
- : diffItem?.style;
617
- entries.push({
618
- text: rightTruncated,
619
- properties: { type: diffItem?.type || "blank" },
620
- style: rightStyle,
621
- inlineOverlays: diffItem?.inlineOverlays,
622
- });
623
867
 
624
- // Newline
625
- entries.push({ text: "\n", properties: { type: "newline" } });
868
+ pushEntry({
869
+ text: (line.text || "") + "\n",
870
+ style: line.style,
871
+ inlineOverlays: line.inlineOverlays,
872
+ properties: props,
873
+ });
626
874
  }
627
875
 
876
+ // Sentinel: total buffer length, used as the end of the last row.
877
+ diffLineByteOffsets.push(runningByte);
878
+
879
+ state.diffCache[cacheKey] = { entries, hunkHeaderRows, diffLineByteOffsets };
880
+ state.hunkHeaderRows = hunkHeaderRows;
881
+ state.diffLineByteOffsets = diffLineByteOffsets;
628
882
  return entries;
629
883
  }
630
884
 
631
885
  /**
632
- * Refresh the display — rebuild entries and set buffer content.
633
- * Always re-queries viewport dimensions to handle sidebar toggles and splits.
886
+ * Full refresh — rebuild all three panels. Called on data changes
887
+ * (refreshMagitData, comment add/edit, note edit, resize). NOT called on
888
+ * scroll: scrolling is handled natively by the editor in the panel buffers.
634
889
  */
635
890
  function updateMagitDisplay(): void {
636
- if (state.reviewBufferId === null) return;
637
891
  refreshViewportDimensions();
638
- const entries = buildMagitDisplayEntries();
639
- editor.clearNamespace(state.reviewBufferId, "review-diff");
640
- editor.setVirtualBufferContent(state.reviewBufferId, entries);
892
+ if (state.groupId === null) return;
893
+ editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
894
+ editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
895
+ editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
896
+ // setPanelContent wipes the buffer's overlays — re-paint the diff
897
+ // cursor-line highlight (the files panel doesn't have one; selection
898
+ // there is rendered as part of the entry style).
899
+ applyCursorLineOverlay('diff');
900
+ }
901
+
902
+ /**
903
+ * Rebuild only the diff panel. Called when the selected file changes.
904
+ */
905
+ function refreshDiffPanelOnly(): void {
906
+ if (state.groupId === null) return;
907
+ editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
908
+ applyCursorLineOverlay('diff');
909
+ }
910
+
911
+ /**
912
+ * Repaint the synthetic "cursor line" highlight in the diff panel.
913
+ *
914
+ * The diff panel buffer is created with show_cursors=true so the editor
915
+ * moves the cursor natively, but a single-line bg overlay on the cursor row
916
+ * gives a much more visible "you are here" indicator than the bare caret —
917
+ * which matches the magit-style aesthetic and is what the user expects.
918
+ */
919
+ function applyCursorLineOverlay(panel: 'diff'): void {
920
+ const bufId = state.panelBuffers[panel];
921
+ if (bufId === undefined) return;
922
+ editor.clearNamespace(bufId, CURSOR_LINE_NS);
923
+ const offsets = state.diffLineByteOffsets;
924
+ if (offsets.length < 2) return;
925
+ const idx = Math.max(0, Math.min(state.diffCursorRow - 1, offsets.length - 2));
926
+ const start = offsets[idx];
927
+ const end = offsets[idx + 1];
928
+ if (end <= start) return;
929
+ editor.addOverlay(bufId, CURSOR_LINE_NS, start, end, {
930
+ bg: STYLE_SELECTED_BG,
931
+ extendToLineEnd: true,
932
+ });
641
933
  }
642
934
 
643
935
  function review_refresh() { refreshMagitData(); }
644
936
  registerHandler("review_refresh", review_refresh);
645
937
 
646
- // --- New magit navigation handlers (Step 3) ---
938
+ // --- Focus and cursor-driven navigation ---
939
+ //
940
+ // Cursor keys (j/k/Up/Down/PageUp/PageDown/Home/End) are bound to plugin
941
+ // handlers that branch on which panel is focused:
942
+ //
943
+ // * Files panel: selection is plugin-managed (`state.selectedIndex` with
944
+ // a `>` prefix + bg highlight). The handler updates the index, repaints
945
+ // the files panel, and swaps the diff panel content from cache. The
946
+ // native cursor stays hidden in the files panel.
947
+ //
948
+ // * Diff panel: motion is delegated to the editor's built-in actions
949
+ // (`move_up`, `move_down`, etc.) via `executeAction`. The cursor moves
950
+ // natively, the editor handles viewport scrolling, and `cursor_moved`
951
+ // fires so the cursor-line overlay follows along.
952
+
953
+ function isFilesFocused(): boolean {
954
+ return state.focusPanel === 'files';
955
+ }
956
+
957
+ function refreshFilesPanelOnly(): void {
958
+ if (state.groupId === null) return;
959
+ editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
960
+ }
961
+
962
+ function selectFile(newIndex: number) {
963
+ if (newIndex < 0 || newIndex >= state.files.length) return;
964
+ if (newIndex === state.selectedIndex) return;
965
+ state.selectedIndex = newIndex;
966
+ state.diffCursorRow = 1; // diff panel cursor returns to the top of the new file
967
+ refreshFilesPanelOnly();
968
+ refreshDiffPanelOnly();
969
+ }
647
970
 
648
971
  function review_nav_up() {
649
- if (state.focusPanel === 'files') {
650
- if (state.files.length === 0) return;
651
- if (state.selectedIndex > 0) {
652
- state.selectedIndex--;
653
- state.diffScrollOffset = 0;
654
- updateMagitDisplay();
655
- }
972
+ if (isFilesFocused()) {
973
+ selectFile(state.selectedIndex - 1);
656
974
  } else {
657
- state.diffScrollOffset = Math.max(0, state.diffScrollOffset - 1);
658
- updateMagitDisplay();
975
+ editor.executeAction("move_up");
659
976
  }
660
977
  }
661
978
  registerHandler("review_nav_up", review_nav_up);
662
979
 
663
980
  function review_nav_down() {
664
- if (state.focusPanel === 'files') {
665
- if (state.files.length === 0) return;
666
- if (state.selectedIndex < state.files.length - 1) {
667
- state.selectedIndex++;
668
- state.diffScrollOffset = 0;
669
- updateMagitDisplay();
670
- }
981
+ if (isFilesFocused()) {
982
+ selectFile(state.selectedIndex + 1);
671
983
  } else {
672
- state.diffScrollOffset++;
673
- updateMagitDisplay();
984
+ editor.executeAction("move_down");
674
985
  }
675
986
  }
676
987
  registerHandler("review_nav_down", review_nav_down);
677
988
 
678
989
  function review_page_up() {
679
- const mainRows = state.viewportHeight - 2;
680
- if (state.focusPanel === 'files') {
681
- if (state.selectedIndex > 0) {
682
- state.selectedIndex = Math.max(0, state.selectedIndex - mainRows);
683
- state.diffScrollOffset = 0;
684
- updateMagitDisplay();
685
- }
990
+ if (isFilesFocused()) {
991
+ const step = Math.max(1, state.viewportHeight - 2);
992
+ selectFile(Math.max(0, state.selectedIndex - step));
686
993
  } else {
687
- state.diffScrollOffset = Math.max(0, state.diffScrollOffset - mainRows);
688
- updateMagitDisplay();
994
+ editor.executeAction("move_page_up");
689
995
  }
690
996
  }
691
997
  registerHandler("review_page_up", review_page_up);
692
998
 
693
999
  function review_page_down() {
694
- const mainRows = state.viewportHeight - 2;
695
- if (state.focusPanel === 'files') {
696
- if (state.selectedIndex < state.files.length - 1) {
697
- state.selectedIndex = Math.min(state.files.length - 1, state.selectedIndex + mainRows);
698
- state.diffScrollOffset = 0;
699
- updateMagitDisplay();
700
- }
1000
+ if (isFilesFocused()) {
1001
+ const step = Math.max(1, state.viewportHeight - 2);
1002
+ selectFile(Math.min(state.files.length - 1, state.selectedIndex + step));
701
1003
  } else {
702
- state.diffScrollOffset += mainRows;
703
- updateMagitDisplay();
1004
+ editor.executeAction("move_page_down");
704
1005
  }
705
1006
  }
706
1007
  registerHandler("review_page_down", review_page_down);
707
1008
 
708
- function review_toggle_focus() {
709
- state.focusPanel = state.focusPanel === 'files' ? 'diff' : 'files';
710
- updateMagitDisplay();
711
- }
712
- registerHandler("review_toggle_focus", review_toggle_focus);
713
-
714
- function review_focus_files() {
715
- if (state.focusPanel !== 'files') {
716
- state.focusPanel = 'files';
717
- updateMagitDisplay();
718
- }
719
- }
720
- registerHandler("review_focus_files", review_focus_files);
721
-
722
- function review_focus_diff() {
723
- if (state.focusPanel !== 'diff') {
724
- state.focusPanel = 'diff';
725
- updateMagitDisplay();
726
- }
727
- }
728
- registerHandler("review_focus_diff", review_focus_diff);
729
-
730
1009
  function review_nav_home() {
731
- if (state.focusPanel === 'files') {
732
- if (state.files.length === 0) return;
733
- state.selectedIndex = 0;
734
- state.diffScrollOffset = 0;
735
- updateMagitDisplay();
1010
+ if (isFilesFocused()) {
1011
+ selectFile(0);
736
1012
  } else {
737
- state.diffScrollOffset = 0;
738
- updateMagitDisplay();
1013
+ editor.executeAction("move_document_start");
739
1014
  }
740
1015
  }
741
1016
  registerHandler("review_nav_home", review_nav_home);
742
1017
 
743
1018
  function review_nav_end() {
744
- if (state.focusPanel === 'files') {
745
- if (state.files.length === 0) return;
746
- state.selectedIndex = state.files.length - 1;
747
- state.diffScrollOffset = 0;
748
- updateMagitDisplay();
1019
+ if (isFilesFocused()) {
1020
+ selectFile(state.files.length - 1);
749
1021
  } else {
750
- // Scroll diff to bottom
751
- const mainRows = state.viewportHeight - 2;
752
- const selectedFile = state.files[state.selectedIndex];
753
- if (selectedFile) {
754
- const diffLines = buildDiffLines(state.viewportWidth - Math.max(28, Math.floor(state.viewportWidth * 0.3)) - 1);
755
- state.diffScrollOffset = Math.max(0, diffLines.length - mainRows);
756
- }
757
- updateMagitDisplay();
1022
+ editor.executeAction("move_document_end");
758
1023
  }
759
1024
  }
760
1025
  registerHandler("review_nav_end", review_nav_end);
761
1026
 
1027
+ function review_toggle_focus() {
1028
+ if (state.groupId === null) return;
1029
+ const newPanel: 'files' | 'diff' = state.focusPanel === 'files' ? 'diff' : 'files';
1030
+ state.focusPanel = newPanel;
1031
+ editor.focusBufferGroupPanel(state.groupId, newPanel);
1032
+ // Refresh the toolbar so its hint set matches the new focus.
1033
+ editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
1034
+ }
1035
+ registerHandler("review_toggle_focus", review_toggle_focus);
1036
+
762
1037
  // --- Real git stage/unstage/discard actions (Step 4) ---
763
1038
 
1039
+ /**
1040
+ * Build a minimal unified diff patch for a single hunk.
1041
+ */
1042
+ function buildHunkPatch(filePath: string, hunk: Hunk): string {
1043
+ const oldCount = hunk.lines.filter(l => l[0] === '-' || l[0] === ' ').length;
1044
+ const newCount = hunk.lines.filter(l => l[0] === '+' || l[0] === ' ').length;
1045
+ const header = `@@ -${hunk.oldRange.start},${oldCount} +${hunk.range.start},${newCount} @@`;
1046
+ return [
1047
+ `diff --git a/${filePath} b/${filePath}`,
1048
+ `--- a/${filePath}`,
1049
+ `+++ b/${filePath}`,
1050
+ header,
1051
+ ...hunk.lines,
1052
+ ''
1053
+ ].join('\n');
1054
+ }
1055
+
1056
+ /**
1057
+ * Write a patch to a temp file and apply it with the given flags.
1058
+ * Returns true on success.
1059
+ */
1060
+ async function applyHunkPatch(patch: string, flags: string[]): Promise<boolean> {
1061
+ const tmpDir = editor.getTempDir();
1062
+ const patchPath = editor.pathJoin(tmpDir, `fresh-review-${Date.now()}.patch`);
1063
+ editor.writeFile(patchPath, patch);
1064
+ // Validate first
1065
+ const check = await editor.spawnProcess("git", ["apply", "--check", ...flags, patchPath]);
1066
+ if (check.exit_code !== 0) {
1067
+ editor.setStatus("Patch failed: " + (check.stderr || "").trim());
1068
+ return false;
1069
+ }
1070
+ const result = await editor.spawnProcess("git", ["apply", ...flags, patchPath]);
1071
+ return result.exit_code === 0;
1072
+ }
1073
+
1074
+ /**
1075
+ * Merge all text-property records at the cursor of the given panel buffer
1076
+ * into a single object. There's typically only one record covering each
1077
+ * cursor position; merging keeps callers simple.
1078
+ */
1079
+ function readPropsAtCursor(panel: 'files' | 'diff'): Record<string, unknown> | null {
1080
+ const bufId = state.panelBuffers[panel];
1081
+ if (bufId === undefined) return null;
1082
+ const records = editor.getTextPropertiesAtCursor(bufId);
1083
+ if (!records || records.length === 0) return null;
1084
+ const merged: Record<string, unknown> = {};
1085
+ for (const r of records) Object.assign(merged, r);
1086
+ return merged;
1087
+ }
1088
+
1089
+ /**
1090
+ * Get the hunk under the cursor in the diff panel, or null.
1091
+ *
1092
+ * Reads the `hunkId` text property embedded by `buildDiffPanelEntries`. Falls
1093
+ * back to the first hunk of the selected file when the cursor is somewhere
1094
+ * without a hunkId (e.g. the panel header) so commands like `s` still do
1095
+ * something useful.
1096
+ */
1097
+ function getHunkAtDiffCursor(): Hunk | null {
1098
+ const props = readPropsAtCursor('diff');
1099
+ const hunkId = props ? props["hunkId"] : undefined;
1100
+ if (typeof hunkId === 'string') {
1101
+ const found = state.hunks.find(h => h.id === hunkId);
1102
+ if (found) return found;
1103
+ }
1104
+ // Fallback: first hunk for the currently-selected file.
1105
+ const selectedFile = state.files[state.selectedIndex];
1106
+ if (!selectedFile) return null;
1107
+ return state.hunks.find(
1108
+ h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1109
+ ) || null;
1110
+ }
1111
+
764
1112
  async function review_stage_file() {
765
1113
  if (state.files.length === 0) return;
1114
+ if (state.focusPanel === 'diff') {
1115
+ // Hunk-level staging
1116
+ const hunk = getHunkAtDiffCursor();
1117
+ if (!hunk || !hunk.file) return;
1118
+ if (hunk.gitStatus === 'untracked') {
1119
+ await editor.spawnProcess("git", ["add", "--", hunk.file]);
1120
+ } else {
1121
+ const patch = buildHunkPatch(hunk.file, hunk);
1122
+ const ok = await applyHunkPatch(patch, ["--cached"]);
1123
+ if (!ok) return;
1124
+ }
1125
+ editor.setStatus(editor.t("status.hunk_staged") || "Hunk staged");
1126
+ await refreshMagitData();
1127
+ return;
1128
+ }
766
1129
  const f = state.files[state.selectedIndex];
767
1130
  if (!f) return;
768
1131
  await editor.spawnProcess("git", ["add", "--", f.path]);
@@ -772,6 +1135,20 @@ registerHandler("review_stage_file", review_stage_file);
772
1135
 
773
1136
  async function review_unstage_file() {
774
1137
  if (state.files.length === 0) return;
1138
+ if (state.focusPanel === 'diff') {
1139
+ // Hunk-level unstaging
1140
+ const hunk = getHunkAtDiffCursor();
1141
+ if (!hunk || !hunk.file || hunk.gitStatus !== 'staged') {
1142
+ editor.setStatus("Can only unstage staged hunks");
1143
+ return;
1144
+ }
1145
+ const patch = buildHunkPatch(hunk.file, hunk);
1146
+ const ok = await applyHunkPatch(patch, ["--cached", "--reverse"]);
1147
+ if (!ok) return;
1148
+ editor.setStatus(editor.t("status.hunk_unstaged") || "Hunk unstaged");
1149
+ await refreshMagitData();
1150
+ return;
1151
+ }
775
1152
  const f = state.files[state.selectedIndex];
776
1153
  if (!f) return;
777
1154
  await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
@@ -781,6 +1158,22 @@ registerHandler("review_unstage_file", review_unstage_file);
781
1158
 
782
1159
  function review_discard_file() {
783
1160
  if (state.files.length === 0) return;
1161
+ if (state.focusPanel === 'diff') {
1162
+ // Hunk-level discard — show confirmation
1163
+ const hunk = getHunkAtDiffCursor();
1164
+ if (!hunk || !hunk.file) return;
1165
+ editor.startPrompt(
1166
+ editor.t("prompt.discard_hunk", { file: hunk.file }) ||
1167
+ `Discard this hunk in "${hunk.file}"? This cannot be undone.`,
1168
+ "review-discard-hunk-confirm"
1169
+ );
1170
+ const suggestions: PromptSuggestion[] = [
1171
+ { text: "Discard hunk", description: "Permanently lose this change", value: "discard" },
1172
+ { text: "Cancel", description: "Keep the hunk as-is", value: "cancel" },
1173
+ ];
1174
+ editor.setPromptSuggestions(suggestions);
1175
+ return;
1176
+ }
784
1177
  const f = state.files[state.selectedIndex];
785
1178
  if (!f) return;
786
1179
 
@@ -795,6 +1188,26 @@ function review_discard_file() {
795
1188
  }
796
1189
  registerHandler("review_discard_file", review_discard_file);
797
1190
 
1191
+ async function on_review_discard_hunk_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
1192
+ if (args.prompt_type !== "review-discard-hunk-confirm") return true;
1193
+ const response = args.input.trim().toLowerCase();
1194
+ if (response === "discard" || args.selected_index === 0) {
1195
+ const hunk = getHunkAtDiffCursor();
1196
+ if (hunk && hunk.file) {
1197
+ const patch = buildHunkPatch(hunk.file, hunk);
1198
+ const ok = await applyHunkPatch(patch, ["--reverse"]);
1199
+ if (ok) {
1200
+ editor.setStatus(editor.t("status.hunk_discarded") || "Hunk discarded");
1201
+ await refreshMagitData();
1202
+ }
1203
+ }
1204
+ } else {
1205
+ editor.setStatus("Discard cancelled");
1206
+ }
1207
+ return false;
1208
+ }
1209
+ registerHandler("on_review_discard_hunk_confirm", on_review_discard_hunk_confirm);
1210
+
798
1211
  async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
799
1212
  if (args.prompt_type !== "review-discard-confirm") return true;
800
1213
 
@@ -828,7 +1241,8 @@ async function refreshMagitData() {
828
1241
  if (state.selectedIndex >= state.files.length) {
829
1242
  state.selectedIndex = Math.max(0, state.files.length - 1);
830
1243
  }
831
- state.diffScrollOffset = 0;
1244
+ state.diffCursorRow = 1;
1245
+ state.diffCache = {}; // git state may have changed — invalidate cached diffs
832
1246
  updateMagitDisplay();
833
1247
  }
834
1248
 
@@ -853,6 +1267,8 @@ function refreshViewportDimensions(): boolean {
853
1267
  function onReviewDiffResize(_data: { width: number; height: number }): void {
854
1268
  if (state.reviewBufferId === null) return;
855
1269
  refreshViewportDimensions();
1270
+ // Invalidate cached diff entries — they were built for the old viewport width
1271
+ state.diffCache = {};
856
1272
  updateMagitDisplay();
857
1273
  }
858
1274
  registerHandler("onReviewDiffResize", onReviewDiffResize);
@@ -1278,10 +1694,17 @@ async function review_drill_down() {
1278
1694
  }
1279
1695
 
1280
1696
  // Read new file content (use absolute path for readFile)
1281
- const newContent = await editor.readFile(absoluteFilePath);
1282
- if (newContent === null) {
1283
- editor.setStatus(editor.t("status.failed_new_version"));
1284
- return;
1697
+ // For deleted files the path no longer exists — use empty content
1698
+ let newContent: string;
1699
+ if (selectedFile.status === 'D') {
1700
+ newContent = "";
1701
+ } else {
1702
+ const readResult = await editor.readFile(absoluteFilePath);
1703
+ if (readResult === null) {
1704
+ editor.setStatus(editor.t("status.failed_new_version"));
1705
+ return;
1706
+ }
1707
+ newContent = readResult;
1285
1708
  }
1286
1709
 
1287
1710
  // Close any existing side-by-side views (old split-based approach)
@@ -1415,15 +1838,68 @@ registerHandler("review_drill_down", review_drill_down);
1415
1838
 
1416
1839
  // --- Hunk navigation for side-by-side diff view ---
1417
1840
 
1841
+ /**
1842
+ * Move the diff panel's native cursor to the given 1-indexed row, scrolling
1843
+ * the viewport so the row is visible.
1844
+ */
1845
+ function jumpDiffCursorToRow(row: number): void {
1846
+ const diffId = state.panelBuffers["diff"];
1847
+ if (diffId === undefined) return;
1848
+ const idx = row - 1;
1849
+ if (idx < 0 || idx >= state.diffLineByteOffsets.length) return;
1850
+
1851
+ if (state.focusPanel === 'diff') {
1852
+ // When the diff panel is focused, use executeAction so that the
1853
+ // normal cursor event flow fires and the status bar line number
1854
+ // updates correctly. This is O(delta) but necessary because
1855
+ // setBufferCursor doesn't trigger line-index refresh in the
1856
+ // virtual buffer's piece tree.
1857
+ const delta = row - state.diffCursorRow;
1858
+ const action = delta > 0 ? "move_down" : "move_up";
1859
+ for (let i = 0, n = Math.abs(delta); i < n; i++) editor.executeAction(action);
1860
+ } else {
1861
+ // When unfocused, setBufferCursor is safe since the cursor
1862
+ // position isn't displayed in the status bar.
1863
+ const byteOffset = state.diffLineByteOffsets[idx];
1864
+ editor.setBufferCursor(diffId, byteOffset);
1865
+ editor.scrollBufferToLine(diffId, idx);
1866
+ }
1867
+ state.diffCursorRow = row;
1868
+ applyCursorLineOverlay('diff');
1869
+ }
1870
+
1418
1871
  function review_next_hunk() {
1419
- if (!activeCompositeDiffState) return;
1420
- editor.compositeNextHunk(activeCompositeDiffState.compositeBufferId);
1872
+ // Magit review-mode diff panel: jump to the next hunk header row.
1873
+ if (state.groupId !== null && state.focusPanel === 'diff') {
1874
+ for (const row of state.hunkHeaderRows) {
1875
+ if (row > state.diffCursorRow) {
1876
+ jumpDiffCursorToRow(row);
1877
+ return;
1878
+ }
1879
+ }
1880
+ return;
1881
+ }
1882
+ // Composite diff-view hunk navigation is handled by the Action system
1883
+ // (CompositeNextHunk) via CompositeBuffer context keybindings, so no
1884
+ // plugin fallback is needed here.
1421
1885
  }
1422
1886
  registerHandler("review_next_hunk", review_next_hunk);
1423
1887
 
1424
1888
  function review_prev_hunk() {
1425
- if (!activeCompositeDiffState) return;
1426
- editor.compositePrevHunk(activeCompositeDiffState.compositeBufferId);
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.
1427
1903
  }
1428
1904
  registerHandler("review_prev_hunk", review_prev_hunk);
1429
1905
 
@@ -1442,8 +1918,12 @@ editor.defineMode("diff-view", [
1442
1918
  // --- Review Comment Actions ---
1443
1919
 
1444
1920
  function getCurrentHunkId(): string | null {
1445
- // In magit mode, get the first hunk of the selected file
1446
1921
  if (state.files.length === 0) return null;
1922
+ if (state.focusPanel === 'diff') {
1923
+ const hunk = getHunkAtDiffCursor();
1924
+ return hunk?.id || null;
1925
+ }
1926
+ // File panel: return first hunk for selected file
1447
1927
  const selectedFile = state.files[state.selectedIndex];
1448
1928
  if (!selectedFile) return null;
1449
1929
  const hunk = state.hunks.find(
@@ -1452,6 +1932,8 @@ function getCurrentHunkId(): string | null {
1452
1932
  return hunk?.id || null;
1453
1933
  }
1454
1934
 
1935
+
1936
+
1455
1937
  interface PendingCommentInfo {
1456
1938
  hunkId: string;
1457
1939
  file: string;
@@ -1462,26 +1944,62 @@ interface PendingCommentInfo {
1462
1944
  }
1463
1945
 
1464
1946
  function getCurrentLineInfo(): PendingCommentInfo | null {
1465
- // In magit mode, get info from the selected file's first hunk
1466
1947
  if (state.files.length === 0) return null;
1467
1948
  const selectedFile = state.files[state.selectedIndex];
1468
1949
  if (!selectedFile) return null;
1469
- const hunk = state.hunks.find(
1470
- h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
1471
- );
1472
- if (!hunk) return null;
1473
- return {
1474
- hunkId: hunk.id,
1475
- file: hunk.file,
1476
- lineType: undefined,
1477
- oldLine: undefined,
1478
- newLine: undefined,
1479
- lineContent: undefined
1480
- };
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 };
1481
1968
  }
1482
1969
 
1483
1970
  // Pending prompt state for event-based prompt handling
1484
1971
  let pendingCommentInfo: PendingCommentInfo | null = null;
1972
+ let editingCommentId: string | null = null; // non-null when editing an existing comment
1973
+
1974
+ /**
1975
+ * Find an existing comment at the current diff cursor position, either on the
1976
+ * comment display line itself or on the diff line it's attached to.
1977
+ */
1978
+ function findCommentAtCursor(): ReviewComment | null {
1979
+ const props = readPropsAtCursor('diff');
1980
+ if (!props) return null;
1981
+
1982
+ // Cursor sits directly on a comment display line.
1983
+ const commentId = props["commentId"];
1984
+ if (typeof commentId === 'string') {
1985
+ return state.comments.find(c => c.id === commentId) || null;
1986
+ }
1987
+
1988
+ // Cursor sits on a diff line — match by hunk + line type + line number.
1989
+ const hunkId = props["hunkId"];
1990
+ const lineType = props["lineType"];
1991
+ if (typeof hunkId !== 'string') return null;
1992
+ if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
1993
+ const oldLine = typeof props["oldLine"] === 'number' ? props["oldLine"] as number : undefined;
1994
+ const newLine = typeof props["newLine"] === 'number' ? props["newLine"] as number : undefined;
1995
+ return state.comments.find(c =>
1996
+ c.hunk_id === hunkId && (
1997
+ (c.line_type === 'add' && c.new_line === newLine) ||
1998
+ (c.line_type === 'remove' && c.old_line === oldLine) ||
1999
+ (c.line_type === 'context' && c.new_line === newLine)
2000
+ )
2001
+ ) || null;
2002
+ }
1485
2003
 
1486
2004
  async function review_add_comment() {
1487
2005
  const info = getCurrentLineInfo();
@@ -1489,9 +2007,13 @@ async function review_add_comment() {
1489
2007
  editor.setStatus(editor.t("status.no_hunk_selected"));
1490
2008
  return;
1491
2009
  }
2010
+
2011
+ // Check for existing comment to edit
2012
+ const existing = findCommentAtCursor();
2013
+
1492
2014
  pendingCommentInfo = info;
2015
+ editingCommentId = existing?.id || null;
1493
2016
 
1494
- // Show line context in prompt (if on a specific line)
1495
2017
  let lineRef = 'hunk';
1496
2018
  if (info.lineType === 'add' && info.newLine) {
1497
2019
  lineRef = `+${info.newLine}`;
@@ -1502,15 +2024,107 @@ async function review_add_comment() {
1502
2024
  } else if (info.oldLine) {
1503
2025
  lineRef = `L${info.oldLine}`;
1504
2026
  }
1505
- editor.startPrompt(editor.t("prompt.comment", { line: lineRef }), "review-comment");
2027
+
2028
+ const label = existing
2029
+ ? (editor.t("prompt.edit_comment", { line: lineRef }) || `Edit comment on ${lineRef}: `)
2030
+ : editor.t("prompt.comment", { line: lineRef });
2031
+
2032
+ if (existing) {
2033
+ editor.startPromptWithInitial(label, "review-comment", existing.text);
2034
+ } else {
2035
+ editor.startPrompt(label, "review-comment");
2036
+ }
1506
2037
  }
1507
2038
  registerHandler("review_add_comment", review_add_comment);
1508
2039
 
2040
+ let pendingDeleteCommentId: string | null = null;
2041
+
2042
+ async function review_delete_comment() {
2043
+ let target: ReviewComment | null = null;
2044
+
2045
+ if (state.focusPanel === 'diff') {
2046
+ target = findCommentAtCursor();
2047
+ } else {
2048
+ // File panel: target the last note
2049
+ const notes = state.comments.filter(c => c.hunk_id === '__overall__');
2050
+ if (notes.length > 0) target = notes[notes.length - 1];
2051
+ }
2052
+
2053
+ // File panel: delete note
2054
+ if (!target && state.focusPanel === 'files' && state.note) {
2055
+ pendingDeleteCommentId = '__note__';
2056
+ const preview = state.note.length > 40 ? state.note.substring(0, 37) + '...' : state.note;
2057
+ editor.startPrompt(`Delete note "${preview}"?`, "review-delete-comment-confirm");
2058
+ const suggestions: PromptSuggestion[] = [
2059
+ { text: "Delete", description: "Remove this note", value: "delete" },
2060
+ { text: "Cancel", description: "Keep the note", value: "cancel" },
2061
+ ];
2062
+ editor.setPromptSuggestions(suggestions);
2063
+ return;
2064
+ }
2065
+
2066
+ if (!target) {
2067
+ editor.setStatus("No comment to delete");
2068
+ return;
2069
+ }
2070
+
2071
+ pendingDeleteCommentId = target.id;
2072
+ const preview = target.text.length > 40 ? target.text.substring(0, 37) + '...' : target.text;
2073
+ editor.startPrompt(`Delete "${preview}"?`, "review-delete-comment-confirm");
2074
+ const suggestions: PromptSuggestion[] = [
2075
+ { text: "Delete", description: "Remove this comment", value: "delete" },
2076
+ { text: "Cancel", description: "Keep the comment", value: "cancel" },
2077
+ ];
2078
+ editor.setPromptSuggestions(suggestions);
2079
+ }
2080
+ registerHandler("review_delete_comment", review_delete_comment);
2081
+
2082
+ function on_review_delete_comment_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): boolean {
2083
+ if (args.prompt_type !== "review-delete-comment-confirm") return true;
2084
+ const response = args.input.trim().toLowerCase();
2085
+ if ((response === "delete" || args.selected_index === 0) && pendingDeleteCommentId) {
2086
+ if (pendingDeleteCommentId === '__note__') {
2087
+ state.note = '';
2088
+ } else {
2089
+ state.comments = state.comments.filter(c => c.id !== pendingDeleteCommentId);
2090
+ }
2091
+ state.diffCache = {}; // comment changed
2092
+ updateMagitDisplay();
2093
+ editor.setStatus("Deleted");
2094
+ } else {
2095
+ editor.setStatus("Delete cancelled");
2096
+ }
2097
+ pendingDeleteCommentId = null;
2098
+ return false;
2099
+ }
2100
+ registerHandler("on_review_delete_comment_confirm", on_review_delete_comment_confirm);
2101
+
1509
2102
  // Prompt event handlers
1510
2103
  function on_review_prompt_confirm(args: { prompt_type: string; input: string }): boolean {
1511
2104
  if (args.prompt_type !== "review-comment") {
1512
- return true; // Not our prompt
2105
+ return true;
2106
+ }
2107
+
2108
+ if (editingCommentId) {
2109
+ // Edit mode: update existing comment (empty text keeps the comment unchanged)
2110
+ if (args.input && args.input.trim()) {
2111
+ const existing = state.comments.find(c => c.id === editingCommentId);
2112
+ if (existing) {
2113
+ existing.text = args.input.trim();
2114
+ existing.timestamp = new Date().toISOString();
2115
+ state.diffCache = {}; // comment changed
2116
+ updateMagitDisplay();
2117
+ editor.setStatus("Comment updated");
2118
+ }
2119
+ } else {
2120
+ editor.setStatus("Comment unchanged (use x to delete)");
2121
+ }
2122
+ editingCommentId = null;
2123
+ pendingCommentInfo = null;
2124
+ return true;
1513
2125
  }
2126
+
2127
+ // New comment mode
1514
2128
  if (pendingCommentInfo && args.input && args.input.trim()) {
1515
2129
  const comment: ReviewComment = {
1516
2130
  id: `comment-${Date.now()}`,
@@ -1524,6 +2138,7 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
1524
2138
  line_type: pendingCommentInfo.lineType
1525
2139
  };
1526
2140
  state.comments.push(comment);
2141
+ state.diffCache = {}; // comment changed — invalidate cached diff entries
1527
2142
  updateMagitDisplay();
1528
2143
  let lineRef = 'hunk';
1529
2144
  if (comment.line_type === 'add' && comment.new_line) {
@@ -1545,6 +2160,7 @@ registerHandler("on_review_prompt_confirm", on_review_prompt_confirm);
1545
2160
  function on_review_prompt_cancel(args: { prompt_type: string }): boolean {
1546
2161
  if (args.prompt_type === "review-comment") {
1547
2162
  pendingCommentInfo = null;
2163
+ editingCommentId = null;
1548
2164
  editor.setStatus(editor.t("status.comment_cancelled"));
1549
2165
  }
1550
2166
  return true;
@@ -1554,145 +2170,91 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
1554
2170
  // Register prompt event handlers
1555
2171
  editor.on("prompt_confirmed", "on_review_prompt_confirm");
1556
2172
  editor.on("prompt_confirmed", "on_review_discard_confirm");
2173
+ editor.on("prompt_confirmed", "on_review_discard_hunk_confirm");
2174
+ editor.on("prompt_confirmed", "on_review_edit_note_confirm");
2175
+ editor.on("prompt_confirmed", "on_review_delete_comment_confirm");
1557
2176
  editor.on("prompt_cancelled", "on_review_prompt_cancel");
1558
2177
 
1559
- async function review_approve_hunk() {
1560
- const hunkId = getCurrentHunkId();
1561
- if (!hunkId) return;
1562
- const h = state.hunks.find(x => x.id === hunkId);
1563
- if (h) {
1564
- h.reviewStatus = 'approved';
1565
- updateMagitDisplay();
1566
- editor.setStatus(editor.t("status.hunk_approved"));
1567
- }
1568
- }
1569
- registerHandler("review_approve_hunk", review_approve_hunk);
1570
-
1571
- async function review_reject_hunk() {
1572
- const hunkId = getCurrentHunkId();
1573
- if (!hunkId) return;
1574
- const h = state.hunks.find(x => x.id === hunkId);
1575
- if (h) {
1576
- h.reviewStatus = 'rejected';
1577
- updateMagitDisplay();
1578
- editor.setStatus(editor.t("status.hunk_rejected"));
1579
- }
1580
- }
1581
- registerHandler("review_reject_hunk", review_reject_hunk);
1582
-
1583
- async function review_needs_changes() {
1584
- const hunkId = getCurrentHunkId();
1585
- if (!hunkId) return;
1586
- const h = state.hunks.find(x => x.id === hunkId);
1587
- if (h) {
1588
- h.reviewStatus = 'needs_changes';
1589
- updateMagitDisplay();
1590
- editor.setStatus(editor.t("status.hunk_needs_changes"));
1591
- }
1592
- }
1593
- registerHandler("review_needs_changes", review_needs_changes);
1594
-
1595
- async function review_question_hunk() {
1596
- const hunkId = getCurrentHunkId();
1597
- if (!hunkId) return;
1598
- const h = state.hunks.find(x => x.id === hunkId);
1599
- if (h) {
1600
- h.reviewStatus = 'question';
1601
- updateMagitDisplay();
1602
- editor.setStatus(editor.t("status.hunk_question"));
2178
+ async function review_edit_note() {
2179
+ const label = editor.t("prompt.overall_comment") || "Note: ";
2180
+ if (state.note) {
2181
+ editor.startPromptWithInitial(label, "review-edit-note", state.note);
2182
+ } else {
2183
+ editor.startPrompt(label, "review-edit-note");
1603
2184
  }
1604
2185
  }
1605
- registerHandler("review_question_hunk", review_question_hunk);
2186
+ registerHandler("review_edit_note", review_edit_note);
1606
2187
 
1607
- async function review_clear_status() {
1608
- const hunkId = getCurrentHunkId();
1609
- if (!hunkId) return;
1610
- const h = state.hunks.find(x => x.id === hunkId);
1611
- if (h) {
1612
- h.reviewStatus = 'pending';
2188
+ function on_review_edit_note_confirm(args: { prompt_type: string; input: string }): boolean {
2189
+ if (args.prompt_type !== "review-edit-note") return true;
2190
+ if (args.input && args.input.trim()) {
2191
+ state.note = args.input.trim();
1613
2192
  updateMagitDisplay();
1614
- editor.setStatus(editor.t("status.hunk_status_cleared"));
1615
- }
1616
- }
1617
- registerHandler("review_clear_status", review_clear_status);
1618
-
1619
- async function review_set_overall_feedback() {
1620
- const text = await editor.prompt(editor.t("prompt.overall_feedback"), state.overallFeedback || "");
1621
- if (text !== null) {
1622
- state.overallFeedback = text.trim();
1623
- editor.setStatus(text.trim() ? editor.t("status.feedback_set") : editor.t("status.feedback_cleared"));
2193
+ editor.setStatus(state.note ? "Note saved" : "Note cleared");
2194
+ } else {
2195
+ // Empty submission: keep existing note unchanged (use x to delete)
2196
+ if (state.note) {
2197
+ editor.setStatus("Note unchanged (use x to delete)");
2198
+ }
1624
2199
  }
2200
+ return true;
1625
2201
  }
1626
- registerHandler("review_set_overall_feedback", review_set_overall_feedback);
2202
+ registerHandler("on_review_edit_note_confirm", on_review_edit_note_confirm);
1627
2203
 
1628
2204
  async function review_export_session() {
1629
2205
  const cwd = editor.getCwd();
1630
2206
  const reviewDir = editor.pathJoin(cwd, ".review");
1631
2207
 
1632
- // Generate markdown content (writeFile creates parent directories)
1633
2208
  let md = `# Code Review Session\n`;
1634
2209
  md += `Date: ${new Date().toISOString()}\n\n`;
1635
2210
 
1636
- if (state.originalRequest) {
1637
- md += `## Original Request\n${state.originalRequest}\n\n`;
1638
- }
1639
-
1640
- if (state.overallFeedback) {
1641
- md += `## Overall Feedback\n${state.overallFeedback}\n\n`;
2211
+ if (state.note) {
2212
+ md += `## Note\n${state.note}\n\n`;
1642
2213
  }
1643
2214
 
1644
- // Stats
1645
- const approved = state.hunks.filter(h => h.reviewStatus === 'approved').length;
1646
- const rejected = state.hunks.filter(h => h.reviewStatus === 'rejected').length;
1647
- const needsChanges = state.hunks.filter(h => h.reviewStatus === 'needs_changes').length;
1648
- const questions = state.hunks.filter(h => h.reviewStatus === 'question').length;
2215
+ // Summary
2216
+ const filesWithComments = new Set(state.comments.map(c => c.file)).size;
1649
2217
  md += `## Summary\n`;
1650
- md += `- Total hunks: ${state.hunks.length}\n`;
1651
- md += `- Approved: ${approved}\n`;
1652
- md += `- Rejected: ${rejected}\n`;
1653
- md += `- Needs changes: ${needsChanges}\n`;
1654
- md += `- Questions: ${questions}\n\n`;
1655
-
1656
- // Group by file
1657
- const fileGroups: Record<string, Hunk[]> = {};
1658
- for (const hunk of state.hunks) {
1659
- if (!fileGroups[hunk.file]) fileGroups[hunk.file] = [];
1660
- fileGroups[hunk.file].push(hunk);
1661
- }
1662
-
1663
- for (const [file, hunks] of Object.entries(fileGroups)) {
1664
- md += `## File: ${file}\n\n`;
1665
- for (const hunk of hunks) {
1666
- const statusStr = hunk.reviewStatus.toUpperCase();
1667
- md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
1668
- md += `**Status**: ${statusStr}\n\n`;
1669
-
1670
- const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1671
- if (hunkComments.length > 0) {
1672
- md += `**Comments:**\n`;
1673
- for (const c of hunkComments) {
1674
- // Format line reference
1675
- let lineRef = '';
1676
- if (c.line_type === 'add' && c.new_line) {
1677
- lineRef = `[+${c.new_line}]`;
1678
- } else if (c.line_type === 'remove' && c.old_line) {
1679
- lineRef = `[-${c.old_line}]`;
1680
- } else if (c.new_line) {
1681
- lineRef = `[L${c.new_line}]`;
1682
- } else if (c.old_line) {
1683
- lineRef = `[L${c.old_line}]`;
1684
- }
1685
- md += `> 💬 ${lineRef} ${c.text}\n`;
1686
- if (c.line_content) {
1687
- md += `> \`${c.line_content.trim()}\`\n`;
1688
- }
1689
- md += `\n`;
1690
- }
2218
+ md += `- Files: ${state.files.length}\n`;
2219
+ md += `- Hunks: ${state.hunks.length}\n`;
2220
+ if (filesWithComments > 0) {
2221
+ md += `- Files with comments: ${filesWithComments}\n`;
2222
+ }
2223
+ md += `\n`;
2224
+
2225
+ // Group comments by file
2226
+ const fileComments: Record<string, ReviewComment[]> = {};
2227
+ for (const c of state.comments) {
2228
+ const file = c.file || 'unknown';
2229
+ if (!fileComments[file]) fileComments[file] = [];
2230
+ fileComments[file].push(c);
2231
+ }
2232
+
2233
+ for (const [file, comments] of Object.entries(fileComments)) {
2234
+ md += `## ${file}\n\n`;
2235
+ for (const c of comments) {
2236
+ let lineRef = '';
2237
+ if (c.line_type === 'add' && c.new_line) {
2238
+ lineRef = `line +${c.new_line}`;
2239
+ } else if (c.line_type === 'remove' && c.old_line) {
2240
+ lineRef = `line -${c.old_line}`;
2241
+ } else if (c.new_line) {
2242
+ lineRef = `line ${c.new_line}`;
2243
+ } else if (c.old_line) {
2244
+ lineRef = `line ${c.old_line}`;
2245
+ }
2246
+ if (lineRef) {
2247
+ md += `- **${lineRef}**: ${c.text}\n`;
2248
+ } else {
2249
+ md += `- ${c.text}\n`;
2250
+ }
2251
+ if (c.line_content) {
2252
+ md += ` \`${c.line_content.trim()}\`\n`;
1691
2253
  }
1692
2254
  }
2255
+ md += `\n`;
1693
2256
  }
1694
2257
 
1695
- // Write file
1696
2258
  const filePath = editor.pathJoin(reviewDir, "session.md");
1697
2259
  await editor.writeFile(filePath, md);
1698
2260
  editor.setStatus(editor.t("status.exported", { path: filePath }));
@@ -1702,34 +2264,21 @@ registerHandler("review_export_session", review_export_session);
1702
2264
  async function review_export_json() {
1703
2265
  const cwd = editor.getCwd();
1704
2266
  const reviewDir = editor.pathJoin(cwd, ".review");
1705
- // writeFile creates parent directories
1706
2267
 
1707
2268
  const session = {
1708
- version: "1.0",
2269
+ version: "2.0",
1709
2270
  timestamp: new Date().toISOString(),
1710
- original_request: state.originalRequest || null,
1711
- overall_feedback: state.overallFeedback || null,
1712
- files: {} as Record<string, any>
2271
+ note: state.note || null,
2272
+ comments: state.comments.map(c => ({
2273
+ file: c.file,
2274
+ text: c.text,
2275
+ line_type: c.line_type || null,
2276
+ old_line: c.old_line || null,
2277
+ new_line: c.new_line || null,
2278
+ line_content: c.line_content || null
2279
+ }))
1713
2280
  };
1714
2281
 
1715
- for (const hunk of state.hunks) {
1716
- if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
1717
- const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1718
- session.files[hunk.file].hunks.push({
1719
- context: hunk.contextHeader,
1720
- old_lines: [hunk.oldRange.start, hunk.oldRange.end],
1721
- new_lines: [hunk.range.start, hunk.range.end],
1722
- status: hunk.reviewStatus,
1723
- comments: hunkComments.map(c => ({
1724
- text: c.text,
1725
- line_type: c.line_type || null,
1726
- old_line: c.old_line || null,
1727
- new_line: c.new_line || null,
1728
- line_content: c.line_content || null
1729
- }))
1730
- });
1731
- }
1732
-
1733
2282
  const filePath = editor.pathJoin(reviewDir, "session.json");
1734
2283
  await editor.writeFile(filePath, JSON.stringify(session, null, 2));
1735
2284
  editor.setStatus(editor.t("status.exported", { path: filePath }));
@@ -1751,19 +2300,50 @@ async function start_review_diff() {
1751
2300
  state.files = await getGitStatus();
1752
2301
  state.hunks = await fetchDiffsForFiles(state.files);
1753
2302
  state.comments = [];
2303
+ state.note = '';
1754
2304
  state.selectedIndex = 0;
1755
- state.fileScrollOffset = 0;
1756
- state.diffScrollOffset = 0;
2305
+ state.diffCursorRow = 1;
2306
+ state.hunkHeaderRows = [];
2307
+ state.diffLineByteOffsets = [];
1757
2308
  state.focusPanel = 'files';
1758
2309
 
1759
- // Build initial display
1760
- const initialEntries = buildMagitDisplayEntries();
1761
-
1762
- const bufferId = await VirtualBufferFactory.create({
1763
- name: "*Review Diff*", mode: "review-mode", readOnly: true,
1764
- entries: initialEntries, showLineNumbers: false, showCursors: false
2310
+ // Create buffer group with layout:
2311
+ // vertical: [toolbar(fixed 1), horizontal: [files, diff]]
2312
+ const layout = JSON.stringify({
2313
+ type: "split",
2314
+ direction: "v",
2315
+ ratio: 0.05,
2316
+ first: { type: "fixed", id: "toolbar", height: 1 },
2317
+ second: {
2318
+ type: "split",
2319
+ direction: "h",
2320
+ ratio: 0.3,
2321
+ first: { type: "scrollable", id: "files" },
2322
+ second: { type: "scrollable", id: "diff" },
2323
+ },
1765
2324
  });
1766
- state.reviewBufferId = bufferId;
2325
+
2326
+ const groupResult = await editor.createBufferGroup("*Review Diff*", "review-mode", layout);
2327
+ state.groupId = groupResult.groupId;
2328
+ state.panelBuffers = groupResult.panels;
2329
+ state.reviewBufferId = groupResult.panels["files"];
2330
+
2331
+ // Diff panel uses the editor's native cursor for scrolling. Buffer-group
2332
+ // panels default to `show_cursors = false`, which also blocks all native
2333
+ // movement actions in `action_to_events`, so flip the flag for the diff
2334
+ // panel only. The files panel keeps its hidden cursor — selection there
2335
+ // is plugin-managed (state.selectedIndex with a `>` prefix + bg highlight),
2336
+ // and j/k/Up/Down are dispatched through the `review_nav_*` handlers.
2337
+ if (state.panelBuffers["diff"] !== undefined) {
2338
+ (editor as any).setBufferShowCursors(state.panelBuffers["diff"], true);
2339
+ }
2340
+
2341
+ // Set initial content for all panels
2342
+ updateMagitDisplay();
2343
+
2344
+ // Ensure the files panel has focus (moves focus away from File Explorer
2345
+ // if it was open, so review-mode keybindings work immediately)
2346
+ editor.focusBufferGroupPanel(state.groupId, "files");
1767
2347
 
1768
2348
  // Register resize handler
1769
2349
  editor.on("resize", "onReviewDiffResize");
@@ -1771,25 +2351,71 @@ async function start_review_diff() {
1771
2351
  editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
1772
2352
  editor.on("buffer_activated", "on_review_buffer_activated");
1773
2353
  editor.on("buffer_closed", "on_review_buffer_closed");
2354
+ editor.on("cursor_moved", "on_review_cursor_moved");
1774
2355
  }
1775
2356
  registerHandler("start_review_diff", start_review_diff);
1776
2357
 
1777
2358
  function stop_review_diff() {
2359
+ if (state.groupId !== null) {
2360
+ editor.closeBufferGroup(state.groupId);
2361
+ state.groupId = null;
2362
+ state.panelBuffers = {};
2363
+ }
1778
2364
  state.reviewBufferId = null;
1779
2365
  editor.setContext("review-mode", false);
1780
2366
  editor.off("resize", "onReviewDiffResize");
1781
2367
  editor.off("buffer_activated", "on_review_buffer_activated");
1782
2368
  editor.off("buffer_closed", "on_review_buffer_closed");
2369
+ editor.off("cursor_moved", "on_review_cursor_moved");
1783
2370
  editor.setStatus(editor.t("status.stopped"));
1784
2371
  }
1785
2372
  registerHandler("stop_review_diff", stop_review_diff);
1786
2373
 
1787
2374
 
1788
- function on_review_buffer_activated(data: any) {
1789
- if (data.buffer_id === state.reviewBufferId) refreshMagitData();
2375
+ /**
2376
+ * React to a buffer becoming active. Used here purely to track which review
2377
+ * panel currently has focus (Tab and mouse clicks both fire buffer_activated).
2378
+ * The focus state drives toolbar hint rendering and the `review_nav_*`
2379
+ * handlers' files-vs-diff branching.
2380
+ *
2381
+ * Note: this used to call `refreshMagitData()` on every activation, which
2382
+ * spawned several `git` subprocesses every time the user switched panels.
2383
+ * The user has a dedicated `r` key for that — auto-refresh was too aggressive.
2384
+ */
2385
+ function on_review_buffer_activated(data: { buffer_id: number }): void {
2386
+ if (state.groupId === null) return;
2387
+ const filesId = state.panelBuffers["files"];
2388
+ const diffId = state.panelBuffers["diff"];
2389
+ let newPanel: 'files' | 'diff' | null = null;
2390
+ if (data.buffer_id === filesId) newPanel = 'files';
2391
+ else if (data.buffer_id === diffId) newPanel = 'diff';
2392
+ if (newPanel === null || newPanel === state.focusPanel) return;
2393
+ state.focusPanel = newPanel;
2394
+ editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
1790
2395
  }
1791
2396
  registerHandler("on_review_buffer_activated", on_review_buffer_activated);
1792
2397
 
2398
+ /**
2399
+ * React to native cursor movement inside the diff panel — the only panel
2400
+ * that has a visible native cursor. The handler keeps `state.diffCursorRow`
2401
+ * in sync (used by `n`/`p` hunk navigation) and re-paints the cursor-line
2402
+ * highlight overlay.
2403
+ */
2404
+ function on_review_cursor_moved(data: {
2405
+ buffer_id: number;
2406
+ cursor_id: number;
2407
+ old_position: number;
2408
+ new_position: number;
2409
+ line: number;
2410
+ text_properties: Array<Record<string, unknown>>;
2411
+ }): void {
2412
+ if (state.groupId === null) return;
2413
+ if (data.buffer_id !== state.panelBuffers["diff"]) return;
2414
+ state.diffCursorRow = data.line;
2415
+ applyCursorLineOverlay('diff');
2416
+ }
2417
+ registerHandler("on_review_cursor_moved", on_review_cursor_moved);
2418
+
1793
2419
  function on_review_buffer_closed(data: any) {
1794
2420
  if (data.buffer_id === state.reviewBufferId) stop_review_diff();
1795
2421
  }
@@ -1879,7 +2505,6 @@ async function side_by_side_diff_current_file() {
1879
2505
  type: isUntracked ? 'add' : 'modify',
1880
2506
  lines: [],
1881
2507
  status: 'pending',
1882
- reviewStatus: 'pending',
1883
2508
  contextHeader: match[5]?.trim() || "",
1884
2509
  byteOffset: 0
1885
2510
  };
@@ -2046,12 +2671,7 @@ editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc",
2046
2671
 
2047
2672
  // Review Comment Commands
2048
2673
  editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
2049
- editor.registerCommand("%cmd.approve_hunk", "%cmd.approve_hunk_desc", "review_approve_hunk", "review-mode");
2050
- editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
2051
- editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
2052
- editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
2053
- editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
2054
- editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "review_set_overall_feedback", "review-mode");
2674
+ editor.registerCommand("%cmd.edit_note", "%cmd.edit_note_desc", "review_edit_note", "review-mode");
2055
2675
  editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
2056
2676
  editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
2057
2677
 
@@ -2089,23 +2709,30 @@ registerHandler("on_buffer_closed", on_buffer_closed);
2089
2709
  editor.on("buffer_closed", "on_buffer_closed");
2090
2710
 
2091
2711
  editor.defineMode("review-mode", [
2092
- // Navigation (arrow keys, vim keys, page keys)
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.
2093
2716
  ["Up", "review_nav_up"], ["Down", "review_nav_down"],
2094
2717
  ["k", "review_nav_up"], ["j", "review_nav_down"],
2095
2718
  ["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
2096
2719
  ["Home", "review_nav_home"], ["End", "review_nav_end"],
2720
+ // Focus toggle between panels
2097
2721
  ["Tab", "review_toggle_focus"],
2098
- ["Left", "review_focus_files"], ["Right", "review_focus_diff"],
2722
+ // Hunk navigation (diff panel) — jumps the native cursor between hunks.
2723
+ ["n", "review_next_hunk"], ["p", "review_prev_hunk"],
2099
2724
  // Drill-down
2100
2725
  ["Enter", "review_drill_down"],
2101
- // Git actions (plain letter keys — safe because buffer is read-only with cursors hidden)
2726
+ // Git actions (context-sensitive: file-level or hunk-level based on focus)
2102
2727
  ["s", "review_stage_file"], ["u", "review_unstage_file"],
2103
2728
  ["d", "review_discard_file"],
2104
2729
  ["r", "review_refresh"],
2105
- // Review actions
2106
- ["a", "review_approve_hunk"],
2730
+ // Comments
2107
2731
  ["c", "review_add_comment"],
2108
- // Export
2732
+ ["N", "review_edit_note"],
2733
+ ["x", "review_delete_comment"],
2734
+ // Close & export
2735
+ ["q", "close"],
2109
2736
  ["e", "review_export_session"],
2110
2737
  ], true);
2111
2738