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
107 changes: 79 additions & 28 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className={cn(
"inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
getDiffCollapseIconClassName(fileDiff),
)}
aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
aria-expanded={!collapsed}
onClick={(event) => {
event.stopPropagation();
toggleDiffFileCollapsed(fileKey);
}}
/>
}
>
{collapsed ? (
<ChevronRightIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipPopup side="top">
{collapsed ? "Expand diff" : "Collapse diff"}
</TooltipPopup>
</Tooltip>
<>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className={cn(
"inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
getDiffCollapseIconClassName(fileDiff),
)}
aria-label={
collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
}
aria-expanded={!collapsed}
onClick={(event) => {
event.stopPropagation();
toggleDiffFileCollapsed(fileKey);
}}
/>
}
>
{collapsed ? (
<ChevronRightIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipPopup side="top">
{collapsed ? "Expand diff" : "Collapse diff"}
</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Checkbox
checked={viewed}
className="shrink-0"
aria-label={
viewed
? `Mark ${filePath} as not viewed`
: `Mark ${filePath} as viewed`
}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggleFileViewed(fileKey)}
/>
}
/>
<TooltipPopup side="top">
{viewed ? "Mark as not viewed" : "Mark as viewed"}
</TooltipPopup>
</Tooltip>
</>
);
}}
options={{
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/diffs/AnnotatableCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,16 @@ interface AnnotatableCodeViewProps {
options: NonNullable<CodeViewProps<DiffCommentAnnotationGroup>["options"]>;
viewerRef?: Ref<AnnotatableCodeViewHandle>;
className?: string;
viewedFileKeys?: ReadonlySet<string>;
renderHeaderPrefix: (
fileDiff: FileDiffMetadata,
fileKey: string,
collapsed: boolean,
) => ReactNode;
}

const EMPTY_VIEWED_FILE_KEYS: ReadonlySet<string> = new Set();

interface DiffSelectionContext {
item: CodeViewItem<DiffCommentAnnotationGroup>;
}
Expand All @@ -102,6 +105,7 @@ export function AnnotatableCodeView({
options,
viewerRef,
className,
viewedFileKeys = EMPTY_VIEWED_FILE_KEYS,
renderHeaderPrefix,
}: AnnotatableCodeViewProps) {
const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
Expand Down Expand Up @@ -142,14 +146,15 @@ export function AnnotatableCodeView({
}, []);
const annotations =
draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted;
const viewed = viewedFileKeys.has(fileKey);
return {
id: fileKey,
type: "diff",
fileDiff,
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}`,
Expand All @@ -159,7 +164,7 @@ export function AnnotatableCodeView({
),
};
}),
[draft, files, reviewComments, sectionId],
[draft, files, reviewComments, sectionId, viewedFileKeys],
);

const removeEntry = useCallback(
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/diffViewedStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;
toggleFileViewed: (scopeKey: string, fileKey: string) => void;
setFileViewed: (scopeKey: string, fileKey: string, viewed: boolean) => void;
clearScope: (scopeKey: string) => void;
}

const EMPTY_VIEWED_FILE_KEYS: ReadonlyArray<string> = [];

export const useDiffViewedStore = create<DiffViewedStoreState>()(
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<string, string[]>,
scopeKey: string | null,
): ReadonlyArray<string> {
if (scopeKey === null) return EMPTY_VIEWED_FILE_KEYS;
return viewedByScope[scopeKey] ?? EMPTY_VIEWED_FILE_KEYS;
}