@fresh-editor/fresh-editor 0.2.25 → 0.3.1

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