From 9a19b96b6c5dc08bd7ac44acfabcc01303c65ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 25 Aug 2026 13:55:01 +0200 Subject: [PATCH] =?UTF-8?q?standalone:=20file-change=20hints=20=E2=80=94?= =?UTF-8?q?=20~files=20watch,=20sibling=20restage,=20target=20notice=20(pr?= =?UTF-8?q?oject-sync=200002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayDocFacade.onFiles + hint-only channel; files-watch router (Tier 0 echo/observed bookkeeping, Tier 1 debounced sibling restage, Tier 2 open- target notice); WasmTool/ToolPage wiring with observed-revision seams. Bumps pcbjam-shared (4fd6af2). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa --- web/pcbjam-shared | 2 +- web/standalone/src/components/WasmTool.tsx | 50 +++++ web/standalone/src/lib/api.ts | 14 ++ web/standalone/src/lib/project-source.ts | 21 ++ web/standalone/src/pages/ToolPage.tsx | 6 + .../src/wasm/collab/files-watch.test.ts | 153 +++++++++++++++ web/standalone/src/wasm/collab/files-watch.ts | 184 ++++++++++++++++++ web/standalone/src/wasm/collab/gateway.ts | 23 ++- 8 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 web/standalone/src/wasm/collab/files-watch.test.ts create mode 100644 web/standalone/src/wasm/collab/files-watch.ts diff --git a/web/pcbjam-shared b/web/pcbjam-shared index 9050630..4fd6af2 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit 905063094caf183420f09d09e973132132481d2b +Subproject commit 4fd6af24a2a8d818970931bbf13d15c9818e6169 diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 5323708..9b64095 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -72,9 +72,11 @@ import { memfsFilePath, memfsProjectDir, TOOL_BUNDLE, TOOL_FRAME } from "@/wasm/ import { driveProjectIntoTool, readStagedFile, + restageFile, usedLibNicknames, type ToolFile, } from "@/wasm/kicad-runner"; +import { startFilesWatch, type FilesWatchHandle } from "@/wasm/collab/files-watch"; import { resolveSheetHierarchy } from "@/wasm/collab/sheet-hierarchy"; import { dump as dumpTrace, mark } from "@/wasm/load-trace"; import { errorMessage, isTerminalError } from "@/wasm/terminal-error"; @@ -974,6 +976,8 @@ export function WasmTool({ targetPath, fetchBytes, onStagedRevision, + observedRevision, + rememberObservedRevision, saveBytes, createFile, docSource, @@ -1015,6 +1019,15 @@ export function WasmTool({ * the CAS ancestry `saveBytes` must publish against (see DriveOptions). */ onStagedRevision?: (relPath: string, revision: number) => void; + /** + * Files-route change hints (project-sync 0002): the latest server revision + * this client observed for a path (its own PUT ack — the echo check) and + * the recorder for revisions learned from a peer's hint. Absent ⇒ hints + * still restage siblings but every hint stamped with our user is treated + * as a peer's (no echo suppression). + */ + observedRevision?: (relPath: string) => number | undefined; + rememberObservedRevision?: (relPath: string, revision: number) => void; /** * Persist one file the user saved in the editor (File→Save writes MEMFS, then * the wasm fires window.kicadCollab.onSave → this). API upload for backend @@ -1072,6 +1085,7 @@ export function WasmTool({ // eeschema sheet rebinds — the bridge re-reads it on every startPresence. const crossAppRef = React.useRef(null); const siblingRestageRef = React.useRef(null); + const filesWatchRef = React.useRef(null); // Set at the boot effect's cleanup; deferred starters bail on it (the // sibling-restage idle stagger can fire after unmount). const disposedRef = React.useRef(false); @@ -1252,6 +1266,8 @@ export function WasmTool({ crossAppRef.current = null; siblingRestageRef.current?.destroy(); siblingRestageRef.current = null; + filesWatchRef.current?.destroy(); + filesWatchRef.current = null; driftRef.current?.stop(); driftRef.current = null; // Tears down every warm room's provider/doc (the only place providers are @@ -2287,6 +2303,40 @@ export function WasmTool({ }); } } + // Files-route change hints (project-sync 0002 §3): peers' PUT-channel + // writes (.kicad_pro after assign-footprints, uploads, job resaves) + // restage into MEMFS; a hint for the open non-room target becomes a + // reload/conflict notice. Rides the same gateway socket as presence. + if ((tool === "pcbnew" || tool === "eeschema") && !readOnly && !collabOptOut) { + void startFilesWatch({ + scopeId, + projectId, + provider: yjsProviderConfig(), + targetPath, + selfUser: presenceUser().id, + knownPaths: files.map((f) => f.path), + isRoomBacked: (p) => roomBacked.has(p), + observedRevision: (p) => observedRevision?.(p), + rememberObserved: (p, r) => rememberObservedRevision?.(p, r), + fetchBytes, + restage: (p, bytes) => restageFile(win, slug, p, bytes, append), + onNewPath: (p) => { + if (p.endsWith(".kicad_sch")) void sheetManagerRef.current?.onboard(p); + }, + onTargetChanged: (c) => { + const who = c.by ?? "a collaborator"; + setStatus( + `${c.path} was updated by ${who} (rev ${c.revision}) — reload to see it; your next save will report a conflict`, + ); + }, + onListingStale: () => append("[files] hint gap — project listing is stale until reload"), + log: append, + }).then((handle) => { + if (!handle) return; + if (disposedRef.current) handle.destroy(); + else filesWatchRef.current = handle; + }); + } // Viewer selection feed (viewer-panels): read-only sessions never // bind presence (no room, no awareness), so the SelectionInspector's // store is fed by a minimal onSelection handler + the C++ canvas diff --git a/web/standalone/src/lib/api.ts b/web/standalone/src/lib/api.ts index 591734a..041f1b4 100644 --- a/web/standalone/src/lib/api.ts +++ b/web/standalone/src/lib/api.ts @@ -165,6 +165,20 @@ export function rememberFileBaseRevision( projectSource().rememberBaseRevision?.(slug, relPath, revision); } +/** Latest server revision seen for a path (project-sync 0002 echo check). */ +export function observedFileRevision(slug: string, relPath: string): number | undefined { + return projectSource().observedRevision?.(slug, relPath); +} + +/** Record a revision learned from a `files` hint; observed only, never base. */ +export function rememberFileObservedRevision( + slug: string, + relPath: string, + revision: number, +): void { + projectSource().rememberObservedRevision?.(slug, relPath, revision); +} + /** Observe the server revision after an ambiguous save; never rebases this model. */ export async function refreshFileRevision( slug: string, diff --git a/web/standalone/src/lib/project-source.ts b/web/standalone/src/lib/project-source.ts index e82a9ed..11e86bc 100644 --- a/web/standalone/src/lib/project-source.ts +++ b/web/standalone/src/lib/project-source.ts @@ -91,6 +91,14 @@ export interface ProjectSource { * was vouched against, so it IS the model's ancestry, not merely observed. */ rememberBaseRevision?(slug: string, relPath: string, revision: number): void; + /** + * Latest server revision this source has SEEN for a path (listing, GET or + * PUT response, or a file-change hint) — diagnostic/observed only, never a + * write precondition. Undefined when the path was never observed. + */ + observedRevision?(slug: string, relPath: string): number | undefined; + /** Record an observed revision (e.g. from a `files` hint). Never rebases. */ + rememberObservedRevision?(slug: string, relPath: string, revision: number): void; } // --- remote (REST backend over the shared contract) --------------------------- @@ -270,6 +278,12 @@ function remoteProjectSource(): ProjectSource { rememberObservedRevision(slug, relPath, revision); baseRevisions.set(revisionKey(slug, relPath), revision); }, + observedRevision(slug, relPath) { + return observedRevisions.get(revisionKey(slug, relPath)); + }, + rememberObservedRevision(slug, relPath, revision) { + rememberObservedRevision(slug, relPath, revision); + }, // Editor saves publish through the CAS PUT (findings D-3 client half): the // expected revision is the model's ANCESTRY (baseRevisions), never the // latest observed metadata — so a save issued after a conflict cannot @@ -509,6 +523,13 @@ function compositeProjectSource( rememberBaseRevision: (slug, p, revision) => { void route(slug).then((s) => s.rememberBaseRevision?.(slug, p, revision)); }, + // Composite reads route asynchronously; the observed getter is sync, so + // it answers from whichever source already routed this slug (primary + // first — local projects never carry server revisions). + observedRevision: (slug, p) => primary.observedRevision?.(slug, p), + rememberObservedRevision: (slug, p, revision) => { + void route(slug).then((s) => s.rememberObservedRevision?.(slug, p, revision)); + }, }; } diff --git a/web/standalone/src/pages/ToolPage.tsx b/web/standalone/src/pages/ToolPage.tsx index d7fcf0d..5b966cd 100644 --- a/web/standalone/src/pages/ToolPage.tsx +++ b/web/standalone/src/pages/ToolPage.tsx @@ -5,7 +5,9 @@ import { Loader2 } from "lucide-react"; import { createProjectFileIfMissing, fetchFileBytes, + observedFileRevision, rememberFileBaseRevision, + rememberFileObservedRevision, uploadFileBytes, useProjectBoot, useSourceDescriptor, @@ -104,6 +106,10 @@ export function ToolPage() { onStagedRevision={(relPath, revision) => rememberFileBaseRevision(slug, relPath, revision) } + observedRevision={(relPath) => observedFileRevision(slug, relPath)} + rememberObservedRevision={(relPath, revision) => + rememberFileObservedRevision(slug, relPath, revision) + } saveBytes={ readOnly ? undefined diff --git a/web/standalone/src/wasm/collab/files-watch.test.ts b/web/standalone/src/wasm/collab/files-watch.test.ts new file mode 100644 index 0000000..0924f4e --- /dev/null +++ b/web/standalone/src/wasm/collab/files-watch.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayFileChange } from "@pcbjam/shared"; +import { createFilesHintRouter, startFilesWatch, type FilesWatchOptions } from "./files-watch"; + +const ch = (over: Partial): GatewayFileChange => ({ + path: "x.kicad_pro", + revision: 2, + origin: "editor", + ...over, +}); + +function makeRouter(over: Partial = {}) { + const observed = new Map(); + const restaged: string[] = []; + const fetched: string[] = []; + const events: string[] = []; + const opts: FilesWatchOptions = { + scopeId: "s", + projectId: "p", + provider: { kind: "none" }, + targetPath: "board.kicad_pcb", + selfUser: "me", + isRoomBacked: (p) => p.endsWith(".kicad_sch"), + observedRevision: (p) => observed.get(p), + rememberObserved: (p, r) => void observed.set(p, r), + fetchBytes: async (p) => { + fetched.push(p); + return new Uint8Array([1]); + }, + restage: (p) => void restaged.push(p), + onNewPath: (p) => events.push(`new:${p}`), + onTargetChanged: (c) => events.push(`target:${c.path}@${c.revision}`), + onListingStale: () => events.push("stale"), + log: () => {}, + debounceMs: 0, + ...over, + }; + const router = createFilesHintRouter(opts); + router.seedKnown(["board.kicad_pcb", "x.kicad_pro", "root.kicad_sch"]); + return { router, observed, restaged, fetched, events }; +} + +const tick = () => new Promise((r) => setTimeout(r, 5)); + +describe("files hint router (project-sync 0002 §3)", () => { + it("Tier 1: a plain sibling is refetched + restaged once (debounced)", async () => { + const { router, restaged, fetched, observed } = makeRouter(); + router.handle(1, [ch({ revision: 2, by: "peer" })]); + router.handle(2, [ch({ revision: 3, by: "peer" })]); + await tick(); + expect(fetched).toEqual(["x.kicad_pro"]); + expect(restaged).toEqual(["x.kicad_pro"]); + expect(observed.get("x.kicad_pro")).toBe(3); + }); + + it("own echo (by me, revision already observed from the PUT ack) is ignored", async () => { + const { router, restaged, observed } = makeRouter(); + observed.set("x.kicad_pro", 2); + router.handle(1, [ch({ revision: 2, by: "me" })]); + await tick(); + expect(restaged).toEqual([]); + // Same user, a DIFFERENT revision (another tab) is not an echo. + router.handle(2, [ch({ revision: 3, by: "me" })]); + await tick(); + expect(restaged).toEqual(["x.kicad_pro"]); + }); + + it("room-backed paths are never restaged from the row", async () => { + const { router, restaged, events } = makeRouter(); + router.handle(1, [ch({ path: "root.kicad_sch", revision: 9, by: "peer" })]); + await tick(); + expect(restaged).toEqual([]); + expect(events).toEqual([]); + }); + + it("Tier 2: the open target on the PUT channel only notifies", async () => { + const { router, restaged, events } = makeRouter({ isRoomBacked: () => false }); + router.handle(1, [ch({ path: "board.kicad_pcb", revision: 4, by: "peer" })]); + await tick(); + expect(restaged).toEqual([]); + expect(events).toEqual(["target:board.kicad_pcb@4"]); + }); + + it("a seq gap flags the listing stale; a new path is announced", async () => { + const { router, events, restaged } = makeRouter(); + router.handle(5, [ch({ path: "sub.kicad_pro", revision: 1, by: "peer" })]); + router.handle(8, []); // gap + oversized-batch shape + await tick(); + expect(events).toEqual(["new:sub.kicad_pro", "stale"]); + expect(restaged).toEqual(["sub.kicad_pro"]); + }); + + it("deleted rows are logged, not restaged", async () => { + const log = vi.fn(); + const { router, restaged } = makeRouter({ log }); + router.handle(1, [ch({ revision: 0, deleted: true, origin: "job" })]); + await tick(); + expect(restaged).toEqual([]); + expect(log.mock.calls.some((c) => String(c[0]).includes("deleted"))).toBe(true); + }); + + it("destroy cancels pending restages", async () => { + const { router, restaged } = makeRouter({ debounceMs: 20 }); + router.handle(1, [ch({ by: "peer" })]); + router.destroy(); + await new Promise((r) => setTimeout(r, 40)); + expect(restaged).toEqual([]); + }); +}); + +describe("startFilesWatch", () => { + it("wires the hint source to the router and tears both down", async () => { + let cb: ((seq: number, c: GatewayFileChange[]) => void) | undefined; + const destroy = vi.fn(); + const restaged: string[] = []; + const handle = await startFilesWatch({ + scopeId: "s", + projectId: "p", + provider: { kind: "none" }, + knownPaths: [], + isRoomBacked: () => false, + observedRevision: () => undefined, + rememberObserved: () => {}, + fetchBytes: async () => new Uint8Array(), + restage: (p) => void restaged.push(p), + log: () => {}, + debounceMs: 0, + connect: async () => ({ onFiles: (f) => void (cb = f), destroy }), + }); + expect(handle).toBeDefined(); + cb!(1, [ch({ path: "n.kicad_pro", by: "peer" })]); + await tick(); + expect(restaged).toEqual(["n.kicad_pro"]); + handle!.destroy(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it("is a no-op without the gateway transport", async () => { + const handle = await startFilesWatch({ + scopeId: "s", + projectId: "p", + provider: { kind: "broadcastchannel" }, + knownPaths: [], + isRoomBacked: () => false, + observedRevision: () => undefined, + rememberObserved: () => {}, + fetchBytes: async () => new Uint8Array(), + restage: () => {}, + log: () => {}, + }); + expect(handle).toBeUndefined(); + }); +}); diff --git a/web/standalone/src/wasm/collab/files-watch.ts b/web/standalone/src/wasm/collab/files-watch.ts new file mode 100644 index 0000000..dd05362 --- /dev/null +++ b/web/standalone/src/wasm/collab/files-watch.ts @@ -0,0 +1,184 @@ +import { collabRoomId, FILES_DOC_PATH, type GatewayFileChange } from "@pcbjam/shared"; +import * as Y from "yjs"; +import { connectProvider, type ProviderConfig, type YjsProvider } from "./provider"; + +/** + * Files-route change watch (project-sync 0002 §3): subscribes the project's + * `~files` gateway channel and turns each `files` hint into INVALIDATION — + * never a write into an open document: + * + * - Tier 0 (always): observed-revision bookkeeping + own-echo suppression, + * `onListingStale` on a seq gap (reconnect / oversized batch). + * - Tier 1: a changed path that is neither the open target nor room-backed + * (`.kicad_pro`, netlists, a peer's new sheet) is re-fetched and restaged + * into MEMFS after a short debounce — the next "Update PCB from + * Schematic" / project-settings read sees the peer's version. + * - Tier 2: the open target itself changed on the PUT channel (a file with + * no room): `onTargetChanged` — the host shows a reload/conflict notice; + * the CAS lane keeps guarding the next save. + * + * Room-backed paths (listing hasYdoc/isLive) are ignored: the room is the + * truth there and already carries its own `touched`/frames. + */ + +export const FILES_RESTAGE_DEBOUNCE_MS = 400; + +export interface FilesWatchHandle { + destroy(): void; +} + +/** The slice of the gateway facade this consumes (structural; tests fake it). */ +export interface FilesHintSource { + onFiles(cb: (seq: number, changes: GatewayFileChange[]) => void): void; + destroy(): void; +} + +export interface FilesWatchOptions { + scopeId: string; + projectId: string; + provider: ProviderConfig; + /** The open document (Tier 2 routing). */ + targetPath?: string; + /** This session's user slug — hints stamped `by` us are echo candidates. */ + selfUser?: string; + /** Listing-derived: room-owned paths are never restaged from the row. */ + isRoomBacked: (relPath: string) => boolean; + /** Latest revision this client observed for a path (its own PUT ack). */ + observedRevision: (relPath: string) => number | undefined; + rememberObserved: (relPath: string, revision: number) => void; + /** Fresh bytes for a sibling (goes through the project source: the cache + * validator no longer matches, so this is a real GET + base-revision record). */ + fetchBytes: (relPath: string) => Promise; + /** MEMFS write of a sibling (kicad-runner restageFile). */ + restage: (relPath: string, bytes: Uint8Array) => void; + /** A path not in the boot listing appeared (a peer's "Add Sheet"). */ + onNewPath?: (relPath: string) => void; + onTargetChanged?: (change: GatewayFileChange) => void; + onListingStale?: () => void; + log: (m: string) => void; + /** Test seam: replace the gateway connect. */ + connect?: () => Promise; + /** Test seam. */ + debounceMs?: number; +} + +/** Pure hint router — exported for tests; `startFilesWatch` wires it to the gateway. */ +export function createFilesHintRouter(opts: FilesWatchOptions) { + const knownPaths = new Set(); + let lastSeq: number | null = null; + let destroyed = false; + const timers = new Map>(); + const debounceMs = opts.debounceMs ?? FILES_RESTAGE_DEBOUNCE_MS; + + const restageLater = (relPath: string): void => { + const prev = timers.get(relPath); + if (prev) clearTimeout(prev); + timers.set( + relPath, + setTimeout(() => { + timers.delete(relPath); + if (destroyed) return; + void opts + .fetchBytes(relPath) + .then((bytes) => { + if (destroyed) return; + opts.restage(relPath, bytes); + opts.log(`[files] restaged ${relPath} from a peer's write`); + }) + .catch((err) => opts.log(`[files] restage failed for ${relPath}: ${String(err)}`)); + }, debounceMs), + ); + }; + + const handle = (seq: number, changes: GatewayFileChange[]): void => { + if (destroyed) return; + if (lastSeq !== null && seq !== lastSeq + 1) { + opts.log(`[files] hint seq gap (${lastSeq} → ${seq}) — listing is stale`); + opts.onListingStale?.(); + } + lastSeq = seq; + for (const change of changes) { + // Own echo: our PUT ack already recorded exactly this revision. + if ( + opts.selfUser && + change.by === opts.selfUser && + opts.observedRevision(change.path) === change.revision + ) { + continue; + } + opts.rememberObserved(change.path, change.revision); + if (opts.isRoomBacked(change.path)) continue; // the room owns it + if (change.path === opts.targetPath) { + opts.onTargetChanged?.(change); + continue; + } + if (change.deleted) { + // v1: MEMFS keeps the last copy; the next boot drops it. + opts.log(`[files] ${change.path} deleted by ${change.by ?? "a job"} — kept in MEMFS until reload`); + continue; + } + if (!knownPaths.has(change.path)) { + knownPaths.add(change.path); + opts.onNewPath?.(change.path); + } + restageLater(change.path); + } + }; + + return { + handle, + seedKnown(paths: Iterable) { + for (const p of paths) knownPaths.add(p); + }, + destroy() { + destroyed = true; + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + }, + }; +} + +export async function startFilesWatch( + opts: FilesWatchOptions & { knownPaths: Iterable }, +): Promise { + if (opts.provider.kind !== "partykit" && !opts.connect) return undefined; // gateway only + const router = createFilesHintRouter(opts); + router.seedKnown(opts.knownPaths); + let source: FilesHintSource; + try { + source = opts.connect ? await opts.connect() : await connectGateway(opts); + } catch (err) { + opts.log(`[files] watch connect failed: ${String(err)}`); + router.destroy(); + return undefined; + } + source.onFiles(router.handle); + opts.log("[files] watching project file changes"); + return { + destroy() { + router.destroy(); + source.destroy(); + }, + }; +} + +async function connectGateway(opts: FilesWatchOptions): Promise { + const doc = new Y.Doc(); + const provider: YjsProvider = await connectProvider(doc, opts.provider, { + room: collabRoomId(opts.scopeId, opts.projectId, FILES_DOC_PATH), + passive: true, + }); + const facade = provider as YjsProvider & Partial; + if (typeof facade.onFiles !== "function") { + provider.destroy(); + doc.destroy(); + throw new Error("provider has no files channel"); + } + return { + onFiles: (cb) => facade.onFiles!(cb), + destroy: () => { + provider.destroy(); + doc.destroy(); + }, + }; +} diff --git a/web/standalone/src/wasm/collab/gateway.ts b/web/standalone/src/wasm/collab/gateway.ts index 2278ccf..3ba6bc9 100644 --- a/web/standalone/src/wasm/collab/gateway.ts +++ b/web/standalone/src/wasm/collab/gateway.ts @@ -25,9 +25,11 @@ import * as syncProtocol from "y-protocols/sync"; import type * as Y from "yjs"; import { type GatewayClientMsg, + type GatewayFileChange, type GatewayServerMsg, type GatewaySubMode, parseGatewayServerMsg, + FILES_DOC_PATH, PRESENCE_DOC_PATH, projectRoomName, tagGatewayFrame, @@ -267,6 +269,10 @@ export class GatewayDocFacade implements YjsProvider { private synced = false; private subEverSent = false; private readonly touchedCbs: Array<() => void> = []; + /** `files` hints (project-sync 0002) — only ever delivered on `~files`. */ + private readonly filesCbs: Array<(seq: number, changes: GatewayFileChange[]) => void> = []; + /** The hint-only channel: no doc, no awareness — control frames only. */ + private readonly isHintOnly: boolean; private readonly syncWaiters: Array<{ resolve: () => void; reject: (e: unknown) => void; @@ -281,8 +287,10 @@ export class GatewayDocFacade implements YjsProvider { opts: GatewayFacadeOpts, ) { this.isPresence = opts.docPath === PRESENCE_DOC_PATH; + this.isHintOnly = opts.docPath === FILES_DOC_PATH; this.docPath = opts.docPath; - this.mode = opts.passive && !this.isPresence ? "passive" : "active"; + this.mode = + (opts.passive && !this.isPresence) || this.isHintOnly ? "passive" : "active"; this.awareness = new Awareness(doc); this.conn = acquireConnection( opts.endpoint, @@ -319,7 +327,7 @@ export class GatewayDocFacade implements YjsProvider { */ activate(): Promise { if (this.dead) return Promise.reject(this.dead); - if (this.isPresence) return this.whenSynced(); + if (this.isPresence || this.isHintOnly) return this.whenSynced(); if (this.synced) return Promise.resolve(); const wasPassive = this.mode === "passive"; this.mode = "active"; @@ -358,6 +366,11 @@ export class GatewayDocFacade implements YjsProvider { this.touchedCbs.push(cb); } + /** `files` hints — project rows changed on the files route (0002 §1). */ + onFiles(cb: (seq: number, changes: GatewayFileChange[]) => void): void { + this.filesCbs.push(cb); + } + destroy(): void { if (this.destroyed) return; this.destroyed = true; @@ -387,6 +400,7 @@ export class GatewayDocFacade implements YjsProvider { if (this.destroyed || this.dead) return; this.subEverSent = true; for (const w of this.subWaiters.splice(0)) w.resolve(); + if (this.isHintOnly) return; // nothing to announce, nothing to sync // (Re)announce presence: a query for peers' states + our own, if any. this.sendQueryAwareness(); if (this.awareness.getLocalState() !== null) this.publishLocalAwareness(); @@ -423,6 +437,10 @@ export class GatewayDocFacade implements YjsProvider { if (this.mode === "active" && !this.isPresence) this.sendSyncStep1(); return; } + if (msg.t === "files") { + for (const cb of this.filesCbs) cb(msg.seq, msg.changes); + return; + } // touched for (const cb of this.touchedCbs) cb(); } @@ -497,6 +515,7 @@ export class GatewayDocFacade implements YjsProvider { } private publishLocalAwareness(): void { + if (this.isHintOnly) return; const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, MESSAGE_AWARENESS); encoding.writeVarUint8Array(