diff --git a/src/main/ipc/ssh-connection-state-callbacks.ts b/src/main/ipc/ssh-connection-state-callbacks.ts index 51c68b94832..3d190c9f730 100644 --- a/src/main/ipc/ssh-connection-state-callbacks.ts +++ b/src/main/ipc/ssh-connection-state-callbacks.ts @@ -11,6 +11,7 @@ import { testingTargets } from './ssh-connect-attempt-registry' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' import { requestCredential } from './ssh-passphrase' import { clearRelayLostBackoff } from './ssh-relay-lost-backoff' import { @@ -108,6 +109,10 @@ export function handleSshConnectionStateChange(targetId: string, state: SshConne } if (!session) { + // An explicit connect restores through its own publish; this covers a transport that recovered. + if (state.status === 'connected' && !connectInFlight.has(targetId)) { + void restoreManagedHostPortForwards(targetId) + } return } // Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session. diff --git a/src/main/ipc/ssh-host-server-connect.ts b/src/main/ipc/ssh-host-server-connect.ts index 4d1ff5ba22c..e15c60e4bd9 100644 --- a/src/main/ipc/ssh-host-server-connect.ts +++ b/src/main/ipc/ssh-host-server-connect.ts @@ -18,6 +18,7 @@ import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { allowsDirectSshRelay } from '../ssh/ssh-connection-store' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' import { broadcastSshState, clearRelayStateOverride, @@ -101,6 +102,7 @@ export function publishManagedServerConnect( ...(serving ? { serving } : {}) } setSshHostServerStatus(targetId, managedServer) + void restoreManagedHostPortForwards(targetId) const state: SshConnectionState = { ...(connectionManager!.getState(targetId) ?? { targetId, reconnectAttempt: 0 }), targetId, diff --git a/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts b/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts new file mode 100644 index 00000000000..022472d7d2a --- /dev/null +++ b/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + restore: vi.fn(async () => {}), + removeAllForwards: vi.fn(async () => {}), + disconnect: vi.fn(async () => {}), + getState: vi.fn(() => ({ targetId: 't1', status: 'connected', error: null, reconnectAttempt: 0 })) +})) + +vi.mock('./ssh-managed-port-forward-restore', () => ({ restoreManagedHostPortForwards: h.restore })) +vi.mock('./ssh-ipc-context', () => ({ + connectionManager: { getState: h.getState, disconnect: h.disconnect, getConnection: vi.fn() }, + currentRuntime: null, + getCurrentMainWindow: () => null, + persistedStore: null, + portForwardManager: { removeAllForwards: h.removeAllForwards } +})) +vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + getSshTargetRegistryStore: () => null +})) + +const { handleSshConnectionStateChange } = await import('./ssh-connection-state-callbacks') +const { publishManagedServerConnect } = await import('./ssh-host-server-connect') +const { disconnectRegisteredSshTarget } = await import('./ssh-session-teardown') +const { connectInFlight } = await import('./ssh-connect-attempt-registry') +const { getSshProviderAuthority } = await import('../ssh/ssh-provider-authority') +const { clearSshHostServerStatus, setSshHostServerStatus } = + await import('../ssh/ssh-host-server-status') + +const connected = { targetId: 't1', status: 'connected', error: null, reconnectAttempt: 0 } as const + +describe('saved port forwards on a managed host', () => { + beforeEach(() => { + vi.clearAllMocks() + clearSshHostServerStatus('t1') + }) + + it('restores them when a managed connect is published', () => { + publishManagedServerConnect('t1', 'env-1') + expect(h.restore).toHaveBeenCalledWith('t1') + }) + + it('restores them when the managed host transport reconnects on its own', () => { + setSshHostServerStatus('t1', { kind: 'managed', environmentId: 'env-1' }) + handleSshConnectionStateChange('t1', { ...connected, status: 'reconnecting' }) + expect(h.restore).not.toHaveBeenCalled() + handleSshConnectionStateChange('t1', connected) + expect(h.restore).toHaveBeenCalledWith('t1') + }) + + it('leaves an explicit connect to restore through its own publish', () => { + connectInFlight.set('t1', { + authority: getSshProviderAuthority('t1'), + promise: new Promise(() => {}) + }) + try { + handleSshConnectionStateChange('t1', connected) + expect(h.restore).not.toHaveBeenCalled() + } finally { + connectInFlight.delete('t1') + } + }) + + it('closes the local listeners on disconnect even though no relay session exists', async () => { + await disconnectRegisteredSshTarget('t1') + expect(h.removeAllForwards).toHaveBeenCalledWith('t1') + }) +}) diff --git a/src/main/ipc/ssh-managed-port-forward-restore.ts b/src/main/ipc/ssh-managed-port-forward-restore.ts new file mode 100644 index 00000000000..41b868c45cc --- /dev/null +++ b/src/main/ipc/ssh-managed-port-forward-restore.ts @@ -0,0 +1,26 @@ +import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { getCurrentMainWindow } from './ssh-ipc-context' +import { restorePortForwards } from './ssh-port-forward-persistence' + +const restoring = new Map>() + +/** A managed host has no relay session to restore its saved forwards, so its connects do. */ +export function restoreManagedHostPortForwards(targetId: string): Promise { + if (getSshHostServerStatus(targetId)?.kind !== 'managed') { + return Promise.resolve() + } + // Why chained: overlapping connects would otherwise both bind the same saved local port. + const previous = restoring.get(targetId) ?? Promise.resolve() + const next = previous + .then(() => restorePortForwards(targetId, getCurrentMainWindow)) + .catch((error: unknown) => { + console.warn(`[ssh] Could not restore port forwards for ${targetId}:`, error) + }) + .finally(() => { + if (restoring.get(targetId) === next) { + restoring.delete(targetId) + } + }) + restoring.set(targetId, next) + return next +} diff --git a/src/main/ipc/ssh-port-forward-persistence.ts b/src/main/ipc/ssh-port-forward-persistence.ts index e0407f859f5..7addd6b7dde 100644 --- a/src/main/ipc/ssh-port-forward-persistence.ts +++ b/src/main/ipc/ssh-port-forward-persistence.ts @@ -4,6 +4,10 @@ import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { connectionManager, portForwardManager } from './ssh-ipc-context' import { broadcastPortForwards } from './ssh-renderer-broadcast' +function forwardKey(forward: SavedPortForward): string { + return `${forward.localPort}:${forward.remoteHost}:${forward.remotePort}` +} + // Why: after user add/remove/update the runtime manager is the source of truth — persist exactly its entries (unrestored ones handled by a separate helper). export function persistPortForwards(targetId: string): void { const active = portForwardManager!.listForwards(targetId) @@ -21,12 +25,10 @@ export function persistPortForwards(targetId: string): void { // Why: keep forwards that failed to restore in the persisted list so they retry on next reconnect instead of being silently dropped. export function persistPortForwardsWithUnrestored(targetId: string): void { const active = portForwardManager!.listForwards(targetId) - const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`)) + const activeKeys = new Set(active.map(forwardKey)) const existing = getSshTargetRegistryStore()!.getTarget(targetId)?.portForwards ?? [] - const unrestored = existing.filter( - (pf) => !activeKeys.has(`${pf.localPort}:${pf.remoteHost}:${pf.remotePort}`) - ) + const unrestored = existing.filter((pf) => !activeKeys.has(forwardKey(pf))) const saved: SavedPortForward[] = [ ...active.map((f) => ({ @@ -54,9 +56,15 @@ export async function restorePortForwards( if (!conn) { return } + // Why: a forward bound to the replaced transport still holds its local port but reaches nothing. + await portForwardManager!.closeStaleForwards(targetId, conn) + const live = new Set(portForwardManager!.listForwards(targetId).map(forwardKey)) // Why: keep failed restores in persisted state — a failure may be transient (port temporarily busy), so retry on next reconnect. for (const saved of target.portForwards) { + if (live.has(forwardKey(saved))) { + continue + } // Why: a reconnect mid-loop swaps the connection object; bail on identity change so we don't add forwards to a stale conn (leaking listeners). if (connectionManager!.getConnection(targetId) !== conn) { return diff --git a/src/main/ipc/ssh-port-forward-restore.test.ts b/src/main/ipc/ssh-port-forward-restore.test.ts new file mode 100644 index 00000000000..e4517510dc6 --- /dev/null +++ b/src/main/ipc/ssh-port-forward-restore.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PortForwardEntry, SavedPortForward } from '../../shared/ssh-types' + +const h = vi.hoisted(() => { + const conn = { id: 'conn' } + const saved: { portForwards?: SavedPortForward[] } = {} + const active: PortForwardEntry[] = [] + return { + conn, + saved, + active, + store: { + getTarget: vi.fn(() => ({ id: 't1', ...saved })), + updateTarget: vi.fn((_id: string, update: { portForwards?: SavedPortForward[] }) => { + saved.portForwards = update.portForwards + }) + }, + connectionManager: { getConnection: vi.fn(() => conn) }, + portForwardManager: { + closeStaleForwards: vi.fn(async () => {}), + listForwards: vi.fn(() => [...active]), + addForward: vi.fn( + async ( + connectionId: string, + _conn: unknown, + localPort: number, + remoteHost: string, + remotePort: number, + label?: string + ) => { + const entry = { + id: `pf-${active.length + 1}`, + connectionId, + localPort, + remoteHost, + remotePort, + label + } + active.push(entry) + return entry + } + ) + }, + hostServerStatus: vi.fn<() => { kind: 'managed'; environmentId: string } | undefined>(() => ({ + kind: 'managed', + environmentId: 'env-1' + })), + broadcastPortForwards: vi.fn() + } +}) + +vi.mock('../ssh/ssh-target-registry', () => ({ getSshTargetRegistryStore: () => h.store })) +vi.mock('./ssh-ipc-context', () => ({ + connectionManager: h.connectionManager, + portForwardManager: h.portForwardManager, + getCurrentMainWindow: () => null +})) +vi.mock('./ssh-renderer-broadcast', () => ({ broadcastPortForwards: h.broadcastPortForwards })) +vi.mock('../ssh/ssh-host-server-status', () => ({ getSshHostServerStatus: h.hostServerStatus })) + +import { restorePortForwards } from './ssh-port-forward-persistence' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' + +const FORWARD = { localPort: 4100, remoteHost: '127.0.0.1', remotePort: 7860, label: 'app' } + +describe('saved port forward restore', () => { + beforeEach(() => { + vi.clearAllMocks() + h.active.length = 0 + h.saved.portForwards = [{ ...FORWARD }] + }) + + it('closes forwards bound to a replaced transport before re-adding the saved ones', async () => { + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.closeStaleForwards).toHaveBeenCalledWith('t1', h.conn) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.portForwardManager.closeStaleForwards.mock.invocationCallOrder[0]).toBeLessThan( + h.portForwardManager.addForward.mock.invocationCallOrder[0] + ) + }) + + it('leaves a forward that is still live on the current transport alone', async () => { + await restorePortForwards('t1', () => null) + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.saved.portForwards).toEqual([FORWARD]) + }) + + it('restores a managed host once even when two connects race', async () => { + await Promise.all([restoreManagedHostPortForwards('t1'), restoreManagedHostPortForwards('t1')]) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.broadcastPortForwards).toHaveBeenCalledWith(expect.any(Function), 't1') + }) + + it('does nothing for a host that is not managed', async () => { + h.hostServerStatus.mockReturnValueOnce(undefined) + await restoreManagedHostPortForwards('t1') + expect(h.portForwardManager.addForward).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ssh-session-teardown.ts b/src/main/ipc/ssh-session-teardown.ts index bd8dbbe0e9d..38d9a252013 100644 --- a/src/main/ipc/ssh-session-teardown.ts +++ b/src/main/ipc/ssh-session-teardown.ts @@ -73,7 +73,10 @@ export async function teardownSshTargetTransport( // Why: start the transport disconnect before session teardown; the async wrapper turns a sync throw into a rejection. const transportDisconnect = (async () => connectionManager?.disconnect(targetId))() const [teardownResult, disconnectResult] = await Promise.allSettled([ - teardownActiveSshSession(targetId, teardown), + activeSessions.has(targetId) + ? teardownActiveSshSession(targetId, teardown) + : // A managed host has no relay session, but its forwards still hold local ports. + (async () => portForwardManager?.removeAllForwards(targetId))(), transportDisconnect ]) if (teardownResult.status === 'rejected') { diff --git a/src/main/ssh/ssh-port-forward-provider.ts b/src/main/ssh/ssh-port-forward-provider.ts index ba17d434dc3..51c13404749 100644 --- a/src/main/ssh/ssh-port-forward-provider.ts +++ b/src/main/ssh/ssh-port-forward-provider.ts @@ -20,6 +20,8 @@ export type StartedPortForward = { entry: PortForwardEntry close: () => Promise dispose: () => void + /** False once `conn` no longer carries the transport this forward tunnels through. */ + isLive?: (conn: SshConnection) => boolean } export type SshPortForwardProvider = { diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index 147ba2f08d2..f4af3a36e6e 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -412,6 +412,22 @@ describe('SshPortForwardManager', () => { expect(manager.listForwards('conn-2')).toHaveLength(1) }) + it('closes only the ssh2 forwards bound to a client the connection replaced', async () => { + const conn = createMockConn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements the getClient/usesSystemSshTransport surface the ssh2 provider reads. + const sshConn = conn as never + await manager.addForward('conn-1', sshConn, 3000, 'localhost', 8080) + await manager.addForward('conn-2', sshConn, 3002, 'localhost', 8082) + + await manager.closeStaleForwards('conn-1', sshConn) + expect(manager.listForwards('conn-1')).toHaveLength(1) + + conn.getClient.mockReturnValue({ forwardOut: vi.fn() }) + await manager.closeStaleForwards('conn-1', sshConn) + expect(manager.listForwards('conn-1')).toHaveLength(0) + expect(manager.listForwards('conn-2')).toHaveLength(1) + }) + it('dispose removes all forwards', async () => { const conn = createMockConn() await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) diff --git a/src/main/ssh/ssh-port-forward.ts b/src/main/ssh/ssh-port-forward.ts index bc151eaab1e..4c7b7207825 100644 --- a/src/main/ssh/ssh-port-forward.ts +++ b/src/main/ssh/ssh-port-forward.ts @@ -182,6 +182,15 @@ export class SshPortForwardManager { await Promise.all(toRemove.map((id) => this.removeForwardAsync(id))) } + /** Closes this connection's forwards that tunnel through a transport `conn` replaced. */ + async closeStaleForwards(connectionId: string, conn: SshConnection): Promise { + const stale = [...this.forwards.entries()] + .filter(([, forward]) => forward.entry.connectionId === connectionId) + .filter(([, forward]) => forward.isLive?.(conn) === false) + .map(([id]) => id) + await Promise.all(stale.map((id) => this.removeForwardAsync(id))) + } + dispose(): void { const ids = [...this.forwards.keys()] for (const id of ids) { diff --git a/src/main/ssh/ssh2-port-forward-provider.ts b/src/main/ssh/ssh2-port-forward-provider.ts index 633b6922001..73b4d8a0716 100644 --- a/src/main/ssh/ssh2-port-forward-provider.ts +++ b/src/main/ssh/ssh2-port-forward-provider.ts @@ -86,7 +86,9 @@ export class Ssh2PortForwardProvider implements SshPortForwardProvider { close, dispose: () => { void close() - } + }, + // Why: this forward dials through `client`; a reconnect gives the connection a new one. + isLive: (current) => !closed && current.getClient() === client } } } diff --git a/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx index 4bab9fd907e..2284ac53ac4 100644 --- a/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx +++ b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx @@ -23,7 +23,7 @@ export type ActivityBarItem = { gitOnly?: boolean /** When true, shown only for folder workspaces. */ folderOnly?: boolean - /** When true, shown only for worktrees that belong to an SSH repo. */ + /** When true, shown only for workspaces on an SSH host. */ sshOnly?: boolean /** Host-owned health indicator; plugin content cannot style this chrome. */ statusIndicator?: CheckStatus diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts index 5082189c586..1e5b31fb54f 100644 --- a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts +++ b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts @@ -7,6 +7,7 @@ import type { import { getPortsPanelOwnerKey, portsPanelHostForOwnerKey, + portsPanelReachesSshHost, type PortsPanelHost } from './ports-panel-host' @@ -148,3 +149,41 @@ describe('Ports panel host', () => { }) }) }) + +describe('Ports tab visibility', () => { + const managed = { + id: 'env-managed', + orcadDeployment: { sshTargetId: 'box', sshTargetGeneration: 1 } + } as const + const environments = [{ id: 'env-paired' }, managed] + + it.each<[string, PortsPanelHost, boolean]>([ + ['a direct SSH workspace', { kind: 'direct-ssh', connectionId: 'box' }, true], + ['an SSH target behind a paired server', serverSsh('env-paired', 't'), true], + [ + 'a workspace on a managed server reached over SSH', + { kind: 'endpoint', target: { kind: 'environment', environmentId: 'env-managed' } }, + true + ], + [ + 'a workspace on a paired server that is not an SSH host', + { kind: 'endpoint', target: { kind: 'environment', environmentId: 'env-paired' } }, + false + ], + ['a local workspace', { kind: 'endpoint', target: { kind: 'local' } }, false], + ['an unknown owner', { kind: 'unknown' }, false] + ])('%s', (_name, host, visible) => { + expect(portsPanelReachesSshHost(host, environments)).toBe(visible) + }) + + it('follows the workspace owner, not the repo connection', () => { + const state: WorktreeOperationRouteState = { + repos: [{ id: 'repo-1' }], + worktreesByRepo: { 'repo-1': [worktree('runtime:env-managed', 'env-managed')] }, + runtimeEnvironments: [{ id: 'env-managed' }], + runtimeEnvironmentCatalogHydrated: true + } + const host = portsPanelHostForOwnerKey(getPortsPanelOwnerKey(state, WORKTREE_ID)) + expect(portsPanelReachesSshHost(host, environments)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.ts index 0d4a0bacd81..48b4d1561a4 100644 --- a/src/renderer/src/components/right-sidebar/ports-panel-host.ts +++ b/src/renderer/src/components/right-sidebar/ports-panel-host.ts @@ -56,3 +56,27 @@ export function portsPanelHostForOwnerKey(key: HostAuthorityKey | null): PortsPa } return { kind: 'host-scoped', route, executionHostId: authority.at } } + +/** The Ports tab shows for a workspace on an SSH host, dialed directly or through its managed server. */ +export function portsPanelReachesSshHost( + host: PortsPanelHost, + environments: readonly { id: string; orcadDeployment?: unknown }[] +): boolean { + switch (host.kind) { + case 'direct-ssh': + case 'host-scoped': + return true + case 'endpoint': { + const { target } = host + return ( + target.kind === 'environment' && + environments.some( + (environment) => + environment.id === target.environmentId && environment.orcadDeployment !== undefined + ) + ) + } + case 'unknown': + return false + } +} diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts index 01c44ba79c5..90517dddb0b 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts +++ b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts @@ -37,12 +37,12 @@ const items: ActivityBarItem[] = [ ] describe('getVisibleRightSidebarActivityItems', () => { - it('shows ports only for SSH repos', () => { + it('shows ports only for workspaces on an SSH host', () => { expect( getVisibleRightSidebarActivityItems(items, { isFolder: false, isFolderWorkspace: false, - isSshRepo: false + hasSshHost: false }).map((item) => item.id) ).toEqual(['explorer', 'source-control', 'plugin:orca-samples.my-plugin/dashboard']) @@ -50,7 +50,7 @@ describe('getVisibleRightSidebarActivityItems', () => { getVisibleRightSidebarActivityItems(items, { isFolder: false, isFolderWorkspace: false, - isSshRepo: true + hasSshHost: true }).map((item) => item.id) ).toEqual(['explorer', 'source-control', 'ports', 'plugin:orca-samples.my-plugin/dashboard']) }) @@ -60,7 +60,7 @@ describe('getVisibleRightSidebarActivityItems', () => { getVisibleRightSidebarActivityItems(items, { isFolder: true, isFolderWorkspace: true, - isSshRepo: true + hasSshHost: true }).map((item) => item.id) ).toEqual([ 'explorer', @@ -74,7 +74,7 @@ describe('getVisibleRightSidebarActivityItems', () => { getVisibleRightSidebarActivityItems(items, { isFolder: true, isFolderWorkspace: false, - isSshRepo: true + hasSshHost: true }).map((item) => item.id) ).toEqual(['explorer', 'ports', 'plugin:orca-samples.my-plugin/dashboard']) }) diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts index f4bc0958959..2f9fefc70e0 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts +++ b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts @@ -3,17 +3,18 @@ import type { ActivityBarItem } from './activity-bar-buttons' type RightSidebarActivityVisibilityState = { isFolder: boolean isFolderWorkspace: boolean - isSshRepo: boolean + /** The workspace runs on an SSH host, reached directly or through its managed server. */ + hasSshHost: boolean } export function getVisibleRightSidebarActivityItems( items: ActivityBarItem[], - { isFolder, isFolderWorkspace, isSshRepo }: RightSidebarActivityVisibilityState + { isFolder, isFolderWorkspace, hasSshHost }: RightSidebarActivityVisibilityState ): ActivityBarItem[] { return items.filter( (item) => (!item.gitOnly || !isFolder) && (!item.folderOnly || isFolderWorkspace) && - (!item.sshOnly || isSshRepo) + (!item.sshOnly || hasSshHost) ) } diff --git a/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts b/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts index 5dd40c53f1c..bf9ff26770a 100644 --- a/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts +++ b/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts @@ -6,6 +6,11 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getVisibleRightSidebarActivityItems } from './right-sidebar-activity-visibility' import { getPluginPanelActivityItems } from './plugin-panel-activity-items' +import { + getPortsPanelOwnerKey, + portsPanelHostForOwnerKey, + portsPanelReachesSshHost +} from './ports-panel-host' import { collectInstalledPluginTabKeys, usePluginPanels, @@ -44,7 +49,13 @@ export function useRightSidebarActivityItems({ const activeWorkspaceScope = parseWorkspaceKey(activeWorktreeId ?? '') const isFolderWorkspace = activeWorkspaceScope?.type === 'folder' const isFolder = isFolderWorkspace || (activeRepo ? isFolderRepo(activeRepo) : false) - const isSshRepo = Boolean(activeRepo?.connectionId) + // Why the owner: a managed server's workspace names its server, not an SSH connection. + const hasSshHost = useAppStore((s) => + portsPanelReachesSshHost( + portsPanelHostForOwnerKey(getPortsPanelOwnerKey(s, activeWorktreeId)), + s.runtimeEnvironments + ) + ) const pluginSystemEnabled = useAppStore((s) => s.settings?.pluginSystemEnabled === true) const pluginPanels = usePluginPanels() const visiblePluginPanels = useMemo( @@ -130,9 +141,9 @@ export function useRightSidebarActivityItems({ getVisibleRightSidebarActivityItems(activityItems, { isFolder, isFolderWorkspace, - isSshRepo + hasSshHost }), - [activityItems, isFolder, isFolderWorkspace, isSshRepo] + [activityItems, isFolder, isFolderWorkspace, hasSshHost] ) const activeFolderWorkspaceKey = isFolderWorkspace ? (activeWorktreeId ?? null) : null diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts index 4edb8dfec11..0c9a9fe7adf 100644 --- a/tests/e2e/ssh-orcad-workspace-tools.spec.ts +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -61,8 +61,6 @@ test('Quick Open finds a file in a monorepo-sized managed workspace', async (// for (const acrossReconnect of [false, true]) { test(`a managed host reports its listener, and an SSH forward reaches it${acrossReconnect ? ' across a reconnect' : ''}`, async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. {}, testInfo) => { - // Why fixme: saved forwards are re-established only by the relay session's connect callbacks. - test.fixme(acrossReconnect, 'A managed host reconnect never calls restorePortForwards') test.setTimeout(10 * 60_000) const remotePort = 7860 const marker = `ORCA_FORWARD_${Date.now()}` @@ -97,11 +95,18 @@ for (const acrossReconnect of [false, true]) { } } await expect.poll(fetchMarker, { timeout: 30_000 }).toBe(marker) + let forwardId = forward.id if (acrossReconnect) { await reconnect(page, seeded.targetId) await expect.poll(fetchMarker, { timeout: 60_000 }).toBe(marker) + // The restored forward is a new entry for the same saved port. + const restored = await page.evaluate( + (targetId) => window.api.ssh.listPortForwards({ targetId }), + seeded.targetId + ) + forwardId = restored.find((entry) => entry.localPort === localPort)?.id ?? forwardId } - await page.evaluate((id) => window.api.ssh.removePortForward({ id }), forward.id) + await page.evaluate((id) => window.api.ssh.removePortForward({ id }), forwardId) await expect.poll(fetchMarker, { timeout: 30_000 }).not.toBe(marker) }).finally(() => reservation.release()) })