mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
feat(browser): add favicon display for website tabs and history
- Capture favicons from navigation events and store in browser history - Display favicons in tab bar, tab search, and jump palette results - Clear favicons on cross-origin navigation; preserve on same-origin - Extract favicon rendering into reusable BrowserFavicon component - Validate and normalize favicon URLs (http/https/data URIs only)
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { BrowserFavicon } from './browser-favicon'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function render(faviconUrl: string | null, loading = false): void {
|
||||
act(() =>
|
||||
root.render(<BrowserFavicon faviconUrl={faviconUrl} loading={loading} className="size-4" />)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('BrowserFavicon', () => {
|
||||
it('renders a normalized web favicon', () => {
|
||||
render(' https://example.com/favicon.ico ')
|
||||
|
||||
expect(container.querySelector('img')?.getAttribute('src')).toBe(
|
||||
'https://example.com/favicon.ico'
|
||||
)
|
||||
expect(container.querySelector('img')?.className).toContain('size-4')
|
||||
})
|
||||
|
||||
it('falls back for invalid and failed favicons', () => {
|
||||
render('javascript:alert(1)')
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
|
||||
render('https://example.com/favicon.ico')
|
||||
act(() => container.querySelector('img')?.dispatchEvent(new Event('error')))
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('retries a failed URL after its identity changes', () => {
|
||||
const faviconUrl = 'https://example.com/favicon.ico'
|
||||
render(faviconUrl)
|
||||
act(() => container.querySelector('img')?.dispatchEvent(new Event('error')))
|
||||
|
||||
render('https://example.org/favicon.ico')
|
||||
render(faviconUrl)
|
||||
|
||||
expect(container.querySelector('img')?.getAttribute('src')).toBe(faviconUrl)
|
||||
})
|
||||
|
||||
it('retries a failed URL when a reload settles', () => {
|
||||
const faviconUrl = 'https://example.com/favicon.ico'
|
||||
render(faviconUrl)
|
||||
act(() => container.querySelector('img')?.dispatchEvent(new Event('error')))
|
||||
|
||||
render(faviconUrl, true)
|
||||
expect(container.firstElementChild?.className).toContain('motion-safe:animate-spin')
|
||||
render(faviconUrl)
|
||||
|
||||
expect(container.querySelector('img')?.getAttribute('src')).toBe(faviconUrl)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import { Globe, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { normalizeBrowserFaviconUrl } from '../../../shared/browser-favicon-url'
|
||||
|
||||
type BrowserFaviconClasses = {
|
||||
className?: string
|
||||
fallbackClassName?: string
|
||||
}
|
||||
|
||||
function FaviconFallback({
|
||||
className,
|
||||
fallbackClassName
|
||||
}: BrowserFaviconClasses): React.JSX.Element {
|
||||
return <Globe className={cn('shrink-0', className, fallbackClassName)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
function FaviconImage({
|
||||
src,
|
||||
className,
|
||||
fallbackClassName
|
||||
}: BrowserFaviconClasses & { src: string }): React.JSX.Element {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (failed) {
|
||||
return <FaviconFallback className={className} fallbackClassName={fallbackClassName} />
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
fetchPriority="low"
|
||||
className={cn(
|
||||
'shrink-0 rounded-sm object-contain drop-shadow-[0_0_1px_var(--foreground)]',
|
||||
className
|
||||
)}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrowserFavicon({
|
||||
faviconUrl,
|
||||
loading = false,
|
||||
className,
|
||||
fallbackClassName
|
||||
}: {
|
||||
faviconUrl: string | null | undefined
|
||||
loading?: boolean
|
||||
className?: string
|
||||
fallbackClassName?: string
|
||||
}): React.JSX.Element {
|
||||
if (loading) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn('shrink-0 motion-safe:animate-spin', className, fallbackClassName)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const displayUrl = normalizeBrowserFaviconUrl(faviconUrl)
|
||||
return displayUrl ? (
|
||||
<FaviconImage
|
||||
key={displayUrl}
|
||||
src={displayUrl}
|
||||
className={className}
|
||||
fallbackClassName={fallbackClassName}
|
||||
/>
|
||||
) : (
|
||||
<FaviconFallback className={className} fallbackClassName={fallbackClassName} />
|
||||
)
|
||||
}
|
||||
@@ -57,7 +57,9 @@ export type AttachBrowserPageWebviewArgs = {
|
||||
setPendingAnnotationPayload: Dispatch<SetStateAction<BrowserGrabPayload | null>>
|
||||
setBrowserOverlayViewport: Dispatch<SetStateAction<BrowserOverlayViewport>>
|
||||
setAddressBarValue: Dispatch<SetStateAction<string>>
|
||||
addBrowserHistoryEntryRef: MutableRefObject<(url: string, title: string) => void>
|
||||
addBrowserHistoryEntryRef: MutableRefObject<
|
||||
(url: string, title: string, faviconUrl?: string) => void
|
||||
>
|
||||
annotationViewportBridgeTokenRef: MutableRefObject<string>
|
||||
initialBrowserUrlRef: MutableRefObject<string>
|
||||
validateVisibleGuestRegistrationRef: MutableRefObject<() => void>
|
||||
|
||||
+3
@@ -116,6 +116,7 @@ export function bindBrowserPageWebviewListeners({
|
||||
|
||||
const {
|
||||
handleDidStartNavigation,
|
||||
handleDidRedirectNavigation,
|
||||
handleFullDidNavigate,
|
||||
handleDidNavigateInPage,
|
||||
handleTitleUpdate,
|
||||
@@ -149,6 +150,7 @@ export function bindBrowserPageWebviewListeners({
|
||||
webview.addEventListener('focus', dismissAddressBarSuggestions)
|
||||
webview.addEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.addEventListener('did-start-navigation', handleDidStartNavigation)
|
||||
webview.addEventListener('did-redirect-navigation', handleDidRedirectNavigation)
|
||||
webview.addEventListener('did-stop-loading', handleDidStopLoading)
|
||||
// Why: close find only on full 'did-navigate', not the shared handler, which also fires on SPA in-page hash/pushState changes.
|
||||
const handleFindCloseOnNavigate = (): void => {
|
||||
@@ -186,6 +188,7 @@ export function bindBrowserPageWebviewListeners({
|
||||
webview.removeEventListener('focus', dismissAddressBarSuggestions)
|
||||
webview.removeEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.removeEventListener('did-start-navigation', handleDidStartNavigation)
|
||||
webview.removeEventListener('did-redirect-navigation', handleDidRedirectNavigation)
|
||||
webview.removeEventListener('did-stop-loading', handleDidStopLoading)
|
||||
webview.removeEventListener('did-navigate', handleFullDidNavigate)
|
||||
webview.removeEventListener('did-navigate', handleFindCloseOnNavigate)
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers'
|
||||
import type { BrowserTabPageState } from '../describe-page/browser-page-types'
|
||||
|
||||
const ICON_URL = 'https://example.com/favicon.ico'
|
||||
|
||||
function createHarness(startUrl = 'https://example.com/one') {
|
||||
let currentUrl = startUrl
|
||||
const faviconUrlRef = { current: ICON_URL as string | null }
|
||||
const updatePage = vi.fn<(tabId: string, updates: BrowserTabPageState) => void>()
|
||||
const addHistory = vi.fn<(url: string, title: string, faviconUrl?: string) => void>()
|
||||
const ref = <T>(current: T) => ({ current })
|
||||
const handlers = createBrowserPageWebviewNavigationHandlers({
|
||||
webview: { getURL: () => currentUrl } as Electron.WebviewTag,
|
||||
browserTabId: 'page-1',
|
||||
browserTabUrl: startUrl,
|
||||
recoveryNavigationValidationRef: ref(null),
|
||||
activeLoadFailureRef: ref(null),
|
||||
lastKnownWebviewUrlRef: ref<string | null>(startUrl),
|
||||
addressBarInputRef: ref(null),
|
||||
onSetUrlRef: ref(vi.fn()),
|
||||
onUpdatePageStateRef: ref(updatePage),
|
||||
addBrowserHistoryEntryRef: ref(addHistory),
|
||||
faviconUrlRef,
|
||||
setAddressBarValue: vi.fn(),
|
||||
annotationViewportBridgeTokenRef: ref('token'),
|
||||
setBrowserOverlayViewport: vi.fn()
|
||||
})
|
||||
|
||||
return {
|
||||
addHistory,
|
||||
faviconUrlRef,
|
||||
handlers,
|
||||
setCurrentUrl: (url: string) => {
|
||||
currentUrl = url
|
||||
},
|
||||
updatePage
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser page favicon navigation', () => {
|
||||
it('retains an icon for same-origin navigation', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
harness.handlers.handleDidStartNavigation({
|
||||
isMainFrame: true,
|
||||
isInPlace: false,
|
||||
url: 'https://example.com/two'
|
||||
} as Electron.DidStartNavigationEvent)
|
||||
|
||||
expect(harness.faviconUrlRef.current).toBe(ICON_URL)
|
||||
expect(harness.updatePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears an icon before filing history for a different origin', () => {
|
||||
const harness = createHarness()
|
||||
const destination = 'https://example.org/two'
|
||||
|
||||
harness.handlers.handleDidStartNavigation({
|
||||
isMainFrame: true,
|
||||
isInPlace: false,
|
||||
url: destination
|
||||
} as Electron.DidStartNavigationEvent)
|
||||
harness.setCurrentUrl(destination)
|
||||
harness.handlers.handleTitleUpdate({ title: 'Example org' })
|
||||
|
||||
expect(harness.faviconUrlRef.current).toBeNull()
|
||||
expect(harness.updatePage).toHaveBeenCalledWith('page-1', { faviconUrl: null })
|
||||
expect(harness.addHistory).toHaveBeenCalledWith(destination, 'Example org', undefined)
|
||||
})
|
||||
|
||||
it('clears an icon when a same-origin navigation redirects elsewhere', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
harness.handlers.handleDidRedirectNavigation({
|
||||
isMainFrame: true,
|
||||
isInPlace: false,
|
||||
url: 'https://example.org/redirected'
|
||||
} as Electron.DidRedirectNavigationEvent)
|
||||
|
||||
expect(harness.faviconUrlRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the first displayable icon Chromium reports', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
harness.handlers.handleFaviconUpdate({
|
||||
favicons: ['data:,', 'https://example.com/second.ico']
|
||||
})
|
||||
|
||||
expect(harness.faviconUrlRef.current).toBe('https://example.com/second.ico')
|
||||
})
|
||||
})
|
||||
+1
-5
@@ -78,11 +78,7 @@ export function createBrowserPageWebviewLoadingHandlers({
|
||||
if (!trackNextLoadingEventRef.current) {
|
||||
return
|
||||
}
|
||||
faviconUrlRef.current = null
|
||||
onUpdatePageStateRef.current(browserTabId, {
|
||||
loading: true,
|
||||
faviconUrl: null
|
||||
})
|
||||
onUpdatePageStateRef.current(browserTabId, { loading: true })
|
||||
}
|
||||
|
||||
const handleDidStopLoading = (): void => {
|
||||
|
||||
+34
-10
@@ -5,6 +5,10 @@ import {
|
||||
redactKagiSessionToken
|
||||
} from '../../../../../shared/browser-url'
|
||||
import type { BrowserLoadError } from '../../../../../shared/browser-workspace-types'
|
||||
import {
|
||||
browserNavigationChangesFaviconOrigin,
|
||||
firstBrowserFaviconUrl
|
||||
} from '../../../../../shared/browser-favicon-url'
|
||||
import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery'
|
||||
import { rememberLiveBrowserUrl } from '../describe-page/live-browser-url-registry'
|
||||
import type { BrowserOverlayViewport } from '../describe-page/browser-annotation-geometry'
|
||||
@@ -30,7 +34,9 @@ export type BrowserPageWebviewNavigationHandlersArgs = {
|
||||
addressBarInputRef: RefObject<HTMLInputElement | null>
|
||||
onSetUrlRef: MutableRefObject<BrowserPageUrlSetter>
|
||||
onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void>
|
||||
addBrowserHistoryEntryRef: MutableRefObject<(url: string, title: string) => void>
|
||||
addBrowserHistoryEntryRef: MutableRefObject<
|
||||
(url: string, title: string, faviconUrl?: string) => void
|
||||
>
|
||||
faviconUrlRef: MutableRefObject<string | null>
|
||||
setAddressBarValue: Dispatch<SetStateAction<string>>
|
||||
annotationViewportBridgeTokenRef: MutableRefObject<string>
|
||||
@@ -39,6 +45,7 @@ export type BrowserPageWebviewNavigationHandlersArgs = {
|
||||
|
||||
export type BrowserPageWebviewNavigationHandlers = {
|
||||
handleDidStartNavigation: (event: Electron.DidStartNavigationEvent) => void
|
||||
handleDidRedirectNavigation: (event: Electron.DidRedirectNavigationEvent) => void
|
||||
handleFullDidNavigate: (event: BrowserPageNavigateEvent) => void
|
||||
handleDidNavigateInPage: (event: BrowserPageNavigateEvent) => void
|
||||
handleTitleUpdate: (event: { title?: string }) => void
|
||||
@@ -62,6 +69,24 @@ export function createBrowserPageWebviewNavigationHandlers({
|
||||
annotationViewportBridgeTokenRef,
|
||||
setBrowserOverlayViewport
|
||||
}: BrowserPageWebviewNavigationHandlersArgs): BrowserPageWebviewNavigationHandlers {
|
||||
const clearFaviconAcrossOrigins = (
|
||||
event: Electron.DidStartNavigationEvent | Electron.DidRedirectNavigationEvent
|
||||
): void => {
|
||||
if (!event.isMainFrame || event.isInPlace || !event.url || !faviconUrlRef.current) {
|
||||
return
|
||||
}
|
||||
let currentUrl: string | null = null
|
||||
try {
|
||||
currentUrl = webview.getURL() || null
|
||||
} catch {
|
||||
// An unattached guest has no trustworthy current origin.
|
||||
}
|
||||
if (browserNavigationChangesFaviconOrigin(currentUrl, event.url)) {
|
||||
faviconUrlRef.current = null
|
||||
onUpdatePageStateRef.current(browserTabId, { faviconUrl: null })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDidStartNavigation = (event: Electron.DidStartNavigationEvent): void => {
|
||||
if (!event.isMainFrame || event.isInPlace || !event.url) {
|
||||
return
|
||||
@@ -72,8 +97,13 @@ export function createBrowserPageWebviewNavigationHandlers({
|
||||
if (pendingRecoveryNavigation?.targetUrl === startedUrl) {
|
||||
pendingRecoveryNavigation.started = true
|
||||
}
|
||||
// Chromium may not re-announce an unchanged favicon after a same-origin navigation.
|
||||
clearFaviconAcrossOrigins(event)
|
||||
}
|
||||
|
||||
const handleDidRedirectNavigation = (event: Electron.DidRedirectNavigationEvent): void =>
|
||||
clearFaviconAcrossOrigins(event)
|
||||
|
||||
const handleDidNavigate = (
|
||||
event: { url?: string; isMainFrame?: boolean },
|
||||
persistUrl = true,
|
||||
@@ -127,21 +157,14 @@ export function createBrowserPageWebviewNavigationHandlers({
|
||||
const browserModelUrl = redactKagiSessionToken(currentUrl)
|
||||
const title = getBrowserDisplayTitle(event.title, browserModelUrl)
|
||||
onUpdatePageStateRef.current(browserTabId, { title })
|
||||
addBrowserHistoryEntryRef.current(browserModelUrl, title)
|
||||
addBrowserHistoryEntryRef.current(browserModelUrl, title, faviconUrlRef.current ?? undefined)
|
||||
} catch {
|
||||
// Why: title-updated can fire before dom-ready, making getURL() throw.
|
||||
}
|
||||
}
|
||||
|
||||
const handleFaviconUpdate = (event: { favicons?: string[] }): void => {
|
||||
const faviconUrl = event.favicons?.[0] ?? null
|
||||
faviconUrlRef.current =
|
||||
faviconUrl &&
|
||||
(faviconUrl.startsWith('https://') ||
|
||||
faviconUrl.startsWith('http://') ||
|
||||
faviconUrl.startsWith('data:image/'))
|
||||
? faviconUrl
|
||||
: null
|
||||
faviconUrlRef.current = firstBrowserFaviconUrl(event.favicons)
|
||||
onUpdatePageStateRef.current(browserTabId, { faviconUrl: faviconUrlRef.current })
|
||||
}
|
||||
|
||||
@@ -173,6 +196,7 @@ export function createBrowserPageWebviewNavigationHandlers({
|
||||
|
||||
return {
|
||||
handleDidStartNavigation,
|
||||
handleDidRedirectNavigation,
|
||||
handleFullDidNavigate,
|
||||
handleDidNavigateInPage,
|
||||
handleTitleUpdate,
|
||||
|
||||
@@ -69,6 +69,9 @@ vi.mock('lucide-react', () => ({
|
||||
Globe: function Globe(props: Record<string, unknown>) {
|
||||
return { type: 'Globe', props }
|
||||
},
|
||||
Loader2: function Loader2(props: Record<string, unknown>) {
|
||||
return { type: 'Loader2', props }
|
||||
},
|
||||
Pin: function Pin(props: Record<string, unknown>) {
|
||||
return { type: 'Pin', props }
|
||||
},
|
||||
@@ -256,7 +259,9 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
|
||||
expect(images[0].props.alt).toBe('')
|
||||
expect(images[0].props['aria-hidden']).toBe(true)
|
||||
expect(images[0].props.draggable).toBe(false)
|
||||
expect(images[0].props.className).toContain('size-3 mr-1 shrink-0')
|
||||
expect(images[0].props.className).toContain('size-3')
|
||||
expect(images[0].props.className).toContain('mr-1')
|
||||
expect(images[0].props.className).toContain('shrink-0')
|
||||
expect(images[0].props.className).toContain('object-contain')
|
||||
expect(images[0].props.className).toContain('drop-shadow-[0_0_1px_var(--foreground)]')
|
||||
expect(findElementsByType(element, 'Globe')).toHaveLength(0)
|
||||
@@ -274,7 +279,9 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
|
||||
expect(findElementsByType(element, 'img')).toHaveLength(0)
|
||||
const globes = findElementsByType(element, 'Globe')
|
||||
expect(globes).toHaveLength(1)
|
||||
expect(globes[0].props.className).toContain('size-3 mr-1 shrink-0')
|
||||
expect(globes[0].props.className).toContain('size-3')
|
||||
expect(globes[0].props.className).toContain('mr-1')
|
||||
expect(globes[0].props.className).toContain('shrink-0')
|
||||
expect(globes[0].props.className).toContain('text-blue-500')
|
||||
})
|
||||
|
||||
@@ -289,24 +296,4 @@ describe('BrowserTab favicon', { timeout: 30_000 }, () => {
|
||||
expect(findElementsByType(secondRender, 'img')).toHaveLength(0)
|
||||
expect(findElementsByType(secondRender, 'Globe')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resets the image-error fallback when faviconUrl changes', async () => {
|
||||
const tab = baseBrowserTab({ faviconUrl: 'https://example.com/favicon.ico' })
|
||||
const firstRender = await renderExpandedBrowserTab(tab)
|
||||
const image = findElementsByType(firstRender, 'img')[0]
|
||||
|
||||
;(image.props.onError as () => void)()
|
||||
const failedRender = await renderExpandedBrowserTab(tab)
|
||||
expect(findElementsByType(failedRender, 'Globe')).toHaveLength(1)
|
||||
|
||||
const nextIconUrl = 'data:image/png;base64,abc123'
|
||||
const resetRender = await renderExpandedBrowserTab(
|
||||
baseBrowserTab({ id: tab.id, faviconUrl: nextIconUrl })
|
||||
)
|
||||
|
||||
const images = findElementsByType(resetRender, 'img')
|
||||
expect(images).toHaveLength(1)
|
||||
expect(images[0].props.src).toBe(nextIconUrl)
|
||||
expect(findElementsByType(resetRender, 'Globe')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import {
|
||||
Globe,
|
||||
X,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
@@ -39,6 +38,7 @@ import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { BrowserFavicon } from '@/components/browser-favicon'
|
||||
|
||||
export function formatBrowserTabUrlLabel(url: string): string {
|
||||
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
|
||||
@@ -68,51 +68,6 @@ function isBlankBrowserTab(tab: BrowserTabState): boolean {
|
||||
return tab.url === ORCA_BROWSER_BLANK_URL || tab.url === 'about:blank'
|
||||
}
|
||||
|
||||
type FailedFavicon = {
|
||||
tabId: string
|
||||
faviconUrl: string
|
||||
}
|
||||
|
||||
function BrowserTabFavicon({
|
||||
tabId,
|
||||
faviconUrl
|
||||
}: {
|
||||
tabId: string
|
||||
faviconUrl: string | null
|
||||
}): React.JSX.Element {
|
||||
const displayFaviconUrl = faviconUrl?.trim() ? faviconUrl : null
|
||||
const [failedFavicon, setFailedFavicon] = useState<FailedFavicon | null>(null)
|
||||
|
||||
// Why: reset during render so a new favicon identity retries before the tab
|
||||
// commits one frame with the stale fallback icon.
|
||||
if (
|
||||
failedFavicon &&
|
||||
(failedFavicon.tabId !== tabId || failedFavicon.faviconUrl !== displayFaviconUrl)
|
||||
) {
|
||||
setFailedFavicon(null)
|
||||
}
|
||||
|
||||
const currentFaviconFailed =
|
||||
failedFavicon?.tabId === tabId && failedFavicon.faviconUrl === displayFaviconUrl
|
||||
|
||||
if (displayFaviconUrl && !currentFaviconFailed) {
|
||||
return (
|
||||
<img
|
||||
src={displayFaviconUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
// Why: transparent dark/light-mode favicons can disappear against tab
|
||||
// chrome; a token-colored 1px shadow keeps the 12px mark legible.
|
||||
className="size-3 mr-1 shrink-0 rounded-sm object-contain drop-shadow-[0_0_1px_var(--foreground)]"
|
||||
onError={() => setFailedFavicon({ tabId, faviconUrl: displayFaviconUrl })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <Globe className="size-3 mr-1 shrink-0 text-blue-500" />
|
||||
}
|
||||
|
||||
export default function BrowserTab({
|
||||
tab,
|
||||
isActive,
|
||||
@@ -227,19 +182,15 @@ export default function BrowserTab({
|
||||
}}
|
||||
>
|
||||
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{/* Why: the browser tab icon is the only non-terminal, non-editor
|
||||
surface in the tab strip. Coloring the Globe blue (matching the
|
||||
in-app browser's identity and the default tab insertion bar)
|
||||
gives it a distinct, recognizable anchor so users can spot
|
||||
browser tabs at a glance even when the strip is saturated. We
|
||||
keep full color on both active and inactive tabs — dimming to
|
||||
muted-foreground made the icon read as "disabled" in practice. */}
|
||||
<BrowserTabFavicon tabId={tab.id} faviconUrl={tab.faviconUrl} />
|
||||
{/* Keep favicons full-color; muted icons read as disabled in the tab strip. */}
|
||||
<BrowserFavicon
|
||||
faviconUrl={tab.faviconUrl}
|
||||
loading={tab.loading && !tab.loadError && !isBlankBrowserTab(tab)}
|
||||
className="size-3 mr-1"
|
||||
fallbackClassName="text-blue-500"
|
||||
/>
|
||||
{isPinned && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
<span className={`${TAB_LABEL_WIDTH_CLASSES} mr-1`}>{tabLabel}</span>
|
||||
{tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && (
|
||||
<span className="mr-1 size-1.5 rounded-full bg-sky-500/80 shrink-0" />
|
||||
)}
|
||||
{!isPinned && (
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
|
||||
@@ -169,6 +169,16 @@ describe('TabBarCreateEntry browser history rows', () => {
|
||||
expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the favicon captured with a history entry', () => {
|
||||
const faviconUrl = 'https://linear.app/favicon.ico'
|
||||
historyStoreMock.entries = [historyEntry({ ...linear, faviconUrl })]
|
||||
mount()
|
||||
|
||||
setQuery('linear')
|
||||
|
||||
expect(container.querySelector<HTMLImageElement>('[role="option"] img')?.src).toBe(faviconUrl)
|
||||
})
|
||||
|
||||
it('skips history for a path-shaped query and for a forced search', () => {
|
||||
pathLikeMock.value = true
|
||||
mount()
|
||||
@@ -192,7 +202,8 @@ describe('TabBarCreateEntry browser history rows', () => {
|
||||
contentType: 'browser',
|
||||
pageId: 'page-1',
|
||||
workspaceId: 'ws-1',
|
||||
url: 'https://linear.app/acme/team/ORC/active'
|
||||
url: 'https://linear.app/acme/team/ORC/active',
|
||||
faviconUrl: null
|
||||
}
|
||||
]
|
||||
mount()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url'
|
||||
import { formatBrowserHistoryUrl } from '@/lib/browser-history-match'
|
||||
import type { ActiveOption } from './tab-create-entry-active-option'
|
||||
import { BrowserFavicon } from '@/components/browser-favicon'
|
||||
|
||||
export const RESULT_LISTBOX_ID = 'tab-create-entry-results'
|
||||
|
||||
@@ -138,7 +139,7 @@ function getOpenTabIcon(option: Extract<ActiveOption, { kind: 'tab' }>['option']
|
||||
return <TerminalSquare className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
}
|
||||
if (contentType === 'browser') {
|
||||
return <Globe className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
return <BrowserFavicon faviconUrl={option.faviconUrl} className="size-3.5" />
|
||||
}
|
||||
if (contentType === 'simulator') {
|
||||
return <Smartphone className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
@@ -191,7 +192,7 @@ function getActionPresentation(option: ActiveOption): {
|
||||
// Why the title is detail, not label: the label span is shrink-0 whenever a
|
||||
// detail shows, so a variable-length title there would refuse to truncate.
|
||||
detail: entry.title ? `${entry.title} · ${url}` : url,
|
||||
icon: <Globe className="size-3.5 shrink-0" aria-hidden="true" />,
|
||||
icon: <BrowserFavicon faviconUrl={entry.faviconUrl} className="size-3.5" />,
|
||||
label: translate('auto.components.tab.bar.TabBarCreateEntry.openPage', 'Open page'),
|
||||
showDetail: true
|
||||
}
|
||||
|
||||
@@ -90,7 +90,8 @@ describe('dropFileEntriesCoveredByTabResults', () => {
|
||||
contentType: 'browser',
|
||||
pageId: 'page-1',
|
||||
url: 'https://example.com/zebra',
|
||||
workspaceId: 'ws-1'
|
||||
workspaceId: 'ws-1',
|
||||
faviconUrl: null
|
||||
},
|
||||
{
|
||||
executionHostId: 'local',
|
||||
|
||||
@@ -124,12 +124,14 @@ function makeBrowserPage({
|
||||
id,
|
||||
title,
|
||||
url = 'https://example.com/one',
|
||||
faviconUrl = null,
|
||||
workspaceLabel = null,
|
||||
isCurrentPage = false
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
url?: string
|
||||
faviconUrl?: string | null
|
||||
workspaceLabel?: string | null
|
||||
isCurrentPage?: boolean
|
||||
}): SearchableBrowserPage {
|
||||
@@ -140,7 +142,7 @@ function makeBrowserPage({
|
||||
url,
|
||||
title,
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
faviconUrl,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
@@ -445,10 +447,11 @@ describe('searchOpenTabs result fields', () => {
|
||||
})
|
||||
|
||||
it('carries the activation identifiers each source needs', () => {
|
||||
const faviconUrl = 'https://example.com/favicon.ico'
|
||||
const results = search({
|
||||
query: 'zebra',
|
||||
workspaceTabs: [makeWorkspaceTab({ id: 'tab-1', title: 'Zebra tab' })],
|
||||
browserPages: [makeBrowserPage({ id: 'page-1', title: 'Zebra page' })],
|
||||
browserPages: [makeBrowserPage({ id: 'page-1', title: 'Zebra page', faviconUrl })],
|
||||
simulatorTabs: [makeSimulatorTab({ id: 'sim-1', label: 'Zebra emulator' })]
|
||||
})
|
||||
|
||||
@@ -467,7 +470,8 @@ describe('searchOpenTabs result fields', () => {
|
||||
contentType: 'browser',
|
||||
pageId: 'page-1',
|
||||
workspaceId: 'page-1-ws',
|
||||
worktreeId: 'wt-1'
|
||||
worktreeId: 'wt-1',
|
||||
faviconUrl
|
||||
},
|
||||
{
|
||||
source: 'simulator',
|
||||
|
||||
@@ -58,6 +58,7 @@ export type OpenTabSearchResult =
|
||||
pageId: string
|
||||
workspaceId: string
|
||||
url: string
|
||||
faviconUrl: string | null
|
||||
})
|
||||
| (OpenTabSearchResultBase & {
|
||||
source: 'simulator'
|
||||
@@ -206,7 +207,8 @@ export function searchOpenTabs({
|
||||
contentType: 'browser',
|
||||
pageId: result.pageId,
|
||||
workspaceId: result.workspaceId,
|
||||
url: result.url
|
||||
url: result.url,
|
||||
faviconUrl: result.faviconUrl
|
||||
})),
|
||||
...rank('simulator', searchSimulatorTabs([...simulatorTabs], trimmed), (result) => ({
|
||||
...baseResult('simulator', result.tabId, result, executionHostId),
|
||||
|
||||
@@ -63,7 +63,8 @@ const browserResult: OpenTabSearchResult = {
|
||||
contentType: 'browser',
|
||||
pageId: 'page-1',
|
||||
workspaceId: 'ws-1',
|
||||
url: 'https://example.com/docs'
|
||||
url: 'https://example.com/docs',
|
||||
faviconUrl: null
|
||||
}
|
||||
|
||||
const simulatorResult: OpenTabSearchResult = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { Globe, Smartphone } from 'lucide-react'
|
||||
import { Smartphone } from 'lucide-react'
|
||||
import { CommandItem } from '@/components/ui/command'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './worktree-jump-palette-primitives'
|
||||
import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age'
|
||||
import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution'
|
||||
import { BrowserFavicon } from '@/components/browser-favicon'
|
||||
|
||||
export function WorktreeJumpPaletteSimulatorRow({
|
||||
entry,
|
||||
@@ -147,7 +148,7 @@ export function WorktreeJumpPaletteBrowserRow({
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 w-4 shrink-0 items-center justify-center self-start text-muted-foreground/85">
|
||||
<Globe className="size-3.5" aria-hidden="true" />
|
||||
<BrowserFavicon faviconUrl={result.faviconUrl} className="size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
|
||||
@@ -102,6 +102,26 @@ describe('browser-palette-search', () => {
|
||||
expect(result.executionHostId).toBe('ssh:box')
|
||||
})
|
||||
|
||||
it('carries the page favicon into palette results', () => {
|
||||
const faviconUrl = 'https://example.com/favicon.ico'
|
||||
const [result] = searchBrowserPages(
|
||||
[
|
||||
makeEntry({
|
||||
page: makePage({ faviconUrl }),
|
||||
workspace: makeWorkspace(),
|
||||
worktree: makeWorktree(),
|
||||
repoName: 'repo/one',
|
||||
worktreeSortIndex: 0,
|
||||
isCurrentPage: false,
|
||||
isCurrentWorktree: false
|
||||
})
|
||||
],
|
||||
''
|
||||
)
|
||||
|
||||
expect(result.faviconUrl).toBe(faviconUrl)
|
||||
})
|
||||
|
||||
it('keeps empty-query ordering deterministic and context-first', () => {
|
||||
const results = searchBrowserPages(
|
||||
[
|
||||
|
||||
@@ -42,6 +42,7 @@ export type BrowserPaletteSearchResult = {
|
||||
workspaceId: string
|
||||
worktreeId: string
|
||||
title: string
|
||||
faviconUrl: string | null
|
||||
/** Raw page URL, so callers can dedupe a row against another list of destinations. */
|
||||
url: string
|
||||
secondaryText: string
|
||||
@@ -153,6 +154,7 @@ function baseResult(entry: SearchableBrowserPage): BrowserPaletteSearchResult {
|
||||
workspaceId: entry.workspace.id,
|
||||
worktreeId: entry.worktree.id,
|
||||
title: entry.page.title || formattedUrl,
|
||||
faviconUrl: entry.page.faviconUrl,
|
||||
url: entry.page.url,
|
||||
secondaryText: formattedUrl,
|
||||
workspaceLabel: entry.workspace.label ?? null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH } from '../../../../shared/browser-favicon-url'
|
||||
import {
|
||||
createBrowserMockApi,
|
||||
createTestStore,
|
||||
@@ -302,6 +303,34 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
|
||||
})
|
||||
|
||||
it('persists a captured favicon with history and refreshes it with the page state', () => {
|
||||
const store = createTestStore()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
}
|
||||
const initialFavicon = 'https://example.com/favicon.ico'
|
||||
const refreshedFavicon = 'https://cdn.example.com/favicon.png'
|
||||
|
||||
store.getState().addBrowserHistoryEntry('https://example.com', 'Example', initialFavicon)
|
||||
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(initialFavicon)
|
||||
|
||||
store.getState().updateBrowserPageState(pageId, { faviconUrl: refreshedFavicon })
|
||||
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(refreshedFavicon)
|
||||
|
||||
store
|
||||
.getState()
|
||||
.addBrowserHistoryEntry(
|
||||
'https://large-icon.example',
|
||||
'Large icon',
|
||||
`data:image/png,${'a'.repeat(MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH)}`
|
||||
)
|
||||
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => {
|
||||
const store = createTestStore()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../../../../shared/workspace-doc-history'
|
||||
import { browserPageDocLocationsEqual } from '../../../../../shared/browser-page-doc-location'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
|
||||
import { normalizePersistedBrowserFaviconUrl } from '../../../../../shared/browser-favicon-url'
|
||||
|
||||
export function createBrowserHistoryActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -60,18 +61,25 @@ export function createBrowserHistoryActions(
|
||||
})
|
||||
},
|
||||
|
||||
addBrowserHistoryEntry: (url, title) => {
|
||||
addBrowserHistoryEntry: (url, title, faviconUrl) => {
|
||||
const safeUrl = redactKagiSessionToken(url)
|
||||
if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) {
|
||||
return
|
||||
}
|
||||
const normalized = normalizeBrowserHistoryUrl(safeUrl)
|
||||
const normalizedFaviconUrl = normalizePersistedBrowserFaviconUrl(faviconUrl)
|
||||
set((s) => {
|
||||
const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized)
|
||||
let next: BrowserHistoryEntry[] = existing
|
||||
? s.browserUrlHistory.map((entry) =>
|
||||
entry === existing
|
||||
? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 }
|
||||
? {
|
||||
...entry,
|
||||
title,
|
||||
...(normalizedFaviconUrl ? { faviconUrl: normalizedFaviconUrl } : {}),
|
||||
lastVisitedAt: Date.now(),
|
||||
visitCount: entry.visitCount + 1
|
||||
}
|
||||
: entry
|
||||
)
|
||||
: [
|
||||
@@ -79,6 +87,7 @@ export function createBrowserHistoryActions(
|
||||
url: safeUrl,
|
||||
normalizedUrl: normalized,
|
||||
title,
|
||||
...(normalizedFaviconUrl ? { faviconUrl: normalizedFaviconUrl } : {}),
|
||||
lastVisitedAt: Date.now(),
|
||||
visitCount: 1
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
normalizeBrowserTitle,
|
||||
normalizeUrl
|
||||
} from '../browser-page-records'
|
||||
import { normalizeBrowserHistoryUrl } from '../../../../../shared/workspace-session-browser-history'
|
||||
import { normalizePersistedBrowserFaviconUrl } from '../../../../../shared/browser-favicon-url'
|
||||
|
||||
export function createBrowserPageStateActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -100,6 +102,20 @@ export function createBrowserPageStateActions(
|
||||
[workspace.id]: nextPages
|
||||
}
|
||||
}
|
||||
const historyFaviconUrl = normalizePersistedBrowserFaviconUrl(updates.faviconUrl)
|
||||
if (historyFaviconUrl) {
|
||||
// Title and favicon events can arrive in either order; refresh history when the icon wins.
|
||||
const normalizedPageUrl = normalizeBrowserHistoryUrl(page.url)
|
||||
const historyIndex = s.browserUrlHistory.findIndex(
|
||||
(entry) => entry.normalizedUrl === normalizedPageUrl
|
||||
)
|
||||
const historyEntry = s.browserUrlHistory[historyIndex]
|
||||
if (historyEntry && historyEntry.faviconUrl !== historyFaviconUrl) {
|
||||
nextState.browserUrlHistory = s.browserUrlHistory.map((entry, index) =>
|
||||
index === historyIndex ? { ...entry, faviconUrl: historyFaviconUrl } : entry
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) {
|
||||
nextState.browserTabsByWorktree = {
|
||||
...s.browserTabsByWorktree,
|
||||
|
||||
@@ -239,7 +239,7 @@ export type BrowserSlice = {
|
||||
) => Promise<BrowserCookieImportExecutionResult>
|
||||
clearDefaultSessionCookies: () => Promise<boolean>
|
||||
browserUrlHistory: BrowserHistoryEntry[]
|
||||
addBrowserHistoryEntry: (url: string, title: string) => void
|
||||
addBrowserHistoryEntry: (url: string, title: string, faviconUrl?: string) => void
|
||||
workspaceDocHistory: WorkspaceDocHistoryEntry[]
|
||||
/** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */
|
||||
recordWorkspaceDocVisit: (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH,
|
||||
browserNavigationChangesFaviconOrigin,
|
||||
firstBrowserFaviconUrl,
|
||||
normalizeBrowserFaviconUrl,
|
||||
normalizePersistedBrowserFaviconUrl
|
||||
} from './browser-favicon-url'
|
||||
|
||||
describe('normalizeBrowserFaviconUrl', () => {
|
||||
it('accepts web and bounded image data URLs', () => {
|
||||
expect(normalizeBrowserFaviconUrl(' https://example.com/icon.png ')).toBe(
|
||||
'https://example.com/icon.png'
|
||||
)
|
||||
expect(normalizeBrowserFaviconUrl('data:image/png;base64,AAAA')).toBe(
|
||||
'data:image/png;base64,AAAA'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unsupported URLs', () => {
|
||||
expect(normalizeBrowserFaviconUrl('file:///tmp/icon.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds persisted favicons without restricting live display', () => {
|
||||
const oversized = `data:image/png,${'a'.repeat(MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH)}`
|
||||
expect(normalizeBrowserFaviconUrl(oversized)).toBe(oversized)
|
||||
expect(normalizePersistedBrowserFaviconUrl(oversized)).toBeNull()
|
||||
})
|
||||
|
||||
it('takes the first usable favicon', () => {
|
||||
expect(firstBrowserFaviconUrl(['data:,', 'https://example.com/icon.png'])).toBe(
|
||||
'https://example.com/icon.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserNavigationChangesFaviconOrigin', () => {
|
||||
it('retains same-origin favicons and clears cross-origin ones', () => {
|
||||
expect(
|
||||
browserNavigationChangesFaviconOrigin('https://example.com/one', 'https://example.com/two')
|
||||
).toBe(false)
|
||||
expect(
|
||||
browserNavigationChangesFaviconOrigin('https://example.com', 'https://example.org')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('clears favicons for non-web destinations but preserves them when origin is unknown', () => {
|
||||
expect(browserNavigationChangesFaviconOrigin('https://example.com', 'about:blank')).toBe(true)
|
||||
expect(browserNavigationChangesFaviconOrigin(null, 'https://example.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// Favicons render at icon scale; bound page-controlled data before it reaches session history.
|
||||
export const MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH = 64 * 1024
|
||||
|
||||
export function normalizeBrowserFaviconUrl(faviconUrl: string | null | undefined): string | null {
|
||||
const trimmed = faviconUrl?.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
if (trimmed.startsWith('data:image/')) {
|
||||
return trimmed
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePersistedBrowserFaviconUrl(
|
||||
faviconUrl: string | null | undefined
|
||||
): string | null {
|
||||
const normalized = normalizeBrowserFaviconUrl(faviconUrl)
|
||||
return normalized && normalized.length <= MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH
|
||||
? normalized
|
||||
: null
|
||||
}
|
||||
|
||||
export function firstBrowserFaviconUrl(favicons: readonly string[] | undefined): string | null {
|
||||
for (const favicon of favicons ?? []) {
|
||||
const normalized = normalizeBrowserFaviconUrl(favicon)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function browserOrigin(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function browserNavigationChangesFaviconOrigin(
|
||||
currentUrl: string | null,
|
||||
destinationUrl: string
|
||||
): boolean {
|
||||
const destinationOrigin = browserOrigin(destinationUrl)
|
||||
if (!destinationOrigin) {
|
||||
return true
|
||||
}
|
||||
return currentUrl ? browserOrigin(currentUrl) !== destinationOrigin : false
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type BrowserHistoryEntry = {
|
||||
url: string
|
||||
normalizedUrl: string
|
||||
title: string
|
||||
faviconUrl?: string
|
||||
lastVisitedAt: number
|
||||
visitCount: number
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { normalizeBrowserHistoryEntries } from './workspace-session-browser-hist
|
||||
import { normalizeWorkspaceDocHistoryEntries } from './workspace-doc-history'
|
||||
import { isDocPreviewUrl } from './doc-preview-scheme'
|
||||
import { salvagingArray } from './zod-salvage'
|
||||
import { normalizePersistedBrowserFaviconUrl } from './browser-favicon-url'
|
||||
|
||||
const browserLoadErrorSchema = z.object({
|
||||
code: z.number(),
|
||||
@@ -113,13 +114,19 @@ export const browserPageSchema = z.object({
|
||||
convertedTo: browserPageConversionOriginSchema
|
||||
})
|
||||
|
||||
const browserHistoryEntrySchema = z.object({
|
||||
url: z.string(),
|
||||
normalizedUrl: z.string(),
|
||||
title: z.string(),
|
||||
lastVisitedAt: z.number(),
|
||||
visitCount: z.number()
|
||||
})
|
||||
const browserHistoryEntrySchema = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
normalizedUrl: z.string(),
|
||||
title: z.string(),
|
||||
faviconUrl: z.string().nullable().optional(),
|
||||
lastVisitedAt: z.number(),
|
||||
visitCount: z.number()
|
||||
})
|
||||
.transform(({ faviconUrl, ...entry }) => {
|
||||
const normalizedFaviconUrl = normalizePersistedBrowserFaviconUrl(faviconUrl)
|
||||
return normalizedFaviconUrl ? { ...entry, faviconUrl: normalizedFaviconUrl } : entry
|
||||
})
|
||||
|
||||
export const browserHistoryEntriesSchema = salvagingArray(browserHistoryEntrySchema).transform(
|
||||
(entries) => normalizeBrowserHistoryEntries(entries)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseWorkspaceSession } from './workspace-session-schema'
|
||||
import { MAX_BROWSER_HISTORY_ENTRIES } from './workspace-session-browser-history'
|
||||
import { MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH } from './browser-favicon-url'
|
||||
|
||||
describe('parseWorkspaceSession', () => {
|
||||
it('accepts a minimal valid session', () => {
|
||||
@@ -504,6 +505,12 @@ describe('parseWorkspaceSession', () => {
|
||||
url: `https://example.com/${index}`,
|
||||
normalizedUrl: `https://example.com/${index}`,
|
||||
title: `Example ${index}`,
|
||||
faviconUrl:
|
||||
index === 0
|
||||
? 'https://example.com/favicon.ico'
|
||||
: index === 1
|
||||
? `data:image/png,${'a'.repeat(MAX_PERSISTED_BROWSER_FAVICON_URL_LENGTH)}`
|
||||
: null,
|
||||
lastVisitedAt: 1_700_000_000_000 - index,
|
||||
visitCount: 1
|
||||
}))
|
||||
@@ -512,6 +519,10 @@ describe('parseWorkspaceSession', () => {
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
|
||||
expect(result.value.browserUrlHistory?.[0]?.faviconUrl).toBe(
|
||||
'https://example.com/favicon.ico'
|
||||
)
|
||||
expect(result.value.browserUrlHistory?.[1]?.faviconUrl).toBeUndefined()
|
||||
expect(result.value.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user