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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user