diff --git a/apps/desktop/src/components/WindowControls.test.tsx b/apps/desktop/src/components/WindowControls.test.tsx new file mode 100644 index 000000000..e73c8186e --- /dev/null +++ b/apps/desktop/src/components/WindowControls.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { WindowControls } from './WindowControls'; + +const native = vi.hoisted(() => ({ + maximized: false, + resized: undefined as (() => void) | undefined, + unlisten: vi.fn(), + toggleMaximize: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => true })); +vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ + isMaximized: async () => native.maximized, + onResized: async (handler: () => void) => { native.resized = handler; return native.unlisten; }, + toggleMaximize: native.toggleMaximize, + minimize: vi.fn(), + close: vi.fn(), +}) })); + +let root: Root; +let host: HTMLDivElement; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + native.maximized = false; + native.resized = undefined; + native.unlisten.mockClear(); + native.toggleMaximize.mockReset().mockImplementation(async () => { native.maximized = !native.maximized; }); + host = document.createElement('div'); + root = createRoot(host); +}); +afterEach(async () => { await act(async () => root.unmount()); vi.unstubAllGlobals(); }); + +it('shows the restore icon after maximizing and tracks external window resizing', async () => { + await act(async () => root.render( { throw Error(error); }} t={key => key} />)); + const button = host.querySelector('[aria-label="windowMenu.maximize"]')!; + expect(button.getAttribute('aria-pressed')).toBe('false'); + expect(button.querySelector('.lucide-square')).not.toBeNull(); + + await act(async () => button.click()); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(button.querySelector('.lucide-copy')).not.toBeNull(); + + await act(async () => { native.maximized = false; native.resized?.(); }); + expect(button.getAttribute('aria-pressed')).toBe('false'); + expect(button.querySelector('.lucide-square')).not.toBeNull(); + expect(native.unlisten).not.toHaveBeenCalled(); +}); diff --git a/apps/desktop/src/components/WindowControls.tsx b/apps/desktop/src/components/WindowControls.tsx index 96de88fc5..e4529afa6 100644 --- a/apps/desktop/src/components/WindowControls.tsx +++ b/apps/desktop/src/components/WindowControls.tsx @@ -1,17 +1,49 @@ +import { useEffect, useState } from "react"; +import { isTauri } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Minus, Square, X } from "lucide-react"; +import { Copy, Minus, Square, X } from "lucide-react"; import type { Translate } from "../i18n"; export function WindowControls({ onError, t }: { onError: (message: string) => void; t: Translate }) { + const [maximized, setMaximized] = useState(false); + + useEffect(() => { + if (!isTauri()) return; + const appWindow = getCurrentWindow(); + let active = true; + let unlisten: (() => void) | undefined; + const refresh = () => { + void appWindow.isMaximized().then(value => { + if (active) setMaximized(value); + }).catch((error: unknown) => { + if (active) onError(String(error)); + }); + }; + refresh(); + void appWindow.onResized(refresh).then(stop => { + if (active) unlisten = stop; + else stop(); + }).catch((error: unknown) => { + if (active) onError(String(error)); + }); + return () => { active = false; unlisten?.(); }; + }, [onError]); + const run = (action: "minimize" | "toggleMaximize" | "close") => { - void getCurrentWindow()[action]().catch((error: unknown) => onError(String(error))); + void getCurrentWindow()[action]().then(() => { + if (action === "toggleMaximize") { + void getCurrentWindow().isMaximized().then(setMaximized).catch((error: unknown) => onError(String(error))); + } + }).catch((error: unknown) => onError(String(error))); }; return (
+ aria-pressed={maximized} onClick={() => run("toggleMaximize")}> + {maximized ? : } +