@silo-code/sdk 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/context-keys.d.ts +19 -0
  4. package/dist/context-keys.d.ts.map +1 -0
  5. package/dist/context-keys.js +2 -0
  6. package/dist/context-keys.js.map +1 -0
  7. package/dist/dnd-service.d.ts +140 -0
  8. package/dist/dnd-service.d.ts.map +1 -0
  9. package/dist/dnd-service.js +17 -0
  10. package/dist/dnd-service.js.map +1 -0
  11. package/dist/domain-types.d.ts +237 -0
  12. package/dist/domain-types.d.ts.map +1 -0
  13. package/dist/domain-types.js +11 -0
  14. package/dist/domain-types.js.map +1 -0
  15. package/dist/editor-service.d.ts +175 -0
  16. package/dist/editor-service.d.ts.map +1 -0
  17. package/dist/editor-service.js +2 -0
  18. package/dist/editor-service.js.map +1 -0
  19. package/dist/extension-storage.d.ts +26 -0
  20. package/dist/extension-storage.d.ts.map +1 -0
  21. package/dist/extension-storage.js +2 -0
  22. package/dist/extension-storage.js.map +1 -0
  23. package/dist/file-service.d.ts +84 -0
  24. package/dist/file-service.d.ts.map +1 -0
  25. package/dist/file-service.js +2 -0
  26. package/dist/file-service.js.map +1 -0
  27. package/dist/index.d.ts +32 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +22 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/layout-service.d.ts +46 -0
  32. package/dist/layout-service.d.ts.map +1 -0
  33. package/dist/layout-service.js +2 -0
  34. package/dist/layout-service.js.map +1 -0
  35. package/dist/permissions.d.ts +41 -0
  36. package/dist/permissions.d.ts.map +1 -0
  37. package/dist/permissions.js +40 -0
  38. package/dist/permissions.js.map +1 -0
  39. package/dist/process-service.d.ts +132 -0
  40. package/dist/process-service.d.ts.map +1 -0
  41. package/dist/process-service.js +2 -0
  42. package/dist/process-service.js.map +1 -0
  43. package/dist/terminal-service.d.ts +38 -0
  44. package/dist/terminal-service.d.ts.map +1 -0
  45. package/dist/terminal-service.js +2 -0
  46. package/dist/terminal-service.js.map +1 -0
  47. package/dist/theme-service.d.ts +87 -0
  48. package/dist/theme-service.d.ts.map +1 -0
  49. package/dist/theme-service.js +2 -0
  50. package/dist/theme-service.js.map +1 -0
  51. package/dist/types.d.ts +495 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/dist/ui-service.d.ts +469 -0
  56. package/dist/ui-service.d.ts.map +1 -0
  57. package/dist/ui-service.js +2 -0
  58. package/dist/ui-service.js.map +1 -0
  59. package/dist/use-focus-group.d.ts +202 -0
  60. package/dist/use-focus-group.d.ts.map +1 -0
  61. package/dist/use-focus-group.js +236 -0
  62. package/dist/use-focus-group.js.map +1 -0
  63. package/dist/use-service-state.d.ts +36 -0
  64. package/dist/use-service-state.d.ts.map +1 -0
  65. package/dist/use-service-state.js +25 -0
  66. package/dist/use-service-state.js.map +1 -0
  67. package/dist/workspace-service.d.ts +72 -0
  68. package/dist/workspace-service.d.ts.map +1 -0
  69. package/dist/workspace-service.js +2 -0
  70. package/dist/workspace-service.js.map +1 -0
  71. package/package.json +54 -0
  72. package/src/context-keys.ts +18 -0
  73. package/src/dnd-service.ts +151 -0
  74. package/src/domain-types.ts +252 -0
  75. package/src/editor-service.ts +196 -0
  76. package/src/extension-storage.ts +25 -0
  77. package/src/file-service.ts +90 -0
  78. package/src/index.ts +151 -0
  79. package/src/layout-service.ts +49 -0
  80. package/src/permissions.ts +55 -0
  81. package/src/process-service.ts +143 -0
  82. package/src/terminal-service.ts +41 -0
  83. package/src/theme-service.ts +102 -0
  84. package/src/types.ts +513 -0
  85. package/src/ui-service.ts +487 -0
  86. package/src/use-focus-group.test.ts +168 -0
  87. package/src/use-focus-group.ts +382 -0
  88. package/src/use-service-state.ts +43 -0
  89. package/src/workspace-service.ts +76 -0
