mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user