@protoboardai/views 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 +13 -0
- package/README.md +21 -2
- package/package.json +46 -4
- package/src/applies.ts +54 -0
- package/src/builtins.tsx +89 -0
- package/src/drawing/BoardDrawingView.tsx +515 -0
- package/src/drawing/CircuitPcbView.tsx +494 -0
- package/src/drawing/DrawingControls.tsx +39 -0
- package/src/drawing/boards.ts +82 -0
- package/src/drawing/circuit.ts +413 -0
- package/src/drawing/kicadSchematic.ts +277 -0
- package/src/drawing/palettes.ts +150 -0
- package/src/drawing/svg.ts +445 -0
- package/src/drawing/usePanZoom.ts +400 -0
- package/src/icons.tsx +69 -0
- package/src/index.ts +33 -0
- package/src/inspector/DrcPanel.tsx +302 -0
- package/src/inspector/SelectionInspector.tsx +392 -0
- package/src/inspector/index.ts +9 -0
- package/src/inspector/model.ts +224 -0
- package/src/pair/PairView.tsx +314 -0
- package/src/pair/model.ts +206 -0
- package/src/parts/BomEditor.tsx +564 -0
- package/src/parts/CardPages.tsx +317 -0
- package/src/parts/CategoryList.tsx +72 -0
- package/src/parts/CloudPanel.tsx +160 -0
- package/src/parts/GlbPreview.tsx +116 -0
- package/src/parts/LibrariesManager.tsx +251 -0
- package/src/parts/LibraryBrowser.tsx +191 -0
- package/src/parts/LibraryPage.tsx +168 -0
- package/src/parts/PartCard.tsx +274 -0
- package/src/parts/PartDetail.tsx +613 -0
- package/src/parts/PartStatus.tsx +54 -0
- package/src/parts/Prices.tsx +241 -0
- package/src/parts/ProjectParts.tsx +174 -0
- package/src/parts/ProjectPartsPage.tsx +264 -0
- package/src/parts/SettingsPage.tsx +268 -0
- package/src/parts/Surface.tsx +51 -0
- package/src/parts/Windowed.tsx +70 -0
- package/src/parts/carousel.tsx +344 -0
- package/src/parts/index.ts +32 -0
- package/src/parts/library-data.ts +726 -0
- package/src/parts/matte.ts +69 -0
- package/src/parts/model.ts +590 -0
- package/src/parts/part-pages.ts +379 -0
- package/src/parts/part-status.ts +88 -0
- package/src/parts/revision.ts +11 -0
- package/src/parts/three.d.ts +5 -0
- package/src/parts/ui.tsx +335 -0
- package/src/parts/views.tsx +433 -0
- package/src/registry.ts +107 -0
- package/src/tabs.ts +118 -0
- package/src/theme.ts +225 -0
- package/src/types.ts +93 -0
- package/src/ui/Select.tsx +99 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `library` and `bom` view tabs (registry views, PB-902, PB-904), for
|
|
3
|
+
* shells that draw the registry's views themselves (the web shell). Their
|
|
4
|
+
* data comes from the shell through `PartsDataProvider` (the web shell asks
|
|
5
|
+
* `protoboard dev`); VS Code draws the same components in native slots and
|
|
6
|
+
* editor tabs instead.
|
|
7
|
+
*/
|
|
8
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
9
|
+
import { EmptyState, Icon, IconButton, cx } from "@deltarobotics/uhd-viewer/components";
|
|
10
|
+
import type { ViewProps } from "../types.js";
|
|
11
|
+
import { bomCsv } from "@protoboardai/core/bom";
|
|
12
|
+
import { BomEditor } from "./BomEditor.js";
|
|
13
|
+
import { EMPTY_QUERY, sortLocally } from "./library-data.js";
|
|
14
|
+
import { LibraryPage } from "./LibraryPage.js";
|
|
15
|
+
import type { BomModel, LibraryModel, LibraryQuery, PartDetailModel, PartDetailTab, PartPages, PartPagesRef, PartPricesModel, ProjectPartsModel } from "./model.js";
|
|
16
|
+
import { PartDetail } from "./PartDetail.js";
|
|
17
|
+
import { ProjectPartsPage } from "./ProjectPartsPage.js";
|
|
18
|
+
import { rev } from "./revision.js";
|
|
19
|
+
import { PartsSurface } from "./Surface.js";
|
|
20
|
+
|
|
21
|
+
/** A library search's page: the model without the view's own loading state, and the cursor of the next page. */
|
|
22
|
+
export interface LibraryPageResult {
|
|
23
|
+
model: Omit<LibraryModel, "loading" | "loadingMore">;
|
|
24
|
+
cursor?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a shell offers the parts views. Every method may fail; the views show why. */
|
|
28
|
+
export interface PartsData {
|
|
29
|
+
search(query: LibraryQuery, cursor?: string): Promise<LibraryPageResult>;
|
|
30
|
+
part(key: string, revision?: number): Promise<PartDetailModel>;
|
|
31
|
+
/** A part's pages, for the cards' text carousel (PB-918). */
|
|
32
|
+
pages?(ref: PartPagesRef): Promise<PartPages>;
|
|
33
|
+
/** A part's prices by source, from its library. */
|
|
34
|
+
prices?(key: string): Promise<PartPricesModel>;
|
|
35
|
+
/** Add (or update, or re-pin) a part in the project; the message says what happened. */
|
|
36
|
+
vendor?(key: string, kind: "add" | "update" | "switch", revision: number): Promise<string>;
|
|
37
|
+
projectParts?(check?: boolean): Promise<ProjectPartsModel>;
|
|
38
|
+
bom?(): Promise<BomModel>;
|
|
39
|
+
/** Keep the units to build, or the offer chosen for a line (`null`: the best offer again), in the project. */
|
|
40
|
+
saveBom?(change: { units: number } | { line: string; offer: string | null }): Promise<void>;
|
|
41
|
+
/** Ask the library for its current offers for the project's parts (parts/offers.json); the message says what came back. */
|
|
42
|
+
refreshOffers?(): Promise<string>;
|
|
43
|
+
openLink?(url: string): void;
|
|
44
|
+
/** Where a part's definition is written, opened by the host. */
|
|
45
|
+
openSource?(file: string, line?: number): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const Ctx = createContext<PartsData | null>(null);
|
|
49
|
+
|
|
50
|
+
export function PartsDataProvider({ data, children }: { data: PartsData | null; children: ReactNode }) {
|
|
51
|
+
return <Ctx.Provider value={data}>{children}</Ctx.Provider>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const usePartsData = () => useContext(Ctx);
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
/** The library's state over a `PartsData`: the query, the pages loaded, and the next one. */
|
|
58
|
+
export function useLibrary(data: PartsData | null) {
|
|
59
|
+
const [query, setQuery] = useState<LibraryQuery>(EMPTY_QUERY);
|
|
60
|
+
const [model, setModel] = useState<LibraryModel>({ query: EMPTY_QUERY, crumbs: [], categories: [], level: { path: "", name: "All categories", count: 0 }, sort: { value: "name", options: ["name", "manufacturer"], local: true }, topCategories: [], total: 0, libraries: [], items: [], more: false, loading: true });
|
|
61
|
+
const cursor = useRef<string | undefined>(undefined);
|
|
62
|
+
const seq = useRef(0);
|
|
63
|
+
const load = useCallback(
|
|
64
|
+
async (q: LibraryQuery) => {
|
|
65
|
+
if (!data) return;
|
|
66
|
+
const n = ++seq.current;
|
|
67
|
+
setModel((m) => ({ ...m, query: q, loading: true, error: undefined }));
|
|
68
|
+
try {
|
|
69
|
+
const r = await data.search(q);
|
|
70
|
+
if (n !== seq.current) return;
|
|
71
|
+
cursor.current = r.cursor;
|
|
72
|
+
setModel({ ...r.model, loading: false });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (n === seq.current) setModel((m) => ({ ...m, loading: false, items: [], total: 0, error: (e as Error).message }));
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
[data],
|
|
78
|
+
);
|
|
79
|
+
useEffect(() => void load(query), [load, query]);
|
|
80
|
+
const loadMore = useCallback(async () => {
|
|
81
|
+
if (!data || !cursor.current) return;
|
|
82
|
+
const n = seq.current;
|
|
83
|
+
setModel((m) => (m.loadingMore ? m : { ...m, loadingMore: true }));
|
|
84
|
+
try {
|
|
85
|
+
const r = await data.search(query, cursor.current);
|
|
86
|
+
if (n !== seq.current) return;
|
|
87
|
+
cursor.current = r.cursor;
|
|
88
|
+
// an order made here covers every part loaded, so the new page is ordered in with the rest
|
|
89
|
+
setModel((m) => {
|
|
90
|
+
const items = [...m.items, ...r.model.items.filter((x) => !m.items.some((y) => y.key === x.key))];
|
|
91
|
+
return { ...m, items: m.sort.local ? sortLocally(items, m.sort.value) : items, more: r.model.more, loadingMore: false };
|
|
92
|
+
});
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (n === seq.current) setModel((m) => ({ ...m, loadingMore: false, error: (e as Error).message }));
|
|
95
|
+
}
|
|
96
|
+
}, [data, query]);
|
|
97
|
+
return { model, query, setQuery: (q: Partial<LibraryQuery>) => setQuery((old) => ({ ...old, ...q })), reload: () => load(query), loadMore };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Unavailable({ what }: { what: string }) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="grid size-full place-items-center bg-canvas">
|
|
103
|
+
<EmptyState icon={<Icon name="plug-zap" size={26} strokeWidth={1.25} />} title={`No ${what} here`}>
|
|
104
|
+
This page was opened without the project's parts service; run <code>protoboard dev</code> on the project.
|
|
105
|
+
</EmptyState>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The `library` tab: the full-page library, a part's detail in place of the grid, and the project's parts. */
|
|
111
|
+
export function LibraryView(_: ViewProps) {
|
|
112
|
+
const data = usePartsData();
|
|
113
|
+
const lib = useLibrary(data);
|
|
114
|
+
const [mode, setMode] = useState<"browse" | "project">("browse");
|
|
115
|
+
const [open, setOpen] = useState<{ key: string; tab?: PartDetailTab; revision?: number } | null>(null);
|
|
116
|
+
const [detail, setDetail] = useState<PartDetailModel | { error: string } | null>(null);
|
|
117
|
+
const [project, setProject] = useState<ProjectPartsModel | null>(null);
|
|
118
|
+
const [note, setNote] = useState<string | null>(null);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!data || !open) return setDetail(null);
|
|
121
|
+
let live = true;
|
|
122
|
+
setDetail(null);
|
|
123
|
+
data.part(open.key, open.revision).then(
|
|
124
|
+
(d) => live && setDetail(d),
|
|
125
|
+
(e: Error) => live && setDetail({ error: e.message }),
|
|
126
|
+
);
|
|
127
|
+
return () => {
|
|
128
|
+
live = false;
|
|
129
|
+
};
|
|
130
|
+
}, [data, open]);
|
|
131
|
+
const loadProject = useCallback(
|
|
132
|
+
async (check = false) => {
|
|
133
|
+
if (!data?.projectParts) return;
|
|
134
|
+
setProject((p) => (p ? { ...p, loading: true } : { parts: [], counts: { total: 0, library: 0, researched: 0, designed: 0, updates: 0, diverged: 0 }, loading: true }));
|
|
135
|
+
await data.projectParts(check).then(setProject, (e: Error) => setProject((p) => ({ ...(p ?? { parts: [], counts: { total: 0, library: 0, researched: 0, designed: 0, updates: 0, diverged: 0 } }), loading: false, error: e.message })));
|
|
136
|
+
},
|
|
137
|
+
[data],
|
|
138
|
+
);
|
|
139
|
+
// first the offline list (what the project pins), then what the libraries recommend: each part's sync status
|
|
140
|
+
const listed = useRef(false);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (mode !== "project" || project || listed.current) return;
|
|
143
|
+
listed.current = true;
|
|
144
|
+
void loadProject().then(() => loadProject(true));
|
|
145
|
+
}, [mode, project, loadProject]);
|
|
146
|
+
if (!data) return <Unavailable what="library" />;
|
|
147
|
+
const vendor = async (key: string, kind: "add" | "update" | "switch", revision: number) => {
|
|
148
|
+
if (!data.vendor) return;
|
|
149
|
+
setNote(`${kind === "add" ? "Adding" : "Updating"} ${key.split("/").pop()}…`);
|
|
150
|
+
try {
|
|
151
|
+
setNote(await data.vendor(key, kind, revision));
|
|
152
|
+
void lib.reload();
|
|
153
|
+
// the project's parts changed: list them again, with the libraries' word
|
|
154
|
+
if (mode === "project") void loadProject(true);
|
|
155
|
+
else {
|
|
156
|
+
listed.current = false;
|
|
157
|
+
setProject(null);
|
|
158
|
+
}
|
|
159
|
+
if (open?.key === key) setOpen({ ...open });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
setNote((e as Error).message);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const item = (key: string) => lib.model.items.find((x) => x.key === key);
|
|
165
|
+
const part = (id: string) => project?.parts.find((x) => x.id === id);
|
|
166
|
+
const keyOf = (id: string) => {
|
|
167
|
+
const p = part(id);
|
|
168
|
+
return p?.tier === "catalog" && p.library ? `${p.library}/${id}` : undefined;
|
|
169
|
+
};
|
|
170
|
+
// a part's detail goes back to the list it was opened from: the library, or the Project Parts page
|
|
171
|
+
const shown = detail && !("error" in detail) ? { ...detail, back: mode === "project" ? "Project parts" : "Library", backShortcut: "Alt+←", ...(data?.prices ? { hasPrices: true } : {}) } : detail;
|
|
172
|
+
return (
|
|
173
|
+
<div className="flex size-full flex-col bg-background pt-[var(--uhd-inset-top,0px)]">
|
|
174
|
+
{!open && (
|
|
175
|
+
<div className="flex shrink-0 items-center gap-1 border-b border-line-subtle px-3 py-1">
|
|
176
|
+
<IconButton icon="library-big" label="Browse the library" pressed={mode === "browse"} onClick={() => setMode("browse")} />
|
|
177
|
+
<IconButton icon="package" label="The project's parts" pressed={mode === "project"} onClick={() => setMode("project")} disabled={!data.projectParts} />
|
|
178
|
+
{note && <span className="ml-2 min-w-0 truncate text-[12px] text-fg-muted" role="status">{note}</span>}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
{/* the list stays where it was while a part is open over it: its scroll position and its cards' pages are kept (PB-918) */}
|
|
182
|
+
<div className="relative min-h-0 flex-1">
|
|
183
|
+
<div className={cx("absolute inset-0", open && "invisible")} inert={!!open} aria-hidden={open ? true : undefined}>
|
|
184
|
+
{mode === "project" ? (
|
|
185
|
+
<ProjectPartsPage
|
|
186
|
+
model={project ?? { parts: [], counts: { total: 0, library: 0, researched: 0, designed: 0, updates: 0, diverged: 0 }, loading: true }}
|
|
187
|
+
actions={{
|
|
188
|
+
open: (id) => {
|
|
189
|
+
const key = keyOf(id);
|
|
190
|
+
const p = part(id);
|
|
191
|
+
if (key) setOpen({ key });
|
|
192
|
+
else if (p && data.openSource) data.openSource(p.file, p.line);
|
|
193
|
+
},
|
|
194
|
+
compare: (id) => {
|
|
195
|
+
const key = keyOf(id);
|
|
196
|
+
if (key) setOpen({ key, tab: "revisions" });
|
|
197
|
+
},
|
|
198
|
+
...(data.openSource
|
|
199
|
+
? {
|
|
200
|
+
openSource: (id: string) => {
|
|
201
|
+
const p = part(id);
|
|
202
|
+
if (p) data.openSource!(p.file, p.line);
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
: {}),
|
|
206
|
+
...(data.vendor
|
|
207
|
+
? {
|
|
208
|
+
update: (id: string) => {
|
|
209
|
+
const key = keyOf(id);
|
|
210
|
+
const p = part(id);
|
|
211
|
+
if (key && p?.recommended !== undefined) void vendor(key, "update", p.recommended);
|
|
212
|
+
},
|
|
213
|
+
restore: (id: string) => {
|
|
214
|
+
const key = keyOf(id);
|
|
215
|
+
const p = part(id);
|
|
216
|
+
if (!key || typeof p?.revision !== "number") return;
|
|
217
|
+
if (window.confirm(`Restore ${p.name} to ${rev(p.revision)}? Your edits to its vendored files are replaced by that revision.`)) void vendor(key, "add", p.revision);
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
: {}),
|
|
221
|
+
check: () => loadProject(true),
|
|
222
|
+
openLibrary: () => setMode("browse"),
|
|
223
|
+
...(data.pages ? { pages: data.pages } : {}),
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
) : (
|
|
227
|
+
<LibraryPage
|
|
228
|
+
model={lib.model}
|
|
229
|
+
actions={{
|
|
230
|
+
query: lib.setQuery,
|
|
231
|
+
open: (key, tab) => setOpen({ key, ...(tab ? { tab } : {}) }),
|
|
232
|
+
add: (key) => {
|
|
233
|
+
const it = item(key);
|
|
234
|
+
if (it) void vendor(key, "add", it.revision);
|
|
235
|
+
},
|
|
236
|
+
update: (key) => {
|
|
237
|
+
const it = item(key);
|
|
238
|
+
if (it?.update !== undefined) void vendor(key, "update", it.update);
|
|
239
|
+
},
|
|
240
|
+
loadMore: () => void lib.loadMore(),
|
|
241
|
+
retry: () => void lib.reload(),
|
|
242
|
+
...(data.pages ? { pages: data.pages } : {}),
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
{open && (
|
|
248
|
+
<div className="absolute inset-0">
|
|
249
|
+
{shown && "error" in shown ? (
|
|
250
|
+
<div className="grid size-full place-items-center">
|
|
251
|
+
<EmptyState icon={<Icon name="triangle-alert" size={24} />} title="The part did not load">
|
|
252
|
+
{shown.error}
|
|
253
|
+
<span className="mt-3 block">
|
|
254
|
+
<IconButton icon="arrow-left" label={mode === "project" ? "Back to Project parts" : "Back to the library"} onClick={() => setOpen(null)} />
|
|
255
|
+
</span>
|
|
256
|
+
</EmptyState>
|
|
257
|
+
</div>
|
|
258
|
+
) : shown ? (
|
|
259
|
+
<PartDetail
|
|
260
|
+
model={shown}
|
|
261
|
+
tab={open.tab}
|
|
262
|
+
actions={{
|
|
263
|
+
act: (kind, revision) => void vendor(open.key, kind, revision),
|
|
264
|
+
revision: (revision) => setOpen({ ...open, revision }),
|
|
265
|
+
back: () => setOpen(null),
|
|
266
|
+
openLink: (url) => (data.openLink ? data.openLink(url) : window.open(url, "_blank", "noopener")),
|
|
267
|
+
...(data.prices ? { prices: () => data.prices!(open.key) } : {}),
|
|
268
|
+
}}
|
|
269
|
+
/>
|
|
270
|
+
) : (
|
|
271
|
+
<PartsSurface place="editor">
|
|
272
|
+
<div className="grid size-full place-items-center">
|
|
273
|
+
<Icon name="loader" size={20} className="animate-spin text-fg-muted" aria-label="Loading the part" />
|
|
274
|
+
</div>
|
|
275
|
+
</PartsSurface>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Hand the reader a file the page made (a browser download). */
|
|
285
|
+
function downloadText(name: string, text: string, type: string) {
|
|
286
|
+
const url = URL.createObjectURL(new Blob([text], { type }));
|
|
287
|
+
const a = document.createElement("a");
|
|
288
|
+
a.href = url;
|
|
289
|
+
a.download = name;
|
|
290
|
+
a.click();
|
|
291
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The `bom` tab: the bill of materials of the project. A line opens its
|
|
296
|
+
* part: a library part's page in place of the bill (back returns to it), a
|
|
297
|
+
* part of the project's own where it is written, when the shell can open
|
|
298
|
+
* source. "Show in the viewer" goes to the diagram at the part's first
|
|
299
|
+
* place.
|
|
300
|
+
*/
|
|
301
|
+
export function BomView({ host }: ViewProps) {
|
|
302
|
+
const data = usePartsData();
|
|
303
|
+
const [bom, setBom] = useState<BomModel | { error: string } | null>(null);
|
|
304
|
+
const [note, setNote] = useState<string | null>(null);
|
|
305
|
+
const [open, setOpen] = useState<{ key: string; revision?: number } | null>(null);
|
|
306
|
+
const [detail, setDetail] = useState<PartDetailModel | { error: string } | null>(null);
|
|
307
|
+
// which library each of the project's parts comes from, asked once
|
|
308
|
+
const parts = useRef<Promise<ProjectPartsModel> | null>(null);
|
|
309
|
+
const load = useCallback(() => {
|
|
310
|
+
if (!data?.bom) return Promise.resolve();
|
|
311
|
+
return data.bom().then(setBom, (e: Error) => setBom({ error: e.message }));
|
|
312
|
+
}, [data]);
|
|
313
|
+
useEffect(() => void load(), [load]);
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
if (!data || !open) return setDetail(null);
|
|
316
|
+
let live = true;
|
|
317
|
+
setDetail(null);
|
|
318
|
+
data.part(open.key, open.revision).then(
|
|
319
|
+
(d) => live && setDetail(d),
|
|
320
|
+
(e: Error) => live && setDetail({ error: e.message }),
|
|
321
|
+
);
|
|
322
|
+
return () => {
|
|
323
|
+
live = false;
|
|
324
|
+
};
|
|
325
|
+
}, [data, open]);
|
|
326
|
+
if (!data?.bom) return <Unavailable what="bill of materials" />;
|
|
327
|
+
if (!bom)
|
|
328
|
+
return (
|
|
329
|
+
<div className="grid size-full place-items-center bg-background">
|
|
330
|
+
<Icon name="loader" size={20} className="animate-spin text-fg-muted" aria-label="Loading the bill of materials" />
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
if ("error" in bom)
|
|
334
|
+
return (
|
|
335
|
+
<div className="grid size-full place-items-center bg-background">
|
|
336
|
+
<EmptyState icon={<Icon name="sheet" size={26} strokeWidth={1.25} />} title="No bill of materials">{bom.error}</EmptyState>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
const openLink = (url: string) => (data.openLink ? data.openLink(url) : void window.open(url, "_blank", "noopener"));
|
|
340
|
+
const openPart = async (line: { definition: string; item: string; instances: string[] }) => {
|
|
341
|
+
setNote(null);
|
|
342
|
+
const list = data.projectParts ? await (parts.current ??= data.projectParts()).catch(() => ((parts.current = null), undefined)) : undefined;
|
|
343
|
+
const p = list?.parts.find((x) => x.id === line.definition);
|
|
344
|
+
if (p?.tier === "catalog" && p.library) return setOpen({ key: `${p.library}/${p.id}` });
|
|
345
|
+
if (p && data.openSource) return data.openSource(p.file, p.line);
|
|
346
|
+
if (host.openSource) return host.openSource({ defId: line.definition, ...(line.instances[0] ? { instancePath: line.instances[0] } : {}) });
|
|
347
|
+
if (line.instances.length && host.openView) return focus(line);
|
|
348
|
+
setNote(`${line.item} is a part of this project, not of a library: it has no page to open here.`);
|
|
349
|
+
};
|
|
350
|
+
const focus = (line: { instances: string[] }) => {
|
|
351
|
+
const path = line.instances[0];
|
|
352
|
+
if (!path || !host.openView) return;
|
|
353
|
+
const at = path.split("/");
|
|
354
|
+
host.openView("diagram", at.slice(0, -1).join("/"), { select: at[at.length - 1] });
|
|
355
|
+
};
|
|
356
|
+
if (open)
|
|
357
|
+
return (
|
|
358
|
+
<div className="size-full bg-background pt-[var(--uhd-inset-top,0px)]">
|
|
359
|
+
{detail && "error" in detail ? (
|
|
360
|
+
<div className="grid size-full place-items-center">
|
|
361
|
+
<EmptyState icon={<Icon name="triangle-alert" size={24} />} title="The part did not load">
|
|
362
|
+
{detail.error}
|
|
363
|
+
<span className="mt-3 block">
|
|
364
|
+
<IconButton icon="arrow-left" label="Back to the bill of materials" onClick={() => setOpen(null)} />
|
|
365
|
+
</span>
|
|
366
|
+
</EmptyState>
|
|
367
|
+
</div>
|
|
368
|
+
) : detail ? (
|
|
369
|
+
<PartDetail
|
|
370
|
+
model={{ ...detail, back: "Bill of materials" }}
|
|
371
|
+
actions={{
|
|
372
|
+
act: (kind, revision) => {
|
|
373
|
+
if (!data.vendor) return;
|
|
374
|
+
void data.vendor(open.key, kind, revision).then(
|
|
375
|
+
(message) => (setNote(message), setOpen({ ...open }), load()),
|
|
376
|
+
(e: Error) => setNote(e.message),
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
revision: (revision) => setOpen({ ...open, revision }),
|
|
380
|
+
back: () => setOpen(null),
|
|
381
|
+
openLink,
|
|
382
|
+
}}
|
|
383
|
+
/>
|
|
384
|
+
) : (
|
|
385
|
+
<PartsSurface place="editor">
|
|
386
|
+
<div className="grid size-full place-items-center">
|
|
387
|
+
<Icon name="loader" size={20} className="animate-spin text-fg-muted" aria-label="Loading the part" />
|
|
388
|
+
</div>
|
|
389
|
+
</PartsSurface>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
return (
|
|
394
|
+
<div className="flex size-full flex-col pt-[var(--uhd-inset-top,0px)]">
|
|
395
|
+
{note && (
|
|
396
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-line-subtle bg-background px-3 py-1 text-[12px] text-fg-muted" role="status">
|
|
397
|
+
<span className="min-w-0 flex-1 truncate">{note}</span>
|
|
398
|
+
<IconButton icon="x" size="sm" label="Dismiss" onClick={() => setNote(null)} />
|
|
399
|
+
</div>
|
|
400
|
+
)}
|
|
401
|
+
<div className="min-h-0 flex-1">
|
|
402
|
+
<BomEditor
|
|
403
|
+
model={bom}
|
|
404
|
+
actions={{
|
|
405
|
+
openLink,
|
|
406
|
+
openPart: (l) => void openPart(l),
|
|
407
|
+
...(host.openView ? { focus } : {}),
|
|
408
|
+
...(data.openSource ? { openRow: (l) => l.source && data.openSource!(l.source.file, l.source.line) } : {}),
|
|
409
|
+
...(data.saveBom
|
|
410
|
+
? {
|
|
411
|
+
// the bill as the project now holds it, so it is still right after a visit to a part's page
|
|
412
|
+
setUnits: (units) => void data.saveBom!({ units }).then(load, (e: Error) => setBom({ error: e.message })),
|
|
413
|
+
chooseOffer: (line, offer) => void data.saveBom!({ line, offer }).then(load, (e: Error) => setBom({ error: e.message })),
|
|
414
|
+
}
|
|
415
|
+
: {}),
|
|
416
|
+
...(data.refreshOffers
|
|
417
|
+
? {
|
|
418
|
+
refreshOffers: () => {
|
|
419
|
+
setNote("Asking the library for its current offers…");
|
|
420
|
+
void data.refreshOffers!().then(
|
|
421
|
+
(message) => (setNote(message), load()),
|
|
422
|
+
(e: Error) => setNote(`The offers were not refreshed: ${e.message}`),
|
|
423
|
+
);
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
: {}),
|
|
427
|
+
exportCsv: (shown) => downloadText(`${shown.project}-bom.csv`, bomCsv(shown), "text/csv;charset=utf-8"),
|
|
428
|
+
}}
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view registry: views keyed by id, each saying which modules it applies
|
|
3
|
+
* to. The shells' view tabs for the focused module come from it, in order.
|
|
4
|
+
* Built-in views register at start-up; extensions' views come from their
|
|
5
|
+
* manifests (`contributes.views`), with or without a component yet.
|
|
6
|
+
*/
|
|
7
|
+
import type { ComponentType } from "react";
|
|
8
|
+
import type { ModuleDef, UhdBundle } from "@deltarobotics/uhd";
|
|
9
|
+
import type { ResolvedView } from "@protoboardai/core";
|
|
10
|
+
import { isViewId } from "@protoboardai/core/views";
|
|
11
|
+
import { appliesFromDecl } from "./applies.js";
|
|
12
|
+
import type { ViewDefinition, ViewProps } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export class ViewRegistry {
|
|
15
|
+
private views = new Map<string, ViewDefinition>();
|
|
16
|
+
private listeners = new Set<() => void>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Adds a view. An id already registered is refused, unless `replace` is
|
|
20
|
+
* given (an extension's working view taking over a planned one).
|
|
21
|
+
* Returns a function that removes it again.
|
|
22
|
+
*/
|
|
23
|
+
register(view: ViewDefinition, options: { replace?: boolean } = {}): () => void {
|
|
24
|
+
if (!isViewId(view.id)) throw new Error(`view id "${view.id}" must be lowercase words with dashes, optionally "<extension>.<view>"`);
|
|
25
|
+
const had = this.views.get(view.id);
|
|
26
|
+
if (had && !options.replace) throw new Error(`view ${view.id} is already registered (by ${had.source ?? "builtin"})`);
|
|
27
|
+
const entry: ViewDefinition = { status: view.component ? "ready" : "planned", ...view };
|
|
28
|
+
this.views.set(view.id, entry);
|
|
29
|
+
this.changed();
|
|
30
|
+
return () => {
|
|
31
|
+
if (this.views.get(view.id) !== entry) return;
|
|
32
|
+
if (had) this.views.set(view.id, had);
|
|
33
|
+
else this.views.delete(view.id);
|
|
34
|
+
this.changed();
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(id: string): ViewDefinition | undefined {
|
|
39
|
+
return this.views.get(id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every view, in tab order. */
|
|
43
|
+
list(): ViewDefinition[] {
|
|
44
|
+
return [...this.views.values()].sort(byOrder);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The views that apply to a module, in tab order (a view whose predicate throws is left out). */
|
|
48
|
+
forModule(module: ModuleDef, bundle: UhdBundle, path = ""): ViewDefinition[] {
|
|
49
|
+
return this.list().filter((v) => {
|
|
50
|
+
try {
|
|
51
|
+
return v.applies(module, bundle, { path });
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Calls `listener` after every change; returns the unsubscribe. */
|
|
59
|
+
subscribe(listener: () => void): () => void {
|
|
60
|
+
this.listeners.add(listener);
|
|
61
|
+
return () => this.listeners.delete(listener);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private changed() {
|
|
65
|
+
for (const l of [...this.listeners]) l();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const byOrder = (a: ViewDefinition, b: ViewDefinition) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Registers the views extensions declare (core's resolveViews). A declared
|
|
73
|
+
* view with a component in `components` (by view id) is ready; one without is
|
|
74
|
+
* planned. A declared id the registry already has replaces it only when the
|
|
75
|
+
* declaration brings a component and the registered one is planned; otherwise
|
|
76
|
+
* the declaration is skipped and reported in the result.
|
|
77
|
+
*/
|
|
78
|
+
export function registerContributedViews(
|
|
79
|
+
registry: ViewRegistry,
|
|
80
|
+
views: readonly ResolvedView[],
|
|
81
|
+
components: Readonly<Record<string, ComponentType<ViewProps>>> = {},
|
|
82
|
+
): { registered: string[]; skipped: { id: string; reason: string }[] } {
|
|
83
|
+
const registered: string[] = [];
|
|
84
|
+
const skipped: { id: string; reason: string }[] = [];
|
|
85
|
+
for (const v of views) {
|
|
86
|
+
const component = components[v.id];
|
|
87
|
+
const existing = registry.get(v.id);
|
|
88
|
+
if (existing && !(component && existing.status === "planned")) {
|
|
89
|
+
skipped.push({ id: v.id, reason: existing.status === "planned" ? `${v.extension} declares ${v.id} without a component; the built-in placeholder stays` : `${v.id} is already a working view (${existing.source ?? "builtin"})` });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
registry.register(
|
|
93
|
+
{
|
|
94
|
+
id: v.id,
|
|
95
|
+
title: v.title,
|
|
96
|
+
icon: v.icon,
|
|
97
|
+
order: v.order,
|
|
98
|
+
applies: appliesFromDecl(v.appliesTo),
|
|
99
|
+
component,
|
|
100
|
+
source: v.extension,
|
|
101
|
+
},
|
|
102
|
+
{ replace: Boolean(existing) },
|
|
103
|
+
);
|
|
104
|
+
registered.push(v.id);
|
|
105
|
+
}
|
|
106
|
+
return { registered, skipped };
|
|
107
|
+
}
|
package/src/tabs.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which tabs a module gets, without any view's component: the built-in
|
|
3
|
+
* views' registrations (id, title, order, `applies`) and the registry
|
|
4
|
+
* built from them plus the views extensions declare. No React and no DOM,
|
|
5
|
+
* so the command line answers the same question the shells do
|
|
6
|
+
* (`protoboard views check`, PB-1030); builtins.tsx adds the components.
|
|
7
|
+
*/
|
|
8
|
+
import type { ModuleDef, UhdBundle } from "@deltarobotics/uhd";
|
|
9
|
+
import type { ResolvedView } from "@protoboardai/core";
|
|
10
|
+
import { VIEW_FILES, isModelFile } from "@protoboardai/core/views";
|
|
11
|
+
import { appliesFromDecl, hasArtifact, hasFile } from "./applies.js";
|
|
12
|
+
|
|
13
|
+
/** A view's registration without its component (types.ts ViewDefinition, which adds it). */
|
|
14
|
+
export interface TabRule {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
icon: string;
|
|
18
|
+
applies(module: ModuleDef, bundle: UhdBundle, at?: { path: string }): boolean;
|
|
19
|
+
order?: number;
|
|
20
|
+
description?: string;
|
|
21
|
+
source?: string;
|
|
22
|
+
uhd?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The schematic view's icon (drawn by icons.tsx: Lucide has no schematic symbol). */
|
|
26
|
+
export const SCHEMATIC_ICON = "pb-schematic";
|
|
27
|
+
|
|
28
|
+
export const BUILTIN_TABS: readonly TabRule[] = [
|
|
29
|
+
{
|
|
30
|
+
id: "diagram",
|
|
31
|
+
title: "Diagram",
|
|
32
|
+
icon: "workflow",
|
|
33
|
+
order: 0,
|
|
34
|
+
uhd: true,
|
|
35
|
+
source: "builtin",
|
|
36
|
+
description: "Modules, interfaces, links and harnesses of this level.",
|
|
37
|
+
applies: () => true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "3d",
|
|
41
|
+
title: "3D",
|
|
42
|
+
icon: "box",
|
|
43
|
+
order: 10,
|
|
44
|
+
uhd: true,
|
|
45
|
+
source: "builtin",
|
|
46
|
+
description: "The assembled model, placed by its mates.",
|
|
47
|
+
applies: (module, bundle) => hasArtifact(module, bundle, isModelFile),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "schematic",
|
|
51
|
+
title: "Schematic",
|
|
52
|
+
icon: SCHEMATIC_ICON,
|
|
53
|
+
order: 20,
|
|
54
|
+
source: "builtin",
|
|
55
|
+
description: "The board's schematic, from its circuit tool's native drawing, in the host's theme.",
|
|
56
|
+
// scoped to the module being viewed (the 2026-10-05 review): the board that has the drawing, never a level above it
|
|
57
|
+
applies: (module, bundle) => hasFile(module, bundle, VIEW_FILES.schematic),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "pcb",
|
|
61
|
+
title: "PCB",
|
|
62
|
+
icon: "circuit-board",
|
|
63
|
+
order: 30,
|
|
64
|
+
source: "builtin",
|
|
65
|
+
description: "The board's layout, top and bottom, in the host's theme; a pad lights its net.",
|
|
66
|
+
applies: (module, bundle) => hasFile(module, bundle, VIEW_FILES.pcb),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "bom",
|
|
70
|
+
title: "BOM",
|
|
71
|
+
icon: "sheet",
|
|
72
|
+
order: 40,
|
|
73
|
+
source: "builtin",
|
|
74
|
+
description: "Every part below this module, with quantities, sources and prices.",
|
|
75
|
+
applies: (module, bundle, at) => (at ? at.path === "" : module.id === bundle.root) || hasFile(module, bundle, VIEW_FILES.bom),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "pair",
|
|
79
|
+
title: "Pair",
|
|
80
|
+
icon: "git-compare-arrows",
|
|
81
|
+
order: 50,
|
|
82
|
+
source: "builtin",
|
|
83
|
+
description: "Two linked modules side by side, link by link, with every interface of both.",
|
|
84
|
+
// a level whose stored links join two different modules (a link back to the same one is no pair)
|
|
85
|
+
applies: (module) => (module.links ?? []).some((l) => ("child" in l.a ? l.a.child : "") !== ("child" in l.b ? l.b.child : "")),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "library",
|
|
89
|
+
title: "Library",
|
|
90
|
+
icon: "library-big",
|
|
91
|
+
order: 60,
|
|
92
|
+
source: "builtin",
|
|
93
|
+
description: "The project's parts and the libraries they come from.",
|
|
94
|
+
applies: (module, bundle, at) => (at ? at.path === "" : module.id === bundle.root),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const byOrder = (a: TabRule, b: TabRule) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The tabs a module gets at an instance path, in tab order, as the shells'
|
|
102
|
+
* registry offers them (registry.ts: the built-in views, and each view an
|
|
103
|
+
* extension declares whose id is not taken; a predicate that throws leaves
|
|
104
|
+
* its tab out).
|
|
105
|
+
*/
|
|
106
|
+
export function tabsFor(module: ModuleDef, bundle: UhdBundle, path: string, contributed: readonly ResolvedView[] = []): TabRule[] {
|
|
107
|
+
const all: TabRule[] = [...BUILTIN_TABS];
|
|
108
|
+
for (const v of contributed) if (!all.some((t) => t.id === v.id)) all.push({ id: v.id, title: v.title, icon: v.icon, order: v.order, applies: appliesFromDecl(v.appliesTo), source: v.extension });
|
|
109
|
+
return all
|
|
110
|
+
.filter((t) => {
|
|
111
|
+
try {
|
|
112
|
+
return t.applies(module, bundle, { path });
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
.sort(byOrder);
|
|
118
|
+
}
|