Honor system-browser shortcut for port opens (#5756)
This commit is contained in:
parent
f1384597f7
commit
4a2dc069d5
|
|
@ -23,10 +23,13 @@ vi.mock('@/lib/worktree-activation', () => ({
|
|||
|
||||
import { getLocalWorkspacePortSections } from './PortsPanel'
|
||||
import {
|
||||
getPortOpenBrowserTooltipLabel,
|
||||
getPortSystemBrowserHint,
|
||||
killWorkspacePortForTarget,
|
||||
mergeWorkspacePortScans,
|
||||
openWorkspacePortInBrowser,
|
||||
refreshWorkspacePortScanAfterStop,
|
||||
resolvePortOpenInOrcaBrowser,
|
||||
scanWorkspacePortsForTarget
|
||||
} from '@/lib/workspace-port-actions'
|
||||
|
||||
|
|
@ -67,6 +70,17 @@ const runtimeCall = vi.fn()
|
|||
const runtimeEnvironmentCall = vi.fn()
|
||||
const openUrl = vi.fn()
|
||||
|
||||
function portOpenClick(
|
||||
overrides: Partial<Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>> = {}
|
||||
): Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'> {
|
||||
return {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localScan.mockReset()
|
||||
localKill.mockReset()
|
||||
|
|
@ -96,6 +110,61 @@ beforeEach(() => {
|
|||
})
|
||||
|
||||
describe('PortsPanel runtime routing', () => {
|
||||
it('formats platform-specific system-browser hints for port open tooltips', () => {
|
||||
expect(getPortSystemBrowserHint(true)).toBe('⇧⌘+click for system browser')
|
||||
expect(getPortSystemBrowserHint(false)).toBe('Shift+Ctrl+click for system browser')
|
||||
expect(getPortOpenBrowserTooltipLabel('Open in Browser', false)).toBe(
|
||||
'Open in Browser. Shift+Ctrl+click for system browser'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps macOS Shift+Cmd-click to system-browser port routing', () => {
|
||||
expect(
|
||||
resolvePortOpenInOrcaBrowser({
|
||||
settings: { openLinksInApp: true },
|
||||
event: portOpenClick({ metaKey: true, shiftKey: true }),
|
||||
isMac: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('maps non-macOS Shift+Ctrl-click to system-browser port routing', () => {
|
||||
expect(
|
||||
resolvePortOpenInOrcaBrowser({
|
||||
settings: { openLinksInApp: true },
|
||||
event: portOpenClick({ ctrlKey: true, shiftKey: true }),
|
||||
isMac: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat macOS Shift+Ctrl-click as a system-browser port override', () => {
|
||||
expect(
|
||||
resolvePortOpenInOrcaBrowser({
|
||||
settings: { openLinksInApp: true },
|
||||
event: portOpenClick({ ctrlKey: true, shiftKey: true }),
|
||||
isMac: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps plain and no-event port opens on the saved link-routing setting', () => {
|
||||
expect(
|
||||
resolvePortOpenInOrcaBrowser({
|
||||
settings: { openLinksInApp: true },
|
||||
event: portOpenClick(),
|
||||
isMac: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
resolvePortOpenInOrcaBrowser({
|
||||
settings: { openLinksInApp: false },
|
||||
event: null,
|
||||
isMac: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('uses local IPC for local workspace port scans and kills', async () => {
|
||||
localScan.mockResolvedValueOnce(emptyScan)
|
||||
localKill.mockResolvedValueOnce({ ok: true })
|
||||
|
|
@ -313,6 +382,31 @@ describe('PortsPanel runtime routing', () => {
|
|||
expect(activateAndRevealWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens forced local workspace port clicks in the system browser', async () => {
|
||||
const createBrowserTab = vi.fn()
|
||||
const setRemoteBrowserPageHandle = vi.fn()
|
||||
openUrl.mockResolvedValueOnce(undefined)
|
||||
const openInOrcaBrowser = resolvePortOpenInOrcaBrowser({
|
||||
settings: { openLinksInApp: true },
|
||||
event: portOpenClick({ ctrlKey: true, shiftKey: true }),
|
||||
isMac: false
|
||||
})
|
||||
|
||||
await expect(
|
||||
openWorkspacePortInBrowser({
|
||||
port: workspacePort,
|
||||
runtimeTarget: { kind: 'local' },
|
||||
createBrowserTab: createBrowserTab as never,
|
||||
setRemoteBrowserPageHandle: setRemoteBrowserPageHandle as never,
|
||||
openInOrcaBrowser
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:63468')
|
||||
expect(createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(activateAndRevealWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns post-stop refresh failures without throwing', async () => {
|
||||
const setWorkspacePortScan = vi.fn()
|
||||
const setWorkspacePortScanRefreshing = vi.fn()
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
|||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
killWorkspacePortForTarget,
|
||||
getPortOpenBrowserTooltipLabel,
|
||||
openWorkspacePortInBrowser,
|
||||
refreshWorkspacePortScanAfterStop,
|
||||
resolvePortOpenInOrcaBrowser,
|
||||
scanWorkspacePortsForTarget,
|
||||
shouldOpenWorkspacePortInOrcaBrowser,
|
||||
workspacePortRuntimeTargetKey
|
||||
} from '@/lib/workspace-port-actions'
|
||||
import {
|
||||
|
|
@ -281,14 +282,18 @@ function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React.
|
|||
)
|
||||
|
||||
const handleOpenPortInBrowser = useCallback(
|
||||
async (port: WorkspacePort) => {
|
||||
async (port: WorkspacePort, event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const result = await openWorkspacePortInBrowser({
|
||||
port,
|
||||
activeWorktreeId: activeWorktree?.id,
|
||||
runtimeTarget,
|
||||
createBrowserTab,
|
||||
setRemoteBrowserPageHandle,
|
||||
openInOrcaBrowser: shouldOpenWorkspacePortInOrcaBrowser(settings)
|
||||
openInOrcaBrowser: resolvePortOpenInOrcaBrowser({
|
||||
settings,
|
||||
event,
|
||||
isMac: navigator.userAgent.includes('Mac')
|
||||
})
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
|
|
@ -453,7 +458,7 @@ function LocalPortSection({
|
|||
onToggle: () => void
|
||||
onStopPort: (port: WorkspacePort) => void
|
||||
onShowDetails: (port: WorkspacePort) => void
|
||||
onOpenInBrowser: (port: WorkspacePort) => void
|
||||
onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}): React.JSX.Element | null {
|
||||
if (ports.length === 0 && !emptyText) {
|
||||
return null
|
||||
|
|
@ -507,15 +512,18 @@ function LocalPortRow({
|
|||
port: WorkspacePort
|
||||
onStop: (port: WorkspacePort) => void
|
||||
onShowDetails: (port: WorkspacePort) => void
|
||||
onOpenInBrowser: (port: WorkspacePort) => void
|
||||
onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}): React.JSX.Element {
|
||||
const handleCopy = useCallback(() => {
|
||||
void window.api.ui.writeClipboardText(addressForPort(port))
|
||||
}, [port])
|
||||
|
||||
const handleOpenBrowser = useCallback(() => {
|
||||
void onOpenInBrowser(port)
|
||||
}, [onOpenInBrowser, port])
|
||||
const handleOpenBrowser = useCallback(
|
||||
(event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
void onOpenInBrowser(port, event)
|
||||
},
|
||||
[onOpenInBrowser, port]
|
||||
)
|
||||
|
||||
const handleCopyButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
|
@ -529,7 +537,9 @@ function LocalPortRow({
|
|||
|
||||
const handleOpenBrowserButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpenBrowser()
|
||||
// Why: keyboard activations have detail=0; only pointer clicks carry
|
||||
// the modifier intent for the system-browser escape hatch.
|
||||
handleOpenBrowser(event.detail > 0 ? event : undefined)
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
|
|
@ -555,6 +565,10 @@ function LocalPortRow({
|
|||
: port.kind === 'container'
|
||||
? 'Container or forwarded service'
|
||||
: 'Unassigned'
|
||||
const openBrowserLabel = translate(
|
||||
'auto.components.right.sidebar.PortsPanel.b22b128b2a',
|
||||
'Open in Browser'
|
||||
)
|
||||
const confidenceLabel =
|
||||
port.kind === 'workspace' ? (port.owner.confidence === 'cwd' ? 'cwd' : 'command') : null
|
||||
const canStopProcess =
|
||||
|
|
@ -603,19 +617,13 @@ function LocalPortRow({
|
|||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenBrowserButtonClick}
|
||||
aria-label={translate(
|
||||
'auto.components.right.sidebar.PortsPanel.b22b128b2a',
|
||||
'Open in Browser'
|
||||
)}
|
||||
aria-label={openBrowserLabel}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.PortsPanel.b22b128b2a',
|
||||
'Open in Browser'
|
||||
)}
|
||||
{getPortOpenBrowserTooltipLabel(openBrowserLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
|
|
@ -672,9 +680,12 @@ function LocalPortRow({
|
|||
<ContextMenuLabel
|
||||
className={LOCAL_PORT_MENU_LABEL_CLASS}
|
||||
>{`:${port.port}`}</ContextMenuLabel>
|
||||
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleOpenBrowser}>
|
||||
<ContextMenuItem
|
||||
className={LOCAL_PORT_MENU_ITEM_CLASS}
|
||||
onSelect={() => handleOpenBrowser()}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
{translate('auto.components.right.sidebar.PortsPanel.b22b128b2a', 'Open in Browser')}
|
||||
{openBrowserLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleCopy}>
|
||||
<Copy size={13} />
|
||||
|
|
@ -850,9 +861,15 @@ function SshPortsPanel(): React.JSX.Element {
|
|||
}, [])
|
||||
|
||||
const handleOpenForwardInBrowser = useCallback(
|
||||
(entry: PortForwardEntry) => {
|
||||
(entry: PortForwardEntry, event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const url = browserUrlForPortForwardEntry(entry)
|
||||
if (!shouldOpenWorkspacePortInOrcaBrowser(settings)) {
|
||||
if (
|
||||
!resolvePortOpenInOrcaBrowser({
|
||||
settings,
|
||||
event,
|
||||
isMac: navigator.userAgent.includes('Mac')
|
||||
})
|
||||
) {
|
||||
void window.api.shell.openUrl(url)
|
||||
return
|
||||
}
|
||||
|
|
@ -935,7 +952,7 @@ function SshPortsPanel(): React.JSX.Element {
|
|||
key={entry.id}
|
||||
entry={entry}
|
||||
onEdit={() => handleEdit(entry)}
|
||||
onOpenInBrowser={() => handleOpenForwardInBrowser(entry)}
|
||||
onOpenInBrowser={(event) => handleOpenForwardInBrowser(entry, event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1015,7 +1032,7 @@ function ForwardedPortRow({
|
|||
}: {
|
||||
entry: PortForwardEntry
|
||||
onEdit: () => void
|
||||
onOpenInBrowser: () => void
|
||||
onOpenInBrowser: (event?: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}): React.JSX.Element {
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
|
|
@ -1037,9 +1054,12 @@ function ForwardedPortRow({
|
|||
void window.api.ui.writeClipboardText(forwardedAddress)
|
||||
}, [forwardedAddress])
|
||||
|
||||
const handleOpenBrowser = useCallback(() => {
|
||||
onOpenInBrowser()
|
||||
}, [onOpenInBrowser])
|
||||
const handleOpenBrowser = useCallback(
|
||||
(event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onOpenInBrowser(event)
|
||||
},
|
||||
[onOpenInBrowser]
|
||||
)
|
||||
|
||||
const handleCopyButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
|
@ -1053,7 +1073,9 @@ function ForwardedPortRow({
|
|||
|
||||
const handleOpenBrowserButtonClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpenBrowser()
|
||||
// Why: keyboard activations have detail=0; only pointer clicks carry
|
||||
// the modifier intent for the system-browser escape hatch.
|
||||
handleOpenBrowser(event.detail > 0 ? event : undefined)
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
|
|
@ -1082,6 +1104,21 @@ function ForwardedPortRow({
|
|||
)
|
||||
|
||||
const advertisedBrowserUrl = advertisedBrowserUrlForForwardedRow(entry)
|
||||
const openBrowserLabel = translate(
|
||||
'auto.components.right.sidebar.PortsPanel.b22b128b2a',
|
||||
'Open in Browser'
|
||||
)
|
||||
const openBrowserTitle = getPortOpenBrowserTooltipLabel(
|
||||
advertisedBrowserUrl
|
||||
? translate(
|
||||
'auto.components.right.sidebar.PortsPanel.75aeea592f',
|
||||
'Open {{value0}} in Browser',
|
||||
{
|
||||
value0: advertisedBrowserUrl
|
||||
}
|
||||
)
|
||||
: openBrowserLabel
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
|
||||
|
|
@ -1112,15 +1149,7 @@ function ForwardedPortRow({
|
|||
type="button"
|
||||
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenBrowserButtonClick}
|
||||
title={
|
||||
advertisedBrowserUrl
|
||||
? translate(
|
||||
'auto.components.right.sidebar.PortsPanel.75aeea592f',
|
||||
'Open {{value0}} in Browser',
|
||||
{ value0: advertisedBrowserUrl }
|
||||
)
|
||||
: translate('auto.components.right.sidebar.PortsPanel.b22b128b2a', 'Open in Browser')
|
||||
}
|
||||
title={openBrowserTitle}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ describe('WorktreeCardPortsDetails', () => {
|
|||
|
||||
expect(markup).toContain('dev.preview.localhost:58941')
|
||||
expect(markup).toContain('aria-label="Copy dev.preview.localhost:58941"')
|
||||
expect(markup).toContain('Open in Browser. Shift+Ctrl+click for system browser')
|
||||
expect(markup).toContain(
|
||||
'<section class="space-y-1.5"><div class="flex items-center gap-1.5 px-1'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
|||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
canStopWorkspacePort,
|
||||
getPortOpenBrowserTooltipLabel,
|
||||
goToWorkspacePortOwner,
|
||||
killWorkspacePortForTarget,
|
||||
openWorkspacePortInBrowser,
|
||||
refreshWorkspacePortScanAfterStop,
|
||||
shouldOpenWorkspacePortInOrcaBrowser
|
||||
resolvePortOpenInOrcaBrowser
|
||||
} from '@/lib/workspace-port-actions'
|
||||
import { addressForPort } from '@/lib/workspace-port-urls'
|
||||
import type { WorkspacePort } from '../../../../shared/workspace-ports'
|
||||
|
|
@ -59,11 +60,13 @@ export function WorktreeCardPortsTrigger({
|
|||
|
||||
function PortAction({
|
||||
label,
|
||||
tooltipLabel = label,
|
||||
disabled = false,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
tooltipLabel?: string
|
||||
disabled?: boolean
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
children: React.ReactNode
|
||||
|
|
@ -91,7 +94,7 @@ function PortAction({
|
|||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
|
@ -115,17 +118,28 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
|||
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
|
||||
const address = addressForPort(port)
|
||||
const canStop = canStopWorkspacePort(port)
|
||||
const openBrowserLabel = translate(
|
||||
'auto.components.sidebar.WorktreeCardPorts.33bc7d7495',
|
||||
'Open in Browser'
|
||||
)
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
const openInOrcaBrowser = resolvePortOpenInOrcaBrowser({
|
||||
settings,
|
||||
// Why: keyboard activations have detail=0; only pointer clicks carry
|
||||
// the modifier intent for the system-browser escape hatch.
|
||||
event: event.detail > 0 ? event : null,
|
||||
isMac: navigator.userAgent.includes('Mac')
|
||||
})
|
||||
void openWorkspacePortInBrowser({
|
||||
port,
|
||||
runtimeTarget,
|
||||
createBrowserTab,
|
||||
setRemoteBrowserPageHandle,
|
||||
openInOrcaBrowser: shouldOpenWorkspacePortInOrcaBrowser(settings)
|
||||
openInOrcaBrowser
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
|
|
@ -244,10 +258,8 @@ function WorktreePortRow({ port }: { port: WorkspacePort }): React.JSX.Element {
|
|||
</Tooltip>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 can-hover:opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
|
||||
<PortAction
|
||||
label={translate(
|
||||
'auto.components.sidebar.WorktreeCardPorts.33bc7d7495',
|
||||
'Open in Browser'
|
||||
)}
|
||||
label={openBrowserLabel}
|
||||
tooltipLabel={getPortOpenBrowserTooltipLabel(openBrowserLabel)}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspacePort } from '../../../../shared/workspace-ports'
|
||||
|
||||
const {
|
||||
activateAndRevealWorktreeMock,
|
||||
createBrowserTabMock,
|
||||
openUrlMock,
|
||||
recordFeatureInteractionMock,
|
||||
setRemoteBrowserPageHandleMock,
|
||||
storeState
|
||||
} = vi.hoisted(() => {
|
||||
const state = {
|
||||
settings: { openLinksInApp: true },
|
||||
createBrowserTab: vi.fn(),
|
||||
setRemoteBrowserPageHandle: vi.fn(),
|
||||
setWorkspacePortScan: vi.fn(),
|
||||
setWorkspacePortScanForKey: vi.fn(),
|
||||
setWorkspacePortScanRefreshing: vi.fn(),
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
workspacePortScansByKey: {}
|
||||
}
|
||||
return {
|
||||
activateAndRevealWorktreeMock: vi.fn(),
|
||||
createBrowserTabMock: state.createBrowserTab,
|
||||
openUrlMock: vi.fn(),
|
||||
recordFeatureInteractionMock: state.recordFeatureInteraction,
|
||||
setRemoteBrowserPageHandleMock: state.setRemoteBrowserPageHandle,
|
||||
storeState: state
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
{ getState: () => storeState }
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: activateAndRevealWorktreeMock
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
getActiveRuntimeTarget: () => ({ kind: 'local' }),
|
||||
callRuntimeRpc: vi.fn(),
|
||||
RuntimeRpcCallError: class RuntimeRpcCallError extends Error {
|
||||
code?: string
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { PortRow } from './ports-status-popover-rows'
|
||||
|
||||
const externalPort: 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: 'http',
|
||||
kind: 'external'
|
||||
}
|
||||
|
||||
describe('status bar port row open routing', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
value: 'Mozilla/5.0 (X11; Linux x86_64)',
|
||||
configurable: true
|
||||
})
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
shell: {
|
||||
openUrl: openUrlMock
|
||||
},
|
||||
ui: {
|
||||
writeClipboardText: vi.fn()
|
||||
}
|
||||
}
|
||||
openUrlMock.mockResolvedValue(undefined)
|
||||
createBrowserTabMock.mockReset()
|
||||
openUrlMock.mockClear()
|
||||
recordFeatureInteractionMock.mockClear()
|
||||
setRemoteBrowserPageHandleMock.mockClear()
|
||||
activateAndRevealWorktreeMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderPortRow(): HTMLButtonElement {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<PortRow port={externalPort} activeWorktreeId={null} external />)
|
||||
})
|
||||
const openButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Open in Browser"]'
|
||||
)
|
||||
if (!openButton) {
|
||||
throw new Error('expected Open in Browser button')
|
||||
}
|
||||
expect(container.textContent).toContain('Open in Browser. Shift+Ctrl+click for system browser')
|
||||
return openButton
|
||||
}
|
||||
|
||||
it('keeps the open button enabled and forwards Shift+Ctrl-click to system-browser routing', async () => {
|
||||
const openButton = renderPortRow()
|
||||
|
||||
expect(openButton.disabled).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
openButton.dispatchEvent(
|
||||
new window.MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
detail: 1,
|
||||
shiftKey: true
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('ports')
|
||||
expect(openUrlMock).toHaveBeenCalledWith('http://127.0.0.1:63468')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(activateAndRevealWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps no-pointer activations on the saved link-routing setting', async () => {
|
||||
const openButton = renderPortRow()
|
||||
|
||||
expect(openButton.disabled).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
openButton.dispatchEvent(
|
||||
new window.MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('ports')
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -6,11 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||
import {
|
||||
addressForPort,
|
||||
canStopWorkspacePort,
|
||||
getPortOpenBrowserTooltipLabel,
|
||||
goToWorkspacePortOwner,
|
||||
killWorkspacePortForTarget,
|
||||
openWorkspacePortInBrowser,
|
||||
refreshWorkspacePortScanAfterStop,
|
||||
shouldOpenWorkspacePortInOrcaBrowser
|
||||
resolvePortOpenInOrcaBrowser
|
||||
} from '@/lib/workspace-port-actions'
|
||||
import type { WorkspacePortGroup } from '@/lib/workspace-port-groups'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
|
|
@ -21,11 +22,13 @@ import { translate } from '@/i18n/i18n'
|
|||
|
||||
function PortAction({
|
||||
label,
|
||||
tooltipLabel = label,
|
||||
onClick,
|
||||
disabled,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
tooltipLabel?: string
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
|
|
@ -57,7 +60,7 @@ function PortAction({
|
|||
{disabled ? <span className="inline-flex">{button}</span> : button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="z-[70]">
|
||||
{label}
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
|
@ -90,14 +93,23 @@ export function PortRow({
|
|||
[runtimeEnvironmentId, settings]
|
||||
)
|
||||
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
|
||||
const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings)
|
||||
const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId)
|
||||
const canStop = canStopWorkspacePort(port)
|
||||
const openBrowserLabel = translate(
|
||||
'auto.components.status.bar.ports.status.popover.rows.085f4f0334',
|
||||
'Open in Browser'
|
||||
)
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
const openInOrcaBrowser = resolvePortOpenInOrcaBrowser({
|
||||
settings,
|
||||
// Why: keyboard activations have detail=0; only pointer clicks carry
|
||||
// the modifier intent for the system-browser escape hatch.
|
||||
event: event.detail > 0 ? event : null,
|
||||
isMac: navigator.userAgent.includes('Mac')
|
||||
})
|
||||
void openWorkspacePortInBrowser({
|
||||
port,
|
||||
activeWorktreeId,
|
||||
|
|
@ -120,10 +132,10 @@ export function PortRow({
|
|||
[
|
||||
activeWorktreeId,
|
||||
createBrowserTab,
|
||||
openInOrcaBrowser,
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
settings,
|
||||
setRemoteBrowserPageHandle
|
||||
]
|
||||
)
|
||||
|
|
@ -219,12 +231,9 @@ export function PortRow({
|
|||
</Tooltip>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 can-hover:opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
|
||||
<PortAction
|
||||
label={translate(
|
||||
'auto.components.status.bar.ports.status.popover.rows.085f4f0334',
|
||||
'Open in Browser'
|
||||
)}
|
||||
label={openBrowserLabel}
|
||||
tooltipLabel={getPortOpenBrowserTooltipLabel(openBrowserLabel)}
|
||||
onClick={handleOpen}
|
||||
disabled={!canOpen}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</PortAction>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,37 @@ export function shouldOpenWorkspacePortInOrcaBrowser(
|
|||
return settings?.openLinksInApp === true
|
||||
}
|
||||
|
||||
function isMacShortcutPlatform(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
|
||||
}
|
||||
|
||||
export function getPortSystemBrowserHint(isMac: boolean = isMacShortcutPlatform()): string {
|
||||
return isMac ? '⇧⌘+click for system browser' : 'Shift+Ctrl+click for system browser'
|
||||
}
|
||||
|
||||
export function getPortOpenBrowserTooltipLabel(openLabel: string, isMac?: boolean): string {
|
||||
return `${openLabel}. ${getPortSystemBrowserHint(isMac)}`
|
||||
}
|
||||
|
||||
type PortOpenClickEvent = Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>
|
||||
|
||||
export function resolvePortOpenInOrcaBrowser({
|
||||
settings,
|
||||
event,
|
||||
isMac
|
||||
}: {
|
||||
settings: { openLinksInApp?: boolean } | null | undefined
|
||||
event?: PortOpenClickEvent | null
|
||||
isMac: boolean
|
||||
}): boolean {
|
||||
// Why: Shift+Cmd/Ctrl is the external-browser escape hatch; no pointer
|
||||
// event means context-menu and keyboard opens should keep the saved setting.
|
||||
if (event?.shiftKey && (isMac ? event.metaKey : event.ctrlKey)) {
|
||||
return false
|
||||
}
|
||||
return shouldOpenWorkspacePortInOrcaBrowser(settings)
|
||||
}
|
||||
|
||||
export function workspacePortOwnerWorktreeId(port: WorkspacePort): string | null {
|
||||
return port.kind === 'workspace' ? port.owner.worktreeId : null
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue