feat(collab): local-ops-only undo — remote applies skip the undo stack (ysync miss 09)

Ctrl+Z after a peer's edit no longer reverts (and re-broadcasts) the
peer's work, and the adopt undo-bomb is gone:

- doApply/doApplyItems (both editors) Push with SKIP_UNDO; the emit path
  is unaffected (suppression keys off s_applyingRemote, not undo).
- With SKIP_UNDO no picker owns removed items — the bindings free them
  after Push (explicit removals + upsert's remove-before-re-add; fields
  excluded: CHT_REMOVE hides them, parent keeps ownership). Freeing stays
  out of the fork commit classes so DRC's SKIP_UNDO callers can't
  double-free.
- Test hooks kicadCollabTestUndo/UndoDepth, registered per-editor AND in
  the kicad_editor dispatcher (merged image compiles out per-app
  registrations).
- kicad pointer: eeschema UUID undo guard + SKIP_UNDO connectivity split
  + quiet stale-entry drop (ca8877324c).
- tests/kicad/collab-undo.spec.ts: 5 scenarios (no undo entry from remote
  applies; selective undo; stranded replaced/deleted entries) — 5/5, plus
  collab/ysync regression 30 pass.
- docs: ysync-review 20 fix record; 09 marked FIXED; overview indexed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ejJEvS7ogef2o9gVTXjmp
This commit is contained in:
Gergő Törcsvári 2026-07-08 11:09:26 +02:00
commit 574284c486
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 528 additions and 6 deletions

View file

@ -1064,6 +1064,11 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
SCH_COMMIT commit( aFrame );
bool staged = false;
// With SKIP_UNDO no undo picker takes ownership of removed items; the commit
// detaches them from the screen and we free them after Push. Fields are hidden
// by CHT_REMOVE, not detached (still owned by their parent), so never freed.
std::vector<SCH_ITEM*> removedItems;
for( const json& rid : aDelta.value( "removed", json::array() ) )
{
SCH_SHEET_PATH path;
@ -1072,6 +1077,10 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) )
{
commit.Remove( item, path.LastScreen() );
if( item->Type() != SCH_FIELD_T )
removedItems.push_back( item );
staged = true;
}
}
@ -1120,8 +1129,14 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
}
}
// SKIP_UNDO: a peer's edit must never land on this editor's undo stack — Ctrl+Z
// would revert (and re-broadcast) the peer's work. Undo is local-ops-only; stale
// local undo entries are dropped/re-resolved by UUID at undo time (miss 09).
if( staged )
commit.Push( wxT( "Collaborative edit" ) );
commit.Push( wxT( "Collaborative edit" ), SKIP_UNDO );
for( SCH_ITEM* item : removedItems )
delete item;
// The applied remote changes (and any connectivity cleanup they triggered) are now the
// shared state — fold them into the baseline so the post-apply listener flush doesn't
@ -1146,6 +1161,10 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
std::vector<std::string> touched; // uuids this apply acts on (targeted rebaseline)
// Owned by nobody once the SKIP_UNDO commit detaches them — freed after Push
// (fields are hidden, not detached, so excluded). See doApply.
std::vector<SCH_ITEM*> removedItems;
for( const json& rid : aWire.value( "removed", json::array() ) )
{
SCH_SHEET_PATH path;
@ -1156,6 +1175,10 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) )
{
commit.Remove( item, path.LastScreen() );
if( item->Type() != SCH_FIELD_T )
removedItems.push_back( item );
staged = true;
}
}
@ -1223,8 +1246,13 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
SCH_SHEET_PATH path;
if( SCH_ITEM* existing = sch.ResolveItem( item->m_Uuid, &path, /*allowNull*/ true ) )
{
commit.Remove( existing, path.LastScreen() );
if( existing->Type() != SCH_FIELD_T )
removedItems.push_back( existing );
}
if( item->Type() == SCH_SYMBOL_T )
{
auto* sym = static_cast<SCH_SYMBOL*>( item );
@ -1245,8 +1273,12 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
for( const json& w : aWire.value( "changed", json::array() ) )
upsert( w );
// SKIP_UNDO: remote applies never land on the local undo stack (see doApply).
if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) );
commit.Push( wxT( "Collaborative edit (items)" ), SKIP_UNDO );
for( SCH_ITEM* item : removedItems )
delete item;
// Fold ONLY the applied uuids into the baseline (echo suppression), then flush:
// anything else that now differs — a concurrent local edit, the connectivity
@ -1505,6 +1537,35 @@ bool schCollabTestRotateItem( std::string aId, double aDeg )
return true;
}
// Run Edit>Undo exactly like the UI would (main-loop + fiber stack) — miss 09:
// exercises the local-ops-only undo policy and the stale-picker UUID guard.
bool schCollabTestUndo()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return false;
fr->CallAfter( [fr]() {
COROUTINE<int, int> cor( [fr]( int ) -> int
{
fr->GetToolManager()->RunAction( ACTIONS::undo );
return 0;
} );
cor.Call( 0 );
} );
return true;
}
// Local undo stack depth — remote applies must not grow it (miss 09).
int schCollabTestUndoDepth()
{
SCH_EDIT_FRAME* fr = schFrame();
return fr ? fr->GetUndoCommandCount() : -1;
}
// Set a symbol's Value field text — bug 04: fields live inside the symbol (not
// in screen->Items()) and the symbol json carries no field text, so the most
// common schematic edit after moving things never syncs.
@ -2116,6 +2177,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
// ysync-review repro hooks shared with pcbnew (dispatched when merged).
function("kicadCollabTestRemoveItem", &schCollabTestRemoveItem);
function("kicadCollabTestRotateItem", &schCollabTestRotateItem);
function("kicadCollabTestUndo", &schCollabTestUndo);
function("kicadCollabTestUndoDepth", &schCollabTestUndoDepth);
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
function("kicadCollabPresenceStart", &schCollabPresenceStart);
function("kicadCollabSetRemote", &schCollabSetRemote);

