From f6d7facd82a5a749bc0c1c00a19504b11581f3ac Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:58:49 +0800 Subject: [PATCH] fix(window): stop macOS traffic lights jittering and fix local open from remote windows (#641) AppKit puts the title bar container and window buttons back at their defaults on every live resize step, title change and fullscreen exit. They were corrected from window notifications after that layout had been drawn, so the buttons bounced between positions, or stayed at the defaults when no notification followed. The frames are now corrected in a main run loop observer ordered just before the Core Animation commit, project windows are created hidden on macOS so the buttons are placed before the first frame, and repeated title bar height updates are skipped. Choosing This Window for a local project in a remote window was routed to the remote host or skipped when the path matched the remote root. open_project and light_edit_open now run on the local backend and leave remote mode once they succeed, project trust for these opens is checked locally, and a failed open cancels the pending close. --- Cargo.lock | 2 + src-tauri/Cargo.toml | 1 + src-tauri/src/titlebar.rs | 59 +++++++++++++--------- src-tauri/src/windows.rs | 11 ++++ src/features/app/__tests__/windows.test.ts | 46 ++++++++++++++++- src/features/app/windows.ts | 7 +-- src/features/project-trust/index.tsx | 33 ++++++++---- src/features/project-trust/trust.test.tsx | 29 +++++++++++ src/platform/project.ts | 2 +- src/platform/projectTrust.ts | 12 +++-- src/platform/remote.ts | 6 +++ src/platform/remoteSettings.test.ts | 19 ++++++- src/workbench/nativeTitlebar.test.tsx | 19 +++++++ src/workbench/nativeTitlebar.ts | 18 ++++--- 14 files changed, 215 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c1ab634..6a87905c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5807,6 +5807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "dispatch2", "objc2 0.6.5", ] @@ -6184,6 +6185,7 @@ dependencies = [ "ide-workspace", "objc2 0.6.5", "objc2-app-kit 0.3.2", + "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-web-kit 0.3.2", "parking_lot", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 131f729a..5ebf05c5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,6 +74,7 @@ ide-github = { path = "../crates/ide-github" } block2 = "0.6" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSView", "NSWindow"] } +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index 432d951c..fdf68376 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -5,19 +5,26 @@ use std::collections::HashMap; use std::rc::Rc; use block2::RcBlock; -use objc2::rc::Retained; -use objc2::runtime::ProtocolObject; -use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowDidUpdateNotification, NSWindowStyleMask}; -use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSPoint}; +use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask}; +use objc2_core_foundation::{ + CFIndex, CFOptionFlags, CFRetained, CFRunLoop, CFRunLoopActivity, CFRunLoopObserver, kCFRunLoopCommonModes, +}; +use objc2_foundation::NSPoint; + +/// AppKit lays out the title bar in a run loop observer and puts the buttons back at their +/// defaults on resize, title changes and fullscreen exit. Core Animation commits at 2000000, so +/// correcting the frames just before it keeps AppKit's positions off screen. +const BEFORE_COMMIT: CFIndex = 1_999_999; struct Titlebar { height: Rc>, - observer: Retained>, + spacing: f64, + observer: CFRetained, } impl Drop for Titlebar { fn drop(&mut self) { - unsafe { NSNotificationCenter::defaultCenter().removeObserver((*self.observer).as_ref()) }; + self.observer.invalidate(); } } @@ -30,22 +37,30 @@ pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<( // with_webview runs on the AppKit thread; the observer is also delivered there. window.with_webview(move |webview| unsafe { let native = &*webview.ns_window().cast::(); + let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { return }; + let Some(minimize) = native.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return }; + let Some(run_loop) = CFRunLoop::main() else { return }; + let spacing = minimize.frame().origin.x - close.frame().origin.x; TITLEBARS.with_borrow_mut(|bars| { - let bar = bars.entry(label).or_insert_with(|| { - let height = Rc::new(Cell::new(height)); - let current = height.clone(); - let pointer = native as *const NSWindow; - let block = RcBlock::new(move |_| center_buttons(&*pointer, current.get())); - let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( - Some(NSWindowDidUpdateNotification), - Some(native), - None, - &block, - ); - Titlebar { height, observer } + if let Some(bar) = bars.get(&label) { + bar.height.set(height); + center_buttons(native, height, bar.spacing); + return; + } + let shared = Rc::new(Cell::new(height)); + let current = shared.clone(); + let pointer = native as *const NSWindow; + let block = RcBlock::new(move |_: *mut CFRunLoopObserver, _: CFRunLoopActivity| { + center_buttons(&*pointer, current.get(), spacing) }); - bar.height.set(height); - center_buttons(native, height); + let activities = CFRunLoopActivity::BeforeWaiting.0 as CFOptionFlags; + let Some(observer) = CFRunLoopObserver::with_handler(None, activities, true, BEFORE_COMMIT, Some(&block)) + else { + return; + }; + run_loop.add_observer(Some(&observer), kCFRunLoopCommonModes); + center_buttons(native, height, spacing); + bars.insert(label, Titlebar { height: shared, spacing, observer }); }); }) } @@ -57,14 +72,12 @@ pub fn forget(window: &tauri::Window) { }); } -unsafe fn center_buttons(window: &NSWindow, height: f64) { +unsafe fn center_buttons(window: &NSWindow, height: f64, spacing: f64) { // Fullscreen uses AppKit's own revealable title bar. if window.styleMask().contains(NSWindowStyleMask::FullScreen) { return; } let Some(close) = window.standardWindowButton(NSWindowButton::CloseButton) else { return }; - let Some(minimize) = window.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return }; - let spacing = minimize.frame().origin.x - close.frame().origin.x; let Some(container) = (unsafe { close.superview().and_then(|view| view.superview()) }) else { return }; let Some(content) = window.contentView() else { return }; let mut frame = container.frame(); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 30a13ed8..54b6a35c 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -284,6 +284,11 @@ fn hex_color(hex: &str) -> Option { /// Builds a project window painted in the saved theme's frame colour, so it /// doesn't flash the light default before its page (and splash) loads. fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult { + let visible = cfg!(target_os = "macos") && config.visible; + let focused = config.focus; + if visible { + config.visible = false; + } let state = app.try_state::(); let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance); // A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame. @@ -305,6 +310,12 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< { let _ = window.set_background_color(Some(frame_color(&a, true))); } + if visible { + window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; + if focused { + let _ = window.set_focus(); + } + } Ok(window) } diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index 955f0e7f..c2b41fcb 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -6,6 +6,12 @@ const projectCurrent = vi.fn(); const windowOpenProject = vi.fn(); const windowOpenFiles = vi.fn(); const windowTakeLaunch = vi.fn(); +const cancelClose = vi.fn(); +const notify = vi.fn(); +vi.mock("@/platform/remote", async (original) => ({ + ...(await original()), + isRemoteWindow: vi.fn(() => false), +})); // The backend answers with the global settings, here: whatever the test set up. const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} })); const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] })); @@ -48,7 +54,10 @@ const { windowActions, } = await import("../windows"); const { useProject } = await import("@/platform/project"); -const { provideEditorService } = await import("@/workbench/contributions"); +const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions"); +const { isRemoteWindow } = await import("@/platform/remote"); +const { IpcError } = await import("@/platform/ipc"); +provideNotificationService({ notify }); const opened: string[] = []; let activePath: string | null = null; @@ -58,6 +67,7 @@ provideEditorService({ activePath: () => activePath, activeCursor: () => null, saveAll: async () => {}, + cancelClose, }); const ok = (data: unknown) => ({ status: "ok", data }); @@ -66,6 +76,9 @@ const projectState = (root: string) => ({ project: { root, name: root, trusted: beforeEach(() => { for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset(); for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear(); + cancelClose.mockClear(); + notify.mockClear(); + vi.mocked(isRemoteWindow).mockReturnValue(false); opened.length = 0; activePath = null; useProject.setState({ project: null, lightEdit: false, settings: null } as never); @@ -101,6 +114,37 @@ describe("openProjectInWindow", () => { }); }); +describe("replacing a project in this window", () => { + it.each([ + new Error("local folder unavailable"), + new IpcError({ kind: "cancelled", message: "Project opening cancelled" }), + ])("releases the close decision after opening fails or is cancelled: %s", async (error) => { + useProject.setState({ + ...projectState("/remote"), + settings: { general: { openProjectIn: "same" } }, + } as never); + windowOpenProject.mockResolvedValue(ok({ kind: "current" })); + openProject.mockRejectedValueOnce(error); + await openProjectWithPolicy("/local"); + expect(cancelClose).toHaveBeenCalledOnce(); + expect(useProject.getState().project?.root).toBe("/remote"); + await openProjectWithPolicy("/local"); + expect(useProject.getState().project?.root).toBe("/local"); + }); + + it("opens the local project when its path matches the remote root", async () => { + useProject.setState({ + ...projectState("/same"), + settings: { general: { openProjectIn: "same" } }, + } as never); + vi.mocked(isRemoteWindow).mockReturnValue(true); + windowOpenProject.mockResolvedValue(ok({ kind: "current" })); + await openProjectWithPolicy("/same"); + expect(windowOpenProject).toHaveBeenCalledWith("/same", false, []); + expect(openProject).toHaveBeenCalledWith("/same"); + }); +}); + describe("loose files (LightEdit)", () => { it("turns a window without a project into LightEdit when no LightEdit window exists", async () => { windowOpenFiles.mockResolvedValue(ok(["/x/a.txt", "/y/b.md"])); diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index f49133b1..3b39eaf4 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -50,12 +50,13 @@ function openFailed(path: string, e: unknown) { /** Opens project `path` in this window; true once it is this window's project. */ async function openHere(path: string): Promise { - if (useProject.getState().project?.root === path) return true; + if (!isRemoteWindow() && useProject.getState().project?.root === path) return true; if (!(await confirmLeaveProject())) return false; try { await openProject(path); return true; } catch (e) { + maybeEditorService()?.cancelClose?.(); openFailed(path, e); return false; } @@ -139,8 +140,8 @@ export async function openProjectWithPolicy(path: string, files: readonly string if (desktopOnly()) return; const { project: current, lightEdit } = useProject.getState(); if (!current && !lightEdit) return openProjectInWindow(path, false, files); - if (current?.root === path) return openInEditor(files); - if (current?.workspace?.projects.includes(path)) { + if (!isRemoteWindow() && current?.root === path) return openInEditor(files); + if (!isRemoteWindow() && current?.workspace?.projects.includes(path)) { await executeAction("project.reveal", path); return openInEditor(files); } diff --git a/src/features/project-trust/index.tsx b/src/features/project-trust/index.tsx index a00226cb..eac48b77 100644 --- a/src/features/project-trust/index.tsx +++ b/src/features/project-trust/index.tsx @@ -1,16 +1,23 @@ import { useEffect, useState } from "react"; import { create } from "zustand"; import { executeAction, registerActions } from "@/platform/actions"; -import { call, commands, onIdeEvent, type TrustDecisions } from "@/platform/ipc"; +import { call, commands, onIdeEvent, type TrustDecisions, type TrustStatus } from "@/platform/ipc"; import { dirname, useProject } from "@/platform/project"; import { initializeRemoteTrustMigration } from "@/platform/remote"; -import { provideProjectTrustPrompt } from "@/platform/projectTrust"; +import { provideProjectTrustPrompt, type ProjectTrustScope } from "@/platform/projectTrust"; +import { invokeLocal } from "@/platform/tauriCore"; import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions"; import { Button, Checkbox, Dialog, T } from "@/ui"; import styles from "./trust.module.css"; -const usePrompt = create<{ path: string | null; folder: boolean; finish: ((open: boolean) => void) | null }>(() => ({ +const usePrompt = create<{ + path: string | null; + scope: ProjectTrustScope; + folder: boolean; + finish: ((open: boolean) => void) | null; +}>(() => ({ path: null, + scope: "current", folder: false, finish: null, })); @@ -144,25 +151,33 @@ async function decide(trusted: boolean) { const pending = usePrompt.getState(); const path = pending.path ?? useProject.getState().project?.root; if (!path) return; - await call(commands.projectTrustSet(path, trusted, trusted && pending.folder)); - const project = await commands.projectCurrent(); - if (project) useProject.setState({ project, settings: project.settings }); + const folder = trusted && pending.folder; + if (pending.scope === "local") { + await invokeLocal("project_trust_set", { path, trusted, folder }); + } else { + await call(commands.projectTrustSet(path, trusted, folder)); + const project = await commands.projectCurrent(); + if (project) useProject.setState({ project, settings: project.settings }); + } pending.finish?.(true); } export function activate(): () => void { initializeRemoteTrustMigration(); const disposers = [ - provideProjectTrustPrompt(async (path) => { - const status = await call(commands.projectTrustStatus(path)); + provideProjectTrustPrompt(async (path, scope) => { + const status = await (scope === "local" + ? invokeLocal("project_trust_status", { path }) + : call(commands.projectTrustStatus(path))); if (status.decided) return true; usePrompt.getState().finish?.(false); return new Promise((resolve) => usePrompt.setState({ path: status.root, + scope, folder: false, finish: (open) => { - usePrompt.setState({ path: null, finish: null, folder: false }); + usePrompt.setState({ path: null, scope: "current", finish: null, folder: false }); resolve(open); }, }), diff --git a/src/features/project-trust/trust.test.tsx b/src/features/project-trust/trust.test.tsx index 076d107c..703cda7c 100644 --- a/src/features/project-trust/trust.test.tsx +++ b/src/features/project-trust/trust.test.tsx @@ -4,12 +4,17 @@ import { confirmProjectTrust } from "@/platform/projectTrust"; import { executeAction } from "@/platform/actions"; import { emitLocalIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; +import { invokeLocal } from "@/platform/tauriCore"; import { OverlayHost } from "@/workbench/OverlayHost"; import { useContributions } from "@/workbench/contributions"; import { activate } from "./index"; const status = vi.fn(); const decide = vi.fn(); +vi.mock("@/platform/tauriCore", async (original) => ({ + ...(await original()), + invokeLocal: vi.fn(), +})); vi.mock("@/platform/ipc", async (original) => { const real = await original(); return { @@ -24,6 +29,7 @@ vi.mock("@/platform/ipc", async (original) => { }); let dispose: () => void; beforeEach(() => { + vi.mocked(invokeLocal).mockReset(); status .mockReset() .mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } })); @@ -81,3 +87,26 @@ it("shows and trusts the canonical folder when opening through a symlink", async await act(async () => opening); expect(decide).toHaveBeenCalledWith("/real/projects/new", true, true); }); + +it.each(["Trust Project", "Preview in Restricted Mode"])( + "keeps a local opening's trust decision on the client: %s", + async (button) => { + const project = { root: "/remote", name: "Remote", isGit: false, trusted: true, settings: {} }; + useProject.setState({ project }); + vi.mocked(invokeLocal).mockResolvedValueOnce({ root: "/local", trusted: false, decided: false }); + render(); + const opening = confirmProjectTrust("/local", "local"); + await screen.findByRole("dialog"); + fireEvent.click(screen.getByRole("button", { name: button })); + await act(async () => opening); + expect(invokeLocal).toHaveBeenCalledWith("project_trust_status", { path: "/local" }); + expect(invokeLocal).toHaveBeenCalledWith("project_trust_set", { + path: "/local", + trusted: button === "Trust Project", + folder: false, + }); + expect(status).not.toHaveBeenCalled(); + expect(decide).not.toHaveBeenCalled(); + expect(useProject.getState().project).toBe(project); + }, +); diff --git a/src/platform/project.ts b/src/platform/project.ts index 014996ec..4c009b02 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -49,7 +49,7 @@ export function startupLaunchHandled(): Promise { } export async function openProject(path: string): Promise { - await confirmProjectTrust(path); + await confirmProjectTrust(path, "local"); useProject.setState({ opening: true }); try { const project = await call(commands.openProject(path)); diff --git a/src/platform/projectTrust.ts b/src/platform/projectTrust.ts index 58a0252f..c274c1ac 100644 --- a/src/platform/projectTrust.ts +++ b/src/platform/projectTrust.ts @@ -1,16 +1,20 @@ import { IpcError } from "./ipc"; -let prompt: ((path: string) => Promise) | null = null; +export type ProjectTrustScope = "current" | "local"; -export function provideProjectTrustPrompt(value: (path: string) => Promise): () => void { +let prompt: ((path: string, scope: ProjectTrustScope) => Promise) | null = null; + +export function provideProjectTrustPrompt( + value: (path: string, scope: ProjectTrustScope) => Promise, +): () => void { prompt = value; return () => { if (prompt === value) prompt = null; }; } -export async function confirmProjectTrust(path: string): Promise { - if (prompt && !(await prompt(path))) { +export async function confirmProjectTrust(path: string, scope: ProjectTrustScope = "current"): Promise { + if (prompt && !(await prompt(path, scope))) { throw new IpcError({ kind: "cancelled", message: "Project opening cancelled" }); } } diff --git a/src/platform/remote.ts b/src/platform/remote.ts index 5ebe1975..c6947cf9 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -72,6 +72,12 @@ async function clientSettings(settings: T): Promise { } const route: InvokeRoute = (cmd, args, options) => { + if (cmd === "open_project" || cmd === "light_edit_open") { + return invokeLocal(cmd, args, options).then((result) => { + disableRemote(); + return result; + }); + } const destination = remoteRouting(cmd, args, routed); if (cmd === "settings_set") { const setting = args as { scope: string; key: string; value: string }; diff --git a/src/platform/remoteSettings.test.ts b/src/platform/remoteSettings.test.ts index 4c21fde1..86360e1a 100644 --- a/src/platform/remoteSettings.test.ts +++ b/src/platform/remoteSettings.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CLIENT_SETTINGS_KEYS } from "./clientSettings"; import { commands, call, type Settings } from "./ipc"; import { loadSettings, useProject } from "./project"; -import { connectRemote, disableRemote, enableRemote, remoteRouting } from "./remote"; +import { connectRemote, disableRemote, enableRemote, isRemoteWindow, remoteRouting } from "./remote"; const handlers = vi.hoisted(() => new Map void>()); vi.mock("./ipc", async (original) => ({ @@ -49,6 +49,23 @@ afterEach(() => { }); describe("remote client settings", () => { + it("ends remote routing before a successful local open is published, even at the same path", async () => { + invoke.mockResolvedValueOnce({ root: "/remote", settings: local }); + await call(commands.openProject("/remote")); + expect(isRemoteWindow()).toBe(false); + await loadSettings(); + expect(useProject.getState().settings).toEqual(local); + expect(invoke.mock.calls.map(([cmd]) => cmd)).toEqual(["open_project", "settings_get"]); + }); + + it("preserves remote routing when opening a local project fails", async () => { + invoke.mockRejectedValueOnce({ kind: "notFound", message: "missing folder" }); + await expect(call(commands.openProject("/missing"))).rejects.toThrow("missing folder"); + expect(isRemoteWindow()).toBe(true); + await loadSettings(); + expect(useProject.getState().settings?.editor?.tabSize).toBe(2); + }); + it.each([...CLIENT_SETTINGS_KEYS, "customKeymaps.mine", "keymapOverrides.editor.copy", "appearance.theme"])( "keeps %s writes local in either scope", (key) => { diff --git a/src/workbench/nativeTitlebar.test.tsx b/src/workbench/nativeTitlebar.test.tsx index a864bc2a..8e7547c0 100644 --- a/src/workbench/nativeTitlebar.test.tsx +++ b/src/workbench/nativeTitlebar.test.tsx @@ -49,6 +49,25 @@ function titlebar(height: number) { } describe("native title bar alignment", () => { + it("keeps the native startup position until saved settings arrive", () => { + useProject.setState({ settings: null }); + const { bar } = titlebar(40); + renderHook(() => useNativeTitlebar(bar)); + expect(setHeight).not.toHaveBeenCalled(); + act(() => zoom(110)); + expect(setHeight).toHaveBeenCalledExactlyOnceWith(44); + }); + + it("does not resend the same height on resize notifications or effect remounts", () => { + zoom(110); + const { bar } = titlebar(40); + const { rerender } = renderHook(({ bar }) => useNativeTitlebar(bar), { initialProps: { bar } }); + act(() => resize()); + rerender({ bar: titlebar(40).bar }); + act(() => resize()); + expect(setHeight).toHaveBeenCalledExactlyOnceWith(44); + }); + it.each([50, 100, 125, 150, 200, 300])("converts CSS height to AppKit points at %s%%", (percent) => { zoom(percent); const { bar } = titlebar(40); diff --git a/src/workbench/nativeTitlebar.ts b/src/workbench/nativeTitlebar.ts index 47d2632a..e655ce40 100644 --- a/src/workbench/nativeTitlebar.ts +++ b/src/workbench/nativeTitlebar.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { call, commands } from "@/platform/ipc"; import { usePlatform } from "@/platform/platformInfo"; import { useProject } from "@/platform/project"; @@ -6,17 +6,21 @@ import { clampZoom } from "@/platform/zoom"; export function useNativeTitlebar(bar: HTMLElement | null) { const overlay = usePlatform((s) => s.overlayTitleBar); - const zoom = useProject((s) => clampZoom(s.settings?.appearance?.zoom)); + const zoom = useProject((s) => (s.settings ? clampZoom(s.settings.appearance?.zoom) : null)); + const lastHeight = useRef(null); useEffect(() => { - if (!overlay || !bar) return; + if (!overlay || !bar || zoom === null) return; bar.style.setProperty("--titlebar-zoom", String(zoom / 100)); const update = () => { // Webview zoom scales CSS pixels; Retina scale is already handled by AppKit. const height = (bar.getBoundingClientRect().height * zoom) / 100; - if (height > 0) - void call(commands.windowSetTitlebarHeight(height)).catch((e) => - console.warn("cannot align window buttons", e), - ); + if (height > 0 && height !== lastHeight.current) { + lastHeight.current = height; + void call(commands.windowSetTitlebarHeight(height)).catch((e) => { + if (lastHeight.current === height) lastHeight.current = null; + console.warn("cannot align window buttons", e); + }); + } }; update(); const observer = new ResizeObserver(update);