дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
ec5193f827
коміт
f6d7facd82
14 files changed
+215
-49
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Заблокувати користувача