@protoboardai/web-viewer 0.0.0-stage → 0.2.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 +202 -0
- package/NOTICE +22 -0
- package/README.md +34 -2
- package/dist/vscode/protoboard-viewer.css +3 -0
- package/dist/vscode/protoboard-viewer.js +115025 -0
- package/dist/web-viewer.css +3 -0
- package/dist/web-viewer.js +114064 -0
- package/package.json +52 -4
- package/src/Logo.tsx +24 -0
- package/src/Shell.tsx +313 -0
- package/src/index.ts +36 -0
- package/src/logo-mark.ts +2 -0
- package/src/partsData.ts +80 -0
- package/src/styles.css +46 -0
- package/src/vscode/Shell.tsx +662 -0
- package/src/vscode/devLink.ts +444 -0
- package/src/vscode/index.ts +263 -0
- package/src/vscode/protocol.ts +314 -0
- package/src/vscode/styles.css +43 -0
- package/src/vscode/tab-module.ts +18 -0
- package/src/vscode/tabs-fit.ts +26 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The VS Code shell (PB-894 §2, PB-895, PB-896, PB-897): one viewer editor
|
|
3
|
+
* tab, as simple as it can be. A floating header with the Protoboard mark,
|
|
4
|
+
* the module the tab shows and the view tabs the registry offers for it; the
|
|
5
|
+
* view; for the diagram a toolbar (fit, zoom, wire detail, grounds) and a
|
|
6
|
+
* legend that holds the colouring choice; and at the left the tab's own
|
|
7
|
+
* hierarchy (PB-1042: the module tree of this tab's project, synced with its
|
|
8
|
+
* canvas; its right-click menu is VS Code's, from the rows' context). No
|
|
9
|
+
* inspector and no DRC: those are VS Code's own views (the Protoboard
|
|
10
|
+
* Inspector, which follows the focused tab, and the DRC panel). The theme is
|
|
11
|
+
* VS Code's (high contrast included); the Protoboard faces apply only under a
|
|
12
|
+
* Protoboard colour theme.
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
|
15
|
+
import {
|
|
16
|
+
CLASSIFICATIONS,
|
|
17
|
+
DETAIL_OPTIONS,
|
|
18
|
+
CLASSIFICATION_OPTIONS,
|
|
19
|
+
DiagramCanvas,
|
|
20
|
+
DiagramLegendItems,
|
|
21
|
+
EmptyState,
|
|
22
|
+
FittedLegend,
|
|
23
|
+
LegendToolbarButton,
|
|
24
|
+
Icon,
|
|
25
|
+
IconButton,
|
|
26
|
+
IsolationClear,
|
|
27
|
+
Scene3D,
|
|
28
|
+
ScrollArea,
|
|
29
|
+
ThemeProvider,
|
|
30
|
+
ToggleGroup,
|
|
31
|
+
ToolToggle,
|
|
32
|
+
Toolbar,
|
|
33
|
+
ToolbarSeparator,
|
|
34
|
+
UhdViewer,
|
|
35
|
+
ViewButtons,
|
|
36
|
+
HierarchyToggle,
|
|
37
|
+
ViewerHierarchy,
|
|
38
|
+
ViewerNodeMenu,
|
|
39
|
+
cx,
|
|
40
|
+
defineTheme,
|
|
41
|
+
useViewer,
|
|
42
|
+
vscodeThemeAdapter,
|
|
43
|
+
type HierarchyCommand,
|
|
44
|
+
type HierarchyNode,
|
|
45
|
+
type HierarchyPanelState,
|
|
46
|
+
type ThemeAdapter,
|
|
47
|
+
type UhdViewerOptions,
|
|
48
|
+
} from "@deltarobotics/uhd-viewer/components";
|
|
49
|
+
import type { ResolvedView } from "@protoboardai/core";
|
|
50
|
+
import { MissingPartsHint, PartStatusStyles, PlannedView, ViewIcon, ViewRegistry, createViewRegistry, nodeMarks, registerContributedViews, type HostAdapter, type PartTrust, type ViewDefinition } from "@protoboardai/views";
|
|
51
|
+
import { Logo } from "../Logo.js";
|
|
52
|
+
import { DEV_SERVER_IS, type ViewerStatus } from "./devLink.js";
|
|
53
|
+
import type { DevAction, FromWebview, HierarchyAction, HierarchyRowContext, ModuleSource, SourceAction } from "./protocol.js";
|
|
54
|
+
import { tabModule } from "./tab-module.js";
|
|
55
|
+
import { fitTabs } from "./tabs-fit.js";
|
|
56
|
+
|
|
57
|
+
export { tabModule };
|
|
58
|
+
|
|
59
|
+
/** What the page tells the shell besides the viewer's options. */
|
|
60
|
+
export interface ShellBridge {
|
|
61
|
+
/** Send a message to the extension. */
|
|
62
|
+
post(message: FromWebview): void;
|
|
63
|
+
/** The extension asked for a view (`showView`): subscribe; returns the unsubscribe. */
|
|
64
|
+
onShowView(listener: (view: string) => void): () => void;
|
|
65
|
+
/** The view the tab opens on. */
|
|
66
|
+
startView: string;
|
|
67
|
+
/** The level the tab opens on ("" = the top): the shell reports no state until the viewer shows it, so the tab never passes through the top level. */
|
|
68
|
+
startPath?: string;
|
|
69
|
+
/** What the tab's view was opened with (the pair view's `link`, `a`, `b`). */
|
|
70
|
+
startParams?: Record<string, string>;
|
|
71
|
+
/** What the page says over the canvas while it has no bundle to show, or lost its server. */
|
|
72
|
+
status: () => ViewerStatus | null;
|
|
73
|
+
onStatus(listener: () => void): () => void;
|
|
74
|
+
/** The buttons of a dev server problem: try again, reload the window, show the extension's log. */
|
|
75
|
+
devAction?(action: DevAction): void;
|
|
76
|
+
/** The source file of a module (by instance path), as the extension last said it; undefined: not known. */
|
|
77
|
+
source?: (path: string) => ModuleSource | null | undefined;
|
|
78
|
+
onSource?(listener: () => void): () => void;
|
|
79
|
+
/** What the tab's hierarchy panel kept (the webview's own state), and where its changes go. */
|
|
80
|
+
hierarchyState?: unknown;
|
|
81
|
+
onHierarchyState?(state: HierarchyPanelState): void;
|
|
82
|
+
/** The extension's commands for the hierarchy panel (reveal a module, the filter, open or fold): subscribe; returns the unsubscribe. */
|
|
83
|
+
onHierarchy?(listener: (command: { type: HierarchyAction; path?: string }) => void): () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface VsCodeViewerOptions extends UhdViewerOptions {
|
|
87
|
+
bridge: ShellBridge;
|
|
88
|
+
/** Views the project's extensions declare, added to the built-in ones. */
|
|
89
|
+
views?: ResolvedView[];
|
|
90
|
+
registry?: ViewRegistry;
|
|
91
|
+
/** Every definition's part status by id (PB-932; the dev server's state): the dot on a module in the diagram. */
|
|
92
|
+
partStatus?: Record<string, { trust: PartTrust; basis?: string; blank?: boolean }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** VS Code's faces, or the Protoboard ones under a Protoboard colour theme (the only place they apply). */
|
|
96
|
+
const VSCODE_FACES = { fontUi: "var(--vscode-font-family)", fontLabel: "var(--vscode-font-family)", fontMono: "var(--vscode-editor-font-family, ui-monospace, monospace)" };
|
|
97
|
+
const PROTOBOARD_FACES = { fontUi: '"Kode Mono", var(--vscode-font-family)', fontLabel: '"Kode Mono Bold", "Kode Mono", var(--vscode-font-family)', fontMono: "var(--vscode-editor-font-family, ui-monospace, monospace)" };
|
|
98
|
+
|
|
99
|
+
/** The colour theme VS Code applies is a Protoboard one (the extension's Protoboard Dark or Light). */
|
|
100
|
+
export const isProtoboardTheme = (doc: Document | undefined = typeof document === "undefined" ? undefined : document): boolean =>
|
|
101
|
+
/^protoboard\b/i.test(doc?.body?.dataset.vscodeThemeId ?? doc?.body?.dataset.vscodeThemeName ?? "");
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* VS Code's theme (uhd-viewer's adapter, every token on `--vscode-*`) with
|
|
105
|
+
* the faces of the colour theme in use: a change of theme (the body's class
|
|
106
|
+
* or its theme id) re-themes.
|
|
107
|
+
*/
|
|
108
|
+
export function vscodeShellThemeAdapter(doc: Document | undefined = typeof document === "undefined" ? undefined : document): ThemeAdapter {
|
|
109
|
+
const base = vscodeThemeAdapter(doc);
|
|
110
|
+
const current = () => {
|
|
111
|
+
const t = base.current();
|
|
112
|
+
return defineTheme(t, { id: t.id, label: t.label, tokens: isProtoboardTheme(doc) ? PROTOBOARD_FACES : VSCODE_FACES });
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
id: "vscode",
|
|
116
|
+
current,
|
|
117
|
+
subscribe: doc?.body
|
|
118
|
+
? (onChange) => {
|
|
119
|
+
let key = `${doc.body.className}|${isProtoboardTheme(doc)}`;
|
|
120
|
+
const observer = new MutationObserver(() => {
|
|
121
|
+
const next = `${doc.body.className}|${isProtoboardTheme(doc)}`;
|
|
122
|
+
if (next !== key) {
|
|
123
|
+
key = next;
|
|
124
|
+
onChange(current());
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
observer.observe(doc.body, { attributes: true, attributeFilter: ["class", "data-vscode-theme-id", "data-vscode-theme-name"] });
|
|
128
|
+
return () => observer.disconnect();
|
|
129
|
+
}
|
|
130
|
+
: undefined,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function VsCodeShell(props: VsCodeViewerOptions) {
|
|
135
|
+
const adapter = useMemo(() => vscodeShellThemeAdapter(), []);
|
|
136
|
+
const status = useSyncExternalStore(props.bridge.onStatus, props.bridge.status);
|
|
137
|
+
// the viewer knows marks, not parts: the status becomes a tone and a tooltip here
|
|
138
|
+
const marks = useMemo(() => nodeMarks(props.partStatus), [props.partStatus]);
|
|
139
|
+
return (
|
|
140
|
+
<ThemeProvider adapter={adapter} className="pb-vscode">
|
|
141
|
+
<PartStatusStyles />
|
|
142
|
+
{props.bundle || props.bundleUrl ? (
|
|
143
|
+
<UhdViewer {...props} nodeMarks={props.nodeMarks ?? marks}>
|
|
144
|
+
<Shell bridge={props.bridge} views={props.views} registry={props.registry} />
|
|
145
|
+
{status && <StatusCard status={status} bridge={props.bridge} over />}
|
|
146
|
+
</UhdViewer>
|
|
147
|
+
) : (
|
|
148
|
+
<div className={cx("relative grid size-full bg-canvas", MIDDLE)}>
|
|
149
|
+
<Header />
|
|
150
|
+
<StatusCard status={status ?? { message: "Loading the project…", busy: true }} bridge={props.bridge} />
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</ThemeProvider>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What the tab says over the canvas. A note (a rebuild running, a rebuild
|
|
159
|
+
* that failed) sits at the bottom, out of the drawing's way. A dev server
|
|
160
|
+
* problem sits in the middle of the tab (PB-943): what failed, what the dev
|
|
161
|
+
* server is, and what to try.
|
|
162
|
+
*/
|
|
163
|
+
function StatusCard({ status, bridge, over }: { status: ViewerStatus; bridge: ShellBridge; over?: boolean }) {
|
|
164
|
+
const { message, busy, problem } = status;
|
|
165
|
+
const mark = busy ? (
|
|
166
|
+
<span aria-hidden className="mt-[3px] size-3 shrink-0 animate-spin rounded-full border-2 border-accent border-r-transparent" />
|
|
167
|
+
) : (
|
|
168
|
+
<Icon name="circle-alert" size={14} className="mt-[2px] shrink-0 text-warning" aria-hidden />
|
|
169
|
+
);
|
|
170
|
+
const card = "z-40 rounded-panel border border-line bg-surface font-ui text-[12px] text-fg-muted shadow-panel outline outline-1 -outline-offset-1 outline-contrast";
|
|
171
|
+
if (!problem) {
|
|
172
|
+
return (
|
|
173
|
+
<div role="status" className={cx(card, "flex max-w-[min(520px,calc(100%-32px))] items-start gap-2.5 px-3 py-2", over && "absolute bottom-16 left-1/2 -translate-x-1/2")}>
|
|
174
|
+
{mark}
|
|
175
|
+
<span className="min-w-0">{message}</span>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const act = bridge.devAction;
|
|
180
|
+
const box = (
|
|
181
|
+
<div role="alert" data-dev-problem className={cx(card, CARD_BOX)}>
|
|
182
|
+
<ScrollArea className="min-h-0" contentClassName="flex flex-col gap-2.5 px-4 py-3.5">
|
|
183
|
+
<div className="flex items-start gap-2.5">
|
|
184
|
+
{mark}
|
|
185
|
+
<span className="min-w-0 text-[13px] leading-snug text-fg [overflow-wrap:anywhere]">{message}</span>
|
|
186
|
+
</div>
|
|
187
|
+
<p className="m-0 leading-snug">{DEV_SERVER_IS}</p>
|
|
188
|
+
{act && (
|
|
189
|
+
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
|
190
|
+
<DevButton icon="refresh-cw" label="Retry" title="Find or start the dev server again and reload this tab from it" primary onClick={() => act("retry")} />
|
|
191
|
+
<DevButton icon="rotate-ccw" label="Reload Window" title="Reload this VS Code window: the extension and every viewer tab start again" onClick={() => act("reload")} />
|
|
192
|
+
<DevButton icon="scroll-text" label="Show Log" title="Protoboard: Show Log. What the extension started, on which address, and what this tab tried to reach" onClick={() => act("showLog")} />
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</ScrollArea>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
// in the middle of the tab, over the drawing when there is one (which stays usable around the card)
|
|
199
|
+
return over ? <div className={cx("pointer-events-none absolute inset-0 z-40 grid", MIDDLE)}>{box}</div> : box;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Where a card sits: in the middle of what the floating header leaves free
|
|
204
|
+
* (the header is 40px tall, 4px from the top). One row as tall as that space,
|
|
205
|
+
* so a card taller than a small tab is held to it and scrolls inside itself.
|
|
206
|
+
*/
|
|
207
|
+
const MIDDLE = "box-border grid-rows-[minmax(0,1fr)] place-items-center pt-12 pb-1";
|
|
208
|
+
const CARD_BOX = "pointer-events-auto flex max-h-full w-[min(440px,calc(100%-32px))] min-w-[220px] flex-col overflow-hidden";
|
|
209
|
+
|
|
210
|
+
function DevButton({ icon, label, title, primary, onClick }: { icon: string; label: string; title: string; primary?: boolean; onClick: () => void }) {
|
|
211
|
+
return (
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
data-tooltip={title}
|
|
215
|
+
onClick={onClick}
|
|
216
|
+
className={cx(
|
|
217
|
+
"inline-flex h-[26px] cursor-pointer items-center gap-1.5 rounded-md border px-2.5 font-ui text-[12px] leading-none outline-offset-2 focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus",
|
|
218
|
+
primary ? "pb-dev-primary border-transparent" : "border-line bg-transparent text-fg hover:bg-hover",
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
221
|
+
<Icon name={icon} size={13} aria-hidden />
|
|
222
|
+
{label}
|
|
223
|
+
</button>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function useRegistry(views: ResolvedView[] | undefined, given?: ViewRegistry): ViewRegistry {
|
|
228
|
+
const registry = useMemo(() => {
|
|
229
|
+
if (given) return given;
|
|
230
|
+
const r = createViewRegistry();
|
|
231
|
+
if (views?.length) registerContributedViews(r, views);
|
|
232
|
+
return r;
|
|
233
|
+
}, [views, given]);
|
|
234
|
+
useSyncExternalStore(
|
|
235
|
+
(l) => registry.subscribe(l),
|
|
236
|
+
() => registry.list().length + registry.list().filter((v) => v.status === "ready").length * 1000,
|
|
237
|
+
);
|
|
238
|
+
return registry;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Views VS Code shows in editors of their own (PB-912): the full-page library and the BOM editor. */
|
|
242
|
+
const NATIVE_VIEWS = new Set(["library", "bom"]);
|
|
243
|
+
|
|
244
|
+
/** What the floating header and the toolbar cover (the viewer's floating cards sit inside it). */
|
|
245
|
+
const INSETS = { "--uhd-inset-top": "44px", "--uhd-inset-left": "0px", "--uhd-inset-right": "0px" } as CSSProperties;
|
|
246
|
+
|
|
247
|
+
/** The floating header without a model: the mark only. */
|
|
248
|
+
function Header({ children }: { children?: ReactNode }) {
|
|
249
|
+
return (
|
|
250
|
+
<header className="absolute top-1 right-1 left-1 z-50 flex h-10 min-w-0 items-center gap-1 rounded-panel border border-line bg-surface px-1 shadow-panel outline outline-1 -outline-offset-1 outline-contrast">
|
|
251
|
+
<Logo />
|
|
252
|
+
{children}
|
|
253
|
+
</header>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function Shell({ bridge, views, registry: given }: { bridge: ShellBridge; views?: ResolvedView[]; registry?: ViewRegistry }) {
|
|
258
|
+
const c = useViewer();
|
|
259
|
+
const registry = useRegistry(views, given);
|
|
260
|
+
const path = c.drill.join("/");
|
|
261
|
+
const tabs = registry.forModule(c.levelDef, c.source.bundle, path);
|
|
262
|
+
// a domain tab (schematic, BOM, …) when one is shown; the viewer's own view (diagram, 3D) otherwise
|
|
263
|
+
const [domain, setDomain] = useState<string | null>(() => (bridge.startView === "diagram" || bridge.startView === "3d" ? null : bridge.startView));
|
|
264
|
+
const lastView = useRef(c.view);
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
// the viewer's view changed (a host's setView): show it
|
|
267
|
+
if (lastView.current !== c.view) setDomain(null);
|
|
268
|
+
lastView.current = c.view;
|
|
269
|
+
}, [c.view]);
|
|
270
|
+
const wanted = domain ?? c.view;
|
|
271
|
+
const active = tabs.find((t) => t.id === wanted) ?? tabs[0];
|
|
272
|
+
const show = (id: string) => {
|
|
273
|
+
if (id === "diagram" || id === "3d") {
|
|
274
|
+
setDomain(null);
|
|
275
|
+
c.setView(id);
|
|
276
|
+
} else setDomain(id);
|
|
277
|
+
};
|
|
278
|
+
useEffect(() => bridge.onShowView(show));
|
|
279
|
+
// views with an editor of their own in VS Code (the full library, the BOM editor): their tab opens it
|
|
280
|
+
const opensNative = (id: string) => NATIVE_VIEWS.has(id);
|
|
281
|
+
const host = useMemo<HostAdapter>(
|
|
282
|
+
() => ({
|
|
283
|
+
kind: "vscode",
|
|
284
|
+
openSource: c.hostActions.openSource ? (r) => c.hostActions.openSource!(r.defId, r.instancePath) : undefined,
|
|
285
|
+
openArtifact: c.hostActions.openArtifact ? (r) => c.hostActions.openArtifact!(r.defId, r.artifactId) : undefined,
|
|
286
|
+
openView: (view, at, params) => bridge.post({ type: "openView", view, path: at, ...(params ? { params } : {}) }),
|
|
287
|
+
notify: (message, severity) => bridge.post({ type: "notify", message, ...(severity ? { severity } : {}) }),
|
|
288
|
+
// the diagram's notice about parts the project has not fetched: the extension's Fetch Parts (B6)
|
|
289
|
+
fetchParts: () => bridge.post({ type: "devAction", action: "fetchParts" }),
|
|
290
|
+
// a view's files (the board drawings) from the dev server, as the bundle's artifacts are
|
|
291
|
+
artifactUrl: (defId, filePath) => c.source.resolveArtifact(defId, filePath),
|
|
292
|
+
// a component picked in a domain view (the PCB, the schematic): the extension's Inspector follows it, as it
|
|
293
|
+
// follows the diagram's selection
|
|
294
|
+
selectionChanged: (selection) => bridge.post({ type: "event", event: { type: "uhd-viewer:selectionChanged", selection } }),
|
|
295
|
+
}),
|
|
296
|
+
[c.hostActions, bridge, c.source],
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// the extension titles the tab and follows the level (the Inspector, a new tab's module)
|
|
300
|
+
// (a part's name as its level declares it: the diagram's own labels are capitals)
|
|
301
|
+
const parts = ((c.levelDef.children ?? []) as { id: string; name?: string; moduleDefId?: string }[]).map((ch) => ({ id: ch.id, label: ch.name ?? c.defFor(ch.moduleDefId)?.name ?? ch.id }));
|
|
302
|
+
const on = tabModule(c.drill, bridge.startPath ?? "", c.vm.path[c.vm.path.length - 1]?.label ?? c.source.name, parts);
|
|
303
|
+
const label = on.label;
|
|
304
|
+
const viewId = active?.id ?? "diagram";
|
|
305
|
+
const nodes = c.drawn.nodes.length;
|
|
306
|
+
const tabKey = tabs.map((t) => `${t.id}:${t.title}`).join(",");
|
|
307
|
+
// a tab opened on a module (or on 3D) says nothing until the viewer shows it; a path the bundle no longer has gives up after a moment
|
|
308
|
+
const startPath = bridge.startPath ?? "";
|
|
309
|
+
const wantsStart = startPath !== "" || bridge.startView === "3d";
|
|
310
|
+
const [settled, setSettled] = useState(!wantsStart);
|
|
311
|
+
const atStart = path === startPath && (bridge.startView !== "3d" || c.view === "3d");
|
|
312
|
+
const loaded = !!c.source.bundle;
|
|
313
|
+
// definitions the project names and has not fetched: the tab says how many, and the extension's log with it
|
|
314
|
+
const missing = c.scenario.missing.length;
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (settled) return;
|
|
317
|
+
if (atStart && loaded) return void setSettled(true);
|
|
318
|
+
if (!loaded) return;
|
|
319
|
+
const t = setTimeout(() => setSettled(true), 1500);
|
|
320
|
+
return () => clearTimeout(t);
|
|
321
|
+
}, [settled, atStart, loaded]);
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
if (!settled) return;
|
|
324
|
+
bridge.post({ type: "state", path, label, view: viewId, nodes, views: tabs.map((t) => ({ id: t.id, title: t.title })), ...(missing ? { missing } : {}), ...(on.part ? { at: on.path } : {}) });
|
|
325
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
326
|
+
}, [bridge, settled, path, label, viewId, nodes, tabKey, missing, on.path]);
|
|
327
|
+
|
|
328
|
+
// 3D shows the module the tab is on (PB-897): the level, or the part the tab was opened on when it has no level of its own
|
|
329
|
+
const startAt = startPath ? startPath.split("/") : [];
|
|
330
|
+
const scope = startAt.length > c.drill.length && c.drill.every((d, i) => startAt[i] === d) ? startAt : c.drill;
|
|
331
|
+
// a board's 3D model is an output of its circuit tool: say how it is made where there is none (PB-946)
|
|
332
|
+
// (a board: a module with nets, whether or not its drawings exist yet; and the tab is on the board itself, not on one of its parts)
|
|
333
|
+
const board = scope === c.drill && (tabs.some((t) => t.id === "pcb" || t.id === "schematic") || ((c.levelDef.interfaces ?? []) as { protocols?: { type: string }[] }[]).some((i) => i.protocols?.some((p) => p.type === "net")));
|
|
334
|
+
const modelHint = board ? (
|
|
335
|
+
<>
|
|
336
|
+
A board's 3D model is its <span className="cmd">out/board.glb</span>. It is written by <span className="cmd">npx protoboard run circuit:generate --module {c.levelDef.id}</span> and then{" "}
|
|
337
|
+
<span className="cmd">npx protoboard run circuit:export --module {c.levelDef.id}</span>, and shown once the board's module lists it in its <span className="cmd">artifacts</span> (role body, format glb). Your agent can do all three.
|
|
338
|
+
</>
|
|
339
|
+
) : (
|
|
340
|
+
<>A part's 3D model is its body artifact (a GLB). A part from the library brings its own; for a part of your own, ask your agent to model it.</>
|
|
341
|
+
);
|
|
342
|
+
const View = active?.component ?? PlannedView;
|
|
343
|
+
// the tab's hierarchy: the extension reveals modules in it and puts the keyboard in its filter
|
|
344
|
+
const [hierarchyCommand, setHierarchyCommand] = useState<HierarchyCommand>();
|
|
345
|
+
const [hierarchyOpen, setHierarchyOpen] = useState(true);
|
|
346
|
+
const commandSeq = useRef(0);
|
|
347
|
+
useEffect(() => bridge.onHierarchy?.((m) => setHierarchyCommand({ type: m.type, ...(m.path !== undefined ? { path: m.path } : {}), seq: ++commandSeq.current })), [bridge]);
|
|
348
|
+
// a row's right-click menu is VS Code's (the manifest's webview/context menus read this, and its commands get it)
|
|
349
|
+
const rowAttributes = useCallback(
|
|
350
|
+
(n: HierarchyNode) => {
|
|
351
|
+
const context: HierarchyRowContext = {
|
|
352
|
+
webviewSection: "hierarchy",
|
|
353
|
+
pbPath: n.path,
|
|
354
|
+
pbKind: n.kind,
|
|
355
|
+
pbDefId: n.defId,
|
|
356
|
+
pbLevel: n.drillable && n.path !== "",
|
|
357
|
+
pbArtifacts: (c.defFor(n.defId)?.artifacts?.length ?? 0) > 0,
|
|
358
|
+
preventDefaultContextMenuItems: true,
|
|
359
|
+
};
|
|
360
|
+
return { "data-vscode-context": JSON.stringify(context) } as Record<`data-${string}`, string>;
|
|
361
|
+
},
|
|
362
|
+
[c.defFor],
|
|
363
|
+
);
|
|
364
|
+
return (
|
|
365
|
+
<div className="relative size-full min-h-[240px] min-w-[320px] overflow-hidden bg-background text-fg" style={INSETS}>
|
|
366
|
+
<Header>
|
|
367
|
+
<ToolbarSeparator />
|
|
368
|
+
<HierarchyToggle open={hierarchyOpen} onClick={() => setHierarchyCommand({ type: "toggle", seq: ++commandSeq.current })} />
|
|
369
|
+
<ToolbarSeparator />
|
|
370
|
+
<ViewTabs tabs={tabs} active={active?.id} opensEditor={opensNative} onOpen={(id, beside) => (beside || opensNative(id) ? bridge.post({ type: "openView", view: id, path }) : show(id))} />
|
|
371
|
+
<ToolbarSeparator />
|
|
372
|
+
<Breadcrumb part={on.part ? label : undefined} />
|
|
373
|
+
<SourcePath bridge={bridge} path={on.path} />
|
|
374
|
+
</Header>
|
|
375
|
+
<ViewerHierarchy state={bridge.hierarchyState} onStateChange={bridge.onHierarchyState} onOpenChange={setHierarchyOpen} command={hierarchyCommand} rowAttributes={rowAttributes} ownMenu={false} />
|
|
376
|
+
<div className="workspace" role="tabpanel" aria-label={active?.title}>
|
|
377
|
+
{active?.id === "diagram" ? (
|
|
378
|
+
<>
|
|
379
|
+
<DiagramCanvas toolbar={false} legend={false} coveredRight={false} missingHint={<MissingPartsHint host={host} />} />
|
|
380
|
+
<DiagramTools />
|
|
381
|
+
<DiagramKey />
|
|
382
|
+
</>
|
|
383
|
+
) : active?.id === "3d" ? (
|
|
384
|
+
<Scene3D inspector={false} scope={scope} emptyHint={modelHint} />
|
|
385
|
+
) : (
|
|
386
|
+
active && <View module={c.levelDef} path={path} bundle={c.source.bundle} host={host} view={active} {...(bridge.startParams && active.id === bridge.startView ? { params: bridge.startParams } : {})} />
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
<ViewerNodeMenu />
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Where the tab is: the system, the levels drilled into (each one a way back), and the part the tab is on when it has no level of its own. */
|
|
395
|
+
function Breadcrumb({ part }: { part?: string }) {
|
|
396
|
+
const c = useViewer();
|
|
397
|
+
const path = c.vm.path;
|
|
398
|
+
const crumbs = [...path.map((p, i) => ({ key: p.id || "root", label: p.label, go: i < path.length - 1 || part ? () => c.onNavigate(i) : undefined })), ...(part ? [{ key: "part", label: part, go: undefined }] : [])];
|
|
399
|
+
return (
|
|
400
|
+
<nav aria-label="Level" className="flex min-w-0 shrink items-center gap-0.5 overflow-hidden max-[520px]:hidden">
|
|
401
|
+
{crumbs.map((p, i) => {
|
|
402
|
+
const current = i === crumbs.length - 1;
|
|
403
|
+
return (
|
|
404
|
+
<span key={p.key} className="flex min-w-0 items-center gap-0.5">
|
|
405
|
+
{i > 0 && <Icon name="chevron-right" size={12} className="shrink-0 text-fg-faint" aria-hidden />}
|
|
406
|
+
{p.go && !current ? (
|
|
407
|
+
<button type="button" onClick={p.go} className="h-7 max-w-48 cursor-pointer truncate rounded-md border-0 bg-transparent px-1.5 font-ui text-[12px] text-fg-muted hover:bg-hover hover:text-fg">
|
|
408
|
+
{p.label}
|
|
409
|
+
</button>
|
|
410
|
+
) : (
|
|
411
|
+
<span aria-current={current ? "location" : undefined} className="max-w-56 truncate px-1.5 font-ui text-[12px] text-fg">
|
|
412
|
+
{p.label}
|
|
413
|
+
</span>
|
|
414
|
+
)}
|
|
415
|
+
</span>
|
|
416
|
+
);
|
|
417
|
+
})}
|
|
418
|
+
</nav>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The view tabs (B8): titles while they fit, icons when they do not, and a
|
|
424
|
+
* More menu for the tabs that do not fit even as icons. Two hidden copies of
|
|
425
|
+
* the strip measure the tabs.
|
|
426
|
+
*/
|
|
427
|
+
function ViewTabs({ tabs, active, opensEditor, onOpen }: { tabs: ViewDefinition[]; active?: string; opensEditor: (id: string) => boolean; onOpen: (id: string, beside: boolean) => void }) {
|
|
428
|
+
const nav = useRef<HTMLElement>(null);
|
|
429
|
+
const fullRow = useRef<HTMLDivElement>(null);
|
|
430
|
+
const compactRow = useRef<HTMLDivElement>(null);
|
|
431
|
+
const [fit, setFit] = useState<{ compact: boolean; shown: number[] }>({ compact: false, shown: tabs.map((_, i) => i) });
|
|
432
|
+
// the More menu, open at the button's place (the strip clips what overflows it)
|
|
433
|
+
const [menu, setMenuAt] = useState<{ left: number; top: number } | null>(null);
|
|
434
|
+
const setMenu = (open: boolean, at?: Element) => {
|
|
435
|
+
const r = at?.getBoundingClientRect();
|
|
436
|
+
setMenuAt(open && r ? { left: Math.max(4, r.right - 168), top: r.bottom + 4 } : null);
|
|
437
|
+
};
|
|
438
|
+
const key = tabs.map((t) => t.id).join(",");
|
|
439
|
+
const activeIndex = tabs.findIndex((t) => t.id === active);
|
|
440
|
+
useLayoutEffect(() => {
|
|
441
|
+
const el = nav.current;
|
|
442
|
+
if (!el) return;
|
|
443
|
+
const widths = (row: HTMLDivElement | null) => [...(row?.children ?? [])].map((c) => (c as HTMLElement).offsetWidth);
|
|
444
|
+
const measure = () => {
|
|
445
|
+
const next = fitTabs(widths(fullRow.current), widths(compactRow.current), el.clientWidth, activeIndex);
|
|
446
|
+
setFit((was) => (was.compact === next.compact && was.shown.join() === next.shown.join() ? was : next));
|
|
447
|
+
};
|
|
448
|
+
measure();
|
|
449
|
+
const ro = new ResizeObserver(measure);
|
|
450
|
+
ro.observe(el);
|
|
451
|
+
return () => ro.disconnect();
|
|
452
|
+
}, [key, activeIndex]);
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (!menu) return;
|
|
455
|
+
const close = (e: Event) => {
|
|
456
|
+
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
|
457
|
+
if (e instanceof PointerEvent && (e.target as Element | null)?.closest?.("[data-view-menu]")) return;
|
|
458
|
+
setMenu(false);
|
|
459
|
+
};
|
|
460
|
+
window.addEventListener("pointerdown", close, true);
|
|
461
|
+
window.addEventListener("keydown", close, true);
|
|
462
|
+
return () => {
|
|
463
|
+
window.removeEventListener("pointerdown", close, true);
|
|
464
|
+
window.removeEventListener("keydown", close, true);
|
|
465
|
+
};
|
|
466
|
+
}, [menu]);
|
|
467
|
+
const hidden = tabs.filter((_, i) => !fit.shown.includes(i));
|
|
468
|
+
const row = (compact: boolean) => tabs.map((t) => <Tab key={t.id} view={t} active={t.id === active} compact={compact} measure />);
|
|
469
|
+
return (
|
|
470
|
+
<>
|
|
471
|
+
<nav ref={nav} role="tablist" aria-label="Views" className="relative flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
|
472
|
+
{tabs.map((t, i) =>
|
|
473
|
+
fit.shown.includes(i) ? <Tab key={t.id} view={t} active={t.id === active} compact={fit.compact} opensEditor={opensEditor(t.id)} onOpen={(beside) => onOpen(t.id, beside)} /> : null,
|
|
474
|
+
)}
|
|
475
|
+
{hidden.length > 0 && (
|
|
476
|
+
<IconButton size="sm" icon="chevrons-right" label={`More views: ${hidden.map((t) => t.title).join(", ")}`} aria-haspopup="menu" aria-expanded={!!menu} data-view-menu onClick={(e) => setMenu(!menu, e.currentTarget)} className="size-8 shrink-0" />
|
|
477
|
+
)}
|
|
478
|
+
{/* the measuring copies: never seen, never focused */}
|
|
479
|
+
<div aria-hidden inert className="pointer-events-none invisible absolute top-0 left-0 flex gap-0.5" ref={fullRow}>
|
|
480
|
+
{row(false)}
|
|
481
|
+
</div>
|
|
482
|
+
<div aria-hidden inert className="pointer-events-none invisible absolute top-0 left-0 flex gap-0.5" ref={compactRow}>
|
|
483
|
+
{row(true)}
|
|
484
|
+
</div>
|
|
485
|
+
</nav>
|
|
486
|
+
{menu && hidden.length > 0 && (
|
|
487
|
+
<div role="menu" aria-label="More views" data-view-menu style={{ left: menu.left, top: menu.top }} className="fixed z-[60] flex w-[168px] flex-col gap-0.5 rounded-panel border border-line bg-surface p-1 shadow-panel outline outline-1 -outline-offset-1 outline-contrast">
|
|
488
|
+
{hidden.map((t) => (
|
|
489
|
+
<button
|
|
490
|
+
key={t.id}
|
|
491
|
+
type="button"
|
|
492
|
+
role="menuitem"
|
|
493
|
+
onClick={(e) => {
|
|
494
|
+
setMenu(false);
|
|
495
|
+
onOpen(t.id, e.ctrlKey || e.metaKey);
|
|
496
|
+
}}
|
|
497
|
+
className="flex h-8 cursor-pointer items-center gap-2 rounded-md border border-transparent px-2 text-left font-ui text-[12px] text-fg-muted hover:bg-hover hover:text-fg focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus"
|
|
498
|
+
>
|
|
499
|
+
<ViewIcon name={t.icon} size={15} strokeWidth={1.75} aria-hidden />
|
|
500
|
+
{t.title}
|
|
501
|
+
</button>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
)}
|
|
505
|
+
</>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function Tab({ view, active, compact, measure, opensEditor, onOpen }: { view: ViewDefinition; active: boolean; compact?: boolean; measure?: boolean; opensEditor?: boolean; onOpen?: (beside: boolean) => void }) {
|
|
510
|
+
const planned = view.status === "planned";
|
|
511
|
+
const click = (e: MouseEvent) => onOpen?.(e.ctrlKey || e.metaKey || e.button === 1);
|
|
512
|
+
return (
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
role={measure ? undefined : "tab"}
|
|
516
|
+
tabIndex={measure ? -1 : undefined}
|
|
517
|
+
// the tab's name for screen readers and automation, also when it shows only its icon (B9)
|
|
518
|
+
aria-label={view.title}
|
|
519
|
+
aria-selected={measure ? undefined : active}
|
|
520
|
+
data-tooltip={`${view.title}${planned ? " · planned" : ""}${view.description ? `: ${view.description}` : ""}`}
|
|
521
|
+
data-tooltip-shortcut={opensEditor ? "Opens its own editor" : "Ctrl+click: new tab"}
|
|
522
|
+
onClick={click}
|
|
523
|
+
onAuxClick={(e) => e.button === 1 && click(e)}
|
|
524
|
+
className={cx(
|
|
525
|
+
"relative flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-transparent px-2 font-ui text-[12px] tracking-[-0.01em] transition-colors",
|
|
526
|
+
active ? "bg-accent-tint text-fg outline outline-1 -outline-offset-1 outline-contrast-active" : "text-fg-muted hover:bg-hover hover:text-fg",
|
|
527
|
+
planned && !active && "text-fg-faint",
|
|
528
|
+
"focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus",
|
|
529
|
+
)}
|
|
530
|
+
>
|
|
531
|
+
<ViewIcon name={view.icon} size={15} strokeWidth={1.75} className={active ? "text-accent" : undefined} aria-hidden />
|
|
532
|
+
{!compact && <span>{view.title}</span>}
|
|
533
|
+
{active && <span aria-hidden className="absolute right-2 bottom-0 left-2 h-0.5 rounded-full bg-accent" />}
|
|
534
|
+
</button>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* From a canvas tab to its source (the 2026-10-08 first builds, item 29):
|
|
540
|
+
* the file the module in view is written in, at the right of the header. A
|
|
541
|
+
* click opens it; beside it, show it in the Explorer and copy its path (the
|
|
542
|
+
* tab's own right-click menu has the same three, since VS Code offers its
|
|
543
|
+
* Reveal and Copy Path only for files).
|
|
544
|
+
*/
|
|
545
|
+
function SourcePath({ bridge, path }: { bridge: ShellBridge; path: string }) {
|
|
546
|
+
const source = useSyncExternalStore(
|
|
547
|
+
(l) => bridge.onSource?.(l) ?? (() => undefined),
|
|
548
|
+
() => bridge.source?.(path) ?? null,
|
|
549
|
+
);
|
|
550
|
+
if (!source) return null;
|
|
551
|
+
const act = (action: SourceAction) => bridge.post({ type: "source", action });
|
|
552
|
+
const at = `${source.file}${source.line ? `:${source.line}` : ""}`;
|
|
553
|
+
const parts = source.file.split("/");
|
|
554
|
+
const name = parts.pop() ?? source.file;
|
|
555
|
+
return (
|
|
556
|
+
<div data-source-path={source.file} className="ml-auto flex min-w-0 shrink-0 items-center gap-0.5 pl-1 max-[560px]:shrink">
|
|
557
|
+
<button
|
|
558
|
+
type="button"
|
|
559
|
+
aria-label={`Open ${at}`}
|
|
560
|
+
data-tooltip={`Open the source of this module: ${at}`}
|
|
561
|
+
onClick={() => act("open")}
|
|
562
|
+
className="flex h-8 max-w-[280px] min-w-0 cursor-pointer items-center gap-1.5 rounded-md border border-transparent px-1.5 font-code text-[11px] text-fg-muted transition-colors hover:bg-hover hover:text-fg hover:outline hover:outline-1 hover:outline-dashed hover:-outline-offset-1 hover:outline-contrast-active focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus"
|
|
563
|
+
>
|
|
564
|
+
<Icon name="file-code" size={14} strokeWidth={1.75} className="shrink-0" aria-hidden />
|
|
565
|
+
<span className="flex min-w-0 max-[760px]:hidden">
|
|
566
|
+
{/* the folders give way first: the file's name stays whole */}
|
|
567
|
+
{parts.length > 0 && <span className="min-w-0 shrink truncate text-fg-faint">{parts.join("/")}/</span>}
|
|
568
|
+
<span className="shrink-0">{name}</span>
|
|
569
|
+
</span>
|
|
570
|
+
</button>
|
|
571
|
+
<IconButton size="sm" icon="folder-search" label="Reveal the source file in the Explorer" onClick={() => act("reveal")} className="max-[480px]:hidden" />
|
|
572
|
+
<IconButton size="sm" icon="copy" label="Copy the source file's path" shortcut="Shift+click: relative" onClick={(e) => act(e.shiftKey ? "copyRelative" : "copy")} className="max-[480px]:hidden" />
|
|
573
|
+
</div>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The diagram's tools: the pointer's tool (select or pan), fit and zoom, then how links are drawn. No reset and no scenario (PB-896): the layout is saved with Ctrl+S. */
|
|
578
|
+
function DiagramTools() {
|
|
579
|
+
const c = useViewer();
|
|
580
|
+
return (
|
|
581
|
+
<Toolbar label="Diagram tools" className="toolbar">
|
|
582
|
+
{/* the legend, when it has no room beside the toolbar (FittedLegend) */}
|
|
583
|
+
<LegendToolbarButton>
|
|
584
|
+
<DiagramKeyBody form="popover" />
|
|
585
|
+
</LegendToolbarButton>
|
|
586
|
+
<ToolToggle tooltipSide="top" />
|
|
587
|
+
<ToolbarSeparator />
|
|
588
|
+
<ViewButtons tooltipSide="top" />
|
|
589
|
+
<ToolbarSeparator />
|
|
590
|
+
<ToggleGroup label="Wires" tooltipSide="top" options={DETAIL_OPTIONS} value={c.conductors ? "conductors" : "bundled"} onChange={(v) => c.setConductors(v === "conductors")} />
|
|
591
|
+
<IconButton icon="arrow-down-to-line" tooltipSide="top" label={c.showGrounds ? "Draw grounds as symbols" : "Draw grounds as wires"} pressed={c.showGrounds} onClick={() => c.setShowGrounds(!c.showGrounds)} />
|
|
592
|
+
</Toolbar>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const LEGEND_KEY = "protoboard-vscode:legend";
|
|
597
|
+
|
|
598
|
+
/** The legend starts open on a roomy tab and folded on a narrow one; a choice made on it is kept (per webview). */
|
|
599
|
+
function useLegendOpen(): [boolean, (open: boolean) => void] {
|
|
600
|
+
const [open, setOpen] = useState<boolean>(() => {
|
|
601
|
+
try {
|
|
602
|
+
const v = localStorage.getItem(LEGEND_KEY);
|
|
603
|
+
if (v) return v === "open";
|
|
604
|
+
} catch {
|
|
605
|
+
/* storage unavailable */
|
|
606
|
+
}
|
|
607
|
+
// folded until the user opens it (B22): open, it covered a quarter of a laptop-sized canvas and the ports under it
|
|
608
|
+
return false;
|
|
609
|
+
});
|
|
610
|
+
const set = (next: boolean) => {
|
|
611
|
+
setOpen(next);
|
|
612
|
+
try {
|
|
613
|
+
localStorage.setItem(LEGEND_KEY, next ? "open" : "folded");
|
|
614
|
+
} catch {
|
|
615
|
+
/* storage unavailable: the choice lasts this tab */
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
return [open, set];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The legend, with the colouring it explains chosen in it (PB-896: "it should
|
|
623
|
+
* be in the legend itself"); it folds to its header. Its entries are buttons
|
|
624
|
+
* (PB-946, the viewer's own): a click isolates a signal type or a domain, and
|
|
625
|
+
* the header then carries the way back, folded or not. It stays at the bottom
|
|
626
|
+
* left and gives way to the toolbar as the tab narrows: compact (no title,
|
|
627
|
+
* swatches without labels), then a button in the toolbar that opens it.
|
|
628
|
+
*/
|
|
629
|
+
function DiagramKey() {
|
|
630
|
+
const [open, setOpen] = useLegendOpen();
|
|
631
|
+
return <FittedLegend label="Legend" render={(form) => <DiagramKeyBody form={form} open={open} onOpen={setOpen} />} />;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The legend's content: `full` and `compact` fold (`open`); `popover` (from the toolbar's button) always lists the entries. */
|
|
635
|
+
function DiagramKeyBody({ form, open = true, onOpen }: { form: "full" | "compact" | "popover"; open?: boolean; onOpen?: (open: boolean) => void }) {
|
|
636
|
+
const c = useViewer();
|
|
637
|
+
const title = CLASSIFICATIONS.find((x) => x.id === c.mode)?.label;
|
|
638
|
+
const folds = form !== "popover";
|
|
639
|
+
const shown = !folds || open;
|
|
640
|
+
return (
|
|
641
|
+
<>
|
|
642
|
+
<div className={cx("-mx-1 -my-1 flex items-center justify-between gap-1", shown && "mb-1")}>
|
|
643
|
+
{folds && <IconButton icon={open ? "chevron-down" : "chevron-right"} size="sm" tooltipSide="top" label={open ? "Fold the legend" : "Show the legend"} aria-expanded={open} onClick={() => onOpen?.(!open)} className="size-6" />}
|
|
644
|
+
{form !== "compact" && <span className={cx("min-w-0 flex-1 truncate font-label text-[10px] tracking-[0.8px] text-fg-muted uppercase", !folds && "pl-1")}>{title}</span>}
|
|
645
|
+
<IsolationClear />
|
|
646
|
+
<ToggleGroup label="Colour links by" tooltipSide="top" size="sm" options={CLASSIFICATION_OPTIONS} value={c.mode} onChange={c.setMode} />
|
|
647
|
+
</div>
|
|
648
|
+
{shown && (form === "compact" ? <DiagramLegendItems compact columns={4} /> : <DiagramLegendItems />)}
|
|
649
|
+
</>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** A view that has nothing yet (kept for shells that want the placeholder outside the registry). */
|
|
654
|
+
export function NoView({ title }: { title: string }) {
|
|
655
|
+
return (
|
|
656
|
+
<div className="grid size-full place-items-center bg-canvas">
|
|
657
|
+
<EmptyState icon={<Icon name="eye-off" size={28} strokeWidth={1.25} />} title={title}>
|
|
658
|
+
Nothing to show here.
|
|
659
|
+
</EmptyState>
|
|
660
|
+
</div>
|
|
661
|
+
);
|
|
662
|
+
}
|