@fresh-editor/fresh-editor 0.3.6 → 0.3.8
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 +139 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +112 -0
- package/plugins/audit_mode.ts +173 -6
- package/plugins/config-schema.json +34 -3
- package/plugins/dashboard.ts +18 -18
- package/plugins/env-manager.ts +168 -0
- package/plugins/flash.ts +22 -4
- package/plugins/git_blame.ts +10 -6
- package/plugins/git_log.ts +589 -196
- package/plugins/git_statusbar.i18n.json +72 -0
- package/plugins/git_statusbar.ts +133 -0
- package/plugins/lib/fresh.d.ts +412 -6
- package/plugins/lib/widgets.ts +111 -4
- package/plugins/live_diff.ts +168 -58
- package/plugins/merge_conflict.ts +89 -64
- package/plugins/orchestrator.ts +2174 -296
- package/plugins/pkg.ts +169 -4
- package/plugins/schemas/theme.schema.json +53 -0
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +674 -117
- package/plugins/tab_actions.i18n.json +212 -0
- package/plugins/tab_actions.ts +76 -0
- package/plugins/theme_editor.i18n.json +112 -84
- package/plugins/tsconfig.json +2 -0
- package/plugins/vi_mode.ts +11 -0
- package/themes/dark.json +1 -0
- package/themes/dracula.json +1 -0
- package/themes/high-contrast.json +1 -0
- package/themes/light.json +2 -1
- package/themes/nord.json +1 -0
- package/themes/nostalgia.json +1 -0
- package/themes/solarized-dark.json +1 -0
- package/themes/terminal.json +4 -3
package/plugins/git_log.ts
CHANGED
|
@@ -2,13 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type GitCommit,
|
|
5
|
-
buildCommitDetailEntries,
|
|
6
5
|
buildCommitLogEntries,
|
|
7
|
-
buildDetailPlaceholderEntries,
|
|
8
|
-
fetchCommitShow,
|
|
9
6
|
fetchGitLog,
|
|
10
7
|
} from "./lib/git_history.ts";
|
|
11
|
-
import { button, flexSpacer,
|
|
8
|
+
import { button, flexSpacer, list, row, WidgetPanel } from "./lib/index.ts";
|
|
12
9
|
|
|
13
10
|
const editor = getEditor();
|
|
14
11
|
|
|
@@ -36,32 +33,42 @@ interface GitLogState {
|
|
|
36
33
|
isOpen: boolean;
|
|
37
34
|
groupId: number | null;
|
|
38
35
|
logBufferId: number | null;
|
|
36
|
+
/**
|
|
37
|
+
* The buffer-group's initial detail panel buffer (a virtual buffer
|
|
38
|
+
* created by `createBufferGroup`). After the first commit is shown,
|
|
39
|
+
* the panel is retargeted at a file-backed streaming buffer via
|
|
40
|
+
* `setBufferGroupPanelBuffer`; this id is kept so we can close the
|
|
41
|
+
* orphaned virtual buffer on group teardown.
|
|
42
|
+
*/
|
|
43
|
+
initialDetailBufferId: number | null;
|
|
44
|
+
/**
|
|
45
|
+
* The buffer id currently displayed in the detail panel (one
|
|
46
|
+
* file-backed buffer per visited commit). Tracked for focus checks
|
|
47
|
+
* and cursor placement.
|
|
48
|
+
*/
|
|
39
49
|
detailBufferId: number | null;
|
|
40
50
|
toolbarBufferId: number | null;
|
|
41
|
-
/** Widget panel rendering the toolbar (Row of Buttons).
|
|
42
|
-
* `show_git_log` once the buffer group exists; cleaned up in
|
|
43
|
-
* `git_log_close`. */
|
|
51
|
+
/** Widget panel rendering the toolbar (Row of Buttons). */
|
|
44
52
|
toolbarPanel: WidgetPanel | null;
|
|
45
|
-
/** Widget panel rendering the log (List of commit rows).
|
|
46
|
-
* `selected_index` + `scroll_offset` as instance state — the
|
|
47
|
-
* plugin's `state.selectedIndex` mirrors what the host reports
|
|
48
|
-
* via `widget_event "select"`. */
|
|
53
|
+
/** Widget panel rendering the log (List of commit rows). */
|
|
49
54
|
logPanel: WidgetPanel | null;
|
|
50
55
|
commits: GitCommit[];
|
|
51
56
|
selectedIndex: number;
|
|
52
|
-
/** Cached `git show` output for the currently-displayed detail commit. */
|
|
53
|
-
detailCache: { hash: string; output: string } | null;
|
|
54
57
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
58
|
+
* Per-commit cache: sha → file-backed buffer id. Each visited
|
|
59
|
+
* commit gets its own buffer pointing at `<dataDir>/git-show/<sha>.diff`,
|
|
60
|
+
* which a background `git show --patch` writes into. Returning to a
|
|
61
|
+
* cached commit is just a `setBufferGroupPanelBuffer` call — no
|
|
62
|
+
* git invocation, scroll position preserved.
|
|
57
63
|
*/
|
|
58
|
-
|
|
64
|
+
commitBuffers: Map<string, number>;
|
|
65
|
+
/** sha → in-flight spawnProcess handle, for kill-on-supersession. */
|
|
66
|
+
inFlightSpawns: Map<string, ProcessHandle<SpawnResult>>;
|
|
59
67
|
/**
|
|
60
68
|
* Debounce token for List `select` events. Rapid selection moves
|
|
61
|
-
* (PageDown, held j/k)
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* has arrived.
|
|
69
|
+
* (PageDown, held j/k) shouldn't churn through buffer swaps + spawns;
|
|
70
|
+
* we bump this id on every event and only do the work after a short
|
|
71
|
+
* delay if no newer event has arrived.
|
|
65
72
|
*/
|
|
66
73
|
pendingSelectId: number;
|
|
67
74
|
}
|
|
@@ -70,14 +77,15 @@ const state: GitLogState = {
|
|
|
70
77
|
isOpen: false,
|
|
71
78
|
groupId: null,
|
|
72
79
|
logBufferId: null,
|
|
80
|
+
initialDetailBufferId: null,
|
|
73
81
|
detailBufferId: null,
|
|
74
82
|
toolbarBufferId: null,
|
|
75
83
|
toolbarPanel: null,
|
|
76
84
|
logPanel: null,
|
|
77
85
|
commits: [],
|
|
78
86
|
selectedIndex: 0,
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
commitBuffers: new Map(),
|
|
88
|
+
inFlightSpawns: new Map(),
|
|
81
89
|
pendingSelectId: 0,
|
|
82
90
|
};
|
|
83
91
|
|
|
@@ -97,20 +105,23 @@ const SELECT_DEBOUNCE_MS = 60;
|
|
|
97
105
|
// the log, and opens the file at the cursor when pressed in the detail).
|
|
98
106
|
// =============================================================================
|
|
99
107
|
|
|
100
|
-
// j/k/Up/Down/PageUp/PageDown
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
108
|
+
// The log pane is cursor-driven: j/k/Up/Down/PageUp/PageDown move the
|
|
109
|
+
// pane's real buffer cursor (normal editor movement), which scrolls via
|
|
110
|
+
// the standard `ensure_cursor_visible` wheel — only when the cursor
|
|
111
|
+
// crosses the top/bottom edge. The cursor is the source of truth for
|
|
112
|
+
// which commit is selected; a `cursor_moved` subscription mirrors its
|
|
113
|
+
// line into the List highlight + detail pane. On the detail pane the
|
|
114
|
+
// same keys scroll the diff. Other actions (q/r/y/Tab/Return) are direct
|
|
115
|
+
// bindings — they don't depend on the cursor row.
|
|
105
116
|
editor.defineMode(
|
|
106
117
|
"git-log",
|
|
107
118
|
[
|
|
108
|
-
["k", "
|
|
109
|
-
["j", "
|
|
110
|
-
["Up", "
|
|
111
|
-
["Down", "
|
|
112
|
-
["PageUp", "
|
|
113
|
-
["PageDown", "
|
|
119
|
+
["k", "move_up"],
|
|
120
|
+
["j", "move_down"],
|
|
121
|
+
["Up", "move_up"],
|
|
122
|
+
["Down", "move_down"],
|
|
123
|
+
["PageUp", "move_page_up"],
|
|
124
|
+
["PageDown", "move_page_down"],
|
|
114
125
|
["Return", "git_log_enter"],
|
|
115
126
|
["Tab", "git_log_tab"],
|
|
116
127
|
["q", "git_log_q"],
|
|
@@ -122,52 +133,6 @@ editor.defineMode(
|
|
|
122
133
|
true, // inherit Normal-context bindings for unbound keys
|
|
123
134
|
);
|
|
124
135
|
|
|
125
|
-
function git_log_select_up(): void {
|
|
126
|
-
if (isLogPanelActive()) {
|
|
127
|
-
state.logPanel?.command(key("Up"));
|
|
128
|
-
} else {
|
|
129
|
-
editor.executeAction("move_up");
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
function git_log_select_down(): void {
|
|
133
|
-
if (isLogPanelActive()) {
|
|
134
|
-
state.logPanel?.command(key("Down"));
|
|
135
|
-
} else {
|
|
136
|
-
editor.executeAction("move_down");
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function git_log_select_page_up(): void {
|
|
140
|
-
if (isLogPanelActive()) {
|
|
141
|
-
state.logPanel?.command(key("PageUp"));
|
|
142
|
-
} else {
|
|
143
|
-
editor.executeAction("page_up");
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
function git_log_select_page_down(): void {
|
|
147
|
-
if (isLogPanelActive()) {
|
|
148
|
-
state.logPanel?.command(key("PageDown"));
|
|
149
|
-
} else {
|
|
150
|
-
editor.executeAction("page_down");
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** True iff the log panel is the focused buffer in the group. The
|
|
155
|
-
* group's bindings (j/k/Up/Down/PageUp/PageDown) apply to all panels
|
|
156
|
-
* uniformly; we only want navigation to drive the List widget when
|
|
157
|
-
* the user is *on* the log panel. From the detail panel, the same
|
|
158
|
-
* keys must move the buffer cursor (so users can scroll the diff
|
|
159
|
-
* before pressing Enter on a diff line to open the file view). */
|
|
160
|
-
function isLogPanelActive(): boolean {
|
|
161
|
-
return (
|
|
162
|
-
state.logBufferId !== null &&
|
|
163
|
-
editor.getActiveBufferId() === state.logBufferId
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
registerHandler("git_log_select_up", git_log_select_up);
|
|
167
|
-
registerHandler("git_log_select_down", git_log_select_down);
|
|
168
|
-
registerHandler("git_log_select_page_up", git_log_select_page_up);
|
|
169
|
-
registerHandler("git_log_select_page_down", git_log_select_page_down);
|
|
170
|
-
|
|
171
136
|
// =============================================================================
|
|
172
137
|
// Panel layout
|
|
173
138
|
// =============================================================================
|
|
@@ -260,19 +225,13 @@ editor.on("widget_event", (data) => {
|
|
|
260
225
|
}
|
|
261
226
|
return;
|
|
262
227
|
}
|
|
263
|
-
// Log pane (List of commit rows)
|
|
264
|
-
//
|
|
265
|
-
//
|
|
228
|
+
// Log pane (List of commit rows). Selection is cursor-driven (see the
|
|
229
|
+
// `cursor_moved` handler), so the List's `select` event is ignored —
|
|
230
|
+
// a row click places the buffer cursor, and `cursor_moved` mirrors it
|
|
231
|
+
// into the selection. `activate` (Enter / double-click) still opens.
|
|
266
232
|
if (state.logPanel !== null && data.panel_id === state.logPanel.id()) {
|
|
267
|
-
if (data.event_type === "select") {
|
|
268
|
-
const idx =
|
|
269
|
-
typeof data.payload?.index === "number" ? data.payload.index : -1;
|
|
270
|
-
if (idx >= 0) void on_log_select(idx);
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
233
|
if (data.event_type === "activate") {
|
|
274
234
|
void git_log_enter();
|
|
275
|
-
return;
|
|
276
235
|
}
|
|
277
236
|
return;
|
|
278
237
|
}
|
|
@@ -292,6 +251,11 @@ function detailFooter(hash: string): string {
|
|
|
292
251
|
return editor.t("status.commit_ready", { hash });
|
|
293
252
|
}
|
|
294
253
|
|
|
254
|
+
/** Stable widget key for the log List. The host keys selection +
|
|
255
|
+
* scroll instance state off this; the plugin re-pins selection
|
|
256
|
+
* through it after click/keyboard `select` events. */
|
|
257
|
+
const LOG_LIST_KEY = "git-log-list";
|
|
258
|
+
|
|
295
259
|
function renderLog(): void {
|
|
296
260
|
if (state.logPanel === null) return;
|
|
297
261
|
// List takes the per-row entries directly. selectedIndex: -1 on the
|
|
@@ -313,87 +277,264 @@ function renderLog(): void {
|
|
|
313
277
|
// scroll handle viewport. Revisit if commit lists grow into the
|
|
314
278
|
// tens of thousands.
|
|
315
279
|
visibleRows: Math.max(1, state.commits.length),
|
|
316
|
-
key:
|
|
280
|
+
key: LOG_LIST_KEY,
|
|
317
281
|
}),
|
|
318
282
|
);
|
|
319
283
|
}
|
|
320
284
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
285
|
+
// =============================================================================
|
|
286
|
+
// Streaming detail panel
|
|
287
|
+
//
|
|
288
|
+
// Per-commit cached file-backed buffers. On commit switch we either reuse
|
|
289
|
+
// the existing cached buffer (instant) or spawn `git show --patch` into a
|
|
290
|
+
// per-SHA file and open it via `openFileStreaming`, polling for growth
|
|
291
|
+
// while git runs in the background. The buffer-group panel is re-pointed
|
|
292
|
+
// at the chosen buffer via `setBufferGroupPanelBuffer` — the same single
|
|
293
|
+
// tab keeps the side-by-side log/detail UX.
|
|
294
|
+
// =============================================================================
|
|
329
295
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
296
|
+
/**
|
|
297
|
+
* Path of the per-SHA cache file. Commits are immutable; once we've
|
|
298
|
+
* written one, repeat visits are zero-git.
|
|
299
|
+
*/
|
|
300
|
+
function cachePathForHash(hash: string): string {
|
|
301
|
+
// `<dataDir>/git-show/<sha>.diff` — the .diff extension lets the
|
|
302
|
+
// syntax-highlight grammar kick in for free.
|
|
303
|
+
return `${editor.getDataDir()}/git-show/${hash}.diff`;
|
|
338
304
|
}
|
|
339
305
|
|
|
306
|
+
/** Polling interval while git is still writing. ~5 fps is plenty. */
|
|
307
|
+
const STREAM_POLL_MS = 200;
|
|
308
|
+
|
|
340
309
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
* `git show` is debounced.
|
|
310
|
+
* Start a `git show --patch` for `hash`, piping stdout straight into the
|
|
311
|
+
* cache file. Returns the handle so a later commit switch can `.kill()`
|
|
312
|
+
* the still-running spawn.
|
|
345
313
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
314
|
+
* Caller has already verified the cache file doesn't yet exist (or wants
|
|
315
|
+
* to overwrite it).
|
|
348
316
|
*/
|
|
349
|
-
function
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
317
|
+
function spawnGitShow(hash: string, cwd: string): ProcessHandle<SpawnResult> {
|
|
318
|
+
// `--stat --patch` matches what the previous plugin used. The stat
|
|
319
|
+
// header gives users a per-file changed-lines summary at the top
|
|
320
|
+
// of the diff and is also what `git show` produces by default, so
|
|
321
|
+
// its presence is what most readers (and tests) expect.
|
|
322
|
+
//
|
|
323
|
+
// The generated d.ts shows `spawnProcess(cmd, args, cwd?, stdoutTo?)`
|
|
324
|
+
// as flat positional args. The runtime JS wrapper also accepts an
|
|
325
|
+
// `{stdoutTo}` options object in the 4th slot, but using the flat
|
|
326
|
+
// form keeps the call type-checked without a cast.
|
|
327
|
+
return editor.spawnProcess(
|
|
328
|
+
"git",
|
|
329
|
+
["show", "--stat", "--patch", hash],
|
|
330
|
+
cwd,
|
|
331
|
+
cachePathForHash(hash),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Poll `editor.refreshBufferFromDisk` until the spawn handle resolves,
|
|
337
|
+
* then do one final catch-up refresh. Returns immediately if the
|
|
338
|
+
* commit is no longer in flight (e.g. user moved on, kill() fired).
|
|
339
|
+
*/
|
|
340
|
+
async function pollUntilSpawnDone(
|
|
341
|
+
hash: string,
|
|
342
|
+
bufferId: number,
|
|
343
|
+
handle: ProcessHandle<SpawnResult>,
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
// Wrap the handle's settlement in a non-rejecting marker promise so
|
|
346
|
+
// a fast subscription loop can `await` it (or race it against a
|
|
347
|
+
// delay) without worrying about whether the spawn errored. The
|
|
348
|
+
// ProcessHandle is a thenable, not a real Promise, so adapt via
|
|
349
|
+
// Promise.resolve().
|
|
350
|
+
let done = false;
|
|
351
|
+
void Promise.resolve(handle).then(
|
|
352
|
+
() => {
|
|
353
|
+
done = true;
|
|
354
|
+
},
|
|
355
|
+
() => {
|
|
356
|
+
done = true;
|
|
357
|
+
},
|
|
358
|
+
);
|
|
358
359
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return
|
|
360
|
+
while (!done) {
|
|
361
|
+
await editor.delay(STREAM_POLL_MS);
|
|
362
|
+
if (!state.isOpen) return; // group closed mid-stream
|
|
363
|
+
if (state.inFlightSpawns.get(hash) !== handle) return; // superseded
|
|
364
|
+
await editor.refreshBufferFromDisk(bufferId);
|
|
362
365
|
}
|
|
366
|
+
// Final catch-up so any bytes written between the last poll and
|
|
367
|
+
// process exit are visible immediately.
|
|
368
|
+
await editor.refreshBufferFromDisk(bufferId);
|
|
369
|
+
// Done — clear the in-flight handle if it's still ours.
|
|
370
|
+
if (state.inFlightSpawns.get(hash) === handle) {
|
|
371
|
+
state.inFlightSpawns.delete(hash);
|
|
372
|
+
}
|
|
373
|
+
// Apply diff coloring once the buffer is complete. Doing this
|
|
374
|
+
// pre-completion would either churn (re-walk on every refresh) or
|
|
375
|
+
// double-overlay newly-extended lines; on completion we walk once.
|
|
376
|
+
await applyDiffHighlights(bufferId);
|
|
377
|
+
}
|
|
363
378
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
379
|
+
// =============================================================================
|
|
380
|
+
// Diff syntax highlighting via per-line bg overlays
|
|
381
|
+
//
|
|
382
|
+
// Sublime-syntax's bundled `Diff` definition only scopes the `diff`
|
|
383
|
+
// keyword, so themes only colour that. Plugins are responsible for the
|
|
384
|
+
// rest — same approach `live_diff` uses for inline diff coloring in
|
|
385
|
+
// regular buffers.
|
|
386
|
+
//
|
|
387
|
+
// One overlay per line of added/removed content is fine for the
|
|
388
|
+
// "normal commit" workload but explodes on giant commits (the
|
|
389
|
+
// rewrite-bun commit is 1M lines = 1M overlays = back to the old
|
|
390
|
+
// 500k-overlay problem this rewire eliminated). Gate on buffer size;
|
|
391
|
+
// gracefully degrade to no highlighting for outliers.
|
|
392
|
+
// =============================================================================
|
|
393
|
+
|
|
394
|
+
const HIGHLIGHT_BG_ADDED = "editor.diff_add_bg";
|
|
395
|
+
const HIGHLIGHT_BG_REMOVED = "editor.diff_remove_bg";
|
|
396
|
+
const HIGHLIGHT_BG_HUNK = "editor.diff_modify_bg";
|
|
397
|
+
const HIGHLIGHT_NAMESPACE = "git-log-diff";
|
|
398
|
+
/** Skip overlay highlighting above this size. ~256 KB covers
|
|
399
|
+
* basically every hand-written commit comfortably; very large
|
|
400
|
+
* generated-file diffs (lockfiles, minified code) just stay
|
|
401
|
+
* uncoloured — the cost would be a few thousand-to-a-million
|
|
402
|
+
* overlays for content the user mostly skims. */
|
|
403
|
+
const HIGHLIGHT_MAX_BYTES = 256 * 1024;
|
|
404
|
+
|
|
405
|
+
async function applyDiffHighlights(bufferId: number): Promise<void> {
|
|
406
|
+
const total = editor.getBufferLength(bufferId);
|
|
407
|
+
if (total === 0 || total > HIGHLIGHT_MAX_BYTES) return;
|
|
408
|
+
const text = await editor.getBufferText(bufferId, 0, total);
|
|
409
|
+
if (!text) return;
|
|
410
|
+
|
|
411
|
+
// Walk lines tracking byte offsets; coalesce consecutive same-kind
|
|
412
|
+
// rows into single ranges so a 30-line added block costs one
|
|
413
|
+
// overlay, not 30.
|
|
414
|
+
let byte = 0;
|
|
415
|
+
let runKind: "+" | "-" | "@" | null = null;
|
|
416
|
+
let runStart = 0;
|
|
417
|
+
let runEnd = 0;
|
|
418
|
+
|
|
419
|
+
const flushRun = () => {
|
|
420
|
+
if (runKind === null) return;
|
|
421
|
+
const bg =
|
|
422
|
+
runKind === "+"
|
|
423
|
+
? HIGHLIGHT_BG_ADDED
|
|
424
|
+
: runKind === "-"
|
|
425
|
+
? HIGHLIGHT_BG_REMOVED
|
|
426
|
+
: HIGHLIGHT_BG_HUNK;
|
|
427
|
+
editor.addOverlay(bufferId, HIGHLIGHT_NAMESPACE, runStart, runEnd, {
|
|
428
|
+
bg,
|
|
429
|
+
extendToLineEnd: true,
|
|
430
|
+
});
|
|
431
|
+
runKind = null;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
for (const line of text.split("\n")) {
|
|
435
|
+
const lineLen = line.length;
|
|
436
|
+
const ch = line.charAt(0);
|
|
437
|
+
let kind: "+" | "-" | "@" | null = null;
|
|
438
|
+
if (ch === "+" && !line.startsWith("+++")) kind = "+";
|
|
439
|
+
else if (ch === "-" && !line.startsWith("---")) kind = "-";
|
|
440
|
+
else if (line.startsWith("@@")) kind = "@";
|
|
441
|
+
|
|
442
|
+
if (kind !== runKind) {
|
|
443
|
+
flushRun();
|
|
444
|
+
if (kind !== null) {
|
|
445
|
+
runStart = byte;
|
|
446
|
+
runKind = kind;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (kind !== null) {
|
|
450
|
+
// Include the trailing newline in the range so the bg colour
|
|
451
|
+
// fills the row even on empty lines that wrap-extend.
|
|
452
|
+
runEnd = byte + lineLen + 1;
|
|
453
|
+
}
|
|
454
|
+
byte += lineLen + 1;
|
|
455
|
+
}
|
|
456
|
+
flushRun();
|
|
368
457
|
}
|
|
369
458
|
|
|
370
459
|
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
460
|
+
* Get (or create) the file-backed buffer that displays `commit`.
|
|
461
|
+
* On first call for a hash: ensure cache file exists, kick off
|
|
462
|
+
* `git show` if it doesn't, openFileStreaming, start the poll loop.
|
|
463
|
+
* Returns the buffer id on success or null on failure.
|
|
373
464
|
*/
|
|
374
|
-
async function
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
465
|
+
async function ensureCommitBuffer(commit: GitCommit, cwd: string): Promise<number | null> {
|
|
466
|
+
const hash = commit.hash;
|
|
467
|
+
const existing = state.commitBuffers.get(hash);
|
|
468
|
+
if (existing !== undefined) return existing;
|
|
469
|
+
|
|
470
|
+
const path = cachePathForHash(hash);
|
|
471
|
+
const cacheHit = editor.fileExists(path);
|
|
472
|
+
|
|
473
|
+
if (!cacheHit) {
|
|
474
|
+
// Cache miss: spawn git, polling the file as it grows. The handle
|
|
475
|
+
// is stashed so a fast-scrolling user can supersede us via kill().
|
|
476
|
+
const handle = spawnGitShow(hash, cwd);
|
|
477
|
+
state.inFlightSpawns.set(hash, handle);
|
|
478
|
+
const bufferId = await editor.openFileStreaming(path);
|
|
479
|
+
if (bufferId === null) {
|
|
480
|
+
handle.kill?.();
|
|
481
|
+
state.inFlightSpawns.delete(hash);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
state.commitBuffers.set(hash, bufferId);
|
|
485
|
+
// Fire-and-forget polling task.
|
|
486
|
+
void pollUntilSpawnDone(hash, bufferId, handle);
|
|
487
|
+
return bufferId;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Cache hit: just open the existing file. No git spawned.
|
|
491
|
+
const bufferId = await editor.openFileStreaming(path);
|
|
492
|
+
if (bufferId === null) return null;
|
|
493
|
+
state.commitBuffers.set(hash, bufferId);
|
|
494
|
+
return bufferId;
|
|
388
495
|
}
|
|
389
496
|
|
|
390
497
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
498
|
+
* Show `commit` in the detail panel. Cancels any superseded in-flight
|
|
499
|
+
* spawn for *other* commits (the user has navigated past them) and
|
|
500
|
+
* retargets the panel at the chosen commit's buffer.
|
|
393
501
|
*/
|
|
502
|
+
async function showCommitInDetail(commit: GitCommit, cwd: string): Promise<void> {
|
|
503
|
+
// Cancel anything still streaming for a commit that isn't this one —
|
|
504
|
+
// the user has moved on; no point keeping git running.
|
|
505
|
+
for (const [hash, handle] of state.inFlightSpawns) {
|
|
506
|
+
if (hash !== commit.hash) {
|
|
507
|
+
handle.kill?.();
|
|
508
|
+
state.inFlightSpawns.delete(hash);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const bufferId = await ensureCommitBuffer(commit, cwd);
|
|
513
|
+
if (bufferId === null) {
|
|
514
|
+
editor.setStatus(
|
|
515
|
+
editor.t("status.failed_open_file", { file: commit.shortHash }),
|
|
516
|
+
);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (state.groupId === null) return;
|
|
520
|
+
await editor.setBufferGroupPanelBuffer(state.groupId, "detail", bufferId);
|
|
521
|
+
state.detailBufferId = bufferId;
|
|
522
|
+
// Each commit buffer needs the same per-buffer presentation as the
|
|
523
|
+
// initial virtual one: visible cursor for diff-line navigation,
|
|
524
|
+
// wrap on (long minified lines unreadable in the 40% panel).
|
|
525
|
+
editor.setBufferShowCursors(bufferId, true);
|
|
526
|
+
editor.setLineWrap(bufferId, null, true);
|
|
527
|
+
// Land at the top of the diff every time we (re-)visit a commit.
|
|
528
|
+
editor.setBufferCursor(bufferId, 0);
|
|
529
|
+
}
|
|
530
|
+
|
|
394
531
|
async function refreshDetail(): Promise<void> {
|
|
395
|
-
|
|
396
|
-
if (
|
|
532
|
+
if (state.groupId === null) return;
|
|
533
|
+
if (state.commits.length === 0) return;
|
|
534
|
+
const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1));
|
|
535
|
+
const commit = state.commits[idx];
|
|
536
|
+
if (!commit) return;
|
|
537
|
+
await showCommitInDetail(commit, editor.getCwd());
|
|
397
538
|
}
|
|
398
539
|
|
|
399
540
|
// =============================================================================
|
|
@@ -438,7 +579,11 @@ async function show_git_log(): Promise<void> {
|
|
|
438
579
|
);
|
|
439
580
|
state.groupId = group.groupId as number;
|
|
440
581
|
state.logBufferId = (group.panels["log"] as number | undefined) ?? null;
|
|
441
|
-
state.
|
|
582
|
+
state.initialDetailBufferId =
|
|
583
|
+
(group.panels["detail"] as number | undefined) ?? null;
|
|
584
|
+
// detailBufferId starts as the initial virtual buffer; it gets
|
|
585
|
+
// retargeted to a file-backed buffer on first commit selection.
|
|
586
|
+
state.detailBufferId = state.initialDetailBufferId;
|
|
442
587
|
state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null;
|
|
443
588
|
if (state.toolbarBufferId !== null) {
|
|
444
589
|
state.toolbarPanel = new WidgetPanel(state.toolbarBufferId);
|
|
@@ -447,35 +592,36 @@ async function show_git_log(): Promise<void> {
|
|
|
447
592
|
state.logPanel = new WidgetPanel(state.logBufferId);
|
|
448
593
|
}
|
|
449
594
|
state.selectedIndex = 0;
|
|
450
|
-
state.
|
|
595
|
+
state.commitBuffers = new Map();
|
|
596
|
+
state.inFlightSpawns = new Map();
|
|
451
597
|
state.isOpen = true;
|
|
452
598
|
|
|
453
|
-
// The detail panel
|
|
454
|
-
// clicked / traversed before pressing Enter to open a file.
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
editor.setLineWrap(state.detailBufferId, null, true);
|
|
462
|
-
// Per-panel mode: the group was created with "git-log" which applies
|
|
463
|
-
// to the initially-focused panel (log). The detail panel's mode is
|
|
464
|
-
// set when we focus into it.
|
|
599
|
+
// The detail panel owns a native cursor so diff lines can be
|
|
600
|
+
// clicked / traversed before pressing Enter to open a file. We set
|
|
601
|
+
// the cursor on each retargeted buffer as it gets swapped in, but
|
|
602
|
+
// wrap-default needs setting too — long minified lines in lock-file
|
|
603
|
+
// diffs are unreadable without wrap in the 40% panel.
|
|
604
|
+
if (state.initialDetailBufferId !== null) {
|
|
605
|
+
editor.setBufferShowCursors(state.initialDetailBufferId, true);
|
|
606
|
+
editor.setLineWrap(state.initialDetailBufferId, null, true);
|
|
465
607
|
}
|
|
466
608
|
|
|
467
609
|
renderToolbar();
|
|
468
610
|
renderLog();
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
611
|
+
// Cursor-driven selection: give the log pane a real, visible cursor and
|
|
612
|
+
// take ownership of it (`setBufferShowCursors` locks it so the widget
|
|
613
|
+
// runtime won't clear it on repaint). The cursor's line is the selected
|
|
614
|
+
// commit; `cursor_moved` mirrors it into the List highlight + detail.
|
|
615
|
+
// Start on HEAD (line 0). Scrolling is the normal cursor-follow wheel.
|
|
616
|
+
if (state.logBufferId !== null) {
|
|
617
|
+
editor.setBufferShowCursors(state.logBufferId, true);
|
|
618
|
+
editor.setBufferCursor(state.logBufferId, 0);
|
|
619
|
+
}
|
|
472
620
|
await refreshDetail();
|
|
473
621
|
|
|
474
|
-
if (state.groupId !== null) {
|
|
475
|
-
editor.focusBufferGroupPanel(state.groupId, "log");
|
|
476
|
-
}
|
|
477
622
|
editor.on("resize", on_git_log_resize);
|
|
478
623
|
editor.on("buffer_closed", on_git_log_buffer_closed);
|
|
624
|
+
editor.on("cursor_moved", on_git_log_cursor_moved);
|
|
479
625
|
|
|
480
626
|
editor.setStatus(
|
|
481
627
|
editor.t("status.log_ready", { count: String(state.commits.length) })
|
|
@@ -490,20 +636,37 @@ function git_log_cleanup(): void {
|
|
|
490
636
|
if (!state.isOpen) return;
|
|
491
637
|
editor.off("resize", on_git_log_resize);
|
|
492
638
|
editor.off("buffer_closed", on_git_log_buffer_closed);
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
639
|
+
editor.off("cursor_moved", on_git_log_cursor_moved);
|
|
640
|
+
// Kill any still-running `git show` spawns — we no longer care.
|
|
641
|
+
for (const [, handle] of state.inFlightSpawns) {
|
|
642
|
+
handle.kill?.();
|
|
643
|
+
}
|
|
644
|
+
state.inFlightSpawns.clear();
|
|
645
|
+
// Close each per-commit buffer we created. The buffer-group's own
|
|
646
|
+
// `close` (called below in `git_log_close`) tears down the panel
|
|
647
|
+
// buffers (toolbar/log/initialDetail) — but retargeted file-backed
|
|
648
|
+
// buffers we allocated via openFileStreaming are *outside* the
|
|
649
|
+
// group's panel_buffers map by the time we got here, so we must
|
|
650
|
+
// close them explicitly to avoid leaks.
|
|
651
|
+
for (const [, bufferId] of state.commitBuffers) {
|
|
652
|
+
editor.closeBuffer(bufferId);
|
|
653
|
+
}
|
|
654
|
+
state.commitBuffers.clear();
|
|
655
|
+
// The buffer-group's `close` will tear down its own panel buffers
|
|
656
|
+
// (toolbar/log/initialDetail) too, which implicitly drops the widget
|
|
657
|
+
// panels rendering into them. We still null out the handles so any
|
|
658
|
+
// stray `renderToolbar()` / `renderLog()` call post-cleanup is a
|
|
659
|
+
// no-op.
|
|
497
660
|
state.toolbarPanel = null;
|
|
498
661
|
state.logPanel = null;
|
|
499
662
|
state.isOpen = false;
|
|
500
663
|
state.groupId = null;
|
|
501
664
|
state.logBufferId = null;
|
|
665
|
+
state.initialDetailBufferId = null;
|
|
502
666
|
state.detailBufferId = null;
|
|
503
667
|
state.toolbarBufferId = null;
|
|
504
668
|
state.commits = [];
|
|
505
669
|
state.selectedIndex = 0;
|
|
506
|
-
state.detailCache = null;
|
|
507
670
|
}
|
|
508
671
|
|
|
509
672
|
function git_log_close(): void {
|
|
@@ -519,12 +682,26 @@ registerHandler("git_log_close", git_log_close);
|
|
|
519
682
|
|
|
520
683
|
function on_git_log_buffer_closed(data: { buffer_id: number }): void {
|
|
521
684
|
if (!state.isOpen) return;
|
|
685
|
+
// Tear down the whole group only when the *group's* buffers close
|
|
686
|
+
// (toolbar / log / the initial virtual detail). A retargeted
|
|
687
|
+
// file-backed commit buffer closing is normal — drop it from our
|
|
688
|
+
// cache but keep the group alive.
|
|
522
689
|
if (
|
|
523
690
|
data.buffer_id === state.logBufferId ||
|
|
524
|
-
data.buffer_id === state.
|
|
691
|
+
data.buffer_id === state.initialDetailBufferId ||
|
|
525
692
|
data.buffer_id === state.toolbarBufferId
|
|
526
693
|
) {
|
|
527
694
|
git_log_cleanup();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Removed from cache so a revisit re-spawns / re-opens.
|
|
698
|
+
for (const [hash, bufId] of state.commitBuffers) {
|
|
699
|
+
if (bufId === data.buffer_id) {
|
|
700
|
+
state.commitBuffers.delete(hash);
|
|
701
|
+
state.inFlightSpawns.get(hash)?.kill?.();
|
|
702
|
+
state.inFlightSpawns.delete(hash);
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
528
705
|
}
|
|
529
706
|
}
|
|
530
707
|
registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed);
|
|
@@ -533,7 +710,13 @@ async function git_log_refresh(): Promise<void> {
|
|
|
533
710
|
if (!state.isOpen) return;
|
|
534
711
|
editor.setStatus(editor.t("status.refreshing"));
|
|
535
712
|
state.commits = await fetchGitLog(editor);
|
|
536
|
-
|
|
713
|
+
// The on-disk cache files are keyed by SHA and commits are
|
|
714
|
+
// immutable, so they remain valid — but our in-memory buffer ids
|
|
715
|
+
// for commits no longer in the visible list are stale; clear them.
|
|
716
|
+
for (const [, handle] of state.inFlightSpawns) handle.kill?.();
|
|
717
|
+
state.inFlightSpawns.clear();
|
|
718
|
+
for (const [, bufferId] of state.commitBuffers) editor.closeBuffer(bufferId);
|
|
719
|
+
state.commitBuffers.clear();
|
|
537
720
|
if (state.selectedIndex >= state.commits.length) {
|
|
538
721
|
state.selectedIndex = Math.max(0, state.commits.length - 1);
|
|
539
722
|
}
|
|
@@ -604,26 +787,222 @@ function git_log_q(): void {
|
|
|
604
787
|
}
|
|
605
788
|
registerHandler("git_log_q", git_log_q);
|
|
606
789
|
|
|
790
|
+
// =============================================================================
|
|
791
|
+
// Folding by file and hunk
|
|
792
|
+
//
|
|
793
|
+
// Publishes structural fold ranges into the buffer's `folding_ranges`
|
|
794
|
+
// via `setFoldingRanges` — the same channel an LSP `foldingRange`
|
|
795
|
+
// response uses. Nothing is pre-collapsed; the user toggles a range
|
|
796
|
+
// with the standard fold keybinding (`za` etc.), which finds the
|
|
797
|
+
// matching range under the cursor.
|
|
798
|
+
//
|
|
799
|
+
// The diff structure gives us two natural fold levels:
|
|
800
|
+
// * per-file: each `diff --git a/X b/Y` section
|
|
801
|
+
// * per-hunk: each `@@ -A,B +C,D @@` block within a file
|
|
802
|
+
// We publish both; the toggle-fold key picks the innermost containing
|
|
803
|
+
// range at the cursor's line.
|
|
804
|
+
//
|
|
805
|
+
// Computed once after `pollUntilSpawnDone` settles — re-running on
|
|
806
|
+
// every refresh would churn the marker list for no benefit (the diff
|
|
807
|
+
// structure is monotonic-append until exit).
|
|
808
|
+
// =============================================================================
|
|
809
|
+
|
|
810
|
+
interface DiffFoldRange {
|
|
811
|
+
startLine: number;
|
|
812
|
+
endLine: number;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Walk the buffer text and return (file-level, hunk-level) fold
|
|
816
|
+
* ranges. Lines are 0-indexed, both endpoints inclusive — the LSP
|
|
817
|
+
* shape. The "fold header" is the line at `startLine`; everything up
|
|
818
|
+
* through `endLine` collapses under it. */
|
|
819
|
+
function computeDiffFoldRanges(text: string): {
|
|
820
|
+
files: DiffFoldRange[];
|
|
821
|
+
hunks: DiffFoldRange[];
|
|
822
|
+
} {
|
|
823
|
+
const lines = text.split("\n");
|
|
824
|
+
const files: DiffFoldRange[] = [];
|
|
825
|
+
const hunks: DiffFoldRange[] = [];
|
|
826
|
+
|
|
827
|
+
let fileStart: number | null = null;
|
|
828
|
+
let hunkStart: number | null = null;
|
|
829
|
+
|
|
830
|
+
const closeHunk = (endLine: number) => {
|
|
831
|
+
if (hunkStart !== null && endLine > hunkStart) {
|
|
832
|
+
hunks.push({ startLine: hunkStart, endLine });
|
|
833
|
+
}
|
|
834
|
+
hunkStart = null;
|
|
835
|
+
};
|
|
836
|
+
const closeFile = (endLine: number) => {
|
|
837
|
+
closeHunk(endLine);
|
|
838
|
+
if (fileStart !== null && endLine > fileStart) {
|
|
839
|
+
files.push({ startLine: fileStart, endLine });
|
|
840
|
+
}
|
|
841
|
+
fileStart = null;
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
for (let i = 0; i < lines.length; i++) {
|
|
845
|
+
const l = lines[i];
|
|
846
|
+
if (l.startsWith("diff --git ")) {
|
|
847
|
+
closeFile(i - 1);
|
|
848
|
+
fileStart = i;
|
|
849
|
+
} else if (l.startsWith("@@ ")) {
|
|
850
|
+
closeHunk(i - 1);
|
|
851
|
+
hunkStart = i;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
closeFile(lines.length - 1);
|
|
855
|
+
return { files, hunks };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function publishDiffFoldRanges(bufferId: number): Promise<void> {
|
|
859
|
+
const total = editor.getBufferLength(bufferId);
|
|
860
|
+
if (total === 0) return;
|
|
861
|
+
const text = await editor.getBufferText(bufferId, 0, total);
|
|
862
|
+
if (!text) return;
|
|
863
|
+
|
|
864
|
+
const { files, hunks } = computeDiffFoldRanges(text);
|
|
865
|
+
|
|
866
|
+
// Merge — the host accepts a single array. "region" kind tags both
|
|
867
|
+
// levels generically; the LSP spec also defines comment/imports
|
|
868
|
+
// kinds which don't apply to diffs.
|
|
869
|
+
const ranges = [...files, ...hunks].map((r) => ({
|
|
870
|
+
startLine: r.startLine,
|
|
871
|
+
endLine: r.endLine,
|
|
872
|
+
kind: "region",
|
|
873
|
+
}));
|
|
874
|
+
editor.setFoldingRanges(bufferId, ranges);
|
|
875
|
+
}
|
|
876
|
+
|
|
607
877
|
// =============================================================================
|
|
608
878
|
// Detail panel — open file at commit
|
|
609
879
|
// =============================================================================
|
|
610
880
|
|
|
881
|
+
/**
|
|
882
|
+
* Walk through the streaming diff buffer to find the file + line
|
|
883
|
+
* context near the cursor. Diff format:
|
|
884
|
+
*
|
|
885
|
+
* diff --git a/<path> b/<path>
|
|
886
|
+
* index ...
|
|
887
|
+
* --- a/<path> (or /dev/null for additions)
|
|
888
|
+
* +++ b/<path> (or /dev/null for deletions)
|
|
889
|
+
* @@ -old,n +new,m @@
|
|
890
|
+
* <context|+|- lines>
|
|
891
|
+
*
|
|
892
|
+
* Strategy:
|
|
893
|
+
* - Read up to the END of the cursor's line, not just up to the
|
|
894
|
+
* cursor's byte offset. This way a cursor sitting on a header line
|
|
895
|
+
* (`diff --git`, `+++ b/...`, `@@ ...`) still gets that line
|
|
896
|
+
* matched, matching the old text-property behaviour.
|
|
897
|
+
* - Walk backwards for the per-file header. Match either:
|
|
898
|
+
* `+++ b/<path>` (preferred — names the new-side path)
|
|
899
|
+
* `diff --git a/<src> b/<dst>` (fallback — covers the case where
|
|
900
|
+
* the cursor is on the `diff --git` line itself, before the
|
|
901
|
+
* `+++` line has appeared in the search range)
|
|
902
|
+
* - Walk backwards for the most recent `@@ -... +<new>,<count> @@`
|
|
903
|
+
* between the header and cursor, then count context/'+' rows
|
|
904
|
+
* forward to the cursor to derive the new-side line number.
|
|
905
|
+
*/
|
|
906
|
+
async function deriveFileAndLineFromDiffCursor(
|
|
907
|
+
bufferId: number,
|
|
908
|
+
): Promise<{ file: string; line: number } | null> {
|
|
909
|
+
const cursor = editor.getCursorPosition();
|
|
910
|
+
if (cursor < 0) return null;
|
|
911
|
+
|
|
912
|
+
const bufLen = editor.getBufferLength(bufferId);
|
|
913
|
+
const readEnd = Math.min(bufLen, cursor + 4096);
|
|
914
|
+
if (readEnd === 0) return null;
|
|
915
|
+
const text = await editor.getBufferText(bufferId, 0, readEnd);
|
|
916
|
+
if (!text) return null;
|
|
917
|
+
const lines = text.split("\n");
|
|
918
|
+
|
|
919
|
+
// Locate the cursor's line index by walking byte offsets. `lines[i]`
|
|
920
|
+
// covers bytes [byte, byte+len]; the `\n` separator lives at
|
|
921
|
+
// byte+len, so the next line starts at byte+len+1.
|
|
922
|
+
let byte = 0;
|
|
923
|
+
let cursorLineIdx = lines.length - 1;
|
|
924
|
+
for (let i = 0; i < lines.length; i++) {
|
|
925
|
+
const lineLen = lines[i].length;
|
|
926
|
+
if (cursor <= byte + lineLen) {
|
|
927
|
+
cursorLineIdx = i;
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
byte += lineLen + 1;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Walk back from the cursor's line for the per-file header. Match
|
|
934
|
+
// either `+++ b/<path>` or `diff --git a/<src> b/<dst>` so cursor-
|
|
935
|
+
// on-header cases work.
|
|
936
|
+
let file: string | null = null;
|
|
937
|
+
let headerIdx = -1;
|
|
938
|
+
for (let i = cursorLineIdx; i >= 0; i--) {
|
|
939
|
+
const l = lines[i];
|
|
940
|
+
if (l.startsWith("+++ b/")) {
|
|
941
|
+
file = l.slice(6).trim();
|
|
942
|
+
headerIdx = i;
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
if (l.startsWith("+++ /dev/null")) {
|
|
946
|
+
// Deletion — no new-side path. Opening the pre-image is a
|
|
947
|
+
// separate flow.
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(l);
|
|
951
|
+
if (m) {
|
|
952
|
+
const aSide = m[1];
|
|
953
|
+
const bSide = m[2];
|
|
954
|
+
file = bSide === "/dev/null" ? aSide : bSide;
|
|
955
|
+
headerIdx = i;
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (file === null || headerIdx < 0) return null;
|
|
960
|
+
|
|
961
|
+
// Find the most recent `@@ ... +start,count @@` between header and
|
|
962
|
+
// cursor. Default: line 1 (cursor sits on the header itself, or
|
|
963
|
+
// between the header and the first hunk).
|
|
964
|
+
let line = 1;
|
|
965
|
+
for (let i = cursorLineIdx; i > headerIdx; i--) {
|
|
966
|
+
const l = lines[i];
|
|
967
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
|
|
968
|
+
if (!m) continue;
|
|
969
|
+
const newStart = parseInt(m[1], 10);
|
|
970
|
+
if (!Number.isFinite(newStart)) return null;
|
|
971
|
+
// Walk forward from the hunk header to the cursor's line,
|
|
972
|
+
// advancing the new-file line counter for context (' ') and
|
|
973
|
+
// addition ('+') rows; skip deletion ('-') rows since they don't
|
|
974
|
+
// exist in the new file.
|
|
975
|
+
let cur = newStart;
|
|
976
|
+
for (let j = i + 1; j <= cursorLineIdx; j++) {
|
|
977
|
+
if (j === cursorLineIdx) {
|
|
978
|
+
line = cur;
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
const ch = lines[j].charAt(0);
|
|
982
|
+
if (ch === "+" || ch === " " || ch === "") cur += 1;
|
|
983
|
+
// '-' / '\' (no-newline marker): don't advance.
|
|
984
|
+
}
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
return { file, line };
|
|
988
|
+
}
|
|
989
|
+
|
|
611
990
|
async function git_log_detail_open_file(): Promise<void> {
|
|
612
991
|
if (state.detailBufferId === null) return;
|
|
613
992
|
const commit = selectedCommit();
|
|
614
993
|
if (!commit) return;
|
|
615
994
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
if (!file) {
|
|
995
|
+
// The detail buffer is a plain file-backed view of `git show --patch`,
|
|
996
|
+
// so we don't have plugin-attached `file`/`line` properties anymore.
|
|
997
|
+
// Parse the diff backwards from the cursor to find the nearest
|
|
998
|
+
// `+++ b/<path>` header (a per-file diff section opener) and the
|
|
999
|
+
// most recent hunk header to derive a line number.
|
|
1000
|
+
const ctx = await deriveFileAndLineFromDiffCursor(state.detailBufferId);
|
|
1001
|
+
if (!ctx) {
|
|
624
1002
|
editor.setStatus(editor.t("status.move_to_diff_with_context"));
|
|
625
1003
|
return;
|
|
626
1004
|
}
|
|
1005
|
+
const { file, line } = ctx;
|
|
627
1006
|
|
|
628
1007
|
editor.setStatus(
|
|
629
1008
|
editor.t("status.file_loading", { file, hash: commit.shortHash })
|
|
@@ -699,19 +1078,33 @@ function git_log_file_view_close(): void {
|
|
|
699
1078
|
registerHandler("git_log_file_view_close", git_log_file_view_close);
|
|
700
1079
|
|
|
701
1080
|
// =============================================================================
|
|
702
|
-
// Selection tracking —
|
|
703
|
-
//
|
|
1081
|
+
// Selection tracking — the log pane is cursor-driven. The buffer cursor's
|
|
1082
|
+
// line (set by arrow-key movement or a click) is the selected commit; this
|
|
1083
|
+
// `cursor_moved` subscription mirrors it into the List highlight and the
|
|
1084
|
+
// detail pane. Scrolling is handled by the normal cursor-follow wheel, so
|
|
1085
|
+
// the viewport only moves when the cursor crosses the top/bottom edge.
|
|
704
1086
|
// =============================================================================
|
|
705
1087
|
|
|
706
|
-
|
|
1088
|
+
function on_git_log_cursor_moved(data: { buffer_id: number; line: number }): void {
|
|
1089
|
+
if (!state.isOpen || state.logBufferId === null) return;
|
|
1090
|
+
if (data.buffer_id !== state.logBufferId) return;
|
|
1091
|
+
// `cursor_moved.line` is 1-based; commit rows are 0-based (no header),
|
|
1092
|
+
// so the selected commit index is `line - 1`.
|
|
1093
|
+
const idx = data.line - 1;
|
|
1094
|
+
if (idx < 0 || idx >= state.commits.length) return;
|
|
1095
|
+
void selectCommitLine(idx);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function selectCommitLine(idx: number): Promise<void> {
|
|
707
1099
|
if (!state.isOpen) return;
|
|
708
1100
|
if (idx === state.selectedIndex) return;
|
|
709
1101
|
state.selectedIndex = idx;
|
|
710
1102
|
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
|
|
1103
|
+
// Move the List's highlight bar to the cursor's row. The cursor itself
|
|
1104
|
+
// is the real (plugin-owned) buffer cursor, so it stays exactly where
|
|
1105
|
+
// the user moved or clicked it — this only repaints the row styling,
|
|
1106
|
+
// and the repaint preserves the cursor position.
|
|
1107
|
+
state.logPanel?.setSelectedIndex(LOG_LIST_KEY, idx);
|
|
715
1108
|
|
|
716
1109
|
const commit = state.commits[state.selectedIndex];
|
|
717
1110
|
if (commit) {
|
|
@@ -723,16 +1116,17 @@ async function on_log_select(idx: number): Promise<void> {
|
|
|
723
1116
|
);
|
|
724
1117
|
}
|
|
725
1118
|
|
|
726
|
-
if (!pending) return;
|
|
727
|
-
|
|
728
1119
|
// Debounce: bump the token, wait a beat, bail if a newer event has
|
|
729
|
-
// arrived.
|
|
730
|
-
//
|
|
1120
|
+
// arrived. Even though re-pointing the panel at a cached buffer is
|
|
1121
|
+
// ~free, kicking off a new `git show --patch` for every intermediate
|
|
1122
|
+
// row in a held-j burst is wasteful. Collapse rapid selection moves.
|
|
731
1123
|
const myId = ++state.pendingSelectId;
|
|
732
1124
|
await editor.delay(SELECT_DEBOUNCE_MS);
|
|
733
1125
|
if (myId !== state.pendingSelectId) return;
|
|
734
1126
|
if (!state.isOpen) return;
|
|
735
|
-
|
|
1127
|
+
const current = state.commits[state.selectedIndex];
|
|
1128
|
+
if (!current) return;
|
|
1129
|
+
await showCommitInDetail(current, editor.getCwd());
|
|
736
1130
|
}
|
|
737
1131
|
|
|
738
1132
|
// =============================================================================
|
|
@@ -757,5 +1151,4 @@ editor.registerCommand(
|
|
|
757
1151
|
"git_log_refresh",
|
|
758
1152
|
null
|
|
759
1153
|
);
|
|
760
|
-
|
|
761
1154
|
editor.debug("Git Log plugin initialized (modern buffer-group layout)");
|