Remove overflow-hidden from ReviewTab outer wrapper (was clipping the absolutely-positioned dropdown), make ReviewHeader sticky with z-20 to sit above file headers (z-10), and bump the dropdown to z-30.
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import {
|
|
Check,
|
|
X,
|
|
GitBranch,
|
|
FileCode,
|
|
Plus,
|
|
Minus,
|
|
ExternalLink,
|
|
Loader2,
|
|
Square,
|
|
CircleDot,
|
|
RotateCcw,
|
|
ArrowRight,
|
|
Eye,
|
|
AlertCircle,
|
|
GitMerge,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import type { FileDiff, ReviewStatus } from "./types";
|
|
|
|
interface PhaseOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface PreviewState {
|
|
status: "idle" | "building" | "running" | "failed";
|
|
url?: string;
|
|
onStart: () => void;
|
|
onStop: () => void;
|
|
isStarting: boolean;
|
|
isStopping: boolean;
|
|
}
|
|
|
|
interface ReviewHeaderProps {
|
|
phases: PhaseOption[];
|
|
activePhaseId: string | null;
|
|
onPhaseSelect: (id: string) => void;
|
|
phaseName: string;
|
|
sourceBranch: string;
|
|
targetBranch: string;
|
|
files: FileDiff[];
|
|
status: ReviewStatus;
|
|
unresolvedCount: number;
|
|
onApprove: () => void;
|
|
onRequestChanges: () => void;
|
|
isRequestingChanges?: boolean;
|
|
preview: PreviewState | null;
|
|
viewedCount?: number;
|
|
totalCount?: number;
|
|
}
|
|
|
|
export function ReviewHeader({
|
|
phases,
|
|
activePhaseId,
|
|
onPhaseSelect,
|
|
phaseName,
|
|
sourceBranch,
|
|
targetBranch,
|
|
files,
|
|
status,
|
|
unresolvedCount,
|
|
onApprove,
|
|
onRequestChanges,
|
|
isRequestingChanges,
|
|
preview,
|
|
viewedCount,
|
|
totalCount,
|
|
}: ReviewHeaderProps) {
|
|
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
|
|
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
|
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
|
const confirmRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Click-outside handler to dismiss confirmation
|
|
useEffect(() => {
|
|
if (!showConfirmation) return;
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (
|
|
confirmRef.current &&
|
|
!confirmRef.current.contains(e.target as Node)
|
|
) {
|
|
setShowConfirmation(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [showConfirmation]);
|
|
|
|
const viewed = viewedCount ?? 0;
|
|
const total = totalCount ?? 0;
|
|
|
|
return (
|
|
<div className="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-20">
|
|
{/* Phase selector row */}
|
|
{phases.length > 1 && (
|
|
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
|
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mr-2 shrink-0">
|
|
Phases
|
|
</span>
|
|
<div className="flex gap-1 overflow-x-auto">
|
|
{phases.map((phase) => {
|
|
const isActive = phase.id === activePhaseId;
|
|
return (
|
|
<button
|
|
key={phase.id}
|
|
onClick={() => onPhaseSelect(phase.id)}
|
|
className={`
|
|
flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium
|
|
transition-all duration-fast whitespace-nowrap
|
|
${isActive
|
|
? "bg-primary/10 text-primary border border-primary/20 shadow-xs"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted border border-transparent"
|
|
}
|
|
`}
|
|
>
|
|
<span
|
|
className={`h-1.5 w-1.5 rounded-full shrink-0 ${
|
|
isActive ? "bg-primary" : "bg-status-warning-dot"
|
|
}`}
|
|
/>
|
|
{phase.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main toolbar row */}
|
|
<div className="flex items-center gap-3 px-4 py-2.5">
|
|
{/* Left: branch info + stats */}
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<h2 className="text-sm font-semibold truncate shrink-0">
|
|
{phaseName}
|
|
</h2>
|
|
|
|
{sourceBranch && (
|
|
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono min-w-0">
|
|
<GitBranch className="h-3 w-3 shrink-0" />
|
|
<span className="truncate" title={sourceBranch}>
|
|
{sourceBranch}
|
|
</span>
|
|
<ArrowRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
|
<span className="truncate" title={targetBranch}>
|
|
{targetBranch}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2.5 text-[11px] shrink-0">
|
|
<span className="flex items-center gap-0.5 text-muted-foreground">
|
|
<FileCode className="h-3 w-3" />
|
|
{files.length}
|
|
</span>
|
|
<span className="flex items-center gap-0.5 text-diff-add-fg">
|
|
<Plus className="h-3 w-3" />
|
|
{totalAdditions}
|
|
</span>
|
|
<span className="flex items-center gap-0.5 text-diff-remove-fg">
|
|
<Minus className="h-3 w-3" />
|
|
{totalDeletions}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center: review progress */}
|
|
{total > 0 && (
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground shrink-0">
|
|
<Eye className="h-3 w-3" />
|
|
<span>
|
|
{viewed}/{total} viewed
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Right: preview + actions */}
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
{/* Preview controls */}
|
|
{preview && <PreviewControls preview={preview} />}
|
|
|
|
{/* Review status / actions */}
|
|
{status === "pending" && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onRequestChanges}
|
|
disabled={isRequestingChanges}
|
|
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
|
|
>
|
|
{isRequestingChanges ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<X className="h-3 w-3" />
|
|
)}
|
|
Request Changes
|
|
</Button>
|
|
<div className="relative" ref={confirmRef}>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
if (unresolvedCount > 0) return;
|
|
setShowConfirmation(true);
|
|
}}
|
|
disabled={unresolvedCount > 0}
|
|
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
|
>
|
|
{unresolvedCount > 0 ? (
|
|
<>
|
|
<AlertCircle className="h-3.5 w-3.5" />
|
|
{unresolvedCount} unresolved
|
|
</>
|
|
) : (
|
|
<>
|
|
<GitMerge className="h-3.5 w-3.5" />
|
|
Approve & Merge
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
{/* Merge confirmation dropdown */}
|
|
{showConfirmation && (
|
|
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
|
|
<p className="text-sm font-semibold mb-3">
|
|
Ready to merge?
|
|
</p>
|
|
<div className="space-y-1.5 mb-4">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Check className="h-3.5 w-3.5 text-status-success-fg" />
|
|
<span className="text-muted-foreground">
|
|
0 unresolved comments
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-muted-foreground">
|
|
{viewed}/{total} files viewed
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowConfirmation(false)}
|
|
className="h-8 text-xs"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setShowConfirmation(false);
|
|
onApprove();
|
|
}}
|
|
className="h-8 px-4 text-xs font-semibold shadow-sm"
|
|
>
|
|
<GitMerge className="h-3.5 w-3.5" />
|
|
Merge Now
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{status === "approved" && (
|
|
<Badge variant="success" size="xs">
|
|
<Check className="h-3 w-3" />
|
|
Approved
|
|
</Badge>
|
|
)}
|
|
{status === "changes_requested" && (
|
|
<Badge variant="warning" size="xs">
|
|
<X className="h-3 w-3" />
|
|
Changes Requested
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewControls({ preview }: { preview: PreviewState }) {
|
|
if (preview.status === "building" || preview.isStarting) {
|
|
return (
|
|
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
<span>Building...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (preview.status === "running") {
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
<a
|
|
href={preview.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
|
|
>
|
|
<CircleDot className="h-3 w-3" />
|
|
Preview
|
|
<ExternalLink className="h-2.5 w-2.5" />
|
|
</a>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={preview.onStop}
|
|
disabled={preview.isStopping}
|
|
className="h-6 w-6 p-0"
|
|
>
|
|
<Square className="h-2.5 w-2.5" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (preview.status === "failed") {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={preview.onStart}
|
|
className="h-7 text-xs text-status-error-fg"
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
Retry Preview
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={preview.onStart}
|
|
disabled={preview.isStarting}
|
|
className="h-7 text-xs"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
Preview
|
|
</Button>
|
|
);
|
|
}
|