diff --git a/apps/server/src/prReview/Layers/PrReview.ts b/apps/server/src/prReview/Layers/PrReview.ts index 34f433b99..dfbdec5bc 100644 --- a/apps/server/src/prReview/Layers/PrReview.ts +++ b/apps/server/src/prReview/Layers/PrReview.ts @@ -259,14 +259,24 @@ function buildSyntheticPatch(file: { patch: string | null; }): string | null { if (!file.patch) return null; - const currentPath = file.path; - const previousPath = file.previousPath ?? file.path; - const oldPath = file.status === "added" ? "/dev/null" : `a/${previousPath.replaceAll("\\", "/")}`; - const newPath = - file.status === "removed" ? "/dev/null" : `b/${currentPath.replaceAll("\\", "/")}`; - return [`diff --git ${oldPath} ${newPath}`, `--- ${oldPath}`, `+++ ${newPath}`, file.patch].join( - "\n", - ); + const currentPath = file.path.replaceAll("\\", "/"); + const previousPath = (file.previousPath ?? file.path).replaceAll("\\", "/"); + + // The diff --git header MUST always use a/ and b/ prefixes for the parser + // (@pierre/diffs ALTERNATE_FILE_NAMES_GIT regex requires this format). + // /dev/null is only valid in the --- and +++ lines. + const gitHeaderA = `a/${previousPath}`; + const gitHeaderB = `b/${currentPath}`; + + const headerOld = file.status === "added" ? "/dev/null" : `a/${previousPath}`; + const headerNew = file.status === "removed" ? "/dev/null" : `b/${currentPath}`; + + const lines = [`diff --git ${gitHeaderA} ${gitHeaderB}`]; + if (file.status === "added") lines.push("new file mode 100644"); + if (file.status === "removed") lines.push("deleted file mode 100644"); + lines.push(`--- ${headerOld}`, `+++ ${headerNew}`, file.patch); + + return lines.join("\n"); } function normalizeStatusChecks(raw: unknown): PrReviewSummary["statusChecks"] { diff --git a/apps/web/src/components/pr-review/PrFileTabStrip.tsx b/apps/web/src/components/pr-review/PrFileTabStrip.tsx index 630ac91a4..617c73037 100644 --- a/apps/web/src/components/pr-review/PrFileTabStrip.tsx +++ b/apps/web/src/components/pr-review/PrFileTabStrip.tsx @@ -1,7 +1,7 @@ import type { FileDiffMetadata } from "@pierre/diffs/react"; import type { PrReviewThread } from "@okcode/contracts"; import { useMemo, useRef, useEffect } from "react"; -import { ListIcon, FileIcon } from "lucide-react"; +import { CheckCircle2Icon, ListIcon, FileIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { resolveFileDiffPath, summarizeFileDiffStats } from "./pr-review-utils"; @@ -17,6 +17,7 @@ export function PrFileTabStrip({ files, threads, selectedFilePath, + reviewedFiles, onSelectFilePath, fileViewMode, onFileViewModeChange, @@ -24,6 +25,7 @@ export function PrFileTabStrip({ files: FileDiffMetadata[]; threads: readonly PrReviewThread[]; selectedFilePath: string | null; + reviewedFiles: ReadonlySet; onSelectFilePath: (path: string) => void; fileViewMode: FileViewMode; onFileViewModeChange: (mode: FileViewMode) => void; @@ -52,9 +54,10 @@ export function PrFileTabStrip({ additions: stats.additions, deletions: stats.deletions, threadCount: threadsByPath[path] ?? 0, + reviewed: reviewedFiles.has(path), }; }), - [files, threadsByPath], + [files, threadsByPath, reviewedFiles], ); // Scroll the active tab into view when it changes @@ -88,11 +91,17 @@ export function PrFileTabStrip({ "group relative flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors", isActive ? "bg-amber-500/10 text-amber-700 dark:text-amber-300" - : "text-muted-foreground hover:bg-muted/50 hover:text-foreground", + : entry.reviewed + ? "text-emerald-600/70 dark:text-emerald-400/70 hover:bg-muted/50 hover:text-foreground" + : "text-muted-foreground hover:bg-muted/50 hover:text-foreground", )} type="button" > - + {entry.reviewed ? ( + + ) : ( + + )} {entry.basename} ( + reviewedFilesKey, + [], + REVIEWED_FILES_SCHEMA, + ); // --- Collapsible panel state --- const [leftRailCollapsed, setLeftRailCollapsed] = useLocalStorage( @@ -382,7 +389,7 @@ export function PrReviewShell({ return ( <> {/* Main content area — flexbox layout with collapsible panels */} -
+
{/* Left rail — collapsible */} { + setReviewedFiles((prev) => { + const set = new Set(prev); + if (set.has(path)) set.delete(path); + else set.add(path); + return [...set]; + }); + }} patch={patchQuery.data?.combinedPatch ?? null} project={project} + reviewedFiles={reviewedFiles} selectedFilePath={selectedFilePath} selectedThreadId={selectedThreadId} /> diff --git a/apps/web/src/components/pr-review/PrWorkspace.tsx b/apps/web/src/components/pr-review/PrWorkspace.tsx index 2b0329e3e..ebc1bfcac 100644 --- a/apps/web/src/components/pr-review/PrWorkspace.tsx +++ b/apps/web/src/components/pr-review/PrWorkspace.tsx @@ -3,7 +3,9 @@ import type { NativeApi, PrReviewThread } from "@okcode/contracts"; import { useMemo } from "react"; import { Schema } from "effect"; import { + CheckCircle2Icon, ChevronRightIcon, + CircleIcon, ExternalLinkIcon, FileCode2Icon, GitBranchIcon, @@ -38,18 +40,22 @@ export function PrWorkspace({ dashboard, selectedFilePath, selectedThreadId, + reviewedFiles, onSelectFilePath, onSelectThreadId, onCreateThread, + onToggleFileReviewed, }: { project: Project; patch: string | null; dashboard: Awaited> | null | undefined; selectedFilePath: string | null; selectedThreadId: string | null; + reviewedFiles: readonly string[]; onSelectFilePath: (path: string) => void; onSelectThreadId: (threadId: string | null) => void; onCreateThread: (input: { path: string; line: number; body: string }) => Promise; + onToggleFileReviewed: (path: string) => void; }) { const { resolvedTheme } = useTheme(); const openFileInCodeViewer = useFileViewNavigation(); @@ -59,6 +65,8 @@ export function PrWorkspace({ FILE_VIEW_MODE_SCHEMA, ); + const reviewedFilesSet = useMemo(() => new Set(reviewedFiles), [reviewedFiles]); + const renderablePatch = useMemo( () => parseRenderablePatch( @@ -123,6 +131,31 @@ export function PrWorkspace({ {dashboard.files.length} + {patchFiles.length > 0 + ? (() => { + const reviewedCount = patchFiles.filter((f) => + reviewedFilesSet.has(resolveFileDiffPath(f)), + ).length; + return ( + <> + + · + + = patchFiles.length + ? "text-emerald-600 dark:text-emerald-400" + : "text-muted-foreground", + )} + > + + {reviewedCount}/{patchFiles.length} viewed + + + ); + })() + : null}