diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 50df89109e0559304cec5e6655f0cc1fd43b4e71..975f493c28f3713a85d7599f268a2e83d908324e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -21,7 +21,7 @@ import { import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { useFileContextMenuHandler } from "../fileContextMenu"; @@ -118,6 +118,74 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); +/** Collapse control for one file header; re-renders only when its own file changes. */ +function DiffFileCollapseToggle({ + filePath, + fileKey, + collapsed, + unavailable, + iconClassName, + onToggle, +}: { + filePath: string; + fileKey: string; + collapsed: boolean; + unavailable: boolean; + iconClassName: string; + onToggle: (fileKey: string) => void; +}) { + return ( + + { + event.stopPropagation(); + onToggle(fileKey); + }} + /> + } + > + + + {collapsed ? "Expand diff" : "Collapse diff"} + + ); +} + +/** Copy and status controls for one file header; re-renders only when its own file changes. */ +function DiffFileHeaderSuffix({ + filePath, + hasStat, + error, + truncated, + onRetry, +}: { + filePath: string; + hasStat: boolean; + error: boolean; + truncated: boolean; + onRetry: (path: string) => void; +}) { + return ( + <> + + {hasStat ? ( + onRetry(filePath)} /> + ) : null} + + ); +} + interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; @@ -598,22 +666,24 @@ export default function DiffPanel({ }, [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], ); - const toggleDiffFileCollapsed = useCallback( - (fileKey: string) => { - setCollapsedDiffFiles((current) => { - const next = new Set( - current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, - ); - if (next.has(fileKey)) { - next.delete(fileKey); - } else { - next.add(fileKey); - } - return { scopeKey: collapseScopeKey, fileKeys: next }; - }); - }, - [collapseScopeKey, defaultCollapsedDiffFileKeys], - ); + const collapseDefaultsRef = useRef({ collapseScopeKey, defaultCollapsedDiffFileKeys }); + useLayoutEffect(() => { + collapseDefaultsRef.current = { collapseScopeKey, defaultCollapsedDiffFileKeys }; + }, [collapseScopeKey, defaultCollapsedDiffFileKeys]); + const toggleDiffFileCollapsed = useCallback((fileKey: string) => { + const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current; + setCollapsedDiffFiles((current) => { + const next = new Set( + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, + ); + if (next.has(fileKey)) { + next.delete(fileKey); + } else { + next.add(fileKey); + } + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, []); const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); @@ -1082,14 +1152,15 @@ export default function DiffPanel({ composerDraftTarget={composerDraftTarget} renderHeaderFilenameSuffix={(fileDiff) => { const path = resolveFileDiffPath(fileDiff); - const stat = fileStats.get(path); + const state = fileStates.get(path); return ( - <> - - {stat ? ( - retry(path)} /> - ) : null} - + ); }} {...(lazySource @@ -1109,37 +1180,15 @@ export default function DiffPanel({ : {})} renderHeaderPrefix={(fileDiff, fileKey) => { const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true; - const collapsed = unavailable || collapsedDiffFileKeys.has(fileKey); - const filePath = resolveFileDiffPath(fileDiff); return ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - - - - {collapsed ? "Expand diff" : "Collapse diff"} - - + ); }} options={{ diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 253a0f3d215efc864e8acfa6208ae50e30933f26..52b538f92b0fc0b8242d1d80d4b4084cb4a029dc 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -11,7 +11,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { fnv1a32 } from "~/lib/diffRendering"; +import { fnv1a32, resolveFileDiffPath } from "~/lib/diffRendering"; import { buildDiffReviewComment, restoreDiffReviewCommentRange, @@ -217,15 +217,15 @@ export function AnnotatableCodeView({ if (!range) return; const item = context.item; if (item.type !== "diff") return; - const file = filesByKey.get(item.id); - if (!file) return; + // Read from the item, not the file list, so this callback keeps its identity as + // patches arrive; the viewer re-applies its options whenever it changes. const id = nextFileCommentId(); const comment = buildDiffReviewComment({ id, sectionId, sectionTitle, - filePath: file.filePath, - fileDiff: file.fileDiff, + filePath: resolveFileDiffPath(item.fileDiff), + fileDiff: item.fileDiff, range, text: "", }); @@ -242,7 +242,7 @@ export function AnnotatableCodeView({ }, }); }, - [filesByKey, sectionId, sectionTitle], + [sectionId, sectionTitle], ); const hasOpenComment = draft !== null; diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index 60536fdd5d7938806a05421ace22fa7650b3903f..ad36f75c595c428628ccac021cce121ef785f6c0 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -3,7 +3,15 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import type { EnvironmentId, ReviewDiffPreviewSource } from "@t3tools/contracts"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering"; import { reviewEnvironment } from "~/state/review"; @@ -146,12 +154,17 @@ export function useReviewFilePatches({ [requestFiles, settledFileCount], ); const requestFile = useCallback((index: number) => requestFiles([index]), [requestFiles]); + const retryInputsRef = useRef({ queries, files }); + useLayoutEffect(() => { + retryInputsRef.current = { queries, files }; + }, [queries, files]); const retry = useCallback( (path: string) => { + const { queries, files } = retryInputsRef.current; const query = queries.find(({ index }) => files[index]?.path === path)?.query; if (query) registry.refresh(query); }, - [queries, files, registry], + [registry], ); const renderableFiles = useMemo( () => @@ -185,23 +198,27 @@ export function useReviewFilePatches({ ), [source, files, patches, scope, preview], ); - const fileStates = new Map( - files.map((file, index) => { - const patch = patches.get(index); - return [ - file.path, - { - error: - patch?._tag === "Failure" || - (patch?._tag === "Success" && - (patch.value.patch?.kind !== "files" || - !patch.value.patch.files.some( - (candidate) => resolveFileDiffPath(candidate) === file.path, - ))), - truncated: patch?._tag === "Success" && patch.value.source.truncated, - }, - ] as const; - }), + const fileStates = useMemo( + () => + new Map( + files.map((file, index) => { + const patch = patches.get(index); + return [ + file.path, + { + error: + patch?._tag === "Failure" || + (patch?._tag === "Success" && + (patch.value.patch?.kind !== "files" || + !patch.value.patch.files.some( + (candidate) => resolveFileDiffPath(candidate) === file.path, + ))), + truncated: patch?._tag === "Success" && patch.value.source.truncated, + }, + ] as const; + }), + ), + [files, patches], ); const readyFilePaths = useMemo( () =>