feat(webapp): [phase 2] file browsing — tree, listings, files, upload, share, palette

- FileTree with fold state, lone-root auto-open, reveal-on-deep-link;
  folder listings with heat dots and the journals-backed change feed
- FileView: markdown (HTML transformed BEFORE render, link clicks
  delegated — React re-applies dangerouslySetInnerHTML markup on
  unrelated updates, so post-commit DOM patching loses handlers),
  images, text, download card
- breadcrumbs, per-route scroll restoration (location.key memo)
- topbar actions: share dialog (mint/copy/open/revoke), history/upload/
  download buttons, ⋯ overflow menu; upload via upload/init direct or
  relay path, then tree refresh + open
- ⌘K command palette: fuzzy files/projects/actions with stemming
- e2e: 11 new browse specs (22 total green in ~12s); helpers cache one
  session cookie per identity to stay under the 10/min auth rate limit

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:31:01 -07:00
co-authored by Claude Fable 5
parent 1c4e178b39
commit 2db399a766
23 changed files with 1646 additions and 53 deletions
+22 -11
View File
@@ -137,16 +137,27 @@ refresh; invalidate after uploads/renames/admin actions — mirror today's
or the unknown-id fallback bounces off the stale list.
### Phase 2 — file browsing (long pole)
- [ ] Tree with expansion persistence, active marking, reveal-in-tree.
- [ ] Folder listing incl. heat dots (members) + folder history strip.
- [ ] File view: server-rendered markdown injected; wikilinks + relative
links rewritten (`fixLinks` semantics); meta/provenance line.
- [ ] Breadcrumbs; per-route scroll restoration.
- [ ] Download + raw file view; share button state; share dialog.
- [ ] Drag-drop upload (presign and relay paths) with refresh after commit.
- [ ] Command palette (⌘K: file names, projects, actions — port the
palette overlay from the old shell) and the topbar overflow menu
(`more-btn`/`more-menu`) for narrow viewports.
- [x] Tree with expansion persistence, active marking, reveal-in-tree
(deep links unfold the way to the file — e2e covered).
- [x] Folder listing incl. heat dots (members) + folder history strip.
- [x] File view: server-rendered markdown; wikilinks + relative links;
meta/provenance line. LESSON (do not regress): never patch the
dangerouslySetInnerHTML subtree after commit (the classic fixLinks
approach) — React re-applies the markup on unrelated updates and
silently discards DOM patches. Instead: transform the HTML string
before rendering (img src, target=_blank) and handle link clicks by
delegation on the container (`FileView.tsx`).
- [x] Breadcrumbs; per-route scroll restoration (location.key memo,
restore on POP, re-applied as async sections grow).
- [x] Download + raw file view; share button state; share dialog (e2e:
mint → public fetch → revoke → 404).
- [x] Upload via the topbar button + file picker (direct/relay per
upload/init) with tree refresh + open after commit.
- [x] Command palette (⌘K fuzzy files/projects/actions) and the topbar
overflow menu.
Harness note: helpers.login caches one session cookie per identity —
the server rate-limits credential POSTs (10/min/IP) and per-spec
form logins trip it with flaky timeouts.
### Phase 3 — project home, insights, history
- [ ] Project home at `/<pid>`: connect guide (3 tabs: "Claude Code &
@@ -226,7 +237,7 @@ file path; reload on `/insights`.
- [x] Phase 0
- [x] Phase 1
- [ ] Phase 2
- [x] Phase 2
- [ ] Phase 3
- [ ] Phase 4
- [ ] Phase 5
+129
View File
@@ -0,0 +1,129 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
// Phase 2: tree, folder listings (heat dots + change feed), file views
// (markdown/wikilinks/images), breadcrumbs, upload, share, palette.
test("tree lists the seeded folders and files", async ({ page }) => {
await login(page);
await expect(page.locator('#tree .row[data-path="notes"]')).toBeVisible();
await expect(page.locator('#tree .row[data-path="index.md"]')).toBeVisible();
await expect(page.locator('#tree .row[data-path="guide.md"]')).toBeVisible();
});
test("markdown file: rendered content, crumb, meta, download + share buttons", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click('#tree .row[data-path="index.md"]');
await page.waitForURL(`/${pid}/index.md`);
await expect(page.locator("#content h1")).toHaveText("Wiki");
await expect(page.locator("#crumb")).toContainText("index.md");
await expect(page.locator("#meta")).toContainText("alice@x.io");
await expect(page.locator("#download")).toBeVisible();
await expect(page.locator("#share-btn")).toBeVisible();
});
test("wikilink navigates to the target file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/index.md`);
await page.click('#content a:has-text("guide")');
await page.waitForURL(`/${pid}/guide.md`);
await expect(page.locator("#content")).toContainText("Second version");
});
test("folder listing: counts, change feed, heat dot on a read file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.click('#tree .row[data-path="notes"]');
await page.waitForURL(`/${pid}/notes`);
await expect(page.locator(".dl-title")).toContainText("notes");
await expect(page.locator(".dl-sub")).toContainText("1 folder");
await expect(page.locator(".dl-sub")).toContainText("1 file");
await expect(page.locator(".dl-history .dl-h3")).toHaveText("Recent changes");
await expect(page.locator(".dl-history .hentry").first()).toBeVisible();
// notes/readme.md has seeded agent reads → a heat dot on its row
await expect(page.locator('.dl-row[title="notes/readme.md"] .heatdot')).toBeVisible();
});
test("image file renders an <img>", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/assets/logo.png`);
await expect(page.locator("#content img")).toBeVisible();
});
test("breadcrumb ancestor opens that folder", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes/deep/topic.md`);
await expect(page.locator("#content h1")).toHaveText("Topic");
await page.click('#crumb .crumb-seg[title="notes"]');
await page.waitForURL(`/${pid}/notes`);
await expect(page.locator(".dl-title")).toContainText("notes");
});
test("deep file link resolves after a hard reload", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes/readme.md`);
await expect(page.locator("#content h1")).toHaveText("Notes");
await page.reload();
await expect(page.locator("#content h1")).toHaveText("Notes");
// The tree unfolds the way to the deep-linked file
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toBeVisible();
});
test("back/forward walks file → folder → file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/index.md`);
await page.click('#tree .row[data-path="notes"]');
await page.waitForURL(`/${pid}/notes`);
await page.goBack();
await expect(page.locator("#content h1")).toHaveText("Wiki");
await page.goForward();
await expect(page.locator(".dl-title")).toContainText("notes");
});
test("palette (⌘K) fuzzy-jumps to a file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.keyboard.press("ControlOrMeta+k");
await expect(page.locator("#palette")).toBeVisible();
await page.fill("#palette-input", "topic");
await page.keyboard.press("Enter");
await page.waitForURL(`/${pid}/notes/deep/topic.md`);
await expect(page.locator("#content h1")).toHaveText("Topic");
});
test("share mints a public link that serves the file, revoke kills it", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/guide.md`);
await page.click("#share-btn");
const url = await page.locator(".modal-url").textContent();
expect(url).toContain("/s/");
const publicRes = await page.request.get(url!);
expect(publicRes.status()).toBe(200);
expect(await publicRes.text()).toContain("Second version");
await page.click(".modal .ai-del"); // revoke
await expect(page.locator("#toast")).toContainText("revoked");
const gone = await page.request.get(url!);
expect(gone.status()).toBe(404);
});
test("upload into the selected folder, then the file opens", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.locator("#upload-btn").waitFor();
await page.setInputFiles('input[type="file"]', {
name: "dropped.md",
mimeType: "text/markdown",
buffer: Buffer.from("# Dropped\n\nUploaded through the browser.\n"),
});
await page.waitForURL(`/${pid}/notes/dropped.md`);
await expect(page.locator("#content h1")).toHaveText("Dropped");
await expect(page.locator('#tree .row[data-path="notes/dropped.md"]')).toBeVisible();
});
+21 -2
View File
@@ -4,13 +4,32 @@ export const ADMIN = "e2e@example.com";
export const MEMBER = "member@example.com";
export const PASSWORD = "e2e-pass-1";
// Signs in through the server-rendered /auth pages and waits for the SPA
// shell to render.
// One real form login per identity per run, then the session cookie is
// reused — the server rate-limits credential POSTs to 10/min per IP, which
// a fresh login in every spec would trip.
type Cookies = Awaited<ReturnType<ReturnType<Page["context"]>["cookies"]>>;
const sessions = new Map<string, Cookies>();
// Signs in (through the server-rendered /auth pages on first use) and waits
// for the SPA shell to render.
export async function login(page: Page, email: string = ADMIN) {
const cached = sessions.get(email);
if (cached) {
await page.context().addCookies(cached);
await page.goto("/");
await page.waitForSelector("#sidebar");
return;
}
await page.goto("/");
await page.waitForURL(/auth\/login/);
await page.fill('input[name="email"]', email);
await page.fill('input[name="password"]', PASSWORD);
await page.click("form button");
await page.waitForSelector("#sidebar");
sessions.set(email, await page.context().cookies());
}
export async function wikiId(page: Page): Promise<string> {
const out = await (await page.request.get("/api/projects")).json();
return out.projects.find((p: { name: string }) => p.name === "wiki").id;
}
+2 -7
View File
@@ -1,15 +1,10 @@
import { test, expect, Page } from "@playwright/test";
import { login, MEMBER, PASSWORD } from "./helpers";
import { test, expect } from "@playwright/test";
import { login, wikiId, MEMBER, PASSWORD } from "./helpers";
// Phase 1: shell, session flags, project list/selection, routing, empty
// state, invite accept. Mutating specs (project creation) run last —
// specs share one seeded hub per run.
async function wikiId(page: Page): Promise<string> {
const out = await (await page.request.get("/api/projects")).json();
return out.projects.find((p: { name: string }) => p.name === "wiki").id;
}
test("landing selects the first project and rewrites the URL", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
+12
View File
@@ -16,6 +16,18 @@ export async function getJSON<T>(url: string): Promise<T> {
return r.json();
}
/* fetch wrapper for methods without a body-returning helper */
export async function api<T = unknown>(method: string, url: string, body?: unknown): Promise<T> {
const opt: RequestInit = { method };
if (body !== undefined) {
opt.headers = { "Content-Type": "application/json" };
opt.body = JSON.stringify(body);
}
const r = await fetch(url, opt);
if (!r.ok) throw new Error(await r.text());
return r.status === 204 ? ({} as T) : r.json();
}
export async function postJSON<T>(url: string, body?: unknown): Promise<T> {
const r = await fetch(url, {
method: "POST",
+67
View File
@@ -65,3 +65,70 @@ export interface InviteAccepted {
export interface PendingList {
pending: Array<{ id: string; email: string; name: string }>;
}
// GET .../tree (handleTree → Node, server.go)
export interface Node {
name: string;
path: string;
dir?: boolean;
size?: number;
time?: string;
author?: string;
device?: string;
children?: Node[];
}
// GET .../render (handleRender, server.go)
export interface RenderDoc {
path: string;
html: string;
size: number;
time?: string;
author?: string;
device?: string;
}
// GET .../heat (handleHeat, reads.go) — counts only, never who.
export interface HeatEntry {
human?: number;
agent?: number;
share?: number;
readers?: number;
last?: string;
}
export type HeatMap = Record<string, HeatEntry>;
// GET .../history (HistoryEntry, history.go)
export interface DeviceInfo {
id?: string;
name?: string;
os?: string;
ip?: string;
}
export interface HistoryEntry {
time: string;
kind: string; // add | edit | delete (older servers: raw "put")
path: string;
size?: number;
blob?: string;
user?: string;
user_name?: string;
author?: string;
device: DeviceInfo;
note?: string;
}
// POST .../shares (handleShareCreate, shares.go)
export interface ShareCreated {
token: string;
url: string;
}
// POST .../upload/init (handleUploadInit, upload.go)
export interface UploadPlan {
mode: "direct" | "server";
exists?: boolean;
url?: string;
method?: string;
headers?: Record<string, string>;
}
@@ -0,0 +1,382 @@
import {
useCallback,
useEffect,
useRef,
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 { uploadFile } from "../upload";
import { copyText } from "../util";
import { toast } from "../toast";
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
import { FileTree, ancestorsOf } from "../components/FileTree";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { FolderListing } from "../components/FolderListing";
import { FileView } from "../components/FileView";
import { ShareDialog } from "../components/ShareDialog";
import { Palette, type PaletteItem } from "../components/Palette";
// The browsing surface shared by hub projects and single-volume mode: the
// file tree, folder listings, file views, and every topbar action. Sidebar
// chrome (vault header, project nav, org bar) is injected by the caller;
// key this component by project id so tree state resets on project switch.
export default function Browser(props: {
config: ServerConfig;
apiBase: string;
route: Route;
hub: boolean;
project?: Project;
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 qc = useQueryClient();
const { tree, flatFiles, dirIndex, loaded } = useTree(apiBase, !hub || !!project);
const heatMap = useHeat(apiBase, hub && !!project && !!config.reads?.enabled);
const path = route.path;
const isDir = !!path && dirIndex.has(path);
const isFile = !!path && loaded && !dirIndex.has(path);
const listingShowing = isDir && !route.view;
/* ---- tree expansion ---- */
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const firstLoad = useRef(true);
useEffect(() => {
// First render of the tree: every folder starts closed, except a lone
// root folder — opening it spares the user a single shut folder.
if (!tree || !firstLoad.current) return;
firstLoad.current = false;
const rootDirs = (tree.children || []).filter((c) => c.dir);
if (rootDirs.length === 1) setExpanded((s) => new Set(s).add(rootDirs[0].path));
}, [tree]);
useEffect(() => {
// Opening any path (tree click, palette, wikilink, deep link) unfolds
// the way to it; a selected folder itself opens too.
if (!path || !loaded) return;
setExpanded((s) => {
const next = new Set(s);
for (const a of ancestorsOf(path)) next.add(a);
if (dirIndex.has(path)) next.add(path);
return next;
});
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(path)}"]`);
if (row) row.scrollIntoView({ block: "nearest" });
}, [path, loaded, dirIndex]);
const onToggle = useCallback((p: string) => {
setExpanded((s) => {
const next = new Set(s);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
}, []);
/* ---- per-route scroll restoration ----
Back/forward returns to where the reader was; fresh navigations start
at the top. Views call onRendered when their content lands (and again
when async sections grow), and we re-apply the target until it fits. */
const contentRef = useRef<HTMLElement>(null);
const memo = useRef(new Map<string, number>());
const scrollGoal = useRef({ key: "", want: 0, attempts: 0 });
useEffect(() => {
scrollGoal.current = {
key: location.key,
want: navType === "POP" ? (memo.current.get(location.key) ?? 0) : 0,
attempts: 0,
};
}, [location.key, navType]);
const onRendered = useCallback(() => {
const c = contentRef.current;
const g = scrollGoal.current;
if (!c || g.key !== location.key || g.attempts >= 3) return;
g.attempts++;
c.scrollTo({ top: g.want, behavior: "instant" });
}, [location.key]);
const onScroll = useCallback(() => {
if (contentRef.current) memo.current.set(location.key, contentRef.current.scrollTop);
}, [location.key]);
/* ---- navigation ---- */
const openPath = useCallback(
(p: string) => {
navigate(urlForPath(p, project?.id));
closeSidebarOnMobile();
},
[navigate, project?.id],
);
const openHistory = useCallback(
(target: string) => navigate(urlForView("history", project?.id, target)),
[navigate, project?.id],
);
/* ---- topbar state + actions ---- */
const [meta, setMeta] = useState("");
const [uploadStatus, setUploadStatus] = useState("");
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const uploadInput = useRef<HTMLInputElement>(null);
const downloadRef = useRef<HTMLAnchorElement>(null);
const canShare = hub && !!project && isFile;
const canHistory = hub && !!project;
const canUpload = !!config.upload?.enabled && (!hub || !!project);
const canDownload = isFile;
const canMore = isFile || (hub && !!project && isDir);
const downloadURL = apiBase + "download?path=" + encodeURIComponent(path);
const shareNow = useCallback(async () => {
// Shares are per-file; a selected folder has nothing to mint.
try {
const r = await fetch(apiBase + "shares", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!r.ok) throw new Error(await r.text());
const s = await r.json();
const copied = await copyText(s.url);
setShare({ url: s.url, copied });
} catch (err) {
toast("Share failed: " + (err as Error).message, true);
}
}, [apiBase, path]);
const historyNow = useCallback(() => {
if (!path) return openHistory("");
openHistory(isDir ? path + "/" : path);
}, [path, isDir, openHistory]);
const uploadNow = useCallback(() => uploadInput.current?.click(), []);
const onUploadPick = async () => {
const input = uploadInput.current!;
const file = input.files?.[0];
input.value = "";
if (!file) return;
// A selected folder receives the upload; a selected file means "next
// to it".
const dir = !path ? "" : isDir ? path : path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
const dest = dir ? dir + "/" + file.name : file.name;
try {
setUploadStatus(`Uploading ${dest}`);
await uploadFile(apiBase, dest, file);
setUploadStatus(`Uploaded ${dest}`);
await qc.invalidateQueries({ queryKey: ["tree", apiBase] });
openPath(dest);
} catch (err) {
setUploadStatus("Upload failed: " + (err as Error).message);
}
};
useEffect(() => {
// Any navigation clears a stale upload status from the meta slot.
setUploadStatus("");
}, [location.key]);
/* ---- ⌘K palette ---- */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setPaletteOpen((v) => !v);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const paletteCandidates = useCallback((): PaletteItem[] => {
const items: PaletteItem[] = [];
const add = (icon: string, label: string, kind: string, run: () => void) =>
items.push({ icon, label, kind, run });
if (hub && project && path) {
if (isFile) add("share", "Share: " + path, "action", shareNow);
add("hist", "History: " + path, "action", historyNow);
if (isFile) add("download", "Download: " + path, "action", () => downloadRef.current?.click());
}
if (hub && project) add("hist", "History: whole project", "action", () => openHistory(""));
if (canUpload) add("upload", "Upload a file…", "action", uploadNow);
if (hub) {
for (const p of props.projects || []) {
if (!project || p.id !== project.id) {
add("folder", "Switch to project: " + p.name, "project", () => navigate("/" + p.id));
}
}
}
if (config.auth?.enabled) {
add("power", "Sign out", "action", () => (window.location.href = "/auth/logout"));
}
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]);
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
useEffect(() => {
if (!moreOpen) return;
const close = () => setMoreOpen(false);
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [moreOpen]);
/* ---- content view ---- */
let contentClass = "markdown";
let view: ReactNode;
if (route.view === "insights" || route.view === "history") {
// Phase 3 delivers these views.
contentClass = "view";
view = <div className="empty">{route.view} view is on its way.</div>;
} else if (path) {
if (!loaded) {
view = <div className="empty">Loading</div>;
} else if (isDir) {
contentClass = "view";
view = (
<FolderListing
node={dirIndex.get(path)!}
heatMap={heatMap}
hub={hub && !!project}
apiBase={apiBase}
onOpen={openPath}
onFullHistory={openHistory}
onRendered={onRendered}
/>
);
} else {
view = (
<FileView
apiBase={apiBase}
path={path}
heatMap={heatMap}
flatFiles={flatFiles}
onOpenFile={openPath}
onMeta={setMeta}
onRendered={onRendered}
/>
);
}
} else if (props.home) {
contentClass = "view";
view = props.home;
} else {
view = <div className="empty">Select a file to read it.</div>;
}
const crumb = path ? <Breadcrumbs path={path} onOpenFolder={openPath} /> : null;
const topbar = (
<Topbar
crumb={crumb}
meta={uploadStatus || meta}
actions={
<>
<button className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
<Icon name="search" /> <span className="lbl">Search</span> <kbd>K</kbd>
</button>
{canShare && (
<button id="share-btn" className="btn" onClick={shareNow}>
<Icon name="share" /> <span className="lbl">Share</span>
</button>
)}
{canHistory && (
<button id="history-btn" className="btn" onClick={historyNow}>
<Icon name="hist" /> <span className="lbl">History</span>
</button>
)}
{canUpload && (
<button id="upload-btn" className="btn" onClick={uploadNow}>
<Icon name="upload" /> <span className="lbl">Upload</span>
</button>
)}
<input type="file" hidden ref={uploadInput} onChange={onUploadPick} />
{canDownload && (
<a id="download" className="btn" download href={downloadURL} ref={downloadRef}>
<Icon name="download" /> <span className="lbl">Download</span>
</a>
)}
{canMore && (
<button
id="more-btn"
className="btn icon-only"
title="More actions"
aria-label="More actions"
onClick={(e) => {
e.stopPropagation();
setMoreOpen(!moreOpen);
}}
>
<Icon name="dots" />
</button>
)}
{moreOpen && (
<div id="more-menu" role="menu">
{canHistory && (
<button className="more-item" onClick={historyNow}>
History
</button>
)}
{canUpload && (
<button className="more-item" onClick={uploadNow}>
Upload
</button>
)}
{canDownload && (
<button className="more-item" onClick={() => downloadRef.current?.click()}>
Download
</button>
)}
{props.canInsights && (
<button
className="more-item"
onClick={() => navigate(urlForView("insights", project?.id))}
>
Insights
</button>
)}
</div>
)}
</>
}
/>
);
return (
<>
<AppShell
vault={props.sidebar.vault}
projectsNav={props.sidebar.projectsNav}
orgBar={props.sidebar.orgBar}
tree={
<FileTree
root={tree}
expanded={expanded}
onToggle={onToggle}
currentPath={path}
listingShowing={listingShowing}
onOpen={openPath}
/>
}
topbar={topbar}
contentClass={contentClass}
contentRef={contentRef}
onContentScroll={onScroll}
>
{view}
</AppShell>
{share && <ShareDialog url={share.url} copied={share.copied} onClose={() => setShare(null)} />}
<Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} candidates={paletteCandidates} />
</>
);
}
+19 -10
View File
@@ -9,6 +9,7 @@ import { ProjectNav } from "../components/ProjectNav";
import { OrgBar } from "../components/OrgBar";
import { EmptyState } from "../components/EmptyState";
import { toast } from "../toast";
import Browser from "./Browser";
export default function HubApp({ config }: { config: ServerConfig }) {
const location = useLocation();
@@ -69,6 +70,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
// account that owns an org (or is a hub admin) gets it, whatever project
// is open. The panels it opens arrive in Phase 4.
const gearTarget = org && org.role === "owner" ? org : ownedOrg;
// Insights (embedded on the project home and behind the ⋯ menu) is for
// hub admins and owners of the project's org.
const canInsights = isAdmin || (org ? org.role === "owner" : false);
const vault = (
<VaultHeader
@@ -124,16 +128,21 @@ export default function HubApp({ config }: { config: ServerConfig }) {
}
return (
<AppShell
vault={vault}
projectsNav={<ProjectNav projects={projects} currentId={current.id} />}
orgBar={<OrgBar org={org} onManage={() => {}} />}
topbar={<Topbar />}
>
{/* Content views (project home, files, insights, history) arrive in
Phases 23. */}
<div className="empty">Select a file to read it.</div>
</AppShell>
<Browser
key={current.id} // fresh tree/fold state per project
config={config}
apiBase={"/api/p/" + current.id + "/"}
route={route}
hub
project={current}
projects={projects}
canInsights={canInsights}
sidebar={{
vault,
projectsNav: <ProjectNav projects={projects} currentId={current.id} />,
orgBar: <OrgBar org={org} onManage={() => {}} />,
}}
/>
);
}
+19 -10
View File
@@ -1,21 +1,30 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useLocation } from "react-router-dom";
import type { ServerConfig } from "../api/types";
import { AppShell, Topbar, VaultHeader } from "../components/shell";
import { VaultHeader } from "../components/shell";
import { parseRoute } from "../router";
import Browser from "./Browser";
// Single-volume mode: one folder, no projects or orgs. File browsing
// arrives in Phase 2.
// 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 name = config.volume || "BearDrive";
useEffect(() => {
document.title = config.brand || name;
}, [config, name]);
const route = useMemo(
() => parseRoute(location.pathname, "volume"),
[location.pathname],
);
return (
<AppShell
vault={<VaultHeader name={name} showSignout={config.auth.enabled} />}
topbar={<Topbar />}
>
<div className="empty">Select a file to read it.</div>
</AppShell>
<Browser
config={config}
apiBase="/api/"
route={route}
hub={false}
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} /> }}
/>
);
}
@@ -0,0 +1,33 @@
// Every ancestor segment is a link to that folder's listing; the last
// segment is the current page.
export function Breadcrumbs({
path,
onOpenFolder,
}: {
path: string;
onOpenFolder: (dir: string) => void;
}) {
const parts = path.split("/");
let acc = "";
return (
<>
{parts.map((seg, i) => {
acc = acc ? acc + "/" + seg : seg;
const target = acc;
const last = i === parts.length - 1;
return (
<span key={target}>
{i > 0 && <span className="crumb-sep">/</span>}
{last ? (
<span>{seg}</span>
) : (
<span className="crumb-seg" title={target} onClick={() => onOpenFolder(target)}>
{seg}
</span>
)}
</span>
);
})}
</>
);
}
@@ -0,0 +1,95 @@
import type { Node } from "../api/types";
import { Icon, closeSidebarOnMobile } from "./shell";
// The sidebar file tree. The chevron only folds; the row selects (opens a
// folder's listing / a file). Clicking the folder whose listing is already
// showing folds/unfolds it, like a plain tree.
export function FileTree(props: {
root: Node | undefined;
expanded: Set<string>;
onToggle: (path: string) => void;
currentPath: string;
listingShowing: boolean; // current view is a folder listing
onOpen: (path: string) => void;
}) {
return (
<nav id="tree" aria-label="Files">
{props.root && <TreeChildren nodes={props.root.children || []} {...props} />}
</nav>
);
}
type RowProps = Omit<Parameters<typeof FileTree>[0], "root">;
function TreeChildren({ nodes, ...rest }: RowProps & { nodes: Node[] }) {
return (
<ul>
{nodes.map((n) => (
<TreeNode key={n.path} node={n} {...rest} />
))}
</ul>
);
}
function TreeNode({ node: n, ...rest }: RowProps & { node: Node }) {
const { expanded, onToggle, currentPath, listingShowing, onOpen } = rest;
const open = n.dir ? expanded.has(n.path) : false;
const click = () => {
if (n.dir) {
// Folding beats re-opening when this folder's listing is already up.
if (currentPath === n.path && listingShowing) {
onToggle(n.path);
return;
}
}
onOpen(n.path);
if (!n.dir) closeSidebarOnMobile();
};
return (
<li className={(n.dir ? "dir" : "file") + (n.dir && !open ? " collapsed" : "")}>
<div
className={"row" + (currentPath === n.path ? " active" : "")}
data-path={n.path}
tabIndex={0}
role="button"
title={n.name}
aria-expanded={n.dir ? open : undefined}
onClick={click}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
click();
}
}}
>
<span
className="chev"
onClick={(e) => {
if (!n.dir) return;
e.stopPropagation();
onToggle(n.path);
}}
>
<Icon name="chevd" />
</span>
<span className="ticon">
<Icon name={n.dir ? "folder" : "doc"} />
</span>
<span className="label">{n.name}</span>
</div>
{n.dir && <TreeChildren nodes={n.children || []} {...rest} />}
</li>
);
}
/* Every ancestor folder of a path (for unfolding the way to it). */
export function ancestorsOf(filePath: string): string[] {
const parts = filePath.split("/");
const out: string[] = [];
let acc = "";
for (let i = 0; i < parts.length - 1; i++) {
acc = acc ? acc + "/" + parts[i] : parts[i];
out.push(acc);
}
return out;
}
@@ -0,0 +1,155 @@
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
import { heatTotal, heatText } from "../hooks/useBrowse";
import { IMG_EXT, MD_EXT, TEXT_EXT, joinPath } from "../util";
export function FileView(props: {
apiBase: string;
path: string;
heatMap: HeatMap | null;
flatFiles: Node[];
onOpenFile: (path: string) => void;
onMeta: (meta: string) => void;
onRendered?: () => void;
}) {
const { apiBase, path, onMeta } = props;
const fileURL = apiBase + "file?path=" + encodeURIComponent(path);
useEffect(() => () => onMeta(""), [path, onMeta]); // leaving a file clears its meta line
if (MD_EXT.test(path)) return <MarkdownView {...props} />;
if (IMG_EXT.test(path)) {
return <ImgView src={fileURL} alt={path} onRendered={props.onRendered} />;
}
if (TEXT_EXT.test(path)) return <TextView {...props} fileURL={fileURL} />;
return (
<div className="filecard">
<div className="name">{path.split("/").pop()}</div>
<p>No preview for this file type.</p>
<a className="btn" download href={apiBase + "download?path=" + encodeURIComponent(path)}>
Download
</a>
</div>
);
}
function MarkdownView(props: Parameters<typeof FileView>[0]) {
const { apiBase, path, heatMap, flatFiles, onOpenFile, onMeta, onRendered } = props;
const { data: doc, error } = useQuery({
queryKey: ["render", apiBase, path],
queryFn: () => getJSON<RenderDoc>(apiBase + "render?path=" + encodeURIComponent(path)),
});
// Rewrite the HTML BEFORE rendering (relative image sources, external
// link targets) rather than patching the live DOM afterwards: React owns
// the dangerouslySetInnerHTML subtree and may re-apply the markup on any
// update, silently discarding post-commit DOM patches. Link navigation
// is delegated on the container for the same reason.
const html = useMemo(
() => (doc ? transformHTML(doc.html, path, apiBase) : ""),
[doc, path, apiBase],
);
useEffect(() => {
if (!doc) return;
const parts: string[] = [];
if (doc.author) parts.push(doc.author + (doc.device ? " on " + doc.device : ""));
if (doc.time) parts.push(new Date(doc.time).toLocaleString());
const he = heatMap && heatMap[doc.path];
if (he && heatTotal(he)) parts.push(heatText(he) + " / 30d");
onMeta(parts.join(" · "));
onRendered?.();
}, [doc, heatMap, onMeta, onRendered]);
if (error) return <div className="empty">Could not load file: {(error as Error).message}</div>;
if (!doc) return null;
// Server-rendered, server-sanitized markdown — same trust model as the
// classic app assigning innerHTML.
return (
<div
dangerouslySetInnerHTML={{ __html: html }}
onClick={(e) => handleLinkClick(e, path, flatFiles, onOpenFile)}
/>
);
}
/* Delegated click handling for rendered-markdown links: wiki: targets
resolve by basename, relative links resolve against the current file's
folder, everything else keeps its native behavior. */
function handleLinkClick(
e: React.MouseEvent,
p: string,
flatFiles: Node[],
openFile: (path: string) => void,
) {
const a = (e.target as HTMLElement).closest("a");
if (!a || !(e.currentTarget as HTMLElement).contains(a)) return;
const href = a.getAttribute("href") || "";
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
if (href.startsWith("wiki:")) {
e.preventDefault();
openWikilink(decodeURIComponent(href.slice(5)), flatFiles, openFile);
} else if (!/^([a-z]+:|\/|#)/i.test(href)) {
e.preventDefault();
openFile(joinPath(dir, decodeURIComponent(href)));
}
}
/* String-level rewrite of the server's HTML: relative image sources point
at the file API, external links open in a new tab. */
function transformHTML(html: string, p: string, apiBase: string): string {
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
const fileURL = (path: string) => apiBase + "file?path=" + encodeURIComponent(path);
const parsed = new DOMParser().parseFromString(html, "text/html");
for (const img of parsed.querySelectorAll("img")) {
const src = img.getAttribute("src") || "";
if (!/^([a-z]+:|\/)/i.test(src)) img.setAttribute("src", fileURL(joinPath(dir, src)));
}
for (const a of parsed.querySelectorAll("a")) {
const href = a.getAttribute("href") || "";
if (/^https?:/i.test(href)) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
}
return parsed.body.innerHTML;
}
function ImgView({ src, alt, onRendered }: { src: string; alt: string; onRendered?: () => void }) {
return <img src={src} alt={alt} onLoad={onRendered} />;
}
function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
const { path, fileURL, onRendered } = props;
const { data, error } = useQuery({
queryKey: ["text", fileURL],
queryFn: async () => {
const r = await fetch(fileURL);
if (!r.ok) throw new Error(await r.text());
return r.text();
},
});
useEffect(() => {
if (data != null) onRendered?.();
}, [data, onRendered]);
if (error) return <div className="empty">Could not load file: {(error as Error).message}</div>;
if (data == null) return null;
return (
<pre className="plain" key={path}>
{data}
</pre>
);
}
function openWikilink(target: string, flatFiles: Node[], openFile: (path: string) => void) {
const want = target.toLowerCase();
const hit =
flatFiles.find((f) => f.path.toLowerCase() === want || f.path.toLowerCase() === want + ".md") ||
flatFiles.find((f) => {
const n = f.name.toLowerCase();
return n === want || n === want + ".md";
});
if (hit) openFile(hit.path);
}
@@ -0,0 +1,123 @@
import { useEffect } from "react";
import type { HeatMap, Node } from "../api/types";
import { heatFor, heatLevel, heatText, useFolderHistory } from "../hooks/useBrowse";
import { humanSize } from "../util";
import { Icon } from "./shell";
import { HistoryRow } from "./HistoryRow";
export function FolderListing(props: {
node: Node;
heatMap: HeatMap | null;
hub: boolean; // hub feeds exist; a plain-folder viewer has no journals
apiBase: string;
onOpen: (path: string) => void;
onFullHistory: (prefix: string) => void;
onRendered?: () => void; // scroll restoration: content height just grew
}) {
const { node, heatMap, onOpen } = props;
const kids = (node.children || [])
.slice()
.sort((a, b) => Number(b.dir || false) - Number(a.dir || false) || a.name.localeCompare(b.name));
const dirs = kids.filter((c) => c.dir).length;
const files = kids.length - dirs;
const counts: string[] = [];
if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders"));
if (files) counts.push(files + (files === 1 ? " file" : " files"));
const folderHeat = heatFor(heatMap, node.path, true);
if (folderHeat) counts.push(heatText(folderHeat) + " in 30 days");
return (
<div className="dirlist">
<h1 className="dl-title">
<span className="dl-title-icon">
<Icon name="folder" />
</span>
<span>{node.name}</span>
</h1>
<p className="dl-sub">{counts.join(" · ") || "Empty folder"}</p>
{kids.length === 0 ? (
<div className="dl-empty">Nothing in this folder yet.</div>
) : (
<div className="dl-items">
{kids.map((c) => {
let meta = "";
if (c.dir) {
const n = (c.children || []).length;
meta = n + (n === 1 ? " item" : " items");
} else {
meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""]
.filter(Boolean)
.join(" · ");
}
const he = heatFor(heatMap, c.path, !!c.dir);
if (he) meta = heatText(he) + (meta ? " · " + meta : "");
return (
<div
key={c.path}
className="dl-row"
tabIndex={0}
role="button"
title={c.path}
onClick={() => onOpen(c.path)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(c.path);
}
}}
>
<span className="ticon">
<Icon name={c.dir ? "folder" : "doc"} />
</span>
<span className="dl-name">{c.name}</span>
{he && <span className={"heatdot lvl" + heatLevel(he)} title={heatText(he) + " in 30 days"} />}
<span className="dl-meta">{meta}</span>
</div>
);
})}
</div>
)}
{props.hub && (
<FolderHistory
apiBase={props.apiBase}
prefix={node.path + "/"}
onOpen={onOpen}
onFullHistory={() => props.onFullHistory(node.path + "/")}
onRendered={props.onRendered}
/>
)}
</div>
);
}
/* The folder's change feed, straight from the journals: files added,
edited, and deleted anywhere under it, newest first. */
function FolderHistory(props: {
apiBase: string;
prefix: string;
onOpen: (path: string) => void;
onFullHistory: () => void;
onRendered?: () => void;
}) {
const entries = useFolderHistory(props.apiBase, props.prefix, true);
const { onRendered } = props;
useEffect(() => {
// The feed adds height after the listing rendered; a restored scroll
// position (back/forward) may only fit now.
if (entries && entries.length && onRendered) onRendered();
}, [entries, onRendered]);
if (!entries || entries.length === 0) return null;
return (
<div className="dl-history">
<h3 className="dl-h3">Recent changes</h3>
<div className="history dl-hlist">
{entries.map((e, i) => (
<HistoryRow key={i} entry={e} onOpen={props.onOpen} />
))}
</div>
<button className="ai-btn dl-more" onClick={props.onFullHistory}>
Full history
</button>
</div>
);
}
@@ -0,0 +1,89 @@
import { useState } from "react";
import type { HistoryEntry } from "../api/types";
import { humanSize } from "../util";
import { Icon } from "./shell";
/* One change as a row: what happened (added / edited / deleted), to which
file, by whom, from where — with the note (session link) expandable. */
const KIND_ICON: Record<string, string> = { add: "plus", edit: "edit", delete: "x" };
const KIND_LABEL: Record<string, string> = { add: "added", edit: "edited", delete: "deleted" };
export function HistoryRow({
entry: e,
onOpen,
}: {
entry: HistoryEntry;
onOpen: (path: string) => void;
}) {
const [noteOpen, setNoteOpen] = useState(false);
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
const who = e.user_name ? `${e.user_name} <${e.user}>` : e.user || e.author || "unknown";
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const clickable = kind !== "delete";
const open = (ev: React.MouseEvent | React.KeyboardEvent) => {
if ((ev.target as HTMLElement).tagName === "A") return;
if (clickable) onOpen(e.path);
};
return (
<div
className={"hentry " + kind + (clickable ? " clickable" : "")}
tabIndex={clickable ? 0 : undefined}
role={clickable ? "button" : undefined}
onClick={open}
onKeyDown={(ev) => {
if (clickable && (ev.key === "Enter" || ev.key === " ")) {
ev.preventDefault();
onOpen(e.path);
}
}}
>
<div className="hline">
<span className="hkind">
<Icon name={KIND_ICON[kind] || "dot"} />
</span>
<span className="hpath">{e.path}</span>
<span className="htag">{KIND_LABEL[kind] || kind}</span>
<span className="htime">{new Date(e.time).toLocaleString()}</span>
</div>
<div className="hmeta">
<span className="hwho">{who}</span>
<span className="hdev">{dev}</span>
<span className="hsize">{e.size ? humanSize(e.size) : ""}</span>
</div>
{e.note && (
<div
className={"hnote" + (noteOpen ? " open" : "")}
tabIndex={0}
role="button"
title={noteOpen ? "Collapse note" : "Show full note"}
aria-expanded={noteOpen}
onClick={(ev) => {
ev.stopPropagation(); // expanding a note is not a navigation
if ((ev.target as HTMLElement).tagName === "A") return;
setNoteOpen(!noteOpen);
}}
onKeyDown={(ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
ev.stopPropagation();
setNoteOpen(!noteOpen);
}
}}
>
{/* Linkify http(s) URLs (e.g. a Claude session link); everything
else stays plain text — notes are user/agent input, never
markup. */}
{e.note.split(/(https?:\/\/\S+)/).map((tok, i) =>
/^https?:\/\//.test(tok) ? (
<a key={i} href={tok} target="_blank" rel="noopener">
{tok}
</a>
) : (
tok
),
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "./shell";
/* ---- command palette (⌘K / Ctrl+K) ----
One box for everything: fuzzy-jump to any file, switch projects, and run
quick actions (share, history, upload, download, sign out). */
export interface PaletteItem {
icon: string;
label: string;
kind: string; // action | project | folder | file
run: () => void;
}
/* Subsequence fuzzy match. Returns a score (higher = better), plus the
matched positions for highlighting; null when it doesn't match. */
function fuzzy(query: string, text: string): { score: number; hits: number[] } | null {
if (!query) return { score: 0, hits: [] };
const q = query.toLowerCase();
const t = text.toLowerCase();
let ti = 0,
score = 0,
streak = 0;
const hits: number[] = [];
for (let qi = 0; qi < q.length; qi++) {
const found = t.indexOf(q[qi], ti);
if (found === -1) return null;
streak = found === ti ? streak + 1 : 1;
score += streak * 3; // consecutive runs
if (found === 0 || "/ -_.".includes(t[found - 1])) score += 8; // word starts
hits.push(found);
ti = found + 1;
}
score -= Math.floor(t.length / 8); // mild preference for short targets
return { score, hits };
}
/* Match a query against a label, tolerating a simple English plural so
"ideas" still finds idea.md. Tries the raw query first, then a lightly
de-pluralized form (…ies→…y, …es→…, …s→…). */
function fuzzyStemmed(query: string, label: string) {
const m = fuzzy(query, label);
if (m) return m;
const q = query.toLowerCase();
let stem: string | null = null;
if (q.length > 3 && q.endsWith("ies")) stem = q.slice(0, -3) + "y";
else if (q.length > 3 && q.endsWith("es")) stem = q.slice(0, -2);
else if (q.length > 2 && q.endsWith("s")) stem = q.slice(0, -1);
return stem ? fuzzy(stem, label) : null;
}
function Highlight({ text, hits }: { text: string; hits: number[] }) {
const out: React.ReactNode[] = [];
let last = 0;
hits.forEach((h, i) => {
if (h > last) out.push(text.slice(last, h));
out.push(<b key={i}>{text[h]}</b>);
last = h + 1;
});
out.push(text.slice(last));
return <span className="plabel">{out}</span>;
}
export function Palette({
open,
onClose,
candidates,
}: {
open: boolean;
onClose: () => void;
candidates: () => PaletteItem[];
}) {
const [query, setQuery] = useState("");
const [sel, setSel] = useState(0);
const input = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const items = useMemo(() => {
if (!open) return [];
const scored: Array<PaletteItem & { score: number; hits: number[] }> = [];
for (const c of candidates()) {
const m = fuzzyStemmed(query, c.label);
if (m) scored.push({ ...c, score: m.score, hits: m.hits });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, 40);
}, [open, query, candidates]);
useEffect(() => {
if (open) {
setQuery("");
setSel(0);
input.current?.focus();
}
}, [open]);
useEffect(() => setSel(0), [query]);
useEffect(() => {
listRef.current?.children[sel]?.scrollIntoView({ block: "nearest" });
}, [sel, items]);
const run = (item: PaletteItem) => {
onClose();
item.run();
};
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const n = items.length;
if (n) setSel((s) => (s + (e.key === "ArrowDown" ? 1 : n - 1)) % n);
} else if (e.key === "Enter") {
e.preventDefault();
if (items[sel]) run(items[sel]);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, items, sel]);
if (!open) return null;
return (
<div id="palette-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div id="palette" role="dialog" aria-label="Search and quick actions">
<div id="palette-inputwrap">
<Icon name="search" />
<input
id="palette-input"
type="text"
placeholder="Search file names, projects, actions…"
autoComplete="off"
spellCheck={false}
ref={input}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ul id="palette-results" ref={listRef}>
{items.length === 0 ? (
<li className="pempty">No matches search covers file names, projects, and actions</li>
) : (
items.map((item, i) => (
<li
key={item.kind + ":" + item.label}
className={i === sel ? "selected" : undefined}
onClick={() => run(item)}
onMouseMove={() => sel !== i && setSel(i)}
>
<span className="picon">
<Icon name={item.icon} />
</span>
<Highlight text={item.label} hits={item.hits} />
<span className="pkind">{item.kind}</span>
</li>
))
)}
</ul>
<footer id="palette-hint"> navigate · select · esc close</footer>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { api } from "../api/http";
import { copyText } from "../util";
import { toast } from "../toast";
/* A clear, explicitly-public share confirmation: warns that anyone with the
link can view, and offers copy / open / revoke. */
export function ShareDialog({
url,
copied,
onClose,
}: {
url: string;
copied: boolean;
onClose: () => void;
}) {
const token = url.split("/s/")[1];
return (
<div className="modal-back" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal">
<h3>Public link created</h3>
<p>
<b>Anyone with this link can view this file</b> no account needed. It always shows the
latest version until you revoke it.
</p>
<div className="modal-url">{url}</div>
<div className="modal-actions">
<button
className="pbtn"
onClick={() =>
copyText(url).then((ok) => toast(ok ? "Copied." : "Select and copy the link above."))
}
>
{copied ? "Copied ✓" : "Copy link"}
</button>
<button className="ai-btn" onClick={() => window.open(url, "_blank")}>
Open
</button>
<button
className="ai-del"
onClick={async () => {
try {
await api("DELETE", "/api/shares/" + token);
toast("Link revoked — it no longer works.");
onClose();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Revoke
</button>
<button className="ai-btn" onClick={onClose}>
Done
</button>
</div>
</div>
</div>
);
}
@@ -26,6 +26,8 @@ export function AppShell(props: {
orgBar?: ReactNode;
topbar: ReactNode;
contentClass?: string;
contentRef?: React.Ref<HTMLElement>;
onContentScroll?: () => void;
children: ReactNode;
}) {
return (
@@ -39,7 +41,12 @@ export function AppShell(props: {
</aside>
<main id="main">
{props.topbar}
<article id="content" className={props.contentClass ?? "markdown"}>
<article
id="content"
className={props.contentClass ?? "markdown"}
ref={props.contentRef}
onScroll={props.onContentScroll}
>
{props.children}
</article>
</main>
@@ -0,0 +1,101 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatEntry, HeatMap, HistoryEntry, Node } from "../api/types";
// The volume's file tree, polled so synced changes appear without a
// reload. react-query's structural sharing keeps identical polls from
// re-rendering (the classic app compared JSON strings for the same
// reason).
export function useTree(apiBase: string, enabled = true) {
const q = useQuery({
queryKey: ["tree", apiBase],
queryFn: () => getJSON<Node>(apiBase + "tree"),
enabled,
refetchInterval: 15_000,
});
// Flattened lookups: every file (wikilink resolution, palette) and every
// directory (folder listings, path-kind dispatch).
const index = useMemo(() => {
const flatFiles: Node[] = [];
const dirIndex = new Map<string, Node>();
const walk = (n: Node) => {
for (const c of n.children || []) {
if (c.dir) {
dirIndex.set(c.path, c);
walk(c);
} else {
flatFiles.push(c);
}
}
};
if (q.data) walk(q.data);
return { flatFiles, dirIndex };
}, [q.data]);
return { tree: q.data, ...index, loaded: !!q.data };
}
/* ---- read heat ----
30-day read counts per path from the heat API (hub only). Counts only —
the server never says who read what. */
export function useHeat(apiBase: string, enabled: boolean) {
const q = useQuery({
queryKey: ["heat", apiBase],
queryFn: () => getJSON<{ entries: HeatMap }>(apiBase + "heat?days=30"),
enabled,
staleTime: 60_000,
refetchInterval: 60_000,
});
return q.data?.entries ?? null;
}
// The folder's change feed, straight from the journals (hub only).
export function useFolderHistory(apiBase: string, prefix: string, enabled: boolean) {
const q = useQuery({
queryKey: ["history", apiBase, "prefix", prefix, 20],
queryFn: () =>
getJSON<{ entries: HistoryEntry[] }>(
apiBase + "history?prefix=" + encodeURIComponent(prefix) + "&n=20",
),
enabled,
staleTime: 15_000,
});
return q.data?.entries ?? null;
}
/* Heat for one listing entry: a file's own bucket, or the subtree sum for a
folder. Null when there is nothing to show. */
export function heatFor(heatMap: HeatMap | null, path: string, isDir: boolean): HeatEntry | null {
if (!heatMap) return null;
if (!isDir) return heatMap[path] || null;
const agg = { human: 0, agent: 0, share: 0 };
for (const [p, e] of Object.entries(heatMap)) {
if (!p.startsWith(path + "/")) continue;
agg.human += e.human || 0;
agg.agent += e.agent || 0;
agg.share += e.share || 0;
}
return agg.human || agg.agent || agg.share ? agg : null;
}
export function heatTotal(e: HeatEntry): number {
return (e.human || 0) + (e.agent || 0) + (e.share || 0);
}
export function heatText(e: HeatEntry): string {
const total = heatTotal(e);
if (!total) return "";
let s = total + (total === 1 ? " read" : " reads");
if (e.agent) s += " (" + e.agent + " agent)";
return s;
}
/* Dot intensity 14, log-ish steps: 12, 39, 1029, 30+ reads. */
export function heatLevel(e: HeatEntry): number {
const total = heatTotal(e);
if (!total) return 0;
if (total < 3) return 1;
if (total < 10) return 2;
if (total < 30) return 3;
return 4;
}
+90
View File
@@ -0,0 +1,90 @@
import type { UploadPlan } from "./api/types";
/* The client asks the server how to upload (upload/init): "direct" hands
back a short-lived presigned URL and the bytes go straight to the object
store; "server" means relay the bytes through the bdrive server. */
export async function uploadFile(apiBase: string, dest: string, file: File): Promise<void> {
const buf = await file.arrayBuffer();
const sha = await sha256Hex(buf);
const post = async (url: string, body: unknown) => {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
};
const req = { path: dest, sha256: sha, size: file.size };
const plan: UploadPlan = await post(apiBase + "upload/init", req);
if (plan.mode === "direct") {
if (!plan.exists) {
// identical content already in the store? skip the PUT
const r = await fetch(plan.url!, {
method: plan.method || "PUT",
headers: plan.headers || {},
body: buf,
});
if (!r.ok) throw new Error("storage upload failed: " + r.status);
}
await post(apiBase + "upload/commit", req);
} else {
const r = await fetch(apiBase + "upload/content?path=" + encodeURIComponent(dest), {
method: "PUT",
body: buf,
});
if (!r.ok) throw new Error(await r.text());
}
}
async function sha256Hex(buf: ArrayBuffer): Promise<string> {
if (crypto.subtle) {
const d = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
return sha256Fallback(new Uint8Array(buf)); // plain-http origins have no crypto.subtle
}
/* Minimal SHA-256 (FIPS 180-4) for non-secure contexts. */
function sha256Fallback(bytes: Uint8Array): string {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const rr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
const len = bytes.length;
const padded = new Uint8Array(((((len + 8) >> 6) + 1) << 6));
padded.set(bytes);
padded[len] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 8, Math.floor((len * 8) / 0x100000000));
dv.setUint32(padded.length - 4, (len * 8) >>> 0);
const w = new Uint32Array(64);
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rr(w[i - 15], 7) ^ rr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rr(w[i - 2], 17) ^ rr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = H as unknown as number[];
for (let i = 0; i < 64; i++) {
const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
const t1 = (h + S1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
}
return [...H].map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
}
+41
View File
@@ -0,0 +1,41 @@
export const MD_EXT = /\.(md|markdown)$/i;
export const IMG_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|avif)$/i;
export const TEXT_EXT =
/\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh|rb|rs|c|h|cpp|java|kt|swift|sql|html|css|xml|ini|conf|env|mod|sum|jsonl)$/i;
export function humanSize(n: number): string {
if (n < 1024) return n + " B";
const units = ["KB", "MB", "GB", "TB"];
let i = -1;
do {
n /= 1024;
i++;
} while (n >= 1024 && i < units.length - 1);
return n.toFixed(1) + " " + units[i];
}
// Resolve a relative link against a directory, folding "." and "..".
export function joinPath(dir: string, rel: string): string {
const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
const out: string[] = [];
for (const s of parts) {
if (s === "" || s === ".") continue;
if (s === "..") out.pop();
else out.push(s);
}
return out.join("/");
}
/* clipboard copy that never throws on a non-HTTPS origin (where
navigator.clipboard is undefined). Returns true on success. */
export async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through */
}
return false;
}
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-BoqNtp9Y.js"></script>
<script type="module" crossorigin src="/assets/index-DAT78awr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oVhdizP9.css">
</head>
<body>