11import React , { memo , useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
2- import { Eye , EyeOff , Minus , Plus , RotateCcw , UnfoldVertical } from 'lucide-react' ;
2+ import { Check , ChevronRight , Eye , EyeOff , Minus , Plus , RotateCcw , UnfoldVertical } from 'lucide-react' ;
33import { FileDiff , WorkerPoolContextProvider } from '@pierre/diffs/react' ;
44import { parsePatchFiles , type DiffLineAnnotation , type FileDiffMetadata , type Hunk } from '@pierre/diffs' ;
55
@@ -267,6 +267,10 @@ const FileCard = memo(function FileCard({
267267 isPreviewing,
268268 onTogglePreview,
269269 themeType,
270+ isReviewed,
271+ onToggleReviewed,
272+ isCollapsed,
273+ onToggleCollapsed,
270274} : {
271275 fileDiff : FileDiffMetadata ;
272276 stagedEntries : HunkHeaderEntry [ ] | undefined ;
@@ -281,6 +285,10 @@ const FileCard = memo(function FileCard({
281285 isPreviewing : boolean ;
282286 onTogglePreview : ( ) => void ;
283287 themeType : 'light' | 'dark' ;
288+ isReviewed : boolean ;
289+ onToggleReviewed : ( ) => void ;
290+ isCollapsed : boolean ;
291+ onToggleCollapsed : ( ) => void ;
284292} ) {
285293 const headerRef = useRef < HTMLDivElement > ( null ) ;
286294 useEffect ( ( ) => {
@@ -298,6 +306,16 @@ const FileCard = memo(function FileCard({
298306 } ) ;
299307 } , [ ] ) ;
300308
309+ // Flash feedback: tracks which button key just completed an action
310+ const [ flashKey , setFlashKey ] = useState < string | null > ( null ) ;
311+ const flashTimeout = useRef < ReturnType < typeof setTimeout > > ( undefined ) ;
312+ const triggerFlash = useCallback ( ( key : string ) => {
313+ setFlashKey ( key ) ;
314+ clearTimeout ( flashTimeout . current ) ;
315+ flashTimeout . current = setTimeout ( ( ) => setFlashKey ( null ) , 600 ) ;
316+ } , [ ] ) ;
317+ useEffect ( ( ) => ( ) => clearTimeout ( flashTimeout . current ) , [ ] ) ;
318+
301319 const hunksMeta = useMemo < HunkMeta [ ] > ( ( ) => {
302320 return fileDiff . hunks . map ( ( hunk , idx ) => {
303321 const sig = hunkSignature ( hunk ) ;
@@ -392,6 +410,7 @@ const FileCard = memo(function FileCard({
392410 try {
393411 setPending ( key , true ) ;
394412 await API . sessions . changeFileStage ( sessionId , { filePath : fileDiff . name , stage } ) ;
413+ triggerFlash ( stage ? 'file-stage' : 'file-unstage' ) ;
395414 onChanged ?.( ) ;
396415 } catch ( err ) {
397416 console . error ( `[Diffs] Failed to ${ stage ? 'stage' : 'unstage' } file` , { filePath : fileDiff . name , err } ) ;
@@ -400,7 +419,7 @@ const FileCard = memo(function FileCard({
400419 if ( document . activeElement instanceof HTMLElement ) document . activeElement . blur ( ) ;
401420 }
402421 } ,
403- [ fileDiff . name , onChanged , sessionId , setPending ]
422+ [ fileDiff . name , onChanged , sessionId , setPending , triggerFlash ]
404423 ) ;
405424
406425 const restoreFile = useCallback ( async ( ) => {
@@ -409,14 +428,15 @@ const FileCard = memo(function FileCard({
409428 try {
410429 setPending ( key , true ) ;
411430 await API . sessions . restoreFile ( sessionId , { filePath : fileDiff . name } ) ;
431+ triggerFlash ( 'file-restore' ) ;
412432 onChanged ?.( ) ;
413433 } catch ( err ) {
414434 console . error ( '[Diffs] Failed to restore file' , { filePath : fileDiff . name , err } ) ;
415435 } finally {
416436 setPending ( key , false ) ;
417437 if ( document . activeElement instanceof HTMLElement ) document . activeElement . blur ( ) ;
418438 }
419- } , [ fileDiff . name , onChanged , sessionId , setPending ] ) ;
439+ } , [ fileDiff . name , onChanged , sessionId , setPending , triggerFlash ] ) ;
420440
421441 const stageHunk = useCallback (
422442 async ( hunk : HunkMeta , stage : boolean ) => {
@@ -425,6 +445,7 @@ const FileCard = memo(function FileCard({
425445 try {
426446 setPending ( key , true ) ;
427447 await API . sessions . stageHunk ( sessionId , { filePath : fileDiff . name , isStaging : stage , hunkHeader : hunk . stagedHeader || hunk . unstagedHeader || hunk . header } ) ;
448+ triggerFlash ( `hunk-${ stage ? 'stage' : 'unstage' } -${ hunk . index } ` ) ;
428449 onChanged ?.( ) ;
429450 } catch ( err ) {
430451 console . error ( `[Diffs] Failed to ${ stage ? 'stage' : 'unstage' } hunk` , { filePath : fileDiff . name , hunkHeader : hunk . header , err } ) ;
@@ -433,7 +454,7 @@ const FileCard = memo(function FileCard({
433454 if ( document . activeElement instanceof HTMLElement ) document . activeElement . blur ( ) ;
434455 }
435456 } ,
436- [ fileDiff . name , onChanged , sessionId , setPending ]
457+ [ fileDiff . name , onChanged , sessionId , setPending , triggerFlash ]
437458 ) ;
438459
439460 const restoreHunk = useCallback (
@@ -444,6 +465,7 @@ const FileCard = memo(function FileCard({
444465 try {
445466 setPending ( key , true ) ;
446467 await API . sessions . restoreHunk ( sessionId , { filePath : fileDiff . name , scope : hunk . status , hunkHeader : hunk . stagedHeader || hunk . unstagedHeader || hunk . header } ) ;
468+ triggerFlash ( `hunk-restore-${ hunk . index } ` ) ;
447469 onChanged ?.( ) ;
448470 } catch ( err ) {
449471 console . error ( '[Diffs] Failed to restore hunk' , { filePath : fileDiff . name , hunkHeader : hunk . header , err } ) ;
@@ -452,7 +474,7 @@ const FileCard = memo(function FileCard({
452474 if ( document . activeElement instanceof HTMLElement ) document . activeElement . blur ( ) ;
453475 }
454476 } ,
455- [ fileDiff . name , onChanged , sessionId , setPending ]
477+ [ fileDiff . name , onChanged , sessionId , setPending , triggerFlash ]
456478 ) ;
457479
458480 const [ hoveredHunk , setHoveredHunk ] = useState < HunkMeta | null > ( null ) ;
@@ -641,6 +663,14 @@ const FileCard = memo(function FileCard({
641663 fileDiff . hunks . length > 0
642664 ) ;
643665
666+ const flashStyle = useCallback (
667+ ( key : string ) : React . CSSProperties =>
668+ flashKey === key
669+ ? { animation : 'st-btn-flash 0.6s ease-out' }
670+ : { } ,
671+ [ flashKey ]
672+ ) ;
673+
644674 return (
645675 < div className = "st-diff-file" data-testid = "diff-file" data-diff-file-path = { fileDiff . name } >
646676 < div
@@ -649,17 +679,53 @@ const FileCard = memo(function FileCard({
649679 className = "sticky top-0 z-20 px-3 py-2 text-xs font-semibold flex items-center justify-between gap-2"
650680 style = { { backgroundColor : 'var(--st-surface)' , borderBottom : '1px solid var(--st-border-variant)' } }
651681 >
652- < div className = "min-w-0 flex-1" >
653- < div className = "font-mono truncate" title = { fileDiff . name } >
654- { fileDiff . name }
655- </ div >
656- { fileDiff . prevName && fileDiff . prevName !== fileDiff . name && (
657- < div className = "text-[11px] font-mono truncate mt-0.5" style = { { color : 'var(--st-text-faint)' } } title = { fileDiff . prevName } >
658- { fileDiff . prevName }
682+ < div className = "flex items-center gap-1.5 min-w-0 flex-1" >
683+ < button
684+ type = "button"
685+ className = "st-icon-button st-focus-ring !w-5 !h-5 flex-shrink-0"
686+ onClick = { onToggleCollapsed }
687+ title = { isCollapsed ? 'Expand file' : 'Collapse file' }
688+ >
689+ < ChevronRight
690+ className = "w-3.5 h-3.5 transition-transform duration-150"
691+ style = { { transform : isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' } }
692+ />
693+ </ button >
694+ < div className = "min-w-0 flex-1 cursor-pointer" onClick = { onToggleCollapsed } >
695+ < div className = "font-mono truncate" title = { fileDiff . name } >
696+ { fileDiff . name }
697+ { isReviewed && (
698+ < span
699+ className = "ml-2 inline-flex items-center gap-0.5 rounded border px-1 py-[1px] text-[10px] font-medium leading-none select-none align-middle"
700+ style = { {
701+ backgroundColor : 'color-mix(in srgb, var(--st-success) 12%, transparent)' ,
702+ borderColor : 'color-mix(in srgb, var(--st-success) 30%, transparent)' ,
703+ color : 'var(--st-text-faint)' ,
704+ } }
705+ >
706+ Viewed
707+ </ span >
708+ ) }
659709 </ div >
660- ) }
710+ { fileDiff . prevName && fileDiff . prevName !== fileDiff . name && (
711+ < div className = "text-[11px] font-mono truncate mt-0.5" style = { { color : 'var(--st-text-faint)' } } title = { fileDiff . prevName } >
712+ { fileDiff . prevName }
713+ </ div >
714+ ) }
715+ </ div >
661716 </ div >
662717 < div className = "flex items-center gap-1.5 flex-shrink-0" >
718+ { ! isCommitView && (
719+ < button
720+ type = "button"
721+ className = "st-icon-button st-focus-ring !w-5 !h-5"
722+ onClick = { onToggleReviewed }
723+ title = { isReviewed ? 'Mark as not reviewed' : 'Mark as reviewed' }
724+ style = { isReviewed ? { color : 'var(--st-success)' } : { color : 'var(--st-text-faint)' } }
725+ >
726+ < Check className = "w-3.5 h-3.5" />
727+ </ button >
728+ ) }
663729 { canLoadContext && ! contextLines && (
664730 < button
665731 type = "button"
@@ -690,7 +756,7 @@ const FileCard = memo(function FileCard({
690756 < button
691757 type = "button"
692758 className = "px-2 py-1 rounded text-[11px] font-medium st-focus-ring disabled:opacity-40"
693- style = { { backgroundColor : 'color-mix(in srgb, var(--st-success) 14%, transparent)' , color : 'var(--st-text)' } }
759+ style = { { backgroundColor : 'color-mix(in srgb, var(--st-success) 14%, transparent)' , color : 'var(--st-text)' , ... flashStyle ( 'file-stage' ) } }
694760 disabled = { ! sessionId || pendingKeys . has ( `file:${ fileDiff . name } ` ) }
695761 onClick = { ( ) => stageFile ( true ) }
696762 title = "Stage file"
@@ -702,18 +768,19 @@ const FileCard = memo(function FileCard({
702768 < button
703769 type = "button"
704770 className = "px-2 py-1 rounded text-[11px] font-medium st-focus-ring disabled:opacity-40"
705- style = { { backgroundColor : 'color-mix(in srgb, var(--st-warning) 14%, transparent)' , color : 'var(--st-text)' } }
771+ style = { { backgroundColor : 'color-mix(in srgb, var(--st-warning) 14%, transparent)' , color : 'var(--st-text)' , ... flashStyle ( 'file-unstage' ) } }
706772 disabled = { ! sessionId || pendingKeys . has ( `file:${ fileDiff . name } ` ) }
707773 onClick = { ( ) => stageFile ( false ) }
708774 title = "Unstage file"
709775 >
710776 Unstage
711777 </ button >
712778 ) }
779+ < span className = "w-px h-4 mx-0.5" style = { { backgroundColor : 'var(--st-border-variant)' } } />
713780 < button
714781 type = "button"
715782 className = "px-2 py-1 rounded text-[11px] font-medium st-focus-ring disabled:opacity-40"
716- style = { { backgroundColor : 'color-mix(in srgb, var(--st-danger) 10%, transparent)' , color : 'var(--st-text)' } }
783+ style = { { backgroundColor : 'color-mix(in srgb, var(--st-danger) 10%, transparent)' , color : 'var(--st-text)' , ... flashStyle ( 'file-restore' ) } }
717784 disabled = { ! sessionId || pendingKeys . has ( `file:${ fileDiff . name } :restore` ) }
718785 onClick = { ( ) => restoreFile ( ) }
719786 title = "Restore file"
@@ -725,28 +792,30 @@ const FileCard = memo(function FileCard({
725792 </ div >
726793 </ div >
727794
728- < div style = { containerStyle } >
729- { isPreviewing && previewContent ? (
730- isImageFile ( fileDiff . name ) ? (
731- < ImagePreview content = { previewContent } filePath = { fileDiff . name } />
795+ { ! isCollapsed && (
796+ < div style = { containerStyle } >
797+ { isPreviewing && previewContent ? (
798+ isImageFile ( fileDiff . name ) ? (
799+ < ImagePreview content = { previewContent } filePath = { fileDiff . name } />
800+ ) : (
801+ < MarkdownPreview content = { previewContent } />
802+ )
803+ ) : fileDiff . hunks . length === 0 ? (
804+ < div className = "px-3 py-6 text-xs" style = { { color : 'var(--st-text-faint)' } } >
805+ { isImageFile ( fileDiff . name ) ? 'Binary file (image)' : isBinaryFile ( fileDiff . name ) ? 'Binary file' : 'Diff unavailable.' }
806+ </ div >
732807 ) : (
733- < MarkdownPreview content = { previewContent } />
734- )
735- ) : fileDiff . hunks . length === 0 ? (
736- < div className = "px-3 py-6 text-xs" style = { { color : 'var(--st-text-faint)' } } >
737- { isImageFile ( fileDiff . name ) ? 'Binary file (image)' : isBinaryFile ( fileDiff . name ) ? 'Binary file' : 'Diff unavailable.' }
738- </ div >
739- ) : (
740- < FileDiff < HunkStatusAnnotation >
741- fileDiff = { fileDiffForRender }
742- options = { diffOptions as any }
743- lineAnnotations = { hunkStatusAnnotations }
744- renderAnnotation = { renderHunkStatusAnnotation }
745- renderHoverUtility = { ( ) => hoverUtility }
746- style = { { width : '100%' , maxWidth : '100%' , minWidth : 0 } }
747- />
748- ) }
749- </ div >
808+ < FileDiff < HunkStatusAnnotation >
809+ fileDiff = { fileDiffForRender }
810+ options = { diffOptions as any }
811+ lineAnnotations = { hunkStatusAnnotations }
812+ renderAnnotation = { renderHunkStatusAnnotation }
813+ renderHoverUtility = { ( ) => hoverUtility }
814+ style = { { width : '100%' , maxWidth : '100%' , minWidth : 0 } }
815+ />
816+ ) }
817+ </ div >
818+ ) }
750819 </ div >
751820 ) ;
752821} ) ;
@@ -775,6 +844,78 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = memo(function P
775844 const stagedEntriesByFile = useMemo ( ( ) => buildHunkHeaderEntries ( stagedDiff ) , [ stagedDiff ] ) ;
776845 const unstagedEntriesByFile = useMemo ( ( ) => buildHunkHeaderEntries ( unstagedDiff ) , [ unstagedDiff ] ) ;
777846
847+ // --- Reviewed / collapsed file tracking ---
848+ // reviewedFiles: Map<filePath, diffSignature> — stores the diff signature at the time of marking reviewed
849+ const [ reviewedFiles , setReviewedFiles ] = useState < Map < string , string > > ( ( ) => new Map ( ) ) ;
850+ const [ collapsedFiles , setCollapsedFiles ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
851+
852+ // Compute a simple signature per file for change detection
853+ const fileDiffSignatures = useMemo ( ( ) => {
854+ const sigs = new Map < string , string > ( ) ;
855+ for ( const file of parsedFiles ) {
856+ const parts = file . hunks . map ( ( h ) => hunkSignature ( h ) ) ;
857+ sigs . set ( file . name , parts . join ( '\n---\n' ) ) ;
858+ }
859+ return sigs ;
860+ } , [ parsedFiles ] ) ;
861+
862+ // When diff changes, check if any reviewed file's signature changed — if so, un-collapse and un-review it
863+ useEffect ( ( ) => {
864+ setReviewedFiles ( ( prev ) => {
865+ let changed = false ;
866+ const next = new Map ( prev ) ;
867+ for ( const [ path , oldSig ] of prev ) {
868+ const currentSig = fileDiffSignatures . get ( path ) ;
869+ if ( currentSig === undefined || currentSig !== oldSig ) {
870+ next . delete ( path ) ;
871+ changed = true ;
872+ }
873+ }
874+ if ( ! changed ) return prev ;
875+ // Also un-collapse those files
876+ setCollapsedFiles ( ( prevCollapsed ) => {
877+ const nextCollapsed = new Set ( prevCollapsed ) ;
878+ for ( const [ path ] of prev ) {
879+ const currentSig = fileDiffSignatures . get ( path ) ;
880+ if ( currentSig === undefined || currentSig !== prev . get ( path ) ) {
881+ nextCollapsed . delete ( path ) ;
882+ }
883+ }
884+ return nextCollapsed ;
885+ } ) ;
886+ return next ;
887+ } ) ;
888+ } , [ fileDiffSignatures ] ) ;
889+
890+ const toggleReviewed = useCallback ( ( filePath : string ) => {
891+ setReviewedFiles ( ( prev ) => {
892+ const next = new Map ( prev ) ;
893+ if ( next . has ( filePath ) ) {
894+ next . delete ( filePath ) ;
895+ // Un-collapse when un-reviewing
896+ setCollapsedFiles ( ( c ) => {
897+ const nc = new Set ( c ) ;
898+ nc . delete ( filePath ) ;
899+ return nc ;
900+ } ) ;
901+ } else {
902+ // Mark reviewed with current signature, and collapse
903+ next . set ( filePath , fileDiffSignatures . get ( filePath ) ?? '' ) ;
904+ setCollapsedFiles ( ( c ) => new Set ( c ) . add ( filePath ) ) ;
905+ }
906+ return next ;
907+ } ) ;
908+ } , [ fileDiffSignatures ] ) ;
909+
910+ const toggleCollapsed = useCallback ( ( filePath : string ) => {
911+ setCollapsedFiles ( ( prev ) => {
912+ const next = new Set ( prev ) ;
913+ if ( next . has ( filePath ) ) next . delete ( filePath ) ;
914+ else next . add ( filePath ) ;
915+ return next ;
916+ } ) ;
917+ } , [ ] ) ;
918+
778919 const autoPreviewPaths = useMemo ( ( ) => {
779920 return parsedFiles . map ( ( f ) => f . name ) . filter ( ( p ) => isImageFile ( p ) ) ;
780921 } , [ parsedFiles ] ) ;
@@ -811,6 +952,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = memo(function P
811952 isPreviewing = { previewFiles . has ( file . name ) }
812953 onTogglePreview = { ( ) => togglePreview ( file . name ) }
813954 themeType = { themeType }
955+ isReviewed = { reviewedFiles . has ( file . name ) }
956+ onToggleReviewed = { ( ) => toggleReviewed ( file . name ) }
957+ isCollapsed = { collapsedFiles . has ( file . name ) }
958+ onToggleCollapsed = { ( ) => toggleCollapsed ( file . name ) }
814959 />
815960 ) )
816961 ) }
0 commit comments