@fresh-editor/fresh-editor 0.3.5 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +9 -2
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.i18n.json +84 -0
  5. package/plugins/audit_mode.ts +139 -3
  6. package/plugins/config-schema.json +33 -3
  7. package/plugins/dashboard.ts +34 -111
  8. package/plugins/flash.ts +22 -4
  9. package/plugins/git_blame.ts +10 -6
  10. package/plugins/git_log.ts +705 -323
  11. package/plugins/git_statusbar.i18n.json +72 -0
  12. package/plugins/git_statusbar.ts +133 -0
  13. package/plugins/goto_with_selection.i18n.json +58 -0
  14. package/plugins/goto_with_selection.ts +17 -0
  15. package/plugins/lib/fresh.d.ts +911 -15
  16. package/plugins/lib/index.ts +34 -0
  17. package/plugins/lib/widgets.ts +903 -0
  18. package/plugins/live_diff.ts +442 -32
  19. package/plugins/merge_conflict.ts +89 -64
  20. package/plugins/orchestrator.ts +3425 -0
  21. package/plugins/pkg.ts +235 -54
  22. package/plugins/rust-lsp.ts +58 -40
  23. package/plugins/schemas/theme.schema.json +18 -0
  24. package/plugins/search_replace.i18n.json +140 -28
  25. package/plugins/search_replace.ts +1335 -515
  26. package/plugins/tab_actions.i18n.json +212 -0
  27. package/plugins/tab_actions.ts +76 -0
  28. package/plugins/theme_editor.i18n.json +112 -0
  29. package/plugins/theme_editor.ts +30 -5
  30. package/plugins/tsconfig.json +3 -0
  31. package/plugins/vi_mode.ts +49 -17
  32. package/themes/dark.json +1 -0
  33. package/themes/dracula.json +1 -0
  34. package/themes/high-contrast.json +1 -0
  35. package/themes/light.json +1 -0
  36. package/themes/nord.json +1 -0
  37. package/themes/nostalgia.json +1 -0
  38. package/themes/solarized-dark.json +1 -0
  39. package/themes/terminal.json +4 -0
@@ -2,12 +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";
8
+ import { button, flexSpacer, key, list, row, WidgetPanel } from "./lib/index.ts";
11
9
 
12
10
  const editor = getEditor();
13
11
 
@@ -35,90 +33,68 @@ interface GitLogState {
35
33
  isOpen: boolean;
36
34
  groupId: number | null;
37
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
+ */
38
49
  detailBufferId: number | null;
39
50
  toolbarBufferId: number | null;
40
- /** Click-regions for the toolbar's buttons, populated by `renderToolbar`. */
41
- toolbarButtons: ToolbarButton[];
51
+ /** Widget panel rendering the toolbar (Row of Buttons). */
52
+ toolbarPanel: WidgetPanel | null;
53
+ /** Widget panel rendering the log (List of commit rows). */
54
+ logPanel: WidgetPanel | null;
42
55
  commits: GitCommit[];
43
56
  selectedIndex: number;
44
- /** Cached `git show` output for the currently-displayed detail commit. */
45
- detailCache: { hash: string; output: string } | null;
46
- /**
47
- * In-flight detail request id. Used to ignore stale responses when the
48
- * user scrolls through the log faster than `git show` can return.
49
- */
50
- pendingDetailId: number;
51
57
  /**
52
- * Debounce token for `cursor_moved`. Rapid cursor motion (PageDown, held
53
- * j/k) would otherwise trigger a full log re-render + `git show` per
54
- * intermediate row; we bump this id on every event and only do the work
55
- * after a short delay if no newer event has arrived.
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.
56
63
  */
57
- pendingCursorMoveId: number;
64
+ commitBuffers: Map<string, number>;
65
+ /** sha → in-flight spawnProcess handle, for kill-on-supersession. */
66
+ inFlightSpawns: Map<string, ProcessHandle<SpawnResult>>;
58
67
  /**
59
- * Byte offset at the start of each row in the rendered log panel, plus
60
- * the total buffer length at the end. Populated by `renderLog` so the
61
- * cursor_moved handler can map byte positions to commit indices without
62
- * relying on `getCursorLine` (which is not implemented for virtual
63
- * buffers).
68
+ * Debounce token for List `select` events. Rapid selection moves
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.
64
72
  */
65
- logRowByteOffsets: number[];
73
+ pendingSelectId: number;
66
74
  }
