@fresh-editor/fresh-editor 0.2.23 → 0.2.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1163 +1,760 @@
1
1
  /// <reference path="./lib/fresh.d.ts" />
2
- const editor = getEditor();
3
2
 
3
+ import {
4
+ type GitCommit,
5
+ buildCommitDetailEntries,
6
+ buildCommitLogEntries,
7
+ buildDetailPlaceholderEntries,
8
+ fetchCommitShow,
9
+ fetchGitLog,
10
+ } from "./lib/git_history.ts";
11
+
12
+ const editor = getEditor();
4
13
 
5
14
  /**
6
- * Git Log Plugin - Magit-style Git Log Interface
15
+ * Git Log Plugin Magit-style git history interface built on top of the
16
+ * modern plugin API primitives:
7
17
  *
8
- * Provides an interactive git log view with:
9
- * - Syntax highlighting for hash, author, date, subject
10
- * - Cursor navigation between commits
11
- * - Enter to open commit details in a virtual buffer
18
+ * * `createBufferGroup` for a side-by-side "log | detail" layout that
19
+ * appears as a single tab with its own inner scroll state.
20
+ * * `setPanelContent` with `TextPropertyEntry[]` + `inlineOverlays` for
21
+ * aligned columns and per-theme colouring (every colour is a theme key,
22
+ * so the panel follows theme changes).
23
+ * * `cursor_moved` subscription to live-update the right-hand detail panel
24
+ * as the user scrolls through the commit list.
12
25
  *
13
- * Architecture designed for future magit-style features.
26
+ * The rendering helpers live in `lib/git_history.ts` so the same commit-list
27
+ * view can be reused by `audit_mode`'s PR-branch review mode.
14
28
  */
15
29
 
16
30
  // =============================================================================
17
- // Types and Interfaces
31
+ // State
18
32
  // =============================================================================
19
33
 
20
- interface GitCommit {
21
- hash: string;
22
- shortHash: string;
23
- author: string;
24
- authorEmail: string;
25
- date: string;
26
- relativeDate: string;
27
- subject: string;
28
- body: string;
29
- refs: string; // Branch/tag refs
30
- graph: string; // Graph characters
31
- }
32
-
33
- interface GitLogOptions {
34
- showGraph: boolean;
35
- showRefs: boolean;
36
- maxCommits: number;
37
- }
38
-
39
34
  interface GitLogState {
40
35
  isOpen: boolean;
41
- bufferId: number | null;
42
- splitId: number | null; // The split where git log is displayed
43
- sourceBufferId: number | null; // The buffer that was open before git log (to restore on close)
36
+ groupId: number | null;
37
+ logBufferId: number | null;
38
+ detailBufferId: number | null;
39
+ toolbarBufferId: number | null;
40
+ /** Click-regions for the toolbar's buttons, populated by `renderToolbar`. */
41
+ toolbarButtons: ToolbarButton[];
44
42
  commits: GitCommit[];
45
- options: GitLogOptions;
46
- cachedContent: string; // Store content for highlighting (getBufferText doesn't work for virtual buffers)
47
- }
48
-
49
- interface GitCommitDetailState {
50
- isOpen: boolean;
51
- bufferId: number | null;
52
- splitId: number | null;
53
- commit: GitCommit | null;
54
- cachedContent: string; // Store content for highlighting
43
+ 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
+ /**
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.
56
+ */
57
+ pendingCursorMoveId: number;
58
+ /**
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).
64
+ */
65
+ logRowByteOffsets: number[];
55
66
  }
56
67
 
57
- interface GitFileViewState {
58
- isOpen: boolean;
59
- bufferId: number | null;
60
- splitId: number | null;
61
- filePath: string | null;
62
- commitHash: string | null;
63
- }
64
-
65
- // =============================================================================
66
- // State Management
67
- // =============================================================================
68
-
69
- const gitLogState: GitLogState = {
68
+ const state: GitLogState = {
70
69
  isOpen: false,
71
- bufferId: null,
72
- splitId: null,
73
- sourceBufferId: null,
70
+ groupId: null,
71
+ logBufferId: null,
72
+ detailBufferId: null,
73
+ toolbarBufferId: null,
74
+ toolbarButtons: [],
74
75
  commits: [],
75
- options: {
76
- showGraph: false, // Disabled by default - graph interferes with format parsing
77
- showRefs: true,
78
- maxCommits: 100,
79
- },
80
- cachedContent: "",
81
- };
82
-
83
- const commitDetailState: GitCommitDetailState = {
84
- isOpen: false,
85
- bufferId: null,
86
- splitId: null,
87
- commit: null,
88
- cachedContent: "",
89
- };
90
-
91
- const fileViewState: GitFileViewState = {
92
- isOpen: false,
93
- bufferId: null,
94
- splitId: null,
95
- filePath: null,
96
- commitHash: null,
76
+ selectedIndex: 0,
77
+ detailCache: null,
78
+ pendingDetailId: 0,
79
+ pendingCursorMoveId: 0,
80
+ logRowByteOffsets: [],
97
81
  };
98
82
 
99
- // =============================================================================
100
- // Color Definitions (for syntax highlighting)
101
- // =============================================================================
83
+ /**
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
+ }
102
105
 
103
- const colors = {
104
- hash: [255, 180, 50] as [number, number, number], // Yellow/Orange
105
- author: [100, 200, 255] as [number, number, number], // Cyan
106
- date: [150, 255, 150] as [number, number, number], // Green
107
- subject: [255, 255, 255] as [number, number, number], // White
108
- header: [255, 200, 100] as [number, number, number], // Gold
109
- separator: [100, 100, 100] as [number, number, number], // Gray
110
- selected: [80, 80, 120] as [number, number, number], // Selection background
111
- diffAdd: [100, 255, 100] as [number, number, number], // Green for additions
112
- diffDel: [255, 100, 100] as [number, number, number], // Red for deletions
113
- diffHunk: [150, 150, 255] as [number, number, number], // Blue for hunk headers
114
- branch: [255, 150, 255] as [number, number, number], // Magenta for branches
115
- tag: [255, 255, 100] as [number, number, number], // Yellow for tags
116
- remote: [255, 130, 100] as [number, number, number], // Orange for remotes
117
- graph: [150, 150, 150] as [number, number, number], // Gray for graph
118
- // Syntax highlighting colors
119
- syntaxKeyword: [200, 120, 220] as [number, number, number], // Purple for keywords
120
- syntaxString: [180, 220, 140] as [number, number, number], // Light green for strings
121
- syntaxComment: [120, 120, 120] as [number, number, number], // Gray for comments
122
- syntaxNumber: [220, 180, 120] as [number, number, number], // Orange for numbers
123
- syntaxFunction: [100, 180, 255] as [number, number, number], // Blue for functions
124
- syntaxType: [80, 200, 180] as [number, number, number], // Teal for types
125
- };
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.
109
+ */
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
+ }
126
122
 
127
123
  // =============================================================================
128
- // Mode Definitions
124
+ // Modes
125
+ //
126
+ // A buffer group has a single mode shared by all of its panels, so the
127
+ // handlers below branch on which panel currently has focus to do the
128
+ // right thing (`Return` jumps into the detail panel when pressed in
129
+ // the log, and opens the file at the cursor when pressed in the detail).
129
130
  // =============================================================================
130
131
 
