From d93568368ec7fd2f9e3790253c0939dbb87a97ae Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:40:20 -0700 Subject: [PATCH] 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 --- ...-managed-server-converted-catalogs.test.ts | 146 ++++++++++++++++ .../ssh-managed-server-state-effects.ts | 159 +++++++++++++++--- 2 files changed, 286 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts index 710f924fd7b..bdc18726c92 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts @@ -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', diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index 639a7ac27ee..914397d287d 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -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() -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, 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 { + 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() + 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 +): 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 +): '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' }