mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(terminal): add anchored link action popovers (#13414)
This commit is contained in:
@@ -12,6 +12,7 @@ import { BrowserUseSetup } from './BrowserUsePane'
|
||||
import { BrowserSearchEngineSetting } from './BrowserSearchEngineSetting'
|
||||
import { BrowserLinkRoutingSetting } from './BrowserLinkRoutingSetting'
|
||||
import { BrowserLinkRoutingModifierSetting } from './BrowserLinkRoutingModifierSetting'
|
||||
import { BrowserTerminalLinkActionsSetting } from './BrowserTerminalLinkActionsSetting'
|
||||
import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting'
|
||||
import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection'
|
||||
import { BrowserNewProfileDialog } from './BrowserNewProfileDialog'
|
||||
@@ -104,8 +105,11 @@ export function BrowserPane({
|
||||
const showLinkRoutingModifier = matchesSettingsSearch(searchQuery, [
|
||||
getBrowserPaneSearchEntries()[4]
|
||||
])
|
||||
const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]])
|
||||
const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]])
|
||||
const showTerminalLinkActions = matchesSettingsSearch(searchQuery, [
|
||||
getBrowserPaneSearchEntries()[5]
|
||||
])
|
||||
const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]])
|
||||
const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[7]])
|
||||
const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries())
|
||||
const isMac = isMacUserAgent()
|
||||
const linkRoutingDescription = getBrowserLinkRoutingDescription(
|
||||
@@ -254,6 +258,14 @@ export function BrowserPane({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showTerminalLinkActions ? (
|
||||
<BrowserTerminalLinkActionsSetting
|
||||
settings={settings}
|
||||
isMac={isMac}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showLocalhostLabels ? (
|
||||
<BrowserLocalhostWorktreeLabelsSetting
|
||||
settings={settings}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsSwitchRow } from './SettingsFormControls'
|
||||
import { getTerminalLinkActionsDescription } from './browser-link-routing-copy'
|
||||
import { getTerminalLinkActionSearchKeywords } from './browser-search'
|
||||
|
||||
type BrowserTerminalLinkActionsSettingProps = {
|
||||
settings: Pick<GlobalSettings, 'terminalLinkActionPopoverEnabled'>
|
||||
isMac: boolean
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
export function BrowserTerminalLinkActionsSetting({
|
||||
settings,
|
||||
isMac,
|
||||
updateSettings
|
||||
}: BrowserTerminalLinkActionsSettingProps): React.JSX.Element {
|
||||
const title = translate(
|
||||
'auto.components.settings.BrowserTerminalLinkActionsSetting.title',
|
||||
'Show terminal link actions'
|
||||
)
|
||||
const description = getTerminalLinkActionsDescription({ isMac })
|
||||
|
||||
return (
|
||||
<SearchableSetting
|
||||
id={BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID}
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={getTerminalLinkActionSearchKeywords({ isMac })}
|
||||
>
|
||||
<div className="ml-4 border-l border-border pl-4">
|
||||
<SettingsSwitchRow
|
||||
label={title}
|
||||
description={description}
|
||||
checked={settings.terminalLinkActionPopoverEnabled !== false}
|
||||
onChange={() =>
|
||||
updateSettings({
|
||||
terminalLinkActionPopoverEnabled: settings.terminalLinkActionPopoverEnabled === false
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,14 @@ export function getBrowserLinkRoutingShortcutLabel(platform: { isMac: boolean })
|
||||
return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click'
|
||||
}
|
||||
|
||||
export function getTerminalLinkActionsDescription(platform: { isMac: boolean }): string {
|
||||
return translate(
|
||||
'auto.components.settings.BrowserTerminalLinkActionsSetting.description',
|
||||
'Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click.',
|
||||
{ modifier: platform.isMac ? '⌘' : 'Ctrl' }
|
||||
)
|
||||
}
|
||||
|
||||
// Why: "always" stops being true once inverting is on, so only then does the nested
|
||||
// row take over the chord sentence — with it off this reads exactly as it always has.
|
||||
// Each variant is a complete catalog entry: stitched suffixes break in locales whose
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import ko from '@/i18n/locales/ko.json'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { getBrowserPaneSearchEntries } from './browser-search'
|
||||
import { getBrowserPaneSearchEntries, getTerminalLinkActionSearchKeywords } from './browser-search'
|
||||
import {
|
||||
getBrowserLinkRoutingDescription,
|
||||
getBrowserLinkRoutingShortcutLabel,
|
||||
@@ -47,6 +47,17 @@ describe('browser settings search copy', () => {
|
||||
expect(linkRoutingEntry?.description).toBe(getBrowserLinkRoutingDescription({ isMac: false }))
|
||||
expect(linkRoutingEntry?.keywords).toContain('ctrl')
|
||||
expect(linkRoutingEntry?.keywords).not.toContain('cmd')
|
||||
|
||||
const terminalActionsEntry = getBrowserPaneSearchEntries({ isMac: false }).find(
|
||||
(entry) => entry.title === 'Show terminal link actions'
|
||||
)
|
||||
expect(terminalActionsEntry?.description).toContain('Ctrl-click')
|
||||
expect(terminalActionsEntry?.description).not.toContain('Cmd/Ctrl')
|
||||
expect(terminalActionsEntry?.keywords).toEqual(
|
||||
getTerminalLinkActionSearchKeywords({ isMac: false })
|
||||
)
|
||||
expect(terminalActionsEntry?.keywords).toContain('browser')
|
||||
expect(terminalActionsEntry?.keywords).toContain('ctrl')
|
||||
})
|
||||
|
||||
// Why: shipping the opt-in must not reword this row for anyone who never enables
|
||||
@@ -79,6 +90,7 @@ describe('browser link routing modifier copy', () => {
|
||||
'Default Zoom',
|
||||
'Link Routing',
|
||||
'Hold Shift to open in Orca',
|
||||
'Show terminal link actions',
|
||||
'Localhost Worktree Labels',
|
||||
'Session & Cookies'
|
||||
])
|
||||
|
||||
@@ -3,11 +3,12 @@ import { translate } from '@/i18n/i18n'
|
||||
import { translateSearchKeyword } from './settings-search-keywords'
|
||||
import {
|
||||
getBrowserLinkRoutingDescription,
|
||||
getTerminalLinkActionsDescription,
|
||||
getLinkRoutingModifierDescription,
|
||||
getLinkRoutingModifierTitle
|
||||
} from './browser-link-routing-copy'
|
||||
|
||||
type BrowserShortcutPlatform = {
|
||||
export type BrowserShortcutPlatform = {
|
||||
isMac: boolean
|
||||
}
|
||||
|
||||
@@ -17,6 +18,38 @@ function getDefaultBrowserShortcutPlatform(): BrowserShortcutPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
export function getTerminalLinkActionSearchKeywords(platform: BrowserShortcutPlatform): string[] {
|
||||
return [
|
||||
...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'),
|
||||
...translateSearchKeyword('auto.components.settings.browser.search.bea27bac4b', 'links'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.browser.search.terminalLinkActions.terminal',
|
||||
'terminal'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.browser.search.terminalLinkActions.click',
|
||||
'click'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.browser.search.terminalLinkActions.actions',
|
||||
'actions'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.browser.search.terminalLinkActions.popover',
|
||||
'popover'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.browser.search.terminalLinkActions.menu',
|
||||
'menu'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.browser.search.terminalLinkActions.disable',
|
||||
'disable'
|
||||
),
|
||||
platform.isMac ? 'cmd' : 'ctrl'
|
||||
]
|
||||
}
|
||||
|
||||
export function getBrowserPaneSearchEntries(
|
||||
platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform()
|
||||
): SettingsSearchEntry[] {
|
||||
@@ -138,6 +171,14 @@ export function getBrowserPaneSearchEntries(
|
||||
platform.isMac ? 'cmd' : 'ctrl'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.BrowserTerminalLinkActionsSetting.title',
|
||||
'Show terminal link actions'
|
||||
),
|
||||
description: getTerminalLinkActionsDescription(platform),
|
||||
keywords: getTerminalLinkActionSearchKeywords(platform)
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.browser.search.19ea5607cf',
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ReactNode } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
|
||||
import type { TerminalLinkActionRequest } from './terminal-link-action-request'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (
|
||||
selector: (state: {
|
||||
openSettingsPage: () => void
|
||||
openSettingsTarget: (target: unknown) => void
|
||||
}) => unknown
|
||||
) =>
|
||||
selector({
|
||||
openSettingsPage: mocks.openSettingsPage,
|
||||
openSettingsTarget: mocks.openSettingsTarget
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => children,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => children,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
children: ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) =>
|
||||
open ? (
|
||||
<div>
|
||||
<button data-testid="dismiss-popover" onClick={() => onOpenChange(false)} />
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
PopoverAnchor: () => null,
|
||||
PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
)
|
||||
}))
|
||||
|
||||
import { TerminalLinkActionPopover } from './TerminalLinkActionPopover'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('TerminalLinkActionPopover', () => {
|
||||
it('shows the full destination and runs the selected action', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const onClose = vi.fn()
|
||||
const focusTerminal = vi.fn()
|
||||
const run = vi.fn()
|
||||
const request: TerminalLinkActionRequest = {
|
||||
paneId: 1,
|
||||
anchorX: 100,
|
||||
anchorY: 200,
|
||||
destination: 'https://example.com/full/hidden/destination?query=actual',
|
||||
kind: 'url',
|
||||
primary: { label: 'Open link', run },
|
||||
alternate: { external: true, label: 'System Browser', run: vi.fn() },
|
||||
focusTerminal
|
||||
}
|
||||
|
||||
render(<TerminalLinkActionPopover request={request} onClose={onClose} />)
|
||||
|
||||
const destination = screen.getByText(request.destination)
|
||||
expect(destination.className).toContain('line-clamp-2')
|
||||
expect(destination.getAttribute('title')).toBe(request.destination)
|
||||
expect(screen.getByText('System Browser')).toBeTruthy()
|
||||
expect(screen.getAllByText('Click')).toHaveLength(2)
|
||||
const textOnlyAction = screen.getByText('Open link').closest('button')
|
||||
const externalAction = screen.getByText('System Browser').closest('button')
|
||||
expect(textOnlyAction?.querySelector('svg')).toBeNull()
|
||||
expect(externalAction?.querySelector('svg')).toBeTruthy()
|
||||
expect(externalAction?.className).toContain('has-[>svg]:px-1.5')
|
||||
expect(externalAction?.className).not.toContain('has-[>svg]:px-3')
|
||||
expect(destination.parentElement?.className).toContain('items-center')
|
||||
expect(destination.parentElement?.className).toContain('py-0.5')
|
||||
expect(destination.closest('[class*="w-max"]')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText('Open link'))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(focusTerminal).toHaveBeenCalledOnce()
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies the dismissed request so a newer request can survive', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const onClose = vi.fn()
|
||||
const request: TerminalLinkActionRequest = {
|
||||
paneId: 1,
|
||||
anchorX: 100,
|
||||
anchorY: 200,
|
||||
destination: 'https://example.com',
|
||||
kind: 'url',
|
||||
primary: { label: 'Open link', run: vi.fn() },
|
||||
focusTerminal: vi.fn()
|
||||
}
|
||||
|
||||
render(<TerminalLinkActionPopover request={request} onClose={onClose} />)
|
||||
fireEvent.click(screen.getByTestId('dismiss-popover'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledWith(request)
|
||||
})
|
||||
|
||||
it('uses distinct icons for system and Orca browser actions', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const request: TerminalLinkActionRequest = {
|
||||
paneId: 1,
|
||||
anchorX: 100,
|
||||
anchorY: 200,
|
||||
destination: 'https://example.com',
|
||||
kind: 'url',
|
||||
primary: { external: false, label: 'Orca Browser', run: vi.fn() },
|
||||
alternate: { external: true, label: 'System Browser', run: vi.fn() },
|
||||
focusTerminal: vi.fn()
|
||||
}
|
||||
|
||||
render(<TerminalLinkActionPopover request={request} onClose={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByText('Orca Browser').closest('button')?.querySelector('.lucide-globe')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('System Browser').closest('button')?.querySelector('.lucide-external-link')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the terminal link setting from the compact settings button', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const onClose = vi.fn()
|
||||
const focusTerminal = vi.fn()
|
||||
const request: TerminalLinkActionRequest = {
|
||||
paneId: 1,
|
||||
anchorX: 100,
|
||||
anchorY: 200,
|
||||
destination: 'https://example.com',
|
||||
kind: 'url',
|
||||
primary: { label: 'System Browser', run: vi.fn() },
|
||||
focusTerminal
|
||||
}
|
||||
|
||||
render(<TerminalLinkActionPopover request={request} onClose={onClose} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Terminal link settings' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSettingsTarget).toHaveBeenCalledWith({
|
||||
pane: 'browser',
|
||||
repoId: null,
|
||||
sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID
|
||||
})
|
||||
expect(mocks.openSettingsPage).toHaveBeenCalledOnce()
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ExternalLink, Globe, Settings } from 'lucide-react'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { TerminalLinkAction, TerminalLinkActionRequest } from './terminal-link-action-request'
|
||||
|
||||
type TerminalLinkActionPopoverProps = {
|
||||
request: TerminalLinkActionRequest | null
|
||||
onClose: (dismissed?: TerminalLinkActionRequest) => void
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
action,
|
||||
alternate,
|
||||
onRun
|
||||
}: {
|
||||
action: TerminalLinkAction
|
||||
alternate: boolean
|
||||
onRun: () => void
|
||||
}): React.JSX.Element {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const keys = alternate
|
||||
? [isMac ? '⇧' : 'Shift', isMac ? '⌘' : 'Ctrl', 'Click']
|
||||
: [isMac ? '⌘' : 'Ctrl', 'Click']
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="h-8 w-full justify-start gap-1.5 px-1.5 text-[13px] font-normal has-[>svg]:px-1.5"
|
||||
variant="ghost"
|
||||
onClick={onRun}
|
||||
>
|
||||
{action.external === true ? <ExternalLink className="size-3.5" /> : null}
|
||||
{action.external === false ? <Globe className="size-3.5" /> : null}
|
||||
<span className="min-w-0 flex-1 text-left">{action.label}</span>
|
||||
<ShortcutKeyCombo keys={keys} keyCapClassName="min-w-5 px-1 py-0 text-[11px]" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TerminalLinkActionPopover({
|
||||
request,
|
||||
onClose
|
||||
}: TerminalLinkActionPopoverProps): React.JSX.Element {
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const virtualRef = useMemo(
|
||||
() => ({
|
||||
current: {
|
||||
getBoundingClientRect: () => new DOMRect(request?.anchorX ?? 0, request?.anchorY ?? 0, 0, 0)
|
||||
}
|
||||
}),
|
||||
[request?.anchorX, request?.anchorY]
|
||||
)
|
||||
|
||||
const runAction = (action: TerminalLinkAction): void => {
|
||||
onClose()
|
||||
request?.focusTerminal()
|
||||
void action.run()
|
||||
}
|
||||
|
||||
const settingsLabel = translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.terminalLinkSettings',
|
||||
'Terminal link settings'
|
||||
)
|
||||
|
||||
const openTerminalLinkSettings = (): void => {
|
||||
onClose()
|
||||
openSettingsTarget({
|
||||
pane: 'browser',
|
||||
repoId: null,
|
||||
sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID
|
||||
})
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={request !== null}
|
||||
onOpenChange={(open) => !open && onClose(request ?? undefined)}
|
||||
>
|
||||
<PopoverAnchor virtualRef={virtualRef} />
|
||||
{request ? (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
collisionPadding={8}
|
||||
className="w-max min-w-52 max-w-[min(17rem,calc(100vw-1rem))] p-1"
|
||||
data-terminal-link-action-popover
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={() => request.focusTerminal()}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1 overflow-hidden border-b border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
<span
|
||||
className="line-clamp-2 min-w-0 flex-1 break-all"
|
||||
data-terminal-link-destination
|
||||
title={request.destination}
|
||||
>
|
||||
{request.destination}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={settingsLabel}
|
||||
className="text-muted-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={openTerminalLinkSettings}
|
||||
>
|
||||
<Settings />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{settingsLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ActionRow
|
||||
action={request.primary}
|
||||
alternate={false}
|
||||
onRun={() => runAction(request.primary)}
|
||||
/>
|
||||
{request.alternate ? (
|
||||
<ActionRow
|
||||
action={request.alternate}
|
||||
alternate
|
||||
onRun={() => runAction(request.alternate!)}
|
||||
/>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
) : null}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -93,6 +93,11 @@ import {
|
||||
import { useSystemPrefersDark } from './use-system-prefers-dark'
|
||||
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
|
||||
import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle'
|
||||
import { TerminalLinkActionPopover } from './TerminalLinkActionPopover'
|
||||
import {
|
||||
closeTerminalLinkActionRequest,
|
||||
type TerminalLinkActionRequest
|
||||
} from './terminal-link-action-request'
|
||||
import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu'
|
||||
import {
|
||||
detachTerminalPaneToTab,
|
||||
@@ -390,6 +395,14 @@ function TerminalPane(
|
||||
const [paneCount, setPaneCount] = useState<number>(0)
|
||||
// Why: pane reorders can move panes without changing count or size, so overlay rects need an explicit layout-change render trigger.
|
||||
const [paneLayoutRevision, setPaneLayoutRevision] = useState(0)
|
||||
const [terminalLinkActionRequest, setTerminalLinkActionRequest] =
|
||||
useState<TerminalLinkActionRequest | null>(null)
|
||||
const requestTerminalLinkAction = useCallback((request: TerminalLinkActionRequest) => {
|
||||
setTerminalLinkActionRequest(request)
|
||||
}, [])
|
||||
const closeTerminalLinkActions = useCallback((dismissed?: TerminalLinkActionRequest) => {
|
||||
setTerminalLinkActionRequest((current) => closeTerminalLinkActionRequest(current, dismissed))
|
||||
}, [])
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const searchOpenRef = useRef(false)
|
||||
searchOpenRef.current = searchOpen
|
||||
@@ -1382,6 +1395,7 @@ function TerminalPane(
|
||||
settings,
|
||||
settingsRef,
|
||||
requestOpenLinksInAppPreference,
|
||||
requestTerminalLinkAction,
|
||||
effectiveMacOptionAsAlt,
|
||||
effectiveMacOptionAsAltRef: macOptionAsAltRef,
|
||||
initialLayoutRef,
|
||||
@@ -1434,6 +1448,10 @@ function TerminalPane(
|
||||
onExternalPaneDrop: handleExternalPaneDrop
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
closeTerminalLinkActions()
|
||||
}, [closeTerminalLinkActions, isActive, isRendererVisible, paneLayoutRevision])
|
||||
|
||||
useEffect(() => {
|
||||
const manager = managerRef.current
|
||||
if (!manager || !restoredLayout.root) {
|
||||
@@ -3112,6 +3130,10 @@ function TerminalPane(
|
||||
onCopyTerminalId={() => void contextMenu.onCopyTerminalId()}
|
||||
onCopyPaneId={contextMenu.onCopyPaneId}
|
||||
/>
|
||||
<TerminalLinkActionPopover
|
||||
request={terminalLinkActionRequest}
|
||||
onClose={closeTerminalLinkActions}
|
||||
/>
|
||||
{/* Why: repos is a broad store slice; only subscribe while the editor is visible. */}
|
||||
{quickCommandEditorOpen ? (
|
||||
<TerminalQuickCommandEditorDialog
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLinkActionContext } from './terminal-link-action-request'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canOpenWithSystemDefault: true,
|
||||
openDetectedFilePath: vi.fn(),
|
||||
worktreeRoot: false
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-file-open-routing', () => ({
|
||||
getTerminalFileContext: () => ({}),
|
||||
mapTerminalFilePath: (filePath: string) => filePath,
|
||||
openDetectedFilePath: mocks.openDetectedFilePath,
|
||||
shouldOpenTerminalFileWithSystemDefault: () => mocks.canOpenWithSystemDefault,
|
||||
terminalLinkWslDistro: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-worktree-path-link', () => ({
|
||||
resolveKnownWorktreeRootPathLink: () => (mocks.worktreeRoot ? { id: 'wt-2' } : null)
|
||||
}))
|
||||
|
||||
import { handleTerminalFileLink } from './terminal-file-link-actions'
|
||||
|
||||
const deps = { worktreeId: 'wt-1', worktreePath: '/repo' }
|
||||
|
||||
function plainEvent(): MouseEvent {
|
||||
return {
|
||||
button: 0,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
clientX: 12,
|
||||
clientY: 24,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as MouseEvent
|
||||
}
|
||||
|
||||
function context(request: ReturnType<typeof vi.fn>): TerminalLinkActionContext {
|
||||
return {
|
||||
paneId: 3,
|
||||
pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
|
||||
request: request as TerminalLinkActionContext['request'],
|
||||
focusTerminal: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
mocks.canOpenWithSystemDefault = true
|
||||
mocks.worktreeRoot = false
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('terminal file link actions', () => {
|
||||
it('offers Orca and system-default actions for a local file', () => {
|
||||
const request = vi.fn()
|
||||
expect(
|
||||
handleTerminalFileLink('/repo/src/main.ts', 12, 4, plainEvent(), deps, context(request))
|
||||
).toBe(true)
|
||||
|
||||
const actionRequest = request.mock.calls[0][0]
|
||||
expect(actionRequest).toEqual(
|
||||
expect.objectContaining({
|
||||
destination: '/repo/src/main.ts',
|
||||
kind: 'file',
|
||||
primary: expect.objectContaining({ label: 'Open file' }),
|
||||
alternate: expect.objectContaining({ label: 'Open with default app' })
|
||||
})
|
||||
)
|
||||
actionRequest.primary.run()
|
||||
actionRequest.alternate.run()
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(1, '/repo/src/main.ts', 12, 4, deps)
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(2, '/repo/src/main.ts', 12, 4, {
|
||||
...deps,
|
||||
openWithSystemDefault: true
|
||||
})
|
||||
})
|
||||
|
||||
it('labels workspace switching and omits an impossible remote alternate', () => {
|
||||
mocks.worktreeRoot = true
|
||||
mocks.canOpenWithSystemDefault = false
|
||||
const request = vi.fn()
|
||||
handleTerminalFileLink('/repo', null, null, plainEvent(), deps, context(request))
|
||||
|
||||
const actionRequest = request.mock.calls[0][0]
|
||||
expect(actionRequest).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'workspace',
|
||||
primary: expect.objectContaining({ label: 'Switch workspace' })
|
||||
})
|
||||
)
|
||||
expect(actionRequest).not.toHaveProperty('alternate')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
getTerminalFileContext,
|
||||
mapTerminalFilePath,
|
||||
openDetectedFilePath,
|
||||
shouldOpenTerminalFileWithSystemDefault,
|
||||
terminalLinkWslDistro
|
||||
} from './terminal-file-open-routing'
|
||||
import { isTerminalLinkDirectActivation } from './terminal-link-activation'
|
||||
import {
|
||||
requestTerminalLinkAction,
|
||||
type TerminalLinkActionContext
|
||||
} from './terminal-link-action-request'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type TerminalFileLinkActionDeps = {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
wslDistro?: string | null
|
||||
}
|
||||
|
||||
export function handleTerminalFileLink(
|
||||
filePath: string,
|
||||
line: number | null,
|
||||
column: number | null,
|
||||
event: MouseEvent | undefined,
|
||||
deps: TerminalFileLinkActionDeps,
|
||||
actionContext?: TerminalLinkActionContext | null,
|
||||
actionDestination?: string
|
||||
): boolean {
|
||||
if (isTerminalLinkDirectActivation(event)) {
|
||||
event?.preventDefault?.()
|
||||
openDetectedFilePath(filePath, line, column, {
|
||||
...deps,
|
||||
openWithSystemDefault: Boolean(event?.shiftKey)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const mappedPath = mapTerminalFilePath(
|
||||
filePath,
|
||||
deps.worktreePath,
|
||||
terminalLinkWslDistro(deps.wslDistro, deps.runtimeEnvironmentId)
|
||||
)
|
||||
const fileContext = getTerminalFileContext(
|
||||
deps.worktreeId,
|
||||
deps.worktreePath,
|
||||
deps.runtimeEnvironmentId
|
||||
)
|
||||
const worktreeRoot = resolveKnownWorktreeRootPathLink(mappedPath)
|
||||
const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, mappedPath)
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
return requestTerminalLinkAction(event, actionContext, {
|
||||
destination: actionDestination ?? mappedPath,
|
||||
kind: worktreeRoot ? 'workspace' : 'file',
|
||||
primary: {
|
||||
label: worktreeRoot
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.switchWorkspace',
|
||||
'Switch workspace'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openFile',
|
||||
'Open file'
|
||||
),
|
||||
run: () => openDetectedFilePath(filePath, line, column, deps)
|
||||
},
|
||||
...(canOpenWithSystemDefault
|
||||
? {
|
||||
alternate: {
|
||||
label: worktreeRoot
|
||||
? isMac
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder',
|
||||
'Open in Finder'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder',
|
||||
'Open folder'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp',
|
||||
'Open with default app'
|
||||
),
|
||||
run: () =>
|
||||
openDetectedFilePath(filePath, line, column, {
|
||||
...deps,
|
||||
openWithSystemDefault: true
|
||||
})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
findTerminalHandleTarget,
|
||||
focusRendererTerminalHandle
|
||||
} from './terminal-handle-links'
|
||||
import type { TerminalLinkActionContext } from './terminal-link-action-request'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateTabAndFocusPane: vi.fn(),
|
||||
@@ -84,7 +85,8 @@ function setPlatform(userAgent: string): void {
|
||||
async function collectLinks(
|
||||
rows: TestBufferLine[],
|
||||
bufferLineNumber = 1,
|
||||
runtimeEnvironmentId: string | null = null
|
||||
runtimeEnvironmentId: string | null = null,
|
||||
linkActionContext?: TerminalLinkActionContext
|
||||
): Promise<ILink[]> {
|
||||
const terminal = {
|
||||
buffer: {
|
||||
@@ -97,7 +99,8 @@ async function collectLinks(
|
||||
const provider = createTerminalHandleLinkProvider({
|
||||
getTerminal: () => terminal as never,
|
||||
getRuntimeEnvironmentId: () => runtimeEnvironmentId,
|
||||
linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement
|
||||
linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement,
|
||||
getLinkActionContext: () => linkActionContext ?? null
|
||||
})
|
||||
return await new Promise<ILink[]>((resolve) => {
|
||||
provider.provideLinks(bufferLineNumber, (links) => resolve(links ?? []))
|
||||
@@ -303,6 +306,39 @@ describe('createTerminalHandleLinkProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defers a plain terminal-handle click to the action popover', async () => {
|
||||
const request = vi.fn()
|
||||
const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, null, {
|
||||
paneId: 4,
|
||||
pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
|
||||
request,
|
||||
focusTerminal: vi.fn()
|
||||
})
|
||||
|
||||
links[0].activate(
|
||||
{
|
||||
button: 0,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as MouseEvent,
|
||||
links[0].text
|
||||
)
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
destination: 'term_worker',
|
||||
kind: 'terminal',
|
||||
primary: expect.objectContaining({ label: 'Switch terminal' })
|
||||
})
|
||||
)
|
||||
expect(window.api.runtime.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the owning runtime for terminal links when a renderer match belongs to another runtime', async () => {
|
||||
markRuntimeEnvironmentCompatible('env-1')
|
||||
mocks.storeState.tabsByWorktree = {
|
||||
|
||||
@@ -11,6 +11,15 @@ import {
|
||||
focusRuntimeOrchestrationTask,
|
||||
ORCHESTRATION_TASK_PREFIX
|
||||
} from './terminal-orchestration-task-links'
|
||||
import {
|
||||
isTerminalLinkActionActivation,
|
||||
isTerminalLinkDirectActivation
|
||||
} from './terminal-link-activation'
|
||||
import {
|
||||
requestTerminalLinkAction,
|
||||
type TerminalLinkActionContext
|
||||
} from './terminal-link-action-request'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export { extractOrchestrationTaskLinks } from './terminal-orchestration-task-links'
|
||||
export type { ParsedOrchestrationTaskLink } from './terminal-orchestration-task-links'
|
||||
@@ -36,6 +45,7 @@ type TerminalHandleLinkProviderDeps = {
|
||||
getTerminal: () => Terminal | null
|
||||
getRuntimeEnvironmentId: () => string | null
|
||||
linkTooltip: HTMLElement
|
||||
getLinkActionContext?: () => TerminalLinkActionContext | null
|
||||
}
|
||||
|
||||
const TERMINAL_HANDLE_PREFIX = 'term_'
|
||||
@@ -188,15 +198,44 @@ export function createTerminalHandleLinkProvider(
|
||||
range,
|
||||
text: parsed.text,
|
||||
activate: (event) => {
|
||||
if (!isTerminalHandleLinkActivation(event)) {
|
||||
const directActivation = isTerminalLinkDirectActivation(event)
|
||||
const actionActivation = isTerminalLinkActionActivation(event)
|
||||
if (!directActivation && !actionActivation) {
|
||||
return
|
||||
}
|
||||
event?.preventDefault()
|
||||
void activateParsedLink(parsed, deps.getRuntimeEnvironmentId())
|
||||
terminal.clearSelection()
|
||||
let handled = false
|
||||
if (directActivation) {
|
||||
event?.preventDefault()
|
||||
void activateParsedLink(parsed, deps.getRuntimeEnvironmentId())
|
||||
handled = true
|
||||
} else {
|
||||
handled = requestTerminalLinkAction(event, deps.getLinkActionContext?.(), {
|
||||
destination: parsed.text,
|
||||
kind: parsed.kind,
|
||||
primary: {
|
||||
label:
|
||||
parsed.kind === 'terminal'
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.switchTerminal',
|
||||
'Switch terminal'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openTaskTerminal',
|
||||
'Open task terminal'
|
||||
),
|
||||
run: () => activateParsedLink(parsed, deps.getRuntimeEnvironmentId())
|
||||
}
|
||||
})
|
||||
}
|
||||
if (handled) {
|
||||
terminal.clearSelection()
|
||||
}
|
||||
},
|
||||
hover: () => {
|
||||
deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint()})`
|
||||
const showActions = deps.getLinkActionContext
|
||||
? deps.getLinkActionContext() !== null
|
||||
: true
|
||||
deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint(showActions)})`
|
||||
deps.linkTooltip.style.display = ''
|
||||
},
|
||||
leave: () => {
|
||||
@@ -252,17 +291,11 @@ function ptyIdMatchesTerminalHandle(
|
||||
return ptyEnvironmentId === targetEnvironmentId
|
||||
}
|
||||
|
||||
function getTerminalHandleFocusHint(): string {
|
||||
function getTerminalHandleFocusHint(showActions: boolean): string {
|
||||
const prefix = showActions ? 'Click for actions or ' : ''
|
||||
return navigator.userAgent.includes('Mac')
|
||||
? '⌘+click to switch terminal'
|
||||
: 'Ctrl+click to switch terminal'
|
||||
}
|
||||
|
||||
function isTerminalHandleLinkActivation(
|
||||
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey'> | undefined
|
||||
): boolean {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
|
||||
? `${prefix}⌘+click to switch terminal`
|
||||
: `${prefix}Ctrl+click to switch terminal`
|
||||
}
|
||||
|
||||
async function focusRuntimeTerminalHandle(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { isTerminalLinkActivation } from './terminal-link-activation'
|
||||
import { isTerminalLinkDirectActivation } from './terminal-link-activation'
|
||||
|
||||
export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean {
|
||||
// Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY,
|
||||
// so plain HTTP link handling must leave those gestures to the child TUI.
|
||||
return Boolean(event && !event.altKey && isTerminalLinkActivation(event))
|
||||
return isTerminalLinkDirectActivation(event)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TerminalLinkPointerGesture } from './terminal-link-pointer-gesture'
|
||||
import { isTerminalLinkActionActivation } from './terminal-link-activation'
|
||||
|
||||
export type TerminalLinkActionKind = 'url' | 'file' | 'workspace' | 'terminal' | 'task'
|
||||
|
||||
export type TerminalLinkAction = {
|
||||
external?: boolean
|
||||
label: string
|
||||
run: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export type TerminalLinkActionRequest = {
|
||||
paneId: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
destination: string
|
||||
kind: TerminalLinkActionKind
|
||||
primary: TerminalLinkAction
|
||||
alternate?: TerminalLinkAction
|
||||
focusTerminal: () => void
|
||||
}
|
||||
|
||||
export type TerminalLinkActionRequester = (request: TerminalLinkActionRequest) => void
|
||||
|
||||
export type TerminalLinkActionContext = {
|
||||
paneId: number
|
||||
pointerGesture: TerminalLinkPointerGesture
|
||||
request: TerminalLinkActionRequester
|
||||
focusTerminal: () => void
|
||||
}
|
||||
|
||||
export function closeTerminalLinkActionRequest(
|
||||
current: TerminalLinkActionRequest | null,
|
||||
dismissed?: TerminalLinkActionRequest
|
||||
): TerminalLinkActionRequest | null {
|
||||
return dismissed && current !== dismissed ? current : null
|
||||
}
|
||||
|
||||
type LinkActionDetails = Pick<
|
||||
TerminalLinkActionRequest,
|
||||
'destination' | 'kind' | 'primary' | 'alternate'
|
||||
>
|
||||
|
||||
export function requestTerminalLinkAction(
|
||||
event: MouseEvent | undefined,
|
||||
context: TerminalLinkActionContext | null | undefined,
|
||||
details: LinkActionDetails
|
||||
): boolean {
|
||||
if (
|
||||
!event ||
|
||||
!context ||
|
||||
!isTerminalLinkActionActivation(event) ||
|
||||
!context.pointerGesture.canRequestAction(event)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
context.request({
|
||||
...details,
|
||||
paneId: context.paneId,
|
||||
anchorX: event.clientX,
|
||||
anchorY: event.clientY,
|
||||
focusTerminal: context.focusTerminal
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
|
||||
import {
|
||||
closeTerminalLinkActionRequest,
|
||||
type TerminalLinkActionContext,
|
||||
type TerminalLinkActionRequest
|
||||
} from './terminal-link-action-request'
|
||||
import { handleOscLink } from './terminal-osc-link-routing'
|
||||
import { handleTerminalHttpLink } from './terminal-url-link-hit-testing'
|
||||
|
||||
const openUrl = vi.fn()
|
||||
const createBrowserTab = vi.fn()
|
||||
const setActiveWorktree = vi.fn()
|
||||
|
||||
function plainEvent(): MouseEvent {
|
||||
return {
|
||||
button: 0,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as MouseEvent
|
||||
}
|
||||
|
||||
function actionContext(request = vi.fn()): TerminalLinkActionContext {
|
||||
return {
|
||||
paneId: 7,
|
||||
pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
|
||||
request,
|
||||
focusTerminal: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
vi.stubGlobal('window', { api: { shell: { openUrl } } })
|
||||
registerHttpLinkStoreAccessor(() => ({
|
||||
settings: { openLinksInApp: false },
|
||||
setActiveWorktree,
|
||||
createBrowserTab
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('terminal link action routing', () => {
|
||||
it('keeps a replacement request when the previous popover dismisses', () => {
|
||||
const first = { destination: 'https://first.example' } as TerminalLinkActionRequest
|
||||
const second = { destination: 'https://second.example' } as TerminalLinkActionRequest
|
||||
|
||||
expect(closeTerminalLinkActionRequest(second, first)).toBe(second)
|
||||
expect(closeTerminalLinkActionRequest(first, first)).toBeNull()
|
||||
})
|
||||
|
||||
it('offers system browser first and Orca second when system browser is the default', () => {
|
||||
const request = vi.fn()
|
||||
const event = plainEvent()
|
||||
|
||||
expect(
|
||||
handleTerminalHttpLink('https://example.com/path', event, {
|
||||
worktreeId: 'wt-1',
|
||||
linkActionContext: actionContext(request),
|
||||
actionDestinations: { primary: 'system', alternate: 'orca' }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(openUrl).not.toHaveBeenCalled()
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paneId: 7,
|
||||
anchorX: 40,
|
||||
anchorY: 60,
|
||||
destination: 'https://example.com/path',
|
||||
kind: 'url'
|
||||
})
|
||||
)
|
||||
expect(request.mock.calls[0][0].primary.label).toBe('System Browser')
|
||||
expect(request.mock.calls[0][0].primary.external).toBe(true)
|
||||
expect(request.mock.calls[0][0].alternate.label).toBe('Orca Browser')
|
||||
expect(request.mock.calls[0][0].alternate.external).toBe(false)
|
||||
|
||||
request.mock.calls[0][0].primary.run()
|
||||
expect(openUrl).toHaveBeenCalledWith('https://example.com/path')
|
||||
|
||||
request.mock.calls[0][0].alternate.run()
|
||||
expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
|
||||
activate: true
|
||||
})
|
||||
})
|
||||
|
||||
it('offers Orca first and system browser second when Orca is the default', () => {
|
||||
const request = vi.fn()
|
||||
|
||||
handleTerminalHttpLink('https://example.com/path', plainEvent(), {
|
||||
worktreeId: 'wt-1',
|
||||
linkActionContext: actionContext(request),
|
||||
actionDestinations: { primary: 'orca', alternate: 'system' }
|
||||
})
|
||||
|
||||
expect(request.mock.calls[0][0].primary.label).toBe('Orca Browser')
|
||||
expect(request.mock.calls[0][0].primary.external).toBe(false)
|
||||
expect(request.mock.calls[0][0].alternate.label).toBe('System Browser')
|
||||
expect(request.mock.calls[0][0].alternate.external).toBe(true)
|
||||
|
||||
request.mock.calls[0][0].primary.run()
|
||||
expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
|
||||
activate: true
|
||||
})
|
||||
|
||||
request.mock.calls[0][0].alternate.run()
|
||||
expect(openUrl).toHaveBeenCalledWith('https://example.com/path')
|
||||
})
|
||||
|
||||
it('offers only the system browser for a remote link', () => {
|
||||
const request = vi.fn()
|
||||
|
||||
handleTerminalHttpLink('https://example.com/path', plainEvent(), {
|
||||
worktreeId: 'wt-1',
|
||||
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
linkActionContext: actionContext(request),
|
||||
actionDestinations: { primary: 'system' }
|
||||
})
|
||||
|
||||
expect(request.mock.calls[0][0].primary.label).toBe('System Browser')
|
||||
expect(request.mock.calls[0][0].primary.external).toBe(true)
|
||||
expect(request.mock.calls[0][0].alternate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses Shift+modifier for the alternate local destination', () => {
|
||||
const event = { ...plainEvent(), metaKey: true, shiftKey: true }
|
||||
|
||||
handleTerminalHttpLink('https://example.com/path', event, {
|
||||
worktreeId: 'wt-1',
|
||||
actionDestinations: { primary: 'system', alternate: 'orca' }
|
||||
})
|
||||
|
||||
expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
|
||||
activate: true
|
||||
})
|
||||
expect(openUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes the actual hidden OSC 8 destination', () => {
|
||||
const request = vi.fn()
|
||||
const destination = 'https://example.com'
|
||||
|
||||
expect(
|
||||
handleOscLink(destination, plainEvent(), {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
startupCwd: '/repo',
|
||||
linkActionContext: actionContext(request)
|
||||
})
|
||||
).toBe(true)
|
||||
expect(request.mock.calls[0][0].destination).toBe(destination)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isTerminalLinkActionActivation,
|
||||
isTerminalLinkDirectActivation,
|
||||
isTerminalOwnedLinkGesture
|
||||
} from './terminal-link-activation'
|
||||
|
||||
function event(overrides: Partial<MouseEvent> = {}): MouseEvent {
|
||||
return {
|
||||
button: 0,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
...overrides
|
||||
} as MouseEvent
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('terminal link activation', () => {
|
||||
it('uses plain click for actions and Command-click for direct open on macOS', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
|
||||
expect(isTerminalLinkActionActivation(event())).toBe(true)
|
||||
expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(true)
|
||||
expect(isTerminalOwnedLinkGesture(event())).toBe(true)
|
||||
})
|
||||
|
||||
it('reserves macOS Control-click for the context menu', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
|
||||
expect(isTerminalLinkActionActivation(event({ ctrlKey: true }))).toBe(false)
|
||||
expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('uses Ctrl-click for direct open on Windows and Linux', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows' })
|
||||
|
||||
expect(isTerminalLinkActionActivation(event())).toBe(true)
|
||||
expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(true)
|
||||
expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves Shift-only, Alt, and non-primary gestures unowned', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
|
||||
expect(isTerminalOwnedLinkGesture(event({ shiftKey: true }))).toBe(false)
|
||||
expect(isTerminalOwnedLinkGesture(event({ altKey: true }))).toBe(false)
|
||||
expect(isTerminalOwnedLinkGesture(event({ button: 2 }))).toBe(false)
|
||||
expect(isTerminalLinkDirectActivation(event({ metaKey: true, shiftKey: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -5,3 +5,30 @@ export function isTerminalLinkActivation(
|
||||
): boolean {
|
||||
return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
|
||||
}
|
||||
|
||||
type TerminalLinkMouseEvent = Pick<MouseEvent, 'ctrlKey' | 'metaKey'> &
|
||||
Partial<Pick<MouseEvent, 'altKey' | 'button' | 'shiftKey'>>
|
||||
|
||||
export function isTerminalLinkDirectActivation(event: TerminalLinkMouseEvent | undefined): boolean {
|
||||
return Boolean(
|
||||
event &&
|
||||
(event.button === undefined || event.button === 0) &&
|
||||
!event.altKey &&
|
||||
isTerminalLinkActivation(event)
|
||||
)
|
||||
}
|
||||
|
||||
export function isTerminalLinkActionActivation(event: TerminalLinkMouseEvent | undefined): boolean {
|
||||
return Boolean(
|
||||
event &&
|
||||
(event.button === undefined || event.button === 0) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey
|
||||
)
|
||||
}
|
||||
|
||||
export function isTerminalOwnedLinkGesture(event: TerminalLinkMouseEvent | undefined): boolean {
|
||||
return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event)
|
||||
}
|
||||
|
||||
@@ -505,17 +505,36 @@ describe('handleOscLink', () => {
|
||||
|
||||
it('advertises the system default open behavior in hover hints', () => {
|
||||
setPlatform('Macintosh')
|
||||
expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app')
|
||||
expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser')
|
||||
expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser')
|
||||
|
||||
setPlatform('Windows')
|
||||
expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app')
|
||||
expect(getTerminalFileOpenHint()).toBe(
|
||||
'Click for actions, ⌘+click to open, or ⇧⌘+click for default app'
|
||||
)
|
||||
expect(getTerminalHtmlFileOpenHint()).toBe(
|
||||
'Ctrl+click to open or Shift+Ctrl+click for default browser'
|
||||
'Click for actions, ⌘+click to open, or ⇧⌘+click for default browser'
|
||||
)
|
||||
expect(getTerminalUrlOpenHint()).toBe(
|
||||
'Ctrl+click to open or Shift+Ctrl+click for system browser'
|
||||
'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser'
|
||||
)
|
||||
|
||||
setPlatform('Windows')
|
||||
expect(getTerminalFileOpenHint()).toBe(
|
||||
'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default app'
|
||||
)
|
||||
expect(getTerminalHtmlFileOpenHint()).toBe(
|
||||
'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default browser'
|
||||
)
|
||||
expect(getTerminalUrlOpenHint()).toBe(
|
||||
'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for system browser'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits plain-click actions from hover hints when the popover is disabled', () => {
|
||||
setPlatform('Macintosh')
|
||||
expect(getTerminalFileOpenHint(false)).toBe('⌘+click to open, or ⇧⌘+click for default app')
|
||||
expect(getTerminalHtmlFileOpenHint(false)).toBe(
|
||||
'⌘+click to open, or ⇧⌘+click for default browser'
|
||||
)
|
||||
expect(getTerminalUrlOpenHint({ showActions: false })).toBe(
|
||||
'⌘+click to open, or ⇧⌘+click for system browser'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1383,7 +1402,7 @@ describe('createFilePathLinkProvider range bounds', () => {
|
||||
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
|
||||
|
||||
expect(linkTooltip.textContent).toBe(
|
||||
'/repo/CLAUDE.md (⌘+click to open or ⇧⌘+click for default app)'
|
||||
'/repo/CLAUDE.md (Click for actions, ⌘+click to open, or ⇧⌘+click for default app)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1426,7 +1445,7 @@ describe('createFilePathLinkProvider range bounds', () => {
|
||||
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
|
||||
|
||||
expect(linkTooltip.textContent).toBe(
|
||||
'/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
|
||||
'/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1448,7 +1467,7 @@ describe('createFilePathLinkProvider range bounds', () => {
|
||||
|
||||
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
|
||||
expect(linkTooltip.textContent).toBe(
|
||||
'/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
|
||||
'/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1481,7 +1500,7 @@ describe('createFilePathLinkProvider range bounds', () => {
|
||||
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
|
||||
|
||||
expect(linkTooltip.textContent).toBe(
|
||||
'/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
|
||||
'/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1499,7 +1518,9 @@ describe('createFilePathLinkProvider range bounds', () => {
|
||||
expect(links[0]).toBeDefined()
|
||||
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
|
||||
|
||||
expect(linkTooltip.textContent).toBe('/repo (Ctrl+click to switch workspace)')
|
||||
expect(linkTooltip.textContent).toBe(
|
||||
'/repo (Click for actions or Ctrl+click to switch workspace)'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the Orca hint for SSH file link hover', async () => {
|
||||
@@ -1513,7 +1534,9 @@ describe('createFilePathLinkProvider range bounds', () => {
|
||||
expect(links[0]).toBeDefined()
|
||||
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
|
||||
|
||||
expect(linkTooltip.textContent).toBe('/repo/CLAUDE.md (⌘+click to open in Orca)')
|
||||
expect(linkTooltip.textContent).toBe(
|
||||
'/repo/CLAUDE.md (Click for actions or ⌘+click to open in Orca)'
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds the terminal path-exists cache while preserving recent probes', async () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
getTerminalFileContext,
|
||||
isHtmlFilePath,
|
||||
mapTerminalFilePath,
|
||||
openDetectedFilePath,
|
||||
shouldOpenTerminalFileWithSystemDefault,
|
||||
terminalLinkWslDistro
|
||||
} from './terminal-file-open-routing'
|
||||
@@ -38,8 +37,10 @@ import {
|
||||
getTerminalUrlOpenHint
|
||||
} from './terminal-link-open-hints'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
import { isTerminalLinkActivation } from './terminal-link-activation'
|
||||
import { isTerminalLinkDirectActivation } from './terminal-link-activation'
|
||||
import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position'
|
||||
import type { TerminalLinkActionContext } from './terminal-link-action-request'
|
||||
import { handleTerminalFileLink } from './terminal-file-link-actions'
|
||||
|
||||
export { openDetectedFilePath } from './terminal-file-open-routing'
|
||||
export { mapTerminalFilePath } from './terminal-file-open-routing'
|
||||
@@ -59,6 +60,7 @@ export type LinkHandlerDeps = {
|
||||
terminalHomePath?: string | null
|
||||
wslDistro?: string | null
|
||||
getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null
|
||||
getLinkActionContext?: (paneId: number) => TerminalLinkActionContext | null
|
||||
}
|
||||
|
||||
type ProvidedFileLink = {
|
||||
@@ -186,16 +188,23 @@ export function createFilePathLinkProvider(
|
||||
range,
|
||||
text: parsed.displayText,
|
||||
activate: (event) => {
|
||||
if (!isTerminalLinkActivation(event)) {
|
||||
return
|
||||
if (
|
||||
handleTerminalFileLink(
|
||||
mappedPath,
|
||||
resolved.line,
|
||||
resolved.column,
|
||||
event,
|
||||
{
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro
|
||||
},
|
||||
deps.getLinkActionContext?.(paneId)
|
||||
)
|
||||
) {
|
||||
pane.terminal.clearSelection?.()
|
||||
}
|
||||
openDetectedFilePath(mappedPath, resolved.line, resolved.column, {
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro,
|
||||
openWithSystemDefault: Boolean(event.shiftKey)
|
||||
})
|
||||
},
|
||||
hover: () => {
|
||||
// Why: only local paths can offer the Shift+modifier system
|
||||
@@ -204,13 +213,18 @@ export function createFilePathLinkProvider(
|
||||
fileContext,
|
||||
mappedPath
|
||||
)
|
||||
const showActions = deps.getLinkActionContext
|
||||
? deps.getLinkActionContext(paneId) !== null
|
||||
: true
|
||||
const hint = worktreeRootLink
|
||||
? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault)
|
||||
? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault, showActions)
|
||||
: canOpenWithSystemDefault
|
||||
? isHtmlFilePath(mappedPath)
|
||||
? getTerminalHtmlFileOpenHint()
|
||||
: openLinkHint
|
||||
: getTerminalOrcaFileOpenHint()
|
||||
? getTerminalHtmlFileOpenHint(showActions)
|
||||
: showActions
|
||||
? openLinkHint
|
||||
: getTerminalFileOpenHint(false)
|
||||
: getTerminalOrcaFileOpenHint(showActions)
|
||||
linkTooltip.textContent = `${mappedPath} (${hint})`
|
||||
linkTooltip.style.display = ''
|
||||
},
|
||||
@@ -262,7 +276,7 @@ export function installFilePathLinkClickFallback(
|
||||
): IDisposable {
|
||||
const mouseUpListenerOptions = { capture: true }
|
||||
const handleMouseUp = (event: MouseEvent): void => {
|
||||
if (event.button !== 0 || !isTerminalLinkActivation(event)) {
|
||||
if (!isTerminalLinkDirectActivation(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ afterEach(() => {
|
||||
describe('getTerminalUrlOpenHint', () => {
|
||||
it('keeps the system-browser wording by default', () => {
|
||||
stubPlatform(true)
|
||||
expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser')
|
||||
expect(getTerminalUrlOpenHint()).toBe(
|
||||
'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the system-browser wording when inverting is off', () => {
|
||||
@@ -34,16 +36,27 @@ describe('getTerminalUrlOpenHint', () => {
|
||||
it('names Orca when inverting and links open externally', () => {
|
||||
stubPlatform(true)
|
||||
expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe(
|
||||
'⌘+click to open or ⇧⌘+click to open in Orca'
|
||||
'Click for actions, ⌘+click to open, or ⇧⌘+click to open in Orca'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the Ctrl chord off macOS', () => {
|
||||
stubPlatform(false)
|
||||
expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe(
|
||||
'Ctrl+click to open or Shift+Ctrl+click to open in Orca'
|
||||
'Click for actions, Ctrl+click to open, or Shift+Ctrl+click to open in Orca'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the action-menu gesture when terminal link actions are disabled', () => {
|
||||
stubPlatform(false)
|
||||
expect(
|
||||
getTerminalUrlOpenHint({
|
||||
openLinksInApp: false,
|
||||
modifierInverts: true,
|
||||
showActions: false
|
||||
})
|
||||
).toBe('Ctrl+click to open, or Shift+Ctrl+click to open in Orca')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminalUrlOpenHintOptionsFor', () => {
|
||||
|
||||
@@ -4,27 +4,36 @@ export function isMacPlatform(): boolean {
|
||||
return navigator.userAgent.includes('Mac')
|
||||
}
|
||||
|
||||
export function getTerminalFileOpenHint(): string {
|
||||
return isMacPlatform()
|
||||
? '⌘+click to open or ⇧⌘+click for default app'
|
||||
: 'Ctrl+click to open or Shift+Ctrl+click for default app'
|
||||
function terminalLinkActionHintPrefix(showActions: boolean): string {
|
||||
return showActions ? 'Click for actions, ' : ''
|
||||
}
|
||||
|
||||
export function getTerminalOrcaFileOpenHint(): string {
|
||||
return isMacPlatform() ? '⌘+click to open in Orca' : 'Ctrl+click to open in Orca'
|
||||
export function getTerminalFileOpenHint(showActions = true): string {
|
||||
const prefix = terminalLinkActionHintPrefix(showActions)
|
||||
return isMacPlatform()
|
||||
? `${prefix}⌘+click to open, or ⇧⌘+click for default app`
|
||||
: `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default app`
|
||||
}
|
||||
|
||||
// Why: detected local .html/.htm file paths keep the same modifier gate as
|
||||
// other file-path links, with Shift+modifier as the system-browser escape hatch.
|
||||
export function getTerminalHtmlFileOpenHint(): string {
|
||||
export function getTerminalOrcaFileOpenHint(showActions = true): string {
|
||||
const prefix = showActions ? 'Click for actions or ' : ''
|
||||
return isMacPlatform()
|
||||
? '⌘+click to open or ⇧⌘+click for default browser'
|
||||
: 'Ctrl+click to open or Shift+Ctrl+click for default browser'
|
||||
? `${prefix}⌘+click to open in Orca`
|
||||
: `${prefix}Ctrl+click to open in Orca`
|
||||
}
|
||||
|
||||
// Why: local HTML paths keep Shift+modifier as the system-browser shortcut.
|
||||
export function getTerminalHtmlFileOpenHint(showActions = true): string {
|
||||
const prefix = terminalLinkActionHintPrefix(showActions)
|
||||
return isMacPlatform()
|
||||
? `${prefix}⌘+click to open, or ⇧⌘+click for default browser`
|
||||
: `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default browser`
|
||||
}
|
||||
|
||||
export type TerminalUrlOpenHintOptions = {
|
||||
openLinksInApp?: boolean
|
||||
modifierInverts?: boolean
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
// Why: openHttpLink only routes to Orca when the source is local, so a remote pane
|
||||
@@ -55,14 +64,15 @@ export function terminalUrlOpenHintOptionsFor(
|
||||
// it means "the other one" — so the hint has to name the actual destination.
|
||||
export function getTerminalUrlOpenHint(options: TerminalUrlOpenHintOptions = {}): string {
|
||||
const invertsToOrca = options.modifierInverts === true && options.openLinksInApp !== true
|
||||
const prefix = terminalLinkActionHintPrefix(options.showActions !== false)
|
||||
if (invertsToOrca) {
|
||||
return isMacPlatform()
|
||||
? '⌘+click to open or ⇧⌘+click to open in Orca'
|
||||
: 'Ctrl+click to open or Shift+Ctrl+click to open in Orca'
|
||||
? `${prefix}⌘+click to open, or ⇧⌘+click to open in Orca`
|
||||
: `${prefix}Ctrl+click to open, or Shift+Ctrl+click to open in Orca`
|
||||
}
|
||||
return isMacPlatform()
|
||||
? '⌘+click to open or ⇧⌘+click for system browser'
|
||||
: 'Ctrl+click to open or Shift+Ctrl+click for system browser'
|
||||
? `${prefix}⌘+click to open, or ⇧⌘+click for system browser`
|
||||
: `${prefix}Ctrl+click to open, or Shift+Ctrl+click for system browser`
|
||||
}
|
||||
|
||||
export function getTerminalUrlSystemBrowserHint(): string {
|
||||
@@ -75,12 +85,19 @@ export function getTerminalUrlOrcaBrowserHint(): string {
|
||||
return isMacPlatform() ? '⇧⌘+click to open in Orca' : 'Shift+Ctrl+click to open in Orca'
|
||||
}
|
||||
|
||||
export function getTerminalWorktreePathOpenHint(canOpenWithSystemDefault: boolean): string {
|
||||
export function getTerminalWorktreePathOpenHint(
|
||||
canOpenWithSystemDefault: boolean,
|
||||
showActions = true
|
||||
): string {
|
||||
const prefix = terminalLinkActionHintPrefix(showActions)
|
||||
if (!canOpenWithSystemDefault) {
|
||||
return isMacPlatform() ? '⌘+click to switch workspace' : 'Ctrl+click to switch workspace'
|
||||
const directPrefix = showActions ? 'Click for actions or ' : ''
|
||||
return isMacPlatform()
|
||||
? `${directPrefix}⌘+click to switch workspace`
|
||||
: `${directPrefix}Ctrl+click to switch workspace`
|
||||
}
|
||||
|
||||
return isMacPlatform()
|
||||
? '⌘+click to switch workspace or ⇧⌘+click to open in Finder'
|
||||
: 'Ctrl+click to switch workspace or Shift+Ctrl+click to open folder'
|
||||
? `${prefix}⌘+click to switch workspace, or ⇧⌘+click to open in Finder`
|
||||
: `${prefix}Ctrl+click to switch workspace, or Shift+Ctrl+click to open folder`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture'
|
||||
|
||||
const activeGestures = new Set<ReturnType<typeof installTerminalLinkPointerGesture>>()
|
||||
|
||||
function createGesture(): {
|
||||
gesture: ReturnType<typeof installTerminalLinkPointerGesture>
|
||||
element: HTMLDivElement
|
||||
setSelection: (selected: boolean) => void
|
||||
} {
|
||||
let selected = false
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
const terminal = {
|
||||
element,
|
||||
hasSelection: () => selected
|
||||
} as unknown as Terminal
|
||||
const gesture = installTerminalLinkPointerGesture(terminal)
|
||||
activeGestures.add(gesture)
|
||||
return {
|
||||
gesture,
|
||||
element,
|
||||
setSelection: (next) => {
|
||||
selected = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mouse(type: string, x: number, y: number): MouseEvent {
|
||||
return new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const gesture of activeGestures) {
|
||||
gesture.dispose()
|
||||
}
|
||||
activeGestures.clear()
|
||||
document.body.replaceChildren()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('terminal link pointer gesture', () => {
|
||||
it('allows a stationary plain click', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const { element, gesture } = createGesture()
|
||||
const down = mouse('mousedown', 20, 30)
|
||||
element.dispatchEvent(down)
|
||||
|
||||
expect(gesture.canRequestAction(down)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a drag and a click that began with a selection', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const dragged = createGesture()
|
||||
const dragDown = mouse('mousedown', 10, 10)
|
||||
dragged.element.dispatchEvent(dragDown)
|
||||
document.dispatchEvent(mouse('mousemove', 20, 10))
|
||||
expect(dragged.gesture.canRequestAction(dragDown)).toBe(false)
|
||||
|
||||
const selected = createGesture()
|
||||
selected.setSelection(true)
|
||||
const selectionDown = mouse('mousedown', 10, 10)
|
||||
selected.element.dispatchEvent(selectionDown)
|
||||
selected.setSelection(false)
|
||||
expect(selected.gesture.canRequestAction(selectionDown)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a selection created during the gesture and clears on blur', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const { element, gesture, setSelection } = createGesture()
|
||||
const down = mouse('mousedown', 10, 10)
|
||||
element.dispatchEvent(down)
|
||||
setSelection(true)
|
||||
expect(gesture.canRequestAction(down)).toBe(false)
|
||||
|
||||
setSelection(false)
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
expect(gesture.canRequestAction(down)).toBe(false)
|
||||
})
|
||||
|
||||
it('removes its listeners on dispose', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const { element, gesture } = createGesture()
|
||||
gesture.dispose()
|
||||
const down = mouse('mousedown', 10, 10)
|
||||
|
||||
element.dispatchEvent(down)
|
||||
|
||||
expect(gesture.canRequestAction(down)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { IDisposable, Terminal } from '@xterm/xterm'
|
||||
import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
|
||||
|
||||
export type TerminalLinkPointerGesture = IDisposable & {
|
||||
canRequestAction: (event: MouseEvent) => boolean
|
||||
}
|
||||
|
||||
type PendingGesture = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
hadSelection: boolean
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export function installTerminalLinkPointerGesture(terminal: Terminal): TerminalLinkPointerGesture {
|
||||
const terminalElement = terminal.element
|
||||
const ownerDocument = terminalElement?.ownerDocument
|
||||
const ownerWindow = ownerDocument?.defaultView
|
||||
let pending: PendingGesture | null = null
|
||||
|
||||
const clear = (): void => {
|
||||
pending = null
|
||||
}
|
||||
const handleMouseDown = (event: MouseEvent): void => {
|
||||
if (!isTerminalOwnedLinkGesture(event)) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
pending = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
hadSelection: terminal.hasSelection(),
|
||||
moved: false
|
||||
}
|
||||
}
|
||||
const handleMouseMove = (event: MouseEvent): void => {
|
||||
if (
|
||||
pending &&
|
||||
Math.hypot(event.clientX - pending.clientX, event.clientY - pending.clientY) >
|
||||
DRAG_THRESHOLD_PX
|
||||
) {
|
||||
pending.moved = true
|
||||
}
|
||||
}
|
||||
const handleMouseUp = (): void => {
|
||||
queueMicrotask(clear)
|
||||
}
|
||||
|
||||
terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
|
||||
ownerDocument?.addEventListener('mousemove', handleMouseMove)
|
||||
ownerDocument?.addEventListener('mouseup', handleMouseUp)
|
||||
ownerWindow?.addEventListener('blur', clear)
|
||||
|
||||
return {
|
||||
canRequestAction: () =>
|
||||
Boolean(pending && !pending.moved && !pending.hadSelection && !terminal.hasSelection()),
|
||||
dispose: () => {
|
||||
clear()
|
||||
terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
|
||||
ownerDocument?.removeEventListener('mousemove', handleMouseMove)
|
||||
ownerDocument?.removeEventListener('mouseup', handleMouseUp)
|
||||
ownerWindow?.removeEventListener('blur', clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IDisposable, Terminal } from '@xterm/xterm'
|
||||
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
|
||||
import { isTerminalLinkDirectActivation } from './terminal-link-activation'
|
||||
|
||||
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
|
||||
|
||||
@@ -31,17 +31,12 @@ export function installTerminalLinkPtyMouseSuppression(
|
||||
queueMicrotask(restore)
|
||||
}
|
||||
const handleMouseDown = (event: MouseEvent): void => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
!isTerminalHttpLinkActivation(event) ||
|
||||
!shouldSuppressMouseEvent(event)
|
||||
) {
|
||||
if (!isTerminalLinkDirectActivation(event) || !shouldSuppressMouseEvent(event)) {
|
||||
return
|
||||
}
|
||||
restore()
|
||||
previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt)
|
||||
// Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware
|
||||
// TUI, letting the terminal and the child process both open the URL.
|
||||
// Why: an Orca-owned link gesture must not also reach a mouse-aware child TUI.
|
||||
terminal.options.mouseEventsRequireAlt = true
|
||||
ownerDocument?.addEventListener('mouseup', queueRestore)
|
||||
ownerWindow?.addEventListener('blur', restore)
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('installTerminalLinkifierClickPriming', () => {
|
||||
expect(linkifier._activeLine).toBe(5)
|
||||
})
|
||||
|
||||
it('ignores plain clicks and degrades safely when xterm internals are unavailable', () => {
|
||||
it('primes plain action clicks and degrades safely when xterm internals are unavailable', () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
const handleMouseMove = vi.fn()
|
||||
const present = createTerminal({ _handleMouseMove: handleMouseMove })
|
||||
@@ -136,7 +136,7 @@ describe('installTerminalLinkifierClickPriming', () => {
|
||||
expect(() =>
|
||||
getMouseDownHandler(absent.registrations)(modifierMouseDown({ metaKey: true }))
|
||||
).not.toThrow()
|
||||
expect(handleMouseMove).not.toHaveBeenCalled()
|
||||
expect(handleMouseMove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('removes its capture listener on dispose', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IDisposable, Terminal } from '@xterm/xterm'
|
||||
import { isTerminalLinkActivation } from './terminal-link-activation'
|
||||
import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
|
||||
|
||||
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
|
||||
|
||||
@@ -39,7 +39,7 @@ function primeTerminalLinkifier(terminal: Terminal, event: MouseEvent): void {
|
||||
export function installTerminalLinkifierClickPriming(terminal: Terminal): IDisposable {
|
||||
const terminalElement = terminal.element
|
||||
const handleMouseDown = (event: MouseEvent): void => {
|
||||
if (event.button !== 0 || !isTerminalLinkActivation(event)) {
|
||||
if (!isTerminalOwnedLinkGesture(event)) {
|
||||
return
|
||||
}
|
||||
// Why: xterm snapshots its current link on mousedown but otherwise resolves
|
||||
|
||||
@@ -2,16 +2,32 @@ import { resolveTerminalFileLinkText } from '@/lib/terminal-links'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import type { LinkHandlerDeps } from './terminal-link-handlers'
|
||||
import { resolveTerminalFileUrlTarget } from '../../../../shared/terminal-file-url-target'
|
||||
import { openDetectedFilePath } from './terminal-file-open-routing'
|
||||
import { isTerminalLinkActivation } from './terminal-link-activation'
|
||||
import {
|
||||
openTerminalHttpLink,
|
||||
isTerminalLinkActionActivation,
|
||||
isTerminalLinkDirectActivation
|
||||
} from './terminal-link-activation'
|
||||
import {
|
||||
handleTerminalHttpLink,
|
||||
type TerminalHttpLinkActionDestinations,
|
||||
type TerminalLinkRoutingPreferenceRequester
|
||||
} from './terminal-url-link-hit-testing'
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import type { TerminalLinkActionContext } from './terminal-link-action-request'
|
||||
import { handleTerminalFileLink } from './terminal-file-link-actions'
|
||||
|
||||
type TerminalLinkEvent = Pick<MouseEvent, 'metaKey' | 'ctrlKey'> &
|
||||
Partial<Pick<MouseEvent, 'button' | 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
|
||||
Partial<
|
||||
Pick<
|
||||
MouseEvent,
|
||||
| 'altKey'
|
||||
| 'button'
|
||||
| 'clientX'
|
||||
| 'clientY'
|
||||
| 'shiftKey'
|
||||
| 'preventDefault'
|
||||
| 'stopPropagation'
|
||||
>
|
||||
>
|
||||
|
||||
function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean {
|
||||
if (!event) {
|
||||
@@ -22,7 +38,7 @@ function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boole
|
||||
}
|
||||
// Why: desktop xterm links must not open while the user is just placing the
|
||||
// cursor or selecting text. Mobile URL taps use a separate WebView path.
|
||||
return isTerminalLinkActivation(event)
|
||||
return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event)
|
||||
}
|
||||
|
||||
export function handleOscLink(
|
||||
@@ -37,20 +53,20 @@ export function handleOscLink(
|
||||
> & {
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
linkActionContext?: TerminalLinkActionContext | null
|
||||
actionDestinations?: TerminalHttpLinkActionDestinations
|
||||
}
|
||||
): boolean {
|
||||
if (!isDesktopOscLinkActivation(event)) {
|
||||
return false
|
||||
}
|
||||
// Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress
|
||||
// default anchor navigation so link-routing settings can choose the target.
|
||||
// Note: we intentionally do NOT stopPropagation here — xterm's
|
||||
// SelectionService listens for mouseup on ownerDocument to clear the
|
||||
// pending drag-select state initiated by the mousedown of the same click.
|
||||
// Stopping propagation leaves SelectionService's mousemove/mouseup handlers
|
||||
// attached, so returning focus to the terminal and moving the mouse (even
|
||||
// without holding a button) extends a selection until the next click/Esc.
|
||||
event?.preventDefault?.()
|
||||
const finish = (handled: boolean): boolean => {
|
||||
if (handled) {
|
||||
// Why: prevent anchor navigation without blocking xterm's document-level selection cleanup.
|
||||
event?.preventDefault?.()
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
const openDetectedPathLink = (): boolean => {
|
||||
const resolved = resolveTerminalFileLinkText(
|
||||
@@ -61,11 +77,17 @@ export function handleOscLink(
|
||||
if (!resolved) {
|
||||
return false
|
||||
}
|
||||
openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, {
|
||||
...deps,
|
||||
openWithSystemDefault: Boolean(event?.shiftKey)
|
||||
})
|
||||
return true
|
||||
return finish(
|
||||
handleTerminalFileLink(
|
||||
resolved.absolutePath,
|
||||
resolved.line,
|
||||
resolved.column,
|
||||
event as MouseEvent,
|
||||
deps,
|
||||
deps.linkActionContext,
|
||||
rawText
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -86,17 +108,20 @@ export function handleOscLink(
|
||||
}
|
||||
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
openTerminalHttpLink(parsed.toString(), {
|
||||
worktreeId: deps.worktreeId,
|
||||
sourceOwner:
|
||||
deps.sourceOwner ??
|
||||
(deps.runtimeEnvironmentId
|
||||
? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
|
||||
: { kind: 'local' }),
|
||||
modifierHeld: Boolean(event?.shiftKey),
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
|
||||
})
|
||||
return true
|
||||
return finish(
|
||||
handleTerminalHttpLink(parsed.toString(), event as MouseEvent, {
|
||||
worktreeId: deps.worktreeId,
|
||||
sourceOwner:
|
||||
deps.sourceOwner ??
|
||||
(deps.runtimeEnvironmentId
|
||||
? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
|
||||
: { kind: 'local' }),
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference,
|
||||
linkActionContext: deps.linkActionContext,
|
||||
actionDestinations: deps.actionDestinations,
|
||||
actionDestination: rawText
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (parsed.protocol === 'file:') {
|
||||
@@ -113,11 +138,17 @@ export function handleOscLink(
|
||||
if (!resolved) {
|
||||
return false
|
||||
}
|
||||
openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, {
|
||||
...deps,
|
||||
openWithSystemDefault: Boolean(event?.shiftKey)
|
||||
})
|
||||
return true
|
||||
return finish(
|
||||
handleTerminalFileLink(
|
||||
resolved.filePath,
|
||||
resolved.line,
|
||||
resolved.column,
|
||||
event as MouseEvent,
|
||||
deps,
|
||||
deps.linkActionContext,
|
||||
rawText
|
||||
)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -134,14 +134,17 @@ function makeTerminal(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent {
|
||||
function mouseEventForRow(
|
||||
row: number,
|
||||
options: { altKey?: boolean; plain?: boolean } = {}
|
||||
): MouseEvent {
|
||||
let defaultPrevented = false
|
||||
return {
|
||||
button: 0,
|
||||
metaKey: true,
|
||||
metaKey: !options.plain,
|
||||
ctrlKey: false,
|
||||
altKey: options.altKey ?? false,
|
||||
shiftKey: true,
|
||||
shiftKey: !options.plain,
|
||||
get defaultPrevented() {
|
||||
return defaultPrevented
|
||||
},
|
||||
@@ -482,4 +485,51 @@ describe('hard-wrapped terminal HTTP clicks', () => {
|
||||
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('owns a plain HTTP link click without opening until an action is chosen', () => {
|
||||
const request = vi.fn()
|
||||
const { terminal, registrations } = makeTerminal()
|
||||
const disposable = installHttpLinkClickFallback(terminal, {
|
||||
worktreeId: 'wt-1',
|
||||
getLinkActionContext: () => ({
|
||||
paneId: 1,
|
||||
pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
|
||||
request,
|
||||
focusTerminal: vi.fn()
|
||||
})
|
||||
})
|
||||
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
|
||||
const mouseUp = registrations.find(
|
||||
([name, _listener, options]) => name === 'mouseup' && options === undefined
|
||||
)?.[1]
|
||||
const event = mouseEventForRow(0, { plain: true })
|
||||
|
||||
mouseDown!(event)
|
||||
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
|
||||
mouseUp!(event)
|
||||
|
||||
expect(request).toHaveBeenCalledWith(expect.objectContaining({ kind: 'url' }))
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('does not suppress a plain HTTP link when action popovers are disabled', () => {
|
||||
const { terminal, registrations } = makeTerminal()
|
||||
const disposable = installHttpLinkClickFallback(terminal, {
|
||||
worktreeId: 'wt-1',
|
||||
getLinkActionContext: () => null
|
||||
})
|
||||
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
|
||||
const mouseUp = registrations.find(
|
||||
([name, _listener, options]) => name === 'mouseup' && options === undefined
|
||||
)?.[1]
|
||||
const event = mouseEventForRow(0, { plain: true })
|
||||
|
||||
mouseDown!(event)
|
||||
mouseUp!(event)
|
||||
|
||||
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
disposable.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,12 @@ import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-
|
||||
import { extractTerminalHttpLinks } from './terminal-http-url-extraction'
|
||||
import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
|
||||
import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
|
||||
import {
|
||||
requestTerminalLinkAction,
|
||||
type TerminalLinkActionContext
|
||||
} from './terminal-link-action-request'
|
||||
|
||||
export { extractTerminalHttpLinks } from './terminal-http-url-extraction'
|
||||
export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
|
||||
@@ -18,6 +24,10 @@ type UrlLinkHitTestDeps = {
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
modifierHeld?: boolean
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
linkActionContext?: TerminalLinkActionContext | null
|
||||
actionDestinations?: TerminalHttpLinkActionDestinations
|
||||
actionDestination?: string
|
||||
forceDestination?: TerminalHttpLinkDestination
|
||||
}
|
||||
|
||||
type UrlLinkClickFallbackDeps = {
|
||||
@@ -25,6 +35,15 @@ type UrlLinkClickFallbackDeps = {
|
||||
/** Resolved per click: the pane's PTY (and its runtime binding) may not exist at install time. */
|
||||
getSourceOwner?: () => HttpLinkSourceOwner
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
getLinkActionContext?: () => TerminalLinkActionContext | null
|
||||
getActionDestinations?: () => TerminalHttpLinkActionDestinations
|
||||
}
|
||||
|
||||
export type TerminalHttpLinkDestination = 'orca' | 'system'
|
||||
|
||||
export type TerminalHttpLinkActionDestinations = {
|
||||
primary: TerminalHttpLinkDestination
|
||||
alternate?: TerminalHttpLinkDestination
|
||||
}
|
||||
|
||||
export type TerminalLinkRoutingPreferenceRequester = (
|
||||
@@ -35,10 +54,73 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean {
|
||||
if (event.defaultPrevented || event.button !== 0) {
|
||||
return false
|
||||
}
|
||||
// Why: desktop terminal links require an intentional Cmd/Ctrl gesture so
|
||||
// plain clicks remain available for cursor placement and selection. Mobile
|
||||
// tap routing is handled separately under mobile/src/terminal.
|
||||
return isTerminalHttpLinkActivation(event)
|
||||
// Why: Shift-only, Alt, and non-primary clicks remain available to the terminal or child TUI.
|
||||
return isTerminalOwnedLinkGesture(event)
|
||||
}
|
||||
|
||||
export function handleTerminalHttpLink(
|
||||
url: string,
|
||||
event: MouseEvent | undefined,
|
||||
deps: UrlLinkHitTestDeps
|
||||
): boolean {
|
||||
if (isTerminalHttpLinkActivation(event)) {
|
||||
const forceDestination = event?.shiftKey
|
||||
? deps.actionDestinations?.alternate
|
||||
: deps.actionDestinations?.primary
|
||||
openTerminalHttpLink(url, {
|
||||
...deps,
|
||||
modifierHeld: forceDestination ? false : Boolean(event?.shiftKey),
|
||||
forceDestination
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const actionDestinations = deps.actionDestinations
|
||||
const primaryDestination = actionDestinations?.primary
|
||||
const labelForDestination = (destination: TerminalHttpLinkDestination): string =>
|
||||
destination === 'orca'
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.orcaBrowser',
|
||||
'Orca Browser'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.systemBrowser',
|
||||
'System Browser'
|
||||
)
|
||||
|
||||
return requestTerminalLinkAction(event, deps.linkActionContext, {
|
||||
destination: deps.actionDestination ?? url,
|
||||
kind: 'url',
|
||||
primary: {
|
||||
external: primaryDestination === 'system',
|
||||
label: primaryDestination
|
||||
? labelForDestination(primaryDestination)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalLinkActionPopover.openLink',
|
||||
'Open link'
|
||||
),
|
||||
run: () =>
|
||||
openTerminalHttpLink(url, {
|
||||
...deps,
|
||||
modifierHeld: false,
|
||||
forceDestination: primaryDestination
|
||||
})
|
||||
},
|
||||
...(actionDestinations?.alternate
|
||||
? {
|
||||
alternate: {
|
||||
external: actionDestinations.alternate === 'system',
|
||||
label: labelForDestination(actionDestinations.alternate),
|
||||
run: () =>
|
||||
openTerminalHttpLink(url, {
|
||||
...deps,
|
||||
modifierHeld: false,
|
||||
forceDestination: actionDestinations.alternate
|
||||
})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
export function openHttpLinkAtTerminalMouseEvent(
|
||||
@@ -56,6 +138,19 @@ export function openHttpLinkAtTerminalMouseEvent(
|
||||
return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps)
|
||||
}
|
||||
|
||||
export function findHttpLinkAtTerminalMouseEvent(
|
||||
terminal: Terminal,
|
||||
event: MouseEvent
|
||||
): string | null {
|
||||
if (event.button !== 0 || !isTerminalOwnedLinkGesture(event)) {
|
||||
return null
|
||||
}
|
||||
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
|
||||
return position
|
||||
? findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)
|
||||
: null
|
||||
}
|
||||
|
||||
export function installHttpLinkClickFallback(
|
||||
terminal: Terminal,
|
||||
deps: UrlLinkClickFallbackDeps
|
||||
@@ -74,16 +169,19 @@ export function installHttpLinkClickFallback(
|
||||
return
|
||||
}
|
||||
|
||||
// Why: xterm's WebLinksAddon only activates after hover state exists. This
|
||||
// direct mouseup fallback preserves modifier-clicks when the hover link was
|
||||
// never established, while defaultPrevented avoids duplicate opens.
|
||||
const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, {
|
||||
worktreeId: deps.worktreeId,
|
||||
sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' },
|
||||
modifierHeld: event.shiftKey,
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
|
||||
})
|
||||
if (opened) {
|
||||
// Why: xterm's WebLinksAddon misses first clicks before hover state exists.
|
||||
const url = findHttpLinkAtTerminalMouseEvent(terminal, event)
|
||||
const handled = Boolean(
|
||||
url &&
|
||||
handleTerminalHttpLink(url, event, {
|
||||
worktreeId: deps.worktreeId,
|
||||
sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' },
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference,
|
||||
linkActionContext: deps.getLinkActionContext?.(),
|
||||
actionDestinations: deps.getActionDestinations?.()
|
||||
})
|
||||
)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
terminal.clearSelection()
|
||||
}
|
||||
@@ -161,6 +259,15 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi
|
||||
// Why: Orca browser tabs are local-only, so a link clicked in a runtime-hosted
|
||||
// pane must be classified by its pane's host, not the global active runtime.
|
||||
const sourceOwner = deps.sourceOwner ?? { kind: 'local' }
|
||||
if (deps.forceDestination) {
|
||||
openHttpLink(url, {
|
||||
worktreeId: deps.worktreeId,
|
||||
forceInApp: deps.forceDestination === 'orca',
|
||||
forceSystemBrowser: deps.forceDestination === 'system',
|
||||
sourceOwner
|
||||
})
|
||||
return
|
||||
}
|
||||
if (deps.modifierHeld) {
|
||||
// Why: the modifier states a destination outright, so it also skips the
|
||||
// one-time routing prompt; openHttpLink resolves which destination it means.
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import type { LinkHandlerDeps } from './terminal-link-handlers'
|
||||
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
|
||||
import { isTerminalOwnedLinkGesture } from './terminal-link-activation'
|
||||
import { handleOscLink } from './terminal-osc-link-routing'
|
||||
import {
|
||||
openHttpLinkAtTerminalMouseEvent,
|
||||
findHttpLinkAtTerminalMouseEvent,
|
||||
handleTerminalHttpLink,
|
||||
type TerminalHttpLinkActionDestinations,
|
||||
type TerminalLinkRoutingPreferenceRequester
|
||||
} from './terminal-url-link-hit-testing'
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import type { TerminalLinkActionContext } from './terminal-link-action-request'
|
||||
|
||||
type TerminalWebLinkClickDeps = Pick<
|
||||
LinkHandlerDeps,
|
||||
@@ -15,6 +18,8 @@ type TerminalWebLinkClickDeps = Pick<
|
||||
terminal: Terminal | null
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
linkActionContext?: TerminalLinkActionContext | null
|
||||
actionDestinations?: TerminalHttpLinkActionDestinations
|
||||
}
|
||||
|
||||
export function handleTerminalWebLinkClick(
|
||||
@@ -22,28 +27,27 @@ export function handleTerminalWebLinkClick(
|
||||
event: MouseEvent | undefined,
|
||||
deps: TerminalWebLinkClickDeps
|
||||
): boolean {
|
||||
if (!event || !isTerminalHttpLinkActivation(event)) {
|
||||
if (!event || !isTerminalOwnedLinkGesture(event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let handled: boolean
|
||||
if (
|
||||
deps.terminal &&
|
||||
openHttpLinkAtTerminalMouseEvent(deps.terminal, event, {
|
||||
const completeUrl = deps.terminal ? findHttpLinkAtTerminalMouseEvent(deps.terminal, event) : null
|
||||
if (completeUrl) {
|
||||
handled = handleTerminalHttpLink(completeUrl, event, {
|
||||
worktreeId: deps.worktreeId,
|
||||
sourceOwner:
|
||||
deps.sourceOwner ??
|
||||
(deps.runtimeEnvironmentId
|
||||
? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
|
||||
: { kind: 'local' }),
|
||||
modifierHeld: Boolean(event.shiftKey),
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference,
|
||||
linkActionContext: deps.linkActionContext,
|
||||
actionDestinations: deps.actionDestinations
|
||||
})
|
||||
) {
|
||||
// Why: WebLinksAddon only knows the physical row; Orca's logical hit-test
|
||||
// preserves the complete URL rendered across hard-wrapped TUI rows.
|
||||
event.preventDefault()
|
||||
handled = true
|
||||
} else {
|
||||
handled = handleOscLink(url, event, deps)
|
||||
}
|
||||
|
||||
@@ -39,9 +39,15 @@ import { handleOscLink } from './terminal-osc-link-routing'
|
||||
import { handleTerminalWebLinkClick } from './terminal-web-link-click'
|
||||
import {
|
||||
installHttpLinkClickFallback,
|
||||
type TerminalHttpLinkActionDestinations,
|
||||
type TerminalLinkRoutingPreferenceRequester
|
||||
} from './terminal-url-link-hit-testing'
|
||||
import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming'
|
||||
import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture'
|
||||
import type {
|
||||
TerminalLinkActionContext,
|
||||
TerminalLinkActionRequester
|
||||
} from './terminal-link-action-request'
|
||||
import {
|
||||
resolveLocalhostHttpLinkDisplayUrl,
|
||||
type HttpLinkSourceOwner
|
||||
@@ -255,6 +261,7 @@ type UseTerminalPaneLifecycleDeps = {
|
||||
settings: GlobalSettings | null | undefined
|
||||
settingsRef: React.RefObject<GlobalSettings | null | undefined>
|
||||
requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester
|
||||
requestTerminalLinkAction: TerminalLinkActionRequester
|
||||
/** Resolved Option-as-Alt: `'auto'` already mapped to `'true'|'false'` via the layout probe, which lives outside the settings store. */
|
||||
effectiveMacOptionAsAlt: EffectiveMacOptionAsAlt
|
||||
effectiveMacOptionAsAltRef: React.RefObject<EffectiveMacOptionAsAlt>
|
||||
@@ -594,6 +601,7 @@ export function useTerminalPaneLifecycle({
|
||||
settings,
|
||||
settingsRef,
|
||||
requestOpenLinksInAppPreference,
|
||||
requestTerminalLinkAction,
|
||||
effectiveMacOptionAsAlt,
|
||||
effectiveMacOptionAsAltRef,
|
||||
initialLayoutRef,
|
||||
@@ -656,6 +664,9 @@ export function useTerminalPaneLifecycle({
|
||||
const linkProviderDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const terminalHandleLinkDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const linkifierClickPrimingDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const linkPointerGesturesRef = useRef(
|
||||
new Map<number, ReturnType<typeof installTerminalLinkPointerGesture>>()
|
||||
)
|
||||
const fileLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const httpLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
// Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes.
|
||||
@@ -698,6 +709,7 @@ export function useTerminalPaneLifecycle({
|
||||
const linkDisposables = linkProviderDisposablesRef.current
|
||||
const terminalHandleLinkDisposables = terminalHandleLinkDisposablesRef.current
|
||||
const linkifierClickPrimingDisposables = linkifierClickPrimingDisposablesRef.current
|
||||
const linkPointerGestures = linkPointerGesturesRef.current
|
||||
const fileLinkClickFallbackDisposables = fileLinkClickFallbackDisposablesRef.current
|
||||
const httpLinkClickFallbackDisposables = httpLinkClickFallbackDisposablesRef.current
|
||||
const selectionDisposables = selectionDisposablesRef.current
|
||||
@@ -728,6 +740,32 @@ export function useTerminalPaneLifecycle({
|
||||
resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd)
|
||||
const getHttpLinkSourceOwnerForPane = (paneId: number) =>
|
||||
resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId))
|
||||
const getHttpLinkActionDestinations = (paneId: number): TerminalHttpLinkActionDestinations => {
|
||||
const sourceOwner = getHttpLinkSourceOwnerForPane(paneId)
|
||||
if (sourceOwner.kind !== 'local') {
|
||||
return { primary: 'system' }
|
||||
}
|
||||
const options = terminalUrlOpenHintOptionsFor(settingsRef.current, sourceOwner)
|
||||
return options.openLinksInApp
|
||||
? { primary: 'orca', alternate: 'system' }
|
||||
: { primary: 'system', alternate: 'orca' }
|
||||
}
|
||||
const getLinkActionContext = (paneId: number): TerminalLinkActionContext | null => {
|
||||
if (settingsRef.current?.terminalLinkActionPopoverEnabled === false) {
|
||||
return null
|
||||
}
|
||||
const pane = managerRef.current?.getPanes().find((candidate) => candidate.id === paneId)
|
||||
const pointerGesture = linkPointerGestures.get(paneId)
|
||||
if (!pane || !pointerGesture) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
paneId,
|
||||
pointerGesture,
|
||||
request: requestTerminalLinkAction,
|
||||
focusTerminal: () => pane.terminal.focus()
|
||||
}
|
||||
}
|
||||
// Why: lifecycle-scoped cache for cross-SSH/runtime existence probes; may hold temporarily stale entries.
|
||||
const pathExistsCache = new Map<string, boolean>()
|
||||
const linkDeps: LinkHandlerDeps = {
|
||||
@@ -743,7 +781,8 @@ export function useTerminalPaneLifecycle({
|
||||
getRuntimeEnvironmentIdForPane: (paneId) => {
|
||||
const sourceOwner = getHttpLinkSourceOwnerForPane(paneId)
|
||||
return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null
|
||||
}
|
||||
},
|
||||
getLinkActionContext
|
||||
}
|
||||
let resizeRaf: number | null = null
|
||||
const queueResizeAll = (focusActive: boolean): void => {
|
||||
@@ -847,9 +886,13 @@ export function useTerminalPaneLifecycle({
|
||||
const fileOpenLinkHint = getTerminalFileOpenHint()
|
||||
// Why: read settingsRef at fire time so toggling link routing applies without recreating panes.
|
||||
const getUrlOpenLinkHint = (paneId: number): string =>
|
||||
getTerminalUrlOpenHint(
|
||||
terminalUrlOpenHintOptionsFor(settingsRef.current, getHttpLinkSourceOwnerForPane(paneId))
|
||||
)
|
||||
getTerminalUrlOpenHint({
|
||||
...terminalUrlOpenHintOptionsFor(
|
||||
settingsRef.current,
|
||||
getHttpLinkSourceOwnerForPane(paneId)
|
||||
),
|
||||
showActions: settingsRef.current?.terminalLinkActionPopoverEnabled !== false
|
||||
})
|
||||
const osc7UncHost = extractUncHost(startupCwd)
|
||||
|
||||
let releaseWebviewDragPassthrough: (() => void) | null = null
|
||||
@@ -1046,6 +1089,8 @@ export function useTerminalPaneLifecycle({
|
||||
return !shouldBypass
|
||||
})
|
||||
|
||||
const linkPointerGesture = installTerminalLinkPointerGesture(pane.terminal)
|
||||
linkPointerGestures.set(pane.id, linkPointerGesture)
|
||||
const linkProviderDisposable = pane.terminal.registerLinkProvider(
|
||||
createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint)
|
||||
)
|
||||
@@ -1057,7 +1102,8 @@ export function useTerminalPaneLifecycle({
|
||||
?.terminal ?? null,
|
||||
getRuntimeEnvironmentId: () =>
|
||||
linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
|
||||
linkTooltip: pane.linkTooltip
|
||||
linkTooltip: pane.linkTooltip,
|
||||
getLinkActionContext: () => getLinkActionContext(pane.id)
|
||||
})
|
||||
)
|
||||
terminalHandleLinkDisposablesRef.current.set(pane.id, terminalHandleLinkDisposable)
|
||||
@@ -1072,7 +1118,9 @@ export function useTerminalPaneLifecycle({
|
||||
const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, {
|
||||
...linkDeps,
|
||||
getSourceOwner: () => getHttpLinkSourceOwnerForPane(pane.id),
|
||||
requestOpenLinksInAppPreference
|
||||
requestOpenLinksInAppPreference,
|
||||
getLinkActionContext: () => getLinkActionContext(pane.id),
|
||||
getActionDestinations: () => getHttpLinkActionDestinations(pane.id)
|
||||
})
|
||||
httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable)
|
||||
seedStartupSessionRestoredBanner(ptyDeps.startup, pane.id, onShowSessionRestoredBanner)
|
||||
@@ -1142,7 +1190,9 @@ export function useTerminalPaneLifecycle({
|
||||
startupCwd: getPaneLinkCwd(pane.id),
|
||||
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
|
||||
sourceOwner: getHttpLinkSourceOwnerForPane(pane.id),
|
||||
requestOpenLinksInAppPreference
|
||||
requestOpenLinksInAppPreference,
|
||||
linkActionContext: getLinkActionContext(pane.id),
|
||||
actionDestinations: getHttpLinkActionDestinations(pane.id)
|
||||
})
|
||||
// Why: link activation can steal focus before the click's mouseup reaches xterm, stranding its drag-select
|
||||
// listener (runaway selection until next click/Esc); clearSelection detaches it (SelectionService._removeMouseDownListeners).
|
||||
@@ -1216,6 +1266,11 @@ export function useTerminalPaneLifecycle({
|
||||
linkifierClickPrimingDisposable.dispose()
|
||||
linkifierClickPrimingDisposablesRef.current.delete(paneId)
|
||||
}
|
||||
const linkPointerGesture = linkPointerGestures.get(paneId)
|
||||
if (linkPointerGesture) {
|
||||
linkPointerGesture.dispose()
|
||||
linkPointerGestures.delete(paneId)
|
||||
}
|
||||
const fileLinkClickFallbackDisposable =
|
||||
fileLinkClickFallbackDisposablesRef.current.get(paneId)
|
||||
if (fileLinkClickFallbackDisposable) {
|
||||
@@ -1459,8 +1514,10 @@ export function useTerminalPaneLifecycle({
|
||||
},
|
||||
terminalTuiScrollSensitivity: () =>
|
||||
normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity),
|
||||
onLinkClick: (event, url) => {
|
||||
const activePane = managerRef.current?.getActivePane()
|
||||
onLinkClick: (paneId, event, url) => {
|
||||
const activePane = managerRef.current
|
||||
?.getPanes()
|
||||
.find((candidate) => candidate.id === paneId)
|
||||
handleTerminalWebLinkClick(url, event, {
|
||||
...linkDeps,
|
||||
terminal: activePane?.terminal ?? null,
|
||||
@@ -1471,7 +1528,9 @@ export function useTerminalPaneLifecycle({
|
||||
sourceOwner: activePane
|
||||
? getHttpLinkSourceOwnerForPane(activePane.id)
|
||||
: { kind: 'local' },
|
||||
requestOpenLinksInAppPreference
|
||||
requestOpenLinksInAppPreference,
|
||||
linkActionContext: getLinkActionContext(paneId),
|
||||
actionDestinations: getHttpLinkActionDestinations(paneId)
|
||||
})
|
||||
},
|
||||
linkOpenHint: getUrlOpenLinkHint,
|
||||
@@ -1713,6 +1772,10 @@ export function useTerminalPaneLifecycle({
|
||||
disposable.dispose()
|
||||
}
|
||||
linkifierClickPrimingDisposables.clear()
|
||||
for (const gesture of linkPointerGestures.values()) {
|
||||
gesture.dispose()
|
||||
}
|
||||
linkPointerGestures.clear()
|
||||
for (const disposable of fileLinkClickFallbackDisposables.values()) {
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
@@ -2839,6 +2839,19 @@
|
||||
"cc5a2dc706": "Edit pane title: {{value0}}",
|
||||
"7dbbfcbecc": "Pane title"
|
||||
},
|
||||
"TerminalLinkActionPopover": {
|
||||
"openLink": "Open link",
|
||||
"openFile": "Open file",
|
||||
"switchWorkspace": "Switch workspace",
|
||||
"openInFinder": "Open in Finder",
|
||||
"openFolder": "Open folder",
|
||||
"openWithDefaultApp": "Open with default app",
|
||||
"switchTerminal": "Switch terminal",
|
||||
"openTaskTerminal": "Open task terminal",
|
||||
"systemBrowser": "System Browser",
|
||||
"orcaBrowser": "Orca Browser",
|
||||
"terminalLinkSettings": "Terminal link settings"
|
||||
},
|
||||
"TerminalSessionStateSaveFailureDialog": {
|
||||
"6bee0c8f17": "Open Disk Space Analyzer",
|
||||
"ae20d0ffc2": "Dismiss",
|
||||
@@ -8110,6 +8123,14 @@
|
||||
"invert": "invert",
|
||||
"opposite": "opposite"
|
||||
},
|
||||
"terminalLinkActions": {
|
||||
"terminal": "terminal",
|
||||
"click": "click",
|
||||
"actions": "actions",
|
||||
"popover": "popover",
|
||||
"menu": "menu",
|
||||
"disable": "disable"
|
||||
},
|
||||
"72c58f7792": "webview",
|
||||
"82ba1c80ea": "localhost",
|
||||
"bea27bac4b": "links",
|
||||
@@ -10023,6 +10044,10 @@
|
||||
"descriptionSystem": "Links open in Orca, so {{chord}}+click sends one to your system browser instead.",
|
||||
"descriptionOrca": "Links open in your system browser. When enabled, {{chord}}+click opens one in Orca's built-in browser instead."
|
||||
},
|
||||
"BrowserTerminalLinkActionsSetting": {
|
||||
"title": "Show terminal link actions",
|
||||
"description": "Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click."
|
||||
},
|
||||
"PluginConsentDialog": {
|
||||
"workerTrust": "Background worker — runs its own process",
|
||||
"instructionalTrust": "Instructional content — runs later under user or agent authority",
|
||||
|
||||
@@ -103,6 +103,34 @@ describe('openHttpLink', () => {
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forceInApp opens a local link in Orca when the setting is off', () => {
|
||||
storeState.settings = { openLinksInApp: false }
|
||||
|
||||
openHttpLink('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
forceInApp: true,
|
||||
sourceOwner: { kind: 'local' }
|
||||
})
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
|
||||
activate: true
|
||||
})
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not force a remote link into the Orca browser', () => {
|
||||
storeState.settings = { openLinksInApp: false }
|
||||
|
||||
openHttpLink('https://example.com/', {
|
||||
worktreeId: 'wt-1',
|
||||
forceInApp: true,
|
||||
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
|
||||
})
|
||||
|
||||
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes to the system browser when a remote runtime environment is active', () => {
|
||||
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' }
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export type OpenHttpLinkOptions = {
|
||||
worktreeId?: string | null
|
||||
/** Unconditional: always use the system browser regardless of settings. */
|
||||
forceSystemBrowser?: boolean
|
||||
/** Unconditional for local sources: open inside Orca regardless of settings. */
|
||||
forceInApp?: boolean
|
||||
/** The Shift escape-hatch modifier was held; resolveModifierRouting decides what it means. */
|
||||
modifierHeld?: boolean
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
@@ -91,7 +93,7 @@ export function resolveModifierRouting(
|
||||
}
|
||||
|
||||
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
|
||||
const { worktreeId, forceSystemBrowser, modifierHeld, sourceOwner } = opts
|
||||
const { worktreeId, forceSystemBrowser, forceInApp, modifierHeld, sourceOwner } = opts
|
||||
if (sourceOwner?.kind === 'unknown') {
|
||||
return
|
||||
}
|
||||
@@ -109,7 +111,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
||||
!forceSystemBrowser &&
|
||||
!modifier.wantsSystemBrowser &&
|
||||
Boolean(worktreeId) &&
|
||||
(openLinksInApp || modifier.wantsOrca)
|
||||
(forceInApp || openLinksInApp || modifier.wantsOrca)
|
||||
|
||||
if (routeToOrca && worktreeId && state) {
|
||||
// Why: http clicks from inside a worktree should not push a worktree-switch
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { createPaneDOM } from './pane-dom-creation'
|
||||
|
||||
const webLinksAddonMock = vi.hoisted(() => ({
|
||||
handler: null as ((event: MouseEvent, uri: string) => void) | null,
|
||||
options: null as { hover?: (event: MouseEvent, uri: string) => void; leave?: () => void } | null
|
||||
}))
|
||||
|
||||
@@ -32,7 +33,8 @@ vi.mock('@xterm/addon-unicode11', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-web-links', () => ({
|
||||
WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(_handler, options) {
|
||||
WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(handler, options) {
|
||||
webLinksAddonMock.handler = handler
|
||||
webLinksAddonMock.options = options
|
||||
return {}
|
||||
})
|
||||
@@ -135,4 +137,23 @@ describe('createPaneDOM link tooltips', () => {
|
||||
expect(linkOpenHint).toHaveBeenCalledWith(7)
|
||||
expect(formatLinkTooltip).toHaveBeenCalledWith(7, 'http://localhost:5180/', 'open hint')
|
||||
})
|
||||
|
||||
it('identifies the clicked pane to link routing', () => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
|
||||
const onLinkClick = vi.fn()
|
||||
createPaneDOM(
|
||||
7,
|
||||
leafId,
|
||||
{ linkOpenHint: () => 'open hint', onLinkClick },
|
||||
{ active: null } as never,
|
||||
{} as never,
|
||||
vi.fn(),
|
||||
vi.fn()
|
||||
)
|
||||
const event = {} as MouseEvent
|
||||
|
||||
webLinksAddonMock.handler?.(event, 'https://example.com')
|
||||
|
||||
expect(onLinkClick).toHaveBeenCalledWith(7, event, 'https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createPaneDOM(
|
||||
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
|
||||
|
||||
const webLinksAddon = new WebLinksAddon(
|
||||
options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
|
||||
options.onLinkClick ? (event, uri) => options.onLinkClick!(id, event, uri) : undefined,
|
||||
{
|
||||
hover: (_event, uri) => {
|
||||
if (uri) {
|
||||
|
||||
@@ -58,7 +58,7 @@ export type PaneManagerOptions = {
|
||||
onExternalPaneDrop?: PaneExternalDropHandler
|
||||
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
|
||||
terminalTuiScrollSensitivity?: () => number | undefined
|
||||
onLinkClick?: (event: MouseEvent | undefined, url: string) => void
|
||||
onLinkClick?: (paneId: number, event: MouseEvent | undefined, url: string) => void
|
||||
/** Resolved per hover so link-routing setting changes apply without recreating panes. */
|
||||
// Why: required so dropping the wiring is a compile error — an optional hint with a
|
||||
// default would silently serve stale copy that no test can distinguish.
|
||||
|
||||
@@ -60,6 +60,7 @@ const SETTINGS_NAV_INTENT_SET: ReadonlySet<string> = new Set(SETTINGS_NAV_INTENT
|
||||
|
||||
export type SettingsNavTarget = (typeof SETTINGS_NAV_TARGETS)[number]
|
||||
export const FULL_DISK_ACCESS_SETTINGS_TARGET_ID = 'developer-permissions-full-disk-access'
|
||||
export const BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID = 'browser-terminal-link-actions'
|
||||
|
||||
export type SettingsNavigationTarget = {
|
||||
pane: SettingsNavTarget
|
||||
|
||||
@@ -69,6 +69,10 @@ describe('getDefaultSettings', () => {
|
||||
expect(getDefaultSettings('/tmp').showMenuBarIcon).toBe(true)
|
||||
})
|
||||
|
||||
it('shows terminal link actions by default', () => {
|
||||
expect(getDefaultSettings('/tmp').terminalLinkActionPopoverEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('confirms before closing pinned tabs by default', () => {
|
||||
expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true)
|
||||
})
|
||||
|
||||
@@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
localhostWorktreeLabelsEnabled: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
openLinksInAppModifierInverts: false,
|
||||
terminalLinkActionPopoverEnabled: true,
|
||||
openAgentTabsInChatByDefault: false,
|
||||
experimentalNativeChat: false,
|
||||
nativeChatSessionOptions: {},
|
||||
|
||||
@@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
|
||||
'editorAutoSave',
|
||||
'openLinksInApp',
|
||||
'openLinksInAppModifierInverts',
|
||||
'terminalLinkActionPopoverEnabled',
|
||||
'experimentalMobile',
|
||||
'experimentalPet',
|
||||
'experimentalNativeChat',
|
||||
|
||||
@@ -2878,6 +2878,8 @@ export type GlobalSettings = {
|
||||
openLinksInAppPreferencePrompted: boolean
|
||||
/** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */
|
||||
openLinksInAppModifierInverts?: boolean
|
||||
/** Show terminal link actions on plain click; off restores modifier-click-only terminal links. */
|
||||
terminalLinkActionPopoverEnabled?: boolean
|
||||
/** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */
|
||||
openAgentTabsInChatByDefault?: boolean
|
||||
/** Experimental native chat surface for Claude/Codex sessions; off by default. */
|
||||
|
||||
Reference in New Issue
Block a user