diff --git a/src/renderer/src/lib/worktree-operation-route.test.ts b/src/renderer/src/lib/worktree-operation-route.test.ts index fe965d9aad0..8f2ef74d3b5 100644 --- a/src/renderer/src/lib/worktree-operation-route.test.ts +++ b/src/renderer/src/lib/worktree-operation-route.test.ts @@ -241,6 +241,98 @@ describe('resolveWorktreeOperationRouteResult', () => { }) }) + describe('active workspace host selection', () => { + it('keeps the HUB transport when the paired SSH worktree is the active workspace', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + worktreesByRepo: { 'repo-1': [worktree('ssh:hub-private-target', 'hub-a')] } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('recovers the HUB transport from the repo for an active mixed-version SSH publication', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + repos: [ + { + id: 'repo-1', + connectionId: 'hub-private-target', + executionHostId: 'runtime:hub-a' + } + ], + detectedWorktreesByRepo: { + 'repo-1': { worktrees: [worktree('ssh:hub-private-target')] } + } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('keeps the selected host authoritative when the same ID exists on two hosts', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'runtime:hub-a', + worktreesByRepo: { 'repo-1': [worktree('local'), worktree('runtime:hub-a')] } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('drops the HUB transport when two HUBs project the active SSH worktree', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:same-private-target', + worktreesByRepo: { + 'repo-1': [ + worktree('ssh:same-private-target', 'hub-a'), + worktree('ssh:same-private-target', 'hub-b') + ] + } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:same-private-target', runtimeEnvironmentId: null } + }) + }) + + it('keeps an unknown active worktree on its selected host', () => { + expect( + resolveWorktreeOperationRouteResult( + { activeWorktreeId: WORKTREE_ID, activeWorkspaceExecutionHostId: 'ssh:ssh-1' }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null } + }) + }) + }) + describe('folder workspaces', () => { const FOLDER_WORKSPACE_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const FOLDER_KEY = `folder:${FOLDER_WORKSPACE_ID}` diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index c2281b28ac0..fadd7284172 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -2,11 +2,11 @@ import type { AppState } from '@/store/types' import { getRepoExecutionHostId, parseExecutionHostId, - toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route' import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { findFolderWorkspaceOwner, @@ -24,7 +24,7 @@ export type WorktreeOperationRouteResolution = | { kind: 'ambiguous' } | { kind: 'missing' } -type WorktreeOperationOwnerRecord = { +export type WorktreeOperationOwnerRecord = { id: string repoId: string hostId?: ExecutionHostId @@ -32,7 +32,7 @@ type WorktreeOperationOwnerRecord = { } // settings/runtimeEnvironments come from FolderWorkspaceRuntimeOwnerState's legacy-owner base. -type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & { +export type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & { repos?: readonly Pick[] worktreesByRepo?: Record detectedWorktreesByRepo?: Record @@ -45,83 +45,67 @@ const repoOperationRouteIndexCache = new WeakMap< ReadonlyMap >() -function routeForOwner(owner: { - hostId?: ExecutionHostId - runtimeOwnerEnvironmentId?: string -}): WorktreeOperationRoute | null { - const runtimeOwnerEnvironmentId = owner.runtimeOwnerEnvironmentId?.trim() - if (!owner.hostId && !runtimeOwnerEnvironmentId) { +function ownerRecordsOnHost( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeOperationOwnerRecord[] { + const owners: WorktreeOperationOwnerRecord[] = [] + const catalogs = [ + ...Object.values(state.worktreesByRepo ?? {}), + ...Object.values(state.detectedWorktreesByRepo ?? {}).map((result) => result.worktrees) + ] + for (const worktrees of catalogs) { + for (const worktree of worktrees) { + if ( + worktree.id === worktreeId && + parseExecutionHostId(worktree.hostId)?.id === executionHostId + ) { + owners.push(worktree) + } + } + } + return owners +} + +/** + * The active workspace's host selection is authoritative identity, but it carries no transport: + * an `ssh:` host reached through a paired HUB names the target, not the HUB that proxies it. Keep + * the selected host and recover the runtime owner from the matching owner rows (#11346). + */ +export function resolveActiveWorkspaceRoute( + state: WorktreeOperationRouteState, + worktreeId: string +): WorktreeOperationRoute | null { + const activeHost = + state.activeWorktreeId === worktreeId + ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) + : null + if (!activeHost) { return null } - const parsedHost = parseExecutionHostId(owner.hostId) + if (activeHost.kind === 'runtime') { + return { executionHostId: activeHost.id, runtimeEnvironmentId: activeHost.environmentId } + } + // Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path. + if (activeHost.kind !== 'ssh') { + return { executionHostId: activeHost.id, runtimeEnvironmentId: null } + } + const environmentIds = new Set() + for (const owner of ownerRecordsOnHost(state, worktreeId, activeHost.id)) { + const resolution = resolveExactWorktreeRoute(state, owner) + if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) { + environmentIds.add(resolution.route.runtimeEnvironmentId) + } + } + const environmentId = environmentIds.values().next().value return { - executionHostId: owner.hostId ?? null, - runtimeEnvironmentId: - runtimeOwnerEnvironmentId || - (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + executionHostId: activeHost.id, + // Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone. + runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null } } -function addRoute( - routes: Map, - route: WorktreeOperationRoute | null -): void { - if (!route) { - return - } - routes.set(JSON.stringify(route), route) -} - -function resolveRepoRouteForSshOwner( - repos: WorktreeOperationRouteState['repos'], - owner: WorktreeOperationOwnerRecord -): WorktreeOperationRouteResolution { - if (!repos || !owner.hostId) { - return { kind: 'missing' } - } - const routes = new Map() - for (const repo of repos) { - if (repo.id !== owner.repoId) { - continue - } - const connectionHostId = repo.connectionId ? toSshExecutionHostId(repo.connectionId) : null - if (getRepoExecutionHostId(repo) !== owner.hostId && connectionHostId !== owner.hostId) { - continue - } - addRoute(routes, routeForOwner({ hostId: getRepoExecutionHostId(repo) })) - } - const route = routes.values().next().value - if (routes.size === 1 && route) { - return { kind: 'resolved', route } - } - return routes.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' } -} - -function resolveExactWorktreeRoute( - state: WorktreeOperationRouteState, - owner: WorktreeOperationOwnerRecord -): WorktreeOperationRouteResolution { - const route = routeForOwner(owner) - if (!route) { - return { kind: 'missing' } - } - if (route.runtimeEnvironmentId || parseExecutionHostId(route.executionHostId)?.kind !== 'ssh') { - return { kind: 'resolved', route } - } - // Recover an optional HUB transport only from the repo setup matching the worktree's SSH host. - const repoRoute = resolveRepoRouteForSshOwner(state.repos, owner) - if (repoRoute.kind === 'ambiguous') { - return repoRoute - } - if (repoRoute.kind === 'resolved' && repoRoute.route.runtimeEnvironmentId) { - return { - kind: 'resolved', - route: { ...route, runtimeEnvironmentId: repoRoute.route.runtimeEnvironmentId } - } - } - return { kind: 'resolved', route } -} - export function resolveWorktreeOperationRoute( state: WorktreeOperationRouteState, worktreeId: string @@ -134,18 +118,9 @@ export function resolveWorktreeOperationRouteResult( state: WorktreeOperationRouteState, worktreeId: string ): WorktreeOperationRouteResolution { - const activeHost = - state.activeWorktreeId === worktreeId - ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) - : null - if (activeHost) { - return { - kind: 'resolved', - route: { - executionHostId: activeHost.id, - runtimeEnvironmentId: activeHost.kind === 'runtime' ? activeHost.environmentId : null - } - } + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return { kind: 'resolved', route: activeRoute } } // Why: folder workspaces are not Git worktrees — they never appear in the worktree/repo // catalogs scanned below, so without this branch a plain local folder workspace reads as an diff --git a/src/renderer/src/lib/worktree-owner-route.ts b/src/renderer/src/lib/worktree-owner-route.ts new file mode 100644 index 00000000000..a370eca651c --- /dev/null +++ b/src/renderer/src/lib/worktree-owner-route.ts @@ -0,0 +1,89 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { + WorktreeOperationOwnerRecord, + WorktreeOperationRoute, + WorktreeOperationRouteResolution, + WorktreeOperationRouteState +} from './worktree-operation-route' + +export function routeForOwner(owner: { + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +}): WorktreeOperationRoute | null { + const runtimeOwnerEnvironmentId = owner.runtimeOwnerEnvironmentId?.trim() + if (!owner.hostId && !runtimeOwnerEnvironmentId) { + return null + } + const parsedHost = parseExecutionHostId(owner.hostId) + return { + executionHostId: owner.hostId ?? null, + runtimeEnvironmentId: + runtimeOwnerEnvironmentId || + (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + } +} + +export function addRoute( + routes: Map, + route: WorktreeOperationRoute | null +): void { + if (!route) { + return + } + routes.set(JSON.stringify(route), route) +} + +function resolveRepoRouteForSshOwner( + repos: WorktreeOperationRouteState['repos'], + owner: WorktreeOperationOwnerRecord +): WorktreeOperationRouteResolution { + if (!repos || !owner.hostId) { + return { kind: 'missing' } + } + const routes = new Map() + for (const repo of repos) { + if (repo.id !== owner.repoId) { + continue + } + const connectionHostId = repo.connectionId ? toSshExecutionHostId(repo.connectionId) : null + if (getRepoExecutionHostId(repo) !== owner.hostId && connectionHostId !== owner.hostId) { + continue + } + addRoute(routes, routeForOwner({ hostId: getRepoExecutionHostId(repo) })) + } + const route = routes.values().next().value + if (routes.size === 1 && route) { + return { kind: 'resolved', route } + } + return routes.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' } +} + +export function resolveExactWorktreeRoute( + state: WorktreeOperationRouteState, + owner: WorktreeOperationOwnerRecord +): WorktreeOperationRouteResolution { + const route = routeForOwner(owner) + if (!route) { + return { kind: 'missing' } + } + if (route.runtimeEnvironmentId || parseExecutionHostId(route.executionHostId)?.kind !== 'ssh') { + return { kind: 'resolved', route } + } + // Recover an optional HUB transport only from the repo setup matching the worktree's SSH host. + const repoRoute = resolveRepoRouteForSshOwner(state.repos, owner) + if (repoRoute.kind === 'ambiguous') { + return repoRoute + } + if (repoRoute.kind === 'resolved' && repoRoute.route.runtimeEnvironmentId) { + return { + kind: 'resolved', + route: { ...route, runtimeEnvironmentId: repoRoute.route.runtimeEnvironmentId } + } + } + return { kind: 'resolved', route } +} diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 410c3b3bebf..995d7c15715 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -579,3 +579,34 @@ describe('getRuntimeSessionMirrorEnvironmentIds', () => { expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([]) }) }) + +describe('active workspace host selection', () => { + const PAIRED_HUB_WORKTREE_ID = 'hub-repo::wt-paired' + const pairedHubState: WorktreeRuntimeOwnerState = { + activeWorktreeId: PAIRED_HUB_WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + worktreesByRepo: { + 'hub-repo': [ + { + id: PAIRED_HUB_WORKTREE_ID, + repoId: 'hub-repo', + hostId: 'ssh:hub-private-target', + runtimeOwnerEnvironmentId: 'hub-a' + } + ] + } + } + + it('keeps the HUB transport for the active paired SSH worktree', () => { + expect(getRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a') + expect(getExplicitRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe( + 'hub-a' + ) + }) + + it('keeps the selected host authoritative for the active worktree', () => { + expect(getExecutionHostIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe( + 'ssh:hub-private-target' + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 0ad6b696c07..e322e834d43 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -17,6 +17,7 @@ import { getRuntimeEnvironmentIdForFolderWorkspace } from './folder-workspace-runtime-owner' import { + resolveActiveWorkspaceRoute, resolveExplicitWorktreeOperationRouteResult, resolveWorktreeOperationRouteResult } from './worktree-operation-route' @@ -62,9 +63,9 @@ export function getRuntimeEnvironmentIdForWorktree( if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { return null } - const activeHost = parseExecutionHostId(getActiveWorkspaceExecutionHostId(state, worktreeId)) - if (activeHost) { - return activeHost.kind === 'runtime' ? activeHost.environmentId : null + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return activeRoute.runtimeEnvironmentId } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { @@ -113,9 +114,9 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( if (!worktreeId) { return null } - const activeHost = parseExecutionHostId(getActiveWorkspaceExecutionHostId(state, worktreeId)) - if (activeHost) { - return activeHost.kind === 'runtime' ? activeHost.environmentId : null + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return activeRoute.runtimeEnvironmentId } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index ca9248a3ab5..ba77ae902d0 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { Repo, TerminalTab, Worktree } from '../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { isGitRepoKind } from '../../../shared/repo-kind' import type { AppState } from './types' import { getAllWorktreesFromState, @@ -288,6 +289,100 @@ describe('store selectors', () => { ).toBeNull() }) + it('keeps the repo when a paired-hub worktree reports a different execution host', () => { + // Why: withRepoHostOwnership deliberately keeps an SSH worktree's own host while its repo stays hub-owned. + const repo = makeRepo({ + id: 'hub-repo', + path: '/hub/repo', + displayName: 'hub', + executionHostId: toRuntimeExecutionHostId('hub-a') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [repo], + activeRepoId: 'hub-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + }, + 'hub-repo' + ) + ).toBe(repo) + // Why: useGitStatusPolling gates every lane on this exact expression, so a null repo silently stops polling. + const activeRepo = selectRepoByIdForActiveWorkspace( + { + repos: [repo], + activeRepoId: 'hub-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + }, + 'hub-repo' + ) + expect(activeRepo ? isGitRepoKind(activeRepo) : false).toBe(true) + }) + + it('still prefers the host-matching row when one repo ID spans hosts', () => { + const hub = makeRepo({ + id: 'shared-repo', + path: '/hub/repo', + displayName: 'hub', + executionHostId: toRuntimeExecutionHostId('hub-a') + }) + const ssh = makeRepo({ + id: 'shared-repo', + path: '/ssh/repo', + displayName: 'ssh', + executionHostId: toSshExecutionHostId('hub-private-target') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [hub, ssh], + activeRepoId: 'shared-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + }, + 'shared-repo' + ) + ).toBe(ssh) + }) + + it('keeps every non-paired-hub host mismatch failing closed', () => { + // Why: the paired-hub exemption is ssh-selection-over-runtime-repo only; anything else is a wrong host. + const runtime = makeRepo({ + id: 'same-repo', + path: '/runtime/repo', + displayName: 'runtime', + executionHostId: toRuntimeExecutionHostId('env-1') + }) + const ssh = makeRepo({ + id: 'ssh-repo', + path: '/ssh/repo', + displayName: 'ssh', + executionHostId: toSshExecutionHostId('target-1') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [runtime], + activeRepoId: 'same-repo', + activeWorkspaceExecutionHostId: toRuntimeExecutionHostId('env-2') + }, + 'same-repo' + ) + ).toBeNull() + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [ssh], + activeRepoId: 'ssh-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('target-2') + }, + 'ssh-repo' + ) + ).toBeNull() + }) + it('prefers hydrated project host setup state when present', () => { const repos = [ makeRepo({ diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 1904074f487..fec5a2481ae 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import type { Repo, Worktree, TerminalTab } from '../../../shared/types' import type { AppState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' import { getIndexedAllWorktrees as getCachedAllWorktrees, @@ -181,16 +181,26 @@ export function selectRepoByIdForActiveWorkspace( if (!repoId) { return null } + const repo = getCachedRepoMap(state.repos).get(repoId) ?? null if (repoId === state.activeRepoId && state.activeWorkspaceExecutionHostId) { - return ( - state.repos.find( - (repo) => - repo.id === repoId && - getRepoExecutionHostId(repo) === state.activeWorkspaceExecutionHostId - ) ?? null + const hostMatch = state.repos.find( + (candidate) => + candidate.id === repoId && + getRepoExecutionHostId(candidate) === state.activeWorkspaceExecutionHostId ) + if (hostMatch) { + return hostMatch + } + // Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo + // stays hub-owned, so that one mismatch still names the right repo; every other stays closed. + const isPairedHubRepo = + parseExecutionHostId(state.activeWorkspaceExecutionHostId)?.kind === 'ssh' && + parseExecutionHostId(repo ? getRepoExecutionHostId(repo) : undefined)?.kind === 'runtime' + if (!isPairedHubRepo) { + return null + } } - return getCachedRepoMap(state.repos).get(repoId) ?? null + return repo } export const useRepoById = (repoId: string | null) =>