refactor: extract TreeNode out of file-tree-view (#1358)

* refactor: extract TreeNode out of file-tree-view

* fix: failing tests
This commit is contained in:
Hiep Le
2026-06-16 18:53:17 +00:00
committed by GitHub
parent 998b673f10
commit 57d078a271
4 changed files with 174 additions and 87 deletions
@@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { FileTreeView } from "#/components/features/files-tab/file-tree-view";
// FileTreeView composes the recursive TreeNode (extracted into its own file).
// The route-level files-tab test treats the tree as a black box, so these
// cover the tree's own user-facing behavior: empty state, directory
// expand/collapse, and file selection.
describe("FileTreeView", () => {
it("shows the empty-state message and no tree when there are no files", () => {
// Arrange + Act
render(
<FileTreeView paths={[]} selectedPath={null} onSelectFile={vi.fn()} />,
);
// Assert
expect(screen.getByText("FILES$NO_FILES")).toBeInTheDocument();
expect(screen.queryByTestId("file-tree-view")).not.toBeInTheDocument();
});
it("keeps a directory collapsed until clicked, then reveals its children", async () => {
// Arrange
const user = userEvent.setup();
render(
<FileTreeView
paths={["src/main.ts"]}
selectedPath={null}
onSelectFile={vi.fn()}
/>,
);
// Assert: the directory row shows but its nested file is hidden.
expect(screen.getByTestId("file-tree-dir-src")).toBeInTheDocument();
expect(
screen.queryByTestId("file-tree-file-src/main.ts"),
).not.toBeInTheDocument();
// Act: expand the directory.
await user.click(screen.getByTestId("file-tree-dir-src"));
// Assert: the nested file is now visible.
expect(
screen.getByTestId("file-tree-file-src/main.ts"),
).toBeInTheDocument();
});
it("calls onSelectFile with the file path when a file row is clicked", async () => {
// Arrange
const user = userEvent.setup();
const onSelectFile = vi.fn();
render(
<FileTreeView
paths={["README.md"]}
selectedPath={null}
onSelectFile={onSelectFile}
/>,
);
// Act
await user.click(screen.getByTestId("file-tree-file-README.md"));
// Assert
expect(onSelectFile).toHaveBeenCalledTimes(1);
expect(onSelectFile).toHaveBeenCalledWith("README.md");
});
});
@@ -1,11 +1,9 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import FileIcon from "#/icons/file.svg?react";
import FolderIcon from "#/icons/folder.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { buildFileTree, FileTreeNode } from "#/utils/file-tree";
import { cn } from "#/utils/utils";
import { buildFileTree } from "#/utils/file-tree";
import { TreeNode } from "./tree-node";
interface FileTreeViewProps {
paths: string[];
@@ -13,85 +11,6 @@ interface FileTreeViewProps {
onSelectFile: (path: string) => void;
}
interface TreeNodeProps {
node: FileTreeNode;
depth: number;
selectedPath: string | null;
onSelectFile: (path: string) => void;
}
function TreeNode({ node, depth, selectedPath, onSelectFile }: TreeNodeProps) {
const [isOpen, setIsOpen] = useState(false);
const indentPx = 8 + depth * 12;
if (node.isDirectory) {
return (
<li>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen}
data-testid={`file-tree-dir-${node.path}`}
className={cn(
"flex w-full items-center gap-1.5 py-1 pr-2 text-left text-sm text-white",
"hover:bg-tertiary cursor-pointer",
)}
// per-row indentation computed from tree depth at runtime
style={{ paddingLeft: `${indentPx}px` }}
>
<span
aria-hidden
className={cn(
"inline-block w-3 text-[10px] text-[var(--oh-muted)] transition-transform",
isOpen ? "rotate-90" : "rotate-0",
)}
>
▶
</span>
<FolderIcon className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</button>
{isOpen && node.children.length > 0 && (
<ul>
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onSelectFile={onSelectFile}
/>
))}
</ul>
)}
</li>
);
}
const isSelected = selectedPath === node.path;
return (
<li>
<button
type="button"
onClick={() => onSelectFile(node.path)}
data-testid={`file-tree-file-${node.path}`}
className={cn(
"flex w-full items-center gap-1.5 py-1 pr-2 text-left text-sm",
"hover:bg-tertiary cursor-pointer",
isSelected
? "bg-[var(--oh-interactive-hover)] text-white"
: "text-[var(--oh-text-tertiary)]",
)}
// per-row indentation computed from tree depth at runtime
style={{ paddingLeft: `${indentPx + 16}px` }}
>
<FileIcon className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</button>
</li>
);
}
export function FileTreeView({
paths,
selectedPath,
@@ -0,0 +1,90 @@
import { useState } from "react";
import FileIcon from "#/icons/file.svg?react";
import FolderIcon from "#/icons/folder.svg?react";
import { FileTreeNode } from "#/utils/file-tree";
import { cn } from "#/utils/utils";
interface TreeNodeProps {
node: FileTreeNode;
depth: number;
selectedPath: string | null;
onSelectFile: (path: string) => void;
}
export function TreeNode({
node,
depth,
selectedPath,
onSelectFile,
}: TreeNodeProps) {
const [isOpen, setIsOpen] = useState(false);
const indentPx = 8 + depth * 12;
if (node.isDirectory) {
return (
<li>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen}
data-testid={`file-tree-dir-${node.path}`}
className={cn(
"flex w-full items-center gap-1.5 py-1 pr-2 text-left text-sm text-white",
"hover:bg-tertiary cursor-pointer",
)}
// per-row indentation computed from tree depth at runtime
style={{ paddingLeft: `${indentPx}px` }}
>
<span
aria-hidden
className={cn(
"inline-block w-3 text-[10px] text-[var(--oh-muted)] transition-transform",
isOpen ? "rotate-90" : "rotate-0",
)}
>
▶
</span>
<FolderIcon className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</button>
{isOpen && node.children.length > 0 && (
<ul>
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onSelectFile={onSelectFile}
/>
))}
</ul>
)}
</li>
);
}
const isSelected = selectedPath === node.path;
return (
<li>
<button
type="button"
onClick={() => onSelectFile(node.path)}
data-testid={`file-tree-file-${node.path}`}
className={cn(
"flex w-full items-center gap-1.5 py-1 pr-2 text-left text-sm",
"hover:bg-tertiary cursor-pointer",
isSelected
? "bg-[var(--oh-interactive-hover)] text-white"
: "text-[var(--oh-text-tertiary)]",
)}
// per-row indentation computed from tree depth at runtime
style={{ paddingLeft: `${indentPx + 16}px` }}
>
<FileIcon className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</button>
</li>
);
}
@@ -22,7 +22,7 @@ import {
getConversationIdFromURL,
waitForNonUserMessageText,
deleteConversation,
ensureMockLLMProfileViaAPI,
ensureMockLLMProfile,
registerTrajectory,
activateTrajectory,
resetMockLLM,
@@ -94,8 +94,18 @@ test.describe("files tab, git control bar, and browser tab", () => {
// ── Step 1: Setup LLM profile ──────────────────────────────────────
test("step 1: ensure mock LLM profile is configured", async ({ request }) => {
await ensureMockLLMProfileViaAPI(request);
test("step 1: ensure mock LLM profile is configured", async ({
page,
request,
}) => {
// Create AND activate a real LLM profile through the Settings UI.
// In local mode the home launcher gates sending on an active LLM profile
// (profiles are the source of truth — see `useLlmConfigured`), so the
// settings-only `ensureMockLLMProfileViaAPI` path leaves the chat input
// and submit button disabled and step 2 can never submit. Other
// conversation-starting mock-LLM specs create a profile for this reason;
// under selective E2E runs no earlier spec leaves an active profile behind.
await ensureMockLLMProfile(page);
// Register a trajectory that ensures the workspace has a git remote.
// The npm path inherits the host repo; the Docker path bootstraps one.