feat: standalone save/load routing + VITE_DOC_SOURCE ydoc mode
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
07863da416
commit
8131bf9bac
19 changed files with 735 additions and 62 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 8192a71cc2c21f76c41c822a3f0db9998b28ca86
|
||||
Subproject commit 2642cd741fdad5916fd1016299df728ef919cdc8
|
||||
230
tests/kicad/save-hook.spec.ts
Normal file
230
tests/kicad/save-hook.spec.ts
Normal 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
|
||||
* 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<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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 9cfea4184b007d2358978cbfd472d4a93a656c82
|
||||
Subproject commit 92b2634c6d17fb387574b43a38890fd22b30891d
|
||||
|
|
@ -14,3 +14,13 @@ VITE_WASM_ASSET_BASE_URL=/wasm
|
|||
# Override the artifact source dir the dev symlink points at (default:
|
||||
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
|
||||
# WASM_SRC_DIR=
|
||||
|
||||
# Where a backend project's DOCUMENT content lives ("api" default | "ydoc").
|
||||
# Same /p/<project> URLs either way. "api": file bytes come from the REST
|
||||
# backend and a user save (File->Save in the editor) is uploaded back to it.
|
||||
# "ydoc": the collab room (VITE_YJS_PROVIDER) is the source of truth - when the
|
||||
# room holds the document it is materialized client-side instead of fetched,
|
||||
# and saves stay in the browser (the provider persists the doc); the REST
|
||||
# backend still serves project metadata + sibling files, and the file fetch is
|
||||
# the first-open fallback that seeds the room.
|
||||
# VITE_DOC_SOURCE=ydoc
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import * as React from "react";
|
||||
import {
|
||||
collabRoomId,
|
||||
docToFile,
|
||||
EXTENSION_TOOL,
|
||||
FILELESS_TOOLS,
|
||||
fileToDoc,
|
||||
kicadItemsMap,
|
||||
toolSchema,
|
||||
yToDoc,
|
||||
type KicadDoc,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config";
|
||||
import { WASM_ASSET_BASE_URL, yjsProviderConfig, type DocSource } from "@/lib/config";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||
import type { KicadItemsWindow } from "@/wasm/collab";
|
||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||
import type { KicadDocSession, KicadItemsWindow } from "@/wasm/collab";
|
||||
import { clog, cwarn } from "@/wasm/collab/debug";
|
||||
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
||||
|
|
@ -195,6 +199,45 @@ function seedDocFromMemfs(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `docSource: "ydoc"` pre-step (config/env-selected — same /p/ URLs as "api"
|
||||
* mode): connect the document's collab room BEFORE the file opens and, when the
|
||||
* room already holds the doc, materialize the file from it (docToFile) so the
|
||||
* editor opens the doc's state instead of the API's copy. An empty room (first
|
||||
* ever open) falls back to the API fetch — the seed() that follows file-seeds
|
||||
* the room from it. Returns the session for `maybeStartCollab` to attach to.
|
||||
*/
|
||||
async function maybeConnectDocSession(
|
||||
win: ToolWindow,
|
||||
opts: {
|
||||
docSource?: DocSource;
|
||||
tool: Tool;
|
||||
projectId: string;
|
||||
targetPath?: string;
|
||||
log: (m: string) => void;
|
||||
},
|
||||
): Promise<{ session?: KicadDocSession; targetBytes?: Uint8Array }> {
|
||||
if (opts.docSource !== "ydoc") return {};
|
||||
if (!opts.targetPath || !COLLAB_TOOLS.has(opts.tool)) return {};
|
||||
|
||||
const { connectKicadDoc } = await import("@/wasm/collab");
|
||||
const room = collabRoomId(opts.projectId, opts.targetPath);
|
||||
const session = await connectKicadDoc({ provider: yjsProviderConfig(), room });
|
||||
|
||||
if (kicadItemsMap(session.doc).size === 0) {
|
||||
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
|
||||
return { session };
|
||||
}
|
||||
try {
|
||||
const text = docToFile(yToDoc(session.doc));
|
||||
opts.log(`[ydoc] materialized ${opts.targetPath} from room ${room} (${text.length} chars)`);
|
||||
return { session, targetBytes: new TextEncoder().encode(text) };
|
||||
} catch (err) {
|
||||
cwarn("ydoc: materialize failed — falling back to the API fetch", err);
|
||||
return { session };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any
|
||||
* tool that has the collab bridge. Open the same project URL in two tabs to edit
|
||||
|
|
@ -211,6 +254,9 @@ async function maybeStartCollab(
|
|||
slug: string;
|
||||
projectId: string;
|
||||
targetPath?: string;
|
||||
collabSession?: KicadDocSession;
|
||||
/** The opened file was materialized from collabSession's doc (ydoc source). */
|
||||
editorMatchesDoc?: boolean;
|
||||
log: (m: string) => void;
|
||||
onStatus: (t: string) => void;
|
||||
},
|
||||
|
|
@ -226,8 +272,10 @@ async function maybeStartCollab(
|
|||
url: win.location.href,
|
||||
});
|
||||
|
||||
// On by default; only an explicit opt-out disables it.
|
||||
if (collabParam === "0" || collabParam === "false") {
|
||||
// On by default; only an explicit opt-out disables it. A pre-connected doc
|
||||
// session (Y.Doc-load path) ignores the opt-out: the doc IS the data source,
|
||||
// so detaching would silently drop every edit.
|
||||
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
|
||||
clog("disabled (?collab=0) — skipping");
|
||||
return;
|
||||
}
|
||||
|
|
@ -244,13 +292,29 @@ async function maybeStartCollab(
|
|||
return;
|
||||
}
|
||||
|
||||
const { startKicadCollab } = await import("@/wasm/collab");
|
||||
const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab");
|
||||
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
|
||||
|
||||
if (opts.collabSession) {
|
||||
// docSource "ydoc": the provider is already connected. When the editor
|
||||
// opened the file materialized from this very doc, attach + baseline only;
|
||||
// when the room was empty (API fallback), seed() file-seeds it as usual.
|
||||
clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc);
|
||||
attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
|
||||
seedDoc,
|
||||
editorMatchesDoc: opts.editorMatchesDoc,
|
||||
});
|
||||
opts.log(`[collab] attached to Y.Doc session`);
|
||||
opts.onStatus("Collab: connected");
|
||||
clog("connected ✓");
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = yjsProviderConfig();
|
||||
// One room per (project, document). Two tabs of the same build compute the
|
||||
// same id, so cross-tab BroadcastChannel still works; network providers use it
|
||||
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
|
||||
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
|
||||
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
|
||||
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
|
||||
await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
|
||||
provider,
|
||||
|
|
@ -276,6 +340,8 @@ export function WasmTool({
|
|||
files,
|
||||
targetPath,
|
||||
fetchBytes,
|
||||
saveBytes,
|
||||
docSource,
|
||||
assetBaseUrl,
|
||||
}: {
|
||||
tool: Tool;
|
||||
|
|
@ -286,6 +352,20 @@ export function WasmTool({
|
|||
targetPath?: string;
|
||||
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||
/**
|
||||
* Persist one file the user saved in the editor (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)}`);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { API_BASE_URL } from "./config";
|
||||
|
||||
/**
|
||||
* Read-only client over the shared contract. The standalone editor only ever
|
||||
* READS projects from a backend (enumerate, get file tree, stream bytes) — it
|
||||
* never creates/deletes/uploads. Those management concerns live in the closed
|
||||
* application that hosts this editor.
|
||||
* Client over the shared contract. The standalone editor READS projects from a
|
||||
* backend (enumerate, get file tree, stream bytes) and writes back exactly one
|
||||
* thing: the bytes of a file the user explicitly saved in the editor (see
|
||||
* uploadFileBytes). Project management (create/delete/bulk upload) stays in the
|
||||
* closed application that hosts this editor.
|
||||
*/
|
||||
export const client = initClient(contract, {
|
||||
baseUrl: API_BASE_URL,
|
||||
|
|
@ -59,3 +60,24 @@ export async function fetchFileBytes(
|
|||
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one saved file back to the backend via the multipart upload route
|
||||
* (POST /api/projects/:project/files — upserts by (project, path); the form
|
||||
* FIELD NAME carries the project-relative path, same convention as the
|
||||
* management app's folder upload).
|
||||
*/
|
||||
export async function uploadFileBytes(
|
||||
slug: string,
|
||||
relPath: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<void> {
|
||||
const name = relPath.split("/").pop() ?? relPath;
|
||||
const form = new FormData();
|
||||
form.append(relPath, new File([bytes as BlobPart], name));
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
|
||||
{ method: "POST", body: form },
|
||||
);
|
||||
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,3 +25,21 @@ export function yjsProviderConfig(): ProviderConfig {
|
|||
params: token ? { token } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a backend project's DOCUMENT content lives (per deployment, not per
|
||||
* route — /p/<project> URLs behave the same either way):
|
||||
*
|
||||
* "api" — file bytes are fetched from the REST backend and a user save is
|
||||
* uploaded back to it (the Y.Doc, when collab is on, mirrors the file).
|
||||
* "ydoc" — the collab room is the source of truth: when it holds the document
|
||||
* it is materialized client-side (docToFile) instead of fetching the
|
||||
* file, and saves stay in MEMFS (the provider persists the doc). The
|
||||
* REST backend still serves project metadata + sibling files, and the
|
||||
* file fetch remains the first-open fallback that seeds the room.
|
||||
*/
|
||||
export type DocSource = "api" | "ydoc";
|
||||
|
||||
export function docSourceConfig(): DocSource {
|
||||
return import.meta.env.VITE_DOC_SOURCE === "ydoc" ? "ydoc" : "api";
|
||||
}
|
||||
|
|
|
|||
13
web/standalone/src/lib/download.ts
Normal file
13
web/standalone/src/lib/download.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -9,8 +9,10 @@ import {
|
|||
} from "@pcbjam/shared";
|
||||
import { FolderOpen, Loader2 } from "lucide-react";
|
||||
import { useProjects } from "@/lib/api";
|
||||
import { downloadBytes } from "@/lib/download";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ToolFile } from "@/wasm/kicad-runner";
|
||||
import type { SaveBytes } from "@/wasm/save-flow";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
|
||||
/** A KiCad project picked from the local filesystem (no backend involved). */
|
||||
|
|
@ -18,6 +20,12 @@ interface LocalProject {
|
|||
name: string;
|
||||
files: ToolFile[];
|
||||
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
||||
/**
|
||||
* Where editor saves land: write-back through File System Access handles
|
||||
* (folder picked via showDirectoryPicker), or a browser download per save
|
||||
* (webkitdirectory fallback — its FileList grants no write access).
|
||||
*/
|
||||
saveBytes: SaveBytes;
|
||||
defaultTool?: Tool;
|
||||
defaultTarget?: string;
|
||||
}
|
||||
|
|
@ -28,6 +36,56 @@ function toolForPath(path: string): Tool | null {
|
|||
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
function defaultOpenTarget(files: ToolFile[]): { defaultTool?: Tool; defaultTarget?: string } {
|
||||
for (const { path } of files) {
|
||||
const tool = toolForPath(path);
|
||||
if (tool) return { defaultTool: tool, defaultTarget: path };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a LocalProject over File System Access handles (showDirectoryPicker,
|
||||
* Chromium): reads come from the live files, and editor saves are written
|
||||
* straight back to the user's folder on disk.
|
||||
*/
|
||||
async function buildFsaProject(root: FileSystemDirectoryHandle): Promise<LocalProject> {
|
||||
const handles = new Map<string, FileSystemFileHandle>();
|
||||
async function walk(dir: FileSystemDirectoryHandle, prefix: string): Promise<void> {
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
if (handle.kind === "file") handles.set(prefix + name, handle as FileSystemFileHandle);
|
||||
else await walk(handle as FileSystemDirectoryHandle, `${prefix}${name}/`);
|
||||
}
|
||||
}
|
||||
await walk(root, "");
|
||||
const files: ToolFile[] = [...handles.keys()].map((path) => ({ path }));
|
||||
return {
|
||||
name: root.name,
|
||||
files,
|
||||
...defaultOpenTarget(files),
|
||||
fetchBytes: async (relPath) => {
|
||||
const handle = handles.get(relPath);
|
||||
if (!handle) throw new Error(`local file not found: ${relPath}`);
|
||||
return new Uint8Array(await (await handle.getFile()).arrayBuffer());
|
||||
},
|
||||
saveBytes: async (relPath, bytes) => {
|
||||
// Resolve (and create — the editor may save a brand-new file, e.g. a
|
||||
// .kicad_pro next to a board) the path under the picked root.
|
||||
const segs = relPath.split("/");
|
||||
const fileName = segs.pop();
|
||||
if (!fileName) throw new Error(`invalid save path: ${relPath}`);
|
||||
let dir = root;
|
||||
for (const seg of segs) dir = await dir.getDirectoryHandle(seg, { create: true });
|
||||
const handle =
|
||||
handles.get(relPath) ?? (await dir.getFileHandle(fileName, { create: true }));
|
||||
handles.set(relPath, handle);
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(bytes as unknown as FileSystemWriteChunkType);
|
||||
await writable.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a LocalProject from a webkitdirectory FileList, stripping the top folder. */
|
||||
function buildLocalProject(fileList: FileList): LocalProject {
|
||||
const map = new Map<string, File>();
|
||||
|
|
@ -41,26 +99,17 @@ function buildLocalProject(fileList: FileList): LocalProject {
|
|||
map.set(rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel, f);
|
||||
}
|
||||
const files: ToolFile[] = [...map.keys()].map((path) => ({ path }));
|
||||
let defaultTool: Tool | undefined;
|
||||
let defaultTarget: string | undefined;
|
||||
for (const { path } of files) {
|
||||
const tool = toolForPath(path);
|
||||
if (tool) {
|
||||
defaultTool = tool;
|
||||
defaultTarget = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: topPrefix ? topPrefix.slice(0, -1) : "local",
|
||||
files,
|
||||
defaultTool,
|
||||
defaultTarget,
|
||||
...defaultOpenTarget(files),
|
||||
fetchBytes: async (relPath) => {
|
||||
const f = map.get(relPath);
|
||||
if (!f) throw new Error(`local file not found: ${relPath}`);
|
||||
return new Uint8Array(await f.arrayBuffer());
|
||||
},
|
||||
// A webkitdirectory FileList is read-only — saves become downloads.
|
||||
saveBytes: async (relPath, bytes) => downloadBytes(relPath, bytes),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +137,7 @@ export function HomePage() {
|
|||
files={local.files}
|
||||
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
|
||||
fetchBytes={local.fetchBytes}
|
||||
saveBytes={local.saveBytes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -109,19 +159,42 @@ export function HomePage() {
|
|||
No upload — files stay in your browser. Pick a folder containing a
|
||||
KiCad project.
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="block text-sm"
|
||||
onChange={(e) => {
|
||||
const fl = e.target.files;
|
||||
if (!fl || fl.length === 0) return;
|
||||
const proj = buildLocalProject(fl);
|
||||
setLocal(proj);
|
||||
setTool(proj.defaultTool ?? "");
|
||||
}}
|
||||
/>
|
||||
{window.showDirectoryPicker ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
let root: FileSystemDirectoryHandle;
|
||||
try {
|
||||
root = await window.showDirectoryPicker!({ mode: "readwrite" });
|
||||
} catch {
|
||||
return; // user cancelled the picker / denied write access
|
||||
}
|
||||
const proj = await buildFsaProject(root);
|
||||
setLocal(proj);
|
||||
setTool(proj.defaultTool ?? "");
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={16} /> Choose folder
|
||||
</Button>
|
||||
) : (
|
||||
// No File System Access API (Firefox/Safari): read-only folder input;
|
||||
// editor saves arrive as browser downloads instead of disk writes.
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="block text-sm"
|
||||
onChange={(e) => {
|
||||
const fl = e.target.files;
|
||||
if (!fl || fl.length === 0) return;
|
||||
const proj = buildLocalProject(fl);
|
||||
setLocal(proj);
|
||||
setTool(proj.defaultTool ?? "");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{local && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useParams } from "react-router-dom";
|
||||
import { toolSchema } from "@pcbjam/shared";
|
||||
import { fetchFileBytes, useProject } from "@/lib/api";
|
||||
import { fetchFileBytes, uploadFileBytes, useProject } from "@/lib/api";
|
||||
import { docSourceConfig } from "@/lib/config";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
||||
|
||||
|
|
@ -31,6 +32,11 @@ export function ToolPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Env-selected document source (same /p/ URLs either way): with "ydoc" the
|
||||
// collab room is the source of truth, so saves are NOT uploaded back — the
|
||||
// provider persists the doc. With "api" a user save uploads to the backend.
|
||||
const docSource = docSourceConfig();
|
||||
|
||||
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks
|
||||
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
|
||||
return (
|
||||
|
|
@ -42,6 +48,12 @@ export function ToolPage() {
|
|||
files={data.files}
|
||||
targetPath={targetPath}
|
||||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||
saveBytes={
|
||||
docSource === "api"
|
||||
? (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
|
||||
: undefined
|
||||
}
|
||||
docSource={docSource}
|
||||
/>
|
||||
</PreflightGate>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,56 @@ export interface KicadCollabHandle {
|
|||
destroy(): void;
|
||||
}
|
||||
|
||||
/** A provider-connected, initial-state-synced Y.Doc, not yet bound to an editor. */
|
||||
export interface KicadDocSession {
|
||||
doc: Y.Doc;
|
||||
provider: YjsProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a fresh Y.Doc to a provider room and wait for its authoritative
|
||||
* initial state. Used standalone by the Y.Doc-load path (materialize the file
|
||||
* from the doc BEFORE any editor exists), and as the first half of
|
||||
* `startKicadCollab`.
|
||||
*/
|
||||
export async function connectKicadDoc(opts: {
|
||||
provider: ProviderConfig;
|
||||
room: string;
|
||||
}): Promise<KicadDocSession> {
|
||||
const doc = new Y.Doc();
|
||||
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
|
||||
await provider.whenSynced();
|
||||
return { doc, provider };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a running editor to an already-synced doc session (second half of
|
||||
* `startKicadCollab`). `editorMatchesDoc` marks the Y.Doc-load path: the open
|
||||
* file was materialized from this very doc, so seed only baselines the differ
|
||||
* instead of re-applying the full document.
|
||||
*/
|
||||
export function attachKicadCollab(
|
||||
mod: KicadItemsModule,
|
||||
win: KicadItemsWindow,
|
||||
session: KicadDocSession,
|
||||
opts?: { seedDoc?: KicadDoc; editorMatchesDoc?: boolean },
|
||||
): KicadCollabHandle {
|
||||
const binding = bindKicadCollab(session.doc, moduleItemsBridge(mod, win));
|
||||
binding.seed(opts?.seedDoc, { editorMatchesDoc: opts?.editorMatchesDoc });
|
||||
clog("attachKicadCollab: ready; doc items =", binding.items.size);
|
||||
|
||||
return {
|
||||
doc: session.doc,
|
||||
binding,
|
||||
provider: session.provider,
|
||||
destroy() {
|
||||
binding.destroy();
|
||||
session.provider.destroy();
|
||||
session.doc.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Slot-model counterpart of `startCollab` (ysync 0008): wires the v2 items
|
||||
* bridge (kicadCollabSnapshotItems / ApplyItems / onItems — Stage C exports) into
|
||||
|
|
@ -129,23 +179,6 @@ export async function startKicadCollab(
|
|||
opts: StartCollabOptions,
|
||||
): Promise<KicadCollabHandle> {
|
||||
clog("startKicadCollab:", opts.provider.kind, "room =", opts.room);
|
||||
const doc = new Y.Doc();
|
||||
const bridge = moduleItemsBridge(mod, win);
|
||||
const binding = bindKicadCollab(doc, bridge);
|
||||
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
|
||||
|
||||
await provider.whenSynced();
|
||||
binding.seed(opts.seedDoc);
|
||||
clog("startKicadCollab: ready; doc items =", binding.items.size);
|
||||
|
||||
return {
|
||||
doc,
|
||||
binding,
|
||||
provider,
|
||||
destroy() {
|
||||
binding.destroy();
|
||||
provider.destroy();
|
||||
doc.destroy();
|
||||
},
|
||||
};
|
||||
const session = await connectKicadDoc({ provider: opts.provider, room: opts.room });
|
||||
return attachKicadCollab(mod, win, session, { seedDoc: opts.seedDoc });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
web/standalone/src/wasm/global.d.ts
vendored
5
web/standalone/src/wasm/global.d.ts
vendored
|
|
@ -45,6 +45,11 @@ declare global {
|
|||
FS?: EmscriptenFS;
|
||||
wxElementRegistry?: WxElementRegistry;
|
||||
kicadWebOpenTool?: (toolName: string, fileName: string) => boolean;
|
||||
/** File System Access API (Chromium): writable local-folder sessions. */
|
||||
showDirectoryPicker?(options?: {
|
||||
mode?: "read" | "readwrite";
|
||||
id?: string;
|
||||
}): Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
|
||||
// The browsing-context window the tool runs in — now the top-level `window`
|
||||
|
|
|
|||
75
web/standalone/src/wasm/save-flow.ts
Normal file
75
web/standalone/src/wasm/save-flow.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue