@involvex/fresh-editor 0.1.76 → 0.1.78

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.
Files changed (90) hide show
  1. package/bin/CHANGELOG.md +1017 -0
  2. package/bin/LICENSE +117 -0
  3. package/bin/README.md +248 -0
  4. package/bin/fresh.exe +0 -0
  5. package/bin/plugins/README.md +71 -0
  6. package/bin/plugins/audit_mode.i18n.json +821 -0
  7. package/bin/plugins/audit_mode.ts +1810 -0
  8. package/bin/plugins/buffer_modified.i18n.json +67 -0
  9. package/bin/plugins/buffer_modified.ts +281 -0
  10. package/bin/plugins/calculator.i18n.json +93 -0
  11. package/bin/plugins/calculator.ts +770 -0
  12. package/bin/plugins/clangd-lsp.ts +168 -0
  13. package/bin/plugins/clangd_support.i18n.json +223 -0
  14. package/bin/plugins/clangd_support.md +20 -0
  15. package/bin/plugins/clangd_support.ts +325 -0
  16. package/bin/plugins/color_highlighter.i18n.json +145 -0
  17. package/bin/plugins/color_highlighter.ts +304 -0
  18. package/bin/plugins/config-schema.json +768 -0
  19. package/bin/plugins/csharp-lsp.ts +147 -0
  20. package/bin/plugins/csharp_support.i18n.json +80 -0
  21. package/bin/plugins/csharp_support.ts +170 -0
  22. package/bin/plugins/css-lsp.ts +143 -0
  23. package/bin/plugins/diagnostics_panel.i18n.json +236 -0
  24. package/bin/plugins/diagnostics_panel.ts +642 -0
  25. package/bin/plugins/examples/README.md +85 -0
  26. package/bin/plugins/examples/async_demo.ts +165 -0
  27. package/bin/plugins/examples/bookmarks.ts +329 -0
  28. package/bin/plugins/examples/buffer_query_demo.ts +110 -0
  29. package/bin/plugins/examples/git_grep.ts +262 -0
  30. package/bin/plugins/examples/hello_world.ts +93 -0
  31. package/bin/plugins/examples/virtual_buffer_demo.ts +116 -0
  32. package/bin/plugins/find_references.i18n.json +275 -0
  33. package/bin/plugins/find_references.ts +359 -0
  34. package/bin/plugins/git_blame.i18n.json +496 -0
  35. package/bin/plugins/git_blame.ts +707 -0
  36. package/bin/plugins/git_find_file.i18n.json +314 -0
  37. package/bin/plugins/git_find_file.ts +300 -0
  38. package/bin/plugins/git_grep.i18n.json +171 -0
  39. package/bin/plugins/git_grep.ts +191 -0
  40. package/bin/plugins/git_gutter.i18n.json +93 -0
  41. package/bin/plugins/git_gutter.ts +477 -0
  42. package/bin/plugins/git_log.i18n.json +481 -0
  43. package/bin/plugins/git_log.ts +1285 -0
  44. package/bin/plugins/go-lsp.ts +143 -0
  45. package/bin/plugins/html-lsp.ts +145 -0
  46. package/bin/plugins/json-lsp.ts +145 -0
  47. package/bin/plugins/lib/fresh.d.ts +1321 -0
  48. package/bin/plugins/lib/index.ts +24 -0
  49. package/bin/plugins/lib/navigation-controller.ts +214 -0
  50. package/bin/plugins/lib/panel-manager.ts +220 -0
  51. package/bin/plugins/lib/types.ts +72 -0
  52. package/bin/plugins/lib/virtual-buffer-factory.ts +130 -0
  53. package/bin/plugins/live_grep.i18n.json +171 -0
  54. package/bin/plugins/live_grep.ts +422 -0
  55. package/bin/plugins/markdown_compose.i18n.json +223 -0
  56. package/bin/plugins/markdown_compose.ts +630 -0
  57. package/bin/plugins/merge_conflict.i18n.json +821 -0
  58. package/bin/plugins/merge_conflict.ts +1810 -0
  59. package/bin/plugins/path_complete.i18n.json +80 -0
  60. package/bin/plugins/path_complete.ts +165 -0
  61. package/bin/plugins/python-lsp.ts +162 -0
  62. package/bin/plugins/rust-lsp.ts +166 -0
  63. package/bin/plugins/search_replace.i18n.json +405 -0
  64. package/bin/plugins/search_replace.ts +484 -0
  65. package/bin/plugins/test_i18n.i18n.json +67 -0
  66. package/bin/plugins/test_i18n.ts +18 -0
  67. package/bin/plugins/theme_editor.i18n.json +3746 -0
  68. package/bin/plugins/theme_editor.ts +2063 -0
  69. package/bin/plugins/todo_highlighter.i18n.json +184 -0
  70. package/bin/plugins/todo_highlighter.ts +206 -0
  71. package/bin/plugins/typescript-lsp.ts +167 -0
  72. package/bin/plugins/vi_mode.i18n.json +1549 -0
  73. package/bin/plugins/vi_mode.ts +2747 -0
  74. package/bin/plugins/welcome.i18n.json +236 -0
  75. package/bin/plugins/welcome.ts +76 -0
  76. package/bin/themes/dark.json +102 -0
  77. package/bin/themes/dracula.json +62 -0
  78. package/bin/themes/high-contrast.json +102 -0
  79. package/bin/themes/light.json +102 -0
  80. package/bin/themes/nord.json +62 -0
  81. package/bin/themes/nostalgia.json +102 -0
  82. package/bin/themes/solarized-dark.json +62 -0
  83. package/binary-install.js +1 -1
  84. package/dist/bin/fresh.js +9 -0
  85. package/dist/binary-install.js +149 -0
  86. package/dist/binary.js +30 -0
  87. package/dist/fresh-6yhknp07.exe +0 -0
  88. package/dist/install.js +158 -0
  89. package/dist/run-fresh.js +43 -0
  90. package/package.json +7 -2
