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:
parent
3186986a0a
commit
c30bcd4531
14 changed files with 1221 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue