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,