fix(sidebar): keep file tree expanded while refreshing root (#2092)

Replace the destructive refreshRoot() with an incremental refresh that

re-fetches the root and every expanded directory while preserving

childrenByDir. This keeps the file tree expanded instead of collapsing

it to the root on every manual refresh.

Closes #2036

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
This commit is contained in:
Leonid
2026-07-11 14:34:03 +03:00
committed by GitHub
co-authored by bashrusakh
parent 743d1c90c0
commit cd1ffa8b66
@@ -437,6 +437,7 @@ export const SidebarFilesTree: React.FC = () => {
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
const refreshAbortRef = React.useRef<AbortController | null>(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({});