feat(collab): selection soft-locks — remote-selected items can't be dragged locally (collab-presence 0007)

While a peer has an item selected, local users can still select it for
inspection but move/drag/rotate/delete skip it with an infobar naming the
holder (native locked-item UX; enforced via the fork's PCBJAM_REMOTE_LOCK
query — kicad 81f9cd80fd, the epic's first fork-touching phase). Overlapping
holds (both grabbed inside the awareness propagation window) tie-break
deterministically: lowest (user.id, clientID) keeps the item, every losing
client auto-releases it.

- lock-tiebreak.ts: pure policy — beats(), remoteLocks() (union of ALL other
  clients' selections incl. own user's other tabs, minus own-held-and-winning
  uuids so the winner isn't blocked mid-release), contestedReleases()
- presence.ts: clients() (per-client view, no user dedupe) + self(); FIX for
  a pre-existing flaky stack overflow — resolveCollision re-entered itself
  synchronously via its own patch's awareness 'change' and could ping-pong on
  stale same-user states (~1-in-3 unit runs); re-entrancy guard defers
  re-resolution to the next genuine delivery
- presence-kicad.ts: locks ride the kicadCollabSetRemote snapshot
  (`locks:[{uuid,name}]`); losing overlaps call kicadCollabReleaseSelection
- wasm bindings (both TUs + merged dispatch): g_locks map + fork query
  install; kicadCollabReleaseSelection (cancelInteractive only when a tool
  stack is live — bare ESC would clear the whole selection — then selective
  RemoveItemFromSel + infobar + forced re-emit); kicadCollabTestGetLocked
- tests: lock-tiebreak unit suite; presence-locks e2e for both editors (real
  move veto — pcbnew click+M hotkey since its default left-drag is
  rubber-band select, eeschema real drag — each with an unlocked control);
  two-tab tests/web/locks.spec.ts (lock propagation + deterministic tiebreak
  release + unlock on clear, passing vs real partykit)

Spec: docs/features/collab-presence/0007 (closed repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
This commit is contained in:
Gergő Törcsvári 2026-07-07 20:35:26 +02:00
commit c30bcd4531
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
14 changed files with 1221 additions and 7 deletions

View file

@ -53,7 +53,9 @@
#include <sch_screen.h>
#include <sch_sheet_path.h>
#include <schematic_settings.h>
#include <tool/actions.h>
#include <tool/coroutine.h>
#include <pcbjam_remote_lock.h>
#include "collab_presence_style.h"
#include <algorithm>
@ -720,6 +722,8 @@ struct PIN
std::vector<PEER> g_peers;
std::vector<PIN> g_pins;
// Remote soft-locks (collab-presence 0007) — see pcbnew_embind.cpp.
std::map<KIID, std::string> g_locks;
// Every visual knob (shapes, widths, alphas, label placement, color overrides)
// — see collab_presence_style.h; live-patched by kicadCollabSetStyle (tuner).
// eeschema ships its own defaults (hairline outline, subtler fill/cursor).
@ -1573,6 +1577,22 @@ void schCollabPresenceStart()
presence::g_started = true;
// Remote soft-locks (0007): let the selection/move tools consult the
// peers' live selections through the fork's process-global query.
PCBJAM_REMOTE_LOCK::SetQuery(
[]( const KIID& aId, wxString* aHolder ) -> bool
{
auto it = presence::g_locks.find( aId );
if( it == presence::g_locks.end() )
return false;
if( aHolder )
*aHolder = wxString::FromUTF8( it->second.c_str() );
return true;
} );
wxWindow* canvas = fr->GetCanvas();
canvas->Bind( wxEVT_MOTION, []( wxMouseEvent& e ) { presence::onMotion( e ); } );
@ -1633,7 +1653,19 @@ void schCollabSetRemote( std::string aJson )
peers.push_back( std::move( peer ) );
}
// Remote soft-locks (0007): `locks: [{uuid, name}]` — see pcbnew_embind.cpp.
std::map<KIID, std::string> locks;
for( const json& l : j.value( "locks", json::array() ) )
{
std::string uuid = l.is_object() ? l.value( "uuid", "" ) : "";
if( !uuid.empty() )
locks[ KIID( wxString::FromUTF8( uuid.c_str() ) ) ] = l.value( "name", "" );
}
presence::g_peers = std::move( peers );
presence::g_locks = std::move( locks );
schCollabPresenceStart();
presence::scheduleRedraw();
}
@ -1930,6 +1962,79 @@ std::string schCollabTestSelectComponent()
return toUtf8( target->m_Uuid.AsString() );
}
// JS → C++ (0007): tiebreak release — see pcbnew_embind.cpp for the design
// (cancel-interactive-if-a-tool-holds-them → selective unselect → infobar →
// forced re-emit).
void schCollabReleaseSelection( std::string aUuidsJson, std::string aHolder )
{
json j = json::parse( aUuidsJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() || !j.is_array() )
return;
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
std::vector<KIID> ids;
for( const json& u : j )
{
if( u.is_string() )
ids.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
if( ids.empty() )
return;
wxString holder = wxString::FromUTF8( aHolder.c_str() );
fr->CallAfter( [fr, ids, holder]() {
if( !fr->ToolStackIsEmpty() )
fr->GetToolManager()->RunAction( ACTIONS::cancelInteractive );
SCH_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<SCH_SELECTION_TOOL>();
if( !st )
return;
bool released = false;
for( const KIID& id : ids )
{
SCH_ITEM* item = fr->Schematic().ResolveItem( id, nullptr, /*allowNull*/ true );
if( item && item->IsSelected() )
{
st->RemoveItemFromSel( item );
released = true;
}
}
if( released )
{
fr->ShowInfoBarWarning( wxString::Format( _( "%s is editing this — released from "
"your selection." ),
holder ),
true );
}
schedulePresenceSelCheck();
} );
}
// Test probe (0007): the current remote soft-lock set as `[{uuid, name}]`.
std::string schCollabTestGetLocked()
{
json arr = json::array();
for( const auto& [id, name] : presence::g_locks )
arr.push_back( { { "uuid", toUtf8( id.AsString() ) }, { "name", name } } );
return arr.dump();
}
// Test helper: clear the selection through the tool + run the presence check.
bool schCollabTestClearSelection()
{
@ -2024,6 +2129,9 @@ EMSCRIPTEN_BINDINGS(eeschema) {
// Cross-app selection (0006).
function("kicadCollabGetSelectionFull", &schCollabGetSelectionFull);
function("kicadCollabTestGetCrossMapped", &schCollabTestGetCrossMapped);
// Selection soft-locks (0007).
function("kicadCollabReleaseSelection", &schCollabReleaseSelection);
function("kicadCollabTestGetLocked", &schCollabTestGetLocked);
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
function("kicadCollabTestSelectComponent", &schCollabTestSelectComponent);
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);

View file

@ -59,6 +59,9 @@ std::string pcbCollabGetSelection();
std::string pcbCollabGetSelectionFull();
std::string pcbCollabTestGetCrossMapped();
std::string pcbCollabTestSelectComponent();
// Selection soft-locks (collab-presence 0007).
void pcbCollabReleaseSelection( std::string aUuidsJson, std::string aHolder );
std::string pcbCollabTestGetLocked();
std::string pcbCollabTestSelectFirst();
bool pcbCollabTestClearSelection();
@ -85,6 +88,9 @@ std::string schCollabGetSelection();
std::string schCollabGetSelectionFull();
std::string schCollabTestGetCrossMapped();
std::string schCollabTestSelectComponent();
// Selection soft-locks (collab-presence 0007).
void schCollabReleaseSelection( std::string aUuidsJson, std::string aHolder );
std::string schCollabTestGetLocked();
std::string schCollabTestSelectFirst();
bool schCollabTestClearSelection();
@ -227,6 +233,17 @@ static std::string collabTestSelectComponent()
return pcbEditorActive() ? pcbCollabTestSelectComponent() : schCollabTestSelectComponent();
}
static void collabReleaseSelection( std::string aUuidsJson, std::string aHolder )
{
pcbEditorActive() ? pcbCollabReleaseSelection( aUuidsJson, aHolder )
: schCollabReleaseSelection( aUuidsJson, aHolder );
}
static std::string collabTestGetLocked()
{
return pcbEditorActive() ? pcbCollabTestGetLocked() : schCollabTestGetLocked();
}
static std::string collabTestSelectFirst()
{
return pcbEditorActive() ? pcbCollabTestSelectFirst() : schCollabTestSelectFirst();
@ -268,6 +285,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabGetSelectionFull", &collabGetSelectionFull);
function("kicadCollabTestGetCrossMapped", &collabTestGetCrossMapped);
function("kicadCollabTestSelectComponent", &collabTestSelectComponent);
// Selection soft-locks (collab-presence 0007).
function("kicadCollabReleaseSelection", &collabReleaseSelection);
function("kicadCollabTestGetLocked", &collabTestGetLocked);
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);
function("kicadCollabTestClearSelection", &collabTestClearSelection);
}