@@ -0,0 +1,487 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ // `ctx.ui` — the user-interaction domain (public contract). The host renders the
4
+ // chrome; extensions ask. Native OS dialogs, toast notifications, themed menus,
5
+ // and host-owned confirm/prompt modals. The implementation lives in the host.
6
+
7
+ /**
8
+ * A file-type filter for the native open/save dialogs ({@link UiService.pickFile},
9
+ * {@link UiService.savePath}) — a human-readable group plus the extensions it
10
+ * matches. Mirrors the OS dialog's file-type dropdown.
11
+ *
12
+ * @category Core Types
13
+ * @public
14
+ */
15
+ export interface FileFilter {
16
+ /** Human-readable label for the group, e.g. `"JSON"` or `"Images"`. */
17
+ name: string;
18
+ /** Extensions this group matches, **without** the leading dot, e.g. `["json"]`. */
19
+ extensions: string[];
20
+ }
21
+
22
+ /**
23
+ * A secondary control rendered at the trailing edge of a {@link MenuItem} —
24
+ * e.g. a delete button on a row whose primary click does something else
25
+ * (reopen). Its click is isolated: it runs `onClick` and does **not** trigger
26
+ * the row's {@link MenuItem.run}.
27
+ *
28
+ * @category Registration
29
+ * @public
30
+ */
31
+ export interface MenuItemTrailing {
32
+ /** The control's glyph (e.g. a Phosphor icon element). */
33
+ icon: ReactNode;
34
+ /** Native tooltip for the control. */
35
+ title?: string;
36
+ /** Invoked when the control is clicked; the menu closes first. */
37
+ onClick: () => void;
38
+ }
39
+
40
+ /**
41
+ * One actionable row in a menu shown by {@link UiService.showMenu}. The host
42
+ * renders and themes the chrome; the extension supplies the data and an action.
43
+ *
44
+ * @category Registration
45
+ * @public
46
+ */
47
+ export interface MenuItem {
48
+ /** The row's text. */
49
+ label: string;
50
+ /**
51
+ * A pre-formatted shortcut hint shown right-aligned, e.g. `"⌘C"` or
52
+ * `"Ctrl+C"`. Display only — it does not bind the key. Format it for the
53
+ * platform yourself.
54
+ */
55
+ accelerator?: string;
56
+ /** Leading glyph (e.g. a Phosphor icon element). */
57
+ icon?: ReactNode;
58
+ /** Show a check in the leading gutter — for toggle / current-selection rows. */
59
+ checked?: boolean;
60
+ /** Render the row dimmed and inert. */
61
+ disabled?: boolean;
62
+ /** Style the row as destructive (e.g. Delete). */
63
+ danger?: boolean;
64
+ /** Native tooltip for the row. */
65
+ title?: string;
66
+ /** A secondary trailing control (see {@link MenuItemTrailing}). */
67
+ trailing?: MenuItemTrailing;
68
+ /**
69
+ * A nested menu that cascades open to the side when this row is hovered or
70
+ * clicked. A row with a `submenu` is a *parent*: it shows a trailing caret and
71
+ * opening it reveals these {@link MenuEntry | entries} rather than running an
72
+ * action. Give a row a `submenu` **or** a {@link MenuItem.run | run}, not both
73
+ * (a `run` is ignored while the submenu is the active target).
74
+ */
75
+ submenu?: MenuEntry[];
76
+ /**
77
+ * Invoked when the row is chosen; the menu closes first. Optional only for
78
+ * submenu parents (rows with a {@link MenuItem.submenu | submenu}); every leaf
79
+ * row must supply one.
80
+ */
81
+ run?: () => void | Promise<void>;
82
+ }
83
+
84
+ /**
85
+ * Options for {@link UiService.confirm} — a host-rendered yes/no dialog. Always
86
+ * dismissible (`Escape` and backdrop-click both resolve to `false`, the safe
87
+ * choice). Set {@link ConfirmOptions.danger | danger} for destructive actions.
88
+ *
89
+ * @category Core Types
90
+ * @public
91
+ */
92
+ export interface ConfirmOptions {
93
+ /** The dialog's heading. */
94
+ title: string;
95
+ /** Optional explanatory line beneath the title. */
96
+ body?: string;
97
+ /** Label for the confirm button. Default `"OK"`. */
98
+ confirmLabel?: string;
99
+ /** Label for the cancel button. Default `"Cancel"`. */
100
+ cancelLabel?: string;
101
+ /** Style the confirm button as destructive (`.silo-button-danger`). */
102
+ danger?: boolean;
103
+ }
104
+
105
+ /**
106
+ * Options for {@link UiService.prompt} — a host-rendered single-line text input
107
+ * dialog. Always dismissible (`Escape` and backdrop-click both resolve to
108
+ * `null`, i.e. cancelled).
109
+ *
110
+ * @category Core Types
111
+ * @public
112
+ */
113
+ export interface PromptOptions {
114
+ /** The dialog's heading. */
115
+ title: string;
116
+ /** Optional label shown above the input. */
117
+ label?: string;
118
+ /** Pre-fills the input (and is selected for easy replacement). */
119
+ initialValue?: string;
120
+ /** Placeholder shown when the input is empty. */
121
+ placeholder?: string;
122
+ /** Label for the confirm button. Default `"OK"`. */
123
+ confirmLabel?: string;
124
+ /** Label for the cancel button. Default `"Cancel"`. */
125
+ cancelLabel?: string;
126
+ }
127
+
128
+ /**
129
+ * One action button rendered in a toast — see {@link NotifyOptions.actions}.
130
+ * The host themes the button; the extension supplies the label and what to do.
131
+ *
132
+ * @category Core Types
133
+ * @public
134
+ */
135
+ export interface NotifyAction {
136
+ /** The button's text. */
137
+ label: string;
138
+ /**
139
+ * Invoked when the button is clicked. The toast then dismisses unless
140
+ * {@link NotifyAction.keepOpen} is set — so a "View details" action that opens
141
+ * a modal can close the toast behind it.
142
+ */
143
+ run: () => void | Promise<void>;
144
+ /** Keep the toast open after {@link NotifyAction.run} (default: dismiss it). */
145
+ keepOpen?: boolean;
146
+ }
147
+
148
+ /**
149
+ * Options for {@link UiService.notify} — an optional title, action buttons, and
150
+ * auto-dismiss control layered on top of the toast's `level` + `message`.
151
+ *
152
+ * @category Core Types
153
+ * @public
154
+ */
155
+ export interface NotifyOptions {
156
+ /** A short bold heading rendered above the `message`. */
157
+ title?: string;
158
+ /** Action buttons rendered in the toast's footer (see {@link NotifyAction}). */
159
+ actions?: NotifyAction[];
160
+ /**
161
+ * Auto-dismiss delay in milliseconds. Omit for the default behavior: `error`
162
+ * toasts and any toast with {@link NotifyOptions.actions | actions} stay until
163
+ * the user dismisses them, while `info` / `warn` auto-dismiss after ~4s. Pass
164
+ * `0` to force "stay until dismissed"; a positive number sets an explicit delay.
165
+ */
166
+ durationMs?: number;
167
+ }
168
+
169
+ /**
170
+ * Options for {@link UiService.showModal} — the host-owned chrome around your
171
+ * custom modal content. The host owns the backdrop, z-order (stacking above all
172
+ * host chrome, arbitrated centrally), focus trap, and restore-focus-on-close;
173
+ * you supply the content and these presentation options.
174
+ *
175
+ * Unlike {@link ConfirmOptions} / {@link PromptOptions}, a `showModal` dialog is
176
+ * **not dismissible by default** — set {@link ModalOptions.dismissible} to wire
177
+ * `Escape` + backdrop-click to close (guarding staged edits otherwise).
178
+ *
179
+ * @category Core Types
180
+ * @public
181
+ */
182
+ export interface ModalOptions {
183
+ /** Optional header rendered at the top of the card; omit for bare layouts. */
184
+ title?: ReactNode;
185
+ /**
186
+ * Allow `Escape` and backdrop-click to close the modal (resolving the
187
+ * {@link UiService.showModal} promise with `undefined`). Defaults to
188
+ * **`false`** — the modal stays open until your content calls `close`,
189
+ * guarding against accidental loss of staged edits.
190
+ */
191
+ dismissible?: boolean;
192
+ /** Width preset for the card. Default `"md"`. Ignored when `bare`. */
193
+ size?: "sm" | "md" | "lg";
194
+ /**
195
+ * Skip the card chrome — your content *is* the card (it supplies its own
196
+ * background/size). The host still owns the backdrop, stacking, and focus
197
+ * trap. Used by full-bleed layouts.
198
+ */
199
+ bare?: boolean;
200
+ /** Extra class on the card, for special-case layouts. */
201
+ className?: string;
202
+ /** Accessible name for dialogs without a visible {@link ModalOptions.title}. */
203
+ ariaLabel?: string;
204
+ }
205
+
206
+ /**
207
+ * A horizontal rule between groups of menu items.
208
+ *
209
+ * @category Registration
210
+ * @public
211
+ */
212
+ export interface MenuSeparator {
213
+ type: "separator";
214
+ }
215
+
216
+ /**
217
+ * A non-interactive group label within a menu.
218
+ *
219
+ * @category Registration
220
+ * @public
221
+ */
222
+ export interface MenuHeader {
223
+ type: "header";
224
+ /** The label text (rendered uppercase). */
225
+ label: string;
226
+ }
227
+
228
+ /**
229
+ * One entry in a menu — an actionable {@link MenuItem}, a {@link MenuSeparator},
230
+ * or a {@link MenuHeader}.
231
+ *
232
+ * @category Registration
233
+ * @public
234
+ */
235
+ export type MenuEntry = MenuItem | MenuSeparator | MenuHeader;
236
+
237
+ /**
238
+ * Options for {@link UiService.showMenu}. Position resolves in the order
239
+ * `anchor` → `at` → the current cursor (so `showMenu({ items })` with no
240
+ * position opens at the mouse, which is what a right-click handler wants).
241
+ *
242
+ * @category Registration
243
+ * @public
244
+ */
245
+ export interface ShowMenuOptions {
246
+ /** The rows to show, top to bottom. */
247
+ items: MenuEntry[];
248
+ /** Explicit viewport point — e.g. a right-click's `clientX`/`clientY`. */
249
+ at?: { x: number; y: number };
250
+ /** Anchor element to hang the menu off (for a button dropdown). */
251
+ anchor?: HTMLElement | null;
252
+ /** Align the menu to the anchor's left (`"start"`, default) or right (`"end"`). */
253
+ align?: "start" | "end";
254
+ /**
255
+ * Toggle an anchored dropdown. When `true` (the default), calling `showMenu`
256
+ * again with the **same `anchor`** while that menu is still open closes it
257
+ * instead of reopening — so a second click on the button dismisses its
258
+ * dropdown. Set `false` to keep the legacy always-(re)open behaviour. Has no
259
+ * effect without an `anchor` (cursor / `at` menus always open).
260
+ */
261
+ toggle?: boolean;
262
+ }
263
+
264
+ /**
265
+ * The user-interaction domain, exposed as {@link ExtensionContext.ui}. The host
266
+ * renders the chrome; an extension only asks. Interactions today:
267
+ *
268
+ * - **Native OS dialogs** — {@link UiService.pickFolder | pickFolder},
269
+ * {@link UiService.pickFile | pickFile}, {@link UiService.savePath | savePath}.
270
+ * Thin wrappers over the platform dialogs the host owns; each resolves to an
271
+ * absolute path or `null` when the user cancels.
272
+ * - **Notifications** — {@link UiService.notify | notify} shows a transient
273
+ * toast, optionally with a title and action buttons (see {@link NotifyOptions}).
274
+ * The only way an extension can proactively message the user.
275
+ * - **Menus** — {@link UiService.showMenu | showMenu} pops a context menu or
276
+ * button dropdown, themed to match the rest of the app.
277
+ * - **Modal dialogs** — {@link UiService.confirm | confirm} and
278
+ * {@link UiService.prompt | prompt} pop a host-owned modal and resolve on the
279
+ * user's choice; {@link UiService.showModal | showModal} pops one around your
280
+ * own custom content (a form or bespoke layout).
281
+ * - **External links** — {@link UiService.openExternal | openExternal} hands a
282
+ * URL to the OS (browser / mail client), the host's gateway to the world
283
+ * outside the app.
284
+ *
285
+ * Mirrors VS Code's `window.show*`. More host-rendered chrome (quick-pick,
286
+ * progress) is planned — see the roadmap.
287
+ *
288
+ * @category Consumer Services
289
+ * @public
290
+ */
291
+ export interface UiService {
292
+ /**
293
+ * Show the native folder picker. Resolves to the chosen absolute path, or
294
+ * `null` if the user cancelled.
295
+ *
296
+ * @param opts.defaultPath - Absolute path to open the dialog at.
297
+ */
298
+ pickFolder(opts?: { defaultPath?: string }): Promise<string | null>;
299
+ /**
300
+ * Show the native open-file picker (single selection). Resolves to the chosen
301
+ * absolute path, or `null` if the user cancelled.
302
+ *
303
+ * @param opts.defaultPath - Absolute path to open the dialog at.
304
+ * @param opts.filters - Restrict the selectable file types (see {@link FileFilter}).
305
+ */
306
+ pickFile(opts?: {
307
+ defaultPath?: string;
308
+ filters?: FileFilter[];
309
+ }): Promise<string | null>;
310
+ /**
311
+ * Show the native save dialog. Resolves to the chosen destination's absolute
312
+ * path, or `null` if the user cancelled.
313
+ *
314
+ * @param opts.defaultPath - Seeds the dialog's location and suggested filename.
315
+ * @param opts.filters - Restrict the file-type dropdown (see {@link FileFilter}).
316
+ */
317
+ savePath(opts?: {
318
+ defaultPath?: string;
319
+ filters?: FileFilter[];
320
+ }): Promise<string | null>;
321
+ /**
322
+ * Show a transient toast notification to the user. Fire-and-forget — the host
323
+ * renders it (and, for `info` / `warn` without actions, auto-dismisses it).
324
+ * `level` drives the icon and accent.
325
+ *
326
+ * Pass {@link NotifyOptions} for a bold `title`, footer `actions`, or an
327
+ * explicit `durationMs`. Errors and toasts with actions stay until dismissed
328
+ * (so a "View details" action isn't lost to the timer); everything else
329
+ * auto-dismisses after ~4s.
330
+ *
331
+ * @example
332
+ * ```ts
333
+ * // a plain info toast (auto-dismisses)
334
+ * ctx.ui.notify("info", "Theme exported.");
335
+ *
336
+ * // an error with a title and an action that opens the full detail in a modal
337
+ * ctx.ui.notify("error", String(err), {
338
+ * title: "Commit failed",
339
+ * actions: [
340
+ * {
341
+ * label: "View details",
342
+ * run: () =>
343
+ * ctx.ui.showModal((close) => <pre>{String(err)}</pre>, {
344
+ * title: "Commit failed",
345
+ * dismissible: true,
346
+ * }),
347
+ * },
348
+ * ],
349
+ * });
350
+ * ```
351
+ */
352
+ notify(
353
+ level: "info" | "warn" | "error",
354
+ message: string,
355
+ options?: NotifyOptions,
356
+ ): void;
357
+ /**
358
+ * Pop a menu — the same themed primitive behind every context menu and
359
+ * dropdown in Silo. Supply the {@link MenuEntry | rows} and where to place it
360
+ * (see {@link ShowMenuOptions}); the host renders it, runs the chosen item's
361
+ * {@link MenuItem.run | run}, and dismisses on outside-click or Escape.
362
+ *
363
+ * Only one menu is open at a time — calling `showMenu` again replaces it,
364
+ * except that re-opening with the same {@link ShowMenuOptions.anchor | anchor}
365
+ * toggles it closed (a second click on a dropdown button dismisses it); opt
366
+ * out with {@link ShowMenuOptions.toggle | toggle: false}. Resolves once an
367
+ * item runs or the menu is dismissed.
368
+ *
369
+ * @example
370
+ * ```ts
371
+ * // A right-click context menu at the cursor.
372
+ * element.addEventListener("contextmenu", (e) => {
373
+ * e.preventDefault();
374
+ * ctx.ui.showMenu({
375
+ * items: [
376
+ * { label: "Rename", run: rename },
377
+ * { type: "separator" },
378
+ * { label: "Delete", danger: true, run: del },
379
+ * ],
380
+ * });
381
+ * });
382
+ *
383
+ * // A dropdown anchored under a button.
384
+ * ctx.ui.showMenu({ items, anchor: buttonEl });
385
+ * ```
386
+ */
387
+ showMenu(opts: ShowMenuOptions): Promise<void>;
388
+ /**
389
+ * Pop a host-rendered confirm dialog and resolve to the user's choice —
390
+ * `true` for confirm, `false` for cancel. Always dismissible: `Escape` and
391
+ * backdrop-click both resolve `false`. The dialog stacks above all host
392
+ * chrome via the modal manager, so it works from anywhere.
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * if (await ctx.ui.confirm({
397
+ * title: "Delete workspace?",
398
+ * body: `"${name}" and its saved terminals will be permanently removed.`,
399
+ * confirmLabel: "Delete",
400
+ * danger: true,
401
+ * })) {
402
+ * service.delete(id);
403
+ * }
404
+ * ```
405
+ */
406
+ confirm(opts: ConfirmOptions): Promise<boolean>;
407
+ /**
408
+ * Pop a host-rendered single-line input dialog and resolve to the entered
409
+ * string, or `null` if the user cancelled (`Escape` / backdrop / Cancel).
410
+ *
411
+ * @example
412
+ * ```ts
413
+ * const name = await ctx.ui.prompt({ title: "Rename", initialValue: current });
414
+ * if (name !== null) rename(name);
415
+ * ```
416
+ */
417
+ prompt(opts: PromptOptions): Promise<string | null>;
418
+ /**
419
+ * Pop a host-rendered modal around your **own custom content** — the escape
420
+ * hatch beyond {@link UiService.confirm | confirm} / {@link UiService.prompt |
421
+ * prompt} when you need a form or bespoke layout. The host owns the hard parts
422
+ * (backdrop, central z-stacking above all chrome, focus trap,
423
+ * restore-focus-on-close); you own the content.
424
+ *
425
+ * Supply a `render` callback that receives a `close` function and returns the
426
+ * modal's content; wire your own buttons to `close(result)` (or `close()` to
427
+ * cancel). The returned promise resolves with the value passed to `close`, or
428
+ * `undefined` if the modal was dismissed (only possible when
429
+ * {@link ModalOptions.dismissible} is set) or `close()` was called with no
430
+ * argument — paralleling `confirm`→`false` / `prompt`→`null`. If you must tell
431
+ * "dismissed" from "closed with no result" apart, pass a distinct sentinel.
432
+ *
433
+ * **Not dismissible by default:** unless you set
434
+ * {@link ModalOptions.dismissible}, `Escape` and backdrop-click do nothing and
435
+ * the modal stays open until your content calls `close`. A non-dismissible
436
+ * modal whose content never calls `close` leaves the promise pending forever —
437
+ * by design, so staged edits can't be lost to an accidental click-away.
438
+ *
439
+ * @typeParam T - The result type your content resolves with via `close`.
440
+ * @param render - Returns the modal content; receives `close` to settle it.
441
+ * @param options - Presentation options (see {@link ModalOptions}).
442
+ *
443
+ * @example
444
+ * ```tsx
445
+ * const changes = await ctx.ui.showModal<Changes>(
446
+ * (close) => (
447
+ * <MyForm onCancel={() => close()} onSave={(c) => close(c)} />
448
+ * ),
449
+ * { title: "Properties", size: "md" },
450
+ * );
451
+ * if (changes) apply(changes);
452
+ * ```
453
+ */
454
+ showModal<T = void>(
455
+ render: (close: (result?: T) => void) => ReactNode,
456
+ options?: ModalOptions,
457
+ ): Promise<T | undefined>;
458
+ /**
459
+ * Hand a URL to the operating system — open an `http`/`https` link in the
460
+ * user's default browser, or a `mailto:` link in their mail client. The host
461
+ * owns the privileged platform access; this is an extension's only sanctioned
462
+ * way to send the user out of the app.
463
+ *
464
+ * **Scheme-guarded.** Only `http:`, `https:`, and `mailto:` URLs are opened;
465
+ * any other scheme (notably `file:` and `javascript:`) is rejected — the
466
+ * returned promise rejects, nothing is opened. This makes it safe to pass
467
+ * untrusted URLs (e.g. links inside a rendered Markdown document) straight
468
+ * through without first vetting the scheme yourself.
469
+ *
470
+ * @param url - The URL to open. Must be `http:`, `https:`, or `mailto:`.
471
+ * @throws If `url` has any other scheme (or is unparseable).
472
+ *
473
+ * @example
474
+ * ```ts
475
+ * // open a docs link in the browser
476
+ * await ctx.ui.openExternal("https://silo.dev/docs");
477
+ *
478
+ * // route a clicked Markdown link safely — bad schemes just reject
479
+ * try {
480
+ * await ctx.ui.openExternal(href);
481
+ * } catch {
482
+ * ctx.ui.notify("warn", "That link can't be opened.");
483
+ * }
484
+ * ```
485
+ */
486
+ openExternal(url: string): Promise<void>;
487
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { focusGroupNextIndex, isContextMenuKey } from "./use-focus-group";
3
+
4
+ // The hook itself is React-stateful; per the repo testing guide we test its pure
5
+ // core — the index math (`focusGroupNextIndex`) and the context-menu-key
6
+ // predicate — which is where the `workspace-list-nav` / `menu-nav` logic folded
7
+ // in. Rendered behavior is covered end-to-end by keyboard-nav.it.test.ts.
8
+
9
+ const all = () => true;
10
+
11
+ describe("focusGroupNextIndex", () => {
12
+ describe("vertical orientation (the default list case)", () => {
13
+ const nav = (current: number, count: number, key: string, wrap = true) =>
14
+ focusGroupNextIndex({
15
+ current,
16
+ count,
17
+ key,
18
+ orientation: "vertical",
19
+ wrap,
20
+ isNavigable: all,
21
+ });
22
+
23
+ it("moves down and up between items", () => {
24
+ expect(nav(0, 3, "ArrowDown")).toBe(1);
25
+ expect(nav(2, 3, "ArrowUp")).toBe(1);
26
+ });
27
+
28
+ it("wraps at both ends when wrap is on", () => {
29
+ expect(nav(2, 3, "ArrowDown")).toBe(0); // last → first
30
+ expect(nav(0, 3, "ArrowUp")).toBe(2); // first → last
31
+ });
32
+
33
+ it("stops at the ends when wrap is off (returns null)", () => {
34
+ expect(nav(2, 3, "ArrowDown", false)).toBeNull();
35
+ expect(nav(0, 3, "ArrowUp", false)).toBeNull();
36
+ });
37
+
38
+ it("jumps to the ends with Home/End", () => {
39
+ expect(nav(2, 4, "Home")).toBe(0);
40
+ expect(nav(1, 4, "End")).toBe(3);
41
+ });
42
+
43
+ it("ignores the cross-axis arrows", () => {
44
+ expect(nav(0, 3, "ArrowRight")).toBeNull();
45
+ expect(nav(1, 3, "ArrowLeft")).toBeNull();
46
+ });
47
+
48
+ it("returns null for keys it doesn't handle", () => {
49
+ expect(nav(0, 3, "Enter")).toBeNull();
50
+ expect(nav(0, 3, "a")).toBeNull();
51
+ });
52
+
53
+ it("returns null for an empty list", () => {
54
+ expect(nav(0, 0, "ArrowDown")).toBeNull();
55
+ expect(nav(0, 0, "Home")).toBeNull();
56
+ });
57
+
58
+ it("returns null on a single-item list (nowhere else to go)", () => {
59
+ expect(nav(0, 1, "ArrowDown")).toBeNull();
60
+ expect(nav(0, 1, "ArrowUp")).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("horizontal orientation", () => {
65
+ const nav = (current: number, count: number, key: string) =>
66
+ focusGroupNextIndex({
67
+ current,
68
+ count,
69
+ key,
70
+ orientation: "horizontal",
71
+ wrap: true,
72
+ isNavigable: all,
73
+ });
74
+
75
+ it("navigates with ←/→ and ignores ↑/↓", () => {
76
+ expect(nav(0, 3, "ArrowRight")).toBe(1);
77
+ expect(nav(0, 3, "ArrowLeft")).toBe(2); // wrap
78
+ expect(nav(0, 3, "ArrowDown")).toBeNull();
79
+ expect(nav(0, 3, "ArrowUp")).toBeNull();
80
+ });
81
+
82
+ it("still honors Home/End", () => {
83
+ expect(nav(2, 4, "Home")).toBe(0);
84
+ expect(nav(0, 4, "End")).toBe(3);
85
+ });
86
+ });
87
+
88
+ describe("grid orientation steps linearly on all four arrows", () => {
89
+ const nav = (current: number, count: number, key: string) =>
90
+ focusGroupNextIndex({
91
+ current,
92
+ count,
93
+ key,
94
+ orientation: "grid",
95
+ wrap: true,
96
+ isNavigable: all,
97
+ });
98
+
99
+ it("treats Down/Right as +1 and Up/Left as -1", () => {
100
+ expect(nav(1, 4, "ArrowDown")).toBe(2);
101
+ expect(nav(1, 4, "ArrowRight")).toBe(2);
102
+ expect(nav(1, 4, "ArrowUp")).toBe(0);
103
+ expect(nav(1, 4, "ArrowLeft")).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe("isNavigable skips non-navigable items (separators/headers/disabled)", () => {
108
+ // indices 1 and 3 are non-navigable (e.g. a separator + a disabled row).
109
+ const isNavigable = (i: number) => i !== 1 && i !== 3;
110
+ const nav = (current: number, key: string, wrap = true) =>
111
+ focusGroupNextIndex({
112
+ current,
113
+ count: 5,
114
+ key,
115
+ orientation: "vertical",
116
+ wrap,
117
+ isNavigable,
118
+ });
119
+
120
+ it("hops over skipped indices going down", () => {
121
+ expect(nav(0, "ArrowDown")).toBe(2); // skip 1
122
+ expect(nav(2, "ArrowDown")).toBe(4); // skip 3
123
+ });
124
+
125
+ it("hops over skipped indices going up", () => {
126
+ expect(nav(4, "ArrowUp")).toBe(2); // skip 3
127
+ expect(nav(2, "ArrowUp")).toBe(0); // skip 1
128
+ });
129
+
130
+ it("wraps over a trailing skipped index", () => {
131
+ expect(nav(4, "ArrowDown")).toBe(0); // 5(out)→wrap→0 navigable
132
+ });
133
+
134
+ it("lands Home/End on the first/last NAVIGABLE index", () => {
135
+ expect(nav(2, "Home")).toBe(0);
136
+ expect(nav(0, "End")).toBe(4); // 4 is navigable, 3 isn't
137
+ });
138
+
139
+ it("returns null when no other navigable item exists", () => {
140
+ const onlyOne = (i: number) => i === 2;
141
+ expect(
142
+ focusGroupNextIndex({
143
+ current: 2,
144
+ count: 5,
145
+ key: "ArrowDown",
146
+ orientation: "vertical",
147
+ wrap: true,
148
+ isNavigable: onlyOne,
149
+ }),
150
+ ).toBeNull();
151
+ });
152
+ });
153
+ });
154
+
155
+ describe("isContextMenuKey", () => {
156
+ it("matches the ContextMenu key and Shift+F10", () => {
157
+ expect(isContextMenuKey({ key: "ContextMenu", shiftKey: false })).toBe(
158
+ true,
159
+ );
160
+ expect(isContextMenuKey({ key: "F10", shiftKey: true })).toBe(true);
161
+ expect(isContextMenuKey({ key: "ContextMenu", shiftKey: true })).toBe(true);
162
+ });
163
+
164
+ it("ignores plain F10 and other keys", () => {
165
+ expect(isContextMenuKey({ key: "F10", shiftKey: false })).toBe(false);
166
+ expect(isContextMenuKey({ key: "Enter", shiftKey: false })).toBe(false);
167
+ });
168
+ });