mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
2db399a766
commit
44b95143b0
+28
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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'>🐻</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>
|
||||
|
||||
Reference in New Issue
Block a user