@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.
- package/package.json +38 -4
- package/src/components.tsx +163 -0
- package/src/fetch.test.tsx +167 -0
- package/src/fetch.ts +173 -0
- package/src/index.tsx +87 -0
- package/src/panel-host.tsx +49 -0
- package/src/panels.ts +206 -0
- package/src/participant.test.tsx +238 -0
- package/src/participant.ts +186 -0
- package/src/plugin-id.tsx +79 -0
- package/src/repo-picker.tsx +212 -0
- package/src/repo.test.tsx +218 -0
- package/src/repo.ts +72 -0
- package/src/services.ts +309 -0
- package/src/session-value.test.tsx +162 -0
- package/src/sidebar.test.tsx +190 -0
- package/src/sidebar.ts +136 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|