@fresh-editor/fresh-editor 0.3.6 → 0.3.7

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,10 +2,7 @@
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
8
  import { button, flexSpacer, key, list, row, WidgetPanel } from "./lib/index.ts";
@@ -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
 
@@ -140,14 +148,14 @@ function git_log_select_page_up(): void {
140
148
  if (isLogPanelActive()) {
141
149
  state.logPanel?.command(key("PageUp"));
142
150
  } else {
143
- editor.executeAction("page_up");
151
+ editor.executeAction("move_page_up");
144
152
  }
145
153
  }
146
154
  function git_log_select_page_down(): void {
147
155
  if (isLogPanelActive()) {
148
156
  state.logPanel?.command(key("PageDown"));
149
157
  } else {
150
- editor.executeAction("page_down");
158
+ editor.executeAction("move_page_down");
151
159
  }
152
160
  }
153
161
 
@@ -318,82 +326,259 @@ function renderLog(): void {
318
326
  );
319
327
  }
320
328
 
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
- }
329
+ // =============================================================================
330
+ // Streaming detail panel
331
+ //
332
+ // Per-commit cached file-backed buffers. On commit switch we either reuse
333
+ // the existing cached buffer (instant) or spawn `git show --patch` into a
334
+ // per-SHA file and open it via `openFileStreaming`, polling for growth
335
+ // while git runs in the background. The buffer-group panel is re-pointed
336
+ // at the chosen buffer via `setBufferGroupPanelBuffer` — the same single
337
+ // tab keeps the side-by-side log/detail UX.
338
+ // =============================================================================
329
339
 
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
- }
340
+ /**
341
+ * Path of the per-SHA cache file. Commits are immutable; once we've
342
+ * written one, repeat visits are zero-git.
343
+ */
344
+ function cachePathForHash(hash: string): string {
345
+ // `<dataDir>/git-show/<sha>.diff` — the .diff extension lets the
346
+ // syntax-highlight grammar kick in for free.
347
+ return `${editor.getDataDir()}/git-show/${hash}.diff`;
338
348
  }
339
349
 
