diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d10cb39f0e36..62f187862235 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -26,6 +26,7 @@ import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { cn } from "~/lib/utils"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; +import { selectViewedFileKeys, useDiffViewedStore } from "../diffViewedStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, @@ -47,6 +48,7 @@ import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/Ann import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; +import { Checkbox } from "./ui/checkbox"; import { Combobox, ComboboxEmpty, @@ -302,6 +304,10 @@ export default function DiffPanel({ collapsedDiffFiles.scopeKey === collapseScopeKey ? collapsedDiffFiles.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; + const viewedFileKeys = useDiffViewedStore((state) => + selectViewedFileKeys(state.viewedByScope, collapseScopeKey), + ); + const viewedFileKeySet = useMemo(() => new Set(viewedFileKeys), [viewedFileKeys]); const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` : selectedGitScope === "unstaged" @@ -505,6 +511,25 @@ export default function DiffPanel({ [collapseScopeKey], ); + const toggleFileViewed = useCallback( + (fileKey: string) => { + if (!collapseScopeKey) return; + const nowViewed = !viewedFileKeySet.has(fileKey); + useDiffViewedStore.getState().toggleFileViewed(collapseScopeKey, fileKey); + // Collapse a file when it is marked viewed, and expand it when unmarked. + setCollapsedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + if (nowViewed) { + next.add(fileKey); + } else { + next.delete(fileKey); + } + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, + [collapseScopeKey, viewedFileKeySet], + ); + const toggleDiffFileCollapse = useCallback(() => { setCollapsedDiffFiles((current) => { const currentKeys = @@ -882,37 +907,63 @@ export default function DiffPanel({ sectionId={reviewSectionId} sectionTitle={reviewSectionTitle} composerDraftTarget={composerDraftTarget} + viewedFileKeys={viewedFileKeySet} renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { const filePath = resolveFileDiffPath(fileDiff); + const viewed = viewedFileKeySet.has(fileKey); return ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - - )} - - - {collapsed ? "Expand diff" : "Collapse diff"} - - + <> + + { + event.stopPropagation(); + toggleDiffFileCollapsed(fileKey); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + + event.stopPropagation()} + onCheckedChange={() => toggleFileViewed(fileKey)} + /> + } + /> + + {viewed ? "Mark as not viewed" : "Mark as viewed"} + + + ); }} options={{ diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 6cea64fb5702..9f90e7cdbda7 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -83,6 +83,7 @@ interface AnnotatableCodeViewProps { options: NonNullable["options"]>; viewerRef?: Ref; className?: string; + viewedFileKeys?: ReadonlySet; renderHeaderPrefix: ( fileDiff: FileDiffMetadata, fileKey: string, @@ -90,6 +91,8 @@ interface AnnotatableCodeViewProps { ) => ReactNode; } +const EMPTY_VIEWED_FILE_KEYS: ReadonlySet = new Set(); + interface DiffSelectionContext { item: CodeViewItem; } @@ -102,6 +105,7 @@ export function AnnotatableCodeView({ options, viewerRef, className, + viewedFileKeys = EMPTY_VIEWED_FILE_KEYS, renderHeaderPrefix, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); @@ -142,6 +146,7 @@ export function AnnotatableCodeView({ }, []); const annotations = draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted; + const viewed = viewedFileKeys.has(fileKey); return { id: fileKey, type: "diff", @@ -149,7 +154,7 @@ export function AnnotatableCodeView({ annotations, collapsed, version: fnv1a32( - `${collapsed ? "1" : "0"}:${annotations + `${collapsed ? "1" : "0"}:${viewed ? "1" : "0"}:${annotations .flatMap((annotation) => annotation.metadata.entries.map( (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`, @@ -159,7 +164,7 @@ export function AnnotatableCodeView({ ), }; }), - [draft, files, reviewComments, sectionId], + [draft, files, reviewComments, sectionId, viewedFileKeys], ); const removeEntry = useCallback( diff --git a/apps/web/src/diffViewedStore.ts b/apps/web/src/diffViewedStore.ts new file mode 100644 index 000000000000..7b4f4be6eded --- /dev/null +++ b/apps/web/src/diffViewedStore.ts @@ -0,0 +1,62 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +interface DiffViewedStoreState { + /** Maps a diff scope key to the set of file keys marked as viewed. */ + viewedByScope: Record; + toggleFileViewed: (scopeKey: string, fileKey: string) => void; + setFileViewed: (scopeKey: string, fileKey: string, viewed: boolean) => void; + clearScope: (scopeKey: string) => void; +} + +const EMPTY_VIEWED_FILE_KEYS: ReadonlyArray = []; + +export const useDiffViewedStore = create()( + persist( + (set) => ({ + viewedByScope: {}, + toggleFileViewed: (scopeKey, fileKey) => + set((state) => { + const current = state.viewedByScope[scopeKey] ?? EMPTY_VIEWED_FILE_KEYS; + const next = current.includes(fileKey) + ? current.filter((key) => key !== fileKey) + : [...current, fileKey]; + return { viewedByScope: { ...state.viewedByScope, [scopeKey]: next } }; + }), + setFileViewed: (scopeKey, fileKey, viewed) => + set((state) => { + const current = state.viewedByScope[scopeKey] ?? EMPTY_VIEWED_FILE_KEYS; + const alreadyViewed = current.includes(fileKey); + if (viewed === alreadyViewed) return state; + const next = viewed + ? [...current, fileKey] + : current.filter((key) => key !== fileKey); + return { viewedByScope: { ...state.viewedByScope, [scopeKey]: next } }; + }), + clearScope: (scopeKey) => + set((state) => { + if (!(scopeKey in state.viewedByScope)) return state; + const { [scopeKey]: _removed, ...viewedByScope } = state.viewedByScope; + return { viewedByScope }; + }), + }), + { + name: "t3code:diff-viewed-state:v1", + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ viewedByScope: state.viewedByScope }), + }, + ), +); + +export function selectViewedFileKeys( + viewedByScope: Record, + scopeKey: string | null, +): ReadonlyArray { + if (scopeKey === null) return EMPTY_VIEWED_FILE_KEYS; + return viewedByScope[scopeKey] ?? EMPTY_VIEWED_FILE_KEYS; +}