diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 49cce086da6..5ca79eb798e 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -290,6 +290,7 @@ it.each([ 'src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts', 'src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts', 'src/renderer/src/components/right-sidebar/FileExplorer.tsx', + 'src/renderer/src/hooks/editor-external-watch-targets.ts', 'src/renderer/src/components/right-sidebar/useFileExplorerTree.ts', 'src/renderer/src/components/right-sidebar/use-file-explorer-tree-load-effects.ts' ])('routes %s to the template-building selected-host explorer lane', (file) => { diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 89d0963e3f8..14df75c8e09 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -75,6 +75,7 @@ export const PR_E2E_SOURCE_ROUTES = [ (file === 'src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts' || file === 'src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts' || file === 'src/renderer/src/components/right-sidebar/FileExplorer.tsx' || + file === 'src/renderer/src/hooks/editor-external-watch-targets.ts' || file === 'src/renderer/src/components/right-sidebar/useFileExplorerTree.ts' || file === 'src/renderer/src/components/right-sidebar/use-file-explorer-tree-load-effects.ts') }, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx new file mode 100644 index 00000000000..0e29d3e235c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { buildEditorExternalWatchEventHandler } from '@/hooks/editor-external-watch-event-reconciliation' +import { selectEditorExternalWatchTargets } from '@/hooks/editor-external-watch-targets' +import { getDefaultSettings } from '../../../../shared/constants' +import { useFileExplorerWatch } from './useFileExplorerWatch' + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), { + getState: () => ({ openFiles: [] }) + }) +})) +vi.mock('./file-explorer-operation-owner', () => { + const owner = { kind: 'runtime', environmentId: 'host-b', executionHostId: 'runtime:host-b' } + return { + getFileExplorerOperationOwner: () => owner, + getFileExplorerOperationOwnerFromState: () => owner + } +}) +vi.mock('@/runtime/runtime-file-client', () => ({ subscribeRuntimeFileChanges: vi.fn() })) + +const refreshDir = vi.fn(async () => {}) +const refreshTree = vi.fn(async () => 'refreshed' as const) + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + Object.defineProperty(window, 'api', { + configurable: true, + value: { fs: { onFsChanged: vi.fn(() => vi.fn()) } } + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +it('refreshes the selected host Explorer when another host publishes the same worktree id at a different root', async () => { + const { targets } = selectEditorExternalWatchTargets({ + settings: getDefaultSettings('/home/me'), + openFiles: [], + activeWorktreeId: 'same-worktree', + activeWorkspaceExecutionHostId: 'runtime:host-b', + repos: [], + worktreesByRepo: { + repo: (['host-a', 'host-b'] as const).map((host) => + makeWorktree({ + id: 'same-worktree', + repoId: 'repo', + path: `/${host}/repo`, + hostId: `runtime:${host}` + }) + ) + }, + folderWorkspaces: [], + projectGroups: [], + rightSidebarOpen: true, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + gitStatusHugeByWorktree: {}, + sshConnectionStates: new Map() + }) + const [target] = targets + const adapter = buildEditorExternalWatchEventHandler((worktreePath, runtimeEnvironmentId) => + targets.find( + (candidate) => + candidate.worktreePath === worktreePath && + candidate.runtimeEnvironmentId === runtimeEnvironmentId + ) + ) + renderHook(() => + useFileExplorerWatch({ + worktreePath: '/host-b/repo', + activeWorktreeId: 'same-worktree', + dirCache: { '/host-b/repo': { children: [] } }, + setDirCache: vi.fn(), + expanded: new Set(), + setSelectedPath: vi.fn(), + refreshDir, + refreshTree, + inlineInput: null, + dragSourcePath: null, + isNativeDragOver: false + }) + ) + // The runtime watch client roots each batch at the watched target's path. + act(() => { + adapter.handleFsChanged( + { + worktreePath: target.worktreePath, + events: [ + { + kind: 'create', + absolutePath: `${target.worktreePath}/new.ts`, + isDirectory: false + } + ] + }, + target.runtimeEnvironmentId + ) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + adapter.dispose() + expect(refreshDir).toHaveBeenCalledExactlyOnceWith('/host-b/repo') +}) diff --git a/src/renderer/src/hooks/editor-external-watch-selected-host.test.ts b/src/renderer/src/hooks/editor-external-watch-selected-host.test.ts new file mode 100644 index 00000000000..c6773a0d9c8 --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-selected-host.test.ts @@ -0,0 +1,164 @@ +import { expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { makeFolderWorkspace, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { getDefaultSettings } from '../../../shared/constants' +import { + selectEditorExternalWatchTargets, + type EditorExternalWatchTargetState +} from './editor-external-watch-targets' + +type SelectedHostState = EditorExternalWatchTargetState & + Pick + +const hosts = ['host-a', 'host-b'] as const +function makeState( + workspace: 'worktree' | 'folder', + rootFor: (host: string) => string = () => (workspace === 'folder' ? '/folder' : '/repo') +): SelectedHostState { + const id = workspace === 'folder' ? 'folder:same-folder' : 'same-worktree' + return { + settings: getDefaultSettings('/home/me'), + openFiles: [], + activeWorktreeId: id, + activeWorkspaceExecutionHostId: 'runtime:host-a', + repos: [], + worktreesByRepo: + workspace === 'worktree' + ? { + repo: hosts.map((host) => + makeWorktree({ id, repoId: 'repo', path: rootFor(host), hostId: `runtime:${host}` }) + ) + } + : {}, + folderWorkspaces: + workspace === 'folder' + ? hosts.map((host) => + makeFolderWorkspace({ + id: 'same-folder', + folderPath: rootFor(host), + executionHostId: `runtime:${host}` + }) + ) + : [], + projectGroups: [], + rightSidebarOpen: true, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + gitStatusHugeByWorktree: {}, + sshConnectionStates: new Map() + } +} + +it.each(['worktree', 'folder'] as const)( + 'changes the %s watcher when only the selected execution host changes', + (workspace) => { + const state = makeState(workspace) + const first = selectEditorExternalWatchTargets(state) + expect(first.targets.map((target) => target.runtimeEnvironmentId)).toEqual(['host-a']) + const switched = selectEditorExternalWatchTargets({ + ...state, + activeWorkspaceExecutionHostId: 'runtime:host-b' + }) + expect(switched.targets.map((target) => target.runtimeEnvironmentId)).toEqual(['host-b']) + expect(switched.targetsKey).not.toBe(first.targetsKey) + expect(selectEditorExternalWatchTargets(state).targetsKey).toBe(first.targetsKey) + } +) + +it.each(['worktree', 'folder'] as const)( + 'preserves the %s watcher snapshot when selection and watched roots stay unchanged', + (workspace) => { + const state = makeState(workspace) + const first = selectEditorExternalWatchTargets(state) + expect(selectEditorExternalWatchTargets({ ...state })).toBe(first) + } +) + +it.each(['worktree', 'folder'] as const)( + "watches the selected host's %s root when hosts publish the same id at different roots", + (workspace) => { + const state = makeState(workspace, (host) => `/${host}/repo`) + const onB = selectEditorExternalWatchTargets({ + ...state, + activeWorkspaceExecutionHostId: 'runtime:host-b' + }) + expect(onB.targets.map((target) => [target.runtimeEnvironmentId, target.worktreePath])).toEqual( + [['host-b', '/host-b/repo']] + ) + const onA = selectEditorExternalWatchTargets(state) + expect(onA.targets.map((target) => [target.runtimeEnvironmentId, target.worktreePath])).toEqual( + [['host-a', '/host-a/repo']] + ) + } +) + +it('watches each open file owner at its own host root', () => { + const state = makeState('worktree', (host) => `/${host}/repo`) + const { targets } = selectEditorExternalWatchTargets({ + ...state, + rightSidebarOpen: false, + openFiles: (['host-a', 'host-b'] as const).map((host) => ({ + id: `${host}:a.ts`, + filePath: `/${host}/repo/a.ts`, + relativePath: 'a.ts', + worktreeId: 'same-worktree', + language: 'typescript', + isDirty: false, + mode: 'edit' as const, + runtimeEnvironmentId: host + })) + }) + expect(targets.map((target) => [target.runtimeEnvironmentId, target.worktreePath])).toEqual([ + ['host-a', '/host-a/repo'], + ['host-b', '/host-b/repo'] + ]) +}) + +it('keeps an open local editor watched locally when a same-id direct-SSH folder is selected', () => { + const localFile = { + id: 'local:a.txt', + filePath: '/local/notes/a.txt', + relativePath: 'a.txt', + worktreeId: 'folder:notes', + language: 'plaintext', + isDirty: false, + mode: 'edit' as const, + runtimeEnvironmentId: null, + operationProvenance: { + generation: { + route: { executionHostId: 'local' as const, runtimeEnvironmentId: null }, + runtimeConnectionGeneration: null, + runtimePairingRevision: undefined, + runtimeSshGeneration: null, + nestedSshGeneration: null, + directSshGeneration: null + }, + ownershipProjection: 'explicit' as const + } + } + const state: SelectedHostState = { + ...makeState('folder'), + activeWorktreeId: 'folder:notes', + activeWorkspaceExecutionHostId: 'ssh:ssh-a', + openFiles: [localFile], + folderWorkspaces: [ + makeFolderWorkspace({ id: 'notes', folderPath: '/local/notes', executionHostId: 'local' }), + makeFolderWorkspace({ + id: 'notes', + folderPath: '/remote/notes', + executionHostId: 'ssh:ssh-a' + }) + ] + } + const watched = (next: SelectedHostState): [string, string | undefined][] => + selectEditorExternalWatchTargets(next).targets.map((target) => [ + target.worktreePath, + target.connectionId + ]) + + expect(watched({ ...state, rightSidebarOpen: false })).toEqual([['/local/notes', undefined]]) + expect(watched(state)).toEqual([ + ['/local/notes', undefined], + ['/remote/notes', 'ssh-a'] + ]) +}) diff --git a/src/renderer/src/hooks/editor-external-watch-target-rows.ts b/src/renderer/src/hooks/editor-external-watch-target-rows.ts new file mode 100644 index 00000000000..0b2b77cca3d --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-target-rows.ts @@ -0,0 +1,93 @@ +import type { AppState } from '@/store' +import { getIndexedWorktreesById } from '@/store/worktree-repo-index' +import { findRepoForHost } from '@/store/slices/repo-host-identity' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' + +type WatchTargetRowState = Pick< + AppState, + 'worktreesByRepo' | 'repos' | 'folderWorkspaces' | 'projectGroups' +> + +export type EditorExternalWatchTargetRows = { + worktree: AppState['worktreesByRepo'][string][number] | undefined + repo: AppState['repos'][number] | undefined + folderWorkspace: AppState['folderWorkspaces'][number] | undefined + projectGroup: AppState['projectGroups'][number] | undefined + connectionId: string | null | undefined +} + +function pickHostRow( + rows: readonly T[], + hostId: ExecutionHostId | null, + rowHostId: (row: T) => string | null | undefined +): T | undefined { + // Why: hosts can publish the same workspace id with different roots; a first-row pick would watch another host's path. + const onHost = hostId + ? rows.find((row) => parseExecutionHostId(rowHostId(row))?.id === hostId) + : undefined + return onHost ?? rows[0] +} + +/** The catalog rows for one watch consumer, taken from the row on that consumer's own execution host. */ +export function resolveEditorExternalWatchTargetRows( + state: WatchTargetRowState, + worktreeId: string, + hostId: ExecutionHostId | null +): EditorExternalWatchTargetRows | null { + const worktree = pickHostRow( + getIndexedWorktreesById(state.worktreesByRepo, worktreeId), + hostId, + (row) => + row.hostId ?? + (row.runtimeOwnerEnvironmentId?.trim() + ? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId.trim()) + : null) + ) + const workspaceScope = parseWorkspaceKey(worktreeId) + const folderWorkspace = + workspaceScope?.type === 'folder' + ? pickHostRow( + state.folderWorkspaces.filter( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + ), + hostId, + (row) => row.executionHostId + ) + : undefined + if (!worktree && !folderWorkspace) { + return null + } + const worktreeHost = parseExecutionHostId(worktree?.hostId) + const repo = worktree + ? worktreeHost + ? (findRepoForHost(state.repos, worktree.repoId, { hostId: worktreeHost.id }) ?? + (worktreeHost.kind === 'local' + ? undefined + : state.repos.find((candidate) => candidate.id === worktree.repoId))) + : state.repos.find((candidate) => candidate.id === worktree.repoId) + : undefined + const folderHost = parseExecutionHostId(folderWorkspace?.executionHostId) + const projectGroup = folderWorkspace + ? state.projectGroups.find( + (group) => + group.id === folderWorkspace.projectGroupId && + parseExecutionHostId(group.executionHostId)?.id === folderHost?.id + ) + : undefined + const connectionId = folderWorkspace + ? folderHost + ? folderHost.kind === 'ssh' + ? folderHost.targetId + : null + : getFolderWorkspaceConnectionId(state, folderWorkspace.id) + : repo + ? (repo.connectionId ?? null) + : undefined + return { worktree, repo, folderWorkspace, projectGroup, connectionId } +} diff --git a/src/renderer/src/hooks/editor-external-watch-targets.ts b/src/renderer/src/hooks/editor-external-watch-targets.ts index b3be004ff37..085f7bc695c 100644 --- a/src/renderer/src/hooks/editor-external-watch-targets.ts +++ b/src/renderer/src/hooks/editor-external-watch-targets.ts @@ -2,13 +2,17 @@ import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { findRepoForHost } from '@/store/slices/repo-host-identity' -import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { isGitRepoKind } from '../../../shared/repo-kind' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { resolveEditorExternalWatchTargetRows } from './editor-external-watch-target-rows' export type EditorExternalWatchTarget = { worktreeId: string @@ -33,7 +37,7 @@ export type EditorExternalWatchTargetState = Pick< | 'folderWorkspaces' | 'projectGroups' > & - Partial> + Partial> type WatchedTargetsSnapshot = { targets: EditorExternalWatchTarget[] @@ -44,6 +48,7 @@ let cachedOpenFiles: AppState['openFiles'] | null = null let cachedWorktreesByRepo: AppState['worktreesByRepo'] | null = null let cachedRepos: AppState['repos'] | null = null let cachedActiveWorktreeId: string | null = null +let cachedActiveWorkspaceExecutionHostId: AppState['activeWorkspaceExecutionHostId'] = null let cachedRuntimeEnvironmentId: string | undefined let cachedRightSidebarOpen: boolean | null = null let cachedRightSidebarTab: AppState['rightSidebarTab'] | null = null @@ -74,6 +79,52 @@ export function getLocalWindowsWslAliasOption( : {} } +type WatchConsumer = { owner: string | null; hostId: ExecutionHostId | null } + +/** The host an open file was opened on; never the current selection, which may name another host's same-id workspace. */ +function getOpenFileWatchConsumer( + file: Pick +): WatchConsumer { + const owner = getOpenFileRuntimeOwner(file) + const capturedHostId = file.operationProvenance?.generation.route.executionHostId + return { + owner, + hostId: + capturedHostId ?? + (file.externalSshTargetId + ? toSshExecutionHostId(file.externalSshTargetId) + : owner + ? toRuntimeExecutionHostId(owner) + : null) + } +} + +function getSidebarWatchConsumer( + state: EditorExternalWatchTargetState, + worktreeId: string +): WatchConsumer { + // Why: sidebar watcher must follow the selected worktree's host owner, not the host currently focused in the UI. + const owner = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (owner) { + return { owner, hostId: toRuntimeExecutionHostId(owner) } + } + const selectedHost = parseExecutionHostId(state.activeWorkspaceExecutionHostId) + return { owner, hostId: selectedHost && selectedHost.kind !== 'runtime' ? selectedHost.id : null } +} + +function addWatchConsumer( + consumersByWorktreeId: Map>, + worktreeId: string, + consumer: WatchConsumer +): void { + let consumers = consumersByWorktreeId.get(worktreeId) + if (!consumers) { + consumers = new Map() + consumersByWorktreeId.set(worktreeId, consumers) + } + consumers.set(`${consumer.owner ?? ''}::${consumer.hostId ?? ''}`, consumer) +} + function isLocalHostStamp(value: string | null | undefined): boolean { return parseExecutionHostId(value)?.kind === 'local' } @@ -113,11 +164,13 @@ export function selectEditorExternalWatchTargets( state: EditorExternalWatchTargetState ): WatchedTargetsSnapshot { const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || undefined + const activeWorkspaceExecutionHostId = state.activeWorkspaceExecutionHostId ?? null if ( cachedOpenFiles === state.openFiles && cachedWorktreesByRepo === state.worktreesByRepo && cachedRepos === state.repos && cachedActiveWorktreeId === state.activeWorktreeId && + cachedActiveWorkspaceExecutionHostId === activeWorkspaceExecutionHostId && cachedRuntimeEnvironmentId === runtimeEnvironmentId && cachedRightSidebarOpen === state.rightSidebarOpen && cachedRightSidebarTab === state.rightSidebarTab && @@ -131,16 +184,10 @@ export function selectEditorExternalWatchTargets( return cachedWatchedTargetsSnapshot } - const targetOwnersByWorktreeId = new Map>() - // Why: watcher ownership is scoped by worktree + runtime owner — the same path can be open locally and in a runtime workspace, and reads/saves already route per owner. + const consumersByWorktreeId = new Map>() + // Why: watcher ownership is scoped by worktree + execution host — the same workspace id can be open locally, over SSH and in a runtime, and reads/saves already route per host. for (const file of state.openFiles) { - let owners = targetOwnersByWorktreeId.get(file.worktreeId) - if (!owners) { - owners = new Set() - targetOwnersByWorktreeId.set(file.worktreeId, owners) - } - // Why: persisted/restored tabs may have runtimeEnvironmentId undefined; new openFile calls resolve inheritance before storing, so an ownerless tab stays local. - owners.add(getOpenFileRuntimeOwner(file)) + addWatchConsumer(consumersByWorktreeId, file.worktreeId, getOpenFileWatchConsumer(file)) } const activeWorktreeId = state.activeWorktreeId const activeWorktree = activeWorktreeId @@ -170,56 +217,21 @@ export function selectEditorExternalWatchTargets( (shownRightSidebarTab === 'source-control' && sourceControlCanConsumeWatch)) if (activeWorktreeNeedsSidebarWatch) { // Why: this app-level watcher owns Explorer/Source-Control subscriptions so downstream consumers don't fight over watch/unwatch IPC. - let owners = targetOwnersByWorktreeId.get(activeWorktreeId) - if (!owners) { - owners = new Set() - targetOwnersByWorktreeId.set(activeWorktreeId, owners) - } - // Why: sidebar watcher must follow the selected worktree's host owner, not the host currently focused in the UI. - owners.add(getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)) + addWatchConsumer( + consumersByWorktreeId, + activeWorktreeId, + getSidebarWatchConsumer(state, activeWorktreeId) + ) } - const nextTargets: EditorExternalWatchTarget[] = [] - const parts: string[] = [] - const sortedWorktreeIds = Array.from(targetOwnersByWorktreeId.keys()).sort() - for (const id of sortedWorktreeIds) { - const worktree = findWorktreeById(state.worktreesByRepo, id) - const workspaceScope = parseWorkspaceKey(id) - const folderWorkspace = - workspaceScope?.type === 'folder' - ? state.folderWorkspaces.find( - (workspace) => workspace.id === workspaceScope.folderWorkspaceId - ) - : undefined - if (!worktree && !folderWorkspace) { - continue - } - const worktreeHost = parseExecutionHostId(worktree?.hostId) - const repo = worktree - ? worktreeHost?.kind === 'local' - ? (findRepoForHost(state.repos, worktree.repoId, { hostId: worktreeHost.id }) ?? undefined) - : state.repos.find((candidate) => candidate.id === worktree.repoId) - : undefined - const folderHostId = parseExecutionHostId(folderWorkspace?.executionHostId)?.id - const projectGroup = folderWorkspace - ? state.projectGroups.find( - (group) => - group.id === folderWorkspace.projectGroupId && - parseExecutionHostId(group.executionHostId)?.id === folderHostId - ) - : undefined - const connectionId = folderWorkspace - ? getFolderWorkspaceConnectionId(state, folderWorkspace.id) - : repo - ? (repo.connectionId ?? null) - : undefined - if (connectionId === undefined && folderWorkspace) { - continue - } - const owners = Array.from(targetOwnersByWorktreeId.get(id) ?? []).sort((left, right) => - (left ?? '').localeCompare(right ?? '') - ) - for (const owner of owners) { + const targetsByKey = new Map() + for (const [id, consumers] of consumersByWorktreeId) { + for (const { owner, hostId } of consumers.values()) { + const rows = resolveEditorExternalWatchTargetRows(state, id, hostId) + if (!rows || (rows.connectionId === undefined && rows.folderWorkspace)) { + continue + } + const { worktree, repo, folderWorkspace, projectGroup, connectionId } = rows const target = { worktreeId: id, worktreePath: worktree?.path ?? folderWorkspace!.folderPath, @@ -237,16 +249,21 @@ export function selectEditorExternalWatchTargets( ? { allowLocalWindowsWslAliases: true as const } : {}) } - nextTargets.push(target) - parts.push(getEditorExternalWatchTargetKey(target)) + targetsByKey.set(getEditorExternalWatchTargetKey(target), target) } } - const targetsKey = parts.join('|') + // Why: consumers on one host (an unqualified legacy tab and its captured sibling) collapse to one watch. + const sortedEntries = Array.from(targetsByKey).sort(([left], [right]) => + left.localeCompare(right) + ) + const nextTargets = sortedEntries.map(([, target]) => target) + const targetsKey = sortedEntries.map(([key]) => key).join('|') cachedOpenFiles = state.openFiles cachedWorktreesByRepo = state.worktreesByRepo cachedRepos = state.repos cachedActiveWorktreeId = state.activeWorktreeId + cachedActiveWorkspaceExecutionHostId = activeWorkspaceExecutionHostId cachedRuntimeEnvironmentId = runtimeEnvironmentId cachedRightSidebarOpen = state.rightSidebarOpen cachedRightSidebarTab = state.rightSidebarTab diff --git a/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts b/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts index 851e7683b94..93742f1ede0 100644 --- a/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts +++ b/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts @@ -224,6 +224,27 @@ for (const selectedHost of ['A', 'B'] as const) { await expect(page.getByText(`NESTED_${otherName}.txt`, { exact: true })).toBeVisible() await expect(page.getByText(`NESTED_${selectedHost}.txt`, { exact: true })).toHaveCount(0) await page.screenshot({ path: testInfo.outputPath('same-path-host-switch-refreshed.png') }) + await page.waitForTimeout(1_000) + const switchedMarker = `SWITCHED_HOST_${otherName}.txt` + const switchedChange = page.waitForEvent('console', { + predicate: (message) => + message.text().startsWith('[file-change-event]') && + message.text().includes(switchedMarker) && + message.text().includes(otherEnvironment.id), + timeout: 15_000 + }) + otherHost.exec?.(`printf 'switched host\\n' > '${folderPath}/${switchedMarker}'`) + console.log( + '[switched-host-created-file]', + await serverCall(page, otherEnvironment.id, 'files.read', { + worktree: `id:${worktreeId}`, + relativePath: switchedMarker + }) + ) + await page.screenshot({ path: testInfo.outputPath('switched-host-new-file-observed.png') }) + await switchedChange + await expect(page.getByText(switchedMarker, { exact: true })).toBeVisible({ timeout: 10_000 }) + await page.screenshot({ path: testInfo.outputPath('switched-host-new-file-visible.png') }) await page .getByRole('option') .filter({ hasText: `Folder host ${selectedHost}` })