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
This commit is contained in:
Neil 2026-03-28 00:49:47 -07:00 committed by GitHub
parent 33ff96a54d
commit 7cf92c6e49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 110 additions and 14 deletions

View file

@ -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')
})
})

View file

@ -125,7 +125,19 @@ export function createFilePathLinkProvider(paneId: number, deps: LinkHandlerDeps
}
}
export function handleOscLink(rawText: string): void {
export function isTerminalLinkActivation(event: Pick<MouseEvent, 'metaKey' | 'ctrlKey'> | undefined): boolean {
const isMac = navigator.userAgent.includes('Mac')
return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
}
export function handleOscLink(
rawText: string,
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey'> | undefined
): void {
if (!isTerminalLinkActivation(event)) {
return
}
let parsed: URL
try {
parsed = new URL(rawText)

View file

@ -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)
}
})

View file

@ -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)

View file

@ -16,7 +16,7 @@ export type PaneManagerOptions = {
onActivePaneChange?: (pane: ManagedPane) => void
onLayoutChanged?: () => void
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
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