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