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:
fenghp
2026-06-10 17:57:51 +08:00
co-authored by Cursor
parent 81e71837bf
commit bbbb938df0
17 changed files with 831 additions and 68 deletions
@@ -54,7 +54,11 @@ export function WorkspaceShell({
onNavigate={onNavigate}
/>
)}
<FolderItemsPanel session={session} selectedServerPath={selectedServerPath} />
<FolderItemsPanel
session={session}
selectedServerPath={selectedServerPath}
onNavigate={onNavigate}
/>
{panels.changes && <ChangesPanel />}
</div>
@@ -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>
)
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}