diff --git a/src/main/browser/browser-guest-shortcut-dispatch.ts b/src/main/browser/browser-guest-shortcut-dispatch.ts index b6e241bce44..4f42c86ef7f 100644 --- a/src/main/browser/browser-guest-shortcut-dispatch.ts +++ b/src/main/browser/browser-guest-shortcut-dispatch.ts @@ -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 }) diff --git a/src/main/browser/browser-manager-guest-shortcuts.test.ts b/src/main/browser/browser-manager-guest-shortcuts.test.ts index f16b4076310..86ce9e84ab5 100644 --- a/src/main/browser/browser-manager-guest-shortcuts.test.ts +++ b/src/main/browser/browser-manager-guest-shortcuts.test.ts @@ -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', () => { diff --git a/src/preload/api/ui-bridge-tab-and-browser-commands.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts index 00535f1103c..427eb0920b4 100644 --- a/src/preload/api/ui-bridge-tab-and-browser-commands.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -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) => diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 94059083a37..81ecab79c1d 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -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: ( diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx index a109ef90fc4..61dd5c70801 100644 --- a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -155,6 +155,7 @@ function dropdownMenuStubs(): Record { DropdownMenuRadioGroup: passthrough, DropdownMenuRadioItem: block, DropdownMenuSeparator: () =>
, + DropdownMenuShortcut: block, DropdownMenuSub: passthrough, DropdownMenuSubContent: block, DropdownMenuSubTrigger: block, diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx index 0cd7e180daf..e94cf5766b3 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx @@ -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 openPageUrl: string | null activateBrowserPage: ReturnType + removeBrowserHistoryEntry: ReturnType + removeWorkspaceDocHistoryEntry: ReturnType + requestBrowserFocus: ReturnType } = { 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(null) return ( - <> + {value} - + ) } @@ -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( - + ) }) - - const input = container.querySelector('input[data-orca-browser-address-bar]') - expect(input).not.toBeNull() - + const input = container.querySelector('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 => { + 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( + + ) + }) + const input = container.querySelector('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( + + ) + }) + const input = container.querySelector('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( + + ) + }) + const input = container.querySelector('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 () => { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx index 0f56040308b..e3e950bbc27 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx @@ -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) => { - 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} /> )} + ) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBarSuggestionList.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBarSuggestionList.tsx index 6a6d7234d80..d973a25d12b 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBarSuggestionList.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBarSuggestionList.tsx @@ -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({ )} ) : null} + {isRemovableHistoryRow(entry) ? ( + onRemove(entry)} /> + ) : null} ))} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts index 795b69f1f59..1fc7133ac2e 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts @@ -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() 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` diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx index 265c3f79185..9583e222696 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx @@ -45,6 +45,7 @@ function renderRow(overrides: Partial = {}): BrowserN s.keybindings) + const historyShortcut = formatOptionalShortcutLabel('browser.showHistory', keybindings) return ( @@ -276,6 +280,18 @@ export function BrowserToolbarMenuDropdown({ + + overflow.deferUntilClose(() => + useAppStore.getState().setBrowserHistoryPageId(browserPageId) + ) + } + > + + {translate('browser.history.menuItem', 'History')} + {historyShortcut ? {historyShortcut} : null} + { useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null }) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx index c3ef0a5f9e9..234dd0c68c1 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx @@ -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: [ diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts index 59cad9b1009..deadc0ba827 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts @@ -1,11 +1,10 @@ import { useEffect } from 'react' -import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** * Why: Electron 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 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]) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-escape.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-escape.ts new file mode 100644 index 00000000000..f6c001586ea --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-escape.ts @@ -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 + 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) => 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(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): 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 } +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-row-removal.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-row-removal.ts new file mode 100644 index 00000000000..12f08d6bc23 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-row-removal.ts @@ -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] + ) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-suggestion-preview.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-suggestion-preview.ts new file mode 100644 index 00000000000..d704eb91f99 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-suggestion-preview.ts @@ -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 + autocompleteQuery: string + suggestions: BrowserAddressBarSuggestion[] + setSelectedValueOverride: Dispatch> + 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 } +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx index a40d0af378e..f3781bcc0e4 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx @@ -129,7 +129,8 @@ describe('useBrowserPageChromeFocus', () => { value: { ui: { onFocusBrowserAddressBar: (callback: (target: BrowserPageCommandTarget) => void) => - focusAddressBarFromIpc.subscribe(callback) + focusAddressBarFromIpc.subscribe(callback), + onShowBrowserHistory: () => () => {} } } }) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts index 62600bd05e8..e3ecd46ec3f 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts @@ -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 } { + useBrowserHistoryShortcut({ browserTabId, workspaceId, isActive, chromeShortcutScope }) const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest) const keybindings = useAppStore((s) => s.keybindings) const keepAddressBarFocusRef = useRef(false) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts b/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts index d2b357d92dd..f07b7c4a5a7 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-pane-test-rig.ts @@ -53,6 +53,7 @@ export function installClientHostedPaneApi(overrides?: { }, ui: { onFocusBrowserAddressBar: inert, + onShowBrowserHistory: inert, onFindInBrowserPage: inert, onBrowserHistoryNavigate: inert, onReloadBrowserPage: inert, diff --git a/src/renderer/src/components/browser-pane/history/BrowserHistoryDialog.tsx b/src/renderer/src/components/browser-pane/history/BrowserHistoryDialog.tsx new file mode 100644 index 00000000000..34206a489d5 --- /dev/null +++ b/src/renderer/src/components/browser-pane/history/BrowserHistoryDialog.tsx @@ -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 ? : 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 { + 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(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 ( + !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) => { + 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) + } + } + }} + > + + + + {hasHistory + ? translate('browser.history.noMatches', 'No pages match your search') + : translate('browser.history.empty', 'Pages you visit will show up here')} + + {days.map((day) => ( + + {day.rows.map((row) => { + const key = browserHistoryRowKey(row) + return ( + openRow(row)} + className="cursor-pointer" + > + {row.docLocation ? ( + + ) : ( + + )} +
+ + {row.title || row.url} + + + {row.subtitle} + +
+ + {timeFormat.format(row.lastVisitedAt)} + + removeHistoryRow(row)} /> +
+ ) + })} +
+ ))} +
+
event.key !== 'Tab' && event.stopPropagation()} + > + {cleared ? ( +
+ {translate('browser.history.cleared', 'Cleared browsing history')} + +
+ ) : ( + + + + + + + {translate('browser.history.clearRange', 'Clear history from')} + + {CLEAR_RANGES.map((range) => ( + clear(range)}> + {clearRangeLabel(range)} + + ))} + + + )} +
+ {translate('browser.history.enterKey', 'Enter')} + {translate('browser.history.open', 'Open')} + {formatKeybinding('Shift+Delete', getShortcutPlatform()).join(' ')} + {translate('browser.history.removeShort', 'Remove')} + {translate('browser.history.escKey', 'Esc')} + {translate('browser.history.close', 'Close')} +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/history/BrowserHistoryRowRemoveButton.tsx b/src/renderer/src/components/browser-pane/history/BrowserHistoryRowRemoveButton.tsx new file mode 100644 index 00000000000..bf22d241b1a --- /dev/null +++ b/src/renderer/src/components/browser-pane/history/BrowserHistoryRowRemoveButton.tsx @@ -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 ( + + + + + + + {label} + + + + ) +} diff --git a/src/renderer/src/components/browser-pane/history/browser-history-row-removal.ts b/src/renderer/src/components/browser-pane/history/browser-history-row-removal.ts new file mode 100644 index 00000000000..cb8266f14a1 --- /dev/null +++ b/src/renderer/src/components/browser-pane/history/browser-history-row-removal.ts @@ -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 + +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) + ) +} diff --git a/src/renderer/src/components/browser-pane/history/browser-history-view-rows.test.ts b/src/renderer/src/components/browser-pane/history/browser-history-view-rows.test.ts new file mode 100644 index 00000000000..68784c94e4b --- /dev/null +++ b/src/renderer/src/components/browser-pane/history/browser-history-view-rows.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/browser-pane/history/browser-history-view-rows.ts b/src/renderer/src/components/browser-pane/history/browser-history-view-rows.ts new file mode 100644 index 00000000000..df3e9bc4aba --- /dev/null +++ b/src/renderer/src/components/browser-pane/history/browser-history-view-rows.ts @@ -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 +} diff --git a/src/renderer/src/components/browser-pane/history/use-browser-history-shortcut.test.tsx b/src/renderer/src/components/browser-pane/history/use-browser-history-shortcut.test.tsx new file mode 100644 index 00000000000..1d57fc6e2b3 --- /dev/null +++ b/src/renderer/src/components/browser-pane/history/use-browser-history-shortcut.test.tsx @@ -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( + <> + + + + ) + 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') + }) +}) diff --git a/src/renderer/src/components/browser-pane/history/use-browser-history-shortcut.ts b/src/renderer/src/components/browser-pane/history/use-browser-history-shortcut.ts new file mode 100644 index 00000000000..212bf6de0e8 --- /dev/null +++ b/src/renderer/src/components/browser-pane/history/use-browser-history-shortcut.ts @@ -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]) +} diff --git a/src/renderer/src/components/browser-pane/new-tab/BrowserNewTabPage.tsx b/src/renderer/src/components/browser-pane/new-tab/BrowserNewTabPage.tsx index 98990192be6..34c93ca5a27 100644 --- a/src/renderer/src/components/browser-pane/new-tab/BrowserNewTabPage.tsx +++ b/src/renderer/src/components/browser-pane/new-tab/BrowserNewTabPage.tsx @@ -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: [] }) } } ) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx index 11d250801c0..8e4de9853af 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx @@ -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: [], diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx index 851c87f294f..bf0735bdc42 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx @@ -147,7 +147,9 @@ beforeEach(() => { mocks.viewportRenders.current = 0 Object.defineProperty(window, 'api', { configurable: true, - value: { ui: { onFocusBrowserAddressBar: () => () => {} } } + value: { + ui: { onFocusBrowserAddressBar: () => () => {}, onShowBrowserHistory: () => () => {} } + } }) }) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-address-edit.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-address-edit.tsx index 8c0132de4cf..abf48de8ef6 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-address-edit.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-address-edit.tsx @@ -111,11 +111,6 @@ export function DocPreviewAddressEdit({ return (
{ - 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({ { 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', { diff --git a/src/renderer/src/store/slices/browser/browser-history-actions.ts b/src/renderer/src/store/slices/browser/browser-history-actions.ts index 9e9880b5695..992942a6d8d 100644 --- a/src/renderer/src/store/slices/browser/browser-history-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-history-actions.ts @@ -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 }) } } diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index cf898ff3ab8..0ee75099f28 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -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> setDefaultBrowserSessionProfileId: (profileId: string | null) => void diff --git a/src/renderer/src/store/slices/browser/create-browser-slice.ts b/src/renderer/src/store/slices/browser/create-browser-slice.ts index e2c0b4f9749..e33df5349c2 100644 --- a/src/renderer/src/store/slices/browser/create-browser-slice.ts +++ b/src/renderer/src/store/slices/browser/create-browser-slice.ts @@ -38,6 +38,7 @@ export const createBrowserSlice: StateCreator = browserSessionImportState: null, browserUrlHistory: [], workspaceDocHistory: [], + browserHistoryPageId: null, defaultBrowserSessionProfileId: null, defaultBrowserSessionProfileIdByHostId: {}, detectedBrowsers: [], diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 3ed86c91906..107225465ad 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -245,6 +245,7 @@ export function createWebUiApi(): NonNullable['ui']> { replyTabClose: () => {}, onNewTerminalTab: () => noopUnsubscribe, onFocusBrowserAddressBar: () => noopUnsubscribe, + onShowBrowserHistory: () => noopUnsubscribe, onFindInBrowserPage: () => noopUnsubscribe, onReloadBrowserPage: () => noopUnsubscribe, onBrowserHistoryNavigate: () => noopUnsubscribe, diff --git a/src/shared/keybindings/definitions-core-2.ts b/src/shared/keybindings/definitions-core-2.ts index 1ae589b67c5..31c7e751ae7 100644 --- a/src/shared/keybindings/definitions-core-2.ts +++ b/src/shared/keybindings/definitions-core-2.ts @@ -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'] } } ] diff --git a/src/shared/keybindings/types.ts b/src/shared/keybindings/types.ts index 2aba38f51ff..edb66379feb 100644 --- a/src/shared/keybindings/types.ts +++ b/src/shared/keybindings/types.ts @@ -85,6 +85,7 @@ export type KeybindingActionId = | 'browser.reload' | 'browser.hardReload' | 'browser.focusAddressBar' + | 'browser.showHistory' | 'browser.grabElement' | 'browser.annotateElement' | 'editor.find'