67
75
 
68
76
  const state: GitLogState = {
69
77
  isOpen: false,
70
78
  groupId: null,
71
79
  logBufferId: null,
80
+ initialDetailBufferId: null,
72
81
  detailBufferId: null,
73
82
  toolbarBufferId: null,
74
- toolbarButtons: [],
83
+ toolbarPanel: null,
84
+ logPanel: null,
75
85
  commits: [],
76
86
  selectedIndex: 0,
77
- detailCache: null,
78
- pendingDetailId: 0,
79
- pendingCursorMoveId: 0,
80
- logRowByteOffsets: [],
87
+ commitBuffers: new Map(),
88
+ inFlightSpawns: new Map(),
89
+ pendingSelectId: 0,
81
90
  };
82
91
 
83
92
  /**
84
- * Delay before reacting to `cursor_moved`. Long enough to collapse a burst
85
- * of events from held j/k or PageDown into a single render, short enough
86
- * that the detail panel still feels live.
87
- */
88
- const CURSOR_DEBOUNCE_MS = 60;
89
-
90
- // UTF-8 byte length — the overlay API expects byte offsets; JS strings are
91
- // UTF-16. Matches the helper used by `lib/git_history.ts`.
92
- function utf8Len(s: string): number {
93
- let b = 0;
94
- for (let i = 0; i < s.length; i++) {
95
- const c = s.charCodeAt(i);
96
- if (c <= 0x7f) b += 1;
97
- else if (c <= 0x7ff) b += 2;
98
- else if (c >= 0xd800 && c <= 0xdfff) {
99
- b += 4;
100
- i++;
101
- } else b += 3;
102
- }
103
- return b;
104
- }
105
-
106
- /**
107
- * Binary search `logRowByteOffsets` for the 0-indexed row whose byte
108
- * offset is the largest one ≤ `bytePos`. Returns 0 on an empty table.
93
+ * Delay before spawning `git show` after a List `select` event. Long
94
+ * enough to collapse a burst (held j/k or PageDown) into one fetch,
95
+ * short enough that the detail panel still feels live.
109
96
  */
110
- function rowFromByte(bytePos: number): number {
111
- const offs = state.logRowByteOffsets;
112
- if (offs.length === 0) return 0;
113
- let lo = 0;
114
- let hi = offs.length - 1;
115
- while (lo < hi) {
116
- const mid = (lo + hi + 1) >> 1;
117
- if (offs[mid] <= bytePos) lo = mid;
118
- else hi = mid - 1;
119
- }
120
- return lo;
121
- }
97
+ const SELECT_DEBOUNCE_MS = 60;
122
98
 
123
99
  // =============================================================================
124
100
  // Modes
