libs 0017: sync overrides indexed + stale-lib session menu + Cmd+S DOM-focus fix + WasmTool split

- kicadLibsFootprintUsage + kicadUpdateFromLibrary embinds (result via
  pcbjam:lib-update-done — runOnCoroutine is deferred)
- standalone: stale-lib FAB triangle + session-menu Update-from-library row,
  save busy notice names the item, footprint placed-usage in the toast
- WasmTool.tsx split: module helpers → components/wasm-tool/
- specs: save-cmd-key (Meta+S, mac UA), fpedit-cmd-save (DOM-focus repro)
- wxwidgets → cdd5a5c (wxDomBlurActive)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa
This commit is contained in:
Gergő Törcsvári 2026-08-25 17:58:54 +02:00
commit 866db5888c
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
18 changed files with 2084 additions and 1092 deletions

View file

@ -1,5 +1,5 @@
import * as React from "react";
import { Users } from "lucide-react";
import { AlertTriangle, Users } from "lucide-react";
import { useDraggablePanel } from "@/components/useDraggablePanel";
/**
@ -64,10 +64,15 @@ export function OverlayMenu({
badge,
unread = 0,
unreadMention = false,
alert = false,
children,
}: {
/** Peer count shown on the FAB (0 hides the badge). */
badge: number;
/** Something in this session is behind the latest state (libs 0017 §2b —
* e.g. placed library items updated by a peer): amber warning triangle on
* the FAB, persisting until the session-menu row resolves it. */
alert?: boolean;
/** Unread comment threads (comments-ux 0001 C) — amber FAB badge, bottom
* corner; rose when one of them mentions the current user. 0 hides it. */
unread?: number;
@ -156,6 +161,15 @@ export function OverlayMenu({
{badge}
</span>
)}
{alert && (
<span
data-testid="overlay-menu-alert"
title="Some of what you see is behind the latest library state — open the session menu"
className="absolute -bottom-1 -left-1 flex h-4 w-4 items-center justify-center rounded-full bg-amber-400 text-neutral-900 ring-1 ring-white/70 dark:ring-neutral-950"
>
<AlertTriangle size={10} strokeWidth={2.5} />
</span>
)}
{unread > 0 && (
<span
data-testid="overlay-menu-unread-badge"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,309 @@
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
import * as React from "react";
import { Download } from "lucide-react";
import type { Tool } from "@pcbjam/shared";
import { TOOL_BUNDLE } from "@/wasm/constants";
import { fetchWasmStoredSize, hasAnyWasmDownload, type WasmMeta } from "@/wasm/wasm-assets";
import type { LibsSource, LibsSyncState } from "@/wasm/libs/source";
import { LIB_KIND_FOR_TOOL } from "./ui-helpers";
/** What the download-consent dialog quotes (standalone-load-ux 0001). */
export interface ConsentInfo {
/** Over-the-wire (COMPRESSED) bytes for the editor bundle — null when the CDN
* carries no size info and HEAD yielded none ("large download" wording then).
* Quoted as "compressed" in the dialog: the load screen's progress bar counts
* RAW decoded bytes, which are several times this. */
toolBytes: number | null;
/** Raw (decoded) wasm bytes — the same total the progress bar counts, quoted
* next to `toolBytes` so the two figures can't read as a contradiction. Null
* when the manifest prices nothing (HEAD fallback knows the wire size only). */
toolRawBytes: number | null;
/** A previous version of this bundle was downloaded → word it as an update. */
update: boolean;
/** The lib kind this tool pre-syncs — warmed in parallel with the wasm
* download (the boot fan-out; see startLibPresync). Editors with a project
* file open without waiting on it; the lib editors wait (enumerate gate). */
libNowKind: "symbol" | "footprint" | null;
libNow: LibsSyncState | null;
/** The other kind a merged-bundle session can pull lazily ("only if used"). */
libLaterKind: "symbol" | "footprint" | null;
libLater: LibsSyncState | null;
}
/**
* Gather the consent dialog's figures. Everything is best-effort: only small
* JSON/HEAD requests run here (never a bundle or the wasm), and any missing
* piece degrades to vaguer wording rather than blocking the dialog.
*/
export async function gatherConsentInfo(
meta: WasmMeta,
source: LibsSource | null,
tool: Tool,
): Promise<ConsentInfo> {
let toolBytes = meta.sizes?.totalStored ?? null;
if (toolBytes === null) {
toolBytes = await fetchWasmStoredSize(meta.base, meta.bundle);
}
const libNowKind = LIB_KIND_FOR_TOOL[tool] ?? null;
// The merged kicad_editor bundle seeds BOTH lib tables — the other kind loads
// lazily per-lib when a cross-face feature reaches it (see boot.ts libKinds).
const libLaterKind =
TOOL_BUNDLE[tool] === "kicad_editor" && libNowKind
? libNowKind === "symbol"
? ("footprint" as const)
: ("symbol" as const)
: null;
const state = async (
kind: "symbol" | "footprint" | null,
): Promise<LibsSyncState | null> => {
if (!kind || !source?.syncState) return null;
try {
return await source.syncState(kind);
} catch {
return null;
}
};
return {
toolBytes,
// Only the manifest prices the DECODED wasm (wasm-assets WasmBundleSizes);
// the HEAD fallback above sees the compressed body alone.
toolRawBytes: meta.sizes?.wasm ?? null,
update: hasAnyWasmDownload(meta.bundle),
libNowKind,
libNow: await state(libNowKind),
libLaterKind,
libLater: await state(libLaterKind),
};
}
function approxMB(bytes: number): string {
const mb = bytes / 1e6;
return `~${mb >= 10 ? Math.round(mb) : Math.max(0.1, mb).toFixed(1)} MB`;
}
/** "1 library" / "155 libraries". */
function libCount(n: number): string {
return `${n} librar${n === 1 ? "y" : "ies"}`;
}
/**
* One consent row's size figure — MB only; the COUNTS live in the row's detail
* line (libNowDetail/libLaterDetail), because "download" and "check" cover
* different sets of libs and one number can't stand for both.
*/
function libStateLabel(s: LibsSyncState | null): string | null {
if (!s || s.total === 0) return null;
if (s.warm >= s.total) return "already cached";
// sizesKnown false ⇒ some cold libs carry no published size, so coldBytes is a
// FLOOR, never the total: say "at least", and quote nothing at all when not a
// single cold lib was priced.
if (!s.sizesKnown) {
return s.coldBytes > 0 ? `at least ${approxMB(s.coldBytes)}` : null;
}
return approxMB(s.coldBytes);
}
/**
* Detail line for the kind this editor PRE-SYNCS at boot. Two different numbers
* matter here and quoting either alone reads as a lie: only the libs that aren't
* cached yet download their contents ("1 library"), but the pre-sync walks EVERY
* library of the kind to see whether it changed — and that walk is what the
* "Syncing footprint libraries — 99/155" bar counts. A null state (source can't
* tell) keeps the old count-free wording.
*/
function libNowDetail(s: LibsSyncState | null): string {
const base = "this editor browses them";
// No warmth answer at all: say only what stays true regardless of counts —
// the warm-up runs alongside the editor download and finishes in the
// background, so "downloaded now" would overpromise as well as vague.
if (!s || s.total === 0) return `${base} — fetched in the background`;
const cold = s.total - s.warm;
if (cold === 0) {
return `${base} — ${libCount(s.total)} already here, just checked for updates`;
}
if (s.warm === 0) return `${base} — downloads ${libCount(s.total)}`;
return `${base} — downloads ${libCount(cold)}, checks all ${s.total} for updates`;
}
/**
* Detail line for the OTHER kind of a merged-bundle session: never walked at
* boot (no pre-sync, no update check) — each lib is fetched lazily the first
* time a cross-face feature reaches it.
*/
function libLaterDetail(s: LibsSyncState | null): string {
const base = "downloaded later, only if you use them";
if (!s || s.total === 0) return base;
const cold = s.total - s.warm;
if (cold === 0) return `${libCount(s.total)} already here — nothing to download`;
return `${base} (${libCount(cold)} not here yet)`;
}
/**
* The editor bundle's figure. `toolBytes` is the OVER-THE-WIRE (compressed)
* size, while the load screen's progress bar counts RAW decoded bytes — quoting
* the first bare is what made "~32 MB" look like a lie next to a ~150 MB bar.
* Show both whenever the manifest prices them; the HEAD fallback knows only the
* wire size, so it says just that.
*/
function toolFigure(info: ConsentInfo): React.ReactNode {
if (info.toolBytes === null) return "large (hundreds of MB)";
const wire = `${approxMB(info.toolBytes)} compressed`;
if (info.toolRawBytes === null) return wire;
return (
<>
{wire}
<br />
<span className="text-white/50">
{approxMB(info.toolRawBytes)} uncompressed
</span>
</>
);
}
/**
* The download-consent card (standalone-load-ux 0001): what's about to be
* pulled onto this device — the editor bundle now, the tool's lib kind now,
* the other kind later on demand — and an OK that actually gates the fetches.
*/
export function DownloadConsent({
info,
onAccept,
}: {
info: ConsentInfo;
onAccept: (always: boolean) => void;
}) {
const [always, setAlways] = React.useState(false);
const kindTitle = (k: "symbol" | "footprint") =>
k === "symbol" ? "Symbol libraries" : "Footprint libraries";
const row = (
title: string,
detail: string,
figure: React.ReactNode,
testid: string,
) => (
<li
data-testid={testid}
className="flex items-baseline gap-3 border-t border-white/10 py-2 first:border-t-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-white/90">{title}</p>
<p className="text-xs text-white/50">{detail}</p>
</div>
{figure && (
<span className="whitespace-nowrap text-right font-mono text-xs leading-snug text-white/70">
{figure}
</span>
)}
</li>
);
return (
<div
data-testid="download-consent"
className="flex w-full max-w-md flex-col items-center gap-4 px-6"
>
<Download size={32} className="text-white/70" />
<h2 className="text-base font-semibold">
{info.update ? "Editor update available" : "One-time download needed"}
</h2>
<p className="text-center text-sm text-white/70">
PCBJam runs KiCad fully in your browser.{" "}
{info.update
? "This release ships a new editor build, so it needs downloading again — it's cached after that."
: "Opening this tool downloads it once — repeat visits load from your browser's cache."}
</p>
<ul className="w-full rounded-lg bg-white/5 px-4 py-1">
{row(
"Editor engine",
"the KiCad build, downloaded now",
toolFigure(info),
"consent-row-tool",
)}
{info.libNowKind &&
row(
kindTitle(info.libNowKind),
libNowDetail(info.libNow),
libStateLabel(info.libNow),
"consent-row-now",
)}
{info.libLaterKind &&
row(
kindTitle(info.libLaterKind),
libLaterDetail(info.libLater),
libStateLabel(info.libLater),
"consent-row-later",
)}
</ul>
<label className="flex cursor-pointer items-center gap-2 text-xs text-white/60">
<input
type="checkbox"
checked={always}
onChange={(e) => setAlways(e.target.checked)}
className="accent-white/80"
/>
Always download without asking
</label>
<button
data-testid="consent-accept"
className="rounded bg-white/90 px-4 py-1.5 text-sm font-medium text-[#1a1a2e] hover:bg-white"
onClick={() => onAccept(always)}
>
Download &amp; open
</button>
</div>
);
}
/**
* Fixed-width lib pre-sync line, e.g. "Checking symbol libraries — 42/208".
* "Checking", not "downloading": the walk visits every lib of the kind but
* downloads only the new/changed ones — a bare counter read as 155 downloads
* (standalone-load-ux follow-up). The prefix is constant and `done` is
* space-padded to `total`'s digit count, so the text stays still while the
* counter ticks (render it in a font-mono + whitespace-pre element so the pad
* spaces hold their width).
*/
export function libSyncLabel(s: { kind: string; done: number; total: number }): string {
const total = String(s.total);
const done = String(Math.min(s.done, s.total)).padStart(total.length, " ");
return `Checking ${s.kind} libraries — ${done}/${total}`;
}
/**
* WASM download progress for the boot overlay. A determinate bar when the server
* sent a Content-Length the decoded stream agrees with; otherwise just MB so far
* (gzip/br makes Content-Length the COMPRESSED size, so `loaded` can pass it).
*/
export function DownloadProgress({
progress,
}: {
progress: { loaded: number; total: number } | null;
}) {
if (!progress) return null;
const mb = (n: number) => `${(n / 1e6).toFixed(1)} MB`;
const determinate = progress.total > 0 && progress.loaded <= progress.total;
const pct = determinate
? Math.round((progress.loaded / progress.total) * 100)
: 0;
return (
<div className="w-64 max-w-[80vw]">
{determinate ? (
<>
<div className="h-1.5 w-full overflow-hidden rounded bg-white/15">
<div
className="h-full rounded bg-white/70 transition-[width]"
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-1 text-center font-mono text-xs text-white/50">
{mb(progress.loaded)} / {mb(progress.total)} ({pct}%)
</p>
</>
) : (
<p className="text-center font-mono text-xs text-white/50">
{mb(progress.loaded)} downloaded…
</p>
)}
</div>
);
}

View file

@ -0,0 +1,34 @@
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
import * as React from "react";
/**
* Keeps a poisoned wasm runtime from taking the React tree down with it.
*
* The v0.1.21 prod crash logs showed the actual white-screen mechanism: after
* a wasm trap, some child's EFFECT calls into the dead runtime (an embind
* entry via a react-query subscription), the throw lands in React's commit,
* and React unmounts the whole root — destroying the fatal overlay AND the
* console panel, the two things built to report exactly this. The boundary
* absorbs descendant render/effect throws: it reports up (the parent promotes
* its fatal screen, which lives OUTSIDE this boundary) and renders nothing in
* place of the dead subtree. WasmTool's own state — logs included — survives.
*/
export class WasmErrorBoundary extends React.Component<
{ onFatal: (msg: string) => void; children: React.ReactNode },
{ dead: boolean }
> {
state = { dead: false };
static getDerivedStateFromError() {
return { dead: true };
}
componentDidCatch(err: unknown) {
this.props.onFatal(err instanceof Error ? err.message : String(err));
}
render() {
return this.state.dead ? null : this.props.children;
}
}

View file

@ -0,0 +1,387 @@
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
import {
collabRoomId,
docToFile,
fileToDoc,
ydocHasState,
yToDoc,
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { presenceUser, yjsProviderConfig, type DocSource } from "@/lib/config";
import { memfsFilePath } from "@/wasm/constants";
import { readStagedFile, type ToolFile } from "@/wasm/kicad-runner";
import { resolveSheetHierarchy } from "@/wasm/collab/sheet-hierarchy";
import type { SaveBytes } from "@/wasm/save-flow";
import type { KicadCollabHandle, KicadDocSession, KicadItemsWindow } from "@/wasm/collab";
import {
createSheetCollabManager,
registerSheetChangedHook,
registerSheetCreatedHook,
type ActiveSheet,
type SheetChangedWindow,
type SheetCollabManager,
type SheetCreatedWindow,
} from "@/wasm/collab/sheet-manager";
import { clog, cwarn } from "@/wasm/collab/debug";
import { relativeProjectPath } from "./tool-navigation";
import { COLLAB_TOOLS } from "./ui-helpers";
/**
* Read the opened file back from MEMFS (what the editor actually loaded) and
* parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the
* Y.Doc LOSSLESSLY when this client opens an empty room (ysync 0005): the doc
* then carries meta + layout + items, so the file is recoverable from the Y.Doc
* alone. Falls back to undefined (→ editor-snapshot seed, items only) when the
* file is absent or doesn't parse as a KiCad s-expr document.
*/
export function seedDocFromMemfs(
win: ToolWindow,
slug: string,
targetPath?: string,
): KicadDoc | undefined {
if (!targetPath) return undefined;
try {
const text = win.FS?.readFile(memfsFilePath(slug, targetPath), { encoding: "utf8" });
if (typeof text !== "string") return undefined;
return fileToDoc(text);
} catch (err) {
cwarn("seed: fileToDoc failed — falling back to editor-snapshot seed", err);
return undefined;
}
}
/**
* The `docSource: "ydoc"` pre-step (config/env-selected — same /p/ URLs as "api"
* mode): connect the document's collab room BEFORE the file opens and, when the
* room already holds the doc, materialize the file from it (docToFile) so the
* editor opens the doc's state instead of the API's copy. An empty room (first
* ever open) falls back to the API fetch — the seed() that follows file-seeds
* the room from it. Returns the session for `maybeStartCollab` to attach to.
*/
export async function maybeConnectDocSession(
win: ToolWindow,
opts: {
docSource?: DocSource;
tool: Tool;
scopeId: string;
projectId: string;
targetPath?: string;
/** Unmount abort — cancels the connect and destroys partials (C-1/C-3). */
signal?: AbortSignal;
log: (m: string) => void;
},
): Promise<{ session?: KicadDocSession; targetBytes?: Uint8Array }> {
if (opts.docSource !== "ydoc") return {};
if (!opts.targetPath || !COLLAB_TOOLS.has(opts.tool)) return {};
const { connectKicadDoc } = await import("@/wasm/collab");
const room = collabRoomId(opts.scopeId, opts.projectId, opts.targetPath);
const session = await connectKicadDoc({
provider: yjsProviderConfig(),
room,
signal: opts.signal,
});
// Use the full doc state (meta + layout + items), NOT just item count: a
// populated drawing sheet (pl_editor `.kicad_wks`) has zero uuid items, so an
// items-only check makes a joining tab refetch the stale file instead of
// materializing the shared doc's current state.
if (!ydocHasState(session.doc)) {
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
return { session };
}
try {
const text = docToFile(yToDoc(session.doc));
opts.log(`[ydoc] materialized ${opts.targetPath} from room ${room} (${text.length} chars)`);
return { session, targetBytes: new TextEncoder().encode(text) };
} catch (err) {
cwarn("ydoc: materialize failed — falling back to the API fetch", err);
return { session };
}
}
/**
* Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any
* tool that has the collab bridge. Open the same project URL in two tabs to edit
* together: the channel is keyed to project+file, so both tabs share one Y.Doc over
* BroadcastChannel. Editor edits (add/move items) fire the tool's change hook → the
* bridge → the peer tab.
*
* Opt OUT with `?collab=0` (or `collab=false`). Tools without a bridge are skipped anyway.
*/
export async function maybeStartCollab(
win: ToolWindow,
opts: {
tool: Tool;
slug: string;
scopeId: string;
projectId: string;
targetPath?: string;
collabSession?: KicadDocSession;
/** The opened file was materialized from collabSession's doc (ydoc source). */
editorMatchesDoc?: boolean;
/** Read-only viewer (read-only-viewer): see `bindKicadCollab`. */
readOnly?: boolean;
log: (m: string) => void;
onStatus: (t: string) => void;
},
): Promise<KicadCollabHandle | undefined> {
const collabParam = new URLSearchParams(win.location.search).get("collab");
const mod = win.Module;
clog("maybeStartCollab gate:", {
collabParam,
tool: opts.tool,
hasModule: !!mod,
hasSnapshotItems: typeof mod?.kicadCollabSnapshotItems,
hasApplyItems: typeof mod?.kicadCollabApplyItems,
url: win.location.href,
});
// On by default; only an explicit opt-out disables it. A pre-connected doc
// session (Y.Doc-load path) ignores the opt-out: the doc IS the data source,
// so detaching would silently drop every edit.
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
clog("disabled (?collab=0) — skipping");
return undefined;
}
if (!COLLAB_TOOLS.has(opts.tool)) {
clog(`tool ${opts.tool} has no collab bridge — skipping`);
return undefined;
}
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
cwarn(
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is",
typeof mod?.kicadCollabSnapshotItems,
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
);
return undefined;
}
const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab");
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
if (opts.collabSession) {
// docSource "ydoc": the provider is already connected. When the editor
// opened the file materialized from this very doc, attach + baseline only;
// when the room was empty (API fallback), seed() file-seeds it as usual.
clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc);
const handle = attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
seedDoc,
editorMatchesDoc: opts.editorMatchesDoc,
readOnly: opts.readOnly,
});
opts.log(`[collab] attached to Y.Doc session`);
opts.onStatus("Collab: connected");
clog("connected ✓");
return handle;
}
const provider = yjsProviderConfig();
// One room per (project, document). Two tabs of the same build compute the
// same id, so cross-tab BroadcastChannel still works; network providers use it
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
const room = collabRoomId(opts.scopeId, opts.projectId, opts.targetPath ?? opts.tool);
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
const handle = await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider,
room,
seedDoc,
readOnly: opts.readOnly,
});
opts.log(`[collab] ${provider.kind} connected on ${room}`);
opts.onStatus("Collab: connected");
clog("connected ✓");
return handle;
}
/**
* Hierarchical-sheet (subschema) collaborative editing for eeschema: every `.kicad_sch`
* in the design is its own WARM collab room (provider kept open for the session), and the
* editor's single active-screen binding is re-routed between them on sheet navigation (the
* C++ `onSheetChanged` hook). Supersedes the single-room `maybeStartCollab` for eeschema;
* background sheets stay synced at the data layer, the active sheet is bound to the editor.
*
* Opt OUT with `?collab=0`; a pre-connected ydoc session ignores the opt-out (the doc IS
* the data source). Returns undefined when collab is off or the wasm predates the Phase-0
* items+sheet bridge.
*/
export async function startSheetCollab(
win: ToolWindow,
opts: {
slug: string;
scopeId: string;
projectId: string;
targetPath?: string;
files: ToolFile[];
/** ydoc mode: the entry sheet's pre-connected room (from maybeConnectDocSession). */
session?: KicadDocSession;
/** The entry file was materialized from `session`'s doc (baseline-only first seed). */
editorMatchesDoc?: boolean;
onActiveChange: (active: ActiveSheet | null) => void;
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
saveBytes?: SaveBytes;
/** Read-only viewer (read-only-viewer): see `createSheetCollabManager`. */
readOnly?: boolean;
log: (m: string) => void;
onStatus: (t: string) => void;
},
): Promise<SheetCollabManager | undefined> {
const collabParam = new URLSearchParams(win.location.search).get("collab");
const mod = win.Module;
if (!opts.session && (collabParam === "0" || collabParam === "false")) {
clog("[sheet] collab disabled (?collab=0) — skipping");
return undefined;
}
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
cwarn(
"[sheet] BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is",
typeof mod?.kicadCollabSnapshotItems,
"— the loaded eeschema.wasm predates the items+sheet bridge (subschema Phase 0). Rebuild + `npm run setup:kicad` and restart the dev server.",
);
return undefined;
}
const manager = createSheetCollabManager({
mod,
win: win as unknown as KicadItemsWindow,
scopeId: opts.scopeId,
projectId: opts.projectId,
provider: yjsProviderConfig(),
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
onActiveChange: opts.onActiveChange,
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
// any sheet's roster shows the whole schematic's crew (0003). Read-only
// viewers publish none (invisible observer) — skeletons are broadcasts.
presenceUser: opts.readOnly ? undefined : presenceUser(),
readOnly: opts.readOnly,
log: opts.log,
initial:
opts.session && opts.targetPath
? {
sheetPath: opts.targetPath,
session: opts.session,
editorMatchesDoc: !!opts.editorMatchesDoc,
}
: undefined,
});
// Warm ONLY the opened hierarchy (root + transitive Sheetfile references),
// not every schematic in the project: a repo-as-project upload can hold
// dozens of unrelated boards' schematics that the wasm never loads — no
// in-memory copy, no divergence risk, no room needed (sheet-hierarchy.ts).
// A root we can't scope (fileless boot, unreadable staging) falls back to
// all project sheets — over-warming costs sockets, under-warming would cost
// collab. In-editor "Add Sheet" children are warmed by the created hook.
const allSheets = opts.files
.filter((f) => f.path.endsWith(".kicad_sch"))
.map((f) => f.path);
const sheetPaths =
opts.targetPath?.endsWith(".kicad_sch") && allSheets.includes(opts.targetPath)
? resolveSheetHierarchy(
opts.targetPath,
(p) => {
const bytes = readStagedFile(win, opts.slug, p);
return bytes ? new TextDecoder().decode(bytes) : null;
},
allSheets,
)
: allSheets;
// C++ navigation → rebind the active room to the now-shown sheet.
registerSheetChangedHook(win as unknown as SheetChangedWindow, (abs) => {
const rel = relativeProjectPath(opts.slug, abs);
// switchTo rejects on TERMINAL failures only (SexprVersionError — C-5);
// transient failures retry internally. A skewed sheet mid-session can't
// fail the whole boot anymore, so log it and leave the sheet unbound.
if (rel) {
manager.switchTo(rel).catch((err: unknown) => {
opts.log(
`[sheet] ${rel} needs a newer app version — collab disabled for this sheet: ${String(err)}`,
);
opts.onStatus("Collab: version skew on this sheet");
});
}
});
// C++ sheet creation ("Add Sheet") → the child .kicad_sch was just written to MEMFS by
// the hook; register it with the backend + warm its room, so a subsheet placed but never
// entered or saved still persists (the file-list snapshot can't contain it).
registerSheetCreatedHook(win as unknown as SheetCreatedWindow, (abs) => {
const rel = relativeProjectPath(opts.slug, abs);
if (rel && rel.endsWith(".kicad_sch")) {
persistCreatedSheet(win, opts.slug, rel, opts.saveBytes, manager, opts.log);
}
});
// Warm every schematic file in the project so later sheet switches are instant.
void manager.connectAll(sheetPaths);
if (opts.targetPath) {
try {
await manager.switchTo(opts.targetPath);
} catch (err) {
// switchTo only rejects on TERMINAL failures (SexprVersionError — C-5).
// The manager already owns the entry session + every warmed room; tear
// it down before surfacing, or the boot error leaks the pool (C-1).
manager.destroy();
throw err;
}
}
opts.log(`[sheet] multi-room collab active (${sheetPaths.length} sheet(s) warmed)`);
opts.onStatus("Collab: connected");
return manager;
}
/**
* A subsheet was just created in-editor — the C++ `onSheetCreated` hook has already written
* the child .kicad_sch to MEMFS. Register it with the backend (so it survives reload and
* reaches peers) and warm its collab room. Covers a subsheet that's placed but never entered
* or saved, which the page-load file list can't contain.
*/
function persistCreatedSheet(
win: ToolWindow,
slug: string,
relPath: string,
saveBytes: SaveBytes | undefined,
manager: SheetCollabManager,
log: (m: string) => void,
): void {
void manager.onboard(relPath);
if (!saveBytes) return;
try {
const bytes = win.FS?.readFile(memfsFilePath(slug, relPath));
if (!(bytes instanceof Uint8Array)) return;
void saveBytes(relPath, bytes)
.then((outcome) => {
if (outcome.kind === "committed") {
log(`[sheet] registered created subsheet ${relPath} (${bytes.length} bytes)`);
} else {
cwarn(
`[sheet] upload of created subsheet ${relPath} did not commit`,
outcome,
);
}
})
.catch((err) => cwarn(`[sheet] upload of created subsheet ${relPath} failed`, err));
} catch (err) {
cwarn(`[sheet] read of created subsheet ${relPath} failed`, err);
}
}
/**
* 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.
*/
export 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));
}
}

