feat(collab): cross-app selection — eeschema symbol ⇄ pcbnew footprint ghost highlight (collab-presence 0006)

Selecting a symbol in eeschema ghost-highlights the linked footprint(s) in
every pcbnew tab of the project, and vice versa — across users AND one
user's own two tabs. Native KIWAY cross-probe is inert in WASM (one frame
per page); this rides the presence layer instead.

- cross-app.ts: project-wide awareness-only room (presenceRoomId), publishes
  full PresenceState at selection rate (cursor always null); peers() = other-
  TOOL clients incl. own user's other tabs; window.__pcbjamCrossApp test handle
- presence-kicad.ts: parseSelectionEmit (bare array | {uuids,fpPaths}),
  xselFromPeerState (pcbnew paths → symbol uuids; eeschema uuids verbatim),
  cross peers appended to the kicadCollabSetRemote snapshot as
  {id "<user>#x<client>", name "<user> · sch|pcb", xsel}
- C++ (zero fork changes): pcbnew emits {uuids, fpPaths} (FOOTPRINT::GetPath)
  and ghost-renders xsel via path-tail suffix scan; eeschema resolves xsel via
  ResolveItem gated to the CURRENT sheet (xsel arrives project-wide, unlike
  room-scoped selections); ghostStyle = alphas × xselAlphaScale (0.55, tuner-
  patchable); new exports kicadCollabGetSelectionFull / TestGetCrossMapped /
  TestSelectComponent (skips power symbols — PWR_FLAG has no footprint) +
  merged-image dispatch
- tests: presence suites extended (payload shape, ghost render pixel tests,
  13/13) + new two-tab tests/web/cross-probe.spec.ts (passing vs real
  partykit); eeschema pixel compares now target the #glcanvas-* GAL panel
  (the whole-window #canvas compare flaked on the auto-dismissing version
  infobar — also fixes the long-known presence-eeschema restore flake);
  cross-app + presence-kicad vitest suites

Spec: docs/features/collab-presence/0006 (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 19:30:18 +02:00
commit 3186986a0a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
14 changed files with 1276 additions and 48 deletions

View file

@ -34,6 +34,7 @@ type Mod = {
kicadCollabSetRemote(j: string): void;
kicadCollabGetViewport(): string;
kicadCollabGetSelection(): string;
kicadCollabTestGetCrossMapped(): string;
kicadCollabTestSelectFirst(): string;
kicadCollabTestClearSelection(): boolean;
};
@ -49,6 +50,28 @@ function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
/**
* The visible GAL draw panel — the pixel-compare target for overlay tests.
* `#canvas` is the WHOLE wx window; its chrome is not pixel-stable across a
* test (the "older KiCad version" infobar auto-dismisses, widget carets
* blink), which made whole-window before/after equality flaky. The presence
* overlay renders only into the GL panel, so compare exactly that.
*/
async function galPanel(page: Page) {
const glId = await page.evaluate(() => {
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
.map((c) => c as HTMLCanvasElement)
.find(
(c) =>
window.getComputedStyle(c).display !== "none" &&
c.getBoundingClientRect().width > 0,
);
return visible?.id ?? null;
});
expect(glId, "no visible GAL panel found").toBeTruthy();
return page.locator(`#${glId}`);
}
async function bootAndOpen(page: Page): Promise<void> {
await page.goto("/kicad/eeschema.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
@ -236,7 +259,7 @@ test("remote render paints the overlay without touching local selection", async
}) => {
await bootAndOpen(page);
const canvas = page.locator("#canvas");
const canvas = await galPanel(page);
const before = await canvas.screenshot();
await page.evaluate(
@ -295,6 +318,108 @@ test("remote render paints the overlay without touching local selection", async
expect(hasAbort(testLogger)).toBe(false);
});
test("cross-app ghost render: a pcbnew peer's xsel uuid resolves on the current sheet (0006)", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const canvas = await galPanel(page);
const before = await canvas.screenshot();
// A pcbnew peer snapshot entry: xsel = the symbol uuid derived from the
// footprint's path by the TS side. The fixture has no symbols, but the C++
// resolve/draw path is uuid-kind-agnostic — a wire uuid exercises it fully.
await page.evaluate(
({ id }) => {
const w = window as unknown as PresenceWindow;
w.Module.kicadCollabSetRemote(
JSON.stringify({
peers: [
{
id: "alice#x3",
name: "alice · pcb",
color: "#3b82f6",
cursor: null,
selection: [],
xsel: [id],
},
],
}),
);
},
{ id: WIRE1 },
);
// Resolves onto the current sheet → probe reports it, ghost outline paints.
await expect
.poll(
() =>
page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
),
),
{ timeout: 10000 },
)
.toEqual([WIRE1]);
await expect
.poll(
async () => !(await canvas.screenshot()).equals(before),
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
// No selection leak, unknown uuids map to nothing, clear restores pixels.
const localSel = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelection()),
);
expect(localSel).toEqual([]);
await page.evaluate(() => {
const w = window as unknown as PresenceWindow;
w.Module.kicadCollabSetRemote(
JSON.stringify({
peers: [
{
id: "alice#x3",
name: "alice · pcb",
color: "#3b82f6",
cursor: null,
selection: [],
xsel: ["99999999-0000-0000-0000-000000000099"],
},
],
}),
);
});
await expect
.poll(
() =>
page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
),
),
{ timeout: 10000 },
)
.toEqual([]);
await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
JSON.stringify({ peers: [] }),
),
);
await expect
.poll(
async () => (await canvas.screenshot()).equals(before),
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
expect(hasAbort(testLogger)).toBe(false);
});
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
await bootAndOpen(page);

View file

