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:
parent
157d60f6b8
commit
3186986a0a
14 changed files with 1276 additions and 48 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 5f5effa4e0d9cab0261a5e0f771c7c4cb31b427d
|
||||
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5
|
||||
|
|
@ -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
|
||||
|
|
|
|||
108
web/standalone/src/wasm/collab/cross-app.test.ts
Normal file
108
web/standalone/src/wasm/collab/cross-app.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
141
web/standalone/src/wasm/collab/cross-app.ts
Normal file
141
web/standalone/src/wasm/collab/cross-app.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue