refactor: 更新挂起更改处理逻辑与组件交互

- 将 `onToggleExcluded` 方法重命名为 `onSetExcluded`,并更新相关调用,支持批量设置挂起更改的 Included/Excluded 状态。
- 在 `ChangesPanel` 组件中实现目录树结构,支持折叠与三态复选功能,提升用户体验。
- 修改 `Checkbox` 组件以支持不确定状态的显示,增强视觉反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-27 11:48:49 +08:00
co-authored by Cursor
parent 24de23ee61
commit 0bbbe068d4
4 changed files with 331 additions and 90 deletions
@@ -139,7 +139,7 @@ export function WorkspaceShell({
loading={pending.loading} loading={pending.loading}
error={pending.error} error={pending.error}
checkinBusy={actions.checkinBusy} checkinBusy={actions.checkinBusy}
onToggleExcluded={pending.toggleExcluded} onSetExcluded={pending.setExcluded}
onCheckin={actions.handleCheckin} onCheckin={actions.handleCheckin}
onFileAction={actions.handleFileAction} onFileAction={actions.handleFileAction}
onRefresh={() => void pending.refresh()} onRefresh={() => void pending.refresh()}
@@ -1,7 +1,8 @@
import { useCallback, useMemo, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, ListChecks, Loader2, RefreshCw } from "lucide-react" import { ChevronRight, ListChecks, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu" import { FileTargetMenu } from "~/components/app/file-target-menu"
import { FileIcon } from "~/components/explorer/file-icon"
import { Badge } from "~/components/ui/badge" import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox" import { Checkbox } from "~/components/ui/checkbox"
@@ -9,12 +10,112 @@ import { ScrollArea } from "~/components/ui/scroll-area"
import { Textarea } from "~/components/ui/textarea" import { Textarea } from "~/components/ui/textarea"
import type { MappingInfo, PendingChange } from "~/lib/api" import type { MappingInfo, PendingChange } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs" import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs" import {
SERVER_ROOT_PATH,
makeFileTarget,
normalizeServerPath,
statusBadgeClass,
statusLabel,
} from "~/lib/tfs"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
// 挂起更改目录树节点:目录节点聚合子项,文件/变更目录节点携带 change。
interface ChangeNode {
path: string
name: string
isDir: boolean
change: PendingChange | null
children: Map<string, ChangeNode>
}
/** /**
* 右侧 Changes 面板:Pending Changes 的 Included / Excluded 分组维护、 * 把扁平的挂起更改按 serverPath 还原成目录树。中间目录自动补齐为目录节点,
* 项级右键菜单,以及作为签入唯一入口的 comment + Checkin 区域。 * 末段挂载对应 change(变更本身是目录时该节点 isDir=true,可继续容纳子项)。
*/
function buildChangeTree(changes: PendingChange[]): ChangeNode {
const root: ChangeNode = {
path: SERVER_ROOT_PATH,
name: SERVER_ROOT_PATH,
isDir: true,
change: null,
children: new Map(),
}
for (const change of changes) {
const normalized = normalizeServerPath(change.serverPath)
const relative =
normalized === SERVER_ROOT_PATH ? "" : normalized.slice(SERVER_ROOT_PATH.length)
const segments = relative.length > 0 ? relative.split("/") : []
let current = root
let currentPath = SERVER_ROOT_PATH
segments.forEach((segment, index) => {
currentPath =
currentPath === SERVER_ROOT_PATH
? `${SERVER_ROOT_PATH}${segment}`
: `${currentPath}/${segment}`
let child = current.children.get(segment)
if (!child) {
child = { path: currentPath, name: segment, isDir: true, change: null, children: new Map() }
current.children.set(segment, child)
}
if (index === segments.length - 1) {
child.change = change
child.isDir = change.folder
}
current = child
})
}
return root
}
/**
* 收集某节点子树内全部挂起更改的 serverPath(含节点自身的 change),
* 用于目录节点的计数与三态复选。
*/
function collectChangePaths(node: ChangeNode): string[] {
const result: string[] = []
const walk = (current: ChangeNode) => {
if (current.change) {
result.push(current.change.serverPath)
}
for (const child of current.children.values()) {
walk(child)
}
}
walk(node)
return result
}
// 目录/文件混排排序:目录在前,名称不区分大小写升序。
function sortNodes(nodes: ChangeNode[]): ChangeNode[] {
return [...nodes].sort((a, b) => {
if (a.isDir !== b.isDir) {
return a.isDir ? -1 : 1
}
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
})
}
/**
* 路径压缩:把「只有单个子目录且自身无变更」的目录链合并为一行(VS Code 风格),
* 返回最终展示节点与合并后的名称。
*/
function compactDir(node: ChangeNode): { node: ChangeNode; label: string } {
let current = node
const parts = [node.name]
while (current.isDir && !current.change && current.children.size === 1) {
const only = [...current.children.values()][0]
if (!only.isDir) {
break
}
parts.push(only.name)
current = only
}
return { node: current, label: parts.join("/") }
}
/**
* 右侧 Changes 面板:以服务端目录树呈现 Pending Changes,目录节点支持折叠与
* 三态复选(级联子项 Included / Excluded),并作为签入唯一入口提供 comment + Checkin。
*/ */
export function ChangesPanel({ export function ChangesPanel({
mappings, mappings,
@@ -23,7 +124,7 @@ export function ChangesPanel({
loading, loading,
error, error,
checkinBusy, checkinBusy,
onToggleExcluded, onSetExcluded,
onCheckin, onCheckin,
onFileAction, onFileAction,
onRefresh, onRefresh,
@@ -34,24 +135,40 @@ export function ChangesPanel({
loading: boolean loading: boolean
error: string | null error: string | null
checkinBusy: boolean checkinBusy: boolean
onToggleExcluded: (serverPath: string) => void onSetExcluded: (serverPaths: string[], excluded: boolean) => void
onCheckin: (paths: string[], comment: string) => Promise<boolean> onCheckin: (paths: string[], comment: string) => Promise<boolean>
onFileAction: (target: FileTarget, action: FileActionId) => void onFileAction: (target: FileTarget, action: FileActionId) => void
onRefresh: () => void onRefresh: () => void
}) { }) {
const [comment, setComment] = useState("") const [comment, setComment] = useState("")
// 已折叠的目录 path 集合;默认全部展开。
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const tree = useMemo(() => buildChangeTree(pendingChanges), [pendingChanges])
const rootChildren = useMemo(() => sortNodes([...tree.children.values()]), [tree])
const included = useMemo( const included = useMemo(
() => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)), () => pendingChanges.filter((change) => !excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys], [pendingChanges, excludedKeys],
) )
const excluded = useMemo(
() => pendingChanges.filter((change) => excludedKeys.has(change.serverPath)),
[pendingChanges, excludedKeys],
)
const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy const canCheckin = included.length > 0 && comment.trim().length > 0 && !checkinBusy
/**
* 折叠 / 展开目录节点。
*/
const toggleCollapsed = useCallback((path: string) => {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}, [])
/** /**
* 提交签入:只提交 Included 项,成功后清空注释。 * 提交签入:只提交 Included 项,成功后清空注释。
*/ */
@@ -106,38 +223,19 @@ export function ChangesPanel({
) : ( ) : (
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="p-2"> <div className="p-2">
<GroupHeader label="Included" count={included.length} /> {rootChildren.map((node) => (
{included.length === 0 && ( <ChangeTreeNode
<p className="px-2 py-1.5 text-xs text-muted-foreground"> key={node.path}
没有要签入的更改,勾选下方 Excluded 项可移回 node={node}
</p> depth={0}
)}
{included.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included
mappings={mappings} mappings={mappings}
onToggleExcluded={onToggleExcluded} excludedKeys={excludedKeys}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
onSetExcluded={onSetExcluded}
onFileAction={onFileAction} onFileAction={onFileAction}
/> />
))} ))}
{excluded.length > 0 && (
<>
<GroupHeader label="Excluded" count={excluded.length} className="mt-3" />
{excluded.map((change) => (
<PendingRow
key={change.serverPath}
change={change}
included={false}
mappings={mappings}
onToggleExcluded={onToggleExcluded}
onFileAction={onFileAction}
/>
))}
</>
)}
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
@@ -152,9 +250,7 @@ export function ChangesPanel({
className="resize-none text-sm" className="resize-none text-sm"
/> />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">将签入 {included.length} 项</span>
将签入 {included.length} 项
</span>
<Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}> <Button size="sm" disabled={!canCheckin} onClick={() => void handleCheckin()}>
{checkinBusy ? ( {checkinBusy ? (
<> <>
@@ -175,39 +271,155 @@ export function ChangesPanel({
} }
/** /**
* 分组标题行。 * 目录树节点:目录节点渲染折叠箭头 + 三态复选 + 计数并递归子节点;
* 文件 / 变更目录节点渲染单项复选 + 状态徽标,整行支持统一右键菜单。
*/ */
function GroupHeader({ function ChangeTreeNode({
label, node,
count, depth,
className, mappings,
excludedKeys,
collapsed,
onToggleCollapsed,
onSetExcluded,
onFileAction,
}: { }: {
label: string node: ChangeNode
count: number depth: number
className?: string mappings: MappingInfo[]
excludedKeys: Set<string>
collapsed: Set<string>
onToggleCollapsed: (path: string) => void
onSetExcluded: (serverPaths: string[], excluded: boolean) => void
onFileAction: (target: FileTarget, action: FileActionId) => void
}) { }) {
// 纯目录节点(自身无 change)做路径压缩;变更节点保持原样。
const { node: displayNode, label } =
node.isDir && !node.change ? compactDir(node) : { node, label: node.name }
const indent = depth * 12 + 4
const change = displayNode.change
// 叶子:文件,或没有子项的变更目录。
const isLeaf = !displayNode.isDir || displayNode.children.size === 0
if (isLeaf && change) {
return (
<LeafRow
node={displayNode}
label={label}
change={change}
indent={indent}
mappings={mappings}
excluded={excludedKeys.has(change.serverPath)}
onSetExcluded={onSetExcluded}
onFileAction={onFileAction}
/>
)
}
// 目录节点:聚合子树变更,决定三态与计数。
const descendantPaths = collectChangePaths(displayNode)
const excludedCount = descendantPaths.filter((path) => excludedKeys.has(path)).length
const checkboxState: boolean | "indeterminate" =
excludedCount === 0 ? true : excludedCount === descendantPaths.length ? false : "indeterminate"
const expanded = !collapsed.has(displayNode.path)
const children = sortNodes([...displayNode.children.values()])
// 已全部 Included 时点击改为全部排除,否则全部移回 Included。
const handleToggle = () => onSetExcluded(descendantPaths, checkboxState === true)
const header = (
<div
className="flex h-8 cursor-default items-center gap-1.5 rounded-md pr-2 select-none hover:bg-muted"
style={{ paddingLeft: `${indent}px` }}
title={displayNode.path}
>
<button
type="button"
aria-label={expanded ? "收起目录" : "展开目录"}
onClick={() => onToggleCollapsed(displayNode.path)}
className="flex size-4 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRight
className={cn("size-3.5 transition-transform duration-200", expanded && "rotate-90")}
/>
</button>
<Checkbox
checked={checkboxState}
onCheckedChange={handleToggle}
aria-label={checkboxState === true ? "排除该目录全部更改" : "签入该目录全部更改"}
/>
<FileIcon name={label} folder mapped={false} expanded={expanded} />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{label}</span>
{change && (
<Badge
variant="secondary"
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
>
<span className="size-1.5 rounded-full bg-current opacity-60" />
{statusLabel(change.status)}
</Badge>
)}
<span className="shrink-0 text-[11px] text-muted-foreground/70">{descendantPaths.length}</span>
</div>
)
return ( return (
<div className={cn("flex items-center gap-1.5 px-2 py-1", className)}> <div>
<span className="text-xs font-medium text-muted-foreground">{label}</span> {change ? (
<span className="text-xs text-muted-foreground/70">{count}</span> <FolderTargetMenu
node={displayNode}
change={change}
mappings={mappings}
onFileAction={onFileAction}
>
{header}
</FolderTargetMenu>
) : (
header
)}
{expanded && children.length > 0 && (
<div>
{children.map((child) => (
<ChangeTreeNode
key={child.path}
node={child}
depth={depth + 1}
mappings={mappings}
excludedKeys={excludedKeys}
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
onSetExcluded={onSetExcluded}
onFileAction={onFileAction}
/>
))}
</div>
)}
</div> </div>
) )
} }
/** /**
* 单条挂起更改:勾选维护 Included / Excluded,整行支持统一右键菜单。 * 文件 / 变更目录叶子行:复选框维护 Included / Excluded,整行支持统一右键菜单。
*/ */
function PendingRow({ function LeafRow({
node,
label,
change, change,
included, indent,
mappings, mappings,
onToggleExcluded, excluded,
onSetExcluded,
onFileAction, onFileAction,
}: { }: {
node: ChangeNode
label: string
change: PendingChange change: PendingChange
included: boolean indent: number
mappings: MappingInfo[] mappings: MappingInfo[]
onToggleExcluded: (serverPath: string) => void excluded: boolean
onSetExcluded: (serverPaths: string[], excluded: boolean) => void
onFileAction: (target: FileTarget, action: FileActionId) => void onFileAction: (target: FileTarget, action: FileActionId) => void
}) { }) {
const target = makeFileTarget({ const target = makeFileTarget({
@@ -217,35 +429,27 @@ function PendingRow({
mappings, mappings,
pendingStatus: change.status, pendingStatus: change.status,
}) })
const displayName =
change.status === "pendingRename" && change.sourceServerPath
? `${change.sourceServerPath.slice(change.sourceServerPath.lastIndexOf("/") + 1)} → ${change.name}`
: label
return ( return (
<FileTargetMenu target={target} onAction={onFileAction}> <FileTargetMenu target={target} onAction={onFileAction}>
<div <div
className={cn( className={cn(
"flex h-9 cursor-default items-center gap-2 rounded-md px-2 select-none hover:bg-muted", "flex h-8 cursor-default items-center gap-1.5 rounded-md pr-2 select-none hover:bg-muted",
!included && "opacity-60", excluded && "opacity-60",
)} )}
style={{ paddingLeft: `${indent + 22}px` }}
title={change.serverPath} title={change.serverPath}
> >
<Checkbox <Checkbox
checked={included} checked={!excluded}
onCheckedChange={() => onToggleExcluded(change.serverPath)} onCheckedChange={() => onSetExcluded([change.serverPath], !excluded)}
aria-label={included ? "移到 Excluded" : "移回 Included"} aria-label={!excluded ? "移到 Excluded" : "移回 Included"}
/> />
{change.folder ? ( <FileIcon name={node.name} folder={change.folder} />
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" /> <span className="min-w-0 flex-1 truncate text-sm">{displayName}</span>
) : (
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">
{change.status === "pendingRename" && change.sourceServerPath
? `${change.sourceServerPath.slice(change.sourceServerPath.lastIndexOf("/") + 1)} → ${change.name}`
: change.name}
</span>
<span className="block truncate font-mono text-[10px] leading-3 text-muted-foreground">
{change.serverPath}
</span>
</span>
<Badge <Badge
variant="secondary" variant="secondary"
className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))} className={cn("shrink-0 gap-1.5 rounded-md", statusBadgeClass(change.status))}
@@ -257,3 +461,33 @@ function PendingRow({
</FileTargetMenu> </FileTargetMenu>
) )
} }
/**
* 变更目录节点的右键菜单包装:复用统一动作模型,对目录类挂起更改提供动作。
*/
function FolderTargetMenu({
node,
change,
mappings,
onFileAction,
children,
}: {
node: ChangeNode
change: PendingChange
mappings: MappingInfo[]
onFileAction: (target: FileTarget, action: FileActionId) => void
children: React.ReactNode
}) {
const target = makeFileTarget({
source: "pending",
folder: true,
serverPath: node.path,
mappings,
pendingStatus: change.status,
})
return (
<FileTargetMenu target={target} onAction={onFileAction}>
{children}
</FileTargetMenu>
)
}
+4 -4
View File
@@ -2,17 +2,18 @@ import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui" import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
import { CheckIcon } from "lucide-react" import { CheckIcon, MinusIcon } from "lucide-react"
function Checkbox({ function Checkbox({
className, className,
...props ...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) { }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const indeterminate = props.checked === "indeterminate"
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
className className
)} )}
{...props} {...props}
@@ -21,8 +22,7 @@ function Checkbox({
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5" className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
> >
<CheckIcon {indeterminate ? <MinusIcon /> : <CheckIcon />}
/>
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) )
+14 -7
View File
@@ -40,15 +40,22 @@ export function usePendingChanges() {
}, [refresh]) }, [refresh])
/** /**
* 切换某条挂起更改的 Included / Excluded 归属。 * 批量设置一组挂起更改的 Included / Excluded 归属。
* 单项勾选传单元素数组,目录节点级联传子树全部 serverPath。
* excluded=true 全部排除,false 全部移回 Included。
*/ */
const toggleExcluded = useCallback((serverPath: string) => { const setExcluded = useCallback((serverPaths: string[], excluded: boolean) => {
if (serverPaths.length === 0) {
return
}
setExcludedKeys((prev) => { setExcludedKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(serverPath)) { for (const serverPath of serverPaths) {
next.delete(serverPath) if (excluded) {
} else { next.add(serverPath)
next.add(serverPath) } else {
next.delete(serverPath)
}
} }
return next return next
}) })
@@ -70,6 +77,6 @@ export function usePendingChanges() {
excludedKeys, excludedKeys,
pendingByServerPath, pendingByServerPath,
refresh, refresh,
toggleExcluded, setExcluded,
} }
} }