diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx new file mode 100644 index 00000000..111044d2 --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Globe } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { useAppStore } from '@/store' + +const MAX_SUGGESTIONS = 8 + +type BrowserAddressBarProps = { + value: string + onChange: (value: string) => void + onSubmit: () => void + onNavigate: (url: string) => void + inputRef: React.RefObject +} + +function scoreSuggestion( + entry: { url: string; title: string; lastVisitedAt: number; visitCount: number }, + query: string +): number { + const lowerQuery = query.toLowerCase() + const lowerUrl = entry.url.toLowerCase() + const lowerTitle = entry.title.toLowerCase() + + if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) { + return -1 + } + + let score = 0 + if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) { + score += 100 + } + score += Math.min(entry.visitCount, 50) + const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60) + score += Math.max(0, 24 - ageHours) + return score +} + +export default function BrowserAddressBar({ + value, + onChange, + onSubmit, + onNavigate, + inputRef +}: BrowserAddressBarProps): React.ReactElement { + const [open, setOpen] = useState(false) + const [selectedValue, setSelectedValue] = useState('') + const browserUrlHistory = useAppStore((s) => s.browserUrlHistory) + const closingRef = useRef(false) + const openedAtRef = useRef(0) + + const suggestions = useMemo(() => { + if (browserUrlHistory.length === 0) { + return [] + } + const trimmed = value.trim() + if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) { + return [...browserUrlHistory] + .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .slice(0, MAX_SUGGESTIONS) + } + + const scored = browserUrlHistory + .map((entry) => ({ entry, score: scoreSuggestion(entry, trimmed) })) + .filter((item) => item.score >= 0) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_SUGGESTIONS) + + return scored.map((item) => item.entry) + }, [browserUrlHistory, value]) + + const handleFocus = useCallback(() => { + if (closingRef.current) { + return + } + inputRef.current?.select() + if (browserUrlHistory.length > 0) { + openedAtRef.current = Date.now() + setOpen(true) + } + }, [browserUrlHistory.length, inputRef]) + + const handleBlur = useCallback(() => { + // Why: delay close so that clicking a suggestion item registers before + // the popover unmounts. Without this, onSelect never fires because the + // mousedown on PopoverContent triggers input blur first. + // + // Why (grace window): BrowserPane's focusAddressBarNow() retries focus + // across multiple animation frames to fight webview focus stealing. Each + // cycle can cause a transient blur on the input. Without this guard the + // popover opens on focus, immediately gets a blur, and closes ~150ms later + // — producing the "flash then disappear" on first click. + const elapsed = Date.now() - openedAtRef.current + const grace = elapsed < 400 + setTimeout(() => { + if (grace && inputRef.current && document.activeElement === inputRef.current) { + return + } + setOpen(false) + }, 200) + }, [inputRef]) + + const handleSelect = useCallback( + (url: string) => { + closingRef.current = true + setOpen(false) + onNavigate(url) + setTimeout(() => { + closingRef.current = false + }, 100) + }, + [onNavigate] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false) + setSelectedValue('') + return + } + + if (!open || suggestions.length === 0) { + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedValue((prev) => { + const idx = suggestions.findIndex((s) => s.url === prev) + const next = idx < suggestions.length - 1 ? idx + 1 : 0 + return suggestions[next].url + }) + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedValue((prev) => { + const idx = suggestions.findIndex((s) => s.url === prev) + const next = idx > 0 ? idx - 1 : suggestions.length - 1 + return suggestions[next].url + }) + return + } + + if (event.key === 'Enter' && selectedValue) { + const match = suggestions.find((s) => s.url === selectedValue) + if (match) { + event.preventDefault() + handleSelect(match.url) + } + } + }, + [open, suggestions, selectedValue, handleSelect] + ) + + useEffect(() => { + if (open && suggestions.length === 0) { + setOpen(false) + } + }, [open, suggestions.length]) + + useEffect(() => { + setSelectedValue('') + }, [suggestions]) + + return ( + { + // Why: Radix fires onOpenChange(false) when it detects an outside + // interaction, but during the focus-retry loop the input may still + // hold focus. Only allow programmatic closes (setOpen(false) from + // our handlers) or genuine outside dismissals. + if (!next && inputRef.current && document.activeElement === inputRef.current) { + return + } + setOpen(next) + }} + > + +
{ + event.preventDefault() + setOpen(false) + onSubmit() + }} + > + + onChange(event.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + data-orca-browser-address-bar="true" + className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + role="combobox" + aria-expanded={open} + aria-controls="browser-history-listbox" + aria-autocomplete="list" + /> + +
+ {suggestions.length > 0 && ( + { + // Why: prevent the popover from stealing focus away from the + // address bar input. The user is still typing; the popover is + // an overlay of suggestions, not a focus target. + e.preventDefault() + }} + > + + + + {suggestions.map((entry) => ( + handleSelect(entry.url)} + className="flex items-center gap-2 px-3 py-2" + > + +
+ {entry.title} + {entry.url} +
+
+ ))} +
+
+
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index bb2686bc..4dee2c28 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -65,6 +65,7 @@ import type { import { useGrabMode } from './useGrabMode' import { formatGrabPayloadAsText } from './GrabConfirmationSheet' import { isEditableKeyboardTarget } from './browser-keyboard' +import BrowserAddressBar from './BrowserAddressBar' import BrowserFind from './BrowserFind' import { consumeBrowserFocusRequest, @@ -377,6 +378,8 @@ function BrowserPagePane({ const lastKnownWebviewUrlRef = useRef(null) const onUpdatePageStateRef = useRef(onUpdatePageState) const onSetUrlRef = useRef(onSetUrl) + const addBrowserHistoryEntry = useAppStore((s) => s.addBrowserHistoryEntry) + const addBrowserHistoryEntryRef = useRef(addBrowserHistoryEntry) const [addressBarValue, setAddressBarValue] = useState(browserTab.url) const addressBarValueRef = useRef(browserTab.url) const [resourceNotice, setResourceNotice] = useState(null) @@ -957,7 +960,8 @@ function BrowserPagePane({ useEffect(() => { onUpdatePageStateRef.current = onUpdatePageState onSetUrlRef.current = onSetUrl - }, [onSetUrl, onUpdatePageState]) + addBrowserHistoryEntryRef.current = addBrowserHistoryEntry + }, [onSetUrl, onUpdatePageState, addBrowserHistoryEntry]) const syncNavigationState = useCallback( (webview: Electron.WebviewTag): void => { @@ -1133,9 +1137,10 @@ function BrowserPagePane({ const handleTitleUpdate = (event: { title?: string }): void => { try { - onUpdatePageStateRef.current(browserTab.id, { - title: getBrowserDisplayTitle(event.title, webview.getURL() || browserTab.url) - }) + const currentUrl = webview.getURL() || browserTab.url + const title = getBrowserDisplayTitle(event.title, currentUrl) + onUpdatePageStateRef.current(browserTab.id, { title }) + addBrowserHistoryEntryRef.current(currentUrl, title) } catch { // Why: title-updated can fire before dom-ready, making getURL() throw. } @@ -1541,6 +1546,31 @@ function BrowserPagePane({ grab.rearm() }, [grab, showGrabToast]) + const navigateToUrl = useCallback( + (url: string): void => { + setAddressBarValue(toDisplayUrl(url)) + onSetUrlRef.current(browserTab.id, url) + onUpdatePageStateRef.current(browserTab.id, { + loading: true, + loadError: null, + title: getBrowserDisplayTitle(url, url) + }) + setResourceNotice(null) + + const webview = webviewRef.current + if (!webview) { + return + } + trackNextLoadingEventRef.current = url !== ORCA_BROWSER_BLANK_URL + lastKnownWebviewUrlRef.current = url + webview.src = url + if (url !== ORCA_BROWSER_BLANK_URL) { + focusWebviewNow() + } + }, + [browserTab.id, focusWebviewNow] + ) + const submitAddressBar = (): void => { keepAddressBarFocusRef.current = false const nextUrl = normalizeBrowserNavigationUrl(addressBarValue) @@ -1554,26 +1584,7 @@ function BrowserPagePane({ }) return } - - setAddressBarValue(toDisplayUrl(nextUrl)) - onSetUrlRef.current(browserTab.id, nextUrl) - onUpdatePageStateRef.current(browserTab.id, { - loading: true, - loadError: null, - title: getBrowserDisplayTitle(nextUrl, nextUrl) - }) - setResourceNotice(null) - - const webview = webviewRef.current - if (!webview) { - return - } - trackNextLoadingEventRef.current = nextUrl !== ORCA_BROWSER_BLANK_URL - lastKnownWebviewUrlRef.current = nextUrl - webview.src = nextUrl - if (nextUrl !== ORCA_BROWSER_BLANK_URL) { - focusWebviewNow() - } + navigateToUrl(nextUrl) } // Why: the store initially holds 'about:blank', but once the webview loads @@ -1920,25 +1931,13 @@ function BrowserPagePane({ )} -
{ - event.preventDefault() - submitAddressBar() - }} - > - - setAddressBarValue(event.target.value)} - data-orca-browser-address-bar="true" - className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" - spellCheck={false} - autoCapitalize="none" - autoCorrect="off" - /> - +