mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2194f7fc1a | ||
|
|
c8d97d503d | ||
|
|
9d030cd0a2 |
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
+36
-23
@@ -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<Cell<f64>>,
|
||||
observer: Retained<ProtocolObject<dyn NSObjectProtocol>>,
|
||||
spacing: f64,
|
||||
observer: CFRetained<CFRunLoopObserver>,
|
||||
}
|
||||
|
||||
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::<NSWindow>();
|
||||
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();
|
||||
|
||||
@@ -284,6 +284,11 @@ fn hex_color(hex: &str) -> Option<Color> {
|
||||
/// 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<WebviewWindow> {
|
||||
let visible = cfg!(target_os = "macos") && config.visible;
|
||||
let focused = config.focus;
|
||||
if visible {
|
||||
config.visible = false;
|
||||
}
|
||||
let state = app.try_state::<AppState>();
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof import("@/platform/remote")>()),
|
||||
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"]));
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<TrustStatus>("project_trust_status", { path })
|
||||
: call(commands.projectTrustStatus(path)));
|
||||
if (status.decided) return true;
|
||||
usePrompt.getState().finish?.(false);
|
||||
return new Promise<boolean>((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);
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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<typeof import("@/platform/tauriCore")>()),
|
||||
invokeLocal: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
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(<OverlayHost />);
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -49,7 +49,7 @@ export function startupLaunchHandled(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function openProject(path: string): Promise<ProjectInfo> {
|
||||
await confirmProjectTrust(path);
|
||||
await confirmProjectTrust(path, "local");
|
||||
useProject.setState({ opening: true });
|
||||
try {
|
||||
const project = await call(commands.openProject(path));
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { IpcError } from "./ipc";
|
||||
|
||||
let prompt: ((path: string) => Promise<boolean>) | null = null;
|
||||
export type ProjectTrustScope = "current" | "local";
|
||||
|
||||
export function provideProjectTrustPrompt(value: (path: string) => Promise<boolean>): () => void {
|
||||
let prompt: ((path: string, scope: ProjectTrustScope) => Promise<boolean>) | null = null;
|
||||
|
||||
export function provideProjectTrustPrompt(
|
||||
value: (path: string, scope: ProjectTrustScope) => Promise<boolean>,
|
||||
): () => void {
|
||||
prompt = value;
|
||||
return () => {
|
||||
if (prompt === value) prompt = null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmProjectTrust(path: string): Promise<void> {
|
||||
if (prompt && !(await prompt(path))) {
|
||||
export async function confirmProjectTrust(path: string, scope: ProjectTrustScope = "current"): Promise<void> {
|
||||
if (prompt && !(await prompt(path, scope))) {
|
||||
throw new IpcError({ kind: "cancelled", message: "Project opening cancelled" });
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,12 @@ async function clientSettings<T extends Settings>(settings: T): Promise<T> {
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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<string, () => 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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number | null>(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);
|
||||
|
||||
Reference in new issue
Block a user