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

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

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

View file

@ -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<string, (...a: (string | number)[]) => unknown>;
type FS = {
mkdirTree(p: string): void;
writeFile(p: string, d: string): void;
};
type HookWindow = Window & {
FS: FS;
Module: Mod;
kicadCollab?: Record<string, unknown>;
__savedPaths: string[];
};
const BOOT_TIMEOUT = 150000;
const NAME = "savecmd";
async function bootOpen(page: Page, cfg: ToolCfg): Promise<string> {
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<void> {
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<number> {
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<void> {
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);
});
});

View file

@ -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 <input> 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<unknown> };
__libOps: { op: string; arg: string }[];
};
function frameNames(page: Page): Promise<string[]> {
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<string | null> {
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<number> {
return page.evaluate(
() => (window as unknown as SpyWindow).__libOps.filter((o) => o.op === 'save').length,
);
}
async function clickCanvasCenter(page: Page): Promise<void> {
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<void> {
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/<id>", 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');
});
});

View file

@ -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<std::string>& 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<wxString> names;
try
{
for( const auto& n : nlohmann::json::parse( aNamesJson ) )
names.insert( wxString::FromUTF8( n.get<std::string>().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<std::string> missing;
SCH_COMMIT commit( fr );
SCH_SCREENS screens( fr->Schematic().Root() );
for( SCH_SCREEN* screen = screens.GetFirst(); screen; screen = screens.GetNext() )
{
std::vector<SCH_SYMBOL*> targets;
for( SCH_ITEM* item : screen->Items().OfType( SCH_SYMBOL_T ) )
{
SCH_SYMBOL* sym = static_cast<SCH_SYMBOL*>( 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<LIB_SYMBOL> 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<SCH_SYMBOL*>( 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

View file

@ -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__

View file

@ -26,6 +26,7 @@
#include <zone.h>
#include <eda_text.h>
#include <pcb_edit_frame.h>
#include <lib_id.h>
#include <kicad_clipboard.h>
#include <io/kicad/kicad_io_utils.h>
#include <richio.h>
@ -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<std::string>& 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<wxString> names;
try
{
for( const auto& n : nlohmann::json::parse( aNamesJson ) )
names.insert( wxString::FromUTF8( n.get<std::string>().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<std::string> missing;
BOARD_COMMIT commit( fr );
// Reverse: ExchangeFootprint appends the replacement at the end of the list.
std::vector<FOOTPRINT*> 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<FOOTPRINT*>("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);

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1 +1 @@
Subproject commit 0447984ab803dec31ac9a427524433e4c3117e97
Subproject commit cdd5a5c99e457dfc0e92abec0419bf33331bddc9