mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(browser): history controls: Chrome-style Escape, removable suggestions, history view (#31846)
This commit is contained in:
@@ -133,6 +133,8 @@ export function forwardGuestShortcutInput(
|
||||
keybindingMatchesAction('browser.focusAddressBar', input, process.platform, keybindings)
|
||||
) {
|
||||
renderer.send('ui:focusBrowserAddressBar', { browserPageId: browserTabId })
|
||||
} else if (keybindingMatchesAction('browser.showHistory', input, process.platform, keybindings)) {
|
||||
renderer.send('ui:showBrowserHistory', { browserPageId: browserTabId })
|
||||
} else if (keybindingMatchesAction('browser.hardReload', input, process.platform, keybindings)) {
|
||||
// The renderer owns the parked webview used for reload.
|
||||
renderer.send('ui:hardReloadBrowserPage', { browserPageId: browserTabId })
|
||||
|
||||
@@ -284,6 +284,15 @@ describe('browserManager', () => {
|
||||
control: !isDarwin,
|
||||
alt: false,
|
||||
shift: true
|
||||
},
|
||||
{
|
||||
type: 'keyDown',
|
||||
code: isDarwin ? 'KeyY' : 'KeyH',
|
||||
key: isDarwin ? 'y' : 'h',
|
||||
meta: isDarwin,
|
||||
control: !isDarwin,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -311,6 +320,9 @@ describe('browserManager', () => {
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(10, 'ui:hardReloadBrowserPage', {
|
||||
browserPageId: 'browser-1'
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(11, 'ui:showBrowserHistory', {
|
||||
browserPageId: 'browser-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses customized keybindings when forwarding browser guest shortcuts', () => {
|
||||
|
||||
@@ -67,6 +67,12 @@ export const uiTabAndBrowserCommandsApi = {
|
||||
ipcRenderer.on('ui:focusBrowserAddressBar', listener)
|
||||
return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener)
|
||||
},
|
||||
onShowBrowserHistory: (callback: (target: BrowserPageCommandTarget) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, target: BrowserPageCommandTarget) =>
|
||||
callback(target)
|
||||
ipcRenderer.on('ui:showBrowserHistory', listener)
|
||||
return () => ipcRenderer.removeListener('ui:showBrowserHistory', listener)
|
||||
},
|
||||
onFindInBrowserPage: browserFindSubscriptions.subscribe,
|
||||
onReloadBrowserPage: (callback: (target: BrowserPageCommandTarget) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, target: BrowserPageCommandTarget) =>
|
||||
|
||||
@@ -143,6 +143,7 @@ export type UiCommandEventApi = {
|
||||
}) => void
|
||||
onNewTerminalTab: (callback: () => void) => () => void
|
||||
onFocusBrowserAddressBar: (callback: (target: BrowserPageCommandTarget) => void) => () => void
|
||||
onShowBrowserHistory: (callback: (target: BrowserPageCommandTarget) => void) => () => void
|
||||
onFindInBrowserPage: (source: BrowserFindSource, callback: () => void) => () => void
|
||||
onReloadBrowserPage: (callback: (target: BrowserPageCommandTarget) => void) => () => void
|
||||
onBrowserHistoryNavigate: (
|
||||
|
||||
@@ -155,6 +155,7 @@ function dropdownMenuStubs(): Record<string, unknown> {
|
||||
DropdownMenuRadioGroup: passthrough,
|
||||
DropdownMenuRadioItem: block,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuShortcut: block,
|
||||
DropdownMenuSub: passthrough,
|
||||
DropdownMenuSubContent: block,
|
||||
DropdownMenuSubTrigger: block,
|
||||
|
||||
+119
-23
@@ -4,6 +4,7 @@ import { act, type ReactNode, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
@@ -14,6 +15,9 @@ const mocks = vi.hoisted(() => {
|
||||
browserPagesByWorkspace: Record<string, { id: string; worktreeId: string; url: string }[]>
|
||||
openPageUrl: string | null
|
||||
activateBrowserPage: ReturnType<typeof vi.fn>
|
||||
removeBrowserHistoryEntry: ReturnType<typeof vi.fn>
|
||||
removeWorkspaceDocHistoryEntry: ReturnType<typeof vi.fn>
|
||||
requestBrowserFocus: ReturnType<typeof vi.fn>
|
||||
} = {
|
||||
browserUrlHistory: [],
|
||||
browserDefaultSearchEngine: null,
|
||||
@@ -22,7 +26,10 @@ const mocks = vi.hoisted(() => {
|
||||
'workspace-1': [{ id: 'page-1', worktreeId: 'wt-1', url: 'about:blank' }]
|
||||
},
|
||||
openPageUrl: null,
|
||||
activateBrowserPage: vi.fn()
|
||||
activateBrowserPage: vi.fn(),
|
||||
removeBrowserHistoryEntry: vi.fn(),
|
||||
removeWorkspaceDocHistoryEntry: vi.fn(),
|
||||
requestBrowserFocus: vi.fn()
|
||||
}
|
||||
return state
|
||||
})
|
||||
@@ -50,7 +57,7 @@ vi.mock('@/lib/browser-page-palette-activation', () => ({
|
||||
activateBrowserPagePaletteResult: mocks.activateBrowserPage
|
||||
}))
|
||||
|
||||
vi.mock('../host-guest/browser-focus', () => ({ requestBrowserFocus: vi.fn() }))
|
||||
vi.mock('../host-guest/browser-focus', () => ({ requestBrowserFocus: mocks.requestBrowserFocus }))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign((selector: (state: typeof mocks) => unknown) => selector(mocks), {
|
||||
@@ -103,10 +110,12 @@ function typeInto(input: HTMLInputElement, value: string, inputType: string): vo
|
||||
|
||||
function AddressBarHarness({
|
||||
initialValue,
|
||||
committedValue = initialValue,
|
||||
onNavigate,
|
||||
onSubmit
|
||||
}: {
|
||||
initialValue: string
|
||||
committedValue?: string
|
||||
onNavigate: (url: string) => void
|
||||
onSubmit: () => void
|
||||
}): React.ReactElement {
|
||||
@@ -114,17 +123,18 @@ function AddressBarHarness({
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<BrowserAddressBar
|
||||
pageId="page-1"
|
||||
value={value}
|
||||
committedValue={committedValue}
|
||||
onChange={setValue}
|
||||
onSubmit={onSubmit}
|
||||
onNavigate={onNavigate}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
<span data-current-address-value="true">{value}</span>
|
||||
</>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -388,39 +398,125 @@ describe('BrowserAddressBar autocomplete preview', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses suggestions when Escape is pressed outside React input handling', async () => {
|
||||
const onNavigate = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
it('backs Escape out one step at a time, in Chrome order', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AddressBarHarness initialValue="local" onNavigate={onNavigate} onSubmit={onSubmit} />
|
||||
<AddressBarHarness
|
||||
initialValue="http://localhost:3000/"
|
||||
onNavigate={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')
|
||||
expect(input).not.toBeNull()
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
|
||||
const shown = (): string | null | undefined =>
|
||||
container.querySelector('[data-current-address-value="true"]')?.textContent
|
||||
const press = async (key: string): Promise<void> => {
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
})
|
||||
}
|
||||
await act(async () => {
|
||||
input?.focus()
|
||||
input.focus()
|
||||
typeInto(input, 'review', 'insertText')
|
||||
})
|
||||
await press('ArrowDown')
|
||||
expect(shown()).toBe('http://localhost:3000/review-one')
|
||||
|
||||
await press('Escape')
|
||||
expect(shown()).toBe('review')
|
||||
expect(input.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
await press('Escape')
|
||||
expect(shown()).toBe('review')
|
||||
expect(input.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
await press('Escape')
|
||||
expect(shown()).toBe('http://localhost:3000/')
|
||||
expect([input.selectionStart, input.selectionEnd]).toEqual([0, shown()!.length])
|
||||
expect(mocks.requestBrowserFocus).not.toHaveBeenCalled()
|
||||
|
||||
await press('Escape')
|
||||
expect(mocks.requestBrowserFocus).toHaveBeenCalledWith({ pageId: 'page-1', target: 'webview' })
|
||||
})
|
||||
|
||||
it('keeps focus in the bar when the typed text is the page address retyped', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AddressBarHarness
|
||||
initialValue="local"
|
||||
committedValue="localhost"
|
||||
onNavigate={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
|
||||
await act(async () => {
|
||||
input.focus()
|
||||
typeInto(input, 'localhost', 'insertText')
|
||||
})
|
||||
for (const _ of [1, 2]) {
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
}
|
||||
expect(mocks.requestBrowserFocus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the highlighted history row on Shift+Delete and puts back what was typed', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AddressBarHarness initialValue="local" onNavigate={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
})
|
||||
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
|
||||
await act(async () => {
|
||||
input.focus()
|
||||
})
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
})
|
||||
const removal = new KeyboardEvent('keydown', {
|
||||
key: 'Delete',
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe(
|
||||
'http://localhost:3000/review-one'
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
input.dispatchEvent(removal)
|
||||
})
|
||||
|
||||
expect(mocks.removeBrowserHistoryEntry).toHaveBeenCalledWith('http://localhost:3000/review-one')
|
||||
expect(removal.defaultPrevented).toBe(true)
|
||||
expect(container.querySelector('[data-current-address-value="true"]')?.textContent).toBe(
|
||||
'local'
|
||||
)
|
||||
expect(onNavigate).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves Shift+Delete to the text field when the highlighted row is not history', async () => {
|
||||
mocks.browserUrlHistory = []
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AddressBarHarness initialValue="local" onNavigate={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
})
|
||||
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
|
||||
await act(async () => {
|
||||
input.focus()
|
||||
})
|
||||
const removal = new KeyboardEvent('keydown', {
|
||||
key: 'Delete',
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
await act(async () => {
|
||||
input.dispatchEvent(removal)
|
||||
})
|
||||
|
||||
expect(removal.defaultPrevented).toBe(false)
|
||||
expect(mocks.removeBrowserHistoryEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses suggestions when focus moves into an Electron webview guest', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findPage } from '@/store/slices/browser-page-records'
|
||||
import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../../shared/browser-url'
|
||||
import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types'
|
||||
import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions'
|
||||
@@ -14,9 +15,15 @@ import { useBrowserAddressBarEditHandoff } from './use-browser-address-bar-edit-
|
||||
import { useBrowserAddressBarOverlay } from './browser-address-bar-expansion'
|
||||
import { useBrowserAddressBarClickSelect } from './use-browser-address-bar-click-select'
|
||||
import { useBrowserAddressBarDismissal } from './use-browser-address-bar-dismissal'
|
||||
import { useBrowserAddressBarEscape } from './use-browser-address-bar-escape'
|
||||
import { useBrowserAddressBarSuggestionPreview } from './use-browser-address-bar-suggestion-preview'
|
||||
import { useRemoveSuggestionRow } from './use-browser-address-bar-row-removal'
|
||||
import { isRemovableHistoryRow, isRemoveRowShortcut } from '../history/browser-history-row-removal'
|
||||
import type { BrowserAddressBarEditSessionBinding } from './use-browser-address-bar-edit-session'
|
||||
import BrowserAddressBarSuggestionList from './BrowserAddressBarSuggestionList'
|
||||
import { BrowserLoadProgress } from './browser-load-progress'
|
||||
import { toDisplayUrl } from '../describe-page/browser-page-url-display'
|
||||
import BrowserHistoryDialog from '../history/BrowserHistoryDialog'
|
||||
|
||||
function clearTimerRef(timerRef: { current: number | null }): void {
|
||||
if (timerRef.current !== null) {
|
||||
@@ -29,6 +36,10 @@ type BrowserAddressBarProps = {
|
||||
/** The page this bar edits; its worktree's other open pages are offered as "Switch to tab". */
|
||||
pageId: string
|
||||
value: string
|
||||
/** What Escape reverts an edit to; the page's own address when omitted. */
|
||||
committedValue?: string
|
||||
/** Where the last Escape goes once nothing is left to undo; the page itself by default. */
|
||||
onLeave?: () => void
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onNavigate: (url: string) => void
|
||||
@@ -50,6 +61,8 @@ type BrowserAddressBarProps = {
|
||||
export default function BrowserAddressBar({
|
||||
pageId,
|
||||
value,
|
||||
committedValue,
|
||||
onLeave,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onNavigate,
|
||||
@@ -71,6 +84,9 @@ export default function BrowserAddressBar({
|
||||
const workspaceDocHistory = useAppStore((s) => s.workspaceDocHistory)
|
||||
const browserDefaultSearchEngine = useAppStore((s) => s.browserDefaultSearchEngine)
|
||||
const browserKagiSessionLink = useAppStore((s) => s.browserKagiSessionLink)
|
||||
const pageAddress = useAppStore((s) =>
|
||||
toDisplayUrl(findPage(s.browserPagesByWorkspace, pageId)?.url ?? '')
|
||||
)
|
||||
const closingRef = useRef(false)
|
||||
const addressBarClickSelect = useBrowserAddressBarClickSelect()
|
||||
const openedAtRef = useRef(0)
|
||||
@@ -124,61 +140,16 @@ export default function BrowserAddressBar({
|
||||
[buildSuggestions, autocompleteQuery, inlineText]
|
||||
)
|
||||
|
||||
const clearSuggestionPreview = useCallback((): void => {
|
||||
prePreviewValueRef.current = null
|
||||
setSelectedValueOverride(null)
|
||||
clearInline()
|
||||
}, [clearInline])
|
||||
|
||||
const previewSuggestion = useCallback(
|
||||
(url: string): void => {
|
||||
if (prePreviewValueRef.current === null) {
|
||||
prePreviewValueRef.current = autocompleteQuery
|
||||
}
|
||||
setSelectedValueOverride(url)
|
||||
onChange(url)
|
||||
},
|
||||
[autocompleteQuery, onChange]
|
||||
)
|
||||
|
||||
const selectSuggestionAtIndex = useCallback(
|
||||
(index: number): void => {
|
||||
const suggestion = suggestions[index]
|
||||
if (!suggestion) {
|
||||
return
|
||||
}
|
||||
if (suggestion.isSearch) {
|
||||
// Why: the search row is what Enter does with the typed query, so the bar keeps the
|
||||
// typed text instead of the search URL.
|
||||
prePreviewValueRef.current = null
|
||||
clearInline()
|
||||
setSelectedValueOverride(suggestion.url)
|
||||
onChange(autocompleteQuery)
|
||||
return
|
||||
}
|
||||
if (suggestion.inlineText !== undefined) {
|
||||
// Why: like Chrome, the completion comes back as typed text plus a selected tail, so
|
||||
// Escape still restores what was typed.
|
||||
setSelectedValueOverride(suggestion.url)
|
||||
showInline(autocompleteQuery, suggestion.inlineText)
|
||||
onChange(suggestion.inlineText)
|
||||
return
|
||||
}
|
||||
previewSuggestion(suggestion.url)
|
||||
},
|
||||
[autocompleteQuery, clearInline, onChange, previewSuggestion, showInline, suggestions]
|
||||
)
|
||||
|
||||
const restoreTypedQuery = useCallback((): void => {
|
||||
const typed = prePreviewValueRef.current
|
||||
if (typed === null) {
|
||||
return
|
||||
}
|
||||
prePreviewValueRef.current = null
|
||||
setSelectedValueOverride(null)
|
||||
clearInline()
|
||||
onChange(typed)
|
||||
}, [clearInline, onChange])
|
||||
const { clearSuggestionPreview, selectSuggestionAtIndex, restoreTypedQuery } =
|
||||
useBrowserAddressBarSuggestionPreview({
|
||||
prePreviewValueRef,
|
||||
autocompleteQuery,
|
||||
suggestions,
|
||||
setSelectedValueOverride,
|
||||
onChange,
|
||||
clearInline,
|
||||
showInline
|
||||
})
|
||||
|
||||
const dismissSuggestions = useCallback((): void => {
|
||||
clearTimerRef(blurCloseTimerRef)
|
||||
@@ -186,6 +157,8 @@ export default function BrowserAddressBar({
|
||||
setOpen(false)
|
||||
}, [restoreTypedQuery])
|
||||
|
||||
const removeSuggestion = useRemoveSuggestionRow(restoreTypedQuery)
|
||||
|
||||
const switchToTab = useBrowserAddressBarSwitchToTab({
|
||||
pageId,
|
||||
open,
|
||||
@@ -194,6 +167,23 @@ export default function BrowserAddressBar({
|
||||
onNavigate
|
||||
})
|
||||
|
||||
const { handleEscapeKeyDown, noteTyped } = useBrowserAddressBarEscape({
|
||||
pageId,
|
||||
inputRef,
|
||||
value,
|
||||
committedValue: committedValue ?? pageAddress,
|
||||
open,
|
||||
previewing:
|
||||
prePreviewValueRef.current !== null && !(inlineText !== null && value === inlineText),
|
||||
restoreTypedQuery: () => {
|
||||
// Why: like Chrome's revert of an arrowed row, the row's Switch to tab button lets go too.
|
||||
switchToTab.clearFocus()
|
||||
restoreTypedQuery()
|
||||
},
|
||||
closeSuggestions: dismissSuggestions,
|
||||
onChange,
|
||||
onLeave
|
||||
})
|
||||
// Why nothing for an empty bar: row 0 always shows what Enter does, and there Enter does nothing.
|
||||
const selectedValue =
|
||||
selectedValueOverride &&
|
||||
@@ -246,6 +236,7 @@ export default function BrowserAddressBar({
|
||||
closingRef.current = true
|
||||
setOpen(false)
|
||||
clearSuggestionPreview()
|
||||
noteTyped(null)
|
||||
// Why looked up by row: a workspace-doc suggestion opens on a fresh grant instead of
|
||||
// navigating; its url is the document's path, so even the fallback routes via detection.
|
||||
const docLocation = suggestions.find(
|
||||
@@ -262,7 +253,7 @@ export default function BrowserAddressBar({
|
||||
closingRef.current = false
|
||||
}, 100)
|
||||
},
|
||||
[clearSuggestionPreview, onNavigate, onOpenWorkspaceDoc, suggestions]
|
||||
[clearSuggestionPreview, noteTyped, onNavigate, onOpenWorkspaceDoc, suggestions]
|
||||
)
|
||||
|
||||
const submitTypedValue = useCallback((): void => {
|
||||
@@ -272,13 +263,18 @@ export default function BrowserAddressBar({
|
||||
}
|
||||
setOpen(false)
|
||||
clearSuggestionPreview()
|
||||
noteTyped(null)
|
||||
onSubmit()
|
||||
}, [clearSuggestionPreview, onSubmit, value])
|
||||
}, [clearSuggestionPreview, noteTyped, onSubmit, value])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
dismissSuggestions()
|
||||
if (handleEscapeKeyDown(event)) {
|
||||
return
|
||||
}
|
||||
if (open && selectedRow && isRemovableHistoryRow(selectedRow) && isRemoveRowShortcut(event)) {
|
||||
event.preventDefault()
|
||||
removeSuggestion(selectedRow)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -340,7 +336,8 @@ export default function BrowserAddressBar({
|
||||
switchFocused,
|
||||
selectSuggestionAtIndex,
|
||||
restoreTypedQuery,
|
||||
dismissSuggestions,
|
||||
handleEscapeKeyDown,
|
||||
removeSuggestion,
|
||||
submitTypedValue
|
||||
]
|
||||
)
|
||||
@@ -436,6 +433,7 @@ export default function BrowserAddressBar({
|
||||
if (completed !== null) {
|
||||
prePreviewValueRef.current = nextValue
|
||||
}
|
||||
noteTyped(nextValue)
|
||||
onChange(completed ?? nextValue)
|
||||
}}
|
||||
role="combobox"
|
||||
@@ -466,10 +464,16 @@ export default function BrowserAddressBar({
|
||||
hasSwitchTarget={switchToTab.hasSwitchTarget}
|
||||
switchFocusedUrl={switchFocused ? selectedValue : null}
|
||||
onSwitchToTab={switchToTab.switchToTab}
|
||||
onRemove={removeSuggestion}
|
||||
/>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
<BrowserHistoryDialog
|
||||
pageId={pageId}
|
||||
onNavigate={onNavigate}
|
||||
onOpenWorkspaceDoc={onOpenWorkspaceDoc}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-1
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions'
|
||||
import BrowserHistoryRowRemoveButton from '../history/BrowserHistoryRowRemoveButton'
|
||||
import { isRemovableHistoryRow } from '../history/browser-history-row-removal'
|
||||
|
||||
const NO_SELECTED_ROW = 'orca:no-selected-row'
|
||||
|
||||
@@ -16,6 +18,7 @@ type BrowserAddressBarSuggestionListProps = {
|
||||
/** The row whose button holds keyboard focus (focus itself stays in the input). */
|
||||
switchFocusedUrl: string | null
|
||||
onSwitchToTab: (url: string) => void
|
||||
onRemove: (suggestion: BrowserAddressBarSuggestion) => void
|
||||
}
|
||||
|
||||
export default function BrowserAddressBarSuggestionList({
|
||||
@@ -25,7 +28,8 @@ export default function BrowserAddressBarSuggestionList({
|
||||
onSelect,
|
||||
hasSwitchTarget,
|
||||
switchFocusedUrl,
|
||||
onSwitchToTab
|
||||
onSwitchToTab,
|
||||
onRemove
|
||||
}: BrowserAddressBarSuggestionListProps): React.ReactElement {
|
||||
return (
|
||||
// Why the sentinel: cmdk selects its first row whenever the value is empty, and an empty bar
|
||||
@@ -82,6 +86,9 @@ export default function BrowserAddressBarSuggestionList({
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{isRemovableHistoryRow(entry) ? (
|
||||
<BrowserHistoryRowRemoveButton onRemove={() => onRemove(entry)} />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
+22
-8
@@ -35,6 +35,8 @@ export type BrowserAddressBarSuggestion = {
|
||||
visitCount: number
|
||||
isSearch: boolean
|
||||
faviconUrl?: string | null
|
||||
/** Set on a visited-page row: the history entry it came from, so the row can be removed. */
|
||||
normalizedUrl?: string
|
||||
/** Set on a previewed-document row; selecting it opens the document on a fresh grant. */
|
||||
docLocation?: BrowserPageDocLocation
|
||||
/** On row 0 when the bar completes the typed text inline to it: the full bar text. */
|
||||
@@ -53,6 +55,21 @@ function toWorkspaceDocSuggestion(entry: WorkspaceDocHistoryEntry): BrowserAddre
|
||||
}
|
||||
}
|
||||
|
||||
function toBrowserHistorySuggestion(entry: BrowserHistoryEntry): BrowserAddressBarSuggestion {
|
||||
return { ...entry, subtitle: entry.url, isSearch: false }
|
||||
}
|
||||
|
||||
/** Every visited page and previewed document as rows, newest first. */
|
||||
export function listBrowserHistoryRecents(
|
||||
browserUrlHistory: readonly BrowserHistoryEntry[],
|
||||
workspaceDocHistory: readonly WorkspaceDocHistoryEntry[]
|
||||
): BrowserAddressBarSuggestion[] {
|
||||
return [
|
||||
...browserUrlHistory.map(toBrowserHistorySuggestion),
|
||||
...workspaceDocHistory.map(toWorkspaceDocSuggestion)
|
||||
].sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
|
||||
}
|
||||
|
||||
export function isBrowserAddressBarQueryTooLarge(
|
||||
query: string,
|
||||
maxBytes = BROWSER_ADDRESS_BAR_QUERY_MAX_BYTES
|
||||
@@ -112,13 +129,10 @@ function buildSuggestionRows({
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) {
|
||||
const recents: BrowserAddressBarSuggestion[] = [
|
||||
...browserUrlHistory.map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false })),
|
||||
...workspaceDocHistory.map(toWorkspaceDocSuggestion)
|
||||
]
|
||||
return recents
|
||||
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
|
||||
.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS)
|
||||
return listBrowserHistoryRecents(browserUrlHistory, workspaceDocHistory).slice(
|
||||
0,
|
||||
MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS
|
||||
)
|
||||
}
|
||||
const documentRows = workspaceDocHistory.map(toWorkspaceDocSuggestion)
|
||||
const documentEntries: BrowserHistoryEntry[] = documentRows.map((row) => ({
|
||||
@@ -130,7 +144,7 @@ function buildSuggestionRows({
|
||||
}))
|
||||
const rowByEntry = new Map<BrowserHistoryEntry, BrowserAddressBarSuggestion>()
|
||||
for (const entry of browserUrlHistory) {
|
||||
rowByEntry.set(entry, { ...entry, subtitle: entry.url, isSearch: false })
|
||||
rowByEntry.set(entry, toBrowserHistorySuggestion(entry))
|
||||
}
|
||||
documentEntries.forEach((entry, index) => rowByEntry.set(entry, documentRows[index]))
|
||||
// Why prepare the caller's array as-is: it is the stable `browserUrlHistory`
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): BrowserN
|
||||
<BrowserAddressBar
|
||||
pageId="page-1"
|
||||
value="https://example.com/"
|
||||
committedValue="https://example.com/"
|
||||
onChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onNavigate={controls.navigate}
|
||||
|
||||
+17
-1
@@ -1,4 +1,4 @@
|
||||
import { Check, Ellipsis, FileCode2, Import, Monitor, Plus, Settings } from 'lucide-react'
|
||||
import { Check, Ellipsis, FileCode2, History, Import, Monitor, Plus, Settings } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { BrowserCookieImportDisclosure } from '@/components/BrowserCookieImportDisclosure'
|
||||
import { BrowserCookieImportMachineNotice } from '@/components/BrowserCookieImportMachineNotice'
|
||||
import { useAppStore } from '@/store'
|
||||
import { formatOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { BROWSER_FAMILY_LABELS } from '../../../../../shared/constants'
|
||||
import type {
|
||||
BrowserSessionProfile,
|
||||
@@ -87,6 +89,8 @@ export function BrowserToolbarMenuDropdown({
|
||||
onOpenSourceFile = null,
|
||||
overflow
|
||||
}: BrowserToolbarMenuDropdownProps): React.JSX.Element {
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const historyShortcut = formatOptionalShortcutLabel('browser.showHistory', keybindings)
|
||||
return (
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -276,6 +280,18 @@ export function BrowserToolbarMenuDropdown({
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
// Why deferred: focus returning to the ⋯ trigger would otherwise close the dialog at once.
|
||||
onSelect={() =>
|
||||
overflow.deferUntilClose(() =>
|
||||
useAppStore.getState().setBrowserHistoryPageId(browserPageId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<History className="mr-2 size-3.5" />
|
||||
{translate('browser.history.menuItem', 'History')}
|
||||
{historyShortcut ? <DropdownMenuShortcut>{historyShortcut}</DropdownMenuShortcut> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
|
||||
|
||||
+3
-1
@@ -361,7 +361,9 @@ describe.each([
|
||||
})
|
||||
mocks.ensureRemotePage.mockClear()
|
||||
mocks.callRuntimeRpc.mockClear()
|
||||
installClientHostedPaneApi({ ui: { onFocusBrowserAddressBar: () => () => {} } })
|
||||
installClientHostedPaneApi({
|
||||
ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} }
|
||||
})
|
||||
useAppStore.setState({
|
||||
browserPagesByWorkspace: { [WORKSPACE_ID]: [browserPage()] },
|
||||
browserUrlHistory: [
|
||||
|
||||
+1
-13
@@ -1,11 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
|
||||
/**
|
||||
* Why: Electron <webview> guests run in a separate process, so clicking the page never dispatches
|
||||
* pointerdown on the renderer document and Radix cannot detect an outside dismiss. Window blur and
|
||||
* focus moves into the guest (the host <webview> tag) close the dropdown the same way
|
||||
* BrowserImportHintButton does for its popover; Escape closes it at window capture.
|
||||
* BrowserImportHintButton does for its popover. Escape belongs to the bar's own keydown.
|
||||
*/
|
||||
export function useBrowserAddressBarDismissal(open: boolean, dismissSuggestions: () => void): void {
|
||||
useEffect(() => {
|
||||
@@ -25,22 +24,11 @@ export function useBrowserAddressBarDismissal(open: boolean, dismissSuggestions:
|
||||
dismissSuggestions()
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
dismissSuggestions()
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
document.addEventListener('focusin', handleFocusIn, true)
|
||||
addAppKeyListener('keydown', handleEscape)
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
document.removeEventListener('focusin', handleFocusIn, true)
|
||||
removeAppKeyListener('keydown', handleEscape)
|
||||
}
|
||||
}, [dismissSuggestions, open])
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
|
||||
import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event'
|
||||
import { requestBrowserFocus } from '../host-guest/browser-focus'
|
||||
|
||||
/** Escape in the bar, in the order of Chromium's OmniboxEditModel::OnEscapeKeyPressed. */
|
||||
export function useBrowserAddressBarEscape({
|
||||
pageId,
|
||||
inputRef,
|
||||
value,
|
||||
committedValue,
|
||||
open,
|
||||
previewing,
|
||||
restoreTypedQuery,
|
||||
closeSuggestions,
|
||||
onChange,
|
||||
onLeave
|
||||
}: {
|
||||
pageId: string
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
value: string
|
||||
/** What the bar shows when nobody is editing it: the page's address. */
|
||||
committedValue: string
|
||||
open: boolean
|
||||
/** An arrowed-to row fills the bar (Chrome's temporary text); an inline completion is not one. */
|
||||
previewing: boolean
|
||||
restoreTypedQuery: () => void
|
||||
closeSuggestions: () => void
|
||||
onChange: (value: string) => void
|
||||
onLeave?: () => void
|
||||
}): {
|
||||
handleEscapeKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => boolean
|
||||
/** What the user's own keystroke put in the bar; null once it is submitted or picked. */
|
||||
noteTyped: (value: string | null) => void
|
||||
} {
|
||||
const typedValueRef = useRef<string | null>(null)
|
||||
const selectAllOnCommitRef = useRef(false)
|
||||
|
||||
// Why after commit: React moves the caret to the end when it writes a new value into a focused
|
||||
// input, so selecting in the handler would be undone by the reverted text landing.
|
||||
useLayoutEffect(() => {
|
||||
if (!selectAllOnCommitRef.current) {
|
||||
return
|
||||
}
|
||||
selectAllOnCommitRef.current = false
|
||||
inputRef.current?.select()
|
||||
}, [inputRef, value])
|
||||
|
||||
const noteTyped = useCallback((typed: string | null) => {
|
||||
typedValueRef.current = typed
|
||||
}, [])
|
||||
|
||||
const handleEscapeKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>): boolean => {
|
||||
if (event.key !== 'Escape' || isImeOwnedKeyboardEvent(event)) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Why both: the typed text tells a retyped page address from an untouched one, and the
|
||||
// comparison covers drafts handed over from a pane that remounted mid-edit.
|
||||
const inputInProgress = value === typedValueRef.current || value !== committedValue
|
||||
if (previewing) {
|
||||
restoreTypedQuery()
|
||||
} else if (open) {
|
||||
closeSuggestions()
|
||||
} else if (inputInProgress) {
|
||||
typedValueRef.current = null
|
||||
if (value === committedValue) {
|
||||
inputRef.current?.select()
|
||||
} else {
|
||||
selectAllOnCommitRef.current = true
|
||||
onChange(committedValue)
|
||||
}
|
||||
} else if (onLeave) {
|
||||
onLeave()
|
||||
} else {
|
||||
requestBrowserFocus({ pageId, target: 'webview' })
|
||||
}
|
||||
return true
|
||||
},
|
||||
[
|
||||
closeSuggestions,
|
||||
committedValue,
|
||||
inputRef,
|
||||
onChange,
|
||||
onLeave,
|
||||
open,
|
||||
pageId,
|
||||
previewing,
|
||||
restoreTypedQuery,
|
||||
value
|
||||
]
|
||||
)
|
||||
|
||||
return { handleEscapeKeyDown, noteTyped }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useRemoveHistoryRow } from '../history/browser-history-row-removal'
|
||||
import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions'
|
||||
|
||||
/** Removes a dropdown row, then puts back what was typed if that row filled the bar. */
|
||||
export function useRemoveSuggestionRow(
|
||||
restoreTypedQuery: () => void
|
||||
): (row: BrowserAddressBarSuggestion) => void {
|
||||
const removeHistoryRow = useRemoveHistoryRow()
|
||||
return useCallback(
|
||||
(row: BrowserAddressBarSuggestion) => {
|
||||
removeHistoryRow(row)
|
||||
// Why: a previewed or inline-completed row would otherwise stay in the bar for Enter to submit.
|
||||
restoreTypedQuery()
|
||||
},
|
||||
[removeHistoryRow, restoreTypedQuery]
|
||||
)
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
||||
import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions'
|
||||
|
||||
/** Arrowing through the dropdown fills the bar with the row while remembering what was typed. */
|
||||
export function useBrowserAddressBarSuggestionPreview({
|
||||
prePreviewValueRef,
|
||||
autocompleteQuery,
|
||||
suggestions,
|
||||
setSelectedValueOverride,
|
||||
onChange,
|
||||
clearInline,
|
||||
showInline
|
||||
}: {
|
||||
prePreviewValueRef: MutableRefObject<string | null>
|
||||
autocompleteQuery: string
|
||||
suggestions: BrowserAddressBarSuggestion[]
|
||||
setSelectedValueOverride: Dispatch<SetStateAction<string | null>>
|
||||
onChange: (value: string) => void
|
||||
clearInline: () => void
|
||||
showInline: (typed: string, text: string) => void
|
||||
}): {
|
||||
clearSuggestionPreview: () => void
|
||||
selectSuggestionAtIndex: (index: number) => void
|
||||
restoreTypedQuery: () => void
|
||||
} {
|
||||
const clearSuggestionPreview = useCallback((): void => {
|
||||
prePreviewValueRef.current = null
|
||||
setSelectedValueOverride(null)
|
||||
clearInline()
|
||||
}, [clearInline, prePreviewValueRef, setSelectedValueOverride])
|
||||
|
||||
const previewSuggestion = useCallback(
|
||||
(url: string): void => {
|
||||
if (prePreviewValueRef.current === null) {
|
||||
prePreviewValueRef.current = autocompleteQuery
|
||||
}
|
||||
setSelectedValueOverride(url)
|
||||
onChange(url)
|
||||
},
|
||||
[autocompleteQuery, onChange, prePreviewValueRef, setSelectedValueOverride]
|
||||
)
|
||||
|
||||
const selectSuggestionAtIndex = useCallback(
|
||||
(index: number): void => {
|
||||
const suggestion = suggestions[index]
|
||||
if (!suggestion) {
|
||||
return
|
||||
}
|
||||
if (suggestion.isSearch) {
|
||||
// Why: the search row is what Enter does with the typed query, so the bar keeps the
|
||||
// typed text instead of the search URL.
|
||||
prePreviewValueRef.current = null
|
||||
clearInline()
|
||||
setSelectedValueOverride(suggestion.url)
|
||||
onChange(autocompleteQuery)
|
||||
return
|
||||
}
|
||||
if (suggestion.inlineText !== undefined) {
|
||||
// Why: like Chrome, the completion comes back as typed text plus a selected tail, so
|
||||
// Escape still restores what was typed.
|
||||
setSelectedValueOverride(suggestion.url)
|
||||
showInline(autocompleteQuery, suggestion.inlineText)
|
||||
onChange(suggestion.inlineText)
|
||||
return
|
||||
}
|
||||
previewSuggestion(suggestion.url)
|
||||
},
|
||||
[
|
||||
autocompleteQuery,
|
||||
clearInline,
|
||||
onChange,
|
||||
prePreviewValueRef,
|
||||
previewSuggestion,
|
||||
setSelectedValueOverride,
|
||||
showInline,
|
||||
suggestions
|
||||
]
|
||||
)
|
||||
|
||||
const restoreTypedQuery = useCallback((): void => {
|
||||
const typed = prePreviewValueRef.current
|
||||
if (typed === null) {
|
||||
return
|
||||
}
|
||||
prePreviewValueRef.current = null
|
||||
setSelectedValueOverride(null)
|
||||
clearInline()
|
||||
onChange(typed)
|
||||
}, [clearInline, onChange, prePreviewValueRef, setSelectedValueOverride])
|
||||
|
||||
return { clearSuggestionPreview, selectSuggestionAtIndex, restoreTypedQuery }
|
||||
}
|
||||
+2
-1
@@ -129,7 +129,8 @@ describe('useBrowserPageChromeFocus', () => {
|
||||
value: {
|
||||
ui: {
|
||||
onFocusBrowserAddressBar: (callback: (target: BrowserPageCommandTarget) => void) =>
|
||||
focusAddressBarFromIpc.subscribe(callback)
|
||||
focusAddressBarFromIpc.subscribe(callback),
|
||||
onShowBrowserHistory: () => () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+3
@@ -20,6 +20,7 @@ import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-t
|
||||
import type { BrowserAddressBarSelection } from './browser-address-bar-edit-session'
|
||||
import type { BrowserPageGuestFocus } from './browser-page-guest-focus'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
import { useBrowserHistoryShortcut } from '../history/use-browser-history-shortcut'
|
||||
|
||||
/** Frames an address-bar grab keeps retrying before it lets the guest have focus back. */
|
||||
const ADDRESS_BAR_FOCUS_FRAMES = 6
|
||||
@@ -30,6 +31,7 @@ const GUEST_ATTACH_WAIT_FRAMES = 120
|
||||
* Who owns focus in a browser pane's chrome. Every pane — local, client-hosted and streamed —
|
||||
* uses this so a new blank tab lands in the address bar with its text selected, the command
|
||||
* palette can aim focus at either surface, and Cmd/Ctrl+L reaches the bar from anywhere.
|
||||
* It also carries the history shortcut, so every pane kind opens the history view the same way.
|
||||
*/
|
||||
export function useBrowserPageChromeFocus({
|
||||
browserTabId,
|
||||
@@ -56,6 +58,7 @@ export function useBrowserPageChromeFocus({
|
||||
/** Set while a focus grab is in flight, so panes don't hand focus back to the guest mid-retry. */
|
||||
keepAddressBarFocusRef: MutableRefObject<boolean>
|
||||
} {
|
||||
useBrowserHistoryShortcut({ browserTabId, workspaceId, isActive, chromeShortcutScope })
|
||||
const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const keepAddressBarFocusRef = useRef(false)
|
||||
|
||||
@@ -53,6 +53,7 @@ export function installClientHostedPaneApi(overrides?: {
|
||||
},
|
||||
ui: {
|
||||
onFocusBrowserAddressBar: inert,
|
||||
onShowBrowserHistory: inert,
|
||||
onFindInBrowserPage: inert,
|
||||
onBrowserHistoryNavigate: inert,
|
||||
onReloadBrowserPage: inert,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useDeferredValue, useMemo, useRef, useState } from 'react'
|
||||
import { FileText, Trash2 } from 'lucide-react'
|
||||
import { BrowserFavicon } from '@/components/browser-favicon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { FooterKey } from '@/components/worktree-jump-palette-primitives'
|
||||
import { getIntlLocale, translate } from '@/i18n/i18n'
|
||||
import { getShortcutPlatform } from '@/hooks/useShortcutLabel'
|
||||
import { useAppStore } from '@/store'
|
||||
import { requestBrowserFocus } from '../host-guest/browser-focus'
|
||||
import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types'
|
||||
import { startOfLocalDay } from '../../../../../shared/automation-cron-occurrence'
|
||||
import { formatKeybinding } from '../../../../../shared/keybindings/formatting'
|
||||
import type { RemovedBrowserHistory } from '@/store/slices/browser/browser-slice-contract'
|
||||
import type { BrowserAddressBarSuggestion } from '../assemble-chrome/browser-address-bar-suggestions'
|
||||
import BrowserHistoryRowRemoveButton from './BrowserHistoryRowRemoveButton'
|
||||
import { isRemoveRowShortcut, useRemoveHistoryRow } from './browser-history-row-removal'
|
||||
import {
|
||||
browserHistoryClearSince,
|
||||
browserHistoryRowKey,
|
||||
groupBrowserHistoryRowsByDay,
|
||||
listBrowserHistoryRows,
|
||||
type BrowserHistoryClearRange
|
||||
} from './browser-history-view-rows'
|
||||
|
||||
type BrowserHistoryDialogProps = {
|
||||
pageId: string
|
||||
onNavigate: (url: string) => void
|
||||
onOpenWorkspaceDoc?: (docLocation: BrowserPageDocLocation) => void
|
||||
}
|
||||
|
||||
/** Rendered by every address bar; only the page the history view was opened for shows it. */
|
||||
export default function BrowserHistoryDialog(
|
||||
props: BrowserHistoryDialogProps
|
||||
): React.JSX.Element | null {
|
||||
const open = useAppStore((s) => s.browserHistoryPageId === props.pageId)
|
||||
return open ? <BrowserHistoryDialogContent {...props} /> : null
|
||||
}
|
||||
|
||||
// Why cached per locale: the UI language can change while the app runs.
|
||||
let historyFormats: {
|
||||
locale: string
|
||||
dateFormat: Intl.DateTimeFormat
|
||||
timeFormat: Intl.DateTimeFormat
|
||||
} | null = null
|
||||
|
||||
function getHistoryFormats(): NonNullable<typeof historyFormats> {
|
||||
const locale = getIntlLocale()
|
||||
if (historyFormats?.locale !== locale) {
|
||||
historyFormats = {
|
||||
locale,
|
||||
dateFormat: new Intl.DateTimeFormat(locale, { dateStyle: 'full' }),
|
||||
timeFormat: new Intl.DateTimeFormat(locale, { timeStyle: 'short' })
|
||||
}
|
||||
}
|
||||
return historyFormats
|
||||
}
|
||||
|
||||
function formatDayHeading(startsAt: number, now: number, dateFormat: Intl.DateTimeFormat): string {
|
||||
const today = startOfLocalDay(now)
|
||||
if (startsAt === today) {
|
||||
return translate('browser.history.today', 'Today')
|
||||
}
|
||||
if (startsAt === startOfLocalDay(today - 1)) {
|
||||
return translate('browser.history.yesterday', 'Yesterday')
|
||||
}
|
||||
return dateFormat.format(startsAt)
|
||||
}
|
||||
|
||||
const CLEAR_RANGES: readonly BrowserHistoryClearRange[] = ['hour', 'today', 'all']
|
||||
|
||||
function clearRangeLabel(range: BrowserHistoryClearRange): string {
|
||||
if (range === 'hour') {
|
||||
return translate('browser.history.clearLastHour', 'Last hour')
|
||||
}
|
||||
return range === 'today'
|
||||
? translate('browser.history.clearToday', 'Today')
|
||||
: translate('browser.history.clearAllTime', 'All time')
|
||||
}
|
||||
|
||||
function BrowserHistoryDialogContent({
|
||||
pageId,
|
||||
onNavigate,
|
||||
onOpenWorkspaceDoc
|
||||
}: BrowserHistoryDialogProps): React.JSX.Element {
|
||||
const browserUrlHistory = useAppStore((s) => s.browserUrlHistory)
|
||||
const workspaceDocHistory = useAppStore((s) => s.workspaceDocHistory)
|
||||
const setBrowserHistoryPageId = useAppStore((s) => s.setBrowserHistoryPageId)
|
||||
const clearBrowserHistory = useAppStore((s) => s.clearBrowserHistory)
|
||||
const restoreBrowserHistory = useAppStore((s) => s.restoreBrowserHistory)
|
||||
const removeHistoryRow = useRemoveHistoryRow()
|
||||
const [query, setQuery] = useState('')
|
||||
// Why in the footer and not a toast: the modal overlay covers the app's toaster.
|
||||
const [cleared, setCleared] = useState<RemovedBrowserHistory | null>(null)
|
||||
const deferredQuery = useDeferredValue(query)
|
||||
const [now] = useState(() => Date.now())
|
||||
const rows = useMemo(
|
||||
() => listBrowserHistoryRows(browserUrlHistory, workspaceDocHistory, deferredQuery),
|
||||
[browserUrlHistory, workspaceDocHistory, deferredQuery]
|
||||
)
|
||||
const days = useMemo(() => groupBrowserHistoryRowsByDay(rows), [rows])
|
||||
const { dateFormat, timeFormat } = getHistoryFormats()
|
||||
const openedRowRef = useRef(false)
|
||||
const close = (): void => setBrowserHistoryPageId(null)
|
||||
|
||||
const openRow = (row: BrowserAddressBarSuggestion): void => {
|
||||
openedRowRef.current = true
|
||||
close()
|
||||
if (row.docLocation && onOpenWorkspaceDoc) {
|
||||
onOpenWorkspaceDoc(row.docLocation)
|
||||
} else {
|
||||
onNavigate(row.url)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = (range: BrowserHistoryClearRange): void => {
|
||||
const removed = clearBrowserHistory(browserHistoryClearSince(range))
|
||||
if (removed.browserUrlHistory.length === 0 && removed.workspaceDocHistory.length === 0) {
|
||||
return
|
||||
}
|
||||
setCleared(removed)
|
||||
}
|
||||
|
||||
const hasHistory = browserUrlHistory.length > 0 || workspaceDocHistory.length > 0
|
||||
return (
|
||||
<CommandDialog
|
||||
open
|
||||
onOpenChange={(next) => !next && close()}
|
||||
shouldFilter={false}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!openedRowRef.current) {
|
||||
return
|
||||
}
|
||||
// Why after teardown: like picking a row in the address bar, the opened page gets the
|
||||
// keyboard, and the dialog's focus trap would pull a request made any earlier back in.
|
||||
event.preventDefault()
|
||||
requestBrowserFocus({ pageId, target: 'webview' })
|
||||
}}
|
||||
title={translate('browser.history.title', 'History')}
|
||||
description={translate('browser.history.description', 'Search and manage browsing history')}
|
||||
contentClassName="top-[min(10%,4rem)] w-[720px] max-w-[96vw] overflow-hidden"
|
||||
commandProps={{
|
||||
loop: true,
|
||||
className: 'jump-palette-command bg-transparent',
|
||||
onKeyDownCapture: (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isRemoveRowShortcut(event)) {
|
||||
return
|
||||
}
|
||||
// Why read from cmdk: tracking its selection in state would re-render every row per hover.
|
||||
const selectedKey = event.currentTarget
|
||||
.querySelector('[cmdk-item][data-selected="true"]')
|
||||
?.getAttribute('data-value')
|
||||
const selected = rows.find((row) => browserHistoryRowKey(row) === selectedKey)
|
||||
if (selected) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
removeHistoryRow(selected)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={translate('browser.history.search', 'Search history')}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="h-11 min-w-0 flex-1"
|
||||
/>
|
||||
<CommandList className="max-h-[min(560px,calc(100vh-14rem))]">
|
||||
<CommandEmpty>
|
||||
{hasHistory
|
||||
? translate('browser.history.noMatches', 'No pages match your search')
|
||||
: translate('browser.history.empty', 'Pages you visit will show up here')}
|
||||
</CommandEmpty>
|
||||
{days.map((day) => (
|
||||
<CommandGroup
|
||||
key={day.startsAt}
|
||||
heading={formatDayHeading(day.startsAt, now, dateFormat)}
|
||||
>
|
||||
{day.rows.map((row) => {
|
||||
const key = browserHistoryRowKey(row)
|
||||
return (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
onSelect={() => openRow(row)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{row.docLocation ? (
|
||||
<FileText className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<BrowserFavicon
|
||||
faviconUrl={row.faviconUrl}
|
||||
className="size-3.5"
|
||||
fallbackClassName="text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="truncate text-[13px] text-foreground">
|
||||
{row.title || row.url}
|
||||
</span>
|
||||
<span className="min-w-0 shrink-[2] truncate text-xs text-muted-foreground">
|
||||
{row.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{timeFormat.format(row.lastVisitedAt)}
|
||||
</span>
|
||||
<BrowserHistoryRowRemoveButton onRemove={() => removeHistoryRow(row)} />
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
<div
|
||||
className="flex items-center justify-between border-t border-border/60 px-3.5 py-2 text-2xs text-muted-foreground"
|
||||
// Why: cmdk's root takes Enter and arrows for the highlighted row; footer buttons keep their
|
||||
// own. Tab still bubbles so the dialog's focus loop wraps.
|
||||
onKeyDown={(event) => event.key !== 'Tab' && event.stopPropagation()}
|
||||
>
|
||||
{cleared ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{translate('browser.history.cleared', 'Cleared browsing history')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
restoreBrowserHistory(cleared)
|
||||
setCleared(null)
|
||||
}}
|
||||
>
|
||||
{translate('browser.newTab.undo', 'Undo')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" disabled={!hasHistory}>
|
||||
<Trash2 />
|
||||
{translate('browser.history.clear', 'Clear browsing history')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="top">
|
||||
<DropdownMenuLabel>
|
||||
{translate('browser.history.clearRange', 'Clear history from')}
|
||||
</DropdownMenuLabel>
|
||||
{CLEAR_RANGES.map((range) => (
|
||||
<DropdownMenuItem key={range} onSelect={() => clear(range)}>
|
||||
{clearRangeLabel(range)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<FooterKey>{translate('browser.history.enterKey', 'Enter')}</FooterKey>
|
||||
<span>{translate('browser.history.open', 'Open')}</span>
|
||||
<FooterKey>{formatKeybinding('Shift+Delete', getShortcutPlatform()).join(' ')}</FooterKey>
|
||||
<span>{translate('browser.history.removeShort', 'Remove')}</span>
|
||||
<FooterKey>{translate('browser.history.escKey', 'Esc')}</FooterKey>
|
||||
<span>{translate('browser.history.close', 'Close')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** The X on a history row: shown when cmdk marks the row selected (hover or arrows), like Chrome's. */
|
||||
export default function BrowserHistoryRowRemoveButton({
|
||||
onRemove
|
||||
}: {
|
||||
onRemove: () => void
|
||||
}): React.JSX.Element {
|
||||
const label = translate('browser.history.remove', 'Remove from history')
|
||||
return (
|
||||
<span className="shrink-0 text-muted-foreground opacity-0 [[data-selected=true]_&]:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
// Why: keep focus in the text field, so its list keeps the keyboard after the row goes.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react'
|
||||
import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { BrowserAddressBarSuggestion } from '../assemble-chrome/browser-address-bar-suggestions'
|
||||
|
||||
type RemovableRow = Pick<BrowserAddressBarSuggestion, 'normalizedUrl' | 'docLocation'>
|
||||
|
||||
export function isRemovableHistoryRow(row: RemovableRow): boolean {
|
||||
return row.docLocation !== undefined || row.normalizedUrl !== undefined
|
||||
}
|
||||
|
||||
/** Removes a visited page or document from history, so it stops being suggested anywhere. */
|
||||
export function useRemoveHistoryRow(): (row: RemovableRow) => void {
|
||||
const removeBrowserHistoryEntry = useAppStore((s) => s.removeBrowserHistoryEntry)
|
||||
const removeWorkspaceDocHistoryEntry = useAppStore((s) => s.removeWorkspaceDocHistoryEntry)
|
||||
return useCallback(
|
||||
(row: RemovableRow) => {
|
||||
if (row.docLocation) {
|
||||
removeWorkspaceDocHistoryEntry(row.docLocation)
|
||||
} else if (row.normalizedUrl !== undefined) {
|
||||
removeBrowserHistoryEntry(row.normalizedUrl)
|
||||
}
|
||||
},
|
||||
[removeBrowserHistoryEntry, removeWorkspaceDocHistoryEntry]
|
||||
)
|
||||
}
|
||||
|
||||
/** Chrome's Shift+Delete (Shift+Fn+Delete on Mac laptops) on the highlighted row. */
|
||||
export function isRemoveRowShortcut(event: React.KeyboardEvent): boolean {
|
||||
return (
|
||||
event.key === 'Delete' &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!isImeOwnedKeyboardEvent(event)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
|
||||
import {
|
||||
browserHistoryClearSince,
|
||||
groupBrowserHistoryRowsByDay,
|
||||
listBrowserHistoryRows
|
||||
} from './browser-history-view-rows'
|
||||
|
||||
function entry(url: string, title: string, lastVisitedAt: number): BrowserHistoryEntry {
|
||||
return { url, normalizedUrl: url, title, lastVisitedAt, visitCount: 1 }
|
||||
}
|
||||
|
||||
const at = (day: number, hour: number): number => new Date(2026, 9, day, hour).getTime()
|
||||
|
||||
describe('browser history view rows', () => {
|
||||
it('matches every word in the title or address, in any order', () => {
|
||||
const history = [
|
||||
entry('https://github.com/stablyai/orca', 'Orca repo', at(20, 9)),
|
||||
entry('https://en.wikipedia.org/wiki/Orca', 'Orca - Wikipedia', at(21, 9))
|
||||
]
|
||||
expect(listBrowserHistoryRows(history, [], 'orca github').map((r) => r.title)).toEqual([
|
||||
'Orca repo'
|
||||
])
|
||||
expect(listBrowserHistoryRows(history, [], '').map((r) => r.title)).toEqual([
|
||||
'Orca - Wikipedia',
|
||||
'Orca repo'
|
||||
])
|
||||
})
|
||||
|
||||
it('groups by the local day of the last visit, newest day first', () => {
|
||||
const rows = listBrowserHistoryRows(
|
||||
[
|
||||
entry('https://a.test', 'A', at(21, 23)),
|
||||
entry('https://b.test', 'B', at(22, 0)),
|
||||
entry('https://c.test', 'C', at(22, 8))
|
||||
],
|
||||
[],
|
||||
''
|
||||
)
|
||||
expect(groupBrowserHistoryRowsByDay(rows).map((day) => day.rows.map((r) => r.title))).toEqual([
|
||||
['C', 'B'],
|
||||
['A']
|
||||
])
|
||||
})
|
||||
|
||||
it('clears from an hour ago, from local midnight, or everything', () => {
|
||||
const now = at(22, 8)
|
||||
expect(browserHistoryClearSince('hour', now)).toBe(at(22, 7))
|
||||
expect(browserHistoryClearSince('today', now)).toBe(at(22, 0))
|
||||
expect(browserHistoryClearSince('all', now)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
|
||||
import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history'
|
||||
import { startOfLocalDay } from '../../../../../shared/automation-cron-occurrence'
|
||||
import {
|
||||
listBrowserHistoryRecents,
|
||||
type BrowserAddressBarSuggestion
|
||||
} from '../assemble-chrome/browser-address-bar-suggestions'
|
||||
|
||||
export type BrowserHistoryClearRange = 'hour' | 'today' | 'all'
|
||||
|
||||
export type BrowserHistoryDay = {
|
||||
/** Local midnight of the day, which also orders the groups. */
|
||||
startsAt: number
|
||||
rows: BrowserAddressBarSuggestion[]
|
||||
}
|
||||
|
||||
/** The cmdk value for a row: unique across pages and documents. */
|
||||
export function browserHistoryRowKey(row: BrowserAddressBarSuggestion): string {
|
||||
return row.docLocation
|
||||
? `doc:${row.docLocation.worktreeId}:${row.docLocation.filePath}`
|
||||
: `url:${row.normalizedUrl ?? row.url}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages and documents as one list, newest first. Like Chrome's history search, every word must
|
||||
* appear in the title or the address, in any order.
|
||||
*/
|
||||
export function listBrowserHistoryRows(
|
||||
browserUrlHistory: readonly BrowserHistoryEntry[],
|
||||
workspaceDocHistory: readonly WorkspaceDocHistoryEntry[],
|
||||
query: string
|
||||
): BrowserAddressBarSuggestion[] {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
return listBrowserHistoryRecents(browserUrlHistory, workspaceDocHistory).filter((row) => {
|
||||
const text = `${row.title}\n${row.subtitle}`.toLowerCase()
|
||||
return words.every((word) => text.includes(word))
|
||||
})
|
||||
}
|
||||
|
||||
/** Groups rows already sorted newest first by the local day of their last visit. */
|
||||
export function groupBrowserHistoryRowsByDay(
|
||||
rows: readonly BrowserAddressBarSuggestion[]
|
||||
): BrowserHistoryDay[] {
|
||||
const days: BrowserHistoryDay[] = []
|
||||
for (const row of rows) {
|
||||
const startsAt = startOfLocalDay(row.lastVisitedAt)
|
||||
const day = days.at(-1)
|
||||
if (day?.startsAt === startsAt) {
|
||||
day.rows.push(row)
|
||||
} else {
|
||||
days.push({ startsAt, rows: [row] })
|
||||
}
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/** The earliest visit a clear removes; undefined clears everything. */
|
||||
export function browserHistoryClearSince(
|
||||
range: BrowserHistoryClearRange,
|
||||
now = Date.now()
|
||||
): number | undefined {
|
||||
if (range === 'hour') {
|
||||
return now - 60 * 60 * 1000
|
||||
}
|
||||
return range === 'today' ? startOfLocalDay(now) : undefined
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
|
||||
import { useBrowserHistoryShortcut } from './use-browser-history-shortcut'
|
||||
|
||||
function Pane({
|
||||
pageId,
|
||||
workspaceId,
|
||||
chromeShortcutScope
|
||||
}: {
|
||||
pageId: string
|
||||
workspaceId: string
|
||||
chromeShortcutScope: BrowserChromeShortcutScope
|
||||
}): null {
|
||||
useBrowserHistoryShortcut({
|
||||
browserTabId: pageId,
|
||||
workspaceId,
|
||||
isActive: true,
|
||||
chromeShortcutScope
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function pressShowHistory(target: Element): void {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'y', code: 'KeyY', metaKey: true, bubbles: true })
|
||||
)
|
||||
}
|
||||
|
||||
describe('useBrowserHistoryShortcut', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { onShowBrowserHistory: () => () => {} } }
|
||||
})
|
||||
useAppStore.setState({ browserHistoryPageId: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('keeps a floating browser history open on its own page when the shortcut repeats', () => {
|
||||
render(
|
||||
<>
|
||||
<Pane pageId="main-page" workspaceId="main-ws" chromeShortcutScope="focused" />
|
||||
<Pane pageId="floating-page" workspaceId="floating-ws" chromeShortcutScope="owned-target" />
|
||||
</>
|
||||
)
|
||||
const floatingPanel = document.createElement('div')
|
||||
floatingPanel.setAttribute('data-floating-terminal-panel', '')
|
||||
const floatingChrome = document.createElement('div')
|
||||
floatingChrome.setAttribute('data-browser-overlay-tab-id', 'floating-ws')
|
||||
floatingPanel.append(floatingChrome)
|
||||
// The dialog portals its search field to the body, outside both panes.
|
||||
const dialogSearch = document.createElement('input')
|
||||
document.body.append(floatingPanel, dialogSearch)
|
||||
|
||||
act(() => pressShowHistory(floatingChrome))
|
||||
expect(useAppStore.getState().browserHistoryPageId).toBe('floating-page')
|
||||
|
||||
act(() => pressShowHistory(dialogSearch))
|
||||
expect(useAppStore.getState().browserHistoryPageId).toBe('floating-page')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getShortcutPlatform } from '@/hooks/useShortcutLabel'
|
||||
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
|
||||
import { keybindingMatchesAction } from '../../../../../shared/keybindings'
|
||||
import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target'
|
||||
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
|
||||
|
||||
/** Cmd+Y (Ctrl+H on Linux/Windows) opens the history view, from the page or from Orca's chrome. */
|
||||
export function useBrowserHistoryShortcut({
|
||||
browserTabId,
|
||||
workspaceId,
|
||||
isActive,
|
||||
chromeShortcutScope
|
||||
}: {
|
||||
browserTabId: string
|
||||
workspaceId: string
|
||||
isActive: boolean
|
||||
chromeShortcutScope: BrowserChromeShortcutScope
|
||||
}): void {
|
||||
const setBrowserHistoryPageId = useAppStore((s) => s.setBrowserHistoryPageId)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
// Why: a focused page swallows the chord in main, which forwards it here.
|
||||
const unsubscribe = window.api.ui.onShowBrowserHistory(({ browserPageId }) => {
|
||||
if (browserPageId === browserTabId) {
|
||||
setBrowserHistoryPageId(browserTabId)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
// Why: a history view left over a page the user switched away from would open its pick there.
|
||||
if (useAppStore.getState().browserHistoryPageId === browserTabId) {
|
||||
setBrowserHistoryPageId(null)
|
||||
}
|
||||
}
|
||||
}, [browserTabId, isActive, setBrowserHistoryPageId])
|
||||
|
||||
useEffect(() => {
|
||||
if (chromeShortcutScope === 'inactive') {
|
||||
return
|
||||
}
|
||||
const shortcutPlatform = getShortcutPlatform()
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (!keybindingMatchesAction('browser.showHistory', event, shortcutPlatform, keybindings)) {
|
||||
return
|
||||
}
|
||||
// Why: an open view keeps its page; its portaled search field sits outside every pane, so
|
||||
// ownership would hand the chord, and the next pick, to whichever pane claims stray keys.
|
||||
const historyOpen = useAppStore.getState().browserHistoryPageId !== null
|
||||
if (
|
||||
!historyOpen &&
|
||||
!browserChromeShortcutOwnsEvent(chromeShortcutScope, event, workspaceId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
// Why: every mounted pane registers this same capture listener on window.
|
||||
event.stopImmediatePropagation()
|
||||
if (!historyOpen) {
|
||||
setBrowserHistoryPageId(browserTabId)
|
||||
}
|
||||
}
|
||||
addAppKeyListener('keydown', handleKeyDown)
|
||||
return () => removeAppKeyListener('keydown', handleKeyDown)
|
||||
}, [browserTabId, chromeShortcutScope, keybindings, setBrowserHistoryPageId, workspaceId])
|
||||
}
|
||||
@@ -90,7 +90,7 @@ function BrowserNewTabPageLayout({
|
||||
}): React.JSX.Element {
|
||||
const browserUrlHistory = useAppStore((s) => s.browserUrlHistory)
|
||||
const removeBrowserHistoryEntry = useAppStore((s) => s.removeBrowserHistoryEntry)
|
||||
const restoreBrowserHistoryEntry = useAppStore((s) => s.restoreBrowserHistoryEntry)
|
||||
const restoreBrowserHistory = useAppStore((s) => s.restoreBrowserHistory)
|
||||
const suggested = useMemo(
|
||||
() =>
|
||||
rankBrowserTopSites({
|
||||
@@ -116,7 +116,8 @@ function BrowserNewTabPageLayout({
|
||||
{
|
||||
action: {
|
||||
label: translate('browser.newTab.undo', 'Undo'),
|
||||
onClick: () => restoreBrowserHistoryEntry(removed)
|
||||
onClick: () =>
|
||||
restoreBrowserHistory({ browserUrlHistory: [removed], workspaceDocHistory: [] })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+3
-1
@@ -199,7 +199,9 @@ describe('RemoteBrowserPagePane address bar parity', () => {
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { onFocusBrowserAddressBar: () => () => {} } }
|
||||
value: {
|
||||
ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} }
|
||||
}
|
||||
})
|
||||
useAppStore.setState({
|
||||
browserUrlHistory: [],
|
||||
|
||||
+3
-1
@@ -147,7 +147,9 @@ beforeEach(() => {
|
||||
mocks.viewportRenders.current = 0
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { onFocusBrowserAddressBar: () => () => {} } }
|
||||
value: {
|
||||
ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -111,11 +111,6 @@ export function DocPreviewAddressEdit({
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-1"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
exitEdit()
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
// Why a grace timer and not an immediate exit: a suggestion click blurs the input before
|
||||
// its own click lands, and unmounting the list mid-gesture would swallow the commit.
|
||||
@@ -134,6 +129,8 @@ export function DocPreviewAddressEdit({
|
||||
<BrowserAddressBar
|
||||
pageId={previewId}
|
||||
value={value}
|
||||
committedValue={`${identity.directoryPrefix}${identity.fileName}`}
|
||||
onLeave={exitEdit}
|
||||
onChange={setValue}
|
||||
onSubmit={submit}
|
||||
onNavigate={navigateToUrl}
|
||||
|
||||
@@ -193,6 +193,28 @@
|
||||
},
|
||||
"managedProfile": {
|
||||
"createFailed": "Couldn't create this workspace's browser profile. Close this tab and open a new one to try again."
|
||||
},
|
||||
"history": {
|
||||
"clear": "Clear browsing history",
|
||||
"clearAllTime": "All time",
|
||||
"clearLastHour": "Last hour",
|
||||
"clearRange": "Clear history from",
|
||||
"clearToday": "Today",
|
||||
"cleared": "Cleared browsing history",
|
||||
"close": "Close",
|
||||
"description": "Search and manage browsing history",
|
||||
"empty": "Pages you visit will show up here",
|
||||
"enterKey": "Enter",
|
||||
"escKey": "Esc",
|
||||
"menuItem": "History",
|
||||
"noMatches": "No pages match your search",
|
||||
"open": "Open",
|
||||
"remove": "Remove from history",
|
||||
"removeShort": "Remove",
|
||||
"search": "Search history",
|
||||
"title": "History",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
|
||||
@@ -436,16 +436,51 @@ describe('createBrowserSlice annotations', () => {
|
||||
const removed = store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl)
|
||||
expect(removed).toBe(visited)
|
||||
expect(store.getState().browserUrlHistory).toEqual([])
|
||||
store.getState().restoreBrowserHistoryEntry(removed!)
|
||||
store
|
||||
.getState()
|
||||
.restoreBrowserHistory({ browserUrlHistory: [removed!], workspaceDocHistory: [] })
|
||||
expect(store.getState().browserUrlHistory).toEqual([visited])
|
||||
|
||||
store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl)
|
||||
store.getState().addBrowserHistoryEntry('https://example.com', 'Example')
|
||||
const revisited = store.getState().browserUrlHistory
|
||||
store.getState().restoreBrowserHistoryEntry(removed!)
|
||||
store
|
||||
.getState()
|
||||
.restoreBrowserHistory({ browserUrlHistory: [removed!], workspaceDocHistory: [] })
|
||||
expect(store.getState().browserUrlHistory).toBe(revisited)
|
||||
})
|
||||
|
||||
it('clears pages and documents visited since a time, and Undo puts back exactly those', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = createTestStore()
|
||||
const doc = { kind: 'workspace-doc' as const, worktreeId: 'wt-1', filePath: '/repo/a.html' }
|
||||
vi.setSystemTime(1_000)
|
||||
store.getState().addBrowserHistoryEntry('https://old.example', 'Old')
|
||||
vi.setSystemTime(5_000)
|
||||
store.getState().addBrowserHistoryEntry('https://new.example', 'New')
|
||||
store.getState().recordWorkspaceDocVisit(doc, 'Doc')
|
||||
|
||||
const removed = store.getState().clearBrowserHistory(5_000)
|
||||
expect(store.getState().browserUrlHistory.map((e) => e.title)).toEqual(['Old'])
|
||||
expect(store.getState().workspaceDocHistory).toEqual([])
|
||||
|
||||
store.getState().restoreBrowserHistory(removed)
|
||||
expect(
|
||||
store
|
||||
.getState()
|
||||
.browserUrlHistory.map((e) => e.title)
|
||||
.sort()
|
||||
).toEqual(['New', 'Old'])
|
||||
expect(store.getState().workspaceDocHistory.map((e) => e.title)).toEqual(['Doc'])
|
||||
|
||||
store.getState().clearBrowserHistory()
|
||||
expect(store.getState().browserUrlHistory).toEqual([])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
|
||||
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
|
||||
import type {
|
||||
BrowserSlice,
|
||||
BrowserSliceGet,
|
||||
BrowserSliceSet,
|
||||
RemovedBrowserHistory
|
||||
} from './browser-slice-contract'
|
||||
import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history'
|
||||
import { redactKagiSessionToken } from '../../../../../shared/browser-url'
|
||||
import {
|
||||
@@ -23,8 +28,10 @@ export function createBrowserHistoryActions(
|
||||
| 'recordWorkspaceDocVisit'
|
||||
| 'addBrowserHistoryEntry'
|
||||
| 'removeBrowserHistoryEntry'
|
||||
| 'restoreBrowserHistoryEntry'
|
||||
| 'restoreBrowserHistory'
|
||||
| 'removeWorkspaceDocHistoryEntry'
|
||||
| 'clearBrowserHistory'
|
||||
| 'setBrowserHistoryPageId'
|
||||
> {
|
||||
return {
|
||||
recordWorkspaceDocVisit: (docLocation, title, options) => {
|
||||
@@ -125,16 +132,50 @@ export function createBrowserHistoryActions(
|
||||
return removed
|
||||
},
|
||||
|
||||
restoreBrowserHistoryEntry: (entry) => {
|
||||
set((s) =>
|
||||
restoreBrowserHistory: (removed) => {
|
||||
set((s) => {
|
||||
// A revisit between remove and Undo already re-created the row; keep the live one.
|
||||
s.browserUrlHistory.some((existing) => existing.normalizedUrl === entry.normalizedUrl)
|
||||
? s
|
||||
: { browserUrlHistory: [entry, ...s.browserUrlHistory] }
|
||||
)
|
||||
const urls = removed.browserUrlHistory.filter(
|
||||
(entry) => !s.browserUrlHistory.some((live) => live.normalizedUrl === entry.normalizedUrl)
|
||||
)
|
||||
const docs = removed.workspaceDocHistory.filter(
|
||||
(entry) =>
|
||||
!s.workspaceDocHistory.some((live) =>
|
||||
browserPageDocLocationsEqual(live.docLocation, entry.docLocation)
|
||||
)
|
||||
)
|
||||
if (urls.length === 0 && docs.length === 0) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
browserUrlHistory: [...urls, ...s.browserUrlHistory],
|
||||
workspaceDocHistory: [...docs, ...s.workspaceDocHistory]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
removeWorkspaceDocHistoryEntry: (docLocation) => {
|
||||
set((s) => ({
|
||||
workspaceDocHistory: s.workspaceDocHistory.filter(
|
||||
(entry) => !browserPageDocLocationsEqual(entry.docLocation, docLocation)
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
// One clear for both sources: the dropdown presents them as one history.
|
||||
clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] })
|
||||
clearBrowserHistory: (since = -Infinity) => {
|
||||
const { browserUrlHistory, workspaceDocHistory } = get()
|
||||
const removed: RemovedBrowserHistory = {
|
||||
browserUrlHistory: browserUrlHistory.filter((entry) => entry.lastVisitedAt >= since),
|
||||
workspaceDocHistory: workspaceDocHistory.filter((entry) => entry.lastVisitedAt >= since)
|
||||
}
|
||||
set({
|
||||
browserUrlHistory: browserUrlHistory.filter((entry) => entry.lastVisitedAt < since),
|
||||
workspaceDocHistory: workspaceDocHistory.filter((entry) => entry.lastVisitedAt < since)
|
||||
})
|
||||
return removed
|
||||
},
|
||||
|
||||
setBrowserHistoryPageId: (pageId) => set({ browserHistoryPageId: pageId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ export type RemoteBrowserPageHandle = {
|
||||
restoredClientHosted?: true
|
||||
}
|
||||
|
||||
export type RemovedBrowserHistory = {
|
||||
browserUrlHistory: BrowserHistoryEntry[]
|
||||
workspaceDocHistory: WorkspaceDocHistoryEntry[]
|
||||
}
|
||||
|
||||
export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & {
|
||||
executionHostId: ExecutionHostId
|
||||
executionHostLabel: string
|
||||
@@ -275,15 +280,21 @@ export type BrowserSlice = {
|
||||
addBrowserHistoryEntry: (url: string, title: string, faviconUrl?: string | null) => void
|
||||
/** Returns the removed entry so an Undo can put it back with its visit count. */
|
||||
removeBrowserHistoryEntry: (normalizedUrl: string) => BrowserHistoryEntry | null
|
||||
restoreBrowserHistoryEntry: (entry: BrowserHistoryEntry) => void
|
||||
/** Puts removed or cleared entries back, except ones a revisit has already re-created. */
|
||||
restoreBrowserHistory: (removed: RemovedBrowserHistory) => void
|
||||
workspaceDocHistory: WorkspaceDocHistoryEntry[]
|
||||
removeWorkspaceDocHistoryEntry: (docLocation: BrowserPageDocLocation) => void
|
||||
/** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */
|
||||
recordWorkspaceDocVisit: (
|
||||
docLocation: BrowserPageDocLocation,
|
||||
title: string | null,
|
||||
options?: { bump?: boolean }
|
||||
) => void
|
||||
clearBrowserHistory: () => void
|
||||
/** Clears pages and documents last visited at or after `since` (everything when omitted). */
|
||||
clearBrowserHistory: (since?: number) => RemovedBrowserHistory
|
||||
/** The page whose history view is open; it opens what you pick in that page. */
|
||||
browserHistoryPageId: string | null
|
||||
setBrowserHistoryPageId: (pageId: string | null) => void
|
||||
defaultBrowserSessionProfileId: string | null
|
||||
defaultBrowserSessionProfileIdByHostId: Partial<Record<ExecutionHostId, string | null>>
|
||||
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
|
||||
|
||||
@@ -38,6 +38,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
browserSessionImportState: null,
|
||||
browserUrlHistory: [],
|
||||
workspaceDocHistory: [],
|
||||
browserHistoryPageId: null,
|
||||
defaultBrowserSessionProfileId: null,
|
||||
defaultBrowserSessionProfileIdByHostId: {},
|
||||
detectedBrowsers: [],
|
||||
|
||||
@@ -245,6 +245,7 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
||||
replyTabClose: () => {},
|
||||
onNewTerminalTab: () => noopUnsubscribe,
|
||||
onFocusBrowserAddressBar: () => noopUnsubscribe,
|
||||
onShowBrowserHistory: () => noopUnsubscribe,
|
||||
onFindInBrowserPage: () => noopUnsubscribe,
|
||||
onReloadBrowserPage: () => noopUnsubscribe,
|
||||
onBrowserHistoryNavigate: () => noopUnsubscribe,
|
||||
|
||||
@@ -276,5 +276,14 @@ export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [
|
||||
linux: ['Alt+ArrowRight'],
|
||||
win32: ['Alt+ArrowRight']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'browser.showHistory',
|
||||
title: 'Show Browser History',
|
||||
group: 'Browser',
|
||||
scope: 'browser',
|
||||
searchKeywords: ['shortcut', 'browser', 'history', 'visited', 'clear'],
|
||||
// Why Chrome's split: Ctrl+Y is redo (and readline yank) in pages on Linux/Windows.
|
||||
defaultBindings: { darwin: ['Mod+Y'], linux: ['Mod+H'], win32: ['Mod+H'] }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -85,6 +85,7 @@ export type KeybindingActionId =
|
||||
| 'browser.reload'
|
||||
| 'browser.hardReload'
|
||||
| 'browser.focusAddressBar'
|
||||
| 'browser.showHistory'
|
||||
| 'browser.grabElement'
|
||||
| 'browser.annotateElement'
|
||||
| 'editor.find'
|
||||
|
||||
Reference in New Issue
Block a user