feat(webapp): [phase 3] project home guide, insights, history — and a synchronous router

- project home at /<pid>: connect-an-agent guide (Claude plugin flow,
  Hermes/Codex CLI tabs, persisted choice, copy buttons, real hub origin
  + project id), with Insights embedded below for admins/org-owners
- Insights: squarified treemap, reads×freshness scatter with danger
  quadrant, hot-path list, agent coverage matrix — classic math ported
  verbatim into JSX SVG; dedicated /insights route
- history views: whole-project / subtree / per-file feeds with kind tags,
  device attribution, expandable linkified session notes
- REPLACED react-router-dom with src/nav.ts (~40-line synchronous history
  router): v7 wraps navigation in startTransition, which left the old
  view on screen for seconds after the URL changed (flaky navigations,
  35KB heavier bundle). Routing semantics are unchanged.
- e2e: 12 new specs porting the original 17 parity checks (34 total,
  ~13s, stable across repeated runs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-13 10:45:19 -07:00
co-authored by Claude Fable 5
parent 2db399a766
commit 44b95143b0
16 changed files with 1073 additions and 131 deletions
+28 -14
View File
@@ -39,10 +39,15 @@ blockers in §Status at the bottom.
details; never expect or display human actor emails from the heat API.
4. **Never commit `internal/webapp/manual_serve_test.go`** (untracked local
demo harness). Check `git status` before every commit.
5. Runtime deps allowed: `react`, `react-dom`, `react-router-dom`,
`@tanstack/react-query`. Anything beyond these requires updating this
PRD with a justification first. Dev deps: `vite`, `typescript`,
`@vitejs/plugin-react`, `@playwright/test` (+ types).
5. Runtime deps allowed: `react`, `react-dom`, `@tanstack/react-query`.
(`react-router-dom` was allowed, adopted, then REMOVED in Phase 3: v7
wraps navigation in React.startTransition, which left the old view on
screen for seconds after the URL changed. Routing is `src/nav.ts` — a
~40-line synchronous history router — plus the hand-ported `parseRoute`
in `src/router.ts`. Do not reintroduce a router library.) Anything
beyond these requires updating this PRD with a justification first.
Dev deps: `vite`, `typescript`, `@vitejs/plugin-react`,
`@playwright/test` (+ types).
## Toolchain & layout
@@ -160,16 +165,25 @@ refresh; invalidate after uploads/renames/admin actions — mirror today's
form logins trip it with flaky timeouts.
### Phase 3 — project home, insights, history
- [ ] Project home at `/<pid>`: connect guide (3 tabs: "Claude Code &
- [x] Project home at `/<pid>`: connect guide (3 tabs: "Claude Code &
Cowork" plugin flow, Hermes CLI, Codex CLI; copy buttons; persisted
tab; commands pre-filled with hub origin + project id).
- [ ] Insights embedded below the guide for admins/org-owners only
(`canSeeInsights` logic), plus dedicated `/insights` route.
- [ ] Insights views: treemap (squarify), device matrix, chart, hot-path
list — visually equivalent.
- [ ] History view: `?path=`/`?prefix=` modes, newest-first entries, blob
version links, device attribution.
- [ ] Vault-name click returns to project home.
tab with stale-value fallback; commands pre-filled with hub origin +
project id).
- [x] Insights embedded below the guide for admins/org-owners only
(`canInsights` = hub admin or project-org owner), plus dedicated
`/insights` route; members see neither.
- [x] Insights views: squarified treemap, reads×freshness scatter with
danger quadrant, hot-path list with agent/human split, agent
coverage matrix — math ported as-is into JSX SVG.
- [x] History view: whole-project / subtree (`prefix`) / per-file (`path`)
modes, newest first, add/edit/delete tags, device attribution,
expandable linkified notes; folder listings link into the subtree
feed. (Blob version links remain server-side; the classic app had
no version-viewer UI either.)
- [x] Vault-name click returns to project home.
NOTE (structural, discovered here): react-router-dom removed — see
invariant 5. The original 17 parity checks are ported in
`e2e/home.spec.ts`; suite is 34 specs, ~13s, stable across runs.
### Phase 4 — admin surfaces
- [ ] Org admin: rename, members (role change/remove), invite create/list/
@@ -238,7 +252,7 @@ file path; reload on `/insights`.
- [x] Phase 0
- [x] Phase 1
- [x] Phase 2
- [ ] Phase 3
- [x] Phase 3
- [ ] Phase 4
- [ ] Phase 5
+153
View File
@@ -0,0 +1,153 @@
import { test, expect } from "@playwright/test";
import { login, wikiId, MEMBER } from "./helpers";
// Phase 3: project home (connect guide + embedded insights), the dedicated
// insights route, and the history views. Ports the original parity checks
// from the pre-migration smoke suite.
test("landing is the project home (guide), not an insights redirect", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForURL("/" + pid);
await expect(page.locator(".guide")).toBeVisible();
await expect(page.locator("#crumb")).toHaveText("wiki");
});
test("guide: three agent tabs, one active, choice persisted", async ({ page }) => {
await login(page);
await page.waitForSelector(".guide");
await expect(page.locator(".gd-tab")).toHaveText(["Claude Code & Cowork", "Hermes", "Codex"]);
await expect(page.locator(".gd-tab.active")).toHaveCount(1);
await page.click('.gd-tab[data-key="codex"]');
expect(await page.evaluate(() => localStorage.getItem("bdrive-guide-agent"))).toBe("codex");
await page.click('.gd-tab[data-key="claude"]');
});
test("claude tab: plugin flow with real hub origin and project id, no raw CLI", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click('.gd-tab[data-key="claude"]');
const codes = await page.$$eval(".gd-code code", (els) => els.map((e) => e.textContent).join("\n"));
expect(codes).toContain("/plugin marketplace add runbear-io/beardrive");
expect(codes).toContain("/plugin install beardrive@beardrive");
expect(codes).toContain(`/beardrive:install connect to http://localhost:8993, project ${pid}`);
expect(codes).not.toContain("brew install");
expect(codes).not.toContain("hooks install");
await expect(page.locator(".gd-body")).toContainText("Cowork");
});
test("codex tab keeps the full CLI flow", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click('.gd-tab[data-key="codex"]');
const codes = await page.$$eval(".gd-code code", (els) => els.map((e) => e.textContent).join("\n"));
expect(codes).toContain("brew install runbear-io/tap/beardrive");
expect(codes).toContain("bdrive login http://localhost:8993");
expect(codes).toContain(`bdrive init --project ${pid}`);
expect(codes).toContain("bdrive hooks install --agent codex");
await page.click('.gd-tab[data-key="claude"]');
});
test("a stale saved tab choice falls back to the first tab", async ({ page }) => {
await login(page);
await page.waitForSelector(".guide");
await page.evaluate(() => localStorage.setItem("bdrive-guide-agent", "cowork"));
await page.reload();
await page.waitForSelector(".guide");
await expect(page.locator(".gd-tab.active")).toHaveText("Claude Code & Cowork");
await page.evaluate(() => localStorage.setItem("bdrive-guide-agent", "claude"));
});
test("admin home embeds insights below the guide; member home does not", async ({ page, browser }) => {
await login(page);
await page.waitForSelector(".guide");
await expect(page.locator(".home-insights .insights")).toBeVisible();
// Guide renders above the embedded insights
const order = await page.evaluate(() => {
const g = document.querySelector(".guide");
const i = document.querySelector(".home-insights");
return g && i ? (g.compareDocumentPosition(i) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 : false;
});
expect(order).toBe(true);
await expect(page.locator(".in-treemap")).toBeVisible();
await expect(page.locator(".in-hotpath .in-hp-row").first()).toBeVisible();
const ctx = await browser.newContext();
const p2 = await ctx.newPage();
await login(p2, MEMBER);
await p2.waitForSelector(".guide");
await expect(p2.locator(".home-insights")).toHaveCount(0);
await ctx.close();
});
test("dedicated insights route still works and survives reload", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/insights`);
await expect(page.locator("#crumb")).toHaveText("Insights — wiki");
await expect(page.locator(".in-treemap")).toBeVisible();
await page.reload();
await expect(page.locator(".in-treemap")).toBeVisible();
});
test("hot path row opens the file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/insights`);
await page.click(".in-hp-row:first-child");
await page.waitForURL(/\/(index|guide)\.md$/);
await expect(page.locator("#content h1")).toBeVisible();
});
test("vault name returns to the project home", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/index.md`);
await page.click("#vault-name");
await page.waitForURL("/" + pid);
await expect(page.locator(".guide")).toBeVisible();
});
test("back/forward walks home → file → insights", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForURL("/" + pid);
await page.click('#tree .row[data-path="index.md"]');
await page.waitForURL(`/${pid}/index.md`);
await page.goto(`/${pid}/insights`);
await page.goBack();
await expect(page.locator("#content h1")).toHaveText("Wiki");
await page.goBack();
await expect(page.locator(".guide")).toBeVisible();
await page.goForward();
await expect(page.locator("#content h1")).toHaveText("Wiki");
});
test("history: whole project, newest first, and per-file versions", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click("#history-btn"); // from home: whole project
await page.waitForURL(`/${pid}/history`);
await expect(page.locator("#crumb")).toContainText("History — all changes");
await expect(page.locator(".history .hentry").first()).toBeVisible();
expect(await page.locator(".history .hentry").count()).toBeGreaterThanOrEqual(6); // all seeded ops
// guide.md has two versions
await page.goto(`/${pid}/history/guide.md`);
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
await page.click(".history .hentry.clickable >> nth=0");
await page.waitForURL(`/${pid}/guide.md`);
});
test("folder listing's Full history goes to the subtree feed", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.click(".dl-more");
await page.waitForURL(`/${pid}/history/notes`);
await expect(page.locator("#crumb")).toContainText("History — notes/ (folder)");
const paths = await page.$$eval(".history .hpath", (els) => els.map((e) => e.textContent));
for (const p of paths) expect(p).toContain("notes/");
});
+1 -59
View File
@@ -10,8 +10,7 @@
"dependencies": {
"@tanstack/react-query": "^5.90.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.0"
"react-dom": "^19.2.0"
},
"devDependencies": {
"@playwright/test": "1.61.1",
@@ -1363,19 +1362,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1694,44 +1680,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -1793,12 +1741,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1 -2
View File
@@ -11,8 +11,7 @@
"dependencies": {
"@tanstack/react-query": "^5.90.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.0"
"react-dom": "^19.2.0"
},
"devDependencies": {
"@playwright/test": "1.61.1",
+79 -22
View File
@@ -5,11 +5,11 @@ import {
useState,
type ReactNode,
} from "react";
import { useLocation, useNavigate, useNavigationType } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { urlForPath, urlForView, type Route } from "../router";
import { currentNavType, navigate, useLocationPath } from "../nav";
import { uploadFile } from "../upload";
import { copyText } from "../util";
import { toast } from "../toast";
@@ -20,6 +20,9 @@ import { FolderListing } from "../components/FolderListing";
import { FileView } from "../components/FileView";
import { ShareDialog } from "../components/ShareDialog";
import { Palette, type PaletteItem } from "../components/Palette";
import { ConnectGuide } from "../components/ConnectGuide";
import { Insights, useInsightsDevices } from "../components/Insights";
import { HistoryView, historyTitle } from "../components/HistoryView";
// The browsing surface shared by hub projects and single-volume mode: the
// file tree, folder listings, file views, and every topbar action. Sidebar
@@ -34,16 +37,21 @@ export default function Browser(props: {
projects?: Project[];
canInsights?: boolean;
sidebar: { vault: ReactNode; projectsNav?: ReactNode; orgBar?: ReactNode };
home?: ReactNode; // hub landing view (project home); default prompt otherwise
}) {
const { config, apiBase, route, hub, project } = props;
const navigate = useNavigate();
const location = useLocation();
const navType = useNavigationType();
const routeKey = useLocationPath(); // scroll memo key, one slot per URL
const qc = useQueryClient();
const { tree, flatFiles, dirIndex, loaded } = useTree(apiBase, !hub || !!project);
const heatMap = useHeat(apiBase, hub && !!project && !!config.reads?.enabled);
// Insights data: the per-device breakdown, plus a fresh heat fetch when
// an insights surface opens (the ambient heat cache may be a minute old).
const isHome = hub && !!project && !route.path && !route.view;
const insightsOpen = !!props.canInsights && (route.view === "insights" || isHome);
const devices = useInsightsDevices(apiBase, insightsOpen);
useEffect(() => {
if (insightsOpen) qc.invalidateQueries({ queryKey: ["heat", apiBase] });
}, [insightsOpen, apiBase, qc]);
const path = route.path;
const isDir = !!path && dirIndex.has(path);
@@ -92,21 +100,21 @@ export default function Browser(props: {
const scrollGoal = useRef({ key: "", want: 0, attempts: 0 });
useEffect(() => {
scrollGoal.current = {
key: location.key,
want: navType === "POP" ? (memo.current.get(location.key) ?? 0) : 0,
key: routeKey,
want: currentNavType() === "POP" ? (memo.current.get(routeKey) ?? 0) : 0,
attempts: 0,
};
}, [location.key, navType]);
}, [routeKey]);
const onRendered = useCallback(() => {
const c = contentRef.current;
const g = scrollGoal.current;
if (!c || g.key !== location.key || g.attempts >= 3) return;
if (!c || g.key !== routeKey || g.attempts >= 3) return;
g.attempts++;
c.scrollTo({ top: g.want, behavior: "instant" });
}, [location.key]);
}, [routeKey]);
const onScroll = useCallback(() => {
if (contentRef.current) memo.current.set(location.key, contentRef.current.scrollTop);
}, [location.key]);
if (contentRef.current) memo.current.set(routeKey, contentRef.current.scrollTop);
}, [routeKey]);
/* ---- navigation ---- */
const openPath = useCallback(
@@ -114,11 +122,11 @@ export default function Browser(props: {
navigate(urlForPath(p, project?.id));
closeSidebarOnMobile();
},
[navigate, project?.id],
[project?.id],
);
const openHistory = useCallback(
(target: string) => navigate(urlForView("history", project?.id, target)),
[navigate, project?.id],
[project?.id],
);
/* ---- topbar state + actions ---- */
@@ -183,7 +191,7 @@ export default function Browser(props: {
useEffect(() => {
// Any navigation clears a stale upload status from the meta slot.
setUploadStatus("");
}, [location.key]);
}, [routeKey]);
/* ---- ⌘K palette ---- */
useEffect(() => {
@@ -221,7 +229,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, canUpload, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, uploadNow, openHistory, openPath, navigate]);
}, [hub, project, path, isFile, canUpload, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, uploadNow, openHistory, openPath]);
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
useEffect(() => {
@@ -232,12 +240,35 @@ export default function Browser(props: {
}, [moreOpen]);
/* ---- content view ---- */
const isFolderFn = useCallback((p: string) => dirIndex.has(p), [dirIndex]);
let contentClass = "markdown";
let view: ReactNode;
if (route.view === "insights" || route.view === "history") {
// Phase 3 delivers these views.
if (route.view === "insights") {
contentClass = "view";
view = <div className="empty">{route.view} view is on its way.</div>;
view = props.canInsights ? (
<Insights
flatFiles={flatFiles}
heatMap={heatMap}
devices={devices}
onOpenFile={openPath}
onOpenFolder={openPath}
isFolder={isFolderFn}
/>
) : (
<div className="empty">Insights is for hub admins and org owners.</div>
);
} else if (route.view === "history") {
contentClass = "view";
view = (
<HistoryView
apiBase={apiBase}
target={route.viewTarget || ""}
isFolder={isFolderFn}
onOpen={openPath}
onMeta={setMeta}
onRendered={onRendered}
/>
);
} else if (path) {
if (!loaded) {
view = <div className="empty">Loading…</div>;
@@ -267,14 +298,40 @@ export default function Browser(props: {
/>
);
}
} else if (props.home) {
} else if (isHome) {
// The project's index page: the connect-an-agent guide, with Insights
// below for admins/owners.
contentClass = "view";
view = props.home;
view = (
<>
<ConnectGuide project={project!} />
{props.canInsights && (
<div className="home-insights">
<Insights
flatFiles={flatFiles}
heatMap={heatMap}
devices={devices}
onOpenFile={openPath}
onOpenFolder={openPath}
isFolder={isFolderFn}
/>
</div>
)}
</>
);
} else {
view = <div className="empty">Select a file to read it.</div>;
}
const crumb = path ? <Breadcrumbs path={path} onOpenFolder={openPath} /> : null;
const crumb = path ? (
<Breadcrumbs path={path} onOpenFolder={openPath} />
) : route.view === "insights" ? (
"Insights — " + (project?.name ?? "")
) : route.view === "history" ? (
"History — " + historyTitle(route.viewTarget || "", isFolderFn)
) : isHome ? (
project!.name
) : null;
const topbar = (
<Topbar
+6 -10
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { postJSON } from "../api/http";
import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../api/types";
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
import { parseRoute } from "../router";
import { navigate, Redirect, useLocationPath } from "../nav";
import { AppShell, Topbar, VaultHeader } from "../components/shell";
import { ProjectNav } from "../components/ProjectNav";
import { OrgBar } from "../components/OrgBar";
@@ -12,27 +12,23 @@ import { toast } from "../toast";
import Browser from "./Browser";
export default function HubApp({ config }: { config: ServerConfig }) {
const location = useLocation();
const navigate = useNavigate();
const pathname = useLocationPath();
const refresh = useHubRefresh();
// Org just joined via an invite this page-load: prefer its projects over
// whatever happens to be first in the list.
const [joinedOrgId, setJoinedOrgId] = useState<string | null>(null);
const joinToken = useMemo(() => {
const m = location.pathname.match(/^\/join\/([0-9a-f]+)\/?$/);
const m = pathname.match(/^\/join\/([0-9a-f]+)\/?$/);
return m ? m[1] : null;
}, [location.pathname]);
}, [pathname]);
const { data: projects } = useProjects(!joinToken);
const { data: orgs } = useOrgs(!joinToken);
const isAdmin = !!config.auth.admin;
const { data: pending } = usePending(isAdmin);
const route = useMemo(
() => parseRoute(location.pathname, "hub"),
[location.pathname],
);
const route = useMemo(() => parseRoute(pathname, "hub"), [pathname]);
const current: Project | null = useMemo(() => {
if (!projects) return null;
@@ -124,7 +120,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
// Landing ("/") and unknown project ids both resolve to a real project
// URL; replace so back/forward never bounces through the redirect.
if (route.project !== current.id) {
return <Navigate to={"/" + current.id} replace />;
return <Redirect to={"/" + current.id} />;
}
return (
@@ -1,22 +1,19 @@
import { useEffect, useMemo } from "react";
import { useLocation } from "react-router-dom";
import type { ServerConfig } from "../api/types";
import { VaultHeader } from "../components/shell";
import { parseRoute } from "../router";
import { useLocationPath } from "../nav";
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 location = useLocation();
const pathname = useLocationPath();
const name = config.volume || "BearDrive";
useEffect(() => {
document.title = config.brand || name;
}, [config, name]);
const route = useMemo(
() => parseRoute(location.pathname, "volume"),
[location.pathname],
);
const route = useMemo(() => parseRoute(pathname, "volume"), [pathname]);
return (
<Browser
@@ -0,0 +1,182 @@
import { useState } from "react";
import type { Project } from "../api/types";
import { copyText } from "../util";
/* ---- project home guide ----
How to mount the project as a local folder and connect a coding agent,
with real, copyable commands: this hub's URL and this project's id
filled in. */
interface GuideAgent {
key: string;
label: string;
hook?: string;
note?: string;
extra?: string;
}
const GUIDE_AGENTS: GuideAgent[] = [
{ key: "claude", label: "Claude Code & Cowork" },
{
key: "hermes",
label: "Hermes",
hook: "hermes",
note:
"Registers BearDrive's hooks in Hermes's config: pull before every turn, push after edits " +
"with a session note, and report file reads to Insights.",
},
{
key: "codex",
label: "Codex",
hook: "codex",
note: "Registers hooks in .codex/hooks.json.",
extra:
"Run /hooks inside Codex once to trust the project's .codex layer — after that every turn " +
"pulls, edits push automatically, and reads are reported to Insights.",
},
];
interface Step {
title: string;
desc?: string;
code?: string;
extra?: string;
}
function guideSteps(agent: GuideAgent, project: Project): Step[] {
const origin = window.location.origin;
const pid = project.id;
// Claude Code and Claude Cowork share one plugin: install it once, then
// /beardrive:install does the whole setup conversationally.
if (agent.key === "claude") {
return [
{
title: "Add the BearDrive plugin",
desc:
"One time, in any Claude Code session. The plugin ships the beardrive skill, the /beardrive " +
"commands, and turn-boundary sync hooks — and Claude Cowork shares the same plugins, so " +
"installing it once covers both.",
code: "/plugin marketplace add runbear-io/beardrive\n/plugin install beardrive@beardrive",
},
{
title: "Set up this project conversationally",
desc: "In a Claude Code or Cowork session in the folder where you want the files, run:",
code: "/beardrive:install connect to " + origin + ", project " + pid,
extra:
"Claude installs the CLI, signs this machine in, mounts the project, and registers the " +
"sync hooks — pull the latest before every turn, push after edits (stamped with the session " +
"that made them), and report file reads to Insights. It asks before anything it changes.",
},
];
}
const slug =
(project.name || "project").toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "project";
return [
{
title: "Install the BearDrive CLI",
desc: "One static binary. Homebrew on macOS and Linux; releases and `go install` also work.",
code: "brew install runbear-io/tap/beardrive",
},
{
title: "Sign in to this hub",
desc:
"Opens the browser once and stores a device token on this machine — the synced folder itself never holds credentials.",
code: "bdrive login " + origin,
},
{
title: "Mount the project into a local folder",
desc:
"Run it where you want the files. An existing folder works too — contents merge, and re-running init later (or after moving the folder) just resumes.",
code: "mkdir -p ~/" + slug + " && cd ~/" + slug + "\nbdrive init --project " + pid,
},
{
title: "Connect " + agent.label,
desc: agent.note,
code: "bdrive hooks install --agent " + agent.hook,
extra: agent.extra,
},
];
}
function savedAgent(): string {
try {
return localStorage.getItem("bdrive-guide-agent") || "claude";
} catch {
return "claude"; // private mode
}
}
export function ConnectGuide({ project }: { project: Project }) {
const [agentKey, setAgentKey] = useState(savedAgent);
// A stale saved key (e.g. a tab that no longer exists) falls back to the
// first tab rather than rendering nothing.
const agent = GUIDE_AGENTS.find((a) => a.key === agentKey) || GUIDE_AGENTS[0];
return (
<div className="guide">
<h1 className="in-title">{project.name}</h1>
<p className="dl-sub">
Mount this project as a folder on any machine and connect your coding agent: files sync both
ways in the background, every change is journaled with who made it, and agent reads feed
Insights.
</p>
<div className="gd-tabs">
{GUIDE_AGENTS.map((a) => (
<button
key={a.key}
className={"gd-tab" + (a.key === agent.key ? " active" : "")}
data-key={a.key}
onClick={() => {
setAgentKey(a.key);
try {
localStorage.setItem("bdrive-guide-agent", a.key);
} catch {
/* private mode */
}
}}
>
{a.label}
</button>
))}
</div>
<div className="gd-body">
{guideSteps(agent, project).map((s, i) => (
<div className="gd-step" key={i}>
<div className="gd-step-head">
<span className="gd-num">{i + 1}</span>
<span className="gd-step-title">{s.title}</span>
</div>
{s.desc && <p className="gd-desc">{s.desc}</p>}
{s.code && <GuideCode code={s.code} />}
{s.extra && <p className="gd-desc gd-extra">{s.extra}</p>}
</div>
))}
<p className="gd-done">
That's it — the folder now syncs on its own. Every agent turn starts from the latest
state, edits appear here (and on every teammate's mount) within seconds, and what your
agents read shows up in Insights.
</p>
</div>
</div>
);
}
function GuideCode({ code }: { code: string }) {
const [label, setLabel] = useState("Copy");
return (
<pre className="gd-code">
<code>{code}</code>
<button
className="gd-copy"
onClick={async () => {
setLabel((await copyText(code)) ? "Copied" : "Copy failed");
setTimeout(() => setLabel("Copy"), 1400);
}}
>
{label}
</button>
</pre>
);
}
@@ -0,0 +1,59 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HistoryEntry } from "../api/types";
import { HistoryRow } from "./HistoryRow";
/* ---- history ----
Every change ever made, straight from the journals: who (account), when,
from which device (name, OS, IP as the server saw it). The route stores
one target; the tree says whether it is a folder (subtree feed) or a
file (version list). */
export function HistoryView(props: {
apiBase: string;
target: string; // "" = whole project
isFolder: (p: string) => boolean;
onOpen: (path: string) => void;
onMeta: (meta: string) => void;
onRendered?: () => void;
}) {
const { apiBase, target, isFolder, onMeta, onRendered } = props;
const q = !target
? { prefix: "" }
: isFolder(target)
? { prefix: target + "/" }
: { path: target };
const qs =
"path" in q && q.path !== undefined
? "path=" + encodeURIComponent(q.path)
: "prefix=" + encodeURIComponent(q.prefix ?? "");
const { data, error } = useQuery({
queryKey: ["history", apiBase, qs, 200],
queryFn: () => getJSON<{ entries: HistoryEntry[] }>(apiBase + "history?" + qs + "&n=200"),
staleTime: 15_000,
});
useEffect(() => {
if (error) onMeta("History unavailable: " + (error as Error).message);
}, [error, onMeta]);
useEffect(() => {
if (data) onRendered?.();
}, [data, onRendered]);
if (!data) return null;
const entries = data.entries || [];
return (
<div className="history">
{entries.length === 0 && <div className="empty">No history yet.</div>}
{entries.map((e, i) => (
<HistoryRow key={i} entry={e} onOpen={props.onOpen} />
))}
</div>
);
}
// The crumb title for a history route target.
export function historyTitle(target: string, isFolder: (p: string) => boolean): string {
if (!target) return "all changes";
return isFolder(target) ? target + "/ (folder)" : target;
}
@@ -0,0 +1,492 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node } from "../api/types";
import { heatTotal } from "../hooks/useBrowse";
/* ---- insights: the read×write matrix ----
Every file plotted by how much it is read (30 days, from the heat API)
against how long since it last changed (from the tree). The hot-but-stale
quadrant is the danger zone: knowledge people still rely on that nobody
maintains. Admin/org-owner only — members get the ambient heat dots. */
const HOT_READS = 3; // ≥ this many reads/30d = hot
const STALE_DAYS = 30; // ≥ this many days since last write = stale
interface DeviceHeat {
id?: string;
name?: string;
folders?: Record<string, number>;
}
// /heat?by=device breakdown; older servers lack it — the coverage section
// simply doesn't render.
export function useInsightsDevices(apiBase: string, enabled: boolean) {
const q = useQuery({
queryKey: ["heatDevices", apiBase],
queryFn: () =>
getJSON<{ devices: DeviceHeat[] }>(apiBase + "heat?by=device&days=30"),
enabled,
retry: false,
staleTime: 60_000,
});
return q.data?.devices ?? null;
}
interface Pt {
path: string;
reads: number;
agent: number;
total: number;
days: number;
danger: boolean;
}
type Lens = "all" | "human" | "agent";
export function Insights(props: {
flatFiles: Node[];
heatMap: HeatMap | null;
devices: DeviceHeat[] | null;
onOpenFile: (path: string) => void;
onOpenFolder: (path: string) => void;
isFolder: (path: string) => boolean;
}) {
const [lens, setLens] = useState<Lens>("all");
const { flatFiles, heatMap, devices } = props;
const now = Date.now();
const pts: Pt[] = flatFiles.map((f) => {
const e = (heatMap && heatMap[f.path]) || {};
const days = f.time ? Math.max(0, (now - new Date(f.time).getTime()) / 86400000) : 0;
const reads = lens === "all" ? heatTotal(e) : e[lens] || 0;
return {
path: f.path,
reads,
agent: e.agent || 0,
total: heatTotal(e),
days,
danger: reads >= HOT_READS && days >= STALE_DAYS,
};
});
return (
<div className="insights">
<h1 className="in-title">Knowledge insights</h1>
<p className="dl-sub">
Reads over the last 30 days × how long since each file changed. Hot but stale knowledge —
read a lot, maintained by nobody — is the danger zone.
</p>
<div className="in-lens">
{(["all", "human", "agent"] as const).map((l) => (
<button
key={l}
className={"in-lens-btn" + (l === lens ? " active" : "")}
onClick={() => setLens(l)}
>
{l === "all" ? "All reads" : l === "human" ? "Human reads" : "Agent reads"}
</button>
))}
</div>
<h3 className="dl-h3">Map — cell size = reads, color = freshness</h3>
<Treemap pts={pts} onOpenFile={props.onOpenFile} onOpenFolder={props.onOpenFolder} isFolder={props.isFolder} />
<h3 className="dl-h3">Reads × freshness</h3>
<Scatter pts={pts} onOpenFile={props.onOpenFile} />
<h3 className="dl-h3">Hot path — top files by reads</h3>
<HotPath pts={pts} lens={lens} onOpenFile={props.onOpenFile} />
{devices && devices.length > 0 && (
<>
<h3 className="dl-h3">Agent coverage — which agents read which areas</h3>
<CoverageMatrix devices={devices} />
</>
)}
</div>
);
}
/* Staleness color: fresh green → amber → red over 0..300 days. */
function staleColor(days: number): string {
const stops = [
[76, 195, 138],
[232, 196, 84],
[224, 93, 93],
];
const t = Math.min(1, Math.max(0, days / 300)) * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(t)),
f = t - i;
const c = stops[i].map((v, k) => Math.round(v + (stops[i + 1][k] - v) * f));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
/* Squarified treemap (Bruls et al.), dependency-free: items sorted by value
fill a rect in rows along the shorter side, keeping cells near-square. */
interface TmItem {
value: number;
}
function squarify<T extends TmItem>(
items: T[],
x: number,
y: number,
w: number,
h: number,
): Array<{ item: T; x: number; y: number; w: number; h: number }> {
const total = items.reduce((s, it) => s + it.value, 0);
if (!total || w <= 0 || h <= 0) return [];
const rest = items
.slice()
.sort((a, b) => b.value - a.value)
.map((it) => ({ it, a: (it.value / total) * w * h }));
const worst = (row: Array<{ a: number }>, side: number) => {
const sum = row.reduce((t, r) => t + r.a, 0);
const d = sum / side;
let m = 0;
for (const r of row) {
const l = r.a / d;
m = Math.max(m, l / d, d / l);
}
return m;
};
const out: Array<{ item: T; x: number; y: number; w: number; h: number }> = [];
while (rest.length) {
const horiz = w >= h; // row = a strip along the shorter side
const side = horiz ? h : w;
const row = [rest.shift()!];
while (rest.length && worst(row.concat(rest[0]), side) <= worst(row, side)) {
row.push(rest.shift()!);
}
const d = row.reduce((t, r) => t + r.a, 0) / side;
let off = 0;
for (const r of row) {
const l = r.a / d;
if (horiz) out.push({ item: r.it, x, y: y + off, w: d, h: l });
else out.push({ item: r.it, x: x + off, y, w: l, h: d });
off += l;
}
if (horiz) {
x += d;
w -= d;
} else {
y += d;
h -= d;
}
}
return out;
}
const TM_HEADER = 15; // group label strip height
function Treemap({
pts,
onOpenFile,
onOpenFolder,
isFolder,
}: {
pts: Pt[];
onOpenFile: (p: string) => void;
onOpenFolder: (p: string) => void;
isFolder: (p: string) => boolean;
}) {
const W = 720,
H = 480;
// Two levels: top-level folder groups, files within each.
const groups = new Map<string, { name: string; files: Pt[]; value: number }>();
for (const p of pts) {
const top = p.path.includes("/") ? p.path.split("/")[0] : "/";
let g = groups.get(top);
if (!g) groups.set(top, (g = { name: top, files: [], value: 0 }));
g.files.push(p);
g.value += p.reads + 1; // +1: unread files still occupy a sliver
}
const cells: React.ReactNode[] = [];
for (const gc of squarify([...groups.values()], 0, 0, W, H)) {
const g = gc.item;
const dir = g.name === "/" ? "" : g.name;
cells.push(
<rect
key={"g" + g.name}
x={gc.x + 1}
y={gc.y + 1}
width={Math.max(0, gc.w - 2)}
height={Math.max(0, gc.h - 2)}
rx={3}
className="in-tm-group"
data-dir={dir}
/>,
);
if (gc.w > 46 && gc.h > TM_HEADER + 10) {
let label = g.name === "/" ? "(root)" : g.name;
const fit = Math.floor((gc.w - 8) / 6);
if (label.length > fit) label = label.slice(0, Math.max(1, fit - 1)) + "…";
cells.push(
<text key={"gl" + g.name} x={gc.x + 5} y={gc.y + 12} className="in-tm-glabel" data-dir={dir}>
{label}
</text>,
);
}
const fcells = squarify(
g.files.map((f) => ({ ...f, name: f.path.split("/").pop()!, value: f.reads + 1 })),
gc.x + 2,
gc.y + TM_HEADER,
Math.max(0, gc.w - 4),
Math.max(0, gc.h - TM_HEADER - 2),
);
for (const c of fcells) {
cells.push(
<rect
key={c.item.path}
x={c.x + 0.6}
y={c.y + 0.6}
width={Math.max(0.4, c.w - 1.2)}
height={Math.max(0.4, c.h - 1.2)}
rx={1.5}
fill={staleColor(c.item.days)}
className="in-tm-cell"
data-path={c.item.path}
>
<title>
{`${c.item.path} — ${c.item.reads} read${c.item.reads === 1 ? "" : "s"}/30d · changed ${Math.round(c.item.days)}d ago`}
</title>
</rect>,
);
if (c.w > 54 && c.h > 16) {
const fit = Math.floor((c.w - 8) / 6);
let label = (c.item.danger ? "⚠ " : "") + c.item.name;
if (label.length > fit) label = label.slice(0, Math.max(1, fit - 1)) + "…";
if (fit >= 5)
cells.push(
<text key={"l" + c.item.path} x={c.x + 4.5} y={c.y + 12.5} className="in-tm-label" data-path={c.item.path}>
{label}
</text>,
);
}
}
}
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="in-chart in-treemap"
onClick={(e) => {
// One delegated click handler for thousands of cells.
const t = (e.target as Element).closest("[data-path], [data-dir]");
if (!t) return;
const path = t.getAttribute("data-path");
if (path) return onOpenFile(path);
const dir = t.getAttribute("data-dir");
if (dir && isFolder(dir)) onOpenFolder(dir);
}}
>
{cells}
</svg>
);
}
/* Dependency-free SVG scatter: x = days since last write, y = reads, both
log-scaled; threshold lines split the quadrants. */
function Scatter({ pts, onOpenFile }: { pts: Pt[]; onOpenFile: (p: string) => void }) {
const W = 720,
H = 360,
M = { l: 44, r: 16, t: 20, b: 34 };
const maxDays = Math.max(STALE_DAYS * 2, ...pts.map((p) => p.days));
const maxReads = Math.max(HOT_READS * 2, ...pts.map((p) => p.reads));
const lx = (d: number) => Math.log10(d + 1) / Math.log10(maxDays + 1);
const ly = (r: number) => Math.log10(r + 1) / Math.log10(maxReads + 1);
const X = (d: number) => M.l + lx(d) * (W - M.l - M.r);
const Y = (r: number) => H - M.b - ly(r) * (H - M.t - M.b);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="in-chart">
<rect
x={X(STALE_DAYS)}
y={M.t}
width={W - M.r - X(STALE_DAYS)}
height={Y(HOT_READS) - M.t}
className="in-danger-zone"
/>
<line x1={X(STALE_DAYS)} y1={M.t} x2={X(STALE_DAYS)} y2={H - M.b} className="in-threshold" />
<line x1={M.l} y1={Y(HOT_READS)} x2={W - M.r} y2={Y(HOT_READS)} className="in-threshold" />
<line x1={M.l} y1={H - M.b} x2={W - M.r} y2={H - M.b} className="in-axis" />
<line x1={M.l} y1={M.t} x2={M.l} y2={H - M.b} className="in-axis" />
<text x={(M.l + W - M.r) / 2} y={H - 8} className="in-label">
days since last change →
</text>
<text
x={12}
y={(M.t + H - M.b) / 2}
className="in-label"
transform={`rotate(-90 12 ${(M.t + H - M.b) / 2})`}
>
reads / 30d →
</text>
<text x={W - M.r - 6} y={M.t + 14} className="in-quad in-quad-danger" textAnchor="end">
hot + stale
</text>
<text x={M.l + 6} y={M.t + 14} className="in-quad">
hot + fresh
</text>
<text x={W - M.r - 6} y={H - M.b - 8} className="in-quad" textAnchor="end">
cold + stale
</text>
<text x={W - M.r - 6} y={M.t + 28} className="in-label" textAnchor="end">
dot size = agent share of reads
</text>
{pts.map((p) => {
// Radius encodes the agent share of the file's reads; translucent
// dots keep the cloud readable at hundreds of files.
const share = p.total ? (p.agent || 0) / p.total : 0;
return (
<circle
key={p.path}
cx={Number(X(p.days).toFixed(1))}
cy={Number(Y(p.reads).toFixed(1))}
r={Number((3 + 4 * share).toFixed(1))}
className={"in-pt" + (p.danger ? " danger" : p.reads ? "" : " cold")}
onClick={() => onOpenFile(p.path)}
>
<title>
{`${p.path} — ${p.reads} read${p.reads === 1 ? "" : "s"} / 30d · changed ${Math.round(p.days)}d ago`}
</title>
</circle>
);
})}
</svg>
);
}
/* ---- hot path: top-20 files by reads, agent/human split ---- */
function HotPath({
pts,
lens,
onOpenFile,
}: {
pts: Pt[];
lens: Lens;
onOpenFile: (p: string) => void;
}) {
const top = pts
.filter((p) => p.reads > 0)
.sort((a, b) => b.reads - a.reads || b.days - a.days)
.slice(0, 20);
if (!top.length) return <div className="dl-empty">No reads in the window yet.</div>;
const max = top[0].reads;
return (
<>
<div className="in-hotpath">
{top.map((p) => {
// Split of the lens reads: pure lenses are single-color by definition.
const aFrac = lens === "agent" ? 1 : lens === "human" ? 0 : p.total ? p.agent / p.total : 0;
const pct = (p.reads / max) * 100;
return (
<div
key={p.path}
className="in-hp-row"
tabIndex={0}
role="button"
title={
p.danger
? `${p.reads} read${p.reads === 1 ? "" : "s"}/30d · unchanged ${Math.round(p.days)}d — review this file`
: p.path
}
onClick={() => onOpenFile(p.path)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenFile(p.path);
}
}}
>
<span className={"in-hp-name" + (p.danger ? " danger" : "")}>
{p.path + (p.danger ? " ⚠" : "")}
</span>
<span className="in-hp-bar">
<span className="in-hp-agent" style={{ width: (pct * aFrac).toFixed(1) + "%" }} />
<span className="in-hp-human" style={{ width: (pct * (1 - aFrac)).toFixed(1) + "%" }} />
</span>
<span className="in-hp-count">{p.reads}</span>
</div>
);
})}
</div>
<p className="in-legend">
<span className="in-sw agent" /> agent reads <span className="in-sw human" /> human reads
</p>
</>
);
}
/* ---- agent coverage matrix: devices × top-level folders ---- */
function CoverageMatrix({ devices }: { devices: DeviceHeat[] }) {
const totals = new Map<string, number>();
for (const d of devices) {
for (const [f, n] of Object.entries(d.folders || {})) totals.set(f, (totals.get(f) || 0) + n);
}
const cols = [...totals.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map((e) => e[0]);
const rows = devices.slice(0, 12); // server sorts by total desc
const left = 140,
top = 6,
cw = Math.min(76, Math.max(34, (720 - left - 8) / cols.length)),
ch = 26;
const W = 720,
H = top + rows.length * ch + 58;
const max = Math.max(1, ...rows.flatMap((d) => cols.map((c) => (d.folders || {})[c] || 0)));
const shade = (t: number) => {
// #17191f → amber by intensity
const a = [23, 25, 31],
b = [245, 166, 35];
const c = a.map((v, i) => Math.round(v + (b[i] - v) * t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="in-chart in-matrix">
{rows.map((d, i) => {
let label = d.name || d.id || "";
if (label.length > 20) label = label.slice(0, 19) + "…";
return (
<g key={d.id || i}>
<text x={left - 8} y={top + i * ch + 17} textAnchor="end" className="in-label">
{label}
</text>
{cols.map((c, j) => {
const v = (d.folders || {})[c] || 0;
return (
<rect
key={c}
x={left + j * cw}
y={top + i * ch}
width={cw - 4}
height={ch - 4}
rx={3}
fill={shade(Math.sqrt(v / max))}
>
<title>{`${d.name || d.id} × ${c || "(root)"}: ${v} read${v === 1 ? "" : "s"}/30d`}</title>
</rect>
);
})}
</g>
);
})}
{cols.map((c, j) => {
const cx = left + j * cw + (cw - 4) / 2,
cy = top + rows.length * ch + 14;
return (
<text
key={c}
x={cx}
y={cy}
className="in-label"
textAnchor="end"
transform={`rotate(-28 ${cx} ${cy})`}
>
{c || "(root)"}
</text>
);
})}
</svg>
);
}
@@ -1,4 +1,4 @@
import { useNavigate } from "react-router-dom";
import { navigate } from "../nav";
import { postJSON } from "../api/http";
import type { Project, ProjectCreated } from "../api/types";
import { modalPrompt } from "../modal";
@@ -16,7 +16,6 @@ export function projColor(s: string): string {
}
export function ProjectNav({ projects, currentId }: { projects: Project[]; currentId?: string }) {
const navigate = useNavigate();
const refresh = useHubRefresh();
const create = async () => {
+1 -4
View File
@@ -1,6 +1,5 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./style.css";
@@ -14,9 +13,7 @@ const queryClient = new QueryClient({
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
<App />
</QueryClientProvider>
</StrictMode>,
);
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useSyncExternalStore } from "react";
// Minimal synchronous history router. React Router v7 wraps navigation in
// React.startTransition, which can leave the old view on screen for
// seconds after the URL changes (the transition gets starved by query
// updates) — the classic app's routing was synchronous, and parity needs
// it to stay that way. This is the whole router: pushState/replaceState +
// popstate, delivered through useSyncExternalStore.
export type NavType = "PUSH" | "REPLACE" | "POP";
let navType: NavType = "POP";
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
window.addEventListener("popstate", () => {
navType = "POP";
emit();
});
export function navigate(url: string, opts?: { replace?: boolean }) {
// Skip a no-op push that would just stack a duplicate history entry
// (e.g. when boot opens the file already in the URL).
const cur = location.pathname + location.search;
if (!opts?.replace && cur === url) return;
history[opts?.replace ? "replaceState" : "pushState"](null, "", url);
navType = opts?.replace ? "REPLACE" : "PUSH";
emit();
}
export function useLocationPath(): string {
return useSyncExternalStore(
(l) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
() => location.pathname,
);
}
// How the current location was reached — POP means back/forward, which is
// what scroll restoration keys off.
export function currentNavType(): NavType {
return navType;
}
// Render-time redirect (the declarative <Navigate replace> equivalent).
export function Redirect({ to }: { to: string }) {
useEffect(() => {
navigate(to, { replace: true });
}, [to]);
return null;
}
File diff suppressed because one or more lines are too long
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 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-DAT78awr.js"></script>
<script type="module" crossorigin src="/assets/index-D-n_MmWw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oVhdizP9.css">
</head>
<body>