Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions apps/server/src/prReview/Layers/PrReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/components/pr-review/PrFileTabStrip.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,13 +17,15 @@ export function PrFileTabStrip({
files,
threads,
selectedFilePath,
reviewedFiles,
onSelectFilePath,
fileViewMode,
onFileViewModeChange,
}: {
files: FileDiffMetadata[];
threads: readonly PrReviewThread[];
selectedFilePath: string | null;
reviewedFiles: ReadonlySet<string>;
onSelectFilePath: (path: string) => void;
fileViewMode: FileViewMode;
onFileViewModeChange: (mode: FileViewMode) => void;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
>
<FileIcon className="size-3 shrink-0 opacity-60" />
{entry.reviewed ? (
<CheckCircle2Icon className="size-3 shrink-0 text-emerald-500" />
) : (
<FileIcon className="size-3 shrink-0 opacity-60" />
)}
<span className="truncate max-w-[120px]">{entry.basename}</span>
<span
className={cn(
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/components/pr-review/PrReviewShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { PrInspectorPanel } from "./PrInspectorPanel";
import { PrMentionComposer } from "./PrMentionComposer";
import {
type PullRequestState,
REVIEWED_FILES_SCHEMA,
TEXT_DRAFT_SCHEMA,
requiredChecksState,
openPathInEditor,
Expand Down Expand Up @@ -76,6 +77,12 @@ export function PrReviewShell({
const deferredSearchQuery = useDeferredValue(searchQuery);
const reviewDraftKey = `okcode:pr-review:review-draft:${project.id}:${selectedPrNumber ?? "none"}`;
const [reviewBody, setReviewBody] = useLocalStorage(reviewDraftKey, "", TEXT_DRAFT_SCHEMA);
const reviewedFilesKey = `okcode:pr-review:reviewed-files:${project.id}:${selectedPrNumber ?? "none"}`;
const [reviewedFiles, setReviewedFiles] = useLocalStorage<readonly string[], unknown>(
reviewedFilesKey,
[],
REVIEWED_FILES_SCHEMA,
);

// --- Collapsible panel state ---
const [leftRailCollapsed, setLeftRailCollapsed] = useLocalStorage(
Expand Down Expand Up @@ -382,7 +389,7 @@ export function PrReviewShell({
return (
<>
{/* Main content area — flexbox layout with collapsible panels */}
<div className="flex min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Left rail — collapsible */}
<PrListRail
collapsed={leftRailCollapsed}
Expand Down Expand Up @@ -430,8 +437,17 @@ export function PrReviewShell({
setInspectorCollapsed(false);
}
}}
onToggleFileReviewed={(path) => {
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}
/>
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/components/pr-review/PrWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -38,18 +40,22 @@ export function PrWorkspace({
dashboard,
selectedFilePath,
selectedThreadId,
reviewedFiles,
onSelectFilePath,
onSelectThreadId,
onCreateThread,
onToggleFileReviewed,
}: {
project: Project;
patch: string | null;
dashboard: Awaited<ReturnType<NativeApi["prReview"]["getDashboard"]>> | 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<void>;
onToggleFileReviewed: (path: string) => void;
}) {
const { resolvedTheme } = useTheme();
const openFileInCodeViewer = useFileViewNavigation();
Expand All @@ -59,6 +65,8 @@ export function PrWorkspace({
FILE_VIEW_MODE_SCHEMA,
);

const reviewedFilesSet = useMemo(() => new Set(reviewedFiles), [reviewedFiles]);

const renderablePatch = useMemo(
() =>
parseRenderablePatch(
Expand Down Expand Up @@ -123,6 +131,31 @@ export function PrWorkspace({
<FileCode2Icon className="size-3" />
{dashboard.files.length}
</span>
{patchFiles.length > 0
? (() => {
const reviewedCount = patchFiles.filter((f) =>
reviewedFilesSet.has(resolveFileDiffPath(f)),
).length;
return (
<>
<span className="hidden shrink-0 text-muted-foreground/50 sm:inline">
&middot;
</span>
<span
className={cn(
"hidden shrink-0 items-center gap-1.5 text-xs font-medium sm:flex",
reviewedCount >= patchFiles.length
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
<CheckCircle2Icon className="size-3" />
{reviewedCount}/{patchFiles.length} viewed
</span>
</>
);
})()
: null}
</div>
<Button
onClick={() => {
Expand All @@ -142,6 +175,7 @@ export function PrWorkspace({
files={patchFiles}
threads={dashboard.threads}
selectedFilePath={selectedFilePath}
reviewedFiles={reviewedFilesSet}
onSelectFilePath={(path) => {
onSelectFilePath(path);
// In all-files mode, scroll to the file
Expand Down Expand Up @@ -171,6 +205,7 @@ export function PrWorkspace({
const fileKey = `${buildFileDiffRenderKey(fileDiff)}:${resolvedTheme}`;
const fileThreads = threadsByPath[filePath] ?? [];
const isSelected = selectedFilePath === filePath;
const isReviewed = reviewedFilesSet.has(filePath);
const firstCommentLine = fileThreads[0]?.line ?? 1;
const stats = summarizeFileDiffStats(fileDiff);
return (
Expand Down Expand Up @@ -219,6 +254,27 @@ export function PrWorkspace({
+{fileThreads.length - 2} more
</span>
) : null}
<button
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors",
isReviewed
? "border-emerald-500/20 bg-emerald-500/8 text-emerald-600 dark:text-emerald-300"
: "border-border/70 bg-background text-muted-foreground hover:bg-muted/40",
)}
onClick={(e) => {
e.stopPropagation();
onToggleFileReviewed(filePath);
}}
type="button"
title={isReviewed ? "Mark as unreviewed" : "Mark as reviewed"}
>
{isReviewed ? (
<CheckCircle2Icon className="size-3" />
) : (
<CircleIcon className="size-3" />
)}
Viewed
</button>
<Button
onClick={() => {
openFileInCodeViewer(project.cwd, filePath);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/pr-review/pr-review-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type RenderablePatch =
| { kind: "raw"; text: string; reason: string };

export const TEXT_DRAFT_SCHEMA = Schema.String;
export const REVIEWED_FILES_SCHEMA = Schema.Array(Schema.String);

export const PR_REVIEW_DIFF_UNSAFE_CSS = `
[data-diff],
Expand Down
Loading