@fresh-editor/fresh-editor 0.2.22 → 0.2.24
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 +97 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +521 -381
- package/plugins/audit_mode.ts +3211 -567
- package/plugins/config-schema.json +153 -1
- package/plugins/git_blame.ts +1 -6
- package/plugins/git_explorer.ts +7 -7
- package/plugins/git_log.ts +616 -1025
- package/plugins/lib/fresh.d.ts +94 -4
- package/plugins/lib/git_history.ts +596 -0
- package/plugins/markdown_compose.ts +183 -7
- package/plugins/pkg.ts +151 -397
- package/plugins/search_replace.i18n.json +42 -14
- package/plugins/search_replace.ts +146 -96
- package/plugins/theme_editor.i18n.json +182 -14
- package/plugins/theme_editor.ts +192 -85
- package/plugins/vi_mode.ts +8 -3
package/plugins/audit_mode.ts
CHANGED
|
@@ -3,26 +3,23 @@
|
|
|
3
3
|
/// <reference path="./lib/virtual-buffer-factory.ts" />
|
|
4
4
|
|
|
5
5
|
// Review Diff Plugin
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// - Uses editor.prompt() which doesn't exist in the API (needs event-based prompt)
|
|
10
|
-
// - Uses VirtualBufferOptions.read_only (should be readOnly)
|
|
11
|
-
// - References stop_review_diff which is undefined
|
|
6
|
+
// Magit-style split-panel UI for reviewing and staging code changes.
|
|
7
|
+
// Left panel: file list (staged/unstaged/untracked). Right panel: diff.
|
|
8
|
+
// Actions: stage/unstage/discard hunks or files, line comments, export.
|
|
12
9
|
const editor = getEditor();
|
|
13
10
|
|
|
14
11
|
import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
|
|
12
|
+
import {
|
|
13
|
+
type GitCommit,
|
|
14
|
+
buildCommitDetailEntries,
|
|
15
|
+
buildCommitLogEntries,
|
|
16
|
+
buildDetailPlaceholderEntries,
|
|
17
|
+
fetchCommitShow,
|
|
18
|
+
fetchGitLog,
|
|
19
|
+
} from "./lib/git_history.ts";
|
|
15
20
|
const VirtualBufferFactory = createVirtualBufferFactory(editor);
|
|
16
21
|
|
|
17
|
-
/**
|
|
18
|
-
* Hunk status for staging
|
|
19
|
-
*/
|
|
20
|
-
type HunkStatus = 'pending' | 'staged' | 'discarded';
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
* Review status for a hunk
|
|
24
|
-
*/
|
|
25
|
-
type ReviewStatus = 'pending' | 'approved' | 'needs_changes' | 'rejected' | 'question';
|
|
26
23
|
|
|
27
24
|
/**
|
|
28
25
|
* A review comment attached to a specific line in a file
|
|
@@ -57,8 +54,6 @@ interface Hunk {
|
|
|
57
54
|
oldRange: { start: number; end: number }; // old file line range
|
|
58
55
|
type: 'add' | 'remove' | 'modify';
|
|
59
56
|
lines: string[];
|
|
60
|
-
status: HunkStatus;
|
|
61
|
-
reviewStatus: ReviewStatus;
|
|
62
57
|
contextHeader: string;
|
|
63
58
|
byteOffset: number; // Position in the virtual buffer
|
|
64
59
|
gitStatus?: 'staged' | 'unstaged' | 'untracked';
|
|
@@ -76,38 +71,176 @@ interface FileEntry {
|
|
|
76
71
|
|
|
77
72
|
/**
|
|
78
73
|
* Review Session State
|
|
74
|
+
*
|
|
75
|
+
* Scrolling and cursor tracking inside the panel buffers is handled by the
|
|
76
|
+
* editor core natively — this state only mirrors what the plugin needs to
|
|
77
|
+
* know between events (selected file, focused panel, hunk header rows for
|
|
78
|
+
* `n`/`p` jumps).
|
|
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.
|
|
79
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
|
+
|
|
80
108
|
interface ReviewState {
|
|
81
109
|
hunks: Hunk[];
|
|
82
|
-
hunkStatus: Record<string, HunkStatus>;
|
|
83
110
|
comments: ReviewComment[];
|
|
84
|
-
|
|
85
|
-
overallFeedback?: string;
|
|
111
|
+
note: string;
|
|
86
112
|
reviewBufferId: number | null;
|
|
87
|
-
|
|
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.
|
|
88
128
|
files: FileEntry[];
|
|
89
|
-
|
|
90
|
-
fileScrollOffset: number;
|
|
91
|
-
diffScrollOffset: number;
|
|
129
|
+
emptyState: EmptyStateReason;
|
|
92
130
|
viewportWidth: number;
|
|
93
131
|
viewportHeight: number;
|
|
94
|
-
focusPanel: '
|
|
132
|
+
focusPanel: 'diff' | 'comments';
|
|
133
|
+
groupId: number | null;
|
|
134
|
+
panelBuffers: Record<string, 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
|
|
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;
|
|
95
195
|
}
|
|
96
196
|
|
|
97
197
|
const state: ReviewState = {
|
|
98
198
|
hunks: [],
|
|
99
|
-
hunkStatus: {},
|
|
100
199
|
comments: [],
|
|
200
|
+
note: '',
|
|
101
201
|
reviewBufferId: null,
|
|
202
|
+
mode: 'worktree',
|
|
203
|
+
range: null,
|
|
204
|
+
repoRoot: '',
|
|
205
|
+
reviewKey: 'worktree',
|
|
102
206
|
files: [],
|
|
103
|
-
|
|
104
|
-
fileScrollOffset: 0,
|
|
105
|
-
diffScrollOffset: 0,
|
|
207
|
+
emptyState: null,
|
|
106
208
|
viewportWidth: 80,
|
|
107
209
|
viewportHeight: 24,
|
|
108
|
-
focusPanel: '
|
|
210
|
+
focusPanel: 'diff',
|
|
211
|
+
groupId: null,
|
|
212
|
+
panelBuffers: {},
|
|
213
|
+
hunkHeaderRows: [],
|
|
214
|
+
diffLineByteOffsets: [],
|
|
215
|
+
diffCursorRow: 1,
|
|
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,
|
|
109
233
|
};
|
|
110
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
|
+
|
|
238
|
+
// Theme colour for the synthetic "cursor line" highlight in the panel
|
|
239
|
+
// buffers. Reintroduced after the per-line bg overlay was deleted from the
|
|
240
|
+
// builders — `applyCursorLineOverlay` writes it on every cursor_moved event.
|
|
241
|
+
const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
|
|
242
|
+
const CURSOR_LINE_NS = "review-cursor-line";
|
|
243
|
+
|
|
111
244
|
// --- Refresh State ---
|
|
112
245
|
|
|
113
246
|
// --- Colors & Styles ---
|
|
@@ -119,14 +252,46 @@ const STYLE_ADD_BG: OverlayColorSpec = "editor.diff_add_bg";
|
|
|
119
252
|
const STYLE_REMOVE_BG: OverlayColorSpec = "editor.diff_remove_bg";
|
|
120
253
|
const STYLE_ADD_TEXT: OverlayColorSpec = "diagnostic.info_fg";
|
|
121
254
|
const STYLE_REMOVE_TEXT: OverlayColorSpec = "diagnostic.error_fg";
|
|
122
|
-
|
|
123
|
-
const STYLE_DISCARDED: OverlayColorSpec = "diagnostic.error_fg";
|
|
255
|
+
|
|
124
256
|
const STYLE_SECTION_HEADER: OverlayColorSpec = "syntax.type";
|
|
125
257
|
const STYLE_COMMENT: OverlayColorSpec = "diagnostic.warning_fg";
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
294
|
+
|
|
130
295
|
|
|
131
296
|
/**
|
|
132
297
|
* Calculate UTF-8 byte length of a string manually since TextEncoder is not available
|
|
@@ -144,6 +309,152 @@ function getByteLength(str: string): number {
|
|
|
144
309
|
return s;
|
|
145
310
|
}
|
|
146
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
|
+
|
|
147
458
|
// --- Diff Logic ---
|
|
148
459
|
|
|
149
460
|
interface DiffPart {
|
|
@@ -151,46 +462,45 @@ interface DiffPart {
|
|
|
151
462
|
type: 'added' | 'removed' | 'unchanged';
|
|
152
463
|
}
|
|
153
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Inline word-level diff between two changed lines.
|
|
467
|
+
*
|
|
468
|
+
* Used to highlight the *changed region* inside a -/+ pair, called once per
|
|
469
|
+
* adjacent pair while building a file's diff. The previous implementation
|
|
470
|
+
* was a full O(n*m) LCS that allocated an (n+1)*(m+1) DP table per pair —
|
|
471
|
+
* fast enough for short lines, but for files with hundreds of long-line
|
|
472
|
+
* changes (e.g. `audit_mode.ts` itself) it added hundreds of milliseconds
|
|
473
|
+
* to every diff rebuild and made file-list navigation visibly laggy.
|
|
474
|
+
*
|
|
475
|
+
* This O(n+m) scan finds the longest common prefix and suffix and reports
|
|
476
|
+
* everything in between as the changed region. It misses internal matches
|
|
477
|
+
* (e.g. it can't tell that "abc-xy-def" → "abc-zw-def" only changed the
|
|
478
|
+
* middle "xy"), but for inline highlighting that's fine — the human eye is
|
|
479
|
+
* already drawn to the line as a whole, the highlight just answers "where
|
|
480
|
+
* inside the line did the change happen?". The cost difference is dramatic:
|
|
481
|
+
* for two 200-char lines, ~400 char compares vs. ~40 000.
|
|
482
|
+
*/
|
|
154
483
|
function diffStrings(oldStr: string, newStr: string): DiffPart[] {
|
|
155
484
|
const n = oldStr.length;
|
|
156
485
|
const m = newStr.length;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const result: DiffPart[] = [];
|
|
170
|
-
let i = n, j = m;
|
|
171
|
-
while (i > 0 || j > 0) {
|
|
172
|
-
if (i > 0 && j > 0 && oldStr[i - 1] === newStr[j - 1]) {
|
|
173
|
-
result.unshift({ text: oldStr[i - 1], type: 'unchanged' });
|
|
174
|
-
i--; j--;
|
|
175
|
-
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
176
|
-
result.unshift({ text: newStr[j - 1], type: 'added' });
|
|
177
|
-
j--;
|
|
178
|
-
} else {
|
|
179
|
-
result.unshift({ text: oldStr[i - 1], type: 'removed' });
|
|
180
|
-
i--;
|
|
181
|
-
}
|
|
486
|
+
let pre = 0;
|
|
487
|
+
const minLen = Math.min(n, m);
|
|
488
|
+
while (pre < minLen && oldStr.charCodeAt(pre) === newStr.charCodeAt(pre)) pre++;
|
|
489
|
+
let suf = 0;
|
|
490
|
+
while (
|
|
491
|
+
suf < n - pre &&
|
|
492
|
+
suf < m - pre &&
|
|
493
|
+
oldStr.charCodeAt(n - 1 - suf) === newStr.charCodeAt(m - 1 - suf)
|
|
494
|
+
) {
|
|
495
|
+
suf++;
|
|
182
496
|
}
|
|
183
497
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
coalesced.push(part);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return coalesced;
|
|
498
|
+
const parts: DiffPart[] = [];
|
|
499
|
+
if (pre > 0) parts.push({ text: oldStr.slice(0, pre), type: 'unchanged' });
|
|
500
|
+
if (pre < n - suf) parts.push({ text: oldStr.slice(pre, n - suf), type: 'removed' });
|
|
501
|
+
if (pre < m - suf) parts.push({ text: newStr.slice(pre, m - suf), type: 'added' });
|
|
502
|
+
if (suf > 0) parts.push({ text: oldStr.slice(n - suf), type: 'unchanged' });
|
|
503
|
+
return parts;
|
|
194
504
|
}
|
|
195
505
|
|
|
196
506
|
function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'untracked'): Hunk[] {
|
|
@@ -220,7 +530,6 @@ function parseDiffOutput(stdout: string, gitStatus: 'staged' | 'unstaged' | 'unt
|
|
|
220
530
|
type: 'modify',
|
|
221
531
|
lines: [],
|
|
222
532
|
status: 'pending',
|
|
223
|
-
reviewStatus: 'pending',
|
|
224
533
|
contextHeader: match[3]?.trim() || "",
|
|
225
534
|
byteOffset: 0,
|
|
226
535
|
gitStatus
|
|
@@ -310,11 +619,28 @@ function parseGitStatusPorcelain(raw: string): FileEntry[] {
|
|
|
310
619
|
|
|
311
620
|
/**
|
|
312
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.
|
|
313
628
|
*/
|
|
314
|
-
|
|
629
|
+
interface GitStatusResult {
|
|
630
|
+
files: FileEntry[];
|
|
631
|
+
emptyReason: EmptyStateReason;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function getGitStatus(): Promise<GitStatusResult> {
|
|
315
635
|
const result = await editor.spawnProcess("git", ["status", "--porcelain", "-z"]);
|
|
316
|
-
if (result.exit_code !== 0)
|
|
317
|
-
|
|
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
|
+
};
|
|
318
644
|
}
|
|
319
645
|
|
|
320
646
|
/**
|
|
@@ -374,7 +700,6 @@ async function fetchDiffsForFiles(files: FileEntry[]): Promise<Hunk[]> {
|
|
|
374
700
|
|
|
375
701
|
// --- New magit-style rendering (Step 2 of rewrite) ---
|
|
376
702
|
|
|
377
|
-
const STYLE_SELECTED_BG: OverlayColorSpec = "editor.selection_bg";
|
|
378
703
|
const STYLE_DIVIDER: OverlayColorSpec = "ui.split_separator_fg";
|
|
379
704
|
const STYLE_FOOTER: OverlayColorSpec = "ui.status_bar_fg";
|
|
380
705
|
const STYLE_HUNK_HEADER: OverlayColorSpec = "syntax.keyword";
|
|
@@ -389,114 +714,322 @@ interface ListLine {
|
|
|
389
714
|
|
|
390
715
|
interface DiffLine {
|
|
391
716
|
text: string;
|
|
392
|
-
type: 'hunk-header' | 'add' | 'remove' | 'context' | 'empty';
|
|
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
|
|
393
721
|
style?: Partial<OverlayOptions>;
|
|
394
722
|
inlineOverlays?: InlineOverlay[];
|
|
723
|
+
// Line metadata for comment attachment
|
|
724
|
+
hunkId?: string;
|
|
725
|
+
file?: string;
|
|
726
|
+
lineType?: 'add' | 'remove' | 'context';
|
|
727
|
+
oldLine?: number;
|
|
728
|
+
newLine?: number;
|
|
729
|
+
lineContent?: string;
|
|
730
|
+
commentId?: string;
|
|
395
731
|
}
|
|
396
732
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
// Section headers
|
|
408
|
-
if (f.category !== lastCategory) {
|
|
409
|
-
lastCategory = f.category;
|
|
410
|
-
let label = '';
|
|
411
|
-
if (f.category === 'staged') label = editor.t("section.staged") || "Staged";
|
|
412
|
-
else if (f.category === 'unstaged') label = editor.t("section.unstaged") || "Changes";
|
|
413
|
-
else if (f.category === 'untracked') label = editor.t("section.untracked") || "Untracked";
|
|
414
|
-
lines.push({
|
|
415
|
-
text: `▸ ${label}`,
|
|
416
|
-
type: 'section-header',
|
|
417
|
-
style: { fg: STYLE_SECTION_HEADER, bold: true },
|
|
418
|
-
});
|
|
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++;
|
|
742
|
+
}
|
|
419
743
|
}
|
|
744
|
+
}
|
|
745
|
+
return { added, removed };
|
|
746
|
+
}
|
|
420
747
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
748
|
+
/**
|
|
749
|
+
* Push inline comment lines for a given diff line into the lines array.
|
|
750
|
+
*/
|
|
751
|
+
function pushLineComments(
|
|
752
|
+
lines: DiffLine[], hunk: Hunk,
|
|
753
|
+
lineType: 'add' | 'remove' | 'context',
|
|
754
|
+
oldLine: number | undefined, newLine: number | undefined
|
|
755
|
+
) {
|
|
756
|
+
const lineComments = state.comments.filter(c =>
|
|
757
|
+
c.hunk_id === hunk.id && (
|
|
758
|
+
(c.line_type === 'add' && c.new_line === newLine) ||
|
|
759
|
+
(c.line_type === 'remove' && c.old_line === oldLine) ||
|
|
760
|
+
(c.line_type === 'context' && c.new_line === newLine)
|
|
761
|
+
)
|
|
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);
|
|
766
|
+
for (const comment of lineComments) {
|
|
767
|
+
const lineRef = comment.line_type === 'add'
|
|
768
|
+
? `+${comment.new_line}`
|
|
769
|
+
: comment.line_type === 'remove'
|
|
770
|
+
? `-${comment.old_line}`
|
|
771
|
+
: `${comment.new_line}`;
|
|
425
772
|
lines.push({
|
|
426
|
-
text: `${
|
|
427
|
-
type: '
|
|
428
|
-
|
|
773
|
+
text: `${commentIndent}\u00bb [${lineRef}] ${comment.text}`,
|
|
774
|
+
type: 'comment',
|
|
775
|
+
commentId: comment.id,
|
|
776
|
+
style: { fg: STYLE_COMMENT, italic: true },
|
|
429
777
|
});
|
|
430
778
|
}
|
|
431
|
-
|
|
432
|
-
return lines;
|
|
433
779
|
}
|
|
434
780
|
|
|
435
781
|
/**
|
|
436
|
-
* 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.
|
|
437
785
|
*/
|
|
438
|
-
function buildDiffLines(
|
|
786
|
+
function buildDiffLines(_rightWidth: number): DiffLine[] {
|
|
439
787
|
const lines: DiffLine[] = [];
|
|
440
|
-
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
|
+
}
|
|
441
804
|
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
}
|
|
444
846
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
+
});
|
|
449
867
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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;
|
|
461
887
|
}
|
|
462
|
-
return lines;
|
|
463
|
-
}
|
|
464
888
|
|
|
465
|
-
|
|
466
|
-
// Hunk header
|
|
467
|
-
|
|
889
|
+
for (const hunk of fileHunks) {
|
|
890
|
+
// Hunk header — always emit expanded triangle; collapse
|
|
891
|
+
// overlays a `▸` replacement-conceal.
|
|
892
|
+
const headerInner = hunk.contextHeader
|
|
468
893
|
? `@@ ${hunk.contextHeader} @@`
|
|
469
894
|
: `@@ -${hunk.oldRange.start} +${hunk.range.start} @@`;
|
|
895
|
+
const header = ` ▾ ${headerInner}`;
|
|
896
|
+
|
|
470
897
|
lines.push({
|
|
471
898
|
text: header,
|
|
472
899
|
type: 'hunk-header',
|
|
473
|
-
|
|
900
|
+
hunkId: hunk.id,
|
|
901
|
+
file: hunk.file,
|
|
902
|
+
style: {
|
|
903
|
+
fg: STYLE_HUNK_HEADER,
|
|
904
|
+
bg: STYLE_HUNK_HEADER_BG,
|
|
905
|
+
bold: true,
|
|
906
|
+
extendToLineEnd: true,
|
|
907
|
+
},
|
|
474
908
|
});
|
|
475
909
|
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
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.)
|
|
915
|
+
|
|
916
|
+
// Track actual file line numbers as we iterate
|
|
917
|
+
let oldLineNum = hunk.oldRange.start;
|
|
918
|
+
let newLineNum = hunk.range.start;
|
|
919
|
+
|
|
920
|
+
// Diff content lines with word-level highlighting for adjacent -/+ pairs
|
|
921
|
+
for (let li = 0; li < hunk.lines.length; li++) {
|
|
922
|
+
const line = hunk.lines[li];
|
|
923
|
+
const nextLine = hunk.lines[li + 1];
|
|
480
924
|
const prefix = line[0];
|
|
925
|
+
const lineType: 'add' | 'remove' | 'context' =
|
|
926
|
+
prefix === '+' ? 'add' : prefix === '-' ? 'remove' : 'context';
|
|
927
|
+
const curOldLine = lineType !== 'add' ? oldLineNum : undefined;
|
|
928
|
+
const curNewLine = lineType !== 'remove' ? newLineNum : undefined;
|
|
929
|
+
|
|
930
|
+
// Detect adjacent -/+ pair for word-level diff
|
|
931
|
+
if (prefix === '-' && nextLine && nextLine[0] === '+') {
|
|
932
|
+
const oldContent = line.substring(1);
|
|
933
|
+
const newContent = nextLine.substring(1);
|
|
934
|
+
const parts = diffStrings(oldContent, newContent);
|
|
935
|
+
|
|
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
|
|
944
|
+
for (const part of parts) {
|
|
945
|
+
const pLen = getByteLength(part.text);
|
|
946
|
+
if (part.type === 'removed') {
|
|
947
|
+
removeOverlays.push({ start: rOffset, end: rOffset + pLen, style: { fg: STYLE_REMOVE_TEXT, bg: STYLE_REMOVE_BG, bold: true } });
|
|
948
|
+
}
|
|
949
|
+
if (part.type !== 'added') rOffset += pLen;
|
|
950
|
+
}
|
|
951
|
+
lines.push({
|
|
952
|
+
text: removeText, type: 'remove',
|
|
953
|
+
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
954
|
+
hunkId: hunk.id, file: hunk.file,
|
|
955
|
+
lineType: 'remove', oldLine: curOldLine, newLine: undefined, lineContent: line,
|
|
956
|
+
inlineOverlays: removeOverlays,
|
|
957
|
+
});
|
|
958
|
+
// Inline comments for the removed line
|
|
959
|
+
pushLineComments(lines, hunk, 'remove', curOldLine, undefined);
|
|
960
|
+
oldLineNum++;
|
|
961
|
+
|
|
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]);
|
|
970
|
+
for (const part of parts) {
|
|
971
|
+
const pLen = getByteLength(part.text);
|
|
972
|
+
if (part.type === 'added') {
|
|
973
|
+
addOverlays.push({ start: aOffset, end: aOffset + pLen, style: { fg: STYLE_ADD_TEXT, bg: STYLE_ADD_BG, bold: true } });
|
|
974
|
+
}
|
|
975
|
+
if (part.type !== 'removed') aOffset += pLen;
|
|
976
|
+
}
|
|
977
|
+
lines.push({
|
|
978
|
+
text: addText, type: 'add',
|
|
979
|
+
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
980
|
+
hunkId: hunk.id, file: hunk.file,
|
|
981
|
+
lineType: 'add', oldLine: undefined, newLine: newLineNum, lineContent: nextLine,
|
|
982
|
+
inlineOverlays: addOverlays,
|
|
983
|
+
});
|
|
984
|
+
pushLineComments(lines, hunk, 'add', undefined, newLineNum);
|
|
985
|
+
newLineNum++;
|
|
986
|
+
li++; // skip the + line we already processed
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
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
|
+
|
|
481
997
|
if (prefix === '+') {
|
|
482
998
|
lines.push({
|
|
483
|
-
text:
|
|
484
|
-
type: 'add',
|
|
999
|
+
text: decoratedText, type: 'add',
|
|
485
1000
|
style: { bg: STYLE_ADD_BG, extendToLineEnd: true },
|
|
1001
|
+
hunkId: hunk.id, file: hunk.file,
|
|
1002
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
1003
|
+
inlineOverlays: [dimNumOverlay],
|
|
486
1004
|
});
|
|
1005
|
+
newLineNum++;
|
|
487
1006
|
} else if (prefix === '-') {
|
|
488
1007
|
lines.push({
|
|
489
|
-
text:
|
|
490
|
-
type: 'remove',
|
|
1008
|
+
text: decoratedText, type: 'remove',
|
|
491
1009
|
style: { bg: STYLE_REMOVE_BG, extendToLineEnd: true },
|
|
1010
|
+
hunkId: hunk.id, file: hunk.file,
|
|
1011
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
1012
|
+
inlineOverlays: [dimNumOverlay],
|
|
492
1013
|
});
|
|
1014
|
+
oldLineNum++;
|
|
493
1015
|
} else {
|
|
494
1016
|
lines.push({
|
|
495
|
-
text:
|
|
496
|
-
|
|
1017
|
+
text: decoratedText, type: 'context',
|
|
1018
|
+
hunkId: hunk.id, file: hunk.file,
|
|
1019
|
+
lineType, oldLine: curOldLine, newLine: curNewLine, lineContent: line,
|
|
1020
|
+
inlineOverlays: [dimNumOverlay],
|
|
497
1021
|
});
|
|
1022
|
+
oldLineNum++;
|
|
1023
|
+
newLineNum++;
|
|
498
1024
|
}
|
|
1025
|
+
|
|
1026
|
+
// Render inline comments attached to this line
|
|
1027
|
+
pushLineComments(lines, hunk, lineType, curOldLine, curNewLine);
|
|
499
1028
|
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Blank separator between files
|
|
1032
|
+
lines.push({ text: '', type: 'empty' });
|
|
500
1033
|
}
|
|
501
1034
|
|
|
502
1035
|
return lines;
|
|
@@ -509,282 +1042,1416 @@ function buildDiffLines(rightWidth: number): DiffLine[] {
|
|
|
509
1042
|
* Row 1: Header (left: GIT STATUS, right: DIFF FOR <file>)
|
|
510
1043
|
* Rows 2..H-1: Main content (left file list, │ divider, right diff)
|
|
511
1044
|
*/
|
|
512
|
-
|
|
1045
|
+
|
|
1046
|
+
// Theme colors for toolbar key hints
|
|
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";
|
|
1054
|
+
const STYLE_HINT_FG: OverlayColorSpec = "editor.line_number_fg";
|
|
1055
|
+
const STYLE_TOOLBAR_BG: OverlayColorSpec = "editor.bg";
|
|
1056
|
+
const STYLE_TOOLBAR_SEP: OverlayColorSpec = "ui.split_separator_fg";
|
|
1057
|
+
|
|
1058
|
+
interface HintItem {
|
|
1059
|
+
key: string;
|
|
1060
|
+
label: string;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Build a styled toolbar entry with highlighted key hints.
|
|
1065
|
+
* Keys get bold + keyword color; labels get dim text; groups separated by │.
|
|
1066
|
+
*/
|
|
1067
|
+
function buildToolbarRow(W: number, groups: HintItem[][]): TextPropertyEntry {
|
|
1068
|
+
const overlays: InlineOverlay[] = [];
|
|
1069
|
+
let text = " ";
|
|
1070
|
+
let bytePos = getByteLength(" ");
|
|
1071
|
+
let done = false;
|
|
1072
|
+
|
|
1073
|
+
for (let g = 0; g < groups.length && !done; g++) {
|
|
1074
|
+
if (g > 0) {
|
|
1075
|
+
const sep = " │ ";
|
|
1076
|
+
if (text.length + sep.length > W) { done = true; break; }
|
|
1077
|
+
overlays.push({ start: bytePos, end: bytePos + getByteLength(sep), style: { fg: STYLE_TOOLBAR_SEP } });
|
|
1078
|
+
text += sep;
|
|
1079
|
+
bytePos += getByteLength(sep);
|
|
1080
|
+
}
|
|
1081
|
+
for (let h = 0; h < groups[g].length && !done; h++) {
|
|
1082
|
+
const item = groups[g][h];
|
|
1083
|
+
const gap = h > 0 ? " " : "";
|
|
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;
|
|
1093
|
+
|
|
1094
|
+
if (text.length + fullLen <= W) {
|
|
1095
|
+
if (gap) { text += gap; bytePos += getByteLength(gap); }
|
|
1096
|
+
const keyLen = getByteLength(keyDisplay);
|
|
1097
|
+
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
|
|
1098
|
+
text += keyDisplay;
|
|
1099
|
+
bytePos += keyLen;
|
|
1100
|
+
const labelText = " " + item.label;
|
|
1101
|
+
const labelLen = getByteLength(labelText);
|
|
1102
|
+
overlays.push({ start: bytePos, end: bytePos + labelLen, style: { fg: STYLE_HINT_FG } });
|
|
1103
|
+
text += labelText;
|
|
1104
|
+
bytePos += labelLen;
|
|
1105
|
+
} else if (text.length + keyOnlyLen <= W) {
|
|
1106
|
+
if (gap) { text += gap; bytePos += getByteLength(gap); }
|
|
1107
|
+
const keyLen = getByteLength(keyDisplay);
|
|
1108
|
+
overlays.push({ start: bytePos, end: bytePos + keyLen, style: { fg: STYLE_KEY_FG, bold: true } });
|
|
1109
|
+
text += keyDisplay;
|
|
1110
|
+
bytePos += keyLen;
|
|
1111
|
+
} else {
|
|
1112
|
+
done = true;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const padded = text.padEnd(W) + "\n";
|
|
1118
|
+
return {
|
|
1119
|
+
text: padded,
|
|
1120
|
+
properties: { type: "toolbar" },
|
|
1121
|
+
style: { bg: STYLE_TOOLBAR_BG, extendToLineEnd: true },
|
|
1122
|
+
inlineOverlays: overlays,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
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)];
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// --- Buffer Group panel content builders ---
|
|
1160
|
+
|
|
1161
|
+
function buildToolbarPanelEntries(): TextPropertyEntry[] {
|
|
1162
|
+
// Two-row toolbar: navigation hints on row 1, actions on row 2.
|
|
1163
|
+
return buildToolbar(state.viewportWidth);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
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.
|
|
1173
|
+
*/
|
|
1174
|
+
function buildDiffPanelEntries(): TextPropertyEntry[] {
|
|
513
1175
|
const entries: TextPropertyEntry[] = [];
|
|
514
|
-
const H = state.viewportHeight;
|
|
515
|
-
const W = state.viewportWidth;
|
|
516
|
-
const leftWidth = Math.max(28, Math.floor(W * 0.3));
|
|
517
|
-
const rightWidth = W - leftWidth - 1; // 1 for divider
|
|
518
1176
|
|
|
519
|
-
const
|
|
520
|
-
const
|
|
1177
|
+
const hunkHeaderRows: number[] = [];
|
|
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
|
+
|
|
1199
|
+
let runningByte = 0;
|
|
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
|
|
1202
|
+
|
|
1203
|
+
const pushEntry = (entry: TextPropertyEntry) => {
|
|
1204
|
+
diffLineByteOffsets.push(runningByte);
|
|
1205
|
+
runningByte += getByteLength(entry.text);
|
|
1206
|
+
entries.push(entry);
|
|
1207
|
+
row++;
|
|
1208
|
+
};
|
|
521
1209
|
|
|
522
|
-
const
|
|
1210
|
+
const lines = buildDiffLines(state.viewportWidth);
|
|
1211
|
+
for (const line of lines) {
|
|
1212
|
+
const props: Record<string, unknown> = { type: line.type };
|
|
1213
|
+
if (line.hunkId !== undefined) props.hunkId = line.hunkId;
|
|
1214
|
+
if (line.file !== undefined) props.file = line.file;
|
|
1215
|
+
if (line.lineType !== undefined) props.lineType = line.lineType;
|
|
1216
|
+
if (line.oldLine !== undefined) props.oldLine = line.oldLine;
|
|
1217
|
+
if (line.newLine !== undefined) props.newLine = line.newLine;
|
|
1218
|
+
if (line.lineContent !== undefined) props.lineContent = line.lineContent;
|
|
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
|
+
}
|
|
523
1246
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if (allFileLines[i].type === 'file' && allFileLines[i].fileIndex === state.selectedIndex) {
|
|
528
|
-
selectedLineIdx = i;
|
|
529
|
-
break;
|
|
1247
|
+
if (line.type === 'hunk-header') {
|
|
1248
|
+
hunkHeaderRows.push(row + 1);
|
|
1249
|
+
if (line.hunkId) hunkRowByHunkId[line.hunkId] = row + 1;
|
|
530
1250
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (selectedLineIdx < state.fileScrollOffset) {
|
|
534
|
-
state.fileScrollOffset = selectedLineIdx;
|
|
1251
|
+
if (line.type === 'file-header' && line.fileKey) {
|
|
1252
|
+
fileHeaderRows[line.fileKey] = row + 1;
|
|
535
1253
|
}
|
|
536
|
-
if (
|
|
537
|
-
|
|
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);
|
|
538
1262
|
}
|
|
539
|
-
}
|
|
540
|
-
const maxFileOffset = Math.max(0, allFileLines.length - mainRows);
|
|
541
|
-
if (state.fileScrollOffset > maxFileOffset) state.fileScrollOffset = maxFileOffset;
|
|
542
|
-
if (state.fileScrollOffset < 0) state.fileScrollOffset = 0;
|
|
543
1263
|
|
|
544
|
-
|
|
1264
|
+
entryPropsByRow[row + 1] = props;
|
|
545
1265
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1266
|
+
pushEntry({
|
|
1267
|
+
text: (line.text || "") + "\n",
|
|
1268
|
+
style: line.style,
|
|
1269
|
+
inlineOverlays: line.inlineOverlays,
|
|
1270
|
+
properties: props,
|
|
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;
|
|
1278
|
+
}
|
|
550
1279
|
|
|
551
|
-
|
|
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
|
+
|
|
1285
|
+
diffLineByteOffsets.push(runningByte);
|
|
1286
|
+
|
|
1287
|
+
state.hunkHeaderRows = hunkHeaderRows;
|
|
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
|
+
}
|
|
552
1299
|
|
|
553
|
-
|
|
554
|
-
|
|
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();
|
|
555
1312
|
entries.push({
|
|
556
|
-
text:
|
|
557
|
-
style: {
|
|
558
|
-
|
|
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" },
|
|
559
1321
|
});
|
|
560
1322
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
: { fg: STYLE_HEADER, bold: true, underline: true };
|
|
577
|
-
|
|
578
|
-
entries.push({ text: leftHeaderPadded, style: leftHeaderStyle, properties: { type: "header" } });
|
|
579
|
-
entries.push({ text: "│", style: { fg: STYLE_DIVIDER }, properties: { type: "divider" } });
|
|
580
|
-
entries.push({ text: rightHeaderPadded, style: rightHeaderStyle, properties: { type: "header" } });
|
|
581
|
-
entries.push({ text: "\n", properties: { type: "newline" } });
|
|
582
|
-
|
|
583
|
-
// --- Rows 2..H-1: Main content ---
|
|
584
|
-
for (let i = 0; i < mainRows; i++) {
|
|
585
|
-
const fileItem = visibleFileLines[i];
|
|
586
|
-
const diffItem = visibleDiffLines[i];
|
|
587
|
-
|
|
588
|
-
// Left panel
|
|
589
|
-
const leftText = fileItem ? (" " + fileItem.text) : "";
|
|
590
|
-
const leftPadded = leftText.padEnd(leftWidth).substring(0, leftWidth);
|
|
591
|
-
const isSelected = fileItem?.type === 'file' && fileItem.fileIndex === state.selectedIndex;
|
|
592
|
-
|
|
593
|
-
const leftEntry: TextPropertyEntry = {
|
|
594
|
-
text: leftPadded,
|
|
595
|
-
properties: {
|
|
596
|
-
type: fileItem?.type || "blank",
|
|
597
|
-
fileIndex: fileItem?.fileIndex,
|
|
598
|
-
},
|
|
599
|
-
style: fileItem?.style,
|
|
600
|
-
inlineOverlays: fileItem?.inlineOverlays,
|
|
601
|
-
};
|
|
602
|
-
if (isSelected) {
|
|
603
|
-
leftEntry.style = { ...(leftEntry.style || {}), bg: STYLE_SELECTED_BG, bold: true };
|
|
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;
|
|
604
1338
|
}
|
|
605
|
-
|
|
1339
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1340
|
+
};
|
|
606
1341
|
|
|
607
|
-
|
|
608
|
-
|
|
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
|
+
});
|
|
609
1353
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
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;
|
|
617
1385
|
entries.push({
|
|
618
|
-
text:
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1386
|
+
text: display + "\n",
|
|
1387
|
+
style,
|
|
1388
|
+
inlineOverlays,
|
|
1389
|
+
properties: { type: "comment-nav", commentId: c.id, file: c.file, line: lineRef },
|
|
622
1390
|
});
|
|
623
|
-
|
|
624
|
-
// Newline
|
|
625
|
-
entries.push({ text: "\n", properties: { type: "newline" } });
|
|
626
1391
|
}
|
|
627
1392
|
|
|
628
1393
|
return entries;
|
|
629
1394
|
}
|
|
630
1395
|
|
|
631
1396
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
1397
|
+
* Full refresh — rebuild all three panels. Called on data changes
|
|
1398
|
+
* (refreshMagitData, comment add/edit, note edit, resize). NOT called on
|
|
1399
|
+
* scroll: scrolling is handled natively by the editor in the panel buffers.
|
|
634
1400
|
*/
|
|
635
1401
|
function updateMagitDisplay(): void {
|
|
636
|
-
if (state.reviewBufferId === null) return;
|
|
637
1402
|
refreshViewportDimensions();
|
|
638
|
-
|
|
639
|
-
editor.
|
|
640
|
-
editor.
|
|
1403
|
+
if (state.groupId === null) return;
|
|
1404
|
+
editor.setPanelContent(state.groupId, "toolbar", buildToolbarPanelEntries());
|
|
1405
|
+
editor.setPanelContent(state.groupId, "diff", buildDiffPanelEntries());
|
|
1406
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1407
|
+
refreshStickyHeader(0);
|
|
1408
|
+
applyFolds();
|
|
1409
|
+
applyCursorLineOverlay('diff');
|
|
641
1410
|
}
|
|
642
1411
|
|
|
643
|
-
|
|
644
|
-
|
|
1412
|
+
/**
|
|
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.
|
|
1424
|
+
*/
|
|
1425
|
+
function applyFolds(): void {
|
|
1426
|
+
if (state.groupId === null) return;
|
|
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;
|
|
645
1454
|
|
|
646
|
-
|
|
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
|
+
}
|
|
647
1470
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
state.
|
|
653
|
-
|
|
654
|
-
|
|
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 };
|
|
655
1489
|
}
|
|
656
1490
|
} else {
|
|
657
|
-
|
|
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);
|
|
658
1525
|
updateMagitDisplay();
|
|
659
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);
|
|
660
1550
|
}
|
|
661
|
-
registerHandler("review_nav_up", review_nav_up);
|
|
662
1551
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
+
}
|
|
670
1585
|
}
|
|
671
|
-
|
|
672
|
-
state.
|
|
673
|
-
|
|
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;
|
|
674
1641
|
}
|
|
675
1642
|
}
|
|
676
|
-
registerHandler("
|
|
1643
|
+
registerHandler("on_review_mouse_click", on_review_mouse_click);
|
|
677
1644
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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;
|
|
685
1666
|
}
|
|
686
|
-
} else {
|
|
687
|
-
state.diffScrollOffset = Math.max(0, state.diffScrollOffset - mainRows);
|
|
688
|
-
updateMagitDisplay();
|
|
689
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);
|
|
690
1690
|
}
|
|
691
|
-
registerHandler("review_page_up", review_page_up);
|
|
692
1691
|
|
|
693
|
-
function
|
|
694
|
-
|
|
695
|
-
if (
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* Repaint the synthetic "cursor line" highlight in the diff panel.
|
|
1723
|
+
*
|
|
1724
|
+
* The diff panel buffer is created with show_cursors=true so the editor
|
|
1725
|
+
* moves the cursor natively, but a single-line bg overlay on the cursor row
|
|
1726
|
+
* gives a much more visible "you are here" indicator than the bare caret —
|
|
1727
|
+
* which matches the magit-style aesthetic and is what the user expects.
|
|
1728
|
+
*/
|
|
1729
|
+
function applyCursorLineOverlay(panel: 'diff'): void {
|
|
1730
|
+
const bufId = state.panelBuffers[panel];
|
|
1731
|
+
if (bufId === undefined) return;
|
|
1732
|
+
editor.clearNamespace(bufId, CURSOR_LINE_NS);
|
|
1733
|
+
const offsets = state.diffLineByteOffsets;
|
|
1734
|
+
if (offsets.length < 2) return;
|
|
1735
|
+
const idx = Math.max(0, Math.min(state.diffCursorRow - 1, offsets.length - 2));
|
|
1736
|
+
const start = offsets[idx];
|
|
1737
|
+
const end = offsets[idx + 1];
|
|
1738
|
+
if (end <= start) return;
|
|
1739
|
+
editor.addOverlay(bufId, CURSOR_LINE_NS, start, end, {
|
|
1740
|
+
bg: STYLE_SELECTED_BG,
|
|
1741
|
+
extendToLineEnd: true,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function review_refresh() { refreshMagitData(); }
|
|
1746
|
+
registerHandler("review_refresh", review_refresh);
|
|
1747
|
+
|
|
1748
|
+
// --- Cursor-driven navigation ---
|
|
1749
|
+
//
|
|
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;
|
|
700
1770
|
}
|
|
701
|
-
} else {
|
|
702
|
-
state.diffScrollOffset += mainRows;
|
|
703
|
-
updateMagitDisplay();
|
|
704
1771
|
}
|
|
1772
|
+
return bestFile;
|
|
705
1773
|
}
|
|
706
|
-
registerHandler("review_page_down", review_page_down);
|
|
707
1774
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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;
|
|
711
1781
|
}
|
|
712
|
-
registerHandler("review_toggle_focus", review_toggle_focus);
|
|
713
1782
|
|
|
714
|
-
function
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
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() {
|
|
1801
|
+
if (state.groupId === null) return;
|
|
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;
|
|
718
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);
|
|
719
1852
|
}
|
|
720
|
-
registerHandler("
|
|
1853
|
+
registerHandler("review_toggle_file_collapse", review_toggle_file_collapse);
|
|
721
1854
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
|
|
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());
|
|
726
1884
|
}
|
|
727
1885
|
}
|
|
728
|
-
registerHandler("review_focus_diff", review_focus_diff);
|
|
729
1886
|
|
|
730
|
-
function
|
|
731
|
-
if (state.
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
state.diffScrollOffset = 0;
|
|
735
|
-
updateMagitDisplay();
|
|
736
|
-
} else {
|
|
737
|
-
state.diffScrollOffset = 0;
|
|
738
|
-
updateMagitDisplay();
|
|
1887
|
+
function review_next_comment() {
|
|
1888
|
+
if (state.comments.length === 0) {
|
|
1889
|
+
editor.setStatus(editor.t("status.no_comments") || "No comments");
|
|
1890
|
+
return;
|
|
739
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]);
|
|
740
1899
|
}
|
|
741
|
-
registerHandler("
|
|
1900
|
+
registerHandler("review_next_comment", review_next_comment);
|
|
742
1901
|
|
|
743
|
-
function
|
|
744
|
-
if (state.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1902
|
+
function review_prev_comment() {
|
|
1903
|
+
if (state.comments.length === 0) {
|
|
1904
|
+
editor.setStatus(editor.t("status.no_comments") || "No comments");
|
|
1905
|
+
return;
|
|
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]);
|
|
1912
|
+
}
|
|
1913
|
+
registerHandler("review_prev_comment", review_prev_comment);
|
|
1914
|
+
|
|
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;
|
|
1926
|
+
}
|
|
1927
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1928
|
+
}
|
|
1929
|
+
registerHandler("review_focus_comments", review_focus_comments);
|
|
1930
|
+
|
|
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();
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
registerHandler("review_enter_dispatch", review_enter_dispatch);
|
|
1978
|
+
|
|
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;
|
|
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");
|
|
2011
|
+
}
|
|
2012
|
+
registerHandler("review_visual_start", review_visual_start);
|
|
2013
|
+
|
|
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");
|
|
2019
|
+
}
|
|
2020
|
+
applyCursorLineOverlay('diff');
|
|
2021
|
+
}
|
|
2022
|
+
registerHandler("review_visual_cancel", review_visual_cancel);
|
|
2023
|
+
|
|
2024
|
+
const LINE_SELECTION_NS = "review-line-selection";
|
|
2025
|
+
|
|
2026
|
+
function paintLineSelectionOverlay() {
|
|
2027
|
+
if (state.groupId === null) return;
|
|
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
|
+
});
|
|
758
2045
|
}
|
|
759
2046
|
}
|
|
760
|
-
|
|
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.
|
|
761
2173
|
|
|
762
2174
|
// --- Real git stage/unstage/discard actions (Step 4) ---
|
|
763
2175
|
|
|
2176
|
+
/**
|
|
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.
|
|
2183
|
+
*/
|
|
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
|
+
|
|
2221
|
+
const header = `@@ -${hunk.oldRange.start},${oldCount} +${hunk.range.start},${newCount} @@`;
|
|
2222
|
+
return [
|
|
2223
|
+
`diff --git a/${filePath} b/${filePath}`,
|
|
2224
|
+
`--- a/${filePath}`,
|
|
2225
|
+
`+++ b/${filePath}`,
|
|
2226
|
+
header,
|
|
2227
|
+
...filtered,
|
|
2228
|
+
''
|
|
2229
|
+
].join('\n');
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Write a patch to a temp file and apply it with the given flags.
|
|
2234
|
+
* Returns true on success.
|
|
2235
|
+
*/
|
|
2236
|
+
async function applyHunkPatch(patch: string, flags: string[]): Promise<boolean> {
|
|
2237
|
+
const tmpDir = editor.getTempDir();
|
|
2238
|
+
const patchPath = editor.pathJoin(tmpDir, `fresh-review-${Date.now()}.patch`);
|
|
2239
|
+
editor.writeFile(patchPath, patch);
|
|
2240
|
+
// Validate first
|
|
2241
|
+
const check = await editor.spawnProcess("git", ["apply", "--check", ...flags, patchPath]);
|
|
2242
|
+
if (check.exit_code !== 0) {
|
|
2243
|
+
editor.setStatus("Patch failed: " + (check.stderr || "").trim());
|
|
2244
|
+
return false;
|
|
2245
|
+
}
|
|
2246
|
+
const result = await editor.spawnProcess("git", ["apply", ...flags, patchPath]);
|
|
2247
|
+
return result.exit_code === 0;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* Merge all text-property records at the cursor of the given panel buffer
|
|
2252
|
+
* into a single object. There's typically only one record covering each
|
|
2253
|
+
* cursor position; merging keeps callers simple.
|
|
2254
|
+
*/
|
|
2255
|
+
function readPropsAtCursor(panel: 'files' | 'diff'): Record<string, unknown> | null {
|
|
2256
|
+
const bufId = state.panelBuffers[panel];
|
|
2257
|
+
if (bufId === undefined) return null;
|
|
2258
|
+
const records = editor.getTextPropertiesAtCursor(bufId);
|
|
2259
|
+
if (!records || records.length === 0) return null;
|
|
2260
|
+
const merged: Record<string, unknown> = {};
|
|
2261
|
+
for (const r of records) Object.assign(merged, r);
|
|
2262
|
+
return merged;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/**
|
|
2266
|
+
* Get the hunk under the cursor in the diff panel, or null.
|
|
2267
|
+
*
|
|
2268
|
+
* Reads the `hunkId` text property embedded by `buildDiffPanelEntries`. Falls
|
|
2269
|
+
* back to the first hunk of the selected file when the cursor is somewhere
|
|
2270
|
+
* without a hunkId (e.g. the panel header) so commands like `s` still do
|
|
2271
|
+
* something useful.
|
|
2272
|
+
*/
|
|
2273
|
+
function getHunkAtDiffCursor(): Hunk | null {
|
|
2274
|
+
const props = propsAtCursorRow();
|
|
2275
|
+
const hunkId = props ? props["hunkId"] : undefined;
|
|
2276
|
+
if (typeof hunkId === 'string') {
|
|
2277
|
+
const found = state.hunks.find(h => h.id === hunkId);
|
|
2278
|
+
if (found) return found;
|
|
2279
|
+
}
|
|
2280
|
+
// Fallback: first hunk for the file under the cursor (if any).
|
|
2281
|
+
const cur = currentFileFromCursor();
|
|
2282
|
+
if (!cur) return null;
|
|
2283
|
+
return state.hunks.find(
|
|
2284
|
+
h => h.file === cur.path && h.gitStatus === cur.category
|
|
2285
|
+
) || null;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
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() {
|
|
2312
|
+
if (state.files.length === 0) return;
|
|
2313
|
+
if (state.lineSelection) { await applyLineSelection('stage'); return; }
|
|
2314
|
+
const headerFile = fileHeaderUnderCursor();
|
|
2315
|
+
if (headerFile) {
|
|
2316
|
+
await stageFileEntry(headerFile);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
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
|
+
*/
|
|
764
2339
|
async function review_stage_file() {
|
|
765
2340
|
if (state.files.length === 0) return;
|
|
766
|
-
const f =
|
|
2341
|
+
const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
|
|
767
2342
|
if (!f) return;
|
|
768
|
-
await
|
|
769
|
-
await refreshMagitData();
|
|
2343
|
+
await stageFileEntry(f);
|
|
770
2344
|
}
|
|
771
2345
|
registerHandler("review_stage_file", review_stage_file);
|
|
772
2346
|
|
|
773
2347
|
async function review_unstage_file() {
|
|
774
2348
|
if (state.files.length === 0) return;
|
|
775
|
-
const f =
|
|
2349
|
+
const f = fileHeaderUnderCursor() ?? currentFileFromCursor();
|
|
776
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);
|
|
777
2363
|
await editor.spawnProcess("git", ["reset", "HEAD", "--", f.path]);
|
|
778
2364
|
await refreshMagitData();
|
|
779
2365
|
}
|
|
780
|
-
|
|
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 {
|
|
2373
|
+
const patch = buildHunkPatch(hunk.file, hunk);
|
|
2374
|
+
const ok = await applyHunkPatch(patch, ["--cached"]);
|
|
2375
|
+
if (!ok) return;
|
|
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");
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
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");
|
|
2391
|
+
await refreshMagitData();
|
|
2392
|
+
}
|
|
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);
|
|
781
2427
|
|
|
782
2428
|
function review_discard_file() {
|
|
783
2429
|
if (state.files.length === 0) return;
|
|
784
|
-
|
|
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
|
|
2435
|
+
const hunk = getHunkAtDiffCursor();
|
|
2436
|
+
if (!hunk || !hunk.file) return;
|
|
2437
|
+
rememberPendingHunkAnchor(hunk.id);
|
|
2438
|
+
editor.startPrompt(
|
|
2439
|
+
editor.t("prompt.discard_hunk", { file: hunk.file }) ||
|
|
2440
|
+
`Discard this hunk in "${hunk.file}"? This cannot be undone.`,
|
|
2441
|
+
"review-discard-hunk-confirm"
|
|
2442
|
+
);
|
|
2443
|
+
const suggestions: PromptSuggestion[] = [
|
|
2444
|
+
{ text: "Discard hunk", description: "Permanently lose this change", value: "discard" },
|
|
2445
|
+
{ text: "Cancel", description: "Keep the hunk as-is", value: "cancel" },
|
|
2446
|
+
];
|
|
2447
|
+
editor.setPromptSuggestions(suggestions);
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
785
2450
|
if (!f) return;
|
|
786
2451
|
|
|
787
2452
|
// Show confirmation prompt — discard is destructive and irreversible
|
|
2453
|
+
pendingDiscardFile = f;
|
|
2454
|
+
rememberPendingHunkAnchor(null);
|
|
788
2455
|
const action = f.category === 'untracked' ? "Delete" : "Discard changes in";
|
|
789
2456
|
editor.startPrompt(`${action} "${f.path}"? This cannot be undone.`, "review-discard-confirm");
|
|
790
2457
|
const suggestions: PromptSuggestion[] = [
|
|
@@ -795,12 +2462,32 @@ function review_discard_file() {
|
|
|
795
2462
|
}
|
|
796
2463
|
registerHandler("review_discard_file", review_discard_file);
|
|
797
2464
|
|
|
2465
|
+
async function on_review_discard_hunk_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
|
|
2466
|
+
if (args.prompt_type !== "review-discard-hunk-confirm") return true;
|
|
2467
|
+
const response = args.input.trim().toLowerCase();
|
|
2468
|
+
if (response === "discard" || args.selected_index === 0) {
|
|
2469
|
+
const hunk = getHunkAtDiffCursor();
|
|
2470
|
+
if (hunk && hunk.file) {
|
|
2471
|
+
const patch = buildHunkPatch(hunk.file, hunk);
|
|
2472
|
+
const ok = await applyHunkPatch(patch, ["--reverse"]);
|
|
2473
|
+
if (ok) {
|
|
2474
|
+
editor.setStatus(editor.t("status.hunk_discarded") || "Hunk discarded");
|
|
2475
|
+
await refreshMagitData();
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
} else {
|
|
2479
|
+
editor.setStatus("Discard cancelled");
|
|
2480
|
+
}
|
|
2481
|
+
return false;
|
|
2482
|
+
}
|
|
2483
|
+
registerHandler("on_review_discard_hunk_confirm", on_review_discard_hunk_confirm);
|
|
2484
|
+
|
|
798
2485
|
async function on_review_discard_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): Promise<boolean> {
|
|
799
2486
|
if (args.prompt_type !== "review-discard-confirm") return true;
|
|
800
2487
|
|
|
801
2488
|
const response = args.input.trim().toLowerCase();
|
|
802
2489
|
if (response === "discard" || args.selected_index === 0) {
|
|
803
|
-
const f =
|
|
2490
|
+
const f = pendingDiscardFile;
|
|
804
2491
|
if (f) {
|
|
805
2492
|
if (f.category === 'untracked') {
|
|
806
2493
|
await editor.spawnProcess("rm", ["--", f.path]);
|
|
@@ -813,6 +2500,7 @@ async function on_review_discard_confirm(args: { prompt_type: string; input: str
|
|
|
813
2500
|
} else {
|
|
814
2501
|
editor.setStatus("Discard cancelled");
|
|
815
2502
|
}
|
|
2503
|
+
pendingDiscardFile = null;
|
|
816
2504
|
return false;
|
|
817
2505
|
}
|
|
818
2506
|
registerHandler("on_review_discard_confirm", on_review_discard_confirm);
|
|
@@ -821,15 +2509,51 @@ registerHandler("on_review_discard_confirm", on_review_discard_confirm);
|
|
|
821
2509
|
* Refresh file list and diffs using the new git status approach, then re-render.
|
|
822
2510
|
*/
|
|
823
2511
|
async function refreshMagitData() {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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);
|
|
830
2522
|
}
|
|
831
|
-
state.
|
|
2523
|
+
state.diffCursorRow = 1;
|
|
832
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
|
+
}
|
|
2546
|
+
}
|
|
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);
|
|
833
2557
|
}
|
|
834
2558
|
|
|
835
2559
|
// --- Resize handler ---
|
|
@@ -1130,15 +2854,15 @@ function generateDiffPaneContent(
|
|
|
1130
2854
|
// Line number color
|
|
1131
2855
|
highlights.push({
|
|
1132
2856
|
range: [currentByte + 2, currentByte + 6],
|
|
1133
|
-
fg:
|
|
2857
|
+
fg: "editor.line_number_fg",
|
|
1134
2858
|
});
|
|
1135
2859
|
|
|
1136
2860
|
if (isFiller) {
|
|
1137
2861
|
// Filler styling - extend to full line width
|
|
1138
2862
|
highlights.push({
|
|
1139
2863
|
range: [currentByte + prefixLen, currentByte + lineLen - 1],
|
|
1140
|
-
fg:
|
|
1141
|
-
bg:
|
|
2864
|
+
fg: "editor.line_number_fg",
|
|
2865
|
+
bg: "editor.line_number_bg",
|
|
1142
2866
|
extend_to_line_end: true
|
|
1143
2867
|
});
|
|
1144
2868
|
} else if (line.changeType === 'added' && side === 'new') {
|
|
@@ -1147,7 +2871,7 @@ function generateDiffPaneContent(
|
|
|
1147
2871
|
highlights.push({
|
|
1148
2872
|
range: [currentByte + prefixLen, currentByte + lineLen - 1],
|
|
1149
2873
|
fg: STYLE_ADD_TEXT,
|
|
1150
|
-
bg:
|
|
2874
|
+
bg: STYLE_ADD_BG,
|
|
1151
2875
|
extend_to_line_end: true
|
|
1152
2876
|
});
|
|
1153
2877
|
} else if (line.changeType === 'removed' && side === 'old') {
|
|
@@ -1156,7 +2880,7 @@ function generateDiffPaneContent(
|
|
|
1156
2880
|
highlights.push({
|
|
1157
2881
|
range: [currentByte + prefixLen, currentByte + lineLen - 1],
|
|
1158
2882
|
fg: STYLE_REMOVE_TEXT,
|
|
1159
|
-
bg:
|
|
2883
|
+
bg: STYLE_REMOVE_BG,
|
|
1160
2884
|
extend_to_line_end: true
|
|
1161
2885
|
});
|
|
1162
2886
|
} else if (line.changeType === 'modified') {
|
|
@@ -1245,9 +2969,9 @@ interface CompositeDiffState {
|
|
|
1245
2969
|
let activeCompositeDiffState: CompositeDiffState | null = null;
|
|
1246
2970
|
|
|
1247
2971
|
async function review_drill_down() {
|
|
1248
|
-
// Use
|
|
2972
|
+
// Use the file under the cursor (the file whose section the cursor is in)
|
|
1249
2973
|
if (state.files.length === 0) return;
|
|
1250
|
-
const selectedFile =
|
|
2974
|
+
const selectedFile = currentFileFromCursor();
|
|
1251
2975
|
if (!selectedFile) return;
|
|
1252
2976
|
|
|
1253
2977
|
// Create a minimal hunk-like reference for the rest of the function
|
|
@@ -1278,10 +3002,17 @@ async function review_drill_down() {
|
|
|
1278
3002
|
}
|
|
1279
3003
|
|
|
1280
3004
|
// Read new file content (use absolute path for readFile)
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
3005
|
+
// For deleted files the path no longer exists — use empty content
|
|
3006
|
+
let newContent: string;
|
|
3007
|
+
if (selectedFile.status === 'D') {
|
|
3008
|
+
newContent = "";
|
|
3009
|
+
} else {
|
|
3010
|
+
const readResult = await editor.readFile(absoluteFilePath);
|
|
3011
|
+
if (readResult === null) {
|
|
3012
|
+
editor.setStatus(editor.t("status.failed_new_version"));
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
newContent = readResult;
|
|
1285
3016
|
}
|
|
1286
3017
|
|
|
1287
3018
|
// Close any existing side-by-side views (old split-based approach)
|
|
@@ -1409,21 +3140,137 @@ async function review_drill_down() {
|
|
|
1409
3140
|
}, 0);
|
|
1410
3141
|
const modifiedCount = Math.min(addedCount, removedCount);
|
|
1411
3142
|
|
|
1412
|
-
editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
|
|
3143
|
+
editor.setStatus(editor.t("status.diff_summary", { added: String(addedCount), removed: String(removedCount), modified: String(modifiedCount) }));
|
|
3144
|
+
}
|
|
3145
|
+
registerHandler("review_drill_down", review_drill_down);
|
|
3146
|
+
|
|
3147
|
+
// --- Hunk navigation for side-by-side diff view ---
|
|
3148
|
+
|
|
3149
|
+
/**
|
|
3150
|
+
* Move the diff panel's native cursor to the given 1-indexed row, scrolling
|
|
3151
|
+
* the viewport so the row is visible.
|
|
3152
|
+
*/
|
|
3153
|
+
function jumpDiffCursorToRow(row: number): void {
|
|
3154
|
+
const diffId = state.panelBuffers["diff"];
|
|
3155
|
+
if (diffId === undefined) return;
|
|
3156
|
+
const idx = row - 1;
|
|
3157
|
+
if (idx < 0 || idx >= state.diffLineByteOffsets.length) return;
|
|
3158
|
+
|
|
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);
|
|
3167
|
+
state.diffCursorRow = row;
|
|
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
|
+
}
|
|
1413
3204
|
}
|
|
1414
|
-
registerHandler("review_drill_down", review_drill_down);
|
|
1415
3205
|
|
|
1416
|
-
|
|
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);
|
|
3252
|
+
}
|
|
1417
3253
|
|
|
1418
3254
|
function review_next_hunk() {
|
|
1419
|
-
if (
|
|
1420
|
-
|
|
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);
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
if (cur + 1 >= state.hunks.length) return;
|
|
3264
|
+
jumpToGlobalHunk(cur + 1);
|
|
1421
3265
|
}
|
|
1422
3266
|
registerHandler("review_next_hunk", review_next_hunk);
|
|
1423
3267
|
|
|
1424
3268
|
function review_prev_hunk() {
|
|
1425
|
-
if (
|
|
1426
|
-
|
|
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);
|
|
1427
3274
|
}
|
|
1428
3275
|
registerHandler("review_prev_hunk", review_prev_hunk);
|
|
1429
3276
|
|
|
@@ -1442,16 +3289,13 @@ editor.defineMode("diff-view", [
|
|
|
1442
3289
|
// --- Review Comment Actions ---
|
|
1443
3290
|
|
|
1444
3291
|
function getCurrentHunkId(): string | null {
|
|
1445
|
-
// In magit mode, get the first hunk of the selected file
|
|
1446
3292
|
if (state.files.length === 0) return null;
|
|
1447
|
-
const
|
|
1448
|
-
if (!selectedFile) return null;
|
|
1449
|
-
const hunk = state.hunks.find(
|
|
1450
|
-
h => h.file === selectedFile.path && h.gitStatus === selectedFile.category
|
|
1451
|
-
);
|
|
3293
|
+
const hunk = getHunkAtDiffCursor();
|
|
1452
3294
|
return hunk?.id || null;
|
|
1453
3295
|
}
|
|
1454
3296
|
|
|
3297
|
+
|
|
3298
|
+
|
|
1455
3299
|
interface PendingCommentInfo {
|
|
1456
3300
|
hunkId: string;
|
|
1457
3301
|
file: string;
|
|
@@ -1461,38 +3305,106 @@ interface PendingCommentInfo {
|
|
|
1461
3305
|
lineContent?: string;
|
|
1462
3306
|
}
|
|
1463
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
|
+
*/
|
|
1464
3313
|
function getCurrentLineInfo(): PendingCommentInfo | null {
|
|
1465
|
-
// In magit mode, get info from the selected file's first hunk
|
|
1466
3314
|
if (state.files.length === 0) return null;
|
|
1467
|
-
const
|
|
1468
|
-
if (!
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
);
|
|
1472
|
-
if (
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
newLine: undefined,
|
|
1479
|
-
lineContent: undefined
|
|
1480
|
-
};
|
|
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 };
|
|
1481
3326
|
}
|
|
1482
3327
|
|
|
1483
3328
|
// Pending prompt state for event-based prompt handling
|
|
1484
3329
|
let pendingCommentInfo: PendingCommentInfo | null = null;
|
|
3330
|
+
let editingCommentId: string | null = null; // non-null when editing an existing comment
|
|
3331
|
+
|
|
3332
|
+
/**
|
|
3333
|
+
* Find an existing comment at the current diff cursor position, either on the
|
|
3334
|
+
* comment display line itself or on the diff line it's attached to.
|
|
3335
|
+
*/
|
|
3336
|
+
function findCommentAtCursor(): ReviewComment | null {
|
|
3337
|
+
const props = propsAtCursorRow();
|
|
3338
|
+
if (!props) return null;
|
|
3339
|
+
|
|
3340
|
+
// Cursor sits directly on a comment display line.
|
|
3341
|
+
const commentId = props["commentId"];
|
|
3342
|
+
if (typeof commentId === 'string') {
|
|
3343
|
+
return state.comments.find(c => c.id === commentId) || null;
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
// Cursor sits on a diff line — match by hunk + line type + line number.
|
|
3347
|
+
const hunkId = props["hunkId"];
|
|
3348
|
+
const lineType = props["lineType"];
|
|
3349
|
+
if (typeof hunkId !== 'string') return null;
|
|
3350
|
+
if (lineType !== 'add' && lineType !== 'remove' && lineType !== 'context') return null;
|
|
3351
|
+
const oldLine = typeof props["oldLine"] === 'number' ? props["oldLine"] as number : undefined;
|
|
3352
|
+
const newLine = typeof props["newLine"] === 'number' ? props["newLine"] as number : undefined;
|
|
3353
|
+
return state.comments.find(c =>
|
|
3354
|
+
c.hunk_id === hunkId && (
|
|
3355
|
+
(c.line_type === 'add' && c.new_line === newLine) ||
|
|
3356
|
+
(c.line_type === 'remove' && c.old_line === oldLine) ||
|
|
3357
|
+
(c.line_type === 'context' && c.new_line === newLine)
|
|
3358
|
+
)
|
|
3359
|
+
) || null;
|
|
3360
|
+
}
|
|
1485
3361
|
|
|
1486
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
|
+
|
|
1487
3392
|
const info = getCurrentLineInfo();
|
|
1488
3393
|
if (!info) {
|
|
1489
|
-
editor.setStatus(
|
|
3394
|
+
editor.setStatus(
|
|
3395
|
+
editor.t("status.comment_needs_line") ||
|
|
3396
|
+
"Position cursor on a diff line to add a comment"
|
|
3397
|
+
);
|
|
1490
3398
|
return;
|
|
1491
3399
|
}
|
|
3400
|
+
|
|
3401
|
+
// Check for existing comment on this diff line to edit
|
|
3402
|
+
const existing = findCommentAtCursor();
|
|
3403
|
+
|
|
1492
3404
|
pendingCommentInfo = info;
|
|
3405
|
+
editingCommentId = existing?.id || null;
|
|
1493
3406
|
|
|
1494
|
-
|
|
1495
|
-
let lineRef = 'hunk';
|
|
3407
|
+
let lineRef = 'line';
|
|
1496
3408
|
if (info.lineType === 'add' && info.newLine) {
|
|
1497
3409
|
lineRef = `+${info.newLine}`;
|
|
1498
3410
|
} else if (info.lineType === 'remove' && info.oldLine) {
|
|
@@ -1502,15 +3414,94 @@ async function review_add_comment() {
|
|
|
1502
3414
|
} else if (info.oldLine) {
|
|
1503
3415
|
lineRef = `L${info.oldLine}`;
|
|
1504
3416
|
}
|
|
1505
|
-
|
|
3417
|
+
|
|
3418
|
+
const label = existing
|
|
3419
|
+
? (editor.t("prompt.edit_comment", { line: lineRef }) || `Edit comment on ${lineRef}: `)
|
|
3420
|
+
: editor.t("prompt.comment", { line: lineRef });
|
|
3421
|
+
|
|
3422
|
+
if (existing) {
|
|
3423
|
+
editor.startPromptWithInitial(label, "review-comment", existing.text);
|
|
3424
|
+
} else {
|
|
3425
|
+
editor.startPrompt(label, "review-comment");
|
|
3426
|
+
}
|
|
1506
3427
|
}
|
|
1507
3428
|
registerHandler("review_add_comment", review_add_comment);
|
|
1508
3429
|
|
|
3430
|
+
let pendingDeleteCommentId: string | null = null;
|
|
3431
|
+
|
|
3432
|
+
async function review_delete_comment() {
|
|
3433
|
+
const target: ReviewComment | null = findCommentAtCursor();
|
|
3434
|
+
|
|
3435
|
+
if (!target) {
|
|
3436
|
+
editor.setStatus("No comment to delete");
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
pendingDeleteCommentId = target.id;
|
|
3441
|
+
const preview = target.text.length > 40 ? target.text.substring(0, 37) + '...' : target.text;
|
|
3442
|
+
editor.startPrompt(`Delete "${preview}"?`, "review-delete-comment-confirm");
|
|
3443
|
+
const suggestions: PromptSuggestion[] = [
|
|
3444
|
+
{ text: "Delete", description: "Remove this comment", value: "delete" },
|
|
3445
|
+
{ text: "Cancel", description: "Keep the comment", value: "cancel" },
|
|
3446
|
+
];
|
|
3447
|
+
editor.setPromptSuggestions(suggestions);
|
|
3448
|
+
}
|
|
3449
|
+
registerHandler("review_delete_comment", review_delete_comment);
|
|
3450
|
+
|
|
3451
|
+
function on_review_delete_comment_confirm(args: { prompt_type: string; input: string; selected_index: number | null }): boolean {
|
|
3452
|
+
if (args.prompt_type !== "review-delete-comment-confirm") return true;
|
|
3453
|
+
const response = args.input.trim().toLowerCase();
|
|
3454
|
+
if ((response === "delete" || args.selected_index === 0) && pendingDeleteCommentId) {
|
|
3455
|
+
if (pendingDeleteCommentId === '__note__') {
|
|
3456
|
+
state.note = '';
|
|
3457
|
+
} else {
|
|
3458
|
+
state.comments = state.comments.filter(c => c.id !== pendingDeleteCommentId);
|
|
3459
|
+
}
|
|
3460
|
+
persistReview();
|
|
3461
|
+
updateMagitDisplay();
|
|
3462
|
+
editor.setStatus("Deleted");
|
|
3463
|
+
} else {
|
|
3464
|
+
editor.setStatus("Delete cancelled");
|
|
3465
|
+
}
|
|
3466
|
+
pendingDeleteCommentId = null;
|
|
3467
|
+
return false;
|
|
3468
|
+
}
|
|
3469
|
+
registerHandler("on_review_delete_comment_confirm", on_review_delete_comment_confirm);
|
|
3470
|
+
|
|
1509
3471
|
// Prompt event handlers
|
|
1510
3472
|
function on_review_prompt_confirm(args: { prompt_type: string; input: string }): boolean {
|
|
1511
3473
|
if (args.prompt_type !== "review-comment") {
|
|
1512
|
-
return true;
|
|
3474
|
+
return true;
|
|
3475
|
+
}
|
|
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
|
+
|
|
3484
|
+
if (editingCommentId) {
|
|
3485
|
+
// Edit mode: update existing comment (empty text keeps the comment unchanged)
|
|
3486
|
+
if (args.input && args.input.trim()) {
|
|
3487
|
+
const existing = state.comments.find(c => c.id === editingCommentId);
|
|
3488
|
+
if (existing) {
|
|
3489
|
+
existing.text = args.input.trim();
|
|
3490
|
+
existing.timestamp = new Date().toISOString();
|
|
3491
|
+
persistReview();
|
|
3492
|
+
updateMagitDisplay();
|
|
3493
|
+
jumpDiffCursorToRow(cursorRowBeforeRebuild);
|
|
3494
|
+
editor.setStatus("Comment updated");
|
|
3495
|
+
}
|
|
3496
|
+
} else {
|
|
3497
|
+
editor.setStatus("Comment unchanged (use x to delete)");
|
|
3498
|
+
}
|
|
3499
|
+
editingCommentId = null;
|
|
3500
|
+
pendingCommentInfo = null;
|
|
3501
|
+
return true;
|
|
1513
3502
|
}
|
|
3503
|
+
|
|
3504
|
+
// New comment mode
|
|
1514
3505
|
if (pendingCommentInfo && args.input && args.input.trim()) {
|
|
1515
3506
|
const comment: ReviewComment = {
|
|
1516
3507
|
id: `comment-${Date.now()}`,
|
|
@@ -1524,7 +3515,9 @@ function on_review_prompt_confirm(args: { prompt_type: string; input: string }):
|
|
|
1524
3515
|
line_type: pendingCommentInfo.lineType
|
|
1525
3516
|
};
|
|
1526
3517
|
state.comments.push(comment);
|
|
3518
|
+
persistReview();
|
|
1527
3519
|
updateMagitDisplay();
|
|
3520
|
+
jumpDiffCursorToRow(cursorRowBeforeRebuild);
|
|
1528
3521
|
let lineRef = 'hunk';
|
|
1529
3522
|
if (comment.line_type === 'add' && comment.new_line) {
|
|
1530
3523
|
lineRef = `line +${comment.new_line}`;
|
|
@@ -1545,6 +3538,7 @@ registerHandler("on_review_prompt_confirm", on_review_prompt_confirm);
|
|
|
1545
3538
|
function on_review_prompt_cancel(args: { prompt_type: string }): boolean {
|
|
1546
3539
|
if (args.prompt_type === "review-comment") {
|
|
1547
3540
|
pendingCommentInfo = null;
|
|
3541
|
+
editingCommentId = null;
|
|
1548
3542
|
editor.setStatus(editor.t("status.comment_cancelled"));
|
|
1549
3543
|
}
|
|
1550
3544
|
return true;
|
|
@@ -1554,145 +3548,92 @@ registerHandler("on_review_prompt_cancel", on_review_prompt_cancel);
|
|
|
1554
3548
|
// Register prompt event handlers
|
|
1555
3549
|
editor.on("prompt_confirmed", "on_review_prompt_confirm");
|
|
1556
3550
|
editor.on("prompt_confirmed", "on_review_discard_confirm");
|
|
3551
|
+
editor.on("prompt_confirmed", "on_review_discard_hunk_confirm");
|
|
3552
|
+
editor.on("prompt_confirmed", "on_review_edit_note_confirm");
|
|
3553
|
+
editor.on("prompt_confirmed", "on_review_delete_comment_confirm");
|
|
1557
3554
|
editor.on("prompt_cancelled", "on_review_prompt_cancel");
|
|
1558
3555
|
|
|
1559
|
-
async function
|
|
1560
|
-
const
|
|
1561
|
-
if (
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
updateMagitDisplay();
|
|
1566
|
-
editor.setStatus(editor.t("status.hunk_approved"));
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
registerHandler("review_approve_hunk", review_approve_hunk);
|
|
1570
|
-
|
|
1571
|
-
async function review_reject_hunk() {
|
|
1572
|
-
const hunkId = getCurrentHunkId();
|
|
1573
|
-
if (!hunkId) return;
|
|
1574
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1575
|
-
if (h) {
|
|
1576
|
-
h.reviewStatus = 'rejected';
|
|
1577
|
-
updateMagitDisplay();
|
|
1578
|
-
editor.setStatus(editor.t("status.hunk_rejected"));
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
registerHandler("review_reject_hunk", review_reject_hunk);
|
|
1582
|
-
|
|
1583
|
-
async function review_needs_changes() {
|
|
1584
|
-
const hunkId = getCurrentHunkId();
|
|
1585
|
-
if (!hunkId) return;
|
|
1586
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1587
|
-
if (h) {
|
|
1588
|
-
h.reviewStatus = 'needs_changes';
|
|
1589
|
-
updateMagitDisplay();
|
|
1590
|
-
editor.setStatus(editor.t("status.hunk_needs_changes"));
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
registerHandler("review_needs_changes", review_needs_changes);
|
|
1594
|
-
|
|
1595
|
-
async function review_question_hunk() {
|
|
1596
|
-
const hunkId = getCurrentHunkId();
|
|
1597
|
-
if (!hunkId) return;
|
|
1598
|
-
const h = state.hunks.find(x => x.id === hunkId);
|
|
1599
|
-
if (h) {
|
|
1600
|
-
h.reviewStatus = 'question';
|
|
1601
|
-
updateMagitDisplay();
|
|
1602
|
-
editor.setStatus(editor.t("status.hunk_question"));
|
|
3556
|
+
async function review_edit_note() {
|
|
3557
|
+
const label = editor.t("prompt.overall_comment") || "Note: ";
|
|
3558
|
+
if (state.note) {
|
|
3559
|
+
editor.startPromptWithInitial(label, "review-edit-note", state.note);
|
|
3560
|
+
} else {
|
|
3561
|
+
editor.startPrompt(label, "review-edit-note");
|
|
1603
3562
|
}
|
|
1604
3563
|
}
|
|
1605
|
-
registerHandler("
|
|
3564
|
+
registerHandler("review_edit_note", review_edit_note);
|
|
1606
3565
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
if (
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
h.reviewStatus = 'pending';
|
|
3566
|
+
function on_review_edit_note_confirm(args: { prompt_type: string; input: string }): boolean {
|
|
3567
|
+
if (args.prompt_type !== "review-edit-note") return true;
|
|
3568
|
+
if (args.input && args.input.trim()) {
|
|
3569
|
+
state.note = args.input.trim();
|
|
3570
|
+
persistReview();
|
|
1613
3571
|
updateMagitDisplay();
|
|
1614
|
-
editor.setStatus(
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
const text = await editor.prompt(editor.t("prompt.overall_feedback"), state.overallFeedback || "");
|
|
1621
|
-
if (text !== null) {
|
|
1622
|
-
state.overallFeedback = text.trim();
|
|
1623
|
-
editor.setStatus(text.trim() ? editor.t("status.feedback_set") : editor.t("status.feedback_cleared"));
|
|
3572
|
+
editor.setStatus(state.note ? "Note saved" : "Note cleared");
|
|
3573
|
+
} else {
|
|
3574
|
+
// Empty submission: keep existing note unchanged (use x to delete)
|
|
3575
|
+
if (state.note) {
|
|
3576
|
+
editor.setStatus("Note unchanged (use x to delete)");
|
|
3577
|
+
}
|
|
1624
3578
|
}
|
|
3579
|
+
return true;
|
|
1625
3580
|
}
|
|
1626
|
-
registerHandler("
|
|
3581
|
+
registerHandler("on_review_edit_note_confirm", on_review_edit_note_confirm);
|
|
1627
3582
|
|
|
1628
3583
|
async function review_export_session() {
|
|
1629
3584
|
const cwd = editor.getCwd();
|
|
1630
3585
|
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
1631
3586
|
|
|
1632
|
-
// Generate markdown content (writeFile creates parent directories)
|
|
1633
3587
|
let md = `# Code Review Session\n`;
|
|
1634
3588
|
md += `Date: ${new Date().toISOString()}\n\n`;
|
|
1635
3589
|
|
|
1636
|
-
if (state.
|
|
1637
|
-
md += `##
|
|
3590
|
+
if (state.note) {
|
|
3591
|
+
md += `## Note\n${state.note}\n\n`;
|
|
1638
3592
|
}
|
|
1639
3593
|
|
|
1640
|
-
|
|
1641
|
-
|
|
3594
|
+
// Summary
|
|
3595
|
+
const filesWithComments = new Set(state.comments.map(c => c.file)).size;
|
|
3596
|
+
md += `## Summary\n`;
|
|
3597
|
+
md += `- Files: ${state.files.length}\n`;
|
|
3598
|
+
md += `- Hunks: ${state.hunks.length}\n`;
|
|
3599
|
+
if (filesWithComments > 0) {
|
|
3600
|
+
md += `- Files with comments: ${filesWithComments}\n`;
|
|
3601
|
+
}
|
|
3602
|
+
md += `\n`;
|
|
3603
|
+
|
|
3604
|
+
// Group comments by file
|
|
3605
|
+
const fileComments: Record<string, ReviewComment[]> = {};
|
|
3606
|
+
for (const c of state.comments) {
|
|
3607
|
+
const file = c.file || 'unknown';
|
|
3608
|
+
if (!fileComments[file]) fileComments[file] = [];
|
|
3609
|
+
fileComments[file].push(c);
|
|
1642
3610
|
}
|
|
1643
3611
|
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
md += `## File: ${file}\n\n`;
|
|
1665
|
-
for (const hunk of hunks) {
|
|
1666
|
-
const statusStr = hunk.reviewStatus.toUpperCase();
|
|
1667
|
-
md += `### ${hunk.contextHeader || 'Hunk'} (line ${hunk.range.start})\n`;
|
|
1668
|
-
md += `**Status**: ${statusStr}\n\n`;
|
|
1669
|
-
|
|
1670
|
-
const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
|
|
1671
|
-
if (hunkComments.length > 0) {
|
|
1672
|
-
md += `**Comments:**\n`;
|
|
1673
|
-
for (const c of hunkComments) {
|
|
1674
|
-
// Format line reference
|
|
1675
|
-
let lineRef = '';
|
|
1676
|
-
if (c.line_type === 'add' && c.new_line) {
|
|
1677
|
-
lineRef = `[+${c.new_line}]`;
|
|
1678
|
-
} else if (c.line_type === 'remove' && c.old_line) {
|
|
1679
|
-
lineRef = `[-${c.old_line}]`;
|
|
1680
|
-
} else if (c.new_line) {
|
|
1681
|
-
lineRef = `[L${c.new_line}]`;
|
|
1682
|
-
} else if (c.old_line) {
|
|
1683
|
-
lineRef = `[L${c.old_line}]`;
|
|
1684
|
-
}
|
|
1685
|
-
md += `> 💬 ${lineRef} ${c.text}\n`;
|
|
1686
|
-
if (c.line_content) {
|
|
1687
|
-
md += `> \`${c.line_content.trim()}\`\n`;
|
|
1688
|
-
}
|
|
1689
|
-
md += `\n`;
|
|
1690
|
-
}
|
|
3612
|
+
for (const [file, comments] of Object.entries(fileComments)) {
|
|
3613
|
+
md += `## ${file}\n\n`;
|
|
3614
|
+
for (const c of comments) {
|
|
3615
|
+
let lineRef = '';
|
|
3616
|
+
if (c.line_type === 'add' && c.new_line) {
|
|
3617
|
+
lineRef = `line +${c.new_line}`;
|
|
3618
|
+
} else if (c.line_type === 'remove' && c.old_line) {
|
|
3619
|
+
lineRef = `line -${c.old_line}`;
|
|
3620
|
+
} else if (c.new_line) {
|
|
3621
|
+
lineRef = `line ${c.new_line}`;
|
|
3622
|
+
} else if (c.old_line) {
|
|
3623
|
+
lineRef = `line ${c.old_line}`;
|
|
3624
|
+
}
|
|
3625
|
+
if (lineRef) {
|
|
3626
|
+
md += `- **${lineRef}**: ${c.text}\n`;
|
|
3627
|
+
} else {
|
|
3628
|
+
md += `- ${c.text}\n`;
|
|
3629
|
+
}
|
|
3630
|
+
if (c.line_content) {
|
|
3631
|
+
md += ` \`${c.line_content.trim()}\`\n`;
|
|
1691
3632
|
}
|
|
1692
3633
|
}
|
|
3634
|
+
md += `\n`;
|
|
1693
3635
|
}
|
|
1694
3636
|
|
|
1695
|
-
// Write file
|
|
1696
3637
|
const filePath = editor.pathJoin(reviewDir, "session.md");
|
|
1697
3638
|
await editor.writeFile(filePath, md);
|
|
1698
3639
|
editor.setStatus(editor.t("status.exported", { path: filePath }));
|
|
@@ -1702,94 +3643,471 @@ registerHandler("review_export_session", review_export_session);
|
|
|
1702
3643
|
async function review_export_json() {
|
|
1703
3644
|
const cwd = editor.getCwd();
|
|
1704
3645
|
const reviewDir = editor.pathJoin(cwd, ".review");
|
|
1705
|
-
// writeFile creates parent directories
|
|
1706
3646
|
|
|
1707
3647
|
const session = {
|
|
1708
|
-
version: "
|
|
3648
|
+
version: "2.0",
|
|
1709
3649
|
timestamp: new Date().toISOString(),
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
3650
|
+
note: state.note || null,
|
|
3651
|
+
comments: state.comments.map(c => ({
|
|
3652
|
+
file: c.file,
|
|
3653
|
+
text: c.text,
|
|
3654
|
+
line_type: c.line_type || null,
|
|
3655
|
+
old_line: c.old_line || null,
|
|
3656
|
+
new_line: c.new_line || null,
|
|
3657
|
+
line_content: c.line_content || null
|
|
3658
|
+
}))
|
|
1713
3659
|
};
|
|
1714
3660
|
|
|
1715
|
-
for (const hunk of state.hunks) {
|
|
1716
|
-
if (!session.files[hunk.file]) session.files[hunk.file] = { hunks: [] };
|
|
1717
|
-
const hunkComments = state.comments.filter(c => c.hunk_id === hunk.id);
|
|
1718
|
-
session.files[hunk.file].hunks.push({
|
|
1719
|
-
context: hunk.contextHeader,
|
|
1720
|
-
old_lines: [hunk.oldRange.start, hunk.oldRange.end],
|
|
1721
|
-
new_lines: [hunk.range.start, hunk.range.end],
|
|
1722
|
-
status: hunk.reviewStatus,
|
|
1723
|
-
comments: hunkComments.map(c => ({
|
|
1724
|
-
text: c.text,
|
|
1725
|
-
line_type: c.line_type || null,
|
|
1726
|
-
old_line: c.old_line || null,
|
|
1727
|
-
new_line: c.new_line || null,
|
|
1728
|
-
line_content: c.line_content || null
|
|
1729
|
-
}))
|
|
1730
|
-
});
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
3661
|
const filePath = editor.pathJoin(reviewDir, "session.json");
|
|
1734
3662
|
await editor.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
1735
3663
|
editor.setStatus(editor.t("status.exported", { path: filePath }));
|
|
1736
3664
|
}
|
|
1737
3665
|
registerHandler("review_export_json", review_export_json);
|
|
1738
3666
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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 {
|
|
3673
|
+
state.diffCursorRow = 1;
|
|
3674
|
+
state.hunkHeaderRows = [];
|
|
3675
|
+
state.diffLineByteOffsets = [];
|
|
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
|
+
}
|
|
3687
|
+
|
|
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: {
|
|
3694
|
+
type: "split",
|
|
3695
|
+
direction: "h",
|
|
3696
|
+
ratio: 0.75,
|
|
3697
|
+
first: {
|
|
3698
|
+
type: "split",
|
|
3699
|
+
direction: "v",
|
|
3700
|
+
ratio: 0.05,
|
|
3701
|
+
first: { type: "fixed", id: "sticky", height: 1 },
|
|
3702
|
+
second: { type: "scrollable", id: "diff" },
|
|
3703
|
+
},
|
|
3704
|
+
second: { type: "scrollable", id: "comments" },
|
|
3705
|
+
},
|
|
3706
|
+
});
|
|
1742
3707
|
|
|
1743
|
-
|
|
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> {
|
|
1744
3714
|
const viewport = editor.getViewport();
|
|
1745
3715
|
if (viewport) {
|
|
1746
3716
|
state.viewportWidth = viewport.width;
|
|
1747
3717
|
state.viewportHeight = viewport.height;
|
|
1748
3718
|
}
|
|
3719
|
+
editor.setContext("review-mode", true);
|
|
3720
|
+
const groupResult = await editor.createBufferGroup(groupName, "review-mode", REVIEW_LAYOUT);
|
|
3721
|
+
state.groupId = groupResult.groupId;
|
|
3722
|
+
state.panelBuffers = groupResult.panels;
|
|
3723
|
+
state.reviewBufferId = groupResult.panels["diff"];
|
|
1749
3724
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
state.comments = [];
|
|
1754
|
-
state.selectedIndex = 0;
|
|
1755
|
-
state.fileScrollOffset = 0;
|
|
1756
|
-
state.diffScrollOffset = 0;
|
|
1757
|
-
state.focusPanel = 'files';
|
|
3725
|
+
if (state.panelBuffers["diff"] !== undefined) {
|
|
3726
|
+
(editor as any).setBufferShowCursors(state.panelBuffers["diff"], true);
|
|
3727
|
+
}
|
|
1758
3728
|
|
|
1759
|
-
|
|
1760
|
-
const initialEntries = buildMagitDisplayEntries();
|
|
3729
|
+
updateMagitDisplay();
|
|
1761
3730
|
|
|
1762
|
-
|
|
1763
|
-
name: "*Review Diff*", mode: "review-mode", readOnly: true,
|
|
1764
|
-
entries: initialEntries, showLineNumbers: false, showCursors: false
|
|
1765
|
-
});
|
|
1766
|
-
state.reviewBufferId = bufferId;
|
|
3731
|
+
editor.focusBufferGroupPanel(state.groupId, "diff");
|
|
1767
3732
|
|
|
1768
|
-
// Register resize handler
|
|
1769
3733
|
editor.on("resize", "onReviewDiffResize");
|
|
1770
|
-
|
|
1771
|
-
editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
|
|
3734
|
+
updateReviewStatus();
|
|
1772
3735
|
editor.on("buffer_activated", "on_review_buffer_activated");
|
|
1773
3736
|
editor.on("buffer_closed", "on_review_buffer_closed");
|
|
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*");
|
|
1774
3804
|
}
|
|
1775
3805
|
registerHandler("start_review_diff", start_review_diff);
|
|
1776
3806
|
|
|
1777
3807
|
function stop_review_diff() {
|
|
3808
|
+
if (state.groupId !== null) {
|
|
3809
|
+
editor.closeBufferGroup(state.groupId);
|
|
3810
|
+
state.groupId = null;
|
|
3811
|
+
state.panelBuffers = {};
|
|
3812
|
+
}
|
|
1778
3813
|
state.reviewBufferId = null;
|
|
1779
3814
|
editor.setContext("review-mode", false);
|
|
1780
3815
|
editor.off("resize", "onReviewDiffResize");
|
|
1781
3816
|
editor.off("buffer_activated", "on_review_buffer_activated");
|
|
1782
3817
|
editor.off("buffer_closed", "on_review_buffer_closed");
|
|
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");
|
|
1783
3821
|
editor.setStatus(editor.t("status.stopped"));
|
|
1784
3822
|
}
|
|
1785
3823
|
registerHandler("stop_review_diff", stop_review_diff);
|
|
1786
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
|
+
|
|
1787
4013
|
|
|
1788
|
-
|
|
1789
|
-
|
|
4014
|
+
/**
|
|
4015
|
+
* React to a buffer becoming active. Used here purely to track which review
|
|
4016
|
+
* panel currently has focus (Tab and mouse clicks both fire buffer_activated).
|
|
4017
|
+
* The focus state drives toolbar hint rendering and the `review_nav_*`
|
|
4018
|
+
* handlers' files-vs-diff branching.
|
|
4019
|
+
*
|
|
4020
|
+
* Note: this used to call `refreshMagitData()` on every activation, which
|
|
4021
|
+
* spawned several `git` subprocesses every time the user switched panels.
|
|
4022
|
+
* The user has a dedicated `r` key for that — auto-refresh was too aggressive.
|
|
4023
|
+
*/
|
|
4024
|
+
function on_review_buffer_activated(data: { buffer_id: number }): void {
|
|
4025
|
+
if (state.groupId === null) return;
|
|
4026
|
+
const diffId = state.panelBuffers["diff"];
|
|
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';
|
|
4031
|
+
if (newPanel === null || newPanel === state.focusPanel) return;
|
|
4032
|
+
state.focusPanel = newPanel;
|
|
4033
|
+
// Re-render the comments panel so the selection highlight follows focus.
|
|
4034
|
+
editor.setPanelContent(state.groupId, "comments", buildCommentsPanelEntries());
|
|
1790
4035
|
}
|
|
1791
4036
|
registerHandler("on_review_buffer_activated", on_review_buffer_activated);
|
|
1792
4037
|
|
|
4038
|
+
/**
|
|
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.
|
|
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
|
+
|
|
4077
|
+
function on_review_cursor_moved(data: {
|
|
4078
|
+
buffer_id: number;
|
|
4079
|
+
cursor_id: number;
|
|
4080
|
+
old_position: number;
|
|
4081
|
+
new_position: number;
|
|
4082
|
+
line: number;
|
|
4083
|
+
text_properties: Array<Record<string, unknown>>;
|
|
4084
|
+
}): void {
|
|
4085
|
+
if (state.groupId === null) return;
|
|
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
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
registerHandler("on_review_cursor_moved", on_review_cursor_moved);
|
|
4110
|
+
|
|
1793
4111
|
function on_review_buffer_closed(data: any) {
|
|
1794
4112
|
if (data.buffer_id === state.reviewBufferId) stop_review_diff();
|
|
1795
4113
|
}
|
|
@@ -1879,7 +4197,6 @@ async function side_by_side_diff_current_file() {
|
|
|
1879
4197
|
type: isUntracked ? 'add' : 'modify',
|
|
1880
4198
|
lines: [],
|
|
1881
4199
|
status: 'pending',
|
|
1882
|
-
reviewStatus: 'pending',
|
|
1883
4200
|
contextHeader: match[5]?.trim() || "",
|
|
1884
4201
|
byteOffset: 0
|
|
1885
4202
|
};
|
|
@@ -2038,20 +4355,321 @@ async function side_by_side_diff_current_file() {
|
|
|
2038
4355
|
}
|
|
2039
4356
|
registerHandler("side_by_side_diff_current_file", side_by_side_diff_current_file);
|
|
2040
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
|
+
|
|
2041
4661
|
// Register Modes and Commands
|
|
2042
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");
|
|
2043
4666
|
editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode");
|
|
2044
4667
|
editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode");
|
|
2045
4668
|
editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", null);
|
|
2046
4669
|
|
|
2047
4670
|
// Review Comment Commands
|
|
2048
4671
|
editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
|
|
2049
|
-
editor.registerCommand("%cmd.
|
|
2050
|
-
editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
|
|
2051
|
-
editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
|
|
2052
|
-
editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
|
|
2053
|
-
editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
|
|
2054
|
-
editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "review_set_overall_feedback", "review-mode");
|
|
4672
|
+
editor.registerCommand("%cmd.edit_note", "%cmd.edit_note_desc", "review_edit_note", "review-mode");
|
|
2055
4673
|
editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
|
|
2056
4674
|
editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
|
|
2057
4675
|
|
|
@@ -2089,23 +4707,49 @@ registerHandler("on_buffer_closed", on_buffer_closed);
|
|
|
2089
4707
|
editor.on("buffer_closed", "on_buffer_closed");
|
|
2090
4708
|
|
|
2091
4709
|
editor.defineMode("review-mode", [
|
|
2092
|
-
//
|
|
4710
|
+
// Native cursor motion in the unified diff stream.
|
|
2093
4711
|
["Up", "review_nav_up"], ["Down", "review_nav_down"],
|
|
2094
4712
|
["k", "review_nav_up"], ["j", "review_nav_down"],
|
|
2095
4713
|
["PageUp", "review_page_up"], ["PageDown", "review_page_down"],
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
//
|
|
2100
|
-
["
|
|
2101
|
-
//
|
|
2102
|
-
["
|
|
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.
|
|
4720
|
+
["n", "review_next_hunk"], ["p", "review_prev_hunk"],
|
|
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"],
|
|
2103
4743
|
["d", "review_discard_file"],
|
|
4744
|
+
["S", "review_stage_file"], ["U", "review_unstage_file"],
|
|
4745
|
+
["D", "review_discard_file_only"],
|
|
2104
4746
|
["r", "review_refresh"],
|
|
2105
|
-
//
|
|
2106
|
-
["a", "review_approve_hunk"],
|
|
4747
|
+
// Comments
|
|
2107
4748
|
["c", "review_add_comment"],
|
|
2108
|
-
|
|
4749
|
+
["N", "review_edit_note"],
|
|
4750
|
+
["x", "review_delete_comment"],
|
|
4751
|
+
// Close & export
|
|
4752
|
+
["q", "close"],
|
|
2109
4753
|
["e", "review_export_session"],
|
|
2110
4754
|
], true);
|
|
2111
4755
|
|