diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts index 387b514f5f..53b2a8db66 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts @@ -271,6 +271,111 @@ describe('useProjectTree', () => { expect(readDir).toHaveBeenCalledTimes(2) }) + it.each((['child', 'targeted', 'full'] as const).flatMap(read => [false, true].map(refresh => ({ read, refresh }))))( + 'keeps $read results within the root generation (refresh=$refresh)', + async ({ read, refresh }) => { + const src = { name: 'src', path: '/p/src', isDirectory: true } + const fresh = { name: 'fresh.txt', path: '/p/fresh.txt', isDirectory: false } + let resolveOld!: (value: HermesReadDirResult) => void + + const oldRead = new Promise(resolve => { + resolveOld = resolve + }) + + readDir.mockResolvedValueOnce(ok([src])) + readDir.mockImplementationOnce(() => oldRead) + readDir.mockImplementation(async path => + ok(path === '/p/src' ? [{ ...fresh, path: '/p/src/fresh.txt' }] : [src, fresh]) + ) + const { result } = renderHook(() => useProjectTree('/p')) + await waitFor(() => expect(result.current.data.map(node => node.name)).toEqual(['src'])) + + act(() => { + if (read === 'child') { + void result.current.loadChildren('/p/src') + } else { + notifyWorkspaceChanged(read === 'targeted' ? '/p/changed.txt' : undefined) + } + }) + await waitFor(() => expect(readDir).toHaveBeenCalledTimes(2)) + + if (refresh) { + await act(async () => { + await result.current.refreshRoot() + }) + expect(result.current.data.map(node => node.name)).toEqual(['src', 'fresh.txt']) + + if (read === 'child') { + await act(async () => { + await result.current.loadChildren('/p/src') + }) + } + } + + await act(async () => { + resolveOld( + ok([{ name: 'stale.txt', path: read === 'child' ? '/p/src/stale.txt' : '/p/stale.txt', isDirectory: false }]) + ) + }) + + if (refresh) { + expect(result.current.data.map(node => node.name)).toEqual(['src', 'fresh.txt']) + + if (read === 'child') { + expect(result.current.data[0]?.children?.map(node => node.name)).toEqual(['fresh.txt']) + } + } else if (read === 'child') { + expect(result.current.data[0]?.children?.map(node => node.name)).toEqual(['stale.txt']) + } else { + expect(result.current.data.map(node => node.name)).toEqual(['stale.txt']) + } + } + ) + + it('keeps a refreshed folder read in flight when its predecessor finishes', async () => { + const src = { name: 'src', path: '/p/src', isDirectory: true } + let finishOld!: (value: HermesReadDirResult) => void + let finishNew!: (value: HermesReadDirResult) => void + + const oldRead = new Promise(resolve => { + finishOld = resolve + }) + + const newRead = new Promise(resolve => { + finishNew = resolve + }) + + readDir.mockResolvedValueOnce(ok([src])) + readDir.mockImplementationOnce(() => oldRead) + readDir.mockResolvedValueOnce(ok([src])) + readDir.mockImplementationOnce(() => newRead) + readDir.mockResolvedValue(ok([])) + const { result } = renderHook(() => useProjectTree('/p')) + await waitFor(() => expect(result.current.data[0]?.name).toBe('src')) + act(() => { + void result.current.loadChildren(src.path) + }) + await waitFor(() => expect(readDir).toHaveBeenCalledTimes(2)) + await act(async () => { + await result.current.refreshRoot() + }) + act(() => { + void result.current.loadChildren(src.path) + }) + await waitFor(() => expect(readDir).toHaveBeenCalledTimes(4)) + await act(async () => { + finishOld(ok([])) + }) + act(() => { + void result.current.loadChildren(src.path) + }) + await act(async () => { + finishNew(ok([{ name: 'new.txt', path: '/p/src/new.txt', isDirectory: false }])) + }) + expect(readDir).toHaveBeenCalledTimes(4) + expect(result.current.data[0]?.children?.map(node => node.name)).toEqual(['new.txt']) + }) + it('refreshRoot reloads the root and clears prior error', async () => { readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' }) readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }])) diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index aeb1a1713d..075632bc7a 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -294,7 +294,15 @@ async function revalidateTree( ): Promise { const state = $projectTree.get() - if (!cwd || state.cwd !== cwd || !state.loaded || desktopFsCacheKey() !== connectionKey) { + // The workspace effect runs before the connection-change root effect. Do + // not start a new backend's read using the previous backend's loaded tree. + if ( + !cwd || + state.cwd !== cwd || + !state.loaded || + lastConnectionKey !== connectionKey || + desktopFsCacheKey() !== connectionKey + ) { return } @@ -319,6 +327,7 @@ async function revalidateTree( setProjectTree(latest => { if ( latest.cwd !== cwd || + latest.requestId !== state.requestId || !latest.loaded || desktopFsCacheKey() !== connectionKey || showsIgnoredFiles(rootPath) !== filterAtRead @@ -375,6 +384,7 @@ async function revalidateTree( setProjectTree(latest => latest.cwd === cwd && + latest.requestId === state.requestId && latest.loaded && desktopFsCacheKey() === connectionKey && showsIgnoredFiles(rootPath) === filterAtRead @@ -456,7 +466,10 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { const loadChildren = useCallback( async (id: string) => { - const inflightKey = `${connectionKey}:${id}` + const requestId = $projectTree.get().requestId + // A root refresh can start a new read of this same folder. Its pending + // entry and result must not be released or replaced by the old read. + const inflightKey = `${connectionKey}:${requestId}:${id}` if (!cwd || inflight.has(inflightKey)) { return @@ -493,6 +506,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { // show-ignored toggle must not land after it. if ( current.cwd !== cwd || + current.requestId !== requestId || desktopFsCacheKey() !== connectionKey || showsIgnoredFiles(rootPath) !== filterAtRead ) {