diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx
index c85afab22c..75c6c284c2 100644
--- a/src/renderer/src/pages/minapps/MinAppPage.tsx
+++ b/src/renderer/src/pages/minapps/MinAppPage.tsx
@@ -14,6 +14,7 @@ import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar'
+import WebviewSearch from './components/WebviewSearch'
const logger = loggerService.withContext('MinAppPage')
@@ -184,6 +185,7 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools}
/>
+
{!isReady && (
diff --git a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx
new file mode 100644
index 0000000000..80b88f9c1f
--- /dev/null
+++ b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx
@@ -0,0 +1,298 @@
+import { Button, Input } from '@heroui/react'
+import { loggerService } from '@logger'
+import type { WebviewTag } from 'electron'
+import { ChevronDown, ChevronUp, X } from 'lucide-react'
+import { FC, useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+type FoundInPageResult = Electron.FoundInPageResult
+
+interface WebviewSearchProps {
+ webviewRef: React.RefObject
+ isWebviewReady: boolean
+ appId: string
+}
+
+const logger = loggerService.withContext('WebviewSearch')
+
+const WebviewSearch: FC = ({ webviewRef, isWebviewReady, appId }) => {
+ const { t } = useTranslation()
+ const [isVisible, setIsVisible] = useState(false)
+ const [query, setQuery] = useState('')
+ const [matchCount, setMatchCount] = useState(0)
+ const [activeIndex, setActiveIndex] = useState(0)
+ const [currentWebview, setCurrentWebview] = useState(null)
+ const inputRef = useRef(null)
+ const focusFrameRef = useRef(null)
+ const lastAppIdRef = useRef(appId)
+ const attachedWebviewRef = useRef(null)
+
+ const focusInput = useCallback(() => {
+ if (focusFrameRef.current !== null) {
+ window.cancelAnimationFrame(focusFrameRef.current)
+ focusFrameRef.current = null
+ }
+ focusFrameRef.current = window.requestAnimationFrame(() => {
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ })
+ }, [])
+
+ const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
+ if (!options?.keepQuery) {
+ setQuery('')
+ }
+ setMatchCount(0)
+ setActiveIndex(0)
+ }, [])
+
+ const stopSearch = useCallback(() => {
+ const target = webviewRef.current ?? attachedWebviewRef.current
+ if (!target) return
+ try {
+ target.stopFindInPage('clearSelection')
+ } catch (error) {
+ logger.error('stopFindInPage failed', { error })
+ }
+ }, [webviewRef])
+
+ const closeSearch = useCallback(() => {
+ setIsVisible(false)
+ stopSearch()
+ resetSearchState({ keepQuery: true })
+ }, [resetSearchState, stopSearch])
+
+ const performSearch = useCallback(
+ (text: string, options?: Electron.FindInPageOptions) => {
+ const target = webviewRef.current ?? attachedWebviewRef.current
+ if (!target) {
+ logger.debug('Skip performSearch: webview not attached')
+ return
+ }
+ if (!text) {
+ stopSearch()
+ resetSearchState({ keepQuery: true })
+ return
+ }
+ try {
+ target.findInPage(text, options)
+ } catch (error) {
+ logger.error('findInPage failed', { error })
+ window.toast?.error(t('common.error'))
+ }
+ },
+ [resetSearchState, stopSearch, t, webviewRef]
+ )
+
+ const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
+ if (!event.result) return
+
+ const { activeMatchOrdinal, matches } = event.result
+
+ if (matches !== undefined) {
+ setMatchCount(matches)
+ }
+
+ if (activeMatchOrdinal !== undefined) {
+ setActiveIndex(activeMatchOrdinal)
+ }
+ }, [])
+
+ const openSearch = useCallback(() => {
+ if (!isWebviewReady) {
+ logger.debug('Skip openSearch: webview not ready')
+ return
+ }
+ setIsVisible(true)
+ focusInput()
+ }, [focusInput, isWebviewReady])
+
+ const goToNext = useCallback(() => {
+ if (!query) return
+ performSearch(query, { forward: true, findNext: true })
+ }, [performSearch, query])
+
+ const goToPrevious = useCallback(() => {
+ if (!query) return
+ performSearch(query, { forward: false, findNext: true })
+ }, [performSearch, query])
+
+ useEffect(() => {
+ const nextWebview = webviewRef.current ?? null
+ if (currentWebview === nextWebview) return
+ setCurrentWebview(nextWebview)
+ })
+
+ useEffect(() => {
+ const target = currentWebview
+ if (!target) {
+ attachedWebviewRef.current = null
+ return
+ }
+
+ const handle = handleFoundInPage
+ attachedWebviewRef.current = target
+ target.addEventListener('found-in-page', handle)
+
+ return () => {
+ target.removeEventListener('found-in-page', handle)
+ if (attachedWebviewRef.current === target) {
+ try {
+ target.stopFindInPage('clearSelection')
+ } catch (error) {
+ logger.error('stopFindInPage failed', { error })
+ }
+ attachedWebviewRef.current = null
+ }
+ }
+ }, [currentWebview, handleFoundInPage])
+
+ useEffect(() => {
+ if (!isVisible) return
+ focusInput()
+ }, [focusInput, isVisible])
+
+ useEffect(() => {
+ if (!isVisible) return
+ if (!query) {
+ performSearch('')
+ return
+ }
+ performSearch(query)
+ }, [currentWebview, isVisible, performSearch, query])
+
+ useEffect(() => {
+ const handleKeydown = (event: KeyboardEvent) => {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
+ event.preventDefault()
+ openSearch()
+ return
+ }
+
+ if (!isVisible) return
+
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ closeSearch()
+ return
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ if (event.shiftKey) {
+ goToPrevious()
+ } else {
+ goToNext()
+ }
+ }
+ }
+
+ window.addEventListener('keydown', handleKeydown, true)
+ return () => {
+ window.removeEventListener('keydown', handleKeydown, true)
+ }
+ }, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
+
+ useEffect(() => {
+ if (!isWebviewReady) {
+ setIsVisible(false)
+ resetSearchState()
+ stopSearch()
+ return
+ }
+ }, [isWebviewReady, resetSearchState, stopSearch])
+
+ useEffect(() => {
+ if (!appId) return
+ if (lastAppIdRef.current === appId) return
+ lastAppIdRef.current = appId
+ setIsVisible(false)
+ resetSearchState()
+ stopSearch()
+ }, [appId, resetSearchState, stopSearch])
+
+ useEffect(() => {
+ return () => {
+ stopSearch()
+ if (focusFrameRef.current !== null) {
+ window.cancelAnimationFrame(focusFrameRef.current)
+ focusFrameRef.current = null
+ }
+ }
+ }, [stopSearch])
+
+ if (!isVisible) {
+ return null
+ }
+
+ const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
+ const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
+ const disableNavigation = !query || matchCount === 0
+
+ return (
+
+
+
+ {matchLabel}
+
+
+
+
+
+
+
+ )
+}
+
+export default WebviewSearch
diff --git a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx
new file mode 100644
index 0000000000..0f1985f6bb
--- /dev/null
+++ b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx
@@ -0,0 +1,237 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { WebviewTag } from 'electron'
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import WebviewSearch from '../WebviewSearch'
+
+const translations: Record = {
+ 'common.close': 'Close',
+ 'common.error': 'Error',
+ 'common.no_results': 'No results',
+ 'common.search': 'Search'
+}
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => translations[key] ?? key
+ })
+}))
+
+const createWebviewMock = () => {
+ const listeners = new Map void>>()
+ const findInPageMock = vi.fn()
+ const stopFindInPageMock = vi.fn()
+ const webview = {
+ addEventListener: vi.fn(
+ (type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
+ if (!listeners.has(type)) {
+ listeners.set(type, new Set())
+ }
+ listeners.get(type)!.add(listener)
+ }
+ ),
+ removeEventListener: vi.fn(
+ (type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
+ listeners.get(type)?.delete(listener)
+ }
+ ),
+ findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
+ stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
+ } as unknown as WebviewTag
+
+ const emit = (type: string, result?: Electron.FoundInPageResult) => {
+ listeners.get(type)?.forEach((listener) => {
+ const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
+ event.result = result
+ listener(event)
+ })
+ }
+
+ return {
+ emit,
+ findInPageMock,
+ stopFindInPageMock,
+ webview
+ }
+}
+
+const openSearchOverlay = async () => {
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
+ })
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
+ })
+}
+
+const originalRAF = window.requestAnimationFrame
+const originalCAF = window.cancelAnimationFrame
+
+const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
+ callback(0)
+ return 1
+})
+const cancelAnimationFrameMock = vi.fn()
+
+beforeAll(() => {
+ Object.defineProperty(window, 'requestAnimationFrame', {
+ value: requestAnimationFrameMock,
+ writable: true
+ })
+ Object.defineProperty(window, 'cancelAnimationFrame', {
+ value: cancelAnimationFrameMock,
+ writable: true
+ })
+})
+
+afterAll(() => {
+ Object.defineProperty(window, 'requestAnimationFrame', {
+ value: originalRAF
+ })
+ Object.defineProperty(window, 'cancelAnimationFrame', {
+ value: originalCAF
+ })
+})
+
+describe('WebviewSearch', () => {
+ const toastMock = {
+ error: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ addToast: vi.fn()
+ }
+
+ beforeEach(() => {
+ Object.assign(window, { toast: toastMock })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('opens the search overlay with keyboard shortcut', async () => {
+ const { webview } = createWebviewMock()
+ const webviewRef = { current: webview } as React.RefObject
+
+ render()
+
+ expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
+
+ await openSearchOverlay()
+
+ expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
+ })
+
+ it('performs searches and navigates between results', async () => {
+ const { emit, findInPageMock, webview } = createWebviewMock()
+ const webviewRef = { current: webview } as React.RefObject
+ const user = userEvent.setup()
+
+ render()
+ await openSearchOverlay()
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'Cherry')
+
+ await waitFor(() => {
+ expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
+ })
+
+ await act(async () => {
+ emit('found-in-page', {
+ requestId: 1,
+ matches: 3,
+ activeMatchOrdinal: 1,
+ selectionArea: undefined as unknown as Electron.Rectangle,
+ finalUpdate: false
+ } as Electron.FoundInPageResult)
+ })
+
+ const nextButton = screen.getByRole('button', { name: 'Next match' })
+ await waitFor(() => {
+ expect(nextButton).not.toBeDisabled()
+ })
+ await user.click(nextButton)
+ await waitFor(() => {
+ expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
+ })
+
+ const previousButton = screen.getByRole('button', { name: 'Previous match' })
+ await user.click(previousButton)
+ await waitFor(() => {
+ expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
+ })
+ })
+
+ it('clears search state when appId changes', async () => {
+ const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
+ const webviewRef = { current: webview } as React.RefObject
+ const user = userEvent.setup()
+
+ const { rerender } = render()
+ await openSearchOverlay()
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'Cherry')
+ await waitFor(() => {
+ expect(findInPageMock).toHaveBeenCalled()
+ })
+
+ await act(async () => {
+ rerender()
+ })
+
+ await waitFor(() => {
+ expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
+ })
+ expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
+ })
+
+ it('shows toast error when search fails', async () => {
+ const { findInPageMock, webview } = createWebviewMock()
+ findInPageMock.mockImplementation(() => {
+ throw new Error('findInPage failed')
+ })
+ const webviewRef = { current: webview } as React.RefObject
+ const user = userEvent.setup()
+
+ render()
+ await openSearchOverlay()
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'Cherry')
+
+ await waitFor(() => {
+ expect(toastMock.error).toHaveBeenCalledWith('Error')
+ })
+ })
+
+ it('stops search when component unmounts', async () => {
+ const { stopFindInPageMock, webview } = createWebviewMock()
+ const webviewRef = { current: webview } as React.RefObject
+
+ const { unmount } = render()
+ await openSearchOverlay()
+
+ stopFindInPageMock.mockClear()
+ unmount()
+
+ expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
+ })
+
+ it('ignores keyboard shortcut when webview is not ready', async () => {
+ const { findInPageMock, webview } = createWebviewMock()
+ const webviewRef = { current: webview } as React.RefObject
+
+ render()
+
+ await act(async () => {
+ fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
+ })
+
+ expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
+ expect(findInPageMock).not.toHaveBeenCalled()
+ })
+})