@fresh-editor/fresh-editor 0.1.65 → 0.1.67
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/CHANGELOG.md +48 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/audit_mode.ts +1045 -0
- package/plugins/clangd-lsp.ts +166 -0
- package/plugins/config-schema.json +54 -1
- package/plugins/csharp-lsp.ts +145 -0
- package/plugins/css-lsp.ts +141 -0
- package/plugins/go-lsp.ts +141 -0
- package/plugins/html-lsp.ts +143 -0
- package/plugins/json-lsp.ts +143 -0
- package/plugins/lib/fresh.d.ts +98 -2
- package/plugins/python-lsp.ts +160 -0
- package/plugins/rust-lsp.ts +164 -0
- package/plugins/typescript-lsp.ts +165 -0
- package/plugins/vi_mode.ts +1630 -0
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
// Review Diff Plugin
|
|
2
|
+
// Provides a unified workflow for reviewing code changes (diffs, conflicts, AI outputs).
|
|
3
|
+
|
|
4
|
+
/// <reference path="./lib/fresh.d.ts" />
|
|
5
|
+
/// <reference path="./lib/types.ts" />
|
|
6
|
+
/// <reference path="./lib/virtual-buffer-factory.ts" />
|
|
7
|
+
|
|
8
|
+
import { VirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hunk status for staging
|
|
12
|
+
*/
|
|
13
|
+
type HunkStatus = 'pending' | 'staged' | 'discarded';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Review status for a hunk
|
|
17
|
+
*/
|
|
18
|
+
type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A review comment attached to a specific line in a file
|
|
22
|
+
* Uses file line numbers (not hunk-relative) so comments survive rebases
|
|
23
|
+
*/
|
|
24
|
+
interface ReviewComment {
|
|
25
|
+
id: string;
|
|
26
|
+
hunk_id: string; // For grouping, but line numbers are primary
|
|
27
|
+
file: string; // File path
|
|
28
|
+
text: string;
|
|
29
|
+
timestamp: string;
|
|
30
|
+
// Line positioning using actual file line numbers
|
|
31
|
+
old_line?: number; // Line number in old file version (for - lines)
|
|
32
|
+
new_line?: number; // Line number in new file version (for + lines)
|
|
33
|
+
line_content?: string; // The actual line content for context/matching
|
|
34
|
+
line_type?: 'add' | 'remove' | 'context'; // Type of line
|
|
35
|
+
// Selection range (for multi-line comments)
|
|
36
|
+
selection?: {
|
|
37
|
+
start_line: number; // Start line in file
|
|
38
|
+
end_line: number; // End line in file
|
|
39
|
+
version: 'old' | 'new'; // Which file version
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A diff hunk (block of changes)
|
|
45
|
+
*/
|
|
46
|
+
interface Hunk {
|
|
47
|
+
id: string;
|
|
48
|
+
file: string;
|
|
49
|
+
range: { start: number; end: number }; // new file line range
|
|
50
|
+
oldRange: { start: number; end: number }; // old file line range
|
|
51
|
+
type: 'add' | 'remove' | 'modify';
|
|
52
|
+
lines: string[];
|
|
53
|
+
status: HunkStatus;
|
|
54
|
+
reviewStatus: ReviewStatus;
|
|
55
|
+
contextHeader: string;
|
|
56
|
+
byteOffset: number; // Position in the virtual buffer
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Review Session State
|
|
61
|
+
*/
|
|
62
|
+
interface ReviewState {
|
|
63
|
+
hunks: Hunk[];
|
|
64
|
+
hunkStatus: Record<string, HunkStatus>;
|
|
65
|
+
comments: ReviewComment[];
|
|
66
|
+
originalRequest?: string;
|
|
67
|
+
overallFeedback?: string;
|
|
68
|
+
reviewBufferId: number | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const state: ReviewState = {
|
|
72
|
+
hunks: [],
|
|
73
|
+
hunkStatus: {},
|
|
74
|
+
comments: [],
|
|
75
|
+
reviewBufferId: null,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// --- Refresh State ---
|
|
79
|
+
let isUpdating = false;
|
|
80
|
+
|
|
81
|
+
// --- Colors & Styles ---
|
|
82
|
+
const STYLE_BORDER: [number, number, number] = [70, 70, 70];
|
|
83
|
+
const STYLE_HEADER: [number, number, number] = [120, 120, 255];
|
|
84
|
+
const STYLE_FILE_NAME: [number, number, number] = [220, 220, 100];
|
|
85
|
+
const STYLE_ADD_BG: [number, number, number] = [40, 100, 40]; // Brighter Green BG
|
|
86
|
+
const STYLE_REMOVE_BG: [number, number, number] = [100, 40, 40]; // Brighter Red BG
|
|
87
|
+
const STYLE_ADD_TEXT: [number, number, number] = [150, 255, 150]; // Very Bright Green
|
|
88
|
+
const STYLE_REMOVE_TEXT: [number, number, number] = [255, 150, 150]; // Very Bright Red
|
|
89
|
+
const STYLE_STAGED: [number, number, number] = [100, 100, 100];
|
|
90
|
+
const STYLE_DISCARDED: [number, number, number] = [120, 60, 60];
|
|
91
|
+
const STYLE_COMMENT: [number, number, number] = [180, 180, 100]; // Yellow for comments
|
|
92
|
+
const STYLE_COMMENT_BORDER: [number, number, number] = [100, 100, 60];
|
|
93
|
+
const STYLE_APPROVED: [number, number, number] = [100, 200, 100]; // Green checkmark
|
|
94
|
+
const STYLE_REJECTED: [number, number, number] = [200, 100, 100]; // Red X
|
|
95
|
+
const STYLE_QUESTION: [number, number, number] = [200, 200, 100]; // Yellow ?
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Calculate UTF-8 byte length of a string manually since TextEncoder is not available
|
|
99
|
+
*/
|
|
100
|
+
function getByteLength(str: string): number {
|
|
101
|
+
let s = 0;
|
|
102
|
+
for (let i = 0; i < str.length; i++) {
|
|
103
|
+
const code = str.charCodeAt(i);
|
|
104
|
+
if (code <= 0x7f) s += 1;
|
|
105
|
+
else if (code <= 0x7ff) s += 2;
|
|
106
|
+
else if (code >= 0xd800 && code <= 0xdfff) {
|
|
107
|
+
s += 4; i++;
|
|
108
|
+
} else s += 3;
|
|
109
|
+
}
|
|
110
|
+
return s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Diff Logic ---
|
|
114
|
+
|
|
115
|
+
interface DiffPart {
|
|
116
|
+
text: string;
|
|
117
|
+
type: 'added' | 'removed' | 'unchanged';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function diffStrings(oldStr: string, newStr: string): DiffPart[] {
|
|
121
|
+
const n = oldStr.length;
|
|
122
|
+
const m = newStr.length;
|
|
123
|
+
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
124
|
+
|
|
125
|
+
for (let i = 1; i <= n; i++) {
|
|
126
|
+
for (let j = 1; j <= m; j++) {
|
|
127
|
+
if (oldStr[i - 1] === newStr[j - 1]) {
|
|
128
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
129
|
+
} else {
|
|
130
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result: DiffPart[] = [];
|
|
136
|
+
let i = n, j = m;
|
|
137
|
+
while (i > 0 || j > 0) {
|
|
138
|
+
if (i > 0 && j > 0 && oldStr[i - 1] === newStr[j - 1]) {
|
|
139
|
+
result.unshift({ text: oldStr[i - 1], type: 'unchanged' });
|
|
140
|
+
i--; j--;
|
|
141
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
142
|
+
result.unshift({ text: newStr[j - 1], type: 'added' });
|
|
143
|
+
j--;
|
|
144
|
+
} else {
|
|
145
|
+
result.unshift({ text: oldStr[i - 1], type: 'removed' });
|
|
146
|
+
i--;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const coalesced: DiffPart[] = [];
|
|
151
|
+
for (const part of result) {
|
|
152
|
+
const last = coalesced[coalesced.length - 1];
|
|
153
|
+
if (last && last.type === part.type) {
|
|
154
|
+
last.text += part.text;
|
|
155
|
+
} else {
|
|
156
|
+
coalesced.push(part);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return coalesced;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function getGitDiff(): Promise<Hunk[]> {
|
|
163
|
+
const result = await editor.spawnProcess("git", ["diff", "HEAD", "--unified=3"]);
|
|
164
|
+
if (result.exit_code !== 0) return [];
|
|
165
|
+
|
|
166
|
+
const lines = result.stdout.split('\n');
|
|
167
|
+
const hunks: Hunk[] = [];
|
|
168
|
+
let currentFile = "";
|
|
169
|
+
let currentHunk: Hunk | null = null;
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < lines.length; i++) {
|
|
172
|
+
const line = lines[i];
|
|
173
|
+
if (line.startsWith('diff --git')) {
|
|
174
|
+
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
|
|
175
|
+
if (match) {
|
|
176
|
+
currentFile = match[2];
|
|
177
|
+
currentHunk = null;
|
|
178
|
+
}
|
|
179
|
+
} else if (line.startsWith('@@')) {
|
|
180
|
+
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@(.*)/);
|
|
181
|
+
if (match && currentFile) {
|
|
182
|
+
const oldStart = parseInt(match[1]);
|
|
183
|
+
const newStart = parseInt(match[2]);
|
|
184
|
+
currentHunk = {
|
|
185
|
+
id: `${currentFile}:${newStart}`,
|
|
186
|
+
file: currentFile,
|
|
187
|
+
range: { start: newStart, end: newStart },
|
|
188
|
+
oldRange: { start: oldStart, end: oldStart },
|
|
189
|
+
type: 'modify',
|
|
190
|
+
lines: [],
|
|
191
|
+
status: 'pending',
|
|
192
|
+
reviewStatus: 'pending',
|
|
193
|
+
contextHeader: match[3]?.trim() || "",
|
|
194
|
+
byteOffset: 0
|
|
195
|
+
};
|
|
196
|
+
hunks.push(currentHunk);
|
|
197
|
+
}
|
|
198
|
+
} else if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
|
|
199
|
+
if (!line.startsWith('---') && !line.startsWith('+++')) {
|
|
200
|
+
currentHunk.lines.push(line);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return hunks;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface HighlightTask {
|
|
208
|
+
range: [number, number];
|
|
209
|
+
fg: [number, number, number];
|
|
210
|
+
bg?: [number, number, number];
|
|
211
|
+
bold?: boolean;
|
|
212
|
+
italic?: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Render the Review Stream buffer content and return highlight tasks
|
|
217
|
+
*/
|
|
218
|
+
async function renderReviewStream(): Promise<{ entries: TextPropertyEntry[], highlights: HighlightTask[] }> {
|
|
219
|
+
const entries: TextPropertyEntry[] = [];
|
|
220
|
+
const highlights: HighlightTask[] = [];
|
|
221
|
+
let currentFile = "";
|
|
222
|
+
let currentByte = 0;
|
|
223
|
+
|
|
224
|
+
// Add help header with keybindings at the TOP
|
|
225
|
+
const helpHeader = "╔══════════════════════════════════════════════════════════════════════════╗\n";
|
|
226
|
+
const helpLen0 = getByteLength(helpHeader);
|
|
227
|
+
entries.push({ text: helpHeader, properties: { type: "help" } });
|
|
228
|
+
highlights.push({ range: [currentByte, currentByte + helpLen0], fg: STYLE_COMMENT_BORDER });
|
|
229
|
+
currentByte += helpLen0;
|
|
230
|
+
|
|
231
|
+
const helpLine1 = "║ REVIEW: [c]omment [a]pprove [x]reject [!]changes [?]question [u]ndo ║\n";
|
|
232
|
+
const helpLen1 = getByteLength(helpLine1);
|
|
233
|
+
entries.push({ text: helpLine1, properties: { type: "help" } });
|
|
234
|
+
highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_COMMENT });
|
|
235
|
+
currentByte += helpLen1;
|
|
236
|
+
|
|
237
|
+
const helpLine2 = "║ STAGE: [s]tage [d]iscard | NAV: [n]ext [p]rev [Enter]drill [q]uit ║\n";
|
|
238
|
+
const helpLen2 = getByteLength(helpLine2);
|
|
239
|
+
entries.push({ text: helpLine2, properties: { type: "help" } });
|
|
240
|
+
highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
|
|
241
|
+
currentByte += helpLen2;
|
|
242
|
+
|
|
243
|
+
const helpLine3 = "║ EXPORT: [E] .review/session.md | [O]verall feedback | [r]efresh ║\n";
|
|
244
|
+
const helpLen3 = getByteLength(helpLine3);
|
|
245
|
+
entries.push({ text: helpLine3, properties: { type: "help" } });
|
|
246
|
+
highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
|
|
247
|
+
currentByte += helpLen3;
|
|
248
|
+
|
|
249
|
+
const helpFooter = "╚══════════════════════════════════════════════════════════════════════════╝\n\n";
|
|
250
|
+
const helpLen4 = getByteLength(helpFooter);
|
|
251
|
+
entries.push({ text: helpFooter, properties: { type: "help" } });
|
|
252
|
+
highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT_BORDER });
|
|
253
|
+
currentByte += helpLen4;
|
|
254
|
+
|
|
255
|
+
for (let hunkIndex = 0; hunkIndex < state.hunks.length; hunkIndex++) {
|
|
256
|
+
const hunk = state.hunks[hunkIndex];
|
|
257
|
+
if (hunk.file !== currentFile) {
|
|
258
|
+
// Header & Border
|
|
259
|
+
const titlePrefix = "┌─ ";
|
|
260
|
+
const titleLine = `${titlePrefix}${hunk.file} ${"─".repeat(Math.max(0, 60 - hunk.file.length))}\n`;
|
|
261
|
+
const titleLen = getByteLength(titleLine);
|
|
262
|
+
entries.push({ text: titleLine, properties: { type: "banner", file: hunk.file } });
|
|
263
|
+
highlights.push({ range: [currentByte, currentByte + titleLen], fg: STYLE_BORDER });
|
|
264
|
+
const prefixLen = getByteLength(titlePrefix);
|
|
265
|
+
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + getByteLength(hunk.file)], fg: STYLE_FILE_NAME, bold: true });
|
|
266
|
+
currentByte += titleLen;
|
|
267
|
+
currentFile = hunk.file;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
hunk.byteOffset = currentByte;
|
|
271
|
+
|
|
272
|
+
// Status icons: staging (left) and review (right)
|
|
273
|
+
const stagingIcon = hunk.status === 'staged' ? '✓' : (hunk.status === 'discarded' ? '✗' : ' ');
|
|
274
|
+
const reviewIcon = hunk.reviewStatus === 'approved' ? '✓' :
|
|
275
|
+
hunk.reviewStatus === 'rejected' ? '✗' :
|
|
276
|
+
hunk.reviewStatus === 'needs_changes' ? '!' :
|
|
277
|
+
hunk.reviewStatus === 'question' ? '?' : ' ';
|
|
278
|
+
const reviewLabel = hunk.reviewStatus !== 'pending' ? ` ← ${hunk.reviewStatus.toUpperCase()}` : '';
|
|
279
|
+
|
|
280
|
+
const headerPrefix = "│ ";
|
|
281
|
+
const headerText = `${headerPrefix}${stagingIcon} ${reviewIcon} [ ${hunk.contextHeader} ]${reviewLabel}\n`;
|
|
282
|
+
const headerLen = getByteLength(headerText);
|
|
283
|
+
|
|
284
|
+
let hunkColor = STYLE_HEADER;
|
|
285
|
+
if (hunk.status === 'staged') hunkColor = STYLE_STAGED;
|
|
286
|
+
else if (hunk.status === 'discarded') hunkColor = STYLE_DISCARDED;
|
|
287
|
+
|
|
288
|
+
let reviewColor = STYLE_HEADER;
|
|
289
|
+
if (hunk.reviewStatus === 'approved') reviewColor = STYLE_APPROVED;
|
|
290
|
+
else if (hunk.reviewStatus === 'rejected') reviewColor = STYLE_REJECTED;
|
|
291
|
+
else if (hunk.reviewStatus === 'needs_changes') reviewColor = STYLE_QUESTION;
|
|
292
|
+
else if (hunk.reviewStatus === 'question') reviewColor = STYLE_QUESTION;
|
|
293
|
+
|
|
294
|
+
entries.push({ text: headerText, properties: { type: "header", hunkId: hunk.id, index: hunkIndex } });
|
|
295
|
+
highlights.push({ range: [currentByte, currentByte + headerLen], fg: STYLE_BORDER });
|
|
296
|
+
const headerPrefixLen = getByteLength(headerPrefix);
|
|
297
|
+
// Staging icon
|
|
298
|
+
highlights.push({ range: [currentByte + headerPrefixLen, currentByte + headerPrefixLen + getByteLength(stagingIcon)], fg: hunkColor, bold: true });
|
|
299
|
+
// Review icon
|
|
300
|
+
highlights.push({ range: [currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1, currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon)], fg: reviewColor, bold: true });
|
|
301
|
+
// Context header
|
|
302
|
+
const contextStart = currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon) + 3;
|
|
303
|
+
highlights.push({ range: [contextStart, currentByte + headerLen - getByteLength(reviewLabel) - 2], fg: hunkColor });
|
|
304
|
+
// Review label
|
|
305
|
+
if (reviewLabel) {
|
|
306
|
+
highlights.push({ range: [currentByte + headerLen - getByteLength(reviewLabel) - 1, currentByte + headerLen - 1], fg: reviewColor, bold: true });
|
|
307
|
+
}
|
|
308
|
+
currentByte += headerLen;
|
|
309
|
+
|
|
310
|
+
// Track actual file line numbers as we iterate
|
|
311
|
+
let oldLineNum = hunk.oldRange.start;
|
|
312
|
+
let newLineNum = hunk.range.start;
|
|
313
|
+
|
|
314
|
+
for (let i = 0; i < hunk.lines.length; i++) {
|
|
315
|
+
const line = hunk.lines[i];
|
|
316
|
+
const nextLine = hunk.lines[i + 1];
|
|
317
|
+
const marker = line[0];
|
|
318
|
+
const content = line.substring(1);
|
|
319
|
+
const linePrefix = "│ ";
|
|
320
|
+
const lineText = `${linePrefix}${marker} ${content}\n`;
|
|
321
|
+
const lineLen = getByteLength(lineText);
|
|
322
|
+
const prefixLen = getByteLength(linePrefix);
|
|
323
|
+
|
|
324
|
+
// Determine line type and which line numbers apply
|
|
325
|
+
const lineType: 'add' | 'remove' | 'context' =
|
|
326
|
+
marker === '+' ? 'add' : marker === '-' ? 'remove' : 'context';
|
|
327
|
+
const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
|
|
328
|
+
const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
|
|
329
|
+
|
|
330
|
+
if (line.startsWith('-') && nextLine && nextLine.startsWith('+') && hunk.status === 'pending') {
|
|
331
|
+
const oldContent = line.substring(1);
|
|
332
|
+
const newContent = nextLine.substring(1);
|
|
333
|
+
const diffParts = diffStrings(oldContent, newContent);
|
|
334
|
+
|
|
335
|
+
// Removed
|
|
336
|
+
entries.push({ text: lineText, properties: {
|
|
337
|
+
type: "content", hunkId: hunk.id, file: hunk.file,
|
|
338
|
+
lineType: 'remove', oldLine: curOldLine, lineContent: line
|
|
339
|
+
} });
|
|
340
|
+
highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
|
|
341
|
+
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
|
|
342
|
+
|
|
343
|
+
let cbOffset = currentByte + prefixLen + 2;
|
|
344
|
+
diffParts.forEach(p => {
|
|
345
|
+
const pLen = getByteLength(p.text);
|
|
346
|
+
if (p.type === 'removed') {
|
|
347
|
+
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true });
|
|
348
|
+
cbOffset += pLen;
|
|
349
|
+
} else if (p.type === 'unchanged') {
|
|
350
|
+
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT });
|
|
351
|
+
cbOffset += pLen;
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
currentByte += lineLen;
|
|
355
|
+
|
|
356
|
+
// Added (increment old line for the removed line we just processed)
|
|
357
|
+
oldLineNum++;
|
|
358
|
+
const nextLineText = `${linePrefix}+ ${nextLine.substring(1)}\n`;
|
|
359
|
+
const nextLineLen = getByteLength(nextLineText);
|
|
360
|
+
entries.push({ text: nextLineText, properties: {
|
|
361
|
+
type: "content", hunkId: hunk.id, file: hunk.file,
|
|
362
|
+
lineType: 'add', newLine: newLineNum, lineContent: nextLine
|
|
363
|
+
} });
|
|
364
|
+
newLineNum++;
|
|
365
|
+
highlights.push({ range: [currentByte, currentByte + nextLineLen], fg: STYLE_BORDER });
|
|
366
|
+
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
|
|
367
|
+
|
|
368
|
+
cbOffset = currentByte + prefixLen + 2;
|
|
369
|
+
diffParts.forEach(p => {
|
|
370
|
+
const pLen = getByteLength(p.text);
|
|
371
|
+
if (p.type === 'added') {
|
|
372
|
+
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true });
|
|
373
|
+
cbOffset += pLen;
|
|
374
|
+
} else if (p.type === 'unchanged') {
|
|
375
|
+
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT });
|
|
376
|
+
cbOffset += pLen;
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
currentByte += nextLineLen;
|
|
380
|
+
|
|
381
|
+
// Render comments for the removed line (curOldLine before increment)
|
|
382
|
+
const removedLineComments = state.comments.filter(c =>
|
|
383
|
+
c.hunk_id === hunk.id && c.line_type === 'remove' && c.old_line === curOldLine
|
|
384
|
+
);
|
|
385
|
+
for (const comment of removedLineComments) {
|
|
386
|
+
const commentPrefix = `│ » [-${comment.old_line}] `;
|
|
387
|
+
const commentLines = comment.text.split('\n');
|
|
388
|
+
for (let ci = 0; ci < commentLines.length; ci++) {
|
|
389
|
+
const prefix = ci === 0 ? commentPrefix : "│ ";
|
|
390
|
+
const commentLine = `${prefix}${commentLines[ci]}\n`;
|
|
391
|
+
const commentLineLen = getByteLength(commentLine);
|
|
392
|
+
entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
|
|
393
|
+
highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
|
|
394
|
+
highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
|
|
395
|
+
currentByte += commentLineLen;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Render comments for the added line (newLineNum - 1, since we already incremented)
|
|
400
|
+
const addedLineComments = state.comments.filter(c =>
|
|
401
|
+
c.hunk_id === hunk.id && c.line_type === 'add' && c.new_line === (newLineNum - 1)
|
|
402
|
+
);
|
|
403
|
+
for (const comment of addedLineComments) {
|
|
404
|
+
const commentPrefix = `│ » [+${comment.new_line}] `;
|
|
405
|
+
const commentLines = comment.text.split('\n');
|
|
406
|
+
for (let ci = 0; ci < commentLines.length; ci++) {
|
|
407
|
+
const prefix = ci === 0 ? commentPrefix : "│ ";
|
|
408
|
+
const commentLine = `${prefix}${commentLines[ci]}\n`;
|
|
409
|
+
const commentLineLen = getByteLength(commentLine);
|
|
410
|
+
entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
|
|
411
|
+
highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
|
|
412
|
+
highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
|
|
413
|
+
currentByte += commentLineLen;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
i++;
|
|
418
|
+
} else {
|
|
419
|
+
entries.push({ text: lineText, properties: {
|
|
420
|
+
type: "content", hunkId: hunk.id, file: hunk.file,
|
|
421
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line
|
|
422
|
+
} });
|
|
423
|
+
highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
|
|
424
|
+
if (hunk.status === 'pending') {
|
|
425
|
+
if (line.startsWith('+')) {
|
|
426
|
+
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
|
|
427
|
+
highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_ADD_TEXT });
|
|
428
|
+
} else if (line.startsWith('-')) {
|
|
429
|
+
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
|
|
430
|
+
highlights.push({ range: [currentByte + prefixLen + 2, currentByte + lineLen], fg: STYLE_REMOVE_TEXT });
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
highlights.push({ range: [currentByte + prefixLen, currentByte + lineLen], fg: hunkColor });
|
|
434
|
+
}
|
|
435
|
+
currentByte += lineLen;
|
|
436
|
+
|
|
437
|
+
// Increment line counters based on line type
|
|
438
|
+
if (lineType === 'remove') oldLineNum++;
|
|
439
|
+
else if (lineType === 'add') newLineNum++;
|
|
440
|
+
else { oldLineNum++; newLineNum++; } // context
|
|
441
|
+
|
|
442
|
+
// Render any comments attached to this specific line
|
|
443
|
+
const lineComments = state.comments.filter(c =>
|
|
444
|
+
c.hunk_id === hunk.id && (
|
|
445
|
+
(lineType === 'remove' && c.old_line === curOldLine) ||
|
|
446
|
+
(lineType === 'add' && c.new_line === curNewLine) ||
|
|
447
|
+
(lineType === 'context' && (c.old_line === curOldLine || c.new_line === curNewLine))
|
|
448
|
+
)
|
|
449
|
+
);
|
|
450
|
+
for (const comment of lineComments) {
|
|
451
|
+
const lineRef = comment.line_type === 'add'
|
|
452
|
+
? `+${comment.new_line}`
|
|
453
|
+
: comment.line_type === 'remove'
|
|
454
|
+
? `-${comment.old_line}`
|
|
455
|
+
: `${comment.new_line}`;
|
|
456
|
+
const commentPrefix = `│ » [${lineRef}] `;
|
|
457
|
+
const commentLines = comment.text.split('\n');
|
|
458
|
+
for (let ci = 0; ci < commentLines.length; ci++) {
|
|
459
|
+
const prefix = ci === 0 ? commentPrefix : "│ ";
|
|
460
|
+
const commentLine = `${prefix}${commentLines[ci]}\n`;
|
|
461
|
+
const commentLineLen = getByteLength(commentLine);
|
|
462
|
+
entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
|
|
463
|
+
highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
|
|
464
|
+
highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
|
|
465
|
+
currentByte += commentLineLen;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Render any comments without specific line info at the end of hunk
|
|
472
|
+
const orphanComments = state.comments.filter(c =>
|
|
473
|
+
c.hunk_id === hunk.id && !c.old_line && !c.new_line
|
|
474
|
+
);
|
|
475
|
+
if (orphanComments.length > 0) {
|
|
476
|
+
const commentBorder = "│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄\n";
|
|
477
|
+
const borderLen = getByteLength(commentBorder);
|
|
478
|
+
entries.push({ text: commentBorder, properties: { type: "comment-border" } });
|
|
479
|
+
highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
|
|
480
|
+
currentByte += borderLen;
|
|
481
|
+
|
|
482
|
+
for (const comment of orphanComments) {
|
|
483
|
+
const commentPrefix = "│ » ";
|
|
484
|
+
const commentLines = comment.text.split('\n');
|
|
485
|
+
for (let ci = 0; ci < commentLines.length; ci++) {
|
|
486
|
+
const prefix = ci === 0 ? commentPrefix : "│ ";
|
|
487
|
+
const commentLine = `${prefix}${commentLines[ci]}\n`;
|
|
488
|
+
const commentLineLen = getByteLength(commentLine);
|
|
489
|
+
entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
|
|
490
|
+
highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
|
|
491
|
+
highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
|
|
492
|
+
currentByte += commentLineLen;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
entries.push({ text: commentBorder, properties: { type: "comment-border" } });
|
|
497
|
+
highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
|
|
498
|
+
currentByte += borderLen;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const isLastOfFile = hunkIndex === state.hunks.length - 1 || state.hunks[hunkIndex + 1].file !== hunk.file;
|
|
502
|
+
if (isLastOfFile) {
|
|
503
|
+
const bottomLine = `└${"─".repeat(64)}\n`;
|
|
504
|
+
const bottomLen = getByteLength(bottomLine);
|
|
505
|
+
entries.push({ text: bottomLine, properties: { type: "border" } });
|
|
506
|
+
highlights.push({ range: [currentByte, currentByte + bottomLen], fg: STYLE_BORDER });
|
|
507
|
+
currentByte += bottomLen;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (entries.length === 0) {
|
|
512
|
+
entries.push({ text: "No changes to review.\n", properties: {} });
|
|
513
|
+
} else {
|
|
514
|
+
// Add help footer with keybindings
|
|
515
|
+
const helpSeparator = "\n" + "─".repeat(70) + "\n";
|
|
516
|
+
const helpLen1 = getByteLength(helpSeparator);
|
|
517
|
+
entries.push({ text: helpSeparator, properties: { type: "help" } });
|
|
518
|
+
highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_BORDER });
|
|
519
|
+
currentByte += helpLen1;
|
|
520
|
+
|
|
521
|
+
const helpLine1 = "REVIEW: [c]omment [a]pprove [x]reject [!]needs-changes [?]question [u]ndo\n";
|
|
522
|
+
const helpLen2 = getByteLength(helpLine1);
|
|
523
|
+
entries.push({ text: helpLine1, properties: { type: "help" } });
|
|
524
|
+
highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
|
|
525
|
+
currentByte += helpLen2;
|
|
526
|
+
|
|
527
|
+
const helpLine2 = "STAGE: [s]tage [d]iscard | NAV: [n]ext [p]rev [Enter]drill-down [q]uit\n";
|
|
528
|
+
const helpLen3 = getByteLength(helpLine2);
|
|
529
|
+
entries.push({ text: helpLine2, properties: { type: "help" } });
|
|
530
|
+
highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
|
|
531
|
+
currentByte += helpLen3;
|
|
532
|
+
|
|
533
|
+
const helpLine3 = "EXPORT: [E]xport to .review/session.md | [O]verall feedback [r]efresh\n";
|
|
534
|
+
const helpLen4 = getByteLength(helpLine3);
|
|
535
|
+
entries.push({ text: helpLine3, properties: { type: "help" } });
|
|
536
|
+
highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT });
|
|
537
|
+
currentByte += helpLen4;
|
|
538
|
+
}
|
|
539
|
+
return { entries, highlights };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Updates the buffer UI (text and highlights) based on current state.hunks
|
|
544
|
+
*/
|
|
545
|
+
async function updateReviewUI() {
|
|
546
|
+
if (state.reviewBufferId !== null) {
|
|
547
|
+
const { entries, highlights } = await renderReviewStream();
|
|
548
|
+
editor.setVirtualBufferContent(state.reviewBufferId, entries);
|
|
549
|
+
|
|
550
|
+
editor.clearNamespace(state.reviewBufferId, "review-diff");
|
|
551
|
+
highlights.forEach((h) => {
|
|
552
|
+
const bg = h.bg || [-1, -1, -1];
|
|
553
|
+
// addOverlay signature: bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b
|
|
554
|
+
editor.addOverlay(
|
|
555
|
+
state.reviewBufferId!,
|
|
556
|
+
"review-diff",
|
|
557
|
+
h.range[0],
|
|
558
|
+
h.range[1],
|
|
559
|
+
h.fg[0], h.fg[1], h.fg[2], // foreground color
|
|
560
|
+
false, // underline
|
|
561
|
+
h.bold || false, // bold
|
|
562
|
+
h.italic || false, // italic
|
|
563
|
+
bg[0], bg[1], bg[2] // background color
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Fetches latest diff data and refreshes the UI
|
|
571
|
+
*/
|
|
572
|
+
async function refreshReviewData() {
|
|
573
|
+
if (isUpdating) return;
|
|
574
|
+
isUpdating = true;
|
|
575
|
+
editor.setStatus("Refreshing review diff...");
|
|
576
|
+
try {
|
|
577
|
+
const newHunks = await getGitDiff();
|
|
578
|
+
newHunks.forEach(h => h.status = state.hunkStatus[h.id] || 'pending');
|
|
579
|
+
state.hunks = newHunks;
|
|
580
|
+
await updateReviewUI();
|
|
581
|
+
editor.setStatus(`Review diff updated. Found ${state.hunks.length} hunks.`);
|
|
582
|
+
} catch (e) {
|
|
583
|
+
editor.debug(`ReviewDiff Error: ${e}`);
|
|
584
|
+
} finally {
|
|
585
|
+
isUpdating = false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// --- Actions ---
|
|
590
|
+
|
|
591
|
+
globalThis.review_stage_hunk = async () => {
|
|
592
|
+
const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
|
|
593
|
+
if (props.length > 0 && props[0].hunkId) {
|
|
594
|
+
const id = props[0].hunkId as string;
|
|
595
|
+
state.hunkStatus[id] = 'staged';
|
|
596
|
+
const h = state.hunks.find(x => x.id === id);
|
|
597
|
+
if (h) h.status = 'staged';
|
|
598
|
+
await updateReviewUI();
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
globalThis.review_discard_hunk = async () => {
|
|
603
|
+
const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
|
|
604
|
+
if (props.length > 0 && props[0].hunkId) {
|
|
605
|
+
const id = props[0].hunkId as string;
|
|
606
|
+
state.hunkStatus[id] = 'discarded';
|
|
607
|
+
const h = state.hunks.find(x => x.id === id);
|
|
608
|
+
if (h) h.status = 'discarded';
|
|
609
|
+
await updateReviewUI();
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
globalThis.review_undo_action = async () => {
|
|
614
|
+
const props = editor.getTextPropertiesAtCursor(editor.getActiveBufferId());
|
|
615
|
+
if (props.length > 0 && props[0].hunkId) {
|
|
616
|
+
const id = props[0].hunkId as string;
|
|
617
|
+
state.hunkStatus[id] = 'pending';
|
|
618
|
+
const h = state.hunks.find(x => x.id === id);
|
|
619
|
+
if (h) h.status = 'pending';
|
|
620
|
+
await updateReviewUI();
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
globalThis.review_next_hunk = () => {
|
|
625
|
+
const bid = editor.getActiveBufferId();
|
|
626
|
+
const props = editor.getTextPropertiesAtCursor(bid);
|
|
627
|
+
let cur = -1;
|
|
628
|
+
if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
|
|
629
|
+
if (cur + 1 < state.hunks.length) editor.setBufferCursor(bid, state.hunks[cur + 1].byteOffset);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
globalThis.review_prev_hunk = () => {
|
|
633
|
+
const bid = editor.getActiveBufferId();
|
|
634
|
+
const props = editor.getTextPropertiesAtCursor(bid);
|
|
635
|
+
let cur = state.hunks.length;
|
|
636
|
+
if (props.length > 0 && props[0].index !== undefined) cur = props[0].index as number;
|
|
637
|
+
if (cur - 1 >= 0) editor.setBufferCursor(bid, state.hunks[cur - 1].byteOffset);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
globalThis.review_refresh = () => { refreshReviewData(); };
|
|
641
|
+
|
|
642
|
+
let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
|
|
643
|
+
|
|
644
|
+
globalThis.on_viewport_changed = (data: any) => {
|
|
645
|
+
if (!activeDiffViewState) return;
|
|
646
|
+
if (data.split_id === activeDiffViewState.lSplit) (editor as any).setSplitScroll(activeDiffViewState.rSplit, data.top_byte);
|
|
647
|
+
else if (data.split_id === activeDiffViewState.rSplit) (editor as any).setSplitScroll(activeDiffViewState.lSplit, data.top_byte);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
globalThis.review_drill_down = async () => {
|
|
651
|
+
const bid = editor.getActiveBufferId();
|
|
652
|
+
const props = editor.getTextPropertiesAtCursor(bid);
|
|
653
|
+
if (props.length > 0 && props[0].hunkId) {
|
|
654
|
+
const id = props[0].hunkId as string;
|
|
655
|
+
const h = state.hunks.find(x => x.id === id);
|
|
656
|
+
if (!h) return;
|
|
657
|
+
const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
|
|
658
|
+
if (gitShow.exit_code !== 0) return;
|
|
659
|
+
|
|
660
|
+
// Side-by-side layout: NEW (editable, left) | OLD (read-only, right)
|
|
661
|
+
// Note: Ideally OLD should be on left per convention, but API creates splits to the right
|
|
662
|
+
|
|
663
|
+
// Step 1: Open NEW file in current split (becomes LEFT pane)
|
|
664
|
+
editor.openFile(h.file, h.range.start, 0);
|
|
665
|
+
const newSplitId = (editor as any).getActiveSplitId();
|
|
666
|
+
|
|
667
|
+
// Step 2: Create OLD (HEAD) version in new split (becomes RIGHT pane)
|
|
668
|
+
// editing_disabled: true prevents text input in read-only buffer
|
|
669
|
+
const oldRes = await editor.createVirtualBufferInSplit({
|
|
670
|
+
name: `[OLD ◀] ${h.file}`, // Arrow indicates this is the old/reference version
|
|
671
|
+
mode: "special",
|
|
672
|
+
read_only: true,
|
|
673
|
+
editing_disabled: true,
|
|
674
|
+
entries: [{ text: gitShow.stdout, properties: {} }],
|
|
675
|
+
ratio: 0.5,
|
|
676
|
+
direction: "vertical",
|
|
677
|
+
show_line_numbers: true
|
|
678
|
+
});
|
|
679
|
+
const oldSplitId = oldRes.split_id!;
|
|
680
|
+
|
|
681
|
+
// Focus on NEW (left) pane - this is the editable working version
|
|
682
|
+
(editor as any).focusSplit(newSplitId);
|
|
683
|
+
|
|
684
|
+
// Track splits for synchronized scrolling
|
|
685
|
+
activeDiffViewState = { lSplit: newSplitId, rSplit: oldSplitId };
|
|
686
|
+
editor.on("viewport_changed", "on_viewport_changed");
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// --- Review Comment Actions ---
|
|
691
|
+
|
|
692
|
+
function getCurrentHunkId(): string | null {
|
|
693
|
+
const bid = editor.getActiveBufferId();
|
|
694
|
+
const props = editor.getTextPropertiesAtCursor(bid);
|
|
695
|
+
if (props.length > 0 && props[0].hunkId) return props[0].hunkId as string;
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
interface PendingCommentInfo {
|
|
700
|
+
hunkId: string;
|
|
701
|
+
file: string;
|
|
702
|
+
lineType?: 'add' | 'remove' | 'context';
|
|
703
|
+
oldLine?: number;
|
|
704
|
+
newLine?: number;
|
|
705
|
+
lineContent?: string;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function getCurrentLineInfo(): PendingCommentInfo | null {
|
|
709
|
+
const bid = editor.getActiveBufferId();
|
|
710
|
+
const props = editor.getTextPropertiesAtCursor(bid);
|
|
711
|
+
if (props.length > 0 && props[0].hunkId) {
|
|
712
|
+
const hunk = state.hunks.find(h => h.id === props[0].hunkId);
|
|
713
|
+
return {
|
|
714
|
+
hunkId: props[0].hunkId as string,
|
|
715
|
+
file: (props[0].file as string) || hunk?.file || '',
|
|
716
|
+
lineType: props[0].lineType as 'add' | 'remove' | 'context' | undefined,
|
|
717
|
+
oldLine: props[0].oldLine as number | undefined,
|
|
718
|
+
newLine: props[0].newLine as number | undefined,
|
|
719
|
+
lineContent: props[0].lineContent as string | undefined
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Pending prompt state for event-based prompt handling
|
|
726
|
+
let pendingCommentInfo: PendingCommentInfo | null = null;
|
|
727
|
+
|
|
728
|
+
globalThis.review_add_comment = async () => {
|
|
729
|
+
const info = getCurrentLineInfo();
|
|
730
|
+
if (!info) {
|
|
731
|
+
editor.setStatus("No hunk selected for comment");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
pendingCommentInfo = info;
|
|
735
|
+
|
|
736
|
+
// Show line context in prompt (if on a specific line)
|
|
737
|
+
let lineRef = 'hunk';
|
|
738
|
+
if (info.lineType === 'add' && info.newLine) {
|
|
739
|
+
lineRef = `+${info.newLine}`;
|
|
740
|
+
} else if (info.lineType === 'remove' && info.oldLine) {
|
|
741
|
+
lineRef = `-${info.oldLine}`;
|
|
742
|
+
} else if (info.newLine) {
|
|
743
|
+
lineRef = `L${info.newLine}`;
|
|
744
|
+
} else if (info.oldLine) {
|
|
745
|
+
lineRef = `L${info.oldLine}`;
|
|
746
|
+
}
|
|
747
|
+
editor.startPrompt(`Comment on ${lineRef}: `, "review-comment");
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// Prompt event handlers
|
|
751
|
+
globalThis.on_review_prompt_confirm = (args: { prompt_type: string; input: string }): boolean => {
|
|
752
|
+
if (args.prompt_type !== "review-comment") {
|
|
753
|
+
return true; // Not our prompt
|
|
754
|
+
}
|
|
755
|
+
if (pendingCommentInfo && args.input && args.input.trim()) {
|
|
756
|
+
const comment: ReviewComment = {
|
|
757
|
+
id: `comment-${Date.now()}`,
|
|
758
|
+
hunk_id: pendingCommentInfo.hunkId,
|
|
759
|
+
file: pendingCommentInfo.file,
|
|
760
|
+
text: args.input.trim(),
|
|
761
|
+
timestamp: new Date().toISOString(),
|
|
762
|
+
old_line: pendingCommentInfo.oldLine,
|
|
763
|
+
new_line: pendingCommentInfo.newLine,
|
|
764
|
+
line_content: pendingCommentInfo.lineContent,
|
|
765
|
+
line_type: pendingCommentInfo.lineType
|
|
766
|
+
};
|
|
767
|
+
state.comments.push(comment);
|
|
768
|
+
updateReviewUI();
|
|
769
|
+
let lineRef = 'hunk';
|
|
770
|
+
if (comment.line_type === 'add' && comment.new_line) {
|
|
771
|
+
lineRef = `line +${comment.new_line}`;
|
|
772
|
+
} else if (comment.line_type === 'remove' && comment.old_line) {
|
|
773
|
+
lineRef = `line -${comment.old_line}`;
|
|
774
|
+
} else if (comment.new_line) {
|
|
775
|
+
lineRef = `line ${comment.new_line}`;
|
|
776
|
+
} else if (comment.old_line) {
|
|
777
|
+
lineRef = `line ${comment.old_line}`;
|
|
778
|
+
}
|
|
779
|
+
editor.setStatus(`Comment added to ${lineRef}`);
|
|
780
|
+
}
|
|
781
|
+
pendingCommentInfo = null;
|
|
782
|
+
return true;
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
globalThis.on_review_prompt_cancel = (args: { prompt_type: string }): boolean => {
|
|
786
|
+
if (args.prompt_type === "review-comment") {
|
|
787
|
+
pendingCommentInfo = null;
|
|
788
|
+
editor.setStatus("Comment cancelled");
|
|
789
|
+
}
|
|
790
|
+
return true;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// Register prompt event handlers
|
|
794
|
+
editor.on("prompt_confirmed", "on_review_prompt_confirm");
|
|
795
|
+
editor.on("prompt_cancelled", "on_review_prompt_cancel");
|
|
796
|
+
|
|
797
|
+
globalThis.review_approve_hunk = async () => {
|
|
798
|
+
const hunkId = getCurrentHunkId();
|
|
799
|
+
if (!hunkId) return;
|
|
800
|
+
const h = state.hunks.find(x => x.id === hunkId);
|
|
801
|
+
if (h) {
|
|
802
|
+
h.reviewStatus = 'approved';
|
|
803
|
+
await updateReviewUI();
|
|
804
|
+
editor.setStatus(`Hunk approved`);
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
globalThis.review_reject_hunk = async () => {
|
|
809
|
+
const hunkId = getCurrentHunkId();
|
|
810
|
+
if (!hunkId) return;
|
|
811
|
+
const h = state.hunks.find(x => x.id === hunkId);
|
|
812
|
+
if (h) {
|
|
813
|
+
h.reviewStatus = 'rejected';
|
|
814
|
+
await updateReviewUI();
|
|
815
|
+
editor.setStatus(`Hunk rejected`);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
globalThis.review_needs_changes = async () => {
|
|
820
|
+
const hunkId = getCurrentHunkId();
|
|
821
|
+
if (!hunkId) return;
|
|
822
|
+
const h = state.hunks.find(x => x.id === hunkId);
|
|
823
|
+
if (h) {
|
|
824
|
+
h.reviewStatus = 'needs_changes';
|
|
825
|
+
await updateReviewUI();
|
|
826
|
+
editor.setStatus(`Hunk marked as needs changes`);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
globalThis.review_question_hunk = async () => {
|
|
831
|
+
const hunkId = getCurrentHunkId();
|
|
832
|
+
if (!hunkId) return;
|
|
833
|
+
const h = state.hunks.find(x => x.id === hunkId);
|
|
834
|
+
if (h) {
|
|
835
|
+
h.reviewStatus = 'question';
|
|
836
|
+
await updateReviewUI();
|
|
837
|
+
editor.setStatus(`Hunk marked with question`);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
globalThis.review_clear_status = async () => {
|
|
842
|
+
const hunkId = getCurrentHunkId();
|
|
843
|
+
if (!hunkId) return;
|
|
844
|
+
const h = state.hunks.find(x => x.id === hunkId);
|
|
845
|
+
if (h) {
|
|
846
|
+
h.reviewStatus = 'pending';
|
|
847
|
+
await updateReviewUI();
|
|
848
|
+
editor.setStatus(`Hunk review status cleared`);
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
globalThis.review_set_overall_feedback = async () => {
|
|
853
|
+
const text = await editor.prompt("Overall feedback: ", state.overallFeedback || "");
|
|
854
|
+
if (text !== null) {
|
|
855
|
+
state.overallFeedback = text.trim();
|
|
856
|
+
editor.setStatus(`Overall feedback ${text.trim() ? 'set' : 'cleared'}`);
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
globalThis.review_export_session = async () => {
|
|
861
|
+
const cwd = editor.getCwd();
|
|
862
|
+
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
863
|
+
|
|
864
|
+
// Create .review directory if needed
|
|
865
|
+
await editor.spawnProcess("mkdir", ["-p", reviewDir]);
|
|
866
|
+
|
|
867
|
+
// Generate markdown content
|
|
868
|
+
let md = `# Code Review Session\n`;
|
|
869
|
+
md += `Date: ${new Date().toISOString()}\n\n`;
|
|
870
|
+
|
|
871
|
+
if (state.originalRequest) {
|
|
872
|
+
md += `## Original Request\n${state.originalRequest}\n\n`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (state.overallFeedback) {
|
|
876
|
+
md += `## Overall Feedback\n${state.overallFeedback}\n\n`;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Stats
|
|
880
|
+
const approved = state.hunks.filter(h => h.reviewStatus === 'approved').length;
|
|
881
|
+
const rejected = state.hunks.filter(h => h.reviewStatus === 'rejected').length;
|
|
882
|
+
const needsChanges = state.hunks.filter(h => h.reviewStatus === 'needs_changes').length;
|
|
883
|
+
const questions = state.hunks.filter(h => h.reviewStatus === 'question').length;
|
|
884
|
+
md += `## Summary\n`;
|
|
885
|
+
md += `- Total hunks: ${state.hunks.length}\n`;
|
|
886
|
+
md += `- Approved: ${approved}\n`;
|
|
887
|
+
md += `- Rejected: ${rejected}\n`;
|
|
888
|
+
md += `- Needs changes: ${needsChanges}\n`;
|
|
889
|
+
md += `- Questions: ${questions}\n\n`;
|
|
890
|
+
|
|
891
|
+
// Group by file
|
|
892
|
+
const fileGroups: Record<string, Hunk[]> = {};
|
|
893
|
+
for (const hunk of state.hunks) {
|
|
894
|
+
if (!fileGroups[hunk.file]) fileGroups[hunk.file] = [];
|
|
895
|
+
fileGroups[hunk.file].push(hunk);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
for (const [file, hunks] of Object.entries(fileGroups)) {
|
|
899
|
+
md += `## File: ${file}\n\n`;
|
|
900
|
+
for (const hunk of hunks) {
|
|
901
|
+
const statusStr = hunk.reviewStatus.toUpperCase();
|
|
902
|
+
md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
|
|
903
|
+
md += `**Status**: ${statusStr}\n\n`;
|
|
904
|
+
|
|
905
|
+
const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
|
|
906
|
+
if (hunkComments.length > 0) {
|
|
907
|
+
md += `**Comments:**\n`;
|
|
908
|
+
for (const c of hunkComments) {
|
|
909
|
+
// Format line reference
|
|
910
|
+
let lineRef = '';
|
|
911
|
+
if (c.line_type === 'add' && c.new_line) {
|
|
912
|
+
lineRef = `[+${c.new_line}]`;
|
|
913
|
+
} else if (c.line_type === 'remove' && c.old_line) {
|
|
914
|
+
lineRef = `[-${c.old_line}]`;
|
|
915
|
+
} else if (c.new_line) {
|
|
916
|
+
lineRef = `[L${c.new_line}]`;
|
|
917
|
+
} else if (c.old_line) {
|
|
918
|
+
lineRef = `[L${c.old_line}]`;
|
|
919
|
+
}
|
|
920
|
+
md += `> 💬 ${lineRef} ${c.text}\n`;
|
|
921
|
+
if (c.line_content) {
|
|
922
|
+
md += `> \`${c.line_content.trim()}\`\n`;
|
|
923
|
+
}
|
|
924
|
+
md += `\n`;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Write file
|
|
931
|
+
const filePath = editor.pathJoin(reviewDir, "session.md");
|
|
932
|
+
await editor.writeFile(filePath, md);
|
|
933
|
+
editor.setStatus(`Review exported to ${filePath}`);
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
globalThis.review_export_json = async () => {
|
|
937
|
+
const cwd = editor.getCwd();
|
|
938
|
+
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
939
|
+
await editor.spawnProcess("mkdir", ["-p", reviewDir]);
|
|
940
|
+
|
|
941
|
+
const session = {
|
|
942
|
+
version: "1.0",
|
|
943
|
+
timestamp: new Date().toISOString(),
|
|
944
|
+
original_request: state.originalRequest || null,
|
|
945
|
+
overall_feedback: state.overallFeedback || null,
|
|
946
|
+
files: {} as Record<string, any>
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
for (const hunk of state.hunks) {
|
|
950
|
+
if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
|
|
951
|
+
const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
|
|
952
|
+
session.files[hunk.file].hunks.push({
|
|
953
|
+
context: hunk.contextHeader,
|
|
954
|
+
old_lines: [hunk.oldRange.start, hunk.oldRange.end],
|
|
955
|
+
new_lines: [hunk.range.start, hunk.range.end],
|
|
956
|
+
status: hunk.reviewStatus,
|
|
957
|
+
comments: hunkComments.map(c => ({
|
|
958
|
+
text: c.text,
|
|
959
|
+
line_type: c.line_type || null,
|
|
960
|
+
old_line: c.old_line || null,
|
|
961
|
+
new_line: c.new_line || null,
|
|
962
|
+
line_content: c.line_content || null
|
|
963
|
+
}))
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const filePath = editor.pathJoin(reviewDir, "session.json");
|
|
968
|
+
await editor.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
969
|
+
editor.setStatus(`Review exported to ${filePath}`);
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
globalThis.start_review_diff = async () => {
|
|
973
|
+
editor.setStatus("Generating Review Diff Stream...");
|
|
974
|
+
editor.setContext("review-mode", true);
|
|
975
|
+
|
|
976
|
+
// Initial data fetch
|
|
977
|
+
const newHunks = await getGitDiff();
|
|
978
|
+
state.hunks = newHunks;
|
|
979
|
+
state.comments = []; // Reset comments for new session
|
|
980
|
+
|
|
981
|
+
const bufferId = await VirtualBufferFactory.create({
|
|
982
|
+
name: "*Review Diff*", mode: "review-mode", read_only: true,
|
|
983
|
+
entries: (await renderReviewStream()).entries, showLineNumbers: false
|
|
984
|
+
});
|
|
985
|
+
state.reviewBufferId = bufferId;
|
|
986
|
+
await updateReviewUI(); // Apply initial highlights
|
|
987
|
+
|
|
988
|
+
editor.setStatus(`Review Diff: ${state.hunks.length} hunks | [c]omment [a]pprove [x]reject [!]changes [?]question [E]xport`);
|
|
989
|
+
editor.on("buffer_activated", "on_review_buffer_activated");
|
|
990
|
+
editor.on("buffer_closed", "on_review_buffer_closed");
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
globalThis.stop_review_diff = () => {
|
|
994
|
+
state.reviewBufferId = null;
|
|
995
|
+
editor.setContext("review-mode", false);
|
|
996
|
+
editor.off("buffer_activated", "on_review_buffer_activated");
|
|
997
|
+
editor.off("buffer_closed", "on_review_buffer_closed");
|
|
998
|
+
editor.setStatus("Review Diff Mode stopped.");
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
globalThis.on_review_buffer_activated = (data: any) => {
|
|
1002
|
+
if (data.buffer_id === state.reviewBufferId) refreshReviewData();
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
globalThis.on_review_buffer_closed = (data: any) => {
|
|
1006
|
+
if (data.buffer_id === state.reviewBufferId) stop_review_diff();
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
// Register Modes and Commands
|
|
1010
|
+
editor.registerCommand("Review Diff", "Start code review session", "start_review_diff", "global");
|
|
1011
|
+
editor.registerCommand("Stop Review Diff", "Stop the review session", "stop_review_diff", "review-mode");
|
|
1012
|
+
editor.registerCommand("Refresh Review Diff", "Refresh the list of changes", "review_refresh", "review-mode");
|
|
1013
|
+
|
|
1014
|
+
// Review Comment Commands
|
|
1015
|
+
editor.registerCommand("Review: Add Comment", "Add a review comment to the current hunk", "review_add_comment", "review-mode");
|
|
1016
|
+
editor.registerCommand("Review: Approve Hunk", "Mark hunk as approved", "review_approve_hunk", "review-mode");
|
|
1017
|
+
editor.registerCommand("Review: Reject Hunk", "Mark hunk as rejected", "review_reject_hunk", "review-mode");
|
|
1018
|
+
editor.registerCommand("Review: Needs Changes", "Mark hunk as needing changes", "review_needs_changes", "review-mode");
|
|
1019
|
+
editor.registerCommand("Review: Question", "Mark hunk with a question", "review_question_hunk", "review-mode");
|
|
1020
|
+
editor.registerCommand("Review: Clear Status", "Clear hunk review status", "review_clear_status", "review-mode");
|
|
1021
|
+
editor.registerCommand("Review: Overall Feedback", "Set overall review feedback", "review_set_overall_feedback", "review-mode");
|
|
1022
|
+
editor.registerCommand("Review: Export to Markdown", "Export review to .review/session.md", "review_export_session", "review-mode");
|
|
1023
|
+
editor.registerCommand("Review: Export to JSON", "Export review to .review/session.json", "review_export_json", "review-mode");
|
|
1024
|
+
|
|
1025
|
+
editor.on("buffer_closed", "on_buffer_closed");
|
|
1026
|
+
|
|
1027
|
+
editor.defineMode("review-mode", "normal", [
|
|
1028
|
+
// Staging actions
|
|
1029
|
+
["s", "review_stage_hunk"], ["d", "review_discard_hunk"],
|
|
1030
|
+
// Navigation
|
|
1031
|
+
["n", "review_next_hunk"], ["p", "review_prev_hunk"], ["r", "review_refresh"],
|
|
1032
|
+
["Enter", "review_drill_down"], ["q", "close_buffer"],
|
|
1033
|
+
// Review actions
|
|
1034
|
+
["c", "review_add_comment"],
|
|
1035
|
+
["a", "review_approve_hunk"],
|
|
1036
|
+
["x", "review_reject_hunk"],
|
|
1037
|
+
["!", "review_needs_changes"],
|
|
1038
|
+
["?", "review_question_hunk"],
|
|
1039
|
+
["u", "review_clear_status"],
|
|
1040
|
+
["O", "review_set_overall_feedback"],
|
|
1041
|
+
// Export
|
|
1042
|
+
["E", "review_export_session"],
|
|
1043
|
+
], true);
|
|
1044
|
+
|
|
1045
|
+
editor.debug("Review Diff plugin loaded with review comments support");
|