diff --git a/tests/kicad/roundtrip.spec.ts b/tests/kicad/roundtrip.spec.ts index 4933d0f..4313cf5 100644 --- a/tests/kicad/roundtrip.spec.ts +++ b/tests/kicad/roundtrip.spec.ts @@ -3,13 +3,14 @@ import { test, expect } from "./fixtures"; import { sexprDiff } from "../../web/standalone/src/wasm/collab/sexpr-diff"; /** - * Round-trip integration tests (feature 0004): for each collab-capable, file- - * bearing tool prove the Y.Doc representation is lossless — + * Round-trip integration tests (feature 0004; v2 items wire since ysync 0008 + * Stage D): for each collab-capable, file-bearing tool prove the Y.Doc + * representation is lossless — * * load a fixture file → save it (ORIG, normalized through the serializer) - * → kicadCollabSnapshot (the yjs item representation) + * → kicadCollabSnapshotItems (per-item s-expr blobs, the Slot-model wire) * → RELOAD the page (fresh process-global wasm; boot.ts) → open an EMPTY doc - * → kicadCollabApply(snapshot) (rebuild the model purely from the yjs items) + * → kicadCollabApplyItems(snapshot) (rebuild the model purely from the blobs) * → save (REGEN) * → assert sexprDiff(ORIG, REGEN).equal * @@ -40,8 +41,8 @@ interface ToolCfg { type Mod = { kicadOpenFile(p: string): unknown; - kicadCollabSnapshot(): string; - kicadCollabApply(j: string): unknown; + kicadCollabSnapshotItems(): string; + kicadCollabApplyItems(j: string): unknown; } & Record unknown>; type FS = { mkdirTree(p: string): void; @@ -67,8 +68,8 @@ async function bootOpen(page: Page, cfg: ToolCfg, content: string, name: string) const m = (window as unknown as { Module?: Mod }).Module; return ( typeof m?.kicadOpenFile === "function" && - typeof m?.kicadCollabSnapshot === "function" && - typeof m?.kicadCollabApply === "function" && + typeof m?.kicadCollabSnapshotItems === "function" && + typeof m?.kicadCollabApplyItems === "function" && typeof m?.[saveFn] === "function" ); }, @@ -131,16 +132,18 @@ async function roundTrip( const extract = await context.newPage(); await bootOpen(extract, cfg, cfg.fixture, "rt"); const orig = await saveRead(extract, cfg, "orig_dump"); - const snap = await extract.evaluate(() => window.Module.kicadCollabSnapshot()); + const snap = await extract.evaluate(() => window.Module.kicadCollabSnapshotItems()); await extract.close(); - const ids: string[] = JSON.parse(snap).added.map((i: { id: string }) => i.id); + // v2 wire: { added: [{ sexpr, parent }] } — uuids live inside the blobs. + const blobs: string[] = JSON.parse(snap).added.map((w: { sexpr: string }) => w.sexpr); + const ids = [...new Set(blobs.flatMap((s) => [...s.matchAll(/\(uuid "([^"]+)"\)/g)].map((m) => m[1]!)))]; expect(ids.length, "fixture must contain uuid items").toBeGreaterThan(0); // 4–5: fresh page (fresh wasm), open an empty doc, rebuild the model from the snapshot. const rebuild = await context.newPage(); await bootOpen(rebuild, cfg, cfg.empty, "rt"); - await rebuild.evaluate((s) => window.Module.kicadCollabApply(s), snap); + await rebuild.evaluate((s) => window.Module.kicadCollabApplyItems(s), snap); // apply() runs async for eeschema/pcbnew (CallAfter + coroutine). Best-effort wait // for the first applied item to materialize — but DON'T fail here: proceed to save @@ -268,6 +271,46 @@ const PCB: ToolCfg = { ignoreTokens: ["generator_version"], }; +// Footprint-only board: the v2 items wire's PROVEN pcbnew apply path (bare +// footprint blob parse + children — the 0004 containment win). Kept separate +// from PCB so the fragile-envelope types (via/text/segment, see the fixme +// below) don't mask the passing footprint coverage. +const PCB_FP: ToolCfg = { + ...PCB, + fixture: `(kicad_pcb + (version 20241229) + (generator "pcbnew") + (generator_version "9.0") + (general (thickness 1.6)) + (paper "A4") + (layers + (0 "F.Cu" signal) + (2 "B.Cu" signal) + (37 "F.SilkS" user) + (25 "Edge.Cuts" user) + ) + (setup) + (net 0 "") + (footprint "TestLib:R" + (layer "F.Cu") + (uuid "66666666-0000-0000-0000-000000000001") + (at 100 100) + (attr smd) + (property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "R" (at 0 4.6 0) (layer "F.Fab") (uuid "66666666-0000-0000-0000-0000000000bb") (effects (font (size 1 1) (thickness 0.15)))) + (fp_text user "HELLO" (at 0 0 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000cc") (effects (font (size 1 1) (thickness 0.15)))) + ) + (footprint "TestLib:C" + (layer "F.Cu") + (uuid "99999999-0000-0000-0000-000000000009") + (at 50 50) + (attr smd) + (property "Reference" "C1" (at 0 -2 0) (layer "F.SilkS") (uuid "99999999-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15)))) + ) +) +`, +}; + test.beforeAll(() => { // No reconciler bundle needed — the round trip drives only the C++ Module.* // exports (open / snapshot / apply / save). @@ -298,15 +341,29 @@ test.describe("round trip: file → yjs → file", () => { expect(hasAbort(testLogger), "no WASM abort").toBe(false); }); - // KNOWN BRIDGE GAPS (0004 phase-4 findings — tracked, not test bugs). The harness - // boots, round-trips, and the comparator reports precisely what pcbnew's - // kicadCollabApply does NOT yet reconstruct from a snapshot: - // - footprints + their fields are not recreated (FP removed entirely); - // - a footprint-child fp_text comes back as a board gr_text at absolute position; - // - segment `width` and via `size` are lost (default 0); - // - zones are not reconstructed. - // Un-fixme as each apply converter lands. Run with --grep-invert skipped to see the - // live diff. + // THE 0004 CONTAINMENT WIN (ysync 0008 Stage C/D): footprints round-trip via the + // items wire — the bare-footprint blob parse recreates the footprint WITH its + // children (properties, fp_text) in place, which the scalar wire never could. + test("pcbnew preserves footprint containment through a yjs round trip", async ({ + context, + testLogger, + }) => { + const { orig, regen } = await roundTrip(context, PCB_FP); + const diff = sexprDiff(orig, regen, { ignoreTokens: PCB_FP.ignoreTokens }); + expect( + diff.equal, + `round trip lost data:\n${JSON.stringify(diff, null, 2)}`, + ).toBe(true); + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + }); + + // REMAINING KNOWN GAP (ysync 0008 status, known limit 1 — tracked, not a test + // bug): pcbnew track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope + // parse, the codebase's documented asyncify-fragile path (even a verbatim + // SaveSelection envelope for a segment dies silently in the commit). The full + // fixture (via + gr_text + segments) therefore still loses those items on the + // rebuild side. Un-fixme when the envelope parse is solved. Run with + // --grep-invert skipped to see the live diff. test.fixme( "pcbnew preserves items through a yjs round trip", async ({ context, testLogger }) => { diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 18d41a2..0e00e9c 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -26,6 +26,8 @@ #include #include #include +#include +#include #include #include #include @@ -253,8 +255,52 @@ json itemToJson( BOARD_ITEM* aItem ) // Serialize one live board item to a clipboard blob (used only for `added` payloads — NOT the // diff unit, so `changed`/`removed` stay light and the blob never drives change detection). +// +// Footprints DON'T go through SaveSelection: its "make safe to transfer" step copies the +// footprint, and FOOTPRINT's copy ctor ASSIGNS the mandatory fields into the new footprint's +// freshly-constructed ones (`*existingField = *field`; EDA_ITEM::operator= keeps the target's +// uuid) — so Reference/Value/Datasheet/Description would carry NEW uuids in every blob, +// breaking the wire's identity-by-uuid (every emit would read as field remove+add, and round +// trips lose the field uuids). Instead we make the same safety copy ourselves, RESTORE the +// mandatory-field uuids from the source, and Format it directly — the same Format machinery +// SaveSelection uses internally, so asyncify behavior is identical. std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem ) { + if( aItem->Type() == PCB_FOOTPRINT_T ) + { + const FOOTPRINT* src = static_cast( aItem ); + FOOTPRINT copy( *src ); + + for( PCB_FIELD* field : copy.GetFields() ) + { + if( field->IsMandatory() ) + { + if( const PCB_FIELD* srcField = src->GetField( field->GetId() ) ) + const_cast( field->m_Uuid ) = srcField->m_Uuid; + } + } + + // The rest of SaveSelection's footprint safety steps, minus the refPoint move + // (the wire carries absolute positions). + for( PAD* pad : copy.Pads() ) + pad->SetNetCode( 0 ); + + copy.SetLocked( false ); + + CLIPBOARD_IO io; + STRING_FORMATTER fmt; + io.SetBoard( aBoard ); + io.SetOutputFormatter( &fmt ); + io.Format( © ); + + copy.SetParent( nullptr ); + copy.SetParentGroup( nullptr ); + + std::string out = fmt.GetString(); + KICAD_FORMAT::Prettify( out, KICAD_FORMAT::FORMAT_MODE::COMPACT_TEXT_PROPERTIES ); + return out; + } + PCB_SELECTION sel; sel.Add( aItem ); // pointer-only insert; no mutation of the live item diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 4eb4cf9..9d9f7d3 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -3,20 +3,22 @@ import { collabRoomId, EXTENSION_TOOL, FILELESS_TOOLS, + fileToDoc, toolSchema, + type KicadDoc, type Tool, } from "@pcbjam/shared"; import { ChevronDown, ChevronUp } from "lucide-react"; import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config"; import { bootKicadTool } from "@/wasm/boot"; -import { memfsProjectDir } from "@/wasm/constants"; +import { memfsFilePath, memfsProjectDir } from "@/wasm/constants"; import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner"; -import type { CollabWindow } from "@/wasm/collab"; +import type { KicadItemsWindow } from "@/wasm/collab"; import { clog, cwarn } from "@/wasm/collab/debug"; import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch"; import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog"; -// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports). +// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports). const COLLAB_TOOLS = new Set(["pl_editor", "eeschema", "pcbnew"]); const LEGACY_EXTENSION_TOOL: Record = { ".sch": "eeschema", @@ -170,10 +172,35 @@ function installToolNavigationHook( } /** - * Collaborative editing (features/yjs-bridge), ON BY DEFAULT for any tool that has the - * collab bridge. Open the same project URL in two tabs to edit together: the channel is - * keyed to project+file, so both tabs share one Y.Doc over BroadcastChannel. Editor edits - * (add/move items) fire the tool's change hook → the bridge → the peer tab. + * Read the opened file back from MEMFS (what the editor actually loaded) and + * parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the + * Y.Doc LOSSLESSLY when this client opens an empty room (ysync 0005): the doc + * then carries meta + layout + items, so the file is recoverable from the Y.Doc + * alone. Falls back to undefined (→ editor-snapshot seed, items only) when the + * file is absent or doesn't parse as a KiCad s-expr document. + */ +function seedDocFromMemfs( + win: ToolWindow, + slug: string, + targetPath?: string, +): KicadDoc | undefined { + if (!targetPath) return undefined; + try { + const text = win.FS?.readFile(memfsFilePath(slug, targetPath), { encoding: "utf8" }); + if (typeof text !== "string") return undefined; + return fileToDoc(text); + } catch (err) { + cwarn("seed: fileToDoc failed — falling back to editor-snapshot seed", err); + return undefined; + } +} + +/** + * Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any + * tool that has the collab bridge. Open the same project URL in two tabs to edit + * together: the channel is keyed to project+file, so both tabs share one Y.Doc over + * BroadcastChannel. Editor edits (add/move items) fire the tool's change hook → the + * bridge → the peer tab. * * Opt OUT with `?collab=0` (or `collab=false`). Tools without a bridge are skipped anyway. */ @@ -181,6 +208,7 @@ async function maybeStartCollab( win: ToolWindow, opts: { tool: Tool; + slug: string; projectId: string; targetPath?: string; log: (m: string) => void; @@ -193,8 +221,8 @@ async function maybeStartCollab( collabParam, tool: opts.tool, hasModule: !!mod, - hasSnapshot: typeof mod?.kicadCollabSnapshot, - hasApply: typeof mod?.kicadCollabApply, + hasSnapshotItems: typeof mod?.kicadCollabSnapshotItems, + hasApplyItems: typeof mod?.kicadCollabApplyItems, url: win.location.href, }); @@ -207,23 +235,28 @@ async function maybeStartCollab( clog(`tool ${opts.tool} has no collab bridge — skipping`); return; } - if (typeof mod?.kicadCollabSnapshot !== "function") { + if (typeof mod?.kicadCollabSnapshotItems !== "function") { cwarn( - "BRIDGE NOT PRESENT: Module.kicadCollabSnapshot is", - typeof mod?.kicadCollabSnapshot, - `— the loaded ${opts.tool}.wasm predates the collab bridge. Rebuild + \`npm run setup:kicad\` and restart the dev server.`, + "BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is", + typeof mod?.kicadCollabSnapshotItems, + `— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`, ); return; } - const { startCollab } = await import("@/wasm/collab"); + const { startKicadCollab } = await import("@/wasm/collab"); const provider = yjsProviderConfig(); // One room per (project, document). Two tabs of the same build compute the // same id, so cross-tab BroadcastChannel still works; network providers use it // verbatim to namespace + persist (see @pcbjam/shared collabRoomId). const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool); - clog("starting collab", provider.kind, "room", room); - await startCollab(mod, win as unknown as CollabWindow, { provider, room }); + const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath); + clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc); + await startKicadCollab(mod, win as unknown as KicadItemsWindow, { + provider, + room, + seedDoc, + }); opts.log(`[collab] ${provider.kind} connected on ${room}`); opts.onStatus("Collab: connected"); clog("connected ✓"); @@ -324,7 +357,7 @@ export function WasmTool({ log: append, onStatus: setStatus, }); - await maybeStartCollab(win, { tool, projectId, targetPath, log: append, onStatus: setStatus }); + await maybeStartCollab(win, { tool, slug, projectId, targetPath, log: append, onStatus: setStatus }); } catch (err) { append(`[fatal] ${String(err)}`); setStatus(`Error: ${String(err)}`); diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index c610437..8e66e8a 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -14,7 +14,7 @@ import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; * Which Yjs collab provider this deployment uses (one active per env), and its * endpoint/token. Defaults to `broadcastchannel` so a vanilla checkout keeps * the cross-tab-only behavior with no backend. Built here at the composition - * root and passed into `startCollab`, so `wasm/collab` stays env-agnostic. + * root and passed into `startKicadCollab`, so `wasm/collab` stays env-agnostic. */ export function yjsProviderConfig(): ProviderConfig { const kind = (import.meta.env.VITE_YJS_PROVIDER ?? "broadcastchannel") as ProviderKind; diff --git a/web/standalone/src/wasm/collab/index.ts b/web/standalone/src/wasm/collab/index.ts index 44d8d9a..4004f96 100644 --- a/web/standalone/src/wasm/collab/index.ts +++ b/web/standalone/src/wasm/collab/index.ts @@ -1,4 +1,5 @@ import * as Y from "yjs"; +import type { KicadDoc } from "@pcbjam/shared"; import { clog } from "./debug"; import { connectProvider, @@ -55,6 +56,13 @@ export interface StartCollabOptions { provider: ProviderConfig; /** Room id — see @pcbjam/shared `collabRoomId`. Identifies one shared doc. */ room: string; + /** + * The full `KicadDoc` parsed from the opened file (`fileToDoc`) — when the + * room is empty, the Y.Doc is seeded from THIS (meta + layout + items, so the + * file is recoverable from the doc alone — ysync 0005) instead of the editor + * snapshot. Ignored by the legacy scalar `startCollab`. + */ + seedDoc?: KicadDoc; } export interface CollabHandle { @@ -127,7 +135,7 @@ export async function startKicadCollab( const provider = await connectProvider(doc, opts.provider, { room: opts.room }); await provider.whenSynced(); - binding.seed(); + binding.seed(opts.seedDoc); clog("startKicadCollab: ready; doc items =", binding.items.size); return { diff --git a/web/standalone/src/wasm/collab/kicad-binding.test.ts b/web/standalone/src/wasm/collab/kicad-binding.test.ts index 82d888e..b706e0f 100644 --- a/web/standalone/src/wasm/collab/kicad-binding.test.ts +++ b/web/standalone/src/wasm/collab/kicad-binding.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import * as Y from "yjs"; import { + docToFile, + fileToDoc, itemsWireToDelta, parseItemsWireDelta, renderItem, sexprToItems, + yToDoc, type KicadItem, } from "@pcbjam/shared"; import { bindKicadCollab, type KicadItemsBridge } from "./kicad-binding"; @@ -150,6 +153,61 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => { expect(edB.store["fp-1"]).toBeDefined(); // adopted }); + it("seed(seedDoc) writes the FULL doc — file recoverable from the Y.Doc; peer adopts", () => { + const { a, edA, edB, bindA, bindB } = setup(); + const file = `(kicad_wks (version 20220228) (generator "pl_editor") + (setup (textsize 1.5 1.5) (linewidth 0.15)) + (rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner)) +) +`; + const seedDoc = fileToDoc(file); + // Editor A opened the same file: its model holds the same flattened items. + Object.assign(edA.store, seedDoc.items); + + bindA.seed(seedDoc); // empty room → file-seeds meta + layout + items + bindB.seed(); // joins → adopts + + // The peer's editor received the items via adopt. + expect(edB.store["r-1"]).toBeDefined(); + // Lossless: the file is recoverable from the Y.Doc ALONE (ysync 0005/0007), + // which the editor-snapshot seed (items only, no layout/meta) cannot do. + expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc)); + // The file-seed must not have echoed an apply into the seeding editor. + expect(edA.applied.length).toBe(0); + }); + + it("seed(seedDoc) on a populated room still ADOPTS (doc authority wins over the file)", () => { + const { edA, edB, bindA, bindB } = setup(); + seedEditor(edA, FP); + bindA.seed(); // A seeds the room from its editor + + // B cold-opens a divergent copy of the file and offers it as seedDoc. + const fileB = `(kicad_wks (version 20220228) + (rect (uuid "fp-DIVERGENT") (name "border")) +) +`; + const seedDocB = fileToDoc(fileB); + Object.assign(edB.store, seedDocB.items); + bindB.seed(seedDocB); + + expect(edB.store["fp-DIVERGENT"]).toBeUndefined(); // dropped + expect(edB.store["fp-1"]).toBeDefined(); // adopted + }); + + it("pre-seed remote state does NOT stream into the editor (adopt covers it)", () => { + const { edA, edB, bindA, bindB } = setup(); + seedEditor(edA, FP); + bindA.seed(); // relays the full state into B's Y.Doc immediately + + // B has NOT seeded yet: the initial state sync must not have been applied + // item-by-item (in the real app that's a redundant full blob apply into an + // editor that already opened the file). + expect(edB.applied.length).toBe(0); + + bindB.seed(); // the adopt delivers the same state, once + expect(Object.keys(edB.store).sort()).toEqual(["fld-1", "fp-1", "pad-1"]); + }); + it("destroy() detaches the editor from further remote changes", () => { const { edA, edB, bindA, bindB } = setup(); seedEditor(edA, FP); diff --git a/web/standalone/src/wasm/collab/kicad-binding.ts b/web/standalone/src/wasm/collab/kicad-binding.ts index 4ecdae6..509b76c 100644 --- a/web/standalone/src/wasm/collab/kicad-binding.ts +++ b/web/standalone/src/wasm/collab/kicad-binding.ts @@ -3,6 +3,7 @@ import { applyDeltaToY, deltaFromYEvents, deltaToItemsWire, + docToY, isEmptyItemsWireDelta, isEmptyKicadDelta, itemsWireToDelta, @@ -11,6 +12,7 @@ import { renderItem, yToItem, type ItemsWireDelta, + type KicadDoc, type KicadItem, type KicadYItems, } from "@pcbjam/shared"; @@ -46,12 +48,15 @@ export interface KicadItemsBridge { export interface KicadBinding { /** - * Seed-once join: if the shared doc holds no items this client seeds it from - * the editor snapshot; otherwise the editor adopts the doc (doc authority — - * local-only roots are removed, doc roots applied). Call once after the - * doc/provider are connected. + * Seed-once join: if the shared doc holds no items this client seeds it — + * from `seedDoc` (the FULL `KicadDoc` parsed from the opened file via + * `fileToDoc`; writes meta + layout + items so `docToFile` can regenerate the + * file from the Y.Doc alone — ysync 0005/0007) when given, else from the + * editor snapshot (items only). Otherwise the editor adopts the doc (doc + * authority — local-only roots are removed, doc roots applied). Call once + * after the doc/provider are connected. */ - seed(): void; + seed(seedDoc?: KicadDoc): void; destroy(): void; /** The underlying kdoc items map (exposed for tests/inspection). */ readonly items: KicadYItems; @@ -61,6 +66,12 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind const items = kicadItemsMap(doc); // Opaque per-instance origin tag so we can distinguish our own writes from peers'. const ORIGIN = { local: true }; + // Remote events arriving BEFORE seed() (e.g. the provider's initial state sync) + // must not stream into the editor item-by-item: the editor already holds the + // opened file, so that would be a redundant full-document blob apply (observed + // to trap eeschema's paste path in the real app). seed()'s adopt branch covers + // everything those early events contained. + let seeded = false; /** Plain snapshot of the Y items (the `current`/`view` the conversions need). */ const itemsView = (): Record => { @@ -94,6 +105,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind // (the runtime); the event→delta computation is the shared default impl. const observer = (events: Y.YEvent>[], txn: Y.Transaction) => { if (txn.origin === ORIGIN) return; // our own echo — ignore + if (!seeded) return; // pre-seed state sync — seed()'s adopt covers it const delta = deltaFromYEvents(items, events); if (isEmptyKicadDelta(delta)) return; const wire = deltaToItemsWire(delta, itemsView()); @@ -107,7 +119,20 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind }; items.observeDeep(observer); - function seed(): void { + function seed(seedDoc?: KicadDoc): void { + seeded = true; // open the UP gate; everything below runs synchronously + if (items.size === 0 && seedDoc) { + // First tab, file-seeded: write the FULL doc (meta + layout + items) so + // the Y.Doc — not the editor snapshot — is the lossless source of truth + // (the file is recoverable via docToFile). The editor already opened the + // same file, so no applyItems is needed. + clog( + `seed: doc empty → SEEDING from file (${Object.keys(seedDoc.items).length} item(s), root ${seedDoc.root})`, + ); + docToY(seedDoc, doc, ORIGIN); + return; + } + let wire: ItemsWireDelta; try { wire = parseItemsWireDelta(bridge.snapshotItems()); @@ -123,7 +148,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind ); if (items.size === 0) { - // First tab: seed the shared doc from the editor model. + // First tab, no file source: seed the shared doc from the editor model. applyDeltaToY(doc, local, ORIGIN); return; }