feat(browser): show a hovered or focused link's URL in a bubble at the bottom of the page (#32978)

This commit is contained in:
Kelvin Amoaba
2026-10-25 03:09:30 +00:00
committed by GitHub
parent 0fa161ba42
commit 25b9ce5168
14 changed files with 402 additions and 2 deletions
@@ -0,0 +1,69 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const { cursor } = vi.hoisted(() => ({ cursor: { x: 10, y: 20 } }))
vi.mock('electron', () => ({
screen: { getCursorScreenPoint: () => ({ ...cursor }) }
}))
import { setupGuestTargetUrlForwarding } from './browser-guest-target-url'
let guest: EventEmitter
let send: ReturnType<typeof vi.fn>
let teardown: () => void
beforeEach(() => {
vi.useFakeTimers()
guest = new EventEmitter()
send = vi.fn()
teardown = setupGuestTargetUrlForwarding({
browserTabId: 'page-1',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the forwarder only uses on/off, which EventEmitter implements with Electron's semantics.
guest: guest as unknown as Electron.WebContents,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the forwarder only calls send on the renderer.
resolveRenderer: () => ({ send }) as unknown as Electron.WebContents
})
})
afterEach(() => {
vi.useRealTimers()
})
const sentUrls = (): string[] => send.mock.calls.map(([, event]) => event.url)
it('tracks the pointer only while a link is targeted, at most once per interval', () => {
expect(guest.listenerCount('before-mouse-event')).toBe(0)
guest.emit('update-target-url', {}, 'https://example.com/a')
expect(send).toHaveBeenLastCalledWith('browser:target-url-changed', {
browserPageId: 'page-1',
url: 'https://example.com/a',
screenX: 10,
screenY: 20
})
cursor.x = 30
guest.emit('before-mouse-event', {}, { type: 'mouseMove' })
guest.emit('before-mouse-event', {}, { type: 'mouseMove' })
vi.advanceTimersByTime(100)
expect(send).toHaveBeenCalledTimes(2)
expect(send.mock.calls[1][1]).toMatchObject({ url: 'https://example.com/a', screenX: 30 })
guest.emit('update-target-url', {}, '')
expect(guest.listenerCount('before-mouse-event')).toBe(0)
guest.emit('before-mouse-event', {}, { type: 'mouseMove' })
vi.advanceTimersByTime(100)
expect(sentUrls()).toEqual(['https://example.com/a', 'https://example.com/a', ''])
})
it('clears the target when the guest dies or is unregistered under a hovered link', () => {
guest.emit('update-target-url', {}, 'https://example.com/a')
guest.emit('render-process-gone', {}, { reason: 'crashed' })
expect(sentUrls()).toEqual(['https://example.com/a', ''])
guest.emit('update-target-url', {}, 'https://example.com/b')
teardown()
expect(sentUrls()).toEqual(['https://example.com/a', '', 'https://example.com/b', ''])
expect(guest.listenerCount('before-mouse-event')).toBe(0)
})
@@ -0,0 +1,74 @@
import { screen } from 'electron'
import type { BrowserTargetUrlChangedEvent } from '../../shared/browser-guest-events'
import type { ResolveRenderer } from './browser-guest-renderer-target'
const POINTER_REFRESH_MS = 100
export function setupGuestTargetUrlForwarding(args: {
browserTabId: string
guest: Electron.WebContents
resolveRenderer: ResolveRenderer
}): () => void {
const { browserTabId, guest, resolveRenderer } = args
let targetUrl = ''
let refreshTimer: ReturnType<typeof setTimeout> | null = null
const send = (): void => {
// The renderer gets no pointer events over the guest; the cursor lets the bubble step aside.
const cursor = screen.getCursorScreenPoint()
resolveRenderer(browserTabId)?.send('browser:target-url-changed', {
browserPageId: browserTabId,
url: targetUrl,
screenX: cursor.x,
screenY: cursor.y
} satisfies BrowserTargetUrlChangedEvent)
}
// Listen to mouse moves only while a link is targeted, at most one IPC per refresh interval.
const refreshPointer = (_event: Electron.Event, mouse: Electron.MouseInputEvent): void => {
if (mouse.type !== 'mouseMove' || refreshTimer) {
return
}
refreshTimer = setTimeout(() => {
refreshTimer = null
if (targetUrl) {
send()
}
}, POINTER_REFRESH_MS)
}
const setTarget = (url: string): void => {
if (url && !targetUrl) {
guest.on('before-mouse-event', refreshPointer)
} else if (!url && targetUrl) {
guest.off('before-mouse-event', refreshPointer)
}
targetUrl = url
send()
}
const handleTargetUrl = (_event: Electron.Event, url: string): void => setTarget(url)
// A guest that dies under a hovered link never reports leaving it.
const handleGuestGone = (): void => {
if (targetUrl) {
setTarget('')
}
}
guest.on('update-target-url', handleTargetUrl)
guest.on('render-process-gone', handleGuestGone)
return () => {
if (refreshTimer) {
clearTimeout(refreshTimer)
}
try {
// A destroyed guest unregisters here, so this is its last chance to clear the bubble.
handleGuestGone()
guest.off('update-target-url', handleTargetUrl)
guest.off('render-process-gone', handleGuestGone)
guest.off('before-mouse-event', refreshPointer)
} catch {
// The guest may already be destroyed.
}
}
}
@@ -3,6 +3,7 @@ import { setupGuestContextMenu } from './browser-guest-context-menu'
import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts'
import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom'
import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding'
import { setupGuestTargetUrlForwarding } from './browser-guest-target-url'
import { BrowserManagerGrab } from './browser-manager-grab'
export abstract class BrowserManagerBindings extends BrowserManagerGrab {
@@ -81,4 +82,16 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab {
})
)
}
protected setupTargetUrlForwarding(browserTabId: string, guest: Electron.WebContents): void {
this.targetUrlCleanupByTabId.get(browserTabId)?.()
this.targetUrlCleanupByTabId.set(
browserTabId,
setupGuestTargetUrlForwarding({
browserTabId,
guest,
resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId)
})
)
}
}
@@ -79,6 +79,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
this.setupGrabShortcut(browserTabId, guest)
this.setupShortcutForwarding(browserTabId, guest)
this.setupMouseWheelZoomForwarding(browserTabId, guest)
this.setupTargetUrlForwarding(browserTabId, guest)
this.flushPendingLoadFailure(browserTabId, webContentsId)
this.flushPendingPermissionEvents(browserTabId, webContentsId)
this.flushPendingPopupEvents(browserTabId, webContentsId)
@@ -128,6 +129,8 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
mouseWheelZoomCleanup()
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
}
this.targetUrlCleanupByTabId.get(browserTabId)?.()
this.targetUrlCleanupByTabId.delete(browserTabId)
let hasActiveDownloads = false
for (const [downloadId, download] of this.downloadsById.entries()) {
if (download.browserTabId === browserTabId && !download.terminalEvent) {
@@ -229,6 +232,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
this.pendingPopupEventsByGuestId.clear()
this.pendingDownloadIdsByGuestId.clear()
this.mouseWheelZoomCleanupByTabId.clear()
this.targetUrlCleanupByTabId.clear()
this.annotationViewportBridgeOpsByTabId.clear()
}
}
@@ -75,6 +75,10 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
browserTabId: string,
guest: Electron.WebContents
): void
protected abstract setupTargetUrlForwarding(
browserTabId: string,
guest: Electron.WebContents
): void
protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void
protected abstract hasActiveGrabOp(browserTabId: string): boolean
protected abstract unregisterGuest(
@@ -141,6 +145,7 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
protected readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
protected readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
protected readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
protected readonly targetUrlCleanupByTabId = new Map<string, () => void>()
protected readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
protected readonly worktreeIdByTabId = new Map<string, string>()
protected readonly policyAttachedGuestIds = new Set<number>()
+3 -1
View File
@@ -47,7 +47,8 @@ import type {
BrowserDownloadProgressEvent,
BrowserDownloadRequestedEvent,
BrowserPermissionDeniedEvent,
BrowserPopupEvent
BrowserPopupEvent,
BrowserTargetUrlChangedEvent
} from '../../shared/browser-guest-events'
import type {
BrowserCertificateFailure,
@@ -157,6 +158,7 @@ export type BrowserApi = {
onContextMenuDismissed: (
callback: (event: BrowserContextMenuDismissedEvent) => void
) => () => void
onTargetUrlChanged: (callback: (event: BrowserTargetUrlChangedEvent) => void) => () => void
onNavigationUpdate: (
callback: (event: { browserPageId: string; url: string; title: string }) => void
) => () => void
@@ -7,7 +7,10 @@ import type {
BrowserSitePermission,
BrowserSitePermissionDecision
} from '../../shared/browser-site-permission'
import type { BrowserContextMenuRequestedEvent } from '../../shared/browser-guest-events'
import type {
BrowserContextMenuRequestedEvent,
BrowserTargetUrlChangedEvent
} from '../../shared/browser-guest-events'
export const browserPageInteractionAndSessionsApi = {
onContextMenuRequested: (
@@ -24,6 +27,12 @@ export const browserPageInteractionAndSessionsApi = {
ipcRenderer.on('browser:context-menu-dismissed', listener)
return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener)
},
onTargetUrlChanged: (callback: (event: BrowserTargetUrlChangedEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: BrowserTargetUrlChangedEvent) =>
callback(data)
ipcRenderer.on('browser:target-url-changed', listener)
return () => ipcRenderer.removeListener('browser:target-url-changed', listener)
},
onNavigationUpdate: (
callback: (event: { browserPageId: string; url: string; title: string }) => void
): (() => void) => {
@@ -1,5 +1,6 @@
import { useEffectEvent, useLayoutEffect, useRef, useState } from 'react'
import { BrowserPageZoomIndicator } from './assemble-chrome/browser-page-zoom-indicator'
import { BrowserLinkStatusBubble } from './assemble-chrome/browser-link-status-bubble'
import { useAppStore } from '@/store'
import type { BrowserLoadError } from '../../../../shared/browser-workspace-types'
import {
@@ -360,6 +361,7 @@ export function ClientHostedBrowserPagePane({
webviewRef={webviewRef}
guestGeneration={pageHostGeneration}
/>
<BrowserLinkStatusBubble browserPageId={browserTab.id} />
{showFailureOverlay ? (
<ClientHostedBrowserLoadFailure
browserTab={browserTab}
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { BrowserTargetUrlChangedEvent } from '../../../../../shared/browser-guest-events'
import { BrowserLinkStatusBubble } from './browser-link-status-bubble'
let emit: (event: BrowserTargetUrlChangedEvent) => void
beforeEach(() => {
vi.useFakeTimers()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
browser: {
onTargetUrlChanged: (callback: (event: BrowserTargetUrlChangedEvent) => void) => {
emit = callback
return () => {}
}
},
ui: { getZoomLevel: () => 0 }
}
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
function hover(url: string, browserPageId = 'page-1'): void {
act(() => emit({ browserPageId, url, screenX: 0, screenY: 0 }))
}
function advance(ms: number): void {
act(() => vi.advanceTimersByTime(ms))
}
it('shows the hovered URL after a short delay and hides it after leaving the link', () => {
const { container } = render(<BrowserLinkStatusBubble browserPageId="page-1" />)
const bubble = container.querySelector<HTMLElement>('[data-browser-link-status]')!
hover('https://example.com/docs', 'page-2')
advance(500)
expect(bubble.classList.contains('opacity-0')).toBe(true)
hover('https://example.com/docs')
advance(50)
expect(bubble.classList.contains('opacity-0')).toBe(true)
advance(50)
expect(bubble.classList.contains('opacity-100')).toBe(true)
expect(bubble.textContent).toBe('https://example.com/docs')
hover('')
advance(200)
expect(bubble.classList.contains('opacity-100')).toBe(true)
advance(100)
expect(bubble.classList.contains('opacity-0')).toBe(true)
})
it('keeps the host out of the trimmed tail and caps huge URLs', () => {
const { container } = render(<BrowserLinkStatusBubble browserPageId="page-1" />)
const parts = (): string[] =>
[...container.querySelectorAll('[data-browser-link-status] span')].map((s) => s.textContent!)
hover('https://example.com/a')
expect(parts()).toEqual(['https://example.com', '/a'])
hover(`data:text/plain,${'x'.repeat(5000)}`)
const [head, tail] = parts()
expect(head.length).toBe(513)
expect(head.endsWith('…')).toBe(true)
expect(tail).toBe('x'.repeat(20))
})
@@ -0,0 +1,137 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { windowDipToCssPx } from '@/lib/ui-zoom'
import { toDisplayUrl } from '../describe-page/browser-page-url-display'
// Chrome's status bubble timings (status_bubble_views.cc, status_bubble.h).
const SHOW_DELAY_MS = 80
const HIDE_DELAY_MS = 250
const EXPAND_DELAY_MS = 1600
const POINTER_PADDING_PX = 20
const TAIL_CHARS = 20
const MAX_HEAD_CHARS = 512
type LinkStatus = {
text: string
visible: boolean
expanded: boolean
pointer: { x: number; y: number } | null
}
const HIDDEN: LinkStatus = { text: '', visible: false, expanded: false, pointer: null }
/** Splits a URL so CSS ellipsis trims its middle: the head keeps the origin, the tail the end. */
function splitLinkStatusText(text: string): { head: string; tail: string } {
const cut = Math.max(originLength(text), text.length - TAIL_CHARS)
const head = cut > MAX_HEAD_CHARS ? `${text.slice(0, MAX_HEAD_CHARS)}…` : text.slice(0, cut)
return { head, tail: text.slice(cut) }
}
function originLength(text: string): number {
try {
const { origin } = new URL(text)
if (origin !== 'null' && text.startsWith(origin)) {
return origin.length
}
} catch {
// Not a parseable URL; keep the scheme at least.
}
return text.indexOf(':') + 1
}
export function BrowserLinkStatusBubble({
browserPageId
}: {
browserPageId: string
}): React.JSX.Element {
const [status, setStatus] = useState<LinkStatus>(HIDDEN)
const [side, setSide] = useState<'left' | 'right'>('left')
const bubbleRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let lastUrl = ''
let showTimer: number | undefined
let hideTimer: number | undefined
let expandTimer: number | undefined
const clearTimers = (): void => {
window.clearTimeout(showTimer)
window.clearTimeout(hideTimer)
window.clearTimeout(expandTimer)
}
const unsubscribe = window.api.browser.onTargetUrlChanged((event) => {
if (event.browserPageId !== browserPageId) {
return
}
const pointer = {
x: windowDipToCssPx(event.screenX - window.screenX),
y: windowDipToCssPx(event.screenY - window.screenY)
}
// Main repeats the URL while the pointer moves within one link; only the pointer changed.
if (event.url && event.url === lastUrl) {
setStatus((current) => ({ ...current, pointer }))
return
}
lastUrl = event.url
clearTimers()
const text = event.url ? toDisplayUrl(event.url) : ''
if (!text) {
hideTimer = window.setTimeout(() => {
setStatus((current) => ({ ...current, visible: false, expanded: false }))
}, HIDE_DELAY_MS)
return
}
setStatus((current) => ({ ...current, text, pointer }))
showTimer = window.setTimeout(() => {
setStatus((current) => (current.visible ? current : { ...current, visible: true }))
}, SHOW_DELAY_MS)
expandTimer = window.setTimeout(() => {
setStatus((current) => ({ ...current, expanded: true }))
}, EXPAND_DELAY_MS)
})
return () => {
unsubscribe()
clearTimers()
}
}, [browserPageId])
useLayoutEffect(() => {
const bubble = bubbleRef.current
const page = bubble?.offsetParent
if (!bubble || !page || !status.pointer) {
return
}
// Like Firefox, step to the other corner when the link under the pointer sits where the bubble would.
const pageRect = page.getBoundingClientRect()
const { x, y } = status.pointer
const overLeftCorner =
x >= pageRect.left &&
x <= pageRect.left + bubble.offsetWidth + POINTER_PADDING_PX &&
y <= pageRect.bottom &&
y >= pageRect.bottom - bubble.offsetHeight - POINTER_PADDING_PX
setSide(overLeftCorner ? 'right' : 'left')
if (overLeftCorner) {
// A bubble that stepped aside stays narrow so it can't grow back under the pointer.
setStatus((current) => (current.expanded ? { ...current, expanded: false } : current))
}
}, [status.text, status.pointer, status.expanded])
const { head, tail } = useMemo(() => splitLinkStatusText(status.text), [status.text])
return (
<div
ref={bubbleRef}
aria-hidden
data-browser-link-status={side}
className={cn(
'pointer-events-none absolute bottom-1 z-30 flex rounded-md border border-border bg-popover/95 px-2 py-0.5 text-xs text-popover-foreground shadow-xs transition-opacity ease-out',
side === 'left' ? 'left-1' : 'right-1',
status.expanded
? 'max-w-[calc(100%-0.5rem)]'
: 'max-w-[min(calc(100%-0.5rem),max(33%,20rem))]',
status.visible ? 'opacity-100 duration-100' : 'opacity-0 duration-200'
)}
>
<span className="truncate">{head}</span>
<span className="shrink-0 whitespace-nowrap">{tail}</span>
</div>
)
}
@@ -1,5 +1,6 @@
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react'
import { BrowserPageZoomIndicator } from './browser-page-zoom-indicator'
import { BrowserLinkStatusBubble } from './browser-link-status-bubble'
import type {
BrowserCertificateFailure,
BrowserLoadError,
@@ -88,6 +89,7 @@ export function BrowserPageViewportOverlays({
/>
<BrowserPageZoomIndicator state={browserZoomIndicatorState} percent={browserZoomPercent} />
<BrowserFind isOpen={findOpen} onClose={() => setFindOpen(false)} webviewRef={webviewRef} />
<BrowserLinkStatusBubble browserPageId={browserTab.id} />
{showFailureOverlay && browserTab.loadError ? (
<BrowserPageLoadFailure
browserTab={browserTab}
@@ -46,6 +46,7 @@ export function installClientHostedPaneApi(overrides?: {
onPermissionDenied: inert,
onContextMenuRequested: inert,
onContextMenuDismissed: inert,
onTargetUrlChanged: inert,
openDevTools: vi.fn(async () => true),
proceedCertificate: vi.fn(async () => ({ ok: true })),
publishClientPageMetadata: vi.fn(async () => ({ status: 'published', accepted: true })),
@@ -24,6 +24,7 @@ export function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']>
onDownloadProgress: () => noopUnsubscribe,
onDownloadFinished: () => noopUnsubscribe,
onContextMenuRequested: () => noopUnsubscribe,
onTargetUrlChanged: () => noopUnsubscribe,
onContextMenuDismissed: () => noopUnsubscribe,
onNavigationUpdate: () => noopUnsubscribe,
onActivateView: () => noopUnsubscribe,
+8
View File
@@ -79,3 +79,11 @@ export type ContextMenuSpelling = {
export type BrowserContextMenuDismissedEvent = {
browserPageId: string
}
export type BrowserTargetUrlChangedEvent = {
browserPageId: string
/** Link under the pointer or keyboard focus; empty when it leaves the link. */
url: string
screenX: number
screenY: number
}