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:
Gergő Törcsvári 2026-06-17 17:53:38 +02:00
commit b1320afab9
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
9 changed files with 1052 additions and 81 deletions

View file

@ -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<string, (...a: never[]) => 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<void> {
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);
});
});

View file

@ -16,6 +16,7 @@
#include <string>
#include <vector>
#include <wx/app.h>
#include <wx/filename.h>
#include <wx/string.h>
#include <wx/window.h>
#include <nlohmann/json.hpp>
@ -94,6 +95,21 @@ SCH_EDIT_FRAME* schFrame()
return wxTheApp ? dynamic_cast<SCH_EDIT_FRAME*>( 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<std::string> seen;
json arr = json::array();
for( const SCH_SHEET_PATH& path : aSch.Hierarchy() )
if( SCH_SCREEN* screen = currentScreen( aFrame ) )
{
SCH_SCREEN* screen = const_cast<SCH_SHEET_PATH&>( 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<std::string, json> snapshotByUuid( SCHEMATIC& aSch )
// Diff baseline of the ACTIVE screen only (per-sheet collab room scope), keyed by uuid.
std::map<std::string, json> snapshotByUuid( SCH_EDIT_FRAME* aFrame )
{
std::map<std::string, json> m;
for( const SCH_SHEET_PATH& path : aSch.Hierarchy() )
if( SCH_SCREEN* screen = currentScreen( aFrame ) )
{
SCH_SCREEN* screen = const_cast<SCH_SHEET_PATH&>( 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<std::string, json> cur = snapshotByUuid( fr->Schematic() );
std::map<std::string, json> 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<int, int> 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<SCH_SHEET*>( 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<SCH_ITEM*>& ) override { trigger(); }
void OnSchItemsAdded( SCHEMATIC&, std::vector<SCH_ITEM*>& 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<SCH_SHEET*>( item ) );
}
trigger();
}
void OnSchItemsChanged( SCHEMATIC&, std::vector<SCH_ITEM*>& ) override { trigger(); }
void OnSchItemsRemoved( SCHEMATIC&, std::vector<SCH_ITEM*>& ) 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<std::string> seen;
for( const SCH_SHEET_PATH& path : fr->Schematic().Hierarchy() )
if( SCH_SCREEN* screen = currentScreen( fr ) )
{
SCH_SCREEN* screen = const_cast<SCH_SHEET_PATH&>( 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();

@ -1 +1 @@
Subproject commit cb402cfac1a88cb6507c0b8fdfd4a5906ad9978c
Subproject commit 3367a91b39060d6be51b5d30c5503a00933314f9

View file

@ -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

View file

@ -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>

View 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 });
});
});

View 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 };
}

View 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();
});
});

View file

@ -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)`);