diff --git a/packages/core/src/archive/archiveOrchestration.test.ts b/packages/core/src/archive/archiveOrchestration.test.ts index f95370da1c..334d330d6a 100644 --- a/packages/core/src/archive/archiveOrchestration.test.ts +++ b/packages/core/src/archive/archiveOrchestration.test.ts @@ -30,6 +30,7 @@ class Harness { disableFocus: vi.fn().mockResolvedValue(undefined), disconnectFromTask: vi.fn().mockResolvedValue(undefined), archive: vi.fn().mockResolvedValue(undefined), + clearReadState: vi.fn(), logError: vi.fn(), cache: { cancelPathFilter: vi.fn().mockResolvedValue(undefined), @@ -60,10 +61,19 @@ describe("archiveTask", () => { expect(harness.deps.archive).toHaveBeenCalledWith(TASK_ID); expect(harness.deps.disconnectFromTask).toHaveBeenCalledWith(TASK_ID); + expect(harness.deps.clearReadState).toHaveBeenCalledWith(TASK_ID); expect(harness.ids).toContain(TASK_ID); expect(harness.list.some((a) => a.taskId === TASK_ID)).toBe(true); }); + it("does not clear read state when the archive request fails", async () => { + harness.deps.archive = vi.fn().mockRejectedValue(new Error("boom")); + + await expect(archiveTask(TASK_ID, harness.deps)).rejects.toThrow("boom"); + + expect(harness.deps.clearReadState).not.toHaveBeenCalled(); + }); + it("with optimistic:false, defers cache writes until archive resolves", async () => { let idsWhenArchiveCalled: string[] = ["sentinel"]; harness.deps.archive = vi.fn().mockImplementation(async () => { diff --git a/packages/core/src/archive/archiveOrchestration.ts b/packages/core/src/archive/archiveOrchestration.ts index a8d8406c41..0cb59c20a9 100644 --- a/packages/core/src/archive/archiveOrchestration.ts +++ b/packages/core/src/archive/archiveOrchestration.ts @@ -40,6 +40,7 @@ export interface ArchiveOrchestrationDeps { disableFocus(): Promise; disconnectFromTask(taskId: string): Promise; archive(taskId: string): Promise; + clearReadState(taskId: string): void; logError(message: string, error: unknown): void; cache: ArchiveCacheWriter; } @@ -101,6 +102,9 @@ export async function archiveTask( try { await deps.disconnectFromTask(taskId); await deps.archive(taskId); + // Read state is per-task review convenience; an archived task won't be + // re-reviewed, so drop it once the archive is confirmed. + deps.clearReadState(taskId); // Non-optimistic flows keep the row visible during the request, then remove // it the moment the archive succeeds. if (!optimistic) { diff --git a/packages/ui/src/features/archive/useArchiveTask.ts b/packages/ui/src/features/archive/useArchiveTask.ts index fabafdcc7b..5d16bd5f10 100644 --- a/packages/ui/src/features/archive/useArchiveTask.ts +++ b/packages/ui/src/features/archive/useArchiveTask.ts @@ -16,6 +16,7 @@ import { type HostTrpcClient, } from "@posthog/host-router/client"; import { useHostTRPC } from "@posthog/host-router/react"; +import { useReviewViewedStore } from "@posthog/ui/features/code-review/reviewViewedStore"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFocusStore } from "@posthog/ui/features/focus/focusStore"; import { pinnedTasksApi } from "@posthog/ui/features/sidebar/taskMetaApi"; @@ -138,6 +139,8 @@ function makeOrchestrationDeps( ), archive: (taskId) => hostClient.archive.archive.mutate({ taskId }).then(() => undefined), + clearReadState: (taskId) => + useReviewViewedStore.getState().clearTasks([taskId]), logError: (message, error) => log.error(message, error), cache: makeCacheWriter(queryClient, keys), }; diff --git a/packages/ui/src/features/code-review/components/CloudReviewPage.tsx b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx index 5f92127f31..f1460600f2 100644 --- a/packages/ui/src/features/code-review/components/CloudReviewPage.tsx +++ b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx @@ -15,6 +15,7 @@ import { ReviewShell, useReviewState, } from "./ReviewShell"; +import { changedFileSignature } from "./reviewItemBuilders"; interface CloudReviewPageProps { task: Task; @@ -50,7 +51,10 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { expandAll, collapseAll, uncollapseFile, - } = useReviewState(reviewFiles, allPaths); + collapseFiles, + viewedRecord, + toggleViewed, + } = useReviewState(reviewFiles, allPaths, taskId); const toolCallFallbacks = useMemo( () => @@ -70,6 +74,7 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { return { key: file.path, scrollKey: file.path, + sig: changedFileSignature(file), node: ( ), }; @@ -130,8 +136,11 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { onExpandAll={expandAll} onCollapseAll={collapseAll} onUncollapseFile={uncollapseFile} + onCollapseFiles={collapseFiles} items={items} itemIndexByFilePath={itemIndexByFilePath} + viewedRecord={viewedRecord} + onToggleViewed={toggleViewed} /> ); } diff --git a/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx b/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx index cb93df86c6..4d60953ce1 100644 --- a/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx +++ b/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx @@ -16,6 +16,7 @@ interface PatchedFileDiffProps { externalUrl?: string; prUrl?: string | null; commentThreads?: Map; + viewedKey?: string; } export function PatchedFileDiff({ @@ -28,6 +29,7 @@ export function PatchedFileDiff({ externalUrl, prUrl, commentThreads, + viewedKey, }: PatchedFileDiffProps) { const fileDiff = useMemo((): FileDiffMetadata | undefined => { if (!file.patch) return undefined; @@ -56,6 +58,7 @@ export function PatchedFileDiff({ collapsed={collapsed} onToggle={onToggle} externalUrl={externalUrl} + viewedKey={viewedKey} /> ); } @@ -72,6 +75,7 @@ export function PatchedFileDiff({ fileDiff={fd} collapsed={collapsed} onToggle={onToggle} + viewedKey={viewedKey} /> )} /> diff --git a/packages/ui/src/features/code-review/components/ReviewPage.tsx b/packages/ui/src/features/code-review/components/ReviewPage.tsx index caacf02bd4..ab1ef7dc38 100644 --- a/packages/ui/src/features/code-review/components/ReviewPage.tsx +++ b/packages/ui/src/features/code-review/components/ReviewPage.tsx @@ -134,7 +134,10 @@ export function ReviewPage({ task }: ReviewPageProps) { expandAll, collapseAll, uncollapseFile, - } = useReviewState(changedFiles, allPaths); + collapseFiles, + viewedRecord, + toggleViewed, + } = useReviewState(changedFiles, allPaths, taskId); const stagedPathSet = useMemo( () => new Set(stagedParsedFiles.map((f) => f.name ?? f.prevName ?? "")), @@ -186,6 +189,9 @@ export function ReviewPage({ task }: ReviewPageProps) { expandAll={expandAll} collapseAll={collapseAll} uncollapseFile={uncollapseFile} + collapseFiles={collapseFiles} + viewedRecord={viewedRecord} + toggleViewed={toggleViewed} refetch={refetch} hasStagedFiles={hasStagedFiles} stagedParsedFiles={stagedParsedFiles} @@ -218,6 +224,9 @@ function LocalReviewContent({ expandAll, collapseAll, uncollapseFile, + collapseFiles, + viewedRecord, + toggleViewed, refetch, hasStagedFiles, stagedParsedFiles, @@ -246,6 +255,9 @@ function LocalReviewContent({ expandAll: () => void; collapseAll: () => void; uncollapseFile: (filePath: string) => void; + collapseFiles: (keys: string[]) => void; + viewedRecord: Record; + toggleViewed: (key: string, sig: string | null) => void; refetch: () => void; hasStagedFiles: boolean; stagedParsedFiles: ReturnType[number]["files"]; @@ -350,6 +362,7 @@ function LocalReviewContent({ onExpandAll={expandAll} onCollapseAll={collapseAll} onUncollapseFile={uncollapseFile} + onCollapseFiles={collapseFiles} onRefresh={refetch} effectiveSource={effectiveSource} branchSourceAvailable={branchSourceAvailable} @@ -357,6 +370,8 @@ function LocalReviewContent({ defaultBranch={defaultBranch} items={items} itemIndexByFilePath={itemIndexByFilePath} + viewedRecord={viewedRecord} + onToggleViewed={toggleViewed} /> ); } @@ -401,7 +416,7 @@ function RemoteReviewPage({ : prLoading && files.length === 0; const allPaths = useMemo(() => files.map((f) => f.path), [files]); - const reviewState = useReviewState(files, allPaths); + const reviewState = useReviewState(files, allPaths, taskId); const items = useMemo( () => @@ -438,12 +453,15 @@ function RemoteReviewPage({ onExpandAll={reviewState.expandAll} onCollapseAll={reviewState.collapseAll} onUncollapseFile={reviewState.uncollapseFile} + onCollapseFiles={reviewState.collapseFiles} effectiveSource={effectiveSource} branchSourceAvailable={branchSourceAvailable} prSourceAvailable={prSourceAvailable} defaultBranch={defaultBranch} items={items} itemIndexByFilePath={itemIndexByFilePath} + viewedRecord={reviewState.viewedRecord} + onToggleViewed={reviewState.toggleViewed} /> ); } diff --git a/packages/ui/src/features/code-review/components/ReviewRows.tsx b/packages/ui/src/features/code-review/components/ReviewRows.tsx index aa5b6e8b5f..34db2bee58 100644 --- a/packages/ui/src/features/code-review/components/ReviewRows.tsx +++ b/packages/ui/src/features/code-review/components/ReviewRows.tsx @@ -64,9 +64,10 @@ export const PatchRow = memo(function PatchRow({ collapsed={collapsed} onToggle={onToggle} onOpenFile={onOpenFile} + viewedKey={itemKey} /> ), - [collapsed, onToggle, onOpenFile], + [collapsed, onToggle, onOpenFile, itemKey], ); return ( ); }); @@ -152,6 +154,7 @@ export const RemoteRow = memo(function RemoteRow({ onToggle={onToggle} commentThreads={commentThreads} externalUrl={externalUrl} + viewedKey={file.path} /> ); }); @@ -163,6 +166,7 @@ function UntrackedFileDiff({ options, collapsed, onToggle, + viewedKey, }: { file: ChangedFile; repoPath: string; @@ -170,6 +174,7 @@ function UntrackedFileDiff({ options: DiffOptions; collapsed: boolean; onToggle: () => void; + viewedKey?: string; }) { const [containerRef, inView] = useInView({ rootMargin: REVIEW_PREFETCH_ROOT_MARGIN, @@ -211,6 +216,7 @@ function UntrackedFileDiff({ reason="line-limit" collapsed={collapsed} onToggle={onToggle} + viewedKey={viewedKey} /> ); } @@ -231,6 +237,7 @@ function UntrackedFileDiff({ fileDiff={fd} collapsed={collapsed} onToggle={onToggle} + viewedKey={viewedKey} /> )} /> @@ -242,6 +249,7 @@ function UntrackedFileDiff({ deletions={0} collapsed={collapsed} onToggle={onToggle} + viewedKey={viewedKey} /> )} diff --git a/packages/ui/src/features/code-review/components/ReviewShell.tsx b/packages/ui/src/features/code-review/components/ReviewShell.tsx index 66e1a4263e..2174c0d6db 100644 --- a/packages/ui/src/features/code-review/components/ReviewShell.tsx +++ b/packages/ui/src/features/code-review/components/ReviewShell.tsx @@ -1,8 +1,11 @@ import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { useService } from "@posthog/di/react"; import type { Task } from "@posthog/shared/domain-types"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { useCloudPrUrl } from "@posthog/ui/features/git-interaction/useCloudPrUrl"; +import { useTaskPrStatus } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { VList, type VListHandle } from "virtua"; import { REVIEW_LIST_BUFFER_PX, @@ -12,6 +15,9 @@ import { useReviewDraftsStore } from "../reviewDraftsStore"; import { REVIEW_HOST, type ReviewHost } from "../reviewHost"; import { useReviewNavigationStore } from "../reviewNavigationStore"; import type { ReviewListItem, ReviewShellProps } from "../reviewShellParts"; +import { isFileRead } from "../reviewShellParts"; +import { ReviewViewedContext } from "../reviewViewedContext"; +import { useReviewViewedStore } from "../reviewViewedStore"; import { PendingReviewBar } from "./PendingReviewBar"; import { ReviewToolbar } from "./ReviewToolbar"; @@ -103,7 +109,10 @@ export function ReviewShell({ isEmpty, items, itemIndexByFilePath, + viewedRecord, + onToggleViewed, onUncollapseFile, + onCollapseFiles, allExpanded, onExpandAll, onCollapseAll, @@ -127,6 +136,90 @@ export function ReviewShell({ ); const isExpanded = reviewMode === "expanded"; + // Rebuild the key->signature map from items, but keep the previous reference + // when its contents are unchanged. items get a new identity on every collapse + // toggle; returning a stable map keeps the review context value stable so + // toggling one file doesn't re-render every ViewedCheckbox via context. + const prevSignaturesRef = useRef>(new Map()); + const currentSignatures = useMemo(() => { + const map = new Map(); + for (const item of items) { + if (item.sig !== undefined) map.set(item.key, item.sig); + } + const prev = prevSignaturesRef.current; + let unchanged = prev.size === map.size; + if (unchanged) { + for (const [key, sig] of map) { + if (prev.get(key) !== sig) { + unchanged = false; + break; + } + } + } + if (unchanged) return prev; + prevSignaturesRef.current = map; + return map; + }, [items]); + + // Count files marked read at their current signature (changed files don't + // count as read). + const readCount = useMemo(() => { + let count = 0; + for (const [key, sig] of currentSignatures) { + if (isFileRead(viewedRecord[key], sig)) count++; + } + return count; + }, [currentSignatures, viewedRecord]); + + // When the panel first opens for a task, collapse files that are already read + // (mirrors GitHub). Runs once per task, once signatures have loaded, so it + // doesn't fight the user manually re-expanding a read file afterwards. Files + // that changed since being read stay expanded so the new diff is visible. + const seededTaskRef = useRef(null); + useEffect(() => { + if (seededTaskRef.current === taskId) return; + if (currentSignatures.size === 0) return; + seededTaskRef.current = taskId; + const readKeys: string[] = []; + for (const [key, sig] of currentSignatures) { + if (isFileRead(viewedRecord[key], sig)) readKeys.push(key); + } + if (readKeys.length > 0) onCollapseFiles(readKeys); + }, [taskId, currentSignatures, viewedRecord, onCollapseFiles]); + + const clearTasks = useReviewViewedStore((s) => s.clearTasks); + + // Drop persisted read state for archived tasks so it does not accumulate. + // Skip the task being reviewed: archiving it while its review is open must + // not wipe the read marks the user is actively working against. + const archivedTaskIds = useArchivedTaskIds(); + useEffect(() => { + const prunable = [...archivedTaskIds].filter((id) => id !== taskId); + if (prunable.length > 0) clearTasks(prunable); + }, [archivedTaskIds, clearTasks, taskId]); + + // Once the PR is merged the diff is settled, so read state is moot — drop it. + // Cloud tasks resolve their PR via cloudPrUrl, so pass it (and the run + // environment) through or merge detection never fires for them. + const cloudPrUrl = useCloudPrUrl(taskId); + const { prState } = useTaskPrStatus({ + id: taskId, + cloudPrUrl, + taskRunEnvironment: task.latest_run?.environment, + }); + useEffect(() => { + if (prState === "merged") clearTasks([taskId]); + }, [prState, taskId, clearTasks]); + + const viewedContextValue = useMemo( + () => ({ + viewedRecord, + currentSignatures, + toggleViewed: onToggleViewed, + }), + [viewedRecord, currentSignatures, onToggleViewed], + ); + const scrollRequest = useReviewNavigationStore( (s) => s.scrollRequests[taskId] ?? null, ); @@ -217,53 +310,64 @@ export function ReviewShell({ ], }} > - - - - - {isLoading ? ( - - - - ) : isEmpty ? ( - - - No file changes to review - - - ) : ( - - {renderItem} - - )} - - + + + + + + {isLoading ? ( + + + + ) : isEmpty ? ( + + + No file changes to review + + + ) : ( + + {renderItem} + + )} + + - {isExpanded && } + {isExpanded && } + - + ); } diff --git a/packages/ui/src/features/code-review/components/ReviewToolbar.tsx b/packages/ui/src/features/code-review/components/ReviewToolbar.tsx index 68c104a0b1..964867273b 100644 --- a/packages/ui/src/features/code-review/components/ReviewToolbar.tsx +++ b/packages/ui/src/features/code-review/components/ReviewToolbar.tsx @@ -16,6 +16,7 @@ import { DiffSourceSelector } from "./DiffSourceSelector"; interface ReviewToolbarProps { taskId: string; fileCount: number; + readCount: number; linesAdded: number; linesRemoved: number; allExpanded: boolean; @@ -31,6 +32,7 @@ interface ReviewToolbarProps { export const ReviewToolbar = memo(function ReviewToolbar({ taskId, fileCount, + readCount, allExpanded, onExpandAll, onCollapseAll, @@ -71,6 +73,11 @@ export const ReviewToolbar = memo(function ReviewToolbar({ {fileCount} file{fileCount !== 1 ? "s" : ""} changed + {fileCount > 0 && ( + + {readCount}/{fileCount} read + + )} {effectiveSource && ( ): ChangedFile => ({ + path: "a.ts", + status: "modified", + ...over, +}); + +describe("changedFileSignature", () => { + it("differs when the patch content differs", () => { + const a = changedFileSignature( + changedFile({ patch: "@@ -1 +1 @@\n-x\n+y" }), + ); + const b = changedFileSignature( + changedFile({ patch: "@@ -1 +1 @@\n-x\n+z" }), + ); + expect(a).not.toBe(b); + }); + + it("falls back to status + line counts when there is no patch", () => { + const a = changedFileSignature( + changedFile({ linesAdded: 1, linesRemoved: 2 }), + ); + const b = changedFileSignature( + changedFile({ linesAdded: 1, linesRemoved: 2 }), + ); + const c = changedFileSignature( + changedFile({ linesAdded: 3, linesRemoved: 2 }), + ); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); +}); + +describe("patchFileSignature", () => { + // biome-ignore lint/suspicious/noExplicitAny: minimal pierre FileDiff stub + const fileDiff = (over: Record): any => ({ + hunks: [], + ...over, + }); + + it("uses git blob object ids and ignores hunk content (whitespace-stable)", () => { + // Same blob ids, different parsed hunks (as the hide-whitespace toggle + // would produce) must yield the same signature. + const a = patchFileSignature( + fileDiff({ prevObjectId: "aaa", newObjectId: "bbb", hunks: [{ x: 1 }] }), + ); + const b = patchFileSignature( + fileDiff({ + prevObjectId: "aaa", + newObjectId: "bbb", + hunks: [{ x: 2, y: 3 }], + }), + ); + expect(a).toBe("aaa:bbb"); + expect(b).toBe("aaa:bbb"); + }); + + it("changes when the new blob id changes", () => { + const a = patchFileSignature( + fileDiff({ prevObjectId: "aaa", newObjectId: "bbb" }), + ); + const b = patchFileSignature( + fileDiff({ prevObjectId: "aaa", newObjectId: "ccc" }), + ); + expect(a).not.toBe(b); + }); + + it("falls back to hashing hunks when object ids are absent", () => { + const a = patchFileSignature(fileDiff({ hunks: [{ additionLines: 1 }] })); + const b = patchFileSignature(fileDiff({ hunks: [{ additionLines: 2 }] })); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/ui/src/features/code-review/components/reviewItemBuilders.tsx b/packages/ui/src/features/code-review/components/reviewItemBuilders.tsx index 3300530329..6641e8378d 100644 --- a/packages/ui/src/features/code-review/components/reviewItemBuilders.tsx +++ b/packages/ui/src/features/code-review/components/reviewItemBuilders.tsx @@ -1,4 +1,5 @@ import type { parsePatchFiles } from "@pierre/diffs"; +import { contentHash } from "@posthog/core/code-review/contentHash"; import { buildGithubFileUrl, computeSkipExpansion, @@ -10,6 +11,42 @@ import type { ReviewListItem } from "../reviewShellParts"; import type { DiffOptions } from "../types"; import { PatchRow, RemoteRow, UntrackedRow } from "./ReviewRows"; +// Signatures are cached by file-object identity. The file objects are stable +// across re-renders (only replaced when the underlying diff is refetched), so +// collapse toggles and other item rebuilds reuse the cached hash instead of +// re-hashing every file. +const signatureCache = new WeakMap(); + +// Prefer the unified patch (changes whenever upstream content does); fall back +// to status + line counts when no patch is available. +export function changedFileSignature(file: ChangedFile): string { + const cached = signatureCache.get(file); + if (cached !== undefined) return cached; + const sig = contentHash( + file.patch ?? + `${file.status}:${file.linesAdded ?? 0}:${file.linesRemoved ?? 0}`, + ); + signatureCache.set(file, sig); + return sig; +} + +export function patchFileSignature( + fileDiff: ReturnType[number]["files"][number], +): string { + const cached = signatureCache.get(fileDiff); + if (cached !== undefined) return cached; + // Prefer the git blob object ids from the patch `index` line: they identify + // file content directly and are unaffected by the hide-whitespace toggle + // (which re-fetches a different diff that would otherwise change a + // hunk-derived signature). Fall back to hunk geometry when absent. + const sig = + fileDiff.newObjectId || fileDiff.prevObjectId + ? `${fileDiff.prevObjectId ?? ""}:${fileDiff.newObjectId ?? ""}` + : contentHash(JSON.stringify(fileDiff.hunks ?? [])); + signatureCache.set(fileDiff, sig); + return sig; +} + interface BuildPatchReviewItemsArgs { files: ReturnType[number]["files"]; staged?: boolean; @@ -50,6 +87,7 @@ export function buildPatchReviewItems({ return { key, scrollKey: key, + sig: patchFileSignature(fileDiff), node: ( = {}; + +function useViewedState( + taskId: string, + setFileCollapsed: (filePath: string, collapsed: boolean) => void, +) { + const viewedRecord = + useReviewViewedStore((s) => s.viewed[taskId]) ?? EMPTY_VIEWED_RECORD; + const setViewed = useReviewViewedStore((s) => s.setViewed); + + // `nextSig` is the signature to store, or null to clear the read mark. + // Marking a file read collapses it; un-marking expands it (mirrors GitHub). + const toggleViewed = useCallback( + (key: string, nextSig: string | null) => { + setViewed(taskId, key, nextSig); + setFileCollapsed(key, nextSig !== null); + }, + [taskId, setViewed, setFileCollapsed], + ); - return { diffOptions, linesAdded, linesRemoved, ...collapseState }; + return { viewedRecord, toggleViewed }; } function useCollapseState(filePaths: string[]) { @@ -82,6 +120,33 @@ function useCollapseState(filePaths: string[]) { }); }, []); + const setFileCollapsed = useCallback( + (filePath: string, collapsed: boolean) => { + setCollapsedFiles((prev) => { + if (collapsed === prev.has(filePath)) return prev; + const next = new Set(prev); + if (collapsed) next.add(filePath); + else next.delete(filePath); + return next; + }); + }, + [], + ); + + const collapseFiles = useCallback((keys: Iterable) => { + setCollapsedFiles((prev) => { + let changed = false; + const next = new Set(prev); + for (const key of keys) { + if (!next.has(key)) { + next.add(key); + changed = true; + } + } + return changed ? next : prev; + }); + }, []); + const expandAll = useCallback(() => setCollapsedFiles(new Set()), []); const collapseAll = useCallback( @@ -93,6 +158,8 @@ function useCollapseState(filePaths: string[]) { collapsedFiles, toggleFile, uncollapseFile, + setFileCollapsed, + collapseFiles, expandAll, collapseAll, }; @@ -107,7 +174,10 @@ export interface ReviewShellProps { isEmpty: boolean; items: ReviewListItem[]; itemIndexByFilePath: Map; + viewedRecord: Record; + onToggleViewed: (key: string, sig: string | null) => void; onUncollapseFile?: (filePath: string) => void; + onCollapseFiles: (keys: string[]) => void; allExpanded: boolean; onExpandAll: () => void; onCollapseAll: () => void; @@ -121,6 +191,8 @@ export interface ReviewShellProps { export interface ReviewListItem { key: string; scrollKey?: string; + // Signature of the file's current diff; absent for non-file rows. + sig?: string; node: ReactNode; } @@ -132,6 +204,7 @@ export function FileHeaderRow({ collapsed, onToggle, trailing, + viewedKey, }: { dirPath: string; fileName: string; @@ -140,41 +213,101 @@ export function FileHeaderRow({ collapsed: boolean; onToggle: () => void; trailing?: ReactNode; + viewedKey?: string; }) { + return ( + // The toggle target is a button; the open-file / read controls sit + // alongside it (not nested inside it, which would be invalid HTML). +
+ + {trailing} + {viewedKey !== undefined && } +
+ ); +} + +// A file is read when its stored signature matches the current diff signature; +// a stored signature that no longer matches means the diff changed since. +export function isFileRead( + storedSig: string | undefined, + currentSig: string, +): boolean { + return storedSig === currentSig; +} + +function ViewedCheckbox({ viewedKey }: { viewedKey: string }) { + const ctx = useReviewViewedContext(); + if (!ctx) return null; + + const current = ctx.currentSignatures.get(viewedKey); + if (current === undefined) return null; + + const stored = ctx.viewedRecord[viewedKey]; + const read = isFileRead(stored, current); + const changed = stored !== undefined && !read; + return ( ); } @@ -184,11 +317,13 @@ export function DiffFileHeader({ collapsed, onToggle, onOpenFile, + viewedKey, }: { fileDiff: FileDiffMetadata; collapsed: boolean; onToggle: () => void; onOpenFile?: () => void; + viewedKey?: string; }) { const fullPath = fileDiff.prevName && fileDiff.prevName !== fileDiff.name @@ -205,6 +340,7 @@ export function DiffFileHeader({ deletions={deletions} collapsed={collapsed} onToggle={onToggle} + viewedKey={viewedKey} trailing={ onOpenFile && (