feat(editor): File→Quit navigates back to the project page
Every KiCad editor's File→Quit used to destroy the wx top frame and notify nobody, leaving the SPA stranded on the editor URL with a dead canvas. It now behaves like the browser Back button: return to wherever the user entered the editor from (project page, or a previous editor). - WasmTool installs window.wxAppTopWindowClosed (the wx wasm port fires it from the main frame's destructor — see the wxwidgets pointer bump). On a real quit it history.back()s when there is same-origin in-app history, else location.assign()s the project page (or "/" for lib editors). - The navigation is deferred one task (setTimeout 0): it fires inside the C++ destructor via EM_ASM under Asyncify, and a cross-document location.assign() started there is aborted by the continuing teardown; only same-document history.back() survives synchronously. - Latches off on pagehide (the port also closes the frame on page unload via UnloadCallback) and reloads on a bfcache pageshow so Forward never restores a dead frame. - e2e: quit-to-project.spec.ts covers both the history-back and the deep-link fallback paths. No kicad changes — the whole hook lives in the wxWidgets wasm port and the web app, keeping the kicad fork clean for upstream syncs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d43f8ca719
commit
3a2255c817
4 changed files with 196 additions and 3 deletions
85
tests/web/quit-to-project.spec.ts
Normal file
85
tests/web/quit-to-project.spec.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import { clickMenuBarItem, clickMenuItemByText } from '../e2e/utils/element-tracker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File → Quit e2e: quitting an editor must leave it, like the browser Back
|
||||||
|
* button — return to wherever the user navigated in from (the project page),
|
||||||
|
* falling back to the project page on a deep link with no same-origin history.
|
||||||
|
*
|
||||||
|
* The wx wasm port notifies the page when the app's top window is destroyed
|
||||||
|
* (window.wxAppTopWindowClosed, wxwidgets src/wasm/toplevel.cpp); WasmTool maps
|
||||||
|
* that to history.back() / location.assign(projectPath). A quit vetoed by the
|
||||||
|
* unsaved-changes prompt never destroys the frame, so it never navigates.
|
||||||
|
*
|
||||||
|
* URL-only assertions — no screenshots.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Scope segment for the demo project (the reference backend serves it for any). */
|
||||||
|
const SCOPE = 'default';
|
||||||
|
|
||||||
|
const EDITOR_URL_RE = /\/default\/projects\/demo\/demo\.kicad_sch/;
|
||||||
|
// Project overview: path ends at /projects/demo (optionally a query string).
|
||||||
|
const PROJECT_URL_RE = /\/default\/projects\/demo\/?(\?.*)?$/;
|
||||||
|
|
||||||
|
async function waitForToolReady(page: Page, titleRe: RegExp): Promise<void> {
|
||||||
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => page.title(), {
|
||||||
|
message: `editor never reached title ${titleRe}`,
|
||||||
|
timeout: 120000,
|
||||||
|
intervals: [1000],
|
||||||
|
})
|
||||||
|
.toMatch(titleRe);
|
||||||
|
// The menu helpers drive the rendered-element registry.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!!(window as unknown as { wxElementRegistry?: { findAllRendered?: unknown } })
|
||||||
|
.wxElementRegistry,
|
||||||
|
null,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
// The boot and eager-library overlays (WasmTool, `absolute inset-0 z-30`)
|
||||||
|
// cover the whole editor including the menubar — synthetic menu clicks land
|
||||||
|
// on them until they clear (eeschema hydrates the full symbol set post-boot).
|
||||||
|
await expect(page.locator("div.absolute.inset-0.z-30")).toHaveCount(0, {
|
||||||
|
timeout: 180000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quitViaFileMenu(page: Page): Promise<void> {
|
||||||
|
expect(await clickMenuBarItem(page, 'File'), 'File menubar item clickable').toBe(true);
|
||||||
|
await clickMenuItemByText(page, 'Quit');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('web app — File → Quit leaves the editor', () => {
|
||||||
|
test('quit after entering from the project page navigates back to it', async ({ page }) => {
|
||||||
|
test.setTimeout(300000); // full wasm boot
|
||||||
|
|
||||||
|
// Enter the editor the way a user does: the project page's "Open in …"
|
||||||
|
// link is a hard navigation, creating the history entry Quit pops.
|
||||||
|
await page.goto(`/${SCOPE}/projects/demo`);
|
||||||
|
await page
|
||||||
|
.getByRole('link', { name: /Open in Schematic Editor/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.waitForURL(EDITOR_URL_RE, { timeout: 30000 });
|
||||||
|
await waitForToolReady(page, /demo — Schematic Editor/i);
|
||||||
|
|
||||||
|
await quitViaFileMenu(page);
|
||||||
|
|
||||||
|
await page.waitForURL(PROJECT_URL_RE, { timeout: 30000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quit on a deep-linked editor falls back to the project page', async ({ page }) => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
|
||||||
|
// Direct entry: no same-origin referrer, nothing meaningful to go back to —
|
||||||
|
// quit must land on the project page via the fallback URL.
|
||||||
|
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_sch`);
|
||||||
|
await waitForToolReady(page, /demo — Schematic Editor/i);
|
||||||
|
|
||||||
|
await quitViaFileMenu(page);
|
||||||
|
|
||||||
|
await page.waitForURL(PROJECT_URL_RE, { timeout: 30000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -246,6 +246,99 @@ function installToolNavigationHook(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The wx wasm port calls window.wxAppTopWindowClosed() when the app's MAIN
|
||||||
|
// frame is destroyed (wxwidgets src/wasm/toplevel.cpp) — i.e. on a real
|
||||||
|
// File→Quit / window close. A close vetoed by the unsaved-changes prompt never
|
||||||
|
// destroys the frame, so it never fires. The port also closes the frame while
|
||||||
|
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
|
||||||
|
// off as soon as any unload/navigation is under way.
|
||||||
|
|
||||||
|
let activeQuitHook: (() => void) | undefined;
|
||||||
|
let quitHandled = false;
|
||||||
|
|
||||||
|
const quitDispatcher = () => {
|
||||||
|
if (quitHandled) return;
|
||||||
|
quitHandled = true;
|
||||||
|
activeQuitHook?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureQuitDispatcher(win: ToolWindow): boolean {
|
||||||
|
if (win.wxAppTopWindowClosed === quitDispatcher) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.defineProperty(win, "wxAppTopWindowClosed", {
|
||||||
|
configurable: true,
|
||||||
|
value: quitDispatcher,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
ensureQuitDispatcher(window as ToolWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installQuitHook(
|
||||||
|
win: ToolWindow,
|
||||||
|
opts: { fallbackUrl: string; log: (m: string) => void },
|
||||||
|
): () => void {
|
||||||
|
const hook = () => {
|
||||||
|
// A same-origin referrer means we entered by an in-app hard navigation
|
||||||
|
// (ProjectView / NewFileDialog / tool-switch all location.assign), so
|
||||||
|
// going back lands wherever the user came from. Deep links and fresh tabs
|
||||||
|
// have no usable history — go to the fallback instead.
|
||||||
|
let sameOriginReferrer = false;
|
||||||
|
try {
|
||||||
|
sameOriginReferrer =
|
||||||
|
!!win.document.referrer &&
|
||||||
|
new URL(win.document.referrer).origin === win.location.origin;
|
||||||
|
} catch {
|
||||||
|
sameOriginReferrer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer the navigation out of the wasm callback: this fires from inside the
|
||||||
|
// frame's C++ destructor (via EM_ASM under Asyncify), and the teardown keeps
|
||||||
|
// running after we return. A cross-document location.assign() started here is
|
||||||
|
// aborted by that continuing teardown (only the same-document history.back()
|
||||||
|
// survives) — so hand it to a fresh task once the wasm stack has unwound.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (sameOriginReferrer && win.history.length > 1) {
|
||||||
|
opts.log("[quit] editor closed — history.back()");
|
||||||
|
win.history.back();
|
||||||
|
} else {
|
||||||
|
opts.log(`[quit] editor closed — no in-app history, going to ${opts.fallbackUrl}`);
|
||||||
|
win.location.assign(opts.fallbackUrl);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ensureQuitDispatcher(win)) {
|
||||||
|
opts.log("[quit] unable to install quit hook");
|
||||||
|
}
|
||||||
|
activeQuitHook = hook;
|
||||||
|
|
||||||
|
// Once the page is unloading for any reason, the hook must never navigate.
|
||||||
|
const markUnloading = () => {
|
||||||
|
quitHandled = true;
|
||||||
|
};
|
||||||
|
win.addEventListener("pagehide", markUnloading);
|
||||||
|
|
||||||
|
// A bfcache restore (Forward after quitting) would resurrect a page whose wx
|
||||||
|
// frame was already destroyed — force a clean re-boot instead.
|
||||||
|
const onPageShow = (e: PageTransitionEvent) => {
|
||||||
|
if (e.persisted) win.location.reload();
|
||||||
|
};
|
||||||
|
win.addEventListener("pageshow", onPageShow);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (activeQuitHook === hook) activeQuitHook = undefined;
|
||||||
|
win.removeEventListener("pagehide", markUnloading);
|
||||||
|
win.removeEventListener("pageshow", onPageShow);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the opened file back from MEMFS (what the editor actually loaded) and
|
* Read the opened file back from MEMFS (what the editor actually loaded) and
|
||||||
* parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the
|
* parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the
|
||||||
|
|
@ -748,14 +841,27 @@ export function WasmTool({
|
||||||
}, [ready]);
|
}, [ready]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
|
const win = window as ToolWindow;
|
||||||
|
const removeNavigationHook = installToolNavigationHook(win, {
|
||||||
slug,
|
slug,
|
||||||
files,
|
files,
|
||||||
targetPath,
|
targetPath,
|
||||||
log: append,
|
log: append,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => removeNavigationHook();
|
// File→Quit leaves the editor. Lib editors (/:scope/libs/:name) have no
|
||||||
|
// project overview to fall back to — go home instead.
|
||||||
|
const segments = win.location.pathname.split("/").filter(Boolean);
|
||||||
|
const removeQuitHook = installQuitHook(win, {
|
||||||
|
fallbackUrl:
|
||||||
|
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug),
|
||||||
|
log: append,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeNavigationHook();
|
||||||
|
removeQuitHook();
|
||||||
|
};
|
||||||
}, [slug, files, targetPath, append]);
|
}, [slug, files, targetPath, append]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
||||||
2
web/standalone/src/wasm/global.d.ts
vendored
2
web/standalone/src/wasm/global.d.ts
vendored
|
|
@ -46,6 +46,8 @@ declare global {
|
||||||
FS?: EmscriptenFS;
|
FS?: EmscriptenFS;
|
||||||
wxElementRegistry?: WxElementRegistry;
|
wxElementRegistry?: WxElementRegistry;
|
||||||
kicadWebOpenTool?: (toolName: string, fileName: string) => boolean;
|
kicadWebOpenTool?: (toolName: string, fileName: string) => boolean;
|
||||||
|
/** wx wasm port → page: the app's main frame was destroyed (File→Quit). */
|
||||||
|
wxAppTopWindowClosed?: () => void;
|
||||||
/** File System Access API (Chromium): writable local-folder sessions. */
|
/** File System Access API (Chromium): writable local-folder sessions. */
|
||||||
showDirectoryPicker?(options?: {
|
showDirectoryPicker?(options?: {
|
||||||
mode?: "read" | "readwrite";
|
mode?: "read" | "readwrite";
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit d9c3feecddad2ac33fc27a217f1a32885d0c0823
|
Subproject commit d4a45100124cbfef846b999f7e1768892b1a9463
|
||||||
Loading…
Reference in a new issue