From ce8c3267b7bc0103f0b22089ebf4cc9f01a2ec63 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:27:01 -0700 Subject: [PATCH] feat(terminal): add anchored link action popovers (#13414) --- .../src/components/settings/BrowserPane.tsx | 16 +- .../BrowserTerminalLinkActionsSetting.tsx | 47 +++++ .../settings/browser-link-routing-copy.ts | 8 + .../settings/browser-search.test.ts | 14 +- .../src/components/settings/browser-search.ts | 43 ++++- .../TerminalLinkActionPopover.test.tsx | 170 ++++++++++++++++++ .../TerminalLinkActionPopover.tsx | 140 +++++++++++++++ .../components/terminal-pane/TerminalPane.tsx | 22 +++ .../terminal-file-link-actions.test.ts | 97 ++++++++++ .../terminal-file-link-actions.ts | 96 ++++++++++ .../terminal-handle-links.test.ts | 40 ++++- .../terminal-pane/terminal-handle-links.ts | 63 +++++-- .../terminal-http-link-activation.ts | 6 +- .../terminal-link-action-request.ts | 67 +++++++ .../terminal-link-action-routing.test.ts | 162 +++++++++++++++++ .../terminal-link-activation.test.ts | 53 ++++++ .../terminal-pane/terminal-link-activation.ts | 27 +++ .../terminal-link-handlers.test.ts | 51 ++++-- .../terminal-pane/terminal-link-handlers.ts | 46 +++-- .../terminal-link-open-hints.test.ts | 19 +- .../terminal-pane/terminal-link-open-hints.ts | 55 ++++-- .../terminal-link-pointer-gesture.test.ts | 93 ++++++++++ .../terminal-link-pointer-gesture.ts | 68 +++++++ .../terminal-link-pty-mouse-suppression.ts | 11 +- .../terminal-linkifier-click-priming.test.ts | 4 +- .../terminal-linkifier-click-priming.ts | 4 +- .../terminal-osc-link-routing.ts | 101 +++++++---- .../terminal-url-link-click.test.ts | 56 +++++- .../terminal-url-link-hit-testing.ts | 135 ++++++++++++-- .../terminal-pane/terminal-web-link-click.ts | 24 +-- .../use-terminal-pane-lifecycle.ts | 83 +++++++-- src/renderer/src/i18n/locales/en.json | 25 +++ .../src/lib/http-link-routing.test.ts | 28 +++ src/renderer/src/lib/http-link-routing.ts | 6 +- .../pane-manager/pane-dom-creation.test.ts | 23 ++- .../src/lib/pane-manager/pane-dom-creation.ts | 2 +- .../lib/pane-manager/pane-manager-types.ts | 2 +- .../src/lib/settings-navigation-types.ts | 1 + src/shared/constants.test.ts | 4 + src/shared/constants.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/types.ts | 2 + 42 files changed, 1750 insertions(+), 166 deletions(-) create mode 100644 src/renderer/src/components/settings/BrowserTerminalLinkActionsSetting.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-action-request.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 5c8088cc9bb..1bc078cfcca 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -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 ? ( + + ) : null} + {showLocalhostLabels ? ( + isMac: boolean + updateSettings: (updates: Partial) => 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 ( + +
+ + updateSettings({ + terminalLinkActionPopoverEnabled: settings.terminalLinkActionPopoverEnabled === false + }) + } + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/browser-link-routing-copy.ts b/src/renderer/src/components/settings/browser-link-routing-copy.ts index 5a4d5bac2d6..bdf005749e2 100644 --- a/src/renderer/src/components/settings/browser-link-routing-copy.ts +++ b/src/renderer/src/components/settings/browser-link-routing-copy.ts @@ -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 diff --git a/src/renderer/src/components/settings/browser-search.test.ts b/src/renderer/src/components/settings/browser-search.test.ts index 0df9173b28d..b9a36f148d7 100644 --- a/src/renderer/src/components/settings/browser-search.test.ts +++ b/src/renderer/src/components/settings/browser-search.test.ts @@ -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' ]) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index 67f5506de42..ad31404e922 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -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', diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx new file mode 100644 index 00000000000..197f5da8269 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx @@ -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 }) => {children} +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ + children, + open, + onOpenChange + }: { + children: ReactNode + open: boolean + onOpenChange: (open: boolean) => void + }) => + open ? ( +
+
+ ) : null, + PopoverAnchor: () => null, + PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ) +})) + +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() + + 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() + 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() + + 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() + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx new file mode 100644 index 00000000000..c313edb545a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx @@ -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 ( + + ) +} + +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 ( + !open && onClose(request ?? undefined)} + > + + {request ? ( + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + onEscapeKeyDown={() => request.focusTerminal()} + > +
+ + {request.destination} + + + + + + + {settingsLabel} + + +
+ runAction(request.primary)} + /> + {request.alternate ? ( + runAction(request.alternate!)} + /> + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e4862c5f085..d0e9dee537d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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(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(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} /> + {/* Why: repos is a broad store slice; only subscribe while the editor is visible. */} {quickCommandEditorOpen ? ( ({ + 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): 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') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts new file mode 100644 index 00000000000..bd3247ee9d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -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 + }) + } + } + : {}) + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index 2fb6064f814..3c042a12d24 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -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 { 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((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 = { diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index baf2adbde90..b9b949fc83f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -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 | 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( diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts index c9e941c874d..893ec638cbf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts @@ -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) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts new file mode 100644 index 00000000000..653ab34597a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts @@ -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 +} + +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 +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts new file mode 100644 index 00000000000..00cdcd3c687 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts new file mode 100644 index 00000000000..ce88c8feeaf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts index 41def2095c1..aae05daa875 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -5,3 +5,30 @@ export function isTerminalLinkActivation( ): boolean { return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) } + +type TerminalLinkMouseEvent = Pick & + Partial> + +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) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 3bb650b0e3a..74574dea02a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 2f4b9155a4e..b3890bb18b5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts index 890a8f3f4c3..801eec81c98 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index a069ad03f44..e1a5f4ff81f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -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` } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts new file mode 100644 index 00000000000..a98486fc2ad --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts @@ -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>() + +function createGesture(): { + gesture: ReturnType + 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts new file mode 100644 index 00000000000..ff3ac6cf945 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts @@ -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) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts index e595974bc18..a325056759b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts index aa58f773680..4416e9ae47e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts index 7965fa488d4..ece9806e827 100644 --- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 1f51187fd21..b041cb65f0d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -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 & - Partial> + 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 } diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index 4c5184cc933..0461474dedd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index d173157ea65..c28ae97824c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -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. diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts index f6d776eb289..441ff06ce86 100644 --- a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -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) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 631287b7217..1b666e20a80 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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 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 @@ -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()) const terminalHandleLinkDisposablesRef = useRef(new Map()) const linkifierClickPrimingDisposablesRef = useRef(new Map()) + const linkPointerGesturesRef = useRef( + new Map>() + ) const fileLinkClickFallbackDisposablesRef = useRef(new Map()) const httpLinkClickFallbackDisposablesRef = useRef(new Map()) // 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() 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() } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3e0980342eb..3184344d835 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index d0c96799978..64991900a25 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -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' } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 86e4f91fcd5..60f53457fc7 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index 64cbdd6ada1..fd0ff5e88ec 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 0163587662e..f383dfa3c53 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -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) { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 21956fad0ec..113f10af80d 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -58,7 +58,7 @@ export type PaneManagerOptions = { onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial 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. diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 1079a47f0f7..b901ffb0cce 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -60,6 +60,7 @@ const SETTINGS_NAV_INTENT_SET: ReadonlySet = 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 diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index f3a6ff1b89e..ea41c8a6af9 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -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) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index fcd72044911..d9a17c103f5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { localhostWorktreeLabelsEnabled: false, openLinksInAppPreferencePrompted: false, openLinksInAppModifierInverts: false, + terminalLinkActionPopoverEnabled: true, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, nativeChatSessionOptions: {}, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 6115584fd04..bbffedaefe0 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'editorAutoSave', 'openLinksInApp', 'openLinksInAppModifierInverts', + 'terminalLinkActionPopoverEnabled', 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', diff --git a/src/shared/types.ts b/src/shared/types.ts index 63019fcd03d..c6dfff01d3c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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. */