diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 0dcf8ca9..62eb88c0 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -437,6 +437,7 @@ export const SidebarFilesTree: React.FC = () => { const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); + const refreshAbortRef = React.useRef(null); // Hydrate the per-root cache on mount or root change. The cache is // module-scoped so it survives close-and-reopen of the right sidebar; @@ -623,12 +624,62 @@ export const SidebarFilesTree: React.FC = () => { const refreshRoot = React.useCallback(async () => { if (!root) return; - loadedDirsRef.current = new Set(); - inFlightDirsRef.current = new Set(); - setLoadErrorsByDir({}); - setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); + // Cancel any previous refresh so stale results for the old root don't + // land after the user switches projects. + refreshAbortRef.current?.abort(); + const controller = new AbortController(); + refreshAbortRef.current = controller; - await loadDirectory(root); + try { + // Refresh root and every expanded directory under it, but keep the + // cached children visible while re-fetching so the tree stays expanded + // and does not flash/collapse. Read expanded paths from the store at + // call time so this callback stays stable when directories are toggled. + const currentExpanded = useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths ?? []; + const normalizedExpanded = currentExpanded + .map((p) => normalizePath(p)) + .filter((normalized): normalized is string => + Boolean(normalized) && normalized !== root && normalized.startsWith(`${root}/`), + ); + const pathsToRefresh = [root, ...normalizedExpanded]; + + loadedDirsRef.current = new Set(loadedDirsRef.current); + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + for (const dirPath of pathsToRefresh) { + loadedDirsRef.current.delete(dirPath); + inFlightDirsRef.current.delete(dirPath); + } + + setLoadErrorsByDir((prev) => { + if (Object.keys(prev).length === 0) return prev; + const next = { ...prev }; + let changed = false; + for (const dirPath of pathsToRefresh) { + if (dirPath in next) { + delete next[dirPath]; + changed = true; + } + } + return changed ? next : prev; + }); + + const isCancelled = () => controller.signal.aborted; + + // Load root first, then expanded children with the same 3-at-a-time + // concurrency limit used on startup to avoid API stampede. + await loadDirectory(root, isCancelled); + for (let i = 0; i < normalizedExpanded.length && !controller.signal.aborted; i += 3) { + const batch = normalizedExpanded.slice(i, i + 3); + await Promise.all(batch.map((dirPath) => loadDirectory(dirPath, isCancelled))); + } + } catch (error) { + if (controller.signal.aborted) return; + console.error('Failed to refresh sidebar tree:', error); + } finally { + if (refreshAbortRef.current === controller) { + refreshAbortRef.current = null; + } + } }, [loadDirectory, root]); /** @@ -652,6 +703,9 @@ export const SidebarFilesTree: React.FC = () => { React.useEffect(() => { if (!root) return; + // Cancel any pending refresh so stale directory listings don't land after + // the user switches projects or toggles showHidden / showGitignored. + refreshAbortRef.current?.abort(); loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); setLoadErrorsByDir({});