From 4a37b9a0057db50b74dff4197e83e923e4263297 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 26 Jun 2026 12:47:01 +0300 Subject: [PATCH] fix: clean up stale file tree paths on startup Removes missing expanded folders from persisted Files state Prevents repeated 404 noise for stale file tree paths Avoids startup SDK race when restoring sessions --- .../ui/src/components/views/FilesView.tsx | 31 ++++++++++----- .../src/stores/useFilesViewTabsStore.test.ts | 19 ++++++++++ .../ui/src/stores/useFilesViewTabsStore.ts | 38 +++++++++++++++++++ packages/ui/src/sync/session-actions.test.ts | 15 ++++++++ packages/ui/src/sync/session-actions.ts | 14 +++---- 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 63a6e0c9..d788584d 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -811,9 +811,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); - const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); - const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); - const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); + const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); + const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); + const removeExpandedPathsByPrefix = useFilesViewTabsStore((state) => state.removeExpandedPathsByPrefix); + const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); const expandPaths = useFilesViewTabsStore((state) => state.expandPaths); @@ -1203,12 +1204,22 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return; } - const message = error instanceof Error ? error.message : String(error ?? ''); - console.error('Failed to load files directory:', error); - setLoadErrorsByDir((prev) => ({ - ...prev, - [normalizedDir]: message, - })); + const message = error instanceof Error ? error.message : String(error ?? ''); + if (message === 'Directory not found' && root && normalizedDir !== root) { + removeExpandedPathsByPrefix(root, normalizedDir); + setLoadErrorsByDir((prev) => { + if (!prev[normalizedDir]) return prev; + const next = { ...prev }; + delete next[normalizedDir]; + return next; + }); + return; + } + console.error('Failed to load files directory:', error); + setLoadErrorsByDir((prev) => ({ + ...prev, + [normalizedDir]: message, + })); }) .finally(() => { if (!isCurrentRequest()) { @@ -1220,7 +1231,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); }); - }, [files, mapDirectoryEntries]); + }, [files, mapDirectoryEntries, removeExpandedPathsByPrefix, root]); const refreshRoot = React.useCallback(async () => { if (!root) { diff --git a/packages/ui/src/stores/useFilesViewTabsStore.test.ts b/packages/ui/src/stores/useFilesViewTabsStore.test.ts index 11472c07..d08df0f6 100644 --- a/packages/ui/src/stores/useFilesViewTabsStore.test.ts +++ b/packages/ui/src/stores/useFilesViewTabsStore.test.ts @@ -28,4 +28,23 @@ describe('useFilesViewTabsStore', () => { expect(useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths).toEqual(['/repo/src']); }); + + test('removes stale expanded paths by prefix without closing files', () => { + const root = '/repo'; + const store = useFilesViewTabsStore.getState(); + + store.addOpenPath(root, '/repo/src/index.ts'); + store.expandPaths(root, [ + '/repo/src', + '/repo/bun test packages', + '/repo/bun test packages/web', + '/repo/other', + ]); + + store.removeExpandedPathsByPrefix(root, '/repo/bun test packages'); + + const state = useFilesViewTabsStore.getState().byRoot[root]; + expect(state?.openPaths).toEqual(['/repo/src/index.ts']); + expect(state?.expandedPaths).toEqual(['/repo/src', '/repo/other']); + }); }); diff --git a/packages/ui/src/stores/useFilesViewTabsStore.ts b/packages/ui/src/stores/useFilesViewTabsStore.ts index 66e43c59..1f391cf0 100644 --- a/packages/ui/src/stores/useFilesViewTabsStore.ts +++ b/packages/ui/src/stores/useFilesViewTabsStore.ts @@ -18,6 +18,7 @@ type FilesViewTabsActions = { addOpenPath: (root: string, path: string, options?: { allowOutsideRoot?: boolean }) => void; removeOpenPath: (root: string, path: string) => void; removeOpenPathsByPrefix: (root: string, prefixPath: string) => void; + removeExpandedPathsByPrefix: (root: string, prefixPath: string) => void; setSelectedPath: (root: string, path: string | null, options?: { allowOutsideRoot?: boolean }) => void; ensureSelectedPath: (root: string) => void; toggleExpandedPath: (root: string, path: string) => void; @@ -270,6 +271,43 @@ export const useFilesViewTabsStore = create()( }); }, + removeExpandedPathsByPrefix: (root, prefixPath) => { + const normalizedRoot = normalizePath((root || '').trim()); + const normalizedPrefix = normalizePath((prefixPath || '').trim()); + if (!normalizedRoot || !normalizedPrefix) { + return; + } + + set((state) => { + const current = state.byRoot[normalizedRoot]; + if (!current) { + return state; + } + + const comparablePrefix = toComparablePath(normalizedPrefix); + const comparablePrefixWithSlash = comparablePrefix.endsWith('/') ? comparablePrefix : `${comparablePrefix}/`; + const expandedPaths = current.expandedPaths.filter((candidate) => { + const comparablePath = toComparablePath(candidate); + return comparablePath !== comparablePrefix && !comparablePath.startsWith(comparablePrefixWithSlash); + }); + + if (expandedPaths.length === current.expandedPaths.length) { + return state; + } + + const byRoot = { + ...state.byRoot, + [normalizedRoot]: { + ...current, + expandedPaths, + touchedAt: Date.now(), + }, + }; + + return { byRoot: clampRoots(byRoot, 20) }; + }); + }, + setSelectedPath: (root, path, options) => { const normalizedRoot = normalizePath((root || '').trim()); const normalizedPath = path ? normalizePath(path.trim()) : null; diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 4c462bd5..a9115409 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -220,6 +220,21 @@ function createChildStores(entries: Array<[string, StoreApi]>) { } as unknown as import("./child-store").ChildStoreManager } +describe("fetchMessagesForSession startup race", () => { + test("does not reject before sync action refs are initialized", async () => { + const { fetchMessagesForSession } = await import("./session-actions") + + let error: unknown = null + try { + await fetchMessagesForSession("session-a", "/test/project") + } catch (err) { + error = err + } + + expect(error).toBe(null) + }) +}) + describe("shareSession live state", () => { beforeEach(() => { replyCalls.length = 0 diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 3055f336..66789135 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1126,19 +1126,19 @@ export async function fetchMessagesForSession(sessionID: string, directory?: str const resolvedDir = directory ?? dir() if (!resolvedDir) return - const s = sdk() - const store = directory - ? dirStoreForDirectory(directory) - : dirStore() - - if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return - const loadingKey = `${resolvedDir}:${sessionID}` if (FETCH_MESSAGES_LOADING.has(loadingKey)) return FETCH_MESSAGES_LOADING.add(loadingKey) try { + const s = sdk() + const store = directory + ? dirStoreForDirectory(directory) + : dirStore() + + if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return + const result = await retry(async () => { const response = await s.session.messages({ sessionID,