feat(browser): per-site appearance and zoom, from the page menu and Cmd/Ctrl +/-/0 (#31621)

This commit is contained in:
Kelvin Amoaba
2026-10-21 23:47:12 +00:00
committed by GitHub
parent 7e795b6be6
commit c5e07bc3af
55 changed files with 1285 additions and 806 deletions
@@ -14,6 +14,7 @@ export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavi
}
this.tabIdByWebContentsId.delete(guestWebContentsId)
this.certificateTrustController?.onGuestRetired(guestWebContentsId)
this.siteDisplayController?.onGuestRetired(guestWebContentsId)
const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId)
if (policyCleanup) {
policyCleanup()
@@ -136,6 +136,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager
// Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it.
this.clearedLoadErrorsByGuestId.delete(guest.id)
this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url)
this.siteDisplayController?.onMainFrameNavigationCommitted(guest.id)
// Why: an offscreen page has no renderer to publish its row, so this commit is the only
// moment paired clients can learn the new url — every failure path above already announces.
this.notifyBrowserGuestStateChanged(guest.id)
@@ -72,6 +72,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
this.worktreeIdByTabId.set(browserTabId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId)
this.siteDisplayController?.onGuestRegistered(guest, sessionProfileId ?? null)
this.setupContextMenu(browserTabId, guest)
this.setupGrabShortcut(browserTabId, guest)
@@ -1,5 +1,6 @@
import { BrowserGrabSessionController } from './browser-grab-session-controller'
import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller'
import type { BrowserSiteDisplayController } from './browser-site-display-controller'
import {
createPageInitiatedTabBudget,
type PageInitiatedTabBudget
@@ -153,6 +154,7 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
protected readonly clearedLoadErrorsByGuestId = new Map<number, BrowserLoadError>()
protected browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null
protected certificateTrustController: BrowserCertificateTrustController | null = null
protected siteDisplayController: BrowserSiteDisplayController | null = null
protected shouldForwardDictationShortcut: (() => boolean) | null = null
protected readonly pendingLoadFailuresByGuestId = new Map<
number,
@@ -176,6 +178,10 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
this.certificateTrustController = controller
}
setSiteDisplayController(controller: BrowserSiteDisplayController): void {
this.siteDisplayController = controller
}
installCertificateRequestGuard(session: Electron.Session): void {
this.certificateTrustController?.installSessionRequestGuard(session)
}
@@ -0,0 +1,123 @@
import { app } from 'electron'
import { mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write'
/** A JSON file of browser profile id → site → entry, kept in the Orca profile directory. */
export type BrowserProfileSiteStore<Entry> = {
configure: (profileDirectory: string) => void
get: (profileId: string, site: string) => Entry | undefined
/** Null forgets the site. */
set: (profileId: string, site: string, entry: Entry | null) => void
list: (profileId: string) => [site: string, entry: Entry][]
forget: (profileId: string) => void
}
type StoredSites<Entry> = Record<string, Record<string, Entry>>
export function createBrowserProfileSiteStore<Entry>({
fileName,
description,
parseEntry
}: {
fileName: string
/** Names the file in the save-failure log. */
description: string
/** Null drops an unreadable or empty entry. */
parseEntry: (value: unknown) => Entry | null
}): BrowserProfileSiteStore<Entry> {
let filePathOverride: string | null = null
let cached: { path: string; sites: StoredSites<Entry> } | null = null
// Why lazy: app.getPath('userData') throws before ready.
const storePath = (): string => filePathOverride ?? join(app.getPath('userData'), fileName)
const parse = (raw: string): StoredSites<Entry> => {
const parsed: unknown = JSON.parse(raw)
const stored: StoredSites<Entry> = {}
if (!parsed || typeof parsed !== 'object' || !('profiles' in parsed)) {
return stored
}
const { profiles } = parsed
if (!profiles || typeof profiles !== 'object') {
return stored
}
for (const [profileId, sites] of Object.entries(profiles)) {
if (!sites || typeof sites !== 'object') {
continue
}
const parsedSites: Record<string, Entry> = {}
for (const [site, value] of Object.entries(sites)) {
const entry = parseEntry(value)
if (entry !== null) {
parsedSites[site] = entry
}
}
stored[profileId] = parsedSites
}
return stored
}
const load = (): StoredSites<Entry> => {
let path: string
try {
path = storePath()
} catch {
return {}
}
if (cached?.path === path) {
return cached.sites
}
let sites: StoredSites<Entry> = {}
try {
sites = parse(readFileSync(path, 'utf-8'))
} catch {
// Missing or unreadable: every site starts over.
}
cached = { path, sites }
return sites
}
const persist = (sites: StoredSites<Entry>): void => {
try {
const path = storePath()
cached = { path, sites }
mkdirSync(dirname(path), { recursive: true })
writeFileDurableSync(
durableWriteTempPath(path),
path,
`${JSON.stringify({ version: 1, profiles: sites }, null, 2)}\n`
)
} catch (error) {
// Why: the change still holds for this run; failing the user's action would punish them.
console.error(`[browser] Could not save ${description}:`, error)
}
}
return {
configure: (profileDirectory) => {
filePathOverride = join(profileDirectory, fileName)
},
get: (profileId, site) => load()[profileId]?.[site],
set: (profileId, site, entry) => {
const stored = load()
const sites = { ...stored[profileId] }
if (entry === null) {
delete sites[site]
} else {
sites[site] = entry
}
persist({ ...stored, [profileId]: sites })
},
list: (profileId) =>
Object.entries(load()[profileId] ?? {}).sort(([a], [b]) => a.localeCompare(b)),
forget: (profileId) => {
const stored = load()
if (!(profileId in stored)) {
return
}
const { [profileId]: _forgotten, ...rest } = stored
persist(rest)
}
}
}
@@ -42,6 +42,7 @@ import { cancelBrowserWebAuthnAccountRequestsForSession } from './browser-webaut
import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path'
import { markBrowserIdentityMigrationNoticePending } from './browser-identity-mode-store'
import { forgetBrowserSitePermissions } from './browser-site-permission-store'
import { forgetBrowserSiteDisplay } from './browser-site-display-store'
export type BrowserSessionRegistryProfileOptions = {
orcaProfileId: string
@@ -274,6 +275,7 @@ class BrowserSessionRegistry {
this.profiles.delete(profileId)
this.persistProfiles()
forgetBrowserSitePermissions(profileId)
forgetBrowserSiteDisplay(profileId)
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports }
delete pendingCookieImports[profile.partition]
@@ -3,6 +3,7 @@ import type { BrowserSessionRegistryProfileOptions } from './browser-session-reg
import { collectOrphanedBrowserRoutePartitionStorage } from './browser-route-partition-storage-runtime'
import { configureRouteSessionsForOrcaProfile } from './browser-route-session-runtime'
import { configureBrowserSitePermissionStore } from './browser-site-permission-store'
import { configureBrowserSiteDisplayStore } from './browser-site-display-store'
import { configurePairedRuntimeBrowserClientHostsForOrcaProfile } from './paired-runtime-browser-client-host-runtime'
let initialized = false
@@ -19,6 +20,7 @@ export function initializeBrowserSessionsForApp(
if (activeProfile) {
browserSessionRegistry.configureForOrcaProfile(activeProfile)
configureBrowserSitePermissionStore(activeProfile.profileDirectory)
configureBrowserSiteDisplayStore(activeProfile.profileDirectory)
configureRouteSessionsForOrcaProfile({
orcaProfileId: activeProfile.orcaProfileId,
profileDirectory: activeProfile.profileDirectory
@@ -0,0 +1,186 @@
import { EventEmitter } from 'node:events'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import {
browserPageZoomLevelToPercent,
normalizeBrowserPageZoomLevel
} from '../../shared/browser-page-zoom'
import { BrowserSiteDisplayController } from './browser-site-display-controller'
import { configureBrowserSiteDisplayStore } from './browser-site-display-store'
type FakeGuest = Electron.WebContents &
EventEmitter & { url: string; level: number; mediaSent: unknown[] }
let nextGuestId = 1
function fakeGuest(url: string): FakeGuest {
const debuggerEvents = new EventEmitter()
let attached = false
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only reads id, getURL, zoom, isDestroyed, dom-ready and the debugger.
const guest = new EventEmitter() as FakeGuest
Object.assign(guest, {
id: nextGuestId++,
url,
level: 0,
mediaSent: [],
isDestroyed: () => false,
getURL: () => guest.url,
getZoomLevel: () => guest.level,
setZoomLevel: (level: number) => {
guest.level = level
},
debugger: Object.assign(debuggerEvents, {
isAttached: () => attached,
attach: () => {
attached = true
},
detach: () => {
attached = false
debuggerEvents.emit('detach')
},
sendCommand: (method: string, params: { features: { value: string }[] }) => {
if (method === 'Emulation.setEmulatedMedia') {
guest.mediaSent.push(params.features[0].value)
}
return Promise.resolve({})
}
})
})
return guest
}
/** What the page shows after the navigation commits and its document loads. */
function commit(controller: BrowserSiteDisplayController, guest: FakeGuest, url: string): void {
guest.url = url
controller.onMainFrameNavigationCommitted(guest.id)
guest.emit('dom-ready')
}
function uiStore(initial: Partial<PersistedUIState> = {}): {
getUI: () => PersistedUIState
onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void
update: (update: Partial<PersistedUIState>) => void
} {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only uiZoomLevel and browserDefaultZoomLevel.
let ui = { uiZoomLevel: 0, browserDefaultZoomLevel: 0, ...initial } as PersistedUIState
const listeners: ((ui: PersistedUIState) => void)[] = []
return {
getUI: () => ui,
onUIChanged: (listener) => {
listeners.push(listener)
return () => {}
},
update: (update) => {
ui = { ...ui, ...update }
listeners.forEach((listener) => listener(ui))
}
}
}
const percent = (guest: FakeGuest): number => browserPageZoomLevelToPercent(guest.level)
beforeEach(() => {
configureBrowserSiteDisplayStore(mkdtempSync(join(tmpdir(), 'orca-site-display-')))
})
describe('BrowserSiteDisplayController zoom', () => {
it('keeps zoom per site and browser profile, and resets it to Default Zoom', () => {
const controller = new BrowserSiteDisplayController()
const ui = uiStore({ browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(0.5) })
controller.followZoomSettings(ui)
const github = fakeGuest('https://github.com/a')
const githubOtherPort = fakeGuest('https://github.com:8443/b')
const githubWork = fakeGuest('https://github.com/c')
const docs = fakeGuest('https://docs.example.com/')
controller.onGuestRegistered(github, null)
controller.onGuestRegistered(githubOtherPort, 'default')
controller.onGuestRegistered(githubWork, 'work')
controller.onGuestRegistered(docs, null)
controller.zoomPage(github.id, 'in')
expect([github, githubOtherPort, githubWork, docs].map(percent)).toEqual([125, 125, 110, 110])
commit(controller, docs, 'https://github.com/d')
expect(percent(docs)).toBe(125)
expect(controller.zoomPage(github.id, 'reset')).toMatchObject({ siteHasZoom: false })
expect([github, githubOtherPort, docs].map(percent)).toEqual([110, 110, 110])
})
it("restores the site's zoom after Orca's UI zoom is copied onto the page", () => {
const controller = new BrowserSiteDisplayController()
const ui = uiStore()
controller.followZoomSettings(ui)
const guest = fakeGuest('https://github.com/')
controller.onGuestRegistered(guest, null)
controller.zoomPage(guest.id, 'in')
// Electron copies the embedder's zoom (here 120%) onto every webview, on change and after commits.
const copyUIZoom = (): void => {
guest.level = 1
}
copyUIZoom()
ui.update({ uiZoomLevel: 1 })
expect(percent(guest)).toBe(110)
guest.url = 'https://github.com/next'
controller.onMainFrameNavigationCommitted(guest.id)
copyUIZoom()
guest.emit('dom-ready')
expect(percent(guest)).toBe(110)
copyUIZoom()
controller.zoomPage(guest.id, 'in')
expect(percent(guest)).toBe(125)
})
})
describe('BrowserSiteDisplayController color scheme', () => {
it('sets the site scheme as each page commits and clears it once on leaving', () => {
const controller = new BrowserSiteDisplayController()
const guest = fakeGuest('https://github.com/')
controller.onGuestRegistered(guest, null)
controller.setColorScheme(guest.id, 'dark')
// Why it matters: each send replaces every emulated feature, including an agent's own.
controller.zoomPage(guest.id, 'in')
commit(controller, guest, 'https://docs.example.com/')
commit(controller, guest, 'https://news.example.com/')
commit(controller, guest, 'https://github.com/x')
expect(guest.mediaSent).toEqual(['dark', '', 'dark'])
})
it('gives a restored tab its scheme when its first load started before it registered', () => {
const controller = new BrowserSiteDisplayController()
const first = fakeGuest('https://github.com/')
controller.onGuestRegistered(first, null)
controller.setColorScheme(first.id, 'light')
const restored = fakeGuest('')
controller.onGuestRegistered(restored, null)
restored.url = 'https://github.com/restored'
controller.onMainFrameNavigationCommitted(restored.id)
expect(restored.mediaSent).toEqual(['light'])
})
it('attaches again after DevTools detaches the debugger', () => {
const controller = new BrowserSiteDisplayController()
const guest = fakeGuest('https://github.com/')
controller.onGuestRegistered(guest, null)
controller.setColorScheme(guest.id, 'dark')
guest.debugger.detach()
expect(guest.debugger.isAttached()).toBe(false)
commit(controller, guest, 'https://github.com/next')
expect(guest.debugger.isAttached()).toBe(true)
expect(guest.mediaSent.at(-1)).toBe('dark')
})
})
vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } }))
@@ -0,0 +1,248 @@
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
import { getBrowserSiteDisplay, updateBrowserSiteDisplay } from './browser-site-display-store'
import {
browserSiteDisplayKey,
type BrowserPageSiteDisplay,
type BrowserSiteColorScheme
} from '../../shared/browser-site-display'
import {
DEFAULT_BROWSER_PAGE_ZOOM_LEVEL,
nextBrowserPageZoomLevel,
normalizeBrowserPageZoomLevel,
type BrowserPageZoomDirection
} from '../../shared/browser-page-zoom'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import { errorMessage } from '../../shared/error-message'
type TrackedGuest = {
guest: Electron.WebContents
profileId: string
stopWatchingDomReady: () => void
colorScheme: {
lease: ElectronDebuggerLease
stopWatchingDetach: () => void
/** Orca's scheme is in force on the page, so leaving the site must clear it. */
standing: boolean
} | null
}
const ZOOM_EPSILON = 1e-6
/**
* Shows each page the way its browser profile remembers the site: its own zoom (or Default Zoom)
* and its own color scheme (or Orca's theme). Only webview guests take part; streamed pages have
* no menu to change either.
*/
export class BrowserSiteDisplayController {
private readonly guests = new Map<number, TrackedGuest>()
private defaultZoomLevel = DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
/** Electron pushes Orca's UI zoom onto every webview, so a UI zoom change moves pages too. */
followZoomSettings(ui: {
getUI: () => PersistedUIState
onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void
}): void {
let { uiZoomLevel, browserDefaultZoomLevel } = ui.getUI()
this.defaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel)
ui.onUIChanged((next) => {
if (
next.uiZoomLevel === uiZoomLevel &&
next.browserDefaultZoomLevel === browserDefaultZoomLevel
) {
return
}
;({ uiZoomLevel, browserDefaultZoomLevel } = next)
this.defaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel)
this.reapplyZoom()
})
}
onGuestRegistered(guest: Electron.WebContents, profileId: string | null): void {
const previous = this.guests.get(guest.id)
previous?.stopWatchingDomReady()
// Why dom-ready: once Orca's UI zoom has changed, Electron copies it onto the guest at some
// point after each commit; dom-ready always comes after that copy.
const onDomReady = (): void => this.applyZoom(guest.id)
guest.on('dom-ready', onDomReady)
this.guests.set(guest.id, {
guest,
profileId: profileId ?? 'default',
stopWatchingDomReady: () => guest.off('dom-ready', onDomReady),
colorScheme: previous?.colorScheme ?? null
})
this.applyColorScheme(guest.id)
this.applyZoom(guest.id)
}
onGuestRetired(guestId: number): void {
this.releaseColorScheme(guestId)
const tracked = this.guests.get(guestId)
if (tracked && !tracked.guest.isDestroyed()) {
tracked.stopWatchingDomReady()
}
this.guests.delete(guestId)
}
/**
* Why at commit: the new document's scripts read prefers-color-scheme only after this, while the
* page being left keeps its own scheme until it is replaced. Zoom is keyed by the committed host.
*/
onMainFrameNavigationCommitted(guestId: number): void {
this.applyColorScheme(guestId)
this.applyZoom(guestId)
}
private reapplyZoom(): void {
for (const guestId of this.guests.keys()) {
this.applyZoom(guestId)
}
}
getPageDisplay(guestId: number): BrowserPageSiteDisplay | null {
const tracked = this.guests.get(guestId)
if (!tracked) {
return null
}
const site = browserSiteDisplayKey(tracked.guest.getURL())
const saved = site ? getBrowserSiteDisplay(tracked.profileId, site) : {}
return {
site,
zoomLevel: saved.zoomLevel ?? this.defaultZoomLevel,
siteHasZoom: saved.zoomLevel !== undefined,
colorScheme: saved.colorScheme ?? null
}
}
zoomPage(guestId: number, direction: BrowserPageZoomDirection): BrowserPageSiteDisplay | null {
const display = this.getPageDisplay(guestId)
const profileId = this.guests.get(guestId)?.profileId
if (!display?.site || !profileId) {
return display
}
// Why from the saved level: Electron pushes Orca's UI zoom onto every webview, so the live
// level can be stale and stepping from it would save the wrong zoom.
const next = nextBrowserPageZoomLevel(display.zoomLevel, direction, this.defaultZoomLevel)
const zoomLevel = next === this.defaultZoomLevel ? undefined : next
if (zoomLevel !== (display.siteHasZoom ? display.zoomLevel : undefined)) {
updateBrowserSiteDisplay(profileId, display.site, { zoomLevel })
this.forEachSiteGuest(profileId, display.site, (siteGuestId) => this.applyZoom(siteGuestId))
}
return this.getPageDisplay(guestId)
}
setColorScheme(
guestId: number,
colorScheme: BrowserSiteColorScheme | null
): BrowserPageSiteDisplay | null {
const display = this.getPageDisplay(guestId)
const profileId = this.guests.get(guestId)?.profileId
if (!display?.site || !profileId || display.colorScheme === colorScheme) {
return display
}
updateBrowserSiteDisplay(profileId, display.site, { colorScheme: colorScheme ?? undefined })
this.forEachSiteGuest(profileId, display.site, (siteGuestId) =>
this.applyColorScheme(siteGuestId)
)
return this.getPageDisplay(guestId)
}
private forEachSiteGuest(
profileId: string,
site: string,
apply: (guestId: number) => void
): void {
for (const [guestId, tracked] of this.guests) {
if (
tracked.profileId === profileId &&
browserSiteDisplayKey(tracked.guest.getURL()) === site
) {
apply(guestId)
}
}
}
private applyZoom(guestId: number): void {
const tracked = this.guests.get(guestId)
if (!tracked || tracked.guest.isDestroyed()) {
return
}
const level = this.getPageDisplay(guestId)?.zoomLevel ?? this.defaultZoomLevel
// Why compare first: Chromium keeps zoom per host, so a no-op write would still reset a
// same-host tab that a different profile or the UI zoom push moved.
if (Math.abs(tracked.guest.getZoomLevel() - level) > ZOOM_EPSILON) {
tracked.guest.setZoomLevel(level)
}
}
private applyColorScheme(guestId: number): void {
const tracked = this.guests.get(guestId)
if (!tracked || tracked.guest.isDestroyed()) {
return
}
const colorScheme = this.getPageDisplay(guestId)?.colorScheme ?? undefined
if (!colorScheme && !tracked.colorScheme?.standing) {
return
}
const state = tracked.colorScheme ?? this.acquireColorScheme(tracked)
if (!state) {
return
}
state.standing = colorScheme !== undefined
// Why resend on every navigation: setEmulatedMedia replaces every emulated feature, so an
// agent's `set media` call silently drops the scheme and only a fresh write restores it.
tracked.guest.debugger
.sendCommand('Emulation.setEmulatedMedia', {
features: [{ name: 'prefers-color-scheme', value: colorScheme ?? '' }]
})
.catch((error: unknown) => {
console.warn('[browser-site-display] failed to set the page color scheme', {
guestWebContentsId: guestId,
error: errorMessage(error)
})
})
}
// Why a lease held for the guest's life: an agent's lease that attached first would otherwise
// detach on release and take the scheme with it.
private acquireColorScheme(tracked: TrackedGuest): TrackedGuest['colorScheme'] {
const { guest } = tracked
let lease: ElectronDebuggerLease
try {
lease = acquireElectronDebugger(guest)
} catch (error) {
// Why: attach fails while DevTools holds the page; the next navigation tries again.
console.warn('[browser-site-display] could not attach to set the page color scheme', {
guestWebContentsId: guest.id,
error: errorMessage(error)
})
return null
}
// Why: opening DevTools detaches us and clears the scheme; drop the lease so the next
// navigation attaches again.
const onDetach = (): void => this.releaseColorScheme(guest.id)
guest.debugger.on('detach', onDetach)
tracked.colorScheme = {
lease,
stopWatchingDetach: () => guest.debugger.off('detach', onDetach),
standing: false
}
return tracked.colorScheme
}
private releaseColorScheme(guestId: number): void {
const tracked = this.guests.get(guestId)
const state = tracked?.colorScheme
if (!tracked || !state) {
return
}
tracked.colorScheme = null
try {
state.stopWatchingDetach()
state.lease.release()
} catch {
// Why: best-effort — the guest may already be destroyed.
}
}
}
export const browserSiteDisplayController = new BrowserSiteDisplayController()
@@ -0,0 +1,48 @@
import {
isBrowserSiteColorScheme,
type BrowserSiteDisplay
} from '../../shared/browser-site-display'
import { normalizeBrowserPageZoomLevel } from '../../shared/browser-page-zoom'
import { createBrowserProfileSiteStore } from './browser-profile-site-store'
function parseSiteDisplay(value: unknown): BrowserSiteDisplay | null {
if (!value || typeof value !== 'object') {
return null
}
const display: BrowserSiteDisplay = {}
if ('zoomLevel' in value && typeof value.zoomLevel === 'number') {
display.zoomLevel = normalizeBrowserPageZoomLevel(value.zoomLevel)
}
if ('colorScheme' in value && isBrowserSiteColorScheme(value.colorScheme)) {
display.colorScheme = value.colorScheme
}
return Object.keys(display).length > 0 ? display : null
}
/** Browser profile id → site → its own zoom and color scheme. */
const store = createBrowserProfileSiteStore({
fileName: 'browser-site-display.json',
description: 'site zoom and appearance',
parseEntry: parseSiteDisplay
})
export function configureBrowserSiteDisplayStore(profileDirectory: string): void {
store.configure(profileDirectory)
}
export function getBrowserSiteDisplay(profileId: string, site: string): BrowserSiteDisplay {
return store.get(profileId, site) ?? {}
}
/** Undefined fields are forgotten; a site left with nothing is dropped. */
export function updateBrowserSiteDisplay(
profileId: string,
site: string,
update: BrowserSiteDisplay
): void {
store.set(profileId, site, parseSiteDisplay({ ...store.get(profileId, site), ...update }))
}
export function forgetBrowserSiteDisplay(profileId: string): void {
store.forget(profileId)
}
@@ -1,7 +1,3 @@
import { app } from 'electron'
import { mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write'
import {
isBrowserSitePermission,
isBrowserSitePermissionDecision,
@@ -9,27 +5,13 @@ import {
type BrowserSitePermissionDecision,
type BrowserSitePermissionEntry
} from '../../shared/browser-site-permission'
import { createBrowserProfileSiteStore } from './browser-profile-site-store'
export const BROWSER_SITE_PERMISSIONS_FILE_NAME = 'browser-site-permissions.json'
type SiteDecisions = BrowserSitePermissionEntry['decisions']
/** Browser profile id → origin → remembered answers. */
type StoredDecisions = Record<string, Record<string, SiteDecisions>>
let filePathOverride: string | null = null
let cached: { path: string; decisions: StoredDecisions } | null = null
// Why lazy: app.getPath('userData') throws before ready.
function storePath(): string {
return filePathOverride ?? join(app.getPath('userData'), BROWSER_SITE_PERMISSIONS_FILE_NAME)
}
/** Answers belong to the Orca profile whose browser profiles they name. */
export function configureBrowserSitePermissionStore(profileDirectory: string): void {
filePathOverride = join(profileDirectory, BROWSER_SITE_PERMISSIONS_FILE_NAME)
}
function parseSiteDecisions(value: unknown): SiteDecisions {
function parseSiteDecisions(value: unknown): SiteDecisions | null {
const decisions: SiteDecisions = {}
if (value && typeof value === 'object') {
for (const [permission, decision] of Object.entries(value)) {
@@ -38,69 +20,19 @@ function parseSiteDecisions(value: unknown): SiteDecisions {
}
}
}
return decisions
return Object.keys(decisions).length > 0 ? decisions : null
}
function parseStoredDecisions(raw: string): StoredDecisions {
const parsed: unknown = JSON.parse(raw)
const stored: StoredDecisions = {}
if (!parsed || typeof parsed !== 'object' || !('profiles' in parsed)) {
return stored
}
const { profiles } = parsed
if (!profiles || typeof profiles !== 'object') {
return stored
}
for (const [profileId, sites] of Object.entries(profiles)) {
if (!sites || typeof sites !== 'object') {
continue
}
const parsedSites: Record<string, SiteDecisions> = {}
for (const [origin, decisions] of Object.entries(sites)) {
const parsedDecisions = parseSiteDecisions(decisions)
if (Object.keys(parsedDecisions).length > 0) {
parsedSites[origin] = parsedDecisions
}
}
stored[profileId] = parsedSites
}
return stored
}
/** Browser profile id → origin → remembered answers. */
const store = createBrowserProfileSiteStore({
fileName: BROWSER_SITE_PERMISSIONS_FILE_NAME,
description: 'site permissions',
parseEntry: parseSiteDecisions
})
function load(): StoredDecisions {
let path: string
try {
path = storePath()
} catch {
return {}
}
if (cached?.path === path) {
return cached.decisions
}
let decisions: StoredDecisions = {}
try {
decisions = parseStoredDecisions(readFileSync(path, 'utf-8'))
} catch {
// Missing or unreadable: every site is asked again.
}
cached = { path, decisions }
return decisions
}
function persist(decisions: StoredDecisions): void {
try {
const path = storePath()
cached = { path, decisions }
mkdirSync(dirname(path), { recursive: true })
writeFileDurableSync(
durableWriteTempPath(path),
path,
`${JSON.stringify({ version: 1, profiles: decisions }, null, 2)}\n`
)
} catch (error) {
// Why: the answer still holds for this run; failing the page's request would punish the user.
console.error('[browser] Could not save site permissions:', error)
}
/** Answers belong to the Orca profile whose browser profiles they name. */
export function configureBrowserSitePermissionStore(profileDirectory: string): void {
store.configure(profileDirectory)
}
export function getBrowserSitePermissionDecision(
@@ -108,7 +40,7 @@ export function getBrowserSitePermissionDecision(
origin: string,
permission: BrowserSitePermission
): BrowserSitePermissionDecision | undefined {
return load()[profileId]?.[origin]?.[permission]
return store.get(profileId, origin)?.[permission]
}
/** Null forgets the answer, so the site is asked again. */
@@ -118,9 +50,7 @@ export function setBrowserSitePermissionDecisions(
permissions: readonly BrowserSitePermission[],
decision: BrowserSitePermissionDecision | null
): void {
const stored = load()
const sites = { ...stored[profileId] }
const decisions = { ...sites[origin] }
const decisions = { ...store.get(profileId, origin) }
for (const permission of permissions) {
if (decision) {
decisions[permission] = decision
@@ -128,25 +58,13 @@ export function setBrowserSitePermissionDecisions(
delete decisions[permission]
}
}
if (Object.keys(decisions).length > 0) {
sites[origin] = decisions
} else {
delete sites[origin]
}
persist({ ...stored, [profileId]: sites })
store.set(profileId, origin, Object.keys(decisions).length > 0 ? decisions : null)
}
export function listBrowserSitePermissions(profileId: string): BrowserSitePermissionEntry[] {
return Object.entries(load()[profileId] ?? {})
.map(([origin, decisions]) => ({ origin, decisions }))
.sort((a, b) => a.origin.localeCompare(b.origin))
return store.list(profileId).map(([origin, decisions]) => ({ origin, decisions }))
}
export function forgetBrowserSitePermissions(profileId: string): void {
const stored = load()
if (!(profileId in stored)) {
return
}
const { [profileId]: _forgotten, ...rest } = stored
persist(rest)
store.forget(profileId)
}
+51 -1
View File
@@ -1,5 +1,7 @@
import { ipcMain } from 'electron'
import { ipcMain, type IpcMainInvokeEvent } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { browserSiteDisplayController } from '../browser/browser-site-display-controller'
import { isBrowserPageZoomDirection } from '../../shared/browser-page-zoom'
import { publishBrowserClientPageMetadata } from '../browser/browser-client-page-metadata-transport'
import {
BrowserClientPageMetadataParams,
@@ -8,6 +10,10 @@ import {
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import type { BrowserViewportOverride } from '../../shared/browser-workspace-types'
import {
isBrowserSiteColorScheme,
type BrowserPageSiteDisplay
} from '../../shared/browser-site-display'
import {
isValidBrowserAnnotationViewportBridgeMarkers,
isValidBrowserAnnotationViewportBridgeToken,
@@ -22,6 +28,50 @@ export function registerBrowserGuestViewHandlers(): void {
ipcMain.removeHandler('browser:acceptDownload')
ipcMain.removeHandler('browser:cancelDownload')
ipcMain.removeHandler('browser:publishClientPageMetadata')
ipcMain.removeHandler('browser:getPageSiteDisplay')
ipcMain.removeHandler('browser:zoomPage')
ipcMain.removeHandler('browser:setPageSiteColorScheme')
// Why the page registry only: these act on a browser page, never on a document preview.
const pageGuestId = (event: IpcMainInvokeEvent, browserPageId: unknown): number | null =>
isTrustedBrowserRenderer(event.sender) && typeof browserPageId === 'string'
? browserManager.getGuestWebContentsId(browserPageId)
: null
ipcMain.handle(
'browser:getPageSiteDisplay',
(event, args: { browserPageId: string }): BrowserPageSiteDisplay | null => {
const guestId = pageGuestId(event, args?.browserPageId)
return guestId === null ? null : browserSiteDisplayController.getPageDisplay(guestId)
}
)
ipcMain.handle(
'browser:zoomPage',
(event, args: { browserPageId: string; direction: unknown }): BrowserPageSiteDisplay | null => {
const guestId = pageGuestId(event, args?.browserPageId)
const { direction } = args
if (guestId === null || !isBrowserPageZoomDirection(direction)) {
return null
}
return browserSiteDisplayController.zoomPage(guestId, direction)
}
)
ipcMain.handle(
'browser:setPageSiteColorScheme',
(
event,
args: { browserPageId: string; colorScheme: unknown }
): BrowserPageSiteDisplay | null => {
const guestId = pageGuestId(event, args?.browserPageId)
const { colorScheme } = args
if (guestId === null || (colorScheme !== null && !isBrowserSiteColorScheme(colorScheme))) {
return null
}
return browserSiteDisplayController.setColorScheme(guestId, colorScheme)
}
)
ipcMain.handle('browser:openDevTools', (event, args: { browserPageId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
@@ -130,6 +130,9 @@ const BROWSER_PAGE_CHANNELS = [
'browser:activeTabChanged',
'browser:openDevTools',
'browser:setViewportOverride',
'browser:getPageSiteDisplay',
'browser:zoomPage',
'browser:setPageSiteColorScheme',
'browser:publishClientPageMetadata',
'browser:cancelDownload',
'browser:session:listProfiles',
+2 -1
View File
@@ -772,9 +772,10 @@ describe('Store', () => {
it('normalizes default browser zoom UI writes', async () => {
const store = await createStore()
// 1.26 is about 130%, which snaps to Chrome's 125% preset.
store.updateUI({ browserDefaultZoomLevel: 1.26 })
expect(store.getUI().browserDefaultZoomLevel).toBe(1.5)
expect(1.2 ** (store.getUI().browserDefaultZoomLevel ?? 0)).toBeCloseTo(1.25)
})
it('encrypts the Kagi session link on disk and decrypts it on load', async () => {
@@ -8,7 +8,8 @@ import { setNodeDistMirrorSettingReader } from '../node-runtime/node-dist-mirror
import { handleBrowserGuestLogin } from '../browser/browser-http-auth-prompt'
import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog'
import { preservePreviousHangDetection } from '../hang-watchdog/previous-hang-detection'
import { browserCertificateTrustController } from '../browser/browser-manager'
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
import { browserSiteDisplayController } from '../browser/browser-site-display-controller'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getCanonicalUserDataPath } from '../persistence'
import { getAppEnvironment } from '../../shared/app-environment'
@@ -291,6 +292,8 @@ export async function initializeReadyFoundation(): Promise<void> {
return currentStore.getSshTargets().map((target) => target.id)
}
})
browserSiteDisplayController.followZoomSettings(store)
browserManager.setSiteDisplayController(browserSiteDisplayController)
try {
// Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write.
await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings())
@@ -76,6 +76,10 @@ vi.mock('../persistence', () => ({
return {}
}
onSettingsChanged() {}
getUI() {
return {}
}
onUIChanged() {}
getSshTargets() {
return []
}
@@ -87,6 +91,8 @@ vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({
store: {
getSettings: () => ({}),
onSettingsChanged: () => {},
getUI: () => ({}),
onUIChanged: () => {},
getSshTargets: () => []
}
})
@@ -119,7 +125,8 @@ vi.mock('../browser/browser-manager', () => ({
installCertificateRequestGuard: vi.fn(),
removeCertificateRequestGuard: vi.fn(),
notifyPermissionDenied: vi.fn(),
handleGuestWillDownload: vi.fn()
handleGuestWillDownload: vi.fn(),
setSiteDisplayController: vi.fn()
}
}))
vi.mock('../orca-profiles/profile-index-store', () => ({
+16
View File
@@ -1,4 +1,9 @@
import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge'
import type {
BrowserPageSiteDisplay,
BrowserSiteColorScheme
} from '../../shared/browser-site-display'
import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom'
import type {
BrowserIdentityModeSetResult,
BrowserIdentityModeStatus,
@@ -96,6 +101,17 @@ export type BrowserApi = {
browserPageId: string
override: BrowserViewportOverride | null
}) => Promise<boolean>
getPageSiteDisplay: (args: { browserPageId: string }) => Promise<BrowserPageSiteDisplay | null>
/** Zooms the page's site in its browser profile, or resets it to Default Zoom. */
zoomPage: (args: {
browserPageId: string
direction: BrowserPageZoomDirection
}) => Promise<BrowserPageSiteDisplay | null>
/** Null makes the site match Orca's theme again. */
setPageSiteColorScheme: (args: {
browserPageId: string
colorScheme: BrowserSiteColorScheme | null
}) => Promise<BrowserPageSiteDisplay | null>
reportViewportScrollState?: (args: {
browserPageId: string
state: BrowserViewportScrollState
@@ -1,5 +1,10 @@
import { ipcRenderer } from 'electron'
import type { BrowserViewportOverride } from '../../shared/browser-workspace-types'
import type {
BrowserPageSiteDisplay,
BrowserSiteColorScheme
} from '../../shared/browser-site-display'
import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom'
import type {
BrowserWebAuthnAccountRequest,
BrowserWebAuthnAccountResponse
@@ -74,6 +79,17 @@ export const browserGuestRegistrationAndDownloadsApi = {
browserPageId: string
override: BrowserViewportOverride | null
}): Promise<boolean> => ipcRenderer.invoke('browser:setViewportOverride', args),
getPageSiteDisplay: (args: { browserPageId: string }): Promise<BrowserPageSiteDisplay | null> =>
ipcRenderer.invoke('browser:getPageSiteDisplay', args),
zoomPage: (args: {
browserPageId: string
direction: BrowserPageZoomDirection
}): Promise<BrowserPageSiteDisplay | null> => ipcRenderer.invoke('browser:zoomPage', args),
setPageSiteColorScheme: (args: {
browserPageId: string
colorScheme: BrowserSiteColorScheme | null
}): Promise<BrowserPageSiteDisplay | null> =>
ipcRenderer.invoke('browser:setPageSiteColorScheme', args),
reportViewportScrollState: (args: {
browserPageId: string
state: {
@@ -54,6 +54,7 @@ describe('cookie-import Google disclosure footer', () => {
'browser toolbar overflow',
() => (
<BrowserToolbarMenuDropdown
browserPageId="page-1"
menuOpen
onMenuOpenChange={vi.fn()}
allProfiles={[]}
@@ -67,6 +68,9 @@ describe('cookie-import Google disclosure footer', () => {
onImportFromFile={vi.fn()}
viewportPresetId={null}
onApplyViewportPreset={vi.fn()}
siteDisplay={null}
onZoomPage={vi.fn()}
onSetSiteColorScheme={vi.fn()}
overflow={{
triggerRef: createRef<HTMLButtonElement>(),
tools: [],
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
BrowserCertificateFailure,
@@ -14,7 +14,11 @@ import type {
BrowserHistoryNavigateCommand,
BrowserPageCommandTarget
} from '../../../../shared/browser-page-command-target'
import type { BrowserPageZoomCommand } from '../../../../shared/browser-page-zoom'
import type { BrowserPageSiteDisplay } from '../../../../shared/browser-site-display'
import {
normalizeBrowserPageZoomLevel,
type BrowserPageZoomCommand
} from '../../../../shared/browser-page-zoom'
const toastMocks = vi.hoisted(() => ({
loading: vi.fn(),
@@ -54,6 +58,7 @@ let reloadRequests = paneChannel<BrowserPageCommandTarget>()
let hardReloadRequests = paneChannel<BrowserPageCommandTarget>()
let zoomRequests = paneChannel<BrowserPageZoomCommand>()
let openDevTools = vi.fn(async () => true)
let zoomPage = vi.fn(async (): Promise<BrowserPageSiteDisplay | null> => null)
let proceedCertificate = vi.fn(async () => ({ ok: true as const }))
let openUrl = vi.fn(async () => {})
let writeClipboardText = vi.fn(async () => {})
@@ -69,6 +74,12 @@ beforeEach(() => {
hardReloadRequests = paneChannel()
zoomRequests = paneChannel()
openDevTools = vi.fn(async () => true)
zoomPage = vi.fn(async () => ({
site: 'example.com',
zoomLevel: normalizeBrowserPageZoomLevel(1),
siteHasZoom: true,
colorScheme: null
}))
proceedCertificate = vi.fn(async () => ({ ok: true as const }))
openUrl = vi.fn(async () => {})
writeClipboardText = vi.fn(async () => {})
@@ -78,7 +89,8 @@ beforeEach(() => {
onContextMenuDismissed: contextMenuDismissed.subscribe,
onPermissionDenied: permissionDenied.subscribe,
openDevTools,
proceedCertificate
proceedCertificate,
zoomPage
},
ui: {
onFindInBrowserPage: (_source: unknown, callback: () => void) =>
@@ -169,21 +181,18 @@ describe('ClientHostedBrowserPagePane chrome parity', () => {
expect(webview.goForward).toHaveBeenCalledTimes(1)
})
it('zooms the retained guest and shows the level while the chord lands', () => {
const { webview } = renderPane()
it('zooms its own page and shows the level main settled on', async () => {
renderPane()
// The HUD is aria-hidden until a zoom lands, so it has to be queried past that gate.
const indicator = screen.getByRole('status', { hidden: true })
expect(indicator.getAttribute('aria-hidden')).toBe('true')
act(() => zoomRequests.emit({ browserPageId: 'page-b', direction: 'in' }))
expect(webview.setZoomLevel).not.toHaveBeenCalled()
act(() => zoomRequests.emit({ browserPageId: 'page-a', direction: 'in' }))
expect(webview.setZoomLevel).toHaveBeenCalledTimes(1)
expect(webview.setZoomLevel.mock.calls[0]![0]).toBeGreaterThan(0)
expect(indicator.getAttribute('aria-hidden')).toBe('false')
expect(indicator.textContent).not.toBe('100%')
expect(zoomPage.mock.calls).toEqual([[{ browserPageId: 'page-a', direction: 'in' }]])
await waitFor(() => expect(indicator.getAttribute('aria-hidden')).toBe('false'))
expect(indicator.textContent).toBe('125%')
})
it('offers Proceed Anyway on a certificate failure and sends it to the local session', () => {
@@ -131,7 +131,7 @@ export function ClientHostedBrowserPagePane({
addressBarInputRef,
startAddressBarFocusGrab
})
const zoom = useBrowserPageZoomFeedback(browserTab.id)
const zoom = useBrowserPageZoomFeedback()
const reload = useBrowserPageReloadActions({
browserTab,
webviewRef,
@@ -152,8 +152,6 @@ export function ClientHostedBrowserPagePane({
...shortcutOwner,
isActiveRef,
webviewRef,
paneZoomLevelRef: zoom.paneZoomLevelRef,
setBrowserDefaultZoomLevel: zoom.setBrowserDefaultZoomLevel,
showBrowserZoomFeedback: zoom.showBrowserZoomFeedback,
reloadWebviewOrRecoverGuest: reload.reloadWebviewOrRecoverGuest
})
@@ -320,8 +318,7 @@ export function ClientHostedBrowserPagePane({
// browserTab.url here named the previous page and offered it an HTTPS retry it never needed.
const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? toDisplayUrl(browserTab.url)
const browserZoomIndicatorState = getBrowserPageZoomIndicatorState({
feedbackVisible: zoom.browserZoomFeedbackVisible,
isDefaultZoom: zoom.browserZoomPercent === zoom.browserDefaultZoomPercent
feedbackVisible: zoom.browserZoomFeedbackVisible
})
const markup = useClientHostedBrowserMarkup({
@@ -339,7 +336,10 @@ export function ClientHostedBrowserPagePane({
const loading = !restoredPageUnrecovered && (placement === null || browserTab.loading)
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col bg-background">
<div
className="relative flex h-full min-h-0 flex-1 flex-col bg-background"
data-browser-page-pane-id={browserTab.id}
>
{/* IPC-driven context menu in a Portal so position:fixed escapes ancestor transform/backdrop-filter containing blocks. */}
<BrowserPageContextMenu
browserPageId={browserTab.id}
@@ -1,10 +1,16 @@
import { useLayoutEffect, useState } from 'react'
import { useEffect, useLayoutEffect, useState } from 'react'
import { toast } from 'sonner'
import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { shouldShowBrowserImportHint } from './browser-import-hint-visibility'
import type { BrowserViewportPresetId } from '../../../../../shared/browser-workspace-types'
import type {
BrowserPageSiteDisplay,
BrowserSiteColorScheme
} from '../../../../../shared/browser-site-display'
import type { BrowserPageZoomDirection } from '../../../../../shared/browser-page-zoom'
import { addBrowserPageZoomEventListener } from '../host-guest/browser-page-zoom'
import {
browserViewportPresetToOverride,
getBrowserViewportPreset
@@ -72,8 +78,38 @@ export function BrowserToolbarMenu({
undefined
)
const [menuOpen, setMenuOpen] = useState(false)
const [siteDisplay, setSiteDisplay] = useState<BrowserPageSiteDisplay | null>(null)
const mountedRef = useMountedRef()
const showSiteDisplay = (request: Promise<BrowserPageSiteDisplay | null>): void => {
void request.then((display) => {
if (mountedRef.current) {
setSiteDisplay(display)
}
})
}
const zoomPage = (direction: BrowserPageZoomDirection): void =>
showSiteDisplay(window.api.browser.zoomPage({ browserPageId, direction }))
const setSiteColorScheme = (colorScheme: BrowserSiteColorScheme | null): void =>
showSiteDisplay(window.api.browser.setPageSiteColorScheme({ browserPageId, colorScheme }))
useEffect(() => {
if (!menuOpen) {
return
}
// Why: Cmd/Ctrl +/-/0 pressed inside the open menu zoom the page through the pane, so read
// the result back; main answers invokes in order, so this read sees that zoom.
return addBrowserPageZoomEventListener((command) => {
if (command.browserPageId === browserPageId) {
void window.api.browser.getPageSiteDisplay({ browserPageId }).then((display) => {
if (mountedRef.current) {
setSiteDisplay(display)
}
})
}
})
}, [browserPageId, menuOpen, mountedRef])
useLayoutEffect(() => {
// Why: step 3 falls back to the Import Cookies row inside this menu, so open
// it only when the tour reaches that step and the hint button is hidden.
@@ -84,6 +120,10 @@ export function BrowserToolbarMenu({
if (shouldForceMenuOpen && !open) {
return
}
if (open) {
// Why on open: the site changes with every navigation and keyboard zoom, so a cached value goes stale.
showSiteDisplay(window.api.browser.getPageSiteDisplay({ browserPageId }))
}
setMenuOpen(open)
}
@@ -232,6 +272,7 @@ export function BrowserToolbarMenu({
return (
<>
<BrowserToolbarMenuDropdown
browserPageId={browserPageId}
menuOpen={menuOpen}
onMenuOpenChange={handleMenuOpenChange}
allProfiles={allProfiles}
@@ -247,6 +288,9 @@ export function BrowserToolbarMenu({
onImportFromFile={() => void handleImportFromFile()}
viewportPresetId={viewportPresetId}
onApplyViewportPreset={applyViewportPreset}
siteDisplay={siteDisplay}
onZoomPage={zoomPage}
onSetSiteColorScheme={setSiteColorScheme}
onOpenSourceFile={onOpenSourceFile}
overflow={overflow}
/>
@@ -150,7 +150,7 @@ export function BrowserPagePane({
const grabElementShortcut = useShortcutLabel('browser.grabElement')
const slotViewport = useBrowserPageSlotViewport(workspaceId)
const zoom = useBrowserPageZoomFeedback(browserTab.id)
const zoom = useBrowserPageZoomFeedback()
const { resourceNotice, setResourceNotice } = useBrowserPageResourceNotices(browserTab.id)
const guestFocus = useWebviewGuestFocus(webviewRef)
const {
@@ -226,8 +226,6 @@ export function BrowserPagePane({
setFindOpen,
focusAddressBarNow,
focusWebviewNow,
paneZoomLevelRef: zoom.paneZoomLevelRef,
setBrowserZoomPercent: zoom.setBrowserZoomPercent,
pendingAnnotationPayload: grabAnnotations.pendingAnnotationPayload,
inputLocked,
faviconUrl: browserTab.faviconUrl,
@@ -277,8 +275,6 @@ export function BrowserPagePane({
isActiveRef,
markupIsActive: markup.isActive,
webviewRef,
paneZoomLevelRef: zoom.paneZoomLevelRef,
setBrowserDefaultZoomLevel: zoom.setBrowserDefaultZoomLevel,
showBrowserZoomFeedback: zoom.showBrowserZoomFeedback,
reloadWebviewOrRecoverGuest: reload.reloadWebviewOrRecoverGuest,
startGrabIntent: grabAnnotations.startGrabIntent,
@@ -298,8 +294,7 @@ export function BrowserPagePane({
const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl)
const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab
const browserZoomIndicatorState = getBrowserPageZoomIndicatorState({
feedbackVisible: zoom.browserZoomFeedbackVisible,
isDefaultZoom: zoom.browserZoomPercent === zoom.browserDefaultZoomPercent
feedbackVisible: zoom.browserZoomFeedbackVisible
})
useEffect(() => {
@@ -21,6 +21,11 @@ import type {
BrowserSessionProfile,
BrowserViewportPresetId
} from '../../../../../shared/browser-workspace-types'
import type {
BrowserPageSiteDisplay,
BrowserSiteColorScheme
} from '../../../../../shared/browser-site-display'
import type { BrowserPageZoomDirection } from '../../../../../shared/browser-page-zoom'
type DetectedBrowserEntry = {
family: string
@@ -34,8 +39,13 @@ import {
BrowserChromeFoldedMenuItems,
type BrowserChromeOverflowMenuProps
} from './browser-chrome-folded-tools'
import {
BrowserToolbarAppearanceSub,
BrowserToolbarZoomRow
} from './browser-toolbar-page-display-items'
type BrowserToolbarMenuDropdownProps = {
browserPageId: string
menuOpen: boolean
onMenuOpenChange: (open: boolean) => void
allProfiles: BrowserSessionProfile[]
@@ -49,11 +59,15 @@ type BrowserToolbarMenuDropdownProps = {
onImportFromFile: () => void
viewportPresetId: BrowserViewportPresetId | null
onApplyViewportPreset: (nextId: BrowserViewportPresetId | null) => void
siteDisplay: BrowserPageSiteDisplay | null
onZoomPage: (direction: BrowserPageZoomDirection) => void
onSetSiteColorScheme: (colorScheme: BrowserSiteColorScheme | null) => void
onOpenSourceFile?: (() => void) | null
overflow: BrowserChromeOverflowMenuProps
}
export function BrowserToolbarMenuDropdown({
browserPageId,
menuOpen,
onMenuOpenChange,
allProfiles,
@@ -67,6 +81,9 @@ export function BrowserToolbarMenuDropdown({
onImportFromFile,
viewportPresetId,
onApplyViewportPreset,
siteDisplay,
onZoomPage,
onSetSiteColorScheme,
onOpenSourceFile = null,
overflow
}: BrowserToolbarMenuDropdownProps): React.JSX.Element {
@@ -89,6 +106,7 @@ export function BrowserToolbarMenuDropdown({
<DropdownMenuContent
align="end"
className="w-56"
data-browser-page-menu-id={browserPageId}
onCloseAutoFocus={overflow.onMenuCloseAutoFocus}
>
<BrowserChromeFoldedMenuItems
@@ -160,7 +178,7 @@ export function BrowserToolbarMenuDropdown({
)}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuSubContent data-browser-page-menu-id={browserPageId}>
<BrowserCookieImportMachineNotice />
{detectedBrowsers.map((browser) =>
browser.profiles.length > 1 ? (
@@ -173,7 +191,7 @@ export function BrowserToolbarMenuDropdown({
)}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuSubContent data-browser-page-menu-id={browserPageId}>
{browser.profiles.map((profile) => (
<DropdownMenuItem
key={profile.directory}
@@ -212,6 +230,12 @@ export function BrowserToolbarMenuDropdown({
<DropdownMenuSeparator />
<BrowserToolbarZoomRow display={siteDisplay} onZoom={onZoomPage} />
<BrowserToolbarAppearanceSub
browserPageId={browserPageId}
display={siteDisplay}
onSetColorScheme={onSetSiteColorScheme}
/>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Monitor className="mr-2 size-3.5" />
@@ -221,7 +245,7 @@ export function BrowserToolbarMenuDropdown({
)}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuSubContent data-browser-page-menu-id={browserPageId}>
{/* Why: Viewport is a "pick one of N" control, so use a radio group
for proper a11y semantics (role="menuitemradio", aria-checked).
The "Default" option represents a null preset (no override),
@@ -0,0 +1,164 @@
import { Minus, Plus, RotateCcw, SunMoon, ZoomIn } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import {
browserPageZoomLevelToPercent,
nextBrowserPageZoomLevel,
type BrowserPageZoomDirection
} from '../../../../../shared/browser-page-zoom'
import {
isBrowserSiteColorScheme,
type BrowserPageSiteDisplay,
type BrowserSiteColorScheme
} from '../../../../../shared/browser-site-display'
const ZOOM_KEY_DIRECTIONS: Record<string, BrowserPageZoomDirection> = {
'-': 'out',
'=': 'in',
'+': 'in',
'0': 'reset'
}
export function BrowserToolbarZoomRow({
display,
onZoom
}: {
display: BrowserPageSiteDisplay | null
onZoom: (direction: BrowserPageZoomDirection) => void
}): React.JSX.Element {
const zoomLevel = display?.zoomLevel ?? 0
const percent = browserPageZoomLevelToPercent(zoomLevel)
return (
<DropdownMenuItem
disabled={!display?.site}
aria-label={translate(
'auto.components.browser.pane.BrowserToolbarMenu.zoomPercent',
'Zoom {{value0}}%',
{ value0: percent }
)}
// Why: keep the menu open so zoom can be stepped more than once.
onSelect={(event) => event.preventDefault()}
onKeyDown={(event) => {
const direction = ZOOM_KEY_DIRECTIONS[event.key]
if (direction) {
event.preventDefault()
onZoom(direction)
}
}}
>
<ZoomIn className="mr-2 size-3.5" />
{translate('auto.components.browser.pane.BrowserToolbarMenu.zoom', 'Zoom')}
<span className="ml-auto flex items-center">
<Button
variant="ghost"
size="icon-xs"
tabIndex={-1}
disabled={nextBrowserPageZoomLevel(zoomLevel, 'out') === zoomLevel}
aria-label={translate(
'auto.components.browser.pane.BrowserToolbarMenu.zoomOut',
'Zoom out'
)}
onClick={() => onZoom('out')}
>
<Minus />
</Button>
<Button
variant="ghost"
size="xs"
tabIndex={-1}
aria-label={translate(
'auto.components.browser.pane.BrowserToolbarMenu.resetZoom',
'Reset zoom'
)}
onClick={() => onZoom('reset')}
>
<span className="min-w-8 tabular-nums">{percent}%</span>
</Button>
<Button
variant="ghost"
size="icon-xs"
tabIndex={-1}
disabled={nextBrowserPageZoomLevel(zoomLevel, 'in') === zoomLevel}
aria-label={translate(
'auto.components.browser.pane.BrowserToolbarMenu.zoomIn',
'Zoom in'
)}
onClick={() => onZoom('in')}
>
<Plus />
</Button>
<Button
variant="ghost"
size="icon-xs"
tabIndex={-1}
disabled={!display?.siteHasZoom}
aria-label={translate(
'auto.components.browser.pane.BrowserToolbarMenu.resetZoom',
'Reset zoom'
)}
onClick={() => onZoom('reset')}
>
<RotateCcw />
</Button>
</span>
</DropdownMenuItem>
)
}
const MATCH_ORCA_VALUE = 'orca'
export function BrowserToolbarAppearanceSub({
browserPageId,
display,
onSetColorScheme
}: {
browserPageId: string
display: BrowserPageSiteDisplay | null
onSetColorScheme: (colorScheme: BrowserSiteColorScheme | null) => void
}): React.JSX.Element {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!display?.site}>
<SunMoon className="mr-2 size-3.5" />
{translate('auto.components.browser.pane.BrowserToolbarMenu.appearance', 'Appearance')}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent data-browser-page-menu-id={browserPageId}>
<DropdownMenuRadioGroup
value={display?.colorScheme ?? MATCH_ORCA_VALUE}
onValueChange={(value) =>
onSetColorScheme(isBrowserSiteColorScheme(value) ? value : null)
}
>
<DropdownMenuRadioItem value={MATCH_ORCA_VALUE}>
{translate(
'auto.components.browser.pane.BrowserToolbarMenu.appearanceMatchOrca',
'Match Orca'
)}
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem value="light">
{translate(
'auto.components.browser.pane.BrowserToolbarMenu.appearanceLight',
'Light'
)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
{translate('auto.components.browser.pane.BrowserToolbarMenu.appearanceDark', 'Dark')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)
}
@@ -39,11 +39,9 @@ export type AttachBrowserPageWebviewArgs = {
activeLoadFailureRef: MutableRefObject<BrowserLoadError | null>
recoveryNavigationValidationRef: MutableRefObject<BrowserPageRecoveryNavigationValidation | null>
keepAddressBarFocusRef: MutableRefObject<boolean>
paneZoomLevelRef: MutableRefObject<number>
viewportPresetIdRef: MutableRefObject<BrowserViewportPresetId | null>
onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void>
setGuestRecoveryGeneration: Dispatch<SetStateAction<number>>
setBrowserZoomPercent: Dispatch<SetStateAction<number>>
focusAddressBarNow: () => boolean
syncNavigationState: (webview: Electron.WebviewTag) => void
syncBrowserAnnotationViewportBridge: () => void
@@ -42,11 +42,9 @@ export function bindBrowserPageWebviewListeners({
activeLoadFailureRef,
recoveryNavigationValidationRef,
keepAddressBarFocusRef,
paneZoomLevelRef,
viewportPresetIdRef,
onUpdatePageStateRef,
setGuestRecoveryGeneration,
setBrowserZoomPercent,
focusAddressBarNow,
syncNavigationState,
syncBrowserAnnotationViewportBridge,
@@ -80,11 +78,9 @@ export function bindBrowserPageWebviewListeners({
activeLoadFailureRef,
recoveryNavigationValidationRef,
keepAddressBarFocusRef,
paneZoomLevelRef,
viewportPresetIdRef,
onUpdatePageStateRef,
setGuestRecoveryGeneration,
setBrowserZoomPercent,
focusAddressBarNow,
syncNavigationState,
syncBrowserAnnotationViewportBridge
@@ -39,11 +39,9 @@ function createPage(id: string) {
activeLoadFailureRef: ref(null),
recoveryNavigationValidationRef: ref(null),
keepAddressBarFocusRef: ref(false),
paneZoomLevelRef: ref(0),
viewportPresetIdRef: ref(null),
onUpdatePageStateRef: ref(vi.fn()),
setGuestRecoveryGeneration: setGeneration,
setBrowserZoomPercent: vi.fn(),
focusAddressBarNow: () => false,
syncNavigationState: vi.fn(),
syncBrowserAnnotationViewportBridge: vi.fn()
@@ -87,11 +87,9 @@ function createSession(
activeLoadFailureRef: loadFailure,
recoveryNavigationValidationRef: ref(null),
keepAddressBarFocusRef: ref(false),
paneZoomLevelRef: ref(0),
viewportPresetIdRef: ref(null),
onUpdatePageStateRef: ref(update),
setGuestRecoveryGeneration: vi.fn(),
setBrowserZoomPercent: vi.fn(),
focusAddressBarNow: () => false,
syncNavigationState: vi.fn(),
syncBrowserAnnotationViewportBridge: sync
@@ -14,7 +14,6 @@ import {
createBrowserPageGuestRecovery,
type BrowserPageGuestRecovery
} from './browser-page-guest-recovery'
import { browserPageZoomLevelToPercent, setBrowserPageZoomLevel } from './browser-page-zoom'
import {
registeredWebContentsIds,
replacePersistentWebview,
@@ -40,11 +39,9 @@ export type BrowserPageWebviewGuestSessionArgs = {
activeLoadFailureRef: MutableRefObject<BrowserLoadError | null>
recoveryNavigationValidationRef: MutableRefObject<BrowserPageRecoveryNavigationValidation | null>
keepAddressBarFocusRef: MutableRefObject<boolean>
paneZoomLevelRef: MutableRefObject<number>
viewportPresetIdRef: MutableRefObject<BrowserViewportPresetId | null>
onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void>
setGuestRecoveryGeneration: Dispatch<SetStateAction<number>>
setBrowserZoomPercent: Dispatch<SetStateAction<number>>
focusAddressBarNow: () => boolean
syncNavigationState: (webview: Electron.WebviewTag) => void
syncBrowserAnnotationViewportBridge: () => void
@@ -71,11 +68,9 @@ export function createBrowserPageWebviewGuestSession({
activeLoadFailureRef,
recoveryNavigationValidationRef,
keepAddressBarFocusRef,
paneZoomLevelRef,
viewportPresetIdRef,
onUpdatePageStateRef,
setGuestRecoveryGeneration,
setBrowserZoomPercent,
focusAddressBarNow,
syncNavigationState,
syncBrowserAnnotationViewportBridge
@@ -259,13 +254,6 @@ export function createBrowserPageWebviewGuestSession({
if (!queuedAnnotationViewportBridgeSync) {
syncBrowserAnnotationViewportBridge()
}
// Why: Chromium restores per-origin zoom on reload/navigation, so reassert THIS pane's level after
// every guest load. Uses the pane-local level, not the shared setting, so reloading one tab never
// adopts a zoom the user applied to a different tab.
const appliedLevel = setBrowserPageZoomLevel(webview, paneZoomLevelRef.current)
if (appliedLevel !== null) {
setBrowserZoomPercent(browserPageZoomLevelToPercent(appliedLevel))
}
// Why: CDP viewport overrides are scoped to the debugger session and don't survive cross-origin nav, so reapply (idempotently) on dom-ready.
const presetId = viewportPresetIdRef.current
const preset = getBrowserViewportPreset(presetId)
@@ -1,330 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyBrowserPageZoom,
browserPageZoomLevelToPercent,
forgetExplicitBrowserPageZoomLevel,
getBrowserPageZoomIndicatorState,
getExplicitBrowserPageZoomLevel,
nextBrowserPageZoomLevel,
normalizeBrowserPageZoomLevel,
rememberExplicitBrowserPageZoomLevel,
setBrowserPageZoomLevel
} 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('normalizeBrowserPageZoomLevel', () => {
it('rounds to supported steps and clamps to Electron zoom bounds', () => {
expect(normalizeBrowserPageZoomLevel(1.24)).toBe(1)
expect(normalizeBrowserPageZoomLevel(1.26)).toBe(1.5)
expect(normalizeBrowserPageZoomLevel(10)).toBe(5)
expect(normalizeBrowserPageZoomLevel(-10)).toBe(-3)
expect(normalizeBrowserPageZoomLevel(Number.NaN)).toBe(0)
})
})
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)
})
it('resets to the configured default zoom level', () => {
expect(nextBrowserPageZoomLevel(3, 'reset', 1)).toBe(1)
expect(nextBrowserPageZoomLevel(3, 'reset', 1.26)).toBe(1.5)
})
})
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('resets the webview to the configured default zoom level', () => {
const webview = {
getZoomLevel: vi.fn(() => 2),
setZoomLevel: vi.fn()
}
expect(applyBrowserPageZoom(webview, 'reset', 1)).toBe(1)
expect(webview.setZoomLevel).toHaveBeenCalledWith(1)
})
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()
})
})
describe('setBrowserPageZoomLevel', () => {
it('normalizes and applies an explicit zoom level', () => {
const webview = {
getZoomLevel: vi.fn(() => 0),
setZoomLevel: vi.fn()
}
expect(setBrowserPageZoomLevel(webview, 1.26)).toBe(1.5)
expect(webview.setZoomLevel).toHaveBeenCalledWith(1.5)
})
it('restores the configured level when Chromium carries zoom across reloads', () => {
let live = 0.5
const webview = {
getZoomLevel: vi.fn(() => live),
setZoomLevel: vi.fn((level: number) => {
live = level
})
}
expect(setBrowserPageZoomLevel(webview, 0)).toBe(0)
expect(webview.setZoomLevel).toHaveBeenNthCalledWith(1, 0)
// Chromium hands the stale level back again on the next load.
live = 0.5
expect(setBrowserPageZoomLevel(webview, 0)).toBe(0)
expect(webview.setZoomLevel).toHaveBeenNthCalledWith(2, 0)
})
// Why: HostZoomMap is keyed by host per partition, so even a no-op write
// overwrites the host-wide zoom a sibling tab on the same hostname set.
it('does not write when the webview already holds the level', () => {
const webview = {
getZoomLevel: vi.fn(() => 0),
setZoomLevel: vi.fn()
}
expect(setBrowserPageZoomLevel(webview, 0)).toBe(0)
expect(webview.setZoomLevel).not.toHaveBeenCalled()
})
it('treats an unnormalized held level as already applied', () => {
const webview = {
getZoomLevel: vi.fn(() => 1.26),
setZoomLevel: vi.fn()
}
expect(setBrowserPageZoomLevel(webview, 1.5)).toBe(1.5)
expect(webview.setZoomLevel).not.toHaveBeenCalled()
})
})
// A guest webview outlives its React pane (worktree switch, Settings visit), so
// the level the USER applied has to outlive the pane too — otherwise the pane
// re-seeds from the shared Settings default and hijacks an already-zoomed tab.
describe('explicit pane zoom levels', () => {
it('reports null until the user zooms the tab', () => {
forgetExplicitBrowserPageZoomLevel('page-1')
expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull()
})
it('survives a pane remount so a later Settings default cannot hijack the tab', () => {
forgetExplicitBrowserPageZoomLevel('page-1')
rememberExplicitBrowserPageZoomLevel('page-1', 1.5)
// Remount: the pane seeds from the explicit level, not the shared default.
const settingsDefault = 0
expect(getExplicitBrowserPageZoomLevel('page-1') ?? settingsDefault).toBe(1.5)
})
it('is dropped when the guest is destroyed so a reused id cannot inherit it', () => {
rememberExplicitBrowserPageZoomLevel('page-1', 1.5)
forgetExplicitBrowserPageZoomLevel('page-1')
expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull()
})
it('keeps tabs independent', () => {
forgetExplicitBrowserPageZoomLevel('page-2')
rememberExplicitBrowserPageZoomLevel('page-1', 1.5)
expect(getExplicitBrowserPageZoomLevel('page-2')).toBeNull()
})
})
/**
* Models BrowserPagePane's zoom wiring: each pane keeps its own level, dom-ready reasserts
* that level, and zooming also writes the shared `browserDefaultZoomLevel` setting.
*/
describe('browser pane zoom across reloads', () => {
function createPane(sharedSetting: { level: number }) {
// Chromium remembers zoom per origin and replays it on reload.
const originZoom = new Map<string, number>()
let url = 'https://a.example'
let live = 0
const webview = {
getZoomLevel: () => live,
setZoomLevel: (level: number) => {
live = level
originZoom.set(url, level)
}
}
let paneLevel = sharedSetting.level
webview.setZoomLevel(paneLevel)
return {
get level() {
return live
},
zoom(direction: 'in' | 'out' | 'reset') {
const next = applyBrowserPageZoom(webview, direction)
if (next !== null) {
paneLevel = next
sharedSetting.level = next
}
},
load(nextUrl = url) {
url = nextUrl
live = originZoom.get(url) ?? 0
setBrowserPageZoomLevel(webview, paneLevel)
}
}
}
it('reasserts the pane level after normal and hard reloads', () => {
const setting = { level: 0 }
const pane = createPane(setting)
// Chromium hands back a stale 150% for this origin on reload.
pane.load()
expect(pane.level).toBe(0)
pane.load()
expect(pane.level).toBe(0)
})
it('keeps an explicit non-default configured zoom across a reload', () => {
const setting = { level: 1.5 }
const pane = createPane(setting)
expect(pane.level).toBe(1.5)
pane.load()
expect(pane.level).toBe(1.5)
})
it('resets to 100% on reset even after zooming moved the shared setting', () => {
const setting = { level: 0 }
const pane = createPane(setting)
pane.zoom('in')
pane.zoom('in')
expect(pane.level).toBe(1)
expect(setting.level).toBe(1)
// Regression: resetting toward the shared setting would be a fixed point and never move.
pane.zoom('reset')
expect(pane.level).toBe(0)
})
it('does not adopt another tab zoom when reloading an untouched tab', () => {
const setting = { level: 0 }
const tabA = createPane(setting)
const tabB = createPane(setting)
tabB.zoom('in')
tabB.zoom('in')
expect(tabB.level).toBe(1)
expect(setting.level).toBe(1)
expect(tabA.level).toBe(0)
// Regression: reasserting the shared setting would silently zoom tab A to tab B's level.
tabA.load()
expect(tabA.level).toBe(0)
})
it('keeps a zoomed tab at its own level across reload and cross-origin navigation', () => {
const setting = { level: 0 }
const pane = createPane(setting)
pane.zoom('in')
expect(pane.level).toBe(0.5)
pane.load()
expect(pane.level).toBe(0.5)
pane.load('https://b.example')
expect(pane.level).toBe(0.5)
})
})
/**
* Chromium's HostZoomMap is keyed by HOST per partition, so two tabs on one
* hostname share live zoom and a reassert by either moves both. Suppressing the
* reassert to protect the sibling is exactly what #10800 fixed (an
* externally-changed level must snap back on reload), so the two requirements
* are the same write seen from opposite sides. What IS in our control is not
* emitting a redundant host-wide write.
*/
describe('browser panes sharing one hostname', () => {
it('skips the host-wide write when the pane already holds its level', () => {
let hostLevel = 0
const webviewFor = (): { getZoomLevel: () => number; setZoomLevel: (n: number) => void } => ({
getZoomLevel: () => hostLevel,
setZoomLevel: (level: number) => {
hostLevel = level
}
})
const tabA = webviewFor()
const tabB = webviewFor()
expect(applyBrowserPageZoom(tabA, 'in')).toBe(0.5)
// Tab B reasserts the level the host already carries: no write at all.
setBrowserPageZoomLevel(tabB, 0.5)
expect(hostLevel).toBe(0.5)
})
})
describe('getBrowserPageZoomIndicatorState', () => {
it('shows browser zoom percent only while feedback is active', () => {
expect(
getBrowserPageZoomIndicatorState({ feedbackVisible: true, isDefaultZoom: false })
).toEqual({
ariaHidden: false,
opacityClassName: 'opacity-100'
})
expect(
getBrowserPageZoomIndicatorState({ feedbackVisible: false, isDefaultZoom: false })
).toEqual({
ariaHidden: true,
opacityClassName: 'opacity-0'
})
})
})
@@ -1,20 +1,6 @@
import {
DEFAULT_BROWSER_PAGE_ZOOM_LEVEL,
nextBrowserPageZoomLevel,
normalizeBrowserPageZoomLevel,
type BrowserPageZoomCommand,
type BrowserPageZoomDirection
} from '../../../../../shared/browser-page-zoom'
import type { BrowserPageZoomCommand } from '../../../../../shared/browser-page-zoom'
export {
BROWSER_PAGE_ZOOM_LEVELS,
DEFAULT_BROWSER_PAGE_ZOOM_LEVEL,
browserPageZoomLevelToPercent,
nextBrowserPageZoomLevel,
normalizeBrowserPageZoomLevel,
type BrowserPageZoomCommand,
type BrowserPageZoomDirection
} from '../../../../../shared/browser-page-zoom'
export type { BrowserPageZoomCommand } from '../../../../../shared/browser-page-zoom'
export const ORCA_BROWSER_PAGE_ZOOM_EVENT = 'orca:browser-page-zoom'
@@ -23,79 +9,11 @@ export type BrowserPageZoomIndicatorState = {
opacityClassName: 'opacity-100' | 'opacity-0'
}
export type BrowserPageZoomIndicatorInput = {
feedbackVisible: boolean
isDefaultZoom: boolean
}
type BrowserPageZoomWebview = {
getZoomLevel: () => number
setZoomLevel: (level: number) => void
isDestroyed?: () => boolean
}
export function applyBrowserPageZoom(
webview: BrowserPageZoomWebview | null | undefined,
direction: BrowserPageZoomDirection,
resetLevel: number = DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
): number | null {
try {
if (!webview || webview.isDestroyed?.()) {
return null
}
const next = nextBrowserPageZoomLevel(webview.getZoomLevel(), direction, resetLevel)
webview.setZoomLevel(next)
return next
} catch {
return null
}
}
export function setBrowserPageZoomLevel(
webview: BrowserPageZoomWebview | null | undefined,
level: number
): number | null {
try {
if (!webview || webview.isDestroyed?.()) {
return null
}
const next = normalizeBrowserPageZoomLevel(level)
// Why compare first: Chromium's HostZoomMap is keyed by host per partition,
// so a no-op write still overwrites the host-wide zoom a sibling tab on the
// same hostname set. Only write when this pane actually needs to move.
if (normalizeBrowserPageZoomLevel(webview.getZoomLevel()) === next) {
return next
}
webview.setZoomLevel(next)
return next
} catch {
return null
}
}
// Why module-level: the guest webview outlives its React pane (a worktree
// switch or a Settings visit unmounts the chrome and parks the viewport), so a
// pane-local ref re-seeds from the shared Settings default on every remount and
// retroactively hijacks a tab the user already zoomed.
const explicitPaneZoomLevels = new Map<string, number>()
/** The level this tab holds because the USER zoomed it, or null while it is
* still sitting on the configured new-tab seed. */
export function getExplicitBrowserPageZoomLevel(browserPageId: string): number | null {
return explicitPaneZoomLevels.get(browserPageId) ?? null
}
export function rememberExplicitBrowserPageZoomLevel(browserPageId: string, level: number): void {
explicitPaneZoomLevels.set(browserPageId, level)
}
export function forgetExplicitBrowserPageZoomLevel(browserPageId: string): void {
explicitPaneZoomLevels.delete(browserPageId)
}
export function getBrowserPageZoomIndicatorState({
feedbackVisible
}: BrowserPageZoomIndicatorInput): BrowserPageZoomIndicatorState {
}: {
feedbackVisible: boolean
}): BrowserPageZoomIndicatorState {
// Why: browser zoom percent is transient feedback; non-default page zoom
// should not leave a permanent badge over the webview.
return {
@@ -104,6 +22,11 @@ export function getBrowserPageZoomIndicatorState({
}
}
/** Zoom chords that land in Orca's own window while a browser pane holds focus. */
export function dispatchBrowserPageZoom(command: BrowserPageZoomCommand): void {
window.dispatchEvent(new CustomEvent(ORCA_BROWSER_PAGE_ZOOM_EVENT, { detail: command }))
}
export function addBrowserPageZoomEventListener(
callback: (command: BrowserPageZoomCommand) => void
): () => void {
@@ -17,8 +17,6 @@ const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
type FakeWebview = {
goBack: ReturnType<typeof vi.fn>
goForward: ReturnType<typeof vi.fn>
getZoomLevel: () => number
setZoomLevel: ReturnType<typeof vi.fn>
}
type PaneSpies = {
@@ -31,15 +29,14 @@ let historyNavigate = paneChannel<BrowserHistoryNavigateCommand>()
let reloadRequests = paneChannel<BrowserPageCommandTarget>()
let hardReloadRequests = paneChannel<BrowserPageCommandTarget>()
let zoomRequests = paneChannel<BrowserPageZoomCommand>()
let zoomPage = vi.fn(() => Promise.resolve(null))
let grabModeToggleListeners: ((browserPageId: string, intent: GrabIntent) => void)[] = []
function createSpies(): PaneSpies {
return {
webview: {
goBack: vi.fn(),
goForward: vi.fn(),
getZoomLevel: () => 0,
setZoomLevel: vi.fn()
goForward: vi.fn()
},
reload: vi.fn(),
startGrabIntent: vi.fn()
@@ -57,10 +54,9 @@ function PaneHarness({
spies: PaneSpies
markupIsActive?: boolean
}): React.JSX.Element {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only calls the history and zoom members the fake provides.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only calls the history members the fake provides.
const webviewRef = useRef(spies.webview as unknown as Electron.WebviewTag)
const isActiveRef = useRef(true)
const paneZoomLevelRef = useRef(0)
useBrowserPageKeyboardShortcuts({
browserTabId: `page-${id}`,
workspaceId: `workspace-${id}`,
@@ -69,8 +65,6 @@ function PaneHarness({
isActiveRef,
markupIsActive,
webviewRef,
paneZoomLevelRef,
setBrowserDefaultZoomLevel: vi.fn(),
showBrowserZoomFeedback: vi.fn(),
reloadWebviewOrRecoverGuest: spies.reload,
startGrabIntent: spies.startGrabIntent,
@@ -143,6 +137,7 @@ beforeEach(() => {
reloadRequests = paneChannel()
hardReloadRequests = paneChannel()
zoomRequests = paneChannel()
zoomPage = vi.fn(() => Promise.resolve(null))
grabModeToggleListeners = []
const inert = (): (() => void) => () => {}
Object.defineProperty(window, 'api', {
@@ -153,7 +148,8 @@ beforeEach(() => {
grabModeToggleListeners.push(callback)
return () => {}
},
onGrabActionShortcut: inert
onGrabActionShortcut: inert,
zoomPage
},
ui: {
onBrowserHistoryNavigate: historyNavigate.subscribe,
@@ -183,11 +179,10 @@ describe('useBrowserPageKeyboardShortcuts in a split of two active browser panes
expect(b.webview.goBack).toHaveBeenCalledTimes(1)
expect(b.webview.goForward).toHaveBeenCalledTimes(1)
expect(b.reload.mock.calls).toEqual([[false], [true]])
expect(b.webview.setZoomLevel).toHaveBeenCalledTimes(1)
expect(zoomPage.mock.calls).toEqual([[{ browserPageId: 'page-b', direction: 'in' }]])
expect(a.webview.goBack).not.toHaveBeenCalled()
expect(a.webview.goForward).not.toHaveBeenCalled()
expect(a.reload).not.toHaveBeenCalled()
expect(a.webview.setZoomLevel).not.toHaveBeenCalled()
})
it('answers a chrome chord only in the focused split', () => {
@@ -16,8 +16,6 @@ export function useBrowserPageKeyboardShortcuts({
isActiveRef,
markupIsActive,
webviewRef,
paneZoomLevelRef,
setBrowserDefaultZoomLevel,
showBrowserZoomFeedback,
reloadWebviewOrRecoverGuest,
startGrabIntent,
@@ -31,8 +29,6 @@ export function useBrowserPageKeyboardShortcuts({
isActiveRef: MutableRefObject<boolean>
markupIsActive: boolean
webviewRef: MutableRefObject<Electron.WebviewTag | null>
paneZoomLevelRef: MutableRefObject<number>
setBrowserDefaultZoomLevel: (level: number) => void
showBrowserZoomFeedback: (level: number) => void
reloadWebviewOrRecoverGuest: (ignoreCache: boolean) => void
startGrabIntent: (intent: GrabIntent) => void
@@ -48,8 +44,6 @@ export function useBrowserPageKeyboardShortcuts({
chromeShortcutScope,
isActiveRef,
webviewRef,
paneZoomLevelRef,
setBrowserDefaultZoomLevel,
showBrowserZoomFeedback,
reloadWebviewOrRecoverGuest
})
@@ -59,8 +59,6 @@ export function useBrowserPageWebviewLifecycle({
setFindOpen,
focusAddressBarNow,
focusWebviewNow,
paneZoomLevelRef,
setBrowserZoomPercent,
pendingAnnotationPayload,
inputLocked,
faviconUrl,
@@ -99,8 +97,6 @@ export function useBrowserPageWebviewLifecycle({
setFindOpen: Dispatch<SetStateAction<boolean>>
focusAddressBarNow: () => boolean
focusWebviewNow: () => boolean
paneZoomLevelRef: MutableRefObject<number>
setBrowserZoomPercent: Dispatch<SetStateAction<number>>
pendingAnnotationPayload: BrowserGrabPayload | null
inputLocked: boolean
faviconUrl: string | null
@@ -254,11 +250,9 @@ export function useBrowserPageWebviewLifecycle({
activeLoadFailureRef,
recoveryNavigationValidationRef,
keepAddressBarFocusRef,
paneZoomLevelRef,
viewportPresetIdRef,
onUpdatePageStateRef,
setGuestRecoveryGeneration,
setBrowserZoomPercent,
focusAddressBarNow,
syncNavigationState,
syncBrowserAnnotationViewportBridge,
@@ -5,13 +5,7 @@ import { keybindingMatchesAction } from '../../../../../shared/keybindings'
import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target'
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
import { isEditableKeyboardTarget } from './browser-keyboard'
import {
addBrowserPageZoomEventListener,
applyBrowserPageZoom,
rememberExplicitBrowserPageZoomLevel,
type BrowserPageZoomCommand,
type BrowserPageZoomDirection
} from './browser-page-zoom'
import { addBrowserPageZoomEventListener, type BrowserPageZoomCommand } from './browser-page-zoom'
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
/**
@@ -26,8 +20,6 @@ export function useBrowserPageWebviewShortcuts({
chromeShortcutScope,
isActiveRef,
webviewRef,
paneZoomLevelRef,
setBrowserDefaultZoomLevel,
showBrowserZoomFeedback,
reloadWebviewOrRecoverGuest
}: {
@@ -37,8 +29,6 @@ export function useBrowserPageWebviewShortcuts({
chromeShortcutScope: BrowserChromeShortcutScope
isActiveRef: MutableRefObject<boolean>
webviewRef: MutableRefObject<Electron.WebviewTag | null>
paneZoomLevelRef: MutableRefObject<number>
setBrowserDefaultZoomLevel: (level: number) => void
showBrowserZoomFeedback: (level: number) => void
reloadWebviewOrRecoverGuest: (ignoreCache: boolean) => void
}): void {
@@ -156,23 +146,16 @@ export function useBrowserPageWebviewShortcuts({
if (!isActive) {
return
}
const applyActivePageZoom = (direction: BrowserPageZoomDirection): void => {
if (!isActiveRef.current) {
// Why main: zoom belongs to the page's site in its browser profile, which main remembers.
const handleZoom = ({ browserPageId, direction }: BrowserPageZoomCommand): void => {
if (browserPageId !== browserTabId || !isActiveRef.current) {
return
}
// Why: reset targets 100% like Chromium; the configured default is a new-tab seed, not a reset target.
const nextLevel = applyBrowserPageZoom(webviewRef.current, direction)
if (nextLevel !== null) {
paneZoomLevelRef.current = nextLevel
rememberExplicitBrowserPageZoomLevel(browserTabId, nextLevel)
setBrowserDefaultZoomLevel(nextLevel)
showBrowserZoomFeedback(nextLevel)
}
}
const handleZoom = ({ browserPageId, direction }: BrowserPageZoomCommand): void => {
if (browserPageId === browserTabId) {
applyActivePageZoom(direction)
}
void window.api.browser.zoomPage({ browserPageId, direction }).then((display) => {
if (display?.site) {
showBrowserZoomFeedback(display.zoomLevel)
}
})
}
const removeGuestListener = window.api.ui.onZoomBrowserPage(handleZoom)
const removeLocalListener = addBrowserPageZoomEventListener(handleZoom)
@@ -180,13 +163,5 @@ export function useBrowserPageWebviewShortcuts({
removeGuestListener()
removeLocalListener()
}
}, [
browserTabId,
isActive,
isActiveRef,
paneZoomLevelRef,
setBrowserDefaultZoomLevel,
showBrowserZoomFeedback,
webviewRef
])
}, [browserTabId, isActive, isActiveRef, showBrowserZoomFeedback])
}
@@ -1,37 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import {
browserPageZoomLevelToPercent,
DEFAULT_BROWSER_PAGE_ZOOM_LEVEL,
getExplicitBrowserPageZoomLevel,
normalizeBrowserPageZoomLevel
} from './browser-page-zoom'
import { browserPageZoomLevelToPercent } from '../../../../../shared/browser-page-zoom'
import { BROWSER_PAGE_ZOOM_FEEDBACK_MS } from '../describe-page/browser-annotation-geometry'
export function useBrowserPageZoomFeedback(browserTabId: string): {
paneZoomLevelRef: React.MutableRefObject<number>
export function useBrowserPageZoomFeedback(): {
browserZoomPercent: number
setBrowserZoomPercent: React.Dispatch<React.SetStateAction<number>>
browserZoomFeedbackVisible: boolean
showBrowserZoomFeedback: (level: number) => void
browserDefaultZoomPercent: number
setBrowserDefaultZoomLevel: (level: number) => void
} {
const browserDefaultZoomLevel = useAppStore(
(state) => state.browserDefaultZoomLevel ?? DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
)
const setBrowserDefaultZoomLevel = useAppStore((state) => state.setBrowserDefaultZoomLevel)
const normalizedBrowserDefaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel)
const browserDefaultZoomPercent = browserPageZoomLevelToPercent(normalizedBrowserDefaultZoomLevel)
// Why: the level THIS pane should hold. Seeded from the configured default ("applied to newly
// opened browser tabs") and moved only by zooming this pane, so a reload can't broadcast another
// tab's zoom through the shared setting. Why the module-level lookup: the guest webview outlives
// this component (worktree switch, Settings visit), so re-seeding on remount would let a later
// Settings change retroactively hijack a tab the user already zoomed.
const paneZoomLevelRef = useRef<number>(undefined!)
paneZoomLevelRef.current ??=
getExplicitBrowserPageZoomLevel(browserTabId) ?? normalizedBrowserDefaultZoomLevel
const [browserZoomPercent, setBrowserZoomPercent] = useState(browserDefaultZoomPercent)
const [browserZoomPercent, setBrowserZoomPercent] = useState(100)
const [browserZoomFeedbackVisible, setBrowserZoomFeedbackVisible] = useState(false)
const browserZoomFeedbackTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => {
@@ -48,13 +24,5 @@ export function useBrowserPageZoomFeedback(browserTabId: string): {
}, BROWSER_PAGE_ZOOM_FEEDBACK_MS)
}, [])
return {
paneZoomLevelRef,
browserZoomPercent,
setBrowserZoomPercent,
browserZoomFeedbackVisible,
showBrowserZoomFeedback,
browserDefaultZoomPercent,
setBrowserDefaultZoomLevel
}
return { browserZoomPercent, browserZoomFeedbackVisible, showBrowserZoomFeedback }
}
@@ -240,21 +240,16 @@ describe('webview registry drag listeners', () => {
expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false)
})
it('preserves the viewport and zoom only while replacing a guest', async () => {
it('preserves the viewport only while replacing a guest', async () => {
const { destroyPersistentWebview, registerPersistentWebview, replacePersistentWebview } =
await import('./webview-registry')
const { getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel } =
await import('./browser-page-zoom')
registerPersistentWebview('page-1', createWebview())
rememberExplicitBrowserPageZoomLevel('page-1', 1.5)
await replacePersistentWebview('page-1', { preserveViewport: true })
expect(getExplicitBrowserPageZoomLevel('page-1')).toBe(1.5)
expect(viewportMocks.removeBrowserPageViewport).not.toHaveBeenCalled()
registerPersistentWebview('page-1', createWebview())
await destroyPersistentWebview('page-1')
expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull()
expect(viewportMocks.clearBrowserPageViewportPresetSize).toHaveBeenCalledWith('page-1')
expect(viewportMocks.removeBrowserPageViewport).toHaveBeenCalledWith('page-1')
})
@@ -303,34 +298,6 @@ describe('webview registry drag listeners', () => {
expect(webview.style.pointerEvents).toBe('auto')
})
it('preserves explicit zoom across a parent-drift replacement', async () => {
const { registerPersistentWebview, replacePersistentWebview } =
await import('./webview-registry')
const { getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel } =
await import('./browser-page-zoom')
registerPersistentWebview('page-1', createWebview())
rememberExplicitBrowserPageZoomLevel('page-1', 1.5)
await replacePersistentWebview('page-1', { preserveViewport: true })
expect(getExplicitBrowserPageZoomLevel('page-1')).toBe(1.5)
})
it('forgets explicit zoom on a real tab close', async () => {
const { destroyPersistentWebview, registerPersistentWebview } =
await import('./webview-registry')
const { getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel } =
await import('./browser-page-zoom')
registerPersistentWebview('page-1', createWebview())
rememberExplicitBrowserPageZoomLevel('page-1', 1.5)
destroyPersistentWebview('page-1')
expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull()
})
it('moves focus back to the renderer before detaching the focused webview', async () => {
const { moveFocusToRendererBeforeWebviewDetach } = await import('./webview-registry')
const webview = createWebview()
@@ -3,7 +3,6 @@ import {
clearBrowserPageViewportPresetSize,
removeBrowserPageViewport
} from './browser-page-viewport'
import { forgetExplicitBrowserPageZoomLevel } from './browser-page-zoom'
import {
acquireWebviewsDragPassthrough,
isWebviewDragPassthroughActive,
@@ -252,13 +251,9 @@ export function moveFocusToRendererBeforeWebviewDetach(webview: Electron.Webview
function removePersistentWebview(
browserTabId: string,
{ preserveViewport, preserveZoom }: { preserveViewport: boolean; preserveZoom: boolean }
{ preserveViewport }: { preserveViewport: boolean }
): Promise<void> {
const webview = webviewRegistry.get(browserTabId)
if (!preserveZoom) {
// The guest is gone, so its user-applied zoom must not be inherited by a later tab that reuses the id.
forgetExplicitBrowserPageZoomLevel(browserTabId)
}
if (!webview) {
// Why: the viewport can outlive a missing webview entry; tear it down on
// explicit close paths so overlay slots do not leak parked shells.
@@ -286,15 +281,12 @@ function removePersistentWebview(
}
export function destroyPersistentWebview(browserTabId: string): Promise<void> {
return removePersistentWebview(browserTabId, {
preserveViewport: false,
preserveZoom: false
})
return removePersistentWebview(browserTabId, { preserveViewport: false })
}
export function replacePersistentWebview(
browserTabId: string,
{ preserveViewport = false }: { preserveViewport?: boolean } = {}
): Promise<void> {
return removePersistentWebview(browserTabId, { preserveViewport, preserveZoom: true })
return removePersistentWebview(browserTabId, { preserveViewport })
}
@@ -26,10 +26,10 @@ export function BrowserDefaultZoomSetting({
'Default Zoom'
)}
description={translate(
'auto.components.settings.BrowserDefaultZoomSetting.2622126877',
'Zoom level applied to newly opened browser tabs.'
'auto.components.settings.BrowserDefaultZoomSetting.siteDefault',
"Zoom for sites you haven't zoomed."
)}
keywords={['browser', 'zoom', 'scale', 'default', 'page zoom', 'new tab', 'percentage']}
keywords={['browser', 'zoom', 'scale', 'default', 'page zoom', 'site', 'percentage']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
@@ -41,8 +41,8 @@ export function BrowserDefaultZoomSetting({
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.BrowserDefaultZoomSetting.bbeec087d3',
'Applied to newly opened browser tabs.'
'auto.components.settings.BrowserDefaultZoomSetting.siteDefault',
"Zoom for sites you haven't zoomed."
)}
</p>
</div>
@@ -5,7 +5,8 @@ import { zoomLevelToPercent } from '@/components/settings/SettingsConstants'
import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
import { stepUIZoomLevel } from '../../../../shared/ui-zoom-level'
import { useAppStore } from '../../store'
import { resolveZoomTarget } from '../resolve-zoom-target'
import { focusedBrowserPageId, resolveZoomTarget } from '../resolve-zoom-target'
import { dispatchBrowserPageZoom } from '@/components/browser-pane/host-guest/browser-page-zoom'
import { requestPdfZoom } from '@/components/editor/pdf-zoom-request'
export function registerZoomIpcBridge(unsubs: (() => void)[]): void {
@@ -29,6 +30,13 @@ export function registerZoomIpcBridge(unsubs: (() => void)[]): void {
if (target === 'terminal') {
return
}
if (target === 'browser') {
const browserPageId = focusedBrowserPageId(document.activeElement)
if (browserPageId) {
dispatchBrowserPageZoom({ browserPageId, direction })
}
return
}
if (target === 'editor') {
// Why: a PDF owns its page zoom; editor font zoom would change nothing visible.
if (requestPdfZoom(direction)) {
+33 -3
View File
@@ -13,12 +13,38 @@ function closestFocusedElement(element: unknown, selector: string): unknown {
return null
}
const BROWSER_PAGE_ID_ATTRIBUTES = [
'data-browser-page-pane-id',
'data-browser-page-viewport-id',
'data-browser-page-menu-id'
]
/**
* The browser page that holds focus: its address bar or toolbar, its open "..." menu, or the
* page itself. The page's <webview> and the menu both live outside the pane.
*/
export function focusedBrowserPageId(activeElement: unknown): string | null {
for (const attribute of BROWSER_PAGE_ID_ATTRIBUTES) {
const owner = closestFocusedElement(activeElement, `[${attribute}]`)
if (
typeof owner === 'object' &&
owner !== null &&
'getAttribute' in owner &&
typeof owner.getAttribute === 'function'
) {
const browserPageId: unknown = owner.getAttribute(attribute)
return typeof browserPageId === 'string' ? browserPageId : null
}
}
return null
}
/** Zoom belongs to the focused surface within the current workspace. */
export function resolveZoomTarget(args: {
activeView: TopLevelView
activeTabType: WorkspaceVisibleTabType
activeElement: unknown
}): 'terminal' | 'editor' | 'simulator' | 'chat' | 'ui' {
}): 'terminal' | 'editor' | 'simulator' | 'chat' | 'browser' | 'ui' {
const { activeView, activeTabType, activeElement } = args
const terminalInputFocused =
typeof activeElement === 'object' &&
@@ -37,14 +63,18 @@ export function resolveZoomTarget(args: {
)
)
// Why first: as in Chrome, zooming from the address bar or toolbar zooms the page, and a
// floating browser can hold focus over Tasks or Settings.
if (focusedBrowserPageId(activeElement)) {
return 'browser'
}
if (activeView !== 'terminal') {
return 'ui'
}
if (activeTabType === 'simulator') {
return 'simulator'
}
// Why: keyboard/menu zoom in an active browser tab belongs to Orca chrome.
// Browser page zoom has a dedicated route for wheel and page-specific IPC.
// Why: with focus outside the pane, the chord belongs to Orca's own zoom.
if (activeTabType === 'browser') {
return 'ui'
}
+65 -4
View File
@@ -7,17 +7,33 @@ function makeTarget(args: {
hasXtermClass?: boolean
editorClosest?: boolean
chatClosest?: boolean
browserPageId?: string
/** The page's <webview> sits in a viewport shell outside its pane. */
viewportPageId?: string
}): {
classList: { contains: (token: string) => boolean }
closest: (selector: string) => object | null
} {
const { hasXtermClass = false, editorClosest = false, chatClosest = false } = args
const {
hasXtermClass = false,
editorClosest = false,
chatClosest = false,
browserPageId,
viewportPageId
} = args
return {
classList: {
contains: (token: string) => hasXtermClass && token === 'xterm-helper-textarea'
},
closest: (selector) =>
(selector === '[data-native-chat-root]' ? chatClosest : editorClosest) ? {} : null
closest: (selector) => {
if (selector === '[data-browser-page-pane-id]') {
return browserPageId ? { getAttribute: () => browserPageId } : null
}
if (selector === '[data-browser-page-viewport-id]') {
return viewportPageId ? { getAttribute: () => viewportPageId } : null
}
return (selector === '[data-native-chat-root]' ? chatClosest : editorClosest) ? {} : null
}
}
}
@@ -85,6 +101,16 @@ describe('resolveZoomTarget', () => {
).toBe('ui')
})
it('routes to the page whose floating browser holds focus over another view', () => {
expect(
resolveZoomTarget({
activeView: 'tasks',
activeTabType: 'terminal',
activeElement: makeTarget({ browserPageId: 'page-1' })
})
).toBe('browser')
})
it('routes to ui zoom for active browser tabs before stale DOM focus', () => {
expect(
resolveZoomTarget({
@@ -136,6 +162,10 @@ describe('registerZoomIpcBridge', () => {
})
const requestPdfZoom = vi.fn(() => pdfTakesZoom)
const dispatchBrowserPageZoom = vi.fn()
vi.doMock('@/components/browser-pane/host-guest/browser-page-zoom', () => ({
dispatchBrowserPageZoom
}))
vi.doMock('@/components/editor/pdf-zoom-request', () => ({ requestPdfZoom }))
vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom }))
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged }))
@@ -180,6 +210,7 @@ describe('registerZoomIpcBridge', () => {
}
return {
fire,
dispatchBrowserPageZoom,
requestPdfZoom,
applyUIZoom,
dispatchZoomLevelChanged,
@@ -229,7 +260,37 @@ describe('registerZoomIpcBridge', () => {
expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled()
})
it('applies app zoom for an active browser tab', async () => {
it('zooms the page, not the app, while focus is in its browser pane', async () => {
const zoom = await mountZoomBridge({
activeTabType: 'browser',
activeElement: makeTarget({ browserPageId: 'page-1' })
})
zoom.fire('in')
zoom.fire('reset')
expect(zoom.dispatchBrowserPageZoom.mock.calls).toEqual([
[{ browserPageId: 'page-1', direction: 'in' }],
[{ browserPageId: 'page-1', direction: 'reset' }]
])
expect(zoom.applyUIZoom).not.toHaveBeenCalled()
})
it('zooms the page from the View menu while the page itself holds focus', async () => {
const zoom = await mountZoomBridge({
activeTabType: 'browser',
activeElement: makeTarget({ viewportPageId: 'page-1' })
})
zoom.fire('out')
expect(zoom.dispatchBrowserPageZoom.mock.calls).toEqual([
[{ browserPageId: 'page-1', direction: 'out' }]
])
expect(zoom.applyUIZoom).not.toHaveBeenCalled()
})
it('applies app zoom for an active browser tab when focus is outside its pane', async () => {
const zoom = await mountZoomBridge({ activeTabType: 'browser' })
zoom.fire('in')
+12 -4
View File
@@ -7401,9 +7401,8 @@
"ee110e1830": "No default base ref"
},
"BrowserDefaultZoomSetting": {
"2622126877": "Zoom level applied to newly opened browser tabs.",
"bbeec087d3": "Applied to newly opened browser tabs.",
"265597101f": "Default Zoom"
"265597101f": "Default Zoom",
"siteDefault": "Zoom for sites you haven't zoomed."
},
"BrowserHomePageSetting": {
"d4ddcd0056": "Save",
@@ -17079,7 +17078,16 @@
"bf648471c5": "Creating…",
"53bbe3dab4": "Imported {{value0}} cookies from file.",
"c5f0e4d3b2a1": "Imported {{value0}} cookies from {{value1}} ({{value2}}).",
"d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}."
"d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}.",
"zoom": "Zoom",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"resetZoom": "Reset zoom",
"appearance": "Appearance",
"appearanceMatchOrca": "Match Orca",
"appearanceLight": "Light",
"appearanceDark": "Dark",
"zoomPercent": "Zoom {{value0}}%"
},
"GrabConfirmationSheet": {
"314a0aaa5b": "Attach to AI",
-2
View File
@@ -7103,8 +7103,6 @@
"ee110e1830": "Sin ref base predeterminada"
},
"BrowserDefaultZoomSetting": {
"2622126877": "Nivel de zoom aplicado a nuevas pestañas del navegador.",
"bbeec087d3": "Se aplica a nuevas pestañas del navegador.",
"265597101f": "Zoom predeterminado"
},
"BrowserHomePageSetting": {
-2
View File
@@ -7216,8 +7216,6 @@
"ee110e1830": "Aucun ref de base par défaut"
},
"BrowserDefaultZoomSetting": {
"2622126877": "Niveau de zoom appliqué aux nouveaux onglets du navigateur.",
"bbeec087d3": "Appliqué aux nouveaux onglets du navigateur.",
"265597101f": "Zoom par défaut"
},
"BrowserHomePageSetting": {
-2
View File
@@ -7093,8 +7093,6 @@
"ee110e1830": "デフォルトの基本参照はありません"
},
"BrowserDefaultZoomSetting": {
"2622126877": "新しく開いたブラウザタブに適用されるズームレベル。",
"bbeec087d3": "新しく開いたブラウザタブに適用されます。",
"265597101f": "デフォルトのズーム"
},
"BrowserHomePageSetting": {
-2
View File
@@ -7093,8 +7093,6 @@
"ee110e1830": "기본 기준 참조 없음"
},
"BrowserDefaultZoomSetting": {
"2622126877": "새로 열린 브라우저 탭에 적용되는 확대/축소 수준입니다.",
"bbeec087d3": "새로 열린 브라우저 탭에 적용됩니다.",
"265597101f": "기본 줌"
},
"BrowserHomePageSetting": {
-2
View File
@@ -7093,8 +7093,6 @@
"ee110e1830": "没有默认的基本参考"
},
"BrowserDefaultZoomSetting": {
"2622126877": "缩放级别应用于新打开的浏览器选项卡。",
"bbeec087d3": "应用于新打开的浏览器选项卡。",
"265597101f": "默认缩放"
},
"BrowserHomePageSetting": {
@@ -11,11 +11,6 @@ import {
destroyWorktreeBrowserGuests
} from './browser-webview-cleanup'
import { destroyPersistentWebview } from '../../components/browser-pane/host-guest/webview-registry'
import {
forgetExplicitBrowserPageZoomLevel,
getExplicitBrowserPageZoomLevel,
rememberExplicitBrowserPageZoomLevel
} from '../../components/browser-pane/host-guest/browser-page-zoom'
function workspace(id: string): BrowserWorkspace {
return {
@@ -131,29 +126,4 @@ describe('destroyWorktreeBrowserGuests', () => {
expect(destroyPersistentWebview).not.toHaveBeenCalled()
})
it('re-remembers explicit zoom past the destroy-path forget (eviction is not a close)', () => {
// Mirror the real registry contract: a plain destroy forgets explicit zoom.
vi.mocked(destroyPersistentWebview).mockImplementation((browserTabId: string) => {
forgetExplicitBrowserPageZoomLevel(browserTabId)
return Promise.resolve()
})
rememberExplicitBrowserPageZoomLevel('page-1', 1.5)
rememberExplicitBrowserPageZoomLevel('legacy-workspace', 0.5)
destroyWorktreeBrowserGuests(
{ 'wt-1': [workspace('workspace-1'), workspace('legacy-workspace')] },
{ 'workspace-1': [page('page-1', 'workspace-1'), page('page-2', 'workspace-1')] },
'wt-1'
)
expect(getExplicitBrowserPageZoomLevel('page-1')).toBe(1.5)
expect(getExplicitBrowserPageZoomLevel('legacy-workspace')).toBe(0.5)
// A page the user never zoomed stays unremembered.
expect(getExplicitBrowserPageZoomLevel('page-2')).toBeNull()
forgetExplicitBrowserPageZoomLevel('page-1')
forgetExplicitBrowserPageZoomLevel('legacy-workspace')
vi.mocked(destroyPersistentWebview).mockReset()
})
})
@@ -3,10 +3,6 @@ import {
destroyPersistentWebview,
moveFocusToRendererBeforeFocusedWebviewHidden
} from '../../components/browser-pane/host-guest/webview-registry'
import {
getExplicitBrowserPageZoomLevel,
rememberExplicitBrowserPageZoomLevel
} from '../../components/browser-pane/host-guest/browser-page-zoom'
import { releaseBrowserPageMount } from '../../components/browser-pane/host-guest/browser-page-mount-admission'
export { moveFocusToRendererBeforeFocusedWebviewHidden }
@@ -57,10 +53,6 @@ export function collectBrowserWebviewIds(
// Why: guest-budget eviction destroys every guest a hidden worktree retains
// while its tabs/pages stay in the store, so a revisit rebuilds from state.
// Eviction is not a user close — the tab stays in the UI, so the user's zoom
// is re-remembered past the destroy-path forget: the revisit reasserts it
// instead of writing the default through Chromium's partition-wide HostZoomMap
// (which would also reset same-host sibling tabs).
export function destroyWorktreeBrowserGuests(
browserTabsByWorktree: Record<string, BrowserWorkspace[]>,
browserPagesByWorkspace: Record<string, BrowserPage[]>,
@@ -72,11 +64,7 @@ export function destroyWorktreeBrowserGuests(
// workspace tab id (same fallback as collectBrowserWebviewIds).
const guestIds = pages.length === 0 ? [tab.id] : pages.map((page) => page.id)
for (const guestId of guestIds) {
const explicitZoomLevel = getExplicitBrowserPageZoomLevel(guestId)
destroyRemovedBrowserWebview(guestId)
if (explicitZoomLevel !== null) {
rememberExplicitBrowserPageZoomLevel(guestId, explicitZoomLevel)
}
}
}
}
@@ -375,7 +375,8 @@ describe('createUISlice hydratePersistedUI', () => {
})
)
expect(store.getState().browserDefaultZoomLevel).toBe(1.5)
// 1.26 is about 130%, which snaps to Chrome's 125% preset.
expect(1.2 ** store.getState().browserDefaultZoomLevel).toBeCloseTo(1.25)
})
it('persists normalized default browser zoom changes', () => {
@@ -385,8 +386,10 @@ describe('createUISlice hydratePersistedUI', () => {
store.getState().setBrowserDefaultZoomLevel(10)
expect(store.getState().browserDefaultZoomLevel).toBe(5)
expect(setUI).toHaveBeenCalledWith({ browserDefaultZoomLevel: 5 })
// Clamped to Chrome's 500% maximum.
const { browserDefaultZoomLevel } = store.getState()
expect(1.2 ** browserDefaultZoomLevel).toBeCloseTo(5)
expect(setUI).toHaveBeenCalledWith({ browserDefaultZoomLevel })
})
it('drops an invalid Kagi session link during hydration', () => {
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
browserPageZoomLevelToPercent,
nextBrowserPageZoomLevel,
normalizeBrowserPageZoomLevel
} from './browser-page-zoom'
const percentsFrom = (level: number, direction: 'in' | 'out', steps: number): number[] => {
const percents: number[] = []
for (let step = 0; step < steps; step++) {
level = nextBrowserPageZoomLevel(level, direction)
percents.push(browserPageZoomLevelToPercent(level))
}
return percents
}
describe('browser page zoom presets', () => {
it("steps through Chrome's presets and stops at 25% and 500%", () => {
expect(percentsFrom(0, 'in', 10)).toEqual([110, 125, 150, 175, 200, 250, 300, 400, 500, 500])
expect(percentsFrom(0, 'out', 8)).toEqual([90, 80, 75, 67, 50, 33, 25, 25])
})
it('snaps levels saved before the presets to the nearest one', () => {
// 0.5-level steps used to give 110%, 131%, 144% and 173%.
expect(
[0.5, 1.5, 2, 3].map(normalizeBrowserPageZoomLevel).map(browserPageZoomLevelToPercent)
).toEqual([110, 125, 150, 175])
expect(normalizeBrowserPageZoomLevel(Number.NaN)).toBe(0)
})
it('resets to the level it is given', () => {
expect(nextBrowserPageZoomLevel(3, 'reset', normalizeBrowserPageZoomLevel(1.5))).toBe(
normalizeBrowserPageZoomLevel(1.5)
)
})
})
+37 -24
View File
@@ -1,34 +1,49 @@
export type BrowserPageZoomDirection = 'in' | 'out' | 'reset'
export function isBrowserPageZoomDirection(value: unknown): value is BrowserPageZoomDirection {
return value === 'in' || value === 'out' || value === 'reset'
}
export type BrowserPageZoomCommand = {
browserPageId: string
direction: BrowserPageZoomDirection
}
export const BROWSER_PAGE_ZOOM_STEP = 0.5
export const BROWSER_PAGE_ZOOM_MIN = -3
export const BROWSER_PAGE_ZOOM_MAX = 5
export const DEFAULT_BROWSER_PAGE_ZOOM_LEVEL = 0
export const BROWSER_PAGE_ZOOM_LEVELS: readonly number[] = Array.from(
{
length: Math.round((BROWSER_PAGE_ZOOM_MAX - BROWSER_PAGE_ZOOM_MIN) / BROWSER_PAGE_ZOOM_STEP) + 1
},
(_, index) => BROWSER_PAGE_ZOOM_MIN + index * BROWSER_PAGE_ZOOM_STEP
// Why: Chrome's page zoom presets; Electron levels are log base 1.2 of the zoom factor.
const BROWSER_PAGE_ZOOM_PERCENTS = [
25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500
] as const
export const BROWSER_PAGE_ZOOM_LEVELS: readonly number[] = BROWSER_PAGE_ZOOM_PERCENTS.map(
(percent) => Math.log(percent / 100) / Math.log(1.2)
)
export function normalizeBrowserPageZoomLevel(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
/** The nearest preset; anything that is not a finite level counts as 100%. */
function nearestZoomLevelIndex(level: unknown): number {
if (typeof level !== 'number' || !Number.isFinite(level)) {
return BROWSER_PAGE_ZOOM_PERCENTS.indexOf(100)
}
const roundedToStep = Math.round(value / BROWSER_PAGE_ZOOM_STEP) * BROWSER_PAGE_ZOOM_STEP
return Math.max(BROWSER_PAGE_ZOOM_MIN, Math.min(BROWSER_PAGE_ZOOM_MAX, roundedToStep))
let nearest = 0
for (let index = 1; index < BROWSER_PAGE_ZOOM_LEVELS.length; index++) {
if (
Math.abs(BROWSER_PAGE_ZOOM_LEVELS[index] - level) <
Math.abs(BROWSER_PAGE_ZOOM_LEVELS[nearest] - level)
) {
nearest = index
}
}
return nearest
}
/** Snaps any level, including ones saved before the presets, to the nearest preset. */
export function normalizeBrowserPageZoomLevel(value: unknown): number {
return BROWSER_PAGE_ZOOM_LEVELS[nearestZoomLevelIndex(value)]
}
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 * 1.2 ** normalizeBrowserPageZoomLevel(level))
return BROWSER_PAGE_ZOOM_PERCENTS[nearestZoomLevelIndex(level)]
}
export function nextBrowserPageZoomLevel(
@@ -36,12 +51,10 @@ export function nextBrowserPageZoomLevel(
direction: BrowserPageZoomDirection,
resetLevel: number = DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
): number {
const rawNext =
direction === 'in'
? current + BROWSER_PAGE_ZOOM_STEP
: direction === 'out'
? current - BROWSER_PAGE_ZOOM_STEP
: normalizeBrowserPageZoomLevel(resetLevel)
return normalizeBrowserPageZoomLevel(rawNext)
if (direction === 'reset') {
return normalizeBrowserPageZoomLevel(resetLevel)
}
const index = nearestZoomLevelIndex(current)
const next = direction === 'in' ? index + 1 : index - 1
return BROWSER_PAGE_ZOOM_LEVELS[Math.max(0, Math.min(BROWSER_PAGE_ZOOM_LEVELS.length - 1, next))]
}
+44
View File
@@ -0,0 +1,44 @@
import { BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN } from './browser-site-permission'
/** A site's own color scheme; a site with none matches Orca's theme. */
export type BrowserSiteColorScheme = 'light' | 'dark'
/** What a browser profile remembers about how one site is shown. */
export type BrowserSiteDisplay = {
zoomLevel?: number
colorScheme?: BrowserSiteColorScheme
}
/** How the page in a tab is shown right now, for the browser menu. */
export type BrowserPageSiteDisplay = {
/** Null for pages no site owns; neither setting applies to them. */
site: string | null
zoomLevel: number
/** False while the site uses Default Zoom. */
siteHasZoom: boolean
colorScheme: BrowserSiteColorScheme | null
}
export function isBrowserSiteColorScheme(value: unknown): value is BrowserSiteColorScheme {
return value === 'light' || value === 'dark'
}
/**
* The hostname, port ignored: Chromium shares page zoom across ports, so `localhost:3000` and
* `localhost:5173` must be one site or they would keep resetting each other. Null for pages no site
* owns (about:blank, data:, chrome errors).
*/
export function browserSiteDisplayKey(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.protocol === 'file:') {
return BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.hostname || null
}
} catch {
// Not a URL; no site.
}
return null
}