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:
parent
9a19b96b6c
commit
866db5888c
18 changed files with 2084 additions and 1092 deletions
187
tests/kicad/save-cmd-key.spec.ts
Normal file
187
tests/kicad/save-cmd-key.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
217
tests/web/fpedit-cmd-save.spec.ts
Normal file
217
tests/web/fpedit-cmd-save.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
309
web/standalone/src/components/wasm-tool/DownloadConsent.tsx
Normal file
309
web/standalone/src/components/wasm-tool/DownloadConsent.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
387
web/standalone/src/components/wasm-tool/collab-start.ts
Normal file
387
web/standalone/src/components/wasm-tool/collab-start.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
124
web/standalone/src/components/wasm-tool/quit-hook.ts
Normal file
124
web/standalone/src/components/wasm-tool/quit-hook.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
||||
213
web/standalone/src/components/wasm-tool/tool-navigation.ts
Normal file
213
web/standalone/src/components/wasm-tool/tool-navigation.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
||||
43
web/standalone/src/components/wasm-tool/ui-helpers.ts
Normal file
43
web/standalone/src/components/wasm-tool/ui-helpers.ts
Normal 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",
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue