diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index 955f0e7f7..c2b41fcb3 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 f49133b1e..3b39eaf47 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 a00226cb8..eac48b77a 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 076d107ca..703cda7c2 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 014996ec1..4c009b022 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 58a0252f4..c274c1ac1 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 5ebe19755..c6947cf9c 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 4c21fde1d..86360e1a9 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) => {