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

View file

@ -12,6 +12,7 @@ import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api";
import {
defaultFileName,
defaultKicadPro,
newFileTemplate,
withExtension,
} from "@/lib/new-file";
@ -86,16 +87,31 @@ export function NewFileDialog({
const bytes = new TextEncoder().encode(
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;
if (homeMode && target === NEW) {
if (!store) throw new Error("local project store unavailable");
const created = await store.createProject(projectName.trim() || "Untitled", [
{ path: finalName, bytes },
]);
const created = await store.createProject(
projectName.trim() || "Untitled",
files,
);
slug = created.slug;
} else {
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.
// A home-created project is browser-local (@local scope); an in-project new

View file

@ -71,6 +71,10 @@ import {
type FollowTarget,
} from "@/wasm/collab/follow-user";
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 {
createComments,
@ -834,6 +838,7 @@ export function WasmTool({
// Project-wide presence room (0006): joined once per session, survives
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
const siblingRestageRef = React.useRef<SiblingRestageHandle | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
@ -1426,6 +1431,21 @@ export function WasmTool({
collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider, undefined, 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) {
driftRef.current = startDriftDetection({
doc: collabHandle.doc,
@ -1462,6 +1482,8 @@ export function WasmTool({
presenceRef.current = null;
crossAppRef.current?.destroy();
crossAppRef.current = null;
siblingRestageRef.current?.destroy();
siblingRestageRef.current = null;
driftRef.current?.stop();
driftRef.current = null;
// 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;
}
/**
* 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 {
switch (tool) {
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 { FILELESS_TOOLS } from "@pcbjam/shared";
import { defaultKicadPro } from "../lib/new-file";
import { memfsFilePath, memfsProjectDir } from "./constants";
import { prescanBoardModels } from "./libs/models-bridge";
import { openFileInTool } from "./open-flow";
@ -45,17 +46,32 @@ function getFS(win: ToolWindow): 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). */
async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<void> {
const fs = getFS(win);
fs.mkdirTree(memfsProjectDir(opts.slug));
getFS(win).mkdirTree(memfsProjectDir(opts.slug));
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);
fs.writeFile(dest, bytes);
opts.log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
restageFile(win, opts.slug, file.path, bytes, opts.log);
// 3D models: prefetch every model this board references (R2 → IDB → MEMFS)
// 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
@ -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
* bootKicadTool — the top-level window). Waits for the Emscripten FS, syncs the
@ -89,6 +125,7 @@ export async function driveProjectIntoTool(
onStatus("Loading project files…");
await syncProjectToMemfs(win, opts);
synthesizeProjectFile(win, opts);
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
onStatus("Opening file…");