fix(webapp): a history row opens the version it describes (BEA-7) (#58)

* fix(webapp): a history row opens the version it describes (BEA-7)

Clicking a row in any history feed called onOpen(e.path) and dropped the
row's blob, so every row opened the CURRENT file — a 7/25 "added" row
rendered content written on 7/26 with nothing on screen saying so. The
backend already served the exact bytes (/blob?sha=); only the UI could
not reach them.

A version is now an address: /<project-id>/<path>?v=<sha>. Routes carry
it (useLocationPath had to snapshot search too, or the URL would change
and nothing would re-render), the file view fetches the pinned blob, and
a banner names the version's time and author, says it is not the current
file, and offers View current + Download this version. /render gains an
optional ?sha= so historical markdown renders as markdown instead of raw
source; history views are still never counted as reads. Delete rows have
no content, so they stay unclickable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webapp): keep an old version from borrowing the current file's framing

An unknown ?v= sat on a blank pane through react-query's retry before
saying anything, and the topbar still showed the path's read counts next
to content the banner had just called historical. A pinned version now
fails fast and drops the heat line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-28 06:59:41 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 849794a8e5
commit 63612743df
22 changed files with 517 additions and 181 deletions
+9 -7
View File
@@ -29,17 +29,18 @@ classDiagram
class router {
+VIEW_ROUTES insights history install settings
+top-level routes orgs billing
+parseRoute(pathname, mode) Route
+urlForPath / urlForView
+encodePath / decodePath
+parseRoute(url, mode) Route
+Route.version ?v= sha, one past version
+urlForPath(path, projectId, version)
+urlForView / encodePath / decodePath
}
class nav {
+navigate(url)
+useLocationPath()
+useLocationPath() pathname + search
+linkProps(href)
+Redirect
}
note for nav "nav.ts + router.ts — deliberately NOT a router library (react-router v7 startTransition left stale views); History-API path routing, slashes literal, every user-facing page owns a URL path"
note for nav "nav.ts + router.ts — deliberately NOT a router library (react-router v7 startTransition left stale views); History-API path routing, slashes literal, every user-facing page owns a URL path. A version is not a view route (the first segment after the project id is reserved for view names) — it rides as ?v=, so useLocationPath must snapshot the search too or the URL changes and nothing re-renders"
class api {
+getJSON / postJSON / api
@@ -56,8 +57,9 @@ classDiagram
class components {
FileView FolderListing FileTree
HistoryView Insights ShareDialog
OrgAdmin HubSettings ProjectSettings
HistoryView HistoryRow VersionBanner
Insights ShareDialog OrgAdmin
HubSettings ProjectSettings
Palette shell AccountBar ...
}
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
+12
View File
@@ -160,6 +160,18 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
png := "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" +
"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
put("assets/logo.png", png, 24*time.Hour)
// One removed file, so the history feed has a delete row: deletes have no
// content, so their rows stay unclickable while every other row is now an
// address for its own version.
put("scratch.md", "# Scratch\n\nTemporary.\n", 12*time.Hour)
lam++
seq++
ops = append(ops, journal.Op{
Seq: seq, Lamport: lam, Time: now.Add(-6 * time.Hour),
Device: "seed", DeviceName: "seed-agent", Author: "alice@x.io",
User: "alice@x.io", UserName: "Alice",
Kind: journal.KindDelete, Path: "scratch.md",
})
if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil {
t.Fatal(err)
}
@@ -189,3 +189,67 @@ test("tree chevron folds and unfolds a folder", async ({ page }) => {
await page.click('#tree .row[data-path="notes"] .chev');
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toBeVisible();
});
// BEA-7: a history row is an address for the version it describes.
test("history row opens THAT version, banner says so, View current returns", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history/guide.md`);
// Oldest row = the first version; click it rather than the latest.
const added = page.locator(".hentry.add");
await expect(added).toBeVisible();
await added.click();
await page.waitForURL(new RegExp(`/${pid}/guide\\.md\\?v=[0-9a-f]{64}$`));
await expect(page.locator("#content")).toContainText("First version");
await expect(page.locator("#content")).not.toContainText("Second version");
// Rendered markdown, not raw source.
await expect(page.locator("#content h1")).toHaveText("Guide");
// The banner is what stops the page misleading.
const banner = page.locator(".vbanner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("This is not the current file");
await expect(banner).toContainText("alice@x.io");
await expect(banner.locator("a[download]")).toHaveAttribute("href", /blob\?sha=[0-9a-f]{64}.*download=1/);
// Downloading while pinned gives that version, not the current bytes.
await expect(page.locator("#download")).toHaveAttribute("href", /blob\?sha=[0-9a-f]{64}/);
await banner.getByText("View current").click();
await page.waitForURL(`/${pid}/guide.md`);
await expect(page.locator("#content")).toContainText("Second version");
await expect(page.locator(".vbanner")).toHaveCount(0);
});
test("a version URL survives a hard reload, and Back returns to history", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history/guide.md`);
await page.locator(".hentry.add").click();
const url = page.url();
await page.reload();
await expect(page.locator("#content")).toContainText("First version");
await expect(page.locator(".vbanner")).toBeVisible();
await page.goto(url); // fresh navigation to the deep link
await expect(page.locator("#content")).toContainText("First version");
await page.goBack();
await expect(page.locator(".history .hentry").first()).toBeVisible();
});
test("an unknown version says so instead of showing current content", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/guide.md?v=${"a".repeat(64)}`);
await expect(page.locator("#content")).toContainText("That version isn't available");
await expect(page.locator("#content")).not.toContainText("Second version");
await expect(page.locator(".vbanner")).toBeVisible(); // still offers a way back
});
test("delete rows have no version to open, so they stay unclickable", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history/scratch.md`);
const del = page.locator(".hentry.delete");
await expect(del).toBeVisible();
await expect(del).not.toHaveClass(/clickable/);
await del.click();
await expect(page).toHaveURL(`/${pid}/history/scratch.md`);
});
+2 -2
View File
@@ -129,9 +129,9 @@ test("history: whole project, newest first, and per-file versions", async ({ pag
await expect(page.locator("#crumb")).toContainText("History — guide.md");
await expect(page.locator(".history .hentry")).toHaveCount(2);
await expect(page.locator(".history .hentry").first()).toContainText("edited");
// clicking an entry opens the file
// clicking an entry opens THAT version of the file (BEA-7)
await page.click(".history .hentry.clickable >> nth=0");
await page.waitForURL(`/${pid}/guide.md`);
await page.waitForURL(new RegExp(`/${pid}/guide\\.md\\?v=[0-9a-f]{64}$`));
});
test("folder listing's Full history goes to the subtree feed", async ({ page }) => {
+33 -12
View File
@@ -25,6 +25,7 @@ import { Palette, type PaletteItem } from "../components/Palette";
import { ConnectGuide } from "../components/ConnectGuide";
import { Insights, useInsightsDevices } from "../components/Insights";
import { HistoryView, historyTitle } from "../components/HistoryView";
import { VersionBanner } from "../components/VersionBanner";
// The browsing surface shared by hub projects and single-volume mode: the
// file tree, folder listings, file views, and every topbar action. Sidebar
@@ -61,6 +62,8 @@ export default function Browser(props: {
}, [insightsOpen, apiBase, qc]);
const path = route.path;
// ?v= belongs to the file page; a view route or folder ignores it.
const version = !route.view ? route.version : undefined;
// On scoped view routes (/insights/<p>, /history/<p>) the subject of the
// page is the target — the tree highlights it, not a menu item.
const treePath = path || (route.view === "insights" || route.view === "history" ? route.viewTarget || "" : "");
@@ -130,8 +133,10 @@ export default function Browser(props: {
/* ---- navigation ---- */
const openPath = useCallback(
(p: string) => {
navigate(urlForPath(p, project?.id));
// A version (a history row's content hash) pins the file page to those
// exact bytes; without one the page is the current file.
(p: string, v?: string) => {
navigate(urlForPath(p, project?.id, v));
closeSidebarOnMobile();
},
[project?.id],
@@ -158,7 +163,12 @@ export default function Browser(props: {
// local sync only; the web app is a read/share/history surface.
const canDownload = !panel && isFile;
const canMore = !panel && (isFile || (hub && !!project && isDir));
const downloadURL = apiBase + "download?path=" + encodeURIComponent(path);
// Downloading while a version is open gives you THAT version — the ⋯ menu
// offering the current bytes under a page framed as historical was half of
// what made old versions unreachable.
const downloadURL = version
? apiBase + "blob?sha=" + version + "&name=" + encodeURIComponent(path) + "&download=1"
: apiBase + "download?path=" + encodeURIComponent(path);
const shareNow = useCallback(async () => {
// Shares are per-file; a selected folder has nothing to mint.
@@ -305,15 +315,26 @@ export default function Browser(props: {
pageWidth = HTML_EXT.test(path) ? "wide" : "read";
pageClass = "markdown";
view = (
<FileView
apiBase={apiBase}
path={path}
heatMap={heatMap}
flatFiles={flatFiles}
onOpenFile={openPath}
onMeta={setMeta}
onRendered={onRendered}
/>
<>
{version && (
<VersionBanner
apiBase={apiBase}
path={path}
version={version}
onViewCurrent={() => openPath(path)}
/>
)}
<FileView
apiBase={apiBase}
path={path}
version={version}
heatMap={heatMap}
flatFiles={flatFiles}
onOpenFile={openPath}
onMeta={setMeta}
onRendered={onRendered}
/>
</>
);
}
} else if (isHome) {
+5 -5
View File
@@ -17,7 +17,7 @@ import { toast } from "../toast";
import Browser from "./Browser";
export default function HubApp({ config }: { config: ServerConfig }) {
const pathname = useLocationPath();
const loc = useLocationPath(); // pathname + search
const refresh = useHubRefresh();
// Org just joined via an invite this page-load: prefer its projects over
// whatever happens to be first in the list.
@@ -26,19 +26,19 @@ export default function HubApp({ config }: { config: ServerConfig }) {
// URL (the last of the classic app's URL-less surfaces); any navigation
// closes it. Org administration is a real route — see /orgs/<id> below.
const [panel, setPanel] = useState<null | { kind: "hub" }>(null);
useEffect(() => setPanel(null), [pathname]);
useEffect(() => setPanel(null), [loc]);
const joinToken = useMemo(() => {
const m = pathname.match(/^\/join\/([0-9a-f]+)\/?$/);
const m = loc.split("?")[0].match(/^\/join\/([0-9a-f]+)\/?$/);
return m ? m[1] : null;
}, [pathname]);
}, [loc]);
const { data: projects } = useProjects(!joinToken);
const { data: orgs } = useOrgs(!joinToken);
const isAdmin = !!config.auth.admin;
const { data: pending } = usePending(isAdmin);
const route = useMemo(() => parseRoute(pathname, "hub"), [pathname]);
const route = useMemo(() => parseRoute(loc, "hub"), [loc]);
const current: Project | null = useMemo(() => {
if (!projects) return null;
@@ -8,12 +8,12 @@ import Browser from "./Browser";
// Single-volume mode: one folder, no projects or orgs — but the full
// browsing surface (tree, listings, files, upload when enabled).
export default function VolumeApp({ config }: { config: ServerConfig }) {
const pathname = useLocationPath();
const loc = useLocationPath(); // pathname + search
const name = config.volume || "BearDrive";
useEffect(() => {
document.title = config.brand || name;
}, [config, name]);
const route = useMemo(() => parseRoute(pathname, "volume"), [pathname]);
const route = useMemo(() => parseRoute(loc, "volume"), [loc]);
return (
<Browser
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
@@ -8,14 +8,21 @@ import { HTML_EXT, IMG_EXT, MD_EXT, TEXT_EXT, joinPath } from "../util";
export function FileView(props: {
apiBase: string;
path: string;
// Pinned to one past version by content hash (?v=), otherwise current.
version?: string;
heatMap: HeatMap | null;
flatFiles: Node[];
onOpenFile: (path: string) => void;
onMeta: (meta: string) => void;
onRendered?: () => void;
}) {
const { apiBase, path, onMeta } = props;
const fileURL = apiBase + "file?path=" + encodeURIComponent(path);
const { apiBase, path, version, onMeta } = props;
// A version is served by content hash; ?name= is what makes the server
// set a real Content-Type, so images and text render instead of
// downloading as octet-stream.
const fileURL = version
? apiBase + "blob?sha=" + version + "&name=" + encodeURIComponent(path)
: apiBase + "file?path=" + encodeURIComponent(path);
useEffect(() => () => onMeta(""), [path, onMeta]); // leaving a file clears its meta line
@@ -35,14 +42,14 @@ export function FileView(props: {
);
}
if (IMG_EXT.test(path)) {
return <ImgView src={fileURL} alt={path} onRendered={props.onRendered} />;
return <ImgView src={fileURL} alt={path} version={version} onRendered={props.onRendered} />;
}
if (TEXT_EXT.test(path)) return <TextView {...props} fileURL={fileURL} />;
return (
<div className="filecard">
<div className="name">{path.split("/").pop()}</div>
<p>No preview for this file type.</p>
<a className="btn" download href={apiBase + "download?path=" + encodeURIComponent(path)}>
<a className="btn" download href={version ? fileURL + "&download=1" : apiBase + "download?path=" + encodeURIComponent(path)}>
Download
</a>
</div>
@@ -50,10 +57,16 @@ export function FileView(props: {
}
function MarkdownView(props: Parameters<typeof FileView>[0]) {
const { apiBase, path, heatMap, flatFiles, onOpenFile, onMeta, onRendered } = props;
const { apiBase, path, version, heatMap, flatFiles, onOpenFile, onMeta, onRendered } = props;
const { data: doc, error } = useQuery({
queryKey: ["render", apiBase, path],
queryFn: () => getJSON<RenderDoc>(apiBase + "render?path=" + encodeURIComponent(path)),
queryKey: ["render", apiBase, path, version || ""],
queryFn: () =>
getJSON<RenderDoc>(
apiBase + "render?path=" + encodeURIComponent(path) + (version ? "&sha=" + version : ""),
),
// A blob that isn't there will not appear on a retry, and the retry's
// delay is a blank pane the reader has no explanation for.
retry: version ? false : undefined,
});
// Rewrite the HTML BEFORE rendering (relative image sources, external
@@ -71,13 +84,16 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
const parts: string[] = [];
if (doc.author) parts.push(doc.author + (doc.device ? " on " + doc.device : ""));
if (doc.time) parts.push(new Date(doc.time).toLocaleString());
const he = heatMap && heatMap[doc.path];
// Read counts belong to the path, not to one version — showing them
// beside content the banner just called historical reads as if they
// counted views of these bytes.
const he = version ? null : heatMap && heatMap[doc.path];
if (he && heatTotal(he)) parts.push(heatText(he) + " / 30d");
onMeta(parts.join(" · "));
onRendered?.();
}, [doc, heatMap, onMeta, onRendered]);
}, [doc, version, heatMap, onMeta, onRendered]);
if (error) return <div className="empty">Could not load file: {(error as Error).message}</div>;
if (error) return <LoadError version={version} err={error as Error} />;
if (!doc) return null;
// Server-rendered, server-sanitized markdown — same trust model as the
// classic app assigning innerHTML.
@@ -131,12 +147,27 @@ function transformHTML(html: string, p: string, apiBase: string): string {
return parsed.body.innerHTML;
}
function ImgView({ src, alt, onRendered }: { src: string; alt: string; onRendered?: () => void }) {
return <img src={src} alt={alt} onLoad={onRendered} />;
function ImgView(props: { src: string; alt: string; version?: string; onRendered?: () => void }) {
const [failed, setFailed] = useState(false);
if (failed) return <LoadError version={props.version} err={new Error("could not be loaded")} />;
return (
<img src={props.src} alt={props.alt} onLoad={props.onRendered} onError={() => setFailed(true)} />
);
}
/* A missing current file is a server problem worth quoting; a missing
version is almost always a bad ?v= in a hand-edited or stale URL, which
the server's "no such version" wording does not explain. */
function LoadError({ version, err }: { version?: string; err: Error }) {
return (
<div className="empty">
{version ? "That version isn't available." : "Could not load file: " + err.message}
</div>
);
}
function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
const { path, fileURL, onRendered } = props;
const { path, version, fileURL, onRendered } = props;
const { data, error } = useQuery({
queryKey: ["text", fileURL],
queryFn: async () => {
@@ -144,11 +175,12 @@ function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
if (!r.ok) throw new Error(await r.text());
return r.text();
},
retry: version ? false : undefined,
});
useEffect(() => {
if (data != null) onRendered?.();
}, [data, onRendered]);
if (error) return <div className="empty">Could not load file: {(error as Error).message}</div>;
if (error) return <LoadError version={version} err={error as Error} />;
if (data == null) return null;
return (
<pre className="plain" key={path}>
@@ -10,7 +10,7 @@ export function FolderListing(props: {
heatMap: HeatMap | null;
hub: boolean; // hub feeds exist; a plain-folder viewer has no journals
apiBase: string;
onOpen: (path: string) => void;
onOpen: (path: string, version?: string) => void;
onFullHistory: (prefix: string) => void;
onRendered?: () => void; // scroll restoration: content height just grew
}) {
@@ -95,7 +95,7 @@ export function FolderListing(props: {
function FolderHistory(props: {
apiBase: string;
prefix: string;
onOpen: (path: string) => void;
onOpen: (path: string, version?: string) => void;
onFullHistory: () => void;
onRendered?: () => void;
}) {
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { HistoryEntry } from "../api/types";
import { humanSize } from "../util";
import { humanSize, whoChanged } from "../util";
import { Icon } from "./shell";
/* One change as a row: what happened (added / edited / deleted), to which
@@ -13,16 +13,18 @@ export function HistoryRow({
onOpen,
}: {
entry: HistoryEntry;
onOpen: (path: string) => void;
// The row's own version (e.blob) rides along: a row is an address for the
// bytes it describes, not a shortcut to whatever the file says now.
onOpen: (path: string, version?: string) => void;
}) {
const [noteOpen, setNoteOpen] = useState(false);
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
const who = e.user_name ? `${e.user_name} <${e.user}>` : e.user || e.author || "unknown";
const who = whoChanged(e);
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const clickable = kind !== "delete";
const open = (ev: React.MouseEvent | React.KeyboardEvent) => {
if ((ev.target as HTMLElement).tagName === "A") return;
if (clickable) onOpen(e.path);
if (clickable) onOpen(e.path, e.blob);
};
return (
<div
@@ -33,7 +35,7 @@ export function HistoryRow({
onKeyDown={(ev) => {
if (clickable && (ev.key === "Enter" || ev.key === " ")) {
ev.preventDefault();
onOpen(e.path);
onOpen(e.path, e.blob);
}
}}
>
@@ -13,7 +13,7 @@ export function HistoryView(props: {
apiBase: string;
target: string; // "" = whole project
isFolder: (p: string) => boolean;
onOpen: (path: string) => void;
onOpen: (path: string, version?: string) => void;
onMeta: (meta: string) => void;
onRendered?: () => void;
}) {
@@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HistoryEntry } from "../api/types";
import { whoChanged } from "../util";
import { Icon } from "./shell";
/* Viewing a past version is an ordinary file page pinned to older bytes, so
nothing about the page itself says the content is stale this banner is
what stops it misleading. It stays on screen the whole time ?v= is set.
Provenance comes from the file's own history feed rather than a new API:
the same query key HistoryView primes, so arriving from a history click
costs no request. */
export function VersionBanner(props: {
apiBase: string;
path: string;
version: string;
onViewCurrent: () => void;
}) {
const { apiBase, path, version } = props;
const qs = "path=" + encodeURIComponent(path);
const { data } = useQuery({
queryKey: ["history", apiBase, qs, 200],
queryFn: () => getJSON<{ entries: HistoryEntry[] }>(apiBase + "history?" + qs + "&n=200"),
staleTime: 15_000,
});
// Newest match: the same content hash recurs if a file is reverted to
// bytes it already had, and the latest of those is the one just clicked.
const entry = data?.entries?.find((e) => e.blob === version);
const who = entry ? whoChanged(entry) : "";
const when = entry?.time ? new Date(entry.time).toLocaleString() : "";
const dl =
apiBase +
"blob?sha=" + version +
"&name=" + encodeURIComponent(path.split("/").pop() || path) +
"&download=1";
return (
<div className="vbanner" role="status">
<span className="vb-icon">
<Icon name="clock" />
</span>
<div className="vb-text">
<b>{[when && "Version from " + when, who && "by " + who].filter(Boolean).join(" ") || "Earlier version"}</b>
<span>This is not the current file.</span>
</div>
<div className="vb-actions">
<button className="ai-btn" onClick={props.onViewCurrent}>
View current
</button>
<a className="ai-btn" download href={dl}>
Download this version
</a>
</div>
</div>
);
}
+5 -1
View File
@@ -28,6 +28,10 @@ export function navigate(url: string, opts?: { replace?: boolean }) {
emit();
}
// The whole location, search included — routes carry state in the query
// (?v=<sha> pins a file to one past version), and a snapshot of pathname
// alone would leave useSyncExternalStore blind to those navigations: the URL
// would change and nothing would re-render.
export function useLocationPath(): string {
return useSyncExternalStore(
(l) => {
@@ -36,7 +40,7 @@ export function useLocationPath(): string {
listeners.delete(l);
};
},
() => location.pathname,
() => location.pathname + location.search,
);
}
+21 -5
View File
@@ -41,9 +41,23 @@ export interface Route {
path: string;
view?: ViewName;
viewTarget?: string;
// A past version of `path`, by content hash (?v=<sha>). Not a view route:
// the first segment after the project id is reserved for view names, and a
// version is the same page pinned to older bytes, so it rides as a query
// param on the file route.
version?: string;
}
export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
// `url` is pathname + search (what useLocationPath hands back).
export function parseRoute(url: string, mode: "volume" | "hub"): Route {
const qi = url.indexOf("?");
const version = qi === -1 ? "" : new URLSearchParams(url.slice(qi)).get("v") || "";
const r = parsePath(qi === -1 ? url : url.slice(0, qi), mode);
if (version) r.version = version;
return r;
}
function parsePath(pathname: string, mode: "volume" | "hub"): Route {
const raw = pathname.replace(/^\/+/, "");
if (mode !== "hub") return { path: raw ? decodePath(raw) : "" };
if (raw === "orgs" || raw.startsWith("orgs/")) {
@@ -65,11 +79,13 @@ export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
return r;
}
// The URL for a file within a project (hub) or the volume (no project id).
export function urlForPath(path: string, projectId?: string): string {
// The URL for a file within a project (hub) or the volume (no project id),
// optionally pinned to one past version by content hash.
export function urlForPath(path: string, projectId?: string, version?: string): string {
const enc = encodePath(path);
if (projectId) return "/" + projectId + (enc ? "/" + enc : "");
return "/" + enc;
const q = version ? "?v=" + version : "";
if (projectId) return "/" + projectId + (enc ? "/" + enc : "") + q;
return "/" + enc + q;
}
// The URL for a special view of a project.
+10
View File
@@ -819,6 +819,16 @@ input[type="checkbox"] { accent-color: var(--accent); }
.notfound code { background: var(--hover); border: 1px solid var(--border); padding: .15em .5em; border-radius: 6px; }
.notfound .nf-sub { max-width: 440px; margin: 12px auto 20px; font-size: 13px; color: var(--text-faint); line-height: 1.6; }
/* viewing a past version: the banner that keeps the page from lying about
which bytes are on screen it stays put for as long as ?v= is set */
.vbanner { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0 0 22px; padding: 11px 14px; border: 1px solid var(--accent-dim); border-radius: var(--r-card); background: var(--glow); }
.vbanner .vb-icon { flex: none; display: flex; color: var(--accent-bright); }
.vbanner .vb-text { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; gap: 1px; font-size: 12.5px; line-height: 1.45; }
.vbanner .vb-text b { color: var(--accent-bright); font-weight: 600; }
.vbanner .vb-text span { color: var(--text-dim); }
.vbanner .vb-actions { flex: none; display: flex; gap: 8px; }
.vbanner .vb-actions .ai-btn { display: inline-flex; align-items: center; text-decoration: none; }
/* plain file / binary views */
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; }
/* long unbreakable lines wrap instead of blowing out the column: .page has min-width: 0 */
+7
View File
@@ -40,3 +40,10 @@ export async function copyText(text: string): Promise<boolean> {
}
return false;
}
/* 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. */
export function whoChanged(e: { user?: string; user_name?: string; author?: string }): string {
return e.user_name ? `${e.user_name} <${e.user}>` : e.user || e.author || "unknown";
}
+71
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -150,6 +151,76 @@ func TestHistoryAPI(t *testing.T) {
}
}
// An old markdown version renders as markdown, not raw source — and looking
// at history is never a read.
func TestRenderVersion(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.putAs("dev1", "alice@x.io", "Alice", "guide.md", "# Guide\n\nFirst version.\n")
f.putAs("dev1", "alice@x.io", "Alice", "guide.md", "# Guide\n\nSecond version, longer.\n")
var err error
if srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0); err != nil {
t.Fatal(err)
}
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
rec := do(t, h, "GET", base+"history?path=guide.md", nil)
var hist struct {
Entries []HistoryEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
t.Fatal(err)
}
first := hist.Entries[len(hist.Entries)-1] // oldest
rec = do(t, h, "GET", base+"render?path=guide.md&sha="+first.Blob, nil)
if rec.Code != 200 {
t.Fatalf("render version: %d %s", rec.Code, rec.Body)
}
var doc struct {
Path string `json:"path"`
HTML string `json:"html"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if !strings.Contains(doc.HTML, "First version") || strings.Contains(doc.HTML, "Second version") {
t.Fatalf("rendered the wrong version: %q", doc.HTML)
}
if !strings.Contains(doc.HTML, "<h1") { // rendered, not raw source
t.Fatalf("not markdown-rendered: %q", doc.HTML)
}
if doc.Path != "guide.md" {
t.Fatalf("path = %q", doc.Path)
}
// current content still renders from the snapshot
rec = do(t, h, "GET", base+"render?path=guide.md", nil)
if !strings.Contains(rec.Body.String(), "Second version") {
t.Fatalf("current render = %s", rec.Body)
}
if rec := do(t, h, "GET", base+"render?path=guide.md&sha=nothex", nil); rec.Code != http.StatusBadRequest {
t.Fatalf("bad sha: %d, want 400", rec.Code)
}
missing := strings.Repeat("a", 64)
if rec := do(t, h, "GET", base+"render?path=guide.md&sha="+missing, nil); rec.Code != http.StatusNotFound {
t.Fatalf("unknown sha: %d, want 404", rec.Code)
}
// Only the current-content render above counted; the version views did not.
rec = do(t, h, "GET", base+"heat", nil)
var heat struct {
Entries map[string]HeatEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &heat); err != nil {
t.Fatal(err)
}
if e := heat.Entries["guide.md"]; e.Human != 1 {
t.Fatalf("heat = %+v; viewing history must not count as a read", heat.Entries)
}
}
// History is newest-first by wall-clock time, not by Lamport clock: a device
// that was offline writes later in real time but carries a lower clock, so
// reverse-journal order would bury the most recent change.
+39
View File
@@ -763,6 +763,10 @@ func (s *Server) handleDownload(v *volume, w http.ResponseWriter, r *http.Reques
}
func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request) {
if sha := r.URL.Query().Get("sha"); sha != "" {
s.renderVersion(v, w, r, sha)
return
}
p, fi, code, err := lookup(v, r)
if err != nil {
http.Error(w, err.Error(), code)
@@ -791,6 +795,41 @@ func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request)
})
}
// renderVersion renders one exact past version by content hash — the
// markdown counterpart of /blob?sha=, so opening an old .md from history
// shows a rendered page instead of raw source. Provenance is not returned:
// the caller already has the history entry it clicked. Viewing history is
// never a read (see the read-heat invariant), so nothing is recorded.
func (s *Server) renderVersion(v *volume, w http.ResponseWriter, r *http.Request, sha string) {
if !blobRe.MatchString(sha) {
http.Error(w, "invalid sha", http.StatusBadRequest)
return
}
rs := storeSource(v, w)
if rs == nil {
return
}
rc, err := rs.Backend.Get(r.Context(), "blobs/"+sha)
if err != nil {
http.Error(w, "no such version", http.StatusNotFound)
return
}
src, err := io.ReadAll(rc)
rc.Close()
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
html, err := RenderMarkdown(src)
if err != nil {
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{
"path": r.URL.Query().Get("path"), "html": html, "size": len(src),
})
}
func contentType(p string) string {
switch strings.ToLower(path.Ext(p)) {
case ".md", ".markdown":
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<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-DOG8Q4eQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ogK17qMq.css">
<script type="module" crossorigin src="/assets/index-BqTFxxiz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPzZXr-O.css">
</head>
<body>
<div id="root"></div>