fix(run): recognise Windows launcher names in run configuration forms (#300)

Run configurations whose command is a Windows launcher (mvn.cmd, MVN.CMD, a full C:\\…\\mvn.cmd path, gradle.bat, pytest.exe, java.exe, godot.exe, npm.cmd) opened in the generic command fallback because the Maven/Gradle, npm, pytest, Godot and Application/Spring Boot forms compared executable names exactly. A shared commandName() (basename, either slash, .exe/.cmd/.bat/.com stripped case-insensitively) now matches them, and the original launcher text is kept so the command line round-trips unchanged.

Refs #244
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 08:52:00 +08:00
1 джерело d44e2414ca
коміт 181a2ea03e
4 files changed
+93 -19

No files matched your search

+5
Переглянути файл
@@ -0,0 +1,5 @@
/** Executable identity for form matching; keep the original command for launching. */
export function commandName(command: string): string {
const name = command.replace(/\\/g, "/").split("/").pop() ?? command;
return /\.(exe|cmd|bat|com)$/i.test(name) ? name.replace(/\.(exe|cmd|bat|com)$/i, "").toLowerCase() : name;
}
+4 -4
Переглянути файл
@@ -8,6 +8,7 @@ import { call, commands, type RunConfig } from "@/platform/ipc";
import { CLASSPATH, parseJavaArgs, TEST_CLASSPATH } from "@/platform/javaLaunch";
import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions";
import { formatArgs, parseArgs } from "./configs";
import { commandName } from "./commandName";
export const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
@@ -38,8 +39,6 @@ const profilesField = (hint: string, visible?: RunConfigTypeField["visible"]): R
...(visible ? { visible } : {}),
});
const isJava = (command: string) => /^java(\.exe)?$/i.test(command.replace(/\\/g, "/").split("/").pop() ?? "");
/** Main classes of the JVM configurations, offered while typing. */
function mainClasses(configs: readonly RunConfig[]): string[] {
const out = new Set<string>();
@@ -85,7 +84,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution {
const main = p.mainClass?.trim() ?? "";
const profiles = spring ? (p.activeProfiles?.trim() ?? "") : "";
return {
command: "java",
command: p.executable || "java",
args: [
...splitJvmArgs(p.vmOptions),
"-cp",
@@ -98,7 +97,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution {
},
// Configurations detected or saved before these fields existed: `java [vm] -cp <cp> Main [args]`.
paramsFrom(c) {
if (!isJava(c.command)) return null;
if (!/^java$/i.test(commandName(c.command))) return null;
const parsed = parseJavaArgs(c.args);
const classpath =
parsed.classpath === "runtime"
@@ -114,6 +113,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution {
rest = rest.slice(1);
}
return {
...(c.command !== "java" ? { executable: c.command } : {}),
mainClass: parsed.mainClass,
vmOptions: formatArgs(parsed.vmArgs),
programArgs: formatArgs(rest),
+55 -4
Переглянути файл
@@ -71,6 +71,47 @@ describe("configuration types", () => {
expect(runConfigTypes().map((t) => t.id)).toEqual(expect.arrayContaining(BUILTIN_TYPES.map((t) => t.id)));
});
it.each([
["maven", "mvn.cmd", ["clean", "test"]],
["maven", "MVN.CMD", ["verify"]],
["maven", "mvn.bat", ["test"]],
["maven", "/opt/maven/bin/mvn", ["package"]],
["maven", String.raw`C:\tools\maven\bin\mvn.cmd`, ["test", "-Dname=two words"]],
["maven", "mvnw.cmd", ["test"]],
["gradle", "gradle.bat", ["build"]],
["gradle", "gradle.cmd", ["test"]],
["gradle", "gradlew.bat", ["test"]],
["pytest", "pytest.exe", ["tests/test_app.py", "-q"]],
["pytest", String.raw`C:\project\.venv\Scripts\PYTEST.EXE`, ["-q"]],
["pytest", "PYTHON.EXE", ["-m", "pytest", "tests"]],
["npm", String.raw`C:\Program Files\nodejs\NPM.CMD`, ["run", "test", "--", "two words"]],
["npm", "pnpm.COM", ["test"]],
["application", String.raw`C:\jdk\bin\JAVA.EXE`, ["-cp", "lib/*", "a.Main"]],
["spring-boot", "java.cmd", ["-cp", "lib/*", "a.App"]],
["godot", "GODOT.EXE", ["--path", ".", "res://a.tscn"]],
])("opens %s launcher %s in its form and preserves the command line", (kind, command, args) => {
const original = cfg("windows", { kind, command, args, cwd: "C:/project" });
const params = formParams(original);
expect(params).not.toBeNull();
expect(withParams(original, params!)).toMatchObject({ command, args, cwd: original.cwd });
if (kind === "maven" || kind === "gradle") {
expect(params!.wrapper === "true").toBe(/^(mvnw|gradlew)/.test(command));
expect(withParams(original, { ...params!, tasks: "clean" })).toMatchObject({ command, args: ["clean"] });
}
});
it("switches launchers when the wrapper or package manager selection changes", () => {
const maven = cfg("m", { kind: "maven", command: "mvnw.cmd", args: ["test"] });
expect(withParams(maven, { ...formParams(maven)!, wrapper: "false" }).command).toBe("mvn");
const npm = cfg("n", { kind: "npm", command: "NPM.CMD", args: ["test"] });
expect(formParams(npm)!.packageManager).toBe("npm");
expect(withParams(npm, { ...formParams(npm)!, packageManager: "pnpm" }).command).toBe("pnpm");
});
it.each(["mvn.cmd.sh", "other.exe", "/bin/MVN"])("does not accept unrelated Maven commands: %s", (command) => {
expect(formParams(cfg("m", { kind: "maven", command, args: ["test"] }))).toBeNull();
});
it("old run.toml entries edit as a Shell Script command line", () => {
const old = cfg("user:server", { command: "cargo", args: ["run", "--bin", "my app"] });
const params = formParams(old)!;
@@ -96,9 +137,19 @@ describe("configuration types", () => {
command: "/Apps/Godot.app/Contents/MacOS/Godot",
args: ["--path", "."],
});
expect(godot.paramsFrom!(detected)).toEqual({ projectDir: "", scene: "", arguments: "" });
expect(godot.paramsFrom!(detected)).toEqual({
projectDir: "",
scene: "",
arguments: "",
executable: detected.command,
});
const scene = { ...detected, cwd: "demo", args: ["--path", ".", "res://a.tscn", "--verbose"] };
expect(godot.paramsFrom!(scene)).toEqual({ projectDir: "demo", scene: "res://a.tscn", arguments: "--verbose" });
expect(godot.paramsFrom!(scene)).toEqual({
projectDir: "demo",
scene: "res://a.tscn",
arguments: "--verbose",
executable: detected.command,
});
expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull();
});
@@ -221,8 +272,8 @@ describe("configuration types", () => {
});
const gradle = cfg("gradle:test", { kind: "gradle", command: "./gradlew", args: ["test"], detected: true });
expect(formParams(gradle)).toMatchObject({ tasks: "test", wrapper: "true" });
// A toolchain-specific command line the form can't reproduce stays a plain command.
expect(formParams(cfg("m", { kind: "maven", command: "/opt/maven/bin/mvn", args: ["package"] }))).toBeNull();
// An unrelated executable stays a plain command.
expect(formParams(cfg("m", { kind: "maven", command: "/opt/tools/custom-build", args: ["package"] }))).toBeNull();
expect(withParams(npm, { packageManager: "npm", command: "test", packageDir: "" })).toMatchObject({
command: "npm",
args: ["test"],
+29 -11
Переглянути файл
@@ -6,6 +6,7 @@
import type { RunConfig } from "@/platform/ipc";
import { registerRunConfigType, useRunConfigTypes, type RunConfigTypeContribution } from "@/workbench/contributions";
import { formatArgs, parseArgs, uid, uniqueName } from "./configs";
import { commandName } from "./commandName";
import {
applicationType,
gradleForkedJvm,
@@ -19,7 +20,6 @@ import { tx } from "@/platform/i18n";
export const COMPOUND = "compound";
const on = (v: string | undefined) => v === "true";
const baseName = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p;
const dirOf = (cwd: string | null | undefined) => cwd ?? "";
/** Values of `args[index]` across detected configurations of `kind`. */
@@ -115,7 +115,7 @@ const program: RunConfigTypeContribution = {
};
const isScene = (a: string) => /\.t?scn$/i.test(a);
const isGodot = (cmd: string) => /^godot([-.\w]*)$/i.test(baseName(cmd).replace(/\.exe$/i, ""));
const isGodot = (cmd: string) => /^godot([-.\w]*)$/i.test(commandName(cmd));
/**
* Godot: `godot --path <project>` runs the main scene, `… <scene>` one scene. The
@@ -148,7 +148,7 @@ const godot: RunConfigTypeContribution = {
},
],
commandLine: (p) => ({
command: "godot",
command: p.executable || "godot",
args: ["--path", ".", ...(p.scene?.trim() ? [p.scene.trim()] : []), ...parseArgs(p.arguments ?? "")],
cwd: p.projectDir?.trim() || null,
}),
@@ -158,7 +158,12 @@ const godot: RunConfigTypeContribution = {
const path = rest.indexOf("--path");
if (path >= 0) rest.splice(path, 2);
const scene = rest.find(isScene) ?? "";
return { projectDir: dirOf(c.cwd), scene, arguments: formatArgs(rest.filter((a) => a !== scene)) };
return {
projectDir: dirOf(c.cwd),
scene,
arguments: formatArgs(rest.filter((a) => a !== scene)),
...(c.command !== "godot" ? { executable: c.command } : {}),
};
},
};
@@ -194,13 +199,22 @@ const npm: RunConfigTypeContribution = {
commandLine(p) {
const command = p.command || "run";
const args = [command, ...(command === "run" ? [p.script ?? ""] : []), ...parseArgs(p.arguments ?? "")];
return { command: p.packageManager || "npm", args, cwd: p.packageDir?.trim() || null };
const manager = p.packageManager || "npm";
const executable = p.executable && commandName(p.executable) === manager ? p.executable : manager;
return { command: executable, args, cwd: p.packageDir?.trim() || null };
},
paramsFrom(c) {
const [command, ...rest] = c.args;
if (!PACKAGE_MANAGERS.includes(c.command) || !command || !NPM_COMMANDS.includes(command)) return null;
if (!PACKAGE_MANAGERS.includes(commandName(c.command)) || !command || !NPM_COMMANDS.includes(command)) return null;
const script = command === "run" ? (rest.shift() ?? "") : "";
return { packageManager: c.command, command, script, arguments: formatArgs(rest), packageDir: dirOf(c.cwd) };
return {
packageManager: commandName(c.command),
command,
script,
arguments: formatArgs(rest),
packageDir: dirOf(c.cwd),
...(c.command !== commandName(c.command) ? { executable: c.command } : {}),
};
},
};
@@ -238,16 +252,20 @@ function buildToolType(
{ key: "wrapper", label: wrapper.label, control: "checkbox" },
],
commandLine: (p) => ({
command: wrapperCommand(wrapper.unix, wrapper.windows, wrapper.plain, on(p.wrapper)),
command:
p.executable && commandName(p.executable) === (on(p.wrapper) ? wrapper.unix : wrapper.plain)
? p.executable
: wrapperCommand(wrapper.unix, wrapper.windows, wrapper.plain, on(p.wrapper)),
args: [...parseArgs(p.tasks ?? ""), ...parseArgs(p.arguments ?? ""), ...jvm.args(p)],
cwd: p.projectDir?.trim() || null,
}),
...(jvm.prepareLaunch ? { prepareLaunch: jvm.prepareLaunch } : {}),
paramsFrom(c) {
const name = baseName(c.command);
const usesWrapper = name === wrapper.unix || name === wrapper.windows;
const name = commandName(c.command);
const usesWrapper = name === wrapper.unix;
if (!usesWrapper && name !== wrapper.plain) return null;
return {
executable: c.command,
projectDir: dirOf(c.cwd),
tasks: formatArgs(c.args),
arguments: "",
@@ -350,7 +368,7 @@ const pytest: RunConfigTypeContribution = {
? 2
: m >= 0 && c.args[m + 1] === "pytest"
? m + 2
: baseName(c.command).replace(/\.exe$/i, "") === "pytest"
: commandName(c.command) === "pytest"
? 0
: null;
if (runner === null) return null;