1111 * given conflict label. Square theme, no card-in-card chrome.
1212 */
1313
14- import React , { useCallback , useEffect , useMemo , useState } from 'react' ;
14+ import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
1515import { ChevronDown , ChevronRight , FileText , Folder } from 'lucide-react' ;
1616import type { VCSProviderType } from '@bitcode/vcs-core' ;
1717
@@ -45,6 +45,27 @@ function baseName(path: string) {
4545 return index === - 1 ? trimmed : trimmed . slice ( index + 1 ) ;
4646}
4747
48+ function normalizeTreeItems ( raw : unknown ) : VCSFileTreePickerTreeItem [ ] {
49+ if ( ! Array . isArray ( raw ) ) return [ ] ;
50+ return raw
51+ . filter (
52+ ( item : { path ?: unknown ; type ?: unknown } ) =>
53+ typeof item ?. path === 'string' &&
54+ ( item ?. type === 'blob' || item ?. type === 'tree' ) ,
55+ )
56+ . map ( ( item : { path : string ; type : 'blob' | 'tree' } ) => ( {
57+ path : item . path ,
58+ type : item . type ,
59+ } ) )
60+ . sort ( ( a , b ) =>
61+ a . type === b . type
62+ ? a . path . localeCompare ( b . path )
63+ : a . type === 'tree'
64+ ? - 1
65+ : 1 ,
66+ ) ;
67+ }
68+
4869export function VCSFileTreePicker ( {
4970 provider,
5071 repositoryFullName,
@@ -63,6 +84,13 @@ export function VCSFileTreePicker({
6384 const [ expanded , setExpanded ] = useState < Record < string , boolean > > ( { } ) ;
6485 const [ loadError , setLoadError ] = useState < string | null > ( null ) ;
6586
87+ // Bumped on every tree-identity change (repo/ref) and on unmount so
88+ // in-flight fetches from a prior package cannot write empty/error results
89+ // over a later successful load (the flicker-to-empty race). Same pattern
90+ // DepositSourceSelection uses with its disposed flags.
91+ const loadGenerationRef = useRef ( 0 ) ;
92+ const abortControllerRef = useRef < AbortController | null > ( null ) ;
93+
6694 const [ owner , repo ] = useMemo ( ( ) => {
6795 const [ ownerPart , repoPart ] = ( repositoryFullName || '' ) . split ( '/' ) ;
6896 return [ ownerPart || null , repoPart || null ] ;
@@ -75,9 +103,15 @@ export function VCSFileTreePicker({
75103 const selectedSet = useMemo ( ( ) => new Set ( selectedPaths ) , [ selectedPaths ] ) ;
76104
77105 const loadDirectory = useCallback (
78- async ( path : string ) => {
106+ async ( path : string , generation : number , signal : AbortSignal ) => {
79107 if ( ! owner || ! repo ) return ;
80- setChildrenByPath ( ( previous ) => ( { ...previous , [ path ] : previous [ path ] ?? null } ) ) ;
108+ // Only mark loading when we don't already have items for this path —
109+ // keeps the tree painted during a soft re-fetch of an expanded dir.
110+ setChildrenByPath ( ( previous ) =>
111+ previous [ path ] === undefined
112+ ? { ...previous , [ path ] : null }
113+ : previous ,
114+ ) ;
81115 try {
82116 const params = new URLSearchParams ( {
83117 resource : 'tree' ,
@@ -87,49 +121,93 @@ export function VCSFileTreePicker({
87121 } ) ;
88122 if ( path ) params . set ( 'path' , path ) ;
89123 if ( treeRef ) params . set ( 'ref' , treeRef ) ;
90- const response = await fetch ( `/api/vcs?${ params . toString ( ) } ` ) ;
91- const payload = response ?. ok ? await response . json ( ) . catch ( ( ) => null ) : null ;
92- const items : VCSFileTreePickerTreeItem [ ] = Array . isArray ( payload ?. items )
93- ? payload . items
94- . filter (
95- ( item : { path ?: unknown ; type ?: unknown } ) =>
96- typeof item ?. path === 'string' &&
97- ( item ?. type === 'blob' || item ?. type === 'tree' ) ,
98- )
99- . sort (
100- ( a : VCSFileTreePickerTreeItem , b : VCSFileTreePickerTreeItem ) =>
101- a . type === b . type
102- ? a . path . localeCompare ( b . path )
103- : a . type === 'tree'
104- ? - 1
105- : 1 ,
106- )
107- : [ ] ;
124+ const response = await fetch ( `/api/vcs?${ params . toString ( ) } ` , {
125+ signal,
126+ } ) ;
127+ if ( generation !== loadGenerationRef . current || signal . aborted ) return ;
128+ const payload = response ?. ok
129+ ? await response . json ( ) . catch ( ( ) => null )
130+ : null ;
131+ if ( generation !== loadGenerationRef . current || signal . aborted ) return ;
132+ if ( ! payload ) {
133+ // Failed read: keep any previously-good paint; only write empty
134+ // when we never had items (null/undefined). Avoids the
135+ // success→stale-failure→Empty directory flicker.
136+ setChildrenByPath ( ( previous ) => {
137+ const existing = previous [ path ] ;
138+ if ( Array . isArray ( existing ) && existing . length > 0 ) return previous ;
139+ return { ...previous , [ path ] : [ ] } ;
140+ } ) ;
141+ setLoadError ( 'Unable to read the repository file tree.' ) ;
142+ return ;
143+ }
144+ const items = normalizeTreeItems ( payload . items ) ;
108145 setChildrenByPath ( ( previous ) => ( { ...previous , [ path ] : items } ) ) ;
109- if ( ! payload ) setLoadError ( 'Unable to read the repository file tree.' ) ;
110- } catch {
111- setChildrenByPath ( ( previous ) => ( { ...previous , [ path ] : [ ] } ) ) ;
146+ setLoadError ( null ) ;
147+ } catch ( error ) {
148+ const aborted =
149+ signal . aborted ||
150+ ( error instanceof Error && error . name === 'AbortError' ) ;
151+ if ( generation !== loadGenerationRef . current || aborted ) {
152+ return ;
153+ }
154+ setChildrenByPath ( ( previous ) => {
155+ const existing = previous [ path ] ;
156+ if ( Array . isArray ( existing ) && existing . length > 0 ) return previous ;
157+ return { ...previous , [ path ] : [ ] } ;
158+ } ) ;
112159 setLoadError ( 'Unable to read the repository file tree.' ) ;
113160 }
114161 } ,
115162 [ owner , provider , repo , treeRef ] ,
116163 ) ;
117164
118- // Reset + load the root whenever the source package changes.
165+ // Reset + load the root whenever the source package (owner/repo/ref) changes.
166+ // treeRef thrashing is expected: DepositSourceSelection publishes branch
167+ // first, then head commit once commits load — without abort + generation
168+ // guards the branch-fetch can land after the commit-fetch and wipe it.
119169 useEffect ( ( ) => {
120- setChildrenByPath ( { } ) ;
170+ const generation = loadGenerationRef . current + 1 ;
171+ loadGenerationRef . current = generation ;
172+ abortControllerRef . current ?. abort ( ) ;
173+ const controller = new AbortController ( ) ;
174+ abortControllerRef . current = controller ;
175+
121176 setExpanded ( { } ) ;
122177 setLoadError ( null ) ;
123- if ( ! owner || ! repo ) return ;
124- void loadDirectory ( '' ) ;
178+ if ( ! owner || ! repo ) {
179+ setChildrenByPath ( { } ) ;
180+ return ( ) => {
181+ controller . abort ( ) ;
182+ } ;
183+ }
184+
185+ // Root reloads to Loading (null), not Empty ([]). Keeping a prior tree
186+ // painted during ref hops would flash the wrong revision, so clear —
187+ // but never seed with [] (that is the "Empty directory" state).
188+ setChildrenByPath ( { '' : null } ) ;
189+ void loadDirectory ( '' , generation , controller . signal ) ;
190+
191+ return ( ) => {
192+ // Invalidate every in-flight response for this package.
193+ if ( loadGenerationRef . current === generation ) {
194+ loadGenerationRef . current = generation + 1 ;
195+ }
196+ controller . abort ( ) ;
197+ } ;
125198 } , [ loadDirectory , owner , repo ] ) ;
126199
127200 const toggleExpanded = ( path : string ) => {
128201 setExpanded ( ( previous ) => {
129202 const next = { ...previous , [ path ] : ! previous [ path ] } ;
130203 return next ;
131204 } ) ;
132- if ( childrenByPath [ path ] === undefined ) void loadDirectory ( path ) ;
205+ if ( childrenByPath [ path ] === undefined ) {
206+ const controller = abortControllerRef . current ;
207+ if ( controller && ! controller . signal . aborted ) {
208+ void loadDirectory ( path , loadGenerationRef . current , controller . signal ) ;
209+ }
210+ }
133211 } ;
134212
135213 const toggleSelected = ( selectionPath : string ) => {
0 commit comments