From 020ddac99430ef952a5381ba6bf6ebdeadbad136 Mon Sep 17 00:00:00 2001 From: Tianyang Date: Sat, 1 Aug 2026 02:45:35 +0800 Subject: [PATCH] feat: add FileExplorer with lazy-loaded folder tree --- frontend/src/components/FileExplorer.tsx | 109 ++++++++++++++++++ .../tests/components/FileExplorer.test.tsx | 69 +++++++++++ 2 files changed, 178 insertions(+) create mode 100644 frontend/src/components/FileExplorer.tsx create mode 100644 frontend/tests/components/FileExplorer.test.tsx diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx new file mode 100644 index 0000000..fbdf32f --- /dev/null +++ b/frontend/src/components/FileExplorer.tsx @@ -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([]); + const [files, setFiles] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + listDocuments('') + .then((data) => { + setFolders(data.folders); + setFiles(data.files); + }) + .catch(() => setError('无法加载文档目录')); + }, []); + + if (error) { + return
{error}
; + } + + return ( +
+ {folders.map((folder) => ( + + ))} + {files.map((file) => ( + + ))} +
+ ); +} + +function FileRow({ + file, + onSelectFile, + selectedKey, +}: { + file: DocumentFile; + onSelectFile: (key: string) => void; + selectedKey?: string | null; +}) { + return ( +
onSelectFile(file.key)} + > + {file.name} +
+ ); +} + +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([]); + const [files, setFiles] = useState([]); + + function toggle() { + if (!expanded && !loaded) { + listDocuments(path).then((data) => { + setFolders(data.folders); + setFiles(data.files); + setLoaded(true); + }); + } + setExpanded((prev) => !prev); + } + + return ( +
+
+ {name} +
+ {expanded && ( +
+ {folders.map((folder) => ( + + ))} + {files.map((file) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/tests/components/FileExplorer.test.tsx b/frontend/tests/components/FileExplorer.test.tsx new file mode 100644 index 0000000..f1bc309 --- /dev/null +++ b/frontend/tests/components/FileExplorer.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument(); +});