@@ -129,15 +105,20 @@ function rowFromByte(bytePos: number): number {
129
105
  // the log, and opens the file at the cursor when pressed in the detail).
130
106
  // =============================================================================
131
107
 
132
- // j/k as vi-style aliases for Up/Down, plus the plugin-specific action
133
- // keys. Everything else (arrows, Page{Up,Down}, Home/End, Shift+motion for
134
- // selection, Ctrl+C copy, …) is inherited from the Normal keymap because
135
- // the mode is registered with `inheritNormalBindings: true`.
108
+ // j/k/Up/Down/PageUp/PageDown route to the log List widget so the host
109
+ // owns selection + scroll + auto-scroll. The List's `select` event then
110
+ // fires back into the plugin's `widget_event` handler for detail-pane
111
+ // refresh. Other plugin actions (q/r/y/Tab/Return) stay as direct
112
+ // bindings — they don't depend on which row is highlighted.
136
113
  editor.defineMode(
137
114
  "git-log",
138
115
  [
139
- ["k", "move_up"],
140
- ["j", "move_down"],
116
+ ["k", "git_log_select_up"],
117
+ ["j", "git_log_select_down"],
118
+ ["Up", "git_log_select_up"],
119
+ ["Down", "git_log_select_down"],
120
+ ["PageUp", "git_log_select_page_up"],
121
+ ["PageDown", "git_log_select_page_down"],
141
122
  ["Return", "git_log_enter"],
142
123
  ["Tab", "git_log_tab"],
143
124
  ["q", "git_log_q"],
@@ -149,6 +130,52 @@ editor.defineMode(
149
130
  true, // inherit Normal-context bindings for unbound keys
150
131
  );
151
132
 
133
+ function git_log_select_up(): void {
134
+ if (isLogPanelActive()) {
135
+ state.logPanel?.command(key("Up"));
136
+ } else {
137
+ editor.executeAction("move_up");
138
+ }
139
+ }
140
+ function git_log_select_down(): void {
141
+ if (isLogPanelActive()) {
142
+ state.logPanel?.command(key("Down"));
143
+ } else {
144
+ editor.executeAction("move_down");
145
+ }
146
+ }
147
+ function git_log_select_page_up(): void {
148
+ if (isLogPanelActive()) {
149
+ state.logPanel?.command(key("PageUp"));
150
+ } else {
151
+ editor.executeAction("move_page_up");
152
+ }
153
+ }
154
+ function git_log_select_page_down(): void {
155
+ if (isLogPanelActive()) {
156
+ state.logPanel?.command(key("PageDown"));
157
+ } else {
158
+ editor.executeAction("move_page_down");
159
+ }
160
+ }
161
+
162
+ /** True iff the log panel is the focused buffer in the group. The
163
+ * group's bindings (j/k/Up/Down/PageUp/PageDown) apply to all panels
164
+ * uniformly; we only want navigation to drive the List widget when
165
+ * the user is *on* the log panel. From the detail panel, the same
166
+ * keys must move the buffer cursor (so users can scroll the diff
167
+ * before pressing Enter on a diff line to open the file view). */
168
+ function isLogPanelActive(): boolean {
169
+ return (
170
+ state.logBufferId !== null &&
171
+ editor.getActiveBufferId() === state.logBufferId
172
+ );
173
+ }
174
+ registerHandler("git_log_select_up", git_log_select_up);
175
+ registerHandler("git_log_select_down", git_log_select_down);
176
+ registerHandler("git_log_select_page_up", git_log_select_page_up);
177
+ registerHandler("git_log_select_page_down", git_log_select_page_down);
178
+
152
179
  // =============================================================================
153
180
  // Panel layout
154
181
  // =============================================================================
@@ -177,116 +204,87 @@ const GROUP_LAYOUT = JSON.stringify({
177
204
  // =============================================================================
178
205
  // Toolbar
179
206
  // =============================================================================
207
+ //
208
+ // The toolbar is a one-row panel mounted above the log/detail split. It's
209
+ // rendered through the widget runtime — a `Row` of `Button` widgets — so
210
+ // the host owns hit-testing, focus styling, and keystroke dispatch, and the
211
+ // plugin only handles the resulting `widget_event` actions.
212
+ //
213
+ // Each button's `key` is a stable identifier (`toolbar.tab`, `toolbar.q`,
214
+ // etc.) that `widget_event` carries back so the plugin can look up the
215
+ // right handler without per-row column arithmetic. The previous custom
216
+ // hit-region tracking (`state.toolbarButtons`, `on_git_log_toolbar_click`)
217
+ // is gone.
180
218
 
181
- interface ToolbarHint {
219
+ interface ToolbarItem {
182
220
  key: string;
183
221
  label: string;
184
- /** Click action `null` for hints that are keyboard-only (j/k, PgUp). */
185
- onClick: (() => void | Promise<void>) | null;
222
+ onClick: () => void | Promise<void>;
186
223
  }
187
224
 
188
- interface ToolbarButton {
189
- row: number;
190
- startCol: number;
191
- endCol: number;
192
- onClick: (() => void | Promise<void>) | null;
193
- }
225
+ const TOOLBAR_KEY_PREFIX = "toolbar.";
194
226
 
195
- function toolbarHints(): ToolbarHint[] {
227
+ function toolbarItems(): ToolbarItem[] {
196
228
  return [
197
- { key: "Tab", label: "switch pane", onClick: git_log_tab },
198
- { key: "RET", label: "open file", onClick: git_log_enter },
199
- { key: "y", label: "copy hash", onClick: git_log_copy_hash },
200
- { key: "r", label: "refresh", onClick: git_log_refresh },
201
- { key: "q", label: "quit", onClick: git_log_q },
229
+ { key: "tab", label: "Tab switch pane", onClick: git_log_tab },
230
+ { key: "ret", label: "RET open file", onClick: git_log_enter },
231
+ { key: "y", label: "y copy hash", onClick: git_log_copy_hash },
232
+ { key: "r", label: "r refresh", onClick: git_log_refresh },
233
+ { key: "q", label: "q quit", onClick: git_log_q },
202
234
  ];
203
235
  }
204
236
 
205
- /**
206
- * Build the single-row toolbar. Each hint renders as a discrete button with
207
- * its own background so it reads as clickable; the column range of each
208
- * button is captured in `state.toolbarButtons` so `on_git_log_toolbar_click`
209
- * can map a mouse click back to the right handler.
210
- */
211
- function buildToolbarEntries(width: number): TextPropertyEntry[] {
212
- const W = Math.max(20, width);
213
- const buttons: ToolbarButton[] = [];
214
- let text = "";
215
- const overlays: InlineOverlay[] = [];
216
-
217
- for (const hint of toolbarHints()) {
218
- const body = ` [${hint.key}] ${hint.label} `;
219
- const bodyLen = body.length;
220
- const gap = text.length > 0 ? 1 : 0;
221
- if (text.length + gap + bodyLen > W) break;
222
-
223
- if (gap) text += " ";
224
-
225
- const startCol = text.length;
226
- const startByte = utf8Len(text);
227
- text += body;
228
- const endByte = utf8Len(text);
229
- const endCol = text.length;
230
-
231
- overlays.push({
232
- start: startByte,
233
- end: endByte,
234
- style: { bg: "ui.status_bar_bg" },
235
- });
236
- const keyDisplay = `[${hint.key}]`;
237
- const keyStartByte = startByte + utf8Len(" ");
238
- const keyEndByte = keyStartByte + utf8Len(keyDisplay);
239
- overlays.push({
240
- start: keyStartByte,
241
- end: keyEndByte,
242
- style: { fg: "editor.fg", bold: true },
243
- });
244
- overlays.push({
245
- start: keyEndByte,
246
- end: endByte,
247
- style: { fg: "editor.line_number_fg" },
248
- });
249
-
250
- buttons.push({ row: 0, startCol, endCol, onClick: hint.onClick });
251
- }
252
-
253
- state.toolbarButtons = buttons;
254
-
255
- return [
256
- {
257
- text: text + "\n",
258
- properties: { type: "git-log-toolbar" },
259
- style: { bg: "editor.bg", extendToLineEnd: true },
260
- inlineOverlays: overlays,
261
- },
262
- ];
237
+ function toolbarSpec(): WidgetSpec {
238
+ const items = toolbarItems();
239
+ // `flexSpacer` at the end pushes the buttons to the left and lets the
240
+ // toolbar background extend across the row.
241
+ return row(
242
+ ...items.map((item) =>
243
+ button(item.label, { key: TOOLBAR_KEY_PREFIX + item.key }),
244
+ ),
245
+ flexSpacer(),
246
+ );
263
247
  }
264
248
 
265
249
  function renderToolbar(): void {
266
- if (state.groupId === null) return;
267
- const vp = editor.getViewport();
268
- const width = vp ? vp.width : 80;
269
- editor.setPanelContent(state.groupId, "toolbar", buildToolbarEntries(width));
250
+ if (state.toolbarPanel === null) return;
251
+ state.toolbarPanel.set(toolbarSpec());
270
252
  }
271
253
 
272
- function on_git_log_toolbar_click(data: {
273
- buffer_id: number | null;
274
- buffer_row: number | null;
275
- buffer_col: number | null;
276
- }): void {
277
- if (!state.isOpen) return;
278
- if (data.buffer_id === null || data.buffer_id !== state.toolbarBufferId) return;
279
- if (data.buffer_row === null || data.buffer_col === null) return;
280
- const row = data.buffer_row;
281
- const col = data.buffer_col;
282
- const hit = state.toolbarButtons.find(
283
- (b) => b.row === row && col >= b.startCol && col < b.endCol
284
- );
285
- if (hit && hit.onClick) {
286
- void hit.onClick();
254
+ editor.on("widget_event", (data) => {
255
+ // Toolbar (Row of Buttons) — `activate` from keypress or click on a
256
+ // button.
257
+ if (
258
+ state.toolbarPanel !== null &&
259
+ data.panel_id === state.toolbarPanel.id()
260
+ ) {
261
+ if (data.event_type !== "activate") return;
262
+ const items = toolbarItems();
263
+ for (const item of items) {
264
+ if (data.widget_key === TOOLBAR_KEY_PREFIX + item.key) {
265
+ void item.onClick();
266
+ return;
267
+ }
268
+ }
269
+ return;
287
270
  }
288
- }
289
- registerHandler("on_git_log_toolbar_click", on_git_log_toolbar_click);
271
+ // Log pane (List of commit rows) — `select` fires on j/k/Up/Down/
272
+ // PageUp/PageDown navigation and on row clicks; `activate` fires on
273
+ // Enter or double-click.
274
+ if (state.logPanel !== null && data.panel_id === state.logPanel.id()) {
275
+ if (data.event_type === "select") {
276
+ const idx =
277
+ typeof data.payload?.index === "number" ? data.payload.index : -1;
278
+ if (idx >= 0) void on_log_select(idx);
279
+ return;
280
+ }
281
+ if (data.event_type === "activate") {
282
+ void git_log_enter();
283
+ return;
284
+ }
285
+ return;
286
+ }
287
+ });
290
288
 
291
289
  function on_git_log_resize(_data: { width: number; height: number }): void {
292
290
  if (!state.isOpen) return;
@@ -303,105 +301,284 @@ function detailFooter(hash: string): string {
303
301
  }
304
302
 
305
303
  function renderLog(): void {
306
- if (state.groupId === null) return;
307
- // No header row and no footer: the sticky toolbar above the group
308
- // carries the shortcut hints, and the commit count goes to the status
309
- // line when the group opens.
310
- const entries = buildCommitLogEntries(state.commits, {
311
- selectedIndex: state.selectedIndex,
304
+ if (state.logPanel === null) return;
305
+ // List takes the per-row entries directly. selectedIndex: -1 on the
306
+ // entry builder suppresses the plugin's selection styling the host
307
+ // renders the focused-row highlight from the List widget's instance
308
+ // state instead.
309
+ const items = buildCommitLogEntries(state.commits, {
310
+ selectedIndex: -1,
312
311
  header: null,
313
312
  });
314
- // Rebuild the byte-offset table used by cursor_moved to map positions
315
- // to commit indices. `offsets[i]` is the byte offset of commit i; the
316
- // final entry is the total buffer length, so row lookups clamp
317
- // correctly on the last row.
318
- const offsets: number[] = [];
319
- let running = 0;
320
- for (const e of entries) {
321
- offsets.push(running);
322
- running += utf8Len(e.text);
323
- }
324
- offsets.push(running);
325
- state.logRowByteOffsets = offsets;
326
- editor.setPanelContent(state.groupId, "log", entries);
327
- }
328
-
329
- function renderDetailPlaceholder(message: string): void {
330
- if (state.groupId === null) return;
331
- editor.setPanelContent(
332
- state.groupId,
333
- "detail",
334
- buildDetailPlaceholderEntries(message)
313
+ const itemKeys = state.commits.map((c) => c.hash);
314
+ state.logPanel.set(
315
+ list({
316
+ items,
317
+ itemKeys,
318
+ selectedIndex: state.selectedIndex,
319
+ // Visible-rows only matters for virtualization; setting it to
320
+ // commits.length renders all rows and lets the buffer's natural
321
+ // scroll handle viewport. Revisit if commit lists grow into the
322
+ // tens of thousands.
323
+ visibleRows: Math.max(1, state.commits.length),
324
+ key: "git-log-list",
325
+ }),
335
326
  );
336
327
  }
337
328
 
338
- function renderDetailForCommit(commit: GitCommit, showOutput: string): void {
339
- if (state.groupId === null) return;
340
- const entries = buildCommitDetailEntries(commit, showOutput);
341
- editor.setPanelContent(state.groupId, "detail", entries);
342
- // Always scroll the detail panel back to the top when the selection changes.
343
- if (state.detailBufferId !== null) {
344
- editor.setBufferCursor(state.detailBufferId, 0);
345
- }
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
+ // =============================================================================
339
+
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`;
346
348
  }
347
349
 
350
+ /** Polling interval while git is still writing. ~5 fps is plenty. */
351
+ const STREAM_POLL_MS = 200;
352
+
348
353
  /**
349
- * Synchronous detail refresh: render from cache if we have it, otherwise
350
- * a "loading…" placeholder. Never spawns git. Called immediately on every
351
- * selection change so the user sees instant feedback even while the real
352
- * `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.
353
357
  *
354
- * Returns the commit that needs fetching (cache miss) or null (cache hit
355
- * 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).
356
360
  */
357
- function refreshDetailImmediate(): GitCommit | null {
358
- if (state.groupId === null) return null;
359
- if (state.commits.length === 0) {
360
- renderDetailPlaceholder(editor.t("status.no_commits"));
361
- return null;
362
- }
363
- const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1));
364
- const commit = state.commits[idx];
365
- 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
+ );
366
403
 
367
- if (state.detailCache && state.detailCache.hash === commit.hash) {
368
- renderDetailForCommit(commit, state.detailCache.output);
369
- 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);
370
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
+ }
371
422
 
372
- renderDetailPlaceholder(
373
- editor.t("status.loading_commit", { hash: commit.shortHash })
374
- );
375
- 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();
376
501
  }
377
502
 
378
503
  /**
379
- * Spawn `git show` for `commit` and render the result. Tagged with
380
- * `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.
381
508
  */
382
- async function fetchAndRenderDetail(commit: GitCommit): Promise<void> {
383
- const myId = ++state.pendingDetailId;
384
- const output = await fetchCommitShow(editor, commit.hash);
385
- if (myId !== state.pendingDetailId) return;
386
- if (state.groupId === null) return;
387
- state.detailCache = { hash: commit.hash, output };
388
- // Only render if the current selection is still this commit — a rapid
389
- // Up/Down burst might have moved on before we got here.
390
- const currentIdx = Math.max(
391
- 0,
392
- Math.min(state.selectedIndex, state.commits.length - 1)
393
- );
394
- if (state.commits[currentIdx]?.hash !== commit.hash) return;
395
- 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;
396
539
  }
397
540
 
398
541
  /**
399
- * Combined synchronous + asynchronous refresh used by open/refresh paths
400
- * 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.
401
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
+
402
575
  async function refreshDetail(): Promise<void> {
403
- const pending = refreshDetailImmediate();
404
- 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());
405
582
  }
406
583
 
407
584
  // =============================================================================
@@ -415,14 +592,6 @@ function selectedCommit(): GitCommit | null {
415
592
  return state.commits[i] ?? null;
416
593
  }
417
594
 
418
- function indexFromCursorByte(bytePos: number): number {
419
- // No header row — row 0 is commit 0.
420
- const idx = rowFromByte(bytePos);
421
- if (idx < 0) return 0;
422
- if (idx >= state.commits.length) return state.commits.length - 1;
423
- return idx;
424
- }
425
-
426
595
  // =============================================================================
427
596
  // Commands
428
597
  // =============================================================================
@@ -454,42 +623,40 @@ async function show_git_log(): Promise<void> {
454
623
  );
455
624
  state.groupId = group.groupId as number;
456
625
  state.logBufferId = (group.panels["log"] as number | undefined) ?? null;
457
- 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;
458
631
  state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null;
632
+ if (state.toolbarBufferId !== null) {
633
+ state.toolbarPanel = new WidgetPanel(state.toolbarBufferId);
634
+ }
635
+ if (state.logBufferId !== null) {
636
+ state.logPanel = new WidgetPanel(state.logBufferId);
637
+ }
459
638
  state.selectedIndex = 0;
460
- state.detailCache = null;
639
+ state.commitBuffers = new Map();
640
+ state.inFlightSpawns = new Map();
461
641
  state.isOpen = true;
462
642
 
463
- // The log panel owns a native cursor so j/k/Up/Down navigate commits,
464
- // and the detail panel also gets a cursor so diff lines can be clicked
465
- // / traversed before pressing Enter to open a file.
466
- if (state.logBufferId !== null) {
467
- editor.setBufferShowCursors(state.logBufferId, true);
468
- }
469
- if (state.detailBufferId !== null) {
470
- editor.setBufferShowCursors(state.detailBufferId, true);
471
- // Wrap long lines in the detail panel — git diffs often exceed the
472
- // 40% split width, and horizontal scrolling a commit is awkward.
473
- editor.setLineWrap(state.detailBufferId, null, true);
474
- // Per-panel mode: the group was created with "git-log" which applies
475
- // to the initially-focused panel (log). The detail panel's mode is
476
- // 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);
477
651
  }
478
652
 
479
653
  renderToolbar();
480
654
  renderLog();
481
- // Position the cursor on the first commit (row 0 now that the header
482
- // row is gone).
483
- if (state.logBufferId !== null && state.commits.length > 0) {
484
- editor.setBufferCursor(state.logBufferId, 0);
485
- }
655
+ // List widget's instance state is the source of truth for selection;
656
+ // no buffer-cursor positioning needed (the renderer auto-scrolls so
657
+ // the selected row stays visible).
486
658
  await refreshDetail();
487
659
 
488
- if (state.groupId !== null) {
489
- editor.focusBufferGroupPanel(state.groupId, "log");
490
- }
491
- editor.on("cursor_moved", on_git_log_cursor_moved);
492
- editor.on("mouse_click", on_git_log_toolbar_click);
493
660
  editor.on("resize", on_git_log_resize);
494
661
  editor.on("buffer_closed", on_git_log_buffer_closed);
495
662
 
@@ -504,19 +671,38 @@ registerHandler("show_git_log", show_git_log);
504
671
  * close button, which triggers `buffer_closed`). */
505
672
  function git_log_cleanup(): void {
506
673
  if (!state.isOpen) return;
507
- editor.off("cursor_moved", on_git_log_cursor_moved);
508
- editor.off("mouse_click", on_git_log_toolbar_click);
509
674
  editor.off("resize", on_git_log_resize);
510
675
  editor.off("buffer_closed", on_git_log_buffer_closed);
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.
696
+ state.toolbarPanel = null;
697
+ state.logPanel = null;
511
698
  state.isOpen = false;
512
699
  state.groupId = null;
513
700
  state.logBufferId = null;
701
+ state.initialDetailBufferId = null;
514
702
  state.detailBufferId = null;
515
703
  state.toolbarBufferId = null;
516
- state.toolbarButtons = [];
517
704
  state.commits = [];
518
705
  state.selectedIndex = 0;
519
- state.detailCache = null;
520
706
  }
521
707
 
522
708
  function git_log_close(): void {
@@ -532,12 +718,26 @@ registerHandler("git_log_close", git_log_close);
532
718
 
533
719
  function on_git_log_buffer_closed(data: { buffer_id: number }): void {
534
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.
535
725
  if (
536
726
  data.buffer_id === state.logBufferId ||
537
- data.buffer_id === state.detailBufferId ||
727
+ data.buffer_id === state.initialDetailBufferId ||
538
728
  data.buffer_id === state.toolbarBufferId
539
729
  ) {
540
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
+ }
541
741
  }
542
742
  }
543
743
  registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed);
@@ -546,7 +746,13 @@ async function git_log_refresh(): Promise<void> {
546
746
  if (!state.isOpen) return;
547
747
  editor.setStatus(editor.t("status.refreshing"));
548
748
  state.commits = await fetchGitLog(editor);
549
- 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();
550
756
  if (state.selectedIndex >= state.commits.length) {
551
757
  state.selectedIndex = Math.max(0, state.commits.length - 1);
552
758
  }
@@ -617,26 +823,222 @@ function git_log_q(): void {
617
823
  }
618
824
  registerHandler("git_log_q", git_log_q);
619
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
+
620
913
  // =============================================================================
621
914
  // Detail panel — open file at commit
622
915
  // =============================================================================
623
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
+
624
1026
  async function git_log_detail_open_file(): Promise<void> {
625
1027
  if (state.detailBufferId === null) return;
626
1028
  const commit = selectedCommit();
627
1029
  if (!commit) return;
628
1030
 
629
- const props = editor.getTextPropertiesAtCursor(state.detailBufferId);
630
- if (props.length === 0) {
631
- editor.setStatus(editor.t("status.move_to_diff"));
632
- return;
633
- }
634
- const file = props[0].file as string | undefined;
635
- const line = (props[0].line as number | undefined) ?? 1;
636
- 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) {
637
1038
  editor.setStatus(editor.t("status.move_to_diff_with_context"));
638
1039
  return;
639
1040
  }
1041
+ const { file, line } = ctx;
640
1042
 
641
1043
  editor.setStatus(
642
1044
  editor.t("status.file_loading", { file, hash: commit.shortHash })
@@ -712,56 +1114,37 @@ function git_log_file_view_close(): void {
712
1114
  registerHandler("git_log_file_view_close", git_log_file_view_close);
713
1115
 
714
1116
  // =============================================================================
715
- // Cursor tracking — live-update the detail panel as the user scrolls through
716
- // the commit list.
1117
+ // Selection tracking — live-update the detail panel as the user
1118
+ // navigates the List. Driven by `widget_event "select"` from the host.
717
1119
  // =============================================================================
718
1120
 
719
- async function on_git_log_cursor_moved(data: {
720
- buffer_id: number;
721
- cursor_id: number;
722
- old_position: number;
723
- new_position: number;
724
- }): Promise<void> {
1121
+ async function on_log_select(idx: number): Promise<void> {
725
1122
  if (!state.isOpen) return;
726
- // Only react to movement inside the log panel.
727
- if (data.buffer_id !== state.logBufferId) return;
728
-
729
- // Map the cursor's byte offset to a commit index via the row-offset
730
- // table built in `renderLog`. This avoids relying on `getCursorLine`
731
- // which is not implemented for virtual buffers.
732
- const idx = indexFromCursorByte(data.new_position);
733
1123
  if (idx === state.selectedIndex) return;
734
1124
  state.selectedIndex = idx;
735
1125
 
736
- // Immediate feedback: update the log panel's selection highlight and
737
- // either show the cached detail or a "loading" placeholder. Only the
738
- // actual `git show` spawn is debounced below, so a burst of j/k events
739
- // still feels responsive even though we collapse the fetches into one.
740
- renderLog();
741
- const pending = refreshDetailImmediate();
742
-
743
1126
  const commit = state.commits[state.selectedIndex];
744
1127
  if (commit) {
745
1128
  editor.setStatus(
746
1129
  editor.t("status.commit_position", {
747
1130
  current: String(state.selectedIndex + 1),
748
1131
  total: String(state.commits.length),
749
- })
1132
+ }),
750
1133
  );
751
1134
  }
752
1135
 
753
- if (!pending) return;
754
-
755
1136
  // Debounce: bump the token, wait a beat, bail if a newer event has
756
- // arrived. `git show` is expensive; a burst of cursor events (held
757
- // j/k, PageDown) must collapse to one spawn.
758
- const myId = ++state.pendingCursorMoveId;
759
- await editor.delay(CURSOR_DEBOUNCE_MS);
760
- if (myId !== state.pendingCursorMoveId) return;
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.
1140
+ const myId = ++state.pendingSelectId;
1141
+ await editor.delay(SELECT_DEBOUNCE_MS);
1142
+ if (myId !== state.pendingSelectId) return;
761
1143
  if (!state.isOpen) return;
762
- await fetchAndRenderDetail(pending);
1144
+ const current = state.commits[state.selectedIndex];
1145
+ if (!current) return;
1146
+ await showCommitInDetail(current, editor.getCwd());
763
1147
  }
764
- registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved);
765
1148
 
766
1149
  // =============================================================================
767
1150
  // Command registration
@@ -785,5 +1168,4 @@ editor.registerCommand(
785
1168
  "git_log_refresh",
786
1169
  null
787
1170
  );
788
-
789
1171
  editor.debug("Git Log plugin initialized (modern buffer-group layout)");