feat(ysync): drift-detection loop + fix second-tab ydoc adopt
- drift-detect.ts: every-N-edits + beforeunload check via kicadSave* (no save-hook), reports drift - WasmTool: start drift detection after collab; return collab handle; stop on cleanup - fix uuid-less docs (pl_editor): use ydocHasState in maybeConnectDocSession + seed() so a 2nd tab materializes the room instead of refetching the stale file - api: reportDrift + reportDriftBeacon (sendBeacon/keepalive) - global.d.ts: FS.unlink - bump web/pcbjam-shared pointer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d54e7afc8
commit
0efeb5d3d9
6 changed files with 246 additions and 16 deletions
|
|
@ -1 +1 @@
|
||||||
Subproject commit 43b28a47e7df8be7e659f9512eb8afa860719df5
|
Subproject commit f1240eaa02154311b9b6ca9d9cc30e054894d387
|
||||||
|
|
@ -5,8 +5,8 @@ import {
|
||||||
EXTENSION_TOOL,
|
EXTENSION_TOOL,
|
||||||
FILELESS_TOOLS,
|
FILELESS_TOOLS,
|
||||||
fileToDoc,
|
fileToDoc,
|
||||||
kicadItemsMap,
|
|
||||||
toolSchema,
|
toolSchema,
|
||||||
|
ydocHasState,
|
||||||
yToDoc,
|
yToDoc,
|
||||||
type KicadDoc,
|
type KicadDoc,
|
||||||
type Tool,
|
type Tool,
|
||||||
|
|
@ -29,7 +29,11 @@ import {
|
||||||
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||||
import type { KicadDocSession, KicadItemsWindow } from "@/wasm/collab";
|
import type {
|
||||||
|
KicadCollabHandle,
|
||||||
|
KicadDocSession,
|
||||||
|
KicadItemsWindow,
|
||||||
|
} from "@/wasm/collab";
|
||||||
import { clog, cwarn } from "@/wasm/collab/debug";
|
import { clog, cwarn } from "@/wasm/collab/debug";
|
||||||
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
||||||
|
|
@ -236,7 +240,11 @@ async function maybeConnectDocSession(
|
||||||
const room = collabRoomId(opts.projectId, opts.targetPath);
|
const room = collabRoomId(opts.projectId, opts.targetPath);
|
||||||
const session = await connectKicadDoc({ provider: yjsProviderConfig(), room });
|
const session = await connectKicadDoc({ provider: yjsProviderConfig(), room });
|
||||||
|
|
||||||
if (kicadItemsMap(session.doc).size === 0) {
|
// Use the full doc state (meta + layout + items), NOT just item count: a
|
||||||
|
// populated drawing sheet (pl_editor `.kicad_wks`) has zero uuid items, so an
|
||||||
|
// items-only check makes a joining tab refetch the stale file instead of
|
||||||
|
// materializing the shared doc's current state.
|
||||||
|
if (!ydocHasState(session.doc)) {
|
||||||
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
|
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
|
||||||
return { session };
|
return { session };
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +280,7 @@ async function maybeStartCollab(
|
||||||
log: (m: string) => void;
|
log: (m: string) => void;
|
||||||
onStatus: (t: string) => void;
|
onStatus: (t: string) => void;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<KicadCollabHandle | undefined> {
|
||||||
const collabParam = new URLSearchParams(win.location.search).get("collab");
|
const collabParam = new URLSearchParams(win.location.search).get("collab");
|
||||||
const mod = win.Module;
|
const mod = win.Module;
|
||||||
clog("maybeStartCollab gate:", {
|
clog("maybeStartCollab gate:", {
|
||||||
|
|
@ -289,11 +297,11 @@ async function maybeStartCollab(
|
||||||
// so detaching would silently drop every edit.
|
// so detaching would silently drop every edit.
|
||||||
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
|
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
|
||||||
clog("disabled (?collab=0) — skipping");
|
clog("disabled (?collab=0) — skipping");
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!COLLAB_TOOLS.has(opts.tool)) {
|
if (!COLLAB_TOOLS.has(opts.tool)) {
|
||||||
clog(`tool ${opts.tool} has no collab bridge — skipping`);
|
clog(`tool ${opts.tool} has no collab bridge — skipping`);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
|
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
|
||||||
cwarn(
|
cwarn(
|
||||||
|
|
@ -301,7 +309,7 @@ async function maybeStartCollab(
|
||||||
typeof mod?.kicadCollabSnapshotItems,
|
typeof mod?.kicadCollabSnapshotItems,
|
||||||
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
|
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
|
||||||
);
|
);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab");
|
const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab");
|
||||||
|
|
@ -312,14 +320,14 @@ async function maybeStartCollab(
|
||||||
// opened the file materialized from this very doc, attach + baseline only;
|
// opened the file materialized from this very doc, attach + baseline only;
|
||||||
// when the room was empty (API fallback), seed() file-seeds it as usual.
|
// when the room was empty (API fallback), seed() file-seeds it as usual.
|
||||||
clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc);
|
clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc);
|
||||||
attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
|
const handle = attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
|
||||||
seedDoc,
|
seedDoc,
|
||||||
editorMatchesDoc: opts.editorMatchesDoc,
|
editorMatchesDoc: opts.editorMatchesDoc,
|
||||||
});
|
});
|
||||||
opts.log(`[collab] attached to Y.Doc session`);
|
opts.log(`[collab] attached to Y.Doc session`);
|
||||||
opts.onStatus("Collab: connected");
|
opts.onStatus("Collab: connected");
|
||||||
clog("connected ✓");
|
clog("connected ✓");
|
||||||
return;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = yjsProviderConfig();
|
const provider = yjsProviderConfig();
|
||||||
|
|
@ -328,7 +336,7 @@ async function maybeStartCollab(
|
||||||
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
|
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
|
||||||
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
|
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
|
||||||
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
|
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
|
||||||
await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
|
const handle = await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
|
||||||
provider,
|
provider,
|
||||||
room,
|
room,
|
||||||
seedDoc,
|
seedDoc,
|
||||||
|
|
@ -336,6 +344,7 @@ async function maybeStartCollab(
|
||||||
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
||||||
opts.onStatus("Collab: connected");
|
opts.onStatus("Collab: connected");
|
||||||
clog("connected ✓");
|
clog("connected ✓");
|
||||||
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -405,6 +414,7 @@ export function WasmTool({
|
||||||
}) {
|
}) {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const startedRef = React.useRef(false);
|
const startedRef = React.useRef(false);
|
||||||
|
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||||
const [status, setStatus] = React.useState("Loading tool…");
|
const [status, setStatus] = React.useState("Loading tool…");
|
||||||
const [logs, setLogs] = React.useState<string[]>([]);
|
const [logs, setLogs] = React.useState<string[]>([]);
|
||||||
const [showLog, setShowLog] = React.useState(false);
|
const [showLog, setShowLog] = React.useState(false);
|
||||||
|
|
@ -540,7 +550,7 @@ export function WasmTool({
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
});
|
});
|
||||||
await maybeStartCollab(win, {
|
const collabHandle = await maybeStartCollab(win, {
|
||||||
tool,
|
tool,
|
||||||
slug,
|
slug,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -550,6 +560,21 @@ export function WasmTool({
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
});
|
});
|
||||||
|
// Drift detection: while this doc is collaboratively edited, periodically
|
||||||
|
// (every N edits + at session end) compare the WASM serialization to the
|
||||||
|
// Y.Doc and report any divergence. Gated on a real collab session.
|
||||||
|
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
||||||
|
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
|
||||||
|
driftRef.current = startDriftDetection({
|
||||||
|
doc: collabHandle.doc,
|
||||||
|
mod: win.Module,
|
||||||
|
win,
|
||||||
|
tool,
|
||||||
|
slug,
|
||||||
|
targetPath,
|
||||||
|
log: append,
|
||||||
|
});
|
||||||
|
}
|
||||||
// Tool booted + project opened. Wait for the wx UI to actually build
|
// Tool booted + project opened. Wait for the wx UI to actually build
|
||||||
// before dropping the overlay, so we don't reveal a still-blank editor.
|
// before dropping the overlay, so we don't reveal a still-blank editor.
|
||||||
await waitForWxUi(win);
|
await waitForWxUi(win);
|
||||||
|
|
@ -563,6 +588,8 @@ export function WasmTool({
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||||
|
driftRef.current?.stop();
|
||||||
|
driftRef.current = null;
|
||||||
oom.stop();
|
oom.stop();
|
||||||
};
|
};
|
||||||
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
contract,
|
contract,
|
||||||
|
type DriftReportBody,
|
||||||
type Lib,
|
type Lib,
|
||||||
type Project,
|
type Project,
|
||||||
type ProjectWithFiles,
|
type ProjectWithFiles,
|
||||||
|
|
@ -79,6 +80,36 @@ export async function fetchFileBytes(
|
||||||
return new Uint8Array(await res.arrayBuffer());
|
return new Uint8Array(await res.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- collaboration drift reporting (ysync) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a detected ydoc/wasm drift (the editor's periodic, every-N-edits check).
|
||||||
|
* Best-effort: a failed report must never disrupt editing, so callers ignore
|
||||||
|
* rejections.
|
||||||
|
*/
|
||||||
|
export async function reportDrift(
|
||||||
|
slug: string,
|
||||||
|
body: DriftReportBody,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.reportDrift({ params: { project: slug }, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget drift report that survives the page closing — used by the
|
||||||
|
* session-end (`beforeunload`) check. `sendBeacon` queues the POST past unload;
|
||||||
|
* a keepalive `fetch` is the fallback when the beacon is rejected (too large).
|
||||||
|
*/
|
||||||
|
export function reportDriftBeacon(slug: string, body: DriftReportBody): void {
|
||||||
|
const url = `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/drift`;
|
||||||
|
const blob = new Blob([JSON.stringify(body)], { type: "application/json" });
|
||||||
|
try {
|
||||||
|
if (navigator.sendBeacon(url, blob)) return;
|
||||||
|
} catch {
|
||||||
|
/* fall through to keepalive fetch */
|
||||||
|
}
|
||||||
|
void fetch(url, { method: "POST", body: blob, keepalive: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist one saved file back to the backend via the multipart upload route
|
* Persist one saved file back to the backend via the multipart upload route
|
||||||
* (POST /api/projects/:project/files — upserts by (project, path); the form
|
* (POST /api/projects/:project/files — upserts by (project, path); the form
|
||||||
|
|
|
||||||
166
web/standalone/src/wasm/collab/drift-detect.ts
Normal file
166
web/standalone/src/wasm/collab/drift-detect.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* Drift detection (ysync): periodically compare what the WASM editor would
|
||||||
|
* actually serialize on save against what the Y.Doc represents, and report any
|
||||||
|
* divergence so it can be tracked down later.
|
||||||
|
*
|
||||||
|
* Cadence (deliberately infrequent): a check runs every N Y.Doc updates and once
|
||||||
|
* more at session end (`beforeunload`). There is NO periodic timer — an editor
|
||||||
|
* left open for hours must never flood the backend; missing the occasional drift
|
||||||
|
* is acceptable (another session reports it).
|
||||||
|
*
|
||||||
|
* The "true WASM representation" comes from the per-tool `kicadSave*` embind fns,
|
||||||
|
* which serialize the current model to a scratch MEMFS path using the same writer
|
||||||
|
* File→Save uses but WITHOUT firing `kicadCollab.onSave` — so a drift check never
|
||||||
|
* looks like a user save (no upload, no peer-tab dirty flag).
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
docDelta,
|
||||||
|
type DriftReportBody,
|
||||||
|
fileToDoc,
|
||||||
|
isEmptyKicadDelta,
|
||||||
|
type Tool,
|
||||||
|
yToDoc,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { reportDrift, reportDriftBeacon } from "@/lib/api";
|
||||||
|
import { memfsFilePath } from "../constants";
|
||||||
|
|
||||||
|
/** The embind serialize fn per collab-capable tool (see module header). */
|
||||||
|
const SAVE_FN = {
|
||||||
|
pcbnew: "kicadSaveBoard",
|
||||||
|
eeschema: "kicadSaveSchematic",
|
||||||
|
pl_editor: "kicadSaveDrawingSheet",
|
||||||
|
} as const satisfies Partial<Record<Tool, string>>;
|
||||||
|
|
||||||
|
type CollabTool = keyof typeof SAVE_FN;
|
||||||
|
|
||||||
|
interface DriftModule {
|
||||||
|
kicadSaveBoard?(path: string): void;
|
||||||
|
kicadSaveSchematic?(path: string): void;
|
||||||
|
kicadSaveDrawingSheet?(path: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriftDetectOptions {
|
||||||
|
doc: Y.Doc;
|
||||||
|
mod: DriftModule;
|
||||||
|
win: { FS?: EmscriptenFS };
|
||||||
|
tool: Tool;
|
||||||
|
slug: string;
|
||||||
|
targetPath: string;
|
||||||
|
/** Run a drift check every N Y.Doc updates (default 50). No periodic timer. */
|
||||||
|
everyN?: number;
|
||||||
|
log?: (m: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EVERY_N = 50;
|
||||||
|
|
||||||
|
function isCollabTool(tool: Tool): tool is CollabTool {
|
||||||
|
return tool in SAVE_FN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriftDetector {
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start drift detection for one collaboratively-edited document. Returns a handle
|
||||||
|
* whose `stop()` detaches the Y.Doc observer and the unload listener. A no-op
|
||||||
|
* detector is returned for tools without a serialize fn (so callers need no
|
||||||
|
* special-casing).
|
||||||
|
*/
|
||||||
|
export function startDriftDetection(opts: DriftDetectOptions): DriftDetector {
|
||||||
|
const log = opts.log ?? (() => {});
|
||||||
|
if (!isCollabTool(opts.tool)) return { stop() {} };
|
||||||
|
|
||||||
|
const saveName = SAVE_FN[opts.tool];
|
||||||
|
const rawSave = opts.mod[saveName];
|
||||||
|
if (typeof rawSave !== "function") {
|
||||||
|
log(`[drift] ${saveName} unavailable on this build — drift detection off`);
|
||||||
|
return { stop() {} };
|
||||||
|
}
|
||||||
|
// Bind to a non-optional type so the nested computeDrift closure can invoke it.
|
||||||
|
const save = rawSave as (path: string) => void;
|
||||||
|
|
||||||
|
const everyN = opts.everyN && opts.everyN > 0 ? opts.everyN : DEFAULT_EVERY_N;
|
||||||
|
const scratchPath = `${memfsFilePath(opts.slug, opts.targetPath)}.drift`;
|
||||||
|
|
||||||
|
let changes = 0;
|
||||||
|
let inFlight = false;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
// Synchronous on purpose: serialize the live model, diff it against the Y.Doc,
|
||||||
|
// and return the report body (or null when there's no drift). Being fully
|
||||||
|
// synchronous is what lets the session-end check finish during page unload.
|
||||||
|
function computeDrift(): DriftReportBody | null {
|
||||||
|
save(scratchPath);
|
||||||
|
let text: unknown;
|
||||||
|
try {
|
||||||
|
text = opts.win.FS?.readFile(scratchPath, { encoding: "utf8" });
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
opts.win.FS?.unlink(scratchPath);
|
||||||
|
} catch {
|
||||||
|
/* scratch cleanup is best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof text !== "string") return null;
|
||||||
|
|
||||||
|
const wasmDoc = fileToDoc(text);
|
||||||
|
const ydocDoc = yToDoc(opts.doc);
|
||||||
|
const diff = docDelta(ydocDoc, wasmDoc);
|
||||||
|
// docDelta covers items only; flag layout/preamble divergence separately.
|
||||||
|
const layoutChanged =
|
||||||
|
JSON.stringify(ydocDoc.layout) !== JSON.stringify(wasmDoc.layout);
|
||||||
|
const metaChanged = ydocDoc.root !== wasmDoc.root;
|
||||||
|
if (isEmptyKicadDelta(diff) && !layoutChanged && !metaChanged) return null;
|
||||||
|
|
||||||
|
return { docPath: opts.targetPath, wasmDoc, ydocDoc, diff, layoutChanged, metaChanged };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOnce(): Promise<void> {
|
||||||
|
if (stopped || inFlight) return;
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
const body = computeDrift();
|
||||||
|
if (body) {
|
||||||
|
log(
|
||||||
|
`[drift] ${opts.targetPath}: +${body.diff.added.length} ~${body.diff.updated.length} -${body.diff.removed.length}` +
|
||||||
|
`${body.layoutChanged ? " layout" : ""}${body.metaChanged ? " meta" : ""}`,
|
||||||
|
);
|
||||||
|
await reportDrift(opts.slug, body);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`[drift] check failed: ${String(e)}`);
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdate = (): void => {
|
||||||
|
if (stopped) return;
|
||||||
|
if (++changes >= everyN) {
|
||||||
|
changes = 0;
|
||||||
|
void checkOnce();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
opts.doc.on("update", onUpdate);
|
||||||
|
|
||||||
|
const onBeforeUnload = (): void => {
|
||||||
|
try {
|
||||||
|
const body = computeDrift();
|
||||||
|
if (body) reportDriftBeacon(opts.slug, body);
|
||||||
|
} catch {
|
||||||
|
/* best-effort at page close */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
|
||||||
|
log(`[drift] on for ${opts.targetPath} (every ${everyN} edits + at session end)`);
|
||||||
|
return {
|
||||||
|
stop(): void {
|
||||||
|
stopped = true;
|
||||||
|
opts.doc.off("update", onUpdate);
|
||||||
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
kicadItemsMap,
|
kicadItemsMap,
|
||||||
parseItemsWireDelta,
|
parseItemsWireDelta,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
ydocHasState,
|
||||||
yToItem,
|
yToItem,
|
||||||
type ItemsWireDelta,
|
type ItemsWireDelta,
|
||||||
type KicadDoc,
|
type KicadDoc,
|
||||||
|
|
@ -125,7 +126,10 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
||||||
|
|
||||||
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
|
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
|
||||||
seeded = true; // open the UP gate; everything below runs synchronously
|
seeded = true; // open the UP gate; everything below runs synchronously
|
||||||
if (opts?.editorMatchesDoc && items.size > 0) {
|
// `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
|
||||||
|
// drawing sheet (pl_editor .kicad_wks) has zero uuid items, so an items-only
|
||||||
|
// check would mis-classify a seeded room as empty and re-seed/clobber it.
|
||||||
|
if (opts?.editorMatchesDoc && ydocHasState(doc)) {
|
||||||
// The editor opened exactly this doc's content (Y.Doc-load path): no
|
// The editor opened exactly this doc's content (Y.Doc-load path): no
|
||||||
// adopt apply needed. snapshotItems() still runs to BASELINE the wasm
|
// adopt apply needed. snapshotItems() still runs to BASELINE the wasm
|
||||||
// differ — otherwise the first local edit would re-emit the full model.
|
// differ — otherwise the first local edit would re-emit the full model.
|
||||||
|
|
@ -137,7 +141,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (items.size === 0 && seedDoc) {
|
if (!ydocHasState(doc) && seedDoc) {
|
||||||
// First tab, file-seeded: write the FULL doc (meta + layout + items) so
|
// First tab, file-seeded: write the FULL doc (meta + layout + items) so
|
||||||
// the Y.Doc — not the editor snapshot — is the lossless source of truth
|
// the Y.Doc — not the editor snapshot — is the lossless source of truth
|
||||||
// (the file is recoverable via docToFile). The editor already opened the
|
// (the file is recoverable via docToFile). The editor already opened the
|
||||||
|
|
@ -158,12 +162,13 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
||||||
}
|
}
|
||||||
const local = itemsWireToDelta(wire, {});
|
const local = itemsWireToDelta(wire, {});
|
||||||
|
|
||||||
|
const hasState = ydocHasState(doc);
|
||||||
clog(
|
clog(
|
||||||
`seed: doc has ${items.size} item(s), editor has ${local.added.length} →`,
|
`seed: doc has ${items.size} item(s), editor has ${local.added.length} →`,
|
||||||
items.size === 0 ? "SEEDING doc (first tab)" : "ADOPTING doc (joining)",
|
hasState ? "ADOPTING doc (joining)" : "SEEDING doc (first tab)",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (items.size === 0) {
|
if (!hasState) {
|
||||||
// First tab, no file source: seed the shared doc from the editor model.
|
// First tab, no file source: seed the shared doc from the editor model.
|
||||||
applyDeltaToY(doc, local, ORIGIN);
|
applyDeltaToY(doc, local, ORIGIN);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
1
web/standalone/src/wasm/global.d.ts
vendored
1
web/standalone/src/wasm/global.d.ts
vendored
|
|
@ -6,6 +6,7 @@ declare global {
|
||||||
writeFile(path: string, data: Uint8Array | string): void;
|
writeFile(path: string, data: Uint8Array | string): void;
|
||||||
readFile(path: string, opts?: { encoding?: "binary" | "utf8" }): unknown;
|
readFile(path: string, opts?: { encoding?: "binary" | "utf8" }): unknown;
|
||||||
analyzePath(path: string): { exists: boolean };
|
analyzePath(path: string): { exists: boolean };
|
||||||
|
unlink(path: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loose shape of the wxWidgets-WASM element registry exposed by wx.js.
|
// Loose shape of the wxWidgets-WASM element registry exposed by wx.js.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue