feat: add Cmd+F find-in-page to browser pane (#668)

This commit is contained in:
Jinwoo Hong 2026-04-15 03:47:05 -04:00 committed by GitHub
parent 90b7f61e65
commit 3632436ca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 264 additions and 6 deletions

View file

@ -271,6 +271,12 @@ export function setupGuestShortcutForwarding(args: {
// relying on the guest's built-in shortcut, which may not reach the
// parked-webview eviction logic.
renderer.send('ui:reloadBrowserPage')
} else if (input.code === 'KeyF' && !input.shift) {
// Why: Cmd/Ctrl+F must be forwarded out of the guest so the renderer can
// open its own find-in-page bar and call webview.findInPage(). Letting the
// guest handle it natively would open Chromium's built-in find UI inside
// the guest frame, which is invisible behind Orca's chrome.
renderer.send('ui:findInBrowserPage')
} else if (input.code === 'KeyW' && !input.shift) {
renderer.send('ui:closeActiveTab')
} else if (input.shift && (input.code === 'BracketRight' || input.code === 'BracketLeft')) {

View file

@ -492,6 +492,7 @@ export type PreloadApi = {
onNewBrowserTab: (callback: () => void) => () => void
onNewTerminalTab: (callback: () => void) => () => void
onFocusBrowserAddressBar: (callback: () => void) => () => void
onFindInBrowserPage: (callback: () => void) => () => void
onReloadBrowserPage: (callback: () => void) => () => void
onHardReloadBrowserPage: (callback: () => void) => () => void
onCloseActiveTab: (callback: () => void) => () => void
@ -533,7 +534,10 @@ export type PreloadApi = {
ssh: {
listTargets: () => Promise<SshTarget[]>
addTarget: (args: { target: Omit<SshTarget, 'id'> }) => Promise<SshTarget>
updateTarget: (args: { id: string; updates: Partial<Omit<SshTarget, 'id'>> }) => Promise<SshTarget>
updateTarget: (args: {
id: string
updates: Partial<Omit<SshTarget, 'id'>>
}) => Promise<SshTarget>
removeTarget: (args: { id: string }) => Promise<void>
importConfig: () => Promise<SshTarget[]>
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
@ -542,7 +546,9 @@ export type PreloadApi = {
testConnection: (args: {
targetId: string
}) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }>
onStateChanged: (callback: (data: { targetId: string; state: SshConnectionState }) => void) => () => void
onStateChanged: (
callback: (data: { targetId: string; state: SshConnectionState }) => void
) => () => void
addPortForward: (args: {
targetId: string
localPort: number

View file

@ -963,6 +963,11 @@ const api = {
ipcRenderer.on('ui:focusBrowserAddressBar', listener)
return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener)
},
onFindInBrowserPage: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:findInBrowserPage', listener)
return () => ipcRenderer.removeListener('ui:findInBrowserPage', listener)
},
onReloadBrowserPage: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:reloadBrowserPage', listener)
@ -1129,10 +1134,10 @@ const api = {
addTarget: (args: { target: Omit<SshTarget, 'id'> }): Promise<SshTarget> =>
ipcRenderer.invoke('ssh:addTarget', args),
updateTarget: (
args: { id: string; updates: Partial<Omit<SshTarget, 'id'>> }
): Promise<SshTarget> =>
ipcRenderer.invoke('ssh:updateTarget', args),
updateTarget: (args: {
id: string
updates: Partial<Omit<SshTarget, 'id'>>
}): Promise<SshTarget> => ipcRenderer.invoke('ssh:updateTarget', args),
removeTarget: (args: { id: string }): Promise<void> =>
ipcRenderer.invoke('ssh:removeTarget', args),

View file

@ -0,0 +1,187 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronUp, ChevronDown, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
type BrowserFindProps = {
isOpen: boolean
onClose: () => void
webviewRef: React.RefObject<Electron.WebviewTag | null>
}
export default function BrowserFind({
isOpen,
onClose,
webviewRef
}: BrowserFindProps): React.JSX.Element | null {
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [activeMatch, setActiveMatch] = useState(0)
const [totalMatches, setTotalMatches] = useState(0)
const safeFindInPage = useCallback(
(text: string, opts?: Electron.FindInPageOptions): void => {
const webview = webviewRef.current
if (!webview || !text) {
return
}
try {
webview.findInPage(text, opts)
} catch {
// Why: the webview can be mid-teardown during tab close or navigation
// races. Best-effort is better than crashing.
}
},
[webviewRef]
)
const safeStopFindInPage = useCallback((): void => {
const webview = webviewRef.current
if (!webview) {
return
}
try {
webview.stopFindInPage('clearSelection')
} catch {
// Why: same teardown race as safeFindInPage.
}
}, [webviewRef])
const findNext = useCallback(() => {
if (query) {
safeFindInPage(query, { forward: true, findNext: true })
}
}, [query, safeFindInPage])
const findPrevious = useCallback(() => {
if (query) {
safeFindInPage(query, { forward: false, findNext: true })
}
}, [query, safeFindInPage])
useEffect(() => {
if (isOpen) {
inputRef.current?.focus()
inputRef.current?.select()
} else {
safeStopFindInPage()
setActiveMatch(0)
setTotalMatches(0)
}
}, [isOpen, safeStopFindInPage])
useEffect(() => {
if (!query) {
safeStopFindInPage()
setActiveMatch(0)
setTotalMatches(0)
return
}
if (isOpen) {
safeFindInPage(query)
}
}, [query, isOpen, safeFindInPage, safeStopFindInPage])
// Why: this effect captures `webviewRef.current` into a local variable, so
// if the webview element were replaced while `isOpen` stays true the listener
// would be on a stale node. This is safe because BrowserPane closes the find
// bar (`setFindOpen(false)`) on every full navigation (`did-navigate`) and on
// tab deactivation, which toggles `isOpen` and re-runs this effect.
useEffect(() => {
const webview = webviewRef.current
if (!webview || !isOpen) {
return
}
const handleFoundInPage = (event: Electron.FoundInPageEvent): void => {
const { activeMatchOrdinal, matches } = event.result
setActiveMatch(activeMatchOrdinal)
setTotalMatches(matches)
}
webview.addEventListener('found-in-page', handleFoundInPage)
return () => {
try {
webview.removeEventListener('found-in-page', handleFoundInPage)
} catch {
// Why: webview may be destroyed during cleanup.
}
}
}, [webviewRef, isOpen])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation()
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'Enter' && e.shiftKey) {
findPrevious()
} else if (e.key === 'Enter') {
findNext()
}
},
[onClose, findNext, findPrevious]
)
if (!isOpen) {
return null
}
return (
<div
className="absolute top-2 right-2 z-50 flex items-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/95 px-2 py-1 shadow-lg backdrop-blur-sm"
style={{ width: 300 }}
onKeyDown={handleKeyDown}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find in page..."
className="min-w-0 flex-1 border-none bg-transparent text-sm text-white outline-none placeholder:text-zinc-500"
/>
{query ? (
<span className="shrink-0 text-xs text-zinc-400">
{totalMatches > 0 ? `${activeMatch} of ${totalMatches}` : 'No matches'}
</span>
) : null}
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={findPrevious}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Previous match"
>
<ChevronUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={findNext}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Next match"
>
<ChevronDown size={14} />
</Button>
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Close"
>
<X size={14} />
</Button>
</div>
)
}

View file

@ -64,6 +64,7 @@ import type {
import { useGrabMode } from './useGrabMode'
import { formatGrabPayloadAsText } from './GrabConfirmationSheet'
import { isEditableKeyboardTarget } from './browser-keyboard'
import BrowserFind from './BrowserFind'
import {
formatByteCount,
formatDownloadFinishedNotice,
@ -380,6 +381,7 @@ function BrowserPagePane({
linkUrl: string | null
pageUrl: string
} | null>(null)
const [findOpen, setFindOpen] = useState(false)
const grab = useGrabMode(browserTab.id)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest)
@ -716,6 +718,47 @@ function BrowserPagePane({
})
}, [focusAddressBarNow, isActive])
// Cmd/Ctrl+F — find in page (renderer path: focus on browser chrome)
// Why: unlike grab-mode shortcuts (bare C/S) which skip editable targets,
// Cmd+F is a modified chord that should always open find — even from the
// address bar. This matches Chrome/Safari behavior.
useEffect(() => {
if (!isActive) {
return
}
const handleKeyDown = (e: KeyboardEvent): void => {
const isMod = navigator.userAgent.includes('Mac') ? e.metaKey : e.ctrlKey
if (!isMod || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'f') {
return
}
e.preventDefault()
e.stopPropagation()
setFindOpen(true)
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [isActive])
// Cmd/Ctrl+F — find in page (IPC path: focus inside webview guest)
// Why: a focused webview guest is a separate Chromium process so the renderer
// keydown handler above never fires. Main intercepts the chord and sends it
// back here so find works whether focus is on the toolbar or the page.
useEffect(() => {
if (!isActive) {
return
}
return window.api.ui.onFindInBrowserPage(() => {
setFindOpen(true)
})
}, [isActive])
// Close find bar when tab is deactivated
useEffect(() => {
if (!isActive) {
setFindOpen(false)
}
}, [isActive])
// Cmd/Ctrl+R — reload (renderer path: focus on browser chrome, not in guest)
// Why: when focus is inside the renderer chrome (address bar, toolbar buttons)
// rather than the webview guest, the guest shortcut forwarding in main never
@ -993,7 +1036,16 @@ function BrowserPagePane({
webview.addEventListener('dom-ready', handleDomReady)
webview.addEventListener('did-start-loading', handleDidStartLoading)
webview.addEventListener('did-stop-loading', handleDidStopLoading)
// Why: separate handler registered only on 'did-navigate' (full page loads),
// NOT on 'did-navigate-in-page'. The shared handleDidNavigate is registered
// on both events, so adding find-close logic there would also close on SPA
// hash changes and pushState calls, which fire constantly on single-page apps.
const handleFindCloseOnNavigate = (): void => {
setFindOpen(false)
}
webview.addEventListener('did-navigate', handleDidNavigate)
webview.addEventListener('did-navigate', handleFindCloseOnNavigate)
webview.addEventListener('did-navigate-in-page', handleDidNavigate)
webview.addEventListener('page-title-updated', handleTitleUpdate)
webview.addEventListener('page-favicon-updated', handleFaviconUpdate)
@ -1018,6 +1070,7 @@ function BrowserPagePane({
webview.removeEventListener('did-start-loading', handleDidStartLoading)
webview.removeEventListener('did-stop-loading', handleDidStopLoading)
webview.removeEventListener('did-navigate', handleDidNavigate)
webview.removeEventListener('did-navigate', handleFindCloseOnNavigate)
webview.removeEventListener('did-navigate-in-page', handleDidNavigate)
webview.removeEventListener('page-title-updated', handleTitleUpdate)
webview.removeEventListener('page-favicon-updated', handleFaviconUpdate)
@ -1876,6 +1929,7 @@ function BrowserPagePane({
ref={containerRef}
className="relative flex min-h-0 flex-1 overflow-hidden bg-background"
>
<BrowserFind isOpen={findOpen} onClose={() => setFindOpen(false)} webviewRef={webviewRef} />
{showFailureOverlay ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
<div className="flex max-w-sm flex-col items-center px-8 py-8 text-center opacity-70">