frontend: add unified conflict dialog and wire get latest and checkout execution with skip feedback

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-10 18:30:05 +08:00
co-authored by Cursor
parent a750fff3b0
commit b8a24084c2
5 changed files with 464 additions and 20 deletions
@@ -0,0 +1,303 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { FileDiff, Loader2 } from "lucide-react"
import { DiffDialog, type DiffRequest } from "~/components/explorer/diff-dialog"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import { api } from "~/lib/api"
import type { ConflictInfo, ConflictResolution } from "~/lib/api"
// 每个冲突的取舍选择:未选择 / 采用服务器版本 / 保留本地版本。
type ChoiceValue = "none" | ConflictResolution
/**
* 统一冲突弹窗:Get Latest 与 Checkout 共用。
* 支持逐文件选择服务器版本或保留本地版本、批量设置、冲突文件 Diff、批量应用。
*/
export function ConflictDialog({
serverPath,
onClose,
onResolved,
}: {
serverPath: string
onClose: () => void
onResolved: () => void
}) {
const [conflicts, setConflicts] = useState<ConflictInfo[]>([])
const [choices, setChoices] = useState<Record<number, ChoiceValue>>({})
const [loading, setLoading] = useState(true)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
// 应用结果摘要:conflictId -> 是否成功。
const [applied, setApplied] = useState<Record<number, boolean>>({})
const [diffRequest, setDiffRequest] = useState<DiffRequest | null>(null)
// 是否发生过成功应用,关闭时据此通知上层刷新。
const [didResolve, setDidResolve] = useState(false)
/**
* 加载当前范围内的未解决冲突。
*/
const loadConflicts = useCallback(async () => {
setLoading(true)
const result = await api.listConflicts({ serverPath, recursive: true })
setLoading(false)
if (!result.ok) {
setError(result.errorMessage ?? "冲突明细加载失败")
return
}
setError(null)
const list = (result.data?.conflicts ?? []).filter((item) => !item.resolved)
setConflicts(list)
setChoices({})
setApplied({})
}, [serverPath])
useEffect(() => {
void loadConflicts()
}, [loadConflicts])
const chosenCount = useMemo(
() => Object.values(choices).filter((value) => value !== "none").length,
[choices],
)
/**
* 批量把所有冲突设置为同一取舍。
*/
const setAll = useCallback(
(value: ConflictResolution) => {
setChoices(() => {
const next: Record<number, ChoiceValue> = {}
for (const conflict of conflicts) {
next[conflict.conflictId] = value
}
return next
})
},
[conflicts],
)
/**
* 应用所有已选择的取舍,逐个调用后端并记录结果。
*/
const applyChoices = useCallback(async () => {
setApplying(true)
setError(null)
const results: Record<number, boolean> = {}
let resolvedAny = false
for (const conflict of conflicts) {
const choice = choices[conflict.conflictId]
if (!choice || choice === "none") {
continue
}
const result = await api.applyConflict({
conflictId: conflict.conflictId,
resolution: choice,
})
const success = result.ok && result.data?.resolution?.resolved === true
results[conflict.conflictId] = success
if (success) {
resolvedAny = true
}
}
setApplying(false)
setApplied(results)
if (resolvedAny) {
setDidResolve(true)
}
const failures = Object.values(results).filter((value) => !value).length
if (failures > 0) {
setError(`${failures} 个冲突应用失败,可重试`)
await loadConflicts()
return
}
// 全部成功:重新加载剩余冲突,没有剩余则关闭并通知刷新。
const remain = await api.listConflicts({ serverPath, recursive: true })
const remaining = (remain.data?.conflicts ?? []).filter((item) => !item.resolved)
if (remaining.length === 0) {
onResolved()
return
}
setConflicts(remaining)
setChoices({})
setApplied({})
}, [conflicts, choices, serverPath, loadConflicts, onResolved])
/**
* 关闭弹窗:若期间有成功应用,通知上层刷新状态。
*/
const handleClose = useCallback(() => {
if (didResolve) {
onResolved()
} else {
onClose()
}
}, [didResolve, onResolved, onClose])
return (
<>
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="flex max-h-[80svh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle>处理冲突</DialogTitle>
<DialogDescription className="font-mono text-xs">{serverPath}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
{conflicts.length} 个冲突
</Badge>
<span className="text-xs text-muted-foreground">
逐项选择,或批量设置后统一应用
</span>
<div className="ml-auto flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={applying || conflicts.length === 0}
onClick={() => setAll("takeServer")}
>
全部采用服务器
</Button>
<Button
variant="outline"
size="sm"
disabled={applying || conflicts.length === 0}
onClick={() => setAll("keepLocal")}
>
全部保留本地
</Button>
</div>
</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>
) : conflicts.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
当前范围内没有未解决的冲突
</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-24 text-xs text-muted-foreground">类型</TableHead>
<TableHead className="h-8 w-44 text-xs text-muted-foreground">取舍</TableHead>
<TableHead className="h-8 w-20 text-xs text-muted-foreground">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conflicts.map((conflict) => {
const choice = choices[conflict.conflictId] ?? "none"
const appliedState = applied[conflict.conflictId]
const canDiff =
conflict.serverPath != null && conflict.localPath != null
return (
<TableRow key={conflict.conflictId} className="select-none">
<TableCell
className="max-w-0 truncate py-1.5 font-mono text-xs"
title={conflict.serverPath ?? conflict.localPath ?? ""}
>
{conflict.serverPath ?? conflict.localPath ?? `冲突 ${conflict.conflictId}`}
{appliedState === false && (
<span className="ml-2 text-destructive">应用失败</span>
)}
</TableCell>
<TableCell className="py-1.5 text-xs text-muted-foreground">
{conflict.type}
</TableCell>
<TableCell className="py-1.5">
<Select
value={choice}
onValueChange={(value) =>
setChoices((prev) => ({
...prev,
[conflict.conflictId]: value as ChoiceValue,
}))
}
disabled={applying}
>
<SelectTrigger size="sm" className="w-40 text-xs">
<SelectValue placeholder="选择取舍" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">暂不处理</SelectItem>
<SelectItem value="takeServer">采用服务器版本</SelectItem>
<SelectItem value="keepLocal">保留本地版本</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon-sm"
disabled={!canDiff}
title={canDiff ? "查看差异" : "缺少路径信息,无法对比"}
onClick={() =>
setDiffRequest({
mode: "localLatest",
serverPath: conflict.serverPath as string,
localPath: conflict.localPath as string,
})
}
>
<FileDiff />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{error && <p className="shrink-0 text-sm text-destructive">{error}</p>}
<DialogFooter className="shrink-0">
<Button variant="ghost" onClick={handleClose} disabled={applying}>
稍后处理
</Button>
<Button
onClick={() => void applyChoices()}
disabled={applying || chosenCount === 0}
>
{applying ? "正在应用…" : `应用选择(${chosenCount})`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{diffRequest && (
<DiffDialog request={diffRequest} onClose={() => setDiffRequest(null)} />
)}
</>
)
}