View file

@ -0,0 +1,124 @@
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
// The wx wasm port calls window.wxAppTopWindowClosed() when the app's MAIN
// frame is destroyed (wxwidgets src/wasm/toplevel.cpp) — i.e. on a real
// File→Quit / window close. A close vetoed by the unsaved-changes prompt never
// destroys the frame, so it never fires. The port also closes the frame while
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
// off as soon as any unload/navigation is under way.
let activeQuitHook: (() => void) | undefined;
let quitHandled = false;
/**
* Latch the quit dispatcher off ahead of a deliberate in-app navigation (the
* tool-switch hook's location.assign). The wx port's UnloadCallback runs on
* BEFOREUNLOAD — i.e. the instant the navigation starts, while this document
* keeps running until the next one commits — and closes the top frame, which
* fires wxAppTopWindowClosed. Without the latch the quit hook then navigates
* to the exit URL over the in-flight navigation (the pagehide latch below is
* too late: pagehide only fires at commit time). One-shot per document, same
* as the pagehide latch — this page is on its way out.
*/
export function markDeliberateNavigation() {
quitHandled = true;
}
const quitDispatcher = () => {
if (quitHandled) return;
quitHandled = true;
// The wasm side only calls this when the app's top window is genuinely
// destroyed. When that happens unexpectedly (2026-08-03: a guarded-off
// settle-window dispatch cascaded into a silent frame close), the stack is
// the only artifact that says WHO closed it — keep it in every log.
console.warn("[quit] wxAppTopWindowClosed invoked — top window destroyed", new Error("quit-origin").stack);
activeQuitHook?.();
};
function ensureQuitDispatcher(win: ToolWindow): boolean {
if (win.wxAppTopWindowClosed === quitDispatcher) return true;
try {
Object.defineProperty(win, "wxAppTopWindowClosed", {
configurable: true,
value: quitDispatcher,
});
return true;
} catch {
return false;
}
}
if (typeof window !== "undefined") {
ensureQuitDispatcher(window as ToolWindow);
// Latch off for a BROWSER-initiated unload: reload (F5), Back, closing the
// tab, typing a URL. markDeliberateNavigation covers only our own in-app
// navigations, and the pagehide latch below fires at commit time — too late.
// The wx port's UnloadCallback runs on beforeunload and closes the top frame,
// which fires wxAppTopWindowClosed; unlatched, the quit hook then navigated to
// the project overview OVER the in-flight reload, so every refresh of an
// editor URL bounced to the management app instead of reloading.
//
// Registered at MODULE scope, which runs on import — before the wasm boots and
// installs its own beforeunload handler. Listeners fire in registration order,
// so this latch is always set before UnloadCallback can close the frame.
//
// Tradeoff: if a beforeunload prompt is shown and the user chooses to stay,
// the latch stays set and a later File→Quit won't navigate on its own. That
// is strictly better than the alternative — a page that cannot be refreshed —
// and the user can still navigate manually.
window.addEventListener(
"beforeunload",
() => {
quitHandled = true;
},
{ capture: true },
);
}
export function installQuitHook(
win: ToolWindow,
opts: { exitUrl: string; log: (m: string) => void },
): () => void {
const hook = () => {
// Quit always navigates to the exit URL (project overview / home). Never
// history.back(): every in-app entry AND every tool switch is a hard
// location.assign(), so after a schematic ⇄ pcb switch the previous
// history entry is another editor — unwinding history strands the user
// there instead of leaving the editor.
//
// Defer the navigation out of the wasm callback: this fires from inside the
// frame's C++ destructor (via EM_ASM), and the teardown keeps
// running after we return. A cross-document location.assign() started here is
// aborted by that continuing teardown — so hand it to a fresh task once the
// wasm stack has unwound.
setTimeout(() => {
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
win.location.assign(opts.exitUrl);
}, 0);
};
if (!ensureQuitDispatcher(win)) {
opts.log("[quit] unable to install quit hook");
}
activeQuitHook = hook;
// Once the page is unloading for any reason, the hook must never navigate.
const markUnloading = () => {
quitHandled = true;
};
win.addEventListener("pagehide", markUnloading);
// A bfcache restore (Forward after quitting) would resurrect a page whose wx
// frame was already destroyed — force a clean re-boot instead.
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) win.location.reload();
};
win.addEventListener("pageshow", onPageShow);
return () => {
if (activeQuitHook === hook) activeQuitHook = undefined;
win.removeEventListener("pagehide", markUnloading);
win.removeEventListener("pageshow", onPageShow);
};
}

