259 lines
7.4 KiB
TypeScript
259 lines
7.4 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useState } from "react"
|
|
import { subscribe } from "@/lib/platform"
|
|
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/api"
|
|
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 [done, setDone] = 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())
|
|
setDone(false)
|
|
}
|
|
}, [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: (() => void) | null = null
|
|
let unlistenCompleted: (() => void) | null = null
|
|
let unlistenAborted: (() => void) | null = null
|
|
|
|
subscribe<{ folder_id: number; file: string }>(
|
|
"folder://merge-conflict-resolved",
|
|
(payload) => {
|
|
if (payload.folder_id !== folderId) return
|
|
setResolvedFiles((prev) => new Set([...prev, payload.file]))
|
|
}
|
|
)
|
|
.then((fn) => {
|
|
unlistenResolved = fn
|
|
})
|
|
.catch(() => {})
|
|
|
|
subscribe<{ folder_id: number }>("folder://merge-completed", (payload) => {
|
|
if (payload.folder_id !== folderId) return
|
|
setDone(true)
|
|
onResolved()
|
|
onClose()
|
|
})
|
|
.then((fn) => {
|
|
unlistenCompleted = fn
|
|
})
|
|
.catch(() => {})
|
|
|
|
// Merge was aborted (user clicked abort in merge window, or window closed)
|
|
// Reset resolved state since abort reverts all changes
|
|
subscribe<{ folder_id: number }>("folder://merge-aborted", (payload) => {
|
|
if (payload.folder_id !== folderId) return
|
|
setDone(true)
|
|
setResolvedFiles(new Set())
|
|
onClose()
|
|
})
|
|
.then((fn) => {
|
|
unlistenAborted = fn
|
|
})
|
|
.catch(() => {})
|
|
|
|
return () => {
|
|
unlistenResolved?.()
|
|
unlistenCompleted?.()
|
|
unlistenAborted?.()
|
|
}
|
|
}, [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, conflictInfo?.upstream_commit)
|
|
} 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() {
|
|
if (done) return
|
|
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 || done}
|
|
>
|
|
{completing && (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
)}
|
|
{t("completeMerge")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|