fix(webapp): /history?path=<file> shows that file's history instead of the whole project (BEA-64) (#118)

* fix(webapp): /history?path=<file> shows that file's history instead of the whole project (BEA-64)

The History API takes ?path=/?prefix=, so a reader who has seen the API
types the query form at the page too. The router dropped it: viewTarget
stayed empty, the project-wide feed rendered, and nothing said a parameter
had been ignored — read as "this file has no history" or "history is
broken".

Honour it and normalize, the same shape as the legacyView and trailingSlash
redirects already there: parseRoute sets viewTarget from ?path=/?prefix=
(only on the history view, only when no path segment named a target) and
flags the URL for replacement; HubApp swaps in the canonical
/history/<target> URL with the filters intact.

The legacyView redirect next door dropped history filters on its own hop —
one-argument fix in the same call shape.

* docs(architecture): Route.queryTarget joins the normalization flags (BEA-64)
This commit is contained in:
Snow Lee (Sungwon)
2026-08-05 11:04:49 +09:00
committed by GitHub
parent 71e52d5704
commit 83613f4c85
7 changed files with 152 additions and 21 deletions
+1
View File
@@ -40,6 +40,7 @@ classDiagram
+parseRoute(url, mode) Route
+Route.version ?v= sha, one past version
+Route.trailingSlash notes/ resolves, then replaces to notes
+Route.queryTarget history ?path= / ?prefix= resolves, then replaces to /history/target
+Route.filters q user since until, history feed
+historyFilterQuery(filters) / hasHistoryFilters
+urlForPath(path, projectId, version)
@@ -105,3 +105,40 @@ test("the folder feed and the per-file version list filter too", async ({ page }
await page.goto(`/${pid}/history/guide.md?user=bob%40x.io`);
await expect(page.locator(rows)).toHaveCount(0);
});
// BEA-64: the History API takes ?path=, so the query form is what a reader
// who has seen the API types at the page. It used to be dropped, rendering
// the whole project as if the file had no history of its own.
test("?path= lands on that file's feed, normalizes the URL, and keeps filters", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
await expect(page.locator(rows).first()).toBeVisible();
// Arrive from a real page, so Back has somewhere to go that is not the
// ?path= URL — the redirect must replace, not push.
await page.goto(`/${pid}/history?path=guide.md&user=alice%40x.io`);
await page.waitForURL(`/${pid}/history/guide.md?user=alice%40x.io`);
await expect(page.locator("#crumb")).toHaveText("History — guide.md");
await expect(page.locator(`${rows} .hpath`).first()).toBeVisible();
for (const p of await page.locator(`${rows} .hpath`).allTextContents()) {
expect(p).toContain("guide.md");
}
await expect(page.locator(".hfilters select.hf-user")).toHaveValue("alice@x.io");
await page.goBack();
await expect(page).toHaveURL(`/${pid}/history`);
// ?prefix= is the same parameter by another name, and lands on the subtree.
await page.goto(`/${pid}/history?prefix=notes`);
await page.waitForURL(`/${pid}/history/notes`);
await expect(page.locator("#crumb")).toHaveText("History — notes/ (folder)");
for (const p of await page.locator(`${rows} .hpath`).allTextContents()) {
expect(p).toContain("notes/");
}
// The path route is canonical, so it wins and stays put.
await page.goto(`/${pid}/history/guide.md?path=notes/readme.md`);
await expect(page.locator("#crumb")).toHaveText("History — guide.md");
await expect(page).toHaveURL(`/${pid}/history/guide.md?path=notes/readme.md`);
});
+11 -2
View File
@@ -267,9 +267,18 @@ export default function HubApp({ config }: { config: ServerConfig }) {
}
// A renamed view URL (/insights) still resolves; swap it for the current
// one so there is one live URL per page.
// one so there is one live URL per page. Filters ride along: the hop is a
// rename, not a reset, and dropping them would silently widen the feed.
if (route.legacyView && route.view) {
return <Redirect to={urlForView(route.view, current.id, route.viewTarget)} />;
return <Redirect to={urlForView(route.view, current.id, route.viewTarget, route.filters)} />;
}
// /history?path=guide.md resolved to guide.md's feed (the query form is
// what the History API teaches); put the canonical path URL in the address
// bar. Below the unknown-project redirect for the same reason the trailing
// slash one is: normalizing on a bad project id would pin the wrong project.
if (route.queryTarget && route.view) {
return <Redirect to={urlForView(route.view, current.id, route.viewTarget, route.filters)} />;
}
// /notes/ is the same page as /notes — resolve it, then take the slash off
@@ -96,3 +96,69 @@ test("no filters means no query string", () => {
// other views ignore them entirely
assert.equal(urlForView("dashboard", "p-1", "", { q: "x" }), "/p-1/dashboard");
});
// BEA-64: the History API takes ?path=/?prefix=, so a reader who has seen it
// types the query form at the page too. It used to be dropped, which rendered
// the whole project as if the file had no history.
test("?path= and ?prefix= name the history target and ask to be normalized", () => {
const p = parseRoute("/p-1/history?path=guide.md", "hub");
assert.equal(p.view, "history");
assert.equal(p.viewTarget, "guide.md");
assert.equal(p.queryTarget, true);
assert.equal(urlForView("history", "p-1", p.viewTarget, p.filters), "/p-1/history/guide.md");
// Aliases, not modes: the view decides file-vs-subtree from the tree.
const x = parseRoute("/p-1/history?prefix=notes", "hub");
assert.equal(x.viewTarget, "notes");
assert.equal(x.queryTarget, true);
// Same trailing-slash tolerance a path segment gets.
const s = parseRoute("/p-1/history?prefix=notes/", "hub");
assert.equal(s.viewTarget, "notes");
assert.equal(s.queryTarget, true);
// A value that is nothing but separators names no file.
const empty = parseRoute("/p-1/history?path=/", "hub");
assert.equal(empty.viewTarget, "");
assert.ok(!empty.queryTarget);
});
// The path route is the canonical one, so it wins and stays put — otherwise
// arriving at a file's feed with a stray ?path= would bounce you elsewhere.
test("a path segment beats ?path=, and other views ignore it", () => {
const r = parseRoute("/p-1/history/a.md?path=b.md", "hub");
assert.equal(r.viewTarget, "a.md");
assert.equal(r.queryTarget, undefined);
const d = parseRoute("/p-1/dashboard?path=guide.md", "hub");
assert.equal(d.view, "dashboard");
assert.equal(d.viewTarget, "");
assert.equal(d.queryTarget, undefined);
// Not a view at all: a file named like the parameter must not be hijacked.
const f = parseRoute("/p-1/notes/readme.md?path=guide.md", "hub");
assert.equal(f.path, "notes/readme.md");
assert.equal(f.queryTarget, undefined);
});
// The normalization is a redirect, so anything else in the query string has
// to survive it — losing the author filter on the hop is the same bug again.
test("filters survive the ?path= normalization", () => {
const r = parseRoute("/p-1/history?path=guide.md&user=alice@x.io", "hub");
assert.equal(r.viewTarget, "guide.md");
assert.equal(r.queryTarget, true);
assert.deepEqual(r.filters, { user: "alice@x.io" });
assert.equal(
urlForView("history", "p-1", r.viewTarget, r.filters),
"/p-1/history/guide.md?user=alice%40x.io",
);
});
// Encoded slashes survive both decodes, so a target that arrived
// double-encoded re-encodes to exactly one URL rather than another redirect.
test("an encoded separator in ?path= round-trips", () => {
const r = parseRoute("/p-1/history?path=a%2Fb.md", "hub");
assert.equal(r.viewTarget, "a/b.md");
assert.equal(r.queryTarget, true);
assert.equal(urlForView("history", "p-1", r.viewTarget), "/p-1/history/a/b.md");
});
+18
View File
@@ -118,6 +118,10 @@ export interface Route {
// History feed filters (?q=&user=&since=&until=). Only ever set on the
// history view; absent when nothing is filtered.
filters?: HistoryFilters;
// The history target arrived as ?path=/?prefix= rather than as a path
// segment. Same treatment as legacyView: it resolves, then the app
// replaces it with the canonical /history/<target> URL.
queryTarget?: boolean;
}
// `url` is pathname + search (what useLocationPath hands back).
@@ -135,6 +139,20 @@ export function parseRoute(url: string, mode: "volume" | "hub"): Route {
if (v) filters[k] = v;
}
if (hasHistoryFilters(filters)) r.filters = filters;
// The History API takes ?path=/?prefix= (see CLAUDE.md), so a reader who
// has seen the API types the query form at the page too. Honour it and
// normalize, rather than dropping it and rendering the whole project as if
// the file had no history of its own. The two are aliases, not modes: the
// view decides file-vs-subtree from the tree, exactly as it does for
// /history/<target>. A path segment already present wins, which is also
// what leaves ?path= on every other view untouched.
if (r.view === "history" && !r.viewTarget) {
const t = (q?.get("path") || q?.get("prefix") || "").replace(/^\/+|\/+$/g, "");
if (t) {
r.viewTarget = decodePath(t);
r.queryTarget = true;
}
}
return r;
}
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-BMdHuKLj.js"></script>
<script type="module" crossorigin src="/assets/index-BgBsEqHO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DKdbhP6i.css">
</head>
<body>