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", () => {...@@ -78,6 +78,7 @@ describe("DesktopLifecycle", () => {
78 handleBackendNotReady: Effect.void,78 handleBackendNotReady: Effect.void,
79 flushMainWindowBounds: Effect.void,79 flushMainWindowBounds: Effect.void,
80 dispatchMenuAction: () => Effect.void,80 dispatchMenuAction: () => Effect.void,
81 zoomMain: () => Effect.void,
81 syncAppearance: Effect.void,82 syncAppearance: Effect.void,
82 });83 });
8384
apps/desktop/src/backend/DesktopBackendPool.test.ts+1
...@@ -91,6 +91,7 @@ function makePoolLayer(...@@ -91,6 +91,7 @@ function makePoolLayer(
91 handleBackendNotReady: Effect.void,91 handleBackendNotReady: Effect.void,
92 flushMainWindowBounds: Effect.void,92 flushMainWindowBounds: Effect.void,
93 dispatchMenuAction: () => Effect.die("unexpected menu action"),93 dispatchMenuAction: () => Effect.die("unexpected menu action"),
94 zoomMain: () => Effect.die("unexpected zoom"),
94 syncAppearance: Effect.void,95 syncAppearance: Effect.void,
95 } satisfies DesktopWindow.DesktopWindow["Service"]),96 } satisfies DesktopWindow.DesktopWindow["Service"]),
96 ),97 ),
apps/desktop/src/window/DesktopApplicationMenu.test.ts+61-19
...@@ -81,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>...@@ -81,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
81 handleBackendNotReady: Effect.void,81 handleBackendNotReady: Effect.void,
82 flushMainWindowBounds: Effect.void,82 flushMainWindowBounds: Effect.void,
83 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),83 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
84 zoomMain: (direction) =>
85 Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
84 syncAppearance: Effect.void,86 syncAppearance: Effect.void,
85 } satisfies DesktopWindow.DesktopWindow["Service"]);87 } satisfies DesktopWindow.DesktopWindow["Service"]);
8688
...@@ -94,6 +96,30 @@ const makeElectronMenuLayer = (...@@ -94,6 +96,30 @@ const makeElectronMenuLayer = (
94 showContextMenu: () => Effect.succeed(Option.none()),96 showContextMenu: () => Effect.succeed(Option.none()),
95 } satisfies ElectronMenu.ElectronMenu["Service"]);97 } 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
97describe("DesktopApplicationMenu", () => {123describe("DesktopApplicationMenu", () => {
98 it.effect("installs the native menu and routes Settings through DesktopWindow", () =>124 it.effect("installs the native menu and routes Settings through DesktopWindow", () =>
99 Effect.gen(function* () {125 Effect.gen(function* () {
...@@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => {...@@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => {
101 const applicationMenuTemplate =127 const applicationMenuTemplate =
102 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();128 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
103129
104 yield* Effect.gen(function* () {130 yield* configureMenu(selectedAction, applicationMenuTemplate);
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 );
123131
124 const template = yield* Deferred.await(applicationMenuTemplate);132 const template = yield* Deferred.await(applicationMenuTemplate);
125 const fileMenu = template.find((item) => item.label === "File");133 const fileMenu = template.find((item) => item.label === "File");
...@@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => {...@@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => {
138 assert.equal(yield* Deferred.await(selectedAction), "open-settings");146 assert.equal(yield* Deferred.await(selectedAction), "open-settings");
139 }),147 }),
140 );148 );
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 );
141});183});
apps/desktop/src/window/DesktopApplicationMenu.ts+25-4
...@@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function...@@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function
49 yield* desktopWindow.dispatchMenuAction(action);49 yield* desktopWindow.dispatchMenuAction(action);
50});50});
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
52const checkForUpdatesFromMenu = Effect.gen(function* () {59const checkForUpdatesFromMenu = Effect.gen(function* () {
53 const updates = yield* DesktopUpdates.DesktopUpdates;60 const updates = yield* DesktopUpdates.DesktopUpdates;
54 const electronDialog = yield* ElectronDialog.ElectronDialog;61 const electronDialog = yield* ElectronDialog.ElectronDialog;
...@@ -127,6 +134,9 @@ export const make = Effect.gen(function* () {...@@ -127,6 +134,9 @@ export const make = Effect.gen(function* () {
127 const settingsClick = () => {134 const settingsClick = () => {
128 runMenuEffect("open-settings", dispatchMenuAction("open-settings"));135 runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
129 };136 };
137 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
138 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
139 };
130 const template: Electron.MenuItemConstructorOptions[] = [];140 const template: Electron.MenuItemConstructorOptions[] = [];
131141
132 if (environment.platform === "darwin") {142 if (environment.platform === "darwin") {
...@@ -181,10 +191,21 @@ export const make = Effect.gen(function* () {...@@ -181,10 +191,21 @@ export const make = Effect.gen(function* () {
181 { role: "forceReload" },191 { role: "forceReload" },
182 { role: "toggleDevTools" },192 { role: "toggleDevTools" },
183 { type: "separator" },193 { type: "separator" },
184 { role: "resetZoom" },194 /*
185 { role: "zoomIn", accelerator: "CmdOrCtrl+=" },195 Not the zoom roles: those act on the focused webContents, so with
186 { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },196 an embedded preview WebContentsView focused they zoom the guest
187 { role: "zoomOut" },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") },
188 { type: "separator" },209 { type: "separator" },
189 { role: "togglefullscreen" },210 { role: "togglefullscreen" },
190 ],211 ],
apps/desktop/src/window/DesktopWindow.ts+20
...@@ -61,6 +61,8 @@ export type DesktopWindowError =...@@ -61,6 +61,8 @@ export type DesktopWindowError =
61 | ElectronWindow.ElectronWindowCreateError61 | ElectronWindow.ElectronWindowCreateError
62 | PreviewManager.PreviewManagerError;62 | PreviewManager.PreviewManagerError;
6363
64export type MainWindowZoomDirection = "in" | "out" | "reset";
65
64export class DesktopWindow extends Context.Service<66export class DesktopWindow extends Context.Service<
65 DesktopWindow,67 DesktopWindow,
66 {68 {
...@@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service<...@@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service<
87 readonly handleBackendNotReady: Effect.Effect<void>;89 readonly handleBackendNotReady: Effect.Effect<void>;
88 readonly flushMainWindowBounds: Effect.Effect<void>;90 readonly flushMainWindowBounds: Effect.Effect<void>;
89 readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;91 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>;
90 readonly syncAppearance: Effect.Effect<void>;98 readonly syncAppearance: Effect.Effect<void>;
91 }99 }
92>()("@t3tools/desktop/window/DesktopWindow") {}100>()("@t3tools/desktop/window/DesktopWindow") {}
...@@ -836,6 +844,18 @@ export const make = Effect.gen(function* () {...@@ -836,6 +844,18 @@ export const make = Effect.gen(function* () {
836844
837 send();845 send();
838 }),846 }),
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 }),
839 syncAppearance: Effect.gen(function* () {859 syncAppearance: Effect.gen(function* () {
840 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;860 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
841 yield* electronWindow.syncAllAppearance((window) =>861 yield* electronWindow.syncAllAppearance((window) =>