import { useCallback, useEffect, useRef, useState } from "react" import { ChevronRight, Loader2, RefreshCw } from "lucide-react" import { FileTargetMenu } from "~/components/app/file-target-menu" import { FileIcon } from "~/components/explorer/file-icon" import { Button } from "~/components/ui/button" import { ScrollArea } from "~/components/ui/scroll-area" import { api } from "~/lib/api" import type { MappingInfo, ServerItem } from "~/lib/api" import type { FileActionId, FileTarget } from "~/lib/tfs" import { SERVER_ROOT_PATH, getAncestorPaths, makeFileTarget, normalizeServerPath, resolveLocalPath, } from "~/lib/tfs" import { cn } from "~/lib/utils" /** * 左侧 Source Tree 面板:固定 Collection 下的服务端目录树,懒加载子目录, * 与中间列表共享当前路径;节点支持与中间列表同规则的右键菜单。 */ export function SourceTreePanel({ collection, mappings, selectedServerPath, onNavigate, onFileAction, }: { collection: string mappings: MappingInfo[] selectedServerPath: string onNavigate: (serverPath: string) => void onFileAction: (target: FileTarget, action: FileActionId) => void }) { // path -> 子目录列表(仅 folder),undefined 表示尚未加载。 const [childrenByPath, setChildrenByPath] = useState>({}) const [expanded, setExpanded] = useState>({ [SERVER_ROOT_PATH]: true, }) const [loadingPaths, setLoadingPaths] = useState>({}) const [error, setError] = useState(null) // 手动刷新令牌:递增时清空缓存并重新加载。 const [reloadToken, setReloadToken] = useState(0) // 已加载与加载中的路径集合,避免异步流程里读到过期 state。 const requestedRef = useRef>(new Set()) /** * 懒加载指定目录的子目录列表,已加载或加载中的路径直接跳过。 */ const ensureChildren = useCallback( async (serverPath: string) => { const target = normalizeServerPath(serverPath) if (requestedRef.current.has(target)) { return } requestedRef.current.add(target) setLoadingPaths((prev) => ({ ...prev, [target]: true })) const result = await api.getServerTree({ path: target, collection }) setLoadingPaths((prev) => ({ ...prev, [target]: false })) if (!result.ok) { requestedRef.current.delete(target) setError(result.errorMessage ?? "目录加载失败") return } setError(null) const folders = (result.data?.items ?? []).filter((item) => item.folder) setChildrenByPath((prev) => ({ ...prev, [target]: folders })) }, [collection], ) // 当前路径变化或手动刷新时,逐级加载并展开祖先链与自身。 useEffect(() => { let active = true void (async () => { const chain = [...getAncestorPaths(selectedServerPath), normalizeServerPath(selectedServerPath)] for (const path of chain) { if (!active) { return } await ensureChildren(path) setExpanded((prev) => (prev[path] ? prev : { ...prev, [path]: true })) } })() return () => { active = false } }, [selectedServerPath, ensureChildren, reloadToken]) /** * 手动刷新目录树:清空懒加载缓存并从根重新加载,保持当前选中路径的展开链。 */ const handleRefresh = useCallback(() => { requestedRef.current.clear() setChildrenByPath({}) setExpanded({ [SERVER_ROOT_PATH]: true }) setError(null) setReloadToken((token) => token + 1) }, []) /** * 切换节点展开 / 收起,首次展开时触发懒加载。 */ const toggleExpand = useCallback( (serverPath: string) => { setExpanded((prev) => { const next = !prev[serverPath] if (next) { void ensureChildren(serverPath) } return { ...prev, [serverPath]: next } }) }, [ensureChildren], ) /** * 点击节点名称:选中并导航到该目录,同时确保其展开。 */ const handleSelect = useCallback( (serverPath: string) => { onNavigate(serverPath) setExpanded((prev) => (prev[serverPath] ? prev : { ...prev, [serverPath]: true })) void ensureChildren(serverPath) }, [onNavigate, ensureChildren], ) return ( ) } /** * 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,整行支持右键菜单。 */ function TreeNode({ path, name, depth, mappings, childrenByPath, expanded, loadingPaths, selectedServerPath, onToggle, onSelect, onFileAction, }: { path: string name: string depth: number mappings: MappingInfo[] childrenByPath: Record expanded: Record loadingPaths: Record selectedServerPath: string onToggle: (serverPath: string) => void onSelect: (serverPath: string) => void onFileAction: (target: FileTarget, action: FileActionId) => void }) { const children = childrenByPath[path] const isExpanded = expanded[path] === true const isLoading = loadingPaths[path] === true const isSelected = selectedServerPath === path // 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。 const isLeaf = children != null && children.length === 0 const isMapped = resolveLocalPath(mappings, path) != null const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings }) return (
{isLoading ? ( ) : isLeaf ? null : ( )}
{isExpanded && children != null && children.length > 0 && (
{children.map((child) => ( ))}
)}
) }