@fresh-editor/fresh-editor 0.2.21 → 0.2.22
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 +45 -1
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +57 -15
- package/plugins/audit_mode.ts +907 -628
- package/plugins/config-schema.json +15 -35
- package/plugins/lib/fresh.d.ts +42 -0
- package/plugins/lib/virtual-buffer-factory.ts +8 -0
- package/plugins/schemas/package.schema.json +8 -0
- package/plugins/theme_editor.i18n.json +64 -8
- package/plugins/theme_editor.ts +107 -119
- package/themes/dark.json +5 -3
- package/themes/dracula.json +7 -2
- package/themes/high-contrast.json +6 -4
- package/themes/light.json +7 -2
- package/themes/nord.json +14 -9
- package/themes/nostalgia.json +1 -1
- package/themes/solarized-dark.json +16 -11
package/plugins/audit_mode.ts
CHANGED
|
@@ -61,6 +61,17 @@ interface Hunk {
|
|
|
61
61
|
reviewStatus: ReviewStatus;
|
|
62
62
|
contextHeader: string;
|
|
63
63
|
byteOffset: number; // Position in the virtual buffer
|
|
64
|
+
gitStatus?: 'staged' | 'unstaged' | 'untracked';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A file entry from git status --porcelain
|
|
69
|
+
*/
|
|
70
|
+
interface FileEntry {
|
|
71
|
+
path: string;
|
|
72
|
+
status: string; // 'M', 'A', 'D', 'R', 'C', '?'
|
|
73
|
+
category: 'staged' | 'unstaged' | 'untracked';
|
|
74
|
+
origPath?: string; // for renames/copies
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
/**
|
|
@@ -73,6 +84,14 @@ interface ReviewState {
|
|
|
73
84
|
originalRequest?: string;
|
|
74
85
|
overallFeedback?: string;
|
|
75
86
|
reviewBufferId: number | null;
|
|
87
|
+
// New magit-style state
|
|
88
|
+
files: FileEntry[];
|
|
89
|
+
selectedIndex: number;
|
|
90
|
+
fileScrollOffset: number;
|
|
91
|
+
diffScrollOffset: number;
|
|
92
|
+
viewportWidth: number;
|
|
93
|
+
viewportHeight: number;
|
|
94
|
+
focusPanel: 'files' | 'diff';
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
const state: ReviewState = {
|
|
@@ -80,26 +99,34 @@ const state: ReviewState = {
|
|
|
80
99
|
hunkStatus: {},
|
|
81
100
|
comments: [],
|
|
82
101
|
reviewBufferId: null,
|
|
102
|
+
files: [],
|
|
103
|
+
selectedIndex: 0,
|
|
104
|
+
fileScrollOffset: 0,
|
|
105
|
+
diffScrollOffset: 0,
|
|
106
|
+
viewportWidth: 80,
|
|
107
|
+
viewportHeight: 24,
|
|
108
|
+
focusPanel: 'files',
|
|
83
109
|
};
|
|
84
110
|
|
|
85
111
|
// --- Refresh State ---
|
|
86
|
-
let isUpdating = false;
|
|
87
112
|
|
|
88
113
|
// --- Colors & Styles ---
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
114
|
+
// Colors use theme keys where possible, falling back to direct values
|
|
115
|
+
const STYLE_BORDER: OverlayColorSpec = "ui.split_separator_fg";
|
|
116
|
+
const STYLE_HEADER: OverlayColorSpec = "syntax.keyword";
|
|
117
|
+
const STYLE_FILE_NAME: OverlayColorSpec = "syntax.string";
|
|
118
|
+
const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
|
|
119
|
+
const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
|
|
120
|
+
const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
|
|
121
|
+
const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
|
|
122
|
+
const STYLE_STAGED: OverlayColorSpec = "editor.line_number_fg";
|
|
123
|
+
const STYLE_DISCARDED: OverlayColorSpec = "diagnostic.error_fg";
|
|
124
|
+
const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
|
|
125
|
+
const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
|
|
126
|
+
const STYLE_COMMENT_BORDER: OverlayColorSpec = "ui.split_separator_fg";
|
|
127
|
+
const STYLE_APPROVED: OverlayColorSpec = "diagnostic.info_fg";
|
|
128
|
+
const STYLE_REJECTED: OverlayColorSpec = "diagnostic.error_fg";
|
|
129
|
+
const STYLE_QUESTION: OverlayColorSpec = "diagnostic.warning_fg";
|
|
103
130
|
|
|
104
131
|
/**
|
|
105
132
|
* Calculate UTF-8 byte length of a string manually since TextEncoder is not available
|
|
@@ -166,11 +193,8 @@ function diffStrings(oldStr: string, newStr: string): DiffPart[] {
|
|
|
166
193
|
return coalesced;
|
|
167
194
|
}
|
|
168
195
|
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
if (result.exit_code !== 0) return [];
|
|
172
|
-
|
|
173
|
-
const lines = result.stdout.split('\n');
|
|
196
|
+
function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
|
|
197
|
+
const lines = stdout.split('\n');
|
|
174
198
|
const hunks: Hunk[] = [];
|
|
175
199
|
let currentFile = "";
|
|
176
200
|
let currentHunk: Hunk | null = null;
|
|
@@ -189,7 +213,7 @@ async function getGitDiff(): Promise<Hunk[]> {
|
|
|
189
213
|
const oldStart = parseInt(match[1]);
|
|
190
214
|
const newStart = parseInt(match[2]);
|
|
191
215
|
currentHunk = {
|
|
192
|
-
id: `${currentFile}:${newStart}`,
|
|
216
|
+
id: `${currentFile}:${newStart}:${gitStatus}`,
|
|
193
217
|
file: currentFile,
|
|
194
218
|
range: { start: newStart, end: newStart },
|
|
195
219
|
oldRange: { start: oldStart, end: oldStart },
|
|
@@ -198,7 +222,8 @@ async function getGitDiff(): Promise<Hunk[]> {
|
|
|
198
222
|
status: 'pending',
|
|
199
223
|
reviewStatus: 'pending',
|
|
200
224
|
contextHeader: match[3]?.trim() || "",
|
|
201
|
-
byteOffset: 0
|
|
225
|
+
byteOffset: 0,
|
|
226
|
+
gitStatus
|
|
202
227
|
};
|
|
203
228
|
hunks.push(currentHunk);
|
|
204
229
|
}
|
|
@@ -211,440 +236,626 @@ async function getGitDiff(): Promise<Hunk[]> {
|
|
|
211
236
|
return hunks;
|
|
212
237
|
}
|
|
213
238
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
239
|
+
// --- Git status detection ---
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Parse `git status --porcelain -z` output into FileEntry[].
|
|
243
|
+
*
|
|
244
|
+
* Format: each entry is "XY path\0" where X = index status, Y = worktree status.
|
|
245
|
+
* Renames/copies add "origPath\0" after the entry.
|
|
246
|
+
* A file can appear in BOTH staged and unstaged if both X and Y are set.
|
|
247
|
+
*/
|
|
248
|
+
function parseGitStatusPorcelain(raw: string): FileEntry[] {
|
|
249
|
+
const files: FileEntry[] = [];
|
|
250
|
+
if (!raw) return files;
|
|
251
|
+
|
|
252
|
+
// Split on null bytes
|
|
253
|
+
const parts = raw.split('\0');
|
|
254
|
+
let i = 0;
|
|
255
|
+
while (i < parts.length) {
|
|
256
|
+
const entry = parts[i];
|
|
257
|
+
if (entry.length < 3) { i++; continue; }
|
|
258
|
+
|
|
259
|
+
const x = entry[0]; // index (staged) status
|
|
260
|
+
const y = entry[1]; // worktree (unstaged) status
|
|
261
|
+
// entry[2] is a space
|
|
262
|
+
const path = entry.slice(3);
|
|
263
|
+
|
|
264
|
+
if (!path) { i++; continue; }
|
|
265
|
+
|
|
266
|
+
// Check for rename/copy — next part is the original path
|
|
267
|
+
let origPath: string | undefined;
|
|
268
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
|
|
269
|
+
i++;
|
|
270
|
+
origPath = parts[i];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Untracked files: XY = '??'
|
|
274
|
+
if (x === '?' && y === '?') {
|
|
275
|
+
files.push({ path, status: '?', category: 'untracked' });
|
|
276
|
+
i++;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Ignored files: XY = '!!' — skip
|
|
281
|
+
if (x === '!' && y === '!') {
|
|
282
|
+
i++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Staged changes: X is not ' ' and not '?'
|
|
287
|
+
if (x !== ' ' && x !== '?') {
|
|
288
|
+
files.push({ path, status: x, category: 'staged', origPath });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Unstaged changes: Y is not ' ' and not '?'
|
|
292
|
+
if (y !== ' ' && y !== '?') {
|
|
293
|
+
files.push({ path, status: y, category: 'unstaged', origPath });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
i++;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Sort: staged → unstaged → untracked, then by filename
|
|
300
|
+
const categoryOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
|
|
301
|
+
files.sort((a, b) => {
|
|
302
|
+
const orderA = categoryOrder[a.category] ?? 2;
|
|
303
|
+
const orderB = categoryOrder[b.category] ?? 2;
|
|
304
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
305
|
+
return a.path.localeCompare(b.path);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
return files;
|
|
221
309
|
}
|
|
222
310
|
|
|
223
311
|
/**
|
|
224
|
-
*
|
|
312
|
+
* Single source of truth for changed files using `git status --porcelain -z`.
|
|
225
313
|
*/
|
|
226
|
-
async function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// Add help header with keybindings at the TOP
|
|
233
|
-
const helpHeader = "╔" + "═".repeat(74) + "╗\n";
|
|
234
|
-
const helpLen0 = getByteLength(helpHeader);
|
|
235
|
-
entries.push({ text: helpHeader, properties: { type: "help" } });
|
|
236
|
-
highlights.push({ range: [currentByte, currentByte + helpLen0], fg: STYLE_COMMENT_BORDER });
|
|
237
|
-
currentByte += helpLen0;
|
|
238
|
-
|
|
239
|
-
const helpLine1 = "║ " + editor.t("panel.help_review").padEnd(72) + " ║\n";
|
|
240
|
-
const helpLen1 = getByteLength(helpLine1);
|
|
241
|
-
entries.push({ text: helpLine1, properties: { type: "help" } });
|
|
242
|
-
highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_COMMENT });
|
|
243
|
-
currentByte += helpLen1;
|
|
244
|
-
|
|
245
|
-
const helpLine2 = "║ " + editor.t("panel.help_stage").padEnd(72) + " ║\n";
|
|
246
|
-
const helpLen2 = getByteLength(helpLine2);
|
|
247
|
-
entries.push({ text: helpLine2, properties: { type: "help" } });
|
|
248
|
-
highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
|
|
249
|
-
currentByte += helpLen2;
|
|
250
|
-
|
|
251
|
-
const helpLine3 = "║ " + editor.t("panel.help_export").padEnd(72) + " ║\n";
|
|
252
|
-
const helpLen3 = getByteLength(helpLine3);
|
|
253
|
-
entries.push({ text: helpLine3, properties: { type: "help" } });
|
|
254
|
-
highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
|
|
255
|
-
currentByte += helpLen3;
|
|
256
|
-
|
|
257
|
-
const helpFooter = "╚" + "═".repeat(74) + "╝\n\n";
|
|
258
|
-
const helpLen4 = getByteLength(helpFooter);
|
|
259
|
-
entries.push({ text: helpFooter, properties: { type: "help" } });
|
|
260
|
-
highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT_BORDER });
|
|
261
|
-
currentByte += helpLen4;
|
|
262
|
-
|
|
263
|
-
for (let hunkIndex = 0; hunkIndex < state.hunks.length; hunkIndex++) {
|
|
264
|
-
const hunk = state.hunks[hunkIndex];
|
|
265
|
-
if (hunk.file !== currentFile) {
|
|
266
|
-
// Header & Border
|
|
267
|
-
const titlePrefix = "┌─ ";
|
|
268
|
-
const titleLine = `${titlePrefix}${hunk.file} ${"─".repeat(Math.max(0, 60 - hunk.file.length))}\n`;
|
|
269
|
-
const titleLen = getByteLength(titleLine);
|
|
270
|
-
entries.push({ text: titleLine, properties: { type: "banner", file: hunk.file } });
|
|
271
|
-
highlights.push({ range: [currentByte, currentByte + titleLen], fg: STYLE_BORDER });
|
|
272
|
-
const prefixLen = getByteLength(titlePrefix);
|
|
273
|
-
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + getByteLength(hunk.file)], fg: STYLE_FILE_NAME, bold: true });
|
|
274
|
-
currentByte += titleLen;
|
|
275
|
-
currentFile = hunk.file;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
hunk.byteOffset = currentByte;
|
|
279
|
-
|
|
280
|
-
// Status icons: staging (left) and review (right)
|
|
281
|
-
const stagingIcon = hunk.status === 'staged' ? '✓' : (hunk.status === 'discarded' ? '✗' : ' ');
|
|
282
|
-
const reviewIcon = hunk.reviewStatus === 'approved' ? '✓' :
|
|
283
|
-
hunk.reviewStatus === 'rejected' ? '✗' :
|
|
284
|
-
hunk.reviewStatus === 'needs_changes' ? '!' :
|
|
285
|
-
hunk.reviewStatus === 'question' ? '?' : ' ';
|
|
286
|
-
const reviewLabel = hunk.reviewStatus !== 'pending' ? ` ← ${hunk.reviewStatus.toUpperCase()}` : '';
|
|
287
|
-
|
|
288
|
-
const headerPrefix = "│ ";
|
|
289
|
-
const headerText = `${headerPrefix}${stagingIcon} ${reviewIcon} [ ${hunk.contextHeader} ]${reviewLabel}\n`;
|
|
290
|
-
const headerLen = getByteLength(headerText);
|
|
291
|
-
|
|
292
|
-
let hunkColor = STYLE_HEADER;
|
|
293
|
-
if (hunk.status === 'staged') hunkColor = STYLE_STAGED;
|
|
294
|
-
else if (hunk.status === 'discarded') hunkColor = STYLE_DISCARDED;
|
|
295
|
-
|
|
296
|
-
let reviewColor = STYLE_HEADER;
|
|
297
|
-
if (hunk.reviewStatus === 'approved') reviewColor = STYLE_APPROVED;
|
|
298
|
-
else if (hunk.reviewStatus === 'rejected') reviewColor = STYLE_REJECTED;
|
|
299
|
-
else if (hunk.reviewStatus === 'needs_changes') reviewColor = STYLE_QUESTION;
|
|
300
|
-
else if (hunk.reviewStatus === 'question') reviewColor = STYLE_QUESTION;
|
|
301
|
-
|
|
302
|
-
entries.push({ text: headerText, properties: { type: "header", hunkId: hunk.id, index: hunkIndex } });
|
|
303
|
-
highlights.push({ range: [currentByte, currentByte + headerLen], fg: STYLE_BORDER });
|
|
304
|
-
const headerPrefixLen = getByteLength(headerPrefix);
|
|
305
|
-
// Staging icon
|
|
306
|
-
highlights.push({ range: [currentByte + headerPrefixLen, currentByte + headerPrefixLen + getByteLength(stagingIcon)], fg: hunkColor, bold: true });
|
|
307
|
-
// Review icon
|
|
308
|
-
highlights.push({ range: [currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1, currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon)], fg: reviewColor, bold: true });
|
|
309
|
-
// Context header
|
|
310
|
-
const contextStart = currentByte + headerPrefixLen + getByteLength(stagingIcon) + 1 + getByteLength(reviewIcon) + 3;
|
|
311
|
-
highlights.push({ range: [contextStart, currentByte + headerLen - getByteLength(reviewLabel) - 2], fg: hunkColor });
|
|
312
|
-
// Review label
|
|
313
|
-
if (reviewLabel) {
|
|
314
|
-
highlights.push({ range: [currentByte + headerLen - getByteLength(reviewLabel) - 1, currentByte + headerLen - 1], fg: reviewColor, bold: true });
|
|
315
|
-
}
|
|
316
|
-
currentByte += headerLen;
|
|
317
|
-
|
|
318
|
-
// Track actual file line numbers as we iterate
|
|
319
|
-
let oldLineNum = hunk.oldRange.start;
|
|
320
|
-
let newLineNum = hunk.range.start;
|
|
321
|
-
|
|
322
|
-
for (let i = 0; i < hunk.lines.length; i++) {
|
|
323
|
-
const line = hunk.lines[i];
|
|
324
|
-
const nextLine = hunk.lines[i + 1];
|
|
325
|
-
const marker = line[0];
|
|
326
|
-
const content = line.substring(1);
|
|
327
|
-
const linePrefix = "│ ";
|
|
328
|
-
const lineText = `${linePrefix}${marker} ${content}\n`;
|
|
329
|
-
const lineLen = getByteLength(lineText);
|
|
330
|
-
const prefixLen = getByteLength(linePrefix);
|
|
331
|
-
|
|
332
|
-
// Determine line type and which line numbers apply
|
|
333
|
-
const lineType: 'add' | 'remove' | 'context' =
|
|
334
|
-
marker === '+' ? 'add' : marker === '-' ? 'remove' : 'context';
|
|
335
|
-
const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
|
|
336
|
-
const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
|
|
337
|
-
|
|
338
|
-
if (line.startsWith('-') && nextLine && nextLine.startsWith('+') && hunk.status === 'pending') {
|
|
339
|
-
const oldContent = line.substring(1);
|
|
340
|
-
const newContent = nextLine.substring(1);
|
|
341
|
-
const diffParts = diffStrings(oldContent, newContent);
|
|
342
|
-
|
|
343
|
-
// Removed
|
|
344
|
-
entries.push({ text: lineText, properties: {
|
|
345
|
-
type: "content", hunkId: hunk.id, file: hunk.file,
|
|
346
|
-
lineType: 'remove', oldLine: curOldLine, lineContent: line
|
|
347
|
-
} });
|
|
348
|
-
highlights.push({ range: [currentByte, currentByte + lineLen], fg: STYLE_BORDER });
|
|
349
|
-
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_REMOVE_TEXT, bold: true });
|
|
350
|
-
|
|
351
|
-
let cbOffset = currentByte + prefixLen + 2;
|
|
352
|
-
diffParts.forEach(p => {
|
|
353
|
-
const pLen = getByteLength(p.text);
|
|
354
|
-
if (p.type === 'removed') {
|
|
355
|
-
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true });
|
|
356
|
-
cbOffset += pLen;
|
|
357
|
-
} else if (p.type === 'unchanged') {
|
|
358
|
-
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_REMOVE_TEXT });
|
|
359
|
-
cbOffset += pLen;
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
currentByte += lineLen;
|
|
363
|
-
|
|
364
|
-
// Added (increment old line for the removed line we just processed)
|
|
365
|
-
oldLineNum++;
|
|
366
|
-
const nextLineText = `${linePrefix}+ ${nextLine.substring(1)}\n`;
|
|
367
|
-
const nextLineLen = getByteLength(nextLineText);
|
|
368
|
-
entries.push({ text: nextLineText, properties: {
|
|
369
|
-
type: "content", hunkId: hunk.id, file: hunk.file,
|
|
370
|
-
lineType: 'add', newLine: newLineNum, lineContent: nextLine
|
|
371
|
-
} });
|
|
372
|
-
newLineNum++;
|
|
373
|
-
highlights.push({ range: [currentByte, currentByte + nextLineLen], fg: STYLE_BORDER });
|
|
374
|
-
highlights.push({ range: [currentByte + prefixLen, currentByte + prefixLen + 1], fg: STYLE_ADD_TEXT, bold: true });
|
|
375
|
-
|
|
376
|
-
cbOffset = currentByte + prefixLen + 2;
|
|
377
|
-
diffParts.forEach(p => {
|
|
378
|
-
const pLen = getByteLength(p.text);
|
|
379
|
-
if (p.type === 'added') {
|
|
380
|
-
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true });
|
|
381
|
-
cbOffset += pLen;
|
|
382
|
-
} else if (p.type === 'unchanged') {
|
|
383
|
-
highlights.push({ range: [cbOffset, cbOffset + pLen], fg: STYLE_ADD_TEXT });
|
|
384
|
-
cbOffset += pLen;
|
|
385
|
-
}
|
|
386
|
-
});
|
|
387
|
-
currentByte += nextLineLen;
|
|
388
|
-
|
|
389
|
-
// Render comments for the removed line (curOldLine before increment)
|
|
390
|
-
const removedLineComments = state.comments.filter(c =>
|
|
391
|
-
c.hunk_id === hunk.id && c.line_type === 'remove' && c.old_line === curOldLine
|
|
392
|
-
);
|
|
393
|
-
for (const comment of removedLineComments) {
|
|
394
|
-
const commentPrefix = `│ » [-${comment.old_line}] `;
|
|
395
|
-
const commentLines = comment.text.split('\n');
|
|
396
|
-
for (let ci = 0; ci < commentLines.length; ci++) {
|
|
397
|
-
const prefix = ci === 0 ? commentPrefix : "│ ";
|
|
398
|
-
const commentLine = `${prefix}${commentLines[ci]}\n`;
|
|
399
|
-
const commentLineLen = getByteLength(commentLine);
|
|
400
|
-
entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
|
|
401
|
-
highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
|
|
402
|
-
highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
|
|
403
|
-
currentByte += commentLineLen;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
314
|
+
async function getGitStatus(): Promise<FileEntry[]> {
|
|
315
|
+
const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
|
|
316
|
+
if (result.exit_code !== 0) return [];
|
|
317
|
+
return parseGitStatusPorcelain(result.stdout);
|
|
318
|
+
}
|
|
406
319
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
320
|
+
/**
|
|
321
|
+
* Fetch unified diffs for the given file entries.
|
|
322
|
+
* Groups by category to minimize git invocations.
|
|
323
|
+
*/
|
|
324
|
+
async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
|
|
325
|
+
const allHunks: Hunk[] = [];
|
|
326
|
+
|
|
327
|
+
const hasStaged = files.some(f => f.category === 'staged');
|
|
328
|
+
const hasUnstaged = files.some(f => f.category === 'unstaged');
|
|
329
|
+
const untrackedFiles = files.filter(f => f.category === 'untracked');
|
|
330
|
+
|
|
331
|
+
// Staged diffs
|
|
332
|
+
if (hasStaged) {
|
|
333
|
+
const result = await editor.spawnProcess("git", ["diff", "--cached", "--unified=3"]);
|
|
334
|
+
if (result.exit_code === 0 && result.stdout.trim()) {
|
|
335
|
+
allHunks.push(...parseDiffOutput(result.stdout, 'staged'));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Unstaged diffs
|
|
340
|
+
if (hasUnstaged) {
|
|
341
|
+
const result = await editor.spawnProcess("git", ["diff", "--unified=3"]);
|
|
342
|
+
if (result.exit_code === 0 && result.stdout.trim()) {
|
|
343
|
+
allHunks.push(...parseDiffOutput(result.stdout, 'unstaged'));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Untracked file diffs
|
|
348
|
+
for (const f of untrackedFiles) {
|
|
349
|
+
const result = await editor.spawnProcess("git", [
|
|
350
|
+
"diff", "--no-index", "--unified=3", "/dev/null", f.path
|
|
351
|
+
]);
|
|
352
|
+
if (result.stdout.trim()) {
|
|
353
|
+
const hunks = parseDiffOutput(result.stdout, 'untracked');
|
|
354
|
+
for (const h of hunks) {
|
|
355
|
+
h.file = f.path;
|
|
356
|
+
h.id = `${f.path}:${h.range.start}:untracked`;
|
|
357
|
+
h.type = 'add';
|
|
423
358
|
}
|
|
359
|
+
allHunks.push(...hunks);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
424
362
|
|
|
425
|
-
|
|
363
|
+
// Sort: staged → unstaged → untracked, then by filename
|
|
364
|
+
const statusOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
|
|
365
|
+
allHunks.sort((a, b) => {
|
|
366
|
+
const orderA = statusOrder[a.gitStatus || 'unstaged'];
|
|
367
|
+
const orderB = statusOrder[b.gitStatus || 'unstaged'];
|
|
368
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
369
|
+
return a.file.localeCompare(b.file);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return allHunks;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --- New magit-style rendering (Step 2 of rewrite) ---
|
|
376
|
+
|
|
377
|
+
const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
|
|
378
|
+
const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
|
|
379
|
+
const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
|
|
380
|
+
const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
|
|
381
|
+
|
|
382
|
+
interface ListLine {
|
|
383
|
+
text: string;
|
|
384
|
+
type: 'section-header' | 'file';
|
|
385
|
+
fileIndex?: number; // index into state.files[]
|
|
386
|
+
style?: Partial<OverlayOptions>;
|
|
387
|
+
inlineOverlays?: InlineOverlay[];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface DiffLine {
|
|
391
|
+
text: string;
|
|
392
|
+
type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty';
|
|
393
|
+
style?: Partial<OverlayOptions>;
|
|
394
|
+
inlineOverlays?: InlineOverlay[];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Build the file list lines for the left panel.
|
|
399
|
+
* Returns section headers (not selectable) and file entries.
|
|
400
|
+
*/
|
|
401
|
+
function buildFileListLines(): ListLine[] {
|
|
402
|
+
const lines: ListLine[] = [];
|
|
403
|
+
let lastCategory: string | undefined;
|
|
404
|
+
|
|
405
|
+
for (let i = 0; i < state.files.length; i++) {
|
|
406
|
+
const f = state.files[i];
|
|
407
|
+
// Section headers
|
|
408
|
+
if (f.category !== lastCategory) {
|
|
409
|
+
lastCategory = f.category;
|
|
410
|
+
let label = '';
|
|
411
|
+
if (f.category === 'staged') label = editor.t("section.staged") || "Staged";
|
|
412
|
+
else if (f.category === 'unstaged') label = editor.t("section.unstaged") || "Changes";
|
|
413
|
+
else if (f.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
|
|
414
|
+
lines.push({
|
|
415
|
+
text: `▸ ${label}`,
|
|
416
|
+
type: 'section-header',
|
|
417
|
+
style: { fg: STYLE_SECTION_HEADER, bold: true },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Status icon
|
|
422
|
+
const statusIcon = f.status === '?' ? 'A' : f.status;
|
|
423
|
+
const prefix = i === state.selectedIndex ? '>' : ' ';
|
|
424
|
+
const filename = f.origPath ? `${f.origPath} → ${f.path}` : f.path;
|
|
425
|
+
lines.push({
|
|
426
|
+
text: `${prefix}${statusIcon} ${filename}`,
|
|
427
|
+
type: 'file',
|
|
428
|
+
fileIndex: i,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return lines;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Build the diff lines for the right panel based on currently selected file.
|
|
437
|
+
*/
|
|
438
|
+
function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
439
|
+
const lines: DiffLine[] = [];
|
|
440
|
+
if (state.files.length === 0) return lines;
|
|
441
|
+
|
|
442
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
443
|
+
if (!selectedFile) return lines;
|
|
444
|
+
|
|
445
|
+
// Find hunks matching the selected file and category
|
|
446
|
+
const fileHunks = state.hunks.filter(
|
|
447
|
+
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
if (fileHunks.length === 0) {
|
|
451
|
+
if (selectedFile.status === 'R' && selectedFile.origPath) {
|
|
452
|
+
lines.push({ text: `Renamed from ${selectedFile.origPath}`, type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
|
|
453
|
+
} else if (selectedFile.status === 'D') {
|
|
454
|
+
lines.push({ text: "(file deleted)", type: 'empty' });
|
|
455
|
+
} else if (selectedFile.status === 'T') {
|
|
456
|
+
lines.push({ text: "(type change: file ↔ symlink)", type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
|
|
457
|
+
} else if (selectedFile.status === '?' && selectedFile.path.endsWith('/')) {
|
|
458
|
+
lines.push({ text: "(untracked directory)", type: 'empty' });
|
|
426
459
|
} else {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
460
|
+
lines.push({ text: "(no diff available)", type: 'empty' });
|
|
461
|
+
}
|
|
462
|
+
return lines;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const hunk of fileHunks) {
|
|
466
|
+
// Hunk header
|
|
467
|
+
const header = hunk.contextHeader
|
|
468
|
+
? `@@ ${hunk.contextHeader} @@`
|
|
469
|
+
: `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
|
|
470
|
+
lines.push({
|
|
471
|
+
text: header,
|
|
472
|
+
type: 'hunk-header',
|
|
473
|
+
style: { fg: STYLE_HUNK_HEADER, bold: true },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Diff content lines — only set background color so the normal editor
|
|
477
|
+
// foreground stays readable across all themes. The bg uses theme-aware
|
|
478
|
+
// diff colors that each theme can customize.
|
|
479
|
+
for (const line of hunk.lines) {
|
|
480
|
+
const prefix = line[0];
|
|
481
|
+
if (prefix === '+') {
|
|
482
|
+
lines.push({
|
|
483
|
+
text: line,
|
|
484
|
+
type: 'add',
|
|
485
|
+
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
486
|
+
});
|
|
487
|
+
} else if (prefix === '-') {
|
|
488
|
+
lines.push({
|
|
489
|
+
text: line,
|
|
490
|
+
type: 'remove',
|
|
491
|
+
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
492
|
+
});
|
|
440
493
|
} else {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
// Increment line counters based on line type
|
|
446
|
-
if (lineType === 'remove') oldLineNum++;
|
|
447
|
-
else if (lineType === 'add') newLineNum++;
|
|
448
|
-
else { oldLineNum++; newLineNum++; } // context
|
|
449
|
-
|
|
450
|
-
// Render any comments attached to this specific line
|
|
451
|
-
const lineComments = state.comments.filter(c =>
|
|
452
|
-
c.hunk_id === hunk.id && (
|
|
453
|
-
(lineType === 'remove' && c.old_line === curOldLine) ||
|
|
454
|
-
(lineType === 'add' && c.new_line === curNewLine) ||
|
|
455
|
-
(lineType === 'context' && (c.old_line === curOldLine || c.new_line === curNewLine))
|
|
456
|
-
)
|
|
457
|
-
);
|
|
458
|
-
for (const comment of lineComments) {
|
|
459
|
-
const lineRef = comment.line_type === 'add'
|
|
460
|
-
? `+${comment.new_line}`
|
|
461
|
-
: comment.line_type === 'remove'
|
|
462
|
-
? `-${comment.old_line}`
|
|
463
|
-
: `${comment.new_line}`;
|
|
464
|
-
const commentPrefix = `│ » [${lineRef}] `;
|
|
465
|
-
const commentLines = comment.text.split('\n');
|
|
466
|
-
for (let ci = 0; ci < commentLines.length; ci++) {
|
|
467
|
-
const prefix = ci === 0 ? commentPrefix : "│ ";
|
|
468
|
-
const commentLine = `${prefix}${commentLines[ci]}\n`;
|
|
469
|
-
const commentLineLen = getByteLength(commentLine);
|
|
470
|
-
entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
|
|
471
|
-
highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
|
|
472
|
-
highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
|
|
473
|
-
currentByte += commentLineLen;
|
|
474
|
-
}
|
|
494
|
+
lines.push({
|
|
495
|
+
text: line,
|
|
496
|
+
type: 'context',
|
|
497
|
+
});
|
|
475
498
|
}
|
|
476
499
|
}
|
|
477
500
|
}
|
|
478
501
|
|
|
479
|
-
|
|
480
|
-
const orphanComments = state.comments.filter(c =>
|
|
481
|
-
c.hunk_id === hunk.id && !c.old_line && !c.new_line
|
|
482
|
-
);
|
|
483
|
-
if (orphanComments.length > 0) {
|
|
484
|
-
const commentBorder = "│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄\n";
|
|
485
|
-
const borderLen = getByteLength(commentBorder);
|
|
486
|
-
entries.push({ text: commentBorder, properties: { type: "comment-border" } });
|
|
487
|
-
highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
|
|
488
|
-
currentByte += borderLen;
|
|
489
|
-
|
|
490
|
-
for (const comment of orphanComments) {
|
|
491
|
-
const commentPrefix = "│ » ";
|
|
492
|
-
const commentLines = comment.text.split('\n');
|
|
493
|
-
for (let ci = 0; ci < commentLines.length; ci++) {
|
|
494
|
-
const prefix = ci === 0 ? commentPrefix : "│ ";
|
|
495
|
-
const commentLine = `${prefix}${commentLines[ci]}\n`;
|
|
496
|
-
const commentLineLen = getByteLength(commentLine);
|
|
497
|
-
entries.push({ text: commentLine, properties: { type: "comment", commentId: comment.id, hunkId: hunk.id } });
|
|
498
|
-
highlights.push({ range: [currentByte, currentByte + getByteLength(prefix)], fg: STYLE_COMMENT_BORDER });
|
|
499
|
-
highlights.push({ range: [currentByte + getByteLength(prefix), currentByte + commentLineLen], fg: STYLE_COMMENT });
|
|
500
|
-
currentByte += commentLineLen;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
entries.push({ text: commentBorder, properties: { type: "comment-border" } });
|
|
505
|
-
highlights.push({ range: [currentByte, currentByte + borderLen], fg: STYLE_COMMENT_BORDER });
|
|
506
|
-
currentByte += borderLen;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const isLastOfFile = hunkIndex === state.hunks.length - 1 || state.hunks[hunkIndex + 1].file !== hunk.file;
|
|
510
|
-
if (isLastOfFile) {
|
|
511
|
-
const bottomLine = `└${"─".repeat(64)}\n`;
|
|
512
|
-
const bottomLen = getByteLength(bottomLine);
|
|
513
|
-
entries.push({ text: bottomLine, properties: { type: "border" } });
|
|
514
|
-
highlights.push({ range: [currentByte, currentByte + bottomLen], fg: STYLE_BORDER });
|
|
515
|
-
currentByte += bottomLen;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (entries.length === 0) {
|
|
520
|
-
entries.push({ text: editor.t("panel.no_changes") + "\n", properties: {} });
|
|
521
|
-
} else {
|
|
522
|
-
// Add help footer with keybindings
|
|
523
|
-
const helpSeparator = "\n" + "─".repeat(70) + "\n";
|
|
524
|
-
const helpLen1 = getByteLength(helpSeparator);
|
|
525
|
-
entries.push({ text: helpSeparator, properties: { type: "help" } });
|
|
526
|
-
highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_BORDER });
|
|
527
|
-
currentByte += helpLen1;
|
|
528
|
-
|
|
529
|
-
const helpLine1 = editor.t("panel.help_review_footer") + "\n";
|
|
530
|
-
const helpLen2 = getByteLength(helpLine1);
|
|
531
|
-
entries.push({ text: helpLine1, properties: { type: "help" } });
|
|
532
|
-
highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
|
|
533
|
-
currentByte += helpLen2;
|
|
534
|
-
|
|
535
|
-
const helpLine2 = editor.t("panel.help_stage_footer") + "\n";
|
|
536
|
-
const helpLen3 = getByteLength(helpLine2);
|
|
537
|
-
entries.push({ text: helpLine2, properties: { type: "help" } });
|
|
538
|
-
highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
|
|
539
|
-
currentByte += helpLen3;
|
|
540
|
-
|
|
541
|
-
const helpLine3 = editor.t("panel.help_export_footer") + "\n";
|
|
542
|
-
const helpLen4 = getByteLength(helpLine3);
|
|
543
|
-
entries.push({ text: helpLine3, properties: { type: "help" } });
|
|
544
|
-
highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT });
|
|
545
|
-
currentByte += helpLen4;
|
|
546
|
-
}
|
|
547
|
-
return { entries, highlights };
|
|
502
|
+
return lines;
|
|
548
503
|
}
|
|
549
504
|
|
|
550
505
|
/**
|
|
551
|
-
*
|
|
506
|
+
* Build the full display as exactly viewportHeight lines.
|
|
507
|
+
* Layout:
|
|
508
|
+
* Row 0: Toolbar (shortcuts)
|
|
509
|
+
* Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
|
|
510
|
+
* Rows 2..H-1: Main content (left file list, │ divider, right diff)
|
|
552
511
|
*/
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
512
|
+
function buildMagitDisplayEntries(): TextPropertyEntry[] {
|
|
513
|
+
const entries: TextPropertyEntry[] = [];
|
|
514
|
+
const H = state.viewportHeight;
|
|
515
|
+
const W = state.viewportWidth;
|
|
516
|
+
const leftWidth = Math.max(28, Math.floor(W * 0.3));
|
|
517
|
+
const rightWidth = W - leftWidth - 1; // 1 for divider
|
|
518
|
+
|
|
519
|
+
const allFileLines = buildFileListLines();
|
|
520
|
+
const diffLines = buildDiffLines(rightWidth);
|
|
521
|
+
|
|
522
|
+
const mainRows = H - 2; // rows 2..H-1
|
|
523
|
+
|
|
524
|
+
// --- File list scrolling ---
|
|
525
|
+
let selectedLineIdx = -1;
|
|
526
|
+
for (let i = 0; i < allFileLines.length; i++) {
|
|
527
|
+
if (allFileLines[i].type === 'file' && allFileLines[i].fileIndex === state.selectedIndex) {
|
|
528
|
+
selectedLineIdx = i;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (selectedLineIdx >= 0) {
|
|
533
|
+
if (selectedLineIdx < state.fileScrollOffset) {
|
|
534
|
+
state.fileScrollOffset = selectedLineIdx;
|
|
535
|
+
}
|
|
536
|
+
if (selectedLineIdx >= state.fileScrollOffset + mainRows) {
|
|
537
|
+
state.fileScrollOffset = selectedLineIdx - mainRows + 1;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const maxFileOffset = Math.max(0, allFileLines.length - mainRows);
|
|
541
|
+
if (state.fileScrollOffset > maxFileOffset) state.fileScrollOffset = maxFileOffset;
|
|
542
|
+
if (state.fileScrollOffset < 0) state.fileScrollOffset = 0;
|
|
543
|
+
|
|
544
|
+
const visibleFileLines = allFileLines.slice(state.fileScrollOffset, state.fileScrollOffset + mainRows);
|
|
545
|
+
|
|
546
|
+
// --- Diff scrolling ---
|
|
547
|
+
const maxDiffOffset = Math.max(0, diffLines.length - mainRows);
|
|
548
|
+
if (state.diffScrollOffset > maxDiffOffset) state.diffScrollOffset = maxDiffOffset;
|
|
549
|
+
if (state.diffScrollOffset < 0) state.diffScrollOffset = 0;
|
|
550
|
+
|
|
551
|
+
const visibleDiffLines = diffLines.slice(state.diffScrollOffset, state.diffScrollOffset + mainRows);
|
|
552
|
+
|
|
553
|
+
// --- Row 0: Toolbar ---
|
|
554
|
+
const toolbar = " [Tab] Switch Panel [s] Stage [u] Unstage [d] Discard [Enter] Drill-Down [r] Refresh";
|
|
555
|
+
entries.push({
|
|
556
|
+
text: toolbar.substring(0, W).padEnd(W) + "\n",
|
|
557
|
+
style: { fg: STYLE_FOOTER, bg: "ui.status_bar_bg" as OverlayColorSpec, extendToLineEnd: true },
|
|
558
|
+
properties: { type: "toolbar" },
|
|
566
559
|
});
|
|
567
|
-
|
|
560
|
+
|
|
561
|
+
// --- Row 1: Header ---
|
|
562
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
563
|
+
const focusLeft = state.focusPanel === 'files';
|
|
564
|
+
const leftHeader = " GIT STATUS";
|
|
565
|
+
const rightHeader = selectedFile
|
|
566
|
+
? ` DIFF FOR ${selectedFile.path}`
|
|
567
|
+
: " DIFF";
|
|
568
|
+
const leftHeaderPadded = leftHeader.padEnd(leftWidth).substring(0, leftWidth);
|
|
569
|
+
const rightHeaderPadded = rightHeader.substring(0, rightWidth);
|
|
570
|
+
|
|
571
|
+
const leftHeaderStyle: Partial<OverlayOptions> = focusLeft
|
|
572
|
+
? { fg: STYLE_HEADER, bold: true, underline: true }
|
|
573
|
+
: { fg: STYLE_DIVIDER };
|
|
574
|
+
const rightHeaderStyle: Partial<OverlayOptions> = focusLeft
|
|
575
|
+
? { fg: STYLE_DIVIDER }
|
|
576
|
+
: { fg: STYLE_HEADER, bold: true, underline: true };
|
|
577
|
+
|
|
578
|
+
entries.push({ text: leftHeaderPadded, style: leftHeaderStyle, properties: { type: "header" } });
|
|
579
|
+
entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
|
|
580
|
+
entries.push({ text: rightHeaderPadded, style: rightHeaderStyle, properties: { type: "header" } });
|
|
581
|
+
entries.push({ text: "\n", properties: { type: "newline" } });
|
|
582
|
+
|
|
583
|
+
// --- Rows 2..H-1: Main content ---
|
|
584
|
+
for (let i = 0; i < mainRows; i++) {
|
|
585
|
+
const fileItem = visibleFileLines[i];
|
|
586
|
+
const diffItem = visibleDiffLines[i];
|
|
587
|
+
|
|
588
|
+
// Left panel
|
|
589
|
+
const leftText = fileItem ? (" " + fileItem.text) : "";
|
|
590
|
+
const leftPadded = leftText.padEnd(leftWidth).substring(0, leftWidth);
|
|
591
|
+
const isSelected = fileItem?.type === 'file' && fileItem.fileIndex === state.selectedIndex;
|
|
592
|
+
|
|
593
|
+
const leftEntry: TextPropertyEntry = {
|
|
594
|
+
text: leftPadded,
|
|
595
|
+
properties: {
|
|
596
|
+
type: fileItem?.type || "blank",
|
|
597
|
+
fileIndex: fileItem?.fileIndex,
|
|
598
|
+
},
|
|
599
|
+
style: fileItem?.style,
|
|
600
|
+
inlineOverlays: fileItem?.inlineOverlays,
|
|
601
|
+
};
|
|
602
|
+
if (isSelected) {
|
|
603
|
+
leftEntry.style = { ...(leftEntry.style || {}), bg: STYLE_SELECTED_BG, bold: true };
|
|
604
|
+
}
|
|
605
|
+
entries.push(leftEntry);
|
|
606
|
+
|
|
607
|
+
// Divider
|
|
608
|
+
entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
|
|
609
|
+
|
|
610
|
+
// Right panel — when diff panel is focused, highlight the top line as cursor
|
|
611
|
+
const rightText = diffItem ? (" " + diffItem.text) : "";
|
|
612
|
+
const rightTruncated = rightText.substring(0, rightWidth);
|
|
613
|
+
const isDiffCursorLine = !focusLeft && i === 0 && diffItem != null;
|
|
614
|
+
const rightStyle = isDiffCursorLine
|
|
615
|
+
? { ...(diffItem?.style || {}), bg: STYLE_SELECTED_BG, extendToLineEnd: true }
|
|
616
|
+
: diffItem?.style;
|
|
617
|
+
entries.push({
|
|
618
|
+
text: rightTruncated,
|
|
619
|
+
properties: { type: diffItem?.type || "blank" },
|
|
620
|
+
style: rightStyle,
|
|
621
|
+
inlineOverlays: diffItem?.inlineOverlays,
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Newline
|
|
625
|
+
entries.push({ text: "\n", properties: { type: "newline" } });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return entries;
|
|
568
629
|
}
|
|
569
630
|
|
|
570
631
|
/**
|
|
571
|
-
*
|
|
632
|
+
* Refresh the display — rebuild entries and set buffer content.
|
|
633
|
+
* Always re-queries viewport dimensions to handle sidebar toggles and splits.
|
|
572
634
|
*/
|
|
573
|
-
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
635
|
+
function updateMagitDisplay(): void {
|
|
636
|
+
if (state.reviewBufferId === null) return;
|
|
637
|
+
refreshViewportDimensions();
|
|
638
|
+
const entries = buildMagitDisplayEntries();
|
|
639
|
+
editor.clearNamespace(state.reviewBufferId, "review-diff");
|
|
640
|
+
editor.setVirtualBufferContent(state.reviewBufferId, entries);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function review_refresh() { refreshMagitData(); }
|
|
644
|
+
registerHandler("review_refresh", review_refresh);
|
|
645
|
+
|
|
646
|
+
// --- New magit navigation handlers (Step 3) ---
|
|
647
|
+
|
|
648
|
+
function review_nav_up() {
|
|
649
|
+
if (state.focusPanel === 'files') {
|
|
650
|
+
if (state.files.length === 0) return;
|
|
651
|
+
if (state.selectedIndex > 0) {
|
|
652
|
+
state.selectedIndex--;
|
|
653
|
+
state.diffScrollOffset = 0;
|
|
654
|
+
updateMagitDisplay();
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
state.diffScrollOffset = Math.max(0, state.diffScrollOffset - 1);
|
|
658
|
+
updateMagitDisplay();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
registerHandler("review_nav_up", review_nav_up);
|
|
662
|
+
|
|
663
|
+
function review_nav_down() {
|
|
664
|
+
if (state.focusPanel === 'files') {
|
|
665
|
+
if (state.files.length === 0) return;
|
|
666
|
+
if (state.selectedIndex < state.files.length - 1) {
|
|
667
|
+
state.selectedIndex++;
|
|
668
|
+
state.diffScrollOffset = 0;
|
|
669
|
+
updateMagitDisplay();
|
|
670
|
+
}
|
|
671
|
+
} else {
|
|
672
|
+
state.diffScrollOffset++;
|
|
673
|
+
updateMagitDisplay();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
registerHandler("review_nav_down", review_nav_down);
|
|
677
|
+
|
|
678
|
+
function review_page_up() {
|
|
679
|
+
const mainRows = state.viewportHeight - 2;
|
|
680
|
+
if (state.focusPanel === 'files') {
|
|
681
|
+
if (state.selectedIndex > 0) {
|
|
682
|
+
state.selectedIndex = Math.max(0, state.selectedIndex - mainRows);
|
|
683
|
+
state.diffScrollOffset = 0;
|
|
684
|
+
updateMagitDisplay();
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
state.diffScrollOffset = Math.max(0, state.diffScrollOffset - mainRows);
|
|
688
|
+
updateMagitDisplay();
|
|
587
689
|
}
|
|
588
690
|
}
|
|
691
|
+
registerHandler("review_page_up", review_page_up);
|
|
692
|
+
|
|
693
|
+
function review_page_down() {
|
|
694
|
+
const mainRows = state.viewportHeight - 2;
|
|
695
|
+
if (state.focusPanel === 'files') {
|
|
696
|
+
if (state.selectedIndex < state.files.length - 1) {
|
|
697
|
+
state.selectedIndex = Math.min(state.files.length - 1, state.selectedIndex + mainRows);
|
|
698
|
+
state.diffScrollOffset = 0;
|
|
699
|
+
updateMagitDisplay();
|
|
700
|
+
}
|
|
701
|
+
} else {
|
|
702
|
+
state.diffScrollOffset += mainRows;
|
|
703
|
+
updateMagitDisplay();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
registerHandler("review_page_down", review_page_down);
|
|
589
707
|
|
|
590
|
-
|
|
708
|
+
function review_toggle_focus() {
|
|
709
|
+
state.focusPanel = state.focusPanel === 'files' ? 'diff' : 'files';
|
|
710
|
+
updateMagitDisplay();
|
|
711
|
+
}
|
|
712
|
+
registerHandler("review_toggle_focus", review_toggle_focus);
|
|
591
713
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
state.hunkStatus[id] = 'staged';
|
|
597
|
-
const h = state.hunks.find(x => x.id === id);
|
|
598
|
-
if (h) h.status = 'staged';
|
|
599
|
-
await updateReviewUI();
|
|
714
|
+
function review_focus_files() {
|
|
715
|
+
if (state.focusPanel !== 'files') {
|
|
716
|
+
state.focusPanel = 'files';
|
|
717
|
+
updateMagitDisplay();
|
|
600
718
|
}
|
|
601
719
|
}
|
|
602
|
-
registerHandler("
|
|
720
|
+
registerHandler("review_focus_files", review_focus_files);
|
|
603
721
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
722
|
+
function review_focus_diff() {
|
|
723
|
+
if (state.focusPanel !== 'diff') {
|
|
724
|
+
state.focusPanel = 'diff';
|
|
725
|
+
updateMagitDisplay();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
registerHandler("review_focus_diff", review_focus_diff);
|
|
729
|
+
|
|
730
|
+
function review_nav_home() {
|
|
731
|
+
if (state.focusPanel === 'files') {
|
|
732
|
+
if (state.files.length === 0) return;
|
|
733
|
+
state.selectedIndex = 0;
|
|
734
|
+
state.diffScrollOffset = 0;
|
|
735
|
+
updateMagitDisplay();
|
|
736
|
+
} else {
|
|
737
|
+
state.diffScrollOffset = 0;
|
|
738
|
+
updateMagitDisplay();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
registerHandler("review_nav_home", review_nav_home);
|
|
742
|
+
|
|
743
|
+
function review_nav_end() {
|
|
744
|
+
if (state.focusPanel === 'files') {
|
|
745
|
+
if (state.files.length === 0) return;
|
|
746
|
+
state.selectedIndex = state.files.length - 1;
|
|
747
|
+
state.diffScrollOffset = 0;
|
|
748
|
+
updateMagitDisplay();
|
|
749
|
+
} else {
|
|
750
|
+
// Scroll diff to bottom
|
|
751
|
+
const mainRows = state.viewportHeight - 2;
|
|
752
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
753
|
+
if (selectedFile) {
|
|
754
|
+
const diffLines = buildDiffLines(state.viewportWidth - Math.max(28, Math.floor(state.viewportWidth * 0.3)) - 1);
|
|
755
|
+
state.diffScrollOffset = Math.max(0, diffLines.length - mainRows);
|
|
756
|
+
}
|
|
757
|
+
updateMagitDisplay();
|
|
612
758
|
}
|
|
613
759
|
}
|
|
614
|
-
registerHandler("
|
|
760
|
+
registerHandler("review_nav_end", review_nav_end);
|
|
761
|
+
|
|
762
|
+
// --- Real git stage/unstage/discard actions (Step 4) ---
|
|
763
|
+
|
|
764
|
+
async function review_stage_file() {
|
|
765
|
+
if (state.files.length === 0) return;
|
|
766
|
+
const f = state.files[state.selectedIndex];
|
|
767
|
+
if (!f) return;
|
|
768
|
+
await editor.spawnProcess("git", ["add", "--", f.path]);
|
|
769
|
+
await refreshMagitData();
|
|
770
|
+
}
|
|
771
|
+
registerHandler("review_stage_file", review_stage_file);
|
|
772
|
+
|
|
773
|
+
async function review_unstage_file() {
|
|
774
|
+
if (state.files.length === 0) return;
|
|
775
|
+
const f = state.files[state.selectedIndex];
|
|
776
|
+
if (!f) return;
|
|
777
|
+
await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
|
|
778
|
+
await refreshMagitData();
|
|
779
|
+
}
|
|
780
|
+
registerHandler("review_unstage_file", review_unstage_file);
|
|
781
|
+
|
|
782
|
+
function review_discard_file() {
|
|
783
|
+
if (state.files.length === 0) return;
|
|
784
|
+
const f = state.files[state.selectedIndex];
|
|
785
|
+
if (!f) return;
|
|
786
|
+
|
|
787
|
+
// Show confirmation prompt — discard is destructive and irreversible
|
|
788
|
+
const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
|
|
789
|
+
editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
|
|
790
|
+
const suggestions: PromptSuggestion[] = [
|
|
791
|
+
{ text: `${action} file`, description: "Permanently lose changes", value: "discard" },
|
|
792
|
+
{ text: "Cancel", description: "Keep the file as-is", value: "cancel" },
|
|
793
|
+
];
|
|
794
|
+
editor.setPromptSuggestions(suggestions);
|
|
795
|
+
}
|
|
796
|
+
registerHandler("review_discard_file", review_discard_file);
|
|
615
797
|
|
|
616
|
-
async function
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
if (
|
|
623
|
-
|
|
798
|
+
async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
|
|
799
|
+
if (args.prompt_type !== "review-discard-confirm") return true;
|
|
800
|
+
|
|
801
|
+
const response = args.input.trim().toLowerCase();
|
|
802
|
+
if (response === "discard" || args.selected_index === 0) {
|
|
803
|
+
const f = state.files[state.selectedIndex];
|
|
804
|
+
if (f) {
|
|
805
|
+
if (f.category === 'untracked') {
|
|
806
|
+
await editor.spawnProcess("rm", ["--", f.path]);
|
|
807
|
+
} else {
|
|
808
|
+
await editor.spawnProcess("git", ["checkout", "--", f.path]);
|
|
809
|
+
}
|
|
810
|
+
await refreshMagitData();
|
|
811
|
+
editor.setStatus(`Discarded: ${f.path}`);
|
|
812
|
+
}
|
|
813
|
+
} else {
|
|
814
|
+
editor.setStatus("Discard cancelled");
|
|
624
815
|
}
|
|
816
|
+
return false;
|
|
625
817
|
}
|
|
626
|
-
registerHandler("
|
|
818
|
+
registerHandler("on_review_discard_confirm", on_review_discard_confirm);
|
|
627
819
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
820
|
+
/**
|
|
821
|
+
* Refresh file list and diffs using the new git status approach, then re-render.
|
|
822
|
+
*/
|
|
823
|
+
async function refreshMagitData() {
|
|
824
|
+
const files = await getGitStatus();
|
|
825
|
+
state.files = files;
|
|
826
|
+
state.hunks = await fetchDiffsForFiles(files);
|
|
827
|
+
// Clamp selectedIndex
|
|
828
|
+
if (state.selectedIndex >= state.files.length) {
|
|
829
|
+
state.selectedIndex = Math.max(0, state.files.length - 1);
|
|
830
|
+
}
|
|
831
|
+
state.diffScrollOffset = 0;
|
|
832
|
+
updateMagitDisplay();
|
|
634
833
|
}
|
|
635
|
-
registerHandler("review_next_hunk", review_next_hunk);
|
|
636
834
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
835
|
+
// --- Resize handler ---
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Refresh viewport dimensions from the actual split viewport.
|
|
839
|
+
* This accounts for sidebars (file explorer) that reduce available width,
|
|
840
|
+
* unlike the terminal-level resize event which reports full terminal size.
|
|
841
|
+
*/
|
|
842
|
+
function refreshViewportDimensions(): boolean {
|
|
843
|
+
const viewport = editor.getViewport();
|
|
844
|
+
if (viewport) {
|
|
845
|
+
const changed = viewport.width !== state.viewportWidth || viewport.height !== state.viewportHeight;
|
|
846
|
+
state.viewportWidth = viewport.width;
|
|
847
|
+
state.viewportHeight = viewport.height;
|
|
848
|
+
return changed;
|
|
849
|
+
}
|
|
850
|
+
return false;
|
|
643
851
|
}
|
|
644
|
-
registerHandler("review_prev_hunk", review_prev_hunk);
|
|
645
852
|
|
|
646
|
-
function
|
|
647
|
-
|
|
853
|
+
function onReviewDiffResize(_data: { width: number; height: number }): void {
|
|
854
|
+
if (state.reviewBufferId === null) return;
|
|
855
|
+
refreshViewportDimensions();
|
|
856
|
+
updateMagitDisplay();
|
|
857
|
+
}
|
|
858
|
+
registerHandler("onReviewDiffResize", onReviewDiffResize);
|
|
648
859
|
|
|
649
860
|
let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
|
|
650
861
|
|
|
@@ -848,6 +1059,15 @@ function computeFullFileAlignedDiff(oldContent: string, newContent: string, hunk
|
|
|
848
1059
|
return aligned;
|
|
849
1060
|
}
|
|
850
1061
|
|
|
1062
|
+
interface HighlightTask {
|
|
1063
|
+
range: [number, number];
|
|
1064
|
+
fg: OverlayColorSpec;
|
|
1065
|
+
bg?: OverlayColorSpec;
|
|
1066
|
+
bold?: boolean;
|
|
1067
|
+
italic?: boolean;
|
|
1068
|
+
extend_to_line_end?: boolean;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
851
1071
|
/**
|
|
852
1072
|
* Generate virtual buffer content with diff highlighting for one side.
|
|
853
1073
|
* Returns entries, highlight tasks, and line byte offsets for scroll sync.
|
|
@@ -1025,174 +1245,188 @@ interface CompositeDiffState {
|
|
|
1025
1245
|
let activeCompositeDiffState: CompositeDiffState | null = null;
|
|
1026
1246
|
|
|
1027
1247
|
async function review_drill_down() {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
const h = state.hunks.find(x => x.id === id);
|
|
1033
|
-
if (!h) return;
|
|
1248
|
+
// Use selected file from magit state instead of cursor properties
|
|
1249
|
+
if (state.files.length === 0) return;
|
|
1250
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
1251
|
+
if (!selectedFile) return;
|
|
1034
1252
|
|
|
1035
|
-
|
|
1253
|
+
// Create a minimal hunk-like reference for the rest of the function
|
|
1254
|
+
const h = { file: selectedFile.path, gitStatus: selectedFile.category };
|
|
1036
1255
|
|
|
1037
|
-
|
|
1038
|
-
const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
|
|
1039
|
-
|
|
1040
|
-
// Get git root to construct absolute path
|
|
1041
|
-
const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
|
|
1042
|
-
if (gitRootResult.exit_code !== 0) {
|
|
1043
|
-
editor.setStatus(editor.t("status.not_git_repo"));
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
const gitRoot = gitRootResult.stdout.trim();
|
|
1047
|
-
const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
|
|
1256
|
+
editor.setStatus(editor.t("status.loading_diff"));
|
|
1048
1257
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
editor.setStatus(editor.t("status.failed_old_version"));
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
const oldContent = gitShow.stdout;
|
|
1258
|
+
// Get all hunks for this file
|
|
1259
|
+
const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
|
|
1260
|
+
if (fileHunks.length === 0) return;
|
|
1056
1261
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1262
|
+
// Get git root to construct absolute path
|
|
1263
|
+
const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
|
|
1264
|
+
if (gitRootResult.exit_code !== 0) {
|
|
1265
|
+
editor.setStatus(editor.t("status.not_git_repo"));
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const gitRoot = gitRootResult.stdout.trim();
|
|
1269
|
+
const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
|
|
1063
1270
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
} catch {}
|
|
1073
|
-
activeSideBySideState = null;
|
|
1074
|
-
}
|
|
1271
|
+
// Get old (HEAD) and new (working) file content
|
|
1272
|
+
let oldContent: string;
|
|
1273
|
+
const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
|
|
1274
|
+
if (gitShow.exit_code !== 0) {
|
|
1275
|
+
oldContent = "";
|
|
1276
|
+
} else {
|
|
1277
|
+
oldContent = gitShow.stdout;
|
|
1278
|
+
}
|
|
1075
1279
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
} catch {}
|
|
1083
|
-
activeCompositeDiffState = null;
|
|
1084
|
-
}
|
|
1280
|
+
// Read new file content (use absolute path for readFile)
|
|
1281
|
+
const newContent = await editor.readFile(absoluteFilePath);
|
|
1282
|
+
if (newContent === null) {
|
|
1283
|
+
editor.setStatus(editor.t("status.failed_new_version"));
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1085
1286
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
text: line + '\n',
|
|
1092
|
-
properties: { type: 'line', lineNum: idx + 1 }
|
|
1093
|
-
}));
|
|
1094
|
-
|
|
1095
|
-
const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
|
|
1096
|
-
text: line + '\n',
|
|
1097
|
-
properties: { type: 'line', lineNum: idx + 1 }
|
|
1098
|
-
}));
|
|
1099
|
-
|
|
1100
|
-
// Create source buffers (hidden from tabs, used by composite)
|
|
1101
|
-
const oldResult = await editor.createVirtualBuffer({
|
|
1102
|
-
name: `*OLD:${h.file}*`,
|
|
1103
|
-
mode: "normal",
|
|
1104
|
-
readOnly: true,
|
|
1105
|
-
entries: oldEntries,
|
|
1106
|
-
showLineNumbers: true,
|
|
1107
|
-
editingDisabled: true,
|
|
1108
|
-
hiddenFromTabs: true
|
|
1109
|
-
});
|
|
1110
|
-
const oldBufferId = oldResult.bufferId;
|
|
1111
|
-
|
|
1112
|
-
const newResult = await editor.createVirtualBuffer({
|
|
1113
|
-
name: `*NEW:${h.file}*`,
|
|
1114
|
-
mode: "normal",
|
|
1115
|
-
readOnly: true,
|
|
1116
|
-
entries: newEntries,
|
|
1117
|
-
showLineNumbers: true,
|
|
1118
|
-
editingDisabled: true,
|
|
1119
|
-
hiddenFromTabs: true
|
|
1120
|
-
});
|
|
1121
|
-
const newBufferId = newResult.bufferId;
|
|
1122
|
-
|
|
1123
|
-
// Convert hunks to composite buffer format (parse counts from git diff)
|
|
1124
|
-
const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => {
|
|
1125
|
-
// Parse actual counts from the hunk lines
|
|
1126
|
-
let oldCount = 0, newCount = 0;
|
|
1127
|
-
for (const line of fh.lines) {
|
|
1128
|
-
if (line.startsWith('-')) oldCount++;
|
|
1129
|
-
else if (line.startsWith('+')) newCount++;
|
|
1130
|
-
else if (line.startsWith(' ')) { oldCount++; newCount++; }
|
|
1287
|
+
// Close any existing side-by-side views (old split-based approach)
|
|
1288
|
+
if (activeSideBySideState) {
|
|
1289
|
+
try {
|
|
1290
|
+
if (activeSideBySideState.scrollSyncGroupId !== null) {
|
|
1291
|
+
(editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
|
|
1131
1292
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1293
|
+
editor.closeBuffer(activeSideBySideState.oldBufferId);
|
|
1294
|
+
editor.closeBuffer(activeSideBySideState.newBufferId);
|
|
1295
|
+
} catch {}
|
|
1296
|
+
activeSideBySideState = null;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Close any existing composite diff view
|
|
1300
|
+
if (activeCompositeDiffState) {
|
|
1301
|
+
try {
|
|
1302
|
+
editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId);
|
|
1303
|
+
editor.closeBuffer(activeCompositeDiffState.oldBufferId);
|
|
1304
|
+
editor.closeBuffer(activeCompositeDiffState.newBufferId);
|
|
1305
|
+
} catch {}
|
|
1306
|
+
activeCompositeDiffState = null;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Create virtual buffers for old and new content
|
|
1310
|
+
const oldLines = oldContent.split('\n');
|
|
1311
|
+
const newLines = newContent.split('\n');
|
|
1312
|
+
|
|
1313
|
+
const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
|
|
1314
|
+
text: line + '\n',
|
|
1315
|
+
properties: { type: 'line', lineNum: idx + 1 }
|
|
1316
|
+
}));
|
|
1317
|
+
|
|
1318
|
+
const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
|
|
1319
|
+
text: line + '\n',
|
|
1320
|
+
properties: { type: 'line', lineNum: idx + 1 }
|
|
1321
|
+
}));
|
|
1322
|
+
|
|
1323
|
+
// Create source buffers (hidden from tabs, used by composite)
|
|
1324
|
+
const oldResult = await editor.createVirtualBuffer({
|
|
1325
|
+
name: `*OLD:${h.file}*`,
|
|
1326
|
+
mode: "normal",
|
|
1327
|
+
readOnly: true,
|
|
1328
|
+
entries: oldEntries,
|
|
1329
|
+
showLineNumbers: true,
|
|
1330
|
+
editingDisabled: true,
|
|
1331
|
+
hiddenFromTabs: true
|
|
1332
|
+
});
|
|
1333
|
+
const oldBufferId = oldResult.bufferId;
|
|
1334
|
+
|
|
1335
|
+
const newResult = await editor.createVirtualBuffer({
|
|
1336
|
+
name: `*NEW:${h.file}*`,
|
|
1337
|
+
mode: "normal",
|
|
1338
|
+
readOnly: true,
|
|
1339
|
+
entries: newEntries,
|
|
1340
|
+
showLineNumbers: true,
|
|
1341
|
+
editingDisabled: true,
|
|
1342
|
+
hiddenFromTabs: true
|
|
1343
|
+
});
|
|
1344
|
+
const newBufferId = newResult.bufferId;
|
|
1345
|
+
|
|
1346
|
+
// Convert hunks to composite buffer format (parse counts from git diff)
|
|
1347
|
+
const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => {
|
|
1348
|
+
let oldCount = 0, newCount = 0;
|
|
1349
|
+
for (const line of fh.lines) {
|
|
1350
|
+
if (line.startsWith('-')) oldCount++;
|
|
1351
|
+
else if (line.startsWith('+')) newCount++;
|
|
1352
|
+
else if (line.startsWith(' ')) { oldCount++; newCount++; }
|
|
1353
|
+
}
|
|
1354
|
+
return {
|
|
1355
|
+
oldStart: Math.max(0, fh.oldRange.start - 1),
|
|
1356
|
+
oldCount: oldCount || 1,
|
|
1357
|
+
newStart: Math.max(0, fh.range.start - 1),
|
|
1358
|
+
newCount: newCount || 1
|
|
1359
|
+
};
|
|
1360
|
+
});
|
|
1139
1361
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1362
|
+
// Create composite buffer with side-by-side layout
|
|
1363
|
+
const compositeBufferId = await editor.createCompositeBuffer({
|
|
1364
|
+
name: `*Diff: ${h.file}*`,
|
|
1365
|
+
mode: "diff-view",
|
|
1366
|
+
layout: {
|
|
1367
|
+
type: "side-by-side",
|
|
1368
|
+
ratios: [0.5, 0.5],
|
|
1369
|
+
showSeparator: true
|
|
1370
|
+
},
|
|
1371
|
+
sources: [
|
|
1372
|
+
{
|
|
1373
|
+
bufferId: oldBufferId,
|
|
1374
|
+
label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
|
|
1375
|
+
editable: false,
|
|
1376
|
+
style: {
|
|
1377
|
+
gutterStyle: "diff-markers"
|
|
1378
|
+
}
|
|
1148
1379
|
},
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
removeBg: [80, 40, 40],
|
|
1156
|
-
gutterStyle: "diff-markers"
|
|
1157
|
-
}
|
|
1158
|
-
},
|
|
1159
|
-
{
|
|
1160
|
-
bufferId: newBufferId,
|
|
1161
|
-
label: "NEW (Working)",
|
|
1162
|
-
editable: false,
|
|
1163
|
-
style: {
|
|
1164
|
-
addBg: [40, 80, 40],
|
|
1165
|
-
gutterStyle: "diff-markers"
|
|
1166
|
-
}
|
|
1380
|
+
{
|
|
1381
|
+
bufferId: newBufferId,
|
|
1382
|
+
label: "NEW (Working)",
|
|
1383
|
+
editable: false,
|
|
1384
|
+
style: {
|
|
1385
|
+
gutterStyle: "diff-markers"
|
|
1167
1386
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1387
|
+
}
|
|
1388
|
+
],
|
|
1389
|
+
hunks: compositeHunks.length > 0 ? compositeHunks : null,
|
|
1390
|
+
initialFocusHunk: compositeHunks.length > 0 ? 0 : undefined
|
|
1391
|
+
});
|
|
1171
1392
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1393
|
+
// Store state for cleanup
|
|
1394
|
+
activeCompositeDiffState = {
|
|
1395
|
+
compositeBufferId,
|
|
1396
|
+
oldBufferId,
|
|
1397
|
+
newBufferId,
|
|
1398
|
+
filePath: h.file
|
|
1399
|
+
};
|
|
1179
1400
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1401
|
+
// Show the composite buffer (replaces the review diff buffer)
|
|
1402
|
+
editor.showBuffer(compositeBufferId);
|
|
1182
1403
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1404
|
+
const addedCount = fileHunks.reduce((sum, fh) => {
|
|
1405
|
+
return sum + fh.lines.filter(l => l.startsWith('+')).length;
|
|
1406
|
+
}, 0);
|
|
1407
|
+
const removedCount = fileHunks.reduce((sum, fh) => {
|
|
1408
|
+
return sum + fh.lines.filter(l => l.startsWith('-')).length;
|
|
1409
|
+
}, 0);
|
|
1410
|
+
const modifiedCount = Math.min(addedCount, removedCount);
|
|
1190
1411
|
|
|
1191
|
-
|
|
1192
|
-
}
|
|
1412
|
+
editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
|
|
1193
1413
|
}
|
|
1194
1414
|
registerHandler("review_drill_down", review_drill_down);
|
|
1195
1415
|
|
|
1416
|
+
// --- Hunk navigation for side-by-side diff view ---
|
|
1417
|
+
|
|
1418
|
+
function review_next_hunk() {
|
|
1419
|
+
if (!activeCompositeDiffState) return;
|
|
1420
|
+
editor.compositeNextHunk(activeCompositeDiffState.compositeBufferId);
|
|
1421
|
+
}
|
|
1422
|
+
registerHandler("review_next_hunk", review_next_hunk);
|
|
1423
|
+
|
|
1424
|
+
function review_prev_hunk() {
|
|
1425
|
+
if (!activeCompositeDiffState) return;
|
|
1426
|
+
editor.compositePrevHunk(activeCompositeDiffState.compositeBufferId);
|
|
1427
|
+
}
|
|
1428
|
+
registerHandler("review_prev_hunk", review_prev_hunk);
|
|
1429
|
+
|
|
1196
1430
|
// Define the diff-view mode - inherits from "normal" for all standard navigation/selection/copy
|
|
1197
1431
|
// Only adds diff-specific keybindings (close, hunk navigation)
|
|
1198
1432
|
editor.defineMode("diff-view", [
|
|
@@ -1208,10 +1442,14 @@ editor.defineMode("diff-view", [
|
|
|
1208
1442
|
// --- Review Comment Actions ---
|
|
1209
1443
|
|
|
1210
1444
|
function getCurrentHunkId(): string | null {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
return null;
|
|
1445
|
+
// In magit mode, get the first hunk of the selected file
|
|
1446
|
+
if (state.files.length === 0) return null;
|
|
1447
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
1448
|
+
if (!selectedFile) return null;
|
|
1449
|
+
const hunk = state.hunks.find(
|
|
1450
|
+
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
1451
|
+
);
|
|
1452
|
+
return hunk?.id || null;
|
|
1215
1453
|
}
|
|
1216
1454
|
|
|
1217
1455
|
interface PendingCommentInfo {
|
|
@@ -1224,20 +1462,22 @@ interface PendingCommentInfo {
|
|
|
1224
1462
|
}
|
|
1225
1463
|
|
|
1226
1464
|
function getCurrentLineInfo(): PendingCommentInfo | null {
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1465
|
+
// In magit mode, get info from the selected file's first hunk
|
|
1466
|
+
if (state.files.length === 0) return null;
|
|
1467
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
1468
|
+
if (!selectedFile) return null;
|
|
1469
|
+
const hunk = state.hunks.find(
|
|
1470
|
+
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
1471
|
+
);
|
|
1472
|
+
if (!hunk) return null;
|
|
1473
|
+
return {
|
|
1474
|
+
hunkId: hunk.id,
|
|
1475
|
+
file: hunk.file,
|
|
1476
|
+
lineType: undefined,
|
|
1477
|
+
oldLine: undefined,
|
|
1478
|
+
newLine: undefined,
|
|
1479
|
+
lineContent: undefined
|
|
1480
|
+
};
|
|
1241
1481
|
}
|
|
1242
1482
|
|
|
1243
1483
|
// Pending prompt state for event-based prompt handling
|
|
@@ -1284,7 +1524,7 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
|
|
|
1284
1524
|
line_type: pendingCommentInfo.lineType
|
|
1285
1525
|
};
|
|
1286
1526
|
state.comments.push(comment);
|
|
1287
|
-
|
|
1527
|
+
updateMagitDisplay();
|
|
1288
1528
|
let lineRef = 'hunk';
|
|
1289
1529
|
if (comment.line_type === 'add' && comment.new_line) {
|
|
1290
1530
|
lineRef = `line +${comment.new_line}`;
|
|
@@ -1313,6 +1553,7 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
|
|
|
1313
1553
|
|
|
1314
1554
|
// Register prompt event handlers
|
|
1315
1555
|
editor.on("prompt_confirmed", "on_review_prompt_confirm");
|
|
1556
|
+
editor.on("prompt_confirmed", "on_review_discard_confirm");
|
|
1316
1557
|
editor.on("prompt_cancelled", "on_review_prompt_cancel");
|
|
1317
1558
|
|
|
1318
1559
|
async function review_approve_hunk() {
|
|
@@ -1321,7 +1562,7 @@ async function review_approve_hunk() {
|
|
|
1321
1562
|
const h = state.hunks.find(x => x.id === hunkId);
|
|
1322
1563
|
if (h) {
|
|
1323
1564
|
h.reviewStatus = 'approved';
|
|
1324
|
-
|
|
1565
|
+
updateMagitDisplay();
|
|
1325
1566
|
editor.setStatus(editor.t("status.hunk_approved"));
|
|
1326
1567
|
}
|
|
1327
1568
|
}
|
|
@@ -1333,7 +1574,7 @@ async function review_reject_hunk() {
|
|
|
1333
1574
|
const h = state.hunks.find(x => x.id === hunkId);
|
|
1334
1575
|
if (h) {
|
|
1335
1576
|
h.reviewStatus = 'rejected';
|
|
1336
|
-
|
|
1577
|
+
updateMagitDisplay();
|
|
1337
1578
|
editor.setStatus(editor.t("status.hunk_rejected"));
|
|
1338
1579
|
}
|
|
1339
1580
|
}
|
|
@@ -1345,7 +1586,7 @@ async function review_needs_changes() {
|
|
|
1345
1586
|
const h = state.hunks.find(x => x.id === hunkId);
|
|
1346
1587
|
if (h) {
|
|
1347
1588
|
h.reviewStatus = 'needs_changes';
|
|
1348
|
-
|
|
1589
|
+
updateMagitDisplay();
|
|
1349
1590
|
editor.setStatus(editor.t("status.hunk_needs_changes"));
|
|
1350
1591
|
}
|
|
1351
1592
|
}
|
|
@@ -1357,7 +1598,7 @@ async function review_question_hunk() {
|
|
|
1357
1598
|
const h = state.hunks.find(x => x.id === hunkId);
|
|
1358
1599
|
if (h) {
|
|
1359
1600
|
h.reviewStatus = 'question';
|
|
1360
|
-
|
|
1601
|
+
updateMagitDisplay();
|
|
1361
1602
|
editor.setStatus(editor.t("status.hunk_question"));
|
|
1362
1603
|
}
|
|
1363
1604
|
}
|
|
@@ -1369,7 +1610,7 @@ async function review_clear_status() {
|
|
|
1369
1610
|
const h = state.hunks.find(x => x.id === hunkId);
|
|
1370
1611
|
if (h) {
|
|
1371
1612
|
h.reviewStatus = 'pending';
|
|
1372
|
-
|
|
1613
|
+
updateMagitDisplay();
|
|
1373
1614
|
editor.setStatus(editor.t("status.hunk_status_cleared"));
|
|
1374
1615
|
}
|
|
1375
1616
|
}
|
|
@@ -1499,17 +1740,33 @@ async function start_review_diff() {
|
|
|
1499
1740
|
editor.setStatus(editor.t("status.generating"));
|
|
1500
1741
|
editor.setContext("review-mode", true);
|
|
1501
1742
|
|
|
1502
|
-
//
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1743
|
+
// Get viewport size
|
|
1744
|
+
const viewport = editor.getViewport();
|
|
1745
|
+
if (viewport) {
|
|
1746
|
+
state.viewportWidth = viewport.width;
|
|
1747
|
+
state.viewportHeight = viewport.height;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Fetch data using new git status approach
|
|
1751
|
+
state.files = await getGitStatus();
|
|
1752
|
+
state.hunks = await fetchDiffsForFiles(state.files);
|
|
1753
|
+
state.comments = [];
|
|
1754
|
+
state.selectedIndex = 0;
|
|
1755
|
+
state.fileScrollOffset = 0;
|
|
1756
|
+
state.diffScrollOffset = 0;
|
|
1757
|
+
state.focusPanel = 'files';
|
|
1758
|
+
|
|
1759
|
+
// Build initial display
|
|
1760
|
+
const initialEntries = buildMagitDisplayEntries();
|
|
1506
1761
|
|
|
1507
1762
|
const bufferId = await VirtualBufferFactory.create({
|
|
1508
1763
|
name: "*Review Diff*", mode: "review-mode", readOnly: true,
|
|
1509
|
-
entries:
|
|
1764
|
+
entries: initialEntries, showLineNumbers: false, showCursors: false
|
|
1510
1765
|
});
|
|
1511
1766
|
state.reviewBufferId = bufferId;
|
|
1512
|
-
|
|
1767
|
+
|
|
1768
|
+
// Register resize handler
|
|
1769
|
+
editor.on("resize", "onReviewDiffResize");
|
|
1513
1770
|
|
|
1514
1771
|
editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
|
|
1515
1772
|
editor.on("buffer_activated", "on_review_buffer_activated");
|
|
@@ -1520,6 +1777,7 @@ registerHandler("start_review_diff", start_review_diff);
|
|
|
1520
1777
|
function stop_review_diff() {
|
|
1521
1778
|
state.reviewBufferId = null;
|
|
1522
1779
|
editor.setContext("review-mode", false);
|
|
1780
|
+
editor.off("resize", "onReviewDiffResize");
|
|
1523
1781
|
editor.off("buffer_activated", "on_review_buffer_activated");
|
|
1524
1782
|
editor.off("buffer_closed", "on_review_buffer_closed");
|
|
1525
1783
|
editor.setStatus(editor.t("status.stopped"));
|
|
@@ -1528,7 +1786,7 @@ registerHandler("stop_review_diff", stop_review_diff);
|
|
|
1528
1786
|
|
|
1529
1787
|
|
|
1530
1788
|
function on_review_buffer_activated(data: any) {
|
|
1531
|
-
if (data.buffer_id === state.reviewBufferId)
|
|
1789
|
+
if (data.buffer_id === state.reviewBufferId) refreshMagitData();
|
|
1532
1790
|
}
|
|
1533
1791
|
registerHandler("on_review_buffer_activated", on_review_buffer_activated);
|
|
1534
1792
|
|
|
@@ -1579,15 +1837,29 @@ async function side_by_side_diff_current_file() {
|
|
|
1579
1837
|
}
|
|
1580
1838
|
}
|
|
1581
1839
|
|
|
1582
|
-
//
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1840
|
+
// Check if the file is untracked
|
|
1841
|
+
const isTrackedResult = await editor.spawnProcess("git", ["-C", gitRoot, "ls-files", "--", filePath]);
|
|
1842
|
+
const isUntracked = isTrackedResult.exit_code !== 0 || !isTrackedResult.stdout.trim();
|
|
1843
|
+
|
|
1844
|
+
// Get hunks for this specific file
|
|
1845
|
+
let diffOutput: string;
|
|
1846
|
+
if (isUntracked) {
|
|
1847
|
+
// For untracked files, use --no-index to diff against /dev/null
|
|
1848
|
+
const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "--no-index", "--unified=3", "--", "/dev/null", filePath]);
|
|
1849
|
+
// git diff --no-index exits with 1 when there are differences, which is expected
|
|
1850
|
+
diffOutput = result.stdout || "";
|
|
1851
|
+
} else {
|
|
1852
|
+
// For tracked files, use normal diff against HEAD
|
|
1853
|
+
const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "HEAD", "--unified=3", "--", filePath]);
|
|
1854
|
+
if (result.exit_code !== 0) {
|
|
1855
|
+
editor.setStatus(editor.t("status.failed_git_diff"));
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
diffOutput = result.stdout;
|
|
1587
1859
|
}
|
|
1588
1860
|
|
|
1589
1861
|
// Parse hunks from diff output
|
|
1590
|
-
const lines =
|
|
1862
|
+
const lines = diffOutput.split('\n');
|
|
1591
1863
|
const fileHunks: Hunk[] = [];
|
|
1592
1864
|
let currentHunk: Hunk | null = null;
|
|
1593
1865
|
|
|
@@ -1604,7 +1876,7 @@ async function side_by_side_diff_current_file() {
|
|
|
1604
1876
|
file: filePath,
|
|
1605
1877
|
range: { start: newStart, end: newStart + newCount - 1 },
|
|
1606
1878
|
oldRange: { start: oldStart, end: oldStart + oldCount - 1 },
|
|
1607
|
-
type: 'modify',
|
|
1879
|
+
type: isUntracked ? 'add' : 'modify',
|
|
1608
1880
|
lines: [],
|
|
1609
1881
|
status: 'pending',
|
|
1610
1882
|
reviewStatus: 'pending',
|
|
@@ -1626,12 +1898,18 @@ async function side_by_side_diff_current_file() {
|
|
|
1626
1898
|
}
|
|
1627
1899
|
|
|
1628
1900
|
// Get old (HEAD) and new (working) file content (use -C gitRoot since filePath is relative to git root)
|
|
1629
|
-
|
|
1630
|
-
if (
|
|
1631
|
-
|
|
1632
|
-
|
|
1901
|
+
let oldContent: string;
|
|
1902
|
+
if (isUntracked) {
|
|
1903
|
+
// For untracked files, old content is empty (file didn't exist before)
|
|
1904
|
+
oldContent = "";
|
|
1905
|
+
} else {
|
|
1906
|
+
const gitShow = await editor.spawnProcess("git", ["-C", gitRoot, "show", `HEAD:${filePath}`]);
|
|
1907
|
+
if (gitShow.exit_code !== 0) {
|
|
1908
|
+
editor.setStatus(editor.t("status.failed_old_new_file"));
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
oldContent = gitShow.stdout;
|
|
1633
1912
|
}
|
|
1634
|
-
const oldContent = gitShow.stdout;
|
|
1635
1913
|
|
|
1636
1914
|
// Read new file content (use absolute path for readFile)
|
|
1637
1915
|
const newContent = await editor.readFile(absolutePath);
|
|
@@ -1701,9 +1979,9 @@ async function side_by_side_diff_current_file() {
|
|
|
1701
1979
|
|
|
1702
1980
|
// Convert hunks to composite buffer format
|
|
1703
1981
|
const compositeHunks: TsCompositeHunk[] = fileHunks.map(h => ({
|
|
1704
|
-
oldStart: h.oldRange.start - 1, // Convert to 0-indexed
|
|
1705
|
-
oldCount: h.oldRange.end - h.oldRange.start + 1,
|
|
1706
|
-
newStart: h.range.start - 1, // Convert to 0-indexed
|
|
1982
|
+
oldStart: Math.max(0, h.oldRange.start - 1), // Convert to 0-indexed (0 for new files)
|
|
1983
|
+
oldCount: Math.max(1, h.oldRange.end - h.oldRange.start + 1),
|
|
1984
|
+
newStart: Math.max(0, h.range.start - 1), // Convert to 0-indexed
|
|
1707
1985
|
newCount: h.range.end - h.range.start + 1
|
|
1708
1986
|
}));
|
|
1709
1987
|
|
|
@@ -1719,10 +1997,9 @@ async function side_by_side_diff_current_file() {
|
|
|
1719
1997
|
sources: [
|
|
1720
1998
|
{
|
|
1721
1999
|
bufferId: oldBufferId,
|
|
1722
|
-
label: "OLD (HEAD)",
|
|
2000
|
+
label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
|
|
1723
2001
|
editable: false,
|
|
1724
2002
|
style: {
|
|
1725
|
-
removeBg: [80, 40, 40],
|
|
1726
2003
|
gutterStyle: "diff-markers"
|
|
1727
2004
|
}
|
|
1728
2005
|
},
|
|
@@ -1731,7 +2008,6 @@ async function side_by_side_diff_current_file() {
|
|
|
1731
2008
|
label: "NEW (Working)",
|
|
1732
2009
|
editable: false,
|
|
1733
2010
|
style: {
|
|
1734
|
-
addBg: [40, 80, 40],
|
|
1735
2011
|
gutterStyle: "diff-markers"
|
|
1736
2012
|
}
|
|
1737
2013
|
}
|
|
@@ -1813,21 +2089,24 @@ registerHandler("on_buffer_closed", on_buffer_closed);
|
|
|
1813
2089
|
editor.on("buffer_closed", "on_buffer_closed");
|
|
1814
2090
|
|
|
1815
2091
|
editor.defineMode("review-mode", [
|
|
1816
|
-
//
|
|
1817
|
-
["
|
|
1818
|
-
|
|
1819
|
-
["
|
|
1820
|
-
["
|
|
2092
|
+
// Navigation (arrow keys, vim keys, page keys)
|
|
2093
|
+
["Up", "review_nav_up"], ["Down", "review_nav_down"],
|
|
2094
|
+
["k", "review_nav_up"], ["j", "review_nav_down"],
|
|
2095
|
+
["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
|
|
2096
|
+
["Home", "review_nav_home"], ["End", "review_nav_end"],
|
|
2097
|
+
["Tab", "review_toggle_focus"],
|
|
2098
|
+
["Left", "review_focus_files"], ["Right", "review_focus_diff"],
|
|
2099
|
+
// Drill-down
|
|
2100
|
+
["Enter", "review_drill_down"],
|
|
2101
|
+
// Git actions (plain letter keys — safe because buffer is read-only with cursors hidden)
|
|
2102
|
+
["s", "review_stage_file"], ["u", "review_unstage_file"],
|
|
2103
|
+
["d", "review_discard_file"],
|
|
2104
|
+
["r", "review_refresh"],
|
|
1821
2105
|
// Review actions
|
|
1822
|
-
["c", "review_add_comment"],
|
|
1823
2106
|
["a", "review_approve_hunk"],
|
|
1824
|
-
["
|
|
1825
|
-
["!", "review_needs_changes"],
|
|
1826
|
-
["?", "review_question_hunk"],
|
|
1827
|
-
["u", "review_clear_status"],
|
|
1828
|
-
["O", "review_set_overall_feedback"],
|
|
2107
|
+
["c", "review_add_comment"],
|
|
1829
2108
|
// Export
|
|
1830
|
-
["
|
|
2109
|
+
["e", "review_export_session"],
|
|
1831
2110
|
], true);
|
|
1832
2111
|
|
|
1833
2112
|
editor.debug("Review Diff plugin loaded with review comments support");
|