diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index fd8098a9ad5..4a24a5cb969 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -66,6 +66,7 @@ import { } from "./lib/route-paths"; import { AppCommandProvider } from "./components/commands/AppCommandProvider"; import { WindowFindHost } from "./components/layout/WindowFindHost"; +import { DesktopZoomIndicator } from "./components/layout/DesktopZoomIndicator"; import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install"; import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay"; import { AppUpdateHost } from "./components/app-update/AppUpdateHost"; @@ -443,6 +444,7 @@ export function App() { } /> + diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx new file mode 100644 index 00000000000..c7f35e18d73 --- /dev/null +++ b/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DesktopZoomIndicator } from "./DesktopZoomIndicator"; + +describe("DesktopZoomIndicator", () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + delete window.bbDesktop; + }); + + function setup() { + const changeListener = { current: (_factor: number) => undefined }; + const unsubscribe = vi.fn(); + const zoom = vi.fn(); + window.bbDesktop = { + onZoomChange: vi.fn((callback) => { + changeListener.current = callback; + return unsubscribe; + }), + zoom, + } as unknown as Window["bbDesktop"]; + const view = render(); + return { + emitZoomChange: (factor: number) => changeListener.current(factor), + unsubscribe, + view, + zoom, + }; + } + + it("stays unrendered until a zoom change", () => { + setup(); + + expect(screen.queryByRole("toolbar", { name: "Zoom" })).toBeNull(); + }); + + it("shows the changed percentage and dispatches zoom commands", () => { + const { emitZoomChange, zoom } = setup(); + act(() => emitZoomChange(1.25)); + + expect(screen.getByText("125%")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Zoom out" })); + fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); + fireEvent.click(screen.getByRole("button", { name: "Reset zoom" })); + expect(zoom.mock.calls).toEqual([["out"], ["in"], ["reset"]]); + }); + + it("disables Reset at 100 percent", () => { + const { emitZoomChange } = setup(); + act(() => emitZoomChange(1)); + + expect( + screen + .getByRole("button", { name: "Reset zoom" }) + .hasAttribute("disabled"), + ).toBe(true); + }); + + it("appears without an animation and fades out after two seconds", () => { + vi.useFakeTimers(); + const { emitZoomChange } = setup(); + act(() => emitZoomChange(1.25)); + const toolbar = screen.getByRole("toolbar", { name: "Zoom" }); + expect(toolbar.className).not.toMatch(/animate-in|opacity-0/); + + act(() => vi.advanceTimersByTime(2000)); + expect(toolbar.className).toContain("opacity-0"); + + act(() => emitZoomChange(1.5)); + expect(toolbar.className).not.toContain("opacity-0"); + + act(() => vi.advanceTimersByTime(2150)); + expect(screen.queryByRole("toolbar", { name: "Zoom" })).toBeNull(); + }); + + it("stays visible while hovered", () => { + vi.useFakeTimers(); + const { emitZoomChange } = setup(); + act(() => emitZoomChange(1.25)); + fireEvent.pointerEnter(screen.getByRole("toolbar", { name: "Zoom" })); + act(() => vi.advanceTimersByTime(2500)); + + expect(screen.getByRole("toolbar", { name: "Zoom" })).toBeTruthy(); + }); + + it("renders nothing without zoom notifications and unsubscribes", () => { + window.bbDesktop = {} as Window["bbDesktop"]; + const view = render(); + expect(view.container.childElementCount).toBe(0); + view.unmount(); + + const { unsubscribe, view: subscribedView } = setup(); + subscribedView.unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.tsx new file mode 100644 index 00000000000..d360b0ae323 --- /dev/null +++ b/apps/app/src/components/layout/DesktopZoomIndicator.tsx @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { APP_OVERLAY_LAYER } from "@/components/ui/app-overlay-layers"; +import { + getBbDesktopInfo, + MACOS_APP_REGION_NO_DRAG_CLASS, +} from "@/lib/bb-desktop"; + +const HIDE_DELAY_MS = 2000; +const FADE_OUT_MS = 150; + +export function DesktopZoomIndicator() { + const desktop = getBbDesktopInfo(); + const onZoomChange = desktop?.onZoomChange; + const zoom = desktop?.zoom; + const [zoomFactor, setZoomFactor] = useState(null); + const [fading, setFading] = useState(false); + const hideTimer = useRef>(undefined); + const hovered = useRef(false); + + const restartHideTimer = useCallback(() => { + clearTimeout(hideTimer.current); + setFading(false); + if (!hovered.current) { + hideTimer.current = setTimeout(() => { + setFading(true); + hideTimer.current = setTimeout(() => setZoomFactor(null), FADE_OUT_MS); + }, HIDE_DELAY_MS); + } + }, []); + + useEffect(() => { + const unsubscribe = onZoomChange?.((factor) => { + setZoomFactor(factor); + restartHideTimer(); + }); + return () => { + unsubscribe?.(); + clearTimeout(hideTimer.current); + }; + }, [onZoomChange, restartHideTimer]); + + if (zoomFactor === null) { + return null; + } + + return ( +
+
{ + hovered.current = true; + restartHideTimer(); + }} + onPointerLeave={() => { + hovered.current = false; + restartHideTimer(); + }} + > + + {Math.round(zoomFactor * 100)}% + + + +
+
+ ); +} diff --git a/apps/desktop/src/desktop-update-ipc.ts b/apps/desktop/src/desktop-update-ipc.ts index 900ce6f24ff..19ac09391f1 100644 --- a/apps/desktop/src/desktop-update-ipc.ts +++ b/apps/desktop/src/desktop-update-ipc.ts @@ -3,6 +3,7 @@ export const BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL = export const BB_DESKTOP_GET_INFO_CHANNEL = "bb-desktop:get-info"; export const BB_DESKTOP_INFO_CHANGED_CHANNEL = "bb-desktop:info-changed"; export const BB_DESKTOP_INSTALL_UPDATE_CHANNEL = "bb-desktop:install-update"; +export const BB_DESKTOP_ZOOM_COMMAND_CHANNEL = "bb-desktop:zoom-command"; export const BB_DESKTOP_SET_THEME_CHANNEL = "bb-desktop:set-theme"; export const BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL = "bb-desktop:open-external-url"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0649bcd955b..cb4a4578379 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -184,6 +184,7 @@ import { BB_DESKTOP_INSTALL_UPDATE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, + BB_DESKTOP_ZOOM_COMMAND_CHANNEL, } from "./desktop-update-ipc.js"; import { BB_DESKTOP_APP_COMMAND_CHANNEL, @@ -217,6 +218,7 @@ import { import { createDesktopBrowserBrokerClient } from "./desktop-browser-broker-client.js"; import { bbDesktopBrowserTabRefSchema, + bbDesktopZoomCommandSchema, bbDesktopWindowFindRequestSchema, } from "@bb/desktop-contract"; import { @@ -1973,6 +1975,17 @@ async function finishQuit(): Promise { } function registerDesktopUpdateIpc(): void { + ipcMain.on(BB_DESKTOP_ZOOM_COMMAND_CHANNEL, (event, payload: unknown) => { + const parsed = bbDesktopZoomCommandSchema.safeParse(payload); + const webContents = resolveApplicationWindow(event.sender)?.webContents; + if (!parsed.success || webContents === undefined) { + return; + } + webContents.zoomLevel = + parsed.data === "reset" + ? 0 + : webContents.zoomLevel + (parsed.data === "in" ? 0.5 : -0.5); + }); ipcMain.handle(BB_DESKTOP_GET_INFO_CHANNEL, () => { return getCurrentDesktopInfo(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 503a2876431..71bb593c78c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -42,6 +42,7 @@ import { type BbDesktopTheme, type BbDesktopWindowState, type BbDesktopWindowStateChangeHandler, + type BbDesktopZoomChangeHandler, } from "@bb/desktop-contract"; import { BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, @@ -50,6 +51,7 @@ import { BB_DESKTOP_INSTALL_UPDATE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, + BB_DESKTOP_ZOOM_COMMAND_CHANNEL, } from "./desktop-update-ipc.js"; import { BB_DESKTOP_BROWSER_ATTACH_CHANNEL, @@ -205,6 +207,19 @@ const browserFindResultListeners = new Set(); const closeWindowRequestListeners = new Set(); const openNewTabListeners = new Set(); +const zoomListeners = new Set(); +let lastZoomFactor = webFrame.getZoomFactor(); + +function notifyZoomChangeIfChanged(): void { + const zoomFactor = webFrame.getZoomFactor(); + if (zoomFactor === lastZoomFactor) { + return; + } + lastZoomFactor = zoomFactor; + for (const listener of zoomListeners) { + listener(zoomFactor); + } +} function addListener(listeners: Set, listener: T): () => void { listeners.add(listener); @@ -407,6 +422,12 @@ const bbDesktopApi: BbDesktopApi = { ): BbDesktopInfoUnsubscribe { return addListener(windowStateListeners, listener); }, + onZoomChange(listener): BbDesktopInfoUnsubscribe { + return addListener(zoomListeners, listener); + }, + zoom(command): void { + ipcRenderer.send(BB_DESKTOP_ZOOM_COMMAND_CHANNEL, command); + }, onOpenNewTab(listener): BbDesktopInfoUnsubscribe { return addListener(openNewTabListeners, listener); }, @@ -538,6 +559,7 @@ forwardParsed( ); if (typeof window !== "undefined" && typeof document !== "undefined") { + window.addEventListener("resize", notifyZoomChangeIfChanged); window.addEventListener("DOMContentLoaded", () => { document .querySelector('[data-testid="bb-startup-retry"]') diff --git a/apps/desktop/test/server-moved.test.ts b/apps/desktop/test/server-moved.test.ts index 3f7c230ff72..7ce36893817 100644 --- a/apps/desktop/test/server-moved.test.ts +++ b/apps/desktop/test/server-moved.test.ts @@ -579,7 +579,9 @@ describe("createServerMovedWatcher", () => { await writeServerMovedFile(harness.dataDir, movedFile()); harness.watcher.start(); await harness.timers.flush(); - expect(harness.confirmMove).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(harness.confirmMove).toHaveBeenCalledOnce(); + }); await writeServerMovedFile( harness.dataDir, @@ -596,11 +598,13 @@ describe("createServerMovedWatcher", () => { expect(harness.timers.pendingCount()).toBe(1); await harness.timers.flush(); - expect(harness.confirmMove).toHaveBeenCalledTimes(2); - expect(harness.onMove).toHaveBeenCalledExactlyOnceWith({ - ...CONNECT_MOVE, - moveId: "move-3", + await vi.waitFor(() => { + expect(harness.onMove).toHaveBeenCalledExactlyOnceWith({ + ...CONNECT_MOVE, + moveId: "move-3", + }); }); + expect(harness.confirmMove).toHaveBeenCalledTimes(2); harness.watcher.stop(); }); diff --git a/packages/desktop-contract/src/info.ts b/packages/desktop-contract/src/info.ts index b44c7755bea..a15912088be 100644 --- a/packages/desktop-contract/src/info.ts +++ b/packages/desktop-contract/src/info.ts @@ -36,11 +36,15 @@ export type BbDesktopWindowState = z.infer; export const bbDesktopThemeSchema = z.enum(["system", "light", "dark"]); export type BbDesktopTheme = z.infer; +export const bbDesktopZoomCommandSchema = z.enum(["in", "out", "reset"]); +export type BbDesktopZoomCommand = z.infer; + export type BbDesktopInfoChangeHandler = (info: BbDesktopInfo) => void; export type BbDesktopInfoUnsubscribe = () => void; export type BbDesktopWindowStateChangeHandler = ( state: BbDesktopWindowState, ) => void; +export type BbDesktopZoomChangeHandler = (zoomFactor: number) => void; export type BbDesktopOpenNewTabHandler = () => void; export type BbDesktopAppCommandHandler = (command: AppCommandId) => void; export type BbDesktopCloseWindowRequestHandler = () => boolean; @@ -55,6 +59,8 @@ export interface BbDesktopApi extends BbDesktopInfo { onWindowStateChange?( listener: BbDesktopWindowStateChangeHandler, ): BbDesktopInfoUnsubscribe; + onZoomChange?(listener: BbDesktopZoomChangeHandler): BbDesktopInfoUnsubscribe; + zoom?(command: BbDesktopZoomCommand): void; onOpenNewTab?(listener: BbDesktopOpenNewTabHandler): BbDesktopInfoUnsubscribe; onAppCommand?(listener: BbDesktopAppCommandHandler): BbDesktopInfoUnsubscribe; onCloseWindowRequest?( diff --git a/plugins/provider-pi/src/bridge/test-support.ts b/plugins/provider-pi/src/bridge/test-support.ts index 7081a2c6afe..fcb274600d9 100644 --- a/plugins/provider-pi/src/bridge/test-support.ts +++ b/plugins/provider-pi/src/bridge/test-support.ts @@ -34,6 +34,15 @@ const threadDeltaParamsSchema = z.object({ deltas: z.array(z.record(z.string(), z.unknown())), }); +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + export interface StartFakePiBridgeOptions { prefix: string; sessionDir?: (workspaceDir: string) => string; @@ -165,6 +174,10 @@ export async function startFakePiBridge( }, async teardown() { await experimental_closeAllForTests(); + await bridge.waitFor( + () => bridge.readProcessLog().spawned.every((pid) => !isRunning(pid)), + "fake Pi processes to exit", + ); harness.restore(); vi.unstubAllEnvs(); rmSync(workspaceDir, { recursive: true, force: true });