From 07a8f79c2560eef841f20f96012eeb1cda2b3ec6 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 3 Oct 2026 19:16:44 -0700 Subject: [PATCH] fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server (#25110) * fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server * fix(orcad): a v1.4.218 profile focused on the SSH worktree converts, and a refusal names what blocks it The debounced session writer never patched activeWorkspaceKey or activeWorkspaceExecutionHostId, so the first focus stayed on disk. The all-dependency census also counted global focus copies in every non-source partition. --------- Co-authored-by: m4air --- .../orcad-source-client-focus-census.test.ts | 231 ++++++++++++++++++ .../orcad-source-client-focus-retarget.ts | 74 ++++++ .../orcad-source-retirement.ts | 2 + .../orcad-source-session-dependencies.ts | 4 + ...ad-source-workspace-session-eligibility.ts | 21 +- ...cad-source-workspace-session-retirement.ts | 30 ++- .../orcad-source-workspace-session.ts | 5 + src/main/ssh/orcad-migration-delta-move.ts | 3 +- .../orcad-migration-refusal-reason.test.ts | 37 +++ .../ssh/orcad-migration-refusal-reason.ts | 50 ++++ src/main/ssh/orcad-migration-source-fence.ts | 7 +- .../src/lib/unified-tab-host-ownership.ts | 16 +- .../src/lib/workspace-session-patch.test.ts | 17 ++ .../src/lib/workspace-session-patch.ts | 7 + .../slices/tabs-open-close-lifecycle.test.ts | 21 +- 15 files changed, 499 insertions(+), 26 deletions(-) create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-census.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-retarget.ts create mode 100644 src/main/ssh/orcad-migration-refusal-reason.test.ts create mode 100644 src/main/ssh/orcad-migration-refusal-reason.ts diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-census.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-census.test.ts new file mode 100644 index 00000000000..7ebe6fc1337 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-census.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationManifest +} from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import type { SshTarget } from '../../../shared/ssh-types' +import type { Tab } from '../../../shared/tab-types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { retargetOrcadSourceClientFocus } from './orcad-source-client-focus-retarget' +import { + collectOrcadMigrationSourceDependencyCensus, + collectOrcadMigrationUntransferredDependencyCensus +} from './orcad-source-dependency-census' +import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' +import { + assertOrcadMigrationSourceWorkspaceSessionRetired, + retireOrcadMigrationSourceWorkspaceSession +} from './orcad-source-workspace-session-retirement' + +const TARGET: SshTarget = { id: 'ssh-prod', label: 'Prod', host: 'prod', port: 22, username: 'u' } +const SSH_HOST = `ssh:${TARGET.id}` as const +const REPO: Repo = { + id: 'repo-1', + path: '/srv/repo', + displayName: 'Repository', + badgeColor: '#737373', + addedAt: 1, + connectionId: TARGET.id, + executionHostId: SSH_HOST +} +const WORKTREE = `${REPO.id}::/srv/repo` + +function manifest(destinationEnvironmentId?: string): OrcadMigrationManifest { + return { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-1', + createdAt: '2026-10-03T12:00:00.000Z', + source: { sshTargetId: TARGET.id, sshTargetGeneration: null, targetLabel: TARGET.label }, + payload: { repositories: [REPO], projectGroups: [], folderWorkspaces: [] }, + ...(destinationEnvironmentId ? { destinationEnvironmentId } : {}), + manifestSha256: 'a'.repeat(64) + } +} + +function editorTab(executionHostId?: ExecutionHostId): Tab { + return { + id: 'tab-1', + entityId: '/srv/repo/README.md', + groupId: 'group-1', + worktreeId: WORKTREE, + ...(executionHostId ? { executionHostId } : {}), + contentType: 'editor', + label: 'README.md', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function sourceState(): PersistedState { + const state = getDefaultPersistedState('/home/test') + state.sshTargets = [TARGET] + state.repos = [REPO] + return state +} + +function focusLocalOnSourceWorktree(state: PersistedState): void { + state.workspaceSession = { + ...state.workspaceSession, + activeRepoId: REPO.id, + activeWorktreeId: WORKTREE, + activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE), + activeWorkspaceExecutionHostId: SSH_HOST, + activeTabId: 'tab-1' + } +} + +const sessionCount = (state: PersistedState): number => + collectOrcadMigrationUntransferredDependencyCensus(state, manifest('env-1')).counts[ + 'workspace-session' + ] + +describe('orcad migration census and client focus', () => { + it('never blocks a move on client focus aimed at a migrating worktree', () => { + const state = sourceState() + focusLocalOnSourceWorktree(state) + + expect(sessionCount(state)).toBe(0) + }) + + it('passes a v1.4.218 profile quit while focused on the source worktree', () => { + // v1.4.218 copied the global focus fields into 'local' and into every host partition it wrote. + const state = sourceState() + focusLocalOnSourceWorktree(state) + const focusCopy = { + ...state.workspaceSession, + unifiedTabs: {}, + tabsByWorktree: {} + } + state.workspaceSessionsByHostId = { + [SSH_HOST]: { ...focusCopy, unifiedTabs: { [WORKTREE]: [editorTab('local')] } }, + 'ssh:other-a': { ...focusCopy }, + 'ssh:other-b': { ...focusCopy } + } + // The same profile with focus only in the source partition: what the census may count. + const sourceOnly = structuredClone(state) + const noFocus = { + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null + } + Object.assign(sourceOnly.workspaceSession, noFocus) + Object.assign(sourceOnly.workspaceSessionsByHostId!['ssh:other-a']!, noFocus) + Object.assign(sourceOnly.workspaceSessionsByHostId!['ssh:other-b']!, noFocus) + + expect(sessionCount(state)).toBe(0) + expect( + collectOrcadMigrationSourceDependencyCensus(state, manifest('env-1')).counts[ + 'workspace-session' + ] + ).toBe( + collectOrcadMigrationSourceDependencyCensus(sourceOnly, manifest('env-1')).counts[ + 'workspace-session' + ] + ) + }) + + it('carries no client focus into the destination session', () => { + const state = sourceState() + focusLocalOnSourceWorktree(state) + state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab(SSH_HOST)] } + + const session = collectOrcadMigrationSourceDormantState(state, manifest().source, { + repositories: [REPO], + projectGroups: [], + folderWorkspaces: [] + }).payload.workspaceSession + + expect(session?.unifiedTabs?.[WORKTREE]).toHaveLength(1) + expect(session?.activeWorktreeId ?? null).toBeNull() + expect(session?.activeWorkspaceKey ?? null).toBeNull() + }) + + it('remaps client focus to the same worktree in the managed environment', () => { + const state = sourceState() + focusLocalOnSourceWorktree(state) + + retargetOrcadSourceClientFocus(state, manifest('env-1')) + + expect(state.workspaceSession).toMatchObject({ + activeRepoId: REPO.id, + activeWorktreeId: WORKTREE, + activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE), + activeWorkspaceExecutionHostId: 'runtime:env-1', + activeTabId: 'tab-1' + }) + }) + + it('clears client focus the managed environment cannot resolve', () => { + const state = sourceState() + focusLocalOnSourceWorktree(state) + const unmoved = sourceState() + focusLocalOnSourceWorktree(unmoved) + unmoved.workspaceSession.activeWorktreeId = 'other-repo::/srv/other' + unmoved.workspaceSession.activeWorkspaceKey = worktreeWorkspaceKey('other-repo::/srv/other') + + retargetOrcadSourceClientFocus(state, manifest()) + retargetOrcadSourceClientFocus(unmoved, manifest('env-1')) + + for (const session of [state.workspaceSession, unmoved.workspaceSession]) { + expect(session).toMatchObject({ + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null + }) + } + }) + + it('keeps remapped focus through session retirement, which then verifies clean', () => { + const state = sourceState() + focusLocalOnSourceWorktree(state) + state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab(SSH_HOST)] } + const moved = manifest('env-1') + moved.payload.dormantState = collectOrcadMigrationSourceDormantState( + state, + moved.source, + moved.payload + ).payload + + retargetOrcadSourceClientFocus(state, moved) + retireOrcadMigrationSourceWorkspaceSession(state, moved) + + expect(state.workspaceSession.unifiedTabs?.[WORKTREE]).toBeUndefined() + expect(state.workspaceSession).toMatchObject({ + activeWorktreeId: WORKTREE, + activeWorkspaceExecutionHostId: 'runtime:env-1' + }) + expect(() => assertOrcadMigrationSourceWorkspaceSessionRetired(state, moved)).not.toThrow() + }) + + it("counts a 'local'-stamped tab in a migrating SSH worktree as the SSH host's", () => { + const local = sourceState() + local.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab('local')] } + const partitioned = sourceState() + partitioned.workspaceSessionsByHostId = { + [SSH_HOST]: { + ...partitioned.workspaceSession, + unifiedTabs: { [WORKTREE]: [editorTab('local')] } + } + } + + expect(sessionCount(local)).toBe(0) + expect(sessionCount(partitioned)).toBe(0) + }) + + it('still blocks a tab another host owns inside a migrating worktree', () => { + const state = sourceState() + state.workspaceSession.unifiedTabs = { [WORKTREE]: [editorTab('ssh:other')] } + + expect(sessionCount(state)).toBe(1) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-retarget.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-retarget.ts new file mode 100644 index 00000000000..4163242eeda --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-retarget.ts @@ -0,0 +1,74 @@ +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { isWorkspaceKey } from '../../../shared/workspace-scope' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey, + type OrcadMigrationSourceScope +} from './orcad-source-scope' + +/** + * Client focus on a moved worktree follows it to the managed environment, or is cleared when the + * destination cannot resolve it. The census never blocks on this focus, so retirement owns it. + */ +export function retargetOrcadSourceClientFocus( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { + const scope = createOrcadMigrationSourceScope({ + source: manifest.source, + catalog: manifest.payload + }) + const destinationHostId = manifest.destinationEnvironmentId + ? toRuntimeExecutionHostId(manifest.destinationEnvironmentId) + : null + retargetSessionFocus(state.workspaceSession, scope, destinationHostId) + for (const [hostId, session] of Object.entries(state.workspaceSessionsByHostId ?? {})) { + if (session && hostId !== scope.hostId) { + retargetSessionFocus(session, scope, destinationHostId) + } + } +} + +/** Focus already on a runtime host names that host's workspace, never the SSH source's. */ +export function isOrcadRuntimeHostFocus(session: WorkspaceSessionState): boolean { + return parseExecutionHostId(session.activeWorkspaceExecutionHostId)?.kind === 'runtime' +} + +function retargetSessionFocus( + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope, + destinationHostId: `runtime:${string}` | null +): void { + const owns = (value: string | null | undefined): value is string => + orcadMigrationOwnerMatchesScope(value, scope) + const focusHost = session.activeWorkspaceExecutionHostId + const aimedAtSource = + focusHost === scope.hostId || + (!focusHost && (owns(session.activeWorktreeId) || owns(session.activeWorkspaceKey))) + if (!aimedAtSource) { + return + } + const worktreeId = owns(session.activeWorktreeId) + ? unqualifyOrcadMigrationOwnerKey(session.activeWorktreeId) + : null + const workspaceKey = owns(session.activeWorkspaceKey) + ? unqualifyOrcadMigrationOwnerKey(session.activeWorkspaceKey) + : null + if (destinationHostId && (worktreeId || workspaceKey)) { + session.activeWorktreeId = worktreeId + session.activeWorkspaceKey = workspaceKey && isWorkspaceKey(workspaceKey) ? workspaceKey : null + session.activeWorkspaceExecutionHostId = destinationHostId + return + } + session.activeWorktreeId = null + session.activeWorkspaceKey = null + session.activeWorkspaceExecutionHostId = null + session.activeTabId = null + if (session.activeRepoId && scope.repoIds.has(session.activeRepoId)) { + session.activeRepoId = null + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts index a43b904d853..a2641a2adb1 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-retirement.ts @@ -17,6 +17,7 @@ import { retireOrcadMigrationSourceDormantState } from './orcad-source-dormant-retirement' import { retireOrcadSourceReconnectHint } from './orcad-source-workspace-session-retirement' +import { retargetOrcadSourceClientFocus } from './orcad-source-client-focus-retarget' const orcadSourceRetirementContext = Symbol('OrcadSourceRetirementPersistence') type OrcadSourceRetirementContext = { @@ -42,6 +43,7 @@ export class OrcadSourceRetirementPersistence { const context = this[orcadSourceRetirementContext] const state = context.runtime.state retireOrcadSourceCatalogState(state, manifest) + retargetOrcadSourceClientFocus(state, manifest) retireOrcadMigrationSourceDormantState(state, manifest) retireOrcadSourceReconnectHint(state, manifest.source.sshTargetId) syncProjectHostSetupCompatibilityState(context.repos) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts index c6f51cb82ed..64ece97f5d3 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts @@ -110,6 +110,10 @@ function inspectSession( } } result.dependencyCount += (session.activeWorktreeIdsOnShutdown ?? []).filter(matchesOwner).length + if (!sourceHostPartition) { + // Global fields here are copies of client focus and history, not this host's state. + return result + } result.dependencyCount += session.activeRepoId && scope.ownerMatches(session.activeRepoId) ? 1 : 0 result.dependencyCount += matchesOwner(session.activeWorktreeId ?? '') ? 1 : 0 result.dependencyCount += matchesOwner(session.activeWorkspaceKey ?? '') ? 1 : 0 diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts index 9ede45523e9..98d01dea874 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts @@ -79,22 +79,15 @@ export function countUnsupportedSessionState( shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) ).length count += + sourceHostPartition && session.activeRepoId && owns(session.activeRepoId) && - !(sourceHostPartition && scope.repoIds.has(session.activeRepoId)) + !scope.repoIds.has(session.activeRepoId) ? 1 : 0 - // Why not in the source partition: there focus is a copy of the client-wide focus. An in-scope - // copy is carried; one aimed at another host's workspace is not host state and is never carried. - count += - !sourceHostPartition && session.activeWorktreeId && owns(session.activeWorktreeId) ? 1 : 0 - count += - !sourceHostPartition && session.activeWorkspaceKey && owns(session.activeWorkspaceKey) ? 1 : 0 - count += session.activeWorkspaceExecutionHostId === scope.hostId && !sourceHostPartition ? 1 : 0 + // Focus outside the source partition is client focus, not host state: retirement retargets it. // activeConnectionIdsAtShutdown is not counted: it is the renderer's live "connected now" hint, and // the remote work it can stand for (tab PTYs, remote session ids, leases) is counted on its own. - count += - session.activeTabId && terminalTabIds.has(session.activeTabId) && !sourceHostPartition ? 1 : 0 for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { if (owns(ownerKey)) { count += files.filter( @@ -112,7 +105,8 @@ export function countUnsupportedSessionState( for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { if (owns(ownerKey)) { count += tabs.filter( - (tab) => tab.executionHostId !== undefined && tab.executionHostId !== scope.hostId + (tab) => + tab.executionHostId !== undefined && !isOrcadSourceTabHost(tab.executionHostId, scope) ).length } } @@ -206,6 +200,11 @@ export function projectDormantSessionFocus( } } +/** Older builds stamped 'local' on tabs created in an SSH worktree; its owner key proves the host. */ +function isOrcadSourceTabHost(executionHostId: string, scope: OrcadMigrationSourceScope): boolean { + return executionHostId === scope.hostId || executionHostId === LOCAL_EXECUTION_HOST_ID +} + export function projectSessionToDestination( session: WorkspaceSessionState, scope: OrcadMigrationSourceScope diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts index be1f7c87d48..4debfe28e04 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts @@ -10,6 +10,7 @@ import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragme import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' import type { PersistedState } from '../../../shared/persisted-state-types' import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session' +import { isOrcadRuntimeHostFocus } from './orcad-source-client-focus-retarget' export function retireOrcadMigrationSourceWorkspaceSession( state: PersistedState, @@ -83,17 +84,30 @@ export function removeOwnedSessionState( ) } } + // Focus retargeted to the destination names the same ids on the managed host; keep it. + const retargetedFocus = isOrcadRuntimeHostFocus(session) + ? { + activeRepoId: session.activeRepoId, + activeWorktreeId: session.activeWorktreeId, + activeWorkspaceKey: session.activeWorkspaceKey, + activeTabId: session.activeTabId + } + : null const next = removeWorkspaceSessionOwners(session, ownerKeys) ?? session // Selection-only sessions and canonical workspace keys need the same scoped retirement. const retired = next === session ? structuredClone(session) : next - if (retired.activeRepoId && scope.repoIds.has(retired.activeRepoId)) { - retired.activeRepoId = null - } - if (orcadMigrationOwnerMatchesScope(retired.activeWorktreeId, scope)) { - retired.activeWorktreeId = null - } - if (orcadMigrationOwnerMatchesScope(retired.activeWorkspaceKey, scope)) { - retired.activeWorkspaceKey = null + if (retargetedFocus) { + Object.assign(retired, retargetedFocus) + } else { + if (retired.activeRepoId && scope.repoIds.has(retired.activeRepoId)) { + retired.activeRepoId = null + } + if (orcadMigrationOwnerMatchesScope(retired.activeWorktreeId, scope)) { + retired.activeWorktreeId = null + } + if (orcadMigrationOwnerMatchesScope(retired.activeWorkspaceKey, scope)) { + retired.activeWorkspaceKey = null + } } if (retired.activeWorkspaceExecutionHostId === scope.hostId) { retired.activeWorkspaceExecutionHostId = null diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index e56a1943a57..3a4462a0c12 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -80,6 +80,11 @@ export function collectOrcadMigrationSourceWorkspaceSession( } : null }) + if (!sourceHostPartition) { + // Client focus is not host state; retirement retargets it instead of the destination carrying it. + fragment.activeWorktreeId = null + delete fragment.activeWorkspaceKey + } // A shutdown marker is only a reconnect hint. Once the source has no live // PTY authority for that worktree, carrying it would make the destination // try to resurrect a process that no longer exists. diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts index a63c6df01a4..f5376d1b257 100644 --- a/src/main/ssh/orcad-migration-delta-move.ts +++ b/src/main/ssh/orcad-migration-delta-move.ts @@ -34,6 +34,7 @@ import { } from './orcad-migration-terminal-gate' import { currentOrcadSourceFingerprint } from './orcad-retained-source' import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims' +import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason' export type OrcadDeltaMoveArgs = { userDataPath: string @@ -58,7 +59,7 @@ export async function runOrcadDeltaMove(args: OrcadDeltaMoveArgs): Promise 0) { return { - ...refuse('orcad_migration_preflight_blocked', 'This SSH host cannot move yet.'), + ...refuse('orcad_migration_preflight_blocked', orcadMigrationRefusalReason(plan.blockers)), blockers: plan.blockers } } diff --git a/src/main/ssh/orcad-migration-refusal-reason.test.ts b/src/main/ssh/orcad-migration-refusal-reason.test.ts new file mode 100644 index 00000000000..8c0b41c8f9d --- /dev/null +++ b/src/main/ssh/orcad-migration-refusal-reason.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason' + +describe('orcadMigrationRefusalReason', () => { + it('names each blocking kind once, in plain words', () => { + expect( + orcadMigrationRefusalReason([ + { + code: 'orcad_migration_direct_ssh_repositories', + category: 'drainable-static-state', + repositories: [] + }, + { + code: 'orcad_migration_dependent_state', + category: 'client-owned-state', + dependencies: [ + { kind: 'workspace-session', count: 7 }, + { kind: 'worktree-lineage', count: 1 }, + { kind: 'workspace-lineage', count: 1 }, + { kind: 'automation', count: 2 } + ] + }, + { + code: 'orcad_migration_direct_ssh_terminal_leases', + category: 'live-or-unverifiable', + terminalLeases: [] + } + ]) + ).toBe( + 'This SSH host cannot move yet: saved tabs and panes, workspace history, automations, terminals still running.' + ) + }) + + it('keeps the bare refusal when nothing that blocks is named', () => { + expect(orcadMigrationRefusalReason([])).toBe('This SSH host cannot move yet.') + }) +}) diff --git a/src/main/ssh/orcad-migration-refusal-reason.ts b/src/main/ssh/orcad-migration-refusal-reason.ts new file mode 100644 index 00000000000..4f4681c6961 --- /dev/null +++ b/src/main/ssh/orcad-migration-refusal-reason.ts @@ -0,0 +1,50 @@ +import type { + OrcadMigrationBlocker, + OrcadMigrationDependencyKind +} from '../../shared/orcad-migration-preflight' + +const DEPENDENCY_WORDS: Record = { + 'saved-port-forward': 'saved port forwards', + 'terminal-lease': 'terminals still running', + 'terminal-recovery': 'terminals waiting to reconnect', + 'workspace-session': 'saved tabs and panes', + 'worktree-metadata': 'saved workspace details', + 'worktree-lineage': 'workspace history', + 'workspace-lineage': 'workspace history', + 'sparse-preset': 'sparse checkout presets', + 'retired-worktree-name': 'retired workspace names', + automation: 'automations', + 'automation-run': 'automation runs', + 'mobile-tab-selection': 'mobile tab selections', + 'ui-routing': 'sidebar settings' +} + +function blockerWords(blocker: OrcadMigrationBlocker): string[] { + switch (blocker.code) { + case 'orcad_migration_target_not_found': + return ['the host is no longer saved'] + case 'orcad_migration_target_owned': + return ['another server holds it'] + case 'orcad_migration_owner_unrecorded': + return ['its server record is missing'] + case 'orcad_migration_direct_ssh_terminal_leases': + return ['terminals still running'] + case 'orcad_migration_dependent_state': + return blocker.dependencies.map(({ kind }) => DEPENDENCY_WORDS[kind]) + case 'orcad_migration_dependency_unverifiable': + return ['saved state Orca could not read'] + case 'orcad_migration_direct_ssh_repositories': + case 'orcad_migration_direct_ssh_folder_workspaces': + case 'orcad_migration_saved_port_forwards': + // These move with the host or stay behind; they never refuse a conversion. + return [] + } +} + +/** The refusal names what blocks, in plain words; the status line shows only this text. */ +export function orcadMigrationRefusalReason(blockers: readonly OrcadMigrationBlocker[]): string { + const words = [...new Set(blockers.flatMap(blockerWords))] + return words.length > 0 + ? `This SSH host cannot move yet: ${words.join(', ')}.` + : 'This SSH host cannot move yet.' +} diff --git a/src/main/ssh/orcad-migration-source-fence.ts b/src/main/ssh/orcad-migration-source-fence.ts index 3c632483445..36844fbf565 100644 --- a/src/main/ssh/orcad-migration-source-fence.ts +++ b/src/main/ssh/orcad-migration-source-fence.ts @@ -29,6 +29,7 @@ import { preflightOrcadMigrationExport, type OrcadMigrationPreflightStore } from './ssh-target-orcad-preflight' +import { orcadMigrationRefusalReason } from './orcad-migration-refusal-reason' export type OrcadMigrationFenceState = | { state: 'none' } @@ -115,7 +116,11 @@ export async function fenceOrcadMigrationSource(args: { const preflight = preflightOrcadMigrationExport(args.store, target.id) if (!preflight.claimable) { return { - ...refuse('live', 'orcad_migration_preflight_blocked', 'This SSH host cannot move yet.'), + ...refuse( + 'live', + 'orcad_migration_preflight_blocked', + orcadMigrationRefusalReason(preflight.blockers) + ), blockers: preflight.blockers } } diff --git a/src/renderer/src/lib/unified-tab-host-ownership.ts b/src/renderer/src/lib/unified-tab-host-ownership.ts index 9eeac23c123..b8c0a4a0427 100644 --- a/src/renderer/src/lib/unified-tab-host-ownership.ts +++ b/src/renderer/src/lib/unified-tab-host-ownership.ts @@ -11,6 +11,8 @@ import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias import { folderWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '@/store/types' import { dedupePaletteWorktrees } from './palette-repo-resolution' +import { getResolvedExecutionHostIdForWorktree } from './resolved-worktree-execution-host' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' export function getPaletteOwnershipWorktreeIds( state: Pick @@ -40,15 +42,21 @@ export function findAmbiguousWorktreeIds( } export function getActiveExecutionHostIdForWorktree( - state: { + state: WorktreeRuntimeOwnerState & { activeWorktreeId: string | null activeWorkspaceExecutionHostId?: ExecutionHostId | null }, worktreeId: string ): ExecutionHostId | undefined { - return state.activeWorktreeId === worktreeId - ? (state.activeWorkspaceExecutionHostId ?? LOCAL_EXECUTION_HOST_ID) - : undefined + if (state.activeWorktreeId !== worktreeId) { + return undefined + } + // Why: most activations pass no host; a bare 'local' there stamped SSH worktrees' tabs as local. + return ( + state.activeWorkspaceExecutionHostId ?? + getResolvedExecutionHostIdForWorktree(state, worktreeId) ?? + LOCAL_EXECUTION_HOST_ID + ) } export function isUnifiedTabOwnedByWorktree( diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 04b8d3d0c61..00c47f20859 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -90,6 +90,23 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch).toEqual({ activeTabId: 'tab-2' }) }) + it('writes a changed workspace key and host so a refocus replaces the persisted ones', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ + activeWorktreeId: 'wt-2', + activeWorkspaceKey: 'worktree:wt-2', + activeWorkspaceExecutionHostId: null + }), + ['activeWorktreeId', 'activeWorkspaceKey', 'activeWorkspaceExecutionHostId'] + ) + + expect(patch).toEqual({ + activeWorktreeId: 'wt-2', + activeWorkspaceKey: 'worktree:wt-2', + activeWorkspaceExecutionHostId: null + }) + }) + it('derives only editor session keys for open file changes', () => { const patch = buildWorkspaceSessionPatch(createSnapshot(), ['openFiles']) diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index dc2947918c2..d1a16cf25e9 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -65,6 +65,13 @@ export function buildWorkspaceSessionPatch( if (changed.has('activeWorktreeId')) { patch.activeWorktreeId = snapshot.activeWorktreeId } + // Why: without these the debounced writer kept the first focus's key and host forever. + if (changed.has('activeWorkspaceKey')) { + patch.activeWorkspaceKey = snapshot.activeWorkspaceKey + } + if (changed.has('activeWorkspaceExecutionHostId')) { + patch.activeWorkspaceExecutionHostId = snapshot.activeWorkspaceExecutionHostId ?? null + } if (changed.has('activeTabId')) { patch.activeTabId = snapshot.activeTabId } diff --git a/src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts b/src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts index ecd9fed0c2c..35c88658100 100644 --- a/src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts +++ b/src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts @@ -4,7 +4,13 @@ import { getDefaultUIState } from '../../../../shared/constants' import { buildMobileSessionTabSnapshots } from '../../runtime/sync-runtime-graph' import { closeMobileSessionTabInStore } from '../../runtime/mobile-session-tab-close' import { createTabsSliceMockApi } from './tabs-slice-test-harness' -import { createTestStore, makeOpenFile, makeTabGroup, makeUnifiedTab } from './store-test-helpers' +import { + createTestStore, + makeOpenFile, + makeTabGroup, + makeUnifiedTab, + makeWorktree +} from './store-test-helpers' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -74,6 +80,19 @@ describe('TabsSlice', () => { expect(store.getState().createUnifiedTab(WT, 'browser').executionHostId).toBe('local') }) + it("stamps an SSH worktree's own host when activation passed none", () => { + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', hostId: 'ssh:box' })] }, + activeWorktreeId: WT, + activeWorkspaceExecutionHostId: null + }) + + expect(store.getState().createUnifiedTab(WT, 'browser').executionHostId).toBe('ssh:box') + }) + it('activates the newly created tab', () => { const tab1 = store.getState().createUnifiedTab(WT, 'terminal') const tab2 = store.getState().createUnifiedTab(WT, 'terminal')