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.
This commit is contained in:
Mark Xian
2026-07-20 02:55:56 -04:00
committed by GitHub
parent 5fcf777617
commit 936ec06e5a
11 changed files with 392 additions and 32 deletions
@@ -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<typeof openMatchResult>[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()
})
})
@@ -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<number | null>): void {
@@ -9,16 +10,20 @@ export function cancelRevealFrame(frameRef: React.RefObject<number | null>): 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<number | null>
}): 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)
@@ -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,
@@ -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<string, unknown>, worktreeId: string) {
const updates: Record<string, unknown>[] = []
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<void> {
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 }
})
})
})
@@ -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 {
@@ -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
})
})
})
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'>
): FileSearchResultOwner {
return {
worktreeId,
runtimeEnvironmentId: settings.activeRuntimeEnvironmentId?.trim() || null
}
}
+7
View File
@@ -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<string>()
} satisfies Omit<
@@ -723,6 +725,7 @@ export type EditorSlice = {
includePattern: string
excludePattern: string
results: SearchResult | null
resultOwner: FileSearchResultOwner | null
loading: boolean
collapsedFiles: Set<string>
seedRequestId?: number
@@ -1528,6 +1531,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
...(shouldSeed
? {
results: null,
resultOwner: null,
loading: false,
collapsedFiles: new Set<string>(),
seedRequestId: (current.seedRequestId ?? 0) + 1
@@ -4106,6 +4110,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
...current,
query: '',
results: null,
resultOwner: null,
loading: false,
collapsedFiles: new Set()
}
@@ -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'])
}
@@ -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.
@@ -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)
}