@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,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type FocusEvent,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type PointerEvent,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The arrow-key axis a {@link useFocusGroup} navigates.
|
|
13
|
+
*
|
|
14
|
+
* - `"vertical"` — ↑/↓ move between items (lists, menus, listboxes).
|
|
15
|
+
* - `"horizontal"` — ←/→ move between items (toolbars, tablists, button groups).
|
|
16
|
+
* - `"grid"` — all four arrows step linearly through the items (a flat picker);
|
|
17
|
+
* `Home`/`End` still jump to the ends regardless of orientation.
|
|
18
|
+
*
|
|
19
|
+
* @category Core Types
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export type FocusGroupOrientation = "vertical" | "horizontal" | "grid";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Options for {@link useFocusGroup}. Describe the set of peer items and what
|
|
26
|
+
* `Enter`/the context-menu key should do; the hook owns the rest (single tab
|
|
27
|
+
* stop, arrow/Home/End movement, the keyboard-only ring).
|
|
28
|
+
*
|
|
29
|
+
* @category Core Types
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export interface FocusGroupOptions {
|
|
33
|
+
/** Number of items in the group. */
|
|
34
|
+
count: number;
|
|
35
|
+
/**
|
|
36
|
+
* The index focus enters on (the host's "first tabbable" lands here, e.g. the
|
|
37
|
+
* selected row). Re-parked here whenever the group doesn't hold focus, so a
|
|
38
|
+
* fresh entry always starts on it. Default `0`. Skipped to the nearest
|
|
39
|
+
* navigable index if {@link FocusGroupOptions.isNavigable | isNavigable} rejects it.
|
|
40
|
+
*/
|
|
41
|
+
start?: number;
|
|
42
|
+
/** Arrow-key axis. Default `"vertical"`. */
|
|
43
|
+
orientation?: FocusGroupOrientation;
|
|
44
|
+
/** Wrap past the ends (default `true`) vs. stop at the first/last item. */
|
|
45
|
+
wrap?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Which indices accept focus; others (separators, headers, disabled rows) are
|
|
48
|
+
* skipped by the arrows, Home/End, and the entry point. Default: all navigable.
|
|
49
|
+
*/
|
|
50
|
+
isNavigable?: (index: number) => boolean;
|
|
51
|
+
/** `Enter`/`Space` on an item. */
|
|
52
|
+
onActivate?: (index: number) => void;
|
|
53
|
+
/**
|
|
54
|
+
* The context-menu key / `Shift`+`F10` on an item; `anchor` is the item's DOM
|
|
55
|
+
* element, so a menu can be positioned against the row.
|
|
56
|
+
*/
|
|
57
|
+
onMenu?: (index: number, anchor: HTMLElement) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Props {@link useFocusGroup} returns for the group container — spread onto the
|
|
62
|
+
* element wrapping the items.
|
|
63
|
+
*
|
|
64
|
+
* @category Core Types
|
|
65
|
+
* @public
|
|
66
|
+
*/
|
|
67
|
+
export interface FocusGroupContainerProps {
|
|
68
|
+
onBlur: (e: FocusEvent) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Props {@link useFocusGroup} returns per item — spread onto item `index`. They
|
|
73
|
+
* carry the single-tab-stop `tabIndex`, the key/focus handlers, and the
|
|
74
|
+
* `data-focus-*` markers the host's CSS styles into the keyboard ring. Your own
|
|
75
|
+
* `role`/`aria-*`/`onClick`/`className` sit alongside them.
|
|
76
|
+
*
|
|
77
|
+
* @category Core Types
|
|
78
|
+
* @public
|
|
79
|
+
*/
|
|
80
|
+
export interface FocusGroupItemProps {
|
|
81
|
+
tabIndex: number;
|
|
82
|
+
ref: (el: HTMLElement | null) => void;
|
|
83
|
+
onKeyDown: (e: KeyboardEvent) => void;
|
|
84
|
+
onFocus: () => void;
|
|
85
|
+
onPointerDown: (e: PointerEvent) => void;
|
|
86
|
+
/** Marks every group item, so the host can reset the native focus outline. */
|
|
87
|
+
"data-focus-item": "";
|
|
88
|
+
/**
|
|
89
|
+
* Present on the active item only while focus is keyboard-driven — the host's
|
|
90
|
+
* CSS keys the ring on it. Absent for pointer focus, matching `:focus-visible`.
|
|
91
|
+
*/
|
|
92
|
+
"data-focus-visible"?: "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The headless focus-group controller {@link useFocusGroup} returns.
|
|
97
|
+
*
|
|
98
|
+
* @category Core Types
|
|
99
|
+
* @public
|
|
100
|
+
*/
|
|
101
|
+
export interface FocusGroup {
|
|
102
|
+
/** Spread on the group container. */
|
|
103
|
+
containerProps: FocusGroupContainerProps;
|
|
104
|
+
/** Spread on item `index`. */
|
|
105
|
+
getItemProps: (index: number) => FocusGroupItemProps;
|
|
106
|
+
/** The item that currently holds (or, when unfocused, would receive) focus. */
|
|
107
|
+
activeIndex: number;
|
|
108
|
+
/** Imperatively move focus to an item (e.g. `↓` from a search box into a list). */
|
|
109
|
+
focusItem: (index: number) => void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const clamp = (n: number, max: number): number => Math.max(0, Math.min(n, max));
|
|
113
|
+
|
|
114
|
+
/** First navigable index at or after scanning the whole list, or `null`. */
|
|
115
|
+
function firstNavigable(
|
|
116
|
+
count: number,
|
|
117
|
+
isNavigable: (i: number) => boolean,
|
|
118
|
+
): number | null {
|
|
119
|
+
for (let i = 0; i < count; i++) if (isNavigable(i)) return i;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Last navigable index, or `null`. */
|
|
124
|
+
function lastNavigable(
|
|
125
|
+
count: number,
|
|
126
|
+
isNavigable: (i: number) => boolean,
|
|
127
|
+
): number | null {
|
|
128
|
+
for (let i = count - 1; i >= 0; i--) if (isNavigable(i)) return i;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A roving-navigation query for {@link focusGroupNextIndex}.
|
|
134
|
+
*
|
|
135
|
+
* @category Core Types
|
|
136
|
+
* @public
|
|
137
|
+
*/
|
|
138
|
+
export interface FocusGroupNavQuery {
|
|
139
|
+
/** The index focus is on now. */
|
|
140
|
+
current: number;
|
|
141
|
+
/** Number of items. */
|
|
142
|
+
count: number;
|
|
143
|
+
/** The pressed key (`ArrowDown`/`ArrowUp`/`ArrowLeft`/`ArrowRight`/`Home`/`End`). */
|
|
144
|
+
key: string;
|
|
145
|
+
/** Which arrows navigate. */
|
|
146
|
+
orientation: FocusGroupOrientation;
|
|
147
|
+
/** Wrap past the ends vs. stop. */
|
|
148
|
+
wrap: boolean;
|
|
149
|
+
/** Which indices accept focus; others are skipped. */
|
|
150
|
+
isNavigable: (index: number) => boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The index a navigation key moves focus to within a focus group, or `null` when
|
|
155
|
+
* the key isn't a navigation key for this orientation, the list is empty, or no
|
|
156
|
+
* other navigable item exists. Steps over non-navigable items; wraps at the ends
|
|
157
|
+
* when `wrap`, otherwise stops (returns `null`). `Home`/`End` jump to the
|
|
158
|
+
* first/last navigable index regardless of orientation.
|
|
159
|
+
*
|
|
160
|
+
* This is the pure roving-index core that {@link useFocusGroup} runs internally.
|
|
161
|
+
* Reach for it directly only when you **can't** use the hook — e.g. a widget that
|
|
162
|
+
* drives keys from a document-level listener and a state-driven highlight rather
|
|
163
|
+
* than DOM focus (Silo's menus work this way). For an ordinary list/toolbar,
|
|
164
|
+
* prefer {@link useFocusGroup}, which calls this for you.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* const next = focusGroupNextIndex({
|
|
169
|
+
* current: activeIndex, count: items.length, key: e.key,
|
|
170
|
+
* orientation: "vertical", wrap: true,
|
|
171
|
+
* isNavigable: (i) => !items[i].disabled,
|
|
172
|
+
* });
|
|
173
|
+
* if (next !== null) setActiveIndex(next);
|
|
174
|
+
* ```
|
|
175
|
+
*
|
|
176
|
+
* @category Consumer Services
|
|
177
|
+
* @public
|
|
178
|
+
*/
|
|
179
|
+
export function focusGroupNextIndex(params: FocusGroupNavQuery): number | null {
|
|
180
|
+
const { current, count, key, orientation, wrap, isNavigable } = params;
|
|
181
|
+
if (count <= 0) return null;
|
|
182
|
+
if (key === "Home") return firstNavigable(count, isNavigable);
|
|
183
|
+
if (key === "End") return lastNavigable(count, isNavigable);
|
|
184
|
+
|
|
185
|
+
const vertical = orientation === "vertical" || orientation === "grid";
|
|
186
|
+
const horizontal = orientation === "horizontal" || orientation === "grid";
|
|
187
|
+
let dir: 1 | -1 | 0 = 0;
|
|
188
|
+
if ((vertical && key === "ArrowDown") || (horizontal && key === "ArrowRight"))
|
|
189
|
+
dir = 1;
|
|
190
|
+
else if (
|
|
191
|
+
(vertical && key === "ArrowUp") ||
|
|
192
|
+
(horizontal && key === "ArrowLeft")
|
|
193
|
+
)
|
|
194
|
+
dir = -1;
|
|
195
|
+
if (dir === 0) return null;
|
|
196
|
+
|
|
197
|
+
let i = current;
|
|
198
|
+
for (let n = 0; n < count; n++) {
|
|
199
|
+
i += dir;
|
|
200
|
+
if (i < 0 || i >= count) {
|
|
201
|
+
if (!wrap) return null;
|
|
202
|
+
i = (i + count) % count;
|
|
203
|
+
}
|
|
204
|
+
if (i === current) return null; // came all the way around — nothing else navigable
|
|
205
|
+
if (isNavigable(i)) return i;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether a keydown should open an item's context menu — the dedicated
|
|
212
|
+
* ContextMenu (Menu/Application) key, or `Shift`+`F10` for keyboards without it.
|
|
213
|
+
*
|
|
214
|
+
* @internal
|
|
215
|
+
*/
|
|
216
|
+
export function isContextMenuKey(e: {
|
|
217
|
+
key: string;
|
|
218
|
+
shiftKey: boolean;
|
|
219
|
+
}): boolean {
|
|
220
|
+
return e.key === "ContextMenu" || (e.shiftKey && e.key === "F10");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Headless keyboard navigation for a **focus group** — a set of peer items that
|
|
225
|
+
* share a single tab stop and move with the arrow keys (a list, listbox, menu,
|
|
226
|
+
* toolbar, tablist, radio group, or flat grid). It owns, once and correctly, the
|
|
227
|
+
* mechanics every such widget needs:
|
|
228
|
+
*
|
|
229
|
+
* - a single-tab-stop `tabIndex` (one item tabbable, the rest `-1`), so the group
|
|
230
|
+
* is one Tab stop and the host's "focus the first tabbable" entry lands on
|
|
231
|
+
* {@link FocusGroupOptions.start | start};
|
|
232
|
+
* - Arrow / Home / End movement (per
|
|
233
|
+
* {@link FocusGroupOptions.orientation | orientation}, wrapping or stopping per
|
|
234
|
+
* {@link FocusGroupOptions.wrap | wrap}, skipping non-navigable items);
|
|
235
|
+
* - `Enter`/`Space` → {@link FocusGroupOptions.onActivate | onActivate}, the
|
|
236
|
+
* context-menu key / `Shift`+`F10` → {@link FocusGroupOptions.onMenu | onMenu};
|
|
237
|
+
* - a **WebKit-safe, keyboard-only focus ring**: it flags the active item with a
|
|
238
|
+
* `data-focus-visible` attribute (state-driven, because WebKit won't repaint
|
|
239
|
+
* `:focus` for the programmatic focus the host's region cycle performs), and
|
|
240
|
+
* the host ships the ring CSS keyed on that attribute — so every group's ring
|
|
241
|
+
* is identical and correct without the author touching it.
|
|
242
|
+
*
|
|
243
|
+
* You keep the markup and semantics (`role`, `aria-*`, `onClick`, styling); the
|
|
244
|
+
* hook supplies behavior. Spread {@link FocusGroup.containerProps | containerProps}
|
|
245
|
+
* on the wrapper and {@link FocusGroup.getItemProps | getItemProps(i)} on each
|
|
246
|
+
* item. The index is clamped when {@link FocusGroupOptions.count | count} changes,
|
|
247
|
+
* so live-filtering a list is safe.
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```tsx
|
|
251
|
+
* const group = useFocusGroup({
|
|
252
|
+
* count: items.length,
|
|
253
|
+
* start: activeIndex,
|
|
254
|
+
* onActivate: (i) => select(items[i].id),
|
|
255
|
+
* onMenu: (i, anchor) => showMenu(items[i], anchor),
|
|
256
|
+
* });
|
|
257
|
+
* return (
|
|
258
|
+
* <ul {...group.containerProps}>
|
|
259
|
+
* {items.map((it, i) => (
|
|
260
|
+
* <li key={it.id} {...group.getItemProps(i)}>{it.label}</li>
|
|
261
|
+
* ))}
|
|
262
|
+
* </ul>
|
|
263
|
+
* );
|
|
264
|
+
* ```
|
|
265
|
+
*
|
|
266
|
+
* @category Consumer Services
|
|
267
|
+
* @public
|
|
268
|
+
*/
|
|
269
|
+
export function useFocusGroup(options: FocusGroupOptions): FocusGroup {
|
|
270
|
+
const {
|
|
271
|
+
count,
|
|
272
|
+
start = 0,
|
|
273
|
+
orientation = "vertical",
|
|
274
|
+
wrap = true,
|
|
275
|
+
isNavigable = () => true,
|
|
276
|
+
onActivate,
|
|
277
|
+
onMenu,
|
|
278
|
+
} = options;
|
|
279
|
+
|
|
280
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
281
|
+
// Focus is somewhere inside the group (drives re-parking on the active row).
|
|
282
|
+
const [focused, setFocused] = useState(false);
|
|
283
|
+
// Focus arrived/moved via the keyboard (drives the ring), à la `:focus-visible`.
|
|
284
|
+
const [visible, setVisible] = useState(false);
|
|
285
|
+
|
|
286
|
+
// The most recent focus was preceded by a pointer-down on an item — so it's a
|
|
287
|
+
// mouse focus and must NOT show the ring. Read+reset on the following onFocus.
|
|
288
|
+
const pointerOrigin = useRef(false);
|
|
289
|
+
const itemEls = useRef<Map<number, HTMLElement>>(new Map());
|
|
290
|
+
|
|
291
|
+
// Read latest options in stable handlers without re-creating them each render.
|
|
292
|
+
const opts = useRef({
|
|
293
|
+
count,
|
|
294
|
+
orientation,
|
|
295
|
+
wrap,
|
|
296
|
+
isNavigable,
|
|
297
|
+
onActivate,
|
|
298
|
+
onMenu,
|
|
299
|
+
});
|
|
300
|
+
opts.current = { count, orientation, wrap, isNavigable, onActivate, onMenu };
|
|
301
|
+
|
|
302
|
+
// Park the active index on `start` (clamped, and nudged to a navigable item)
|
|
303
|
+
// whenever the group isn't focused, so the host's entry lands there; while
|
|
304
|
+
// focused, just keep the index in range as items come and go.
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
setActiveIndex((i) => {
|
|
307
|
+
if (count <= 0) return 0;
|
|
308
|
+
if (focused) return Math.min(i, count - 1);
|
|
309
|
+
const at = clamp(start, count - 1);
|
|
310
|
+
return isNavigable(at) ? at : (firstNavigable(count, isNavigable) ?? at);
|
|
311
|
+
// isNavigable identity changes per render; `start`/`count`/`focused` are the
|
|
312
|
+
// real inputs — re-running on isNavigable alone would thrash focus.
|
|
313
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
314
|
+
});
|
|
315
|
+
}, [start, focused, count]);
|
|
316
|
+
|
|
317
|
+
const focusItem = useCallback((index: number) => {
|
|
318
|
+
itemEls.current.get(index)?.focus();
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
const containerProps: FocusGroupContainerProps = {
|
|
322
|
+
onBlur: (e: FocusEvent) => {
|
|
323
|
+
// Drop focus state only when focus leaves the whole group (not when it
|
|
324
|
+
// moves between two items).
|
|
325
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
326
|
+
setFocused(false);
|
|
327
|
+
setVisible(false);
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const getItemProps = (index: number): FocusGroupItemProps => ({
|
|
333
|
+
tabIndex: index === activeIndex ? 0 : -1,
|
|
334
|
+
ref: (el: HTMLElement | null) => {
|
|
335
|
+
if (el) itemEls.current.set(index, el);
|
|
336
|
+
else itemEls.current.delete(index);
|
|
337
|
+
},
|
|
338
|
+
onPointerDown: () => {
|
|
339
|
+
pointerOrigin.current = true;
|
|
340
|
+
},
|
|
341
|
+
onFocus: () => {
|
|
342
|
+
setFocused(true);
|
|
343
|
+
setActiveIndex(index);
|
|
344
|
+
// Keyboard/programmatic focus shows the ring; a pointer-driven focus doesn't.
|
|
345
|
+
setVisible(!pointerOrigin.current);
|
|
346
|
+
pointerOrigin.current = false;
|
|
347
|
+
},
|
|
348
|
+
onKeyDown: (e: KeyboardEvent) => {
|
|
349
|
+
const o = opts.current;
|
|
350
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
351
|
+
if (!o.onActivate) return;
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
setVisible(true);
|
|
354
|
+
o.onActivate(index);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (isContextMenuKey(e)) {
|
|
358
|
+
if (!o.onMenu) return;
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
setVisible(true);
|
|
361
|
+
o.onMenu(index, e.currentTarget as HTMLElement);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const next = focusGroupNextIndex({
|
|
365
|
+
current: index,
|
|
366
|
+
count: o.count,
|
|
367
|
+
key: e.key,
|
|
368
|
+
orientation: o.orientation,
|
|
369
|
+
wrap: o.wrap,
|
|
370
|
+
isNavigable: o.isNavigable,
|
|
371
|
+
});
|
|
372
|
+
if (next === null) return;
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
setVisible(true);
|
|
375
|
+
focusItem(next);
|
|
376
|
+
},
|
|
377
|
+
"data-focus-item": "",
|
|
378
|
+
...(visible && index === activeIndex ? { "data-focus-visible": "" } : {}),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return { containerProps, getItemProps, activeIndex, focusItem };
|
|
382
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import type { Disposable } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The minimal reactive contract every stateful `ctx` service satisfies: a
|
|
6
|
+
* `getState` returning a stable, frozen value and a `subscribe` that fires
|
|
7
|
+
* on change. This is exactly what React's `useSyncExternalStore` needs — and
|
|
8
|
+
* the only shape {@link useServiceState} requires.
|
|
9
|
+
*
|
|
10
|
+
* @category Consumer Services
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export interface ReactiveService<T> {
|
|
14
|
+
getState(): T;
|
|
15
|
+
subscribe(listener: (s: T) => void): Disposable;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Subscribe a React component to a `ctx` service's reactive state. Returns the
|
|
20
|
+
* service's current state and re-renders when it changes — the one blessed
|
|
21
|
+
* way to read service state in an extension. Use it for every domain
|
|
22
|
+
* ({@link ExtensionContext.workspaces | workspaces},
|
|
23
|
+
* {@link ExtensionContext.layout | layout},
|
|
24
|
+
* {@link ExtensionContext.theme | theme}, …) rather than re-implementing the
|
|
25
|
+
* `useSyncExternalStore` boilerplate per call site.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* function Panel({ ctx }: { ctx: ExtensionContext }) {
|
|
30
|
+
* const ws = useServiceState(ctx.workspaces);
|
|
31
|
+
* return <span>{ws.open.length} open workspaces</span>;
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @category Consumer Services
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export function useServiceState<T>(service: ReactiveService<T>): T {
|
|
39
|
+
return useSyncExternalStore(
|
|
40
|
+
useCallback((cb) => service.subscribe(cb).dispose, [service]),
|
|
41
|
+
service.getState,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Disposable } from "./types";
|
|
2
|
+
import type { Workspace } from "./domain-types";
|
|
3
|
+
|
|
4
|
+
// Re-export the workspace domain types so consumers can name them from the SDK.
|
|
5
|
+
export type { Workspace, TerminalRecord } from "./domain-types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An immutable, frozen view of workspace state, returned by
|
|
9
|
+
* {@link WorkspaceService.getState} and delivered to subscribers — read
|
|
10
|
+
* access without a Valtio dependency.
|
|
11
|
+
*
|
|
12
|
+
* @category Consumer Services
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export interface WorkspaceState {
|
|
16
|
+
/** All workspaces, in user-defined order. */
|
|
17
|
+
all: readonly Workspace[];
|
|
18
|
+
/** Workspaces where closedAt is null/undefined, in user-defined order. */
|
|
19
|
+
open: readonly Workspace[];
|
|
20
|
+
/** Workspaces where closedAt is set, sorted by closedAt descending. */
|
|
21
|
+
closed: readonly Workspace[];
|
|
22
|
+
activeId: string | null;
|
|
23
|
+
/** True once the persisted state has been loaded into the store. */
|
|
24
|
+
hydrated: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Input for {@link WorkspaceService.create}.
|
|
29
|
+
*
|
|
30
|
+
* @category Consumer Services
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
export interface CreateWorkspaceInput {
|
|
34
|
+
/** Absolute path of the workspace's primary folder. */
|
|
35
|
+
folder: string;
|
|
36
|
+
/** Display name for the workspace. */
|
|
37
|
+
name: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Consumer API for workspace state, exposed as {@link ExtensionContext.workspaces}.
|
|
42
|
+
* Read via {@link WorkspaceService.getState | getState} /
|
|
43
|
+
* {@link WorkspaceService.subscribe | subscribe}; drive via the create/rename/
|
|
44
|
+
* close methods. Opening editor tabs lives on {@link ExtensionContext.editors},
|
|
45
|
+
* not here.
|
|
46
|
+
*
|
|
47
|
+
* @category Consumer Services
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
export interface WorkspaceService {
|
|
51
|
+
/** Current frozen view of workspace state. */
|
|
52
|
+
getState(): WorkspaceState;
|
|
53
|
+
subscribe(listener: (s: WorkspaceState) => void): Disposable;
|
|
54
|
+
/**
|
|
55
|
+
* The workspace with this id, or `undefined`. A one-shot lookup for event
|
|
56
|
+
* handlers; for reactive reads use {@link useServiceState} over the state.
|
|
57
|
+
*/
|
|
58
|
+
get(id: string): Workspace | undefined;
|
|
59
|
+
/** Show a folder picker and create a workspace from the chosen folder. */
|
|
60
|
+
createFromFolderPicker(): Promise<Workspace | null>;
|
|
61
|
+
create(input: CreateWorkspaceInput): Workspace;
|
|
62
|
+
rename(id: string, name: string): void;
|
|
63
|
+
reorder(from: string, to: string, position: "before" | "after"): void;
|
|
64
|
+
/** Activate (and reopen if closed). */
|
|
65
|
+
activate(id: string): void;
|
|
66
|
+
/** Soft close — workspace stays saved but is hidden from the active list. */
|
|
67
|
+
close(id: string): void;
|
|
68
|
+
/** Reverse of close. */
|
|
69
|
+
reopen(id: string): void;
|
|
70
|
+
/** Add an extra folder to a workspace (no-op if already present or is the primary). */
|
|
71
|
+
addFolder(id: string, folder: string): void;
|
|
72
|
+
/** Remove an extra folder from a workspace. */
|
|
73
|
+
removeFolder(id: string, folder: string): void;
|
|
74
|
+
/** Hard delete — permanent removal. */
|
|
75
|
+
delete(id: string): void;
|
|
76
|
+
}
|