@@ -4,6 +4,8 @@ import { useQuery } from "@tanstack/react-query";
44import { useNavigate , useParams , useSearch } from "@tanstack/react-router" ;
55import { ThreadId , type TurnId } from "@okcode/contracts" ;
66import {
7+ CheckIcon ,
8+ ChevronDownIcon ,
79 ChevronLeftIcon ,
810 ChevronRightIcon ,
911 Columns2Icon ,
@@ -27,12 +29,21 @@ import { resolvePathLinkTarget } from "../terminal-links";
2729import { parseDiffRouteSearch , stripDiffSearchParams } from "../diffRouteSearch" ;
2830import { useTheme } from "../hooks/useTheme" ;
2931import { buildPatchCacheKey } from "../lib/diffRendering" ;
32+ import {
33+ expandDiffFile ,
34+ reconcileDiffFileReviewState ,
35+ toggleDiffFileAccepted ,
36+ toggleDiffFileCollapsed ,
37+ type DiffFileReviewStateByPath ,
38+ } from "../lib/diffFileReviewState" ;
3039import { resolveDiffThemeName } from "../lib/diffRendering" ;
3140import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries" ;
3241import { useStore } from "../store" ;
3342import { useAppSettings } from "../appSettings" ;
3443import { formatShortTimestamp } from "../timestampFormat" ;
3544import { DiffPanelLoadingState , DiffPanelShell , type DiffPanelMode } from "./DiffPanelShell" ;
45+ import { DiffStatLabel , hasNonZeroStat } from "./chat/DiffStatLabel" ;
46+ import { Button } from "./ui/button" ;
3647import { ToggleGroup , Toggle } from "./ui/toggle-group" ;
3748
3849type DiffRenderMode = "stacked" | "split" ;
@@ -157,6 +168,113 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
157168 return fileDiff . cacheKey ?? `${ fileDiff . prevName ?? "none" } :${ fileDiff . name } ` ;
158169}
159170
171+ function summarizeFileDiffStats ( fileDiff : FileDiffMetadata ) : {
172+ additions : number ;
173+ deletions : number ;
174+ } {
175+ return fileDiff . hunks . reduce (
176+ ( summary , hunk ) => ( {
177+ additions : summary . additions + hunk . additionLines ,
178+ deletions : summary . deletions + hunk . deletionLines ,
179+ } ) ,
180+ { additions : 0 , deletions : 0 } ,
181+ ) ;
182+ }
183+
184+ function DiffFileSection ( props : {
185+ fileDiff : FileDiffMetadata ;
186+ filePath : string ;
187+ fileKey : string ;
188+ diffRenderMode : DiffRenderMode ;
189+ diffWordWrap : boolean ;
190+ resolvedTheme : "light" | "dark" ;
191+ collapsed : boolean ;
192+ accepted : boolean ;
193+ onOpenInEditor : ( filePath : string ) => void ;
194+ onToggleCollapsed : ( filePath : string ) => void ;
195+ onToggleAccepted : ( filePath : string ) => void ;
196+ } ) {
197+ const {
198+ accepted,
199+ collapsed,
200+ diffRenderMode,
201+ diffWordWrap,
202+ fileDiff,
203+ fileKey,
204+ filePath,
205+ onOpenInEditor,
206+ onToggleAccepted,
207+ onToggleCollapsed,
208+ resolvedTheme,
209+ } = props ;
210+ const stats = summarizeFileDiffStats ( fileDiff ) ;
211+
212+ return (
213+ < section
214+ data-diff-file-path = { filePath }
215+ className = { cn (
216+ "diff-render-file mb-2 overflow-hidden rounded-md border border-border/70 bg-card/30 first:mt-2 last:mb-0" ,
217+ accepted && "border-success/40" ,
218+ ) }
219+ >
220+ < div className = "flex items-center gap-2 border-b border-border/60 bg-card/70 px-2 py-1.5" >
221+ < Button
222+ size = "icon-xs"
223+ variant = "ghost"
224+ aria-label = { collapsed ? `Expand ${ filePath } ` : `Collapse ${ filePath } ` }
225+ aria-expanded = { ! collapsed }
226+ onClick = { ( ) => onToggleCollapsed ( filePath ) }
227+ className = "text-muted-foreground/80"
228+ >
229+ < ChevronDownIcon
230+ className = { cn ( "size-3.5 transition-transform" , collapsed && "-rotate-90" ) }
231+ />
232+ </ Button >
233+ < button
234+ type = "button"
235+ className = "min-w-0 flex-1 truncate text-left font-mono text-[11px] text-foreground/90 underline-offset-2 hover:underline"
236+ onClick = { ( ) => onOpenInEditor ( filePath ) }
237+ title = { `Open ${ filePath } in editor` }
238+ >
239+ { filePath }
240+ </ button >
241+ { hasNonZeroStat ( stats ) && (
242+ < span className = "hidden shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground/80 sm:inline" >
243+ < DiffStatLabel additions = { stats . additions } deletions = { stats . deletions } />
244+ </ span >
245+ ) }
246+ < Button
247+ size = "xs"
248+ variant = { accepted ? "secondary" : "outline" }
249+ onClick = { ( ) => onToggleAccepted ( filePath ) }
250+ className = { cn (
251+ "gap-1.5" ,
252+ accepted && "border-success/30 bg-success/12 text-success hover:bg-success/18" ,
253+ ) }
254+ >
255+ < CheckIcon className = { cn ( "size-3.5" , accepted ? "opacity-100" : "opacity-35" ) } />
256+ { accepted ? "Accepted" : "Accept" }
257+ </ Button >
258+ </ div >
259+ { ! collapsed && (
260+ < div key = { fileKey } >
261+ < FileDiff
262+ fileDiff = { fileDiff }
263+ options = { {
264+ diffStyle : diffRenderMode === "split" ? "split" : "unified" ,
265+ lineDiffType : "none" ,
266+ overflow : diffWordWrap ? "wrap" : "scroll" ,
267+ theme : resolveDiffThemeName ( resolvedTheme ) ,
268+ themeType : resolvedTheme as DiffThemeType ,
269+ unsafeCSS : DIFF_PANEL_UNSAFE_CSS ,
270+ } }
271+ />
272+ </ div >
273+ ) }
274+ </ section >
275+ ) ;
276+ }
277+
160278interface DiffPanelProps {
161279 mode ?: DiffPanelMode ;
162280}
@@ -174,6 +292,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
174292 const previousDiffOpenRef = useRef ( false ) ;
175293 const [ canScrollTurnStripLeft , setCanScrollTurnStripLeft ] = useState ( false ) ;
176294 const [ canScrollTurnStripRight , setCanScrollTurnStripRight ] = useState ( false ) ;
295+ const [ reviewStateBySelectionKey , setReviewStateBySelectionKey ] = useState <
296+ Record < string , DiffFileReviewStateByPath >
297+ > ( { } ) ;
177298 const routeThreadId = useParams ( {
178299 strict : false ,
179300 select : ( params ) => ( params . threadId ? ThreadId . makeUnsafe ( params . threadId ) : null ) ,
@@ -301,6 +422,20 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
301422 } ) ,
302423 ) ;
303424 } , [ renderablePatch ] ) ;
425+ const patchReviewSelectionKey = useMemo ( ( ) => {
426+ if ( ! activeThreadId || ! selectedPatch ) {
427+ return null ;
428+ }
429+ const scope = selectedTurn ? `turn:${ selectedTurn . turnId } ` : "conversation" ;
430+ return `${ activeThreadId } :${ scope } :${ buildPatchCacheKey ( selectedPatch , "diff-review" ) } ` ;
431+ } , [ activeThreadId , selectedPatch , selectedTurn ] ) ;
432+ const renderableFilePaths = useMemo (
433+ ( ) => renderableFiles . map ( ( fileDiff ) => resolveFileDiffPath ( fileDiff ) ) ,
434+ [ renderableFiles ] ,
435+ ) ;
436+ const activeReviewState = patchReviewSelectionKey
437+ ? ( reviewStateBySelectionKey [ patchReviewSelectionKey ] ?? { } )
438+ : { } ;
304439
305440 useEffect ( ( ) => {
306441 if ( diffOpen && ! previousDiffOpenRef . current ) {
@@ -309,6 +444,45 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
309444 previousDiffOpenRef . current = diffOpen ;
310445 } , [ diffOpen , settings . diffWordWrap ] ) ;
311446
447+ useEffect ( ( ) => {
448+ if ( ! patchReviewSelectionKey ) {
449+ return ;
450+ }
451+ setReviewStateBySelectionKey ( ( current ) => {
452+ const nextSelectionState = reconcileDiffFileReviewState (
453+ renderableFilePaths ,
454+ current [ patchReviewSelectionKey ] ,
455+ ) ;
456+ if ( current [ patchReviewSelectionKey ] === nextSelectionState ) {
457+ return current ;
458+ }
459+ return {
460+ ...current ,
461+ [ patchReviewSelectionKey ] : nextSelectionState ,
462+ } ;
463+ } ) ;
464+ } , [ patchReviewSelectionKey , renderableFilePaths ] ) ;
465+
466+ useEffect ( ( ) => {
467+ if ( ! patchReviewSelectionKey || ! selectedFilePath ) {
468+ return ;
469+ }
470+ setReviewStateBySelectionKey ( ( current ) => {
471+ const selectionState = current [ patchReviewSelectionKey ] ;
472+ if ( ! selectionState ) {
473+ return current ;
474+ }
475+ const nextSelectionState = expandDiffFile ( selectionState , selectedFilePath ) ;
476+ if ( nextSelectionState === selectionState ) {
477+ return current ;
478+ }
479+ return {
480+ ...current ,
481+ [ patchReviewSelectionKey ] : nextSelectionState ,
482+ } ;
483+ } ) ;
484+ } , [ patchReviewSelectionKey , selectedFilePath ] ) ;
485+
312486 useEffect ( ( ) => {
313487 if ( ! selectedFilePath || ! patchViewportRef . current ) {
314488 return ;
@@ -330,6 +504,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
330504 } ,
331505 [ activeCwd ] ,
332506 ) ;
507+ const updateActiveReviewState = useCallback (
508+ ( updater : ( current : DiffFileReviewStateByPath ) => DiffFileReviewStateByPath ) => {
509+ if ( ! patchReviewSelectionKey ) {
510+ return ;
511+ }
512+ setReviewStateBySelectionKey ( ( current ) => ( {
513+ ...current ,
514+ [ patchReviewSelectionKey ] : updater ( current [ patchReviewSelectionKey ] ?? { } ) ,
515+ } ) ) ;
516+ } ,
517+ [ patchReviewSelectionKey ] ,
518+ ) ;
519+ const onToggleFileAccepted = useCallback (
520+ ( filePath : string ) => {
521+ updateActiveReviewState ( ( current ) => toggleDiffFileAccepted ( current , filePath ) ) ;
522+ } ,
523+ [ updateActiveReviewState ] ,
524+ ) ;
525+ const onToggleFileCollapsed = useCallback (
526+ ( filePath : string ) => {
527+ updateActiveReviewState ( ( current ) => toggleDiffFileCollapsed ( current , filePath ) ) ;
528+ } ,
529+ [ updateActiveReviewState ] ,
530+ ) ;
333531
334532 const latestSelectedTurnId = orderedTurnDiffSummaries [ 0 ] ?. turnId ?? null ;
335533
@@ -604,34 +802,25 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
604802 const filePath = resolveFileDiffPath ( fileDiff ) ;
605803 const fileKey = buildFileDiffRenderKey ( fileDiff ) ;
606804 const themedFileKey = `${ fileKey } :${ resolvedTheme } ` ;
805+ const fileReviewState = activeReviewState [ filePath ] ?? {
806+ accepted : false ,
807+ collapsed : false ,
808+ } ;
607809 return (
608- < div
810+ < DiffFileSection
609811 key = { themedFileKey }
610- data-diff-file-path = { filePath }
611- className = "diff-render-file mb-2 rounded-md first:mt-2 last:mb-0"
612- onClickCapture = { ( event ) => {
613- const nativeEvent = event . nativeEvent as MouseEvent ;
614- const composedPath = nativeEvent . composedPath ?.( ) ?? [ ] ;
615- const clickedHeader = composedPath . some ( ( node ) => {
616- if ( ! ( node instanceof Element ) ) return false ;
617- return node . hasAttribute ( "data-title" ) ;
618- } ) ;
619- if ( ! clickedHeader ) return ;
620- openDiffFileInEditor ( filePath ) ;
621- } }
622- >
623- < FileDiff
624- fileDiff = { fileDiff }
625- options = { {
626- diffStyle : diffRenderMode === "split" ? "split" : "unified" ,
627- lineDiffType : "none" ,
628- overflow : diffWordWrap ? "wrap" : "scroll" ,
629- theme : resolveDiffThemeName ( resolvedTheme ) ,
630- themeType : resolvedTheme as DiffThemeType ,
631- unsafeCSS : DIFF_PANEL_UNSAFE_CSS ,
632- } }
633- />
634- </ div >
812+ accepted = { fileReviewState . accepted }
813+ collapsed = { fileReviewState . collapsed }
814+ diffRenderMode = { diffRenderMode }
815+ diffWordWrap = { diffWordWrap }
816+ fileDiff = { fileDiff }
817+ fileKey = { themedFileKey }
818+ filePath = { filePath }
819+ onOpenInEditor = { openDiffFileInEditor }
820+ onToggleAccepted = { onToggleFileAccepted }
821+ onToggleCollapsed = { onToggleFileCollapsed }
822+ resolvedTheme = { resolvedTheme }
823+ />
635824 ) ;
636825 } ) }
637826 </ Virtualizer >
0 commit comments