View file

@ -45,6 +45,9 @@ std::string pcbCollabTestMoveFirst( int aDx, int aDy );
std::string pcbCollabGetPos( std::string aId );
bool pcbCollabTestRemoveItem( std::string aId );
bool pcbCollabTestRotateItem( std::string aId, double aDeg );
// Collab-aware undo (ysync miss 09).
bool pcbCollabTestUndo();
int pcbCollabTestUndoDepth();
// Presence (collab-presence 0002) + comment pins/panning (0005).
void pcbCollabPresenceStart();
void pcbCollabSetRemote( std::string aJson );
@ -74,6 +77,9 @@ std::string schCollabTestMoveFirst( int aDx, int aDy );
std::string schCollabGetPos( std::string aId );
bool schCollabTestRemoveItem( std::string aId );
bool schCollabTestRotateItem( std::string aId, double aDeg );
// Collab-aware undo (ysync miss 09).
bool schCollabTestUndo();
int schCollabTestUndoDepth();
// Presence (collab-presence 0003 — eeschema counterparts) + pins (0005).
void schCollabPresenceStart();
void schCollabSetRemote( std::string aJson );
@ -171,6 +177,16 @@ static bool collabTestRotateItem( std::string aId, double aDeg )
: schCollabTestRotateItem( aId, aDeg );
}
static bool collabTestUndo()
{
return pcbEditorActive() ? pcbCollabTestUndo() : schCollabTestUndo();
}
static int collabTestUndoDepth()
{
return pcbEditorActive() ? pcbCollabTestUndoDepth() : schCollabTestUndoDepth();
}
// Presence shims (collab-presence 0002 pcbnew / 0003 eeschema): route to the live
// editor's implementation, same pattern as the collab bridge shims above.
static void collabPresenceStart()
@ -271,6 +287,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
// endpoint, field text — flow from the per-editor blocks unchanged).
function("kicadCollabTestRemoveItem", &collabTestRemoveItem);
function("kicadCollabTestRotateItem", &collabTestRotateItem);
// Collab-aware undo (ysync miss 09).
function("kicadCollabTestUndo", &collabTestUndo);
function("kicadCollabTestUndoDepth", &collabTestUndoDepth);
// Presence (collab-presence 0002/0003) + comment pins/panning (0005).
function("kicadCollabPresenceStart", &collabPresenceStart);
function("kicadCollabSetRemote", &collabSetRemote);

View file

