From 3419c4f93ac2c584d872aebe2a482e08dfcd81eb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:02:06 -0700 Subject: [PATCH] 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) --- .../src/components/browser-favicon.test.tsx | 71 ++++++++++++++ .../src/components/browser-favicon.tsx | 75 +++++++++++++++ .../host-guest/attach-browser-page-webview.ts | 4 +- .../bind-browser-page-webview-listeners.ts | 3 + .../browser-page-favicon-navigation.test.ts | 93 +++++++++++++++++++ .../browser-page-webview-loading-handlers.ts | 6 +- ...rowser-page-webview-navigation-handlers.ts | 44 +++++++-- .../components/tab-bar/BrowserTab.test.tsx | 31 ++----- .../src/components/tab-bar/BrowserTab.tsx | 65 ++----------- .../TabBarCreateEntry.history.test.tsx | 13 ++- .../tab-bar/TabBarCreateEntryRow.tsx | 5 +- .../tab-bar/open-tab-entry-dedupe.test.ts | 3 +- .../tab-bar/open-tab-search.test.ts | 10 +- .../src/components/tab-bar/open-tab-search.ts | 4 +- .../open-tab-selection-routing.test.ts | 3 +- ...ee-jump-palette-browser-simulator-rows.tsx | 5 +- .../src/lib/browser-palette-search.test.ts | 20 ++++ .../src/lib/browser-palette-search.ts | 2 + src/renderer/src/store/slices/browser.test.ts | 29 ++++++ .../slices/browser/browser-history-actions.ts | 13 ++- .../browser/browser-page-state-actions.ts | 16 ++++ .../slices/browser/browser-slice-contract.ts | 2 +- src/shared/browser-favicon-url.test.ts | 51 ++++++++++ src/shared/browser-favicon-url.ts | 57 ++++++++++++ src/shared/browser-workspace-types.ts | 1 + .../workspace-session-browser-schema.ts | 21 +++-- src/shared/workspace-session-schema.test.ts | 11 +++ 27 files changed, 542 insertions(+), 116 deletions(-) create mode 100644 src/renderer/src/components/browser-favicon.test.tsx create mode 100644 src/renderer/src/components/browser-favicon.tsx create mode 100644 src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-navigation.test.ts create mode 100644 src/shared/browser-favicon-url.test.ts create mode 100644 src/shared/browser-favicon-url.ts diff --git a/src/renderer/src/components/browser-favicon.test.tsx b/src/renderer/src/components/browser-favicon.test.tsx new file mode 100644 index 00000000000..2812e7a73fb --- /dev/null +++ b/src/renderer/src/components/browser-favicon.test.tsx @@ -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() + ) +} + +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) + }) +}) diff --git a/src/renderer/src/components/browser-favicon.tsx b/src/renderer/src/components/browser-favicon.tsx new file mode 100644 index 00000000000..ec68a14dc14 --- /dev/null +++ b/src/renderer/src/components/browser-favicon.tsx @@ -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