From 192b59801a476669e3bcb8e3ed7e7ce37065a071 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:48:14 +0800 Subject: [PATCH] fix(build): build selected CMake targets in the Build window (#2061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2039 CMake configurations bypassed the Build executor and Build Project ignored the selected target, so CMake projects had no hammer, configure/build output went to Run tabs, and generated names carried a "(build)" suffix (the build directory) that read like an action. Now the toolbar hammer and Cmd+F9 build the selected CMake target and profile without running it; configure/build prerequisites of Run, Debug and CTest run in the Build window and a failed build prevents the launch; generated executables are "CMake Application" configurations; generated names add profile/build directory only when needed to tell them apart ("hello", or "hello [Debug · build-debug/]"). Saved names and configuration ids are kept. Verified on macOS. --- crates/ide-build/src/compile/mod.rs | 5 + crates/ide-host/src/ops/build.rs | 59 +++++- crates/ide-host/src/ops/run.rs | 22 ++- crates/ide-run/src/detect.rs | 2 +- crates/ide-run/src/detect/cmake.rs | 178 +++++++++++++----- crates/ide-run/src/lib.rs | 4 +- crates/ide-run/tests/detect.rs | 128 ++++++++++++- crates/ide-run/tests/process.rs | 2 +- e2e/run.spec.ts | 65 +++++++ src-tauri/src/commands/build.rs | 3 + src/bindings.ts | 5 +- src/features/build/BuildTitleItem.tsx | 6 +- src/features/build/build.test.tsx | 34 +++- src/features/build/index.ts | 18 +- src/features/build/store.ts | 8 +- .../debugger/__tests__/launch.test.ts | 29 +-- src/features/run/ConfigChooser.tsx | 2 +- src/features/run/EditConfigsDialog.tsx | 4 +- src/features/run/beforeLaunch.ts | 5 +- src/features/run/cmake.test.ts | 105 +++++++++++ src/features/run/cmake.ts | 29 +++ src/features/run/configs.test.ts | 10 + src/features/run/configs.ts | 7 +- src/features/run/index.ts | 11 +- src/features/run/store.ts | 5 + src/platform/locales/text/en.json | 7 + src/platform/locales/text/zh.json | 7 + 27 files changed, 670 insertions(+), 90 deletions(-) create mode 100644 src/features/run/cmake.test.ts create mode 100644 src/features/run/cmake.ts diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 8b4119225..b083a5169 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -67,6 +67,11 @@ impl BuildStep { } } + /// A CMake configure/build invocation, parsed as compiler output in the Build view. + pub fn cmake(program: &str, args: Vec, cwd: PathBuf, env: BTreeMap) -> Self { + Self { program: program.into(), args, cwd, env, format: Format::Gcc } + } + /// The command line as echoed in the console. pub fn display(&self) -> String { let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into()); diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 3acb817fe..7bcc9cc99 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -488,15 +488,32 @@ pub async fn maven_exclude_dependency( .map_err(join_err)? } +fn cmake_plan(root: &Path, kind: BuildKind, configs: &[ide_run::RunConfig], id: &str) -> IdeResult { + let commands = ide_run::cmake_build_configs(configs, id)?; + let title = commands.last().map(|c| c.name.clone()).unwrap_or_default(); + let mut steps = Vec::new(); + for mut config in commands { + let cwd = ide_core::project_roots::resolve(root, config.cwd.as_deref().unwrap_or(Path::new(""))); + ide_run::prepare_cmake(&config, &cwd)?; + if kind == BuildKind::Rebuild && config.args.first().is_some_and(|a| a == "--build") { + config.args.insert(2, "--clean-first".into()); + } + steps.push(compile::BuildStep::cmake(&config.command, config.args, cwd, config.env)); + } + Ok(compile::BuildPlan { system: compile::BuildSystem::CMake, title, steps }) +} + /// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the /// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable /// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build) /// are cleared first; the new messages are published as `build` diagnostics. +#[allow(clippy::too_many_arguments)] pub async fn build_run( host: &Host, window: &str, kind: BuildKind, file: Option, + cmake: Option, request: RequestId, clear: Vec, on_event: Channel, @@ -515,7 +532,10 @@ pub async fn build_run( items: Vec::new(), }); } - let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? { + let plan = if let Some(id) = cmake { + let configs = super::run::project_configs(&root, &settings, &config_dir)?; + cmake_plan(&root, kind, &configs, &id)? + } else if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? { let mut combined: Option = None; for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) { let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective(); @@ -612,6 +632,7 @@ pub(crate) fn register(d: &mut Dispatcher) { &c.window, c.arg("kind")?, c.arg("file")?, + c.arg("cmake")?, c.arg("request")?, c.arg("clear")?, c.sender("onEvent")?, @@ -624,6 +645,42 @@ pub(crate) fn register(d: &mut Dispatcher) { mod build_tool_settings_tests { use super::*; + #[test] + fn cmake_builds_keep_target_configuration_environment_and_directory() { + let root = tempfile::tempdir().unwrap(); + let member = root.path().join("nested project"); + std::fs::create_dir_all(&member).unwrap(); + let config = |id: &str, args: &[&str]| -> ide_run::RunConfig { + serde_json::from_value(serde_json::json!({ + "id":id,"name":id,"kind":"cmake","command":"custom-cmake", + "args":args,"cwd":"nested project","env":{"PROFILE":"release"}, + "detected":true,"isTest":false,"debugAdapter":null + })) + .unwrap() + }; + let configure = config("configure", &["-S", ".", "-B", "release", "-G", "Ninja"]); + let mut build = + config("target", &["--build", "release", "--target", "hello", "--config", "Release", "-j", "8"]); + build.extras.before_launch.push(ide_run::BeforeLaunchTask { kind: "build".into(), target: "configure".into() }); + let configs = [configure, build]; + for kind in [BuildKind::Build, BuildKind::Rebuild] { + let plan = cmake_plan(root.path(), kind, &configs, "target").unwrap(); + assert_eq!(plan.system, compile::BuildSystem::CMake); + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].args, configs[0].args); + let mut args = configs[1].args.clone(); + if kind == BuildKind::Rebuild { + args.insert(2, "--clean-first".into()); + } + assert_eq!(plan.steps[1].args, args); + for step in plan.steps { + assert_eq!(step.program, "custom-cmake"); + assert_eq!(step.cwd, member); + assert_eq!(step.env["PROFILE"], "release"); + } + } + } + #[test] fn builds_apply_the_same_explicit_toolchains_as_runs() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 513d6d0dd..558ce392b 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -9,14 +9,20 @@ use std::time::Duration; pub fn configs(host: &Host, window: &str) -> IdeResult> { host.require_trust(window)?; let project = host.project(window)?; - let root = project.root.clone(); - let user = ide_run::load_user_configs(&root)?; - let root_suppressed = ide_run::load_suppressed_config_ids(&root)?; - let settings = project.settings.effective(); - let mut all = detected_and_saved(&root, &settings, &user, &root_suppressed); - if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? { + project_configs(&project.root, &project.settings.effective(), &host.config_dir) +} + +pub(super) fn project_configs( + root: &std::path::Path, + settings: &ide_core::settings::Settings, + config_dir: &std::path::Path, +) -> IdeResult> { + let user = ide_run::load_user_configs(root)?; + let root_suppressed = ide_run::load_suppressed_config_ids(root)?; + let mut all = detected_and_saved(root, settings, &user, &root_suppressed); + if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? { for member in workspace.projects.into_iter().filter(|p| p.is_dir()) { - let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective(); + let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective(); let saved = ide_run::load_user_configs(&member)?; let suppressed = ide_run::load_suppressed_config_ids(&member)?; let configs = detected_and_saved(&member, &settings, &saved, &suppressed); @@ -24,7 +30,7 @@ pub fn configs(host: &Host, window: &str) -> IdeResult> { let name = member.file_name().unwrap_or_default().to_string_lossy(); for mut config in configs { for task in &mut config.extras.before_launch { - if task.kind == "run" { + if matches!(task.kind.as_str(), "run" | "build") && !task.target.is_empty() { task.target = format!("workspace:{}:{}", key, task.target); } } diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 438ccf6ba..3b5b73874 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -62,7 +62,7 @@ pub(crate) fn detect_with_profiles( config.id = scope.id(&config.id); config.name = scope.name(&config.name); for task in &mut config.extras.before_launch { - if task.kind == "run" { + if matches!(task.kind.as_str(), "run" | "build") && !task.target.is_empty() { task.target = scope.id(&task.target); } } diff --git a/crates/ide-run/src/detect/cmake.rs b/crates/ide-run/src/detect/cmake.rs index 93abe0b98..3892e51f0 100644 --- a/crates/ide-run/src/detect/cmake.rs +++ b/crates/ide-run/src/detect/cmake.rs @@ -17,16 +17,57 @@ fn reply_file(reply: &Path, name: &str) -> Option { } fn before(config: &mut RunConfig, id: &str) { - config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() }); + config.extras.before_launch.push(BeforeLaunchTask { kind: "build".into(), target: id.into() }); +} + +#[derive(Default)] +struct Names { + contexts: Vec<(String, String)>, +} + +impl Names { + fn push(&mut self, out: &mut Vec, config: RunConfig, dir: &str, profile: &str) { + self.contexts.push((dir.replace('\\', "/"), profile.into())); + out.push(config); + } + + fn apply(self, configs: &mut [RunConfig]) { + let names: Vec<_> = configs.iter().map(|c| c.name.clone()).collect(); + for (index, config) in configs.iter_mut().enumerate() { + let duplicates: Vec<_> = names.iter().enumerate().filter(|(_, name)| **name == names[index]).collect(); + if duplicates.len() < 2 { + continue; + } + let (dir, profile) = &self.contexts[index]; + let needs_dir = duplicates.iter().any(|(other, _)| *other != index && self.contexts[*other].1 == *profile); + let mut context = Vec::new(); + if !profile.is_empty() { + context.push(profile.clone()); + } + if needs_dir { + context.push(format!("{}/", dir.trim_end_matches('/'))); + } + if !context.is_empty() { + config.name = format!("{} [{}]", config.name, context.join(" · ")); + } + } + } } pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec) { + let start = out.len(); + let mut names = Names::default(); + detect_configs(root, profiles, out, &mut names); + names.apply(&mut out[start..]); +} + +fn detect_configs(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec, names: &mut Names) { if !root.join("CMakeLists.txt").is_file() { return; } if let Some(profiles) = profiles { for (index, profile) in profiles.iter().enumerate() { - detect_profile(root, profile, index, profiles.len() > 1, out); + detect_profile(root, profile, index, out, names); } return; } @@ -62,35 +103,30 @@ pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut V if !build.join("CMakeCache.txt").is_file() { args.push("-DCMAKE_BUILD_TYPE=Debug"); } - let mut configure = config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &args); + let mut configure = config(&configure_id, "CMake Configure", "cmake", "cmake", &args); configure.extras.params.insert("buildDir".into(), dir.clone()); - out.push(configure); - let mut all = config( - &build_id, - format!("CMake Build ({dir})"), - "cmake", - "cmake", - &["--build", &dir, "--config", &configuration], - ); + names.push(out, configure, &dir, ""); + let mut all = + config(&build_id, "CMake Build", "cmake", "cmake", &["--build", &dir, "--config", &configuration]); // `cmake --build` regenerates by itself once a build directory is configured. if !build.join("CMakeCache.txt").is_file() { before(&mut all, &configure_id); } - out.push(all); + names.push(out, all, &dir, ""); let mut test = config( format!("{prefix}:test"), - format!("CTest ({dir})"), + "CTest", "cmake", "ctest", &["--test-dir", &dir, "-C", &configuration, "--output-on-failure"], ); before(&mut test, &build_id); - out.push(test); - targets(root, &build, &dir, &prefix, None, out); + names.push(out, test, &dir, ""); + targets(root, &build, &dir, &prefix, None, out, names); } } -fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec) { +fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, out: &mut Vec, names: &mut Names) { let dir = if profile.build_dir.trim().is_empty() { format!("cmake-build-{}", profile.build_type.to_lowercase()) } else { @@ -101,8 +137,7 @@ fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: b let configure_id = format!("{prefix}:configure"); let build_id = format!("{prefix}:build"); let start = out.len(); - let mut configure = - config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]); + let mut configure = config(&configure_id, "CMake Configure", "cmake", "cmake", &["-S", ".", "-B", &dir]); if !profile.generator.trim().is_empty() { configure.args.extend(["-G".into(), profile.generator.clone()]); } @@ -110,34 +145,26 @@ fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: b configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into()); configure.args.extend_from_slice(&profile.cmake_args); configure.extras.params.insert("buildDir".into(), dir.clone()); - out.push(configure); - let mut all = config( - &build_id, - format!("CMake Build ({dir})"), - "cmake", - "cmake", - &["--build", &dir, "--config", &profile.build_type], - ); + names.push(out, configure, &dir, &profile.name); + let mut all = + config(&build_id, "CMake Build", "cmake", "cmake", &["--build", &dir, "--config", &profile.build_type]); all.args.extend_from_slice(&profile.build_args); if !build.join("CMakeCache.txt").is_file() { before(&mut all, &configure_id); } - out.push(all); + names.push(out, all, &dir, &profile.name); let mut test = config( format!("{prefix}:test"), - format!("CTest ({dir})"), + "CTest", "cmake", "ctest", &["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"], ); before(&mut test, &build_id); - out.push(test); - targets(root, &build, &dir, &prefix, Some(profile), out); + names.push(out, test, &dir, &profile.name); + targets(root, &build, &dir, &prefix, Some(profile), out, names); for config in &mut out[start..] { config.env = profile.env.clone(); - if multiple { - config.name = format!("{} [{}]", config.name, profile.name); - } } } @@ -148,6 +175,7 @@ fn targets( prefix: &str, profile: Option<&CMakeProfile>, out: &mut Vec, + names: &mut Names, ) { let reply = build.join(".cmake/api/v1/reply"); let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| { @@ -198,8 +226,7 @@ fn targets( if !name.is_empty() { args.extend(["--config", name]); } - let mut build_config = - config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args); + let mut build_config = config(&target_build_id, format!("Build {target_name}"), "cmake", "cmake", &args); if let Some(profile) = profile { if name.is_empty() { build_config.args.extend(["--config".into(), profile.build_type.clone()]); @@ -209,11 +236,11 @@ fn targets( before(&mut build_config, &format!("{prefix}:configure")); } } - out.push(build_config); + let label = profile.map_or(name, |p| p.name.as_str()); + names.push(out, build_config, dir, label); let binary = build.join(artifact); let mut run = - config(&id, format!("{target_name} ({dir} {name})"), "native", &binary.to_string_lossy(), &[]) - .debug("lldb-dap"); + config(&id, target_name, "cmake-application", &binary.to_string_lossy(), &[]).debug("lldb-dap"); before(&mut run, &target_build_id); let sources: Vec = target["sources"] .as_array() @@ -231,13 +258,50 @@ fn targets( .params .insert("cmakeCanonicalSourceRoot".into(), source_root.to_string_lossy().replace('\\', "/")); } - out.push(run); + names.push(out, run, dir, label); } } } -/// Request the codemodel only when the user runs a configure configuration. -pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> { +/// Ordered configure/build commands for a CMake build configuration. Executables and CTest +/// are never executed by Build; only their explicit build prerequisites are accepted. +pub fn build_configs(configs: &[RunConfig], id: &str) -> crate::IdeResult> { + fn visit( + configs: &[RunConfig], + id: &str, + chain: &mut Vec, + out: &mut Vec, + ) -> crate::IdeResult<()> { + if chain.iter().any(|entry| entry == id) { + return Err(ide_core::IdeError::invalid("The before launch tasks run in a cycle")); + } + let config = configs + .iter() + .find(|c| c.id == id) + .ok_or_else(|| ide_core::IdeError::not_found(format!("Configuration '{id}' no longer exists")))?; + if config.kind != "cmake" || !config.args.first().is_some_and(|a| a == "--build" || a == "-S") { + return Err(ide_core::IdeError::invalid("Not a CMake configure or build configuration")); + } + chain.push(id.into()); + for task in &config.extras.before_launch { + if !matches!(task.kind.as_str(), "run" | "build") || task.target.is_empty() { + return Err(ide_core::IdeError::invalid("CMake builds require configure/build prerequisites")); + } + visit(configs, &task.target, chain, out)?; + } + chain.pop(); + if !out.iter().any(|c| c.id == id) { + out.push(config.clone()); + } + Ok(()) + } + let mut out = Vec::new(); + visit(configs, id, &mut Vec::new(), &mut out)?; + Ok(out) +} + +/// Request the codemodel when executing a configure configuration. +pub fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> { if config.kind == "cmake" && config.args.first().is_some_and(|a| a == "-S") && let Some(dir) = config.extras.params.get("buildDir") @@ -300,10 +364,10 @@ mod tests { }; let mut configs = Vec::new(); detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs); - let run = configs.iter().find(|c| c.kind == "native").unwrap(); + let run = configs.iter().find(|c| c.kind == "cmake-application").unwrap(); assert!(run.command.ends_with("release/app")); assert_eq!(run.env, profile.env); - assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1); + assert_eq!(configs.iter().filter(|c| c.kind == "cmake-application").count(), 1); let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap(); assert_eq!( build.args, @@ -320,6 +384,34 @@ mod tests { } } + #[test] + fn build_dependencies_are_scoped_and_reject_cycles_or_executables() { + let root = tempfile::tempdir().unwrap(); + let nested = root.path().join("nested"); + fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("CMakeLists.txt"), "project(app)").unwrap(); + let configs = crate::detect(root.path()); + let build = configs.iter().find(|c| c.args.first().is_some_and(|a| a == "--build")).unwrap(); + assert_eq!(build.extras.before_launch[0].kind, "build"); + let ordered = build_configs(&configs, &build.id).unwrap(); + assert_eq!(ordered.len(), 2); + assert_eq!(ordered[0].args[0], "-S"); + assert_eq!(ordered[1].args[0], "--build"); + assert_eq!(ordered[1].cwd, build.cwd); + assert!(build_configs(&configs, "missing").is_err()); + let mut cycle = configs.clone(); + let configure = cycle.iter_mut().find(|c| c.id == ordered[0].id).unwrap(); + before(configure, &build.id); + assert!(build_configs(&cycle, &build.id).is_err()); + for kind in ["native", "cmake-application", "shell"] { + let mut invalid = build.clone(); + invalid.kind = kind.into(); + assert!(build_configs(&[invalid.clone()], &invalid.id).is_err()); + } + let test = configs.iter().find(|c| c.command == "ctest").unwrap(); + assert!(build_configs(&configs, &test.id).is_err()); + } + #[test] fn generator_change_clears_only_cmake_state_at_launch() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index a352ee838..f54bbc46f 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -20,6 +20,7 @@ mod utf8; pub mod which; pub mod wrap; +pub use detect::cmake::{build_configs as cmake_build_configs, prepare as prepare_cmake}; pub use process::{ProcessManager, StartOptions}; use ide_core::{IdeResult, RunId}; @@ -59,7 +60,8 @@ pub struct RunConfig { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct BeforeLaunchTask { - /// `run` (another configuration, `target` = its id) | `build` | `tool` (external tool id) | + /// `run` (another configuration id) | `build` (project, or `target` = CMake build id) | + /// `tool` (external tool id) | /// `command` (a shell command line) | `toolWindow` (tool window id). pub kind: String, #[serde(default)] diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 73e5d3dc4..4bff19d05 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -676,6 +676,130 @@ fn cmake_detection_is_read_only_and_preserves_existing_build_directories() { assert_eq!(test.extras.before_launch[0].target, build.id); } +fn cmake_reply(root: &Path, dir: &str, configurations: &[(&str, &[&str])]) { + let reply = root.join(dir).join(".cmake/api/v1/reply"); + fs::create_dir_all(&reply).unwrap(); + fs::write(root.join(dir).join("CMakeCache.txt"), "").unwrap(); + let write = |name: &str, value: serde_json::Value| fs::write(reply.join(name), value.to_string()).unwrap(); + write( + "index-1.json", + serde_json::json!({ + "objects": [{"kind": "codemodel", "version": {"major": 2}, "jsonFile": "model.json"}] + }), + ); + let configurations: Vec<_> = configurations + .iter() + .map(|(configuration, targets)| { + let targets: Vec<_> = targets + .iter() + .map(|target| { + let file = format!("{configuration}-{target}.json"); + write( + &file, + serde_json::json!({ + "name": target, "type": "EXECUTABLE", "artifacts": [{"path": target}] + }), + ); + serde_json::json!({"jsonFile": file}) + }) + .collect(); + serde_json::json!({"name": configuration, "targets": targets}) + }) + .collect(); + write("model.json", serde_json::json!({"paths": {"source": root}, "configurations": configurations})); +} + +#[test] +fn cmake_names_only_include_context_that_distinguishes_duplicates() { + for configuration in ["", "Debug"] { + let root = project(&[("CMakeLists.txt", "project(demo)")]); + cmake_reply(root.path(), "build", &[(configuration, &["hello", "other"])]); + let single = detect(root.path()); + let expected = ["CMake Configure", "CMake Build", "CTest", "Build hello", "hello", "Build other", "other"]; + assert_eq!(single.iter().map(|c| c.name.as_str()).collect::>(), expected); + assert_eq!(find(&single, &format!("cmake:build:{configuration}:hello")).name, "hello"); + + cmake_reply(root.path(), "build-debug", &[(configuration, &["hello", "unique"])]); + let multiple = detect(root.path()); + let context = if configuration.is_empty() { "build/" } else { "Debug · build/" }; + assert_eq!(find(&multiple, &format!("cmake:build:{configuration}:hello")).name, format!("hello [{context}]")); + assert_eq!( + find(&multiple, &format!("cmake:build:{configuration}:hello:build")).name, + format!("Build hello [{context}]") + ); + let context = if configuration.is_empty() { "build-debug/" } else { "Debug · build-debug/" }; + assert_eq!( + find(&multiple, &format!("cmake:build-debug:{configuration}:hello")).name, + format!("hello [{context}]") + ); + for (id, name) in [("configure", "CMake Configure"), ("build", "CMake Build"), ("test", "CTest")] { + for dir in ["build", "build-debug"] { + assert_eq!(find(&multiple, &format!("cmake:{dir}:{id}")).name, format!("{name} [{dir}/]")); + } + } + assert_eq!(find(&multiple, &format!("cmake:build:{configuration}:other")).name, "other"); + assert_eq!(find(&multiple, &format!("cmake:build-debug:{configuration}:unique")).name, "unique"); + for mut config in single { + let renamed = find(&multiple, &config.id); + config.name = renamed.name.clone(); + assert_eq!(&config, renamed, "only names may change when another directory appears"); + } + } +} + +#[test] +fn cmake_multiconfig_names_use_configuration_without_redundant_directory() { + let root = project(&[("CMakeLists.txt", "project(demo)")]); + cmake_reply(root.path(), "build", &[("Debug", &["hello"]), ("Release", &["hello"])]); + let configs = detect(root.path()); + for configuration in ["Debug", "Release"] { + assert_eq!( + find(&configs, &format!("cmake:build:{configuration}:hello")).name, + format!("hello [{configuration}]") + ); + assert_eq!( + find(&configs, &format!("cmake:build:{configuration}:hello:build")).name, + format!("Build hello [{configuration}]") + ); + } + assert_eq!(find(&configs, "cmake:build:build").name, "CMake Build"); +} + +#[test] +fn cmake_profile_names_disambiguate_before_adding_directory_locations() { + use ide_core::settings::CMakeProfile; + let root = project(&[("CMakeLists.txt", "project(demo)")]); + cmake_reply(root.path(), "out/debug", &[("Debug", &["hello"])]); + cmake_reply(root.path(), "out/release", &[("Release", &["hello"])]); + for same_name in [false, true] { + let profiles = [ + CMakeProfile { name: "Development".into(), build_dir: "out/debug".into(), ..Default::default() }, + CMakeProfile { + name: if same_name { "Development" } else { "Production" }.into(), + build_type: "Release".into(), + build_dir: "out/release".into(), + ..Default::default() + }, + ]; + let configs = ide_run::detect_with_profiles(root.path(), &[], Some(&profiles)); + for (index, profile) in profiles.iter().enumerate() { + let context = + if same_name { format!("{} · {}/", profile.name, profile.build_dir) } else { profile.name.clone() }; + for (id, name) in [("configure", "CMake Configure"), ("build", "CMake Build"), ("test", "CTest")] { + assert_eq!(find(&configs, &format!("cmake:profile:{index}:{id}")).name, format!("{name} [{context}]")); + } + assert_eq!( + find(&configs, &format!("cmake:profile:{index}:{}:hello", profile.build_type)).name, + format!("hello [{context}]") + ); + assert_eq!( + find(&configs, &format!("cmake:profile:{index}:{}:hello:build", profile.build_type)).name, + format!("Build hello [{context}]") + ); + } + } +} + #[test] fn cmake_configures_before_the_first_build() { let dir = project(&[("CMakeLists.txt", "project(demo)")]); @@ -693,7 +817,7 @@ fn cmake_ignores_malformed_file_api_replies() { ]); let configs = detect(dir.path()); assert_eq!(configs.iter().filter(|c| c.kind == "cmake").count(), 3); - assert!(!configs.iter().any(|c| c.kind == "native")); + assert!(!configs.iter().any(|c| c.kind == "cmake-application")); } #[test] @@ -789,7 +913,7 @@ fn cmake_profiles_control_commands_names_and_environment() { "", ]); assert_eq!(configure.args, expected); - assert_eq!(configure.name, "CMake Configure (output dir)"); + assert_eq!(configure.name, "CMake Configure"); let build = find(&configs, "cmake:profile:0:build"); assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]); assert_eq!(build.extras.before_launch[0].target, configure.id); diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index 94765dedd..d2c9caa6d 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -572,7 +572,7 @@ fn cmake_configure_discovers_builds_and_runs_a_multifile_target() { assert!(h.root.path().join("build/.cmake/api/v1/query/client-openidea/codemodel-v2").exists()); let root = h.root.path().canonicalize().unwrap(); let configs = ide_run::detect(&root); - let target = configs.iter().find(|c| c.kind == "native").expect("File API executable"); + let target = configs.iter().find(|c| c.kind == "cmake-application").expect("File API executable"); assert_eq!(target.debug_adapter.as_deref(), Some("lldb-dap")); let sources = &target.extras.params["cmakeSources"]; assert!(sources.lines().any(|s| s.ends_with("/main.cpp"))); diff --git a/e2e/run.spec.ts b/e2e/run.spec.ts index 78902a460..5369b435a 100644 --- a/e2e/run.spec.ts +++ b/e2e/run.spec.ts @@ -143,3 +143,68 @@ test("Go reruns keep each package's exact failures and the original configuratio ["test", "-json", "./..."], ]); }); + +test("CMake builds the selected target in Build before starting only the application in Run", async ({ + workbench: page, +}) => { + await stubDesktop(page); + await page.evaluate(async () => { + const build = { + id: "cmake:release:hello:build", + name: "Build hello", + kind: "cmake", + command: "cmake", + args: ["--build", "release", "--target", "hello", "--config", "Release"], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + }; + const app = { + ...build, + id: "cmake:release:hello", + name: "hello [Release, release]", + kind: "cmake-application", + command: "/dev/acme-api/release/hello", + args: [], + debugAdapter: "lldb-dap", + beforeLaunch: [{ kind: "build", target: build.id }], + }; + const h = window.__ipc.handlers; + h.run_configs = () => [build, app]; + h.run_apply_settings = (args) => args.configs; + h.build_run = (args) => { + window.__ipc.send(args.onEvent, { kind: "started", system: "cmake", title: "Build hello" }); + window.__ipc.send(args.onEvent, { kind: "output", text: "COMPILER_OUTPUT\n", stderr: false }); + return { + system: "cmake", + title: "Build hello", + success: true, + errors: 0, + warnings: 0, + durationMs: 1, + messages: [], + }; + }; + h.run_start = () => { + setTimeout(() => window.__ipc.emit({ type: "runExited", run: 7, code: 0 }), 100); + return { id: 7, configId: app.id, name: app.name, pid: 4242, running: true, pty: false }; + }; + const runModule = "/src/features/run/store.ts"; + const { loadConfigs, selectConfig } = await import(/* @vite-ignore */ runModule); + await loadConfigs(); + selectConfig(app.id); + }); + await page.getByRole("button", { name: "Build Project", exact: true }).click(); + const build = page.getByRole("region", { name: "Build", exact: true }); + await expect(build.getByText("COMPILER_OUTPUT", { exact: true })).toBeVisible(); + expect(await ipcCalls(page, "run_start")).toHaveLength(0); + expect(await ipcCalls(page, "build_run")).toEqual([expect.objectContaining({ cmake: "cmake:release:hello:build" })]); + await page.keyboard.press("Control+r"); + await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(1); + expect(await ipcCalls(page, "build_run")).toHaveLength(2); + const run = page.getByRole("region", { name: "Run", exact: true }); + await expect(run.getByRole("tab", { name: "hello [Release, release]" })).toBeVisible(); + await expect(run.getByText("COMPILER_OUTPUT", { exact: true })).toHaveCount(0); +}); diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 2e07f0f71..2385c36cd 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -150,11 +150,13 @@ pub async fn maven_exclude_dependency( /// are cleared first; the new messages are published as `build` diagnostics. #[tauri::command] #[specta::specta] +#[allow(clippy::too_many_arguments)] pub async fn build_run( window: Window, state: State<'_, AppState>, kind: BuildKind, file: Option, + cmake: Option, request: RequestId, clear: Vec, on_event: Channel, @@ -164,6 +166,7 @@ pub async fn build_run( window.label(), kind, file, + cmake, request, clear, crate::commands::sender(on_event), diff --git a/src/bindings.ts b/src/bindings.ts index 12dd4034a..c8f2ea933 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -761,7 +761,7 @@ export const commands = { * with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build) * are cleared first; the new messages are published as `build` diagnostics. */ - buildRun: (kind: BuildKind, file: string | null, request: RequestId, clear: string[], onEvent: Channel) => typedError(__TAURI_INVOKE("build_run", { kind, file, request, clear, onEvent })), + buildRun: (kind: BuildKind, file: string | null, cmake: string | null, request: RequestId, clear: string[], onEvent: Channel) => typedError(__TAURI_INVOKE("build_run", { kind, file, cmake, request, clear, onEvent })), /** * Cancellable with `cancel_request(request)`. * Builds the workspace (when `build`), then resolves the runtime (or test) @@ -1949,7 +1949,8 @@ export type AutoUpload = "never" | /** A step run before a configuration launches; a failing step aborts the launch. */ export type BeforeLaunchTask = { /** - * `run` (another configuration, `target` = its id) | `build` | `tool` (external tool id) | + * `run` (another configuration id) | `build` (project, or `target` = CMake build id) | + * `tool` (external tool id) | * `command` (a shell command line) | `toolWindow` (tool window id). */ kind: string, diff --git a/src/features/build/BuildTitleItem.tsx b/src/features/build/BuildTitleItem.tsx index 02c7f830a..58881bbaf 100644 --- a/src/features/build/BuildTitleItem.tsx +++ b/src/features/build/BuildTitleItem.tsx @@ -8,10 +8,12 @@ import styles from "./Build.module.css"; /** Run configuration kinds a project build precedes; scripts (Python, shell, Node…) have nothing to compile. */ const COMPILED_KINDS = new Set(["", "spring-boot", "application", "maven", "gradle"]); -/** IDEA's main toolbar hammer, left of the run widget: Maven / Gradle projects, unless a script configuration is selected. */ +/** Main toolbar hammer, left of the run widget, for compiled project configurations. */ export function BuildTitleItem() { const shown = useContextKeys( - (c) => (!!c["build.maven"] || !!c["build.gradle"]) && COMPILED_KINDS.has(String(c["run.selectedKind"] ?? "")), + (c) => + !!c["run.cmakeBuild"] || + ((!!c["build.maven"] || !!c["build.gradle"]) && COMPILED_KINDS.has(String(c["run.selectedKind"] ?? ""))), ); const running = useContextKeys((c) => !!c[BUILD_RUNNING]); if (!shown) return null; diff --git a/src/features/build/build.test.tsx b/src/features/build/build.test.tsx index 26036e07b..28a7a9aec 100644 --- a/src/features/build/build.test.tsx +++ b/src/features/build/build.test.tsx @@ -16,6 +16,7 @@ import { type ToolWindowService, } from "@/workbench/contributions"; import { useTasks } from "@/workbench/tasks"; +import { BuildTitleItem } from "./BuildTitleItem"; import { BuildView } from "./BuildView"; import { activate } from "./index"; import { @@ -86,13 +87,20 @@ describe("build model", () => { type Note = Parameters[0]; class FakeBackend implements BuildBackend { - calls: Array<{ kind: string; file: string | null; clear: string[] }> = []; + calls: Array<{ kind: string; file: string | null; clear: string[]; cmake?: string }> = []; cancelled: number[] = []; private finish?: (r: BuildResult) => void; private fail?: (e: unknown) => void; emit?: (e: BuildEvent) => void; - run(kind: string, file: string | null, _request: number, clear: string[], onEvent: (e: BuildEvent) => void) { - this.calls.push({ kind, file, clear }); + run( + kind: string, + file: string | null, + _request: number, + clear: string[], + onEvent: (e: BuildEvent) => void, + cmake: string | null, + ) { + this.calls.push({ kind, file, clear, ...(cmake ? { cmake } : {}) }); this.emit = onEvent; return new Promise((resolve, reject) => { this.finish = resolve; @@ -150,6 +158,26 @@ describe("build feature", () => { setBuildBackend(null); useProject.setState({ project: null }); setContext("projectOpen", false); + setContext("run.cmakeBuild", null); + }); + + it("shows the CMake hammer and builds the selected target through the shared action", async () => { + setContext("run.cmakeBuild", "cmake:release:hello:build"); + render(); + expect(screen.getByRole("button", { name: "Build Project" })).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Build Project" })); + await act(async () => {}); + expect(fake.calls.at(-1)).toMatchObject({ cmake: "cmake:release:hello:build" }); + await act(async () => { + fake.resolve(result([], true)); + }); + const explicit = executeAction("build.buildProject", { cmake: "cmake:debug:other:build", activate: false }); + await act(async () => {}); + expect(fake.calls.at(-1)).toMatchObject({ cmake: "cmake:debug:other:build" }); + await act(async () => { + fake.resolve(result([], true)); + await explicit; + }); }); it("registers the Build menu actions, shortcuts and an on-use bottom tool window", () => { diff --git a/src/features/build/index.ts b/src/features/build/index.ts index 7eaab3c00..0e6421c46 100644 --- a/src/features/build/index.ts +++ b/src/features/build/index.ts @@ -1,8 +1,9 @@ /** * Build menu (Build Project ⌘F9, Build Module ⇧⌘F9, Rebuild Project, Build * Artifacts) and the Build tool window. The backend picks the build system of - * the project or module: Maven, Gradle, Cargo, Go or TypeScript. + * the project or module: Maven, Gradle, Cargo, Go, TypeScript or CMake. */ +import { getContext } from "@/platform/context"; import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; import { @@ -57,11 +58,17 @@ export function activate(): () => void { category: "Build", icon: "build-project", when: WHEN, - keywords: ["compile", "make", "mvn", "gradle", "cargo", "tsc", "go build"], + keywords: ["compile", "make", "mvn", "gradle", "cargo", "tsc", "go build", "cmake"], // Awaitable: Run's "Build" before launch task waits for the result. run: async ({ arg }) => { const activate = !(typeof arg === "object" && arg !== null && "activate" in arg && arg.activate === false); - await startBuild("build", null, activate); + const cmake = + arg == null + ? getContext()["run.cmakeBuild"] + : typeof arg === "object" && arg !== null && "cmake" in arg + ? arg.cmake + : null; + await startBuild("build", null, activate, typeof cmake === "string" ? cmake : null); }, }, { @@ -84,7 +91,10 @@ export function activate(): () => void { icon: "build-rebuild", when: WHEN, keywords: ["clean", "compile"], - run: () => void startBuild("rebuild"), + run: () => { + const cmake = getContext()["run.cmakeBuild"]; + void startBuild("rebuild", null, true, typeof cmake === "string" ? cmake : null); + }, }, { id: "build.buildArtifacts", diff --git a/src/features/build/store.ts b/src/features/build/store.ts index 84454e792..1b6fe02fc 100644 --- a/src/features/build/store.ts +++ b/src/features/build/store.ts @@ -41,15 +41,16 @@ export interface BuildBackend { request: RequestId, clear: string[], onEvent: (e: BuildEvent) => void, + cmake: string | null, ): Promise; cancel(request: RequestId): void; } const tauriBackend: BuildBackend = { - run(kind, file, request, clear, onEvent) { + run(kind, file, request, clear, onEvent, cmake) { const channel = new Channel(); channel.onmessage = onEvent; - return call(commands.buildRun(kind, file, request, clear, channel)); + return call(commands.buildRun(kind, file, cmake, request, clear, channel)); }, cancel(request) { void commands.cancelRequest(request); @@ -130,6 +131,7 @@ export async function startBuild( kind: BuildKind, file: string | null = null, activate = true, + cmake: string | null = null, ): Promise { if (isBuilding() || !useProject.getState().project) return null; const request = nextRequestId(); @@ -170,7 +172,7 @@ export async function startBuild( let result: BuildResult | null = null; try { - result = await backend.run(kind, file, request, previous, onEvent); + result = await backend.run(kind, file, request, previous, onEvent, cmake); clearTimeout(timer); flush(); const status: BuildStatus = result.success ? "success" : "failed"; diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index 4658aa663..191b3dba8 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -299,19 +299,22 @@ describe("run config → launch config", () => { expect(extra(dap)).toEqual({ env: ["K=v"] }); }); - it("maps CMake debug-info paths to the editor's canonical source root", () => { - const config = rc({ - kind: "native", - command: "/private/tmp/project/build/app", - debugAdapter: "lldb-dap", - env: { K: "v" }, - params: { cmakeSourceRoot: "/tmp/project", cmakeCanonicalSourceRoot: "/private/tmp/project" }, - }); - expect(extra(launchConfigFor(config, "/private/tmp/project")!)).toEqual({ - env: ["K=v"], - sourceMap: [["/tmp/project", "/private/tmp/project"]], - }); - }); + it.each(["native", "cmake-application"])( + "maps %s CMake debug-info paths to the editor's canonical source root", + (kind) => { + const config = rc({ + kind, + command: "/private/tmp/project/build/app", + debugAdapter: "lldb-dap", + env: { K: "v" }, + params: { cmakeSourceRoot: "/tmp/project", cmakeCanonicalSourceRoot: "/private/tmp/project" }, + }); + expect(extra(launchConfigFor(config, "/private/tmp/project")!)).toEqual({ + env: ["K=v"], + sourceMap: [["/tmp/project", "/private/tmp/project"]], + }); + }, + ); it("Godot: the editor's adapter plays the main scene or the chosen one of the project", () => { const project = rc({ diff --git a/src/features/run/ConfigChooser.tsx b/src/features/run/ConfigChooser.tsx index 967d53437..48c74268a 100644 --- a/src/features/run/ConfigChooser.tsx +++ b/src/features/run/ConfigChooser.tsx @@ -263,7 +263,7 @@ function ConfigChooser() { items={rows} getKey={(r) => r.key} isSection={isSection} - renderSection={(r) => (r.type === "section" ? r.label : null)} + renderSection={(r) => (r.type === "section" ? {r.label} : null)} activeIndex={clamped} onActiveChange={setActive} focusable={false} diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 965c6de2f..311f251ae 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -504,7 +504,9 @@ function EditConfigsDialog() { > - {r.label} + + {r.label} + ); if (r.type === "folder") { diff --git a/src/features/run/beforeLaunch.ts b/src/features/run/beforeLaunch.ts index c5984c722..5c3e128a1 100644 --- a/src/features/run/beforeLaunch.ts +++ b/src/features/run/beforeLaunch.ts @@ -2,6 +2,7 @@ * Before launch tasks (IDEA's "Before launch" list): run in order before a * configuration starts; the first failure aborts the launch. */ +import { buildCMake, isCMakeBuild } from "./cmake"; import { dotnetBuildConfig } from "./dotnetTypes"; import { executeAction, getAction } from "@/platform/actions"; import { getContext } from "@/platform/context"; @@ -33,7 +34,7 @@ export function taskLabel(task: BeforeLaunchTask, configs: readonly RunConfig[] case "run": return `Run '${configs.find((c) => c.id === target)?.name ?? target}'`; case "build": - return "Build Project"; + return configs.find((c) => c.id === target)?.name ?? "Build Project"; case "dotnetBuild": return "Build .NET project"; case "tool": @@ -71,6 +72,7 @@ async function runTask( case "dotnetBuild": return (await runToEnd(dotnetBuildConfig(config), chain, show)) ? null : "Build failed"; case "build": { + if (target) return buildCMake(target, show === true); if (!getAction(BUILD_ACTION)) return "Building is not available"; if (!(await executeAction(BUILD_ACTION, { activate: false }))) return "A build is already running"; return getContext()[BUILD_STATUS] === "success" ? null : "Build failed"; @@ -79,6 +81,7 @@ async function runTask( if (chain.includes(target)) return "The before launch tasks run in a cycle"; const other = useRun.getState().configs.find((c) => c.id === target); if (!other) return `Configuration '${target}' no longer exists`; + if (isCMakeBuild(other)) return buildCMake(other.id, show === true); return (await runToEnd(other, chain, show)) ? null : `'${other.name}' failed`; } case "tool": { diff --git a/src/features/run/cmake.test.ts b/src/features/run/cmake.test.ts new file mode 100644 index 000000000..5225469b6 --- /dev/null +++ b/src/features/run/cmake.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { registerActions } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import type { RunConfig } from "@/platform/ipc"; +import { provideNotificationService } from "@/workbench/contributions"; +import { validateConfig } from "./configs"; +import { cmakeBuildId, isCMakeBuild } from "./cmake"; +import { runBeforeLaunch, taskLabel } from "./beforeLaunch"; +import { resetRuns, startRun, useRun } from "./store"; + +const config = (id: string, kind: string, args: string[] = []): RunConfig => ({ + id, + kind, + args, + name: id, + command: "cmake", + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, +}); +const configure = config("cmake:build:configure", "cmake", ["-S", ".", "-B", "build"]); +const build = config("cmake:build:build", "cmake", ["--build", "build"]); +const target = config("cmake:release:hello:build", "cmake", [ + "--build", + "release", + "--target", + "hello", + "--config", + "Release", +]); +const app = { ...config("hello", "cmake-application"), beforeLaunch: [{ kind: "build", target: target.id }] }; +const configs = [configure, build, target, app]; +afterEach(() => resetRuns()); + +it("selects the target build, configure's build directory, and CTest's prerequisite", () => { + expect(cmakeBuildId(configure, configs)).toBe(build.id); + expect(cmakeBuildId(target, configs)).toBe(target.id); + expect(cmakeBuildId(app, configs)).toBe(target.id); + expect( + cmakeBuildId( + { ...config("ctest", "cmake", ["--test-dir", "build"]), beforeLaunch: [{ kind: "build", target: build.id }] }, + configs, + ), + ).toBe(build.id); + expect(cmakeBuildId(config("script", "shell"), configs)).toBeNull(); + expect(cmakeBuildId(undefined, configs)).toBeNull(); + expect(isCMakeBuild(config("test", "cmake", ["--test-dir", "build"]))).toBe(false); +}); + +it.each(["success", "failed", "cancelled"])( + "awaits a %s target build before launching, including saved run prerequisites", + async (status) => { + const notify = vi.fn(); + provideNotificationService({ notify }); + useRun.setState({ configs }); + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const run = vi.fn(async () => { + await pending; + setContext("build.lastStatus", status); + }); + const dispose = registerActions([{ id: "build.buildProject", title: "Build Project", run }]); + try { + const launch = runBeforeLaunch(app, [app.id]); + expect(run).toHaveBeenCalledWith(expect.objectContaining({ arg: { cmake: target.id, activate: false } })); + finish(); + expect(await launch).toBe(status === "success"); + const legacy = { ...app, beforeLaunch: [{ kind: "run", target: target.id }] }; + expect(await runBeforeLaunch(legacy, [app.id])).toBe(status === "success"); + expect(taskLabel(app.beforeLaunch[0]!, configs)).toBe(target.name); + expect(useRun.getState().sessions).toHaveLength(0); + } finally { + dispose(); + } + }, +); + +it("sends explicit configure and build configurations to Build without creating Run tabs", async () => { + const run = vi.fn(async () => setContext("build.lastStatus", "success")); + const dispose = registerActions([{ id: "build.buildProject", title: "Build Project", run }]); + try { + for (const c of [configure, build, target]) { + expect(await startRun(c)).toBeNull(); + expect(run).toHaveBeenLastCalledWith(expect.objectContaining({ arg: { cmake: c.id, activate: true } })); + } + expect(useRun.getState().sessions).toHaveLength(0); + } finally { + dispose(); + } +}); + +it("validates target build prerequisites while allowing a generic project build", () => { + expect(validateConfig(app, configs).beforeLaunch).toBeUndefined(); + expect(validateConfig(app, []).beforeLaunch).toBeDefined(); + expect( + validateConfig({ ...app, beforeLaunch: [{ kind: "build", target: app.id }] }, configs).beforeLaunch, + ).toBeDefined(); + expect( + validateConfig({ ...app, beforeLaunch: [{ kind: "build", target: "" }] }, configs).beforeLaunch, + ).toBeUndefined(); +}); diff --git a/src/features/run/cmake.ts b/src/features/run/cmake.ts new file mode 100644 index 000000000..24cb8012e --- /dev/null +++ b/src/features/run/cmake.ts @@ -0,0 +1,29 @@ +import { executeAction, getAction } from "@/platform/actions"; +import { getContext } from "@/platform/context"; +import type { RunConfig } from "@/platform/ipc"; + +export function isCMakeBuild(config: RunConfig): boolean { + return config.kind === "cmake" && ["-S", "--build"].includes(config.args[0] ?? ""); +} + +/** The selected target's build, including saved configurations and CTest. */ +export function cmakeBuildId(config: RunConfig | undefined, configs: readonly RunConfig[]): string | null { + if (!config) return null; + if (isCMakeBuild(config)) { + if (config.args[0] === "--build") return config.id; + const build = configs.find((c) => c.id !== config.id && c.id === config.id.replace(/:configure$/, ":build")); + return build && isCMakeBuild(build) ? build.id : null; + } + return ( + config.beforeLaunch + ?.filter((task) => task.kind === "run" || task.kind === "build") + .map((task) => configs.find((c) => c.id === task.target)) + .find((c) => c && isCMakeBuild(c))?.id ?? null + ); +} + +export async function buildCMake(id: string, activate = false): Promise { + if (!getAction("build.buildProject")) return "Building is not available"; + if (!(await executeAction("build.buildProject", { cmake: id, activate }))) return "A build is already running"; + return getContext()["build.lastStatus"] === "success" ? null : "Build failed"; +} diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 64a0abb10..53b9efa59 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -45,6 +45,16 @@ describe("config merge / override", () => { ]); }); + it("keeps saved CMake names when generated context changes", () => { + const id = "cmake:build:Debug:hello"; + const saved = cfg({ id, kind: "cmake-application", name: "My hello (build)", detected: false }); + for (const name of ["hello", "hello [Debug · build/]"]) { + const generated = cfg({ id, kind: "cmake-application", name }); + const merged = mergeConfigs([generated], [saved]); + expect(merged).toEqual([saved]); + } + }); + it("editing a detected config creates a user copy with the same id", () => { const edited = upsertConfig(detected, { ...detected[0]!, args: ["run", "dev", "--port", "3001"] }); expect(edited[0]).toMatchObject({ id: "npm:dev", detected: false, args: ["run", "dev", "--port", "3001"] }); diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index 4e42f832c..7f759a216 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -29,6 +29,7 @@ const KIND_LABELS: Record = { kotlinc: "Kotlin Single File", cc: "C/C++ Single File", native: "Native Executable", + "cmake-application": "CMake Application", ruby: "Ruby", php: "PHP", perl: "Perl", @@ -85,6 +86,7 @@ export function configIcon(config: Pick): string { case "rustc": return "file-rust"; case "cc": + case "cmake-application": return "file-cpp"; case "shell": return "file-shell"; @@ -228,7 +230,10 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = [] } else if (!config.command.trim() && !useRunConfigTypes.getState().types.get(config.kind)?.debugOnly) errors.command = "Command is required"; for (const t of config.beforeLaunch ?? []) { - if (t.kind === "run" && (t.target === config.id || !all.some((c) => c.id === t.target))) + if ( + (t.kind === "run" || (t.kind === "build" && t.target)) && + (t.target === config.id || !all.some((c) => c.id === t.target)) + ) errors.beforeLaunch = "A before launch task runs a missing configuration or this one"; else if (t.kind !== "build" && t.kind !== "dotnetBuild" && t.kind !== "run" && !(t.target ?? "").trim()) errors.beforeLaunch = "A before launch task is incomplete"; diff --git a/src/features/run/index.ts b/src/features/run/index.ts index bca9c6987..9655f46cb 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -4,7 +4,7 @@ */ import { registerEditorContribution } from "@/editor/api"; import { executeAction, getAction, registerActions } from "@/platform/actions"; -import { getContext, setContext } from "@/platform/context"; +import { getContext, setContext, useContextKeys } from "@/platform/context"; import { call, commands, onIdeEvent, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { onWorkflowSettingsChange } from "@/platform/workflowSettings"; @@ -44,6 +44,7 @@ import { RunWidget } from "./RunWidget"; import { runConfigsRunAnything } from "./runAnything"; import { cleanupRemovedProject, recordRun, runServiceProvider } from "./services"; import { liveServiceSession, serviceSessions } from "./serviceSessions"; +import { cmakeBuildId } from "./cmake"; import { runBeforeLaunch } from "./beforeLaunch"; import { compoundChildren } from "./configs"; import { containerDebuggable, remoteDebugBlocked } from "./remote"; @@ -636,7 +637,10 @@ export function activate(): () => void { onIdeEvent("runExited", (e) => onRunExited(e.run, e.code)), // Lets other title items follow the selected configuration (e.g. Build shows only for compiled ones). useRun.subscribe((s) => { - setContext("run.selectedKind", s.configs.find((c) => c.id === s.selectedId)?.kind ?? ""); + const selected = s.configs.find((c) => c.id === s.selectedId); + setContext("run.selectedKind", selected?.kind ?? ""); + const cmake = selected ?? s.configs.find((c) => c.kind === "cmake" && c.args[0] === "--build"); + setContext("run.cmakeBuild", cmakeBuildId(cmake, s.configs)); setContext("run.updatableRunning", updatableSessions().length > 0); }), useProject.subscribe((s, prev) => { @@ -661,6 +665,9 @@ export function activate(): () => void { if (paths.some((p) => p.endsWith("/__init__.py"))) invalidatePythonPackages(); if (paths.some((p) => CONFIG_FILES.test(p))) scheduleReload(); }), + useContextKeys.subscribe((s, prev) => { + if (prev["build.running"] && !s["build.running"]) scheduleReload(); + }), onIdeEvent("runExited", (e) => { if (useRun.getState().sessions.some((s) => s.runId === e.run && s.config.kind === "cmake")) scheduleReload(); }), diff --git a/src/features/run/store.ts b/src/features/run/store.ts index eab344fff..9df27bead 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -48,6 +48,7 @@ import { TeamcityOutput } from "./testing/teamcity"; import type { TestStdout } from "./testing/types"; import { recordTestStates } from "./testing/states"; import { failedTestPaths, leafTestPaths, rerunFailedConfigs, testsConfigs } from "./testing/rerun"; +import { buildCMake, isCMakeBuild } from "./cmake"; import { compoundChildren } from "./configs"; import { remoteLabel, startWithRemedies } from "./remote"; import { TerminalOutput } from "./terminalOutput"; @@ -710,6 +711,10 @@ export async function startRun( chain?: readonly string[]; } = {}, ): Promise { + if (!options.launch && isCMakeBuild(config)) { + await buildCMake(config.id, options.show !== false); + return null; + } if (config.kind === "compound") { await startCompound(config, options.chain, options.show); return null; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 05c1adb3f..5db21f60d 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,11 @@ { + "CMake Configure": "CMake Configure", + "CMake Build": "CMake Build", + "CTest": "CTest", + "Build {0}": "Build {0}", + "Not a CMake configure or build configuration": "Not a CMake configure or build configuration", + "CMake builds require configure/build prerequisites": "CMake builds require configure/build prerequisites", + "CMake Application": "CMake Application", "Invalid PTY request": "Invalid PTY request", "Cannot write to PTY": "Cannot write to PTY", "Cannot resize PTY": "Cannot resize PTY", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 6304d6b8f..ecde6c917 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,11 @@ { + "CMake Configure": "CMake 配置", + "CMake Build": "CMake 构建", + "CTest": "CTest", + "Build {0}": "构建 {0}", + "Not a CMake configure or build configuration": "不是 CMake 配置或构建配置", + "CMake builds require configure/build prerequisites": "CMake 构建需要配置或构建前置任务", + "CMake Application": "CMake 应用程序", "Invalid PTY request": "无效的 PTY 请求", "Cannot write to PTY": "无法写入 PTY", "Cannot resize PTY": "无法调整 PTY 大小",