387 lines
17 KiB
TypeScript
387 lines
17 KiB
TypeScript
/**
|
|
* MergeReviewDrawer — Full merge request review interface.
|
|
* Shows: diff summary, conflict cards per conflict class, resolution controls.
|
|
* Supports approve / reject / request-changes decisions.
|
|
*/
|
|
import { useState, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, GitMerge, Plus, Edit2, ArrowUpDown, Trash2, AlertTriangle, CheckCircle2, ChevronDown } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import type { MergeRequest, ConflictRecord } from '../../types/canvas';
|
|
|
|
interface MergeReviewDrawerProps {
|
|
mergeRequest: MergeRequest | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onReview: (decision: 'approve' | 'reject' | 'changes_requested', comment?: string) => Promise<void>;
|
|
}
|
|
|
|
const CONFLICT_CLASS_CONFIG: Record<string, {
|
|
icon: typeof AlertTriangle;
|
|
label: string;
|
|
color: string;
|
|
bg: string;
|
|
description: string;
|
|
severity: 'none' | 'low' | 'medium' | 'high';
|
|
}> = {
|
|
safe_append: {
|
|
icon: Plus, label: 'Safe Append', color: '#34d399', bg: 'rgba(52,211,153,0.1)',
|
|
description: 'New component added in source, not present in target. Will be appended.',
|
|
severity: 'none',
|
|
},
|
|
safe_reorder: {
|
|
icon: ArrowUpDown, label: 'Safe Reorder', color: '#60a5fa', bg: 'rgba(96,165,250,0.1)',
|
|
description: 'Component order differs between branches. Will be merged using longest-common-subsequence.',
|
|
severity: 'none',
|
|
},
|
|
component_content_conflict: {
|
|
icon: Edit2, label: 'Content Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)',
|
|
description: 'Both branches edited the same component content. Manual resolution required.',
|
|
severity: 'high',
|
|
},
|
|
query_descriptor_conflict: {
|
|
icon: AlertTriangle, label: 'Query Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
|
|
description: 'Data source or filter parameters conflict. Requires reviewer decision.',
|
|
severity: 'high',
|
|
},
|
|
layout_slot_conflict: {
|
|
icon: ArrowUpDown, label: 'Layout Conflict', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)',
|
|
description: 'Same layout slot claimed by different components.',
|
|
severity: 'medium',
|
|
},
|
|
access_policy_conflict: {
|
|
icon: AlertTriangle, label: 'Policy Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
|
|
description: 'Access control policies diverge. Stricter policy will prevail.',
|
|
severity: 'high',
|
|
},
|
|
delete_edit_conflict: {
|
|
icon: Trash2, label: 'Delete-Edit Conflict', color: '#f87171', bg: 'rgba(248,113,113,0.1)',
|
|
description: 'Component deleted in one branch, edited in another.',
|
|
severity: 'high',
|
|
},
|
|
};
|
|
|
|
function ConflictCard({ conflict, resolution, onResolve }: {
|
|
conflict: ConflictRecord;
|
|
resolution?: string;
|
|
onResolve: (id: string, decision: string) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const cfg = CONFLICT_CLASS_CONFIG[conflict.conflictClass] ?? CONFLICT_CLASS_CONFIG.component_content_conflict;
|
|
const Icon = cfg.icon;
|
|
const isResolved = !!resolution;
|
|
|
|
return (
|
|
<div
|
|
className="rounded-xl overflow-hidden"
|
|
style={{ background: cfg.bg, border: `1px solid ${cfg.color}30` }}
|
|
>
|
|
<button
|
|
className="w-full flex items-center gap-3 p-3.5 text-left"
|
|
onClick={() => setExpanded((p) => !p)}
|
|
>
|
|
<Icon className="w-4 h-4 flex-shrink-0" style={{ color: cfg.color }} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold" style={{ color: cfg.color }}>{cfg.label}</span>
|
|
{isResolved && (
|
|
<CheckCircle2 className="w-3 h-3 text-green-400" />
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-zinc-500 truncate font-mono">
|
|
{conflict.componentId}{conflict.field ? ` → ${conflict.field}` : ''}
|
|
</p>
|
|
</div>
|
|
<ChevronDown
|
|
className="w-3.5 h-3.5 text-zinc-600 flex-shrink-0 transition-transform"
|
|
style={{ transform: expanded ? 'rotate(180deg)' : 'none' }}
|
|
/>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{expanded && (
|
|
<motion.div
|
|
initial={{ height: 0 }}
|
|
animate={{ height: 'auto' }}
|
|
exit={{ height: 0 }}
|
|
className="overflow-hidden border-t"
|
|
style={{ borderColor: `${cfg.color}25` }}
|
|
>
|
|
<div className="p-3.5 space-y-3">
|
|
<p className="text-xs text-zinc-300">{cfg.description}</p>
|
|
{conflict.description && (
|
|
<p className="text-xs text-zinc-400 italic">"{conflict.description}"</p>
|
|
)}
|
|
{/* Show values if present */}
|
|
{(conflict.sourceValue !== undefined || conflict.targetValue !== undefined) && (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{conflict.sourceValue !== undefined && (
|
|
<div className="p-2 rounded-lg" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
|
<p className="text-[10px] text-zinc-500 mb-1">Source</p>
|
|
<p className="text-xs text-zinc-300 font-mono break-all">
|
|
{JSON.stringify(conflict.sourceValue).slice(0, 80)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{conflict.targetValue !== undefined && (
|
|
<div className="p-2 rounded-lg" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
|
<p className="text-[10px] text-zinc-500 mb-1">Target</p>
|
|
<p className="text-xs text-zinc-300 font-mono break-all">
|
|
{JSON.stringify(conflict.targetValue).slice(0, 80)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Resolution buttons */}
|
|
{cfg.severity !== 'none' && !isResolved && (
|
|
<div className="flex gap-2">
|
|
<button
|
|
className="text-[10px] px-2.5 py-1.5 rounded-lg transition-all flex-1"
|
|
style={{ background: 'rgba(59,130,246,0.15)', color: '#60a5fa', border: '1px solid rgba(59,130,246,0.3)' }}
|
|
onClick={() => onResolve(conflict.conflictId, 'source_wins')}
|
|
>
|
|
Use Source
|
|
</button>
|
|
<button
|
|
className="text-[10px] px-2.5 py-1.5 rounded-lg transition-all flex-1"
|
|
style={{ background: 'rgba(255,255,255,0.06)', color: '#a1a1aa', border: '1px solid rgba(255,255,255,0.1)' }}
|
|
onClick={() => onResolve(conflict.conflictId, 'target_wins')}
|
|
>
|
|
Keep Target
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isResolved && (
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
|
|
<span className="text-xs text-green-300">Resolved: {resolution}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MergeReviewDrawer({ mergeRequest, isOpen, onClose, onReview }: MergeReviewDrawerProps) {
|
|
const [mounted, setMounted] = useState(false);
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
const [comment, setComment] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [resolutions, setResolutions] = useState<Record<string, string>>({});
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
|
|
const conflicts: ConflictRecord[] = mergeRequest?.conflicts ?? [];
|
|
const diff = mergeRequest?.diffSummary;
|
|
const highSeverityConflicts = conflicts.filter(
|
|
(c) => (CONFLICT_CLASS_CONFIG[c.conflictClass]?.severity ?? 'none') === 'high'
|
|
);
|
|
const unresolvedHigh = highSeverityConflicts.filter((c) => !resolutions[c.conflictId]);
|
|
const canApprove = unresolvedHigh.length === 0;
|
|
|
|
const handleReview = async (decision: 'approve' | 'reject' | 'changes_requested') => {
|
|
setSubmitting(true);
|
|
try {
|
|
await onReview(decision, comment);
|
|
setSuccess(decision);
|
|
setTimeout(() => {
|
|
setSuccess(null);
|
|
onClose();
|
|
}, 2000);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const content = (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<>
|
|
<motion.div
|
|
key="mr-backdrop"
|
|
className="fixed inset-0 z-40"
|
|
style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(4px)' }}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
/>
|
|
<motion.div
|
|
key="mr-drawer"
|
|
className="fixed z-50 right-0 top-0 h-full"
|
|
style={{ width: 480 }}
|
|
initial={{ x: 480 }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: 480 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
>
|
|
<div
|
|
className="h-full flex flex-col"
|
|
style={{
|
|
background: 'rgba(10, 11, 18, 0.99)',
|
|
borderLeft: '1px solid rgba(255,255,255,0.1)',
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
|
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-xl bg-violet-500/15 border border-violet-500/25 flex items-center justify-center">
|
|
<GitMerge className="w-4 h-4 text-violet-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-zinc-100">
|
|
{mergeRequest?.title ?? 'Merge Request'}
|
|
</h2>
|
|
<p className="text-xs text-zinc-500">
|
|
{mergeRequest?.sourceBranchId ?? 'fork'} → {mergeRequest?.targetBranchId ?? 'main'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{success ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
|
<CheckCircle2 className="w-12 h-12 text-green-400" />
|
|
<p className="text-base font-medium text-zinc-100 capitalize">
|
|
{success === 'approve' ? 'Merge approved!' : success === 'reject' ? 'Review rejected' : 'Changes requested'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
|
|
{/* Diff summary */}
|
|
{diff && (
|
|
<div>
|
|
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-3">Changes</h3>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{[
|
|
{ label: 'Added', val: diff.componentsAdded, color: '#34d399', icon: Plus },
|
|
{ label: 'Edited', val: diff.componentsEdited, color: '#60a5fa', icon: Edit2 },
|
|
{ label: 'Reordered', val: diff.componentsReordered, color: '#a78bfa', icon: ArrowUpDown },
|
|
{ label: 'Deleted', val: diff.componentsDeleted, color: '#f87171', icon: Trash2 },
|
|
].map(({ label, val, color, icon: Icon }) => (
|
|
<div
|
|
key={label}
|
|
className="p-2.5 rounded-xl text-center"
|
|
style={{ background: `${color}10`, border: `1px solid ${color}25` }}
|
|
>
|
|
<Icon className="w-3.5 h-3.5 mx-auto mb-1" style={{ color }} />
|
|
<p className="text-base font-bold" style={{ color }}>{val}</p>
|
|
<p className="text-[10px] text-zinc-600 uppercase tracking-wider">{label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conflicts */}
|
|
{conflicts.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide">
|
|
Conflicts ({conflicts.length})
|
|
</h3>
|
|
{unresolvedHigh.length > 0 && (
|
|
<span className="text-[10px] text-amber-400">
|
|
{unresolvedHigh.length} require resolution
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{conflicts.map((c) => (
|
|
<ConflictCard
|
|
key={c.conflictId}
|
|
conflict={c}
|
|
resolution={resolutions[c.conflictId]}
|
|
onResolve={(id, decision) =>
|
|
setResolutions((p) => ({ ...p, [id]: decision }))
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{conflicts.length === 0 && (
|
|
<div
|
|
className="flex items-center gap-3 p-4 rounded-xl"
|
|
style={{ background: 'rgba(52,211,153,0.07)', border: '1px solid rgba(52,211,153,0.2)' }}
|
|
>
|
|
<CheckCircle2 className="w-5 h-5 text-green-400 flex-shrink-0" />
|
|
<p className="text-sm text-green-300">No conflicts — clean merge</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reviewer comment */}
|
|
<div>
|
|
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Reviewer comment</label>
|
|
<textarea
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
placeholder="Optional note for the author…"
|
|
rows={3}
|
|
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
|
|
style={{
|
|
background: 'rgba(255,255,255,0.04)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
color: '#e4e4e7',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Decision buttons */}
|
|
<div
|
|
className="flex gap-2 px-5 py-4 flex-shrink-0"
|
|
style={{ borderTop: '1px solid rgba(255,255,255,0.07)' }}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void handleReview('reject')}
|
|
disabled={submitting}
|
|
className="flex-1 border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20"
|
|
>
|
|
Reject
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void handleReview('changes_requested')}
|
|
disabled={submitting}
|
|
className="flex-1 border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
|
|
>
|
|
Request Changes
|
|
</Button>
|
|
<Button
|
|
onClick={() => void handleReview('approve')}
|
|
disabled={!canApprove || submitting}
|
|
className="flex-1 bg-green-700 hover:bg-green-600 text-white disabled:opacity-40"
|
|
>
|
|
{canApprove ? 'Approve & Merge' : `Resolve ${unresolvedHigh.length} conflicts`}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
if (!mounted) return null;
|
|
return createPortal(content, document.body);
|
|
}
|