frontend: add lazy server tree and folder items list with synced navigation and local state badges
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,65 +1,219 @@
|
||||
import { FolderOpen } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { FileText, FolderClosed, Loader2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table"
|
||||
import { api } from "~/lib/api"
|
||||
import type { ServerItem } from "~/lib/api"
|
||||
import { pathsExist } from "~/lib/electron"
|
||||
import { resolveLocalPath } from "~/lib/tfs/mapping"
|
||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||
import {
|
||||
resolveItemLocalState,
|
||||
statusBadgeClass,
|
||||
statusLabel,
|
||||
} from "~/lib/tfs/status"
|
||||
import { cn, formatDateTime } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
|
||||
*/
|
||||
function findMapping(session: WorkspaceSession, serverPath: string) {
|
||||
let matched: WorkspaceSession["mappings"][number] | null = null
|
||||
for (const mapping of session.mappings) {
|
||||
if (
|
||||
serverPath === mapping.serverPath ||
|
||||
serverPath.startsWith(`${mapping.serverPath}/`)
|
||||
) {
|
||||
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
|
||||
matched = mapping
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
// 文件列表行:服务端目录项 + 推导出的本地路径与本地状态。
|
||||
interface FolderItemRow {
|
||||
item: ServerItem
|
||||
localPath: string | null
|
||||
localState: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 中间主工作区骨架:顶部展示当前路径与 Mapping 摘要,主体为文件列表占位,
|
||||
* 真实目录数据加载与表格在 FE-005 实现。
|
||||
* 中间主工作区:展示当前目录下一级文件 / 文件夹列表,
|
||||
* 单击选中、双击目录进入,与左侧树共享当前路径状态。
|
||||
*/
|
||||
export function FolderItemsPanel({
|
||||
session,
|
||||
selectedServerPath,
|
||||
onNavigate,
|
||||
}: {
|
||||
session: WorkspaceSession
|
||||
selectedServerPath: string
|
||||
onNavigate: (serverPath: string) => void
|
||||
}) {
|
||||
const mapping = findMapping(session, selectedServerPath)
|
||||
const [items, setItems] = useState<ServerItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null)
|
||||
// 本地路径 -> 是否存在;null 表示当前环境无法检测。
|
||||
const [existsMap, setExistsMap] = useState<Record<string, boolean> | null>(null)
|
||||
|
||||
// 当前路径变化时加载目录内容,并批量检测映射目标的本地存在性。
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSelectedItemPath(null)
|
||||
void (async () => {
|
||||
const result = await api.getFolderItems({
|
||||
path: selectedServerPath,
|
||||
collection: session.collection,
|
||||
})
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setItems([])
|
||||
setError(result.errorMessage ?? "目录加载失败")
|
||||
return
|
||||
}
|
||||
const list = result.data?.items ?? []
|
||||
setItems(list)
|
||||
const localPaths = list
|
||||
.map((item) => resolveLocalPath(session.mappings, item.serverPath))
|
||||
.filter((path): path is string => path != null)
|
||||
const exists = await pathsExist(localPaths)
|
||||
if (active) {
|
||||
setExistsMap(exists)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [selectedServerPath, session.collection, session.mappings])
|
||||
|
||||
// 组合行数据:推导本地路径与 已映射 / 未映射 / 未下载 状态。
|
||||
const rows = useMemo<FolderItemRow[]>(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const localPath = resolveLocalPath(session.mappings, item.serverPath)
|
||||
const localExists =
|
||||
localPath == null || existsMap == null
|
||||
? null
|
||||
: existsMap[localPath] ?? false
|
||||
return {
|
||||
item,
|
||||
localPath,
|
||||
localState: resolveItemLocalState(localPath != null, localExists),
|
||||
}
|
||||
}),
|
||||
[items, session.mappings, existsMap],
|
||||
)
|
||||
|
||||
const currentLocalPath = resolveLocalPath(session.mappings, selectedServerPath)
|
||||
const selectedRow = rows.find((row) => row.item.serverPath === selectedItemPath)
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{mapping ? (
|
||||
<span className="font-mono">{mapping.localPath}</span>
|
||||
{currentLocalPath ? (
|
||||
<span className="font-mono">{currentLocalPath}</span>
|
||||
) : (
|
||||
"未映射到本地"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<FolderOpen className="size-8 text-muted-foreground/60" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前目录文件列表将在后续任务加载
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground/80">
|
||||
{selectedServerPath}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载目录…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
当前目录为空
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
|
||||
<TableHead className="h-8 w-20 text-right text-xs text-muted-foreground">
|
||||
最新版本
|
||||
</TableHead>
|
||||
<TableHead className="h-8 w-36 text-xs text-muted-foreground">
|
||||
上次签入时间
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">本地路径</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const selected = row.item.serverPath === selectedItemPath
|
||||
return (
|
||||
<TableRow
|
||||
key={row.item.serverPath}
|
||||
data-state={selected ? "selected" : undefined}
|
||||
className="cursor-default select-none"
|
||||
onClick={() => setSelectedItemPath(row.item.serverPath)}
|
||||
onDoubleClick={() => {
|
||||
if (row.item.folder) {
|
||||
onNavigate(row.item.serverPath)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="py-1.5">
|
||||
<span className="flex items-center gap-1.5" title={row.item.serverPath}>
|
||||
{row.item.folder ? (
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm">{row.item.name}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("rounded-md", statusBadgeClass(row.localState))}
|
||||
>
|
||||
{statusLabel(row.localState)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-right font-mono text-xs">
|
||||
{row.item.latestVersion > 0 ? row.item.latestVersion : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||
{formatDateTime(row.item.checkinDate)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground">
|
||||
{row.localPath ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 shrink-0 items-center border-t px-3 text-xs text-muted-foreground">
|
||||
未选中对象
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 border-t px-3 text-xs text-muted-foreground">
|
||||
{selectedRow ? (
|
||||
<>
|
||||
<span className="font-medium text-foreground">{selectedRow.item.name}</span>
|
||||
<span className="truncate font-mono">{selectedRow.item.serverPath}</span>
|
||||
<span className="ml-auto shrink-0">
|
||||
{statusLabel(selectedRow.localState)}
|
||||
{selectedRow.item.latestVersion > 0 &&
|
||||
` · 版本 ${selectedRow.item.latestVersion}`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{rows.length > 0 ? `${rows.length} 个对象 · 未选中` : "未选中对象"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { FolderClosed } from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ChevronRight, FolderClosed, FolderOpen, Loader2 } from "lucide-react"
|
||||
|
||||
import { ScrollArea } from "~/components/ui/scroll-area"
|
||||
import { api } from "~/lib/api"
|
||||
import type { ServerItem } from "~/lib/api"
|
||||
import { getAncestorPaths, normalizeServerPath } from "~/lib/tfs/path"
|
||||
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
/**
|
||||
* 左侧 Source Tree 面板骨架:展示固定 Collection 的根节点并与中间列表共享路径状态,
|
||||
* 目录树懒加载与子节点展开在 FE-005 实现。
|
||||
* 左侧 Source Tree 面板:固定 Collection 下的服务端目录树,懒加载子目录,
|
||||
* 与中间列表共享当前路径;中间双击进入目录时自动展开并选中对应节点。
|
||||
*/
|
||||
export function SourceTreePanel({
|
||||
collection,
|
||||
@@ -17,33 +21,206 @@ export function SourceTreePanel({
|
||||
selectedServerPath: string
|
||||
onNavigate: (serverPath: string) => void
|
||||
}) {
|
||||
const rootActive = selectedServerPath === SERVER_ROOT_PATH
|
||||
// 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)
|
||||
// 已加载与加载中的路径集合,避免异步流程里读到过期 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])
|
||||
|
||||
/**
|
||||
* 切换节点展开 / 收起,首次展开时触发懒加载。
|
||||
*/
|
||||
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 border-r bg-sidebar">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between border-b px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">源码目录</span>
|
||||
<span className="max-w-32 truncate text-xs text-muted-foreground">{collection}</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(SERVER_ROOT_PATH)}
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm",
|
||||
rootActive
|
||||
? "bg-primary/10 font-medium text-foreground"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-xs">{SERVER_ROOT_PATH}</span>
|
||||
</button>
|
||||
<p className="px-2 py-3 text-xs leading-5 text-muted-foreground">
|
||||
服务端目录树将在后续任务加载,当前仅提供根节点导航。
|
||||
</p>
|
||||
{error && (
|
||||
<p className="px-2 py-1 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
<TreeNode
|
||||
path={SERVER_ROOT_PATH}
|
||||
name={SERVER_ROOT_PATH}
|
||||
depth={0}
|
||||
childrenByPath={childrenByPath}
|
||||
expanded={expanded}
|
||||
loadingPaths={loadingPaths}
|
||||
selectedServerPath={normalizeServerPath(selectedServerPath)}
|
||||
onToggle={toggleExpand}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,行高 28px。
|
||||
*/
|
||||
function TreeNode({
|
||||
path,
|
||||
name,
|
||||
depth,
|
||||
childrenByPath,
|
||||
expanded,
|
||||
loadingPaths,
|
||||
selectedServerPath,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
path: string
|
||||
name: string
|
||||
depth: number
|
||||
childrenByPath: Record<string, ServerItem[]>
|
||||
expanded: Record<string, boolean>
|
||||
loadingPaths: Record<string, boolean>
|
||||
selectedServerPath: string
|
||||
onToggle: (serverPath: string) => void
|
||||
onSelect: (serverPath: string) => 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
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-7 items-center rounded-md pr-1",
|
||||
isSelected ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
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 hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 transition-transform", 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"
|
||||
>
|
||||
{isExpanded && !isLeaf ? (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
isSelected && "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded &&
|
||||
children?.map((child) => (
|
||||
<TreeNode
|
||||
key={child.serverPath}
|
||||
path={child.serverPath}
|
||||
name={child.name}
|
||||
depth={depth + 1}
|
||||
childrenByPath={childrenByPath}
|
||||
expanded={expanded}
|
||||
loadingPaths={loadingPaths}
|
||||
selectedServerPath={selectedServerPath}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user