1- import { useMemo , useState } from "react" ;
1+ import { useEffect , useMemo , useState } from "react" ;
22import { createRoot } from "react-dom/client" ;
3- import { parsePatchFiles , type FileDiffMetadata , type FileDiffOptions } from "@pierre/diffs" ;
3+ import type { FileDiffOptions } from "@pierre/diffs" ;
44import { FileDiff } from "@pierre/diffs/react" ;
5+ import type { GitStatusEntry } from "@pierre/trees" ;
6+ import { FileTree , useFileTree } from "@pierre/trees/react" ;
57import type { HostContext , ToolResultCard } from "./card-types.js" ;
8+ import {
9+ buildReviewFileEntries ,
10+ initialReviewPath ,
11+ type ReviewFileEntry ,
12+ } from "./review-model.js" ;
613
714type ThemeType = "light" | "dark" ;
815
@@ -41,27 +48,30 @@ function ReviewPayload({
4148 hostContext,
4249 errorMessage = null ,
4350 visibleFileCount,
51+ presentation = "inline" ,
4452} : PayloadRendererOptions ) {
45- const patch = card . payload ?. patch ;
4653 const themeType : ThemeType = hostContext ?. theme === "light" ? "light" : "dark" ;
47- const files = useMemo ( ( ) => parseFiles ( patch ) , [ patch ] ) ;
48- const visibleFiles = typeof visibleFileCount === "number"
49- ? files . slice ( 0 , visibleFileCount )
50- : files ;
54+ const entries = useMemo ( ( ) => buildReviewFileEntries ( card ) , [ card ] ) ;
5155 const [ openFiles , setOpenFiles ] = useState ( ( ) => new Set < string > ( ) ) ;
5256
5357 if ( errorMessage ) return < StatusLine message = { errorMessage } tone = "error" /> ;
54- if ( ! patch ) return < StatusLine message = "Diff payload is not available." /> ;
55- if ( files . length === 0 ) return < StatusLine message = "No diff hunks to review." /> ;
58+ if ( ! card . payload ?. patch ) return < StatusLine message = "Diff payload is not available." /> ;
59+ if ( entries . length === 0 ) return < StatusLine message = "No diff hunks to review." /> ;
5660
5761 const options = diffOptions ( themeType ) ;
62+ if ( presentation === "fullscreen" ) {
63+ return < FullscreenReview entries = { entries } options = { options } /> ;
64+ }
65+
66+ const visibleEntries = typeof visibleFileCount === "number"
67+ ? entries . slice ( 0 , visibleFileCount )
68+ : entries ;
5869
5970 return (
6071 < div className = "review-diff" >
6172 < div className = "review-diff-files" >
62- { visibleFiles . map ( ( fileDiff , index ) => {
63- const key = fileDiff . cacheKey ?? `${ fileDiff . prevName ?? "" } ->${ fileDiff . name } -${ index } ` ;
64- const stats = diffStats ( fileDiff ) ;
73+ { visibleEntries . map ( ( entry ) => {
74+ const key = entry . path ;
6575 const isOpen = openFiles . has ( key ) ;
6676
6777 return (
@@ -80,15 +90,13 @@ function ReviewPayload({
8090 setOpenFiles ( next ) ;
8191 } }
8292 >
83- < span className = "review-diff-file-name" > { fileDiff . name } </ span >
93+ < span className = "review-diff-file-name" > { entry . path } </ span >
8494 < span className = "review-diff-file-stats" >
85- < span className = "add" > +{ stats . additions } </ span >
86- < span className = "remove" > -{ stats . removals } </ span >
95+ < span className = "add" > +{ entry . additions } </ span >
96+ < span className = "remove" > -{ entry . removals } </ span >
8797 </ span >
8898 </ button >
89- { isOpen ? (
90- < FileDiff fileDiff = { fileDiff } options = { options } className = "pierre-diff" />
91- ) : null }
99+ { isOpen ? < ReviewFileBody entry = { entry } options = { options } /> : null }
92100 </ div >
93101 ) ;
94102 } ) }
@@ -97,19 +105,116 @@ function ReviewPayload({
97105 ) ;
98106}
99107
100- function parseFiles ( patch : string | undefined ) : FileDiffMetadata [ ] {
101- if ( ! patch ) return [ ] ;
102- return parsePatchFiles ( patch , "review" , true ) . flatMap ( ( parsedPatch ) => parsedPatch . files ) ;
108+ function FullscreenReview ( {
109+ entries,
110+ options,
111+ } : {
112+ entries : ReviewFileEntry [ ] ;
113+ options : FileDiffOptions < undefined > ;
114+ } ) {
115+ const [ selectedPath , setSelectedPath ] = useState ( ( ) => initialReviewPath ( entries ) ) ;
116+
117+ useEffect ( ( ) => {
118+ setSelectedPath ( ( currentPath ) => initialReviewPath ( entries , currentPath ) ) ;
119+ } , [ entries ] ) ;
120+
121+ const selectedEntry = entries . find ( ( entry ) => entry . path === selectedPath ) ?? entries [ 0 ] ;
122+
123+ return (
124+ < div className = "review-workspace" >
125+ < section className = "review-selected-file" >
126+ < header className = "review-selected-file-header" >
127+ < div className = "review-selected-file-title" >
128+ < strong title = { selectedEntry ?. path } > { selectedEntry ?. path } </ strong >
129+ { selectedEntry ?. previousPath && selectedEntry . previousPath !== selectedEntry . path ? (
130+ < span title = { selectedEntry . previousPath } > from { selectedEntry . previousPath } </ span >
131+ ) : null }
132+ </ div >
133+ { selectedEntry ? (
134+ < span className = "review-diff-file-stats" aria-label = "Selected file diff statistics" >
135+ < span className = "add" > +{ selectedEntry . additions } </ span >
136+ < span className = "remove" > -{ selectedEntry . removals } </ span >
137+ </ span >
138+ ) : null }
139+ </ header >
140+ < div className = "review-selected-file-body" >
141+ { selectedEntry ? (
142+ < ReviewFileBody entry = { selectedEntry } options = { options } />
143+ ) : (
144+ < StatusLine message = "Select a changed file to review it." />
145+ ) }
146+ </ div >
147+ </ section >
148+
149+ < aside className = "review-file-tree-panel" aria-label = "Changed files" >
150+ < div className = "review-file-tree-header" >
151+ < strong > Changed files</ strong >
152+ < span > { entries . length } </ span >
153+ </ div >
154+ < div className = "review-file-tree-body" >
155+ < ReviewFileTree
156+ entries = { entries }
157+ selectedPath = { selectedEntry ?. path }
158+ onSelect = { setSelectedPath }
159+ />
160+ </ div >
161+ </ aside >
162+ </ div >
163+ ) ;
103164}
104165
105- function diffStats ( fileDiff : FileDiffMetadata ) : { additions : number ; removals : number } {
106- return fileDiff . hunks . reduce (
107- ( stats , hunk ) => ( {
108- additions : stats . additions + hunk . additionLines ,
109- removals : stats . removals + hunk . deletionLines ,
110- } ) ,
111- { additions : 0 , removals : 0 } ,
166+ function ReviewFileTree ( {
167+ entries,
168+ selectedPath,
169+ onSelect,
170+ } : {
171+ entries : ReviewFileEntry [ ] ;
172+ selectedPath ?: string ;
173+ onSelect ( path : string ) : void ;
174+ } ) {
175+ const paths = useMemo ( ( ) => entries . map ( ( entry ) => entry . path ) , [ entries ] ) ;
176+ const gitStatus = useMemo < GitStatusEntry [ ] > (
177+ ( ) => entries . map ( ( entry ) => ( { path : entry . path , status : entry . status } ) ) ,
178+ [ entries ] ,
112179 ) ;
180+ const { model } = useFileTree ( {
181+ paths,
182+ gitStatus,
183+ flattenEmptyDirectories : true ,
184+ initialExpansion : "open" ,
185+ search : paths . length > 8 ,
186+ onSelectionChange ( selectedPaths ) {
187+ const path = selectedPaths . find ( ( candidate ) => paths . includes ( candidate ) ) ;
188+ if ( path ) onSelect ( path ) ;
189+ } ,
190+ } ) ;
191+
192+ useEffect ( ( ) => {
193+ if ( ! selectedPath ) return ;
194+ for ( const path of model . getSelectedPaths ( ) ) {
195+ if ( path !== selectedPath ) model . getItem ( path ) ?. deselect ( ) ;
196+ }
197+ model . getItem ( selectedPath ) ?. select ( ) ;
198+ model . scrollToPath ( selectedPath , { focus : false } ) ;
199+ } , [ model , selectedPath ] ) ;
200+
201+ return < FileTree model = { model } className = "review-file-tree" /> ;
202+ }
203+
204+ function ReviewFileBody ( {
205+ entry,
206+ options,
207+ } : {
208+ entry : ReviewFileEntry ;
209+ options : FileDiffOptions < undefined > ;
210+ } ) {
211+ if ( ! entry . fileDiff ) {
212+ return (
213+ < StatusLine message = "This file changed without a textual diff that can be rendered." />
214+ ) ;
215+ }
216+
217+ return < FileDiff fileDiff = { entry . fileDiff } options = { options } className = "pierre-diff" /> ;
113218}
114219
115220function diffOptions ( themeType : ThemeType ) : FileDiffOptions < undefined > {
0 commit comments