From 079522d1e79e2ee8944951f9555e1e1b9fa677de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 25 Aug 2026 19:52:11 +0200 Subject: [PATCH] standalone: split the WasmTool component body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useLibNotices hook (toast/stale-lib/load-badge state + window listeners + auto-dismiss timers) and presentational children — NoticeStack, SessionMenu (+ StaleLibsRow, FollowBanner), BootOverlay, LibLoadingOverlay, FatalOverlay, ConsolePanel. Behavior and data-testids unchanged; WasmTool.tsx 2378 → 1680 lines. The boot effect + collab wiring stay in place. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa --- web/standalone/src/components/WasmTool.tsx | 848 ++---------------- .../src/components/wasm-tool/BootOverlay.tsx | 99 ++ .../src/components/wasm-tool/ConsolePanel.tsx | 64 ++ .../src/components/wasm-tool/FatalOverlay.tsx | 36 + .../wasm-tool/LibLoadingOverlay.tsx | 48 + .../src/components/wasm-tool/NoticeStack.tsx | 135 +++ .../src/components/wasm-tool/SessionMenu.tsx | 321 +++++++ .../src/components/wasm-tool/useLibNotices.ts | 333 +++++++ 8 files changed, 1111 insertions(+), 773 deletions(-) create mode 100644 web/standalone/src/components/wasm-tool/BootOverlay.tsx create mode 100644 web/standalone/src/components/wasm-tool/ConsolePanel.tsx create mode 100644 web/standalone/src/components/wasm-tool/FatalOverlay.tsx create mode 100644 web/standalone/src/components/wasm-tool/LibLoadingOverlay.tsx create mode 100644 web/standalone/src/components/wasm-tool/NoticeStack.tsx create mode 100644 web/standalone/src/components/wasm-tool/SessionMenu.tsx create mode 100644 web/standalone/src/components/wasm-tool/useLibNotices.ts diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 95fc130..5299c4f 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -7,7 +7,7 @@ import { syncLayoutToY, type Tool, } from "@pcbjam/shared"; -import { AlertTriangle, ChevronDown, ChevronUp, Crosshair, Download, EyeOff, Layers, Loader2, Moon, PanelsTopLeft, RefreshCw, Sun } from "lucide-react"; +import { Download } from "lucide-react"; import { API_BASE_URL, APP_URL, @@ -22,34 +22,18 @@ import { } from "@/lib/config"; import { redirectTargetFor } from "@/lib/redirect"; import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity"; -import { setTheme, useThemeValue } from "@/lib/theme"; +import { useThemeValue } from "@/lib/theme"; import { bootKicadTool } from "@/wasm/boot"; import { autoDownloadEnabled, isWasmDownloaded, markWasmDownloaded, resolveWasmMeta, - setAutoDownloadEnabled, } from "@/wasm/wasm-assets"; import { - LIB_BUSY_EVENT, - LIB_ERROR_EVENT, - LIB_ITEM_UPDATED_EVENT, - LIB_LOADING_EVENT, - LIB_SET_CHANGED_EVENT, - type LibBusyDetail, - type LibErrorDetail, - type LibItemUpdatedDetail, - type LibLoadingDetail, - type LibSetChangedDetail, type LibsSource, } from "@/wasm/libs/source"; -import { addAnnouncedLib } from "@/wasm/libs/runtime-add"; -import { - MODELS_LOADING_EVENT, - type ModelsLoadingDetail, -} from "@/wasm/libs/models-bridge"; import { TOOL_FRAME } from "@/wasm/constants"; import { driveProjectIntoTool, @@ -88,7 +72,6 @@ import { startSiblingRestage, type SiblingRestageHandle, } from "@/wasm/collab/sibling-restage"; -import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding"; import { createComments, hasCommentsBridge, @@ -97,11 +80,6 @@ import { } from "@/wasm/collab/comments"; import { PresenceRoster } from "@/components/PresenceRoster"; import { CommentLayer } from "@/components/CommentLayer"; -import { - OverlayMenu, - OverlayMenuSection, - overlayRowClass, -} from "@/components/OverlayMenu"; import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner"; import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel"; import { SelectionInspector } from "@/components/SelectionInspector"; @@ -122,11 +100,15 @@ import { } from "@/lib/chrome-visibility"; import { recordFatalLog, showFatalScreen } from "@/wasm/fatal-screen"; import { WasmErrorBoundary } from "@/components/wasm-tool/WasmErrorBoundary"; +import { BootOverlay } from "@/components/wasm-tool/BootOverlay"; +import { ConsolePanel } from "@/components/wasm-tool/ConsolePanel"; +import { FatalOverlay } from "@/components/wasm-tool/FatalOverlay"; +import { LibLoadingOverlay } from "@/components/wasm-tool/LibLoadingOverlay"; +import { NoticeStack } from "@/components/wasm-tool/NoticeStack"; +import { FollowBanner, SessionMenu } from "@/components/wasm-tool/SessionMenu"; +import { useLibNotices } from "@/components/wasm-tool/useLibNotices"; import { - DownloadConsent, - DownloadProgress, gatherConsentInfo, - libSyncLabel, type ConsentInfo, } from "@/components/wasm-tool/DownloadConsent"; import { @@ -138,13 +120,11 @@ import { import { installQuitHook } from "@/components/wasm-tool/quit-hook"; import { installToolNavigationHook } from "@/components/wasm-tool/tool-navigation"; import { - CHROME_HOTKEY_LABEL, chromeSetter, COLLAB_TOOLS, INSPECTOR_OPEN_KEY, LAYERS_OPEN_KEY, LIB_KIND_FOR_TOOL, - reloadFallbackMsg, } from "@/components/wasm-tool/ui-helpers"; /** @@ -310,8 +290,6 @@ export function WasmTool({ // This bundle+version finished downloading before (completion marker) — the // load overlay says "from cache" instead of the first-download excuse. const [warmBoot, setWarmBoot] = React.useState(false); - // A library item currently being fetched (open/save), for a transient spinner. - const [libBusy, setLibBusy] = React.useState(null); // Load-screen pre-sync progress: warming the project's lib bundles into IDB in // parallel with the wasm download. Null when idle/done. Counts only (no // "current lib") — the fetches run several-at-a-time, so there is no single @@ -327,124 +305,19 @@ export function WasmTool({ done: number; total: number; } | null>(null); - // Last lib error (e.g. a backend 404 on open), shown as a dismissible toast. - const [libError, setLibError] = React.useState(null); - // A collaborator updated library items that are PLACED in the open document - // (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn. - const [libUpdate, setLibUpdate] = React.useState(null); - // Persistent "behind the library" state (libs 0017 §2b): every PLACED item a - // peer's lib edit touched, keyed `\u0000` → names. The toast above - // is disposable; this survives until the user updates from the library - // (2c) or dismisses it, and drives the FAB's amber triangle + the Document - // section row. Symbols/footprints only — the kinds with a placed-usage - // bridge (kicadLibsSymbolUsage / kicadLibsFootprintUsage). - const [staleLibItems, setStaleLibItems] = React.useState< - Map }> - >(() => new Map()); - const [staleUpdating, setStaleUpdating] = React.useState(false); - const staleKey = (kind: string, lib: string) => `${kind}\u0000${lib}`; - const noteStale = React.useCallback((kind: string, lib: string, names: string[]) => { - if (names.length === 0) return; - setStaleLibItems((prev) => { - const next = new Map(prev); - const k = staleKey(kind, lib); - const cur = next.get(k) ?? { kind, lib, names: new Set() }; - const merged = new Set(cur.names); - for (const n of names) merged.add(n); - next.set(k, { kind, lib, names: merged }); - return next; - }); - }, []); - const clearStale = React.useCallback((key?: string) => { - setStaleLibItems((prev) => { - if (key === undefined) return new Map(); - const next = new Map(prev); - next.delete(key); - return next; - }); - }, []); - /** Update every placed instance of the stale items from the library (2c). */ - const updateStaleFromLibrary = React.useCallback(async () => { - const mod = (window as { Module?: { kicadUpdateFromLibrary?: unknown } }).Module; - const fn = mod?.kicadUpdateFromLibrary; - if (typeof fn !== "function") { - setLibError("This editor build can't update placed items from the library — reload to refresh them."); - return; - } - setStaleUpdating(true); - try { - for (const [key, entry] of staleLibItems) { - // The bridge queues the edit on the frame's coroutine and answers - // {queued:true}; the outcome arrives as a `pcbjam:lib-update-done` - // window event (or {ok:false,error} synchronously). - const done = new Promise<{ ok: boolean; updated?: number; error?: string }>((resolve) => { - const onDone = (e: Event) => { - window.removeEventListener("pcbjam:lib-update-done", onDone); - resolve((e as CustomEvent<{ ok: boolean; updated?: number }>).detail); - }; - window.addEventListener("pcbjam:lib-update-done", onDone); - setTimeout(() => { - window.removeEventListener("pcbjam:lib-update-done", onDone); - resolve({ ok: false, error: "timed out" }); - }, 30_000); - }); - let res: { ok?: boolean; queued?: boolean; error?: string } = {}; - try { - res = JSON.parse( - (fn as (kind: string, lib: string, namesJson: string) => string)( - entry.kind, - entry.lib, - JSON.stringify([...entry.names]), - ), - ) as typeof res; - } catch { - res = { ok: false, error: "bridge call failed" }; - } - const outcome = res.ok === false ? { ok: false, error: res.error } : await done; - if (!outcome.ok) { - setLibError(`Couldn't update from the library: ${outcome.error ?? "unknown error"}`); - continue; - } - console.log(`[libs] updated ${outcome.updated ?? "?"} placed ${entry.kind}(s) from "${entry.lib}"`); - clearStale(key); - } - } finally { - setStaleUpdating(false); - } - }, [staleLibItems, clearStale]); - // The backend rolled this document back to its last valid state - // (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding). - const [docReverted, setDocReverted] = React.useState(null); - // A peer changed the team's lib SET mid-session (LIB_SET_CHANGED_EVENT — - // the scope room's `libset` broadcast). The lib table is frozen at boot, so - // the toast's click action loads the new lib live (addAnnouncedLib), with a - // reload fallback when the runtime bridge is missing. - const [libSetNotice, setLibSetNotice] = React.useState<{ - message: string; - detail: LibSetChangedDetail; - mode: "load" | "reload"; - } | null>(null); // The one libs source instance the running editor uses (set by the boot // effect) — the libset toast's action needs it to re-list and load. const activeLibsSourceRef = React.useRef(null); + // Every library / document notice (toasts, stale-lib state, load badges) — + // state + listeners in useLibNotices, rendered by NoticeStack / SessionMenu + // / LibLoadingOverlay below. + const notices = useLibNotices({ getLibsSource: () => activeLibsSourceRef.current }); // A save path entered the DURABLE blocked state (409 conflict / unknown // commit state — save-flow's absorbing blockedPaths). Rendered as a // persistent banner, never auto-dismissed: further Ctrl+S on the path is // silently absorbed, so without this surface the user would keep "saving" // into the void. const [saveBlocked, setSaveBlocked] = React.useState(null); - // Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on - // first chooser/editor open). Drives a full-cover overlay so the freeze reads as - // "loading, just slow" rather than a hang. Null when idle; `done/total` count the - // per-lib fat-load crossings so the overlay can show a progress bar. - const [libLoading, setLibLoading] = React.useState<{ - kind: string; - done: number; - total: number; - } | null>(null); - // Board 3D-model prefetch in flight (background; the viewer works without it — - // anything still missing lazy-loads per model). Small badge, not an overlay. - const [modelsSync, setModelsSync] = React.useState(null); // The OTHER users in this document's collab room (awareness roster) — drives // the PresenceRoster chip next to SourceChip. Empty when collab is off, the // provider has no awareness (kind "none"), or nobody else is here. @@ -614,142 +487,6 @@ export function WasmTool({ setLogs((prev) => [...prev.slice(-800), msg]); }, []); - // Loading/error chrome for library item fetches (open/save), driven by events - // the libs bridge dispatches (wasm/libs/source). The fetch is otherwise - // invisible; a 404 would silently do nothing without this. - React.useEffect(() => { - let busyTimer: ReturnType | undefined; - const onBusy = (e: Event) => { - const d = (e as CustomEvent).detail; - clearTimeout(busyTimer); - if (d.busy) { - // Debounce — only flag slow fetches, so fast ones don't flicker. - busyTimer = setTimeout(() => setLibBusy(d.name || "library item"), 180); - } else { - setLibBusy(null); - } - }; - const onError = (e: Event) => { - setLibError((e as CustomEvent).detail.message); - }; - const onItemUpdated = (e: Event) => { - const d = (e as CustomEvent).detail; - // Footprints have no placed-usage bridge (kicadLibsSymbolUsage is - // symbol-only), so every applied peer edit is announced — silently - // refreshing the lib under the user was the worse failure mode. - // Only warn when the update touches something PLACED here — the library - // tree already reflects updates to everything else. Both kinds have a - // placed-usage bridge now (libs 0017 §2d added the footprint one). - if (d.usedNames.length === 0) return; - const label = d.kind === "footprint" ? "Footprint" : "Symbol"; - const names = d.usedNames.map((n) => `"${n}"`).join(", "); - noteStale(d.kind, d.lib, d.usedNames); - setLibUpdate( - `${d.usedNames.length === 1 ? label : `${label}s`} ${names} in "${d.lib}" ` + - `${d.usedNames.length === 1 ? "was" : "were"} updated by a collaborator — ` + - `placed copies keep the previous version. Update them from the session menu.`, - ); - }; - const onDocReverted = (e: Event) => { - const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail; - setDocReverted( - `This document was rolled back to its last valid state — invalid content ` + - `was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`, - ); - }; - const onLibSet = (e: Event) => { - const d = (e as CustomEvent).detail; - // Only additions get a call to action — a removed lib's table row is - // inert until the next boot and needs no interruption. - if (d.op !== "add") return; - setLibSetNotice({ - message: d.name - ? `A collaborator added library "${d.name}" — click to load it into this session.` - : `A collaborator added a new library — click to load it into this session.`, - detail: d, - mode: "load", - }); - }; - window.addEventListener(LIB_BUSY_EVENT, onBusy); - window.addEventListener(LIB_ERROR_EVENT, onError); - window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated); - window.addEventListener(LIB_SET_CHANGED_EVENT, onLibSet); - window.addEventListener(DOC_REVERTED_EVENT, onDocReverted); - return () => { - clearTimeout(busyTimer); - window.removeEventListener(LIB_BUSY_EVENT, onBusy); - window.removeEventListener(LIB_ERROR_EVENT, onError); - window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated); - window.removeEventListener(LIB_SET_CHANGED_EVENT, onLibSet); - window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted); - }; - }, [noteStale]); - - // Auto-dismiss the lib error toast. - React.useEffect(() => { - if (!libError) return; - const t = setTimeout(() => setLibError(null), 6000); - return () => clearTimeout(t); - }, [libError]); - - // Auto-dismiss the lib update toast (a touch longer — it carries a caveat). - React.useEffect(() => { - if (!libUpdate) return; - const t = setTimeout(() => setLibUpdate(null), 10_000); - return () => clearTimeout(t); - }, [libUpdate]); - - // Auto-dismiss the lib-set toast (long — it carries a click action). - React.useEffect(() => { - if (!libSetNotice) return; - const t = setTimeout(() => setLibSetNotice(null), 30_000); - return () => clearTimeout(t); - }, [libSetNotice]); - - // Auto-dismiss the doc-reverted toast (longest — the user should see it). - React.useEffect(() => { - if (!docReverted) return; - const t = setTimeout(() => setDocReverted(null), 15_000); - return () => clearTimeout(t); - }, [docReverted]); - - // Full-library eager load overlay. The fat-load fires one loading:true/false - // pair PER library (222 on the full set), and between them the C++ side parses - // with the main thread blocked. Show immediately on `true`, and only hide after - // a short quiet gap on `false` (reset by the next lib's `true`) — so the overlay - // stays continuous across the whole run and drops shortly after the last lib, - // instead of flickering 222 times. - React.useEffect(() => { - let hideTimer: ReturnType | undefined; - const onLoading = (e: Event) => { - const d = (e as CustomEvent).detail; - clearTimeout(hideTimer); - // Update the bar on every event (true and false) so the count reflects the - // latest lib; arm the hide only when the run reports it's winding down. - setLibLoading({ kind: d.kind || "library", done: d.done, total: d.total }); - if (!d.loading) { - hideTimer = setTimeout(() => setLibLoading(null), 700); - } - }; - window.addEventListener(LIB_LOADING_EVENT, onLoading); - return () => { - clearTimeout(hideTimer); - window.removeEventListener(LIB_LOADING_EVENT, onLoading); - }; - }, []); - - // Board 3D-model prefetch progress (models-bridge prescan) — background badge. - React.useEffect(() => { - const onModels = (e: Event) => { - const d = (e as CustomEvent).detail; - setModelsSync( - d.loading ? `Fetching 3D models — ${d.done}/${d.total}` : null, - ); - }; - window.addEventListener(MODELS_LOADING_EVENT, onModels); - return () => window.removeEventListener(MODELS_LOADING_EVENT, onModels); - }, []); - // "Taking too long": once the tool has been loading for a while without // becoming ready, surface a hint (slow link / something may be wrong) + a // reload, so a stalled boot doesn't look like a frozen blank screen. Paused @@ -1811,116 +1548,20 @@ export function WasmTool({ /> )} - {/* Boot overlay — covers the big WASM download/compile freeze until the - tool has booted + opened. */} {!ready && ( -
- {status.startsWith("Error") ? ( - <> -

- {status} -

- - - ) : consent ? ( - { - if (always) setAutoDownloadEnabled(true); - consentResolveRef.current?.(true); - }} - /> - ) : ( - <> - -

- {status || "Loading…"} -

- - {/* The parallel fan-out's other progress: project files staging - into MEMFS, and the lib warm-up. The lib line says "checking" - on purpose — the walk visits every lib but downloads only new - or changed ones, so a bare 15/155 next to the consent dialog's - MB figures would read as 155 big downloads. */} - {fileSync && fileSync.total > 0 && ( -

- Project files — {String(fileSync.done).padStart(String(fileSync.total).length, " ")}/{fileSync.total} -

- )} - {libSync && ( -

- {libSyncLabel(libSync)} -

- )} -

- {warmBoot - ? "Loading from your browser's cache — no download needed." - : "Downloading the editor — it's cached for future visits."} -

- {slow && ( - <> -

- This is taking longer than usual — a slow connection, or - something may be wrong. You can keep waiting, or reload. -

- - - )} - - )} -
+ consentResolveRef.current?.(true)} + progress={progress} + fileSync={fileSync} + libSync={libSync} + warmBoot={warmBoot} + slow={slow} + /> )} - {/* Eager library load overlay — the first chooser/editor open hydrates the - whole library set from IDB into wasm (tens of seconds on the full CDN - set) with the main thread blocked. Cover the (frozen) editor so it reads - as "loading, just slow" rather than a hang. Shown post-boot; before - `ready` the boot overlay already covers it. */} - {ready && libLoading && ( -
- -

- {libLoading.kind === "library" - ? "Loading libraries…" - : `Loading ${libLoading.kind} libraries…`} -

- {libLoading.total > 0 && ( -
-
-
-
- {/* Space-pad `done` to `total`'s width so the centered line - doesn't shift as the count gains a digit. */} -

- {String(Math.min(libLoading.done, libLoading.total)).padStart( - String(libLoading.total).length, - " ", - )}{" "} - / {libLoading.total} libraries -

-
- )} -

- Moving the library set into the editor. The first open can take a - moment — it's cached after this. -

-
- )} + {ready && notices.libLoading && } {/* Transient post-boot status (e.g. file open). */} {ready && status && ( @@ -1929,210 +1570,41 @@ export function WasmTool({
)} - {/* Follow-user (0008): who we're following + how to stop. Esc also works - because any canvas key input breaks the follow via noteLocalViewport - only when the viewport moves — this banner is the explicit out. */} {ready && followingTarget && ( -
- - Following {followingTarget.name} — move to stop - - -
+ followRef.current?.unfollow()} /> )} - {/* Overlay menu (0010): the single draggable circular icon replacing the - old top-right row. Its badge is the peer count; the panel stacks the - session sections — roster, source chip, view-only pill, follow row, - comments (portal slot filled by CommentLayer), chrome toggle. It is - the one control that stays up in canvas-only (chrome-hidden) mode. */} + {/* Session menu (0010): the draggable FAB + its sections. */} {ready && ( - 0} - > - {/* PEOPLE — who else is here, and whose view you're locked to. The - follow state lives on each person's own row (PresenceRoster), so - there is no separate "Following…" banner to keep in sync. */} - {peers.length > 0 && ( - - { - if (t) followRef.current?.follow(t); - else followRef.current?.unfollow(); - }} - /> - - )} - - {/* DOCUMENT — where this file came from and whether you may edit it. - SourceChip is shared with the light project pages, so instead of - restyling it we ask for its `muted` tone: colour drops to a dot, - and the chip sits in a normal row like everything else. */} - {(sourceDescriptor || readOnly || staleLibItems.size > 0) && ( - - {/* Behind-the-library state (libs 0017 §2b/2c): placed items a - peer updated in the library. Persistent — unlike the toast — - and actionable without a page reload: "Update from library" - re-reads just those items into the placed instances. */} - {staleLibItems.size > 0 && ( -
-
- - - {[...staleLibItems.values()].reduce((n, e) => n + e.names.size, 0)} placed{" "} - {[...staleLibItems.values()].every((e) => e.kind === "footprint") - ? "footprint(s)" - : [...staleLibItems.values()].every((e) => e.kind === "symbol") - ? "symbol(s)" - : "item(s)"}{" "} - behind the library - -
-
    - {[...staleLibItems.values()].flatMap((e) => - [...e.names].map((n) => ( -
  • - {e.lib}:{n} -
  • - )), - )} -
-
- - -
-
- )} - {sourceDescriptor && ( -
- -
- )} - {readOnly && ( -
- - View only - - read-only - -
- )} -
- )} - - {commentsCtl && ( - -
- - )} - - - {/* Viewer panels (viewer-panels): canvas-only stand-ins for the - chrome-hidden wx panes — available to viewers and to editors - in hide-UI mode alike. */} - {effectiveChromeHidden && layersMod && ( - - )} - {effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && ( - - )} - {setChromeFn !== null && !readOnly && ( - - )} - {/* Light/dark toggle (comments-ux 0002): flips the shell theme; - the F4 effect above re-themes the GAL canvas through the - bridge. Available to viewers too — theming isn't editing. */} - - - + { + if (t) followRef.current?.follow(t); + else followRef.current?.unfollow(); + }} + sourceDescriptor={sourceDescriptor} + staleLibItems={notices.staleLibItems} + staleUpdating={notices.staleUpdating} + onUpdateStale={() => void notices.updateStaleFromLibrary()} + onDismissStale={() => notices.clearStale()} + commentsUnread={commentsUnread} + hasComments={commentsCtl !== null} + setCommentsSlot={setCommentsSlot} + effectiveChromeHidden={effectiveChromeHidden} + hasLayers={layersMod !== null} + layersOpen={layersOpen} + setLayersOpen={setLayersOpen} + inspectorOpen={inspectorOpen} + setInspectorOpen={setInspectorOpen} + canToggleChrome={setChromeFn !== null} + chromeHidden={chromeHidden} + onToggleChrome={() => toggleChromeHidden()} + /> )} {/* Figma-like comments (0005): GAL pin dots + this DOM layer (hit targets, @@ -2176,201 +1648,31 @@ export function WasmTool({ {/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */} {ready && tunerMod && } - {/* Lib pre-sync warming IDB after the editor opened (big set) — the ONLY - surface for it now that the warm-up starts post-open: a small unobtrusive - indicator so the user knows browsing is still filling in behind them, - never something they are waiting on. */} - {ready && libSync && ( -
- {" "} - {libSyncLabel(libSync)} -
- )} - - {/* Board 3D models still prefetching into the cache (background). */} - {ready && modelsSync && ( -
- {modelsSync} -
- )} - - {/* A library item is being fetched (open/save). */} - {ready && libBusy && ( -
- Loading {libBusy}… -
- )} - - {/* A save path is durably BLOCKED (CAS conflict / unknown commit state) — - persistent full-width banner, no auto-dismiss: subsequent Ctrl+S on - the path is absorbed by the save lane, so this must stay visible. */} - {saveBlocked && ( -
- {saveBlocked.message} -
- )} - - {/* Top-center toast column: simultaneous notices stack instead of - overlapping (they all used to render at the same absolute spot). */} -
- - {/* Library error (e.g. a backend 404 on open) — auto-dismisses. */} - {libError && ( - - )} - - {/* A collaborator updated a symbol PLACED in this document — auto-dismisses. */} - {libUpdate && ( - - )} - - {/* A peer changed the team's lib set — click loads the new lib live - (kicadLibsAddEntry bridge), falling back to a reload offer. */} - {libSetNotice && ( - - )} - - {/* Backend rolled this doc back to the last valid state (kicad-validity). */} - {docReverted && ( - - )} - -
+ - {/* Terminal failure — z-35, ABOVE the boot overlay but below the console - panel, OUTSIDE the error boundary, and independent of `ready`: a - post-boot runtime death gets a proper blue screen instead of a blank - page, with the console panel forced open beneath it. */} - {fatal && ( -
-

:(

-

- The editor hit an unrecoverable error and stopped. -

-

- {fatal} -

-

- The console below records what was loading when this happened — - please copy it into a bug report. -

-
- -
-
- )} + {fatal && } - {/* z-40 (above the z-30 boot overlay and the z-35 fatal overlay): when a - load fails, the log this panel holds is the only account of WHY, so it - must never end up underneath the thing reporting the failure. Forced - visible on a fatal even with chrome hidden, for the same reason. */} + {/* The log console (z-40, above boot + fatal overlays) — forced visible + on a fatal even with chrome hidden: the log is the only account of + WHY a load failed. */} {(!effectiveChromeHidden || fatal) && ( - /* Closed: a content-width tab pinned bottom-left (no right-0), so the - version badge and the app's bottom edge stay visible/clickable. - Open: the full-width footer panel. */ -
- {showLog ? ( - <> -
- - -
-
-                {logs.join("\n")}
-              
- - ) : ( - - )} -
+ )}
); diff --git a/web/standalone/src/components/wasm-tool/BootOverlay.tsx b/web/standalone/src/components/wasm-tool/BootOverlay.tsx new file mode 100644 index 0000000..b8d0c5d --- /dev/null +++ b/web/standalone/src/components/wasm-tool/BootOverlay.tsx @@ -0,0 +1,99 @@ +// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged. +import { Loader2 } from "lucide-react"; +import { setAutoDownloadEnabled } from "@/wasm/wasm-assets"; +import { DownloadConsent, DownloadProgress, libSyncLabel, type ConsentInfo } from "./DownloadConsent"; + +/** + * Boot overlay — covers the big WASM download/compile freeze until the tool + * has booted + opened: the error state, the download-consent card, or the + * spinner with download / staging / lib warm-up progress and the "taking + * too long" hint. + */ +export function BootOverlay({ + status, + consent, + onConsentAccept, + progress, + fileSync, + libSync, + warmBoot, + slow, +}: { + status: string; + consent: ConsentInfo | null; + /** The consent card's OK — resolves the boot's gate. */ + onConsentAccept: () => void; + progress: { loaded: number; total: number } | null; + fileSync: { done: number; total: number } | null; + libSync: { kind: string; done: number; total: number } | null; + warmBoot: boolean; + slow: boolean; +}) { + return ( +
+ {status.startsWith("Error") ? ( + <> +

+ {status} +

+ + + ) : consent ? ( + { + if (always) setAutoDownloadEnabled(true); + onConsentAccept(); + }} + /> + ) : ( + <> + +

+ {status || "Loading…"} +

+ + {/* The parallel fan-out's other progress: project files staging + into MEMFS, and the lib warm-up. The lib line says "checking" + on purpose — the walk visits every lib but downloads only new + or changed ones, so a bare 15/155 next to the consent dialog's + MB figures would read as 155 big downloads. */} + {fileSync && fileSync.total > 0 && ( +

+ Project files — {String(fileSync.done).padStart(String(fileSync.total).length, " ")}/{fileSync.total} +

+ )} + {libSync && ( +

+ {libSyncLabel(libSync)} +

+ )} +

+ {warmBoot + ? "Loading from your browser's cache — no download needed." + : "Downloading the editor — it's cached for future visits."} +

+ {slow && ( + <> +

+ This is taking longer than usual — a slow connection, or + something may be wrong. You can keep waiting, or reload. +

+ + + )} + + )} +
+ ); +} diff --git a/web/standalone/src/components/wasm-tool/ConsolePanel.tsx b/web/standalone/src/components/wasm-tool/ConsolePanel.tsx new file mode 100644 index 0000000..a786b4d --- /dev/null +++ b/web/standalone/src/components/wasm-tool/ConsolePanel.tsx @@ -0,0 +1,64 @@ +// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged. +import * as React from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +/** + * The editor's log console. z-40 (above the z-30 boot overlay and the z-35 + * fatal overlay): when a load fails, the log this panel holds is the only + * account of WHY, so it must never end up underneath the thing reporting the + * failure. Closed: a content-width tab pinned bottom-left (no right-0), so the + * version badge and the app's bottom edge stay visible/clickable. Open: the + * full-width footer panel. `panelRef` lets WasmTool detect log-text selections + * for its Ctrl/Cmd+C interception. + */ +export const ConsolePanel = React.forwardRef< + HTMLDivElement, + { + logs: string[]; + open: boolean; + setOpen: (v: boolean) => void; + /** Append a line to the log (used for the copy outcome). */ + append: (msg: string) => void; + } +>(function ConsolePanel({ logs, open, setOpen, append }, ref) { + return ( +
+ {open ? ( + <> +
+ + +
+
+            {logs.join("\n")}
+          
+ + ) : ( + + )} +
+ ); +}); diff --git a/web/standalone/src/components/wasm-tool/FatalOverlay.tsx b/web/standalone/src/components/wasm-tool/FatalOverlay.tsx new file mode 100644 index 0000000..49d7b72 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/FatalOverlay.tsx @@ -0,0 +1,36 @@ +// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged. + +/** + * Terminal failure — z-35, ABOVE the boot overlay but below the console panel, + * OUTSIDE the error boundary, and independent of `ready`: a post-boot runtime + * death gets a proper blue screen instead of a blank page, with the console + * panel forced open beneath it. + */ +export function FatalOverlay({ message }: { message: string }) { + return ( +
+

