authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 05:24:38-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 02:24:38-07:00
log06404107261cae24394d6abe39984fb445c5aff5
treee8a72e90a199b29e65d6f8c64e5aa1512fc5f387
parent2c7267ad43a05cf3e30343400c76fd9ac47698e7
signature Signed by PGP key B5690EEEBB952194

fix(desktop): zoom shortcuts no longer die when the preview browser has focus (#5691)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 108 insertions(+), 23 deletions(-)

apps/desktop/src/app/DesktopLifecycle.test.ts+1
......@@ -78,6 +78,7 @@ describe("DesktopLifecycle", () => {
7878 handleBackendNotReady: Effect.void,
7979 flushMainWindowBounds: Effect.void,
8080 dispatchMenuAction: () => Effect.void,
81 zoomMain: () => Effect.void,
8182 syncAppearance: Effect.void,
8283 });
8384
apps/desktop/src/backend/DesktopBackendPool.test.ts+1
......@@ -91,6 +91,7 @@ function makePoolLayer(
9191 handleBackendNotReady: Effect.void,
9292 flushMainWindowBounds: Effect.void,
9393 dispatchMenuAction: () => Effect.die("unexpected menu action"),
94 zoomMain: () => Effect.die("unexpected zoom"),
9495 syncAppearance: Effect.void,
9596 } satisfies DesktopWindow.DesktopWindow["Service"]),
9697 ),
apps/desktop/src/window/DesktopApplicationMenu.test.ts+61-19
......@@ -81,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
8181 handleBackendNotReady: Effect.void,
8282 flushMainWindowBounds: Effect.void,
8383 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
84 zoomMain: (direction) =>
85 Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
8486 syncAppearance: Effect.void,
8587 } satisfies DesktopWindow.DesktopWindow["Service"]);
8688
......@@ -94,6 +96,30 @@ const makeElectronMenuLayer = (
9496 showContextMenu: () => Effect.succeed(Option.none()),
9597 } satisfies ElectronMenu.ElectronMenu["Service"]);
9698
99const configureMenu = (
100 selectedAction: Deferred.Deferred<string>,
101 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
102) =>
103 Effect.gen(function* () {
104 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
105 yield* menu.configure;
106 }).pipe(
107 Effect.provide(
108 DesktopApplicationMenu.layer.pipe(
109 Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)),
110 Layer.provideMerge(makeDesktopWindowLayer(selectedAction)),
111 Layer.provideMerge(desktopUpdatesLayer),
112 Layer.provideMerge(electronDialogLayer),
113 Layer.provideMerge(electronAppLayer),
114 Layer.provideMerge(
115 DesktopEnvironment.layer(environmentInput).pipe(
116 Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
117 ),
118 ),
119 ),
120 ),
121 );
122
97123describe("DesktopApplicationMenu", () => {
98124 it.effect("installs the native menu and routes Settings through DesktopWindow", () =>
99125 Effect.gen(function* () {
......@@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => {
101127 const applicationMenuTemplate =
102128 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
103129
104 yield* Effect.gen(function* () {
105 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
106 yield* menu.configure;
107 }).pipe(
108 Effect.provide(
109 DesktopApplicationMenu.layer.pipe(
110 Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)),
111 Layer.provideMerge(makeDesktopWindowLayer(selectedAction)),
112 Layer.provideMerge(desktopUpdatesLayer),
113 Layer.provideMerge(electronDialogLayer),
114 Layer.provideMerge(electronAppLayer),
115 Layer.provideMerge(
116 DesktopEnvironment.layer(environmentInput).pipe(
117 Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
118 ),
119 ),
120 ),
121 ),
122 );
130 yield* configureMenu(selectedAction, applicationMenuTemplate);
123131
124132 const template = yield* Deferred.await(applicationMenuTemplate);
125133 const fileMenu = template.find((item) => item.label === "File");
......@@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => {
138146 assert.equal(yield* Deferred.await(selectedAction), "open-settings");
139147 }),
140148 );
149
150 // Zoom must route through DesktopWindow.zoomMain instead of the Electron
151 // zoom roles: the roles zoom whichever webContents has focus, which breaks
152 // app zoom while an embedded preview WebContentsView holds focus.
153 it.effect("routes View menu zoom to the main window instead of zoom roles", () =>
154 Effect.gen(function* () {
155 const selectedAction = yield* Deferred.make<string>();
156 const applicationMenuTemplate =
157 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
158
159 yield* configureMenu(selectedAction, applicationMenuTemplate);
160
161 const template = yield* Deferred.await(applicationMenuTemplate);
162 const viewMenu = template.find((item) => item.label === "View");
163 assert.isDefined(viewMenu);
164 if (!Array.isArray(viewMenu.submenu)) {
165 throw new Error("Expected View menu submenu to be an array.");
166 }
167
168 assert.isUndefined(
169 viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")),
170 );
171
172 const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In");
173 assert.isDefined(zoomIn);
174 assert.equal(zoomIn.accelerator, "CmdOrCtrl+=");
175 if (typeof zoomIn.click !== "function") {
176 throw new Error("Expected Zoom In menu item to have a click handler.");
177 }
178
179 zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
180 assert.equal(yield* Deferred.await(selectedAction), "zoom-in");
181 }),
182 );
141183});
apps/desktop/src/window/DesktopApplicationMenu.ts+25-4
......@@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function
4949 yield* desktopWindow.dispatchMenuAction(action);
5050});
5151
52const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
53 direction: DesktopWindow.MainWindowZoomDirection,
54): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
55 const desktopWindow = yield* DesktopWindow.DesktopWindow;
56 yield* desktopWindow.zoomMain(direction);
57});
58
5259const checkForUpdatesFromMenu = Effect.gen(function* () {
5360 const updates = yield* DesktopUpdates.DesktopUpdates;
5461 const electronDialog = yield* ElectronDialog.ElectronDialog;
......@@ -127,6 +134,9 @@ export const make = Effect.gen(function* () {
127134 const settingsClick = () => {
128135 runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
129136 };
137 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
138 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
139 };
130140 const template: Electron.MenuItemConstructorOptions[] = [];
131141
132142 if (environment.platform === "darwin") {
......@@ -181,10 +191,21 @@ export const make = Effect.gen(function* () {
181191 { role: "forceReload" },
182192 { role: "toggleDevTools" },
183193 { type: "separator" },
184 { role: "resetZoom" },
185 { role: "zoomIn", accelerator: "CmdOrCtrl+=" },
186 { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
187 { role: "zoomOut" },
194 /*
195 Not the zoom roles: those act on the focused webContents, so with
196 an embedded preview WebContentsView focused they zoom the guest
197 page and the app UI appears stuck. These always zoom the main
198 window (see DesktopWindow.zoomMain).
199 */
200 { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
201 { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
202 {
203 label: "Zoom In",
204 accelerator: "CmdOrCtrl+Plus",
205 visible: false,
206 click: zoomClick("in"),
207 },
208 { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") },
188209 { type: "separator" },
189210 { role: "togglefullscreen" },
190211 ],
apps/desktop/src/window/DesktopWindow.ts+20
......@@ -61,6 +61,8 @@ export type DesktopWindowError =
6161 | ElectronWindow.ElectronWindowCreateError
6262 | PreviewManager.PreviewManagerError;
6363
64export type MainWindowZoomDirection = "in" | "out" | "reset";
65
6466export class DesktopWindow extends Context.Service<
6567 DesktopWindow,
6668 {
......@@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service<
8789 readonly handleBackendNotReady: Effect.Effect<void>;
8890 readonly flushMainWindowBounds: Effect.Effect<void>;
8991 readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;
92 // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut`
93 // menu roles act on whichever webContents has keyboard focus, so with an
94 // embedded preview WebContentsView (or DevTools) focused they zoom the
95 // guest page instead of the app UI. The menu routes here to always target
96 // the main window.
97 readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect<void>;
9098 readonly syncAppearance: Effect.Effect<void>;
9199 }
92100>()("@t3tools/desktop/window/DesktopWindow") {}
......@@ -836,6 +844,18 @@ export const make = Effect.gen(function* () {
836844
837845 send();
838846 }),
847 zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) {
848 yield* Effect.annotateCurrentSpan({ direction });
849 const window = yield* focusedMainWindow;
850 if (Option.isNone(window) || window.value.isDestroyed()) {
851 return;
852 }
853 const webContents = window.value.webContents;
854 // Same step size as the Electron zoomIn/zoomOut menu roles.
855 webContents.setZoomLevel(
856 direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5),
857 );
858 }),
839859 syncAppearance: Effect.gen(function* () {
840860 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
841861 yield* electronWindow.syncAllAppearance((window) =>