@fresh-editor/fresh-editor 0.2.23 → 0.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +497 -119
- package/plugins/audit_mode.ts +2568 -551
- package/plugins/config-schema.json +7 -1
- package/plugins/git_blame.ts +1 -6
- package/plugins/git_log.ts +616 -1025
- package/plugins/lib/fresh.d.ts +76 -4
- package/plugins/lib/git_history.ts +596 -0
- package/plugins/markdown_compose.ts +183 -7
- package/plugins/search_replace.i18n.json +42 -14
- package/plugins/search_replace.ts +146 -96
- package/plugins/vi_mode.ts +8 -3
package/plugins/git_log.ts
CHANGED
|
@@ -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
|
|
15
|
+
* Git Log Plugin — Magit-style git history interface built on top of the
|
|
16
|
+
* modern plugin API primitives:
|
|
7
17
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
groupId: null,
|
|
71
|
+
logBufferId: null,
|
|
72
|
+
detailBufferId: null,
|
|
73
|
+
toolbarBufferId: null,
|
|
74
|
+
toolbarButtons: [],
|
|
74
75
|
commits: [],
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
132
|
-
//
|
|
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
|
-
["
|
|
137
|
-
["
|
|
138
|
-
["
|
|
139
|
-
["
|
|
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
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
//
|
|
178
|
+
// Toolbar
|
|
170
179
|
// =============================================================================
|
|
171
180
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
298
|
+
// Rendering
|
|
236
299
|
// =============================================================================
|
|
237
300
|
|
|
238
|
-
function
|
|
239
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
//
|
|
427
|
+
// Commands
|
|
626
428
|
// =============================================================================
|
|
627
429
|
|
|
628
|
-
async function show_git_log()
|
|
629
|
-
if (
|
|
630
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
editor.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
692
|
-
if (
|
|
693
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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("
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
745
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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.
|
|
590
|
+
editor.focusBufferGroupPanel(state.groupId, "detail");
|
|
591
|
+
const commit = selectedCommit();
|
|
592
|
+
if (commit) editor.setStatus(detailFooter(commit.shortHash));
|
|
815
593
|
}
|
|
816
594
|
}
|
|
817
|
-
registerHandler("
|
|
595
|
+
registerHandler("git_log_tab", git_log_tab);
|
|
818
596
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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("
|
|
618
|
+
registerHandler("git_log_q", git_log_q);
|
|
833
619
|
|
|
834
620
|
// =============================================================================
|
|
835
|
-
//
|
|
621
|
+
// Detail panel — open file at commit
|
|
836
622
|
// =============================================================================
|
|
837
623
|
|
|
838
|
-
function
|
|
839
|
-
if (
|
|
840
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
871
|
-
if (
|
|
872
|
-
editor.
|
|
873
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
`${
|
|
900
|
-
]
|
|
901
|
-
|
|
646
|
+
`${commit.hash}:${file}`,
|
|
647
|
+
]);
|
|
902
648
|
if (result.exit_code !== 0) {
|
|
903
|
-
|
|
649
|
+
editor.setStatus(
|
|
650
|
+
editor.t("status.file_not_found", { file, hash: commit.shortHash })
|
|
651
|
+
);
|
|
652
|
+
return;
|
|
904
653
|
}
|
|
905
654
|
|
|
906
|
-
|
|
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
|
-
//
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
".
|
|
932
|
-
|
|
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
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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("
|
|
754
|
+
registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved);
|
|
1158
755
|
|
|
1159
756
|
// =============================================================================
|
|
1160
|
-
// Command
|
|
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)");
|