View file

@ -40,8 +40,10 @@
#include <layer_ids.h>
#include <lset.h>
#include <math/util.h>
#include <tool/actions.h>
#include <tool/coroutine.h>
#include <tool/tool_manager.h>
#include <pcbjam_remote_lock.h>
#include <view/view.h>
#include <view/view_overlay.h>
#include <pcb_draw_panel_gal.h>
@ -1126,6 +1128,12 @@ struct PIN
std::vector<PEER> g_peers;
std::vector<PIN> g_pins;
// Remote soft-locks (collab-presence 0007): uuid → holding peer's display
// name, derived by the TS side from ALL other clients' live selections (own
// user's other tabs included). Consulted by the fork's PCBJAM_REMOTE_LOCK
// query from the selection/move tools. Ephemeral — replaced on every
// kicadCollabSetRemote snapshot.
std::map<KIID, std::string> g_locks;
// Every visual knob (shapes, widths, alphas, label placement, color overrides)
// — see collab_presence_style.h; live-patched by kicadCollabSetStyle (tuner).
pcbjam_presence::STYLE g_style;
@ -1709,6 +1717,22 @@ void pcbCollabPresenceStart()
presence::g_started = true;
// Remote soft-locks (0007): let the selection/move tools consult the
// peers' live selections through the fork's process-global query.
PCBJAM_REMOTE_LOCK::SetQuery(
[]( const KIID& aId, wxString* aHolder ) -> bool
{
auto it = presence::g_locks.find( aId );
if( it == presence::g_locks.end() )
return false;
if( aHolder )
*aHolder = wxString::FromUTF8( it->second.c_str() );
return true;
} );
wxWindow* canvas = fr->GetCanvas();
canvas->Bind( wxEVT_MOTION, []( wxMouseEvent& e ) { presence::onMotion( e ); } );
@ -1770,7 +1794,20 @@ void pcbCollabSetRemote( std::string aJson )
peers.push_back( std::move( peer ) );
}
// Remote soft-locks (0007): `locks: [{uuid, name}]` — every other client's
// held uuids with the holder's display name for the infobar.
std::map<KIID, std::string> locks;
for( const json& l : j.value( "locks", json::array() ) )
{
std::string uuid = l.is_object() ? l.value( "uuid", "" ) : "";
if( !uuid.empty() )
locks[ KIID( wxString::FromUTF8( uuid.c_str() ) ) ] = l.value( "name", "" );
}
presence::g_peers = std::move( peers );
presence::g_locks = std::move( locks );
pcbCollabPresenceStart();
presence::scheduleRedraw();
}
@ -2061,6 +2098,82 @@ std::string pcbCollabTestSelectComponent()
return toUtf8( target->m_Uuid.AsString() );
}
// JS → C++ (0007): the local client LOST the selection tiebreak — release the
// contested items (only those) from the live selection. If an interactive
// tool (move/drag) holds them, cancel it first (ESC semantics — the preview
// reverts); a bare cancel is NOT sent when idle, since ESC on the base
// selection tool would clear the whole selection. Ends with a forced
// selection re-emit (programmatic changes close no canvas event).
void pcbCollabReleaseSelection( std::string aUuidsJson, std::string aHolder )
{
json j = json::parse( aUuidsJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() || !j.is_array() )
return;
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return;
std::vector<KIID> ids;
for( const json& u : j )
{
if( u.is_string() )
ids.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
if( ids.empty() )
return;
wxString holder = wxString::FromUTF8( aHolder.c_str() );
fr->CallAfter( [fr, ids, holder]() {
if( !fr->ToolStackIsEmpty() )
fr->GetToolManager()->RunAction( ACTIONS::cancelInteractive );
PCB_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<PCB_SELECTION_TOOL>();
if( !st )
return;
bool released = false;
for( const KIID& id : ids )
{
BOARD_ITEM* item = fr->GetBoard()->ResolveItem( id, /*allowNullptr*/ true );
if( item && item->IsSelected() )
{
st->RemoveItemFromSel( item );
released = true;
}
}
if( released )
{
fr->ShowInfoBarWarning( wxString::Format( _( "%s is editing this — released from "
"your selection." ),
holder ),
true );
}
schedulePresenceSelCheck();
} );
}
// Test probe (0007): the current remote soft-lock set as `[{uuid, name}]`.
std::string pcbCollabTestGetLocked()
{
json arr = json::array();
for( const auto& [id, name] : presence::g_locks )
arr.push_back( { { "uuid", toUtf8( id.AsString() ) }, { "name", name } } );
return arr.dump();
}
// Test helper: clear the selection through the tool + run the presence check.
bool pcbCollabTestClearSelection()
{
@ -2338,6 +2451,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
// Cross-app selection (0006).
function("kicadCollabGetSelectionFull", &pcbCollabGetSelectionFull);
function("kicadCollabTestGetCrossMapped", &pcbCollabTestGetCrossMapped);
// Selection soft-locks (0007).
function("kicadCollabReleaseSelection", &pcbCollabReleaseSelection);
function("kicadCollabTestGetLocked", &pcbCollabTestGetLocked);
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
function("kicadCollabTestSelectComponent", &pcbCollabTestSelectComponent);
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);