Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/main/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,44 @@ function buildViewSubmenu({ isFullScreen: fullScreen, showAppDevtools }) {
},
},
{ type: 'separator' },
// Zoom targets the active <webview>, so it goes through the renderer
// rather than Electron's zoomIn/zoomOut/resetZoom roles — those step
// zoomLevel on the focused webContents (the chrome, when the address
// bar has focus) and carry accelerators this registry cannot remap.
{
id: 'zoom-in',
label: 'Zoom In',
accelerator: acc('page.zoomIn'),
click: () => {
const win = getTargetWindow();
if (win) {
win.webContents.send('page:zoom-in');
}
},
},
{
id: 'zoom-out',
label: 'Zoom Out',
accelerator: acc('page.zoomOut'),
click: () => {
const win = getTargetWindow();
if (win) {
win.webContents.send('page:zoom-out');
}
},
},
{
id: 'zoom-reset',
label: 'Actual Size',
accelerator: acc('page.zoomReset'),
click: () => {
const win = getTargetWindow();
if (win) {
win.webContents.send('page:zoom-reset');
}
},
},
{ type: 'separator' },
{
id: 'fullscreen',
label: fullScreen ? 'Exit Full Screen' : 'Enter Full Screen',
Expand Down
53 changes: 52 additions & 1 deletion src/main/menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ function loadMenuModule(platform, options = {}) {
const settings = { shortcutOverrides: options.shortcutOverrides || {} };
const settingsListeners = [];

// Tests that invoke an item's click() need getTargetWindow() to resolve;
// without a targetWindow the electron mock has no getFocusedWindow and
// clicking throws, so the default stays the window-less template build.
const targetWindow = options.targetWindow || null;

const { mod, dialog } = loadMainModule(require.resolve('./menu'), {
electronOverrides: {
Menu: {
Expand All @@ -26,11 +31,17 @@ function loadMenuModule(platform, options = {}) {
setApplicationMenu: jest.fn(),
getApplicationMenu: jest.fn(() => menuInstance),
},
...(targetWindow && {
BrowserWindow: {
getFocusedWindow: jest.fn(() => targetWindow),
getAllWindows: jest.fn(() => [targetWindow]),
},
}),
},
extraMocks: {
[require.resolve('./windows/mainWindow')]: () => ({
isMainBrowserWindow: () => true,
getMainWindows: () => [],
getMainWindows: () => (targetWindow ? [targetWindow] : []),
createMainWindow: jest.fn(),
}),
[require.resolve('./updater')]: () => ({
Expand Down Expand Up @@ -256,6 +267,46 @@ describe('menu', () => {
}
});

test('View menu carries the zoom group ahead of Full Screen on every platform', () => {
for (const platform of ['darwin', 'win32', 'linux']) {
const send = jest.fn();
const { capturedTemplate } = loadMenuModule(platform, {
targetWindow: { webContents: { send } },
});
const view = findTopLabel(capturedTemplate, 'View');

const cases = [
['zoom-in', 'Zoom In', 'CmdOrCtrl+=', 'page:zoom-in'],
['zoom-out', 'Zoom Out', 'CmdOrCtrl+-', 'page:zoom-out'],
['zoom-reset', 'Actual Size', 'CmdOrCtrl+0', 'page:zoom-reset'],
];

for (const [id, label, accelerator, channel] of cases) {
const item = view.submenu.find((entry) => entry.id === id);
expect(item).toEqual(expect.objectContaining({ label, accelerator }));

send.mockClear();
item.click();
expect(send).toHaveBeenCalledWith(channel);
}

// Chromium order: zoom sits directly above the fullscreen toggle.
const ids = view.submenu.map((entry) => entry.id);
expect(ids.indexOf('zoom-reset')).toBeLessThan(ids.indexOf('fullscreen'));
expect(ids.indexOf('zoom-in')).toBeLessThan(ids.indexOf('zoom-out'));
}
});

test('zoom accelerators follow a user remap', () => {
const { capturedTemplate } = loadMenuModule('linux', {
shortcutOverrides: { 'page.zoomIn': 'Ctrl+Shift+Up' },
});
const view = findTopLabel(capturedTemplate, 'View');

expect(view.submenu.find((entry) => entry.id === 'zoom-in').accelerator).toBe('Ctrl+Shift+Up');
expect(view.submenu.find((entry) => entry.id === 'zoom-out').accelerator).toBe('CmdOrCtrl+-');
});

test('macOS places editMenu immediately after File', () => {
const { capturedTemplate } = loadMenuModule('darwin');
const labels = capturedTemplate.map((item) => item.label ?? item.role);
Expand Down
15 changes: 15 additions & 0 deletions src/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('page:hard-reload', handler);
return () => ipcRenderer.removeListener('page:hard-reload', handler);
},
onZoomIn: (callback) => {
const handler = () => callback();
ipcRenderer.on('page:zoom-in', handler);
return () => ipcRenderer.removeListener('page:zoom-in', handler);
},
onZoomOut: (callback) => {
const handler = () => callback();
ipcRenderer.on('page:zoom-out', handler);
return () => ipcRenderer.removeListener('page:zoom-out', handler);
},
onZoomReset: (callback) => {
const handler = () => callback();
ipcRenderer.on('page:zoom-reset', handler);
return () => ipcRenderer.removeListener('page:zoom-reset', handler);
},
onNextTab: (callback) => {
const handler = () => callback();
ipcRenderer.on('tab:next', handler);
Expand Down
72 changes: 59 additions & 13 deletions src/renderer/lib/menus.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { startRadicleInfoPolling, stopRadicleInfoPolling } from './radicle-ui.js
import { hideTabContextMenu, getActiveWebview } from './tabs.js';
import { hideBookmarkContextMenu, hideOverflowMenu } from './bookmarks-ui.js';
import { showMenuBackdrop, hideMenuBackdrop } from './menu-backdrop.js';
import { matchesShortcut } from './shortcuts.js';

const electronAPI = window.electronAPI;

Expand Down Expand Up @@ -124,6 +125,32 @@ export const updateZoomDisplay = () => {
}
};

// Zoom bounds and step, matching the hamburger menu's − / + buttons.
const ZOOM_STEP = 0.1;
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;

// Single zoom code path shared by the hamburger buttons, the View-menu
// accelerators and the renderer keydown fallback, so the zoom-level readout
// never drifts from the webview's real factor. getZoomFactor throws on a
// webview that is not yet dom-ready — reachable now that a keystroke can
// zoom a tab the moment it opens — so the read is guarded the same way
// updateZoomDisplay guards it.
const applyZoomFactor = (next) => {
const webview = getActiveWebview();
if (!webview) return;
try {
webview.setZoomFactor(next(webview.getZoomFactor()));
} catch {
return;
}
updateZoomDisplay();
};

export const zoomIn = () => applyZoomFactor((current) => Math.min(ZOOM_MAX, current + ZOOM_STEP));
export const zoomOut = () => applyZoomFactor((current) => Math.max(ZOOM_MIN, current - ZOOM_STEP));
export const zoomReset = () => applyZoomFactor(() => 1);

// Format keyboard shortcuts for the current platform
const formatShortcut = (shortcut, isMac) => {
if (!shortcut) return '';
Expand Down Expand Up @@ -208,22 +235,41 @@ export const initMenus = () => {

// Zoom controls
zoomOutBtn?.addEventListener('click', () => {
const webview = getActiveWebview();
if (webview) {
const currentZoom = webview.getZoomFactor();
const newZoom = Math.max(0.25, currentZoom - 0.1);
webview.setZoomFactor(newZoom);
updateZoomDisplay();
}
zoomOut();
});

zoomInBtn?.addEventListener('click', () => {
const webview = getActiveWebview();
if (webview) {
const currentZoom = webview.getZoomFactor();
const newZoom = Math.min(5, currentZoom + 0.1);
webview.setZoomFactor(newZoom);
updateZoomDisplay();
zoomIn();
});

// View-menu zoom accelerators arrive here so all entry points share one
// code path (issue #88 — the shortcuts README documents were never wired).
electronAPI?.onZoomIn?.(() => {
zoomIn();
});

electronAPI?.onZoomOut?.(() => {
zoomOut();
});

electronAPI?.onZoomReset?.(() => {
zoomReset();
});

// Keyboard fallback for the zoom accelerators, resolved through the shared
// shortcut registry so user remaps apply live. Needed on the Linux
// frameless setups where menu accelerators never reach the app — the same
// reason tabs.js and navigation.js carry keydown fallbacks.
window.addEventListener('keydown', (event) => {
if (matchesShortcut(event, 'page.zoomIn')) {
event.preventDefault();
zoomIn();
} else if (matchesShortcut(event, 'page.zoomOut')) {
event.preventDefault();
zoomOut();
} else if (matchesShortcut(event, 'page.zoomReset')) {
event.preventDefault();
zoomReset();
}
});

Expand Down
118 changes: 118 additions & 0 deletions src/renderer/lib/menus.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,24 @@ const loadMenusModule = async ({ platform = 'darwin', webview } = {}) => {

const documentHandlers = {};
const windowHandlers = {};
// Captures the View-menu zoom subscriptions so tests can fire them the way
// the main process would.
const zoomCallbacks = {};
const electronAPI = {
getPlatform: jest.fn().mockResolvedValue(platform),
newWindow: jest.fn(),
toggleFullscreen: jest.fn(),
showAbout: jest.fn(),
checkForUpdates: jest.fn(),
onZoomIn: jest.fn((callback) => {
zoomCallbacks.in = callback;
}),
onZoomOut: jest.fn((callback) => {
zoomCallbacks.out = callback;
}),
onZoomReset: jest.fn((callback) => {
zoomCallbacks.reset = callback;
}),
};
const tabsMocks = {
hideTabContextMenu: jest.fn(),
Expand Down Expand Up @@ -136,9 +148,14 @@ const loadMenusModule = async ({ platform = 'darwin', webview } = {}) => {

const menus = await import('./menus.js');
const stateModule = await import('./state.js');
// Same module instance menus.js resolves matchesShortcut through, so the
// platform can be pinned instead of sniffed from a jsdom-less navigator.
const shortcuts = await import('./shortcuts.js');
shortcuts.configureShortcuts({ platform, overrides: {} });

return {
menus,
shortcuts,
state: stateModule.state,
elements: {
menuButton,
Expand Down Expand Up @@ -169,6 +186,7 @@ const loadMenusModule = async ({ platform = 'darwin', webview } = {}) => {
},
mocks: {
electronAPI,
zoomCallbacks,
tabsMocks,
bookmarkMocks,
backdropMocks,
Expand Down Expand Up @@ -266,6 +284,106 @@ describe('menus', () => {
expect(mocks.electronAPI.checkForUpdates).toHaveBeenCalled();
});

test('zoom shortcuts share the hamburger buttons code path and keep the readout in sync', async () => {
let zoomFactor = 1;
const webview = {
getZoomFactor: jest.fn(() => zoomFactor),
setZoomFactor: jest.fn((next) => {
zoomFactor = next;
}),
};
const { menus, elements, handlers, mocks } = await loadMenusModule({
platform: 'darwin',
webview,
});

menus.initMenus();
await Promise.resolve();

// View-menu accelerator → main → renderer.
mocks.zoomCallbacks.in();
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(1.1);
expect(elements.zoomLevelDisplay.textContent).toBe('110%');

mocks.zoomCallbacks.out();
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(1);

mocks.zoomCallbacks.in();
mocks.zoomCallbacks.reset();
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(1);
expect(elements.zoomLevelDisplay.textContent).toBe('100%');

// Keydown fallback — the only path on the Linux frameless setups where
// menu accelerators never reach the app.
const preventDefault = jest.fn();
handlers.windowHandlers.keydown({
key: '=',
code: 'Equal',
metaKey: true,
preventDefault,
});
expect(preventDefault).toHaveBeenCalled();
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(1.1);

handlers.windowHandlers.keydown({
key: '-',
code: 'Minus',
metaKey: true,
preventDefault: jest.fn(),
});
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(1);

handlers.windowHandlers.keydown({
key: '0',
code: 'Digit0',
metaKey: true,
preventDefault: jest.fn(),
});
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(1);

// An unrelated chord must not move the zoom.
webview.setZoomFactor.mockClear();
handlers.windowHandlers.keydown({
key: '=',
code: 'Equal',
preventDefault: jest.fn(),
});
expect(webview.setZoomFactor).not.toHaveBeenCalled();
});

test('zoom clamps at both ends and tolerates a webview that is not dom-ready', async () => {
let zoomFactor = 5;
const webview = {
getZoomFactor: jest.fn(() => zoomFactor),
setZoomFactor: jest.fn((next) => {
zoomFactor = next;
}),
};
const { menus, elements, mocks } = await loadMenusModule({ platform: 'darwin', webview });

menus.initMenus();
await Promise.resolve();

elements.zoomInBtn.handlers.click();
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(5);

zoomFactor = 0.25;
elements.zoomOutBtn.handlers.click();
expect(webview.setZoomFactor).toHaveBeenLastCalledWith(0.25);

// getZoomFactor throws until the webview is attached and dom-ready.
webview.getZoomFactor.mockImplementationOnce(() => {
throw new Error('The WebView must be attached to the DOM');
});
webview.setZoomFactor.mockClear();
expect(() => mocks.zoomCallbacks.in()).not.toThrow();
expect(webview.setZoomFactor).not.toHaveBeenCalled();

// No active webview at all is a no-op, not a crash.
mocks.tabsMocks.getActiveWebview.mockReturnValueOnce(null);
expect(() => mocks.zoomCallbacks.reset()).not.toThrow();
});

test('opens and closes the bee menu while managing polling and backdrop state', async () => {
const { menus, state, elements, mocks } = await loadMenusModule();

Expand Down
Loading