Files
mactfs/mactfsui/app/components/explorer/source-tree-panel.tsx
T

279 lines
9.2 KiB
TypeScript

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<Record<string, ServerItem[]>>({})
const [expanded, setExpanded] = useState<Record<string, boolean>>({
[SERVER_ROOT_PATH]: true,
})
const [loadingPaths, setLoadingPaths] = useState<Record<string, boolean>>({})
const [error, setError] = useState<string | null>(null)
// 手动刷新令牌:递增时清空缓存并重新加载。
const [reloadToken, setReloadToken] = useState(0)
// 已加载与加载中的路径集合,避免异步流程里读到过期 state。
const requestedRef = useRef<Set<string>>(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 (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
源码目录
</span>
<span className="ml-auto max-w-28 truncate text-xs text-muted-foreground">{collection}</span>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
onClick={handleRefresh}
aria-label="刷新目录树"
title="刷新目录树"
>
<RefreshCw />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2">
{error && (
<p className="px-2 py-1 text-xs text-destructive">{error}</p>
)}
<TreeNode
path={SERVER_ROOT_PATH}
name={SERVER_ROOT_PATH}
depth={0}
mappings={mappings}
childrenByPath={childrenByPath}
expanded={expanded}
loadingPaths={loadingPaths}
selectedServerPath={normalizeServerPath(selectedServerPath)}
onToggle={toggleExpand}
onSelect={handleSelect}
onFileAction={onFileAction}
/>
</div>
</ScrollArea>
</aside>
)
}
/**
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,整行支持右键菜单。
*/
function TreeNode({
path,
name,
depth,
mappings,
childrenByPath,
expanded,
loadingPaths,
selectedServerPath,
onToggle,
onSelect,
onFileAction,
}: {
path: string
name: string
depth: number
mappings: MappingInfo[]
childrenByPath: Record<string, ServerItem[]>
expanded: Record<string, boolean>
loadingPaths: Record<string, boolean>
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 (
<div>
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"group flex h-7 items-center rounded-md pr-1 transition-colors duration-150",
isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
)}
style={{ paddingLeft: `${depth * 14 + 2}px` }}
>
<span className="flex size-5 shrink-0 items-center justify-center">
{isLoading ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isLeaf ? null : (
<button
type="button"
aria-label={isExpanded ? "收起目录" : "展开目录"}
onClick={() => onToggle(path)}
className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-200 ease-out-quart",
isExpanded && "rotate-90",
)}
/>
</button>
)}
</span>
<button
type="button"
onClick={() => onSelect(path)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
>
<FileIcon name={name} folder mapped={isMapped} expanded={isExpanded && !isLeaf} />
<span
className={cn(
"truncate text-sm",
isSelected && "font-medium text-foreground",
)}
>
{name}
</span>
</button>
</div>
</FileTargetMenu>
{isExpanded && children != null && children.length > 0 && (
<div className="animate-in fade-in slide-in-from-top-1 duration-200">
{children.map((child) => (
<TreeNode
key={child.serverPath}
path={child.serverPath}
name={child.name}
depth={depth + 1}
mappings={mappings}
childrenByPath={childrenByPath}
expanded={expanded}
loadingPaths={loadingPaths}
selectedServerPath={selectedServerPath}
onToggle={onToggle}
onSelect={onSelect}
onFileAction={onFileAction}
/>
))}
</div>
)}
</div>
)
}