View file

@ -0,0 +1,213 @@
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
projectPath,
projectToolPath,
toolSchema,
type Tool,
} from "@pcbjam/shared";
import { currentScope } from "@/lib/config";
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { memfsProjectDir } from "@/wasm/constants";
import type { ToolFile } from "@/wasm/kicad-runner";
import { markDeliberateNavigation } from "./quit-hook";
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
".sch": "eeschema",
".brd": "pcbnew",
};
let activeToolNavigationHook:
| ((toolName: string, fileName: string) => boolean)
| undefined;
const toolNavigationDispatcher = (toolName: string, fileName: string) =>
activeToolNavigationHook?.(toolName, fileName) ?? false;
function ensureToolNavigationDispatcher(win: ToolWindow): boolean {
if (win.kicadWebOpenTool === toolNavigationDispatcher) return true;
try {
Object.defineProperty(win, "kicadWebOpenTool", {
configurable: true,
value: toolNavigationDispatcher,
});
return true;
} catch {
return false;
}
}
if (typeof window !== "undefined") {
ensureToolNavigationDispatcher(window as ToolWindow);
}
export function normalizeToolName(rawName: string): Tool | null {
const basename = rawName.replace(/\\/g, "/").split("/").pop() ?? rawName;
const withoutExe = basename.replace(/\.exe$/i, "");
const toolName = withoutExe === "pcb_calculator" ? "calculator" : withoutExe;
const parsed = toolSchema.safeParse(toolName);
return parsed.success ? parsed.data : null;
}
export function relativeProjectPath(slug: string, path: string): string | undefined {
if (!path) return undefined;
const normalized = path.replace(/\\/g, "/");
const prefix = `${memfsProjectDir(slug)}/`;
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
const marker = `/projects/${slug}/`;
const markerIndex = normalized.indexOf(marker);
if (markerIndex >= 0) return normalized.slice(markerIndex + marker.length);
return normalized.startsWith("/") ? undefined : normalized;
}
export function fileStem(path: string): string {
const name = path.replace(/\\/g, "/").split("/").pop() ?? path;
return name.replace(/\.[^.]+$/, "");
}
export function fileTool(path: string): Tool | undefined {
const lower = path.toLowerCase();
for (const [extension, mappedTool] of Object.entries({
...EXTENSION_TOOL,
...LEGACY_EXTENSION_TOOL,
})) {
if (lower.endsWith(extension)) return mappedTool;
}
return undefined;
}
export function chooseToolFile(
files: ToolFile[],
nextTool: Tool,
requestedPath?: string,
currentPath?: string,
): string | undefined {
if (requestedPath && files.some((file) => file.path === requestedPath)) {
return requestedPath;
}
const candidates = files.filter((file) => fileTool(file.path) === nextTool);
const preferredStem = requestedPath
? fileStem(requestedPath)
: currentPath
? fileStem(currentPath)
: undefined;
if (preferredStem) {
const matchingStem = candidates.find(
(file) => fileStem(file.path) === preferredStem,
);
if (matchingStem) return matchingStem.path;
}
return candidates[0]?.path;
}
export function installToolNavigationHook(
win: ToolWindow,
opts: {
slug: string;
files: ToolFile[];
targetPath?: string;
/** Persist a new file into the project (see the WasmTool prop). Absent ⇒
* this session can't create one, and a missing target stays a no-op. */
createFile?: (relPath: string, bytes: Uint8Array) => Promise<void>;
log: (m: string) => void;
},
): () => void {
// One create at a time: a double-fired menu item must not upload twice.
// Cleared only on failure — success navigates the page away.
let pendingCreate: string | null = null;
const hook = (rawToolName: string, rawFileName: string): boolean => {
const nextTool = normalizeToolName(rawToolName);
if (!nextTool) {
opts.log(`[nav] unsupported KiCad tool: ${rawToolName}`);
return false;
}
const requestedPath = relativeProjectPath(opts.slug, rawFileName);
const nextPath = FILELESS_TOOLS.has(nextTool)
? undefined
: chooseToolFile(opts.files, nextTool, requestedPath, opts.targetPath);
if (!FILELESS_TOOLS.has(nextTool) && !nextPath) {
// Native KiCad's "Switch to PCB Editor" with no board opens pcbnew on a
// NEW empty board at the derived path — mirror it by creating the
// templated counterpart in the project (the shape NewFileDialog writes)
// and navigating to it. Only sessions that can persist pass `createFile`
// (ToolPage); viewers and scratch/local-folder sessions keep the quiet
// no-op. C++ calls this hook synchronously (EM_ASM_INT) and ignores the
// result beyond a log line, so the create+navigate runs async and we
// answer true optimistically once it's kicked off.
const createFile = opts.createFile;
if (!createFile) {
opts.log(`[nav] no project file found for ${nextTool}: ${rawFileName}`);
return false;
}
if (pendingCreate) {
opts.log(`[nav] create already pending: ${pendingCreate}`);
return true;
}
const relPath =
requestedPath ??
(opts.targetPath
? withExtension(nextTool, fileStem(opts.targetPath))
: defaultFileName(nextTool));
const url =
projectPath(currentScope(), opts.slug, relPath) + win.location.search;
pendingCreate = relPath;
void (async () => {
try {
const bytes = new TextEncoder().encode(
newFileTemplate(nextTool, crypto.randomUUID()),
);
await createFile(relPath, bytes);
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
} catch (e) {
pendingCreate = null;
opts.log(
`[nav] create failed for ${relPath}: ${e instanceof Error ? e.message : String(e)}`,
);
}
})();
return true;
}
// Scope/kind/name grammar: a fileless tool boots at `…/-/:tool`; a file route
// carries the path (its tool is inferred). Scope = the current URL's scope.
const scope = currentScope();
const url =
(FILELESS_TOOLS.has(nextTool)
? projectToolPath(scope, opts.slug, nextTool)
: projectPath(scope, opts.slug, nextPath)) + win.location.search;
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
return true;
};
if (!ensureToolNavigationDispatcher(win)) {
opts.log("[nav] unable to install KiCad tool navigation hook");
}
activeToolNavigationHook = hook;
return () => {
if (activeToolNavigationHook === hook) activeToolNavigationHook = undefined;
};
}