350
+ /** Polling interval while git is still writing. ~5 fps is plenty. */
351
+ const STREAM_POLL_MS = 200;
352
+
340
353
  /**
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.
354
+ * Start a `git show --patch` for `hash`, piping stdout straight into the
355
+ * cache file. Returns the handle so a later commit switch can `.kill()`
356
+ * the still-running spawn.
345
357
  *
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.
358
+ * Caller has already verified the cache file doesn't yet exist (or wants
359
+ * to overwrite it).
348
360
  */
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;
361
+ function spawnGitShow(hash: string, cwd: string): ProcessHandle<SpawnResult> {
362
+ // `--stat --patch` matches what the previous plugin used. The stat
363
+ // header gives users a per-file changed-lines summary at the top
364
+ // of the diff and is also what `git show` produces by default, so
365
+ // its presence is what most readers (and tests) expect.
366
+ //
367
+ // The generated d.ts shows `spawnProcess(cmd, args, cwd?, stdoutTo?)`
368
+ // as flat positional args. The runtime JS wrapper also accepts an
369
+ // `{stdoutTo}` options object in the 4th slot, but using the flat
370
+ // form keeps the call type-checked without a cast.
371
+ return editor.spawnProcess(
372
+ "git",
373
+ ["show", "--stat", "--patch", hash],
374
+ cwd,
375
+ cachePathForHash(hash),
376
+ );
377
+ }
378
+
379
+ /**
380
+ * Poll `editor.refreshBufferFromDisk` until the spawn handle resolves,
381
+ * then do one final catch-up refresh. Returns immediately if the
382
+ * commit is no longer in flight (e.g. user moved on, kill() fired).
383
+ */
384
+ async function pollUntilSpawnDone(
385
+ hash: string,
386
+ bufferId: number,
387
+ handle: ProcessHandle<SpawnResult>,
388
+ ): Promise<void> {
389
+ // Wrap the handle's settlement in a non-rejecting marker promise so
390
+ // a fast subscription loop can `await` it (or race it against a
391
+ // delay) without worrying about whether the spawn errored. The
392
+ // ProcessHandle is a thenable, not a real Promise, so adapt via
393
+ // Promise.resolve().
394
+ let done = false;
395
+ void Promise.resolve(handle).then(
396
+ () => {
397
+ done = true;
398
+ },
399
+ () => {
400
+ done = true;
401
+ },
402
+ );
358
403
 
359
- if (state.detailCache && state.detailCache.hash === commit.hash) {
360
- renderDetailForCommit(commit, state.detailCache.output);
361
- return null;
404
+ while (!done) {
405
+ await editor.delay(STREAM_POLL_MS);
406
+ if (!state.isOpen) return; // group closed mid-stream
407
+ if (state.inFlightSpawns.get(hash) !== handle) return; // superseded
408
+ await editor.refreshBufferFromDisk(bufferId);
409
+ }
410
+ // Final catch-up so any bytes written between the last poll and
411
+ // process exit are visible immediately.
412
+ await editor.refreshBufferFromDisk(bufferId);
413
+ // Done — clear the in-flight handle if it's still ours.
414
+ if (state.inFlightSpawns.get(hash) === handle) {
415
+ state.inFlightSpawns.delete(hash);
362
416
  }
417
+ // Apply diff coloring once the buffer is complete. Doing this
418
+ // pre-completion would either churn (re-walk on every refresh) or
419
+ // double-overlay newly-extended lines; on completion we walk once.
420
+ await applyDiffHighlights(bufferId);
421
+ }
363
422
 
364
- renderDetailPlaceholder(
365
- editor.t("status.loading_commit", { hash: commit.shortHash })
366
- );
367
- return commit;
423
+ // =============================================================================
424
+ // Diff syntax highlighting via per-line bg overlays
425
+ //
426
+ // Sublime-syntax's bundled `Diff` definition only scopes the `diff`
427
+ // keyword, so themes only colour that. Plugins are responsible for the
428
+ // rest — same approach `live_diff` uses for inline diff coloring in
429
+ // regular buffers.
430
+ //
431
+ // One overlay per line of added/removed content is fine for the
432
+ // "normal commit" workload but explodes on giant commits (the
433
+ // rewrite-bun commit is 1M lines = 1M overlays = back to the old
434
+ // 500k-overlay problem this rewire eliminated). Gate on buffer size;
435
+ // gracefully degrade to no highlighting for outliers.
436
+ // =============================================================================
437
+
438
+ const HIGHLIGHT_BG_ADDED = "editor.diff_add_bg";
439
+ const HIGHLIGHT_BG_REMOVED = "editor.diff_remove_bg";
440
+ const HIGHLIGHT_BG_HUNK = "editor.diff_modify_bg";
441
+ const HIGHLIGHT_NAMESPACE = "git-log-diff";
442
+ /** Skip overlay highlighting above this size. ~256 KB covers
443
+ * basically every hand-written commit comfortably; very large
444
+ * generated-file diffs (lockfiles, minified code) just stay
445
+ * uncoloured — the cost would be a few thousand-to-a-million
446
+ * overlays for content the user mostly skims. */
447
+ const HIGHLIGHT_MAX_BYTES = 256 * 1024;
448
+
449
+ async function applyDiffHighlights(bufferId: number): Promise<void> {
450
+ const total = editor.getBufferLength(bufferId);
451
+ if (total === 0 || total > HIGHLIGHT_MAX_BYTES) return;
452
+ const text = await editor.getBufferText(bufferId, 0, total);
453
+ if (!text) return;
454
+
455
+ // Walk lines tracking byte offsets; coalesce consecutive same-kind
456
+ // rows into single ranges so a 30-line added block costs one
457
+ // overlay, not 30.
458
+ let byte = 0;
459
+ let runKind: "+" | "-" | "@" | null = null;
460
+ let runStart = 0;
461
+ let runEnd = 0;
462
+
463
+ const flushRun = () => {
464
+ if (runKind === null) return;
465
+ const bg =
466
+ runKind === "+"
467
+ ? HIGHLIGHT_BG_ADDED
468
+ : runKind === "-"
469
+ ? HIGHLIGHT_BG_REMOVED
470
+ : HIGHLIGHT_BG_HUNK;
471
+ editor.addOverlay(bufferId, HIGHLIGHT_NAMESPACE, runStart, runEnd, {
472
+ bg,
473
+ extendToLineEnd: true,
474
+ });
475
+ runKind = null;
476
+ };
477
+
478
+ for (const line of text.split("\n")) {
479
+ const lineLen = line.length;
480
+ const ch = line.charAt(0);
481
+ let kind: "+" | "-" | "@" | null = null;
482
+ if (ch === "+" && !line.startsWith("+++")) kind = "+";
483
+ else if (ch === "-" && !line.startsWith("---")) kind = "-";
484
+ else if (line.startsWith("@@")) kind = "@";
485
+
486
+ if (kind !== runKind) {
487
+ flushRun();
488
+ if (kind !== null) {
489
+ runStart = byte;
490
+ runKind = kind;
491
+ }
492
+ }
493
+ if (kind !== null) {
494
+ // Include the trailing newline in the range so the bg colour
495
+ // fills the row even on empty lines that wrap-extend.
496
+ runEnd = byte + lineLen + 1;
497
+ }
498
+ byte += lineLen + 1;
499
+ }
500
+ flushRun();
368
501
  }
369
502
 
370
503
  /**
371
- * Spawn `git show` for `commit` and render the result. Tagged with
372
- * `pendingDetailId` so a newer selection supersedes in-flight fetches.
504
+ * Get (or create) the file-backed buffer that displays `commit`.
505
+ * On first call for a hash: ensure cache file exists, kick off
506
+ * `git show` if it doesn't, openFileStreaming, start the poll loop.
507
+ * Returns the buffer id on success or null on failure.
373
508
  */
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);
509
+ async function ensureCommitBuffer(commit: GitCommit, cwd: string): Promise<number | null> {
510
+ const hash = commit.hash;
511
+ const existing = state.commitBuffers.get(hash);
512
+ if (existing !== undefined) return existing;
513
+
514
+ const path = cachePathForHash(hash);
515
+ const cacheHit = editor.fileExists(path);
516
+
517
+ if (!cacheHit) {
518
+ // Cache miss: spawn git, polling the file as it grows. The handle
519
+ // is stashed so a fast-scrolling user can supersede us via kill().
520
+ const handle = spawnGitShow(hash, cwd);
521
+ state.inFlightSpawns.set(hash, handle);
522
+ const bufferId = await editor.openFileStreaming(path);
523
+ if (bufferId === null) {
524
+ handle.kill?.();
525
+ state.inFlightSpawns.delete(hash);
526
+ return null;
527
+ }
528
+ state.commitBuffers.set(hash, bufferId);
529
+ // Fire-and-forget polling task.
530
+ void pollUntilSpawnDone(hash, bufferId, handle);
531
+ return bufferId;
532
+ }
533
+
534
+ // Cache hit: just open the existing file. No git spawned.
535
+ const bufferId = await editor.openFileStreaming(path);
536
+ if (bufferId === null) return null;
537
+ state.commitBuffers.set(hash, bufferId);
538
+ return bufferId;
388
539
  }
389
540
 
390
541
  /**
391
- * Combined synchronous + asynchronous refresh used by open/refresh paths
392
- * where there's no burst of events to collapse.
542
+ * Show `commit` in the detail panel. Cancels any superseded in-flight
543
+ * spawn for *other* commits (the user has navigated past them) and
544
+ * retargets the panel at the chosen commit's buffer.
393
545
  */
546
+ async function showCommitInDetail(commit: GitCommit, cwd: string): Promise<void> {
547
+ // Cancel anything still streaming for a commit that isn't this one —
548
+ // the user has moved on; no point keeping git running.
549
+ for (const [hash, handle] of state.inFlightSpawns) {
550
+ if (hash !== commit.hash) {
551
+ handle.kill?.();
552
+ state.inFlightSpawns.delete(hash);
553
+ }
554
+ }
555
+
556
+ const bufferId = await ensureCommitBuffer(commit, cwd);
557
+ if (bufferId === null) {
558
+ editor.setStatus(
559
+ editor.t("status.failed_open_file", { file: commit.shortHash }),
560
+ );
561
+ return;
562
+ }
563
+ if (state.groupId === null) return;
564
+ await editor.setBufferGroupPanelBuffer(state.groupId, "detail", bufferId);
565
+ state.detailBufferId = bufferId;
566
+ // Each commit buffer needs the same per-buffer presentation as the
567
+ // initial virtual one: visible cursor for diff-line navigation,
568
+ // wrap on (long minified lines unreadable in the 40% panel).
569
+ editor.setBufferShowCursors(bufferId, true);
570
+ editor.setLineWrap(bufferId, null, true);
571
+ // Land at the top of the diff every time we (re-)visit a commit.
572
+ editor.setBufferCursor(bufferId, 0);
573
+ }
574
+
394
575
  async function refreshDetail(): Promise<void> {
395
- const pending = refreshDetailImmediate();
396
- if (pending) await fetchAndRenderDetail(pending);
576
+ if (state.groupId === null) return;
577
+ if (state.commits.length === 0) return;
578
+ const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1));
579
+ const commit = state.commits[idx];
580
+ if (!commit) return;
581
+ await showCommitInDetail(commit, editor.getCwd());
397
582
  }
