From 9a6b84e2871a71e66cb821220b18e1cfd0f50c6f Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Tue, 3 Nov 2026 22:46:50 +0100 Subject: [PATCH] feat(run): import JetBrains run configurations (.run/*.run.xml, .idea/workspace.xml) (#1039) Run/Debug Configurations offers Import from JetBrains run configurations when the project has .run/*.run.xml, .idea/runConfigurations/*.xml or run configurations in .idea/workspace.xml; Find Action has Import Run Configurations from JetBrains (.run, .idea). Python, Django server/tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and compound configurations map to the matching kinds; other types, unknown path variables and multi-module $MODULE_DIR$ are skipped with the reason. Shared .run files take precedence over .idea/runConfigurations and the workspace's RunManager (templates and temporary configurations left out). Path and argument helpers shared with the launch.json importer moved to import/mod.rs. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- crates/ide-run/src/detect.rs | 4 +- crates/ide-run/src/import/jetbrains.rs | 879 +++++++++++++++++++++++ crates/ide-run/src/import/mod.rs | 151 +++- crates/ide-run/src/import/vscode.rs | 108 +-- crates/ide-run/tests/import_jetbrains.rs | 691 ++++++++++++++++++ src/bindings.ts | 16 +- src/features/run/importAction.test.tsx | 13 + src/features/run/importConfigs.test.tsx | 37 +- src/features/run/importConfigs.tsx | 41 +- src/features/run/index.ts | 26 +- src/platform/locales/text/zh.json | 9 +- 11 files changed, 1832 insertions(+), 143 deletions(-) create mode 100644 crates/ide-run/src/import/jetbrains.rs create mode 100644 crates/ide-run/tests/import_jetbrains.rs diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index c9195b404..acb7e87dc 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -151,7 +151,7 @@ fn relative(root: &Path, dir: &Path) -> PathBuf { // ---------------------------------------------------------------- node -fn package_manager(root: &Path, pkg: &Json) -> &'static str { +pub(crate) fn package_manager(root: &Path, pkg: &Json) -> &'static str { if let Some(pm) = pkg.get("packageManager").and_then(Json::as_str) { match pm.split('@').next().unwrap_or_default() { "pnpm" => return "pnpm", @@ -172,7 +172,7 @@ fn package_manager(root: &Path, pkg: &Json) -> &'static str { } } -fn is_test_script(name: &str) -> bool { +pub(crate) fn is_test_script(name: &str) -> bool { matches!(name, "test" | "vitest" | "jest") || name.starts_with("test:") } diff --git a/crates/ide-run/src/import/jetbrains.rs b/crates/ide-run/src/import/jetbrains.rs new file mode 100644 index 000000000..777843a59 --- /dev/null +++ b/crates/ide-run/src/import/jetbrains.rs @@ -0,0 +1,879 @@ +//! JetBrains run configurations (PyCharm, WebStorm, IntelliJ IDEA): the shared `.run/*.run.xml` +//! files, `.idea/runConfigurations/*.xml`, and the `RunManager` of `.idea/workspace.xml` without +//! its templates (`default="true"`) and temporary configurations. A configuration found in more +//! than one place is taken from the first, in that order. +//! +//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and +//! compound configurations become the matching kinds; other types are skipped with their type. +//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`, +//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another +//! path variable skips the configuration. + +use super::{ + Entry, Found, SkipReason, command_name, config, env_var, normalize, path_from, project_python, relative_to, + working_dir, +}; +use crate::RunConfig; +use ide_core::IdeResult; +use quick_xml::{Reader, XmlVersion, events::Event}; +use std::cell::Cell; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The files read, in order of precedence, with the name the summary gives them. +pub(crate) fn files(root: &Path) -> Vec<(String, PathBuf)> { + let mut out = Vec::new(); + for (dir, suffix) in [(".run", ".run.xml"), (".idea/runConfigurations", ".xml")] { + let mut found: Vec = std::fs::read_dir(root.join(dir)) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(suffix))) + .collect(); + found.sort(); + for p in found { + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + out.push((format!("{dir}/{name}"), p)); + } + } + // IDEA writes a workspace.xml in every project it opens: it counts only with configurations to import. + let workspace = root.join(".idea/workspace.xml"); + let has_configurations = std::fs::read_to_string(&workspace).is_ok_and(|text| { + text.contains("RunManager") + && parse(&text).map_or(true, |doc| configurations(&doc, "RunManager").next().is_some()) + }); + if has_configurations { + out.push((".idea/workspace.xml".into(), workspace)); + } + out +} + +/// The configurations of `manager` (`RunManager` in workspace.xml), without IDEA's templates for +/// new configurations and the temporary ones it makes for a single run. +fn configurations<'a>(doc: &'a El, manager: &'a str) -> impl Iterator { + std::iter::once(doc) + .chain(doc.children.iter()) + .filter(move |c| c.name == "component" && c.attr("name") == Some(manager)) + .flat_map(|component| component.children_named("configuration")) + .filter(|c| c.attr("default") != Some("true") && c.attr("temporary") != Some("true")) +} + +pub(crate) fn entries(root: &Path, env: &BTreeMap) -> IdeResult> { + let windows = cfg!(windows); + let homes = if windows { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] }; + let home = homes.into_iter().find_map(|k| env_var(env, k, windows)); + let cx = Cx { root: &normalize(root), home, modules: module_count(root), machine_values: Cell::new(false) }; + let mut out = Vec::new(); + for (label, path) in files(root) { + let doc = std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|text| parse(&text)); + let doc = match doc { + Ok(doc) => doc, + Err(detail) => { + out.push(Entry { name: label, result: Err(SkipReason::InvalidFile { detail }) }); + continue; + } + }; + let workspace = label == ".idea/workspace.xml"; + let manager = if workspace { "RunManager" } else { "ProjectRunConfigurationManager" }; + out.extend(configurations(&doc, manager).map(|c| cx.entry(c))); + } + Ok(out) +} + +// ------------------------------------------------------------------------- XML + +/// An element of the file, with its attributes (values unescaped) and child elements. +#[derive(Debug, Default)] +struct El { + name: String, + attrs: Vec<(String, String)>, + children: Vec, +} + +impl El { + fn attr(&self, key: &str) -> Option<&str> { + self.attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } + + fn child(&self, name: &str) -> Option<&El> { + self.children.iter().find(|c| c.name == name) + } + + fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator { + self.children.iter().filter(move |c| c.name == name) + } + + /// `