131
- // Define git-log mode with minimal keybindings
132
- // Navigation uses normal cursor movement (arrows, j/k work naturally via parent mode)
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`.
133
136
  editor.defineMode(
134
137
  "git-log",
135
138
  [
136
- ["Return", "git_log_show_commit"],
137
- ["Tab", "git_log_show_commit"],
138
- ["q", "git_log_close"],
139
- ["Escape", "git_log_close"],
139
+ ["k", "move_up"],
140
+ ["j", "move_down"],
141
+ ["Return", "git_log_enter"],
142
+ ["Tab", "git_log_tab"],
143
+ ["q", "git_log_q"],
140
144
  ["r", "git_log_refresh"],
141
145
  ["y", "git_log_copy_hash"],
142
146
  ],
143
- true // read-only
147
+ true, // read-only
148
+ false, // allow_text_input
149
+ true, // inherit Normal-context bindings for unbound keys
144
150
  );
145
151
 
146
- // Define git-commit-detail mode for viewing commit details
147
- // Inherits from normal mode for natural cursor movement
148
- editor.defineMode(
149
- "git-commit-detail",
150
- [
151
- ["Return", "git_commit_detail_open_file"],
152
- ["q", "git_commit_detail_close"],
153
- ["Escape", "git_commit_detail_close"],
154
- ],
155
- true // read-only
156
- );
152
+ // =============================================================================
153
+ // Panel layout
154
+ // =============================================================================
157
155
 
158
- // Define git-file-view mode for viewing files at a specific commit
159
- editor.defineMode(
160
- "git-file-view",
161
- [
162
- ["q", "git_file_view_close"],
163
- ["Escape", "git_file_view_close"],
164
- ],
165
- true // read-only
166
- );
156
+ /**
157
+ * Group buffer layout — a one-row sticky toolbar on top, then a horizontal
158
+ * split below with the commit log on the left (60%) and detail on the
159
+ * right (40%). The toolbar mirrors the review-diff style: a fixed-height
160
+ * panel above the scrollable content that holds all the keybinding hints
161
+ * so they don't shift or scroll with the data.
162
+ */
163
+ const GROUP_LAYOUT = JSON.stringify({
164
+ type: "split",
165
+ direction: "v",
166
+ ratio: 0.05, // ignored when one side is `fixed`
167
+ first: { type: "fixed", id: "toolbar", height: 1 },
168
+ second: {
169
+ type: "split",
170
+ direction: "h",
171
+ ratio: 0.6,
172
+ first: { type: "scrollable", id: "log" },
173
+ second: { type: "scrollable", id: "detail" },
174
+ },
175
+ });
167
176
 
168
177
  // =============================================================================
169
- // Git Command Execution
178
+ // Toolbar
170
179
  // =============================================================================
171
180
 
172
- async function fetchGitLog(): Promise<GitCommit[]> {
173
- // Use record separator to reliably split commits
174
- // Format: hash, short hash, author, email, date, relative date, refs, subject, body
175
- const format = "%H%x00%h%x00%an%x00%ae%x00%ai%x00%ar%x00%d%x00%s%x00%b%x1e";
181
+ interface ToolbarHint {
182
+ key: string;
183
+ label: string;
184
+ /** Click action — `null` for hints that are keyboard-only (j/k, PgUp). */
185
+ onClick: (() => void | Promise<void>) | null;
186
+ }
187
+
188
+ interface ToolbarButton {
189
+ row: number;
190
+ startCol: number;
191
+ endCol: number;
192
+ onClick: (() => void | Promise<void>) | null;
193
+ }
176
194
 
177
- const args = [
178
- "log",
179
- `--format=${format}`,
180
- `-n${gitLogState.options.maxCommits}`,
195
+ function toolbarHints(): ToolbarHint[] {
196
+ 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 },
181
202
  ];
203
+ }
182
204
 
183
- const cwd = editor.getCwd();
184
- const result = await editor.spawnProcess("git", args, cwd);
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
+ });
185
249
 
186
- if (result.exit_code !== 0) {
187
- editor.setStatus(editor.t("status.git_error", { error: result.stderr }));
188
- return [];
250
+ buttons.push({ row: 0, startCol, endCol, onClick: hint.onClick });
189
251
  }
190
252
 
191
- const commits: GitCommit[] = [];
192
- // Split by record separator (0x1e)
193
- const records = result.stdout.split("\x1e");
194
-
195
- for (const record of records) {
196
- if (!record.trim()) continue;
197
-
198
- const parts = record.split("\x00");
199
- if (parts.length >= 8) {
200
- commits.push({
201
- hash: parts[0].trim(),
202
- shortHash: parts[1].trim(),
203
- author: parts[2].trim(),
204
- authorEmail: parts[3].trim(),
205
- date: parts[4].trim(),
206
- relativeDate: parts[5].trim(),
207
- refs: parts[6].trim(),
208
- subject: parts[7].trim(),
209
- body: parts[8] ? parts[8].trim() : "",
210
- graph: "", // Graph is handled separately if needed
211
- });
212
- }
213
- }
253
+ state.toolbarButtons = buttons;
214
254
 
215
- return commits;
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
+ ];
216
263
  }
217
264
 
218
- async function fetchCommitDiff(hash: string): Promise<string> {
219
- const cwd = editor.getCwd();
220
- const result = await editor.spawnProcess("git", [
221
- "show",
222
- "--stat",
223
- "--patch",
224
- hash,
225
- ], cwd);
265
+ 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));
270
+ }
226
271
 
227
- if (result.exit_code !== 0) {
228
- return editor.t("status.error_fetching_diff", { error: result.stderr });
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();
229
287
  }
288
+ }
289
+ registerHandler("on_git_log_toolbar_click", on_git_log_toolbar_click);
230
290
 
231
- return result.stdout;
291
+ function on_git_log_resize(_data: { width: number; height: number }): void {
292
+ if (!state.isOpen) return;
293
+ renderToolbar();
232
294
  }
295
+ registerHandler("on_git_log_resize", on_git_log_resize);
233
296
 
234
297
  // =============================================================================
235
- // Git Log View
298
+ // Rendering
236
299
  // =============================================================================
237
300
 
238
- function formatCommitRow(commit: GitCommit): string {
239
- // Build a structured line for consistent parsing and highlighting
240
- // Format: shortHash (author, relativeDate) subject [refs]
241
- let line = commit.shortHash;
242
-
243
- // Add author in parentheses
244
- line += " (" + commit.author + ", " + commit.relativeDate + ")";
245
-
246
- // Add subject
247
- line += " " + commit.subject;
248
-
249
- // Add refs at the end if present and enabled
250
- if (gitLogState.options.showRefs && commit.refs) {
251
- line += " " + commit.refs;
252
- }
253
-
254
- return line + "\n";
301
+ function detailFooter(hash: string): string {
302
+ return editor.t("status.commit_ready", { hash });
255
303
  }
256
304
 
257
- // Helper to extract content string from entries (for highlighting)
258
- function entriesToContent(entries: TextPropertyEntry[]): string {
259
- return entries.map(e => e.text).join("");
305
+ 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,
312
+ header: null,
313
+ });
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);
260
327
  }
261
328
 
262
- function buildGitLogEntries(): TextPropertyEntry[] {
263
- const entries: TextPropertyEntry[] = [];
264
-
265
- // Magit-style header
266
- entries.push({
267
- text: editor.t("panel.commits_header") + "\n",
268
- properties: { type: "section-header" },
269
- });
329
+ function renderDetailPlaceholder(message: string): void {
330
+ if (state.groupId === null) return;
331
+ editor.setPanelContent(
332
+ state.groupId,
333
+ "detail",
334
+ buildDetailPlaceholderEntries(message)
335
+ );
336
+ }
270
337
 
271
- if (gitLogState.commits.length === 0) {
272
- entries.push({
273
- text: editor.t("panel.no_commits") + "\n",
274
- properties: { type: "empty" },
275
- });
276
- } else {
277
- // Add each commit
278
- for (let i = 0; i < gitLogState.commits.length; i++) {
279
- const commit = gitLogState.commits[i];
280
- entries.push({
281
- text: formatCommitRow(commit),
282
- properties: {
283
- type: "commit",
284
- index: i,
285
- hash: commit.hash,
286
- shortHash: commit.shortHash,
287
- author: commit.author,
288
- date: commit.relativeDate,
289
- subject: commit.subject,
290
- refs: commit.refs,
291
- graph: commit.graph,
292
- },
293
- });
294
- }
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);
295
345
  }
296
-
297
- // Footer with help
298
- entries.push({
299
- text: "\n",
300
- properties: { type: "blank" },
301
- });
302
- entries.push({
303
- text: editor.t("panel.log_footer", { count: String(gitLogState.commits.length) }) + "\n",
304
- properties: { type: "footer" },
305
- });
306
-
307
- return entries;
308
346
  }
309
347
 
310
- function applyGitLogHighlighting(): void {
311
- if (gitLogState.bufferId === null) return;
312
-
313
- const bufferId = gitLogState.bufferId;
314
-
315
- // Clear existing overlays
316
- editor.clearNamespace(bufferId, "gitlog");
317
-
318
- // Use cached content (getBufferText doesn't work for virtual buffers)
319
- const content = gitLogState.cachedContent;
320
- if (!content) return;
321
- const lines = content.split("\n");
322
-
323
- // Get cursor line to highlight current row (1-indexed from API)
324
- const cursorLine = editor.getCursorLine();
325
- const headerLines = 1; // Just "Commits:" header
326
-
327
- let byteOffset = 0;
328
-
329
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
330
- const line = lines[lineIdx];
331
- const lineStart = byteOffset;
332
- const lineEnd = byteOffset + line.length;
333
-
334
- // Highlight section header
335
- if (line === editor.t("panel.commits_header")) {
336
- editor.addOverlay(bufferId, "gitlog", lineStart, lineEnd, {
337
- fg: colors.header,
338
- underline: true,
339
- bold: true,
340
- });
341
- byteOffset += line.length + 1;
342
- continue;
343
- }
344
-
345
- const commitIndex = lineIdx - headerLines;
346
- if (commitIndex < 0 || commitIndex >= gitLogState.commits.length) {
347
- byteOffset += line.length + 1;
348
- continue;
349
- }
350
-
351
- const commit = gitLogState.commits[commitIndex];
352
- // cursorLine is 1-indexed, lineIdx is 0-indexed
353
- const isCurrentLine = (lineIdx + 1) === cursorLine;
354
-
355
- // Highlight entire line if cursor is on it (using selected color with underline)
356
- if (isCurrentLine) {
357
- editor.addOverlay(bufferId, "gitlog", lineStart, lineEnd, {
358
- fg: colors.selected,
359
- underline: true,
360
- bold: true,
361
- });
362
- }
363
-
364
- // Parse the line format: "shortHash (author, relativeDate) subject [refs]"
365
- // Highlight hash (first 7+ chars until space)
366
- const hashEnd = commit.shortHash.length;
367
- editor.addOverlay(bufferId, "gitlog", lineStart, lineStart + hashEnd, {
368
- fg: colors.hash,
369
- });
370
-
371
- // Highlight author name (inside parentheses)
372
- const authorPattern = "(" + commit.author + ",";
373
- const authorStartInLine = line.indexOf(authorPattern);
374
- if (authorStartInLine >= 0) {
375
- const authorStart = lineStart + authorStartInLine + 1; // skip "("
376
- const authorEnd = authorStart + commit.author.length;
377
- editor.addOverlay(bufferId, "gitlog", authorStart, authorEnd, {
378
- fg: colors.author,
379
- });
380
- }
348
+ /**
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.
353
+ *
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.
356
+ */
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;
381
366
 
382
- // Highlight relative date
383
- const datePattern = ", " + commit.relativeDate + ")";
384
- const dateStartInLine = line.indexOf(datePattern);
385
- if (dateStartInLine >= 0) {
386
- const dateStart = lineStart + dateStartInLine + 2; // skip ", "
387
- const dateEnd = dateStart + commit.relativeDate.length;
388
- editor.addOverlay(bufferId, "gitlog", dateStart, dateEnd, {
389
- fg: colors.date,
390
- });
391
- }
367
+ if (state.detailCache && state.detailCache.hash === commit.hash) {
368
+ renderDetailForCommit(commit, state.detailCache.output);
369
+ return null;
370
+ }
392
371
 
393
- // Highlight refs (branches/tags) at end of line if present
394
- if (gitLogState.options.showRefs && commit.refs) {
395
- const refsStartInLine = line.lastIndexOf(commit.refs);
396
- if (refsStartInLine >= 0) {
397
- const refsStart = lineStart + refsStartInLine;
398
- const refsEnd = refsStart + commit.refs.length;
399
-
400
- // Determine color based on ref type
401
- let refColor = colors.branch;
402
- if (commit.refs.includes("tag:")) {
403
- refColor = colors.tag;
404
- } else if (commit.refs.includes("origin/") || commit.refs.includes("remote")) {
405
- refColor = colors.remote;
406
- }
407
-
408
- editor.addOverlay(bufferId, "gitlog", refsStart, refsEnd, {
409
- fg: refColor,
410
- bold: true,
411
- });
412
- }
413
- }
372
+ renderDetailPlaceholder(
373
+ editor.t("status.loading_commit", { hash: commit.shortHash })
374
+ );
375
+ return commit;
376
+ }
414
377
 
415
- byteOffset += line.length + 1;
416
- }
378
+ /**
379
+ * Spawn `git show` for `commit` and render the result. Tagged with
380
+ * `pendingDetailId` so a newer selection supersedes in-flight fetches.
381
+ */
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);
417
396
  }
418
397
 
419
- function updateGitLogView(): void {
420
- if (gitLogState.bufferId !== null) {
421
- const entries = buildGitLogEntries();
422
- gitLogState.cachedContent = entriesToContent(entries);
423
- editor.setVirtualBufferContent(gitLogState.bufferId, entries);
424
- applyGitLogHighlighting();
425
- }
398
+ /**
399
+ * Combined synchronous + asynchronous refresh used by open/refresh paths
400
+ * where there's no burst of events to collapse.
401
+ */
402
+ async function refreshDetail(): Promise<void> {
403
+ const pending = refreshDetailImmediate();
404
+ if (pending) await fetchAndRenderDetail(pending);
426
405
  }
427
406
 
428
407
  // =============================================================================
429
- // Commit Detail View
408
+ // Selection tracking — keeps `state.selectedIndex` in sync with the log
409
+ // panel's native cursor so the highlight and detail stay consistent.
430
410
  // =============================================================================
431
411
 
432
- // Parse diff line to extract file and line information
433
- interface DiffContext {
434
- currentFile: string | null;
435
- currentHunkNewStart: number;
436
- currentHunkNewLine: number; // Current line within the new file
412
+ function selectedCommit(): GitCommit | null {
413
+ if (state.commits.length === 0) return null;
414
+ const i = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1));
415
+ return state.commits[i] ?? null;
437
416
  }
438
417
 
439
- function buildCommitDetailEntries(commit: GitCommit, showOutput: string): TextPropertyEntry[] {
440
- const entries: TextPropertyEntry[] = [];
441
- const lines = showOutput.split("\n");
442
-
443
- // Track diff context for file/line navigation
444
- const diffContext: DiffContext = {
445
- currentFile: null,
446
- currentHunkNewStart: 0,
447
- currentHunkNewLine: 0,
448
- };
449
-
450
- for (const line of lines) {
451
- let lineType = "text";
452
- const properties: Record<string, unknown> = { type: lineType };
453
-
454
- // Detect diff file header: diff --git a/path b/path
455
- const diffHeaderMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/);
456
- if (diffHeaderMatch) {
457
- diffContext.currentFile = diffHeaderMatch[2]; // Use the 'b' (new) file path
458
- diffContext.currentHunkNewStart = 0;
459
- diffContext.currentHunkNewLine = 0;
460
- lineType = "diff-header";
461
- properties.type = lineType;
462
- properties.file = diffContext.currentFile;
463
- }
464
- // Detect +++ line (new file path)
465
- else if (line.startsWith("+++ b/")) {
466
- diffContext.currentFile = line.slice(6);
467
- lineType = "diff-header";
468
- properties.type = lineType;
469
- properties.file = diffContext.currentFile;
470
- }
471
- // Detect hunk header: @@ -old,count +new,count @@
472
- else if (line.startsWith("@@")) {
473
- lineType = "diff-hunk";
474
- const hunkMatch = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
475
- if (hunkMatch) {
476
- diffContext.currentHunkNewStart = parseInt(hunkMatch[1], 10);
477
- diffContext.currentHunkNewLine = diffContext.currentHunkNewStart;
478
- }
479
- properties.type = lineType;
480
- properties.file = diffContext.currentFile;
481
- properties.line = diffContext.currentHunkNewStart;
482
- }
483
- // Addition line
484
- else if (line.startsWith("+") && !line.startsWith("+++")) {
485
- lineType = "diff-add";
486
- properties.type = lineType;
487
- properties.file = diffContext.currentFile;
488
- properties.line = diffContext.currentHunkNewLine;
489
- diffContext.currentHunkNewLine++;
490
- }
491
- // Deletion line
492
- else if (line.startsWith("-") && !line.startsWith("---")) {
493
- lineType = "diff-del";
494
- properties.type = lineType;
495
- properties.file = diffContext.currentFile;
496
- // Deletion lines don't advance the new file line counter
497
- }
498
- // Context line (unchanged)
499
- else if (line.startsWith(" ") && diffContext.currentFile && diffContext.currentHunkNewLine > 0) {
500
- lineType = "diff-context";
501
- properties.type = lineType;
502
- properties.file = diffContext.currentFile;
503
- properties.line = diffContext.currentHunkNewLine;
504
- diffContext.currentHunkNewLine++;
505
- }
506
- // Other diff header lines
507
- else if (line.startsWith("index ") || line.startsWith("--- ")) {
508
- lineType = "diff-header";
509
- properties.type = lineType;
510
- }
511
- // Commit header lines
512
- else if (line.startsWith("commit ")) {
513
- lineType = "header";
514
- properties.type = lineType;
515
- const hashMatch = line.match(/^commit ([a-f0-9]+)/);
516
- if (hashMatch) {
517
- properties.hash = hashMatch[1];
518
- }
519
- }
520
- else if (line.startsWith("Author:")) {
521
- lineType = "meta";
522
- properties.type = lineType;
523
- properties.field = "author";
524
- }
525
- else if (line.startsWith("Date:")) {
526
- lineType = "meta";
527
- properties.type = lineType;
528
- properties.field = "date";
529
- }
530
-
531
- entries.push({
532
- text: `${line}\n`,
533
- properties: properties,
534
- });
535
- }
536
-
537
- // Footer with help
538
- entries.push({
539
- text: "\n",
540
- properties: { type: "blank" },
541
- });
542
- entries.push({
543
- text: editor.t("panel.detail_footer") + "\n",
544
- properties: { type: "footer" },
545
- });
546
-
547
- return entries;
548
- }
549
-
550
- function applyCommitDetailHighlighting(): void {
551
- if (commitDetailState.bufferId === null) return;
552
-
553
- const bufferId = commitDetailState.bufferId;
554
-
555
- // Clear existing overlays
556
- editor.clearNamespace(bufferId, "gitdetail");
557
-
558
- // Use cached content (getBufferText doesn't work for virtual buffers)
559
- const content = commitDetailState.cachedContent;
560
- if (!content) return;
561
- const lines = content.split("\n");
562
-
563
- let byteOffset = 0;
564
-
565
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
566
- const line = lines[lineIdx];
567
- const lineStart = byteOffset;
568
- const lineEnd = byteOffset + line.length;
569
-
570
- // Highlight diff additions (green)
571
- if (line.startsWith("+") && !line.startsWith("+++")) {
572
- editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, {
573
- fg: colors.diffAdd,
574
- });
575
- }
576
- // Highlight diff deletions (red)
577
- else if (line.startsWith("-") && !line.startsWith("---")) {
578
- editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, {
579
- fg: colors.diffDel,
580
- });
581
- }
582
- // Highlight hunk headers (cyan/blue)
583
- else if (line.startsWith("@@")) {
584
- editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, {
585
- fg: colors.diffHunk,
586
- bold: true,
587
- });
588
- }
589
- // Highlight commit hash in "commit <hash>" line (git show format)
590
- else if (line.startsWith("commit ")) {
591
- const hashMatch = line.match(/^commit ([a-f0-9]+)/);
592
- if (hashMatch) {
593
- const hashStart = lineStart + 7; // "commit " is 7 chars
594
- editor.addOverlay(bufferId, "gitdetail", hashStart, hashStart + hashMatch[1].length, {
595
- fg: colors.hash,
596
- bold: true,
597
- });
598
- }
599
- }
600
- // Highlight author line
601
- else if (line.startsWith("Author:")) {
602
- editor.addOverlay(bufferId, "gitdetail", lineStart + 8, lineEnd, {
603
- fg: colors.author,
604
- });
605
- }
606
- // Highlight date line
607
- else if (line.startsWith("Date:")) {
608
- editor.addOverlay(bufferId, "gitdetail", lineStart + 6, lineEnd, {
609
- fg: colors.date,
610
- });
611
- }
612
- // Highlight diff file headers
613
- else if (line.startsWith("diff --git")) {
614
- editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, {
615
- fg: colors.header,
616
- bold: true,
617
- });
618
- }
619
-
620
- byteOffset += line.length + 1;
621
- }
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;
622
424
  }
623
425
 
624
426
  // =============================================================================
625
- // Public Commands - Git Log
427
+ // Commands
626
428
  // =============================================================================
627
429
 
628
- async function show_git_log() : Promise<void> {
629
- if (gitLogState.isOpen) {
630
- editor.setStatus(editor.t("status.already_open"));
430
+ async function show_git_log(): Promise<void> {
431
+ if (state.isOpen) {
432
+ // Already open — pull the existing tab to the front instead of
433
+ // bailing out with a status message.
434
+ if (state.groupId !== null) {
435
+ editor.focusBufferGroupPanel(state.groupId, "log");
436
+ }
631
437
  return;
632
438
  }
633
-
634
439
  editor.setStatus(editor.t("status.loading"));
635
440
 
636
- // Store the current split ID and buffer ID before opening git log
637
- gitLogState.splitId = editor.getActiveSplitId();
638
- gitLogState.sourceBufferId = editor.getActiveBufferId();
639
-
640
- // Fetch commits
641
- gitLogState.commits = await fetchGitLog();
642
-
643
- if (gitLogState.commits.length === 0) {
441
+ state.commits = await fetchGitLog(editor);
442
+ if (state.commits.length === 0) {
644
443
  editor.setStatus(editor.t("status.no_commits"));
645
- gitLogState.splitId = null;
646
444
  return;
647
445
  }
648
446
 
649
- // Build entries and cache content for highlighting
650
- const entries = buildGitLogEntries();
651
- gitLogState.cachedContent = entriesToContent(entries);
652
-
653
- // Create virtual buffer in the current split (replacing current buffer)
654
- const result = await editor.createVirtualBufferInExistingSplit({
655
- name: "*Git Log*",
656
- mode: "git-log",
657
- readOnly: true,
658
- entries: entries,
659
- splitId: gitLogState.splitId!,
660
- showLineNumbers: false,
661
- showCursors: true,
662
- editingDisabled: true,
663
- });
664
-
665
- if (result !== null) {
666
- gitLogState.isOpen = true;
667
- gitLogState.bufferId = result.bufferId;
668
-
669
- // Apply syntax highlighting
670
- applyGitLogHighlighting();
671
-
672
- editor.setStatus(editor.t("status.log_ready", { count: String(gitLogState.commits.length) }));
673
- editor.debug("Git log panel opened");
674
- } else {
675
- gitLogState.splitId = null;
676
- editor.setStatus(editor.t("status.failed_open"));
677
- }
447
+ // `createBufferGroup` is not currently included in the generated
448
+ // `EditorAPI` type (it's a runtime-only binding, same as in audit_mode),
449
+ // so we cast to `any` to keep the type checker happy.
450
+ const group = await (editor as any).createBufferGroup(
451
+ "*Git Log*",
452
+ "git-log",
453
+ GROUP_LAYOUT
454
+ );
455
+ state.groupId = group.groupId as number;
456
+ state.logBufferId = (group.panels["log"] as number | undefined) ?? null;
457
+ state.detailBufferId = (group.panels["detail"] as number | undefined) ?? null;
458
+ state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null;
459
+ state.selectedIndex = 0;
460
+ state.detailCache = null;
461
+ state.isOpen = true;
462
+
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.
477
+ }
478
+
479
+ renderToolbar();
480
+ 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
+ }
486
+ await refreshDetail();
487
+
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
+ editor.on("resize", "on_git_log_resize");
494
+ editor.on("buffer_closed", "on_git_log_buffer_closed");
495
+
496
+ editor.setStatus(
497
+ editor.t("status.log_ready", { count: String(state.commits.length) })
498
+ );
678
499
  }
679
500
  registerHandler("show_git_log", show_git_log);
680
501
 
681
- function git_log_close() : void {
682
- if (!gitLogState.isOpen) {
683
- return;
684
- }
685
-
686
- // Restore the original buffer in the split
687
- if (gitLogState.splitId !== null && gitLogState.sourceBufferId !== null) {
688
- editor.setSplitBuffer(gitLogState.splitId, gitLogState.sourceBufferId);
689
- }
502
+ /** Reset all state + unsubscribe. Idempotent; safe to call from either
503
+ * path (user-initiated close or externally-closed group via the tab's
504
+ * close button, which triggers `buffer_closed`). */
505
+ function git_log_cleanup(): void {
506
+ 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
+ editor.off("resize", "on_git_log_resize");
510
+ editor.off("buffer_closed", "on_git_log_buffer_closed");
511
+ state.isOpen = false;
512
+ state.groupId = null;
513
+ state.logBufferId = null;
514
+ state.detailBufferId = null;
515
+ state.toolbarBufferId = null;
516
+ state.toolbarButtons = [];
517
+ state.commits = [];
518
+ state.selectedIndex = 0;
519
+ state.detailCache = null;
520
+ }
690
521
 
691
- // Close the git log buffer (it's no longer displayed)
692
- if (gitLogState.bufferId !== null) {
693
- editor.closeBuffer(gitLogState.bufferId);
522
+ function git_log_close(): void {
523
+ if (!state.isOpen) return;
524
+ const groupId = state.groupId;
525
+ git_log_cleanup();
526
+ if (groupId !== null) {
527
+ editor.closeBufferGroup(groupId);
694
528
  }
695
-
696
- gitLogState.isOpen = false;
697
- gitLogState.bufferId = null;
698
- gitLogState.splitId = null;
699
- gitLogState.sourceBufferId = null;
700
- gitLogState.commits = [];
701
529
  editor.setStatus(editor.t("status.closed"));
702
530
  }
703
531
  registerHandler("git_log_close", git_log_close);
704
532
 
705
- // Cursor moved handler for git log - update highlighting and status
706
- function on_git_log_cursor_moved(data: {
707
- buffer_id: number;
708
- cursor_id: number;
709
- old_position: number;
710
- new_position: number;
711
- }): void {
712
- // Only handle cursor movement in our git log buffer
713
- if (gitLogState.bufferId === null || data.buffer_id !== gitLogState.bufferId) {
714
- return;
715
- }
716
-
717
- // Re-apply highlighting to update cursor line highlight
718
- applyGitLogHighlighting();
719
-
720
- // Get cursor line to show status
721
- const cursorLine = editor.getCursorLine();
722
- const headerLines = 1;
723
- const commitIndex = cursorLine - headerLines;
724
-
725
- if (commitIndex >= 0 && commitIndex < gitLogState.commits.length) {
726
- editor.setStatus(editor.t("status.commit_position", { current: String(commitIndex + 1), total: String(gitLogState.commits.length) }));
533
+ function on_git_log_buffer_closed(data: { buffer_id: number }): void {
534
+ if (!state.isOpen) return;
535
+ if (
536
+ data.buffer_id === state.logBufferId ||
537
+ data.buffer_id === state.detailBufferId ||
538
+ data.buffer_id === state.toolbarBufferId
539
+ ) {
540
+ git_log_cleanup();
727
541
  }
728
542
  }
729
- registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved);
730
-
731
- // Register cursor movement handler
732
- editor.on("cursor_moved", "on_git_log_cursor_moved");
733
-
734
- async function git_log_refresh() : Promise<void> {
735
- if (!gitLogState.isOpen) return;
543
+ registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed);
736
544
 
545
+ async function git_log_refresh(): Promise<void> {
546
+ if (!state.isOpen) return;
737
547
  editor.setStatus(editor.t("status.refreshing"));
738
- gitLogState.commits = await fetchGitLog();
739
- updateGitLogView();
740
- editor.setStatus(editor.t("status.refreshed", { count: String(gitLogState.commits.length) }));
548
+ state.commits = await fetchGitLog(editor);
549
+ state.detailCache = null;
550
+ if (state.selectedIndex >= state.commits.length) {
551
+ state.selectedIndex = Math.max(0, state.commits.length - 1);
552
+ }
553
+ renderLog();
554
+ await refreshDetail();
555
+ editor.setStatus(
556
+ editor.t("status.refreshed", { count: String(state.commits.length) })
557
+ );
741
558
  }
742
559
  registerHandler("git_log_refresh", git_log_refresh);
743
560
 
744
- // Helper function to get commit at current cursor position
745
- function getCommitAtCursor(): GitCommit | null {
746
- if (gitLogState.bufferId === null) return null;
747
-
748
- // Use text properties to find which commit the cursor is on
749
- // This is more reliable than line number calculation
750
- const props = editor.getTextPropertiesAtCursor(gitLogState.bufferId);
751
-
752
- if (props.length > 0) {
753
- const prop = props[0];
754
- // Check if cursor is on a commit line (has type "commit" and index)
755
- if (prop.type === "commit" && typeof prop.index === "number") {
756
- const index = prop.index as number;
757
- if (index >= 0 && index < gitLogState.commits.length) {
758
- return gitLogState.commits[index];
759
- }
760
- }
761
- // Also support finding commit by hash (alternative lookup)
762
- if (prop.hash && typeof prop.hash === "string") {
763
- return gitLogState.commits.find(c => c.hash === prop.hash) || null;
764
- }
765
- }
766
-
767
- return null;
768
- }
769
-
770
- async function git_log_show_commit() : Promise<void> {
771
- if (!gitLogState.isOpen || gitLogState.commits.length === 0) return;
772
- if (gitLogState.splitId === null) return;
773
-
774
- const commit = getCommitAtCursor();
561
+ function git_log_copy_hash(): void {
562
+ const commit = selectedCommit();
775
563
  if (!commit) {
776
564
  editor.setStatus(editor.t("status.move_to_commit"));
777
565
  return;
778
566
  }
567
+ editor.copyToClipboard(commit.hash);
568
+ editor.setStatus(
569
+ editor.t("status.hash_copied", {
570
+ short: commit.shortHash,
571
+ full: commit.hash,
572
+ })
573
+ );
574
+ }
575
+ registerHandler("git_log_copy_hash", git_log_copy_hash);
779
576
 
780
- editor.setStatus(editor.t("status.loading_commit", { hash: commit.shortHash }));
781
-
782
- // Fetch full commit info using git show (includes header and diff)
783
- const showOutput = await fetchCommitDiff(commit.hash);
784
-
785
- // Build entries using raw git show output
786
- const entries = buildCommitDetailEntries(commit, showOutput);
787
-
788
- // Cache content for highlighting (getBufferText doesn't work for virtual buffers)
789
- commitDetailState.cachedContent = entriesToContent(entries);
790
-
791
- // Create virtual buffer in the current split (replacing git log view)
792
- const result = await editor.createVirtualBufferInExistingSplit({
793
- name: `*Commit: ${commit.shortHash}*`,
794
- mode: "git-commit-detail",
795
- readOnly: true,
796
- entries: entries,
797
- splitId: gitLogState.splitId!,
798
- showLineNumbers: false, // Disable line numbers for cleaner diff view
799
- showCursors: true,
800
- editingDisabled: true,
801
- });
802
-
803
- if (result !== null) {
804
- commitDetailState.isOpen = true;
805
- commitDetailState.bufferId = result.bufferId;
806
- commitDetailState.splitId = gitLogState.splitId;
807
- commitDetailState.commit = commit;
808
-
809
- // Apply syntax highlighting
810
- applyCommitDetailHighlighting();
577
+ /** Is the detail panel the currently-focused buffer? */
578
+ function isDetailFocused(): boolean {
579
+ return (
580
+ state.detailBufferId !== null &&
581
+ editor.getActiveBufferId() === state.detailBufferId
582
+ );
583
+ }
811
584
 
812
- editor.setStatus(editor.t("status.commit_ready", { hash: commit.shortHash }));
585
+ function git_log_tab(): void {
586
+ if (state.groupId === null) return;
587
+ if (isDetailFocused()) {
588
+ editor.focusBufferGroupPanel(state.groupId, "log");
813
589
  } else {
814
- editor.setStatus(editor.t("status.failed_open_details"));
590
+ editor.focusBufferGroupPanel(state.groupId, "detail");
591
+ const commit = selectedCommit();
592
+ if (commit) editor.setStatus(detailFooter(commit.shortHash));
815
593
  }
816
594
  }
817
- registerHandler("git_log_show_commit", git_log_show_commit);
595
+ registerHandler("git_log_tab", git_log_tab);
818
596
 
819
- function git_log_copy_hash() : void {
820
- if (!gitLogState.isOpen || gitLogState.commits.length === 0) return;
821
-
822
- const commit = getCommitAtCursor();
823
- if (!commit) {
824
- editor.setStatus(editor.t("status.move_to_commit"));
597
+ /**
598
+ * Enter: on the log panel jumps focus into the detail panel; on the detail
599
+ * panel opens the file at the cursor position (if any).
600
+ */
601
+ function git_log_enter(): void {
602
+ if (state.groupId === null) return;
603
+ if (isDetailFocused()) {
604
+ git_log_detail_open_file();
825
605
  return;
826
606
  }
607
+ editor.focusBufferGroupPanel(state.groupId, "detail");
608
+ const commit = selectedCommit();
609
+ if (commit) editor.setStatus(detailFooter(commit.shortHash));
610
+ }
611
+ registerHandler("git_log_enter", git_log_enter);
827
612
 
828
- // Copy hash to clipboard
829
- editor.copyToClipboard(commit.hash);
830
- editor.setStatus(editor.t("status.hash_copied", { short: commit.shortHash, full: commit.hash }));
613
+ /** q/Escape: closes the entire log group from any panel. */
614
+ function git_log_q(): void {
615
+ if (state.groupId === null) return;
616
+ git_log_close();
831
617
  }
832
- registerHandler("git_log_copy_hash", git_log_copy_hash);
618
+ registerHandler("git_log_q", git_log_q);
833
619
 
834
620
  // =============================================================================
835
- // Public Commands - Commit Detail
621
+ // Detail panel open file at commit
836
622
  // =============================================================================
837
623
 
838
- function git_commit_detail_close() : void {
839
- if (!commitDetailState.isOpen) {
840
- return;
841
- }
842
-
843
- // Go back to the git log view by restoring the git log buffer
844
- if (commitDetailState.splitId !== null && gitLogState.bufferId !== null) {
845
- editor.setSplitBuffer(commitDetailState.splitId, gitLogState.bufferId);
846
- // Re-apply highlighting since we're switching back
847
- applyGitLogHighlighting();
848
- }
849
-
850
- // Close the commit detail buffer (it's no longer displayed)
851
- if (commitDetailState.bufferId !== null) {
852
- editor.closeBuffer(commitDetailState.bufferId);
853
- }
854
-
855
- commitDetailState.isOpen = false;
856
- commitDetailState.bufferId = null;
857
- commitDetailState.splitId = null;
858
- commitDetailState.commit = null;
859
-
860
- editor.setStatus(editor.t("status.log_ready", { count: String(gitLogState.commits.length) }));
861
- }
862
- registerHandler("git_commit_detail_close", git_commit_detail_close);
624
+ async function git_log_detail_open_file(): Promise<void> {
625
+ if (state.detailBufferId === null) return;
626
+ const commit = selectedCommit();
627
+ if (!commit) return;
863
628
 
864
- // Close file view and go back to commit detail
865
- function git_file_view_close() : void {
866
- if (!fileViewState.isOpen) {
629
+ const props = editor.getTextPropertiesAtCursor(state.detailBufferId);
630
+ if (props.length === 0) {
631
+ editor.setStatus(editor.t("status.move_to_diff"));
867
632
  return;
868
633
  }
869
-
870
- // Go back to the commit detail view by restoring the commit detail buffer
871
- if (fileViewState.splitId !== null && commitDetailState.bufferId !== null) {
872
- editor.setSplitBuffer(fileViewState.splitId, commitDetailState.bufferId);
873
- // Re-apply highlighting since we're switching back
874
- applyCommitDetailHighlighting();
875
- }
876
-
877
- // Close the file view buffer (it's no longer displayed)
878
- if (fileViewState.bufferId !== null) {
879
- editor.closeBuffer(fileViewState.bufferId);
880
- }
881
-
882
- fileViewState.isOpen = false;
883
- fileViewState.bufferId = null;
884
- fileViewState.splitId = null;
885
- fileViewState.filePath = null;
886
- fileViewState.commitHash = null;
887
-
888
- if (commitDetailState.commit) {
889
- editor.setStatus(editor.t("status.commit_ready", { hash: commitDetailState.commit.shortHash }));
634
+ const file = props[0].file as string | undefined;
635
+ const line = (props[0].line as number | undefined) ?? 1;
636
+ if (!file) {
637
+ editor.setStatus(editor.t("status.move_to_diff_with_context"));
638
+ return;
890
639
  }
891
- }
892
- registerHandler("git_file_view_close", git_file_view_close);
893
640
 
894
- // Fetch file content at a specific commit
895
- async function fetchFileAtCommit(commitHash: string, filePath: string): Promise<string | null> {
896
- const cwd = editor.getCwd();
641
+ editor.setStatus(
642
+ editor.t("status.file_loading", { file, hash: commit.shortHash })
643
+ );
897
644
  const result = await editor.spawnProcess("git", [
898
645
  "show",
899
- `${commitHash}:${filePath}`,
900
- ], cwd);
901
-
646
+ `${commit.hash}:${file}`,
647
+ ]);
902
648
  if (result.exit_code !== 0) {
903
- return null;
649
+ editor.setStatus(
650
+ editor.t("status.file_not_found", { file, hash: commit.shortHash })
651
+ );
652
+ return;
904
653
  }
905
654
 
906
- return result.stdout;
907
- }
655
+ const lines = result.stdout.split("\n");
656
+ const entries: TextPropertyEntry[] = lines.map((l, i) => ({
657
+ text: l + (i < lines.length - 1 ? "\n" : ""),
658
+ properties: { type: "content", line: i + 1 },
659
+ }));
908
660
 
909
- // Get language type from file extension
910
- function getLanguageFromPath(filePath: string): string {
911
- const ext = editor.pathExtname(filePath).toLowerCase();
912
- const extMap: Record<string, string> = {
913
- ".rs": "rust",
914
- ".ts": "typescript",
915
- ".tsx": "typescript",
916
- ".js": "javascript",
917
- ".jsx": "javascript",
918
- ".py": "python",
919
- ".go": "go",
920
- ".c": "c",
921
- ".cpp": "cpp",
922
- ".h": "c",
923
- ".hpp": "cpp",
924
- ".java": "java",
925
- ".rb": "ruby",
926
- ".sh": "shell",
927
- ".bash": "shell",
928
- ".zsh": "shell",
929
- ".toml": "toml",
930
- ".yaml": "yaml",
931
- ".yml": "yaml",
932
- ".json": "json",
933
- ".md": "markdown",
934
- ".css": "css",
935
- ".html": "html",
936
- ".xml": "xml",
937
- };
938
- return extMap[ext] || "text";
661
+ // `*<hash>:<path>*` matches the virtual-name convention the host uses
662
+ // to detect syntax from the trailing filename's extension.
663
+ const name = `*${commit.shortHash}:${file}*`;
664
+ const view = await editor.createVirtualBuffer({
665
+ name,
666
+ mode: "git-log-file-view",
667
+ readOnly: true,
668
+ editingDisabled: true,
669
+ showLineNumbers: true,
670
+ entries,
671
+ });
672
+ if (view) {
673
+ const byte = await editor.getLineStartPosition(Math.max(0, line - 1));
674
+ if (byte !== null) editor.setBufferCursor(view.bufferId, byte);
675
+ editor.setStatus(
676
+ editor.t("status.file_view_ready", {
677
+ file,
678
+ hash: commit.shortHash,
679
+ line: String(line),
680
+ })
681
+ );
682
+ } else {
683
+ editor.setStatus(editor.t("status.failed_open_file", { file }));
684
+ }
939
685
  }
686
+ registerHandler("git_log_detail_open_file", git_log_detail_open_file);
940
687
 
941
- // Keywords for different languages
942
- const languageKeywords: Record<string, string[]> = {
943
- rust: ["fn", "let", "mut", "const", "pub", "use", "mod", "struct", "enum", "impl", "trait", "for", "while", "loop", "if", "else", "match", "return", "async", "await", "move", "self", "Self", "super", "crate", "where", "type", "static", "unsafe", "extern", "ref", "dyn", "as", "in", "true", "false"],
944
- typescript: ["function", "const", "let", "var", "class", "interface", "type", "extends", "implements", "import", "export", "from", "async", "await", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "new", "this", "super", "null", "undefined", "true", "false", "try", "catch", "finally", "throw", "typeof", "instanceof", "void", "delete", "in", "of", "static", "readonly", "private", "public", "protected", "abstract", "enum"],
945
- javascript: ["function", "const", "let", "var", "class", "extends", "import", "export", "from", "async", "await", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "new", "this", "super", "null", "undefined", "true", "false", "try", "catch", "finally", "throw", "typeof", "instanceof", "void", "delete", "in", "of", "static"],
946
- python: ["def", "class", "if", "elif", "else", "for", "while", "try", "except", "finally", "with", "as", "import", "from", "return", "yield", "raise", "pass", "break", "continue", "and", "or", "not", "in", "is", "lambda", "None", "True", "False", "global", "nonlocal", "async", "await", "self"],
947
- go: ["func", "var", "const", "type", "struct", "interface", "map", "chan", "if", "else", "for", "range", "switch", "case", "default", "break", "continue", "return", "go", "defer", "select", "import", "package", "nil", "true", "false", "make", "new", "len", "cap", "append", "copy", "delete", "panic", "recover"],
948
- };
949
-
950
- // Apply basic syntax highlighting to file view
951
- function applyFileViewHighlighting(bufferId: number, content: string, filePath: string): void {
952
- const language = getLanguageFromPath(filePath);
953
- const keywords = languageKeywords[language] || [];
954
- const lines = content.split("\n");
955
-
956
- // Clear existing overlays
957
- editor.clearNamespace(bufferId, "syntax");
958
-
959
- let byteOffset = 0;
960
- let inMultilineComment = false;
961
- let inMultilineString = false;
962
-
963
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
964
- const line = lines[lineIdx];
965
- const lineStart = byteOffset;
966
-
967
- // Skip empty lines
968
- if (line.trim() === "") {
969
- byteOffset += line.length + 1;
970
- continue;
971
- }
972
-
973
- // Check for multiline comment start/end
974
- if (language === "rust" || language === "c" || language === "cpp" || language === "java" || language === "javascript" || language === "typescript" || language === "go") {
975
- if (line.includes("/*") && !line.includes("*/")) {
976
- inMultilineComment = true;
977
- }
978
- if (inMultilineComment) {
979
- editor.addOverlay(bufferId, "syntax", lineStart, lineStart + line.length, {
980
- fg: colors.syntaxComment,
981
- italic: true,
982
- });
983
- if (line.includes("*/")) {
984
- inMultilineComment = false;
985
- }
986
- byteOffset += line.length + 1;
987
- continue;
988
- }
989
- }
990
-
991
- // Python multiline strings
992
- if (language === "python" && (line.includes('"""') || line.includes("'''"))) {
993
- const tripleQuote = line.includes('"""') ? '"""' : "'''";
994
- const firstIdx = line.indexOf(tripleQuote);
995
- const secondIdx = line.indexOf(tripleQuote, firstIdx + 3);
996
- if (firstIdx >= 0 && secondIdx < 0) {
997
- inMultilineString = !inMultilineString;
998
- }
999
- }
1000
- if (inMultilineString) {
1001
- editor.addOverlay(bufferId, "syntax", lineStart, lineStart + line.length, {
1002
- fg: colors.syntaxString,
1003
- });
1004
- byteOffset += line.length + 1;
1005
- continue;
1006
- }
1007
-
1008
- // Single-line comment detection
1009
- let commentStart = -1;
1010
- if (language === "rust" || language === "c" || language === "cpp" || language === "java" || language === "javascript" || language === "typescript" || language === "go") {
1011
- commentStart = line.indexOf("//");
1012
- } else if (language === "python" || language === "shell" || language === "ruby" || language === "yaml" || language === "toml") {
1013
- commentStart = line.indexOf("#");
1014
- }
1015
-
1016
- if (commentStart >= 0) {
1017
- editor.addOverlay(bufferId, "syntax", lineStart + commentStart, lineStart + line.length, {
1018
- fg: colors.syntaxComment,
1019
- italic: true,
1020
- });
1021
- }
1022
-
1023
- // String highlighting (simple: find "..." and '...')
1024
- let i = 0;
1025
- while (i < line.length) {
1026
- const ch = line[i];
1027
- if (ch === '"' || ch === "'") {
1028
- const quote = ch;
1029
- const start = i;
1030
- i++;
1031
- while (i < line.length && line[i] !== quote) {
1032
- if (line[i] === '\\') i++; // Skip escaped chars
1033
- i++;
1034
- }
1035
- if (i < line.length) i++; // Include closing quote
1036
- const end = i;
1037
- if (commentStart < 0 || start < commentStart) {
1038
- editor.addOverlay(bufferId, "syntax", lineStart + start, lineStart + end, {
1039
- fg: colors.syntaxString,
1040
- });
1041
- }
1042
- } else {
1043
- i++;
1044
- }
1045
- }
1046
-
1047
- // Keyword highlighting
1048
- for (const keyword of keywords) {
1049
- const regex = new RegExp(`\\b${keyword}\\b`, "g");
1050
- let match: RegExpExecArray | null;
1051
- while ((match = regex.exec(line)) !== null) {
1052
- const kwStart = match.index;
1053
- const kwEnd = kwStart + keyword.length;
1054
- // Don't highlight if inside comment
1055
- if (commentStart < 0 || kwStart < commentStart) {
1056
- editor.addOverlay(bufferId, "syntax", lineStart + kwStart, lineStart + kwEnd, {
1057
- fg: colors.syntaxKeyword,
1058
- bold: true,
1059
- });
1060
- }
1061
- }
1062
- }
1063
-
1064
- // Number highlighting
1065
- const numberRegex = /\b\d+(\.\d+)?\b/g;
1066
- let numMatch: RegExpExecArray | null;
1067
- while ((numMatch = numberRegex.exec(line)) !== null) {
1068
- const numStart = numMatch.index;
1069
- const numEnd = numStart + numMatch[0].length;
1070
- if (commentStart < 0 || numStart < commentStart) {
1071
- editor.addOverlay(bufferId, "syntax", lineStart + numStart, lineStart + numEnd, {
1072
- fg: colors.syntaxNumber,
1073
- });
1074
- }
1075
- }
688
+ // File-view mode so `q` closes the tab and returns to the group.
689
+ editor.defineMode(
690
+ "git-log-file-view",
691
+ [
692
+ ["q", "git_log_file_view_close"],
693
+ ["Escape", "git_log_file_view_close"],
694
+ ],
695
+ true
696
+ );
1076
697
 
1077
- byteOffset += line.length + 1;
1078
- }
698
+ function git_log_file_view_close(): void {
699
+ const id = editor.getActiveBufferId();
700
+ if (id) editor.closeBuffer(id);
1079
701
  }
702
+ registerHandler("git_log_file_view_close", git_log_file_view_close);
1080
703
 
1081
- // Open file at the current diff line position - shows file as it was at that commit
1082
- async function git_commit_detail_open_file() : Promise<void> {
1083
- if (!commitDetailState.isOpen || commitDetailState.bufferId === null) {
1084
- return;
1085
- }
1086
-
1087
- const commit = commitDetailState.commit;
1088
- if (!commit) {
1089
- editor.setStatus(editor.t("status.move_to_commit"));
1090
- return;
1091
- }
704
+ // =============================================================================
705
+ // Cursor tracking live-update the detail panel as the user scrolls through
706
+ // the commit list.
707
+ // =============================================================================
1092
708
 
1093
- // Get text properties at cursor position to find file/line info
1094
- const props = editor.getTextPropertiesAtCursor(commitDetailState.bufferId);
1095
-
1096
- if (props.length > 0) {
1097
- const file = props[0].file as string | undefined;
1098
- const line = props[0].line as number | undefined;
1099
-
1100
- if (file) {
1101
- editor.setStatus(editor.t("status.file_loading", { file, hash: commit.shortHash }));
1102
-
1103
- // Fetch file content at this commit
1104
- const content = await fetchFileAtCommit(commit.hash, file);
1105
-
1106
- if (content === null) {
1107
- editor.setStatus(editor.t("status.file_not_found", { file, hash: commit.shortHash }));
1108
- return;
1109
- }
1110
-
1111
- // Build entries for the virtual buffer - one entry per line for proper line tracking
1112
- const lines = content.split("\n");
1113
- const entries: TextPropertyEntry[] = [];
1114
-
1115
- for (let i = 0; i < lines.length; i++) {
1116
- entries.push({
1117
- text: lines[i] + (i < lines.length - 1 ? "\n" : ""),
1118
- properties: { type: "content", line: i + 1 },
1119
- });
1120
- }
1121
-
1122
- // Create a read-only virtual buffer with the file content
1123
- const result = await editor.createVirtualBufferInExistingSplit({
1124
- name: `${file} @ ${commit.shortHash}`,
1125
- mode: "git-file-view",
1126
- readOnly: true,
1127
- entries: entries,
1128
- splitId: commitDetailState.splitId!,
1129
- showLineNumbers: true,
1130
- showCursors: true,
1131
- editingDisabled: true,
1132
- });
1133
-
1134
- if (result !== null) {
1135
- // Track file view state so we can navigate back
1136
- fileViewState.isOpen = true;
1137
- fileViewState.bufferId = result.bufferId;
1138
- fileViewState.splitId = commitDetailState.splitId;
1139
- fileViewState.filePath = file;
1140
- fileViewState.commitHash = commit.hash;
1141
-
1142
- // Apply syntax highlighting based on file type
1143
- applyFileViewHighlighting(result.bufferId, content, file);
1144
-
1145
- const targetLine = line || 1;
1146
- editor.setStatus(editor.t("status.file_view_ready", { file, hash: commit.shortHash, line: String(targetLine) }));
1147
- } else {
1148
- editor.setStatus(editor.t("status.failed_open_file", { file }));
1149
- }
1150
- } else {
1151
- editor.setStatus(editor.t("status.move_to_diff_with_context"));
1152
- }
1153
- } else {
1154
- editor.setStatus(editor.t("status.move_to_diff"));
1155
- }
709
+ async function on_git_log_cursor_moved(data: {
710
+ buffer_id: number;
711
+ cursor_id: number;
712
+ old_position: number;
713
+ new_position: number;
714
+ }): Promise<void> {
715
+ if (!state.isOpen) return;
716
+ // Only react to movement inside the log panel.
717
+ if (data.buffer_id !== state.logBufferId) return;
718
+
719
+ // Map the cursor's byte offset to a commit index via the row-offset
720
+ // table built in `renderLog`. This avoids relying on `getCursorLine`
721
+ // which is not implemented for virtual buffers.
722
+ const idx = indexFromCursorByte(data.new_position);
723
+ if (idx === state.selectedIndex) return;
724
+ state.selectedIndex = idx;
725
+
726
+ // Immediate feedback: update the log panel's selection highlight and
727
+ // either show the cached detail or a "loading" placeholder. Only the
728
+ // actual `git show` spawn is debounced below, so a burst of j/k events
729
+ // still feels responsive even though we collapse the fetches into one.
730
+ renderLog();
731
+ const pending = refreshDetailImmediate();
732
+
733
+ const commit = state.commits[state.selectedIndex];
734
+ if (commit) {
735
+ editor.setStatus(
736
+ editor.t("status.commit_position", {
737
+ current: String(state.selectedIndex + 1),
738
+ total: String(state.commits.length),
739
+ })
740
+ );
741
+ }
742
+
743
+ if (!pending) return;
744
+
745
+ // Debounce: bump the token, wait a beat, bail if a newer event has
746
+ // arrived. `git show` is expensive; a burst of cursor events (held
747
+ // j/k, PageDown) must collapse to one spawn.
748
+ const myId = ++state.pendingCursorMoveId;
749
+ await editor.delay(CURSOR_DEBOUNCE_MS);
750
+ if (myId !== state.pendingCursorMoveId) return;
751
+ if (!state.isOpen) return;
752
+ await fetchAndRenderDetail(pending);
1156
753
  }
1157
- registerHandler("git_commit_detail_open_file", git_commit_detail_open_file);
754
+ registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved);
1158
755
 
1159
756
  // =============================================================================
1160
- // Command Registration
757
+ // Command registration
1161
758
  // =============================================================================
1162
759
 
1163
760
  editor.registerCommand(
@@ -1166,14 +763,12 @@ editor.registerCommand(
1166
763
  "show_git_log",
1167
764
  null
1168
765
  );
1169
-
1170
766
  editor.registerCommand(
1171
767
  "%cmd.git_log_close",
1172
768
  "%cmd.git_log_close_desc",
1173
769
  "git_log_close",
1174
770
  null
1175
771
  );
1176
-
1177
772
  editor.registerCommand(
1178
773
  "%cmd.git_log_refresh",
1179
774
  "%cmd.git_log_refresh_desc",
@@ -1181,8 +776,4 @@ editor.registerCommand(
1181
776
  null
1182
777
  );
1183
778
 
1184
- // =============================================================================
1185
- // Plugin Initialization
1186
- // =============================================================================
1187
-
1188
- editor.debug("Git Log plugin initialized - Use 'Git Log' command to open");
779
+ editor.debug("Git Log plugin initialized (modern buffer-group layout)");