Sidebar overflowed horizontally on long file/folder names, which broke vertical scrolling too — locked width with min/max-width plus overflow-x: hidden, and truncate folder names with ellipsis. Documents now open in a rendered preview (marked + DOMPurify) by default, with an 编辑/预览 toggle to switch to the CodeMirror source view.
120 lines
3.3 KiB
TypeScript
120 lines
3.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { listDocuments, type DocumentFile } from '../api/client';
|
|
|
|
interface FileExplorerProps {
|
|
onSelectFile: (key: string) => void;
|
|
selectedKey?: string | null;
|
|
refreshKey?: number;
|
|
}
|
|
|
|
export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }: 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('无法加载文档目录'));
|
|
}, [refreshKey]);
|
|
|
|
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 ? 'tree-item file-row active' : 'tree-item file-row'}
|
|
onClick={() => onSelectFile(file.key)}
|
|
>
|
|
<svg className="file-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
<path d="M14 2v6h6" />
|
|
</svg>
|
|
<span className="filename">{file.name}</span>
|
|
</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="tree-item folder" onClick={toggle}>
|
|
<span className={expanded ? 'chevron open' : 'chevron'}>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
</span>
|
|
<span className="foldername">{name}</span>
|
|
</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>
|
|
);
|
|
}
|