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:
parent
f4d3c3f146
commit
2d89f41060
8 changed files with 757 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
130
web/standalone/src/wasm/collab/sibling-restage.test.ts
Normal file
130
web/standalone/src/wasm/collab/sibling-restage.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
111
web/standalone/src/wasm/collab/sibling-restage.ts
Normal file
111
web/standalone/src/wasm/collab/sibling-restage.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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…");
|
||||
|
|
|
|||
Loading…
Reference in a new issue