- Add SVG favicon using pine/paper design tokens; wire into index.html - Add SidebarLabel component with ResizeObserver overflow detection and CSS-only tooltip (pine-deep bg, IBM Plex Mono, 0.08s fade, no delay) - Fix Meilisearch invalid_api_key: default api key to None instead of '' so no Authorization header is sent when auth is disabled - Stub ResizeObserver in test setup for jsdom compatibility - Wrap async renders in act() and advance fake timers inside act() to eliminate all act() warnings in App.test.tsx
141 lines
3.9 KiB
TypeScript
141 lines
3.9 KiB
TypeScript
import { useEffect, useRef, 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 SidebarLabel({ text }: { text: string }) {
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const [overflows, setOverflows] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const check = () => setOverflows(el.scrollWidth > el.clientWidth);
|
|
check();
|
|
const ro = new ResizeObserver(check);
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [text]);
|
|
|
|
return (
|
|
<span className={overflows ? 'sidebar-label has-tip' : 'sidebar-label'} data-tip={text}>
|
|
<span ref={ref} className="label-text">{text}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<SidebarLabel text={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="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>
|
|
<SidebarLabel text={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>
|
|
);
|
|
}
|