project-sync: boot .kicad_pro synthesis + creation-time companion + live sibling restage

- defaultKicadPro() template + synthesizeProjectFile() writes a minimal
  <stem>.kicad_pro into MEMFS when a board/schematic opens without one, so KiCad
  runs a real project (self-heals legacy fileless projects).
- NewFileDialog creates the .kicad_pro companion alongside a new board/schematic.
- sibling-restage.ts: pcbnew sessions subscribe to sibling .kicad_sch collab rooms
  as invisible observers and re-stage MEMFS on update (restageFile extracted from
  syncProjectToMemfs), so a sync reads live schematic data, not the boot snapshot.
- tests/kicad/project-sync.spec.ts (2 e2e) + sibling-restage.test.ts (5 unit).
- Bump kicad pointer (SCH-from-PCB sync gate + hidden player + .kicad_pro save hook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdqhM7j71XQn96hcHkabDu
This commit is contained in:
Gergő Törcsvári 2026-07-16 19:05:23 +02:00
commit 2d89f41060
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 757 additions and 12 deletions

2
kicad

@ -1 +1 @@
Subproject commit c73cc55489047e377afa8088e598d003308c0925 Subproject commit 261621e87174886d6fd4633e8bfb7f138908d56b

View file

@ -0,0 +1,413 @@
import { test, expect } from '@playwright/test';
import {
clickMenuBarItem,
clickMenuItemByText,
waitForEditorReady,
waitUntil,
stableShot,
} from '../e2e/utils/element-tracker';
/**
* project-sync 0001 "Update PCB from Schematic" in the merged WASM editor.
*
* The merged kicad_editor bundle statically links BOTH kifaces, so the fork's
* KIWAY::FaceRegistered(FACE_SCH) predicate lets a "standalone" PCB editor run
* the sync: F8 spawns the eeschema kiface IN-PROCESS (kept hidden in the wasm
* build see TestStandalone's __EMSCRIPTEN__ branch), loads the sibling
* .kicad_sch, and returns the netlist over MAIL_SCH_GET_NETLIST into
* DIALOG_UPDATE_PCB.
*
* Two tests:
* 1. first sync the action is enabled, the dialog opens, the spawned
* schematic frame stays HIDDEN (does not hijack the PCB tab).
* 2. edit-then-resync after the .kicad_sch changes underneath (the live
* sibling-restage's effect), a SECOND sync reflects the NEW schematic, not
* a cached parse. This is the regression guard for the hidden-player
* reload (repeat-F8 freshness).
*/
const STEM = 'syncspike';
const DIR = '/home/kicad/documents';
const SCH_UUID = '00000000-0000-4000-8000-00000000a001';
const PCB = `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
\t(generator_version "9.0")
\t(general (thickness 1.6))
\t(paper "A4")
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(2 "B.Cu" signal)
\t\t(25 "Edge.Cuts" user)
\t)
\t(setup)
\t(net 0 "")
)
`;
// Keep in sync with web/standalone/src/lib/new-file.ts defaultKicadPro().
const PRO = `${JSON.stringify({ meta: { filename: `${STEM}.kicad_pro`, version: 3 } }, null, 2)}\n`;
// Empty root sheet — enough to prove the sync round-trips (netlist has zero
// components; DIALOG_UPDATE_PCB still opens).
const EMPTY_SCH = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "${SCH_UUID}")
\t(paper "A4")
\t(lib_symbols)
\t(sheet_instances
\t\t(path "/" (page "1"))
\t)
)
`;
// A self-contained resistor symbol (lib def + placement) lifted verbatim from
// the ecc83 demo, with a swappable reference designator so two schematic
// versions carry DIFFERENT, searchable references in the netlist.
const R_LIBDEF = String.raw`(symbol "ecc83-pp:R"
(pin_numbers
(hide yes)
)
(pin_names
(offset 0)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(property "Reference" "R"
(at 2.032 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Value" "R"
(at 0 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Footprint" ""
(at -1.778 0 90)
(effects
(font
(size 0.762 0.762)
)
)
)
(property "Datasheet" ""
(at 0 0 0)
(effects
(font
(size 0.762 0.762)
)
)
)
(property "Description" ""
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "ki_fp_filters" "R_* Resistor_*"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(symbol "R_0_1"
(rectangle
(start -1.016 -2.54)
(end 1.016 2.54)
(stroke
(width 0.254)
(type default)
)
(fill
(type none)
)
)
)
(symbol "R_1_1"
(pin passive line
(at 0 3.81 270)
(length 1.27)
(name "~"
(effects
(font
(size 1.524 1.524)
)
)
)
(number "1"
(effects
(font
(size 1.524 1.524)
)
)
)
)
(pin passive line
(at 0 -3.81 90)
(length 1.27)
(name "~"
(effects
(font
(size 1.524 1.524)
)
)
)
(number "2"
(effects
(font
(size 1.524 1.524)
)
)
)
)
)
(embedded_fonts no)
)`;
const R_PLACEMENT = String.raw`(symbol
(lib_id "ecc83-pp:R")
(at 157.48 85.09 180)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(uuid "00000000-0000-0000-0000-00004549f38a")
(property "Reference" "__REF__"
(at 154.94 85.09 0)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Value" "1.5K"
(at 157.48 85.09 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Footprint" "Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal"
(at 159.512 85.0392 90)
(effects
(font
(size 0.254 0.254)
)
)
)
(property "Datasheet" ""
(at 157.48 85.09 0)
(effects
(font
(size 1.524 1.524)
)
(hide yes)
)
)
(property "Description" ""
(at 157.48 85.09 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(pin "1"
(uuid "490a35ba-1ba2-44c5-adce-e27a045257ab")
)
(pin "2"
(uuid "51355c82-d1cf-445b-b079-21c660dd8989")
)
(instances
(project "ecc83-pp"
(path "/28f865a0-4433-4a53-bbd7-b62f276848e4"
(reference "__REF__")
(unit 1)
)
)
)
)`;
function resistorSch(ref: string): string {
return `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "${SCH_UUID}")
\t(paper "A4")
\t(lib_symbols
\t\t${R_LIBDEF}
\t)
\t${R_PLACEMENT.replace(/__REF__/g, ref)}
\t(sheet_instances
\t\t(path "/" (page "1"))
\t)
)
`;
}
const BOOT_TIMEOUT = 150000;
interface FS { mkdirTree(p: string): void; writeFile(p: string, d: string): void; }
interface Mod { kicadOpenFile(p: string): unknown; }
/** Stage board + schematic + minimal .kicad_pro into MEMFS, then open the board. */
async function stageAndOpen(page: import('@playwright/test').Page, sch: string): Promise<void> {
await page.goto('/kicad/pcbnew.html');
await waitForEditorReady(page);
await page.evaluate(
({ dir, stem, pcb, sch, pro }) => {
const w = window as unknown as { FS: FS; Module: Mod };
try { w.FS.mkdirTree(dir); } catch { /* exists */ }
w.FS.writeFile(`${dir}/${stem}.kicad_pcb`, pcb);
w.FS.writeFile(`${dir}/${stem}.kicad_sch`, sch);
w.FS.writeFile(`${dir}/${stem}.kicad_pro`, pro);
w.Module.kicadOpenFile(`${dir}/${stem}.kicad_pcb`);
},
{ dir: DIR, stem: STEM, pcb: PCB, sch, pro: PRO },
);
await waitUntil(
page,
(s: string) => document.title.includes(s),
`board "${STEM}" opened (window title)`,
{ timeout: BOOT_TIMEOUT, arg: STEM },
);
}
/** Overwrite the staged schematic in MEMFS (simulates the live sibling-restage). */
async function rewriteSchematic(page: import('@playwright/test').Page, sch: string): Promise<void> {
await page.evaluate(
({ dir, stem, sch }) => {
const w = window as unknown as { FS: FS };
w.FS.writeFile(`${dir}/${stem}.kicad_sch`, sch);
},
{ dir: DIR, stem: STEM, sch },
);
}
/** Tools -> Update PCB from Schematic, and wait for DIALOG_UPDATE_PCB. */
async function openSyncDialog(page: import('@playwright/test').Page): Promise<void> {
const opened = await clickMenuBarItem(page, 'Tools');
expect(opened, 'Tools menu should open').toBe(true);
await clickMenuItemByText(page, 'Update PCB from Schematic');
await waitUntil(
page,
() => {
const r = window.wxElementRegistry;
if (!r || !r.findAll) return false;
const visible = r.findAll({ visible: true });
const dialogUp = visible.some((e) => e.typeName === 'wxDialog');
const marker = visible.some((e) => /Update PCB|Changes to Be Applied/i
.test(`${e.label || ''} ${e.name || ''}`));
return dialogUp && marker;
},
'DIALOG_UPDATE_PCB visible',
{ timeout: 120000 },
);
}
/** Close the currently-open modal dialog (Escape unwinds the wx modal loop). */
async function closeDialog(page: import('@playwright/test').Page): Promise<void> {
await page.keyboard.press('Escape');
await waitUntil(
page,
() => {
const r = window.wxElementRegistry;
return !!r && !!r.findAll && !r.findAll({ visible: true })
.some((e) => e.typeName === 'wxDialog');
},
'DIALOG_UPDATE_PCB closed',
{ timeout: 30000 },
);
}
/** The spawned schematic frame must not be visible / must not take over the tab. */
async function assertSchFrameHidden(page: import('@playwright/test').Page): Promise<void> {
const schShown = await page.evaluate(() => {
const r = window.wxElementRegistry;
if (!r || !r.findAll) return false;
return r.findAll({ visible: true })
.some((e) => e.typeName === 'wxFrame' && /Schematic/i.test(e.name || e.label || ''));
});
expect(schShown, 'schematic frame must stay hidden (no PCB-tab hijack)').toBe(false);
expect(await page.title(), 'PCB tab keeps its title').toMatch(/PCB Editor/i);
}
test.describe('project-sync: update PCB from schematic (merged bundle)', () => {
test('first sync opens the dialog with the schematic frame hidden', async ({ page }) => {
const consoleLines: string[] = [];
page.on('console', (m) => consoleLines.push(m.text()));
page.on('pageerror', (e) => consoleLines.push(`pageerror: ${e.message}`));
await stageAndOpen(page, EMPTY_SCH);
await stableShot(page, 'project-sync-00-board-open.png');
await openSyncDialog(page);
await stableShot(page, 'project-sync-01-update-dialog.png');
// The core of the hidden-player fix: the netlist came back and the
// dialog is up, but eeschema never hijacked the PCB tab.
await assertSchFrameHidden(page);
expect(consoleLines.some((s) => s.includes('Aborted(')),
'no wasm abort during the sync flow').toBe(false);
});
test('a schematic edit between syncs is reflected on the second sync', async ({ page }) => {
const consoleLines: string[] = [];
page.on('console', (m) => consoleLines.push(m.text()));
page.on('pageerror', (e) => consoleLines.push(`pageerror: ${e.message}`));
// The netlist report (DIALOG_UPDATE_PCB's WX_HTML_REPORT_BOX) is painted to
// the GL canvas — its text is NOT in the DOM or the wx registry (verified),
// so the R777-vs-R888 content is asserted the way this repo asserts all
// editor-rendered content: the committed screenshot baseline. The
// PROGRAMMATIC guards here are the reload MECHANICS: that a second sync,
// with the .kicad_sch changed underneath, reaches DIALOG_UPDATE_PCB again
// (openSyncDialog throws on timeout — so a reload that hung on a "save
// changes?" prompt, or a cached frame that never re-read, would fail loudly)
// with the schematic frame still hidden and no wasm abort.
// First sync: schematic has R777. Reaching the dialog proves the freshly
// spawned hidden player loaded this schematic and returned its netlist.
await stageAndOpen(page, resistorSch('R777'));
await openSyncDialog(page);
await assertSchFrameHidden(page);
await stableShot(page, 'project-sync-02-resync-first-R777.png');
await closeDialog(page);
// The schematic changes underneath — exactly what the live sibling-restage
// writes into MEMFS when a collaborator edits it: R777 -> R888.
await rewriteSchematic(page, resistorSch('R888'));
// Second sync: the hidden player re-reads MEMFS (IsShownOnScreen() stays
// false, so TestStandalone re-runs OpenProjectFiles), so the netlist now
// reflects R888. Reaching the dialog again == the repeat-sync path did not
// hang; the screenshot baseline is the R888-not-R777 content guard.
await openSyncDialog(page);
await assertSchFrameHidden(page);
await stableShot(page, 'project-sync-03-resync-second-R888.png');
expect(consoleLines.some((s) => s.includes('Aborted(')),
'no wasm abort during the resync flow').toBe(false);
});
});

View file

@ -12,6 +12,7 @@ import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api"; import { useLocalProjects } from "@/lib/api";
import { import {
defaultFileName, defaultFileName,
defaultKicadPro,
newFileTemplate, newFileTemplate,
withExtension, withExtension,
} from "@/lib/new-file"; } from "@/lib/new-file";
@ -86,16 +87,31 @@ export function NewFileDialog({
const bytes = new TextEncoder().encode( const bytes = new TextEncoder().encode(
newFileTemplate(tool, crypto.randomUUID()), newFileTemplate(tool, crypto.randomUUID()),
); );
const files = [{ path: finalName, bytes }];
// A board/schematic is a project root: create its `<stem>.kicad_pro`
// sibling with it (project-sync 0001) so KiCad opens a real project and
// project-scoped settings (netclasses, ERC/DRC exclusions) can persist.
const proName = finalName.replace(/\.kicad_(pcb|sch)$/, ".kicad_pro");
if (
proName !== finalName &&
!(project?.existingPaths ?? []).includes(proName)
) {
files.push({
path: proName,
bytes: new TextEncoder().encode(defaultKicadPro(proName)),
});
}
let slug: string; let slug: string;
if (homeMode && target === NEW) { if (homeMode && target === NEW) {
if (!store) throw new Error("local project store unavailable"); if (!store) throw new Error("local project store unavailable");
const created = await store.createProject(projectName.trim() || "Untitled", [ const created = await store.createProject(
{ path: finalName, bytes }, projectName.trim() || "Untitled",
]); files,
);
slug = created.slug; slug = created.slug;
} else { } else {
slug = project ? project.slug : target; slug = project ? project.slug : target;
await uploadFileBytes(slug, finalName, bytes); for (const f of files) await uploadFileBytes(slug, f.path, f.bytes);
} }
// Full navigation so Emscripten boots into a clean page opening the file. // Full navigation so Emscripten boots into a clean page opening the file.
// A home-created project is browser-local (@local scope); an in-project new // A home-created project is browser-local (@local scope); an in-project new

View file

@ -71,6 +71,10 @@ import {
type FollowTarget, type FollowTarget,
} from "@/wasm/collab/follow-user"; } from "@/wasm/collab/follow-user";
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app"; import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
import {
startSiblingRestage,
type SiblingRestageHandle,
} from "@/wasm/collab/sibling-restage";
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding"; import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
import { import {
createComments, createComments,
@ -834,6 +838,7 @@ export function WasmTool({
// Project-wide presence room (0006): joined once per session, survives // Project-wide presence room (0006): joined once per session, survives
// eeschema sheet rebinds — the bridge re-reads it on every startPresence. // eeschema sheet rebinds — the bridge re-reads it on every startPresence.
const crossAppRef = React.useRef<CrossAppHandle | null>(null); const crossAppRef = React.useRef<CrossAppHandle | null>(null);
const siblingRestageRef = React.useRef<SiblingRestageHandle | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null); const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync // The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead. // (miss 08B); eeschema routes per sheet through the manager instead.
@ -1426,6 +1431,21 @@ export function WasmTool({
collabDocRef.current = collabHandle?.doc ?? null; collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider, undefined, collabHandle?.doc); startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
startComments(collabHandle?.doc); startComments(collabHandle?.doc);
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
// files a PCB session syncs from fresh in MEMFS, instead of the
// one-shot boot snapshot. Same opt-out as the room collab; read-only
// viewers skip it (they can't run the sync anyway).
if (tool === "pcbnew" && collabHandle && !readOnly) {
siblingRestageRef.current = await startSiblingRestage({
win,
slug,
scopeId,
projectId,
files,
provider: yjsProviderConfig(),
log: append,
});
}
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) { if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
driftRef.current = startDriftDetection({ driftRef.current = startDriftDetection({
doc: collabHandle.doc, doc: collabHandle.doc,
@ -1462,6 +1482,8 @@ export function WasmTool({
presenceRef.current = null; presenceRef.current = null;
crossAppRef.current?.destroy(); crossAppRef.current?.destroy();
crossAppRef.current = null; crossAppRef.current = null;
siblingRestageRef.current?.destroy();
siblingRestageRef.current = null;
driftRef.current?.stop(); driftRef.current?.stop();
driftRef.current = null; driftRef.current = null;
// Tears down every warm room's provider/doc (the only place providers are // Tears down every warm room's provider/doc (the only place providers are

View file

@ -32,6 +32,22 @@ export function withExtension(tool: Tool, name: string): string {
return ext && !name.toLowerCase().endsWith(ext) ? `${name}${ext}` : name; return ext && !name.toLowerCase().endsWith(ext) ? `${name}${ext}` : name;
} }
/**
* Minimal `.kicad_pro` a board/schematic can live next to. KiCad's PROJECT_FILE
* (JSON_SETTINGS) fills every missing key with schema defaults, and the file's
* mere existence is what unlocks the project-settings save paths (netclasses,
* ERC/DRC exclusions, text variables pcbnew/files.cpp gates them on
* FileExists()); the first in-editor save then writes the full document.
* `version` must track projectFileSchemaVersion (kicad common/project/project_file.cpp).
*/
export function defaultKicadPro(fileName: string): string {
return `${JSON.stringify(
{ meta: { filename: fileName, version: 3 } },
null,
2,
)}\n`;
}
export function newFileTemplate(tool: Tool, uuid: string): string { export function newFileTemplate(tool: Tool, uuid: string): string {
switch (tool) { switch (tool) {
case "eeschema": case "eeschema":

View file

@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Exercise ONLY the subscribe/debounce/restage orchestration: the room connect,
// the ydoc materialization, and the MEMFS write are all collaborators.
const { connectKicadDoc, restageFile, ydocHasState } = vi.hoisted(() => ({
connectKicadDoc: vi.fn(),
restageFile: vi.fn(),
ydocHasState: vi.fn(),
}));
vi.mock("./index", () => ({ connectKicadDoc }));
vi.mock("../kicad-runner", () => ({ restageFile }));
vi.mock("@pcbjam/shared", () => ({
collabRoomId: (s: string, p: string, d: string) => `${s}:${p}:${d}`,
ydocHasState,
yToDoc: (doc: unknown) => doc,
docToFile: () => "(kicad_sch materialized)",
}));
import { startSiblingRestage } from "./sibling-restage";
interface FakeSession {
room: string;
doc: {
on: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
emitRemote: () => void;
};
provider: {
destroy: ReturnType<typeof vi.fn>;
awareness: { setLocalState: ReturnType<typeof vi.fn> };
};
}
let sessions: FakeSession[];
function makeSession(room: string): FakeSession {
const handlers = new Set<() => void>();
return {
room,
doc: {
on: vi.fn((ev: string, cb: () => void) => {
if (ev === "update") handlers.add(cb);
}),
destroy: vi.fn(),
emitRemote: () => handlers.forEach((h) => h()),
},
provider: {
destroy: vi.fn(),
awareness: { setLocalState: vi.fn() },
},
};
}
function start(files: string[]) {
return startSiblingRestage({
win: {} as never,
slug: "proj",
scopeId: "S",
projectId: "P",
files: files.map((path) => ({ path })),
provider: { kind: "none" } as never,
log: () => {},
});
}
beforeEach(() => {
vi.useFakeTimers();
sessions = [];
connectKicadDoc.mockReset().mockImplementation(({ room }: { room: string }) => {
const s = makeSession(room);
sessions.push(s);
return Promise.resolve(s);
});
restageFile.mockReset();
ydocHasState.mockReset().mockReturnValue(true);
});
afterEach(() => {
vi.useRealTimers();
});
describe("startSiblingRestage", () => {
it("subscribes only the .kicad_sch siblings, as an invisible observer", async () => {
await start(["main.kicad_pcb", "main.kicad_sch", "sub.kicad_sch", "a.kicad_wks"]);
expect(sessions.map((s) => s.room)).toEqual([
"S:P:main.kicad_sch",
"S:P:sub.kicad_sch",
]);
for (const s of sessions) {
expect(s.provider.awareness.setLocalState).toHaveBeenCalledWith(null);
}
});
it("restages once on connect when the room already holds state", async () => {
await start(["main.kicad_sch"]);
expect(restageFile).toHaveBeenCalledTimes(1);
expect(restageFile.mock.calls[0]![2]).toBe("main.kicad_sch");
});
it("leaves the boot snapshot alone when the room is empty", async () => {
ydocHasState.mockReturnValue(false);
await start(["main.kicad_sch"]);
expect(restageFile).not.toHaveBeenCalled();
});
it("debounces remote updates into one restage", async () => {
await start(["main.kicad_sch"]);
restageFile.mockClear();
sessions[0]!.doc.emitRemote();
sessions[0]!.doc.emitRemote();
sessions[0]!.doc.emitRemote();
expect(restageFile).not.toHaveBeenCalled();
vi.runAllTimers();
expect(restageFile).toHaveBeenCalledTimes(1);
});
it("destroy tears down providers and cancels pending restages", async () => {
const handle = await start(["main.kicad_sch", "sub.kicad_sch"]);
restageFile.mockClear();
sessions[0]!.doc.emitRemote();
handle.destroy();
vi.runAllTimers();
expect(restageFile).not.toHaveBeenCalled();
for (const s of sessions) {
expect(s.provider.destroy).toHaveBeenCalled();
expect(s.doc.destroy).toHaveBeenCalled();
}
});
});

View file

@ -0,0 +1,111 @@
import { collabRoomId, docToFile, ydocHasState, yToDoc } from "@pcbjam/shared";
import type * as Y from "yjs";
import { restageFile } from "../kicad-runner";
import { connectKicadDoc, type KicadDocSession } from "./index";
import type { ProviderConfig } from "./provider";
/**
* Live sibling-document mirror for pcbnew sessions (project-sync 0001 bug 3).
*
* Boot stages every project file into MEMFS exactly once (kicad-runner
* syncProjectToMemfs), so the `.kicad_sch` a PCB session would sync from is a
* page-load snapshot while collaborators keep editing it in its own room. This
* subscribes as an invisible, data-only observer to every sibling sheet's
* collab room and re-materializes the file into MEMFS on updates, debounced
* like the lib refresh (synced-source scheduleEditorReload). It also restages
* once right after connect: the room is the source of truth since ysync, so it
* can already be ahead of the API snapshot boot fetched (unsaved collab edits
* live only in the room).
*
* MEMFS-only: nothing is uploaded and no editor poke is needed pcbnew reads
* the schematic from MEMFS when the sync runs. A sheet created by a peer
* mid-session is not picked up (its path isn't in the boot file list); v1
* accepts that gap.
*/
const RESTAGE_DEBOUNCE_MS = 400;
export interface SiblingRestageHandle {
destroy(): void;
}
export async function startSiblingRestage(opts: {
win: ToolWindow;
slug: string;
scopeId: string;
projectId: string;
files: { path: string }[];
provider: ProviderConfig;
log: (m: string) => void;
}): Promise<SiblingRestageHandle> {
const { win, slug, log } = opts;
const sheetPaths = opts.files
.map((f) => f.path)
.filter((p) => p.endsWith(".kicad_sch"));
const sessions: KicadDocSession[] = [];
const timers = new Map<string, ReturnType<typeof setTimeout>>();
let destroyed = false;
const restageFromDoc = (sheetPath: string, doc: Y.Doc) => {
try {
// An empty room means no one ever seeded this sheet — the boot-staged
// API snapshot is the freshest copy there is; leave it alone.
if (!ydocHasState(doc)) return;
const text = docToFile(yToDoc(doc));
restageFile(win, slug, sheetPath, new TextEncoder().encode(text), log);
} catch (err) {
log(`[sibling] restage failed for ${sheetPath}: ${String(err)}`);
}
};
const schedule = (sheetPath: string, doc: Y.Doc) => {
const prev = timers.get(sheetPath);
if (prev) clearTimeout(prev);
timers.set(
sheetPath,
setTimeout(() => {
timers.delete(sheetPath);
if (!destroyed) restageFromDoc(sheetPath, doc);
}, RESTAGE_DEBOUNCE_MS),
);
};
await Promise.all(
sheetPaths.map(async (sheetPath) => {
try {
const session = await connectKicadDoc({
provider: opts.provider,
room: collabRoomId(opts.scopeId, opts.projectId, sheetPath),
});
if (destroyed) {
session.provider.destroy();
session.doc.destroy();
return;
}
// Data-only observer: never appear in the sheet's presence roster
// (mirrors the sheet-manager's read-only invisible-observer handling).
session.provider.awareness?.setLocalState(null);
sessions.push(session);
session.doc.on("update", () => schedule(sheetPath, session.doc));
log(`[sibling] watching ${sheetPath}`);
restageFromDoc(sheetPath, session.doc);
} catch (err) {
log(`[sibling] room connect failed for ${sheetPath}: ${String(err)}`);
}
}),
);
return {
destroy() {
destroyed = true;
for (const t of timers.values()) clearTimeout(t);
timers.clear();
for (const s of sessions) {
s.provider.destroy();
s.doc.destroy();
}
sessions.length = 0;
},
};
}

View file

@ -1,5 +1,6 @@
import type { Tool } from "@pcbjam/shared"; import type { Tool } from "@pcbjam/shared";
import { FILELESS_TOOLS } from "@pcbjam/shared"; import { FILELESS_TOOLS } from "@pcbjam/shared";
import { defaultKicadPro } from "../lib/new-file";
import { memfsFilePath, memfsProjectDir } from "./constants"; import { memfsFilePath, memfsProjectDir } from "./constants";
import { prescanBoardModels } from "./libs/models-bridge"; import { prescanBoardModels } from "./libs/models-bridge";
import { openFileInTool } from "./open-flow"; import { openFileInTool } from "./open-flow";
@ -45,17 +46,32 @@ function getFS(win: ToolWindow): EmscriptenFS {
return fs as EmscriptenFS; return fs as EmscriptenFS;
} }
/**
* Write one project file into the tool's MEMFS. Shared by the boot-time
* whole-tree staging below and the live sibling re-stage (collab/sibling-restage):
* a peer's schematic edits must land in MEMFS so "Update PCB from Schematic"
* reads current data, not the page-load snapshot.
*/
export function restageFile(
win: ToolWindow,
slug: string,
relPath: string,
bytes: Uint8Array,
log: (msg: string) => void,
): void {
const fs = getFS(win);
const dest = memfsFilePath(slug, relPath);
fs.mkdirTree(dest.slice(0, dest.lastIndexOf("/")));
fs.writeFile(dest, bytes);
log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
}
/** Mirror the whole project tree into the tool's MEMFS (sync-whole-tree). */ /** Mirror the whole project tree into the tool's MEMFS (sync-whole-tree). */
async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<void> { async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<void> {
const fs = getFS(win); getFS(win).mkdirTree(memfsProjectDir(opts.slug));
fs.mkdirTree(memfsProjectDir(opts.slug));
for (const file of opts.files) { for (const file of opts.files) {
const dest = memfsFilePath(opts.slug, file.path);
const dir = dest.slice(0, dest.lastIndexOf("/"));
fs.mkdirTree(dir);
const bytes = await opts.fetchBytes(file.path); const bytes = await opts.fetchBytes(file.path);
fs.writeFile(dest, bytes); restageFile(win, opts.slug, file.path, bytes, opts.log);
opts.log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
// 3D models: prefetch every model this board references (R2 → IDB → MEMFS) // 3D models: prefetch every model this board references (R2 → IDB → MEMFS)
// so the 3D viewer's first open resolves locally. Fire-and-forget — project // so the 3D viewer's first open resolves locally. Fire-and-forget — project
// open never waits on it; a ref that misses falls back to the C++ per-model // open never waits on it; a ref that misses falls back to the C++ per-model
@ -69,6 +85,26 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
} }
} }
/**
* KiCad expects a `<stem>.kicad_pro` next to a board/schematic; without one it
* runs on an in-memory defaults project, so nothing project-scoped (netclasses,
* ERC/DRC exclusions, text variables) persists and the settings save paths
* are gated on the file existing at all. pcbjam projects historically carry no
* project file (docs/features/project-sync/0001), so seed a minimal one into
* MEMFS for the target document when the project doesn't provide its own.
* MEMFS-only: it is not added to the project file list or uploaded.
*/
function synthesizeProjectFile(win: ToolWindow, opts: DriveOptions): void {
const match = (opts.targetPath ?? "").match(/^(.*)\.kicad_(pcb|sch)$/);
if (!match) return;
const proPath = `${match[1]}.kicad_pro`;
if (opts.files.some((f) => f.path === proPath)) return;
const fileName = proPath.slice(proPath.lastIndexOf("/") + 1);
const bytes = new TextEncoder().encode(defaultKicadPro(fileName));
getFS(win).writeFile(memfsFilePath(opts.slug, proPath), bytes);
opts.log(`[memfs] synthesized ${proPath} (project has no project file)`);
}
/** /**
* Drive a project into an already-booting tool runtime (booted into `win` by * Drive a project into an already-booting tool runtime (booted into `win` by
* bootKicadTool the top-level window). Waits for the Emscripten FS, syncs the * bootKicadTool the top-level window). Waits for the Emscripten FS, syncs the
@ -89,6 +125,7 @@ export async function driveProjectIntoTool(
onStatus("Loading project files…"); onStatus("Loading project files…");
await syncProjectToMemfs(win, opts); await syncProjectToMemfs(win, opts);
synthesizeProjectFile(win, opts);
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) { if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
onStatus("Opening file…"); onStatus("Opening file…");