feat(ysync): lib_symbols travel + layout save-sync (miss 08), TS hot-path opts (12), diff-on-rebind adopt (13) — doc 18
Miss 08A: the binding stores wire-carried (lib_symbols …) definitions in kdoc_libsymbols and prefixes them on apply wires — a joiner that never saw a symbol adopts it WITH its definition (new e2e ysync-libsymbols.spec.ts). Miss 08B: registerSaveHook gains onSavedText; WasmTool routes saved-file text to syncLayoutToY (per sheet room via the manager's syncLayoutFromSave, or the single-room doc) so title block / paper / setup edits converge instead of drifting. Opt 12 (TS half): zod off the observer hot path (yToItemUnchecked), children index built once per conversion. Opt 13: seed()'s adopt diffs the editor snapshot against the doc view and applies only the doc-authoritative difference — clean rebinds apply nothing, the adopt undo entry shrinks to the real changed set. Opt 14 deliberately deferred (doc 18). All TS-side; no wasm rebuild (the C++ blob/findLib sides already carried definitions). Verified: shared 107, standalone 79 (+2 known pre-existing wasm-assets), ysync e2e 21/21 chromium, collab regression 21/3-skip firefox. Bumps: web/pcbjam-shared (lib_symbols channel + syncLayoutToY + opts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgThWXtdvrYLK47EDFoGdq
This commit is contained in:
parent
ae5d41a3d7
commit
62ca571802
14 changed files with 570 additions and 27 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit f12d9af9595c915bb17a31bb59d156d38b256cbf
|
||||
Subproject commit 2387cecba80221e463fde02636d9ce4c3c24a044
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
projectToolPath,
|
||||
toolSchema,
|
||||
ydocHasState,
|
||||
syncLayoutToY,
|
||||
yToDoc,
|
||||
type KicadDoc,
|
||||
type Tool,
|
||||
|
|
@ -570,6 +571,9 @@ export function WasmTool({
|
|||
const startedRef = React.useRef(false);
|
||||
const driftRef = React.useRef<{ stop(): void } | 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.
|
||||
const collabDocRef = React.useRef<import("yjs").Doc | null>(null);
|
||||
const [status, setStatus] = React.useState("Loading tool…");
|
||||
const [logs, setLogs] = React.useState<string[]>([]);
|
||||
const [showLog, setShowLog] = React.useState(false);
|
||||
|
|
@ -795,6 +799,21 @@ export function WasmTool({
|
|||
onSaved: (relPath) => {
|
||||
if (relPath.endsWith(".kicad_sch")) void sheetManagerRef.current?.onboard(relPath);
|
||||
},
|
||||
// Non-item document state (title block, paper, setup…) only reaches the
|
||||
// room at seed time; reconcile it from every save (miss 08B).
|
||||
onSavedText: (relPath, text) => {
|
||||
if (sheetManagerRef.current) {
|
||||
sheetManagerRef.current.syncLayoutFromSave(relPath, text);
|
||||
return;
|
||||
}
|
||||
if (collabDocRef.current && relPath === targetPath) {
|
||||
try {
|
||||
syncLayoutToY(fileToDoc(text), collabDocRef.current, "layout-save");
|
||||
} catch (err) {
|
||||
append(`[save] layout sync failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
const { session, targetBytes } = await maybeConnectDocSession(win, {
|
||||
docSource,
|
||||
|
|
@ -865,6 +884,7 @@ export function WasmTool({
|
|||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
collabDocRef.current = collabHandle?.doc ?? null;
|
||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
||||
driftRef.current = startDriftDetection({
|
||||
doc: collabHandle.doc,
|
||||
|
|
@ -897,6 +917,7 @@ export function WasmTool({
|
|||
// onActiveChange(null).
|
||||
sheetManagerRef.current?.destroy();
|
||||
sheetManagerRef.current = null;
|
||||
collabDocRef.current = null;
|
||||
oom.stop();
|
||||
};
|
||||
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
docToFile,
|
||||
fileToDoc,
|
||||
itemsWireToDelta,
|
||||
kicadLibSymbolsMap,
|
||||
parseItemsWireDelta,
|
||||
renderItem,
|
||||
sexprToItems,
|
||||
|
|
@ -244,4 +245,100 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
|
|||
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
||||
);
|
||||
});
|
||||
|
||||
it("adopt applies only the DIFFERENCE (opt 13) — identical items cost nothing", () => {
|
||||
const { edA, edB, bindA, bindB } = setup();
|
||||
const SEG = `(segment (start 0 0) (end 1 1) (uuid "seg-1"))`;
|
||||
seedEditor(edA, FP);
|
||||
seedEditor(edA, SEG);
|
||||
bindA.seed();
|
||||
|
||||
// B's editor already holds the IDENTICAL footprint but not the segment.
|
||||
seedEditor(edB, FP);
|
||||
bindB.seed();
|
||||
|
||||
expect(edB.store["seg-1"]).toBeDefined(); // caught up
|
||||
expect(edB.applied).toHaveLength(1);
|
||||
const wire = parseItemsWireDelta(edB.applied[0]!);
|
||||
// Only the missing segment travelled — the matching footprint did not.
|
||||
expect(wire.added).toHaveLength(1);
|
||||
expect(wire.added[0]!.sexpr).toContain("seg-1");
|
||||
expect(wire.changed).toHaveLength(0);
|
||||
expect(wire.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adopt with a fully matching editor applies NOTHING (clean rebind)", () => {
|
||||
const { edA, edB, bindA, bindB } = setup();
|
||||
seedEditor(edA, FP);
|
||||
bindA.seed();
|
||||
seedEditor(edB, FP);
|
||||
bindB.seed();
|
||||
expect(edB.applied).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adopt re-applies a differing item's DOC version, lifted to its root", () => {
|
||||
const { edA, edB, bindA, bindB } = setup();
|
||||
seedEditor(edA, FP);
|
||||
bindA.seed();
|
||||
|
||||
// B holds the same footprint but its pad drifted (never-synced local state).
|
||||
seedEditor(edB, FP.replace(`(pad "1" smd (at 0 0)`, `(pad "1" smd (at 9 9)`));
|
||||
bindB.seed();
|
||||
|
||||
// Doc authority: B's editor converges on the doc's pad, via ONE root re-apply.
|
||||
expect(edB.store["pad-1"]!.body).toEqual(
|
||||
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
||||
);
|
||||
expect(edB.applied).toHaveLength(1);
|
||||
const wire = parseItemsWireDelta(edB.applied[0]!);
|
||||
expect(wire.changed).toHaveLength(1);
|
||||
expect(wire.changed[0]!.sexpr).toContain(`(uuid "fp-1")`); // the root, not the bare pad
|
||||
});
|
||||
});
|
||||
|
||||
describe("lib_symbols flow through the binding (miss 08A)", () => {
|
||||
const DEF = `(symbol "Device:R" (property "Reference" "R" (at 2 0 90)))`;
|
||||
const INSTANCE = `(symbol (lib_id "Device:R") (at 100 50 0) (uuid "sym-1"))`;
|
||||
|
||||
it("an emitted placement's definition is stored and re-rendered for the peer", () => {
|
||||
const { a, b } = pair();
|
||||
const edA = new FakeEditor();
|
||||
const edB = new FakeEditor();
|
||||
bindKicadCollab(a, edA).seed();
|
||||
bindKicadCollab(b, edB).seed();
|
||||
|
||||
// A places a symbol: the eeschema blob is multi-form (definition + instance).
|
||||
edA.localUpsert(`(lib_symbols ${DEF}) ${INSTANCE}`, null, "added");
|
||||
|
||||
// B's editor received the instance WITH its definition prefixed (findLib's
|
||||
// first branch), even though B has never seen this symbol.
|
||||
expect(edB.store["sym-1"]).toBeDefined();
|
||||
const applied = edB.applied.map((j) => parseItemsWireDelta(j));
|
||||
const symWire = applied
|
||||
.flatMap((w) => [...w.added, ...w.changed])
|
||||
.find((w) => w.sexpr.includes("sym-1"));
|
||||
expect(symWire, "the symbol reached B").toBeTruthy();
|
||||
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
|
||||
|
||||
// And the definition landed in the room's defs map on BOTH sides
|
||||
// (materialization injection is covered by the shared-lib tests — this
|
||||
// room was editor-snapshot-seeded, which carries no layout/meta).
|
||||
expect(kicadLibSymbolsMap(b).get("Device:R")).toContain(`"Device:R"`);
|
||||
expect(kicadLibSymbolsMap(a).get("Device:R")).toContain(`"Device:R"`);
|
||||
});
|
||||
|
||||
it("adopt of a doc holding a symbol carries the definition too", () => {
|
||||
const { a, b } = pair();
|
||||
const edA = new FakeEditor();
|
||||
const edB = new FakeEditor();
|
||||
bindKicadCollab(a, edA).seed();
|
||||
edA.localUpsert(`(lib_symbols ${DEF}) ${INSTANCE}`, null, "added");
|
||||
|
||||
// B joins with an empty editor → adopts the doc.
|
||||
bindKicadCollab(b, edB).seed();
|
||||
expect(edB.store["sym-1"]).toBeDefined();
|
||||
const wire = parseItemsWireDelta(edB.applied[0]!);
|
||||
const symWire = [...wire.added, ...wire.changed].find((w) => w.sexpr.includes("sym-1"));
|
||||
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,13 +7,16 @@ import {
|
|||
isEmptyKicadDelta,
|
||||
itemsWireToDelta,
|
||||
kicadItemsMap,
|
||||
kicadLibSymbolsMap,
|
||||
parseItemsWireDelta,
|
||||
renderItem,
|
||||
seedDocToY,
|
||||
upsertLibSymbolsToY,
|
||||
wireItemUuids,
|
||||
wireLibSymbols,
|
||||
Y_KDOC_META,
|
||||
Y_KDOC_SEED_NONCE,
|
||||
ydocHasState,
|
||||
yToItem,
|
||||
yToItemUnchecked,
|
||||
type ItemsWireDelta,
|
||||
type KicadDoc,
|
||||
type KicadItem,
|
||||
|
|
@ -87,15 +90,24 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
// Concurrent double-seed arbitration cleanup (bug 06); set by the file-seed branch.
|
||||
let detachSeedArbitration: (() => void) | undefined;
|
||||
|
||||
/** Plain snapshot of the Y items (the `current`/`view` the conversions need). */
|
||||
/**
|
||||
* Plain snapshot of the Y items (the `current`/`view` the conversions need).
|
||||
* Unchecked reads (opt 12): this runs on every local emit AND every remote
|
||||
* batch; the zod walk of each body tree dominated at scale. The wire parse
|
||||
* zod-validates at the trust boundary; seed/materialize keep checked reads.
|
||||
*/
|
||||
const itemsView = (): Record<string, KicadItem> => {
|
||||
const view: Record<string, KicadItem> = {};
|
||||
items.forEach((ym, uuid) => {
|
||||
view[uuid] = yToItem(ym);
|
||||
view[uuid] = yToItemUnchecked(ym);
|
||||
});
|
||||
return view;
|
||||
};
|
||||
|
||||
/** kdoc_libsymbols reader for the apply direction (miss 08). */
|
||||
const libDefs = (libId: string): string | undefined =>
|
||||
kicadLibSymbolsMap(doc).get(libId);
|
||||
|
||||
// DOWN: local editor change → Y.Doc
|
||||
bridge.onItems((json: string) => {
|
||||
if (destroyed) return; // stale hook (bug 07) — a destroyed binding is inert
|
||||
|
|
@ -107,13 +119,19 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
return;
|
||||
}
|
||||
const delta = itemsWireToDelta(wire, itemsView());
|
||||
if (isEmptyKicadDelta(delta)) return;
|
||||
// Library definitions the blob carried (a placed symbol's lib_symbols
|
||||
// context — miss 08): store them alongside the items, same transaction.
|
||||
const defs = wireLibSymbols(wire);
|
||||
if (isEmptyKicadDelta(delta) && Object.keys(defs).length === 0) return;
|
||||
clog("⬇ onItems (local edit):", {
|
||||
added: delta.added.length,
|
||||
updated: delta.updated.length,
|
||||
removed: delta.removed.length,
|
||||
});
|
||||
applyDeltaToY(doc, delta, ORIGIN);
|
||||
doc.transact(() => {
|
||||
applyDeltaToY(doc, delta, ORIGIN);
|
||||
upsertLibSymbolsToY(doc, defs, ORIGIN);
|
||||
}, ORIGIN);
|
||||
});
|
||||
|
||||
// UP: remote Y change → editor. The subscription + origin policy live HERE
|
||||
|
|
@ -123,7 +141,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
if (!seeded) return; // pre-seed state sync — seed()'s adopt covers it
|
||||
const delta = deltaFromYEvents(items, events);
|
||||
if (isEmptyKicadDelta(delta)) return;
|
||||
const wire = deltaToItemsWire(delta, itemsView());
|
||||
const wire = deltaToItemsWire(delta, itemsView(), libDefs);
|
||||
if (isEmptyItemsWireDelta(wire)) return;
|
||||
clog("⬆ remote Y change → apply to editor:", {
|
||||
added: wire.added.length,
|
||||
|
|
@ -203,32 +221,69 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
cwarn("seed: snapshotItems unparseable", err);
|
||||
return;
|
||||
}
|
||||
const local = itemsWireToDelta(wire, {});
|
||||
|
||||
const hasState = ydocHasState(doc);
|
||||
clog(
|
||||
`seed: doc has ${items.size} item(s), editor has ${local.added.length} →`,
|
||||
hasState ? "ADOPTING doc (joining)" : "SEEDING doc (first tab)",
|
||||
);
|
||||
|
||||
if (!hasState) {
|
||||
// First tab, no file source: seed the shared doc from the editor model.
|
||||
applyDeltaToY(doc, local, ORIGIN);
|
||||
const local = itemsWireToDelta(wire, {});
|
||||
clog(`seed: doc empty → SEEDING from editor snapshot (${local.added.length} item(s))`);
|
||||
doc.transact(() => {
|
||||
applyDeltaToY(doc, local, ORIGIN);
|
||||
upsertLibSymbolsToY(doc, wireLibSymbols(wire), ORIGIN);
|
||||
}, ORIGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// Joining a populated doc: the editor adopts it (seed-once authority, same
|
||||
// rationale as the scalar reconciler §2 — divergent local uuids from a
|
||||
// never-saved cold open must yield to the doc's identity). Apply the doc's
|
||||
// ROOT items (their sexprs embed all descendants) and remove local-only roots.
|
||||
// never-saved cold open must yield to the doc's identity). Diff the editor
|
||||
// snapshot against the doc VIEW and apply only the DIFFERENCE (opt 13):
|
||||
// identical items cost nothing, the apply commit (and its undo entry — the
|
||||
// adopt undo-bomb, miss 09) shrinks to the real changed set, and a clean
|
||||
// rebind degrades to baseline-only.
|
||||
const view = itemsView();
|
||||
const docRoots = Object.entries(view)
|
||||
.filter(([, item]) => item.parent === null)
|
||||
.map(([uuid]) => ({ sexpr: renderItem({ items: view }, uuid), parent: null }));
|
||||
const removed = local.added
|
||||
const editorDelta = itemsWireToDelta(wire, view); // editor state vs doc view
|
||||
const editorUuids = wireItemUuids(wire);
|
||||
|
||||
// Doc authority, inverted per class:
|
||||
// - doc-only ROOTS → add to the editor (their sexprs embed descendants;
|
||||
// a doc-only CHILD makes its shared parent's body differ → covered below);
|
||||
// - items that DIFFER → re-apply the doc's version, lifted to their root
|
||||
// (the C++ upsert replaces roots; a bare child apply would mis-parent);
|
||||
// - editor-only ROOTS → remove (editor-only children disappear with their
|
||||
// parent's re-apply).
|
||||
const liftToRoot = (uuid: string): string => {
|
||||
let cur = uuid;
|
||||
while (view[cur]?.parent != null) cur = view[cur]!.parent!;
|
||||
return cur;
|
||||
};
|
||||
const docOnly = Object.entries(view)
|
||||
.filter(([uuid, it]) => it.parent === null && !editorUuids.has(uuid))
|
||||
.map(([uuid, it]) => ({ uuid, ...it }));
|
||||
const changedRoots = [
|
||||
...new Set(
|
||||
editorDelta.updated.filter((it) => it.uuid in view).map((it) => liftToRoot(it.uuid)),
|
||||
),
|
||||
]
|
||||
.filter((uuid) => !docOnly.some((it) => it.uuid === uuid))
|
||||
.map((uuid) => ({ uuid, ...view[uuid]! }));
|
||||
const removed = editorDelta.added
|
||||
.filter((it) => it.parent === null && !(it.uuid in view))
|
||||
.map((it) => it.uuid);
|
||||
bridge.applyItems(JSON.stringify({ added: docRoots, changed: [], removed }));
|
||||
|
||||
const adoptWire = deltaToItemsWire(
|
||||
{ added: docOnly, updated: changedRoots, removed },
|
||||
view,
|
||||
libDefs,
|
||||
);
|
||||
|
||||
clog(
|
||||
`seed: doc has ${items.size} item(s) → ADOPTING diff:`,
|
||||
`+${adoptWire.added.length} ~${adoptWire.changed.length} -${adoptWire.removed.length}`,
|
||||
);
|
||||
if (isEmptyItemsWireDelta(adoptWire)) return; // editor already matches — baseline only
|
||||
bridge.applyItems(JSON.stringify(adoptWire));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type * as Y from "yjs";
|
||||
import { collabRoomId, type KicadDoc } from "@pcbjam/shared";
|
||||
import { collabRoomId, fileToDoc, syncLayoutToY, type KicadDoc } from "@pcbjam/shared";
|
||||
import { connectKicadDoc, type KicadDocSession } from "./index";
|
||||
import {
|
||||
bindKicadCollab,
|
||||
|
|
@ -40,6 +40,12 @@ export interface SheetCollabManager {
|
|||
switchTo(sheetPath: string): Promise<void>;
|
||||
/** Warm a sheet created mid-session (driven by the save hook on an unknown path). */
|
||||
onboard(sheetPath: string): Promise<void>;
|
||||
/**
|
||||
* Coarse non-item layout sync from a just-saved sheet file (miss 08B): title
|
||||
* block / paper / settings edits reconcile into the sheet's room doc, which
|
||||
* otherwise only carries them from seed time. No-op for unknown sheets.
|
||||
*/
|
||||
syncLayoutFromSave(sheetPath: string, fileText: string): void;
|
||||
/** The currently-bound sheet, for drift-detection wiring (null before first switch). */
|
||||
active(): { sheetPath: string; doc: Y.Doc } | null;
|
||||
/** Tear down ALL bindings + providers + docs (session end / unmount). */
|
||||
|
|
@ -251,6 +257,20 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
}
|
||||
}
|
||||
|
||||
function syncLayoutFromSave(sheetPath: string, fileText: string): void {
|
||||
const room = rooms.get(sheetPath);
|
||||
if (!room) return; // not a collab sheet (or still onboarding) — nothing to sync
|
||||
try {
|
||||
// Writing to a PARKED room's doc marks it dirty via startWatch — fine:
|
||||
// the diff-on-rebind adopt makes the catch-up cost the real delta only.
|
||||
if (syncLayoutToY(fileToDoc(fileText), room.doc, "layout-save")) {
|
||||
clog(`[sheet] layout save-sync: ${sheetPath} updated`);
|
||||
}
|
||||
} catch (err) {
|
||||
cwarn(`[sheet] layout save-sync failed for ${sheetPath}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectAll(sheetPaths: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
sheetPaths.map((p) =>
|
||||
|
|
@ -289,7 +309,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
opts.onActiveChange?.(null);
|
||||
}
|
||||
|
||||
return { connectAll, switchTo, onboard, active, destroy };
|
||||
return { connectAll, switchTo, onboard, syncLayoutFromSave, active, destroy };
|
||||
}
|
||||
|
||||
export interface SheetChangedWindow {
|
||||
|
|
|
|||
|
|
@ -45,4 +45,39 @@ describe("registerSaveHook path routing", () => {
|
|||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(saveBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onSavedText receives the decoded file text (layout save-sync, miss 08B)", () => {
|
||||
const onSavedText = vi.fn();
|
||||
const win: SaveHookWindow = {
|
||||
FS: {
|
||||
readFile: () => new TextEncoder().encode("(kicad_sch (version 1))"),
|
||||
} as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, {
|
||||
slug: SLUG,
|
||||
onSavedText,
|
||||
log: () => {},
|
||||
onStatus: () => {},
|
||||
});
|
||||
win.kicadCollab!.onSave!(`${PROJ}/sheet.kicad_sch`);
|
||||
expect(onSavedText).toHaveBeenCalledWith("sheet.kicad_sch", "(kicad_sch (version 1))");
|
||||
});
|
||||
|
||||
it("an onSavedText read failure is logged, not thrown", () => {
|
||||
const onSavedText = vi.fn();
|
||||
const log = vi.fn();
|
||||
const win: SaveHookWindow = {
|
||||
FS: {
|
||||
readFile: () => {
|
||||
throw new Error("gone");
|
||||
},
|
||||
} as unknown as SaveHookWindow["FS"],
|
||||
kicadCollab: {},
|
||||
};
|
||||
registerSaveHook(win, { slug: SLUG, onSavedText, log, onStatus: () => {} });
|
||||
win.kicadCollab!.onSave!(`${PROJ}/sheet.kicad_sch`);
|
||||
expect(onSavedText).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("onSavedText read failed"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ export function registerSaveHook(
|
|||
* ("Add Sheet"), which the page-load file list can't contain.
|
||||
*/
|
||||
onSaved?: (relPath: string) => void;
|
||||
/**
|
||||
* Like `onSaved` but with the saved file's TEXT (read back from MEMFS).
|
||||
* The collab layout save-sync (miss 08B) uses it to reconcile non-item
|
||||
* document state (title block, paper, setup…) into the room doc.
|
||||
*/
|
||||
onSavedText?: (relPath: string, text: string) => void;
|
||||
},
|
||||
): void {
|
||||
const projectPrefix = `${memfsProjectDir(opts.slug)}/`;
|
||||
|
|
@ -64,6 +70,17 @@ export function registerSaveHook(
|
|||
|
||||
opts.onSaved?.(relPath);
|
||||
|
||||
if (opts.onSavedText) {
|
||||
try {
|
||||
const data = win.FS?.readFile(absPath);
|
||||
if (data instanceof Uint8Array) {
|
||||
opts.onSavedText(relPath, new TextDecoder().decode(data));
|
||||
}
|
||||
} catch (err) {
|
||||
opts.log(`[save] onSavedText read failed for ${relPath}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.saveBytes) {
|
||||
opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`);
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue