From 25b9ce5168bbb0dc76449797916758c2e9d198ec Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:09:30 +0000 Subject: [PATCH] feat(browser): show a hovered or focused link's URL in a bubble at the bottom of the page (#32978) --- .../browser/browser-guest-target-url.test.ts | 69 +++++++++ src/main/browser/browser-guest-target-url.ts | 74 ++++++++++ src/main/browser/browser-manager-bindings.ts | 13 ++ .../browser/browser-manager-registration.ts | 4 + src/main/browser/browser-manager-state.ts | 5 + src/preload/api/browser-api.ts | 4 +- ...er-bridge-page-interaction-and-sessions.ts | 11 +- .../ClientHostedBrowserPagePane.tsx | 2 + .../browser-link-status-bubble.test.tsx | 73 ++++++++++ .../browser-link-status-bubble.tsx | 137 ++++++++++++++++++ .../browser-page-viewport-overlays.tsx | 2 + .../client-hosted-browser-pane-test-rig.ts | 1 + .../src/web/preload-api/web-browser-api.ts | 1 + src/shared/browser-guest-events.ts | 8 + 14 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 src/main/browser/browser-guest-target-url.test.ts create mode 100644 src/main/browser/browser-guest-target-url.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-link-status-bubble.test.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-link-status-bubble.tsx diff --git a/src/main/browser/browser-guest-target-url.test.ts b/src/main/browser/browser-guest-target-url.test.ts new file mode 100644 index 00000000000..60d2177a1c1 --- /dev/null +++ b/src/main/browser/browser-guest-target-url.test.ts @@ -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 +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) +}) diff --git a/src/main/browser/browser-guest-target-url.ts b/src/main/browser/browser-guest-target-url.ts new file mode 100644 index 00000000000..0072d92750f --- /dev/null +++ b/src/main/browser/browser-guest-target-url.ts @@ -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 | 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. + } + } +} diff --git a/src/main/browser/browser-manager-bindings.ts b/src/main/browser/browser-manager-bindings.ts index bc76a7e569b..b96aa086604 100644 --- a/src/main/browser/browser-manager-bindings.ts +++ b/src/main/browser/browser-manager-bindings.ts @@ -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) + }) + ) + } } diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts index 402a390f2c9..8d1bcfc6e86 100644 --- a/src/main/browser/browser-manager-registration.ts +++ b/src/main/browser/browser-manager-registration.ts @@ -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() } } diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts index 75e6d23edd4..da9339a3c8b 100644 --- a/src/main/browser/browser-manager-state.ts +++ b/src/main/browser/browser-manager-state.ts @@ -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 void>() protected readonly shortcutForwardingCleanupByTabId = new Map void>() protected readonly mouseWheelZoomCleanupByTabId = new Map void>() + protected readonly targetUrlCleanupByTabId = new Map void>() protected readonly annotationViewportBridgeOpsByTabId = new Map>() protected readonly worktreeIdByTabId = new Map() protected readonly policyAttachedGuestIds = new Set() diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index 9e8aef7d8dd..ea0220d0b4b 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -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 diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index efba98ae261..1f526bc35ed 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -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) => { diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index 8ebc0e220fa..51b0d4e84b9 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -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} /> + {showFailureOverlay ? ( 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() + const bubble = container.querySelector('[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() + 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)) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-link-status-bubble.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-link-status-bubble.tsx new file mode 100644 index 00000000000..9c1cc7ecd22 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-link-status-bubble.tsx @@ -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(HIDDEN) + const [side, setSide] = useState<'left' | 'right'>('left') + const bubbleRef = useRef(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 ( +
+ {head} + {tail} +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx index 64e88d10514..a38651c5185 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx @@ -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({ /> setFindOpen(false)} webviewRef={webviewRef} /> + {showFailureOverlay && browserTab.loadError ? ( true), proceedCertificate: vi.fn(async () => ({ ok: true })), publishClientPageMetadata: vi.fn(async () => ({ status: 'published', accepted: true })), diff --git a/src/renderer/src/web/preload-api/web-browser-api.ts b/src/renderer/src/web/preload-api/web-browser-api.ts index 3fde1a06553..f3dceed08a7 100644 --- a/src/renderer/src/web/preload-api/web-browser-api.ts +++ b/src/renderer/src/web/preload-api/web-browser-api.ts @@ -24,6 +24,7 @@ export function createBrowserApi(): NonNullable['browser']> onDownloadProgress: () => noopUnsubscribe, onDownloadFinished: () => noopUnsubscribe, onContextMenuRequested: () => noopUnsubscribe, + onTargetUrlChanged: () => noopUnsubscribe, onContextMenuDismissed: () => noopUnsubscribe, onNavigationUpdate: () => noopUnsubscribe, onActivateView: () => noopUnsubscribe, diff --git a/src/shared/browser-guest-events.ts b/src/shared/browser-guest-events.ts index 6579b6df383..545a5936905 100644 --- a/src/shared/browser-guest-events.ts +++ b/src/shared/browser-guest-events.ts @@ -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 +}