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
|
// 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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 (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. */
|
/** 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)}`);
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
|
||||||
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";
|
} 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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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;
|
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`
|
||||||
|
|
|
||||||
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",
|
"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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue