mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: add Cmd+F find-in-page to browser pane (#668)
This commit is contained in:
parent
90b7f61e65
commit
3632436ca6
5 changed files with 264 additions and 6 deletions
|
|
@ -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')) {
|
||||
|
|
|
|||
10
src/preload/api-types.d.ts
vendored
10
src/preload/api-types.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
187
src/renderer/src/components/browser-pane/BrowserFind.tsx
Normal file
187
src/renderer/src/components/browser-pane/BrowserFind.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue