"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 { useReviewSidebarResize } from "@/hooks/use-review-sidebar-resize"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults"; 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, reviewFileKey, splitReviewFileKey as splitFileKey, } from "./types"; /** * Multi-repo dedup: keying ReviewFile entries by `path` only collapses * `kandev/README.md` and `lvc/README.md` into a single row. Use the shared * `reviewFileKey` helper from `./types` so the in-memory state, persisted * reviews, and the file tree all agree on one key shape. `fileMapKey` is * the lower-level form for code that has the path + repo name as separate * variables (e.g. when building maps from `Object.entries` of the backend * payload). */ function fileMapKey(path: string, repositoryName?: string): string { return reviewFileKey({ path, repository_name: repositoryName }); } function addCumulativeDiffFiles( fileMap: Map, files: CumulativeDiff["files"], gitStatusFiles: Record | null, ) { // Multi-repo: backend always stamps each per-file payload with // `repository_name` + the repo-relative `path`. Single-repo (or legacy) // payloads keep the bare path on the map key. We trust `file.path` to be // set in the multi-repo shape — a missing `path` would be a backend // contract violation and indicates the caller is on an outdated agentctl; // surface it loudly via a console warning rather than silently injecting // the composite map key into the displayed path (which used to corrupt // the file tree node names). for (const [mapKey, file] of Object.entries(files)) { const repoName = file.repository_name; const path = file.path; if (!path) { console.warn("[review] cumulative diff entry missing `path` field; skipping", { mapKey, repoName, }); continue; } const key = fileMapKey(path, repoName); if (fileMap.has(key)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (diff) { const matchingUncommitted = findUncommittedByPathAndRepo(gitStatusFiles, path, repoName); fileMap.set(key, { path, diff, status: file.status || "modified", additions: file.additions ?? 0, deletions: file.deletions ?? 0, staged: matchingUncommitted?.staged ?? false, source: matchingUncommitted ? "uncommitted" : "committed", repository_name: repoName, }); } } } /** Looks up a FileInfo in the (possibly composite-keyed) gitStatus map by * (path, repository_name). When repo is undefined or empty (single-repo), * returns the first match by path. */ function findUncommittedByPathAndRepo( gitStatusFiles: Record | null, path: string, repositoryName: string | undefined, ): FileInfo | undefined { if (!gitStatusFiles) return undefined; for (const file of Object.values(gitStatusFiles)) { if (file.path !== path) continue; if (!repositoryName) return file; if (file.repository_name === repositoryName) return file; } return undefined; } function addUncommittedFiles( fileMap: Map, gitStatusFiles: Record, ) { for (const file of Object.values(gitStatusFiles)) { const key = fileMapKey(file.path, file.repository_name); if (fileMap.has(key)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (diff) fileMap.set(key, { path: file.path, diff, status: file.status, additions: file.additions ?? 0, deletions: file.deletions ?? 0, staged: file.staged, source: "uncommitted", repository_name: file.repository_name, }); } } function prFileStatus(status: string): "added" | "deleted" | "modified" { if (status === "added") return "added"; if (status === "removed") return "deleted"; return "modified"; } function addPRFiles(fileMap: Map, 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 | null, cumulativeDiff: CumulativeDiff | null, prDiffFiles?: PRDiffFile[], ): ReviewFile[] { const fileMap = new Map(); // Order matters and must match `buildReviewSources` (the Changes panel // builder): uncommitted FIRST so its always-fresh WS-pushed diff content // wins over the polled cumulative diff for files that exist in both. Before // this, the dialog and the panel disagreed on `datasource.ts`-style files // (panel: fresh worktree content from `git-status`, dialog: stale cumulative // diff snapshot from the last fetch) — the dialog appeared to show outdated // content even though the cumulative-diff hook was successfully refetching. if (gitStatusFiles) addUncommittedFiles(fileMap, gitStatusFiles); if (cumulativeDiff?.files) addCumulativeDiffFiles(fileMap, cumulativeDiff.files, gitStatusFiles); if (prDiffFiles) addPRFiles(fileMap, prDiffFiles); 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 | null; cumulativeDiff: CumulativeDiff | null; prDiffFiles?: PRDiffFile[]; }; function computeReviewSets( allFiles: ReviewFile[], reviews: ReturnType["reviews"], ) { const reviewed = new Set(); const stale = new Set(); for (const file of allFiles) { const key = reviewFileKey(file); const reviewState = reviews.get(key); if (!reviewState?.reviewed) continue; const currentHash = hashDiff(file.diff); if (reviewState.diffHash && reviewState.diffHash !== currentHash) stale.add(key); else reviewed.add(key); } return { reviewedFiles: reviewed, staleFiles: stale }; } /** * Counts diff comments per file, scoped by repo when known. Multi-repo: * comments carrying `repositoryId` are matched only against files in that * repo (translated from `repository_name` via `repositoryNameToId`); legacy * comments without `repositoryId` and same-repo unattributed comments * match by path. Returned record is keyed by `reviewFileKey(file)` so the * file tree's per-row badge correctly disambiguates same-named files. */ function computeCommentCounts( byId: Record, sessionCommentIds: string[] | undefined, allFiles: ReviewFile[], repositoryNameToId: Map, ): Record { const counts: Record = {}; if (!sessionCommentIds) return counts; type BucketKey = string; const bucketKey = (repoId: string, path: string) => `${repoId}::${path}`; const bucket = new Map(); for (const id of sessionCommentIds) { const comment = byId[id]; if (!comment || !isDiffComment(comment)) continue; const k = bucketKey(comment.repositoryId ?? "", comment.filePath); bucket.set(k, (bucket.get(k) ?? 0) + 1); } for (const file of allFiles) { let total = 0; if (file.repository_name) { const repoId = repositoryNameToId.get(file.repository_name) ?? ""; if (repoId) total += bucket.get(bucketKey(repoId, file.path)) ?? 0; } // Path-only comments (legacy / pre-multi-repo data) match every file // sharing the path; this preserves the prior single-repo behavior and // keeps existing comments visible after the multi-repo rollout. total += bucket.get(bucketKey("", file.path)) ?? 0; if (total > 0) counts[reviewFileKey(file)] = total; } return counts; } type ReviewDialogHandlerOptions = { allFiles: ReviewFile[]; markReviewed: (path: string, hash: string) => void; markUnreviewed: (path: string) => void; onSendComments: ReviewDialogProps["onSendComments"]; onOpenChange: ReviewDialogProps["onOpenChange"]; sessionId: string; }; function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) { const { allFiles, 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((key: string, setSelectedFile: (k: string) => void) => { setSelectedFile(key); // Note: scrolling is now handled by FileDiffSection when isSelected changes // This ensures proper timing after the section expands }, []); const handleToggleReviewed = useCallback( (key: string, reviewed: boolean) => { if (reviewed) { // Look up by composite key so two same-name files in different repos // don't share their reviewed/diff-hash. const file = allFiles.find((f) => reviewFileKey(f) === key); markReviewed(key, file ? hashDiff(file.diff) : ""); } else markUnreviewed(key); }, [allFiles, markReviewed, markUnreviewed], ); const handleSendComments = useCallback( (comments: DiffComment[]) => { onSendComments(comments); onOpenChange(false); }, [onSendComments, onOpenChange], ); const handleDiscard = useCallback( async (key: string) => { // Caller passes the composite key; split out the repo + path so // discard runs in the correct repo's worktree. const { repositoryName, path } = splitFileKey(key); try { const result = await discard([path], repositoryName || undefined); 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(null); const [splitView, setSplitView] = useState(() => typeof window === "undefined" ? false : localStorage.getItem("diff-view-mode") === "split", ); const [wordWrap, setWordWrap] = useState(DEFAULT_DIFF_WORD_WRAP); 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( () => 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], ); // Build a `repository_name` → `repositoryId` map from the workspace repos // slice. Comments store `repositoryId` (UUID) but ReviewFiles carry // `repository_name` (e.g. "kandev"); the dialog needs both to scope the // per-repo comment counts. Falls back to an empty map (legacy single-repo // counts by path only). const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); const repositoryNameToId = useMemo(() => { const m = new Map(); for (const list of Object.values(reposByWorkspace)) { for (const r of list) m.set(r.name, r.id); } return m; }, [reposByWorkspace]); const commentCountByFile = useMemo( () => computeCommentCounts(byId, sessionCommentIds, allFiles, repositoryNameToId), [byId, sessionCommentIds, allFiles, repositoryNameToId], ); const totalCommentCount = useMemo( () => Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0), [commentCountByFile], ); // Composite-key the file refs so two files with the same path in different // repos get distinct refs (otherwise auto-scroll-on-select / "selected" // styling lands on the wrong row). const fileRefs = useMemo(() => { const refs = new Map>(); for (const file of allFiles) refs.set(reviewFileKey(file), createRef()); return refs; }, [allFiles]); const prevCountRef = useRef(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, 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); const splitRowRef = useRef(null); const sidebar = useReviewSidebarResize(splitRowRef, open); return ( Review Changes onOpenChange(false)} getPendingComments={s.getPendingComments} markCommentsSent={s.markCommentsSent} />
{s.filteredFiles.length > 0 ? ( ) : (
{s.filter.trim() ? "No files match the filter" : "No changes to review"}
)}
); });