feat(browser): add hard reload option and shortcut hints to reload button (#12483)
* feat(browser): add hard reload option and shortcut hints to reload button Add a tooltip to the browser reload button showing the reload shortcut. Add a right-click context menu with Reload and Hard Reload options. Add localized labels for Hard Reload across EN, ZH, JA, KO, ES. * fix(browser): add aria-labels to reload buttons * feat(browser): make reload button contextual and extract action logic - Button label now reflects actual action: Stop when loading, Retry on failure, Reload when idle - Extract reload intent resolution into reusable browser-reload-action module with tests - Add keyboard support (Enter/Space) for the reload button - Simplify remote page reload to tooltip-only (no ignore-cache RPC for remote pages) - Add "Stop" translations for all supported languages * fix(browser): exhaust reload intent switch for type-aware lint Replace the default branch with an explicit reload case so oxlint switch-exhaustiveness-check accepts BrowserReloadIntent. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
parent
fde816e4ee
commit
b1b291db08
|
|
@ -195,6 +195,11 @@ import {
|
|||
createBrowserPageGuestRecovery
|
||||
} from './browser-page-guest-recovery'
|
||||
import { subscribeBrowserSystemResume } from './browser-system-resume'
|
||||
import {
|
||||
type BrowserReloadTrigger,
|
||||
resolveBrowserReloadButtonLabelKind,
|
||||
resolveBrowserReloadIntent
|
||||
} from './browser-reload-action'
|
||||
|
||||
type BrowserTabPageState = Partial<
|
||||
Pick<
|
||||
|
|
@ -2613,18 +2618,31 @@ function RemoteBrowserPagePane({
|
|||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => void runRemoteNavigation('browser.reload')}
|
||||
>
|
||||
{busy || browserTab.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{/* Why: no ignore-cache RPC exists for remote pages, and this pane binds no reload chord, so there is
|
||||
nothing truthful to put in a menu or a shortcut hint here — tooltip only. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={translate(
|
||||
'auto.components.browser.pane.BrowserPane.0e080d820e',
|
||||
'Reload'
|
||||
)}
|
||||
onClick={() => void runRemoteNavigation('browser.reload')}
|
||||
>
|
||||
{busy || browserTab.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<BrowserAddressBar
|
||||
value={addressBarValue}
|
||||
onChange={setAddressBarValue}
|
||||
|
|
@ -2879,6 +2897,9 @@ function BrowserPagePane({
|
|||
getExplicitBrowserPageZoomLevel(browserTab.id) ?? normalizedBrowserDefaultZoomLevel
|
||||
)
|
||||
const grabElementShortcut = useShortcutLabel('browser.grabElement')
|
||||
const reloadShortcut = useShortcutLabel('browser.reload')
|
||||
const hardReloadShortcut = useShortcutLabel('browser.hardReload')
|
||||
const [reloadMenuOpen, setReloadMenuOpen] = useState(false)
|
||||
const faviconUrlRef = useRef<string | null>(browserTab.faviconUrl)
|
||||
const initialBrowserUrlRef = useRef(browserTab.url)
|
||||
const browserTabUrlRef = useRef(browserTab.url)
|
||||
|
|
@ -2916,6 +2937,47 @@ function BrowserPagePane({
|
|||
const [findOpen, setFindOpen] = useState(false)
|
||||
const grab = useGrabMode(browserTab.id)
|
||||
|
||||
const reloadState = useMemo(
|
||||
() => ({ loading: browserTab.loading, loadErrorCode: browserTab.loadError?.code ?? null }),
|
||||
[browserTab.loading, browserTab.loadError]
|
||||
)
|
||||
const runReloadTrigger = useCallback(
|
||||
(trigger: BrowserReloadTrigger) => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) {
|
||||
return
|
||||
}
|
||||
switch (resolveBrowserReloadIntent(trigger, reloadState)) {
|
||||
case 'stop':
|
||||
webview.stop()
|
||||
break
|
||||
case 'retry-guest-recovery':
|
||||
onUpdatePageStateRef.current(browserTab.id, { loading: true })
|
||||
retryGuestRecoveryRef.current()
|
||||
break
|
||||
case 'retry-load':
|
||||
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
|
||||
break
|
||||
case 'hard-reload':
|
||||
webview.reloadIgnoringCache()
|
||||
break
|
||||
case 'reload':
|
||||
webview.reload()
|
||||
break
|
||||
}
|
||||
},
|
||||
[browserTab, reloadState]
|
||||
)
|
||||
|
||||
// Keep the accessible name honest: the same button is Stop mid-load and Retry after a failure.
|
||||
const reloadButtonLabelKind = resolveBrowserReloadButtonLabelKind(reloadState)
|
||||
const reloadButtonLabel =
|
||||
reloadButtonLabelKind === 'stop'
|
||||
? translate('auto.components.browser.pane.BrowserPane.b7e4d9c1a2', 'Stop')
|
||||
: reloadButtonLabelKind === 'retry'
|
||||
? translate('auto.components.browser.pane.BrowserPane.781d6459ad', 'Retry')
|
||||
: translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')
|
||||
|
||||
const markup = useMarkupMode({
|
||||
getCaptureContext: useCallback((): MarkupCaptureContext | null => {
|
||||
const webview = webviewRef.current
|
||||
|
|
@ -5175,35 +5237,62 @@ function BrowserPagePane({
|
|||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) {
|
||||
return
|
||||
}
|
||||
if (browserTab.loading) {
|
||||
webview.stop()
|
||||
} else if (browserTab.loadError) {
|
||||
if (browserTab.loadError.code === BROWSER_GUEST_RECOVERY_ERROR_CODE) {
|
||||
onUpdatePageStateRef.current(browserTab.id, { loading: true })
|
||||
retryGuestRecoveryRef.current()
|
||||
} else {
|
||||
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
|
||||
}
|
||||
} else {
|
||||
webview.reload()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{browserTab.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu modal={false} open={reloadMenuOpen} onOpenChange={setReloadMenuOpen}>
|
||||
{/* Why: suppress the tooltip while the menu is open — both anchor below the button and would overlap. */}
|
||||
<Tooltip open={reloadMenuOpen ? false : undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={reloadButtonLabel}
|
||||
// Why: preventDefault suppresses Radix's open-on-left-click (composeEventHandlers skips its
|
||||
// handler once defaultPrevented), keeping left-click on the primary action and the menu on right-click.
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
// Why: same trick for Radix's open-on-Enter/Space, which would otherwise preventDefault the
|
||||
// synthesized click and strand keyboard users. ArrowDown still falls through to open the menu.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
runReloadTrigger('button')
|
||||
}
|
||||
}}
|
||||
onClick={() => runReloadTrigger('button')}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setReloadMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{browserTab.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{reloadButtonLabel}
|
||||
{/* Why: the chord maps to plain reload(), which is not what Stop or Retry do — only hint when they match. */}
|
||||
{reloadShortcut && reloadButtonLabelKind === 'reload' ? ` · ${reloadShortcut}` : ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" alignOffset={-4}>
|
||||
<DropdownMenuItem onClick={() => runReloadTrigger('reload')}>
|
||||
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
|
||||
<DropdownMenuShortcut>{reloadShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => runReloadTrigger('hard-reload')}>
|
||||
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
|
||||
<DropdownMenuShortcut>{hardReloadShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<BrowserAddressBar
|
||||
value={addressBarValue}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery'
|
||||
import {
|
||||
resolveBrowserReloadButtonLabelKind,
|
||||
resolveBrowserReloadIntent
|
||||
} from './browser-reload-action'
|
||||
|
||||
const idle = { loading: false, loadErrorCode: null }
|
||||
const loading = { loading: true, loadErrorCode: null }
|
||||
const failed = { loading: false, loadErrorCode: -105 }
|
||||
const guestFailed = { loading: false, loadErrorCode: BROWSER_GUEST_RECOVERY_ERROR_CODE }
|
||||
|
||||
describe('resolveBrowserReloadIntent', () => {
|
||||
it('reloads an idle page from every trigger', () => {
|
||||
expect(resolveBrowserReloadIntent('button', idle)).toBe('reload')
|
||||
expect(resolveBrowserReloadIntent('reload', idle)).toBe('reload')
|
||||
expect(resolveBrowserReloadIntent('hard-reload', idle)).toBe('hard-reload')
|
||||
})
|
||||
|
||||
it('stops an in-flight load only from the toolbar button', () => {
|
||||
expect(resolveBrowserReloadIntent('button', loading)).toBe('stop')
|
||||
expect(resolveBrowserReloadIntent('reload', loading)).toBe('reload')
|
||||
expect(resolveBrowserReloadIntent('hard-reload', loading)).toBe('hard-reload')
|
||||
})
|
||||
|
||||
// Why: reload() on chrome-error:// only refreshes the error page — every entry point must retry the load.
|
||||
it('routes a failed load to the retry path from the menu too', () => {
|
||||
expect(resolveBrowserReloadIntent('button', failed)).toBe('retry-load')
|
||||
expect(resolveBrowserReloadIntent('reload', failed)).toBe('retry-load')
|
||||
expect(resolveBrowserReloadIntent('hard-reload', failed)).toBe('retry-load')
|
||||
})
|
||||
|
||||
it('routes a guest-recovery failure to guest recovery from the menu too', () => {
|
||||
expect(resolveBrowserReloadIntent('button', guestFailed)).toBe('retry-guest-recovery')
|
||||
expect(resolveBrowserReloadIntent('reload', guestFailed)).toBe('retry-guest-recovery')
|
||||
expect(resolveBrowserReloadIntent('hard-reload', guestFailed)).toBe('retry-guest-recovery')
|
||||
})
|
||||
|
||||
it('prefers stop over retry when a failed page is already reloading', () => {
|
||||
expect(resolveBrowserReloadIntent('button', { loading: true, loadErrorCode: -105 })).toBe(
|
||||
'stop'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBrowserReloadButtonLabelKind', () => {
|
||||
it('names the button for what it actually does', () => {
|
||||
expect(resolveBrowserReloadButtonLabelKind(idle)).toBe('reload')
|
||||
expect(resolveBrowserReloadButtonLabelKind(loading)).toBe('stop')
|
||||
expect(resolveBrowserReloadButtonLabelKind(failed)).toBe('retry')
|
||||
expect(resolveBrowserReloadButtonLabelKind(guestFailed)).toBe('retry')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery'
|
||||
|
||||
/** Where the reload request came from: the toolbar button, or an explicit menu entry. */
|
||||
export type BrowserReloadTrigger = 'button' | 'reload' | 'hard-reload'
|
||||
|
||||
export type BrowserReloadIntent =
|
||||
| 'stop'
|
||||
| 'retry-guest-recovery'
|
||||
| 'retry-load'
|
||||
| 'reload'
|
||||
| 'hard-reload'
|
||||
|
||||
export type BrowserReloadState = {
|
||||
loading: boolean
|
||||
loadErrorCode: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: webview.reload() only refreshes a chrome-error:// page, so a failed load has to go through the
|
||||
* recovery paths no matter which entry point asked for it. Only the toolbar button doubles as Stop.
|
||||
*/
|
||||
export function resolveBrowserReloadIntent(
|
||||
trigger: BrowserReloadTrigger,
|
||||
state: BrowserReloadState
|
||||
): BrowserReloadIntent {
|
||||
if (trigger === 'button' && state.loading) {
|
||||
return 'stop'
|
||||
}
|
||||
if (state.loadErrorCode !== null) {
|
||||
return state.loadErrorCode === BROWSER_GUEST_RECOVERY_ERROR_CODE
|
||||
? 'retry-guest-recovery'
|
||||
: 'retry-load'
|
||||
}
|
||||
return trigger === 'hard-reload' ? 'hard-reload' : 'reload'
|
||||
}
|
||||
|
||||
/** Accessible name for the toolbar button, which is Stop mid-load and Retry after a failure. */
|
||||
export type BrowserReloadButtonLabelKind = 'stop' | 'retry' | 'reload'
|
||||
|
||||
export function resolveBrowserReloadButtonLabelKind(
|
||||
state: BrowserReloadState
|
||||
): BrowserReloadButtonLabelKind {
|
||||
if (state.loading) {
|
||||
return 'stop'
|
||||
}
|
||||
return state.loadErrorCode !== null ? 'retry' : 'reload'
|
||||
}
|
||||
|
|
@ -13736,6 +13736,8 @@
|
|||
"1b179ab561": "Copy Page URL",
|
||||
"f7ab83f7ed": "Open Page In Default Browser",
|
||||
"0e080d820e": "Reload",
|
||||
"a1f3c2e4b5": "Hard Reload",
|
||||
"b7e4d9c1a2": "Stop",
|
||||
"250a9b3e42": "Forward",
|
||||
"40edfa75cb": "Back",
|
||||
"efb0e8f7f3": "Copy Link Address",
|
||||
|
|
|
|||
|
|
@ -13455,6 +13455,8 @@
|
|||
"1b179ab561": "Copiar URL de página",
|
||||
"f7ab83f7ed": "Abrir página en el navegador predeterminado",
|
||||
"0e080d820e": "Recargar",
|
||||
"a1f3c2e4b5": "Recarga forzada",
|
||||
"b7e4d9c1a2": "Detener",
|
||||
"250a9b3e42": "Adelante",
|
||||
"40edfa75cb": "Atrás",
|
||||
"efb0e8f7f3": "Copiar dirección de enlace",
|
||||
|
|
|
|||
|
|
@ -13455,6 +13455,8 @@
|
|||
"1b179ab561": "ページの URL をコピー",
|
||||
"f7ab83f7ed": "デフォルトのブラウザでページを開く",
|
||||
"0e080d820e": "リロード",
|
||||
"a1f3c2e4b5": "ハードリロード",
|
||||
"b7e4d9c1a2": "停止",
|
||||
"250a9b3e42": "進む",
|
||||
"40edfa75cb": "戻る",
|
||||
"efb0e8f7f3": "リンクアドレスをコピーする",
|
||||
|
|
|
|||
|
|
@ -13466,6 +13466,8 @@
|
|||
"1b179ab561": "페이지 URL 복사",
|
||||
"f7ab83f7ed": "기본 브라우저에서 페이지 열기",
|
||||
"0e080d820e": "새로고침",
|
||||
"a1f3c2e4b5": "강제 새로고침",
|
||||
"b7e4d9c1a2": "중지",
|
||||
"250a9b3e42": "앞으로",
|
||||
"40edfa75cb": "뒤로",
|
||||
"efb0e8f7f3": "링크 주소 복사",
|
||||
|
|
|
|||
|
|
@ -13475,6 +13475,8 @@
|
|||
"1b179ab561": "复制页面网址",
|
||||
"f7ab83f7ed": "在默认浏览器中打开页面",
|
||||
"0e080d820e": "重新加载",
|
||||
"a1f3c2e4b5": "硬刷新",
|
||||
"b7e4d9c1a2": "停止",
|
||||
"250a9b3e42": "向前",
|
||||
"40edfa75cb": "返回",
|
||||
"efb0e8f7f3": "复制链接地址",
|
||||
|
|
|
|||
Loading…
Reference in New Issue