feat: add FileExplorer with lazy-loaded folder tree
This commit is contained in:
parent
e4dbd34604
commit
020ddac994
109
frontend/src/components/FileExplorer.tsx
Normal file
109
frontend/src/components/FileExplorer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/tests/components/FileExplorer.test.tsx
Normal file
69
frontend/tests/components/FileExplorer.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user