diff --git a/src/renderer/src/components/quick-open-file-list-metadata.react.test.tsx b/src/renderer/src/components/quick-open-file-list-metadata.react.test.tsx new file mode 100644 index 00000000000..160f852e224 --- /dev/null +++ b/src/renderer/src/components/quick-open-file-list-metadata.react.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { Worktree } from '../../../shared/worktree/types' +import type { RuntimeFileListState } from './quick-open-file-list' +import { + cancelRuntimeFileListMock, + flushEffects, + listRuntimeFilesMock, + makeRemoteWorktree, + renderProbe, + searchRuntimeFilePathsMock, + seedRemoteWorktree +} from './quick-open-file-list-test-harness' + +vi.mock('@/runtime/runtime-file-search-client', async () => { + const mocks = await import('./__mocks__/quick-open-runtime-file-client') + return { + listRuntimeFiles: mocks.listRuntimeFilesMock, + cancelRuntimeFileList: mocks.cancelRuntimeFileListMock, + searchRuntimeFilePaths: mocks.searchRuntimeFilePathsMock + } +}) + +async function replaceWorktrees(worktrees: Worktree[]): Promise { + await act(async () => { + useAppStore.setState({ worktreesByRepo: { 'repo-remote': worktrees } }) + }) + await flushEffects() +} + +it('keeps a settled inventory through metadata updates', async () => { + seedRemoteWorktree() + const states: RuntimeFileListState[] = [] + await renderProbe({ enabled: true, states, worktreeId: 'wt-remote' }) + const settledFiles = states.at(-1)?.files + const initialRequest = listRuntimeFilesMock.mock.calls[0]?.[1] + const rendersBeforeUpdates = states.length + + await replaceWorktrees([{ ...makeRemoteWorktree(), head: 'commit-1' }]) + await replaceWorktrees([{ ...makeRemoteWorktree(), head: 'commit-2' }]) + + expect(listRuntimeFilesMock).toHaveBeenCalledOnce() + expect(cancelRuntimeFileListMock).not.toHaveBeenCalled() + expect(initialRequest?.signal?.aborted).toBe(false) + expect(states.slice(rendersBeforeUpdates).every((state) => !state.loading)).toBe(true) + expect(states.at(-1)?.files).toBe(settledFiles) +}) + +it('keeps an in-flight query through metadata updates but replaces it for changed exclusions', async () => { + vi.useFakeTimers() + seedRemoteWorktree() + searchRuntimeFilePathsMock.mockReturnValue(new Promise(() => {})) + try { + await renderProbe({ enabled: true, states: [], query: 'target', worktreeId: 'wt-remote' }) + await act(async () => vi.advanceTimersByTimeAsync(120)) + const initialRequest = searchRuntimeFilePathsMock.mock.calls[0]?.[1] + await replaceWorktrees([{ ...makeRemoteWorktree(), comment: 'Updated comment' }]) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(initialRequest?.signal?.aborted).toBe(false) + expect(searchRuntimeFilePathsMock).toHaveBeenCalledOnce() + + const nested = { ...makeRemoteWorktree(), id: 'nested', path: '/srv/remote/nested' } + await replaceWorktrees([makeRemoteWorktree(), nested]) + expect(initialRequest?.signal?.aborted).toBe(true) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(searchRuntimeFilePathsMock).toHaveBeenCalledTimes(2) + expect(searchRuntimeFilePathsMock.mock.calls[1]?.[1].excludePaths).toEqual([nested.path]) + + await replaceWorktrees([makeRemoteWorktree()]) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(searchRuntimeFilePathsMock).toHaveBeenCalledTimes(3) + expect(searchRuntimeFilePathsMock.mock.calls[2]?.[1].excludePaths).toBeUndefined() + } finally { + vi.useRealTimers() + } +}) diff --git a/src/renderer/src/components/quick-open-file-list-target.ts b/src/renderer/src/components/quick-open-file-list-target.ts index f85bfccb918..15c093cfdb8 100644 --- a/src/renderer/src/components/quick-open-file-list-target.ts +++ b/src/renderer/src/components/quick-open-file-list-target.ts @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { Worktree } from '../../../shared/worktree/types' import { getNestedWorktreeExcludePaths } from './quick-open-nested-worktrees' @@ -44,3 +45,14 @@ export function getNestedWorktreeExcludeRequest( // JSON preserves newline-containing paths in stable dependency keys; pass the original path array to IPC. return { paths, key: JSON.stringify(paths) } } + +// Why: worktree metadata updates rebuild the request object; only a key change should restart scans. +export function useKeyedExcludeRequest( + request: NestedWorktreeExcludeRequest +): NestedWorktreeExcludeRequest { + const [keyed, setKeyed] = useState(request) + if (keyed.key !== request.key) { + setKeyed(request) + } + return keyed.key === request.key ? keyed : request +} diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 43bd560079f..36e1fb11dd7 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -1,4 +1,4 @@ -import { getRuntimeFileListTarget } from './quick-open-file-list-target' +import { getRuntimeFileListTarget, useKeyedExcludeRequest } from './quick-open-file-list-target' export { getRuntimeFileListTarget, getNestedWorktreeExcludeRequest @@ -93,7 +93,7 @@ export function useRuntimeFileListForWorktree({ () => getRuntimeFileListTarget(worktreeId, worktreePath, repoWorktrees), [repoWorktrees, worktreeId, worktreePath] ) - const { excludeRequest } = target + const excludeRequest = useKeyedExcludeRequest(target.excludeRequest) const operationOwnerState = useAppStore( useShallow((state) => ({