Skip to content
2 changes: 2 additions & 0 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -443,6 +444,7 @@ export function App() {
<Route path="*" element={<AppRoutes />} />
</Routes>
<WindowFindHost />
<DesktopZoomIndicator />
<ProviderCliInstallLogDialogHost />
<ServerMoveOverlay />
<AppUpdateHost />
Expand Down
105 changes: 105 additions & 0 deletions apps/app/src/components/layout/DesktopZoomIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DesktopZoomIndicator />);
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(<DesktopZoomIndicator />);
expect(view.container.childElementCount).toBe(0);
view.unmount();

const { unsubscribe, view: subscribedView } = setup();
subscribedView.unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
113 changes: 113 additions & 0 deletions apps/app/src/components/layout/DesktopZoomIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);
const [fading, setFading] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(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 (
<div
className={`fixed right-0 top-(--bb-app-chrome-row-height) ${MACOS_APP_REGION_NO_DRAG_CLASS}`}
style={{ zIndex: APP_OVERLAY_LAYER.sharedPortaledOverlay }}
>
<div
role="toolbar"
aria-label="Zoom"
className={cn(
"mt-2 mr-3 flex items-center gap-1 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
fading &&
"opacity-0 motion-safe:transition-opacity motion-safe:duration-150",
)}
style={{ zoom: 1 / zoomFactor }}
onPointerEnter={() => {
hovered.current = true;
restartHideTimer();
}}
onPointerLeave={() => {
hovered.current = false;
restartHideTimer();
}}
>
<span
aria-live="polite"
className="min-w-12 text-center text-sm font-medium tabular-nums"
>
{Math.round(zoomFactor * 100)}%
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Zoom out"
onClick={() => zoom?.("out")}
>
<Icon name="Minus" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Zoom in"
onClick={() => zoom?.("in")}
>
<Icon name="Plus" />
</Button>
<span aria-hidden="true" className="mx-1 h-5 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
aria-label="Reset zoom"
disabled={zoomFactor === 1}
onClick={() => zoom?.("reset")}
>
Reset
</Button>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions apps/desktop/src/desktop-update-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
13 changes: 13 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -217,6 +218,7 @@ import {
import { createDesktopBrowserBrokerClient } from "./desktop-browser-broker-client.js";
import {
bbDesktopBrowserTabRefSchema,
bbDesktopZoomCommandSchema,
bbDesktopWindowFindRequestSchema,
} from "@bb/desktop-contract";
import {
Expand Down Expand Up @@ -1973,6 +1975,17 @@ async function finishQuit(): Promise<void> {
}

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();
});
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
type BbDesktopTheme,
type BbDesktopWindowState,
type BbDesktopWindowStateChangeHandler,
type BbDesktopZoomChangeHandler,
} from "@bb/desktop-contract";
import {
BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL,
Expand All @@ -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,
Expand Down Expand Up @@ -205,6 +207,19 @@ const browserFindResultListeners = new Set<BbDesktopBrowserFindResultHandler>();
const closeWindowRequestListeners =
new Set<BbDesktopCloseWindowRequestHandler>();
const openNewTabListeners = new Set<BbDesktopOpenNewTabHandler>();
const zoomListeners = new Set<BbDesktopZoomChangeHandler>();
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<T>(listeners: Set<T>, listener: T): () => void {
listeners.add(listener);
Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -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"]')
Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/test/server-moved.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
});

Expand Down
6 changes: 6 additions & 0 deletions packages/desktop-contract/src/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ export type BbDesktopWindowState = z.infer<typeof bbDesktopWindowStateSchema>;
export const bbDesktopThemeSchema = z.enum(["system", "light", "dark"]);
export type BbDesktopTheme = z.infer<typeof bbDesktopThemeSchema>;

export const bbDesktopZoomCommandSchema = z.enum(["in", "out", "reset"]);
export type BbDesktopZoomCommand = z.infer<typeof bbDesktopZoomCommandSchema>;

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;
Expand All @@ -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?(
Expand Down
Loading
Loading