:(

+

+ The editor hit an unrecoverable error and stopped. +

+

+ {message} +

+

+ The console below records what was loading when this happened — + please copy it into a bug report. +

+
+ +
+
+ ); +} diff --git a/web/standalone/src/components/wasm-tool/LibLoadingOverlay.tsx b/web/standalone/src/components/wasm-tool/LibLoadingOverlay.tsx new file mode 100644 index 0000000..03e29ab --- /dev/null +++ b/web/standalone/src/components/wasm-tool/LibLoadingOverlay.tsx @@ -0,0 +1,48 @@ +// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged. +import { Loader2 } from "lucide-react"; +import type { LibLoadingState } from "./useLibNotices"; + +/** + * Eager library load overlay — the first chooser/editor open hydrates the + * whole library set from IDB into wasm (tens of seconds on the full CDN set) + * with the main thread blocked. Cover the (frozen) editor so it reads as + * "loading, just slow" rather than a hang. Shown post-boot; before `ready` + * the boot overlay already covers it. + */ +export function LibLoadingOverlay({ libLoading }: { libLoading: LibLoadingState }) { + return ( +
+ +

+ {libLoading.kind === "library" + ? "Loading libraries…" + : `Loading ${libLoading.kind} libraries…`} +

+ {libLoading.total > 0 && ( +
+
+
+
+ {/* Space-pad `done` to `total`'s width so the centered line + doesn't shift as the count gains a digit. */} +

+ {String(Math.min(libLoading.done, libLoading.total)).padStart( + String(libLoading.total).length, + " ", + )}{" "} + / {libLoading.total} libraries +

+
+ )} +

+ Moving the library set into the editor. The first open can take a + moment — it's cached after this. +

+
+ ); +} diff --git a/web/standalone/src/components/wasm-tool/NoticeStack.tsx b/web/standalone/src/components/wasm-tool/NoticeStack.tsx new file mode 100644 index 0000000..3af95c8 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/NoticeStack.tsx @@ -0,0 +1,135 @@ +// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged. +import { Loader2 } from "lucide-react"; +import type { SaveBlock } from "@/wasm/save-flow"; +import { libSyncLabel } from "./DownloadConsent"; +import type { LibSetNotice } from "./useLibNotices"; + +/** + * Every transient notice the running editor shows over the canvas: the + * bottom-left progress badges (lib pre-sync, 3D models), the busy pill, the + * durable save-blocked banner and the top-center toast column (lib error, + * placed-item update, lib-set change, doc revert). State + timers live in + * useLibNotices; this is the render. + */ +export function NoticeStack({ + ready, + libSync, + modelsSync, + libBusy, + saveBlocked, + libError, + onDismissLibError, + libUpdate, + onDismissLibUpdate, + libSetNotice, + onLibSetClick, + docReverted, + onDismissDocReverted, +}: { + ready: boolean; + libSync: { kind: string; done: number; total: number } | null; + modelsSync: string | null; + libBusy: string | null; + saveBlocked: SaveBlock | null; + libError: string | null; + onDismissLibError: () => void; + libUpdate: string | null; + onDismissLibUpdate: () => void; + libSetNotice: LibSetNotice | null; + onLibSetClick: () => void; + docReverted: string | null; + onDismissDocReverted: () => void; +}) { + return ( + <> + {/* Lib pre-sync warming IDB after the editor opened (big set) — the ONLY + surface for it now that the warm-up starts post-open: a small unobtrusive + indicator so the user knows browsing is still filling in behind them, + never something they are waiting on. */} + {ready && libSync && ( +
+ {" "} + {libSyncLabel(libSync)} +
+ )} + + {/* Board 3D models still prefetching into the cache (background). */} + {ready && modelsSync && ( +
+ {modelsSync} +
+ )} + + {/* A library item is being fetched (open/save). */} + {ready && libBusy && ( +
+ Loading {libBusy}… +
+ )} + + {/* A save path is durably BLOCKED (CAS conflict / unknown commit state) — + persistent full-width banner, no auto-dismiss: subsequent Ctrl+S on + the path is absorbed by the save lane, so this must stay visible. */} + {saveBlocked && ( +
+ {saveBlocked.message} +
+ )} + + {/* Top-center toast column: simultaneous notices stack instead of + overlapping (they all used to render at the same absolute spot). */} +
+ {/* Library error (e.g. a backend 404 on open) — auto-dismisses. */} + {libError && ( + + )} + + {/* A collaborator updated a symbol PLACED in this document — auto-dismisses. */} + {libUpdate && ( + + )} + + {/* A peer changed the team's lib set — click loads the new lib live + (kicadLibsAddEntry bridge), falling back to a reload offer. */} + {libSetNotice && ( + + )} + + {/* Backend rolled this doc back to the last valid state (kicad-validity). */} + {docReverted && ( + + )} +
+ + ); +} diff --git a/web/standalone/src/components/wasm-tool/SessionMenu.tsx b/web/standalone/src/components/wasm-tool/SessionMenu.tsx new file mode 100644 index 0000000..e87d94a --- /dev/null +++ b/web/standalone/src/components/wasm-tool/SessionMenu.tsx @@ -0,0 +1,321 @@ +// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged. +import * as React from "react"; +import { + AlertTriangle, + Crosshair, + EyeOff, + Layers, + Moon, + PanelsTopLeft, + RefreshCw, + Sun, +} from "lucide-react"; +import type { Tool } from "@pcbjam/shared"; +import { setTheme } from "@/lib/theme"; +import type { SourceDescriptor } from "@/lib/project-source-shared"; +import type { PresencePeer } from "@/wasm/collab/presence"; +import type { FollowTarget } from "@/wasm/collab/follow-user"; +import { PresenceRoster } from "@/components/PresenceRoster"; +import { SourceChip } from "@/components/SourceChip"; +import { OverlayMenu, OverlayMenuSection, overlayRowClass } from "@/components/OverlayMenu"; +import { CHROME_HOTKEY_LABEL } from "./ui-helpers"; +import type { StaleLibEntry } from "./useLibNotices"; + +/** + * Behind-the-library state (libs 0017 §2b/2c): placed items a peer updated in + * the library. Persistent — unlike the toast — and actionable without a page + * reload: "Update from library" re-reads just those items into the placed + * instances. + */ +export function StaleLibsRow({ + items, + updating, + readOnly, + onUpdate, + onDismiss, +}: { + items: Map; + updating: boolean; + readOnly: boolean; + onUpdate: () => void; + onDismiss: () => void; +}) { + const entries = [...items.values()]; + return ( +
+
+ + + {entries.reduce((n, e) => n + e.names.size, 0)} placed{" "} + {entries.every((e) => e.kind === "footprint") + ? "footprint(s)" + : entries.every((e) => e.kind === "symbol") + ? "symbol(s)" + : "item(s)"}{" "} + behind the library + +
+
    + {entries.flatMap((e) => + [...e.names].map((n) => ( +
  • + {e.lib}:{n} +
  • + )), + )} +
