@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.
- package/bin/CHANGELOG.md +1017 -0
- package/bin/LICENSE +117 -0
- package/bin/README.md +248 -0
- package/bin/fresh.exe +0 -0
- package/bin/plugins/README.md +71 -0
- package/bin/plugins/audit_mode.i18n.json +821 -0
- package/bin/plugins/audit_mode.ts +1810 -0
- package/bin/plugins/buffer_modified.i18n.json +67 -0
- package/bin/plugins/buffer_modified.ts +281 -0
- package/bin/plugins/calculator.i18n.json +93 -0
- package/bin/plugins/calculator.ts +770 -0
- package/bin/plugins/clangd-lsp.ts +168 -0
- package/bin/plugins/clangd_support.i18n.json +223 -0
- package/bin/plugins/clangd_support.md +20 -0
- package/bin/plugins/clangd_support.ts +325 -0
- package/bin/plugins/color_highlighter.i18n.json +145 -0
- package/bin/plugins/color_highlighter.ts +304 -0
- package/bin/plugins/config-schema.json +768 -0
- package/bin/plugins/csharp-lsp.ts +147 -0
- package/bin/plugins/csharp_support.i18n.json +80 -0
- package/bin/plugins/csharp_support.ts +170 -0
- package/bin/plugins/css-lsp.ts +143 -0
- package/bin/plugins/diagnostics_panel.i18n.json +236 -0
- package/bin/plugins/diagnostics_panel.ts +642 -0
- package/bin/plugins/examples/README.md +85 -0
- package/bin/plugins/examples/async_demo.ts +165 -0
- package/bin/plugins/examples/bookmarks.ts +329 -0
- package/bin/plugins/examples/buffer_query_demo.ts +110 -0
- package/bin/plugins/examples/git_grep.ts +262 -0
- package/bin/plugins/examples/hello_world.ts +93 -0
- package/bin/plugins/examples/virtual_buffer_demo.ts +116 -0
- package/bin/plugins/find_references.i18n.json +275 -0
- package/bin/plugins/find_references.ts +359 -0
- package/bin/plugins/git_blame.i18n.json +496 -0
- package/bin/plugins/git_blame.ts +707 -0
- package/bin/plugins/git_find_file.i18n.json +314 -0
- package/bin/plugins/git_find_file.ts +300 -0
- package/bin/plugins/git_grep.i18n.json +171 -0
- package/bin/plugins/git_grep.ts +191 -0
- package/bin/plugins/git_gutter.i18n.json +93 -0
- package/bin/plugins/git_gutter.ts +477 -0
- package/bin/plugins/git_log.i18n.json +481 -0
- package/bin/plugins/git_log.ts +1285 -0
- package/bin/plugins/go-lsp.ts +143 -0
- package/bin/plugins/html-lsp.ts +145 -0
- package/bin/plugins/json-lsp.ts +145 -0
- package/bin/plugins/lib/fresh.d.ts +1321 -0
- package/bin/plugins/lib/index.ts +24 -0
- package/bin/plugins/lib/navigation-controller.ts +214 -0
- package/bin/plugins/lib/panel-manager.ts +220 -0
- package/bin/plugins/lib/types.ts +72 -0
- package/bin/plugins/lib/virtual-buffer-factory.ts +130 -0
- package/bin/plugins/live_grep.i18n.json +171 -0
- package/bin/plugins/live_grep.ts +422 -0
- package/bin/plugins/markdown_compose.i18n.json +223 -0
- package/bin/plugins/markdown_compose.ts +630 -0
- package/bin/plugins/merge_conflict.i18n.json +821 -0
- package/bin/plugins/merge_conflict.ts +1810 -0
- package/bin/plugins/path_complete.i18n.json +80 -0
- package/bin/plugins/path_complete.ts +165 -0
- package/bin/plugins/python-lsp.ts +162 -0
- package/bin/plugins/rust-lsp.ts +166 -0
- package/bin/plugins/search_replace.i18n.json +405 -0
- package/bin/plugins/search_replace.ts +484 -0
- package/bin/plugins/test_i18n.i18n.json +67 -0
- package/bin/plugins/test_i18n.ts +18 -0
- package/bin/plugins/theme_editor.i18n.json +3746 -0
- package/bin/plugins/theme_editor.ts +2063 -0
- package/bin/plugins/todo_highlighter.i18n.json +184 -0
- package/bin/plugins/todo_highlighter.ts +206 -0
- package/bin/plugins/typescript-lsp.ts +167 -0
- package/bin/plugins/vi_mode.i18n.json +1549 -0
- package/bin/plugins/vi_mode.ts +2747 -0
- package/bin/plugins/welcome.i18n.json +236 -0
- package/bin/plugins/welcome.ts +76 -0
- package/bin/themes/dark.json +102 -0
- package/bin/themes/dracula.json +62 -0
- package/bin/themes/high-contrast.json +102 -0
- package/bin/themes/light.json +102 -0
- package/bin/themes/nord.json +62 -0
- package/bin/themes/nostalgia.json +102 -0
- package/bin/themes/solarized-dark.json +62 -0
- package/binary-install.js +1 -1
- package/dist/bin/fresh.js +9 -0
- package/dist/binary-install.js +149 -0
- package/dist/binary.js +30 -0
- package/dist/fresh-6yhknp07.exe +0 -0
- package/dist/install.js +158 -0
- package/dist/run-fresh.js +43 -0
- 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");
|