@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,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The viewer webview's script (dist/vscode/protoboard-viewer.js): the VS Code
|
|
3
|
+
* shell (./Shell.tsx) in one editor tab, loaded from the extension itself,
|
|
4
|
+
* never from a server.
|
|
5
|
+
*
|
|
6
|
+
* - The bundle, its artifacts and live rebuilds come from the project's
|
|
7
|
+
* `protoboard dev` server (its `uhd-bundle.json` and server-sent events);
|
|
8
|
+
* the extension says where it is (`dev`) and when it moves.
|
|
9
|
+
* - The viewer's events, the tab's state, layout edits and requests for new
|
|
10
|
+
* tabs go to the extension (protocol.ts); its host messages come back.
|
|
11
|
+
* - Keys the viewer does not use reach VS Code: the webview forwards every
|
|
12
|
+
* key press to the workbench, and nothing here stops one.
|
|
13
|
+
*/
|
|
14
|
+
import "@deltarobotics/uhd-viewer/components.css";
|
|
15
|
+
import "./styles.css";
|
|
16
|
+
import { renderViewer, type ViewerHandle } from "@deltarobotics/uhd-viewer/components";
|
|
17
|
+
import type { UhdBundle } from "@deltarobotics/uhd";
|
|
18
|
+
import type { ResolvedView } from "@protoboardai/core";
|
|
19
|
+
import { VsCodeShell, type ShellBridge, type VsCodeViewerOptions } from "./Shell.js";
|
|
20
|
+
import { DevLink, type EventSourceLike, type ViewerStatus } from "./devLink.js";
|
|
21
|
+
import { emptyLayout, hasEdits, type FromWebview, type HierarchyAction, type HierarchyRowReport, type LayoutSnapshot, type ModuleSource, type ToWebview, type ViewerLayout, type ViewerViewport, type WebviewConfig } from "./protocol.js";
|
|
22
|
+
|
|
23
|
+
export { VsCodeShell, vscodeShellThemeAdapter, isProtoboardTheme } from "./Shell.js";
|
|
24
|
+
export * from "./protocol.js";
|
|
25
|
+
export * from "./devLink.js";
|
|
26
|
+
|
|
27
|
+
interface VsCodeApi {
|
|
28
|
+
postMessage(message: unknown): void;
|
|
29
|
+
getState(): unknown;
|
|
30
|
+
setState(state: unknown): void;
|
|
31
|
+
}
|
|
32
|
+
declare function acquireVsCodeApi(): VsCodeApi;
|
|
33
|
+
|
|
34
|
+
/** Where every node and edge of the diagram on screen is drawn (absolute canvas coordinates), as the display file needs for a first save of a level. */
|
|
35
|
+
export function snapshot(doc: Document = document): LayoutSnapshot | undefined {
|
|
36
|
+
const flow = doc.querySelector(".react-flow");
|
|
37
|
+
if (!flow) return undefined;
|
|
38
|
+
const nodes: LayoutSnapshot["nodes"] = {};
|
|
39
|
+
for (const el of flow.querySelectorAll<HTMLElement>(".react-flow__node[data-id]")) {
|
|
40
|
+
const m = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(el.style.transform);
|
|
41
|
+
if (m && el.dataset.id) nodes[el.dataset.id] = { x: Number(m[1]), y: Number(m[2]) };
|
|
42
|
+
}
|
|
43
|
+
const edges = [...flow.querySelectorAll<HTMLElement>(".react-flow__edge[data-id]")].map((el) => el.dataset.id!).filter(Boolean);
|
|
44
|
+
return { nodes, edges };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The hierarchy panel's rows on screen, whether it is open, and the tab's width (the extension's tests ask). */
|
|
48
|
+
export function hierarchyReport(doc: Document = document): { open: boolean; rows: HierarchyRowReport[]; width: number } {
|
|
49
|
+
const rows = [...doc.querySelectorAll<HTMLElement>('[role="treeitem"][data-hierarchy-path]')].map((el) => ({
|
|
50
|
+
path: el.dataset.hierarchyPath ?? "",
|
|
51
|
+
label: el.getAttribute("aria-label") ?? el.textContent ?? "",
|
|
52
|
+
...(el.dataset.kind ? { kind: el.dataset.kind } : {}),
|
|
53
|
+
...(el.getAttribute("aria-selected") === "true" ? { selected: true } : {}),
|
|
54
|
+
...(el.getAttribute("aria-current") ? { current: true } : {}),
|
|
55
|
+
}));
|
|
56
|
+
return { open: Boolean(doc.querySelector('[data-hierarchy="open"]')), rows, width: doc.defaultView?.innerWidth ?? 0 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The bundle's artifact bases are named by the host the server was asked on: reach them through the URL the webview uses. */
|
|
60
|
+
export function rebase(bundle: UhdBundle & { artifactBases?: Record<string, string> }, devUrl: string): UhdBundle {
|
|
61
|
+
for (const [id, base] of Object.entries(bundle.artifactBases ?? {})) {
|
|
62
|
+
const m = /^https?:\/\/[^/]+\/(artifacts\/.*)$/.exec(base);
|
|
63
|
+
if (m) bundle.artifactBases![id] = new URL(m[1], devUrl).href;
|
|
64
|
+
}
|
|
65
|
+
return bundle;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Mount the shell in `element` with the page's config; returns the viewer's handle once the first bundle loads. */
|
|
69
|
+
export function mountVsCodeViewer(element: HTMLElement, config: WebviewConfig, vscode: VsCodeApi = acquireVsCodeApi()) {
|
|
70
|
+
const post = (m: FromWebview) => vscode.postMessage(m);
|
|
71
|
+
const showListeners = new Set<(view: string) => void>();
|
|
72
|
+
const statusListeners = new Set<() => void>();
|
|
73
|
+
let status: ViewerStatus | null = { message: "Connecting to the project…", busy: true };
|
|
74
|
+
const setStatus = (next: typeof status) => {
|
|
75
|
+
status = next;
|
|
76
|
+
for (const l of statusListeners) l();
|
|
77
|
+
};
|
|
78
|
+
// the source file of each module the extension has told the tab about (the header's path)
|
|
79
|
+
const sources = new Map<string, ModuleSource | null>(config.source ? [[config.path ?? "", config.source]] : []);
|
|
80
|
+
const sourceListeners = new Set<() => void>();
|
|
81
|
+
// where the user left each level's view: kept with the tab (the webview's own state), so it comes back
|
|
82
|
+
// when the tab is shown again or VS Code restores it; a level with none is fitted
|
|
83
|
+
const kept = (vscode.getState() ?? {}) as { viewports?: Record<string, ViewerViewport>; hierarchy?: unknown };
|
|
84
|
+
const hierarchyListeners = new Set<(command: { type: HierarchyAction; path?: string }) => void>();
|
|
85
|
+
// a command that came before the shell drew its panel goes to the panel when it does
|
|
86
|
+
let unheardHierarchy: { type: HierarchyAction; path?: string } | undefined;
|
|
87
|
+
let viewports: Record<string, ViewerViewport> = kept.viewports && typeof kept.viewports === "object" ? kept.viewports : {};
|
|
88
|
+
const bridge: ShellBridge = {
|
|
89
|
+
post,
|
|
90
|
+
source: (path) => sources.get(path),
|
|
91
|
+
onSource: (l) => (sourceListeners.add(l), () => void sourceListeners.delete(l)),
|
|
92
|
+
startView: config.view,
|
|
93
|
+
startPath: config.path ?? "",
|
|
94
|
+
...(config.params ? { startParams: config.params } : {}),
|
|
95
|
+
onShowView: (l) => (showListeners.add(l), () => void showListeners.delete(l)),
|
|
96
|
+
status: () => status,
|
|
97
|
+
onStatus: (l) => (statusListeners.add(l), () => void statusListeners.delete(l)),
|
|
98
|
+
// the hierarchy panel's open rows, width and fold stay with the tab, as its viewports do
|
|
99
|
+
hierarchyState: kept.hierarchy,
|
|
100
|
+
onHierarchyState: (state) => vscode.setState({ ...((vscode.getState() ?? {}) as object), hierarchy: state }),
|
|
101
|
+
onHierarchy: (l) => {
|
|
102
|
+
hierarchyListeners.add(l);
|
|
103
|
+
if (unheardHierarchy) {
|
|
104
|
+
const m = unheardHierarchy;
|
|
105
|
+
unheardHierarchy = undefined;
|
|
106
|
+
queueMicrotask(() => l(m));
|
|
107
|
+
}
|
|
108
|
+
return () => void hierarchyListeners.delete(l);
|
|
109
|
+
},
|
|
110
|
+
devAction: (action) => {
|
|
111
|
+
post({ type: "devAction", action });
|
|
112
|
+
// the tab tries by itself too: the extension answers with the same endpoint when the server is where it was
|
|
113
|
+
if (action === "retry") link.retry();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
let viewer: ViewerHandle | undefined;
|
|
118
|
+
let layout: ViewerLayout = config.layout ?? emptyLayout();
|
|
119
|
+
/** Our edits were saved: drop them with the rebuilt bundle that carries them. */
|
|
120
|
+
let savedPending = false;
|
|
121
|
+
const waiting: ToWebview[] = [];
|
|
122
|
+
let views: ResolvedView[] = [];
|
|
123
|
+
let partStatus: VsCodeViewerOptions["partStatus"];
|
|
124
|
+
|
|
125
|
+
const options = (): Omit<VsCodeViewerOptions, "bundle" | "bundleUrl"> => ({
|
|
126
|
+
bridge,
|
|
127
|
+
views,
|
|
128
|
+
...(partStatus ? { partStatus } : {}),
|
|
129
|
+
layout,
|
|
130
|
+
onLayoutChange: (next) => {
|
|
131
|
+
layout = next;
|
|
132
|
+
post({ type: "edit", layout: next, ...(hasEdits(next) ? { snapshot: snapshot() } : {}) });
|
|
133
|
+
},
|
|
134
|
+
onReady: () => post({ type: "event", event: { type: "uhd-viewer:ready" } }),
|
|
135
|
+
onSelectionChanged: (selection) => post({ type: "event", event: { type: "uhd-viewer:selectionChanged", selection } }),
|
|
136
|
+
onOpenSource: (r) => post({ type: "event", event: { type: "uhd-viewer:openSource", ...r } }),
|
|
137
|
+
onOpenArtifact: (r) => post({ type: "event", event: { type: "uhd-viewer:openArtifact", ...r } }),
|
|
138
|
+
onOpenModule: (r) => post({ type: "event", event: { type: "uhd-viewer:openModule", path: r.path } }),
|
|
139
|
+
// the pair view is an editor tab of its own (PB-894 §3): the extension opens it
|
|
140
|
+
onOpenPair: (r) => post({ type: "event", event: { type: "uhd-viewer:openPair", linkId: r.linkId, a: r.a, b: r.b } }),
|
|
141
|
+
// INSPECT in a module's menu: the Protoboard Inspector (this tab has no inspector of its own)
|
|
142
|
+
onInspect: (r) => post({ type: "event", event: { type: "uhd-viewer:inspect", path: r.path, ...(r.defId ? { defId: r.defId } : {}) } }),
|
|
143
|
+
viewports,
|
|
144
|
+
onViewportChange: (next) => {
|
|
145
|
+
viewports = next;
|
|
146
|
+
vscode.setState({ ...((vscode.getState() ?? {}) as object), viewports: next });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// nothing to draw until there is a bundle: the shell says why
|
|
151
|
+
viewer = renderViewer(element, options() as VsCodeViewerOptions, VsCodeShell);
|
|
152
|
+
let loaded = false;
|
|
153
|
+
|
|
154
|
+
const log = (level: "info" | "warn" | "error", message: string) => post({ type: "log", level, message });
|
|
155
|
+
const link = new DevLink({
|
|
156
|
+
fetch: (url, init) => fetch(url, init),
|
|
157
|
+
eventSource: (url) => new EventSource(url) as unknown as EventSourceLike,
|
|
158
|
+
log,
|
|
159
|
+
onStatus: setStatus,
|
|
160
|
+
onLoad: ({ base, state, bundle }) => {
|
|
161
|
+
views = Array.isArray(state.views) ? (state.views as ResolvedView[]) : [];
|
|
162
|
+
partStatus = state.partStatus && typeof state.partStatus === "object" ? (state.partStatus as VsCodeViewerOptions["partStatus"]) : undefined;
|
|
163
|
+
viewer!.update({ ...options(), bundle: rebase(bundle as UhdBundle, base), bundleUrl: new URL("uhd-bundle.json", base).href, diagnostics: state.diagnostics } as Partial<VsCodeViewerOptions>);
|
|
164
|
+
if (!loaded) {
|
|
165
|
+
loaded = true;
|
|
166
|
+
if (config.path) viewer!.drill(config.path);
|
|
167
|
+
if (config.view === "3d") viewer!.setView("3d");
|
|
168
|
+
for (const m of waiting.splice(0)) receive(m);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
onBundle: ({ base, bundle, event }) => {
|
|
172
|
+
// the display file changed (our save, a checkout, an edit by hand): edits it now holds go
|
|
173
|
+
// (`poll`: the build was found by asking, which does not say why it was made)
|
|
174
|
+
const reset = savedPending && (event.reason === "display" || event.reason === "layout" || event.reason === "poll");
|
|
175
|
+
if (reset) {
|
|
176
|
+
savedPending = false;
|
|
177
|
+
layout = emptyLayout();
|
|
178
|
+
}
|
|
179
|
+
// a rebuild says the parts' status again (a part added, an update taken)
|
|
180
|
+
if (event.partStatus && typeof event.partStatus === "object") partStatus = event.partStatus as VsCodeViewerOptions["partStatus"];
|
|
181
|
+
viewer!.update({ bundle: rebase(bundle as UhdBundle, base), diagnostics: event.diagnostics, ...(reset ? { layout } : {}), ...(partStatus ? { partStatus } : {}) } as Partial<VsCodeViewerOptions>);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
// the extension answers `ready` with where the dev server is; a page that hears nothing asks again, then says so
|
|
185
|
+
let heard = Boolean(config.dev);
|
|
186
|
+
const ask = (n: number) =>
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
if (heard) return;
|
|
189
|
+
post({ type: "ready" });
|
|
190
|
+
if (n === 2) {
|
|
191
|
+
log("warn", "the viewer tab has not been told where the dev server is (asked 3 times in 6 s)");
|
|
192
|
+
setStatus({ message: "This tab is not connected to a project.", problem: true });
|
|
193
|
+
}
|
|
194
|
+
if (n < 10) ask(n + 1);
|
|
195
|
+
}, 2000);
|
|
196
|
+
|
|
197
|
+
function receive(m: ToWebview) {
|
|
198
|
+
if (!loaded && (m.type === "command" || m.type === "showView" || m.type === "layout" || m.type === "hierarchy")) {
|
|
199
|
+
waiting.push(m);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
switch (m.type) {
|
|
203
|
+
case "dev":
|
|
204
|
+
heard = true;
|
|
205
|
+
link.set(m.dev, m.message, m.busy);
|
|
206
|
+
return;
|
|
207
|
+
case "command":
|
|
208
|
+
viewer?.post(m.command);
|
|
209
|
+
return;
|
|
210
|
+
case "showView":
|
|
211
|
+
if (m.view === "diagram" || m.view === "3d") viewer?.setView(m.view);
|
|
212
|
+
for (const l of showListeners) l(m.view);
|
|
213
|
+
return;
|
|
214
|
+
case "layout":
|
|
215
|
+
layout = m.layout;
|
|
216
|
+
viewer?.update({ layout });
|
|
217
|
+
return;
|
|
218
|
+
case "saved":
|
|
219
|
+
savedPending = true;
|
|
220
|
+
return;
|
|
221
|
+
case "source":
|
|
222
|
+
sources.set(m.path, m.source);
|
|
223
|
+
for (const l of sourceListeners) l();
|
|
224
|
+
return;
|
|
225
|
+
case "hierarchy": {
|
|
226
|
+
const command = { type: m.command, ...(m.path !== undefined ? { path: m.path } : {}) };
|
|
227
|
+
if (!hierarchyListeners.size) unheardHierarchy = command;
|
|
228
|
+
for (const l of hierarchyListeners) l(command);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
case "reportHierarchy":
|
|
232
|
+
post({ type: "hierarchy", ...hierarchyReport() });
|
|
233
|
+
return;
|
|
234
|
+
case "reportTheme": {
|
|
235
|
+
const style = document.documentElement.style;
|
|
236
|
+
const vars: Record<string, string> = {};
|
|
237
|
+
for (let i = 0; i < style.length; i++) {
|
|
238
|
+
const k = style[i];
|
|
239
|
+
if (k.startsWith("--vscode-")) vars[k] = style.getPropertyValue(k).trim();
|
|
240
|
+
}
|
|
241
|
+
post({ type: "theme", vars, classes: [...document.body.classList], themeId: document.body.dataset.vscodeThemeId });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
window.addEventListener("message", (e) => {
|
|
248
|
+
const m = e.data as ToWebview | undefined;
|
|
249
|
+
if (m && typeof m === "object" && typeof m.type === "string") receive(m);
|
|
250
|
+
});
|
|
251
|
+
window.addEventListener("online", () => log("info", "the browser reports the network is back"));
|
|
252
|
+
window.addEventListener("offline", () => log("warn", "the browser reports the network is gone (loopback connections are closed with it)"));
|
|
253
|
+
if (config.dev) link.set(config.dev);
|
|
254
|
+
post({ type: "ready" });
|
|
255
|
+
ask(0);
|
|
256
|
+
return { get viewer() { return viewer; }, receive, link };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// the page: config in a JSON script, the shell in #viewer
|
|
260
|
+
if (typeof document !== "undefined" && document.getElementById("protoboard-viewer-config")) {
|
|
261
|
+
const config = JSON.parse(document.getElementById("protoboard-viewer-config")!.textContent!) as WebviewConfig;
|
|
262
|
+
(window as unknown as { protoboardViewer: unknown }).protoboardViewer = mountVsCodeViewer(document.getElementById("viewer")!, config);
|
|
263
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The messages between the VS Code extension and its viewer webview (the
|
|
3
|
+
* VS Code shell, ./Shell.tsx). One viewer editor tab is one webview showing
|
|
4
|
+
* one module of a project; the bundle and live rebuilds come from the
|
|
5
|
+
* project's `protoboard dev` server, everything else (selection, sources,
|
|
6
|
+
* artifacts, new tabs, layout saving) goes through the extension.
|
|
7
|
+
*
|
|
8
|
+
* No React and no VS Code API here: the extension imports this file too.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The viewer's own host messages, as the extension sends them (uhd-viewer's protocol). */
|
|
12
|
+
export type ViewerCommand =
|
|
13
|
+
| { type: "uhd-viewer:focus"; path: string }
|
|
14
|
+
| { type: "uhd-viewer:select"; ids: string[]; reveal?: boolean }
|
|
15
|
+
| { type: "uhd-viewer:drill"; path: string }
|
|
16
|
+
| { type: "uhd-viewer:setView"; view: "diagram" | "3d" };
|
|
17
|
+
|
|
18
|
+
export interface ViewerSelection {
|
|
19
|
+
kind: "node" | "link";
|
|
20
|
+
id: string;
|
|
21
|
+
defId?: string;
|
|
22
|
+
/** Modules selected together (two or more, paths from the root); `id` is the first (QA B10). */
|
|
23
|
+
ids?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** As in uhd-viewer's host contract (`LinkLayout`). */
|
|
27
|
+
export interface LinkLayout {
|
|
28
|
+
waypoints?: [number, number][];
|
|
29
|
+
labelT?: number;
|
|
30
|
+
labelFlip?: boolean;
|
|
31
|
+
tabT?: number;
|
|
32
|
+
tabFlip?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** As in uhd-viewer's host contract (`ViewerLayout`): both maps keyed by drill level ("" = top). */
|
|
36
|
+
export interface ViewerLayout {
|
|
37
|
+
positions: Record<string, Record<string, { x: number; y: number }>>;
|
|
38
|
+
links: Record<string, Record<string, LinkLayout>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where every node and edge of the diagram on screen is drawn (as the dev server's layout route takes it). */
|
|
42
|
+
export interface LayoutSnapshot {
|
|
43
|
+
nodes: Record<string, { x: number; y: number }>;
|
|
44
|
+
edges: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the page starts with (a JSON script in the page). */
|
|
48
|
+
export interface WebviewConfig {
|
|
49
|
+
/** The module the tab shows: its instance path ("" is the root system). */
|
|
50
|
+
path: string;
|
|
51
|
+
/** The view the tab opens on (`diagram`, `3d`, or a registry view id). */
|
|
52
|
+
view: string;
|
|
53
|
+
/** The extension's media folder as the webview reaches it, no trailing slash. */
|
|
54
|
+
media: string;
|
|
55
|
+
/** The dev server, when it is already known. */
|
|
56
|
+
dev?: DevEndpoint;
|
|
57
|
+
/** A layout the tab had (a restored backup or unsaved edits). */
|
|
58
|
+
layout?: ViewerLayout;
|
|
59
|
+
/** What the view was opened with (the pair view's `link`, `a`, `b`), as `ViewProps.params`. */
|
|
60
|
+
params?: Record<string, string>;
|
|
61
|
+
/** The source file of the module the tab opens on, when the extension knows it already. */
|
|
62
|
+
source?: ModuleSource;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The file a module is written in: project-relative, with the line of its definition when the build records it. */
|
|
66
|
+
export interface ModuleSource {
|
|
67
|
+
file: string;
|
|
68
|
+
line?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A view of the diagram, as uhd-viewer's host contract has it (`ViewerViewport`). */
|
|
72
|
+
export interface ViewerViewport {
|
|
73
|
+
x: number;
|
|
74
|
+
y: number;
|
|
75
|
+
zoom: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DevEndpoint {
|
|
79
|
+
/** The dev server's page URL as the webview reaches it, with a trailing slash. */
|
|
80
|
+
url: string;
|
|
81
|
+
session: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extension → webview. */
|
|
85
|
+
export type ToWebview =
|
|
86
|
+
/** The dev server to load from (`null`: there is none; `message` says why). */
|
|
87
|
+
| { type: "dev"; dev: DevEndpoint | null; message?: string; busy?: boolean }
|
|
88
|
+
/** A host message for the viewer (focus, select, drill, setView). */
|
|
89
|
+
| { type: "command"; command: ViewerCommand }
|
|
90
|
+
/** Show a view of the tab's registry (`diagram`, `3d`, `schematic`, …). */
|
|
91
|
+
| { type: "showView"; view: string }
|
|
92
|
+
/** Replace the viewer's layout edits (undo, redo, revert). */
|
|
93
|
+
| { type: "layout"; layout: ViewerLayout }
|
|
94
|
+
/** The edits were written to the display file: drop them once the rebuilt bundle carries them. */
|
|
95
|
+
| { type: "saved" }
|
|
96
|
+
/** The source file of the module the tab shows now (`null`: none is recorded), for the header's path. */
|
|
97
|
+
| { type: "source"; path: string; source: ModuleSource | null }
|
|
98
|
+
/** Test hook: report the page's `--vscode-*` variables and body classes. */
|
|
99
|
+
| { type: "reportTheme" }
|
|
100
|
+
/**
|
|
101
|
+
* The tab's hierarchy panel (PB-1042): show a module's row (`reveal`, with its
|
|
102
|
+
* instance path), put the keyboard in its filter, open or fold it.
|
|
103
|
+
*/
|
|
104
|
+
| { type: "hierarchy"; command: HierarchyAction; path?: string }
|
|
105
|
+
/** Test hook: report the hierarchy panel's rows. */
|
|
106
|
+
| { type: "reportHierarchy" };
|
|
107
|
+
|
|
108
|
+
export type HierarchyAction = "reveal" | "filter" | "open" | "close" | "toggle";
|
|
109
|
+
|
|
110
|
+
/** One row of a tab's hierarchy panel, as the page reports it (tests). */
|
|
111
|
+
export interface HierarchyRowReport {
|
|
112
|
+
path: string;
|
|
113
|
+
label: string;
|
|
114
|
+
kind?: string;
|
|
115
|
+
selected?: boolean;
|
|
116
|
+
current?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a hierarchy row tells VS Code for its right-click menu (`data-vscode-context`;
|
|
121
|
+
* the manifest's `webview/context` menus read it, and the commands get it as their argument).
|
|
122
|
+
*/
|
|
123
|
+
export interface HierarchyRowContext {
|
|
124
|
+
webviewSection: "hierarchy";
|
|
125
|
+
/** The module's instance path ("" is the root). */
|
|
126
|
+
pbPath: string;
|
|
127
|
+
pbKind: string;
|
|
128
|
+
pbDefId: string;
|
|
129
|
+
/** It has a level of its own (Open in New Tab). */
|
|
130
|
+
pbLevel: boolean;
|
|
131
|
+
/** Its definition lists artifacts. */
|
|
132
|
+
pbArtifacts: boolean;
|
|
133
|
+
preventDefaultContextMenuItems: true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A command's argument when it comes from a hierarchy row's menu: the row's context with the webview's id; anything else is undefined. */
|
|
137
|
+
export function hierarchyRowArg(v: unknown): { path: string; defId?: string } | undefined {
|
|
138
|
+
if (!obj(v) || v.webviewSection !== "hierarchy" || !str(v.pbPath)) return undefined;
|
|
139
|
+
return { path: v.pbPath, ...(str(v.pbDefId) && v.pbDefId ? { defId: v.pbDefId } : {}) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Webview → extension. */
|
|
143
|
+
export type FromWebview =
|
|
144
|
+
/** The page runs. The extension answers every one with where the dev server is (`dev`): a page that loaded again asks again. */
|
|
145
|
+
| { type: "ready" }
|
|
146
|
+
/** A line for the extension's log (Protoboard: Show Log): what the page tried to reach and the error it got. */
|
|
147
|
+
| { type: "log"; level: "info" | "warn" | "error"; message: string }
|
|
148
|
+
/** The buttons of the tab's dev server message: find or start the server again, reload the window, show the log. */
|
|
149
|
+
| { type: "devAction"; action: DevAction }
|
|
150
|
+
/** The viewer's events: ready, selectionChanged, openSource, openArtifact, openModule, openPair. */
|
|
151
|
+
| { type: "event"; event: ViewerEvent }
|
|
152
|
+
/** What the tab shows now: the level, its label, the view, how many nodes the diagram draws. */
|
|
153
|
+
| {
|
|
154
|
+
type: "state";
|
|
155
|
+
path: string;
|
|
156
|
+
label: string;
|
|
157
|
+
view: string;
|
|
158
|
+
nodes: number;
|
|
159
|
+
views: { id: string; title: string }[];
|
|
160
|
+
error?: string;
|
|
161
|
+
/** Definitions the bundle names and does not hold (drawn as placeholders). */
|
|
162
|
+
missing?: number;
|
|
163
|
+
/** The module the tab is on when it is not the level shown: a part with nothing inside, opened as a tab (its `label` is the part's). */
|
|
164
|
+
at?: string;
|
|
165
|
+
}
|
|
166
|
+
/** The layout edits changed (`dirty`: edits beyond what the display file holds). */
|
|
167
|
+
| { type: "edit"; layout: ViewerLayout; snapshot?: LayoutSnapshot }
|
|
168
|
+
/** Open a view of a module as its own editor tab (`params` as the view takes them). */
|
|
169
|
+
| { type: "openView"; view: string; path: string; params?: Record<string, string> }
|
|
170
|
+
| { type: "notify"; message: string; severity?: "info" | "warning" | "error" }
|
|
171
|
+
/** The header's path of the module in view: open its file, show it in the Explorer, or copy its path. */
|
|
172
|
+
| { type: "source"; action: SourceAction }
|
|
173
|
+
| { type: "theme"; vars: Record<string, string>; classes: string[]; themeId?: string }
|
|
174
|
+
/** The hierarchy panel's rows (`reportHierarchy`), whether it is open, and the tab's width in CSS pixels. */
|
|
175
|
+
| { type: "hierarchy"; open: boolean; rows: HierarchyRowReport[]; width?: number };
|
|
176
|
+
|
|
177
|
+
export type SourceAction = "open" | "reveal" | "copy" | "copyRelative";
|
|
178
|
+
const SOURCE_ACTIONS: readonly SourceAction[] = ["open", "reveal", "copy", "copyRelative"];
|
|
179
|
+
|
|
180
|
+
/** `signIn`, `signInDevice`, `signOut` and `checkAccount` are account buttons a page may show (Fetch Parts asks for a sign-in). */
|
|
181
|
+
export type DevAction = "retry" | "reload" | "showLog" | "signIn" | "signInDevice" | "signOut" | "checkAccount" | "fetchParts";
|
|
182
|
+
const DEV_ACTIONS: readonly DevAction[] = ["retry", "reload", "showLog", "signIn", "signInDevice", "signOut", "checkAccount", "fetchParts"];
|
|
183
|
+
|
|
184
|
+
export type ViewerEvent =
|
|
185
|
+
| { type: "uhd-viewer:ready" }
|
|
186
|
+
| { type: "uhd-viewer:selectionChanged"; selection: ViewerSelection | null }
|
|
187
|
+
| { type: "uhd-viewer:openSource"; defId: string; instancePath?: string }
|
|
188
|
+
| { type: "uhd-viewer:openArtifact"; defId: string; artifactId: string }
|
|
189
|
+
| { type: "uhd-viewer:openModule"; path: string }
|
|
190
|
+
| { type: "uhd-viewer:openPair"; linkId: string; a: string; b: string }
|
|
191
|
+
/** INSPECT in a module's context menu: the Protoboard Inspector, in front and focused. */
|
|
192
|
+
| { type: "uhd-viewer:inspect"; path: string; defId?: string };
|
|
193
|
+
|
|
194
|
+
const str = (v: unknown): v is string => typeof v === "string";
|
|
195
|
+
const obj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
196
|
+
|
|
197
|
+
/** An empty layout: no edits. */
|
|
198
|
+
export const emptyLayout = (): ViewerLayout => ({ positions: {}, links: {} });
|
|
199
|
+
|
|
200
|
+
/** A layout holds edits when any level has a moved node or an edited link. */
|
|
201
|
+
export function hasEdits(layout: ViewerLayout | undefined): boolean {
|
|
202
|
+
if (!layout) return false;
|
|
203
|
+
return Object.values(layout.positions ?? {}).some((l) => Object.keys(l ?? {}).length > 0) || Object.values(layout.links ?? {}).some((l) => Object.keys(l ?? {}).length > 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseLayout(v: unknown): ViewerLayout | undefined {
|
|
207
|
+
if (!obj(v) || !obj(v.positions) || !obj(v.links)) return undefined;
|
|
208
|
+
for (const level of Object.values(v.positions)) {
|
|
209
|
+
if (!obj(level)) return undefined;
|
|
210
|
+
for (const p of Object.values(level)) if (!obj(p) || typeof p.x !== "number" || typeof p.y !== "number") return undefined;
|
|
211
|
+
}
|
|
212
|
+
for (const level of Object.values(v.links)) if (!obj(level) || !Object.values(level).every(obj)) return undefined;
|
|
213
|
+
return v as unknown as ViewerLayout;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseSnapshot(v: unknown): LayoutSnapshot | undefined {
|
|
217
|
+
if (!obj(v) || !obj(v.nodes) || !Array.isArray(v.edges) || !v.edges.every(str)) return undefined;
|
|
218
|
+
for (const p of Object.values(v.nodes)) if (!obj(p) || typeof p.x !== "number" || typeof p.y !== "number") return undefined;
|
|
219
|
+
return v as unknown as LayoutSnapshot;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A view's params: a few short strings by name, nothing else. */
|
|
223
|
+
export function parseParams(v: unknown): Record<string, string> | undefined {
|
|
224
|
+
if (!obj(v)) return undefined;
|
|
225
|
+
const out: Record<string, string> = {};
|
|
226
|
+
for (const [k, x] of Object.entries(v)) if (/^[a-z][a-zA-Z0-9]{0,31}$/.test(k) && str(x) && x.length <= 512) out[k] = x;
|
|
227
|
+
return Object.keys(out).length && Object.keys(out).length <= 8 ? out : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseEvent(v: unknown): ViewerEvent | undefined {
|
|
231
|
+
if (!obj(v)) return undefined;
|
|
232
|
+
switch (v.type) {
|
|
233
|
+
case "uhd-viewer:ready":
|
|
234
|
+
return { type: v.type };
|
|
235
|
+
case "uhd-viewer:selectionChanged": {
|
|
236
|
+
const s = v.selection;
|
|
237
|
+
if (s === null || s === undefined) return { type: v.type, selection: null };
|
|
238
|
+
if (!obj(s) || (s.kind !== "node" && s.kind !== "link") || !str(s.id)) return undefined;
|
|
239
|
+
const ids = Array.isArray(s.ids) ? s.ids.filter(str).slice(0, 500) : [];
|
|
240
|
+
return { type: v.type, selection: { kind: s.kind, id: s.id, ...(str(s.defId) ? { defId: s.defId } : {}), ...(ids.length > 1 ? { ids } : {}) } };
|
|
241
|
+
}
|
|
242
|
+
case "uhd-viewer:openSource":
|
|
243
|
+
return str(v.defId) ? { type: v.type, defId: v.defId, ...(str(v.instancePath) ? { instancePath: v.instancePath } : {}) } : undefined;
|
|
244
|
+
case "uhd-viewer:openArtifact":
|
|
245
|
+
return str(v.defId) && str(v.artifactId) ? { type: v.type, defId: v.defId, artifactId: v.artifactId } : undefined;
|
|
246
|
+
case "uhd-viewer:openModule":
|
|
247
|
+
return str(v.path) ? { type: v.type, path: v.path } : undefined;
|
|
248
|
+
case "uhd-viewer:openPair":
|
|
249
|
+
return str(v.linkId) && str(v.a) && str(v.b) ? { type: v.type, linkId: v.linkId, a: v.a, b: v.b } : undefined;
|
|
250
|
+
case "uhd-viewer:inspect":
|
|
251
|
+
return str(v.path) ? { type: v.type, path: v.path, ...(str(v.defId) ? { defId: v.defId } : {}) } : undefined;
|
|
252
|
+
default:
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A message from the webview, checked field by field; anything else is undefined. */
|
|
258
|
+
export function parseFromWebview(data: unknown): FromWebview | undefined {
|
|
259
|
+
if (!obj(data)) return undefined;
|
|
260
|
+
switch (data.type) {
|
|
261
|
+
case "ready":
|
|
262
|
+
return { type: "ready" };
|
|
263
|
+
case "log": {
|
|
264
|
+
if (!str(data.message)) return undefined;
|
|
265
|
+
const level = data.level === "warn" || data.level === "error" ? data.level : "info";
|
|
266
|
+
return { type: "log", level, message: data.message.slice(0, 2000) };
|
|
267
|
+
}
|
|
268
|
+
case "devAction":
|
|
269
|
+
return (DEV_ACTIONS as readonly unknown[]).includes(data.action) ? { type: "devAction", action: data.action as DevAction } : undefined;
|
|
270
|
+
case "event": {
|
|
271
|
+
const event = parseEvent(data.event);
|
|
272
|
+
return event && { type: "event", event };
|
|
273
|
+
}
|
|
274
|
+
case "state": {
|
|
275
|
+
if (!str(data.path) || !str(data.label) || !str(data.view) || typeof data.nodes !== "number") return undefined;
|
|
276
|
+
const views = Array.isArray(data.views) ? data.views.filter((v): v is { id: string; title: string } => obj(v) && str(v.id) && str(v.title)).map((v) => ({ id: v.id, title: v.title })) : [];
|
|
277
|
+
return { type: "state", path: data.path, label: data.label, view: data.view, nodes: data.nodes, views, ...(str(data.error) ? { error: data.error } : {}), ...(typeof data.missing === "number" && data.missing > 0 ? { missing: data.missing } : {}), ...(str(data.at) && data.at ? { at: data.at } : {}) };
|
|
278
|
+
}
|
|
279
|
+
case "edit": {
|
|
280
|
+
const layout = parseLayout(data.layout);
|
|
281
|
+
if (!layout) return undefined;
|
|
282
|
+
const snapshot = data.snapshot === undefined ? undefined : parseSnapshot(data.snapshot);
|
|
283
|
+
return { type: "edit", layout, ...(snapshot ? { snapshot } : {}) };
|
|
284
|
+
}
|
|
285
|
+
case "openView": {
|
|
286
|
+
if (!str(data.view) || !str(data.path)) return undefined;
|
|
287
|
+
const params = parseParams(data.params);
|
|
288
|
+
return { type: "openView", view: data.view, path: data.path, ...(params ? { params } : {}) };
|
|
289
|
+
}
|
|
290
|
+
case "notify": {
|
|
291
|
+
if (!str(data.message)) return undefined;
|
|
292
|
+
const severity = data.severity === "warning" || data.severity === "error" || data.severity === "info" ? data.severity : undefined;
|
|
293
|
+
return { type: "notify", message: data.message, ...(severity ? { severity } : {}) };
|
|
294
|
+
}
|
|
295
|
+
case "source":
|
|
296
|
+
return (SOURCE_ACTIONS as readonly unknown[]).includes(data.action) ? { type: "source", action: data.action as SourceAction } : undefined;
|
|
297
|
+
case "theme": {
|
|
298
|
+
if (!obj(data.vars) || !Array.isArray(data.classes)) return undefined;
|
|
299
|
+
const vars: Record<string, string> = {};
|
|
300
|
+
for (const [k, v] of Object.entries(data.vars)) if (str(v) && k.startsWith("--vscode-")) vars[k] = v;
|
|
301
|
+
return { type: "theme", vars, classes: data.classes.filter(str), ...(str(data.themeId) ? { themeId: data.themeId } : {}) };
|
|
302
|
+
}
|
|
303
|
+
case "hierarchy": {
|
|
304
|
+
if (typeof data.open !== "boolean" || !Array.isArray(data.rows)) return undefined;
|
|
305
|
+
const rows = data.rows
|
|
306
|
+
.filter((r): r is Record<string, unknown> => obj(r) && str(r.path) && str(r.label))
|
|
307
|
+
.slice(0, 5000)
|
|
308
|
+
.map((r) => ({ path: r.path as string, label: r.label as string, ...(str(r.kind) ? { kind: r.kind } : {}), ...(r.selected === true ? { selected: true } : {}), ...(r.current === true ? { current: true } : {}) }));
|
|
309
|
+
return { type: "hierarchy", open: data.open, rows, ...(typeof data.width === "number" && Number.isFinite(data.width) ? { width: data.width } : {}) };
|
|
310
|
+
}
|
|
311
|
+
default:
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* The VS Code shell's stylesheet: Tailwind 4 utilities over the viewer's token
|
|
2
|
+
* theme, no Preflight (the viewer's rules set their own base). */
|
|
3
|
+
@layer theme, base, components, utilities;
|
|
4
|
+
@import "tailwindcss/theme.css" layer(theme);
|
|
5
|
+
@import "tailwindcss/utilities.css" layer(utilities);
|
|
6
|
+
@import "@deltarobotics/uhd-viewer/tailwind-theme.css";
|
|
7
|
+
@source "./";
|
|
8
|
+
@source "../Logo.tsx";
|
|
9
|
+
@source "../../../views/src";
|
|
10
|
+
|
|
11
|
+
/* Outside the layers on purpose: VS Code puts its own default webview styles
|
|
12
|
+
* (body padding, scrollbars) unlayered at the top of the page, and these must win. */
|
|
13
|
+
html,
|
|
14
|
+
body {
|
|
15
|
+
height: 100%;
|
|
16
|
+
margin: 0;
|
|
17
|
+
padding: 0;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
background: var(--vscode-editor-background);
|
|
20
|
+
scrollbar-color: auto;
|
|
21
|
+
}
|
|
22
|
+
#viewer {
|
|
23
|
+
height: 100vh;
|
|
24
|
+
}
|
|
25
|
+
/* the shell's root fills the tab */
|
|
26
|
+
#viewer > .uhd-root {
|
|
27
|
+
height: 100%;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* an editor tab is no place for a link out of VS Code */
|
|
31
|
+
.pb-vscode .react-flow__attribution {
|
|
32
|
+
display: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* the dev server message's main button: VS Code's own button colours (orange only under a Protoboard theme, which sets them) */
|
|
36
|
+
.pb-vscode .pb-dev-primary {
|
|
37
|
+
background: var(--vscode-button-background);
|
|
38
|
+
color: var(--vscode-button-foreground);
|
|
39
|
+
border-color: var(--vscode-button-border, transparent);
|
|
40
|
+
}
|
|
41
|
+
.pb-vscode .pb-dev-primary:hover {
|
|
42
|
+
background: var(--vscode-button-hoverBackground, var(--vscode-button-background));
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** No React and no VS Code API here: the extension's tests import this file too. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The module a tab is on, for its name, its title and its source file: the
|
|
5
|
+
* level the viewer shows, or, for a tab opened on a part with nothing inside
|
|
6
|
+
* (which has no level of its own, so the viewer shows the level that holds
|
|
7
|
+
* it), that part. A tab is never named after the parent of what it was
|
|
8
|
+
* opened on.
|
|
9
|
+
*/
|
|
10
|
+
export function tabModule(drill: readonly string[], startPath: string, levelLabel: string, nodes: readonly { id: string; label: string }[]): { path: string; label: string; part: boolean } {
|
|
11
|
+
const level = drill.join("/");
|
|
12
|
+
const start = startPath ? startPath.split("/") : [];
|
|
13
|
+
const below = start.length > drill.length && drill.every((d, i) => start[i] === d);
|
|
14
|
+
if (!below) return { path: level, label: levelLabel, part: false };
|
|
15
|
+
const local = start.slice(drill.length).join("/");
|
|
16
|
+
const node = nodes.find((n) => n.id === local);
|
|
17
|
+
return { path: startPath, label: node?.label ?? start[start.length - 1], part: true };
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a viewer tab's view tabs fit its header (B8), from the widths the
|
|
3
|
+
* shell measures: all with their titles, all as icons, or the first few as
|
|
4
|
+
* icons and a More menu for the rest. No DOM here: the tests call it.
|
|
5
|
+
*/
|
|
6
|
+
export function fitTabs(full: number[], compact: number[], avail: number, activeIndex: number, more = 34, gap = 2): { compact: boolean; shown: number[] } {
|
|
7
|
+
const total = (ws: number[]) => ws.reduce((a, w) => a + w, 0) + gap * Math.max(0, ws.length - 1);
|
|
8
|
+
const all = full.map((_, i) => i);
|
|
9
|
+
if (total(full) <= avail) return { compact: false, shown: all };
|
|
10
|
+
if (total(compact) <= avail) return { compact: true, shown: all };
|
|
11
|
+
const shown: number[] = [];
|
|
12
|
+
let used = more;
|
|
13
|
+
for (const i of all) {
|
|
14
|
+
if (used + compact[i] + gap > avail) break;
|
|
15
|
+
shown.push(i);
|
|
16
|
+
used += compact[i] + gap;
|
|
17
|
+
}
|
|
18
|
+
// the active tab is always one of those shown: it takes the last place
|
|
19
|
+
if (activeIndex >= 0 && !shown.includes(activeIndex)) {
|
|
20
|
+
if (shown.length) shown.pop();
|
|
21
|
+
shown.push(activeIndex);
|
|
22
|
+
shown.sort((a, b) => a - b);
|
|
23
|
+
}
|
|
24
|
+
return { compact: true, shown };
|
|
25
|
+
}
|
|
26
|
+
|