@fresh-editor/fresh-editor 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +9 -2
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.i18n.json +84 -0
  5. package/plugins/audit_mode.ts +139 -3
  6. package/plugins/config-schema.json +33 -3
  7. package/plugins/dashboard.ts +34 -111
  8. package/plugins/flash.ts +22 -4
  9. package/plugins/git_blame.ts +10 -6
  10. package/plugins/git_log.ts +705 -323
  11. package/plugins/git_statusbar.i18n.json +72 -0
  12. package/plugins/git_statusbar.ts +133 -0
  13. package/plugins/goto_with_selection.i18n.json +58 -0
  14. package/plugins/goto_with_selection.ts +17 -0
  15. package/plugins/lib/fresh.d.ts +911 -15
  16. package/plugins/lib/index.ts +34 -0
  17. package/plugins/lib/widgets.ts +903 -0
  18. package/plugins/live_diff.ts +442 -32
  19. package/plugins/merge_conflict.ts +89 -64
  20. package/plugins/orchestrator.ts +3425 -0
  21. package/plugins/pkg.ts +235 -54
  22. package/plugins/rust-lsp.ts +58 -40
  23. package/plugins/schemas/theme.schema.json +18 -0
  24. package/plugins/search_replace.i18n.json +140 -28
  25. package/plugins/search_replace.ts +1335 -515
  26. package/plugins/tab_actions.i18n.json +212 -0
  27. package/plugins/tab_actions.ts +76 -0
  28. package/plugins/theme_editor.i18n.json +112 -0
  29. package/plugins/theme_editor.ts +30 -5
  30. package/plugins/tsconfig.json +3 -0
  31. package/plugins/vi_mode.ts +49 -17
  32. package/themes/dark.json +1 -0
  33. package/themes/dracula.json +1 -0
  34. package/themes/high-contrast.json +1 -0
  35. package/themes/light.json +1 -0
  36. package/themes/nord.json +1 -0
  37. package/themes/nostalgia.json +1 -0
  38. package/themes/solarized-dark.json +1 -0
  39. package/themes/terminal.json +4 -0
