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);