mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
849794a8e5
commit
63612743df
@@ -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"
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user