feat(web): file tree virtualized with @tanstack/react-virtual

Only the visible window renders (collapsed subtrees not at all — the ~5k-file
DOM cliff from the CTO review is gone). Flat rows keep data-path/.active
contract, nesting guide lines, mobile 44px rows; scroll-into-view moves into
FileTree via scrollToIndex. Fold behavior pinned by spec first. 54/54.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
Snow Lee
2026-07-19 13:08:39 -07:00
co-authored by Claude Fable 5
parent 2993049bf4
commit 2e835d266d
9 changed files with 179 additions and 131 deletions
@@ -176,3 +176,16 @@ test("missing path gets the not-found view; Check again finds a late upload", as
await page.click(".notfound .pbtn"); // Check again
await expect(page.locator("#content h1")).toHaveText("Finally here");
});
test("tree chevron folds and unfolds a folder", async ({ page }) => {
await login(page);
await wikiId(page);
await expect(page.locator('#tree .row[data-path="notes"]')).toBeVisible();
// Unfold via row click (opens listing + expands), then fold via chevron.
await page.click('#tree .row[data-path="notes"]');
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toBeVisible();
await page.click('#tree .row[data-path="notes"] .chev');
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).not.toBeVisible();
await page.click('#tree .row[data-path="notes"] .chev');
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toBeVisible();
});
@@ -92,8 +92,6 @@ export default function Browser(props: {
if (dirIndex.has(treePath)) next.add(treePath);
return next;
});
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(treePath)}"]`);
if (row) row.scrollIntoView({ block: "nearest" });
}, [treePath, loaded, dirIndex]);
const onToggle = useCallback((p: string) => {
setExpanded((s) => {
@@ -1,9 +1,31 @@
import { useEffect, useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Node } from "../api/types";
import { Icon, closeSidebarOnMobile } from "./shell";
// The sidebar file tree. The chevron only folds; the row selects (opens a
// The sidebar file tree, virtualized: only the visible window of rows is in
// the DOM (collapsed subtrees aren't rendered at all), so 10k-file projects
// scroll smoothly. 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.
interface FlatRow {
node: Node;
depth: number;
}
function flatten(root: Node | undefined, expanded: Set<string>): FlatRow[] {
const out: FlatRow[] = [];
const walk = (nodes: Node[], depth: number) => {
for (const n of nodes) {
out.push({ node: n, depth });
if (n.dir && expanded.has(n.path)) walk(n.children || [], depth + 1);
}
};
walk(root?.children || [], 0);
return out;
}
export function FileTree(props: {
root: Node | undefined;
expanded: Set<string>;
@@ -12,73 +34,91 @@ export function FileTree(props: {
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>
);
}
const { root, expanded, onToggle, currentPath, listingShowing, onOpen } = props;
const scrollRef = useRef<HTMLElement>(null);
type RowProps = Omit<Parameters<typeof FileTree>[0], "root">;
const rows = useMemo(() => flatten(root, expanded), [root, expanded]);
function TreeChildren({ nodes, ...rest }: RowProps & { nodes: Node[] }) {
return (
<ul>
{nodes.map((n) => (
<TreeNode key={n.path} node={n} {...rest} />
))}
</ul>
);
}
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => (window.matchMedia("(max-width: 768px)").matches ? 44 : 28),
overscan: 12,
getItemKey: (i) => rows[i].node.path,
});
// Keep the subject visible: when the selection changes (tree click, deep
// link, scoped view), scroll its row into the window.
useEffect(() => {
if (!currentPath) return;
const i = rows.findIndex((r) => r.node.path === currentPath);
if (i >= 0) virtualizer.scrollToIndex(i, { align: "auto" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPath, rows]);
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>
<nav id="tree" aria-label="Files" ref={scrollRef}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((vi) => {
const { node: n, depth } = rows[vi.index];
const open = n.dir ? expanded.has(n.path) : false;
const click = () => {
if (n.dir && currentPath === n.path && listingShowing) {
// Folding beats re-opening when this folder's listing is up.
onToggle(n.path);
return;
}
onOpen(n.path);
if (!n.dir) closeSidebarOnMobile();
};
return (
<div
key={vi.key}
className={"row" + (currentPath === n.path ? " active" : "") + (n.dir && !open ? " collapsed" : "")}
data-path={n.path}
tabIndex={0}
role="button"
title={n.name}
aria-expanded={n.dir ? open : undefined}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${vi.start}px)`,
paddingLeft: 8 + depth * 13,
}}
onClick={click}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
click();
}
}}
>
{/* nesting guide lines, matching the old ul borders */}
{Array.from({ length: depth }, (_, i) => (
<span key={i} className="tguide" style={{ left: 8 + i * 13 + 5 }} aria-hidden="true" />
))}
<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>
);
})}
</div>
{n.dir && <TreeChildren nodes={n.children || []} {...rest} />}
</li>
</nav>
);
}
+9 -12
View File
@@ -114,29 +114,26 @@ button, input, a.btn { font-family: inherit; }
/* file tree */
#tree { flex: 1; overflow-y: auto; padding: 8px 8px 24px; }
#tree ul { list-style: none; margin: 0; padding-left: 13px; position: relative; }
#tree > ul { padding-left: 0; }
#tree ul ul::before { content: ""; position: absolute; left: 5px; top: 0; bottom: 0; width: 1px; background: var(--border); }
#tree li > .row {
.tguide { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); pointer-events: none; }
#tree .row {
display: flex; align-items: center; gap: 6px;
height: 28px; padding: 0 8px; border-radius: 7px;
color: var(--text-dim); cursor: pointer; position: relative;
white-space: nowrap; overflow: hidden;
}
#tree li > .row:hover { background: var(--hover); color: var(--text); }
#tree li > .row.active { background: var(--glow); color: var(--accent-bright); }
#tree li > .row.active::before { content: ""; position: absolute; left: 0; top: 5px; bottom: 5px; width: 2px; border-radius: 2px; background: var(--accent); }
#tree .row:hover { background: var(--hover); color: var(--text); }
#tree .row.active { background: var(--glow); color: var(--accent-bright); }
#tree .row.active::before { content: ""; position: absolute; left: 0; top: 5px; bottom: 5px; width: 2px; border-radius: 2px; background: var(--accent); }
#tree .chev { width: 14px; height: 14px; flex: none; color: var(--text-ghost); transition: transform .12s; display: flex; align-items: center; justify-content: center; }
#tree .chev .ico { width: 13px; height: 13px; }
#tree .ticon { flex: none; display: flex; color: var(--text-ghost); }
#tree .ticon .ico { width: 15px; height: 15px; }
#tree li > .row:hover .ticon, #tree li > .row:hover .chev { color: var(--text-faint); }
#tree li > .row.active .ticon, #tree li > .row.active .chev { color: var(--accent); }
#tree .row:hover .ticon, #tree .row:hover .chev { color: var(--text-faint); }
#tree .row.active .ticon, #tree .row.active .chev { color: var(--accent); }
#tree .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
#tree .file .label { font-size: 12.5px; }
#tree .file .chev { visibility: hidden; }
#tree li.collapsed > ul { display: none; }
#tree li.collapsed > .row .chev { transform: rotate(-90deg); }
#tree .row.collapsed .chev { transform: rotate(-90deg); }
/* org bar */
/* ---- project menu ---- */
@@ -488,7 +485,7 @@ button, input, a.btn { font-family: inherit; }
#meta { display: none; }
#crumb { flex: 1; }
#vault { padding: 0 8px 0 12px; }
.icon-btn2, #signout, #tree li > .row, #projects .row { height: 44px; }
.icon-btn2, #signout, #tree .row, #projects .row { height: 44px; }
#account-btn { min-height: 44px; }
#project-select { min-height: 44px; }
.nav-add { min-width: 44px; min-height: 44px; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-Ky-4qqmy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DagN_NN8.css">
<script type="module" crossorigin src="/assets/index-ESH0Hl7P.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MFSXFxvy.css">
</head>
<body>
<div id="root"></div>