+
+ + +
+
+ ); +} + +/** + * Follow-user (0008): who we're following + how to stop. Esc also works + * because any canvas key input breaks the follow via noteLocalViewport only + * when the viewport moves — this banner is the explicit out. + */ +export function FollowBanner({ + target, + onStop, +}: { + target: FollowTarget; + onStop: () => void; +}) { + return ( +
+ + Following {target.name} — move to stop + + +
+ ); +} + +/** + * Overlay menu (0010): the single draggable circular icon replacing the old + * top-right row. Its badge is the peer count; the panel stacks the session + * sections — roster, source chip, view-only pill, stale-libs row, comments + * (portal slot filled by CommentLayer), viewer-panel toggles, chrome toggle, + * theme. It is the one control that stays up in canvas-only (chrome-hidden) + * mode. + */ +export function SessionMenu({ + tool, + readOnly, + theme, + peers, + activeSheetPath, + followingTarget, + onFollow, + sourceDescriptor, + staleLibItems, + staleUpdating, + onUpdateStale, + onDismissStale, + commentsUnread, + hasComments, + setCommentsSlot, + effectiveChromeHidden, + hasLayers, + layersOpen, + setLayersOpen, + inspectorOpen, + setInspectorOpen, + canToggleChrome, + chromeHidden, + onToggleChrome, +}: { + tool: Tool; + readOnly: boolean; + theme: string; + peers: PresencePeer[]; + activeSheetPath: string | undefined; + followingTarget: FollowTarget | null; + onFollow: (target: FollowTarget | null) => void; + sourceDescriptor: SourceDescriptor | undefined; + staleLibItems: Map; + staleUpdating: boolean; + onUpdateStale: () => void; + onDismissStale: () => void; + commentsUnread: { threads: number; mentioned: boolean }; + /** A comments controller is bound — the Comments section renders its slot. */ + hasComments: boolean; + /** Ref-callback slot the CommentLayer portals its bar/panel into. */ + setCommentsSlot: (el: HTMLDivElement | null) => void; + effectiveChromeHidden: boolean; + /** The layers bridge is available (pcbnew sessions). */ + hasLayers: boolean; + layersOpen: boolean; + setLayersOpen: (v: boolean) => void; + inspectorOpen: boolean; + setInspectorOpen: (v: boolean) => void; + /** The loaded bundle exports kicadSetChrome. */ + canToggleChrome: boolean; + chromeHidden: boolean; + onToggleChrome: () => void; +}) { + return ( + 0} + > + {/* PEOPLE — who else is here, and whose view you're locked to. The + follow state lives on each person's own row (PresenceRoster), so + there is no separate "Following…" banner to keep in sync. */} + {peers.length > 0 && ( + + + + )} + + {/* DOCUMENT — where this file came from and whether you may edit it. + SourceChip is shared with the light project pages, so instead of + restyling it we ask for its `muted` tone: colour drops to a dot, + and the chip sits in a normal row like everything else. */} + {(sourceDescriptor || readOnly || staleLibItems.size > 0) && ( + + {staleLibItems.size > 0 && ( + + )} + {sourceDescriptor && ( +
+ +
+ )} + {readOnly && ( +
+ + View only + + read-only + +
+ )} +
+ )} + + {hasComments && ( + +
+ + )} + + + {/* Viewer panels (viewer-panels): canvas-only stand-ins for the + chrome-hidden wx panes — available to viewers and to editors + in hide-UI mode alike. */} + {effectiveChromeHidden && hasLayers && ( + + )} + {effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && ( + + )} + {canToggleChrome && !readOnly && ( + + )} + {/* Light/dark toggle (comments-ux 0002): flips the shell theme; + the F4 effect in WasmTool re-themes the GAL canvas through the + bridge. Available to viewers too — theming isn't editing. */} + + + + ); +} diff --git a/web/standalone/src/components/wasm-tool/useLibNotices.ts b/web/standalone/src/components/wasm-tool/useLibNotices.ts new file mode 100644 index 0000000..53dc626 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/useLibNotices.ts @@ -0,0 +1,333 @@ +// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged. +import * as React from "react"; +import { + LIB_BUSY_EVENT, + LIB_ERROR_EVENT, + LIB_ITEM_UPDATED_EVENT, + LIB_LOADING_EVENT, + LIB_SET_CHANGED_EVENT, + type LibBusyDetail, + type LibErrorDetail, + type LibItemUpdatedDetail, + type LibLoadingDetail, + type LibSetChangedDetail, + type LibsSource, +} from "@/wasm/libs/source"; +import { MODELS_LOADING_EVENT, type ModelsLoadingDetail } from "@/wasm/libs/models-bridge"; +import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding"; +import { addAnnouncedLib } from "@/wasm/libs/runtime-add"; +import { reloadFallbackMsg } from "./ui-helpers"; + +/** One library's placed items a peer updated (libs 0017 §2b). */ +export interface StaleLibEntry { + kind: string; + lib: string; + names: Set; +} + +export interface LibSetNotice { + message: string; + detail: LibSetChangedDetail; + mode: "load" | "reload"; +} + +export interface LibLoadingState { + kind: string; + done: number; + total: number; +} + +/** + * Every notice the running editor raises about its libraries and document — + * the window events the libs bridge / collab binding dispatch, the transient + * toasts they become (with their auto-dismiss timers), the persistent + * "behind the library" state with its update-from-library action, and the + * eager-load / 3D-model progress badges. Pure state + listeners; WasmTool + * renders it through NoticeStack / SessionMenu / LibLoadingOverlay. + */ +export function useLibNotices(opts: { + /** The one libs source instance the running editor uses (set by the boot + * effect) — the libset toast's action needs it to re-list and load. */ + getLibsSource: () => LibsSource | null; +}) { + // A library item currently being fetched (open/save), for a transient spinner. + const [libBusy, setLibBusy] = React.useState(null); + // Last lib error (e.g. a backend 404 on open), shown as a dismissible toast. + const [libError, setLibError] = React.useState(null); + // A collaborator updated library items that are PLACED in the open document + // (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn. + const [libUpdate, setLibUpdate] = React.useState(null); + // Persistent "behind the library" state (libs 0017 §2b): every PLACED item a + // peer's lib edit touched, keyed `\u0000` → names. The toast above + // is disposable; this survives until the user updates from the library + // (2c) or dismisses it, and drives the FAB's amber triangle + the Document + // section row. Symbols/footprints only — the kinds with a placed-usage + // bridge (kicadLibsSymbolUsage / kicadLibsFootprintUsage). + const [staleLibItems, setStaleLibItems] = React.useState>( + () => new Map(), + ); + const [staleUpdating, setStaleUpdating] = React.useState(false); + const staleKey = (kind: string, lib: string) => `${kind}\u0000${lib}`; + const noteStale = React.useCallback((kind: string, lib: string, names: string[]) => { + if (names.length === 0) return; + setStaleLibItems((prev) => { + const next = new Map(prev); + const k = staleKey(kind, lib); + const cur = next.get(k) ?? { kind, lib, names: new Set() }; + const merged = new Set(cur.names); + for (const n of names) merged.add(n); + next.set(k, { kind, lib, names: merged }); + return next; + }); + }, []); + const clearStale = React.useCallback((key?: string) => { + setStaleLibItems((prev) => { + if (key === undefined) return new Map(); + const next = new Map(prev); + next.delete(key); + return next; + }); + }, []); + /** Update every placed instance of the stale items from the library (2c). */ + const updateStaleFromLibrary = React.useCallback(async () => { + const mod = (window as { Module?: { kicadUpdateFromLibrary?: unknown } }).Module; + const fn = mod?.kicadUpdateFromLibrary; + if (typeof fn !== "function") { + setLibError("This editor build can't update placed items from the library — reload to refresh them."); + return; + } + setStaleUpdating(true); + try { + for (const [key, entry] of staleLibItems) { + // The bridge queues the edit on the frame's coroutine and answers + // {queued:true}; the outcome arrives as a `pcbjam:lib-update-done` + // window event (or {ok:false,error} synchronously). + const done = new Promise<{ ok: boolean; updated?: number; error?: string }>((resolve) => { + const onDone = (e: Event) => { + window.removeEventListener("pcbjam:lib-update-done", onDone); + resolve((e as CustomEvent<{ ok: boolean; updated?: number }>).detail); + }; + window.addEventListener("pcbjam:lib-update-done", onDone); + setTimeout(() => { + window.removeEventListener("pcbjam:lib-update-done", onDone); + resolve({ ok: false, error: "timed out" }); + }, 30_000); + }); + let res: { ok?: boolean; queued?: boolean; error?: string } = {}; + try { + res = JSON.parse( + (fn as (kind: string, lib: string, namesJson: string) => string)( + entry.kind, + entry.lib, + JSON.stringify([...entry.names]), + ), + ) as typeof res; + } catch { + res = { ok: false, error: "bridge call failed" }; + } + const outcome = res.ok === false ? { ok: false, error: res.error } : await done; + if (!outcome.ok) { + setLibError(`Couldn't update from the library: ${outcome.error ?? "unknown error"}`); + continue; + } + console.log(`[libs] updated ${outcome.updated ?? "?"} placed ${entry.kind}(s) from "${entry.lib}"`); + clearStale(key); + } + } finally { + setStaleUpdating(false); + } + }, [staleLibItems, clearStale]); + // The backend rolled this document back to its last valid state + // (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding). + const [docReverted, setDocReverted] = React.useState(null); + // A peer changed the team's lib SET mid-session (LIB_SET_CHANGED_EVENT — + // the scope room's `libset` broadcast). The lib table is frozen at boot, so + // the toast's click action loads the new lib live (addAnnouncedLib), with a + // reload fallback when the runtime bridge is missing. + const [libSetNotice, setLibSetNotice] = React.useState(null); + // Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on + // first chooser/editor open). Drives a full-cover overlay so the freeze reads as + // "loading, just slow" rather than a hang. Null when idle; `done/total` count the + // per-lib fat-load crossings so the overlay can show a progress bar. + const [libLoading, setLibLoading] = React.useState(null); + // Board 3D-model prefetch in flight (background; the viewer works without it — + // anything still missing lazy-loads per model). Small badge, not an overlay. + const [modelsSync, setModelsSync] = React.useState(null); + + // Loading/error chrome for library item fetches (open/save), driven by events + // the libs bridge dispatches (wasm/libs/source). The fetch is otherwise + // invisible; a 404 would silently do nothing without this. + React.useEffect(() => { + let busyTimer: ReturnType | undefined; + const onBusy = (e: Event) => { + const d = (e as CustomEvent).detail; + clearTimeout(busyTimer); + if (d.busy) { + // Debounce — only flag slow fetches, so fast ones don't flicker. + busyTimer = setTimeout(() => setLibBusy(d.name || "library item"), 180); + } else { + setLibBusy(null); + } + }; + const onError = (e: Event) => { + setLibError((e as CustomEvent).detail.message); + }; + const onItemUpdated = (e: Event) => { + const d = (e as CustomEvent).detail; + // Only warn when the update touches something PLACED here — the library + // tree already reflects updates to everything else. Both kinds have a + // placed-usage bridge now (libs 0017 §2d added the footprint one). + if (d.usedNames.length === 0) return; + const label = d.kind === "footprint" ? "Footprint" : "Symbol"; + const names = d.usedNames.map((n) => `"${n}"`).join(", "); + noteStale(d.kind, d.lib, d.usedNames); + setLibUpdate( + `${d.usedNames.length === 1 ? label : `${label}s`} ${names} in "${d.lib}" ` + + `${d.usedNames.length === 1 ? "was" : "were"} updated by a collaborator — ` + + `placed copies keep the previous version. Update them from the session menu.`, + ); + }; + const onDocReverted = (e: Event) => { + const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail; + setDocReverted( + `This document was rolled back to its last valid state — invalid content ` + + `was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`, + ); + }; + const onLibSet = (e: Event) => { + const d = (e as CustomEvent).detail; + // Only additions get a call to action — a removed lib's table row is + // inert until the next boot and needs no interruption. + if (d.op !== "add") return; + setLibSetNotice({ + message: d.name + ? `A collaborator added library "${d.name}" — click to load it into this session.` + : `A collaborator added a new library — click to load it into this session.`, + detail: d, + mode: "load", + }); + }; + window.addEventListener(LIB_BUSY_EVENT, onBusy); + window.addEventListener(LIB_ERROR_EVENT, onError); + window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated); + window.addEventListener(LIB_SET_CHANGED_EVENT, onLibSet); + window.addEventListener(DOC_REVERTED_EVENT, onDocReverted); + return () => { + clearTimeout(busyTimer); + window.removeEventListener(LIB_BUSY_EVENT, onBusy); + window.removeEventListener(LIB_ERROR_EVENT, onError); + window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated); + window.removeEventListener(LIB_SET_CHANGED_EVENT, onLibSet); + window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted); + }; + }, [noteStale]); + + // Auto-dismiss the lib error toast. + React.useEffect(() => { + if (!libError) return; + const t = setTimeout(() => setLibError(null), 6000); + return () => clearTimeout(t); + }, [libError]); + + // Auto-dismiss the lib update toast (a touch longer — it carries a caveat). + React.useEffect(() => { + if (!libUpdate) return; + const t = setTimeout(() => setLibUpdate(null), 10_000); + return () => clearTimeout(t); + }, [libUpdate]); + + // Auto-dismiss the lib-set toast (long — it carries a click action). + React.useEffect(() => { + if (!libSetNotice) return; + const t = setTimeout(() => setLibSetNotice(null), 30_000); + return () => clearTimeout(t); + }, [libSetNotice]); + + // Auto-dismiss the doc-reverted toast (longest — the user should see it). + React.useEffect(() => { + if (!docReverted) return; + const t = setTimeout(() => setDocReverted(null), 15_000); + return () => clearTimeout(t); + }, [docReverted]); + + // Full-library eager load overlay. The fat-load fires one loading:true/false + // pair PER library (222 on the full set), and between them the C++ side parses + // with the main thread blocked. Show immediately on `true`, and only hide after + // a short quiet gap on `false` (reset by the next lib's `true`) — so the overlay + // stays continuous across the whole run and drops shortly after the last lib, + // instead of flickering 222 times. + React.useEffect(() => { + let hideTimer: ReturnType | undefined; + const onLoading = (e: Event) => { + const d = (e as CustomEvent).detail; + clearTimeout(hideTimer); + // Update the bar on every event (true and false) so the count reflects the + // latest lib; arm the hide only when the run reports it's winding down. + setLibLoading({ kind: d.kind || "library", done: d.done, total: d.total }); + if (!d.loading) { + hideTimer = setTimeout(() => setLibLoading(null), 700); + } + }; + window.addEventListener(LIB_LOADING_EVENT, onLoading); + return () => { + clearTimeout(hideTimer); + window.removeEventListener(LIB_LOADING_EVENT, onLoading); + }; + }, []); + + // Board 3D-model prefetch progress (models-bridge prescan) — background badge. + React.useEffect(() => { + const onModels = (e: Event) => { + const d = (e as CustomEvent).detail; + setModelsSync( + d.loading ? `Fetching 3D models — ${d.done}/${d.total}` : null, + ); + }; + window.addEventListener(MODELS_LOADING_EVENT, onModels); + return () => window.removeEventListener(MODELS_LOADING_EVENT, onModels); + }, []); + + const { getLibsSource } = opts; + /** The libset toast's click: load the announced lib live, else offer a reload. */ + const onLibSetClick = React.useCallback(() => { + const notice = libSetNotice; + if (!notice) return; + if (notice.mode === "reload") { + window.location.reload(); + return; + } + const source = getLibsSource(); + if (!source) { + setLibSetNotice({ ...notice, mode: "reload", message: reloadFallbackMsg(notice) }); + return; + } + setLibSetNotice(null); + void addAnnouncedLib(source, notice.detail, (m) => console.log(m)).then((ok) => { + if (!ok) { + setLibSetNotice({ ...notice, mode: "reload", message: reloadFallbackMsg(notice) }); + } + }); + }, [libSetNotice, getLibsSource]); + + const dismissLibError = React.useCallback(() => setLibError(null), []); + const dismissLibUpdate = React.useCallback(() => setLibUpdate(null), []); + const dismissDocReverted = React.useCallback(() => setDocReverted(null), []); + + return { + libBusy, + libError, + dismissLibError, + libUpdate, + dismissLibUpdate, + libSetNotice, + onLibSetClick, + docReverted, + dismissDocReverted, + libLoading, + modelsSync, + staleLibItems, + staleUpdating, + clearStale, + updateStaleFromLibrary, + }; +}