@@ -0,0 +1,1810 @@
1
+ // Review Diff Plugin
2
+ // Provides a unified workflow for reviewing code changes (diffs, conflicts, AI outputs).
3
+ const editor = getEditor();
4
+
5
+ /// <reference path="./lib/fresh.d.ts" />
6
+ /// <reference path="./lib/types.ts" />
7
+ /// <reference path="./lib/virtual-buffer-factory.ts" />
8
+
9
+ import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
10
+ const VirtualBufferFactory = createVirtualBufferFactory(editor);
11
+
12
+ /**
13
+ * Hunk status for staging
14
+ */
15
+ type HunkStatus = 'pending' | 'staged' | 'discarded';
16
+
17
+ /**
18
+ * Review status for a hunk
19
+ */
20
+ type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
21
+
22
+ /**
23
+ * A review comment attached to a specific line in a file
24
+ * Uses file line numbers (not hunk-relative) so comments survive rebases
25
+ */
26
+ interface ReviewComment {
27
+ id: string;
28
+ hunk_id: string; // For grouping, but line numbers are primary
29
+ file: string; // File path
30
+ text: string;
31
+ timestamp: string;
32
+ // Line positioning using actual file line numbers
33
+ old_line?: number; // Line number in old file version (for - lines)
34
+ new_line?: number; // Line number in new file version (for + lines)
35
+ line_content?: string; // The actual line content for context/matching
36
+ line_type?: 'add' | 'remove' | 'context'; // Type of line
37
+ // Selection range (for multi-line comments)
38
+ selection?: {
39
+ start_line: number; // Start line in file
40
+ end_line: number; // End line in file
41
+ version: 'old' | 'new'; // Which file version
42
+ };
43
+ }
44
+
45
+ /**
46
+ * A diff hunk (block of changes)
47
+ */
48
+ interface Hunk {
49
+ id: string;
50
+ file: string;
51
+ range: { start: number; end: number }; // new file line range
52
+ oldRange: { start: number; end: number }; // old file line range
53
+ type: 'add' | 'remove' | 'modify';
54
+ lines: string[];
55
+ status: HunkStatus;
56
+ reviewStatus: ReviewStatus;
57
+ contextHeader: string;
58
+ byteOffset: number; // Position in the virtual buffer
59
+ }
60
+
61
+ /**
62
+ * Review Session State
63
+ */
64
+ interface ReviewState {
65
+ hunks: Hunk[];
66
+ hunkStatus: Record<string, HunkStatus>;
67
+ comments: ReviewComment[];
68
+ originalRequest?: string;
69
+ overallFeedback?: string;
70
+ reviewBufferId: number | null;
71
+ }
72
+
73
+ const state: ReviewState = {
74
+ hunks: [],
75
+ hunkStatus: {},
76
+ comments: [],
77
+ reviewBufferId: null,
78
+ };
79
+
80
+ // --- Refresh State ---
81
+ let isUpdating = false;
82
+
83
+ // --- Colors & Styles ---
84
+ const STYLE_BORDER: [number, number, number] = [70, 70, 70];
85
+ const STYLE_HEADER: [number, number, number] = [120, 120, 255];
86
+ const STYLE_FILE_NAME: [number, number, number] = [220, 220, 100];
87
+ const STYLE_ADD_BG: [number, number, number] = [40, 100, 40]; // Brighter Green BG
88
+ const STYLE_REMOVE_BG: [number, number, number] = [100, 40, 40]; // Brighter Red BG
89
+ const STYLE_ADD_TEXT: [number, number, number] = [150, 255, 150]; // Very Bright Green
90
+ const STYLE_REMOVE_TEXT: [number, number, number] = [255, 150, 150]; // Very Bright Red
91
+ const STYLE_STAGED: [number, number, number] = [100, 100, 100];
92
+ const STYLE_DISCARDED: [number, number, number] = [120, 60, 60];
93
+ const STYLE_COMMENT: [number, number, number] = [180, 180, 100]; // Yellow for comments
94
+ const STYLE_COMMENT_BORDER: [number, number, number] = [100, 100, 60];
95
+ const STYLE_APPROVED: [number, number, number] = [100, 200, 100]; // Green checkmark
96
+ const STYLE_REJECTED: [number, number, number] = [200, 100, 100]; // Red X
97
+ const STYLE_QUESTION: [number, number, number] = [200, 200, 100]; // Yellow ?
98
+
99
+ /**
100
+ * Calculate UTF-8 byte length of a string manually since TextEncoder is not available
101
+ */
102
+ function getByteLength(str: string): number {
103
+ let s = 0;
104
+ for (let i = 0; i < str.length; i++) {
105
+ const code = str.charCodeAt(i);
106
+ if (code <= 0x7f) s += 1;
107
+ else if (code <= 0x7ff) s += 2;
108
+ else if (code >= 0xd800 && code <= 0xdfff) {
109
+ s += 4; i++;
110
+ } else s += 3;
111
+ }
112
+ return s;
113
+ }
114
+
115
+ // --- Diff Logic ---
116
+
117
+ interface DiffPart {
118
+ text: string;
119
+ type: 'added' | 'removed' | 'unchanged';
120
+ }
121
+
122
+ function diffStrings(oldStr: string, newStr: string): DiffPart[] {
123
+ const n = oldStr.length;
124
+ const m = newStr.length;
125
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
126
+
127
+ for (let i = 1; i <= n; i++) {
128
+ for (let j = 1; j <= m; j++) {
129
+ if (oldStr[i - 1] === newStr[j - 1]) {
130
+ dp[i][j] = dp[i - 1][j - 1] + 1;
131
+ } else {
132
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
133
+ }
134
+ }
135
+ }
136
+
137
+ const result: DiffPart[] = [];
138
+ let i = n, j = m;
139
+ while (i > 0 || j > 0) {
140
+ if (i > 0 && j > 0 && oldStr[i - 1] === newStr[j - 1]) {
141
+ result.unshift({ text: oldStr[i - 1], type: 'unchanged' });
142
+ i--; j--;
143
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
144
+ result.unshift({ text: newStr[j - 1], type: 'added' });
145
+ j--;
146
+ } else {
147
+ result.unshift({ text: oldStr[i - 1], type: 'removed' });
148
+ i--;
149
+ }
150
+ }
151
+
152
+ const coalesced: DiffPart[] = [];
153
+ for (const part of result) {
154
+ const last = coalesced[coalesced.length - 1];
155
+ if (last && last.type === part.type) {
156
+ last.text += part.text;
157
+ } else {
158
+ coalesced.push(part);
159
+ }
160
+ }
161
+ return coalesced;
162
+ }
163
+
164
+ async function getGitDiff(): Promise<Hunk[]> {
165
+ const result = await editor.spawnProcess("git", ["diff", "HEAD", "--unified=3"]);
166
+ if (result.exit_code !== 0) return [];
167
+
168
+ const lines = result.stdout.split('\n');
169
+ const hunks: Hunk[] = [];
170
+ let currentFile = "";
171
+ let currentHunk: Hunk | null = null;
172
+
173
+ for (let i = 0; i < lines.length; i++) {
174
+ const line = lines[i];
175
+ if (line.startsWith('diff --git')) {
176
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/);
177
+ if (match) {
178
+ currentFile = match[2];
179
+ currentHunk = null;
180
+ }
181
+ } else if (line.startsWith('@@')) {
182
+ const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@(.*)/);
183
+ if (match && currentFile) {
184
+ const oldStart = parseInt(match[1]);
185
+ const newStart = parseInt(match[2]);
186
+ currentHunk = {
187
+ id: `${currentFile}:${newStart}`,
188
+ file: currentFile,
189
+ range: { start: newStart, end: newStart },
190
+ oldRange: { start: oldStart, end: oldStart },
191
+ type: 'modify',
192
+ lines: [],
193
+ status: 'pending',
194
+ reviewStatus: 'pending',
195
+ contextHeader: match[3]?.trim() || "",
196
+ byteOffset: 0
197
+ };
198
+ hunks.push(currentHunk);
199
+ }
200
+ } else if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
201
+ if (!line.startsWith('---') && !line.startsWith('+++')) {
202
+ currentHunk.lines.push(line);
203
+ }
204
+ }
205
+ }
206
+ return hunks;
207
+ }
208
+
209
+ interface HighlightTask {
210
+ range: [number, number];
211
+ fg: [number, number, number];
212
+ bg?: [number, number, number];
213
+ bold?: boolean;
214
+ italic?: boolean;
215
+ extend_to_line_end?: boolean;
216
+ }
217
+
218
+ /**
219
+ * Render the Review Stream buffer content and return highlight tasks
220
+ */
221
+ async function renderReviewStream(): Promise<{ entries: TextPropertyEntry[], highlights: HighlightTask[] }> {
222
+ const entries: TextPropertyEntry[] = [];
223
+ const highlights: HighlightTask[] = [];
224
+ let currentFile = "";
225
+ let currentByte = 0;
226
+
227
+ // Add help header with keybindings at the TOP
228
+ const helpHeader = "╔" + "═".repeat(74) + "╗\n";
229
+ const helpLen0 = getByteLength(helpHeader);
230
+ entries.push({ text: helpHeader, properties: { type: "help" } });
231
+ highlights.push({ range: [currentByte, currentByte + helpLen0], fg: STYLE_COMMENT_BORDER });
232
+ currentByte += helpLen0;
233
+
234
+ const helpLine1 = "║ " + editor.t("panel.help_review").padEnd(72) + " ║\n";
235
+ const helpLen1 = getByteLength(helpLine1);
236
+ entries.push({ text: helpLine1, properties: { type: "help" } });
237
+ highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_COMMENT });
238
+ currentByte += helpLen1;
239
+
240
+ const helpLine2 = "║ " + editor.t("panel.help_stage").padEnd(72) + " ║\n";
241
+ const helpLen2 = getByteLength(helpLine2);
242
+ entries.push({ text: helpLine2, properties: { type: "help" } });
243
+ highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
244
+ currentByte += helpLen2;
245
+
246
+ const helpLine3 = "║ " + editor.t("panel.help_export").padEnd(72) + " ║\n";
247
+ const helpLen3 = getByteLength(helpLine3);
248
+ entries.push({ text: helpLine3, properties: { type: "help" } });
249
+ highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
250
+ currentByte += helpLen3;
251
+
252
+ const helpFooter = "╚" + "═".repeat(74) + "╝\n\n";
253
+ const helpLen4 = getByteLength(helpFooter);
254
+ entries.push({ text: helpFooter, properties: { type: "help" } });
255
+ highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT_BORDER });
256
+ currentByte += helpLen4;
257
+
258
+ for (let hunkIndex = 0; hunkIndex < state.hunks.length; hunkIndex++) {
259
+ const hunk = state.hunks[hunkIndex];
260
+ if (hunk.file !== currentFile) {
261
+ // Header & Border
262
+ const titlePrefix = "┌─ ";
263
+ const titleLine = `${titlePrefix}${hunk.file} ${"─".repeat(Math.max(0, 60 - hunk.file.length))}\n`;
264
+ const titleLen = getByteLength(titleLine);
265
+ entries.push({ text: titleLine, properties: { type: "banner", file: hunk.file } });
266
+ highlights.push({ range: [currentByte, currentByte + titleLen], fg: STYLE_BORDER });
267
+ const prefixLen = getByteLength(titlePrefix);
268
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + getByteLength(hunk.file)], fg: STYLE_FILE_NAME, bold: true });
269
+ currentByte += titleLen;
270
+ currentFile = hunk.file;
271
+ }
272
+
273
+ hunk.byteOffset = currentByte;
274
+
275
+ // Status icons: staging (left) and review (right)
276
+ const stagingIcon = hunk.status === 'staged' ? '✓' : (hunk.status === 'discarded' ? '✗' : ' ');
277
+ const reviewIcon = hunk.reviewStatus === 'approved' ? '✓' :
278
+ hunk.reviewStatus === 'rejected' ? '✗' :
279
+ hunk.reviewStatus === 'needs_changes' ? '!' :
280
+ hunk.reviewStatus === 'question' ? '?' : ' ';
281
+ const reviewLabel = hunk.reviewStatus !== 'pending' ? ` ← ${hunk.reviewStatus.toUpperCase()}` : '';
282
+
283
+ const headerPrefix = "│ ";
284
+ const headerText = `${headerPrefix}${stagingIcon} ${reviewIcon} [ ${hunk.contextHeader} ]${reviewLabel}\n`;
285
+ const headerLen = getByteLength(headerText);
286
+
287
+ let hunkColor = STYLE_HEADER;
288
+ if (hunk.status === 'staged') hunkColor = STYLE_STAGED;
289
+ else if (hunk.status === 'discarded') hunkColor = STYLE_DISCARDED;
290
+
291
+ let reviewColor = STYLE_HEADER;
292
+ if (hunk.reviewStatus === 'approved') reviewColor = STYLE_APPROVED;
293
+ else if (hunk.reviewStatus === 'rejected') reviewColor = STYLE_REJECTED;
294
+ else if (hunk.reviewStatus === 'needs_changes') reviewColor = STYLE_QUESTION;
295
+ else if (hunk.reviewStatus === 'question') reviewColor = STYLE_QUESTION;
296
+
297
+ entries.push({ text: headerText, properties: { type: "header", hunkId: hunk.id, index: hunkIndex } });
298
+ highlights.push({ range: [currentByte, currentByte + headerLen], fg: STYLE_BORDER });
299
+ const headerPrefixLen = getByteLength(headerPrefix);
300
+ // Staging icon
301
+ highlights.push({ range: [currentByte + headerPrefixLen, currentByte + headerPrefixLen + getByteLength(stagingIcon)], fg: hunkColor, bold: true });
302
+ // Review icon
303
+ highlights.push({ range: [currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1, currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon)], fg: reviewColor, bold: true });
304
+ // Context header
305
+ const contextStart = currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon) + 3;
306
+ highlights.push({ range: [contextStart, currentByte + headerLen - getByteLength(reviewLabel) - 2], fg: hunkColor });
307
+ // Review label
308
+ if (reviewLabel) {
309
+ highlights.push({ range: [currentByte + headerLen - getByteLength(reviewLabel) - 1, currentByte + headerLen - 1], fg: reviewColor, bold: true });
310
+ }
311
+ currentByte += headerLen;
312
+
313
+ // Track actual file line numbers as we iterate
314
+ let oldLineNum = hunk.oldRange.start;
315
+ let newLineNum = hunk.range.start;
316
+
317
+ for (let i = 0; i < hunk.lines.length; i++) {
318
+ const line = hunk.lines[i];
319
+ const nextLine = hunk.lines[i + 1];
320
+ const marker = line[0];
321
+ const content = line.substring(1);
322
+ const linePrefix = "│ ";
323
+ const lineText = `${linePrefix}${marker} ${content}\n`;
324
+ const lineLen = getByteLength(lineText);
325
+ const prefixLen = getByteLength(linePrefix);
326
+
327
+ // Determine line type and which line numbers apply
328
+ const lineType: 'add' | 'remove' | 'context' =
329
+ marker === '+' ? 'add' : marker === '-' ? 'remove' : 'context';
330
+ const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
331
+ const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
332
+
333
+ if (line.startsWith('-') && nextLine && nextLine.startsWith('+') && hunk.status === 'pending') {
334
+ const oldContent = line.substring(1);
335
+ const newContent = nextLine.substring(1);
336
+ const diffParts = diffStrings(oldContent, newContent);
337
+
338
+ // Removed
339
+ entries.push({ text: lineText, properties: {
340
+ type: "content", hunkId: hunk.id, file: hunk.file,
341
+ lineType: 'remove', oldLine: curOldLine, lineContent: line
342
+ } });
343
+ highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
344
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
345
+
346
+ let cbOffset = currentByte + prefixLen + 2;
347
+ diffParts.forEach(p => {
348
+ const pLen = getByteLength(p.text);
349
+ if (p.type === 'removed') {
350
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true });
351
+ cbOffset += pLen;
352
+ } else if (p.type === 'unchanged') {
353
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT });
354
+ cbOffset += pLen;
355
+ }
356
+ });
357
+ currentByte += lineLen;
358
+
359
+ // Added (increment old line for the removed line we just processed)
360
+ oldLineNum++;
361
+ const nextLineText = `${linePrefix}+ ${nextLine.substring(1)}\n`;
362
+ const nextLineLen = getByteLength(nextLineText);
363
+ entries.push({ text: nextLineText, properties: {
364
+ type: "content", hunkId: hunk.id, file: hunk.file,
365
+ lineType: 'add', newLine: newLineNum, lineContent: nextLine
366
+ } });
367
+ newLineNum++;
368
+ highlights.push({ range: [currentByte, currentByte + nextLineLen], fg: STYLE_BORDER });
369
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
370
+
371
+ cbOffset = currentByte + prefixLen + 2;
372
+ diffParts.forEach(p => {
373
+ const pLen = getByteLength(p.text);
374
+ if (p.type === 'added') {
375
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true });
376
+ cbOffset += pLen;
377
+ } else if (p.type === 'unchanged') {
378
+ highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT });
379
+ cbOffset += pLen;
380
+ }
381
+ });
382
+ currentByte += nextLineLen;
383
+
384
+ // Render comments for the removed line (curOldLine before increment)
385
+ const removedLineComments = state.comments.filter(c =>
386
+ c.hunk_id === hunk.id && c.line_type === 'remove' && c.old_line === curOldLine
387
+ );
388
+ for (const comment of removedLineComments) {
389
+ const commentPrefix = `│ » [-${comment.old_line}] `;
390
+ const commentLines = comment.text.split('\n');
391
+ for (let ci = 0; ci < commentLines.length; ci++) {
392
+ const prefix = ci === 0 ? commentPrefix : "│ ";
393
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
394
+ const commentLineLen = getByteLength(commentLine);
395
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
396
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
397
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
398
+ currentByte += commentLineLen;
399
+ }
400
+ }
401
+
402
+ // Render comments for the added line (newLineNum - 1, since we already incremented)
403
+ const addedLineComments = state.comments.filter(c =>
404
+ c.hunk_id === hunk.id && c.line_type === 'add' && c.new_line === (newLineNum - 1)
405
+ );
406
+ for (const comment of addedLineComments) {
407
+ const commentPrefix = `│ » [+${comment.new_line}] `;
408
+ const commentLines = comment.text.split('\n');
409
+ for (let ci = 0; ci < commentLines.length; ci++) {
410
+ const prefix = ci === 0 ? commentPrefix : "│ ";
411
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
412
+ const commentLineLen = getByteLength(commentLine);
413
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
414
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
415
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
416
+ currentByte += commentLineLen;
417
+ }
418
+ }
419
+
420
+ i++;
421
+ } else {
422
+ entries.push({ text: lineText, properties: {
423
+ type: "content", hunkId: hunk.id, file: hunk.file,
424
+ lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line
425
+ } });
426
+ highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
427
+ if (hunk.status === 'pending') {
428
+ if (line.startsWith('+')) {
429
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
430
+ highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_ADD_TEXT });
431
+ } else if (line.startsWith('-')) {
432
+ highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
433
+ highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_REMOVE_TEXT });
434
+ }
435
+ } else {
436
+ highlights.push({ range: [currentByte + prefixLen, currentByte + lineLen], fg: hunkColor });
437
+ }
438
+ currentByte += lineLen;
439
+
440
+ // Increment line counters based on line type
441
+ if (lineType === 'remove') oldLineNum++;
442
+ else if (lineType === 'add') newLineNum++;
443
+ else { oldLineNum++; newLineNum++; } // context
444
+
445
+ // Render any comments attached to this specific line
446
+ const lineComments = state.comments.filter(c =>
447
+ c.hunk_id === hunk.id && (
448
+ (lineType === 'remove' && c.old_line === curOldLine) ||
449
+ (lineType === 'add' && c.new_line === curNewLine) ||
450
+ (lineType === 'context' && (c.old_line === curOldLine || c.new_line === curNewLine))
451
+ )
452
+ );
453
+ for (const comment of lineComments) {
454
+ const lineRef = comment.line_type === 'add'
455
+ ? `+${comment.new_line}`
456
+ : comment.line_type === 'remove'
457
+ ? `-${comment.old_line}`
458
+ : `${comment.new_line}`;
459
+ const commentPrefix = `│ » [${lineRef}] `;
460
+ const commentLines = comment.text.split('\n');
461
+ for (let ci = 0; ci < commentLines.length; ci++) {
462
+ const prefix = ci === 0 ? commentPrefix : "│ ";
463
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
464
+ const commentLineLen = getByteLength(commentLine);
465
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
466
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
467
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
468
+ currentByte += commentLineLen;
469
+ }
470
+ }
471
+ }
472
+ }
473
+
474
+ // Render any comments without specific line info at the end of hunk
475
+ const orphanComments = state.comments.filter(c =>
476
+ c.hunk_id === hunk.id && !c.old_line && !c.new_line
477
+ );
478
+ if (orphanComments.length > 0) {
479
+ const commentBorder = "│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄\n";
480
+ const borderLen = getByteLength(commentBorder);
481
+ entries.push({ text: commentBorder, properties: { type: "comment-border" } });
482
+ highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
483
+ currentByte += borderLen;
484
+
485
+ for (const comment of orphanComments) {
486
+ const commentPrefix = "│ » ";
487
+ const commentLines = comment.text.split('\n');
488
+ for (let ci = 0; ci < commentLines.length; ci++) {
489
+ const prefix = ci === 0 ? commentPrefix : "│ ";
490
+ const commentLine = `${prefix}${commentLines[ci]}\n`;
491
+ const commentLineLen = getByteLength(commentLine);
492
+ entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
493
+ highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
494
+ highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
495
+ currentByte += commentLineLen;
496
+ }
497
+ }
498
+
499
+ entries.push({ text: commentBorder, properties: { type: "comment-border" } });
500
+ highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
501
+ currentByte += borderLen;
502
+ }
503
+
504
+ const isLastOfFile = hunkIndex === state.hunks.length - 1 || state.hunks[hunkIndex + 1].file !== hunk.file;
505
+ if (isLastOfFile) {
506
+ const bottomLine = `└${"─".repeat(64)}\n`;
507
+ const bottomLen = getByteLength(bottomLine);
508
+ entries.push({ text: bottomLine, properties: { type: "border" } });
509
+ highlights.push({ range: [currentByte, currentByte + bottomLen], fg: STYLE_BORDER });
510
+ currentByte += bottomLen;
511
+ }
512
+ }
513
+
514
+ if (entries.length === 0) {
515
+ entries.push({ text: editor.t("panel.no_changes") + "\n", properties: {} });
516
+ } else {
517
+ // Add help footer with keybindings
518
+ const helpSeparator = "\n" + "─".repeat(70) + "\n";
519
+ const helpLen1 = getByteLength(helpSeparator);
520
+ entries.push({ text: helpSeparator, properties: { type: "help" } });
521
+ highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_BORDER });
522
+ currentByte += helpLen1;
523
+
524
+ const helpLine1 = editor.t("panel.help_review_footer") + "\n";
525
+ const helpLen2 = getByteLength(helpLine1);
526
+ entries.push({ text: helpLine1, properties: { type: "help" } });
527
+ highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
528
+ currentByte += helpLen2;
529
+
530
+ const helpLine2 = editor.t("panel.help_stage_footer") + "\n";
531
+ const helpLen3 = getByteLength(helpLine2);
532
+ entries.push({ text: helpLine2, properties: { type: "help" } });
533
+ highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
534
+ currentByte += helpLen3;
535
+
536
+ const helpLine3 = editor.t("panel.help_export_footer") + "\n";
537
+ const helpLen4 = getByteLength(helpLine3);
538
+ entries.push({ text: helpLine3, properties: { type: "help" } });
539
+ highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT });
540
+ currentByte += helpLen4;
541
+ }
542
+ return { entries, highlights };
543
+ }
544
+
545
+ /**
546
+ * Updates the buffer UI (text and highlights) based on current state.hunks
547
+ */
548
+ async function updateReviewUI() {
549
+ if (state.reviewBufferId !== null) {
550
+ const { entries, highlights } = await renderReviewStream();
551
+ editor.setVirtualBufferContent(state.reviewBufferId, entries);
552
+
553
+ editor.clearNamespace(state.reviewBufferId, "review-diff");
554
+ highlights.forEach((h) => {
555
+ const bg = h.bg || [-1, -1, -1];
556
+ // addOverlay signature: bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b
557
+ editor.addOverlay(
558
+ state.reviewBufferId!,
559
+ "review-diff",
560
+ h.range[0],
561
+ h.range[1],
562
+ h.fg[0], h.fg[1], h.fg[2], // foreground color
563
+ false, // underline
564
+ h.bold || false, // bold
565
+ h.italic || false, // italic
566
+ bg[0], bg[1], bg[2] // background color
567
+ );
568
+ });
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Fetches latest diff data and refreshes the UI
574
+ */
575
+ async function refreshReviewData() {
576
+ if (isUpdating) return;
577
+ isUpdating = true;
578
+ editor.setStatus(editor.t("status.refreshing"));
579
+ try {
580
+ const newHunks = await getGitDiff();
581
+ newHunks.forEach(h => h.status = state.hunkStatus[h.id] || 'pending');
582
+ state.hunks = newHunks;
583
+ await updateReviewUI();
584
+ editor.setStatus(editor.t("status.updated", { count: String(state.hunks.length) }));
585
+ } catch (e) {
586
+ editor.debug(`ReviewDiff Error: ${e}`);
587
+ } finally {
588
+ isUpdating = false;
589
+ }
590
+ }
591
+
592
+ // --- Actions ---
593
+
594
+ globalThis.review_stage_hunk = async () => {
595
+ const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
596
+ if (props.length > 0 && props[0].hunkId) {
597
+ const id = props[0].hunkId as string;
598
+ state.hunkStatus[id] = 'staged';
599
+ const h = state.hunks.find(x => x.id === id);
600
+ if (h) h.status = 'staged';
601
+ await updateReviewUI();
602
+ }
603
+ };
604
+
605
+ globalThis.review_discard_hunk = async () => {
606
+ const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
607
+ if (props.length > 0 && props[0].hunkId) {
608
+ const id = props[0].hunkId as string;
609
+ state.hunkStatus[id] = 'discarded';
610
+ const h = state.hunks.find(x => x.id === id);
611
+ if (h) h.status = 'discarded';
612
+ await updateReviewUI();
613
+ }
614
+ };
615
+
616
+ globalThis.review_undo_action = async () => {
617
+ const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
618
+ if (props.length > 0 && props[0].hunkId) {
619
+ const id = props[0].hunkId as string;
620
+ state.hunkStatus[id] = 'pending';
621
+ const h = state.hunks.find(x => x.id === id);
622
+ if (h) h.status = 'pending';
623
+ await updateReviewUI();
624
+ }
625
+ };
626
+
627
+ globalThis.review_next_hunk = () => {
628
+ const bid = editor.getActiveBufferId();
629
+ const props = editor.getTextPropertiesAtCursor(bid);
630
+ let cur = -1;
631
+ if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
632
+ if (cur + 1 < state.hunks.length) editor.setBufferCursor(bid, state.hunks[cur + 1].byteOffset);
633
+ };
634
+
635
+ globalThis.review_prev_hunk = () => {
636
+ const bid = editor.getActiveBufferId();
637
+ const props = editor.getTextPropertiesAtCursor(bid);
638
+ let cur = state.hunks.length;
639
+ if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
640
+ if (cur - 1 >= 0) editor.setBufferCursor(bid, state.hunks[cur - 1].byteOffset);
641
+ };
642
+
643
+ globalThis.review_refresh = () => { refreshReviewData(); };
644
+
645
+ let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
646
+
647
+ /**
648
+ * Find line number for a given byte offset using binary search
649
+ */
650
+ function findLineForByte(lineByteOffsets: number[], topByte: number): number {
651
+ let low = 0;
652
+ let high = lineByteOffsets.length - 1;
653
+ while (low < high) {
654
+ const mid = Math.floor((low + high + 1) / 2);
655
+ if (lineByteOffsets[mid] <= topByte) {
656
+ low = mid;
657
+ } else {
658
+ high = mid - 1;
659
+ }
660
+ }
661
+ return low;
662
+ }
663
+
664
+ globalThis.on_viewport_changed = (data: any) => {
665
+ // This handler is now a no-op - scroll sync is handled by the core
666
+ // using the anchor-based ScrollSyncGroup system.
667
+ // Keeping the handler for backward compatibility if core sync fails.
668
+ if (!activeDiffViewState || !activeSideBySideState) return;
669
+
670
+ // Skip if core scroll sync is active (we have a scrollSyncGroupId)
671
+ if (activeSideBySideState.scrollSyncGroupId !== null) return;
672
+
673
+ const { oldSplitId, newSplitId, oldLineByteOffsets, newLineByteOffsets } = activeSideBySideState;
674
+
675
+ if (data.split_id === oldSplitId && newLineByteOffsets.length > 0) {
676
+ // OLD pane scrolled - find which line it's on and sync NEW pane to same line
677
+ const lineNum = findLineForByte(oldLineByteOffsets, data.top_byte);
678
+ const targetByte = newLineByteOffsets[Math.min(lineNum, newLineByteOffsets.length - 1)];
679
+ (editor as any).setSplitScroll(newSplitId, targetByte);
680
+ } else if (data.split_id === newSplitId && oldLineByteOffsets.length > 0) {
681
+ // NEW pane scrolled - find which line it's on and sync OLD pane to same line
682
+ const lineNum = findLineForByte(newLineByteOffsets, data.top_byte);
683
+ const targetByte = oldLineByteOffsets[Math.min(lineNum, oldLineByteOffsets.length - 1)];
684
+ (editor as any).setSplitScroll(oldSplitId, targetByte);
685
+ }
686
+ };
687
+
688
+ /**
689
+ * Represents an aligned line pair for side-by-side diff display
690
+ */
691
+ interface AlignedLine {
692
+ oldLine: string | null; // null means filler line
693
+ newLine: string | null; // null means filler line
694
+ oldLineNum: number | null;
695
+ newLineNum: number | null;
696
+ changeType: 'unchanged' | 'added' | 'removed' | 'modified';
697
+ }
698
+
699
+ /**
700
+ * Parse git diff and compute fully aligned line pairs for side-by-side display.
701
+ * Shows the complete files with proper alignment through all hunks.
702
+ */
703
+ function computeFullFileAlignedDiff(oldContent: string, newContent: string, hunks: Hunk[]): AlignedLine[] {
704
+ const oldLines = oldContent.split('\n');
705
+ const newLines = newContent.split('\n');
706
+ const aligned: AlignedLine[] = [];
707
+
708
+ // Build a map of changes from all hunks for this file
709
+ // Key: old line number (1-based), Value: { type, newLineNum, content }
710
+ interface ChangeInfo {
711
+ type: 'removed' | 'added' | 'modified' | 'context';
712
+ oldContent?: string;
713
+ newContent?: string;
714
+ newLineNum?: number;
715
+ }
716
+
717
+ // Parse all hunks for this file
718
+ const allHunkChanges: { oldStart: number, newStart: number, changes: { type: 'add' | 'remove' | 'context', content: string }[] }[] = [];
719
+ for (const hunk of hunks) {
720
+ const changes: { type: 'add' | 'remove' | 'context', content: string }[] = [];
721
+ for (const line of hunk.lines) {
722
+ if (line.startsWith('+')) {
723
+ changes.push({ type: 'add', content: line.substring(1) });
724
+ } else if (line.startsWith('-')) {
725
+ changes.push({ type: 'remove', content: line.substring(1) });
726
+ } else if (line.startsWith(' ')) {
727
+ changes.push({ type: 'context', content: line.substring(1) });
728
+ }
729
+ }
730
+ allHunkChanges.push({
731
+ oldStart: hunk.oldRange.start,
732
+ newStart: hunk.range.start,
733
+ changes
734
+ });
735
+ }
736
+
737
+ // Sort hunks by old line start
738
+ allHunkChanges.sort((a, b) => a.oldStart - b.oldStart);
739
+
740
+ // Process the file line by line
741
+ let oldIdx = 0; // 0-based index into oldLines
742
+ let newIdx = 0; // 0-based index into newLines
743
+ let hunkIdx = 0;
744
+
745
+ while (oldIdx < oldLines.length || newIdx < newLines.length || hunkIdx < allHunkChanges.length) {
746
+ // Check if we're at a hunk boundary
747
+ const currentHunk = hunkIdx < allHunkChanges.length ? allHunkChanges[hunkIdx] : null;
748
+
749
+ if (currentHunk && oldIdx + 1 === currentHunk.oldStart) {
750
+ // Process this hunk
751
+ let changeIdx = 0;
752
+ while (changeIdx < currentHunk.changes.length) {
753
+ const change = currentHunk.changes[changeIdx];
754
+
755
+ if (change.type === 'context') {
756
+ aligned.push({
757
+ oldLine: oldLines[oldIdx],
758
+ newLine: newLines[newIdx],
759
+ oldLineNum: oldIdx + 1,
760
+ newLineNum: newIdx + 1,
761
+ changeType: 'unchanged'
762
+ });
763
+ oldIdx++;
764
+ newIdx++;
765
+ changeIdx++;
766
+ } else if (change.type === 'remove') {
767
+ // Look ahead to see if next is an 'add' (modification)
768
+ if (changeIdx + 1 < currentHunk.changes.length &&
769
+ currentHunk.changes[changeIdx + 1].type === 'add') {
770
+ // Modified line
771
+ aligned.push({
772
+ oldLine: oldLines[oldIdx],
773
+ newLine: newLines[newIdx],
774
+ oldLineNum: oldIdx + 1,
775
+ newLineNum: newIdx + 1,
776
+ changeType: 'modified'
777
+ });
778
+ oldIdx++;
779
+ newIdx++;
780
+ changeIdx += 2;
781
+ } else {
782
+ // Pure removal
783
+ aligned.push({
784
+ oldLine: oldLines[oldIdx],
785
+ newLine: null,
786
+ oldLineNum: oldIdx + 1,
787
+ newLineNum: null,
788
+ changeType: 'removed'
789
+ });
790
+ oldIdx++;
791
+ changeIdx++;
792
+ }
793
+ } else if (change.type === 'add') {
794
+ // Pure addition
795
+ aligned.push({
796
+ oldLine: null,
797
+ newLine: newLines[newIdx],
798
+ oldLineNum: null,
799
+ newLineNum: newIdx + 1,
800
+ changeType: 'added'
801
+ });
802
+ newIdx++;
803
+ changeIdx++;
804
+ }
805
+ }
806
+ hunkIdx++;
807
+ } else if (oldIdx < oldLines.length && newIdx < newLines.length) {
808
+ // Not in a hunk - add unchanged line
809
+ aligned.push({
810
+ oldLine: oldLines[oldIdx],
811
+ newLine: newLines[newIdx],
812
+ oldLineNum: oldIdx + 1,
813
+ newLineNum: newIdx + 1,
814
+ changeType: 'unchanged'
815
+ });
816
+ oldIdx++;
817
+ newIdx++;
818
+ } else if (oldIdx < oldLines.length) {
819
+ // Only old lines left (shouldn't happen normally)
820
+ aligned.push({
821
+ oldLine: oldLines[oldIdx],
822
+ newLine: null,
823
+ oldLineNum: oldIdx + 1,
824
+ newLineNum: null,
825
+ changeType: 'removed'
826
+ });
827
+ oldIdx++;
828
+ } else if (newIdx < newLines.length) {
829
+ // Only new lines left
830
+ aligned.push({
831
+ oldLine: null,
832
+ newLine: newLines[newIdx],
833
+ oldLineNum: null,
834
+ newLineNum: newIdx + 1,
835
+ changeType: 'added'
836
+ });
837
+ newIdx++;
838
+ } else {
839
+ break;
840
+ }
841
+ }
842
+
843
+ return aligned;
844
+ }
845
+
846
+ /**
847
+ * Generate virtual buffer content with diff highlighting for one side.
848
+ * Returns entries, highlight tasks, and line byte offsets for scroll sync.
849
+ */
850
+ function generateDiffPaneContent(
851
+ alignedLines: AlignedLine[],
852
+ side: 'old' | 'new'
853
+ ): { entries: TextPropertyEntry[], highlights: HighlightTask[], lineByteOffsets: number[] } {
854
+ const entries: TextPropertyEntry[] = [];
855
+ const highlights: HighlightTask[] = [];
856
+ const lineByteOffsets: number[] = [];
857
+ let currentByte = 0;
858
+
859
+ for (const line of alignedLines) {
860
+ lineByteOffsets.push(currentByte);
861
+ const content = side === 'old' ? line.oldLine : line.newLine;
862
+ const lineNum = side === 'old' ? line.oldLineNum : line.newLineNum;
863
+ const isFiller = content === null;
864
+
865
+ // Format: "│ NNN │ content" or "│ │ ~~~~~~~~" for filler
866
+ let lineNumStr: string;
867
+ if (lineNum !== null) {
868
+ lineNumStr = lineNum.toString().padStart(4, ' ');
869
+ } else {
870
+ lineNumStr = ' ';
871
+ }
872
+
873
+ // Gutter marker based on change type
874
+ let gutterMarker = ' ';
875
+ if (line.changeType === 'added' && side === 'new') gutterMarker = '+';
876
+ else if (line.changeType === 'removed' && side === 'old') gutterMarker = '-';
877
+ else if (line.changeType === 'modified') gutterMarker = '~';
878
+
879
+ let lineText: string;
880
+ if (isFiller) {
881
+ // Filler line for alignment
882
+ lineText = `│${gutterMarker}${lineNumStr} │ ${"░".repeat(40)}\n`;
883
+ } else {
884
+ lineText = `│${gutterMarker}${lineNumStr} │ ${content}\n`;
885
+ }
886
+
887
+ const lineLen = getByteLength(lineText);
888
+ const prefixLen = getByteLength(`│${gutterMarker}${lineNumStr} │ `);
889
+
890
+ entries.push({
891
+ text: lineText,
892
+ properties: {
893
+ type: 'diff-line',
894
+ changeType: line.changeType,
895
+ lineNum: lineNum,
896
+ side: side
897
+ }
898
+ });
899
+
900
+ // Apply colors based on change type
901
+ // Border color
902
+ highlights.push({ range: [currentByte, currentByte + 1], fg: STYLE_BORDER });
903
+ highlights.push({ range: [currentByte + prefixLen - 3, currentByte + prefixLen - 1], fg: STYLE_BORDER });
904
+
905
+ // Line number color
906
+ highlights.push({
907
+ range: [currentByte + 2, currentByte + 6],
908
+ fg: [120, 120, 120] // Gray line numbers
909
+ });
910
+
911
+ if (isFiller) {
912
+ // Filler styling - extend to full line width
913
+ highlights.push({
914
+ range: [currentByte + prefixLen, currentByte + lineLen - 1],
915
+ fg: [60, 60, 60],
916
+ bg: [30, 30, 30],
917
+ extend_to_line_end: true
918
+ });
919
+ } else if (line.changeType === 'added' && side === 'new') {
920
+ // Added line (green) - extend to full line width
921
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_ADD_TEXT, bold: true }); // gutter marker
922
+ highlights.push({
923
+ range: [currentByte + prefixLen, currentByte + lineLen - 1],
924
+ fg: STYLE_ADD_TEXT,
925
+ bg: [30, 50, 30],
926
+ extend_to_line_end: true
927
+ });
928
+ } else if (line.changeType === 'removed' && side === 'old') {
929
+ // Removed line (red) - extend to full line width
930
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_REMOVE_TEXT, bold: true }); // gutter marker
931
+ highlights.push({
932
+ range: [currentByte + prefixLen, currentByte + lineLen - 1],
933
+ fg: STYLE_REMOVE_TEXT,
934
+ bg: [50, 30, 30],
935
+ extend_to_line_end: true
936
+ });
937
+ } else if (line.changeType === 'modified') {
938
+ // Modified line - show word-level diff
939
+ const oldText = line.oldLine || '';
940
+ const newText = line.newLine || '';
941
+ const diffParts = diffStrings(oldText, newText);
942
+
943
+ let offset = currentByte + prefixLen;
944
+ if (side === 'old') {
945
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_REMOVE_TEXT, bold: true });
946
+ // Highlight removed parts in old line
947
+ for (const part of diffParts) {
948
+ const partLen = getByteLength(part.text);
949
+ if (part.type === 'removed') {
950
+ highlights.push({
951
+ range: [offset, offset + partLen],
952
+ fg: STYLE_REMOVE_TEXT,
953
+ bg: STYLE_REMOVE_BG,
954
+ bold: true
955
+ });
956
+ } else if (part.type === 'unchanged') {
957
+ highlights.push({
958
+ range: [offset, offset + partLen],
959
+ fg: STYLE_REMOVE_TEXT
960
+ });
961
+ }
962
+ if (part.type !== 'added') {
963
+ offset += partLen;
964
+ }
965
+ }
966
+ } else {
967
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_ADD_TEXT, bold: true });
968
+ // Highlight added parts in new line
969
+ for (const part of diffParts) {
970
+ const partLen = getByteLength(part.text);
971
+ if (part.type === 'added') {
972
+ highlights.push({
973
+ range: [offset, offset + partLen],
974
+ fg: STYLE_ADD_TEXT,
975
+ bg: STYLE_ADD_BG,
976
+ bold: true
977
+ });
978
+ } else if (part.type === 'unchanged') {
979
+ highlights.push({
980
+ range: [offset, offset + partLen],
981
+ fg: STYLE_ADD_TEXT
982
+ });
983
+ }
984
+ if (part.type !== 'removed') {
985
+ offset += partLen;
986
+ }
987
+ }
988
+ }
989
+ }
990
+
991
+ currentByte += lineLen;
992
+ }
993
+
994
+ return { entries, highlights, lineByteOffsets };
995
+ }
996
+
997
+ // State for active side-by-side diff view
998
+ interface SideBySideDiffState {
999
+ oldSplitId: number;
1000
+ newSplitId: number;
1001
+ oldBufferId: number;
1002
+ newBufferId: number;
1003
+ alignedLines: AlignedLine[];
1004
+ oldLineByteOffsets: number[];
1005
+ newLineByteOffsets: number[];
1006
+ scrollSyncGroupId: number | null; // Core scroll sync group ID
1007
+ }
1008
+
1009
+ let activeSideBySideState: SideBySideDiffState | null = null;
1010
+ let nextScrollSyncGroupId = 1;
1011
+
1012
+ // State for composite buffer-based diff view
1013
+ interface CompositeDiffState {
1014
+ compositeBufferId: number;
1015
+ oldBufferId: number;
1016
+ newBufferId: number;
1017
+ filePath: string;
1018
+ }
1019
+
1020
+ let activeCompositeDiffState: CompositeDiffState | null = null;
1021
+
1022
+ globalThis.review_drill_down = async () => {
1023
+ const bid = editor.getActiveBufferId();
1024
+ const props = editor.getTextPropertiesAtCursor(bid);
1025
+ if (props.length > 0 && props[0].hunkId) {
1026
+ const id = props[0].hunkId as string;
1027
+ const h = state.hunks.find(x => x.id === id);
1028
+ if (!h) return;
1029
+
1030
+ editor.setStatus(editor.t("status.loading_diff"));
1031
+
1032
+ // Get all hunks for this file
1033
+ const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
1034
+
1035
+ // Get git root to construct absolute path
1036
+ const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
1037
+ if (gitRootResult.exit_code !== 0) {
1038
+ editor.setStatus(editor.t("status.not_git_repo"));
1039
+ return;
1040
+ }
1041
+ const gitRoot = gitRootResult.stdout.trim();
1042
+ const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
1043
+
1044
+ // Get old (HEAD) and new (working) file content
1045
+ const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
1046
+ if (gitShow.exit_code !== 0) {
1047
+ editor.setStatus(editor.t("status.failed_old_version"));
1048
+ return;
1049
+ }
1050
+ const oldContent = gitShow.stdout;
1051
+
1052
+ // Read new file content (use absolute path for readFile)
1053
+ let newContent: string;
1054
+ try {
1055
+ newContent = await editor.readFile(absoluteFilePath);
1056
+ } catch (e) {
1057
+ editor.setStatus(editor.t("status.failed_new_version"));
1058
+ return;
1059
+ }
1060
+
1061
+ // Close any existing side-by-side views (old split-based approach)
1062
+ if (activeSideBySideState) {
1063
+ try {
1064
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1065
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1066
+ }
1067
+ editor.closeBuffer(activeSideBySideState.oldBufferId);
1068
+ editor.closeBuffer(activeSideBySideState.newBufferId);
1069
+ } catch {}
1070
+ activeSideBySideState = null;
1071
+ }
1072
+
1073
+ // Close any existing composite diff view
1074
+ if (activeCompositeDiffState) {
1075
+ try {
1076
+ editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId);
1077
+ editor.closeBuffer(activeCompositeDiffState.oldBufferId);
1078
+ editor.closeBuffer(activeCompositeDiffState.newBufferId);
1079
+ } catch {}
1080
+ activeCompositeDiffState = null;
1081
+ }
1082
+
1083
+ // Create virtual buffers for old and new content
1084
+ const oldLines = oldContent.split('\n');
1085
+ const newLines = newContent.split('\n');
1086
+
1087
+ const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
1088
+ text: line + '\n',
1089
+ properties: { type: 'line', lineNum: idx + 1 }
1090
+ }));
1091
+
1092
+ const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
1093
+ text: line + '\n',
1094
+ properties: { type: 'line', lineNum: idx + 1 }
1095
+ }));
1096
+
1097
+ // Create source buffers (hidden from tabs, used by composite)
1098
+ const oldBufferId = await editor.createVirtualBuffer({
1099
+ name: `*OLD:${h.file}*`,
1100
+ mode: "normal",
1101
+ read_only: true,
1102
+ entries: oldEntries,
1103
+ show_line_numbers: true,
1104
+ editing_disabled: true,
1105
+ hidden_from_tabs: true
1106
+ });
1107
+
1108
+ const newBufferId = await editor.createVirtualBuffer({
1109
+ name: `*NEW:${h.file}*`,
1110
+ mode: "normal",
1111
+ read_only: true,
1112
+ entries: newEntries,
1113
+ show_line_numbers: true,
1114
+ editing_disabled: true,
1115
+ hidden_from_tabs: true
1116
+ });
1117
+
1118
+ // Convert hunks to composite buffer format (parse counts from git diff)
1119
+ const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => {
1120
+ // Parse actual counts from the hunk lines
1121
+ let oldCount = 0, newCount = 0;
1122
+ for (const line of fh.lines) {
1123
+ if (line.startsWith('-')) oldCount++;
1124
+ else if (line.startsWith('+')) newCount++;
1125
+ else if (line.startsWith(' ')) { oldCount++; newCount++; }
1126
+ }
1127
+ return {
1128
+ old_start: fh.oldRange.start - 1, // Convert to 0-indexed
1129
+ old_count: oldCount || 1,
1130
+ new_start: fh.range.start - 1, // Convert to 0-indexed
1131
+ new_count: newCount || 1
1132
+ };
1133
+ });
1134
+
1135
+ // Create composite buffer with side-by-side layout
1136
+ const compositeBufferId = await editor.createCompositeBuffer({
1137
+ name: `*Diff: ${h.file}*`,
1138
+ mode: "diff-view",
1139
+ layout: {
1140
+ layout_type: "side-by-side",
1141
+ ratios: [0.5, 0.5],
1142
+ show_separator: true
1143
+ },
1144
+ sources: [
1145
+ {
1146
+ buffer_id: oldBufferId,
1147
+ label: "OLD (HEAD)",
1148
+ editable: false,
1149
+ style: {
1150
+ remove_bg: [80, 40, 40],
1151
+ gutter_style: "diff-markers"
1152
+ }
1153
+ },
1154
+ {
1155
+ buffer_id: newBufferId,
1156
+ label: "NEW (Working)",
1157
+ editable: false,
1158
+ style: {
1159
+ add_bg: [40, 80, 40],
1160
+ gutter_style: "diff-markers"
1161
+ }
1162
+ }
1163
+ ],
1164
+ hunks: compositeHunks.length > 0 ? compositeHunks : null
1165
+ });
1166
+
1167
+ // Store state for cleanup
1168
+ activeCompositeDiffState = {
1169
+ compositeBufferId,
1170
+ oldBufferId,
1171
+ newBufferId,
1172
+ filePath: h.file
1173
+ };
1174
+
1175
+ // Show the composite buffer (replaces the review diff buffer)
1176
+ editor.showBuffer(compositeBufferId);
1177
+
1178
+ const addedCount = fileHunks.reduce((sum, fh) => {
1179
+ return sum + fh.lines.filter(l => l.startsWith('+')).length;
1180
+ }, 0);
1181
+ const removedCount = fileHunks.reduce((sum, fh) => {
1182
+ return sum + fh.lines.filter(l => l.startsWith('-')).length;
1183
+ }, 0);
1184
+ const modifiedCount = Math.min(addedCount, removedCount);
1185
+
1186
+ editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
1187
+ }
1188
+ };
1189
+
1190
+ // Define the diff-view mode - inherits from "normal" for all standard navigation/selection/copy
1191
+ // Only adds diff-specific keybindings (close, hunk navigation)
1192
+ editor.defineMode("diff-view", "normal", [
1193
+ // Close the diff view
1194
+ ["q", "close"],
1195
+ // Hunk navigation (diff-specific)
1196
+ ["n", "review_next_hunk"],
1197
+ ["p", "review_prev_hunk"],
1198
+ ["]", "review_next_hunk"],
1199
+ ["[", "review_prev_hunk"],
1200
+ ], true);
1201
+
1202
+ // --- Review Comment Actions ---
1203
+
1204
+ function getCurrentHunkId(): string | null {
1205
+ const bid = editor.getActiveBufferId();
1206
+ const props = editor.getTextPropertiesAtCursor(bid);
1207
+ if (props.length > 0 && props[0].hunkId) return props[0].hunkId as string;
1208
+ return null;
1209
+ }
1210
+
1211
+ interface PendingCommentInfo {
1212
+ hunkId: string;
1213
+ file: string;
1214
+ lineType?: 'add' | 'remove' | 'context';
1215
+ oldLine?: number;
1216
+ newLine?: number;
1217
+ lineContent?: string;
1218
+ }
1219
+
1220
+ function getCurrentLineInfo(): PendingCommentInfo | null {
1221
+ const bid = editor.getActiveBufferId();
1222
+ const props = editor.getTextPropertiesAtCursor(bid);
1223
+ if (props.length > 0 && props[0].hunkId) {
1224
+ const hunk = state.hunks.find(h => h.id === props[0].hunkId);
1225
+ return {
1226
+ hunkId: props[0].hunkId as string,
1227
+ file: (props[0].file as string) || hunk?.file || '',
1228
+ lineType: props[0].lineType as 'add' | 'remove' | 'context' | undefined,
1229
+ oldLine: props[0].oldLine as number | undefined,
1230
+ newLine: props[0].newLine as number | undefined,
1231
+ lineContent: props[0].lineContent as string | undefined
1232
+ };
1233
+ }
1234
+ return null;
1235
+ }
1236
+
1237
+ // Pending prompt state for event-based prompt handling
1238
+ let pendingCommentInfo: PendingCommentInfo | null = null;
1239
+
1240
+ globalThis.review_add_comment = async () => {
1241
+ const info = getCurrentLineInfo();
1242
+ if (!info) {
1243
+ editor.setStatus(editor.t("status.no_hunk_selected"));
1244
+ return;
1245
+ }
1246
+ pendingCommentInfo = info;
1247
+
1248
+ // Show line context in prompt (if on a specific line)
1249
+ let lineRef = 'hunk';
1250
+ if (info.lineType === 'add' && info.newLine) {
1251
+ lineRef = `+${info.newLine}`;
1252
+ } else if (info.lineType === 'remove' && info.oldLine) {
1253
+ lineRef = `-${info.oldLine}`;
1254
+ } else if (info.newLine) {
1255
+ lineRef = `L${info.newLine}`;
1256
+ } else if (info.oldLine) {
1257
+ lineRef = `L${info.oldLine}`;
1258
+ }
1259
+ editor.startPrompt(editor.t("prompt.comment", { line: lineRef }), "review-comment");
1260
+ };
1261
+
1262
+ // Prompt event handlers
1263
+ globalThis.on_review_prompt_confirm = (args: { prompt_type: string; input: string }): boolean => {
1264
+ if (args.prompt_type !== "review-comment") {
1265
+ return true; // Not our prompt
1266
+ }
1267
+ if (pendingCommentInfo && args.input && args.input.trim()) {
1268
+ const comment: ReviewComment = {
1269
+ id: `comment-${Date.now()}`,
1270
+ hunk_id: pendingCommentInfo.hunkId,
1271
+ file: pendingCommentInfo.file,
1272
+ text: args.input.trim(),
1273
+ timestamp: new Date().toISOString(),
1274
+ old_line: pendingCommentInfo.oldLine,
1275
+ new_line: pendingCommentInfo.newLine,
1276
+ line_content: pendingCommentInfo.lineContent,
1277
+ line_type: pendingCommentInfo.lineType
1278
+ };
1279
+ state.comments.push(comment);
1280
+ updateReviewUI();
1281
+ let lineRef = 'hunk';
1282
+ if (comment.line_type === 'add' && comment.new_line) {
1283
+ lineRef = `line +${comment.new_line}`;
1284
+ } else if (comment.line_type === 'remove' && comment.old_line) {
1285
+ lineRef = `line -${comment.old_line}`;
1286
+ } else if (comment.new_line) {
1287
+ lineRef = `line ${comment.new_line}`;
1288
+ } else if (comment.old_line) {
1289
+ lineRef = `line ${comment.old_line}`;
1290
+ }
1291
+ editor.setStatus(editor.t("status.comment_added", { line: lineRef }));
1292
+ }
1293
+ pendingCommentInfo = null;
1294
+ return true;
1295
+ };
1296
+
1297
+ globalThis.on_review_prompt_cancel = (args: { prompt_type: string }): boolean => {
1298
+ if (args.prompt_type === "review-comment") {
1299
+ pendingCommentInfo = null;
1300
+ editor.setStatus(editor.t("status.comment_cancelled"));
1301
+ }
1302
+ return true;
1303
+ };
1304
+
1305
+ // Register prompt event handlers
1306
+ editor.on("prompt_confirmed", "on_review_prompt_confirm");
1307
+ editor.on("prompt_cancelled", "on_review_prompt_cancel");
1308
+
1309
+ globalThis.review_approve_hunk = async () => {
1310
+ const hunkId = getCurrentHunkId();
1311
+ if (!hunkId) return;
1312
+ const h = state.hunks.find(x => x.id === hunkId);
1313
+ if (h) {
1314
+ h.reviewStatus = 'approved';
1315
+ await updateReviewUI();
1316
+ editor.setStatus(editor.t("status.hunk_approved"));
1317
+ }
1318
+ };
1319
+
1320
+ globalThis.review_reject_hunk = async () => {
1321
+ const hunkId = getCurrentHunkId();
1322
+ if (!hunkId) return;
1323
+ const h = state.hunks.find(x => x.id === hunkId);
1324
+ if (h) {
1325
+ h.reviewStatus = 'rejected';
1326
+ await updateReviewUI();
1327
+ editor.setStatus(editor.t("status.hunk_rejected"));
1328
+ }
1329
+ };
1330
+
1331
+ globalThis.review_needs_changes = async () => {
1332
+ const hunkId = getCurrentHunkId();
1333
+ if (!hunkId) return;
1334
+ const h = state.hunks.find(x => x.id === hunkId);
1335
+ if (h) {
1336
+ h.reviewStatus = 'needs_changes';
1337
+ await updateReviewUI();
1338
+ editor.setStatus(editor.t("status.hunk_needs_changes"));
1339
+ }
1340
+ };
1341
+
1342
+ globalThis.review_question_hunk = async () => {
1343
+ const hunkId = getCurrentHunkId();
1344
+ if (!hunkId) return;
1345
+ const h = state.hunks.find(x => x.id === hunkId);
1346
+ if (h) {
1347
+ h.reviewStatus = 'question';
1348
+ await updateReviewUI();
1349
+ editor.setStatus(editor.t("status.hunk_question"));
1350
+ }
1351
+ };
1352
+
1353
+ globalThis.review_clear_status = async () => {
1354
+ const hunkId = getCurrentHunkId();
1355
+ if (!hunkId) return;
1356
+ const h = state.hunks.find(x => x.id === hunkId);
1357
+ if (h) {
1358
+ h.reviewStatus = 'pending';
1359
+ await updateReviewUI();
1360
+ editor.setStatus(editor.t("status.hunk_status_cleared"));
1361
+ }
1362
+ };
1363
+
1364
+ globalThis.review_set_overall_feedback = async () => {
1365
+ const text = await editor.prompt(editor.t("prompt.overall_feedback"), state.overallFeedback || "");
1366
+ if (text !== null) {
1367
+ state.overallFeedback = text.trim();
1368
+ editor.setStatus(text.trim() ? editor.t("status.feedback_set") : editor.t("status.feedback_cleared"));
1369
+ }
1370
+ };
1371
+
1372
+ globalThis.review_export_session = async () => {
1373
+ const cwd = editor.getCwd();
1374
+ const reviewDir = editor.pathJoin(cwd, ".review");
1375
+
1376
+ // Generate markdown content (writeFile creates parent directories)
1377
+ let md = `# Code Review Session\n`;
1378
+ md += `Date: ${new Date().toISOString()}\n\n`;
1379
+
1380
+ if (state.originalRequest) {
1381
+ md += `## Original Request\n${state.originalRequest}\n\n`;
1382
+ }
1383
+
1384
+ if (state.overallFeedback) {
1385
+ md += `## Overall Feedback\n${state.overallFeedback}\n\n`;
1386
+ }
1387
+
1388
+ // Stats
1389
+ const approved = state.hunks.filter(h => h.reviewStatus === 'approved').length;
1390
+ const rejected = state.hunks.filter(h => h.reviewStatus === 'rejected').length;
1391
+ const needsChanges = state.hunks.filter(h => h.reviewStatus === 'needs_changes').length;
1392
+ const questions = state.hunks.filter(h => h.reviewStatus === 'question').length;
1393
+ md += `## Summary\n`;
1394
+ md += `- Total hunks: ${state.hunks.length}\n`;
1395
+ md += `- Approved: ${approved}\n`;
1396
+ md += `- Rejected: ${rejected}\n`;
1397
+ md += `- Needs changes: ${needsChanges}\n`;
1398
+ md += `- Questions: ${questions}\n\n`;
1399
+
1400
+ // Group by file
1401
+ const fileGroups: Record<string, Hunk[]> = {};
1402
+ for (const hunk of state.hunks) {
1403
+ if (!fileGroups[hunk.file]) fileGroups[hunk.file] = [];
1404
+ fileGroups[hunk.file].push(hunk);
1405
+ }
1406
+
1407
+ for (const [file, hunks] of Object.entries(fileGroups)) {
1408
+ md += `## File: ${file}\n\n`;
1409
+ for (const hunk of hunks) {
1410
+ const statusStr = hunk.reviewStatus.toUpperCase();
1411
+ md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
1412
+ md += `**Status**: ${statusStr}\n\n`;
1413
+
1414
+ const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1415
+ if (hunkComments.length > 0) {
1416
+ md += `**Comments:**\n`;
1417
+ for (const c of hunkComments) {
1418
+ // Format line reference
1419
+ let lineRef = '';
1420
+ if (c.line_type === 'add' && c.new_line) {
1421
+ lineRef = `[+${c.new_line}]`;
1422
+ } else if (c.line_type === 'remove' && c.old_line) {
1423
+ lineRef = `[-${c.old_line}]`;
1424
+ } else if (c.new_line) {
1425
+ lineRef = `[L${c.new_line}]`;
1426
+ } else if (c.old_line) {
1427
+ lineRef = `[L${c.old_line}]`;
1428
+ }
1429
+ md += `> 💬 ${lineRef} ${c.text}\n`;
1430
+ if (c.line_content) {
1431
+ md += `> \`${c.line_content.trim()}\`\n`;
1432
+ }
1433
+ md += `\n`;
1434
+ }
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ // Write file
1440
+ const filePath = editor.pathJoin(reviewDir, "session.md");
1441
+ await editor.writeFile(filePath, md);
1442
+ editor.setStatus(editor.t("status.exported", { path: filePath }));
1443
+ };
1444
+
1445
+ globalThis.review_export_json = async () => {
1446
+ const cwd = editor.getCwd();
1447
+ const reviewDir = editor.pathJoin(cwd, ".review");
1448
+ // writeFile creates parent directories
1449
+
1450
+ const session = {
1451
+ version: "1.0",
1452
+ timestamp: new Date().toISOString(),
1453
+ original_request: state.originalRequest || null,
1454
+ overall_feedback: state.overallFeedback || null,
1455
+ files: {} as Record<string, any>
1456
+ };
1457
+
1458
+ for (const hunk of state.hunks) {
1459
+ if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
1460
+ const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
1461
+ session.files[hunk.file].hunks.push({
1462
+ context: hunk.contextHeader,
1463
+ old_lines: [hunk.oldRange.start, hunk.oldRange.end],
1464
+ new_lines: [hunk.range.start, hunk.range.end],
1465
+ status: hunk.reviewStatus,
1466
+ comments: hunkComments.map(c => ({
1467
+ text: c.text,
1468
+ line_type: c.line_type || null,
1469
+ old_line: c.old_line || null,
1470
+ new_line: c.new_line || null,
1471
+ line_content: c.line_content || null
1472
+ }))
1473
+ });
1474
+ }
1475
+
1476
+ const filePath = editor.pathJoin(reviewDir, "session.json");
1477
+ await editor.writeFile(filePath, JSON.stringify(session, null, 2));
1478
+ editor.setStatus(editor.t("status.exported", { path: filePath }));
1479
+ };
1480
+
1481
+ globalThis.start_review_diff = async () => {
1482
+ editor.setStatus(editor.t("status.generating"));
1483
+ editor.setContext("review-mode", true);
1484
+
1485
+ // Initial data fetch
1486
+ const newHunks = await getGitDiff();
1487
+ state.hunks = newHunks;
1488
+ state.comments = []; // Reset comments for new session
1489
+
1490
+ const bufferId = await VirtualBufferFactory.create({
1491
+ name: "*Review Diff*", mode: "review-mode", read_only: true,
1492
+ entries: (await renderReviewStream()).entries, showLineNumbers: false
1493
+ });
1494
+ state.reviewBufferId = bufferId;
1495
+ await updateReviewUI(); // Apply initial highlights
1496
+
1497
+ editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
1498
+ editor.on("buffer_activated", "on_review_buffer_activated");
1499
+ editor.on("buffer_closed", "on_review_buffer_closed");
1500
+ };
1501
+
1502
+ globalThis.stop_review_diff = () => {
1503
+ state.reviewBufferId = null;
1504
+ editor.setContext("review-mode", false);
1505
+ editor.off("buffer_activated", "on_review_buffer_activated");
1506
+ editor.off("buffer_closed", "on_review_buffer_closed");
1507
+ editor.setStatus(editor.t("status.stopped"));
1508
+ };
1509
+
1510
+
1511
+ globalThis.on_review_buffer_activated = (data: any) => {
1512
+ if (data.buffer_id === state.reviewBufferId) refreshReviewData();
1513
+ };
1514
+
1515
+ globalThis.on_review_buffer_closed = (data: any) => {
1516
+ if (data.buffer_id === state.reviewBufferId) stop_review_diff();
1517
+ };
1518
+
1519
+ // Side-by-side diff for current file using composite buffers
1520
+ globalThis.side_by_side_diff_current_file = async () => {
1521
+ const bid = editor.getActiveBufferId();
1522
+ const absolutePath = editor.getBufferPath(bid);
1523
+
1524
+ if (!absolutePath) {
1525
+ editor.setStatus(editor.t("status.no_file_open"));
1526
+ return;
1527
+ }
1528
+
1529
+ editor.setStatus(editor.t("status.loading_diff"));
1530
+
1531
+ // Get the file's directory and name for running git commands
1532
+ const fileDir = editor.pathDirname(absolutePath);
1533
+ const fileName = editor.pathBasename(absolutePath);
1534
+
1535
+ // Run git commands from the file's directory to avoid path format issues on Windows
1536
+ const gitRootResult = await editor.spawnProcess("git", ["-C", fileDir, "rev-parse", "--show-toplevel"]);
1537
+ if (gitRootResult.exit_code !== 0) {
1538
+ editor.setStatus(editor.t("status.not_git_repo"));
1539
+ return;
1540
+ }
1541
+ const gitRoot = gitRootResult.stdout.trim();
1542
+
1543
+ // Get relative path from git root using git itself (handles Windows paths correctly)
1544
+ const relPathResult = await editor.spawnProcess("git", ["-C", fileDir, "ls-files", "--full-name", fileName]);
1545
+ let filePath: string;
1546
+ if (relPathResult.exit_code === 0 && relPathResult.stdout.trim()) {
1547
+ filePath = relPathResult.stdout.trim();
1548
+ } else {
1549
+ // File might be untracked, compute relative path manually
1550
+ // Normalize paths: replace backslashes with forward slashes for comparison
1551
+ const normAbsPath = absolutePath.replace(/\\/g, '/');
1552
+ const normGitRoot = gitRoot.replace(/\\/g, '/');
1553
+ if (normAbsPath.toLowerCase().startsWith(normGitRoot.toLowerCase())) {
1554
+ filePath = normAbsPath.substring(normGitRoot.length + 1);
1555
+ } else {
1556
+ // Fallback to just the filename
1557
+ filePath = fileName;
1558
+ }
1559
+ }
1560
+
1561
+ // Get hunks for this specific file (use -C gitRoot since filePath is relative to git root)
1562
+ const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "HEAD", "--unified=3", "--", filePath]);
1563
+ if (result.exit_code !== 0) {
1564
+ editor.setStatus(editor.t("status.failed_git_diff"));
1565
+ return;
1566
+ }
1567
+
1568
+ // Parse hunks from diff output
1569
+ const lines = result.stdout.split('\n');
1570
+ const fileHunks: Hunk[] = [];
1571
+ let currentHunk: Hunk | null = null;
1572
+
1573
+ for (const line of lines) {
1574
+ if (line.startsWith('@@')) {
1575
+ const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)/);
1576
+ if (match) {
1577
+ const oldStart = parseInt(match[1]);
1578
+ const oldCount = match[2] ? parseInt(match[2]) : 1;
1579
+ const newStart = parseInt(match[3]);
1580
+ const newCount = match[4] ? parseInt(match[4]) : 1;
1581
+ currentHunk = {
1582
+ id: `${filePath}:${newStart}`,
1583
+ file: filePath,
1584
+ range: { start: newStart, end: newStart + newCount - 1 },
1585
+ oldRange: { start: oldStart, end: oldStart + oldCount - 1 },
1586
+ type: 'modify',
1587
+ lines: [],
1588
+ status: 'pending',
1589
+ reviewStatus: 'pending',
1590
+ contextHeader: match[5]?.trim() || "",
1591
+ byteOffset: 0
1592
+ };
1593
+ fileHunks.push(currentHunk);
1594
+ }
1595
+ } else if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
1596
+ if (!line.startsWith('---') && !line.startsWith('+++')) {
1597
+ currentHunk.lines.push(line);
1598
+ }
1599
+ }
1600
+ }
1601
+
1602
+ if (fileHunks.length === 0) {
1603
+ editor.setStatus(editor.t("status.no_changes"));
1604
+ return;
1605
+ }
1606
+
1607
+ // Get old (HEAD) and new (working) file content (use -C gitRoot since filePath is relative to git root)
1608
+ const gitShow = await editor.spawnProcess("git", ["-C", gitRoot, "show", `HEAD:${filePath}`]);
1609
+ if (gitShow.exit_code !== 0) {
1610
+ editor.setStatus(editor.t("status.failed_old_new_file"));
1611
+ return;
1612
+ }
1613
+ const oldContent = gitShow.stdout;
1614
+
1615
+ // Read new file content (use absolute path for readFile)
1616
+ let newContent: string;
1617
+ try {
1618
+ newContent = await editor.readFile(absolutePath);
1619
+ } catch (e) {
1620
+ editor.setStatus(editor.t("status.failed_new_version"));
1621
+ return;
1622
+ }
1623
+
1624
+ // Close any existing side-by-side views
1625
+ if (activeSideBySideState) {
1626
+ try {
1627
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1628
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1629
+ }
1630
+ editor.closeBuffer(activeSideBySideState.oldBufferId);
1631
+ editor.closeBuffer(activeSideBySideState.newBufferId);
1632
+ } catch {}
1633
+ activeSideBySideState = null;
1634
+ }
1635
+
1636
+ // Close any existing composite diff view
1637
+ if (activeCompositeDiffState) {
1638
+ try {
1639
+ editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId);
1640
+ editor.closeBuffer(activeCompositeDiffState.oldBufferId);
1641
+ editor.closeBuffer(activeCompositeDiffState.newBufferId);
1642
+ } catch {}
1643
+ activeCompositeDiffState = null;
1644
+ }
1645
+
1646
+ // Create virtual buffers for old and new content
1647
+ const oldLines = oldContent.split('\n');
1648
+ const newLines = newContent.split('\n');
1649
+
1650
+ const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
1651
+ text: line + '\n',
1652
+ properties: { type: 'line', lineNum: idx + 1 }
1653
+ }));
1654
+
1655
+ const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
1656
+ text: line + '\n',
1657
+ properties: { type: 'line', lineNum: idx + 1 }
1658
+ }));
1659
+
1660
+ // Create source buffers (hidden from tabs, used by composite)
1661
+ const oldBufferId = await editor.createVirtualBuffer({
1662
+ name: `*OLD:${filePath}*`,
1663
+ mode: "normal",
1664
+ read_only: true,
1665
+ entries: oldEntries,
1666
+ show_line_numbers: true,
1667
+ editing_disabled: true,
1668
+ hidden_from_tabs: true
1669
+ });
1670
+
1671
+ const newBufferId = await editor.createVirtualBuffer({
1672
+ name: `*NEW:${filePath}*`,
1673
+ mode: "normal",
1674
+ read_only: true,
1675
+ entries: newEntries,
1676
+ show_line_numbers: true,
1677
+ editing_disabled: true,
1678
+ hidden_from_tabs: true
1679
+ });
1680
+
1681
+ // Convert hunks to composite buffer format
1682
+ const compositeHunks: TsCompositeHunk[] = fileHunks.map(h => ({
1683
+ old_start: h.oldRange.start - 1, // Convert to 0-indexed
1684
+ old_count: h.oldRange.end - h.oldRange.start + 1,
1685
+ new_start: h.range.start - 1, // Convert to 0-indexed
1686
+ new_count: h.range.end - h.range.start + 1
1687
+ }));
1688
+
1689
+ // Create composite buffer with side-by-side layout
1690
+ const compositeBufferId = await editor.createCompositeBuffer({
1691
+ name: `*Diff: ${filePath}*`,
1692
+ mode: "diff-view",
1693
+ layout: {
1694
+ layout_type: "side-by-side",
1695
+ ratios: [0.5, 0.5],
1696
+ show_separator: true
1697
+ },
1698
+ sources: [
1699
+ {
1700
+ buffer_id: oldBufferId,
1701
+ label: "OLD (HEAD)",
1702
+ editable: false,
1703
+ style: {
1704
+ remove_bg: [80, 40, 40],
1705
+ gutter_style: "diff-markers"
1706
+ }
1707
+ },
1708
+ {
1709
+ buffer_id: newBufferId,
1710
+ label: "NEW (Working)",
1711
+ editable: false,
1712
+ style: {
1713
+ add_bg: [40, 80, 40],
1714
+ gutter_style: "diff-markers"
1715
+ }
1716
+ }
1717
+ ],
1718
+ hunks: compositeHunks.length > 0 ? compositeHunks : null
1719
+ });
1720
+
1721
+ // Store state for cleanup
1722
+ activeCompositeDiffState = {
1723
+ compositeBufferId,
1724
+ oldBufferId,
1725
+ newBufferId,
1726
+ filePath
1727
+ };
1728
+
1729
+ // Show the composite buffer
1730
+ editor.showBuffer(compositeBufferId);
1731
+
1732
+ const addedCount = fileHunks.reduce((sum, h) => {
1733
+ return sum + h.lines.filter(l => l.startsWith('+')).length;
1734
+ }, 0);
1735
+ const removedCount = fileHunks.reduce((sum, h) => {
1736
+ return sum + h.lines.filter(l => l.startsWith('-')).length;
1737
+ }, 0);
1738
+ const modifiedCount = Math.min(addedCount, removedCount);
1739
+
1740
+ editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
1741
+ };
1742
+
1743
+ // Register Modes and Commands
1744
+ editor.registerCommand("%cmd.review_diff", "%cmd.review_diff_desc", "start_review_diff", "global");
1745
+ editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode");
1746
+ editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode");
1747
+ editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", "global");
1748
+
1749
+ // Review Comment Commands
1750
+ editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
1751
+ editor.registerCommand("%cmd.approve_hunk", "%cmd.approve_hunk_desc", "review_approve_hunk", "review-mode");
1752
+ editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
1753
+ editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
1754
+ editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
1755
+ editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
1756
+ editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "review_set_overall_feedback", "review-mode");
1757
+ editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
1758
+ editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
1759
+
1760
+ // Handler for when buffers are closed - cleans up scroll sync groups and composite buffers
1761
+ globalThis.on_buffer_closed = (data: any) => {
1762
+ // If one of the diff view buffers is closed, clean up the scroll sync group
1763
+ if (activeSideBySideState) {
1764
+ if (data.buffer_id === activeSideBySideState.oldBufferId ||
1765
+ data.buffer_id === activeSideBySideState.newBufferId) {
1766
+ // Remove scroll sync group
1767
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1768
+ try {
1769
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1770
+ } catch {}
1771
+ }
1772
+ activeSideBySideState = null;
1773
+ activeDiffViewState = null;
1774
+ }
1775
+ }
1776
+
1777
+ // Clean up composite diff state if the composite buffer is closed
1778
+ if (activeCompositeDiffState) {
1779
+ if (data.buffer_id === activeCompositeDiffState.compositeBufferId) {
1780
+ // Close the source buffers
1781
+ try {
1782
+ editor.closeBuffer(activeCompositeDiffState.oldBufferId);
1783
+ editor.closeBuffer(activeCompositeDiffState.newBufferId);
1784
+ } catch {}
1785
+ activeCompositeDiffState = null;
1786
+ }
1787
+ }
1788
+ };
1789
+
1790
+ editor.on("buffer_closed", "on_buffer_closed");
1791
+
1792
+ editor.defineMode("review-mode", "normal", [
1793
+ // Staging actions
1794
+ ["s", "review_stage_hunk"], ["d", "review_discard_hunk"],
1795
+ // Navigation
1796
+ ["n", "review_next_hunk"], ["p", "review_prev_hunk"], ["r", "review_refresh"],
1797
+ ["Enter", "review_drill_down"], ["q", "close"],
1798
+ // Review actions
1799
+ ["c", "review_add_comment"],
1800
+ ["a", "review_approve_hunk"],
1801
+ ["x", "review_reject_hunk"],
1802
+ ["!", "review_needs_changes"],
1803
+ ["?", "review_question_hunk"],
1804
+ ["u", "review_clear_status"],
1805
+ ["O", "review_set_overall_feedback"],
1806
+ // Export
1807
+ ["E", "review_export_session"],
1808
+ ], true);
1809
+
1810
+ editor.debug("Review Diff plugin loaded with review comments support");