1- import { useState , useMemo , useCallback , useEffect , Fragment } from 'react' ;
1+ import { useState , useMemo , useCallback , useEffect , useRef , Fragment } from 'react' ;
22import {
33 Card , Spin , Alert , Modal , Drawer , Stat , Table , ScrollDiv , Space ,
44} from '@ossrandom/design-system' ;
@@ -132,6 +132,7 @@ function buildTreemapTree(
132132 nodes : FileTreeNode [ ] ,
133133 parentPath : string ,
134134 pathMap : WeakMap < TreemapNode , string > ,
135+ truncatedDirMap : WeakMap < TreemapNode , boolean > ,
135136) : TreemapNode [ ] {
136137 // Sort children by name so the treemap layout is stable across page
137138 // loads regardless of API result ordering. d3-hierarchy's squarified
@@ -143,7 +144,7 @@ function buildTreemapTree(
143144 const fullPath = parentPath ? `${ parentPath } /${ n . name } ` : n . name ;
144145 if ( n . nodeCount <= 0 && ( ! n . children || n . children . length === 0 ) ) continue ;
145146 if ( n . type === 'directory' && n . children && n . children . length > 0 ) {
146- const children = buildTreemapTree ( n . children , fullPath , pathMap ) ;
147+ const children = buildTreemapTree ( n . children , fullPath , pathMap , truncatedDirMap ) ;
147148 if ( children . length === 0 ) continue ;
148149 const node : TreemapNode = {
149150 name : n . name ,
@@ -153,18 +154,60 @@ function buildTreemapTree(
153154 pathMap . set ( node , fullPath ) ;
154155 out . push ( node ) ;
155156 } else {
157+ // A directory with no children but nodeCount > 0 is the backend's depth-cap
158+ // marker — descendants exist but weren't fetched. Render as a leaf and
159+ // flag for lazy expansion on click (Phase 2/3 path-rooted refetch).
160+ const isTruncatedDir = n . type === 'directory' && n . nodeCount > 0 ;
156161 const node : TreemapNode = {
157162 name : n . name ,
158163 value : Math . max ( n . nodeCount , 1 ) ,
159- color : LANG_COLORS [ inferLang ( n . name ) ] ?? '#666' ,
164+ color : LANG_COLORS [ isTruncatedDir ? 'other' : inferLang ( n . name ) ] ?? '#666' ,
160165 } ;
161166 pathMap . set ( node , fullPath ) ;
167+ if ( isTruncatedDir ) truncatedDirMap . set ( node , true ) ;
162168 out . push ( node ) ;
163169 }
164170 }
165171 return out ;
166172}
167173
174+ /**
175+ * Walk down to leaves and sum their values. Used when flattening the visible
176+ * level for label rendering: the design-system Treemap canvas only paints
177+ * names on leaf cells (its `isLeaf` check guards label drawing), so
178+ * directories at the focused level have to be rendered as leaves with a
179+ * pre-aggregated value to keep the cell-sizing accurate.
180+ */
181+ function aggregateLeafValue ( node : TreemapNode ) : number {
182+ if ( ! node . children || node . children . length === 0 ) return node . value ?? 1 ;
183+ let sum = 0 ;
184+ for ( const c of node . children ) sum += aggregateLeafValue ( c ) ;
185+ return sum ;
186+ }
187+
188+ /**
189+ * Splice a freshly-fetched subtree into the right slot of the existing tree.
190+ * Matches by absolute filesystem path (which the backend emits unchanged in
191+ * `path` for both root and path-rooted responses), then replaces the
192+ * directory's children. Returns a new tree array — the caller should treat
193+ * the result as immutable.
194+ */
195+ function mergeSubtree (
196+ tree : FileTreeNode [ ] ,
197+ fsPath : string ,
198+ subtree : FileTreeNode [ ] ,
199+ ) : FileTreeNode [ ] {
200+ return tree . map ( n => {
201+ if ( n . path === fsPath && n . type === 'directory' ) {
202+ return { ...n , children : subtree } ;
203+ }
204+ if ( n . children && n . children . length > 0 && fsPath . startsWith ( n . path + '/' ) ) {
205+ return { ...n , children : mergeSubtree ( n . children , fsPath , subtree ) } ;
206+ }
207+ return n ;
208+ } ) ;
209+ }
210+
168211function useViewportHeight ( offset : number ) : number {
169212 const [ h , setH ] = useState ( ( ) => ( typeof window === 'undefined' ? 600 : window . innerHeight - offset ) ) ;
170213 useEffect ( ( ) => {
@@ -208,27 +251,63 @@ export default function Dashboard() {
208251 // breadcrumb(38) + gaps(24)
209252 const treemapHeight = useViewportHeight ( 56 + 32 + 110 + 38 + 24 ) ;
210253
211- // Treemap. Cap initial fetch at depth 8 — enough for a fully-qualified Java
254+ // Treemap. Initial fetch caps at depth 8 — enough for a fully-qualified Java
212255 // path (src/main/java/io/github/<org>/<pkg>/<sub>/File.java = 8 segments)
213- // and most other languages, but spares the 200 K-node case from shipping
214- // the full tree for paths the user will never drill into. Past depth 8
215- // the directory renders as a leaf with its aggregate node count; on-demand
216- // subtree fetching is a follow-up (Phase 2).
217- const { data : treeData , loading : treeLoading } = useApi < FileTreeResponse > ( ( ) => api . getFileTree ( 8 ) , [ ] ) ;
218- const { treemapRoot, pathMap } = useMemo ( ( ) => {
219- const map = new WeakMap < TreemapNode , string > ( ) ;
220- const children = buildTreemapTree ( collapseTree ( treeData ?. tree ?? [ ] ) , '' , map ) ;
256+ // and the typical TS/Python/Go layouts. Directories beyond depth 8 come
257+ // back as truncation markers (type=directory, children=[], nodeCount>0);
258+ // when the user clicks one, we fetch its subtree on demand via the
259+ // path-rooted /api/file-tree?path=… endpoint and splice it into place.
260+ const [ treeData , setTreeData ] = useState < FileTreeResponse | null > ( null ) ;
261+ const [ treeLoading , setTreeLoading ] = useState ( true ) ;
262+ const [ subtreeLoading , setSubtreeLoading ] = useState ( false ) ;
263+ const loadedPathsRef = useRef < Set < string > > ( new Set ( ) ) ;
264+
265+ useEffect ( ( ) => {
266+ let cancelled = false ;
267+ setTreeLoading ( true ) ;
268+ api . getFileTree ( 8 )
269+ . then ( r => { if ( ! cancelled ) setTreeData ( r ) ; } )
270+ . catch ( ( ) => { /* surface via empty-tree state */ } )
271+ . finally ( ( ) => { if ( ! cancelled ) setTreeLoading ( false ) ; } ) ;
272+ return ( ) => { cancelled = true ; } ;
273+ } , [ ] ) ;
274+
275+ const ensureSubtreeLoaded = useCallback ( async ( fsPath : string ) => {
276+ if ( loadedPathsRef . current . has ( fsPath ) ) return ;
277+ // Mark eagerly to dedupe concurrent clicks; revert on failure so the
278+ // user can retry by clicking again.
279+ loadedPathsRef . current . add ( fsPath ) ;
280+ setSubtreeLoading ( true ) ;
281+ try {
282+ const sub = await api . getFileTree ( 8 , fsPath ) ;
283+ setTreeData ( prev => prev
284+ ? { ...prev , tree : mergeSubtree ( prev . tree , fsPath , sub . tree ) }
285+ : prev ) ;
286+ } catch {
287+ loadedPathsRef . current . delete ( fsPath ) ;
288+ } finally {
289+ setSubtreeLoading ( false ) ;
290+ }
291+ } , [ ] ) ;
292+
293+ const { treemapRoot, pathMap, truncatedDirMap } = useMemo ( ( ) => {
294+ const pMap = new WeakMap < TreemapNode , string > ( ) ;
295+ const tMap = new WeakMap < TreemapNode , boolean > ( ) ;
296+ const children = buildTreemapTree ( collapseTree ( treeData ?. tree ?? [ ] ) , '' , pMap , tMap ) ;
221297 const root : TreemapNode = { name : 'root' , children } ;
222- return { treemapRoot : root , pathMap : map } ;
298+ return { treemapRoot : root , pathMap : pMap , truncatedDirMap : tMap } ;
223299 } , [ treeData ] ) ;
224300
225301 // Drill state — names of the directories we've drilled into, in order.
226302 // Empty = full tree. Single-click on a directory pushes; clicking a
227303 // breadcrumb segment slices back to that depth.
228304 const [ focusPath , setFocusPath ] = useState < string [ ] > ( [ ] ) ;
229305
230- // Reset focus when the underlying tree changes (e.g., re-fetch after enrich).
231- useEffect ( ( ) => { setFocusPath ( [ ] ) ; } , [ treemapRoot ] ) ;
306+ // Reset focus only on a fresh full-tree load (total_files indicates the
307+ // initial fetch landed). Subtree-merge updates also bump treeData but keep
308+ // total_files unchanged, so the user's drill position survives lazy loads.
309+ const totalFiles = treeData ?. total_files ;
310+ useEffect ( ( ) => { setFocusPath ( [ ] ) ; } , [ totalFiles ] ) ;
232311
233312 // Walk treemapRoot along focusPath. Falls back to root if any segment is
234313 // missing (defensive — shouldn't happen since focusPath only ever holds
@@ -243,15 +322,56 @@ export default function Dashboard() {
243322 return cur ;
244323 } , [ treemapRoot , focusPath ] ) ;
245324
325+ // Render-only flat copy of the focused level. The design-system Treemap
326+ // only paints names on leaf cells (its canvas path checks `!n.children?.length`
327+ // before drawing the label), so directories at the visible level — which
328+ // legitimately have children for drill-down — appear as unlabelled
329+ // rectangles. Strip children for the render pass to satisfy the leaf
330+ // check; the original TreemapNode (with intact children) stays in
331+ // `focusedRoot` and is recovered via `renderToOriginal` in the click
332+ // handler so drill-down still works.
333+ const { focusedRootForRender, renderToOriginal } = useMemo ( ( ) => {
334+ const map = new WeakMap < TreemapNode , TreemapNode > ( ) ;
335+ const flat = ( focusedRoot . children ?? [ ] ) . map ( c => {
336+ if ( c . children && c . children . length > 0 ) {
337+ const rendered : TreemapNode = {
338+ name : c . name ,
339+ value : aggregateLeafValue ( c ) ,
340+ color : c . color ,
341+ } ;
342+ map . set ( rendered , c ) ;
343+ return rendered ;
344+ }
345+ // Files and truncated-directory markers are already leaves; preserve
346+ // identity so existing pathMap / truncatedDirMap lookups keep working.
347+ return c ;
348+ } ) ;
349+ return {
350+ focusedRootForRender : { name : focusedRoot . name , children : flat } ,
351+ renderToOriginal : map ,
352+ } ;
353+ } , [ focusedRoot ] ) ;
354+
246355 // File viewer
247356 const [ fileDrawer , setFileDrawer ] = useState < { path : string ; content : string } | null > ( null ) ;
248357 const [ fileLoading , setFileLoading ] = useState ( false ) ;
249- const onTreemapNodeClick = useCallback ( async ( node : TreemapNode ) => {
250- // Directory — drill down one level.
358+ const onTreemapNodeClick = useCallback ( async ( clicked : TreemapNode ) => {
359+ // Resolve the original TreemapNode behind the rendered (children-stripped)
360+ // cell so pathMap / truncatedDirMap / drill-down keep working.
361+ const node = renderToOriginal . get ( clicked ) ?? clicked ;
362+ // Directory with children — drill down one level.
251363 if ( node . children && node . children . length > 0 ) {
252364 setFocusPath ( prev => [ ...prev , node . name ] ) ;
253365 return ;
254366 }
367+ // Truncated directory (depth-cap marker) — fetch its subtree, then drill in.
368+ if ( truncatedDirMap . get ( node ) ) {
369+ const fsPath = pathMap . get ( node ) ;
370+ if ( ! fsPath ) return ;
371+ await ensureSubtreeLoaded ( fsPath ) ;
372+ setFocusPath ( prev => [ ...prev , node . name ] ) ;
373+ return ;
374+ }
255375 // Leaf — open file in drawer.
256376 const filePath = pathMap . get ( node ) ;
257377 if ( ! filePath ) return ;
@@ -260,7 +380,7 @@ export default function Dashboard() {
260380 try { setFileDrawer ( { path : filePath , content : await api . readFile ( filePath ) } ) ; }
261381 catch { setFileDrawer ( { path : filePath , content : '// Could not load file' } ) ; }
262382 finally { setFileLoading ( false ) ; }
263- } , [ pathMap ] ) ;
383+ } , [ pathMap , truncatedDirMap , renderToOriginal , ensureSubtreeLoaded ] ) ;
264384
265385 if ( statsLoading || treeLoading ) {
266386 return < div style = { { textAlign : 'center' , padding : 80 } } > < Spin size = "lg" /> </ div > ;
@@ -307,6 +427,11 @@ export default function Dashboard() {
307427 </ button >
308428 </ Fragment >
309429 ) ) }
430+ { subtreeLoading && (
431+ < span style = { { marginLeft : 8 , opacity : 0.7 } } aria-live = "polite" >
432+ < Spin size = "sm" /> loading subtree…
433+ </ span >
434+ ) }
310435 </ div >
311436
312437 < div >
@@ -316,7 +441,7 @@ export default function Dashboard() {
316441 // design-system Treemap caches layout on `data` identity, and a
317442 // remount is the simplest way to ensure a clean redraw.
318443 key = { focusPath . join ( '/' ) || 'root' }
319- data = { focusedRoot }
444+ data = { focusedRootForRender }
320445 height = { treemapHeight }
321446 engine = "canvas"
322447 // One level at a time — each cell maps 1:1 to a direct child of
0 commit comments