tool-switch: create the missing counterpart file instead of silently no-opping
Tools → "Switch to PCB Editor" in a project with no .kicad_pcb (e.g. created from a lone schematic) did nothing: the nav hook found no file for the target tool and returned false. Now a session that can persist (ToolPage passes the new createFile prop) writes the templated counterpart via createProjectFileIfMissing — no download fallback, and it re-checks existence so a collaborator's file is never clobbered — then navigates to it, matching native KiCad (pcbnew opens a new board at the derived path). Sessions that can't persist (read-only viewers, scratch/local-folder) keep the logged no-op. Also latch the quit dispatcher off before every deliberate tool-switch navigation (markDeliberateNavigation): the wx port's UnloadCallback runs on BEFOREUNLOAD and closes the top frame the moment the navigation starts, so the quit hook history.back()'d over the in-flight navigation — the pagehide latch is too late (it only fires at commit time). e2e: new tests/web/tool-switch-missing-file.spec.ts reproduces the flow via a browser-local (IDB) project created from the home page; playwright-web config gains VITE_LOCAL_PROJECTS=idb and derives STANDALONE_PORT/CORS_ORIGIN from WEB_APP_URL (runs the suite past a squatted :3048); both vars declared in web/turbo.json globalEnv (turbo strict-env strips undeclared vars). Includes the previously-uncommitted tool-switch spec repairs (URL grammar + z-30 boot-overlay wait). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f8ab901772
commit
2652d6fa81
7 changed files with 215 additions and 10 deletions
|
|
@ -24,6 +24,7 @@ import {
|
|||
yjsProviderConfig,
|
||||
type DocSource,
|
||||
} from "@/lib/config";
|
||||
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import { resolveWasmBase } from "@/wasm/wasm-assets";
|
||||
import {
|
||||
|
|
@ -231,9 +232,16 @@ function installToolNavigationHook(
|
|||
slug: string;
|
||||
files: ToolFile[];
|
||||
targetPath?: string;
|
||||
/** Persist a new file into the project (see the WasmTool prop). Absent ⇒
|
||||
* this session can't create one, and a missing target stays a no-op. */
|
||||
createFile?: (relPath: string, bytes: Uint8Array) => Promise<void>;
|
||||
log: (m: string) => void;
|
||||
},
|
||||
): () => void {
|
||||
// One create at a time: a double-fired menu item must not upload twice.
|
||||
// Cleared only on failure — success navigates the page away.
|
||||
let pendingCreate: string | null = null;
|
||||
|
||||
const hook = (rawToolName: string, rawFileName: string): boolean => {
|
||||
const nextTool = normalizeToolName(rawToolName);
|
||||
|
||||
|
|
@ -248,8 +256,48 @@ function installToolNavigationHook(
|
|||
: chooseToolFile(opts.files, nextTool, requestedPath, opts.targetPath);
|
||||
|
||||
if (!FILELESS_TOOLS.has(nextTool) && !nextPath) {
|
||||
opts.log(`[nav] no project file found for ${nextTool}: ${rawFileName}`);
|
||||
return false;
|
||||
// Native KiCad's "Switch to PCB Editor" with no board opens pcbnew on a
|
||||
// NEW empty board at the derived path — mirror it by creating the
|
||||
// templated counterpart in the project (the shape NewFileDialog writes)
|
||||
// and navigating to it. Only sessions that can persist pass `createFile`
|
||||
// (ToolPage); viewers and scratch/local-folder sessions keep the quiet
|
||||
// no-op. C++ calls this hook synchronously (EM_ASM_INT) and ignores the
|
||||
// result beyond a log line, so the create+navigate runs async and we
|
||||
// answer true optimistically once it's kicked off.
|
||||
const createFile = opts.createFile;
|
||||
if (!createFile) {
|
||||
opts.log(`[nav] no project file found for ${nextTool}: ${rawFileName}`);
|
||||
return false;
|
||||
}
|
||||
if (pendingCreate) {
|
||||
opts.log(`[nav] create already pending: ${pendingCreate}`);
|
||||
return true;
|
||||
}
|
||||
const relPath =
|
||||
requestedPath ??
|
||||
(opts.targetPath
|
||||
? withExtension(nextTool, fileStem(opts.targetPath))
|
||||
: defaultFileName(nextTool));
|
||||
const url =
|
||||
projectPath(currentScope(), opts.slug, relPath) + win.location.search;
|
||||
pendingCreate = relPath;
|
||||
void (async () => {
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(
|
||||
newFileTemplate(nextTool, crypto.randomUUID()),
|
||||
);
|
||||
await createFile(relPath, bytes);
|
||||
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
|
||||
markDeliberateNavigation();
|
||||
win.location.assign(url);
|
||||
} catch (e) {
|
||||
pendingCreate = null;
|
||||
opts.log(
|
||||
`[nav] create failed for ${relPath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scope/kind/name grammar: a fileless tool boots at `…/-/:tool`; a file route
|
||||
|
|
@ -261,6 +309,7 @@ function installToolNavigationHook(
|
|||
: projectPath(scope, opts.slug, nextPath)) + win.location.search;
|
||||
|
||||
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
|
||||
markDeliberateNavigation();
|
||||
win.location.assign(url);
|
||||
return true;
|
||||
};
|
||||
|
|
@ -286,6 +335,20 @@ function installToolNavigationHook(
|
|||
let activeQuitHook: (() => void) | undefined;
|
||||
let quitHandled = false;
|
||||
|
||||
/**
|
||||
* Latch the quit dispatcher off ahead of a deliberate in-app navigation (the
|
||||
* tool-switch hook's location.assign). The wx port's UnloadCallback runs on
|
||||
* BEFOREUNLOAD — i.e. the instant the navigation starts, while this document
|
||||
* keeps running until the next one commits — and closes the top frame, which
|
||||
* fires wxAppTopWindowClosed. Without the latch the quit hook then
|
||||
* history.back()s over the in-flight navigation (the pagehide latch below is
|
||||
* too late: pagehide only fires at commit time). One-shot per document, same
|
||||
* as the pagehide latch — this page is on its way out.
|
||||
*/
|
||||
function markDeliberateNavigation() {
|
||||
quitHandled = true;
|
||||
}
|
||||
|
||||
const quitDispatcher = () => {
|
||||
if (quitHandled) return;
|
||||
quitHandled = true;
|
||||
|
|
@ -686,6 +749,7 @@ export function WasmTool({
|
|||
targetPath,
|
||||
fetchBytes,
|
||||
saveBytes,
|
||||
createFile,
|
||||
docSource,
|
||||
assetBaseUrl,
|
||||
libsSource,
|
||||
|
|
@ -718,6 +782,15 @@ export function WasmTool({
|
|||
* MEMFS-only (e.g. Y.Doc-backed sessions).
|
||||
*/
|
||||
saveBytes?: SaveBytes;
|
||||
/**
|
||||
* Create a new file in the project (tool-switch auto-create: eeschema's
|
||||
* "Switch to PCB Editor" when no board exists yet). Persisted BEFORE the
|
||||
* hook navigates, so the next ToolPage load finds it. Omit for sessions
|
||||
* that can't persist a new project file (read-only viewers, scratch and
|
||||
* local-folder sessions) — a missing switch target then stays a logged
|
||||
* no-op.
|
||||
*/
|
||||
createFile?: (relPath: string, bytes: Uint8Array) => Promise<void>;
|
||||
/**
|
||||
* Where this project's DOCUMENT lives (see lib/config docSourceConfig):
|
||||
* "ydoc" materializes the target file from its collab room when the room has
|
||||
|
|
@ -953,6 +1026,7 @@ export function WasmTool({
|
|||
slug,
|
||||
files,
|
||||
targetPath,
|
||||
createFile,
|
||||
log: append,
|
||||
});
|
||||
|
||||
|
|
@ -969,7 +1043,7 @@ export function WasmTool({
|
|||
removeNavigationHook();
|
||||
removeQuitHook();
|
||||
};
|
||||
}, [slug, files, targetPath, append]);
|
||||
}, [slug, files, targetPath, createFile, append]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Guard re-entry: the WASM runtime is process-global and must boot exactly
|
||||
|
|
|
|||
Loading…
Reference in a new issue