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 KicadDoc,
|
||||||
type Tool,
|
type Tool,
|
||||||
} from "@pcbjam/shared";
|
} from "@pcbjam/shared";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
libsSourceConfig,
|
libsSourceConfig,
|
||||||
WASM_ASSET_BASE_URL,
|
WASM_ASSET_BASE_URL,
|
||||||
|
|
@ -19,7 +19,13 @@ import {
|
||||||
type DocSource,
|
type DocSource,
|
||||||
} from "@/lib/config";
|
} from "@/lib/config";
|
||||||
import { bootKicadTool } from "@/wasm/boot";
|
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 { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||||
|
|
@ -332,6 +338,21 @@ async function maybeStartCollab(
|
||||||
clog("connected ✓");
|
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
|
* Boots a KiCad tool directly in this React document (no iframe): builds the
|
||||||
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
|
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
|
||||||
|
|
@ -388,6 +409,13 @@ export function WasmTool({
|
||||||
const [logs, setLogs] = React.useState<string[]>([]);
|
const [logs, setLogs] = React.useState<string[]>([]);
|
||||||
const [showLog, setShowLog] = React.useState(false);
|
const [showLog, setShowLog] = React.useState(false);
|
||||||
const [oomExhausted, setOomExhausted] = 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 base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
|
||||||
const append = React.useCallback(
|
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(() => {
|
React.useEffect(() => {
|
||||||
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
|
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -488,6 +550,11 @@ export function WasmTool({
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
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) {
|
} catch (err) {
|
||||||
append(`[fatal] ${String(err)}`);
|
append(`[fatal] ${String(err)}`);
|
||||||
setStatus(`Error: ${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">
|
<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}
|
{status}
|
||||||
</div>
|
</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">
|
<div className="absolute bottom-0 left-0 right-0 z-20">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
|
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. */
|
/** Optional artificial latency (`?libdelay=1500`) to exercise the bridge. */
|
||||||
function artificialDelayMs(): number {
|
function artificialDelayMs(): number {
|
||||||
const raw = new URLSearchParams(window.location.search).get("libdelay");
|
const raw = new URLSearchParams(window.location.search).get("libdelay");
|
||||||
|
|
@ -142,6 +172,10 @@ export function installLibsProvider(
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
if (delay) await sleep(delay);
|
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 {
|
try {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "list": {
|
case "list": {
|
||||||
|
|
@ -153,8 +187,15 @@ export function installLibsProvider(
|
||||||
const key = kind === "footprint" ? "footprints" : "symbols";
|
const key = kind === "footprint" ? "footprints" : "symbols";
|
||||||
return JSON.stringify({ [key]: names });
|
return JSON.stringify({ [key]: names });
|
||||||
}
|
}
|
||||||
case "get":
|
case "get": {
|
||||||
return await source.getItemBody(id, kind, arg);
|
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": {
|
case "save": {
|
||||||
let parsed: { name?: string; body?: string };
|
let parsed: { name?: string; body?: string };
|
||||||
try {
|
try {
|
||||||
|
|
@ -174,6 +215,7 @@ export function installLibsProvider(
|
||||||
parsed.name,
|
parsed.name,
|
||||||
parsed.body,
|
parsed.body,
|
||||||
);
|
);
|
||||||
|
if (!ok) emitLibError(`Couldn't save "${parsed.name}".`);
|
||||||
return ok ? "ok" : null;
|
return ok ? "ok" : null;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
@ -181,7 +223,10 @@ export function installLibsProvider(
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`[libs] request failed: ${String(e)}`);
|
log(`[libs] request failed: ${String(e)}`);
|
||||||
|
if (userFacing) emitLibError(`Failed to ${op} "${arg}".`);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (userFacing) emitLibBusy({ busy: false, op, kind, name: arg });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue