支持git冲突时弹出窗口合并代码解决冲突
This commit is contained in:
243
src/components/layout/conflict-dialog.tsx
Normal file
243
src/components/layout/conflict-dialog.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { AlertTriangle, Check, FileWarning, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
gitListConflicts,
|
||||
gitAbortOperation,
|
||||
gitContinueOperation,
|
||||
openMergeWindow,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type { GitConflictInfo } from "@/lib/types"
|
||||
|
||||
interface ConflictDialogProps {
|
||||
conflictInfo: GitConflictInfo | null
|
||||
folderId: number
|
||||
folderPath: string
|
||||
onClose: () => void
|
||||
onResolved: () => void
|
||||
}
|
||||
|
||||
export function ConflictDialog({
|
||||
conflictInfo,
|
||||
folderId,
|
||||
folderPath,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: ConflictDialogProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.conflict")
|
||||
const [conflictedFiles, setConflictedFiles] = useState<string[]>([])
|
||||
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
|
||||
const [aborting, setAborting] = useState(false)
|
||||
const [completing, setCompleting] = useState(false)
|
||||
|
||||
const open = conflictInfo !== null
|
||||
const operation = conflictInfo?.operation ?? "merge"
|
||||
|
||||
// Initialize conflict files from conflictInfo
|
||||
useEffect(() => {
|
||||
if (conflictInfo) {
|
||||
setConflictedFiles(conflictInfo.conflicted_files)
|
||||
setResolvedFiles(new Set())
|
||||
}
|
||||
}, [conflictInfo])
|
||||
|
||||
// Refresh conflict list to detect resolved files
|
||||
const refreshConflicts = useCallback(async () => {
|
||||
if (!folderPath || !open) return
|
||||
try {
|
||||
const remaining = await gitListConflicts(folderPath)
|
||||
const nowResolved = new Set(
|
||||
conflictedFiles.filter((f) => !remaining.includes(f))
|
||||
)
|
||||
setResolvedFiles(nowResolved)
|
||||
} catch {
|
||||
// ignore refresh errors
|
||||
}
|
||||
}, [folderPath, open, conflictedFiles])
|
||||
|
||||
// Listen for merge events from the merge window
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let unlistenResolved: UnlistenFn | null = null
|
||||
let unlistenCompleted: UnlistenFn | null = null
|
||||
|
||||
listen<{ folder_id: number; file: string }>(
|
||||
"folder://merge-conflict-resolved",
|
||||
(event) => {
|
||||
if (event.payload.folder_id !== folderId) return
|
||||
setResolvedFiles((prev) => new Set([...prev, event.payload.file]))
|
||||
}
|
||||
)
|
||||
.then((fn) => {
|
||||
unlistenResolved = fn
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
listen<{ folder_id: number }>("folder://merge-completed", (event) => {
|
||||
if (event.payload.folder_id !== folderId) return
|
||||
onResolved()
|
||||
onClose()
|
||||
})
|
||||
.then((fn) => {
|
||||
unlistenCompleted = fn
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
disposeTauriListener(
|
||||
unlistenResolved,
|
||||
"ConflictDialog.mergeConflictResolved"
|
||||
)
|
||||
disposeTauriListener(unlistenCompleted, "ConflictDialog.mergeCompleted")
|
||||
}
|
||||
}, [open, folderId, onResolved, onClose])
|
||||
|
||||
// Periodically refresh conflict status (skip for pull — merge is aborted
|
||||
// until the merge tool re-starts it, so git index has no conflicts yet)
|
||||
useEffect(() => {
|
||||
if (!open || operation === "pull") return
|
||||
const interval = setInterval(refreshConflicts, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [open, operation, refreshConflicts])
|
||||
|
||||
const allResolved =
|
||||
conflictedFiles.length > 0 &&
|
||||
conflictedFiles.every((f) => resolvedFiles.has(f))
|
||||
|
||||
async function handleOpenMergeTool() {
|
||||
try {
|
||||
await openMergeWindow(folderId, operation)
|
||||
} catch (err) {
|
||||
toast.error(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAbort() {
|
||||
// For pull operations, the merge was already aborted during conflict
|
||||
// detection, so there's nothing to abort — just close the dialog.
|
||||
if (operation === "pull") {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setAborting(true)
|
||||
try {
|
||||
await gitAbortOperation(folderPath, operation)
|
||||
toast.success(t("abortSuccess"))
|
||||
onClose()
|
||||
onResolved()
|
||||
} catch (err) {
|
||||
toast.error(String(err))
|
||||
} finally {
|
||||
setAborting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
setCompleting(true)
|
||||
try {
|
||||
await gitContinueOperation(folderPath, operation)
|
||||
toast.success(t("completeSuccess"))
|
||||
onResolved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(String(err))
|
||||
} finally {
|
||||
setCompleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-60">
|
||||
<div className="space-y-1 pr-3">
|
||||
{conflictedFiles.map((file) => {
|
||||
const isResolved = resolvedFiles.has(file)
|
||||
return (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
||||
>
|
||||
{isResolved ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<FileWarning className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
isResolved
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground"
|
||||
}
|
||||
>
|
||||
{file}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleAbort}
|
||||
disabled={aborting || completing}
|
||||
>
|
||||
{aborting && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("abort")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenMergeTool}
|
||||
disabled={aborting || completing}
|
||||
>
|
||||
{t("openMergeTool")}
|
||||
</Button>
|
||||
{allResolved && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleComplete}
|
||||
disabled={completing || aborting}
|
||||
>
|
||||
{completing && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("completeMerge")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user