Allow browser pages to zoom independently (#4646)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
45705b0a7a
commit
8b66785e66
|
|
@ -9,7 +9,7 @@ vi.mock('electron', () => ({
|
|||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
|
||||
import { setupGuestContextMenu } from './browser-guest-ui'
|
||||
import { setupGuestContextMenu, setupGuestShortcutForwarding } from './browser-guest-ui'
|
||||
|
||||
describe('setupGuestContextMenu', () => {
|
||||
const browserTabId = 'tab-1'
|
||||
|
|
@ -252,3 +252,114 @@ describe('setupGuestContextMenu', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupGuestShortcutForwarding', () => {
|
||||
const browserTabId = 'tab-1'
|
||||
let rendererSendMock: ReturnType<typeof vi.fn>
|
||||
let guestOnMock: ReturnType<typeof vi.fn>
|
||||
let guestOffMock: ReturnType<typeof vi.fn>
|
||||
|
||||
function makeGuest() {
|
||||
return {
|
||||
on: guestOnMock,
|
||||
off: guestOffMock
|
||||
} as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
function makeRenderer() {
|
||||
return { send: rendererSendMock } as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
function triggerBeforeInput(input: Partial<Electron.Input>): ReturnType<typeof vi.fn> {
|
||||
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'before-input-event')?.[1] as
|
||||
| ((event: Electron.Event, input: Electron.Input) => void)
|
||||
| undefined
|
||||
expect(handler).toBeTypeOf('function')
|
||||
const preventDefault = vi.fn()
|
||||
handler!(
|
||||
{ preventDefault } as unknown as Electron.Event,
|
||||
{
|
||||
type: 'keyDown',
|
||||
alt: false,
|
||||
meta: process.platform === 'darwin',
|
||||
control: process.platform !== 'darwin',
|
||||
shift: false,
|
||||
...input
|
||||
} as Electron.Input
|
||||
)
|
||||
return preventDefault
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rendererSendMock = vi.fn()
|
||||
guestOnMock = vi.fn()
|
||||
guestOffMock = vi.fn()
|
||||
})
|
||||
|
||||
it('forwards browser page zoom shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer()
|
||||
})
|
||||
|
||||
const zoomInPreventDefault = triggerBeforeInput({ code: 'Equal', key: '=' })
|
||||
const shiftedPlusPreventDefault = triggerBeforeInput({
|
||||
code: 'Equal',
|
||||
key: '+',
|
||||
shift: true
|
||||
})
|
||||
const zoomOutPreventDefault = triggerBeforeInput({ code: 'Minus', key: '-' })
|
||||
const numpadSubtractPreventDefault = triggerBeforeInput({ code: 'NumpadSubtract', key: '-' })
|
||||
const resetPreventDefault = triggerBeforeInput({ code: 'Digit0', key: '0' })
|
||||
const repeatPreventDefault = triggerBeforeInput({
|
||||
code: 'NumpadAdd',
|
||||
key: '+',
|
||||
isAutoRepeat: true
|
||||
})
|
||||
|
||||
expect(zoomInPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(shiftedPlusPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(zoomOutPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(numpadSubtractPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(resetPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(repeatPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'in')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:zoomBrowserPage', 'out')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:zoomBrowserPage', 'out')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:zoomBrowserPage', 'reset')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:zoomBrowserPage', 'in')
|
||||
})
|
||||
|
||||
it('consumes guest zoom shortcuts even when the renderer is unavailable', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => null
|
||||
})
|
||||
|
||||
const preventDefault = triggerBeforeInput({ code: 'Equal', key: '=' })
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses customized zoom keybindings when forwarding guest shortcuts', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer(),
|
||||
getKeybindings: () => ({
|
||||
'zoom.in': ['Mod+Alt+Z']
|
||||
})
|
||||
})
|
||||
|
||||
const defaultPreventDefault = triggerBeforeInput({ code: 'Equal', key: '=' })
|
||||
const customPreventDefault = triggerBeforeInput({ code: 'KeyZ', key: 'z', alt: true })
|
||||
|
||||
expect(defaultPreventDefault).not.toHaveBeenCalled()
|
||||
expect(customPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -255,6 +255,14 @@ export function setupGuestShortcutForwarding(args: {
|
|||
// which rejects Alt. Every other chord handled further down can reuse
|
||||
// the same `action` rather than re-running the full predicate chain.
|
||||
const action = resolveWindowShortcutAction(input, process.platform, keybindings)
|
||||
if (action?.type === 'zoom') {
|
||||
// Why: browser page zoom must consume repeats and teardown races before
|
||||
// Chromium or the guest page can apply its own shortcut behavior.
|
||||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:zoomBrowserPage', action.direction)
|
||||
return
|
||||
}
|
||||
if (input.isAutoRepeat) {
|
||||
if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
|
|
@ -2072,6 +2072,7 @@ export type PreloadApi = {
|
|||
onFocusBrowserAddressBar: (callback: () => void) => () => void
|
||||
onFindInBrowserPage: (callback: () => void) => () => void
|
||||
onReloadBrowserPage: (callback: () => void) => () => void
|
||||
onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
|
||||
onHardReloadBrowserPage: (callback: () => void) => () => void
|
||||
onCloseActiveTab: (callback: () => void) => () => void
|
||||
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void
|
||||
|
|
|
|||
|
|
@ -2600,6 +2600,12 @@ const api = {
|
|||
ipcRenderer.on('ui:reloadBrowserPage', listener)
|
||||
return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener)
|
||||
},
|
||||
onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') =>
|
||||
callback(direction)
|
||||
ipcRenderer.on('ui:zoomBrowserPage', listener)
|
||||
return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener)
|
||||
},
|
||||
onHardReloadBrowserPage: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('ui:hardReloadBrowserPage', listener)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,12 @@ import {
|
|||
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
|
||||
type BrowserFocusRequestDetail
|
||||
} from './browser-focus'
|
||||
import {
|
||||
addBrowserPageZoomEventListener,
|
||||
applyBrowserPageZoom,
|
||||
browserPageZoomLevelToPercent,
|
||||
type BrowserPageZoomDirection
|
||||
} from './browser-page-zoom'
|
||||
import {
|
||||
isRemoteRuntimeFileOperation,
|
||||
statRuntimePath,
|
||||
|
|
@ -181,6 +187,7 @@ const BROWSER_ANNOTATION_INTENT_OPTIONS = [
|
|||
// Why: priority remains in the persisted annotation shape for backwards
|
||||
// compatibility, but the annotation UI no longer exposes urgency choices.
|
||||
const DEFAULT_BROWSER_ANNOTATION_PRIORITY: BrowserAnnotationPriority = 'important'
|
||||
const BROWSER_PAGE_ZOOM_FEEDBACK_MS = 1400
|
||||
|
||||
type BrowserOverlayViewport = {
|
||||
scrollX: number
|
||||
|
|
@ -2493,6 +2500,7 @@ function BrowserPagePane({
|
|||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const grabToastTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const annotationCopyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const browserZoomFeedbackTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const setContainerRef = useCallback((node: HTMLDivElement | null): void => {
|
||||
containerRef.current = node
|
||||
if (node !== null) {
|
||||
|
|
@ -2502,6 +2510,7 @@ function BrowserPagePane({
|
|||
// after the DOM owner is detached.
|
||||
clearTimeout(grabToastTimerRef.current)
|
||||
clearTimeout(annotationCopyTimerRef.current)
|
||||
clearTimeout(browserZoomFeedbackTimerRef.current)
|
||||
}, [])
|
||||
const addressBarInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const webviewRef = useRef<Electron.WebviewTag | null>(null)
|
||||
|
|
@ -2537,6 +2546,8 @@ function BrowserPagePane({
|
|||
const [resourceNotice, setResourceNotice] = useState<string | null>(null)
|
||||
const [downloadState, setDownloadState] = useState<BrowserDownloadState | null>(null)
|
||||
const downloadStateRef = useRef<BrowserDownloadState | null>(null)
|
||||
const [browserZoomPercent, setBrowserZoomPercent] = useState(100)
|
||||
const [browserZoomFeedbackVisible, setBrowserZoomFeedbackVisible] = useState(false)
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
|
|
@ -2599,6 +2610,14 @@ function BrowserPagePane({
|
|||
const webviewPartition = sessionProfile?.partition ?? ORCA_BROWSER_PARTITION
|
||||
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
|
||||
const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState)
|
||||
const showBrowserZoomFeedback = useCallback((level: number): void => {
|
||||
setBrowserZoomPercent(browserPageZoomLevelToPercent(level))
|
||||
setBrowserZoomFeedbackVisible(true)
|
||||
clearTimeout(browserZoomFeedbackTimerRef.current)
|
||||
browserZoomFeedbackTimerRef.current = setTimeout(() => {
|
||||
setBrowserZoomFeedbackVisible(false)
|
||||
}, BROWSER_PAGE_ZOOM_FEEDBACK_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserSessionImportState) {
|
||||
|
|
@ -3179,6 +3198,32 @@ function BrowserPagePane({
|
|||
})
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
const applyActivePageZoom = (direction: BrowserPageZoomDirection): void => {
|
||||
if (!isActiveRef.current) {
|
||||
return
|
||||
}
|
||||
const nextLevel = applyBrowserPageZoom(webviewRef.current, direction)
|
||||
if (nextLevel !== null) {
|
||||
showBrowserZoomFeedback(nextLevel)
|
||||
}
|
||||
}
|
||||
const removeGuestListener = window.api.ui.onZoomBrowserPage(applyActivePageZoom)
|
||||
const removeLocalListener = addBrowserPageZoomEventListener((detail) => {
|
||||
if (detail.browserPageId !== browserTabIdRef.current) {
|
||||
return
|
||||
}
|
||||
applyActivePageZoom(detail.direction)
|
||||
})
|
||||
return () => {
|
||||
removeGuestListener()
|
||||
removeLocalListener()
|
||||
}
|
||||
}, [isActive, showBrowserZoomFeedback])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
|
|
@ -4193,6 +4238,7 @@ function BrowserPagePane({
|
|||
}
|
||||
return received
|
||||
})()
|
||||
const showBrowserZoomIndicator = browserZoomFeedbackVisible || browserZoomPercent !== 100
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
|
|
@ -4717,6 +4763,21 @@ function BrowserPagePane({
|
|||
onDragOver={handleInternalFileDragOver}
|
||||
onDrop={handleInternalFileDrop}
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-hidden={!showBrowserZoomIndicator}
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-3 right-3 z-30 rounded-md border border-border bg-popover/95 px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-xs transition-opacity duration-300 ease-out',
|
||||
browserZoomFeedbackVisible
|
||||
? 'opacity-100'
|
||||
: browserZoomPercent === 100
|
||||
? 'opacity-0'
|
||||
: 'opacity-80'
|
||||
)}
|
||||
>
|
||||
{browserZoomPercent}%
|
||||
</div>
|
||||
<BrowserFind isOpen={findOpen} onClose={() => setFindOpen(false)} webviewRef={webviewRef} />
|
||||
{showFailureOverlay ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyBrowserPageZoom,
|
||||
browserPageZoomLevelToPercent,
|
||||
nextBrowserPageZoomLevel
|
||||
} from './browser-page-zoom'
|
||||
|
||||
describe('browserPageZoomLevelToPercent', () => {
|
||||
it('maps Electron zoom levels to Chromium-style percentages', () => {
|
||||
expect(browserPageZoomLevelToPercent(0)).toBe(100)
|
||||
expect(browserPageZoomLevelToPercent(0.5)).toBe(110)
|
||||
expect(browserPageZoomLevelToPercent(-0.5)).toBe(91)
|
||||
expect(browserPageZoomLevelToPercent(5)).toBe(249)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextBrowserPageZoomLevel', () => {
|
||||
it('steps, clamps, and resets browser page zoom levels', () => {
|
||||
expect(nextBrowserPageZoomLevel(0, 'in')).toBe(0.5)
|
||||
expect(nextBrowserPageZoomLevel(0, 'out')).toBe(-0.5)
|
||||
expect(nextBrowserPageZoomLevel(3, 'reset')).toBe(0)
|
||||
expect(nextBrowserPageZoomLevel(5, 'in')).toBe(5)
|
||||
expect(nextBrowserPageZoomLevel(-3, 'out')).toBe(-3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBrowserPageZoom', () => {
|
||||
it('applies the next zoom level to a live webview', () => {
|
||||
const webview = {
|
||||
getZoomLevel: vi.fn(() => 1),
|
||||
setZoomLevel: vi.fn()
|
||||
}
|
||||
|
||||
expect(applyBrowserPageZoom(webview, 'in')).toBe(1.5)
|
||||
expect(webview.setZoomLevel).toHaveBeenCalledWith(1.5)
|
||||
})
|
||||
|
||||
it('returns null for missing or destroyed webviews', () => {
|
||||
expect(applyBrowserPageZoom(null, 'in')).toBeNull()
|
||||
expect(
|
||||
applyBrowserPageZoom(
|
||||
{
|
||||
isDestroyed: () => true,
|
||||
getZoomLevel: vi.fn(() => 0),
|
||||
setZoomLevel: vi.fn()
|
||||
},
|
||||
'out'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when webview zoom methods throw', () => {
|
||||
const getZoomFailure = {
|
||||
getZoomLevel: vi.fn(() => {
|
||||
throw new Error('detached')
|
||||
}),
|
||||
setZoomLevel: vi.fn()
|
||||
}
|
||||
const setZoomFailure = {
|
||||
getZoomLevel: vi.fn(() => 0),
|
||||
setZoomLevel: vi.fn(() => {
|
||||
throw new Error('destroyed')
|
||||
})
|
||||
}
|
||||
|
||||
expect(applyBrowserPageZoom(getZoomFailure, 'in')).toBeNull()
|
||||
expect(applyBrowserPageZoom(setZoomFailure, 'out')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
export type BrowserPageZoomDirection = 'in' | 'out' | 'reset'
|
||||
|
||||
export const ORCA_BROWSER_PAGE_ZOOM_EVENT = 'orca:browser-page-zoom'
|
||||
|
||||
export type BrowserPageZoomEventDetail = {
|
||||
browserPageId: string
|
||||
direction: BrowserPageZoomDirection
|
||||
}
|
||||
|
||||
type BrowserPageZoomWebview = {
|
||||
getZoomLevel: () => number
|
||||
setZoomLevel: (level: number) => void
|
||||
isDestroyed?: () => boolean
|
||||
}
|
||||
|
||||
const BROWSER_PAGE_ZOOM_STEP = 0.5
|
||||
const BROWSER_PAGE_ZOOM_MIN = -3
|
||||
const BROWSER_PAGE_ZOOM_MAX = 5
|
||||
const BROWSER_PAGE_ZOOM_RESET = 0
|
||||
|
||||
export function browserPageZoomLevelToPercent(level: number): number {
|
||||
// Why: Electron zoom levels are exponential; show the same percentage users
|
||||
// expect from Chromium browser zoom controls.
|
||||
return Math.round(100 * Math.pow(1.2, level))
|
||||
}
|
||||
|
||||
export function nextBrowserPageZoomLevel(
|
||||
current: number,
|
||||
direction: BrowserPageZoomDirection
|
||||
): number {
|
||||
const rawNext =
|
||||
direction === 'in'
|
||||
? current + BROWSER_PAGE_ZOOM_STEP
|
||||
: direction === 'out'
|
||||
? current - BROWSER_PAGE_ZOOM_STEP
|
||||
: BROWSER_PAGE_ZOOM_RESET
|
||||
|
||||
return Math.max(BROWSER_PAGE_ZOOM_MIN, Math.min(BROWSER_PAGE_ZOOM_MAX, rawNext))
|
||||
}
|
||||
|
||||
export function applyBrowserPageZoom(
|
||||
webview: BrowserPageZoomWebview | null | undefined,
|
||||
direction: BrowserPageZoomDirection
|
||||
): number | null {
|
||||
try {
|
||||
if (!webview || webview.isDestroyed?.()) {
|
||||
return null
|
||||
}
|
||||
const next = nextBrowserPageZoomLevel(webview.getZoomLevel(), direction)
|
||||
webview.setZoomLevel(next)
|
||||
return next
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchBrowserPageZoomEvent(detail: BrowserPageZoomEventDetail): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<BrowserPageZoomEventDetail>(ORCA_BROWSER_PAGE_ZOOM_EVENT, {
|
||||
detail
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function addBrowserPageZoomEventListener(
|
||||
callback: (detail: BrowserPageZoomEventDetail) => void
|
||||
): () => void {
|
||||
const listener = (event: Event): void => {
|
||||
callback((event as CustomEvent<BrowserPageZoomEventDetail>).detail)
|
||||
}
|
||||
window.addEventListener(ORCA_BROWSER_PAGE_ZOOM_EVENT, listener)
|
||||
return () => window.removeEventListener(ORCA_BROWSER_PAGE_ZOOM_EVENT, listener)
|
||||
}
|
||||
|
|
@ -13,9 +13,10 @@ export function resolveZoomTarget(args: {
|
|||
| 'skills'
|
||||
| 'mobile'
|
||||
activeTabType: 'terminal' | 'editor' | 'browser'
|
||||
activeBrowserPageId?: string | null
|
||||
activeElement: unknown
|
||||
}): 'terminal' | 'editor' | 'ui' {
|
||||
const { activeView, activeTabType, activeElement } = args
|
||||
}): 'terminal' | 'editor' | 'browser' | 'ui' {
|
||||
const { activeView, activeTabType, activeBrowserPageId, activeElement } = args
|
||||
const terminalInputFocused =
|
||||
typeof activeElement === 'object' &&
|
||||
activeElement !== null &&
|
||||
|
|
@ -43,6 +44,11 @@ export function resolveZoomTarget(args: {
|
|||
if (activeView !== 'terminal') {
|
||||
return 'ui'
|
||||
}
|
||||
// Why: a browser tab owns zoom shortcuts even if DOM focus still points at a
|
||||
// just-deactivated editor or terminal during tab switches.
|
||||
if (activeTabType === 'browser' && activeBrowserPageId) {
|
||||
return 'browser'
|
||||
}
|
||||
if (activeTabType === 'editor' || editorFocused) {
|
||||
return 'editor'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,196 @@ describe('resolveZoomTarget', () => {
|
|||
})
|
||||
).toBe('ui')
|
||||
})
|
||||
|
||||
it('routes to browser zoom for active browser tabs before stale DOM focus', () => {
|
||||
expect(
|
||||
resolveZoomTarget({
|
||||
activeView: 'terminal',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserPageId: 'page-1',
|
||||
activeElement: makeTarget({ editorClosest: true, hasXtermClass: true })
|
||||
})
|
||||
).toBe('browser')
|
||||
})
|
||||
|
||||
it('does not route to browser zoom without an active browser page', () => {
|
||||
expect(
|
||||
resolveZoomTarget({
|
||||
activeView: 'terminal',
|
||||
activeTabType: 'browser',
|
||||
activeBrowserPageId: null,
|
||||
activeElement: makeTarget({})
|
||||
})
|
||||
).toBe('ui')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIpcEvents zoom routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('dispatches browser page zoom without applying or persisting UI zoom', async () => {
|
||||
const terminalZoomListenerRef: {
|
||||
current: ((direction: 'in' | 'out' | 'reset') => void) | null
|
||||
} = { current: null }
|
||||
const dispatchEvent = vi.fn()
|
||||
const setUI = vi.fn()
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => ({
|
||||
activeView: 'terminal',
|
||||
activeTabType: 'browser',
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeBrowserTabId: 'workspace-1',
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' },
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1']
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [{ id: 'page-1', worktreeId: 'wt-1' }]
|
||||
},
|
||||
editorFontZoomLevel: 0,
|
||||
setEditorFontZoomLevel: vi.fn(),
|
||||
settings: { terminalFontSize: 13 },
|
||||
setUpdateStatus: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
activeModal: null,
|
||||
closeModal: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
setActiveRepo: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
setIsFullScreen: vi.fn(),
|
||||
setRateLimitsFromPush: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() }))
|
||||
vi.doMock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
|
||||
getVisibleWorktreeIds: () => []
|
||||
}))
|
||||
vi.doMock('@/lib/editor-font-zoom', () => ({
|
||||
nextEditorFontZoomLevel: vi.fn(() => 0),
|
||||
computeEditorFontSize: vi.fn(() => 13)
|
||||
}))
|
||||
vi.doMock('@/components/settings/SettingsConstants', () => ({
|
||||
zoomLevelToPercent: vi.fn(() => 100),
|
||||
ZOOM_MIN: -3,
|
||||
ZOOM_MAX: 3
|
||||
}))
|
||||
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() }))
|
||||
|
||||
const makeEvents = (target: Record<string, unknown> = {}): Record<string, unknown> =>
|
||||
new Proxy(target, {
|
||||
get: (namespace, prop) => {
|
||||
if (prop in namespace) {
|
||||
return Reflect.get(namespace, prop)
|
||||
}
|
||||
return () => () => {}
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('document', {
|
||||
activeElement: makeTarget({ editorClosest: true })
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent,
|
||||
setTimeout: vi.fn(() => 1),
|
||||
clearTimeout: vi.fn(),
|
||||
api: {
|
||||
repos: makeEvents(),
|
||||
worktrees: makeEvents(),
|
||||
keybindings: makeEvents(),
|
||||
settings: makeEvents(),
|
||||
updater: {
|
||||
getStatus: () => Promise.resolve({ state: 'idle' }),
|
||||
onStatus: () => () => {},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: makeEvents(),
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
listPortForwards: () => Promise.resolve([]),
|
||||
listDetectedPorts: () => Promise.resolve([]),
|
||||
getState: () => Promise.resolve(null),
|
||||
onStateChanged: () => () => {},
|
||||
onCredentialRequest: () => () => {},
|
||||
onCredentialResolved: () => () => {},
|
||||
onPortForwardsChanged: () => () => {},
|
||||
onDetectedPortsChanged: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
getTerminalDrivers: () => Promise.resolve([]),
|
||||
getBrowserDrivers: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {},
|
||||
onTerminalDriverChanged: () => () => {},
|
||||
onBrowserDriverChanged: () => () => {}
|
||||
},
|
||||
agentStatus: { onSet: () => () => {} },
|
||||
ui: makeEvents({
|
||||
onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => {
|
||||
terminalZoomListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
getZoomLevel: vi.fn(() => 0),
|
||||
set: setUI
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
const { applyUIZoom } = await import('@/lib/ui-zoom')
|
||||
|
||||
useIpcEvents()
|
||||
expect(terminalZoomListenerRef.current).toBeTypeOf('function')
|
||||
const listener = terminalZoomListenerRef.current
|
||||
if (!listener) {
|
||||
throw new Error('Expected terminal zoom listener to be registered')
|
||||
}
|
||||
listener('reset')
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(1)
|
||||
const event = dispatchEvent.mock.calls[0]?.[0] as CustomEvent<{
|
||||
browserPageId: string
|
||||
direction: string
|
||||
}>
|
||||
expect(event.type).toBe('orca:browser-page-zoom')
|
||||
expect(event.detail).toEqual({ browserPageId: 'page-1', direction: 'reset' })
|
||||
expect(applyUIZoom).not.toHaveBeenCalled()
|
||||
expect(setUI).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ uiZoomLevel: expect.anything() })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBrowserSessionTabTarget', () => {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import {
|
|||
setDriverForBrowserPage
|
||||
} from '@/lib/pane-manager/browser-mobile-driver-state'
|
||||
import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry'
|
||||
import { dispatchBrowserPageZoomEvent } from '@/components/browser-pane/browser-page-zoom'
|
||||
import {
|
||||
acquireBrowserAutomationVisibility,
|
||||
releaseBrowserAutomationVisibility
|
||||
|
|
@ -215,6 +216,23 @@ function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set<stri
|
|||
return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id))
|
||||
}
|
||||
|
||||
function resolveActiveBrowserPageId(state: AppState): string | null {
|
||||
const worktreeId = state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const activeWorkspaceId =
|
||||
state.activeBrowserTabIdByWorktree[worktreeId] ?? state.activeBrowserTabId ?? null
|
||||
const browserWorkspaces = state.browserTabsByWorktree[worktreeId] ?? []
|
||||
const workspace =
|
||||
browserWorkspaces.find((candidate) => candidate.id === activeWorkspaceId) ?? null
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
const pages = state.browserPagesByWorkspace[workspace.id] ?? []
|
||||
return workspace.activePageId ?? workspace.pageIds?.[0] ?? pages[0]?.id ?? null
|
||||
}
|
||||
|
||||
type TerminalSplitDirection = 'horizontal' | 'vertical'
|
||||
|
||||
function insertLeafAfterSource(
|
||||
|
|
@ -2144,16 +2162,26 @@ export function useIpcEvents(): void {
|
|||
// Zoom handling for menu accelerators and keyboard fallback paths.
|
||||
unsubs.push(
|
||||
window.api.ui.onTerminalZoom((direction) => {
|
||||
const store = useAppStore.getState()
|
||||
const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } =
|
||||
useAppStore.getState()
|
||||
store
|
||||
const activeBrowserPageId = resolveActiveBrowserPageId(store)
|
||||
const target = resolveZoomTarget({
|
||||
activeView,
|
||||
activeTabType,
|
||||
activeBrowserPageId,
|
||||
activeElement: document.activeElement
|
||||
})
|
||||
if (target === 'terminal') {
|
||||
return
|
||||
}
|
||||
if (target === 'browser') {
|
||||
if (!activeBrowserPageId) {
|
||||
return
|
||||
}
|
||||
dispatchBrowserPageZoomEvent({ browserPageId: activeBrowserPageId, direction })
|
||||
return
|
||||
}
|
||||
if (target === 'editor') {
|
||||
const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction)
|
||||
setEditorFontZoomLevel(next)
|
||||
|
|
|
|||
|
|
@ -1820,6 +1820,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onFocusBrowserAddressBar: () => noopUnsubscribe,
|
||||
onFindInBrowserPage: () => noopUnsubscribe,
|
||||
onReloadBrowserPage: () => noopUnsubscribe,
|
||||
onZoomBrowserPage: () => noopUnsubscribe,
|
||||
onHardReloadBrowserPage: () => noopUnsubscribe,
|
||||
onCloseActiveTab: () => noopUnsubscribe,
|
||||
onSwitchTab: () => noopUnsubscribe,
|
||||
|
|
|
|||
Loading…
Reference in New Issue