From 92b3ae508c8fda460329c81564579196a1575e8c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:06:11 +0800 Subject: [PATCH] feat(run): Tomcat local run configuration (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tomcat Server › Local run configurations for WAR applications: installation validation and version detection, Maven/Gradle WAR artifact discovery and build before launch, WAR file or exploded-directory deployment, context path and HTTP port, project JDK or a chosen JRE, VM options, environment variables and opening the browser. Each configuration runs with its own CATALINA_BASE in the project cache (HTTP, shutdown and local JMX ports configured there); Stop requests a clean shutdown before the usual process timeout. Debug attaches the Java debugger over JDWP. Update Application (Cmd+F10) updates resources, classes and resources with HotSwap, redeploys or restarts the server. Configurations are grouped under Tomcat Server in the configuration tree and Services. Not verified on a real Windows machine; command construction is unit tested. Fixes #205 --- Cargo.lock | 1 + crates/ide-build/src/gradle.rs | 6 + crates/ide-build/src/lib.rs | 1 + crates/ide-build/src/web.rs | 169 +++++++ crates/ide-host/src/ops/run.rs | 17 +- crates/ide-host/src/ops/run/commands.rs | 79 +++ crates/ide-run/Cargo.toml | 1 + crates/ide-run/src/lib.rs | 1 + crates/ide-run/src/tomcat.rs | 474 ++++++++++++++++++ crates/ide-sdk/src/apply.rs | 2 +- src-tauri/src/commands/mod.rs | 4 + src-tauri/src/commands/run.rs | 37 ++ src-tauri/src/menu.rs | 1 + src/bindings.ts | 33 ++ .../debugger/__tests__/hotswap.test.ts | 10 + src/features/debugger/actions.ts | 8 +- src/features/debugger/hotswap.ts | 4 +- src/features/run/ConfigForm.tsx | 5 +- src/features/run/RunView.tsx | 10 + src/features/run/TomcatSettings.tsx | 103 ++++ src/features/run/configs.ts | 8 +- src/features/run/index.ts | 17 +- src/features/run/javaDebug.ts | 1 + src/features/run/store.ts | 13 +- src/features/run/tomcat.test.tsx | 85 ++++ src/features/run/tomcat.ts | 108 ++++ src/features/run/tomcatUpdate.test.ts | 63 +++ src/features/run/tomcatUpdate.ts | 64 +++ src/features/run/types.ts | 3 + src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/locales/text/zh.json | 50 +- 32 files changed, 1366 insertions(+), 14 deletions(-) create mode 100644 crates/ide-build/src/web.rs create mode 100644 crates/ide-run/src/tomcat.rs create mode 100644 src/features/run/TomcatSettings.tsx create mode 100644 src/features/run/tomcat.test.tsx create mode 100644 src/features/run/tomcat.ts create mode 100644 src/features/run/tomcatUpdate.test.ts create mode 100644 src/features/run/tomcatUpdate.ts diff --git a/Cargo.lock b/Cargo.lock index aa980437f..7c05bd413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4462,6 +4462,7 @@ dependencies = [ "libc", "parking_lot", "portable-pty", + "quick-xml", "serde", "serde_json", "specta", diff --git a/crates/ide-build/src/gradle.rs b/crates/ide-build/src/gradle.rs index 529ed6803..4acc986e9 100644 --- a/crates/ide-build/src/gradle.rs +++ b/crates/ide-build/src/gradle.rs @@ -399,6 +399,12 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> { out } +/// Static fallback until Gradle has reported its tasks. +pub(crate) fn has_war_plugin(script: &str) -> bool { + let script = strip_comments(script); + script.split(|c: char| !c.is_alphanumeric() && c != '_').any(|word| word == "war") +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 84783f3e8..9afb4e672 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -11,6 +11,7 @@ pub mod maven; pub mod pom_edit; mod process; mod sync; +pub mod web; mod xml; pub use gradle::{GradleModel, GradleProject, GradleTask}; diff --git a/crates/ide-build/src/web.rs b/crates/ide-build/src/web.rs new file mode 100644 index 000000000..c986dee90 --- /dev/null +++ b/crates/ide-build/src/web.rs @@ -0,0 +1,169 @@ +//! WAR modules and build-tool-owned archive resolution (including custom output names). +use crate::BuildModel; +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::{collections::BTreeMap, fs, path::Path}; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WebArtifact { + pub id: String, + pub name: String, + pub module: String, + pub tool: String, +} + +pub fn artifacts(root: &Path, model: &BuildModel) -> Vec { + let mut out = Vec::new(); + if let Some(maven) = &model.maven { + for p in maven.projects.iter().filter(|p| p.packaging == "war") { + out.push(WebArtifact { + id: format!("maven:{}", p.pom), + name: p.artifact_id.clone(), + module: p.dir.clone(), + tool: "maven".into(), + }); + } + } + if let Some(gradle) = &model.gradle { + for p in &gradle.projects { + let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default(); + if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) { + out.push(WebArtifact { + id: format!("gradle:{}", p.path), + name: p.name.clone(), + module: p.dir.clone(), + tool: "gradle".into(), + }); + } + } + } + out +} + +pub struct WebBuild { + pub command: String, + pub args: Vec, + pub cwd: String, + pub env: BTreeMap, +} + +pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult { + fs::create_dir_all(cache)?; + let manifest = cache.join("artifact-path.txt"); + // A failed build must never silently reuse an older archive manifest. + if manifest.exists() { + fs::remove_file(&manifest)?; + } + fs::write(cache.join("artifact-id.txt"), id)?; + if let Some(pom) = id.strip_prefix("maven:") { + let p = model + .maven + .as_ref() + .and_then(|m| m.projects.iter().find(|p| p.pom == pom && p.packaging == "war")) + .ok_or_else(|| IdeError::invalid("Choose a Maven WAR module"))?; + let projects = &model.maven.as_ref().unwrap().projects; + let top = projects + .iter() + .filter(|t| { + !t.aggregated && (t.dir.is_empty() || p.dir == t.dir || p.dir.starts_with(&format!("{}/", t.dir))) + }) + .max_by_key(|t| t.dir.len()) + .unwrap_or(p); + let mut args = top.options.clone(); + if top.pom != p.pom { + let module = root + .join(&p.dir) + .strip_prefix(root.join(&top.dir)) + .map_err(|e| IdeError::invalid(e.to_string()))? + .to_string_lossy() + .replace('\\', "/"); + args.extend(["-pl".into(), module, "-am".into()]); + } + args.extend([ + "-DskipTests".into(), + "package".into(), + "help:evaluate".into(), + "-Dexpression=project.artifact.file".into(), + format!("-Doutput={}", manifest.display()), + ]); + return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() }); + } + if let Some(path) = id.strip_prefix("gradle:") { + let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?; + if !artifacts(root, model).iter().any(|a| a.id == id) { + return Err(IdeError::invalid("Choose a Gradle WAR module")); + } + let literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")); + let script = cache.join("war.init.gradle"); + fs::write( + &script, + format!( + r#"gradle.projectsEvaluated {{ + def p = gradle.rootProject.project({}) + def warTask = p.tasks.named('war') + p.tasks.register('openideaTomcatArtifact') {{ + dependsOn warTask + doLast {{ new File({}).text = warTask.get().archiveFile.get().asFile.absolutePath }} + }} +}} +"#, + literal(path), + literal(&manifest.to_string_lossy()) + ), + )?; + let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':')); + return Ok(WebBuild { + command: g.command.clone(), + args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task], + cwd: g.dir.clone(), + env: g.env.clone(), + }); + } + Err(IdeError::invalid("Choose a WAR artifact")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn maven_war_modules_use_resolved_artifact_file() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("pom.xml"), "4.0.0testweb1warcustomrenamed").unwrap(); + let model = crate::load(tmp.path(), &tmp.path().join("cache"), None); + let a = artifacts(tmp.path(), &model); + assert_eq!(a.len(), 1); + assert_eq!(a[0].name, "web"); + let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap(); + assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into())); + assert!(plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt"))); + } + #[test] + fn maven_module_build_includes_reactor_dependencies() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("web")).unwrap(); + fs::write(tmp.path().join("pom.xml"), "testparent1pomweb").unwrap(); + fs::write(tmp.path().join("web/pom.xml"), "testparent1webwar").unwrap(); + let model = crate::load(tmp.path(), &tmp.path().join("cache"), None); + let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap(); + assert_eq!(plan.cwd, ""); + assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]); + } + #[test] + fn gradle_war_uses_archive_provider_and_ignores_comments() { + let tmp = tempfile::tempdir().unwrap(); + fs::write( + tmp.path().join("build.gradle.kts"), + "plugins { war }\ntasks.war { archiveFileName.set(\"custom.war\") }", + ) + .unwrap(); + let model = crate::load(tmp.path(), &tmp.path().join("cache"), None); + let a = artifacts(tmp.path(), &model); + assert_eq!(a.len(), 1); + let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base")).unwrap(); + assert!(plan.args.contains(&":openideaTomcatArtifact".into())); + assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()")); + assert!(!crate::gradle::has_war_plugin("// plugins { war }")); + } +} diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 3dfe64fed..d9c8fadb7 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -40,6 +40,9 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit return Err(IdeError::invalid("run configuration has no command")); } // Settings › Run Targets: over SSH. + if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { + return Err(IdeError::invalid("Tomcat Local requires the local machine")); + } if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { let settings = project.settings.effective(); let out = on_output.clone(); @@ -51,7 +54,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit let settings = project.settings.effective(); // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. let mut config = config; - if config.kind == "gradle" { + if matches!(config.kind.as_str(), "gradle" | "tomcat") { ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), &project.root, &settings); } else { ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings); @@ -59,6 +62,18 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit if config.command == ide_sdk::godot::COMMAND { return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); } + if config.kind == "tomcat" { + if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) { + return Err(IdeError::invalid("This Tomcat configuration is already running")); + } + let (root, cache) = (project.root.clone(), project.cache_dir.clone()); + config = tokio::task::spawn_blocking(move || { + ide_run::tomcat::prepare(&root, &cache, &mut config)?; + Ok::<_, IdeError>(config) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + } let encoding = settings.console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()), diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index c792a44cc..fdfc21af8 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -49,8 +49,87 @@ pub async fn run_test_reports( .map_err(|e| IdeError::internal(e.to_string())) } +pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult { + let root = host.project(window)?.root.clone(); + tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home))) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective()); + ide_build::web::artifacts(&project.root, &model) + }) + .await + .map_err(|e| IdeError::internal(e.to_string())) +} + +pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult> { + if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") { + return Ok(None); + } + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective()); + let artifacts = ide_build::web::artifacts(&project.root, &model); + let id = config + .extras + .params + .get("artifact") + .filter(|s| !s.is_empty()) + .or_else(|| artifacts.first().map(|a| &a.id)) + .ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?; + let plan = ide_build::web::build( + &project.root, + &model, + id, + &ide_run::tomcat::base_dir(&project.cache_dir, &config.id), + )?; + let mut env = plan.env; + env.extend(config.env.clone()); + Ok(Some(RunConfig { + id: format!("tomcat-build:{}", config.id), + name: format!("Build {}", config.name), + kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(), + command: plan.command, + args: plan.args, + cwd: Some(std::path::PathBuf::from(plan.cwd).into()), + env, + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: ide_run::RunExtras { + include_system_env: config.extras.include_system_env, + env_files: config.extras.env_files, + ..Default::default() + }, + })) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> { + let project = host.project(window)?; + if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) { + return Err(IdeError::invalid("Start this Tomcat configuration before updating it")); + } + tokio::task::spawn_blocking(move || ide_run::tomcat::update(&project.root, &project.cache_dir, &config, &mode)) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { + d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await }); + d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await }); + d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await }); + d.add("run_tomcat_update", |c| async move { + run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await + }); d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) }); d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) }); d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) }); diff --git a/crates/ide-run/Cargo.toml b/crates/ide-run/Cargo.toml index bacdf07f0..23df66031 100644 --- a/crates/ide-run/Cargo.toml +++ b/crates/ide-run/Cargo.toml @@ -18,6 +18,7 @@ portable-pty.workspace = true parking_lot.workspace = true tracing.workspace = true zip.workspace = true +quick-xml = "0.42" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 31b6c8f3e..97eb4afd0 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -8,6 +8,7 @@ pub mod java_cmdline; pub mod junit; mod process; pub mod python_path; +pub mod tomcat; mod utf8; pub mod which; pub mod wrap; diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs new file mode 100644 index 000000000..7410dd187 --- /dev/null +++ b/crates/ide-run/src/tomcat.rs @@ -0,0 +1,474 @@ +//! Isolated local Tomcat installations and deployments. Never writes to CATALINA_HOME. +use crate::RunConfig; +use ide_core::{IdeError, IdeResult}; +use quick_xml::{ + Reader, Writer, + events::{BytesStart, Event}, +}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::{ + fs, + io::Read, + net::TcpListener, + path::{Path, PathBuf}, +}; + +fn invalid(e: impl std::fmt::Display) -> IdeError { + IdeError::invalid(e.to_string()) +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TomcatHome { + pub version: String, +} + +pub fn validate_home(home: &Path) -> IdeResult { + if !(home.join("bin/catalina.sh").is_file() || home.join("bin/catalina.bat").is_file()) + || !home.join("lib/catalina.jar").is_file() + { + return Err(invalid("Tomcat home must contain bin/catalina.sh or bin/catalina.bat and lib/catalina.jar")); + } + let mut jar = zip::ZipArchive::new(fs::File::open(home.join("lib/catalina.jar"))?).map_err(invalid)?; + let mut properties = String::new(); + jar.by_name("org/apache/catalina/util/ServerInfo.properties").map_err(invalid)?.read_to_string(&mut properties)?; + let version = + properties.lines().find_map(|l| l.strip_prefix("server.info=")).unwrap_or("Apache Tomcat").trim().to_owned(); + Ok(TomcatHome { version }) +} + +pub fn base_dir(cache: &Path, id: &str) -> PathBuf { + // Stable and collision-free for configuration IDs, including Windows path characters. + let key: String = id.as_bytes().iter().map(|b| format!("{b:02x}")).collect(); + cache.join("tomcat").join(key) +} + +pub fn free_port() -> IdeResult { + Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) +} + +pub fn context_name(context: &str) -> IdeResult { + if context.is_empty() || context == "/" { + return Ok("ROOT".into()); + } + if !context.starts_with('/') + || context.ends_with('/') + || context[1..].split('/').any(|p| { + p.is_empty() || p == "." || p == ".." || !p.chars().all(|c| c.is_alphanumeric() || "-_".contains(c)) + }) + { + return Err(invalid("Context path must start with / and contain only letters, digits, /, - or _")); + } + Ok(context[1..].replace('/', "#")) +} + +fn escape(text: &str) -> String { + quick_xml::escape::escape(text).into_owned() +} + +pub fn context_xml(doc_base: &Path, reload: bool) -> String { + format!("\n", escape(&doc_base.to_string_lossy())) +} + +pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult { + let mut reader = Reader::from_str(xml); + let mut writer = Writer::new(Vec::new()); + let mut http_found = false; + loop { + let event = reader.read_event().map_err(invalid)?; + if matches!(event, Event::Eof) { + break; + } + let event = match event { + Event::Start(ref tag) | Event::Empty(ref tag) => { + let name = tag.name().as_ref().to_owned(); + let mut attrs: Vec<(String, String)> = tag + .attributes() + .map(|a| { + let a = a.map_err(invalid)?; + Ok(( + a.key.as_ref().to_owned(), + a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map_err(invalid)?.into_owned(), + )) + }) + .collect::>()?; + let mut set = |key: &str, value: String| { + attrs.retain(|(k, _)| k != key); + attrs.push((key.into(), value)); + }; + match name.as_str() { + "Server" => { + set("port", shutdown.to_string()); + set("address", "127.0.0.1".into()); + set("shutdown", secret.into()); + } + "Connector" => { + let protocol = tag + .try_get_attribute("protocol") + .map_err(invalid)? + .map(|a| a.value.into_owned()) + .unwrap_or_default(); + let is_http = !http_found + && (protocol.is_empty() || protocol.contains("HTTP") || protocol.contains("Http")); + set("port", if is_http { http } else { free_port()? }.to_string()); + if is_http { + http_found = true; + } + } + "Engine" => set("name", "Catalina".into()), + "Host" => { + set("name", "localhost".into()); + set("appBase", "webapps".into()); + set("autoDeploy", "true".into()); + set("deployOnStartup", "true".into()); + } + _ => {} + } + let mut new = BytesStart::new(name); + for (k, v) in &attrs { + new.push_attribute((k.as_str(), v.as_str())); + } + if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) } + } + e => e.into_owned(), + }; + writer.write_event(event).map_err(invalid)?; + } + if !http_found { + return Err(invalid("Tomcat server.xml has no HTTP connector")); + } + String::from_utf8(writer.into_inner()).map_err(invalid) +} + +fn copy_tree(from: &Path, to: &Path) -> IdeResult<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), target)?; + } + } + Ok(()) +} + +/// Synchronize an archive into a private exploded deployment, preserving unchanged class timestamps. +pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResult<()> { + let mut zip = zip::ZipArchive::new(fs::File::open(war)?).map_err(invalid)?; + fs::create_dir_all(destination)?; + let mut expected = std::collections::HashSet::new(); + for i in 0..zip.len() { + let mut entry = zip.by_index(i).map_err(invalid)?; + let path = entry.enclosed_name().ok_or_else(|| invalid("WAR contains an invalid path"))?; + expected.insert(path.clone()); + if entry.is_dir() { + continue; + } + if resources_only && (path.extension().is_some_and(|e| e == "class") || path.starts_with("WEB-INF/lib")) { + continue; + } + let target = destination.join(&path); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + if fs::read(&target).ok().as_deref() != Some(&bytes) { + fs::create_dir_all(target.parent().unwrap())?; + fs::write(target, bytes)?; + } + } + fn remove_stale( + root: &Path, + dir: &Path, + expected: &std::collections::HashSet, + resources: bool, + ) -> IdeResult<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + remove_stale(root, &path, expected, resources)?; + } else { + let rel = path.strip_prefix(root).map_err(invalid)?; + if !expected.contains(rel) + && !(resources && (rel.extension().is_some_and(|e| e == "class") || rel.starts_with("WEB-INF/lib"))) + { + fs::remove_file(path)?; + } + } + } + Ok(()) + } + remove_stale(destination, destination, &expected, resources_only) +} + +pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult { + let p = &config.extras.params; + match p.get("deployment").map(String::as_str).unwrap_or("artifact") { + "artifact" => { + if let Some(id) = p.get("artifact").filter(|s| !s.is_empty()) + && fs::read_to_string(base.join("artifact-id.txt")).ok().as_ref() != Some(id) + { + return Err(invalid("Build the selected WAR artifact before launching Tomcat")); + } + let war = fs::read_to_string(base.join("artifact-path.txt")) + .map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?; + let war = PathBuf::from(war.trim()); + if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) { + return Err(invalid("The built WAR artifact does not exist")); + } + let dest = base.join("deployment"); + explode(&war, &dest, resources_only)?; + Ok(dest) + } + kind @ ("war" | "directory") => { + let value = p + .get("deploymentPath") + .filter(|v| !v.trim().is_empty()) + .ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?; + let path = root.join(value).canonicalize()?; + if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")))) + || (kind == "directory" && !path.is_dir()) + { + return Err(invalid("Invalid Tomcat deployment")); + } + Ok(path) + } + _ => Err(invalid("Unknown Tomcat deployment type")), + } +} + +pub fn effective_context(config: &RunConfig, doc: &Path) -> String { + config.extras.params.get("contextPath").filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| { + let name = config + .extras + .params + .get("artifactName") + .cloned() + .unwrap_or_else(|| doc.file_stem().unwrap_or_default().to_string_lossy().into_owned()); + if name == "ROOT" { "/".into() } else { format!("/{name}") } + }) +} + +/// Platform-neutral plan; Windows uses the process manager's raw cmd quoting for batch scripts. +pub fn script_command(home: &Path, windows: bool, verb: &str) -> (String, Vec) { + if windows { + (format!("\"{}\" {verb}", home.join("bin/catalina.bat").display()), vec![]) + } else { + ("sh".into(), vec![home.join("bin/catalina.sh").to_string_lossy().into_owned(), verb.into()]) + } +} + +pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<()> { + let home = root + .join(config.extras.params.get("tomcatHome").ok_or_else(|| invalid("Choose a Tomcat installation directory"))?) + .canonicalize()?; + validate_home(&home)?; + let base = base_dir(cache, &config.id); + fs::create_dir_all(&base)?; + let base = base.canonicalize()?; + let doc = deployment(root, &base, config, false)?; + let context = effective_context(config, &doc); + let context = context_name(&context)?; + let port: u16 = + config.extras.params.get("httpPort").map(String::as_str).unwrap_or("8080").parse().map_err(invalid)?; + if port == 0 { + return Err(invalid("HTTP port must be between 1 and 65535")); + } + let shutdown = free_port()?; + let secret = format!( + "OPENIDEA_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(invalid)?.as_nanos() + ); + // Refresh only the IDE-owned configuration, leaving logs and deployed artifacts available. + if base.join("conf").exists() { + fs::remove_dir_all(base.join("conf"))?; + } + copy_tree(&home.join("conf"), &base.join("conf"))?; + let contexts = base.join("conf/Catalina/localhost"); + if contexts.exists() { + fs::remove_dir_all(&contexts)?; + } + for dir in ["logs", "temp", "work", "webapps", "conf/Catalina/localhost"] { + fs::create_dir_all(base.join(dir))?; + } + let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?; + fs::write(base.join("conf/server.xml"), server)?; + let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true"); + fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?; + config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned()); + config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned()); + if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) { + let jre = root.join(jre).canonicalize()?; + config.env.insert("JAVA_HOME".into(), jre.to_string_lossy().into_owned()); + config.env.insert("JRE_HOME".into(), jre.to_string_lossy().into_owned()); + } else if let Some(java) = config.env.get("JAVA_HOME").cloned() { + config.env.insert("JRE_HOME".into(), java); + } + let vm = config.extras.params.get("vmOptions").cloned().unwrap_or_default(); + let jmx = free_port()?; + let mut opts = config.env.get("CATALINA_OPTS").cloned().unwrap_or_default(); + opts.push_str(&format!(" {vm} -Dcom.sun.management.jmxremote.port={jmx} -Dcom.sun.management.jmxremote.rmi.port={jmx} -Dcom.sun.management.jmxremote.host=127.0.0.1 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false")); + if debug { + opts.push_str(" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:0"); + } + config.env.insert("CATALINA_OPTS".into(), opts); + (config.command, config.args) = script_command(&home, cfg!(windows), "run"); + config.extras.stop_command = Some(if cfg!(windows) { + script_command(&home, true, "stop").0 + } else { + format!("sh '{}' stop", home.join("bin/catalina.sh").to_string_lossy().replace('\'', "'\\''")) + }); + config.extras.emulate_terminal = false; + Ok(()) +} + +pub fn update(root: &Path, cache: &Path, config: &RunConfig, mode: &str) -> IdeResult<()> { + if !matches!(mode, "resources" | "classes" | "redeploy") { + return Err(invalid("Unknown Tomcat update operation")); + } + let base = base_dir(cache, &config.id); + let doc = deployment(root, &base, config, mode == "resources")?; + let context = context_name(&effective_context(config, &doc))?; + let file = base.join("conf/Catalina/localhost").join(format!("{context}.xml")); + if !file.is_file() { + return Err(invalid("Start this Tomcat configuration before updating it")); + } + if mode == "redeploy" { + let content = fs::read(&file)?; + fs::write(file, content)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + fn archive(path: &Path, entries: &[(&str, &str)]) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap()); + for (name, text) in entries { + zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(text.as_bytes()).unwrap(); + } + zip.finish().unwrap(); + } + fn config() -> RunConfig { + RunConfig { + id: "local:demo".into(), + name: "Tomcat".into(), + kind: "tomcat".into(), + command: "catalina".into(), + args: vec!["run".into()], + cwd: None, + env: Default::default(), + detected: false, + is_test: false, + debug_adapter: Some("java-debug".into()), + allow_parallel: false, + extras: Default::default(), + } + } + #[test] + fn validates_home_and_reads_version() { + let tmp = tempfile::tempdir().unwrap(); + assert!(validate_home(tmp.path()).is_err()); + fs::create_dir_all(tmp.path().join("bin")).unwrap(); + fs::write(tmp.path().join("bin/catalina.bat"), "").unwrap(); + archive( + &tmp.path().join("lib/catalina.jar"), + &[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1.61\n")], + ); + assert_eq!(validate_home(tmp.path()).unwrap().version, "Apache Tomcat/10.1.61"); + } + #[test] + fn patches_ports_without_changing_comments_and_escapes_context() { + let xml = ""; + let patched = patch_server_xml(xml, 18080, 18005, "SECRET").unwrap(); + assert!(patched.contains("port=\"18080\"")); + assert!(patched.contains("port=\"18005\"")); + assert!(!patched.contains("port=\"8009\"")); + assert!(patched.contains("")); + assert_eq!(context_name("/").unwrap(), "ROOT"); + assert_eq!(context_name("/a/b").unwrap(), "a#b"); + for path in ["../escape", "/../escape", "/x#y", "/a//b", "/a/"] { + assert!(context_name(path).is_err()); + } + assert!(context_xml(Path::new("/tmp/a & b/\"web\""), false).contains("a & b/"web"")); + } + #[test] + fn constructs_platform_commands_without_splitting_paths() { + let home = Path::new("/some path/tomcat"); + assert_eq!( + script_command(home, false, "run"), + ("sh".into(), vec!["/some path/tomcat/bin/catalina.sh".into(), "run".into()]) + ); + let (command, args) = script_command(home, true, "run"); + assert_eq!(command, "\"/some path/tomcat/bin/catalina.bat\" run"); + assert!(args.is_empty()); + } + #[test] + fn resolves_manual_deployments_and_synchronizes_war_resources() { + let tmp = tempfile::tempdir().unwrap(); + let war = tmp.path().join("web app.war"); + archive(&war, &[("index.html", "old"), ("WEB-INF/classes/App.class", "class1"), ("removed.txt", "delete")]); + let base = tmp.path().join("cache"); + fs::create_dir_all(&base).unwrap(); + fs::write(base.join("artifact-path.txt"), war.to_string_lossy().as_bytes()).unwrap(); + let mut c = config(); + let dest = deployment(tmp.path(), &base, &c, false).unwrap(); + archive(&war, &[("index.html", "new"), ("WEB-INF/classes/App.class", "class2")]); + deployment(tmp.path(), &base, &c, true).unwrap(); + assert_eq!(fs::read_to_string(dest.join("index.html")).unwrap(), "new"); + assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/App.class")).unwrap(), "class1"); + assert!(!dest.join("removed.txt").exists()); + deployment(tmp.path(), &base, &c, false).unwrap(); + assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/App.class")).unwrap(), "class2"); + c.extras.params.insert("deployment".into(), "war".into()); + c.extras.params.insert("deploymentPath".into(), "web app.war".into()); + assert_eq!(deployment(tmp.path(), &base, &c, false).unwrap(), war.canonicalize().unwrap()); + fs::create_dir_all(tmp.path().join("WebContent")).unwrap(); + c.extras.params.insert("deployment".into(), "directory".into()); + c.extras.params.insert("deploymentPath".into(), "WebContent".into()); + assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent")); + } + #[test] + fn prepares_isolated_base_and_jdwp_only_for_run() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("Tomcat Home"); + fs::create_dir_all(home.join("bin")).unwrap(); + fs::create_dir_all(home.join("conf")).unwrap(); + fs::create_dir_all(tmp.path().join("WebRoot")).unwrap(); + fs::write(home.join("bin/catalina.sh"), "").unwrap(); + archive( + &home.join("lib/catalina.jar"), + &[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1")], + ); + let server = ""; + fs::write(home.join("conf/server.xml"), server).unwrap(); + let mut c = config(); + for (k, v) in [ + ("tomcatHome", "Tomcat Home"), + ("deployment", "directory"), + ("deploymentPath", "WebRoot"), + ("contextPath", "/app"), + ("tomcatDebug", "true"), + ("vmOptions", "-Xmx256m"), + ] { + c.extras.params.insert(k.into(), v.into()); + } + c.env.insert("JAVA_HOME".into(), "/jdk with spaces".into()); + prepare(tmp.path(), &tmp.path().join("cache"), &mut c).unwrap(); + assert_eq!(fs::read_to_string(home.join("conf/server.xml")).unwrap(), server); + let base = PathBuf::from(&c.env["CATALINA_BASE"]); + assert!(base.join("conf/Catalina/localhost/app.xml").is_file()); + assert!(c.env["CATALINA_OPTS"].contains("suspend=n,address=127.0.0.1:0")); + assert!(c.env["CATALINA_OPTS"].contains("-Xmx256m")); + assert!(!c.env.get("JAVA_OPTS").is_some_and(|s| s.contains("jdwp"))); + assert_eq!(c.env["JRE_HOME"], "/jdk with spaces"); + assert!(c.extras.stop_command.unwrap().contains("catalina.sh' stop")); + } +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 527e1924a..5e0d685bd 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -651,7 +651,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings c.env.entry(key).or_insert(value); } } - "maven" => { + "maven" | "tomcat" => { if let Some(jdk) = &jdk { c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned()); } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 71c00ca72..8085a112f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -284,6 +284,10 @@ pub fn builder() -> tauri_specta::Builder { run::run_close_stdin, run::run_resize, run::run_gradle_jvm_args_script, + run::run_tomcat_home, + run::run_tomcat_artifacts, + run::run_tomcat_build, + run::run_tomcat_update, run::run_test_reports, build::build_model, build::build_sync, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 09701bb7a..fcfe8efa8 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -117,3 +117,40 @@ pub async fn run_test_reports( ) -> IdeResult> { ide_host::ops::run::run_test_reports(&state.host, window.label(), module, since_ms).await } + +#[tauri::command] +#[specta::specta] +pub async fn run_tomcat_home( + window: Window, + state: State<'_, AppState>, + home: String, +) -> IdeResult { + ide_host::ops::run::run_tomcat_home(&state.host, window.label(), home).await +} +#[tauri::command] +#[specta::specta] +pub async fn run_tomcat_artifacts( + window: Window, + state: State<'_, AppState>, +) -> IdeResult> { + ide_host::ops::run::run_tomcat_artifacts(&state.host, window.label()).await +} +#[tauri::command] +#[specta::specta] +pub async fn run_tomcat_build( + window: Window, + state: State<'_, AppState>, + config: RunConfig, +) -> IdeResult> { + ide_host::ops::run::run_tomcat_build(&state.host, window.label(), config).await +} +#[tauri::command] +#[specta::specta] +pub async fn run_tomcat_update( + window: Window, + state: State<'_, AppState>, + config: RunConfig, + mode: String, +) -> IdeResult<()> { + ide_host::ops::run::run_tomcat_update(&state.host, window.label(), config, mode).await +} diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index d9754a0dc..3e4d00ecb 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -571,6 +571,7 @@ pub fn spec(os: Os) -> Vec { act("run.chooseDebugConfig", "Debug…"), act("runAnything.show", "Run Anything…"), act("run.editConfigs", "Edit Configurations…"), + act("run.updateApplication", "Update Application"), act("debug.attachToProcess", "Attach to Process…"), SEP, act("debug.resume", "Resume Program"), diff --git a/src/bindings.ts b/src/bindings.ts index c00b882fa..891b7efc2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -544,6 +544,28 @@ export const commands = { runResize: (id: RunId, cols: number, rows: number) => typedError(__TAURI_INVOKE("run_resize", { id, cols, rows })), /** Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path. */ runGradleJvmArgsScript: (jvmArgs: string[]) => typedError(__TAURI_INVOKE("run_gradle_jvm_args_script", { jvmArgs })), + runTomcatHome: (home: string) => typedError(__TAURI_INVOKE("run_tomcat_home", { home })), + runTomcatArtifacts: () => typedError(__TAURI_INVOKE("run_tomcat_artifacts")), + runTomcatBuild: (config: RunConfig_Deserialize) => typedError<({ + /** Stable id, e.g. `npm:dev`, `cargo:run:app`, or a uuid for user-defined. */ + id: string, + name: string, + /** `npm` | `cargo` | `gradle` | `maven` | `go` | `python` | `pytest` | `make` | `shell` … */ + kind: string, + command: string, + args: string[], + cwd: IpcPath | null, + env: { [key in string]: string }, + /** Detected configs are regenerated; user configs persist in `.openidea/run.toml`. */ + detected: boolean, + /** Test runner configs render results in the test tree. */ + isTest: boolean, + /** DAP adapter that can debug this config, if any. */ + debugAdapter: string | null, + /** "Allow multiple instances": Run on a running config starts another one instead of restarting it. */ + allowParallel?: boolean, +}) & (RunExtras_Serialize) | null, IdeError>(__TAURI_INVOKE("run_tomcat_build", { config })), + runTomcatUpdate: (config: RunConfig_Deserialize, mode: string) => typedError(__TAURI_INVOKE("run_tomcat_update", { config, mode })), /** * Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at * `module` (relative to the project root) written since `since_ms` (Unix epoch, ms). @@ -5864,6 +5886,10 @@ export type ThreadInfo = { name: string, }; +export type TomcatHome = { + version: string, +}; + /** UI-facing state of one tool. */ export type ToolInfo = { /** Server / adapter name from `languages/*.toml`. */ @@ -6047,6 +6073,13 @@ export type Visibility = "public" | "protected" | /** Package / module / assembly scope (Java default, `internal`, `pub(crate)`, Go unexported). */ "package" | "private"; +export type WebArtifact = { + id: string, + name: string, + module: string, + tool: string, +}; + /** * A web reference no language server resolves and where it points: `class="card"` → * `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` → diff --git a/src/features/debugger/__tests__/hotswap.test.ts b/src/features/debugger/__tests__/hotswap.test.ts index a8ffc840b..ace2fc1b3 100644 --- a/src/features/debugger/__tests__/hotswap.test.ts +++ b/src/features/debugger/__tests__/hotswap.test.ts @@ -87,6 +87,16 @@ describe("HotSwap", () => { expect(notes.at(-1)).toMatchObject({ level: "info", title: "2 classes reloaded" }); }); + it("limits a configuration update to that configuration's debug session", async () => { + const one = { ...session(1, true), runConfig: { id: "tomcat:one" } as NonNullable }; + const two = { ...session(2, true), runConfig: { id: "tomcat:two" } as NonNullable }; + useDebug.setState({ sessions: { 1: one, 2: two } }); + await reloadChangedClasses(false, "tomcat:two"); + expect(ipc.javaBuildWorkspace).not.toHaveBeenCalled(); + expect(ipc.debugRedefineClasses).toHaveBeenCalledTimes(1); + expect(ipc.debugRedefineClasses).toHaveBeenCalledWith(2); + }); + it("does not reload after a failed build", async () => { ipc.javaBuildWorkspace.mockImplementationOnce(() => ipc.ok("failed")); await reloadChangedClasses(); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index d40740b5f..939101f0a 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -341,7 +341,13 @@ export function registerDebugActions(): () => void { icon: "refresh", keywords: ["hotswap", "hot swap", "hot code replace"], when: "debugCanHotSwap", - run: () => reloadChangedClasses(), + run: ({ arg }) => + reloadChangedClasses( + true, + typeof arg === "object" && arg !== null && "configId" in arg && typeof arg.configId === "string" + ? arg.configId + : undefined, + ), }, { id: "debug.rerun", diff --git a/src/features/debugger/hotswap.ts b/src/features/debugger/hotswap.ts index d13abdb0c..a4825af1a 100644 --- a/src/features/debugger/hotswap.ts +++ b/src/features/debugger/hotswap.ts @@ -33,8 +33,8 @@ export function hotSwapMessage( } /** Compiles (unless `build` is false) and reloads changed classes into every Java debug session. */ -export async function reloadChangedClasses(build = true): Promise { - const sessions = hotSwapSessions(); +export async function reloadChangedClasses(build = true, configId?: string): Promise { + const sessions = hotSwapSessions().filter((s) => !configId || s.runConfig?.id === configId); if (!sessions.length || !isTauri()) { notifications().notify({ group: "run", level: "info", key: KEY, title: "No debug session can reload classes" }); return; diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index fea940889..6933e0253 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -25,6 +25,7 @@ import { import { EnvDialog } from "./EnvDialog"; import { withTarget } from "./remote"; import { COMPOUND, formParams, terminalApplies, withParams } from "./types"; +import { TomcatSettings } from "./TomcatSettings"; import styles from "./Run.module.css"; /** @@ -331,7 +332,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi )}
- {tx(type?.label ?? kindLabel(config.kind))} + {tx(kindLabel(config.kind))}
+ {config.kind === "tomcat" && } + {compound ? ( template ? null : ( void stopSession(session.id)} /> + {session.config.kind === "tomcat" && ( + showTomcatUpdate(session.id)} + /> + )}
{!rt.terminal && ( <> diff --git a/src/features/run/TomcatSettings.tsx b/src/features/run/TomcatSettings.tsx new file mode 100644 index 000000000..92c02c326 --- /dev/null +++ b/src/features/run/TomcatSettings.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import { call, commands, type RunConfig, type WebArtifact } from "@/platform/ipc"; +import { Select, T } from "@/ui"; +import { useTx } from "@/platform/i18n"; +import styles from "./Run.module.css"; + +/** Asynchronous installation validation and project artifact discovery in the configuration editor. */ +export function TomcatSettings({ config, onChange }: { config: RunConfig; onChange: (config: RunConfig) => void }) { + const tx = useTx(); + const [artifacts, setArtifacts] = useState([]); + const [status, setStatus] = useState(""); + const [error, setError] = useState(""); + const latest = useRef({ config, onChange }); + useEffect(() => { + latest.current = { config, onChange }; + }, [config, onChange]); + const select = (artifact: WebArtifact) => { + const { config, onChange } = latest.current; + onChange({ + ...config, + cwd: artifact.module || null, + params: { + ...config.params, + artifact: artifact.id, + artifactName: artifact.name, + contextPath: config.params?.contextPath || `/${artifact.name}`, + }, + }); + }; + useEffect(() => { + let live = true; + void call(commands.runTomcatArtifacts()) + .then((items) => { + if (!live) return; + setArtifacts(items); + if (!latest.current.config.params?.artifact && items[0]) select(items[0]); + }) + .catch((e) => { + if (live) setError(String(e)); + }); + return () => { + live = false; + }; + }, []); + const home = config.params?.tomcatHome ?? ""; + useEffect(() => { + let live = true; + const timer = setTimeout(() => { + setStatus(""); + setError(""); + if (home.trim()) + void call(commands.runTomcatHome(home)) + .then((info) => { + if (live) setStatus(info.version); + }) + .catch(() => { + if (live) setError("Invalid Tomcat installation: check bin/catalina and lib/catalina.jar"); + }); + }, 250); + return () => { + live = false; + clearTimeout(timer); + }; + }, [home]); + return ( + <> + {status && ( +
+ {status} +
+ )} + {error && ( +
+ {tx(error)} +
+ )} + {(config.params?.deployment ?? "artifact") === "artifact" && ( +
+ + WAR artifact + +
+