398
583
 
399
584
  // =============================================================================
@@ -438,7 +623,11 @@ async function show_git_log(): Promise<void> {
438
623
  );
439
624
  state.groupId = group.groupId as number;
440
625
  state.logBufferId = (group.panels["log"] as number | undefined) ?? null;
441
- state.detailBufferId = (group.panels["detail"] as number | undefined) ?? null;
626
+ state.initialDetailBufferId =
627
+ (group.panels["detail"] as number | undefined) ?? null;
628
+ // detailBufferId starts as the initial virtual buffer; it gets
629
+ // retargeted to a file-backed buffer on first commit selection.
630
+ state.detailBufferId = state.initialDetailBufferId;
442
631
  state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null;
443
632
  if (state.toolbarBufferId !== null) {
444
633
  state.toolbarPanel = new WidgetPanel(state.toolbarBufferId);
@@ -447,21 +636,18 @@ async function show_git_log(): Promise<void> {
447
636
  state.logPanel = new WidgetPanel(state.logBufferId);
448
637
  }
449
638
  state.selectedIndex = 0;
450
- state.detailCache = null;
639
+ state.commitBuffers = new Map();
640
+ state.inFlightSpawns = new Map();
451
641
  state.isOpen = true;
452
642
 
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.
643
+ // The detail panel owns a native cursor so diff lines can be
644
+ // clicked / traversed before pressing Enter to open a file. We set
645
+ // the cursor on each retargeted buffer as it gets swapped in, but
646
+ // wrap-default needs setting too long minified lines in lock-file
647
+ // diffs are unreadable without wrap in the 40% panel.
648
+ if (state.initialDetailBufferId !== null) {
649
+ editor.setBufferShowCursors(state.initialDetailBufferId, true);
650
+ editor.setLineWrap(state.initialDetailBufferId, null, true);
465
651
  }
466
652
 
467
653
  renderToolbar();
@@ -471,9 +657,6 @@ async function show_git_log(): Promise<void> {
471
657
  // the selected row stays visible).
472
658
  await refreshDetail();
473
659
 
474
- if (state.groupId !== null) {
475
- editor.focusBufferGroupPanel(state.groupId, "log");
476
- }
477
660
  editor.on("resize", on_git_log_resize);
478
661
  editor.on("buffer_closed", on_git_log_buffer_closed);
479
662
 
@@ -490,20 +673,36 @@ function git_log_cleanup(): void {
490
673
  if (!state.isOpen) return;
491
674
  editor.off("resize", on_git_log_resize);
492
675
  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.
676
+ // Kill any still-running `git show` spawns we no longer care.
677
+ for (const [, handle] of state.inFlightSpawns) {
678
+ handle.kill?.();
679
+ }
680
+ state.inFlightSpawns.clear();
681
+ // Close each per-commit buffer we created. The buffer-group's own
682
+ // `close` (called below in `git_log_close`) tears down the panel
683
+ // buffers (toolbar/log/initialDetail) — but retargeted file-backed
684
+ // buffers we allocated via openFileStreaming are *outside* the
685
+ // group's panel_buffers map by the time we got here, so we must
686
+ // close them explicitly to avoid leaks.
687
+ for (const [, bufferId] of state.commitBuffers) {
688
+ editor.closeBuffer(bufferId);
689
+ }
690
+ state.commitBuffers.clear();
691
+ // The buffer-group's `close` will tear down its own panel buffers
692
+ // (toolbar/log/initialDetail) too, which implicitly drops the widget
693
+ // panels rendering into them. We still null out the handles so any
694
+ // stray `renderToolbar()` / `renderLog()` call post-cleanup is a
695
+ // no-op.
497
696
  state.toolbarPanel = null;
498
697
  state.logPanel = null;
499
698
  state.isOpen = false;
500
699
  state.groupId = null;
501
700
  state.logBufferId = null;
701
+ state.initialDetailBufferId = null;
502
702
  state.detailBufferId = null;
503
703
  state.toolbarBufferId = null;
504
704
  state.commits = [];
505
705
  state.selectedIndex = 0;
506
- state.detailCache = null;
507
706
  }
508
707
 
509
708
  function git_log_close(): void {
@@ -519,12 +718,26 @@ registerHandler("git_log_close", git_log_close);
519
718
 
520
719
  function on_git_log_buffer_closed(data: { buffer_id: number }): void {
521
720
  if (!state.isOpen) return;
721
+ // Tear down the whole group only when the *group's* buffers close
722
+ // (toolbar / log / the initial virtual detail). A retargeted
723
+ // file-backed commit buffer closing is normal — drop it from our
724
+ // cache but keep the group alive.
522
725
  if (
523
726
  data.buffer_id === state.logBufferId ||
524
- data.buffer_id === state.detailBufferId ||
727
+ data.buffer_id === state.initialDetailBufferId ||
525
728
  data.buffer_id === state.toolbarBufferId
526
729
  ) {
527
730
  git_log_cleanup();
731
+ return;
732
+ }
733
+ // Removed from cache so a revisit re-spawns / re-opens.
734
+ for (const [hash, bufId] of state.commitBuffers) {
735
+ if (bufId === data.buffer_id) {
736
+ state.commitBuffers.delete(hash);
737
+ state.inFlightSpawns.get(hash)?.kill?.();
738
+ state.inFlightSpawns.delete(hash);
739
+ break;
740
+ }
528
741
  }
529
742
  }
530
743
  registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed);
@@ -533,7 +746,13 @@ async function git_log_refresh(): Promise<void> {
533
746
  if (!state.isOpen) return;
534
747
  editor.setStatus(editor.t("status.refreshing"));
535
748
  state.commits = await fetchGitLog(editor);
536
- state.detailCache = null;
749
+ // The on-disk cache files are keyed by SHA and commits are
750
+ // immutable, so they remain valid — but our in-memory buffer ids
751
+ // for commits no longer in the visible list are stale; clear them.
752
+ for (const [, handle] of state.inFlightSpawns) handle.kill?.();
753
+ state.inFlightSpawns.clear();
754
+ for (const [, bufferId] of state.commitBuffers) editor.closeBuffer(bufferId);
755
+ state.commitBuffers.clear();
537
756
  if (state.selectedIndex >= state.commits.length) {
538
757
  state.selectedIndex = Math.max(0, state.commits.length - 1);
539
758
  }
@@ -604,26 +823,222 @@ function git_log_q(): void {
604
823
  }
605
824
  registerHandler("git_log_q", git_log_q);
606
825
 
826
+ // =============================================================================
827
+ // Folding by file and hunk
828
+ //
829
+ // Publishes structural fold ranges into the buffer's `folding_ranges`
830
+ // via `setFoldingRanges` — the same channel an LSP `foldingRange`
831
+ // response uses. Nothing is pre-collapsed; the user toggles a range
832
+ // with the standard fold keybinding (`za` etc.), which finds the
833
+ // matching range under the cursor.
834
+ //
835
+ // The diff structure gives us two natural fold levels:
836
+ // * per-file: each `diff --git a/X b/Y` section
837
+ // * per-hunk: each `@@ -A,B +C,D @@` block within a file
838
+ // We publish both; the toggle-fold key picks the innermost containing
839
+ // range at the cursor's line.
840
+ //
841
+ // Computed once after `pollUntilSpawnDone` settles — re-running on
842
+ // every refresh would churn the marker list for no benefit (the diff
843
+ // structure is monotonic-append until exit).
844
+ // =============================================================================
845
+
846
+ interface DiffFoldRange {
847
+ startLine: number;
848
+ endLine: number;
849
+ }
850
+
851
+ /** Walk the buffer text and return (file-level, hunk-level) fold
852
+ * ranges. Lines are 0-indexed, both endpoints inclusive — the LSP
853
+ * shape. The "fold header" is the line at `startLine`; everything up
854
+ * through `endLine` collapses under it. */
855
+ function computeDiffFoldRanges(text: string): {
856
+ files: DiffFoldRange[];
857
+ hunks: DiffFoldRange[];
858
+ } {
859
+ const lines = text.split("\n");
860
+ const files: DiffFoldRange[] = [];
861
+ const hunks: DiffFoldRange[] = [];
862
+
863
+ let fileStart: number | null = null;
864
+ let hunkStart: number | null = null;
865
+
866
+ const closeHunk = (endLine: number) => {
867
+ if (hunkStart !== null && endLine > hunkStart) {
868
+ hunks.push({ startLine: hunkStart, endLine });
869
+ }
870
+ hunkStart = null;
871
+ };
872
+ const closeFile = (endLine: number) => {
873
+ closeHunk(endLine);
874
+ if (fileStart !== null && endLine > fileStart) {
875
+ files.push({ startLine: fileStart, endLine });
876
+ }
877
+ fileStart = null;
878
+ };
879
+
880
+ for (let i = 0; i < lines.length; i++) {
881
+ const l = lines[i];
882
+ if (l.startsWith("diff --git ")) {
883
+ closeFile(i - 1);
884
+ fileStart = i;
885
+ } else if (l.startsWith("@@ ")) {
886
+ closeHunk(i - 1);
887
+ hunkStart = i;
888
+ }
889
+ }
890
+ closeFile(lines.length - 1);
891
+ return { files, hunks };
892
+ }
893
+
894
+ async function publishDiffFoldRanges(bufferId: number): Promise<void> {
895
+ const total = editor.getBufferLength(bufferId);
896
+ if (total === 0) return;
897
+ const text = await editor.getBufferText(bufferId, 0, total);
898
+ if (!text) return;
899
+
900
+ const { files, hunks } = computeDiffFoldRanges(text);
901
+
902
+ // Merge — the host accepts a single array. "region" kind tags both
903
+ // levels generically; the LSP spec also defines comment/imports
904
+ // kinds which don't apply to diffs.
905
+ const ranges = [...files, ...hunks].map((r) => ({
906
+ startLine: r.startLine,
907
+ endLine: r.endLine,
908
+ kind: "region",
909
+ }));
910
+ editor.setFoldingRanges(bufferId, ranges);
911
+ }
912
+
607
913
  // =============================================================================
608
914
  // Detail panel — open file at commit
609
915
  // =============================================================================
610
916
 
917
+ /**
918
+ * Walk through the streaming diff buffer to find the file + line
919
+ * context near the cursor. Diff format:
920
+ *
921
+ * diff --git a/<path> b/<path>
922
+ * index ...
923
+ * --- a/<path> (or /dev/null for additions)
924
+ * +++ b/<path> (or /dev/null for deletions)
925
+ * @@ -old,n +new,m @@
926
+ * <context|+|- lines>
927
+ *
928
+ * Strategy:
929
+ * - Read up to the END of the cursor's line, not just up to the
930
+ * cursor's byte offset. This way a cursor sitting on a header line
931
+ * (`diff --git`, `+++ b/...`, `@@ ...`) still gets that line
932
+ * matched, matching the old text-property behaviour.
933
+ * - Walk backwards for the per-file header. Match either:
934
+ * `+++ b/<path>` (preferred — names the new-side path)
935
+ * `diff --git a/<src> b/<dst>` (fallback — covers the case where
936
+ * the cursor is on the `diff --git` line itself, before the
937
+ * `+++` line has appeared in the search range)
938
+ * - Walk backwards for the most recent `@@ -... +<new>,<count> @@`
939
+ * between the header and cursor, then count context/'+' rows
940
+ * forward to the cursor to derive the new-side line number.
941
+ */
942
+ async function deriveFileAndLineFromDiffCursor(
943
+ bufferId: number,
944
+ ): Promise<{ file: string; line: number } | null> {
945
+ const cursor = editor.getCursorPosition();
946
+ if (cursor < 0) return null;
947
+
948
+ const bufLen = editor.getBufferLength(bufferId);
949
+ const readEnd = Math.min(bufLen, cursor + 4096);
950
+ if (readEnd === 0) return null;
951
+ const text = await editor.getBufferText(bufferId, 0, readEnd);
952
+ if (!text) return null;
953
+ const lines = text.split("\n");
954
+
955
+ // Locate the cursor's line index by walking byte offsets. `lines[i]`
956
+ // covers bytes [byte, byte+len]; the `\n` separator lives at
957
+ // byte+len, so the next line starts at byte+len+1.
958
+ let byte = 0;
959
+ let cursorLineIdx = lines.length - 1;
960
+ for (let i = 0; i < lines.length; i++) {
961
+ const lineLen = lines[i].length;
962
+ if (cursor <= byte + lineLen) {
963
+ cursorLineIdx = i;
964
+ break;
965
+ }
966
+ byte += lineLen + 1;
967
+ }
968
+
969
+ // Walk back from the cursor's line for the per-file header. Match
970
+ // either `+++ b/<path>` or `diff --git a/<src> b/<dst>` so cursor-
971
+ // on-header cases work.
972
+ let file: string | null = null;
973
+ let headerIdx = -1;
974
+ for (let i = cursorLineIdx; i >= 0; i--) {
975
+ const l = lines[i];
976
+ if (l.startsWith("+++ b/")) {
977
+ file = l.slice(6).trim();
978
+ headerIdx = i;
979
+ break;
980
+ }
981
+ if (l.startsWith("+++ /dev/null")) {
982
+ // Deletion — no new-side path. Opening the pre-image is a
983
+ // separate flow.
984
+ return null;
985
+ }
986
+ const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(l);
987
+ if (m) {
988
+ const aSide = m[1];
989
+ const bSide = m[2];
990
+ file = bSide === "/dev/null" ? aSide : bSide;
991
+ headerIdx = i;
992
+ break;
993
+ }
994
+ }
995
+ if (file === null || headerIdx < 0) return null;
996
+
997
+ // Find the most recent `@@ ... +start,count @@` between header and
998
+ // cursor. Default: line 1 (cursor sits on the header itself, or
999
+ // between the header and the first hunk).
1000
+ let line = 1;
1001
+ for (let i = cursorLineIdx; i > headerIdx; i--) {
1002
+ const l = lines[i];
1003
+ const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
1004
+ if (!m) continue;
1005
+ const newStart = parseInt(m[1], 10);
1006
+ if (!Number.isFinite(newStart)) return null;
1007
+ // Walk forward from the hunk header to the cursor's line,
1008
+ // advancing the new-file line counter for context (' ') and
1009
+ // addition ('+') rows; skip deletion ('-') rows since they don't
1010
+ // exist in the new file.
1011
+ let cur = newStart;
1012
+ for (let j = i + 1; j <= cursorLineIdx; j++) {
1013
+ if (j === cursorLineIdx) {
1014
+ line = cur;
1015
+ break;
1016
+ }
1017
+ const ch = lines[j].charAt(0);
1018
+ if (ch === "+" || ch === " " || ch === "") cur += 1;
1019
+ // '-' / '\' (no-newline marker): don't advance.
1020
+ }
1021
+ break;
1022
+ }
1023
+ return { file, line };
1024
+ }
1025
+
611
1026
  async function git_log_detail_open_file(): Promise<void> {
612
1027
  if (state.detailBufferId === null) return;
613
1028
  const commit = selectedCommit();
614
1029
  if (!commit) return;
615
1030
 
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) {
1031
+ // The detail buffer is a plain file-backed view of `git show --patch`,
1032
+ // so we don't have plugin-attached `file`/`line` properties anymore.
1033
+ // Parse the diff backwards from the cursor to find the nearest
1034
+ // `+++ b/<path>` header (a per-file diff section opener) and the
1035
+ // most recent hunk header to derive a line number.
1036
+ const ctx = await deriveFileAndLineFromDiffCursor(state.detailBufferId);
1037
+ if (!ctx) {
624
1038
  editor.setStatus(editor.t("status.move_to_diff_with_context"));
625
1039
  return;
626
1040
  }
1041
+ const { file, line } = ctx;
627
1042
 
628
1043
  editor.setStatus(
629
1044
  editor.t("status.file_loading", { file, hash: commit.shortHash })
@@ -708,11 +1123,6 @@ async function on_log_select(idx: number): Promise<void> {
708
1123
  if (idx === state.selectedIndex) return;
709
1124
  state.selectedIndex = idx;
710
1125
 
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();
715
-
716
1126
  const commit = state.commits[state.selectedIndex];
717
1127
  if (commit) {
718
1128
  editor.setStatus(
@@ -723,16 +1133,17 @@ async function on_log_select(idx: number): Promise<void> {
723
1133
  );
724
1134
  }
725
1135
 
726
- if (!pending) return;
727
-
728
1136
  // 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.
1137
+ // arrived. Even though re-pointing the panel at a cached buffer is
1138
+ // ~free, kicking off a new `git show --patch` for every intermediate
1139
+ // row in a held-j burst is wasteful. Collapse rapid selection moves.
731
1140
  const myId = ++state.pendingSelectId;
732
1141
  await editor.delay(SELECT_DEBOUNCE_MS);
733
1142
  if (myId !== state.pendingSelectId) return;
734
1143
  if (!state.isOpen) return;
735
- await fetchAndRenderDetail(pending);
1144
+ const current = state.commits[state.selectedIndex];
1145
+ if (!current) return;
1146
+ await showCommitInDetail(current, editor.getCwd());
736
1147
  }
737
1148
 
738
1149
  // =============================================================================
@@ -757,5 +1168,4 @@ editor.registerCommand(
757
1168
  "git_log_refresh",
758
1169
  null
759
1170
  );
760
-
761
1171
  editor.debug("Git Log plugin initialized (modern buffer-group layout)");