405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Loader2 } from "lucide-react";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { parseUnifiedDiff } from "./parse-diff";
|
|
import { DiffViewer } from "./DiffViewer";
|
|
import { ReviewSidebar } from "./ReviewSidebar";
|
|
import { ReviewHeader } from "./ReviewHeader";
|
|
import { InitiativeReview } from "./InitiativeReview";
|
|
import type { ReviewStatus, DiffLine } from "./types";
|
|
|
|
interface ReviewTabProps {
|
|
initiativeId: string;
|
|
}
|
|
|
|
export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|
const [status, setStatus] = useState<ReviewStatus>("pending");
|
|
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
|
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
|
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
const [headerHeight, setHeaderHeight] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const el = headerRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver(([entry]) => {
|
|
setHeaderHeight(entry.contentRect.height);
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
const toggleViewed = useCallback((filePath: string) => {
|
|
setViewedFiles(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(filePath)) {
|
|
next.delete(filePath);
|
|
} else {
|
|
next.add(filePath);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const registerFileRef = useCallback((filePath: string, el: HTMLDivElement | null) => {
|
|
if (el) {
|
|
fileRefs.current.set(filePath, el);
|
|
} else {
|
|
fileRefs.current.delete(filePath);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch initiative to check for initiative-level pending_review
|
|
const initiativeQuery = trpc.getInitiative.useQuery({ id: initiativeId });
|
|
const isInitiativePendingReview = initiativeQuery.data?.status === "pending_review";
|
|
|
|
// Fetch phases for this initiative
|
|
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
|
|
const reviewablePhases = useMemo(
|
|
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"),
|
|
[phasesQuery.data],
|
|
);
|
|
|
|
// Select first pending review phase, falling back to completed phases
|
|
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
|
const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null;
|
|
const activePhaseId = selectedPhaseId ?? defaultPhaseId;
|
|
const activePhase = reviewablePhases.find((p) => p.id === activePhaseId);
|
|
const isActivePhaseCompleted = activePhase?.status === "completed";
|
|
|
|
// Fetch projects for this initiative (needed for preview)
|
|
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
|
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
|
|
|
// Fetch full branch diff for active phase
|
|
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
|
{ phaseId: activePhaseId! },
|
|
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
|
);
|
|
|
|
// Fetch commits for active phase
|
|
const commitsQuery = trpc.getPhaseReviewCommits.useQuery(
|
|
{ phaseId: activePhaseId! },
|
|
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
|
);
|
|
const commits = commitsQuery.data?.commits ?? [];
|
|
|
|
// Fetch single-commit diff when a commit is selected
|
|
const commitDiffQuery = trpc.getCommitDiff.useQuery(
|
|
{ phaseId: activePhaseId!, commitHash: selectedCommit! },
|
|
{ enabled: !!activePhaseId && !!selectedCommit && !isInitiativePendingReview },
|
|
);
|
|
|
|
// Preview state
|
|
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
|
const existingPreview = previewsQuery.data?.find(
|
|
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
|
);
|
|
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
|
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
|
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
|
|
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
|
|
);
|
|
const preview = previewStatusQuery.data ?? existingPreview;
|
|
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
|
|
|
const startPreview = trpc.startPreview.useMutation({
|
|
onSuccess: (data) => {
|
|
setActivePreviewId(data.id);
|
|
previewsQuery.refetch();
|
|
toast.success(`Preview running at ${data.url}`);
|
|
},
|
|
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
|
});
|
|
|
|
const stopPreview = trpc.stopPreview.useMutation({
|
|
onSuccess: () => {
|
|
setActivePreviewId(null);
|
|
toast.success("Preview stopped");
|
|
previewsQuery.refetch();
|
|
},
|
|
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
|
|
});
|
|
|
|
const previewState = firstProjectId && sourceBranch
|
|
? {
|
|
status: preview?.status === "running"
|
|
? ("running" as const)
|
|
: preview?.status === "failed"
|
|
? ("failed" as const)
|
|
: (startPreview.isPending || preview?.status === "building")
|
|
? ("building" as const)
|
|
: ("idle" as const),
|
|
url: preview?.url ?? undefined,
|
|
onStart: () =>
|
|
startPreview.mutate({
|
|
initiativeId,
|
|
phaseId: activePhaseId ?? undefined,
|
|
projectId: firstProjectId,
|
|
branch: sourceBranch,
|
|
}),
|
|
onStop: () => {
|
|
const id = activePreviewId ?? existingPreview?.id;
|
|
if (id) stopPreview.mutate({ previewId: id });
|
|
},
|
|
isStarting: startPreview.isPending,
|
|
isStopping: stopPreview.isPending,
|
|
}
|
|
: null;
|
|
|
|
// Review comments — persisted to DB
|
|
const utils = trpc.useUtils();
|
|
const commentsQuery = trpc.listReviewComments.useQuery(
|
|
{ phaseId: activePhaseId! },
|
|
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
|
);
|
|
const comments = useMemo(() => {
|
|
return (commentsQuery.data ?? []).map((c) => ({
|
|
id: c.id,
|
|
filePath: c.filePath,
|
|
lineNumber: c.lineNumber,
|
|
lineType: c.lineType as "added" | "removed" | "context",
|
|
body: c.body,
|
|
author: c.author,
|
|
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
|
|
resolved: c.resolved,
|
|
parentCommentId: c.parentCommentId ?? null,
|
|
}));
|
|
}, [commentsQuery.data]);
|
|
|
|
const createCommentMutation = trpc.createReviewComment.useMutation({
|
|
onSuccess: () => {
|
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
|
},
|
|
onError: (err) => toast.error(`Failed to save comment: ${err.message}`),
|
|
});
|
|
|
|
const resolveCommentMutation = trpc.resolveReviewComment.useMutation({
|
|
onSuccess: () => {
|
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
|
},
|
|
});
|
|
|
|
const unresolveCommentMutation = trpc.unresolveReviewComment.useMutation({
|
|
onSuccess: () => {
|
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
|
},
|
|
});
|
|
|
|
const replyToCommentMutation = trpc.replyToReviewComment.useMutation({
|
|
onSuccess: () => {
|
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
|
},
|
|
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
|
|
});
|
|
|
|
const approveMutation = trpc.approvePhaseReview.useMutation({
|
|
onSuccess: () => {
|
|
setStatus("approved");
|
|
toast.success("Phase approved and merged");
|
|
phasesQuery.refetch();
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
});
|
|
|
|
// Determine which diff to display
|
|
const activeDiffRaw = selectedCommit
|
|
? commitDiffQuery.data?.rawDiff
|
|
: diffQuery.data?.rawDiff;
|
|
|
|
const files = useMemo(() => {
|
|
if (!activeDiffRaw) return [];
|
|
return parseUnifiedDiff(activeDiffRaw);
|
|
}, [activeDiffRaw]);
|
|
|
|
const isDiffLoading = selectedCommit
|
|
? commitDiffQuery.isLoading
|
|
: diffQuery.isLoading;
|
|
|
|
const handleAddComment = useCallback(
|
|
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
|
|
if (!activePhaseId) return;
|
|
createCommentMutation.mutate({
|
|
phaseId: activePhaseId,
|
|
filePath,
|
|
lineNumber,
|
|
lineType,
|
|
body,
|
|
});
|
|
toast.success("Comment added");
|
|
},
|
|
[activePhaseId, createCommentMutation],
|
|
);
|
|
|
|
const handleResolveComment = useCallback((commentId: string) => {
|
|
resolveCommentMutation.mutate({ id: commentId });
|
|
}, [resolveCommentMutation]);
|
|
|
|
const handleUnresolveComment = useCallback((commentId: string) => {
|
|
unresolveCommentMutation.mutate({ id: commentId });
|
|
}, [unresolveCommentMutation]);
|
|
|
|
const handleReplyComment = useCallback((parentCommentId: string, body: string) => {
|
|
replyToCommentMutation.mutate({ parentCommentId, body });
|
|
}, [replyToCommentMutation]);
|
|
|
|
const handleApprove = useCallback(() => {
|
|
if (!activePhaseId) return;
|
|
approveMutation.mutate({ phaseId: activePhaseId });
|
|
}, [activePhaseId, approveMutation]);
|
|
|
|
const requestChangesMutation = trpc.requestPhaseChanges.useMutation({
|
|
onSuccess: () => {
|
|
setStatus("changes_requested");
|
|
toast.success("Changes requested — revision task dispatched");
|
|
phasesQuery.refetch();
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
});
|
|
|
|
const handleRequestChanges = useCallback(() => {
|
|
if (!activePhaseId) return;
|
|
requestChangesMutation.mutate({ phaseId: activePhaseId });
|
|
}, [activePhaseId, requestChangesMutation]);
|
|
|
|
const handleFileClick = useCallback((filePath: string) => {
|
|
const el = fileRefs.current.get(filePath);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
}, []);
|
|
|
|
const handleCommentClick = useCallback((commentId: string) => {
|
|
const el = document.querySelector(`[data-comment-id="${commentId}"]`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "instant", block: "center" });
|
|
// Brief highlight flash
|
|
el.classList.add("ring-2", "ring-primary/50");
|
|
setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500);
|
|
}
|
|
}, []);
|
|
|
|
const handlePhaseSelect = useCallback((id: string) => {
|
|
setSelectedPhaseId(id);
|
|
setSelectedCommit(null);
|
|
setStatus("pending");
|
|
setViewedFiles(new Set());
|
|
}, []);
|
|
|
|
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
|
|
|
const activePhaseName =
|
|
diffQuery.data?.phaseName ??
|
|
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
|
|
"Phase";
|
|
|
|
// All files from the full branch diff (for sidebar file list)
|
|
const allFiles = useMemo(() => {
|
|
if (!diffQuery.data?.rawDiff) return [];
|
|
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
|
}, [diffQuery.data?.rawDiff]);
|
|
|
|
// Initiative-level review takes priority
|
|
if (isInitiativePendingReview) {
|
|
return (
|
|
<InitiativeReview
|
|
initiativeId={initiativeId}
|
|
onCompleted={() => {
|
|
initiativeQuery.refetch();
|
|
phasesQuery.refetch();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (reviewablePhases.length === 0) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
<p>No phases pending review</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-card">
|
|
{/* Header: phase selector + toolbar */}
|
|
<div ref={headerRef} className="sticky top-0 z-20">
|
|
<ReviewHeader
|
|
phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))}
|
|
activePhaseId={activePhaseId}
|
|
isReadOnly={isActivePhaseCompleted}
|
|
onPhaseSelect={handlePhaseSelect}
|
|
phaseName={activePhaseName}
|
|
sourceBranch={sourceBranch}
|
|
targetBranch={diffQuery.data?.targetBranch ?? commitsQuery.data?.targetBranch ?? ""}
|
|
files={allFiles}
|
|
status={status}
|
|
unresolvedCount={unresolvedCount}
|
|
onApprove={handleApprove}
|
|
onRequestChanges={handleRequestChanges}
|
|
isRequestingChanges={requestChangesMutation.isPending}
|
|
preview={previewState}
|
|
viewedCount={viewedFiles.size}
|
|
totalCount={allFiles.length}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main content area — sidebar always rendered to preserve state */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] rounded-b-lg">
|
|
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
|
|
<div className="border-r border-border">
|
|
<div
|
|
className="sticky overflow-hidden"
|
|
style={{
|
|
top: `${headerHeight}px`,
|
|
maxHeight: `calc(100vh - ${headerHeight}px)`,
|
|
}}
|
|
>
|
|
<ReviewSidebar
|
|
files={allFiles}
|
|
comments={comments}
|
|
onFileClick={handleFileClick}
|
|
onCommentClick={handleCommentClick}
|
|
selectedCommit={selectedCommit}
|
|
activeFiles={files}
|
|
commits={commits}
|
|
onSelectCommit={setSelectedCommit}
|
|
viewedFiles={viewedFiles}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Diff */}
|
|
<div className="min-w-0 p-4">
|
|
{isDiffLoading ? (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading diff...
|
|
</div>
|
|
) : files.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
|
{selectedCommit
|
|
? "No changes in this commit"
|
|
: "No changes in this phase"}
|
|
</div>
|
|
) : (
|
|
<DiffViewer
|
|
files={files}
|
|
comments={comments}
|
|
onAddComment={handleAddComment}
|
|
onResolveComment={handleResolveComment}
|
|
onUnresolveComment={handleUnresolveComment}
|
|
onReplyComment={handleReplyComment}
|
|
viewedFiles={viewedFiles}
|
|
onToggleViewed={toggleViewed}
|
|
onRegisterRef={registerFileRef}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|