@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.
@@ -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, key, list, row, WidgetPanel } from "./lib/index.ts";
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). Created in
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). Owns
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
- * In-flight detail request id. Used to ignore stale responses when the
56
- * user scrolls through the log faster than `git show` can return.
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
- pendingDetailId: number;
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) would otherwise trigger a full `git show`
62
- * spawn per intermediate row; we bump this id on every event
63
- * and only do the work after a short delay if no newer event
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
- detailCache: null,
80
- pendingDetailId: 0,
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 route to the log List widget so the host
101
- // owns selection + scroll + auto-scroll. The List's `select` event then
102
- // fires back into the plugin's `widget_event` handler for detail-pane
103
- // refresh. Other plugin actions (q/r/y/Tab/Return) stay as direct
104
- // bindings they don't depend on which row is highlighted.
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", "git_log_select_up"],
109
- ["j", "git_log_select_down"],
110
- ["Up", "git_log_select_up"],
111
- ["Down", "git_log_select_down"],
112
- ["PageUp", "git_log_select_page_up"],
113
- ["PageDown", "git_log_select_page_down"],
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) `select` fires on j/k/Up/Down/
264
- // PageUp/PageDown navigation and on row clicks; `activate` fires on
265
- // Enter or double-click.
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: "git-log-list",
280
+ key: LOG_LIST_KEY,
317
281
  }),
318
282
  );
319
283
  }
320
284
 
321
- function renderDetailPlaceholder(message: string): void {
322
- if (state.groupId === null) return;
323
- editor.setPanelContent(
324
- state.groupId,
325
- "detail",
326
- buildDetailPlaceholderEntries(message)
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
- function renderDetailForCommit(commit: GitCommit, showOutput: string): void {
331
- if (state.groupId === null) return;
332
- const entries = buildCommitDetailEntries(commit, showOutput);
333
- editor.setPanelContent(state.groupId, "detail", entries);
334
- // Always scroll the detail panel back to the top when the selection changes.
335
- if (state.detailBufferId !== null) {
336
- editor.setBufferCursor(state.detailBufferId, 0);
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
- * Synchronous detail refresh: render from cache if we have it, otherwise
342
- * a "loading…" placeholder. Never spawns git. Called immediately on every
343
- * selection change so the user sees instant feedback even while the real
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
- * Returns the commit that needs fetching (cache miss) or null (cache hit
347
- * or no commit selected) so the caller can decide whether to spawn.
314
+ * Caller has already verified the cache file doesn't yet exist (or wants
315
+ * to overwrite it).
348
316
  */
349
- function refreshDetailImmediate(): GitCommit | null {
350
- if (state.groupId === null) return null;
351
- if (state.commits.length === 0) {
352
- renderDetailPlaceholder(editor.t("status.no_commits"));
353
- return null;
354
- }
355
- const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1));
356
- const commit = state.commits[idx];
357
- if (!commit) return null;
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
- if (state.detailCache && state.detailCache.hash === commit.hash) {
360
- renderDetailForCommit(commit, state.detailCache.output);
361
- return null;
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
- renderDetailPlaceholder(
365
- editor.t("status.loading_commit", { hash: commit.shortHash })
366
- );
367
- return commit;
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
- * Spawn `git show` for `commit` and render the result. Tagged with
372
- * `pendingDetailId` so a newer selection supersedes in-flight fetches.
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 fetchAndRenderDetail(commit: GitCommit): Promise<void> {
375
- const myId = ++state.pendingDetailId;
376
- const output = await fetchCommitShow(editor, commit.hash);
377
- if (myId !== state.pendingDetailId) return;
378
- if (state.groupId === null) return;
379
- state.detailCache = { hash: commit.hash, output };
380
- // Only render if the current selection is still this commit — a rapid
381
- // Up/Down burst might have moved on before we got here.
382
- const currentIdx = Math.max(
383
- 0,
384
- Math.min(state.selectedIndex, state.commits.length - 1)
385
- );
386
- if (state.commits[currentIdx]?.hash !== commit.hash) return;
387
- renderDetailForCommit(commit, output);
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
- * Combined synchronous + asynchronous refresh used by open/refresh paths
392
- * where there's no burst of events to collapse.
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
- const pending = refreshDetailImmediate();
396
- if (pending) await fetchAndRenderDetail(pending);
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.detailBufferId = (group.panels["detail"] as number | undefined) ?? null;
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.detailCache = null;
595
+ state.commitBuffers = new Map();
596
+ state.inFlightSpawns = new Map();
451
597
  state.isOpen = true;
452
598
 
453
- // The detail panel still owns a native cursor so diff lines can be
454
- // clicked / traversed before pressing Enter to open a file. The log
455
- // panel's selection is owned by the List widget no buffer cursor
456
- // needed (the focused-row highlight indicates position).
457
- if (state.detailBufferId !== null) {
458
- editor.setBufferShowCursors(state.detailBufferId, true);
459
- // Wrap long lines in the detail panel — git diffs often exceed the
460
- // 40% split width, and horizontal scrolling a commit is awkward.
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
- // List widget's instance state is the source of truth for selection;
470
- // no buffer-cursor positioning needed (the renderer auto-scrolls so
471
- // the selected row stays visible).
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
- // The buffer-group's `close` will tear down the panel buffers too,
494
- // which implicitly drops the widget panels rendering into them. We
495
- // still null out the handles so any stray `renderToolbar()` /
496
- // `renderLog()` call post-cleanup is a no-op.
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.detailBufferId ||
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
- state.detailCache = null;
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
- const props = editor.getTextPropertiesAtCursor(state.detailBufferId);
617
- if (props.length === 0) {
618
- editor.setStatus(editor.t("status.move_to_diff"));
619
- return;
620
- }
621
- const file = props[0].file as string | undefined;
622
- const line = (props[0].line as number | undefined) ?? 1;
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 — live-update the detail panel as the user
703
- // navigates the List. Driven by `widget_event "select"` from the host.
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
- async function on_log_select(idx: number): Promise<void> {
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
- // Immediate feedback: cached detail or "loading" placeholder.
712
- // The host already re-rendered the List with the new selection
713
- // highlight, so we only need to update the right-hand pane.
714
- const pending = refreshDetailImmediate();
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. `git show` is expensive; a burst of select events (held
730
- // j/k, PageDown) must collapse to one spawn.
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
- await fetchAndRenderDetail(pending);
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)");