From 866db5888c5cc12c4ea34aeefca359ea38a4cfc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 25 Aug 2026 17:58:54 +0200 Subject: [PATCH] libs 0017: sync overrides indexed + stale-lib session menu + Cmd+S DOM-focus fix + WasmTool split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa --- tests/kicad/save-cmd-key.spec.ts | 187 +++ tests/web/fpedit-cmd-save.spec.ts | 217 +++ wasm/bindings/eeschema_embind.cpp | 127 ++ wasm/bindings/kicad_editor_embind.cpp | 27 + wasm/bindings/pcbnew_embind.cpp | 140 ++ web/standalone/src/components/OverlayMenu.tsx | 16 +- web/standalone/src/components/WasmTool.tsx | 1255 +++-------------- .../components/wasm-tool/DownloadConsent.tsx | 309 ++++ .../wasm-tool/WasmErrorBoundary.tsx | 34 + .../src/components/wasm-tool/collab-start.ts | 387 +++++ .../src/components/wasm-tool/quit-hook.ts | 124 ++ .../components/wasm-tool/tool-navigation.ts | 213 +++ .../src/components/wasm-tool/ui-helpers.ts | 43 + web/standalone/src/wasm/libs/source.test.ts | 32 + web/standalone/src/wasm/libs/source.ts | 17 +- .../src/wasm/libs/synced-source.test.ts | 34 + web/standalone/src/wasm/libs/synced-source.ts | 12 +- wxwidgets | 2 +- 18 files changed, 2084 insertions(+), 1092 deletions(-) create mode 100644 tests/kicad/save-cmd-key.spec.ts create mode 100644 tests/web/fpedit-cmd-save.spec.ts create mode 100644 web/standalone/src/components/wasm-tool/DownloadConsent.tsx create mode 100644 web/standalone/src/components/wasm-tool/WasmErrorBoundary.tsx create mode 100644 web/standalone/src/components/wasm-tool/collab-start.ts create mode 100644 web/standalone/src/components/wasm-tool/quit-hook.ts create mode 100644 web/standalone/src/components/wasm-tool/tool-navigation.ts create mode 100644 web/standalone/src/components/wasm-tool/ui-helpers.ts diff --git a/tests/kicad/save-cmd-key.spec.ts b/tests/kicad/save-cmd-key.spec.ts new file mode 100644 index 0000000..59dacb8 --- /dev/null +++ b/tests/kicad/save-cmd-key.spec.ts @@ -0,0 +1,187 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures"; + +/** + * Cmd+S (Meta+S — the Mac save chord) must save exactly like Ctrl+S does. + * + * save-hook.spec pins Ctrl+S → window.kicadCollab.onSave for every frame; every + * other spec in the tree presses "Control+s" too, so the Meta chord had no + * coverage at all. A Mac user reported "Cmd+S didn't save, the Save icon did" + * (libs 0017). This spec is the red side of that report: same boot / edit / + * hook recipe as save-hook.spec, chord swapped for Meta+S. A Ctrl+S control + * run in the same session guards against blaming the chord for a boot + * problem. + * + * wx side: keyboard.cpp SetKeyboardModifiers maps metaKey → ControlDown only + * when wxGetOsVersion() reports a Mac (wx.js platformInfo from the UA), and + * app.cpp TranslateMenuAccel matches "\tCtrl+S" on ControlDown — so the Meta + * chord has two places to fall through. + */ + +interface ToolCfg { + html: string; + ext: string; + modify: { fn: string; args: (string | number)[] }; + fixture: string; +} + +type Mod = Record unknown>; +type FS = { + mkdirTree(p: string): void; + writeFile(p: string, d: string): void; +}; +type HookWindow = Window & { + FS: FS; + Module: Mod; + kicadCollab?: Record; + __savedPaths: string[]; +}; + +const BOOT_TIMEOUT = 150000; +const NAME = "savecmd"; + +async function bootOpen(page: Page, cfg: ToolCfg): Promise { + await page.goto(`/kicad/${cfg.html}`); + await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT }); + await page.waitForFunction( + (modFn) => { + const m = (window as unknown as { Module?: Mod }).Module; + return typeof m?.kicadOpenFile === "function" && typeof m?.[modFn] === "function"; + }, + cfg.modify.fn, + { timeout: BOOT_TIMEOUT }, + ); + await page.waitForFunction( + () => + !!window.wxElementRegistry && + window.wxElementRegistry + .findAll({ visible: true }) + .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), + null, + { timeout: BOOT_TIMEOUT }, + ); + const abs = `/home/kicad/documents/${NAME}.${cfg.ext}`; + await page.evaluate( + ({ content, abs }) => { + const w = window as unknown as HookWindow; + try { + w.FS.mkdirTree("/home/kicad/documents"); + } catch { + /* exists */ + } + w.FS.writeFile(abs, content); + w.Module.kicadOpenFile(abs); + }, + { content: cfg.fixture, abs }, + ); + await expect.poll(() => page.title(), { timeout: BOOT_TIMEOUT, intervals: [300] }).toMatch(new RegExp(NAME, "i")); + return abs; +} + +async function focusCanvas(page: Page): Promise { + const box = await page.locator("#canvas").boundingBox(); + expect(box, "#canvas has a bounding box").not.toBeNull(); + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.waitForTimeout(300); // eslint-disable-line -- focus click settle, no JS signal +} + +/** Edit, press `chord`, return the number of onSave calls it produced within 10 s. */ +async function savesAfter(page: Page, cfg: ToolCfg, chord: string): Promise { + await page.evaluate(() => { + (window as unknown as HookWindow).__savedPaths = []; + }); + await page.evaluate( + ({ fn, args }) => (window as unknown as HookWindow).Module[fn](...args), + cfg.modify, + ); + await page.waitForTimeout(500); // eslint-disable-line -- dirty flag settle (save-hook.spec) + await focusCanvas(page); + await page.keyboard.press(chord); + await page + .waitForFunction(() => (window as unknown as HookWindow).__savedPaths.length > 0, null, { + timeout: 10000, + }) + .catch(() => undefined); + return page.evaluate(() => (window as unknown as HookWindow).__savedPaths.length); +} + +async function expectMetaSaves(page: Page, cfg: ToolCfg): Promise { + await bootOpen(page, cfg); + await page.evaluate(() => { + const w = window as unknown as HookWindow; + w.__savedPaths = []; + w.kicadCollab = { ...w.kicadCollab, onSave: (p: string) => w.__savedPaths.push(p) }; + }); + // Control: the Ctrl chord saves in this very session (else the boot is the + // problem, not the chord). + expect(await savesAfter(page, cfg, "Control+s"), "Ctrl+S control save").toBeGreaterThan(0); + // The claim: Cmd+S saves too. + expect(await savesAfter(page, cfg, "Meta+s"), "Meta+S (Cmd+S) save").toBeGreaterThan(0); +} + +const SCH: ToolCfg = { + html: "eeschema.html", + ext: "kicad_sch", + modify: { fn: "kicadCollabTestMoveFirst", args: [2, 2] }, + fixture: `(kicad_sch + (version 20250114) + (generator "eeschema") + (generator_version "9.0") + (uuid "11111111-1111-1111-1111-111111111111") + (paper "A4") + (lib_symbols) + (wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001")) + (sheet_instances (path "/" (page "1"))) +) +`, +}; + +const PCB: ToolCfg = { + html: "pcbnew-collab.html", + ext: "kicad_pcb", + modify: { fn: "kicadCollabTestMoveFirst", args: [2, 2] }, + fixture: `(kicad_pcb + (version 20241229) + (generator "pcbnew") + (generator_version "9.0") + (general (thickness 1.6)) + (paper "A4") + (layers + (0 "F.Cu" signal) + (2 "B.Cu" signal) + (37 "F.SilkS" user) + (25 "Edge.Cuts" user) + ) + (setup) + (net 0 "") + (footprint "TestLib:R" + (layer "F.Cu") + (uuid "66666666-0000-0000-0000-000000000001") + (at 100 100) + (attr smd) + (property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15)))) + ) +) +`, +}; + +test.describe("Cmd+S (Meta+S) saves like Ctrl+S", () => { + test.describe.configure({ timeout: 300000 }); + // The Playwright device presets carry a WINDOWS user agent; wx.js derives the + // OS from the UA and only maps the Meta key to wx's ControlDown on a Mac. + // Pin a macOS UA so the chord is judged the way a Mac browser sends it. + test.use({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0", + }); + + test("eeschema: Meta+S → onSave", async ({ page, testLogger }) => { + void testLogger; + await expectMetaSaves(page, SCH); + }); + + test("pcbnew: Meta+S → onSave", async ({ page, testLogger }) => { + void testLogger; + await expectMetaSaves(page, PCB); + }); +}); diff --git a/tests/web/fpedit-cmd-save.spec.ts b/tests/web/fpedit-cmd-save.spec.ts new file mode 100644 index 0000000..ba0733c --- /dev/null +++ b/tests/web/fpedit-cmd-save.spec.ts @@ -0,0 +1,217 @@ +import { test, expect, type Page } from '@playwright/test'; +import { clickByTooltip, stableShot, waitForWxApp } from '../e2e/utils/element-tracker'; + +/** + * Cmd+S in a CHILD editor (Footprint Editor opened from a board session) must + * save the open footprint through the lib write bridge, exactly like the + * toolbar Save icon does. + * + * Report (libs 0017): on a Mac, a footprint edited in the editor opened from + * pcbnew saved from the Save icon but not from Cmd+S. tests/kicad/save-cmd-key + * proves the Meta chord saves in the MAIN frames under a macOS UA, so the gap + * is child-frame specific. Recipe: board session → toolbar "Create, delete and + * edit board footprints" → expand Resistor_SMD → open a footprint → dirty it + * (select-all + Delete) → Cmd+S → expect one `save` request on + * window.kicadLibs.request. The Save toolbar button is the control. + * + * UA: the Playwright device presets say Windows; wx.js derives the OS from the + * UA and maps the Meta key to ControlDown only on a Mac — so pin a Mac UA. + * + * Root cause + fix (2026-08-25): the lib-tree filter kept BROWSER focus + * after a canvas click (the wasm mouse callback preventDefaults mousedown), so + * every key stayed with the input. wxWindowWasm::SetFocus now blurs the active + * wx-dom control when a canvas-drawn window takes wx focus (wxDomBlurActive). + * The from-pcbnew variant needs the board route (collab); a local + * web/standalone/.env pointing VITE_YJS_* at :3055 breaks that route locally. + */ + +const SCOPE = 'default'; +const BOOT_TIMEOUT = 180000; +const LIB = 'Resistor_SMD'; + +type SpyWindow = Window & { + kicadLibs?: { request: (...a: unknown[]) => Promise }; + __libOps: { op: string; arg: string }[]; +}; + +function frameNames(page: Page): Promise { + return page.evaluate(() => + (window as unknown as { wxElementRegistry: any }).wxElementRegistry + .findAll({}) + .filter((e: any) => /Frame$/.test(e.typeName || '')) + .map((e: any) => e.name as string), + ); +} + +/** Map a rendered tree row's offset Y to its true screen Y (footprint-browse-remote). */ +async function treeGeom(page: Page) { + return page.evaluate(() => { + const rd = (window as any).wxElementRegistry.findAllRendered({}); + const hdr = rd.find((e: any) => e.elementType === 'columnheader' && e.label === 'Item'); + const rows = rd + .filter((e: any) => e.elementType === 'dataviewitem') + .sort((a: any, b: any) => a.centerY - b.centerY); + if (!hdr || rows.length === 0) return null; + const pitch = rows.length > 1 ? rows[1].centerY - rows[0].centerY : 17; + const firstTrue = hdr.centerY + hdr.height / 2 + pitch / 2; + const offset = firstTrue - rows[0].centerY; + return { + offset, + rows: rows.map((r: any) => ({ label: r.label, cx: r.centerX, cy: r.centerY })), + }; + }); +} + +async function dblclickRow(page: Page, re: RegExp): Promise { + const geom = await treeGeom(page); + if (!geom) return null; + const row = geom.rows.find((r) => re.test(r.label || '')); + if (!row) return null; + await page.mouse.dblclick(row.cx, row.cy + geom.offset); + return row.label; +} + +/** Count of `save` ops the lib bridge received. */ +function saveOps(page: Page): Promise { + return page.evaluate( + () => (window as unknown as SpyWindow).__libOps.filter((o) => o.op === 'save').length, + ); +} + +async function clickCanvasCenter(page: Page): Promise { + const box = await page.locator('canvas').first().boundingBox(); + expect(box, 'canvas box').not.toBeNull(); + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); +} + +test.describe('footprint editor from pcbnew: Cmd+S saves', () => { + test.describe.configure({ timeout: 480000 }); + test.use({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36', + }); + + async function run(page: Page, entry: 'from-pcbnew' | 'standalone'): Promise { + const logs: string[] = []; + page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`)); + page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}`)); + + if (entry === 'from-pcbnew') { + await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb`); + await waitForWxApp(page, { timeout: BOOT_TIMEOUT }); + await expect + .poll(() => page.title(), { message: 'board editor up', timeout: BOOT_TIMEOUT }) + .toMatch(/PCB Editor/i); + } else { + await page.goto(`/${SCOPE}/projects/demo/-/footprint_editor`); + await waitForWxApp(page, { timeout: BOOT_TIMEOUT }); + } + await page.waitForFunction(() => !!(window as unknown as SpyWindow).kicadLibs, null, { + timeout: 60000, + }); + + // Spy on the lib bridge: every op the editor asks for, in order. + await page.evaluate(() => { + const w = window as unknown as SpyWindow; + w.__libOps = []; + const real = w.kicadLibs!.request; + w.kicadLibs!.request = (...a: unknown[]) => { + // request(op, "/mnt/pcbjam/", arg, kind) + w.__libOps.push({ op: String(a[0]), arg: String(a[2] ?? '').slice(0, 60) }); + return real.apply(w.kicadLibs, a); + }; + }); + + if (entry === 'from-pcbnew') { + expect(await frameNames(page)).toEqual(['PcbFrame']); + expect( + await clickByTooltip(page, 'Create, delete and edit board footprints', { elementType: 'tool' }), + 'Footprint Editor toolbar button clicked', + ).toBe(true); + } + await expect + .poll(() => frameNames(page), { message: 'ModEditFrame opened', timeout: BOOT_TIMEOUT }) + .toContain('ModEditFrame'); + await expect + .poll(() => treeGeom(page).then((g) => g?.rows.some((r) => r.label === LIB) ?? false), { + message: `${LIB} row in the footprint tree`, + timeout: 120000, + }) + .toBe(true); + await stableShot(page, 'fpedit-cmd-save-01-tree.png'); + + expect(await dblclickRow(page, new RegExp(`^${LIB}$`)), 'lib expanded').toBeTruthy(); + await expect + .poll( + () => + page.evaluate(() => + (window as any).wxElementRegistry + .findAllRendered({ elementType: 'dataviewitem' }) + .some((e: any) => /Metric/.test(e.label || '')), + ), + { message: 'footprint child rows after enumerate', timeout: 60000 }, + ) + .toBe(true); + const opened = await dblclickRow(page, /Metric/); + expect(opened, 'a footprint row opened').toBeTruthy(); + await expect + .poll(() => page.title(), { message: 'footprint loaded (title)', timeout: 60000 }) + .toContain(LIB); + await stableShot(page, 'fpedit-cmd-save-02-loaded.png'); + + // Dirty the footprint from the canvas: select all + rotate (R). Delete is + // refused on the mandatory ${REFERENCE} field, so it doesn't dirty anything. + await clickCanvasCenter(page); + await page.waitForTimeout(300); // eslint-disable-line -- focus settle, no JS signal + await page.keyboard.press('Meta+a'); + await page.keyboard.press('r'); + await page.waitForTimeout(500); // eslint-disable-line -- modified flag settle, no JS signal + await stableShot(page, 'fpedit-cmd-save-03-dirty.png'); + logs.push( + `[spec] before chord: title="${await page.title()}" activeElement=${await page.evaluate( + () => `${document.activeElement?.tagName}#${(document.activeElement as HTMLElement)?.id}`, + )}`, + ); + + const before = await saveOps(page); + await page.keyboard.press('Meta+s'); + await page + .waitForFunction( + (n) => (window as unknown as SpyWindow).__libOps.filter((o) => o.op === 'save').length > n, + before, + { timeout: 15000 }, + ) + .catch(() => undefined); + const afterCmd = await saveOps(page); + logs.push(`[spec] saves before=${before} afterCmd=${afterCmd}`); + await stableShot(page, 'fpedit-cmd-save-04-after-cmd-s.png'); + + // Control: the toolbar Save icon (what the user fell back to). + if (afterCmd === before) { + await clickByTooltip(page, 'Save', { elementType: 'tool' }); + await page + .waitForFunction( + (n) => (window as unknown as SpyWindow).__libOps.filter((o) => o.op === 'save').length > n, + before, + { timeout: 15000 }, + ) + .catch(() => undefined); + logs.push(`[spec] saves after toolbar Save=${await saveOps(page)}`); + } + logs.push( + `[spec] lib ops: ${JSON.stringify(await page.evaluate(() => (window as unknown as SpyWindow).__libOps))}`, + ); + console.log('--- spec log ---\n' + logs.filter((l) => /\[spec\]|\[libs\]|save|accel/i.test(l)).join('\n')); + + expect(logs.some((l) => l.includes('Aborted(')), 'no WASM abort').toBe(false); + expect(afterCmd, 'Cmd+S produced a lib save').toBeGreaterThan(before); + } + + test('standalone footprint editor: Cmd+S on a dirty footprint reaches the lib save bridge', async ({ page }) => { + await run(page, 'standalone'); + }); + + test('from pcbnew: Cmd+S on a dirty footprint reaches the lib save bridge', async ({ page }) => { + await run(page, 'from-pcbnew'); + }); +}); diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 983ac37..b7b9a6b 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -1272,12 +1272,137 @@ std::string schCollabTestMoveFirst( int aDx, int aDy ) } +// Outcome of a deferred update-from-library run → window event +// `pcbjam:lib-update-done` {ok, updated, missing[]} (libs 0017 §2c). +static void emitLibUpdateDone( int aUpdated, const std::vector& aMissing ) +{ + nlohmann::json j; + j["ok"] = true; + j["updated"] = aUpdated; + j["missing"] = aMissing; + std::string s = j.dump(); + EM_ASM( { + if( typeof window !== 'undefined' ) + window.dispatchEvent( new CustomEvent( 'pcbjam:lib-update-done', + { detail: JSON.parse( UTF8ToString( $0 ) ) } ) ); + }, s.c_str() ); +} + // How many placed instances of a library symbol the open schematic holds — // the JS lib-sync bridge asks after a remote lib update so the editor chrome // can warn "a symbol you are using changed" (placed SCH_SYMBOLs keep their // embedded copy across a lib reload, so the user must update explicitly). // Counts across all unique screens of the hierarchy; 0 without a schematic // frame (symbol editor / viewer sessions). +// Re-read the named library symbols into every placed instance — the headless +// core of Tools ▸ Update Symbols from Library… (DIALOG_CHANGE_SYMBOLS:: +// processSymbols with the dialog's defaults: keep field text/positions, take +// the new body/pins/attributes), scoped to `aNamesJson` of `aLibNickname`. +// Runs as a normal SCH_COMMIT on the frame's coroutine so peers receive it as +// an ordinary edit. Returns {ok, updated, missing[]}. (libs 0017 §2c) +std::string schUpdateFromLibrary( std::string aLibNickname, std::string aNamesJson ) +{ + nlohmann::json out; + SCH_EDIT_FRAME* fr = schFrame(); + + if( !fr ) + { + out["ok"] = false; + out["error"] = "no schematic frame"; + return out.dump(); + } + + std::set names; + + try + { + for( const auto& n : nlohmann::json::parse( aNamesJson ) ) + names.insert( wxString::FromUTF8( n.get().c_str() ) ); + } + catch( ... ) + { + out["ok"] = false; + out["error"] = "bad names"; + return out.dump(); + } + + const wxString lib = wxString::FromUTF8( aLibNickname.c_str() ); + pcbjam_collab::runOnCoroutine( fr, [fr, lib, names]() + { + int updated = 0; + std::vector missing; + SCH_COMMIT commit( fr ); + SCH_SCREENS screens( fr->Schematic().Root() ); + + for( SCH_SCREEN* screen = screens.GetFirst(); screen; screen = screens.GetNext() ) + { + std::vector targets; + + for( SCH_ITEM* item : screen->Items().OfType( SCH_SYMBOL_T ) ) + { + SCH_SYMBOL* sym = static_cast( item ); + const LIB_ID& id = sym->GetLibId(); + + if( wxString( id.GetLibNickname() ) == lib && names.count( wxString( id.GetLibItemName() ) ) ) + targets.push_back( sym ); + } + + for( SCH_SYMBOL* sym : targets ) + { + LIB_SYMBOL* libSymbol = fr->GetLibSymbol( sym->GetLibId() ); + + if( !libSymbol ) + { + missing.push_back( std::string( sym->GetLibId().Format().c_str() ) ); + continue; + } + + std::unique_ptr flattened = libSymbol->Flatten(); + + if( flattened->GetUnitCount() < sym->GetUnit() ) + { + missing.push_back( std::string( sym->GetLibId().Format().c_str() ) ); + continue; + } + + // Same order as the dialog: remove, record, swap the lib symbol, + // re-append (the screen's RTree + connectivity re-index). + screen->Remove( sym ); + commit.Modified( sym, static_cast( sym->Clone() ), screen ); + sym->SetLibSymbol( flattened.release() ); + sym->SetExcludedFromSim( sym->GetLibSymbolRef()->GetExcludedFromSim() ); + sym->SetExcludedFromBOM( sym->GetLibSymbolRef()->GetExcludedFromBOM() ); + sym->SetExcludedFromBoard( sym->GetLibSymbolRef()->GetExcludedFromBoard() ); + sym->SetShowPinNames( sym->GetLibSymbolRef()->GetShowPinNames() ); + sym->SetShowPinNumbers( sym->GetLibSymbolRef()->GetShowPinNumbers() ); + sym->SetSchSymbolLibraryName( wxEmptyString ); + screen->Append( sym ); + updated++; + } + } + + commit.Push( wxT( "Update symbols from library" ) ); + fr->GetCanvas()->Refresh(); + emitLibUpdateDone( updated, missing ); + } ); + + // The body runs deferred on the frame's coroutine (runOnCoroutine = + // CallAfter): the caller awaits the `pcbjam:lib-update-done` window event + // for the outcome. + out["ok"] = true; + out["queued"] = true; + return out.dump(); +} + +// Standalone-eeschema shape of kicadUpdateFromLibrary(kind, lib, namesJson). +static std::string schUpdateFromLibraryShim( std::string aKind, std::string aLib, std::string aNames ) +{ + if( aKind != "symbol" ) + return "{\"ok\":false,\"error\":\"kind not handled by this editor\"}"; + + return schUpdateFromLibrary( aLib, aNames ); +} + int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName ) { SCH_EDIT_FRAME* fr = schFrame(); @@ -2110,6 +2235,8 @@ EMSCRIPTEN_BINDINGS(eeschema) { // Placed-instance count for a library symbol (drives the "a symbol you are // using was updated" toast after a remote lib edit). function("kicadLibsSymbolUsage", &schLibsSymbolUsage); + // Update placed symbols from the library (libs 0017 §2c). + function("kicadUpdateFromLibrary", &schUpdateFromLibraryShim); #endif // !KICAD_MERGED_EMBIND } #endif diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index 9a042b8..6a45cdc 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -50,6 +50,10 @@ using namespace emscripten; // Per-editor entry points and frame probes — defined (with external linkage) in // pcbnew_embind.cpp / eeschema_embind.cpp. bool pcbEditorActive(); +// libs 0017 §2c/2d: placed-footprint usage + update-from-library. +int pcbLibsFootprintUsage( std::string aLib, std::string aName ); +std::string pcbUpdateFromLibrary( std::string aLib, std::string aNamesJson ); +std::string schUpdateFromLibrary( std::string aLib, std::string aNamesJson ); void pcbCollabApply( std::string aJson ); void pcbCollabApplyItems( std::string aJson ); std::string pcbCollabSnapshot(); @@ -409,6 +413,26 @@ static int libsSymbolUsage( std::string aLib, std::string aName ) return schEditorActive() ? schLibsSymbolUsage( aLib, aName ) : 0; } +// Placed-instance count for a library footprint — board frame only (libs 0017 §2d). +static int libsFootprintUsage( std::string aLib, std::string aName ) +{ + return pcbEditorActive() ? pcbLibsFootprintUsage( aLib, aName ) : 0; +} + +// Update placed instances of the named lib items from the library (libs 0017 +// §2c): `aKind` picks the editor — "footprint" needs the board frame, +// "symbol" the schematic frame; a mismatch answers {ok:false}. +static std::string updateFromLibrary( std::string aKind, std::string aLib, std::string aNamesJson ) +{ + if( aKind == "footprint" && pcbEditorActive() ) + return pcbUpdateFromLibrary( aLib, aNamesJson ); + + if( aKind == "symbol" && schEditorActive() ) + return schUpdateFromLibrary( aLib, aNamesJson ); + + return "{\"ok\":false,\"error\":\"no editor for this kind\"}"; +} + // Presence shims (collab-presence 0002 pcbnew / 0003 eeschema): route to the live // editor's implementation, same pattern as the collab bridge shims above. static void collabPresenceStart() @@ -603,6 +627,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { // 0 from any other frame; drives the "symbol you are using was updated" // toast after a remote lib edit). function("kicadLibsSymbolUsage", &libsSymbolUsage); + // Placed-footprint usage + update-from-library (libs 0017 §2c/2d). + function("kicadLibsFootprintUsage", &libsFootprintUsage); + function("kicadUpdateFromLibrary", &updateFromLibrary); } #endif // __EMSCRIPTEN__ diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 3039f19..c45335c 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -2480,6 +2481,140 @@ static bool kicadCollabBusyProbe() return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty(); } + +// ── libs 0017 §2d/§2c: placed-footprint usage + update-from-library ────────── + +// Placed-instance count for a library footprint (mirror of schLibsSymbolUsage): +// drives the "a footprint you placed was updated" notice after a remote lib +// edit. 0 without a board frame. +int pcbLibsFootprintUsage( std::string aLibNickname, std::string aFootprintName ) +{ + PCB_EDIT_FRAME* fr = pcbFrame(); + + if( !fr || !fr->GetBoard() ) + return 0; + + const LIB_ID target( wxString::FromUTF8( aLibNickname.c_str() ), + wxString::FromUTF8( aFootprintName.c_str() ) ); + int count = 0; + + for( FOOTPRINT* fp : fr->GetBoard()->Footprints() ) + { + if( fp->GetFPID() == target ) + count++; + } + + return count; +} + +// Outcome of a deferred update-from-library run → window event +// `pcbjam:lib-update-done` {ok, updated, missing[]} (libs 0017 §2c). +static void emitLibUpdateDone( int aUpdated, const std::vector& aMissing ) +{ + nlohmann::json j; + j["ok"] = true; + j["updated"] = aUpdated; + j["missing"] = aMissing; + std::string s = j.dump(); + EM_ASM( { + if( typeof window !== 'undefined' ) + window.dispatchEvent( new CustomEvent( 'pcbjam:lib-update-done', + { detail: JSON.parse( UTF8ToString( $0 ) ) } ) ); + }, s.c_str() ); +} + +// Re-read the named library footprints into every placed instance — the +// headless core of Tools ▸ Update Footprints from Library… (DIALOG_EXCHANGE_ +// FOOTPRINTS::processFootprint with the dialog's defaults), scoped to +// `aNamesJson` (a JSON array of footprint names) of `aLibNickname`. Runs as a +// normal BOARD_COMMIT on the frame's coroutine so peers receive it as an +// ordinary edit. Returns {ok, updated, missing[]}. +std::string pcbUpdateFromLibrary( std::string aLibNickname, std::string aNamesJson ) +{ + nlohmann::json out; + PCB_EDIT_FRAME* fr = pcbFrame(); + + if( !fr || !fr->GetBoard() ) + { + out["ok"] = false; + out["error"] = "no board frame"; + return out.dump(); + } + + std::set names; + + try + { + for( const auto& n : nlohmann::json::parse( aNamesJson ) ) + names.insert( wxString::FromUTF8( n.get().c_str() ) ); + } + catch( ... ) + { + out["ok"] = false; + out["error"] = "bad names"; + return out.dump(); + } + + const wxString lib = wxString::FromUTF8( aLibNickname.c_str() ); + pcbjam_collab::runOnCoroutine( fr, [fr, lib, names]() + { + int updated = 0; + std::vector missing; + BOARD_COMMIT commit( fr ); + // Reverse: ExchangeFootprint appends the replacement at the end of the list. + std::vector targets; + + for( FOOTPRINT* fp : fr->GetBoard()->Footprints() ) + { + const LIB_ID& id = fp->GetFPID(); + + if( wxString( id.GetLibNickname() ) == lib && names.count( wxString( id.GetLibItemName() ) ) ) + targets.push_back( fp ); + } + + for( auto it = targets.rbegin(); it != targets.rend(); ++it ) + { + FOOTPRINT* fp = *it; + FOOTPRINT* fresh = fr->LoadFootprint( fp->GetFPID() ); + + if( !fresh ) + { + missing.push_back( std::string( fp->GetFPID().Format().c_str() ) ); + continue; + } + + bool changed = fp->FootprintNeedsUpdate( fresh ); + fr->ExchangeFootprint( fp, fresh, commit, /*deleteExtraTexts*/ true, + /*resetTextLayers*/ true, /*resetTextEffects*/ true, + /*resetTextPositions*/ true, /*resetTextContent*/ true, + /*resetFabricationAttrs*/ true, + /*resetClearanceOverrides*/ true, /*reset3DModels*/ true, + &changed ); + updated++; + } + + commit.Push( wxT( "Update footprints from library" ) ); + fr->GetCanvas()->Refresh(); + emitLibUpdateDone( updated, missing ); + } ); + + // The body runs deferred on the frame's coroutine (runOnCoroutine = + // CallAfter): the caller awaits the `pcbjam:lib-update-done` window event + // for the outcome. + out["ok"] = true; + out["queued"] = true; + return out.dump(); +} + +// Standalone-pcbnew shape of kicadUpdateFromLibrary(kind, lib, namesJson). +static std::string pcbUpdateFromLibraryShim( std::string aKind, std::string aLib, std::string aNames ) +{ + if( aKind != "footprint" ) + return "{\"ok\":false,\"error\":\"kind not handled by this editor\"}"; + + return pcbUpdateFromLibrary( aLib, aNames ); +} + EMSCRIPTEN_BINDINGS(pcbnew) { // Register vector types for iteration register_vector("FootprintVector"); @@ -2497,6 +2632,11 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // Programmatic save of the in-memory board (round-trip tests, README §A). function("kicadSaveBoard", &kicadSaveBoard); +#ifndef KICAD_MERGED_EMBIND + // Placed-footprint usage + update-from-library (libs 0017 §2c/2d). + function("kicadLibsFootprintUsage", &pcbLibsFootprintUsage); + function("kicadUpdateFromLibrary", &pcbUpdateFromLibraryShim); +#endif // Layer bridge (viewer-panels) — pcbnew-only names, merged-image safe // (null-frame no-op when eeschema is the live frame). function("kicadLayersGetState", &pcbLayersGetState); diff --git a/web/standalone/src/components/OverlayMenu.tsx b/web/standalone/src/components/OverlayMenu.tsx index 66b7edb..62ce21b 100644 --- a/web/standalone/src/components/OverlayMenu.tsx +++ b/web/standalone/src/components/OverlayMenu.tsx @@ -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} )} + {alert && ( + + + + )} {unread > 0 && ( (["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. -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. -const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open"; -const INSPECTOR_OPEN_KEY = "pcbjam:inspector-panel-open"; - -// Tooltip only — the matcher accepts both chords on any platform. -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). -const LIB_KIND_FOR_TOOL: Partial> = { - symbol_editor: "symbol", - eeschema: "symbol", - footprint_editor: "footprint", - pcbnew: "footprint", -}; - -/** What the download-consent dialog quotes (standalone-load-ux 0001). */ -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. - */ -async function gatherConsentInfo( - meta: WasmMeta, - source: LibsSource | null, - tool: Tool, -): Promise { - 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 => { - 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), - }; -} -const LEGACY_EXTENSION_TOOL: Record = { - ".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); -} - -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; -} - -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; -} - -function fileStem(path: string): string { - const name = path.replace(/\\/g, "/").split("/").pop() ?? path; - return name.replace(/\.[^.]+$/, ""); -} - -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; -} - -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; -} - -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; - 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; - }; -} - -// 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. - */ -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 }, - ); -} - -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); - }; -} - -/** - * 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. - */ -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. - */ -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. - */ -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 { - 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. - */ -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 { - 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. - */ -async function waitForWxUi(win: ToolWindow, timeoutMs = 25_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if ((win.wxElementRegistry?.findAll({}).length ?? 0) > 3) return; - await new Promise((r) => setTimeout(r, 150)); - } -} - -/** - * 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. - */ -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; - } -} +import { WasmErrorBoundary } from "@/components/wasm-tool/WasmErrorBoundary"; +import { + DownloadConsent, + DownloadProgress, + gatherConsentInfo, + libSyncLabel, + type ConsentInfo, +} from "@/components/wasm-tool/DownloadConsent"; +import { + maybeConnectDocSession, + maybeStartCollab, + startSheetCollab, + waitForWxUi, +} from "@/components/wasm-tool/collab-start"; +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"; /** * Boots a KiCad tool directly in this React document (no iframe): builds the @@ -1145,6 +332,86 @@ export function WasmTool({ // 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); @@ -1370,24 +637,17 @@ export function WasmTool({ // 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. - if (d.kind === "footprint") { - if (d.names.length === 0) return; - const names = d.names.map((n) => `"${n}"`).join(", "); - setLibUpdate( - `${d.names.length === 1 ? "Footprint" : "Footprints"} ${names} in "${d.lib}" ` + - `${d.names.length === 1 ? "was" : "were"} updated by a collaborator — ` + - `placed copies keep the previous version until updated from the library.`, - ); - return; - } // Only warn when the update touches something PLACED here — the library - // tree already reflects updates to everything else. + // 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 ? "Symbol" : "Symbols"} ${names} in "${d.lib}" ` + + `${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 until updated from the library.`, + `placed copies keep the previous version. Update them from the session menu.`, ); }; const onDocReverted = (e: Event) => { @@ -1423,7 +683,7 @@ export function WasmTool({ window.removeEventListener(LIB_SET_CHANGED_EVENT, onLibSet); window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted); }; - }, []); + }, [noteStale]); // Auto-dismiss the lib error toast. React.useEffect(() => { @@ -2700,6 +1960,7 @@ export function WasmTool({ badge={peers.length} unread={commentsUnread.threads} unreadMention={commentsUnread.mentioned} + alert={staleLibItems.size > 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 @@ -2722,8 +1983,60 @@ export function WasmTool({ 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) && ( + {(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 && (
@@ -3063,234 +2376,4 @@ export function WasmTool({ ); } -/** "~173 MB" — coarse on purpose; these are quotes, not meters. */ -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} -
- - {approxMB(info.toolRawBytes)} uncompressed - - - ); -} - -/** - * 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. - */ -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, - ) => ( -
  • -
    -

    {title}

    -

    {detail}

    -
    - {figure && ( - - {figure} - - )} -
  • - ); - return ( -
    - -

    - {info.update ? "Editor update available" : "One-time download needed"} -

    -

    - 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."} -

    -
      - {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", - )} -
    - - -
    - ); -} - -/** - * 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). - */ -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). - */ -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 ( -
    - {determinate ? ( - <> -
    -
    -
    -

    - {mb(progress.loaded)} / {mb(progress.total)} ({pct}%) -

    - - ) : ( -

    - {mb(progress.loaded)} downloaded… -

    - )} -
    - ); -} +/** "~173 MB" — coarse on purpose; these are quotes, not meters. */ \ No newline at end of file diff --git a/web/standalone/src/components/wasm-tool/DownloadConsent.tsx b/web/standalone/src/components/wasm-tool/DownloadConsent.tsx new file mode 100644 index 0000000..28d47b0 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/DownloadConsent.tsx @@ -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 { + 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 => { + 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} +
    + + {approxMB(info.toolRawBytes)} uncompressed + + + ); +} + +/** + * 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, + ) => ( +
  • +
    +

    {title}

    +

    {detail}

    +
    + {figure && ( + + {figure} + + )} +
  • + ); + return ( +
    + +

    + {info.update ? "Editor update available" : "One-time download needed"} +

    +

    + 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."} +

    +
      + {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", + )} +
    + + +
    + ); +} + +/** + * 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 ( +
    + {determinate ? ( + <> +
    +
    +
    +

    + {mb(progress.loaded)} / {mb(progress.total)} ({pct}%) +

    + + ) : ( +

    + {mb(progress.loaded)} downloaded… +

    + )} +
    + ); +} + diff --git a/web/standalone/src/components/wasm-tool/WasmErrorBoundary.tsx b/web/standalone/src/components/wasm-tool/WasmErrorBoundary.tsx new file mode 100644 index 0000000..621c66c --- /dev/null +++ b/web/standalone/src/components/wasm-tool/WasmErrorBoundary.tsx @@ -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; + } +} + diff --git a/web/standalone/src/components/wasm-tool/collab-start.ts b/web/standalone/src/components/wasm-tool/collab-start.ts new file mode 100644 index 0000000..9496d69 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/collab-start.ts @@ -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 { + 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 { + 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 { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((win.wxElementRegistry?.findAll({}).length ?? 0) > 3) return; + await new Promise((r) => setTimeout(r, 150)); + } +} + diff --git a/web/standalone/src/components/wasm-tool/quit-hook.ts b/web/standalone/src/components/wasm-tool/quit-hook.ts new file mode 100644 index 0000000..dfd3f78 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/quit-hook.ts @@ -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); + }; +} + diff --git a/web/standalone/src/components/wasm-tool/tool-navigation.ts b/web/standalone/src/components/wasm-tool/tool-navigation.ts new file mode 100644 index 0000000..77aeb39 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/tool-navigation.ts @@ -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 = { + ".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; + 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; + }; +} + diff --git a/web/standalone/src/components/wasm-tool/ui-helpers.ts b/web/standalone/src/components/wasm-tool/ui-helpers.ts new file mode 100644 index 0000000..86db5e6 --- /dev/null +++ b/web/standalone/src/components/wasm-tool/ui-helpers.ts @@ -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(["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> = { + symbol_editor: "symbol", + eeschema: "symbol", + footprint_editor: "footprint", + pcbnew: "footprint", +}; + diff --git a/web/standalone/src/wasm/libs/source.test.ts b/web/standalone/src/wasm/libs/source.test.ts index afe2e1e..08fc9fa 100644 --- a/web/standalone/src/wasm/libs/source.test.ts +++ b/web/standalone/src/wasm/libs/source.test.ts @@ -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; + } + }); +}); diff --git a/web/standalone/src/wasm/libs/source.ts b/web/standalone/src/wasm/libs/source.ts index 849bb7a..22e8cdc 100644 --- a/web/standalone/src/wasm/libs/source.ts +++ b/web/standalone/src/wasm/libs/source.ts @@ -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 }); } }; diff --git a/web/standalone/src/wasm/libs/synced-source.test.ts b/web/standalone/src/wasm/libs/synced-source.test.ts index 034c6b1..571dd97 100644 --- a/web/standalone/src/wasm/libs/synced-source.test.ts +++ b/web/standalone/src/wasm/libs/synced-source.test.ts @@ -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({}); diff --git a/web/standalone/src/wasm/libs/synced-source.ts b/web/standalone/src/wasm/libs/synced-source.ts index 1fdd762..8c7406f 100644 --- a/web/standalone/src/wasm/libs/synced-source.ts +++ b/web/standalone/src/wasm/libs/synced-source.ts @@ -133,9 +133,17 @@ export function syncedLibsSource( mod: Record | 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 ( diff --git a/wxwidgets b/wxwidgets index 0447984..cdd5a5c 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit 0447984ab803dec31ac9a427524433e4c3117e97 +Subproject commit cdd5a5c99e457dfc0e92abec0419bf33331bddc9