From 936ec06e5ab550657dafe8bc56c765b701b8fd5e Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Mon, 20 Jul 2026 14:55:56 +0800 Subject: [PATCH] fix(search): bind content results to runtime owner (#9262) Capture the worktree and runtime route that produced each committed content-search result set, then reuse that owner for opens and retries. This prevents active-worktree and ambient-runtime changes from retargeting remote matches while preserving explicit local and SSH routing. Closes #9185. --- .../right-sidebar/search-match-open.test.ts | 98 +++++++++++ .../right-sidebar/search-match-open.ts | 47 +++-- .../right-sidebar/useFileSearchPanel.ts | 22 +-- .../useFileSearchRunner.test.tsx | 166 ++++++++++++++++++ .../right-sidebar/useFileSearchRunner.ts | 27 ++- .../src/lib/file-search-result-owner.test.ts | 26 +++ .../src/lib/file-search-result-owner.ts | 16 ++ src/renderer/src/store/slices/editor.ts | 7 + .../src/store/slices/store-cascades.test.ts | 3 + .../src/store/slices/worktrees.test.ts | 8 + src/renderer/src/store/slices/worktrees.ts | 4 + 11 files changed, 392 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/search-match-open.test.ts create mode 100644 src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx create mode 100644 src/renderer/src/lib/file-search-result-owner.test.ts create mode 100644 src/renderer/src/lib/file-search-result-owner.ts diff --git a/src/renderer/src/components/right-sidebar/search-match-open.test.ts b/src/renderer/src/components/right-sidebar/search-match-open.test.ts new file mode 100644 index 00000000000..3724851aa9a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/search-match-open.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { openMatchResult } from './search-match-open' + +type IntendedResultOwner = { + worktreeId: string + runtimeEnvironmentId: string | null +} + +function openResult(resultOwner: IntendedResultOwner) { + const openFile = vi.fn() + const setPendingEditorReveal = vi.fn() + const params = { + resultOwner, + fileResult: { + filePath: '/owner/repo/src/example.ts', + relativePath: 'src/example.ts', + matches: [] + }, + match: { + line: 7, + column: 4, + matchLength: 6, + lineContent: 'const result = owner' + }, + openFile, + setPendingEditorReveal, + revealRafRef: { current: null }, + revealInnerRafRef: { current: null } + } satisfies Parameters[0] + + openMatchResult(params) + return { openFile, setPendingEditorReveal } +} + +describe('openMatchResult', () => { + beforeEach(() => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + it('opens with the remote owner captured by the search after the active worktree changes', () => { + const { openFile } = openResult({ + worktreeId: 'worktree-that-produced-results', + runtimeEnvironmentId: 'runtime-that-produced-results' + }) + + expect(openFile).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'worktree-that-produced-results', + runtimeEnvironmentId: 'runtime-that-produced-results' + }), + { suppressActiveRuntimeFallback: false } + ) + }) + + it('keeps an explicitly local result local when another runtime is active', () => { + const { openFile } = openResult({ + worktreeId: 'local-worktree', + runtimeEnvironmentId: null + }) + + expect(openFile).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'local-worktree', + runtimeEnvironmentId: null + }), + { suppressActiveRuntimeFallback: true } + ) + }) + + it('does not guess an owner for results without a committed search source', () => { + const openFile = vi.fn() + + openMatchResult({ + resultOwner: null, + fileResult: { + filePath: '/unresolved/repo/file.ts', + relativePath: 'file.ts', + matches: [] + }, + match: { + line: 1, + column: 1, + matchLength: 4, + lineContent: 'test' + }, + openFile, + setPendingEditorReveal: vi.fn(), + revealRafRef: { current: null }, + revealInnerRafRef: { current: null } + }) + + expect(openFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/search-match-open.ts b/src/renderer/src/components/right-sidebar/search-match-open.ts index 43c328a3606..0e2bf3b74ac 100644 --- a/src/renderer/src/components/right-sidebar/search-match-open.ts +++ b/src/renderer/src/components/right-sidebar/search-match-open.ts @@ -1,4 +1,5 @@ import { detectLanguage } from '@/lib/language-detect' +import type { FileSearchResultOwner } from '@/lib/file-search-result-owner' import type { SearchFileResult, SearchMatch } from '../../../../shared/types' export function cancelRevealFrame(frameRef: React.RefObject): void { @@ -9,16 +10,20 @@ export function cancelRevealFrame(frameRef: React.RefObject): voi } export function openMatchResult(params: { - activeWorktreeId: string + resultOwner: FileSearchResultOwner | null fileResult: SearchFileResult match: SearchMatch - openFile: (file: { - filePath: string - relativePath: string - worktreeId: string - language: string - mode: 'edit' - }) => void + openFile: ( + file: { + filePath: string + relativePath: string + worktreeId: string + language: string + mode: 'edit' + runtimeEnvironmentId: string | null + }, + options: { suppressActiveRuntimeFallback: boolean } + ) => void setPendingEditorReveal: ( reveal: { filePath: string @@ -31,7 +36,7 @@ export function openMatchResult(params: { revealInnerRafRef: React.RefObject }): void { const { - activeWorktreeId, + resultOwner, fileResult, match, openFile, @@ -40,13 +45,23 @@ export function openMatchResult(params: { revealInnerRafRef } = params - openFile({ - filePath: fileResult.filePath, - relativePath: fileResult.relativePath, - worktreeId: activeWorktreeId, - language: detectLanguage(fileResult.relativePath), - mode: 'edit' - }) + if (!resultOwner) { + return + } + + openFile( + { + filePath: fileResult.filePath, + relativePath: fileResult.relativePath, + worktreeId: resultOwner.worktreeId, + runtimeEnvironmentId: resultOwner.runtimeEnvironmentId, + language: detectLanguage(fileResult.relativePath), + mode: 'edit' + }, + { + suppressActiveRuntimeFallback: resultOwner.runtimeEnvironmentId === null + } + ) cancelRevealFrame(revealRafRef) cancelRevealFrame(revealInnerRafRef) diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts index 3e315c34a7f..f0935b625a0 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts @@ -44,6 +44,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch const fileSearchIncludePattern = searchState?.includePattern ?? '' const fileSearchExcludePattern = searchState?.excludePattern ?? '' const fileSearchResults = searchState?.results ?? null + const fileSearchResultOwner = searchState?.resultOwner ?? null const fileSearchLoading = searchState?.loading ?? false const fileSearchCollapsedFiles = searchState?.collapsedFiles ?? EMPTY_COLLAPSED_FILES const fileSearchSeedRequestId = searchState?.seedRequestId @@ -127,18 +128,22 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch useEffect(() => { if (!worktreePath) { cancelPendingSearch() - updateActiveSearchState({ results: null }) + updateActiveSearchState({ results: null, resultOwner: null }) } }, [worktreePath, cancelPendingSearch, updateActiveSearchState]) - const deferredSearchResults = useDeferredValue(fileSearchResults) + const committedSearchResults = useMemo( + () => ({ results: fileSearchResults, owner: fileSearchResultOwner }), + [fileSearchResultOwner, fileSearchResults] + ) + const deferredSearchResults = useDeferredValue(committedSearchResults) const searchRows = useMemo( () => buildSearchRows( - fileSearchQuery.trim() && worktreePath ? deferredSearchResults : null, + fileSearchQuery.trim() && worktreePath ? deferredSearchResults.results : null, fileSearchCollapsedFiles ), - [deferredSearchResults, fileSearchCollapsedFiles, fileSearchQuery, worktreePath] + [deferredSearchResults.results, fileSearchCollapsedFiles, fileSearchQuery, worktreePath] ) useEffect(() => { @@ -218,11 +223,8 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch const handleMatchClick = useCallback( (fileResult: SearchFileResult, match: SearchMatch) => { - if (!activeWorktreeId) { - return - } openMatchResult({ - activeWorktreeId, + resultOwner: deferredSearchResults.owner, fileResult, match, openFile, @@ -231,7 +233,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch revealInnerRafRef }) }, - [activeWorktreeId, openFile, setPendingEditorReveal] + [deferredSearchResults.owner, openFile, setPendingEditorReveal] ) return { @@ -274,7 +276,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch } }, resultsProps: { - results: deferredSearchResults, + results: deferredSearchResults.results, hasCommittedResults: fileSearchResults !== null, query: fileSearchQuery, loading: fileSearchLoading, diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx new file mode 100644 index 00000000000..62bbf4f9bec --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SearchResult } from '../../../../shared/types' +import { useFileSearchRunner } from './useFileSearchRunner' + +const mocks = vi.hoisted(() => ({ + getConnectionId: vi.fn(), + getState: vi.fn(), + searchRuntimeFiles: vi.fn() +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: mocks.getConnectionId +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + searchRuntimeFiles: mocks.searchRuntimeFiles +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: mocks.getState }) +})) + +const RESULTS: SearchResult = { + files: [], + totalMatches: 0, + truncated: false +} + +function renderSearchRunner(state: Record, worktreeId: string) { + const updates: Record[] = [] + mocks.getState.mockImplementation(() => state) + mocks.searchRuntimeFiles.mockResolvedValue(RESULTS) + + const hook = renderHook(() => + useFileSearchRunner({ + activeWorktreeId: worktreeId, + worktreePath: '/repo', + updateActiveSearchState: (update) => updates.push(update) + }) + ) + + return { hook, updates } +} + +async function finishSearch(executeSearch: (query: string) => void): Promise { + await act(async () => { + executeSearch('owner') + await vi.advanceTimersByTimeAsync(300) + }) +} + +describe('useFileSearchRunner result ownership', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.getConnectionId.mockReturnValue(null) + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('commits the explicit remote owner used for the search, not the ambient runtime', async () => { + const worktreeId = 'repo-a::/repo' + const state = { + settings: { activeRuntimeEnvironmentId: 'ambient-runtime-b' }, + repos: [{ id: 'repo-a', executionHostId: 'runtime:repo-runtime' }], + worktreesByRepo: { + 'repo-a': [{ id: worktreeId, repoId: 'repo-a', hostId: 'runtime:search-runtime-a' }] + }, + fileSearchStateByWorktree: { [worktreeId]: {} } + } + const { hook, updates } = renderSearchRunner(state, worktreeId) + + await finishSearch(hook.result.current.executeSearch) + + expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { activeRuntimeEnvironmentId: 'search-runtime-a' }, + worktreeId + }), + expect.any(Object) + ) + expect(updates).toContainEqual({ + results: RESULTS, + resultOwner: { + worktreeId, + runtimeEnvironmentId: 'search-runtime-a' + } + }) + }) + + it('commits explicit local ownership without inheriting an ambient runtime', async () => { + const worktreeId = 'repo-a::/repo' + const state = { + settings: { activeRuntimeEnvironmentId: 'ambient-runtime-b' }, + repos: [{ id: 'repo-a', executionHostId: 'runtime:repo-runtime' }], + worktreesByRepo: { + 'repo-a': [{ id: worktreeId, repoId: 'repo-a', hostId: 'local' }] + }, + fileSearchStateByWorktree: { [worktreeId]: {} } + } + const { hook, updates } = renderSearchRunner(state, worktreeId) + + await finishSearch(hook.result.current.executeSearch) + + expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( + expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null }, worktreeId }), + expect.any(Object) + ) + expect(updates).toContainEqual({ + results: RESULTS, + resultOwner: { worktreeId, runtimeEnvironmentId: null } + }) + }) + + it('preserves SSH routing through the worktree connection without a runtime owner', async () => { + const worktreeId = 'repo-a::/repo' + const state = { + settings: { activeRuntimeEnvironmentId: 'ambient-runtime-b' }, + repos: [{ id: 'repo-a', connectionId: 'ssh-target' }], + worktreesByRepo: { + 'repo-a': [{ id: worktreeId, repoId: 'repo-a', hostId: 'ssh:ssh-target' }] + }, + fileSearchStateByWorktree: { [worktreeId]: {} } + } + mocks.getConnectionId.mockReturnValue('ssh-target') + const { hook, updates } = renderSearchRunner(state, worktreeId) + + await finishSearch(hook.result.current.executeSearch) + + expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId, + connectionId: 'ssh-target' + }), + expect.any(Object) + ) + expect(updates).toContainEqual({ + results: RESULTS, + resultOwner: { worktreeId, runtimeEnvironmentId: null } + }) + }) + + it('keeps an unresolved owner local when no runtime actually handled the search', async () => { + const worktreeId = 'missing-repo::/repo' + const state = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [], + worktreesByRepo: {}, + fileSearchStateByWorktree: { [worktreeId]: {} } + } + const { hook, updates } = renderSearchRunner(state, worktreeId) + + await finishSearch(hook.result.current.executeSearch) + + expect(updates).toContainEqual({ + results: RESULTS, + resultOwner: { worktreeId, runtimeEnvironmentId: null } + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts index 4269d730342..20feac4a610 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useRef } from 'react' import { getConnectionId } from '@/lib/connection-context' +import { + createFileSearchResultOwner, + type FileSearchResultOwner +} from '@/lib/file-search-result-owner' import { createEmptyRuntimeFileSearchResult, getRuntimeFileSearchRejectedField @@ -12,7 +16,11 @@ import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime- const SEARCH_DEBOUNCE_MS = 300 const SEARCH_MAX_RESULTS = 2000 -type UpdateSearchState = (updates: { loading?: boolean; results?: SearchResult | null }) => void +type UpdateSearchState = (updates: { + loading?: boolean + results?: SearchResult | null + resultOwner?: FileSearchResultOwner | null +}) => void type UseFileSearchRunnerArgs = { activeWorktreeId: string | null @@ -53,7 +61,7 @@ export function useFileSearchRunner({ } if (!worktreePath || !activeWorktreeId) { - updateActiveSearchState({ results: null, loading: false }) + updateActiveSearchState({ results: null, resultOwner: null, loading: false }) return } @@ -65,21 +73,26 @@ export function useFileSearchRunner({ excludePattern: currentSearchState?.excludePattern || undefined }) ) { + const runtimeSettings = getRightSidebarWorktreeRuntimeSettings(activeWorktreeId) updateActiveSearchState({ results: createEmptyRuntimeFileSearchResult(), + resultOwner: createFileSearchResultOwner(activeWorktreeId, runtimeSettings), loading: false }) return } if (!query.trim()) { - updateActiveSearchState({ results: null, loading: false }) + updateActiveSearchState({ results: null, resultOwner: null, loading: false }) return } updateActiveSearchState({ loading: true }) searchTimerRef.current = setTimeout(async () => { searchTimerRef.current = null + // Why: results can outlive the selected worktree; clicks must reuse the route that produced them. + const runtimeSettings = getRightSidebarWorktreeRuntimeSettings(activeWorktreeId) + const resultOwner = createFileSearchResultOwner(activeWorktreeId, runtimeSettings) try { const state = useAppStore.getState() const connectionId = getConnectionId(activeWorktreeId) ?? undefined @@ -94,6 +107,7 @@ export function useFileSearchRunner({ if (latestSearchIdRef.current === searchId) { updateActiveSearchState({ results: createEmptyRuntimeFileSearchResult(), + resultOwner, loading: false }) } @@ -101,7 +115,7 @@ export function useFileSearchRunner({ } const results = await searchRuntimeFiles( { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + settings: runtimeSettings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -118,13 +132,14 @@ export function useFileSearchRunner({ } ) if (latestSearchIdRef.current === searchId) { - updateActiveSearchState({ results }) + updateActiveSearchState({ results, resultOwner }) } } catch (err) { console.error('Search failed:', err) if (latestSearchIdRef.current === searchId) { updateActiveSearchState({ - results: { files: [], totalMatches: 0, truncated: false } + results: { files: [], totalMatches: 0, truncated: false }, + resultOwner }) } } finally { diff --git a/src/renderer/src/lib/file-search-result-owner.test.ts b/src/renderer/src/lib/file-search-result-owner.test.ts new file mode 100644 index 00000000000..561e14a20c8 --- /dev/null +++ b/src/renderer/src/lib/file-search-result-owner.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { createFileSearchResultOwner } from './file-search-result-owner' + +describe('createFileSearchResultOwner', () => { + it('captures the exact runtime used by the completed search', () => { + const settings = { activeRuntimeEnvironmentId: ' runtime-owner-a ' } + const owner = createFileSearchResultOwner('worktree-a', settings) + settings.activeRuntimeEnvironmentId = 'runtime-owner-b' + + expect(owner).toEqual({ + worktreeId: 'worktree-a', + runtimeEnvironmentId: 'runtime-owner-a' + }) + }) + + it.each([null, '', ' '])('records %j as an explicit non-runtime owner', (environmentId) => { + expect( + createFileSearchResultOwner('local-or-ssh-worktree', { + activeRuntimeEnvironmentId: environmentId + }) + ).toEqual({ + worktreeId: 'local-or-ssh-worktree', + runtimeEnvironmentId: null + }) + }) +}) diff --git a/src/renderer/src/lib/file-search-result-owner.ts b/src/renderer/src/lib/file-search-result-owner.ts new file mode 100644 index 00000000000..b2c719f245a --- /dev/null +++ b/src/renderer/src/lib/file-search-result-owner.ts @@ -0,0 +1,16 @@ +import type { GlobalSettings } from '../../../shared/types' + +export type FileSearchResultOwner = { + worktreeId: string + runtimeEnvironmentId: string | null +} + +export function createFileSearchResultOwner( + worktreeId: string, + settings: Pick +): FileSearchResultOwner { + return { + worktreeId, + runtimeEnvironmentId: settings.activeRuntimeEnvironmentId?.trim() || null + } +} diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index bd2bc1fddeb..5856c14039c 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -72,6 +72,7 @@ import { import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { translate } from '@/i18n/i18n' +import type { FileSearchResultOwner } from '@/lib/file-search-result-owner' export type { ActiveRightSidebarTab, @@ -87,6 +88,7 @@ const DEFAULT_FILE_SEARCH_STATE = { includePattern: '', excludePattern: '', results: null, + resultOwner: null, loading: false, collapsedFiles: new Set() } satisfies Omit< @@ -723,6 +725,7 @@ export type EditorSlice = { includePattern: string excludePattern: string results: SearchResult | null + resultOwner: FileSearchResultOwner | null loading: boolean collapsedFiles: Set seedRequestId?: number @@ -1528,6 +1531,7 @@ export const createEditorSlice: StateCreator = (s ...(shouldSeed ? { results: null, + resultOwner: null, loading: false, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 @@ -4106,6 +4110,7 @@ export const createEditorSlice: StateCreator = (s ...current, query, results: null, + resultOwner: null, loading: false, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 @@ -4123,6 +4128,7 @@ export const createEditorSlice: StateCreator = (s ...current, includePattern, results: null, + resultOwner: null, loading: false, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 @@ -4177,6 +4183,7 @@ export const createEditorSlice: StateCreator = (s ...current, query: '', results: null, + resultOwner: null, loading: false, collapsedFiles: new Set() } diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 30a19084ec4..5e6e10f13fc 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -146,6 +146,7 @@ describe('removeWorktree cascade', () => { includePattern: '*.ts', excludePattern: 'dist/**', results: { files: [], totalMatches: 0, truncated: false }, + resultOwner: null, loading: false, collapsedFiles: new Set(['/path/wt1/file.ts']) } @@ -697,6 +698,7 @@ describe('removeWorktree cascade', () => { includePattern: '', excludePattern: '', results: { files: [], totalMatches: 0, truncated: false }, + resultOwner: null, loading: false, collapsedFiles: new Set() }, @@ -708,6 +710,7 @@ describe('removeWorktree cascade', () => { includePattern: '*.md', excludePattern: '', results: { files: [], totalMatches: 1, truncated: false }, + resultOwner: null, loading: false, collapsedFiles: new Set(['/path/wt2/notes.md']) } diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 3b148562442..bb120d973db 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6882,6 +6882,9 @@ describe('migrateWorktreeIdentity', () => { renamingWorktreeId: { worktreeId: OLD, rowKey: 'all:old' }, tabsByWorktree: { [OLD]: [{ id: 'tab1', worktreeId: OLD }] }, rightSidebarExplorerViewByWorktree: { [OLD]: 'search' }, + fileSearchStateByWorktree: { + [OLD]: { resultOwner: { worktreeId: OLD, runtimeEnvironmentId: 'runtime-a' } } + }, activeTabIdByWorktree: { [OLD]: 'tab1' }, browserTabsByWorktree: { [OLD]: [{ id: 'browser1', worktreeId: OLD }] }, browserPagesByWorkspace: { browser1: [{ id: 'page1', worktreeId: OLD }] }, @@ -6945,6 +6948,11 @@ describe('migrateWorktreeIdentity', () => { expect(s.gitBranchCompareRequestStatusHeadByWorktree[NEW]).toBe('head-old') expect(s.rightSidebarExplorerViewByWorktree[OLD]).toBeUndefined() expect(s.rightSidebarExplorerViewByWorktree[NEW]).toBe('search') + expect(s.fileSearchStateByWorktree[OLD]).toBeUndefined() + expect(s.fileSearchStateByWorktree[NEW]?.resultOwner).toEqual({ + worktreeId: NEW, + runtimeEnvironmentId: 'runtime-a' + }) expect(s.lastVisitedAtByWorktreeId[NEW]).toBe(123) expect(s.defaultTerminalTabsAppliedByWorktreeId[NEW]).toBe(true) // The two maps absent from the purge list are still re-keyed. diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 49c9cdce963..298a18c965c 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1824,6 +1824,10 @@ function buildWorktreeRenameState( workspace: withNewWorktreeId(snapshot.workspace), pages: snapshot.pages.map(withNewWorktreeId) })), + fileSearchStateByWorktree: (searchState: AppState['fileSearchStateByWorktree'][string]) => ({ + ...searchState, + resultOwner: searchState.resultOwner ? withNewWorktreeId(searchState.resultOwner) : null + }), unifiedTabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId), groupsByWorktree: (groups: { worktreeId: string }[]) => groups.map(withNewWorktreeId) }