@ -21,6 +21,9 @@ import { test, expect } from "./fixtures";
const SEG1 = "44444444-0000-0000-0000-000000000001";
const FP1 = "66666666-0000-0000-0000-000000000001";
// The footprint's schematic link (0006): its (path …) ends in the symbol uuid.
const SYM1 = "aaaaaaaa-0000-0000-0000-000000000001";
const FP1_PATH = `/${SYM1}`;
const SAMPLE_PCB = `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
@ -41,6 +44,7 @@ const SAMPLE_PCB = `(kicad_pcb
\t\t(layer "F.Cu")
\t\t(uuid "${FP1}")
\t\t(at 100 100)
\t\t(path "${FP1_PATH}")
\t\t(attr smd)
\t\t(property "Reference" "R1"
\t\t\t(at 0 -4.2 0)
@ -72,14 +76,18 @@ type Mod = {
kicadCollabSetRemote(j: string): void;
kicadCollabGetViewport(): string;
kicadCollabGetSelection(): string;
kicadCollabGetSelectionFull(): string;
kicadCollabTestGetCrossMapped(): string;
kicadCollabTestSelectFirst(): string;
kicadCollabTestClearSelection(): boolean;
};
type SelPayload = { uuids: string[]; fpPaths: string[] };
type PresenceWindow = {
FS: FS;
Module: Mod;
kicadCollab?: Record<string, unknown>;
__selEmits?: string[][];
__selPayloads?: SelPayload[];
__cursorEmits?: Array<{ x: number; y: number; active: number }>;
};
@ -134,14 +142,24 @@ async function bootAndOpen(page: Page): Promise<void> {
.poll(() => page.title(), { timeout: 60000, intervals: [500] })
.toMatch(/presence/i);
// Install the capture hooks + start the presence input bindings.
// Install the capture hooks + start the presence input bindings. Since 0006
// pcbnew emits `{uuids, fpPaths}` — capture the uuid arrays (the pre-0006
// assertions) AND the full payloads (the cross-app ones).
await page.evaluate(() => {
const w = window as unknown as PresenceWindow;
w.__selEmits = [];
w.__selPayloads = [];
w.__cursorEmits = [];
w.kicadCollab = {
...w.kicadCollab,
onSelection: (json: string) => w.__selEmits!.push(JSON.parse(json)),
onSelection: (json: string) => {
const parsed = JSON.parse(json) as SelPayload | string[];
const payload: SelPayload = Array.isArray(parsed)
? { uuids: parsed, fpPaths: [] }
: parsed;
w.__selEmits!.push(payload.uuids);
w.__selPayloads!.push(payload);
},
onCursor: (x: number, y: number, active: number) =>
w.__cursorEmits!.push({ x, y, active }),
};
@ -188,6 +206,49 @@ test("selection emit: programmatic select/clear reaches onSelection with uuids",
expect(hasAbort(testLogger)).toBe(false);
});
test("selection emit carries the footprint's schematic path (0006)", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
// TestSelectFirst picks the first top-level item — the fixture's only
// footprint sorts first, but assert by payload rather than by luck: select,
// then find the emit whose uuids contain the footprint.
await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabTestSelectFirst(),
);
await expect
.poll(
() =>
page.evaluate(() => {
const w = window as unknown as PresenceWindow;
return w.__selPayloads!.at(-1) ?? null;
}),
{ timeout: 10000 },
)
.not.toBeNull();
const payload = await page.evaluate(
() => (window as unknown as PresenceWindow).__selPayloads!.at(-1)!,
);
if (payload.uuids.includes(FP1)) {
expect(payload.fpPaths).toEqual([FP1_PATH]);
} else {
// A non-footprint got selected first — the paths list must then be empty.
expect(payload.fpPaths).toEqual([]);
}
// The pull export mirrors the emit payload (cross-app seed at attach).
const full = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelectionFull()),
);
expect(full.uuids).toEqual(payload.uuids);
expect(full.fpPaths).toEqual(payload.fpPaths);
expect(hasAbort(testLogger)).toBe(false);
});
test("selection emit: a real canvas click drives the wx-layer trigger", async ({
page,
testLogger,
@ -352,6 +413,107 @@ test("remote render paints the overlay without touching local selection", async
expect(hasAbort(testLogger)).toBe(false);
});
test("cross-app ghost render: an eeschema peer's symbol uuid maps to the footprint (0006)", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const canvas = page.locator("#canvas");
const before = await canvas.screenshot();
// An eeschema peer snapshot entry: no same-doc selection, xsel = symbol uuid.
await page.evaluate(
({ sym }) => {
const w = window as unknown as PresenceWindow;
w.Module.kicadCollabSetRemote(
JSON.stringify({
peers: [
{
id: "bob#x7",
name: "bob · sch",
color: "#d946ef",
cursor: null,
selection: [],
xsel: [sym],
},
],
}),
);
},
{ sym: SYM1 },
);
// The suffix match resolves the symbol to the fixture footprint.
await expect
.poll(
() =>
page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
),
),
{ timeout: 10000 },
)
.toEqual([FP1]);
// …and paints the ghost outline (pixels change), with no selection leak.
await expect
.poll(
async () => !(await canvas.screenshot()).equals(before),
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
const localSel = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelection()),
);
expect(localSel).toEqual([]);
// An unknown symbol uuid maps to nothing; clearing restores the pixels.
await page.evaluate(() => {
const w = window as unknown as PresenceWindow;
w.Module.kicadCollabSetRemote(
JSON.stringify({
peers: [
{
id: "bob#x7",
name: "bob · sch",
color: "#d946ef",
cursor: null,
selection: [],
xsel: ["99999999-0000-0000-0000-000000000099"],
},
],
}),
);
});
await expect
.poll(
() =>
page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
),
),
{ timeout: 10000 },
)
.toEqual([]);
await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
JSON.stringify({ peers: [] }),
),
);
await expect
.poll(
async () => (await canvas.screenshot()).equals(before),
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
expect(hasAbort(testLogger)).toBe(false);
});
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
await bootAndOpen(page);

View file

@ -0,0 +1,128 @@
import { test, expect, type Page } from '@playwright/test';
/**
* Cross-app selection e2e (collab-presence 0006): one tab on the demo
* SCHEMATIC and one on the demo BOARD of the same project share the
* project-wide presence room over the default BroadcastChannel provider.
* Selecting a symbol in eeschema must ghost-highlight the linked footprint in
* pcbnew (footprint `(path …)` tail = symbol uuid — the demo fixtures are
* linked), and vice versa. Assertions use the deterministic
* `kicadCollabTestGetCrossMapped()` probe (what the ghost render draws from)
* rather than pixel diffs — the render path itself is covered by the
* tests/kicad specs.
*
* This is also the single-user cross-probe test by construction: both tabs
* run as the same `?user=` in one browser context and still map each other
* (cross-app peers are keyed by client, not user).
*/
const SCOPE = 'default';
type Mod = {
kicadCollabTestSelectComponent(): string;
kicadCollabTestClearSelection(): boolean;
kicadCollabTestGetCrossMapped(): string;
kicadCollabGetSelectionFull(): string;
};
type W = { Module: Mod };
async function bootTool(
page: Page,
route: string,
titleRe: RegExp,
user: string,
): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/${route}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), {
message: `${route}: editor never reached the expected title`,
timeout: 120000,
intervals: [1000],
})
.toMatch(titleRe);
await page.waitForFunction(
() => {
const m = (window as unknown as Partial<W>).Module;
return (
typeof m?.kicadCollabTestSelectComponent === 'function' &&
typeof m?.kicadCollabTestGetCrossMapped === 'function'
);
},
null,
{ timeout: 60000 },
);
}
const crossMapped = (page: Page) =>
page.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabTestGetCrossMapped()),
);
test('selecting a symbol in eeschema ghost-highlights the footprint in pcbnew, and back', async ({
page,
context,
}) => {
test.setTimeout(480000); // two full editor boots (eeschema + pcbnew wasm)
const sch = page;
await bootTool(sch, 'demo.kicad_sch', /demo — Schematic Editor/i, 'alice');
const pcb = await context.newPage();
await bootTool(pcb, 'demo.kicad_pcb', /demo — PCB Editor/i, 'bob');
// ── eeschema → pcbnew ──────────────────────────────────────────────────────
const symId = await sch.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
);
expect(symId, 'demo schematic should contain a symbol').toBeTruthy();
// The board tab maps the symbol to ≥1 footprint (reused sheets map to more).
await expect
.poll(() => crossMapped(pcb), {
timeout: 20000,
message: 'pcbnew never received/mapped the eeschema selection',
})
.not.toEqual([]);
// The selection emit carried the symbol uuid across (sanity on the wire).
const schSel = await sch.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
);
expect(schSel.uuids).toContain(symId);
// Clearing the schematic selection clears the board ghosts.
await sch.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestClearSelection(),
);
await expect
.poll(() => crossMapped(pcb), { timeout: 20000 })
.toEqual([]);
// ── pcbnew → eeschema ──────────────────────────────────────────────────────
const fpId = await pcb.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
);
expect(fpId, 'demo board should contain a footprint').toBeTruthy();
// The footprint's path tail resolves to a symbol on the schematic's sheet.
await expect
.poll(() => crossMapped(sch), {
timeout: 20000,
message: 'eeschema never received/mapped the pcbnew selection',
})
.not.toEqual([]);
// …and the emit carried the footprint's schematic path.
const pcbSel = await pcb.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
);
expect(pcbSel.uuids).toContain(fpId);
expect(pcbSel.fpPaths.length).toBeGreaterThan(0);
await pcb.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestClearSelection(),
);
await expect
.poll(() => crossMapped(sch), { timeout: 20000 })
.toEqual([]);
});

