project-sync: boot .kicad_pro synthesis + creation-time companion + live sibling restage
- defaultKicadPro() template + synthesizeProjectFile() writes a minimal <stem>.kicad_pro into MEMFS when a board/schematic opens without one, so KiCad runs a real project (self-heals legacy fileless projects). - NewFileDialog creates the .kicad_pro companion alongside a new board/schematic. - sibling-restage.ts: pcbnew sessions subscribe to sibling .kicad_sch collab rooms as invisible observers and re-stage MEMFS on update (restageFile extracted from syncProjectToMemfs), so a sync reads live schematic data, not the boot snapshot. - tests/kicad/project-sync.spec.ts (2 e2e) + sibling-restage.test.ts (5 unit). - Bump kicad pointer (SCH-from-PCB sync gate + hidden player + .kicad_pro save hook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdqhM7j71XQn96hcHkabDu
This commit is contained in:
parent
f4d3c3f146
commit
2d89f41060
8 changed files with 757 additions and 12 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
||||||
Subproject commit c73cc55489047e377afa8088e598d003308c0925
|
Subproject commit 261621e87174886d6fd4633e8bfb7f138908d56b
|
||||||
413
tests/kicad/project-sync.spec.ts
Normal file
413
tests/kicad/project-sync.spec.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
clickMenuBarItem,
|
||||||
|
clickMenuItemByText,
|
||||||
|
waitForEditorReady,
|
||||||
|
waitUntil,
|
||||||
|
stableShot,
|
||||||
|
} from '../e2e/utils/element-tracker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* project-sync 0001 — "Update PCB from Schematic" in the merged WASM editor.
|
||||||
|
*
|
||||||
|
* The merged kicad_editor bundle statically links BOTH kifaces, so the fork's
|
||||||
|
* KIWAY::FaceRegistered(FACE_SCH) predicate lets a "standalone" PCB editor run
|
||||||
|
* the sync: F8 spawns the eeschema kiface IN-PROCESS (kept hidden in the wasm
|
||||||
|
* build — see TestStandalone's __EMSCRIPTEN__ branch), loads the sibling
|
||||||
|
* .kicad_sch, and returns the netlist over MAIL_SCH_GET_NETLIST into
|
||||||
|
* DIALOG_UPDATE_PCB.
|
||||||
|
*
|
||||||
|
* Two tests:
|
||||||
|
* 1. first sync — the action is enabled, the dialog opens, the spawned
|
||||||
|
* schematic frame stays HIDDEN (does not hijack the PCB tab).
|
||||||
|
* 2. edit-then-resync — after the .kicad_sch changes underneath (the live
|
||||||
|
* sibling-restage's effect), a SECOND sync reflects the NEW schematic, not
|
||||||
|
* a cached parse. This is the regression guard for the hidden-player
|
||||||
|
* reload (repeat-F8 freshness).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STEM = 'syncspike';
|
||||||
|
const DIR = '/home/kicad/documents';
|
||||||
|
const SCH_UUID = '00000000-0000-4000-8000-00000000a001';
|
||||||
|
|
||||||
|
const PCB = `(kicad_pcb
|
||||||
|
\t(version 20241229)
|
||||||
|
\t(generator "pcbnew")
|
||||||
|
\t(generator_version "9.0")
|
||||||
|
\t(general (thickness 1.6))
|
||||||
|
\t(paper "A4")
|
||||||
|
\t(layers
|
||||||
|
\t\t(0 "F.Cu" signal)
|
||||||
|
\t\t(2 "B.Cu" signal)
|
||||||
|
\t\t(25 "Edge.Cuts" user)
|
||||||
|
\t)
|
||||||
|
\t(setup)
|
||||||
|
\t(net 0 "")
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Keep in sync with web/standalone/src/lib/new-file.ts defaultKicadPro().
|
||||||
|
const PRO = `${JSON.stringify({ meta: { filename: `${STEM}.kicad_pro`, version: 3 } }, null, 2)}\n`;
|
||||||
|
|
||||||
|
// Empty root sheet — enough to prove the sync round-trips (netlist has zero
|
||||||
|
// components; DIALOG_UPDATE_PCB still opens).
|
||||||
|
const EMPTY_SCH = `(kicad_sch
|
||||||
|
\t(version 20250114)
|
||||||
|
\t(generator "eeschema")
|
||||||
|
\t(generator_version "9.0")
|
||||||
|
\t(uuid "${SCH_UUID}")
|
||||||
|
\t(paper "A4")
|
||||||
|
\t(lib_symbols)
|
||||||
|
\t(sheet_instances
|
||||||
|
\t\t(path "/" (page "1"))
|
||||||
|
\t)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// A self-contained resistor symbol (lib def + placement) lifted verbatim from
|
||||||
|
// the ecc83 demo, with a swappable reference designator so two schematic
|
||||||
|
// versions carry DIFFERENT, searchable references in the netlist.
|
||||||
|
const R_LIBDEF = String.raw`(symbol "ecc83-pp:R"
|
||||||
|
(pin_numbers
|
||||||
|
(hide yes)
|
||||||
|
)
|
||||||
|
(pin_names
|
||||||
|
(offset 0)
|
||||||
|
)
|
||||||
|
(exclude_from_sim no)
|
||||||
|
(in_bom yes)
|
||||||
|
(on_board yes)
|
||||||
|
(property "Reference" "R"
|
||||||
|
(at 2.032 0 90)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.27 1.27)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Value" "R"
|
||||||
|
(at 0 0 90)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.27 1.27)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Footprint" ""
|
||||||
|
(at -1.778 0 90)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 0.762 0.762)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Datasheet" ""
|
||||||
|
(at 0 0 0)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 0.762 0.762)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Description" ""
|
||||||
|
(at 0 0 0)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.27 1.27)
|
||||||
|
)
|
||||||
|
(hide yes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "ki_fp_filters" "R_* Resistor_*"
|
||||||
|
(at 0 0 0)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.27 1.27)
|
||||||
|
)
|
||||||
|
(hide yes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(symbol "R_0_1"
|
||||||
|
(rectangle
|
||||||
|
(start -1.016 -2.54)
|
||||||
|
(end 1.016 2.54)
|
||||||
|
(stroke
|
||||||
|
(width 0.254)
|
||||||
|
(type default)
|
||||||
|
)
|
||||||
|
(fill
|
||||||
|
(type none)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(symbol "R_1_1"
|
||||||
|
(pin passive line
|
||||||
|
(at 0 3.81 270)
|
||||||
|
(length 1.27)
|
||||||
|
(name "~"
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.524 1.524)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(number "1"
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.524 1.524)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(pin passive line
|
||||||
|
(at 0 -3.81 90)
|
||||||
|
(length 1.27)
|
||||||
|
(name "~"
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.524 1.524)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(number "2"
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.524 1.524)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(embedded_fonts no)
|
||||||
|
)`;
|
||||||
|
const R_PLACEMENT = String.raw`(symbol
|
||||||
|
(lib_id "ecc83-pp:R")
|
||||||
|
(at 157.48 85.09 180)
|
||||||
|
(unit 1)
|
||||||
|
(exclude_from_sim no)
|
||||||
|
(in_bom yes)
|
||||||
|
(on_board yes)
|
||||||
|
(dnp no)
|
||||||
|
(uuid "00000000-0000-0000-0000-00004549f38a")
|
||||||
|
(property "Reference" "__REF__"
|
||||||
|
(at 154.94 85.09 0)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.27 1.27)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Value" "1.5K"
|
||||||
|
(at 157.48 85.09 90)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.27 1.27)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Footprint" "Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal"
|
||||||
|
(at 159.512 85.0392 90)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 0.254 0.254)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Datasheet" ""
|
||||||
|
(at 157.48 85.09 0)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.524 1.524)
|
||||||
|
)
|
||||||
|
(hide yes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(property "Description" ""
|
||||||
|
(at 157.48 85.09 0)
|
||||||
|
(effects
|
||||||
|
(font
|
||||||
|
(size 1.27 1.27)
|
||||||
|
)
|
||||||
|
(hide yes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(pin "1"
|
||||||
|
(uuid "490a35ba-1ba2-44c5-adce-e27a045257ab")
|
||||||
|
)
|
||||||
|
(pin "2"
|
||||||
|
(uuid "51355c82-d1cf-445b-b079-21c660dd8989")
|
||||||
|
)
|
||||||
|
(instances
|
||||||
|
(project "ecc83-pp"
|
||||||
|
(path "/28f865a0-4433-4a53-bbd7-b62f276848e4"
|
||||||
|
(reference "__REF__")
|
||||||
|
(unit 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`;
|
||||||
|
|
||||||
|
function resistorSch(ref: string): string {
|
||||||
|
return `(kicad_sch
|
||||||
|
\t(version 20250114)
|
||||||
|
\t(generator "eeschema")
|
||||||
|
\t(generator_version "9.0")
|
||||||
|
\t(uuid "${SCH_UUID}")
|
||||||
|
\t(paper "A4")
|
||||||
|
\t(lib_symbols
|
||||||
|
\t\t${R_LIBDEF}
|
||||||
|
\t)
|
||||||
|
\t${R_PLACEMENT.replace(/__REF__/g, ref)}
|
||||||
|
\t(sheet_instances
|
||||||
|
\t\t(path "/" (page "1"))
|
||||||
|
\t)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOT_TIMEOUT = 150000;
|
||||||
|
|
||||||
|
interface FS { mkdirTree(p: string): void; writeFile(p: string, d: string): void; }
|
||||||
|
interface Mod { kicadOpenFile(p: string): unknown; }
|
||||||
|
|
||||||
|
/** Stage board + schematic + minimal .kicad_pro into MEMFS, then open the board. */
|
||||||
|
async function stageAndOpen(page: import('@playwright/test').Page, sch: string): Promise<void> {
|
||||||
|
await page.goto('/kicad/pcbnew.html');
|
||||||
|
await waitForEditorReady(page);
|
||||||
|
await page.evaluate(
|
||||||
|
({ dir, stem, pcb, sch, pro }) => {
|
||||||
|
const w = window as unknown as { FS: FS; Module: Mod };
|
||||||
|
try { w.FS.mkdirTree(dir); } catch { /* exists */ }
|
||||||
|
w.FS.writeFile(`${dir}/${stem}.kicad_pcb`, pcb);
|
||||||
|
w.FS.writeFile(`${dir}/${stem}.kicad_sch`, sch);
|
||||||
|
w.FS.writeFile(`${dir}/${stem}.kicad_pro`, pro);
|
||||||
|
w.Module.kicadOpenFile(`${dir}/${stem}.kicad_pcb`);
|
||||||
|
},
|
||||||
|
{ dir: DIR, stem: STEM, pcb: PCB, sch, pro: PRO },
|
||||||
|
);
|
||||||
|
await waitUntil(
|
||||||
|
page,
|
||||||
|
(s: string) => document.title.includes(s),
|
||||||
|
`board "${STEM}" opened (window title)`,
|
||||||
|
{ timeout: BOOT_TIMEOUT, arg: STEM },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrite the staged schematic in MEMFS (simulates the live sibling-restage). */
|
||||||
|
async function rewriteSchematic(page: import('@playwright/test').Page, sch: string): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
({ dir, stem, sch }) => {
|
||||||
|
const w = window as unknown as { FS: FS };
|
||||||
|
w.FS.writeFile(`${dir}/${stem}.kicad_sch`, sch);
|
||||||
|
},
|
||||||
|
{ dir: DIR, stem: STEM, sch },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tools -> Update PCB from Schematic, and wait for DIALOG_UPDATE_PCB. */
|
||||||
|
async function openSyncDialog(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
const opened = await clickMenuBarItem(page, 'Tools');
|
||||||
|
expect(opened, 'Tools menu should open').toBe(true);
|
||||||
|
await clickMenuItemByText(page, 'Update PCB from Schematic');
|
||||||
|
await waitUntil(
|
||||||
|
page,
|
||||||
|
() => {
|
||||||
|
const r = window.wxElementRegistry;
|
||||||
|
if (!r || !r.findAll) return false;
|
||||||
|
const visible = r.findAll({ visible: true });
|
||||||
|
const dialogUp = visible.some((e) => e.typeName === 'wxDialog');
|
||||||
|
const marker = visible.some((e) => /Update PCB|Changes to Be Applied/i
|
||||||
|
.test(`${e.label || ''} ${e.name || ''}`));
|
||||||
|
return dialogUp && marker;
|
||||||
|
},
|
||||||
|
'DIALOG_UPDATE_PCB visible',
|
||||||
|
{ timeout: 120000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the currently-open modal dialog (Escape unwinds the wx modal loop). */
|
||||||
|
async function closeDialog(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitUntil(
|
||||||
|
page,
|
||||||
|
() => {
|
||||||
|
const r = window.wxElementRegistry;
|
||||||
|
return !!r && !!r.findAll && !r.findAll({ visible: true })
|
||||||
|
.some((e) => e.typeName === 'wxDialog');
|
||||||
|
},
|
||||||
|
'DIALOG_UPDATE_PCB closed',
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spawned schematic frame must not be visible / must not take over the tab. */
|
||||||
|
async function assertSchFrameHidden(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
const schShown = await page.evaluate(() => {
|
||||||
|
const r = window.wxElementRegistry;
|
||||||
|
if (!r || !r.findAll) return false;
|
||||||
|
return r.findAll({ visible: true })
|
||||||
|
.some((e) => e.typeName === 'wxFrame' && /Schematic/i.test(e.name || e.label || ''));
|
||||||
|
});
|
||||||
|
expect(schShown, 'schematic frame must stay hidden (no PCB-tab hijack)').toBe(false);
|
||||||
|
expect(await page.title(), 'PCB tab keeps its title').toMatch(/PCB Editor/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('project-sync: update PCB from schematic (merged bundle)', () => {
|
||||||
|
test('first sync opens the dialog with the schematic frame hidden', async ({ page }) => {
|
||||||
|
const consoleLines: string[] = [];
|
||||||
|
page.on('console', (m) => consoleLines.push(m.text()));
|
||||||
|
page.on('pageerror', (e) => consoleLines.push(`pageerror: ${e.message}`));
|
||||||
|
|
||||||
|
await stageAndOpen(page, EMPTY_SCH);
|
||||||
|
await stableShot(page, 'project-sync-00-board-open.png');
|
||||||
|
|
||||||
|
await openSyncDialog(page);
|
||||||
|
await stableShot(page, 'project-sync-01-update-dialog.png');
|
||||||
|
|
||||||
|
// The core of the hidden-player fix: the netlist came back and the
|
||||||
|
// dialog is up, but eeschema never hijacked the PCB tab.
|
||||||
|
await assertSchFrameHidden(page);
|
||||||
|
|
||||||
|
expect(consoleLines.some((s) => s.includes('Aborted(')),
|
||||||
|
'no wasm abort during the sync flow').toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a schematic edit between syncs is reflected on the second sync', async ({ page }) => {
|
||||||
|
const consoleLines: string[] = [];
|
||||||
|
page.on('console', (m) => consoleLines.push(m.text()));
|
||||||
|
page.on('pageerror', (e) => consoleLines.push(`pageerror: ${e.message}`));
|
||||||
|
|
||||||
|
// The netlist report (DIALOG_UPDATE_PCB's WX_HTML_REPORT_BOX) is painted to
|
||||||
|
// the GL canvas — its text is NOT in the DOM or the wx registry (verified),
|
||||||
|
// so the R777-vs-R888 content is asserted the way this repo asserts all
|
||||||
|
// editor-rendered content: the committed screenshot baseline. The
|
||||||
|
// PROGRAMMATIC guards here are the reload MECHANICS: that a second sync,
|
||||||
|
// with the .kicad_sch changed underneath, reaches DIALOG_UPDATE_PCB again
|
||||||
|
// (openSyncDialog throws on timeout — so a reload that hung on a "save
|
||||||
|
// changes?" prompt, or a cached frame that never re-read, would fail loudly)
|
||||||
|
// with the schematic frame still hidden and no wasm abort.
|
||||||
|
|
||||||
|
// First sync: schematic has R777. Reaching the dialog proves the freshly
|
||||||
|
// spawned hidden player loaded this schematic and returned its netlist.
|
||||||
|
await stageAndOpen(page, resistorSch('R777'));
|
||||||
|
await openSyncDialog(page);
|
||||||
|
await assertSchFrameHidden(page);
|
||||||
|
await stableShot(page, 'project-sync-02-resync-first-R777.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
|
||||||
|
// The schematic changes underneath — exactly what the live sibling-restage
|
||||||
|
// writes into MEMFS when a collaborator edits it: R777 -> R888.
|
||||||
|
await rewriteSchematic(page, resistorSch('R888'));
|
||||||
|
|
||||||
|
// Second sync: the hidden player re-reads MEMFS (IsShownOnScreen() stays
|
||||||
|
// false, so TestStandalone re-runs OpenProjectFiles), so the netlist now
|
||||||
|
// reflects R888. Reaching the dialog again == the repeat-sync path did not
|
||||||
|
// hang; the screenshot baseline is the R888-not-R777 content guard.
|
||||||
|
await openSyncDialog(page);
|
||||||
|
await assertSchFrameHidden(page);
|
||||||
|
await stableShot(page, 'project-sync-03-resync-second-R888.png');
|
||||||
|
|
||||||
|
expect(consoleLines.some((s) => s.includes('Aborted(')),
|
||||||
|
'no wasm abort during the resync flow').toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,7 @@ import { localProjectStore } from "@/lib/project-source";
|
||||||
import { useLocalProjects } from "@/lib/api";
|
import { useLocalProjects } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
defaultFileName,
|
defaultFileName,
|
||||||
|
defaultKicadPro,
|
||||||
newFileTemplate,
|
newFileTemplate,
|
||||||
withExtension,
|
withExtension,
|
||||||
} from "@/lib/new-file";
|
} from "@/lib/new-file";
|
||||||
|
|
@ -86,16 +87,31 @@ export function NewFileDialog({
|
||||||
const bytes = new TextEncoder().encode(
|
const bytes = new TextEncoder().encode(
|
||||||
newFileTemplate(tool, crypto.randomUUID()),
|
newFileTemplate(tool, crypto.randomUUID()),
|
||||||
);
|
);
|
||||||
|
const files = [{ path: finalName, bytes }];
|
||||||
|
// A board/schematic is a project root: create its `<stem>.kicad_pro`
|
||||||
|
// sibling with it (project-sync 0001) so KiCad opens a real project and
|
||||||
|
// project-scoped settings (netclasses, ERC/DRC exclusions) can persist.
|
||||||
|
const proName = finalName.replace(/\.kicad_(pcb|sch)$/, ".kicad_pro");
|
||||||
|
if (
|
||||||
|
proName !== finalName &&
|
||||||
|
!(project?.existingPaths ?? []).includes(proName)
|
||||||
|
) {
|
||||||
|
files.push({
|
||||||
|
path: proName,
|
||||||
|
bytes: new TextEncoder().encode(defaultKicadPro(proName)),
|
||||||
|
});
|
||||||
|
}
|
||||||
let slug: string;
|
let slug: string;
|
||||||
if (homeMode && target === NEW) {
|
if (homeMode && target === NEW) {
|
||||||
if (!store) throw new Error("local project store unavailable");
|
if (!store) throw new Error("local project store unavailable");
|
||||||
const created = await store.createProject(projectName.trim() || "Untitled", [
|
const created = await store.createProject(
|
||||||
{ path: finalName, bytes },
|
projectName.trim() || "Untitled",
|
||||||
]);
|
files,
|
||||||
|
);
|
||||||
slug = created.slug;
|
slug = created.slug;
|
||||||
} else {
|
} else {
|
||||||
slug = project ? project.slug : target;
|
slug = project ? project.slug : target;
|
||||||
await uploadFileBytes(slug, finalName, bytes);
|
for (const f of files) await uploadFileBytes(slug, f.path, f.bytes);
|
||||||
}
|
}
|
||||||
// Full navigation so Emscripten boots into a clean page opening the file.
|
// Full navigation so Emscripten boots into a clean page opening the file.
|
||||||
// A home-created project is browser-local (@local scope); an in-project new
|
// A home-created project is browser-local (@local scope); an in-project new
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ import {
|
||||||
type FollowTarget,
|
type FollowTarget,
|
||||||
} from "@/wasm/collab/follow-user";
|
} from "@/wasm/collab/follow-user";
|
||||||
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
||||||
|
import {
|
||||||
|
startSiblingRestage,
|
||||||
|
type SiblingRestageHandle,
|
||||||
|
} from "@/wasm/collab/sibling-restage";
|
||||||
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
|
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
|
||||||
import {
|
import {
|
||||||
createComments,
|
createComments,
|
||||||
|
|
@ -834,6 +838,7 @@ export function WasmTool({
|
||||||
// Project-wide presence room (0006): joined once per session, survives
|
// Project-wide presence room (0006): joined once per session, survives
|
||||||
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
|
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
|
||||||
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
|
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
|
||||||
|
const siblingRestageRef = React.useRef<SiblingRestageHandle | null>(null);
|
||||||
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
||||||
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
||||||
// (miss 08B); eeschema routes per sheet through the manager instead.
|
// (miss 08B); eeschema routes per sheet through the manager instead.
|
||||||
|
|
@ -1426,6 +1431,21 @@ export function WasmTool({
|
||||||
collabDocRef.current = collabHandle?.doc ?? null;
|
collabDocRef.current = collabHandle?.doc ?? null;
|
||||||
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
||||||
startComments(collabHandle?.doc);
|
startComments(collabHandle?.doc);
|
||||||
|
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
|
||||||
|
// files a PCB session syncs from fresh in MEMFS, instead of the
|
||||||
|
// one-shot boot snapshot. Same opt-out as the room collab; read-only
|
||||||
|
// viewers skip it (they can't run the sync anyway).
|
||||||
|
if (tool === "pcbnew" && collabHandle && !readOnly) {
|
||||||
|
siblingRestageRef.current = await startSiblingRestage({
|
||||||
|
win,
|
||||||
|
slug,
|
||||||
|
scopeId,
|
||||||
|
projectId,
|
||||||
|
files,
|
||||||
|
provider: yjsProviderConfig(),
|
||||||
|
log: append,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
|
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: collabHandle.doc,
|
doc: collabHandle.doc,
|
||||||
|
|
@ -1462,6 +1482,8 @@ export function WasmTool({
|
||||||
presenceRef.current = null;
|
presenceRef.current = null;
|
||||||
crossAppRef.current?.destroy();
|
crossAppRef.current?.destroy();
|
||||||
crossAppRef.current = null;
|
crossAppRef.current = null;
|
||||||
|
siblingRestageRef.current?.destroy();
|
||||||
|
siblingRestageRef.current = null;
|
||||||
driftRef.current?.stop();
|
driftRef.current?.stop();
|
||||||
driftRef.current = null;
|
driftRef.current = null;
|
||||||
// Tears down every warm room's provider/doc (the only place providers are
|
// Tears down every warm room's provider/doc (the only place providers are
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,22 @@ export function withExtension(tool: Tool, name: string): string {
|
||||||
return ext && !name.toLowerCase().endsWith(ext) ? `${name}${ext}` : name;
|
return ext && !name.toLowerCase().endsWith(ext) ? `${name}${ext}` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal `.kicad_pro` a board/schematic can live next to. KiCad's PROJECT_FILE
|
||||||
|
* (JSON_SETTINGS) fills every missing key with schema defaults, and the file's
|
||||||
|
* mere existence is what unlocks the project-settings save paths (netclasses,
|
||||||
|
* ERC/DRC exclusions, text variables — pcbnew/files.cpp gates them on
|
||||||
|
* FileExists()); the first in-editor save then writes the full document.
|
||||||
|
* `version` must track projectFileSchemaVersion (kicad common/project/project_file.cpp).
|
||||||
|
*/
|
||||||
|
export function defaultKicadPro(fileName: string): string {
|
||||||
|
return `${JSON.stringify(
|
||||||
|
{ meta: { filename: fileName, version: 3 } },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
export function newFileTemplate(tool: Tool, uuid: string): string {
|
export function newFileTemplate(tool: Tool, uuid: string): string {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "eeschema":
|
case "eeschema":
|
||||||
|
|
|
||||||
130
web/standalone/src/wasm/collab/sibling-restage.test.ts
Normal file
130
web/standalone/src/wasm/collab/sibling-restage.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Exercise ONLY the subscribe/debounce/restage orchestration: the room connect,
|
||||||
|
// the ydoc materialization, and the MEMFS write are all collaborators.
|
||||||
|
const { connectKicadDoc, restageFile, ydocHasState } = vi.hoisted(() => ({
|
||||||
|
connectKicadDoc: vi.fn(),
|
||||||
|
restageFile: vi.fn(),
|
||||||
|
ydocHasState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./index", () => ({ connectKicadDoc }));
|
||||||
|
vi.mock("../kicad-runner", () => ({ restageFile }));
|
||||||
|
vi.mock("@pcbjam/shared", () => ({
|
||||||
|
collabRoomId: (s: string, p: string, d: string) => `${s}:${p}:${d}`,
|
||||||
|
ydocHasState,
|
||||||
|
yToDoc: (doc: unknown) => doc,
|
||||||
|
docToFile: () => "(kicad_sch materialized)",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { startSiblingRestage } from "./sibling-restage";
|
||||||
|
|
||||||
|
interface FakeSession {
|
||||||
|
room: string;
|
||||||
|
doc: {
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
destroy: ReturnType<typeof vi.fn>;
|
||||||
|
emitRemote: () => void;
|
||||||
|
};
|
||||||
|
provider: {
|
||||||
|
destroy: ReturnType<typeof vi.fn>;
|
||||||
|
awareness: { setLocalState: ReturnType<typeof vi.fn> };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessions: FakeSession[];
|
||||||
|
|
||||||
|
function makeSession(room: string): FakeSession {
|
||||||
|
const handlers = new Set<() => void>();
|
||||||
|
return {
|
||||||
|
room,
|
||||||
|
doc: {
|
||||||
|
on: vi.fn((ev: string, cb: () => void) => {
|
||||||
|
if (ev === "update") handlers.add(cb);
|
||||||
|
}),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
emitRemote: () => handlers.forEach((h) => h()),
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
destroy: vi.fn(),
|
||||||
|
awareness: { setLocalState: vi.fn() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(files: string[]) {
|
||||||
|
return startSiblingRestage({
|
||||||
|
win: {} as never,
|
||||||
|
slug: "proj",
|
||||||
|
scopeId: "S",
|
||||||
|
projectId: "P",
|
||||||
|
files: files.map((path) => ({ path })),
|
||||||
|
provider: { kind: "none" } as never,
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
sessions = [];
|
||||||
|
connectKicadDoc.mockReset().mockImplementation(({ room }: { room: string }) => {
|
||||||
|
const s = makeSession(room);
|
||||||
|
sessions.push(s);
|
||||||
|
return Promise.resolve(s);
|
||||||
|
});
|
||||||
|
restageFile.mockReset();
|
||||||
|
ydocHasState.mockReset().mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startSiblingRestage", () => {
|
||||||
|
it("subscribes only the .kicad_sch siblings, as an invisible observer", async () => {
|
||||||
|
await start(["main.kicad_pcb", "main.kicad_sch", "sub.kicad_sch", "a.kicad_wks"]);
|
||||||
|
expect(sessions.map((s) => s.room)).toEqual([
|
||||||
|
"S:P:main.kicad_sch",
|
||||||
|
"S:P:sub.kicad_sch",
|
||||||
|
]);
|
||||||
|
for (const s of sessions) {
|
||||||
|
expect(s.provider.awareness.setLocalState).toHaveBeenCalledWith(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restages once on connect when the room already holds state", async () => {
|
||||||
|
await start(["main.kicad_sch"]);
|
||||||
|
expect(restageFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(restageFile.mock.calls[0]![2]).toBe("main.kicad_sch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the boot snapshot alone when the room is empty", async () => {
|
||||||
|
ydocHasState.mockReturnValue(false);
|
||||||
|
await start(["main.kicad_sch"]);
|
||||||
|
expect(restageFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces remote updates into one restage", async () => {
|
||||||
|
await start(["main.kicad_sch"]);
|
||||||
|
restageFile.mockClear();
|
||||||
|
sessions[0]!.doc.emitRemote();
|
||||||
|
sessions[0]!.doc.emitRemote();
|
||||||
|
sessions[0]!.doc.emitRemote();
|
||||||
|
expect(restageFile).not.toHaveBeenCalled();
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(restageFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroy tears down providers and cancels pending restages", async () => {
|
||||||
|
const handle = await start(["main.kicad_sch", "sub.kicad_sch"]);
|
||||||
|
restageFile.mockClear();
|
||||||
|
sessions[0]!.doc.emitRemote();
|
||||||
|
handle.destroy();
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(restageFile).not.toHaveBeenCalled();
|
||||||
|
for (const s of sessions) {
|
||||||
|
expect(s.provider.destroy).toHaveBeenCalled();
|
||||||
|
expect(s.doc.destroy).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
111
web/standalone/src/wasm/collab/sibling-restage.ts
Normal file
111
web/standalone/src/wasm/collab/sibling-restage.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { collabRoomId, docToFile, ydocHasState, yToDoc } from "@pcbjam/shared";
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
import { restageFile } from "../kicad-runner";
|
||||||
|
import { connectKicadDoc, type KicadDocSession } from "./index";
|
||||||
|
import type { ProviderConfig } from "./provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live sibling-document mirror for pcbnew sessions (project-sync 0001 bug 3).
|
||||||
|
*
|
||||||
|
* Boot stages every project file into MEMFS exactly once (kicad-runner
|
||||||
|
* syncProjectToMemfs), so the `.kicad_sch` a PCB session would sync from is a
|
||||||
|
* page-load snapshot while collaborators keep editing it in its own room. This
|
||||||
|
* subscribes — as an invisible, data-only observer — to every sibling sheet's
|
||||||
|
* collab room and re-materializes the file into MEMFS on updates, debounced
|
||||||
|
* like the lib refresh (synced-source scheduleEditorReload). It also restages
|
||||||
|
* once right after connect: the room is the source of truth since ysync, so it
|
||||||
|
* can already be ahead of the API snapshot boot fetched (unsaved collab edits
|
||||||
|
* live only in the room).
|
||||||
|
*
|
||||||
|
* MEMFS-only: nothing is uploaded and no editor poke is needed — pcbnew reads
|
||||||
|
* the schematic from MEMFS when the sync runs. A sheet created by a peer
|
||||||
|
* mid-session is not picked up (its path isn't in the boot file list); v1
|
||||||
|
* accepts that gap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RESTAGE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
export interface SiblingRestageHandle {
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSiblingRestage(opts: {
|
||||||
|
win: ToolWindow;
|
||||||
|
slug: string;
|
||||||
|
scopeId: string;
|
||||||
|
projectId: string;
|
||||||
|
files: { path: string }[];
|
||||||
|
provider: ProviderConfig;
|
||||||
|
log: (m: string) => void;
|
||||||
|
}): Promise<SiblingRestageHandle> {
|
||||||
|
const { win, slug, log } = opts;
|
||||||
|
const sheetPaths = opts.files
|
||||||
|
.map((f) => f.path)
|
||||||
|
.filter((p) => p.endsWith(".kicad_sch"));
|
||||||
|
|
||||||
|
const sessions: KicadDocSession[] = [];
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
const restageFromDoc = (sheetPath: string, doc: Y.Doc) => {
|
||||||
|
try {
|
||||||
|
// An empty room means no one ever seeded this sheet — the boot-staged
|
||||||
|
// API snapshot is the freshest copy there is; leave it alone.
|
||||||
|
if (!ydocHasState(doc)) return;
|
||||||
|
const text = docToFile(yToDoc(doc));
|
||||||
|
restageFile(win, slug, sheetPath, new TextEncoder().encode(text), log);
|
||||||
|
} catch (err) {
|
||||||
|
log(`[sibling] restage failed for ${sheetPath}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = (sheetPath: string, doc: Y.Doc) => {
|
||||||
|
const prev = timers.get(sheetPath);
|
||||||
|
if (prev) clearTimeout(prev);
|
||||||
|
timers.set(
|
||||||
|
sheetPath,
|
||||||
|
setTimeout(() => {
|
||||||
|
timers.delete(sheetPath);
|
||||||
|
if (!destroyed) restageFromDoc(sheetPath, doc);
|
||||||
|
}, RESTAGE_DEBOUNCE_MS),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
sheetPaths.map(async (sheetPath) => {
|
||||||
|
try {
|
||||||
|
const session = await connectKicadDoc({
|
||||||
|
provider: opts.provider,
|
||||||
|
room: collabRoomId(opts.scopeId, opts.projectId, sheetPath),
|
||||||
|
});
|
||||||
|
if (destroyed) {
|
||||||
|
session.provider.destroy();
|
||||||
|
session.doc.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Data-only observer: never appear in the sheet's presence roster
|
||||||
|
// (mirrors the sheet-manager's read-only invisible-observer handling).
|
||||||
|
session.provider.awareness?.setLocalState(null);
|
||||||
|
sessions.push(session);
|
||||||
|
session.doc.on("update", () => schedule(sheetPath, session.doc));
|
||||||
|
log(`[sibling] watching ${sheetPath}`);
|
||||||
|
restageFromDoc(sheetPath, session.doc);
|
||||||
|
} catch (err) {
|
||||||
|
log(`[sibling] room connect failed for ${sheetPath}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
for (const t of timers.values()) clearTimeout(t);
|
||||||
|
timers.clear();
|
||||||
|
for (const s of sessions) {
|
||||||
|
s.provider.destroy();
|
||||||
|
s.doc.destroy();
|
||||||
|
}
|
||||||
|
sessions.length = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Tool } from "@pcbjam/shared";
|
import type { Tool } from "@pcbjam/shared";
|
||||||
import { FILELESS_TOOLS } from "@pcbjam/shared";
|
import { FILELESS_TOOLS } from "@pcbjam/shared";
|
||||||
|
import { defaultKicadPro } from "../lib/new-file";
|
||||||
import { memfsFilePath, memfsProjectDir } from "./constants";
|
import { memfsFilePath, memfsProjectDir } from "./constants";
|
||||||
import { prescanBoardModels } from "./libs/models-bridge";
|
import { prescanBoardModels } from "./libs/models-bridge";
|
||||||
import { openFileInTool } from "./open-flow";
|
import { openFileInTool } from "./open-flow";
|
||||||
|
|
@ -45,17 +46,32 @@ function getFS(win: ToolWindow): EmscriptenFS {
|
||||||
return fs as EmscriptenFS;
|
return fs as EmscriptenFS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one project file into the tool's MEMFS. Shared by the boot-time
|
||||||
|
* whole-tree staging below and the live sibling re-stage (collab/sibling-restage):
|
||||||
|
* a peer's schematic edits must land in MEMFS so "Update PCB from Schematic"
|
||||||
|
* reads current data, not the page-load snapshot.
|
||||||
|
*/
|
||||||
|
export function restageFile(
|
||||||
|
win: ToolWindow,
|
||||||
|
slug: string,
|
||||||
|
relPath: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
log: (msg: string) => void,
|
||||||
|
): void {
|
||||||
|
const fs = getFS(win);
|
||||||
|
const dest = memfsFilePath(slug, relPath);
|
||||||
|
fs.mkdirTree(dest.slice(0, dest.lastIndexOf("/")));
|
||||||
|
fs.writeFile(dest, bytes);
|
||||||
|
log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Mirror the whole project tree into the tool's MEMFS (sync-whole-tree). */
|
/** Mirror the whole project tree into the tool's MEMFS (sync-whole-tree). */
|
||||||
async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<void> {
|
async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<void> {
|
||||||
const fs = getFS(win);
|
getFS(win).mkdirTree(memfsProjectDir(opts.slug));
|
||||||
fs.mkdirTree(memfsProjectDir(opts.slug));
|
|
||||||
for (const file of opts.files) {
|
for (const file of opts.files) {
|
||||||
const dest = memfsFilePath(opts.slug, file.path);
|
|
||||||
const dir = dest.slice(0, dest.lastIndexOf("/"));
|
|
||||||
fs.mkdirTree(dir);
|
|
||||||
const bytes = await opts.fetchBytes(file.path);
|
const bytes = await opts.fetchBytes(file.path);
|
||||||
fs.writeFile(dest, bytes);
|
restageFile(win, opts.slug, file.path, bytes, opts.log);
|
||||||
opts.log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
|
|
||||||
// 3D models: prefetch every model this board references (R2 → IDB → MEMFS)
|
// 3D models: prefetch every model this board references (R2 → IDB → MEMFS)
|
||||||
// so the 3D viewer's first open resolves locally. Fire-and-forget — project
|
// so the 3D viewer's first open resolves locally. Fire-and-forget — project
|
||||||
// open never waits on it; a ref that misses falls back to the C++ per-model
|
// open never waits on it; a ref that misses falls back to the C++ per-model
|
||||||
|
|
@ -69,6 +85,26 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KiCad expects a `<stem>.kicad_pro` next to a board/schematic; without one it
|
||||||
|
* runs on an in-memory defaults project, so nothing project-scoped (netclasses,
|
||||||
|
* ERC/DRC exclusions, text variables) persists — and the settings save paths
|
||||||
|
* are gated on the file existing at all. pcbjam projects historically carry no
|
||||||
|
* project file (docs/features/project-sync/0001), so seed a minimal one into
|
||||||
|
* MEMFS for the target document when the project doesn't provide its own.
|
||||||
|
* MEMFS-only: it is not added to the project file list or uploaded.
|
||||||
|
*/
|
||||||
|
function synthesizeProjectFile(win: ToolWindow, opts: DriveOptions): void {
|
||||||
|
const match = (opts.targetPath ?? "").match(/^(.*)\.kicad_(pcb|sch)$/);
|
||||||
|
if (!match) return;
|
||||||
|
const proPath = `${match[1]}.kicad_pro`;
|
||||||
|
if (opts.files.some((f) => f.path === proPath)) return;
|
||||||
|
const fileName = proPath.slice(proPath.lastIndexOf("/") + 1);
|
||||||
|
const bytes = new TextEncoder().encode(defaultKicadPro(fileName));
|
||||||
|
getFS(win).writeFile(memfsFilePath(opts.slug, proPath), bytes);
|
||||||
|
opts.log(`[memfs] synthesized ${proPath} (project has no project file)`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drive a project into an already-booting tool runtime (booted into `win` by
|
* Drive a project into an already-booting tool runtime (booted into `win` by
|
||||||
* bootKicadTool — the top-level window). Waits for the Emscripten FS, syncs the
|
* bootKicadTool — the top-level window). Waits for the Emscripten FS, syncs the
|
||||||
|
|
@ -89,6 +125,7 @@ export async function driveProjectIntoTool(
|
||||||
|
|
||||||
onStatus("Loading project files…");
|
onStatus("Loading project files…");
|
||||||
await syncProjectToMemfs(win, opts);
|
await syncProjectToMemfs(win, opts);
|
||||||
|
synthesizeProjectFile(win, opts);
|
||||||
|
|
||||||
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
|
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
|
||||||
onStatus("Opening file…");
|
onStatus("Opening file…");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue