mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Move shared file watches when the selected remote host changes (#26632)
* Invalidate shared file watch targets when the selected host changes * Watch the selected host's own workspace root when hosts share a workspace id * Watch each open editor on its own execution host instead of the selected host --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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<AppState, 'activeWorkspaceExecutionHostId'>
|
||||
|
||||
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']
|
||||
])
|
||||
})
|
||||
@@ -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<T>(
|
||||
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 }
|
||||
}
|
||||
@@ -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<Pick<AppState, 'rightSidebarEffectiveTab'>>
|
||||
Partial<Pick<AppState, 'activeWorkspaceExecutionHostId' | 'rightSidebarEffectiveTab'>>
|
||||
|
||||
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<OpenFile, 'externalSshTargetId' | 'operationProvenance' | 'runtimeEnvironmentId'>
|
||||
): 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<string, Map<string, WatchConsumer>>,
|
||||
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<string, Set<string | null>>()
|
||||
// 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<string, Map<string, WatchConsumer>>()
|
||||
// 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<string, EditorExternalWatchTarget>()
|
||||
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
|
||||
|
||||
@@ -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}` })
|
||||
|
||||
Reference in New Issue
Block a user