frontend: add unified file action model with context menus across tree, list and pending changes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-10 18:09:48 +08:00
co-authored by Cursor
parent bbbb938df0
commit 0b908e8d2c
12 changed files with 911 additions and 78 deletions
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2 } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { Badge } from "~/components/ui/badge"
import {
Table,
@@ -13,13 +14,14 @@ import {
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 type { FileActionId, FileTarget, WorkspaceSession } from "~/lib/tfs"
import {
makeFileTarget,
resolveItemLocalState,
resolveLocalPath,
statusBadgeClass,
statusLabel,
} from "~/lib/tfs/status"
} from "~/lib/tfs"
import { cn, formatDateTime } from "~/lib/utils"
// 文件列表行:服务端目录项 + 推导出的本地路径与本地状态。
@@ -31,16 +33,22 @@ interface FolderItemRow {
/**
* 中间主工作区:展示当前目录下一级文件 / 文件夹列表,
* 单击选中、双击目录进入,与左侧树共享当前路径状态。
* 单击选中、双击目录进入,行级右键菜单与左侧树共用同一套动作模型。
*/
export function FolderItemsPanel({
session,
selectedServerPath,
pendingByServerPath,
refreshToken,
onNavigate,
onFileAction,
}: {
session: WorkspaceSession
selectedServerPath: string
pendingByServerPath: Record<string, string>
refreshToken: number
onNavigate: (serverPath: string) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) {
const [items, setItems] = useState<ServerItem[]>([])
const [loading, setLoading] = useState(false)
@@ -82,7 +90,7 @@ export function FolderItemsPanel({
return () => {
active = false
}
}, [selectedServerPath, session.collection, session.mappings])
}, [selectedServerPath, session.collection, session.mappings, refreshToken])
// 组合行数据:推导本地路径与 已映射 / 未映射 / 未下载 状态。
const rows = useMemo<FolderItemRow[]>(
@@ -151,12 +159,24 @@ export function FolderItemsPanel({
<TableBody>
{rows.map((row) => {
const selected = row.item.serverPath === selectedItemPath
const target = makeFileTarget({
source: "list",
folder: row.item.folder,
serverPath: row.item.serverPath,
mappings: session.mappings,
pendingStatus: pendingByServerPath[row.item.serverPath] ?? null,
})
return (
<TableRow
<FileTargetMenu
key={row.item.serverPath}
target={target}
onAction={onFileAction}
>
<TableRow
data-state={selected ? "selected" : undefined}
className="cursor-default select-none"
onClick={() => setSelectedItemPath(row.item.serverPath)}
onContextMenu={() => setSelectedItemPath(row.item.serverPath)}
onDoubleClick={() => {
if (row.item.folder) {
onNavigate(row.item.serverPath)
@@ -191,6 +211,7 @@ export function FolderItemsPanel({
{row.localPath ?? "—"}
</TableCell>
</TableRow>
</FileTargetMenu>
)
})}
</TableBody>
@@ -1,25 +1,35 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronRight, FolderClosed, FolderOpen, Loader2 } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
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 type { MappingInfo, ServerItem } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import {
SERVER_ROOT_PATH,
getAncestorPaths,
makeFileTarget,
normalizeServerPath,
} 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[]>>({})
@@ -117,12 +127,14 @@ export function SourceTreePanel({
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>
@@ -131,28 +143,32 @@ export function SourceTreePanel({
}
/**
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,行高 28px。
* 递归渲染目录树节点:缩进 + 展开箭头 + 文件夹图标 + 名称,整行支持右键菜单。
*/
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
@@ -160,52 +176,55 @@ function TreeNode({
const isSelected = selectedServerPath === path
// 未加载前默认认为可能有子目录;加载后子目录为空则视为叶子。
const isLeaf = children != null && children.length === 0
const target = makeFileTarget({ source: "tree", folder: true, serverPath: path, mappings })
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>
<FileTargetMenu target={target} onAction={onFileAction}>
<div
className={cn(
"group flex h-7 items-center rounded-md pr-1",
isSelected ? "bg-primary/10" : "hover:bg-muted",
)}
</span>
<button
type="button"
onClick={() => onSelect(path)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
style={{ paddingLeft: `${depth * 14 + 2}px` }}
>
{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",
<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>
)}
>
{name}
</span>
</button>
</div>
<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>
</FileTargetMenu>
{isExpanded &&
children?.map((child) => (
<TreeNode
@@ -213,12 +232,14 @@ function TreeNode({
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>