View file

@ -0,0 +1,43 @@
// Extracted from WasmTool.tsx (2026-08-25 split) — behavior unchanged.
import type { Tool } from "@pcbjam/shared";
import type { LibSetChangedDetail } from "@/wasm/libs/source";
/** The libset toast's message once live-loading failed and reload is the offer. */
export function reloadFallbackMsg(notice: { detail: LibSetChangedDetail }): string {
const label = notice.detail.name ? `"${notice.detail.name}"` : "the new library";
return `Couldn't load ${label} into the running session — click to reload the editor.`;
}
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
export const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
// Chrome (editor UI) toggle: only the merged kicad_editor bundle exports
// kicadSetChrome (gerbview/calculator/pl_editor don't) — everything about the
// toggle is feature-gated on the export being there.
export function chromeSetter(win: Window): ((show: boolean) => boolean) | null {
const fn = (win as { Module?: { kicadSetChrome?: unknown } }).Module
?.kicadSetChrome;
return typeof fn === "function" ? (fn as (show: boolean) => boolean) : null;
}
// Viewer panels (viewer-panels): floating layer selector + selection
// inspector open-state persistence, mirroring the comments panel's keys.
export const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open";
export const INSPECTOR_OPEN_KEY = "pcbjam:inspector-panel-open";
// Tooltip only — the matcher accepts both chords on any platform.
export const CHROME_HOTKEY_LABEL =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
? "⌘\\"
: "Ctrl+\\";
// Which library item kind each tool browses — drives the load-screen pre-sync
// (warm the right bundles into IDB while the wasm downloads). Tools that don't
// browse a library are omitted (no pre-sync).
export const LIB_KIND_FOR_TOOL: Partial<Record<Tool, "symbol" | "footprint">> = {
symbol_editor: "symbol",
eeschema: "symbol",
footprint_editor: "footprint",
pcbnew: "footprint",
};

