Add workspace port management (#2316)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d5fb49b18f
commit
062725da76
|
|
@ -44,7 +44,8 @@ const {
|
|||
registerOnboardingHandlersMock,
|
||||
registerSpeechHandlersMock,
|
||||
registerSkillsHandlersMock,
|
||||
registerWorkspaceSpaceHandlersMock
|
||||
registerWorkspaceSpaceHandlersMock,
|
||||
registerWorkspacePortHandlersMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerCliHandlersMock: vi.fn(),
|
||||
registerPreflightHandlersMock: vi.fn(),
|
||||
|
|
@ -87,7 +88,8 @@ const {
|
|||
registerOnboardingHandlersMock: vi.fn(),
|
||||
registerSpeechHandlersMock: vi.fn(),
|
||||
registerSkillsHandlersMock: vi.fn(),
|
||||
registerWorkspaceSpaceHandlersMock: vi.fn()
|
||||
registerWorkspaceSpaceHandlersMock: vi.fn(),
|
||||
registerWorkspacePortHandlersMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./onboarding', () => ({
|
||||
|
|
@ -166,6 +168,10 @@ vi.mock('./workspace-space', () => ({
|
|||
registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-ports', () => ({
|
||||
registerWorkspacePortHandlers: registerWorkspacePortHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry', () => ({
|
||||
registerTelemetryHandlers: registerTelemetryHandlersMock
|
||||
}))
|
||||
|
|
@ -297,6 +303,7 @@ describe('registerCoreHandlers', () => {
|
|||
registerSpeechHandlersMock.mockReset()
|
||||
registerSkillsHandlersMock.mockReset()
|
||||
registerWorkspaceSpaceHandlersMock.mockReset()
|
||||
registerWorkspacePortHandlersMock.mockReset()
|
||||
})
|
||||
|
||||
it('passes the store through to handler registrars that need it', () => {
|
||||
|
|
@ -349,6 +356,7 @@ describe('registerCoreHandlers', () => {
|
|||
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
|
||||
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { registerSessionHandlers } from './session'
|
|||
import { registerSettingsHandlers } from './settings'
|
||||
import { registerSkillsHandlers } from './skills'
|
||||
import { registerWorkspaceSpaceHandlers } from './workspace-space'
|
||||
import { registerWorkspacePortHandlers } from './workspace-ports'
|
||||
import { registerAutomationHandlers } from './automations'
|
||||
import { registerTelemetryHandlers } from './telemetry'
|
||||
import { registerBrowserHandlers } from './browser'
|
||||
|
|
@ -131,6 +132,7 @@ export function registerCoreHandlers(
|
|||
registerSessionHandlers(store)
|
||||
registerUIHandlers(store)
|
||||
registerWorkspaceSpaceHandlers(store)
|
||||
registerWorkspacePortHandlers(store)
|
||||
if (commitMessageAgentEnv) {
|
||||
registerFilesystemHandlers(store, commitMessageAgentEnv)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspacePort, WorkspacePortScanResult } from '../../shared/workspace-ports'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>()
|
||||
const { handleMock, scanWorkspacePortsMock, processKillMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
scanWorkspacePortsMock: vi.fn(),
|
||||
processKillMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: handleMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../ports/local-workspace-port-scanner', () => ({
|
||||
scanWorkspacePorts: scanWorkspacePortsMock
|
||||
}))
|
||||
|
||||
import { registerWorkspacePortHandlers } from './workspace-ports'
|
||||
|
||||
const EMPTY_SCAN: WorkspacePortScanResult = {
|
||||
ports: [],
|
||||
platform: process.platform,
|
||||
scannedAt: 0
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
return {
|
||||
getRepos: vi.fn(() => [
|
||||
{
|
||||
id: 'local-repo',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'Local Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
},
|
||||
{
|
||||
id: 'remote-repo',
|
||||
path: '/remote/repo',
|
||||
displayName: 'Remote Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-target'
|
||||
},
|
||||
{
|
||||
id: 'other-repo',
|
||||
path: '/workspace/other',
|
||||
displayName: 'Other Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
]),
|
||||
getAllWorktreeMeta: vi.fn(() => ({
|
||||
'local-repo::/workspace/repo': { displayName: 'Primary' },
|
||||
'remote-repo::/remote/repo': { displayName: 'Remote' },
|
||||
'other-repo::/workspace/other': { displayName: 'Other' },
|
||||
'malformed-worktree-id': { displayName: 'Malformed' }
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerWorkspacePortHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
scanWorkspacePortsMock.mockReset()
|
||||
handleMock.mockImplementation((channel, handler) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
scanWorkspacePortsMock.mockResolvedValue(EMPTY_SCAN)
|
||||
processKillMock.mockReset()
|
||||
vi.spyOn(process, 'kill').mockImplementation(processKillMock)
|
||||
})
|
||||
|
||||
it('derives local worktree probes from the main store instead of renderer input', async () => {
|
||||
const store = makeStore()
|
||||
registerWorkspacePortHandlers(store as never)
|
||||
|
||||
const handler = handlers.get('workspacePorts:scan')
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
await handler?.(null, {
|
||||
repoId: 'local-repo',
|
||||
worktrees: [{ id: 'attacker', path: '/tmp/not-authorized', repoId: 'local-repo' }]
|
||||
})
|
||||
|
||||
expect(scanWorkspacePortsMock).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'local-repo::/workspace/repo',
|
||||
repoId: 'local-repo',
|
||||
displayName: 'Primary',
|
||||
path: '/workspace/repo'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates concurrent scans for the same store-derived probe set', async () => {
|
||||
const store = makeStore()
|
||||
let resolveScan: (result: WorkspacePortScanResult) => void = () => {}
|
||||
scanWorkspacePortsMock.mockReturnValue(
|
||||
new Promise<WorkspacePortScanResult>((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
)
|
||||
registerWorkspacePortHandlers(store as never)
|
||||
|
||||
const handler = handlers.get('workspacePorts:scan')
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
const first = handler?.(null, { repoId: 'local-repo' })
|
||||
const second = handler?.(null, { repoId: 'local-repo' })
|
||||
expect(scanWorkspacePortsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveScan(EMPTY_SCAN)
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_SCAN, EMPTY_SCAN])
|
||||
})
|
||||
|
||||
it('handles malformed renderer scan input as an unfiltered local scan', async () => {
|
||||
const store = makeStore()
|
||||
registerWorkspacePortHandlers(store as never)
|
||||
|
||||
await handlers.get('workspacePorts:scan')?.(null, undefined)
|
||||
|
||||
expect(scanWorkspacePortsMock).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'local-repo::/workspace/repo',
|
||||
repoId: 'local-repo',
|
||||
displayName: 'Primary',
|
||||
path: '/workspace/repo'
|
||||
},
|
||||
{
|
||||
id: 'other-repo::/workspace/other',
|
||||
repoId: 'other-repo',
|
||||
displayName: 'Other',
|
||||
path: '/workspace/other'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('stops a process only after the current scan proves the pid owns a workspace port', async () => {
|
||||
const store = makeStore()
|
||||
const port = workspacePort({ pid: 1234, port: 5173 })
|
||||
scanWorkspacePortsMock.mockResolvedValue({
|
||||
...EMPTY_SCAN,
|
||||
ports: [port]
|
||||
})
|
||||
registerWorkspacePortHandlers(store as never)
|
||||
|
||||
const result = await handlers.get('workspacePorts:kill')?.(null, {
|
||||
repoId: 'local-repo',
|
||||
pid: 1234,
|
||||
port: 5173
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(processKillMock).toHaveBeenCalledWith(1234, 'SIGTERM')
|
||||
})
|
||||
|
||||
it('refuses to stop external ports', async () => {
|
||||
const store = makeStore()
|
||||
scanWorkspacePortsMock.mockResolvedValue({
|
||||
...EMPTY_SCAN,
|
||||
ports: [
|
||||
{
|
||||
id: '127.0.0.1:55182:2222',
|
||||
bindHost: '127.0.0.1',
|
||||
connectHost: '127.0.0.1',
|
||||
port: 55182,
|
||||
pid: 2222,
|
||||
processName: 'node',
|
||||
protocol: 'unknown',
|
||||
kind: 'external'
|
||||
}
|
||||
]
|
||||
})
|
||||
registerWorkspacePortHandlers(store as never)
|
||||
|
||||
const result = await handlers.get('workspacePorts:kill')?.(null, {
|
||||
repoId: 'local-repo',
|
||||
pid: 2222,
|
||||
port: 55182
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'Only workspace-owned local processes can be stopped here.'
|
||||
})
|
||||
expect(processKillMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses stale or forged pid requests that do not match the current scan', async () => {
|
||||
const store = makeStore()
|
||||
scanWorkspacePortsMock.mockResolvedValue({
|
||||
...EMPTY_SCAN,
|
||||
ports: [workspacePort({ pid: 1234, port: 5173 })]
|
||||
})
|
||||
registerWorkspacePortHandlers(store as never)
|
||||
|
||||
const result = await handlers.get('workspacePorts:kill')?.(null, {
|
||||
repoId: 'local-repo',
|
||||
pid: 9999,
|
||||
port: 5173
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'The port is no longer listening.' })
|
||||
expect(processKillMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses malformed renderer kill input without throwing', async () => {
|
||||
const store = makeStore()
|
||||
registerWorkspacePortHandlers(store as never)
|
||||
|
||||
const result = await handlers.get('workspacePorts:kill')?.(null, { pid: '1234', port: 5173 })
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'Invalid process or port.' })
|
||||
expect(scanWorkspacePortsMock).not.toHaveBeenCalled()
|
||||
expect(processKillMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function workspacePort({ pid, port }: { pid: number; port: number }): WorkspacePort {
|
||||
return {
|
||||
id: `127.0.0.1:${port}:${pid}`,
|
||||
bindHost: '127.0.0.1',
|
||||
connectHost: '127.0.0.1',
|
||||
port,
|
||||
pid,
|
||||
processName: 'node',
|
||||
protocol: 'unknown',
|
||||
kind: 'workspace',
|
||||
owner: {
|
||||
worktreeId: 'local-repo::/workspace/repo',
|
||||
repoId: 'local-repo',
|
||||
displayName: 'Primary',
|
||||
path: '/workspace/repo',
|
||||
confidence: 'cwd'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
WorkspacePortKillRequest,
|
||||
WorkspacePortKillResult,
|
||||
WorkspacePortScanRequest,
|
||||
WorkspacePortScanResult
|
||||
} from '../../shared/workspace-ports'
|
||||
import {
|
||||
getStoreWorkspacePortProbes,
|
||||
killWorkspacePort,
|
||||
scanWorkspacePortProbes
|
||||
} from '../ports/workspace-port-ownership'
|
||||
|
||||
export function registerWorkspacePortHandlers(store: Store): void {
|
||||
const inFlightScans = new Map<string, Promise<WorkspacePortScanResult>>()
|
||||
|
||||
ipcMain.handle(
|
||||
'workspacePorts:scan',
|
||||
(_event, rawArgs?: unknown): Promise<WorkspacePortScanResult> => {
|
||||
const args = parseScanRequest(rawArgs)
|
||||
const worktrees = getStoreWorkspacePortProbes(store, args?.repoId)
|
||||
const key = JSON.stringify(
|
||||
worktrees
|
||||
.map((worktree) => [worktree.id, worktree.repoId, worktree.displayName, worktree.path])
|
||||
.sort(([a], [b]) => String(a).localeCompare(String(b)))
|
||||
)
|
||||
const existing = inFlightScans.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const promise = scanWorkspacePortProbes(worktrees).finally(() => {
|
||||
if (inFlightScans.get(key) === promise) {
|
||||
inFlightScans.delete(key)
|
||||
}
|
||||
})
|
||||
inFlightScans.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspacePorts:kill',
|
||||
async (_event, rawArgs?: unknown): Promise<WorkspacePortKillResult> => {
|
||||
const args = parseKillRequest(rawArgs)
|
||||
if (!args) {
|
||||
return { ok: false, reason: 'Invalid process or port.' }
|
||||
}
|
||||
const worktrees = getStoreWorkspacePortProbes(store, args.repoId)
|
||||
return killWorkspacePort(worktrees, args)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function parseScanRequest(value: unknown): WorkspacePortScanRequest | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const repoId = (value as { repoId?: unknown }).repoId
|
||||
return typeof repoId === 'string' && repoId.length > 0 ? { repoId } : undefined
|
||||
}
|
||||
|
||||
function parseKillRequest(value: unknown): WorkspacePortKillRequest | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const args = value as { repoId?: unknown; pid?: unknown; port?: unknown }
|
||||
if (!Number.isSafeInteger(args.pid) || !Number.isSafeInteger(args.port)) {
|
||||
return null
|
||||
}
|
||||
const pid = args.pid as number
|
||||
const port = args.port as number
|
||||
return {
|
||||
...(typeof args.repoId === 'string' && args.repoId.length > 0 ? { repoId: args.repoId } : {}),
|
||||
pid,
|
||||
port
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
attributePortToWorkspace,
|
||||
isContainerProcess,
|
||||
parseLsofListeningOutput,
|
||||
parseNetstatListeningOutput,
|
||||
parseProcNetTcp
|
||||
} from './local-workspace-port-scanner'
|
||||
|
||||
const worktrees = [
|
||||
{
|
||||
id: 'repo::/repo',
|
||||
repoId: 'repo',
|
||||
displayName: 'main',
|
||||
path: '/repo'
|
||||
},
|
||||
{
|
||||
id: 'repo::/repo/worktrees/feature',
|
||||
repoId: 'repo',
|
||||
displayName: 'feature',
|
||||
path: '/repo/worktrees/feature'
|
||||
}
|
||||
]
|
||||
|
||||
describe('local workspace port scanner parsing', () => {
|
||||
it('parses lsof field output into listening ports', () => {
|
||||
const ports = parseLsofListeningOutput(
|
||||
['p123', 'cnode', 'n127.0.0.1:5173', 'p456', 'cnginx', 'n*:8080'].join('\n')
|
||||
)
|
||||
|
||||
expect(ports).toEqual([
|
||||
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 5173 },
|
||||
{ pid: 456, processName: 'nginx', host: '*', port: 8080 }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses multiple lsof listening ports for the same process', () => {
|
||||
const ports = parseLsofListeningOutput(
|
||||
['p123', 'cnode', 'n127.0.0.1:5173', 'n127.0.0.1:55173'].join('\n')
|
||||
)
|
||||
|
||||
expect(ports).toEqual([
|
||||
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 5173 },
|
||||
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 55173 }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses Windows netstat listening rows', () => {
|
||||
const ports = parseNetstatListeningOutput(
|
||||
[
|
||||
'Proto Local Address Foreign Address State PID',
|
||||
'TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 4242',
|
||||
'TCP [::]:5173 [::]:0 LISTENING 5151'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(ports).toEqual([
|
||||
{ host: '127.0.0.1', port: 3000, pid: 4242 },
|
||||
{ host: '::', port: 5173, pid: 5151 }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses Linux proc tcp listeners', () => {
|
||||
const ports = parseProcNetTcp(
|
||||
[
|
||||
' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode',
|
||||
' 0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('attributePortToWorkspace', () => {
|
||||
it('uses cwd ancestry and picks the deepest matching worktree', () => {
|
||||
const owner = attributePortToWorkspace(
|
||||
{ cwd: '/repo/worktrees/feature/packages/app', commandLine: 'node server.js' },
|
||||
worktrees
|
||||
)
|
||||
|
||||
expect(owner).toMatchObject({
|
||||
worktreeId: 'repo::/repo/worktrees/feature',
|
||||
displayName: 'feature',
|
||||
confidence: 'cwd'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to command-line path evidence', () => {
|
||||
const owner = attributePortToWorkspace(
|
||||
{ commandLine: 'node /repo/worktrees/feature/node_modules/vite/bin/vite.js' },
|
||||
worktrees
|
||||
)
|
||||
|
||||
expect(owner).toMatchObject({
|
||||
worktreeId: 'repo::/repo/worktrees/feature',
|
||||
confidence: 'command'
|
||||
})
|
||||
})
|
||||
|
||||
it('requires command-line path boundary evidence', () => {
|
||||
const owner = attributePortToWorkspace(
|
||||
{ commandLine: 'node /repo/worktrees/feature-other/server.js' },
|
||||
[worktrees[1]]
|
||||
)
|
||||
|
||||
expect(owner).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps path case significant on case-sensitive platforms', () => {
|
||||
const owner = attributePortToWorkspace({ cwd: '/Repo/worktrees/feature' }, worktrees)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
expect(owner).toMatchObject({ worktreeId: 'repo::/repo/worktrees/feature' })
|
||||
} else {
|
||||
expect(owner).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not guess when there is no worktree evidence', () => {
|
||||
const owner = attributePortToWorkspace({ cwd: '/Applications/ContainerRuntime.app' }, worktrees)
|
||||
|
||||
expect(owner).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('container process classification', () => {
|
||||
it('detects common container listener owners without workspace attribution', () => {
|
||||
expect(isContainerProcess({ processName: 'com.container.backend' })).toBe(true)
|
||||
expect(isContainerProcess({ processName: 'com.vendor.backend' })).toBe(true)
|
||||
expect(isContainerProcess({ commandLine: '/usr/bin/container-runtime port-forward' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(isContainerProcess({ processName: 'node', commandLine: 'node server.js' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
/* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing,
|
||||
attribution, and normalization rules that must stay in lockstep. */
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { readFile, readdir, readlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import type {
|
||||
WorkspacePort,
|
||||
WorkspacePortOwner,
|
||||
WorkspacePortProbe,
|
||||
WorkspacePortScanResult
|
||||
} from '../../shared/workspace-ports'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 4_000
|
||||
const MAX_PORTS = 200
|
||||
const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888])
|
||||
const HTTPS_PORTS = new Set([443, 8443])
|
||||
|
||||
type RawListeningPort = {
|
||||
host: string
|
||||
port: number
|
||||
pid?: number
|
||||
processName?: string
|
||||
commandLine?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type ProcessMetadata = {
|
||||
processName?: string
|
||||
commandLine?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export async function scanWorkspacePorts(
|
||||
worktrees: WorkspacePortProbe[]
|
||||
): Promise<WorkspacePortScanResult> {
|
||||
try {
|
||||
const rawPorts = await scanPlatformListeningPorts()
|
||||
const ports = rawPorts
|
||||
.map((port) => enrichPort(port, worktrees))
|
||||
.sort(compareWorkspacePorts)
|
||||
.slice(0, MAX_PORTS)
|
||||
return { platform: process.platform, scannedAt: Date.now(), ports }
|
||||
} catch (error) {
|
||||
console.warn('[workspace-ports] scan failed', error)
|
||||
return {
|
||||
platform: process.platform,
|
||||
scannedAt: Date.now(),
|
||||
ports: [],
|
||||
unavailableReason: `Port scanning is unavailable on ${process.platform}.`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function attributePortToWorkspace(
|
||||
port: Pick<RawListeningPort, 'cwd' | 'commandLine'>,
|
||||
worktrees: WorkspacePortProbe[]
|
||||
): WorkspacePortOwner | undefined {
|
||||
const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null
|
||||
const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null
|
||||
|
||||
const cwdMatches = cwd
|
||||
? worktrees
|
||||
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
|
||||
.filter(({ normalizedPath }) => isSameOrDescendant(cwd, normalizedPath))
|
||||
: []
|
||||
|
||||
const cwdMatch = pickDeepestMatch(cwdMatches)
|
||||
if (cwdMatch) {
|
||||
return toOwner(cwdMatch.worktree, 'cwd')
|
||||
}
|
||||
|
||||
if (!commandLine) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const commandMatches = worktrees
|
||||
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
|
||||
.filter(({ normalizedPath }) => includesPathBoundary(commandLine, normalizedPath))
|
||||
const commandMatch = pickDeepestMatch(commandMatches)
|
||||
return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined
|
||||
}
|
||||
|
||||
export function parseLsofListeningOutput(output: string): RawListeningPort[] {
|
||||
const ports: RawListeningPort[] = []
|
||||
let currentPid: number | undefined
|
||||
let currentProcessName: string | undefined
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const tag = line[0]
|
||||
const value = line.slice(1)
|
||||
if (tag === 'p') {
|
||||
const pid = Number.parseInt(value, 10)
|
||||
currentPid = Number.isFinite(pid) ? pid : undefined
|
||||
currentProcessName = undefined
|
||||
} else if (tag === 'c') {
|
||||
currentProcessName = value
|
||||
} else if (tag === 'n') {
|
||||
const parsed = parseAddressWithPort(value)
|
||||
if (parsed) {
|
||||
ports.push({ pid: currentPid, processName: currentProcessName, ...parsed })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeRawPorts(ports)
|
||||
}
|
||||
|
||||
export function parseNetstatListeningOutput(output: string): RawListeningPort[] {
|
||||
const ports: RawListeningPort[] = []
|
||||
for (const line of output.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.toUpperCase().startsWith('TCP')) {
|
||||
continue
|
||||
}
|
||||
const fields = trimmed.split(/\s+/)
|
||||
const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING')
|
||||
if (stateIndex < 2) {
|
||||
continue
|
||||
}
|
||||
const parsed = parseAddressWithPort(fields[1])
|
||||
const pid = Number.parseInt(fields[stateIndex + 1] ?? '', 10)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
ports.push({ ...parsed, pid: Number.isFinite(pid) ? pid : undefined })
|
||||
}
|
||||
return dedupeRawPorts(ports)
|
||||
}
|
||||
|
||||
export function parseProcNetTcp(content: string): { host: string; port: number; inode: number }[] {
|
||||
const results: { host: string; port: number; inode: number }[] = []
|
||||
const lines = content.split('\n')
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const fields = lines[i].trim().split(/\s+/)
|
||||
if (fields.length < 10 || fields[3] !== '0A') {
|
||||
continue
|
||||
}
|
||||
const parsed = parseProcAddress(fields[1])
|
||||
const inode = Number.parseInt(fields[9], 10)
|
||||
if (!parsed || !Number.isFinite(inode) || inode === 0) {
|
||||
continue
|
||||
}
|
||||
results.push({ ...parsed, inode })
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function scanPlatformListeningPorts(): Promise<RawListeningPort[]> {
|
||||
if (process.platform === 'linux') {
|
||||
return scanLinuxProcPorts()
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return scanDarwinLsofPorts()
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return scanWindowsNetstatPorts()
|
||||
}
|
||||
throw new Error(`Port scanning is not supported on ${process.platform}`)
|
||||
}
|
||||
|
||||
async function scanDarwinLsofPorts(): Promise<RawListeningPort[]> {
|
||||
const { stdout } = await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'])
|
||||
const ports = parseLsofListeningOutput(stdout)
|
||||
const metadata = await loadDarwinProcessMetadata(
|
||||
new Set(ports.flatMap((p) => (p.pid ? [p.pid] : [])))
|
||||
)
|
||||
return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port }))
|
||||
}
|
||||
|
||||
async function scanWindowsNetstatPorts(): Promise<RawListeningPort[]> {
|
||||
const { stdout } = await runCommand('netstat', ['-ano', '-p', 'tcp'])
|
||||
const ports = parseNetstatListeningOutput(stdout)
|
||||
const metadata = await loadWindowsProcessMetadata(
|
||||
new Set(ports.flatMap((p) => (p.pid ? [p.pid] : [])))
|
||||
)
|
||||
return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port }))
|
||||
}
|
||||
|
||||
async function scanLinuxProcPorts(): Promise<RawListeningPort[]> {
|
||||
const [tcp4, tcp6] = await Promise.all([
|
||||
readProcNet('/proc/net/tcp'),
|
||||
readProcNet('/proc/net/tcp6')
|
||||
])
|
||||
const sockets = [...tcp4, ...tcp6]
|
||||
const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode)))
|
||||
const metadata = new Map<number, ProcessMetadata>()
|
||||
const rawPorts: RawListeningPort[] = []
|
||||
|
||||
for (const socket of sockets) {
|
||||
const pid = inodeToPid.get(socket.inode)
|
||||
if (pid != null && !metadata.has(pid)) {
|
||||
metadata.set(pid, await loadLinuxProcessMetadata(pid))
|
||||
}
|
||||
rawPorts.push({
|
||||
host: socket.host,
|
||||
port: socket.port,
|
||||
pid,
|
||||
...metadata.get(pid ?? -1)
|
||||
})
|
||||
}
|
||||
|
||||
return dedupeRawPorts(rawPorts)
|
||||
}
|
||||
|
||||
async function readProcNet(
|
||||
filePath: string
|
||||
): Promise<{ host: string; port: number; inode: number }[]> {
|
||||
try {
|
||||
return parseProcNetTcp(await readFile(filePath, 'utf-8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function mapLinuxInodesToPids(inodes: Set<number>): Promise<Map<number, number>> {
|
||||
const result = new Map<number, number>()
|
||||
let pids: string[]
|
||||
try {
|
||||
pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry))
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
for (const pidText of pids) {
|
||||
let fds: string[]
|
||||
try {
|
||||
fds = await readdir(`/proc/${pidText}/fd`)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const pid = Number.parseInt(pidText, 10)
|
||||
for (const fd of fds) {
|
||||
let link: string
|
||||
try {
|
||||
link = await readlink(`/proc/${pidText}/fd/${fd}`)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const match = link.match(/^socket:\[(\d+)\]$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const inode = Number.parseInt(match[1], 10)
|
||||
if (inodes.has(inode)) {
|
||||
result.set(inode, pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadLinuxProcessMetadata(pid: number): Promise<ProcessMetadata> {
|
||||
const [comm, cmdline, cwd] = await Promise.all([
|
||||
readTextIfAvailable(`/proc/${pid}/comm`),
|
||||
readTextIfAvailable(`/proc/${pid}/cmdline`),
|
||||
readlink(`/proc/${pid}/cwd`).catch(() => undefined)
|
||||
])
|
||||
return {
|
||||
processName: comm?.trim() || undefined,
|
||||
commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined,
|
||||
cwd
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDarwinProcessMetadata(pids: Set<number>): Promise<Map<number, ProcessMetadata>> {
|
||||
const result = new Map<number, ProcessMetadata>()
|
||||
const pidList = Array.from(pids).join(',')
|
||||
if (!pidList) {
|
||||
return result
|
||||
}
|
||||
|
||||
const [cwdOutput, commandOutput] = await Promise.all([
|
||||
runCommand('lsof', ['-a', '-p', pidList, '-d', 'cwd', '-Fn']).catch(() => null),
|
||||
runCommand('ps', ['-p', pidList, '-o', 'pid=', '-o', 'command=']).catch(() => null)
|
||||
])
|
||||
|
||||
let currentPid: number | null = null
|
||||
for (const line of cwdOutput?.stdout.split('\n') ?? []) {
|
||||
if (line.startsWith('p')) {
|
||||
const pid = Number.parseInt(line.slice(1), 10)
|
||||
currentPid = Number.isFinite(pid) ? pid : null
|
||||
} else if (line.startsWith('n') && currentPid != null) {
|
||||
result.set(currentPid, { ...result.get(currentPid), cwd: line.slice(1) || undefined })
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of commandOutput?.stdout.split('\n') ?? []) {
|
||||
const match = line.match(/^\s*(\d+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const pid = Number.parseInt(match[1], 10)
|
||||
result.set(pid, { ...result.get(pid), commandLine: match[2].trim() || undefined })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadWindowsProcessMetadata(
|
||||
pids: Set<number>
|
||||
): Promise<Map<number, ProcessMetadata>> {
|
||||
const result = new Map<number, ProcessMetadata>()
|
||||
if (pids.size === 0) {
|
||||
return result
|
||||
}
|
||||
try {
|
||||
const pidFilter = Array.from(pids)
|
||||
.filter(Number.isFinite)
|
||||
.map((pid) => `ProcessId=${pid}`)
|
||||
.join(' OR ')
|
||||
const { stdout } = await runCommand('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Get-CimInstance Win32_Process -Filter "${pidFilter}" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`
|
||||
])
|
||||
const parsed = JSON.parse(stdout) as
|
||||
| { ProcessId: number; Name?: string; CommandLine?: string }
|
||||
| { ProcessId: number; Name?: string; CommandLine?: string }[]
|
||||
for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
|
||||
if (pids.has(row.ProcessId)) {
|
||||
result.set(row.ProcessId, {
|
||||
processName: row.Name,
|
||||
commandLine: row.CommandLine
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Process metadata is optional; port rows still render without attribution.
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function runCommand(command: string, args: string[]): Promise<{ stdout: string }> {
|
||||
const { stdout } = await execFileAsync(command, args, {
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
windowsHide: true
|
||||
})
|
||||
return { stdout: String(stdout) }
|
||||
}
|
||||
|
||||
async function readTextIfAvailable(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function enrichPort(port: RawListeningPort, worktrees: WorkspacePortProbe[]): WorkspacePort {
|
||||
const owner = attributePortToWorkspace(port, worktrees)
|
||||
const base = {
|
||||
id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`,
|
||||
bindHost: port.host,
|
||||
connectHost: connectHostForBindHost(port.host),
|
||||
port: port.port,
|
||||
pid: port.pid,
|
||||
processName: port.processName,
|
||||
protocol: inferProtocol(port.port)
|
||||
}
|
||||
|
||||
if (owner) {
|
||||
return { ...base, kind: 'workspace', owner }
|
||||
}
|
||||
if (isContainerProcess(port)) {
|
||||
return { ...base, kind: 'container' }
|
||||
}
|
||||
return { ...base, kind: 'external' }
|
||||
}
|
||||
|
||||
function compareWorkspacePorts(a: WorkspacePort, b: WorkspacePort): number {
|
||||
const aRank = a.kind === 'workspace' ? 0 : a.kind === 'container' ? 1 : 2
|
||||
const bRank = b.kind === 'workspace' ? 0 : b.kind === 'container' ? 1 : 2
|
||||
return aRank - bRank || a.port - b.port || a.connectHost.localeCompare(b.connectHost)
|
||||
}
|
||||
|
||||
function inferProtocol(port: number): 'http' | 'https' | 'unknown' {
|
||||
if (HTTPS_PORTS.has(port)) {
|
||||
return 'https'
|
||||
}
|
||||
if (HTTP_PORTS.has(port)) {
|
||||
return 'http'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function isContainerProcess(
|
||||
port: Pick<RawListeningPort, 'processName' | 'commandLine'>
|
||||
): boolean {
|
||||
const haystack = `${port.processName ?? ''} ${port.commandLine ?? ''}`.toLowerCase()
|
||||
return /\b(com\.[\w.-]+\.backend|com\.container\w*|container\w*)\b/.test(haystack)
|
||||
}
|
||||
|
||||
function toOwner(
|
||||
worktree: WorkspacePortProbe,
|
||||
confidence: WorkspacePortOwner['confidence']
|
||||
): WorkspacePortOwner {
|
||||
return {
|
||||
worktreeId: worktree.id,
|
||||
repoId: worktree.repoId,
|
||||
displayName: worktree.displayName,
|
||||
path: worktree.path,
|
||||
confidence
|
||||
}
|
||||
}
|
||||
|
||||
function pickDeepestMatch<T extends { normalizedPath: string }>(matches: T[]): T | undefined {
|
||||
return matches.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)[0]
|
||||
}
|
||||
|
||||
function isSameOrDescendant(candidate: string, parent: string): boolean {
|
||||
return candidate === parent || candidate.startsWith(`${parent}/`)
|
||||
}
|
||||
|
||||
function includesPathBoundary(commandLine: string, normalizedPath: string): boolean {
|
||||
let index = commandLine.indexOf(normalizedPath)
|
||||
while (index !== -1) {
|
||||
const before = index === 0 ? '' : commandLine[index - 1]
|
||||
const after = commandLine[index + normalizedPath.length] ?? ''
|
||||
const startsOnBoundary = before === '' || /\s|["'=]/.test(before)
|
||||
const endsOnBoundary = after === '' || /[\s"'/:]/.test(after)
|
||||
if (startsOnBoundary && endsOnBoundary) {
|
||||
return true
|
||||
}
|
||||
index = commandLine.indexOf(normalizedPath, index + normalizedPath.length)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeComparablePath(input: string): string {
|
||||
return normalizeComparableText(path.resolve(input))
|
||||
}
|
||||
|
||||
function normalizeComparableText(input: string): string {
|
||||
const normalized = input.replace(/\\/g, '/').replace(/\/+/g, '/')
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
function connectHostForBindHost(host: string): string {
|
||||
if (host === '*' || host === '0.0.0.0' || host === '::') {
|
||||
return 'localhost'
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
function dedupeRawPorts(ports: RawListeningPort[]): RawListeningPort[] {
|
||||
const seen = new Set<string>()
|
||||
const result: RawListeningPort[] = []
|
||||
for (const port of ports) {
|
||||
const key = `${connectHostForBindHost(port.host)}:${port.port}:${port.pid ?? 'unknown'}`
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
result.push(port)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function parseAddressWithPort(value: string): { host: string; port: number } | null {
|
||||
const trimmed = value.trim().replace(/\s+\(LISTEN\)$/i, '')
|
||||
const bracketed = trimmed.match(/^\[([^\]]+)\]:(\d+)$/)
|
||||
if (bracketed) {
|
||||
return { host: bracketed[1], port: Number.parseInt(bracketed[2], 10) }
|
||||
}
|
||||
const match = trimmed.match(/^(.+):(\d+)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const port = Number.parseInt(match[2], 10)
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
return null
|
||||
}
|
||||
return { host: match[1], port }
|
||||
}
|
||||
|
||||
function parseProcAddress(hexAddress: string): { host: string; port: number } | null {
|
||||
const [addrHex, portHex] = hexAddress.split(':')
|
||||
const port = Number.parseInt(portHex, 16)
|
||||
if (!Number.isFinite(port) || port === 0) {
|
||||
return null
|
||||
}
|
||||
if (addrHex.length === 8) {
|
||||
const bytes = [6, 4, 2, 0].map((index) => Number.parseInt(addrHex.slice(index, index + 2), 16))
|
||||
return { host: bytes.join('.'), port }
|
||||
}
|
||||
if (addrHex.length === 32) {
|
||||
if (addrHex === '00000000000000000000000000000000') {
|
||||
return { host: '::', port }
|
||||
}
|
||||
if (addrHex === '00000000000000000000000001000000') {
|
||||
return { host: '::1', port }
|
||||
}
|
||||
return { host: formatIPv6Address(addrHex), port }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatIPv6Address(hex: string): string {
|
||||
const groups: string[] = []
|
||||
for (let i = 0; i < 32; i += 8) {
|
||||
const chunk = hex.slice(i, i + 8)
|
||||
const reversed = chunk.slice(6, 8) + chunk.slice(4, 6) + chunk.slice(2, 4) + chunk.slice(0, 2)
|
||||
groups.push(reversed.slice(0, 4), reversed.slice(4, 8))
|
||||
}
|
||||
return groups.map((group) => group.replace(/^0+/, '') || '0').join(':')
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import path from 'path'
|
||||
import type { Store } from '../persistence'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import type {
|
||||
WorkspacePortKillRequest,
|
||||
WorkspacePortKillResult,
|
||||
WorkspacePortProbe,
|
||||
WorkspacePortScanResult
|
||||
} from '../../shared/workspace-ports'
|
||||
import { scanWorkspacePorts } from './local-workspace-port-scanner'
|
||||
|
||||
export type WorkspacePortProbeInput = WorkspacePortProbe & {
|
||||
connectionId?: string | null
|
||||
}
|
||||
|
||||
export function getStoreWorkspacePortProbes(
|
||||
store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>,
|
||||
repoId?: string
|
||||
): WorkspacePortProbe[] {
|
||||
const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo]))
|
||||
return Object.entries(store.getAllWorktreeMeta()).flatMap(([worktreeId, meta]) => {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!parsed || (repoId && parsed.repoId !== repoId)) {
|
||||
return []
|
||||
}
|
||||
const repo = reposById.get(parsed.repoId)
|
||||
if (!repo || repo.connectionId) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: worktreeId,
|
||||
repoId: parsed.repoId,
|
||||
displayName: meta.displayName || path.basename(parsed.worktreePath),
|
||||
path: parsed.worktreePath
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function filterWorkspacePortProbes(
|
||||
worktrees: readonly WorkspacePortProbeInput[],
|
||||
repoId?: string
|
||||
): WorkspacePortProbe[] {
|
||||
return worktrees.flatMap((worktree) => {
|
||||
if ((repoId && worktree.repoId !== repoId) || worktree.connectionId) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: worktree.id,
|
||||
repoId: worktree.repoId,
|
||||
displayName: worktree.displayName || path.basename(worktree.path),
|
||||
path: worktree.path
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export async function killWorkspacePort(
|
||||
worktrees: readonly WorkspacePortProbe[],
|
||||
args: WorkspacePortKillRequest
|
||||
): Promise<WorkspacePortKillResult> {
|
||||
if (!Number.isSafeInteger(args.pid) || args.pid <= 0 || !Number.isSafeInteger(args.port)) {
|
||||
return { ok: false, reason: 'Invalid process or port.' }
|
||||
}
|
||||
|
||||
const scan = await scanWorkspacePorts([...worktrees])
|
||||
const port = scan.ports.find(
|
||||
(candidate) => candidate.pid === args.pid && candidate.port === args.port
|
||||
)
|
||||
|
||||
if (!port) {
|
||||
return { ok: false, reason: 'The port is no longer listening.' }
|
||||
}
|
||||
if (port.kind !== 'workspace') {
|
||||
return { ok: false, reason: 'Only workspace-owned local processes can be stopped here.' }
|
||||
}
|
||||
const pid = port.pid
|
||||
if (!pid) {
|
||||
return { ok: false, reason: 'The owning process is unknown.' }
|
||||
}
|
||||
if (pid === process.pid) {
|
||||
return { ok: false, reason: 'Orca cannot stop its own process.' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: caller-supplied pids are not trusted; the re-scan above proves
|
||||
// this pid still owns the requested workspace listener before SIGTERM.
|
||||
process.kill(pid, 'SIGTERM')
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { ok: false, reason: message || 'Failed to stop the process.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanWorkspacePortProbes(
|
||||
worktrees: readonly WorkspacePortProbe[]
|
||||
): Promise<WorkspacePortScanResult> {
|
||||
return scanWorkspacePorts([...worktrees])
|
||||
}
|
||||
|
|
@ -448,6 +448,7 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(status.runtimeProtocolVersion).toBe(status.protocolVersion)
|
||||
expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion)
|
||||
expect(status.capabilities).toContain('terminal.binary-stream.v1')
|
||||
expect(status.capabilities).toContain('workspace-ports.v1')
|
||||
expect(typeof status.protocolVersion).toBe('number')
|
||||
expect(typeof status.minCompatibleMobileVersion).toBe('number')
|
||||
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,17 @@ import {
|
|||
RUNTIME_CAPABILITIES,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import type {
|
||||
WorkspacePortKillRequest,
|
||||
WorkspacePortKillResult,
|
||||
WorkspacePortProbe,
|
||||
WorkspacePortScanResult
|
||||
} from '../../shared/workspace-ports'
|
||||
import {
|
||||
filterWorkspacePortProbes,
|
||||
killWorkspacePort,
|
||||
scanWorkspacePortProbes
|
||||
} from '../ports/workspace-port-ownership'
|
||||
import type {
|
||||
RuntimeGraphStatus,
|
||||
RuntimeRepoSearchRefs,
|
||||
|
|
@ -5653,6 +5664,34 @@ export class OrcaRuntimeService {
|
|||
return await this.resolveWorktreeSelector(worktreeSelector)
|
||||
}
|
||||
|
||||
async scanWorkspacePorts(repoId?: string): Promise<WorkspacePortScanResult> {
|
||||
return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId))
|
||||
}
|
||||
|
||||
async killWorkspacePort(args: WorkspacePortKillRequest): Promise<WorkspacePortKillResult> {
|
||||
return killWorkspacePort(await this.getWorkspacePortProbes(args.repoId), args)
|
||||
}
|
||||
|
||||
// Why: remote clients may invoke this over RPC, so the runtime derives
|
||||
// allowed worktree paths from its own store instead of trusting client paths.
|
||||
private async getWorkspacePortProbes(repoId?: string): Promise<WorkspacePortProbe[]> {
|
||||
const reposById = new Map(
|
||||
this.requireStore()
|
||||
.getRepos()
|
||||
.map((repo) => [repo.id, repo])
|
||||
)
|
||||
return filterWorkspacePortProbes(
|
||||
(await this.listResolvedWorktrees()).map((worktree) => ({
|
||||
id: worktree.id,
|
||||
repoId: worktree.repoId,
|
||||
displayName: worktree.displayName,
|
||||
path: worktree.git.path,
|
||||
connectionId: reposById.get(worktree.repoId)?.connectionId ?? null
|
||||
})),
|
||||
repoId
|
||||
)
|
||||
}
|
||||
|
||||
async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> {
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
// Why: sleep is renderer-initiated on desktop (it tears down tab state
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { HOSTED_REVIEW_METHODS } from './hosted-review'
|
|||
import { LINEAR_METHODS } from './linear'
|
||||
import { SPEECH_METHODS } from './speech'
|
||||
import { CLIENT_UI_METHODS } from './client-ui'
|
||||
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
// grep-point for "what methods does the RPC server expose?" — useful when
|
||||
|
|
@ -47,5 +48,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
|||
...HOSTED_REVIEW_METHODS,
|
||||
...LINEAR_METHODS,
|
||||
...SPEECH_METHODS,
|
||||
...WORKSPACE_PORT_METHODS,
|
||||
...CLIENT_UI_METHODS
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
|
||||
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('workspace port RPC methods', () => {
|
||||
it('scans workspace ports on the runtime host', async () => {
|
||||
const scan: WorkspacePortScanResult = {
|
||||
platform: process.platform,
|
||||
scannedAt: 123,
|
||||
ports: []
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
scanWorkspacePorts: vi.fn().mockResolvedValue(scan)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('workspacePorts.scan', { repoId: 'repo-1' })
|
||||
)
|
||||
|
||||
expect(runtime.scanWorkspacePorts).toHaveBeenCalledWith('repo-1')
|
||||
expect(response).toMatchObject({ ok: true, result: scan })
|
||||
})
|
||||
|
||||
it('kills a workspace-owned port on the runtime host', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
killWorkspacePort: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('workspacePorts.kill', {
|
||||
repoId: 'repo-1',
|
||||
pid: 1234,
|
||||
port: 5173
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.killWorkspacePort).toHaveBeenCalledWith({
|
||||
repoId: 'repo-1',
|
||||
pid: 1234,
|
||||
port: 5173
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredNumber } from '../schemas'
|
||||
|
||||
const WorkspacePortScanParams = z.object({
|
||||
repoId: OptionalString
|
||||
})
|
||||
|
||||
const WorkspacePortKillParams = z.object({
|
||||
repoId: OptionalString,
|
||||
pid: requiredNumber('Missing process id'),
|
||||
port: requiredNumber('Missing port')
|
||||
})
|
||||
|
||||
export const WORKSPACE_PORT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'workspacePorts.scan',
|
||||
params: WorkspacePortScanParams,
|
||||
handler: async (params, { runtime }) => runtime.scanWorkspacePorts(params.repoId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'workspacePorts.kill',
|
||||
params: WorkspacePortKillParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.killWorkspacePort({
|
||||
repoId: params.repoId,
|
||||
pid: params.pid,
|
||||
port: params.port
|
||||
})
|
||||
})
|
||||
]
|
||||
|
|
@ -221,6 +221,12 @@ import type {
|
|||
WorkspaceSpaceAnalyzeResult,
|
||||
WorkspaceSpaceScanProgress
|
||||
} from '../shared/workspace-space-types'
|
||||
import type {
|
||||
WorkspacePortKillRequest,
|
||||
WorkspacePortKillResult,
|
||||
WorkspacePortScanRequest,
|
||||
WorkspacePortScanResult
|
||||
} from '../shared/workspace-ports'
|
||||
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
|
||||
import type {
|
||||
SshConnectionState,
|
||||
|
|
@ -658,6 +664,10 @@ export type PreloadApi = {
|
|||
cancel: () => Promise<boolean>
|
||||
onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void
|
||||
}
|
||||
workspacePorts: {
|
||||
scan: (args: WorkspacePortScanRequest) => Promise<WorkspacePortScanResult>
|
||||
kill: (args: WorkspacePortKillRequest) => Promise<WorkspacePortKillResult>
|
||||
}
|
||||
pty: {
|
||||
spawn: (opts: {
|
||||
cols: number
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@ import type {
|
|||
WorkspaceSpaceAnalyzeResult,
|
||||
WorkspaceSpaceScanProgress
|
||||
} from '../shared/workspace-space-types'
|
||||
import type {
|
||||
WorkspacePortKillRequest,
|
||||
WorkspacePortKillResult,
|
||||
WorkspacePortScanRequest,
|
||||
WorkspacePortScanResult
|
||||
} from '../shared/workspace-ports'
|
||||
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
|
||||
import type {
|
||||
AddIssueCommentBySlugArgs,
|
||||
|
|
@ -553,6 +559,13 @@ const api = {
|
|||
}
|
||||
},
|
||||
|
||||
workspacePorts: {
|
||||
scan: (args: WorkspacePortScanRequest): Promise<WorkspacePortScanResult> =>
|
||||
ipcRenderer.invoke('workspacePorts:scan', args),
|
||||
kill: (args: WorkspacePortKillRequest): Promise<WorkspacePortKillResult> =>
|
||||
ipcRenderer.invoke('workspacePorts:kill', args)
|
||||
},
|
||||
|
||||
pty: {
|
||||
spawn: (opts: {
|
||||
cols: number
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { WorkspacePort, WorkspacePortScanResult } from '../../../../shared/workspace-ports'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
const { activateAndRevealWorktreeMock } = vi.hoisted(() => ({
|
||||
activateAndRevealWorktreeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: activateAndRevealWorktreeMock
|
||||
}))
|
||||
|
||||
import {
|
||||
browserUrlForPort,
|
||||
killWorkspacePortForTarget,
|
||||
openWorkspacePortInBrowser,
|
||||
scanWorkspacePortsForTarget
|
||||
} from './PortsPanel'
|
||||
|
||||
const workspacePort: WorkspacePort = {
|
||||
id: '127.0.0.1:63468:1234',
|
||||
bindHost: '127.0.0.1',
|
||||
connectHost: '127.0.0.1',
|
||||
port: 63468,
|
||||
pid: 1234,
|
||||
processName: 'node',
|
||||
protocol: 'unknown',
|
||||
kind: 'workspace',
|
||||
owner: {
|
||||
worktreeId: 'repo::/workspace/app',
|
||||
repoId: 'repo',
|
||||
displayName: 'app',
|
||||
path: '/workspace/app',
|
||||
confidence: 'cwd'
|
||||
}
|
||||
}
|
||||
|
||||
const emptyScan: WorkspacePortScanResult = {
|
||||
platform: process.platform,
|
||||
scannedAt: 1,
|
||||
ports: []
|
||||
}
|
||||
|
||||
const compatibleStatus = {
|
||||
runtimeId: 'runtime-1',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
}
|
||||
|
||||
const localScan = vi.fn()
|
||||
const localKill = vi.fn()
|
||||
const runtimeCall = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
localScan.mockReset()
|
||||
localKill.mockReset()
|
||||
runtimeCall.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
activateAndRevealWorktreeMock.mockReset()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
workspacePorts: {
|
||||
scan: localScan,
|
||||
kill: localKill
|
||||
},
|
||||
runtime: {
|
||||
call: runtimeCall
|
||||
},
|
||||
runtimeEnvironments: {
|
||||
call: runtimeEnvironmentCall
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PortsPanel runtime routing', () => {
|
||||
it('uses local IPC for local workspace port scans and kills', async () => {
|
||||
localScan.mockResolvedValueOnce(emptyScan)
|
||||
localKill.mockResolvedValueOnce({ ok: true })
|
||||
|
||||
await expect(scanWorkspacePortsForTarget({ kind: 'local' }, 'repo')).resolves.toBe(emptyScan)
|
||||
await expect(
|
||||
killWorkspacePortForTarget({ kind: 'local' }, { repoId: 'repo', pid: 1234, port: 63468 })
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(localScan).toHaveBeenCalledWith({ repoId: 'repo' })
|
||||
expect(localKill).toHaveBeenCalledWith({ repoId: 'repo', pid: 1234, port: 63468 })
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes remote scans through runtime RPC and degrades on older runtimes', async () => {
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) =>
|
||||
Promise.resolve(
|
||||
method === 'status.get'
|
||||
? { id: method, ok: true, result: compatibleStatus, _meta: { runtimeId: 'runtime-1' } }
|
||||
: {
|
||||
id: method,
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await scanWorkspacePortsForTarget(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'repo'
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ports: [],
|
||||
unavailableReason: 'The connected runtime does not support workspace port management yet.'
|
||||
})
|
||||
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
|
||||
'status.get',
|
||||
'workspacePorts.scan'
|
||||
])
|
||||
})
|
||||
|
||||
it('opens remote workspace ports in the server-side browser and binds the local page handle', async () => {
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) =>
|
||||
Promise.resolve({
|
||||
id: method,
|
||||
ok: true,
|
||||
result:
|
||||
method === 'status.get' ? compatibleStatus : { browserPageId: 'remote-browser-page-1' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
const createBrowserTab = vi.fn(() => ({ activePageId: 'local-page-1' }))
|
||||
const setRemoteBrowserPageHandle = vi.fn()
|
||||
|
||||
await expect(
|
||||
openWorkspacePortInBrowser({
|
||||
port: workspacePort,
|
||||
runtimeTarget: { kind: 'environment', environmentId: 'env-1' },
|
||||
createBrowserTab: createBrowserTab as never,
|
||||
setRemoteBrowserPageHandle: setRemoteBrowserPageHandle as never
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(activateAndRevealWorktreeMock).toHaveBeenCalledWith('repo::/workspace/app')
|
||||
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
|
||||
'status.get',
|
||||
'browser.tabCreate'
|
||||
])
|
||||
expect(runtimeEnvironmentCall.mock.calls[1][0].params).toEqual({
|
||||
worktree: 'id:repo::/workspace/app',
|
||||
url: 'http://127.0.0.1:63468'
|
||||
})
|
||||
expect(createBrowserTab).toHaveBeenCalledWith(
|
||||
'repo::/workspace/app',
|
||||
'http://127.0.0.1:63468',
|
||||
{
|
||||
activate: true
|
||||
}
|
||||
)
|
||||
expect(setRemoteBrowserPageHandle).toHaveBeenCalledWith('local-page-1', {
|
||||
environmentId: 'env-1',
|
||||
remotePageId: 'remote-browser-page-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults unknown protocols to http for built-in browser opens', () => {
|
||||
expect(browserUrlForPort(workspacePort)).toBe('http://127.0.0.1:63468')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,30 @@
|
|||
/* oxlint-disable max-lines -- Why: co-locates forwarded list, detected list, modal form, and
|
||||
per-entry actions in one file to keep the data flow straightforward. */
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { ExternalLink, Copy, Trash2, Plus, Unplug, ChevronRight, Pencil } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Trash2,
|
||||
Plus,
|
||||
Unplug,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Box,
|
||||
Info
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useActiveWorktree, useRepoById } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
getActiveRuntimeTarget,
|
||||
RuntimeRpcCallError,
|
||||
type RuntimeClientTarget
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -13,7 +33,28 @@ import {
|
|||
DialogDescription
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import type { PortForwardEntry, DetectedPort } from '../../../../shared/ssh-types'
|
||||
import type {
|
||||
WorkspacePort,
|
||||
WorkspacePortKillResult,
|
||||
WorkspacePortScanResult
|
||||
} from '../../../../shared/workspace-ports'
|
||||
|
||||
const LOCAL_PORT_SCAN_INTERVAL_MS = 5_000
|
||||
const LOCAL_PORT_MENU_CONTENT_CLASS =
|
||||
'!rounded-md !border-border/60 !bg-popover !text-popover-foreground !shadow-[0_10px_24px_rgba(0,0,0,0.18)] !backdrop-blur-none'
|
||||
const LOCAL_PORT_MENU_ITEM_CLASS =
|
||||
'rounded-md focus:bg-accent focus:text-accent-foreground dark:focus:bg-accent'
|
||||
const LOCAL_PORT_MENU_LABEL_CLASS = 'px-2 py-1 text-[11px] font-semibold text-muted-foreground'
|
||||
|
||||
// Why: ports < 1024 require root to bind on the local machine. Remap them
|
||||
// to a high port so the default "Forward" action doesn't fail with EACCES.
|
||||
|
|
@ -24,7 +65,6 @@ function safeLocalPort(remotePort: number): number {
|
|||
return remotePort
|
||||
}
|
||||
|
||||
const HTTP_PORTS = new Set([80, 443, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8443, 8888])
|
||||
const HTTPS_PORTS = new Set([443, 8443])
|
||||
|
||||
// Why: the scanner reports numeric addresses (127.0.0.1, 0.0.0.0, ::1, ::)
|
||||
|
|
@ -38,6 +78,118 @@ function normalizeHost(host: string | undefined): string {
|
|||
return host
|
||||
}
|
||||
|
||||
function hostForLocalAction(host: string): string {
|
||||
if (!host) {
|
||||
return 'localhost'
|
||||
}
|
||||
return host.includes(':') ? `[${host}]` : host
|
||||
}
|
||||
|
||||
function addressForPort(port: WorkspacePort): string {
|
||||
return `${hostForLocalAction(port.connectHost)}:${port.port}`
|
||||
}
|
||||
|
||||
export function browserUrlForPort(port: WorkspacePort): string {
|
||||
const protocol = port.protocol === 'https' ? 'https' : 'http'
|
||||
return `${protocol}://${addressForPort(port)}`
|
||||
}
|
||||
|
||||
type BrowserTabCreator = ReturnType<typeof useAppStore.getState>['createBrowserTab']
|
||||
type RemoteBrowserPageHandleSetter = ReturnType<
|
||||
typeof useAppStore.getState
|
||||
>['setRemoteBrowserPageHandle']
|
||||
|
||||
export async function openWorkspacePortInBrowser(args: {
|
||||
port: WorkspacePort
|
||||
activeWorktreeId?: string | null
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
createBrowserTab: BrowserTabCreator
|
||||
setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter
|
||||
}): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const worktreeId =
|
||||
args.port.kind === 'workspace' ? args.port.owner.worktreeId : args.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
return { ok: false, reason: 'No workspace selected for the browser.' }
|
||||
}
|
||||
const url = browserUrlForPort(args.port)
|
||||
activateAndRevealWorktree(worktreeId)
|
||||
if (args.runtimeTarget.kind === 'environment') {
|
||||
try {
|
||||
const remotePage = await callRuntimeRpc<{ browserPageId: string }>(
|
||||
args.runtimeTarget,
|
||||
'browser.tabCreate',
|
||||
{ worktree: `id:${worktreeId}`, url },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
const tab = args.createBrowserTab(worktreeId, url, { activate: true })
|
||||
if (!tab.activePageId) {
|
||||
return { ok: false, reason: 'Failed to create a browser page.' }
|
||||
}
|
||||
args.setRemoteBrowserPageHandle(tab.activePageId, {
|
||||
environmentId: args.runtimeTarget.environmentId,
|
||||
remotePageId: remotePage.browserPageId
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { ok: false, reason: message || 'Failed to open remote browser.' }
|
||||
}
|
||||
}
|
||||
args.createBrowserTab(worktreeId, url, { activate: true })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function runtimeTargetKey(target: RuntimeClientTarget): string {
|
||||
return target.kind === 'local' ? 'local' : `environment:${target.environmentId}`
|
||||
}
|
||||
|
||||
export async function scanWorkspacePortsForTarget(
|
||||
target: RuntimeClientTarget,
|
||||
repoId: string
|
||||
): Promise<WorkspacePortScanResult> {
|
||||
const params = { repoId }
|
||||
if (target.kind === 'local') {
|
||||
return window.api.workspacePorts.scan(params)
|
||||
}
|
||||
try {
|
||||
return await callRuntimeRpc<WorkspacePortScanResult>(target, 'workspacePorts.scan', params, {
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
|
||||
return {
|
||||
platform: 'unknown',
|
||||
scannedAt: Date.now(),
|
||||
ports: [],
|
||||
unavailableReason: 'The connected runtime does not support workspace port management yet.'
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function killWorkspacePortForTarget(
|
||||
target: RuntimeClientTarget,
|
||||
args: { repoId: string; pid: number; port: number }
|
||||
): Promise<WorkspacePortKillResult> {
|
||||
if (target.kind === 'local') {
|
||||
return window.api.workspacePorts.kill(args)
|
||||
}
|
||||
try {
|
||||
return await callRuntimeRpc<WorkspacePortKillResult>(target, 'workspacePorts.kill', args, {
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'The connected runtime does not support workspace port management yet.'
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
type PortForwardDialogState =
|
||||
| { mode: 'closed' }
|
||||
| {
|
||||
|
|
@ -46,10 +198,559 @@ type PortForwardDialogState =
|
|||
}
|
||||
| { mode: 'edit'; entry: PortForwardEntry }
|
||||
|
||||
export default function PortsPanel(): React.JSX.Element {
|
||||
export default function PortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
|
||||
if (activeRepo?.connectionId) {
|
||||
return <SshPortsPanel />
|
||||
}
|
||||
|
||||
return <LocalWorkspacePortsPanel isVisible={isVisible} />
|
||||
}
|
||||
|
||||
function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
|
||||
const [scan, setScan] = useState<{ key: string; result: WorkspacePortScanResult } | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [detailsPort, setDetailsPort] = useState<WorkspacePort | null>(null)
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({
|
||||
other: true,
|
||||
external: true
|
||||
})
|
||||
const inFlightScanRef = useRef<Promise<void> | null>(null)
|
||||
const inFlightScanKeyRef = useRef<string | null>(null)
|
||||
const scanGenerationRef = useRef(0)
|
||||
|
||||
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
|
||||
const scanKey = `${runtimeTargetKey(runtimeTarget)}:${activeRepo?.id ?? ''}`
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!activeRepo) {
|
||||
setScan(null)
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (inFlightScanRef.current && inFlightScanKeyRef.current === scanKey) {
|
||||
return inFlightScanRef.current
|
||||
}
|
||||
const generation = scanGenerationRef.current
|
||||
setRefreshing(true)
|
||||
const promise = scanWorkspacePortsForTarget(runtimeTarget, activeRepo.id)
|
||||
.then((nextScan) => {
|
||||
if (generation === scanGenerationRef.current) {
|
||||
setScan({ key: scanKey, result: nextScan })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightScanRef.current === promise) {
|
||||
inFlightScanRef.current = null
|
||||
inFlightScanKeyRef.current = null
|
||||
}
|
||||
if (generation === scanGenerationRef.current) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
})
|
||||
inFlightScanRef.current = promise
|
||||
inFlightScanKeyRef.current = scanKey
|
||||
return promise
|
||||
}, [activeRepo, runtimeTarget, scanKey])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
scanGenerationRef.current += 1
|
||||
|
||||
if (!isVisible) {
|
||||
inFlightScanRef.current = null
|
||||
inFlightScanKeyRef.current = null
|
||||
setScan(null)
|
||||
setRefreshing(false)
|
||||
return () => {
|
||||
cancelled = true
|
||||
scanGenerationRef.current += 1
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
await refresh()
|
||||
if (!cancelled) {
|
||||
timeout = setTimeout(() => void run(), LOCAL_PORT_SCAN_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
setScan(null)
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
scanGenerationRef.current += 1
|
||||
inFlightScanRef.current = null
|
||||
inFlightScanKeyRef.current = null
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [isVisible, refresh])
|
||||
|
||||
const displayScan = scan?.key === scanKey && isVisible ? scan.result : null
|
||||
|
||||
const toggleSection = useCallback((sectionId: string) => {
|
||||
setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] }))
|
||||
}, [])
|
||||
|
||||
const handleStopPort = useCallback(
|
||||
async (port: WorkspacePort) => {
|
||||
if (!activeRepo || !port.pid) {
|
||||
return
|
||||
}
|
||||
const result = await killWorkspacePortForTarget(runtimeTarget, {
|
||||
repoId: activeRepo.id,
|
||||
pid: port.pid,
|
||||
port: port.port
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(result.reason)
|
||||
return
|
||||
}
|
||||
toast.success(`Stopped process on :${port.port}`)
|
||||
await refresh()
|
||||
},
|
||||
[activeRepo, refresh, runtimeTarget]
|
||||
)
|
||||
|
||||
const handleOpenPortInBrowser = useCallback(
|
||||
async (port: WorkspacePort) => {
|
||||
const result = await openWorkspacePortInBrowser({
|
||||
port,
|
||||
activeWorktreeId: activeWorktree?.id,
|
||||
runtimeTarget,
|
||||
createBrowserTab,
|
||||
setRemoteBrowserPageHandle
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to open browser', { description: result.reason })
|
||||
}
|
||||
},
|
||||
[activeWorktree?.id, createBrowserTab, runtimeTarget, setRemoteBrowserPageHandle]
|
||||
)
|
||||
|
||||
const activePorts = useMemo(
|
||||
() =>
|
||||
(displayScan?.ports ?? []).filter(
|
||||
(port) => port.kind === 'workspace' && port.owner.worktreeId === activeWorktree?.id
|
||||
),
|
||||
[activeWorktree?.id, displayScan?.ports]
|
||||
)
|
||||
const otherWorkspacePorts = useMemo(
|
||||
() =>
|
||||
(displayScan?.ports ?? []).filter(
|
||||
(port) => port.kind === 'workspace' && port.owner.worktreeId !== activeWorktree?.id
|
||||
),
|
||||
[activeWorktree?.id, displayScan?.ports]
|
||||
)
|
||||
const externalPorts = useMemo(
|
||||
() => (displayScan?.ports ?? []).filter((port) => port.kind !== 'workspace'),
|
||||
[displayScan?.ports]
|
||||
)
|
||||
|
||||
if (!activeRepo) {
|
||||
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" />
|
||||
<p className="text-sm">No workspace selected</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto scrollbar-sleek">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Ports
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void refresh()}
|
||||
disabled={refreshing}
|
||||
aria-label="Refresh Ports"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Refresh Ports
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{displayScan?.unavailableReason && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground border-b border-border">
|
||||
Port scan unavailable on {displayScan.platform}: {displayScan.unavailableReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!displayScan?.unavailableReason && (
|
||||
<>
|
||||
<LocalPortSection
|
||||
id="active"
|
||||
title="Active Workspace"
|
||||
ports={activePorts}
|
||||
emptyText={refreshing && !displayScan ? 'Scanning...' : 'No ports detected'}
|
||||
collapsed={collapsedSections.active ?? false}
|
||||
onToggle={() => toggleSection('active')}
|
||||
onStopPort={(port) => void handleStopPort(port)}
|
||||
onShowDetails={setDetailsPort}
|
||||
onOpenInBrowser={handleOpenPortInBrowser}
|
||||
/>
|
||||
<LocalPortSection
|
||||
id="other"
|
||||
title="Other Workspaces"
|
||||
ports={otherWorkspacePorts}
|
||||
collapsed={collapsedSections.other ?? false}
|
||||
onToggle={() => toggleSection('other')}
|
||||
onStopPort={(port) => void handleStopPort(port)}
|
||||
onShowDetails={setDetailsPort}
|
||||
onOpenInBrowser={handleOpenPortInBrowser}
|
||||
/>
|
||||
<LocalPortSection
|
||||
id="external"
|
||||
title="External"
|
||||
ports={externalPorts}
|
||||
collapsed={collapsedSections.external ?? false}
|
||||
onToggle={() => toggleSection('external')}
|
||||
onStopPort={(port) => void handleStopPort(port)}
|
||||
onShowDetails={setDetailsPort}
|
||||
onOpenInBrowser={handleOpenPortInBrowser}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!displayScan?.unavailableReason &&
|
||||
displayScan &&
|
||||
activePorts.length === 0 &&
|
||||
otherWorkspacePorts.length === 0 &&
|
||||
externalPorts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center flex-1 px-4 text-center text-muted-foreground">
|
||||
<Server size={32} className="mb-3 opacity-50" />
|
||||
<p className="text-sm">No local ports detected</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalPortDetailsDialog port={detailsPort} onClose={() => setDetailsPort(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalPortSection({
|
||||
id,
|
||||
title,
|
||||
ports,
|
||||
emptyText,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onStopPort,
|
||||
onShowDetails,
|
||||
onOpenInBrowser
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
ports: WorkspacePort[]
|
||||
emptyText?: string
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
onStopPort: (port: WorkspacePort) => void
|
||||
onShowDetails: (port: WorkspacePort) => void
|
||||
onOpenInBrowser: (port: WorkspacePort) => void
|
||||
}): React.JSX.Element | null {
|
||||
if (ports.length === 0 && !emptyText) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 w-full text-left mb-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={onToggle}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`local-port-section-${id}`}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn('shrink-0 transition-transform', !collapsed && 'rotate-90')}
|
||||
/>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
{ports.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60 ml-1">{ports.length}</span>
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div id={`local-port-section-${id}`}>
|
||||
{ports.length > 0
|
||||
? ports.map((port) => (
|
||||
<LocalPortRow
|
||||
key={port.id}
|
||||
port={port}
|
||||
onStop={onStopPort}
|
||||
onShowDetails={onShowDetails}
|
||||
onOpenInBrowser={onOpenInBrowser}
|
||||
/>
|
||||
))
|
||||
: emptyText && <div className="py-1 text-xs text-muted-foreground">{emptyText}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalPortRow({
|
||||
port,
|
||||
onStop,
|
||||
onShowDetails,
|
||||
onOpenInBrowser
|
||||
}: {
|
||||
port: WorkspacePort
|
||||
onStop: (port: WorkspacePort) => void
|
||||
onShowDetails: (port: WorkspacePort) => void
|
||||
onOpenInBrowser: (port: WorkspacePort) => void
|
||||
}): React.JSX.Element {
|
||||
const handleCopy = useCallback(() => {
|
||||
void window.api.ui.writeClipboardText(addressForPort(port))
|
||||
}, [port])
|
||||
|
||||
const handleOpenBrowser = useCallback(() => {
|
||||
void onOpenInBrowser(port)
|
||||
}, [onOpenInBrowser, port])
|
||||
|
||||
const handleCopyButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleCopy()
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
},
|
||||
[handleCopy]
|
||||
)
|
||||
|
||||
const handleOpenBrowserButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpenBrowser()
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
},
|
||||
[handleOpenBrowser]
|
||||
)
|
||||
|
||||
const handleStopButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onStop(port)
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
},
|
||||
[onStop, port]
|
||||
)
|
||||
|
||||
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
|
||||
const ownerLabel =
|
||||
port.kind === 'workspace'
|
||||
? port.owner.displayName
|
||||
: port.kind === 'container'
|
||||
? 'Container or forwarded service'
|
||||
: 'Unassigned'
|
||||
const confidenceLabel =
|
||||
port.kind === 'workspace' ? (port.owner.confidence === 'cwd' ? 'cwd' : 'command') : null
|
||||
const canStopProcess =
|
||||
port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron'
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
aria-label={`Port ${port.port} menu`}
|
||||
>
|
||||
<div className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{port.kind === 'container' ? <Box size={13} /> : <Server size={13} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">:{port.port}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{processLabel}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
{confidenceLabel && (
|
||||
<span className="shrink-0 text-muted-foreground/70">{confidenceLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenBrowserButtonClick}
|
||||
aria-label="Open in Orca Browser"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Open in Orca Browser
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCopyButtonClick}
|
||||
aria-label="Copy Address"
|
||||
>
|
||||
<Copy size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Copy Address
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canStopProcess && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={handleStopButtonClick}
|
||||
aria-label="Stop Process"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Stop Process
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<ContextMenuContent className={LOCAL_PORT_MENU_CONTENT_CLASS}>
|
||||
<ContextMenuLabel
|
||||
className={LOCAL_PORT_MENU_LABEL_CLASS}
|
||||
>{`:${port.port}`}</ContextMenuLabel>
|
||||
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleOpenBrowser}>
|
||||
<ExternalLink size={13} />
|
||||
Open in Orca Browser
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleCopy}>
|
||||
<Copy size={13} />
|
||||
Copy Address
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className={LOCAL_PORT_MENU_ITEM_CLASS}
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(JSON.stringify(port, null, 2))
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
Copy Details
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className={LOCAL_PORT_MENU_ITEM_CLASS}
|
||||
onSelect={() => onShowDetails(port)}
|
||||
>
|
||||
<Info size={13} />
|
||||
Show Details
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className={LOCAL_PORT_MENU_ITEM_CLASS}
|
||||
variant="destructive"
|
||||
disabled={!canStopProcess}
|
||||
onSelect={() => onStop(port)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Stop Process
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalPortDetailsDialog({
|
||||
port,
|
||||
onClose
|
||||
}: {
|
||||
port: WorkspacePort | null
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Dialog open={Boolean(port)} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{port ? `Port :${port.port}` : 'Port'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{port ? `${port.processName ?? 'Unknown process'} · ${addressForPort(port)}` : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{port && (
|
||||
<dl className="grid grid-cols-[88px_1fr] gap-x-3 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
<dd className="min-w-0 break-all text-foreground">{addressForPort(port)}</dd>
|
||||
<dt className="text-muted-foreground">Bind</dt>
|
||||
<dd className="min-w-0 break-all text-foreground">{`${port.bindHost}:${port.port}`}</dd>
|
||||
<dt className="text-muted-foreground">Kind</dt>
|
||||
<dd className="text-foreground">{port.kind}</dd>
|
||||
<dt className="text-muted-foreground">Protocol</dt>
|
||||
<dd className="text-foreground">{port.protocol}</dd>
|
||||
<dt className="text-muted-foreground">Process</dt>
|
||||
<dd className="min-w-0 break-all text-foreground">{port.processName ?? 'Unknown'}</dd>
|
||||
<dt className="text-muted-foreground">PID</dt>
|
||||
<dd className="text-foreground">{port.pid ?? 'Unknown'}</dd>
|
||||
{port.kind === 'workspace' && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Workspace</dt>
|
||||
<dd className="min-w-0 break-all text-foreground">{port.owner.displayName}</dd>
|
||||
<dt className="text-muted-foreground">Evidence</dt>
|
||||
<dd className="text-foreground">{port.owner.confidence}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SshPortsPanel(): React.JSX.Element {
|
||||
const portForwardsByConnection = useAppStore((s) => s.portForwardsByConnection)
|
||||
const detectedPortsByConnection = useAppStore((s) => s.detectedPortsByConnection)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
// Why: scope the panel to the active worktree's SSH connection so
|
||||
// actions target the correct machine and the disconnected state
|
||||
// reflects the active worktree, not some other SSH session.
|
||||
|
|
@ -107,6 +808,22 @@ export default function PortsPanel(): React.JSX.Element {
|
|||
setDialogState({ mode: 'edit', entry })
|
||||
}, [])
|
||||
|
||||
const handleOpenForwardInBrowser = useCallback(
|
||||
(entry: PortForwardEntry) => {
|
||||
if (!activeWorktree?.id) {
|
||||
toast.error('No workspace selected for the browser.')
|
||||
return
|
||||
}
|
||||
// Why: the protocol hint comes from the remote port (the actual service),
|
||||
// not the local port which may be an arbitrary remap.
|
||||
const protocol = HTTPS_PORTS.has(entry.remotePort) ? 'https' : 'http'
|
||||
createBrowserTab(activeWorktree.id, `${protocol}://127.0.0.1:${entry.localPort}`, {
|
||||
activate: true
|
||||
})
|
||||
},
|
||||
[activeWorktree?.id, createBrowserTab]
|
||||
)
|
||||
|
||||
const handleDialogClose = useCallback(() => {
|
||||
setDialogState({ mode: 'closed' })
|
||||
}, [])
|
||||
|
|
@ -162,7 +879,12 @@ export default function PortsPanel(): React.JSX.Element {
|
|||
</button>
|
||||
{!forwardedCollapsed &&
|
||||
allForwards.map((entry) => (
|
||||
<ForwardedPortRow key={entry.id} entry={entry} onEdit={() => handleEdit(entry)} />
|
||||
<ForwardedPortRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onEdit={() => handleEdit(entry)}
|
||||
onOpenInBrowser={() => handleOpenForwardInBrowser(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -231,10 +953,12 @@ export default function PortsPanel(): React.JSX.Element {
|
|||
|
||||
function ForwardedPortRow({
|
||||
entry,
|
||||
onEdit
|
||||
onEdit,
|
||||
onOpenInBrowser
|
||||
}: {
|
||||
entry: PortForwardEntry
|
||||
onEdit: () => void
|
||||
onOpenInBrowser: () => void
|
||||
}): React.JSX.Element {
|
||||
const [removing, setRemoving] = useState(false)
|
||||
|
||||
|
|
@ -256,13 +980,48 @@ function ForwardedPortRow({
|
|||
}, [entry.localPort])
|
||||
|
||||
const handleOpenBrowser = useCallback(() => {
|
||||
// Why: the protocol hint comes from the remote port (the actual service),
|
||||
// not the local port which may be an arbitrary remap.
|
||||
const protocol = HTTPS_PORTS.has(entry.remotePort) ? 'https' : 'http'
|
||||
void window.api.shell.openUrl(`${protocol}://127.0.0.1:${entry.localPort}`)
|
||||
}, [entry.localPort, entry.remotePort])
|
||||
onOpenInBrowser()
|
||||
}, [onOpenInBrowser])
|
||||
|
||||
const isHttpPort = HTTP_PORTS.has(entry.remotePort)
|
||||
const handleCopyButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleCopy()
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
},
|
||||
[handleCopy]
|
||||
)
|
||||
|
||||
const handleOpenBrowserButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpenBrowser()
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
},
|
||||
[handleOpenBrowser]
|
||||
)
|
||||
|
||||
const handleEditButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onEdit()
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
},
|
||||
[onEdit]
|
||||
)
|
||||
|
||||
const handleRemoveButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
void handleRemove()
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
},
|
||||
[handleRemove]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
|
||||
|
|
@ -281,21 +1040,19 @@ function ForwardedPortRow({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isHttpPort && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenBrowser}
|
||||
title="Open in Browser"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
onClick={handleOpenBrowserButtonClick}
|
||||
title="Open in Orca Browser"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCopyButtonClick}
|
||||
title="Copy Address"
|
||||
>
|
||||
<Copy size={13} />
|
||||
|
|
@ -303,7 +1060,7 @@ function ForwardedPortRow({
|
|||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onEdit}
|
||||
onClick={handleEditButtonClick}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
|
|
@ -314,7 +1071,7 @@ function ForwardedPortRow({
|
|||
'p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground',
|
||||
removing && 'opacity-50'
|
||||
)}
|
||||
onClick={handleRemove}
|
||||
onClick={handleRemoveButtonClick}
|
||||
disabled={removing}
|
||||
title="Remove"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -67,8 +67,6 @@ type ActivityBarItem = {
|
|||
shortcut: string
|
||||
/** When true, hidden for non-git (folder-mode) repos. */
|
||||
gitOnly?: boolean
|
||||
/** When true, only shown when at least one SSH connection is active. */
|
||||
sshOnly?: boolean
|
||||
}
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
|
@ -107,8 +105,7 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
|
|||
title: 'Ports',
|
||||
// Why: Ctrl+Shift+I is the DevTools accelerator on Windows/Linux, so this
|
||||
// shortcut is macOS-only. On other platforms the tooltip omits it.
|
||||
shortcut: isMac ? `\u21E7${mod}I` : '',
|
||||
sshOnly: true
|
||||
shortcut: isMac ? `\u21E7${mod}I` : ''
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -128,84 +125,15 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
|
||||
|
||||
// Why: show the Ports tab only when the active worktree belongs to a
|
||||
// remote (SSH) repo, not for any global SSH connection. Switching to a
|
||||
// local worktree should hide the tab even if SSH sessions are alive.
|
||||
const isRemoteWorktree = !!activeRepo?.connectionId
|
||||
const hasActiveSshConnection = useAppStore((s) => {
|
||||
if (!activeRepo?.connectionId) {
|
||||
return false
|
||||
}
|
||||
const state = s.sshConnectionStates.get(activeRepo.connectionId)
|
||||
return state?.status === 'connected'
|
||||
})
|
||||
|
||||
// Why: when the SSH connection drops while the user is viewing the Ports
|
||||
// panel, hiding the tab immediately would be jarring. Keep it visible
|
||||
// during a 30-second grace period, then hide it.
|
||||
const isPortsPanelActive = rightSidebarTab === 'ports'
|
||||
// Why: graceActiveRef is set synchronously during render (not via useEffect)
|
||||
// so that the very first render after disconnect already sees the grace flag,
|
||||
// preventing a one-frame flicker to the Explorer tab.
|
||||
const graceActiveRef = React.useRef(false)
|
||||
const graceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [, forceUpdate] = useState(0)
|
||||
|
||||
if (!hasActiveSshConnection && isPortsPanelActive && !graceActiveRef.current) {
|
||||
graceActiveRef.current = true
|
||||
} else if (graceActiveRef.current && (hasActiveSshConnection || !isPortsPanelActive)) {
|
||||
// Why: clear grace when either (a) the SSH session reconnects, or (b) the
|
||||
// user navigates away from the Ports tab — no reason to keep it visible
|
||||
// once they've moved on.
|
||||
graceActiveRef.current = false
|
||||
if (graceTimerRef.current) {
|
||||
clearTimeout(graceTimerRef.current)
|
||||
graceTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectGraceActive = graceActiveRef.current
|
||||
|
||||
useEffect(() => {
|
||||
if (disconnectGraceActive) {
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
graceActiveRef.current = false
|
||||
graceTimerRef.current = null
|
||||
// Why: only reset the tab if the user is still on Ports. If they
|
||||
// already navigated to Search/Checks/etc during the grace period,
|
||||
// forcing them back to Explorer would be disruptive.
|
||||
if (useAppStore.getState().rightSidebarTab === 'ports') {
|
||||
setRightSidebarTab('explorer')
|
||||
}
|
||||
forceUpdate((n) => n + 1)
|
||||
}, 30_000)
|
||||
return () => {
|
||||
if (graceTimerRef.current) {
|
||||
clearTimeout(graceTimerRef.current)
|
||||
graceTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [disconnectGraceActive, setRightSidebarTab])
|
||||
|
||||
const visibleItems = useMemo(
|
||||
() =>
|
||||
ACTIVITY_ITEMS.filter((item) => {
|
||||
if (item.gitOnly && isFolder) {
|
||||
return false
|
||||
}
|
||||
if (item.sshOnly) {
|
||||
if (!isRemoteWorktree) {
|
||||
return false
|
||||
}
|
||||
if (!hasActiveSshConnection && !disconnectGraceActive) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}),
|
||||
[isFolder, isRemoteWorktree, hasActiveSshConnection, disconnectGraceActive]
|
||||
[isFolder]
|
||||
)
|
||||
|
||||
// If the active tab is hidden (e.g. switched from a git repo to a folder),
|
||||
|
|
@ -244,7 +172,7 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
{effectiveTab === 'search' && <SearchPanel />}
|
||||
{effectiveTab === 'source-control' && <SourceControl />}
|
||||
{effectiveTab === 'checks' && <ChecksPanel />}
|
||||
{effectiveTab === 'ports' && <PortsPanel />}
|
||||
{effectiveTab === 'ports' && <PortsPanel isVisible={rightSidebarOpen} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export const RUNTIME_CAPABILITIES = [
|
|||
'runtime.environments.v1',
|
||||
'browser.screencast.v1',
|
||||
'terminal.binary-stream.v1',
|
||||
'terminal.multiplex.v1'
|
||||
'terminal.multiplex.v1',
|
||||
'workspace-ports.v1'
|
||||
] as const
|
||||
|
||||
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
export type WorkspacePortProbe = {
|
||||
id: string
|
||||
repoId: string
|
||||
displayName: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type WorkspacePortAttributionConfidence = 'cwd' | 'command' | 'none'
|
||||
|
||||
export type WorkspacePortOwner = {
|
||||
worktreeId: string
|
||||
repoId: string
|
||||
displayName: string
|
||||
path: string
|
||||
confidence: WorkspacePortAttributionConfidence
|
||||
}
|
||||
|
||||
type WorkspacePortBase = {
|
||||
id: string
|
||||
/** Address reported by the OS listener. May be a wildcard bind. */
|
||||
bindHost: string
|
||||
/** Address the renderer should copy/open. Wildcard binds are normalized to localhost. */
|
||||
connectHost: string
|
||||
port: number
|
||||
pid?: number
|
||||
processName?: string
|
||||
protocol: 'http' | 'https' | 'unknown'
|
||||
}
|
||||
|
||||
export type WorkspacePort =
|
||||
| (WorkspacePortBase & {
|
||||
kind: 'workspace'
|
||||
owner: WorkspacePortOwner
|
||||
})
|
||||
| (WorkspacePortBase & {
|
||||
kind: 'container'
|
||||
})
|
||||
| (WorkspacePortBase & {
|
||||
kind: 'external'
|
||||
})
|
||||
|
||||
export type WorkspacePortScanRequest = {
|
||||
repoId?: string
|
||||
}
|
||||
|
||||
export type WorkspacePortKillRequest = {
|
||||
repoId?: string
|
||||
pid: number
|
||||
port: number
|
||||
}
|
||||
|
||||
export type WorkspacePortKillResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type WorkspacePortScanResult = {
|
||||
platform: NodeJS.Platform | 'unknown'
|
||||
scannedAt: number
|
||||
ports: WorkspacePort[]
|
||||
unavailableReason?: string
|
||||
}
|
||||
Loading…
Reference in New Issue