@fresh-editor/fresh-editor 0.2.24 → 0.3.0
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 +172 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +14 -14
- package/plugins/audit_mode.ts +76 -33
- package/plugins/config-schema.json +69 -6
- package/plugins/dashboard.ts +1785 -0
- package/plugins/devcontainer.i18n.json +1416 -0
- package/plugins/devcontainer.ts +2066 -0
- package/plugins/git_log.i18n.json +14 -42
- package/plugins/git_log.ts +11 -1
- package/plugins/lib/fresh.d.ts +232 -2
- package/plugins/markdown_compose.ts +54 -6
- package/plugins/schemas/theme.schema.json +108 -0
- package/plugins/theme_editor.i18n.json +28 -0
- package/plugins/theme_editor.ts +4 -1
- package/themes/high-contrast.json +2 -2
- package/themes/nord.json +4 -0
- package/themes/solarized-dark.json +4 -0
|
@@ -0,0 +1,1785 @@
|
|
|
1
|
+
/// <reference path="./lib/fresh.d.ts" />
|
|
2
|
+
const editor = getEditor();
|
|
3
|
+
|
|
4
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
5
|
+
// DASHBOARD PLUGIN
|
|
6
|
+
//
|
|
7
|
+
// Shows a TUI dashboard with weather, git, GitHub PRs, and disk
|
|
8
|
+
// stats when there's no real work open — either at startup or
|
|
9
|
+
// after the user closes the last file buffer (instead of the
|
|
10
|
+
// default untitled scratch).
|
|
11
|
+
//
|
|
12
|
+
// Controlled via the standard plugin config flag
|
|
13
|
+
// (`plugins.dashboard.enabled` in config.json / Settings UI).
|
|
14
|
+
// When the plugin is enabled and loaded, it subscribes to the
|
|
15
|
+
// editor hooks that drive the dashboard's lifecycle; when disabled
|
|
16
|
+
// it is never loaded, so no buffers are created, no timers run,
|
|
17
|
+
// and no network fetches fire.
|
|
18
|
+
//
|
|
19
|
+
// A second flag `plugins.dashboard.auto-open` (default true) gates
|
|
20
|
+
// only the ambient open paths (startup + last-buffer-closed). When
|
|
21
|
+
// false the plugin still loads and the "Show Dashboard" command is
|
|
22
|
+
// still available — it just won't appear on its own.
|
|
23
|
+
//
|
|
24
|
+
// - Auto-centers both horizontally and vertically. Repaints when the
|
|
25
|
+
// viewport changes (terminal resize, file-explorer toggle, split
|
|
26
|
+
// reshape).
|
|
27
|
+
// - Auto-refreshes every 5 seconds while visible.
|
|
28
|
+
// - All colors are theme keys → repaints for free on theme switch.
|
|
29
|
+
// - Clickable rows (repo URL, branch name, PR numbers, review-branch
|
|
30
|
+
// action) route clicks through the mouse_click hook, so they work
|
|
31
|
+
// in terminals that swallow OSC-8 hyperlinks. The OSC-8 `url` span
|
|
32
|
+
// is still set as a fallback for terminals that do honor it.
|
|
33
|
+
// - Content is pushed to the buffer via `setVirtualBufferContent`, a
|
|
34
|
+
// single atomic command. Going through clearNamespace / deleteRange /
|
|
35
|
+
// insertText / addOverlay would let a render frame slip in between
|
|
36
|
+
// the delete and the insert — the plugin thread pushes each call as
|
|
37
|
+
// an independent message onto an MPSC channel that the editor drains
|
|
38
|
+
// non-blocking every tick, so a partial-batch render is possible and
|
|
39
|
+
// observably flickery. `setVirtualBufferContent` ships text + all
|
|
40
|
+
// inline overlays in one message, so the editor applies the whole
|
|
41
|
+
// replacement before the next frame.
|
|
42
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
type Span = {
|
|
45
|
+
start: number;
|
|
46
|
+
end: number;
|
|
47
|
+
fg?: string;
|
|
48
|
+
bg?: string;
|
|
49
|
+
bold?: boolean;
|
|
50
|
+
underline?: boolean;
|
|
51
|
+
url?: string;
|
|
52
|
+
};
|
|
53
|
+
// Click action attached to whole rows — dispatched by the mouse_click
|
|
54
|
+
// handler, which looks up the clicked buffer row in Draw.rowActions.
|
|
55
|
+
// Since terminals that swallow OSC-8 hyperlinks are common, we can't
|
|
56
|
+
// rely on the `url` span alone; routing clicks through the editor
|
|
57
|
+
// guarantees that PR numbers / repo / custom section rows are always
|
|
58
|
+
// actionable. The `callback` variant lets third-party sections wire
|
|
59
|
+
// up arbitrary click handlers via the public `DashboardContext` API.
|
|
60
|
+
type ClickAction =
|
|
61
|
+
| { kind: "open-url"; url: string }
|
|
62
|
+
| { kind: "callback"; fn: () => void };
|
|
63
|
+
type Draw = {
|
|
64
|
+
text: string;
|
|
65
|
+
spans: Span[];
|
|
66
|
+
// currentRow / currentCol are maintained by `emit()` + `newline()` so
|
|
67
|
+
// click-action ranges land on the same cells the underlined text
|
|
68
|
+
// actually occupies. A click at (buffer_row, buffer_col) only fires
|
|
69
|
+
// if it falls inside one of the row's registered ranges — padding
|
|
70
|
+
// spaces, kv labels, and the frame border inside an inner row are
|
|
71
|
+
// not clickable, matching the visual affordance.
|
|
72
|
+
currentRow: number;
|
|
73
|
+
currentCol: number;
|
|
74
|
+
rowActions: Map<number, ClickActionRange[]>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Column range within a row that carries a click target. */
|
|
78
|
+
type ClickActionRange = { colStart: number; colEnd: number; action: ClickAction };
|
|
79
|
+
const MAX_INNER = 72; // content width excluding frame + centering pad
|
|
80
|
+
|
|
81
|
+
const C = {
|
|
82
|
+
frame: "ui.popup_border_fg",
|
|
83
|
+
title: "syntax.keyword",
|
|
84
|
+
accent: "syntax.function",
|
|
85
|
+
value: "syntax.string",
|
|
86
|
+
number: "syntax.constant",
|
|
87
|
+
muted: "syntax.comment",
|
|
88
|
+
branch: "syntax.variable",
|
|
89
|
+
ok: "ui.file_status_added_fg",
|
|
90
|
+
warn: "syntax.constant",
|
|
91
|
+
err: "diagnostic.error_fg",
|
|
92
|
+
barFill: "syntax.function",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ── Public section API ─────────────────────────────────────────────────
|
|
96
|
+
//
|
|
97
|
+
// Third-party plugins (and user init.ts) can add their own dashboard
|
|
98
|
+
// rows via the exported plugin API. See `editor.exportPluginApi` at
|
|
99
|
+
// the bottom of this file and the usage example in the init.ts
|
|
100
|
+
// starter template (`init_script.rs::STARTER_TEMPLATE`).
|
|
101
|
+
|
|
102
|
+
// Named palette colors exposed to section callbacks. Each maps to a
|
|
103
|
+
// theme key under the hood so sections follow the active theme
|
|
104
|
+
// without hard-coding RGB values.
|
|
105
|
+
export type DashboardColor =
|
|
106
|
+
| "muted"
|
|
107
|
+
| "accent"
|
|
108
|
+
| "value"
|
|
109
|
+
| "number"
|
|
110
|
+
| "ok"
|
|
111
|
+
| "warn"
|
|
112
|
+
| "err"
|
|
113
|
+
| "branch";
|
|
114
|
+
|
|
115
|
+
const COLOR_KEYS: Record<DashboardColor, string> = {
|
|
116
|
+
muted: C.muted,
|
|
117
|
+
accent: C.accent,
|
|
118
|
+
value: C.value,
|
|
119
|
+
number: C.number,
|
|
120
|
+
ok: C.ok,
|
|
121
|
+
warn: C.warn,
|
|
122
|
+
err: C.err,
|
|
123
|
+
branch: C.branch,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type DashboardTextOpts = {
|
|
127
|
+
color?: DashboardColor;
|
|
128
|
+
bold?: boolean;
|
|
129
|
+
/** OSC-8 hyperlink target; terminals that honor it render the span as a
|
|
130
|
+
* clickable link. When only `onClick` is set (no `url`), the span is
|
|
131
|
+
* still routed through Fresh's own mouse-click dispatch. */
|
|
132
|
+
url?: string;
|
|
133
|
+
/** Invoked when the user clicks anywhere on the row carrying this text
|
|
134
|
+
* span, regardless of whether the terminal honors OSC-8. Multiple text
|
|
135
|
+
* spans on the same row share a single row-level click target; the
|
|
136
|
+
* first `onClick` emitted wins. */
|
|
137
|
+
onClick?: () => void;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type DashboardContext = {
|
|
141
|
+
/** Emit a label/value row like " label value". The label
|
|
142
|
+
* column is padded to 10 cols so multi-row sections align. */
|
|
143
|
+
kv(label: string, value: string, color?: DashboardColor): void;
|
|
144
|
+
/** Emit a styled text segment on the current row. No newline is
|
|
145
|
+
* added — call `newline()` when the row is finished. */
|
|
146
|
+
text(s: string, opts?: DashboardTextOpts): void;
|
|
147
|
+
/** End the current row. */
|
|
148
|
+
newline(): void;
|
|
149
|
+
/** Shortcut for a single-row error message: " status why". */
|
|
150
|
+
error(message: string): void;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type SectionRefresh = (ctx: DashboardContext) => Promise<void>;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Public surface of the bundled `dashboard` plugin, reachable through
|
|
157
|
+
* `editor.getPluginApi("dashboard")`. Third-party plugins and user
|
|
158
|
+
* init.ts can contribute their own rows via `registerSection`, and can
|
|
159
|
+
* tear them down again via `removeSection` / `clearAllSections`.
|
|
160
|
+
*/
|
|
161
|
+
export type DashboardApi = {
|
|
162
|
+
registerSection(name: string, refresh: SectionRefresh): () => void;
|
|
163
|
+
/** Remove every registered section whose name matches `name`.
|
|
164
|
+
* Returns true if at least one section was removed. */
|
|
165
|
+
removeSection(name: string): boolean;
|
|
166
|
+
/** Remove every registered section, including the bundled
|
|
167
|
+
* built-ins (git, disk). */
|
|
168
|
+
clearAllSections(): void;
|
|
169
|
+
/** Toggle the ambient auto-open behaviour for this session.
|
|
170
|
+
* Equivalent to setting `plugins.dashboard.auto-open` in the
|
|
171
|
+
* user config, but scoped to the current process. */
|
|
172
|
+
setAutoOpen(enabled: boolean): void;
|
|
173
|
+
/** Refresh handlers for the built-in widgets that aren't
|
|
174
|
+
* registered by default (both hit the network). Pass one to
|
|
175
|
+
* `registerSection` from init.ts to enable it. */
|
|
176
|
+
builtinHandlers: {
|
|
177
|
+
weather: SectionRefresh;
|
|
178
|
+
github: SectionRefresh;
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
declare global {
|
|
183
|
+
interface FreshPluginRegistry {
|
|
184
|
+
dashboard: DashboardApi;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type RegisteredSection = {
|
|
189
|
+
id: number;
|
|
190
|
+
name: string;
|
|
191
|
+
refresh: SectionRefresh;
|
|
192
|
+
/** Rendered output from the most recent refresh. Re-used until the
|
|
193
|
+
* next refresh lands so the dashboard doesn't flash back to a
|
|
194
|
+
* "loading…" placeholder on every tick. */
|
|
195
|
+
draw: Draw;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// ── Internal state ─────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
// State survives across open/close cycles so we don't pile up dashboards.
|
|
201
|
+
let dashboardBufferId: number | null = null;
|
|
202
|
+
let fetchToken = 0; // bumped each open; late fetches from a prior open no-op.
|
|
203
|
+
|
|
204
|
+
// Registered sections, in render order. Built-ins are registered at
|
|
205
|
+
// plugin load (see the bottom of this file); third-party plugins
|
|
206
|
+
// append via the exported `registerSection` API.
|
|
207
|
+
let nextSectionId = 1;
|
|
208
|
+
const registeredSections: RegisteredSection[] = [];
|
|
209
|
+
|
|
210
|
+
// Ordered list of focusable clickable targets on the currently-painted
|
|
211
|
+
// frame. Rebuilt every paint() from `currentRowActions`. One entry per
|
|
212
|
+
// row that carries at least one action — when a row holds multiple
|
|
213
|
+
// ranges (e.g. PR rows with both #num and title clickable), the highlight
|
|
214
|
+
// spans the union and the activation dispatches the first range's action
|
|
215
|
+
// so Enter matches the leftmost click target on that row.
|
|
216
|
+
type ClickTarget = {
|
|
217
|
+
bufferRow: number; // absolute buffer row (after topPad)
|
|
218
|
+
colStart: number; // visual col (inclusive)
|
|
219
|
+
colEnd: number; // visual col (exclusive)
|
|
220
|
+
action: ClickAction;
|
|
221
|
+
};
|
|
222
|
+
let clickableTargets: ClickTarget[] = [];
|
|
223
|
+
let focusedIndex = 0;
|
|
224
|
+
|
|
225
|
+
// ── Drawing primitives ─────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function utf8Len(s: string): number {
|
|
228
|
+
return editor.utf8ByteLength(s);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function visualWidth(s: string): number {
|
|
232
|
+
// Approximation: wide (E. Asian / most emoji) = 2 cols, everything else = 1.
|
|
233
|
+
let w = 0;
|
|
234
|
+
for (const ch of s) {
|
|
235
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
236
|
+
if (cp === 0) continue;
|
|
237
|
+
if (cp < 0x80) { w += 1; continue; }
|
|
238
|
+
// CJK / wide ranges (coarse).
|
|
239
|
+
if (
|
|
240
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
241
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
242
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
243
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
244
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
245
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
246
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
247
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
248
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
249
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
250
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
251
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
252
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff)
|
|
253
|
+
) { w += 2; continue; }
|
|
254
|
+
w += 1;
|
|
255
|
+
}
|
|
256
|
+
return w;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function pad(s: string, width: number): string {
|
|
260
|
+
const missing = Math.max(0, width - visualWidth(s));
|
|
261
|
+
return s + " ".repeat(missing);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function emit(
|
|
265
|
+
d: Draw,
|
|
266
|
+
s: string,
|
|
267
|
+
opts?: { fg?: string; bold?: boolean; url?: string; action?: ClickAction },
|
|
268
|
+
) {
|
|
269
|
+
if (!s) return;
|
|
270
|
+
const start = utf8Len(d.text);
|
|
271
|
+
d.text += s;
|
|
272
|
+
const width = visualWidth(s);
|
|
273
|
+
const startCol = d.currentCol;
|
|
274
|
+
d.currentCol += width;
|
|
275
|
+
// Anything the user can click gets underlined so it reads as a link
|
|
276
|
+
// even in terminals that don't render OSC-8 hyperlinks.
|
|
277
|
+
const clickable = !!(opts?.url || opts?.action);
|
|
278
|
+
if (opts?.fg || opts?.bold || opts?.url || clickable) {
|
|
279
|
+
d.spans.push({
|
|
280
|
+
start,
|
|
281
|
+
end: start + utf8Len(s),
|
|
282
|
+
fg: opts?.fg,
|
|
283
|
+
bold: opts?.bold,
|
|
284
|
+
underline: clickable || undefined,
|
|
285
|
+
url: opts?.url,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (opts?.action) {
|
|
289
|
+
// Record a column-scoped range so the click handler can match
|
|
290
|
+
// clicks only on the text cells the underline actually covers,
|
|
291
|
+
// not on padding or kv labels sharing the row.
|
|
292
|
+
const ranges = d.rowActions.get(d.currentRow) ?? [];
|
|
293
|
+
ranges.push({ colStart: startCol, colEnd: startCol + width, action: opts.action });
|
|
294
|
+
d.rowActions.set(d.currentRow, ranges);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function newline(d: Draw) {
|
|
299
|
+
d.text += "\n";
|
|
300
|
+
d.currentRow++;
|
|
301
|
+
d.currentCol = 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function emptyDraw(): Draw {
|
|
305
|
+
return { text: "", spans: [], currentRow: 0, currentCol: 0, rowActions: new Map() };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Sections (sentinel / placeholder factories) ────────────────────────
|
|
309
|
+
|
|
310
|
+
// Produce a one-line status row: " status text". Used for the
|
|
311
|
+
// initial "loading…" placeholder and for top-level error messages
|
|
312
|
+
// that replace a whole section's body.
|
|
313
|
+
function statusRowDraw(text: string, fg: string): Draw {
|
|
314
|
+
const d = emptyDraw();
|
|
315
|
+
const label = pad("status", 10);
|
|
316
|
+
emit(d, " " + label, { fg: C.muted });
|
|
317
|
+
emit(d, text, { fg });
|
|
318
|
+
newline(d);
|
|
319
|
+
return d;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function loadingDraw(): Draw {
|
|
323
|
+
return statusRowDraw("loading…", C.muted);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Section registry + DashboardContext factory ────────────────────────
|
|
327
|
+
|
|
328
|
+
// Build a DashboardContext that accumulates drawing operations into a
|
|
329
|
+
// fresh Draw. After the caller's refresh callback resolves, the Draw
|
|
330
|
+
// is stashed on the section entry and the dashboard repaints with it.
|
|
331
|
+
function makeContext(): { ctx: DashboardContext; draw: Draw } {
|
|
332
|
+
const d = emptyDraw();
|
|
333
|
+
const ctx: DashboardContext = {
|
|
334
|
+
kv(label, value, color) {
|
|
335
|
+
const fg = color ? COLOR_KEYS[color] : C.value;
|
|
336
|
+
emit(d, " " + pad(label, 10), { fg: C.muted });
|
|
337
|
+
emit(d, value, { fg });
|
|
338
|
+
newline(d);
|
|
339
|
+
},
|
|
340
|
+
text(s, opts) {
|
|
341
|
+
const action: ClickAction | undefined = opts?.onClick
|
|
342
|
+
? { kind: "callback", fn: opts.onClick }
|
|
343
|
+
: opts?.url
|
|
344
|
+
? { kind: "open-url", url: opts.url }
|
|
345
|
+
: undefined;
|
|
346
|
+
emit(d, s, {
|
|
347
|
+
fg: opts?.color ? COLOR_KEYS[opts.color] : undefined,
|
|
348
|
+
bold: opts?.bold,
|
|
349
|
+
url: opts?.url,
|
|
350
|
+
action,
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
newline() {
|
|
354
|
+
newline(d);
|
|
355
|
+
},
|
|
356
|
+
error(message) {
|
|
357
|
+
const label = pad("status", 10);
|
|
358
|
+
emit(d, " " + label, { fg: C.muted });
|
|
359
|
+
emit(d, message, { fg: C.err });
|
|
360
|
+
newline(d);
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
return { ctx, draw: d };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Register a dashboard section. `refresh` is invoked each tick (every
|
|
367
|
+
// 5s while the dashboard is visible) and on every open. Returns a
|
|
368
|
+
// function that unregisters the section when called — e.g. for
|
|
369
|
+
// plugins that want to remove their section on disable.
|
|
370
|
+
function registerSection(name: string, refresh: SectionRefresh): () => void {
|
|
371
|
+
const id = nextSectionId++;
|
|
372
|
+
const entry: RegisteredSection = {
|
|
373
|
+
id,
|
|
374
|
+
name,
|
|
375
|
+
refresh,
|
|
376
|
+
draw: loadingDraw(),
|
|
377
|
+
};
|
|
378
|
+
registeredSections.push(entry);
|
|
379
|
+
// Kick an immediate refresh so the initial frame isn't "loading…"
|
|
380
|
+
// for any longer than the callback actually takes.
|
|
381
|
+
void refreshSection(entry, fetchToken);
|
|
382
|
+
paint();
|
|
383
|
+
return () => {
|
|
384
|
+
const idx = registeredSections.findIndex((s) => s.id === id);
|
|
385
|
+
if (idx >= 0) {
|
|
386
|
+
registeredSections.splice(idx, 1);
|
|
387
|
+
paint();
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Remove every registered section whose name matches `name`. Returns
|
|
393
|
+
// true if at least one section was removed. Names are compared verbatim
|
|
394
|
+
// — no case folding or trimming — matching the name passed to
|
|
395
|
+
// `registerSection`. In-flight refreshes for removed sections resolve
|
|
396
|
+
// onto detached entry objects and no longer influence what's rendered.
|
|
397
|
+
function removeSection(name: string): boolean {
|
|
398
|
+
let removed = false;
|
|
399
|
+
for (let i = registeredSections.length - 1; i >= 0; i--) {
|
|
400
|
+
if (registeredSections[i].name === name) {
|
|
401
|
+
registeredSections.splice(i, 1);
|
|
402
|
+
removed = true;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (removed) paint();
|
|
406
|
+
return removed;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Clear every registered section, including the bundled built-ins.
|
|
410
|
+
// The dashboard frame is still drawn — only the section body between
|
|
411
|
+
// header and footer is empty until something registers a new section.
|
|
412
|
+
function clearAllSections(): void {
|
|
413
|
+
if (registeredSections.length === 0) return;
|
|
414
|
+
registeredSections.length = 0;
|
|
415
|
+
paint();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function refreshSection(entry: RegisteredSection, myToken: number) {
|
|
419
|
+
const { ctx, draw } = makeContext();
|
|
420
|
+
try {
|
|
421
|
+
await entry.refresh(ctx);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
// A thrown error becomes a one-line error row so a buggy
|
|
424
|
+
// third-party section can't blank the whole dashboard.
|
|
425
|
+
const { ctx: fallbackCtx, draw: fallbackDraw } = makeContext();
|
|
426
|
+
fallbackCtx.error(`failed — ${String(e).slice(0, 60)}`);
|
|
427
|
+
if (myToken !== fetchToken) return;
|
|
428
|
+
entry.draw = fallbackDraw;
|
|
429
|
+
paint();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (myToken !== fetchToken) return;
|
|
433
|
+
entry.draw = draw;
|
|
434
|
+
paint();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Frame + section renderer ───────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
function clockNow(): string {
|
|
440
|
+
const d = new Date();
|
|
441
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
442
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
443
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
444
|
+
return `${hh}:${mm}:${ss}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function frameWidth(viewportW: number): { inner: number; leftPad: number } {
|
|
448
|
+
const usable = Math.max(40, viewportW - 4);
|
|
449
|
+
const inner = Math.min(MAX_INNER, usable - 2); // subtract 2 for frame edges
|
|
450
|
+
const total = inner + 2;
|
|
451
|
+
const leftPad = Math.max(0, Math.floor((viewportW - total) / 2));
|
|
452
|
+
return { inner, leftPad };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function renderFrame(inner: number, leftPad: number): Draw {
|
|
456
|
+
const d: Draw = emptyDraw();
|
|
457
|
+
const lp = " ".repeat(leftPad);
|
|
458
|
+
|
|
459
|
+
const titleText = "FRESH";
|
|
460
|
+
const stamp = clockNow();
|
|
461
|
+
const titleSegment = ` ${titleText} `;
|
|
462
|
+
const stampSegment = ` ${stamp} `;
|
|
463
|
+
// Top frame: ╭── FRESH ────…──── HH:MM:SS ──╮
|
|
464
|
+
//
|
|
465
|
+
// `inner` is the column count between the two corner glyphs. The top
|
|
466
|
+
// row emits, between ╭ and ╮:
|
|
467
|
+
// "──" (2) + titleSegment (7) + dashRun (fillLen) + stampSegment (10) + "──" (2)
|
|
468
|
+
// so fillLen = inner - visualWidth(titleSegment) - visualWidth(stampSegment) - 4.
|
|
469
|
+
const fillLen =
|
|
470
|
+
inner - visualWidth(titleSegment) - visualWidth(stampSegment) - 4;
|
|
471
|
+
const dashRun = "─".repeat(Math.max(1, fillLen));
|
|
472
|
+
|
|
473
|
+
// top
|
|
474
|
+
emit(d, lp, undefined);
|
|
475
|
+
emit(d, "╭──", { fg: C.frame });
|
|
476
|
+
emit(d, titleSegment, { fg: C.title, bold: true });
|
|
477
|
+
emit(d, dashRun, { fg: C.frame });
|
|
478
|
+
emit(d, stampSegment, { fg: C.muted });
|
|
479
|
+
emit(d, "──╮", { fg: C.frame });
|
|
480
|
+
newline(d);
|
|
481
|
+
|
|
482
|
+
// blank row
|
|
483
|
+
emit(d, lp, undefined);
|
|
484
|
+
emit(d, "│", { fg: C.frame });
|
|
485
|
+
emit(d, " ".repeat(inner), undefined);
|
|
486
|
+
emit(d, "│", { fg: C.frame });
|
|
487
|
+
newline(d);
|
|
488
|
+
|
|
489
|
+
const sectionHeader = (name: string) => {
|
|
490
|
+
// Format: │ ▎ NAME ...
|
|
491
|
+
// Dropped per-section icons: their widths (☀ ⎇ ⚡ ◆) disagree with
|
|
492
|
+
// unicode-width depending on font/emoji-presentation, which
|
|
493
|
+
// silently misaligned the right frame edge.
|
|
494
|
+
const prefix = " ▎ ";
|
|
495
|
+
emit(d, lp, undefined);
|
|
496
|
+
emit(d, "│", { fg: C.frame });
|
|
497
|
+
emit(d, prefix, { fg: C.accent, bold: true });
|
|
498
|
+
emit(d, name, { fg: C.title, bold: true });
|
|
499
|
+
const consumed = visualWidth(prefix) + visualWidth(name);
|
|
500
|
+
emit(d, " ".repeat(Math.max(0, inner - consumed)), undefined);
|
|
501
|
+
emit(d, "│", { fg: C.frame });
|
|
502
|
+
newline(d);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const row = (
|
|
506
|
+
body: { text: string; spans: Span[] },
|
|
507
|
+
ranges?: ClickActionRange[],
|
|
508
|
+
) => {
|
|
509
|
+
// Wraps a single logical row of section body in the frame.
|
|
510
|
+
emit(d, lp, undefined);
|
|
511
|
+
emit(d, "│", { fg: C.frame });
|
|
512
|
+
// body is already one line (no embedded newlines) — renderSection
|
|
513
|
+
// slices multi-line section output before calling row().
|
|
514
|
+
const line = body.text;
|
|
515
|
+
const used = visualWidth(line);
|
|
516
|
+
const startInDoc = utf8Len(d.text);
|
|
517
|
+
// Content starts in the outer draw at this visual column —
|
|
518
|
+
// section-body ranges are offset by it below so clicks hit
|
|
519
|
+
// the same cells the text actually lives in.
|
|
520
|
+
const contentStartCol = d.currentCol;
|
|
521
|
+
d.text += line;
|
|
522
|
+
d.currentCol += used;
|
|
523
|
+
for (const sp of body.spans) {
|
|
524
|
+
if (sp.start < utf8Len(line)) {
|
|
525
|
+
d.spans.push({
|
|
526
|
+
start: startInDoc + sp.start,
|
|
527
|
+
end: startInDoc + Math.min(sp.end, utf8Len(line)),
|
|
528
|
+
fg: sp.fg,
|
|
529
|
+
bold: sp.bold,
|
|
530
|
+
underline: sp.underline,
|
|
531
|
+
url: sp.url,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (ranges && ranges.length > 0) {
|
|
536
|
+
const shifted = ranges.map((r) => ({
|
|
537
|
+
colStart: r.colStart + contentStartCol,
|
|
538
|
+
colEnd: r.colEnd + contentStartCol,
|
|
539
|
+
action: r.action,
|
|
540
|
+
}));
|
|
541
|
+
const existing = d.rowActions.get(d.currentRow) ?? [];
|
|
542
|
+
d.rowActions.set(d.currentRow, existing.concat(shifted));
|
|
543
|
+
}
|
|
544
|
+
emit(d, " ".repeat(Math.max(0, inner - used)), undefined);
|
|
545
|
+
emit(d, "│", { fg: C.frame });
|
|
546
|
+
newline(d);
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const spacerRow = () => {
|
|
550
|
+
emit(d, lp, undefined);
|
|
551
|
+
emit(d, "│", { fg: C.frame });
|
|
552
|
+
emit(d, " ".repeat(inner), undefined);
|
|
553
|
+
emit(d, "│", { fg: C.frame });
|
|
554
|
+
newline(d);
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const renderSection = (name: string, body: Draw) => {
|
|
558
|
+
sectionHeader(name);
|
|
559
|
+
const bodyLines = body.text.split("\n");
|
|
560
|
+
let cursor = 0;
|
|
561
|
+
for (let lineIdx = 0; lineIdx < bodyLines.length; lineIdx++) {
|
|
562
|
+
const ln = bodyLines[lineIdx];
|
|
563
|
+
if (ln.length === 0 && cursor + ln.length + 1 >= body.text.length) break;
|
|
564
|
+
// Slice the body's spans that fall inside this line's byte range.
|
|
565
|
+
const lineStart = cursor;
|
|
566
|
+
const lineEnd = cursor + utf8Len(ln);
|
|
567
|
+
const sliced: Span[] = body.spans
|
|
568
|
+
.filter((sp) => sp.start >= lineStart && sp.end <= lineEnd + 1)
|
|
569
|
+
.map((sp) => ({
|
|
570
|
+
start: sp.start - lineStart,
|
|
571
|
+
end: sp.end - lineStart,
|
|
572
|
+
fg: sp.fg,
|
|
573
|
+
bold: sp.bold,
|
|
574
|
+
underline: sp.underline,
|
|
575
|
+
url: sp.url,
|
|
576
|
+
}));
|
|
577
|
+
// Propagate the section-level row action (keyed by section-body
|
|
578
|
+
// line index) onto the outer frame row we're about to write.
|
|
579
|
+
row({ text: ln, spans: sliced }, body.rowActions.get(lineIdx));
|
|
580
|
+
cursor = lineEnd + 1;
|
|
581
|
+
}
|
|
582
|
+
spacerRow();
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
for (const entry of registeredSections) {
|
|
586
|
+
renderSection(entry.name.toUpperCase(), entry.draw);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// bottom
|
|
590
|
+
emit(d, lp, undefined);
|
|
591
|
+
emit(d, "╰" + "─".repeat(inner) + "╯", { fg: C.frame });
|
|
592
|
+
newline(d);
|
|
593
|
+
|
|
594
|
+
return d;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── Paint the buffer ───────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
// Convert the byte-indexed Draw model produced by renderFrame into per-line
|
|
600
|
+
// TextPropertyEntry[] with inlineOverlays. Spans are expected to stay within
|
|
601
|
+
// a single line (the renderer never emits a newline inside a styled span)
|
|
602
|
+
// but we clip defensively so a stray cross-line span doesn't misindex.
|
|
603
|
+
function drawToEntries(d: Draw): TextPropertyEntry[] {
|
|
604
|
+
const entries: TextPropertyEntry[] = [];
|
|
605
|
+
const lines = d.text.split("\n");
|
|
606
|
+
let lineByteStart = 0;
|
|
607
|
+
for (let i = 0; i < lines.length; i++) {
|
|
608
|
+
const line = lines[i];
|
|
609
|
+
const isLast = i === lines.length - 1;
|
|
610
|
+
if (isLast && line.length === 0) break; // trailing empty after final \n
|
|
611
|
+
const lineBytes = utf8Len(line);
|
|
612
|
+
const lineByteEnd = lineByteStart + lineBytes;
|
|
613
|
+
const ios: InlineOverlay[] = [];
|
|
614
|
+
for (const sp of d.spans) {
|
|
615
|
+
if (sp.end <= lineByteStart) continue;
|
|
616
|
+
if (sp.start >= lineByteEnd) continue;
|
|
617
|
+
const s = Math.max(sp.start, lineByteStart) - lineByteStart;
|
|
618
|
+
const e = Math.min(sp.end, lineByteEnd) - lineByteStart;
|
|
619
|
+
if (e <= s) continue;
|
|
620
|
+
const style: Partial<OverlayOptions> = {};
|
|
621
|
+
if (sp.fg) style.fg = sp.fg;
|
|
622
|
+
if (sp.bold) style.bold = true;
|
|
623
|
+
if (sp.underline) style.underline = true;
|
|
624
|
+
if (sp.url) style.url = sp.url;
|
|
625
|
+
ios.push({ start: s, end: e, style });
|
|
626
|
+
}
|
|
627
|
+
entries.push({
|
|
628
|
+
text: line + (isLast ? "" : "\n"),
|
|
629
|
+
inlineOverlays: ios.length > 0 ? ios : undefined,
|
|
630
|
+
});
|
|
631
|
+
lineByteStart = lineByteEnd + 1; // account for the \n byte we split on
|
|
632
|
+
}
|
|
633
|
+
return entries;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Track the last viewport dims we painted for, so repeat viewport_changed
|
|
637
|
+
// events (e.g. scroll fires one every time) don't trigger redundant paints.
|
|
638
|
+
let lastPaintedW = -1;
|
|
639
|
+
let lastPaintedH = -1;
|
|
640
|
+
|
|
641
|
+
// Row-index → click-action ranges map, keyed by absolute buffer row
|
|
642
|
+
// (after adding topPad). Each range is a `[colStart, colEnd)` pair so
|
|
643
|
+
// the click handler can gate on buffer_col too. Rebuilt every paint.
|
|
644
|
+
let currentRowActions: Map<number, ClickActionRange[]> = new Map();
|
|
645
|
+
|
|
646
|
+
// Map a visual column (counted from the start of the line) to a UTF-8
|
|
647
|
+
// byte offset inside `text`. Used to translate the visual col ranges
|
|
648
|
+
// stored in ClickTarget into the byte-offset range an InlineOverlay
|
|
649
|
+
// needs. Walks chars with visualWidth/utf8Len so frame glyphs like
|
|
650
|
+
// `│` (3 bytes, 1 col) and future wide chars stay aligned.
|
|
651
|
+
function visualColToByteOffset(text: string, visualCol: number): number {
|
|
652
|
+
if (visualCol <= 0) return 0;
|
|
653
|
+
let col = 0;
|
|
654
|
+
let bytes = 0;
|
|
655
|
+
for (const ch of text) {
|
|
656
|
+
if (col >= visualCol) return bytes;
|
|
657
|
+
col += visualWidth(ch);
|
|
658
|
+
bytes += utf8Len(ch);
|
|
659
|
+
}
|
|
660
|
+
return bytes;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function paint(dims?: { width: number; height: number }) {
|
|
664
|
+
if (dashboardBufferId === null) return;
|
|
665
|
+
const bufferId = dashboardBufferId;
|
|
666
|
+
// Prefer explicit dims (from a viewport_changed event, which ships
|
|
667
|
+
// the just-resized width/height before the state snapshot catches
|
|
668
|
+
// up) and fall back to the snapshot. Without this, toggling the
|
|
669
|
+
// file explorer repaints against the stale pre-toggle width, so
|
|
670
|
+
// the frame stays anchored at the old position for one tick.
|
|
671
|
+
const vp = dims ?? editor.getViewport();
|
|
672
|
+
const width = vp?.width ?? 100;
|
|
673
|
+
const height = vp?.height ?? 24;
|
|
674
|
+
const { inner, leftPad } = frameWidth(width);
|
|
675
|
+
const drawn = renderFrame(inner, leftPad);
|
|
676
|
+
|
|
677
|
+
// Count newlines in the rendered frame to vertically center it. Pad
|
|
678
|
+
// above with blank lines; there's no need to pad below since the
|
|
679
|
+
// virtual buffer's empty trailing rows already render as blank.
|
|
680
|
+
let frameHeight = 0;
|
|
681
|
+
for (let i = 0; i < drawn.text.length; i++) {
|
|
682
|
+
if (drawn.text.charCodeAt(i) === 10) frameHeight++;
|
|
683
|
+
}
|
|
684
|
+
const topPad = Math.max(0, Math.floor((height - frameHeight) / 2));
|
|
685
|
+
|
|
686
|
+
const entries: TextPropertyEntry[] = [];
|
|
687
|
+
for (let i = 0; i < topPad; i++) entries.push({ text: "\n" });
|
|
688
|
+
for (const e of drawToEntries(drawn)) entries.push(e);
|
|
689
|
+
|
|
690
|
+
// Translate frame-relative row actions to absolute buffer rows by
|
|
691
|
+
// shifting by the vertical padding we just prepended. Columns are
|
|
692
|
+
// absolute already (the frame renderer placed them via currentCol).
|
|
693
|
+
const abs: Map<number, ClickActionRange[]> = new Map();
|
|
694
|
+
for (const [row, ranges] of drawn.rowActions) {
|
|
695
|
+
abs.set(row + topPad, ranges);
|
|
696
|
+
}
|
|
697
|
+
currentRowActions = abs;
|
|
698
|
+
|
|
699
|
+
// Rebuild the ordered focus targets in visual row order. A row with
|
|
700
|
+
// multiple ranges collapses into a single target whose highlight
|
|
701
|
+
// spans the union of its ranges; Enter dispatches the first range's
|
|
702
|
+
// action, matching the leftmost click target on that row.
|
|
703
|
+
const targets: ClickTarget[] = [];
|
|
704
|
+
const sortedRows = [...abs.keys()].sort((a, b) => a - b);
|
|
705
|
+
for (const row of sortedRows) {
|
|
706
|
+
const ranges = abs.get(row)!;
|
|
707
|
+
if (ranges.length === 0) continue;
|
|
708
|
+
let minCol = ranges[0].colStart;
|
|
709
|
+
let maxCol = ranges[0].colEnd;
|
|
710
|
+
for (const r of ranges) {
|
|
711
|
+
if (r.colStart < minCol) minCol = r.colStart;
|
|
712
|
+
if (r.colEnd > maxCol) maxCol = r.colEnd;
|
|
713
|
+
}
|
|
714
|
+
targets.push({
|
|
715
|
+
bufferRow: row,
|
|
716
|
+
colStart: minCol,
|
|
717
|
+
colEnd: maxCol,
|
|
718
|
+
action: ranges[0].action,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
clickableTargets = targets;
|
|
722
|
+
if (targets.length === 0) {
|
|
723
|
+
focusedIndex = 0;
|
|
724
|
+
} else if (focusedIndex < 0 || focusedIndex >= targets.length) {
|
|
725
|
+
focusedIndex =
|
|
726
|
+
((focusedIndex % targets.length) + targets.length) % targets.length;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Paint the focus highlight by mutating the entry for the focused
|
|
730
|
+
// row: translate its visual col range into a byte range and push an
|
|
731
|
+
// inline overlay on top of whatever foreground/underline spans the
|
|
732
|
+
// frame renderer already added. Using `editor.selection_bg` keeps
|
|
733
|
+
// the highlight theme-aware — it follows theme switches for free
|
|
734
|
+
// and matches other selection-style highlights elsewhere in the UI.
|
|
735
|
+
if (targets.length > 0) {
|
|
736
|
+
const focus = targets[focusedIndex];
|
|
737
|
+
const entry = entries[focus.bufferRow];
|
|
738
|
+
if (entry) {
|
|
739
|
+
const lineText = entry.text.endsWith("\n")
|
|
740
|
+
? entry.text.slice(0, -1)
|
|
741
|
+
: entry.text;
|
|
742
|
+
const byteStart = visualColToByteOffset(lineText, focus.colStart);
|
|
743
|
+
const byteEnd = visualColToByteOffset(lineText, focus.colEnd);
|
|
744
|
+
if (byteEnd > byteStart) {
|
|
745
|
+
const overlays: InlineOverlay[] = entry.inlineOverlays
|
|
746
|
+
? [...entry.inlineOverlays]
|
|
747
|
+
: [];
|
|
748
|
+
overlays.push({
|
|
749
|
+
start: byteStart,
|
|
750
|
+
end: byteEnd,
|
|
751
|
+
style: { bg: "editor.selection_bg" },
|
|
752
|
+
});
|
|
753
|
+
entry.inlineOverlays = overlays;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
editor.setVirtualBufferContent(bufferId, entries);
|
|
759
|
+
lastPaintedW = width;
|
|
760
|
+
lastPaintedH = height;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Open a URL in the user's browser via the platform's "open" helper.
|
|
764
|
+
// Fires both xdg-open (Linux) and open (macOS) — only one exists per
|
|
765
|
+
// platform; the other exits immediately with ENOENT and causes no
|
|
766
|
+
// user-visible effect. Fire-and-forget: we don't await.
|
|
767
|
+
function openUrl(url: string) {
|
|
768
|
+
// spawnProcess returns a ProcessHandle; we intentionally discard it.
|
|
769
|
+
// The process runs to completion on its own; failures are silent.
|
|
770
|
+
editor.spawnProcess("xdg-open", [url]);
|
|
771
|
+
editor.spawnProcess("open", [url]);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function dispatchClickAction(action: ClickAction) {
|
|
775
|
+
switch (action.kind) {
|
|
776
|
+
case "open-url":
|
|
777
|
+
openUrl(action.url);
|
|
778
|
+
return;
|
|
779
|
+
case "callback":
|
|
780
|
+
try {
|
|
781
|
+
action.fn();
|
|
782
|
+
} catch (e) {
|
|
783
|
+
editor.debug(`dashboard click handler threw: ${String(e)}`);
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Data fetchers ──────────────────────────────────────────────────────
|
|
790
|
+
|
|
791
|
+
async function run(
|
|
792
|
+
cmd: string,
|
|
793
|
+
args: string[],
|
|
794
|
+
cwd: string,
|
|
795
|
+
timeoutMs: number,
|
|
796
|
+
): Promise<{ stdout: string; stderr: string; ok: boolean }> {
|
|
797
|
+
const handle = editor.spawnProcess(cmd, args, cwd);
|
|
798
|
+
const timeout = editor.delay(timeoutMs).then(() => "__timeout__");
|
|
799
|
+
const res = await Promise.race([(async () => await handle)(), timeout]);
|
|
800
|
+
if (res === "__timeout__") {
|
|
801
|
+
await handle.kill();
|
|
802
|
+
return { stdout: "", stderr: "timed out", ok: false };
|
|
803
|
+
}
|
|
804
|
+
const r = res as SpawnResult;
|
|
805
|
+
return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", ok: r.exit_code === 0 };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const trim = (s: string) => s.replace(/\s+$/, "");
|
|
809
|
+
|
|
810
|
+
// Truncate to at most `maxCols` visual columns. Adds an ellipsis when
|
|
811
|
+
// the string is shortened. Uses the same visualWidth estimator as the
|
|
812
|
+
// frame renderer so the result fits exactly.
|
|
813
|
+
function truncate(s: string, maxCols: number): string {
|
|
814
|
+
if (visualWidth(s) <= maxCols) return s;
|
|
815
|
+
let out = "";
|
|
816
|
+
let w = 0;
|
|
817
|
+
for (const ch of s) {
|
|
818
|
+
const cw = visualWidth(ch);
|
|
819
|
+
if (w + cw > Math.max(0, maxCols - 1)) break;
|
|
820
|
+
out += ch;
|
|
821
|
+
w += cw;
|
|
822
|
+
}
|
|
823
|
+
return out + "…";
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Max room for a `kv` value cell inside a standard row. The ` ` + 10-
|
|
827
|
+
// col padded key consume 14 cols, so the value must fit in inner - 14.
|
|
828
|
+
// With MAX_INNER = 72, that's 58 cols in the default case.
|
|
829
|
+
const VALUE_MAX = MAX_INNER - 14;
|
|
830
|
+
|
|
831
|
+
function bar(pct: number, width: number): string {
|
|
832
|
+
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
|
|
833
|
+
return "━".repeat(filled) + "╌".repeat(width - filled);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// wttr.in's j1 response shape — only the fields we consume.
|
|
837
|
+
type WttrHour = {
|
|
838
|
+
time?: string; // "0", "300", …, "2100"
|
|
839
|
+
tempC?: string;
|
|
840
|
+
FeelsLikeC?: string;
|
|
841
|
+
windspeedKmph?: string;
|
|
842
|
+
humidity?: string;
|
|
843
|
+
weatherDesc?: { value?: string }[];
|
|
844
|
+
};
|
|
845
|
+
type WttrDay = {
|
|
846
|
+
date?: string;
|
|
847
|
+
maxtempC?: string;
|
|
848
|
+
mintempC?: string;
|
|
849
|
+
hourly?: WttrHour[];
|
|
850
|
+
};
|
|
851
|
+
type WttrCurrent = {
|
|
852
|
+
temp_C?: string;
|
|
853
|
+
FeelsLikeC?: string;
|
|
854
|
+
windspeedKmph?: string;
|
|
855
|
+
humidity?: string;
|
|
856
|
+
weatherDesc?: { value?: string }[];
|
|
857
|
+
};
|
|
858
|
+
type WttrJ1 = {
|
|
859
|
+
current_condition?: WttrCurrent[];
|
|
860
|
+
weather?: WttrDay[];
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// Find the hourly entry whose `time` is closest to `targetHour` (0–23).
|
|
864
|
+
// wttr.in returns 3-hour samples encoded as "0", "300", "600", …, so a
|
|
865
|
+
// requested 17 (5pm) snaps to the 1800 sample. We round to the nearest
|
|
866
|
+
// (rather than floor) to avoid showing "morning" weather for 17:00 just
|
|
867
|
+
// because 1500 is numerically smaller.
|
|
868
|
+
function hourlyAt(hours: WttrHour[], targetHour: number): WttrHour | null {
|
|
869
|
+
let best: WttrHour | null = null;
|
|
870
|
+
let bestDelta = Infinity;
|
|
871
|
+
for (const h of hours) {
|
|
872
|
+
const raw = h.time ?? "";
|
|
873
|
+
const hh = Math.round((Number(raw) || 0) / 100);
|
|
874
|
+
const delta = Math.abs(hh - targetHour);
|
|
875
|
+
if (delta < bestDelta) {
|
|
876
|
+
best = h;
|
|
877
|
+
bestDelta = delta;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return best;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function formatCurrent(c: WttrCurrent | undefined): string | null {
|
|
884
|
+
if (!c) return null;
|
|
885
|
+
const cond = c.weatherDesc?.[0]?.value ?? "";
|
|
886
|
+
const temp = c.temp_C ? `${c.temp_C}°C` : "";
|
|
887
|
+
const feels = c.FeelsLikeC && c.FeelsLikeC !== c.temp_C
|
|
888
|
+
? `feels ${c.FeelsLikeC}°C` : "";
|
|
889
|
+
const wind = c.windspeedKmph ? `${c.windspeedKmph} km/h` : "";
|
|
890
|
+
const hum = c.humidity ? `${c.humidity}%` : "";
|
|
891
|
+
const s = [cond, temp, feels, wind, hum]
|
|
892
|
+
.filter((x) => x.length > 0)
|
|
893
|
+
.join(" · ");
|
|
894
|
+
return s ? truncate(s, VALUE_MAX) : null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function formatHour(h: WttrHour | null): string | null {
|
|
898
|
+
if (!h) return null;
|
|
899
|
+
const cond = h.weatherDesc?.[0]?.value ?? "";
|
|
900
|
+
const temp = h.tempC ? `${h.tempC}°C` : "";
|
|
901
|
+
const feels = h.FeelsLikeC && h.FeelsLikeC !== h.tempC
|
|
902
|
+
? `feels ${h.FeelsLikeC}°C` : "";
|
|
903
|
+
const s = [cond, temp, feels]
|
|
904
|
+
.filter((x) => x.length > 0)
|
|
905
|
+
.join(" · ");
|
|
906
|
+
return s ? truncate(s, VALUE_MAX) : null;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function formatDaySummary(day: WttrDay | undefined): string | null {
|
|
910
|
+
if (!day) return null;
|
|
911
|
+
const midday = hourlyAt(day.hourly ?? [], 12);
|
|
912
|
+
const cond = midday?.weatherDesc?.[0]?.value ?? "";
|
|
913
|
+
const range = day.mintempC && day.maxtempC
|
|
914
|
+
? `${day.mintempC}°..${day.maxtempC}°C`
|
|
915
|
+
: "";
|
|
916
|
+
const s = [range, cond].filter((x) => x.length > 0).join(" · ");
|
|
917
|
+
return s ? truncate(s, VALUE_MAX) : null;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const weatherRefresh: SectionRefresh = async (ctx) => {
|
|
921
|
+
let stdout: string;
|
|
922
|
+
let ok: boolean;
|
|
923
|
+
try {
|
|
924
|
+
// j1 = full JSON payload (current + 3-day forecast, 3-hour samples).
|
|
925
|
+
// Larger than the old %-format but gets us everything in one call.
|
|
926
|
+
const res = await run(
|
|
927
|
+
"curl",
|
|
928
|
+
["-fsS", "--max-time", "5", "https://wttr.in/?format=j1"],
|
|
929
|
+
"",
|
|
930
|
+
6000,
|
|
931
|
+
);
|
|
932
|
+
stdout = res.stdout;
|
|
933
|
+
ok = res.ok;
|
|
934
|
+
} catch {
|
|
935
|
+
ctx.error("fetch failed");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (!ok || !stdout.trim()) {
|
|
939
|
+
ctx.error("offline");
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
let parsed: WttrJ1;
|
|
943
|
+
try {
|
|
944
|
+
parsed = JSON.parse(stdout) as WttrJ1;
|
|
945
|
+
} catch {
|
|
946
|
+
ctx.error("malformed response");
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const now = formatCurrent(parsed.current_condition?.[0]);
|
|
950
|
+
// "5pm": nearest 3-hour sample in today's forecast. wttr.in
|
|
951
|
+
// buckets at 0/3/6/…/21, so 17:00 picks the 18:00 bucket.
|
|
952
|
+
const todayHours = parsed.weather?.[0]?.hourly ?? [];
|
|
953
|
+
const evening = formatHour(hourlyAt(todayHours, 17));
|
|
954
|
+
const tomorrow = formatDaySummary(parsed.weather?.[1]);
|
|
955
|
+
ctx.kv("now", now ?? "–", now ? "accent" : "muted");
|
|
956
|
+
if (evening) ctx.kv("5pm", evening, "value");
|
|
957
|
+
if (tomorrow) ctx.kv("tomorrow", tomorrow, "value");
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
function normalizeRepoUrl(raw: string): string | null {
|
|
961
|
+
const s = trim(raw);
|
|
962
|
+
if (!s) return null;
|
|
963
|
+
// git@github.com:owner/repo(.git)? -> https://github.com/owner/repo
|
|
964
|
+
const sshMatch = s.match(/^git@([^:]+):(.+?)(\.git)?$/);
|
|
965
|
+
if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;
|
|
966
|
+
// https://github.com/owner/repo(.git)? -> stripped
|
|
967
|
+
const httpsMatch = s.match(/^(https?:\/\/[^/]+\/.+?)(\.git)?$/);
|
|
968
|
+
if (httpsMatch) return httpsMatch[1];
|
|
969
|
+
return s;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Extract "owner/repo" (GitHub "nwo" — name-with-owner) from a git
|
|
973
|
+
// remote URL. Returns null for non-GitHub hosts or unparseable URLs so
|
|
974
|
+
// fetchGithub can surface a clear reason instead of firing off a query
|
|
975
|
+
// against the wrong repo.
|
|
976
|
+
function parseGithubNwo(raw: string): string | null {
|
|
977
|
+
const s = trim(raw);
|
|
978
|
+
if (!s) return null;
|
|
979
|
+
// git@github.com:owner/repo(.git)?
|
|
980
|
+
const ssh = s.match(/^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/i);
|
|
981
|
+
if (ssh) return `${ssh[1]}/${ssh[2]}`;
|
|
982
|
+
// https://github.com/owner/repo(.git)? (allow optional user:token@ prefix)
|
|
983
|
+
const https = s.match(
|
|
984
|
+
/^https?:\/\/(?:[^@/]*@)?github\.com\/([^/]+)\/([^/]+?)(\.git)?\/?$/i,
|
|
985
|
+
);
|
|
986
|
+
if (https) return `${https[1]}/${https[2]}`;
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Parse the two numbers produced by `git rev-list --left-right --count`
|
|
991
|
+
// ("<ahead> <behind>"). Returns null on malformed output.
|
|
992
|
+
function parseLeftRight(stdout: string): { ahead: number; behind: number } | null {
|
|
993
|
+
const parts = trim(stdout).split(/\s+/);
|
|
994
|
+
const a = Number(parts[0]);
|
|
995
|
+
const b = Number(parts[1]);
|
|
996
|
+
if (isNaN(a) || isNaN(b)) return null;
|
|
997
|
+
return { ahead: a, behind: b };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const gitRefresh: SectionRefresh = async (ctx) => {
|
|
1001
|
+
const cwd = editor.getCwd();
|
|
1002
|
+
let branch;
|
|
1003
|
+
let status;
|
|
1004
|
+
let ahead;
|
|
1005
|
+
let remote;
|
|
1006
|
+
try {
|
|
1007
|
+
[branch, status, ahead, remote] = await Promise.all([
|
|
1008
|
+
run("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd, 3000),
|
|
1009
|
+
run("git", ["status", "--porcelain"], cwd, 3000),
|
|
1010
|
+
run("git", ["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd, 3000),
|
|
1011
|
+
run("git", ["remote", "get-url", "origin"], cwd, 3000),
|
|
1012
|
+
]);
|
|
1013
|
+
} catch {
|
|
1014
|
+
ctx.error("git failed");
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!branch.ok) {
|
|
1018
|
+
ctx.error("not a git repo");
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const modified = status.stdout
|
|
1022
|
+
.split("\n")
|
|
1023
|
+
.filter((l) => l.trim().length > 0).length;
|
|
1024
|
+
let trackStr = "no upstream";
|
|
1025
|
+
let trackColor: DashboardColor = "muted";
|
|
1026
|
+
if (ahead.ok) {
|
|
1027
|
+
const ab = parseLeftRight(ahead.stdout);
|
|
1028
|
+
if (ab) {
|
|
1029
|
+
trackStr = `↑ ${ab.ahead} ↓ ${ab.behind}`;
|
|
1030
|
+
trackColor = ab.ahead > 0 || ab.behind > 0 ? "accent" : "ok";
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const repoUrl = remote.ok ? normalizeRepoUrl(remote.stdout) : null;
|
|
1034
|
+
const branchName = trim(branch.stdout);
|
|
1035
|
+
|
|
1036
|
+
// "vs master" row: commits ahead/behind of master, or main as a
|
|
1037
|
+
// fallback for repos that use it as the default branch. Skipped
|
|
1038
|
+
// when the current branch IS master/main (self-comparison is 0/0
|
|
1039
|
+
// and not interesting), or when neither ref exists.
|
|
1040
|
+
let vsBase: { base: string; ahead: number; behind: number } | null = null;
|
|
1041
|
+
if (branchName !== "master" && branchName !== "main") {
|
|
1042
|
+
for (const base of ["origin/master", "origin/main", "master", "main"]) {
|
|
1043
|
+
const r = await run(
|
|
1044
|
+
"git",
|
|
1045
|
+
["rev-list", "--left-right", "--count", `HEAD...${base}`],
|
|
1046
|
+
cwd,
|
|
1047
|
+
3000,
|
|
1048
|
+
);
|
|
1049
|
+
if (r.ok) {
|
|
1050
|
+
const ab = parseLeftRight(r.stdout);
|
|
1051
|
+
if (ab) {
|
|
1052
|
+
vsBase = { base: base.replace(/^origin\//, ""), ...ab };
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// branch — whole row routes clicks to the branch page.
|
|
1060
|
+
const branchBranchUrl = repoUrl
|
|
1061
|
+
? `${repoUrl}/tree/${encodeURIComponent(branchName)}`
|
|
1062
|
+
: undefined;
|
|
1063
|
+
ctx.text(" " + pad("branch", 10), { color: "muted" });
|
|
1064
|
+
ctx.text(branchName, {
|
|
1065
|
+
color: "branch",
|
|
1066
|
+
url: branchBranchUrl,
|
|
1067
|
+
onClick: branchBranchUrl
|
|
1068
|
+
? () => openUrl(branchBranchUrl)
|
|
1069
|
+
: undefined,
|
|
1070
|
+
});
|
|
1071
|
+
ctx.newline();
|
|
1072
|
+
|
|
1073
|
+
// remote URL — displayed in full with scheme so that terminals
|
|
1074
|
+
// that auto-detect URLs (but ignore OSC-8) still recognize it.
|
|
1075
|
+
// The whole row is also click-routable via the mouse_click hook.
|
|
1076
|
+
if (repoUrl) {
|
|
1077
|
+
ctx.text(" " + pad("repo", 10), { color: "muted" });
|
|
1078
|
+
ctx.text(repoUrl, {
|
|
1079
|
+
color: "accent",
|
|
1080
|
+
url: repoUrl,
|
|
1081
|
+
onClick: () => openUrl(repoUrl),
|
|
1082
|
+
});
|
|
1083
|
+
ctx.newline();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
ctx.kv("tracking", trackStr, trackColor);
|
|
1087
|
+
if (vsBase) {
|
|
1088
|
+
const label = `vs ${vsBase.base}`;
|
|
1089
|
+
const str = `↑ ${vsBase.ahead} ↓ ${vsBase.behind}`;
|
|
1090
|
+
const color: DashboardColor =
|
|
1091
|
+
vsBase.ahead > 0 || vsBase.behind > 0 ? "accent" : "ok";
|
|
1092
|
+
ctx.kv(label, str, color);
|
|
1093
|
+
}
|
|
1094
|
+
ctx.kv(
|
|
1095
|
+
"changes",
|
|
1096
|
+
`${modified} file${modified === 1 ? "" : "s"}`,
|
|
1097
|
+
modified > 0 ? "warn" : "muted",
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
// Clickable "review branch" action. Triggers the audit_mode
|
|
1101
|
+
// plugin's `start_review_branch` handler via the plugin-action
|
|
1102
|
+
// bridge — executeAction falls through to Action::PluginAction
|
|
1103
|
+
// for any name that's not a built-in, and the plugin manager
|
|
1104
|
+
// dispatches that to the registered handler by name.
|
|
1105
|
+
ctx.text(" " + pad("review", 10), { color: "muted" });
|
|
1106
|
+
ctx.text("▶ review branch", {
|
|
1107
|
+
color: "accent",
|
|
1108
|
+
bold: true,
|
|
1109
|
+
onClick: () => editor.executeAction("start_review_branch"),
|
|
1110
|
+
});
|
|
1111
|
+
ctx.newline();
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// PR row types — module-level so the last-good state can reference them.
|
|
1115
|
+
type GhRollup = { state?: string } | null;
|
|
1116
|
+
type GhCommit = { statusCheckRollup?: GhRollup };
|
|
1117
|
+
type GhCommitNode = { commit?: GhCommit };
|
|
1118
|
+
type GhThread = { isResolved?: boolean; comments?: { totalCount?: number } };
|
|
1119
|
+
type GhPR = {
|
|
1120
|
+
number?: number;
|
|
1121
|
+
title?: string;
|
|
1122
|
+
state?: string;
|
|
1123
|
+
repository?: { nameWithOwner?: string };
|
|
1124
|
+
commits?: { nodes?: GhCommitNode[] };
|
|
1125
|
+
reviewThreads?: { nodes?: GhThread[] };
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
// Last-known-good GitHub state, preserved across refresh failures so
|
|
1129
|
+
// the panel doesn't jump between "data" and "error". `prs === null`
|
|
1130
|
+
// means we've never successfully fetched — in that case an error
|
|
1131
|
+
// replaces the section wholesale. Once we have PRs, a later failure
|
|
1132
|
+
// only adds a one-line banner at the top.
|
|
1133
|
+
let githubLastPrs: GhPR[] | null = null;
|
|
1134
|
+
let githubLastError: string | null = null;
|
|
1135
|
+
|
|
1136
|
+
// Column widths for PR rows. `num` covers `#` + up to 7 digits so PR
|
|
1137
|
+
// numbers in large repos (e.g. the node/k8s ranges) don't overflow and
|
|
1138
|
+
// push the state column out of alignment.
|
|
1139
|
+
const PR_COL_NUM = 8;
|
|
1140
|
+
const PR_COL_STATE = 5;
|
|
1141
|
+
const PR_COL_CHECK = 2;
|
|
1142
|
+
const PR_COL_CMTS = 6;
|
|
1143
|
+
|
|
1144
|
+
function renderPrRows(ctx: DashboardContext, prs: GhPR[]) {
|
|
1145
|
+
if (prs.length === 0) {
|
|
1146
|
+
ctx.kv("PRs", "no open PRs", "muted");
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
ctx.kv("PRs", `${prs.length} open`, "number");
|
|
1150
|
+
for (const pr of prs) {
|
|
1151
|
+
const state = (pr.state ?? "").toUpperCase();
|
|
1152
|
+
const stateTag =
|
|
1153
|
+
state === "OPEN"
|
|
1154
|
+
? "open"
|
|
1155
|
+
: state === "MERGED"
|
|
1156
|
+
? "mrgd"
|
|
1157
|
+
: state === "CLOSED"
|
|
1158
|
+
? "clsd"
|
|
1159
|
+
: "???";
|
|
1160
|
+
const stateColor: DashboardColor =
|
|
1161
|
+
state === "OPEN"
|
|
1162
|
+
? "ok"
|
|
1163
|
+
: state === "MERGED"
|
|
1164
|
+
? "accent"
|
|
1165
|
+
: "muted";
|
|
1166
|
+
|
|
1167
|
+
const rollup = pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null;
|
|
1168
|
+
const checkGlyph =
|
|
1169
|
+
rollup === "SUCCESS"
|
|
1170
|
+
? "✓"
|
|
1171
|
+
: rollup === "FAILURE" || rollup === "ERROR"
|
|
1172
|
+
? "✗"
|
|
1173
|
+
: rollup === "PENDING" || rollup === "EXPECTED"
|
|
1174
|
+
? "◌"
|
|
1175
|
+
: "–";
|
|
1176
|
+
const checkColor: DashboardColor =
|
|
1177
|
+
rollup === "SUCCESS"
|
|
1178
|
+
? "ok"
|
|
1179
|
+
: rollup === "FAILURE" || rollup === "ERROR"
|
|
1180
|
+
? "err"
|
|
1181
|
+
: rollup === "PENDING" || rollup === "EXPECTED"
|
|
1182
|
+
? "warn"
|
|
1183
|
+
: "muted";
|
|
1184
|
+
|
|
1185
|
+
const threads = pr.reviewThreads?.nodes ?? [];
|
|
1186
|
+
const openCmts = threads
|
|
1187
|
+
.filter((t) => t.isResolved === false)
|
|
1188
|
+
.reduce((acc, t) => acc + (t.comments?.totalCount ?? 0), 0);
|
|
1189
|
+
|
|
1190
|
+
const num = `#${pr.number ?? "?"}`;
|
|
1191
|
+
const title = (pr.title ?? "").slice(0, 44);
|
|
1192
|
+
const repoName = pr.repository?.nameWithOwner ?? "";
|
|
1193
|
+
const prUrl =
|
|
1194
|
+
repoName && pr.number
|
|
1195
|
+
? `https://github.com/${repoName}/pull/${pr.number}`
|
|
1196
|
+
: undefined;
|
|
1197
|
+
|
|
1198
|
+
// Whole PR row routes clicks to prUrl (set once on the first
|
|
1199
|
+
// action-bearing emit — subsequent emits on the same row would
|
|
1200
|
+
// overwrite with the same value). Emit the number text and
|
|
1201
|
+
// its padding in two separate spans so the underline (added
|
|
1202
|
+
// to clickable spans by `emit`) lands on the "#1234" text
|
|
1203
|
+
// only — trailing padding spaces stay plain.
|
|
1204
|
+
const onClickPr = prUrl ? () => openUrl(prUrl) : undefined;
|
|
1205
|
+
const numPad = " ".repeat(
|
|
1206
|
+
Math.max(0, PR_COL_NUM - visualWidth(num)),
|
|
1207
|
+
);
|
|
1208
|
+
ctx.text(" ");
|
|
1209
|
+
ctx.text(num, {
|
|
1210
|
+
color: "number",
|
|
1211
|
+
url: prUrl,
|
|
1212
|
+
onClick: onClickPr,
|
|
1213
|
+
});
|
|
1214
|
+
if (numPad) ctx.text(numPad);
|
|
1215
|
+
ctx.text(pad(stateTag, PR_COL_STATE), {
|
|
1216
|
+
color: stateColor,
|
|
1217
|
+
bold: true,
|
|
1218
|
+
});
|
|
1219
|
+
ctx.text(" ");
|
|
1220
|
+
ctx.text(pad(checkGlyph, PR_COL_CHECK), {
|
|
1221
|
+
color: checkColor,
|
|
1222
|
+
bold: true,
|
|
1223
|
+
});
|
|
1224
|
+
const cmtCell =
|
|
1225
|
+
openCmts > 0
|
|
1226
|
+
? pad(`${openCmts} cmt`, PR_COL_CMTS)
|
|
1227
|
+
: pad("", PR_COL_CMTS);
|
|
1228
|
+
ctx.text(cmtCell, { color: openCmts > 0 ? "warn" : "muted" });
|
|
1229
|
+
ctx.text(" ");
|
|
1230
|
+
ctx.text(title, { color: "value", url: prUrl });
|
|
1231
|
+
ctx.newline();
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function drawGithubState(ctx: DashboardContext) {
|
|
1236
|
+
// Stale-data banner: when we have previously-good PRs AND the
|
|
1237
|
+
// latest refresh failed, show both. Keeps the rest of the
|
|
1238
|
+
// section anchored — no row-count jumps between ticks.
|
|
1239
|
+
if (githubLastError && githubLastPrs !== null) {
|
|
1240
|
+
ctx.text(" " + pad("update", 10), { color: "muted" });
|
|
1241
|
+
ctx.text(`failed — ${githubLastError}`, { color: "err" });
|
|
1242
|
+
ctx.newline();
|
|
1243
|
+
renderPrRows(ctx, githubLastPrs);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (githubLastPrs !== null) {
|
|
1247
|
+
renderPrRows(ctx, githubLastPrs);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (githubLastError) {
|
|
1251
|
+
ctx.error(githubLastError);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
ctx.kv("status", "loading…", "muted");
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Detect the GitHub owner/repo for the working directory. Returns
|
|
1258
|
+
// either the nwo ("owner/repo") or a human-readable reason we couldn't
|
|
1259
|
+
// determine one — fetchGithub renders that reason in the UI instead of
|
|
1260
|
+
// fetching PRs against the wrong repo.
|
|
1261
|
+
async function detectGithubNwo(
|
|
1262
|
+
cwd: string,
|
|
1263
|
+
): Promise<{ nwo: string } | { err: string }> {
|
|
1264
|
+
const inside = await run(
|
|
1265
|
+
"git",
|
|
1266
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
1267
|
+
cwd,
|
|
1268
|
+
3000,
|
|
1269
|
+
);
|
|
1270
|
+
if (!inside.ok) return { err: "not a git repo" };
|
|
1271
|
+
const remote = await run("git", ["remote", "get-url", "origin"], cwd, 3000);
|
|
1272
|
+
if (!remote.ok) return { err: "no git remote" };
|
|
1273
|
+
const nwo = parseGithubNwo(remote.stdout);
|
|
1274
|
+
if (!nwo) return { err: "not a github repo" };
|
|
1275
|
+
return { nwo };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const githubRefresh: SectionRefresh = async (ctx) => {
|
|
1279
|
+
const cwd = editor.getCwd();
|
|
1280
|
+
const detected = await detectGithubNwo(cwd);
|
|
1281
|
+
if ("err" in detected) {
|
|
1282
|
+
githubLastPrs = null;
|
|
1283
|
+
githubLastError = detected.err;
|
|
1284
|
+
drawGithubState(ctx);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const nwo = detected.nwo;
|
|
1288
|
+
// Recent PRs in THIS repo. One GraphQL round-trip fetches state
|
|
1289
|
+
// (OPEN / MERGED / CLOSED), combined check status from the tip
|
|
1290
|
+
// commit's rollup, and the list of review threads so we can count
|
|
1291
|
+
// *unresolved* comment threads per PR. `$owner`/`$name` are passed
|
|
1292
|
+
// as variables so the nwo is not interpolated into the query
|
|
1293
|
+
// string.
|
|
1294
|
+
const [owner, name] = nwo.split("/");
|
|
1295
|
+
const query = `
|
|
1296
|
+
query($owner: String!, $name: String!) {
|
|
1297
|
+
repository(owner: $owner, name: $name) {
|
|
1298
|
+
pullRequests(first: 6, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
1299
|
+
nodes {
|
|
1300
|
+
number
|
|
1301
|
+
title
|
|
1302
|
+
state
|
|
1303
|
+
repository { nameWithOwner }
|
|
1304
|
+
commits(last: 1) {
|
|
1305
|
+
nodes {
|
|
1306
|
+
commit {
|
|
1307
|
+
statusCheckRollup { state }
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
reviewThreads(first: 50) {
|
|
1312
|
+
nodes {
|
|
1313
|
+
isResolved
|
|
1314
|
+
comments { totalCount }
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
`;
|
|
1322
|
+
let failure: string | null = null;
|
|
1323
|
+
try {
|
|
1324
|
+
const res = await run(
|
|
1325
|
+
"gh",
|
|
1326
|
+
[
|
|
1327
|
+
"api",
|
|
1328
|
+
"graphql",
|
|
1329
|
+
"-f",
|
|
1330
|
+
`query=${query}`,
|
|
1331
|
+
"-F",
|
|
1332
|
+
`owner=${owner}`,
|
|
1333
|
+
"-F",
|
|
1334
|
+
`name=${name}`,
|
|
1335
|
+
],
|
|
1336
|
+
"",
|
|
1337
|
+
7000,
|
|
1338
|
+
);
|
|
1339
|
+
if (!res.ok) {
|
|
1340
|
+
const stderr = res.stderr.toLowerCase();
|
|
1341
|
+
failure =
|
|
1342
|
+
stderr.includes("not found") || stderr.includes("no such file")
|
|
1343
|
+
? "gh not installed"
|
|
1344
|
+
: stderr.includes("auth")
|
|
1345
|
+
? "gh not authenticated"
|
|
1346
|
+
: trim(res.stderr).split("\n")[0]?.slice(0, 40) || "gh failed";
|
|
1347
|
+
} else {
|
|
1348
|
+
try {
|
|
1349
|
+
const parsed = JSON.parse(res.stdout);
|
|
1350
|
+
const prs: GhPR[] =
|
|
1351
|
+
(
|
|
1352
|
+
parsed as {
|
|
1353
|
+
data?: {
|
|
1354
|
+
repository?: { pullRequests?: { nodes?: GhPR[] } };
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
)?.data?.repository?.pullRequests?.nodes ?? [];
|
|
1358
|
+
githubLastPrs = prs;
|
|
1359
|
+
githubLastError = null;
|
|
1360
|
+
} catch {
|
|
1361
|
+
failure = "malformed response";
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
} catch {
|
|
1365
|
+
failure = "gh failed";
|
|
1366
|
+
}
|
|
1367
|
+
if (failure !== null) githubLastError = failure;
|
|
1368
|
+
drawGithubState(ctx);
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
const diskRefresh: SectionRefresh = async (ctx) => {
|
|
1372
|
+
const mounts = ["/", editor.getEnv("HOME") ?? "/home"];
|
|
1373
|
+
const seen = new Set<string>();
|
|
1374
|
+
const rows: { mount: string; pct: number; used: string; size: string }[] = [];
|
|
1375
|
+
try {
|
|
1376
|
+
for (const m of mounts) {
|
|
1377
|
+
const { stdout, ok } = await run("df", ["-hP", m], "", 3000);
|
|
1378
|
+
if (!ok) continue;
|
|
1379
|
+
const lns = stdout.split("\n").filter((l) => l.length > 0);
|
|
1380
|
+
if (lns.length < 2) continue;
|
|
1381
|
+
const cols = lns[1].split(/\s+/);
|
|
1382
|
+
if (cols.length < 6) continue;
|
|
1383
|
+
const mount = cols[5];
|
|
1384
|
+
if (seen.has(mount)) continue;
|
|
1385
|
+
seen.add(mount);
|
|
1386
|
+
rows.push({
|
|
1387
|
+
mount,
|
|
1388
|
+
pct: Number(cols[4].replace("%", "")) || 0,
|
|
1389
|
+
used: cols[2],
|
|
1390
|
+
size: cols[1],
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
} catch {
|
|
1394
|
+
ctx.error("df failed");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
if (rows.length === 0) {
|
|
1398
|
+
ctx.error("df failed");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
for (const row of rows) {
|
|
1402
|
+
const color: DashboardColor =
|
|
1403
|
+
row.pct >= 90 ? "err" : row.pct >= 75 ? "warn" : "ok";
|
|
1404
|
+
ctx.text(" " + pad(row.mount, 10), { color: "muted" });
|
|
1405
|
+
ctx.text(bar(row.pct, 18), { color, bold: true });
|
|
1406
|
+
ctx.text(" " + String(row.pct).padStart(3) + "%", { color });
|
|
1407
|
+
ctx.text(` ${row.used} / ${row.size}`, { color: "muted" });
|
|
1408
|
+
ctx.newline();
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
1413
|
+
|
|
1414
|
+
// Fire-and-forget: refresh every 5s while the dashboard remains the
|
|
1415
|
+
// active dashboard. Each tick bumps `fetchToken` and re-kicks every
|
|
1416
|
+
// registered section's refresh callback; in-flight refreshes from a
|
|
1417
|
+
// previous tick become no-ops the moment their token stops matching.
|
|
1418
|
+
// Loop exits when the dashboard buffer is closed (dashboardBufferId
|
|
1419
|
+
// becomes null).
|
|
1420
|
+
async function refreshLoop(myBufferId: number) {
|
|
1421
|
+
while (dashboardBufferId === myBufferId) {
|
|
1422
|
+
await editor.delay(5000);
|
|
1423
|
+
if (dashboardBufferId !== myBufferId) return;
|
|
1424
|
+
paint(); // refresh clock even if fetches lag
|
|
1425
|
+
fetchToken++;
|
|
1426
|
+
const tok = fetchToken;
|
|
1427
|
+
for (const entry of registeredSections) {
|
|
1428
|
+
void refreshSection(entry, tok);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Set for the duration of an in-flight openDashboard call. The
|
|
1434
|
+
// createVirtualBuffer round-trip is async, so a second openDashboard
|
|
1435
|
+
// invocation (e.g. from the `ready` hook firing while enable()'s own
|
|
1436
|
+
// call is still awaiting) would otherwise see dashboardBufferId === null
|
|
1437
|
+
// and create a second Dashboard tab.
|
|
1438
|
+
let dashboardOpening = false;
|
|
1439
|
+
|
|
1440
|
+
// Kick section refreshes and start the periodic refresh loop. Used
|
|
1441
|
+
// both by the ambient auto-open path and by the command-palette
|
|
1442
|
+
// "Show Dashboard" handler — the parts that differ (whether to bail
|
|
1443
|
+
// on real files, whether to sweep untitled scratch) live in the
|
|
1444
|
+
// callers.
|
|
1445
|
+
function bootstrapDashboard(bufferId: number) {
|
|
1446
|
+
// Reset section draws to "loading…" and kick a fresh refresh for
|
|
1447
|
+
// each registered section. Token guards against late resolvers
|
|
1448
|
+
// from a prior open clobbering the new one.
|
|
1449
|
+
//
|
|
1450
|
+
// GitHub's section callback reuses the last-good PR snapshot (if
|
|
1451
|
+
// any) on its first call post-open so a re-opened dashboard can
|
|
1452
|
+
// draw real data on the first frame while the refresh round-trip
|
|
1453
|
+
// is still in flight. Refresh failures surface via the in-panel
|
|
1454
|
+
// stale-data banner.
|
|
1455
|
+
fetchToken++;
|
|
1456
|
+
const myToken = fetchToken;
|
|
1457
|
+
for (const entry of registeredSections) {
|
|
1458
|
+
entry.draw = loadingDraw();
|
|
1459
|
+
void refreshSection(entry, myToken);
|
|
1460
|
+
}
|
|
1461
|
+
paint();
|
|
1462
|
+
|
|
1463
|
+
// Kick off the 5-second refresh loop. It stops itself when the
|
|
1464
|
+
// dashboard is closed.
|
|
1465
|
+
refreshLoop(bufferId);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async function openDashboard() {
|
|
1469
|
+
if (dashboardBufferId !== null) return; // already open
|
|
1470
|
+
if (dashboardOpening) return; // another openDashboard is mid-await
|
|
1471
|
+
dashboardOpening = true;
|
|
1472
|
+
|
|
1473
|
+
const res = await editor.createVirtualBuffer({
|
|
1474
|
+
name: "Dashboard",
|
|
1475
|
+
mode: "dashboard",
|
|
1476
|
+
readOnly: true,
|
|
1477
|
+
showLineNumbers: false,
|
|
1478
|
+
showCursors: false,
|
|
1479
|
+
editingDisabled: true,
|
|
1480
|
+
});
|
|
1481
|
+
dashboardBufferId = res.bufferId;
|
|
1482
|
+
dashboardOpening = false;
|
|
1483
|
+
focusedIndex = 0;
|
|
1484
|
+
|
|
1485
|
+
// Re-check: while we were awaiting createVirtualBuffer, a real
|
|
1486
|
+
// file may have landed — e.g. a CLI file from `fresh my_file`
|
|
1487
|
+
// that was queued before our `ready` handler ran, or a file the
|
|
1488
|
+
// user opened from the explorer. If so, quietly close the buffer
|
|
1489
|
+
// we just created instead of showing it and stealing focus.
|
|
1490
|
+
const realFilesNow = editor.listBuffers().filter(
|
|
1491
|
+
(b) =>
|
|
1492
|
+
!b.is_virtual &&
|
|
1493
|
+
b.path &&
|
|
1494
|
+
b.path.length > 0 &&
|
|
1495
|
+
b.id !== dashboardBufferId,
|
|
1496
|
+
);
|
|
1497
|
+
if (realFilesNow.length > 0) {
|
|
1498
|
+
editor.closeBuffer(dashboardBufferId);
|
|
1499
|
+
dashboardBufferId = null;
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
editor.showBuffer(dashboardBufferId);
|
|
1503
|
+
|
|
1504
|
+
// Close any untitled scratch left over from the last-tab-closed event
|
|
1505
|
+
// or the initial launch — the dashboard should own the split.
|
|
1506
|
+
for (const b of editor.listBuffers()) {
|
|
1507
|
+
if (
|
|
1508
|
+
!b.is_virtual &&
|
|
1509
|
+
(!b.path || b.path.length === 0) &&
|
|
1510
|
+
b.id !== dashboardBufferId
|
|
1511
|
+
) {
|
|
1512
|
+
editor.closeBuffer(b.id);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
bootstrapDashboard(dashboardBufferId);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Command-palette handler: show the dashboard if it isn't open, or
|
|
1520
|
+
// bring it to the front of the current split if it is. Unlike the
|
|
1521
|
+
// ambient open path, this never closes real files or untitled scratch
|
|
1522
|
+
// — if the user has a file open and types "Show Dashboard", the
|
|
1523
|
+
// dashboard opens alongside it rather than replacing it.
|
|
1524
|
+
async function dashboardShowOrFocus() {
|
|
1525
|
+
if (dashboardBufferId !== null) {
|
|
1526
|
+
editor.showBuffer(dashboardBufferId);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (dashboardOpening) return;
|
|
1530
|
+
dashboardOpening = true;
|
|
1531
|
+
const res = await editor.createVirtualBuffer({
|
|
1532
|
+
name: "Dashboard",
|
|
1533
|
+
mode: "dashboard",
|
|
1534
|
+
readOnly: true,
|
|
1535
|
+
showLineNumbers: false,
|
|
1536
|
+
showCursors: false,
|
|
1537
|
+
editingDisabled: true,
|
|
1538
|
+
});
|
|
1539
|
+
dashboardBufferId = res.bufferId;
|
|
1540
|
+
dashboardOpening = false;
|
|
1541
|
+
focusedIndex = 0;
|
|
1542
|
+
editor.showBuffer(dashboardBufferId);
|
|
1543
|
+
bootstrapDashboard(dashboardBufferId);
|
|
1544
|
+
}
|
|
1545
|
+
registerHandler("dashboardShowOrFocus", dashboardShowOrFocus);
|
|
1546
|
+
|
|
1547
|
+
// Auto-open resolution: the session override (set via the exported
|
|
1548
|
+
// plugin API from init.ts) wins over the user config. We read from
|
|
1549
|
+
// getUserConfig (raw file) rather than getConfig because unknown
|
|
1550
|
+
// fields are dropped when the Config struct reserializes. Default
|
|
1551
|
+
// is true.
|
|
1552
|
+
let autoOpenOverride: boolean | null = null;
|
|
1553
|
+
|
|
1554
|
+
function autoOpenEnabled(): boolean {
|
|
1555
|
+
if (autoOpenOverride !== null) return autoOpenOverride;
|
|
1556
|
+
const cfg = editor.getUserConfig() as Record<string, unknown> | null;
|
|
1557
|
+
const plugins = cfg?.plugins as Record<string, unknown> | undefined;
|
|
1558
|
+
const dashboard = plugins?.dashboard as Record<string, unknown> | undefined;
|
|
1559
|
+
return dashboard?.["auto-open"] !== false;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function shouldShowDashboard(): boolean {
|
|
1563
|
+
if (dashboardBufferId !== null) return false;
|
|
1564
|
+
if (!autoOpenEnabled()) return false;
|
|
1565
|
+
const all = editor.listBuffers();
|
|
1566
|
+
const realFiles = all.filter(
|
|
1567
|
+
(b) => !b.is_virtual && b.path && b.path.length > 0,
|
|
1568
|
+
);
|
|
1569
|
+
return realFiles.length === 0;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// ── Editor event handlers ─────────────────────────────────────────────
|
|
1573
|
+
|
|
1574
|
+
// Named handlers are registered up-front — the plugin runtime requires
|
|
1575
|
+
// handlers to exist before `editor.on(...)` subscribes to them. The
|
|
1576
|
+
// subscription itself happens at the bottom of the file, once all the
|
|
1577
|
+
// handlers below exist.
|
|
1578
|
+
registerHandler("dashboardOnReady", async () => {
|
|
1579
|
+
if (shouldShowDashboard()) await openDashboard();
|
|
1580
|
+
});
|
|
1581
|
+
registerHandler(
|
|
1582
|
+
"dashboardOnBufferClosed",
|
|
1583
|
+
async (e: { buffer_id: number }) => {
|
|
1584
|
+
// If the dashboard itself was closed, clear our handle so we'll
|
|
1585
|
+
// re-open on the next "last tab closed" event.
|
|
1586
|
+
if (dashboardBufferId !== null && e.buffer_id === dashboardBufferId) {
|
|
1587
|
+
dashboardBufferId = null;
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
if (shouldShowDashboard()) await openDashboard();
|
|
1591
|
+
},
|
|
1592
|
+
);
|
|
1593
|
+
// Step aside when a real file opens. This covers two flows:
|
|
1594
|
+
// 1. `fresh my_file` at the command line: the file is queued before
|
|
1595
|
+
// the event loop starts, so the dashboard's `ready` handler can
|
|
1596
|
+
// race ahead and open on top of the pending file. When the file
|
|
1597
|
+
// eventually lands here, we close the dashboard so the user's
|
|
1598
|
+
// file is the visible tab.
|
|
1599
|
+
// 2. Opening a file from the explorer / command palette while the
|
|
1600
|
+
// dashboard is the current tab. Same reasoning: the user asked
|
|
1601
|
+
// for a real buffer, so the dashboard shouldn't linger in front.
|
|
1602
|
+
registerHandler(
|
|
1603
|
+
"dashboardOnAfterFileOpen",
|
|
1604
|
+
(_e: { buffer_id: number; path: string }) => {
|
|
1605
|
+
if (dashboardBufferId === null) return;
|
|
1606
|
+
editor.closeBuffer(dashboardBufferId);
|
|
1607
|
+
dashboardBufferId = null;
|
|
1608
|
+
},
|
|
1609
|
+
);
|
|
1610
|
+
// viewport_changed fires whenever a split's dimensions change, which
|
|
1611
|
+
// covers terminal resize *and* file-explorer toggle (opening the explorer
|
|
1612
|
+
// shrinks the dashboard split's width; closing it grows it back). We
|
|
1613
|
+
// dedupe against the last-painted dims so scroll-only events (which also
|
|
1614
|
+
// fire this hook) don't cause gratuitous repaints.
|
|
1615
|
+
registerHandler(
|
|
1616
|
+
"dashboardOnViewportChanged",
|
|
1617
|
+
(data: { buffer_id: number; width: number; height: number }) => {
|
|
1618
|
+
if (dashboardBufferId === null) return;
|
|
1619
|
+
if (data.buffer_id !== dashboardBufferId) return;
|
|
1620
|
+
if (data.width === lastPaintedW && data.height === lastPaintedH) return;
|
|
1621
|
+
// Pass the fresh dims through so we center against the new
|
|
1622
|
+
// split width on this very tick — the getViewport() snapshot
|
|
1623
|
+
// is only updated on the next render pass.
|
|
1624
|
+
paint({ width: data.width, height: data.height });
|
|
1625
|
+
},
|
|
1626
|
+
);
|
|
1627
|
+
// Keyboard navigation. The dashboard buffer is `showCursors: false` +
|
|
1628
|
+
// `editingDisabled: true`, so there's no native cursor to drive
|
|
1629
|
+
// selection — we track focus ourselves via `focusedIndex` and repaint
|
|
1630
|
+
// to move the highlight. Wraparound in both directions so the user
|
|
1631
|
+
// can't walk off either end of the clickable list.
|
|
1632
|
+
function moveFocus(delta: number) {
|
|
1633
|
+
if (clickableTargets.length === 0) return;
|
|
1634
|
+
focusedIndex =
|
|
1635
|
+
(focusedIndex + delta + clickableTargets.length) %
|
|
1636
|
+
clickableTargets.length;
|
|
1637
|
+
paint();
|
|
1638
|
+
}
|
|
1639
|
+
registerHandler("dashboardFocusNext", () => moveFocus(1));
|
|
1640
|
+
registerHandler("dashboardFocusPrev", () => moveFocus(-1));
|
|
1641
|
+
registerHandler("dashboardActivate", () => {
|
|
1642
|
+
if (clickableTargets.length === 0) return;
|
|
1643
|
+
const target = clickableTargets[focusedIndex];
|
|
1644
|
+
if (!target) return;
|
|
1645
|
+
dispatchClickAction(target.action);
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
// Mode bindings mirror the standard "list with selectable rows"
|
|
1649
|
+
// idiom: Tab / Down / j step forward, BackTab / Up / k step back,
|
|
1650
|
+
// Return activates. `inheritNormalBindings: false` because every
|
|
1651
|
+
// useful key on a read-only, no-cursor buffer is either bound above
|
|
1652
|
+
// or intentionally inert (we don't want j/k falling through to cursor
|
|
1653
|
+
// movement commands that would silently do nothing here).
|
|
1654
|
+
editor.defineMode(
|
|
1655
|
+
"dashboard",
|
|
1656
|
+
[
|
|
1657
|
+
["Tab", "dashboardFocusNext"],
|
|
1658
|
+
["Down", "dashboardFocusNext"],
|
|
1659
|
+
["j", "dashboardFocusNext"],
|
|
1660
|
+
["BackTab", "dashboardFocusPrev"],
|
|
1661
|
+
["Up", "dashboardFocusPrev"],
|
|
1662
|
+
["k", "dashboardFocusPrev"],
|
|
1663
|
+
["Return", "dashboardActivate"],
|
|
1664
|
+
],
|
|
1665
|
+
true, // read-only
|
|
1666
|
+
false, // allow_text_input
|
|
1667
|
+
false, // don't inherit Normal bindings — no cursor to move
|
|
1668
|
+
);
|
|
1669
|
+
|
|
1670
|
+
// Dispatch clicks on rows that carry an action. We don't trust the
|
|
1671
|
+
// terminal to honor OSC-8 hyperlinks on the `url` span — many strip
|
|
1672
|
+
// them silently — so every clickable element also registers a
|
|
1673
|
+
// row-based ClickAction and we route the click ourselves.
|
|
1674
|
+
registerHandler(
|
|
1675
|
+
"dashboardOnMouseClick",
|
|
1676
|
+
(data: {
|
|
1677
|
+
column: number;
|
|
1678
|
+
row: number;
|
|
1679
|
+
button: string;
|
|
1680
|
+
modifiers: string;
|
|
1681
|
+
content_x: number;
|
|
1682
|
+
content_y: number;
|
|
1683
|
+
buffer_id: number | null;
|
|
1684
|
+
buffer_row: number | null;
|
|
1685
|
+
buffer_col: number | null;
|
|
1686
|
+
}) => {
|
|
1687
|
+
if (data.button !== "left") return;
|
|
1688
|
+
if (dashboardBufferId === null) return;
|
|
1689
|
+
if (data.buffer_id !== dashboardBufferId) return;
|
|
1690
|
+
if (data.buffer_row === null) return;
|
|
1691
|
+
const ranges = currentRowActions.get(data.buffer_row);
|
|
1692
|
+
if (!ranges) return;
|
|
1693
|
+
// Gate on the visual column within the buffer's content area.
|
|
1694
|
+
// `buffer_col` is in UTF-8 bytes and doesn't line up with the
|
|
1695
|
+
// frame's multi-byte characters (`│`, `╭` etc.), so we derive
|
|
1696
|
+
// the visual column from `column - content_x` — the screen
|
|
1697
|
+
// column relative to the buffer panel's left edge. Our
|
|
1698
|
+
// registered ranges are stored in visual cells and reset to 0
|
|
1699
|
+
// at each newline, so the two units match. A click outside
|
|
1700
|
+
// every registered range is a no-op: whitespace, kv labels,
|
|
1701
|
+
// and the frame border inside an inner row stay unclickable,
|
|
1702
|
+
// matching the underline-as-affordance contract.
|
|
1703
|
+
const visualCol = data.column - data.content_x;
|
|
1704
|
+
const match = ranges.find(
|
|
1705
|
+
(r) => visualCol >= r.colStart && visualCol < r.colEnd,
|
|
1706
|
+
);
|
|
1707
|
+
if (!match) return;
|
|
1708
|
+
dispatchClickAction(match.action);
|
|
1709
|
+
},
|
|
1710
|
+
);
|
|
1711
|
+
|
|
1712
|
+
// Register the built-in sections. They use the same public
|
|
1713
|
+
// `DashboardContext` API that third-party plugins consume, so any
|
|
1714
|
+
// change to the context contract surfaces here first.
|
|
1715
|
+
//
|
|
1716
|
+
// `weather` and `github` are opt-in — they hit the network on every
|
|
1717
|
+
// refresh, so we only register `git` and `disk` by default. Users
|
|
1718
|
+
// wire the others up from init.ts via the exported plugin API; see
|
|
1719
|
+
// the init.ts starter template for a ready-to-paste example.
|
|
1720
|
+
registerSection("git", gitRefresh);
|
|
1721
|
+
registerSection("disk", diskRefresh);
|
|
1722
|
+
|
|
1723
|
+
// Expose the section-management entry points to other plugins and to
|
|
1724
|
+
// user init.ts. `registerSection(name, refresh)` adds a section and
|
|
1725
|
+
// returns an unregister callback; `removeSection(name)` tears sections
|
|
1726
|
+
// down by name; `clearAllSections()` removes every section, built-ins
|
|
1727
|
+
// included. The refresh callback receives a `DashboardContext` with
|
|
1728
|
+
// `kv`, `text`, `newline`, and `error` primitives — see the init.ts
|
|
1729
|
+
// starter template for an end-to-end example.
|
|
1730
|
+
editor.exportPluginApi("dashboard", {
|
|
1731
|
+
registerSection(name: string, refresh: SectionRefresh): () => void {
|
|
1732
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1733
|
+
throw new Error("dashboard.registerSection: name must be a non-empty string");
|
|
1734
|
+
}
|
|
1735
|
+
if (typeof refresh !== "function") {
|
|
1736
|
+
throw new Error("dashboard.registerSection: refresh must be a function");
|
|
1737
|
+
}
|
|
1738
|
+
return registerSection(name, refresh);
|
|
1739
|
+
},
|
|
1740
|
+
removeSection(name: string): boolean {
|
|
1741
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1742
|
+
throw new Error("dashboard.removeSection: name must be a non-empty string");
|
|
1743
|
+
}
|
|
1744
|
+
return removeSection(name);
|
|
1745
|
+
},
|
|
1746
|
+
clearAllSections(): void {
|
|
1747
|
+
clearAllSections();
|
|
1748
|
+
},
|
|
1749
|
+
setAutoOpen(enabled: boolean): void {
|
|
1750
|
+
autoOpenOverride = !!enabled;
|
|
1751
|
+
},
|
|
1752
|
+
builtinHandlers: {
|
|
1753
|
+
weather: weatherRefresh,
|
|
1754
|
+
github: githubRefresh,
|
|
1755
|
+
},
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
// Subscribe to the hooks that drive the dashboard. Reaching this code
|
|
1759
|
+
// means the plugin has been loaded, which only happens when
|
|
1760
|
+
// `plugins.dashboard.enabled` is true in the resolved config — so the
|
|
1761
|
+
// standard settings UI is the single enable/disable surface.
|
|
1762
|
+
//
|
|
1763
|
+
// If the plugin loads mid-session (user toggles it on in Settings),
|
|
1764
|
+
// the `ready` hook has already fired, so we also run an immediate
|
|
1765
|
+
// check. At startup the `listBuffers().length > 0` guard keeps us
|
|
1766
|
+
// dormant until the workspace has actually restored: plugins load
|
|
1767
|
+
// before restore, and opening a buffer here would race with the
|
|
1768
|
+
// restore and leave a stray Dashboard tab even when real files exist.
|
|
1769
|
+
editor.on("ready", "dashboardOnReady");
|
|
1770
|
+
editor.on("buffer_closed", "dashboardOnBufferClosed");
|
|
1771
|
+
editor.on("viewport_changed", "dashboardOnViewportChanged");
|
|
1772
|
+
editor.on("mouse_click", "dashboardOnMouseClick");
|
|
1773
|
+
editor.on("after_file_open", "dashboardOnAfterFileOpen");
|
|
1774
|
+
|
|
1775
|
+
// Command-palette entry. No-op when the dashboard is already the
|
|
1776
|
+
// focused tab (showBuffer on a visible buffer is a cheap re-focus).
|
|
1777
|
+
editor.registerCommand(
|
|
1778
|
+
"Show Dashboard",
|
|
1779
|
+
"Open the dashboard, or bring it to the front if it's already open",
|
|
1780
|
+
"dashboardShowOrFocus",
|
|
1781
|
+
);
|
|
1782
|
+
|
|
1783
|
+
if (editor.listBuffers().length > 0 && shouldShowDashboard()) {
|
|
1784
|
+
openDashboard();
|
|
1785
|
+
}
|