支持git冲突时弹出窗口合并代码解决冲突
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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