mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
d837cb1acb
commit
92b3ae508c
32 files changed
+1366
-14
No files matched your search
Generated
+1
@@ -4462,6 +4462,7 @@ dependencies = [
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 }"));
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)) });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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 = "<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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}/`}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
})),
|
||||
]);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user