fix(eeschema): collab drag no longer drops segments — batch one edit into one delta

A single SCH_COMMIT::Push fires OnItemsAdded/Removed/Changed separately and
synchronously, then RecalculateConnections once. COLLAB_LISTENER emitted each
category as its own delta, so the peer applied one atomic edit as three separate
commits, each with its own connectivity recompute. On a large connected drag the
junction at the wires' new crossing (added) was applied before the wires moved
(changed) -> dangling junction -> the peer's cleanup deleted it (lost segments).

COLLAB_LISTENER now buffers added/changed/removed (serialized in each synchronous
callback) and flushes one combined delta after Push returns, coalesced via
CallAfter. doApply applies it atomically removed->changed->added in a single
commit with one recompute, so the junction survives. Verified two-tab: a G-drag
of U1A that previously left the peer at 74 items / 7 junctions now emits one
delta {a:1,c:4,r:1} and both tabs converge at 75 / 8.

Root-repo only; kicad and wxwidgets untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-05 12:42:58 +02:00
commit f2a6deaf7b
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
2 changed files with 79 additions and 17 deletions

View file

@ -286,17 +286,30 @@ void emit( const json& aDelta )
// ChangeSource: native SCHEMATIC_LISTENER. SCH_COMMIT::Push fires these in bulk for
// every local edit (move, add, remove, …) — that's our emit trigger.
//
// CRUCIAL: one local edit (a single SCH_COMMIT::Push) calls OnItemsAdded, OnItemsRemoved
// and OnItemsChanged *separately and synchronously*, then runs RecalculateConnections once
// (sch_commit.cpp). Emitting each category as its own delta makes the peer apply them as
// THREE separate commits, each followed by its own connectivity recompute — so an item that
// only makes sense in the final state is mis-handled mid-sequence. Concretely, a connected
// drag adds a junction at the wires' new crossing AND moves those wires; if the junction
// (added) is applied before the wires (changed), the peer sees a dangling junction and
// connectivity cleanup deletes it → the peer permanently loses it (the "lost segments on a
// big drag" divergence). Fix: buffer all three categories and emit ONE combined delta after
// Push returns (coalesced via CallAfter), so the peer applies it atomically in a single
// SCH_COMMIT — doApply orders it removed→changed→added, with one recompute at the end, so the
// junction is added after its wires are in place and survives.
class COLLAB_LISTENER : public SCHEMATIC_LISTENER
{
public:
void OnSchItemsAdded( SCHEMATIC&, std::vector<SCH_ITEM*>& aItems ) override
{
emitItems( "added", aItems );
accumulate( m_added, aItems );
}
void OnSchItemsChanged( SCHEMATIC&, std::vector<SCH_ITEM*>& aItems ) override
{
emitItems( "changed", aItems );
accumulate( m_changed, aItems );
}
void OnSchItemsRemoved( SCHEMATIC&, std::vector<SCH_ITEM*>& aItems ) override
@ -304,35 +317,60 @@ public:
if( s_applyingRemote )
return;
json removed = json::array();
for( SCH_ITEM* item : aItems )
removed.push_back( toUtf8( item->m_Uuid.AsString() ) );
m_removed.push_back( toUtf8( item->m_Uuid.AsString() ) );
if( !removed.empty() )
emit( json{ { "added", json::array() }, { "changed", json::array() },
{ "removed", removed } } );
scheduleFlush();
}
private:
void emitItems( const char* aKey, std::vector<SCH_ITEM*>& aItems )
// Serialize the items NOW (they're valid during the synchronous callback); the buffered
// json is emitted later by flush().
void accumulate( json& aBucket, std::vector<SCH_ITEM*>& aItems )
{
if( s_applyingRemote )
return;
json arr = json::array();
for( SCH_ITEM* item : aItems )
arr.push_back( itemToJson( item ) );
aBucket.push_back( itemToJson( item ) );
if( arr.empty() )
scheduleFlush();
}
// All three category callbacks for one Push fire synchronously before control returns to
// the main loop, so a single CallAfter run after Push drains them into one delta. (Several
// commits in the same loop turn coalesce into one delta — harmless for the CRDT.)
void scheduleFlush()
{
if( m_flushScheduled )
return;
json d = { { "added", json::array() }, { "changed", json::array() },
{ "removed", json::array() } };
d[aKey] = arr;
emit( d );
m_flushScheduled = true;
if( SCH_EDIT_FRAME* fr = schFrame() )
fr->CallAfter( [this]() { flush(); } );
else
flush();
}
void flush()
{
m_flushScheduled = false;
if( m_added.empty() && m_changed.empty() && m_removed.empty() )
return;
emit( json{ { "added", m_added }, { "changed", m_changed }, { "removed", m_removed } } );
m_added = json::array();
m_changed = json::array();
m_removed = json::array();
}
json m_added = json::array();
json m_changed = json::array();
json m_removed = json::array();
bool m_flushScheduled = false;
};
COLLAB_LISTENER* g_listener = nullptr;