From 3614cbc40cd823c7612d70b236d591e4fd132000 Mon Sep 17 00:00:00 2001 From: Ricardo Sawir <37329575+sawirricardo@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:09:52 +0700 Subject: [PATCH] Add default browser action for terminal links (#6129) * Add terminal link browser context action * Polish terminal link default-browser action - Route Open in Default Browser through the shared openHttpLink funnel (forceSystemBrowser) so loopback dev-server links get the same worktree-label rewrite as shift+click's system-browser path. - Translate the new menu label in es/ja/ko/zh (was en-only). - Extract TerminalQuickCommandMenuItem to keep TerminalContextMenu under the max-lines lint after adding the link item. - Hit-test the link only when the menu actually opens (skip on Windows right-click copy/paste path). - Add scheme-safety unit tests (file:/javascript:/smb:/vscode: rejected) and a menu-item render/callback test; fix TerminalContextMenu.test props for the new required fields. Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../TerminalContextMenu.test.tsx | 20 +++ .../terminal-pane/TerminalContextMenu.tsx | 57 ++++---- .../components/terminal-pane/TerminalPane.tsx | 2 + .../TerminalQuickCommandMenuItem.tsx | 38 ++++++ .../terminal-url-link-hit-testing.test.ts | 126 ++++++++++++++++++ .../terminal-url-link-hit-testing.ts | 34 ++++- .../use-terminal-pane-context-menu.ts | 26 ++++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 12 files changed, 278 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/TerminalQuickCommandMenuItem.tsx diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 0cfa3f1619f..a70b378d2bd 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -57,6 +57,8 @@ function renderMenu(overrides: Record = {}): void { canClosePane: true, canExpandPane: true, menuPaneIsExpanded: false, + linkUrl: null, + onOpenLinkInDefaultBrowser: vi.fn(), onCopy: vi.fn(), onPaste: vi.fn(), onSplitRight: vi.fn(), @@ -114,6 +116,24 @@ describe('TerminalContextMenu', () => { expect(onForkAgentSession).not.toHaveBeenCalled() }) + it('shows "Open in Default Browser" only when a link is under the cursor', () => { + renderMenu({ linkUrl: null }) + expect( + items.list.some((item) => childrenText(item.children) === 'Open in Default Browser') + ).toBe(false) + + items.list = [] + const onOpenLinkInDefaultBrowser = vi.fn() + renderMenu({ linkUrl: 'https://example.com/docs', onOpenLinkInDefaultBrowser }) + + const openLinkItem = items.list.find( + (item) => childrenText(item.children) === 'Open in Default Browser' + ) + expect(openLinkItem).toBeDefined() + openLinkItem?.onSelect?.() + expect(onOpenLinkInDefaultBrowser).toHaveBeenCalledTimes(1) + }) + it('shows one shortcut per terminal menu action on Windows', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 749b1e98a28..d46d66e5865 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -4,6 +4,7 @@ import { ClipboardCopy, Copy, Eraser, + ExternalLink, GitFork, Maximize2, MessageSquare, @@ -31,12 +32,11 @@ import { } from '@/components/ui/dropdown-menu' import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss' import type { TerminalQuickCommand } from '../../../../shared/types' -import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' -import { AgentIcon } from '@/lib/agent-catalog' import type { KeybindingOverrides } from '../../../../shared/keybindings' import { translate } from '@/i18n/i18n' import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut' +import { TerminalQuickCommandMenuItem } from './TerminalQuickCommandMenuItem' type TerminalContextMenuProps = { open: boolean @@ -46,6 +46,8 @@ type TerminalContextMenuProps = { canClosePane: boolean canExpandPane: boolean menuPaneIsExpanded: boolean + linkUrl: string | null + onOpenLinkInDefaultBrowser: () => void onCopy: () => void onPaste: () => void onSplitRight: () => void @@ -81,6 +83,8 @@ export default function TerminalContextMenu({ canClosePane, canExpandPane, menuPaneIsExpanded, + linkUrl, + onOpenLinkInDefaultBrowser, onCopy, onPaste, onSplitRight, @@ -128,27 +132,6 @@ export default function TerminalContextMenu({ const showEqualizeShortcut = shortcuts.equalize !== 'Unassigned' const showSetTitleShortcut = shortcuts.setTitle !== 'Unassigned' const showClearPaneTitleShortcut = shortcuts.clearPaneTitle !== 'Unassigned' - const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => ( - onQuickCommand(command)}> - {isTerminalAgentQuickCommand(command) ? ( - - - - ) : ( - - )} - {command.label} - {!isTerminalAgentQuickCommand(command) && !command.appendEnter ? ( - - {translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')} - - ) : null} - - ) return ( + {linkUrl ? ( + <> + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.openLinkDefaultBrowser', + 'Open in Default Browser' + )} + + + + ) : null} {translate('auto.components.terminal.pane.TerminalContextMenu.f3eeb1de13', 'Copy')} @@ -220,7 +215,13 @@ export default function TerminalContextMenu({ {quickCommandRepoLabel} - {repoQuickCommands.map(renderQuickCommandItem)} + {repoQuickCommands.map((command) => ( + + ))} ) : null} {globalQuickCommands.length > 0 ? ( @@ -234,7 +235,13 @@ export default function TerminalContextMenu({ )} ) : null} - {globalQuickCommands.map(renderQuickCommandItem)} + {globalQuickCommands.map((command) => ( + + ))} ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index dd5d2116b8c..5044f893525 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -2971,6 +2971,8 @@ export default function TerminalPane({ menuPaneIsExpanded={ contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId } + linkUrl={contextMenu.menuLinkUrl} + onOpenLinkInDefaultBrowser={contextMenu.onOpenLinkInDefaultBrowser} onCopy={() => void contextMenu.onCopy()} onPaste={() => void contextMenu.onPaste()} onSplitRight={contextMenu.onSplitRight} diff --git a/src/renderer/src/components/terminal-pane/TerminalQuickCommandMenuItem.tsx b/src/renderer/src/components/terminal-pane/TerminalQuickCommandMenuItem.tsx new file mode 100644 index 00000000000..4dac903b0b3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalQuickCommandMenuItem.tsx @@ -0,0 +1,38 @@ +import { Play } from 'lucide-react' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import type { TerminalQuickCommand } from '../../../../shared/types' +import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' + +type TerminalQuickCommandMenuItemProps = { + command: TerminalQuickCommand + onSelect: (command: TerminalQuickCommand) => void +} + +export function TerminalQuickCommandMenuItem({ + command, + onSelect +}: TerminalQuickCommandMenuItemProps): React.JSX.Element { + return ( + onSelect(command)}> + {isTerminalAgentQuickCommand(command) ? ( + + + + ) : ( + + )} + {command.label} + {!isTerminalAgentQuickCommand(command) && !command.appendEnter ? ( + + {translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')} + + ) : null} + + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts index 87d03c748b7..8af8a006890 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { extractTerminalHttpLinks, + getTerminalHttpLinkAtBufferPosition, + getTerminalHttpLinkForMouseEvent, TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-url-link-hit-testing' @@ -47,3 +49,127 @@ describe('extractTerminalHttpLinks', () => { expect(matchAllSpy).not.toHaveBeenCalled() }) }) + +describe('terminal HTTP link hit testing', () => { + type TestBufferLine = { + isWrapped: boolean + length: number + translateToString: ( + trimRight?: boolean, + startColumn?: number, + endColumn?: number, + outColumns?: number[] + ) => string + } + + function defaultColumnsForText(text: string): number[] { + return Array.from({ length: text.length + 1 }, (_value, index) => index) + } + + function makeBufferLine( + text: string, + options: { isWrapped?: boolean; columns?: number[] } = {} + ): TestBufferLine { + const columns = options.columns ?? defaultColumnsForText(text) + return { + isWrapped: options.isWrapped ?? false, + length: text.length, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + if (outColumns) { + outColumns.length = 0 + for (let index = startColumn; index <= endColumn; index++) { + outColumns.push(columns[index] ?? index) + } + } + return text.slice(startColumn, endColumn) + } + } + } + + function makeBuffer( + rows: TestBufferLine[] + ): Parameters[0] { + return { getLine: (y: number) => rows[y] } as Parameters< + typeof getTerminalHttpLinkAtBufferPosition + >[0] + } + + it('returns the URL at a buffer position without opening it', () => { + const line = 'Open https://example.com/docs?q=orca now' + + expect( + getTerminalHttpLinkAtBufferPosition( + makeBuffer([makeBufferLine(line)]), + { x: line.indexOf('example') + 1, y: 1 }, + 80 + ) + ).toBe('https://example.com/docs?q=orca') + }) + + it('returns null when the buffer position is outside the URL', () => { + const line = 'Open https://example.com/docs now' + + expect( + getTerminalHttpLinkAtBufferPosition(makeBuffer([makeBufferLine(line)]), { x: 2, y: 1 }, 80) + ).toBeNull() + }) + + // Why: the context-menu "Open in Default Browser" item hands this URL to + // shell.openExternal, so a dangerous scheme under the cursor must never be + // surfaced as an openable link. + it.each(['file:///etc/passwd', 'javascript:alert(1)', 'smb://host/share', 'vscode://x'])( + 'returns null for the non-http scheme %s', + (dangerousUrl) => { + const line = `Open ${dangerousUrl} now` + + expect( + getTerminalHttpLinkAtBufferPosition( + makeBuffer([makeBufferLine(line)]), + { x: line.indexOf(dangerousUrl) + 1, y: 1 }, + 80 + ) + ).toBeNull() + } + ) + + it('returns wrapped URLs from mouse coordinates', () => { + const rows = [ + makeBufferLine('Visit https://exa'), + makeBufferLine('mple.com/docs', { isWrapped: true }) + ] + const screen = { + getBoundingClientRect: () => ({ + left: 10, + top: 20, + width: 200, + height: 40 + }) + } + const terminal = { + cols: 20, + rows: 2, + element: { querySelector: () => screen }, + buffer: { + active: { + viewportY: 0, + getLine: (y: number) => rows[y] + } + } + } + + expect( + getTerminalHttpLinkForMouseEvent( + terminal as unknown as Parameters[0], + { + clientX: 30, + clientY: 45 + } + ) + ).toBe('https://example.com/docs') + }) +}) 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 67505d5f622..93ff8954be6 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 @@ -191,7 +191,7 @@ function getTerminalScreenElement(terminal: Terminal): HTMLElement | null { function getBufferPositionForTerminalMouseEvent( terminal: Terminal, - event: MouseEvent + event: Pick ): { x: number; y: number } | null { const screenElement = getTerminalScreenElement(terminal) if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) { @@ -260,9 +260,34 @@ export function openHttpLinkAtBufferPosition( terminalColumns: number, deps: UrlLinkHitTestDeps ): boolean { + const url = getTerminalHttpLinkAtBufferPosition(buffer, position, terminalColumns) + if (!url) { + return false + } + + openTerminalHttpLink(url, deps) + return true +} + +export function getTerminalHttpLinkForMouseEvent( + terminal: Terminal, + event: Pick +): string | null { + const position = getBufferPositionForTerminalMouseEvent(terminal, event) + if (!position) { + return null + } + return getTerminalHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols) +} + +export function getTerminalHttpLinkAtBufferPosition( + buffer: { getLine(y: number): IBufferLine | undefined }, + position: { x: number; y: number }, + terminalColumns: number +): string | null { const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y) if (logicalLines.length === 0) { - return false + return null } for (const logicalLine of logicalLines) { @@ -271,12 +296,11 @@ export function openHttpLinkAtBufferPosition( if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) { continue } - openTerminalHttpLink(parsed.url, deps) - return true + return parsed.url } } - return false + return null } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index d84f1a080ab..2dd939f614e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -41,6 +41,8 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { copyTerminalHandleForPane } from './terminal-handle-copy' +import { getTerminalHttpLinkForMouseEvent } from './terminal-url-link-hit-testing' +import { openHttpLink } from '@/lib/http-link-routing' const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -81,6 +83,8 @@ type TerminalMenuState = { menuOpenedAtRef: React.RefObject paneCount: number menuPaneId: number | null + menuLinkUrl: string | null + onOpenLinkInDefaultBrowser: () => void onContextMenuCapture: (event: React.MouseEvent) => void onPaneTitleContextMenu: (event: React.MouseEvent, paneId: number) => void onCopy: () => Promise @@ -124,6 +128,7 @@ export function useTerminalPaneContextMenu({ const menuOpenedAtRef = useRef(0) const [open, setOpen] = useState(false) const [point, setPoint] = useState({ x: 0, y: 0 }) + const [menuLinkUrl, setMenuLinkUrl] = useState(null) useEffect(() => { const closeMenu = (): void => { @@ -182,6 +187,17 @@ export function useTerminalPaneContextMenu({ pane.terminal.focus() } + const onOpenLinkInDefaultBrowser = (): void => { + if (!menuLinkUrl) { + return + } + // Why: route through the shared funnel (not shell.openUrl directly) so this + // matches shift+click's system-browser path, including the loopback + // worktree-label rewrite for local dev-server links. + openHttpLink(menuLinkUrl, { worktreeId, forceSystemBrowser: true }) + resolveMenuPane()?.terminal.focus() + } + const getShortcutPlatform = (): NodeJS.Platform => { if (navigator.userAgent.includes('Mac')) { return 'darwin' @@ -472,6 +488,7 @@ export function useTerminalPaneContextMenu({ const manager = managerRef.current if (!manager) { contextPaneIdRef.current = null + setMenuLinkUrl(null) return } const clickedPane = @@ -499,6 +516,11 @@ export function useTerminalPaneContextMenu({ return } + // Why: only hit-test the link once the menu is actually opening; the Windows + // copy/paste path above returns without a menu and needs no link lookup. + setMenuLinkUrl( + clickedPane ? getTerminalHttpLinkForMouseEvent(clickedPane.terminal, event.nativeEvent) : null + ) menuOpenedAtRef.current = Date.now() const bounds = boundsElement.getBoundingClientRect() setPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top }) @@ -510,12 +532,14 @@ export function useTerminalPaneContextMenu({ if (!manager) { event.preventDefault() contextPaneIdRef.current = null + setMenuLinkUrl(null) return } const target = event.target if (!(target instanceof Node)) { event.preventDefault() contextPaneIdRef.current = null + setMenuLinkUrl(null) return } const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null @@ -544,6 +568,8 @@ export function useTerminalPaneContextMenu({ menuOpenedAtRef, paneCount, menuPaneId, + menuLinkUrl, + onOpenLinkInDefaultBrowser, onContextMenuCapture, onPaneTitleContextMenu, onCopy, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index adc4290c7c4..4c0d1b72a92 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2450,6 +2450,7 @@ "0a917b591a": "Paste", "f3eeb1de13": "Copy", "c2f0b72b8d": "Insert", + "openLinkDefaultBrowser": "Open in Default Browser", "925f49f210": "Expand Pane", "df766809e0": "Collapse Pane", "cff67afad1": "Copy Context" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 67e5867cfaa..ef78ae0fdde 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2450,6 +2450,7 @@ "0a917b591a": "Pasta", "f3eeb1de13": "Copiar", "c2f0b72b8d": "Insertar", + "openLinkDefaultBrowser": "Abrir en el navegador predeterminado", "925f49f210": "Expandir panel", "df766809e0": "Contraer panel", "cff67afad1": "Copy Context" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c38dfc0264d..4ab680e6750 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2450,6 +2450,7 @@ "0a917b591a": "ペースト", "f3eeb1de13": "コピー", "c2f0b72b8d": "入れる", + "openLinkDefaultBrowser": "既定のブラウザで開く", "925f49f210": "ペインを展開する", "df766809e0": "ペインを折りたたむ", "cff67afad1": "Copy Context" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3307660af5b..5675de5d879 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2450,6 +2450,7 @@ "0a917b591a": "붙여넣기", "f3eeb1de13": "복사", "c2f0b72b8d": "삽입", + "openLinkDefaultBrowser": "기본 브라우저에서 열기", "925f49f210": "창 확장", "df766809e0": "창 축소", "cff67afad1": "Copy Context" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 26ed1980b3e..09570dec44e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2450,6 +2450,7 @@ "0a917b591a": "粘贴", "f3eeb1de13": "复制", "c2f0b72b8d": "插入", + "openLinkDefaultBrowser": "在默认浏览器中打开", "925f49f210": "展开窗格", "df766809e0": "折叠窗格", "cff67afad1": "Copy Context"