refactor: 更新挂起更改处理逻辑与组件交互
- 将 `onToggleExcluded` 方法重命名为 `onSetExcluded`,并更新相关调用,支持批量设置挂起更改的 Included/Excluded 状态。 - 在 `ChangesPanel` 组件中实现目录树结构,支持折叠与三态复选功能,提升用户体验。 - 修改 `Checkbox` 组件以支持不确定状态的显示,增强视觉反馈。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user