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",