View file

@ -208,3 +208,35 @@ describe("installLibsProvider — enumerate gate (load-fanout)", () => {
expect(gateKinds).toEqual(["symbol", "symbol"]);
});
});
describe("busy notices (libs 0017 §B)", () => {
it("a save announces the ITEM NAME, never the JSON envelope", async () => {
const busy: Array<{ busy: boolean; op: string; name?: string }> = [];
(globalThis as unknown as { window: unknown }).window = {
location: { search: "" },
dispatchEvent: (e: Event) => {
if (e.type === "pcbjam:lib-busy") {
busy.push((e as CustomEvent<{ busy: boolean; op: string; name?: string }>).detail);
}
return true;
},
};
try {
const request = installAndGetRequest({
listLibs: async () => [{ id: "L", name: "L", kind: "symbol" }],
listItems: async () => [],
getItemBody: async () => null,
saveItemBody: async () => true,
} as unknown as LibsSource);
const body = '(kicad_symbol_lib (symbol "R_0402"))';
await request("save", libUri("L"), JSON.stringify({ name: "R_0402", body }), "symbol");
expect(busy.length).toBeGreaterThan(0);
for (const b of busy) {
expect(b.op).toBe("save");
expect(b.name).toBe("R_0402");
}
} finally {
delete (globalThis as unknown as { window?: unknown }).window;
}
});
});

