支持git冲突时弹出窗口合并代码解决冲突
This commit is contained in:
291
src/components/merge/merge-workspace.tsx
Normal file
291
src/components/merge/merge-workspace.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { emit } from "@tauri-apps/api/event"
|
||||
import { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
gitListConflicts,
|
||||
gitConflictFileVersions,
|
||||
gitResolveConflict,
|
||||
gitAbortOperation,
|
||||
gitContinueOperation,
|
||||
gitStartPullMerge,
|
||||
} from "@/lib/tauri"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import type { GitConflictFileVersions } from "@/lib/types"
|
||||
import { ThreePaneMergeEditor } from "./three-pane-merge-editor"
|
||||
|
||||
interface MergeWorkspaceProps {
|
||||
folderId: number
|
||||
folderPath: string
|
||||
operation: string
|
||||
onCompleted: () => void
|
||||
onAborted: () => void
|
||||
}
|
||||
|
||||
export function MergeWorkspace({
|
||||
folderId,
|
||||
folderPath,
|
||||
operation,
|
||||
onCompleted,
|
||||
onAborted,
|
||||
}: MergeWorkspaceProps) {
|
||||
const t = useTranslations("MergePage")
|
||||
const [files, setFiles] = useState<string[]>([])
|
||||
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [versions, setVersions] = useState<GitConflictFileVersions | null>(null)
|
||||
const [loadingVersions, setLoadingVersions] = useState(false)
|
||||
const [resolving, setResolving] = useState(false)
|
||||
const [aborting, setAborting] = useState(false)
|
||||
const [completing, setCompleting] = useState(false)
|
||||
const currentContentRef = useRef<string>("")
|
||||
const [hasUnresolvedConflicts, setHasUnresolvedConflicts] = useState(true)
|
||||
|
||||
// Load conflict files on mount
|
||||
useEffect(() => {
|
||||
loadConflicts()
|
||||
}, [folderPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function loadConflicts() {
|
||||
try {
|
||||
// For pull operations, the merge was aborted during detection to keep
|
||||
// working tree clean. Re-start the merge to create conflict state.
|
||||
if (operation === "pull") {
|
||||
await gitStartPullMerge(folderPath)
|
||||
}
|
||||
const conflictFiles = await gitListConflicts(folderPath)
|
||||
setFiles(conflictFiles)
|
||||
if (conflictFiles.length > 0 && !selectedFile) {
|
||||
selectFile(conflictFiles[0])
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(file: string) {
|
||||
setSelectedFile(file)
|
||||
setLoadingVersions(true)
|
||||
try {
|
||||
const v = await gitConflictFileVersions(folderPath, file)
|
||||
setVersions(v)
|
||||
currentContentRef.current = v.base
|
||||
setHasUnresolvedConflicts(true)
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
setVersions(null)
|
||||
} finally {
|
||||
setLoadingVersions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = useCallback((content: string) => {
|
||||
currentContentRef.current = content
|
||||
}, [])
|
||||
|
||||
const handleConflictStatusChange = useCallback((hasUnresolved: boolean) => {
|
||||
setHasUnresolvedConflicts(hasUnresolved)
|
||||
}, [])
|
||||
|
||||
async function handleResolve() {
|
||||
if (!selectedFile) return
|
||||
|
||||
const content = currentContentRef.current
|
||||
if (hasUnresolvedConflicts) {
|
||||
toast.warning(t("unresolvedConflicts"))
|
||||
return
|
||||
}
|
||||
|
||||
setResolving(true)
|
||||
try {
|
||||
await gitResolveConflict(folderPath, selectedFile, content)
|
||||
setResolvedFiles((prev) => new Set([...prev, selectedFile]))
|
||||
|
||||
// Notify parent window
|
||||
await emit("folder://merge-conflict-resolved", {
|
||||
folder_id: folderId,
|
||||
file: selectedFile,
|
||||
})
|
||||
|
||||
// Auto-select next unresolved file
|
||||
const nextUnresolved = files.find(
|
||||
(f) => f !== selectedFile && !resolvedFiles.has(f)
|
||||
)
|
||||
if (nextUnresolved) {
|
||||
selectFile(nextUnresolved)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setResolving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAbort() {
|
||||
setAborting(true)
|
||||
try {
|
||||
await gitAbortOperation(folderPath, operation)
|
||||
toast.success(t("abortSuccess"))
|
||||
await emit("folder://merge-completed", { folder_id: folderId })
|
||||
onAborted()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setAborting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
setCompleting(true)
|
||||
try {
|
||||
await gitContinueOperation(folderPath, operation)
|
||||
toast.success(t("allResolved"))
|
||||
await emit("folder://merge-completed", { folder_id: folderId })
|
||||
onCompleted()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setCompleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const allResolved =
|
||||
files.length > 0 && files.every((f) => resolvedFiles.has(f))
|
||||
|
||||
const language = selectedFile ? languageFromPath(selectedFile) : "plaintext"
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="flex-1 min-h-0 rounded-lg border"
|
||||
>
|
||||
{/* Left sidebar: conflict file list */}
|
||||
<ResizablePanel defaultSize={18} minSize={12} maxSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("conflictFiles")} ({files.length})
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-1">
|
||||
{files.map((file) => {
|
||||
const isResolved = resolvedFiles.has(file)
|
||||
const isSelected = file === selectedFile
|
||||
return (
|
||||
<button
|
||||
key={file}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => !isResolved && selectFile(file)}
|
||||
disabled={isResolved}
|
||||
>
|
||||
{isResolved ? (
|
||||
<Check className="h-3 w-3 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<FileWarning className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate ${isResolved ? "text-muted-foreground line-through" : ""}`}
|
||||
>
|
||||
{file}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{files.length === 0 && (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{t("noConflicts")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* Main area: three-pane merge editor */}
|
||||
<ResizablePanel defaultSize={82}>
|
||||
{loadingVersions ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("loadingFile")}
|
||||
</div>
|
||||
) : versions && selectedFile ? (
|
||||
<ThreePaneMergeEditor
|
||||
key={selectedFile}
|
||||
base={versions.base}
|
||||
ours={versions.ours}
|
||||
theirs={versions.theirs}
|
||||
merged={versions.merged}
|
||||
language={language}
|
||||
onContentChange={handleContentChange}
|
||||
onConflictStatusChange={handleConflictStatusChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("selectFile")}
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAbort}
|
||||
disabled={aborting || completing || resolving}
|
||||
>
|
||||
{aborting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
<X className="mr-1 h-3.5 w-3.5" />
|
||||
{t("abortMerge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleResolve}
|
||||
disabled={
|
||||
!selectedFile ||
|
||||
resolving ||
|
||||
aborting ||
|
||||
completing ||
|
||||
(selectedFile !== null && resolvedFiles.has(selectedFile))
|
||||
}
|
||||
>
|
||||
{resolving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
{t("markResolved")}
|
||||
</Button>
|
||||
{allResolved && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleComplete}
|
||||
disabled={completing || aborting}
|
||||
>
|
||||
{completing && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
<CheckCheck className="mr-1 h-3.5 w-3.5" />
|
||||
{t("completeMerge")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user