diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 6adc5dcbf..132127fbc 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", + "core:webview:allow-set-webview-zoom", "core:window:allow-start-dragging", "core:window:allow-show", "core:window:allow-close", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index c58b846b3..776a9c2a7 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -58,10 +58,12 @@ import { SidebarProvider, SidebarTrigger, } from "@/shared/ui/sidebar"; +import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; type AppView = "home" | "channel" | "settings" | "agents"; type MainView = Exclude; export function AppShell() { + useWebviewZoomShortcuts(); const [selectedView, setSelectedView] = React.useState("home"); const [settingsSection, setSettingsSection] = React.useState( DEFAULT_SETTINGS_SECTION, @@ -182,7 +184,6 @@ export function AppShell() { ), [messageProfilesQuery.data?.profiles, profileQuery.data], ); - const timelineMessages = React.useMemo( () => formatTimelineMessages( diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts new file mode 100644 index 000000000..1c4c4d71d --- /dev/null +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -0,0 +1,90 @@ +import * as React from "react"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; + +const DEFAULT_ZOOM_FACTOR = 1; +const MIN_ZOOM_FACTOR = 0.2; +const MAX_ZOOM_FACTOR = 10; +const ZOOM_STEP = 0.2; + +type ZoomAction = "increase" | "decrease" | "reset"; + +function getZoomAction(event: KeyboardEvent): ZoomAction | null { + if (!(event.metaKey || event.ctrlKey) || event.altKey) { + return null; + } + + if ( + event.key === "+" || + event.key === "=" || + event.code === "Equal" || + event.code === "NumpadAdd" + ) { + return "increase"; + } + + if ( + !event.shiftKey && + (event.key === "-" || + event.code === "Minus" || + event.code === "NumpadSubtract") + ) { + return "decrease"; + } + + if ( + !event.shiftKey && + (event.key === "0" || event.code === "Digit0" || event.code === "Numpad0") + ) { + return "reset"; + } + + return null; +} + +function getNextZoomFactor(action: ZoomAction, zoomFactor: number) { + if (action === "reset") { + return DEFAULT_ZOOM_FACTOR; + } + + if (action === "increase") { + return Math.min(zoomFactor + ZOOM_STEP, MAX_ZOOM_FACTOR); + } + + return Math.max(zoomFactor - ZOOM_STEP, MIN_ZOOM_FACTOR); +} + +export function useWebviewZoomShortcuts() { + const zoomFactorRef = React.useRef(DEFAULT_ZOOM_FACTOR); + + React.useEffect(() => { + const webview = getCurrentWebview(); + + function handleKeyDown(event: KeyboardEvent) { + const action = getZoomAction(event); + if (!action) { + return; + } + + event.preventDefault(); + + const previousZoomFactor = zoomFactorRef.current; + const nextZoomFactor = getNextZoomFactor(action, previousZoomFactor); + + if (nextZoomFactor === previousZoomFactor) { + return; + } + + zoomFactorRef.current = nextZoomFactor; + + void webview.setZoom(nextZoomFactor).catch((error) => { + zoomFactorRef.current = previousZoomFactor; + console.error("Failed to update webview zoom", error); + }); + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b192885e8..33ed504bb 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -316,6 +316,7 @@ declare global { interface Window { __SPROUT_E2E__?: E2eConfig; __SPROUT_E2E_COMMANDS__?: string[]; + __SPROUT_E2E_WEBVIEW_ZOOM__?: number; __SPROUT_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; content: string; @@ -3207,6 +3208,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonas(); mockWindows("main"); window.__SPROUT_E2E_COMMANDS__ = []; + window.__SPROUT_E2E_WEBVIEW_ZOOM__ = 1; window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({ channelName, content, @@ -3542,6 +3544,11 @@ export function maybeInstallE2eTauriMocks() { } return disconnectMockSocket((payload as { id: number }).id); + case "plugin:webview|set_webview_zoom": + window.__SPROUT_E2E_WEBVIEW_ZOOM__ = ( + payload as { value: number } + ).value; + return; default: throw new Error(`Unsupported mocked Tauri command: ${command}`); } diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 321064cc7..cf1ffd16f 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -215,6 +215,45 @@ test("opens settings with the keyboard shortcut and updates theme", async ({ await expect(page.getByTestId("chat-title")).toHaveText("Home"); }); +test("supports webview zoom keyboard shortcuts", async ({ page }) => { + await page.goto("/"); + + await page.keyboard.press( + process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal", + ); + + await expect + .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) + .toBe(1.2); + + await page.keyboard.press( + process.platform === "darwin" ? "Meta+Minus" : "Control+Minus", + ); + + await expect + .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) + .toBe(1); + + await page.keyboard.press( + process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal", + ); + await page.keyboard.press( + process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal", + ); + + await expect + .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) + .toBe(1.4); + + await page.keyboard.press( + process.platform === "darwin" ? "Meta+Digit0" : "Control+Digit0", + ); + + await expect + .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) + .toBe(1); +}); + test("shows doctor checks for local sprout tooling", async ({ page }) => { await page.goto("/");