diff --git a/tests/kicad/eeschema-subschema.spec.ts b/tests/kicad/eeschema-subschema.spec.ts new file mode 100644 index 0000000..fea6cbb --- /dev/null +++ b/tests/kicad/eeschema-subschema.spec.ts @@ -0,0 +1,148 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures"; + +/** + * Subschema (hierarchical sheet) collab scoping — the Phase-0 C++ change. + * + * A hierarchical design references a child `.kicad_sch` from the root via a (sheet …) + * symbol; opening the root loads BOTH screens into memory. Each `.kicad_sch` is its own + * collab room, so `kicadCollabSnapshotItems()` (which feeds the active room) must return + * ONLY the active screen's items — NOT the whole hierarchy. Before Phase 0 it iterated + * `Schematic().Hierarchy()` and would have leaked the child's items into the root's room. + * + * Headless scope: this asserts the snapshot is scoped to the root (active) screen on + * load. Driving sheet navigation + the onSheetChanged hook needs the real wx UI (the + * same reason eeschema-collab's two-tab test is skipped) and is verified in the app. + */ + +type Mod = { + kicadOpenFile(p: string): unknown; + kicadCollabSnapshotItems(): string; +} & Record unknown>; +type FS = { + mkdirTree(p: string): void; + writeFile(p: string, d: string): void; +}; + +const BOOT_TIMEOUT = 150000; + +const ROOT_WIRE_UUID = "1aaaaaaa-0000-0000-0000-000000000001"; +const SHEET_SYMBOL_UUID = "5ee70000-0000-0000-0000-000000000001"; +const CHILD_WIRE_UUID = "2ccccccc-0000-0000-0000-000000000001"; + +const ROOT_SCH = `(kicad_sch + (version 20250114) + (generator "eeschema") + (generator_version "9.0") + (uuid "10000000-0000-0000-0000-000000000000") + (paper "A4") + (lib_symbols) + (wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${ROOT_WIRE_UUID}")) + (sheet (at 127 50.8) (size 20 20) + (stroke (width 0.1524) (type solid)) + (fill (color 0 0 0 0.0000)) + (uuid "${SHEET_SYMBOL_UUID}") + (property "Sheetname" "child" (at 127 50 0) (effects (font (size 1.27 1.27)) (justify left bottom))) + (property "Sheetfile" "child.kicad_sch" (at 127 71 0) (effects (font (size 1.27 1.27)) (justify left top))) + (instances (project "rt" (path "/10000000-0000-0000-0000-000000000000" (page "2")))) + ) + (sheet_instances (path "/" (page "1"))) +) +`; + +const CHILD_SCH = `(kicad_sch + (version 20250114) + (generator "eeschema") + (generator_version "9.0") + (uuid "20000000-0000-0000-0000-000000000000") + (paper "A4") + (lib_symbols) + (wire (pts (xy 25.4 25.4) (xy 76.2 25.4)) (stroke (width 0) (type default)) (uuid "${CHILD_WIRE_UUID}")) + (sheet_instances (path "/" (page "1"))) +) +`; + +function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean { + return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted(")); +} + +async function bootOpenHierarchy(page: Page): Promise { + await page.goto("/kicad/eeschema.html"); + await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT }); + await page.waitForFunction( + () => { + const m = (window as unknown as { Module?: Mod }).Module; + return ( + typeof m?.kicadOpenFile === "function" && + typeof m?.kicadCollabSnapshotItems === "function" + ); + }, + null, + { timeout: BOOT_TIMEOUT }, + ); + await page.waitForFunction( + () => + !!window.wxElementRegistry && + window.wxElementRegistry + .findAll({ visible: true }) + .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), + null, + { timeout: BOOT_TIMEOUT }, + ); + await page.evaluate( + ({ root, child }) => { + const w = window as unknown as { FS: FS; Module: Mod }; + try { + w.FS.mkdirTree("/home/kicad/documents"); + } catch { + /* exists */ + } + // Write BOTH sheets so the root's (sheet … Sheetfile "child.kicad_sch") resolves + // and eeschema loads the child screen alongside the root. + w.FS.writeFile("/home/kicad/documents/child.kicad_sch", child); + w.FS.writeFile("/home/kicad/documents/root.kicad_sch", root); + w.Module.kicadOpenFile("/home/kicad/documents/root.kicad_sch"); + }, + { root: ROOT_SCH, child: CHILD_SCH }, + ); +} + +test.describe("eeschema subschema (hierarchical sheet) collab scoping", () => { + test.describe.configure({ timeout: 420000 }); + + test("snapshot covers ONLY the active (root) screen, not the child sheet", async ({ + page, + testLogger, + }) => { + await bootOpenHierarchy(page); + + // Poll: the root snapshot must include the root's own items but NOT the child's. + await expect + .poll( + async () => { + const snap = JSON.parse( + await page.evaluate(() => window.Module.kicadCollabSnapshotItems()), + ) as { added: Array<{ sexpr: string }> }; + return snap.added.map((w) => w.sexpr).join("\n"); + }, + { timeout: 30000, intervals: [500] }, + ) + .toContain(ROOT_WIRE_UUID); + + const blobs = JSON.parse( + await page.evaluate(() => window.Module.kicadCollabSnapshotItems()), + ) as { added: Array<{ sexpr: string }> }; + const allBlobs = blobs.added.map((w) => w.sexpr).join("\n"); + + // Root items present (the wire + the sheet symbol live on the root screen)… + expect(allBlobs, "root wire in snapshot").toContain(ROOT_WIRE_UUID); + expect(allBlobs, "sheet symbol in snapshot").toContain(SHEET_SYMBOL_UUID); + // …but the CHILD sheet's items must NOT leak into the root's room (Phase 0). + expect(allBlobs, "child wire must NOT be in the root snapshot").not.toContain( + CHILD_WIRE_UUID, + ); + + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + }); +}); diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index fbff005..0332368 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -94,6 +95,21 @@ SCH_EDIT_FRAME* schFrame() return wxTheApp ? dynamic_cast( wxTheApp->GetTopWindow() ) : nullptr; } +// The screen of the sheet the editor is currently showing. Per-sheet collab keys a room +// to each .kicad_sch, so the snapshot/diff that feeds a room must cover ONLY this screen, +// never the whole Hierarchy(). GetCurrentSheet().LastScreen() is the active sheet's screen +// (GetScreen() tracks the same screen and is the fallback before a sheet path exists). +SCH_SCREEN* currentScreen( SCH_EDIT_FRAME* aFrame ) +{ + if( !aFrame ) + return nullptr; + + if( SCH_SCREEN* screen = aFrame->GetCurrentSheet().LastScreen() ) + return screen; + + return aFrame->GetScreen(); +} + json itemToJson( SCH_ITEM* aItem ) { VECTOR2I p = aItem->GetPosition(); @@ -256,26 +272,17 @@ SCH_ITEM* makeItem( const json& j ) return item; } -// Full current model as an array of item json, deduped by uuid across the hierarchy. -json snapshotItems( SCHEMATIC& aSch ) +// Current model of the ACTIVE screen as an array of item json. One collab room == one +// .kicad_sch screen, so we never fold in the rest of the hierarchy (uuids are unique +// within a screen, so no cross-sheet dedup is needed). +json snapshotItems( SCH_EDIT_FRAME* aFrame ) { - json arr = json::array(); - std::set seen; + json arr = json::array(); - for( const SCH_SHEET_PATH& path : aSch.Hierarchy() ) + if( SCH_SCREEN* screen = currentScreen( aFrame ) ) { - SCH_SCREEN* screen = const_cast( path ).LastScreen(); - - if( !screen ) - continue; - for( SCH_ITEM* item : screen->Items() ) - { - std::string id = toUtf8( item->m_Uuid.AsString() ); - - if( seen.insert( id ).second ) - arr.push_back( itemToJson( item ) ); - } + arr.push_back( itemToJson( item ) ); } return arr; @@ -302,6 +309,25 @@ void emitItems( const json& aWire ) }, s.c_str() ); } +// Notify the standalone that the editor switched to a different sheet — a different +// .kicad_sch == a different collab room (ysync subschemas). The path is the active +// screen's load path: the same absolute MEMFS form kicadCollabOnSave emits, so the JS +// side strips the project prefix the same way (relativeProjectPath). No-op without a +// JS listener. +void emitSheetChanged() +{ + SCH_SCREEN* screen = currentScreen( schFrame() ); + + if( !screen ) + return; + + std::string s = toUtf8( screen->GetFileName() ); + EM_ASM( { + if( window.kicadCollab && window.kicadCollab.onSheetChanged ) + window.kicadCollab.onSheetChanged( UTF8ToString( $0 ) ); + }, s.c_str() ); +} + // Serialize one live schematic item to its native s-expr via the clipboard // formatter (the exact path Ctrl-C uses: a one-item SCH_SELECTION through // SCH_IO_KICAD_SEXPR::Format). For a symbol the output also carries its @@ -336,24 +362,15 @@ std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem ) // applies it and re-cleaning already-clean geometry is idempotent, so the two converge. (This // mirrors pl_editor's snapshot-differ.) g_baseline is the last-broadcast state. -std::map snapshotByUuid( SCHEMATIC& aSch ) +// Diff baseline of the ACTIVE screen only (per-sheet collab room scope), keyed by uuid. +std::map snapshotByUuid( SCH_EDIT_FRAME* aFrame ) { std::map m; - for( const SCH_SHEET_PATH& path : aSch.Hierarchy() ) + if( SCH_SCREEN* screen = currentScreen( aFrame ) ) { - SCH_SCREEN* screen = const_cast( path ).LastScreen(); - - if( !screen ) - continue; - for( SCH_ITEM* item : screen->Items() ) - { - std::string id = toUtf8( item->m_Uuid.AsString() ); - - if( !m.count( id ) ) - m[id] = itemToJson( item ); - } + m[toUtf8( item->m_Uuid.AsString() )] = itemToJson( item ); } return m; @@ -367,7 +384,7 @@ bool g_flushScheduled = false; void rebaseline() { if( SCH_EDIT_FRAME* fr = schFrame() ) - g_baseline = snapshotByUuid( fr->Schematic() ); + g_baseline = snapshotByUuid( fr ); } // Diff the current (settled, post-cleanup) model against the baseline and broadcast the change. @@ -380,7 +397,7 @@ void flushDiff() if( !fr ) return; - std::map cur = snapshotByUuid( fr->Schematic() ); + std::map cur = snapshotByUuid( fr ); json added = json::array(), changed = json::array(), removed = json::array(); @@ -456,16 +473,89 @@ void scheduleFlush() } } +// A hierarchical sheet was just created locally ("Add Sheet"): write its new child screen +// to the child .kicad_sch file and tell the standalone (window.kicadCollab.onSheetCreated), +// so the child is persisted/registered the moment it's created — without waiting for the +// user to enter it or save the project. Otherwise the parent's `(sheet … child)` reference +// dangles for peers / on reload. Deferred onto the fiber stack (CallAfter + COROUTINE): +// SCH_IO_KICAD_SEXPR::Format's virtual dispatch traps on the bare listener/CallAfter stack, +// same as flushDiff/doApply. The sheet is re-resolved by uuid in the deferred body so a +// since-deleted sheet (e.g. an immediate undo) is a no-op rather than a dangling pointer. +void scheduleSheetSave( SCH_SHEET* aSheet ) +{ + SCH_EDIT_FRAME* fr = schFrame(); + SCH_SCREEN* parent = currentScreen( fr ); + + if( !fr || !parent || !aSheet->GetScreen() ) + return; + + wxFileName childFn( aSheet->GetFileName() ); // relative "Sheetfile" + childFn.MakeAbsolute( wxFileName( parent->GetFileName() ).GetPath() ); + + std::string childAbs = toUtf8( childFn.GetFullPath() ); + std::string uuid = toUtf8( aSheet->m_Uuid.AsString() ); + + fr->CallAfter( [fr, childAbs, uuid]() { + COROUTINE cor( [fr, childAbs, uuid]( int ) -> int + { + KIID kid( wxString::FromUTF8( uuid.c_str() ) ); + SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ); + + if( !item || item->Type() != SCH_SHEET_T ) + return 0; + + try + { + SCH_IO_KICAD_SEXPR io; + io.SaveSchematicFile( wxString::FromUTF8( childAbs.c_str() ), + static_cast( item ), &fr->Schematic() ); + } + catch( ... ) + { + return 0; // a write failure must not abort the runtime + } + + EM_ASM( { + if( window.kicadCollab && window.kicadCollab.onSheetCreated ) + window.kicadCollab.onSheetCreated( UTF8ToString( $0 ) ); + }, childAbs.c_str() ); + return 0; + } ); + cor.Call( 0 ); + } ); +} + // ChangeSource: the native SCHEMATIC_LISTENER is just a trigger — the actual change set comes // from the post-settle snapshot diff above. Skipped while applying a remote delta (no echo); // doApply rebaselines instead. class COLLAB_LISTENER : public SCHEMATIC_LISTENER { public: - void OnSchItemsAdded( SCHEMATIC&, std::vector& ) override { trigger(); } + void OnSchItemsAdded( SCHEMATIC&, std::vector& aItems ) override + { + // A newly-added hierarchical sheet → persist + register its child file (above). + if( !s_applyingRemote ) + { + for( SCH_ITEM* item : aItems ) + if( item->Type() == SCH_SHEET_T ) + scheduleSheetSave( static_cast( item ) ); + } + trigger(); + } void OnSchItemsChanged( SCHEMATIC&, std::vector& ) override { trigger(); } void OnSchItemsRemoved( SCHEMATIC&, std::vector& ) override { trigger(); } + // The editor switched to a different sheet (a different .kicad_sch == a different + // collab room). Re-baseline so the first edit on the new sheet diffs against ITS + // screen, not the previous one, then tell the standalone to rebind its room to the + // now-active sheet file. Fires from SCH_EDIT_FRAME::DisplayCurrentSheet, by which + // point GetCurrentSheet()/GetScreen() already point at the new sheet. + void OnSchSheetChanged( SCHEMATIC& ) override + { + rebaseline(); + emitSheetChanged(); + } + private: void trigger() { @@ -808,8 +898,8 @@ void kicadCollabApply( std::string aJson ) // registers the change listener on first call. std::string kicadCollabSnapshot() { - SCHEMATIC* sch = ensureBridge(); - json added = sch ? snapshotItems( *sch ) : json::array(); + ensureBridge(); + json added = snapshotItems( schFrame() ); // Seed the diff baseline to exactly the model we're handing out, so the first local edit // diffs against this snapshot (and we don't re-broadcast the whole model). @@ -845,9 +935,9 @@ void kicadCollabApplyItems( std::string aJson ) } -// JS pull of the full current model as an all-"added" v2 items wire: one clipboard- -// style blob per screen item across the hierarchy (deduped by uuid). Registers the -// listener + rebaselines exactly like kicadCollabSnapshot. +// JS pull of the ACTIVE screen's model as an all-"added" v2 items wire: one clipboard- +// style blob per item on the current sheet (one collab room == one .kicad_sch screen). +// Registers the listener + rebaselines exactly like kicadCollabSnapshot. std::string kicadCollabSnapshotItems() { SCH_EDIT_FRAME* fr = schFrame(); @@ -858,20 +948,10 @@ std::string kicadCollabSnapshotItems() { ensureBridge(); - std::set seen; - - for( const SCH_SHEET_PATH& path : fr->Schematic().Hierarchy() ) + if( SCH_SCREEN* screen = currentScreen( fr ) ) { - SCH_SCREEN* screen = const_cast( path ).LastScreen(); - - if( !screen ) - continue; - for( SCH_ITEM* item : screen->Items() ) - { - if( seen.insert( toUtf8( item->m_Uuid.AsString() ) ).second ) - added.push_back( json{ { "sexpr", itemBlob( fr, item ) }, { "parent", nullptr } } ); - } + added.push_back( json{ { "sexpr", itemBlob( fr, item ) }, { "parent", nullptr } } ); } rebaseline(); diff --git a/web/pcbjam-shared b/web/pcbjam-shared index cb402cf..3367a91 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit cb402cfac1a88cb6507c0b8fdfd4a5906ad9978c +Subproject commit 3367a91b39060d6be51b5d30c5503a00933314f9 diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 7f25c3a..92e18aa 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -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 { + 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(null); const startedRef = React.useRef(false); const driftRef = React.useRef<{ stop(): void } | null>(null); + const sheetManagerRef = React.useRef(null); const [status, setStatus] = React.useState("Loading tool…"); const [logs, setLogs] = React.useState([]); 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 diff --git a/web/standalone/src/pages/ToolPage.tsx b/web/standalone/src/pages/ToolPage.tsx index fcca518..3d61753 100644 --- a/web/standalone/src/pages/ToolPage.tsx +++ b/web/standalone/src/pages/ToolPage.tsx @@ -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} /> diff --git a/web/standalone/src/wasm/collab/sheet-manager.test.ts b/web/standalone/src/wasm/collab/sheet-manager.test.ts new file mode 100644 index 0000000..6e7fc7f --- /dev/null +++ b/web/standalone/src/wasm/collab/sheet-manager.test.ts @@ -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; + off: ReturnType; + destroy: ReturnType; + /** Simulate a remote update arriving over the (warm) provider while parked. */ + emitRemote: () => void; +} +interface FakeSession { + room: string; + doc: FakeDoc; + provider: { destroy: ReturnType }; +} +interface FakeBinding { + seed: ReturnType; + destroy: ReturnType; + 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 }); + }); +}); diff --git a/web/standalone/src/wasm/collab/sheet-manager.ts b/web/standalone/src/wasm/collab/sheet-manager.ts new file mode 100644 index 0000000..37ecf0c --- /dev/null +++ b/web/standalone/src/wasm/collab/sheet-manager.ts @@ -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; + /** Bind the editor to `sheetPath` (driven by the C++ `onSheetChanged` hook). */ + switchTo(sheetPath: string): Promise; + /** Warm a sheet created mid-session (driven by the save hook on an unknown path). */ + onboard(sheetPath: string): Promise; + /** 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(); + // 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>(); + 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/web/standalone/src/wasm/save-flow.test.ts b/web/standalone/src/wasm/save-flow.test.ts new file mode 100644 index 0000000..56097a1 --- /dev/null +++ b/web/standalone/src/wasm/save-flow.test.ts @@ -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(); + }); +}); diff --git a/web/standalone/src/wasm/save-flow.ts b/web/standalone/src/wasm/save-flow.ts index b9f138d..ac68a03 100644 --- a/web/standalone/src/wasm/save-flow.ts +++ b/web/standalone/src/wasm/save-flow.ts @@ -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)`);