From c8d97d503d83dbf5dc272002812ce2fa9d1144a2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:40:10 +0800 Subject: [PATCH] fix(remote): open local projects from a remote window Choosing This Window for a local project did nothing in a remote window: the open was routed to the remote host, or skipped when the path matched the current root. Route project opens to the local backend and leave remote mode once they succeed, check trust against the local backend, and cancel the pending close if the open fails. --- 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 ++++++++- 8 files changed, 135 insertions(+), 19 deletions(-) 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) => {