diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index 958a57766..718620273 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -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 + let guestOnMock: ReturnType + let guestOffMock: ReturnType + + 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): ReturnType { + 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') + }) +}) diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 5fa7a8c9d..5470026b4 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -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() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3a444e391..fab78ebe9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 8107332e7..adee37913 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index f97e5debf..57fe9a2d4 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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(null) const grabToastTimerRef = useRef>(undefined) const annotationCopyTimerRef = useRef>(undefined) + const browserZoomFeedbackTimerRef = useRef>(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(null) const webviewRef = useRef(null) @@ -2537,6 +2546,8 @@ function BrowserPagePane({ const [resourceNotice, setResourceNotice] = useState(null) const [downloadState, setDownloadState] = useState(null) const downloadStateRef = useRef(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} > +
+ {browserZoomPercent}% +
setFindOpen(false)} webviewRef={webviewRef} /> {showFailureOverlay ? (
diff --git a/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts b/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts new file mode 100644 index 000000000..06c3b3499 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-page-zoom.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-page-zoom.ts b/src/renderer/src/components/browser-pane/browser-page-zoom.ts new file mode 100644 index 000000000..2b31a4a6c --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-page-zoom.ts @@ -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(ORCA_BROWSER_PAGE_ZOOM_EVENT, { + detail + }) + ) +} + +export function addBrowserPageZoomEventListener( + callback: (detail: BrowserPageZoomEventDetail) => void +): () => void { + const listener = (event: Event): void => { + callback((event as CustomEvent).detail) + } + window.addEventListener(ORCA_BROWSER_PAGE_ZOOM_EVENT, listener) + return () => window.removeEventListener(ORCA_BROWSER_PAGE_ZOOM_EVENT, listener) +} diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts index 91b40081e..128a1ac49 100644 --- a/src/renderer/src/hooks/resolve-zoom-target.ts +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -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' } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 2547305f5..7837c3e6a 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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('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 = {}): Record => + 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', () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 6996d4dde..ff3c097e5 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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 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) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ed8e902e9..d51c9232a 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1820,6 +1820,7 @@ function createWebUiApi(): NonNullable['ui']> { onFocusBrowserAddressBar: () => noopUnsubscribe, onFindInBrowserPage: () => noopUnsubscribe, onReloadBrowserPage: () => noopUnsubscribe, + onZoomBrowserPage: () => noopUnsubscribe, onHardReloadBrowserPage: () => noopUnsubscribe, onCloseActiveTab: () => noopUnsubscribe, onSwitchTab: () => noopUnsubscribe,