diff options
Diffstat (limited to 'makima/frontend/src/components/mesh/MergeConflictResolver.tsx')
| -rw-r--r-- | makima/frontend/src/components/mesh/MergeConflictResolver.tsx | 504 |
1 files changed, 504 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/MergeConflictResolver.tsx b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx new file mode 100644 index 0000000..4479705 --- /dev/null +++ b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx @@ -0,0 +1,504 @@ +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 }; |
