import { ChevronRight, Cloud, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert, } from "lucide-react"; import { useEffect, useState } from "react"; import { type FileRoot, useDirChildren } from "~/api/files.ts"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { Pagination } from "~/components/ui/pagination.tsx"; import { healthLabel } from "~/lib/labels.ts"; import { cn } from "~/lib/utils.ts"; import { displayPath, groupRoots, joinRel, segments } from "./model.ts"; type Loc = { root: string; path: string }; type DropHandler = (target: Loc, e: React.DragEvent) => void; const nodeKey = (root: string, path: string) => `${root}\u0000${path}`; /** * 登记目录 + 懒加载目录树。下载目录在前、媒体库在后,每个登记目录带健康点与剩余空间; * 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。 * pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。 */ export function DirTree({ roots, current, onSelect, onDropTo, pickOnly = false, paginate = false, defaultOpen = [], className, }: { roots: FileRoot[]; current: Loc | null; onSelect: (loc: Loc) => void; onDropTo?: DropHandler; pickOnly?: boolean; /** 目录选择小窗按页展示子目录;主文件树保持懒展开。 */ paginate?: boolean; /** 初始展开的根 id(目标选择器里默认展开可写根) */ defaultOpen?: string[]; className?: string; }) { const [open, setOpen] = useState>(() => new Set(defaultOpen.map((id) => nodeKey(id, "")))); // 当前路径的各级祖先自动展开 useEffect(() => { if (!current) return; setOpen((prev) => { const next = new Set(prev); const segs = segments(current.path); next.add(nodeKey(current.root, "")); for (let i = 1; i < segs.length; i++) next.add(nodeKey(current.root, segs.slice(0, i).join("/"))); return next.size === prev.size ? prev : next; }); }, [current]); const toggle = (k: string) => setOpen((prev) => { const next = new Set(prev); if (next.has(k)) next.delete(k); else next.add(k); return next; }); const groups = groupRoots(roots); return ( ); } function RootNode({ root, current, open, toggle, onSelect, onDropTo, pickOnly, paginate, }: { root: FileRoot; current: Loc | null; open: Set; toggle: (k: string) => void; onSelect: (loc: Loc) => void; onDropTo?: DropHandler; pickOnly: boolean; paginate: boolean; }) { const k = nodeKey(root.id, ""); const offline = root.health === "offline"; const disabled = pickOnly && (!root.writable || offline); const isOpen = open.has(k) && !offline; const active = current?.root === root.id && current.path === ""; const h = healthLabel(root.health); const Icon = root.kind === "download" ? Download : root.kind === "storage" ? Cloud : HardDrive; return (
toggle(k)} onClick={() => { if (disabled) return; onSelect({ root: root.id, path: "" }); if (!open.has(k)) toggle(k); }} onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined} title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : displayPath(root)} > {root.name} {root.health !== "healthy" ? ( {h.tone !== "neutral" ? : null} {h.label} ) : null} {root.pendingCount ? ( 未识别 {root.pendingCount} {root.pendingPartial ? "+" : ""} ) : null} {!root.writable && root.kind === "library" ? 只读 : null} {isOpen ? ( ) : null}
); } function Children({ root, path, depth, current, open, toggle, onSelect, onDropTo, pickOnly, paginate, }: { root: FileRoot; path: string; depth: number; current: Loc | null; open: Set; toggle: (k: string) => void; onSelect: (loc: Loc) => void; onDropTo?: DropHandler; pickOnly: boolean; paginate: boolean; }) { const q = useDirChildren(root.id, path, true); const dirs = q.data?.dirs ?? []; const paged = useListPagination(dirs, { resetKey: JSON.stringify({ root: root.id, path }) }); const selectedIndex = current?.root === root.id ? dirs.findIndex((dir) => { const childPath = dir.path || joinRel(path, dir.name); return current.path === childPath || current.path.startsWith(`${childPath}/`); }) : -1; // biome-ignore lint/correctness/useExhaustiveDependencies: 选中目录改变时定位;手动翻页不改变已有选择 useEffect(() => { if (paginate && selectedIndex >= 0) paged.pagination.onPageChange(Math.floor(selectedIndex / paged.pagination.pageSize) + 1); }, [paginate, root.id, path, selectedIndex, paged.pagination.pageSize]); if (q.isPending) return (
加载中
); if (q.error) return (

读取失败

); if (!dirs.length) return (

无子目录

); return (
{(paginate ? paged.items : dirs)?.map((d) => { const p = d.path || joinRel(path, d.name); const k = nodeKey(root.id, p); const isOpen = open.has(k); const active = current?.root === root.id && current.path === p; const disabled = pickOnly && !root.writable; return (
toggle(k)} onClick={() => !disabled && onSelect({ root: root.id, path: p })} onDrop={onDropTo && root.writable ? (e) => onDropTo({ root: root.id, path: p }, e) : undefined} title={d.name} > {isOpen ? ( ) : ( )} {d.name} {isOpen && d.hasChildren ? ( ) : null}
); })} {paginate && dirs.length > 0 ? : null} {q.data?.truncated ? (

子目录过多,只列出一部分

) : null}
); } function TreeRow({ depth, active, disabled, expandable, expanded, onToggle, onClick, onDrop, title, children, }: { depth: number; active: boolean; disabled?: boolean; expandable: boolean; expanded: boolean; onToggle: () => void; onClick: () => void; onDrop?: (e: React.DragEvent) => void; title?: string; children: React.ReactNode; }) { const [over, setOver] = useState(false); return ( // biome-ignore lint/a11y/noStaticElementInteractions: 整行是拖放目标,交互本体是里面的两个按钮
{ e.preventDefault(); e.dataTransfer.dropEffect = e.altKey ? "copy" : "move"; setOver(true); } : undefined } onDragLeave={onDrop ? () => setOver(false) : undefined} onDrop={ onDrop ? (e) => { e.preventDefault(); setOver(false); onDrop(e); } : undefined } >
); }