@ -914,6 +914,10 @@ void doApply( PCB_EDIT_FRAME* aFrame, const json& aDelta )
BOARD_COMMIT commit( aFrame );
bool staged = false;
// With SKIP_UNDO no undo picker takes ownership of removed items; the commit
// detaches them from the board and we free them after Push.
std::vector<BOARD_ITEM*> removedItems;
for( const json& rid : aDelta.value( "removed", json::array() ) )
{
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
@ -928,6 +932,7 @@ void doApply( PCB_EDIT_FRAME* aFrame, const json& aDelta )
continue;
commit.Remove( item );
removedItems.push_back( item );
staged = true;
}
}
@ -963,8 +968,14 @@ void doApply( PCB_EDIT_FRAME* aFrame, const json& aDelta )
}
}
// SKIP_UNDO: a peer's edit must never land on this editor's undo stack — Ctrl+Z
// would revert (and re-broadcast) the peer's work. Undo is local-ops-only; stale
// local undo entries are dropped/re-resolved by UUID at undo time (miss 09).
if( staged )
commit.Push( wxT( "Collaborative edit" ) );
commit.Push( wxT( "Collaborative edit" ), SKIP_UNDO );
for( BOARD_ITEM* item : removedItems )
delete item;
// The applied remote changes (and any connectivity cleanup they triggered) are now the
// shared state — fold them into the baseline so the post-apply listener flush doesn't
@ -988,6 +999,9 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
std::vector<std::string> touched; // root uuids this apply acts on (targeted rebaseline)
// Owned by nobody once the SKIP_UNDO commit detaches them — freed after Push.
std::vector<BOARD_ITEM*> removedItems;
std::set<std::string> removedIds;
for( const json& rid : aWire.value( "removed", json::array() ) )
@ -1012,6 +1026,12 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
}
commit.Remove( item );
// A bare child "removal" of a PCB_FIELD_T is a hide, not a detach —
// the field stays owned by its parent footprint.
if( item->Type() != PCB_FIELD_T )
removedItems.push_back( item );
staged = true;
}
}
@ -1046,6 +1066,7 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
existing = fp;
commit.Remove( existing );
removedItems.push_back( existing );
}
touched.push_back( toUtf8( parsed->m_Uuid.AsString() ) );
@ -1059,8 +1080,12 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
for( const json& w : aWire.value( "changed", json::array() ) )
upsert( w );
// SKIP_UNDO: remote applies never land on the local undo stack (see doApply).
if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) );
commit.Push( wxT( "Collaborative edit (items)" ), SKIP_UNDO );
for( BOARD_ITEM* item : removedItems )
delete item;
// Fold ONLY the applied uuids into the baseline (echo suppression), then flush:
// anything else that now differs — a concurrent local edit, cleanup this apply's
@ -2235,6 +2260,35 @@ static BOARD_ITEM* testResolve( PCB_EDIT_FRAME* aFrame, const std::string& aId )
// Delete an item by uuid. With a footprint CHILD uuid this is the bug-03
// sending half (the UI's fp-text delete): the emit must lift to a parent
// re-blob; today it goes out as a bare child removal.
// Run Edit>Undo exactly like the UI would (main-loop + fiber stack) — miss 09:
// exercises the local-ops-only undo policy and the stale-picker UUID guard.
bool pcbCollabTestUndo()
{
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return false;
fr->CallAfter( [fr]() {
COROUTINE<int, int> cor( [fr]( int ) -> int
{
fr->GetToolManager()->RunAction( ACTIONS::undo );
return 0;
} );
cor.Call( 0 );
} );
return true;
}
// Local undo stack depth — remote applies must not grow it (miss 09).
int pcbCollabTestUndoDepth()
{
PCB_EDIT_FRAME* fr = pcbFrame();
return fr ? fr->GetUndoCommandCount() : -1;
}
bool pcbCollabTestRemoveItem( std::string aId )
{
PCB_EDIT_FRAME* fr = pcbFrame();
@ -2436,6 +2490,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabGetPos", &pcbCollabGetPos);
// ysync-review repro hooks shared with eeschema (dispatched when merged).
function("kicadCollabTestRemoveItem", &pcbCollabTestRemoveItem);
function("kicadCollabTestUndo", &pcbCollabTestUndo);
function("kicadCollabTestUndoDepth", &pcbCollabTestUndoDepth);
function("kicadCollabTestRotateItem", &pcbCollabTestRotateItem);
// Presence (collab-presence 0002) — shared names; eeschema's counterparts land
// with 0003 (the merged image dispatches pcb-only until then).