目录树放进同背景、边框、圆角的卡片,两卡间距与上下边缘对齐;头部「目录」+ 折叠钮与工具栏第一行等高, 折叠钮从主面板工具栏挪到这里;拖宽与折叠记忆保留,手机抽屉不变。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { type FileRoot, useDirChildren } from "~/api/files.ts";
|
|
import { healthLabel } from "~/lib/labels.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { 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,
|
|
defaultOpen = [],
|
|
className,
|
|
}: {
|
|
roots: FileRoot[];
|
|
current: Loc | null;
|
|
onSelect: (loc: Loc) => void;
|
|
onDropTo?: DropHandler;
|
|
pickOnly?: boolean;
|
|
/** 初始展开的根 id(目标选择器里默认展开可写根) */
|
|
defaultOpen?: string[];
|
|
className?: string;
|
|
}) {
|
|
const [open, setOpen] = useState<Set<string>>(() => 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 (
|
|
<nav aria-label="登记目录" className={cn("space-y-4", className)}>
|
|
{groups.map((g) => (
|
|
<section key={g.title} aria-label={g.title}>
|
|
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground/75 uppercase">
|
|
{g.title}
|
|
</p>
|
|
<div className="space-y-0.5">
|
|
{g.items.map((r) => (
|
|
<RootNode
|
|
key={r.id}
|
|
root={r}
|
|
current={current}
|
|
open={open}
|
|
toggle={toggle}
|
|
onSelect={onSelect}
|
|
onDropTo={onDropTo}
|
|
pickOnly={pickOnly}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function RootNode({
|
|
root,
|
|
current,
|
|
open,
|
|
toggle,
|
|
onSelect,
|
|
onDropTo,
|
|
pickOnly,
|
|
}: {
|
|
root: FileRoot;
|
|
current: Loc | null;
|
|
open: Set<string>;
|
|
toggle: (k: string) => void;
|
|
onSelect: (loc: Loc) => void;
|
|
onDropTo?: DropHandler;
|
|
pickOnly: 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 : HardDrive;
|
|
return (
|
|
<div>
|
|
<TreeRow
|
|
depth={0}
|
|
active={active}
|
|
disabled={disabled}
|
|
expandable={!offline}
|
|
expanded={isOpen}
|
|
onToggle={() => 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 ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-body text-foreground">{root.name}</span>
|
|
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
|
{root.health !== "healthy" ? (
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1",
|
|
h.tone === "destructive" && "text-destructive",
|
|
h.tone === "warning" && "text-warning",
|
|
)}
|
|
>
|
|
{h.tone !== "neutral" ? <TriangleAlert className="h-3 w-3" /> : null}
|
|
{h.label}
|
|
</span>
|
|
) : null}
|
|
{root.pendingCount ? (
|
|
<span className="shrink-0 text-warning">
|
|
未识别 {root.pendingCount}
|
|
{root.pendingPartial ? "+" : ""}
|
|
</span>
|
|
) : null}
|
|
{!root.writable && root.kind === "library" ? <span>只读</span> : null}
|
|
</span>
|
|
</span>
|
|
</TreeRow>
|
|
{isOpen ? (
|
|
<Children
|
|
root={root}
|
|
path=""
|
|
depth={1}
|
|
current={current}
|
|
open={open}
|
|
toggle={toggle}
|
|
onSelect={onSelect}
|
|
onDropTo={onDropTo}
|
|
pickOnly={pickOnly}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Children({
|
|
root,
|
|
path,
|
|
depth,
|
|
current,
|
|
open,
|
|
toggle,
|
|
onSelect,
|
|
onDropTo,
|
|
pickOnly,
|
|
}: {
|
|
root: FileRoot;
|
|
path: string;
|
|
depth: number;
|
|
current: Loc | null;
|
|
open: Set<string>;
|
|
toggle: (k: string) => void;
|
|
onSelect: (loc: Loc) => void;
|
|
onDropTo?: DropHandler;
|
|
pickOnly: boolean;
|
|
}) {
|
|
const q = useDirChildren(root.id, path, true);
|
|
if (q.isPending)
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
|
|
style={{ paddingLeft: depth * 14 + 28 }}
|
|
>
|
|
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中
|
|
</div>
|
|
);
|
|
if (q.error)
|
|
return (
|
|
<p className="py-1 text-meta text-destructive" style={{ paddingLeft: depth * 14 + 28 }}>
|
|
读取失败
|
|
</p>
|
|
);
|
|
const dirs = q.data?.dirs ?? [];
|
|
if (!dirs.length)
|
|
return (
|
|
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
|
无子目录
|
|
</p>
|
|
);
|
|
return (
|
|
<div>
|
|
{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 (
|
|
<div key={k}>
|
|
<TreeRow
|
|
depth={depth}
|
|
active={active}
|
|
disabled={disabled}
|
|
expandable={d.hasChildren}
|
|
expanded={isOpen && d.hasChildren}
|
|
onToggle={() => 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 ? (
|
|
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-body">{d.name}</span>
|
|
</TreeRow>
|
|
{isOpen && d.hasChildren ? (
|
|
<Children
|
|
root={root}
|
|
path={p}
|
|
depth={depth + 1}
|
|
current={current}
|
|
open={open}
|
|
toggle={toggle}
|
|
onSelect={onSelect}
|
|
onDropTo={onDropTo}
|
|
pickOnly={pickOnly}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
{q.data?.truncated ? (
|
|
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
|
子目录过多,只列出一部分
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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: 整行是拖放目标,交互本体是里面的两个按钮
|
|
<div
|
|
className={cn(
|
|
"group flex items-center gap-1 rounded-lg pr-2 transition-colors duration-micro",
|
|
active
|
|
? "pill-selected text-foreground"
|
|
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
|
disabled && "opacity-50",
|
|
over && "bg-foreground/[0.08] ring-1 ring-ring",
|
|
)}
|
|
style={{ paddingLeft: depth * 14 }}
|
|
onDragOver={
|
|
onDrop
|
|
? (e) => {
|
|
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
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-label={expanded ? "收起" : "展开"}
|
|
aria-expanded={expanded}
|
|
onClick={onToggle}
|
|
className={cn("grid h-7 w-6 shrink-0 place-items-center rounded-md", !expandable && "invisible")}
|
|
>
|
|
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform duration-base", expanded && "rotate-90")} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
title={title}
|
|
aria-current={active ? "location" : undefined}
|
|
aria-disabled={disabled || undefined}
|
|
className={cn(
|
|
"flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left outline-none focus-visible:underline",
|
|
disabled && "cursor-not-allowed",
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|