@@ -16,6 +16,7 @@ import {
1616 buildDiffFileTreeUpdates ,
1717 compareDiffFileTreeEntries ,
1818 collectDirectoryPaths ,
19+ diffFileTreeModel ,
1920 diffFileTreePositions ,
2021 type DiffFileTreeEntry ,
2122} from "./diffFileTree.logic" ;
@@ -45,8 +46,10 @@ export function DiffFileTree({
4546} : DiffFileTreeProps ) {
4647 const { resolvedTheme } = useTheme ( ) ;
4748 const paths = useMemo ( ( ) => entries . map ( ( entry ) => entry . path ) , [ entries ] ) ;
48- const directoryPaths = useMemo ( ( ) => collectDirectoryPaths ( paths ) , [ paths ] ) ;
49- const positions = useMemo ( ( ) => diffFileTreePositions ( paths ) , [ paths ] ) ;
49+ const presented = useMemo ( ( ) => diffFileTreeModel ( paths ) , [ paths ] ) ;
50+ const modelPaths = presented . paths ;
51+ const directoryPaths = useMemo ( ( ) => collectDirectoryPaths ( modelPaths ) , [ modelPaths ] ) ;
52+ const positions = useMemo ( ( ) => diffFileTreePositions ( modelPaths ) , [ modelPaths ] ) ;
5053 const [ ordering ] = useState ( ( ) => {
5154 let currentPositions : ReadonlyMap < string , number > = new Map ( ) ;
5255 return {
@@ -57,19 +60,29 @@ export function DiffFileTree({
5760 } ;
5861 } ) ;
5962 const gitStatus = useMemo < ReadonlyArray < GitStatusEntry > > (
60- ( ) => entries . map ( ( entry ) => ( { path : entry . path , status : entry . status } ) ) ,
61- [ entries ] ,
63+ ( ) =>
64+ entries . map ( ( entry , index ) => ( {
65+ path : modelPaths [ index ] ?? entry . path ,
66+ status : entry . status ,
67+ } ) ) ,
68+ [ entries , modelPaths ] ,
6269 ) ;
63- const filePathsRef = useRef < ReadonlySet < string > > ( new Set ( paths ) ) ;
70+ const filePathsRef = useRef < ReadonlySet < string > > ( new Set ( modelPaths ) ) ;
6471 const onSelectFileRef = useRef ( onSelectFile ) ;
72+ const toSelectionPathRef = useRef ( presented . selectionPath ) ;
6573 const syncingSelectionRef = useRef ( false ) ;
66- const handledRevealRef = useRef < { path : string ; revealRequestId : number } | null > ( null ) ;
74+ const handledRevealRef = useRef < {
75+ path : string ;
76+ revealRequestId : number ;
77+ modelPath : string ;
78+ } | null > ( null ) ;
6779 const mountedPathsRef = useRef < ReadonlyArray < string > | null > ( null ) ;
6880
6981 useEffect ( ( ) => {
70- filePathsRef . current = new Set ( paths ) ;
82+ filePathsRef . current = new Set ( modelPaths ) ;
7183 onSelectFileRef . current = onSelectFile ;
72- } , [ onSelectFile , paths ] ) ;
84+ toSelectionPathRef . current = presented . selectionPath ;
85+ } , [ modelPaths , onSelectFile , presented . selectionPath ] ) ;
7386
7487 const { model } = useFileTree ( {
7588 density : "compact" ,
@@ -78,8 +91,13 @@ export function DiffFileTree({
7891 icons : T3_PIERRE_ICONS ,
7992 onSelectionChange : ( selectedPaths ) => {
8093 if ( syncingSelectionRef . current ) return ;
81- const path = selectedPaths . at ( - 1 ) ?. replace ( / \/ $ / , "" ) ;
82- if ( path && filePathsRef . current . has ( path ) ) onSelectFileRef . current ( path ) ;
94+ const raw = selectedPaths . at ( - 1 ) ;
95+ if ( ! raw ) return ;
96+ // Directory ids end in `/`. A file that shares that directory's name is stored under a
97+ // different model path, so stripping the slash must not select it.
98+ const modelPath = filePathsRef . current . has ( raw ) ? raw : raw . replace ( / \/ $ / , "" ) ;
99+ if ( ! filePathsRef . current . has ( modelPath ) ) return ;
100+ onSelectFileRef . current ( toSelectionPathRef . current ( modelPath ) ) ;
83101 } ,
84102 paths : [ ] ,
85103 search : false ,
@@ -93,59 +111,67 @@ export function DiffFileTree({
93111 useEffect ( ( ) => {
94112 ordering . update ( positions ) ;
95113 const mountedPaths = mountedPathsRef . current ;
96- if ( mountedPaths === paths ) return ;
97- mountedPathsRef . current = paths ;
114+ if ( mountedPaths === modelPaths ) return ;
115+ mountedPathsRef . current = modelPaths ;
98116 if ( mountedPaths === null ) {
99- model . resetPaths ( paths ) ;
100- } else if ( mountedPaths . every ( ( path , index ) => paths [ index ] === path ) ) {
101- const updates = buildDiffFileTreeUpdates ( mountedPaths , paths ) ;
117+ model . resetPaths ( modelPaths ) ;
118+ } else if ( mountedPaths . every ( ( path , index ) => modelPaths [ index ] === path ) ) {
119+ const updates = buildDiffFileTreeUpdates ( mountedPaths , modelPaths ) ;
102120 if ( updates . length > 0 ) model . batch ( updates ) ;
103121 } else {
104- // A refreshed diff can change the rank of existing siblings. Mutations do not reorder
105- // those rows, so rebuild while carrying the reader's folder expansion forward.
122+ // A refreshed diff can change the rank of existing siblings. A file that becomes a
123+ // directory prefix also changes its model path, which Pierre cannot rename in place.
124+ // Rebuild while carrying the reader's folder expansion forward.
106125 const collapsedDirectories = directoryPaths . filter ( ( path ) => {
107126 const directory = model . getItem ( path ) ;
108127 return directory !== null && "isExpanded" in directory && ! directory . isExpanded ( ) ;
109128 } ) ;
110- model . resetPaths ( paths ) ;
129+ model . resetPaths ( modelPaths ) ;
111130 for ( const path of collapsedDirectories ) {
112131 const directory = model . getItem ( path ) ;
113132 if ( directory !== null && "collapse" in directory ) directory . collapse ( ) ;
114133 }
115134 }
116135 model . setGitStatus ( gitStatus ) ;
117- } , [ directoryPaths , gitStatus , model , ordering , paths , positions ] ) ;
136+ } , [ directoryPaths , gitStatus , model , modelPaths , ordering , positions ] ) ;
118137
119138 useEffect ( ( ) => {
120139 if ( selectedPath === null ) {
121140 handledRevealRef . current = null ;
122141 return ;
123142 }
124- const item = model . getItem ( selectedPath ) ;
143+ const modelPath = presented . modelPath ( selectedPath ) ;
144+ const item = model . getItem ( modelPath ) ;
125145 if ( item === null || item . isDirectory ( ) ) {
126146 handledRevealRef . current = null ;
127147 return ;
128148 }
129149 const handled = handledRevealRef . current ;
130- if ( handled ?. path === selectedPath && handled . revealRequestId === revealRequestId ) return ;
131- handledRevealRef . current = { path : selectedPath , revealRequestId } ;
150+ if (
151+ handled ?. path === selectedPath &&
152+ handled . revealRequestId === revealRequestId &&
153+ handled . modelPath === modelPath
154+ ) {
155+ return ;
156+ }
157+ handledRevealRef . current = { path : selectedPath , revealRequestId, modelPath } ;
132158 syncingSelectionRef . current = true ;
133159 for ( const path of model . getSelectedPaths ( ) ) {
134- if ( path !== selectedPath ) model . getItem ( path ) ?. deselect ( ) ;
160+ if ( path !== modelPath ) model . getItem ( path ) ?. deselect ( ) ;
135161 }
136162 let ancestor = "" ;
137- for ( const segment of selectedPath . split ( "/" ) . slice ( 0 , - 1 ) ) {
163+ for ( const segment of modelPath . split ( "/" ) . slice ( 0 , - 1 ) ) {
138164 ancestor += `${ segment } /` ;
139165 const directory = model . getItem ( ancestor ) ;
140166 if ( directory !== null && "expand" in directory ) directory . expand ( ) ;
141167 }
142168 item . select ( ) ;
143- model . scrollToPath ( selectedPath , { offset : "nearest" } ) ;
169+ model . scrollToPath ( modelPath , { offset : "nearest" } ) ;
144170 queueMicrotask ( ( ) => {
145171 syncingSelectionRef . current = false ;
146172 } ) ;
147- // `paths ` is a dependency so a file that arrives after it was asked for is still revealed.
148- } , [ model , paths , revealRequestId , selectedPath ] ) ;
173+ // `presented ` is a dependency so a file that arrives after it was asked for is still revealed.
174+ } , [ model , presented , revealRequestId , selectedPath ] ) ;
149175
150176 return (
151177 < div className = { cn ( "flex min-h-0 flex-1 flex-col bg-background" , className ) } >
@@ -201,14 +227,16 @@ export function DiffFileTree({
201227 // Pierre does not emit a selection change for its sole selected row.
202228 // Read selection before the row handles the click so new selections reveal only once.
203229 const selected = model . getSelectedPaths ( ) ;
204- const path = selected . length === 1 ? selected [ 0 ] : undefined ;
205- if ( ! path || ! filePathsRef . current . has ( path ) ) return ;
230+ const raw = selected . length === 1 ? selected [ 0 ] : undefined ;
231+ if ( ! raw ) return ;
232+ const path = filePathsRef . current . has ( raw ) ? raw : raw . replace ( / \/ $ / , "" ) ;
233+ if ( ! filePathsRef . current . has ( path ) ) return ;
206234 const clickedSelectedRow = event . nativeEvent
207235 . composedPath ( )
208236 . some (
209237 ( node ) => node instanceof HTMLElement && node . getAttribute ( "data-item-path" ) === path ,
210238 ) ;
211- if ( clickedSelectedRow ) onSelectFileRef . current ( path ) ;
239+ if ( clickedSelectedRow ) onSelectFileRef . current ( toSelectionPathRef . current ( path ) ) ;
212240 } }
213241 className = "min-h-0 flex-1 overflow-hidden"
214242 style = { pierreTreeStyle ( resolvedTheme ) }
0 commit comments