Files
kandev/apps/web/components/review/review-dialog.tsx
T

351 lines
12 KiB
TypeScript

"use client";
import { memo, useMemo, useCallback, createRef, useState, useRef, useEffect } from "react";
import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog";
import type { DiffComment } from "@/lib/diff/types";
import type { FileInfo, CumulativeDiff } from "@/lib/state/slices/session-runtime/types";
import { useCommentsStore, isDiffComment } from "@/lib/state/slices/comments";
import { useSessionFileReviews } from "@/hooks/use-session-file-reviews";
import { useGitOperations } from "@/hooks/use-git-operations";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { ReviewTopBar } from "./review-top-bar";
import { ReviewFileTree } from "./review-file-tree";
import { ReviewDiffList } from "./review-diff-list";
import type { ReviewFile } from "./types";
import { hashDiff, normalizeDiffContent } from "./types";
function addUncommittedFiles(
fileMap: Map<string, ReviewFile>,
gitStatusFiles: Record<string, FileInfo>,
) {
for (const [path, file] of Object.entries(gitStatusFiles)) {
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
if (diff)
fileMap.set(path, {
path,
diff,
status: file.status,
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: file.staged,
source: "uncommitted",
});
}
}
function addCommittedFiles(fileMap: Map<string, ReviewFile>, files: CumulativeDiff["files"]) {
for (const [path, file] of Object.entries(files)) {
if (fileMap.has(path)) continue;
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
if (diff)
fileMap.set(path, {
path,
diff,
status: file.status || "modified",
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: false,
source: "committed",
});
}
}
function buildAllFiles(
gitStatusFiles: Record<string, FileInfo> | null,
cumulativeDiff: CumulativeDiff | null,
): ReviewFile[] {
const fileMap = new Map<string, ReviewFile>();
if (gitStatusFiles) addUncommittedFiles(fileMap, gitStatusFiles);
if (cumulativeDiff?.files) addCommittedFiles(fileMap, cumulativeDiff.files);
return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path));
}
type ReviewDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: string;
baseBranch?: string;
onSendComments: (comments: DiffComment[]) => void;
onOpenFile?: (filePath: string) => void;
gitStatusFiles: Record<string, FileInfo> | null;
cumulativeDiff: CumulativeDiff | null;
};
function computeReviewSets(
allFiles: ReviewFile[],
reviews: ReturnType<typeof useSessionFileReviews>["reviews"],
) {
const reviewed = new Set<string>();
const stale = new Set<string>();
for (const file of allFiles) {
const reviewState = reviews.get(file.path);
if (!reviewState?.reviewed) continue;
const currentHash = hashDiff(file.diff);
if (reviewState.diffHash && reviewState.diffHash !== currentHash) stale.add(file.path);
else reviewed.add(file.path);
}
return { reviewedFiles: reviewed, staleFiles: stale };
}
function computeCommentCounts(
byId: Record<string, import("@/lib/state/slices/comments").Comment>,
sessionCommentIds: string[] | undefined,
): Record<string, number> {
const counts: Record<string, number> = {};
if (!sessionCommentIds) return counts;
for (const id of sessionCommentIds) {
const comment = byId[id];
if (comment && isDiffComment(comment)) {
counts[comment.filePath] = (counts[comment.filePath] ?? 0) + 1;
}
}
return counts;
}
type ReviewDialogHandlerOptions = {
allFiles: ReviewFile[];
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
markReviewed: (path: string, hash: string) => void;
markUnreviewed: (path: string) => void;
onSendComments: ReviewDialogProps["onSendComments"];
onOpenChange: ReviewDialogProps["onOpenChange"];
sessionId: string;
};
function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) {
const {
allFiles,
fileRefs,
markReviewed,
markUnreviewed,
onSendComments,
onOpenChange,
sessionId,
} = opts;
const { discard } = useGitOperations(sessionId);
const { toast } = useToast();
const handleToggleSplitView = useCallback((split: boolean) => {
const mode = split ? "split" : "unified";
localStorage.setItem("diff-view-mode", mode);
window.dispatchEvent(new CustomEvent("diff-view-mode-change", { detail: mode }));
}, []);
const handleSelectFile = useCallback(
(path: string, setSelectedFile: (p: string) => void) => {
setSelectedFile(path);
const ref = fileRefs.get(path);
if (ref?.current) ref.current.scrollIntoView({ behavior: "smooth", block: "start" });
},
[fileRefs],
);
const handleToggleReviewed = useCallback(
(path: string, reviewed: boolean) => {
if (reviewed) {
const file = allFiles.find((f) => f.path === path);
markReviewed(path, file ? hashDiff(file.diff) : "");
} else markUnreviewed(path);
},
[allFiles, markReviewed, markUnreviewed],
);
const handleSendComments = useCallback(
(comments: DiffComment[]) => {
onSendComments(comments);
onOpenChange(false);
},
[onSendComments, onOpenChange],
);
const handleDiscard = useCallback(
async (path: string) => {
try {
const result = await discard([path]);
if (result.success)
toast({ title: "Changes discarded", description: path, variant: "success" });
else
toast({
title: "Discard failed",
description: result.error || "An error occurred",
variant: "error",
});
} catch (e) {
toast({
title: "Discard failed",
description: e instanceof Error ? e.message : "An error occurred",
variant: "error",
});
}
},
[discard, toast],
);
return {
handleToggleSplitView,
handleSelectFile,
handleToggleReviewed,
handleSendComments,
handleDiscard,
};
}
function useReviewDialogState(props: ReviewDialogProps) {
const { open, onOpenChange, sessionId, onSendComments, gitStatusFiles, cumulativeDiff } = props;
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [splitView, setSplitView] = useState(() =>
typeof window === "undefined" ? false : localStorage.getItem("diff-view-mode") === "split",
);
const [wordWrap, setWordWrap] = useState(false);
const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll);
const { reviews, markReviewed, markUnreviewed } = useSessionFileReviews(sessionId);
const byId = useCommentsStore((s) => s.byId);
const sessionCommentIds = useCommentsStore((s) => s.bySession[sessionId]);
const getStorePendingComments = useCommentsStore((s) => s.getPendingComments);
const getPendingComments = useCallback((): DiffComment[] => {
return getStorePendingComments().filter(isDiffComment);
}, [getStorePendingComments]);
const markCommentsSent = useCommentsStore((s) => s.markCommentsSent);
const [filter, setFilter] = useState("");
const allFiles = useMemo<ReviewFile[]>(
() => buildAllFiles(gitStatusFiles, cumulativeDiff),
[gitStatusFiles, cumulativeDiff],
);
const filteredFiles = useMemo(() => {
if (!filter.trim()) return allFiles;
const q = filter.toLowerCase();
return allFiles.filter((f) => f.path.toLowerCase().includes(q));
}, [allFiles, filter]);
const { reviewedFiles, staleFiles } = useMemo(
() => computeReviewSets(allFiles, reviews),
[allFiles, reviews],
);
const commentCountByFile = useMemo(
() => computeCommentCounts(byId, sessionCommentIds),
[byId, sessionCommentIds],
);
const totalCommentCount = useMemo(
() => Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0),
[commentCountByFile],
);
const fileRefs = useMemo(() => {
const refs = new Map<string, React.RefObject<HTMLDivElement | null>>();
for (const file of allFiles) refs.set(file.path, createRef<HTMLDivElement>());
return refs;
}, [allFiles]);
const prevCountRef = useRef<number | null>(null);
useEffect(() => {
const prevCount = prevCountRef.current;
if (open && prevCount !== null && prevCount > 0 && allFiles.length === 0) onOpenChange(false);
prevCountRef.current = allFiles.length;
}, [open, allFiles.length, onOpenChange]);
const handlers = useReviewDialogHandlers({
allFiles,
fileRefs,
markReviewed,
markUnreviewed,
onSendComments,
onOpenChange,
sessionId,
});
return {
selectedFile,
splitView,
wordWrap,
setWordWrap,
autoMarkOnScroll,
filter,
setFilter,
allFiles,
filteredFiles,
reviewedFiles,
staleFiles,
commentCountByFile,
totalCommentCount,
fileRefs,
getPendingComments,
markCommentsSent,
handleToggleSplitView: (split: boolean) => {
setSplitView(split);
handlers.handleToggleSplitView(split);
},
handleSelectFile: (path: string) => handlers.handleSelectFile(path, setSelectedFile),
handleToggleReviewed: handlers.handleToggleReviewed,
handleSendComments: handlers.handleSendComments,
handleDiscard: handlers.handleDiscard,
};
}
export const ReviewDialog = memo(function ReviewDialog(props: ReviewDialogProps) {
const { open, onOpenChange, sessionId, baseBranch, onOpenFile } = props;
const s = useReviewDialogState(props);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="!max-w-[80vw] !w-[80vw] max-h-[85vh] h-[85vh] p-0 gap-0 flex flex-col shadow-2xl"
showCloseButton={false}
overlayClassName="bg-transparent"
>
<DialogTitle className="sr-only">Review Changes</DialogTitle>
<ReviewTopBar
sessionId={sessionId}
reviewedCount={s.reviewedFiles.size}
totalCount={s.allFiles.length}
commentCount={s.totalCommentCount}
baseBranch={baseBranch}
splitView={s.splitView}
onToggleSplitView={s.handleToggleSplitView}
wordWrap={s.wordWrap}
onToggleWordWrap={s.setWordWrap}
onSendComments={s.handleSendComments}
onClose={() => onOpenChange(false)}
getPendingComments={s.getPendingComments}
markCommentsSent={s.markCommentsSent}
/>
<div className="flex flex-1 min-h-0">
<div className="w-[280px] min-w-[220px] border-r border-border flex-shrink-0 overflow-hidden">
<ReviewFileTree
files={s.filteredFiles}
reviewedFiles={s.reviewedFiles}
staleFiles={s.staleFiles}
commentCountByFile={s.commentCountByFile}
selectedFile={s.selectedFile}
filter={s.filter}
onFilterChange={s.setFilter}
onSelectFile={s.handleSelectFile}
onToggleReviewed={s.handleToggleReviewed}
/>
</div>
<div className="flex-1 min-w-0 overflow-hidden">
{s.filteredFiles.length > 0 ? (
<ReviewDiffList
files={s.filteredFiles}
selectedFile={s.selectedFile}
reviewedFiles={s.reviewedFiles}
staleFiles={s.staleFiles}
sessionId={sessionId}
autoMarkOnScroll={s.autoMarkOnScroll}
wordWrap={s.wordWrap}
onToggleReviewed={s.handleToggleReviewed}
onDiscard={s.handleDiscard}
onOpenFile={onOpenFile}
fileRefs={s.fileRefs}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{s.filter.trim() ? "No files match the filter" : "No changes to review"}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
});