refactor(web): unify file-tree components on a useTree headless hook (#919)

This commit is contained in:
Carlos Florêncio
2026-05-17 21:26:13 +01:00
committed by GitHub
parent c62739d3c1
commit 32579db525
22 changed files with 2689 additions and 497 deletions
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { ExportFileTree } from "./export-file-tree";
import type { ExportFile } from "./export-types";
import { buildFileTree } from "./export-utils";
afterEach(cleanup);
const ALPHA_PATH = "agents/alpha.yml";
const BETA_PATH = "agents/beta.yml";
const FOO_PATH = "skills/foo.yml";
const ROOT_PATH = "kandev.yml";
const FILES: ExportFile[] = [
{ path: ROOT_PATH, content: "name: root" },
{ path: ALPHA_PATH, content: "name: alpha" },
{ path: BETA_PATH, content: "name: beta" },
{ path: FOO_PATH, content: "name: foo" },
];
const ALPHA_NAME = "alpha.yml";
const CHECKBOX_SELECTOR = "[role='checkbox']";
type RenderOpts = Partial<Parameters<typeof ExportFileTree>[0]>;
function renderExport(overrides: RenderOpts = {}) {
const tree = buildFileTree(FILES);
const onSelectedPathsChange = vi.fn();
const onPreviewPathChange = vi.fn();
render(
<ExportFileTree
tree={tree}
selectedPaths={new Set(FILES.map((f) => f.path))}
onSelectedPathsChange={onSelectedPathsChange}
previewPath={null}
onPreviewPathChange={onPreviewPathChange}
{...overrides}
/>,
);
return { onSelectedPathsChange, onPreviewPathChange };
}
describe("ExportFileTree", () => {
it("renders all directories expanded by default", () => {
renderExport();
// All dirs render expanded on mount, so every leaf file is visible.
expect(screen.getByText(ALPHA_NAME)).toBeTruthy();
expect(screen.getByText("beta.yml")).toBeTruthy();
expect(screen.getByText("foo.yml")).toBeTruthy();
expect(screen.getByText(ROOT_PATH)).toBeTruthy();
});
it("clicking a file row fires onPreviewPathChange with its path", () => {
const { onPreviewPathChange } = renderExport();
fireEvent.click(screen.getByText(ALPHA_NAME));
expect(onPreviewPathChange).toHaveBeenCalledWith(ALPHA_PATH);
});
it("clicking a directory row collapses it and hides children", () => {
renderExport();
fireEvent.click(screen.getByText("agents"));
expect(screen.queryByText(ALPHA_NAME)).toBeNull();
expect(screen.queryByText("beta.yml")).toBeNull();
// Sibling dir's children remain visible.
expect(screen.getByText("foo.yml")).toBeTruthy();
});
it("typing into the search input hides non-matching rows", () => {
renderExport();
const input = screen.getByPlaceholderText("Search files...");
fireEvent.change(input, { target: { value: "alpha" } });
expect(screen.getByText(ALPHA_NAME)).toBeTruthy();
expect(screen.queryByText("beta.yml")).toBeNull();
expect(screen.queryByText("foo.yml")).toBeNull();
expect(screen.queryByText(ROOT_PATH)).toBeNull();
});
it("search keeps an ancestor visible when a descendant matches", () => {
renderExport();
const input = screen.getByPlaceholderText("Search files...");
fireEvent.change(input, { target: { value: "alpha" } });
// The "agents" dir is shown because alpha.yml lives under it.
expect(screen.getByText("agents")).toBeTruthy();
// The unrelated "skills" dir is hidden because none of its descendants match.
expect(screen.queryByText("skills")).toBeNull();
});
it("toggling an unchecked file checkbox adds its path via onSelectedPathsChange", () => {
const { onSelectedPathsChange } = renderExport({
selectedPaths: new Set<string>(),
});
// alpha.yml row's checkbox is rendered alongside the row.
const row = screen.getByText(ALPHA_NAME).parentElement!;
const box = row.querySelector(CHECKBOX_SELECTOR) as HTMLElement;
fireEvent.click(box);
expect(onSelectedPathsChange).toHaveBeenCalledTimes(1);
const next = onSelectedPathsChange.mock.calls[0][0] as Set<string>;
expect(next.has(ALPHA_PATH)).toBe(true);
});
it("toggling a directory checkbox propagates to every descendant", () => {
const { onSelectedPathsChange } = renderExport({
selectedPaths: new Set<string>(),
});
const row = screen.getByText("agents").parentElement!;
const box = row.querySelector(CHECKBOX_SELECTOR) as HTMLElement;
fireEvent.click(box);
const next = onSelectedPathsChange.mock.calls[0][0] as Set<string>;
expect(next.has(ALPHA_PATH)).toBe(true);
expect(next.has(BETA_PATH)).toBe(true);
// Unrelated branch is untouched.
expect(next.has(FOO_PATH)).toBe(false);
});
it("dir checkbox renders indeterminate when only some descendants are selected", () => {
renderExport({ selectedPaths: new Set<string>([ALPHA_PATH]) });
const row = screen.getByText("agents").parentElement!;
const box = row.querySelector(CHECKBOX_SELECTOR) as HTMLElement;
expect(box.getAttribute("data-state")).toBe("indeterminate");
});
it("dir checkbox renders fully checked when every descendant is selected", () => {
renderExport({
selectedPaths: new Set<string>([ALPHA_PATH, BETA_PATH]),
});
const row = screen.getByText("agents").parentElement!;
const box = row.querySelector(CHECKBOX_SELECTOR) as HTMLElement;
expect(box.getAttribute("data-state")).toBe("checked");
});
it("checkbox click does not bubble to the row's preview handler", () => {
const { onPreviewPathChange } = renderExport({
selectedPaths: new Set<string>(),
});
const row = screen.getByText(ALPHA_NAME).parentElement!;
const box = row.querySelector(CHECKBOX_SELECTOR) as HTMLElement;
fireEvent.click(box);
expect(onPreviewPathChange).not.toHaveBeenCalled();
});
it("previewPath highlights the matching file row", () => {
renderExport({ previewPath: ALPHA_PATH });
const row = screen.getByText(ALPHA_NAME).parentElement!;
expect(row.className).toContain("bg-accent");
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useState } from "react";
import {
IconFile,
IconFolder,
@@ -12,70 +12,63 @@ import {
import { Input } from "@kandev/ui/input";
import { Checkbox } from "@kandev/ui/checkbox";
import { cn } from "@/lib/utils";
import type { ExportFile, FileTreeNode } from "./export-types";
import { useTree, type VisibleRow } from "@/hooks/use-tree";
import type { FileTreeNode } from "./export-types";
import { getDescendantFilePaths } from "./export-utils";
interface ExportFileTreeProps {
tree: FileTreeNode[];
files: ExportFile[];
selectedPaths: Set<string>;
onSelectedPathsChange: (next: Set<string>) => void;
previewPath: string | null;
onPreviewPathChange: (path: string) => void;
}
// Stable adapter identities so useTree's visibleRows memoisation is not
// invalidated on every parent render.
const EXPORT_GET_PATH = (n: FileTreeNode) => n.path;
const EXPORT_GET_CHILDREN = (n: FileTreeNode) => n.children;
const EXPORT_IS_DIR = (n: FileTreeNode) => n.isDir;
type CheckState = boolean | "indeterminate";
function getCheckState(node: FileTreeNode, selectedPaths: Set<string>): CheckState {
const descendants = getDescendantFilePaths(node);
// Empty descendants would make .every() vacuously true.
if (descendants.length === 0) return false;
if (descendants.every((p) => selectedPaths.has(p))) return true;
if (descendants.some((p) => selectedPaths.has(p))) return "indeterminate";
return false;
}
export function ExportFileTree({
tree,
files,
selectedPaths,
onSelectedPathsChange,
previewPath,
onPreviewPathChange,
}: ExportFileTreeProps) {
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => {
// Expand all directories by default
const dirs = new Set<string>();
function walk(nodes: FileTreeNode[]) {
for (const n of nodes) {
if (n.isDir) {
dirs.add(n.path);
walk(n.children);
}
}
}
walk(tree);
return dirs;
const { visibleRows, toggle } = useTree<FileTreeNode>({
nodes: tree,
getPath: EXPORT_GET_PATH,
getChildren: EXPORT_GET_CHILDREN,
isDir: EXPORT_IS_DIR,
defaultExpanded: "all",
search,
searchMode: "hide",
});
const toggleExpand = useCallback((path: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
const toggleSelection = useCallback(
(node: FileTreeNode) => {
const paths = getDescendantFilePaths(node);
const allSelected = paths.every((p) => selectedPaths.has(p));
const next = new Set(selectedPaths);
for (const p of paths) {
if (allSelected) next.delete(p);
else next.add(p);
}
onSelectedPathsChange(next);
},
[selectedPaths, onSelectedPathsChange],
);
const lowerSearch = search.toLowerCase();
const matchesSearch = (node: FileTreeNode): boolean => {
if (!lowerSearch) return true;
if (node.name.toLowerCase().includes(lowerSearch)) return true;
return node.children.some(matchesSearch);
const toggleSelection = (node: FileTreeNode) => {
const paths = getDescendantFilePaths(node);
const allSelected = paths.every((p) => selectedPaths.has(p));
const next = new Set(selectedPaths);
for (const p of paths) {
if (allSelected) next.delete(p);
else next.add(p);
}
onSelectedPathsChange(next);
};
return (
@@ -92,19 +85,17 @@ export function ExportFileTree({
</div>
</div>
<div className="flex-1 overflow-y-auto py-1">
{tree.filter(matchesSearch).map((node) => (
<TreeRow
key={node.path}
node={node}
depth={0}
expanded={expanded}
selectedPaths={selectedPaths}
previewPath={previewPath}
onToggleExpand={toggleExpand}
onToggleSelection={toggleSelection}
onPreview={onPreviewPathChange}
matchesSearch={matchesSearch}
allFiles={files}
{visibleRows.map((row) => (
<ExportTreeRow
key={row.path}
row={row}
isActive={!row.isDir && previewPath === row.path}
checkState={getCheckState(row.node, selectedPaths)}
onClick={() => {
if (row.isDir) toggle(row.path);
else onPreviewPathChange(row.path);
}}
onToggleCheck={() => toggleSelection(row.node)}
/>
))}
</div>
@@ -112,94 +103,37 @@ export function ExportFileTree({
);
}
interface TreeRowProps {
node: FileTreeNode;
depth: number;
expanded: Set<string>;
selectedPaths: Set<string>;
previewPath: string | null;
onToggleExpand: (path: string) => void;
onToggleSelection: (node: FileTreeNode) => void;
onPreview: (path: string) => void;
matchesSearch: (node: FileTreeNode) => boolean;
allFiles: ExportFile[];
interface ExportTreeRowProps {
row: VisibleRow<FileTreeNode>;
isActive: boolean;
checkState: CheckState;
onClick: () => void;
onToggleCheck: () => void;
}
function TreeRow({
node,
depth,
expanded,
selectedPaths,
previewPath,
onToggleExpand,
onToggleSelection,
onPreview,
matchesSearch,
allFiles,
}: TreeRowProps) {
const isExpanded = expanded.has(node.path);
const descendants = getDescendantFilePaths(node);
const allSelected = descendants.every((p) => selectedPaths.has(p));
const someSelected = !allSelected && descendants.some((p) => selectedPaths.has(p));
const isActive = !node.isDir && previewPath === node.path;
const handleClick = () => {
if (node.isDir) {
onToggleExpand(node.path);
} else {
onPreview(node.path);
}
};
function ExportTreeRow({ row, isActive, checkState, onClick, onToggleCheck }: ExportTreeRowProps) {
let FileIconComp = IconFile;
if (row.isDir) FileIconComp = row.isExpanded ? IconFolderOpen : IconFolder;
let ChevronIcon: typeof IconChevronDown | null = null;
if (node.isDir) ChevronIcon = isExpanded ? IconChevronDown : IconChevronRight;
let FileIcon = IconFile;
if (node.isDir) FileIcon = isExpanded ? IconFolderOpen : IconFolder;
let checkedState: boolean | "indeterminate" = false;
if (allSelected) checkedState = true;
else if (someSelected) checkedState = "indeterminate";
if (row.isDir) ChevronIcon = row.isExpanded ? IconChevronDown : IconChevronRight;
return (
<>
<div
className={cn(
"flex items-center gap-1.5 px-2 py-1 text-sm cursor-pointer hover:bg-accent/50",
isActive && "bg-accent",
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleClick}
>
<Checkbox
checked={checkedState}
onCheckedChange={() => onToggleSelection(node)}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer shrink-0"
/>
{ChevronIcon && <ChevronIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{node.name}</span>
</div>
{node.isDir &&
isExpanded &&
node.children
.filter(matchesSearch)
.map((child) => (
<TreeRow
key={child.path}
node={child}
depth={depth + 1}
expanded={expanded}
selectedPaths={selectedPaths}
previewPath={previewPath}
onToggleExpand={onToggleExpand}
onToggleSelection={onToggleSelection}
onPreview={onPreview}
matchesSearch={matchesSearch}
allFiles={allFiles}
/>
))}
</>
<div
className={cn(
"flex items-center gap-1.5 px-2 py-1 text-sm cursor-pointer hover:bg-accent/50",
isActive && "bg-accent",
)}
style={{ paddingLeft: `${row.depth * 16 + 8}px` }}
onClick={onClick}
>
<Checkbox
checked={checkState}
onCheckedChange={onToggleCheck}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer shrink-0"
/>
{ChevronIcon && <ChevronIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<FileIconComp className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{row.displayName}</span>
</div>
);
}
@@ -93,7 +93,6 @@ export function ExportPreview() {
<div className="flex flex-1 min-h-0">
<ExportFileTree
tree={tree}
files={files}
selectedPaths={selectedPaths}
onSelectedPathsChange={setSelectedPaths}
previewPath={previewPath}
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, within } from "@testing-library/react";
import type { ReviewFile } from "./types";
import { reviewFileKey } from "./types";
import { ReviewFileTree } from "./review-file-tree";
afterEach(cleanup);
const APP_PATH = "src/app.tsx";
function file(overrides: Partial<ReviewFile>): ReviewFile {
return {
path: APP_PATH,
diff: "",
status: "modified",
additions: 1,
deletions: 0,
staged: false,
source: "uncommitted",
...overrides,
};
}
type RenderOpts = Partial<Parameters<typeof ReviewFileTree>[0]>;
function renderTree(files: ReviewFile[], overrides: RenderOpts = {}) {
const onFilterChange = vi.fn();
const onSelectFile = vi.fn();
const onToggleReviewed = vi.fn();
const props = {
files,
reviewedFiles: new Set<string>(),
staleFiles: new Set<string>(),
commentCountByFile: {} as Record<string, number>,
selectedFile: null,
filter: "",
onFilterChange,
onSelectFile,
onToggleReviewed,
...overrides,
};
render(<ReviewFileTree {...props} />);
return { onFilterChange, onSelectFile, onToggleReviewed };
}
describe("ReviewFileTree", () => {
it("renders one row per file", () => {
renderTree([file({ path: "src/a.ts" }), file({ path: "src/b.ts" })]);
expect(screen.getByText("a.ts")).toBeTruthy();
expect(screen.getByText("b.ts")).toBeTruthy();
});
it("clicking a file row fires onSelectFile with the composite key", () => {
const f = file({ path: APP_PATH, repository_name: "frontend", repository_id: "f" });
const { onSelectFile } = renderTree([f]);
const row = screen.getByText("app.tsx");
fireEvent.click(row);
expect(onSelectFile).toHaveBeenCalledWith(reviewFileKey(f));
});
it("toggling the reviewed checkbox fires onToggleReviewed with the composite key", () => {
const f = file({ path: APP_PATH, repository_name: "frontend", repository_id: "f" });
const { onToggleReviewed, onSelectFile } = renderTree([f]);
// The checkbox is the only role=checkbox in the tree.
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(onToggleReviewed).toHaveBeenCalledTimes(1);
expect(onToggleReviewed).toHaveBeenCalledWith(reviewFileKey(f), true);
// Checkbox click must NOT also fire onSelectFile - the row's onClick is
// stopped by the checkbox's stopPropagation handler. This invariant
// matters because clicking "I reviewed this file" should not navigate
// to it.
expect(onSelectFile).not.toHaveBeenCalled();
});
it("reviewed checkbox renders unchecked when the file is in staleFiles", () => {
const f = file({ path: APP_PATH, repository_name: "x", repository_id: "x" });
const key = reviewFileKey(f);
renderTree([f], {
reviewedFiles: new Set([key]),
staleFiles: new Set([key]),
});
// Stale takes precedence over reviewed: checkbox is unchecked.
const checkbox = screen.getByRole("checkbox") as HTMLInputElement;
// Radix Checkbox surfaces the state via aria-checked / data-state, not
// the underlying input's `checked` attribute.
expect(checkbox.getAttribute("aria-checked")).not.toBe("true");
});
it("reviewed checkbox renders checked when reviewed and not stale", () => {
const f = file({ path: APP_PATH, repository_name: "x", repository_id: "x" });
const key = reviewFileKey(f);
renderTree([f], {
reviewedFiles: new Set([key]),
staleFiles: new Set<string>(),
});
const checkbox = screen.getByRole("checkbox");
expect(checkbox.getAttribute("aria-checked")).toBe("true");
});
it("renders comment count badge for files with comments", () => {
const f = file({ path: APP_PATH, repository_name: "x", repository_id: "x" });
const key = reviewFileKey(f);
renderTree([f], { commentCountByFile: { [key]: 3 } });
expect(screen.getByText("3")).toBeTruthy();
});
it("filter input change fires onFilterChange", () => {
const { onFilterChange } = renderTree([file({ path: "a.ts" })]);
const input = screen.getByPlaceholderText("Filter changed files") as HTMLInputElement;
fireEvent.change(input, { target: { value: "abc" } });
expect(onFilterChange).toHaveBeenCalledWith("abc");
});
it("X button on the filter input clears the filter via onFilterChange", () => {
const { onFilterChange } = renderTree([file({ path: "a.ts" })], { filter: "abc" });
// The X button has no accessible name; locate it via the input's sibling
// button. With filter set, the clear button is rendered.
const input = screen.getByPlaceholderText("Filter changed files");
const container = input.parentElement!;
const clear = within(container).getByRole("button");
fireEvent.click(clear);
expect(onFilterChange).toHaveBeenCalledWith("");
});
it("groups files under a repo-root header when 2+ distinct repositories are present", () => {
renderTree([
file({ path: "src/a.ts", repository_name: "frontend", repository_id: "f" }),
file({ path: "handlers/task.go", repository_name: "backend", repository_id: "b" }),
]);
const repoHeaders = screen.getAllByTestId("repo-root-node");
const names = repoHeaders.map((h) => h.textContent ?? "");
expect(names.some((n) => n.includes("frontend"))).toBe(true);
expect(names.some((n) => n.includes("backend"))).toBe(true);
});
it("collapsing a directory header hides its children", () => {
renderTree([
file({ path: "src/a.ts", repository_name: "frontend", repository_id: "f" }),
file({ path: "handlers/task.go", repository_name: "backend", repository_id: "b" }),
]);
// Both files visible by default (TreeNode expanded=true initially).
expect(screen.getByText("a.ts")).toBeTruthy();
expect(screen.getByText("task.go")).toBeTruthy();
// Click the "frontend" repo root header to collapse.
const repoHeaders = screen.getAllByTestId("repo-root-node");
const frontend = repoHeaders.find((h) => h.textContent?.includes("frontend"))!;
const toggle = within(frontend).getAllByRole("button")[0];
fireEvent.click(toggle);
// a.ts no longer rendered.
expect(screen.queryByText("a.ts")).toBeNull();
// backend's child is still visible.
expect(screen.getByText("task.go")).toBeTruthy();
});
it("disambiguates same-named files in different repos via composite key", () => {
const fA = file({ path: "README.md", repository_name: "frontend", repository_id: "f" });
const fB = file({ path: "README.md", repository_name: "backend", repository_id: "b" });
const { onSelectFile } = renderTree([fA, fB]);
// There are two rows with the same display name; clicking each must
// dispatch a distinct key.
const rows = screen.getAllByText("README.md");
expect(rows).toHaveLength(2);
fireEvent.click(rows[0]);
fireEvent.click(rows[1]);
const calls = onSelectFile.mock.calls.map((c) => c[0] as string);
expect(new Set(calls).size).toBe(2);
expect(calls).toContain(reviewFileKey(fA));
expect(calls).toContain(reviewFileKey(fB));
});
});
+110 -116
View File
@@ -1,6 +1,6 @@
"use client";
import { memo, useState, useCallback, useMemo, useRef } from "react";
import { memo, useMemo, useRef } from "react";
import {
IconChevronDown,
IconChevronRight,
@@ -12,6 +12,7 @@ import {
import { Checkbox } from "@kandev/ui/checkbox";
import { cn } from "@kandev/ui/lib/utils";
import { FileIcon } from "@/components/ui/file-icon";
import { useTree, type VisibleRow } from "@/hooks/use-tree";
import type { ReviewFile, FileTreeNode } from "./types";
import { buildFileTree, reviewFileKey } from "./types";
@@ -27,6 +28,12 @@ type ReviewFileTreeProps = {
onToggleReviewed: (path: string, reviewed: boolean) => void;
};
// Stable adapter identities so useTree's visibleRows memoisation is not
// invalidated on every parent render.
const REVIEW_GET_PATH = (n: FileTreeNode) => n.path;
const REVIEW_GET_CHILDREN = (n: FileTreeNode) => n.children;
const REVIEW_IS_DIR = (n: FileTreeNode) => Boolean(n.isDir);
export const ReviewFileTree = memo(function ReviewFileTree({
files,
reviewedFiles,
@@ -41,43 +48,29 @@ export const ReviewFileTree = memo(function ReviewFileTree({
const inputRef = useRef<HTMLInputElement>(null);
const tree = useMemo(() => buildFileTree(files), [files]);
const { visibleRows, toggle } = useTree<FileTreeNode>({
nodes: tree,
getPath: REVIEW_GET_PATH,
getChildren: REVIEW_GET_CHILDREN,
isDir: REVIEW_IS_DIR,
defaultExpanded: "all",
});
return (
<div className="flex flex-col h-full text-sm">
<div className="px-2 py-2 shrink-0">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 border border-border/50 focus-within:border-border focus-within:bg-muted/80 transition-colors">
<IconSearch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
ref={inputRef}
value={filter}
onChange={(e) => onFilterChange(e.target.value)}
placeholder="Filter changed files"
className="flex-1 bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground outline-none min-w-0"
/>
{filter && (
<button
onClick={() => {
onFilterChange("");
inputRef.current?.focus();
}}
className="text-muted-foreground hover:text-foreground cursor-pointer"
>
<IconX className="h-3 w-3" />
</button>
)}
</div>
</div>
<ReviewFilterInput ref={inputRef} value={filter} onChange={onFilterChange} />
<div className="py-1 overflow-y-auto flex-1">
{tree.map((node) => (
<TreeNode
key={node.path}
node={node}
{visibleRows.map((row) => (
<ReviewTreeRow
key={row.path}
row={row}
reviewedFiles={reviewedFiles}
staleFiles={staleFiles}
commentCountByFile={commentCountByFile}
selectedFile={selectedFile}
onSelectFile={onSelectFile}
onToggleReviewed={onToggleReviewed}
depth={0}
onToggleDir={() => toggle(row.path)}
/>
))}
</div>
@@ -85,30 +78,103 @@ export const ReviewFileTree = memo(function ReviewFileTree({
);
});
type TreeNodeProps = {
node: FileTreeNode;
interface ReviewFilterInputProps {
ref: React.RefObject<HTMLInputElement | null>;
value: string;
onChange: (v: string) => void;
}
function ReviewFilterInput({ ref, value, onChange }: ReviewFilterInputProps) {
return (
<div className="px-2 py-2 shrink-0">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 border border-border/50 focus-within:border-border focus-within:bg-muted/80 transition-colors">
<IconSearch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Filter changed files"
className="flex-1 bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground outline-none min-w-0"
/>
{value && (
<button
onClick={() => {
onChange("");
ref.current?.focus();
}}
className="text-muted-foreground hover:text-foreground cursor-pointer"
>
<IconX className="h-3 w-3" />
</button>
)}
</div>
</div>
);
}
interface ReviewTreeRowProps {
row: VisibleRow<FileTreeNode>;
reviewedFiles: Set<string>;
staleFiles: Set<string>;
commentCountByFile: Record<string, number>;
selectedFile: string | null;
onSelectFile: (path: string) => void;
onToggleReviewed: (path: string, reviewed: boolean) => void;
depth: number;
};
onToggleDir: () => void;
}
function FileNode({
node,
function ReviewTreeRow(props: ReviewTreeRowProps) {
if (props.row.isDir) return <ReviewDirRow {...props} />;
return <ReviewFileRow {...props} />;
}
function ReviewDirRow({ row, onToggleDir }: ReviewTreeRowProps) {
const isRepoRoot = Boolean(row.node.isRepoRoot);
const fileCount = isRepoRoot ? countLeafFiles(row.node) : 0;
return (
<div data-testid={isRepoRoot ? "repo-root-node" : "dir-node"}>
<button
type="button"
className={cn(
"flex items-center w-full gap-1 px-2 py-1 hover:bg-muted/50 transition-colors cursor-pointer",
isRepoRoot && "border-t border-border/40 first:border-t-0 mt-1 first:mt-0",
)}
style={{ paddingLeft: `${row.depth * 12 + 8}px` }}
onClick={onToggleDir}
>
{row.isExpanded ? (
<IconChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<IconChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span
className={cn(
"text-[13px] truncate",
isRepoRoot ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{row.node.name}
</span>
{isRepoRoot && fileCount > 0 && (
<span className="ml-auto text-[10px] text-muted-foreground/70">{fileCount}</span>
)}
</button>
</div>
);
}
function ReviewFileRow({
row,
reviewedFiles,
staleFiles,
commentCountByFile,
selectedFile,
onSelectFile,
onToggleReviewed,
depth,
}: Omit<TreeNodeProps, "node"> & { node: FileTreeNode }) {
const file = node.file!;
// Composite key from reviewFileKey() so two same-name files in
// different repos don't share reviewed/stale/comment-count slots.
}: ReviewTreeRowProps) {
const file = row.node.file as ReviewFile;
// Composite key from reviewFileKey() so two same-name files in different
// repos don't share reviewed/stale/comment-count slots.
const key = reviewFileKey(file);
const isReviewed = reviewedFiles.has(key);
const isStale = staleFiles.has(key);
@@ -120,19 +186,17 @@ function FileNode({
"flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors group",
isSelected ? "bg-accent/50" : "hover:bg-muted/50",
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
style={{ paddingLeft: `${row.depth * 12 + 8}px` }}
onClick={() => onSelectFile(key)}
>
<Checkbox
checked={isReviewed && !isStale}
onCheckedChange={(checked) => {
onToggleReviewed(key, checked === true);
}}
onCheckedChange={(checked) => onToggleReviewed(key, checked === true)}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5"
/>
<FileIcon fileName={node.name} className="h-4 w-4 shrink-0" />
<span className="text-[13px] truncate flex-1">{node.name}</span>
<FileIcon fileName={row.node.name} className="h-4 w-4 shrink-0" />
<span className="text-[13px] truncate flex-1">{row.node.name}</span>
{isStale && <IconAlertTriangle className="h-3 w-3 text-yellow-500 shrink-0" />}
{commentCount > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-blue-500">
@@ -144,76 +208,6 @@ function FileNode({
);
}
function TreeNode({
node,
reviewedFiles,
staleFiles,
commentCountByFile,
selectedFile,
onSelectFile,
onToggleReviewed,
depth,
}: TreeNodeProps) {
const [expanded, setExpanded] = useState(true);
const handleToggle = useCallback(() => {
setExpanded((prev) => !prev);
}, []);
const sharedProps = {
reviewedFiles,
staleFiles,
commentCountByFile,
selectedFile,
onSelectFile,
onToggleReviewed,
};
if (node.isDir) {
// Repo roots get a stronger label so the user sees the multi-repo grouping.
const fileCount = node.isRepoRoot ? countLeafFiles(node) : 0;
return (
<div data-testid={node.isRepoRoot ? "repo-root-node" : "dir-node"}>
<button
type="button"
className={cn(
"flex items-center w-full gap-1 px-2 py-1 hover:bg-muted/50 transition-colors cursor-pointer",
node.isRepoRoot && "border-t border-border/40 first:border-t-0 mt-1 first:mt-0",
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={handleToggle}
>
{expanded ? (
<IconChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<IconChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span
className={cn(
"text-[13px] truncate",
node.isRepoRoot ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{node.name}
</span>
{node.isRepoRoot && fileCount > 0 && (
<span className="ml-auto text-[10px] text-muted-foreground/70">{fileCount}</span>
)}
</button>
{expanded && node.children && (
<div>
{node.children.map((child) => (
<TreeNode key={child.path} node={child} {...sharedProps} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
return <FileNode node={node} {...sharedProps} depth={depth} />;
}
/** Counts leaf (file) nodes anywhere under the given node. Used for the
* "N files" badge on multi-repo repo-root headers. */
function countLeafFiles(node: FileTreeNode): number {
if (!node.isDir) return 1;
let total = 0;
+16 -2
View File
@@ -131,20 +131,34 @@ function buildMultiRepoTree(files: ReviewFile[]): FileTreeNode[] {
}
const repoRoots: FileTreeNode[] = [];
for (const [, entry] of byRepo) {
const repoPath = `__repo__:${entry.id}`;
const subtree = buildFlatTree(entry.files);
// Two repos can contain a same-named path ("src/foo.ts" in both
// frontend and backend). Prefix every node path under this repo with
// the repo root path so tree-node paths are globally unique — required
// for useTree's expansion Set to track them independently.
const prefixedSubtree = subtree.map((n) => prefixPaths(n, repoPath));
repoRoots.push({
name: entry.name,
path: `__repo__:${entry.id}`,
path: repoPath,
isDir: true,
isRepoRoot: true,
repositoryId: entry.id,
children: subtree,
children: prefixedSubtree,
});
}
// Stable alphabetical order by repo name.
return repoRoots.sort((a, b) => a.name.localeCompare(b.name));
}
function prefixPaths(node: FileTreeNode, prefix: string): FileTreeNode {
return {
...node,
path: `${prefix}/${node.path}`,
children: node.children?.map((c) => prefixPaths(c, prefix)),
};
}
function buildFlatTree(files: ReviewFile[]): FileTreeNode[] {
const root: FileTreeNode = { name: "", path: "", isDir: true, children: [] };
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { FileTree, type FileTreeNode } from "./file-tree";
afterEach(cleanup);
function file(name: string, parentPath = ""): FileTreeNode {
return {
name,
path: parentPath ? `${parentPath}/${name}` : name,
isDir: false,
children: [],
};
}
type RenderOpts = Partial<Parameters<typeof FileTree>[0]>;
function renderTree(nodes: FileTreeNode[], overrides: RenderOpts = {}) {
const onSelectPath = vi.fn();
const onCheckedPathsChange = vi.fn();
render(
<FileTree
nodes={nodes}
onSelectPath={onSelectPath}
onCheckedPathsChange={onCheckedPathsChange}
{...overrides}
/>,
);
return { onSelectPath, onCheckedPathsChange };
}
describe("FileTree rendering and selection", () => {
it("renders top-level nodes by default with dirs collapsed", () => {
const tree: FileTreeNode[] = [
{
name: "src",
path: "src",
isDir: true,
children: [file("a.ts", "src"), file("b.ts", "src")],
},
file("README.md"),
];
renderTree(tree);
expect(screen.getByText("src")).toBeTruthy();
expect(screen.getByText("README.md")).toBeTruthy();
// Children of "src" are hidden until expanded.
expect(screen.queryByText("a.ts")).toBeNull();
expect(screen.queryByText("b.ts")).toBeNull();
});
it("clicking a directory expands it and reveals children", () => {
const tree: FileTreeNode[] = [
{
name: "src",
path: "src",
isDir: true,
children: [file("a.ts", "src")],
},
];
renderTree(tree);
fireEvent.click(screen.getByText("src"));
expect(screen.getByText("a.ts")).toBeTruthy();
});
it("clicking a file fires onSelectPath with its path", () => {
const tree: FileTreeNode[] = [file("README.md")];
const { onSelectPath } = renderTree(tree);
fireEvent.click(screen.getByText("README.md"));
expect(onSelectPath).toHaveBeenCalledWith("README.md");
});
it("defaultExpanded=true expands all directories on mount", () => {
const tree: FileTreeNode[] = [
{
name: "src",
path: "src",
isDir: true,
children: [
{
name: "ui",
path: "src/ui",
isDir: true,
children: [file("Button.tsx", "src/ui")],
},
],
},
];
renderTree(tree, { defaultExpanded: true });
// The src/ui chain collapses to a single display row "src/ui",
// and the deepest dir's children are visible because all dirs
// are pre-expanded.
expect(screen.getByText("Button.tsx")).toBeTruthy();
});
it("collapses single-child directory chains into 'a/b' display names", () => {
const tree: FileTreeNode[] = [
{
name: "src",
path: "src",
isDir: true,
children: [
{
name: "ui",
path: "src/ui",
isDir: true,
children: [file("Button.tsx", "src/ui")],
},
],
},
];
renderTree(tree);
// Initial render: "src/ui" appears as one row (chain collapse), no
// separate "src" or "ui" rows.
expect(screen.getByText("src/ui")).toBeTruthy();
expect(screen.queryByText("src")).toBeNull();
expect(screen.queryByText("ui")).toBeNull();
});
it("selectedPath highlights the matching file row", () => {
const tree: FileTreeNode[] = [file("a.ts")];
renderTree(tree, { selectedPath: "a.ts" });
const row = screen.getByText("a.ts").parentElement!;
expect(row.className).toContain("bg-accent");
});
it("renderExtra slot is invoked per row and its output appears in the DOM", () => {
const tree: FileTreeNode[] = [file("a.ts"), file("b.ts")];
const renderExtra = vi.fn((n: FileTreeNode) => <span>{`x:${n.name}`}</span>);
renderTree(tree, { renderExtra });
expect(screen.getByText("x:a.ts")).toBeTruthy();
expect(screen.getByText("x:b.ts")).toBeTruthy();
expect(renderExtra).toHaveBeenCalledTimes(2);
});
});
describe("FileTree checkboxes", () => {
it("does not render checkboxes when showCheckboxes is false", () => {
const tree: FileTreeNode[] = [file("a.ts")];
renderTree(tree);
expect(screen.queryByRole("checkbox")).toBeNull();
});
it("renders a checkbox per node when showCheckboxes and checkedPaths are set", () => {
const tree: FileTreeNode[] = [file("a.ts"), file("b.ts")];
renderTree(tree, { showCheckboxes: true, checkedPaths: new Set<string>() });
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
it("clicking a directory checkbox propagates the toggle to every leaf under it", () => {
const tree: FileTreeNode[] = [
{
name: "src",
path: "src",
isDir: true,
children: [file("a.ts", "src"), file("b.ts", "src")],
},
];
const { onCheckedPathsChange } = renderTree(tree, {
showCheckboxes: true,
checkedPaths: new Set<string>(),
defaultExpanded: true,
});
// There are three checkboxes: src, a.ts, b.ts. The first one is the dir.
const boxes = screen.getAllByRole("checkbox");
fireEvent.click(boxes[0]);
expect(onCheckedPathsChange).toHaveBeenCalledTimes(1);
const next = onCheckedPathsChange.mock.calls[0][0] as Set<string>;
expect(next.has("src/a.ts")).toBe(true);
expect(next.has("src/b.ts")).toBe(true);
});
it("dir checkbox renders indeterminate when only some descendants are checked", () => {
const tree: FileTreeNode[] = [
{
name: "src",
path: "src",
isDir: true,
children: [file("a.ts", "src"), file("b.ts", "src")],
},
];
renderTree(tree, {
showCheckboxes: true,
checkedPaths: new Set<string>(["src/a.ts"]),
defaultExpanded: true,
});
const [dirBox] = screen.getAllByRole("checkbox");
expect(dirBox.getAttribute("data-state")).toBe("indeterminate");
});
it("checkbox click does not bubble to the row's onSelectPath", () => {
const tree: FileTreeNode[] = [file("a.ts")];
const { onSelectPath } = renderTree(tree, {
showCheckboxes: true,
checkedPaths: new Set<string>(),
});
const box = screen.getByRole("checkbox");
fireEvent.click(box);
expect(onSelectPath).not.toHaveBeenCalled();
});
});
+101 -134
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState, type ReactNode } from "react";
import { useCallback, type ReactNode } from "react";
import {
IconChevronRight,
IconChevronDown,
@@ -10,6 +10,8 @@ import {
} from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { cn } from "@/lib/utils";
import { useTree, type VisibleRow } from "@/hooks/use-tree";
import { useTreeKeyboardNav } from "@/hooks/use-tree-keyboard-nav";
export interface FileTreeNode {
name: string;
@@ -19,6 +21,13 @@ export interface FileTreeNode {
content?: string;
}
// Module-level adapter constants so they keep a stable identity across
// renders. Without this, useTree's visibleRows useMemo would recompute on
// every parent render — the adapters land in its dependency array.
const FILE_TREE_GET_PATH = (n: FileTreeNode) => n.path;
const FILE_TREE_GET_CHILDREN = (n: FileTreeNode) => n.children;
const FILE_TREE_IS_DIR = (n: FileTreeNode) => n.isDir;
interface FileTreeProps {
nodes: FileTreeNode[];
selectedPath?: string | null;
@@ -30,21 +39,22 @@ interface FileTreeProps {
defaultExpanded?: boolean;
}
/** Collect all leaf (file) paths under a node */
/** Collect all leaf (file) paths under a node. */
function getLeafPaths(node: FileTreeNode): string[] {
if (!node.isDir) return [node.path];
return node.children.flatMap(getLeafPaths);
}
/** Collapse single-child directory chains into "a/b/c" display names */
function collapseChain(node: FileTreeNode): { displayName: string; node: FileTreeNode } {
let current = node;
let name = current.name;
while (current.isDir && current.children.length === 1 && current.children[0].isDir) {
current = current.children[0];
name = `${name}/${current.name}`;
}
return { displayName: name, node: current };
type CheckState = boolean | "indeterminate";
function getCheckState(node: FileTreeNode, checkedPaths: Set<string>): CheckState {
const leaves = getLeafPaths(node);
// An empty leaves array would make .every() vacuously true and the dir
// would render as checked. A dir with no file leaves can't be "all checked".
if (leaves.length === 0) return false;
if (leaves.every((p) => checkedPaths.has(p))) return true;
if (leaves.some((p) => checkedPaths.has(p))) return "indeterminate";
return false;
}
export function FileTree({
@@ -57,31 +67,25 @@ export function FileTree({
renderExtra,
defaultExpanded = false,
}: FileTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => {
if (!defaultExpanded) return new Set<string>();
const dirs = new Set<string>();
function walk(list: FileTreeNode[]) {
for (const n of list) {
if (n.isDir) {
dirs.add(n.path);
walk(n.children);
}
}
}
walk(nodes);
return dirs;
const tree = useTree<FileTreeNode>({
nodes,
getPath: FILE_TREE_GET_PATH,
getChildren: FILE_TREE_GET_CHILDREN,
isDir: FILE_TREE_IS_DIR,
chainCollapse: true,
defaultExpanded: defaultExpanded ? "all" : undefined,
});
const { visibleRows, toggle } = tree;
const nav = useTreeKeyboardNav<FileTreeNode>({
visibleRows,
toggle,
expand: tree.expand,
collapse: tree.collapse,
isExpanded: tree.isExpanded,
onActivate: (row) => onSelectPath?.(row.path),
});
const toggleExpand = useCallback((path: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
const toggleCheck = useCallback(
const handleToggleCheck = useCallback(
(node: FileTreeNode) => {
if (!checkedPaths || !onCheckedPathsChange) return;
const paths = getLeafPaths(node);
@@ -97,19 +101,28 @@ export function FileTree({
);
return (
<div className="overflow-y-auto py-1">
{nodes.map((node) => (
<TreeNodeRow
key={node.path}
node={node}
depth={0}
expanded={expanded}
onToggleExpand={toggleExpand}
selectedPath={selectedPath}
onSelectPath={onSelectPath}
checkedPaths={checkedPaths}
<div
className="overflow-y-auto py-1 outline-none"
tabIndex={0}
role="tree"
onKeyDown={nav.handleKeyDown}
>
{visibleRows.map((row) => (
<FileTreeRow
key={row.path}
row={row}
isActive={!row.isDir && selectedPath === row.path}
isFocused={nav.focusedPath === row.path}
checkState={
showCheckboxes && checkedPaths ? getCheckState(row.node, checkedPaths) : undefined
}
showCheckboxes={showCheckboxes}
onToggleCheck={toggleCheck}
onClick={() => {
nav.setFocusedPath(row.path);
if (row.isDir) toggle(row.path);
else onSelectPath?.(row.path);
}}
onToggleCheck={() => handleToggleCheck(row.node)}
renderExtra={renderExtra}
/>
))}
@@ -117,102 +130,56 @@ export function FileTree({
);
}
interface TreeNodeRowProps {
node: FileTreeNode;
depth: number;
expanded: Set<string>;
onToggleExpand: (path: string) => void;
selectedPath?: string | null;
onSelectPath?: (path: string) => void;
checkedPaths?: Set<string>;
interface FileTreeRowProps {
row: VisibleRow<FileTreeNode>;
isActive: boolean;
isFocused: boolean;
checkState?: CheckState;
showCheckboxes: boolean;
onToggleCheck: (node: FileTreeNode) => void;
onClick: () => void;
onToggleCheck: () => void;
renderExtra?: (node: FileTreeNode) => ReactNode;
}
function TreeNodeRow({
node,
depth,
expanded,
onToggleExpand,
selectedPath,
onSelectPath,
checkedPaths,
function FileTreeRow({
row,
isActive,
isFocused,
checkState,
showCheckboxes,
onClick,
onToggleCheck,
renderExtra,
}: TreeNodeRowProps) {
const { displayName, node: effectiveNode } = collapseChain(node);
const isExpanded = expanded.has(effectiveNode.path);
const isActive = !effectiveNode.isDir && selectedPath === effectiveNode.path;
const handleClick = () => {
if (effectiveNode.isDir) {
onToggleExpand(effectiveNode.path);
} else {
onSelectPath?.(effectiveNode.path);
}
};
const checkState = (() => {
if (!showCheckboxes || !checkedPaths) return undefined;
const leaves = getLeafPaths(effectiveNode);
const allChecked = leaves.every((p) => checkedPaths.has(p));
if (allChecked) return true;
const someChecked = leaves.some((p) => checkedPaths.has(p));
if (someChecked) return "indeterminate" as const;
return false;
})();
const FolderIcon = isExpanded ? IconFolderOpen : IconFolder;
const ChevronIcon = isExpanded ? IconChevronDown : IconChevronRight;
}: FileTreeRowProps) {
const FolderIcon = row.isExpanded ? IconFolderOpen : IconFolder;
const ChevronIcon = row.isExpanded ? IconChevronDown : IconChevronRight;
return (
<>
<div
className={cn(
"flex items-center gap-1.5 px-2 py-1 text-sm cursor-pointer hover:bg-accent/50",
isActive && "bg-accent",
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleClick}
>
{showCheckboxes && checkState !== undefined && (
<Checkbox
checked={checkState}
onCheckedChange={() => onToggleCheck(effectiveNode)}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer shrink-0"
/>
)}
{effectiveNode.isDir && (
<ChevronIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
{effectiveNode.isDir ? (
<FolderIcon className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<IconFileText className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="truncate">{displayName}</span>
{renderExtra?.(effectiveNode)}
</div>
{effectiveNode.isDir &&
isExpanded &&
effectiveNode.children.map((child) => (
<TreeNodeRow
key={child.path}
node={child}
depth={depth + 1}
expanded={expanded}
onToggleExpand={onToggleExpand}
selectedPath={selectedPath}
onSelectPath={onSelectPath}
checkedPaths={checkedPaths}
showCheckboxes={showCheckboxes}
onToggleCheck={onToggleCheck}
renderExtra={renderExtra}
/>
))}
</>
<div
className={cn(
"flex items-center gap-1.5 px-2 py-1 text-sm cursor-pointer hover:bg-accent/50",
isActive && "bg-accent",
isFocused && "ring-1 ring-ring",
)}
style={{ paddingLeft: `${row.depth * 16 + 8}px` }}
onClick={onClick}
>
{showCheckboxes && checkState !== undefined && (
<Checkbox
checked={checkState}
onCheckedChange={onToggleCheck}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer shrink-0"
/>
)}
{row.isDir && <ChevronIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
{row.isDir ? (
<FolderIcon className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<IconFileText className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="truncate">{row.displayName}</span>
{renderExtra?.(row.node)}
</div>
);
}
+43 -13
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { getWebSocketClient } from "@/lib/ws/connection";
import {
requestFileTree,
@@ -16,7 +16,20 @@ import {
setFilesPanelScrollPosition,
} from "@/lib/local-storage";
import type { useToast } from "@/components/toast-provider";
import { useTree, type VisibleRow } from "@/hooks/use-tree";
import { mergeTreeNodes } from "./file-browser-parts";
import { compareTreeNodes, sortRootChildren } from "./file-tree-utils";
const FB_GET_PATH = (n: FileTreeNode) => n.path;
// Children are sorted (dirs first, then files, alphabetically) on every
// access so the flat visibleRows list comes out in the same order the
// recursive render produced. The backend doesn't guarantee order and tree
// mutations (rename / create) can insert arbitrarily.
const FB_GET_CHILDREN = (n: FileTreeNode) =>
n.children ? [...n.children].sort(compareTreeNodes) : undefined;
const FB_IS_DIR = (n: FileTreeNode) => n.is_dir;
export type FileBrowserRow = VisibleRow<FileTreeNode>;
const MAX_RETRY_ATTEMPTS = 4;
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
@@ -108,7 +121,7 @@ export function useFileBrowserSearch(sessionId: string) {
function applyFileChanges(ctx: {
client: ReturnType<typeof getWebSocketClient>;
sessionId: string;
expandedPaths: Set<string>;
expandedPaths: ReadonlySet<string>;
changes: Array<{ path: string }>;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
@@ -279,15 +292,18 @@ function useTreeLoader(ctx: TreeLoaderContext) {
function useFileChangeSubscription({
sessionIdRef,
expandedPaths,
expandedPathsRef,
setTree,
setLoadState,
}: {
sessionIdRef: React.MutableRefObject<string>;
expandedPaths: Set<string>;
expandedPathsRef: React.MutableRefObject<ReadonlySet<string>>;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
}) {
// expandedPaths is read via ref so that toggling a directory (which mints a
// new Set) does not tear down and re-attach this WS listener — any event
// arriving during the swap would otherwise be silently dropped.
useEffect(() => {
const client = getWebSocketClient();
if (!client) return;
@@ -297,14 +313,13 @@ function useFileChangeSubscription({
applyFileChanges({
client,
sessionId: sessionIdRef.current,
expandedPaths,
expandedPaths: expandedPathsRef.current,
changes,
setTree,
setLoadState,
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedPaths]);
}, [sessionIdRef, expandedPathsRef, setTree, setLoadState]);
}
/**
@@ -317,7 +332,23 @@ function useFileChangeSubscription({
export function useFileBrowserTree(sessionId: string, resetKey?: string) {
const effectiveResetKey = resetKey ?? sessionId;
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
// Expansion + flat visibleRows come from the shared useTree hook. tree is
// a single root node with children; useTree wants an array, so feed it the
// root's children (pre-sorted — see sortRootChildren).
const treeNodes = useMemo(() => sortRootChildren(tree), [tree]);
const treeApi = useTree<FileTreeNode>({
nodes: treeNodes,
getPath: FB_GET_PATH,
getChildren: FB_GET_CHILDREN,
isDir: FB_IS_DIR,
});
const expandedPaths = treeApi.expanded;
const setExpandedPaths = treeApi.setExpanded;
const visibleRows = treeApi.visibleRows;
// Stable ref over `expandedPaths` so the WS file-change subscription does
// not re-attach on every toggle (each toggle mints a new Set reference).
const expandedPathsRef = useRef<ReadonlySet<string>>(expandedPaths);
expandedPathsRef.current = expandedPaths;
const [isLoadingTree, setIsLoadingTree] = useState(true);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [loadError, setLoadError] = useState<string | null>(null);
@@ -373,7 +404,7 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
return () => {
clearRetryTimer();
};
}, [clearRetryTimer, loadTree, effectiveResetKey, sessionId]);
}, [clearRetryTimer, loadTree, effectiveResetKey, sessionId, setExpandedPaths]);
// Fire the initial load on the waiting → ready transition. `loadState` is
// read via a ref so a failed load (which sets state back to "waiting") does
@@ -400,17 +431,16 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
}
}, [expandedPaths, effectiveResetKey]);
useFileChangeSubscription({ sessionIdRef, expandedPaths, setTree, setLoadState });
useFileChangeSubscription({ sessionIdRef, expandedPathsRef, setTree, setLoadState });
const collapseAll = useCallback(() => {
setExpandedPaths(new Set());
}, []);
const collapseAll = treeApi.collapseAll;
return {
tree,
setTree,
expandedPaths,
setExpandedPaths,
visibleRows,
visibleLoadingPaths,
isLoadingTree,
loadState,
+60 -86
View File
@@ -12,9 +12,9 @@ import { cn } from "@/lib/utils";
import { FileIcon } from "@/components/ui/file-icon";
import type { FileTreeNode } from "@/lib/types/backend";
import type { FileInfo } from "@/lib/state/store";
import type { FileBrowserRow } from "./file-browser-hooks";
import { InlineFileInput } from "./inline-file-input";
import { renderSessionOrLoadState } from "./file-browser-load-state";
import { compareTreeNodes } from "./file-tree-utils";
import {
FileContextMenu,
useFileRename,
@@ -32,22 +32,17 @@ export {
type GitFileStatus = FileInfo["status"] | undefined;
type TreeNodeItemProps = {
node: FileTreeNode;
depth: number;
expandedPaths: Set<string>;
type TreeNodeRowProps = {
row: FileBrowserRow;
activeFolderPath: string;
activeFilePath?: string | null;
visibleLoadingPaths: Set<string>;
creatingInPath: string | null;
fileStatuses: Map<string, GitFileStatus>;
tree: FileTreeNode | null;
onToggleExpand: (node: FileTreeNode) => void;
onOpenFile: (path: string) => void;
onDeleteFile?: (path: string) => Promise<boolean>;
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
onCreateFileSubmit: (parentPath: string, name: string) => void;
onCancelCreate: () => void;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
isSelectedFn?: (path: string) => boolean;
onSelect?: (path: string, e: React.MouseEvent) => boolean;
@@ -119,28 +114,6 @@ function TreeNodeFileIcon({
);
}
/** Expanded directory children */
function TreeNodeChildren({ props, depth }: { props: TreeNodeItemProps; depth: number }) {
const { node, creatingInPath, onCreateFileSubmit, onCancelCreate } = props;
return (
<div>
{creatingInPath === node.path && (
<InlineFileInput
depth={depth + 1}
onSubmit={(name) => onCreateFileSubmit(node.path, name)}
onCancel={onCancelCreate}
/>
)}
{node.children &&
[...node.children]
.sort(compareTreeNodes)
.map((child) => (
<TreeNodeItem key={child.path} {...props} node={child} depth={depth + 1} />
))}
</div>
);
}
function getTreeNodeRowClass(
isActive: boolean,
isActiveFolder: boolean,
@@ -160,13 +133,13 @@ function getTreeNodeRowClass(
);
}
export function TreeNodeItem(props: TreeNodeItemProps) {
const { node, depth, expandedPaths, activeFolderPath, activeFilePath, visibleLoadingPaths } =
props;
export function TreeNodeItem(props: TreeNodeRowProps) {
const { row, activeFolderPath, activeFilePath, visibleLoadingPaths } = props;
const { fileStatuses, tree, onToggleExpand, onOpenFile, onDeleteFile, onRenameFile, setTree } =
props;
const node = row.node;
const isExpanded = expandedPaths.has(node.path);
const isExpanded = row.isExpanded;
const isActive = !node.is_dir && activeFilePath === node.path;
const isActiveFolder = node.is_dir && activeFolderPath === node.path;
const gitStatus = node.is_dir ? undefined : fileStatuses.get(node.path);
@@ -198,7 +171,7 @@ export function TreeNodeItem(props: TreeNodeItemProps) {
props.isDragging,
isDropTarget,
)}
style={{ paddingLeft: treeNodePaddingLeft(depth, node.is_dir) }}
style={{ paddingLeft: treeNodePaddingLeft(row.depth, node.is_dir) }}
onClick={handleClick}
draggable={!!props.onDragStart}
onDragStart={(e) => props.onDragStart?.(node.path, e)}
@@ -225,21 +198,18 @@ export function TreeNodeItem(props: TreeNodeItemProps) {
);
return (
<div>
<FileContextMenu
node={node}
tree={tree}
setTree={setTree}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onStartRename={rename.handleStartRename}
selectedCount={props.selectedCount}
selectedPaths={props.selectedPaths}
>
{rowContent}
</FileContextMenu>
{node.is_dir && isExpanded && <TreeNodeChildren props={props} depth={depth} />}
</div>
<FileContextMenu
node={node}
tree={tree}
setTree={setTree}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onStartRename={rename.handleStartRename}
selectedCount={props.selectedCount}
selectedPaths={props.selectedPaths}
>
{rowContent}
</FileContextMenu>
);
}
@@ -310,7 +280,7 @@ type FileBrowserContentAreaProps = {
loadError: string | null;
creatingInPath: string | null;
fileStatuses: Map<string, GitFileStatus>;
expandedPaths: Set<string>;
visibleRows: FileBrowserRow[];
activeFolderPath: string;
activeFilePath?: string | null;
visibleLoadingPaths: Set<string>;
@@ -335,8 +305,35 @@ type FileBrowserContentAreaProps = {
selectedPaths?: Set<string>;
};
function rowToItemProps(props: FileBrowserContentAreaProps, row: FileBrowserRow): TreeNodeRowProps {
return {
row,
activeFolderPath: props.activeFolderPath,
activeFilePath: props.activeFilePath,
visibleLoadingPaths: props.visibleLoadingPaths,
fileStatuses: props.fileStatuses,
tree: props.tree,
onToggleExpand: props.onToggleExpand,
onOpenFile: props.onOpenFile,
onDeleteFile: props.onDeleteFile,
onRenameFile: props.onRenameFile,
setTree: props.setTree,
isSelectedFn: props.isSelectedFn,
onSelect: props.onSelect,
isDragging: props.isDragging,
dragOverPath: props.dragOverPath,
onDragStart: props.onDragStart,
onDragEnd: props.onDragEnd,
onDragOver: props.onDragOver,
onDragLeave: props.onDragLeave,
onDrop: props.onDrop,
selectedCount: props.selectedCount,
selectedPaths: props.selectedPaths,
};
}
function FileTreeView(props: FileBrowserContentAreaProps) {
const { tree, creatingInPath, onCreateFileSubmit, onCancelCreate } = props;
const { tree, visibleRows, creatingInPath, onCreateFileSubmit, onCancelCreate } = props;
if (!tree) return null;
return (
<div className="pb-2">
@@ -347,41 +344,18 @@ function FileTreeView(props: FileBrowserContentAreaProps) {
onCancel={onCancelCreate}
/>
)}
{tree.children &&
[...tree.children]
.sort(compareTreeNodes)
.map((child) => (
<TreeNodeItem
key={child.path}
node={child}
depth={0}
expandedPaths={props.expandedPaths}
activeFolderPath={props.activeFolderPath}
activeFilePath={props.activeFilePath}
visibleLoadingPaths={props.visibleLoadingPaths}
creatingInPath={creatingInPath}
fileStatuses={props.fileStatuses}
tree={tree}
onToggleExpand={props.onToggleExpand}
onOpenFile={props.onOpenFile}
onDeleteFile={props.onDeleteFile}
onRenameFile={props.onRenameFile}
onCreateFileSubmit={onCreateFileSubmit}
onCancelCreate={onCancelCreate}
setTree={props.setTree}
isSelectedFn={props.isSelectedFn}
onSelect={props.onSelect}
isDragging={props.isDragging}
dragOverPath={props.dragOverPath}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
onDragOver={props.onDragOver}
onDragLeave={props.onDragLeave}
onDrop={props.onDrop}
selectedCount={props.selectedCount}
selectedPaths={props.selectedPaths}
{visibleRows.map((row) => (
<React.Fragment key={row.path}>
<TreeNodeItem {...rowToItemProps(props, row)} />
{creatingInPath === row.path && row.isDir && row.isExpanded && (
<InlineFileInput
depth={row.depth + 1}
onSubmit={(name) => onCreateFileSubmit(row.path, name)}
onCancel={onCancelCreate}
/>
))}
)}
</React.Fragment>
))}
</div>
);
}
+1 -1
View File
@@ -442,7 +442,7 @@ export function FileBrowser({
loadError={treeState.loadError}
creatingInPath={handlers.creatingInPath}
fileStatuses={fileStatuses}
expandedPaths={treeState.expandedPaths}
visibleRows={treeState.visibleRows}
activeFolderPath={handlers.activeFolderPath}
activeFilePath={activeFilePath}
visibleLoadingPaths={treeState.visibleLoadingPaths}
@@ -5,6 +5,7 @@ import {
computeMoveTargets,
findNodeByPath,
getVisiblePaths,
sortRootChildren,
} from "./file-tree-utils";
function file(name: string, parentPath = ""): FileTreeNode {
@@ -87,6 +88,38 @@ describe("moveNodesInTree", () => {
});
});
describe("sortRootChildren", () => {
it("orders directories before files at the root, alpha within each group", () => {
// Backend can return root children in any order — interleaved dirs/files
// and not necessarily alphabetical. The file browser feeds this list
// straight into useTree, which never re-sorts its top-level `nodes`, so
// this helper is the only thing keeping dirs on top at depth 0.
const tree = root([
file("README.md"),
dir("zeta", []),
file(".babelrc"),
dir("alpha", []),
file("package.json"),
dir("src", []),
]);
// compareTreeNodes uses localeCompare, which is case-insensitive by
// default — "package" (p) sorts before "README" (R/r).
expect(sortRootChildren(tree).map((n) => n.name)).toEqual([
"alpha",
"src",
"zeta",
".babelrc",
"package.json",
"README.md",
]);
});
it("returns an empty array for a null tree or a tree with no children", () => {
expect(sortRootChildren(null)).toEqual([]);
expect(sortRootChildren({ name: "", path: "", is_dir: true })).toEqual([]);
});
});
describe("getVisiblePaths", () => {
it("returns all nodes in DFS order including directories", () => {
const tree = root([dir("lib", [file("config.ts", "lib")], ""), file("readme.md")]);
+12 -1
View File
@@ -6,6 +6,17 @@ export const compareTreeNodes = (a: FileTreeNode, b: FileTreeNode): number => {
return a.name.localeCompare(b.name);
};
/**
* Return the tree's root children sorted dirs-first / alpha. useTree iterates
* its top-level `nodes` directly (it only invokes `getChildren` to descend),
* so the root list must be pre-sorted by the caller — otherwise backend order
* leaks through and dirs interleave with files at depth 0.
*/
export function sortRootChildren(tree: FileTreeNode | null): FileTreeNode[] {
if (!tree?.children) return [];
return [...tree.children].sort(compareTreeNodes);
}
/**
* Merge a freshly-fetched tree node into an existing one, preserving
* already-loaded children so expanded folders don't collapse.
@@ -89,7 +100,7 @@ export function renameNodeInTree(
}
/** Collect visible (expanded) node paths in DFS order for multi-select range computation. */
export function getVisiblePaths(tree: FileTreeNode, expandedPaths: Set<string>): string[] {
export function getVisiblePaths(tree: FileTreeNode, expandedPaths: ReadonlySet<string>): string[] {
const result: string[] = [];
function walk(node: FileTreeNode) {
// Skip the root node itself (it represents the workspace root)
@@ -0,0 +1,188 @@
import { type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../../helpers/git-helper";
// File creation lives in file-browser-toolbar.tsx ("New file" button) +
// inline-file-input.tsx (InlineFileInput) + file-browser.tsx
// (handleStartCreate / handleCreateFileSubmit).
// Behaviour to lock in:
// - "New file" at the tree root creates a file at root
// - After expanding a folder, "New file" creates inside that folder
// - Typing a path-like name (subdir/file.ts) creates implicit parent folders
// - Escape cancels the input
// There is no separate "create folder" affordance today; users create folders
// implicitly by including a "/" in the new-file name.
async function setupTask(
testPage: Page,
apiClient: ApiClient,
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
profileName: string,
taskTitle: string,
) {
const profile = await createStandardProfile(apiClient, profileName);
await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, taskTitle);
await session.clickTab("Files");
return session;
}
async function startCreateAtRoot(testPage: Page) {
const btn = testPage.getByRole("button", { name: "New file" });
await expect(btn).toBeVisible({ timeout: 15_000 });
await btn.click();
const input = testPage.getByPlaceholder("filename...");
await expect(input).toBeVisible({ timeout: 5_000 });
await expect(input).toBeFocused({ timeout: 2_000 });
return input;
}
test.describe("File tree create file", () => {
test("New file at root creates a file on disk and in the tree", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
// Seed at least one file so the tree loads. Without any files the tree
// shows "No files found" instead of the toolbar.
git.createFile("seed.ts", "seed");
git.stageAll();
git.commit("seed");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-create-root",
"FT Create Root",
);
await expect(session.fileTreeNode("seed.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
await input.fill("brand-new.ts");
await input.press("Enter");
await expect(session.fileTreeNode("brand-new.ts")).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fs.existsSync(path.join(repoDir, "brand-new.ts")), { timeout: 10_000 })
.toBe(true);
});
test("New file inside expanded folder creates the file in that folder", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("scope/existing.ts", "x");
git.stageAll();
git.commit("seed scope");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-create-folder",
"FT Create In Folder",
);
// Expand the folder so it becomes the "active folder" for handleStartCreate.
const folder = session.fileTreeNode("scope");
await expect(folder).toBeVisible({ timeout: 15_000 });
await folder.click();
await expect(session.fileTreeNode("scope/existing.ts")).toBeVisible({ timeout: 10_000 });
await testPage.getByRole("button", { name: "New file" }).click();
const input = testPage.getByPlaceholder("filename...");
await expect(input).toBeVisible({ timeout: 5_000 });
await input.fill("inside.ts");
await input.press("Enter");
await expect(session.fileTreeNode("scope/inside.ts")).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fs.existsSync(path.join(repoDir, "scope", "inside.ts")), { timeout: 10_000 })
.toBe(true);
});
test("typing path-like name creates implicit parent folder", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("seed.ts", "seed");
git.stageAll();
git.commit("seed");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-create-implicit",
"FT Create Implicit Folder",
);
await expect(session.fileTreeNode("seed.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
await input.fill("newdir/leaf.ts");
await input.press("Enter");
// The file appears under the new folder on disk.
await expect
.poll(() => fs.existsSync(path.join(repoDir, "newdir", "leaf.ts")), { timeout: 10_000 })
.toBe(true);
});
test("Escape cancels the inline input without creating a file", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("seed.ts", "seed");
git.stageAll();
git.commit("seed");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-create-cancel",
"FT Create Cancel",
);
await expect(session.fileTreeNode("seed.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
await input.fill("ghost.ts");
await input.press("Escape");
await expect(testPage.getByPlaceholder("filename...")).toHaveCount(0, { timeout: 5_000 });
await expect(session.fileTreeNode("ghost.ts")).toHaveCount(0);
expect(fs.existsSync(path.join(repoDir, "ghost.ts"))).toBe(false);
});
});
@@ -0,0 +1,157 @@
import { type Page, type Locator } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../../helpers/git-helper";
// DnD in file-browser.tsx uses native HTML5 drag events (dragstart, dragover,
// drop) keyed off React's onDragStart/Over/Drop. Playwright's locator.dragTo()
// does not trigger native HTML5 DnD reliably in Chromium - the drop target
// must see dragover with preventDefault() and a drop event with the same
// DataTransfer that was set in dragstart.
//
// We dispatch the events manually via page.evaluate(), constructing a shared
// DataTransfer for the dragstart -> drop sequence. This is the established
// workaround for testing HTML5 DnD in Playwright and mirrors what the user
// would do.
async function setupTask(
testPage: Page,
apiClient: ApiClient,
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
profileName: string,
taskTitle: string,
) {
const profile = await createStandardProfile(apiClient, profileName);
await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, taskTitle);
await session.clickTab("Files");
return session;
}
async function dispatchHtmlDnd(testPage: Page, source: Locator, target: Locator) {
// Make sure both are attached and visible.
await source.scrollIntoViewIfNeeded();
await target.scrollIntoViewIfNeeded();
const sourceHandle = await source.elementHandle();
const targetHandle = await target.elementHandle();
if (!sourceHandle || !targetHandle) throw new Error("DnD: source/target missing");
// Real browsers reuse the same DataTransfer across dragstart -> drop. We
// do the whole sequence inside one evaluate() so the instance is shared.
// dragover gets explicit preventDefault() because that's the contract
// React's onDragOver fulfils when the drop is valid - without it, the
// browser would interpret the drop as a navigation attempt for any
// text-typed data and unload the page.
await testPage.evaluate(
([src, dst]) => {
const dt = new DataTransfer();
const fireOn = (el: Element, type: string) => {
const ev = new DragEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
dataTransfer: dt,
});
el.dispatchEvent(ev);
return ev;
};
fireOn(src, "dragstart");
fireOn(dst, "dragenter");
fireOn(dst, "dragover");
fireOn(dst, "drop");
// Source may have been removed from the DOM by the optimistic move -
// guard the dragend call.
if (src.isConnected) fireOn(src, "dragend");
},
[sourceHandle, targetHandle] as const,
);
}
test.describe("File tree drag and drop", () => {
test("drag a file into a folder moves it on disk and in the tree", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("movable.ts", "m");
git.createFile("target-dir/keep.ts", "k");
git.stageAll();
git.commit("seed dnd");
const session = await setupTask(testPage, apiClient, seedData, "ft-dnd-move", "FT DnD Move");
const file = session.fileTreeNode("movable.ts");
const folder = session.fileTreeNode("target-dir");
await expect(file).toBeVisible({ timeout: 15_000 });
await expect(folder).toBeVisible({ timeout: 15_000 });
await dispatchHtmlDnd(testPage, file, folder);
// The file is removed from the root immediately (optimistic update).
await expect(session.fileTreeNode("movable.ts")).toHaveCount(0, { timeout: 10_000 });
// Expand the target folder to verify the moved child landed inside.
// moveNodesInTree does not auto-expand the drop target.
await folder.click();
await expect(session.fileTreeNode("target-dir/movable.ts")).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fs.existsSync(path.join(repoDir, "target-dir", "movable.ts")), {
timeout: 10_000,
})
.toBe(true);
expect(fs.existsSync(path.join(repoDir, "movable.ts"))).toBe(false);
});
test("drop is rejected when dragging a folder onto itself", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("selfdir/leaf.ts", "leaf");
git.stageAll();
git.commit("seed selfdir");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-dnd-self",
"FT DnD Self Reject",
);
const folder = session.fileTreeNode("selfdir");
await expect(folder).toBeVisible({ timeout: 15_000 });
// Drop onto self: handleDragOver short-circuits via isDropInvalid so
// preventDefault is never called, which means the browser would never
// fire drop in real usage. Dispatching events directly bypasses that
// guard, but the drop handler also calls isDropInvalid and bails.
await dispatchHtmlDnd(testPage, folder, folder);
// Tree is unchanged: folder is still at root with its original child.
await expect(session.fileTreeNode("selfdir")).toBeVisible({ timeout: 5_000 });
// Expand and confirm the child is still there.
await folder.click();
await expect(session.fileTreeNode("selfdir/leaf.ts")).toBeVisible({ timeout: 10_000 });
// Disk untouched - no self-nested directory created.
expect(fs.existsSync(path.join(repoDir, "selfdir", "selfdir"))).toBe(false);
});
});
@@ -0,0 +1,114 @@
import { type Page } from "@playwright/test";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../../helpers/git-helper";
// Children of a directory are loaded lazily when the directory is first expanded
// (see useFileBrowserHandlers.toggleExpand -> loadNodeChildren). The pre-refactor
// behaviour we need to lock in: children rows render *after* a directory click,
// they were not in the DOM before, and re-expanding doesn't refetch synchronously
// (children persist across collapse/expand once loaded).
async function setupTask(
testPage: Page,
apiClient: ApiClient,
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
profileName: string,
taskTitle: string,
) {
const profile = await createStandardProfile(apiClient, profileName);
await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, taskTitle);
await session.clickTab("Files");
return session;
}
test.describe("File tree lazy-load on expand", () => {
test("children render only after directory is expanded", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("lazyfolder/child-a.ts", "a");
git.createFile("lazyfolder/child-b.ts", "b");
git.createFile("lazyfolder/nested/deep.ts", "deep");
git.stageAll();
git.commit("seed lazy folder");
const session = await setupTask(testPage, apiClient, seedData, "ft-lazy-load", "FT Lazy Load");
const folder = session.fileTreeNode("lazyfolder");
await expect(folder).toBeVisible({ timeout: 15_000 });
// Pre-expand: children should not be in the DOM yet.
await expect(session.fileTreeNode("lazyfolder/child-a.ts")).toHaveCount(0);
await expect(session.fileTreeNode("lazyfolder/child-b.ts")).toHaveCount(0);
await expect(session.fileTreeNode("lazyfolder/nested")).toHaveCount(0);
// Expand the folder.
await folder.click();
// After expand: direct children render. Nested subfolder appears but its
// own children are not loaded until that subfolder is also expanded.
await expect(session.fileTreeNode("lazyfolder/child-a.ts")).toBeVisible({ timeout: 10_000 });
await expect(session.fileTreeNode("lazyfolder/child-b.ts")).toBeVisible({ timeout: 10_000 });
await expect(session.fileTreeNode("lazyfolder/nested")).toBeVisible({ timeout: 10_000 });
await expect(session.fileTreeNode("lazyfolder/nested/deep.ts")).toHaveCount(0);
// Expand nested -> its child loads.
await session.fileTreeNode("lazyfolder/nested").click();
await expect(session.fileTreeNode("lazyfolder/nested/deep.ts")).toBeVisible({
timeout: 10_000,
});
});
test("collapsing then re-expanding keeps children without refetch", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("keepfolder/keep-a.ts", "a");
git.createFile("keepfolder/keep-b.ts", "b");
git.stageAll();
git.commit("seed keep folder");
const session = await setupTask(testPage, apiClient, seedData, "ft-lazy-keep", "FT Lazy Keep");
const folder = session.fileTreeNode("keepfolder");
await expect(folder).toBeVisible({ timeout: 15_000 });
// Expand
await folder.click();
await expect(session.fileTreeNode("keepfolder/keep-a.ts")).toBeVisible({ timeout: 10_000 });
// Collapse - children removed from DOM
await folder.click();
await expect(session.fileTreeNode("keepfolder/keep-a.ts")).toHaveCount(0, { timeout: 5_000 });
// Re-expand - children come back quickly (already cached in tree state).
await folder.click();
await expect(session.fileTreeNode("keepfolder/keep-a.ts")).toBeVisible({ timeout: 5_000 });
await expect(session.fileTreeNode("keepfolder/keep-b.ts")).toBeVisible({ timeout: 5_000 });
});
});
@@ -0,0 +1,196 @@
import { type Page } from "@playwright/test";
import path from "node:path";
import fs from "node:fs";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../../helpers/git-helper";
// Inline rename lives in file-context-menu.tsx (useFileRename + TreeNodeName).
// Entry points (today, in product code):
// - Right-click -> "Rename" menu item
// - The input is given focus 150ms after isRenaming=true and blur-commit is
// gated by a 400ms ref so the initial focus race doesn't fire onBlur.
// Commit on Enter, cancel on Escape, commit on blur.
// We test the user-visible flow only (no direct DOM hacks), so the 400ms
// blur gate is exercised implicitly.
async function setupTask(
testPage: Page,
apiClient: ApiClient,
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
profileName: string,
taskTitle: string,
) {
const profile = await createStandardProfile(apiClient, profileName);
await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, taskTitle);
await session.clickTab("Files");
return session;
}
async function startRenameViaContextMenu(testPage: Page, node: ReturnType<Page["locator"]>) {
await node.click({ button: "right" });
const renameItem = testPage.getByRole("menuitem", { name: "Rename" });
await expect(renameItem).toBeVisible({ timeout: 5_000 });
await renameItem.click();
// The input is auto-focused ~150ms after isRenaming flips. The Input inside
// the row is the only one with this className combo; locate via role.
const input = node.getByRole("textbox");
await expect(input).toBeVisible({ timeout: 2_000 });
await expect(input).toBeFocused({ timeout: 2_000 });
return input;
}
test.describe("File tree inline rename", () => {
test("rename commits on Enter and the new file appears on disk", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("rename-me.ts", "hello");
git.stageAll();
git.commit("seed rename file");
const session = await setupTask(testPage, apiClient, seedData, "ft-rename", "FT Rename Enter");
const node = session.fileTreeNode("rename-me.ts");
await expect(node).toBeVisible({ timeout: 15_000 });
const input = await startRenameViaContextMenu(testPage, node);
// Select-all then type the new name (the hook also calls .select() but
// doing it explicitly is robust against focus-timing edge cases).
await input.press("ControlOrMeta+A");
await input.fill("renamed.ts");
await input.press("Enter");
// Old node disappears from the tree, new node appears.
await expect(session.fileTreeNode("rename-me.ts")).toHaveCount(0, { timeout: 10_000 });
await expect(session.fileTreeNode("renamed.ts")).toBeVisible({ timeout: 10_000 });
// And the rename hit the file system.
await expect
.poll(() => fs.existsSync(path.join(repoDir, "renamed.ts")), { timeout: 10_000 })
.toBe(true);
expect(fs.existsSync(path.join(repoDir, "rename-me.ts"))).toBe(false);
});
test("rename cancels on Escape and leaves the tree and disk untouched", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("keep-name.ts", "stay");
git.stageAll();
git.commit("seed keep file");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-rename-esc",
"FT Rename Escape",
);
const node = session.fileTreeNode("keep-name.ts");
await expect(node).toBeVisible({ timeout: 15_000 });
const input = await startRenameViaContextMenu(testPage, node);
await input.press("ControlOrMeta+A");
await input.fill("nope.ts");
await input.press("Escape");
// No mutation - original node still present, no renamed node, disk unchanged.
await expect(session.fileTreeNode("keep-name.ts")).toBeVisible({ timeout: 5_000 });
await expect(session.fileTreeNode("nope.ts")).toHaveCount(0);
expect(fs.existsSync(path.join(repoDir, "keep-name.ts"))).toBe(true);
expect(fs.existsSync(path.join(repoDir, "nope.ts"))).toBe(false);
});
test("rename commits on blur after typing a new name", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("blur-original.ts", "blur");
git.createFile("other.ts", "other");
git.stageAll();
git.commit("seed blur file");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-rename-blur",
"FT Rename Blur",
);
const node = session.fileTreeNode("blur-original.ts");
await expect(node).toBeVisible({ timeout: 15_000 });
const input = await startRenameViaContextMenu(testPage, node);
await input.press("ControlOrMeta+A");
await input.fill("blur-final.ts");
// The blur gate is ~400ms after isRenaming flips. Wait that out before
// moving focus elsewhere so the blur handler actually fires the commit.
await testPage.waitForTimeout(500);
// Click another file to blur the input. The other node also belongs to
// the tree, so we don't lose tree-container focus state.
await session.fileTreeNode("other.ts").click();
await expect(session.fileTreeNode("blur-final.ts")).toBeVisible({ timeout: 10_000 });
await expect(session.fileTreeNode("blur-original.ts")).toHaveCount(0);
await expect
.poll(() => fs.existsSync(path.join(repoDir, "blur-final.ts")), { timeout: 10_000 })
.toBe(true);
});
test("rename is a no-op when the name is unchanged", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("noop.ts", "noop");
git.stageAll();
git.commit("seed noop");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-rename-noop",
"FT Rename NoOp",
);
const node = session.fileTreeNode("noop.ts");
await expect(node).toBeVisible({ timeout: 15_000 });
const input = await startRenameViaContextMenu(testPage, node);
// Don't change anything, just press Enter.
await input.press("Enter");
await expect(session.fileTreeNode("noop.ts")).toBeVisible({ timeout: 5_000 });
expect(fs.existsSync(path.join(repoDir, "noop.ts"))).toBe(true);
});
});
@@ -0,0 +1,147 @@
import { type Page } from "@playwright/test";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../../helpers/git-helper";
// File panel search lives in file-browser-search-header.tsx (input) +
// file-browser-hooks.ts (useFileBrowserSearch with 300ms debounce + WS call
// to searchWorkspaceFiles). Behaviour to lock in:
// - Clicking the Search files toolbar button reveals the input
// - Typing a query produces results that include matching files anywhere
// in the tree (including inside non-expanded folders)
// - Clearing the input goes back to the tree view
// - Escape closes search
async function setupTask(
testPage: Page,
apiClient: ApiClient,
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
profileName: string,
taskTitle: string,
) {
const profile = await createStandardProfile(apiClient, profileName);
await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, taskTitle);
await session.clickTab("Files");
return session;
}
test.describe("File tree search", () => {
test("search reveals matches inside collapsed folders", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("search-alpha.ts", "a");
// Match lives inside a folder we never expand. Search must still find it.
git.createFile("deep/nested/needle-target.ts", "needle");
git.createFile("other-search/sibling.ts", "s");
git.stageAll();
git.commit("seed search");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-search-collapsed",
"FT Search Collapsed",
);
await expect(session.fileTreeNode("search-alpha.ts")).toBeVisible({ timeout: 15_000 });
// Sanity: target file's tree row is not in the DOM (folder is collapsed).
await expect(session.fileTreeNode("deep/nested/needle-target.ts")).toHaveCount(0);
await testPage.getByRole("button", { name: "Search files" }).click();
const input = testPage.getByPlaceholder("Search files...");
await expect(input).toBeVisible({ timeout: 5_000 });
await input.fill("needle-target");
// Search panel shows the matching path even though the parent folders
// are not expanded in the tree.
const match = session.files.getByText("needle-target.ts", { exact: false });
await expect(match).toBeVisible({ timeout: 5_000 });
// The folder path should appear next to the name.
await expect(session.files.getByText("deep/nested/", { exact: false })).toBeVisible();
});
test("clearing the search input restores the tree view", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("clear-alpha.ts", "a");
git.createFile("clear-bravo.ts", "b");
git.stageAll();
git.commit("seed clear");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-search-clear",
"FT Search Clear",
);
await expect(session.fileTreeNode("clear-alpha.ts")).toBeVisible({ timeout: 15_000 });
await testPage.getByRole("button", { name: "Search files" }).click();
const input = testPage.getByPlaceholder("Search files...");
await input.fill("clear-alpha");
// Wait for the debounced search and the filtered list to settle.
await expect(session.files.getByText("clear-alpha.ts", { exact: false })).toBeVisible({
timeout: 5_000,
});
// Clear input - tree comes back, both files visible again.
await input.fill("");
await expect(session.fileTreeNode("clear-alpha.ts")).toBeVisible({ timeout: 5_000 });
await expect(session.fileTreeNode("clear-bravo.ts")).toBeVisible({ timeout: 5_000 });
});
test("Escape closes the search header and returns to the tree", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("escape-target.ts", "e");
git.stageAll();
git.commit("seed escape");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-search-escape",
"FT Search Escape",
);
await expect(session.fileTreeNode("escape-target.ts")).toBeVisible({ timeout: 15_000 });
await testPage.getByRole("button", { name: "Search files" }).click();
const input = testPage.getByPlaceholder("Search files...");
await expect(input).toBeVisible({ timeout: 5_000 });
await input.press("Escape");
await expect(input).toHaveCount(0, { timeout: 5_000 });
// Toolbar comes back - the search button is once again accessible.
await expect(testPage.getByRole("button", { name: "Search files" })).toBeVisible();
});
});
@@ -0,0 +1,172 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTree } from "./use-tree";
import { useTreeKeyboardNav } from "./use-tree-keyboard-nav";
interface TestNode {
path: string;
isDir: boolean;
children?: TestNode[];
}
const getPath = (n: TestNode) => n.path;
const getChildren = (n: TestNode) => n.children;
const isDir = (n: TestNode) => n.isDir;
const SRC = "src";
const SRC_UI = "src/ui";
const SRC_API = "src/api";
const BUTTON = "src/ui/Button.tsx";
const CARD = "src/ui/Card.tsx";
const USERS = "src/api/users.ts";
const README = "README.md";
const TREE: TestNode[] = [
{
path: SRC,
isDir: true,
children: [
{
path: SRC_UI,
isDir: true,
children: [
{ path: BUTTON, isDir: false },
{ path: CARD, isDir: false },
],
},
{
path: SRC_API,
isDir: true,
children: [{ path: USERS, isDir: false }],
},
],
},
{ path: README, isDir: false },
];
/**
* Renders useTree + useTreeKeyboardNav together. Use the returned `result`
* to drive arrow keys and read focused-path state.
*/
function setup(defaultExpanded?: "all" | string[]) {
const onActivate = vi.fn();
const { result } = renderHook(() => {
const tree = useTree<TestNode>({
nodes: TREE,
getPath,
getChildren,
isDir,
defaultExpanded,
});
const nav = useTreeKeyboardNav<TestNode>({
visibleRows: tree.visibleRows,
toggle: tree.toggle,
expand: tree.expand,
collapse: tree.collapse,
isExpanded: tree.isExpanded,
onActivate,
});
return { tree, nav };
});
return { result, onActivate };
}
describe("useTreeKeyboardNav", () => {
it("ArrowDown moves focus to the first row when nothing is focused", () => {
const { result } = setup();
act(() => result.current.nav.dispatchKey("ArrowDown"));
expect(result.current.nav.focusedPath).toBe(SRC);
});
it("ArrowDown advances by one visible row, ArrowUp goes back", () => {
const { result } = setup("all");
act(() => result.current.nav.dispatchKey("ArrowDown")); // src
act(() => result.current.nav.dispatchKey("ArrowDown")); // src/ui
act(() => result.current.nav.dispatchKey("ArrowDown")); // src/ui/Button.tsx
expect(result.current.nav.focusedPath).toBe(BUTTON);
act(() => result.current.nav.dispatchKey("ArrowUp"));
expect(result.current.nav.focusedPath).toBe(SRC_UI);
});
it("ArrowDown clamps at the last row", () => {
const { result } = setup();
// 2 rows visible: src, README.md
act(() => result.current.nav.dispatchKey("ArrowDown"));
act(() => result.current.nav.dispatchKey("ArrowDown"));
act(() => result.current.nav.dispatchKey("ArrowDown"));
expect(result.current.nav.focusedPath).toBe(README);
});
it("ArrowUp clamps at the first row", () => {
const { result } = setup();
act(() => result.current.nav.dispatchKey("ArrowDown")); // src
act(() => result.current.nav.dispatchKey("ArrowUp"));
act(() => result.current.nav.dispatchKey("ArrowUp"));
expect(result.current.nav.focusedPath).toBe(SRC);
});
it("ArrowRight on a collapsed dir expands it without moving focus", () => {
const { result } = setup();
act(() => result.current.nav.dispatchKey("ArrowDown")); // src
act(() => result.current.nav.dispatchKey("ArrowRight"));
expect(result.current.tree.isExpanded(SRC)).toBe(true);
expect(result.current.nav.focusedPath).toBe(SRC);
});
it("ArrowRight on an expanded dir moves focus to the next row", () => {
const { result } = setup("all");
act(() => result.current.nav.dispatchKey("ArrowDown")); // src
act(() => result.current.nav.dispatchKey("ArrowRight"));
expect(result.current.nav.focusedPath).toBe(SRC_UI);
});
it("ArrowLeft on an expanded dir collapses it", () => {
const { result } = setup("all");
act(() => result.current.nav.dispatchKey("ArrowDown")); // src
act(() => result.current.nav.dispatchKey("ArrowLeft"));
expect(result.current.tree.isExpanded(SRC)).toBe(false);
expect(result.current.nav.focusedPath).toBe(SRC);
});
it("ArrowLeft on a leaf moves focus to its parent dir", () => {
const { result } = setup("all");
// Walk down to Button.tsx
act(() => result.current.nav.dispatchKey("ArrowDown")); // src
act(() => result.current.nav.dispatchKey("ArrowDown")); // src/ui
act(() => result.current.nav.dispatchKey("ArrowDown")); // Button.tsx
expect(result.current.nav.focusedPath).toBe(BUTTON);
act(() => result.current.nav.dispatchKey("ArrowLeft"));
expect(result.current.nav.focusedPath).toBe(SRC_UI);
});
it("Enter on a dir toggles it; Enter on a file fires onActivate", () => {
const { result, onActivate } = setup("all");
act(() => result.current.nav.dispatchKey("ArrowDown")); // src
act(() => result.current.nav.dispatchKey("Enter"));
expect(result.current.tree.isExpanded(SRC)).toBe(false);
// Re-expand and walk to README.
act(() => result.current.tree.expandAll());
// Focus on README (last row)
act(() => result.current.nav.setFocusedPath(README));
act(() => result.current.nav.dispatchKey("Enter"));
expect(onActivate).toHaveBeenCalledTimes(1);
expect(onActivate.mock.calls[0][0].path).toBe(README);
});
it("Space activates the same way as Enter", () => {
const { result, onActivate } = setup("all");
act(() => result.current.nav.setFocusedPath(BUTTON));
act(() => result.current.nav.dispatchKey(" "));
expect(onActivate).toHaveBeenCalledTimes(1);
expect(onActivate.mock.calls[0][0].path).toBe(BUTTON);
});
it("ignores unrelated keys and reports no effect", () => {
const { result, onActivate } = setup();
act(() => result.current.nav.setFocusedPath(SRC));
act(() => result.current.nav.dispatchKey("a"));
expect(onActivate).not.toHaveBeenCalled();
expect(result.current.nav.focusedPath).toBe(SRC);
});
});
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { VisibleRow } from "./use-tree";
/**
* Keyboard navigation on a useTree's flat visibleRows list:
* - ArrowDown / ArrowUp move focus by one visible row.
* - ArrowRight expands a collapsed dir; for an already-expanded dir or a file,
* moves to the next row.
* - ArrowLeft collapses an expanded dir; for a collapsed dir or a file,
* moves to the parent row (first row above whose depth is smaller).
* - Enter (and Space) activates: dir toggles, file fires onActivate.
*
* Focus state is owned by this hook. Consumers wire `handleKeyDown` to the
* scrollable container's onKeyDown and read `focusedPath` to highlight a row.
*/
export interface UseTreeKeyboardNavOptions<N> {
visibleRows: VisibleRow<N>[];
toggle: (path: string) => void;
expand: (path: string) => void;
collapse: (path: string) => void;
isExpanded: (path: string) => boolean;
/** Called when Enter / Space is pressed on a file row. */
onActivate?: (row: VisibleRow<N>) => void;
}
export interface UseTreeKeyboardNavResult<N> {
focusedPath: string | null;
setFocusedPath: (path: string | null) => void;
handleKeyDown: (e: ReactKeyboardEvent) => void;
/** Equivalent of running `handleKeyDown` programmatically — useful in tests. */
dispatchKey: (key: string) => void;
visibleRows: VisibleRow<N>[];
}
function moveIndex(idx: number, delta: number, max: number): number {
if (idx < 0) return delta > 0 ? 0 : max - 1;
return Math.max(0, Math.min(idx + delta, max - 1));
}
function findParentIndex<N>(rows: VisibleRow<N>[], idx: number): number {
const row = rows[idx];
for (let i = idx - 1; i >= 0; i--) {
if (rows[i].depth < row.depth) return i;
}
return -1;
}
interface HorizontalCtx<N> {
rows: VisibleRow<N>[];
idx: number;
isExpanded: (p: string) => boolean;
expand: (p: string) => void;
collapse: (p: string) => void;
setFocusedPath: (p: string | null) => void;
}
function handleHorizontal<N>(key: "ArrowLeft" | "ArrowRight", ctx: HorizontalCtx<N>): boolean {
const { rows, idx, isExpanded, expand, collapse, setFocusedPath } = ctx;
const row = rows[idx];
if (!row) return false;
if (key === "ArrowRight") {
if (row.isDir && !isExpanded(row.path)) expand(row.path);
else if (idx + 1 < rows.length) setFocusedPath(rows[idx + 1].path);
return true;
}
if (row.isDir && isExpanded(row.path)) {
collapse(row.path);
return true;
}
const parent = findParentIndex(rows, idx);
if (parent >= 0) setFocusedPath(rows[parent].path);
return true;
}
function handleActivate<N>(
rows: VisibleRow<N>[],
idx: number,
toggle: (p: string) => void,
onActivate: ((row: VisibleRow<N>) => void) | undefined,
): boolean {
const row = rows[idx];
if (!row) return false;
if (row.isDir) toggle(row.path);
else onActivate?.(row);
return true;
}
export function useTreeKeyboardNav<N>(
opts: UseTreeKeyboardNavOptions<N>,
): UseTreeKeyboardNavResult<N> {
const { visibleRows, toggle, expand, collapse, isExpanded, onActivate } = opts;
const [focusedPath, setFocusedPath] = useState<string | null>(null);
const apply = useCallback(
(key: string): boolean => {
const max = visibleRows.length;
if (max === 0) return false;
const idx = visibleRows.findIndex((r) => r.path === focusedPath);
if (key === "ArrowDown") {
setFocusedPath(visibleRows[moveIndex(idx, 1, max)].path);
return true;
}
if (key === "ArrowUp") {
setFocusedPath(visibleRows[moveIndex(idx, -1, max)].path);
return true;
}
if (key === "ArrowLeft" || key === "ArrowRight") {
return handleHorizontal(key, {
rows: visibleRows,
idx,
isExpanded,
expand,
collapse,
setFocusedPath,
});
}
if (key === "Enter" || key === " ") {
return handleActivate(visibleRows, idx, toggle, onActivate);
}
return false;
},
[visibleRows, focusedPath, toggle, expand, collapse, isExpanded, onActivate],
);
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent) => {
if (apply(e.key)) e.preventDefault();
},
[apply],
);
const dispatchKey = useCallback(
(key: string) => {
apply(key);
},
[apply],
);
return { focusedPath, setFocusedPath, handleKeyDown, dispatchKey, visibleRows };
}
+275
View File
@@ -0,0 +1,275 @@
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTree } from "./use-tree";
interface TestNode {
path: string;
isDir: boolean;
children?: TestNode[];
}
const getPath = (n: TestNode) => n.path;
const getChildren = (n: TestNode) => n.children;
const isDir = (n: TestNode) => n.isDir;
function leaf(path: string): TestNode {
return { path, isDir: false };
}
function dir(path: string, children: TestNode[] = []): TestNode {
return { path, isDir: true, children };
}
const SRC = "src";
const SRC_UI = "src/ui";
const SRC_API = "src/api";
const BUTTON_TSX = "src/ui/Button.tsx";
const CARD_TSX = "src/ui/Card.tsx";
const INDEX_TS = "src/index.ts";
const USERS_TS = "src/api/users.ts";
const README = "README.md";
const TREE: TestNode[] = [
dir(SRC, [dir(SRC_UI, [leaf(BUTTON_TSX), leaf(CARD_TSX)]), leaf(INDEX_TS)]),
leaf(README),
];
type Override = Partial<Parameters<typeof useTree<TestNode>>[0]>;
function renderUseTree(override: Override = {}) {
return renderHook(
(props: Override) => useTree<TestNode>({ nodes: TREE, getPath, getChildren, isDir, ...props }),
{ initialProps: override },
).result;
}
describe("useTree — visible rows and expansion", () => {
it("renders only top-level rows when nothing is expanded", () => {
const r = renderUseTree();
expect(r.current.visibleRows.map((row) => row.path)).toEqual([SRC, README]);
});
it("toggle reveals a dir's children", () => {
const r = renderUseTree();
act(() => r.current.toggle(SRC));
expect(r.current.visibleRows.map((row) => row.path)).toEqual([SRC, SRC_UI, INDEX_TS, README]);
});
it("toggle collapses an already-expanded dir", () => {
const r = renderUseTree();
act(() => r.current.toggle(SRC));
act(() => r.current.toggle(SRC));
expect(r.current.visibleRows.map((row) => row.path)).toEqual([SRC, README]);
});
it("expand and collapse are idempotent and explicit", () => {
const r = renderUseTree();
act(() => r.current.expand(SRC));
act(() => r.current.expand(SRC));
expect(r.current.isExpanded(SRC)).toBe(true);
act(() => r.current.collapse(SRC));
act(() => r.current.collapse(SRC));
expect(r.current.isExpanded(SRC)).toBe(false);
});
it("defaultExpanded='all' pre-expands every dir", () => {
const r = renderUseTree({ defaultExpanded: "all" });
expect(r.current.visibleRows.map((row) => row.path)).toEqual([
SRC,
SRC_UI,
BUTTON_TSX,
CARD_TSX,
INDEX_TS,
README,
]);
});
it("defaultExpanded as iterable seeds the expanded set", () => {
const r = renderUseTree({ defaultExpanded: [SRC] });
expect(r.current.isExpanded(SRC)).toBe(true);
expect(r.current.isExpanded(SRC_UI)).toBe(false);
});
it("expandAll walks the current nodes once", () => {
const r = renderUseTree();
act(() => r.current.expandAll());
expect(r.current.isExpanded(SRC)).toBe(true);
expect(r.current.isExpanded(SRC_UI)).toBe(true);
});
it("collapseAll clears the expanded set", () => {
const r = renderUseTree({ defaultExpanded: "all" });
act(() => r.current.collapseAll());
expect(r.current.expanded.size).toBe(0);
});
it("expandAncestorsOf adds every ancestor prefix", () => {
const r = renderUseTree();
act(() => r.current.expandAncestorsOf(BUTTON_TSX));
expect(r.current.isExpanded(SRC)).toBe(true);
expect(r.current.isExpanded(SRC_UI)).toBe(true);
expect(r.current.isExpanded(BUTTON_TSX)).toBe(false);
});
});
describe("useTree — visible-row depth and metadata", () => {
it("depth is 0 for roots and increments for each level", () => {
const r = renderUseTree({ defaultExpanded: "all" });
const byPath = Object.fromEntries(r.current.visibleRows.map((row) => [row.path, row.depth]));
expect(byPath[SRC]).toBe(0);
expect(byPath[SRC_UI]).toBe(1);
expect(byPath[BUTTON_TSX]).toBe(2);
expect(byPath[README]).toBe(0);
});
it("displayName for non-chain-collapsed rows is the last path segment", () => {
const r = renderUseTree({ defaultExpanded: "all" });
const byPath = Object.fromEntries(
r.current.visibleRows.map((row) => [row.path, row.displayName]),
);
expect(byPath[SRC]).toBe(SRC);
expect(byPath[BUTTON_TSX]).toBe("Button.tsx");
});
});
describe("useTree — chain collapse", () => {
it("merges single-child dir chains into one row with a slash-joined displayName", () => {
const nodes: TestNode[] = [dir("a", [dir("a/b", [dir("a/b/c", [leaf("a/b/c/file.ts")])])])];
const r = renderHook(() =>
useTree({ nodes, getPath, getChildren, isDir, chainCollapse: true }),
).result;
// Top-level row is "a/b/c" (the whole chain collapsed). It is not expanded
// yet, so the leaf below is hidden.
expect(r.current.visibleRows.map((row) => ({ path: row.path, name: row.displayName }))).toEqual(
[{ path: "a/b/c", name: "a/b/c" }],
);
});
it("does NOT merge when a chain member has multiple children", () => {
const nodes: TestNode[] = [dir("a", [dir("a/b", [leaf("a/b/x.ts"), leaf("a/b/y.ts")])])];
const r = renderHook(() =>
useTree({
nodes,
getPath,
getChildren,
isDir,
chainCollapse: true,
defaultExpanded: "all",
}),
).result;
// "a" has one child dir "a/b" — that chain merges. But "a/b" has two
// leaf children, so the chain stops there.
const paths = r.current.visibleRows.map((row) => row.path);
expect(paths).toEqual(["a/b", "a/b/x.ts", "a/b/y.ts"]);
expect(r.current.visibleRows[0].displayName).toBe("a/b");
});
it("expanding a chain-collapsed row uses the deepest node's path", () => {
const nodes: TestNode[] = [dir("a", [dir("a/b", [leaf("a/b/file.ts")])])];
const r = renderHook(() =>
useTree({ nodes, getPath, getChildren, isDir, chainCollapse: true }),
).result;
// Toggling the chain-row's path "a/b" must expand the deepest dir.
act(() => r.current.toggle("a/b"));
const paths = r.current.visibleRows.map((row) => row.path);
expect(paths).toEqual(["a/b", "a/b/file.ts"]);
});
});
describe("useTree — search modes", () => {
const SEARCH_TREE: TestNode[] = [
dir(SRC, [dir(SRC_UI, [leaf(BUTTON_TSX), leaf(CARD_TSX)]), dir(SRC_API, [leaf(USERS_TS)])]),
leaf(README),
];
it("hide mode: removes non-matching subtrees and force-expands ancestors of matches", () => {
const r = renderHook(() =>
useTree({
nodes: SEARCH_TREE,
getPath,
getChildren,
isDir,
search: "button",
searchMode: "hide",
}),
).result;
const paths = r.current.visibleRows.map((row) => row.path);
// src and src/ui are visible because they have a matching descendant;
// src/api and README.md are hidden because nothing under them matches.
// Ancestors are force-expanded so Button.tsx is reachable.
expect(paths).toEqual([SRC, SRC_UI, BUTTON_TSX]);
});
it("expand mode: keeps all rows visible, force-expands matching dirs and ancestors", () => {
const r = renderHook(() =>
useTree({
nodes: SEARCH_TREE,
getPath,
getChildren,
isDir,
search: "users",
searchMode: "expand",
}),
).result;
const paths = r.current.visibleRows.map((row) => row.path);
// src is force-expanded (descendant match), src/api is force-expanded
// (descendant match). src/ui is NOT expanded (no match under it), so its
// children stay hidden.
expect(paths).toEqual([SRC, SRC_UI, SRC_API, USERS_TS, README]);
});
it("collapse mode: keeps everything visible, collapses non-matching dirs", () => {
const r = renderHook(() =>
useTree({
nodes: SEARCH_TREE,
getPath,
getChildren,
isDir,
search: "button",
searchMode: "collapse",
defaultExpanded: "all", // start fully open; collapse mode should override
}),
).result;
const paths = r.current.visibleRows.map((row) => row.path);
// src/api collapses (no match under it); src and src/ui stay expanded
// because there's a match under them.
expect(paths).toEqual([SRC, SRC_UI, BUTTON_TSX, CARD_TSX, SRC_API, README]);
});
it("empty search reverts to standard expand behavior", () => {
const r = renderHook(() =>
useTree({
nodes: SEARCH_TREE,
getPath,
getChildren,
isDir,
search: "",
searchMode: "hide",
}),
).result;
// Nothing expanded, no search — only top level visible.
expect(r.current.visibleRows.map((row) => row.path)).toEqual([SRC, README]);
});
});
describe("useTree — node-shape agnosticism via adapter functions", () => {
it("works with snake_case fields (file-browser's backend shape)", () => {
interface SnakeNode {
path: string;
is_dir: boolean;
children?: SnakeNode[];
}
const snake: SnakeNode[] = [
{ path: SRC, is_dir: true, children: [{ path: "src/a.ts", is_dir: false }] },
];
const r = renderHook(() =>
useTree<SnakeNode>({
nodes: snake,
getPath: (n) => n.path,
getChildren: (n) => n.children,
isDir: (n) => n.is_dir,
}),
).result;
act(() => r.current.toggle(SRC));
expect(r.current.visibleRows.map((row) => row.path)).toEqual([SRC, "src/a.ts"]);
});
});
+325
View File
@@ -0,0 +1,325 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
* Headless tree state. Owns expansion + derived flat visibleRows + search
* filtering. Selection, lazy-load, drag/drop, rename, etc. are the caller's
* concern — keep this hook small and pure.
*
* Generic over the node type so it can drive every file-tree in the app,
* including ones that use snake_case fields. Adapter functions
* (getPath/getChildren/isDir) decouple the hook from any specific shape.
*/
export type SearchMode = "hide" | "collapse" | "expand";
export interface UseTreeOptions<N> {
nodes: N[];
getPath: (node: N) => string;
getChildren: (node: N) => N[] | undefined;
isDir: (node: N) => boolean;
/** "all" pre-expands every directory once on mount; iterable seeds the set. */
defaultExpanded?: "all" | Iterable<string>;
search?: string;
/**
* - "hide": non-matching nodes are removed. Ancestors of matches are kept
* and force-expanded so matches stay reachable.
* - "collapse": every node stays visible. Matching subtrees force-expanded,
* non-matching dirs force-collapsed.
* - "expand": every node stays visible. Matching dirs and their ancestors
* force-expanded; other dirs retain their stored expand state.
* Defaults to "hide".
*/
searchMode?: SearchMode;
/**
* When true, single-child dir chains are merged into one row with a slash-
* joined displayName (e.g. "src/ui"). The effective node is the deepest in
* the chain; expanding it reveals the deepest dir's children.
*/
chainCollapse?: boolean;
}
export interface VisibleRow<N> {
/** The effective node — the deepest in a collapsed chain, else === chainRoot. */
node: N;
/** The top of the chain. Same as `node` when chainCollapse is off. */
chainRoot: N;
/** "src/ui" for chains, else the last path segment. */
displayName: string;
/** Path of the effective node. */
path: string;
depth: number;
/** Effective expansion (after applying search-mode overrides). */
isExpanded: boolean;
isDir: boolean;
}
export interface UseTreeResult<N> {
visibleRows: VisibleRow<N>[];
expanded: ReadonlySet<string>;
isExpanded: (path: string) => boolean;
toggle: (path: string) => void;
expand: (path: string) => void;
collapse: (path: string) => void;
expandAll: () => void;
collapseAll: () => void;
/** Adds every ancestor prefix of `path` to the expanded set. */
expandAncestorsOf: (path: string) => void;
/** Direct setter for the expanded set. Use sparingly — for things like
* rehydrating from persistence on session change. */
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
}
function lastSegment(p: string): string {
const i = p.lastIndexOf("/");
return i < 0 ? p : p.slice(i + 1);
}
function collectAllDirPaths<N>(
nodes: N[],
isDir: (n: N) => boolean,
getPath: (n: N) => string,
getChildren: (n: N) => N[] | undefined,
): string[] {
const out: string[] = [];
const stack = [...nodes];
while (stack.length) {
const n = stack.pop() as N;
if (isDir(n)) {
out.push(getPath(n));
const ch = getChildren(n);
if (ch && ch.length) stack.push(...ch);
}
}
return out;
}
function ancestorPaths(path: string): string[] {
const parts = path.split("/");
const out: string[] = [];
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
return out;
}
interface ComputeOpts<N> {
nodes: N[];
expanded: Set<string>;
search: string;
searchMode: SearchMode;
chainCollapse: boolean;
getPath: (n: N) => string;
getChildren: (n: N) => N[] | undefined;
isDir: (n: N) => boolean;
}
/** Walk the chain of single-child dirs starting at `root`, returning the
* deepest effective node and the slash-joined display name. */
function walkChain<N>(
root: N,
chainCollapse: boolean,
isDir: (n: N) => boolean,
getPath: (n: N) => string,
getChildren: (n: N) => N[] | undefined,
): { effective: N; displayName: string } {
let effective = root;
let displayName = lastSegment(getPath(root));
if (!chainCollapse || !isDir(root)) return { effective, displayName };
let kids = getChildren(effective) ?? [];
while (kids.length === 1 && isDir(kids[0])) {
effective = kids[0];
displayName = `${displayName}/${lastSegment(getPath(effective))}`;
kids = getChildren(effective) ?? [];
}
return { effective, displayName };
}
/** Precompute the "self-or-descendant matches search" map. */
function buildMatchMap<N>(
nodes: N[],
lowerSearch: string,
getPath: (n: N) => string,
getChildren: (n: N) => N[] | undefined,
): Map<string, boolean> {
const out = new Map<string, boolean>();
const recurse = (n: N): boolean => {
const path = getPath(n);
const self = lastSegment(path).toLowerCase().includes(lowerSearch);
let any = self;
for (const c of getChildren(n) ?? []) if (recurse(c)) any = true;
out.set(path, any);
return any;
};
for (const n of nodes) recurse(n);
return out;
}
/** Decide a dir's effective expansion under the current search mode. */
function effectiveExpansion(
storedExpanded: boolean,
matches: boolean,
hasSearch: boolean,
searchMode: SearchMode,
): boolean {
if (!hasSearch) return storedExpanded;
if (searchMode === "hide") return true;
if (searchMode === "expand") return storedExpanded || matches;
// "collapse"
return matches;
}
function computeVisibleRows<N>(opts: ComputeOpts<N>): VisibleRow<N>[] {
const { nodes, expanded, search, searchMode, chainCollapse, getPath, getChildren, isDir } = opts;
const lowerSearch = search.toLowerCase();
const hasSearch = lowerSearch.length > 0;
const matchMap = hasSearch ? buildMatchMap(nodes, lowerSearch, getPath, getChildren) : null;
const rows: VisibleRow<N>[] = [];
const walk = (list: N[], depth: number) => {
for (const root of list) {
// Skip non-matching subtrees in "hide" mode.
if (hasSearch && searchMode === "hide" && !(matchMap?.get(getPath(root)) ?? false)) continue;
const { effective, displayName } = walkChain(
root,
chainCollapse,
isDir,
getPath,
getChildren,
);
const effPath = getPath(effective);
const effIsDir = isDir(effective);
const isExpandedEff = effIsDir
? effectiveExpansion(
expanded.has(effPath),
matchMap?.get(effPath) ?? false,
hasSearch,
searchMode,
)
: false;
rows.push({
node: effective,
chainRoot: root,
displayName,
path: effPath,
depth,
isExpanded: isExpandedEff,
isDir: effIsDir,
});
if (effIsDir && isExpandedEff) walk(getChildren(effective) ?? [], depth + 1);
}
};
walk(nodes, 0);
return rows;
}
function useExpandedState<N>(
nodes: N[],
defaultExpanded: UseTreeOptions<N>["defaultExpanded"],
getPath: (n: N) => string,
getChildren: (n: N) => N[] | undefined,
isDir: (n: N) => boolean,
) {
const nodesRef = useRef(nodes);
const getPathRef = useRef(getPath);
const getChildrenRef = useRef(getChildren);
const isDirRef = useRef(isDir);
useEffect(() => {
nodesRef.current = nodes;
getPathRef.current = getPath;
getChildrenRef.current = getChildren;
isDirRef.current = isDir;
});
const [expanded, setExpanded] = useState<Set<string>>(() => {
if (defaultExpanded === "all") {
return new Set(collectAllDirPaths(nodes, isDir, getPath, getChildren));
}
if (defaultExpanded) return new Set(defaultExpanded);
return new Set<string>();
});
const isExpandedFn = useCallback((p: string) => expanded.has(p), [expanded]);
const toggle = useCallback((p: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
}, []);
const expand = useCallback((p: string) => {
setExpanded((prev) => (prev.has(p) ? prev : new Set(prev).add(p)));
}, []);
const collapse = useCallback((p: string) => {
setExpanded((prev) => {
if (!prev.has(p)) return prev;
const next = new Set(prev);
next.delete(p);
return next;
});
}, []);
const expandAll = useCallback(() => {
setExpanded(
new Set(
collectAllDirPaths(
nodesRef.current,
isDirRef.current,
getPathRef.current,
getChildrenRef.current,
),
),
);
}, []);
const collapseAll = useCallback(() => setExpanded(new Set<string>()), []);
const expandAncestorsOf = useCallback((p: string) => {
setExpanded((prev) => {
const next = new Set(prev);
for (const a of ancestorPaths(p)) next.add(a);
return next;
});
}, []);
return {
expanded,
setExpanded,
isExpanded: isExpandedFn,
toggle,
expand,
collapse,
expandAll,
collapseAll,
expandAncestorsOf,
};
}
export function useTree<N>(opts: UseTreeOptions<N>): UseTreeResult<N> {
const {
nodes,
getPath,
getChildren,
isDir,
defaultExpanded,
search = "",
searchMode = "hide",
chainCollapse = false,
} = opts;
const state = useExpandedState(nodes, defaultExpanded, getPath, getChildren, isDir);
const visibleRows = useMemo<VisibleRow<N>[]>(
() =>
computeVisibleRows({
nodes,
expanded: state.expanded,
search,
searchMode,
chainCollapse,
getPath,
getChildren,
isDir,
}),
[nodes, state.expanded, search, searchMode, chainCollapse, getPath, getChildren, isDir],
);
return { ...state, visibleRows };
}