From f76c297fe930fce92eb4146f8fc319613906c0ab Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:24:59 +0800 Subject: [PATCH] fix(terminal): keep Windows shell paths intact (#1723) The terminal's Shell path setting was split with POSIX rules, where a backslash escapes the next character, so C:\Windows\system32\cmd.exe reached the backend as C:Windowssystem32cmd.exe and every new terminal failed to start. On Windows a backslash is now a path separator, and an unquoted path up to its .exe/.cmd/.bat/.com is the program even with spaces; paths picked from the Installed Shells menu or Browse are quoted when they contain whitespace. This was the only place the setting is parsed. Fixes #1327 --- src/features/settings/pages/tools.tsx | 5 ++- src/features/terminal/session.ts | 6 +-- src/platform/commandLine.test.ts | 54 +++++++++++++++++++++++++++ src/platform/commandLine.ts | 22 +++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 src/platform/commandLine.test.ts diff --git a/src/features/settings/pages/tools.tsx b/src/features/settings/pages/tools.tsx index 03ec44d43..1b08c0c17 100644 --- a/src/features/settings/pages/tools.tsx +++ b/src/features/settings/pages/tools.tsx @@ -7,6 +7,7 @@ import { FontPicker } from "../components/FontPicker"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; +import { quotePath } from "@/platform/commandLine"; import { commands, isTauri } from "@/platform/ipc"; import { DEFAULT_EXTERNAL_TOOL, @@ -110,7 +111,7 @@ function ShellPathRow() { const { open: pick } = await import("@tauri-apps/plugin-dialog"); const picked = await pick({ multiple: false, title: "Shell Path" }); const path = Array.isArray(picked) ? picked[0] : picked; - if (path) set(path); + if (path) set(quotePath(path)); }; return ( ({ id: p, label: p, checked: p === value, run: () => set(p) }))} + items={shells.map((p) => ({ id: p, label: p, checked: quotePath(p) === value, run: () => set(quotePath(p)) }))} /> { + it("keeps Windows paths as typed", () => { + expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe`, true)).toEqual([ + String.raw`C:\Windows\system32\cmd.exe`, + ]); + expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe /k C:\tools\init.bat`, true)).toEqual([ + String.raw`C:\Windows\system32\cmd.exe`, + "/k", + String.raw`C:\tools\init.bat`, + ]); + }); + + it("takes an unquoted Windows path with spaces up to its executable", () => { + expect(parseShellCommand(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`, true)).toEqual([ + String.raw`C:\Program Files\PowerShell\7\pwsh.exe`, + ]); + expect(parseShellCommand(String.raw`C:\Program Files\Git\bin\bash.EXE --login -i`, true)).toEqual([ + String.raw`C:\Program Files\Git\bin\bash.EXE`, + "--login", + "-i", + ]); + }); + + it("honours quotes", () => { + expect(parseShellCommand(String.raw`"C:\Program Files\Git\bin\bash.exe" --login`, true)).toEqual([ + String.raw`C:\Program Files\Git\bin\bash.exe`, + "--login", + ]); + expect(parseShellCommand(`/bin/zsh --login`, false)).toEqual(["/bin/zsh", "--login"]); + expect(parseShellCommand(`"/Applications/My Shell/bin/fish" -l`, false)).toEqual([ + "/Applications/My Shell/bin/fish", + "-l", + ]); + expect(parseShellCommand(String.raw`/bin/sh -c a\ b`, false)).toEqual(["/bin/sh", "-c", "a b"]); + }); + + it("is empty for an empty setting", () => { + expect(parseShellCommand(" ", true)).toEqual([]); + expect(parseShellCommand("", false)).toEqual([]); + }); +}); + +describe("quotePath", () => { + it("quotes only paths with whitespace", () => { + expect(quotePath(String.raw`C:\Windows\system32\cmd.exe`)).toBe(String.raw`C:\Windows\system32\cmd.exe`); + expect(quotePath(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`)).toBe( + String.raw`"C:\Program Files\PowerShell\7\pwsh.exe"`, + ); + expect(parseShellCommand(quotePath("/Applications/My Shell/fish"), false)).toEqual(["/Applications/My Shell/fish"]); + }); +}); diff --git a/src/platform/commandLine.ts b/src/platform/commandLine.ts index 0719afa20..37496c8e3 100644 --- a/src/platform/commandLine.ts +++ b/src/platform/commandLine.ts @@ -67,6 +67,28 @@ export function parseArgs(input: string, windowsPaths = false): string[] { return out; } +/** + * The program and arguments of a shell command line (Settings › Terminal › Shell path). On + * Windows a backslash is a path separator, and an unquoted path up to its `.exe` / `.cmd` / + * `.bat` / `.com` is the program even with spaces in it (`C:\Program Files\…\pwsh.exe -NoLogo`). + */ +export function parseShellCommand(line: string, windows: boolean): string[] { + const text = line.trim(); + if (windows && !/["']/.test(text)) { + const end = /\.(?:exe|cmd|bat|com)(?=\s|$)/i.exec(text); + if (end) { + const stop = end.index + end[0].length; + return [text.slice(0, stop), ...parseArgs(text.slice(stop), true)]; + } + } + return parseArgs(text, windows); +} + +/** `path` as one word of a command line: quoted when it has whitespace. */ +export function quotePath(path: string): string { + return /\s/.test(path) ? `"${path}"` : path; +} + /** Inverse of `parseArgs` (quotes only when needed). */ export function formatArgs(args: readonly string[]): string { return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" ");