From 8131bf9bacd5c1b856b216cb3f7eeeb0f01bef47 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?=
Date: Fri, 12 Jun 2026 10:16:02 +0200
Subject: [PATCH] feat: standalone save/load routing + VITE_DOC_SOURCE ydoc
mode
Co-Authored-By: Claude Fable 5
---
kicad | 2 +-
tests/kicad/save-hook.spec.ts | 230 ++++++++++++++++++
wasm/bindings/eeschema_embind.cpp | 13 +
wasm/bindings/pcbnew_embind.cpp | 13 +
wasm/bindings/pl_editor_embind.cpp | 13 +
web/pcbjam-shared | 2 +-
web/standalone/.env.example | 10 +
web/standalone/src/components/WasmTool.tsx | 121 ++++++++-
web/standalone/src/lib/api.ts | 30 ++-
web/standalone/src/lib/config.ts | 18 ++
web/standalone/src/lib/download.ts | 13 +
web/standalone/src/pages/HomePage.tsx | 123 ++++++++--
web/standalone/src/pages/ToolPage.tsx | 14 +-
web/standalone/src/wasm/collab/index.ts | 71 ++++--
.../src/wasm/collab/kicad-binding.test.ts | 22 ++
.../src/wasm/collab/kicad-binding.ts | 20 +-
web/standalone/src/wasm/global.d.ts | 5 +
web/standalone/src/wasm/save-flow.ts | 75 ++++++
web/standalone/tsconfig.json | 2 +-
19 files changed, 735 insertions(+), 62 deletions(-)
create mode 100644 tests/kicad/save-hook.spec.ts
create mode 100644 web/standalone/src/lib/download.ts
create mode 100644 web/standalone/src/wasm/save-flow.ts
diff --git a/kicad b/kicad
index 8192a71..2642cd7 160000
--- a/kicad
+++ b/kicad
@@ -1 +1 @@
-Subproject commit 8192a71cc2c21f76c41c822a3f0db9998b28ca86
+Subproject commit 2642cd741fdad5916fd1016299df728ef919cdc8
diff --git a/tests/kicad/save-hook.spec.ts b/tests/kicad/save-hook.spec.ts
new file mode 100644
index 0000000..f0d0116
--- /dev/null
+++ b/tests/kicad/save-hook.spec.ts
@@ -0,0 +1,230 @@
+import type { Page } from "@playwright/test";
+import { test, expect } from "./fixtures";
+
+/**
+ * onSave hook e2e (standalone-hardening 0005, save routing): a real user
+ * File→Save (Ctrl+S through the wx accelerator path) must fire
+ * window.kicadCollab.onSave(absPath) AFTER the bytes hit MEMFS — the signal the
+ * standalone app's save router (web/standalone/src/wasm/save-flow.ts) uses to
+ * persist saves out of the browser (API upload / local-disk write-back /
+ * download). The per-tool embind save helpers (kicadSaveDrawingSheet /
+ * kicadSaveSchematic / kicadSaveBoard) intentionally BYPASS the frame save
+ * chokepoint and must not fire it — they are test/materialize plumbing, not
+ * user saves.
+ */
+
+interface ToolCfg {
+ tool: string;
+ html: string;
+ ext: string;
+ saveFn: string;
+ /** Embind helper performing a genuine local edit (marks the doc modified, so
+ * the Save action is enabled when Ctrl+S arrives). */
+ modify: { fn: string; args: (string | number)[] };
+ fixture: string;
+}
+
+type Mod = Record unknown>;
+type FS = {
+ mkdirTree(p: string): void;
+ writeFile(p: string, d: string): void;
+ readFile(p: string, o: { encoding: "utf8" }): string;
+};
+type HookWindow = Window & {
+ FS: FS;
+ Module: Mod;
+ kicadCollab?: Record;
+ __savedPaths: string[];
+};
+
+const BOOT_TIMEOUT = 150000;
+const NAME = "savehook";
+
+/** Boot a fresh tool page and open the fixture from MEMFS (roundtrip.spec pattern). */
+async function bootOpen(page: Page, cfg: ToolCfg): Promise {
+ await page.goto(`/kicad/${cfg.html}`);
+ await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT });
+ await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT });
+ await page.waitForFunction(
+ ({ saveFn, modFn }) => {
+ const m = (window as unknown as { Module?: Mod }).Module;
+ return (
+ typeof m?.kicadOpenFile === "function" &&
+ typeof m?.[saveFn] === "function" &&
+ typeof m?.[modFn] === "function"
+ );
+ },
+ { saveFn: cfg.saveFn, modFn: cfg.modify.fn },
+ { timeout: BOOT_TIMEOUT },
+ );
+ await page.waitForFunction(
+ () =>
+ !!window.wxElementRegistry &&
+ window.wxElementRegistry
+ .findAll({ visible: true })
+ .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
+ null,
+ { timeout: BOOT_TIMEOUT },
+ );
+ const abs = `/home/kicad/documents/${NAME}.${cfg.ext}`;
+ await page.evaluate(
+ ({ content, abs }) => {
+ const w = window as unknown as HookWindow;
+ try {
+ w.FS.mkdirTree("/home/kicad/documents");
+ } catch {
+ /* exists */
+ }
+ w.FS.writeFile(abs, content);
+ w.Module.kicadOpenFile(abs);
+ },
+ { content: cfg.fixture, abs },
+ );
+ await page.waitForTimeout(2000); // let the async OpenProjectFiles settle
+ return abs;
+}
+
+/** Click the drawing-area CENTER (clicking near the top edge hits the menubar —
+ * the wx canvas hosts the whole app UI). */
+async function focusCanvas(page: Page): Promise {
+ const box = await page.locator("#canvas").boundingBox();
+ if (box) await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
+ await page.waitForTimeout(300);
+}
+
+async function expectSaveHookFires(page: Page, cfg: ToolCfg): Promise {
+ const abs = await bootOpen(page, cfg);
+
+ // Register the collector the way the standalone save router does (spread-merge
+ // so sibling bridge hooks survive).
+ await page.evaluate(() => {
+ const w = window as unknown as HookWindow;
+ w.__savedPaths = [];
+ w.kicadCollab = { ...w.kicadCollab, onSave: (p: string) => w.__savedPaths.push(p) };
+ });
+
+ // The embind save helper bypasses the frame chokepoint: NO hook.
+ await page.evaluate(
+ ({ saveFn, ext }) => {
+ const w = window as unknown as HookWindow;
+ w.Module[saveFn](`/home/kicad/documents/helper_dump.${ext}`);
+ },
+ { saveFn: cfg.saveFn, ext: cfg.ext },
+ );
+ expect(
+ await page.evaluate(() => (window as unknown as HookWindow).__savedPaths.length),
+ "embind save helper must not fire onSave",
+ ).toBe(0);
+
+ // A genuine local edit (enables Save), then the user save: Ctrl+S.
+ await page.evaluate(
+ ({ fn, args }) => (window as unknown as HookWindow).Module[fn](...args),
+ cfg.modify,
+ );
+ await page.waitForTimeout(500);
+ await focusCanvas(page);
+ await page.keyboard.press("Control+s");
+
+ await page.waitForFunction(
+ () => (window as unknown as HookWindow).__savedPaths.length > 0,
+ null,
+ { timeout: 30000 },
+ );
+ const saved = await page.evaluate(() => (window as unknown as HookWindow).__savedPaths);
+ expect(saved[0], "onSave must carry the opened file's MEMFS path").toBe(abs);
+
+ // And the bytes were on MEMFS by the time the hook fired: the saved file
+ // carries the fixture's uuids (i.e. it is a real serialized document).
+ const text = await page.evaluate(
+ (p) => (window as unknown as HookWindow).FS.readFile(p, { encoding: "utf8" }),
+ saved[0],
+ );
+ expect(text).toContain('(uuid "');
+}
+
+// ── Fixtures (trimmed copies of roundtrip.spec's known-good documents) ────────
+
+const PL: ToolCfg = {
+ tool: "pl_editor",
+ html: "pl_editor.html",
+ ext: "kicad_wks",
+ saveFn: "kicadSaveDrawingSheet",
+ modify: { fn: "kicadCollabTestAddText", args: ["save-hook", 30, 30] },
+ fixture: `(kicad_wks (version 20220228) (generator "pl_editor") (generator_version "9.0")
+ (setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15)
+ (left_margin 10)(right_margin 10)(top_margin 10)(bottom_margin 10))
+ (rect (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") (name border) (start 0 0 ltcorner) (end 0 0 rbcorner))
+)
+`,
+};
+
+const SCH: ToolCfg = {
+ tool: "eeschema",
+ html: "eeschema.html",
+ ext: "kicad_sch",
+ saveFn: "kicadSaveSchematic",
+ modify: { fn: "kicadCollabTestMoveFirst", args: [2, 2] },
+ fixture: `(kicad_sch
+ (version 20250114)
+ (generator "eeschema")
+ (generator_version "9.0")
+ (uuid "11111111-1111-1111-1111-111111111111")
+ (paper "A4")
+ (lib_symbols)
+ (wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001"))
+ (sheet_instances (path "/" (page "1")))
+)
+`,
+};
+
+const PCB: ToolCfg = {
+ tool: "pcbnew",
+ // pcbnew-collab.html seeds kicad_common.json so the first-run wizard is skipped.
+ html: "pcbnew-collab.html",
+ ext: "kicad_pcb",
+ saveFn: "kicadSaveBoard",
+ modify: { fn: "kicadCollabTestMoveFirst", args: [2, 2] },
+ fixture: `(kicad_pcb
+ (version 20241229)
+ (generator "pcbnew")
+ (generator_version "9.0")
+ (general (thickness 1.6))
+ (paper "A4")
+ (layers
+ (0 "F.Cu" signal)
+ (2 "B.Cu" signal)
+ (37 "F.SilkS" user)
+ (25 "Edge.Cuts" user)
+ )
+ (setup)
+ (net 0 "")
+ (footprint "TestLib:R"
+ (layer "F.Cu")
+ (uuid "66666666-0000-0000-0000-000000000001")
+ (at 100 100)
+ (attr smd)
+ (property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
+ )
+)
+`,
+};
+
+test.describe("user File→Save fires window.kicadCollab.onSave", () => {
+ // The heavy tools need well beyond the config's default per-test budget to boot.
+ test.describe.configure({ timeout: 300000 });
+
+ test("pl_editor: Ctrl+S → onSave with the saved MEMFS path", async ({ page, testLogger }) => {
+ void testLogger;
+ await expectSaveHookFires(page, PL);
+ });
+
+ test("eeschema: Ctrl+S → onSave with the saved MEMFS path", async ({ page, testLogger }) => {
+ void testLogger;
+ await expectSaveHookFires(page, SCH);
+ });
+
+ test("pcbnew: Ctrl+S → onSave with the saved MEMFS path", async ({ page, testLogger }) => {
+ void testLogger;
+ await expectSaveHookFires(page, PCB);
+ });
+});
diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp
index 9e1d194..fbff005 100644
--- a/wasm/bindings/eeschema_embind.cpp
+++ b/wasm/bindings/eeschema_embind.cpp
@@ -938,6 +938,19 @@ std::string kicadCollabGetPos( std::string aId )
// writer eeschema uses, so a test can read the file back from MEMFS and assert the
// file ⇄ Y.Doc round trip (README §A; feature 0004). Single-sheet scope: the round-
// trip fixtures are flat schematics; saving the root sheet writes the whole model.
+// C++ → JS save notification (standalone-hardening save routing). Called from the
+// kicad fork's save chokepoint (SCH_EDIT_FRAME::saveSchematicFile) after a
+// successful write to MEMFS, so the web app can route the saved bytes onward
+// (API upload, local-disk write-back, download). No-op without a JS listener.
+extern "C" void kicadCollabOnSave( const char* aPath )
+{
+ EM_ASM( {
+ if( window.kicadCollab && window.kicadCollab.onSave )
+ window.kicadCollab.onSave( UTF8ToString( $0 ) );
+ }, aPath );
+}
+
+
void kicadSaveSchematic( std::string path )
{
SCH_EDIT_FRAME* fr = schFrame();
diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp
index 0e00e9c..8bc2c07 100644
--- a/wasm/bindings/pcbnew_embind.cpp
+++ b/wasm/bindings/pcbnew_embind.cpp
@@ -1016,6 +1016,19 @@ std::string kicadCollabSnapshotItems()
// Serializes exactly what the editor has loaded via the same writer eeschema/pcbnew
// use, so a test can read the file back from MEMFS and assert the file ⇄ Y.Doc
// round trip (README §A; feature 0004). Uses only public PCB_IO_KICAD_SEXPR API.
+// C++ → JS save notification (standalone-hardening save routing). Called from the
+// kicad fork's save chokepoint (PCB_EDIT_FRAME::SavePcbFile) after a successful
+// write to MEMFS, so the web app can route the saved bytes onward (API upload,
+// local-disk write-back, download). No-op without a JS listener.
+extern "C" void kicadCollabOnSave( const char* aPath )
+{
+ EM_ASM( {
+ if( window.kicadCollab && window.kicadCollab.onSave )
+ window.kicadCollab.onSave( UTF8ToString( $0 ) );
+ }, aPath );
+}
+
+
void kicadSaveBoard( std::string path )
{
PCB_EDIT_FRAME* fr = pcbFrame();
diff --git a/wasm/bindings/pl_editor_embind.cpp b/wasm/bindings/pl_editor_embind.cpp
index 9e41ed8..d4bad2b 100644
--- a/wasm/bindings/pl_editor_embind.cpp
+++ b/wasm/bindings/pl_editor_embind.cpp
@@ -394,6 +394,19 @@ extern "C" void kicadCollabOnModify()
}
+// C++ → JS save notification (standalone-hardening save routing). Called from the
+// kicad fork's save chokepoint (PL_EDITOR_FRAME::SaveDrawingSheetFile) after a
+// successful write to MEMFS, so the web app can route the saved bytes onward
+// (API upload, local-disk write-back, download). No-op without a JS listener.
+extern "C" void kicadCollabOnSave( const char* aPath )
+{
+ EM_ASM( {
+ if( window.kicadCollab && window.kicadCollab.onSave )
+ window.kicadCollab.onSave( UTF8ToString( $0 ) );
+ }, aPath );
+}
+
+
// JS pull of the full current model as an all-"added" delta, used to seed the Y.Doc on
// join and to (re)baseline the differ. Idempotent.
std::string kicadCollabSnapshot()
diff --git a/web/pcbjam-shared b/web/pcbjam-shared
index 9cfea41..92b2634 160000
--- a/web/pcbjam-shared
+++ b/web/pcbjam-shared
@@ -1 +1 @@
-Subproject commit 9cfea4184b007d2358978cbfd472d4a93a656c82
+Subproject commit 92b2634c6d17fb387574b43a38890fd22b30891d
diff --git a/web/standalone/.env.example b/web/standalone/.env.example
index 8ae50c4..9f244e2 100644
--- a/web/standalone/.env.example
+++ b/web/standalone/.env.example
@@ -14,3 +14,13 @@ VITE_WASM_ASSET_BASE_URL=/wasm
# Override the artifact source dir the dev symlink points at (default:
# /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/ 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
diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx
index 9d9f7d3..39a0306 100644
--- a/web/standalone/src/components/WasmTool.tsx
+++ b/web/standalone/src/components/WasmTool.tsx
@@ -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;
+ /**
+ * 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
+ * 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)}`);
diff --git a/web/standalone/src/lib/api.ts b/web/standalone/src/lib/api.ts
index 0cabab0..c2bb29f 100644
--- a/web/standalone/src/lib/api.ts
+++ b/web/standalone/src/lib/api.ts
@@ -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 {
+ 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}`);
+}
diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts
index 8e66e8a..90bbe00 100644
--- a/web/standalone/src/lib/config.ts
+++ b/web/standalone/src/lib/config.ts
@@ -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/ 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";
+}
diff --git a/web/standalone/src/lib/download.ts b/web/standalone/src/lib/download.ts
new file mode 100644
index 0000000..8f5ef10
--- /dev/null
+++ b/web/standalone/src/lib/download.ts
@@ -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);
+}
diff --git a/web/standalone/src/pages/HomePage.tsx b/web/standalone/src/pages/HomePage.tsx
index 9a0c3a0..f6febfe 100644
--- a/web/standalone/src/pages/HomePage.tsx
+++ b/web/standalone/src/pages/HomePage.tsx
@@ -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;
+ /**
+ * 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 {
+ const handles = new Map();
+ async function walk(dir: FileSystemDirectoryHandle, prefix: string): Promise {
+ 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();
@@ -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.
- {
- const fl = e.target.files;
- if (!fl || fl.length === 0) return;
- const proj = buildLocalProject(fl);
- setLocal(proj);
- setTool(proj.defaultTool ?? "");
- }}
- />
+ {window.showDirectoryPicker ? (
+
+ ) : (
+ // No File System Access API (Firefox/Safari): read-only folder input;
+ // editor saves arrive as browser downloads instead of disk writes.
+ {
+ const fl = e.target.files;
+ if (!fl || fl.length === 0) return;
+ const proj = buildLocalProject(fl);
+ setLocal(proj);
+ setTool(proj.defaultTool ?? "");
+ }}
+ />
+ )}
{local && (
diff --git a/web/standalone/src/pages/ToolPage.tsx b/web/standalone/src/pages/ToolPage.tsx
index f1f8dba..fcca518 100644
--- a/web/standalone/src/pages/ToolPage.tsx
+++ b/web/standalone/src/pages/ToolPage.tsx
@@ -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}
/>
);
diff --git a/web/standalone/src/wasm/collab/index.ts b/web/standalone/src/wasm/collab/index.ts
index 4004f96..795d4f1 100644
--- a/web/standalone/src/wasm/collab/index.ts
+++ b/web/standalone/src/wasm/collab/index.ts
@@ -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 {
+ 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 {
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 });
}
diff --git a/web/standalone/src/wasm/collab/kicad-binding.test.ts b/web/standalone/src/wasm/collab/kicad-binding.test.ts
index b706e0f..a848705 100644
--- a/web/standalone/src/wasm/collab/kicad-binding.test.ts
+++ b/web/standalone/src/wasm/collab/kicad-binding.test.ts
@@ -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);
diff --git a/web/standalone/src/wasm/collab/kicad-binding.ts b/web/standalone/src/wasm/collab/kicad-binding.ts
index 509b76c..b76b5eb 100644
--- a/web/standalone/src/wasm/collab/kicad-binding.ts
+++ b/web/standalone/src/wasm/collab/kicad-binding.ts
@@ -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
diff --git a/web/standalone/src/wasm/global.d.ts b/web/standalone/src/wasm/global.d.ts
index 690eade..5aa69e4 100644
--- a/web/standalone/src/wasm/global.d.ts
+++ b/web/standalone/src/wasm/global.d.ts
@@ -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;
}
// The browsing-context window the tool runs in — now the top-level `window`
diff --git a/web/standalone/src/wasm/save-flow.ts b/web/standalone/src/wasm/save-flow.ts
new file mode 100644
index 0000000..b9f138d
--- /dev/null
+++ b/web/standalone/src/wasm/save-flow.ts
@@ -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;
+
+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 };
+}
diff --git a/web/standalone/tsconfig.json b/web/standalone/tsconfig.json
index 8826a9f..cabe475 100644
--- a/web/standalone/tsconfig.json
+++ b/web/standalone/tsconfig.json
@@ -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",