Built the Oracle Tab (#14)
This commit is contained in:
386
app/src/oracle/components/review/MergeReviewDrawer.tsx
Normal file
386
app/src/oracle/components/review/MergeReviewDrawer.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user