Browser Zoom (#299161)

* Browser Zoom

* Remove logic from editor

* Feedback

* Small comment change

* Zoom factors, not percentages

* Comment on keybinding

* Add keybindings back to actions

* Add browserZoomLabel helper for zoom percentage display

* First AI changes

* AI pass with zoom level hierarchy

* Fix zoom not applying to other origins after default zoom change

* Promote per-origin zoom to user setting workbench.browser.zoom.perOriginZoomLevels

* Remove unnecessary configuration migration for zoom setting

* Add 'Match VS Code' default zoom level option for Integrated Browser

* Add missing localize import to platform/browserView/common/browserView.ts

* Switch per-origin zoom tracking to per-host (http/https only)

* Rename zoom settings: defaultZoomLevel→pageZoom, perHostZoomLevels→zoomLevels; mark zoomLevels as advanced

* Update setting description and scope

* Improve zoom service: lazy synchronizer, pre-computed label map, RunOnceScheduler, always forceApply on navigate

* Remove self-evident and redundant comments

* Refactor zoom to two independent cascades (ephemeral/persistent each fall back to default independently)

* Use IStorageService for per-host browser zoom instead of settings

* Remove VS Code product name from browser zoom code
This commit is contained in:
Joaquín Ruales
2026-03-11 15:38:03 -07:00
committed by GitHub
parent f144e45025
commit cc5c2fd418
8 changed files with 588 additions and 15 deletions

View File

@@ -6,6 +6,7 @@
import { Event } from '../../../base/common/event.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { URI } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
const commandPrefix = 'workbench.action.browser';
export enum BrowserViewCommandId {
@@ -58,7 +59,7 @@ export interface IBrowserViewState {
lastFavicon: string | undefined;
lastError: IBrowserViewLoadError | undefined;
storageScope: BrowserViewStorageScope;
zoomFactor: number;
browserZoomIndex: number;
}
export interface IBrowserViewNavigationEvent {
@@ -143,6 +144,16 @@ export enum BrowserViewStorageScope {
export const ipcBrowserViewChannelName = 'browserView';
/**
* Discrete zoom levels matching Edge/Chrome.
* Note: When those browsers say "33%" and "67%" zoom, they really mean 33.33...% and 66.66...%
*/
export const browserZoomFactors = [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5] as const;
export const browserZoomDefaultIndex = browserZoomFactors.indexOf(1);
export function browserZoomLabel(zoomFactor: number): string {
return localize('browserZoomPercent', "{0}%", Math.round(zoomFactor * 100));
}
/**
* This should match the isolated world ID defined in `preload-browserView.ts`.
*/
@@ -311,6 +322,9 @@ export interface IBrowserViewService {
*/
clearStorage(id: string): Promise<void>;
/** Set the browser zoom index (independent from VS Code zoom). */
setBrowserZoomIndex(id: string, zoomIndex: number): Promise<void>;
/**
* Update the keybinding accelerators used in browser view context menus.
* @param keybindings A map of command ID to accelerator label

View File

@@ -8,7 +8,7 @@ import { FileAccess } from '../../../base/common/network.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId } from '../common/browserView.js';
import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex } from '../common/browserView.js';
import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js';
import { IWindowsMainService } from '../../windows/electron-main/windows.js';
import { ICodeWindow } from '../../window/electron-main/window.js';
@@ -46,6 +46,7 @@ export class BrowserView extends Disposable implements ICDPTarget {
private _lastFavicon: string | undefined = undefined;
private _lastError: IBrowserViewLoadError | undefined = undefined;
private _lastUserGestureTimestamp: number = -Infinity;
private _browserZoomIndex: number = browserZoomDefaultIndex;
private _debugger: BrowserViewDebugger;
private _window: ICodeWindow | IAuxiliaryWindow | undefined;
@@ -278,6 +279,12 @@ export class BrowserView extends Disposable implements ICDPTarget {
webContents.on('did-navigate', fireNavigationEvent);
webContents.on('did-navigate-in-page', fireNavigationEvent);
// Chromium resets the zoom factor to its per-origin default (100%) when
// navigating to a new document. Re-apply our stored zoom to override it.
webContents.on('did-navigate', () => {
this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]);
});
// Focus events
webContents.on('focus', () => {
this._onDidChangeFocus.fire({ focused: true });
@@ -366,7 +373,7 @@ export class BrowserView extends Disposable implements ICDPTarget {
lastFavicon: this._lastFavicon,
lastError: this._lastError,
storageScope: this.session.storageScope,
zoomFactor: webContents.getZoomFactor()
browserZoomIndex: this._browserZoomIndex
};
}
@@ -390,7 +397,6 @@ export class BrowserView extends Disposable implements ICDPTarget {
}
}
this._view.webContents.setZoomFactor(bounds.zoomFactor);
this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor));
this._view.setBounds({
x: Math.round(bounds.x * bounds.zoomFactor),
@@ -400,6 +406,12 @@ export class BrowserView extends Disposable implements ICDPTarget {
});
}
setBrowserZoomIndex(zoomIndex: number): void {
this._browserZoomIndex = Math.max(0, Math.min(zoomIndex, browserZoomFactors.length - 1));
const browserZoomFactor = browserZoomFactors[this._browserZoomIndex];
this._view.webContents.setZoomFactor(browserZoomFactor);
}
/**
* Set the visibility of this view
*/

View File

@@ -328,6 +328,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
return this._getBrowserView(id).clearStorage();
}
async setBrowserZoomIndex(id: string, zoomIndex: number): Promise<void> {
return this._getBrowserView(id).setBrowserZoomIndex(zoomIndex);
}
async clearGlobalStorage(): Promise<void> {
const browserSession = BrowserSession.getOrCreateGlobal();
await browserSession.electronSession.clearData();

View File

@@ -27,13 +27,25 @@ import {
IBrowserViewCaptureScreenshotOptions,
IBrowserViewFindInPageOptions,
IBrowserViewFindInPageResult,
IBrowserViewVisibilityEvent
IBrowserViewVisibilityEvent,
browserZoomDefaultIndex,
browserZoomFactors
} from '../../../../platform/browserView/common/browserView.js';
import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js';
import { IBrowserZoomService } from './browserZoomService.js';
/** Extracts the host from a URL string for zoom tracking purposes. */
function parseZoomHost(url: string): string | undefined {
const parsed = URL.parse(url);
if (!parsed?.host || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
return undefined;
}
return parsed.host;
}
type IntegratedBrowserNavigationEvent = {
navigationType: 'urlInput' | 'goBack' | 'goForward' | 'reload';
@@ -116,8 +128,11 @@ export interface IBrowserViewModel extends IDisposable {
readonly storageScope: BrowserViewStorageScope;
readonly sharedWithAgent: boolean;
readonly zoomFactor: number;
readonly canZoomIn: boolean;
readonly canZoomOut: boolean;
readonly onDidChangeSharedWithAgent: Event<boolean>;
readonly onDidChangeZoom: Event<void>;
readonly onDidNavigate: Event<IBrowserViewNavigationEvent>;
readonly onDidChangeLoadingState: Event<IBrowserViewLoadingEvent>;
readonly onDidChangeFocus: Event<IBrowserViewFocusEvent>;
@@ -148,6 +163,9 @@ export interface IBrowserViewModel extends IDisposable {
getSelectedText(): Promise<string>;
clearStorage(): Promise<void>;
setSharedWithAgent(shared: boolean): Promise<void>;
zoomIn(): Promise<void>;
zoomOut(): Promise<void>;
resetZoom(): Promise<void>;
}
export class BrowserViewModel extends Disposable implements IBrowserViewModel {
@@ -163,12 +181,17 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
private _canGoForward: boolean = false;
private _error: IBrowserViewLoadError | undefined = undefined;
private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral;
private _isEphemeral: boolean = false;
private _zoomHost: string | undefined = undefined;
private _sharedWithAgent: boolean = false;
private _zoomFactor: number = 1;
private _browserZoomIndex: number = browserZoomDefaultIndex;
private readonly _onDidChangeSharedWithAgent = this._register(new Emitter<boolean>());
readonly onDidChangeSharedWithAgent: Event<boolean> = this._onDidChangeSharedWithAgent.event;
private readonly _onDidChangeZoom = this._register(new Emitter<void>());
readonly onDidChangeZoom: Event<void> = this._onDidChangeZoom.event;
private readonly _onWillDispose = this._register(new Emitter<void>());
readonly onWillDispose: Event<void> = this._onWillDispose.event;
@@ -182,6 +205,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
@IPlaywrightService private readonly playwrightService: IPlaywrightService,
@IDialogService private readonly dialogService: IDialogService,
@IStorageService private readonly storageService: IStorageService,
@IBrowserZoomService private readonly zoomService: IBrowserZoomService,
) {
super();
}
@@ -199,7 +223,9 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
get error(): IBrowserViewLoadError | undefined { return this._error; }
get storageScope(): BrowserViewStorageScope { return this._storageScope; }
get sharedWithAgent(): boolean { return this._sharedWithAgent; }
get zoomFactor(): number { return this._zoomFactor; }
get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; }
get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; }
get canZoomOut(): boolean { return this._browserZoomIndex > 0; }
get onDidNavigate(): Event<IBrowserViewNavigationEvent> {
return this.browserViewService.onDynamicDidNavigate(this.id);
@@ -282,7 +308,26 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
this._error = state.lastError;
this._storageScope = state.storageScope;
this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id);
this._zoomFactor = state.zoomFactor;
this._browserZoomIndex = state.browserZoomIndex;
this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral;
this._zoomHost = parseZoomHost(this._url);
const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral);
if (effectiveZoomIndex !== this._browserZoomIndex) {
await this.setBrowserZoomIndex(effectiveZoomIndex);
}
this._register(this.zoomService.onDidChangeZoom(({ host, isEphemeralChange }) => {
if (isEphemeralChange && !this._isEphemeral) {
return;
}
if (host === undefined || host === this._zoomHost) {
void this.setBrowserZoomIndex(
this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral)
);
}
}));
// Set up state synchronization
@@ -292,10 +337,18 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
this._favicon = undefined;
}
this._zoomHost = parseZoomHost(e.url);
this._url = e.url;
this._title = e.title;
this._canGoBack = e.canGoBack;
this._canGoForward = e.canGoForward;
// Always forceApply because Chromium resets zoom on cross-origin navigation,
// and an origin change may not correspond to a host change (e.g. http→https).
void this.setBrowserZoomIndex(
this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral),
true
);
}));
this._register(this.onDidChangeLoadingState(e => {
@@ -329,7 +382,6 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
}
async layout(bounds: IBrowserViewBounds): Promise<void> {
this._zoomFactor = bounds.zoomFactor;
return this.browserViewService.layout(this.id, bounds);
}
@@ -395,6 +447,49 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
return this.browserViewService.clearStorage(this.id);
}
/**
* @param forceApply When true, the IPC call is made even if the local cached zoom index
* already matches the requested value. Pass true after cross-document navigation because
* Chromium resets the zoom to its per-origin default, making the cache stale.
*/
private async setBrowserZoomIndex(zoomIndex: number, forceApply = false): Promise<void> {
const clamped = Math.max(0, Math.min(zoomIndex, browserZoomFactors.length - 1));
if (!forceApply && clamped === this._browserZoomIndex) {
return;
}
this._browserZoomIndex = clamped;
await this.browserViewService.setBrowserZoomIndex(this.id, this._browserZoomIndex);
this._onDidChangeZoom.fire();
}
async zoomIn(): Promise<void> {
if (!this.canZoomIn) {
return;
}
await this.setBrowserZoomIndex(this._browserZoomIndex + 1);
if (this._zoomHost) {
this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isEphemeral);
}
}
async zoomOut(): Promise<void> {
if (!this.canZoomOut) {
return;
}
await this.setBrowserZoomIndex(this._browserZoomIndex - 1);
if (this._zoomHost) {
this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isEphemeral);
}
}
async resetZoom(): Promise<void> {
const defaultIndex = this.zoomService.getEffectiveZoomIndex(undefined, false);
await this.setBrowserZoomIndex(defaultIndex);
if (this._zoomHost) {
this.zoomService.setHostZoomIndex(this._zoomHost, defaultIndex, this._isEphemeral);
}
}
private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain';
async setSharedWithAgent(shared: boolean): Promise<void> {

View File

@@ -0,0 +1,271 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { browserZoomDefaultIndex, browserZoomFactors } from '../../../../platform/browserView/common/browserView.js';
import { zoomLevelToZoomFactor } from '../../../../platform/window/common/window.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
export const IBrowserZoomService = createDecorator<IBrowserZoomService>('browserZoomService');
/** Storage key for the per-host persistent zoom map. */
const BROWSER_ZOOM_PER_HOST_STORAGE_KEY = 'browserView.zoomPerHost';
/**
* Special value for the default zoom level setting that instructs the browser view
* to dynamically match the closest zoom level to the application's current UI zoom.
*/
export const MATCH_WINDOW_ZOOM_LABEL = 'Match Window';
export interface IBrowserZoomChangeEvent {
/**
* The host (e.g. `"example.com"`) whose zoom changed, or `undefined`
* when the global default zoom level changed.
*/
readonly host: string | undefined;
/**
* Whether the change came from an ephemeral session.
* - `true` → only ephemeral views need to react.
* - `false` → all views (ephemeral and non-ephemeral) for the host may be affected.
*/
readonly isEphemeralChange: boolean;
}
/**
* Manages two independent cascading zoom hierarchies for integrated browser views:
*
* Normal views: `persistent per-host override` ?? `configured default`
* Ephemeral views: `ephemeral per-host override` ?? `configured default`
*
* Ephemeral views never see persistent overrides directly. Instead, when a persistent
* value changes, it is copied into the ephemeral map so that ephemeral views
* immediately reflect the new level. Conversely, ephemeral changes never affect
* normal views.
*
* Per-host values that equal the current default are always removed (both persistent
* and ephemeral), so the view tracks the default going forward.
*/
export interface IBrowserZoomService {
readonly _serviceBrand: undefined;
/** Fired whenever the effective zoom for a host may have changed. */
readonly onDidChangeZoom: Event<IBrowserZoomChangeEvent>;
/**
* Returns the effective zoom index for the given host and session type.
* Pass `host = undefined` to obtain only the configured default zoom index.
*/
getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number;
/**
* Set the zoom for a host.
*
* Non-ephemeral: persisted to storage. Also propagated into
* the ephemeral map so ephemeral views immediately reflect the change.
*
* Ephemeral: stored in memory only, dropped on restart.
*
* In both cases, if the value equals the current default, the entry is removed so the
* view tracks the default going forward.
*/
setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void;
/**
* Notifies the service of the application's current UI zoom factor.
* Must be called once on startup and again whenever the window zoom changes.
* Only relevant when the default zoom level is set to `MATCH_WINDOW_LABEL`.
*/
notifyWindowZoomChanged(windowZoomFactor: number): void;
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
/** Pre-computed map from percentage label (e.g. "125%") to index into browserZoomFactors. */
const ZOOM_LABEL_TO_INDEX = new Map<string, number>(
browserZoomFactors.map((f, i) => [`${Math.round(f * 100)}%`, i])
);
export class BrowserZoomService extends Disposable implements IBrowserZoomService {
declare readonly _serviceBrand: undefined;
private readonly _onDidChangeZoom = this._register(new Emitter<IBrowserZoomChangeEvent>());
readonly onDidChangeZoom: Event<IBrowserZoomChangeEvent> = this._onDidChangeZoom.event;
/**
* In-memory cache of the persistent per-host map.
* Backed by IStorageService.
*/
private _persistentZoomMap: Record<string, number>;
/** In-memory only; dropped on restart. */
private readonly _ephemeralZoomMap = new Map<string, number>();
private _windowZoomFactor: number = zoomLevelToZoomFactor(0); // default: zoom level 0 → factor 1.0
constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IStorageService private readonly storageService: IStorageService,
) {
super();
this._persistentZoomMap = this._readPersistentZoomMap();
this._register(this.configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('workbench.browser.zoom.pageZoom')) {
this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false });
}
}));
}
getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number {
if (host !== undefined) {
if (isEphemeral) {
const ephemeralIndex = this._ephemeralZoomMap.get(host);
if (ephemeralIndex !== undefined) {
return this._clamp(ephemeralIndex);
}
} else {
const persistentIndex = this._persistentZoomMap[host];
if (persistentIndex !== undefined) {
return this._clamp(persistentIndex);
}
}
}
return this._getDefaultZoomIndex();
}
setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void {
const clamped = this._clamp(zoomIndex);
const defaultIndex = this._getDefaultZoomIndex();
const matchesDefault = clamped === defaultIndex;
if (isEphemeral) {
if (matchesDefault) {
if (!this._ephemeralZoomMap.has(host)) {
return;
}
this._ephemeralZoomMap.delete(host);
} else {
if (this._ephemeralZoomMap.get(host) === clamped) {
return;
}
this._ephemeralZoomMap.set(host, clamped);
}
this._onDidChangeZoom.fire({ host, isEphemeralChange: true });
} else {
let persistentChanged = false;
if (matchesDefault) {
if (Object.prototype.hasOwnProperty.call(this._persistentZoomMap, host)) {
delete this._persistentZoomMap[host];
persistentChanged = true;
}
} else if (this._persistentZoomMap[host] !== clamped) {
this._persistentZoomMap[host] = clamped;
persistentChanged = true;
}
// Propagate to ephemeral map so ephemeral views immediately reflect the new level.
let ephemeralChanged = false;
if (matchesDefault) {
ephemeralChanged = this._ephemeralZoomMap.delete(host);
} else if (this._ephemeralZoomMap.get(host) !== clamped) {
this._ephemeralZoomMap.set(host, clamped);
ephemeralChanged = true;
}
if (!persistentChanged && !ephemeralChanged) {
return;
}
if (persistentChanged) {
this._writePersistentZoomMap();
}
this._onDidChangeZoom.fire({ host, isEphemeralChange: false });
}
}
notifyWindowZoomChanged(windowZoomFactor: number): void {
this._windowZoomFactor = windowZoomFactor;
const label = this.configurationService.getValue<string>('workbench.browser.zoom.pageZoom');
if (label === MATCH_WINDOW_ZOOM_LABEL) {
this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false });
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private _getDefaultZoomIndex(): number {
const label = this.configurationService.getValue<string>('workbench.browser.zoom.pageZoom');
if (label === MATCH_WINDOW_ZOOM_LABEL) {
return this._getMatchWindowZoomIndex();
}
return ZOOM_LABEL_TO_INDEX.get(label) ?? browserZoomDefaultIndex;
}
/**
* Finds the browser zoom index whose factor is closest to the application's current UI zoom
* factor, measuring distance on a log scale (since window zoom levels are powers of 1.2).
*/
private _getMatchWindowZoomIndex(): number {
const windowFactor = this._windowZoomFactor;
let bestIndex = browserZoomDefaultIndex;
let bestDist = Infinity;
for (let i = 0; i < browserZoomFactors.length; i++) {
const dist = Math.abs(Math.log(browserZoomFactors[i]) - Math.log(windowFactor));
if (dist < bestDist) {
bestDist = dist;
bestIndex = i;
}
}
return bestIndex;
}
/**
* Reads the persistent per-host zoom map from storage.
* The stored format is a JSON object mapping host strings to zoom indices.
*/
private _readPersistentZoomMap(): Record<string, number> {
const raw = this.storageService.get(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, StorageScope.PROFILE);
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return {};
}
const result: Record<string, number> = {};
for (const [host, index] of Object.entries(parsed)) {
if (typeof index === 'number' && index >= 0 && index < browserZoomFactors.length) {
result[host] = index;
}
}
return result;
} catch {
return {};
}
}
private _writePersistentZoomMap(): void {
const hasEntries = Object.keys(this._persistentZoomMap).length > 0;
if (hasEntries) {
this.storageService.store(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, JSON.stringify(this._persistentZoomMap), StorageScope.PROFILE, StorageTarget.MACHINE);
} else {
this.storageService.remove(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, StorageScope.PROFILE);
}
}
private _clamp(index: number): number {
return Math.max(0, Math.min(Math.trunc(index), browserZoomFactors.length - 1));
}
}

