fix(ports): show the Ports panel for folder workspaces on a managed SSH server (#29908)

A folder workspace has no repo row, only a synthetic repoId, so the panel's repo lookup came back
empty and it said "No workspace selected", hiding the SSH forwards and the server's listeners.
This commit is contained in:
Neil
2026-10-16 18:54:56 -07:00
committed by GitHub
parent b61b9da778
commit bdecb92932
2 changed files with 105 additions and 10 deletions
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { workspacePortRuntimeTargetKey } from '@/lib/workspace-port-actions'
import PortsPanel from './PortsPanel'
const initialState = useAppStore.getInitialState()
const folderWorkspace: FolderWorkspace = {
id: 'f1',
projectGroupId: 'g1',
name: 'notes',
folderPath: '/home/me/notes',
connectionId: null,
executionHostId: 'runtime:env-a',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 0,
updatedAt: 0
}
const scan: WorkspacePortScanResult = {
platform: 'linux',
scannedAt: 1,
ports: [
{
id: '127.0.0.1:5173:42',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 5173,
pid: 42,
processName: 'vite',
protocol: 'http',
kind: 'external'
}
]
}
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
})
describe('Ports panel for a folder workspace on a managed SSH server', () => {
it('shows the SSH forwards and the server listeners, not "No workspace selected"', () => {
const scanKey = `${workspacePortRuntimeTargetKey({ kind: 'environment', environmentId: 'env-a' })}:all`
useAppStore.setState({
folderWorkspaces: [folderWorkspace],
activeWorktreeId: 'folder:f1',
activeWorkspaceExecutionHostId: 'runtime:env-a',
runtimeEnvironments: [
{
id: 'env-a',
name: 'Box',
createdAt: 0,
updatedAt: 0,
lastUsedAt: null,
runtimeId: null,
preferredEndpointId: 'ep',
endpoints: [],
orcadDeployment: {
sshTargetId: 'box',
sshTargetGeneration: 1,
localPort: 7001,
remotePort: 7002
}
}
],
sshConnectionStates: new Map([
['box', { targetId: 'box', status: 'connected', error: null, reconnectAttempt: 0 }]
]),
workspacePortScansByKey: { [scanKey]: scan }
})
render(
<TooltipProvider>
<PortsPanel isVisible />
</TooltipProvider>
)
expect(screen.queryByText('No workspace selected')).toBeNull()
expect(screen.getByRole('button', { name: /Add/ })).toBeTruthy()
// The server's listener reaches the (collapsed) External section.
expect(screen.getByRole('button', { name: /External\s*1/ })).toBeTruthy()
})
})
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react'
import { RefreshCw, Server } from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { useActiveWorktree } from '@/store/selectors'
import { cn } from '@/lib/utils'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import {
@@ -40,7 +40,8 @@ export function LocalWorkspacePortsPanel({
sshForwardTargetId?: string | null
}): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
// Why the workspace's repoId, not a repo row: a folder workspace has no repo, only a synthetic id.
const activeRepoId = activeWorktree?.repoId ?? null
const settings = useAppStore((s) => s.settings)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
@@ -57,7 +58,7 @@ export function LocalWorkspacePortsPanel({
const scanKey = runtimeTarget ? `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` : null
const refresh = useCallback(() => {
if (!activeRepo || !runtimeTarget || !scanKey) {
if (!activeRepoId || !runtimeTarget || !scanKey) {
return Promise.resolve()
}
setWorkspacePortScanRefreshing(true)
@@ -92,7 +93,7 @@ export function LocalWorkspacePortsPanel({
})
return promise
}, [
activeRepo,
activeRepoId,
runtimeTarget,
scanKey,
replaceWorkspacePortScans,
@@ -109,11 +110,11 @@ export function LocalWorkspacePortsPanel({
const handleStopPort = useCallback(
async (port: WorkspacePort) => {
if (!activeRepo || !port.pid) {
if (!activeRepoId || !port.pid) {
return
}
const result = await killWorkspacePortForTarget(runtimeTarget, {
repoId: activeRepo.id,
repoId: activeRepoId,
pid: port.pid,
port: port.port
})
@@ -146,7 +147,7 @@ export function LocalWorkspacePortsPanel({
)
}
},
[activeRepo, runtimeTarget, replaceWorkspacePortScans, setWorkspacePortScanRefreshing]
[activeRepoId, runtimeTarget, replaceWorkspacePortScans, setWorkspacePortScanRefreshing]
)
const handleOpenPortInBrowser = useCallback(
@@ -178,8 +179,8 @@ export function LocalWorkspacePortsPanel({
)
const { activePorts, otherWorkspacePorts, externalPorts } = useMemo(
() => getLocalWorkspacePortSections(displayScan, activeRepo?.id, activeWorktree?.id),
[activeRepo?.id, activeWorktree?.id, displayScan]
() => getLocalWorkspacePortSections(displayScan, activeRepoId, activeWorktree?.id),
[activeRepoId, activeWorktree?.id, displayScan]
)
const showPortSections = shouldShowLocalWorkspacePortSections(displayScan, {
@@ -188,7 +189,7 @@ export function LocalWorkspacePortsPanel({
externalPorts
})
if (!activeRepo) {
if (!activeRepoId) {
return (
<div className="flex flex-col items-center justify-center h-full px-4 text-center text-muted-foreground">
<Server size={32} className="mb-3 opacity-50" />