Skip to content

Commit a0d76ef

Browse files
committed
Rewrite Browse page for large tree support
- Virtualized tree with lazy loading for 100k-1M+ items - Search shows flat paginated results with path tooltips - Client-side tombstone filtering for instant toggle - Auto-select root in Manual Scan dialog when only one exists
1 parent 37c4eca commit a0d76ef

File tree

12 files changed

+876
-254
lines changed

12 files changed

+876
-254
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- **Browse page rewrite**: Completely redesigned to support extremely large file trees (100k-1M+ items)
12+
- Tree view now uses virtualization and lazy loading for fast performance at any scale
13+
- Directories load children on-demand when expanded
14+
- Search displays results as a flat, paginated list with path tooltips instead of a tree
15+
- "Show deleted" toggle works instantly without reloading data
16+
- **Manual Scan dialog**: Auto-selects root directory when only one root is configured
17+
1018
## [v0.2.11] - 2025-11-17
1119

1220
### Added

frontend/package-lock.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@radix-ui/react-separator": "^1.1.7",
2121
"@radix-ui/react-slot": "^1.2.4",
2222
"@radix-ui/react-tabs": "^1.1.13",
23+
"@tanstack/react-virtual": "^3.13.12",
2324
"class-variance-authority": "^0.7.1",
2425
"clsx": "^2.1.1",
2526
"date-fns": "^4.1.0",
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { useState, useCallback, useRef } from 'react'
2+
import { sortTreeItems, type TreeNodeData } from '@/lib/pathUtils'
3+
4+
/**
5+
* Represents a flattened tree item for virtualization.
6+
* Items are stored in a flat array with depth metadata for efficient rendering.
7+
*/
8+
export interface FlatTreeItem {
9+
item_id: number
10+
item_path: string
11+
item_name: string
12+
item_type: 'F' | 'D' | 'S' | 'O'
13+
is_ts: boolean
14+
depth: number
15+
isExpanded: boolean
16+
childrenLoaded: boolean
17+
hasChildren: boolean
18+
}
19+
20+
interface UseVirtualTreeOptions {
21+
rootId: number
22+
}
23+
24+
/**
25+
* Response type from the /api/items/immediate-children endpoint
26+
*/
27+
interface ImmediateChildrenResponse {
28+
item_id: number
29+
item_path: string
30+
item_type: string
31+
is_ts: boolean
32+
}
33+
34+
/**
35+
* Hook for managing a virtualized tree structure with lazy loading.
36+
*
37+
* This hook maintains a flat array of tree items and handles expansion/collapse
38+
* logic with on-demand loading of children from the backend API.
39+
*
40+
* @param options - Configuration including rootId for API calls
41+
* @returns Tree state and operations (flatItems, initializeTree, toggleNode, isLoading)
42+
*/
43+
export function useVirtualTree(options: UseVirtualTreeOptions) {
44+
const { rootId } = options
45+
const [flatItems, setFlatItems] = useState<FlatTreeItem[]>([])
46+
const [loadingItems, setLoadingItems] = useState<Set<number>>(new Set())
47+
48+
// Refs for synchronous access to avoid stale closure issues
49+
const flatItemsRef = useRef<FlatTreeItem[]>([])
50+
const loadingItemsRef = useRef<Set<number>>(new Set())
51+
52+
// Keep ref in sync with state
53+
flatItemsRef.current = flatItems
54+
55+
/**
56+
* Initializes the tree with root-level items.
57+
* This should be called once after fetching the top-level directory contents.
58+
*/
59+
const initializeTree = useCallback((rootItems: TreeNodeData[]) => {
60+
const initialFlatItems: FlatTreeItem[] = rootItems.map(item => ({
61+
item_id: item.item_id,
62+
item_path: item.item_path,
63+
item_name: item.item_name,
64+
item_type: item.item_type,
65+
is_ts: item.is_ts,
66+
depth: 0,
67+
isExpanded: false,
68+
childrenLoaded: false,
69+
hasChildren: item.item_type === 'D', // Directories have children
70+
}))
71+
setFlatItems(initialFlatItems)
72+
}, [])
73+
74+
/**
75+
* Collapses a directory node and removes all its descendants from the flat array.
76+
* Also resets childrenLoaded flag so re-expansion will fetch fresh data.
77+
*/
78+
const collapseNode = useCallback((itemId: number) => {
79+
setFlatItems(prev => {
80+
const itemIndex = prev.findIndex(item => item.item_id === itemId)
81+
if (itemIndex === -1) return prev
82+
83+
const item = prev[itemIndex]
84+
85+
// Find all descendants (items that come after this one with greater depth)
86+
const descendants: number[] = []
87+
for (let i = itemIndex + 1; i < prev.length; i++) {
88+
if (prev[i].depth <= item.depth) {
89+
break // No longer in this subtree
90+
}
91+
descendants.push(i)
92+
}
93+
94+
// Remove descendants and mark as collapsed
95+
const updated = [...prev]
96+
updated[itemIndex] = {
97+
...updated[itemIndex],
98+
isExpanded: false,
99+
childrenLoaded: false // Reset so re-expand will fetch again
100+
}
101+
102+
// Remove descendants in reverse order to maintain indices
103+
for (let i = descendants.length - 1; i >= 0; i--) {
104+
updated.splice(descendants[i], 1)
105+
}
106+
107+
return updated
108+
})
109+
}, [])
110+
111+
/**
112+
* Expands a directory node. Assumes children are already in the flat array.
113+
* Use loadChildren() first if children haven't been loaded yet.
114+
*/
115+
const expandNode = useCallback((itemId: number) => {
116+
setFlatItems(prev => {
117+
const itemIndex = prev.findIndex(item => item.item_id === itemId)
118+
if (itemIndex === -1) return prev
119+
120+
// Mark as expanded
121+
const updated = [...prev]
122+
updated[itemIndex] = { ...updated[itemIndex], isExpanded: true }
123+
return updated
124+
})
125+
}, [])
126+
127+
/**
128+
* Extracts the item name from a full path.
129+
* Handles both Unix and Windows paths.
130+
*/
131+
const extractItemName = useCallback((path: string): string => {
132+
return path.split('/').filter(Boolean).pop() || path
133+
}, [])
134+
135+
/**
136+
* Loads children for a directory node from the API.
137+
* Children are always loaded with tombstones - filtering happens client-side.
138+
*/
139+
const loadChildren = useCallback(async (itemId: number, parentPath: string, parentDepth: number) => {
140+
// Check ref synchronously to prevent race conditions with stale closures
141+
if (loadingItemsRef.current.has(itemId)) {
142+
return
143+
}
144+
145+
// Add to both ref (immediate) and state (for UI)
146+
loadingItemsRef.current.add(itemId)
147+
setLoadingItems(prev => new Set(prev).add(itemId))
148+
149+
try {
150+
// Query from backend using the immediate children endpoint
151+
// Backend always returns tombstones - we filter client-side for better UX
152+
const params = new URLSearchParams({
153+
root_id: rootId.toString(),
154+
parent_path: parentPath,
155+
})
156+
157+
const url = `/api/items/immediate-children?${params}`
158+
const response = await fetch(url)
159+
if (!response.ok) {
160+
throw new Error(`Failed to fetch children: ${response.statusText}`)
161+
}
162+
163+
const items = await response.json() as ImmediateChildrenResponse[]
164+
165+
// Transform API response to TreeNodeData format
166+
const childItems: TreeNodeData[] = items.map(item => {
167+
const itemName = extractItemName(item.item_path)
168+
return {
169+
item_id: item.item_id,
170+
item_path: item.item_path,
171+
item_name: itemName,
172+
item_type: item.item_type as 'F' | 'D' | 'S' | 'O',
173+
is_ts: item.is_ts,
174+
name: itemName, // sortTreeItems expects 'name' field
175+
}
176+
})
177+
178+
// Sort children (directories first, then alphabetically)
179+
const sortedChildren = sortTreeItems(childItems)
180+
181+
// Insert children into flat array
182+
setFlatItems(prev => {
183+
const itemIndex = prev.findIndex(item => item.item_id === itemId)
184+
if (itemIndex === -1) return prev
185+
186+
const updated = [...prev]
187+
188+
// Mark parent as expanded and children loaded
189+
updated[itemIndex] = {
190+
...updated[itemIndex],
191+
isExpanded: true,
192+
childrenLoaded: true,
193+
}
194+
195+
// Convert children to FlatTreeItems
196+
const childFlatItems: FlatTreeItem[] = sortedChildren.map(child => ({
197+
item_id: child.item_id,
198+
item_path: child.item_path,
199+
item_name: child.item_name,
200+
item_type: child.item_type,
201+
is_ts: child.is_ts,
202+
depth: parentDepth + 1,
203+
isExpanded: false,
204+
childrenLoaded: false,
205+
hasChildren: child.item_type === 'D',
206+
}))
207+
208+
// Insert children after parent
209+
updated.splice(itemIndex + 1, 0, ...childFlatItems)
210+
211+
return updated
212+
})
213+
} catch (error) {
214+
console.error('Error loading children:', error)
215+
} finally {
216+
// Remove from both ref and state
217+
loadingItemsRef.current.delete(itemId)
218+
setLoadingItems(prev => {
219+
const updated = new Set(prev)
220+
updated.delete(itemId)
221+
return updated
222+
})
223+
}
224+
}, [rootId, extractItemName])
225+
226+
/**
227+
* Toggles the expansion state of a directory node.
228+
* If collapsed, expands it (loading children if needed).
229+
* If expanded, collapses it and removes descendants from view.
230+
*/
231+
const toggleNode = useCallback(async (itemId: number) => {
232+
// Use ref to get current state and avoid stale closure issues
233+
const item = flatItemsRef.current.find(i => i.item_id === itemId)
234+
if (!item || !item.hasChildren) return
235+
236+
// If currently expanded, collapse it
237+
if (item.isExpanded) {
238+
collapseNode(itemId)
239+
return
240+
}
241+
242+
// If not expanded, expand it
243+
// If children not loaded, fetch them first
244+
if (!item.childrenLoaded) {
245+
await loadChildren(itemId, item.item_path, item.depth)
246+
} else {
247+
// Children already loaded, just expand
248+
expandNode(itemId)
249+
}
250+
}, [collapseNode, loadChildren, expandNode])
251+
252+
/**
253+
* Checks if a specific node is currently loading its children.
254+
*/
255+
const isLoading = useCallback((itemId: number) => loadingItems.has(itemId), [loadingItems])
256+
257+
return {
258+
flatItems,
259+
initializeTree,
260+
toggleNode,
261+
isLoading,
262+
}
263+
}

0 commit comments

Comments
 (0)