View File

@@ -59,6 +59,8 @@ export const CONTEXT_BROWSER_HAS_URL = new RawContextKey<boolean>('browserHasUrl
export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey<boolean>('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error"));
export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey<boolean>('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view"));
export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey<boolean>('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active"));
export const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey<boolean>('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further"));
export const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey<boolean>('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further"));
// Re-export find widget context keys for use in actions
export { CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE };
@@ -254,6 +256,8 @@ export class BrowserEditor extends EditorPane {
private _hasErrorContext!: IContextKey<boolean>;
private _devToolsOpenContext!: IContextKey<boolean>;
private _elementSelectionActiveContext!: IContextKey<boolean>;
private _canZoomInContext!: IContextKey<boolean>;
private _canZoomOutContext!: IContextKey<boolean>;
private _model: IBrowserViewModel | undefined;
private readonly _inputDisposables = this._register(new DisposableStore());
@@ -295,6 +299,8 @@ export class BrowserEditor extends EditorPane {
this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService);
this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService);
this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService);
this._canZoomInContext = CONTEXT_BROWSER_CAN_ZOOM_IN.bindTo(contextKeyService);
this._canZoomOutContext = CONTEXT_BROWSER_CAN_ZOOM_OUT.bindTo(contextKeyService);
// Currently this is always true since it is scoped to the editor container
CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService);
@@ -399,6 +405,7 @@ export class BrowserEditor extends EditorPane {
this._storageScopeContext.set(this._model.storageScope);
this._devToolsOpenContext.set(this._model.isDevToolsOpen);
this.updateZoomContext();
this._updateSharingState(true);
// Update find widget with new model
@@ -417,6 +424,10 @@ export class BrowserEditor extends EditorPane {
this._updateSharingState(false);
}));
this._inputDisposables.add(this._model.onDidChangeZoom(() => {
this.updateZoomContext();
}));
// Initialize UI state and context keys from model
this.updateNavigationState({
url: this._model.url,
@@ -505,7 +516,7 @@ export class BrowserEditor extends EditorPane {
this.checkOverlays();
}));
// Listen for zoom level changes and update browser view zoom factor
// Listen for workbench zoom level changes and update browser view placeholder screenshot's zoom factor
this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => {
if (targetWindowId === this.window.vscodeWindowId) {
// Update CSS variable for size calculations
@@ -717,6 +728,25 @@ export class BrowserEditor extends EditorPane {
return this._model?.clearStorage();
}
async zoomIn(): Promise<void> {
await this._model?.zoomIn();
}
async zoomOut(): Promise<void> {
await this._model?.zoomOut();
}
async resetZoom(): Promise<void> {
await this._model?.resetZoom();
}
private updateZoomContext(): void {
if (this._model) {
this._canZoomInContext.set(this._model.canZoomIn);
this._canZoomOutContext.set(this._model.canZoomOut);
}
}
/**
* Show the find widget, optionally pre-populated with selected text from the browser view
*/
@@ -1263,6 +1293,8 @@ export class BrowserEditor extends EditorPane {
this._storageScopeContext.reset();
this._devToolsOpenContext.reset();
this._elementSelectionActiveContext.reset();
this._canZoomInContext.reset();
this._canZoomOutContext.reset();
this._navigationBar.clear();
this.setBackgroundImage(undefined);

View File

@@ -20,13 +20,17 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase
import { Schemas } from '../../../../base/common/network.js';
import { IBrowserViewWorkbenchService } from '../common/browserView.js';
import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js';
import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js';
import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../common/browserZoomService.js';
import { browserZoomFactors, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js';
import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js';
import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { PolicyCategory } from '../../../../base/common/policy.js';
import { getZoomLevel, onDidChangeZoomLevel } from '../../../../base/browser/browser.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { zoomLevelToZoomFactor } from '../../../../platform/window/common/window.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { URI } from '../../../../base/common/uri.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
@@ -145,7 +149,26 @@ class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchCo
registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup);
/**
* Bridges the application's UI zoom level changes into IBrowserZoomService so that
* views using the 'Match Window' default zoom level stay in sync.
*/
class WindowZoomSynchronizer extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.browserView.windowZoomSynchronizer';
constructor(@IBrowserZoomService browserZoomService: IBrowserZoomService) {
super();
browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow)));
this._register(onDidChangeZoomLevel(() => {
browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow)));
}));
}
}
registerWorkbenchContribution2(WindowZoomSynchronizer.ID, WindowZoomSynchronizer, WorkbenchPhase.Eventually);
registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed);
registerSingleton(IBrowserZoomService, BrowserZoomService, InstantiationType.Delayed);
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
...workbenchConfigurationNodeBase,
@@ -180,6 +203,24 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
},
}
},
'workbench.browser.zoom.pageZoom': {
type: 'string',
enum: [MATCH_WINDOW_ZOOM_LABEL, ...browserZoomFactors.map(f => `${Math.round(f * 100)}%`)],
markdownEnumDescriptions: [
localize(
{ comment: ['This is the description for a setting enum value.'], key: 'browser.defaultZoomLevel.matchWindow' },
'Matches the application\'s current UI zoom level.'
),
...browserZoomFactors.map(() => ''),
],
default: MATCH_WINDOW_ZOOM_LABEL,
markdownDescription: localize(
{ comment: ['This is the description for a setting.'], key: 'browser.pageZoom' },
'Default zoom level for all sites in the Integrated Browser.'
),
// Zoom can change from machine to machine, so we don't need the workspace-level nor syncing that WINDOW has.
scope: ConfigurationScope.MACHINE
},
'workbench.browser.dataStorage': {
type: 'string',
enum: [

View File

@@ -11,7 +11,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind
import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js';
import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js';
import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_CAN_ZOOM_IN, CONTEXT_BROWSER_CAN_ZOOM_OUT, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js';
import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js';
import { IBrowserViewWorkbenchService } from '../common/browserView.js';
import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js';
@@ -26,8 +26,9 @@ const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEdito
const BrowserCategory = localize2('browserCategory', "Browser");
const ActionGroupTabs = '1_tabs';
const ActionGroupPage = '2_page';
const ActionGroupSettings = '3_settings';
const ActionGroupZoom = '2_zoom';
const ActionGroupPage = '3_page';
const ActionGroupSettings = '4_settings';
interface IOpenBrowserOptions {
url?: string;
@@ -445,7 +446,7 @@ class ClearEphemeralBrowserStorageAction extends Action2 {
precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral),
menu: {
id: MenuId.BrowserActionsToolbar,
group: '3_settings',
group: ActionGroupSettings,
order: 1,
when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral)
}
@@ -483,6 +484,106 @@ class OpenBrowserSettingsAction extends Action2 {
}
}
// Zoom actions
class ZoomInAction extends Action2 {
static readonly ID = 'workbench.action.browser.zoomIn';
constructor() {
super({
id: ZoomInAction.ID,
title: localize2('browser.zoomInAction', 'Zoom In'),
category: BrowserCategory,
icon: Codicon.zoomIn,
f1: true,
precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()),
menu: {
id: MenuId.BrowserActionsToolbar,
group: ActionGroupZoom,
order: 1,
when: CONTEXT_BROWSER_CAN_ZOOM_IN,
},
keybinding: {
when: CONTEXT_BROWSER_FOCUSED,
weight: KeybindingWeight.WorkbenchContrib + 75,
primary: KeyMod.CtrlCmd | KeyCode.Equal,
secondary: [KeyMod.CtrlCmd | KeyCode.NumpadAdd],
},
});
}
async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise<void> {
if (browserEditor instanceof BrowserEditor) {
await browserEditor.zoomIn();
}
}
}
class ZoomOutAction extends Action2 {
static readonly ID = 'workbench.action.browser.zoomOut';
constructor() {
super({
id: ZoomOutAction.ID,
title: localize2('browser.zoomOutAction', 'Zoom Out'),
category: BrowserCategory,
icon: Codicon.zoomOut,
f1: true,
precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()),
menu: {
id: MenuId.BrowserActionsToolbar,
group: ActionGroupZoom,
order: 2,
when: CONTEXT_BROWSER_CAN_ZOOM_OUT,
},
keybinding: {
when: CONTEXT_BROWSER_FOCUSED,
weight: KeybindingWeight.WorkbenchContrib + 75,
primary: KeyMod.CtrlCmd | KeyCode.Minus,
secondary: [KeyMod.CtrlCmd | KeyCode.NumpadSubtract],
},
});
}
async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise<void> {
if (browserEditor instanceof BrowserEditor) {
await browserEditor.zoomOut();
}
}
}
class ResetZoomAction extends Action2 {
static readonly ID = 'workbench.action.browser.resetZoom';
constructor() {
super({
id: ResetZoomAction.ID,
title: localize2('browser.resetZoomAction', 'Reset Zoom'),
category: BrowserCategory,
icon: Codicon.screenNormal,
f1: true,
precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()),
menu: {
id: MenuId.BrowserActionsToolbar,
group: ActionGroupZoom,
order: 3,
},
keybinding: {
when: CONTEXT_BROWSER_FOCUSED,
weight: KeybindingWeight.WorkbenchContrib + 75,
// We use Numpad0 and not Digit0 here to match the workbench zoom reset keybinding, and to avoid conflicts with keybinding to focus sidebar.
primary: KeyMod.CtrlCmd | KeyCode.Numpad0,
},
});
}
async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise<void> {
if (browserEditor instanceof BrowserEditor) {
await browserEditor.resetZoom();
}
}
}
// Find actions
class ShowBrowserFindAction extends Action2 {
@@ -617,6 +718,9 @@ registerAction2(ClearGlobalBrowserStorageAction);
registerAction2(ClearWorkspaceBrowserStorageAction);
registerAction2(ClearEphemeralBrowserStorageAction);
registerAction2(OpenBrowserSettingsAction);
registerAction2(ZoomInAction);
registerAction2(ZoomOutAction);
registerAction2(ResetZoomAction);
registerAction2(ShowBrowserFindAction);
registerAction2(HideBrowserFindAction);
registerAction2(BrowserFindNextAction);