@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,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
+ }