View file

@ -82,6 +82,13 @@ struct STYLE
double pinFillAlpha = 1.0;
double pinResolvedAlpha = 0.3;
// ── cross-app "ghost" selection (0006) ────────────────────────────────
// A peer's selection in the OTHER editor (eeschema symbol ⇄ pcbnew
// footprint) renders with the normal selection shape but its stroke/fill
// alphas scaled down, so a cross-probe highlight reads distinctly softer
// than a direct same-document selection.
double xselAlphaScale = 0.55;
};
/**
@ -157,6 +164,18 @@ inline void patchStyle( STYLE& aStyle, const json& j )
aStyle.pinRingAlpha = j.value( "pinRingAlpha", aStyle.pinRingAlpha );
aStyle.pinFillAlpha = j.value( "pinFillAlpha", aStyle.pinFillAlpha );
aStyle.pinResolvedAlpha = j.value( "pinResolvedAlpha", aStyle.pinResolvedAlpha );
aStyle.xselAlphaScale = j.value( "xselAlphaScale", aStyle.xselAlphaScale );
}
/** The style a cross-app (0006) ghost selection draws with: the given style
* with its selection stroke/fill alphas scaled by `xselAlphaScale`. */
inline STYLE ghostStyle( const STYLE& aStyle )
{
STYLE g = aStyle;
g.selStrokeAlpha *= aStyle.xselAlphaScale;
g.selFillAlpha *= aStyle.xselAlphaScale;
return g;
}
/** The color a peer renders with under this style (fixed > palette-by-name-hash

View file

@ -701,6 +701,10 @@ struct PEER
bool hasCursor = false;
VECTOR2D cursor; // world coords (IU)
std::vector<KIID> selection;
// Cross-app selection (0006): SYMBOL uuids derived from a pcbnew peer's
// footprint paths (the TS side strips the KIID_PATH down to its tail).
// Ghost-rendered when the symbol resolves onto the CURRENT sheet.
std::vector<KIID> xsel;
};
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
@ -839,6 +843,29 @@ void redrawOverlay()
g_style );
}
// Cross-app ghosts (0006): a pcbnew peer's selection, as symbol uuids.
// Unlike same-room selections (per-sheet rooms scope those by
// construction) these arrive project-wide, so only draw items that
// resolve onto the sheet THIS canvas is showing — a bbox from another
// sheet's screen would land at meaningless coordinates here.
if( !peer.xsel.empty() )
{
pcbjam_presence::STYLE ghost = pcbjam_presence::ghostStyle( g_style );
for( const KIID& id : peer.xsel )
{
SCH_SHEET_PATH path;
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
if( !item || path.LastScreen() != fr->GetScreen() )
continue;
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
item->ViewBBox(), peer.name, color, px,
ghost );
}
}
if( peer.hasCursor )
pcbjam_presence::drawCursor( g_overlay.get(), g_textOverlay.get(), peer.cursor,
peer.name, color, px, g_style );
@ -1596,6 +1623,13 @@ void schCollabSetRemote( std::string aJson )
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
// Cross-app selection (0006): symbol uuids from a pcbnew peer.
for( const json& u : p.value( "xsel", json::array() ) )
{
if( u.is_string() )
peer.xsel.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
peers.push_back( std::move( peer ) );
}
@ -1776,6 +1810,48 @@ std::string schCollabGetSelection()
return presence::selectionUuids( fr ).dump();
}
// JS pull of the current selection in the 0006 payload shape. eeschema has no
// footprint paths — the uuids ARE the symbol uuids — but the export keeps the
// merged image's kicadCollabGetSelectionFull contract uniform across editors.
std::string schCollabGetSelectionFull()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return "{\"uuids\":[],\"fpPaths\":[]}";
json payload;
payload["uuids"] = presence::selectionUuids( fr );
payload["fpPaths"] = json::array();
return payload.dump();
}
// Test probe (0006): the schematic-item uuids the current peers' cross-app
// selections resolve to ON THE CURRENT SHEET (mirrors the redraw's gate).
std::string schCollabTestGetCrossMapped()
{
json arr = json::array();
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return arr.dump();
for( const presence::PEER& peer : presence::g_peers )
{
for( const KIID& id : peer.xsel )
{
SCH_SHEET_PATH path;
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
if( item && path.LastScreen() == fr->GetScreen() )
arr.push_back( toUtf8( item->m_Uuid.AsString() ) );
}
}
return arr.dump();
}
// Test helper: REALLY select the current sheet's first item through the selection
// tool, then run the presence check (programmatic selects close no canvas event).
std::string schCollabTestSelectFirst()
@ -1812,6 +1888,48 @@ std::string schCollabTestSelectFirst()
return toUtf8( target->m_Uuid.AsString() );
}
// Test helper (0006): REALLY select the first SYMBOL on the current sheet —
// the deterministic cross-app subject (TestSelectFirst may pick a wire, which
// legitimately maps to nothing in pcbnew). Returns the uuid, "" without one.
std::string schCollabTestSelectComponent()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return "";
SCH_SCREEN* screen = currentScreen( fr );
if( !screen )
return "";
SCH_ITEM* target = nullptr;
for( SCH_ITEM* item : screen->Items().OfType( SCH_SYMBOL_T ) )
{
// Power symbols (PWR_FLAG, GND, …) legitimately have no footprint —
// they'd make the cross-app subject map to nothing by construction.
if( static_cast<SCH_SYMBOL*>( item )->IsPower() )
continue;
target = item;
break;
}
if( !target )
return "";
fr->CallAfter( [fr, target]() {
if( SCH_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<SCH_SELECTION_TOOL>() )
{
st->AddItemToSel( target );
schedulePresenceSelCheck();
}
} );
return toUtf8( target->m_Uuid.AsString() );
}
// Test helper: clear the selection through the tool + run the presence check.
bool schCollabTestClearSelection()
{
@ -1903,7 +2021,11 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
function("kicadCollabGetViewport", &schCollabGetViewport);
function("kicadCollabGetSelection", &schCollabGetSelection);
// Cross-app selection (0006).
function("kicadCollabGetSelectionFull", &schCollabGetSelectionFull);
function("kicadCollabTestGetCrossMapped", &schCollabTestGetCrossMapped);
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
function("kicadCollabTestSelectComponent", &schCollabTestSelectComponent);
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
#endif // !KICAD_MERGED_EMBIND
}

View file

@ -55,6 +55,10 @@ std::string pcbCollabTestListItems( int aCount );
std::string pcbCollabTestDemoSet();
std::string pcbCollabGetViewport();
std::string pcbCollabGetSelection();
// Cross-app selection (collab-presence 0006).
std::string pcbCollabGetSelectionFull();
std::string pcbCollabTestGetCrossMapped();
std::string pcbCollabTestSelectComponent();
std::string pcbCollabTestSelectFirst();
bool pcbCollabTestClearSelection();
@ -77,6 +81,10 @@ std::string schCollabTestListItems( int aCount );
std::string schCollabTestDemoSet();
std::string schCollabGetViewport();
std::string schCollabGetSelection();
// Cross-app selection (collab-presence 0006).
std::string schCollabGetSelectionFull();
std::string schCollabTestGetCrossMapped();
std::string schCollabTestSelectComponent();
std::string schCollabTestSelectFirst();
bool schCollabTestClearSelection();
@ -204,6 +212,21 @@ static std::string collabGetSelection()
return pcbEditorActive() ? pcbCollabGetSelection() : schCollabGetSelection();
}
static std::string collabGetSelectionFull()
{
return pcbEditorActive() ? pcbCollabGetSelectionFull() : schCollabGetSelectionFull();
}
static std::string collabTestGetCrossMapped()
{
return pcbEditorActive() ? pcbCollabTestGetCrossMapped() : schCollabTestGetCrossMapped();
}
static std::string collabTestSelectComponent()
{
return pcbEditorActive() ? pcbCollabTestSelectComponent() : schCollabTestSelectComponent();
}
static std::string collabTestSelectFirst()
{
return pcbEditorActive() ? pcbCollabTestSelectFirst() : schCollabTestSelectFirst();
@ -241,6 +264,10 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabTestDemoSet", &collabTestDemoSet);
function("kicadCollabGetViewport", &collabGetViewport);
function("kicadCollabGetSelection", &collabGetSelection);
// Cross-app selection (collab-presence 0006).
function("kicadCollabGetSelectionFull", &collabGetSelectionFull);
function("kicadCollabTestGetCrossMapped", &collabTestGetCrossMapped);
function("kicadCollabTestSelectComponent", &collabTestSelectComponent);
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);
function("kicadCollabTestClearSelection", &collabTestClearSelection);
}

View file

@ -1106,6 +1106,10 @@ struct PEER
bool hasCursor = false;
VECTOR2D cursor; // world coords (IU/nm)
std::vector<KIID> selection;
// Cross-app selection (0006): SYMBOL uuids an eeschema peer has selected —
// rendered as ghost outlines on every footprint whose GetPath() ends in one
// (a reused sheet legitimately maps one symbol to N footprints).
std::vector<KIID> xsel;
};
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
@ -1171,8 +1175,36 @@ json selectionUuids( PCB_EDIT_FRAME* aFrame )
return uuids;
}
// The full selection emit payload (0006): the uuids plus, for every selected
// FOOTPRINT, its schematic link — the GetPath() KIID_PATH string ending in the
// symbol uuid. eeschema peers highlight the corresponding symbols from it.
json selectionPayload( PCB_EDIT_FRAME* aFrame )
{
json payload;
payload["uuids"] = selectionUuids( aFrame );
payload["fpPaths"] = json::array();
PCB_SELECTION_TOOL* selTool = aFrame->GetToolManager()->GetTool<PCB_SELECTION_TOOL>();
if( !selTool )
return payload;
for( EDA_ITEM* item : selTool->GetSelection() )
{
if( item->Type() != PCB_FOOTPRINT_T )
continue;
const KIID_PATH& path = static_cast<FOOTPRINT*>( item )->GetPath();
if( !path.empty() )
payload["fpPaths"].push_back( toUtf8( path.AsString() ) );
}
return payload;
}
// Post-settle selection emit: read the selection AFTER the triggering event finished
// (CallAfter), dedupe against the last emitted set, hand the uuid list to JS.
// (CallAfter), dedupe against the last emitted set, hand the payload to JS.
void checkSelection()
{
g_selCheckScheduled = false;
@ -1182,7 +1214,7 @@ void checkSelection()
if( !fr )
return;
std::string s = selectionUuids( fr ).dump();
std::string s = selectionPayload( fr ).dump();
if( s == g_lastSelectionJson )
return;
@ -1234,6 +1266,47 @@ void redrawOverlay()
// All shapes/sizes/placements come from g_style (collab_presence_style.h);
// the drawing itself is shared with eeschema's TU so the editors never
// diverge visually.
// Draw ONE item's selection box under the given style. Exact-geometry
// outline (style shape 5): footprints hug their bounding hull, everything
// else its transformed shape. Runs on the coroutine fiber (virtual
// dispatch), falls back to the bbox on anything that can't produce a
// polygon.
auto drawItem = [&]( BOARD_ITEM* item, const std::string& name,
const KIGFX::COLOR4D& color, const pcbjam_presence::STYLE& style )
{
SHAPE_POLY_SET outline;
if( style.selShape == 5 )
{
try
{
int pad = KiROUND( style.selPaddingPx * px );
int err = KiROUND( px ) + 1;
if( item->Type() == PCB_FOOTPRINT_T )
{
outline = static_cast<FOOTPRINT*>( item )->GetBoundingHull();
if( pad > 0 )
outline.Inflate( pad, CORNER_STRATEGY::ROUND_ALL_CORNERS, err );
}
else
{
item->TransformShapeToPolygon( outline, (PCB_LAYER_ID) itemLayer( item ),
pad, err, ERROR_OUTSIDE );
}
}
catch( ... )
{
outline.RemoveAllContours();
}
}
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
item->ViewBBox(), name, color, px,
style, &outline );
};
for( const PEER& peer : g_peers )
{
KIGFX::COLOR4D color = pcbjam_presence::peerColor( g_style, peer.name, peer.color );
@ -1245,41 +1318,27 @@ void redrawOverlay()
if( !item )
continue; // not on this board (yet) — skip silently
// Exact-geometry outline (style shape 5): footprints hug their
// bounding hull, everything else its transformed shape. Runs on
// the coroutine fiber (virtual dispatch), falls back to the bbox
// on anything that can't produce a polygon.
SHAPE_POLY_SET outline;
drawItem( item, peer.name, color, g_style );
}
if( g_style.selShape == 5 )
// Cross-app ghosts (0006): the peer's eeschema symbol uuids, matched
// against every footprint's GetPath() tail. The per-redraw linear scan
// reflects the live board with no index to invalidate; boards are small
// relative to the redraw's own draw cost.
if( !peer.xsel.empty() )
{
pcbjam_presence::STYLE ghost = pcbjam_presence::ghostStyle( g_style );
for( const KIID& symId : peer.xsel )
{
try
for( FOOTPRINT* fp : board->Footprints() )
{
int pad = KiROUND( g_style.selPaddingPx * px );
int err = KiROUND( px ) + 1;
const KIID_PATH& path = fp->GetPath();
if( item->Type() == PCB_FOOTPRINT_T )
{
outline = static_cast<FOOTPRINT*>( item )->GetBoundingHull();
if( pad > 0 )
outline.Inflate( pad, CORNER_STRATEGY::ROUND_ALL_CORNERS, err );
}
else
{
item->TransformShapeToPolygon( outline, (PCB_LAYER_ID) itemLayer( item ),
pad, err, ERROR_OUTSIDE );
}
}
catch( ... )
{
outline.RemoveAllContours();
if( !path.empty() && path.back() == symId )
drawItem( fp, peer.name, color, ghost );
}
}
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
item->ViewBBox(), peer.name, color, px,
g_style, &outline );
}
if( peer.hasCursor )
@ -1700,6 +1759,14 @@ void pcbCollabSetRemote( std::string aJson )
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
// Cross-app selection (0006): symbol uuids from an eeschema peer,
// ghost-rendered on the matching footprints.
for( const json& u : p.value( "xsel", json::array() ) )
{
if( u.is_string() )
peer.xsel.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
peers.push_back( std::move( peer ) );
}
@ -1886,6 +1953,49 @@ std::string pcbCollabGetSelection()
return presence::selectionUuids( fr ).dump();
}
// JS pull of the current selection WITH the footprints' schematic paths (0006):
// `{uuids:[…], fpPaths:[…]}` — the same payload checkSelection emits. Seeds the
// cross-app presence at attach; GetSelection above keeps its bare-array shape
// for the pre-0006 callers and the e2e no-leak probe.
std::string pcbCollabGetSelectionFull()
{
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return "{\"uuids\":[],\"fpPaths\":[]}";
return presence::selectionPayload( fr ).dump();
}
// Test probe (0006): the board-item uuids the current peers' cross-app
// selections (xsel symbol uuids) resolve to on THIS board — a deterministic
// assertion target next to the pixel diff.
std::string pcbCollabTestGetCrossMapped()
{
json arr = json::array();
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return arr.dump();
for( const presence::PEER& peer : presence::g_peers )
{
for( const KIID& symId : peer.xsel )
{
for( FOOTPRINT* fp : fr->GetBoard()->Footprints() )
{
const KIID_PATH& path = fp->GetPath();
if( !path.empty() && path.back() == symId )
arr.push_back( toUtf8( fp->m_Uuid.AsString() ) );
}
}
}
return arr.dump();
}
// Test helper: REALLY select the first top-level item through the selection tool (the
// same call the cross-probe flash uses), then run the presence check — a programmatic
// select has no closing canvas event. Returns the selected uuid.
@ -1918,6 +2028,39 @@ std::string pcbCollabTestSelectFirst()
return toUtf8( target->m_Uuid.AsString() );
}
// Test helper (0006): REALLY select the first FOOTPRINT through the selection
// tool — the deterministic cross-app subject (TestSelectFirst may pick a track,
// which legitimately maps to nothing in eeschema). Returns the uuid, "" if the
// board has no footprints.
std::string pcbCollabTestSelectComponent()
{
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return "";
FOOTPRINT* target = nullptr;
for( FOOTPRINT* fp : fr->GetBoard()->Footprints() )
{
target = fp;
break;
}
if( !target )
return "";
fr->CallAfter( [fr, target]() {
if( PCB_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<PCB_SELECTION_TOOL>() )
{
st->AddItemToSel( target );
schedulePresenceSelCheck();
}
} );
return toUtf8( target->m_Uuid.AsString() );
}
// Test helper: clear the selection through the tool + run the presence check.
bool pcbCollabTestClearSelection()
{
@ -2192,7 +2335,11 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
function("kicadCollabGetViewport", &pcbCollabGetViewport);
function("kicadCollabGetSelection", &pcbCollabGetSelection);
// Cross-app selection (0006).
function("kicadCollabGetSelectionFull", &pcbCollabGetSelectionFull);
function("kicadCollabTestGetCrossMapped", &pcbCollabTestGetCrossMapped);
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
function("kicadCollabTestSelectComponent", &pcbCollabTestSelectComponent);
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
#endif // !KICAD_MERGED_EMBIND
}

@ -1 +1 @@
Subproject commit 5f5effa4e0d9cab0261a5e0f771c7c4cb31b427d
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5

View file

@ -58,6 +58,7 @@ import {
hasPresenceBridge,
type PresenceKicadWindow,
} from "@/wasm/collab/presence-kicad";
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
import {
createComments,
hasCommentsBridge,
@ -598,6 +599,9 @@ export function WasmTool({
const driftRef = React.useRef<{ stop(): void } | null>(null);
const presenceRef = React.useRef<PresenceHandle | null>(null);
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
// Project-wide presence room (0006): joined once per session, survives
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
@ -811,6 +815,8 @@ export function WasmTool({
mod: win.Module,
win: win as unknown as PresenceKicadWindow,
presence,
// Cross-app selection (0006): the project presence room, if joined.
crossApp: crossAppRef.current ?? undefined,
// Live world↔screen transform for the DOM comment layer (0005).
onViewport: setViewportState,
});
@ -964,6 +970,26 @@ export function WasmTool({
// divergence. Gated on a real collab session; re-targeted per active sheet below.
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
// Cross-app selection (0006): join the project-wide presence room BEFORE
// the per-file collab starts, so the first startPresence bind already
// routes xsel. Honors the same ?collab=0 opt-out as the room collab.
const collabOptOut =
new URLSearchParams(win.location.search).get("collab") === "0" ||
new URLSearchParams(win.location.search).get("collab") === "false";
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut) {
crossAppRef.current =
(await startCrossAppPresence({
projectId,
provider: yjsProviderConfig(),
user: presenceUser(),
tool,
})) ?? null;
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
// the project-room peer view without driving pixels.
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
crossAppRef.current;
}
if (tool === "eeschema") {
// Multi-room (subschema) collab: every .kicad_sch is its own warm room; the
// active sheet is bound, navigation re-routes it (C++ onSheetChanged hook).
@ -1042,6 +1068,8 @@ export function WasmTool({
presenceBridgeRef.current = null;
presenceRef.current?.destroy();
presenceRef.current = null;
crossAppRef.current?.destroy();
crossAppRef.current = null;
driftRef.current?.stop();
driftRef.current = null;
// Tears down every warm room's provider/doc (the only place providers are

View file

@ -0,0 +1,108 @@
import { afterEach, describe, expect, it } from "vitest";
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import { resetPresenceColorClaims } from "./presence";
import { startCrossAppPresence, type CrossAppHandle } from "./cross-app";
/**
* Cross-app presence room unit tests (collab-presence 0006): real handles on
* the BroadcastChannel provider (node ≥18 has BroadcastChannel globally) stand
* in for an eeschema tab and a pcbnew tab of the same project.
*/
const settle = () => new Promise((r) => setTimeout(r, 60));
function user(id: string): PresenceUser {
return { id, name: id, color: colorForUser(id) };
}
let handles: CrossAppHandle[] = [];
let projectSeq = 0;
async function join(projectId: string, userId: string, tool: string): Promise<CrossAppHandle> {
const h = await startCrossAppPresence({
projectId,
provider: { kind: "broadcastchannel", settleMs: 10 },
user: user(userId),
tool,
});
if (!h) throw new Error("cross-app handle expected on the BC provider");
handles.push(h);
return h;
}
afterEach(() => {
for (const h of handles) h.destroy();
handles = [];
resetPresenceColorClaims();
});
describe("startCrossAppPresence", () => {
it("returns undefined for the none provider", async () => {
const h = await startCrossAppPresence({
projectId: "P",
provider: { kind: "none" },
user: user("alice"),
tool: "pcbnew",
});
expect(h).toBeUndefined();
});
it("peers see only OTHER-tool clients, including the own user's other tab", async () => {
const project = `xapp-${projectSeq++}`;
const sch = await join(project, "alice", "eeschema");
const pcb = await join(project, "alice", "pcbnew"); // same user, other tool
const pcb2 = await join(project, "bob", "pcbnew");
await settle();
// The eeschema tab sees both pcbnew tabs (own user's included).
const schPeers = sch.peers();
expect(schPeers.map((p) => p.state.user.id).sort()).toEqual(["alice", "bob"]);
expect(schPeers.every((p) => p.state.tool === "pcbnew")).toBe(true);
// A pcbnew tab sees only the eeschema tab — the other pcbnew tab is
// same-tool (per-file rooms own that relationship).
expect(pcb.peers().map((p) => p.state.user.id)).toEqual(["alice"]);
expect(pcb2.peers().map((p) => p.state.user.id)).toEqual(["alice"]);
});
it("propagates selection + selectionPaths and clears on destroy", async () => {
const project = `xapp-${projectSeq++}`;
const sch = await join(project, "alice", "eeschema");
const pcb = await join(project, "bob", "pcbnew");
await settle();
pcb.setSelection(["fp-uuid"], ["/sheet/sym-1"]);
await settle();
const seen = sch.peers().find((p) => p.state.user.id === "bob");
expect(seen?.state.selection).toEqual(["fp-uuid"]);
expect(seen?.state.selectionPaths).toEqual(["/sheet/sym-1"]);
// Clearing the paths drops the optional field from the published state.
pcb.setSelection([], undefined);
await settle();
const cleared = sch.peers().find((p) => p.state.user.id === "bob");
expect(cleared?.state.selection).toEqual([]);
expect(cleared?.state.selectionPaths).toBeUndefined();
pcb.destroy();
await settle();
expect(sch.peers()).toEqual([]);
});
it("different projects do not share the presence room", async () => {
const a = await join(`xapp-${projectSeq++}`, "alice", "eeschema");
await join(`xapp-${projectSeq++}`, "bob", "pcbnew");
await settle();
expect(a.peers()).toEqual([]);
});
it("notifies subscribers on peer changes", async () => {
const project = `xapp-${projectSeq++}`;
const sch = await join(project, "alice", "eeschema");
let fired = 0;
sch.subscribe(() => fired++);
await join(project, "bob", "pcbnew");
await settle();
expect(fired).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,141 @@
import * as Y from "yjs";
import {
presenceRoomId,
presenceStateSchema,
type PresenceState,
type PresenceUser,
} from "@pcbjam/shared";
import { connectProvider, type ProviderConfig, type YjsProvider } from "./provider";
import { claimedPresenceColor } from "./presence";
import { clog } from "./debug";
/**
* Project-wide presence room (collab-presence 0006): one awareness-only room
* per PROJECT, joined by every collab-capable editor alongside its per-file
* room(s). Per-file rooms keep owning same-document presence (cursors, roster,
* selection outlines); this room exists ONLY so cross-app features can see
* peers in the project's OTHER documents — an eeschema tab learning what a
* pcbnew tab has selected, and vice versa.
*
* The local client publishes a full `PresenceState` (cursor always null —
* world coordinates are meaningless across documents) and updates only its
* `selection`/`selectionPaths`, so traffic is selection-rate, not cursor-rate.
* The Y.Doc is a required transport sidecar that stays empty; backends skip
* persisting `~presence` rooms.
*/
export interface CrossAppPeer {
clientId: number;
state: PresenceState;
}
export interface CrossAppHandle {
/** Publish this tab's selection (uuids + pcbnew footprint paths). */
setSelection(uuids: string[], paths?: string[]): void;
/**
* Peers in a DIFFERENT tool, one entry per awareness client. Unlike the
* room roster this INCLUDES the own user's other tabs — one person with
* the schematic and the board open gets classic cross-probing.
*/
peers(): CrossAppPeer[];
/** Fires on every awareness change in the project room. Returns unsubscribe. */
subscribe(cb: () => void): () => void;
destroy(): void;
}
export async function startCrossAppPresence(opts: {
projectId: string;
provider: ProviderConfig;
user: PresenceUser;
tool: string;
}): Promise<CrossAppHandle | undefined> {
if (opts.provider.kind === "none") return undefined;
const room = presenceRoomId(opts.projectId);
const doc = new Y.Doc();
let provider: YjsProvider;
try {
provider = await connectProvider(doc, opts.provider, { room });
} catch (err) {
clog("cross-app: provider connect failed —", String(err));
doc.destroy();
return undefined;
}
const awareness = provider.awareness;
if (!awareness) {
provider.destroy();
doc.destroy();
return undefined;
}
let selection: string[] = [];
let selectionPaths: string[] | undefined;
const publish = () => {
const state: PresenceState = {
// Reuse the bound room's claimed color so one user is one color
// everywhere (same rule as the eeschema skeleton states).
user: { ...opts.user, color: claimedPresenceColor(opts.user.id) ?? opts.user.color },
tool: opts.tool,
cursor: null,
selection,
...(selectionPaths?.length ? { selectionPaths } : {}),
updatedAt: Date.now(),
};
awareness.setLocalState(state);
};
publish();
clog("cross-app: joined project presence room", room, "as", opts.tool);
const subscribers = new Set<() => void>();
const onChange = () => {
for (const cb of subscribers) cb();
};
awareness.on("change", onChange);
// Fast removal on tab close (same rationale as presence.ts).
const onPageHide = () => awareness.setLocalState(null);
if (typeof window !== "undefined") {
window.addEventListener("pagehide", onPageHide);
}
let destroyed = false;
return {
setSelection(uuids, paths) {
selection = uuids;
selectionPaths = paths;
publish();
},
peers() {
const out: CrossAppPeer[] = [];
for (const [clientId, raw] of awareness.getStates()) {
if (clientId === awareness.clientID) continue;
const parsed = presenceStateSchema.safeParse(raw);
if (!parsed.success) continue;
// Same-tool peers are the per-file rooms' business (and may not even
// share a document with us) — cross-app only maps across editors.
if (parsed.data.tool === opts.tool) continue;
out.push({ clientId, state: parsed.data });
}
return out.sort((a, b) => a.clientId - b.clientId);
},
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
destroy() {
if (destroyed) return;
destroyed = true;
if (typeof window !== "undefined") {
window.removeEventListener("pagehide", onPageHide);
}
awareness.off("change", onChange);
subscribers.clear();
awareness.setLocalState(null);
provider.destroy();
doc.destroy();
},
};
}

View file

@ -1,7 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { colorForUser } from "@pcbjam/shared";
import { bindKicadPresence, hasPresenceBridge, type PresenceKicadWindow } from "./presence-kicad";
import { colorForUser, type PresenceState } from "@pcbjam/shared";
import {
bindKicadPresence,
hasPresenceBridge,
parseSelectionEmit,
xselFromPeerState,
type PresenceKicadWindow,
} from "./presence-kicad";
import type { PresenceHandle, PresencePeer } from "./presence";
import type { CrossAppHandle, CrossAppPeer } from "./cross-app";
/**
* presence-kicad bridge unit tests (collab-presence 0002): a fake Module +
@ -123,3 +130,120 @@ describe("bindKicadPresence", () => {
expect(hasPresenceBridge(undefined)).toBe(false);
});
});
// ── cross-app selection (0006) ───────────────────────────────────────────────
function crossState(tool: string, over: Partial<PresenceState> = {}): PresenceState {
return {
user: { id: "bob", name: "bob", color: colorForUser("bob") },
tool,
cursor: null,
selection: [],
updatedAt: 1,
...over,
};
}
function stubCrossApp(initial: CrossAppPeer[] = []) {
let peers = initial;
const subscribers = new Set<() => void>();
const handle: CrossAppHandle & { firePeers(p: CrossAppPeer[]): void } = {
setSelection: vi.fn(),
peers: () => peers,
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
destroy: vi.fn(),
firePeers(p) {
peers = p;
for (const cb of subscribers) cb();
},
};
return handle;
}
describe("parseSelectionEmit", () => {
it("handles the bare array (eeschema / pre-0006) and object shapes", () => {
expect(parseSelectionEmit('["u1","u2"]')).toEqual({ uuids: ["u1", "u2"] });
expect(parseSelectionEmit('{"uuids":["u1"],"fpPaths":["/p1"]}')).toEqual({
uuids: ["u1"],
fpPaths: ["/p1"],
});
expect(parseSelectionEmit('{"uuids":["u1"]}')).toEqual({ uuids: ["u1"] });
});
it("drops non-string entries and returns null on malformed payloads", () => {
expect(parseSelectionEmit('["u1",42]')).toEqual({ uuids: ["u1"] });
expect(parseSelectionEmit("not json")).toBeNull();
expect(parseSelectionEmit("42")).toBeNull();
});
});
describe("xselFromPeerState", () => {
it("maps a pcbnew peer's footprint paths to symbol uuids", () => {
expect(
xselFromPeerState(crossState("pcbnew", { selectionPaths: ["/sheet-1/sym-1", "/sym-2"] })),
).toEqual(["sym-1", "sym-2"]);
// Board selection uuids alone (no paths) map to nothing in eeschema.
expect(xselFromPeerState(crossState("pcbnew", { selection: ["board-uuid"] }))).toEqual([]);
});
it("passes an eeschema peer's symbol uuids through, and skips other tools", () => {
expect(xselFromPeerState(crossState("eeschema", { selection: ["sym-1"] }))).toEqual(["sym-1"]);
expect(xselFromPeerState(crossState("pl_editor", { selection: ["u1"] }))).toEqual([]);
});
});
describe("bindKicadPresence × crossApp", () => {
it("forwards selection emits (uuids + fpPaths) into the cross-app room", () => {
const mod = fakeModule();
const win: PresenceKicadWindow = {};
const presence = stubPresence();
const crossApp = stubCrossApp();
bindKicadPresence({ mod, win, presence, crossApp });
win.kicadCollab!.onSelection!('{"uuids":["u1"],"fpPaths":["/p1"]}');
expect(presence.setSelection).toHaveBeenCalledWith(["u1"]);
expect(crossApp.setSelection).toHaveBeenCalledWith(["u1"], ["/p1"]);
win.kicadCollab!.onSelection!('["u2"]');
expect(crossApp.setSelection).toHaveBeenCalledWith(["u2"], undefined);
});
it("seeds cross-app from kicadCollabGetSelectionFull when present", () => {
const mod = {
...fakeModule(),
kicadCollabGetSelectionFull: vi.fn(() => '{"uuids":["pre"],"fpPaths":["/pp"]}'),
};
const crossApp = stubCrossApp();
bindKicadPresence({ mod, win: {}, presence: stubPresence(), crossApp });
expect(crossApp.setSelection).toHaveBeenCalledWith(["pre"], ["/pp"]);
});
it("appends cross-app peers to the remote snapshot as xsel ghost entries", async () => {
const mod = fakeModule();
const presence = stubPresence();
const crossApp = stubCrossApp();
bindKicadPresence({ mod, win: {}, presence, crossApp });
crossApp.firePeers([
{ clientId: 7, state: crossState("eeschema", { selection: ["sym-1"] }) },
// Empty mapped selection → no snapshot entry.
{ clientId: 8, state: crossState("pcbnew", { selection: ["board-uuid"] }) },
]);
await new Promise((r) => setTimeout(r, 60));
const snapshot = JSON.parse(mod.kicadCollabSetRemote.mock.calls.at(-1)![0]);
expect(snapshot.peers).toEqual([
{
id: "bob#x7",
name: "bob · sch",
color: colorForUser("bob"),
cursor: null,
selection: [],
xsel: ["sym-1"],
},
]);
});
});

View file

@ -1,5 +1,7 @@
import { symbolUuidFromFootprintPath } from "@pcbjam/shared";
import { clog } from "./debug";
import type { PresenceHandle, PresencePeer } from "./presence";
import type { CrossAppHandle } from "./cross-app";
/**
* Wire the C++ presence bridge (collab-presence 0002) to the awareness layer:
@ -23,6 +25,9 @@ export interface PresenceKicadModule {
kicadCollabSetRemote(json: string): void;
kicadCollabGetViewport(): string;
kicadCollabGetSelection(): string;
/** 0006 (pcbnew builds): `{uuids, fpPaths}` — uuids plus the selected
* footprints' schematic paths. Absent on older wasm. */
kicadCollabGetSelectionFull?(): string;
}
export interface PresenceKicadWindow {
@ -53,24 +58,78 @@ export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
const PUSH_THROTTLE_MS = 30;
/**
* Parse a C++ selection emit (0006): pcbnew emits `{uuids, fpPaths}` (paths =
* the selected footprints' `GetPath()` strings), eeschema and older builds a
* bare uuid array. Returns null on a malformed payload (callers then keep the
* last published selection, the pre-0006 behavior).
*/
export function parseSelectionEmit(
json: string,
): { uuids: string[]; fpPaths?: string[] } | null {
try {
const v: unknown = JSON.parse(json);
if (Array.isArray(v)) {
return { uuids: v.filter((u): u is string => typeof u === "string") };
}
if (v && typeof v === "object") {
const o = v as { uuids?: unknown; fpPaths?: unknown };
return {
uuids: Array.isArray(o.uuids)
? o.uuids.filter((u): u is string => typeof u === "string")
: [],
...(Array.isArray(o.fpPaths)
? { fpPaths: o.fpPaths.filter((p): p is string => typeof p === "string") }
: {}),
};
}
} catch {
/* malformed */
}
return null;
}
/**
* The cross-app highlight targets a peer's state maps to in THIS editor
* (0006): a pcbnew peer's footprint paths become symbol uuids (eeschema
* resolves them directly); an eeschema peer's selection uuids ARE the symbol
* uuids (pcbnew suffix-matches them against footprint paths). Tools without
* a counterpart map to nothing.
*/
export function xselFromPeerState(state: {
tool: string;
selection: string[];
selectionPaths?: string[];
}): string[] {
if (state.tool === "pcbnew") {
return (state.selectionPaths ?? [])
.map(symbolUuidFromFootprintPath)
.filter((u): u is string => u !== null);
}
if (state.tool === "eeschema") return state.selection;
return [];
}
const TOOL_TAG: Record<string, string> = { pcbnew: "pcb", eeschema: "sch" };
export function bindKicadPresence(opts: {
mod: PresenceKicadModule;
win: PresenceKicadWindow;
presence: PresenceHandle;
/** 0006: the project presence room — cross-app selection in/out. */
crossApp?: CrossAppHandle;
onViewport?: (vp: ViewportState) => void;
}): { destroy(): void } {
const { mod, win, presence } = opts;
const { mod, win, presence, crossApp } = opts;
// C++ → awareness ------------------------------------------------------------
win.kicadCollab = {
...win.kicadCollab,
onSelection: (uuidsJson) => {
try {
const uuids = JSON.parse(uuidsJson) as string[];
presence.setSelection(Array.isArray(uuids) ? uuids : []);
} catch {
/* malformed emit — keep the last published selection */
}
const parsed = parseSelectionEmit(uuidsJson);
if (!parsed) return; // malformed emit — keep the last published selection
presence.setSelection(parsed.uuids);
crossApp?.setSelection(parsed.uuids, parsed.fpPaths);
},
onCursor: (x, y, active) => {
presence.setCursor(active ? { x, y } : null);
@ -80,10 +139,16 @@ export function bindKicadPresence(opts: {
},
};
// Seed: the tab may attach with a selection already made (e.g. rebind).
// Seed: the tab may attach with a selection already made (e.g. rebind). The
// Full variant (pcbnew 0006) also carries footprint paths for cross-app.
try {
const seed = JSON.parse(mod.kicadCollabGetSelection() || "[]") as string[];
if (Array.isArray(seed) && seed.length) presence.setSelection(seed);
const seed = parseSelectionEmit(
(mod.kicadCollabGetSelectionFull?.() ?? mod.kicadCollabGetSelection()) || "[]",
);
if (seed?.uuids.length) {
presence.setSelection(seed.uuids);
crossApp?.setSelection(seed.uuids, seed.fpPaths);
}
} catch {
/* bridge present but frame not up yet — the first emit will seed */
}
@ -91,7 +156,16 @@ export function bindKicadPresence(opts: {
// awareness → C++ ------------------------------------------------------------
const pushRemote = () => {
const peers = presence.peers();
const snapshot = {
const snapshot: {
peers: Array<{
id: string;
name: string;
color: string;
cursor: { x: number; y: number } | null;
selection: string[];
xsel?: string[];
}>;
} = {
peers: peers.map((p: PresencePeer) => ({
id: p.user.id,
name: p.user.name,
@ -100,6 +174,21 @@ export function bindKicadPresence(opts: {
selection: p.selection,
})),
};
// Cross-app peers (0006): rendered as ghost outlines on the mapped items.
// One entry per awareness CLIENT (own other tabs included — that's the
// single-user cross-probe), tagged with the source editor.
for (const p of crossApp?.peers() ?? []) {
const xsel = xselFromPeerState(p.state);
if (!xsel.length) continue;
snapshot.peers.push({
id: `${p.state.user.id}#x${p.clientId}`,
name: `${p.state.user.name} · ${TOOL_TAG[p.state.tool] ?? p.state.tool}`,
color: p.state.user.color,
cursor: null,
selection: [],
xsel,
});
}
mod.kicadCollabSetRemote(JSON.stringify(snapshot));
};
@ -113,6 +202,7 @@ export function bindKicadPresence(opts: {
};
const unsubscribe = presence.subscribe(schedulePush);
const unsubscribeCross = crossApp?.subscribe(schedulePush);
mod.kicadCollabPresenceStart();
pushRemote();
@ -121,6 +211,7 @@ export function bindKicadPresence(opts: {
return {
destroy() {
unsubscribe();
unsubscribeCross?.();
if (pushTimer) clearTimeout(pushTimer);
pushTimer = undefined;
if (win.kicadCollab) {

View file

@ -67,6 +67,12 @@ export function resetPresenceColorClaims(): void {
g_claims.clear();
}
/** The session's claimed color for a user, if any — sibling rooms (skeletons,
* the 0006 project presence room) reuse it so one user is one color everywhere. */
export function claimedPresenceColor(userId: string): string | undefined {
return g_claims.get(userId);
}
/** Least-used palette color among the OTHER users in `states` — the lowest
* free slot while the room is smaller than the palette, fair reuse after. */
function lowestFreeColor(