feat: v2 per-item s-expr collab bridge in all three tools (ysync 0008 Stage C)

Add kicadCollabSnapshotItems / kicadCollabApplyItems / window.kicadCollab.onItems
to pl_editor, eeschema, pcbnew — per-item native-blob payloads
({added/changed:[{sexpr,parent}], removed:[uuid]}) alongside the untouched scalar
wire (legacy collab specs stay green).

- pl_editor: itemBlob per item; apply = SetPageLayout(append) + replace-by-uuid
  (pointer-snapshot safe); bare payloads get the kicad_wks envelope. Snapshot +
  apply + emit verified headless.
- eeschema: clipboard Format per item (symbols carry their lib_symbols); apply
  mirrors the native paste — LoadContent into a throwaway sheet → detach →
  replace-by-uuid → symbol lib relink (blob's lib_symbols first, live screen's
  second) → SCH_COMMIT, in the CallAfter+COROUTINE context. Snapshot + apply
  verified headless (a "lost" lone junction turned out to be correct connection
  cleanup — test uses text).
- pcbnew: blobForItem per ROOT item with child→footprint lifting in flushDiff;
  apply = makeFromBlob + commit replace-by-uuid on the fiber; bare non-footprint
  payloads get wrapInBoardEnvelope (live board layer table). Snapshot + footprint
  replace/add WITH children (the 0004 containment gap, closed) + removal verified
  headless. Track/via/zone/text blob-apply hits the documented asyncify-fragile
  envelope parse (reconfirmed empirically — a verbatim SaveSelection segment
  envelope dies silently in the commit) and stays on the legacy scalar apply;
  tracked in ysync 0008 status.
- eeschema/pcbnew scheduleFlush now runs flushDiff inside a COROUTINE: the
  per-item Format in the v2 emit needs the fiber stack (0007 lesson). Their emit
  remains unverifiable headless (both legacy two-tab tests are test.skip:
  "open=false → SCH_COMMIT no-ops" / "harness can't PAINT") — verify in the real
  app at Stage D; pl_editor's emit IS verified.
- tests/kicad/items-bridge.spec.ts: per-tool suite (snapshot uuids → local-edit
  emit (where drivable) → apply changed/added/removed via save-readback → no
  apply echo). 3/3 pass; roundtrip + collab suites unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-11 13:55:15 +02:00
commit 3a06a267b0
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 945 additions and 4 deletions

View file

