feat: standalone save/load routing + VITE_DOC_SOURCE ydoc mode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-12 10:16:02 +02:00
commit 8131bf9bac
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
19 changed files with 735 additions and 62 deletions

View file

@ -14,3 +14,13 @@ VITE_WASM_ASSET_BASE_URL=/wasm
# Override the artifact source dir the dev symlink points at (default:
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
# WASM_SRC_DIR=
# Where a backend project's DOCUMENT content lives ("api" default | "ydoc").
# Same /p/<project> URLs either way. "api": file bytes come from the REST
# backend and a user save (File->Save in the editor) is uploaded back to it.
# "ydoc": the collab room (VITE_YJS_PROVIDER) is the source of truth - when the
# room holds the document it is materialized client-side instead of fetched,
# and saves stay in the browser (the provider persists the doc); the REST
# backend still serves project metadata + sibling files, and the file fetch is
# the first-open fallback that seeds the room.
# VITE_DOC_SOURCE=ydoc

View file

@ -1,19 +1,23 @@
import * as React from "react";
import {
collabRoomId,
docToFile,
EXTENSION_TOOL,
FILELESS_TOOLS,
fileToDoc,
kicadItemsMap,
toolSchema,
yToDoc,
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react";
import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config";
import { WASM_ASSET_BASE_URL, yjsProviderConfig, type DocSource } from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import type { KicadItemsWindow } from "@/wasm/collab";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
import type { KicadDocSession, KicadItemsWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
@ -195,6 +199,45 @@ function seedDocFromMemfs(
}
}
/**
* The `docSource: "ydoc"` pre-step (config/env-selected same /p/ URLs as "api"
* mode): connect the document's collab room BEFORE the file opens and, when the
* room already holds the doc, materialize the file from it (docToFile) so the
* editor opens the doc's state instead of the API's copy. An empty room (first
* ever open) falls back to the API fetch the seed() that follows file-seeds
* the room from it. Returns the session for `maybeStartCollab` to attach to.
*/
async function maybeConnectDocSession(
win: ToolWindow,
opts: {
docSource?: DocSource;
tool: Tool;
projectId: string;
targetPath?: string;
log: (m: string) => void;
},
): Promise<{ session?: KicadDocSession; targetBytes?: Uint8Array }> {
if (opts.docSource !== "ydoc") return {};
if (!opts.targetPath || !COLLAB_TOOLS.has(opts.tool)) return {};
const { connectKicadDoc } = await import("@/wasm/collab");
const room = collabRoomId(opts.projectId, opts.targetPath);
const session = await connectKicadDoc({ provider: yjsProviderConfig(), room });
if (kicadItemsMap(session.doc).size === 0) {
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
return { session };
}
try {
const text = docToFile(yToDoc(session.doc));
opts.log(`[ydoc] materialized ${opts.targetPath} from room ${room} (${text.length} chars)`);
return { session, targetBytes: new TextEncoder().encode(text) };
} catch (err) {
cwarn("ydoc: materialize failed — falling back to the API fetch", err);
return { session };
}
}
/**
* Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any
* tool that has the collab bridge. Open the same project URL in two tabs to edit
@ -211,6 +254,9 @@ async function maybeStartCollab(
slug: string;
projectId: string;
targetPath?: string;
collabSession?: KicadDocSession;
/** The opened file was materialized from collabSession's doc (ydoc source). */
editorMatchesDoc?: boolean;
log: (m: string) => void;
onStatus: (t: string) => void;
},
@ -226,8 +272,10 @@ async function maybeStartCollab(
url: win.location.href,
});
// On by default; only an explicit opt-out disables it.
if (collabParam === "0" || collabParam === "false") {
// On by default; only an explicit opt-out disables it. A pre-connected doc
// session (Y.Doc-load path) ignores the opt-out: the doc IS the data source,
// so detaching would silently drop every edit.
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
clog("disabled (?collab=0) — skipping");
return;
}
@ -244,13 +292,29 @@ async function maybeStartCollab(
return;
}
const { startKicadCollab } = await import("@/wasm/collab");
const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab");
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
if (opts.collabSession) {
// docSource "ydoc": the provider is already connected. When the editor
// opened the file materialized from this very doc, attach + baseline only;
// when the room was empty (API fallback), seed() file-seeds it as usual.
clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc);
attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
seedDoc,
editorMatchesDoc: opts.editorMatchesDoc,
});
opts.log(`[collab] attached to Y.Doc session`);
opts.onStatus("Collab: connected");
clog("connected ✓");
return;
}
const provider = yjsProviderConfig();
// One room per (project, document). Two tabs of the same build compute the
// same id, so cross-tab BroadcastChannel still works; network providers use it
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider,
@ -276,6 +340,8 @@ export function WasmTool({
files,
targetPath,
fetchBytes,
saveBytes,
docSource,
assetBaseUrl,
}: {
tool: Tool;
@ -286,6 +352,20 @@ export function WasmTool({
targetPath?: string;
/** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/**
* Persist one file the user saved in the editor (FileSave writes MEMFS, then
* the wasm fires window.kicadCollab.onSave this). API upload for backend
* projects, disk write-back/download for local folders; omit to keep saves
* MEMFS-only (e.g. Y.Doc-backed sessions).
*/
saveBytes?: SaveBytes;
/**
* Where this project's DOCUMENT lives (see lib/config docSourceConfig):
* "ydoc" materializes the target file from its collab room when the room has
* state, with `fetchBytes` as the first-open fallback that seeds it. Defaults
* to "api" (plain fetch + open). Local-folder sessions don't pass this.
*/
docSource?: DocSource;
/** Where the WASM glue/artifacts are served from; defaults to VITE_WASM_ASSET_BASE_URL. */
assetBaseUrl?: string;
}) {
@ -348,16 +428,41 @@ export function WasmTool({
onStatus: setStatus,
onAbort: oom.onAbort,
});
// Register the save sink before the file opens: from here on, every
// editor File→Save (MEMFS write) is routed onward through saveBytes.
registerSaveHook(win, { slug, saveBytes, log: append, onStatus: setStatus });
const { session, targetBytes } = await maybeConnectDocSession(win, {
docSource,
tool,
projectId,
targetPath,
log: append,
});
await driveProjectIntoTool(win, {
tool,
slug,
files,
targetPath,
fetchBytes,
// ydoc source with a populated room: the target file's bytes come
// from the doc; everything else (sibling files) still fetches.
fetchBytes:
targetBytes && targetPath
? (relPath) =>
relPath === targetPath ? Promise.resolve(targetBytes) : fetchBytes(relPath)
: fetchBytes,
log: append,
onStatus: setStatus,
});
await maybeStartCollab(win, {
tool,
slug,
projectId,
targetPath,
collabSession: session,
editorMatchesDoc: !!targetBytes,
log: append,
onStatus: setStatus,
});
await maybeStartCollab(win, { tool, slug, projectId, targetPath, log: append, onStatus: setStatus });
} catch (err) {
append(`[fatal] ${String(err)}`);
setStatus(`Error: ${String(err)}`);

View file

@ -8,10 +8,11 @@ import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL } from "./config";
/**
* Read-only client over the shared contract. The standalone editor only ever
* READS projects from a backend (enumerate, get file tree, stream bytes) it
* never creates/deletes/uploads. Those management concerns live in the closed
* application that hosts this editor.
* Client over the shared contract. The standalone editor READS projects from a
* backend (enumerate, get file tree, stream bytes) and writes back exactly one
* thing: the bytes of a file the user explicitly saved in the editor (see
* uploadFileBytes). Project management (create/delete/bulk upload) stays in the
* closed application that hosts this editor.
*/
export const client = initClient(contract, {
baseUrl: API_BASE_URL,
@ -59,3 +60,24 @@ export async function fetchFileBytes(
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer());
}
/**
* Persist one saved file back to the backend via the multipart upload route
* (POST /api/projects/:project/files upserts by (project, path); the form
* FIELD NAME carries the project-relative path, same convention as the
* management app's folder upload).
*/
export async function uploadFileBytes(
slug: string,
relPath: string,
bytes: Uint8Array,
): Promise<void> {
const name = relPath.split("/").pop() ?? relPath;
const form = new FormData();
form.append(relPath, new File([bytes as BlobPart], name));
const res = await fetch(
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
{ method: "POST", body: form },
);
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
}

View file

@ -25,3 +25,21 @@ export function yjsProviderConfig(): ProviderConfig {
params: token ? { token } : undefined,
};
}
/**
* Where a backend project's DOCUMENT content lives (per deployment, not per
* route /p/<project> URLs behave the same either way):
*
* "api" file bytes are fetched from the REST backend and a user save is
* uploaded back to it (the Y.Doc, when collab is on, mirrors the file).
* "ydoc" the collab room is the source of truth: when it holds the document
* it is materialized client-side (docToFile) instead of fetching the
* file, and saves stay in MEMFS (the provider persists the doc). The
* REST backend still serves project metadata + sibling files, and the
* file fetch remains the first-open fallback that seeds the room.
*/
export type DocSource = "api" | "ydoc";
export function docSourceConfig(): DocSource {
return import.meta.env.VITE_DOC_SOURCE === "ydoc" ? "ydoc" : "api";
}

View file

@ -0,0 +1,13 @@
/**
* Hand a saved file's bytes to the browser as a download the save path of
* last resort when nothing writable backs the session (webkitdirectory
* folders give a read-only FileList).
*/
export function downloadBytes(relPath: string, bytes: Uint8Array): void {
const url = URL.createObjectURL(new Blob([bytes as BlobPart]));
const a = document.createElement("a");
a.href = url;
a.download = relPath.split("/").pop() ?? relPath;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}

View file

@ -9,8 +9,10 @@ import {
} from "@pcbjam/shared";
import { FolderOpen, Loader2 } from "lucide-react";
import { useProjects } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { Button } from "@/components/ui/button";
import type { ToolFile } from "@/wasm/kicad-runner";
import type { SaveBytes } from "@/wasm/save-flow";
import { WasmTool } from "@/components/WasmTool";
/** A KiCad project picked from the local filesystem (no backend involved). */
@ -18,6 +20,12 @@ interface LocalProject {
name: string;
files: ToolFile[];
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/**
* Where editor saves land: write-back through File System Access handles
* (folder picked via showDirectoryPicker), or a browser download per save
* (webkitdirectory fallback its FileList grants no write access).
*/
saveBytes: SaveBytes;
defaultTool?: Tool;
defaultTarget?: string;
}
@ -28,6 +36,56 @@ function toolForPath(path: string): Tool | null {
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
function defaultOpenTarget(files: ToolFile[]): { defaultTool?: Tool; defaultTarget?: string } {
for (const { path } of files) {
const tool = toolForPath(path);
if (tool) return { defaultTool: tool, defaultTarget: path };
}
return {};
}
/**
* Build a LocalProject over File System Access handles (showDirectoryPicker,
* Chromium): reads come from the live files, and editor saves are written
* straight back to the user's folder on disk.
*/
async function buildFsaProject(root: FileSystemDirectoryHandle): Promise<LocalProject> {
const handles = new Map<string, FileSystemFileHandle>();
async function walk(dir: FileSystemDirectoryHandle, prefix: string): Promise<void> {
for await (const [name, handle] of dir.entries()) {
if (handle.kind === "file") handles.set(prefix + name, handle as FileSystemFileHandle);
else await walk(handle as FileSystemDirectoryHandle, `${prefix}${name}/`);
}
}
await walk(root, "");
const files: ToolFile[] = [...handles.keys()].map((path) => ({ path }));
return {
name: root.name,
files,
...defaultOpenTarget(files),
fetchBytes: async (relPath) => {
const handle = handles.get(relPath);
if (!handle) throw new Error(`local file not found: ${relPath}`);
return new Uint8Array(await (await handle.getFile()).arrayBuffer());
},
saveBytes: async (relPath, bytes) => {
// Resolve (and create — the editor may save a brand-new file, e.g. a
// .kicad_pro next to a board) the path under the picked root.
const segs = relPath.split("/");
const fileName = segs.pop();
if (!fileName) throw new Error(`invalid save path: ${relPath}`);
let dir = root;
for (const seg of segs) dir = await dir.getDirectoryHandle(seg, { create: true });
const handle =
handles.get(relPath) ?? (await dir.getFileHandle(fileName, { create: true }));
handles.set(relPath, handle);
const writable = await handle.createWritable();
await writable.write(bytes as unknown as FileSystemWriteChunkType);
await writable.close();
},
};
}
/** Build a LocalProject from a webkitdirectory FileList, stripping the top folder. */
function buildLocalProject(fileList: FileList): LocalProject {
const map = new Map<string, File>();
@ -41,26 +99,17 @@ function buildLocalProject(fileList: FileList): LocalProject {
map.set(rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel, f);
}
const files: ToolFile[] = [...map.keys()].map((path) => ({ path }));
let defaultTool: Tool | undefined;
let defaultTarget: string | undefined;
for (const { path } of files) {
const tool = toolForPath(path);
if (tool) {
defaultTool = tool;
defaultTarget = path;
break;
}
}
return {
name: topPrefix ? topPrefix.slice(0, -1) : "local",
files,
defaultTool,
defaultTarget,
...defaultOpenTarget(files),
fetchBytes: async (relPath) => {
const f = map.get(relPath);
if (!f) throw new Error(`local file not found: ${relPath}`);
return new Uint8Array(await f.arrayBuffer());
},
// A webkitdirectory FileList is read-only — saves become downloads.
saveBytes: async (relPath, bytes) => downloadBytes(relPath, bytes),
};
}
@ -88,6 +137,7 @@ export function HomePage() {
files={local.files}
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
fetchBytes={local.fetchBytes}
saveBytes={local.saveBytes}
/>
);
}
@ -109,19 +159,42 @@ export function HomePage() {
No upload files stay in your browser. Pick a folder containing a
KiCad project.
</p>
<input
ref={inputRef}
type="file"
multiple
className="block text-sm"
onChange={(e) => {
const fl = e.target.files;
if (!fl || fl.length === 0) return;
const proj = buildLocalProject(fl);
setLocal(proj);
setTool(proj.defaultTool ?? "");
}}
/>
{window.showDirectoryPicker ? (
<Button
variant="outline"
onClick={() => {
void (async () => {
let root: FileSystemDirectoryHandle;
try {
root = await window.showDirectoryPicker!({ mode: "readwrite" });
} catch {
return; // user cancelled the picker / denied write access
}
const proj = await buildFsaProject(root);
setLocal(proj);
setTool(proj.defaultTool ?? "");
})();
}}
>
<FolderOpen size={16} /> Choose folder
</Button>
) : (
// No File System Access API (Firefox/Safari): read-only folder input;
// editor saves arrive as browser downloads instead of disk writes.
<input
ref={inputRef}
type="file"
multiple
className="block text-sm"
onChange={(e) => {
const fl = e.target.files;
if (!fl || fl.length === 0) return;
const proj = buildLocalProject(fl);
setLocal(proj);
setTool(proj.defaultTool ?? "");
}}
/>
)}
{local && (
<div className="mt-4 flex flex-wrap items-center gap-3">

View file

@ -1,6 +1,7 @@
import { useParams } from "react-router-dom";
import { toolSchema } from "@pcbjam/shared";
import { fetchFileBytes, useProject } from "@/lib/api";
import { fetchFileBytes, uploadFileBytes, useProject } from "@/lib/api";
import { docSourceConfig } from "@/lib/config";
import { WasmTool } from "@/components/WasmTool";
import { PreflightGate } from "@/preflight/PreflightGate";
@ -31,6 +32,11 @@ export function ToolPage() {
);
}
// Env-selected document source (same /p/ URLs either way): with "ydoc" the
// collab room is the source of truth, so saves are NOT uploaded back — the
// provider persists the doc. With "api" a user save uploads to the backend.
const docSource = docSourceConfig();
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
return (
@ -42,6 +48,12 @@ export function ToolPage() {
files={data.files}
targetPath={targetPath}
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
saveBytes={
docSource === "api"
? (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
: undefined
}
docSource={docSource}
/>
</PreflightGate>
);

View file

@ -116,6 +116,56 @@ export interface KicadCollabHandle {
destroy(): void;
}
/** A provider-connected, initial-state-synced Y.Doc, not yet bound to an editor. */
export interface KicadDocSession {
doc: Y.Doc;
provider: YjsProvider;
}
/**
* Connect a fresh Y.Doc to a provider room and wait for its authoritative
* initial state. Used standalone by the Y.Doc-load path (materialize the file
* from the doc BEFORE any editor exists), and as the first half of
* `startKicadCollab`.
*/
export async function connectKicadDoc(opts: {
provider: ProviderConfig;
room: string;
}): Promise<KicadDocSession> {
const doc = new Y.Doc();
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
await provider.whenSynced();
return { doc, provider };
}
/**
* Bind a running editor to an already-synced doc session (second half of
* `startKicadCollab`). `editorMatchesDoc` marks the Y.Doc-load path: the open
* file was materialized from this very doc, so seed only baselines the differ
* instead of re-applying the full document.
*/
export function attachKicadCollab(
mod: KicadItemsModule,
win: KicadItemsWindow,
session: KicadDocSession,
opts?: { seedDoc?: KicadDoc; editorMatchesDoc?: boolean },
): KicadCollabHandle {
const binding = bindKicadCollab(session.doc, moduleItemsBridge(mod, win));
binding.seed(opts?.seedDoc, { editorMatchesDoc: opts?.editorMatchesDoc });
clog("attachKicadCollab: ready; doc items =", binding.items.size);
return {
doc: session.doc,
binding,
provider: session.provider,
destroy() {
binding.destroy();
session.provider.destroy();
session.doc.destroy();
},
};
}
/**
* The Slot-model counterpart of `startCollab` (ysync 0008): wires the v2 items
* bridge (kicadCollabSnapshotItems / ApplyItems / onItems Stage C exports) into
@ -129,23 +179,6 @@ export async function startKicadCollab(
opts: StartCollabOptions,
): Promise<KicadCollabHandle> {
clog("startKicadCollab:", opts.provider.kind, "room =", opts.room);
const doc = new Y.Doc();
const bridge = moduleItemsBridge(mod, win);
const binding = bindKicadCollab(doc, bridge);
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
await provider.whenSynced();
binding.seed(opts.seedDoc);
clog("startKicadCollab: ready; doc items =", binding.items.size);
return {
doc,
binding,
provider,
destroy() {
binding.destroy();
provider.destroy();
doc.destroy();
},
};
const session = await connectKicadDoc({ provider: opts.provider, room: opts.room });
return attachKicadCollab(mod, win, session, { seedDoc: opts.seedDoc });
}

View file

@ -208,6 +208,28 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
expect(Object.keys(edB.store).sort()).toEqual(["fld-1", "fp-1", "pad-1"]);
});
it("seed(editorMatchesDoc) skips the adopt apply but still binds both ways", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);
bindA.seed(); // A seeds the room
// B is the Y.Doc-load path: its editor opened the file materialized from
// the doc, so its store ALREADY matches — no adopt apply must happen.
seedEditor(edB, FP);
bindB.seed(undefined, { editorMatchesDoc: true });
expect(edB.applied.length).toBe(0);
// The binding is still live both ways after the apply-less seed.
edA.localUpsert(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1");
expect(edB.store["pad-1"]!.body).toEqual(
sexprToItems(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
);
edB.localUpsert(`(pad "1" smd (at 6 6) (uuid "pad-1"))`, "fp-1");
expect(edA.store["pad-1"]!.body).toEqual(
sexprToItems(`(pad "1" smd (at 6 6) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
);
});
it("destroy() detaches the editor from further remote changes", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);

View file

@ -55,8 +55,12 @@ export interface KicadBinding {
* editor snapshot (items only). Otherwise the editor adopts the doc (doc
* authority local-only roots are removed, doc roots applied). Call once
* after the doc/provider are connected.
*
* `editorMatchesDoc`: the editor's open file WAS materialized from this doc
* (docToFile the Y.Doc-load path), so the adopt re-apply would be a no-op
* full-document blob apply; skip it and just baseline the wasm differ.
*/
seed(seedDoc?: KicadDoc): void;
seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void;
destroy(): void;
/** The underlying kdoc items map (exposed for tests/inspection). */
readonly items: KicadYItems;
@ -119,8 +123,20 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
};
items.observeDeep(observer);
function seed(seedDoc?: KicadDoc): void {
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
seeded = true; // open the UP gate; everything below runs synchronously
if (opts?.editorMatchesDoc && items.size > 0) {
// The editor opened exactly this doc's content (Y.Doc-load path): no
// adopt apply needed. snapshotItems() still runs to BASELINE the wasm
// differ — otherwise the first local edit would re-emit the full model.
clog(`seed: editor matches doc (${items.size} item(s)) → baseline only, no apply`);
try {
bridge.snapshotItems();
} catch (err) {
cwarn("seed: snapshotItems baseline failed", err);
}
return;
}
if (items.size === 0 && seedDoc) {
// 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

View file

@ -45,6 +45,11 @@ declare global {
FS?: EmscriptenFS;
wxElementRegistry?: WxElementRegistry;
kicadWebOpenTool?: (toolName: string, fileName: string) => boolean;
/** File System Access API (Chromium): writable local-folder sessions. */
showDirectoryPicker?(options?: {
mode?: "read" | "readwrite";
id?: string;
}): Promise<FileSystemDirectoryHandle>;
}
// The browsing-context window the tool runs in — now the top-level `window`

View file

@ -0,0 +1,75 @@
import { memfsProjectDir } from "./constants";
/**
* Persist one saved file's bytes outside MEMFS. The counterpart of
* `fetchBytes` on the load side: each page decides the destination
* API upload (backend projects), local-disk write-back / download (local
* folders). Absent saves stay MEMFS-only (e.g. Y.Doc-backed sessions,
* where the provider already persists the document).
*/
export type SaveBytes = (relPath: string, bytes: Uint8Array) => Promise<void>;
export interface SaveHookWindow {
FS?: EmscriptenFS;
kicadCollab?: { onSave?: (absPath: string) => void };
}
/**
* Register the C++ JS save notification sink (`window.kicadCollab.onSave`).
* The kicad fork fires it from each tool's save chokepoint (SaveDrawingSheetFile /
* saveSchematicFile / SavePcbFile) AFTER the bytes hit MEMFS, so the handler just
* reads them back and routes them to `saveBytes`. eeschema may fire once per
* sheet file in a multi-sheet save each call is one complete file.
*/
export function registerSaveHook(
win: SaveHookWindow,
opts: {
slug: string;
saveBytes?: SaveBytes;
log: (msg: string) => void;
onStatus: (text: string) => void;
},
): void {
const projectPrefix = `${memfsProjectDir(opts.slug)}/`;
const onSave = (absPath: string) => {
if (!absPath.startsWith(projectPrefix)) {
opts.log(`[save] ignoring save outside project dir: ${absPath}`);
return;
}
const relPath = absPath.slice(projectPrefix.length);
if (!opts.saveBytes) {
opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`);
return;
}
let bytes: Uint8Array;
try {
const data = win.FS?.readFile(absPath);
if (!(data instanceof Uint8Array)) throw new Error("FS.readFile returned no bytes");
bytes = data;
} catch (err) {
opts.log(`[save] FAILED to read ${absPath} back from MEMFS: ${String(err)}`);
opts.onStatus(`Save failed: ${relPath}`);
return;
}
opts.onStatus(`Saving ${relPath}`);
void opts
.saveBytes(relPath, bytes)
.then(() => {
opts.log(`[save] ${relPath} persisted (${bytes.length} bytes)`);
opts.onStatus(`Saved ${relPath}`);
setTimeout(() => opts.onStatus(""), 2500);
})
.catch((err) => {
opts.log(`[save] FAILED to persist ${relPath}: ${String(err)}`);
opts.onStatus(`Save failed: ${relPath} — see console`);
});
};
// Spread-merge like moduleItemsBridge does, so sibling hooks (onItems/onDelta)
// registered before or after survive.
win.kicadCollab = { ...win.kicadCollab, onSave };
}

View file

@ -1,7 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"moduleResolution": "Bundler",