feat(eeschema): hierarchical subschema collaboration — per-sheet rooms, lazy seed, save flow, embind + e2e
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
695a46015c
commit
b1320afab9
9 changed files with 1052 additions and 81 deletions
|
|
@ -34,7 +34,16 @@ import type {
|
|||
KicadDocSession,
|
||||
KicadItemsWindow,
|
||||
} from "@/wasm/collab";
|
||||
import {
|
||||
createSheetCollabManager,
|
||||
registerSheetChangedHook,
|
||||
registerSheetCreatedHook,
|
||||
type SheetChangedWindow,
|
||||
type SheetCollabManager,
|
||||
type SheetCreatedWindow,
|
||||
} from "@/wasm/collab/sheet-manager";
|
||||
import { clog, cwarn } from "@/wasm/collab/debug";
|
||||
import type * as Y from "yjs";
|
||||
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
||||
|
||||
|
|
@ -347,6 +356,125 @@ async function maybeStartCollab(
|
|||
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.
|
||||
*/
|
||||
async function startSheetCollab(
|
||||
win: ToolWindow,
|
||||
opts: {
|
||||
slug: 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: { sheetPath: string; doc: Y.Doc } | null) => void;
|
||||
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
|
||||
saveBytes?: SaveBytes;
|
||||
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,
|
||||
projectId: opts.projectId,
|
||||
provider: yjsProviderConfig(),
|
||||
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
|
||||
onActiveChange: opts.onActiveChange,
|
||||
log: opts.log,
|
||||
initial:
|
||||
opts.session && opts.targetPath
|
||||
? {
|
||||
sheetPath: opts.targetPath,
|
||||
session: opts.session,
|
||||
editorMatchesDoc: !!opts.editorMatchesDoc,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const sheetPaths = opts.files
|
||||
.filter((f) => f.path.endsWith(".kicad_sch"))
|
||||
.map((f) => f.path);
|
||||
|
||||
// C++ navigation → rebind the active room to the now-shown sheet.
|
||||
registerSheetChangedHook(win as unknown as SheetChangedWindow, (abs) => {
|
||||
const rel = relativeProjectPath(opts.slug, abs);
|
||||
if (rel) void manager.switchTo(rel);
|
||||
});
|
||||
|
||||
// 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) await manager.switchTo(opts.targetPath);
|
||||
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(() => log(`[sheet] registered created subsheet ${relPath} (${bytes.length} bytes)`))
|
||||
.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
|
||||
|
|
@ -415,6 +543,7 @@ export function WasmTool({
|
|||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const startedRef = React.useRef(false);
|
||||
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
||||
const [status, setStatus] = React.useState("Loading tool…");
|
||||
const [logs, setLogs] = React.useState<string[]>([]);
|
||||
const [showLog, setShowLog] = React.useState(false);
|
||||
|
|
@ -527,7 +656,17 @@ export function WasmTool({
|
|||
});
|
||||
// Register the save sink before the file opens: from here on, every
|
||||
// editor File→Save (MEMFS write) is routed onward through saveBytes.
|
||||
registerSaveHook(win, { slug, saveBytes, log: append, onStatus: setStatus });
|
||||
registerSaveHook(win, {
|
||||
slug,
|
||||
saveBytes,
|
||||
log: append,
|
||||
onStatus: setStatus,
|
||||
// A sheet created mid-session ("Add Sheet") saves to a new .kicad_sch path the
|
||||
// page-load file list can't contain — warm its collab room so it stays in sync.
|
||||
onSaved: (relPath) => {
|
||||
if (relPath.endsWith(".kicad_sch")) void sheetManagerRef.current?.onboard(relPath);
|
||||
},
|
||||
});
|
||||
const { session, targetBytes } = await maybeConnectDocSession(win, {
|
||||
docSource,
|
||||
tool,
|
||||
|
|
@ -550,30 +689,64 @@ export function WasmTool({
|
|||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
const collabHandle = await maybeStartCollab(win, {
|
||||
tool,
|
||||
slug,
|
||||
projectId,
|
||||
targetPath,
|
||||
collabSession: session,
|
||||
editorMatchesDoc: !!targetBytes,
|
||||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
// Drift detection: while this doc is collaboratively edited, periodically
|
||||
// (every N edits + at session end) compare the WASM serialization to the
|
||||
// Y.Doc and report any divergence. Gated on a real collab session.
|
||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
||||
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
|
||||
driftRef.current = startDriftDetection({
|
||||
doc: collabHandle.doc,
|
||||
mod: win.Module,
|
||||
win,
|
||||
// Drift detection: while a sheet is collaboratively edited, periodically (every N
|
||||
// edits + at session end) compare the WASM serialization to the Y.Doc and report
|
||||
// divergence. Gated on a real collab session; re-targeted per active sheet below.
|
||||
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
|
||||
|
||||
if (tool === "eeschema") {
|
||||
// Multi-room (subschema) collab: every .kicad_sch is its own warm room; the
|
||||
// active sheet is bound, navigation re-routes it (C++ onSheetChanged hook).
|
||||
sheetManagerRef.current =
|
||||
(await startSheetCollab(win, {
|
||||
slug,
|
||||
projectId,
|
||||
targetPath,
|
||||
files,
|
||||
session,
|
||||
saveBytes,
|
||||
editorMatchesDoc: !!targetBytes,
|
||||
// Re-point drift detection at whichever sheet is currently bound.
|
||||
onActiveChange: (activeRoom) => {
|
||||
driftRef.current?.stop();
|
||||
driftRef.current = null;
|
||||
if (activeRoom) {
|
||||
driftRef.current = startDriftDetection({
|
||||
doc: activeRoom.doc,
|
||||
mod: win.Module,
|
||||
win,
|
||||
tool,
|
||||
slug,
|
||||
targetPath: activeRoom.sheetPath,
|
||||
log: append,
|
||||
});
|
||||
}
|
||||
},
|
||||
log: append,
|
||||
onStatus: setStatus,
|
||||
})) ?? null;
|
||||
} else {
|
||||
const collabHandle = await maybeStartCollab(win, {
|
||||
tool,
|
||||
slug,
|
||||
projectId,
|
||||
targetPath,
|
||||
collabSession: session,
|
||||
editorMatchesDoc: !!targetBytes,
|
||||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
||||
driftRef.current = startDriftDetection({
|
||||
doc: collabHandle.doc,
|
||||
mod: win.Module,
|
||||
win,
|
||||
tool,
|
||||
slug,
|
||||
targetPath,
|
||||
log: append,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Tool booted + project opened. Wait for the wx UI to actually build
|
||||
// before dropping the overlay, so we don't reveal a still-blank editor.
|
||||
|
|
@ -590,6 +763,11 @@ export function WasmTool({
|
|||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||
driftRef.current?.stop();
|
||||
driftRef.current = null;
|
||||
// Tears down every warm room's provider/doc (the only place providers are
|
||||
// destroyed — switching sheets keeps them connected) and clears drift via
|
||||
// onActiveChange(null).
|
||||
sheetManagerRef.current?.destroy();
|
||||
sheetManagerRef.current = null;
|
||||
oom.stop();
|
||||
};
|
||||
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
||||
|
|
|
|||
|
|
@ -32,9 +32,13 @@ export function ToolPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Env-selected document source (same /p/ URLs either way): with "ydoc" the
|
||||
// collab room is the source of truth, so saves are NOT uploaded back — the
|
||||
// provider persists the doc. With "api" a user save uploads to the backend.
|
||||
// Env-selected document source (same /p/ URLs either way): with "ydoc" the collab
|
||||
// room is the live source of truth (materialized client-side on load), with "api" the
|
||||
// REST file is. EITHER WAY a save is uploaded to the backend — the backend owns the
|
||||
// project FILE LIST, so an editor-created file (e.g. a hierarchical SUBSHEET added via
|
||||
// "Add Sheet") must be registered there or it's missing on reload and the parent's
|
||||
// (sheet … child.kicad_sch) reference fails to load. In ydoc mode the room still wins
|
||||
// on reload when it holds newer state; the upload is the registration + fallback copy.
|
||||
const docSource = docSourceConfig();
|
||||
|
||||
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks
|
||||
|
|
@ -48,11 +52,7 @@ export function ToolPage() {
|
|||
files={data.files}
|
||||
targetPath={targetPath}
|
||||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||
saveBytes={
|
||||
docSource === "api"
|
||||
? (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
|
||||
: undefined
|
||||
}
|
||||
saveBytes={(relPath, bytes) => uploadFileBytes(slug, relPath, bytes)}
|
||||
docSource={docSource}
|
||||
/>
|
||||
</PreflightGate>
|
||||
|
|
|
|||
192
web/standalone/src/wasm/collab/sheet-manager.test.ts
Normal file
192
web/standalone/src/wasm/collab/sheet-manager.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The manager orchestrates connect/bind lifecycle; mock its collaborators so the test
|
||||
// exercises ONLY the warm-pool + active-binding-swap logic (no yjs, no wasm bridge).
|
||||
const { connectKicadDoc, bindKicadCollab, moduleItemsBridge } = vi.hoisted(() => ({
|
||||
connectKicadDoc: vi.fn(),
|
||||
bindKicadCollab: vi.fn(),
|
||||
moduleItemsBridge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./index", () => ({ connectKicadDoc }));
|
||||
vi.mock("./kicad-binding", () => ({ bindKicadCollab, moduleItemsBridge }));
|
||||
vi.mock("@pcbjam/shared", () => ({ collabRoomId: (p: string, d: string) => `${p}:${d}` }));
|
||||
|
||||
import { createSheetCollabManager } from "./sheet-manager";
|
||||
|
||||
interface FakeDoc {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
/** Simulate a remote update arriving over the (warm) provider while parked. */
|
||||
emitRemote: () => void;
|
||||
}
|
||||
interface FakeSession {
|
||||
room: string;
|
||||
doc: FakeDoc;
|
||||
provider: { destroy: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
interface FakeBinding {
|
||||
seed: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
lastSeedOpts?: unknown;
|
||||
}
|
||||
|
||||
let sessions: FakeSession[];
|
||||
let bindings: FakeBinding[];
|
||||
|
||||
function makeDoc(): FakeDoc {
|
||||
const handlers = new Set<() => void>();
|
||||
return {
|
||||
on: vi.fn((ev: string, cb: () => void) => {
|
||||
if (ev === "update") handlers.add(cb);
|
||||
}),
|
||||
off: vi.fn((_ev: string, cb: () => void) => {
|
||||
handlers.delete(cb);
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
emitRemote: () => handlers.forEach((h) => h()),
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager() {
|
||||
return createSheetCollabManager({
|
||||
mod: {} as never,
|
||||
win: {} as never,
|
||||
projectId: "P",
|
||||
provider: { kind: "none" } as never,
|
||||
seedDocForPath: () => undefined,
|
||||
log: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessions = [];
|
||||
bindings = [];
|
||||
connectKicadDoc.mockReset();
|
||||
bindKicadCollab.mockReset();
|
||||
moduleItemsBridge.mockReset();
|
||||
|
||||
connectKicadDoc.mockImplementation(async ({ room }: { room: string }) => {
|
||||
const session: FakeSession = { room, doc: makeDoc(), provider: { destroy: vi.fn() } };
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
bindKicadCollab.mockImplementation(() => {
|
||||
const b: FakeBinding = {
|
||||
seed: vi.fn((_seedDoc: unknown, opts?: unknown) => {
|
||||
b.lastSeedOpts = opts;
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
bindings.push(b);
|
||||
return b;
|
||||
});
|
||||
moduleItemsBridge.mockImplementation(() => ({
|
||||
snapshotItems: vi.fn(),
|
||||
applyItems: vi.fn(),
|
||||
onItems: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("sheet-manager warm pool", () => {
|
||||
it("warms each sheet once and dedups re-warming", async () => {
|
||||
const m = makeManager();
|
||||
await m.connectAll(["a.kicad_sch", "b.kicad_sch"]);
|
||||
expect(connectKicadDoc).toHaveBeenCalledTimes(2);
|
||||
await m.connectAll(["a.kicad_sch"]); // already warm — no reconnect
|
||||
expect(connectKicadDoc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("first switch binds + seeds the active sheet", async () => {
|
||||
const m = makeManager();
|
||||
await m.switchTo("a.kicad_sch");
|
||||
expect(bindKicadCollab).toHaveBeenCalledTimes(1);
|
||||
expect(bindings[0]!.seed).toHaveBeenCalledTimes(1);
|
||||
expect(m.active()?.sheetPath).toBe("a.kicad_sch");
|
||||
});
|
||||
|
||||
it("switching detaches the old binding but keeps every provider warm", async () => {
|
||||
const m = makeManager();
|
||||
await m.switchTo("a.kicad_sch");
|
||||
await m.switchTo("b.kicad_sch");
|
||||
|
||||
expect(bindings[0]!.destroy).toHaveBeenCalledTimes(1); // old binding detached
|
||||
expect(bindKicadCollab).toHaveBeenCalledTimes(2); // new binding for b
|
||||
// No provider is torn down on a switch — that's the whole point of the warm pool.
|
||||
expect(sessions.every((s) => s.provider.destroy.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-warms each sheet exactly once across repeated switches", async () => {
|
||||
const m = makeManager();
|
||||
await m.switchTo("a.kicad_sch");
|
||||
await m.switchTo("b.kicad_sch");
|
||||
await m.switchTo("a.kicad_sch");
|
||||
// a + b connected once each; the revisit reuses the warm room.
|
||||
expect(connectKicadDoc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("a clean revisit rebinds WITHOUT re-applying (baseline-only)", async () => {
|
||||
const m = makeManager();
|
||||
await m.switchTo("a.kicad_sch");
|
||||
await m.switchTo("b.kicad_sch");
|
||||
await m.switchTo("a.kicad_sch"); // no remote change arrived while parked
|
||||
expect(bindings.at(-1)!.lastSeedOpts).toEqual({ editorMatchesDoc: true });
|
||||
});
|
||||
|
||||
it("a remote edit while parked forces a catch-up adopt on revisit", async () => {
|
||||
const m = makeManager();
|
||||
await m.switchTo("a.kicad_sch");
|
||||
const aDoc = sessions[0]!.doc;
|
||||
await m.switchTo("b.kicad_sch"); // parks a, starts its update watch
|
||||
aDoc.emitRemote(); // remote edit lands on the parked doc
|
||||
await m.switchTo("a.kicad_sch");
|
||||
expect(bindings.at(-1)!.lastSeedOpts).toEqual({ editorMatchesDoc: false });
|
||||
});
|
||||
|
||||
it("onboard connects a mid-session sheet exactly once", async () => {
|
||||
const m = makeManager();
|
||||
await m.onboard("new.kicad_sch");
|
||||
await m.onboard("new.kicad_sch");
|
||||
expect(connectKicadDoc).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("destroy tears down every provider and doc", async () => {
|
||||
const m = makeManager();
|
||||
await m.connectAll(["a.kicad_sch", "b.kicad_sch"]);
|
||||
await m.switchTo("a.kicad_sch");
|
||||
m.destroy();
|
||||
expect(sessions).toHaveLength(2);
|
||||
for (const s of sessions) {
|
||||
expect(s.provider.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(s.doc.destroy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(m.active()).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the pre-connected entry session (ydoc mode) instead of reconnecting", async () => {
|
||||
const entryDoc = makeDoc();
|
||||
const entrySession: FakeSession = {
|
||||
room: "P:root.kicad_sch",
|
||||
doc: entryDoc,
|
||||
provider: { destroy: vi.fn() },
|
||||
};
|
||||
const m = createSheetCollabManager({
|
||||
mod: {} as never,
|
||||
win: {} as never,
|
||||
projectId: "P",
|
||||
provider: { kind: "none" } as never,
|
||||
seedDocForPath: () => undefined,
|
||||
log: () => {},
|
||||
initial: {
|
||||
sheetPath: "root.kicad_sch",
|
||||
session: entrySession as never,
|
||||
editorMatchesDoc: true,
|
||||
},
|
||||
});
|
||||
await m.switchTo("root.kicad_sch");
|
||||
expect(connectKicadDoc).not.toHaveBeenCalled(); // entry room already connected
|
||||
// The ydoc-entry seed is baseline-only (its file was materialized from the doc).
|
||||
expect(bindings[0]!.lastSeedOpts).toEqual({ editorMatchesDoc: true });
|
||||
});
|
||||
});
|
||||
300
web/standalone/src/wasm/collab/sheet-manager.ts
Normal file
300
web/standalone/src/wasm/collab/sheet-manager.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import type * as Y from "yjs";
|
||||
import { collabRoomId, type KicadDoc } from "@pcbjam/shared";
|
||||
import { connectKicadDoc, type KicadDocSession } from "./index";
|
||||
import {
|
||||
bindKicadCollab,
|
||||
moduleItemsBridge,
|
||||
type KicadBinding,
|
||||
type KicadItemsModule,
|
||||
type KicadItemsWindow,
|
||||
} from "./kicad-binding";
|
||||
import type { ProviderConfig } from "./provider";
|
||||
import { clog, cwarn } from "./debug";
|
||||
|
||||
/**
|
||||
* Warm-pool multi-room collab manager for hierarchical schematics (subschemas).
|
||||
*
|
||||
* A hierarchical design references several `.kicad_sch` files; each is its own collab
|
||||
* room. This manager keeps EVERY discovered sheet's Y.Doc + provider connected for the
|
||||
* whole session (the "warm pool"), so the doc stays current over its open WebSocket
|
||||
* even when that sheet isn't on screen and switching sheets needs no reconnect.
|
||||
*
|
||||
* The C++ items bridge (`window.kicadCollab.onItems` / `kicadCollabApplyItems` /
|
||||
* `kicadCollabSnapshotItems`) is a SINGLETON tied to the editor's active screen, so at
|
||||
* most ONE room may be bound to the editor at a time. Navigation (the C++
|
||||
* `onSheetChanged` hook → {@link SheetCollabManager.switchTo}) re-routes that single
|
||||
* binding between already-warm docs; it does not tear down providers. The Phase-0 C++
|
||||
* change scopes the snapshot/diff to the active screen, so each room carries exactly its
|
||||
* own sheet's items and per-sheet seed/adopt is correct.
|
||||
*
|
||||
* Background sheets stay synced at the DATA layer (their doc accumulates remote edits)
|
||||
* but are not reflected in the editor's other-sheet view until you navigate in — at
|
||||
* which point the doc is already warm, so the merge into view is instant. Fully live
|
||||
* non-active-sheet VIEW updates would need a sheet-targeted C++ apply (a future upgrade
|
||||
* this design leaves open). Presence (per-room awareness) is likewise additive.
|
||||
*/
|
||||
export interface SheetCollabManager {
|
||||
/** Pre-connect (warm) a set of sheet files so later switches are instant. */
|
||||
connectAll(sheetPaths: string[]): Promise<void>;
|
||||
/** Bind the editor to `sheetPath` (driven by the C++ `onSheetChanged` hook). */
|
||||
switchTo(sheetPath: string): Promise<void>;
|
||||
/** Warm a sheet created mid-session (driven by the save hook on an unknown path). */
|
||||
onboard(sheetPath: string): Promise<void>;
|
||||
/** The currently-bound sheet, for drift-detection wiring (null before first switch). */
|
||||
active(): { sheetPath: string; doc: Y.Doc } | null;
|
||||
/** Tear down ALL bindings + providers + docs (session end / unmount). */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface SheetManagerOptions {
|
||||
/** The Emscripten Module exposing the v2 items bridge exports. */
|
||||
mod: KicadItemsModule;
|
||||
/** The global the C++ emit side calls into (`window.kicadCollab.onItems`). */
|
||||
win: KicadItemsWindow;
|
||||
/** Project uuid — keys each room as `collabRoomId(projectId, sheetPath)`. */
|
||||
projectId: string;
|
||||
/** The env-selected Yjs provider config (same one the single-room path uses). */
|
||||
provider: ProviderConfig;
|
||||
/**
|
||||
* Lossless seed for an EMPTY room: the child `.kicad_sch` parsed from MEMFS
|
||||
* (`fileToDoc`), so a first-ever-opened sheet seeds its room from the file.
|
||||
*/
|
||||
seedDocForPath: (sheetPath: string) => KicadDoc | undefined;
|
||||
/**
|
||||
* Called whenever the active sheet changes (or clears on destroy) so the host can
|
||||
* (re)start drift detection on the now-active doc.
|
||||
*/
|
||||
onActiveChange?: (active: { sheetPath: string; doc: Y.Doc } | null) => void;
|
||||
log: (m: string) => void;
|
||||
/**
|
||||
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
|
||||
* materialized from the doc). Adopted into the pool so its first bind baselines only.
|
||||
*/
|
||||
initial?: { sheetPath: string; session: KicadDocSession; editorMatchesDoc: boolean };
|
||||
}
|
||||
|
||||
/** One warm room. `binding` is non-null ONLY while this is the active sheet. */
|
||||
interface Room {
|
||||
session: KicadDocSession;
|
||||
doc: Y.Doc;
|
||||
binding?: KicadBinding;
|
||||
/** Flipped on first activation (seeded/adopted into the editor at least once). */
|
||||
seeded: boolean;
|
||||
/** ydoc-entry sheet: its open file was materialized from this doc (baseline-only). */
|
||||
editorMatchesDoc: boolean;
|
||||
/** A remote update arrived while this sheet was parked → catch-up adopt on next bind. */
|
||||
dirty: boolean;
|
||||
/** Active only while parked: marks `dirty` on remote doc updates. */
|
||||
detachWatch?: () => void;
|
||||
}
|
||||
|
||||
export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollabManager {
|
||||
const { mod, win, projectId, provider, seedDocForPath, log } = opts;
|
||||
const bridge = moduleItemsBridge(mod, win);
|
||||
const rooms = new Map<string, Room>();
|
||||
// In-flight connects, so connectAll() and switchTo() racing on the same sheet (api
|
||||
// mode, entry sheet) share ONE connection instead of opening the room twice.
|
||||
const connecting = new Map<string, Promise<Room>>();
|
||||
let activePath: string | null = null;
|
||||
|
||||
// Coalesce rapid navigations: only the LATEST requested sheet is actually bound, and
|
||||
// switches run one-at-a-time so concurrent `onSheetChanged` events can't interleave.
|
||||
let requestedPath: string | null = null;
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
|
||||
if (opts.initial) {
|
||||
const { sheetPath, session, editorMatchesDoc } = opts.initial;
|
||||
rooms.set(sheetPath, {
|
||||
session,
|
||||
doc: session.doc,
|
||||
seeded: false,
|
||||
editorMatchesDoc,
|
||||
dirty: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRoom(sheetPath: string): Promise<Room> {
|
||||
const existing = rooms.get(sheetPath);
|
||||
if (existing) return existing;
|
||||
const inflight = connecting.get(sheetPath);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const pending = (async () => {
|
||||
const session = await connectKicadDoc({
|
||||
provider,
|
||||
room: collabRoomId(projectId, sheetPath),
|
||||
});
|
||||
const room: Room = {
|
||||
session,
|
||||
doc: session.doc,
|
||||
seeded: false,
|
||||
editorMatchesDoc: false,
|
||||
dirty: false,
|
||||
};
|
||||
rooms.set(sheetPath, room);
|
||||
log(`[sheet] warm room connected: ${sheetPath}`);
|
||||
return room;
|
||||
})();
|
||||
|
||||
connecting.set(sheetPath, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} finally {
|
||||
connecting.delete(sheetPath);
|
||||
}
|
||||
}
|
||||
|
||||
// While a sheet is parked (no binding), any update to its doc is a remote edit (we
|
||||
// can't make local edits to a non-active screen). Flag it so the next bind catches up.
|
||||
function startWatch(room: Room): void {
|
||||
if (room.detachWatch) return;
|
||||
const onUpdate = () => {
|
||||
room.dirty = true;
|
||||
};
|
||||
room.doc.on("update", onUpdate);
|
||||
room.detachWatch = () => room.doc.off("update", onUpdate);
|
||||
}
|
||||
|
||||
async function doSwitch(sheetPath: string): Promise<void> {
|
||||
if (activePath === sheetPath) return;
|
||||
|
||||
// Detach the OLD binding FIRST (before any await): the editor already navigated to
|
||||
// the new sheet, so the old binding's observer must stop applying remote edits onto
|
||||
// what is now the wrong (new) active screen. Its provider/doc stay warm.
|
||||
if (activePath) {
|
||||
const old = rooms.get(activePath);
|
||||
if (old?.binding) {
|
||||
old.binding.destroy();
|
||||
old.binding = undefined;
|
||||
startWatch(old);
|
||||
}
|
||||
}
|
||||
activePath = null;
|
||||
|
||||
const room = await ensureRoom(sheetPath);
|
||||
|
||||
// Activating: stop tracking parked updates and bind the (warm) doc to the editor.
|
||||
room.detachWatch?.();
|
||||
room.detachWatch = undefined;
|
||||
|
||||
const binding = bindKicadCollab(room.doc, bridge);
|
||||
room.binding = binding;
|
||||
|
||||
if (!room.seeded) {
|
||||
// First activation: file-seed an empty room, else adopt peer/server state.
|
||||
binding.seed(seedDocForPath(sheetPath), { editorMatchesDoc: room.editorMatchesDoc });
|
||||
room.seeded = true;
|
||||
clog(`[sheet] seeded ${sheetPath} (editorMatchesDoc=${room.editorMatchesDoc})`);
|
||||
} else if (room.dirty) {
|
||||
// Remote edits landed while parked: adopt to catch the editor's screen up.
|
||||
binding.seed(undefined, { editorMatchesDoc: false });
|
||||
clog(`[sheet] re-adopted ${sheetPath} (caught up parked remote edits)`);
|
||||
} else {
|
||||
// Clean revisit: the editor screen already matches the doc — baseline the differ
|
||||
// (rebound after the C++ rebaseline on navigation), no full re-apply.
|
||||
binding.seed(undefined, { editorMatchesDoc: true });
|
||||
clog(`[sheet] rebound ${sheetPath} (no apply)`);
|
||||
}
|
||||
|
||||
room.dirty = false;
|
||||
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
|
||||
activePath = sheetPath;
|
||||
opts.onActiveChange?.({ sheetPath, doc: room.doc });
|
||||
}
|
||||
|
||||
function switchTo(sheetPath: string): Promise<void> {
|
||||
requestedPath = sheetPath;
|
||||
queue = queue
|
||||
.then(() => {
|
||||
// Superseded by a newer navigation — skip this stale switch. The editor's active
|
||||
// screen always reflects `requestedPath`, so we only bind when they agree (the
|
||||
// seed/snapshot then reads the right screen).
|
||||
if (requestedPath !== sheetPath) return;
|
||||
return doSwitch(sheetPath);
|
||||
})
|
||||
.catch((err) => {
|
||||
cwarn(`[sheet] switchTo(${sheetPath}) failed`, err);
|
||||
});
|
||||
return queue;
|
||||
}
|
||||
|
||||
async function onboard(sheetPath: string): Promise<void> {
|
||||
if (rooms.has(sheetPath)) return;
|
||||
log(`[sheet] onboarding new sheet ${sheetPath}`);
|
||||
try {
|
||||
await ensureRoom(sheetPath);
|
||||
} catch (err) {
|
||||
cwarn(`[sheet] onboard(${sheetPath}) failed`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectAll(sheetPaths: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
sheetPaths.map((p) =>
|
||||
ensureRoom(p).catch((err) => {
|
||||
cwarn(`[sheet] failed to warm ${p}`, err);
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function active(): { sheetPath: string; doc: Y.Doc } | null {
|
||||
if (!activePath) return null;
|
||||
const room = rooms.get(activePath);
|
||||
return room ? { sheetPath: activePath, doc: room.doc } : null;
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
for (const [path, room] of rooms) {
|
||||
try {
|
||||
room.detachWatch?.();
|
||||
room.binding?.destroy();
|
||||
room.session.provider.destroy();
|
||||
room.doc.destroy();
|
||||
} catch (err) {
|
||||
cwarn(`[sheet] destroy ${path} failed`, err);
|
||||
}
|
||||
}
|
||||
rooms.clear();
|
||||
activePath = null;
|
||||
requestedPath = null;
|
||||
opts.onActiveChange?.(null);
|
||||
}
|
||||
|
||||
return { connectAll, switchTo, onboard, active, destroy };
|
||||
}
|
||||
|
||||
export interface SheetChangedWindow {
|
||||
kicadCollab?: { onSheetChanged?: (absPath: string) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the C++ → JS sheet-navigation sink (`window.kicadCollab.onSheetChanged`),
|
||||
* fired from eeschema's `DisplayCurrentSheet` with the now-active screen's file path.
|
||||
* Spread-merges so sibling hooks (onSave / onItems) registered before or after survive.
|
||||
*/
|
||||
export function registerSheetChangedHook(
|
||||
win: SheetChangedWindow,
|
||||
onSheetChanged: (absPath: string) => void,
|
||||
): void {
|
||||
win.kicadCollab = { ...win.kicadCollab, onSheetChanged };
|
||||
}
|
||||
|
||||
export interface SheetCreatedWindow {
|
||||
kicadCollab?: { onSheetCreated?: (absPath: string) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the C++ → JS sheet-CREATION sink (`window.kicadCollab.onSheetCreated`), fired
|
||||
* when eeschema adds a hierarchical sheet — the child .kicad_sch has just been written to
|
||||
* MEMFS by the hook. The handler registers that child with the backend + warms its room,
|
||||
* so a subsheet that's placed but never entered or saved still persists. Spread-merges so
|
||||
* sibling hooks survive.
|
||||
*/
|
||||
export function registerSheetCreatedHook(
|
||||
win: SheetCreatedWindow,
|
||||
onSheetCreated: (absPath: string) => void,
|
||||
): void {
|
||||
win.kicadCollab = { ...win.kicadCollab, onSheetCreated };
|
||||
}
|
||||
48
web/standalone/src/wasm/save-flow.test.ts
Normal file
48
web/standalone/src/wasm/save-flow.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MEMFS_PROJECTS_DIR } from "./constants";
|
||||
import { registerSaveHook, type SaveHookWindow } from "./save-flow";
|
||||
|
||||
const SLUG = "myproj";
|
||||
const HOME = MEMFS_PROJECTS_DIR; // …/projects (editor's default "projects home")
|
||||
const PROJ = `${HOME}/${SLUG}`; // …/projects/myproj (this project's own folder)
|
||||
|
||||
function setup() {
|
||||
const saveBytes = vi.fn(async () => {});
|
||||
const onSaved = vi.fn();
|
||||
const win: SaveHookWindow = {
|
||||
FS: { readFile: () => new Uint8Array([1, 2, 3]) } as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, { slug: SLUG, saveBytes, onSaved, log: () => {}, onStatus: () => {} });
|
||||
return { fire: (p: string) => win.kicadCollab!.onSave!(p), saveBytes, onSaved };
|
||||
}
|
||||
|
||||
describe("registerSaveHook path routing", () => {
|
||||
it("routes a file in the project's own folder with its full relative path", () => {
|
||||
const { fire, saveBytes, onSaved } = setup();
|
||||
fire(`${PROJ}/sub/sheet.kicad_sch`);
|
||||
expect(onSaved).toHaveBeenCalledWith("sub/sheet.kicad_sch");
|
||||
expect(saveBytes).toHaveBeenCalledWith("sub/sheet.kicad_sch", expect.any(Uint8Array));
|
||||
});
|
||||
|
||||
it("routes a bare file saved in the editor's default projects home to the project root", () => {
|
||||
const { fire, saveBytes, onSaved } = setup();
|
||||
fire(`${HOME}/main.kicad_sch`);
|
||||
expect(onSaved).toHaveBeenCalledWith("main.kicad_sch");
|
||||
expect(saveBytes).toHaveBeenCalledWith("main.kicad_sch", expect.any(Uint8Array));
|
||||
});
|
||||
|
||||
it("ignores a save outside the projects tree", () => {
|
||||
const { fire, saveBytes, onSaved } = setup();
|
||||
fire(`/home/kicad/stray.kicad_sch`);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(saveBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a file under a DIFFERENT project's folder in the home dir", () => {
|
||||
const { fire, saveBytes, onSaved } = setup();
|
||||
fire(`${HOME}/other/board.kicad_pcb`);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(saveBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { memfsProjectDir } from "./constants";
|
||||
import { MEMFS_PROJECTS_DIR, memfsProjectDir } from "./constants";
|
||||
|
||||
/**
|
||||
* Persist one saved file's bytes outside MEMFS. The counterpart of
|
||||
|
|
@ -28,16 +28,41 @@ export function registerSaveHook(
|
|||
saveBytes?: SaveBytes;
|
||||
log: (msg: string) => void;
|
||||
onStatus: (text: string) => void;
|
||||
/**
|
||||
* Fired with every saved project-relative path BEFORE persistence (so it runs even
|
||||
* when `saveBytes` is absent, e.g. Y.Doc-backed sessions). The hierarchical-sheet
|
||||
* collab manager uses it to discover + warm a sheet file created mid-session
|
||||
* ("Add Sheet"), which the page-load file list can't contain.
|
||||
*/
|
||||
onSaved?: (relPath: string) => void;
|
||||
},
|
||||
): void {
|
||||
const projectPrefix = `${memfsProjectDir(opts.slug)}/`;
|
||||
// The editor's default "projects" home (KiCad's GetDefaultUserProjectsPath) — one
|
||||
// level above this project's own folder. A blank editor's Save-As lands HERE, not in
|
||||
// the project subfolder, so we also accept a bare file saved directly in it: the page
|
||||
// holds exactly one project in MEMFS, so such a file belongs to it. (Files under the
|
||||
// project's own folder still take the first branch, with their full relative path.)
|
||||
const projectsHome = `${MEMFS_PROJECTS_DIR}/`;
|
||||
|
||||
/** Saved MEMFS path → project-relative path, or null if it's outside the project. */
|
||||
const toRelPath = (absPath: string): string | null => {
|
||||
if (absPath.startsWith(projectPrefix)) return absPath.slice(projectPrefix.length);
|
||||
if (absPath.startsWith(projectsHome)) {
|
||||
const rest = absPath.slice(projectsHome.length);
|
||||
if (rest && !rest.includes("/")) return rest; // a bare file in the projects home
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const onSave = (absPath: string) => {
|
||||
if (!absPath.startsWith(projectPrefix)) {
|
||||
const relPath = toRelPath(absPath);
|
||||
if (relPath === null) {
|
||||
opts.log(`[save] ignoring save outside project dir: ${absPath}`);
|
||||
return;
|
||||
}
|
||||
const relPath = absPath.slice(projectPrefix.length);
|
||||
|
||||
opts.onSaved?.(relPath);
|
||||
|
||||
if (!opts.saveBytes) {
|
||||
opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue