From 4c5b6de9ada902ffc74b429b7ae3c3384c23a5b3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:11:43 +0800 Subject: [PATCH] feat(run): choose the Node interpreter of an npm configuration (#1120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm configurations get a "Node interpreter" field; the script and the package manager run on that node (its directory first on the PATH). Empty keeps the project's interpreter from Settings › Languages & Frameworks › Node.js. Saving drops the PATH settings derived before the choice, so a stale project PATH isn't kept. Closes #997 --- crates/ide-sdk/src/apply.rs | 13 +++++++++---- crates/ide-sdk/src/tests.rs | 29 +++++++++++++++++++++++++++++ src/features/run/types.ts | 8 ++++++++ src/platform/locales/text/zh.json | 4 +++- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index a6c82be79..bca51e308 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -703,8 +703,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings { c.command = pm.to_owned(); } - if let Some(dir) = &node { - path_dirs.push(dir.clone()); + // The configuration's own Node interpreter, else the project's. + let own = c.extras.params.get("nodeInterpreter").and_then(|p| setting_path(root, p)); + if let Some(dir) = own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| node.clone()) { + path_dirs.push(dir); } } "php" | "phpunit" | "composer" => { @@ -825,14 +827,17 @@ pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConf }; let mut out = config.clone(); // Until nothing changes: some entries are derived only once others are gone - // (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`). + // (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`). An entry derived before + // the configuration's own choice (an npm script's Node interpreter) is derived too. while let Some(key) = out .env .iter() .find(|(key, value)| { let mut probe = out.clone(); probe.env.remove(*key); - applied(&probe).env.get(*key) == Some(*value) + let mut plain = probe.clone(); + plain.extras.params.clear(); + applied(&probe).env.get(*key) == Some(*value) || applied(&plain).env.get(*key) == Some(*value) }) .map(|(key, _)| key.clone()) { diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index b3ad69e0e..858af1135 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -173,6 +173,35 @@ fn run_configs_use_selected_interpreter_and_package_manager() { assert_eq!(configs[6].args, ["manage.py", "test", "polls"]); } +#[test] +fn npm_config_runs_on_its_own_node_interpreter() { + if cfg!(windows) { + return; // Unix paths + } + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + s.languages.node.interpreter = "/opt/node22/bin/node".into(); + let mut own = run("npm", "npm", &["run", "dev"]); + own.extras.params.insert("nodeInterpreter".into(), "/opt/node18/bin/node".into()); + let mut configs = vec![own, run("npm", "npm", &["run", "build"])]; + apply_to_run_configs(&mut configs, tmp.path(), &s); + let first = |c: &RunConfig| c.env["PATH"].split(':').next().unwrap().to_owned(); + assert_eq!(first(&configs[0]), "/opt/node18/bin"); + assert_eq!(first(&configs[1]), "/opt/node22/bin", "the project's Node.js without one"); + // Saved without the derived PATH, keeping the choice itself. + let saved = without_derived(&configs[0], tmp.path(), &s); + assert!(!saved.env.contains_key("PATH")); + assert_eq!(saved.extras.params["nodeInterpreter"], "/opt/node18/bin/node"); + // Chosen on a configuration loaded with the project's PATH: that PATH isn't kept either. + let mut edited = configs[1].clone(); + edited.extras.params.insert("nodeInterpreter".into(), "/opt/node18/bin/node".into()); + let saved = without_derived(&edited, tmp.path(), &s); + assert!(!saved.env.contains_key("PATH")); + let mut reloaded = vec![saved]; + apply_to_run_configs(&mut reloaded, tmp.path(), &s); + assert_eq!(first(&reloaded[0]), "/opt/node18/bin"); +} + fn fake_jdk(dir: &Path) { std::fs::create_dir_all(dir.join("bin")).unwrap(); std::fs::write(dir.join("bin").join(exe("java")), "").unwrap(); diff --git a/src/features/run/types.ts b/src/features/run/types.ts index ec6845ee7..a90bcc5a7 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -232,6 +232,14 @@ const npm: RunConfigTypeContribution = { control: "select", options: PACKAGE_MANAGERS.map((v) => ({ value: v, label: v })), }, + { + key: "nodeInterpreter", + label: "Node interpreter", + mono: true, + browse: "file", + placeholder: "Project default", + hint: "The node executable this script runs on; empty uses Settings › Languages & Frameworks › Node.js.", + }, ], commandLine(p) { const command = p.command || "run"; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 86d3d1ffc..b6337d0ad 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5802,5 +5802,7 @@ "Not in Local History: {names}. They are in the Trash.": "本地历史中没有:{names}。它们在回收站中。", "Show All Commits": "显示所有提交", "Pin": "固定", - "Unpin": "取消固定" + "Unpin": "取消固定", + "Project default": "项目默认", + "The node executable this script runs on; empty uses Settings › Languages & Frameworks › Node.js.": "运行此脚本的 node 可执行文件;留空则使用“设置 › 语言和框架 › Node.js”中的解释器。" }