feat(run): Tomcat local run configuration (#392)

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
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-10-30 18:06:11 +08:00
1 parent d837cb1acb
commit 92b3ae508c
32 files changed
+1366 -14

No files matched your search

Generated
+1
View File
@@ -4462,6 +4462,7 @@ dependencies = [
"libc",
"parking_lot",
"portable-pty",
"quick-xml",
"serde",
"serde_json",
"specta",
+6
View File
@@ -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]
+1
View File
@@ -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};
+169
View File
@@ -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<WebArtifact> {
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<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
}
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
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"), "<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>web</artifactId><version>1</version><packaging>war</packaging><build><directory>custom</directory><finalName>renamed</finalName></build></project>").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"), "<project><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>web</module></modules></project>").unwrap();
fs::write(tmp.path().join("web/pom.xml"), "<project><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>web</artifactId><packaging>war</packaging></project>").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 }"));
}
}
+16 -1
View File
@@ -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()),
+79
View File
@@ -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<ide_run::tomcat::TomcatHome> {
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<Vec<ide_build::web::WebArtifact>> {
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<Option<RunConfig>> {
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)) });
+1
View File
@@ -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"
+1
View File
@@ -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;
+474
View File
@@ -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<TomcatHome> {
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<u16> {
Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port())
}
pub fn context_name(context: &str) -> IdeResult<String> {
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!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
}
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
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::<IdeResult<_>>()?;
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<PathBuf>,
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<PathBuf> {
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<String>) {
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 = "<Server port='8005' shutdown='SHUTDOWN'><!-- <Connector port=\"8080\"/> --><Service><Connector port='8080' protocol='HTTP/1.1'/><Connector port='8009' protocol='AJP/1.3'/><Engine name='Catalina'><Host name='localhost'/></Engine></Service></Server>";
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("<!-- <Connector port=\"8080\"/> -->"));
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 &amp; b/&quot;web&quot;"));
}
#[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 = "<Server port='8005'><Service><Connector port='8080'/><Engine name='Catalina'><Host name='localhost'/></Engine></Service></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"));
}
}
+1 -1
View File
@@ -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());
}
+4
View File
@@ -284,6 +284,10 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+37
View File
@@ -117,3 +117,40 @@ pub async fn run_test_reports(
) -> IdeResult<Vec<ide_run::junit::TestCaseReport>> {
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_run::tomcat::TomcatHome> {
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<Vec<ide_build::web::WebArtifact>> {
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<Option<RunConfig>> {
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
}
+1
View File
@@ -571,6 +571,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
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"),
+33
View File
@@ -544,6 +544,28 @@ export const commands = {
runResize: (id: RunId, cols: number, rows: number) => typedError<null, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("run_gradle_jvm_args_script", { jvmArgs })),
runTomcatHome: (home: string) => typedError<TomcatHome, IdeError>(__TAURI_INVOKE("run_tomcat_home", { home })),
runTomcatArtifacts: () => typedError<WebArtifact[], IdeError>(__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<null, IdeError>(__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)` →
@@ -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<DebugSession["runConfig"]> };
const two = { ...session(2, true), runConfig: { id: "tomcat:two" } as NonNullable<DebugSession["runConfig"]> };
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();
+7 -1
View File
@@ -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",
+2 -2
View File
@@ -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<void> {
const sessions = hotSwapSessions();
export async function reloadChangedClasses(build = true, configId?: string): Promise<void> {
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;
+4 -1
View File
@@ -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
</Field>
)}
<div className={styles.formSection}>
<span className={styles.formSectionTitle}>{tx(type?.label ?? kindLabel(config.kind))}</span>
<span className={styles.formSectionTitle}>{tx(kindLabel(config.kind))}</span>
<button
ref={modifyRef}
type="button"
@@ -352,6 +353,8 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
/>
</div>
{config.kind === "tomcat" && <TomcatSettings config={config} onChange={onChange} />}
{compound ? (
template ? null : (
<CompoundMembers
+10
View File
@@ -29,6 +29,7 @@ import {
} from "./store";
import { TestTreeView, formatDuration } from "./TestTreeView";
import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab } from "./stackTrace";
import { showTomcatUpdate } from "./tomcatUpdate";
import styles from "./Run.module.css";
import { tx } from "@/platform/i18n";
@@ -140,6 +141,15 @@ export function SessionPanel({ session }: { session: RunSession }) {
disabled={!running}
onClick={() => void stopSession(session.id)}
/>
{session.config.kind === "tomcat" && (
<IconButton
icon="refresh"
title="Update Application"
shortcut={shortcutLabel("run.updateApplication")}
disabled={!running}
onClick={() => showTomcatUpdate(session.id)}
/>
)}
<div className={styles.toolbarSep} />
{!rt.terminal && (
<>
+103
View File
@@ -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<WebArtifact[]>([]);
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 && (
<div className={styles.notice} role="status">
{status}
</div>
)}
{error && (
<div className={styles.fieldError} role="alert">
{tx(error)}
</div>
)}
{(config.params?.deployment ?? "artifact") === "artifact" && (
<div className={styles.field}>
<span className={styles.fieldLabel}>
<T>WAR artifact</T>
</span>
<div className={styles.fieldControl}>
<Select
aria-label="WAR artifact"
value={config.params?.artifact || null}
placeholder="Choose a WAR module"
options={artifacts.map((a) => ({ value: a.id, label: `${a.name} (${a.tool}: ${a.module || "."})` }))}
onChange={(id) => {
const artifact = artifacts.find((a) => a.id === id);
if (artifact) select(artifact);
}}
/>
{!artifacts.length && (
<span className={styles.chooserMuted}>
<T>No WAR modules found. Choose a WAR file or an exploded directory.</T>
</span>
)}
</div>
</div>
)}
</>
);
}
+3 -5
View File
@@ -40,11 +40,9 @@ const KIND_LABELS: Record<string, string> = {
export const KIND_OPTIONS = Object.entries(KIND_LABELS).map(([value, label]) => ({ value, label }));
export function kindLabel(kind: string): string {
return (
useRunConfigTypes.getState().types.get(kind)?.label ??
KIND_LABELS[kind] ??
(kind ? kind[0]!.toUpperCase() + kind.slice(1) : "Other")
);
const type = useRunConfigTypes.getState().types.get(kind);
// A grouped type ("Tomcat Server › Local") is named by its group outside the Add menu.
return type?.group ?? type?.label ?? KIND_LABELS[kind] ?? (kind ? kind[0]!.toUpperCase() + kind.slice(1) : "Other");
}
export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
+16 -1
View File
@@ -42,6 +42,7 @@ import { runBeforeLaunch } from "./beforeLaunch";
import { compoundChildren } from "./configs";
import { remoteDebugBlocked } from "./remote";
import { isDebugOnly, registerBuiltinTypes } from "./types";
import { showTomcatUpdate } from "./tomcatUpdate";
import { runGutter } from "./runGutter";
import { activateStackTrace, useStackTraces } from "./stackTrace";
import { clearRuntimeCache } from "./runtimes";
@@ -282,6 +283,14 @@ export function activate(): () => void {
prepare: prepareMenu,
}),
registerActions([
{
id: "run.updateApplication",
title: "Update Application",
category: "Run",
icon: "refresh",
when: "run.tomcatRunning",
run: () => showTomcatUpdate(),
},
{
id: "run.run",
title: "Run",
@@ -494,7 +503,13 @@ 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 ?? "")),
useRun.subscribe((s) => {
setContext("run.selectedKind", s.configs.find((c) => c.id === s.selectedId)?.kind ?? "");
setContext(
"run.tomcatRunning",
s.sessions.some((session) => session.config.kind === "tomcat" && session.status === "running"),
);
}),
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root) return;
resetRuns();
+1
View File
@@ -37,6 +37,7 @@ function withProperty(args: readonly string[], key: string, value: string): stri
/** The configuration with a waiting JDWP agent, or null when it starts no debuggable JVM. */
export function jdwpLaunch(config: RunConfig): RunConfig | null {
if (config.kind === "tomcat") return { ...config, params: { ...config.params, tomcatDebug: "true" } };
const args = config.args;
// A single source file (`java Hi.java`): the agent goes before the file.
if (config.kind === "java-file") return { ...config, args: [JDWP_AGENT, ...args] };
+12 -1
View File
@@ -38,6 +38,8 @@ import { remoteLabel, startWithRemedies } from "./remote";
import { TerminalOutput } from "./terminalOutput";
import { typeFor } from "./types";
import { startedPort } from "./serverPort";
import { tomcatBrowserUrl } from "./tomcat";
import { openUrl } from "./navigate";
import { tx } from "@/platform/i18n";
/** The permanent "Current File" pseudo-configuration of the chooser (runs the active file's context). */
@@ -543,7 +545,15 @@ export async function startRun(
const rt = runtimes.get(sessionId)!;
const generation = rt.generation;
if (options.onLine) rt.lineListeners.push(options.onLine);
let browserOpened = false;
rt.lineListeners.push((line) => {
if (config.kind === "tomcat" && /Server startup in/.test(line)) {
patchSession(sessionId, { httpPort: Number(config.params?.httpPort || "8080") });
if (!browserOpened && config.params?.openBrowser === "true") {
browserOpened = true;
void openUrl(tomcatBrowserUrl(config));
}
}
const port = startedPort(line);
if (port !== null && runtimes.get(sessionId)?.generation === generation && getSession(sessionId)?.httpPort !== port)
patchSession(sessionId, { httpPort: port });
@@ -585,6 +595,7 @@ export async function startRun(
const prepare = typeFor(launch.kind)?.prepareLaunch;
if (prepare) {
launch = await prepare(launch);
if (launch.kind === "tomcat") useRun.setState({ activeSession: sessionId });
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
}
// Debug launches read the agent's output lines: never through a terminal.
@@ -806,7 +817,7 @@ export async function stopSession(sessionId: string): Promise<void> {
}
/** Resolves once the given sessions' processes exited (or after `timeoutMs`). */
function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): Promise<void> {
export function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): Promise<void> {
const live = () =>
sessionIds.some((id) => {
const s = getSession(id);
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { newConfigOfType, missingParams } from "./types";
import { tomcatType, tomcatBrowserUrl } from "./tomcat";
import { jdwpLaunch } from "./javaDebug";
import { TomcatSettings } from "./TomcatSettings";
import { typeRows } from "./AddConfigPopup";
import { commands } from "@/platform/ipc";
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
commands: {
runTomcatHome: vi.fn(async () => ({ status: "ok", data: { version: "Apache Tomcat/10.1.61" } })),
runTomcatArtifacts: vi.fn(async () => ({
status: "ok",
data: [{ id: "maven:pom.xml", name: "hello", module: "", tool: "maven" }],
})),
},
}));
afterEach(cleanup);
describe("Tomcat Local", () => {
it("offers a Local configuration grouped under Tomcat Server with build and browser defaults", () => {
const config = newConfigOfType(tomcatType, []);
expect(config.params).toMatchObject({
httpPort: "8080",
deployment: "artifact",
buildArtifact: "true",
openBrowser: "true",
});
expect(config.debugAdapter).toBe("java-debug");
expect(typeRows([tomcatType], "")).toEqual(
expect.arrayContaining([expect.objectContaining({ label: "Tomcat Server" })]),
);
expect(missingParams(config, tomcatType)).toHaveProperty("tomcatHome");
});
it("validates ports and context paths and supports manually chosen web roots", () => {
const config = newConfigOfType(tomcatType, []);
config.params = {
...config.params,
tomcatHome: "/tomcat",
deployment: "directory",
deploymentPath: "/legacy/WebContent",
contextPath: "/app",
httpPort: "18080",
};
expect(missingParams(config, tomcatType)).toEqual({});
expect(tomcatBrowserUrl(config)).toBe("http://localhost:18080/app/");
config.params.contextPath = "/";
expect(tomcatBrowserUrl(config)).toBe("http://localhost:18080/");
config.params.httpPort = "65536";
config.params.contextPath = "/../app";
expect(missingParams(config, tomcatType)).toHaveProperty("httpPort");
expect(missingParams(config, tomcatType)).toHaveProperty("contextPath");
});
it("debugs through a Tomcat JDWP launch without treating an archive as a main program", () => {
const config = newConfigOfType(tomcatType, []);
const debug = jdwpLaunch(config)!;
expect(debug.command).toBe("catalina");
expect(debug.args).toEqual(["run"]);
expect(debug.params?.tomcatDebug).toBe("true");
expect(debug.env.JAVA_TOOL_OPTIONS).toBeUndefined();
});
it("shows the installation version and defaults to the project's WAR module", async () => {
const config = newConfigOfType(tomcatType, []);
config.params = { ...config.params, tomcatHome: "/tomcat" };
const change = vi.fn();
render(<TomcatSettings config={config} onChange={change} />);
await waitFor(() =>
expect(change).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ artifact: "maven:pom.xml", contextPath: "/hello" }),
}),
),
);
expect(await screen.findByText("Apache Tomcat/10.1.61")).toBeTruthy();
});
it("reports an invalid installation in the form", async () => {
vi.mocked(commands.runTomcatHome).mockRejectedValueOnce(new Error("missing catalina.jar"));
const config = newConfigOfType(tomcatType, []);
config.params = { ...config.params, tomcatHome: "/missing" };
render(<TomcatSettings config={config} onChange={() => {}} />);
expect(await screen.findByRole("alert")).toHaveTextContent("Invalid Tomcat installation");
});
});
+108
View File
@@ -0,0 +1,108 @@
import { call, commands, type RunConfig } from "@/platform/ipc";
import type { RunConfigTypeContribution } from "@/workbench/contributions";
export const tomcatType: RunConfigTypeContribution = {
id: "tomcat",
group: "Tomcat Server",
label: "Local",
icon: "server",
description: "Run a WAR application in a local Tomcat server",
debugAdapter: "java-debug",
terminal: false,
defaults: { deployment: "artifact", httpPort: "8080", buildArtifact: "true", openBrowser: "true" },
fields: [
{ key: "tomcatHome", label: "Tomcat installation directory", required: true, mono: true },
{
key: "deployment",
label: "Deployment",
control: "select",
options: [
{ value: "artifact", label: "Project WAR artifact (exploded)" },
{ value: "war", label: "WAR file" },
{ value: "directory", label: "Exploded directory" },
],
},
{
key: "deploymentPath",
label: "Deployment path",
mono: true,
required: true,
visible: (p) => p.deployment !== "artifact",
},
{
key: "contextPath",
label: "Context path",
mono: true,
placeholder: "/artifact-name",
validate: (value) =>
value === "/" || /^\/[\p{L}\p{N}_-]+(?:\/[\p{L}\p{N}_-]+)*$/u.test(value)
? null
: "Enter / or a context path such as /app",
},
{
key: "httpPort",
label: "HTTP port",
required: true,
validate: (value) =>
/^\d+$/.test(value) && Number(value) > 0 && Number(value) <= 65535 ? null : "Port must be between 1 and 65535",
},
{
key: "jre",
label: "JRE",
mono: true,
placeholder: "Project JDK",
hint: "Java installation directory; empty uses the project JDK",
},
{ key: "vmOptions", label: "VM options", control: "textarea", rows: 2, mono: true },
{ key: "openBrowser", label: "Open browser after launch", control: "checkbox" },
{
key: "browserUrl",
label: "Browser URL",
mono: true,
placeholder: "Server URL from the HTTP port and context path",
visible: (p) => p.openBrowser === "true",
validate: (value) => (/^https?:\/\//i.test(value) ? null : "Enter an HTTP or HTTPS URL"),
},
{
key: "buildArtifact",
label: "Before launch: build the artifact",
control: "checkbox",
visible: (p) => p.deployment === "artifact",
},
],
commandLine: () => ({ command: "catalina", args: ["run"] }),
async prepareLaunch(config) {
if (config.target) throw new Error("Tomcat Local requires the local machine");
await call(commands.runTomcatHome(config.params?.tomcatHome ?? ""));
if (config.params?.buildArtifact === "true") await buildTomcatArtifact(config);
return config;
},
};
export async function buildTomcatArtifact(config: RunConfig): Promise<void> {
const build = await call(commands.runTomcatBuild(config));
if (!build) return;
const { startRun } = await import("./store");
let finish!: (ok: boolean) => void;
const exited = new Promise<boolean>((resolve) => {
finish = resolve;
});
const session = await startRun(build, undefined, undefined, {
onExit: (code, stopped) => finish(code === 0 && !stopped),
});
if (!session || session.status === "failed" || !(await exited)) throw new Error("WAR artifact build failed");
}
export function tomcatBrowserUrl(config: RunConfig): string {
const p = config.params ?? {};
const name =
p.artifactName ||
p.deploymentPath
?.replace(/\\/g, "/")
.split("/")
.pop()
?.replace(/\.war$/i, "") ||
"";
const context = p.contextPath || (name && name !== "ROOT" ? `/${name}` : "/");
return p.browserUrl || `http://localhost:${p.httpPort || "8080"}${context === "/" ? "/" : `${context}/`}`;
}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RunSession } from "./store";
const mocks = vi.hoisted(() => ({
action: vi.fn(async () => true),
build: vi.fn(async () => {}),
update: vi.fn(async () => ({ status: "ok", data: null })),
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
wait: vi.fn(async () => {}),
debug: true,
}));
vi.mock("@/platform/actions", () => ({ executeAction: mocks.action, getAction: () => ({}) }));
vi.mock("@/platform/ipc", () => ({
call: async (p: Promise<{ data: unknown }>) => (await p).data,
commands: { runTomcatUpdate: mocks.update },
}));
vi.mock("@/workbench/contributions", () => ({
maybeEditorService: () => ({ saveAll: async () => {} }),
notifications: () => ({ notify: vi.fn() }),
useDebugRunSessions: { getState: () => ({ sessions: mocks.debug ? [{ configId: "tomcat:one" }] : [] }) },
}));
vi.mock("@/ui", () => ({ showContextMenu: vi.fn() }));
vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build }));
vi.mock("./store", () => ({
startRun: mocks.start,
stopSession: mocks.stop,
waitForExit: mocks.wait,
useRun: { setState: vi.fn() },
}));
import { updateTomcat } from "./tomcatUpdate";
const session = { id: "run1", config: { id: "tomcat:one", kind: "tomcat" } } as RunSession;
beforeEach(() => {
vi.clearAllMocks();
mocks.debug = true;
mocks.action.mockResolvedValue(true);
});
describe("Tomcat application updates", () => {
it("builds and copies the deployment before requesting scoped HotSwap", async () => {
await updateTomcat(session, "classes");
expect(mocks.build).toHaveBeenCalledWith(session.config);
expect(mocks.update).toHaveBeenCalledWith(session.config, "classes");
expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "tomcat:one" });
expect(mocks.build.mock.invocationCallOrder[0]).toBeLessThan(mocks.update.mock.invocationCallOrder[0]!);
expect(mocks.update.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!);
});
it("does not replace classes when updating only resources", async () => {
await updateTomcat(session, "resources");
expect(mocks.update).toHaveBeenCalledWith(session.config, "resources");
expect(mocks.action).not.toHaveBeenCalled();
});
it("waits for clean shutdown before restarting a debug server", async () => {
await updateTomcat(session, "restart");
expect(mocks.stop).toHaveBeenCalledWith("run1");
expect(mocks.wait).toHaveBeenCalledWith(["run1"]);
expect(mocks.wait.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!);
expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", session.config);
});
it("redeploys when the debugger cannot perform HotSwap", async () => {
mocks.action.mockResolvedValue(false);
await updateTomcat(session, "classes");
expect(mocks.update).toHaveBeenLastCalledWith(session.config, "redeploy");
});
});
+64
View File
@@ -0,0 +1,64 @@
import { executeAction, getAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { maybeEditorService, notifications, useDebugRunSessions } from "@/workbench/contributions";
import { showContextMenu } from "@/ui";
import { buildTomcatArtifact } from "./tomcat";
import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store";
const updating = new Set<string>();
export async function updateTomcat(session: RunSession, mode: string): Promise<void> {
if (updating.has(session.id)) return;
updating.add(session.id);
try {
const debugging = useDebugRunSessions.getState().sessions.some((s) => s.configId === session.config.id);
if (mode === "restart") {
if (debugging) {
await stopSession(session.id);
await waitForExit([session.id]);
await executeAction("run.debugConfig", session.config);
} else await startRun(session.config, session.id);
return;
}
await maybeEditorService()?.saveAll();
await buildTomcatArtifact(session.config);
await call(commands.runTomcatUpdate(session.config, mode));
if (mode === "classes" && debugging) {
const swapped =
getAction("debug.reloadChangedClasses") &&
(await executeAction("debug.reloadChangedClasses", { configId: session.config.id }));
if (!swapped) await call(commands.runTomcatUpdate(session.config, "redeploy"));
}
useRun.setState({ activeSession: session.id });
notifications().notify({ group: "run", level: "info", title: "Application updated" });
} catch (e) {
notifications().notify({ group: "run", level: "error", title: "Application update failed", body: String(e) });
} finally {
updating.delete(session.id);
}
}
export function showTomcatUpdate(sessionId?: string): void {
const state = useRun.getState();
const candidates = state.sessions.filter((s) => s.config.kind === "tomcat" && s.status === "running");
const session = candidates.find((s) => s.id === (sessionId || state.activeSession)) ?? candidates[0];
if (!session) return;
showContextMenu({ x: window.innerWidth / 2 - 140, y: 100 }, [
{ kind: "header", label: "Update Application" },
...(
[
["resources", "Update resources"],
["classes", "Update classes and resources"],
["redeploy", "Redeploy"],
["restart", "Restart server"],
] as const
).map(([mode, label]) => ({
label,
disabled:
updating.has(session.id) ||
((mode === "resources" || mode === "classes") && session.config.params?.deployment === "war"),
run: () => {
void updateTomcat(session, mode);
},
})),
]);
}
+3
View File
@@ -18,6 +18,8 @@ import {
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { tomcatType } from "./tomcat";
export const COMPOUND = "compound";
const on = (v: string | undefined) => v === "true";
@@ -394,6 +396,7 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [
pytest,
applicationType,
springBootType,
tomcatType,
compound,
];
+1
View File
@@ -254,6 +254,7 @@ export const intellijMacos: Record<string, string[]> = {
"run.debugContext": ["Ctrl+Shift+D"],
"run.stop": ["Meta+F2"],
"workbench.stopBackgroundProcesses": ["Meta+Shift+F2"],
"run.updateApplication": ["Meta+F10"],
// Rerun in the Run / Debug tool window only (when / keyWhen); Meta+R stays with the editor elsewhere.
"run.rerun": ["Meta+R"],
"coverage.manageReports": ["Meta+Alt+F6"],
+1
View File
@@ -249,6 +249,7 @@ export const intellijWindows: Record<string, string[]> = {
"run.stop": ["Ctrl+F2"],
"workbench.stopBackgroundProcesses": ["Ctrl+Shift+F2"],
// Rerun in the Run / Debug tool window only (when / keyWhen); Ctrl+F5 stays with the editor elsewhere.
"run.updateApplication": ["Ctrl+F10"],
"run.rerun": ["Ctrl+F5"],
"coverage.manageReports": ["Ctrl+Alt+F6"],
"debug.rerun": ["Ctrl+F5"],
+49 -1
View File
@@ -4907,5 +4907,53 @@
"Unarchive": "取消归档",
"Unarchive Agent Tasks": "取消归档 Agent 任务",
"Cannot unarchive agent tasks": "无法取消归档 Agent 任务",
"Unarchive ({archived})": "取消归档({archived})"
"Unarchive ({archived})": "取消归档({archived})",
"Tomcat Server": "Tomcat 服务器",
"Run a WAR application in a local Tomcat server": "在本地 Tomcat 服务器中运行 WAR 应用",
"Tomcat installation directory": "Tomcat 安装目录",
"Project WAR artifact (exploded)": "项目 WAR 构件(展开目录)",
"WAR file": "WAR 文件",
"Exploded directory": "展开目录",
"Context path": "上下文路径",
"Enter / or a context path such as /app": "请输入 / 或 /app 等上下文路径",
"HTTP port": "HTTP 端口",
"JRE": "Java 运行环境",
"Java installation directory; empty uses the project JDK": "Java 安装目录;留空则使用项目 JDK",
"Open browser after launch": "启动后打开浏览器",
"Browser URL": "浏览器网址",
"Server URL from the HTTP port and context path": "根据 HTTP 端口和上下文路径生成服务器网址",
"Enter an HTTP or HTTPS URL": "请输入 HTTP 或 HTTPS 网址",
"Before launch: build the artifact": "启动前:构建部署构件",
"WAR artifact": "WAR 构件",
"Choose a WAR module": "选择 WAR 模块",
"No WAR modules found. Choose a WAR file or an exploded directory.": "未找到 WAR 模块。请选择 WAR 文件或展开目录。",
"Invalid Tomcat installation: check bin/catalina and lib/catalina.jar": "无效的 Tomcat 安装:请检查 bin/catalina 和 lib/catalina.jar",
"Update Application": "更新应用",
"Update resources": "更新资源",
"Update classes and resources": "更新类和资源",
"Redeploy": "重新部署",
"Restart server": "重启服务器",
"Application updated": "应用已更新",
"Application update failed": "应用更新失败",
"WAR artifact build failed": "WAR 构件构建失败",
"Tomcat Local requires the local machine": "本地 Tomcat 配置只能在本机运行",
"/artifact-name": "/构件名称",
"Choose a WAR artifact": "请选择 WAR 构件",
"Choose a Maven WAR module": "请选择 Maven WAR 模块",
"Choose a Gradle WAR module": "请选择 Gradle WAR 模块",
"Build the selected WAR artifact before launching Tomcat": "请先构建所选 WAR 构件,再启动 Tomcat",
"Build the WAR artifact before launching Tomcat": "请先构建 WAR 构件,再启动 Tomcat",
"The built WAR artifact does not exist": "构建的 WAR 构件不存在",
"Choose a WAR file or exploded directory": "请选择 WAR 文件或展开目录",
"Invalid Tomcat deployment": "无效的 Tomcat 部署",
"Unknown Tomcat deployment type": "未知的 Tomcat 部署类型",
"Choose a Tomcat installation directory": "请选择 Tomcat 安装目录",
"HTTP port must be between 1 and 65535": "HTTP 端口必须介于 1 和 65535 之间",
"Unknown Tomcat update operation": "未知的 Tomcat 更新操作",
"Start this Tomcat configuration before updating it": "请先启动此 Tomcat 配置,再更新应用",
"This Tomcat configuration is already running": "此 Tomcat 配置已在运行",
"Tomcat server.xml has no HTTP connector": "Tomcat 的 server.xml 中没有 HTTP 连接器",
"WAR contains an invalid path": "WAR 中包含无效路径",
"Context path must start with / and contain only letters, digits, /, - or _": "上下文路径必须以 / 开头,且只能包含字母、数字、/、- 或 _",
"Tomcat home must contain bin/catalina.sh or bin/catalina.bat and lib/catalina.jar": "Tomcat 安装目录必须包含 bin/catalina.sh 或 bin/catalina.bat,以及 lib/catalina.jar"
}