feat(collab): presence P3 — eeschema port (collab-presence 0003)
- eeschema_embind.cpp: presence section (0002 pattern, zero fork changes) —
wx canvas triggers + SCHEMATIC_LISTENER piggyback → post-settle
SCH_SELECTION_TOOL emit; throttled cursor; remote VIEW_OVERLAY render
(SCHEMATIC::ResolveItem, name tags, screen-constant via GAL matrix);
schCollab{PresenceStart,SetRemote,GetViewport,GetSelection,TestSelectFirst,
TestClearSelection}; kicad_editor_embind dispatches by active frame.
- sheet-manager: parked rooms carry SKELETON awareness states
({user,tool,sheetPath=bound sheet}) via publishSkeletons on switch + late
warm-up — the bound room's awareness then holds every project peer, so no
multi-room aggregation; presence.ts publishSkeleton helper.
- PresenceRoster: sheet-aware — peers on another sheet render dimmed with
'on <sheet>' tooltip; WasmTool un-gates the kicad presence bridge for
eeschema and threads activeSheetPath.
- tests: presence-eeschema.spec.ts (5 e2e, mirrors pcbnew incl. the px/IU
band at eeschema's 1e4/mm IU); presence.test.ts +2 (skeleton visibility,
no ghost cursor after rebind). eeschema-collab/subschema stay green.
Verified live: two tabs on demo.kicad_sch — peer cursor cross + label,
selection box + name tag, roster avatar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
parent
943e8fe4d2
commit
6bc54de92b
9 changed files with 982 additions and 29 deletions
|
|
@ -1,18 +1,43 @@
|
|||
import type { PresencePeer } from "@/wasm/collab/presence";
|
||||
|
||||
/**
|
||||
* "Who else is in this file" (collab-presence 0001): a compact facepile of the
|
||||
* room's OTHER users, one colored initial-avatar per person, fed by the collab
|
||||
* session's awareness. Rendered in the editor's top-right overlay stack next to
|
||||
* SourceChip; the parent hides it when there are no peers. Chip styling mirrors
|
||||
* SourceChip (solid fill + inset ring) so it is legible on any backdrop.
|
||||
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
|
||||
* the room's OTHER users, one colored initial-avatar per person, fed by the
|
||||
* collab session's awareness. For eeschema (per-sheet rooms + warm-pool
|
||||
* skeleton states) peers on a DIFFERENT sheet render dimmed, with the sheet
|
||||
* they're on in the tooltip. Rendered in the editor's top-right overlay stack
|
||||
* next to SourceChip; the parent hides it when there are no peers. Chip styling
|
||||
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
|
||||
*/
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
|
||||
function sheetLabel(sheetPath?: string): string {
|
||||
if (!sheetPath) return "";
|
||||
const base = sheetPath.split("/").pop() ?? sheetPath;
|
||||
return base.replace(/\.kicad_sch$/, "");
|
||||
}
|
||||
|
||||
export function PresenceRoster({
|
||||
peers,
|
||||
activeSheetPath,
|
||||
}: {
|
||||
peers: PresencePeer[];
|
||||
/** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */
|
||||
activeSheetPath?: string;
|
||||
}) {
|
||||
if (!peers.length) return null;
|
||||
const names = peers.map((p) => p.user.name).join(", ");
|
||||
const shown = peers.slice(0, MAX_AVATARS);
|
||||
|
||||
const sameSheet = (p: PresencePeer) =>
|
||||
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
|
||||
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
|
||||
const ordered = [...peers].sort((a, b) => Number(sameSheet(b)) - Number(sameSheet(a)));
|
||||
const names = ordered
|
||||
.map((p) =>
|
||||
sameSheet(p) ? p.user.name : `${p.user.name} (on ${sheetLabel(p.sheetPath) || "another sheet"})`,
|
||||
)
|
||||
.join(", ");
|
||||
const shown = ordered.slice(0, MAX_AVATARS);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="presence-roster"
|
||||
|
|
@ -23,9 +48,16 @@ export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
|
|||
<span
|
||||
key={p.user.id}
|
||||
data-presence-user={p.user.id}
|
||||
title={p.user.name}
|
||||
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
|
||||
title={
|
||||
sameSheet(p)
|
||||
? p.user.name
|
||||
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
|
||||
}
|
||||
style={{ backgroundColor: p.user.color }}
|
||||
className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0"
|
||||
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
|
||||
sameSheet(p) ? "" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
{p.user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -444,6 +444,9 @@ async function startSheetCollab(
|
|||
provider: yjsProviderConfig(),
|
||||
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
|
||||
onActiveChange: opts.onActiveChange,
|
||||
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
|
||||
// any sheet's roster shows the whole schematic's crew (0003).
|
||||
presenceUser: presenceUser(),
|
||||
log: opts.log,
|
||||
initial:
|
||||
opts.session && opts.targetPath
|
||||
|
|
@ -627,6 +630,9 @@ export function WasmTool({
|
|||
// the PresenceRoster chip next to SourceChip. Empty when collab is off, the
|
||||
// provider has no awareness (kind "none"), or nobody else is here.
|
||||
const [peers, setPeers] = React.useState<PresencePeer[]>([]);
|
||||
// eeschema: the sheet THIS client is bound to — the roster dims peers whose
|
||||
// skeleton state says they're on a different sheet (collab-presence 0003).
|
||||
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
|
||||
|
||||
const append = React.useCallback(
|
||||
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
|
||||
|
|
@ -772,10 +778,11 @@ export function WasmTool({
|
|||
presenceRef.current = presence;
|
||||
presence.subscribe(setPeers);
|
||||
setPeers(presence.peers());
|
||||
// Canvas presence (0002): cursor + selection emit and the remote
|
||||
// VIEW_OVERLAY render. pcbnew only until the eeschema port (0003); the
|
||||
// bridge gate also skips wasm builds predating the presence exports.
|
||||
if (tool === "pcbnew" && hasPresenceBridge(win.Module)) {
|
||||
setActiveSheetPath(sheetPath);
|
||||
// Canvas presence (0002 pcbnew / 0003 eeschema): cursor + selection emit
|
||||
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without
|
||||
// the exports and wasm builds predating them.
|
||||
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
|
||||
presenceBridgeRef.current = bindKicadPresence({
|
||||
mod: win.Module,
|
||||
win: win as unknown as PresenceKicadWindow,
|
||||
|
|
@ -1099,7 +1106,9 @@ export function WasmTool({
|
|||
where this project lives / whether Save persists. */}
|
||||
{ready && (peers.length > 0 || sourceDescriptor) && (
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
|
||||
{peers.length > 0 && <PresenceRoster peers={peers} />}
|
||||
{peers.length > 0 && (
|
||||
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} />
|
||||
)}
|
||||
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as Y from "yjs";
|
|||
import { Awareness } from "y-protocols/awareness";
|
||||
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
|
||||
import { connectAwarenessBroadcast } from "./awareness-bc";
|
||||
import { createPresence, type PresenceHandle } from "./presence";
|
||||
import { createPresence, publishSkeleton, type PresenceHandle } from "./presence";
|
||||
|
||||
/**
|
||||
* Presence unit tests (collab-presence 0001): two Awareness instances relayed
|
||||
|
|
@ -107,6 +107,86 @@ describe("presence over the BroadcastChannel awareness relay", () => {
|
|||
expect(a.presence.peers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("skeleton states mark parked-room users with their real sheet (0003)", async () => {
|
||||
// Two per-sheet rooms; alice is BOUND to root and PARKED in sub, bob is
|
||||
// bound to sub. Bob's roster (sub room) must show alice as being on root,
|
||||
// with no cursor/selection.
|
||||
const rootCh = `presence-test-${channelSeq++}`;
|
||||
const subCh = `presence-test-${channelSeq++}`;
|
||||
const aliceRoot = client(rootCh);
|
||||
const aliceSub = client(subCh);
|
||||
const bobSub = client(subCh);
|
||||
|
||||
aliceRoot.presence = createPresence({
|
||||
awareness: aliceRoot.awareness,
|
||||
user: user("alice"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "root.kicad_sch",
|
||||
});
|
||||
publishSkeleton(aliceSub.awareness, user("alice"), "eeschema", "root.kicad_sch");
|
||||
bobSub.presence = createPresence({
|
||||
awareness: bobSub.awareness,
|
||||
user: user("bob"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "sub/child.kicad_sch",
|
||||
});
|
||||
await settle();
|
||||
|
||||
const aliceSeenByBob = bobSub.presence.peers();
|
||||
expect(aliceSeenByBob.map((p) => p.user.id)).toEqual(["alice"]);
|
||||
expect(aliceSeenByBob[0]!.sheetPath).toBe("root.kicad_sch");
|
||||
expect(aliceSeenByBob[0]!.cursor).toBeNull();
|
||||
expect(aliceSeenByBob[0]!.selection).toEqual([]);
|
||||
|
||||
// Alice navigates into sub: full presence rebinds there (overwriting the
|
||||
// skeleton) — bob now sees her on HIS sheet, cursor live again.
|
||||
aliceSub.presence = createPresence({
|
||||
awareness: aliceSub.awareness,
|
||||
user: user("alice"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "sub/child.kicad_sch",
|
||||
});
|
||||
aliceSub.presence.setCursor({ x: 1, y: 2 });
|
||||
await settle();
|
||||
|
||||
const after = bobSub.presence.peers();
|
||||
expect(after[0]!.sheetPath).toBe("sub/child.kicad_sch");
|
||||
expect(after[0]!.cursor).toEqual({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
it("rebind away leaves no ghost cursor in the old room", async () => {
|
||||
// Alice bound in room1 with a live cursor; she navigates away: presence
|
||||
// destroy + skeleton must leave sheetPath pointing elsewhere, cursor null.
|
||||
const ch = `presence-test-${channelSeq++}`;
|
||||
const alice = client(ch);
|
||||
const bob = client(ch);
|
||||
alice.presence = createPresence({
|
||||
awareness: alice.awareness,
|
||||
user: user("alice"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "root.kicad_sch",
|
||||
});
|
||||
alice.presence.setCursor({ x: 9, y: 9 });
|
||||
bob.presence = createPresence({
|
||||
awareness: bob.awareness,
|
||||
user: user("bob"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "root.kicad_sch",
|
||||
});
|
||||
await settle();
|
||||
expect(bob.presence.peers()[0]!.cursor).toEqual({ x: 9, y: 9 });
|
||||
|
||||
alice.presence.destroy();
|
||||
alice.presence = undefined;
|
||||
publishSkeleton(alice.awareness, user("alice"), "eeschema", "sub/child.kicad_sch");
|
||||
await settle();
|
||||
|
||||
const ghost = bob.presence.peers();
|
||||
expect(ghost.map((p) => p.user.id)).toEqual(["alice"]);
|
||||
expect(ghost[0]!.cursor).toBeNull();
|
||||
expect(ghost[0]!.sheetPath).toBe("sub/child.kicad_sch");
|
||||
});
|
||||
|
||||
it("subscribe fires on change and setters keep sibling fields intact", async () => {
|
||||
const channel = `presence-test-${channelSeq++}`;
|
||||
const a = client(channel);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ export interface PresenceHandle {
|
|||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a SKELETON presence state into a room the user is connected to but
|
||||
* not looking at (eeschema warm pool, collab-presence 0003): identity + which
|
||||
* sheet they are actually on, no cursor/selection. Any sheet's roster can then
|
||||
* answer "who is in this schematic, and where". The bound room's full state is
|
||||
* owned by `createPresence` (which overwrites the skeleton on rebind).
|
||||
*/
|
||||
export function publishSkeleton(
|
||||
awareness: Awareness,
|
||||
user: PresenceUser,
|
||||
tool: string,
|
||||
sheetPath: string,
|
||||
): void {
|
||||
const state: PresenceState = {
|
||||
user,
|
||||
tool,
|
||||
sheetPath,
|
||||
cursor: null,
|
||||
selection: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
awareness.setLocalState(state);
|
||||
}
|
||||
|
||||
export function createPresence(opts: {
|
||||
awareness: Awareness;
|
||||
user: PresenceUser;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import type * as Y from "yjs";
|
||||
import { collabRoomId, fileToDoc, syncLayoutToY, type KicadDoc } from "@pcbjam/shared";
|
||||
import {
|
||||
collabRoomId,
|
||||
fileToDoc,
|
||||
syncLayoutToY,
|
||||
type KicadDoc,
|
||||
type PresenceUser,
|
||||
} from "@pcbjam/shared";
|
||||
import { connectKicadDoc, type KicadDocSession } from "./index";
|
||||
import { publishSkeleton } from "./presence";
|
||||
import {
|
||||
bindKicadCollab,
|
||||
moduleItemsBridge,
|
||||
|
|
@ -79,6 +86,15 @@ export interface SheetManagerOptions {
|
|||
* (re)start drift detection on the now-active doc.
|
||||
*/
|
||||
onActiveChange?: (active: ActiveSheet | null) => void;
|
||||
/**
|
||||
* Presence identity (collab-presence 0003). When set, every PARKED room in the
|
||||
* warm pool carries a skeleton awareness state ({user, tool, sheetPath: the
|
||||
* sheet the user is ACTUALLY on}) so any sheet's roster can answer "who is in
|
||||
* this schematic, and where". The BOUND room's full presence (cursor/selection)
|
||||
* is owned by the host via onActiveChange → createPresence, which overwrites
|
||||
* the skeleton on rebind.
|
||||
*/
|
||||
presenceUser?: PresenceUser;
|
||||
log: (m: string) => void;
|
||||
/**
|
||||
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
|
||||
|
|
@ -132,6 +148,19 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
});
|
||||
}
|
||||
|
||||
// Skeleton presence for every PARKED room (0003): mark this user as "in this
|
||||
// schematic, on `activePath`". The bound room is skipped — its full state is
|
||||
// published by the host's presence handle (rebound via onActiveChange).
|
||||
function publishSkeletons(): void {
|
||||
const user = opts.presenceUser;
|
||||
if (!user || !activePath) return;
|
||||
for (const [path, room] of rooms) {
|
||||
if (path === activePath) continue;
|
||||
const awareness = room.session.provider.awareness;
|
||||
if (awareness) publishSkeleton(awareness, user, "eeschema", activePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRoom(sheetPath: string): Promise<Room> {
|
||||
const existing = rooms.get(sheetPath);
|
||||
if (existing) return existing;
|
||||
|
|
@ -152,6 +181,14 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
};
|
||||
rooms.set(sheetPath, room);
|
||||
log(`[sheet] warm room connected: ${sheetPath}`);
|
||||
// A room warmed after the first bind starts parked — give it a skeleton
|
||||
// right away so its roster shows this user without waiting for a switch.
|
||||
if (activePath && sheetPath !== activePath && opts.presenceUser) {
|
||||
const awareness = session.provider.awareness;
|
||||
if (awareness) {
|
||||
publishSkeleton(awareness, opts.presenceUser, "eeschema", activePath);
|
||||
}
|
||||
}
|
||||
return room;
|
||||
})();
|
||||
|
||||
|
|
@ -219,6 +256,10 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
|
||||
activePath = sheetPath;
|
||||
opts.onActiveChange?.({ sheetPath, doc: room.doc, provider: room.session.provider });
|
||||
// AFTER the host rebound its full presence to the new room: refresh every
|
||||
// parked room's skeleton to point at the new sheet (incl. the old active
|
||||
// room, whose full state the host just cleared).
|
||||
publishSkeletons();
|
||||
}
|
||||
|
||||
function switchTo(sheetPath: string): Promise<void> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue