feat: editor loading chrome — boot overlay, item spinner, error toast
The WASM tools boot + fetch lib items with no visible feedback, so a big tool download looks like a freeze and a failed body fetch (e.g. a backend 404) looks like nothing happened. Add a full-screen boot overlay that stays until the wx UI has actually built (waitForWxUi, gated on wxElementRegistry — not just boot resolve, which flashes a blank editor), a debounced spinner while an item is being opened/saved, and an auto-dismissing error toast when a body can't be loaded. Driven by events the libs bridge now dispatches (LIB_BUSY/LIB_ERROR). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f5d37993e
commit
92b5fd7a42
2 changed files with 166 additions and 5 deletions
|
|
@ -11,7 +11,7 @@ import {
|
|||
type KicadDoc,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import {
|
||||
libsSourceConfig,
|
||||
WASM_ASSET_BASE_URL,
|
||||
|
|
@ -19,7 +19,13 @@ import {
|
|||
type DocSource,
|
||||
} from "@/lib/config";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import type { LibsSource } from "@/wasm/libs/source";
|
||||
import {
|
||||
LIB_BUSY_EVENT,
|
||||
LIB_ERROR_EVENT,
|
||||
type LibBusyDetail,
|
||||
type LibErrorDetail,
|
||||
type LibsSource,
|
||||
} from "@/wasm/libs/source";
|
||||
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||
|
|
@ -332,6 +338,21 @@ async function maybeStartCollab(
|
|||
clog("connected ✓");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the wxWidgets UI has actually built some elements — it populates a
|
||||
* frame or two AFTER the boot sequence resolves, so dropping the loading overlay
|
||||
* on boot-resolve flashes a blank editor. Polls `wxElementRegistry` (the same
|
||||
* "UI built" signal the e2e suite uses) and falls through after a timeout so a
|
||||
* tool with a minimal UI can never hang the overlay.
|
||||
*/
|
||||
async function waitForWxUi(win: ToolWindow, timeoutMs = 25_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((win.wxElementRegistry?.findAll({}).length ?? 0) > 3) return;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots a KiCad tool directly in this React document (no iframe): builds the
|
||||
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
|
||||
|
|
@ -388,6 +409,13 @@ export function WasmTool({
|
|||
const [logs, setLogs] = React.useState<string[]>([]);
|
||||
const [showLog, setShowLog] = React.useState(false);
|
||||
const [oomExhausted, setOomExhausted] = React.useState(false);
|
||||
// Editor lifecycle for the loading chrome: false until the tool has booted +
|
||||
// opened (covers the big WASM-compile freeze with a full-screen overlay).
|
||||
const [ready, setReady] = React.useState(false);
|
||||
// A library item currently being fetched (open/save), for a transient spinner.
|
||||
const [libBusy, setLibBusy] = React.useState<string | null>(null);
|
||||
// Last lib error (e.g. a backend 404 on open), shown as a dismissible toast.
|
||||
const [libError, setLibError] = React.useState<string | null>(null);
|
||||
|
||||
const base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
|
||||
const append = React.useCallback(
|
||||
|
|
@ -395,6 +423,40 @@ export function WasmTool({
|
|||
[],
|
||||
);
|
||||
|
||||
// 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<typeof setTimeout> | undefined;
|
||||
const onBusy = (e: Event) => {
|
||||
const d = (e as CustomEvent<LibBusyDetail>).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<LibErrorDetail>).detail.message);
|
||||
};
|
||||
window.addEventListener(LIB_BUSY_EVENT, onBusy);
|
||||
window.addEventListener(LIB_ERROR_EVENT, onError);
|
||||
return () => {
|
||||
clearTimeout(busyTimer);
|
||||
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
|
||||
window.removeEventListener(LIB_ERROR_EVENT, onError);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-dismiss the lib error toast.
|
||||
React.useEffect(() => {
|
||||
if (!libError) return;
|
||||
const t = setTimeout(() => setLibError(null), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [libError]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
|
||||
slug,
|
||||
|
|
@ -488,6 +550,11 @@ export function WasmTool({
|
|||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
// Tool booted + project opened. Wait for the wx UI to actually build
|
||||
// before dropping the overlay, so we don't reveal a still-blank editor.
|
||||
await waitForWxUi(win);
|
||||
setStatus("");
|
||||
setReady(true);
|
||||
} catch (err) {
|
||||
append(`[fatal] ${String(err)}`);
|
||||
setStatus(`Error: ${String(err)}`);
|
||||
|
|
@ -521,12 +588,61 @@ export function WasmTool({
|
|||
/>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
{/* Boot overlay — covers the big WASM download/compile freeze until the
|
||||
tool has booted + opened. */}
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#1a1a2e] text-white">
|
||||
{status.startsWith("Error") ? (
|
||||
<>
|
||||
<p className="max-w-md px-6 text-center font-mono text-sm text-red-300">
|
||||
{status}
|
||||
</p>
|
||||
<button
|
||||
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
<p className="font-mono text-sm text-white/80">
|
||||
{status || "Loading…"}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-white/40">
|
||||
First load downloads the tool (large) — this can take a moment.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transient post-boot status (e.g. file open). */}
|
||||
{ready && status && (
|
||||
<div className="pointer-events-none absolute left-3 top-3 z-20 rounded bg-black/70 px-3 py-2 font-mono text-xs text-white">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A library item is being fetched (open/save). */}
|
||||
{ready && libBusy && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
|
||||
<Loader2 className="animate-spin" size={14} /> Loading {libBusy}…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Library error (e.g. a backend 404 on open) — auto-dismisses. */}
|
||||
{libError && (
|
||||
<button
|
||||
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-red-950/95 px-3 py-2 text-center text-xs text-red-100 shadow-lg ring-1 ring-red-500/40"
|
||||
onClick={() => setLibError(null)}
|
||||
title="Dismiss"
|
||||
>
|
||||
{libError}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20">
|
||||
<button
|
||||
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,36 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events the libs bridge dispatches on `window` so the editor chrome (WasmTool)
|
||||
* can show a loading state for the otherwise-invisible item fetch, and surface an
|
||||
* error when a body can't be loaded (e.g. a backend 404). Decoupled via events so
|
||||
* `wasm/libs` stays UI-agnostic.
|
||||
*/
|
||||
export const LIB_BUSY_EVENT = "pcbjam:lib-busy";
|
||||
export const LIB_ERROR_EVENT = "pcbjam:lib-error";
|
||||
|
||||
export interface LibBusyDetail {
|
||||
busy: boolean;
|
||||
op: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}
|
||||
export interface LibErrorDetail {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function emitLibBusy(detail: LibBusyDetail): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(LIB_BUSY_EVENT, { detail }));
|
||||
}
|
||||
function emitLibError(message: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(LIB_ERROR_EVENT, { detail: { message } }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Optional artificial latency (`?libdelay=1500`) to exercise the bridge. */
|
||||
function artificialDelayMs(): number {
|
||||
const raw = new URLSearchParams(window.location.search).get("libdelay");
|
||||
|
|
@ -142,6 +172,10 @@ export function installLibsProvider(
|
|||
if (!id) return null;
|
||||
if (delay) await sleep(delay);
|
||||
|
||||
// "get"/"save" are user-triggered (open/save an item) and otherwise give no
|
||||
// visible feedback — broadcast busy + errors so the editor can show them.
|
||||
const userFacing = op === "get" || op === "save";
|
||||
if (userFacing) emitLibBusy({ busy: true, op, kind, name: arg });
|
||||
try {
|
||||
switch (op) {
|
||||
case "list": {
|
||||
|
|
@ -153,8 +187,15 @@ export function installLibsProvider(
|
|||
const key = kind === "footprint" ? "footprints" : "symbols";
|
||||
return JSON.stringify({ [key]: names });
|
||||
}
|
||||
case "get":
|
||||
return await source.getItemBody(id, kind, arg);
|
||||
case "get": {
|
||||
const body = await source.getItemBody(id, kind, arg);
|
||||
if (body === null) {
|
||||
emitLibError(
|
||||
`Couldn't open "${arg}" — the backend has no body for it (404).`,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
case "save": {
|
||||
let parsed: { name?: string; body?: string };
|
||||
try {
|
||||
|
|
@ -174,6 +215,7 @@ export function installLibsProvider(
|
|||
parsed.name,
|
||||
parsed.body,
|
||||
);
|
||||
if (!ok) emitLibError(`Couldn't save "${parsed.name}".`);
|
||||
return ok ? "ok" : null;
|
||||
}
|
||||
default:
|
||||
|
|
@ -181,7 +223,10 @@ export function installLibsProvider(
|
|||
}
|
||||
} catch (e) {
|
||||
log(`[libs] request failed: ${String(e)}`);
|
||||
if (userFacing) emitLibError(`Failed to ${op} "${arg}".`);
|
||||
return null;
|
||||
} finally {
|
||||
if (userFacing) emitLibBusy({ busy: false, op, kind, name: arg });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue