diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index a0a553b4f..925c995a1 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -218,12 +218,13 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I .filter(|s| !s.is_empty()) .or_else(|| artifacts.first().map(|a| &a.id)) .ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?; - let mut plan = ide_build::web::build( - &project.root, - &model, - id, - &ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?, - )?; + let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?; + // "Build before launch" is skipped when the built WAR is already newer than the + // module's sources, so a repeated run does not rebuild (IDEA's up-to-date check). + if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) { + return Ok(None); + } + let mut plan = ide_build::web::build(&project.root, &model, id, &base)?; plan.env.extend(config.env.clone()); let mut build = RunConfig { id: format!("tomcat-build:{}", config.id), diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs index ac0a55b03..7895c8f8b 100644 --- a/crates/ide-run/src/tomcat.rs +++ b/crates/ide-run/src/tomcat.rs @@ -849,21 +849,115 @@ fn sync_tree( Ok(()) } +/// A built `*.war` under a module's usual output directory (`target/`, `build/libs/`). +fn is_war(path: &Path) -> bool { + path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) +} + +/// The module directory a config's `cwd` names (absolute, or relative to the project root). +fn module_dir(root: &Path, module: Option<&Path>) -> PathBuf { + match module { + Some(m) if m.is_absolute() => m.to_path_buf(), + Some(m) => root.join(m), + None => root.to_path_buf(), + } +} + +/// The newest modification time of `path` or, for a directory, anything below it. +fn newest_mtime_under(path: &Path) -> Option { + let meta = fs::metadata(path).ok()?; + let mut newest = meta.modified().ok(); + if meta.is_dir() { + for entry in fs::read_dir(path).ok()?.flatten() { + if let Some(time) = newest_mtime_under(&entry.path()) + && newest.is_none_or(|cur| time > cur) + { + newest = Some(time); + } + } + } + newest +} + +/// The newest `*.war` a build tool left in the module's output directories. Used when no +/// build ran this launch and nothing was recorded, so a project that was already built can +/// start directly (IDEA deploys the existing artifact instead of rebuilding it). +fn find_built_war(root: &Path, module: Option<&Path>) -> Option { + let module = module_dir(root, module); + let mut newest: Option<(std::time::SystemTime, PathBuf)> = None; + for dir in [module.join("target"), module.join("build/libs")] { + let Ok(entries) = fs::read_dir(dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && is_war(&path) { + let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH); + if newest.as_ref().is_none_or(|(at, _)| modified > *at) { + newest = Some((modified, path)); + } + } + } + } + newest.map(|(_, path)| path) +} + +/// Whether the WAR a Tomcat config deploys is newer than the module's sources, so its +/// "Before launch › Build" task can be skipped (IDEA's up-to-date check). Conservative: +/// a missing WAR or a newer source/build file means it is not current. Only the module's +/// own sources and build files are inspected, not dependency modules. +pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool { + let p = &config.extras.params; + if p.get("deployment").map(String::as_str).unwrap_or("artifact") != "artifact" { + return true; + } + let module = module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path())); + let selected = p.get("artifact").filter(|s| !s.is_empty()); + let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok(); + let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str())); + let recorded = fs::read_to_string(base.join("artifact-path.txt")).ok().map(|s| PathBuf::from(s.trim())); + let war = match recorded.filter(|_| id_matches) { + Some(war) if war.is_file() && is_war(&war) => war, + _ => match find_built_war(root, Some(&module)) { + Some(war) => war, + None => return false, + }, + }; + let Ok(war_time) = fs::metadata(&war).and_then(|m| m.modified()) else { return false }; + let mut newest = newest_mtime_under(&module.join("src")); + for dir in [module.as_path(), root] { + for name in ["pom.xml", "build.gradle", "build.gradle.kts"] { + if let Ok(time) = fs::metadata(dir.join(name)).and_then(|m| m.modified()) + && newest.is_none_or(|cur| time > cur) + { + newest = Some(time); + } + } + } + newest.is_none_or(|time| time <= war_time) +} + pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult { let p = &config.extras.params; match p.get("deployment").map(String::as_str).unwrap_or("artifact") { "artifact" => { - if let Some(id) = p.get("artifact").filter(|s| !s.is_empty()) - && fs::read_to_string(base.join("artifact-id.txt")).ok().as_ref() != Some(id) - { - return Err(invalid("Build the selected WAR artifact before launching Tomcat")); - } - let war = fs::read_to_string(base.join("artifact-path.txt")) - .map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?; - let war = PathBuf::from(war.trim()); - if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) { - return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display()))); - } + // A build run this launch records its archive here. Without one (Before launch › + // Build removed, as IDEA allows), reuse the last build's archive, or an archive a + // build tool already left in the module output, instead of rebuilding. + let selected = p.get("artifact").filter(|s| !s.is_empty()); + let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok(); + let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str())); + let recorded = fs::read_to_string(base.join("artifact-path.txt")).ok().map(|s| PathBuf::from(s.trim())); + let war = match recorded.filter(|_| id_matches) { + Some(war) => { + if war.is_file() && is_war(&war) { + war + } else { + return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display()))); + } + } + None => find_built_war(root, config.cwd.as_ref().map(|c| c.0.as_path())).ok_or_else(|| { + invalid("No built WAR artifact found - build the project first, or add 'Build' to Before launch") + })?, + }; let dest = base.join("deployment"); explode(&war, &dest, resources_only)?; Ok(dest) @@ -1402,6 +1496,57 @@ mod tests { } } } + #[test] + fn reuses_a_built_war_when_no_build_ran() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let module = root.join("web"); + fs::create_dir_all(module.join("target")).unwrap(); + let war = module.join("target/app.war"); + archive(&war, &[("index.html", "built")]); + let base = root.join("cache"); + fs::create_dir_all(&base).unwrap(); + let mut c = config(); + c.cwd = Some(ide_core::IpcPath(PathBuf::from("web"))); + c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into()); + // No artifact-path.txt / artifact-id.txt: it was never built through Tomcat. + let dest = deployment(root, &base, &c, false).unwrap(); + assert_eq!(fs::read_to_string(dest.join("index.html")).unwrap(), "built"); + } + + #[test] + fn reports_a_missing_artifact_when_nothing_was_built() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("cache"); + fs::create_dir_all(&base).unwrap(); + let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string(); + assert!(error.contains("No built WAR artifact found"), "{error}"); + } + + #[test] + fn skips_the_build_when_the_built_war_is_current() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let module = root.join("web"); + fs::create_dir_all(module.join("src/main/java")).unwrap(); + fs::create_dir_all(module.join("target")).unwrap(); + fs::write(module.join("pom.xml"), "").unwrap(); + let source = module.join("src/main/java/App.java"); + fs::write(&source, "class App {}").unwrap(); + let war = module.join("target/app.war"); + archive(&war, &[("index.html", "built")]); + let base = root.join("cache"); + fs::create_dir_all(&base).unwrap(); + let mut c = config(); + c.cwd = Some(ide_core::IpcPath(PathBuf::from("web"))); + c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into()); + // The WAR was packed after the source was written. + assert!(artifact_is_current(root, &base, &c)); + std::thread::sleep(std::time::Duration::from_millis(20)); + fs::write(&source, "class App { int changed; }").unwrap(); + assert!(!artifact_is_current(root, &base, &c)); + } + #[test] fn prepares_isolated_base_and_jdwp_only_for_run() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/features/run/tomcat.ts b/src/features/run/tomcat.ts index 3a45afd82..fa7f0f592 100644 --- a/src/features/run/tomcat.ts +++ b/src/features/run/tomcat.ts @@ -21,6 +21,7 @@ export const tomcatType: RunConfigTypeContribution = { deployment: "artifact", httpPort: "8080", jmxPort: "1099", + // Build before launch (IDEA default); skipped automatically when the built WAR is current. buildArtifact: "true", openBrowser: "true", ...updatePolicyDefaults, diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index e3eb28e6b..afc607e6c 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -5131,8 +5131,6 @@ "Choose a WAR artifact": "Elija un artefacto WAR", "Choose a Maven WAR module": "Elija un módulo WAR de Maven", "Choose a Gradle WAR module": "Elija un módulo WAR de Gradle", - "Build the selected WAR artifact before launching Tomcat": "Compilar el artefacto WAR seleccionado antes de iniciar Tomcat", - "Build the WAR artifact before launching Tomcat": "Compilar el artefacto WAR antes de iniciar Tomcat", "The built WAR artifact does not exist: {path}": "El artefacto WAR compilado no existe: {path}", "Cannot resolve Maven WAR artifact: {details}": "No se puede resolver el artefacto WAR de Maven: {details}", "Choose a WAR file or exploded directory": "Elija un archivo WAR o un directorio exploded", diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 34952281b..111bdc698 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -4953,8 +4953,6 @@ "Choose a WAR artifact": "Choisissez un artefact WAR", "Choose a Maven WAR module": "Choisissez un module WAR Maven", "Choose a Gradle WAR module": "Choisissez un module WAR Gradle", - "Build the selected WAR artifact before launching Tomcat": "Compiler l'artefact WAR sélectionné avant de lancer Tomcat", - "Build the WAR artifact before launching Tomcat": "Compiler l'artefact WAR avant de lancer Tomcat", "The built WAR artifact does not exist: {path}": "L'artefact WAR compilé n'existe pas : {path}", "Cannot resolve Maven WAR artifact: {details}": "Impossible de résoudre l'artefact WAR Maven : {details}", "Choose a WAR file or exploded directory": "Choisissez un fichier WAR ou un répertoire éclaté", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 503b64a96..1141da1c0 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -5204,8 +5204,6 @@ "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": "Tomcat을 실행하기 전에 선택한 WAR 아티팩트 빌드", - "Build the WAR artifact before launching Tomcat": "Tomcat을 실행하기 전에 WAR 아티팩트 빌드", "The built WAR artifact does not exist: {path}": "빌드된 WAR 아티팩트가 없습니다: {path}", "Cannot resolve Maven WAR artifact: {details}": "Maven WAR 아티팩트를 해결할 수 없습니다: {details}", "Choose a WAR file or exploded directory": "WAR 파일 또는 exploded 디렉터리를 선택하세요", diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json index c63f50b28..1f0e2d015 100644 --- a/src/platform/locales/text/pl.json +++ b/src/platform/locales/text/pl.json @@ -5082,8 +5082,6 @@ "Choose a WAR artifact": "Wybierz artefakt WAR", "Choose a Maven WAR module": "Wybierz moduł WAR Maven", "Choose a Gradle WAR module": "Wybierz moduł WAR Gradle", - "Build the selected WAR artifact before launching Tomcat": "Zbuduj wybrany artefakt WAR przed uruchomieniem serwera Tomcat", - "Build the WAR artifact before launching Tomcat": "Zbuduj artefakt WAR przed uruchomieniem serwera Tomcat", "The built WAR artifact does not exist: {path}": "Zbudowany artefakt WAR nie istnieje: {path}", "Cannot resolve Maven WAR artifact: {details}": "Nie można odnaleźć artefaktu WAR Maven: {details}", "Choose a WAR file or exploded directory": "Wybierz plik WAR lub rozpakowany katalog (exploded)", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index 6288ee8e8..dccb6faf5 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -5096,8 +5096,6 @@ "Choose a WAR artifact": "Escolha um artefato WAR", "Choose a Maven WAR module": "Escolha um módulo WAR do Maven", "Choose a Gradle WAR module": "Escolha um módulo WAR do Gradle", - "Build the selected WAR artifact before launching Tomcat": "Compilar o artefato WAR selecionado antes de iniciar o Tomcat", - "Build the WAR artifact before launching Tomcat": "Compilar o artefato WAR antes de iniciar o Tomcat", "The built WAR artifact does not exist: {path}": "O artefato WAR compilado não existe: {path}", "Cannot resolve Maven WAR artifact: {details}": "Não é possível resolver o artefato WAR do Maven: {details}", "Choose a WAR file or exploded directory": "Escolha um arquivo WAR ou diretório exploded", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 84123aee1..f5360d024 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5464,8 +5464,6 @@ "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: {path}": "构建的 WAR 构件不存在:{path}", "Cannot resolve Maven WAR artifact: {details}": "无法解析 Maven WAR 构件:{details}", "Choose a WAR file or exploded directory": "请选择 WAR 文件或展开目录",