View file

@ -243,6 +243,16 @@ export interface LibSetChangedDetail {
name?: string;
}
/** The item name inside a `save` request's `{name, body}` envelope (or "" when unparsable). */
function saveArgName(arg: string): string {
try {
const parsed = JSON.parse(arg) as { name?: unknown };
return typeof parsed.name === "string" ? parsed.name : "";
} catch {
return "";
}
}
function emitLibBusy(detail: LibBusyDetail): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(LIB_BUSY_EVENT, { detail }));
@ -398,7 +408,10 @@ export function installLibsProvider(
// "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 });
// The busy notice names the ITEM: for `save` the arg is the JSON envelope
// `{name, body}` (the whole body would otherwise be printed — libs 0017 §B).
const busyName = op === "save" ? saveArgName(arg) : arg;
if (userFacing) emitLibBusy({ busy: true, op, kind, name: busyName });
try {
switch (op) {
case "list": {
@ -513,7 +526,7 @@ export function installLibsProvider(
if (userFacing) emitLibError(`Failed to ${op} "${arg}".`);
return null;
} finally {
if (userFacing) emitLibBusy({ busy: false, op, kind, name: arg });
if (userFacing) emitLibBusy({ busy: false, op, kind, name: busyName });
}
};

View file

@ -240,6 +240,40 @@ describe("syncedLibsSource → editor reload bridge", () => {
}
});
it("footprint edits flag PLACED footprints via kicadLibsFootprintUsage (libs 0017 §2d)", async () => {
const dispatched: Array<{ type: string; detail: unknown }> = [];
(globalThis as { window?: unknown }).window = {
dispatchEvent: (e: CustomEvent) =>
dispatched.push({ type: e.type, detail: e.detail }),
};
const fpUsage = vi.fn((_lib: string, name: string) =>
name === "USED_FP" ? 1 : 0,
);
(globalThis as { Module?: unknown }).Module = {
kicadLibsReload: reload,
kicadLibsFootprintUsage: fpUsage,
};
try {
const server = await fakeServer({});
const source = makeSource(server);
await source.listItems(LIB_ID);
await server.remotePut("footprint/USED_FP", "(footprint USED_FP)");
await server.remotePut("footprint/NEW_FP", "(footprint NEW_FP)");
await vi.advanceTimersByTimeAsync(500);
expect(fpUsage).toHaveBeenCalledWith("My Lib", "USED_FP");
expect(dispatched).toHaveLength(1);
expect(dispatched[0]!.detail).toMatchObject({
lib: "My Lib",
kind: "footprint",
usedNames: ["USED_FP"],
});
} finally {
delete (globalThis as { window?: unknown }).window;
}
});
it("a change before the editor booted (no Module export) is a no-op", async () => {
delete (globalThis as { Module?: unknown }).Module;
const server = await fakeServer({});

View file

@ -133,9 +133,17 @@ export function syncedLibsSource(
mod: Record<string, unknown> | undefined,
): void {
if (typeof window === "undefined" || names.length === 0) return;
const usage = mod?.kicadLibsSymbolUsage;
// Placed-instance queries: symbols (schematic frame) and, since libs 0017
// §2d, footprints (board frame). A kind without a bridge announces nothing
// as "used" — the caller treats that as informational.
const usage =
kind === "symbol"
? mod?.kicadLibsSymbolUsage
: kind === "footprint"
? mod?.kicadLibsFootprintUsage
: undefined;
const usedNames =
kind === "symbol" && typeof usage === "function"
typeof usage === "function"
? names.filter((n) => {
try {
return (