@ -0,0 +1,324 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* v2 "items" bridge (ysync 0008 Stage C): per-item s-expr payloads on
* kicadCollabSnapshotItems / kicadCollabApplyItems / window.kicadCollab.onItems.
*
* Per tool this drives the C++ exports directly (no reconciler/Y.Doc that's the
* Stage B binding, unit-tested in the standalone):
* 1. snapshotItems: every fixture item's uuid appears in some wire blob;
* 2. applyItems: changed (full/bare blob) + added (bare blob tool wraps in its
* envelope) + removed (uuid) land in the model verified via the save export;
* 3. onItems: a genuine local edit emits a wire whose blob carries the item.
*/
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabSnapshotItems(): string;
kicadCollabApplyItems(j: string): unknown;
} & Record<string, (...a: never[]) => unknown>;
type FS = {
mkdirTree(p: string): void;
writeFile(p: string, d: string): void;
readFile(p: string, o: { encoding: "utf8" }): string;
};
interface ToolCfg {
tool: string;
html: string;
ext: string;
saveFn: string;
fixture: string;
/** uuids that must appear in the snapshot wire. */
uuids: string[];
/** applyItems payloads + the save-text markers proving they landed. Either a
* static sexpr, or derive it from the snapshot blob of `uuid` via replace
* for pcbnew, whose non-footprint envelopes are the known-fragile parse. */
changed:
| { sexpr: string; marker: string }
| { fromSnapshotUuid: string; replace: [string, string]; marker: string };
added: { sexpr: string; uuid: string };
removedUuid: string;
/** evaluate-able local edit that must trigger an onItems emit. Omitted for
* eeschema: the headless harness can't drive its emit (open=false
* SCH_COMMIT no-ops the documented reason eeschema-collab's two-tab test
* is skipped; verified working in the real web app). */
localEdit?: string;
}
const BOOT_TIMEOUT = 150000;
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
async function bootOpen(page: Page, cfg: ToolCfg): Promise<void> {
await page.goto(`/kicad/${cfg.html}`);
await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT });
await page.waitForFunction(
(saveFn) => {
const m = (window as unknown as { Module?: Mod }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabSnapshotItems === "function" &&
typeof m?.kicadCollabApplyItems === "function" &&
typeof m?.[saveFn] === "function"
);
},
cfg.saveFn,
{ 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(
({ content, ext }) => {
const w = window as unknown as { FS: FS; Module: Mod };
try {
w.FS.mkdirTree("/home/kicad/documents");
} catch {
/* exists */
}
const p = `/home/kicad/documents/rt.${ext}`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content: cfg.fixture, ext: cfg.ext },
);
}
async function saveRead(page: Page, cfg: ToolCfg, name: string): Promise<string> {
return page.evaluate(
({ saveFn, ext, name }) => {
const w = window as unknown as { FS: FS; Module: Mod };
const out = `/home/kicad/documents/${name}.${ext}`;
(w.Module[saveFn] as (p: string) => unknown)(out);
return w.FS.readFile(out, { encoding: "utf8" });
},
{ saveFn: cfg.saveFn, ext: cfg.ext, name },
);
}
/** Poll the saved model until `marker` is present (apply is async for ee/pcb). */
async function pollSaved(page: Page, cfg: ToolCfg, marker: string, present = true): Promise<void> {
await expect
.poll(async () => (await saveRead(page, cfg, "probe")).includes(marker), {
timeout: 25000,
intervals: [400],
})
.toBe(present);
}
// ── Tool configurations ──────────────────────────────────────────────────────
const PL: ToolCfg = {
tool: "pl_editor",
html: "pl_editor.html",
ext: "kicad_wks",
saveFn: "kicadSaveDrawingSheet",
fixture: `(kicad_wks (version 20220228) (generator "pl_editor") (generator_version "9.0")
(setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15)
(left_margin 10)(right_margin 10)(top_margin 10)(bottom_margin 10))
(rect (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") (name border) (start 0 0 ltcorner) (end 0 0 rbcorner))
(tbtext "Title" (uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") (name title) (pos 100 20 ltcorner) (font (size 2 2)))
)
`,
uuids: ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"],
changed: {
// Bare item (Y.Doc-rendered shape) — the tool must wrap it in its envelope.
sexpr: `(tbtext "Title" (uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") (name title) (pos 55 65 ltcorner) (font (size 2 2)))`,
marker: "(pos 55 65",
},
added: {
sexpr: `(tbtext "BareAdd" (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd") (name bare) (pos 10 10 ltcorner) (font (size 2 2)))`,
uuid: "dddddddd-dddd-dddd-dddd-dddddddddddd",
},
removedUuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
localEdit: `window.Module.kicadCollabTestAddText("EmitMe", 40, 40)`,
};
const SCH: ToolCfg = {
tool: "eeschema",
html: "eeschema.html",
ext: "kicad_sch",
saveFn: "kicadSaveSchematic",
fixture: `(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "11111111-1111-1111-1111-111111111111")
(paper "A4")
(lib_symbols)
(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001"))
(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000002"))
(sheet_instances (path "/" (page "1")))
)
`,
uuids: ["22222222-0000-0000-0000-000000000001", "22222222-0000-0000-0000-000000000002"],
changed: {
sexpr: `(wire (pts (xy 25.4 25.4) (xy 76.2 25.4)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001"))`,
marker: "(xy 25.4 25.4)",
},
added: {
// Connectivity-neutral: a lone junction would be deleted by SCH_COMMIT::Push's
// connection cleanup (it sits on no wire crossing) — that's correct editor
// behavior, not a bridge gap. Text survives.
sexpr: `(text "BareAdd" (exclude_from_sim no) (at 60.96 60.96 0) (effects (font (size 1.27 1.27))) (uuid "33333333-0000-0000-0000-000000000003"))`,
uuid: "33333333-0000-0000-0000-000000000003",
},
removedUuid: "22222222-0000-0000-0000-000000000002",
// no localEdit: headless harness cannot drive eeschema emit (see ToolCfg note)
};
const PCB: ToolCfg = {
tool: "pcbnew",
html: "pcbnew-collab.html",
ext: "kicad_pcb",
saveFn: "kicadSaveBoard",
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))))
)
(via (at 80 80) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net 0) (uuid "77777777-0000-0000-0000-000000000001"))
(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "88888888-0000-0000-0000-000000000001"))
(segment (start 50.8 76.2) (end 101.6 76.2) (width 0.2) (layer "F.Cu") (net 0) (uuid "88888888-0000-0000-0000-000000000002"))
)
`,
uuids: [
"66666666-0000-0000-0000-000000000001",
"77777777-0000-0000-0000-000000000001",
"88888888-0000-0000-0000-000000000001",
],
// pcbnew v2-apply scope (ysync 0008 Stage C): FOOTPRINT blobs are the proven
// path (bare-footprint parse + replace-by-uuid with children — the 0004
// containment win). Track/via/zone/text APPLY via the (kicad_pcb …) envelope
// is the codebase's documented asyncify-fragile parse — those types remain on
// the legacy scalar apply until that's solved (tracked in 0008 status).
changed: {
// Replace the footprint wholesale from its own snapshot blob, moved.
fromSnapshotUuid: "66666666-0000-0000-0000-000000000001",
replace: ["(at 100 100)", "(at 90 110)"],
marker: "(at 90 110",
},
added: {
// A bare footprint parses top-level (no envelope) — the proven add path.
sexpr: `(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)))))`,
uuid: "99999999-0000-0000-0000-000000000009",
},
removedUuid: "88888888-0000-0000-0000-000000000002",
// no localEdit: pcbnew emit is likewise unverifiable headless (pcbnew-collab two-tab is test.skip)
};
// ── The per-tool suite ───────────────────────────────────────────────────────
for (const cfg of [PL, SCH, PCB]) {
test.describe(`${cfg.tool} items bridge (v2, per-item s-expr)`, () => {
test.describe.configure({ timeout: 420000 });
test(`${cfg.tool}: snapshot, apply (changed/added/removed), emit`, async ({
page,
testLogger,
}) => {
await bootOpen(page, cfg);
// 1. snapshotItems: every fixture uuid appears in some wire blob.
const snap = JSON.parse(
await page.evaluate(() => window.Module.kicadCollabSnapshotItems()),
) as { added: Array<{ sexpr: string; parent: string | null }> };
const allBlobs = snap.added.map((w) => w.sexpr).join("\n");
for (const uuid of cfg.uuids) {
expect(allBlobs, `snapshot blob for ${uuid}`).toContain(uuid);
}
// 2. Register the v2 emit hook.
await page.evaluate(() => {
(window as unknown as { __items: string[] }).__items = [];
(window as unknown as { kicadCollab: object }).kicadCollab = {
onItems: (j: string) => (window as unknown as { __items: string[] }).__items.push(j),
};
});
// 3. A genuine local edit emits an items wire carrying the touched item.
// Run this BEFORE the applies: TestMoveFirst moves the FIRST screen item,
// which must be a fixture wire/track (the proven off-fiber move path) — an
// apply-added text would no-op the virtual Move (known asyncify quirk).
// Skipped when the harness can't drive the tool's emit (see ToolCfg).
if (cfg.localEdit) {
const editedUuid = (await page.evaluate(cfg.localEdit)) as string;
await expect
.poll(
async () =>
await page.evaluate(
() => (window as unknown as { __items: string[] }).__items.join("\n"),
),
{ timeout: 20000, intervals: [400] },
)
.toContain(editedUuid);
}
const emitsAfterLocalEdit = await page.evaluate(
() => (window as unknown as { __items: string[] }).__items.length,
);
// 4. applyItems: changed + added (bare blobs) + removed.
let changedSexpr: string;
if ("fromSnapshotUuid" in cfg.changed) {
const blob = snap.added
.map((w) => w.sexpr)
.find((s) => s.includes((cfg.changed as { fromSnapshotUuid: string }).fromSnapshotUuid));
expect(blob, "snapshot blob for the changed item").toBeTruthy();
const [from, to] = cfg.changed.replace;
expect(blob!, `blob contains "${from}"`).toContain(from);
changedSexpr = blob!.replace(from, to);
} else {
changedSexpr = cfg.changed.sexpr;
}
await page.evaluate(
({ changed, added, removed }) => {
window.Module.kicadCollabApplyItems(
JSON.stringify({ added: [{ sexpr: added }], changed: [{ sexpr: changed }], removed: [removed] }),
);
},
{ changed: changedSexpr, added: cfg.added.sexpr, removed: cfg.removedUuid },
);
await pollSaved(page, cfg, cfg.changed.marker);
await pollSaved(page, cfg, cfg.added.uuid);
await pollSaved(page, cfg, cfg.removedUuid, false);
// Remote applies must not have echoed an onItems emit.
const echoed = await page.evaluate(
() => (window as unknown as { __items: string[] }).__items.length,
);
expect(echoed, "apply must not emit onItems").toBe(emitsAfterLocalEdit);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
});
}

View file

@ -25,6 +25,9 @@
#include <sch_edit_frame.h>
#include <sch_io/kicad_sexpr/sch_io_kicad_sexpr.h>
#include <sch_sheet.h>
#include <richio.h>
#include <lib_symbol.h>
#include <tools/sch_selection.h>
#include <sch_commit.h>
#include <sch_item.h>
#include <sch_line.h>
@ -287,6 +290,35 @@ void emit( const json& aDelta )
}, s.c_str() );
}
// v2 "items" wire emit (ysync 0008): per-item s-expr blobs instead of decomposed
// scalars. A JS runtime registers window.kicadCollab.onItems to opt in; both wires
// are emitted side by side until the scalar path is retired.
void emitItems( const json& aWire )
{
std::string s = aWire.dump();
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onItems )
window.kicadCollab.onItems( 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
// (lib_symbols …) definition, just like a copy does.
std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem )
{
SCH_SELECTION sel;
sel.SetScreen( aFrame->GetScreen() );
sel.Add( aItem );
STRING_FORMATTER fmt;
SCH_IO_KICAD_SEXPR plugin;
plugin.Format( &sel, &aFrame->GetCurrentSheet(), aFrame->Schematic(), &fmt,
/*aForClipboard*/ true );
return fmt.GetString();
}
// ── Emit via post-settle snapshot diff ───────────────────────────────────────────────────
//
// A local edit is a single SCH_COMMIT::Push that fires OnItemsAdded/Removed/Changed
@ -352,14 +384,32 @@ void flushDiff()
json added = json::array(), changed = json::array(), removed = json::array();
// v2 items wire (per-item s-expr blobs), built from the same diff. Screen items
// are already root-level (fields live inside their symbols), so no lifting.
json wAdded = json::array(), wChanged = json::array();
auto blobFor = [&]( const std::string& id, json& aArr )
{
KIID kid( wxString::FromUTF8( id.c_str() ) );
if( SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) )
aArr.push_back( json{ { "sexpr", itemBlob( fr, item ) }, { "parent", nullptr } } );
};
for( const auto& [id, j] : cur )
{
auto it = g_baseline.find( id );
if( it == g_baseline.end() )
{
added.push_back( j );
blobFor( id, wAdded );
}
else if( it->second != j )
{
changed.push_back( j );
blobFor( id, wChanged );
}
}
for( const auto& [id, j] : g_baseline )
@ -371,11 +421,17 @@ void flushDiff()
g_baseline = std::move( cur );
if( !added.empty() || !changed.empty() || !removed.empty() )
{
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } );
}
}
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
// turn) into a single post-settle diff.
// turn) into a single post-settle diff. flushDiff runs inside a COROUTINE: its v2 items
// emit serializes items via SCH_IO_KICAD_SEXPR::Format (itemBlob), whose virtual dispatch
// is only reliable on the libcontext fiber stack — on the bare CallAfter stack it traps
// and silently kills the whole flush, legacy emit included (same lesson as doApply, 0007).
void scheduleFlush()
{
if( g_flushScheduled )
@ -384,9 +440,20 @@ void scheduleFlush()
g_flushScheduled = true;
if( SCH_EDIT_FRAME* fr = schFrame() )
fr->CallAfter( []() { flushDiff(); } );
else
{
fr->CallAfter( []() {
COROUTINE<int, int> cor( []( int ) -> int
{
flushDiff();
return 0;
} );
cor.Call( 0 );
} );
}
else
{
flushDiff();
}
}
// ChangeSource: the native SCHEMATIC_LISTENER is just a trigger — the actual change set comes
@ -565,6 +632,125 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
s_applyingRemote = false;
}
// v2 items apply: removed by uuid; added/changed are an idempotent per-item upsert.
// Each blob is parsed through the clipboard-paste path — LoadContent into a throwaway
// sheet (sch_editor_control.cpp Paste pattern) — then the loaded items are detached,
// matched by uuid against the live model (replace), lib-relinked for symbols, and
// committed. Runs inside the apply COROUTINE (see kicadCollabApplyItems).
void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
{
SCHEMATIC& sch = aFrame->Schematic();
s_applyingRemote = true;
SCH_COMMIT commit( aFrame );
bool staged = false;
for( const json& rid : aWire.value( "removed", json::array() ) )
{
SCH_SHEET_PATH path;
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) )
{
commit.Remove( item, path.LastScreen() );
staged = true;
}
}
auto upsert = [&]( const json& w )
{
std::string sexpr = w.value( "sexpr", "" );
if( sexpr.find_first_not_of( " \t\r\n" ) == std::string::npos )
return;
// Parse into a throwaway sheet exactly like clipboard paste does. The screen
// is heap-allocated and owned by the sheet (freed with it).
SCH_SHEET tempSheet;
SCH_SCREEN* tempScreen = new SCH_SCREEN( &sch );
tempSheet.SetScreen( tempScreen );
STRING_LINE_READER reader( sexpr, wxT( "collab-items" ) );
SCH_IO_KICAD_SEXPR plugin;
try
{
plugin.LoadContent( reader, &tempSheet );
}
catch( ... )
{
EM_ASM( { console.log( "[collab] eeschema applyItems: blob parse failed" ); } );
return;
}
// Resolve a symbol's LIB_SYMBOL: prefer the blob's own (lib_symbols …) cache
// (a clipboard-style blob carries it), else the live screen's — peers share
// the same document so the definition is normally already present.
auto findLib = [&]( SCH_SYMBOL* aSym ) -> LIB_SYMBOL*
{
wxString lookup = aSym->GetLibId().Format().wx_str();
if( !aSym->UseLibIdLookup() )
lookup = aSym->GetSchSymbolLibraryName();
auto& tlibs = tempScreen->GetLibSymbols();
auto ti = tlibs.find( lookup );
if( ti != tlibs.end() )
return new LIB_SYMBOL( *ti->second );
auto& libs = aFrame->GetScreen()->GetLibSymbols();
auto li = libs.find( lookup );
if( li != libs.end() )
return new LIB_SYMBOL( *li->second );
return nullptr;
};
std::vector<SCH_ITEM*> loaded;
for( SCH_ITEM* item : tempScreen->Items() )
loaded.push_back( item );
for( SCH_ITEM* item : loaded )
{
tempScreen->Remove( item ); // detach: tempSheet's dtor must not free it
SCH_SHEET_PATH path;
if( SCH_ITEM* existing = sch.ResolveItem( item->m_Uuid, &path, /*allowNull*/ true ) )
commit.Remove( existing, path.LastScreen() );
if( item->Type() == SCH_SYMBOL_T )
{
auto* sym = static_cast<SCH_SYMBOL*>( item );
if( LIB_SYMBOL* lib = findLib( sym ) )
sym->SetLibSymbol( lib );
}
item->SetParent( &sch );
commit.Add( item, aFrame->GetScreen() );
staged = true;
}
};
for( const json& w : aWire.value( "added", json::array() ) )
upsert( w );
for( const json& w : aWire.value( "changed", json::array() ) )
upsert( w );
if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) );
// Fold the applied state into the baseline so the post-apply listener flush
// doesn't re-broadcast it as a local diff (echo).
rebaseline();
s_applyingRemote = false;
}
// Test/PoC move (the SCH_COMMIT body for kicadCollabTestMoveFirst, deferred via CallAfter).
void collabTestMove( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem, SCH_SCREEN* aScreen, int aDx,
int aDy )
@ -634,6 +820,68 @@ std::string kicadCollabSnapshot()
}
// JS → C++, v2 items wire. Same CallAfter + COROUTINE context as kicadCollabApply
// (LoadContent + SCH_COMMIT must run where native edits run).
void kicadCollabApplyItems( std::string aJson )
{
json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( wire.is_discarded() )
return;
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
fr->CallAfter( [fr, wire]() {
COROUTINE<int, int> cor( [fr, wire]( int ) -> int
{
doApplyItems( fr, wire );
return 0;
} );
cor.Call( 0 );
} );
}
// 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.
std::string kicadCollabSnapshotItems()
{
SCH_EDIT_FRAME* fr = schFrame();
json added = json::array();
if( fr )
{
ensureBridge();
std::set<std::string> seen;
for( const SCH_SHEET_PATH& path : fr->Schematic().Hierarchy() )
{
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 } } );
}
}
rebaseline();
}
return json{ { "added", added }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
}
// Test/PoC helper: move the first schematic item by (dx,dy) IU via a real SCH_COMMIT,
// firing the listener — a deterministic local edit for the two-tab demo / e2e.
// Returns the moved item's uuid.
@ -730,6 +978,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
// Yjs collaborative bridge entry points (same contract as pl_editor).
function("kicadCollabApply", &kicadCollabApply);
function("kicadCollabSnapshot", &kicadCollabSnapshot);
// v2 items bridge: per-item s-expr payloads (ysync 0008).
function("kicadCollabApplyItems", &kicadCollabApplyItems);
function("kicadCollabSnapshotItems", &kicadCollabSnapshotItems);
function("kicadCollabTestMoveFirst", &kicadCollabTestMoveFirst);
function("kicadCollabGetPos", &kicadCollabGetPos);
}

View file

@ -314,6 +314,29 @@ BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlob )
return found;
}
// Wrap a BARE item s-expr (e.g. one rendered from the Y.Doc Slot body) in the fake
// `(kicad_pcb …)` envelope CLIPBOARD_IO's parser requires for non-footprint items. The
// envelope carries the LIVE board's layer table so the item's layer names resolve — peers
// in a collab session share the same board, so names map 1:1. (Peer-emitted blobs already
// arrive enveloped by blobForItem; this is only for bare payloads.)
std::string wrapInBoardEnvelope( BOARD& aBoard, const std::string& aItemSexpr )
{
std::string s = "(kicad_pcb (version " + std::to_string( SEXPR_BOARD_FILE_VERSION )
+ ") (generator \"pcbnew\") (layers";
for( PCB_LAYER_ID id : aBoard.GetEnabledLayers().Seq() )
{
const char* type = IsCopperLayer( id ) ? LAYER::ShowType( aBoard.GetLayerType( id ) )
: "user";
s += " (" + std::to_string( (int) id ) + " \""
+ std::string( aBoard.GetLayerName( id ).utf8_str() ) + "\" " + type + ")";
}
s += ") " + aItemSexpr + ")";
return s;
}
// Construct a new BOARD_ITEM from a delta item (for `added`), with the delta's uuid (m_Uuid is
// const → const_cast, exactly as the s-expr parser does). PCB_TRACK segments reconstruct natively
// from their fields (cheap, trap-free); every other type goes through the s-expr clipboard blob
@ -444,6 +467,18 @@ void emit( const json& aDelta )
}, s.c_str() );
}
// v2 "items" wire emit (ysync 0008): per-item s-expr blobs instead of decomposed
// scalars. A JS runtime registers window.kicadCollab.onItems to opt in; both wires
// are emitted side by side until the scalar path is retired.
void emitItems( const json& aWire )
{
std::string s = aWire.dump();
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onItems )
window.kicadCollab.onItems( UTF8ToString( $0 ) );
}, s.c_str() );
}
// ── Emit via post-settle snapshot diff (mirrors eeschema 0007) ───────────────────────────────
//
// A local edit is one BOARD_COMMIT::Push that fires the listener callbacks synchronously and
@ -495,12 +530,47 @@ void flushDiff()
json added = json::array(), changed = json::array(), removed = json::array();
// v2 items wire (per-item s-expr blobs): each touched id LIFTS to its root live
// item (footprint children → the footprint), deduped, and the root is blobbed
// whole — so containment travels and a child edit re-sends its parent subtree.
json wAdded = json::array(), wChanged = json::array();
std::set<std::string> wDone;
auto liftBlob = [&]( const std::string& id, json& aArr )
{
BOARD_ITEM* live =
board->ResolveItem( KIID( wxString::FromUTF8( id.c_str() ) ), /*allowNull*/ true );
if( !live )
return;
bool lifted = false;
if( FOOTPRINT* fp = live->GetParentFootprint() )
{
live = fp;
lifted = true;
}
std::string rootId = toUtf8( live->m_Uuid.AsString() );
if( !wDone.insert( rootId ).second )
return;
json w = json{ { "sexpr", blobForItem( board, live ) }, { "parent", nullptr } };
// A lifted child means its (pre-existing) parent's CONTENT changed.
( lifted ? wChanged : aArr ).push_back( w );
};
for( const auto& [id, j] : cur )
{
auto it = g_baseline.find( id );
if( it == g_baseline.end() )
{
liftBlob( id, wAdded );
// Skip a newly-added footprint's text CHILDREN: the footprint's own add carries them,
// and emitting a lone child would (for a field) wrap it in a spurious footprint. (A
// child-only add onto an existing footprint is therefore not synced yet — rare.)
@ -524,6 +594,7 @@ void flushDiff()
}
else if( it->second != j )
{
liftBlob( id, wChanged );
changed.push_back( j );
}
}
@ -537,11 +608,18 @@ void flushDiff()
g_baseline = std::move( cur );
if( !added.empty() || !changed.empty() || !removed.empty() )
{
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } );
}
}
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
// turn) into a single post-settle diff.
// flushDiff runs inside a COROUTINE: the v2 items emit serializes ROOT items via
// CLIPBOARD_IO Format (blobForItem), whose virtual dispatch is only reliable on the
// libcontext fiber stack — on the bare CallAfter stack it can trap and silently kill
// the whole flush, legacy emit included (same lesson as doApply / eeschema 0007).
void scheduleFlush()
{
if( g_flushScheduled )
@ -550,9 +628,20 @@ void scheduleFlush()
g_flushScheduled = true;
if( PCB_EDIT_FRAME* fr = pcbFrame() )
fr->CallAfter( []() { flushDiff(); } );
else
{
fr->CallAfter( []() {
COROUTINE<int, int> cor( []( int ) -> int
{
flushDiff();
return 0;
} );
cor.Call( 0 );
} );
}
else
{
flushDiff();
}
}
// ChangeSource: the native BOARD_LISTENER is just a trigger — the actual change set comes from
@ -672,6 +761,84 @@ void doApply( PCB_EDIT_FRAME* aFrame, const json& aDelta )
s_applyingRemote = false;
}
// v2 items apply: removed by uuid; added/changed are an idempotent per-item upsert —
// parse the blob (wrapping bare non-footprint payloads in a live-board envelope),
// then replace any existing item sharing the parsed uuid. Runs inside the apply
// COROUTINE (see kicadCollabApplyItems), via BOARD_COMMIT like every remote op.
void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
{
BOARD* board = aFrame->GetBoard();
s_applyingRemote = true;
BOARD_COMMIT commit( aFrame );
bool staged = false;
for( const json& rid : aWire.value( "removed", json::array() ) )
{
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
if( BOARD_ITEM* item = board->ResolveItem( id, /*allowNullptr*/ true ) )
{
// A child uuid in `removed` is covered by its parent's replace/remove.
if( item->GetParentFootprint() )
continue;
commit.Remove( item );
staged = true;
}
}
auto upsert = [&]( const json& w )
{
std::string sexpr = w.value( "sexpr", "" );
size_t p = sexpr.find_first_not_of( " \t\r\n" );
if( p == std::string::npos )
return;
std::string trimmed = sexpr.substr( p );
// Peer-emitted blobs are already enveloped (or a bare footprint, which the
// parser accepts top-level); bare Y.Doc-rendered items need the envelope.
if( trimmed.rfind( "(kicad_pcb", 0 ) != 0 && trimmed.rfind( "(footprint", 0 ) != 0 )
trimmed = wrapInBoardEnvelope( *board, trimmed );
BOARD_ITEM* parsed = makeFromBlob( *board, trimmed );
if( !parsed )
{
EM_ASM( { console.log( "[collab] pcbnew applyItems: blob parse failed" ); } );
return;
}
if( BOARD_ITEM* existing = board->ResolveItem( parsed->m_Uuid, /*allowNullptr*/ true ) )
{
// Replacing by uuid; a (shouldn't-happen) child match replaces its parent.
if( FOOTPRINT* fp = existing->GetParentFootprint() )
existing = fp;
commit.Remove( existing );
}
commit.Add( parsed );
staged = true;
};
for( const json& w : aWire.value( "added", json::array() ) )
upsert( w );
for( const json& w : aWire.value( "changed", json::array() ) )
upsert( w );
if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) );
// Fold the applied state into the baseline so the post-apply listener flush
// doesn't re-broadcast it as a local diff (echo).
rebaseline();
s_applyingRemote = false;
}
// Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst). Run inside a COROUTINE by
// the caller: `BOARD_ITEM::Move` is virtual, and dispatched off the app main stack (a bare
// CallAfter) it hits the asyncify call_indirect mis-dispatch and silently NO-OPS — the commit
@ -722,6 +889,31 @@ void kicadCollabApply( std::string aJson )
}
// JS → C++, v2 items wire. Same CallAfter + COROUTINE context as kicadCollabApply
// (the blob parse + commit must run where native edits run — see above).
void kicadCollabApplyItems( std::string aJson )
{
json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( wire.is_discarded() )
return;
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return;
fr->CallAfter( [fr, wire]() {
COROUTINE<int, int> cor( [fr, wire]( int ) -> int
{
doApplyItems( fr, wire );
return 0;
} );
cor.Call( 0 );
} );
}
// JS pull of the full current model as an all-"added" delta (seed/baseline). Also registers the
// change listener on first call.
std::string kicadCollabSnapshot()
@ -744,6 +936,35 @@ std::string kicadCollabSnapshot()
}
// JS pull of the full current model as an all-"added" v2 items wire: one blob per ROOT
// item (a footprint's blob embeds its children — the TS side flattens). Registers the
// listener + rebaselines exactly like kicadCollabSnapshot.
std::string kicadCollabSnapshotItems()
{
BOARD* board = ensureBridge();
json added = json::array();
if( board )
{
auto push = [&]( BOARD_ITEM* item )
{
added.push_back( json{ { "sexpr", blobForItem( board, item ) }, { "parent", nullptr } } );
};
for( FOOTPRINT* fp : board->Footprints() ) push( fp );
for( PCB_TRACK* t : board->Tracks() ) push( t );
for( ZONE* z : board->Zones() ) push( z );
for( BOARD_ITEM* d : board->Drawings() ) push( d );
}
rebaseline();
return json{ { "added", added }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
}
// Programmatically save the in-memory board to a .kicad_pcb file, without driving
// the Save As dialog — pcbnew's analogue of pl_editor's kicadSaveDrawingSheet.
// Serializes exactly what the editor has loaded via the same writer eeschema/pcbnew
@ -922,6 +1143,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).
function("kicadCollabApply", &kicadCollabApply);
function("kicadCollabSnapshot", &kicadCollabSnapshot);
// v2 items bridge: per-item s-expr payloads (ysync 0008).
function("kicadCollabApplyItems", &kicadCollabApplyItems);
function("kicadCollabSnapshotItems", &kicadCollabSnapshotItems);
function("kicadCollabTestMoveFirst", &kicadCollabTestMoveFirst);
function("kicadCollabGetPos", &kicadCollabGetPos);
function("kicadCollabTestItemBlob", &kicadCollabTestItemBlob);

View file

@ -22,6 +22,7 @@
#include <font/text_attributes.h>
#include <drawing_sheet/ds_data_model.h>
#include <drawing_sheet/ds_data_item.h>
#include <drawing_sheet/ds_file_versions.h>
using namespace emscripten;
using json = nlohmann::json;
@ -182,6 +183,26 @@ void emit( const json& aDelta )
}, s.c_str() );
}
// v2 "items" wire emit (ysync 0008): per-item s-expr blobs instead of decomposed
// scalars. A JS runtime registers window.kicadCollab.onItems to opt in; both wires
// are emitted side by side until the scalar path is retired.
void emitItems( const json& aWire )
{
std::string s = aWire.dump();
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onItems )
window.kicadCollab.onItems( UTF8ToString( $0 ) );
}, s.c_str() );
}
// One items-wire entry for a live item: its native blob (SaveInString — a
// self-contained `(kicad_wks …)` envelope; the TS side unwraps). pl_editor's
// model is flat, so parent is always null.
json wireItemFor( DS_DATA_ITEM* aItem )
{
return json{ { "sexpr", itemBlob( aItem ) }, { "parent", nullptr } };
}
// Apply scalar fields from json onto an existing scalar item (text/segment/rect).
void applyFields( DS_DATA_ITEM* aItem, const json& j )
{
@ -331,14 +352,30 @@ extern "C" void kicadCollabOnModify()
json changed = json::array();
json removed = json::array();
// v2 items wire (per-item s-expr blobs), built from the same diff.
json wAdded = json::array();
json wChanged = json::array();
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
for( const auto& [id, j] : cur )
{
auto prev = s_snapshot.find( id );
if( prev == s_snapshot.end() )
{
added.push_back( j );
if( DS_DATA_ITEM* item = findByUuid( model, id ) )
wAdded.push_back( wireItemFor( item ) );
}
else if( prev->second != j )
{
changed.push_back( j );
if( DS_DATA_ITEM* item = findByUuid( model, id ) )
wChanged.push_back( wireItemFor( item ) );
}
}
for( const auto& [id, j] : s_snapshot )
@ -353,6 +390,7 @@ extern "C" void kicadCollabOnModify()
return;
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } );
}
@ -374,6 +412,107 @@ std::string kicadCollabSnapshot()
}
// ── v2 "items" bridge: per-item s-expr (ysync 0008 Stage C) ─────────────────────────
//
// Same contract as the scalar bridge but the payload is each item's full native
// s-expr blob: { added: [{sexpr, parent}], changed: [...], removed: [uuid] }.
// Blobs are SaveInString envelopes; apply accepts both enveloped and bare items.
// JS pull of the full current model as an all-"added" items wire. Rebaselines the
// differ exactly like kicadCollabSnapshot, so a v2 consumer gets no echo either.
std::string kicadCollabSnapshotItems()
{
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
json added = json::array();
for( DS_DATA_ITEM* item : model.GetItems() )
added.push_back( wireItemFor( item ) );
s_snapshot = snapshotMap();
return json{ { "added", added }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
}
// JS → C++. Apply a remote items wire: removed by uuid; added/changed are an
// idempotent per-item upsert — append the blob through the normal parser, then
// drop any pre-existing item that shares an appended uuid (replace-by-uuid).
void kicadCollabApplyItems( std::string aJson )
{
json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( wire.is_discarded() )
return;
s_applyingRemote = true;
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
for( const json& rid : wire.value( "removed", json::array() ) )
{
if( DS_DATA_ITEM* item = findByUuid( model, rid.get<std::string>() ) )
{
model.Remove( item );
delete item;
}
}
auto upsert = [&]( const json& w )
{
std::string sexpr = w.value( "sexpr", "" );
if( sexpr.empty() )
return;
// Bare items (e.g. rendered from the Y.Doc Slot body) get the envelope the
// drawing-sheet parser requires; peer-emitted blobs already carry it.
if( sexpr.rfind( "(kicad_wks", 0 ) != 0 )
{
sexpr = "(kicad_wks (version " + std::to_string( SEXPR_WORKSHEET_FILE_VERSION )
+ ") (generator \"pl_editor\") " + sexpr + ")";
}
// Snapshot the pre-append item pointers, then append through the parser.
std::vector<DS_DATA_ITEM*> before = model.GetItems();
model.SetPageLayout( sexpr.c_str(), /*aAppend*/ true, wxT( "collab-items" ) );
// Replace-by-uuid: drop any pre-existing item sharing a newly appended uuid.
// Work on pointer snapshots — model.Remove() mutates the live vector.
std::vector<DS_DATA_ITEM*> appended( model.GetItems().begin() + before.size(),
model.GetItems().end() );
for( DS_DATA_ITEM* neu : appended )
{
for( DS_DATA_ITEM* old : before )
{
if( old->m_Uuid == neu->m_Uuid )
{
model.Remove( old );
delete old;
break;
}
}
}
};
for( const json& w : wire.value( "added", json::array() ) )
upsert( w );
for( const json& w : wire.value( "changed", json::array() ) )
upsert( w );
// Rebase the differ on the post-apply state so our own mutations aren't echoed,
// then rebuild the GAL view from the model.
s_snapshot = snapshotMap();
if( EDA_DRAW_FRAME* fr = topFrame() )
fr->HardRedraw();
s_applyingRemote = false;
}
// Test/PoC helper: perform a genuine local text insert (the same model mutation a
// PL_DRAWING_TOOLS::PlaceItem(DS_TEXT) UI click produces — 0002 §text-insert path) and
// fire OnModify, so the differ emits an `added` delta. Lets a two-tab demo / e2e create
@ -405,6 +544,9 @@ EMSCRIPTEN_BINDINGS(pl_editor) {
// Yjs collaborative bridge entry points.
function("kicadCollabApply", &kicadCollabApply);
function("kicadCollabSnapshot", &kicadCollabSnapshot);
// v2 items bridge: per-item s-expr payloads (ysync 0008).
function("kicadCollabApplyItems", &kicadCollabApplyItems);
function("kicadCollabSnapshotItems", &kicadCollabSnapshotItems);
function("kicadCollabTestAddText", &kicadCollabTestAddText);
}
#endif

@ -1 +1 @@
Subproject commit cc9c73dba95188ad4a9d9d406c4868ac4c9fae14
Subproject commit 9cfea4184b007d2358978cbfd472d4a93a656c82