summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/MergeConflictResolver.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/mesh/MergeConflictResolver.tsx')
-rw-r--r--makima/frontend/src/components/mesh/MergeConflictResolver.tsx504
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 };