feat: add FileExplorer with lazy-loaded folder tree

This commit is contained in:
Tianyang 2026-08-01 02:45:35 +08:00
parent e4dbd34604
commit 020ddac994
2 changed files with 178 additions and 0 deletions

View File

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { listDocuments, type DocumentFile } from '../api/client';
interface FileExplorerProps {
onSelectFile: (key: string) => void;
selectedKey?: string | null;
}
export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
const [folders, setFolders] = useState<string[]>([]);
const [files, setFiles] = useState<DocumentFile[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
listDocuments('')
.then((data) => {
setFolders(data.folders);
setFiles(data.files);
})
.catch(() => setError('无法加载文档目录'));
}, []);
if (error) {
return <div data-testid="file-explorer-error">{error}</div>;
}
return (
<div data-testid="file-explorer">
{folders.map((folder) => (
<FolderNode key={folder} name={folder} path={folder} onSelectFile={onSelectFile} selectedKey={selectedKey} />
))}
{files.map((file) => (
<FileRow key={file.key} file={file} onSelectFile={onSelectFile} selectedKey={selectedKey} />
))}
</div>
);
}
function FileRow({
file,
onSelectFile,
selectedKey,
}: {
file: DocumentFile;
onSelectFile: (key: string) => void;
selectedKey?: string | null;
}) {
return (
<div
data-testid={`file-${file.key}`}
className={selectedKey === file.key ? 'file-row active' : 'file-row'}
onClick={() => onSelectFile(file.key)}
>
{file.name}
</div>
);
}
function FolderNode({
name,
path,
onSelectFile,
selectedKey,
}: {
name: string;
path: string;
onSelectFile: (key: string) => void;
selectedKey?: string | null;
}) {
const [expanded, setExpanded] = useState(false);
const [loaded, setLoaded] = useState(false);
const [folders, setFolders] = useState<string[]>([]);
const [files, setFiles] = useState<DocumentFile[]>([]);
function toggle() {
if (!expanded && !loaded) {
listDocuments(path).then((data) => {
setFolders(data.folders);
setFiles(data.files);
setLoaded(true);
});
}
setExpanded((prev) => !prev);
}
return (
<div>
<div data-testid={`folder-${path}`} className="folder" onClick={toggle}>
{name}
</div>
{expanded && (
<div className="tree-children">
{folders.map((folder) => (
<FolderNode
key={`${path}/${folder}`}
name={folder}
path={`${path}/${folder}`}
onSelectFile={onSelectFile}
selectedKey={selectedKey}
/>
))}
{files.map((file) => (
<FileRow key={file.key} file={file} onSelectFile={onSelectFile} selectedKey={selectedKey} />
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,69 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
import FileExplorer from '../../src/components/FileExplorer';
import { listDocuments } from '../../src/api/client';
vi.mock('../../src/api/client');
beforeEach(() => {
vi.mocked(listDocuments).mockReset();
});
test('renders folders and files returned by listDocuments for the root path', async () => {
vi.mocked(listDocuments).mockResolvedValue({
folders: ['产品文档'],
files: [{ key: '需求说明.md', name: '需求说明.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
});
render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);
expect(await screen.findByText('产品文档')).toBeInTheDocument();
expect(await screen.findByText('需求说明.md')).toBeInTheDocument();
expect(listDocuments).toHaveBeenCalledWith('');
});
test('clicking a file calls onSelectFile with its key', async () => {
vi.mocked(listDocuments).mockResolvedValue({
folders: [],
files: [{ key: '架构设计.md', name: '架构设计.md', size: 200, modifiedAt: '2026-08-01T10:00:00Z' }],
});
const onSelectFile = vi.fn();
render(<FileExplorer onSelectFile={onSelectFile} selectedKey={null} />);
const fileRow = await screen.findByText('架构设计.md');
fireEvent.click(fileRow);
expect(onSelectFile).toHaveBeenCalledWith('架构设计.md');
});
test('clicking a folder loads and shows its nested contents', async () => {
vi.mocked(listDocuments).mockImplementation((path: string) => {
if (path === '') {
return Promise.resolve({ folders: ['产品文档'], files: [] });
}
if (path === '产品文档') {
return Promise.resolve({
folders: [],
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 300, modifiedAt: '2026-08-01T10:00:00Z' }],
});
}
return Promise.resolve({ folders: [], files: [] });
});
render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);
const folderRow = await screen.findByText('产品文档');
fireEvent.click(folderRow);
expect(await screen.findByText('架构设计.md')).toBeInTheDocument();
expect(listDocuments).toHaveBeenCalledWith('产品文档');
});
test('shows an error message when listDocuments rejects', async () => {
vi.mocked(listDocuments).mockRejectedValue(new Error('network error'));
render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);
expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
});