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

2
kicad

@ -1 +1 @@
Subproject commit 8192a71cc2c21f76c41c822a3f0db9998b28ca86 Subproject commit 2642cd741fdad5916fd1016299df728ef919cdc8

View file

@ -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
* FileSave (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<string, (...a: (string | number)[]) => 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<string, unknown>;
__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<string> {
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<void> {
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<void> {
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);
});
});

View file

@ -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 // 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- // 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. // 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 ) void kicadSaveSchematic( std::string path )
{ {
SCH_EDIT_FRAME* fr = schFrame(); SCH_EDIT_FRAME* fr = schFrame();

View file

@ -1016,6 +1016,19 @@ std::string kicadCollabSnapshotItems()
// Serializes exactly what the editor has loaded via the same writer eeschema/pcbnew // 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 // 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. // 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 ) void kicadSaveBoard( std::string path )
{ {
PCB_EDIT_FRAME* fr = pcbFrame(); PCB_EDIT_FRAME* fr = pcbFrame();

View file

@ -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 // 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. // join and to (re)baseline the differ. Idempotent.
std::string kicadCollabSnapshot() std::string kicadCollabSnapshot()

@ -1 +1 @@
Subproject commit 9cfea4184b007d2358978cbfd472d4a93a656c82 Subproject commit 92b2634c6d17fb387574b43a38890fd22b30891d

View file

@ -14,3 +14,13 @@ VITE_WASM_ASSET_BASE_URL=/wasm
# Override the artifact source dir the dev symlink points at (default: # Override the artifact source dir the dev symlink points at (default:
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere. # <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
# WASM_SRC_DIR= # 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 * as React from "react";
import { import {
collabRoomId, collabRoomId,
docToFile,
EXTENSION_TOOL, EXTENSION_TOOL,
FILELESS_TOOLS, FILELESS_TOOLS,
fileToDoc, fileToDoc,
kicadItemsMap,
toolSchema, toolSchema,
yToDoc,
type KicadDoc, type KicadDoc,
type Tool, type Tool,
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react"; 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 { bootKicadTool } from "@/wasm/boot";
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 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 { 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";
@ -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 * 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 * 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; slug: string;
projectId: string; projectId: string;
targetPath?: string; targetPath?: string;
collabSession?: KicadDocSession;
/** The opened file was materialized from collabSession's doc (ydoc source). */
editorMatchesDoc?: boolean;
log: (m: string) => void; log: (m: string) => void;
onStatus: (t: string) => void; onStatus: (t: string) => void;
}, },
@ -226,8 +272,10 @@ async function maybeStartCollab(
url: win.location.href, url: win.location.href,
}); });
// On by default; only an explicit opt-out disables it. // On by default; only an explicit opt-out disables it. A pre-connected doc
if (collabParam === "0" || collabParam === "false") { // 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"); clog("disabled (?collab=0) — skipping");
return; return;
} }
@ -244,13 +292,29 @@ async function maybeStartCollab(
return; 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(); const provider = yjsProviderConfig();
// One room per (project, document). Two tabs of the same build compute the // 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 // same id, so cross-tab BroadcastChannel still works; network providers use it
// 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);
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
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, { await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider, provider,
@ -276,6 +340,8 @@ export function WasmTool({
files, files,
targetPath, targetPath,
fetchBytes, fetchBytes,
saveBytes,
docSource,
assetBaseUrl, assetBaseUrl,
}: { }: {
tool: Tool; tool: Tool;
@ -286,6 +352,20 @@ export function WasmTool({
targetPath?: string; targetPath?: string;
/** Fetch one project-relative file's bytes (contract loader or local folder). */ /** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>; 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. */ /** Where the WASM glue/artifacts are served from; defaults to VITE_WASM_ASSET_BASE_URL. */
assetBaseUrl?: string; assetBaseUrl?: string;
}) { }) {
@ -348,16 +428,41 @@ export function WasmTool({
onStatus: setStatus, onStatus: setStatus,
onAbort: oom.onAbort, 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, { await driveProjectIntoTool(win, {
tool, tool,
slug, slug,
files, files,
targetPath, 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, log: append,
onStatus: setStatus, onStatus: setStatus,
}); });
await maybeStartCollab(win, { tool, slug, projectId, targetPath, log: append, onStatus: setStatus });
} catch (err) { } catch (err) {
append(`[fatal] ${String(err)}`); append(`[fatal] ${String(err)}`);
setStatus(`Error: ${String(err)}`); setStatus(`Error: ${String(err)}`);

View file

@ -8,10 +8,11 @@ import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL } from "./config"; import { API_BASE_URL } from "./config";
/** /**
* Read-only client over the shared contract. The standalone editor only ever * Client over the shared contract. The standalone editor READS projects from a
* READS projects from a backend (enumerate, get file tree, stream bytes) it * backend (enumerate, get file tree, stream bytes) and writes back exactly one
* never creates/deletes/uploads. Those management concerns live in the closed * thing: the bytes of a file the user explicitly saved in the editor (see
* application that hosts this editor. * uploadFileBytes). Project management (create/delete/bulk upload) stays in the
* closed application that hosts this editor.
*/ */
export const client = initClient(contract, { export const client = initClient(contract, {
baseUrl: API_BASE_URL, baseUrl: API_BASE_URL,
@ -59,3 +60,24 @@ export async function fetchFileBytes(
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`); if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer()); 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, 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"; } from "@pcbjam/shared";
import { FolderOpen, Loader2 } from "lucide-react"; import { FolderOpen, Loader2 } from "lucide-react";
import { useProjects } from "@/lib/api"; import { useProjects } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { ToolFile } from "@/wasm/kicad-runner"; import type { ToolFile } from "@/wasm/kicad-runner";
import type { SaveBytes } from "@/wasm/save-flow";
import { WasmTool } from "@/components/WasmTool"; import { WasmTool } from "@/components/WasmTool";
/** A KiCad project picked from the local filesystem (no backend involved). */ /** A KiCad project picked from the local filesystem (no backend involved). */
@ -18,6 +20,12 @@ interface LocalProject {
name: string; name: string;
files: ToolFile[]; files: ToolFile[];
fetchBytes: (relPath: string) => Promise<Uint8Array>; 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; defaultTool?: Tool;
defaultTarget?: string; defaultTarget?: string;
} }
@ -28,6 +36,56 @@ function toolForPath(path: string): Tool | null {
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? 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. */ /** Build a LocalProject from a webkitdirectory FileList, stripping the top folder. */
function buildLocalProject(fileList: FileList): LocalProject { function buildLocalProject(fileList: FileList): LocalProject {
const map = new Map<string, File>(); 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); map.set(rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel, f);
} }
const files: ToolFile[] = [...map.keys()].map((path) => ({ path })); 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 { return {
name: topPrefix ? topPrefix.slice(0, -1) : "local", name: topPrefix ? topPrefix.slice(0, -1) : "local",
files, files,
defaultTool, ...defaultOpenTarget(files),
defaultTarget,
fetchBytes: async (relPath) => { fetchBytes: async (relPath) => {
const f = map.get(relPath); const f = map.get(relPath);
if (!f) throw new Error(`local file not found: ${relPath}`); if (!f) throw new Error(`local file not found: ${relPath}`);
return new Uint8Array(await f.arrayBuffer()); 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} files={local.files}
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target} targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
fetchBytes={local.fetchBytes} fetchBytes={local.fetchBytes}
saveBytes={local.saveBytes}
/> />
); );
} }
@ -109,6 +159,28 @@ export function HomePage() {
No upload files stay in your browser. Pick a folder containing a No upload files stay in your browser. Pick a folder containing a
KiCad project. KiCad project.
</p> </p>
{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 <input
ref={inputRef} ref={inputRef}
type="file" type="file"
@ -122,6 +194,7 @@ export function HomePage() {
setTool(proj.defaultTool ?? ""); setTool(proj.defaultTool ?? "");
}} }}
/> />
)}
{local && ( {local && (
<div className="mt-4 flex flex-wrap items-center gap-3"> <div className="mt-4 flex flex-wrap items-center gap-3">

View file

@ -1,6 +1,7 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toolSchema } from "@pcbjam/shared"; 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 { WasmTool } from "@/components/WasmTool";
import { PreflightGate } from "@/preflight/PreflightGate"; 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 // PreflightGate runs the device-capability check; on a fatal mismatch it blocks
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped. // here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
return ( return (
@ -42,6 +48,12 @@ export function ToolPage() {
files={data.files} files={data.files}
targetPath={targetPath} targetPath={targetPath}
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)} fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
saveBytes={
docSource === "api"
? (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
: undefined
}
docSource={docSource}
/> />
</PreflightGate> </PreflightGate>
); );

View file

@ -116,6 +116,56 @@ export interface KicadCollabHandle {
destroy(): void; 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 * The Slot-model counterpart of `startCollab` (ysync 0008): wires the v2 items
* bridge (kicadCollabSnapshotItems / ApplyItems / onItems Stage C exports) into * bridge (kicadCollabSnapshotItems / ApplyItems / onItems Stage C exports) into
@ -129,23 +179,6 @@ export async function startKicadCollab(
opts: StartCollabOptions, opts: StartCollabOptions,
): Promise<KicadCollabHandle> { ): Promise<KicadCollabHandle> {
clog("startKicadCollab:", opts.provider.kind, "room =", opts.room); clog("startKicadCollab:", opts.provider.kind, "room =", opts.room);
const doc = new Y.Doc(); const session = await connectKicadDoc({ provider: opts.provider, room: opts.room });
const bridge = moduleItemsBridge(mod, win); return attachKicadCollab(mod, win, session, { seedDoc: opts.seedDoc });
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();
},
};
} }

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"]); 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", () => { it("destroy() detaches the editor from further remote changes", () => {
const { edA, edB, bindA, bindB } = setup(); const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP); seedEditor(edA, FP);

View file

@ -55,8 +55,12 @@ export interface KicadBinding {
* editor snapshot (items only). Otherwise the editor adopts the doc (doc * editor snapshot (items only). Otherwise the editor adopts the doc (doc
* authority local-only roots are removed, doc roots applied). Call once * authority local-only roots are removed, doc roots applied). Call once
* after the doc/provider are connected. * 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; destroy(): void;
/** The underlying kdoc items map (exposed for tests/inspection). */ /** The underlying kdoc items map (exposed for tests/inspection). */
readonly items: KicadYItems; readonly items: KicadYItems;
@ -119,8 +123,20 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
}; };
items.observeDeep(observer); 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 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) { if (items.size === 0 && 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

View file

@ -45,6 +45,11 @@ declare global {
FS?: EmscriptenFS; FS?: EmscriptenFS;
wxElementRegistry?: WxElementRegistry; wxElementRegistry?: WxElementRegistry;
kicadWebOpenTool?: (toolName: string, fileName: string) => boolean; 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` // 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", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"jsx": "react-jsx", "jsx": "react-jsx",
"types": ["vite/client"], "types": ["vite/client"],
"moduleResolution": "Bundler", "moduleResolution": "Bundler",