@kabmo-labs/kabmo-ui 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The plugin-identity context: which plugin's UI a subtree belongs to, AND the
8
+ * DOM surface that plugin's stylesheet is confined to.
9
+ *
10
+ * It lives in the KIT, not in the host, because both ends must share one
11
+ * instance: the host imports `@kabmo-labs/kabmo-ui` directly while a plugin bundle
12
+ * gets the same module through `globalThis.__kabmoPluginRuntime.ui`, so a
13
+ * `Provider` the host renders is the `Provider` a plugin's hook reads.
14
+ *
15
+ * A Preact context rather than a module-level value, and that is a correctness
16
+ * point rather than a style choice: a component's children render AFTER its own
17
+ * function returns, so a value set and restored around `return <>{children}</>`
18
+ * is already gone by the time the children run. Measured — `setId('outer')` in
19
+ * a `try/finally` left the children reading `null`.
20
+ *
21
+ * The id is what makes `useRpc('save')` safe: the hook takes the plugin from
22
+ * here, so a component rendered for plugin A can never address plugin B's
23
+ * namespace, however the caller names its method.
24
+ *
25
+ * The ELEMENT is the other half, and it is why this component renders a node
26
+ * rather than only a context. A plugin's stylesheet is served wrapped in
27
+ * `@scope ([data-plugin-surface="<id>"])` (see the server's css route), so a
28
+ * rule the plugin writes reaches only elements inside a surface carrying its
29
+ * own id. Without this element a plugin's own CSS would match nothing at all,
30
+ * and the pattern it replaced — marking `<html>` — matched the HOST's chrome
31
+ * too, which is the leak this closes.
32
+ */
33
+
34
+ import { createContext } from 'preact';
35
+ import { useContext } from 'preact/hooks';
36
+ import type { ComponentChildren } from 'preact';
37
+ import { PLUGIN_SURFACE_ATTR } from '@kabmo-labs/kabmo-sdk';
38
+
39
+ const PluginIdContext = createContext<string | null>(null);
40
+
41
+ /**
42
+ * Wrap a plugin's own UI so its hooks resolve the right plugin and its CSS has
43
+ * a surface to land on.
44
+ *
45
+ * Every host surface that draws a plugin row wraps it — the right panel, the
46
+ * editor column, a chat row's actions, the composer's slots, a timeline
47
+ * renderer, a file view, the navbar. A surface that forgot would leave its
48
+ * plugin's `useRpc` answering `no_plugin` (the loud failure) AND its stylesheet
49
+ * inert (the silent one).
50
+ *
51
+ * `display: contents` is load-bearing: the wrapper must not become a flex item,
52
+ * a grid item or a block that changes the host's layout. It generates no box,
53
+ * so the plugin's own root behaves exactly as it did before this element
54
+ * existed, while the element itself is still a real DOM node for the CSS scope
55
+ * to root at.
56
+ *
57
+ * It is a `<span>` rather than a `<div>` for the same reason it carries
58
+ * `display: contents`: a host surface may render a plugin row inside a
59
+ * `<button>` (the navbar trigger), a `<label>` or a `<p>`, where only phrasing
60
+ * content is valid. Since the element generates no box, the tag name costs
61
+ * nothing.
62
+ */
63
+ export function PluginIdProvider({ pluginId, children }: { pluginId: string; children?: ComponentChildren }) {
64
+ return (
65
+ <PluginIdContext.Provider value={pluginId}>
66
+ <span data-plugin-surface={pluginId} style="display: contents">
67
+ {children}
68
+ </span>
69
+ </PluginIdContext.Provider>
70
+ );
71
+ }
72
+
73
+ /** The plugin whose subtree this is, or null outside any plugin's UI. */
74
+ export function usePluginId(): string | null {
75
+ return useContext(PluginIdContext);
76
+ }
77
+
78
+ /** The attribute a plugin's surface element carries — re-exported for the host. */
79
+ export { PLUGIN_SURFACE_ATTR };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The nested-repo picker, shared by the host's own panels and by plugins.
8
+ *
9
+ * It is in the KIT rather than in the host because both ends must draw the same
10
+ * control: a plugin panel's header and the Files panel's toolbar describe the
11
+ * same working tree, and a second implementation would drift on the things that
12
+ * matter — the label for the root itself, the checkmark, what refresh does.
13
+ *
14
+ * The kit has no icon dependency (its only dependencies are `preact` and the
15
+ * SDK), so every glyph here is an inline `<svg>` with `currentColor` paint. That
16
+ * is also why it carries its own outside-click handling: the host's
17
+ * `useOnClickOutside` is not reachable from a published package.
18
+ *
19
+ * The rows are the discovered repos of the CURRENT root, and `'.'` is always
20
+ * among them — the root is a legitimate choice even when it is not itself a
21
+ * repository, because a folder of nested repos is a normal workspace.
22
+ */
23
+
24
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
25
+ import type { RepoScopeHandle } from './repo';
26
+
27
+ export interface RepoPickerProps {
28
+ /** The scope to draw and write. Pass `useRepoScope()`'s handle. */
29
+ scope: RepoScopeHandle;
30
+ /** Extra classes for the trigger, for a host that sizes its own toolbar. */
31
+ className?: string;
32
+ /** Hide the picker entirely (no workspace root to describe). */
33
+ disabled?: boolean;
34
+ /**
35
+ * A row under the list that opens a larger surface (the host's repo manager).
36
+ *
37
+ * Optional because it is HOST chrome: Kabmo opens a modal with grouping
38
+ * and per-repo state, which a plugin has no equivalent of. Absent, no row is
39
+ * drawn — the picker never promises an interaction it does not have, the same
40
+ * rule the navbar's header slots follow.
41
+ */
42
+ onManage?: () => void;
43
+ /** The manage row's label. Only read when `onManage` is given. */
44
+ manageLabel?: string;
45
+ }
46
+
47
+ /** One inline glyph. `currentColor` so it follows the theme with no CSS. */
48
+ function Glyph({ path, size = 12, spin = false }: { path: string; size?: number; spin?: boolean }) {
49
+ return (
50
+ <svg
51
+ width={size}
52
+ height={size}
53
+ viewBox="0 0 24 24"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ stroke-width="2"
57
+ stroke-linecap="round"
58
+ stroke-linejoin="round"
59
+ class={spin ? 'animate-spin' : undefined}
60
+ aria-hidden="true"
61
+ >
62
+ <path d={path} />
63
+ </svg>
64
+ );
65
+ }
66
+
67
+ const ICON = {
68
+ folder: 'M4 20h16a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-7l-2-2H4a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1z',
69
+ chevron: 'M6 9l6 6 6-6',
70
+ search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35',
71
+ refresh: 'M3 12a9 9 0 0 1 15.5-6.2M21 12a9 9 0 0 1-15.5 6.2M3 4v5h5M21 20v-5h-5',
72
+ check: 'M20 6L9 17l-5-5',
73
+ manage: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
74
+ } as const;
75
+
76
+ export function RepoPicker({ scope, className, disabled = false, onManage, manageLabel = 'Manage repositories…' }: RepoPickerProps) {
77
+ const [open, setOpen] = useState(false);
78
+ const [query, setQuery] = useState('');
79
+ const ref = useRef<HTMLDivElement>(null);
80
+
81
+ // Outside click closes. A published package cannot import the host's hook, and
82
+ // a pointerdown listener is what a browser actually delivers for a click
83
+ // outside a menu (a click listener misses a drag that ends elsewhere).
84
+ useEffect(() => {
85
+ if (!open) return;
86
+ const onDown = (event: globalThis.MouseEvent) => {
87
+ if (!ref.current?.contains(event.target as Node)) setOpen(false);
88
+ };
89
+ document.addEventListener('pointerdown', onDown);
90
+ return () => document.removeEventListener('pointerdown', onDown);
91
+ }, [open]);
92
+
93
+ // `'.'` always leads, so the root is the first row whatever order discovery
94
+ // returned. The rest keep discovery's order.
95
+ const options = useMemo(() => {
96
+ const rest = scope.rows.filter((row) => row.repo !== '.');
97
+ const root = scope.rows.find((row) => row.repo === '.');
98
+ return root ? [root, ...rest] : rest;
99
+ }, [scope.rows]);
100
+
101
+ const selected = options.find((row) => row.repo === scope.repo) ?? options[0];
102
+
103
+ const filtered = useMemo(() => {
104
+ const needle = query.trim().toLowerCase();
105
+ if (!needle) return options;
106
+ // The LABEL is what is searched, not the path: a user types what they can
107
+ // read, and for the root row the two differ ("Workspace" vs ".").
108
+ return options.filter((row) => row.label.toLowerCase().includes(needle));
109
+ }, [options, query]);
110
+
111
+ // No root: there is no tree to describe, so the control says so rather than
112
+ // offering a list that cannot be applied.
113
+ if (disabled || !scope.root) {
114
+ return (
115
+ <span class={`inline-flex items-center gap-1.5 px-2 py-1 text-xs text-ink/40 ${className ?? ''}`} title="No workspace folder">
116
+ <Glyph path={ICON.folder} />
117
+ no workspace
118
+ </span>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <div class={`relative ${className ?? ''}`} ref={ref}>
124
+ <button
125
+ type="button"
126
+ onClick={() => setOpen((was) => !was)}
127
+ title="Switch git project"
128
+ aria-haspopup="listbox"
129
+ aria-expanded={open}
130
+ class="flex items-center gap-1.5 px-2 py-1 rounded transition-colors text-xs text-ink/80 font-medium hover:bg-ink/5"
131
+ >
132
+ <Glyph path={ICON.folder} />
133
+ <span class="truncate max-w-[130px]">{selected?.short ?? 'workspace root'}</span>
134
+ <Glyph path={ICON.chevron} />
135
+ </button>
136
+
137
+ {open && (
138
+ <div
139
+ role="listbox"
140
+ class="absolute top-full left-0 mt-1 w-56 bg-paper border border-ink/20 rounded-md shadow-lg z-50 flex flex-col overflow-hidden text-xs"
141
+ >
142
+ <div class="px-2 py-1.5 border-b border-ink/10 flex items-center gap-1.5">
143
+ <Glyph path={ICON.search} size={11} />
144
+ <input
145
+ type="text"
146
+ value={query}
147
+ onInput={(event) => setQuery(event.currentTarget.value)}
148
+ placeholder="Search repos..."
149
+ title="Search repositories"
150
+ class="w-full bg-transparent outline-none text-xs text-ink placeholder-ink/40"
151
+ autoFocus
152
+ />
153
+ {query && (
154
+ <button type="button" onClick={() => setQuery('')} title="Clear search" class="text-ink/40 hover:text-ink">
155
+ ×
156
+ </button>
157
+ )}
158
+ <button
159
+ type="button"
160
+ onClick={() => scope.refresh()}
161
+ title="Refresh nested repos"
162
+ disabled={scope.scanning}
163
+ class="text-ink/40 hover:text-ink transition-colors disabled:opacity-40"
164
+ >
165
+ <Glyph path={ICON.refresh} size={11} spin={scope.scanning} />
166
+ </button>
167
+ </div>
168
+ <div class="max-h-48 overflow-y-auto scrollbar-overlay-container py-1">
169
+ {filtered.length === 0 ? (
170
+ <div class="px-3 py-2 text-ink/40 italic">No repos found</div>
171
+ ) : (
172
+ filtered.map((row) => (
173
+ <button
174
+ key={row.repo}
175
+ type="button"
176
+ role="option"
177
+ aria-selected={scope.repo === row.repo}
178
+ onClick={() => {
179
+ scope.setRepo(row.repo);
180
+ setOpen(false);
181
+ setQuery('');
182
+ }}
183
+ title={row.label}
184
+ class="w-full text-left px-3 py-2 hover:bg-ink/5 flex items-center justify-between transition-colors"
185
+ >
186
+ <span class="truncate">{row.label}</span>
187
+ {scope.repo === row.repo && <Glyph path={ICON.check} />}
188
+ </button>
189
+ ))
190
+ )}
191
+ </div>
192
+ {onManage && (
193
+ <div class="border-t border-ink/10">
194
+ <button
195
+ type="button"
196
+ onClick={() => {
197
+ onManage();
198
+ setOpen(false);
199
+ setQuery('');
200
+ }}
201
+ class="w-full text-left px-3 py-2 hover:bg-ink/5 flex items-center gap-2 transition-colors text-ink/80"
202
+ >
203
+ <Glyph path={ICON.manage} size={12} />
204
+ <span>{manageLabel}</span>
205
+ </button>
206
+ </div>
207
+ )}
208
+ </div>
209
+ )}
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useRepoScope` — the kit's view of the nested-repo scope the host's panels
8
+ * share.
9
+ *
10
+ * The behaviours pinned here are the ones a plugin gets wrong when it reads the
11
+ * session slot itself:
12
+ *
13
+ * - **The scope arrives late and changes from elsewhere.** The stored pick loads
14
+ * with the session and the four host panels write the same slot, so a
15
+ * component that captured the value once would show the previous repo forever.
16
+ * The hook must re-read on a service push.
17
+ * - **A host that cannot answer is not an error.** An older host provides no
18
+ * `repoScope`, and the hook reports an empty scope so a panel draws "no
19
+ * workspace" instead of throwing.
20
+ * - **A write goes through the SERVICE**, never through a private copy — that is
21
+ * the whole point of the shared slot, and a plugin that kept its own state
22
+ * would leave the host's picker behind.
23
+ *
24
+ * Rendered with `h()` (no JSX) against happy-dom.
25
+ */
26
+
27
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
28
+ import { Window } from 'happy-dom';
29
+ import { h, render } from 'preact';
30
+ import { act } from 'preact/test-utils';
31
+ import { configureUiKit, useRepoScope, type RepoScope, type UiKitServices } from '@kabmo-labs/kabmo-ui';
32
+
33
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const;
34
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
35
+
36
+ let container: HTMLElement;
37
+ let scope: RepoScope;
38
+ /** Every repo a test's component wrote, in order. */
39
+ let writes: string[];
40
+ /** Every rescan requested. */
41
+ let rescans: number;
42
+ /** The listeners the hook registered, so a test can push a change. */
43
+ let listeners: Array<() => void>;
44
+
45
+ const EMPTY: RepoScope = { root: null, repo: '.', rows: [], scanning: false, ready: false };
46
+
47
+ /** The services a host would inject, with only the repo seam under test. */
48
+ function services(): UiKitServices {
49
+ return {
50
+ context: () => ({ sessionId: 's1', workspacePath: '/ws', theme: 'paper' }),
51
+ subscribe: () => () => {},
52
+ getSessionValue: () => null,
53
+ setSessionValue: () => {},
54
+ readWorkspaceFile: async () => '',
55
+ repoScope: () => scope,
56
+ setRepoScope: (repo: string) => {
57
+ writes.push(repo);
58
+ },
59
+ subscribeRepoScope: (listener: () => void) => {
60
+ listeners.push(listener);
61
+ return () => {
62
+ listeners = listeners.filter((entry) => entry !== listener);
63
+ };
64
+ },
65
+ rescanRepoScope: () => {
66
+ rescans += 1;
67
+ },
68
+ };
69
+ }
70
+
71
+ beforeEach(() => {
72
+ const win = new Window({ url: 'http://localhost' });
73
+ const target = globalThis as unknown as Record<string, unknown>;
74
+ for (const key of DOM_GLOBALS) {
75
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
76
+ target[key] = (win as unknown as Record<string, unknown>)[key];
77
+ }
78
+ container = document.createElement('div') as unknown as HTMLElement;
79
+ document.body.appendChild(container as never);
80
+ scope = EMPTY;
81
+ writes = [];
82
+ rescans = 0;
83
+ listeners = [];
84
+ configureUiKit(services());
85
+ });
86
+
87
+ afterEach(() => {
88
+ render(null, container);
89
+ container.remove();
90
+ const target = globalThis as unknown as Record<string, unknown>;
91
+ for (const key of DOM_GLOBALS) {
92
+ if (key in nativeGlobals) target[key] = nativeGlobals[key];
93
+ else delete target[key];
94
+ }
95
+ });
96
+
97
+ /** Render the hook and expose its value plus its two actions. */
98
+ function Probe() {
99
+ const handle = useRepoScope();
100
+ const text = [
101
+ handle.root ?? 'null',
102
+ handle.repo,
103
+ handle.rows.map((row) => `${row.repo}=${row.label}/${row.short}`).join(','),
104
+ handle.scanning ? 'scanning' : 'idle',
105
+ ].join('|');
106
+ return h(
107
+ 'div',
108
+ { id: 'probe' },
109
+ h('span', { id: 'value' }, text),
110
+ h('button', { id: 'pick', onClick: () => handle.setRepo('projects/a/token') }, 'pick'),
111
+ h('button', { id: 'refresh', onClick: () => handle.refresh() }, 'refresh'),
112
+ );
113
+ }
114
+
115
+ const read = () => (container.querySelector('#value') as HTMLElement | null)?.textContent ?? '';
116
+
117
+ async function settle(): Promise<void> {
118
+ for (let i = 0; i < 8; i += 1) await act(async () => {});
119
+ }
120
+
121
+ describe('useRepoScope', () => {
122
+ test('reports an empty scope against a host with no repo service', async () => {
123
+ const base = services();
124
+ delete base.repoScope;
125
+ delete base.subscribeRepoScope;
126
+ configureUiKit(base);
127
+
128
+ await act(async () => render(h(Probe, {}), container));
129
+ await settle();
130
+
131
+ // "no workspace", never a throw: a plugin panel must still render.
132
+ expect(read()).toBe('null|.||idle');
133
+ });
134
+
135
+ test('renders the host scope, rows and labels included', async () => {
136
+ scope = {
137
+ root: '/ws/Workspace',
138
+ repo: '.',
139
+ rows: [
140
+ { repo: '.', label: 'Workspace', short: 'Workspace' },
141
+ { repo: 'projects/a/token', label: 'projects/a/token', short: 'token' },
142
+ ],
143
+ scanning: false,
144
+ ready: true,
145
+ };
146
+
147
+ await act(async () => render(h(Probe, {}), container));
148
+ await settle();
149
+
150
+ // The labels travel WITH the rows — the naming rule is the host's, and the
151
+ // kit must not re-derive it.
152
+ expect(read()).toBe('/ws/Workspace|.|.=Workspace/Workspace,projects/a/token=projects/a/token/token|idle');
153
+ });
154
+
155
+ test('a scope change pushed by the host re-renders the panel', async () => {
156
+ scope = { root: '/ws', repo: '.', rows: [{ repo: '.', label: 'ws', short: 'ws' }], scanning: false, ready: true };
157
+ await act(async () => render(h(Probe, {}), container));
158
+ await settle();
159
+ expect(read()).toStartWith('/ws|.');
160
+
161
+ // The host's own picker moved the shared slot.
162
+ scope = {
163
+ root: '/ws',
164
+ repo: 'projects/b',
165
+ rows: [
166
+ { repo: '.', label: 'ws', short: 'ws' },
167
+ { repo: 'projects/b', label: 'projects/b', short: 'b' },
168
+ ],
169
+ scanning: false,
170
+ ready: true,
171
+ };
172
+ await act(async () => {
173
+ for (const listener of listeners) listener();
174
+ });
175
+ await settle();
176
+
177
+ expect(read()).toContain('|projects/b|');
178
+ });
179
+
180
+ test('a write goes through the service, not a private copy', async () => {
181
+ scope = { root: '/ws', repo: '.', rows: [{ repo: '.', label: 'ws', short: 'ws' }], scanning: false, ready: true };
182
+ await act(async () => render(h(Probe, {}), container));
183
+ await settle();
184
+
185
+ await act(async () => {
186
+ (container.querySelector('#pick') as HTMLButtonElement).click();
187
+ });
188
+ await settle();
189
+
190
+ expect(writes).toEqual(['projects/a/token']);
191
+ });
192
+
193
+ test('refresh asks the service to rescan', async () => {
194
+ scope = { root: '/ws', repo: '.', rows: [], scanning: false, ready: true };
195
+ await act(async () => render(h(Probe, {}), container));
196
+ await settle();
197
+
198
+ await act(async () => {
199
+ (container.querySelector('#refresh') as HTMLButtonElement).click();
200
+ });
201
+ await settle();
202
+
203
+ expect(rescans).toBe(1);
204
+ });
205
+
206
+ test('unsubscribes on unmount', async () => {
207
+ scope = { root: '/ws', repo: '.', rows: [], scanning: false, ready: true };
208
+ await act(async () => render(h(Probe, {}), container));
209
+ await settle();
210
+ expect(listeners).toHaveLength(1);
211
+
212
+ render(null, container);
213
+ await settle();
214
+
215
+ // A leaked listener would re-render a torn-down panel on every host write.
216
+ expect(listeners).toHaveLength(0);
217
+ });
218
+ });
package/src/repo.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The repo scope, as a plugin reads and writes it.
8
+ *
9
+ * A nested repository is a path RELATIVE to the active workspace root
10
+ * (`projects/api` names a directory under the root), so the choice and the
11
+ * discovered list are both meaningless without the root they belong to. The host
12
+ * keeps the pairing; this module is the plugin's view of it.
13
+ *
14
+ * Two properties the hooks exist to enforce:
15
+ *
16
+ * - **The value arrives late and changes from elsewhere.** The stored pick loads
17
+ * with the session, and the four host panels write the same slot. A component
18
+ * that captured the scope once would show the previous repo forever, so the
19
+ * hook subscribes and re-renders.
20
+ * - **A pick made under another root is not this root's pick.** The service
21
+ * resolves that (it is the host that holds both halves), so a plugin never has
22
+ * to compare roots itself.
23
+ *
24
+ * Against a host too old to provide the service, the hook reports an empty scope
25
+ * rather than throwing: a plugin that renders a picker against a host without
26
+ * one should draw "no workspace", not break the panel.
27
+ */
28
+
29
+ import { useCallback, useEffect, useState } from 'preact/hooks';
30
+ import { requireServices, type RepoScope } from './services';
31
+
32
+ /** The scope with nothing known yet, for a host that cannot answer. */
33
+ const NO_SCOPE: RepoScope = { root: null, repo: '.', rows: [], scanning: false, ready: false };
34
+
35
+ export interface RepoScopeHandle extends RepoScope {
36
+ /** Set the shared pick. */
37
+ setRepo(repo: string): void;
38
+ /** Force a fresh nested-repo walk for the current root. */
39
+ refresh(): void;
40
+ }
41
+
42
+ /**
43
+ * The nested-repo scope the workspace panels share.
44
+ *
45
+ * Takes NO root argument: the service resolves the root from the plugin context
46
+ * the host already publishes, which is the same value a panel body carries. That
47
+ * is deliberate — one resolution serves both a panel (which has a component to
48
+ * call a hook from) and a content script (which does not), so the two cannot
49
+ * disagree about which tree they are describing.
50
+ */
51
+ export function useRepoScope(): RepoScopeHandle {
52
+ const [scope, setScope] = useState<RepoScope>(() => requireServices().repoScope?.() ?? NO_SCOPE);
53
+
54
+ useEffect(() => {
55
+ const api = requireServices();
56
+ const read = () => setScope(api.repoScope?.() ?? NO_SCOPE);
57
+ // Re-read once on mount: the first render may have run before the session's
58
+ // stored state loaded, and the subscription only fires on a CHANGE.
59
+ read();
60
+ return api.subscribeRepoScope?.(read);
61
+ }, []);
62
+
63
+ const setRepo = useCallback((repo: string) => {
64
+ requireServices().setRepoScope?.(repo);
65
+ }, []);
66
+
67
+ const refresh = useCallback(() => {
68
+ requireServices().rescanRepoScope?.();
69
+ }, []);
70
+
71
+ return { ...scope, setRepo, refresh };
72
+ }