@fresh-editor/fresh-editor 0.2.23 → 0.2.25
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 +76 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +497 -119
- package/plugins/audit_mode.ts +2568 -551
- package/plugins/config-schema.json +7 -1
- package/plugins/git_blame.ts +1 -6
- package/plugins/git_log.ts +616 -1025
- package/plugins/lib/fresh.d.ts +76 -4
- package/plugins/lib/git_history.ts +596 -0
- package/plugins/markdown_compose.ts +183 -7
- package/plugins/search_replace.i18n.json +42 -14
- package/plugins/search_replace.ts +146 -96
- package/plugins/vi_mode.ts +8 -3
package/plugins/audit_mode.ts
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
const editor = getEditor();
|
|
10
10
|
|
|
11
11
|
import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
|
|
12
|
+
import {
|
|
13
|
+
type GitCommit,
|
|
14
|
+
buildCommitDetailEntries,
|
|
15
|
+
buildCommitLogEntries,
|
|
16
|
+
buildDetailPlaceholderEntries,
|
|
17
|
+
fetchCommitShow,
|
|
18
|
+
fetchGitLog,
|
|
19
|
+
} from "./lib/git_history.ts";
|
|
12
20
|
const VirtualBufferFactory = createVirtualBufferFactory(editor);
|
|
13
21
|
|
|
14
22
|
|
|
@@ -69,37 +77,121 @@ interface FileEntry {
|
|
|
69
77
|
* know between events (selected file, focused panel, hunk header rows for
|
|
70
78
|
* `n`/`p` jumps).
|
|
71
79
|
*/
|
|
80
|
+
/**
|
|
81
|
+
* Why the file list is empty. `null` means `state.files` has entries; the
|
|
82
|
+
* other two distinguish "cwd is not a git repo" from "repo is clean" so the
|
|
83
|
+
* panels can show a specific message instead of rendering byte-identically.
|
|
84
|
+
*/
|
|
85
|
+
type EmptyStateReason = 'not_git' | 'clean' | null;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which slice of history the current review-diff session is inspecting.
|
|
89
|
+
*
|
|
90
|
+
* - `'worktree'`: the default mode — what `git status` reports right now.
|
|
91
|
+
* No single SHA fingerprints this mode (the working tree is volatile), so
|
|
92
|
+
* comments are keyed only by repo root and restored on a best-effort basis
|
|
93
|
+
* using `file`/`old_line`/`new_line`/`line_content`.
|
|
94
|
+
* - `'range'`: reviewing a static slice (single commit or `A..B` range).
|
|
95
|
+
* The diff is stable, so comments restore 1:1.
|
|
96
|
+
*/
|
|
97
|
+
type ReviewMode = 'worktree' | 'range';
|
|
98
|
+
|
|
99
|
+
interface ReviewRange {
|
|
100
|
+
/** `git diff <from>` left-hand-side. */
|
|
101
|
+
from: string;
|
|
102
|
+
/** `git diff ... <to>` right-hand-side. */
|
|
103
|
+
to: string;
|
|
104
|
+
/** Human-readable label for status bar / layout name. */
|
|
105
|
+
label: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
interface ReviewState {
|
|
73
109
|
hunks: Hunk[];
|
|
74
110
|
comments: ReviewComment[];
|
|
75
111
|
note: string;
|
|
76
112
|
reviewBufferId: number | null;
|
|
77
|
-
|
|
113
|
+
/** Review slice: working tree vs. static commit / range. */
|
|
114
|
+
mode: ReviewMode;
|
|
115
|
+
/** Populated when `mode === 'range'`. */
|
|
116
|
+
range: ReviewRange | null;
|
|
117
|
+
/** Absolute path to the git repo root — stable key for persistence. */
|
|
118
|
+
repoRoot: string;
|
|
119
|
+
/**
|
|
120
|
+
* Persistence key within the repo's review dir:
|
|
121
|
+
* `worktree` — `mode === 'worktree'`
|
|
122
|
+
* `range-<from>__<to>` — `mode === 'range'`
|
|
123
|
+
* Filename-safe characters only (see `sanitizeKeySegment`).
|
|
124
|
+
*/
|
|
125
|
+
reviewKey: string;
|
|
126
|
+
// Files with changes (used for section grouping + headers in the
|
|
127
|
+
// unified stream). Order matches the order they appear in the diff.
|
|
78
128
|
files: FileEntry[];
|
|
79
|
-
|
|
129
|
+
emptyState: EmptyStateReason;
|
|
80
130
|
viewportWidth: number;
|
|
81
131
|
viewportHeight: number;
|
|
82
|
-
focusPanel: '
|
|
132
|
+
focusPanel: 'diff' | 'comments';
|
|
83
133
|
groupId: number | null;
|
|
84
134
|
panelBuffers: Record<string, number>;
|
|
85
|
-
// Caches populated each time the diff
|
|
86
|
-
// hunk navigation, to translate
|
|
87
|
-
// for `setBufferCursor`, and to draw the cursor-line
|
|
88
|
-
//
|
|
89
|
-
// row `i + 1`, and the final entry is
|
|
90
|
-
//
|
|
91
|
-
hunkHeaderRows: number[]; // 1-indexed row numbers in the
|
|
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[];
|
|
135
|
+
// Caches populated each time the unified diff stream is rebuilt —
|
|
136
|
+
// used by `n`/`p` hunk navigation, to translate row numbers into byte
|
|
137
|
+
// positions for `setBufferCursor`, and to draw the cursor-line
|
|
138
|
+
// highlight overlay. `diffLineByteOffsets` has length `(rowCount + 1)`:
|
|
139
|
+
// index `i` is the byte offset of row `i + 1`, and the final entry is
|
|
140
|
+
// the total buffer length.
|
|
141
|
+
hunkHeaderRows: number[]; // 1-indexed row numbers in the unified buffer
|
|
102
142
|
diffLineByteOffsets: number[];
|
|
143
|
+
diffCursorRow: number; // 1-indexed, last known cursor row in diff buffer
|
|
144
|
+
// Maps file key (`${path}\0${category}`) -> 1-indexed row of the
|
|
145
|
+
// file-header row in the unified stream. Used by mouse/collapse/sticky.
|
|
146
|
+
fileHeaderRows: Record<string, number>;
|
|
147
|
+
// Files that are currently collapsed (`${path}\0${category}` keys).
|
|
148
|
+
// Persists across refreshes within a session; cleared on start_review_diff.
|
|
149
|
+
collapsedFiles: Set<string>;
|
|
150
|
+
// Sections (categories) that are currently collapsed. Same persistence
|
|
151
|
+
// rules as `collapsedFiles`.
|
|
152
|
+
collapsedSections: Set<string>;
|
|
153
|
+
// Hunks that are currently collapsed (`hunk.id` keys). When collapsed,
|
|
154
|
+
// only the hunk header row is emitted; the +/-/context lines are
|
|
155
|
+
// skipped. Same persistence rules as collapsedFiles.
|
|
156
|
+
collapsedHunks: Set<string>;
|
|
157
|
+
// Maps hunk-id -> 1-indexed row of its hunk-header row in the diff
|
|
158
|
+
// stream. Used by mouse + Tab to identify the nearest hunk.
|
|
159
|
+
hunkRowByHunkId: Record<string, number>;
|
|
160
|
+
// Maps comment-id -> 1-indexed row of the *diff line* the comment is
|
|
161
|
+
// attached to (not the comment-display row itself). Lets the comments
|
|
162
|
+
// panel jump the cursor straight to the source line.
|
|
163
|
+
diffLineRowByCommentId: Record<string, number>;
|
|
164
|
+
// Maps 1-indexed row -> the entry's properties. Lets handlers look up
|
|
165
|
+
// type / hunkId / fileKey / etc. by cursor row directly, bypassing
|
|
166
|
+
// editor.getTextPropertiesAtCursor (which can return the previous
|
|
167
|
+
// row's props when the cursor sits at a row-boundary byte).
|
|
168
|
+
entryPropsByRow: Record<number, Record<string, unknown>>;
|
|
169
|
+
// Byte ranges of collapsible bodies, captured at build time. Tab /
|
|
170
|
+
// mouse / z a / z r register these as host folds (see applyFolds)
|
|
171
|
+
// — no buffer rebuild on collapse / expand.
|
|
172
|
+
sectionBodyRange: Record<string, { start: number; end: number }>;
|
|
173
|
+
fileBodyRange: Record<string, { start: number; end: number }>;
|
|
174
|
+
hunkBodyRange: Record<string, { start: number; end: number }>;
|
|
175
|
+
// Maps a category name (`'staged'` etc.) -> 1-indexed row of its
|
|
176
|
+
// section-header row in the unified stream. Used by Tab toggle.
|
|
177
|
+
sectionHeaderRows: Record<string, number>;
|
|
178
|
+
// Maps a 1-indexed row in the comments panel -> comment id
|
|
179
|
+
commentsByRow: Record<number, string>;
|
|
180
|
+
// Current selection in the comments panel (1-indexed row, 0 means none)
|
|
181
|
+
commentsSelectedRow: number;
|
|
182
|
+
// Comment-id the diff cursor is sitting on / attached to. Drives the
|
|
183
|
+
// `>` follow-cursor marker in the comments panel.
|
|
184
|
+
commentsHighlightId: string | null;
|
|
185
|
+
// Sticky header current content (for Step 4)
|
|
186
|
+
stickyCurrentFile: string | null;
|
|
187
|
+
// Last known top-visible row in the diff viewport (1-indexed for
|
|
188
|
+
// consistency with hunkHeaderRows, even though the host event delivers
|
|
189
|
+
// 0-indexed). Updated from viewport_changed and cursor_moved.
|
|
190
|
+
diffViewportTopRow: number;
|
|
191
|
+
// Visual line-selection state. Active iff non-null. start and end are
|
|
192
|
+
// 1-indexed rows in the unified stream; hunkId pins the selection to
|
|
193
|
+
// a single hunk (selections that cross hunks are rejected).
|
|
194
|
+
lineSelection: { startRow: number; endRow: number; hunkId: string } | null;
|
|
103
195
|
}
|
|
104
196
|
|
|
105
197
|
const state: ReviewState = {
|
|
@@ -107,19 +199,42 @@ const state: ReviewState = {
|
|
|
107
199
|
comments: [],
|
|
108
200
|
note: '',
|
|
109
201
|
reviewBufferId: null,
|
|
202
|
+
mode: 'worktree',
|
|
203
|
+
range: null,
|
|
204
|
+
repoRoot: '',
|
|
205
|
+
reviewKey: 'worktree',
|
|
110
206
|
files: [],
|
|
111
|
-
|
|
207
|
+
emptyState: null,
|
|
112
208
|
viewportWidth: 80,
|
|
113
209
|
viewportHeight: 24,
|
|
114
|
-
focusPanel: '
|
|
210
|
+
focusPanel: 'diff',
|
|
115
211
|
groupId: null,
|
|
116
212
|
panelBuffers: {},
|
|
117
213
|
hunkHeaderRows: [],
|
|
118
214
|
diffLineByteOffsets: [],
|
|
119
215
|
diffCursorRow: 1,
|
|
120
|
-
|
|
216
|
+
fileHeaderRows: {},
|
|
217
|
+
collapsedFiles: new Set(),
|
|
218
|
+
collapsedSections: new Set(),
|
|
219
|
+
collapsedHunks: new Set(),
|
|
220
|
+
hunkRowByHunkId: {},
|
|
221
|
+
diffLineRowByCommentId: {},
|
|
222
|
+
entryPropsByRow: {},
|
|
223
|
+
sectionBodyRange: {},
|
|
224
|
+
fileBodyRange: {},
|
|
225
|
+
hunkBodyRange: {},
|
|
226
|
+
sectionHeaderRows: {},
|
|
227
|
+
commentsByRow: {},
|
|
228
|
+
commentsSelectedRow: 0,
|
|
229
|
+
commentsHighlightId: null,
|
|
230
|
+
stickyCurrentFile: null,
|
|
231
|
+
diffViewportTopRow: 0,
|
|
232
|
+
lineSelection: null,
|
|
121
233
|
};
|
|
122
234
|
|
|
235
|
+
function fileKey(f: FileEntry): string { return `${f.path}\0${f.category}`; }
|
|
236
|
+
function fileKeyOf(path: string, category: string): string { return `${path}\0${category}`; }
|
|
237
|
+
|
|
123
238
|
// Theme colour for the synthetic "cursor line" highlight in the panel
|
|
124
239
|
// buffers. Reintroduced after the per-line bg overlay was deleted from the
|
|
125
240
|
// builders — `applyCursorLineOverlay` writes it on every cursor_moved event.
|
|
@@ -140,6 +255,42 @@ const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
|
|
|
140
255
|
|
|
141
256
|
const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
|
|
142
257
|
const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
|
|
258
|
+
// Subtle bg for file/section header rows. Uses `editor.current_line_bg`
|
|
259
|
+
// which is reliably a notch lighter than editor bg in every theme
|
|
260
|
+
// (line_number_bg matches editor bg in Dracula and would render
|
|
261
|
+
// invisibly; status_bar_bg is the toolbar accent and is hot pink in
|
|
262
|
+
// Dracula). selection_bg is reserved for the cursor-line overlay so
|
|
263
|
+
// using it here would blend the two highlights.
|
|
264
|
+
const STYLE_FILE_HEADER_BG: OverlayColorSpec = "editor.current_line_bg";
|
|
265
|
+
const STYLE_HUNK_HEADER_BG: OverlayColorSpec = "editor.current_line_bg";
|
|
266
|
+
// File-header foreground: brightest reliable foreground in any theme.
|
|
267
|
+
// `editor.fg` is white-ish on dark themes and black-ish on light, so it
|
|
268
|
+
// always reads as the most prominent text color. Bolded for extra weight.
|
|
269
|
+
const STYLE_FILE_HEADER_FG: OverlayColorSpec = "editor.fg";
|
|
270
|
+
// "Inverse" pair — swap of editor.bg/fg. Used for full-line-wide section
|
|
271
|
+
// dividers (STAGED / UNSTAGED / UNTRACKED) and the Comments panel
|
|
272
|
+
// header. Reads as an inverted band in every theme: dark text on light
|
|
273
|
+
// bg in dark themes, light text on dark bg in light themes.
|
|
274
|
+
const STYLE_INVERSE_FG: OverlayColorSpec = "editor.bg";
|
|
275
|
+
const STYLE_INVERSE_BG: OverlayColorSpec = "editor.fg";
|
|
276
|
+
// Dim foreground for the per-row old/new line-number gutter. Same key
|
|
277
|
+
// the editor uses for its own gutter — already chosen per-theme to be
|
|
278
|
+
// readable but visibly subordinate to content fg.
|
|
279
|
+
const STYLE_LINE_NUM_FG: OverlayColorSpec = "editor.line_number_fg";
|
|
280
|
+
|
|
281
|
+
// Width of each line-number column. 4 chars fits up to 9999 lines —
|
|
282
|
+
// past that we just let the number overflow rather than expanding the
|
|
283
|
+
// gutter (extremely rare in review-diff context).
|
|
284
|
+
const LINE_NUM_W = 4;
|
|
285
|
+
|
|
286
|
+
/** Format the per-row "OLD NEW " prefix (with trailing space). Either
|
|
287
|
+
* side passes `undefined` for blank — removed lines blank the new
|
|
288
|
+
* column, added lines blank the old column. */
|
|
289
|
+
function lineNumPrefix(oldNum: number | undefined, newNum: number | undefined): string {
|
|
290
|
+
const o = oldNum !== undefined ? String(oldNum).padStart(LINE_NUM_W) : ' '.repeat(LINE_NUM_W);
|
|
291
|
+
const n = newNum !== undefined ? String(newNum).padStart(LINE_NUM_W) : ' '.repeat(LINE_NUM_W);
|
|
292
|
+
return ` ${o} ${n} `;
|
|
293
|
+
}
|
|
143
294
|
|
|
144
295
|
|
|
145
296
|
/**
|
|
@@ -158,6 +309,152 @@ function getByteLength(str: string): number {
|
|
|
158
309
|
return s;
|
|
159
310
|
}
|
|
160
311
|
|
|
312
|
+
// --- Persistence ---
|
|
313
|
+
//
|
|
314
|
+
// Review comments for a given repo are persisted under:
|
|
315
|
+
//
|
|
316
|
+
// <data_dir>/audit/<sanitized-repo-root>/<review-key>.json
|
|
317
|
+
//
|
|
318
|
+
// Where:
|
|
319
|
+
// - `<data_dir>` is the host's `DirectoryContext::data_dir` (exposed via
|
|
320
|
+
// the `getDataDir()` API added for this feature).
|
|
321
|
+
// - `<review-key>` captures the *kind* of review — not every git state is
|
|
322
|
+
// a fingerprint:
|
|
323
|
+
// - `worktree` for `start_review_diff` (working tree review). There
|
|
324
|
+
// is no single fingerprint for the working tree so we just reuse a
|
|
325
|
+
// single slot per repo; line-content + line-number matching on
|
|
326
|
+
// restore prunes comments that no longer apply.
|
|
327
|
+
// - `range-<from>__<to>` for `start_review_range` (commit / branch
|
|
328
|
+
// review). The range is stable, so comments survive re-opening.
|
|
329
|
+
//
|
|
330
|
+
// Design notes / alternatives that were considered:
|
|
331
|
+
// - Keying worktree comments by the index or HEAD SHA: rejected — the
|
|
332
|
+
// working tree is volatile so the key would change constantly and you
|
|
333
|
+
// couldn't get your comments back after a single edit.
|
|
334
|
+
// - Storing under `.review/` in the working tree: rejected — that bakes
|
|
335
|
+
// the reviewer's state into the repo, which leaks into `git status`.
|
|
336
|
+
// - One big JSON with all review keys per repo: rejected — concurrent
|
|
337
|
+
// edits across review windows could clobber each other. Per-key files
|
|
338
|
+
// keep each review's writes independent.
|
|
339
|
+
|
|
340
|
+
interface PersistedReview {
|
|
341
|
+
version: number;
|
|
342
|
+
mode: ReviewMode;
|
|
343
|
+
range: ReviewRange | null;
|
|
344
|
+
note: string;
|
|
345
|
+
comments: ReviewComment[];
|
|
346
|
+
updated_at: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const REVIEW_STORAGE_VERSION = 1;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Make a string safe for use as a filename / directory name on all host
|
|
353
|
+
* OSes. Forbidden characters (`/`, `\`, `:`, etc.) collapse to `_`; long
|
|
354
|
+
* tails hash-truncate so path length stays sane.
|
|
355
|
+
*/
|
|
356
|
+
function sanitizeKeySegment(raw: string): string {
|
|
357
|
+
const replaced = raw.replace(/[^A-Za-z0-9._-]+/g, '_');
|
|
358
|
+
if (replaced.length <= 120) return replaced;
|
|
359
|
+
// Cheap 32-bit FNV-1a so different long segments don't alias after
|
|
360
|
+
// truncation.
|
|
361
|
+
let h = 0x811c9dc5 >>> 0;
|
|
362
|
+
for (let i = 0; i < raw.length; i++) {
|
|
363
|
+
h ^= raw.charCodeAt(i);
|
|
364
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
365
|
+
}
|
|
366
|
+
return replaced.slice(0, 100) + '__' + h.toString(16);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Build the review-key portion of the storage filename (without the
|
|
371
|
+
* `.json` extension) for the current mode / range.
|
|
372
|
+
*/
|
|
373
|
+
function buildReviewKey(mode: ReviewMode, range: ReviewRange | null): string {
|
|
374
|
+
if (mode === 'range' && range) {
|
|
375
|
+
return `range-${sanitizeKeySegment(range.from)}__${sanitizeKeySegment(range.to)}`;
|
|
376
|
+
}
|
|
377
|
+
return 'worktree';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Directory that stores all review files for a given repo. */
|
|
381
|
+
function reviewStorageDirFor(repoRoot: string): string | null {
|
|
382
|
+
try {
|
|
383
|
+
const dataDir = (editor as any).getDataDir?.() as string | undefined;
|
|
384
|
+
if (!dataDir) return null;
|
|
385
|
+
return editor.pathJoin(dataDir, "audit", sanitizeKeySegment(repoRoot));
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Absolute path of the JSON file backing a review key. */
|
|
392
|
+
function reviewStoragePathFor(repoRoot: string, reviewKey: string): string | null {
|
|
393
|
+
const dir = reviewStorageDirFor(repoRoot);
|
|
394
|
+
if (!dir) return null;
|
|
395
|
+
return editor.pathJoin(dir, `${reviewKey}.json`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Resolve the git top-level for `editor.getCwd()`. Returns `''` when the
|
|
400
|
+
* cwd isn't inside a repo — callers then skip persistence.
|
|
401
|
+
*/
|
|
402
|
+
async function detectRepoRoot(): Promise<string> {
|
|
403
|
+
try {
|
|
404
|
+
const result = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
|
|
405
|
+
if (result.exit_code === 0) {
|
|
406
|
+
return result.stdout.trim();
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
// fall through
|
|
410
|
+
}
|
|
411
|
+
return '';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Persist the current `state.comments` / `state.note` to disk. Best-effort:
|
|
416
|
+
* filesystem errors never surface to the user — the UI is the source of
|
|
417
|
+
* truth during the session and writes are just a cache for restore.
|
|
418
|
+
*/
|
|
419
|
+
function persistReview(): void {
|
|
420
|
+
if (!state.repoRoot) return;
|
|
421
|
+
const path = reviewStoragePathFor(state.repoRoot, state.reviewKey);
|
|
422
|
+
if (!path) return;
|
|
423
|
+
const dir = reviewStorageDirFor(state.repoRoot);
|
|
424
|
+
if (dir) {
|
|
425
|
+
try { editor.createDir(dir); } catch {}
|
|
426
|
+
}
|
|
427
|
+
const payload: PersistedReview = {
|
|
428
|
+
version: REVIEW_STORAGE_VERSION,
|
|
429
|
+
mode: state.mode,
|
|
430
|
+
range: state.range,
|
|
431
|
+
note: state.note,
|
|
432
|
+
comments: state.comments,
|
|
433
|
+
updated_at: new Date().toISOString(),
|
|
434
|
+
};
|
|
435
|
+
try {
|
|
436
|
+
editor.writeFile(path, JSON.stringify(payload, null, 2));
|
|
437
|
+
} catch {}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Read back a persisted review (if any). Returns null on any failure. */
|
|
441
|
+
function loadPersistedReview(repoRoot: string, reviewKey: string): PersistedReview | null {
|
|
442
|
+
if (!repoRoot) return null;
|
|
443
|
+
const path = reviewStoragePathFor(repoRoot, reviewKey);
|
|
444
|
+
if (!path) return null;
|
|
445
|
+
if (!editor.fileExists(path)) return null;
|
|
446
|
+
try {
|
|
447
|
+
const raw = editor.readFile(path);
|
|
448
|
+
if (!raw) return null;
|
|
449
|
+
const parsed = JSON.parse(raw) as PersistedReview;
|
|
450
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
451
|
+
if (!Array.isArray(parsed.comments)) return null;
|
|
452
|
+
return parsed;
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
161
458
|
// --- Diff Logic ---
|
|
162
459
|
|
|
163
460
|
interface DiffPart {
|
|
@@ -322,11 +619,28 @@ function parseGitStatusPorcelain(raw: string): FileEntry[] {
|
|
|
322
619
|
|
|
323
620
|
/**
|
|
324
621
|
* Single source of truth for changed files using `git status --porcelain -z`.
|
|
622
|
+
*
|
|
623
|
+
* `emptyReason` distinguishes the two no-content cases so the UI can explain
|
|
624
|
+
* itself instead of rendering a blank pane:
|
|
625
|
+
* - `'not_git'`: `git status` failed (no repo at cwd).
|
|
626
|
+
* - `'clean'`: `git status` succeeded but returned no entries.
|
|
627
|
+
* - `null`: files were found; render them normally.
|
|
325
628
|
*/
|
|
326
|
-
|
|
629
|
+
interface GitStatusResult {
|
|
630
|
+
files: FileEntry[];
|
|
631
|
+
emptyReason: EmptyStateReason;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function getGitStatus(): Promise<GitStatusResult> {
|
|
327
635
|
const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
|
|
328
|
-
if (result.exit_code !== 0)
|
|
329
|
-
|
|
636
|
+
if (result.exit_code !== 0) {
|
|
637
|
+
return { files: [], emptyReason: 'not_git' };
|
|
638
|
+
}
|
|
639
|
+
const files = parseGitStatusPorcelain(result.stdout);
|
|
640
|
+
return {
|
|
641
|
+
files,
|
|
642
|
+
emptyReason: files.length === 0 ? 'clean' : null,
|
|
643
|
+
};
|
|
330
644
|
}
|
|
331
645
|
|
|
332
646
|
/**
|
|
@@ -400,7 +714,10 @@ interface ListLine {
|
|
|
400
714
|
|
|
401
715
|
interface DiffLine {
|
|
402
716
|
text: string;
|
|
403
|
-
type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment';
|
|
717
|
+
type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty' | 'comment' | 'file-header' | 'section-header';
|
|
718
|
+
filePath?: string; // for file-header rows
|
|
719
|
+
fileKey?: string; // for file-header rows
|
|
720
|
+
fileIndex?: number; // for file-header rows
|
|
404
721
|
style?: Partial<OverlayOptions>;
|
|
405
722
|
inlineOverlays?: InlineOverlay[];
|
|
406
723
|
// Line metadata for comment attachment
|
|
@@ -413,67 +730,19 @@ interface DiffLine {
|
|
|
413
730
|
commentId?: string;
|
|
414
731
|
}
|
|
415
732
|
|
|
416
|
-
/**
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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;
|
|
733
|
+
/** Compute +N / -M line counts for a file. */
|
|
734
|
+
function fileChangeCounts(file: FileEntry): { added: number; removed: number } {
|
|
735
|
+
let added = 0;
|
|
736
|
+
let removed = 0;
|
|
737
|
+
for (const h of state.hunks) {
|
|
738
|
+
if (h.file === file.path && h.gitStatus === file.category) {
|
|
739
|
+
for (const line of h.lines) {
|
|
740
|
+
if (line[0] === '+') added++;
|
|
741
|
+
else if (line[0] === '-') removed++;
|
|
469
742
|
}
|
|
470
743
|
}
|
|
471
|
-
if (line) {
|
|
472
|
-
lines.push({ text: ` ${line}`, type: 'section-header', style: { fg: STYLE_COMMENT, italic: true } });
|
|
473
|
-
}
|
|
474
744
|
}
|
|
475
|
-
|
|
476
|
-
return lines;
|
|
745
|
+
return { added, removed };
|
|
477
746
|
}
|
|
478
747
|
|
|
479
748
|
/**
|
|
@@ -491,6 +760,9 @@ function pushLineComments(
|
|
|
491
760
|
(c.line_type === 'context' && c.new_line === newLine)
|
|
492
761
|
)
|
|
493
762
|
);
|
|
763
|
+
// Indent the comment so its `»` glyph aligns with the diff content
|
|
764
|
+
// column (just past the OLD/NEW number gutter and the +/- indicator).
|
|
765
|
+
const commentIndent = ' '.repeat(LINE_NUM_W + 1 + LINE_NUM_W + 1 + 1 + 1);
|
|
494
766
|
for (const comment of lineComments) {
|
|
495
767
|
const lineRef = comment.line_type === 'add'
|
|
496
768
|
? `+${comment.new_line}`
|
|
@@ -498,7 +770,7 @@ function pushLineComments(
|
|
|
498
770
|
? `-${comment.old_line}`
|
|
499
771
|
: `${comment.new_line}`;
|
|
500
772
|
lines.push({
|
|
501
|
-
text:
|
|
773
|
+
text: `${commentIndent}\u00bb [${lineRef}] ${comment.text}`,
|
|
502
774
|
type: 'comment',
|
|
503
775
|
commentId: comment.id,
|
|
504
776
|
style: { fg: STYLE_COMMENT, italic: true },
|
|
@@ -507,62 +779,139 @@ function pushLineComments(
|
|
|
507
779
|
}
|
|
508
780
|
|
|
509
781
|
/**
|
|
510
|
-
* Build the diff lines for the
|
|
782
|
+
* Build the diff lines for the unified stream.
|
|
783
|
+
* Emits one file-header row per file, followed by its hunks inline.
|
|
784
|
+
* When the file is collapsed, only the header is emitted.
|
|
511
785
|
*/
|
|
512
|
-
function buildDiffLines(
|
|
786
|
+
function buildDiffLines(_rightWidth: number): DiffLine[] {
|
|
513
787
|
const lines: DiffLine[] = [];
|
|
514
|
-
if (state.files.length === 0)
|
|
788
|
+
if (state.files.length === 0) {
|
|
789
|
+
if (state.emptyState === 'not_git') {
|
|
790
|
+
lines.push({
|
|
791
|
+
text: editor.t("status.not_git_repo") || "Not a git repository",
|
|
792
|
+
type: 'empty',
|
|
793
|
+
style: { fg: STYLE_SECTION_HEADER, italic: true },
|
|
794
|
+
});
|
|
795
|
+
} else if (state.emptyState === 'clean') {
|
|
796
|
+
lines.push({
|
|
797
|
+
text: editor.t("panel.no_changes") || "No changes to review.",
|
|
798
|
+
type: 'empty',
|
|
799
|
+
style: { fg: STYLE_SECTION_HEADER, italic: true },
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
return lines;
|
|
803
|
+
}
|
|
515
804
|
|
|
516
|
-
|
|
517
|
-
|
|
805
|
+
let lastCategory: string | undefined;
|
|
806
|
+
for (let fi = 0; fi < state.files.length; fi++) {
|
|
807
|
+
const file = state.files[fi];
|
|
808
|
+
|
|
809
|
+
// Section header — full-line-wide INVERSE band, uppercase, bold.
|
|
810
|
+
// The strong inverse coloring (editor.bg as fg / editor.fg as bg)
|
|
811
|
+
// makes the band read as a hard divider between Staged /
|
|
812
|
+
// Unstaged / Untracked sections regardless of theme.
|
|
813
|
+
if (file.category !== lastCategory) {
|
|
814
|
+
lastCategory = file.category;
|
|
815
|
+
let label: string = file.category;
|
|
816
|
+
// Range mode reuses the `unstaged` bucket for every hunk as
|
|
817
|
+
// an impl shortcut — surface the range label so the user
|
|
818
|
+
// isn't told their commit review is "Unstaged".
|
|
819
|
+
if (state.mode === 'range' && state.range) {
|
|
820
|
+
label = state.range.label;
|
|
821
|
+
} else if (file.category === 'staged') label = editor.t("section.staged") || "Staged";
|
|
822
|
+
else if (file.category === 'unstaged') label = editor.t("section.unstaged") || "Unstaged";
|
|
823
|
+
else if (file.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
|
|
824
|
+
const sectionCount = state.files.filter(f => f.category === file.category).length;
|
|
825
|
+
// Always render expanded triangle (▾). Collapse state is
|
|
826
|
+
// shown by overlaying a `▸` replacement-conceal on the
|
|
827
|
+
// triangle byte range — the buffer text never changes, so
|
|
828
|
+
// toggling collapse never has to rebuild.
|
|
829
|
+
// Range labels (e.g. `main..HEAD`) carry case already — don't
|
|
830
|
+
// mangle them with the section uppercase; worktree category
|
|
831
|
+
// names are lowercase words and need the uppercase.
|
|
832
|
+
const displayLabel = state.mode === 'range' ? label : label.toUpperCase();
|
|
833
|
+
lines.push({
|
|
834
|
+
text: ` ▾ ${displayLabel} (${sectionCount})`,
|
|
835
|
+
type: 'section-header',
|
|
836
|
+
file: file.category, // store category in 'file' field for reuse
|
|
837
|
+
filePath: file.category,
|
|
838
|
+
style: {
|
|
839
|
+
fg: STYLE_INVERSE_FG,
|
|
840
|
+
bg: STYLE_INVERSE_BG,
|
|
841
|
+
bold: true,
|
|
842
|
+
extendToLineEnd: true,
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
}
|
|
518
846
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
847
|
+
// File header — always emit the expanded triangle; conceal
|
|
848
|
+
// overlays handle the collapsed view.
|
|
849
|
+
const counts = fileChangeCounts(file);
|
|
850
|
+
const key = fileKey(file);
|
|
851
|
+
const filename = file.origPath ? `${file.origPath} → ${file.path}` : file.path;
|
|
852
|
+
const headerText = ` ▾ ${filename} +${counts.added} / -${counts.removed}`;
|
|
853
|
+
lines.push({
|
|
854
|
+
text: headerText,
|
|
855
|
+
type: 'file-header',
|
|
856
|
+
file: file.path,
|
|
857
|
+
filePath: file.path,
|
|
858
|
+
fileKey: key,
|
|
859
|
+
fileIndex: fi,
|
|
860
|
+
style: {
|
|
861
|
+
fg: STYLE_FILE_HEADER_FG,
|
|
862
|
+
bg: STYLE_FILE_HEADER_BG,
|
|
863
|
+
bold: true,
|
|
864
|
+
extendToLineEnd: true,
|
|
865
|
+
},
|
|
866
|
+
});
|
|
523
867
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
868
|
+
// Find hunks for this file
|
|
869
|
+
const fileHunks = state.hunks.filter(
|
|
870
|
+
h => h.file === file.path && h.gitStatus === file.category
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
if (fileHunks.length === 0) {
|
|
874
|
+
if (file.status === 'R' && file.origPath) {
|
|
875
|
+
lines.push({ text: ` Renamed from ${file.origPath}`, type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
|
|
876
|
+
} else if (file.status === 'D') {
|
|
877
|
+
lines.push({ text: " (file deleted)", type: 'empty' });
|
|
878
|
+
} else if (file.status === 'T') {
|
|
879
|
+
lines.push({ text: " (type change: file ↔ symlink)", type: 'empty', style: { fg: STYLE_SECTION_HEADER } });
|
|
880
|
+
} else if (file.status === '?' && file.path.endsWith('/')) {
|
|
881
|
+
lines.push({ text: " (untracked directory)", type: 'empty' });
|
|
882
|
+
} else {
|
|
883
|
+
lines.push({ text: " (no diff available)", type: 'empty' });
|
|
884
|
+
}
|
|
885
|
+
lines.push({ text: '', type: 'empty' });
|
|
886
|
+
continue;
|
|
535
887
|
}
|
|
536
|
-
return lines;
|
|
537
|
-
}
|
|
538
888
|
|
|
539
|
-
|
|
540
|
-
// Hunk header
|
|
541
|
-
|
|
889
|
+
for (const hunk of fileHunks) {
|
|
890
|
+
// Hunk header — always emit expanded triangle; collapse
|
|
891
|
+
// overlays a `▸` replacement-conceal.
|
|
892
|
+
const headerInner = hunk.contextHeader
|
|
542
893
|
? `@@ ${hunk.contextHeader} @@`
|
|
543
894
|
: `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
|
|
895
|
+
const header = ` ▾ ${headerInner}`;
|
|
544
896
|
|
|
545
897
|
lines.push({
|
|
546
898
|
text: header,
|
|
547
899
|
type: 'hunk-header',
|
|
548
900
|
hunkId: hunk.id,
|
|
549
901
|
file: hunk.file,
|
|
550
|
-
style: {
|
|
902
|
+
style: {
|
|
903
|
+
fg: STYLE_HUNK_HEADER,
|
|
904
|
+
bg: STYLE_HUNK_HEADER_BG,
|
|
905
|
+
bold: true,
|
|
906
|
+
extendToLineEnd: true,
|
|
907
|
+
},
|
|
551
908
|
});
|
|
552
909
|
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
}
|
|
910
|
+
// (Body always emitted — collapse is handled by overlay
|
|
911
|
+
// conceals on the body's byte range.)
|
|
912
|
+
|
|
913
|
+
// (Comments are line-based — they appear under their attached
|
|
914
|
+
// diff line via pushLineComments below, never as hunk-level.)
|
|
566
915
|
|
|
567
916
|
// Track actual file line numbers as we iterate
|
|
568
917
|
let oldLineNum = hunk.oldRange.start;
|
|
@@ -584,9 +933,14 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
584
933
|
const newContent = nextLine.substring(1);
|
|
585
934
|
const parts = diffStrings(oldContent, newContent);
|
|
586
935
|
|
|
587
|
-
//
|
|
588
|
-
const
|
|
589
|
-
|
|
936
|
+
// Removed-side line: " OLD -content"
|
|
937
|
+
const removePrefix = lineNumPrefix(curOldLine, undefined);
|
|
938
|
+
const removeText = removePrefix + line;
|
|
939
|
+
const removePrefixLen = getByteLength(removePrefix);
|
|
940
|
+
const removeOverlays: InlineOverlay[] = [
|
|
941
|
+
{ start: 0, end: removePrefixLen, style: { fg: STYLE_LINE_NUM_FG } },
|
|
942
|
+
];
|
|
943
|
+
let rOffset = removePrefixLen + getByteLength(line[0]); // skip diff prefix
|
|
590
944
|
for (const part of parts) {
|
|
591
945
|
const pLen = getByteLength(part.text);
|
|
592
946
|
if (part.type === 'removed') {
|
|
@@ -595,19 +949,24 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
595
949
|
if (part.type !== 'added') rOffset += pLen;
|
|
596
950
|
}
|
|
597
951
|
lines.push({
|
|
598
|
-
text:
|
|
952
|
+
text: removeText, type: 'remove',
|
|
599
953
|
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
600
954
|
hunkId: hunk.id, file: hunk.file,
|
|
601
955
|
lineType: 'remove', oldLine: curOldLine, newLine: undefined, lineContent: line,
|
|
602
|
-
inlineOverlays: removeOverlays
|
|
956
|
+
inlineOverlays: removeOverlays,
|
|
603
957
|
});
|
|
604
958
|
// Inline comments for the removed line
|
|
605
959
|
pushLineComments(lines, hunk, 'remove', curOldLine, undefined);
|
|
606
960
|
oldLineNum++;
|
|
607
961
|
|
|
608
|
-
//
|
|
609
|
-
const
|
|
610
|
-
|
|
962
|
+
// Added-side line: " NEW +content"
|
|
963
|
+
const addPrefix = lineNumPrefix(undefined, newLineNum);
|
|
964
|
+
const addText = addPrefix + nextLine;
|
|
965
|
+
const addPrefixLen = getByteLength(addPrefix);
|
|
966
|
+
const addOverlays: InlineOverlay[] = [
|
|
967
|
+
{ start: 0, end: addPrefixLen, style: { fg: STYLE_LINE_NUM_FG } },
|
|
968
|
+
];
|
|
969
|
+
let aOffset = addPrefixLen + getByteLength(nextLine[0]);
|
|
611
970
|
for (const part of parts) {
|
|
612
971
|
const pLen = getByteLength(part.text);
|
|
613
972
|
if (part.type === 'added') {
|
|
@@ -616,11 +975,11 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
616
975
|
if (part.type !== 'removed') aOffset += pLen;
|
|
617
976
|
}
|
|
618
977
|
lines.push({
|
|
619
|
-
text:
|
|
978
|
+
text: addText, type: 'add',
|
|
620
979
|
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
621
980
|
hunkId: hunk.id, file: hunk.file,
|
|
622
981
|
lineType: 'add', oldLine: undefined, newLine: newLineNum, lineContent: nextLine,
|
|
623
|
-
inlineOverlays: addOverlays
|
|
982
|
+
inlineOverlays: addOverlays,
|
|
624
983
|
});
|
|
625
984
|
pushLineComments(lines, hunk, 'add', undefined, newLineNum);
|
|
626
985
|
newLineNum++;
|
|
@@ -628,27 +987,37 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
628
987
|
continue;
|
|
629
988
|
}
|
|
630
989
|
|
|
990
|
+
const numPrefix = lineNumPrefix(curOldLine, curNewLine);
|
|
991
|
+
const decoratedText = numPrefix + line;
|
|
992
|
+
const numPrefixLen = getByteLength(numPrefix);
|
|
993
|
+
const dimNumOverlay: InlineOverlay = {
|
|
994
|
+
start: 0, end: numPrefixLen, style: { fg: STYLE_LINE_NUM_FG },
|
|
995
|
+
};
|
|
996
|
+
|
|
631
997
|
if (prefix === '+') {
|
|
632
998
|
lines.push({
|
|
633
|
-
text:
|
|
999
|
+
text: decoratedText, type: 'add',
|
|
634
1000
|
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
635
1001
|
hunkId: hunk.id, file: hunk.file,
|
|
636
1002
|
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
1003
|
+
inlineOverlays: [dimNumOverlay],
|
|
637
1004
|
});
|
|
638
1005
|
newLineNum++;
|
|
639
1006
|
} else if (prefix === '-') {
|
|
640
1007
|
lines.push({
|
|
641
|
-
text:
|
|
1008
|
+
text: decoratedText, type: 'remove',
|
|
642
1009
|
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
643
1010
|
hunkId: hunk.id, file: hunk.file,
|
|
644
1011
|
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
1012
|
+
inlineOverlays: [dimNumOverlay],
|
|
645
1013
|
});
|
|
646
1014
|
oldLineNum++;
|
|
647
1015
|
} else {
|
|
648
1016
|
lines.push({
|
|
649
|
-
text:
|
|
1017
|
+
text: decoratedText, type: 'context',
|
|
650
1018
|
hunkId: hunk.id, file: hunk.file,
|
|
651
1019
|
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
1020
|
+
inlineOverlays: [dimNumOverlay],
|
|
652
1021
|
});
|
|
653
1022
|
oldLineNum++;
|
|
654
1023
|
newLineNum++;
|
|
@@ -657,6 +1026,10 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
657
1026
|
// Render inline comments attached to this line
|
|
658
1027
|
pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
|
|
659
1028
|
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Blank separator between files
|
|
1032
|
+
lines.push({ text: '', type: 'empty' });
|
|
660
1033
|
}
|
|
661
1034
|
|
|
662
1035
|
return lines;
|
|
@@ -671,10 +1044,15 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
671
1044
|
*/
|
|
672
1045
|
|
|
673
1046
|
// Theme colors for toolbar key hints
|
|
674
|
-
|
|
675
|
-
|
|
1047
|
+
// Toolbar styling — explicitly NOT using `ui.status_bar_bg` because that
|
|
1048
|
+
// key is a saturated accent in some themes (Dracula's hot pink). Instead
|
|
1049
|
+
// we paint the toolbar with `editor.bg` so it visually matches the
|
|
1050
|
+
// editor content and keys/labels get reliable contrast against it.
|
|
1051
|
+
// * Keys: `editor.fg` + bold (white-bold on dark, etc.).
|
|
1052
|
+
// * Labels: `editor.line_number_fg` (dim foreground in every theme).
|
|
1053
|
+
const STYLE_KEY_FG: OverlayColorSpec = "editor.fg";
|
|
676
1054
|
const STYLE_HINT_FG: OverlayColorSpec = "editor.line_number_fg";
|
|
677
|
-
const STYLE_TOOLBAR_BG: OverlayColorSpec = "
|
|
1055
|
+
const STYLE_TOOLBAR_BG: OverlayColorSpec = "editor.bg";
|
|
678
1056
|
const STYLE_TOOLBAR_SEP: OverlayColorSpec = "ui.split_separator_fg";
|
|
679
1057
|
|
|
680
1058
|
interface HintItem {
|
|
@@ -686,24 +1064,7 @@ interface HintItem {
|
|
|
686
1064
|
* Build a styled toolbar entry with highlighted key hints.
|
|
687
1065
|
* Keys get bold + keyword color; labels get dim text; groups separated by │.
|
|
688
1066
|
*/
|
|
689
|
-
function
|
|
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.
|
|
1067
|
+
function buildToolbarRow(W: number, groups: HintItem[][]): TextPropertyEntry {
|
|
707
1068
|
const overlays: InlineOverlay[] = [];
|
|
708
1069
|
let text = " ";
|
|
709
1070
|
let bytePos = getByteLength(" ");
|
|
@@ -720,15 +1081,21 @@ function buildToolbar(W: number): TextPropertyEntry {
|
|
|
720
1081
|
for (let h = 0; h < groups[g].length && !done; h++) {
|
|
721
1082
|
const item = groups[g][h];
|
|
722
1083
|
const gap = h > 0 ? " " : "";
|
|
723
|
-
|
|
724
|
-
|
|
1084
|
+
// Bracket-style key hint: "[key] label" — the brackets make
|
|
1085
|
+
// the keys legible without a saturated bg, which works in
|
|
1086
|
+
// every theme (no Dracula hot-pink toolbar problem). When
|
|
1087
|
+
// the key itself is `[` or `]`, drop the brackets so we
|
|
1088
|
+
// don't render `[[]` / `[]]`.
|
|
1089
|
+
const isBracket = item.key === '[' || item.key === ']';
|
|
1090
|
+
const keyDisplay = isBracket ? item.key : `[${item.key}]`;
|
|
1091
|
+
const fullLen = gap.length + keyDisplay.length + 1 + item.label.length;
|
|
1092
|
+
const keyOnlyLen = gap.length + keyDisplay.length;
|
|
725
1093
|
|
|
726
1094
|
if (text.length + fullLen <= W) {
|
|
727
|
-
// Full item: gap + key + " " + label
|
|
728
1095
|
if (gap) { text += gap; bytePos += getByteLength(gap); }
|
|
729
|
-
const keyLen = getByteLength(
|
|
730
|
-
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG,
|
|
731
|
-
text +=
|
|
1096
|
+
const keyLen = getByteLength(keyDisplay);
|
|
1097
|
+
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
|
|
1098
|
+
text += keyDisplay;
|
|
732
1099
|
bytePos += keyLen;
|
|
733
1100
|
const labelText = " " + item.label;
|
|
734
1101
|
const labelLen = getByteLength(labelText);
|
|
@@ -736,11 +1103,10 @@ function buildToolbar(W: number): TextPropertyEntry {
|
|
|
736
1103
|
text += labelText;
|
|
737
1104
|
bytePos += labelLen;
|
|
738
1105
|
} else if (text.length + keyOnlyLen <= W) {
|
|
739
|
-
// Key only (no label) when space is tight
|
|
740
1106
|
if (gap) { text += gap; bytePos += getByteLength(gap); }
|
|
741
|
-
const keyLen = getByteLength(
|
|
742
|
-
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG,
|
|
743
|
-
text +=
|
|
1107
|
+
const keyLen = getByteLength(keyDisplay);
|
|
1108
|
+
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
|
|
1109
|
+
text += keyDisplay;
|
|
744
1110
|
bytePos += keyLen;
|
|
745
1111
|
} else {
|
|
746
1112
|
done = true;
|
|
@@ -757,75 +1123,82 @@ function buildToolbar(W: number): TextPropertyEntry {
|
|
|
757
1123
|
};
|
|
758
1124
|
}
|
|
759
1125
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1126
|
+
/**
|
|
1127
|
+
* Build the (two-row) toolbar with all review-diff shortcuts.
|
|
1128
|
+
* Row 1 — navigation; row 2 — actions. Identical regardless of which
|
|
1129
|
+
* panel currently has focus (no more files-pane vs diff-pane variants).
|
|
1130
|
+
*/
|
|
1131
|
+
function buildToolbar(W: number): TextPropertyEntry[] {
|
|
1132
|
+
// In range mode, stage / unstage / discard are meaningless (there is
|
|
1133
|
+
// no working tree to mutate), so hide them from the hint bar to keep
|
|
1134
|
+
// the toolbar honest. The key-bindings themselves are harmless if
|
|
1135
|
+
// pressed — `review_stage_scope` no-ops on range-mode hunks because
|
|
1136
|
+
// their gitStatus is 'unstaged' and the git commands it invokes
|
|
1137
|
+
// target the working tree, which isn't what the user intended. The
|
|
1138
|
+
// toolbar is the user-facing surface, so pruning here is the
|
|
1139
|
+
// cheapest honest thing to do.
|
|
1140
|
+
const inRange = state.mode === 'range';
|
|
1141
|
+
const row1: HintItem[][] = [
|
|
1142
|
+
[{ key: "n", label: "next hunk" }, { key: "p", label: "prev hunk" },
|
|
1143
|
+
{ key: "]", label: "next cmt" }, { key: "[", label: "prev cmt" }],
|
|
1144
|
+
inRange
|
|
1145
|
+
? [{ key: "v", label: "select" }, { key: "c", label: "comment" }]
|
|
1146
|
+
: [{ key: "s", label: "stage" }, { key: "u", label: "unstage" }, { key: "d", label: "discard" },
|
|
1147
|
+
{ key: "v", label: "select" }, { key: "c", label: "comment" }],
|
|
1148
|
+
];
|
|
1149
|
+
const row2: HintItem[][] = [
|
|
1150
|
+
[{ key: "Tab", label: "fold" }, { key: "z a", label: "fold all" }, { key: "z r", label: "unfold all" }],
|
|
1151
|
+
inRange
|
|
1152
|
+
? [{ key: "Enter", label: "jump" }, { key: "e", label: "export" }, { key: "q", label: "close" }]
|
|
1153
|
+
: [{ key: "S U D", label: "file-level" }, { key: "Enter", label: "jump" },
|
|
1154
|
+
{ key: "e", label: "export" }, { key: "q", label: "close" }],
|
|
1155
|
+
];
|
|
1156
|
+
return [buildToolbarRow(W, row1), buildToolbarRow(W, row2)];
|
|
765
1157
|
}
|
|
766
1158
|
|
|
767
|
-
|
|
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
|
-
});
|
|
1159
|
+
// --- Buffer Group panel content builders ---
|
|
781
1160
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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;
|
|
1161
|
+
function buildToolbarPanelEntries(): TextPropertyEntry[] {
|
|
1162
|
+
// Two-row toolbar: navigation hints on row 1, actions on row 2.
|
|
1163
|
+
return buildToolbar(state.viewportWidth);
|
|
800
1164
|
}
|
|
801
1165
|
|
|
802
1166
|
/**
|
|
803
|
-
* Build
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
1167
|
+
* Build the unified-diff stream entries. Emits one row per file header
|
|
1168
|
+
* followed by all of that file's hunks inline, plus inline comments and
|
|
1169
|
+
* a blank separator between files. As a side effect, populates
|
|
1170
|
+
* `state.hunkHeaderRows`, `state.diffLineByteOffsets`, and
|
|
1171
|
+
* `state.fileHeaderRows` so the rest of the plugin can map cursor rows
|
|
1172
|
+
* back to hunks/files.
|
|
808
1173
|
*/
|
|
809
1174
|
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
1175
|
const entries: TextPropertyEntry[] = [];
|
|
822
|
-
const leftWidth = Math.max(28, Math.floor(state.viewportWidth * 0.3));
|
|
823
|
-
const rightWidth = state.viewportWidth - leftWidth - 1;
|
|
824
1176
|
|
|
825
1177
|
const hunkHeaderRows: number[] = [];
|
|
826
1178
|
const diffLineByteOffsets: number[] = [];
|
|
1179
|
+
const fileHeaderRows: Record<string, number> = {};
|
|
1180
|
+
const sectionHeaderRows: Record<string, number> = {};
|
|
1181
|
+
const hunkRowByHunkId: Record<string, number> = {};
|
|
1182
|
+
const diffLineRowByCommentId: Record<string, number> = {};
|
|
1183
|
+
const entryPropsByRow: Record<number, Record<string, unknown>> = {};
|
|
1184
|
+
// Byte ranges of collapsible bodies, captured in this same single
|
|
1185
|
+
// pass so collapse later just registers a host fold (no rebuild).
|
|
1186
|
+
// The "body" of an entity is the byte range from the byte after
|
|
1187
|
+
// its header's newline up to the byte before the next header that
|
|
1188
|
+
// ends it.
|
|
1189
|
+
const sectionBodyRange: Record<string, { start: number; end: number }> = {};
|
|
1190
|
+
const fileBodyRange: Record<string, { start: number; end: number }> = {};
|
|
1191
|
+
const hunkBodyRange: Record<string, { start: number; end: number }> = {};
|
|
1192
|
+
let curSection: string | null = null;
|
|
1193
|
+
let curFile: string | null = null;
|
|
1194
|
+
let curHunk: string | null = null;
|
|
1195
|
+
let sectionBodyStart = 0;
|
|
1196
|
+
let fileBodyStart = 0;
|
|
1197
|
+
let hunkBodyStart = 0;
|
|
1198
|
+
|
|
827
1199
|
let runningByte = 0;
|
|
828
1200
|
let row = 0; // 0-indexed counter; row + 1 is the 1-indexed line number
|
|
1201
|
+
let lastDiffLineRow = 0; // 1-indexed row of the most recent +/-/context line
|
|
829
1202
|
|
|
830
1203
|
const pushEntry = (entry: TextPropertyEntry) => {
|
|
831
1204
|
diffLineByteOffsets.push(runningByte);
|
|
@@ -834,23 +1207,8 @@ function buildDiffPanelEntries(): TextPropertyEntry[] {
|
|
|
834
1207
|
row++;
|
|
835
1208
|
};
|
|
836
1209
|
|
|
837
|
-
|
|
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);
|
|
1210
|
+
const lines = buildDiffLines(state.viewportWidth);
|
|
850
1211
|
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
1212
|
const props: Record<string, unknown> = { type: line.type };
|
|
855
1213
|
if (line.hunkId !== undefined) props.hunkId = line.hunkId;
|
|
856
1214
|
if (line.file !== undefined) props.file = line.file;
|
|
@@ -859,26 +1217,179 @@ function buildDiffPanelEntries(): TextPropertyEntry[] {
|
|
|
859
1217
|
if (line.newLine !== undefined) props.newLine = line.newLine;
|
|
860
1218
|
if (line.lineContent !== undefined) props.lineContent = line.lineContent;
|
|
861
1219
|
if (line.commentId !== undefined) props.commentId = line.commentId;
|
|
1220
|
+
if (line.filePath !== undefined) props.filePath = line.filePath;
|
|
1221
|
+
if (line.fileKey !== undefined) props.fileKey = line.fileKey;
|
|
1222
|
+
if (line.fileIndex !== undefined) props.fileIndex = line.fileIndex;
|
|
1223
|
+
|
|
1224
|
+
const entryStart = runningByte;
|
|
1225
|
+
|
|
1226
|
+
// Header bookkeeping — close any in-progress body for the
|
|
1227
|
+
// entities about to be replaced, then open a new body range.
|
|
1228
|
+
if (line.type === 'section-header' && line.filePath) {
|
|
1229
|
+
if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: entryStart };
|
|
1230
|
+
if (curFile) fileBodyRange[curFile] = { start: fileBodyStart, end: entryStart };
|
|
1231
|
+
if (curSection) sectionBodyRange[curSection] = { start: sectionBodyStart, end: entryStart };
|
|
1232
|
+
curSection = line.filePath;
|
|
1233
|
+
curFile = null;
|
|
1234
|
+
curHunk = null;
|
|
1235
|
+
}
|
|
1236
|
+
if (line.type === 'file-header' && line.fileKey) {
|
|
1237
|
+
if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: entryStart };
|
|
1238
|
+
if (curFile) fileBodyRange[curFile] = { start: fileBodyStart, end: entryStart };
|
|
1239
|
+
curFile = line.fileKey;
|
|
1240
|
+
curHunk = null;
|
|
1241
|
+
}
|
|
1242
|
+
if (line.type === 'hunk-header' && line.hunkId) {
|
|
1243
|
+
if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: entryStart };
|
|
1244
|
+
curHunk = line.hunkId;
|
|
1245
|
+
}
|
|
862
1246
|
|
|
863
1247
|
if (line.type === 'hunk-header') {
|
|
864
|
-
// 1-indexed row of this hunk header in the diff buffer.
|
|
865
1248
|
hunkHeaderRows.push(row + 1);
|
|
1249
|
+
if (line.hunkId) hunkRowByHunkId[line.hunkId] = row + 1;
|
|
1250
|
+
}
|
|
1251
|
+
if (line.type === 'file-header' && line.fileKey) {
|
|
1252
|
+
fileHeaderRows[line.fileKey] = row + 1;
|
|
1253
|
+
}
|
|
1254
|
+
if (line.type === 'section-header' && line.filePath) {
|
|
1255
|
+
sectionHeaderRows[line.filePath] = row + 1;
|
|
1256
|
+
}
|
|
1257
|
+
if (line.type === 'add' || line.type === 'remove' || line.type === 'context') {
|
|
1258
|
+
lastDiffLineRow = row + 1;
|
|
1259
|
+
}
|
|
1260
|
+
if (line.type === 'comment' && line.commentId) {
|
|
1261
|
+
diffLineRowByCommentId[line.commentId] = lastDiffLineRow || (row + 1);
|
|
866
1262
|
}
|
|
867
1263
|
|
|
1264
|
+
entryPropsByRow[row + 1] = props;
|
|
1265
|
+
|
|
868
1266
|
pushEntry({
|
|
869
1267
|
text: (line.text || "") + "\n",
|
|
870
1268
|
style: line.style,
|
|
871
1269
|
inlineOverlays: line.inlineOverlays,
|
|
872
1270
|
properties: props,
|
|
873
1271
|
});
|
|
1272
|
+
|
|
1273
|
+
// After the header is pushed, runningByte points to the first
|
|
1274
|
+
// byte of the body that follows.
|
|
1275
|
+
if (line.type === 'section-header') sectionBodyStart = runningByte;
|
|
1276
|
+
if (line.type === 'file-header') fileBodyStart = runningByte;
|
|
1277
|
+
if (line.type === 'hunk-header') hunkBodyStart = runningByte;
|
|
874
1278
|
}
|
|
875
1279
|
|
|
876
|
-
//
|
|
1280
|
+
// Close trailing bodies.
|
|
1281
|
+
if (curHunk) hunkBodyRange[curHunk] = { start: hunkBodyStart, end: runningByte };
|
|
1282
|
+
if (curFile) fileBodyRange[curFile] = { start: fileBodyStart, end: runningByte };
|
|
1283
|
+
if (curSection) sectionBodyRange[curSection] = { start: sectionBodyStart, end: runningByte };
|
|
1284
|
+
|
|
877
1285
|
diffLineByteOffsets.push(runningByte);
|
|
878
1286
|
|
|
879
|
-
state.diffCache[cacheKey] = { entries, hunkHeaderRows, diffLineByteOffsets };
|
|
880
1287
|
state.hunkHeaderRows = hunkHeaderRows;
|
|
881
1288
|
state.diffLineByteOffsets = diffLineByteOffsets;
|
|
1289
|
+
state.fileHeaderRows = fileHeaderRows;
|
|
1290
|
+
state.sectionHeaderRows = sectionHeaderRows;
|
|
1291
|
+
state.hunkRowByHunkId = hunkRowByHunkId;
|
|
1292
|
+
state.diffLineRowByCommentId = diffLineRowByCommentId;
|
|
1293
|
+
state.entryPropsByRow = entryPropsByRow;
|
|
1294
|
+
state.sectionBodyRange = sectionBodyRange;
|
|
1295
|
+
state.fileBodyRange = fileBodyRange;
|
|
1296
|
+
state.hunkBodyRange = hunkBodyRange;
|
|
1297
|
+
return entries;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Build the comments navigation panel. Flat list of comments in the
|
|
1302
|
+
* order they appear in the unified diff stream. Each row reads
|
|
1303
|
+
* "path:line snippet"
|
|
1304
|
+
* truncated to fit the panel width. Empty state shows a dim "No comments
|
|
1305
|
+
* yet." line. Read-only in this step (interaction lands in Step 5/6).
|
|
1306
|
+
*/
|
|
1307
|
+
function buildCommentsPanelEntries(): TextPropertyEntry[] {
|
|
1308
|
+
const entries: TextPropertyEntry[] = [];
|
|
1309
|
+
state.commentsByRow = {};
|
|
1310
|
+
|
|
1311
|
+
const headerLabel = (editor.t("panel.comments") || "Comments").toUpperCase();
|
|
1312
|
+
entries.push({
|
|
1313
|
+
text: ` ${headerLabel}\n`,
|
|
1314
|
+
style: {
|
|
1315
|
+
fg: STYLE_INVERSE_FG,
|
|
1316
|
+
bg: STYLE_INVERSE_BG,
|
|
1317
|
+
bold: true,
|
|
1318
|
+
extendToLineEnd: true,
|
|
1319
|
+
},
|
|
1320
|
+
properties: { type: "header" },
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
if (state.comments.length === 0) {
|
|
1324
|
+
entries.push({
|
|
1325
|
+
text: ` ${editor.t("panel.no_comments") || "No comments yet."}\n`,
|
|
1326
|
+
style: { fg: STYLE_SECTION_HEADER, italic: true },
|
|
1327
|
+
properties: { type: "empty" },
|
|
1328
|
+
});
|
|
1329
|
+
return entries;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// Order comments by their position in the unified stream. We approximate
|
|
1333
|
+
// by sorting by (file index, line number, removed/added preference).
|
|
1334
|
+
const fileIndex = (file: string, category: string | undefined): number => {
|
|
1335
|
+
for (let i = 0; i < state.files.length; i++) {
|
|
1336
|
+
const f = state.files[i];
|
|
1337
|
+
if (f.path === file) return i;
|
|
1338
|
+
}
|
|
1339
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
const sortedComments = [...state.comments].sort((a, b) => {
|
|
1343
|
+
// Look up via hunk's file
|
|
1344
|
+
const hunkA = state.hunks.find(h => h.id === a.hunk_id);
|
|
1345
|
+
const hunkB = state.hunks.find(h => h.id === b.hunk_id);
|
|
1346
|
+
const fa = fileIndex(a.file, hunkA?.gitStatus);
|
|
1347
|
+
const fb = fileIndex(b.file, hunkB?.gitStatus);
|
|
1348
|
+
if (fa !== fb) return fa - fb;
|
|
1349
|
+
const la = a.new_line ?? a.old_line ?? 0;
|
|
1350
|
+
const lb = b.new_line ?? b.old_line ?? 0;
|
|
1351
|
+
return la - lb;
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
let rowIdx = 1; // header is row 0 (0-indexed); comments start at row 1
|
|
1355
|
+
for (const c of sortedComments) {
|
|
1356
|
+
rowIdx++;
|
|
1357
|
+
const lineRef = c.new_line ?? c.old_line ?? 0;
|
|
1358
|
+
const path = c.file.split('/').pop() || c.file;
|
|
1359
|
+
const snippet = c.text.replace(/\s+/g, ' ').trim();
|
|
1360
|
+
// Leading marker: ">" when this comment is the diff cursor's
|
|
1361
|
+
// current target (cursor is on the comment row itself, or on
|
|
1362
|
+
// the line the comment is attached to). Otherwise a space.
|
|
1363
|
+
const marker = c.id === state.commentsHighlightId ? '>' : ' ';
|
|
1364
|
+
const text = `${marker} ${path}:${lineRef} ${snippet}`;
|
|
1365
|
+
|
|
1366
|
+
// Truncate to fit panel width (estimate).
|
|
1367
|
+
const panelWidth = Math.max(20, Math.floor(state.viewportWidth * 0.25) - 2);
|
|
1368
|
+
const display = text.length > panelWidth ? text.slice(0, panelWidth - 1) + '…' : text;
|
|
1369
|
+
|
|
1370
|
+
const isSelected = rowIdx === state.commentsSelectedRow && state.focusPanel === 'comments';
|
|
1371
|
+
const isCursorMarked = c.id === state.commentsHighlightId;
|
|
1372
|
+
const style: Partial<OverlayOptions> | undefined = isSelected
|
|
1373
|
+
? { bg: STYLE_SELECTED_BG, bold: true, extendToLineEnd: true }
|
|
1374
|
+
: isCursorMarked
|
|
1375
|
+
? { bold: true }
|
|
1376
|
+
: undefined;
|
|
1377
|
+
|
|
1378
|
+
// Color the path:line prefix in keyword color (skip the marker).
|
|
1379
|
+
const prefixLen = getByteLength(`${marker} ${path}:${lineRef}`);
|
|
1380
|
+
const inlineOverlays: InlineOverlay[] = [
|
|
1381
|
+
{ start: 2, end: prefixLen, style: { fg: STYLE_KEY_FG } },
|
|
1382
|
+
];
|
|
1383
|
+
|
|
1384
|
+
state.commentsByRow[rowIdx] = c.id;
|
|
1385
|
+
entries.push({
|
|
1386
|
+
text: display + "\n",
|
|
1387
|
+
style,
|
|
1388
|
+
inlineOverlays,
|
|
1389
|
+
properties: { type: "comment-nav", commentId: c.id, file: c.file, line: lineRef },
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
882
1393
|
return entries;
|
|
883
1394
|
}
|
|
884
1395
|
|
|
@@ -891,21 +1402,320 @@ function updateMagitDisplay(): void {
|
|
|
891
1402
|
refreshViewportDimensions();
|
|
892
1403
|
if (state.groupId === null) return;
|
|
893
1404
|
editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
|
|
894
|
-
editor.setPanelContent(state.groupId, "files", buildFilesPanelEntries());
|
|
895
1405
|
editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1406
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1407
|
+
refreshStickyHeader(0);
|
|
1408
|
+
applyFolds();
|
|
899
1409
|
applyCursorLineOverlay('diff');
|
|
900
1410
|
}
|
|
901
1411
|
|
|
902
1412
|
/**
|
|
903
|
-
*
|
|
1413
|
+
* Apply collapse state via the host's folding infrastructure. Folds
|
|
1414
|
+
* are designed exactly for "header line stays visible, body lines
|
|
1415
|
+
* skipped by the renderer". A fold range covers `[bodyStart, bodyEnd)`
|
|
1416
|
+
* — the line containing `bodyStart - 1` (the header) stays visible,
|
|
1417
|
+
* everything inside the range gets elided. The host renders its own
|
|
1418
|
+
* "..." indicator on the collapsed header line, which is sufficient
|
|
1419
|
+
* visual feedback (no need for a triangle swap).
|
|
1420
|
+
*
|
|
1421
|
+
* Toggling collapse on a 5000-line diff is now O(collapsed_set_size)
|
|
1422
|
+
* `addFold` calls. `clearFolds` drops the entire set in one host call
|
|
1423
|
+
* so re-applying after a state change is also cheap.
|
|
904
1424
|
*/
|
|
905
|
-
function
|
|
1425
|
+
function applyFolds(): void {
|
|
906
1426
|
if (state.groupId === null) return;
|
|
907
|
-
|
|
908
|
-
|
|
1427
|
+
const diffId = state.panelBuffers["diff"];
|
|
1428
|
+
if (diffId === undefined) return;
|
|
1429
|
+
editor.clearFolds(diffId);
|
|
1430
|
+
for (const cat of state.collapsedSections) {
|
|
1431
|
+
const body = state.sectionBodyRange[cat];
|
|
1432
|
+
if (body && body.end > body.start) editor.addFold(diffId, body.start, body.end);
|
|
1433
|
+
}
|
|
1434
|
+
for (const key of state.collapsedFiles) {
|
|
1435
|
+
const body = state.fileBodyRange[key];
|
|
1436
|
+
if (body && body.end > body.start) editor.addFold(diffId, body.start, body.end);
|
|
1437
|
+
}
|
|
1438
|
+
for (const id of state.collapsedHunks) {
|
|
1439
|
+
const body = state.hunkBodyRange[id];
|
|
1440
|
+
if (body && body.end > body.start) editor.addFold(diffId, body.start, body.end);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Render the sticky panel for `topVisibleRow` (0-indexed line at the top
|
|
1446
|
+
* of the diff viewport). Shows the file whose header row is the largest
|
|
1447
|
+
* ≤ topVisibleRow, with its category as a dim prefix. Falls back to a
|
|
1448
|
+
* neutral summary when nothing is above the cursor.
|
|
1449
|
+
*/
|
|
1450
|
+
function refreshStickyHeader(topVisibleRow: number): void {
|
|
1451
|
+
if (state.groupId === null) return;
|
|
1452
|
+
const stickyId = state.panelBuffers["sticky"];
|
|
1453
|
+
if (stickyId === undefined) return;
|
|
1454
|
+
|
|
1455
|
+
const W = state.viewportWidth;
|
|
1456
|
+
let text: string;
|
|
1457
|
+
let style: Partial<OverlayOptions> = { fg: STYLE_HEADER, bold: true };
|
|
1458
|
+
|
|
1459
|
+
// topVisibleRow is 0-indexed; fileHeaderRows are 1-indexed.
|
|
1460
|
+
const top1 = topVisibleRow + 1;
|
|
1461
|
+
let bestFile: FileEntry | null = null;
|
|
1462
|
+
let bestRow = 0;
|
|
1463
|
+
for (const f of state.files) {
|
|
1464
|
+
const row = state.fileHeaderRows[fileKey(f)];
|
|
1465
|
+
if (row !== undefined && row <= top1 && row > bestRow) {
|
|
1466
|
+
bestRow = row;
|
|
1467
|
+
bestFile = f;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (!bestFile) {
|
|
1472
|
+
if (state.files.length === 0) {
|
|
1473
|
+
text = ` ${editor.t("status.review_empty") || "Review Diff"}`;
|
|
1474
|
+
} else {
|
|
1475
|
+
const totals = state.files.reduce(
|
|
1476
|
+
(acc, f) => {
|
|
1477
|
+
const c = fileChangeCounts(f);
|
|
1478
|
+
acc.added += c.added;
|
|
1479
|
+
acc.removed += c.removed;
|
|
1480
|
+
return acc;
|
|
1481
|
+
},
|
|
1482
|
+
{ added: 0, removed: 0 }
|
|
1483
|
+
);
|
|
1484
|
+
const rangeSuffix = state.mode === 'range' && state.range
|
|
1485
|
+
? ` (${state.range.label})`
|
|
1486
|
+
: '';
|
|
1487
|
+
text = ` Review Diff${rangeSuffix} — ${state.files.length} files, +${totals.added} / -${totals.removed}`;
|
|
1488
|
+
style = { fg: STYLE_SECTION_HEADER, italic: true };
|
|
1489
|
+
}
|
|
1490
|
+
} else {
|
|
1491
|
+
const counts = fileChangeCounts(bestFile);
|
|
1492
|
+
let section: string = bestFile.category;
|
|
1493
|
+
// In range mode every hunk is bucketed as 'unstaged' as an impl
|
|
1494
|
+
// detail; "UNSTAGED" would be misleading, so display the range
|
|
1495
|
+
// label instead.
|
|
1496
|
+
if (state.mode === 'range' && state.range) {
|
|
1497
|
+
section = state.range.label;
|
|
1498
|
+
} else if (bestFile.category === 'staged') section = (editor.t("section.staged") || "Staged").toUpperCase();
|
|
1499
|
+
else if (bestFile.category === 'unstaged') section = (editor.t("section.unstaged") || "Changes").toUpperCase();
|
|
1500
|
+
else if (bestFile.category === 'untracked') section = (editor.t("section.untracked") || "Untracked").toUpperCase();
|
|
1501
|
+
const filename = bestFile.origPath ? `${bestFile.origPath} → ${bestFile.path}` : bestFile.path;
|
|
1502
|
+
text = ` ${section} · ${filename} +${counts.added} / -${counts.removed}`;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const padded = (text.length > W ? text.slice(0, W) : text).padEnd(W) + "\n";
|
|
1506
|
+
editor.setPanelContent(state.groupId, "sticky", [{
|
|
1507
|
+
text: padded,
|
|
1508
|
+
// Same band-bg as file/section headers — keeps the sticky visually
|
|
1509
|
+
// tied to the headers it summarizes and avoids the toolbar's
|
|
1510
|
+
// status_bar_bg, which is a saturated accent in some themes
|
|
1511
|
+
// (Dracula's is hot pink — clashes badly with the diff content).
|
|
1512
|
+
style: { ...style, bg: STYLE_FILE_HEADER_BG, extendToLineEnd: true },
|
|
1513
|
+
properties: { type: "sticky-header" },
|
|
1514
|
+
}]);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Helper: jump the diff cursor to the file's first hunk (or its file
|
|
1519
|
+
* header if it has no hunks). Auto-expands the file if collapsed.
|
|
1520
|
+
*/
|
|
1521
|
+
function jumpToFile(file: FileEntry): void {
|
|
1522
|
+
const key = fileKey(file);
|
|
1523
|
+
if (state.collapsedFiles.has(key)) {
|
|
1524
|
+
state.collapsedFiles.delete(key);
|
|
1525
|
+
updateMagitDisplay();
|
|
1526
|
+
}
|
|
1527
|
+
// Prefer first hunk row; fall back to the file-header row.
|
|
1528
|
+
const fileIdx = state.files.indexOf(file);
|
|
1529
|
+
if (fileIdx >= 0) {
|
|
1530
|
+
// Compute visible hunk index of the first hunk for this file.
|
|
1531
|
+
let visibleIdx = 0;
|
|
1532
|
+
let foundGlobal = -1;
|
|
1533
|
+
for (let i = 0; i < state.hunks.length; i++) {
|
|
1534
|
+
const h = state.hunks[i];
|
|
1535
|
+
const hKey = fileKeyOf(h.file, h.gitStatus || 'unstaged');
|
|
1536
|
+
if (state.collapsedFiles.has(hKey)) continue;
|
|
1537
|
+
if (h.file === file.path && h.gitStatus === file.category) {
|
|
1538
|
+
foundGlobal = i;
|
|
1539
|
+
break;
|
|
1540
|
+
}
|
|
1541
|
+
visibleIdx++;
|
|
1542
|
+
}
|
|
1543
|
+
if (foundGlobal >= 0) {
|
|
1544
|
+
const row = state.hunkHeaderRows[visibleIdx];
|
|
1545
|
+
if (row !== undefined) { jumpDiffCursorToRow(row); return; }
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
const headerRow = state.fileHeaderRows[key];
|
|
1549
|
+
if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Mouse click handler. Routes clicks to the appropriate behavior:
|
|
1554
|
+
* * Diff buffer file-header row → toggle that file's collapse state.
|
|
1555
|
+
* * Sticky panel → jump to the currently-pinned file's first hunk.
|
|
1556
|
+
* * Comments panel row → jump diff cursor to that comment's location
|
|
1557
|
+
* (auto-expanding the file when collapsed) and select the row.
|
|
1558
|
+
*/
|
|
1559
|
+
function on_review_mouse_click(data: {
|
|
1560
|
+
column: number; row: number; button: string; modifiers: string;
|
|
1561
|
+
content_x: number; content_y: number;
|
|
1562
|
+
buffer_id: number | null; buffer_row: number | null; buffer_col: number | null;
|
|
1563
|
+
}): void {
|
|
1564
|
+
if (state.groupId === null) return;
|
|
1565
|
+
if (data.buffer_id === null || data.buffer_row === null) return;
|
|
1566
|
+
|
|
1567
|
+
const diffId = state.panelBuffers["diff"];
|
|
1568
|
+
const stickyId = state.panelBuffers["sticky"];
|
|
1569
|
+
const commentsId = state.panelBuffers["comments"];
|
|
1570
|
+
|
|
1571
|
+
// Click in the diff buffer: section headers and file headers are
|
|
1572
|
+
// both interactive — clicking either toggles its fold state.
|
|
1573
|
+
if (data.buffer_id === diffId) {
|
|
1574
|
+
const targetRow1 = data.buffer_row + 1;
|
|
1575
|
+
// Section header click: toggle the whole category.
|
|
1576
|
+
for (const cat of Object.keys(state.sectionHeaderRows)) {
|
|
1577
|
+
if (state.sectionHeaderRows[cat] === targetRow1) {
|
|
1578
|
+
if (state.collapsedSections.has(cat)) state.collapsedSections.delete(cat);
|
|
1579
|
+
else state.collapsedSections.add(cat);
|
|
1580
|
+
applyFolds();
|
|
1581
|
+
const sectionRow = state.sectionHeaderRows[cat];
|
|
1582
|
+
if (sectionRow !== undefined) jumpDiffCursorToRow(sectionRow);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
// File header click: toggle the single file.
|
|
1587
|
+
for (const f of state.files) {
|
|
1588
|
+
if (state.fileHeaderRows[fileKey(f)] === targetRow1) {
|
|
1589
|
+
const key = fileKey(f);
|
|
1590
|
+
if (state.collapsedFiles.has(key)) state.collapsedFiles.delete(key);
|
|
1591
|
+
else state.collapsedFiles.add(key);
|
|
1592
|
+
applyFolds();
|
|
1593
|
+
const headerRow = state.fileHeaderRows[key];
|
|
1594
|
+
if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
// Hunk header click: toggle the single hunk.
|
|
1599
|
+
for (const hunkId of Object.keys(state.hunkRowByHunkId)) {
|
|
1600
|
+
if (state.hunkRowByHunkId[hunkId] === targetRow1) {
|
|
1601
|
+
if (state.collapsedHunks.has(hunkId)) state.collapsedHunks.delete(hunkId);
|
|
1602
|
+
else state.collapsedHunks.add(hunkId);
|
|
1603
|
+
applyFolds();
|
|
1604
|
+
const hunkRow = state.hunkRowByHunkId[hunkId];
|
|
1605
|
+
if (hunkRow !== undefined) jumpDiffCursorToRow(hunkRow);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Click on the sticky pinned-header: jump to the pinned file's first hunk.
|
|
1613
|
+
if (data.buffer_id === stickyId) {
|
|
1614
|
+
// Re-derive the pinned file from current viewport top.
|
|
1615
|
+
const top1 = state.diffCursorRow; // approximation; sticky tracks topmost visible
|
|
1616
|
+
let bestFile: FileEntry | null = null;
|
|
1617
|
+
let bestRow = 0;
|
|
1618
|
+
for (const f of state.files) {
|
|
1619
|
+
const row = state.fileHeaderRows[fileKey(f)];
|
|
1620
|
+
if (row !== undefined && row <= top1 && row > bestRow) {
|
|
1621
|
+
bestRow = row;
|
|
1622
|
+
bestFile = f;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (bestFile) jumpToFile(bestFile);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Click in the comments panel: jump to the comment's location and
|
|
1630
|
+
// hand focus to the diff so the user can immediately keep navigating.
|
|
1631
|
+
if (data.buffer_id === commentsId) {
|
|
1632
|
+
const targetRow1 = data.buffer_row + 1;
|
|
1633
|
+
const commentId = state.commentsByRow[targetRow1];
|
|
1634
|
+
if (commentId) {
|
|
1635
|
+
state.commentsSelectedRow = targetRow1;
|
|
1636
|
+
jumpToComment(commentId);
|
|
1637
|
+
editor.focusBufferGroupPanel(state.groupId, "diff");
|
|
1638
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1639
|
+
}
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
registerHandler("on_review_mouse_click", on_review_mouse_click);
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Jump the diff cursor to the line associated with a comment, auto-
|
|
1647
|
+
* expanding the comment's file if it is currently collapsed.
|
|
1648
|
+
*/
|
|
1649
|
+
function jumpToComment(commentId: string): void {
|
|
1650
|
+
const comment = state.comments.find(c => c.id === commentId);
|
|
1651
|
+
if (!comment) return;
|
|
1652
|
+
const hunk = state.hunks.find(h => h.id === comment.hunk_id);
|
|
1653
|
+
if (!hunk) return;
|
|
1654
|
+
// Auto-expand whatever's between the cursor and this comment.
|
|
1655
|
+
let needRebuild = false;
|
|
1656
|
+
if (hunk.gitStatus && state.collapsedSections.has(hunk.gitStatus)) {
|
|
1657
|
+
state.collapsedSections.delete(hunk.gitStatus);
|
|
1658
|
+
needRebuild = true;
|
|
1659
|
+
}
|
|
1660
|
+
const file = state.files.find(f => f.path === hunk.file && f.category === hunk.gitStatus);
|
|
1661
|
+
if (file) {
|
|
1662
|
+
const key = fileKey(file);
|
|
1663
|
+
if (state.collapsedFiles.has(key)) {
|
|
1664
|
+
state.collapsedFiles.delete(key);
|
|
1665
|
+
needRebuild = true;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
if (state.collapsedHunks.has(hunk.id)) {
|
|
1669
|
+
state.collapsedHunks.delete(hunk.id);
|
|
1670
|
+
needRebuild = true;
|
|
1671
|
+
}
|
|
1672
|
+
if (needRebuild) updateMagitDisplay();
|
|
1673
|
+
// Pin this comment as the highlighted one BEFORE jumping. Any
|
|
1674
|
+
// subsequent cursor_moved event that re-derives the highlight
|
|
1675
|
+
// will recompute the same id; doing it eagerly avoids a flicker
|
|
1676
|
+
// (and works even when the cursor lands on a row whose props
|
|
1677
|
+
// don't directly carry a comment id).
|
|
1678
|
+
const prevHighlight = state.commentsHighlightId;
|
|
1679
|
+
state.commentsHighlightId = commentId;
|
|
1680
|
+
if (state.groupId !== null && prevHighlight !== commentId) {
|
|
1681
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1682
|
+
}
|
|
1683
|
+
// Prefer the diff line the comment is anchored to (line-based);
|
|
1684
|
+
// fall back to the hunk header if the lookup hasn't seen the
|
|
1685
|
+
// comment yet (race / first render).
|
|
1686
|
+
const lineRow = state.diffLineRowByCommentId[commentId];
|
|
1687
|
+
if (lineRow !== undefined) { jumpDiffCursorToRow(lineRow); return; }
|
|
1688
|
+
const hunkRow = state.hunkRowByHunkId[hunk.id];
|
|
1689
|
+
if (hunkRow !== undefined) jumpDiffCursorToRow(hunkRow);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function on_review_viewport_changed(data: { split_id: number; buffer_id: number; top_byte: number; top_line: number | null; width: number; height: number }): void {
|
|
1693
|
+
if (state.groupId === null) return;
|
|
1694
|
+
if (data.buffer_id !== state.panelBuffers["diff"]) return;
|
|
1695
|
+
// Prefer top_line when the host provides it. Virtual buffers may not
|
|
1696
|
+
// have line metadata, in which case top_line is null — fall back to
|
|
1697
|
+
// converting top_byte using our own row-byte index.
|
|
1698
|
+
const topRow = data.top_line ?? rowFromByte(data.top_byte);
|
|
1699
|
+
state.diffViewportTopRow = topRow;
|
|
1700
|
+
refreshStickyHeader(topRow);
|
|
1701
|
+
}
|
|
1702
|
+
registerHandler("on_review_viewport_changed", on_review_viewport_changed);
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Binary-search `state.diffLineByteOffsets` for the 0-indexed row
|
|
1706
|
+
* whose byte offset is the largest one ≤ topByte.
|
|
1707
|
+
*/
|
|
1708
|
+
function rowFromByte(topByte: number): number {
|
|
1709
|
+
const offs = state.diffLineByteOffsets;
|
|
1710
|
+
if (offs.length === 0) return 0;
|
|
1711
|
+
let lo = 0;
|
|
1712
|
+
let hi = offs.length - 1;
|
|
1713
|
+
while (lo < hi) {
|
|
1714
|
+
const mid = (lo + hi + 1) >> 1;
|
|
1715
|
+
if (offs[mid] <= topByte) lo = mid;
|
|
1716
|
+
else hi = mid - 1;
|
|
1717
|
+
}
|
|
1718
|
+
return lo;
|
|
909
1719
|
}
|
|
910
1720
|
|
|
911
1721
|
/**
|
|
@@ -935,120 +1745,486 @@ function applyCursorLineOverlay(panel: 'diff'): void {
|
|
|
935
1745
|
function review_refresh() { refreshMagitData(); }
|
|
936
1746
|
registerHandler("review_refresh", review_refresh);
|
|
937
1747
|
|
|
938
|
-
// ---
|
|
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:
|
|
1748
|
+
// --- Cursor-driven navigation ---
|
|
942
1749
|
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1750
|
+
// In the unified-stream layout the diff panel owns the editor's native
|
|
1751
|
+
// cursor; j/k/Up/Down/PageUp/PageDown/Home/End delegate directly to the
|
|
1752
|
+
// editor's built-in motion actions via `executeAction`. The plugin only
|
|
1753
|
+
// observes `cursor_moved` events to repaint the cursor-line overlay and
|
|
1754
|
+
// keep `state.diffCursorRow` in sync.
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Derive the "current file" (FileEntry) from the cursor row in the unified
|
|
1758
|
+
* diff stream — the file whose header row is the largest one ≤ the cursor
|
|
1759
|
+
* row. Returns null if no file header is at or above the cursor (cursor
|
|
1760
|
+
* sits in the empty preamble or there are no files).
|
|
1761
|
+
*/
|
|
1762
|
+
function currentFileFromCursor(): FileEntry | null {
|
|
1763
|
+
let bestFile: FileEntry | null = null;
|
|
1764
|
+
let bestRow = 0;
|
|
1765
|
+
for (const f of state.files) {
|
|
1766
|
+
const row = state.fileHeaderRows[fileKey(f)];
|
|
1767
|
+
if (row !== undefined && row <= state.diffCursorRow && row > bestRow) {
|
|
1768
|
+
bestRow = row;
|
|
1769
|
+
bestFile = f;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return bestFile;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/** Look up the entry's properties for the cursor's current row. Uses
|
|
1776
|
+
* the per-row props map populated during build, which is exact —
|
|
1777
|
+
* unlike `editor.getTextPropertiesAtCursor`, which can return the
|
|
1778
|
+
* previous row's properties when the cursor sits at a row boundary. */
|
|
1779
|
+
function propsAtCursorRow(): Record<string, unknown> | null {
|
|
1780
|
+
return state.entryPropsByRow[state.diffCursorRow] || null;
|
|
1781
|
+
}
|
|
952
1782
|
|
|
953
|
-
function
|
|
954
|
-
|
|
1783
|
+
function sectionUnderCursor(): string | null {
|
|
1784
|
+
const props = propsAtCursorRow();
|
|
1785
|
+
if (!props || props["type"] !== 'section-header') return null;
|
|
1786
|
+
const filePath = props["filePath"];
|
|
1787
|
+
return typeof filePath === 'string' ? filePath : null;
|
|
955
1788
|
}
|
|
956
1789
|
|
|
957
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* Tab dispatches to the *nearest ancestor* of the cursor's row:
|
|
1792
|
+
* * Section header → toggle the section.
|
|
1793
|
+
* * File header → toggle the file.
|
|
1794
|
+
* * Anywhere inside a hunk (header, body, inline comment) → toggle
|
|
1795
|
+
* the hunk.
|
|
1796
|
+
* * Blank line above any file header (i.e. cursor inside a file's
|
|
1797
|
+
* diff before its first hunk) → toggle that file.
|
|
1798
|
+
* * Cursor in the comments panel → swap focus back to the diff.
|
|
1799
|
+
*/
|
|
1800
|
+
function review_toggle_file_collapse() {
|
|
958
1801
|
if (state.groupId === null) return;
|
|
959
|
-
|
|
1802
|
+
if (state.focusPanel === 'comments') {
|
|
1803
|
+
editor.focusBufferGroupPanel(state.groupId, "diff");
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
if (state.files.length === 0) return;
|
|
1807
|
+
|
|
1808
|
+
// Section header → toggle whole section.
|
|
1809
|
+
const section = sectionUnderCursor();
|
|
1810
|
+
if (section) {
|
|
1811
|
+
if (state.collapsedSections.has(section)) state.collapsedSections.delete(section);
|
|
1812
|
+
else state.collapsedSections.add(section);
|
|
1813
|
+
applyFolds();
|
|
1814
|
+
const sectionRow = state.sectionHeaderRows[section];
|
|
1815
|
+
if (sectionRow !== undefined) jumpDiffCursorToRow(sectionRow);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// File header → toggle whole file.
|
|
1820
|
+
const headerFile = fileHeaderUnderCursor();
|
|
1821
|
+
if (headerFile) {
|
|
1822
|
+
const key = fileKey(headerFile);
|
|
1823
|
+
if (state.collapsedFiles.has(key)) state.collapsedFiles.delete(key);
|
|
1824
|
+
else state.collapsedFiles.add(key);
|
|
1825
|
+
applyFolds();
|
|
1826
|
+
const headerRow = state.fileHeaderRows[key];
|
|
1827
|
+
if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// Hunk (header / body / inline comment) → toggle that hunk.
|
|
1832
|
+
const hunk = getHunkAtDiffCursor();
|
|
1833
|
+
if (hunk) {
|
|
1834
|
+
if (state.collapsedHunks.has(hunk.id)) state.collapsedHunks.delete(hunk.id);
|
|
1835
|
+
else state.collapsedHunks.add(hunk.id);
|
|
1836
|
+
applyFolds();
|
|
1837
|
+
const hunkRow = state.hunkRowByHunkId[hunk.id];
|
|
1838
|
+
if (hunkRow !== undefined) jumpDiffCursorToRow(hunkRow);
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Fall back to the parent file if cursor is in a no-man's-land (e.g.
|
|
1843
|
+
// blank separator after the last hunk of a file).
|
|
1844
|
+
const fallbackFile = currentFileFromCursor();
|
|
1845
|
+
if (!fallbackFile) return;
|
|
1846
|
+
const key = fileKey(fallbackFile);
|
|
1847
|
+
if (state.collapsedFiles.has(key)) state.collapsedFiles.delete(key);
|
|
1848
|
+
else state.collapsedFiles.add(key);
|
|
1849
|
+
applyFolds();
|
|
1850
|
+
const headerRow = state.fileHeaderRows[key];
|
|
1851
|
+
if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
|
|
1852
|
+
}
|
|
1853
|
+
registerHandler("review_toggle_file_collapse", review_toggle_file_collapse);
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* Order comments the same way the comments panel does — by file order
|
|
1857
|
+
* in the unified stream, then by line number. Keeping the ordering
|
|
1858
|
+
* here in sync with `buildCommentsPanelEntries` is important so that
|
|
1859
|
+
* keyboard navigation lands on the same row the user sees.
|
|
1860
|
+
*
|
|
1861
|
+
* Builds an O(F) path -> index map once per call instead of doing a
|
|
1862
|
+
* linear scan of state.files for every comment in the sort comparator.
|
|
1863
|
+
*/
|
|
1864
|
+
function commentsInPanelOrder(): ReviewComment[] {
|
|
1865
|
+
const fileIdx: Record<string, number> = {};
|
|
1866
|
+
for (let i = 0; i < state.files.length; i++) fileIdx[state.files[i].path] = i;
|
|
1867
|
+
return [...state.comments].sort((a, b) => {
|
|
1868
|
+
const fa = fileIdx[a.file] ?? Number.MAX_SAFE_INTEGER;
|
|
1869
|
+
const fb = fileIdx[b.file] ?? Number.MAX_SAFE_INTEGER;
|
|
1870
|
+
if (fa !== fb) return fa - fb;
|
|
1871
|
+
return (a.new_line ?? a.old_line ?? 0) - (b.new_line ?? b.old_line ?? 0);
|
|
1872
|
+
});
|
|
960
1873
|
}
|
|
961
1874
|
|
|
962
|
-
function
|
|
963
|
-
if (
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1875
|
+
function selectAndJumpToComment(c: ReviewComment) {
|
|
1876
|
+
if (state.groupId === null) return;
|
|
1877
|
+
jumpToComment(c.id);
|
|
1878
|
+
// Find the comment's row in the panel (header is row 1, comments start at 2).
|
|
1879
|
+
const sorted = commentsInPanelOrder();
|
|
1880
|
+
const idx = sorted.findIndex(x => x.id === c.id);
|
|
1881
|
+
if (idx >= 0) {
|
|
1882
|
+
state.commentsSelectedRow = idx + 2;
|
|
1883
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1884
|
+
}
|
|
969
1885
|
}
|
|
970
1886
|
|
|
971
|
-
function
|
|
972
|
-
if (
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
editor.executeAction("move_up");
|
|
1887
|
+
function review_next_comment() {
|
|
1888
|
+
if (state.comments.length === 0) {
|
|
1889
|
+
editor.setStatus(editor.t("status.no_comments") || "No comments");
|
|
1890
|
+
return;
|
|
976
1891
|
}
|
|
1892
|
+
const sorted = commentsInPanelOrder();
|
|
1893
|
+
// Determine the comment-id currently under the diff cursor (if any).
|
|
1894
|
+
const currentRow = state.commentsSelectedRow;
|
|
1895
|
+
const currentIdx = currentRow >= 2 ? currentRow - 2 : -1;
|
|
1896
|
+
const nextIdx = Math.min(sorted.length - 1, currentIdx + 1);
|
|
1897
|
+
if (nextIdx === currentIdx && currentIdx >= 0) return;
|
|
1898
|
+
selectAndJumpToComment(sorted[nextIdx >= 0 ? nextIdx : 0]);
|
|
977
1899
|
}
|
|
978
|
-
registerHandler("
|
|
1900
|
+
registerHandler("review_next_comment", review_next_comment);
|
|
979
1901
|
|
|
980
|
-
function
|
|
981
|
-
if (
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
editor.executeAction("move_down");
|
|
1902
|
+
function review_prev_comment() {
|
|
1903
|
+
if (state.comments.length === 0) {
|
|
1904
|
+
editor.setStatus(editor.t("status.no_comments") || "No comments");
|
|
1905
|
+
return;
|
|
985
1906
|
}
|
|
1907
|
+
const sorted = commentsInPanelOrder();
|
|
1908
|
+
const currentRow = state.commentsSelectedRow;
|
|
1909
|
+
const currentIdx = currentRow >= 2 ? currentRow - 2 : sorted.length;
|
|
1910
|
+
const prevIdx = Math.max(0, currentIdx - 1);
|
|
1911
|
+
selectAndJumpToComment(sorted[prevIdx]);
|
|
986
1912
|
}
|
|
987
|
-
registerHandler("
|
|
1913
|
+
registerHandler("review_prev_comment", review_prev_comment);
|
|
988
1914
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1915
|
+
/**
|
|
1916
|
+
* Focus the comments panel. Uses native focus-swap so the buffer's
|
|
1917
|
+
* native cursor takes the keystrokes (j/k/Enter handled by the
|
|
1918
|
+
* comments-mode keybindings).
|
|
1919
|
+
*/
|
|
1920
|
+
function review_focus_comments() {
|
|
1921
|
+
if (state.groupId === null) return;
|
|
1922
|
+
editor.focusBufferGroupPanel(state.groupId, "comments");
|
|
1923
|
+
// Ensure the selection highlight shows immediately.
|
|
1924
|
+
if (state.commentsSelectedRow < 2 && state.comments.length > 0) {
|
|
1925
|
+
state.commentsSelectedRow = 2;
|
|
995
1926
|
}
|
|
1927
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
996
1928
|
}
|
|
997
|
-
registerHandler("
|
|
1929
|
+
registerHandler("review_focus_comments", review_focus_comments);
|
|
998
1930
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1931
|
+
/**
|
|
1932
|
+
* Activate the currently-selected comment in the comments panel:
|
|
1933
|
+
* jump the diff cursor to it (auto-expanding the file if collapsed).
|
|
1934
|
+
*/
|
|
1935
|
+
function review_open_selected_comment() {
|
|
1936
|
+
if (state.commentsSelectedRow < 2) return;
|
|
1937
|
+
const commentId = state.commentsByRow[state.commentsSelectedRow];
|
|
1938
|
+
if (!commentId) return;
|
|
1939
|
+
jumpToComment(commentId);
|
|
1940
|
+
}
|
|
1941
|
+
registerHandler("review_open_selected_comment", review_open_selected_comment);
|
|
1942
|
+
|
|
1943
|
+
function review_comments_select_next() {
|
|
1944
|
+
if (state.groupId === null) return;
|
|
1945
|
+
if (state.comments.length === 0) return;
|
|
1946
|
+
const total = state.comments.length;
|
|
1947
|
+
const currentIdx = Math.max(0, state.commentsSelectedRow - 2);
|
|
1948
|
+
const nextIdx = Math.min(total - 1, currentIdx + 1);
|
|
1949
|
+
state.commentsSelectedRow = nextIdx + 2;
|
|
1950
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1951
|
+
}
|
|
1952
|
+
registerHandler("review_comments_select_next", review_comments_select_next);
|
|
1953
|
+
|
|
1954
|
+
function review_enter_dispatch() {
|
|
1955
|
+
if (state.focusPanel === 'comments') {
|
|
1956
|
+
review_open_selected_comment();
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const props = propsAtCursorRow();
|
|
1960
|
+
if (!props) return;
|
|
1961
|
+
const t = props["type"];
|
|
1962
|
+
// On a file or section header, Enter doubles as Tab: toggle the
|
|
1963
|
+
// header's collapse state. Matches the intuition that a header is a
|
|
1964
|
+
// disclosure widget — pressing the primary key on it should expand
|
|
1965
|
+
// or fold the thing it owns, not drill down.
|
|
1966
|
+
if (t === 'file-header' || t === 'section-header') {
|
|
1967
|
+
review_toggle_file_collapse();
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
// Inside a file's diff content, drill down to side-by-side view.
|
|
1971
|
+
// Blank separators and comment rows are quietly ignored to avoid
|
|
1972
|
+
// drilling into whatever file the cursor happens to be adjacent to.
|
|
1973
|
+
if (t === 'add' || t === 'remove' || t === 'context' || t === 'hunk-header') {
|
|
1974
|
+
review_drill_down();
|
|
1005
1975
|
}
|
|
1006
1976
|
}
|
|
1007
|
-
registerHandler("
|
|
1977
|
+
registerHandler("review_enter_dispatch", review_enter_dispatch);
|
|
1008
1978
|
|
|
1009
|
-
function
|
|
1010
|
-
if (
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1979
|
+
function review_comments_select_prev() {
|
|
1980
|
+
if (state.groupId === null) return;
|
|
1981
|
+
if (state.comments.length === 0) return;
|
|
1982
|
+
const currentIdx = Math.max(0, state.commentsSelectedRow - 2);
|
|
1983
|
+
const prevIdx = Math.max(0, currentIdx - 1);
|
|
1984
|
+
state.commentsSelectedRow = prevIdx + 2;
|
|
1985
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1986
|
+
}
|
|
1987
|
+
registerHandler("review_comments_select_prev", review_comments_select_prev);
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* Visual line-selection mode. Activates a multi-row selection rooted
|
|
1991
|
+
* at the cursor's hunk; j/k extend it; Esc cancels. The selection is
|
|
1992
|
+
* rendered as an inverted background overlay across the selected rows.
|
|
1993
|
+
*/
|
|
1994
|
+
function review_visual_start() {
|
|
1995
|
+
if (state.groupId === null) return;
|
|
1996
|
+
const props = propsAtCursorRow();
|
|
1997
|
+
if (!props) return;
|
|
1998
|
+
const hunkId = props["hunkId"];
|
|
1999
|
+
const lineType = props["lineType"];
|
|
2000
|
+
if (typeof hunkId !== 'string' || (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context')) {
|
|
2001
|
+
editor.setStatus(editor.t("status.visual_no_diff_line") || "Visual selection requires a diff line");
|
|
2002
|
+
return;
|
|
1014
2003
|
}
|
|
2004
|
+
state.lineSelection = {
|
|
2005
|
+
startRow: state.diffCursorRow,
|
|
2006
|
+
endRow: state.diffCursorRow,
|
|
2007
|
+
hunkId,
|
|
2008
|
+
};
|
|
2009
|
+
paintLineSelectionOverlay();
|
|
2010
|
+
editor.setStatus(editor.t("status.visual_started") || "Visual: j/k extend, s/u/d apply, Esc cancel");
|
|
1015
2011
|
}
|
|
1016
|
-
registerHandler("
|
|
2012
|
+
registerHandler("review_visual_start", review_visual_start);
|
|
1017
2013
|
|
|
1018
|
-
function
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
editor.
|
|
2014
|
+
function review_visual_cancel() {
|
|
2015
|
+
state.lineSelection = null;
|
|
2016
|
+
if (state.groupId !== null) {
|
|
2017
|
+
const diffId = state.panelBuffers["diff"];
|
|
2018
|
+
if (diffId !== undefined) editor.clearNamespace(diffId, "review-line-selection");
|
|
1023
2019
|
}
|
|
2020
|
+
applyCursorLineOverlay('diff');
|
|
1024
2021
|
}
|
|
1025
|
-
registerHandler("
|
|
2022
|
+
registerHandler("review_visual_cancel", review_visual_cancel);
|
|
2023
|
+
|
|
2024
|
+
const LINE_SELECTION_NS = "review-line-selection";
|
|
1026
2025
|
|
|
1027
|
-
function
|
|
2026
|
+
function paintLineSelectionOverlay() {
|
|
1028
2027
|
if (state.groupId === null) return;
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
editor.
|
|
1032
|
-
|
|
1033
|
-
|
|
2028
|
+
const diffId = state.panelBuffers["diff"];
|
|
2029
|
+
if (diffId === undefined) return;
|
|
2030
|
+
editor.clearNamespace(diffId, LINE_SELECTION_NS);
|
|
2031
|
+
if (!state.lineSelection) return;
|
|
2032
|
+
const { startRow, endRow } = state.lineSelection;
|
|
2033
|
+
const lo = Math.min(startRow, endRow);
|
|
2034
|
+
const hi = Math.max(startRow, endRow);
|
|
2035
|
+
for (let r = lo; r <= hi; r++) {
|
|
2036
|
+
const idx = r - 1;
|
|
2037
|
+
if (idx < 0 || idx + 1 >= state.diffLineByteOffsets.length) continue;
|
|
2038
|
+
const start = state.diffLineByteOffsets[idx];
|
|
2039
|
+
const end = state.diffLineByteOffsets[idx + 1];
|
|
2040
|
+
if (end <= start) continue;
|
|
2041
|
+
editor.addOverlay(diffId, LINE_SELECTION_NS, start, end, {
|
|
2042
|
+
bg: STYLE_SELECTED_BG,
|
|
2043
|
+
extendToLineEnd: true,
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
1034
2046
|
}
|
|
1035
|
-
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* Translate the active line-selection's (startRow, endRow) into a
|
|
2050
|
+
* lineRange (inclusive 0-indexed indices into `hunk.lines`) by walking
|
|
2051
|
+
* the rows of the unified stream that belong to the selection's hunk.
|
|
2052
|
+
*
|
|
2053
|
+
* Returns `null` if the selection crosses out of its hunk (which can't
|
|
2054
|
+
* happen given how j/k extend, but defensively guarded), or the hunk
|
|
2055
|
+
* can't be found, or the selection contains only context lines (which
|
|
2056
|
+
* makes stage/unstage a no-op).
|
|
2057
|
+
*/
|
|
2058
|
+
function selectionLineRange(): { hunk: Hunk; range: { start: number; end: number } } | null {
|
|
2059
|
+
if (!state.lineSelection) return null;
|
|
2060
|
+
const sel = state.lineSelection;
|
|
2061
|
+
const hunk = state.hunks.find(h => h.id === sel.hunkId);
|
|
2062
|
+
if (!hunk) return null;
|
|
2063
|
+
// Find the row of this hunk's header in the unified stream.
|
|
2064
|
+
const hunkIdx = state.hunks.indexOf(hunk);
|
|
2065
|
+
let visibleIdx = 0;
|
|
2066
|
+
for (let i = 0; i < hunkIdx; i++) {
|
|
2067
|
+
const h = state.hunks[i];
|
|
2068
|
+
if (state.collapsedFiles.has(fileKeyOf(h.file, h.gitStatus || 'unstaged'))) continue;
|
|
2069
|
+
visibleIdx++;
|
|
2070
|
+
}
|
|
2071
|
+
const headerRow = state.hunkHeaderRows[visibleIdx];
|
|
2072
|
+
if (headerRow === undefined) return null;
|
|
2073
|
+
|
|
2074
|
+
const lo = Math.min(sel.startRow, sel.endRow);
|
|
2075
|
+
const hi = Math.max(sel.startRow, sel.endRow);
|
|
2076
|
+
const startInHunk = lo - headerRow - 1; // -1 because the header row itself is not in hunk.lines
|
|
2077
|
+
const endInHunk = hi - headerRow - 1;
|
|
2078
|
+
if (startInHunk < 0 || endInHunk >= hunk.lines.length) return null;
|
|
2079
|
+
|
|
2080
|
+
// Reject context-only selections.
|
|
2081
|
+
let hasChange = false;
|
|
2082
|
+
for (let i = startInHunk; i <= endInHunk; i++) {
|
|
2083
|
+
const ch = hunk.lines[i][0];
|
|
2084
|
+
if (ch === '+' || ch === '-') { hasChange = true; break; }
|
|
2085
|
+
}
|
|
2086
|
+
if (!hasChange) return null;
|
|
2087
|
+
|
|
2088
|
+
return { hunk, range: { start: startInHunk, end: endInHunk } };
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
async function applyLineSelection(action: 'stage' | 'unstage' | 'discard') {
|
|
2092
|
+
const sel = selectionLineRange();
|
|
2093
|
+
if (!sel) {
|
|
2094
|
+
editor.setStatus(editor.t("status.visual_invalid") || "Selection has no add/remove lines or crosses hunk boundary");
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
const { hunk, range } = sel;
|
|
2098
|
+
const patch = buildHunkPatch(hunk.file, hunk, range);
|
|
2099
|
+
let flags: string[];
|
|
2100
|
+
if (action === 'stage') flags = ["--cached", "--unidiff-zero"];
|
|
2101
|
+
else if (action === 'unstage') flags = ["--cached", "--reverse", "--unidiff-zero"];
|
|
2102
|
+
else flags = ["--reverse", "--unidiff-zero"];
|
|
2103
|
+
|
|
2104
|
+
rememberPendingHunkAnchor(hunk.id);
|
|
2105
|
+
const ok = await applyHunkPatch(patch, flags);
|
|
2106
|
+
if (!ok) return;
|
|
2107
|
+
review_visual_cancel();
|
|
2108
|
+
editor.setStatus(editor.t(`status.lines_${action}d`) || `Lines ${action}d`);
|
|
2109
|
+
await refreshMagitData();
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function review_collapse_all() {
|
|
2113
|
+
// Remember which file the cursor is in so we can land on its
|
|
2114
|
+
// header row after every file collapses.
|
|
2115
|
+
const cur = currentFileFromCursor();
|
|
2116
|
+
state.collapsedFiles = new Set(state.files.map(fileKey));
|
|
2117
|
+
applyFolds();
|
|
2118
|
+
if (cur) {
|
|
2119
|
+
const headerRow = state.fileHeaderRows[fileKey(cur)];
|
|
2120
|
+
if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
registerHandler("review_collapse_all", review_collapse_all);
|
|
2124
|
+
|
|
2125
|
+
function review_expand_all() {
|
|
2126
|
+
// Same intuition for unfold-all: keep the cursor on the file it was
|
|
2127
|
+
// in (rows shift as collapsed files/hunks re-emit their content).
|
|
2128
|
+
const cur = currentFileFromCursor();
|
|
2129
|
+
state.collapsedFiles.clear();
|
|
2130
|
+
state.collapsedSections.clear();
|
|
2131
|
+
state.collapsedHunks.clear();
|
|
2132
|
+
applyFolds();
|
|
2133
|
+
if (cur) {
|
|
2134
|
+
const headerRow = state.fileHeaderRows[fileKey(cur)];
|
|
2135
|
+
if (headerRow !== undefined) jumpDiffCursorToRow(headerRow);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
registerHandler("review_expand_all", review_expand_all);
|
|
2139
|
+
|
|
2140
|
+
function review_nav_up() {
|
|
2141
|
+
if (state.focusPanel === 'comments') { review_comments_select_prev(); return; }
|
|
2142
|
+
editor.executeAction("move_up");
|
|
2143
|
+
if (state.lineSelection) {
|
|
2144
|
+
// executeAction has already moved the cursor; sync the selection.
|
|
2145
|
+
// Ensure we don't extend out of the hunk.
|
|
2146
|
+
const newRow = Math.max(1, state.lineSelection.endRow - 1);
|
|
2147
|
+
state.lineSelection.endRow = newRow;
|
|
2148
|
+
paintLineSelectionOverlay();
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
registerHandler("review_nav_up", review_nav_up);
|
|
2152
|
+
|
|
2153
|
+
function review_nav_down() {
|
|
2154
|
+
if (state.focusPanel === 'comments') { review_comments_select_next(); return; }
|
|
2155
|
+
editor.executeAction("move_down");
|
|
2156
|
+
if (state.lineSelection) {
|
|
2157
|
+
state.lineSelection.endRow = state.lineSelection.endRow + 1;
|
|
2158
|
+
paintLineSelectionOverlay();
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
registerHandler("review_nav_down", review_nav_down);
|
|
2162
|
+
|
|
2163
|
+
function review_page_up() { editor.executeAction("move_page_up"); }
|
|
2164
|
+
registerHandler("review_page_up", review_page_up);
|
|
2165
|
+
|
|
2166
|
+
function review_page_down() { editor.executeAction("move_page_down"); }
|
|
2167
|
+
registerHandler("review_page_down", review_page_down);
|
|
2168
|
+
// Home / End intentionally NOT overridden — the editor's native
|
|
2169
|
+
// "move to start/end of line" is exactly what we want here. Mapping
|
|
2170
|
+
// them to move_document_start/end (as the old layout did when Home/
|
|
2171
|
+
// End served as files-pane shortcuts) made them useless on a unified
|
|
2172
|
+
// stream.
|
|
1036
2173
|
|
|
1037
2174
|
// --- Real git stage/unstage/discard actions (Step 4) ---
|
|
1038
2175
|
|
|
1039
2176
|
/**
|
|
1040
2177
|
* Build a minimal unified diff patch for a single hunk.
|
|
2178
|
+
*
|
|
2179
|
+
* When `lineRange` is provided, only the +/- lines whose indices fall
|
|
2180
|
+
* inside the inclusive range are kept; +/- lines outside the range are
|
|
2181
|
+
* converted to context lines so that the patch still applies cleanly
|
|
2182
|
+
* to the file. Context lines are always preserved.
|
|
1041
2183
|
*/
|
|
1042
|
-
function buildHunkPatch(filePath: string, hunk: Hunk): string {
|
|
1043
|
-
const
|
|
1044
|
-
|
|
2184
|
+
function buildHunkPatch(filePath: string, hunk: Hunk, lineRange?: { start: number; end: number }): string {
|
|
2185
|
+
const filtered: string[] = [];
|
|
2186
|
+
let oldCount = 0;
|
|
2187
|
+
let newCount = 0;
|
|
2188
|
+
|
|
2189
|
+
for (let i = 0; i < hunk.lines.length; i++) {
|
|
2190
|
+
const line = hunk.lines[i];
|
|
2191
|
+
const ch = line[0];
|
|
2192
|
+
const inRange = !lineRange || (i >= lineRange.start && i <= lineRange.end);
|
|
2193
|
+
if (ch === '+') {
|
|
2194
|
+
if (inRange) {
|
|
2195
|
+
filtered.push(line);
|
|
2196
|
+
newCount++;
|
|
2197
|
+
} else {
|
|
2198
|
+
// An out-of-range '+' line means: this addition isn't being
|
|
2199
|
+
// applied, so it shouldn't appear in either side. Drop it
|
|
2200
|
+
// entirely (don't convert to context — there's nothing to
|
|
2201
|
+
// match in the source file).
|
|
2202
|
+
}
|
|
2203
|
+
} else if (ch === '-') {
|
|
2204
|
+
if (inRange) {
|
|
2205
|
+
filtered.push(line);
|
|
2206
|
+
oldCount++;
|
|
2207
|
+
} else {
|
|
2208
|
+
// An out-of-range '-' line: this deletion isn't applied,
|
|
2209
|
+
// so the line still exists on both sides — render as context.
|
|
2210
|
+
filtered.push(' ' + line.substring(1));
|
|
2211
|
+
oldCount++;
|
|
2212
|
+
newCount++;
|
|
2213
|
+
}
|
|
2214
|
+
} else {
|
|
2215
|
+
filtered.push(line);
|
|
2216
|
+
oldCount++;
|
|
2217
|
+
newCount++;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
1045
2221
|
const header = `@@ -${hunk.oldRange.start},${oldCount} +${hunk.range.start},${newCount} @@`;
|
|
1046
2222
|
return [
|
|
1047
2223
|
`diff --git a/${filePath} b/${filePath}`,
|
|
1048
2224
|
`--- a/${filePath}`,
|
|
1049
2225
|
`+++ b/${filePath}`,
|
|
1050
2226
|
header,
|
|
1051
|
-
...
|
|
2227
|
+
...filtered,
|
|
1052
2228
|
''
|
|
1053
2229
|
].join('\n');
|
|
1054
2230
|
}
|
|
@@ -1095,73 +2271,170 @@ function readPropsAtCursor(panel: 'files' | 'diff'): Record<string, unknown> | n
|
|
|
1095
2271
|
* something useful.
|
|
1096
2272
|
*/
|
|
1097
2273
|
function getHunkAtDiffCursor(): Hunk | null {
|
|
1098
|
-
const props =
|
|
2274
|
+
const props = propsAtCursorRow();
|
|
1099
2275
|
const hunkId = props ? props["hunkId"] : undefined;
|
|
1100
2276
|
if (typeof hunkId === 'string') {
|
|
1101
2277
|
const found = state.hunks.find(h => h.id === hunkId);
|
|
1102
2278
|
if (found) return found;
|
|
1103
2279
|
}
|
|
1104
|
-
// Fallback: first hunk for the
|
|
1105
|
-
const
|
|
1106
|
-
if (!
|
|
2280
|
+
// Fallback: first hunk for the file under the cursor (if any).
|
|
2281
|
+
const cur = currentFileFromCursor();
|
|
2282
|
+
if (!cur) return null;
|
|
1107
2283
|
return state.hunks.find(
|
|
1108
|
-
h => h.file ===
|
|
2284
|
+
h => h.file === cur.path && h.gitStatus === cur.category
|
|
1109
2285
|
) || null;
|
|
1110
2286
|
}
|
|
1111
2287
|
|
|
1112
|
-
|
|
2288
|
+
/**
|
|
2289
|
+
* Determine if the cursor is on a file-header row. Returns the FileEntry
|
|
2290
|
+
* if so, otherwise null.
|
|
2291
|
+
*
|
|
2292
|
+
* Looks up by `fileKey` (path + category) — looking up by `path` alone
|
|
2293
|
+
* is wrong when the same file appears in both Staged and Unstaged: the
|
|
2294
|
+
* `state.files.find(... === path)` would always return the first
|
|
2295
|
+
* matching entry (typically the staged one), so Tab on the unstaged
|
|
2296
|
+
* file header would silently act on the staged file instead.
|
|
2297
|
+
*/
|
|
2298
|
+
function fileHeaderUnderCursor(): FileEntry | null {
|
|
2299
|
+
const props = propsAtCursorRow();
|
|
2300
|
+
if (!props || props["type"] !== 'file-header') return null;
|
|
2301
|
+
const key = props["fileKey"];
|
|
2302
|
+
if (typeof key !== 'string') return null;
|
|
2303
|
+
return state.files.find(f => fileKey(f) === key) || null;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* Stage at the appropriate scope based on cursor context:
|
|
2308
|
+
* * file header → stage the whole file
|
|
2309
|
+
* * hunk → stage just that hunk
|
|
2310
|
+
*/
|
|
2311
|
+
async function review_stage_scope() {
|
|
1113
2312
|
if (state.files.length === 0) return;
|
|
1114
|
-
if (state.
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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();
|
|
2313
|
+
if (state.lineSelection) { await applyLineSelection('stage'); return; }
|
|
2314
|
+
const headerFile = fileHeaderUnderCursor();
|
|
2315
|
+
if (headerFile) {
|
|
2316
|
+
await stageFileEntry(headerFile);
|
|
1127
2317
|
return;
|
|
1128
2318
|
}
|
|
1129
|
-
|
|
2319
|
+
await stageHunk(getHunkAtDiffCursor());
|
|
2320
|
+
}
|
|
2321
|
+
registerHandler("review_stage_scope", review_stage_scope);
|
|
2322
|
+
|
|
2323
|
+
async function review_unstage_scope() {
|
|
2324
|
+
if (state.files.length === 0) return;
|
|
2325
|
+
if (state.lineSelection) { await applyLineSelection('unstage'); return; }
|
|
2326
|
+
const headerFile = fileHeaderUnderCursor();
|
|
2327
|
+
if (headerFile) {
|
|
2328
|
+
await unstageFileEntry(headerFile);
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
await unstageHunk(getHunkAtDiffCursor());
|
|
2332
|
+
}
|
|
2333
|
+
registerHandler("review_unstage_scope", review_unstage_scope);
|
|
2334
|
+
|
|
2335
|
+
/**
|
|
2336
|
+
* Always-file-level staging (S / U). Acts on the file the cursor is
|
|
2337
|
+
* currently inside, regardless of whether it's on a header or a hunk.
|
|
2338
|
+
*/
|
|
2339
|
+
async function review_stage_file() {
|
|
2340
|
+
if (state.files.length === 0) return;
|
|
2341
|
+
const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
|
|
1130
2342
|
if (!f) return;
|
|
1131
|
-
await
|
|
1132
|
-
await refreshMagitData();
|
|
2343
|
+
await stageFileEntry(f);
|
|
1133
2344
|
}
|
|
1134
2345
|
registerHandler("review_stage_file", review_stage_file);
|
|
1135
2346
|
|
|
1136
2347
|
async function review_unstage_file() {
|
|
1137
2348
|
if (state.files.length === 0) return;
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
2349
|
+
const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
|
|
2350
|
+
if (!f) return;
|
|
2351
|
+
await unstageFileEntry(f);
|
|
2352
|
+
}
|
|
2353
|
+
registerHandler("review_unstage_file", review_unstage_file);
|
|
2354
|
+
|
|
2355
|
+
async function stageFileEntry(f: FileEntry) {
|
|
2356
|
+
rememberPendingHunkAnchor(null);
|
|
2357
|
+
await editor.spawnProcess("git", ["add", "--", f.path]);
|
|
2358
|
+
await refreshMagitData();
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
async function unstageFileEntry(f: FileEntry) {
|
|
2362
|
+
rememberPendingHunkAnchor(null);
|
|
2363
|
+
await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
|
|
2364
|
+
await refreshMagitData();
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
async function stageHunk(hunk: Hunk | null) {
|
|
2368
|
+
if (!hunk || !hunk.file) return;
|
|
2369
|
+
rememberPendingHunkAnchor(hunk.id);
|
|
2370
|
+
if (hunk.gitStatus === 'untracked') {
|
|
2371
|
+
await editor.spawnProcess("git", ["add", "--", hunk.file]);
|
|
2372
|
+
} else {
|
|
1145
2373
|
const patch = buildHunkPatch(hunk.file, hunk);
|
|
1146
|
-
const ok = await applyHunkPatch(patch, ["--cached"
|
|
2374
|
+
const ok = await applyHunkPatch(patch, ["--cached"]);
|
|
1147
2375
|
if (!ok) return;
|
|
1148
|
-
|
|
1149
|
-
|
|
2376
|
+
}
|
|
2377
|
+
editor.setStatus(editor.t("status.hunk_staged") || "Hunk staged");
|
|
2378
|
+
await refreshMagitData();
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
async function unstageHunk(hunk: Hunk | null) {
|
|
2382
|
+
if (!hunk || !hunk.file || hunk.gitStatus !== 'staged') {
|
|
2383
|
+
editor.setStatus("Can only unstage staged hunks");
|
|
1150
2384
|
return;
|
|
1151
2385
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
await
|
|
2386
|
+
rememberPendingHunkAnchor(hunk.id);
|
|
2387
|
+
const patch = buildHunkPatch(hunk.file, hunk);
|
|
2388
|
+
const ok = await applyHunkPatch(patch, ["--cached", "--reverse"]);
|
|
2389
|
+
if (!ok) return;
|
|
2390
|
+
editor.setStatus(editor.t("status.hunk_unstaged") || "Hunk unstaged");
|
|
1155
2391
|
await refreshMagitData();
|
|
1156
2392
|
}
|
|
1157
|
-
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* Cursor continuity: remember the hunk-id we just acted on so that
|
|
2396
|
+
* after the rebuild we can land the cursor back on the same hunk
|
|
2397
|
+
* (which may have moved between sections), or on the nearest survivor.
|
|
2398
|
+
*/
|
|
2399
|
+
let pendingHunkAnchor: { hunkId: string | null; section: string | null; row: number } | null = null;
|
|
2400
|
+
function rememberPendingHunkAnchor(hunkId: string | null) {
|
|
2401
|
+
const cur = getHunkAtDiffCursor();
|
|
2402
|
+
pendingHunkAnchor = {
|
|
2403
|
+
hunkId,
|
|
2404
|
+
section: cur?.gitStatus ?? null,
|
|
2405
|
+
row: state.diffCursorRow,
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
let pendingDiscardFile: FileEntry | null = null;
|
|
2410
|
+
|
|
2411
|
+
/** Always-file-level discard (D). Acts on the file the cursor is in. */
|
|
2412
|
+
function review_discard_file_only() {
|
|
2413
|
+
if (state.files.length === 0) return;
|
|
2414
|
+
const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
|
|
2415
|
+
if (!f) return;
|
|
2416
|
+
pendingDiscardFile = f;
|
|
2417
|
+
rememberPendingHunkAnchor(null);
|
|
2418
|
+
const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
|
|
2419
|
+
editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
|
|
2420
|
+
const suggestions: PromptSuggestion[] = [
|
|
2421
|
+
{ text: `${action} file`, description: "Permanently lose changes", value: "discard" },
|
|
2422
|
+
{ text: "Cancel", description: "Keep the file as-is", value: "cancel" },
|
|
2423
|
+
];
|
|
2424
|
+
editor.setPromptSuggestions(suggestions);
|
|
2425
|
+
}
|
|
2426
|
+
registerHandler("review_discard_file_only", review_discard_file_only);
|
|
1158
2427
|
|
|
1159
2428
|
function review_discard_file() {
|
|
1160
2429
|
if (state.files.length === 0) return;
|
|
1161
|
-
if (state.
|
|
1162
|
-
|
|
2430
|
+
if (state.lineSelection) { void applyLineSelection('discard'); return; }
|
|
2431
|
+
const headerFile = fileHeaderUnderCursor();
|
|
2432
|
+
const f = headerFile ?? currentFileFromCursor();
|
|
2433
|
+
if (!headerFile) {
|
|
2434
|
+
// No file-header under cursor → hunk-level discard
|
|
1163
2435
|
const hunk = getHunkAtDiffCursor();
|
|
1164
2436
|
if (!hunk || !hunk.file) return;
|
|
2437
|
+
rememberPendingHunkAnchor(hunk.id);
|
|
1165
2438
|
editor.startPrompt(
|
|
1166
2439
|
editor.t("prompt.discard_hunk", { file: hunk.file }) ||
|
|
1167
2440
|
`Discard this hunk in "${hunk.file}"? This cannot be undone.`,
|
|
@@ -1174,10 +2447,11 @@ function review_discard_file() {
|
|
|
1174
2447
|
editor.setPromptSuggestions(suggestions);
|
|
1175
2448
|
return;
|
|
1176
2449
|
}
|
|
1177
|
-
const f = state.files[state.selectedIndex];
|
|
1178
2450
|
if (!f) return;
|
|
1179
2451
|
|
|
1180
2452
|
// Show confirmation prompt — discard is destructive and irreversible
|
|
2453
|
+
pendingDiscardFile = f;
|
|
2454
|
+
rememberPendingHunkAnchor(null);
|
|
1181
2455
|
const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
|
|
1182
2456
|
editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
|
|
1183
2457
|
const suggestions: PromptSuggestion[] = [
|
|
@@ -1213,7 +2487,7 @@ async function on_review_discard_confirm(args: { prompt_type: string; input: str
|
|
|
1213
2487
|
|
|
1214
2488
|
const response = args.input.trim().toLowerCase();
|
|
1215
2489
|
if (response === "discard" || args.selected_index === 0) {
|
|
1216
|
-
const f =
|
|
2490
|
+
const f = pendingDiscardFile;
|
|
1217
2491
|
if (f) {
|
|
1218
2492
|
if (f.category === 'untracked') {
|
|
1219
2493
|
await editor.spawnProcess("rm", ["--", f.path]);
|
|
@@ -1226,6 +2500,7 @@ async function on_review_discard_confirm(args: { prompt_type: string; input: str
|
|
|
1226
2500
|
} else {
|
|
1227
2501
|
editor.setStatus("Discard cancelled");
|
|
1228
2502
|
}
|
|
2503
|
+
pendingDiscardFile = null;
|
|
1229
2504
|
return false;
|
|
1230
2505
|
}
|
|
1231
2506
|
registerHandler("on_review_discard_confirm", on_review_discard_confirm);
|
|
@@ -1234,16 +2509,51 @@ registerHandler("on_review_discard_confirm", on_review_discard_confirm);
|
|
|
1234
2509
|
* Refresh file list and diffs using the new git status approach, then re-render.
|
|
1235
2510
|
*/
|
|
1236
2511
|
async function refreshMagitData() {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
2512
|
+
if (state.mode === 'range' && state.range) {
|
|
2513
|
+
const { hunks, files } = await fetchRangeDiff(state.range);
|
|
2514
|
+
state.hunks = hunks;
|
|
2515
|
+
state.files = files;
|
|
2516
|
+
state.emptyState = null;
|
|
2517
|
+
} else {
|
|
2518
|
+
const status = await getGitStatus();
|
|
2519
|
+
state.files = status.files;
|
|
2520
|
+
state.emptyState = status.emptyReason;
|
|
2521
|
+
state.hunks = await fetchDiffsForFiles(status.files);
|
|
2522
|
+
}
|
|
2523
|
+
state.diffCursorRow = 1;
|
|
2524
|
+
updateMagitDisplay();
|
|
2525
|
+
restoreCursorAfterRebuild();
|
|
2526
|
+
updateReviewStatus();
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* After a rebuild caused by stage/unstage/discard, try to land the cursor
|
|
2531
|
+
* back on the same hunk (now possibly in a different section), or the
|
|
2532
|
+
* nearest survivor in the original section, or the first hunk overall.
|
|
2533
|
+
*/
|
|
2534
|
+
function restoreCursorAfterRebuild() {
|
|
2535
|
+
const anchor = pendingHunkAnchor;
|
|
2536
|
+
pendingHunkAnchor = null;
|
|
2537
|
+
if (!anchor) return;
|
|
2538
|
+
if (anchor.hunkId) {
|
|
2539
|
+
// Find the hunk by id in the new state.
|
|
2540
|
+
const found = state.hunks.findIndex(h => h.id === anchor.hunkId);
|
|
2541
|
+
if (found >= 0) {
|
|
2542
|
+
// Compute its visible row (auto-expanding if needed).
|
|
2543
|
+
jumpToGlobalHunk(found);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
1243
2546
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
2547
|
+
// Hunk vanished — fall back to the next hunk in the same section,
|
|
2548
|
+
// else the previous one, else the first hunk overall.
|
|
2549
|
+
if (anchor.section) {
|
|
2550
|
+
const idx = state.hunks.findIndex(h => h.gitStatus === anchor.section);
|
|
2551
|
+
if (idx >= 0) {
|
|
2552
|
+
jumpToGlobalHunk(idx);
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
if (state.hunks.length > 0) jumpToGlobalHunk(0);
|
|
1247
2557
|
}
|
|
1248
2558
|
|
|
1249
2559
|
// --- Resize handler ---
|
|
@@ -1267,8 +2577,6 @@ function refreshViewportDimensions(): boolean {
|
|
|
1267
2577
|
function onReviewDiffResize(_data: { width: number; height: number }): void {
|
|
1268
2578
|
if (state.reviewBufferId === null) return;
|
|
1269
2579
|
refreshViewportDimensions();
|
|
1270
|
-
// Invalidate cached diff entries — they were built for the old viewport width
|
|
1271
|
-
state.diffCache = {};
|
|
1272
2580
|
updateMagitDisplay();
|
|
1273
2581
|
}
|
|
1274
2582
|
registerHandler("onReviewDiffResize", onReviewDiffResize);
|
|
@@ -1546,15 +2854,15 @@ function generateDiffPaneContent(
|
|
|
1546
2854
|
// Line number color
|
|
1547
2855
|
highlights.push({
|
|
1548
2856
|
range: [currentByte + 2, currentByte + 6],
|
|
1549
|
-
fg:
|
|
2857
|
+
fg: "editor.line_number_fg",
|
|
1550
2858
|
});
|
|
1551
2859
|
|
|
1552
2860
|
if (isFiller) {
|
|
1553
2861
|
// Filler styling - extend to full line width
|
|
1554
2862
|
highlights.push({
|
|
1555
2863
|
range: [currentByte + prefixLen, currentByte + lineLen - 1],
|
|
1556
|
-
fg:
|
|
1557
|
-
bg:
|
|
2864
|
+
fg: "editor.line_number_fg",
|
|
2865
|
+
bg: "editor.line_number_bg",
|
|
1558
2866
|
extend_to_line_end: true
|
|
1559
2867
|
});
|
|
1560
2868
|
} else if (line.changeType === 'added' && side === 'new') {
|
|
@@ -1563,7 +2871,7 @@ function generateDiffPaneContent(
|
|
|
1563
2871
|
highlights.push({
|
|
1564
2872
|
range: [currentByte + prefixLen, currentByte + lineLen - 1],
|
|
1565
2873
|
fg: STYLE_ADD_TEXT,
|
|
1566
|
-
bg:
|
|
2874
|
+
bg: STYLE_ADD_BG,
|
|
1567
2875
|
extend_to_line_end: true
|
|
1568
2876
|
});
|
|
1569
2877
|
} else if (line.changeType === 'removed' && side === 'old') {
|
|
@@ -1572,7 +2880,7 @@ function generateDiffPaneContent(
|
|
|
1572
2880
|
highlights.push({
|
|
1573
2881
|
range: [currentByte + prefixLen, currentByte + lineLen - 1],
|
|
1574
2882
|
fg: STYLE_REMOVE_TEXT,
|
|
1575
|
-
bg:
|
|
2883
|
+
bg: STYLE_REMOVE_BG,
|
|
1576
2884
|
extend_to_line_end: true
|
|
1577
2885
|
});
|
|
1578
2886
|
} else if (line.changeType === 'modified') {
|
|
@@ -1661,9 +2969,9 @@ interface CompositeDiffState {
|
|
|
1661
2969
|
let activeCompositeDiffState: CompositeDiffState | null = null;
|
|
1662
2970
|
|
|
1663
2971
|
async function review_drill_down() {
|
|
1664
|
-
// Use
|
|
2972
|
+
// Use the file under the cursor (the file whose section the cursor is in)
|
|
1665
2973
|
if (state.files.length === 0) return;
|
|
1666
|
-
const selectedFile =
|
|
2974
|
+
const selectedFile = currentFileFromCursor();
|
|
1667
2975
|
if (!selectedFile) return;
|
|
1668
2976
|
|
|
1669
2977
|
// Create a minimal hunk-like reference for the rest of the function
|
|
@@ -1848,58 +3156,121 @@ function jumpDiffCursorToRow(row: number): void {
|
|
|
1848
3156
|
const idx = row - 1;
|
|
1849
3157
|
if (idx < 0 || idx >= state.diffLineByteOffsets.length) return;
|
|
1850
3158
|
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
-
}
|
|
3159
|
+
// Set the cursor by absolute byte offset + scroll the viewport.
|
|
3160
|
+
// Trust setBufferCursor — the previous N × executeAction("move_down")
|
|
3161
|
+
// walk was O(target_row) round-trips into the editor and made
|
|
3162
|
+
// collapsing big diffs visibly slow (thousands of round trips just
|
|
3163
|
+
// to land the cursor on a header).
|
|
3164
|
+
const byteOffset = state.diffLineByteOffsets[idx];
|
|
3165
|
+
editor.setBufferCursor(diffId, byteOffset);
|
|
3166
|
+
editor.scrollBufferToLine(diffId, idx);
|
|
1867
3167
|
state.diffCursorRow = row;
|
|
1868
3168
|
applyCursorLineOverlay('diff');
|
|
3169
|
+
refreshStickyHeader(idx);
|
|
3170
|
+
updateReviewStatus();
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
/**
|
|
3174
|
+
* Compute the 1-indexed global hunk number that corresponds to the current
|
|
3175
|
+
* diff-panel cursor row. Returns null when no hunk is "current".
|
|
3176
|
+
*/
|
|
3177
|
+
function currentGlobalHunkIndex(): number | null {
|
|
3178
|
+
if (state.hunkHeaderRows.length === 0) return null;
|
|
3179
|
+
let within = -1;
|
|
3180
|
+
for (let i = 0; i < state.hunkHeaderRows.length; i++) {
|
|
3181
|
+
if (state.hunkHeaderRows[i] <= state.diffCursorRow) within = i;
|
|
3182
|
+
else break;
|
|
3183
|
+
}
|
|
3184
|
+
if (within < 0) return null;
|
|
3185
|
+
return within + 1;
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
/**
|
|
3189
|
+
* Refresh the status-bar summary for review-diff mode. Shows "Hunk N of M"
|
|
3190
|
+
* when a current hunk is known, falls back to the bare hunk count otherwise.
|
|
3191
|
+
*/
|
|
3192
|
+
function updateReviewStatus(): void {
|
|
3193
|
+
if (state.groupId === null) return;
|
|
3194
|
+
const total = state.hunkHeaderRows.length;
|
|
3195
|
+
const current = currentGlobalHunkIndex();
|
|
3196
|
+
if (current !== null) {
|
|
3197
|
+
editor.setStatus(editor.t("status.review_summary_indexed", {
|
|
3198
|
+
current: String(current),
|
|
3199
|
+
count: String(total),
|
|
3200
|
+
}));
|
|
3201
|
+
} else {
|
|
3202
|
+
editor.setStatus(editor.t("status.review_summary", { count: String(total) }));
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
/**
|
|
3207
|
+
* Find the global index in `state.hunks` of the hunk currently visible
|
|
3208
|
+
* at the cursor row, scanning the *visible* hunks (i.e. hunks whose
|
|
3209
|
+
* file is not collapsed). Returns -1 if no hunk is at or before cursor.
|
|
3210
|
+
*/
|
|
3211
|
+
function visibleHunkIndexAtCursor(): number {
|
|
3212
|
+
let visibleIdx = -1;
|
|
3213
|
+
for (let i = 0; i < state.hunkHeaderRows.length; i++) {
|
|
3214
|
+
if (state.hunkHeaderRows[i] <= state.diffCursorRow) visibleIdx = i;
|
|
3215
|
+
else break;
|
|
3216
|
+
}
|
|
3217
|
+
if (visibleIdx < 0) return -1;
|
|
3218
|
+
// Map back to the global state.hunks index.
|
|
3219
|
+
let visited = 0;
|
|
3220
|
+
for (let i = 0; i < state.hunks.length; i++) {
|
|
3221
|
+
const h = state.hunks[i];
|
|
3222
|
+
if (state.collapsedFiles.has(fileKeyOf(h.file, h.gitStatus || 'unstaged'))) continue;
|
|
3223
|
+
if (visited === visibleIdx) return i;
|
|
3224
|
+
visited++;
|
|
3225
|
+
}
|
|
3226
|
+
return -1;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function jumpToGlobalHunk(globalIdx: number) {
|
|
3230
|
+
if (globalIdx < 0 || globalIdx >= state.hunks.length) return;
|
|
3231
|
+
const target = state.hunks[globalIdx];
|
|
3232
|
+
const targetFileKey = fileKeyOf(target.file, target.gitStatus || 'unstaged');
|
|
3233
|
+
let needRebuild = false;
|
|
3234
|
+
// Auto-expand the section, file, AND hunk containing the target so
|
|
3235
|
+
// n/p never silently lands on an invisible row.
|
|
3236
|
+
if (target.gitStatus && state.collapsedSections.has(target.gitStatus)) {
|
|
3237
|
+
state.collapsedSections.delete(target.gitStatus);
|
|
3238
|
+
needRebuild = true;
|
|
3239
|
+
}
|
|
3240
|
+
if (state.collapsedFiles.has(targetFileKey)) {
|
|
3241
|
+
state.collapsedFiles.delete(targetFileKey);
|
|
3242
|
+
needRebuild = true;
|
|
3243
|
+
}
|
|
3244
|
+
if (state.collapsedHunks.has(target.id)) {
|
|
3245
|
+
state.collapsedHunks.delete(target.id);
|
|
3246
|
+
needRebuild = true;
|
|
3247
|
+
}
|
|
3248
|
+
if (needRebuild) updateMagitDisplay();
|
|
3249
|
+
// Look up the target hunk's row directly — much simpler than counting.
|
|
3250
|
+
const row = state.hunkRowByHunkId[target.id];
|
|
3251
|
+
if (row !== undefined) jumpDiffCursorToRow(row);
|
|
1869
3252
|
}
|
|
1870
3253
|
|
|
1871
3254
|
function review_next_hunk() {
|
|
1872
|
-
|
|
1873
|
-
if (state.
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
3255
|
+
if (state.groupId === null) return;
|
|
3256
|
+
if (state.hunks.length === 0) return;
|
|
3257
|
+
const cur = visibleHunkIndexAtCursor();
|
|
3258
|
+
// Find next hunk in global order — auto-expanding its file if needed.
|
|
3259
|
+
if (cur < 0) {
|
|
3260
|
+
jumpToGlobalHunk(0);
|
|
1880
3261
|
return;
|
|
1881
3262
|
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
// plugin fallback is needed here.
|
|
3263
|
+
if (cur + 1 >= state.hunks.length) return;
|
|
3264
|
+
jumpToGlobalHunk(cur + 1);
|
|
1885
3265
|
}
|
|
1886
3266
|
registerHandler("review_next_hunk", review_next_hunk);
|
|
1887
3267
|
|
|
1888
3268
|
function review_prev_hunk() {
|
|
1889
|
-
|
|
1890
|
-
if (state.
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
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.
|
|
3269
|
+
if (state.groupId === null) return;
|
|
3270
|
+
if (state.hunks.length === 0) return;
|
|
3271
|
+
const cur = visibleHunkIndexAtCursor();
|
|
3272
|
+
if (cur <= 0) return;
|
|
3273
|
+
jumpToGlobalHunk(cur - 1);
|
|
1903
3274
|
}
|
|
1904
3275
|
registerHandler("review_prev_hunk", review_prev_hunk);
|
|
1905
3276
|
|
|
@@ -1919,16 +3290,7 @@ editor.defineMode("diff-view", [
|
|
|
1919
3290
|
|
|
1920
3291
|
function getCurrentHunkId(): string | null {
|
|
1921
3292
|
if (state.files.length === 0) return null;
|
|
1922
|
-
|
|
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
|
-
);
|
|
3293
|
+
const hunk = getHunkAtDiffCursor();
|
|
1932
3294
|
return hunk?.id || null;
|
|
1933
3295
|
}
|
|
1934
3296
|
|
|
@@ -1943,28 +3305,24 @@ interface PendingCommentInfo {
|
|
|
1943
3305
|
lineContent?: string;
|
|
1944
3306
|
}
|
|
1945
3307
|
|
|
3308
|
+
/**
|
|
3309
|
+
* Get the line under the cursor for comment attachment. Returns null
|
|
3310
|
+
* unless the cursor is on a real diff line (`add` / `remove` / `context`)
|
|
3311
|
+
* — comments are always line-based, never hunk-level.
|
|
3312
|
+
*/
|
|
1946
3313
|
function getCurrentLineInfo(): PendingCommentInfo | null {
|
|
1947
3314
|
if (state.files.length === 0) return null;
|
|
1948
|
-
const
|
|
1949
|
-
if (!
|
|
1950
|
-
|
|
1951
|
-
const
|
|
1952
|
-
|
|
1953
|
-
if (
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
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 };
|
|
3315
|
+
const props = propsAtCursorRow();
|
|
3316
|
+
if (!props) return null;
|
|
3317
|
+
const hunkId = props["hunkId"];
|
|
3318
|
+
const lineType = props["lineType"];
|
|
3319
|
+
if (typeof hunkId !== 'string') return null;
|
|
3320
|
+
if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
|
|
3321
|
+
const file = typeof props["file"] === 'string' ? props["file"] as string : '';
|
|
3322
|
+
const oldLine = typeof props["oldLine"] === 'number' ? props["oldLine"] as number : undefined;
|
|
3323
|
+
const newLine = typeof props["newLine"] === 'number' ? props["newLine"] as number : undefined;
|
|
3324
|
+
const lineContent = typeof props["lineContent"] === 'string' ? props["lineContent"] as string : undefined;
|
|
3325
|
+
return { hunkId, file, lineType: lineType as 'add' | 'remove' | 'context', oldLine, newLine, lineContent };
|
|
1968
3326
|
}
|
|
1969
3327
|
|
|
1970
3328
|
// Pending prompt state for event-based prompt handling
|
|
@@ -1976,7 +3334,7 @@ let editingCommentId: string | null = null; // non-null when editing an existing
|
|
|
1976
3334
|
* comment display line itself or on the diff line it's attached to.
|
|
1977
3335
|
*/
|
|
1978
3336
|
function findCommentAtCursor(): ReviewComment | null {
|
|
1979
|
-
const props =
|
|
3337
|
+
const props = propsAtCursorRow();
|
|
1980
3338
|
if (!props) return null;
|
|
1981
3339
|
|
|
1982
3340
|
// Cursor sits directly on a comment display line.
|
|
@@ -2002,19 +3360,51 @@ function findCommentAtCursor(): ReviewComment | null {
|
|
|
2002
3360
|
}
|
|
2003
3361
|
|
|
2004
3362
|
async function review_add_comment() {
|
|
3363
|
+
// If the cursor is sitting on an existing comment row, edit it
|
|
3364
|
+
// directly — `c` doubles as "edit this comment" so the user
|
|
3365
|
+
// doesn't have to first move back to the diff line.
|
|
3366
|
+
const props = propsAtCursorRow();
|
|
3367
|
+
if (props && props["type"] === 'comment' && typeof props["commentId"] === 'string') {
|
|
3368
|
+
const existing = state.comments.find(c => c.id === props["commentId"]);
|
|
3369
|
+
if (existing) {
|
|
3370
|
+
editingCommentId = existing.id;
|
|
3371
|
+
pendingCommentInfo = {
|
|
3372
|
+
hunkId: existing.hunk_id,
|
|
3373
|
+
file: existing.file,
|
|
3374
|
+
lineType: existing.line_type,
|
|
3375
|
+
oldLine: existing.old_line,
|
|
3376
|
+
newLine: existing.new_line,
|
|
3377
|
+
lineContent: existing.line_content,
|
|
3378
|
+
};
|
|
3379
|
+
const lineRef =
|
|
3380
|
+
existing.line_type === 'add' && existing.new_line ? `+${existing.new_line}`
|
|
3381
|
+
: existing.line_type === 'remove' && existing.old_line ? `-${existing.old_line}`
|
|
3382
|
+
: existing.new_line ? `L${existing.new_line}`
|
|
3383
|
+
: existing.old_line ? `L${existing.old_line}` : 'line';
|
|
3384
|
+
const label =
|
|
3385
|
+
editor.t("prompt.edit_comment", { line: lineRef }) ||
|
|
3386
|
+
`Edit comment on ${lineRef}: `;
|
|
3387
|
+
editor.startPromptWithInitial(label, "review-comment", existing.text);
|
|
3388
|
+
return;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
|
|
2005
3392
|
const info = getCurrentLineInfo();
|
|
2006
3393
|
if (!info) {
|
|
2007
|
-
editor.setStatus(
|
|
3394
|
+
editor.setStatus(
|
|
3395
|
+
editor.t("status.comment_needs_line") ||
|
|
3396
|
+
"Position cursor on a diff line to add a comment"
|
|
3397
|
+
);
|
|
2008
3398
|
return;
|
|
2009
3399
|
}
|
|
2010
3400
|
|
|
2011
|
-
// Check for existing comment to edit
|
|
3401
|
+
// Check for existing comment on this diff line to edit
|
|
2012
3402
|
const existing = findCommentAtCursor();
|
|
2013
3403
|
|
|
2014
3404
|
pendingCommentInfo = info;
|
|
2015
3405
|
editingCommentId = existing?.id || null;
|
|
2016
3406
|
|
|
2017
|
-
let lineRef = '
|
|
3407
|
+
let lineRef = 'line';
|
|
2018
3408
|
if (info.lineType === 'add' && info.newLine) {
|
|
2019
3409
|
lineRef = `+${info.newLine}`;
|
|
2020
3410
|
} else if (info.lineType === 'remove' && info.oldLine) {
|
|
@@ -2040,28 +3430,7 @@ registerHandler("review_add_comment", review_add_comment);
|
|
|
2040
3430
|
let pendingDeleteCommentId: string | null = null;
|
|
2041
3431
|
|
|
2042
3432
|
async function review_delete_comment() {
|
|
2043
|
-
|
|
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
|
-
}
|
|
3433
|
+
const target: ReviewComment | null = findCommentAtCursor();
|
|
2065
3434
|
|
|
2066
3435
|
if (!target) {
|
|
2067
3436
|
editor.setStatus("No comment to delete");
|
|
@@ -2088,7 +3457,7 @@ function on_review_delete_comment_confirm(args: { prompt_type: string; input: st
|
|
|
2088
3457
|
} else {
|
|
2089
3458
|
state.comments = state.comments.filter(c => c.id !== pendingDeleteCommentId);
|
|
2090
3459
|
}
|
|
2091
|
-
|
|
3460
|
+
persistReview();
|
|
2092
3461
|
updateMagitDisplay();
|
|
2093
3462
|
editor.setStatus("Deleted");
|
|
2094
3463
|
} else {
|
|
@@ -2105,6 +3474,13 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
|
|
|
2105
3474
|
return true;
|
|
2106
3475
|
}
|
|
2107
3476
|
|
|
3477
|
+
// Remember the cursor row from before the rebuild so we can put the
|
|
3478
|
+
// user back where they were. Inserting a comment row shifts later
|
|
3479
|
+
// rows down by one, but the line the user was on keeps its row
|
|
3480
|
+
// number — so saving the row pre-rebuild and restoring it after
|
|
3481
|
+
// lands the cursor on the same diff line.
|
|
3482
|
+
const cursorRowBeforeRebuild = state.diffCursorRow;
|
|
3483
|
+
|
|
2108
3484
|
if (editingCommentId) {
|
|
2109
3485
|
// Edit mode: update existing comment (empty text keeps the comment unchanged)
|
|
2110
3486
|
if (args.input && args.input.trim()) {
|
|
@@ -2112,8 +3488,9 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
|
|
|
2112
3488
|
if (existing) {
|
|
2113
3489
|
existing.text = args.input.trim();
|
|
2114
3490
|
existing.timestamp = new Date().toISOString();
|
|
2115
|
-
|
|
3491
|
+
persistReview();
|
|
2116
3492
|
updateMagitDisplay();
|
|
3493
|
+
jumpDiffCursorToRow(cursorRowBeforeRebuild);
|
|
2117
3494
|
editor.setStatus("Comment updated");
|
|
2118
3495
|
}
|
|
2119
3496
|
} else {
|
|
@@ -2138,8 +3515,9 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
|
|
|
2138
3515
|
line_type: pendingCommentInfo.lineType
|
|
2139
3516
|
};
|
|
2140
3517
|
state.comments.push(comment);
|
|
2141
|
-
|
|
3518
|
+
persistReview();
|
|
2142
3519
|
updateMagitDisplay();
|
|
3520
|
+
jumpDiffCursorToRow(cursorRowBeforeRebuild);
|
|
2143
3521
|
let lineRef = 'hunk';
|
|
2144
3522
|
if (comment.line_type === 'add' && comment.new_line) {
|
|
2145
3523
|
lineRef = `line +${comment.new_line}`;
|
|
@@ -2189,6 +3567,7 @@ function on_review_edit_note_confirm(args: { prompt_type: string; input: string
|
|
|
2189
3567
|
if (args.prompt_type !== "review-edit-note") return true;
|
|
2190
3568
|
if (args.input && args.input.trim()) {
|
|
2191
3569
|
state.note = args.input.trim();
|
|
3570
|
+
persistReview();
|
|
2192
3571
|
updateMagitDisplay();
|
|
2193
3572
|
editor.setStatus(state.note ? "Note saved" : "Note cleared");
|
|
2194
3573
|
} else {
|
|
@@ -2285,73 +3664,143 @@ async function review_export_json() {
|
|
|
2285
3664
|
}
|
|
2286
3665
|
registerHandler("review_export_json", review_export_json);
|
|
2287
3666
|
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
if (viewport) {
|
|
2295
|
-
state.viewportWidth = viewport.width;
|
|
2296
|
-
state.viewportHeight = viewport.height;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
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;
|
|
3667
|
+
/**
|
|
3668
|
+
* Reset the slice of `state` that tracks per-session cursor / fold / row
|
|
3669
|
+
* indices. Keeps `state.comments` and `state.note` untouched so the
|
|
3670
|
+
* caller can populate them (either freshly, or from disk).
|
|
3671
|
+
*/
|
|
3672
|
+
function resetPerSessionState(): void {
|
|
2305
3673
|
state.diffCursorRow = 1;
|
|
2306
3674
|
state.hunkHeaderRows = [];
|
|
2307
3675
|
state.diffLineByteOffsets = [];
|
|
2308
|
-
state.
|
|
3676
|
+
state.fileHeaderRows = {};
|
|
3677
|
+
state.collapsedFiles = new Set();
|
|
3678
|
+
state.collapsedSections = new Set();
|
|
3679
|
+
state.collapsedHunks = new Set();
|
|
3680
|
+
state.commentsByRow = {};
|
|
3681
|
+
state.commentsSelectedRow = 0;
|
|
3682
|
+
state.focusPanel = 'diff';
|
|
3683
|
+
state.commentsHighlightId = null;
|
|
3684
|
+
state.stickyCurrentFile = null;
|
|
3685
|
+
state.lineSelection = null;
|
|
3686
|
+
}
|
|
2309
3687
|
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
3688
|
+
const REVIEW_LAYOUT = JSON.stringify({
|
|
3689
|
+
type: "split",
|
|
3690
|
+
direction: "v",
|
|
3691
|
+
ratio: 0.05,
|
|
3692
|
+
first: { type: "fixed", id: "toolbar", height: 2 },
|
|
3693
|
+
second: {
|
|
2313
3694
|
type: "split",
|
|
2314
|
-
direction: "
|
|
2315
|
-
ratio: 0.
|
|
2316
|
-
first: {
|
|
2317
|
-
second: {
|
|
3695
|
+
direction: "h",
|
|
3696
|
+
ratio: 0.75,
|
|
3697
|
+
first: {
|
|
2318
3698
|
type: "split",
|
|
2319
|
-
direction: "
|
|
2320
|
-
ratio: 0.
|
|
2321
|
-
first: { type: "
|
|
3699
|
+
direction: "v",
|
|
3700
|
+
ratio: 0.05,
|
|
3701
|
+
first: { type: "fixed", id: "sticky", height: 1 },
|
|
2322
3702
|
second: { type: "scrollable", id: "diff" },
|
|
2323
3703
|
},
|
|
2324
|
-
|
|
3704
|
+
second: { type: "scrollable", id: "comments" },
|
|
3705
|
+
},
|
|
3706
|
+
});
|
|
2325
3707
|
|
|
2326
|
-
|
|
3708
|
+
/**
|
|
3709
|
+
* Create the review-diff buffer group (toolbar / sticky / diff / comments)
|
|
3710
|
+
* and wire up the standard review-mode event listeners. Returns true if
|
|
3711
|
+
* the panels were created, false on failure.
|
|
3712
|
+
*/
|
|
3713
|
+
async function openReviewPanels(groupName: string): Promise<boolean> {
|
|
3714
|
+
const viewport = editor.getViewport();
|
|
3715
|
+
if (viewport) {
|
|
3716
|
+
state.viewportWidth = viewport.width;
|
|
3717
|
+
state.viewportHeight = viewport.height;
|
|
3718
|
+
}
|
|
3719
|
+
editor.setContext("review-mode", true);
|
|
3720
|
+
const groupResult = await editor.createBufferGroup(groupName, "review-mode", REVIEW_LAYOUT);
|
|
2327
3721
|
state.groupId = groupResult.groupId;
|
|
2328
3722
|
state.panelBuffers = groupResult.panels;
|
|
2329
|
-
state.reviewBufferId = groupResult.panels["
|
|
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.
|
|
3723
|
+
state.reviewBufferId = groupResult.panels["diff"];
|
|
3724
|
+
|
|
2337
3725
|
if (state.panelBuffers["diff"] !== undefined) {
|
|
2338
3726
|
(editor as any).setBufferShowCursors(state.panelBuffers["diff"], true);
|
|
2339
3727
|
}
|
|
2340
3728
|
|
|
2341
|
-
// Set initial content for all panels
|
|
2342
3729
|
updateMagitDisplay();
|
|
2343
3730
|
|
|
2344
|
-
|
|
2345
|
-
// if it was open, so review-mode keybindings work immediately)
|
|
2346
|
-
editor.focusBufferGroupPanel(state.groupId, "files");
|
|
3731
|
+
editor.focusBufferGroupPanel(state.groupId, "diff");
|
|
2347
3732
|
|
|
2348
|
-
// Register resize handler
|
|
2349
3733
|
editor.on("resize", "onReviewDiffResize");
|
|
2350
|
-
|
|
2351
|
-
editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
|
|
3734
|
+
updateReviewStatus();
|
|
2352
3735
|
editor.on("buffer_activated", "on_review_buffer_activated");
|
|
2353
3736
|
editor.on("buffer_closed", "on_review_buffer_closed");
|
|
2354
3737
|
editor.on("cursor_moved", "on_review_cursor_moved");
|
|
3738
|
+
editor.on("viewport_changed", "on_review_viewport_changed");
|
|
3739
|
+
editor.on("mouse_click", "on_review_mouse_click");
|
|
3740
|
+
return true;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
/**
|
|
3744
|
+
* Drop any comments whose anchor lines can no longer be found in the
|
|
3745
|
+
* current hunks. Applied on restore so stale worktree-mode comments from
|
|
3746
|
+
* a long-since-rewritten file don't pile up. For range mode this is a
|
|
3747
|
+
* no-op because comments should always match.
|
|
3748
|
+
*/
|
|
3749
|
+
function pruneOrphanComments(comments: ReviewComment[], hunks: Hunk[]): ReviewComment[] {
|
|
3750
|
+
const byHunk = new Map<string, Hunk>();
|
|
3751
|
+
for (const h of hunks) byHunk.set(h.id, h);
|
|
3752
|
+
const fileSet = new Set(hunks.map(h => h.file));
|
|
3753
|
+
return comments.filter(c => {
|
|
3754
|
+
// Keep comments whose hunk still exists or whose file is still
|
|
3755
|
+
// part of the diff and whose anchor line is present in some hunk.
|
|
3756
|
+
if (byHunk.has(c.hunk_id)) return true;
|
|
3757
|
+
if (!fileSet.has(c.file)) return false;
|
|
3758
|
+
const fileHunks = hunks.filter(h => h.file === c.file);
|
|
3759
|
+
for (const h of fileHunks) {
|
|
3760
|
+
const lt = c.line_type;
|
|
3761
|
+
if (!lt) continue;
|
|
3762
|
+
let oldN = h.oldRange.start - 1;
|
|
3763
|
+
let newN = h.range.start - 1;
|
|
3764
|
+
for (const raw of h.lines) {
|
|
3765
|
+
if (raw.startsWith('+')) {
|
|
3766
|
+
newN++;
|
|
3767
|
+
if (lt === 'add' && c.new_line === newN) return true;
|
|
3768
|
+
} else if (raw.startsWith('-')) {
|
|
3769
|
+
oldN++;
|
|
3770
|
+
if (lt === 'remove' && c.old_line === oldN) return true;
|
|
3771
|
+
} else {
|
|
3772
|
+
oldN++; newN++;
|
|
3773
|
+
if (lt === 'context' && c.new_line === newN) return true;
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
return false;
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
async function start_review_diff() {
|
|
3782
|
+
editor.setStatus(editor.t("status.generating"));
|
|
3783
|
+
|
|
3784
|
+
// Fetch data using the git status approach.
|
|
3785
|
+
const status = await getGitStatus();
|
|
3786
|
+
state.files = status.files;
|
|
3787
|
+
state.emptyState = status.emptyReason;
|
|
3788
|
+
state.hunks = await fetchDiffsForFiles(status.files);
|
|
3789
|
+
|
|
3790
|
+
// Persistence setup: worktree mode keyed by repo root.
|
|
3791
|
+
state.mode = 'worktree';
|
|
3792
|
+
state.range = null;
|
|
3793
|
+
state.repoRoot = await detectRepoRoot();
|
|
3794
|
+
state.reviewKey = buildReviewKey(state.mode, state.range);
|
|
3795
|
+
|
|
3796
|
+
// Restore persisted comments (if any). We drop orphans so the UI
|
|
3797
|
+
// doesn't display comments that no longer point at visible lines.
|
|
3798
|
+
const loaded = loadPersistedReview(state.repoRoot, state.reviewKey);
|
|
3799
|
+
state.comments = loaded ? pruneOrphanComments(loaded.comments, state.hunks) : [];
|
|
3800
|
+
state.note = loaded?.note ?? '';
|
|
3801
|
+
|
|
3802
|
+
resetPerSessionState();
|
|
3803
|
+
await openReviewPanels("*Review Diff*");
|
|
2355
3804
|
}
|
|
2356
3805
|
registerHandler("start_review_diff", start_review_diff);
|
|
2357
3806
|
|
|
@@ -2367,10 +3816,200 @@ function stop_review_diff() {
|
|
|
2367
3816
|
editor.off("buffer_activated", "on_review_buffer_activated");
|
|
2368
3817
|
editor.off("buffer_closed", "on_review_buffer_closed");
|
|
2369
3818
|
editor.off("cursor_moved", "on_review_cursor_moved");
|
|
3819
|
+
editor.off("viewport_changed", "on_review_viewport_changed");
|
|
3820
|
+
editor.off("mouse_click", "on_review_mouse_click");
|
|
2370
3821
|
editor.setStatus(editor.t("status.stopped"));
|
|
2371
3822
|
}
|
|
2372
3823
|
registerHandler("stop_review_diff", stop_review_diff);
|
|
2373
3824
|
|
|
3825
|
+
// =============================================================================
|
|
3826
|
+
// Range / commit review (Task 2)
|
|
3827
|
+
// =============================================================================
|
|
3828
|
+
//
|
|
3829
|
+
// `start_review_diff` reviews the working tree. `start_review_range` reviews
|
|
3830
|
+
// a flattened diff between two git refs — the user types:
|
|
3831
|
+
//
|
|
3832
|
+
// HEAD~3..HEAD (a span of commits)
|
|
3833
|
+
// main..HEAD (a whole branch)
|
|
3834
|
+
// <sha> (a single commit — rewritten to `<sha>^..<sha>`)
|
|
3835
|
+
//
|
|
3836
|
+
// Alternatives considered for the picker UI:
|
|
3837
|
+
// - A dedicated two-panel picker (from / to). Clean but adds a big new
|
|
3838
|
+
// UI surface for a small benefit.
|
|
3839
|
+
// - The existing `start_review_branch` commit list (inline, Enter-to-
|
|
3840
|
+
// select). Rejected because that view is commit-by-commit and we
|
|
3841
|
+
// specifically want a *flattened* diff for batch commenting.
|
|
3842
|
+
// - Single prompt with a small suggestion list. Chosen — matches the
|
|
3843
|
+
// tone of the existing `start_review_branch` prompt and lets power
|
|
3844
|
+
// users type arbitrary revspecs without a multi-step UI.
|
|
3845
|
+
|
|
3846
|
+
/**
|
|
3847
|
+
* Parse a range string typed into the picker. Accepts:
|
|
3848
|
+
* `A..B`, `A...B` — two-dot / three-dot ranges.
|
|
3849
|
+
* `<ref>` — single commit, rewritten to `<ref>^..<ref>`.
|
|
3850
|
+
*
|
|
3851
|
+
* Returns `null` on invalid input (empty string).
|
|
3852
|
+
*/
|
|
3853
|
+
function parseRangeInput(input: string): ReviewRange | null {
|
|
3854
|
+
const raw = input.trim();
|
|
3855
|
+
if (!raw) return null;
|
|
3856
|
+
const threeDot = raw.indexOf("...");
|
|
3857
|
+
if (threeDot > 0) {
|
|
3858
|
+
const from = raw.slice(0, threeDot).trim();
|
|
3859
|
+
const to = raw.slice(threeDot + 3).trim();
|
|
3860
|
+
if (!from || !to) return null;
|
|
3861
|
+
return { from, to, label: `${from}...${to}` };
|
|
3862
|
+
}
|
|
3863
|
+
const twoDot = raw.indexOf("..");
|
|
3864
|
+
if (twoDot > 0) {
|
|
3865
|
+
const from = raw.slice(0, twoDot).trim();
|
|
3866
|
+
const to = raw.slice(twoDot + 2).trim();
|
|
3867
|
+
if (!from || !to) return null;
|
|
3868
|
+
return { from, to, label: `${from}..${to}` };
|
|
3869
|
+
}
|
|
3870
|
+
// Single ref -> single-commit review.
|
|
3871
|
+
return { from: `${raw}^`, to: raw, label: raw };
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
/**
|
|
3875
|
+
* Fetch a flattened unified diff for the given range and convert it to
|
|
3876
|
+
* the same Hunk + FileEntry shape the worktree path produces. All hunks
|
|
3877
|
+
* are assigned `gitStatus: 'unstaged'` so the existing section grouping
|
|
3878
|
+
* still works; untracked / staged categories are meaningless here.
|
|
3879
|
+
*/
|
|
3880
|
+
async function fetchRangeDiff(range: ReviewRange): Promise<{ hunks: Hunk[]; files: FileEntry[] }> {
|
|
3881
|
+
const result = await editor.spawnProcess("git", [
|
|
3882
|
+
"diff", "--unified=3", `${range.from}..${range.to}`,
|
|
3883
|
+
]);
|
|
3884
|
+
if (result.exit_code !== 0) {
|
|
3885
|
+
return { hunks: [], files: [] };
|
|
3886
|
+
}
|
|
3887
|
+
const hunks = parseDiffOutput(result.stdout, 'unstaged');
|
|
3888
|
+
// Rewrite hunk ids so they include the range — avoids id collisions
|
|
3889
|
+
// when a user opens multiple range reviews in the same session.
|
|
3890
|
+
for (const h of hunks) {
|
|
3891
|
+
h.id = `${range.label}|${h.file}:${h.range.start}`;
|
|
3892
|
+
}
|
|
3893
|
+
// Derive a FileEntry list from the hunks, preserving first-seen order.
|
|
3894
|
+
const seen = new Set<string>();
|
|
3895
|
+
const files: FileEntry[] = [];
|
|
3896
|
+
for (const h of hunks) {
|
|
3897
|
+
if (!seen.has(h.file)) {
|
|
3898
|
+
seen.add(h.file);
|
|
3899
|
+
files.push({ path: h.file, status: 'M', category: 'unstaged' });
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
return { hunks, files };
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/**
|
|
3906
|
+
* Build a short list of revspec suggestions to prefill the picker. Falls
|
|
3907
|
+
* back gracefully if any of the helper git calls fail — the prompt still
|
|
3908
|
+
* accepts arbitrary input.
|
|
3909
|
+
*/
|
|
3910
|
+
async function buildRangeSuggestions(): Promise<PromptSuggestion[]> {
|
|
3911
|
+
const suggestions: PromptSuggestion[] = [];
|
|
3912
|
+
// HEAD last commit.
|
|
3913
|
+
suggestions.push({ text: "HEAD", description: "Review last commit", value: "HEAD" });
|
|
3914
|
+
// Current-branch-vs-main style ranges.
|
|
3915
|
+
const tryRange = async (base: string) => {
|
|
3916
|
+
const exists = await editor.spawnProcess("git", ["rev-parse", "--verify", base]);
|
|
3917
|
+
if (exists.exit_code === 0) {
|
|
3918
|
+
suggestions.push({
|
|
3919
|
+
text: `${base}..HEAD`,
|
|
3920
|
+
description: `Review all commits on current branch vs ${base}`,
|
|
3921
|
+
value: `${base}..HEAD`,
|
|
3922
|
+
});
|
|
3923
|
+
}
|
|
3924
|
+
};
|
|
3925
|
+
await tryRange("main");
|
|
3926
|
+
await tryRange("master");
|
|
3927
|
+
// Recent commits for one-off review.
|
|
3928
|
+
try {
|
|
3929
|
+
const log = await editor.spawnProcess("git", [
|
|
3930
|
+
"log", "-n", "5", "--pretty=format:%h %s",
|
|
3931
|
+
]);
|
|
3932
|
+
if (log.exit_code === 0) {
|
|
3933
|
+
for (const line of log.stdout.split('\n')) {
|
|
3934
|
+
const m = line.match(/^([0-9a-f]+)\s+(.*)$/);
|
|
3935
|
+
if (m) {
|
|
3936
|
+
suggestions.push({
|
|
3937
|
+
text: m[1],
|
|
3938
|
+
description: `Review commit: ${m[2]}`,
|
|
3939
|
+
value: m[1],
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
} catch {}
|
|
3945
|
+
return suggestions;
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
async function start_review_range(): Promise<void> {
|
|
3949
|
+
// If a review is already open, swap it out rather than stacking two.
|
|
3950
|
+
if (state.groupId !== null) {
|
|
3951
|
+
stop_review_diff();
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
const suggestions = await buildRangeSuggestions();
|
|
3955
|
+
const label = editor.t("prompt.review_range") || "Review range (A..B or commit):";
|
|
3956
|
+
editor.startPromptWithInitial(label, "review-range", "HEAD");
|
|
3957
|
+
if (suggestions.length > 0) {
|
|
3958
|
+
editor.setPromptSuggestions(suggestions);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
registerHandler("start_review_range", start_review_range);
|
|
3962
|
+
|
|
3963
|
+
function on_review_range_confirm(args: { prompt_type: string; input: string }): boolean {
|
|
3964
|
+
if (args.prompt_type !== "review-range") return true;
|
|
3965
|
+
const range = parseRangeInput(args.input);
|
|
3966
|
+
if (!range) {
|
|
3967
|
+
editor.setStatus(editor.t("status.cancelled") || "Cancelled");
|
|
3968
|
+
return true;
|
|
3969
|
+
}
|
|
3970
|
+
// Kick off the async bootstrap; the prompt is already dismissed so we
|
|
3971
|
+
// can return immediately.
|
|
3972
|
+
bootstrapRangeReview(range);
|
|
3973
|
+
return true;
|
|
3974
|
+
}
|
|
3975
|
+
registerHandler("on_review_range_confirm", on_review_range_confirm);
|
|
3976
|
+
editor.on("prompt_confirmed", "on_review_range_confirm");
|
|
3977
|
+
|
|
3978
|
+
async function bootstrapRangeReview(range: ReviewRange): Promise<void> {
|
|
3979
|
+
editor.setStatus(editor.t("status.generating") || "Generating diff…");
|
|
3980
|
+
const { hunks, files } = await fetchRangeDiff(range);
|
|
3981
|
+
if (hunks.length === 0) {
|
|
3982
|
+
editor.setStatus(
|
|
3983
|
+
editor.t("status.review_range_empty", { range: range.label }) ||
|
|
3984
|
+
`No changes in ${range.label}`,
|
|
3985
|
+
);
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
state.mode = 'range';
|
|
3989
|
+
state.range = range;
|
|
3990
|
+
state.hunks = hunks;
|
|
3991
|
+
state.files = files;
|
|
3992
|
+
state.emptyState = null;
|
|
3993
|
+
state.repoRoot = await detectRepoRoot();
|
|
3994
|
+
state.reviewKey = buildReviewKey(state.mode, state.range);
|
|
3995
|
+
|
|
3996
|
+
// Load persisted comments for this exact range — the diff is static
|
|
3997
|
+
// so they always line up.
|
|
3998
|
+
const loaded = loadPersistedReview(state.repoRoot, state.reviewKey);
|
|
3999
|
+
state.comments = loaded ? loaded.comments : [];
|
|
4000
|
+
state.note = loaded?.note ?? '';
|
|
4001
|
+
|
|
4002
|
+
resetPerSessionState();
|
|
4003
|
+
await openReviewPanels(`*Review ${range.label}*`);
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
editor.registerCommand(
|
|
4007
|
+
"%cmd.review_range",
|
|
4008
|
+
"%cmd.review_range_desc",
|
|
4009
|
+
"start_review_range",
|
|
4010
|
+
null,
|
|
4011
|
+
);
|
|
4012
|
+
|
|
2374
4013
|
|
|
2375
4014
|
/**
|
|
2376
4015
|
* React to a buffer becoming active. Used here purely to track which review
|
|
@@ -2384,23 +4023,57 @@ registerHandler("stop_review_diff", stop_review_diff);
|
|
|
2384
4023
|
*/
|
|
2385
4024
|
function on_review_buffer_activated(data: { buffer_id: number }): void {
|
|
2386
4025
|
if (state.groupId === null) return;
|
|
2387
|
-
const filesId = state.panelBuffers["files"];
|
|
2388
4026
|
const diffId = state.panelBuffers["diff"];
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
4027
|
+
const commentsId = state.panelBuffers["comments"];
|
|
4028
|
+
let newPanel: 'diff' | 'comments' | null = null;
|
|
4029
|
+
if (data.buffer_id === diffId) newPanel = 'diff';
|
|
4030
|
+
else if (data.buffer_id === commentsId) newPanel = 'comments';
|
|
2392
4031
|
if (newPanel === null || newPanel === state.focusPanel) return;
|
|
2393
4032
|
state.focusPanel = newPanel;
|
|
2394
|
-
|
|
4033
|
+
// Re-render the comments panel so the selection highlight follows focus.
|
|
4034
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
2395
4035
|
}
|
|
2396
4036
|
registerHandler("on_review_buffer_activated", on_review_buffer_activated);
|
|
2397
4037
|
|
|
2398
4038
|
/**
|
|
2399
|
-
* React to native cursor movement inside
|
|
2400
|
-
*
|
|
2401
|
-
*
|
|
2402
|
-
* highlight overlay.
|
|
4039
|
+
* React to native cursor movement inside review panels.
|
|
4040
|
+
*
|
|
4041
|
+
* Diff panel: keeps `state.diffCursorRow` in sync and re-paints the
|
|
4042
|
+
* cursor-line highlight overlay.
|
|
4043
|
+
*
|
|
4044
|
+
* Files panel: when the cursor moves (e.g. via mouse click), read the
|
|
4045
|
+
* `fileIndex` text property at the new position and select that file.
|
|
4046
|
+
* This makes click-to-select work even though the files panel hides its
|
|
4047
|
+
* native cursor (`show_cursors = false` blocks keyboard-driven movement
|
|
4048
|
+
* but mouse clicks still move the cursor).
|
|
4049
|
+
*/
|
|
4050
|
+
/**
|
|
4051
|
+
* Determine the "current comment" — the one the diff cursor is sitting
|
|
4052
|
+
* on (a comment-display row) or attached to (a +/-/context line).
|
|
4053
|
+
* Returns null if the cursor is not associated with any comment.
|
|
2403
4054
|
*/
|
|
4055
|
+
function currentCommentIdAtCursor(): string | null {
|
|
4056
|
+
const props = propsAtCursorRow();
|
|
4057
|
+
if (!props) return null;
|
|
4058
|
+
if (props["type"] === 'comment' && typeof props["commentId"] === 'string') {
|
|
4059
|
+
return props["commentId"] as string;
|
|
4060
|
+
}
|
|
4061
|
+
const hunkId = props["hunkId"];
|
|
4062
|
+
const lineType = props["lineType"];
|
|
4063
|
+
if (typeof hunkId !== 'string') return null;
|
|
4064
|
+
if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
|
|
4065
|
+
const oldLine = typeof props["oldLine"] === 'number' ? (props["oldLine"] as number) : undefined;
|
|
4066
|
+
const newLine = typeof props["newLine"] === 'number' ? (props["newLine"] as number) : undefined;
|
|
4067
|
+
const found = state.comments.find(c =>
|
|
4068
|
+
c.hunk_id === hunkId && (
|
|
4069
|
+
(c.line_type === 'add' && c.new_line === newLine) ||
|
|
4070
|
+
(c.line_type === 'remove' && c.old_line === oldLine) ||
|
|
4071
|
+
(c.line_type === 'context' && c.new_line === newLine)
|
|
4072
|
+
)
|
|
4073
|
+
);
|
|
4074
|
+
return found ? found.id : null;
|
|
4075
|
+
}
|
|
4076
|
+
|
|
2404
4077
|
function on_review_cursor_moved(data: {
|
|
2405
4078
|
buffer_id: number;
|
|
2406
4079
|
cursor_id: number;
|
|
@@ -2410,9 +4083,28 @@ function on_review_cursor_moved(data: {
|
|
|
2410
4083
|
text_properties: Array<Record<string, unknown>>;
|
|
2411
4084
|
}): void {
|
|
2412
4085
|
if (state.groupId === null) return;
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
4086
|
+
|
|
4087
|
+
// Diff panel: track cursor row + repaint the cursor-line overlay.
|
|
4088
|
+
if (data.buffer_id === state.panelBuffers["diff"]) {
|
|
4089
|
+
const prevHighlight = state.commentsHighlightId;
|
|
4090
|
+
state.diffCursorRow = data.line;
|
|
4091
|
+
applyCursorLineOverlay('diff');
|
|
4092
|
+
// Use the cursor row as a sticky-header anchor too — viewport_changed
|
|
4093
|
+
// doesn't always fire reliably for plugin-managed virtual buffers
|
|
4094
|
+
// (top_line can be null). Tracking the cursor row gives a snappy
|
|
4095
|
+
// "what file am I in" indicator regardless.
|
|
4096
|
+
refreshStickyHeader(Math.max(0, data.line - 1));
|
|
4097
|
+
updateReviewStatus();
|
|
4098
|
+
// Re-render the comments panel only when the highlighted comment
|
|
4099
|
+
// actually changes — avoids re-emitting the panel on every
|
|
4100
|
+
// cursor tick.
|
|
4101
|
+
const newHighlight = currentCommentIdAtCursor();
|
|
4102
|
+
if (newHighlight !== prevHighlight) {
|
|
4103
|
+
state.commentsHighlightId = newHighlight;
|
|
4104
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
4105
|
+
}
|
|
4106
|
+
return;
|
|
4107
|
+
}
|
|
2416
4108
|
}
|
|
2417
4109
|
registerHandler("on_review_cursor_moved", on_review_cursor_moved);
|
|
2418
4110
|
|
|
@@ -2663,8 +4355,314 @@ async function side_by_side_diff_current_file() {
|
|
|
2663
4355
|
}
|
|
2664
4356
|
registerHandler("side_by_side_diff_current_file", side_by_side_diff_current_file);
|
|
2665
4357
|
|
|
4358
|
+
// =============================================================================
|
|
4359
|
+
// Review PR Branch
|
|
4360
|
+
//
|
|
4361
|
+
// A companion view to `start_review_diff` for reviewing the full set of
|
|
4362
|
+
// commits on a PR branch (rather than just the working-tree changes). It
|
|
4363
|
+
// opens a buffer group with the commit history on the left (rendered by
|
|
4364
|
+
// the shared `lib/git_history.ts` helpers the git_log plugin uses) and a
|
|
4365
|
+
// live-updating `git show` of the selected commit on the right. This reuses
|
|
4366
|
+
// the same rendering pipeline so both plugins stay visually consistent and
|
|
4367
|
+
// respect theme keys in one place.
|
|
4368
|
+
// =============================================================================
|
|
4369
|
+
|
|
4370
|
+
interface ReviewBranchState {
|
|
4371
|
+
isOpen: boolean;
|
|
4372
|
+
groupId: number | null;
|
|
4373
|
+
logBufferId: number | null;
|
|
4374
|
+
detailBufferId: number | null;
|
|
4375
|
+
commits: GitCommit[];
|
|
4376
|
+
selectedIndex: number;
|
|
4377
|
+
baseRef: string;
|
|
4378
|
+
detailCache: { hash: string; output: string } | null;
|
|
4379
|
+
pendingDetailId: number;
|
|
4380
|
+
/** Byte offset of each row in the log panel; final entry = buffer length. */
|
|
4381
|
+
logRowByteOffsets: number[];
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
const branchState: ReviewBranchState = {
|
|
4385
|
+
isOpen: false,
|
|
4386
|
+
groupId: null,
|
|
4387
|
+
logBufferId: null,
|
|
4388
|
+
detailBufferId: null,
|
|
4389
|
+
commits: [],
|
|
4390
|
+
selectedIndex: 0,
|
|
4391
|
+
baseRef: "main",
|
|
4392
|
+
detailCache: null,
|
|
4393
|
+
pendingDetailId: 0,
|
|
4394
|
+
logRowByteOffsets: [],
|
|
4395
|
+
};
|
|
4396
|
+
|
|
4397
|
+
// UTF-8 byte length helper, local copy so audit_mode doesn't pull in the one
|
|
4398
|
+
// from git_history (keeps the import list tiny).
|
|
4399
|
+
function branchUtf8Len(s: string): number {
|
|
4400
|
+
let b = 0;
|
|
4401
|
+
for (let i = 0; i < s.length; i++) {
|
|
4402
|
+
const c = s.charCodeAt(i);
|
|
4403
|
+
if (c <= 0x7f) b += 1;
|
|
4404
|
+
else if (c <= 0x7ff) b += 2;
|
|
4405
|
+
else if (c >= 0xd800 && c <= 0xdfff) { b += 4; i++; }
|
|
4406
|
+
else b += 3;
|
|
4407
|
+
}
|
|
4408
|
+
return b;
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
function branchRowFromByte(bytePos: number): number {
|
|
4412
|
+
const offs = branchState.logRowByteOffsets;
|
|
4413
|
+
if (offs.length === 0) return 0;
|
|
4414
|
+
let lo = 0;
|
|
4415
|
+
let hi = offs.length - 1;
|
|
4416
|
+
while (lo < hi) {
|
|
4417
|
+
const mid = (lo + hi + 1) >> 1;
|
|
4418
|
+
if (offs[mid] <= bytePos) lo = mid;
|
|
4419
|
+
else hi = mid - 1;
|
|
4420
|
+
}
|
|
4421
|
+
return lo;
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
function branchIndexFromCursor(bytePos: number): number {
|
|
4425
|
+
const row = branchRowFromByte(bytePos);
|
|
4426
|
+
const idx = row - 1; // row 0 is the header
|
|
4427
|
+
if (idx < 0) return 0;
|
|
4428
|
+
if (idx >= branchState.commits.length) return branchState.commits.length - 1;
|
|
4429
|
+
return idx;
|
|
4430
|
+
}
|
|
4431
|
+
|
|
4432
|
+
function branchRenderLog(): void {
|
|
4433
|
+
if (branchState.groupId === null) return;
|
|
4434
|
+
const rawHeader = editor.t("panel.review_branch_header", { base: branchState.baseRef });
|
|
4435
|
+
const header = (rawHeader && !rawHeader.startsWith("panel.")) ? rawHeader : `Commits (${branchState.baseRef}..HEAD)`;
|
|
4436
|
+
const rawFooter = editor.t("panel.review_branch_footer");
|
|
4437
|
+
const footer = (rawFooter && !rawFooter.startsWith("panel.")) ? rawFooter : "j/k: navigate · Enter: focus detail · r: refresh · q: close";
|
|
4438
|
+
const entries = buildCommitLogEntries(branchState.commits, {
|
|
4439
|
+
selectedIndex: branchState.selectedIndex,
|
|
4440
|
+
header,
|
|
4441
|
+
footer,
|
|
4442
|
+
propertyType: "branch-commit",
|
|
4443
|
+
});
|
|
4444
|
+
const offsets: number[] = [];
|
|
4445
|
+
let running = 0;
|
|
4446
|
+
for (const e of entries) {
|
|
4447
|
+
offsets.push(running);
|
|
4448
|
+
running += branchUtf8Len(e.text);
|
|
4449
|
+
}
|
|
4450
|
+
offsets.push(running);
|
|
4451
|
+
branchState.logRowByteOffsets = offsets;
|
|
4452
|
+
editor.setPanelContent(branchState.groupId, "log", entries);
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
function branchByteOffsetOfFirstCommit(): number {
|
|
4456
|
+
return branchState.logRowByteOffsets.length > 1 ? branchState.logRowByteOffsets[1] : 0;
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
async function branchRefreshDetail(): Promise<void> {
|
|
4460
|
+
if (branchState.groupId === null) return;
|
|
4461
|
+
if (branchState.commits.length === 0) {
|
|
4462
|
+
const msg = editor.t("status.review_branch_empty") || "No commits in the selected range.";
|
|
4463
|
+
editor.setPanelContent(
|
|
4464
|
+
branchState.groupId,
|
|
4465
|
+
"detail",
|
|
4466
|
+
buildDetailPlaceholderEntries(msg),
|
|
4467
|
+
);
|
|
4468
|
+
return;
|
|
4469
|
+
}
|
|
4470
|
+
const idx = Math.max(0, Math.min(branchState.selectedIndex, branchState.commits.length - 1));
|
|
4471
|
+
const commit = branchState.commits[idx];
|
|
4472
|
+
if (!commit) return;
|
|
4473
|
+
|
|
4474
|
+
if (branchState.detailCache && branchState.detailCache.hash === commit.hash) {
|
|
4475
|
+
const entries = buildCommitDetailEntries(commit, branchState.detailCache.output, {});
|
|
4476
|
+
editor.setPanelContent(branchState.groupId, "detail", entries);
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4479
|
+
const myId = ++branchState.pendingDetailId;
|
|
4480
|
+
editor.setPanelContent(
|
|
4481
|
+
branchState.groupId,
|
|
4482
|
+
"detail",
|
|
4483
|
+
buildDetailPlaceholderEntries(
|
|
4484
|
+
editor.t("status.loading_commit", { hash: commit.shortHash }) || `Loading ${commit.shortHash}…`,
|
|
4485
|
+
),
|
|
4486
|
+
);
|
|
4487
|
+
const output = await fetchCommitShow(editor, commit.hash);
|
|
4488
|
+
if (myId !== branchState.pendingDetailId) return;
|
|
4489
|
+
if (branchState.groupId === null) return;
|
|
4490
|
+
branchState.detailCache = { hash: commit.hash, output };
|
|
4491
|
+
editor.setPanelContent(
|
|
4492
|
+
branchState.groupId,
|
|
4493
|
+
"detail",
|
|
4494
|
+
buildCommitDetailEntries(commit, output, {}),
|
|
4495
|
+
);
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
async function start_review_branch(): Promise<void> {
|
|
4499
|
+
if (branchState.isOpen) {
|
|
4500
|
+
editor.setStatus(editor.t("status.already_open") || "Review branch already open");
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4503
|
+
// Prompt for the base ref so the user can review any PR, not just
|
|
4504
|
+
// one branched off main.
|
|
4505
|
+
const input = await editor.prompt(
|
|
4506
|
+
editor.t("prompt.branch_base") || "Base ref (default: main):",
|
|
4507
|
+
branchState.baseRef,
|
|
4508
|
+
);
|
|
4509
|
+
if (input === null) {
|
|
4510
|
+
editor.setStatus(editor.t("status.cancelled") || "Cancelled");
|
|
4511
|
+
return;
|
|
4512
|
+
}
|
|
4513
|
+
const base = input.trim() || "main";
|
|
4514
|
+
branchState.baseRef = base;
|
|
4515
|
+
|
|
4516
|
+
editor.setStatus(editor.t("status.loading") || "Loading commits…");
|
|
4517
|
+
branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 });
|
|
4518
|
+
if (branchState.commits.length === 0) {
|
|
4519
|
+
editor.setStatus(
|
|
4520
|
+
editor.t("status.review_branch_empty", { base }) ||
|
|
4521
|
+
`No commits in ${base}..HEAD — nothing to review.`,
|
|
4522
|
+
);
|
|
4523
|
+
return;
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
const layout = JSON.stringify({
|
|
4527
|
+
type: "split",
|
|
4528
|
+
direction: "h",
|
|
4529
|
+
ratio: 0.4,
|
|
4530
|
+
first: { type: "scrollable", id: "log" },
|
|
4531
|
+
second: { type: "scrollable", id: "detail" },
|
|
4532
|
+
});
|
|
4533
|
+
// `createBufferGroup` is a runtime-only binding (not in the generated
|
|
4534
|
+
// EditorAPI type); cast to `any` so the type-checker doesn't complain.
|
|
4535
|
+
const group = await (editor as any).createBufferGroup(
|
|
4536
|
+
`*Review Branch ${base}..HEAD*`,
|
|
4537
|
+
"review-branch",
|
|
4538
|
+
layout,
|
|
4539
|
+
);
|
|
4540
|
+
branchState.groupId = group.groupId as number;
|
|
4541
|
+
branchState.logBufferId = (group.panels["log"] as number | undefined) ?? null;
|
|
4542
|
+
branchState.detailBufferId = (group.panels["detail"] as number | undefined) ?? null;
|
|
4543
|
+
branchState.selectedIndex = 0;
|
|
4544
|
+
branchState.detailCache = null;
|
|
4545
|
+
branchState.isOpen = true;
|
|
4546
|
+
|
|
4547
|
+
if (branchState.logBufferId !== null) {
|
|
4548
|
+
editor.setBufferShowCursors(branchState.logBufferId, true);
|
|
4549
|
+
}
|
|
4550
|
+
if (branchState.detailBufferId !== null) {
|
|
4551
|
+
editor.setBufferShowCursors(branchState.detailBufferId, true);
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
branchRenderLog();
|
|
4555
|
+
if (branchState.logBufferId !== null && branchState.commits.length > 0) {
|
|
4556
|
+
editor.setBufferCursor(branchState.logBufferId, branchByteOffsetOfFirstCommit());
|
|
4557
|
+
}
|
|
4558
|
+
await branchRefreshDetail();
|
|
4559
|
+
|
|
4560
|
+
if (branchState.groupId !== null) {
|
|
4561
|
+
editor.focusBufferGroupPanel(branchState.groupId, "log");
|
|
4562
|
+
}
|
|
4563
|
+
editor.on("cursor_moved", "on_review_branch_cursor_moved");
|
|
4564
|
+
|
|
4565
|
+
editor.setStatus(
|
|
4566
|
+
editor.t("status.review_branch_ready", {
|
|
4567
|
+
count: String(branchState.commits.length),
|
|
4568
|
+
base,
|
|
4569
|
+
}) || `Reviewing ${branchState.commits.length} commits in ${base}..HEAD`,
|
|
4570
|
+
);
|
|
4571
|
+
}
|
|
4572
|
+
registerHandler("start_review_branch", start_review_branch);
|
|
4573
|
+
|
|
4574
|
+
function stop_review_branch(): void {
|
|
4575
|
+
if (!branchState.isOpen) return;
|
|
4576
|
+
if (branchState.groupId !== null) editor.closeBufferGroup(branchState.groupId);
|
|
4577
|
+
editor.off("cursor_moved", "on_review_branch_cursor_moved");
|
|
4578
|
+
branchState.isOpen = false;
|
|
4579
|
+
branchState.groupId = null;
|
|
4580
|
+
branchState.logBufferId = null;
|
|
4581
|
+
branchState.detailBufferId = null;
|
|
4582
|
+
branchState.commits = [];
|
|
4583
|
+
branchState.selectedIndex = 0;
|
|
4584
|
+
branchState.detailCache = null;
|
|
4585
|
+
editor.setStatus(editor.t("status.closed") || "Review branch closed");
|
|
4586
|
+
}
|
|
4587
|
+
registerHandler("stop_review_branch", stop_review_branch);
|
|
4588
|
+
|
|
4589
|
+
async function review_branch_refresh(): Promise<void> {
|
|
4590
|
+
if (!branchState.isOpen) return;
|
|
4591
|
+
const base = branchState.baseRef;
|
|
4592
|
+
branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 });
|
|
4593
|
+
branchState.detailCache = null;
|
|
4594
|
+
if (branchState.selectedIndex >= branchState.commits.length) {
|
|
4595
|
+
branchState.selectedIndex = Math.max(0, branchState.commits.length - 1);
|
|
4596
|
+
}
|
|
4597
|
+
branchRenderLog();
|
|
4598
|
+
await branchRefreshDetail();
|
|
4599
|
+
}
|
|
4600
|
+
registerHandler("review_branch_refresh", review_branch_refresh);
|
|
4601
|
+
|
|
4602
|
+
/** Enter: focus the detail panel (so the user can scroll/click within it). */
|
|
4603
|
+
function review_branch_enter(): void {
|
|
4604
|
+
if (branchState.groupId === null) return;
|
|
4605
|
+
editor.focusBufferGroupPanel(branchState.groupId, "detail");
|
|
4606
|
+
}
|
|
4607
|
+
registerHandler("review_branch_enter", review_branch_enter);
|
|
4608
|
+
|
|
4609
|
+
/** q/Escape: focus-back from detail, or close when already on log. */
|
|
4610
|
+
function review_branch_close_or_back(): void {
|
|
4611
|
+
if (branchState.groupId === null) return;
|
|
4612
|
+
const active = editor.getActiveBufferId();
|
|
4613
|
+
if (branchState.detailBufferId !== null && active === branchState.detailBufferId) {
|
|
4614
|
+
editor.focusBufferGroupPanel(branchState.groupId, "log");
|
|
4615
|
+
return;
|
|
4616
|
+
}
|
|
4617
|
+
stop_review_branch();
|
|
4618
|
+
}
|
|
4619
|
+
registerHandler("review_branch_close_or_back", review_branch_close_or_back);
|
|
4620
|
+
|
|
4621
|
+
function on_review_branch_cursor_moved(data: {
|
|
4622
|
+
buffer_id: number;
|
|
4623
|
+
cursor_id: number;
|
|
4624
|
+
old_position: number;
|
|
4625
|
+
new_position: number;
|
|
4626
|
+
}): void {
|
|
4627
|
+
if (!branchState.isOpen) return;
|
|
4628
|
+
if (data.buffer_id !== branchState.logBufferId) return;
|
|
4629
|
+
const idx = branchIndexFromCursor(data.new_position);
|
|
4630
|
+
if (idx === branchState.selectedIndex) return;
|
|
4631
|
+
branchState.selectedIndex = idx;
|
|
4632
|
+
branchRenderLog();
|
|
4633
|
+
branchRefreshDetail();
|
|
4634
|
+
}
|
|
4635
|
+
registerHandler("on_review_branch_cursor_moved", on_review_branch_cursor_moved);
|
|
4636
|
+
|
|
4637
|
+
editor.defineMode(
|
|
4638
|
+
"review-branch",
|
|
4639
|
+
[
|
|
4640
|
+
// Mode bindings replace globals, so we re-bind the editor's built-in
|
|
4641
|
+
// motion actions here explicitly — without this, j/k and Up/Down
|
|
4642
|
+
// do nothing in the commit list.
|
|
4643
|
+
["Up", "move_up"],
|
|
4644
|
+
["Down", "move_down"],
|
|
4645
|
+
["k", "move_up"],
|
|
4646
|
+
["j", "move_down"],
|
|
4647
|
+
["PageUp", "page_up"],
|
|
4648
|
+
["PageDown", "page_down"],
|
|
4649
|
+
["Home", "move_line_start"],
|
|
4650
|
+
["End", "move_line_end"],
|
|
4651
|
+
// Enter: focus the right-hand detail panel.
|
|
4652
|
+
["Return", "review_branch_enter"],
|
|
4653
|
+
["Tab", "review_branch_enter"],
|
|
4654
|
+
["r", "review_branch_refresh"],
|
|
4655
|
+
["q", "review_branch_close_or_back"],
|
|
4656
|
+
["Escape", "review_branch_close_or_back"],
|
|
4657
|
+
],
|
|
4658
|
+
true,
|
|
4659
|
+
);
|
|
4660
|
+
|
|
2666
4661
|
// Register Modes and Commands
|
|
2667
4662
|
editor.registerCommand("%cmd.review_diff", "%cmd.review_diff_desc", "start_review_diff", null);
|
|
4663
|
+
editor.registerCommand("%cmd.review_branch", "%cmd.review_branch_desc", "start_review_branch", null);
|
|
4664
|
+
editor.registerCommand("%cmd.stop_review_branch", "%cmd.stop_review_branch_desc", "stop_review_branch", "review-branch");
|
|
4665
|
+
editor.registerCommand("%cmd.refresh_review_branch", "%cmd.refresh_review_branch_desc", "review_branch_refresh", "review-branch");
|
|
2668
4666
|
editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode");
|
|
2669
4667
|
editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode");
|
|
2670
4668
|
editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", null);
|
|
@@ -2709,23 +4707,42 @@ registerHandler("on_buffer_closed", on_buffer_closed);
|
|
|
2709
4707
|
editor.on("buffer_closed", "on_buffer_closed");
|
|
2710
4708
|
|
|
2711
4709
|
editor.defineMode("review-mode", [
|
|
2712
|
-
//
|
|
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.
|
|
4710
|
+
// Native cursor motion in the unified diff stream.
|
|
2716
4711
|
["Up", "review_nav_up"], ["Down", "review_nav_down"],
|
|
2717
4712
|
["k", "review_nav_up"], ["j", "review_nav_down"],
|
|
2718
4713
|
["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
|
|
2719
|
-
|
|
2720
|
-
//
|
|
2721
|
-
|
|
2722
|
-
//
|
|
4714
|
+
// Home / End — match the editor's normal-mode defaults so users
|
|
4715
|
+
// get the same start-of-line / end-of-line behavior they're used
|
|
4716
|
+
// to. Mode bindings replace globals, so we must bind these
|
|
4717
|
+
// explicitly even though the actions are built-in.
|
|
4718
|
+
["Home", "move_line_start"], ["End", "move_line_end"],
|
|
4719
|
+
// Hunk navigation across the unified stream.
|
|
2723
4720
|
["n", "review_next_hunk"], ["p", "review_prev_hunk"],
|
|
2724
|
-
//
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
["
|
|
4721
|
+
// Per-file collapse: Tab toggles the file under the cursor;
|
|
4722
|
+
// `z a` collapses every file; `z r` reveals (expands) every file.
|
|
4723
|
+
["Tab", "review_toggle_file_collapse"],
|
|
4724
|
+
["z a", "review_collapse_all"],
|
|
4725
|
+
["z r", "review_expand_all"],
|
|
4726
|
+
// Visual line-selection mode for line-level stage/unstage/discard.
|
|
4727
|
+
["v", "review_visual_start"],
|
|
4728
|
+
["Esc", "review_visual_cancel"],
|
|
4729
|
+
// Drill-down to side-by-side view of the file under the cursor —
|
|
4730
|
+
// unless focus is in the comments panel, in which case Enter opens
|
|
4731
|
+
// the selected comment.
|
|
4732
|
+
["Enter", "review_enter_dispatch"],
|
|
4733
|
+
// Comments-nav: cycle through comments, jump diff cursor, expand
|
|
4734
|
+
// the file if needed. Works regardless of which panel has focus.
|
|
4735
|
+
["]", "review_next_comment"],
|
|
4736
|
+
["[", "review_prev_comment"],
|
|
4737
|
+
// Focus the comments panel (use j/k/Enter inside).
|
|
4738
|
+
["`", "review_focus_comments"],
|
|
4739
|
+
// Stage/unstage/discard — context-sensitive. s/u/d act on the file
|
|
4740
|
+
// (when cursor is on a file header) or the hunk under the cursor.
|
|
4741
|
+
// Capital S/U/D always act on the enclosing file.
|
|
4742
|
+
["s", "review_stage_scope"], ["u", "review_unstage_scope"],
|
|
2728
4743
|
["d", "review_discard_file"],
|
|
4744
|
+
["S", "review_stage_file"], ["U", "review_unstage_file"],
|
|
4745
|
+
["D", "review_discard_file_only"],
|
|
2729
4746
|
["r", "review_refresh"],
|
|
2730
4747
|
// Comments
|
|
2731
4748
|
["c", "review_add_comment"],
|