DocHub/frontend/src/components/FileExplorer.tsx
Tianyang 08a1211341 feat: favicon, sidebar tooltip, fix Meilisearch auth and test warnings
- 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
2026-08-01 18:44:32 +08:00

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>
);
}