fix(webapp): the palette always offers a way back (BEA-52) (#102)

⌘K on a path that doesn't resolve degraded to History ×2 and Sign out —
the tree-derived entries are gone on a dead route and the switcher lists
only OTHER projects, so on a single-project hub the palette, which is the
natural escape hatch there, was the one surface with no way out.

Four static entries now lead the candidate list whenever hub && project:
Go to project root, Dashboard, Installation, Settings — the same four
destinations (and icons) the sidebar has. They're independent of the tree
and of whether the path resolves, and each navigates to a real
VIEW_ROUTES URL, so a reload renders the same view. Panels only close on
a location change, so selecting the page you're already on closes them
explicitly.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-03 08:16:49 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent fe872dd1fa
commit c5f8114e07
4 changed files with 69 additions and 27 deletions
@@ -153,6 +153,30 @@ test("palette (⌘K) fuzzy-jumps to a file", async ({ page }) => {
await expect(page.locator("#content h1")).toHaveText("Topic");
});
// BEA-52: on a path that doesn't resolve the tree entries are gone and the
// switcher lists only other projects, so the palette used to offer no way
// back. cmdk owns the list's id (it overwrites ours), hence [cmdk-list].
test("palette on a dead route still offers the way back", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/does-not-exist.md`);
await expect(page.locator(".notfound")).toBeVisible();
await page.keyboard.press("ControlOrMeta+k");
await expect(page.locator("#palette")).toBeVisible();
for (const label of ["Go to project root", "Dashboard", "Installation", "Settings"]) {
await expect(page.locator("#palette [cmdk-list]")).toContainText(label);
}
// exactly one whole-project history entry, no duplicate
expect(
await page.locator("#palette [cmdk-item]", { hasText: "History: whole project" }).count(),
).toBe(1);
await page.fill("#palette input", "Dashboard");
await page.keyboard.press("Enter");
await page.waitForURL(`/${pid}/dashboard`);
await page.reload(); // the entries are real URLs, not panel state
await expect(page.locator(".in-treemap")).toBeVisible();
});
// BEA-54: cmdk overwrites the `id` we pass its primitives, so every palette
// rule anchored on one was dead — the input lost its only author `color` and
// fell back to the UA's black. Anchors here are ours (#palette) or cmdk's own
+19 -1
View File
@@ -283,6 +283,24 @@ export default function Browser(props: {
const items: PaletteItem[] = [];
const add = (icon: string, label: string, kind: string, run: () => void) =>
items.push({ icon, label, kind, run });
// The project's own destinations, first and unconditional: on a path that
// doesn't resolve the tree-derived entries are gone and the switcher lists
// only OTHER projects, so without these the palette — the natural escape
// hatch on a dead route — is the one surface with no way back. An empty
// query scores everything 0 and the sort is stable, so first here is first
// on screen. Panels aren't routes and only close on a location CHANGE, so
// picking the page you're already on needs the explicit close.
if (hub && project) {
const pid = project.id;
const go = (to: string) => () => {
props.onClosePanel?.();
navigate(to);
};
add("folder", "Go to project root", "action", go("/" + pid));
add("dashboard", "Dashboard", "action", go(urlForView("dashboard", pid)));
add("terminal", "Installation", "action", go(urlForView("install", pid)));
add("gear", "Settings", "action", go(urlForView("settings", pid)));
}
if (hub && project && path) {
if (isFile) add("share", "Share: " + path, "action", shareNow);
add("hist", "History: " + path, "action", historyNow);
@@ -302,7 +320,7 @@ export default function Browser(props: {
for (const d of dirIndex.keys()) add("folder", d, "folder", () => openPath(d));
for (const f of flatFiles) add("doc", f.path, "file", () => openPath(f.path));
return items;
}, [hub, project, path, isFile, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, openHistory, openPath]);
}, [hub, project, path, isFile, config.auth?.enabled, dirIndex, flatFiles, props.projects, props.onClosePanel, shareNow, historyNow, openHistory, openPath]);
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
useEffect(() => {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-CMWTx-Od.js"></script>
<script type="module" crossorigin src="/assets/index-DDAA5G2N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-zSoeQX.css">
</head>
<body>