import { useState, useMemo, useCallback } from "react";
interface ConflictHunk {
id: string;
filePath: string;
startLine: number;
endLine: number;
ours: string[]; // Changes from current branch
theirs: string[]; // Changes from incoming branch
base?: string[]; // Original content (if 3-way merge)
resolved?: "ours" | "theirs" | "both" | "custom";
customResolution?: string[];
}
interface ConflictFile {
path: string;
hunks: ConflictHunk[];
resolved: boolean;
}
interface MergeConflictResolverProps {
conflicts: ConflictFile[];
sourceBranch: string;
targetBranch: string;
loading?: boolean;
onResolve: (resolutions: Map<string, ConflictHunk[]>) => Promise<void>;
onAbort: () => void;
onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
}
type ResolutionChoice = "ours" | "theirs" | "both" | "custom";
function ConflictHunkView({
hunk,
sourceBranch,
targetBranch,
onResolve,
onAskLLM,
}: {
hunk: ConflictHunk;
sourceBranch: string;
targetBranch: string;
onResolve: (resolution: ResolutionChoice, customLines?: string[]) => void;
onAskLLM?: () => Promise<void>;
}) {
const [showCustomEditor, setShowCustomEditor] = useState(false);
const [customText, setCustomText] = useState(
hunk.customResolution?.join("\n") || [...hunk.ours, ...hunk.theirs].join("\n")
);
const [askingLLM, setAskingLLM] = useState(false);
const handleAskLLM = async () => {
if (!onAskLLM || askingLLM) return;
setAskingLLM(true);
try {
await onAskLLM();
} finally {
setAskingLLM(false);
}
};
const handleCustomSave = () => {
const lines = customText.split("\n");
onResolve("custom", lines);
setShowCustomEditor(false);
};
const isResolved = hunk.resolved !== undefined;
return (
<div
className={`border ${
isResolved
? "border-green-400/30 bg-green-400/5"
: "border-yellow-400/30 bg-yellow-400/5"
} mb-3`}
>
{/* Hunk header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(117,170,252,0.2)]">
<div className="font-mono text-xs text-[#75aafc]">
Lines {hunk.startLine}-{hunk.endLine}
{isResolved && (
<span className="ml-2 text-green-400">
(Resolved: {hunk.resolved})
</span>
)}
</div>
<div className="flex items-center gap-2">
{onAskLLM && (
<button
onClick={handleAskLLM}
disabled={askingLLM}
className="px-2 py-1 font-mono text-[10px] text-purple-400 border border-purple-400/30 hover:border-purple-400/50 disabled:opacity-50 transition-colors"
>
{askingLLM ? "..." : "Ask LLM"}
</button>
)}
<button
onClick={() => setShowCustomEditor(!showCustomEditor)}
className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
>
Edit
</button>
</div>
</div>
{/* Conflict content */}
{!showCustomEditor ? (
<div className="grid grid-cols-2 divide-x divide-[rgba(117,170,252,0.2)]">
{/* Ours (current branch) */}
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
{targetBranch} (ours)
</span>
<button
onClick={() => onResolve("ours")}
className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
hunk.resolved === "ours"
? "text-green-400 border-green-400/50 bg-green-400/10"
: "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
}`}
>
Use This
</button>
</div>
<pre className="font-mono text-xs text-red-400 bg-red-400/5 p-2 overflow-x-auto">
{hunk.ours.map((line, i) => (
<div key={i}>
<span className="text-[#555] select-none mr-2">-</span>
{line}
</div>
))}
</pre>
</div>
{/* Theirs (incoming branch) */}
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
{sourceBranch} (theirs)
</span>
<button
onClick={() => onResolve("theirs")}
className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
hunk.resolved === "theirs"
? "text-green-400 border-green-400/50 bg-green-400/10"
: "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
}`}
>
Use This
</button>
</div>
<pre className="font-mono text-xs text-green-400 bg-green-400/5 p-2 overflow-x-auto">
{hunk.theirs.map((line, i) => (
<div key={i}>
<span className="text-[#555] select-none mr-2">+</span>
{line}
</div>
))}
</pre>
</div>
</div>
) : (
/* Custom editor */
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
Custom Resolution
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCustomEditor(false)}
className="px-2 py-0.5 font-mono text-[9px] text-[#555] hover:text-[#9bc3ff]"
>
Cancel
</button>
<button
onClick={handleCustomSave}
className="px-2 py-0.5 font-mono text-[9px] text-green-400 border border-green-400/30 hover:border-green-400/50"
>
Apply
</button>
</div>
</div>
<textarea
value={customText}
onChange={(e) => setCustomText(e.target.value)}
className="w-full bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs p-2 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y"
/>
</div>
)}
{/* Both option */}
<div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)] flex justify-center">
<button
onClick={() => onResolve("both")}
className={`px-3 py-1 font-mono text-[10px] border transition-colors ${
hunk.resolved === "both"
? "text-green-400 border-green-400/50 bg-green-400/10"
: "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
}`}
>
Keep Both
</button>
</div>
</div>
);
}
function ConflictFileView({
file,
sourceBranch,
targetBranch,
onResolveHunk,
onAskLLM,
}: {
file: ConflictFile;
sourceBranch: string;
targetBranch: string;
onResolveHunk: (hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => void;
onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
}) {
const [expanded, setExpanded] = useState(true);
const resolvedCount = file.hunks.filter((h) => h.resolved !== undefined).length;
return (
<div className="border border-[rgba(117,170,252,0.2)] mb-3">
{/* File header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] text-left"
>
<span className="font-mono text-[10px] text-[#555]">
{expanded ? "▼" : "▶"}
</span>
<span
className={`px-1.5 py-0.5 font-mono text-[9px] ${
file.resolved
? "text-green-400 bg-green-400/10"
: "text-yellow-400 bg-yellow-400/10"
}`}
>
{file.resolved ? "RESOLVED" : "CONFLICT"}
</span>
<span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
{file.path}
</span>
<span className="font-mono text-[10px] text-[#555]">
{resolvedCount}/{file.hunks.length} hunks
</span>
</button>
{/* Hunks */}
{expanded && (
<div className="p-3">
{file.hunks.map((hunk) => (
<ConflictHunkView
key={hunk.id}
hunk={hunk}
sourceBranch={sourceBranch}
targetBranch={targetBranch}
onResolve={(resolution, customLines) =>
onResolveHunk(hunk.id, resolution, customLines)
}
onAskLLM={
onAskLLM
? async () => {
const resolution = await onAskLLM(hunk);
onResolveHunk(hunk.id, "custom", resolution);
}
: undefined
}
/>
))}
</div>
)}
</div>
);
}
export function MergeConflictResolver({
conflicts: initialConflicts,
sourceBranch,
targetBranch,
loading = false,
onResolve,
onAbort,
onAskLLM,
}: MergeConflictResolverProps) {
const [conflicts, setConflicts] = useState<ConflictFile[]>(initialConflicts);
const [resolving, setResolving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Calculate resolution stats
const stats = useMemo(() => {
const totalHunks = conflicts.reduce((sum, f) => sum + f.hunks.length, 0);
const resolvedHunks = conflicts.reduce(
(sum, f) => sum + f.hunks.filter((h) => h.resolved !== undefined).length,
0
);
const resolvedFiles = conflicts.filter((f) => f.resolved).length;
return {
totalFiles: conflicts.length,
resolvedFiles,
totalHunks,
resolvedHunks,
allResolved: resolvedHunks === totalHunks,
};
}, [conflicts]);
// Handle resolving a single hunk
const handleResolveHunk = useCallback(
(filePath: string, hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => {
setConflicts((prev) =>
prev.map((file) => {
if (file.path !== filePath) return file;
const updatedHunks = file.hunks.map((hunk) => {
if (hunk.id !== hunkId) return hunk;
return {
...hunk,
resolved: resolution,
customResolution: customLines,
};
});
const allHunksResolved = updatedHunks.every((h) => h.resolved !== undefined);
return {
...file,
hunks: updatedHunks,
resolved: allHunksResolved,
};
})
);
},
[]
);
// Resolve all hunks in a file with same choice
const handleResolveFileAll = useCallback(
(filePath: string, resolution: ResolutionChoice) => {
setConflicts((prev) =>
prev.map((file) => {
if (file.path !== filePath) return file;
const updatedHunks = file.hunks.map((hunk) => ({
...hunk,
resolved: resolution,
customResolution:
resolution === "both"
? [...hunk.ours, ...hunk.theirs]
: resolution === "ours"
? hunk.ours
: resolution === "theirs"
? hunk.theirs
: undefined,
}));
return {
...file,
hunks: updatedHunks,
resolved: true,
};
})
);
},
[]
);
// Apply all resolutions
const handleApplyResolutions = async () => {
if (!stats.allResolved || resolving) return;
setResolving(true);
setError(null);
try {
const resolutionMap = new Map<string, ConflictHunk[]>();
conflicts.forEach((file) => {
resolutionMap.set(file.path, file.hunks);
});
await onResolve(resolutionMap);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to apply resolutions");
} finally {
setResolving(false);
}
};
if (loading) {
return (
<div className="panel p-4">
<div className="flex items-center justify-center h-32">
<div className="font-mono text-sm text-[#75aafc]">
Analyzing conflicts...
</div>
</div>
</div>
);
}
return (
<div className="panel flex flex-col max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
<div className="flex items-center gap-3">
<div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
Merge Conflicts
</div>
<span className="px-2 py-0.5 font-mono text-[10px] text-yellow-400 bg-yellow-400/10 border border-yellow-400/20">
{sourceBranch} → {targetBranch}
</span>
</div>
<button
onClick={onAbort}
className="font-mono text-xs text-red-400 hover:text-red-300"
>
Abort Merge
</button>
</div>
{/* Progress */}
<div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)] shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-[10px] text-[#75aafc]">
{stats.resolvedFiles}/{stats.totalFiles} files resolved
</span>
<span className="font-mono text-[10px] text-[#75aafc]">
{stats.resolvedHunks}/{stats.totalHunks} conflicts resolved
</span>
</div>
<div className="h-1.5 bg-[rgba(117,170,252,0.1)] rounded-full overflow-hidden">
<div
className="h-full bg-green-400 transition-all"
style={{
width: `${(stats.resolvedHunks / stats.totalHunks) * 100}%`,
}}
/>
</div>
</div>
{/* Error */}
{error && (
<div className="mx-4 mt-3 bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400 shrink-0">
{error}
</div>
)}
{/* Conflict files */}
<div className="flex-1 overflow-y-auto p-4">
{conflicts.map((file) => (
<ConflictFileView
key={file.path}
file={file}
sourceBranch={sourceBranch}
targetBranch={targetBranch}
onResolveHunk={(hunkId, resolution, customLines) =>
handleResolveHunk(file.path, hunkId, resolution, customLines)
}
onAskLLM={onAskLLM}
/>
))}
</div>
{/* Footer actions */}
<div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() =>
conflicts.forEach((f) => handleResolveFileAll(f.path, "ours"))
}
className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
>
Accept All Ours
</button>
<button
onClick={() =>
conflicts.forEach((f) => handleResolveFileAll(f.path, "theirs"))
}
className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
>
Accept All Theirs
</button>
</div>
<button
onClick={handleApplyResolutions}
disabled={!stats.allResolved || resolving}
className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
>
{resolving
? "Applying..."
: stats.allResolved
? "Complete Merge"
: `Resolve ${stats.totalHunks - stats.resolvedHunks} Conflicts`}
</button>
</div>
</div>
);
}
// Export types for use in other components
export type { ConflictHunk, ConflictFile, ResolutionChoice };