386 lines
13 KiB
TypeScript
386 lines
13 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 type { PRDiffFile } from "@/lib/types/github";
|
|
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 prFileStatus(status: string): "added" | "deleted" | "modified" {
|
|
if (status === "added") return "added";
|
|
if (status === "removed") return "deleted";
|
|
return "modified";
|
|
}
|
|
|
|
function addPRFiles(fileMap: Map<string, ReviewFile>, files: PRDiffFile[]) {
|
|
for (const file of files) {
|
|
if (fileMap.has(file.filename)) continue;
|
|
const diff = file.patch ? normalizeDiffContent(file.patch) : "";
|
|
if (diff)
|
|
fileMap.set(file.filename, {
|
|
path: file.filename,
|
|
diff,
|
|
status: prFileStatus(file.status),
|
|
additions: file.additions ?? 0,
|
|
deletions: file.deletions ?? 0,
|
|
staged: false,
|
|
source: "pr",
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildAllFiles(
|
|
gitStatusFiles: Record<string, FileInfo> | null,
|
|
cumulativeDiff: CumulativeDiff | null,
|
|
prDiffFiles?: PRDiffFile[],
|
|
): ReviewFile[] {
|
|
const fileMap = new Map<string, ReviewFile>();
|
|
if (prDiffFiles) addPRFiles(fileMap, prDiffFiles);
|
|
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;
|
|
prDiffFiles?: PRDiffFile[];
|
|
};
|
|
|
|
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,
|
|
prDiffFiles,
|
|
} = 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, prDiffFiles),
|
|
[gitStatusFiles, cumulativeDiff, prDiffFiles],
|
|
);
|
|
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>
|
|
);
|
|
});
|