mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
feat(browser): per-site appearance and zoom, from the page menu and Cmd/Ctrl +/-/0 (#31621)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
+20
-11
@@ -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(() => {
|
||||
|
||||
+27
-3
@@ -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),
|
||||
|
||||
+164
@@ -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
|
||||
|
||||
-4
@@ -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
|
||||
|
||||
-2
@@ -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()
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
-12
@@ -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 {
|
||||
|
||||
+7
-12
@@ -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', () => {
|
||||
|
||||
-6
@@ -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
|
||||
})
|
||||
|
||||
-6
@@ -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,
|
||||
|
||||
+10
-35
@@ -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])
|
||||
}
|
||||
|
||||
+4
-36
@@ -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)) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7093,8 +7093,6 @@
|
||||
"ee110e1830": "デフォルトの基本参照はありません"
|
||||
},
|
||||
"BrowserDefaultZoomSetting": {
|
||||
"2622126877": "新しく開いたブラウザタブに適用されるズームレベル。",
|
||||
"bbeec087d3": "新しく開いたブラウザタブに適用されます。",
|
||||
"265597101f": "デフォルトのズーム"
|
||||
},
|
||||
"BrowserHomePageSetting": {
|
||||
|
||||
@@ -7093,8 +7093,6 @@
|
||||
"ee110e1830": "기본 기준 참조 없음"
|
||||
},
|
||||
"BrowserDefaultZoomSetting": {
|
||||
"2622126877": "새로 열린 브라우저 탭에 적용되는 확대/축소 수준입니다.",
|
||||
"bbeec087d3": "새로 열린 브라우저 탭에 적용됩니다.",
|
||||
"265597101f": "기본 줌"
|
||||
},
|
||||
"BrowserHomePageSetting": {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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))]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user