@fresh-editor/fresh-editor 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +84 -0
- package/plugins/audit_mode.ts +139 -3
- package/plugins/config-schema.json +33 -3
- package/plugins/dashboard.ts +34 -111
- package/plugins/flash.ts +22 -4
- package/plugins/git_blame.ts +10 -6
- package/plugins/git_log.ts +705 -323
- package/plugins/git_statusbar.i18n.json +72 -0
- package/plugins/git_statusbar.ts +133 -0
- package/plugins/goto_with_selection.i18n.json +58 -0
- package/plugins/goto_with_selection.ts +17 -0
- package/plugins/lib/fresh.d.ts +911 -15
- package/plugins/lib/index.ts +34 -0
- package/plugins/lib/widgets.ts +903 -0
- package/plugins/live_diff.ts +442 -32
- package/plugins/merge_conflict.ts +89 -64
- package/plugins/orchestrator.ts +3425 -0
- package/plugins/pkg.ts +235 -54
- package/plugins/rust-lsp.ts +58 -40
- package/plugins/schemas/theme.schema.json +18 -0
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +1335 -515
- package/plugins/tab_actions.i18n.json +212 -0
- package/plugins/tab_actions.ts +76 -0
- package/plugins/theme_editor.i18n.json +112 -0
- package/plugins/theme_editor.ts +30 -5
- package/plugins/tsconfig.json +3 -0
- package/plugins/vi_mode.ts +49 -17
- package/themes/dark.json +1 -0
- package/themes/dracula.json +1 -0
- package/themes/high-contrast.json +1 -0
- package/themes/light.json +1 -0
- package/themes/nord.json +1 -0
- package/themes/nostalgia.json +1 -0
- package/themes/solarized-dark.json +1 -0
- package/themes/terminal.json +4 -0
package/plugins/git_log.ts
CHANGED
|
@@ -2,12 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type GitCommit,
|
|
5
|
-
buildCommitDetailEntries,
|
|
6
5
|
buildCommitLogEntries,
|
|
7
|
-
buildDetailPlaceholderEntries,
|
|
8
|
-
fetchCommitShow,
|
|
9
6
|
fetchGitLog,
|
|
10
7
|
} from "./lib/git_history.ts";
|
|
8
|
+
import { button, flexSpacer, key, list, row, WidgetPanel } from "./lib/index.ts";
|
|
11
9
|
|
|
12
10
|
const editor = getEditor();
|
|
13
11
|
|
|
@@ -35,90 +33,68 @@ interface GitLogState {
|
|
|
35
33
|
isOpen: boolean;
|
|
36
34
|
groupId: number | null;
|
|
37
35
|
logBufferId: number | null;
|
|
36
|
+
/**
|
|
37
|
+
* The buffer-group's initial detail panel buffer (a virtual buffer
|
|
38
|
+
* created by `createBufferGroup`). After the first commit is shown,
|
|
39
|
+
* the panel is retargeted at a file-backed streaming buffer via
|
|
40
|
+
* `setBufferGroupPanelBuffer`; this id is kept so we can close the
|
|
41
|
+
* orphaned virtual buffer on group teardown.
|
|
42
|
+
*/
|
|
43
|
+
initialDetailBufferId: number | null;
|
|
44
|
+
/**
|
|
45
|
+
* The buffer id currently displayed in the detail panel (one
|
|
46
|
+
* file-backed buffer per visited commit). Tracked for focus checks
|
|
47
|
+
* and cursor placement.
|
|
48
|
+
*/
|
|
38
49
|
detailBufferId: number | null;
|
|
39
50
|
toolbarBufferId: number | null;
|
|
40
|
-
/**
|
|
41
|
-
|
|
51
|
+
/** Widget panel rendering the toolbar (Row of Buttons). */
|
|
52
|
+
toolbarPanel: WidgetPanel | null;
|
|
53
|
+
/** Widget panel rendering the log (List of commit rows). */
|
|
54
|
+
logPanel: WidgetPanel | null;
|
|
42
55
|
commits: GitCommit[];
|
|
43
56
|
selectedIndex: number;
|
|
44
|
-
/** Cached `git show` output for the currently-displayed detail commit. */
|
|
45
|
-
detailCache: { hash: string; output: string } | null;
|
|
46
|
-
/**
|
|
47
|
-
* In-flight detail request id. Used to ignore stale responses when the
|
|
48
|
-
* user scrolls through the log faster than `git show` can return.
|
|
49
|
-
*/
|
|
50
|
-
pendingDetailId: number;
|
|
51
57
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
58
|
+
* Per-commit cache: sha → file-backed buffer id. Each visited
|
|
59
|
+
* commit gets its own buffer pointing at `<dataDir>/git-show/<sha>.diff`,
|
|
60
|
+
* which a background `git show --patch` writes into. Returning to a
|
|
61
|
+
* cached commit is just a `setBufferGroupPanelBuffer` call — no
|
|
62
|
+
* git invocation, scroll position preserved.
|
|
56
63
|
*/
|
|
57
|
-
|
|
64
|
+
commitBuffers: Map<string, number>;
|
|
65
|
+
/** sha → in-flight spawnProcess handle, for kill-on-supersession. */
|
|
66
|
+
inFlightSpawns: Map<string, ProcessHandle<SpawnResult>>;
|
|
58
67
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* buffers).
|
|
68
|
+
* Debounce token for List `select` events. Rapid selection moves
|
|
69
|
+
* (PageDown, held j/k) shouldn't churn through buffer swaps + spawns;
|
|
70
|
+
* we bump this id on every event and only do the work after a short
|
|
71
|
+
* delay if no newer event has arrived.
|
|
64
72
|
*/
|
|
65
|
-
|
|
73
|
+
pendingSelectId: number;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
const state: GitLogState = {
|
|
69
77
|
isOpen: false,
|
|
70
78
|
groupId: null,
|
|
71
79
|
logBufferId: null,
|
|
80
|
+
initialDetailBufferId: null,
|
|
72
81
|
detailBufferId: null,
|
|
73
82
|
toolbarBufferId: null,
|
|
74
|
-
|
|
83
|
+
toolbarPanel: null,
|
|
84
|
+
logPanel: null,
|
|
75
85
|
commits: [],
|
|
76
86
|
selectedIndex: 0,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
logRowByteOffsets: [],
|
|
87
|
+
commitBuffers: new Map(),
|
|
88
|
+
inFlightSpawns: new Map(),
|
|
89
|
+
pendingSelectId: 0,
|
|
81
90
|
};
|
|
82
91
|
|
|
83
92
|
/**
|
|
84
|
-
* Delay before
|
|
85
|
-
*
|
|
86
|
-
* that the detail panel still feels live.
|
|
87
|
-
*/
|
|
88
|
-
const CURSOR_DEBOUNCE_MS = 60;
|
|
89
|
-
|
|
90
|
-
// UTF-8 byte length — the overlay API expects byte offsets; JS strings are
|
|
91
|
-
// UTF-16. Matches the helper used by `lib/git_history.ts`.
|
|
92
|
-
function utf8Len(s: string): number {
|
|
93
|
-
let b = 0;
|
|
94
|
-
for (let i = 0; i < s.length; i++) {
|
|
95
|
-
const c = s.charCodeAt(i);
|
|
96
|
-
if (c <= 0x7f) b += 1;
|
|
97
|
-
else if (c <= 0x7ff) b += 2;
|
|
98
|
-
else if (c >= 0xd800 && c <= 0xdfff) {
|
|
99
|
-
b += 4;
|
|
100
|
-
i++;
|
|
101
|
-
} else b += 3;
|
|
102
|
-
}
|
|
103
|
-
return b;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Binary search `logRowByteOffsets` for the 0-indexed row whose byte
|
|
108
|
-
* offset is the largest one ≤ `bytePos`. Returns 0 on an empty table.
|
|
93
|
+
* Delay before spawning `git show` after a List `select` event. Long
|
|
94
|
+
* enough to collapse a burst (held j/k or PageDown) into one fetch,
|
|
95
|
+
* short enough that the detail panel still feels live.
|
|
109
96
|
*/
|
|
110
|
-
|
|
111
|
-
const offs = state.logRowByteOffsets;
|
|
112
|
-
if (offs.length === 0) return 0;
|
|
113
|
-
let lo = 0;
|
|
114
|
-
let hi = offs.length - 1;
|
|
115
|
-
while (lo < hi) {
|
|
116
|
-
const mid = (lo + hi + 1) >> 1;
|
|
117
|
-
if (offs[mid] <= bytePos) lo = mid;
|
|
118
|
-
else hi = mid - 1;
|
|
119
|
-
}
|
|
120
|
-
return lo;
|
|
121
|
-
}
|
|
97
|
+
const SELECT_DEBOUNCE_MS = 60;
|
|
122
98
|
|
|
123
99
|
// =============================================================================
|
|
124
100
|
// Modes
|
|
@@ -129,15 +105,20 @@ function rowFromByte(bytePos: number): number {
|
|
|
129
105
|
// the log, and opens the file at the cursor when pressed in the detail).
|
|
130
106
|
// =============================================================================
|
|
131
107
|
|
|
132
|
-
// j/k
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
108
|
+
// j/k/Up/Down/PageUp/PageDown route to the log List widget so the host
|
|
109
|
+
// owns selection + scroll + auto-scroll. The List's `select` event then
|
|
110
|
+
// fires back into the plugin's `widget_event` handler for detail-pane
|
|
111
|
+
// refresh. Other plugin actions (q/r/y/Tab/Return) stay as direct
|
|
112
|
+
// bindings — they don't depend on which row is highlighted.
|
|
136
113
|
editor.defineMode(
|
|
137
114
|
"git-log",
|
|
138
115
|
[
|
|
139
|
-
["k", "
|
|
140
|
-
["j", "
|
|
116
|
+
["k", "git_log_select_up"],
|
|
117
|
+
["j", "git_log_select_down"],
|
|
118
|
+
["Up", "git_log_select_up"],
|
|
119
|
+
["Down", "git_log_select_down"],
|
|
120
|
+
["PageUp", "git_log_select_page_up"],
|
|
121
|
+
["PageDown", "git_log_select_page_down"],
|
|
141
122
|
["Return", "git_log_enter"],
|
|
142
123
|
["Tab", "git_log_tab"],
|
|
143
124
|
["q", "git_log_q"],
|
|
@@ -149,6 +130,52 @@ editor.defineMode(
|
|
|
149
130
|
true, // inherit Normal-context bindings for unbound keys
|
|
150
131
|
);
|
|
151
132
|
|
|
133
|
+
function git_log_select_up(): void {
|
|
134
|
+
if (isLogPanelActive()) {
|
|
135
|
+
state.logPanel?.command(key("Up"));
|
|
136
|
+
} else {
|
|
137
|
+
editor.executeAction("move_up");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function git_log_select_down(): void {
|
|
141
|
+
if (isLogPanelActive()) {
|
|
142
|
+
state.logPanel?.command(key("Down"));
|
|
143
|
+
} else {
|
|
144
|
+
editor.executeAction("move_down");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function git_log_select_page_up(): void {
|
|
148
|
+
if (isLogPanelActive()) {
|
|
149
|
+
state.logPanel?.command(key("PageUp"));
|
|
150
|
+
} else {
|
|
151
|
+
editor.executeAction("move_page_up");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function git_log_select_page_down(): void {
|
|
155
|
+
if (isLogPanelActive()) {
|
|
156
|
+
state.logPanel?.command(key("PageDown"));
|
|
157
|
+
} else {
|
|
158
|
+
editor.executeAction("move_page_down");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True iff the log panel is the focused buffer in the group. The
|
|
163
|
+
* group's bindings (j/k/Up/Down/PageUp/PageDown) apply to all panels
|
|
164
|
+
* uniformly; we only want navigation to drive the List widget when
|
|
165
|
+
* the user is *on* the log panel. From the detail panel, the same
|
|
166
|
+
* keys must move the buffer cursor (so users can scroll the diff
|
|
167
|
+
* before pressing Enter on a diff line to open the file view). */
|
|
168
|
+
function isLogPanelActive(): boolean {
|
|
169
|
+
return (
|
|
170
|
+
state.logBufferId !== null &&
|
|
171
|
+
editor.getActiveBufferId() === state.logBufferId
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
registerHandler("git_log_select_up", git_log_select_up);
|
|
175
|
+
registerHandler("git_log_select_down", git_log_select_down);
|
|
176
|
+
registerHandler("git_log_select_page_up", git_log_select_page_up);
|
|
177
|
+
registerHandler("git_log_select_page_down", git_log_select_page_down);
|
|
178
|
+
|
|
152
179
|
// =============================================================================
|
|
153
180
|
// Panel layout
|
|
154
181
|
// =============================================================================
|
|
@@ -177,116 +204,87 @@ const GROUP_LAYOUT = JSON.stringify({
|
|
|
177
204
|
// =============================================================================
|
|
178
205
|
// Toolbar
|
|
179
206
|
// =============================================================================
|
|
207
|
+
//
|
|
208
|
+
// The toolbar is a one-row panel mounted above the log/detail split. It's
|
|
209
|
+
// rendered through the widget runtime — a `Row` of `Button` widgets — so
|
|
210
|
+
// the host owns hit-testing, focus styling, and keystroke dispatch, and the
|
|
211
|
+
// plugin only handles the resulting `widget_event` actions.
|
|
212
|
+
//
|
|
213
|
+
// Each button's `key` is a stable identifier (`toolbar.tab`, `toolbar.q`,
|
|
214
|
+
// etc.) that `widget_event` carries back so the plugin can look up the
|
|
215
|
+
// right handler without per-row column arithmetic. The previous custom
|
|
216
|
+
// hit-region tracking (`state.toolbarButtons`, `on_git_log_toolbar_click`)
|
|
217
|
+
// is gone.
|
|
180
218
|
|
|
181
|
-
interface
|
|
219
|
+
interface ToolbarItem {
|
|
182
220
|
key: string;
|
|
183
221
|
label: string;
|
|
184
|
-
|
|
185
|
-
onClick: (() => void | Promise<void>) | null;
|
|
222
|
+
onClick: () => void | Promise<void>;
|
|
186
223
|
}
|
|
187
224
|
|
|
188
|
-
|
|
189
|
-
row: number;
|
|
190
|
-
startCol: number;
|
|
191
|
-
endCol: number;
|
|
192
|
-
onClick: (() => void | Promise<void>) | null;
|
|
193
|
-
}
|
|
225
|
+
const TOOLBAR_KEY_PREFIX = "toolbar.";
|
|
194
226
|
|
|
195
|
-
function
|
|
227
|
+
function toolbarItems(): ToolbarItem[] {
|
|
196
228
|
return [
|
|
197
|
-
{ key: "
|
|
198
|
-
{ key: "
|
|
199
|
-
{ key: "y", label: "copy hash", onClick: git_log_copy_hash },
|
|
200
|
-
{ key: "r", label: "refresh", onClick: git_log_refresh },
|
|
201
|
-
{ key: "q", label: "quit", onClick: git_log_q },
|
|
229
|
+
{ key: "tab", label: "Tab switch pane", onClick: git_log_tab },
|
|
230
|
+
{ key: "ret", label: "RET open file", onClick: git_log_enter },
|
|
231
|
+
{ key: "y", label: "y copy hash", onClick: git_log_copy_hash },
|
|
232
|
+
{ key: "r", label: "r refresh", onClick: git_log_refresh },
|
|
233
|
+
{ key: "q", label: "q quit", onClick: git_log_q },
|
|
202
234
|
];
|
|
203
235
|
}
|
|
204
236
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const overlays: InlineOverlay[] = [];
|
|
216
|
-
|
|
217
|
-
for (const hint of toolbarHints()) {
|
|
218
|
-
const body = ` [${hint.key}] ${hint.label} `;
|
|
219
|
-
const bodyLen = body.length;
|
|
220
|
-
const gap = text.length > 0 ? 1 : 0;
|
|
221
|
-
if (text.length + gap + bodyLen > W) break;
|
|
222
|
-
|
|
223
|
-
if (gap) text += " ";
|
|
224
|
-
|
|
225
|
-
const startCol = text.length;
|
|
226
|
-
const startByte = utf8Len(text);
|
|
227
|
-
text += body;
|
|
228
|
-
const endByte = utf8Len(text);
|
|
229
|
-
const endCol = text.length;
|
|
230
|
-
|
|
231
|
-
overlays.push({
|
|
232
|
-
start: startByte,
|
|
233
|
-
end: endByte,
|
|
234
|
-
style: { bg: "ui.status_bar_bg" },
|
|
235
|
-
});
|
|
236
|
-
const keyDisplay = `[${hint.key}]`;
|
|
237
|
-
const keyStartByte = startByte + utf8Len(" ");
|
|
238
|
-
const keyEndByte = keyStartByte + utf8Len(keyDisplay);
|
|
239
|
-
overlays.push({
|
|
240
|
-
start: keyStartByte,
|
|
241
|
-
end: keyEndByte,
|
|
242
|
-
style: { fg: "editor.fg", bold: true },
|
|
243
|
-
});
|
|
244
|
-
overlays.push({
|
|
245
|
-
start: keyEndByte,
|
|
246
|
-
end: endByte,
|
|
247
|
-
style: { fg: "editor.line_number_fg" },
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
buttons.push({ row: 0, startCol, endCol, onClick: hint.onClick });
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
state.toolbarButtons = buttons;
|
|
254
|
-
|
|
255
|
-
return [
|
|
256
|
-
{
|
|
257
|
-
text: text + "\n",
|
|
258
|
-
properties: { type: "git-log-toolbar" },
|
|
259
|
-
style: { bg: "editor.bg", extendToLineEnd: true },
|
|
260
|
-
inlineOverlays: overlays,
|
|
261
|
-
},
|
|
262
|
-
];
|
|
237
|
+
function toolbarSpec(): WidgetSpec {
|
|
238
|
+
const items = toolbarItems();
|
|
239
|
+
// `flexSpacer` at the end pushes the buttons to the left and lets the
|
|
240
|
+
// toolbar background extend across the row.
|
|
241
|
+
return row(
|
|
242
|
+
...items.map((item) =>
|
|
243
|
+
button(item.label, { key: TOOLBAR_KEY_PREFIX + item.key }),
|
|
244
|
+
),
|
|
245
|
+
flexSpacer(),
|
|
246
|
+
);
|
|
263
247
|
}
|
|
264
248
|
|
|
265
249
|
function renderToolbar(): void {
|
|
266
|
-
if (state.
|
|
267
|
-
|
|
268
|
-
const width = vp ? vp.width : 80;
|
|
269
|
-
editor.setPanelContent(state.groupId, "toolbar", buildToolbarEntries(width));
|
|
250
|
+
if (state.toolbarPanel === null) return;
|
|
251
|
+
state.toolbarPanel.set(toolbarSpec());
|
|
270
252
|
}
|
|
271
253
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
254
|
+
editor.on("widget_event", (data) => {
|
|
255
|
+
// Toolbar (Row of Buttons) — `activate` from keypress or click on a
|
|
256
|
+
// button.
|
|
257
|
+
if (
|
|
258
|
+
state.toolbarPanel !== null &&
|
|
259
|
+
data.panel_id === state.toolbarPanel.id()
|
|
260
|
+
) {
|
|
261
|
+
if (data.event_type !== "activate") return;
|
|
262
|
+
const items = toolbarItems();
|
|
263
|
+
for (const item of items) {
|
|
264
|
+
if (data.widget_key === TOOLBAR_KEY_PREFIX + item.key) {
|
|
265
|
+
void item.onClick();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
287
270
|
}
|
|
288
|
-
|
|
289
|
-
|
|
271
|
+
// Log pane (List of commit rows) — `select` fires on j/k/Up/Down/
|
|
272
|
+
// PageUp/PageDown navigation and on row clicks; `activate` fires on
|
|
273
|
+
// Enter or double-click.
|
|
274
|
+
if (state.logPanel !== null && data.panel_id === state.logPanel.id()) {
|
|
275
|
+
if (data.event_type === "select") {
|
|
276
|
+
const idx =
|
|
277
|
+
typeof data.payload?.index === "number" ? data.payload.index : -1;
|
|
278
|
+
if (idx >= 0) void on_log_select(idx);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (data.event_type === "activate") {
|
|
282
|
+
void git_log_enter();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
290
288
|
|
|
291
289
|
function on_git_log_resize(_data: { width: number; height: number }): void {
|
|
292
290
|
if (!state.isOpen) return;
|
|
@@ -303,105 +301,284 @@ function detailFooter(hash: string): string {
|
|
|
303
301
|
}
|
|
304
302
|
|
|
305
303
|
function renderLog(): void {
|
|
306
|
-
if (state.
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
304
|
+
if (state.logPanel === null) return;
|
|
305
|
+
// List takes the per-row entries directly. selectedIndex: -1 on the
|
|
306
|
+
// entry builder suppresses the plugin's selection styling — the host
|
|
307
|
+
// renders the focused-row highlight from the List widget's instance
|
|
308
|
+
// state instead.
|
|
309
|
+
const items = buildCommitLogEntries(state.commits, {
|
|
310
|
+
selectedIndex: -1,
|
|
312
311
|
header: null,
|
|
313
312
|
});
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function renderDetailPlaceholder(message: string): void {
|
|
330
|
-
if (state.groupId === null) return;
|
|
331
|
-
editor.setPanelContent(
|
|
332
|
-
state.groupId,
|
|
333
|
-
"detail",
|
|
334
|
-
buildDetailPlaceholderEntries(message)
|
|
313
|
+
const itemKeys = state.commits.map((c) => c.hash);
|
|
314
|
+
state.logPanel.set(
|
|
315
|
+
list({
|
|
316
|
+
items,
|
|
317
|
+
itemKeys,
|
|
318
|
+
selectedIndex: state.selectedIndex,
|
|
319
|
+
// Visible-rows only matters for virtualization; setting it to
|
|
320
|
+
// commits.length renders all rows and lets the buffer's natural
|
|
321
|
+
// scroll handle viewport. Revisit if commit lists grow into the
|
|
322
|
+
// tens of thousands.
|
|
323
|
+
visibleRows: Math.max(1, state.commits.length),
|
|
324
|
+
key: "git-log-list",
|
|
325
|
+
}),
|
|
335
326
|
);
|
|
336
327
|
}
|
|
337
328
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
329
|
+
// =============================================================================
|
|
330
|
+
// Streaming detail panel
|
|
331
|
+
//
|
|
332
|
+
// Per-commit cached file-backed buffers. On commit switch we either reuse
|
|
333
|
+
// the existing cached buffer (instant) or spawn `git show --patch` into a
|
|
334
|
+
// per-SHA file and open it via `openFileStreaming`, polling for growth
|
|
335
|
+
// while git runs in the background. The buffer-group panel is re-pointed
|
|
336
|
+
// at the chosen buffer via `setBufferGroupPanelBuffer` — the same single
|
|
337
|
+
// tab keeps the side-by-side log/detail UX.
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Path of the per-SHA cache file. Commits are immutable; once we've
|
|
342
|
+
* written one, repeat visits are zero-git.
|
|
343
|
+
*/
|
|
344
|
+
function cachePathForHash(hash: string): string {
|
|
345
|
+
// `<dataDir>/git-show/<sha>.diff` — the .diff extension lets the
|
|
346
|
+
// syntax-highlight grammar kick in for free.
|
|
347
|
+
return `${editor.getDataDir()}/git-show/${hash}.diff`;
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
/** Polling interval while git is still writing. ~5 fps is plenty. */
|
|
351
|
+
const STREAM_POLL_MS = 200;
|
|
352
|
+
|
|
348
353
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* `git show` is debounced.
|
|
354
|
+
* Start a `git show --patch` for `hash`, piping stdout straight into the
|
|
355
|
+
* cache file. Returns the handle so a later commit switch can `.kill()`
|
|
356
|
+
* the still-running spawn.
|
|
353
357
|
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
358
|
+
* Caller has already verified the cache file doesn't yet exist (or wants
|
|
359
|
+
* to overwrite it).
|
|
356
360
|
*/
|
|
357
|
-
function
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
361
|
+
function spawnGitShow(hash: string, cwd: string): ProcessHandle<SpawnResult> {
|
|
362
|
+
// `--stat --patch` matches what the previous plugin used. The stat
|
|
363
|
+
// header gives users a per-file changed-lines summary at the top
|
|
364
|
+
// of the diff and is also what `git show` produces by default, so
|
|
365
|
+
// its presence is what most readers (and tests) expect.
|
|
366
|
+
//
|
|
367
|
+
// The generated d.ts shows `spawnProcess(cmd, args, cwd?, stdoutTo?)`
|
|
368
|
+
// as flat positional args. The runtime JS wrapper also accepts an
|
|
369
|
+
// `{stdoutTo}` options object in the 4th slot, but using the flat
|
|
370
|
+
// form keeps the call type-checked without a cast.
|
|
371
|
+
return editor.spawnProcess(
|
|
372
|
+
"git",
|
|
373
|
+
["show", "--stat", "--patch", hash],
|
|
374
|
+
cwd,
|
|
375
|
+
cachePathForHash(hash),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Poll `editor.refreshBufferFromDisk` until the spawn handle resolves,
|
|
381
|
+
* then do one final catch-up refresh. Returns immediately if the
|
|
382
|
+
* commit is no longer in flight (e.g. user moved on, kill() fired).
|
|
383
|
+
*/
|
|
384
|
+
async function pollUntilSpawnDone(
|
|
385
|
+
hash: string,
|
|
386
|
+
bufferId: number,
|
|
387
|
+
handle: ProcessHandle<SpawnResult>,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
// Wrap the handle's settlement in a non-rejecting marker promise so
|
|
390
|
+
// a fast subscription loop can `await` it (or race it against a
|
|
391
|
+
// delay) without worrying about whether the spawn errored. The
|
|
392
|
+
// ProcessHandle is a thenable, not a real Promise, so adapt via
|
|
393
|
+
// Promise.resolve().
|
|
394
|
+
let done = false;
|
|
395
|
+
void Promise.resolve(handle).then(
|
|
396
|
+
() => {
|
|
397
|
+
done = true;
|
|
398
|
+
},
|
|
399
|
+
() => {
|
|
400
|
+
done = true;
|
|
401
|
+
},
|
|
402
|
+
);
|
|
366
403
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
return
|
|
404
|
+
while (!done) {
|
|
405
|
+
await editor.delay(STREAM_POLL_MS);
|
|
406
|
+
if (!state.isOpen) return; // group closed mid-stream
|
|
407
|
+
if (state.inFlightSpawns.get(hash) !== handle) return; // superseded
|
|
408
|
+
await editor.refreshBufferFromDisk(bufferId);
|
|
409
|
+
}
|
|
410
|
+
// Final catch-up so any bytes written between the last poll and
|
|
411
|
+
// process exit are visible immediately.
|
|
412
|
+
await editor.refreshBufferFromDisk(bufferId);
|
|
413
|
+
// Done — clear the in-flight handle if it's still ours.
|
|
414
|
+
if (state.inFlightSpawns.get(hash) === handle) {
|
|
415
|
+
state.inFlightSpawns.delete(hash);
|
|
370
416
|
}
|
|
417
|
+
// Apply diff coloring once the buffer is complete. Doing this
|
|
418
|
+
// pre-completion would either churn (re-walk on every refresh) or
|
|
419
|
+
// double-overlay newly-extended lines; on completion we walk once.
|
|
420
|
+
await applyDiffHighlights(bufferId);
|
|
421
|
+
}
|
|
371
422
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
423
|
+
// =============================================================================
|
|
424
|
+
// Diff syntax highlighting via per-line bg overlays
|
|
425
|
+
//
|
|
426
|
+
// Sublime-syntax's bundled `Diff` definition only scopes the `diff`
|
|
427
|
+
// keyword, so themes only colour that. Plugins are responsible for the
|
|
428
|
+
// rest — same approach `live_diff` uses for inline diff coloring in
|
|
429
|
+
// regular buffers.
|
|
430
|
+
//
|
|
431
|
+
// One overlay per line of added/removed content is fine for the
|
|
432
|
+
// "normal commit" workload but explodes on giant commits (the
|
|
433
|
+
// rewrite-bun commit is 1M lines = 1M overlays = back to the old
|
|
434
|
+
// 500k-overlay problem this rewire eliminated). Gate on buffer size;
|
|
435
|
+
// gracefully degrade to no highlighting for outliers.
|
|
436
|
+
// =============================================================================
|
|
437
|
+
|
|
438
|
+
const HIGHLIGHT_BG_ADDED = "editor.diff_add_bg";
|
|
439
|
+
const HIGHLIGHT_BG_REMOVED = "editor.diff_remove_bg";
|
|
440
|
+
const HIGHLIGHT_BG_HUNK = "editor.diff_modify_bg";
|
|
441
|
+
const HIGHLIGHT_NAMESPACE = "git-log-diff";
|
|
442
|
+
/** Skip overlay highlighting above this size. ~256 KB covers
|
|
443
|
+
* basically every hand-written commit comfortably; very large
|
|
444
|
+
* generated-file diffs (lockfiles, minified code) just stay
|
|
445
|
+
* uncoloured — the cost would be a few thousand-to-a-million
|
|
446
|
+
* overlays for content the user mostly skims. */
|
|
447
|
+
const HIGHLIGHT_MAX_BYTES = 256 * 1024;
|
|
448
|
+
|
|
449
|
+
async function applyDiffHighlights(bufferId: number): Promise<void> {
|
|
450
|
+
const total = editor.getBufferLength(bufferId);
|
|
451
|
+
if (total === 0 || total > HIGHLIGHT_MAX_BYTES) return;
|
|
452
|
+
const text = await editor.getBufferText(bufferId, 0, total);
|
|
453
|
+
if (!text) return;
|
|
454
|
+
|
|
455
|
+
// Walk lines tracking byte offsets; coalesce consecutive same-kind
|
|
456
|
+
// rows into single ranges so a 30-line added block costs one
|
|
457
|
+
// overlay, not 30.
|
|
458
|
+
let byte = 0;
|
|
459
|
+
let runKind: "+" | "-" | "@" | null = null;
|
|
460
|
+
let runStart = 0;
|
|
461
|
+
let runEnd = 0;
|
|
462
|
+
|
|
463
|
+
const flushRun = () => {
|
|
464
|
+
if (runKind === null) return;
|
|
465
|
+
const bg =
|
|
466
|
+
runKind === "+"
|
|
467
|
+
? HIGHLIGHT_BG_ADDED
|
|
468
|
+
: runKind === "-"
|
|
469
|
+
? HIGHLIGHT_BG_REMOVED
|
|
470
|
+
: HIGHLIGHT_BG_HUNK;
|
|
471
|
+
editor.addOverlay(bufferId, HIGHLIGHT_NAMESPACE, runStart, runEnd, {
|
|
472
|
+
bg,
|
|
473
|
+
extendToLineEnd: true,
|
|
474
|
+
});
|
|
475
|
+
runKind = null;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
for (const line of text.split("\n")) {
|
|
479
|
+
const lineLen = line.length;
|
|
480
|
+
const ch = line.charAt(0);
|
|
481
|
+
let kind: "+" | "-" | "@" | null = null;
|
|
482
|
+
if (ch === "+" && !line.startsWith("+++")) kind = "+";
|
|
483
|
+
else if (ch === "-" && !line.startsWith("---")) kind = "-";
|
|
484
|
+
else if (line.startsWith("@@")) kind = "@";
|
|
485
|
+
|
|
486
|
+
if (kind !== runKind) {
|
|
487
|
+
flushRun();
|
|
488
|
+
if (kind !== null) {
|
|
489
|
+
runStart = byte;
|
|
490
|
+
runKind = kind;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (kind !== null) {
|
|
494
|
+
// Include the trailing newline in the range so the bg colour
|
|
495
|
+
// fills the row even on empty lines that wrap-extend.
|
|
496
|
+
runEnd = byte + lineLen + 1;
|
|
497
|
+
}
|
|
498
|
+
byte += lineLen + 1;
|
|
499
|
+
}
|
|
500
|
+
flushRun();
|
|
376
501
|
}
|
|
377
502
|
|
|
378
503
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
504
|
+
* Get (or create) the file-backed buffer that displays `commit`.
|
|
505
|
+
* On first call for a hash: ensure cache file exists, kick off
|
|
506
|
+
* `git show` if it doesn't, openFileStreaming, start the poll loop.
|
|
507
|
+
* Returns the buffer id on success or null on failure.
|
|
381
508
|
*/
|
|
382
|
-
async function
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
509
|
+
async function ensureCommitBuffer(commit: GitCommit, cwd: string): Promise<number | null> {
|
|
510
|
+
const hash = commit.hash;
|
|
511
|
+
const existing = state.commitBuffers.get(hash);
|
|
512
|
+
if (existing !== undefined) return existing;
|
|
513
|
+
|
|
514
|
+
const path = cachePathForHash(hash);
|
|
515
|
+
const cacheHit = editor.fileExists(path);
|
|
516
|
+
|
|
517
|
+
if (!cacheHit) {
|
|
518
|
+
// Cache miss: spawn git, polling the file as it grows. The handle
|
|
519
|
+
// is stashed so a fast-scrolling user can supersede us via kill().
|
|
520
|
+
const handle = spawnGitShow(hash, cwd);
|
|
521
|
+
state.inFlightSpawns.set(hash, handle);
|
|
522
|
+
const bufferId = await editor.openFileStreaming(path);
|
|
523
|
+
if (bufferId === null) {
|
|
524
|
+
handle.kill?.();
|
|
525
|
+
state.inFlightSpawns.delete(hash);
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
state.commitBuffers.set(hash, bufferId);
|
|
529
|
+
// Fire-and-forget polling task.
|
|
530
|
+
void pollUntilSpawnDone(hash, bufferId, handle);
|
|
531
|
+
return bufferId;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Cache hit: just open the existing file. No git spawned.
|
|
535
|
+
const bufferId = await editor.openFileStreaming(path);
|
|
536
|
+
if (bufferId === null) return null;
|
|
537
|
+
state.commitBuffers.set(hash, bufferId);
|
|
538
|
+
return bufferId;
|
|
396
539
|
}
|
|
397
540
|
|
|
398
541
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
542
|
+
* Show `commit` in the detail panel. Cancels any superseded in-flight
|
|
543
|
+
* spawn for *other* commits (the user has navigated past them) and
|
|
544
|
+
* retargets the panel at the chosen commit's buffer.
|
|
401
545
|
*/
|
|
546
|
+
async function showCommitInDetail(commit: GitCommit, cwd: string): Promise<void> {
|
|
547
|
+
// Cancel anything still streaming for a commit that isn't this one —
|
|
548
|
+
// the user has moved on; no point keeping git running.
|
|
549
|
+
for (const [hash, handle] of state.inFlightSpawns) {
|
|
550
|
+
if (hash !== commit.hash) {
|
|
551
|
+
handle.kill?.();
|
|
552
|
+
state.inFlightSpawns.delete(hash);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const bufferId = await ensureCommitBuffer(commit, cwd);
|
|
557
|
+
if (bufferId === null) {
|
|
558
|
+
editor.setStatus(
|
|
559
|
+
editor.t("status.failed_open_file", { file: commit.shortHash }),
|
|
560
|
+
);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (state.groupId === null) return;
|
|
564
|
+
await editor.setBufferGroupPanelBuffer(state.groupId, "detail", bufferId);
|
|
565
|
+
state.detailBufferId = bufferId;
|
|
566
|
+
// Each commit buffer needs the same per-buffer presentation as the
|
|
567
|
+
// initial virtual one: visible cursor for diff-line navigation,
|
|
568
|
+
// wrap on (long minified lines unreadable in the 40% panel).
|
|
569
|
+
editor.setBufferShowCursors(bufferId, true);
|
|
570
|
+
editor.setLineWrap(bufferId, null, true);
|
|
571
|
+
// Land at the top of the diff every time we (re-)visit a commit.
|
|
572
|
+
editor.setBufferCursor(bufferId, 0);
|
|
573
|
+
}
|
|
574
|
+
|
|
402
575
|
async function refreshDetail(): Promise<void> {
|
|
403
|
-
|
|
404
|
-
if (
|
|
576
|
+
if (state.groupId === null) return;
|
|
577
|
+
if (state.commits.length === 0) return;
|
|
578
|
+
const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1));
|
|
579
|
+
const commit = state.commits[idx];
|
|
580
|
+
if (!commit) return;
|
|
581
|
+
await showCommitInDetail(commit, editor.getCwd());
|
|
405
582
|
}
|
|
406
583
|
|
|
407
584
|
// =============================================================================
|
|
@@ -415,14 +592,6 @@ function selectedCommit(): GitCommit | null {
|
|
|
415
592
|
return state.commits[i] ?? null;
|
|
416
593
|
}
|
|
417
594
|
|
|
418
|
-
function indexFromCursorByte(bytePos: number): number {
|
|
419
|
-
// No header row — row 0 is commit 0.
|
|
420
|
-
const idx = rowFromByte(bytePos);
|
|
421
|
-
if (idx < 0) return 0;
|
|
422
|
-
if (idx >= state.commits.length) return state.commits.length - 1;
|
|
423
|
-
return idx;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
595
|
// =============================================================================
|
|
427
596
|
// Commands
|
|
428
597
|
// =============================================================================
|
|
@@ -454,42 +623,40 @@ async function show_git_log(): Promise<void> {
|
|
|
454
623
|
);
|
|
455
624
|
state.groupId = group.groupId as number;
|
|
456
625
|
state.logBufferId = (group.panels["log"] as number | undefined) ?? null;
|
|
457
|
-
state.
|
|
626
|
+
state.initialDetailBufferId =
|
|
627
|
+
(group.panels["detail"] as number | undefined) ?? null;
|
|
628
|
+
// detailBufferId starts as the initial virtual buffer; it gets
|
|
629
|
+
// retargeted to a file-backed buffer on first commit selection.
|
|
630
|
+
state.detailBufferId = state.initialDetailBufferId;
|
|
458
631
|
state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null;
|
|
632
|
+
if (state.toolbarBufferId !== null) {
|
|
633
|
+
state.toolbarPanel = new WidgetPanel(state.toolbarBufferId);
|
|
634
|
+
}
|
|
635
|
+
if (state.logBufferId !== null) {
|
|
636
|
+
state.logPanel = new WidgetPanel(state.logBufferId);
|
|
637
|
+
}
|
|
459
638
|
state.selectedIndex = 0;
|
|
460
|
-
state.
|
|
639
|
+
state.commitBuffers = new Map();
|
|
640
|
+
state.inFlightSpawns = new Map();
|
|
461
641
|
state.isOpen = true;
|
|
462
642
|
|
|
463
|
-
// The
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
editor.
|
|
471
|
-
// Wrap long lines in the detail panel — git diffs often exceed the
|
|
472
|
-
// 40% split width, and horizontal scrolling a commit is awkward.
|
|
473
|
-
editor.setLineWrap(state.detailBufferId, null, true);
|
|
474
|
-
// Per-panel mode: the group was created with "git-log" which applies
|
|
475
|
-
// to the initially-focused panel (log). The detail panel's mode is
|
|
476
|
-
// set when we focus into it.
|
|
643
|
+
// The detail panel owns a native cursor so diff lines can be
|
|
644
|
+
// clicked / traversed before pressing Enter to open a file. We set
|
|
645
|
+
// the cursor on each retargeted buffer as it gets swapped in, but
|
|
646
|
+
// wrap-default needs setting too — long minified lines in lock-file
|
|
647
|
+
// diffs are unreadable without wrap in the 40% panel.
|
|
648
|
+
if (state.initialDetailBufferId !== null) {
|
|
649
|
+
editor.setBufferShowCursors(state.initialDetailBufferId, true);
|
|
650
|
+
editor.setLineWrap(state.initialDetailBufferId, null, true);
|
|
477
651
|
}
|
|
478
652
|
|
|
479
653
|
renderToolbar();
|
|
480
654
|
renderLog();
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
editor.setBufferCursor(state.logBufferId, 0);
|
|
485
|
-
}
|
|
655
|
+
// List widget's instance state is the source of truth for selection;
|
|
656
|
+
// no buffer-cursor positioning needed (the renderer auto-scrolls so
|
|
657
|
+
// the selected row stays visible).
|
|
486
658
|
await refreshDetail();
|
|
487
659
|
|
|
488
|
-
if (state.groupId !== null) {
|
|
489
|
-
editor.focusBufferGroupPanel(state.groupId, "log");
|
|
490
|
-
}
|
|
491
|
-
editor.on("cursor_moved", on_git_log_cursor_moved);
|
|
492
|
-
editor.on("mouse_click", on_git_log_toolbar_click);
|
|
493
660
|
editor.on("resize", on_git_log_resize);
|
|
494
661
|
editor.on("buffer_closed", on_git_log_buffer_closed);
|
|
495
662
|
|
|
@@ -504,19 +671,38 @@ registerHandler("show_git_log", show_git_log);
|
|
|
504
671
|
* close button, which triggers `buffer_closed`). */
|
|
505
672
|
function git_log_cleanup(): void {
|
|
506
673
|
if (!state.isOpen) return;
|
|
507
|
-
editor.off("cursor_moved", on_git_log_cursor_moved);
|
|
508
|
-
editor.off("mouse_click", on_git_log_toolbar_click);
|
|
509
674
|
editor.off("resize", on_git_log_resize);
|
|
510
675
|
editor.off("buffer_closed", on_git_log_buffer_closed);
|
|
676
|
+
// Kill any still-running `git show` spawns — we no longer care.
|
|
677
|
+
for (const [, handle] of state.inFlightSpawns) {
|
|
678
|
+
handle.kill?.();
|
|
679
|
+
}
|
|
680
|
+
state.inFlightSpawns.clear();
|
|
681
|
+
// Close each per-commit buffer we created. The buffer-group's own
|
|
682
|
+
// `close` (called below in `git_log_close`) tears down the panel
|
|
683
|
+
// buffers (toolbar/log/initialDetail) — but retargeted file-backed
|
|
684
|
+
// buffers we allocated via openFileStreaming are *outside* the
|
|
685
|
+
// group's panel_buffers map by the time we got here, so we must
|
|
686
|
+
// close them explicitly to avoid leaks.
|
|
687
|
+
for (const [, bufferId] of state.commitBuffers) {
|
|
688
|
+
editor.closeBuffer(bufferId);
|
|
689
|
+
}
|
|
690
|
+
state.commitBuffers.clear();
|
|
691
|
+
// The buffer-group's `close` will tear down its own panel buffers
|
|
692
|
+
// (toolbar/log/initialDetail) too, which implicitly drops the widget
|
|
693
|
+
// panels rendering into them. We still null out the handles so any
|
|
694
|
+
// stray `renderToolbar()` / `renderLog()` call post-cleanup is a
|
|
695
|
+
// no-op.
|
|
696
|
+
state.toolbarPanel = null;
|
|
697
|
+
state.logPanel = null;
|
|
511
698
|
state.isOpen = false;
|
|
512
699
|
state.groupId = null;
|
|
513
700
|
state.logBufferId = null;
|
|
701
|
+
state.initialDetailBufferId = null;
|
|
514
702
|
state.detailBufferId = null;
|
|
515
703
|
state.toolbarBufferId = null;
|
|
516
|
-
state.toolbarButtons = [];
|
|
517
704
|
state.commits = [];
|
|
518
705
|
state.selectedIndex = 0;
|
|
519
|
-
state.detailCache = null;
|
|
520
706
|
}
|
|
521
707
|
|
|
522
708
|
function git_log_close(): void {
|
|
@@ -532,12 +718,26 @@ registerHandler("git_log_close", git_log_close);
|
|
|
532
718
|
|
|
533
719
|
function on_git_log_buffer_closed(data: { buffer_id: number }): void {
|
|
534
720
|
if (!state.isOpen) return;
|
|
721
|
+
// Tear down the whole group only when the *group's* buffers close
|
|
722
|
+
// (toolbar / log / the initial virtual detail). A retargeted
|
|
723
|
+
// file-backed commit buffer closing is normal — drop it from our
|
|
724
|
+
// cache but keep the group alive.
|
|
535
725
|
if (
|
|
536
726
|
data.buffer_id === state.logBufferId ||
|
|
537
|
-
data.buffer_id === state.
|
|
727
|
+
data.buffer_id === state.initialDetailBufferId ||
|
|
538
728
|
data.buffer_id === state.toolbarBufferId
|
|
539
729
|
) {
|
|
540
730
|
git_log_cleanup();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
// Removed from cache so a revisit re-spawns / re-opens.
|
|
734
|
+
for (const [hash, bufId] of state.commitBuffers) {
|
|
735
|
+
if (bufId === data.buffer_id) {
|
|
736
|
+
state.commitBuffers.delete(hash);
|
|
737
|
+
state.inFlightSpawns.get(hash)?.kill?.();
|
|
738
|
+
state.inFlightSpawns.delete(hash);
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
541
741
|
}
|
|
542
742
|
}
|
|
543
743
|
registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed);
|
|
@@ -546,7 +746,13 @@ async function git_log_refresh(): Promise<void> {
|
|
|
546
746
|
if (!state.isOpen) return;
|
|
547
747
|
editor.setStatus(editor.t("status.refreshing"));
|
|
548
748
|
state.commits = await fetchGitLog(editor);
|
|
549
|
-
|
|
749
|
+
// The on-disk cache files are keyed by SHA and commits are
|
|
750
|
+
// immutable, so they remain valid — but our in-memory buffer ids
|
|
751
|
+
// for commits no longer in the visible list are stale; clear them.
|
|
752
|
+
for (const [, handle] of state.inFlightSpawns) handle.kill?.();
|
|
753
|
+
state.inFlightSpawns.clear();
|
|
754
|
+
for (const [, bufferId] of state.commitBuffers) editor.closeBuffer(bufferId);
|
|
755
|
+
state.commitBuffers.clear();
|
|
550
756
|
if (state.selectedIndex >= state.commits.length) {
|
|
551
757
|
state.selectedIndex = Math.max(0, state.commits.length - 1);
|
|
552
758
|
}
|
|
@@ -617,26 +823,222 @@ function git_log_q(): void {
|
|
|
617
823
|
}
|
|
618
824
|
registerHandler("git_log_q", git_log_q);
|
|
619
825
|
|
|
826
|
+
// =============================================================================
|
|
827
|
+
// Folding by file and hunk
|
|
828
|
+
//
|
|
829
|
+
// Publishes structural fold ranges into the buffer's `folding_ranges`
|
|
830
|
+
// via `setFoldingRanges` — the same channel an LSP `foldingRange`
|
|
831
|
+
// response uses. Nothing is pre-collapsed; the user toggles a range
|
|
832
|
+
// with the standard fold keybinding (`za` etc.), which finds the
|
|
833
|
+
// matching range under the cursor.
|
|
834
|
+
//
|
|
835
|
+
// The diff structure gives us two natural fold levels:
|
|
836
|
+
// * per-file: each `diff --git a/X b/Y` section
|
|
837
|
+
// * per-hunk: each `@@ -A,B +C,D @@` block within a file
|
|
838
|
+
// We publish both; the toggle-fold key picks the innermost containing
|
|
839
|
+
// range at the cursor's line.
|
|
840
|
+
//
|
|
841
|
+
// Computed once after `pollUntilSpawnDone` settles — re-running on
|
|
842
|
+
// every refresh would churn the marker list for no benefit (the diff
|
|
843
|
+
// structure is monotonic-append until exit).
|
|
844
|
+
// =============================================================================
|
|
845
|
+
|
|
846
|
+
interface DiffFoldRange {
|
|
847
|
+
startLine: number;
|
|
848
|
+
endLine: number;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Walk the buffer text and return (file-level, hunk-level) fold
|
|
852
|
+
* ranges. Lines are 0-indexed, both endpoints inclusive — the LSP
|
|
853
|
+
* shape. The "fold header" is the line at `startLine`; everything up
|
|
854
|
+
* through `endLine` collapses under it. */
|
|
855
|
+
function computeDiffFoldRanges(text: string): {
|
|
856
|
+
files: DiffFoldRange[];
|
|
857
|
+
hunks: DiffFoldRange[];
|
|
858
|
+
} {
|
|
859
|
+
const lines = text.split("\n");
|
|
860
|
+
const files: DiffFoldRange[] = [];
|
|
861
|
+
const hunks: DiffFoldRange[] = [];
|
|
862
|
+
|
|
863
|
+
let fileStart: number | null = null;
|
|
864
|
+
let hunkStart: number | null = null;
|
|
865
|
+
|
|
866
|
+
const closeHunk = (endLine: number) => {
|
|
867
|
+
if (hunkStart !== null && endLine > hunkStart) {
|
|
868
|
+
hunks.push({ startLine: hunkStart, endLine });
|
|
869
|
+
}
|
|
870
|
+
hunkStart = null;
|
|
871
|
+
};
|
|
872
|
+
const closeFile = (endLine: number) => {
|
|
873
|
+
closeHunk(endLine);
|
|
874
|
+
if (fileStart !== null && endLine > fileStart) {
|
|
875
|
+
files.push({ startLine: fileStart, endLine });
|
|
876
|
+
}
|
|
877
|
+
fileStart = null;
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
for (let i = 0; i < lines.length; i++) {
|
|
881
|
+
const l = lines[i];
|
|
882
|
+
if (l.startsWith("diff --git ")) {
|
|
883
|
+
closeFile(i - 1);
|
|
884
|
+
fileStart = i;
|
|
885
|
+
} else if (l.startsWith("@@ ")) {
|
|
886
|
+
closeHunk(i - 1);
|
|
887
|
+
hunkStart = i;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
closeFile(lines.length - 1);
|
|
891
|
+
return { files, hunks };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function publishDiffFoldRanges(bufferId: number): Promise<void> {
|
|
895
|
+
const total = editor.getBufferLength(bufferId);
|
|
896
|
+
if (total === 0) return;
|
|
897
|
+
const text = await editor.getBufferText(bufferId, 0, total);
|
|
898
|
+
if (!text) return;
|
|
899
|
+
|
|
900
|
+
const { files, hunks } = computeDiffFoldRanges(text);
|
|
901
|
+
|
|
902
|
+
// Merge — the host accepts a single array. "region" kind tags both
|
|
903
|
+
// levels generically; the LSP spec also defines comment/imports
|
|
904
|
+
// kinds which don't apply to diffs.
|
|
905
|
+
const ranges = [...files, ...hunks].map((r) => ({
|
|
906
|
+
startLine: r.startLine,
|
|
907
|
+
endLine: r.endLine,
|
|
908
|
+
kind: "region",
|
|
909
|
+
}));
|
|
910
|
+
editor.setFoldingRanges(bufferId, ranges);
|
|
911
|
+
}
|
|
912
|
+
|
|
620
913
|
// =============================================================================
|
|
621
914
|
// Detail panel — open file at commit
|
|
622
915
|
// =============================================================================
|
|
623
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Walk through the streaming diff buffer to find the file + line
|
|
919
|
+
* context near the cursor. Diff format:
|
|
920
|
+
*
|
|
921
|
+
* diff --git a/<path> b/<path>
|
|
922
|
+
* index ...
|
|
923
|
+
* --- a/<path> (or /dev/null for additions)
|
|
924
|
+
* +++ b/<path> (or /dev/null for deletions)
|
|
925
|
+
* @@ -old,n +new,m @@
|
|
926
|
+
* <context|+|- lines>
|
|
927
|
+
*
|
|
928
|
+
* Strategy:
|
|
929
|
+
* - Read up to the END of the cursor's line, not just up to the
|
|
930
|
+
* cursor's byte offset. This way a cursor sitting on a header line
|
|
931
|
+
* (`diff --git`, `+++ b/...`, `@@ ...`) still gets that line
|
|
932
|
+
* matched, matching the old text-property behaviour.
|
|
933
|
+
* - Walk backwards for the per-file header. Match either:
|
|
934
|
+
* `+++ b/<path>` (preferred — names the new-side path)
|
|
935
|
+
* `diff --git a/<src> b/<dst>` (fallback — covers the case where
|
|
936
|
+
* the cursor is on the `diff --git` line itself, before the
|
|
937
|
+
* `+++` line has appeared in the search range)
|
|
938
|
+
* - Walk backwards for the most recent `@@ -... +<new>,<count> @@`
|
|
939
|
+
* between the header and cursor, then count context/'+' rows
|
|
940
|
+
* forward to the cursor to derive the new-side line number.
|
|
941
|
+
*/
|
|
942
|
+
async function deriveFileAndLineFromDiffCursor(
|
|
943
|
+
bufferId: number,
|
|
944
|
+
): Promise<{ file: string; line: number } | null> {
|
|
945
|
+
const cursor = editor.getCursorPosition();
|
|
946
|
+
if (cursor < 0) return null;
|
|
947
|
+
|
|
948
|
+
const bufLen = editor.getBufferLength(bufferId);
|
|
949
|
+
const readEnd = Math.min(bufLen, cursor + 4096);
|
|
950
|
+
if (readEnd === 0) return null;
|
|
951
|
+
const text = await editor.getBufferText(bufferId, 0, readEnd);
|
|
952
|
+
if (!text) return null;
|
|
953
|
+
const lines = text.split("\n");
|
|
954
|
+
|
|
955
|
+
// Locate the cursor's line index by walking byte offsets. `lines[i]`
|
|
956
|
+
// covers bytes [byte, byte+len]; the `\n` separator lives at
|
|
957
|
+
// byte+len, so the next line starts at byte+len+1.
|
|
958
|
+
let byte = 0;
|
|
959
|
+
let cursorLineIdx = lines.length - 1;
|
|
960
|
+
for (let i = 0; i < lines.length; i++) {
|
|
961
|
+
const lineLen = lines[i].length;
|
|
962
|
+
if (cursor <= byte + lineLen) {
|
|
963
|
+
cursorLineIdx = i;
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
byte += lineLen + 1;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Walk back from the cursor's line for the per-file header. Match
|
|
970
|
+
// either `+++ b/<path>` or `diff --git a/<src> b/<dst>` so cursor-
|
|
971
|
+
// on-header cases work.
|
|
972
|
+
let file: string | null = null;
|
|
973
|
+
let headerIdx = -1;
|
|
974
|
+
for (let i = cursorLineIdx; i >= 0; i--) {
|
|
975
|
+
const l = lines[i];
|
|
976
|
+
if (l.startsWith("+++ b/")) {
|
|
977
|
+
file = l.slice(6).trim();
|
|
978
|
+
headerIdx = i;
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
if (l.startsWith("+++ /dev/null")) {
|
|
982
|
+
// Deletion — no new-side path. Opening the pre-image is a
|
|
983
|
+
// separate flow.
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(l);
|
|
987
|
+
if (m) {
|
|
988
|
+
const aSide = m[1];
|
|
989
|
+
const bSide = m[2];
|
|
990
|
+
file = bSide === "/dev/null" ? aSide : bSide;
|
|
991
|
+
headerIdx = i;
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (file === null || headerIdx < 0) return null;
|
|
996
|
+
|
|
997
|
+
// Find the most recent `@@ ... +start,count @@` between header and
|
|
998
|
+
// cursor. Default: line 1 (cursor sits on the header itself, or
|
|
999
|
+
// between the header and the first hunk).
|
|
1000
|
+
let line = 1;
|
|
1001
|
+
for (let i = cursorLineIdx; i > headerIdx; i--) {
|
|
1002
|
+
const l = lines[i];
|
|
1003
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
|
|
1004
|
+
if (!m) continue;
|
|
1005
|
+
const newStart = parseInt(m[1], 10);
|
|
1006
|
+
if (!Number.isFinite(newStart)) return null;
|
|
1007
|
+
// Walk forward from the hunk header to the cursor's line,
|
|
1008
|
+
// advancing the new-file line counter for context (' ') and
|
|
1009
|
+
// addition ('+') rows; skip deletion ('-') rows since they don't
|
|
1010
|
+
// exist in the new file.
|
|
1011
|
+
let cur = newStart;
|
|
1012
|
+
for (let j = i + 1; j <= cursorLineIdx; j++) {
|
|
1013
|
+
if (j === cursorLineIdx) {
|
|
1014
|
+
line = cur;
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
const ch = lines[j].charAt(0);
|
|
1018
|
+
if (ch === "+" || ch === " " || ch === "") cur += 1;
|
|
1019
|
+
// '-' / '\' (no-newline marker): don't advance.
|
|
1020
|
+
}
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
return { file, line };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
624
1026
|
async function git_log_detail_open_file(): Promise<void> {
|
|
625
1027
|
if (state.detailBufferId === null) return;
|
|
626
1028
|
const commit = selectedCommit();
|
|
627
1029
|
if (!commit) return;
|
|
628
1030
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
if (!file) {
|
|
1031
|
+
// The detail buffer is a plain file-backed view of `git show --patch`,
|
|
1032
|
+
// so we don't have plugin-attached `file`/`line` properties anymore.
|
|
1033
|
+
// Parse the diff backwards from the cursor to find the nearest
|
|
1034
|
+
// `+++ b/<path>` header (a per-file diff section opener) and the
|
|
1035
|
+
// most recent hunk header to derive a line number.
|
|
1036
|
+
const ctx = await deriveFileAndLineFromDiffCursor(state.detailBufferId);
|
|
1037
|
+
if (!ctx) {
|
|
637
1038
|
editor.setStatus(editor.t("status.move_to_diff_with_context"));
|
|
638
1039
|
return;
|
|
639
1040
|
}
|
|
1041
|
+
const { file, line } = ctx;
|
|
640
1042
|
|
|
641
1043
|
editor.setStatus(
|
|
642
1044
|
editor.t("status.file_loading", { file, hash: commit.shortHash })
|
|
@@ -712,56 +1114,37 @@ function git_log_file_view_close(): void {
|
|
|
712
1114
|
registerHandler("git_log_file_view_close", git_log_file_view_close);
|
|
713
1115
|
|
|
714
1116
|
// =============================================================================
|
|
715
|
-
//
|
|
716
|
-
// the
|
|
1117
|
+
// Selection tracking — live-update the detail panel as the user
|
|
1118
|
+
// navigates the List. Driven by `widget_event "select"` from the host.
|
|
717
1119
|
// =============================================================================
|
|
718
1120
|
|
|
719
|
-
async function
|
|
720
|
-
buffer_id: number;
|
|
721
|
-
cursor_id: number;
|
|
722
|
-
old_position: number;
|
|
723
|
-
new_position: number;
|
|
724
|
-
}): Promise<void> {
|
|
1121
|
+
async function on_log_select(idx: number): Promise<void> {
|
|
725
1122
|
if (!state.isOpen) return;
|
|
726
|
-
// Only react to movement inside the log panel.
|
|
727
|
-
if (data.buffer_id !== state.logBufferId) return;
|
|
728
|
-
|
|
729
|
-
// Map the cursor's byte offset to a commit index via the row-offset
|
|
730
|
-
// table built in `renderLog`. This avoids relying on `getCursorLine`
|
|
731
|
-
// which is not implemented for virtual buffers.
|
|
732
|
-
const idx = indexFromCursorByte(data.new_position);
|
|
733
1123
|
if (idx === state.selectedIndex) return;
|
|
734
1124
|
state.selectedIndex = idx;
|
|
735
1125
|
|
|
736
|
-
// Immediate feedback: update the log panel's selection highlight and
|
|
737
|
-
// either show the cached detail or a "loading" placeholder. Only the
|
|
738
|
-
// actual `git show` spawn is debounced below, so a burst of j/k events
|
|
739
|
-
// still feels responsive even though we collapse the fetches into one.
|
|
740
|
-
renderLog();
|
|
741
|
-
const pending = refreshDetailImmediate();
|
|
742
|
-
|
|
743
1126
|
const commit = state.commits[state.selectedIndex];
|
|
744
1127
|
if (commit) {
|
|
745
1128
|
editor.setStatus(
|
|
746
1129
|
editor.t("status.commit_position", {
|
|
747
1130
|
current: String(state.selectedIndex + 1),
|
|
748
1131
|
total: String(state.commits.length),
|
|
749
|
-
})
|
|
1132
|
+
}),
|
|
750
1133
|
);
|
|
751
1134
|
}
|
|
752
1135
|
|
|
753
|
-
if (!pending) return;
|
|
754
|
-
|
|
755
1136
|
// Debounce: bump the token, wait a beat, bail if a newer event has
|
|
756
|
-
// arrived.
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1137
|
+
// arrived. Even though re-pointing the panel at a cached buffer is
|
|
1138
|
+
// ~free, kicking off a new `git show --patch` for every intermediate
|
|
1139
|
+
// row in a held-j burst is wasteful. Collapse rapid selection moves.
|
|
1140
|
+
const myId = ++state.pendingSelectId;
|
|
1141
|
+
await editor.delay(SELECT_DEBOUNCE_MS);
|
|
1142
|
+
if (myId !== state.pendingSelectId) return;
|
|
761
1143
|
if (!state.isOpen) return;
|
|
762
|
-
|
|
1144
|
+
const current = state.commits[state.selectedIndex];
|
|
1145
|
+
if (!current) return;
|
|
1146
|
+
await showCommitInDetail(current, editor.getCwd());
|
|
763
1147
|
}
|
|
764
|
-
registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved);
|
|
765
1148
|
|
|
766
1149
|
// =============================================================================
|
|
767
1150
|
// Command registration
|
|
@@ -785,5 +1168,4 @@ editor.registerCommand(
|
|
|
785
1168
|
"git_log_refresh",
|
|
786
1169
|
null
|
|
787
1170
|
);
|
|
788
|
-
|
|
789
1171
|
editor.debug("Git Log plugin initialized (modern buffer-group layout)");
|