From 7cf92c6e497db877a625bec66152daa69123459c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:49:47 -0700 Subject: [PATCH] Restore modifier-click behavior for terminal URLs (#159) * Restore modifier-click behavior for terminal URLs * Fix terminal URL hover preview * Add shortcut hint to terminal URL preview * Tweak terminal URL shortcut hint styling * Simplify terminal URL hover copy * Fix terminal link handler test environment --- .../terminal-link-handlers.test.ts | 65 +++++++++++++++++++ .../terminal-pane/terminal-link-handlers.ts | 14 +++- .../use-terminal-pane-lifecycle.ts | 9 ++- .../src/lib/pane-manager/pane-lifecycle.ts | 33 +++++++--- .../lib/pane-manager/pane-manager-types.ts | 3 +- 5 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts 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 new file mode 100644 index 000000000..03cfb3ba0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { handleOscLink, isTerminalLinkActivation } from './terminal-link-handlers' + +const openUrlMock = vi.fn() +const openFileUriMock = vi.fn() + +function setPlatform(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { + api: { + shell: { + openUrl: openUrlMock, + openFileUri: openFileUriMock + } + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('isTerminalLinkActivation', () => { + it('requires cmd on macOS', () => { + setPlatform('Macintosh') + + expect(isTerminalLinkActivation({ metaKey: true, ctrlKey: false })).toBe(true) + expect(isTerminalLinkActivation({ metaKey: false, ctrlKey: true })).toBe(false) + expect(isTerminalLinkActivation(undefined)).toBe(false) + }) + + it('requires ctrl on non-macOS platforms', () => { + setPlatform('Windows') + + expect(isTerminalLinkActivation({ metaKey: false, ctrlKey: true })).toBe(true) + expect(isTerminalLinkActivation({ metaKey: true, ctrlKey: false })).toBe(false) + expect(isTerminalLinkActivation(undefined)).toBe(false) + }) +}) + +describe('handleOscLink', () => { + it('opens http links only when the platform modifier is pressed', () => { + setPlatform('Macintosh') + + handleOscLink('https://example.com', { metaKey: false, ctrlKey: false }) + expect(openUrlMock).not.toHaveBeenCalled() + + handleOscLink('https://example.com', { metaKey: true, ctrlKey: false }) + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + }) + + it('opens file links only when the platform modifier is pressed', () => { + setPlatform('Windows') + + handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }) + expect(openFileUriMock).not.toHaveBeenCalled() + + handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: true }) + expect(openFileUriMock).toHaveBeenCalledWith('file:///tmp/test.txt') + }) +}) 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 476219d82..0a60ed5a9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -125,7 +125,19 @@ export function createFilePathLinkProvider(paneId: number, deps: LinkHandlerDeps } } -export function handleOscLink(rawText: string): void { +export function isTerminalLinkActivation(event: Pick | undefined): boolean { + const isMac = navigator.userAgent.includes('Mac') + return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) +} + +export function handleOscLink( + rawText: string, + event: Pick | undefined +): void { + if (!isTerminalLinkActivation(event)) { + return + } + let parsed: URL try { parsed = new URL(rawText) 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 e5db77dbe..d18c319d9 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 @@ -162,7 +162,7 @@ export function useTerminalPaneLifecycle({ linkProviderDisposablesRef.current.set(pane.id, linkProviderDisposable) pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, - activate: (_event, text) => handleOscLink(text) + activate: (event, text) => handleOscLink(text, event as MouseEvent | undefined) } applyAppearance(manager) connectPanePty(pane, manager, ptyDeps) @@ -211,8 +211,11 @@ export function useTerminalPaneLifecycle({ cursorBlink: currentSettings?.terminalCursorBlink ?? true } }, - onLinkClick: (url) => { - void window.api.shell.openUrl(url) + onLinkClick: (event, url) => { + if (!event) { + return + } + void handleOscLink(url, event) } }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 51f9b0f36..cb8bb912e 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -37,6 +37,7 @@ export function createPaneDOM( xtermContainer.style.height = `calc(100% - ${TERMINAL_PADDING}px)` xtermContainer.style.marginTop = `${TERMINAL_PADDING}px` xtermContainer.style.marginLeft = `${TERMINAL_PADDING}px` + xtermContainer.style.position = 'relative' container.appendChild(xtermContainer) // Build terminal options @@ -61,16 +62,18 @@ export function createPaneDOM( const fitAddon = new FitAddon() const searchAddon = new SearchAddon() const unicode11Addon = new Unicode11Addon() + const isMac = navigator.userAgent.includes('Mac') + const openLinkHint = isMac ? '⌘+click to open' : 'Ctrl+click to open' // URL tooltip element — Ghostty-style bottom-left hint on hover const linkTooltip = document.createElement('div') linkTooltip.className = 'pane-link-tooltip' + linkTooltip.classList.add('xterm-hover') linkTooltip.style.cssText = 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' + - 'padding:2px 8px;border-radius:4px;font-size:11px;font-family:inherit;' + + 'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' + 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' + 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' - container.appendChild(linkTooltip) // Ghostty-style drag handle — appears at top of pane on hover when 2+ panes const dragHandle = document.createElement('div') @@ -79,15 +82,16 @@ export function createPaneDOM( attachPaneDrag(dragHandle, id, dragState, dragCallbacks) const webLinksAddon = new WebLinksAddon( - options.onLinkClick ? (_event, uri) => options.onLinkClick!(uri) : undefined, + options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined, { - hover: (event, uri) => { - if (event.type === 'mouseover' && uri) { - linkTooltip.textContent = uri + hover: (_event, uri) => { + if (uri) { + linkTooltip.textContent = `${uri} (${openLinkHint})` linkTooltip.style.display = '' - } else { - linkTooltip.style.display = 'none' } + }, + leave: () => { + linkTooltip.style.display = 'none' } } ) @@ -97,6 +101,7 @@ export function createPaneDOM( terminal, container, xtermContainer, + linkTooltip, fitAddon, searchAddon, unicode11Addon, @@ -117,10 +122,20 @@ export function createPaneDOM( /** Open terminal into its container and load addons. Must be called after the container is in the DOM. */ export function openTerminal(pane: ManagedPaneInternal): void { - const { terminal, xtermContainer, fitAddon, searchAddon, unicode11Addon, webLinksAddon } = pane + const { + terminal, + xtermContainer, + linkTooltip, + fitAddon, + searchAddon, + unicode11Addon, + webLinksAddon + } = pane // Open terminal into DOM terminal.open(xtermContainer) + const linkTooltipContainer = terminal.element ?? xtermContainer + linkTooltipContainer.appendChild(linkTooltip) // Load addons (order matters: WebGL must be after open()) terminal.loadAddon(fitAddon) 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 b8a823a87..abd7aa118 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -16,7 +16,7 @@ export type PaneManagerOptions = { onActivePaneChange?: (pane: ManagedPane) => void onLayoutChanged?: () => void terminalOptions?: (paneId: number) => Partial - onLinkClick?: (url: string) => void + onLinkClick?: (event: MouseEvent | undefined, url: string) => void } export type PaneStyleOptions = { @@ -42,6 +42,7 @@ export type ManagedPane = { export type ManagedPaneInternal = { xtermContainer: HTMLElement + linkTooltip: HTMLElement webglAddon: WebglAddon | null unicode11Addon: Unicode11Addon webLinksAddon: WebLinksAddon