From f9d852749f212313a32869b0a329903927340ffa Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Wed, 27 May 2026 01:10:17 +0800 Subject: [PATCH] fix(desktop): open project folders in Windows Explorer on WSL (#1581) Route Continue in CLI directory opens through wslpath + explorer.exe on WSL Linux instead of shell.openPath, which often launches Chrome at a file:// listing via xdg-open. --- .../src/main/open-project-directory.ts | 54 ++++++++++++++++ apps/desktop/src/main/runtime.ts | 3 +- .../tests/main/open-project-directory.test.ts | 62 +++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/main/open-project-directory.ts create mode 100644 apps/desktop/tests/main/open-project-directory.test.ts diff --git a/apps/desktop/src/main/open-project-directory.ts b/apps/desktop/src/main/open-project-directory.ts new file mode 100644 index 000000000..dd55cf138 --- /dev/null +++ b/apps/desktop/src/main/open-project-directory.ts @@ -0,0 +1,54 @@ +import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process"; +import os from "node:os"; +import { promisify } from "node:util"; + +import { shell } from "electron"; + +const execFileAsync = promisify(execFile) as ( + file: string, + args: readonly string[], + options: ExecFileOptionsWithStringEncoding, +) => Promise<{ stdout: string; stderr: string }>; + +export function isWslLinux( + release = os.release(), + platform = process.platform, +): boolean { + return platform === "linux" && release.toLowerCase().includes("microsoft"); +} + +export type OpenProjectDirectoryDeps = { + execFile?: typeof execFileAsync; + isWsl?: () => boolean; + openPath?: (path: string) => Promise; +}; + +/** + * Open a validated project directory in the host file manager. On WSL Linux, + * `shell.openPath` often delegates to Chrome via xdg-open; route through the + * Windows host Explorer instead (issue #1581). + */ +export async function openProjectDirectoryInFileManager( + resolvedDir: string, + deps: OpenProjectDirectoryDeps = {}, +): Promise { + const exec = deps.execFile ?? execFileAsync; + const openPath = deps.openPath ?? ((path: string) => shell.openPath(path)); + const isWsl = deps.isWsl ?? (() => isWslLinux()); + + if (!isWsl()) { + return openPath(resolvedDir); + } + + try { + const { stdout } = await exec("wslpath", ["-w", resolvedDir], { timeout: 5000 }); + const winPath = stdout.trim(); + if (!winPath) { + return "wslpath returned an empty Windows path"; + } + await exec("explorer.exe", [winPath], { timeout: 5000 }); + return ""; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 82c598905..47a16af51 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -16,6 +16,7 @@ import { import type { OpenDesignHostActionResult, OpenDesignHostUpdaterActionOptions } from "@open-design/host"; import { createElectronPdfTarget, exportPdfFromHtml, savePrintReadyDocumentAsPdf } from "./pdf-export.js"; +import { openProjectDirectoryInFileManager } from "./open-project-directory.js"; import type { PrintReadyPdfOptions } from "./pdf-export.js"; import type { DesktopUpdater } from "./updater.js"; @@ -1198,7 +1199,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom const validated = await validateExistingDirectory(resolved.context.resolvedDir); if (!validated.ok) return `open-path: ${validated.reason}`; try { - return await shell.openPath(validated.resolved); + return await openProjectDirectoryInFileManager(validated.resolved); } catch (err) { return err instanceof Error ? err.message : String(err); } diff --git a/apps/desktop/tests/main/open-project-directory.test.ts b/apps/desktop/tests/main/open-project-directory.test.ts new file mode 100644 index 000000000..142145cb0 --- /dev/null +++ b/apps/desktop/tests/main/open-project-directory.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + isWslLinux, + openProjectDirectoryInFileManager, +} from "../../src/main/open-project-directory.js"; + +describe("isWslLinux", () => { + it("detects WSL kernels via microsoft in the release string", () => { + expect(isWslLinux("5.15.133.1-microsoft-standard-WSL2", "linux")).toBe(true); + expect(isWslLinux("6.8.0-45-generic", "linux")).toBe(false); + expect(isWslLinux("5.15.133.1-microsoft-standard-WSL2", "darwin")).toBe(false); + }); +}); + +describe("openProjectDirectoryInFileManager", () => { + it("uses shell.openPath on native Linux", async () => { + const openPath = vi.fn(async () => ""); + const execFile = vi.fn(); + + const result = await openProjectDirectoryInFileManager("/tmp/project", { + isWsl: () => false, + openPath, + execFile, + }); + + expect(result).toBe(""); + expect(openPath).toHaveBeenCalledWith("/tmp/project"); + expect(execFile).not.toHaveBeenCalled(); + }); + + it("routes through wslpath and explorer.exe on WSL (#1581)", async () => { + const execFile = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === "wslpath") { + return { stdout: "C:\\Users\\me\\project\r\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + const openPath = vi.fn(); + + const result = await openProjectDirectoryInFileManager("/home/me/project", { + isWsl: () => true, + execFile, + openPath, + }); + + expect(result).toBe(""); + expect(execFile).toHaveBeenNthCalledWith( + 1, + "wslpath", + ["-w", "/home/me/project"], + { timeout: 5000 }, + ); + expect(execFile).toHaveBeenNthCalledWith( + 2, + "explorer.exe", + ["C:\\Users\\me\\project"], + { timeout: 5000 }, + ); + expect(openPath).not.toHaveBeenCalled(); + }); +});