fix(ssh): move the open workspace to the server's linked project when joining an existing server (#30189)

* fix(ssh): move the open workspace to the server's linked project when joining an existing server

* fix(ssh): follow the active repo and detected checkouts when rehoming a linked workspace

* fix(ssh): treat a missing server row as unknown, and keep watching for it
This commit is contained in:
Neil
2026-10-17 22:40:20 -07:00
committed by GitHub
parent b5fa888a93
commit d93568368e
2 changed files with 286 additions and 19 deletions
@@ -222,6 +222,152 @@ describe('a host that just moved to its managed server', () => {
expect(store.getState().activeWorktreeId).toBe(worktreeId)
})
describe('a project linked to the copy another desktop already put on the server', () => {
const sourceRepo: Repo = { ...repo, id: 'repo-b', path: '/root/migrepo' }
const sourceWorktreeId = 'repo-b::/root/migrepo'
const serverWorktreeId = 'repo-a::/root/migrepo'
const linked = [{ path: '/root/migrepo', automations: 0 }]
beforeEach(() => {
const call = vi.fn(async (args: RuntimeEnvironmentCallRequest) =>
args.method === 'repo.list'
? {
id: 'rpc',
ok: true,
result: { repos: [{ ...repo, id: 'repo-a', path: '/root/migrepo' }] },
_meta: { runtimeId: 'orcad' }
}
: serverReply(args)
)
Object.assign(window.api.runtimeEnvironments, { call })
store.setState({
repos: [{ ...sourceRepo, connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }],
worktreesByRepo: {
'repo-b': [
makeWorktree({
id: sourceWorktreeId,
repoId: 'repo-b',
path: '/root/migrepo',
hostId: 'ssh:ssh-1'
})
]
},
activeRepoId: 'repo-b',
activeWorktreeId: sourceWorktreeId,
activeWorkspaceExecutionHostId: 'ssh:ssh-1'
})
})
const serverRows = (environmentId: string) => ({
'repo-a': [
makeWorktree({
id: serverWorktreeId,
repoId: 'repo-a',
path: '/root/migrepo',
hostId: `runtime:${environmentId}`
})
]
})
it('moves the open workspace onto the server’s project, whose id differs', async () => {
store.setState({
worktreesByRepo: { ...store.getState().worktreesByRepo, ...serverRows('env-link') }
})
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
environmentId: 'env-link',
linked
})
await vi.waitFor(() =>
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link')
)
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
expect(store.getState().activeRepoId).toBe('repo-a')
})
it('finds a checkout the server only detects, as its visibility settings hide it', async () => {
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
environmentId: 'env-link-hidden',
linked
})
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
store.setState({
detectedWorktreesByRepo: {
'repo-a': {
repoId: 'repo-a',
authoritative: true,
source: 'git',
worktrees: serverRows('env-link-hidden')['repo-a'].map((worktree) => ({
...worktree,
ownership: 'external' as const,
selectedCheckout: false,
visible: false
}))
}
},
startupWorktreeRefreshCompleted: true
})
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link-hidden')
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
})
it('moves it once the cold-start scan delivers the server’s rows', async () => {
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
environmentId: 'env-link-cold',
linked
})
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
expect(store.getState().activeWorktreeId).toBe(sourceWorktreeId)
store.setState({
worktreesByRepo: serverRows('env-link-cold'),
startupWorktreeRefreshCompleted: true
})
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link-cold')
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
})
it('keeps the selection while a failed scan leaves the server’s row unknown', async () => {
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
environmentId: 'env-link-failed-scan',
linked
})
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
// Startup's scan completed, but the server's listing failed and delivered no rows.
store.setState({ startupWorktreeRefreshCompleted: true })
expect(store.getState().activeWorktreeId).toBe(sourceWorktreeId)
store.setState({ worktreesByRepo: serverRows('env-link-failed-scan') })
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link-failed-scan')
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
})
it('selects nothing when the server has no copy to move to', async () => {
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
environmentId: 'env-unlinked'
})
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
store.setState({
worktreesByRepo: serverRows('env-unlinked'),
startupWorktreeRefreshCompleted: true
})
expect(store.getState().activeWorktreeId).toBeNull()
expect(store.getState().activeWorkspaceExecutionHostId).toBeNull()
expect(store.getState().activeRepoId).toBeNull()
})
})
it('leaves a workspace open on another host where it is', async () => {
store.setState({
activeRepoId: 'repo-1',
@@ -1,7 +1,15 @@
/** What a change in an SSH host's server means for the rest of the app. */
import { toast } from 'sonner'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type {
SshConnectionState,
SshManagedServerLinkedProject
} from '../../../../shared/ssh-types'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import { splitWorktreeId } from '../../../../shared/worktree/id'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import type { Repo } from '../../../../shared/repo-types'
import {
type ExecutionHostId,
getRepoExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId
@@ -23,7 +31,7 @@ export function applySshManagedServerTransition(
// Starts and wakes keep the same owner; only a new server or a failed load needs a refresh.
const current = catalogLoadByTarget.get(targetId)
if (current?.environmentId !== next.environmentId) {
startManagedServerCatalogLoad(targetId, next.environmentId)
startManagedServerCatalogLoad(targetId, next.environmentId, next.linked ?? [])
} else if (current.pending && previous?.kind !== 'managed') {
// Why: a reconnect during a doomed read must not be lost; replay it once if that read fails.
current.reconnectedWhilePending = true
@@ -80,14 +88,19 @@ function remountBridgedTabsOnce(targetId: string, next: ManagedServerStatus): vo
type CatalogLoad = {
environmentId: string
linked: readonly SshManagedServerLinkedProject[]
pending: boolean
reconnectedWhilePending: boolean
}
const catalogLoadByTarget = new Map<string, CatalogLoad>()
function startManagedServerCatalogLoad(targetId: string, environmentId: string): void {
const load: CatalogLoad = { environmentId, pending: true, reconnectedWhilePending: false }
function startManagedServerCatalogLoad(
targetId: string,
environmentId: string,
linked: readonly SshManagedServerLinkedProject[]
): void {
const load: CatalogLoad = { environmentId, linked, pending: true, reconnectedWhilePending: false }
catalogLoadByTarget.set(targetId, load)
void loadManagedServerCatalogs(targetId, load).then(
() => {
@@ -102,7 +115,7 @@ function startManagedServerCatalogLoad(targetId: string, environmentId: string):
}
catalogLoadByTarget.delete(targetId)
if (load.reconnectedWhilePending) {
startManagedServerCatalogLoad(targetId, environmentId)
startManagedServerCatalogLoad(targetId, environmentId, load.linked)
}
}
)
@@ -155,6 +168,13 @@ async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): P
if (!isCurrent()) {
return
}
// Read before the source rows go: a linked project keeps the server's id, not this desktop's.
const linkedRepoIds = linkedServerRepoIds(
useAppStore.getState().repos,
targetId,
environmentId,
load.linked
)
useAppStore.setState((state) => {
// Why: the host's workspaces are the server's now; unmoved, the next prune drops their recency.
const lastVisitedAtByWorktreeId = rekeyWorktreeVisitHost(
@@ -169,39 +189,140 @@ async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): P
: { lastVisitedAtByWorktreeId })
}
})
rehomeActiveWorkspace(targetId, environmentId)
if (!useAppStore.getState().startupWorktreeRefreshCompleted) {
rehomeAfterStartupScan(targetId, environmentId, isCurrent)
}
rehomeActiveWorkspaceWhenKnown(targetId, environmentId, linkedRepoIds, isCurrent)
}
/** Why: on cold start the server's worktree rows arrive with startup's scan, after the attempt above. */
function rehomeAfterStartupScan(
/**
* Why keep watching: the server's worktree rows arrive with startup's scan on cold start, and a
* failed scan leaves them missing until a later one, so a missing row never reads as no copy.
*/
function rehomeActiveWorkspaceWhenKnown(
targetId: string,
environmentId: string,
linkedRepoIds: ReadonlyMap<string, string>,
isCurrent: () => boolean
): void {
const unsubscribe = useAppStore.subscribe((state) => {
if (rehomeActiveWorkspace(targetId, environmentId, linkedRepoIds) !== 'pending') {
return
}
const unsubscribe = useAppStore.subscribe((state, previous) => {
if (!isCurrent()) {
unsubscribe()
return
}
if (state.startupWorktreeRefreshCompleted) {
if (
state.worktreesByRepo === previous.worktreesByRepo &&
state.detectedWorktreesByRepo === previous.detectedWorktreesByRepo &&
state.activeWorktreeId === previous.activeWorktreeId &&
state.activeWorkspaceExecutionHostId === previous.activeWorkspaceExecutionHostId
) {
return
}
if (rehomeActiveWorkspace(targetId, environmentId, linkedRepoIds) !== 'pending') {
unsubscribe()
rehomeActiveWorkspace(targetId, environmentId)
}
})
}
/** This desktop's project id → the server's, for each project the move linked at the same path. */
function linkedServerRepoIds(
repos: readonly Repo[],
targetId: string,
environmentId: string,
linked: readonly SshManagedServerLinkedProject[]
): Map<string, string> {
const linkedPaths = new Set(linked.map((entry) => normalizeRuntimePathForComparison(entry.path)))
const sshHostId = toSshExecutionHostId(targetId)
const runtimeHostId = toRuntimeExecutionHostId(environmentId)
const serverIdByPath = new Map(
repos
.filter((repo) => getRepoExecutionHostId(repo) === runtimeHostId)
.map((repo) => [normalizeRuntimePathForComparison(repo.path), repo.id])
)
const mapping = new Map<string, string>()
for (const repo of repos) {
const path = normalizeRuntimePathForComparison(repo.path)
const serverId = serverIdByPath.get(path)
if (getRepoExecutionHostId(repo) === sshHostId && linkedPaths.has(path) && serverId) {
mapping.set(repo.id, serverId)
}
}
return mapping
}
type ServerCopy = { kind: 'found'; worktreeId: string } | { kind: 'absent' } | { kind: 'pending' }
/**
* The server's copy of a workspace: the same id, or the same checkout in the project it linked to.
* Only a project the server's catalog lacks is absent; that catalog loaded or this never runs.
*/
function serverCopyOfWorkspace(
worktreeId: string,
runtimeHostId: ExecutionHostId,
linkedRepoIds: ReadonlyMap<string, string>
): ServerCopy {
const state = useAppStore.getState()
if (state.getKnownWorktreeById(worktreeId, runtimeHostId)) {
return { kind: 'found', worktreeId }
}
const source =
parseWorkspaceKey(worktreeId)?.type === 'folder' ? null : splitWorktreeId(worktreeId)
if (!source) {
return { kind: 'absent' }
}
const serverRepoId = state.repos.some(
(repo) => repo.id === source.repoId && getRepoExecutionHostId(repo) === runtimeHostId
)
? source.repoId
: linkedRepoIds.get(source.repoId)
if (!serverRepoId) {
return { kind: 'absent' }
}
const path = normalizeRuntimePathForComparison(source.worktreePath)
// Why detected too: a checkout the server's visibility settings hide still exists there.
const row = [
...(state.worktreesByRepo[serverRepoId] ?? []),
...(state.detectedWorktreesByRepo[serverRepoId]?.worktrees ?? [])
].find(
(worktree) =>
worktree.hostId === runtimeHostId && normalizeRuntimePathForComparison(worktree.path) === path
)
return row ? { kind: 'found', worktreeId: row.id } : { kind: 'pending' }
}
/** Why: an active workspace left on the SSH host keeps showing panes that dial the stopped relay. */
function rehomeActiveWorkspace(targetId: string, environmentId: string): void {
function rehomeActiveWorkspace(
targetId: string,
environmentId: string,
linkedRepoIds: ReadonlyMap<string, string>
): 'settled' | 'pending' {
const state = useAppStore.getState()
const runtimeHostId = toRuntimeExecutionHostId(environmentId)
if (
state.activeWorktreeId &&
state.activeWorkspaceExecutionHostId === toSshExecutionHostId(targetId) &&
state.getKnownWorktreeById(state.activeWorktreeId, runtimeHostId)
!state.activeWorktreeId ||
state.activeWorkspaceExecutionHostId !== toSshExecutionHostId(targetId)
) {
state.setActiveWorktree(state.activeWorktreeId, runtimeHostId)
return 'settled'
}
const copy = serverCopyOfWorkspace(state.activeWorktreeId, runtimeHostId, linkedRepoIds)
if (copy.kind === 'pending') {
return 'pending'
}
if (copy.kind === 'found') {
// Why a transition: it makes the active repo follow the row, which a linked project renames.
state.setActiveWorktree(copy.worktreeId, runtimeHostId, {
stateTransition: () => ({ patch: {}, activate: true })
})
return 'settled'
}
// The server holds no project for it, so the SSH owner it names is retired.
state.setActiveWorktree(null, undefined, {
stateTransition: (current) => ({
patch: current.repos.some((repo) => repo.id === current.activeRepoId)
? {}
: { activeRepoId: null },
activate: true
})
})
return 'settled'
}