Files
mactfs/mactfsui/app/components/explorer/compare-dialog.tsx
T

311 lines
12 KiB
TypeScript

import { useCallback, useMemo, useState } from "react"
import { FileText, FolderClosed, Loader2, RefreshCw } from "lucide-react"
import { FileTargetMenu } from "~/components/app/file-target-menu"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import { Checkbox } from "~/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { FolderDiffItem, MappingInfo } from "~/lib/api"
import type { FileActionId, FileTarget } from "~/lib/tfs"
import { makeFileTarget, statusBadgeClass, statusLabel } from "~/lib/tfs"
import { cn } from "~/lib/utils"
/**
* 目录对比弹窗:对已映射目录执行本地与服务端对比,
* 默认隐藏 upToDate,支持状态筛选、刷新重比,结果项走统一右键菜单。
*/
// 在对比弹窗内直接执行、完成后需要刷新对比结果的动作。
const ACTIONS_NEED_RECOMPARE: FileActionId[] = ["checkout", "getLatest", "undo", "delete", "add"]
export function CompareDialog({
serverPath,
mappings,
onClose,
onFileAction,
}: {
serverPath: string
mappings: MappingInfo[]
onClose: () => void
onFileAction: (target: FileTarget, action: FileActionId) => void | Promise<void>
}) {
// 先选项后对比:setup 阶段确认选项,result 阶段展示对比结果。
const [phase, setPhase] = useState<"setup" | "result">("setup")
const [diffs, setDiffs] = useState<FolderDiffItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUpToDate, setShowUpToDate] = useState(false)
// 忽略仅本地存在的项(如 node_modules):勾选后只对比两端都有的文件,并跳过本地全量扫描。
const [ignoreLocalOnly, setIgnoreLocalOnly] = useState(false)
// 状态筛选:空集合表示不过滤(除 upToDate 开关外)。
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set())
/**
* 执行(或重新执行)目录对比;忽略仅本地存在的项时由后端直接跳过本地扫描。
*/
const runCompare = useCallback(async () => {
setLoading(true)
setError(null)
const result = await api.compareFolder({
serverPath,
recursive: true,
includeLocalOnly: !ignoreLocalOnly,
})
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "目录对比失败")
return
}
setDiffs(result.data?.diffs ?? [])
}, [serverPath, ignoreLocalOnly])
/**
* 转发右键动作:签出 / 获取 / 撤销等就地执行的动作完成后自动重新对比,
* 让结果状态(如 签出编辑)立即反映最新事实。
*/
const handleAction = useCallback(
async (target: FileTarget, action: FileActionId) => {
await onFileAction(target, action)
if (ACTIONS_NEED_RECOMPARE.includes(action)) {
await runCompare()
}
},
[onFileAction, runCompare],
)
// 结果中出现过的状态(不含 upToDate),用于生成筛选 chips。
const presentStatuses = useMemo(() => {
const set = new Set<string>()
for (const diff of diffs) {
if (diff.status !== "upToDate") {
set.add(diff.status)
}
}
return [...set]
}, [diffs])
const visibleDiffs = useMemo(
() =>
diffs.filter((diff) => {
if (diff.status === "upToDate") {
return showUpToDate && statusFilter.size === 0
}
return statusFilter.size === 0 || statusFilter.has(diff.status)
}),
[diffs, showUpToDate, statusFilter],
)
/**
* 切换某个状态筛选 chip。
*/
const toggleStatus = useCallback((status: string) => {
setStatusFilter((prev) => {
const next = new Set(prev)
if (next.has(status)) {
next.delete(status)
} else {
next.add(status)
}
return next
})
}, [])
// 选项确认阶段:先确定对比方式再执行,避免反复触发全量对比。
if (phase === "setup") {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>目录对比</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-md border bg-muted/30 p-4">
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={ignoreLocalOnly}
onCheckedChange={(value) => setIgnoreLocalOnly(value === true)}
className="mt-0.5"
/>
<span>
<span className="font-medium">忽略仅本地存在的项</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
只对比两端都有的文件;仅本地存在的项(如 node_modules、构建产物)不计入差异,
并跳过本地全量扫描,大目录速度更快。
</span>
</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
取消
</Button>
<Button
onClick={() => {
setPhase("result")
void runCompare()
}}
>
开始对比
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="flex h-[88svh] max-h-[88svh] flex-col sm:max-w-[90vw]">
<DialogHeader>
<DialogTitle>目录对比{ignoreLocalOnly ? "(已忽略仅本地存在的项)" : ""}</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{presentStatuses.map((status) => (
<button key={status} type="button" onClick={() => toggleStatus(status)}>
<Badge
variant="secondary"
className={cn(
"cursor-pointer rounded-md",
statusBadgeClass(status),
statusFilter.size > 0 && !statusFilter.has(status) && "opacity-40",
)}
>
{statusLabel(status)}
</Badge>
</button>
))}
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<Checkbox
checked={showUpToDate}
onCheckedChange={(value) => setShowUpToDate(value === true)}
/>
显示最新项
</label>
<Button
variant="outline"
size="sm"
onClick={() => setPhase("setup")}
disabled={loading}
>
调整选项
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void runCompare()}
disabled={loading}
>
<RefreshCw data-icon="inline-start" className={cn(loading && "animate-spin")} />
重新对比
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
{loading ? (
<div className="flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在对比目录…
</div>
) : error ? (
<div className="flex h-48 items-center justify-center text-sm text-destructive">
{error}
</div>
) : visibleDiffs.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
{diffs.length === 0 ? "本地与服务端一致,没有差异" : "没有符合筛选条件的差异项"}
</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-28 text-xs text-muted-foreground">状态</TableHead>
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">本地版本</TableHead>
<TableHead className="h-8 w-24 text-right text-xs text-muted-foreground">最新版本</TableHead>
<TableHead className="h-8 text-xs text-muted-foreground">服务端路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleDiffs.map((diff) => {
const target = makeFileTarget({
source: "compare",
folder: diff.folder,
serverPath: diff.serverPath,
mappings,
pendingStatus: diff.status.startsWith("pending") ? diff.status : null,
compareStatus: diff.status,
})
return (
<FileTargetMenu
key={diff.serverPath}
target={target}
onAction={(menuTarget, action) => void handleAction(menuTarget, action)}
>
<TableRow className="cursor-default select-none">
<TableCell className="py-1.5">
<span className="flex items-center gap-1.5">
{diff.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">{diff.name}</span>
</span>
</TableCell>
<TableCell className="py-1.5">
<Badge
variant="secondary"
className={cn("rounded-md", statusBadgeClass(diff.status))}
>
{statusLabel(diff.status)}
</Badge>
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{diff.localVersion > 0 ? diff.localVersion : "—"}
</TableCell>
<TableCell className="py-1.5 text-right font-mono text-xs">
{diff.latestVersion > 0 ? diff.latestVersion : "—"}
</TableCell>
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs text-muted-foreground"
title={diff.serverPath}
>
{diff.serverPath}
</TableCell>
</TableRow>
</FileTargetMenu>
)
})}
</TableBody>
</Table>
)}
</div>
<p className="shrink-0 text-xs text-muted-foreground">
{visibleDiffs.length} / {diffs.length} 项 · 差异项操作通过右键菜单触发
</p>
</DialogContent>
</Dialog>
)
}