@@ -0,0 +1,903 @@
1
+ /**
2
+ * Plugin widget library — declarative UI for Fresh plugins.
3
+ *
4
+ * Plugins describe panel content as a `WidgetSpec` tree. The host owns
5
+ * rendering, theming, and (in later phases) hit-testing, focus, and
6
+ * keymaps. This module provides:
7
+ *
8
+ * - Type re-exports from the generated `fresh.d.ts` so plugins import
9
+ * `WidgetSpec` / `HintEntry` from one place.
10
+ * - Builder helpers (`row`, `col`, `hintBar`, `raw`) that produce the
11
+ * correct discriminated-union shape.
12
+ * - A `WidgetPanel` class that wraps the
13
+ * `mountWidgetPanel` / `updateWidgetPanel` / `unmountWidgetPanel`
14
+ * IPC trio with mount-once-then-update semantics.
15
+ * - `parseHintString(s)` — parses the legacy `Tab:section Esc:close`
16
+ * string format used by today's plugin i18n bundles into
17
+ * `HintEntry[]`.
18
+ *
19
+ * See `docs/internal/plugin-widget-library-design.md`.
20
+ *
21
+ * @example
22
+ * import { WidgetPanel, hintBar, col, raw, parseHintString } from "./lib/widgets.ts";
23
+ *
24
+ * const panel = new WidgetPanel(bufferId);
25
+ * panel.set(col(
26
+ * raw(myExistingEntries),
27
+ * hintBar(parseHintString(editor.t("panel.help"))),
28
+ * ));
29
+ * // …later, on every state change:
30
+ * panel.set(col(raw(newEntries), hintBar(myHints)));
31
+ * // …on close:
32
+ * panel.unmount();
33
+ */
34
+
35
+ /// <reference path="./fresh.d.ts" />
36
+
37
+ // `fresh.d.ts` declares HintEntry / WidgetSpec / TextPropertyEntry as
38
+ // ambient globals (it is not an ES module). Re-export the relevant
39
+ // type names locally so plugin code can write
40
+ // `import type { WidgetSpec } from "./lib/widgets.ts"` without dipping
41
+ // into the ambient namespace directly.
42
+ export type WidgetSpec = globalThis.WidgetSpec;
43
+ export type HintEntry = globalThis.HintEntry;
44
+ export type ButtonKind = globalThis.ButtonKind;
45
+ export type WidgetAction = globalThis.WidgetAction;
46
+ export type WidgetMutation = globalThis.WidgetMutation;
47
+ export type TreeNode = globalThis.TreeNode;
48
+ export type StyledSegment = globalThis.StyledSegment;
49
+ export type TextPropertyEntry = globalThis.TextPropertyEntry;
50
+ type InlineOverlay = globalThis.InlineOverlay;
51
+ type OverlayOptions = globalThis.OverlayOptions;
52
+
53
+ // =============================================================================
54
+ // Builder helpers — preferred over hand-writing `{ kind: "row", ... }`.
55
+ // =============================================================================
56
+
57
+ /** Horizontal layout. Children laid out left-to-right; inline-sized
58
+ * children collapse into a single line. See §3 of the design doc. */
59
+ export function row(...children: WidgetSpec[]): WidgetSpec {
60
+ return { kind: "row", children };
61
+ }
62
+
63
+ /** Vertical layout. Children stacked top-to-bottom. */
64
+ export function col(...children: WidgetSpec[]): WidgetSpec {
65
+ return { kind: "col", children };
66
+ }
67
+
68
+ /** Keyboard-hint footer. Renders `<keys> <label>` per entry, with the
69
+ * keys portion styled by the `ui.help_key_fg` theme key.
70
+ *
71
+ * Replaces the per-plugin hand-rolled help row. */
72
+ export function hintBar(entries: HintEntry[]): WidgetSpec {
73
+ return { kind: "hintBar", entries };
74
+ }
75
+
76
+ /** Imperative-virtual-buffer escape hatch. Wraps an existing
77
+ * `TextPropertyEntry[]` (the same shape `setVirtualBufferContent`
78
+ * already accepts) so a plugin can migrate its panel one widget at a
79
+ * time. */
80
+ export function raw(entries: TextPropertyEntry[], key?: string): WidgetSpec {
81
+ return { kind: "raw", entries, key };
82
+ }
83
+
84
+ /** Build a `TextPropertyEntry` from a sequence of styled segments.
85
+ *
86
+ * The plugin describes row content structurally — each segment is a
87
+ * piece of text plus optional `style` and optional nested
88
+ * `overlays`. The host concatenates the segments and emits one
89
+ * inline overlay per styled segment plus the segment's nested
90
+ * overlays shifted by the segment's start position; both happen in
91
+ * Rust against the final text, so the plugin never names byte or
92
+ * codepoint offsets between segments.
93
+ *
94
+ * Use `padToChars` / `truncateToChars` to constrain the entry's
95
+ * total width — both are applied AFTER segment concatenation (so
96
+ * `padToChars: 80` pads the full row to 80 codepoints, regardless
97
+ * of how the segments split it).
98
+ *
99
+ * For freeform overlays inside a single segment (e.g. highlighting
100
+ * pattern matches inside a context string), pass them via the
101
+ * segment's `overlays` field with `unit: "char"`. */
102
+ export function styledRow(
103
+ segments: StyledSegment[],
104
+ options?: {
105
+ padToChars?: number;
106
+ truncateToChars?: number;
107
+ properties?: Record<string, unknown>;
108
+ style?: Partial<OverlayOptions>;
109
+ inlineOverlays?: InlineOverlay[];
110
+ },
111
+ ): TextPropertyEntry {
112
+ // Build the entry by spreading only set fields. The plugin
113
+ // bridge converts JS `undefined` to JSON `null` when an object
114
+ // key is present, which then fails to deserialize as the
115
+ // matching `Option<…>` / `Vec<…>` field on the host. Omitting
116
+ // the key entirely lets serde fall back to `#[serde(default)]`.
117
+ const entry: TextPropertyEntry = { text: "", segments };
118
+ if (options?.padToChars !== undefined) entry.padToChars = options.padToChars;
119
+ if (options?.truncateToChars !== undefined) entry.truncateToChars = options.truncateToChars;
120
+ if (options?.properties !== undefined) entry.properties = options.properties;
121
+ if (options?.style !== undefined) entry.style = options.style;
122
+ if (options?.inlineOverlays !== undefined) entry.inlineOverlays = options.inlineOverlays;
123
+ return entry;
124
+ }
125
+
126
+ /** Boolean toggle, rendered as `[v] label` / `[ ] label`.
127
+ * Pass `focused: true` to highlight (the host will own focus once
128
+ * the keymap layer is wired). */
129
+ export function toggle(
130
+ checked: boolean,
131
+ label: string,
132
+ options?: { focused?: boolean; key?: string },
133
+ ): WidgetSpec {
134
+ return {
135
+ kind: "toggle",
136
+ checked,
137
+ label,
138
+ focused: options?.focused ?? false,
139
+ key: options?.key,
140
+ };
141
+ }
142
+
143
+ /** Action button, rendered as `[ Label ]`. `intent` controls visual
144
+ * emphasis: `"normal"` (default) → no override, `"primary"` → bold,
145
+ * `"danger"` → error theme key.
146
+ *
147
+ * `disabled: true` paints the button with `ui.menu_disabled_fg`,
148
+ * drops it from the Tab cycle, and makes clicks no-ops — for actions
149
+ * that aren't currently available against the surrounding state.
150
+ * The button still occupies its layout cell, so flipping `disabled`
151
+ * doesn't reshuffle the surrounding row. */
152
+ export function button(
153
+ label: string,
154
+ options?: {
155
+ focused?: boolean;
156
+ intent?: ButtonKind;
157
+ key?: string;
158
+ disabled?: boolean;
159
+ },
160
+ ): WidgetSpec {
161
+ return {
162
+ kind: "button",
163
+ label,
164
+ focused: options?.focused ?? false,
165
+ intent: options?.intent ?? "normal",
166
+ key: options?.key,
167
+ disabled: options?.disabled ?? false,
168
+ };
169
+ }
170
+
171
+ /** Horizontal spacer of fixed column count. In a `Row` it produces
172
+ * `cols` spaces; at the top level or in a `Col` it produces a
173
+ * short blank line. */
174
+ export function spacer(cols: number, key?: string): WidgetSpec {
175
+ return { kind: "spacer", cols, flex: false, key };
176
+ }
177
+
178
+ /** Flex horizontal spacer — fills remaining row width
179
+ * (`panel_width - sum(non-flex children)`). Use to right-align a
180
+ * trailing widget: `row(label, flexSpacer(), button)`. With
181
+ * multiple flex spacers in one row the leftover splits evenly. */
182
+ export function flexSpacer(key?: string): WidgetSpec {
183
+ return { kind: "spacer", cols: 0, flex: true, key };
184
+ }
185
+
186
+ /** Vertical list of pre-rendered rows with host-managed selection
187
+ * styling, click routing, and **virtual scrolling**. Plugin passes
188
+ * the full dataset of items + a `visibleRows` count; the widget
189
+ * owns scroll offset as instance state (keyed by `key`) and
190
+ * auto-clamps it to keep `selectedIndex` in view. Plugins never
191
+ * compute scroll math.
192
+ *
193
+ * Click on a row fires `widget_event` with `eventType: "select"` and
194
+ * `payload: { index, key }` where `index` is the *absolute* index
195
+ * into `items` (not the visible-window index).
196
+ *
197
+ * `key` is required for any List that should preserve scroll across
198
+ * re-renders. Lists without a key reset to scroll=0 each render. */
199
+ export function list(options: {
200
+ items: TextPropertyEntry[];
201
+ itemKeys?: string[];
202
+ selectedIndex?: number;
203
+ visibleRows: number;
204
+ /** Whether Tab / Shift+Tab lands focus on this list. Default
205
+ * true (matches other tabbable widgets). Set to false in
206
+ * picker-style layouts where the filter input stays focused
207
+ * and Up/Down forward to the list via host smart-keys —
208
+ * skipping the list in the Tab cycle keeps focus jumping
209
+ * straight between filter and action buttons. */
210
+ focusable?: boolean;
211
+ key?: string;
212
+ }): WidgetSpec {
213
+ return {
214
+ kind: "list",
215
+ items: options.items,
216
+ itemKeys: options.itemKeys ?? [],
217
+ selectedIndex: options.selectedIndex ?? -1,
218
+ visibleRows: options.visibleRows,
219
+ focusable: options.focusable ?? true,
220
+ key: options.key,
221
+ };
222
+ }
223
+
224
+ /** Construct one node in a `Tree` widget's flat-list spec. The
225
+ * plugin emits a depth-first traversal of its hierarchy, one
226
+ * `treeNode(...)` per node, plus a parallel `itemKeys` array for
227
+ * stable per-row identifiers. `depth` controls indent (`depth * 2`
228
+ * spaces); `hasChildren: true` renders a disclosure glyph (`▶`/`▼`)
229
+ * with a click-to-expand hit area in the indent column. The host
230
+ * filters out descendants of collapsed nodes when rendering. */
231
+ export function treeNode(
232
+ text: TextPropertyEntry,
233
+ options?: { depth?: number; hasChildren?: boolean; checked?: boolean },
234
+ ): TreeNode {
235
+ // `checked` is intentionally Optional<bool>, not a default-false
236
+ // boolean: omitting it (== undefined here) maps to host-side
237
+ // `None`, which means "no checkbox glyph". Per-node opt-in keeps
238
+ // checkable trees mixing checkbox-bearing rows with rows that
239
+ // shouldn't render one (e.g. a header that doesn't itself have
240
+ // a meaningful checked state).
241
+ const node: TreeNode = {
242
+ text,
243
+ depth: options?.depth ?? 0,
244
+ hasChildren: options?.hasChildren ?? false,
245
+ };
246
+ if (options?.checked !== undefined) {
247
+ node.checked = options.checked;
248
+ }
249
+ return node;
250
+ }
251
+
252
+ /** Hierarchical tree with host-managed expand/collapse, selection,
253
+ * scrolling, and click routing.
254
+ *
255
+ * The plugin emits its hierarchy as a flat list of `TreeNode`s
256
+ * (depth-first); the host filters out descendants of collapsed
257
+ * nodes at render time. **Toggling expansion is host-owned** —
258
+ * `Right`/`Left` arrow keys and disclosure clicks update host
259
+ * instance state without the plugin re-emitting. Plugins that
260
+ * need to react to expansion changes listen for
261
+ * `widget_event` `eventType: "expand"`.
262
+ *
263
+ * Click on the disclosure column → `expand` event. Click on the
264
+ * row body → `select` event. Enter/Space on the focused tree →
265
+ * `activate` event with the currently-selected node. Up/Down move
266
+ * selection through the visible (un-collapsed) flat list.
267
+ *
268
+ * `key` is required for any Tree that should preserve scroll +
269
+ * selection + expansion across re-renders. */
270
+ export function tree(options: {
271
+ nodes: TreeNode[];
272
+ itemKeys?: string[];
273
+ selectedIndex?: number;
274
+ visibleRows: number;
275
+ /** Initial expanded keys; subsequent expansion changes are
276
+ * host-owned and don't read this field. Use
277
+ * `panel.setExpandedKeys(...)` to override host state after
278
+ * mount. */
279
+ expandedKeys?: string[];
280
+ /** When true, every node with `checked: true | false` renders
281
+ * a `[v]` / `[ ]` glyph and emits a `toggle` hit area. Click on
282
+ * the glyph fires `widget_event` `eventType: "toggle"` with
283
+ * `payload: { key, index, checked: <new> }`; the plugin updates
284
+ * its model and pushes the new state back via
285
+ * `panel.setCheckedKeys(...)`. */
286
+ checkable?: boolean;
287
+ key?: string;
288
+ }): WidgetSpec {
289
+ return {
290
+ kind: "tree",
291
+ nodes: options.nodes,
292
+ itemKeys: options.itemKeys ?? [],
293
+ selectedIndex: options.selectedIndex ?? -1,
294
+ visibleRows: options.visibleRows,
295
+ expandedKeys: options.expandedKeys ?? [],
296
+ checkable: options.checkable ?? false,
297
+ key: options.key,
298
+ };
299
+ }
300
+
301
+ /** Text input — single-line (`rows: 1`, default) or multi-line
302
+ * (`rows >= 2`). The host owns `value` and `cursorByte` as instance
303
+ * state once the widget renders for the first time; multi-line
304
+ * widgets also own a vertical scroll offset.
305
+ *
306
+ * Single-line (`rows: 1`) renders as `[value]` (or `Label: [value]`
307
+ * if `label` is provided), with `fieldWidth` giving a constant
308
+ * visible width — short values pad with trailing spaces, long
309
+ * values head-truncate with `…` so the tail (where the cursor
310
+ * usually is) stays visible. Smart-key dispatch: `Enter` advances
311
+ * focus; `Up`/`Down` are no-ops; `Home`/`End` jump to the start /
312
+ * end of the whole value.
313
+ *
314
+ * Multi-line (`rows >= 2`) renders as a `rows`-tall block, padded
315
+ * with blanks when `value` is shorter. Smart-key dispatch differs:
316
+ * `Enter` inserts a newline; `Up`/`Down` move between lines;
317
+ * `Home`/`End` are line-relative; long lines tail-truncate with `…`
318
+ * per-line. Plugins that want `Enter` to submit can intercept the
319
+ * key in their own mode binding and call
320
+ * `panel.command(focusAdvance(1))` instead.
321
+ *
322
+ * `key` is required for any text widget that should preserve its
323
+ * value, cursor, and scroll across re-renders.
324
+ *
325
+ * Prefer the `textInput()` / `textArea()` helpers below when the
326
+ * intent is unambiguous — they call this with the right `rows`. */
327
+ export function text(
328
+ options: {
329
+ value?: string;
330
+ cursorByte?: number;
331
+ focused?: boolean;
332
+ label?: string;
333
+ placeholder?: string;
334
+ /** Number of visible rows of editing region. `1` (default) =
335
+ * single-line behaviour; `>= 2` = multi-line behaviour. */
336
+ rows?: number;
337
+ /** Visible column width. `0` (default) = auto-fit (single-line)
338
+ * or panel width (multi-line). */
339
+ fieldWidth?: number;
340
+ /** Single-line soft cap on visible chars after the
341
+ * `fieldWidth` pad. `0` = no cap. Ignored when `rows >= 2`. */
342
+ maxVisibleChars?: number;
343
+ /** Stretch the visible field to fill the enclosing
344
+ * container's width. Overrides `fieldWidth` when set:
345
+ * the renderer sizes the bracketed region to
346
+ * `panelWidth - label_overhead - bracket_overhead`. Pair
347
+ * with `labeledSection(...)` to get a uniformly full-width
348
+ * fieldset look. */
349
+ fullWidth?: boolean;
350
+ /** Initial completion candidates. Use the
351
+ * `setCompletions(widgetKey, items)` mutation for live
352
+ * updates — the spec field seeds the host's instance state
353
+ * on first render only. Empty = no popup. See
354
+ * `WidgetSpec::Text.completions` (Rust) for the rendering
355
+ * + keyboard semantics. */
356
+ completions?: string[];
357
+ key?: string;
358
+ } = {},
359
+ ): WidgetSpec {
360
+ return {
361
+ kind: "text",
362
+ value: options.value ?? "",
363
+ cursorByte: options.cursorByte ?? -1,
364
+ focused: options.focused ?? false,
365
+ label: options.label ?? "",
366
+ placeholder: options.placeholder,
367
+ rows: options.rows ?? 1,
368
+ fieldWidth: options.fieldWidth ?? 0,
369
+ maxVisibleChars: options.maxVisibleChars ?? 0,
370
+ fullWidth: options.fullWidth ?? false,
371
+ completions: options.completions ?? [],
372
+ key: options.key,
373
+ };
374
+ }
375
+
376
+ /** Multi-line text widget. Thin wrapper over `text({ rows, ... })`
377
+ * for ergonomic intent — renders as a `rows`-tall block with
378
+ * Enter-inserts-newline / Up-Down-line-nav semantics. Default `rows`
379
+ * is 5; pass `rows: N` to override. */
380
+ export function textArea(
381
+ options: {
382
+ value?: string;
383
+ cursorByte?: number;
384
+ focused?: boolean;
385
+ label?: string;
386
+ placeholder?: string;
387
+ /** Visible rows of editing area; default 5. */
388
+ rows?: number;
389
+ /** Visible column width; `0` = use panel width. */
390
+ fieldWidth?: number;
391
+ fullWidth?: boolean;
392
+ key?: string;
393
+ } = {},
394
+ ): WidgetSpec {
395
+ return text({
396
+ ...options,
397
+ rows: options.rows ?? 5,
398
+ });
399
+ }
400
+
401
+ /** Single-line text widget. Thin wrapper over `text({ rows: 1,
402
+ * ... })` matching the historical `textInput(value, opts)`
403
+ * signature. Renders as `[value]` (or `Label: [value]` if `label`
404
+ * is provided), with Enter-advances-focus semantics. */
405
+ export function textInput(
406
+ value: string,
407
+ options?: {
408
+ cursorByte?: number;
409
+ focused?: boolean;
410
+ label?: string;
411
+ placeholder?: string;
412
+ /** Soft truncation cap (legacy). Prefer `fieldWidth`. */
413
+ maxVisibleChars?: number;
414
+ /** Constant visible width inside the brackets. */
415
+ fieldWidth?: number;
416
+ /** See `text({ fullWidth })`. */
417
+ fullWidth?: boolean;
418
+ key?: string;
419
+ },
420
+ ): WidgetSpec {
421
+ return text({
422
+ value,
423
+ cursorByte: options?.cursorByte,
424
+ focused: options?.focused,
425
+ label: options?.label,
426
+ placeholder: options?.placeholder,
427
+ rows: 1,
428
+ fieldWidth: options?.fieldWidth,
429
+ maxVisibleChars: options?.maxVisibleChars,
430
+ fullWidth: options?.fullWidth,
431
+ key: options?.key,
432
+ });
433
+ }
434
+
435
+ /** Reserve a rectangle in the layout for the host to natively
436
+ * render the editor window identified by `windowId`. The widget
437
+ * itself emits `rows` blank lines so layout reserves the space;
438
+ * the host's paint path then overlays the live window UI (split
439
+ * tree, terminals, syntax highlighting, decorations) into the
440
+ * reserved rectangle.
441
+ *
442
+ * `windowId` of 0 (or any unknown id) renders the placeholder
443
+ * blanks without dispatching the per-window paint. */
444
+ export function windowEmbed(options: {
445
+ windowId: number;
446
+ rows: number;
447
+ key?: string;
448
+ }): WidgetSpec {
449
+ return {
450
+ kind: "windowEmbed",
451
+ windowId: options.windowId,
452
+ rows: options.rows,
453
+ key: options.key,
454
+ };
455
+ }
456
+
457
+ /** Group a single child widget inside a rounded, thin border
458
+ * with `label` printed as a top-left legend (HTML
459
+ * `<fieldset>` semantics). The host renders three rows:
460
+ *
461
+ * ╭─ Label ──────────────────╮
462
+ * │ <child rendered content> │
463
+ * ╰──────────────────────────╯
464
+ *
465
+ * The section always spans the parent's available width. The
466
+ * child is rendered with the inner width (parent width minus
467
+ * 4 columns of border + padding), so child widgets that honour
468
+ * `fullWidth: true` size themselves to fill the inner area.
469
+ * Focus, hit areas and cursor positions bubble up from the
470
+ * child unchanged, shifted by the border offset. */
471
+ export function labeledSection(options: {
472
+ label?: string;
473
+ child: WidgetSpec;
474
+ /** When this section is a Block child of a Row, request a
475
+ * specific share of the row's width as a percentage (1..=100).
476
+ * Out-of-range values fall back to the equal-split default.
477
+ * Useful for picker-style layouts: a narrow list pane next to
478
+ * a wide preview pane. */
479
+ widthPct?: number;
480
+ key?: string;
481
+ }): WidgetSpec {
482
+ return {
483
+ kind: "labeledSection",
484
+ label: options.label ?? "",
485
+ child: options.child,
486
+ widthPct: options.widthPct,
487
+ key: options.key,
488
+ };
489
+ }
490
+
491
+ /** Float `child` over the rest of the layout instead of consuming
492
+ * vertical space. Place inside a `Col` at the position where you
493
+ * want the overlay to anchor — at paint time the child renders
494
+ * at that row but DOES NOT push the rows below it down. Used for
495
+ * dropdown completions, tooltips, hover popups — anything that
496
+ * should appear next to a focused widget without reflowing the
497
+ * panel each time it shows or hides.
498
+ *
499
+ * Hit-testing: overlays paint on top, so clicks inside the
500
+ * overlay's region go to the overlay (not what's underneath).
501
+ * Tab cycle: the child IS walked for tabbable keys — give it a
502
+ * `key` if you want focus to reach it, or leave it keyless to
503
+ * keep it out of the cycle (the typical popup case). */
504
+ export function overlay(
505
+ child: WidgetSpec,
506
+ options?: { key?: string },
507
+ ): WidgetSpec {
508
+ return {
509
+ kind: "overlay",
510
+ child,
511
+ key: options?.key,
512
+ };
513
+ }
514
+
515
+ // =============================================================================
516
+ // HintEntry parsing — for the legacy `Tab:section Esc:close` format
517
+ // shipped in existing plugin i18n bundles.
518
+ // =============================================================================
519
+
520
+ /** Parse a hint string of the form `<keys>:<label> <keys>:<label> ...`.
521
+ *
522
+ * The separator between entries defaults to two-or-more spaces (matching
523
+ * what existing i18n bundles use). The separator between keys and label
524
+ * within an entry is a colon.
525
+ *
526
+ * Empty input yields an empty array. Entries without a colon are
527
+ * preserved with empty label. */
528
+ export function parseHintString(
529
+ s: string,
530
+ options?: { entrySep?: RegExp; keyLabelSep?: string },
531
+ ): HintEntry[] {
532
+ if (!s) return [];
533
+ const entrySep = options?.entrySep ?? /\s{2,}/;
534
+ const keyLabelSep = options?.keyLabelSep ?? ":";
535
+ const parts = s.split(entrySep).filter((p) => p.length > 0);
536
+ return parts.map((part) => {
537
+ const idx = part.indexOf(keyLabelSep);
538
+ if (idx < 0) {
539
+ return { keys: part, label: "" };
540
+ }
541
+ return {
542
+ keys: part.slice(0, idx).trim(),
543
+ label: part.slice(idx + keyLabelSep.length).trim(),
544
+ };
545
+ });
546
+ }
547
+
548
+ // =============================================================================
549
+ // WidgetPanel — mount-once-update-many wrapper around the IPC trio.
550
+ // =============================================================================
551
+
552
+ /** A handle to a mounted widget panel. Construct one per virtual
553
+ * buffer that should host widget-rendered content; call `set(spec)`
554
+ * on every render; call `unmount()` when the buffer is closed.
555
+ *
556
+ * The first `set()` issues `mountWidgetPanel`; subsequent calls
557
+ * issue `updateWidgetPanel`. Idempotent re-mount is guaranteed by the
558
+ * host (see `WidgetRegistry::mount`). */
559
+ export class WidgetPanel {
560
+ private mounted = false;
561
+ private readonly panelId: number;
562
+ private readonly bufferId: number;
563
+
564
+ constructor(bufferId: number, panelId?: number) {
565
+ this.bufferId = bufferId;
566
+ this.panelId = panelId ?? allocatePanelId();
567
+ }
568
+
569
+ /** Returns the plugin-allocated panel id, useful for routing
570
+ * widget events back through `editor.on("widget_event", ...)`. */
571
+ id(): number {
572
+ return this.panelId;
573
+ }
574
+
575
+ /** Render or re-render the panel against the given spec.
576
+ * Cheap to call on every state change; the host reconciles. */
577
+ set(spec: WidgetSpec): boolean {
578
+ // deno-lint-ignore no-explicit-any
579
+ const editor = (globalThis as any).editor;
580
+ if (!this.mounted) {
581
+ this.mounted = true;
582
+ return editor.mountWidgetPanel(this.panelId, this.bufferId, spec);
583
+ }
584
+ return editor.updateWidgetPanel(this.panelId, spec);
585
+ }
586
+
587
+ /** Tear down the panel. The plugin retains ownership of the
588
+ * underlying virtual buffer. Subsequent `set()` calls re-mount. */
589
+ unmount(): boolean {
590
+ if (!this.mounted) return true;
591
+ this.mounted = false;
592
+ // deno-lint-ignore no-explicit-any
593
+ const editor = (globalThis as any).editor;
594
+ return editor.unmountWidgetPanel(this.panelId);
595
+ }
596
+
597
+ /** Route a key/nav action to the focused widget in this panel.
598
+ * The host computes the result on the focused widget's kind and
599
+ * fires `widget_event` as appropriate. See `WidgetAction` for
600
+ * the action shapes. */
601
+ command(action: WidgetAction): boolean {
602
+ // deno-lint-ignore no-explicit-any
603
+ const editor = (globalThis as any).editor;
604
+ return editor.widgetCommand(this.panelId, action);
605
+ }
606
+
607
+ /** Apply a targeted mutation in place — the IPC fast path.
608
+ * Use instead of `set(spec)` when only one widget changes;
609
+ * the host applies the mutation directly and re-renders
610
+ * without re-transmitting the full spec. See `WidgetMutation`
611
+ * for the shapes. The typed wrappers below cover the common
612
+ * cases. */
613
+ mutate(mutation: WidgetMutation): boolean {
614
+ // deno-lint-ignore no-explicit-any
615
+ const editor = (globalThis as any).editor;
616
+ return editor.widgetMutate(this.panelId, mutation);
617
+ }
618
+
619
+ /** Set a `TextInput`'s value (and optionally cursor byte).
620
+ * Mutates host instance state; doesn't re-transmit the full
621
+ * spec. */
622
+ setValue(widgetKey: string, value: string, cursorByte?: number): boolean {
623
+ return this.mutate({ kind: "setValue", widgetKey, value, cursorByte });
624
+ }
625
+
626
+ /** Set a `Toggle`'s checked state. */
627
+ setChecked(widgetKey: string, checked: boolean): boolean {
628
+ return this.mutate({ kind: "setChecked", widgetKey, checked });
629
+ }
630
+
631
+ /** Set a `List`'s selected index. */
632
+ setSelectedIndex(widgetKey: string, index: number): boolean {
633
+ return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
634
+ }
635
+
636
+ /** Update a Text widget's completion popup candidates. Empty
637
+ * `items` closes the popup; non-empty opens it and resets the
638
+ * host-managed selection to index 0. The host repaints the
639
+ * popup on its own; the plugin doesn't need to follow up with
640
+ * an `update(spec)` call. */
641
+ setCompletions(
642
+ widgetKey: string,
643
+ items: Array<string | { value: string; kind?: string }>,
644
+ ): boolean {
645
+ return this.mutate({ kind: "setCompletions", widgetKey, items });
646
+ }
647
+
648
+ /** Replace a `List`'s items + parallel `itemKeys`. */
649
+ setItems(
650
+ widgetKey: string,
651
+ items: TextPropertyEntry[],
652
+ itemKeys: string[] = [],
653
+ ): boolean {
654
+ return this.mutate({ kind: "setItems", widgetKey, items, itemKeys });
655
+ }
656
+
657
+ /** Replace a `Tree`'s expanded-keys instance state. The host
658
+ * normally owns expansion (Right/Left arrows + disclosure
659
+ * clicks); use this when a non-user action drives expansion
660
+ * (e.g. "expand all", reveal-on-search). */
661
+ setExpandedKeys(widgetKey: string, keys: string[]): boolean {
662
+ return this.mutate({ kind: "setExpandedKeys", widgetKey, keys });
663
+ }
664
+
665
+ /** Stamp `checked` onto every node in the named `Tree` whose
666
+ * `itemKey` appears in `keys`. Used by the `toggle` event
667
+ * handler to push the post-click state back without a full spec
668
+ * re-emit. Nodes whose existing `checked` is `undefined` (no
669
+ * checkbox glyph) are unchanged. */
670
+ setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
671
+ return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
672
+ }
673
+
674
+ /** Append `newNodes` (and parallel `newItemKeys`) to an existing
675
+ * `Tree`'s node list — the streaming-friendly counterpart to
676
+ * `setItems`. Existing selection, scroll, and expansion state are
677
+ * preserved; the renderer simply has more tail to paint on the next
678
+ * cycle. Cheap relative to a full spec re-emit for plugins that
679
+ * stream large result sets (e.g. a project-wide grep). */
680
+ appendTreeNodes(
681
+ widgetKey: string,
682
+ newNodes: TreeNode[],
683
+ newItemKeys: string[] = [],
684
+ ): boolean {
685
+ return this.mutate({
686
+ kind: "appendTreeNodes",
687
+ widgetKey,
688
+ newNodes,
689
+ newItemKeys,
690
+ });
691
+ }
692
+
693
+ /** Replace the entries of a `Raw` widget identified by `widgetKey`.
694
+ *
695
+ * Use this when a small piece of panel chrome (a label, a header,
696
+ * a status line) needs to change but the rest of the spec is
697
+ * unchanged — calling `set(...)` to push the whole spec just to
698
+ * flip a few characters would force a `js_to_json` walk of every
699
+ * other widget (and every `Tree` node) in the panel, which can
700
+ * block the JS thread for hundreds of ms on large panels. */
701
+ setRawEntries(widgetKey: string, entries: TextPropertyEntry[]): boolean {
702
+ return this.mutate({ kind: "setRawEntries", widgetKey, entries });
703
+ }
704
+
705
+ /** Set the panel's focused widget by key. Passing a key that isn't
706
+ * a current tabbable is harmless — the next render clamps focus to
707
+ * the first tabbable. */
708
+ setFocusKey(widgetKey: string): boolean {
709
+ return this.mutate({ kind: "setFocusKey", widgetKey });
710
+ }
711
+ }
712
+
713
+ // =============================================================================
714
+ // FloatingWidgetPanel — mount-once-update-many wrapper for centered
715
+ // floating overlays (no virtual buffer required).
716
+ // =============================================================================
717
+
718
+ /** A handle to a floating widget panel — a modal-ish overlay
719
+ * rendered in a centered frame on top of the editor, dimming the
720
+ * background. Unlike `WidgetPanel`, no virtual buffer is needed;
721
+ * the host owns the rect and paints the spec inside it.
722
+ *
723
+ * `mount({ widthPct, heightPct })` mounts the panel and renders
724
+ * the spec; `update(spec)` re-renders against the previous instance
725
+ * state; `unmount()` tears it down. The host routes keys to the
726
+ * focused widget automatically while a floating panel is up: Esc
727
+ * unmounts and fires a `widget_event` "cancel"; Tab / Enter /
728
+ * arrows / Backspace / printable chars route through the same
729
+ * smart-key dispatch as `WidgetPanel.command(key(...))`. */
730
+ export class FloatingWidgetPanel {
731
+ private mounted = false;
732
+ private readonly panelId: number;
733
+
734
+ constructor(panelId?: number) {
735
+ this.panelId = panelId ?? allocatePanelId();
736
+ }
737
+
738
+ /** Returns the plugin-allocated panel id, useful for routing
739
+ * widget events back through `editor.on("widget_event", ...)`. */
740
+ id(): number {
741
+ return this.panelId;
742
+ }
743
+
744
+ /** Mount the panel as a centered overlay sized by `widthPct` /
745
+ * `heightPct` (percent of terminal, clamped 1..=100). Cheap to
746
+ * call repeatedly with a new spec — re-mounting replaces the
747
+ * existing panel. */
748
+ mount(
749
+ spec: WidgetSpec,
750
+ options: { widthPct?: number; heightPct?: number } = {},
751
+ ): boolean {
752
+ // deno-lint-ignore no-explicit-any
753
+ const editor = (globalThis as any).editor;
754
+ const wp = options.widthPct ?? 60;
755
+ const hp = options.heightPct ?? 40;
756
+ this.mounted = true;
757
+ return editor.mountFloatingWidget(this.panelId, spec, wp, hp);
758
+ }
759
+
760
+ /** Re-render the panel against the given spec; instance state on
761
+ * keyed widgets is preserved. No-op when not mounted. */
762
+ update(spec: WidgetSpec): boolean {
763
+ if (!this.mounted) return false;
764
+ // deno-lint-ignore no-explicit-any
765
+ const editor = (globalThis as any).editor;
766
+ return editor.updateFloatingWidget(this.panelId, spec);
767
+ }
768
+
769
+ /** Tear down the panel and let the editor return to its normal
770
+ * key/click routing. */
771
+ unmount(): boolean {
772
+ if (!this.mounted) return true;
773
+ this.mounted = false;
774
+ // deno-lint-ignore no-explicit-any
775
+ const editor = (globalThis as any).editor;
776
+ return editor.unmountFloatingWidget(this.panelId);
777
+ }
778
+
779
+ /** Route a key/nav action to the focused widget. The host
780
+ * automatically routes keystrokes while a floating panel is up,
781
+ * so plugins rarely need to call this directly — it's exposed
782
+ * for symmetry with `WidgetPanel`. */
783
+ command(action: WidgetAction): boolean {
784
+ // deno-lint-ignore no-explicit-any
785
+ const editor = (globalThis as any).editor;
786
+ return editor.widgetCommand(this.panelId, action);
787
+ }
788
+
789
+ /** Apply a targeted mutation in place — the IPC fast path. */
790
+ mutate(mutation: WidgetMutation): boolean {
791
+ // deno-lint-ignore no-explicit-any
792
+ const editor = (globalThis as any).editor;
793
+ return editor.widgetMutate(this.panelId, mutation);
794
+ }
795
+
796
+ setValue(widgetKey: string, value: string, cursorByte?: number): boolean {
797
+ return this.mutate({ kind: "setValue", widgetKey, value, cursorByte });
798
+ }
799
+
800
+ setChecked(widgetKey: string, checked: boolean): boolean {
801
+ return this.mutate({ kind: "setChecked", widgetKey, checked });
802
+ }
803
+
804
+ setSelectedIndex(widgetKey: string, index: number): boolean {
805
+ return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
806
+ }
807
+
808
+ /** Update a Text widget's completion popup candidates. Empty
809
+ * `items` closes the popup; non-empty opens it and resets the
810
+ * host-managed selection to index 0. */
811
+ setCompletions(
812
+ widgetKey: string,
813
+ items: Array<string | { value: string; kind?: string }>,
814
+ ): boolean {
815
+ return this.mutate({ kind: "setCompletions", widgetKey, items });
816
+ }
817
+
818
+ setItems(
819
+ widgetKey: string,
820
+ items: TextPropertyEntry[],
821
+ itemKeys: string[] = [],
822
+ ): boolean {
823
+ return this.mutate({ kind: "setItems", widgetKey, items, itemKeys });
824
+ }
825
+
826
+ setExpandedKeys(widgetKey: string, keys: string[]): boolean {
827
+ return this.mutate({ kind: "setExpandedKeys", widgetKey, keys });
828
+ }
829
+
830
+ setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
831
+ return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
832
+ }
833
+
834
+ /** Set the panel's focused widget by key. Passing a key that isn't
835
+ * a current tabbable is harmless — the next render clamps focus to
836
+ * the first tabbable. */
837
+ setFocusKey(widgetKey: string): boolean {
838
+ return this.mutate({ kind: "setFocusKey", widgetKey });
839
+ }
840
+ }
841
+
842
+ // =============================================================================
843
+ // WidgetAction builders — convenience wrappers around `panel.command(...)`.
844
+ // Plugin's mode bindings call these for keys handled by the widget layer.
845
+ // =============================================================================
846
+
847
+ /** Cycle focus through the panel's tabbable widgets. `delta=+1`
848
+ * for Tab, `-1` for Shift+Tab. Wraps at the ends. */
849
+ export function focusAdvance(delta: number): WidgetAction {
850
+ return { kind: "focusAdvance", delta };
851
+ }
852
+
853
+ /** Activate the focused widget (Enter on Button → "activate"
854
+ * event; Enter on Toggle → "toggle" event). No-op for other
855
+ * widget kinds. */
856
+ export function activate(): WidgetAction {
857
+ return { kind: "activate" };
858
+ }
859
+
860
+ /** Move the focused List's selection by `delta`. Plugin listens
861
+ * for `widget_event` "select" to mirror back into its model. */
862
+ export function selectMove(delta: number): WidgetAction {
863
+ return { kind: "selectMove", delta };
864
+ }
865
+
866
+ /** Apply a non-printable editing key to the focused TextInput:
867
+ * `"Backspace"`, `"Delete"`, `"Left"`, `"Right"`, `"Home"`,
868
+ * `"End"`. Fires `widget_event` "change" with the new value +
869
+ * cursorByte. */
870
+ export function textInputKey(key: string): WidgetAction {
871
+ return { kind: "textInputKey", key };
872
+ }
873
+
874
+ /** Append printable text at the focused TextInput's cursor.
875
+ * Fires `widget_event` "change" with the new value + cursorByte.
876
+ * Used for the `mode_text_input` fall-through path. */
877
+ export function textInputChar(text: string): WidgetAction {
878
+ return { kind: "textInputChar", text };
879
+ }
880
+
881
+ /** Smart-key dispatch — routes the keystroke to the right widget
882
+ * action based on the focused widget's kind. Plugin's mode bindings
883
+ * use this rather than picking the right action themselves: bind
884
+ * Tab/Shift+Tab/Enter/Space/Backspace/Delete/Left/Right/Up/Down/
885
+ * Home/End all to one handler that calls `panel.command(key("Tab"))`.
886
+ *
887
+ * See `WidgetAction::Key` (Rust) for the full dispatch table. */
888
+ export function key(name: string): WidgetAction {
889
+ return { kind: "key", key: name };
890
+ }
891
+
892
+ // =============================================================================
893
+ // Panel-id allocation. Plugin-side counter; need only be unique per
894
+ // plugin instance (the host doesn't interpret the value).
895
+ // =============================================================================
896
+
897
+ let nextPanelId = 1;
898
+ function allocatePanelId(): number {
899
+ // Bias high so plugin-allocated ids don't collide with the
900
+ // editor's internal panel-id space if it ever uses small ints.
901
+ const id = nextPanelId++;
902
+ return 0x1000_0000 + id;
903
+ }