mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(webapp): landing on / opens the project you last used (BEA-75) (#115)
Hitting the hub with no project in the URL always resolved to projects[0] — alphabetically first, unrelated to what you were doing — and HubApp then rewrote the address bar to it, so the wrong choice was the one that got bookmarked. The browser now remembers the project it last opened and prefers it. The precedence chain gains one clause between the just-joined org and the fallback; a remembered id is looked up in the project list, so one that was deleted, or that this account can no longer see, simply doesn't match and projects[0] takes over with nothing on screen to say so. localStorage, per browser and origin-scoped, so two hubs never share an answer. Both helpers swallow — storage throws in Safari private mode, and a preference is never worth a broken page. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e02bd5330d
commit
5330532f7f
@@ -23,6 +23,7 @@ classDiagram
|
||||
class HubApp {
|
||||
project list, org walls
|
||||
admin panels, invites
|
||||
remembers last opened project (localStorage)
|
||||
}
|
||||
class VolumeApp {
|
||||
thin wrapper: one volume
|
||||
|
||||
@@ -199,3 +199,34 @@ test("new-project modal cancels on Escape", async ({ page }) => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator(".modal-input")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// BEA-75. Landing on "/" used to open whatever project sorts first, so a
|
||||
// bookmark or a new tab threw away wherever you actually were. Both specs
|
||||
// create or read state, so they sit at the end of the file with the other
|
||||
// mutating ones.
|
||||
test("landing returns to the last project opened, not the first one", async ({ page }) => {
|
||||
await login(page);
|
||||
// Named to sort last, so it can never be projects[0] — that is what makes
|
||||
// the assertion mean anything.
|
||||
const made = (await (await page.request.post("/api/projects", { data: { name: "zz-last" } })).json())
|
||||
.project;
|
||||
await page.goto("/" + made.id);
|
||||
await expect(page.locator("#project-select")).toContainText("zz-last");
|
||||
await page.goto("/"); // no project in the URL, the way a bookmark arrives
|
||||
await page.waitForURL("/" + made.id);
|
||||
await expect(page.locator("#project-select")).toContainText("zz-last");
|
||||
});
|
||||
|
||||
test("a remembered project that is gone falls back silently", async ({ page }) => {
|
||||
await login(page);
|
||||
const errors: Error[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e));
|
||||
await page.addInitScript(() =>
|
||||
localStorage.setItem("bdrive.lastProject", "00000000-0000-0000-0000-000000000000"),
|
||||
);
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/[0-9a-f-]{36}$/);
|
||||
await expect(page.locator("#project-select")).toContainText(/.+/);
|
||||
await expect(page.locator("#toast.show, [data-sonner-toast]")).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ConnectGuide } from "../components/ConnectGuide";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { EXISTING, NewProjectDialog } from "../components/NewProjectDialog";
|
||||
import { toast } from "../toast";
|
||||
import { lastProject, rememberProject } from "../util";
|
||||
import Browser from "./Browser";
|
||||
|
||||
export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
@@ -87,11 +88,17 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Precedence: the URL wins, then an org joined this page-load, then the
|
||||
// project this browser last opened, then whatever sorts first. Looking the
|
||||
// remembered id up in `projects` is the whole "deleted / access revoked /
|
||||
// different account signed in" story — it simply doesn't match and the
|
||||
// fallback takes over, with nothing on screen to say so.
|
||||
const current: Project | null = useMemo(() => {
|
||||
if (!projects) return null;
|
||||
return (
|
||||
projects.find((p) => p.id === route.project) ||
|
||||
(joinedOrgId && projects.find((p) => p.org === joinedOrgId)) ||
|
||||
projects.find((p) => p.id === lastProject()) ||
|
||||
projects[0] ||
|
||||
null
|
||||
);
|
||||
@@ -101,6 +108,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
document.title = current
|
||||
? current.name + " — BearDrive"
|
||||
: config.brand || "BearDrive";
|
||||
// Fires on exactly the events that matter — sidebar, deep link, palette,
|
||||
// post-create navigate — so the memory needs no subscription of its own.
|
||||
if (current) rememberProject(current.id);
|
||||
}, [current, config]);
|
||||
|
||||
if (joinToken) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
|
||||
// Node has no localStorage, which is what makes the hostile-browser branches
|
||||
// cheap to pin: the bare run IS the "storage missing" case.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { lastProject, rememberProject } from "./util.ts";
|
||||
|
||||
// Swap globalThis.localStorage for the length of one call, always putting the
|
||||
// original back — later tests in the same process must not inherit a stub.
|
||||
function withStorage(stub: unknown, fn: () => void) {
|
||||
const g = globalThis as { localStorage?: unknown };
|
||||
const had = "localStorage" in g;
|
||||
const prev = g.localStorage;
|
||||
g.localStorage = stub;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (had) g.localStorage = prev;
|
||||
else delete g.localStorage;
|
||||
}
|
||||
}
|
||||
|
||||
test("no storage at all: reads empty, writes stay silent", () => {
|
||||
assert.equal(lastProject(), "");
|
||||
assert.doesNotThrow(() => rememberProject("p1"));
|
||||
});
|
||||
|
||||
test("round-trips the last project through storage", () => {
|
||||
const m = new Map<string, string>();
|
||||
withStorage(
|
||||
{
|
||||
getItem: (k: string) => m.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void m.set(k, v),
|
||||
},
|
||||
() => {
|
||||
assert.equal(lastProject(), "");
|
||||
rememberProject("p1");
|
||||
assert.equal(lastProject(), "p1");
|
||||
rememberProject("p2");
|
||||
assert.equal(lastProject(), "p2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("storage that throws is swallowed on both sides", () => {
|
||||
const boom = () => {
|
||||
throw new Error("SecurityError");
|
||||
};
|
||||
withStorage({ getItem: boom, setItem: boom }, () => {
|
||||
assert.equal(lastProject(), "");
|
||||
assert.doesNotThrow(() => rememberProject("p1"));
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,28 @@ export async function copyText(text: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Last project opened on this browser. localStorage throws in Safari's
|
||||
private mode and wherever storage is disabled, so both sides swallow — a
|
||||
preference is never worth a broken page. Origin-scoped already, so two
|
||||
hubs never share an answer. */
|
||||
const LAST_PROJECT = "bdrive.lastProject";
|
||||
|
||||
export function lastProject(): string {
|
||||
try {
|
||||
return localStorage.getItem(LAST_PROJECT) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberProject(id: string) {
|
||||
try {
|
||||
localStorage.setItem(LAST_PROJECT, id);
|
||||
} catch {
|
||||
/* preference only */
|
||||
}
|
||||
}
|
||||
|
||||
/* Who made a change, as history renders it everywhere: the account, with
|
||||
the display name in front when the server knows one, falling back to the
|
||||
git/OS identity of an offline device. */
|
||||
|
||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -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-DrjjYyEZ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-w2PDXp5a.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DKdbhP6i.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user