@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.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/context-keys.d.ts +19 -0
- package/dist/context-keys.d.ts.map +1 -0
- package/dist/context-keys.js +2 -0
- package/dist/context-keys.js.map +1 -0
- package/dist/dnd-service.d.ts +140 -0
- package/dist/dnd-service.d.ts.map +1 -0
- package/dist/dnd-service.js +17 -0
- package/dist/dnd-service.js.map +1 -0
- package/dist/domain-types.d.ts +237 -0
- package/dist/domain-types.d.ts.map +1 -0
- package/dist/domain-types.js +11 -0
- package/dist/domain-types.js.map +1 -0
- package/dist/editor-service.d.ts +175 -0
- package/dist/editor-service.d.ts.map +1 -0
- package/dist/editor-service.js +2 -0
- package/dist/editor-service.js.map +1 -0
- package/dist/extension-storage.d.ts +26 -0
- package/dist/extension-storage.d.ts.map +1 -0
- package/dist/extension-storage.js +2 -0
- package/dist/extension-storage.js.map +1 -0
- package/dist/file-service.d.ts +84 -0
- package/dist/file-service.d.ts.map +1 -0
- package/dist/file-service.js +2 -0
- package/dist/file-service.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/layout-service.d.ts +46 -0
- package/dist/layout-service.d.ts.map +1 -0
- package/dist/layout-service.js +2 -0
- package/dist/layout-service.js.map +1 -0
- package/dist/permissions.d.ts +41 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +40 -0
- package/dist/permissions.js.map +1 -0
- package/dist/process-service.d.ts +132 -0
- package/dist/process-service.d.ts.map +1 -0
- package/dist/process-service.js +2 -0
- package/dist/process-service.js.map +1 -0
- package/dist/terminal-service.d.ts +38 -0
- package/dist/terminal-service.d.ts.map +1 -0
- package/dist/terminal-service.js +2 -0
- package/dist/terminal-service.js.map +1 -0
- package/dist/theme-service.d.ts +87 -0
- package/dist/theme-service.d.ts.map +1 -0
- package/dist/theme-service.js +2 -0
- package/dist/theme-service.js.map +1 -0
- package/dist/types.d.ts +495 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui-service.d.ts +469 -0
- package/dist/ui-service.d.ts.map +1 -0
- package/dist/ui-service.js +2 -0
- package/dist/ui-service.js.map +1 -0
- package/dist/use-focus-group.d.ts +202 -0
- package/dist/use-focus-group.d.ts.map +1 -0
- package/dist/use-focus-group.js +236 -0
- package/dist/use-focus-group.js.map +1 -0
- package/dist/use-service-state.d.ts +36 -0
- package/dist/use-service-state.d.ts.map +1 -0
- package/dist/use-service-state.js +25 -0
- package/dist/use-service-state.js.map +1 -0
- package/dist/workspace-service.d.ts +72 -0
- package/dist/workspace-service.d.ts.map +1 -0
- package/dist/workspace-service.js +2 -0
- package/dist/workspace-service.js.map +1 -0
- package/package.json +54 -0
- package/src/context-keys.ts +18 -0
- package/src/dnd-service.ts +151 -0
- package/src/domain-types.ts +252 -0
- package/src/editor-service.ts +196 -0
- package/src/extension-storage.ts +25 -0
- package/src/file-service.ts +90 -0
- package/src/index.ts +151 -0
- package/src/layout-service.ts +49 -0
- package/src/permissions.ts +55 -0
- package/src/process-service.ts +143 -0
- package/src/terminal-service.ts +41 -0
- package/src/theme-service.ts +102 -0
- package/src/types.ts +513 -0
- package/src/ui-service.ts +487 -0
- package/src/use-focus-group.test.ts +168 -0
- package/src/use-focus-group.ts +382 -0
- package/src/use-service-state.ts +43 -0
- 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
|
+
});
|