@fresh-editor/fresh-editor 0.2.21 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -1
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +197 -393
- package/plugins/audit_mode.ts +1747 -841
- package/plugins/config-schema.json +161 -35
- package/plugins/git_explorer.ts +7 -7
- package/plugins/lib/fresh.d.ts +67 -2
- package/plugins/lib/virtual-buffer-factory.ts +8 -0
- package/plugins/pkg.ts +151 -397
- package/plugins/schemas/package.schema.json +8 -0
- package/plugins/theme_editor.i18n.json +238 -14
- package/plugins/theme_editor.ts +299 -204
- 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
|
@@ -3,26 +3,15 @@
|
|
|
3
3
|
/// <reference path="./lib/virtual-buffer-factory.ts" />
|
|
4
4
|
|
|
5
5
|
// Review Diff Plugin
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// - Uses editor.prompt() which doesn't exist in the API (needs event-based prompt)
|
|
10
|
-
// - Uses VirtualBufferOptions.read_only (should be readOnly)
|
|
11
|
-
// - References stop_review_diff which is undefined
|
|
6
|
+
// Magit-style split-panel UI for reviewing and staging code changes.
|
|
7
|
+
// Left panel: file list (staged/unstaged/untracked). Right panel: diff.
|
|
8
|
+
// Actions: stage/unstage/discard hunks or files, line comments, export.
|
|
12
9
|
const editor = getEditor();
|
|
13
10
|
|
|
14
11
|
import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
|
|
15
12
|
const VirtualBufferFactory = createVirtualBufferFactory(editor);
|
|
16
13
|
|
|
17
|
-
/**
|
|
18
|
-
* Hunk status for staging
|
|
19
|
-
*/
|
|
20
|
-
type HunkStatus = 'pending' | 'staged' | 'discarded';
|
|
21
14
|
|
|
22
|
-
/**
|
|
23
|
-
* Review status for a hunk
|
|
24
|
-
*/
|
|
25
|
-
type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
|
|
26
15
|
|
|
27
16
|
/**
|
|
28
17
|
* A review comment attached to a specific line in a file
|
|
@@ -57,49 +46,101 @@ interface Hunk {
|
|
|
57
46
|
oldRange: { start: number; end: number }; // old file line range
|
|
58
47
|
type: 'add' | 'remove' | 'modify';
|
|
59
48
|
lines: string[];
|
|
60
|
-
status: HunkStatus;
|
|
61
|
-
reviewStatus: ReviewStatus;
|
|
62
49
|
contextHeader: string;
|
|
63
50
|
byteOffset: number; // Position in the virtual buffer
|
|
51
|
+
gitStatus?: 'staged' | 'unstaged' | 'untracked';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A file entry from git status --porcelain
|
|
56
|
+
*/
|
|
57
|
+
interface FileEntry {
|
|
58
|
+
path: string;
|
|
59
|
+
status: string; // 'M', 'A', 'D', 'R', 'C', '?'
|
|
60
|
+
category: 'staged' | 'unstaged' | 'untracked';
|
|
61
|
+
origPath?: string; // for renames/copies
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
/**
|
|
67
65
|
* Review Session State
|
|
66
|
+
*
|
|
67
|
+
* Scrolling and cursor tracking inside the panel buffers is handled by the
|
|
68
|
+
* editor core natively — this state only mirrors what the plugin needs to
|
|
69
|
+
* know between events (selected file, focused panel, hunk header rows for
|
|
70
|
+
* `n`/`p` jumps).
|
|
68
71
|
*/
|
|
69
72
|
interface ReviewState {
|
|
70
73
|
hunks: Hunk[];
|
|
71
|
-
hunkStatus: Record<string, HunkStatus>;
|
|
72
74
|
comments: ReviewComment[];
|
|
73
|
-
|
|
74
|
-
overallFeedback?: string;
|
|
75
|
+
note: string;
|
|
75
76
|
reviewBufferId: number | null;
|
|
77
|
+
// New magit-style state
|
|
78
|
+
files: FileEntry[];
|
|
79
|
+
selectedIndex: number;
|
|
80
|
+
viewportWidth: number;
|
|
81
|
+
viewportHeight: number;
|
|
82
|
+
focusPanel: 'files' | 'diff';
|
|
83
|
+
groupId: number | null;
|
|
84
|
+
panelBuffers: Record<string, number>;
|
|
85
|
+
// Caches populated each time the diff panel is rebuilt — used by `n`/`p`
|
|
86
|
+
// hunk navigation, to translate diff-panel row numbers into byte positions
|
|
87
|
+
// for `setBufferCursor`, and to draw the cursor-line highlight overlay.
|
|
88
|
+
// The array has length `(rowCount + 1)`: index `i` is the byte offset of
|
|
89
|
+
// row `i + 1`, and the final entry is the total buffer length (sentinel
|
|
90
|
+
// for the end of the last row).
|
|
91
|
+
hunkHeaderRows: number[]; // 1-indexed row numbers in the diff panel
|
|
92
|
+
diffLineByteOffsets: number[];
|
|
93
|
+
diffCursorRow: number; // 1-indexed, last known cursor row in diff panel
|
|
94
|
+
/** Cache of pre-built diff-panel entries keyed by `${file}\0${gitStatus}`,
|
|
95
|
+
* populated lazily by buildDiffPanelEntries. Cleared on refreshMagitData. */
|
|
96
|
+
diffCache: Record<string, CachedDiff>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface CachedDiff {
|
|
100
|
+
entries: TextPropertyEntry[];
|
|
101
|
+
hunkHeaderRows: number[];
|
|
102
|
+
diffLineByteOffsets: number[];
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
const state: ReviewState = {
|
|
79
106
|
hunks: [],
|
|
80
|
-
hunkStatus: {},
|
|
81
107
|
comments: [],
|
|
108
|
+
note: '',
|
|
82
109
|
reviewBufferId: null,
|
|
110
|
+
files: [],
|
|
111
|
+
selectedIndex: 0,
|
|
112
|
+
viewportWidth: 80,
|
|
113
|
+
viewportHeight: 24,
|
|
114
|
+
focusPanel: 'files',
|
|
115
|
+
groupId: null,
|
|
116
|
+
panelBuffers: {},
|
|
117
|
+
hunkHeaderRows: [],
|
|
118
|
+
diffLineByteOffsets: [],
|
|
119
|
+
diffCursorRow: 1,
|
|
120
|
+
diffCache: {},
|
|
83
121
|
};
|
|
84
122
|
|
|
123
|
+
// Theme colour for the synthetic "cursor line" highlight in the panel
|
|
124
|
+
// buffers. Reintroduced after the per-line bg overlay was deleted from the
|
|
125
|
+
// builders — `applyCursorLineOverlay` writes it on every cursor_moved event.
|
|
126
|
+
const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
|
|
127
|
+
const CURSOR_LINE_NS = "review-cursor-line";
|
|
128
|
+
|
|
85
129
|
// --- Refresh State ---
|
|
86
|
-
let isUpdating = false;
|
|
87
130
|
|
|
88
131
|
// --- Colors & Styles ---
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
const STYLE_REJECTED: [number, number, number] = [200, 100, 100]; // Red X
|
|
102
|
-
const STYLE_QUESTION: [number, number, number] = [200, 200, 100]; // Yellow ?
|
|
132
|
+
// Colors use theme keys where possible, falling back to direct values
|
|
133
|
+
const STYLE_BORDER: OverlayColorSpec = "ui.split_separator_fg";
|
|
134
|
+
const STYLE_HEADER: OverlayColorSpec = "syntax.keyword";
|
|
135
|
+
const STYLE_FILE_NAME: OverlayColorSpec = "syntax.string";
|
|
136
|
+
const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
|
|
137
|
+
const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
|
|
138
|
+
const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
|
|
139
|
+
const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
|
|
140
|
+
|
|
141
|
+
const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
|
|
142
|
+
const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
|
|
143
|
+
|
|
103
144
|
|
|
104
145
|
/**
|
|
105
146
|
* Calculate UTF-8 byte length of a string manually since TextEncoder is not available
|
|
@@ -124,53 +165,49 @@ interface DiffPart {
|
|
|
124
165
|
type: 'added' | 'removed' | 'unchanged';
|
|
125
166
|
}
|
|
126
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Inline word-level diff between two changed lines.
|
|
170
|
+
*
|
|
171
|
+
* Used to highlight the *changed region* inside a -/+ pair, called once per
|
|
172
|
+
* adjacent pair while building a file's diff. The previous implementation
|
|
173
|
+
* was a full O(n*m) LCS that allocated an (n+1)*(m+1) DP table per pair —
|
|
174
|
+
* fast enough for short lines, but for files with hundreds of long-line
|
|
175
|
+
* changes (e.g. `audit_mode.ts` itself) it added hundreds of milliseconds
|
|
176
|
+
* to every diff rebuild and made file-list navigation visibly laggy.
|
|
177
|
+
*
|
|
178
|
+
* This O(n+m) scan finds the longest common prefix and suffix and reports
|
|
179
|
+
* everything in between as the changed region. It misses internal matches
|
|
180
|
+
* (e.g. it can't tell that "abc-xy-def" → "abc-zw-def" only changed the
|
|
181
|
+
* middle "xy"), but for inline highlighting that's fine — the human eye is
|
|
182
|
+
* already drawn to the line as a whole, the highlight just answers "where
|
|
183
|
+
* inside the line did the change happen?". The cost difference is dramatic:
|
|
184
|
+
* for two 200-char lines, ~400 char compares vs. ~40 000.
|
|
185
|
+
*/
|
|
127
186
|
function diffStrings(oldStr: string, newStr: string): DiffPart[] {
|
|
128
187
|
const n = oldStr.length;
|
|
129
188
|
const m = newStr.length;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
189
|
+
let pre = 0;
|
|
190
|
+
const minLen = Math.min(n, m);
|
|
191
|
+
while (pre < minLen && oldStr.charCodeAt(pre) === newStr.charCodeAt(pre)) pre++;
|
|
192
|
+
let suf = 0;
|
|
193
|
+
while (
|
|
194
|
+
suf < n - pre &&
|
|
195
|
+
suf < m - pre &&
|
|
196
|
+
oldStr.charCodeAt(n - 1 - suf) === newStr.charCodeAt(m - 1 - suf)
|
|
197
|
+
) {
|
|
198
|
+
suf++;
|
|
140
199
|
}
|
|
141
200
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
149
|
-
result.unshift({ text: newStr[j - 1], type: 'added' });
|
|
150
|
-
j--;
|
|
151
|
-
} else {
|
|
152
|
-
result.unshift({ text: oldStr[i - 1], type: 'removed' });
|
|
153
|
-
i--;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const coalesced: DiffPart[] = [];
|
|
158
|
-
for (const part of result) {
|
|
159
|
-
const last = coalesced[coalesced.length - 1];
|
|
160
|
-
if (last && last.type === part.type) {
|
|
161
|
-
last.text += part.text;
|
|
162
|
-
} else {
|
|
163
|
-
coalesced.push(part);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return coalesced;
|
|
201
|
+
const parts: DiffPart[] = [];
|
|
202
|
+
if (pre > 0) parts.push({ text: oldStr.slice(0, pre), type: 'unchanged' });
|
|
203
|
+
if (pre < n - suf) parts.push({ text: oldStr.slice(pre, n - suf), type: 'removed' });
|
|
204
|
+
if (pre < m - suf) parts.push({ text: newStr.slice(pre, m - suf), type: 'added' });
|
|
205
|
+
if (suf > 0) parts.push({ text: oldStr.slice(n - suf), type: 'unchanged' });
|
|
206
|
+
return parts;
|
|
167
207
|
}
|
|
168
208
|
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
if (result.exit_code !== 0) return [];
|
|
172
|
-
|
|
173
|
-
const lines = result.stdout.split('\n');
|
|
209
|
+
function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
|
|
210
|
+
const lines = stdout.split('\n');
|
|
174
211
|
const hunks: Hunk[] = [];
|
|
175
212
|
let currentFile = "";
|
|
176
213
|
let currentHunk: Hunk | null = null;
|
|
@@ -189,16 +226,16 @@ async function getGitDiff(): Promise<Hunk[]> {
|
|
|
189
226
|
const oldStart = parseInt(match[1]);
|
|
190
227
|
const newStart = parseInt(match[2]);
|
|
191
228
|
currentHunk = {
|
|
192
|
-
id: `${currentFile}:${newStart}`,
|
|
229
|
+
id: `${currentFile}:${newStart}:${gitStatus}`,
|
|
193
230
|
file: currentFile,
|
|
194
231
|
range: { start: newStart, end: newStart },
|
|
195
232
|
oldRange: { start: oldStart, end: oldStart },
|
|
196
233
|
type: 'modify',
|
|
197
234
|
lines: [],
|
|
198
235
|
status: 'pending',
|
|
199
|
-
reviewStatus: 'pending',
|
|
200
236
|
contextHeader: match[3]?.trim() || "",
|
|
201
|
-
byteOffset: 0
|
|
237
|
+
byteOffset: 0,
|
|
238
|
+
gitStatus
|
|
202
239
|
};
|
|
203
240
|
hunks.push(currentHunk);
|
|
204
241
|
}
|
|
@@ -211,440 +248,1030 @@ async function getGitDiff(): Promise<Hunk[]> {
|
|
|
211
248
|
return hunks;
|
|
212
249
|
}
|
|
213
250
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
251
|
+
// --- Git status detection ---
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse `git status --porcelain -z` output into FileEntry[].
|
|
255
|
+
*
|
|
256
|
+
* Format: each entry is "XY path\0" where X = index status, Y = worktree status.
|
|
257
|
+
* Renames/copies add "origPath\0" after the entry.
|
|
258
|
+
* A file can appear in BOTH staged and unstaged if both X and Y are set.
|
|
259
|
+
*/
|
|
260
|
+
function parseGitStatusPorcelain(raw: string): FileEntry[] {
|
|
261
|
+
const files: FileEntry[] = [];
|
|
262
|
+
if (!raw) return files;
|
|
263
|
+
|
|
264
|
+
// Split on null bytes
|
|
265
|
+
const parts = raw.split('\0');
|
|
266
|
+
let i = 0;
|
|
267
|
+
while (i < parts.length) {
|
|
268
|
+
const entry = parts[i];
|
|
269
|
+
if (entry.length < 3) { i++; continue; }
|
|
270
|
+
|
|
271
|
+
const x = entry[0]; // index (staged) status
|
|
272
|
+
const y = entry[1]; // worktree (unstaged) status
|
|
273
|
+
// entry[2] is a space
|
|
274
|
+
const path = entry.slice(3);
|
|
275
|
+
|
|
276
|
+
if (!path) { i++; continue; }
|
|
277
|
+
|
|
278
|
+
// Check for rename/copy — next part is the original path
|
|
279
|
+
let origPath: string | undefined;
|
|
280
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
|
|
281
|
+
i++;
|
|
282
|
+
origPath = parts[i];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Untracked files: XY = '??'
|
|
286
|
+
if (x === '?' && y === '?') {
|
|
287
|
+
files.push({ path, status: '?', category: 'untracked' });
|
|
288
|
+
i++;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Ignored files: XY = '!!' — skip
|
|
293
|
+
if (x === '!' && y === '!') {
|
|
294
|
+
i++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Staged changes: X is not ' ' and not '?'
|
|
299
|
+
if (x !== ' ' && x !== '?') {
|
|
300
|
+
files.push({ path, status: x, category: 'staged', origPath });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Unstaged changes: Y is not ' ' and not '?'
|
|
304
|
+
if (y !== ' ' && y !== '?') {
|
|
305
|
+
files.push({ path, status: y, category: 'unstaged', origPath });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
i++;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Sort: staged → unstaged → untracked, then by filename
|
|
312
|
+
const categoryOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
|
|
313
|
+
files.sort((a, b) => {
|
|
314
|
+
const orderA = categoryOrder[a.category] ?? 2;
|
|
315
|
+
const orderB = categoryOrder[b.category] ?? 2;
|
|
316
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
317
|
+
return a.path.localeCompare(b.path);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return files;
|
|
221
321
|
}
|
|
222
322
|
|
|
223
323
|
/**
|
|
224
|
-
*
|
|
324
|
+
* Single source of truth for changed files using `git status --porcelain -z`.
|
|
225
325
|
*/
|
|
226
|
-
async function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
}
|
|
326
|
+
async function getGitStatus(): Promise<FileEntry[]> {
|
|
327
|
+
const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
|
|
328
|
+
if (result.exit_code !== 0) return [];
|
|
329
|
+
return parseGitStatusPorcelain(result.stdout);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Fetch unified diffs for the given file entries.
|
|
334
|
+
* Groups by category to minimize git invocations.
|
|
335
|
+
*/
|
|
336
|
+
async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
|
|
337
|
+
const allHunks: Hunk[] = [];
|
|
338
|
+
|
|
339
|
+
const hasStaged = files.some(f => f.category === 'staged');
|
|
340
|
+
const hasUnstaged = files.some(f => f.category === 'unstaged');
|
|
341
|
+
const untrackedFiles = files.filter(f => f.category === 'untracked');
|
|
342
|
+
|
|
343
|
+
// Staged diffs
|
|
344
|
+
if (hasStaged) {
|
|
345
|
+
const result = await editor.spawnProcess("git", ["diff", "--cached", "--unified=3"]);
|
|
346
|
+
if (result.exit_code === 0 && result.stdout.trim()) {
|
|
347
|
+
allHunks.push(...parseDiffOutput(result.stdout, 'staged'));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Unstaged diffs
|
|
352
|
+
if (hasUnstaged) {
|
|
353
|
+
const result = await editor.spawnProcess("git", ["diff", "--unified=3"]);
|
|
354
|
+
if (result.exit_code === 0 && result.stdout.trim()) {
|
|
355
|
+
allHunks.push(...parseDiffOutput(result.stdout, 'unstaged'));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Untracked file diffs
|
|
360
|
+
for (const f of untrackedFiles) {
|
|
361
|
+
const result = await editor.spawnProcess("git", [
|
|
362
|
+
"diff", "--no-index", "--unified=3", "/dev/null", f.path
|
|
363
|
+
]);
|
|
364
|
+
if (result.stdout.trim()) {
|
|
365
|
+
const hunks = parseDiffOutput(result.stdout, 'untracked');
|
|
366
|
+
for (const h of hunks) {
|
|
367
|
+
h.file = f.path;
|
|
368
|
+
h.id = `${f.path}:${h.range.start}:untracked`;
|
|
369
|
+
h.type = 'add';
|
|
405
370
|
}
|
|
371
|
+
allHunks.push(...hunks);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
406
374
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
375
|
+
// Sort: staged → unstaged → untracked, then by filename
|
|
376
|
+
const statusOrder: Record<string, number> = { staged: 0, unstaged: 1, untracked: 2 };
|
|
377
|
+
allHunks.sort((a, b) => {
|
|
378
|
+
const orderA = statusOrder[a.gitStatus || 'unstaged'];
|
|
379
|
+
const orderB = statusOrder[b.gitStatus || 'unstaged'];
|
|
380
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
381
|
+
return a.file.localeCompare(b.file);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return allHunks;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --- New magit-style rendering (Step 2 of rewrite) ---
|
|
388
|
+
|
|
389
|
+
const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
|
|
390
|
+
const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
|
|
391
|
+
const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
|
|
392
|
+
|
|
393
|
+
interface ListLine {
|
|
394
|
+
text: string;
|
|
395
|
+
type: 'section-header' | 'file';
|
|
396
|
+
fileIndex?: number; // index into state.files[]
|
|
397
|
+
style?: Partial<OverlayOptions>;
|
|
398
|
+
inlineOverlays?: InlineOverlay[];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface DiffLine {
|
|
402
|
+
text: string;
|
|
403
|
+
type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment';
|
|
404
|
+
style?: Partial<OverlayOptions>;
|
|
405
|
+
inlineOverlays?: InlineOverlay[];
|
|
406
|
+
// Line metadata for comment attachment
|
|
407
|
+
hunkId?: string;
|
|
408
|
+
file?: string;
|
|
409
|
+
lineType?: 'add' | 'remove' | 'context';
|
|
410
|
+
oldLine?: number;
|
|
411
|
+
newLine?: number;
|
|
412
|
+
lineContent?: string;
|
|
413
|
+
commentId?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Build the file list lines for the left panel.
|
|
418
|
+
* Returns section headers (not selectable) and file entries.
|
|
419
|
+
*/
|
|
420
|
+
function buildFileListLines(leftWidth?: number): ListLine[] {
|
|
421
|
+
const lines: ListLine[] = [];
|
|
422
|
+
let lastCategory: string | undefined;
|
|
423
|
+
|
|
424
|
+
for (let i = 0; i < state.files.length; i++) {
|
|
425
|
+
const f = state.files[i];
|
|
426
|
+
// Section headers
|
|
427
|
+
if (f.category !== lastCategory) {
|
|
428
|
+
lastCategory = f.category;
|
|
429
|
+
let label = '';
|
|
430
|
+
if (f.category === 'staged') label = editor.t("section.staged") || "Staged";
|
|
431
|
+
else if (f.category === 'unstaged') label = editor.t("section.unstaged") || "Changes";
|
|
432
|
+
else if (f.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
|
|
433
|
+
lines.push({
|
|
434
|
+
text: `▸ ${label}`,
|
|
435
|
+
type: 'section-header',
|
|
436
|
+
style: { fg: STYLE_SECTION_HEADER, bold: true },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Status icon + selection prefix.
|
|
441
|
+
const statusIcon = f.status === '?' ? 'A' : f.status;
|
|
442
|
+
const prefix = i === state.selectedIndex ? '>' : ' ';
|
|
443
|
+
const filename = f.origPath ? `${f.origPath} → ${f.path}` : f.path;
|
|
444
|
+
lines.push({
|
|
445
|
+
text: `${prefix}${statusIcon} ${filename}`,
|
|
446
|
+
type: 'file',
|
|
447
|
+
fileIndex: i,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Show session note at the bottom of the file list, word-wrapped
|
|
452
|
+
if (state.note) {
|
|
453
|
+
lines.push({ text: '', type: 'section-header' }); // blank separator
|
|
454
|
+
lines.push({
|
|
455
|
+
text: `▸ Note`,
|
|
456
|
+
type: 'section-header',
|
|
457
|
+
style: { fg: STYLE_COMMENT, bold: true },
|
|
458
|
+
});
|
|
459
|
+
// Wrap note text to fit left panel (minus 3 for " " prefix + padding)
|
|
460
|
+
const wrapWidth = Math.max(20, (leftWidth || 40) - 3);
|
|
461
|
+
const words = state.note.split(' ');
|
|
462
|
+
let line = '';
|
|
463
|
+
for (const word of words) {
|
|
464
|
+
if (line && (line.length + 1 + word.length) > wrapWidth) {
|
|
465
|
+
lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
|
|
466
|
+
line = word;
|
|
467
|
+
} else {
|
|
468
|
+
line = line ? `${line} ${word}` : word;
|
|
423
469
|
}
|
|
470
|
+
}
|
|
471
|
+
if (line) {
|
|
472
|
+
lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
424
475
|
|
|
425
|
-
|
|
476
|
+
return lines;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Push inline comment lines for a given diff line into the lines array.
|
|
481
|
+
*/
|
|
482
|
+
function pushLineComments(
|
|
483
|
+
lines: DiffLine[], hunk: Hunk,
|
|
484
|
+
lineType: 'add' | 'remove' | 'context',
|
|
485
|
+
oldLine: number | undefined, newLine: number | undefined
|
|
486
|
+
) {
|
|
487
|
+
const lineComments = state.comments.filter(c =>
|
|
488
|
+
c.hunk_id === hunk.id && (
|
|
489
|
+
(c.line_type === 'add' && c.new_line === newLine) ||
|
|
490
|
+
(c.line_type === 'remove' && c.old_line === oldLine) ||
|
|
491
|
+
(c.line_type === 'context' && c.new_line === newLine)
|
|
492
|
+
)
|
|
493
|
+
);
|
|
494
|
+
for (const comment of lineComments) {
|
|
495
|
+
const lineRef = comment.line_type === 'add'
|
|
496
|
+
? `+${comment.new_line}`
|
|
497
|
+
: comment.line_type === 'remove'
|
|
498
|
+
? `-${comment.old_line}`
|
|
499
|
+
: `${comment.new_line}`;
|
|
500
|
+
lines.push({
|
|
501
|
+
text: ` \u00bb [${lineRef}] ${comment.text}`,
|
|
502
|
+
type: 'comment',
|
|
503
|
+
commentId: comment.id,
|
|
504
|
+
style: { fg: STYLE_COMMENT, italic: true },
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Build the diff lines for the right panel based on currently selected file.
|
|
511
|
+
*/
|
|
512
|
+
function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
513
|
+
const lines: DiffLine[] = [];
|
|
514
|
+
if (state.files.length === 0) return lines;
|
|
515
|
+
|
|
516
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
517
|
+
if (!selectedFile) return lines;
|
|
518
|
+
|
|
519
|
+
// Find hunks matching the selected file and category
|
|
520
|
+
const fileHunks = state.hunks.filter(
|
|
521
|
+
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
if (fileHunks.length === 0) {
|
|
525
|
+
if (selectedFile.status === 'R' && selectedFile.origPath) {
|
|
526
|
+
lines.push({ text: `Renamed from ${selectedFile.origPath}`, type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
|
|
527
|
+
} else if (selectedFile.status === 'D') {
|
|
528
|
+
lines.push({ text: "(file deleted)", type: 'empty' });
|
|
529
|
+
} else if (selectedFile.status === 'T') {
|
|
530
|
+
lines.push({ text: "(type change: file ↔ symlink)", type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
|
|
531
|
+
} else if (selectedFile.status === '?' && selectedFile.path.endsWith('/')) {
|
|
532
|
+
lines.push({ text: "(untracked directory)", type: 'empty' });
|
|
426
533
|
} else {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
534
|
+
lines.push({ text: "(no diff available)", type: 'empty' });
|
|
535
|
+
}
|
|
536
|
+
return lines;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
for (const hunk of fileHunks) {
|
|
540
|
+
// Hunk header with review status indicator
|
|
541
|
+
const header = hunk.contextHeader
|
|
542
|
+
? `@@ ${hunk.contextHeader} @@`
|
|
543
|
+
: `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
|
|
544
|
+
|
|
545
|
+
lines.push({
|
|
546
|
+
text: header,
|
|
547
|
+
type: 'hunk-header',
|
|
548
|
+
hunkId: hunk.id,
|
|
549
|
+
file: hunk.file,
|
|
550
|
+
style: { fg: STYLE_HUNK_HEADER, bold: true },
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Render hunk-level comments (those with no line_type) right
|
|
554
|
+
// after the hunk header so they are visible in the diff view.
|
|
555
|
+
const hunkComments = state.comments.filter(c =>
|
|
556
|
+
c.hunk_id === hunk.id && !c.line_type
|
|
557
|
+
);
|
|
558
|
+
for (const comment of hunkComments) {
|
|
559
|
+
lines.push({
|
|
560
|
+
text: ` \u00bb [hunk] ${comment.text}`,
|
|
561
|
+
type: 'comment',
|
|
562
|
+
commentId: comment.id,
|
|
563
|
+
style: { fg: STYLE_COMMENT, italic: true },
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Track actual file line numbers as we iterate
|
|
568
|
+
let oldLineNum = hunk.oldRange.start;
|
|
569
|
+
let newLineNum = hunk.range.start;
|
|
570
|
+
|
|
571
|
+
// Diff content lines with word-level highlighting for adjacent -/+ pairs
|
|
572
|
+
for (let li = 0; li < hunk.lines.length; li++) {
|
|
573
|
+
const line = hunk.lines[li];
|
|
574
|
+
const nextLine = hunk.lines[li + 1];
|
|
575
|
+
const prefix = line[0];
|
|
576
|
+
const lineType: 'add' | 'remove' | 'context' =
|
|
577
|
+
prefix === '+' ? 'add' : prefix === '-' ? 'remove' : 'context';
|
|
578
|
+
const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
|
|
579
|
+
const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
|
|
580
|
+
|
|
581
|
+
// Detect adjacent -/+ pair for word-level diff
|
|
582
|
+
if (prefix === '-' && nextLine && nextLine[0] === '+') {
|
|
583
|
+
const oldContent = line.substring(1);
|
|
584
|
+
const newContent = nextLine.substring(1);
|
|
585
|
+
const parts = diffStrings(oldContent, newContent);
|
|
586
|
+
|
|
587
|
+
// Build inline overlays for removed line
|
|
588
|
+
const removeOverlays: InlineOverlay[] = [];
|
|
589
|
+
let rOffset = getByteLength(line[0]); // skip prefix
|
|
590
|
+
for (const part of parts) {
|
|
591
|
+
const pLen = getByteLength(part.text);
|
|
592
|
+
if (part.type === 'removed') {
|
|
593
|
+
removeOverlays.push({ start: rOffset, end: rOffset + pLen, style: { fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true } });
|
|
594
|
+
}
|
|
595
|
+
if (part.type !== 'added') rOffset += pLen;
|
|
439
596
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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;
|
|
597
|
+
lines.push({
|
|
598
|
+
text: line, type: 'remove',
|
|
599
|
+
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
600
|
+
hunkId: hunk.id, file: hunk.file,
|
|
601
|
+
lineType: 'remove', oldLine: curOldLine, newLine: undefined, lineContent: line,
|
|
602
|
+
inlineOverlays: removeOverlays.length > 0 ? removeOverlays : undefined,
|
|
603
|
+
});
|
|
604
|
+
// Inline comments for the removed line
|
|
605
|
+
pushLineComments(lines, hunk, 'remove', curOldLine, undefined);
|
|
606
|
+
oldLineNum++;
|
|
607
|
+
|
|
608
|
+
// Build inline overlays for added line
|
|
609
|
+
const addOverlays: InlineOverlay[] = [];
|
|
610
|
+
let aOffset = getByteLength(nextLine[0]);
|
|
611
|
+
for (const part of parts) {
|
|
612
|
+
const pLen = getByteLength(part.text);
|
|
613
|
+
if (part.type === 'added') {
|
|
614
|
+
addOverlays.push({ start: aOffset, end: aOffset + pLen, style: { fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true } });
|
|
615
|
+
}
|
|
616
|
+
if (part.type !== 'removed') aOffset += pLen;
|
|
474
617
|
}
|
|
618
|
+
lines.push({
|
|
619
|
+
text: nextLine, type: 'add',
|
|
620
|
+
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
621
|
+
hunkId: hunk.id, file: hunk.file,
|
|
622
|
+
lineType: 'add', oldLine: undefined, newLine: newLineNum, lineContent: nextLine,
|
|
623
|
+
inlineOverlays: addOverlays.length > 0 ? addOverlays : undefined,
|
|
624
|
+
});
|
|
625
|
+
pushLineComments(lines, hunk, 'add', undefined, newLineNum);
|
|
626
|
+
newLineNum++;
|
|
627
|
+
li++; // skip the + line we already processed
|
|
628
|
+
continue;
|
|
475
629
|
}
|
|
630
|
+
|
|
631
|
+
if (prefix === '+') {
|
|
632
|
+
lines.push({
|
|
633
|
+
text: line, type: 'add',
|
|
634
|
+
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
635
|
+
hunkId: hunk.id, file: hunk.file,
|
|
636
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
637
|
+
});
|
|
638
|
+
newLineNum++;
|
|
639
|
+
} else if (prefix === '-') {
|
|
640
|
+
lines.push({
|
|
641
|
+
text: line, type: 'remove',
|
|
642
|
+
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
643
|
+
hunkId: hunk.id, file: hunk.file,
|
|
644
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
645
|
+
});
|
|
646
|
+
oldLineNum++;
|
|
647
|
+
} else {
|
|
648
|
+
lines.push({
|
|
649
|
+
text: line, type: 'context',
|
|
650
|
+
hunkId: hunk.id, file: hunk.file,
|
|
651
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
652
|
+
});
|
|
653
|
+
oldLineNum++;
|
|
654
|
+
newLineNum++;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Render inline comments attached to this line
|
|
658
|
+
pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
|
|
476
659
|
}
|
|
477
660
|
}
|
|
478
661
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
662
|
+
return lines;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Build the full display as exactly viewportHeight lines.
|
|
667
|
+
* Layout:
|
|
668
|
+
* Row 0: Toolbar (shortcuts)
|
|
669
|
+
* Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
|
|
670
|
+
* Rows 2..H-1: Main content (left file list, │ divider, right diff)
|
|
671
|
+
*/
|
|
672
|
+
|
|
673
|
+
// Theme colors for toolbar key hints
|
|
674
|
+
const STYLE_KEY_FG: OverlayColorSpec = "syntax.keyword";
|
|
675
|
+
const STYLE_KEY_BG: OverlayColorSpec = "editor.selection_bg";
|
|
676
|
+
const STYLE_HINT_FG: OverlayColorSpec = "editor.line_number_fg";
|
|
677
|
+
const STYLE_TOOLBAR_BG: OverlayColorSpec = "ui.status_bar_bg";
|
|
678
|
+
const STYLE_TOOLBAR_SEP: OverlayColorSpec = "ui.split_separator_fg";
|
|
679
|
+
|
|
680
|
+
interface HintItem {
|
|
681
|
+
key: string;
|
|
682
|
+
label: string;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Build a styled toolbar entry with highlighted key hints.
|
|
687
|
+
* Keys get bold + keyword color; labels get dim text; groups separated by │.
|
|
688
|
+
*/
|
|
689
|
+
function buildToolbar(W: number): TextPropertyEntry {
|
|
690
|
+
// Items within each group are ordered by importance so that when the
|
|
691
|
+
// viewport is narrow, the most useful hints get full labels while
|
|
692
|
+
// less discoverable ones are truncated to key-only or dropped.
|
|
693
|
+
const groups: HintItem[][] = state.focusPanel === 'files'
|
|
694
|
+
? [
|
|
695
|
+
[{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
|
|
696
|
+
[{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
|
|
697
|
+
[{ key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "↵", label: "Open" }, { key: "Tab", label: "Switch" }, { key: "r", label: "Refresh" }],
|
|
698
|
+
]
|
|
699
|
+
: [
|
|
700
|
+
[{ key: "s", label: "Stage" }, { key: "u", label: "Unstage" }, { key: "d", label: "Discard" }],
|
|
701
|
+
[{ key: "c", label: "Comment" }, { key: "N", label: "Note" }, { key: "x", label: "Del" }],
|
|
702
|
+
[{ key: "n", label: "Next" }, { key: "p", label: "Prev" }, { key: "e", label: "Export" }, { key: "q", label: "Close" }, { key: "Tab", label: "Switch" }],
|
|
703
|
+
];
|
|
704
|
+
|
|
705
|
+
// Build text and collect overlay ranges, gracefully dropping labels
|
|
706
|
+
// when the viewport is too narrow to fit everything.
|
|
707
|
+
const overlays: InlineOverlay[] = [];
|
|
708
|
+
let text = " ";
|
|
709
|
+
let bytePos = getByteLength(" ");
|
|
710
|
+
let done = false;
|
|
711
|
+
|
|
712
|
+
for (let g = 0; g < groups.length && !done; g++) {
|
|
713
|
+
if (g > 0) {
|
|
714
|
+
const sep = " │ ";
|
|
715
|
+
if (text.length + sep.length > W) { done = true; break; }
|
|
716
|
+
overlays.push({ start: bytePos, end: bytePos + getByteLength(sep), style: { fg: STYLE_TOOLBAR_SEP } });
|
|
717
|
+
text += sep;
|
|
718
|
+
bytePos += getByteLength(sep);
|
|
501
719
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
720
|
+
for (let h = 0; h < groups[g].length && !done; h++) {
|
|
721
|
+
const item = groups[g][h];
|
|
722
|
+
const gap = h > 0 ? " " : "";
|
|
723
|
+
const fullLen = gap.length + item.key.length + 1 + item.label.length;
|
|
724
|
+
const keyOnlyLen = gap.length + item.key.length;
|
|
725
|
+
|
|
726
|
+
if (text.length + fullLen <= W) {
|
|
727
|
+
// Full item: gap + key + " " + label
|
|
728
|
+
if (gap) { text += gap; bytePos += getByteLength(gap); }
|
|
729
|
+
const keyLen = getByteLength(item.key);
|
|
730
|
+
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
|
|
731
|
+
text += item.key;
|
|
732
|
+
bytePos += keyLen;
|
|
733
|
+
const labelText = " " + item.label;
|
|
734
|
+
const labelLen = getByteLength(labelText);
|
|
735
|
+
overlays.push({ start: bytePos, end: bytePos + labelLen, style: { fg: STYLE_HINT_FG } });
|
|
736
|
+
text += labelText;
|
|
737
|
+
bytePos += labelLen;
|
|
738
|
+
} else if (text.length + keyOnlyLen <= W) {
|
|
739
|
+
// Key only (no label) when space is tight
|
|
740
|
+
if (gap) { text += gap; bytePos += getByteLength(gap); }
|
|
741
|
+
const keyLen = getByteLength(item.key);
|
|
742
|
+
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bg: STYLE_KEY_BG, bold: true } });
|
|
743
|
+
text += item.key;
|
|
744
|
+
bytePos += keyLen;
|
|
745
|
+
} else {
|
|
746
|
+
done = true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const padded = text.padEnd(W) + "\n";
|
|
752
|
+
return {
|
|
753
|
+
text: padded,
|
|
754
|
+
properties: { type: "toolbar" },
|
|
755
|
+
style: { bg: STYLE_TOOLBAR_BG, extendToLineEnd: true },
|
|
756
|
+
inlineOverlays: overlays,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// --- Buffer Group panel content builders ---
|
|
761
|
+
|
|
762
|
+
function buildToolbarPanelEntries(): TextPropertyEntry[] {
|
|
763
|
+
// Reuse buildToolbar — returns one entry with the full toolbar line
|
|
764
|
+
return [buildToolbar(state.viewportWidth)];
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function buildFilesPanelEntries(): TextPropertyEntry[] {
|
|
768
|
+
const entries: TextPropertyEntry[] = [];
|
|
769
|
+
const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
|
|
770
|
+
|
|
771
|
+
// Header row: "GIT STATUS" — emphasized when the files panel has focus.
|
|
772
|
+
const focusLeft = state.focusPanel === 'files';
|
|
773
|
+
const headerStyle: Partial<OverlayOptions> = focusLeft
|
|
774
|
+
? { fg: STYLE_HEADER, bold: true, underline: true }
|
|
775
|
+
: { fg: STYLE_DIVIDER };
|
|
776
|
+
entries.push({
|
|
777
|
+
text: " GIT STATUS\n",
|
|
778
|
+
style: headerStyle,
|
|
779
|
+
properties: { type: "header" },
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const lines = buildFileListLines(leftWidth);
|
|
783
|
+
for (const line of lines) {
|
|
784
|
+
// Selection is plugin-managed: draw a bg highlight on the row whose
|
|
785
|
+
// fileIndex matches state.selectedIndex. The native cursor is hidden
|
|
786
|
+
// for the files panel (show_cursors stays false).
|
|
787
|
+
const isSelected = line.type === 'file' && line.fileIndex === state.selectedIndex;
|
|
788
|
+
const baseStyle = line.style;
|
|
789
|
+
const style: Partial<OverlayOptions> | undefined = isSelected
|
|
790
|
+
? { ...(baseStyle || {}), bg: STYLE_SELECTED_BG, bold: true, extendToLineEnd: true }
|
|
791
|
+
: baseStyle;
|
|
792
|
+
entries.push({
|
|
793
|
+
text: (line.text || "") + "\n",
|
|
794
|
+
style,
|
|
795
|
+
inlineOverlays: line.inlineOverlays,
|
|
796
|
+
properties: { type: line.type, fileIndex: line.fileIndex },
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
return entries;
|
|
548
800
|
}
|
|
549
801
|
|
|
550
802
|
/**
|
|
551
|
-
*
|
|
803
|
+
* Build (or fetch from cache) the diff-panel entries for the currently
|
|
804
|
+
* selected file. The cache is keyed by `${file}\0${gitStatus}` and is cleared
|
|
805
|
+
* in `refreshMagitData`. As a side effect, populates `state.hunkHeaderRows`
|
|
806
|
+
* and `state.diffLineByteOffsets` for the cached entry — these back `n`/`p`
|
|
807
|
+
* hunk navigation and the cursor-line overlay.
|
|
552
808
|
*/
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
809
|
+
function buildDiffPanelEntries(): TextPropertyEntry[] {
|
|
810
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
811
|
+
const cacheKey = selectedFile
|
|
812
|
+
? `${selectedFile.path}\0${selectedFile.category}`
|
|
813
|
+
: "\0";
|
|
814
|
+
const cached = state.diffCache[cacheKey];
|
|
815
|
+
if (cached) {
|
|
816
|
+
state.hunkHeaderRows = cached.hunkHeaderRows;
|
|
817
|
+
state.diffLineByteOffsets = cached.diffLineByteOffsets;
|
|
818
|
+
return cached.entries;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const entries: TextPropertyEntry[] = [];
|
|
822
|
+
const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
|
|
823
|
+
const rightWidth = state.viewportWidth - leftWidth - 1;
|
|
824
|
+
|
|
825
|
+
const hunkHeaderRows: number[] = [];
|
|
826
|
+
const diffLineByteOffsets: number[] = [];
|
|
827
|
+
let runningByte = 0;
|
|
828
|
+
let row = 0; // 0-indexed counter; row + 1 is the 1-indexed line number
|
|
829
|
+
|
|
830
|
+
const pushEntry = (entry: TextPropertyEntry) => {
|
|
831
|
+
diffLineByteOffsets.push(runningByte);
|
|
832
|
+
runningByte += getByteLength(entry.text);
|
|
833
|
+
entries.push(entry);
|
|
834
|
+
row++;
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// Header row: "DIFF FOR <file>". Always rendered as focused (the panel
|
|
838
|
+
// is the only place this header appears) so the cached entries can be
|
|
839
|
+
// reused regardless of which panel currently has focus.
|
|
840
|
+
const rightHeader = selectedFile
|
|
841
|
+
? ` DIFF FOR ${selectedFile.path}`
|
|
842
|
+
: " DIFF";
|
|
843
|
+
pushEntry({
|
|
844
|
+
text: rightHeader + "\n",
|
|
845
|
+
style: { fg: STYLE_HEADER, bold: true, underline: true },
|
|
846
|
+
properties: { type: "header" },
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const lines = buildDiffLines(rightWidth);
|
|
850
|
+
for (const line of lines) {
|
|
851
|
+
// Embed the full DiffLine metadata as text properties so action
|
|
852
|
+
// handlers (`s`, `u`, `d`, `c`, `x`) can read it back via
|
|
853
|
+
// getTextPropertiesAtCursor without re-walking state.hunks.
|
|
854
|
+
const props: Record<string, unknown> = { type: line.type };
|
|
855
|
+
if (line.hunkId !== undefined) props.hunkId = line.hunkId;
|
|
856
|
+
if (line.file !== undefined) props.file = line.file;
|
|
857
|
+
if (line.lineType !== undefined) props.lineType = line.lineType;
|
|
858
|
+
if (line.oldLine !== undefined) props.oldLine = line.oldLine;
|
|
859
|
+
if (line.newLine !== undefined) props.newLine = line.newLine;
|
|
860
|
+
if (line.lineContent !== undefined) props.lineContent = line.lineContent;
|
|
861
|
+
if (line.commentId !== undefined) props.commentId = line.commentId;
|
|
862
|
+
|
|
863
|
+
if (line.type === 'hunk-header') {
|
|
864
|
+
// 1-indexed row of this hunk header in the diff buffer.
|
|
865
|
+
hunkHeaderRows.push(row + 1);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
pushEntry({
|
|
869
|
+
text: (line.text || "") + "\n",
|
|
870
|
+
style: line.style,
|
|
871
|
+
inlineOverlays: line.inlineOverlays,
|
|
872
|
+
properties: props,
|
|
565
873
|
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Sentinel: total buffer length, used as the end of the last row.
|
|
877
|
+
diffLineByteOffsets.push(runningByte);
|
|
878
|
+
|
|
879
|
+
state.diffCache[cacheKey] = { entries, hunkHeaderRows, diffLineByteOffsets };
|
|
880
|
+
state.hunkHeaderRows = hunkHeaderRows;
|
|
881
|
+
state.diffLineByteOffsets = diffLineByteOffsets;
|
|
882
|
+
return entries;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Full refresh — rebuild all three panels. Called on data changes
|
|
887
|
+
* (refreshMagitData, comment add/edit, note edit, resize). NOT called on
|
|
888
|
+
* scroll: scrolling is handled natively by the editor in the panel buffers.
|
|
889
|
+
*/
|
|
890
|
+
function updateMagitDisplay(): void {
|
|
891
|
+
refreshViewportDimensions();
|
|
892
|
+
if (state.groupId === null) return;
|
|
893
|
+
editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
|
|
894
|
+
editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
|
|
895
|
+
editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
|
|
896
|
+
// setPanelContent wipes the buffer's overlays — re-paint the diff
|
|
897
|
+
// cursor-line highlight (the files panel doesn't have one; selection
|
|
898
|
+
// there is rendered as part of the entry style).
|
|
899
|
+
applyCursorLineOverlay('diff');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Rebuild only the diff panel. Called when the selected file changes.
|
|
904
|
+
*/
|
|
905
|
+
function refreshDiffPanelOnly(): void {
|
|
906
|
+
if (state.groupId === null) return;
|
|
907
|
+
editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
|
|
908
|
+
applyCursorLineOverlay('diff');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Repaint the synthetic "cursor line" highlight in the diff panel.
|
|
913
|
+
*
|
|
914
|
+
* The diff panel buffer is created with show_cursors=true so the editor
|
|
915
|
+
* moves the cursor natively, but a single-line bg overlay on the cursor row
|
|
916
|
+
* gives a much more visible "you are here" indicator than the bare caret —
|
|
917
|
+
* which matches the magit-style aesthetic and is what the user expects.
|
|
918
|
+
*/
|
|
919
|
+
function applyCursorLineOverlay(panel: 'diff'): void {
|
|
920
|
+
const bufId = state.panelBuffers[panel];
|
|
921
|
+
if (bufId === undefined) return;
|
|
922
|
+
editor.clearNamespace(bufId, CURSOR_LINE_NS);
|
|
923
|
+
const offsets = state.diffLineByteOffsets;
|
|
924
|
+
if (offsets.length < 2) return;
|
|
925
|
+
const idx = Math.max(0, Math.min(state.diffCursorRow - 1, offsets.length - 2));
|
|
926
|
+
const start = offsets[idx];
|
|
927
|
+
const end = offsets[idx + 1];
|
|
928
|
+
if (end <= start) return;
|
|
929
|
+
editor.addOverlay(bufId, CURSOR_LINE_NS, start, end, {
|
|
930
|
+
bg: STYLE_SELECTED_BG,
|
|
931
|
+
extendToLineEnd: true,
|
|
566
932
|
});
|
|
567
|
-
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function review_refresh() { refreshMagitData(); }
|
|
936
|
+
registerHandler("review_refresh", review_refresh);
|
|
937
|
+
|
|
938
|
+
// --- Focus and cursor-driven navigation ---
|
|
939
|
+
//
|
|
940
|
+
// Cursor keys (j/k/Up/Down/PageUp/PageDown/Home/End) are bound to plugin
|
|
941
|
+
// handlers that branch on which panel is focused:
|
|
942
|
+
//
|
|
943
|
+
// * Files panel: selection is plugin-managed (`state.selectedIndex` with
|
|
944
|
+
// a `>` prefix + bg highlight). The handler updates the index, repaints
|
|
945
|
+
// the files panel, and swaps the diff panel content from cache. The
|
|
946
|
+
// native cursor stays hidden in the files panel.
|
|
947
|
+
//
|
|
948
|
+
// * Diff panel: motion is delegated to the editor's built-in actions
|
|
949
|
+
// (`move_up`, `move_down`, etc.) via `executeAction`. The cursor moves
|
|
950
|
+
// natively, the editor handles viewport scrolling, and `cursor_moved`
|
|
951
|
+
// fires so the cursor-line overlay follows along.
|
|
952
|
+
|
|
953
|
+
function isFilesFocused(): boolean {
|
|
954
|
+
return state.focusPanel === 'files';
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function refreshFilesPanelOnly(): void {
|
|
958
|
+
if (state.groupId === null) return;
|
|
959
|
+
editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function selectFile(newIndex: number) {
|
|
963
|
+
if (newIndex < 0 || newIndex >= state.files.length) return;
|
|
964
|
+
if (newIndex === state.selectedIndex) return;
|
|
965
|
+
state.selectedIndex = newIndex;
|
|
966
|
+
state.diffCursorRow = 1; // diff panel cursor returns to the top of the new file
|
|
967
|
+
refreshFilesPanelOnly();
|
|
968
|
+
refreshDiffPanelOnly();
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function review_nav_up() {
|
|
972
|
+
if (isFilesFocused()) {
|
|
973
|
+
selectFile(state.selectedIndex - 1);
|
|
974
|
+
} else {
|
|
975
|
+
editor.executeAction("move_up");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
registerHandler("review_nav_up", review_nav_up);
|
|
979
|
+
|
|
980
|
+
function review_nav_down() {
|
|
981
|
+
if (isFilesFocused()) {
|
|
982
|
+
selectFile(state.selectedIndex + 1);
|
|
983
|
+
} else {
|
|
984
|
+
editor.executeAction("move_down");
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
registerHandler("review_nav_down", review_nav_down);
|
|
988
|
+
|
|
989
|
+
function review_page_up() {
|
|
990
|
+
if (isFilesFocused()) {
|
|
991
|
+
const step = Math.max(1, state.viewportHeight - 2);
|
|
992
|
+
selectFile(Math.max(0, state.selectedIndex - step));
|
|
993
|
+
} else {
|
|
994
|
+
editor.executeAction("move_page_up");
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
registerHandler("review_page_up", review_page_up);
|
|
998
|
+
|
|
999
|
+
function review_page_down() {
|
|
1000
|
+
if (isFilesFocused()) {
|
|
1001
|
+
const step = Math.max(1, state.viewportHeight - 2);
|
|
1002
|
+
selectFile(Math.min(state.files.length - 1, state.selectedIndex + step));
|
|
1003
|
+
} else {
|
|
1004
|
+
editor.executeAction("move_page_down");
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
registerHandler("review_page_down", review_page_down);
|
|
1008
|
+
|
|
1009
|
+
function review_nav_home() {
|
|
1010
|
+
if (isFilesFocused()) {
|
|
1011
|
+
selectFile(0);
|
|
1012
|
+
} else {
|
|
1013
|
+
editor.executeAction("move_document_start");
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
registerHandler("review_nav_home", review_nav_home);
|
|
1017
|
+
|
|
1018
|
+
function review_nav_end() {
|
|
1019
|
+
if (isFilesFocused()) {
|
|
1020
|
+
selectFile(state.files.length - 1);
|
|
1021
|
+
} else {
|
|
1022
|
+
editor.executeAction("move_document_end");
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
registerHandler("review_nav_end", review_nav_end);
|
|
1026
|
+
|
|
1027
|
+
function review_toggle_focus() {
|
|
1028
|
+
if (state.groupId === null) return;
|
|
1029
|
+
const newPanel: 'files' | 'diff' = state.focusPanel === 'files' ? 'diff' : 'files';
|
|
1030
|
+
state.focusPanel = newPanel;
|
|
1031
|
+
editor.focusBufferGroupPanel(state.groupId, newPanel);
|
|
1032
|
+
// Refresh the toolbar so its hint set matches the new focus.
|
|
1033
|
+
editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
|
|
1034
|
+
}
|
|
1035
|
+
registerHandler("review_toggle_focus", review_toggle_focus);
|
|
1036
|
+
|
|
1037
|
+
// --- Real git stage/unstage/discard actions (Step 4) ---
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Build a minimal unified diff patch for a single hunk.
|
|
1041
|
+
*/
|
|
1042
|
+
function buildHunkPatch(filePath: string, hunk: Hunk): string {
|
|
1043
|
+
const oldCount = hunk.lines.filter(l => l[0] === '-' || l[0] === ' ').length;
|
|
1044
|
+
const newCount = hunk.lines.filter(l => l[0] === '+' || l[0] === ' ').length;
|
|
1045
|
+
const header = `@@ -${hunk.oldRange.start},${oldCount} +${hunk.range.start},${newCount} @@`;
|
|
1046
|
+
return [
|
|
1047
|
+
`diff --git a/${filePath} b/${filePath}`,
|
|
1048
|
+
`--- a/${filePath}`,
|
|
1049
|
+
`+++ b/${filePath}`,
|
|
1050
|
+
header,
|
|
1051
|
+
...hunk.lines,
|
|
1052
|
+
''
|
|
1053
|
+
].join('\n');
|
|
568
1054
|
}
|
|
569
1055
|
|
|
570
1056
|
/**
|
|
571
|
-
*
|
|
1057
|
+
* Write a patch to a temp file and apply it with the given flags.
|
|
1058
|
+
* Returns true on success.
|
|
572
1059
|
*/
|
|
573
|
-
async function
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
editor.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
editor.setStatus(editor.t("status.updated", { count: String(state.hunks.length) }));
|
|
583
|
-
} catch (e) {
|
|
584
|
-
editor.debug(`ReviewDiff Error: ${e}`);
|
|
585
|
-
} finally {
|
|
586
|
-
isUpdating = false;
|
|
1060
|
+
async function applyHunkPatch(patch: string, flags: string[]): Promise<boolean> {
|
|
1061
|
+
const tmpDir = editor.getTempDir();
|
|
1062
|
+
const patchPath = editor.pathJoin(tmpDir, `fresh-review-${Date.now()}.patch`);
|
|
1063
|
+
editor.writeFile(patchPath, patch);
|
|
1064
|
+
// Validate first
|
|
1065
|
+
const check = await editor.spawnProcess("git", ["apply", "--check", ...flags, patchPath]);
|
|
1066
|
+
if (check.exit_code !== 0) {
|
|
1067
|
+
editor.setStatus("Patch failed: " + (check.stderr || "").trim());
|
|
1068
|
+
return false;
|
|
587
1069
|
}
|
|
1070
|
+
const result = await editor.spawnProcess("git", ["apply", ...flags, patchPath]);
|
|
1071
|
+
return result.exit_code === 0;
|
|
588
1072
|
}
|
|
589
1073
|
|
|
590
|
-
|
|
1074
|
+
/**
|
|
1075
|
+
* Merge all text-property records at the cursor of the given panel buffer
|
|
1076
|
+
* into a single object. There's typically only one record covering each
|
|
1077
|
+
* cursor position; merging keeps callers simple.
|
|
1078
|
+
*/
|
|
1079
|
+
function readPropsAtCursor(panel: 'files' | 'diff'): Record<string, unknown> | null {
|
|
1080
|
+
const bufId = state.panelBuffers[panel];
|
|
1081
|
+
if (bufId === undefined) return null;
|
|
1082
|
+
const records = editor.getTextPropertiesAtCursor(bufId);
|
|
1083
|
+
if (!records || records.length === 0) return null;
|
|
1084
|
+
const merged: Record<string, unknown> = {};
|
|
1085
|
+
for (const r of records) Object.assign(merged, r);
|
|
1086
|
+
return merged;
|
|
1087
|
+
}
|
|
591
1088
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
1089
|
+
/**
|
|
1090
|
+
* Get the hunk under the cursor in the diff panel, or null.
|
|
1091
|
+
*
|
|
1092
|
+
* Reads the `hunkId` text property embedded by `buildDiffPanelEntries`. Falls
|
|
1093
|
+
* back to the first hunk of the selected file when the cursor is somewhere
|
|
1094
|
+
* without a hunkId (e.g. the panel header) so commands like `s` still do
|
|
1095
|
+
* something useful.
|
|
1096
|
+
*/
|
|
1097
|
+
function getHunkAtDiffCursor(): Hunk | null {
|
|
1098
|
+
const props = readPropsAtCursor('diff');
|
|
1099
|
+
const hunkId = props ? props["hunkId"] : undefined;
|
|
1100
|
+
if (typeof hunkId === 'string') {
|
|
1101
|
+
const found = state.hunks.find(h => h.id === hunkId);
|
|
1102
|
+
if (found) return found;
|
|
600
1103
|
}
|
|
1104
|
+
// Fallback: first hunk for the currently-selected file.
|
|
1105
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
1106
|
+
if (!selectedFile) return null;
|
|
1107
|
+
return state.hunks.find(
|
|
1108
|
+
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
1109
|
+
) || null;
|
|
601
1110
|
}
|
|
602
|
-
registerHandler("review_stage_hunk", review_stage_hunk);
|
|
603
1111
|
|
|
604
|
-
async function
|
|
605
|
-
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (
|
|
611
|
-
|
|
1112
|
+
async function review_stage_file() {
|
|
1113
|
+
if (state.files.length === 0) return;
|
|
1114
|
+
if (state.focusPanel === 'diff') {
|
|
1115
|
+
// Hunk-level staging
|
|
1116
|
+
const hunk = getHunkAtDiffCursor();
|
|
1117
|
+
if (!hunk || !hunk.file) return;
|
|
1118
|
+
if (hunk.gitStatus === 'untracked') {
|
|
1119
|
+
await editor.spawnProcess("git", ["add", "--", hunk.file]);
|
|
1120
|
+
} else {
|
|
1121
|
+
const patch = buildHunkPatch(hunk.file, hunk);
|
|
1122
|
+
const ok = await applyHunkPatch(patch, ["--cached"]);
|
|
1123
|
+
if (!ok) return;
|
|
1124
|
+
}
|
|
1125
|
+
editor.setStatus(editor.t("status.hunk_staged") || "Hunk staged");
|
|
1126
|
+
await refreshMagitData();
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const f = state.files[state.selectedIndex];
|
|
1130
|
+
if (!f) return;
|
|
1131
|
+
await editor.spawnProcess("git", ["add", "--", f.path]);
|
|
1132
|
+
await refreshMagitData();
|
|
1133
|
+
}
|
|
1134
|
+
registerHandler("review_stage_file", review_stage_file);
|
|
1135
|
+
|
|
1136
|
+
async function review_unstage_file() {
|
|
1137
|
+
if (state.files.length === 0) return;
|
|
1138
|
+
if (state.focusPanel === 'diff') {
|
|
1139
|
+
// Hunk-level unstaging
|
|
1140
|
+
const hunk = getHunkAtDiffCursor();
|
|
1141
|
+
if (!hunk || !hunk.file || hunk.gitStatus !== 'staged') {
|
|
1142
|
+
editor.setStatus("Can only unstage staged hunks");
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const patch = buildHunkPatch(hunk.file, hunk);
|
|
1146
|
+
const ok = await applyHunkPatch(patch, ["--cached", "--reverse"]);
|
|
1147
|
+
if (!ok) return;
|
|
1148
|
+
editor.setStatus(editor.t("status.hunk_unstaged") || "Hunk unstaged");
|
|
1149
|
+
await refreshMagitData();
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const f = state.files[state.selectedIndex];
|
|
1153
|
+
if (!f) return;
|
|
1154
|
+
await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
|
|
1155
|
+
await refreshMagitData();
|
|
1156
|
+
}
|
|
1157
|
+
registerHandler("review_unstage_file", review_unstage_file);
|
|
1158
|
+
|
|
1159
|
+
function review_discard_file() {
|
|
1160
|
+
if (state.files.length === 0) return;
|
|
1161
|
+
if (state.focusPanel === 'diff') {
|
|
1162
|
+
// Hunk-level discard — show confirmation
|
|
1163
|
+
const hunk = getHunkAtDiffCursor();
|
|
1164
|
+
if (!hunk || !hunk.file) return;
|
|
1165
|
+
editor.startPrompt(
|
|
1166
|
+
editor.t("prompt.discard_hunk", { file: hunk.file }) ||
|
|
1167
|
+
`Discard this hunk in "${hunk.file}"? This cannot be undone.`,
|
|
1168
|
+
"review-discard-hunk-confirm"
|
|
1169
|
+
);
|
|
1170
|
+
const suggestions: PromptSuggestion[] = [
|
|
1171
|
+
{ text: "Discard hunk", description: "Permanently lose this change", value: "discard" },
|
|
1172
|
+
{ text: "Cancel", description: "Keep the hunk as-is", value: "cancel" },
|
|
1173
|
+
];
|
|
1174
|
+
editor.setPromptSuggestions(suggestions);
|
|
1175
|
+
return;
|
|
612
1176
|
}
|
|
1177
|
+
const f = state.files[state.selectedIndex];
|
|
1178
|
+
if (!f) return;
|
|
1179
|
+
|
|
1180
|
+
// Show confirmation prompt — discard is destructive and irreversible
|
|
1181
|
+
const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
|
|
1182
|
+
editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
|
|
1183
|
+
const suggestions: PromptSuggestion[] = [
|
|
1184
|
+
{ text: `${action} file`, description: "Permanently lose changes", value: "discard" },
|
|
1185
|
+
{ text: "Cancel", description: "Keep the file as-is", value: "cancel" },
|
|
1186
|
+
];
|
|
1187
|
+
editor.setPromptSuggestions(suggestions);
|
|
613
1188
|
}
|
|
614
|
-
registerHandler("
|
|
1189
|
+
registerHandler("review_discard_file", review_discard_file);
|
|
1190
|
+
|
|
1191
|
+
async function on_review_discard_hunk_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
|
|
1192
|
+
if (args.prompt_type !== "review-discard-hunk-confirm") return true;
|
|
1193
|
+
const response = args.input.trim().toLowerCase();
|
|
1194
|
+
if (response === "discard" || args.selected_index === 0) {
|
|
1195
|
+
const hunk = getHunkAtDiffCursor();
|
|
1196
|
+
if (hunk && hunk.file) {
|
|
1197
|
+
const patch = buildHunkPatch(hunk.file, hunk);
|
|
1198
|
+
const ok = await applyHunkPatch(patch, ["--reverse"]);
|
|
1199
|
+
if (ok) {
|
|
1200
|
+
editor.setStatus(editor.t("status.hunk_discarded") || "Hunk discarded");
|
|
1201
|
+
await refreshMagitData();
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
} else {
|
|
1205
|
+
editor.setStatus("Discard cancelled");
|
|
1206
|
+
}
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
registerHandler("on_review_discard_hunk_confirm", on_review_discard_hunk_confirm);
|
|
1210
|
+
|
|
1211
|
+
async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
|
|
1212
|
+
if (args.prompt_type !== "review-discard-confirm") return true;
|
|
615
1213
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1214
|
+
const response = args.input.trim().toLowerCase();
|
|
1215
|
+
if (response === "discard" || args.selected_index === 0) {
|
|
1216
|
+
const f = state.files[state.selectedIndex];
|
|
1217
|
+
if (f) {
|
|
1218
|
+
if (f.category === 'untracked') {
|
|
1219
|
+
await editor.spawnProcess("rm", ["--", f.path]);
|
|
1220
|
+
} else {
|
|
1221
|
+
await editor.spawnProcess("git", ["checkout", "--", f.path]);
|
|
1222
|
+
}
|
|
1223
|
+
await refreshMagitData();
|
|
1224
|
+
editor.setStatus(`Discarded: ${f.path}`);
|
|
1225
|
+
}
|
|
1226
|
+
} else {
|
|
1227
|
+
editor.setStatus("Discard cancelled");
|
|
624
1228
|
}
|
|
1229
|
+
return false;
|
|
625
1230
|
}
|
|
626
|
-
registerHandler("
|
|
1231
|
+
registerHandler("on_review_discard_confirm", on_review_discard_confirm);
|
|
627
1232
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1233
|
+
/**
|
|
1234
|
+
* Refresh file list and diffs using the new git status approach, then re-render.
|
|
1235
|
+
*/
|
|
1236
|
+
async function refreshMagitData() {
|
|
1237
|
+
const files = await getGitStatus();
|
|
1238
|
+
state.files = files;
|
|
1239
|
+
state.hunks = await fetchDiffsForFiles(files);
|
|
1240
|
+
// Clamp selectedIndex
|
|
1241
|
+
if (state.selectedIndex >= state.files.length) {
|
|
1242
|
+
state.selectedIndex = Math.max(0, state.files.length - 1);
|
|
1243
|
+
}
|
|
1244
|
+
state.diffCursorRow = 1;
|
|
1245
|
+
state.diffCache = {}; // git state may have changed — invalidate cached diffs
|
|
1246
|
+
updateMagitDisplay();
|
|
634
1247
|
}
|
|
635
|
-
registerHandler("review_next_hunk", review_next_hunk);
|
|
636
1248
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1249
|
+
// --- Resize handler ---
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Refresh viewport dimensions from the actual split viewport.
|
|
1253
|
+
* This accounts for sidebars (file explorer) that reduce available width,
|
|
1254
|
+
* unlike the terminal-level resize event which reports full terminal size.
|
|
1255
|
+
*/
|
|
1256
|
+
function refreshViewportDimensions(): boolean {
|
|
1257
|
+
const viewport = editor.getViewport();
|
|
1258
|
+
if (viewport) {
|
|
1259
|
+
const changed = viewport.width !== state.viewportWidth || viewport.height !== state.viewportHeight;
|
|
1260
|
+
state.viewportWidth = viewport.width;
|
|
1261
|
+
state.viewportHeight = viewport.height;
|
|
1262
|
+
return changed;
|
|
1263
|
+
}
|
|
1264
|
+
return false;
|
|
643
1265
|
}
|
|
644
|
-
registerHandler("review_prev_hunk", review_prev_hunk);
|
|
645
1266
|
|
|
646
|
-
function
|
|
647
|
-
|
|
1267
|
+
function onReviewDiffResize(_data: { width: number; height: number }): void {
|
|
1268
|
+
if (state.reviewBufferId === null) return;
|
|
1269
|
+
refreshViewportDimensions();
|
|
1270
|
+
// Invalidate cached diff entries — they were built for the old viewport width
|
|
1271
|
+
state.diffCache = {};
|
|
1272
|
+
updateMagitDisplay();
|
|
1273
|
+
}
|
|
1274
|
+
registerHandler("onReviewDiffResize", onReviewDiffResize);
|
|
648
1275
|
|
|
649
1276
|
let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
|
|
650
1277
|
|
|
@@ -848,6 +1475,15 @@ function computeFullFileAlignedDiff(oldContent: string, newContent: string, hunk
|
|
|
848
1475
|
return aligned;
|
|
849
1476
|
}
|
|
850
1477
|
|
|
1478
|
+
interface HighlightTask {
|
|
1479
|
+
range: [number, number];
|
|
1480
|
+
fg: OverlayColorSpec;
|
|
1481
|
+
bg?: OverlayColorSpec;
|
|
1482
|
+
bold?: boolean;
|
|
1483
|
+
italic?: boolean;
|
|
1484
|
+
extend_to_line_end?: boolean;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
851
1487
|
/**
|
|
852
1488
|
* Generate virtual buffer content with diff highlighting for one side.
|
|
853
1489
|
* Returns entries, highlight tasks, and line byte offsets for scroll sync.
|
|
@@ -1011,187 +1647,261 @@ interface SideBySideDiffState {
|
|
|
1011
1647
|
scrollSyncGroupId: number | null; // Core scroll sync group ID
|
|
1012
1648
|
}
|
|
1013
1649
|
|
|
1014
|
-
let activeSideBySideState: SideBySideDiffState | null = null;
|
|
1015
|
-
let nextScrollSyncGroupId = 1;
|
|
1650
|
+
let activeSideBySideState: SideBySideDiffState | null = null;
|
|
1651
|
+
let nextScrollSyncGroupId = 1;
|
|
1652
|
+
|
|
1653
|
+
// State for composite buffer-based diff view
|
|
1654
|
+
interface CompositeDiffState {
|
|
1655
|
+
compositeBufferId: number;
|
|
1656
|
+
oldBufferId: number;
|
|
1657
|
+
newBufferId: number;
|
|
1658
|
+
filePath: string;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
let activeCompositeDiffState: CompositeDiffState | null = null;
|
|
1662
|
+
|
|
1663
|
+
async function review_drill_down() {
|
|
1664
|
+
// Use selected file from magit state instead of cursor properties
|
|
1665
|
+
if (state.files.length === 0) return;
|
|
1666
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
1667
|
+
if (!selectedFile) return;
|
|
1668
|
+
|
|
1669
|
+
// Create a minimal hunk-like reference for the rest of the function
|
|
1670
|
+
const h = { file: selectedFile.path, gitStatus: selectedFile.category };
|
|
1671
|
+
|
|
1672
|
+
editor.setStatus(editor.t("status.loading_diff"));
|
|
1673
|
+
|
|
1674
|
+
// Get all hunks for this file
|
|
1675
|
+
const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
|
|
1676
|
+
if (fileHunks.length === 0) return;
|
|
1677
|
+
|
|
1678
|
+
// Get git root to construct absolute path
|
|
1679
|
+
const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
|
|
1680
|
+
if (gitRootResult.exit_code !== 0) {
|
|
1681
|
+
editor.setStatus(editor.t("status.not_git_repo"));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const gitRoot = gitRootResult.stdout.trim();
|
|
1685
|
+
const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
|
|
1686
|
+
|
|
1687
|
+
// Get old (HEAD) and new (working) file content
|
|
1688
|
+
let oldContent: string;
|
|
1689
|
+
const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
|
|
1690
|
+
if (gitShow.exit_code !== 0) {
|
|
1691
|
+
oldContent = "";
|
|
1692
|
+
} else {
|
|
1693
|
+
oldContent = gitShow.stdout;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Read new file content (use absolute path for readFile)
|
|
1697
|
+
// For deleted files the path no longer exists — use empty content
|
|
1698
|
+
let newContent: string;
|
|
1699
|
+
if (selectedFile.status === 'D') {
|
|
1700
|
+
newContent = "";
|
|
1701
|
+
} else {
|
|
1702
|
+
const readResult = await editor.readFile(absoluteFilePath);
|
|
1703
|
+
if (readResult === null) {
|
|
1704
|
+
editor.setStatus(editor.t("status.failed_new_version"));
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
newContent = readResult;
|
|
1708
|
+
}
|
|
1016
1709
|
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1710
|
+
// Close any existing side-by-side views (old split-based approach)
|
|
1711
|
+
if (activeSideBySideState) {
|
|
1712
|
+
try {
|
|
1713
|
+
if (activeSideBySideState.scrollSyncGroupId !== null) {
|
|
1714
|
+
(editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
|
|
1715
|
+
}
|
|
1716
|
+
editor.closeBuffer(activeSideBySideState.oldBufferId);
|
|
1717
|
+
editor.closeBuffer(activeSideBySideState.newBufferId);
|
|
1718
|
+
} catch {}
|
|
1719
|
+
activeSideBySideState = null;
|
|
1720
|
+
}
|
|
1024
1721
|
|
|
1025
|
-
|
|
1722
|
+
// Close any existing composite diff view
|
|
1723
|
+
if (activeCompositeDiffState) {
|
|
1724
|
+
try {
|
|
1725
|
+
editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId);
|
|
1726
|
+
editor.closeBuffer(activeCompositeDiffState.oldBufferId);
|
|
1727
|
+
editor.closeBuffer(activeCompositeDiffState.newBufferId);
|
|
1728
|
+
} catch {}
|
|
1729
|
+
activeCompositeDiffState = null;
|
|
1730
|
+
}
|
|
1026
1731
|
|
|
1027
|
-
|
|
1028
|
-
const
|
|
1029
|
-
const
|
|
1030
|
-
if (props.length > 0 && props[0].hunkId) {
|
|
1031
|
-
const id = props[0].hunkId as string;
|
|
1032
|
-
const h = state.hunks.find(x => x.id === id);
|
|
1033
|
-
if (!h) return;
|
|
1732
|
+
// Create virtual buffers for old and new content
|
|
1733
|
+
const oldLines = oldContent.split('\n');
|
|
1734
|
+
const newLines = newContent.split('\n');
|
|
1034
1735
|
|
|
1035
|
-
|
|
1736
|
+
const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
|
|
1737
|
+
text: line + '\n',
|
|
1738
|
+
properties: { type: 'line', lineNum: idx + 1 }
|
|
1739
|
+
}));
|
|
1036
1740
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1741
|
+
const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({
|
|
1742
|
+
text: line + '\n',
|
|
1743
|
+
properties: { type: 'line', lineNum: idx + 1 }
|
|
1744
|
+
}));
|
|
1039
1745
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1746
|
+
// Create source buffers (hidden from tabs, used by composite)
|
|
1747
|
+
const oldResult = await editor.createVirtualBuffer({
|
|
1748
|
+
name: `*OLD:${h.file}*`,
|
|
1749
|
+
mode: "normal",
|
|
1750
|
+
readOnly: true,
|
|
1751
|
+
entries: oldEntries,
|
|
1752
|
+
showLineNumbers: true,
|
|
1753
|
+
editingDisabled: true,
|
|
1754
|
+
hiddenFromTabs: true
|
|
1755
|
+
});
|
|
1756
|
+
const oldBufferId = oldResult.bufferId;
|
|
1048
1757
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1758
|
+
const newResult = await editor.createVirtualBuffer({
|
|
1759
|
+
name: `*NEW:${h.file}*`,
|
|
1760
|
+
mode: "normal",
|
|
1761
|
+
readOnly: true,
|
|
1762
|
+
entries: newEntries,
|
|
1763
|
+
showLineNumbers: true,
|
|
1764
|
+
editingDisabled: true,
|
|
1765
|
+
hiddenFromTabs: true
|
|
1766
|
+
});
|
|
1767
|
+
const newBufferId = newResult.bufferId;
|
|
1056
1768
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1769
|
+
// Convert hunks to composite buffer format (parse counts from git diff)
|
|
1770
|
+
const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => {
|
|
1771
|
+
let oldCount = 0, newCount = 0;
|
|
1772
|
+
for (const line of fh.lines) {
|
|
1773
|
+
if (line.startsWith('-')) oldCount++;
|
|
1774
|
+
else if (line.startsWith('+')) newCount++;
|
|
1775
|
+
else if (line.startsWith(' ')) { oldCount++; newCount++; }
|
|
1062
1776
|
}
|
|
1777
|
+
return {
|
|
1778
|
+
oldStart: Math.max(0, fh.oldRange.start - 1),
|
|
1779
|
+
oldCount: oldCount || 1,
|
|
1780
|
+
newStart: Math.max(0, fh.range.start - 1),
|
|
1781
|
+
newCount: newCount || 1
|
|
1782
|
+
};
|
|
1783
|
+
});
|
|
1063
1784
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1785
|
+
// Create composite buffer with side-by-side layout
|
|
1786
|
+
const compositeBufferId = await editor.createCompositeBuffer({
|
|
1787
|
+
name: `*Diff: ${h.file}*`,
|
|
1788
|
+
mode: "diff-view",
|
|
1789
|
+
layout: {
|
|
1790
|
+
type: "side-by-side",
|
|
1791
|
+
ratios: [0.5, 0.5],
|
|
1792
|
+
showSeparator: true
|
|
1793
|
+
},
|
|
1794
|
+
sources: [
|
|
1795
|
+
{
|
|
1796
|
+
bufferId: oldBufferId,
|
|
1797
|
+
label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
|
|
1798
|
+
editable: false,
|
|
1799
|
+
style: {
|
|
1800
|
+
gutterStyle: "diff-markers"
|
|
1069
1801
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1802
|
+
},
|
|
1803
|
+
{
|
|
1804
|
+
bufferId: newBufferId,
|
|
1805
|
+
label: "NEW (Working)",
|
|
1806
|
+
editable: false,
|
|
1807
|
+
style: {
|
|
1808
|
+
gutterStyle: "diff-markers"
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
],
|
|
1812
|
+
hunks: compositeHunks.length > 0 ? compositeHunks : null,
|
|
1813
|
+
initialFocusHunk: compositeHunks.length > 0 ? 0 : undefined
|
|
1814
|
+
});
|
|
1075
1815
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
activeCompositeDiffState = null;
|
|
1084
|
-
}
|
|
1816
|
+
// Store state for cleanup
|
|
1817
|
+
activeCompositeDiffState = {
|
|
1818
|
+
compositeBufferId,
|
|
1819
|
+
oldBufferId,
|
|
1820
|
+
newBufferId,
|
|
1821
|
+
filePath: h.file
|
|
1822
|
+
};
|
|
1085
1823
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
const newLines = newContent.split('\n');
|
|
1089
|
-
|
|
1090
|
-
const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({
|
|
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++; }
|
|
1131
|
-
}
|
|
1132
|
-
return {
|
|
1133
|
-
oldStart: fh.oldRange.start - 1, // Convert to 0-indexed
|
|
1134
|
-
oldCount: oldCount || 1,
|
|
1135
|
-
newStart: fh.range.start - 1, // Convert to 0-indexed
|
|
1136
|
-
newCount: newCount || 1
|
|
1137
|
-
};
|
|
1138
|
-
});
|
|
1824
|
+
// Show the composite buffer (replaces the review diff buffer)
|
|
1825
|
+
editor.showBuffer(compositeBufferId);
|
|
1139
1826
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
showSeparator: true
|
|
1148
|
-
},
|
|
1149
|
-
sources: [
|
|
1150
|
-
{
|
|
1151
|
-
bufferId: oldBufferId,
|
|
1152
|
-
label: "OLD (HEAD)",
|
|
1153
|
-
editable: false,
|
|
1154
|
-
style: {
|
|
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
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
],
|
|
1169
|
-
hunks: compositeHunks.length > 0 ? compositeHunks : null
|
|
1170
|
-
});
|
|
1827
|
+
const addedCount = fileHunks.reduce((sum, fh) => {
|
|
1828
|
+
return sum + fh.lines.filter(l => l.startsWith('+')).length;
|
|
1829
|
+
}, 0);
|
|
1830
|
+
const removedCount = fileHunks.reduce((sum, fh) => {
|
|
1831
|
+
return sum + fh.lines.filter(l => l.startsWith('-')).length;
|
|
1832
|
+
}, 0);
|
|
1833
|
+
const modifiedCount = Math.min(addedCount, removedCount);
|
|
1171
1834
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
oldBufferId,
|
|
1176
|
-
newBufferId,
|
|
1177
|
-
filePath: h.file
|
|
1178
|
-
};
|
|
1835
|
+
editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
|
|
1836
|
+
}
|
|
1837
|
+
registerHandler("review_drill_down", review_drill_down);
|
|
1179
1838
|
|
|
1180
|
-
|
|
1181
|
-
editor.showBuffer(compositeBufferId);
|
|
1839
|
+
// --- Hunk navigation for side-by-side diff view ---
|
|
1182
1840
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1841
|
+
/**
|
|
1842
|
+
* Move the diff panel's native cursor to the given 1-indexed row, scrolling
|
|
1843
|
+
* the viewport so the row is visible.
|
|
1844
|
+
*/
|
|
1845
|
+
function jumpDiffCursorToRow(row: number): void {
|
|
1846
|
+
const diffId = state.panelBuffers["diff"];
|
|
1847
|
+
if (diffId === undefined) return;
|
|
1848
|
+
const idx = row - 1;
|
|
1849
|
+
if (idx < 0 || idx >= state.diffLineByteOffsets.length) return;
|
|
1850
|
+
|
|
1851
|
+
if (state.focusPanel === 'diff') {
|
|
1852
|
+
// When the diff panel is focused, use executeAction so that the
|
|
1853
|
+
// normal cursor event flow fires and the status bar line number
|
|
1854
|
+
// updates correctly. This is O(delta) but necessary because
|
|
1855
|
+
// setBufferCursor doesn't trigger line-index refresh in the
|
|
1856
|
+
// virtual buffer's piece tree.
|
|
1857
|
+
const delta = row - state.diffCursorRow;
|
|
1858
|
+
const action = delta > 0 ? "move_down" : "move_up";
|
|
1859
|
+
for (let i = 0, n = Math.abs(delta); i < n; i++) editor.executeAction(action);
|
|
1860
|
+
} else {
|
|
1861
|
+
// When unfocused, setBufferCursor is safe since the cursor
|
|
1862
|
+
// position isn't displayed in the status bar.
|
|
1863
|
+
const byteOffset = state.diffLineByteOffsets[idx];
|
|
1864
|
+
editor.setBufferCursor(diffId, byteOffset);
|
|
1865
|
+
editor.scrollBufferToLine(diffId, idx);
|
|
1866
|
+
}
|
|
1867
|
+
state.diffCursorRow = row;
|
|
1868
|
+
applyCursorLineOverlay('diff');
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function review_next_hunk() {
|
|
1872
|
+
// Magit review-mode diff panel: jump to the next hunk header row.
|
|
1873
|
+
if (state.groupId !== null && state.focusPanel === 'diff') {
|
|
1874
|
+
for (const row of state.hunkHeaderRows) {
|
|
1875
|
+
if (row > state.diffCursorRow) {
|
|
1876
|
+
jumpDiffCursorToRow(row);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
// Composite diff-view hunk navigation is handled by the Action system
|
|
1883
|
+
// (CompositeNextHunk) via CompositeBuffer context keybindings, so no
|
|
1884
|
+
// plugin fallback is needed here.
|
|
1885
|
+
}
|
|
1886
|
+
registerHandler("review_next_hunk", review_next_hunk);
|
|
1190
1887
|
|
|
1191
|
-
|
|
1888
|
+
function review_prev_hunk() {
|
|
1889
|
+
// Magit review-mode diff panel: jump to the previous hunk header row.
|
|
1890
|
+
if (state.groupId !== null && state.focusPanel === 'diff') {
|
|
1891
|
+
for (let i = state.hunkHeaderRows.length - 1; i >= 0; i--) {
|
|
1892
|
+
const row = state.hunkHeaderRows[i];
|
|
1893
|
+
if (row < state.diffCursorRow) {
|
|
1894
|
+
jumpDiffCursorToRow(row);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
return;
|
|
1192
1899
|
}
|
|
1900
|
+
// Composite diff-view hunk navigation is handled by the Action system
|
|
1901
|
+
// (CompositePrevHunk) via CompositeBuffer context keybindings, so no
|
|
1902
|
+
// plugin fallback is needed here.
|
|
1193
1903
|
}
|
|
1194
|
-
registerHandler("
|
|
1904
|
+
registerHandler("review_prev_hunk", review_prev_hunk);
|
|
1195
1905
|
|
|
1196
1906
|
// Define the diff-view mode - inherits from "normal" for all standard navigation/selection/copy
|
|
1197
1907
|
// Only adds diff-specific keybindings (close, hunk navigation)
|
|
@@ -1208,12 +1918,22 @@ editor.defineMode("diff-view", [
|
|
|
1208
1918
|
// --- Review Comment Actions ---
|
|
1209
1919
|
|
|
1210
1920
|
function getCurrentHunkId(): string | null {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1921
|
+
if (state.files.length === 0) return null;
|
|
1922
|
+
if (state.focusPanel === 'diff') {
|
|
1923
|
+
const hunk = getHunkAtDiffCursor();
|
|
1924
|
+
return hunk?.id || null;
|
|
1925
|
+
}
|
|
1926
|
+
// File panel: return first hunk for selected file
|
|
1927
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
1928
|
+
if (!selectedFile) return null;
|
|
1929
|
+
const hunk = state.hunks.find(
|
|
1930
|
+
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
1931
|
+
);
|
|
1932
|
+
return hunk?.id || null;
|
|
1215
1933
|
}
|
|
1216
1934
|
|
|
1935
|
+
|
|
1936
|
+
|
|
1217
1937
|
interface PendingCommentInfo {
|
|
1218
1938
|
hunkId: string;
|
|
1219
1939
|
file: string;
|
|
@@ -1224,24 +1944,62 @@ interface PendingCommentInfo {
|
|
|
1224
1944
|
}
|
|
1225
1945
|
|
|
1226
1946
|
function getCurrentLineInfo(): PendingCommentInfo | null {
|
|
1227
|
-
|
|
1228
|
-
const
|
|
1229
|
-
if (
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1947
|
+
if (state.files.length === 0) return null;
|
|
1948
|
+
const selectedFile = state.files[state.selectedIndex];
|
|
1949
|
+
if (!selectedFile) return null;
|
|
1950
|
+
|
|
1951
|
+
const props = readPropsAtCursor('diff');
|
|
1952
|
+
const hunkId = props ? props["hunkId"] : undefined;
|
|
1953
|
+
if (typeof hunkId !== 'string') {
|
|
1954
|
+
// Fallback: first hunk for the selected file.
|
|
1955
|
+
const hunk = state.hunks.find(
|
|
1956
|
+
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
1957
|
+
);
|
|
1958
|
+
if (!hunk) return null;
|
|
1959
|
+
return { hunkId: hunk.id, file: hunk.file };
|
|
1239
1960
|
}
|
|
1240
|
-
|
|
1961
|
+
|
|
1962
|
+
const file = typeof props!["file"] === 'string' ? props!["file"] as string : selectedFile.path;
|
|
1963
|
+
const lineType = props!["lineType"] as ('add' | 'remove' | 'context' | undefined);
|
|
1964
|
+
const oldLine = typeof props!["oldLine"] === 'number' ? props!["oldLine"] as number : undefined;
|
|
1965
|
+
const newLine = typeof props!["newLine"] === 'number' ? props!["newLine"] as number : undefined;
|
|
1966
|
+
const lineContent = typeof props!["lineContent"] === 'string' ? props!["lineContent"] as string : undefined;
|
|
1967
|
+
return { hunkId, file, lineType, oldLine, newLine, lineContent };
|
|
1241
1968
|
}
|
|
1242
1969
|
|
|
1243
1970
|
// Pending prompt state for event-based prompt handling
|
|
1244
1971
|
let pendingCommentInfo: PendingCommentInfo | null = null;
|
|
1972
|
+
let editingCommentId: string | null = null; // non-null when editing an existing comment
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Find an existing comment at the current diff cursor position, either on the
|
|
1976
|
+
* comment display line itself or on the diff line it's attached to.
|
|
1977
|
+
*/
|
|
1978
|
+
function findCommentAtCursor(): ReviewComment | null {
|
|
1979
|
+
const props = readPropsAtCursor('diff');
|
|
1980
|
+
if (!props) return null;
|
|
1981
|
+
|
|
1982
|
+
// Cursor sits directly on a comment display line.
|
|
1983
|
+
const commentId = props["commentId"];
|
|
1984
|
+
if (typeof commentId === 'string') {
|
|
1985
|
+
return state.comments.find(c => c.id === commentId) || null;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Cursor sits on a diff line — match by hunk + line type + line number.
|
|
1989
|
+
const hunkId = props["hunkId"];
|
|
1990
|
+
const lineType = props["lineType"];
|
|
1991
|
+
if (typeof hunkId !== 'string') return null;
|
|
1992
|
+
if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
|
|
1993
|
+
const oldLine = typeof props["oldLine"] === 'number' ? props["oldLine"] as number : undefined;
|
|
1994
|
+
const newLine = typeof props["newLine"] === 'number' ? props["newLine"] as number : undefined;
|
|
1995
|
+
return state.comments.find(c =>
|
|
1996
|
+
c.hunk_id === hunkId && (
|
|
1997
|
+
(c.line_type === 'add' && c.new_line === newLine) ||
|
|
1998
|
+
(c.line_type === 'remove' && c.old_line === oldLine) ||
|
|
1999
|
+
(c.line_type === 'context' && c.new_line === newLine)
|
|
2000
|
+
)
|
|
2001
|
+
) || null;
|
|
2002
|
+
}
|
|
1245
2003
|
|
|
1246
2004
|
async function review_add_comment() {
|
|
1247
2005
|
const info = getCurrentLineInfo();
|
|
@@ -1249,9 +2007,13 @@ async function review_add_comment() {
|
|
|
1249
2007
|
editor.setStatus(editor.t("status.no_hunk_selected"));
|
|
1250
2008
|
return;
|
|
1251
2009
|
}
|
|
2010
|
+
|
|
2011
|
+
// Check for existing comment to edit
|
|
2012
|
+
const existing = findCommentAtCursor();
|
|
2013
|
+
|
|
1252
2014
|
pendingCommentInfo = info;
|
|
2015
|
+
editingCommentId = existing?.id || null;
|
|
1253
2016
|
|
|
1254
|
-
// Show line context in prompt (if on a specific line)
|
|
1255
2017
|
let lineRef = 'hunk';
|
|
1256
2018
|
if (info.lineType === 'add' && info.newLine) {
|
|
1257
2019
|
lineRef = `+${info.newLine}`;
|
|
@@ -1262,15 +2024,107 @@ async function review_add_comment() {
|
|
|
1262
2024
|
} else if (info.oldLine) {
|
|
1263
2025
|
lineRef = `L${info.oldLine}`;
|
|
1264
2026
|
}
|
|
1265
|
-
|
|
2027
|
+
|
|
2028
|
+
const label = existing
|
|
2029
|
+
? (editor.t("prompt.edit_comment", { line: lineRef }) || `Edit comment on ${lineRef}: `)
|
|
2030
|
+
: editor.t("prompt.comment", { line: lineRef });
|
|
2031
|
+
|
|
2032
|
+
if (existing) {
|
|
2033
|
+
editor.startPromptWithInitial(label, "review-comment", existing.text);
|
|
2034
|
+
} else {
|
|
2035
|
+
editor.startPrompt(label, "review-comment");
|
|
2036
|
+
}
|
|
1266
2037
|
}
|
|
1267
2038
|
registerHandler("review_add_comment", review_add_comment);
|
|
1268
2039
|
|
|
2040
|
+
let pendingDeleteCommentId: string | null = null;
|
|
2041
|
+
|
|
2042
|
+
async function review_delete_comment() {
|
|
2043
|
+
let target: ReviewComment | null = null;
|
|
2044
|
+
|
|
2045
|
+
if (state.focusPanel === 'diff') {
|
|
2046
|
+
target = findCommentAtCursor();
|
|
2047
|
+
} else {
|
|
2048
|
+
// File panel: target the last note
|
|
2049
|
+
const notes = state.comments.filter(c => c.hunk_id === '__overall__');
|
|
2050
|
+
if (notes.length > 0) target = notes[notes.length - 1];
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// File panel: delete note
|
|
2054
|
+
if (!target && state.focusPanel === 'files' && state.note) {
|
|
2055
|
+
pendingDeleteCommentId = '__note__';
|
|
2056
|
+
const preview = state.note.length > 40 ? state.note.substring(0, 37) + '...' : state.note;
|
|
2057
|
+
editor.startPrompt(`Delete note "${preview}"?`, "review-delete-comment-confirm");
|
|
2058
|
+
const suggestions: PromptSuggestion[] = [
|
|
2059
|
+
{ text: "Delete", description: "Remove this note", value: "delete" },
|
|
2060
|
+
{ text: "Cancel", description: "Keep the note", value: "cancel" },
|
|
2061
|
+
];
|
|
2062
|
+
editor.setPromptSuggestions(suggestions);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
if (!target) {
|
|
2067
|
+
editor.setStatus("No comment to delete");
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
pendingDeleteCommentId = target.id;
|
|
2072
|
+
const preview = target.text.length > 40 ? target.text.substring(0, 37) + '...' : target.text;
|
|
2073
|
+
editor.startPrompt(`Delete "${preview}"?`, "review-delete-comment-confirm");
|
|
2074
|
+
const suggestions: PromptSuggestion[] = [
|
|
2075
|
+
{ text: "Delete", description: "Remove this comment", value: "delete" },
|
|
2076
|
+
{ text: "Cancel", description: "Keep the comment", value: "cancel" },
|
|
2077
|
+
];
|
|
2078
|
+
editor.setPromptSuggestions(suggestions);
|
|
2079
|
+
}
|
|
2080
|
+
registerHandler("review_delete_comment", review_delete_comment);
|
|
2081
|
+
|
|
2082
|
+
function on_review_delete_comment_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): boolean {
|
|
2083
|
+
if (args.prompt_type !== "review-delete-comment-confirm") return true;
|
|
2084
|
+
const response = args.input.trim().toLowerCase();
|
|
2085
|
+
if ((response === "delete" || args.selected_index === 0) && pendingDeleteCommentId) {
|
|
2086
|
+
if (pendingDeleteCommentId === '__note__') {
|
|
2087
|
+
state.note = '';
|
|
2088
|
+
} else {
|
|
2089
|
+
state.comments = state.comments.filter(c => c.id !== pendingDeleteCommentId);
|
|
2090
|
+
}
|
|
2091
|
+
state.diffCache = {}; // comment changed
|
|
2092
|
+
updateMagitDisplay();
|
|
2093
|
+
editor.setStatus("Deleted");
|
|
2094
|
+
} else {
|
|
2095
|
+
editor.setStatus("Delete cancelled");
|
|
2096
|
+
}
|
|
2097
|
+
pendingDeleteCommentId = null;
|
|
2098
|
+
return false;
|
|
2099
|
+
}
|
|
2100
|
+
registerHandler("on_review_delete_comment_confirm", on_review_delete_comment_confirm);
|
|
2101
|
+
|
|
1269
2102
|
// Prompt event handlers
|
|
1270
2103
|
function on_review_prompt_confirm(args: { prompt_type: string; input: string }): boolean {
|
|
1271
2104
|
if (args.prompt_type !== "review-comment") {
|
|
1272
|
-
return true;
|
|
2105
|
+
return true;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
if (editingCommentId) {
|
|
2109
|
+
// Edit mode: update existing comment (empty text keeps the comment unchanged)
|
|
2110
|
+
if (args.input && args.input.trim()) {
|
|
2111
|
+
const existing = state.comments.find(c => c.id === editingCommentId);
|
|
2112
|
+
if (existing) {
|
|
2113
|
+
existing.text = args.input.trim();
|
|
2114
|
+
existing.timestamp = new Date().toISOString();
|
|
2115
|
+
state.diffCache = {}; // comment changed
|
|
2116
|
+
updateMagitDisplay();
|
|
2117
|
+
editor.setStatus("Comment updated");
|
|
2118
|
+
}
|
|
2119
|
+
} else {
|
|
2120
|
+
editor.setStatus("Comment unchanged (use x to delete)");
|
|
2121
|
+
}
|
|
2122
|
+
editingCommentId = null;
|
|
2123
|
+
pendingCommentInfo = null;
|
|
2124
|
+
return true;
|
|
1273
2125
|
}
|
|
2126
|
+
|
|
2127
|
+
// New comment mode
|
|
1274
2128
|
if (pendingCommentInfo && args.input && args.input.trim()) {
|
|
1275
2129
|
const comment: ReviewComment = {
|
|
1276
2130
|
id: `comment-${Date.now()}`,
|
|
@@ -1284,7 +2138,8 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
|
|
|
1284
2138
|
line_type: pendingCommentInfo.lineType
|
|
1285
2139
|
};
|
|
1286
2140
|
state.comments.push(comment);
|
|
1287
|
-
|
|
2141
|
+
state.diffCache = {}; // comment changed — invalidate cached diff entries
|
|
2142
|
+
updateMagitDisplay();
|
|
1288
2143
|
let lineRef = 'hunk';
|
|
1289
2144
|
if (comment.line_type === 'add' && comment.new_line) {
|
|
1290
2145
|
lineRef = `line +${comment.new_line}`;
|
|
@@ -1305,6 +2160,7 @@ registerHandler("on_review_prompt_confirm", on_review_prompt_confirm);
|
|
|
1305
2160
|
function on_review_prompt_cancel(args: { prompt_type: string }): boolean {
|
|
1306
2161
|
if (args.prompt_type === "review-comment") {
|
|
1307
2162
|
pendingCommentInfo = null;
|
|
2163
|
+
editingCommentId = null;
|
|
1308
2164
|
editor.setStatus(editor.t("status.comment_cancelled"));
|
|
1309
2165
|
}
|
|
1310
2166
|
return true;
|
|
@@ -1313,145 +2169,92 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
|
|
|
1313
2169
|
|
|
1314
2170
|
// Register prompt event handlers
|
|
1315
2171
|
editor.on("prompt_confirmed", "on_review_prompt_confirm");
|
|
2172
|
+
editor.on("prompt_confirmed", "on_review_discard_confirm");
|
|
2173
|
+
editor.on("prompt_confirmed", "on_review_discard_hunk_confirm");
|
|
2174
|
+
editor.on("prompt_confirmed", "on_review_edit_note_confirm");
|
|
2175
|
+
editor.on("prompt_confirmed", "on_review_delete_comment_confirm");
|
|
1316
2176
|
editor.on("prompt_cancelled", "on_review_prompt_cancel");
|
|
1317
2177
|
|
|
1318
|
-
async function
|
|
1319
|
-
const
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
await updateReviewUI();
|
|
1325
|
-
editor.setStatus(editor.t("status.hunk_approved"));
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
registerHandler("review_approve_hunk", review_approve_hunk);
|
|
1329
|
-
|
|
1330
|
-
async function review_reject_hunk() {
|
|
1331
|
-
const hunkId = getCurrentHunkId();
|
|
1332
|
-
if (!hunkId) return;
|
|
1333
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1334
|
-
if (h) {
|
|
1335
|
-
h.reviewStatus = 'rejected';
|
|
1336
|
-
await updateReviewUI();
|
|
1337
|
-
editor.setStatus(editor.t("status.hunk_rejected"));
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
registerHandler("review_reject_hunk", review_reject_hunk);
|
|
1341
|
-
|
|
1342
|
-
async function review_needs_changes() {
|
|
1343
|
-
const hunkId = getCurrentHunkId();
|
|
1344
|
-
if (!hunkId) return;
|
|
1345
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1346
|
-
if (h) {
|
|
1347
|
-
h.reviewStatus = 'needs_changes';
|
|
1348
|
-
await updateReviewUI();
|
|
1349
|
-
editor.setStatus(editor.t("status.hunk_needs_changes"));
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
registerHandler("review_needs_changes", review_needs_changes);
|
|
1353
|
-
|
|
1354
|
-
async function review_question_hunk() {
|
|
1355
|
-
const hunkId = getCurrentHunkId();
|
|
1356
|
-
if (!hunkId) return;
|
|
1357
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1358
|
-
if (h) {
|
|
1359
|
-
h.reviewStatus = 'question';
|
|
1360
|
-
await updateReviewUI();
|
|
1361
|
-
editor.setStatus(editor.t("status.hunk_question"));
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
registerHandler("review_question_hunk", review_question_hunk);
|
|
1365
|
-
|
|
1366
|
-
async function review_clear_status() {
|
|
1367
|
-
const hunkId = getCurrentHunkId();
|
|
1368
|
-
if (!hunkId) return;
|
|
1369
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1370
|
-
if (h) {
|
|
1371
|
-
h.reviewStatus = 'pending';
|
|
1372
|
-
await updateReviewUI();
|
|
1373
|
-
editor.setStatus(editor.t("status.hunk_status_cleared"));
|
|
2178
|
+
async function review_edit_note() {
|
|
2179
|
+
const label = editor.t("prompt.overall_comment") || "Note: ";
|
|
2180
|
+
if (state.note) {
|
|
2181
|
+
editor.startPromptWithInitial(label, "review-edit-note", state.note);
|
|
2182
|
+
} else {
|
|
2183
|
+
editor.startPrompt(label, "review-edit-note");
|
|
1374
2184
|
}
|
|
1375
2185
|
}
|
|
1376
|
-
registerHandler("
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
if (
|
|
1381
|
-
state.
|
|
1382
|
-
|
|
2186
|
+
registerHandler("review_edit_note", review_edit_note);
|
|
2187
|
+
|
|
2188
|
+
function on_review_edit_note_confirm(args: { prompt_type: string; input: string }): boolean {
|
|
2189
|
+
if (args.prompt_type !== "review-edit-note") return true;
|
|
2190
|
+
if (args.input && args.input.trim()) {
|
|
2191
|
+
state.note = args.input.trim();
|
|
2192
|
+
updateMagitDisplay();
|
|
2193
|
+
editor.setStatus(state.note ? "Note saved" : "Note cleared");
|
|
2194
|
+
} else {
|
|
2195
|
+
// Empty submission: keep existing note unchanged (use x to delete)
|
|
2196
|
+
if (state.note) {
|
|
2197
|
+
editor.setStatus("Note unchanged (use x to delete)");
|
|
2198
|
+
}
|
|
1383
2199
|
}
|
|
2200
|
+
return true;
|
|
1384
2201
|
}
|
|
1385
|
-
registerHandler("
|
|
2202
|
+
registerHandler("on_review_edit_note_confirm", on_review_edit_note_confirm);
|
|
1386
2203
|
|
|
1387
2204
|
async function review_export_session() {
|
|
1388
2205
|
const cwd = editor.getCwd();
|
|
1389
2206
|
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
1390
2207
|
|
|
1391
|
-
// Generate markdown content (writeFile creates parent directories)
|
|
1392
2208
|
let md = `# Code Review Session\n`;
|
|
1393
2209
|
md += `Date: ${new Date().toISOString()}\n\n`;
|
|
1394
2210
|
|
|
1395
|
-
if (state.
|
|
1396
|
-
md += `##
|
|
2211
|
+
if (state.note) {
|
|
2212
|
+
md += `## Note\n${state.note}\n\n`;
|
|
1397
2213
|
}
|
|
1398
2214
|
|
|
1399
|
-
|
|
1400
|
-
|
|
2215
|
+
// Summary
|
|
2216
|
+
const filesWithComments = new Set(state.comments.map(c => c.file)).size;
|
|
2217
|
+
md += `## Summary\n`;
|
|
2218
|
+
md += `- Files: ${state.files.length}\n`;
|
|
2219
|
+
md += `- Hunks: ${state.hunks.length}\n`;
|
|
2220
|
+
if (filesWithComments > 0) {
|
|
2221
|
+
md += `- Files with comments: ${filesWithComments}\n`;
|
|
2222
|
+
}
|
|
2223
|
+
md += `\n`;
|
|
2224
|
+
|
|
2225
|
+
// Group comments by file
|
|
2226
|
+
const fileComments: Record<string, ReviewComment[]> = {};
|
|
2227
|
+
for (const c of state.comments) {
|
|
2228
|
+
const file = c.file || 'unknown';
|
|
2229
|
+
if (!fileComments[file]) fileComments[file] = [];
|
|
2230
|
+
fileComments[file].push(c);
|
|
1401
2231
|
}
|
|
1402
2232
|
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
md += `## File: ${file}\n\n`;
|
|
1424
|
-
for (const hunk of hunks) {
|
|
1425
|
-
const statusStr = hunk.reviewStatus.toUpperCase();
|
|
1426
|
-
md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
|
|
1427
|
-
md += `**Status**: ${statusStr}\n\n`;
|
|
1428
|
-
|
|
1429
|
-
const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
|
|
1430
|
-
if (hunkComments.length > 0) {
|
|
1431
|
-
md += `**Comments:**\n`;
|
|
1432
|
-
for (const c of hunkComments) {
|
|
1433
|
-
// Format line reference
|
|
1434
|
-
let lineRef = '';
|
|
1435
|
-
if (c.line_type === 'add' && c.new_line) {
|
|
1436
|
-
lineRef = `[+${c.new_line}]`;
|
|
1437
|
-
} else if (c.line_type === 'remove' && c.old_line) {
|
|
1438
|
-
lineRef = `[-${c.old_line}]`;
|
|
1439
|
-
} else if (c.new_line) {
|
|
1440
|
-
lineRef = `[L${c.new_line}]`;
|
|
1441
|
-
} else if (c.old_line) {
|
|
1442
|
-
lineRef = `[L${c.old_line}]`;
|
|
1443
|
-
}
|
|
1444
|
-
md += `> 💬 ${lineRef} ${c.text}\n`;
|
|
1445
|
-
if (c.line_content) {
|
|
1446
|
-
md += `> \`${c.line_content.trim()}\`\n`;
|
|
1447
|
-
}
|
|
1448
|
-
md += `\n`;
|
|
1449
|
-
}
|
|
2233
|
+
for (const [file, comments] of Object.entries(fileComments)) {
|
|
2234
|
+
md += `## ${file}\n\n`;
|
|
2235
|
+
for (const c of comments) {
|
|
2236
|
+
let lineRef = '';
|
|
2237
|
+
if (c.line_type === 'add' && c.new_line) {
|
|
2238
|
+
lineRef = `line +${c.new_line}`;
|
|
2239
|
+
} else if (c.line_type === 'remove' && c.old_line) {
|
|
2240
|
+
lineRef = `line -${c.old_line}`;
|
|
2241
|
+
} else if (c.new_line) {
|
|
2242
|
+
lineRef = `line ${c.new_line}`;
|
|
2243
|
+
} else if (c.old_line) {
|
|
2244
|
+
lineRef = `line ${c.old_line}`;
|
|
2245
|
+
}
|
|
2246
|
+
if (lineRef) {
|
|
2247
|
+
md += `- **${lineRef}**: ${c.text}\n`;
|
|
2248
|
+
} else {
|
|
2249
|
+
md += `- ${c.text}\n`;
|
|
2250
|
+
}
|
|
2251
|
+
if (c.line_content) {
|
|
2252
|
+
md += ` \`${c.line_content.trim()}\`\n`;
|
|
1450
2253
|
}
|
|
1451
2254
|
}
|
|
2255
|
+
md += `\n`;
|
|
1452
2256
|
}
|
|
1453
2257
|
|
|
1454
|
-
// Write file
|
|
1455
2258
|
const filePath = editor.pathJoin(reviewDir, "session.md");
|
|
1456
2259
|
await editor.writeFile(filePath, md);
|
|
1457
2260
|
editor.setStatus(editor.t("status.exported", { path: filePath }));
|
|
@@ -1461,34 +2264,21 @@ registerHandler("review_export_session", review_export_session);
|
|
|
1461
2264
|
async function review_export_json() {
|
|
1462
2265
|
const cwd = editor.getCwd();
|
|
1463
2266
|
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
1464
|
-
// writeFile creates parent directories
|
|
1465
2267
|
|
|
1466
2268
|
const session = {
|
|
1467
|
-
version: "
|
|
2269
|
+
version: "2.0",
|
|
1468
2270
|
timestamp: new Date().toISOString(),
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
2271
|
+
note: state.note || null,
|
|
2272
|
+
comments: state.comments.map(c => ({
|
|
2273
|
+
file: c.file,
|
|
2274
|
+
text: c.text,
|
|
2275
|
+
line_type: c.line_type || null,
|
|
2276
|
+
old_line: c.old_line || null,
|
|
2277
|
+
new_line: c.new_line || null,
|
|
2278
|
+
line_content: c.line_content || null
|
|
2279
|
+
}))
|
|
1472
2280
|
};
|
|
1473
2281
|
|
|
1474
|
-
for (const hunk of state.hunks) {
|
|
1475
|
-
if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
|
|
1476
|
-
const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
|
|
1477
|
-
session.files[hunk.file].hunks.push({
|
|
1478
|
-
context: hunk.contextHeader,
|
|
1479
|
-
old_lines: [hunk.oldRange.start, hunk.oldRange.end],
|
|
1480
|
-
new_lines: [hunk.range.start, hunk.range.end],
|
|
1481
|
-
status: hunk.reviewStatus,
|
|
1482
|
-
comments: hunkComments.map(c => ({
|
|
1483
|
-
text: c.text,
|
|
1484
|
-
line_type: c.line_type || null,
|
|
1485
|
-
old_line: c.old_line || null,
|
|
1486
|
-
new_line: c.new_line || null,
|
|
1487
|
-
line_content: c.line_content || null
|
|
1488
|
-
}))
|
|
1489
|
-
});
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
2282
|
const filePath = editor.pathJoin(reviewDir, "session.json");
|
|
1493
2283
|
await editor.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
1494
2284
|
editor.setStatus(editor.t("status.exported", { path: filePath }));
|
|
@@ -1499,39 +2289,133 @@ async function start_review_diff() {
|
|
|
1499
2289
|
editor.setStatus(editor.t("status.generating"));
|
|
1500
2290
|
editor.setContext("review-mode", true);
|
|
1501
2291
|
|
|
1502
|
-
//
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
2292
|
+
// Get viewport size
|
|
2293
|
+
const viewport = editor.getViewport();
|
|
2294
|
+
if (viewport) {
|
|
2295
|
+
state.viewportWidth = viewport.width;
|
|
2296
|
+
state.viewportHeight = viewport.height;
|
|
2297
|
+
}
|
|
1506
2298
|
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
2299
|
+
// Fetch data using new git status approach
|
|
2300
|
+
state.files = await getGitStatus();
|
|
2301
|
+
state.hunks = await fetchDiffsForFiles(state.files);
|
|
2302
|
+
state.comments = [];
|
|
2303
|
+
state.note = '';
|
|
2304
|
+
state.selectedIndex = 0;
|
|
2305
|
+
state.diffCursorRow = 1;
|
|
2306
|
+
state.hunkHeaderRows = [];
|
|
2307
|
+
state.diffLineByteOffsets = [];
|
|
2308
|
+
state.focusPanel = 'files';
|
|
2309
|
+
|
|
2310
|
+
// Create buffer group with layout:
|
|
2311
|
+
// vertical: [toolbar(fixed 1), horizontal: [files, diff]]
|
|
2312
|
+
const layout = JSON.stringify({
|
|
2313
|
+
type: "split",
|
|
2314
|
+
direction: "v",
|
|
2315
|
+
ratio: 0.05,
|
|
2316
|
+
first: { type: "fixed", id: "toolbar", height: 1 },
|
|
2317
|
+
second: {
|
|
2318
|
+
type: "split",
|
|
2319
|
+
direction: "h",
|
|
2320
|
+
ratio: 0.3,
|
|
2321
|
+
first: { type: "scrollable", id: "files" },
|
|
2322
|
+
second: { type: "scrollable", id: "diff" },
|
|
2323
|
+
},
|
|
1510
2324
|
});
|
|
1511
|
-
|
|
1512
|
-
await
|
|
2325
|
+
|
|
2326
|
+
const groupResult = await editor.createBufferGroup("*Review Diff*", "review-mode", layout);
|
|
2327
|
+
state.groupId = groupResult.groupId;
|
|
2328
|
+
state.panelBuffers = groupResult.panels;
|
|
2329
|
+
state.reviewBufferId = groupResult.panels["files"];
|
|
2330
|
+
|
|
2331
|
+
// Diff panel uses the editor's native cursor for scrolling. Buffer-group
|
|
2332
|
+
// panels default to `show_cursors = false`, which also blocks all native
|
|
2333
|
+
// movement actions in `action_to_events`, so flip the flag for the diff
|
|
2334
|
+
// panel only. The files panel keeps its hidden cursor — selection there
|
|
2335
|
+
// is plugin-managed (state.selectedIndex with a `>` prefix + bg highlight),
|
|
2336
|
+
// and j/k/Up/Down are dispatched through the `review_nav_*` handlers.
|
|
2337
|
+
if (state.panelBuffers["diff"] !== undefined) {
|
|
2338
|
+
(editor as any).setBufferShowCursors(state.panelBuffers["diff"], true);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// Set initial content for all panels
|
|
2342
|
+
updateMagitDisplay();
|
|
2343
|
+
|
|
2344
|
+
// Ensure the files panel has focus (moves focus away from File Explorer
|
|
2345
|
+
// if it was open, so review-mode keybindings work immediately)
|
|
2346
|
+
editor.focusBufferGroupPanel(state.groupId, "files");
|
|
2347
|
+
|
|
2348
|
+
// Register resize handler
|
|
2349
|
+
editor.on("resize", "onReviewDiffResize");
|
|
1513
2350
|
|
|
1514
2351
|
editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
|
|
1515
2352
|
editor.on("buffer_activated", "on_review_buffer_activated");
|
|
1516
2353
|
editor.on("buffer_closed", "on_review_buffer_closed");
|
|
2354
|
+
editor.on("cursor_moved", "on_review_cursor_moved");
|
|
1517
2355
|
}
|
|
1518
2356
|
registerHandler("start_review_diff", start_review_diff);
|
|
1519
2357
|
|
|
1520
2358
|
function stop_review_diff() {
|
|
2359
|
+
if (state.groupId !== null) {
|
|
2360
|
+
editor.closeBufferGroup(state.groupId);
|
|
2361
|
+
state.groupId = null;
|
|
2362
|
+
state.panelBuffers = {};
|
|
2363
|
+
}
|
|
1521
2364
|
state.reviewBufferId = null;
|
|
1522
2365
|
editor.setContext("review-mode", false);
|
|
2366
|
+
editor.off("resize", "onReviewDiffResize");
|
|
1523
2367
|
editor.off("buffer_activated", "on_review_buffer_activated");
|
|
1524
2368
|
editor.off("buffer_closed", "on_review_buffer_closed");
|
|
2369
|
+
editor.off("cursor_moved", "on_review_cursor_moved");
|
|
1525
2370
|
editor.setStatus(editor.t("status.stopped"));
|
|
1526
2371
|
}
|
|
1527
2372
|
registerHandler("stop_review_diff", stop_review_diff);
|
|
1528
2373
|
|
|
1529
2374
|
|
|
1530
|
-
|
|
1531
|
-
|
|
2375
|
+
/**
|
|
2376
|
+
* React to a buffer becoming active. Used here purely to track which review
|
|
2377
|
+
* panel currently has focus (Tab and mouse clicks both fire buffer_activated).
|
|
2378
|
+
* The focus state drives toolbar hint rendering and the `review_nav_*`
|
|
2379
|
+
* handlers' files-vs-diff branching.
|
|
2380
|
+
*
|
|
2381
|
+
* Note: this used to call `refreshMagitData()` on every activation, which
|
|
2382
|
+
* spawned several `git` subprocesses every time the user switched panels.
|
|
2383
|
+
* The user has a dedicated `r` key for that — auto-refresh was too aggressive.
|
|
2384
|
+
*/
|
|
2385
|
+
function on_review_buffer_activated(data: { buffer_id: number }): void {
|
|
2386
|
+
if (state.groupId === null) return;
|
|
2387
|
+
const filesId = state.panelBuffers["files"];
|
|
2388
|
+
const diffId = state.panelBuffers["diff"];
|
|
2389
|
+
let newPanel: 'files' | 'diff' | null = null;
|
|
2390
|
+
if (data.buffer_id === filesId) newPanel = 'files';
|
|
2391
|
+
else if (data.buffer_id === diffId) newPanel = 'diff';
|
|
2392
|
+
if (newPanel === null || newPanel === state.focusPanel) return;
|
|
2393
|
+
state.focusPanel = newPanel;
|
|
2394
|
+
editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
|
|
1532
2395
|
}
|
|
1533
2396
|
registerHandler("on_review_buffer_activated", on_review_buffer_activated);
|
|
1534
2397
|
|
|
2398
|
+
/**
|
|
2399
|
+
* React to native cursor movement inside the diff panel — the only panel
|
|
2400
|
+
* that has a visible native cursor. The handler keeps `state.diffCursorRow`
|
|
2401
|
+
* in sync (used by `n`/`p` hunk navigation) and re-paints the cursor-line
|
|
2402
|
+
* highlight overlay.
|
|
2403
|
+
*/
|
|
2404
|
+
function on_review_cursor_moved(data: {
|
|
2405
|
+
buffer_id: number;
|
|
2406
|
+
cursor_id: number;
|
|
2407
|
+
old_position: number;
|
|
2408
|
+
new_position: number;
|
|
2409
|
+
line: number;
|
|
2410
|
+
text_properties: Array<Record<string, unknown>>;
|
|
2411
|
+
}): void {
|
|
2412
|
+
if (state.groupId === null) return;
|
|
2413
|
+
if (data.buffer_id !== state.panelBuffers["diff"]) return;
|
|
2414
|
+
state.diffCursorRow = data.line;
|
|
2415
|
+
applyCursorLineOverlay('diff');
|
|
2416
|
+
}
|
|
2417
|
+
registerHandler("on_review_cursor_moved", on_review_cursor_moved);
|
|
2418
|
+
|
|
1535
2419
|
function on_review_buffer_closed(data: any) {
|
|
1536
2420
|
if (data.buffer_id === state.reviewBufferId) stop_review_diff();
|
|
1537
2421
|
}
|
|
@@ -1579,15 +2463,29 @@ async function side_by_side_diff_current_file() {
|
|
|
1579
2463
|
}
|
|
1580
2464
|
}
|
|
1581
2465
|
|
|
1582
|
-
//
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
2466
|
+
// Check if the file is untracked
|
|
2467
|
+
const isTrackedResult = await editor.spawnProcess("git", ["-C", gitRoot, "ls-files", "--", filePath]);
|
|
2468
|
+
const isUntracked = isTrackedResult.exit_code !== 0 || !isTrackedResult.stdout.trim();
|
|
2469
|
+
|
|
2470
|
+
// Get hunks for this specific file
|
|
2471
|
+
let diffOutput: string;
|
|
2472
|
+
if (isUntracked) {
|
|
2473
|
+
// For untracked files, use --no-index to diff against /dev/null
|
|
2474
|
+
const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "--no-index", "--unified=3", "--", "/dev/null", filePath]);
|
|
2475
|
+
// git diff --no-index exits with 1 when there are differences, which is expected
|
|
2476
|
+
diffOutput = result.stdout || "";
|
|
2477
|
+
} else {
|
|
2478
|
+
// For tracked files, use normal diff against HEAD
|
|
2479
|
+
const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "HEAD", "--unified=3", "--", filePath]);
|
|
2480
|
+
if (result.exit_code !== 0) {
|
|
2481
|
+
editor.setStatus(editor.t("status.failed_git_diff"));
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
diffOutput = result.stdout;
|
|
1587
2485
|
}
|
|
1588
2486
|
|
|
1589
2487
|
// Parse hunks from diff output
|
|
1590
|
-
const lines =
|
|
2488
|
+
const lines = diffOutput.split('\n');
|
|
1591
2489
|
const fileHunks: Hunk[] = [];
|
|
1592
2490
|
let currentHunk: Hunk | null = null;
|
|
1593
2491
|
|
|
@@ -1604,10 +2502,9 @@ async function side_by_side_diff_current_file() {
|
|
|
1604
2502
|
file: filePath,
|
|
1605
2503
|
range: { start: newStart, end: newStart + newCount - 1 },
|
|
1606
2504
|
oldRange: { start: oldStart, end: oldStart + oldCount - 1 },
|
|
1607
|
-
type: 'modify',
|
|
2505
|
+
type: isUntracked ? 'add' : 'modify',
|
|
1608
2506
|
lines: [],
|
|
1609
2507
|
status: 'pending',
|
|
1610
|
-
reviewStatus: 'pending',
|
|
1611
2508
|
contextHeader: match[5]?.trim() || "",
|
|
1612
2509
|
byteOffset: 0
|
|
1613
2510
|
};
|
|
@@ -1626,12 +2523,18 @@ async function side_by_side_diff_current_file() {
|
|
|
1626
2523
|
}
|
|
1627
2524
|
|
|
1628
2525
|
// Get old (HEAD) and new (working) file content (use -C gitRoot since filePath is relative to git root)
|
|
1629
|
-
|
|
1630
|
-
if (
|
|
1631
|
-
|
|
1632
|
-
|
|
2526
|
+
let oldContent: string;
|
|
2527
|
+
if (isUntracked) {
|
|
2528
|
+
// For untracked files, old content is empty (file didn't exist before)
|
|
2529
|
+
oldContent = "";
|
|
2530
|
+
} else {
|
|
2531
|
+
const gitShow = await editor.spawnProcess("git", ["-C", gitRoot, "show", `HEAD:${filePath}`]);
|
|
2532
|
+
if (gitShow.exit_code !== 0) {
|
|
2533
|
+
editor.setStatus(editor.t("status.failed_old_new_file"));
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
oldContent = gitShow.stdout;
|
|
1633
2537
|
}
|
|
1634
|
-
const oldContent = gitShow.stdout;
|
|
1635
2538
|
|
|
1636
2539
|
// Read new file content (use absolute path for readFile)
|
|
1637
2540
|
const newContent = await editor.readFile(absolutePath);
|
|
@@ -1701,9 +2604,9 @@ async function side_by_side_diff_current_file() {
|
|
|
1701
2604
|
|
|
1702
2605
|
// Convert hunks to composite buffer format
|
|
1703
2606
|
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
|
|
2607
|
+
oldStart: Math.max(0, h.oldRange.start - 1), // Convert to 0-indexed (0 for new files)
|
|
2608
|
+
oldCount: Math.max(1, h.oldRange.end - h.oldRange.start + 1),
|
|
2609
|
+
newStart: Math.max(0, h.range.start - 1), // Convert to 0-indexed
|
|
1707
2610
|
newCount: h.range.end - h.range.start + 1
|
|
1708
2611
|
}));
|
|
1709
2612
|
|
|
@@ -1719,10 +2622,9 @@ async function side_by_side_diff_current_file() {
|
|
|
1719
2622
|
sources: [
|
|
1720
2623
|
{
|
|
1721
2624
|
bufferId: oldBufferId,
|
|
1722
|
-
label: "OLD (HEAD)",
|
|
2625
|
+
label: "OLD (HEAD) [n/] next [p/[] prev [q] close",
|
|
1723
2626
|
editable: false,
|
|
1724
2627
|
style: {
|
|
1725
|
-
removeBg: [80, 40, 40],
|
|
1726
2628
|
gutterStyle: "diff-markers"
|
|
1727
2629
|
}
|
|
1728
2630
|
},
|
|
@@ -1731,7 +2633,6 @@ async function side_by_side_diff_current_file() {
|
|
|
1731
2633
|
label: "NEW (Working)",
|
|
1732
2634
|
editable: false,
|
|
1733
2635
|
style: {
|
|
1734
|
-
addBg: [40, 80, 40],
|
|
1735
2636
|
gutterStyle: "diff-markers"
|
|
1736
2637
|
}
|
|
1737
2638
|
}
|
|
@@ -1770,12 +2671,7 @@ editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc",
|
|
|
1770
2671
|
|
|
1771
2672
|
// Review Comment Commands
|
|
1772
2673
|
editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
|
|
1773
|
-
editor.registerCommand("%cmd.
|
|
1774
|
-
editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
|
|
1775
|
-
editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
|
|
1776
|
-
editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
|
|
1777
|
-
editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
|
|
1778
|
-
editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "review_set_overall_feedback", "review-mode");
|
|
2674
|
+
editor.registerCommand("%cmd.edit_note", "%cmd.edit_note_desc", "review_edit_note", "review-mode");
|
|
1779
2675
|
editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
|
|
1780
2676
|
editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
|
|
1781
2677
|
|
|
@@ -1813,21 +2709,31 @@ registerHandler("on_buffer_closed", on_buffer_closed);
|
|
|
1813
2709
|
editor.on("buffer_closed", "on_buffer_closed");
|
|
1814
2710
|
|
|
1815
2711
|
editor.defineMode("review-mode", [
|
|
1816
|
-
//
|
|
1817
|
-
|
|
1818
|
-
//
|
|
1819
|
-
|
|
1820
|
-
["
|
|
1821
|
-
|
|
2712
|
+
// Cursor motion goes through plugin handlers that branch on focus —
|
|
2713
|
+
// files panel updates `state.selectedIndex` (plugin-managed selection
|
|
2714
|
+
// with the `>` prefix + bg highlight); diff panel delegates to native
|
|
2715
|
+
// editor motion via executeAction so scrolling stays fast.
|
|
2716
|
+
["Up", "review_nav_up"], ["Down", "review_nav_down"],
|
|
2717
|
+
["k", "review_nav_up"], ["j", "review_nav_down"],
|
|
2718
|
+
["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
|
|
2719
|
+
["Home", "review_nav_home"], ["End", "review_nav_end"],
|
|
2720
|
+
// Focus toggle between panels
|
|
2721
|
+
["Tab", "review_toggle_focus"],
|
|
2722
|
+
// Hunk navigation (diff panel) — jumps the native cursor between hunks.
|
|
2723
|
+
["n", "review_next_hunk"], ["p", "review_prev_hunk"],
|
|
2724
|
+
// Drill-down
|
|
2725
|
+
["Enter", "review_drill_down"],
|
|
2726
|
+
// Git actions (context-sensitive: file-level or hunk-level based on focus)
|
|
2727
|
+
["s", "review_stage_file"], ["u", "review_unstage_file"],
|
|
2728
|
+
["d", "review_discard_file"],
|
|
2729
|
+
["r", "review_refresh"],
|
|
2730
|
+
// Comments
|
|
1822
2731
|
["c", "review_add_comment"],
|
|
1823
|
-
["
|
|
1824
|
-
["x", "
|
|
1825
|
-
|
|
1826
|
-
["
|
|
1827
|
-
["
|
|
1828
|
-
["O", "review_set_overall_feedback"],
|
|
1829
|
-
// Export
|
|
1830
|
-
["E", "review_export_session"],
|
|
2732
|
+
["N", "review_edit_note"],
|
|
2733
|
+
["x", "review_delete_comment"],
|
|
2734
|
+
// Close & export
|
|
2735
|
+
["q", "close"],
|
|
2736
|
+
["e", "review_export_session"],
|
|
1831
2737
|
], true);
|
|
1832
2738
|
|
|
1833
2739
|
editor.debug("Review Diff plugin loaded with review comments support");
|