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

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