mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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>()
|
||||
|
||||
@@ -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}
|
||||
|
||||
+73
@@ -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))
|
||||
})
|
||||
+137
@@ -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>
|
||||
)
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user