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