支持git冲突时弹出窗口合并代码解决冲突

This commit is contained in:
xintaofei
2026-03-14 20:55:15 +08:00
parent f503c25161
commit 4129f02985
25 changed files with 3123 additions and 51 deletions

View File

@@ -80,10 +80,13 @@ import {
openFolderWindow,
openCommitWindow,
setFolderParentBranch,
gitListConflicts,
} from "@/lib/tauri"
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
import { ConflictDialog } from "@/components/layout/conflict-dialog"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type { GitBranchList } from "@/lib/types"
import { toErrorMessage } from "@/lib/app-error"
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context"
import { useTaskContext } from "@/contexts/task-context"
@@ -134,6 +137,7 @@ export function BranchDropdown({
const [worktreeBranchName, setWorktreeBranchName] = useState("")
const [worktreePath, setWorktreePath] = useState("")
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
const taskSeq = useRef(0)
const worktreeBranchSet = useMemo(
() => new Set(branchList.worktree_branches),
@@ -184,7 +188,7 @@ export function BranchDropdown({
async function runGitTask<T>(
label: string,
action: () => Promise<T>,
getSuccessDescription?: (result: T) => string | undefined
getSuccessDescription?: (result: T) => string | false | undefined
) {
const taskId = `git-${++taskSeq.current}-${Date.now()}`
setLoading(true)
@@ -195,19 +199,22 @@ export function BranchDropdown({
const successDescription = getSuccessDescription?.(result)
updateTask(taskId, { status: "completed" })
onBranchChange()
toast.success(
t("toasts.taskCompleted", { label }),
successDescription
? {
description: successDescription,
}
: undefined
)
if (successDescription !== false) {
toast.success(
t("toasts.taskCompleted", { label }),
successDescription
? {
description: successDescription,
}
: undefined
)
}
} catch (err) {
removeTask(taskId)
const errorTitle = t("toasts.taskFailed", { label })
pushAlert("error", errorTitle, String(err))
toast.error(errorTitle, { description: String(err) })
const errorMsg = toErrorMessage(err)
pushAlert("error", errorTitle, errorMsg)
toast.error(errorTitle, { description: errorMsg })
} finally {
setLoading(false)
}
@@ -285,6 +292,117 @@ export function BranchDropdown({
})
}
async function showMergeConflictDialog() {
try {
const remaining = await gitListConflicts(folderPath)
setConflictInfo({
has_conflicts: true,
conflicted_files: remaining,
operation: "merge",
})
} catch {
setConflictInfo({
has_conflicts: true,
conflicted_files: [],
operation: "merge",
})
}
}
async function handlePush() {
const taskId = `git-${++taskSeq.current}-${Date.now()}`
const label = t("tasks.pushCode")
setLoading(true)
addTask(taskId, label)
updateTask(taskId, { status: "running" })
try {
const result = await gitPush(folderPath)
updateTask(taskId, { status: "completed" })
onBranchChange()
let description: string | undefined
if (result.upstream_set) {
description =
result.pushed_commits === 0
? t("toasts.upstreamSet")
: t("toasts.upstreamSetAndPushed", {
count: result.pushed_commits,
})
} else if (result.pushed_commits === 0) {
description = t("toasts.noCommitsToPush")
} else {
description = t("toasts.pushedCommits", {
count: result.pushed_commits,
})
}
toast.success(t("toasts.taskCompleted", { label }), {
description,
})
} catch (err) {
const errorMsg = toErrorMessage(err)
if (/MERGE_HEAD|unfinished merge/i.test(errorMsg)) {
// Unfinished merge — show conflict dialog
removeTask(taskId)
await showMergeConflictDialog()
} else if (/rejected|fetch first/i.test(errorMsg)) {
// Remote has new commits — auto-pull then retry push
updateTask(taskId, {
status: "running",
label: t("tasks.pullCode"),
})
try {
const pullResult = await gitPull(folderPath)
if (pullResult.conflict?.has_conflicts) {
removeTask(taskId)
onBranchChange()
setConflictInfo(pullResult.conflict)
} else {
// Pull succeeded, retry push
updateTask(taskId, { status: "running", label })
const pushResult = await gitPush(folderPath)
updateTask(taskId, { status: "completed" })
onBranchChange()
let description: string | undefined
if (pushResult.upstream_set) {
description =
pushResult.pushed_commits === 0
? t("toasts.upstreamSet")
: t("toasts.upstreamSetAndPushed", {
count: pushResult.pushed_commits,
})
} else if (pushResult.pushed_commits === 0) {
description = t("toasts.noCommitsToPush")
} else {
description = t("toasts.pushedCommits", {
count: pushResult.pushed_commits,
})
}
toast.success(t("toasts.taskCompleted", { label }), {
description,
})
}
} catch (pullErr) {
const pullErrMsg = toErrorMessage(pullErr)
if (/MERGE_HEAD|unfinished merge/i.test(pullErrMsg)) {
removeTask(taskId)
await showMergeConflictDialog()
} else {
removeTask(taskId)
const pullErrTitle = t("toasts.taskFailed", { label })
pushAlert("error", pullErrTitle, pullErrMsg)
toast.error(pullErrTitle, { description: pullErrMsg })
}
}
} else {
removeTask(taskId)
const errorTitle = t("toasts.taskFailed", { label })
pushAlert("error", errorTitle, errorMsg)
toast.error(errorTitle, { description: errorMsg })
}
} finally {
setLoading(false)
}
}
function handleMergeParent() {
if (!parentBranch) return
setConfirmAction({ type: "merge", branchName: parentBranch })
@@ -316,6 +434,10 @@ export function BranchDropdown({
t("tasks.mergeBranch", { branchName }),
() => gitMerge(folderPath, branchName),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
if (result.merged_commits === 0) {
return t("toasts.mergeNoNewCommits", { branchName })
}
@@ -324,8 +446,16 @@ export function BranchDropdown({
)
break
case "rebase":
await runGitTask(t("tasks.rebaseTo", { branchName }), () =>
gitRebase(folderPath, branchName)
await runGitTask(
t("tasks.rebaseTo", { branchName }),
() => gitRebase(folderPath, branchName),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
return undefined
}
)
break
case "delete":
@@ -520,6 +650,10 @@ export function BranchDropdown({
t("tasks.pullCode"),
() => gitPull(folderPath),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
if (result.updated_files === 0) {
return t("toasts.allFilesUpToDate")
}
@@ -552,39 +686,16 @@ export function BranchDropdown({
setDropdownOpen(false)
openCommitWindow(folder.id).catch((err) => {
const title = t("toasts.openCommitWindowFailed")
pushAlert("error", title, String(err))
toast.error(title, { description: String(err) })
const msg = toErrorMessage(err)
pushAlert("error", title, msg)
toast.error(title, { description: msg })
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
{t("openCommitWindow")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(
t("tasks.pushCode"),
() => gitPush(folderPath),
(result) => {
if (result.upstream_set) {
if (result.pushed_commits === 0) {
return t("toasts.upstreamSet")
}
return t("toasts.upstreamSetAndPushed", {
count: result.pushed_commits,
})
}
if (result.pushed_commits === 0) {
return t("toasts.noCommitsToPush")
}
return t("toasts.pushedCommits", {
count: result.pushed_commits,
})
}
)
}
>
<DropdownMenuItem disabled={loading} onSelect={handlePush}>
<Upload className="h-3.5 w-3.5" />
{t("pushCode")}
</DropdownMenuItem>
@@ -846,6 +957,14 @@ export function BranchDropdown({
folderPath={folderPath}
onSaved={() => loadAllBranches()}
/>
<ConflictDialog
conflictInfo={conflictInfo}
folderId={folder?.id ?? 0}
folderPath={folderPath}
onClose={() => setConflictInfo(null)}
onResolved={onBranchChange}
/>
</>
)
}

View 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>
)
}