@fresh-editor/fresh-editor 0.2.22 → 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 +41 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +141 -379
- package/plugins/audit_mode.ts +1078 -451
- package/plugins/config-schema.json +146 -0
- package/plugins/git_explorer.ts +7 -7
- package/plugins/lib/fresh.d.ts +25 -2
- package/plugins/pkg.ts +151 -397
- package/plugins/theme_editor.i18n.json +182 -14
- package/plugins/theme_editor.ts +192 -85
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,8 +46,6 @@ 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
|
|
64
51
|
gitStatus?: 'staged' | 'unstaged' | 'untracked';
|
|
@@ -76,38 +63,69 @@ interface FileEntry {
|
|
|
76
63
|
|
|
77
64
|
/**
|
|
78
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).
|
|
79
71
|
*/
|
|
80
72
|
interface ReviewState {
|
|
81
73
|
hunks: Hunk[];
|
|
82
|
-
hunkStatus: Record<string, HunkStatus>;
|
|
83
74
|
comments: ReviewComment[];
|
|
84
|
-
|
|
85
|
-
overallFeedback?: string;
|
|
75
|
+
note: string;
|
|
86
76
|
reviewBufferId: number | null;
|
|
87
77
|
// New magit-style state
|
|
88
78
|
files: FileEntry[];
|
|
89
79
|
selectedIndex: number;
|
|
90
|
-
fileScrollOffset: number;
|
|
91
|
-
diffScrollOffset: number;
|
|
92
80
|
viewportWidth: number;
|
|
93
81
|
viewportHeight: number;
|
|
94
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[];
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
const state: ReviewState = {
|
|
98
106
|
hunks: [],
|
|
99
|
-
hunkStatus: {},
|
|
100
107
|
comments: [],
|
|
108
|
+
note: '',
|
|
101
109
|
reviewBufferId: null,
|
|
102
110
|
files: [],
|
|
103
111
|
selectedIndex: 0,
|
|
104
|
-
fileScrollOffset: 0,
|
|
105
|
-
diffScrollOffset: 0,
|
|
106
112
|
viewportWidth: 80,
|
|
107
113
|
viewportHeight: 24,
|
|
108
114
|
focusPanel: 'files',
|
|
115
|
+
groupId: null,
|
|
116
|
+
panelBuffers: {},
|
|
117
|
+
hunkHeaderRows: [],
|
|
118
|
+
diffLineByteOffsets: [],
|
|
119
|
+
diffCursorRow: 1,
|
|
120
|
+
diffCache: {},
|
|
109
121
|
};
|
|
110
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
|
+
|
|
111
129
|
// --- Refresh State ---
|
|
112
130
|
|
|
113
131
|
// --- Colors & Styles ---
|
|
@@ -119,14 +137,10 @@ const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
|
|
|
119
137
|
const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
|
|
120
138
|
const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
|
|
121
139
|
const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
|
|
122
|
-
|
|
123
|
-
const STYLE_DISCARDED: OverlayColorSpec = "diagnostic.error_fg";
|
|
140
|
+
|
|
124
141
|
const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
|
|
125
142
|
const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
|
|
126
|
-
|
|
127
|
-
const STYLE_APPROVED: OverlayColorSpec = "diagnostic.info_fg";
|
|
128
|
-
const STYLE_REJECTED: OverlayColorSpec = "diagnostic.error_fg";
|
|
129
|
-
const STYLE_QUESTION: OverlayColorSpec = "diagnostic.warning_fg";
|
|
143
|
+
|
|
130
144
|
|
|
131
145
|
/**
|
|
132
146
|
* Calculate UTF-8 byte length of a string manually since TextEncoder is not available
|
|
@@ -151,46 +165,45 @@ interface DiffPart {
|
|
|
151
165
|
type: 'added' | 'removed' | 'unchanged';
|
|
152
166
|
}
|
|
153
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
|
+
*/
|
|
154
186
|
function diffStrings(oldStr: string, newStr: string): DiffPart[] {
|
|
155
187
|
const n = oldStr.length;
|
|
156
188
|
const m = newStr.length;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
176
|
-
result.unshift({ text: newStr[j - 1], type: 'added' });
|
|
177
|
-
j--;
|
|
178
|
-
} else {
|
|
179
|
-
result.unshift({ text: oldStr[i - 1], type: 'removed' });
|
|
180
|
-
i--;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const coalesced: DiffPart[] = [];
|
|
185
|
-
for (const part of result) {
|
|
186
|
-
const last = coalesced[coalesced.length - 1];
|
|
187
|
-
if (last && last.type === part.type) {
|
|
188
|
-
last.text += part.text;
|
|
189
|
-
} else {
|
|
190
|
-
coalesced.push(part);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return coalesced;
|
|
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++;
|
|
199
|
+
}
|
|
200
|
+
|
|
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;
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
|
|
@@ -220,7 +233,6 @@ function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'unt
|
|
|
220
233
|
type: 'modify',
|
|
221
234
|
lines: [],
|
|
222
235
|
status: 'pending',
|
|
223
|
-
reviewStatus: 'pending',
|
|
224
236
|
contextHeader: match[3]?.trim() || "",
|
|
225
237
|
byteOffset: 0,
|
|
226
238
|
gitStatus
|
|
@@ -374,7 +386,6 @@ async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
|
|
|
374
386
|
|
|
375
387
|
// --- New magit-style rendering (Step 2 of rewrite) ---
|
|
376
388
|
|
|
377
|
-
const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
|
|
378
389
|
const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
|
|
379
390
|
const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
|
|
380
391
|
const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
|
|
@@ -389,16 +400,24 @@ interface ListLine {
|
|
|
389
400
|
|
|
390
401
|
interface DiffLine {
|
|
391
402
|
text: string;
|
|
392
|
-
type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty';
|
|
403
|
+
type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment';
|
|
393
404
|
style?: Partial<OverlayOptions>;
|
|
394
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;
|
|
395
414
|
}
|
|
396
415
|
|
|
397
416
|
/**
|
|
398
417
|
* Build the file list lines for the left panel.
|
|
399
418
|
* Returns section headers (not selectable) and file entries.
|
|
400
419
|
*/
|
|
401
|
-
function buildFileListLines(): ListLine[] {
|
|
420
|
+
function buildFileListLines(leftWidth?: number): ListLine[] {
|
|
402
421
|
const lines: ListLine[] = [];
|
|
403
422
|
let lastCategory: string | undefined;
|
|
404
423
|
|
|
@@ -418,7 +437,7 @@ function buildFileListLines(): ListLine[] {
|
|
|
418
437
|
});
|
|
419
438
|
}
|
|
420
439
|
|
|
421
|
-
// Status icon
|
|
440
|
+
// Status icon + selection prefix.
|
|
422
441
|
const statusIcon = f.status === '?' ? 'A' : f.status;
|
|
423
442
|
const prefix = i === state.selectedIndex ? '>' : ' ';
|
|
424
443
|
const filename = f.origPath ? `${f.origPath} → ${f.path}` : f.path;
|
|
@@ -429,9 +448,64 @@ function buildFileListLines(): ListLine[] {
|
|
|
429
448
|
});
|
|
430
449
|
}
|
|
431
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;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (line) {
|
|
472
|
+
lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
432
476
|
return lines;
|
|
433
477
|
}
|
|
434
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
|
+
|
|
435
509
|
/**
|
|
436
510
|
* Build the diff lines for the right panel based on currently selected file.
|
|
437
511
|
*/
|
|
@@ -463,39 +537,125 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
463
537
|
}
|
|
464
538
|
|
|
465
539
|
for (const hunk of fileHunks) {
|
|
466
|
-
// Hunk header
|
|
540
|
+
// Hunk header with review status indicator
|
|
467
541
|
const header = hunk.contextHeader
|
|
468
542
|
? `@@ ${hunk.contextHeader} @@`
|
|
469
543
|
: `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
|
|
544
|
+
|
|
470
545
|
lines.push({
|
|
471
546
|
text: header,
|
|
472
547
|
type: 'hunk-header',
|
|
548
|
+
hunkId: hunk.id,
|
|
549
|
+
file: hunk.file,
|
|
473
550
|
style: { fg: STYLE_HUNK_HEADER, bold: true },
|
|
474
551
|
});
|
|
475
552
|
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
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];
|
|
480
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;
|
|
596
|
+
}
|
|
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;
|
|
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;
|
|
629
|
+
}
|
|
630
|
+
|
|
481
631
|
if (prefix === '+') {
|
|
482
632
|
lines.push({
|
|
483
|
-
text: line,
|
|
484
|
-
type: 'add',
|
|
633
|
+
text: line, type: 'add',
|
|
485
634
|
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
635
|
+
hunkId: hunk.id, file: hunk.file,
|
|
636
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
486
637
|
});
|
|
638
|
+
newLineNum++;
|
|
487
639
|
} else if (prefix === '-') {
|
|
488
640
|
lines.push({
|
|
489
|
-
text: line,
|
|
490
|
-
type: 'remove',
|
|
641
|
+
text: line, type: 'remove',
|
|
491
642
|
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
643
|
+
hunkId: hunk.id, file: hunk.file,
|
|
644
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
492
645
|
});
|
|
646
|
+
oldLineNum++;
|
|
493
647
|
} else {
|
|
494
648
|
lines.push({
|
|
495
|
-
text: line,
|
|
496
|
-
|
|
649
|
+
text: line, type: 'context',
|
|
650
|
+
hunkId: hunk.id, file: hunk.file,
|
|
651
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
497
652
|
});
|
|
653
|
+
oldLineNum++;
|
|
654
|
+
newLineNum++;
|
|
498
655
|
}
|
|
656
|
+
|
|
657
|
+
// Render inline comments attached to this line
|
|
658
|
+
pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
|
|
499
659
|
}
|
|
500
660
|
}
|
|
501
661
|
|
|
@@ -509,260 +669,463 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
509
669
|
* Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
|
|
510
670
|
* Rows 2..H-1: Main content (left file list, │ divider, right diff)
|
|
511
671
|
*/
|
|
512
|
-
function buildMagitDisplayEntries(): TextPropertyEntry[] {
|
|
513
|
-
const entries: TextPropertyEntry[] = [];
|
|
514
|
-
const H = state.viewportHeight;
|
|
515
|
-
const W = state.viewportWidth;
|
|
516
|
-
const leftWidth = Math.max(28, Math.floor(W * 0.3));
|
|
517
|
-
const rightWidth = W - leftWidth - 1; // 1 for divider
|
|
518
672
|
|
|
519
|
-
|
|
520
|
-
|
|
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";
|
|
521
679
|
|
|
522
|
-
|
|
680
|
+
interface HintItem {
|
|
681
|
+
key: string;
|
|
682
|
+
label: string;
|
|
683
|
+
}
|
|
523
684
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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);
|
|
535
719
|
}
|
|
536
|
-
|
|
537
|
-
|
|
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
|
+
}
|
|
538
748
|
}
|
|
539
749
|
}
|
|
540
|
-
const maxFileOffset = Math.max(0, allFileLines.length - mainRows);
|
|
541
|
-
if (state.fileScrollOffset > maxFileOffset) state.fileScrollOffset = maxFileOffset;
|
|
542
|
-
if (state.fileScrollOffset < 0) state.fileScrollOffset = 0;
|
|
543
750
|
|
|
544
|
-
const
|
|
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 ---
|
|
545
761
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
762
|
+
function buildToolbarPanelEntries(): TextPropertyEntry[] {
|
|
763
|
+
// Reuse buildToolbar — returns one entry with the full toolbar line
|
|
764
|
+
return [buildToolbar(state.viewportWidth)];
|
|
765
|
+
}
|
|
550
766
|
|
|
551
|
-
|
|
767
|
+
function buildFilesPanelEntries(): TextPropertyEntry[] {
|
|
768
|
+
const entries: TextPropertyEntry[] = [];
|
|
769
|
+
const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
|
|
552
770
|
|
|
553
|
-
//
|
|
554
|
-
const
|
|
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 };
|
|
555
776
|
entries.push({
|
|
556
|
-
text:
|
|
557
|
-
style:
|
|
558
|
-
properties: { type: "
|
|
777
|
+
text: " GIT STATUS\n",
|
|
778
|
+
style: headerStyle,
|
|
779
|
+
properties: { type: "header" },
|
|
559
780
|
});
|
|
560
781
|
|
|
561
|
-
|
|
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;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
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.
|
|
808
|
+
*/
|
|
809
|
+
function buildDiffPanelEntries(): TextPropertyEntry[] {
|
|
562
810
|
const selectedFile = state.files[state.selectedIndex];
|
|
563
|
-
const
|
|
564
|
-
|
|
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.
|
|
565
840
|
const rightHeader = selectedFile
|
|
566
841
|
? ` DIFF FOR ${selectedFile.path}`
|
|
567
842
|
: " DIFF";
|
|
568
|
-
|
|
569
|
-
|
|
843
|
+
pushEntry({
|
|
844
|
+
text: rightHeader + "\n",
|
|
845
|
+
style: { fg: STYLE_HEADER, bold: true, underline: true },
|
|
846
|
+
properties: { type: "header" },
|
|
847
|
+
});
|
|
570
848
|
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
// Left panel
|
|
589
|
-
const leftText = fileItem ? (" " + fileItem.text) : "";
|
|
590
|
-
const leftPadded = leftText.padEnd(leftWidth).substring(0, leftWidth);
|
|
591
|
-
const isSelected = fileItem?.type === 'file' && fileItem.fileIndex === state.selectedIndex;
|
|
592
|
-
|
|
593
|
-
const leftEntry: TextPropertyEntry = {
|
|
594
|
-
text: leftPadded,
|
|
595
|
-
properties: {
|
|
596
|
-
type: fileItem?.type || "blank",
|
|
597
|
-
fileIndex: fileItem?.fileIndex,
|
|
598
|
-
},
|
|
599
|
-
style: fileItem?.style,
|
|
600
|
-
inlineOverlays: fileItem?.inlineOverlays,
|
|
601
|
-
};
|
|
602
|
-
if (isSelected) {
|
|
603
|
-
leftEntry.style = { ...(leftEntry.style || {}), bg: STYLE_SELECTED_BG, bold: true };
|
|
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);
|
|
604
866
|
}
|
|
605
|
-
entries.push(leftEntry);
|
|
606
|
-
|
|
607
|
-
// Divider
|
|
608
|
-
entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
|
|
609
|
-
|
|
610
|
-
// Right panel — when diff panel is focused, highlight the top line as cursor
|
|
611
|
-
const rightText = diffItem ? (" " + diffItem.text) : "";
|
|
612
|
-
const rightTruncated = rightText.substring(0, rightWidth);
|
|
613
|
-
const isDiffCursorLine = !focusLeft && i === 0 && diffItem != null;
|
|
614
|
-
const rightStyle = isDiffCursorLine
|
|
615
|
-
? { ...(diffItem?.style || {}), bg: STYLE_SELECTED_BG, extendToLineEnd: true }
|
|
616
|
-
: diffItem?.style;
|
|
617
|
-
entries.push({
|
|
618
|
-
text: rightTruncated,
|
|
619
|
-
properties: { type: diffItem?.type || "blank" },
|
|
620
|
-
style: rightStyle,
|
|
621
|
-
inlineOverlays: diffItem?.inlineOverlays,
|
|
622
|
-
});
|
|
623
867
|
|
|
624
|
-
|
|
625
|
-
|
|
868
|
+
pushEntry({
|
|
869
|
+
text: (line.text || "") + "\n",
|
|
870
|
+
style: line.style,
|
|
871
|
+
inlineOverlays: line.inlineOverlays,
|
|
872
|
+
properties: props,
|
|
873
|
+
});
|
|
626
874
|
}
|
|
627
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;
|
|
628
882
|
return entries;
|
|
629
883
|
}
|
|
630
884
|
|
|
631
885
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
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.
|
|
634
889
|
*/
|
|
635
890
|
function updateMagitDisplay(): void {
|
|
636
|
-
if (state.reviewBufferId === null) return;
|
|
637
891
|
refreshViewportDimensions();
|
|
638
|
-
|
|
639
|
-
editor.
|
|
640
|
-
editor.
|
|
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,
|
|
932
|
+
});
|
|
641
933
|
}
|
|
642
934
|
|
|
643
935
|
function review_refresh() { refreshMagitData(); }
|
|
644
936
|
registerHandler("review_refresh", review_refresh);
|
|
645
937
|
|
|
646
|
-
// ---
|
|
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
|
+
}
|
|
647
970
|
|
|
648
971
|
function review_nav_up() {
|
|
649
|
-
if (
|
|
650
|
-
|
|
651
|
-
if (state.selectedIndex > 0) {
|
|
652
|
-
state.selectedIndex--;
|
|
653
|
-
state.diffScrollOffset = 0;
|
|
654
|
-
updateMagitDisplay();
|
|
655
|
-
}
|
|
972
|
+
if (isFilesFocused()) {
|
|
973
|
+
selectFile(state.selectedIndex - 1);
|
|
656
974
|
} else {
|
|
657
|
-
|
|
658
|
-
updateMagitDisplay();
|
|
975
|
+
editor.executeAction("move_up");
|
|
659
976
|
}
|
|
660
977
|
}
|
|
661
978
|
registerHandler("review_nav_up", review_nav_up);
|
|
662
979
|
|
|
663
980
|
function review_nav_down() {
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
if (state.selectedIndex < state.files.length - 1) {
|
|
667
|
-
state.selectedIndex++;
|
|
668
|
-
state.diffScrollOffset = 0;
|
|
669
|
-
updateMagitDisplay();
|
|
670
|
-
}
|
|
981
|
+
if (isFilesFocused()) {
|
|
982
|
+
selectFile(state.selectedIndex + 1);
|
|
671
983
|
} else {
|
|
672
|
-
|
|
673
|
-
updateMagitDisplay();
|
|
984
|
+
editor.executeAction("move_down");
|
|
674
985
|
}
|
|
675
986
|
}
|
|
676
987
|
registerHandler("review_nav_down", review_nav_down);
|
|
677
988
|
|
|
678
989
|
function review_page_up() {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
state.selectedIndex = Math.max(0, state.selectedIndex - mainRows);
|
|
683
|
-
state.diffScrollOffset = 0;
|
|
684
|
-
updateMagitDisplay();
|
|
685
|
-
}
|
|
990
|
+
if (isFilesFocused()) {
|
|
991
|
+
const step = Math.max(1, state.viewportHeight - 2);
|
|
992
|
+
selectFile(Math.max(0, state.selectedIndex - step));
|
|
686
993
|
} else {
|
|
687
|
-
|
|
688
|
-
updateMagitDisplay();
|
|
994
|
+
editor.executeAction("move_page_up");
|
|
689
995
|
}
|
|
690
996
|
}
|
|
691
997
|
registerHandler("review_page_up", review_page_up);
|
|
692
998
|
|
|
693
999
|
function review_page_down() {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
state.selectedIndex = Math.min(state.files.length - 1, state.selectedIndex + mainRows);
|
|
698
|
-
state.diffScrollOffset = 0;
|
|
699
|
-
updateMagitDisplay();
|
|
700
|
-
}
|
|
1000
|
+
if (isFilesFocused()) {
|
|
1001
|
+
const step = Math.max(1, state.viewportHeight - 2);
|
|
1002
|
+
selectFile(Math.min(state.files.length - 1, state.selectedIndex + step));
|
|
701
1003
|
} else {
|
|
702
|
-
|
|
703
|
-
updateMagitDisplay();
|
|
1004
|
+
editor.executeAction("move_page_down");
|
|
704
1005
|
}
|
|
705
1006
|
}
|
|
706
1007
|
registerHandler("review_page_down", review_page_down);
|
|
707
1008
|
|
|
708
|
-
function review_toggle_focus() {
|
|
709
|
-
state.focusPanel = state.focusPanel === 'files' ? 'diff' : 'files';
|
|
710
|
-
updateMagitDisplay();
|
|
711
|
-
}
|
|
712
|
-
registerHandler("review_toggle_focus", review_toggle_focus);
|
|
713
|
-
|
|
714
|
-
function review_focus_files() {
|
|
715
|
-
if (state.focusPanel !== 'files') {
|
|
716
|
-
state.focusPanel = 'files';
|
|
717
|
-
updateMagitDisplay();
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
registerHandler("review_focus_files", review_focus_files);
|
|
721
|
-
|
|
722
|
-
function review_focus_diff() {
|
|
723
|
-
if (state.focusPanel !== 'diff') {
|
|
724
|
-
state.focusPanel = 'diff';
|
|
725
|
-
updateMagitDisplay();
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
registerHandler("review_focus_diff", review_focus_diff);
|
|
729
|
-
|
|
730
1009
|
function review_nav_home() {
|
|
731
|
-
if (
|
|
732
|
-
|
|
733
|
-
state.selectedIndex = 0;
|
|
734
|
-
state.diffScrollOffset = 0;
|
|
735
|
-
updateMagitDisplay();
|
|
1010
|
+
if (isFilesFocused()) {
|
|
1011
|
+
selectFile(0);
|
|
736
1012
|
} else {
|
|
737
|
-
|
|
738
|
-
updateMagitDisplay();
|
|
1013
|
+
editor.executeAction("move_document_start");
|
|
739
1014
|
}
|
|
740
1015
|
}
|
|
741
1016
|
registerHandler("review_nav_home", review_nav_home);
|
|
742
1017
|
|
|
743
1018
|
function review_nav_end() {
|
|
744
|
-
if (
|
|
745
|
-
|
|
746
|
-
state.selectedIndex = state.files.length - 1;
|
|
747
|
-
state.diffScrollOffset = 0;
|
|
748
|
-
updateMagitDisplay();
|
|
1019
|
+
if (isFilesFocused()) {
|
|
1020
|
+
selectFile(state.files.length - 1);
|
|
749
1021
|
} else {
|
|
750
|
-
|
|
751
|
-
const mainRows = state.viewportHeight - 2;
|
|
752
|
-
const selectedFile = state.files[state.selectedIndex];
|
|
753
|
-
if (selectedFile) {
|
|
754
|
-
const diffLines = buildDiffLines(state.viewportWidth - Math.max(28, Math.floor(state.viewportWidth * 0.3)) - 1);
|
|
755
|
-
state.diffScrollOffset = Math.max(0, diffLines.length - mainRows);
|
|
756
|
-
}
|
|
757
|
-
updateMagitDisplay();
|
|
1022
|
+
editor.executeAction("move_document_end");
|
|
758
1023
|
}
|
|
759
1024
|
}
|
|
760
1025
|
registerHandler("review_nav_end", review_nav_end);
|
|
761
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
|
+
|
|
762
1037
|
// --- Real git stage/unstage/discard actions (Step 4) ---
|
|
763
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');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Write a patch to a temp file and apply it with the given flags.
|
|
1058
|
+
* Returns true on success.
|
|
1059
|
+
*/
|
|
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;
|
|
1069
|
+
}
|
|
1070
|
+
const result = await editor.spawnProcess("git", ["apply", ...flags, patchPath]);
|
|
1071
|
+
return result.exit_code === 0;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
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
|
+
}
|
|
1088
|
+
|
|
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;
|
|
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;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
764
1112
|
async function review_stage_file() {
|
|
765
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
|
+
}
|
|
766
1129
|
const f = state.files[state.selectedIndex];
|
|
767
1130
|
if (!f) return;
|
|
768
1131
|
await editor.spawnProcess("git", ["add", "--", f.path]);
|
|
@@ -772,6 +1135,20 @@ registerHandler("review_stage_file", review_stage_file);
|
|
|
772
1135
|
|
|
773
1136
|
async function review_unstage_file() {
|
|
774
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
|
+
}
|
|
775
1152
|
const f = state.files[state.selectedIndex];
|
|
776
1153
|
if (!f) return;
|
|
777
1154
|
await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
|
|
@@ -781,6 +1158,22 @@ registerHandler("review_unstage_file", review_unstage_file);
|
|
|
781
1158
|
|
|
782
1159
|
function review_discard_file() {
|
|
783
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;
|
|
1176
|
+
}
|
|
784
1177
|
const f = state.files[state.selectedIndex];
|
|
785
1178
|
if (!f) return;
|
|
786
1179
|
|
|
@@ -795,6 +1188,26 @@ function review_discard_file() {
|
|
|
795
1188
|
}
|
|
796
1189
|
registerHandler("review_discard_file", review_discard_file);
|
|
797
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
|
+
|
|
798
1211
|
async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
|
|
799
1212
|
if (args.prompt_type !== "review-discard-confirm") return true;
|
|
800
1213
|
|
|
@@ -828,7 +1241,8 @@ async function refreshMagitData() {
|
|
|
828
1241
|
if (state.selectedIndex >= state.files.length) {
|
|
829
1242
|
state.selectedIndex = Math.max(0, state.files.length - 1);
|
|
830
1243
|
}
|
|
831
|
-
state.
|
|
1244
|
+
state.diffCursorRow = 1;
|
|
1245
|
+
state.diffCache = {}; // git state may have changed — invalidate cached diffs
|
|
832
1246
|
updateMagitDisplay();
|
|
833
1247
|
}
|
|
834
1248
|
|
|
@@ -853,6 +1267,8 @@ function refreshViewportDimensions(): boolean {
|
|
|
853
1267
|
function onReviewDiffResize(_data: { width: number; height: number }): void {
|
|
854
1268
|
if (state.reviewBufferId === null) return;
|
|
855
1269
|
refreshViewportDimensions();
|
|
1270
|
+
// Invalidate cached diff entries — they were built for the old viewport width
|
|
1271
|
+
state.diffCache = {};
|
|
856
1272
|
updateMagitDisplay();
|
|
857
1273
|
}
|
|
858
1274
|
registerHandler("onReviewDiffResize", onReviewDiffResize);
|
|
@@ -1278,10 +1694,17 @@ async function review_drill_down() {
|
|
|
1278
1694
|
}
|
|
1279
1695
|
|
|
1280
1696
|
// Read new file content (use absolute path for readFile)
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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;
|
|
1285
1708
|
}
|
|
1286
1709
|
|
|
1287
1710
|
// Close any existing side-by-side views (old split-based approach)
|
|
@@ -1415,15 +1838,68 @@ registerHandler("review_drill_down", review_drill_down);
|
|
|
1415
1838
|
|
|
1416
1839
|
// --- Hunk navigation for side-by-side diff view ---
|
|
1417
1840
|
|
|
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
|
+
|
|
1418
1871
|
function review_next_hunk() {
|
|
1419
|
-
|
|
1420
|
-
|
|
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.
|
|
1421
1885
|
}
|
|
1422
1886
|
registerHandler("review_next_hunk", review_next_hunk);
|
|
1423
1887
|
|
|
1424
1888
|
function review_prev_hunk() {
|
|
1425
|
-
|
|
1426
|
-
|
|
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;
|
|
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.
|
|
1427
1903
|
}
|
|
1428
1904
|
registerHandler("review_prev_hunk", review_prev_hunk);
|
|
1429
1905
|
|
|
@@ -1442,8 +1918,12 @@ editor.defineMode("diff-view", [
|
|
|
1442
1918
|
// --- Review Comment Actions ---
|
|
1443
1919
|
|
|
1444
1920
|
function getCurrentHunkId(): string | null {
|
|
1445
|
-
// In magit mode, get the first hunk of the selected file
|
|
1446
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
|
|
1447
1927
|
const selectedFile = state.files[state.selectedIndex];
|
|
1448
1928
|
if (!selectedFile) return null;
|
|
1449
1929
|
const hunk = state.hunks.find(
|
|
@@ -1452,6 +1932,8 @@ function getCurrentHunkId(): string | null {
|
|
|
1452
1932
|
return hunk?.id || null;
|
|
1453
1933
|
}
|
|
1454
1934
|
|
|
1935
|
+
|
|
1936
|
+
|
|
1455
1937
|
interface PendingCommentInfo {
|
|
1456
1938
|
hunkId: string;
|
|
1457
1939
|
file: string;
|
|
@@ -1462,26 +1944,62 @@ interface PendingCommentInfo {
|
|
|
1462
1944
|
}
|
|
1463
1945
|
|
|
1464
1946
|
function getCurrentLineInfo(): PendingCommentInfo | null {
|
|
1465
|
-
// In magit mode, get info from the selected file's first hunk
|
|
1466
1947
|
if (state.files.length === 0) return null;
|
|
1467
1948
|
const selectedFile = state.files[state.selectedIndex];
|
|
1468
1949
|
if (!selectedFile) return null;
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
if (
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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 };
|
|
1960
|
+
}
|
|
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 };
|
|
1481
1968
|
}
|
|
1482
1969
|
|
|
1483
1970
|
// Pending prompt state for event-based prompt handling
|
|
1484
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
|
+
}
|
|
1485
2003
|
|
|
1486
2004
|
async function review_add_comment() {
|
|
1487
2005
|
const info = getCurrentLineInfo();
|
|
@@ -1489,9 +2007,13 @@ async function review_add_comment() {
|
|
|
1489
2007
|
editor.setStatus(editor.t("status.no_hunk_selected"));
|
|
1490
2008
|
return;
|
|
1491
2009
|
}
|
|
2010
|
+
|
|
2011
|
+
// Check for existing comment to edit
|
|
2012
|
+
const existing = findCommentAtCursor();
|
|
2013
|
+
|
|
1492
2014
|
pendingCommentInfo = info;
|
|
2015
|
+
editingCommentId = existing?.id || null;
|
|
1493
2016
|
|
|
1494
|
-
// Show line context in prompt (if on a specific line)
|
|
1495
2017
|
let lineRef = 'hunk';
|
|
1496
2018
|
if (info.lineType === 'add' && info.newLine) {
|
|
1497
2019
|
lineRef = `+${info.newLine}`;
|
|
@@ -1502,15 +2024,107 @@ async function review_add_comment() {
|
|
|
1502
2024
|
} else if (info.oldLine) {
|
|
1503
2025
|
lineRef = `L${info.oldLine}`;
|
|
1504
2026
|
}
|
|
1505
|
-
|
|
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
|
+
}
|
|
1506
2037
|
}
|
|
1507
2038
|
registerHandler("review_add_comment", review_add_comment);
|
|
1508
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
|
+
|
|
1509
2102
|
// Prompt event handlers
|
|
1510
2103
|
function on_review_prompt_confirm(args: { prompt_type: string; input: string }): boolean {
|
|
1511
2104
|
if (args.prompt_type !== "review-comment") {
|
|
1512
|
-
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;
|
|
1513
2125
|
}
|
|
2126
|
+
|
|
2127
|
+
// New comment mode
|
|
1514
2128
|
if (pendingCommentInfo && args.input && args.input.trim()) {
|
|
1515
2129
|
const comment: ReviewComment = {
|
|
1516
2130
|
id: `comment-${Date.now()}`,
|
|
@@ -1524,6 +2138,7 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
|
|
|
1524
2138
|
line_type: pendingCommentInfo.lineType
|
|
1525
2139
|
};
|
|
1526
2140
|
state.comments.push(comment);
|
|
2141
|
+
state.diffCache = {}; // comment changed — invalidate cached diff entries
|
|
1527
2142
|
updateMagitDisplay();
|
|
1528
2143
|
let lineRef = 'hunk';
|
|
1529
2144
|
if (comment.line_type === 'add' && comment.new_line) {
|
|
@@ -1545,6 +2160,7 @@ registerHandler("on_review_prompt_confirm", on_review_prompt_confirm);
|
|
|
1545
2160
|
function on_review_prompt_cancel(args: { prompt_type: string }): boolean {
|
|
1546
2161
|
if (args.prompt_type === "review-comment") {
|
|
1547
2162
|
pendingCommentInfo = null;
|
|
2163
|
+
editingCommentId = null;
|
|
1548
2164
|
editor.setStatus(editor.t("status.comment_cancelled"));
|
|
1549
2165
|
}
|
|
1550
2166
|
return true;
|
|
@@ -1554,145 +2170,91 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
|
|
|
1554
2170
|
// Register prompt event handlers
|
|
1555
2171
|
editor.on("prompt_confirmed", "on_review_prompt_confirm");
|
|
1556
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");
|
|
1557
2176
|
editor.on("prompt_cancelled", "on_review_prompt_cancel");
|
|
1558
2177
|
|
|
1559
|
-
async function
|
|
1560
|
-
const
|
|
1561
|
-
if (
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
updateMagitDisplay();
|
|
1566
|
-
editor.setStatus(editor.t("status.hunk_approved"));
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
registerHandler("review_approve_hunk", review_approve_hunk);
|
|
1570
|
-
|
|
1571
|
-
async function review_reject_hunk() {
|
|
1572
|
-
const hunkId = getCurrentHunkId();
|
|
1573
|
-
if (!hunkId) return;
|
|
1574
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1575
|
-
if (h) {
|
|
1576
|
-
h.reviewStatus = 'rejected';
|
|
1577
|
-
updateMagitDisplay();
|
|
1578
|
-
editor.setStatus(editor.t("status.hunk_rejected"));
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
registerHandler("review_reject_hunk", review_reject_hunk);
|
|
1582
|
-
|
|
1583
|
-
async function review_needs_changes() {
|
|
1584
|
-
const hunkId = getCurrentHunkId();
|
|
1585
|
-
if (!hunkId) return;
|
|
1586
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1587
|
-
if (h) {
|
|
1588
|
-
h.reviewStatus = 'needs_changes';
|
|
1589
|
-
updateMagitDisplay();
|
|
1590
|
-
editor.setStatus(editor.t("status.hunk_needs_changes"));
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
registerHandler("review_needs_changes", review_needs_changes);
|
|
1594
|
-
|
|
1595
|
-
async function review_question_hunk() {
|
|
1596
|
-
const hunkId = getCurrentHunkId();
|
|
1597
|
-
if (!hunkId) return;
|
|
1598
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1599
|
-
if (h) {
|
|
1600
|
-
h.reviewStatus = 'question';
|
|
1601
|
-
updateMagitDisplay();
|
|
1602
|
-
editor.setStatus(editor.t("status.hunk_question"));
|
|
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");
|
|
1603
2184
|
}
|
|
1604
2185
|
}
|
|
1605
|
-
registerHandler("
|
|
2186
|
+
registerHandler("review_edit_note", review_edit_note);
|
|
1606
2187
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
if (
|
|
1610
|
-
|
|
1611
|
-
if (h) {
|
|
1612
|
-
h.reviewStatus = 'pending';
|
|
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();
|
|
1613
2192
|
updateMagitDisplay();
|
|
1614
|
-
editor.setStatus(
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
const text = await editor.prompt(editor.t("prompt.overall_feedback"), state.overallFeedback || "");
|
|
1621
|
-
if (text !== null) {
|
|
1622
|
-
state.overallFeedback = text.trim();
|
|
1623
|
-
editor.setStatus(text.trim() ? editor.t("status.feedback_set") : editor.t("status.feedback_cleared"));
|
|
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
|
+
}
|
|
1624
2199
|
}
|
|
2200
|
+
return true;
|
|
1625
2201
|
}
|
|
1626
|
-
registerHandler("
|
|
2202
|
+
registerHandler("on_review_edit_note_confirm", on_review_edit_note_confirm);
|
|
1627
2203
|
|
|
1628
2204
|
async function review_export_session() {
|
|
1629
2205
|
const cwd = editor.getCwd();
|
|
1630
2206
|
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
1631
2207
|
|
|
1632
|
-
// Generate markdown content (writeFile creates parent directories)
|
|
1633
2208
|
let md = `# Code Review Session\n`;
|
|
1634
2209
|
md += `Date: ${new Date().toISOString()}\n\n`;
|
|
1635
2210
|
|
|
1636
|
-
if (state.
|
|
1637
|
-
md += `##
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
if (state.overallFeedback) {
|
|
1641
|
-
md += `## Overall Feedback\n${state.overallFeedback}\n\n`;
|
|
2211
|
+
if (state.note) {
|
|
2212
|
+
md += `## Note\n${state.note}\n\n`;
|
|
1642
2213
|
}
|
|
1643
2214
|
|
|
1644
|
-
//
|
|
1645
|
-
const
|
|
1646
|
-
const rejected = state.hunks.filter(h => h.reviewStatus === 'rejected').length;
|
|
1647
|
-
const needsChanges = state.hunks.filter(h => h.reviewStatus === 'needs_changes').length;
|
|
1648
|
-
const questions = state.hunks.filter(h => h.reviewStatus === 'question').length;
|
|
2215
|
+
// Summary
|
|
2216
|
+
const filesWithComments = new Set(state.comments.map(c => c.file)).size;
|
|
1649
2217
|
md += `## Summary\n`;
|
|
1650
|
-
md += `-
|
|
1651
|
-
md += `-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
if (
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
md += `> 💬 ${lineRef} ${c.text}\n`;
|
|
1686
|
-
if (c.line_content) {
|
|
1687
|
-
md += `> \`${c.line_content.trim()}\`\n`;
|
|
1688
|
-
}
|
|
1689
|
-
md += `\n`;
|
|
1690
|
-
}
|
|
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);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
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`;
|
|
1691
2253
|
}
|
|
1692
2254
|
}
|
|
2255
|
+
md += `\n`;
|
|
1693
2256
|
}
|
|
1694
2257
|
|
|
1695
|
-
// Write file
|
|
1696
2258
|
const filePath = editor.pathJoin(reviewDir, "session.md");
|
|
1697
2259
|
await editor.writeFile(filePath, md);
|
|
1698
2260
|
editor.setStatus(editor.t("status.exported", { path: filePath }));
|
|
@@ -1702,34 +2264,21 @@ registerHandler("review_export_session", review_export_session);
|
|
|
1702
2264
|
async function review_export_json() {
|
|
1703
2265
|
const cwd = editor.getCwd();
|
|
1704
2266
|
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
1705
|
-
// writeFile creates parent directories
|
|
1706
2267
|
|
|
1707
2268
|
const session = {
|
|
1708
|
-
version: "
|
|
2269
|
+
version: "2.0",
|
|
1709
2270
|
timestamp: new Date().toISOString(),
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
+
}))
|
|
1713
2280
|
};
|
|
1714
2281
|
|
|
1715
|
-
for (const hunk of state.hunks) {
|
|
1716
|
-
if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
|
|
1717
|
-
const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
|
|
1718
|
-
session.files[hunk.file].hunks.push({
|
|
1719
|
-
context: hunk.contextHeader,
|
|
1720
|
-
old_lines: [hunk.oldRange.start, hunk.oldRange.end],
|
|
1721
|
-
new_lines: [hunk.range.start, hunk.range.end],
|
|
1722
|
-
status: hunk.reviewStatus,
|
|
1723
|
-
comments: hunkComments.map(c => ({
|
|
1724
|
-
text: c.text,
|
|
1725
|
-
line_type: c.line_type || null,
|
|
1726
|
-
old_line: c.old_line || null,
|
|
1727
|
-
new_line: c.new_line || null,
|
|
1728
|
-
line_content: c.line_content || null
|
|
1729
|
-
}))
|
|
1730
|
-
});
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
2282
|
const filePath = editor.pathJoin(reviewDir, "session.json");
|
|
1734
2283
|
await editor.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
1735
2284
|
editor.setStatus(editor.t("status.exported", { path: filePath }));
|
|
@@ -1751,19 +2300,50 @@ async function start_review_diff() {
|
|
|
1751
2300
|
state.files = await getGitStatus();
|
|
1752
2301
|
state.hunks = await fetchDiffsForFiles(state.files);
|
|
1753
2302
|
state.comments = [];
|
|
2303
|
+
state.note = '';
|
|
1754
2304
|
state.selectedIndex = 0;
|
|
1755
|
-
state.
|
|
1756
|
-
state.
|
|
2305
|
+
state.diffCursorRow = 1;
|
|
2306
|
+
state.hunkHeaderRows = [];
|
|
2307
|
+
state.diffLineByteOffsets = [];
|
|
1757
2308
|
state.focusPanel = 'files';
|
|
1758
2309
|
|
|
1759
|
-
//
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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
|
+
},
|
|
1765
2324
|
});
|
|
1766
|
-
|
|
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");
|
|
1767
2347
|
|
|
1768
2348
|
// Register resize handler
|
|
1769
2349
|
editor.on("resize", "onReviewDiffResize");
|
|
@@ -1771,25 +2351,71 @@ async function start_review_diff() {
|
|
|
1771
2351
|
editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
|
|
1772
2352
|
editor.on("buffer_activated", "on_review_buffer_activated");
|
|
1773
2353
|
editor.on("buffer_closed", "on_review_buffer_closed");
|
|
2354
|
+
editor.on("cursor_moved", "on_review_cursor_moved");
|
|
1774
2355
|
}
|
|
1775
2356
|
registerHandler("start_review_diff", start_review_diff);
|
|
1776
2357
|
|
|
1777
2358
|
function stop_review_diff() {
|
|
2359
|
+
if (state.groupId !== null) {
|
|
2360
|
+
editor.closeBufferGroup(state.groupId);
|
|
2361
|
+
state.groupId = null;
|
|
2362
|
+
state.panelBuffers = {};
|
|
2363
|
+
}
|
|
1778
2364
|
state.reviewBufferId = null;
|
|
1779
2365
|
editor.setContext("review-mode", false);
|
|
1780
2366
|
editor.off("resize", "onReviewDiffResize");
|
|
1781
2367
|
editor.off("buffer_activated", "on_review_buffer_activated");
|
|
1782
2368
|
editor.off("buffer_closed", "on_review_buffer_closed");
|
|
2369
|
+
editor.off("cursor_moved", "on_review_cursor_moved");
|
|
1783
2370
|
editor.setStatus(editor.t("status.stopped"));
|
|
1784
2371
|
}
|
|
1785
2372
|
registerHandler("stop_review_diff", stop_review_diff);
|
|
1786
2373
|
|
|
1787
2374
|
|
|
1788
|
-
|
|
1789
|
-
|
|
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());
|
|
1790
2395
|
}
|
|
1791
2396
|
registerHandler("on_review_buffer_activated", on_review_buffer_activated);
|
|
1792
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
|
+
|
|
1793
2419
|
function on_review_buffer_closed(data: any) {
|
|
1794
2420
|
if (data.buffer_id === state.reviewBufferId) stop_review_diff();
|
|
1795
2421
|
}
|
|
@@ -1879,7 +2505,6 @@ async function side_by_side_diff_current_file() {
|
|
|
1879
2505
|
type: isUntracked ? 'add' : 'modify',
|
|
1880
2506
|
lines: [],
|
|
1881
2507
|
status: 'pending',
|
|
1882
|
-
reviewStatus: 'pending',
|
|
1883
2508
|
contextHeader: match[5]?.trim() || "",
|
|
1884
2509
|
byteOffset: 0
|
|
1885
2510
|
};
|
|
@@ -2046,12 +2671,7 @@ editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc",
|
|
|
2046
2671
|
|
|
2047
2672
|
// Review Comment Commands
|
|
2048
2673
|
editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
|
|
2049
|
-
editor.registerCommand("%cmd.
|
|
2050
|
-
editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
|
|
2051
|
-
editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
|
|
2052
|
-
editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
|
|
2053
|
-
editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
|
|
2054
|
-
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");
|
|
2055
2675
|
editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
|
|
2056
2676
|
editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
|
|
2057
2677
|
|
|
@@ -2089,23 +2709,30 @@ registerHandler("on_buffer_closed", on_buffer_closed);
|
|
|
2089
2709
|
editor.on("buffer_closed", "on_buffer_closed");
|
|
2090
2710
|
|
|
2091
2711
|
editor.defineMode("review-mode", [
|
|
2092
|
-
//
|
|
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.
|
|
2093
2716
|
["Up", "review_nav_up"], ["Down", "review_nav_down"],
|
|
2094
2717
|
["k", "review_nav_up"], ["j", "review_nav_down"],
|
|
2095
2718
|
["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
|
|
2096
2719
|
["Home", "review_nav_home"], ["End", "review_nav_end"],
|
|
2720
|
+
// Focus toggle between panels
|
|
2097
2721
|
["Tab", "review_toggle_focus"],
|
|
2098
|
-
|
|
2722
|
+
// Hunk navigation (diff panel) — jumps the native cursor between hunks.
|
|
2723
|
+
["n", "review_next_hunk"], ["p", "review_prev_hunk"],
|
|
2099
2724
|
// Drill-down
|
|
2100
2725
|
["Enter", "review_drill_down"],
|
|
2101
|
-
// Git actions (
|
|
2726
|
+
// Git actions (context-sensitive: file-level or hunk-level based on focus)
|
|
2102
2727
|
["s", "review_stage_file"], ["u", "review_unstage_file"],
|
|
2103
2728
|
["d", "review_discard_file"],
|
|
2104
2729
|
["r", "review_refresh"],
|
|
2105
|
-
//
|
|
2106
|
-
["a", "review_approve_hunk"],
|
|
2730
|
+
// Comments
|
|
2107
2731
|
["c", "review_add_comment"],
|
|
2108
|
-
|
|
2732
|
+
["N", "review_edit_note"],
|
|
2733
|
+
["x", "review_delete_comment"],
|
|
2734
|
+
// Close & export
|
|
2735
|
+
["q", "close"],
|
|
2109
2736
|
["e", "review_export_session"],
|
|
2110
2737
|
], true);
|
|
2111
2738
|
|