From c3d569d3e39d89752369894775c9c9038922e804 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:35:18 +0800 Subject: [PATCH] feat(run): update Spring Boot resources and application policies (#603) Running Spring Boot main-class configurations had no Update action, and incremental Java builds did not publish edited resources to the running JVM. They now share the Update action, toolbar and build runner with Tomcat: resources go through the module's Maven or Gradle build and are published to the resolved runtime classpath, including Gradle's separate jdt.ls output directory. Class updates use scoped HotSwap when available and explain the restart requirement otherwise. Both configuration types get "On 'Update' action" and "On frame deactivation" policies. A missing policy keeps the chooser and does no deactivation update. Focus tracking debounces external changes and prevents overlapping updates. Verified on macOS with Maven, Gradle, Tomcat 10.1 and HotSwap. Windows and Linux focus detection were not run. On Linux a native GTK dialog can count as deactivation when no webview window is focused. Fixes #580 --- crates/ide-build/src/application_update.rs | 218 +++++++++++++++++++++ crates/ide-build/src/lib.rs | 1 + crates/ide-host/src/ops/run/commands.rs | 78 ++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/src/application_focus.rs | 120 ++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/run.rs | 23 +++ src-tauri/src/lib.rs | 6 + src/bindings.ts | 2 + src/features/run/RunView.tsx | 10 +- src/features/run/applicationUpdate.test.ts | 190 ++++++++++++++++++ src/features/run/applicationUpdate.ts | 149 ++++++++++++++ src/features/run/buildUpdate.ts | 15 ++ src/features/run/index.ts | 13 +- src/features/run/jvmTypes.ts | 4 +- src/features/run/remote.test.tsx | 3 + src/features/run/runConfigTypes.test.tsx | 18 +- src/features/run/services.tsx | 13 ++ src/features/run/tomcat.ts | 21 +- src/features/run/tomcatUpdate.test.ts | 63 ------ src/features/run/tomcatUpdate.ts | 64 ------ src/features/run/updatePolicies.test.ts | 42 ++++ src/features/run/updatePolicies.ts | 49 +++++ src/platform/locales/text/zh.json | 10 +- 24 files changed, 964 insertions(+), 152 deletions(-) create mode 100644 crates/ide-build/src/application_update.rs create mode 100644 src-tauri/src/application_focus.rs create mode 100644 src/features/run/applicationUpdate.test.ts create mode 100644 src/features/run/applicationUpdate.ts create mode 100644 src/features/run/buildUpdate.ts delete mode 100644 src/features/run/tomcatUpdate.test.ts delete mode 100644 src/features/run/tomcatUpdate.ts create mode 100644 src/features/run/updatePolicies.test.ts create mode 100644 src/features/run/updatePolicies.ts diff --git a/crates/ide-build/src/application_update.rs b/crates/ide-build/src/application_update.rs new file mode 100644 index 000000000..cabd8a736 --- /dev/null +++ b/crates/ide-build/src/application_update.rs @@ -0,0 +1,218 @@ +//! Resource processing by the project's build tool, then publishing to the live JVM's output directory. +use crate::BuildModel; +use ide_core::{IdeError, IdeResult}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct ResourceBuild { + pub command: String, + pub args: Vec, + pub cwd: String, + pub env: BTreeMap, + pub kind: &'static str, +} + +fn literal(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) +} + +pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> IdeResult { + fs::create_dir_all(cache)?; + let manifest = cache.join("resource-output.txt"); + if manifest.exists() { + fs::remove_file(&manifest)?; + } + let module = dunce::canonicalize(root.join(module))?; + if let Some(p) = model.maven.as_ref().and_then(|m| m.projects.iter().find(|p| root.join(&p.dir) == module)) { + let mut args = p.options.clone(); + args.extend([ + "process-resources".into(), + "help:evaluate".into(), + "-Dexpression=project.build.outputDirectory".into(), + format!("-Doutput={}", manifest.display()), + ]); + return Ok(ResourceBuild { + command: p.command.clone(), + args, + cwd: p.dir.clone(), + env: p.env.clone(), + kind: "maven", + }); + } + if let Some(g) = &model.gradle + && let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module) + { + let script = cache.join("resources.init.gradle"); + fs::write( + &script, + format!( + r#"gradle.projectsEvaluated {{ + def p = gradle.rootProject.project({}) + def resources = p.tasks.named('processResources') + p.tasks.register('openideaUpdateResources') {{ + dependsOn resources + doLast {{ new File({}).setText(resources.get().destinationDir.absolutePath, 'UTF-8') }} + }} +}} +"#, + literal(&p.path), + literal(&manifest.to_string_lossy()) + ), + )?; + return Ok(ResourceBuild { + command: g.command.clone(), + args: vec![ + "--init-script".into(), + script.to_string_lossy().into_owned(), + format!("{}:openideaUpdateResources", p.path.trim_end_matches(':')), + ], + cwd: g.dir.clone(), + env: g.env.clone(), + kind: "gradle", + }); + } + Err(IdeError::invalid("The Spring Boot module must belong to a linked Maven or Gradle build")) +} + +pub fn publish(root: &Path, cache: &Path, class_paths: &[String], main_class: &str) -> IdeResult<()> { + let output = PathBuf::from(fs::read_to_string(cache.join("resource-output.txt"))?.trim()); + if !output.is_absolute() || !output.is_dir() { + return Err(IdeError::invalid("The resource build did not report an output directory")); + } + let output = dunce::canonicalize(output)?; + let paths: Vec<_> = class_paths.iter().filter_map(|p| dunce::canonicalize(root.join(p)).ok()).collect(); + // A separate resource directory is already on the runtime classpath (e.g. build/resources/main). + if paths.contains(&output) { + return Ok(()); + } + // jdtls uses bin/main for Gradle, rather than Gradle's build/resources/main. + let class_file = main_class.split('.').collect::().with_extension("class"); + let destination = paths.iter().find(|p| p.join(&class_file).is_file()).ok_or_else(|| { + IdeError::invalid("Cannot find the running main class output directory; restart the application") + })?; + let previous: Vec = fs::read(cache.join("published-resources.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + let mut current = Vec::new(); + copy_resources(&output, &output, destination, &mut current)?; + for rel in previous.iter().filter(|p| !current.contains(p)) { + let path = destination.join(rel); + if path.is_file() { + fs::remove_file(path)?; + } + } + fs::write( + cache.join("published-resources.json"), + serde_json::to_vec(¤t).map_err(|e| IdeError::internal(e.to_string()))?, + )?; + Ok(()) +} + +fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec) -> IdeResult<()> { + for entry in fs::read_dir(from)? { + let entry = entry?; + let path = entry.path(); + let kind = entry.file_type()?; + if kind.is_symlink() { + continue; + } + if kind.is_dir() { + copy_resources(root, &path, to, copied)?; + } else if kind.is_file() && path.extension().is_none_or(|e| e != "class") { + let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?; + let dest = to.join(rel); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&path, dest)?; + copied.push(rel.to_string_lossy().into_owned()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_plans_use_the_selected_module_and_build_configuration() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + fs::create_dir(root.join("app")).unwrap(); + let mut model = BuildModel { + maven: Some(crate::MavenModel { + projects: vec![crate::MavenProject { + dir: "app".into(), + command: "/tools/mvnw".into(), + options: vec!["-Pdev".into()], + env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + let cache = root.join("cache"); + let maven = plan(&root, &model, Path::new("app"), &cache).unwrap(); + assert_eq!(maven.command, "/tools/mvnw"); + assert_eq!(maven.cwd, "app"); + assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]); + assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into())); + assert_eq!(maven.env["JAVA_HOME"], "/jdk"); + model.maven = None; + model.gradle = Some(crate::GradleModel { + command: "/tools/gradlew".into(), + projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }], + ..Default::default() + }); + let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap(); + assert_eq!(gradle.command, "/tools/gradlew"); + assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources"); + let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap(); + assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath")); + assert!(!script.contains("build/resources/main")); // The task owns custom output locations. + } + + #[test] + fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() { + for layout in ["target/classes", "build/resources/main", "custom resource output"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let cache = root.join("cache"); + let output = root.join(layout); + let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") }; + fs::create_dir_all(output.join("static")).unwrap(); + fs::create_dir_all(runtime.join("example")).unwrap(); + fs::create_dir_all(&cache).unwrap(); + fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap(); + fs::write(output.join("static/index.html"), "after").unwrap(); + fs::write(output.join("unrelated.class"), "do not publish").unwrap(); + fs::write(runtime.join("example/Application.class"), "running").unwrap(); + publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap(); + assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after"); + assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running"); + if output != runtime { + assert!(!runtime.join("unrelated.class").exists()); + fs::remove_file(output.join("static/index.html")).unwrap(); + publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap(); + assert!(!runtime.join("static/index.html").exists()); + } + } + } + + #[test] + fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let output = root.join("build/resources/main"); + let cache = root.join("cache"); + fs::create_dir_all(&output).unwrap(); + fs::create_dir(&cache).unwrap(); + fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap(); + publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap(); + assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err()); + } +} diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 9afb4e672..10dcd9b1c 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -5,6 +5,7 @@ //! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run //! Maven / Gradle in the background) cached for the project. +pub mod application_update; pub mod compile; pub mod gradle; pub mod maven; diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index 5fa95813a..7a916a519 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -5,6 +5,78 @@ use crate::rpc::Dispatcher; use ide_core::{IdeError, IdeResult}; use ide_run::RunConfig; +fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf { + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + id.hash(&mut hash); + project.cache_dir.join("application-update").join(format!("{:x}", hash.finish())) +} + +pub async fn run_application_build( + host: &Host, + window: &str, + config: RunConfig, + module: std::path::PathBuf, +) -> IdeResult { + host.require_trust(window)?; + if config.kind != "spring-boot" || config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) { + return Err(IdeError::invalid("Application resource updates require a local Spring Boot configuration")); + } + 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 plan = ide_build::application_update::plan( + &project.root, + &model, + &module, + &application_cache(&project, &config.id), + )?; + let mut env = plan.env; + env.extend(config.env); + Ok(RunConfig { + id: format!("application-build:{}", config.id), + name: format!("Update {}", config.name), + kind: plan.kind.into(), + command: plan.command, + args: plan.args, + cwd: Some(project.root.join(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_application_publish( + host: &Host, + window: &str, + config: RunConfig, + class_paths: Vec, + main_class: String, +) -> IdeResult<()> { + host.require_trust(window)?; + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + ide_build::application_update::publish( + &project.root, + &application_cache(&project, &config.id), + &class_paths, + &main_class, + ) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + /// Configuration templates: per-type defaults applied to new configurations. pub fn run_templates(host: &Host, window: &str) -> IdeResult> { let root = host.project(window)?.root.clone(); @@ -134,6 +206,12 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { + d.add("run_application_build", |c| async move { + run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await + }); + d.add("run_application_publish", |c| async move { + run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await + }); 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 }); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6f795e857..fc5613669 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,5 +73,5 @@ ide-github = { path = "../crates/ide-github" } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSView", "NSWindow"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } diff --git a/src-tauri/src/application_focus.rs b/src-tauri/src/application_focus.rs new file mode 100644 index 000000000..292370580 --- /dev/null +++ b/src-tauri/src/application_focus.rs @@ -0,0 +1,120 @@ +//! Debounced application activation, shared by every project window. +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use tauri::{Emitter, Manager}; + +pub const DEACTIVATED: &str = "ide://application-deactivated"; + +#[derive(Default)] +pub struct ApplicationFocus { + generation: AtomicU64, + active: AtomicBool, +} + +impl ApplicationFocus { + fn changed(&self, focused: bool) -> u64 { + if focused { + self.active.store(true, Ordering::Relaxed); + } + self.generation.fetch_add(1, Ordering::Relaxed) + 1 + } + + fn deactivate(&self, generation: u64, active: bool) -> bool { + self.generation.load(Ordering::Relaxed) == generation && !active && self.active.swap(false, Ordering::Relaxed) + } +} + +pub fn changed(app: &tauri::AppHandle, focused: bool) { + let generation = app.state::().changed(focused); + if focused { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if handle.state::().deactivate(generation, application_active(&handle)) { + let _ = handle.emit(DEACTIVATED, ()); + } + }); + }); +} + +#[cfg(target_os = "macos")] +pub fn observe(app: &tauri::AppHandle) { + use objc2::rc::Retained; + use objc2::runtime::ProtocolObject; + use objc2_app_kit::{NSApplicationDidBecomeActiveNotification, NSApplicationDidResignActiveNotification}; + use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; + thread_local! { + static OBSERVERS: std::cell::RefCell>>> = const { std::cell::RefCell::new(Vec::new()) }; + } + for (name, focused) in + unsafe { [(NSApplicationDidBecomeActiveNotification, true), (NSApplicationDidResignActiveNotification, false)] } + { + let handle = app.clone(); + let block = block2::RcBlock::new(move |_| changed(&handle, focused)); + let observer = unsafe { + NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(name), + None, + None, + &block, + ) + }; + OBSERVERS.with_borrow_mut(|observers| observers.push(observer)); + } +} + +fn application_active(app: &tauri::AppHandle) -> bool { + // A settings window, utility window or native dialog is still this application. + #[cfg(target_os = "macos")] + { + let _ = app; + let main = objc2::MainThreadMarker::new().expect("activation is checked on the main thread"); + objc2_app_kit::NSApplication::sharedApplication(main).isActive() + } + #[cfg(target_os = "windows")] + { + let _ = app; + #[link(name = "user32")] + unsafe extern "system" { + fn GetForegroundWindow() -> *mut std::ffi::c_void; + fn GetWindowThreadProcessId(window: *mut std::ffi::c_void, process: *mut u32) -> u32; + } + let mut process = 0; + unsafe { GetWindowThreadProcessId(GetForegroundWindow(), &mut process) }; + process == std::process::id() + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + app.webview_windows().values().any(|w| w.is_focused().unwrap_or(true)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_and_dialog_focus_transfers_do_not_deactivate_the_application() { + let state = ApplicationFocus::default(); + state.changed(true); + let blur = state.changed(false); + assert!(!state.deactivate(blur, true)); + state.changed(true); + assert!(!state.deactivate(blur, false)); + } + + #[test] + fn debounce_emits_once_for_an_external_application_and_skips_stale_checks() { + let state = ApplicationFocus::default(); + state.changed(true); + let stale = state.changed(false); + let latest = state.changed(false); + assert!(!state.deactivate(stale, false)); + assert!(state.deactivate(latest, false)); + assert!(!state.deactivate(latest, false)); + state.changed(true); + let latest = state.changed(false); + assert!(state.deactivate(latest, false)); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f4d94c85b..cb3785ba1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -311,6 +311,8 @@ pub fn builder() -> tauri_specta::Builder { run::run_tomcat_home, run::run_tomcat_artifacts, run::run_tomcat_build, + run::run_application_build, + run::run_application_publish, run::run_tomcat_update, run::run_test_reports, build::build_model, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index fcfe8efa8..998cb5b9d 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -8,6 +8,29 @@ use ide_run::{OutputChunk, RunConfig, RunInfo}; use tauri::ipc::Channel; use tauri::{State, Window}; +#[tauri::command] +#[specta::specta] +pub async fn run_application_build( + window: Window, + state: State<'_, AppState>, + config: RunConfig, + module: std::path::PathBuf, +) -> IdeResult { + ide_host::ops::run::run_application_build(&state.host, window.label(), config, module).await +} + +#[tauri::command] +#[specta::specta] +pub async fn run_application_publish( + window: Window, + state: State<'_, AppState>, + config: RunConfig, + class_paths: Vec, + main_class: String, +) -> IdeResult<()> { + ide_host::ops::run::run_application_publish(&state.host, window.label(), config, class_paths, main_class).await +} + /// Detected configs followed by user-defined ones (user ids win on conflict). #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc2439dc1..2af828bb3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ //! This crate is glue only. Each `commands/.rs` is a thin, typed //! adapter from IPC to a backend crate; no domain logic lives here. +mod application_focus; mod commands; #[cfg(debug_assertions)] mod dev; @@ -134,6 +135,9 @@ pub fn run() { menu::install(app.handle(), &menu_state, &state.recent.list()); app.manage(state); app.manage(windows); + app.manage(application_focus::ApplicationFocus::default()); + #[cfg(target_os = "macos")] + application_focus::observe(app.handle()); app.manage(menu_state); if let Some(paths) = setup_opens.lock().take() { windows::open_paths_at_launch(app.handle(), paths); @@ -144,10 +148,12 @@ pub fn run() { }) .on_window_event(|window, event| match event { tauri::WindowEvent::Focused(true) => { + application_focus::changed(window.app_handle(), true); if let Some(w) = window.try_state::() { w.focused(window.label()); } } + tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false), tauri::WindowEvent::Destroyed => { #[cfg(target_os = "macos")] titlebar::forget(window); diff --git a/src/bindings.ts b/src/bindings.ts index 80ddb8f90..60fcea018 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -590,6 +590,8 @@ export const commands = { /** "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 })), + runApplicationBuild: (config: RunConfig_Deserialize, module: string) => typedError(__TAURI_INVOKE("run_application_build", { config, module })), + runApplicationPublish: (config: RunConfig_Deserialize, classPaths: string[], mainClass: string) => typedError(__TAURI_INVOKE("run_application_publish", { config, classPaths, mainClass })), runTomcatUpdate: (config: RunConfig_Deserialize, mode: string) => typedError(__TAURI_INVOKE("run_tomcat_update", { config, mode })), /** * Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 7c9b5a38f..18964f63d 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -29,7 +29,8 @@ import { } from "./store"; import { TestTreeView, formatDuration } from "./TestTreeView"; import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab } from "./stackTrace"; -import { showTomcatUpdate } from "./tomcatUpdate"; +import { showApplicationUpdate, useApplicationUpdates } from "./applicationUpdate"; +import { isUpdatable } from "./updatePolicies"; import styles from "./Run.module.css"; import { tx } from "@/platform/i18n"; @@ -52,6 +53,7 @@ export function SessionPanel({ onRerun?: (() => void) | null; onStop?: () => void; }) { + const updating = useApplicationUpdates((s) => s.ids.has(session.id)); const version = useRun((s) => s.versions[session.id] ?? 0); const rt = runtime(session.id); const [follow, setFollow] = useState(true); @@ -153,13 +155,13 @@ export function SessionPanel({ disabled={!running && !onStop} onClick={onStop ?? (() => void stopSession(session.id))} /> - {session.config.kind === "tomcat" && ( + {isUpdatable(session.config) && ( showTomcatUpdate(session.id)} + disabled={session.status !== "running" || updating} + onClick={() => showApplicationUpdate(session.id)} /> )}
diff --git a/src/features/run/applicationUpdate.test.ts b/src/features/run/applicationUpdate.test.ts new file mode 100644 index 000000000..834bee8dc --- /dev/null +++ b/src/features/run/applicationUpdate.test.ts @@ -0,0 +1,190 @@ +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, + notify: vi.fn(), + menu: vi.fn(), + springBuild: vi.fn(async () => ({ status: "ok", data: { id: "build" } })), + publish: vi.fn(async () => ({ status: "ok", data: null })), + resolve: vi.fn(async () => ({ + status: "ok", + data: { build: "succeeded", classPaths: ["/p/app/bin/main"], pathListSeparator: ":" }, + })), + runBuild: vi.fn(async () => {}), + state: { configs: [] as RunSession["config"][], sessions: [] as RunSession[], activeSession: null as string | null }, +})); +vi.mock("@/platform/actions", () => ({ executeAction: mocks.action, getAction: () => ({}) })); +vi.mock("@/platform/ipc", () => ({ + call: async (p: Promise<{ data: unknown }>) => (await p).data, + isTauri: () => false, + nextRequestId: () => 1, + commands: { + runTomcatUpdate: mocks.update, + runApplicationBuild: mocks.springBuild, + runApplicationPublish: mocks.publish, + javaResolveLaunch: mocks.resolve, + }, +})); +vi.mock("@/workbench/contributions", () => ({ + maybeEditorService: () => ({ saveAll: async () => {} }), + notifications: () => ({ notify: mocks.notify }), + useDebugRunSessions: { + getState: () => ({ + sessions: mocks.debug ? [{ configId: "tomcat:one" }, { configId: "boot" }] : [], + serviceSessions: [], + }), + }, +})); +vi.mock("@/ui", () => ({ showContextMenu: mocks.menu })); +vi.mock("@/platform/javaLaunch", async (original) => ({ + ...(await original()), + resolveJavaModule: async () => "app", +})); +vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild })); +vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build })); +vi.mock("./store", () => ({ + startRun: mocks.start, + stopSession: mocks.stop, + waitForExit: mocks.wait, + useRun: { setState: vi.fn(), getState: () => mocks.state }, +})); +import { + updateApplication as updateTomcat, + showApplicationUpdate, + updateOnDeactivation, + updatableSessions, + useApplicationUpdates, +} from "./applicationUpdate"; +const session = { id: "run1", config: { id: "tomcat:one", kind: "tomcat" } } as RunSession; +beforeEach(() => { + vi.clearAllMocks(); + mocks.debug = true; + mocks.action.mockResolvedValue(true); + mocks.state.configs = []; + mocks.state.sessions = []; + mocks.state.activeSession = null; + useApplicationUpdates.setState({ ids: new Set() }); +}); +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"); + }); +}); + +const boot = { + id: "boot1", + status: "running", + config: { id: "boot", kind: "spring-boot", args: ["-cp", "${classpath}", "a.Main"] }, +} as RunSession; +describe("Spring Boot application updates", () => { + it("processes resources and publishes them to the resolved runtime output", async () => { + await updateTomcat(boot, "resources"); + expect(mocks.resolve).toHaveBeenCalledWith("app", false, false, 1); + expect(mocks.runBuild).toHaveBeenCalledWith({ id: "build" }); + expect(mocks.publish).toHaveBeenCalledWith(boot.config, ["/p/app/bin/main"], "a.Main"); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.action).not.toHaveBeenCalled(); + }); + it("compiles and invokes the existing scoped HotSwap action in debug", async () => { + await updateTomcat(boot, "classes"); + expect(mocks.resolve).toHaveBeenCalledWith("app", false, true, 1); + expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "boot" }); + }); + it("explains compiled classes require restart without a debugger", async () => { + mocks.debug = false; + await updateTomcat(boot, "classes"); + expect(mocks.action).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining("Restart the application") }), + ); + }); + it("explains the restart requirement if the attached debugger cannot dispatch HotSwap", async () => { + mocks.action.mockResolvedValueOnce(false); + await updateTomcat(boot, "classes"); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining("Restart the application") }), + ); + }); + it("never publishes resources after a failed build", async () => { + mocks.runBuild.mockRejectedValueOnce(new Error("failed")); + await updateTomcat(boot, "resources"); + expect(mocks.publish).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })); + }); + it("does not overlap updates of a session", async () => { + let finish!: () => void; + mocks.runBuild.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const first = updateTomcat(boot, "resources"); + await vi.waitFor(() => expect(mocks.runBuild).toHaveBeenCalled()); + await updateTomcat(boot, "resources"); + expect(mocks.runBuild).toHaveBeenCalledTimes(1); + finish(); + await first; + expect(useApplicationUpdates.getState().ids.size).toBe(0); + }); + it("retains the chooser by default and never offers redeploy", () => { + mocks.state.sessions = [boot]; + showApplicationUpdate(); + const items = mocks.menu.mock.calls[0]![1]; + expect(items.map((i: { label: string }) => i.label)).toEqual([ + "Update Application", + "Update resources", + "Update classes and resources", + "Restart", + ]); + }); + it("uses current saved policies and defaults deactivation to doing nothing", async () => { + mocks.state.sessions = [boot]; + updateOnDeactivation(); + expect(mocks.resolve).not.toHaveBeenCalled(); + mocks.state.configs = [{ ...boot.config, params: { onUpdate: "resources", onDeactivation: "resources" } }]; + showApplicationUpdate(boot.id); + expect(mocks.menu).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(useApplicationUpdates.getState().ids.size).toBe(0)); + updateOnDeactivation(); + await vi.waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(2)); + }); + it("enables only running, local application sessions", () => { + mocks.state.sessions = [ + boot, + { ...boot, id: "done", status: "finished" }, + { ...boot, id: "goal", config: { ...boot.config, kind: "maven" } }, + { ...boot, id: "remote", config: { ...boot.config, target: "ssh" } }, + ]; + expect(updatableSessions().map((s) => s.id)).toEqual([boot.id]); + }); +}); diff --git a/src/features/run/applicationUpdate.ts b/src/features/run/applicationUpdate.ts new file mode 100644 index 000000000..51fb0c51b --- /dev/null +++ b/src/features/run/applicationUpdate.ts @@ -0,0 +1,149 @@ +import { tx } from "@/platform/i18n"; +import { create } from "zustand"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { executeAction, getAction } from "@/platform/actions"; +import { call, commands, isTauri, nextRequestId, type RunConfig } from "@/platform/ipc"; +import { parseJavaArgs, resolveJavaModule } from "@/platform/javaLaunch"; +import { + maybeEditorService, + notifications, + useDebugRunSessions, + type DebugServiceSession, +} from "@/workbench/contributions"; +import { showContextMenu } from "@/ui"; +import { buildTomcatArtifact } from "./tomcat"; +import { runUpdateBuild } from "./buildUpdate"; +import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store"; +import { isUpdatable, updateAllowed, type UpdateMode } from "./updatePolicies"; + +export type UpdateSession = Pick & { debug?: DebugServiceSession }; +export const useApplicationUpdates = create<{ ids: ReadonlySet }>(() => ({ ids: new Set() })); +const updatingConfigs = new Set(); + +export function updatableSessions(): UpdateSession[] { + const runs = useRun.getState().sessions.filter((s) => isUpdatable(s.config) && s.status === "running"); + const debug = useDebugRunSessions + .getState() + .serviceSessions.filter( + (s) => + isUpdatable(s.config) && + (s.status === "running" || s.status === "paused") && + !runs.some((r) => r.runId === s.processId && r.config.id === s.config.id), + ) + .map((s) => ({ id: `debug:${s.id}`, config: s.config, debug: s })); + return [...runs, ...debug]; +} + +function params(session: UpdateSession): RunConfig["params"] { + return useRun.getState().configs.find((c) => c.id === session.config.id)?.params ?? session.config.params; +} + +async function updateSpringBoot(config: RunConfig, mode: UpdateMode, debugging: boolean) { + const module = await resolveJavaModule(config); + const parsed = parseJavaArgs(config.args); + if (!parsed.mainClass) throw new Error(tx("The Spring Boot configuration has no main class")); + const info = await call( + commands.javaResolveLaunch(module, parsed.classpath === "test", mode === "classes", nextRequestId()), + ); + if (info.build === "failed" || info.build === "withErrors" || info.build === "cancelled") + throw new Error(tx("Application update build failed")); + await runUpdateBuild(await call(commands.runApplicationBuild(config, module))); + const classPaths = parsed.explicitClasspath?.split(info.pathListSeparator) ?? info.classPaths; + await call(commands.runApplicationPublish(config, classPaths, parsed.mainClass)); + if (mode === "classes" && debugging && (await executeAction("debug.reloadChangedClasses", { configId: config.id }))) + return; + notifications().notify({ + group: "run", + level: "info", + title: "Resources updated", + ...(mode === "classes" + ? { + body: "Classes were compiled. Restart the application to load them, or launch with the debugger to use HotSwap.", + } + : {}), + }); +} + +export async function updateApplication(session: UpdateSession, mode: string): Promise { + if (!updateAllowed(session.config, mode) || updatingConfigs.has(session.config.id)) return; + updatingConfigs.add(session.config.id); + useApplicationUpdates.setState((s) => ({ ids: new Set([...s.ids, session.id]) })); + try { + const debugging = + !!session.debug || useDebugRunSessions.getState().sessions.some((s) => s.configId === session.config.id); + if (mode === "restart") { + if (session.debug) { + await session.debug.stop(); + await executeAction("run.debugConfig", session.config); + } else 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(); + if (session.config.kind === "spring-boot") { + await updateSpringBoot(session.config, mode, debugging); + } else { + 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")); + } + notifications().notify({ group: "run", level: "info", title: "Application updated" }); + } + if (!session.debug) useRun.setState({ activeSession: session.id }); + } catch (e) { + notifications().notify({ group: "run", level: "error", title: "Application update failed", body: String(e) }); + } finally { + updatingConfigs.delete(session.config.id); + useApplicationUpdates.setState((s) => ({ ids: new Set([...s.ids].filter((id) => id !== session.id)) })); + } +} + +export function showApplicationUpdate(sessionId?: string): void { + const state = useRun.getState(); + const candidates = updatableSessions(); + const session = candidates.find((s) => s.id === (sessionId || state.activeSession)) ?? candidates[0]; + if (!session) return; + const policy = params(session)?.onUpdate ?? "ask"; + if (policy !== "ask" && updateAllowed(session.config, policy)) { + void updateApplication(session, policy); + return; + } + const choices = [ + ["resources", "Update resources"], + ["classes", "Update classes and resources"], + ...(session.config.kind === "tomcat" ? [["redeploy", "Redeploy"]] : []), + ["restart", session.config.kind === "tomcat" ? "Restart server" : "Restart"], + ]; + showContextMenu({ x: window.innerWidth / 2 - 140, y: 100 }, [ + { kind: "header", label: "Update Application" }, + ...choices.map(([mode, label]) => ({ + label: label!, + disabled: useApplicationUpdates.getState().ids.has(session.id) || !updateAllowed(session.config, mode!), + run: () => { + void updateApplication(session, mode!); + }, + })), + ]); +} + +export function updateOnDeactivation(): void { + for (const session of updatableSessions()) { + const mode = params(session)?.onDeactivation; + if (mode === "resources" || mode === "classes") void updateApplication(session, mode); + } +} + +export function listenForApplicationDeactivation(): () => void { + if (!isTauri()) return () => {}; + const listener = getCurrentWebviewWindow().listen("ide://application-deactivated", updateOnDeactivation); + return () => { + void listener.then((unlisten) => unlisten()); + }; +} diff --git a/src/features/run/buildUpdate.ts b/src/features/run/buildUpdate.ts new file mode 100644 index 000000000..759050d95 --- /dev/null +++ b/src/features/run/buildUpdate.ts @@ -0,0 +1,15 @@ +import { tx } from "@/platform/i18n"; +import type { RunConfig } from "@/platform/ipc"; +import { startRun } from "./store"; + +export async function runUpdateBuild(build: RunConfig): Promise { + let finish!: (ok: boolean) => void; + const exited = new Promise((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(tx("Application update build failed")); +} diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 495dcf271..5cac309c2 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -44,7 +44,7 @@ import { runBeforeLaunch } from "./beforeLaunch"; import { compoundChildren } from "./configs"; import { remoteDebugBlocked } from "./remote"; import { isDebugOnly, registerBuiltinTypes } from "./types"; -import { showTomcatUpdate } from "./tomcatUpdate"; +import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate"; import { runGutter } from "./runGutter"; import { activateStackTrace, useStackTraces } from "./stackTrace"; import { clearRuntimeCache } from "./runtimes"; @@ -280,6 +280,8 @@ export function activate(): () => void { onWorkflowSettingsChange((w) => w.console, applyConsoleSettings), registerExternalTools(), activateStackTrace(), + listenForApplicationDeactivation(), + useDebugRunSessions.subscribe(() => setContext("run.updatableRunning", updatableSessions().length > 0)), registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }), registerMenuGroup({ id: "run.context", @@ -293,8 +295,8 @@ export function activate(): () => void { title: "Update Application", category: "Run", icon: "refresh", - when: "run.tomcatRunning", - run: () => showTomcatUpdate(), + when: "run.updatableRunning", + run: () => showApplicationUpdate(), }, { id: "run.run", @@ -510,10 +512,7 @@ export function activate(): () => void { // 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 ?? ""); - setContext( - "run.tomcatRunning", - s.sessions.some((session) => session.config.kind === "tomcat" && session.status === "running"), - ); + setContext("run.updatableRunning", updatableSessions().length > 0); }), useProject.subscribe((s, prev) => { if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return; diff --git a/src/features/run/jvmTypes.ts b/src/features/run/jvmTypes.ts index 421b98b7a..2401ea7e6 100644 --- a/src/features/run/jvmTypes.ts +++ b/src/features/run/jvmTypes.ts @@ -9,6 +9,7 @@ import { CLASSPATH, parseJavaArgs, TEST_CLASSPATH } from "@/platform/javaLaunch" import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions"; import { formatArgs, parseArgs } from "./configs"; import { commandName } from "./commandName"; +import { updatePolicyDefaults, updatePolicyFields } from "./updatePolicies"; export const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform); @@ -56,7 +57,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution { label: spring ? "Spring Boot" : "Application", icon: spring ? "spring-boot" : "class", description: spring ? "Run a Spring Boot application" : "Run a Java main class", - defaults: { classpath: CLASSPATH }, + defaults: { classpath: CLASSPATH, ...(spring ? updatePolicyDefaults : {}) }, debugAdapter: "java-debug", fields: [ { @@ -70,6 +71,7 @@ function jvmApplicationType(spring: boolean): RunConfigTypeContribution { vmOptionsField(), { key: "programArgs", label: "Program arguments", mono: true }, ...(spring ? [profilesField("Comma separated; passed as --spring.profiles.active")] : []), + ...(spring ? updatePolicyFields(false) : []), { key: "classpath", label: "Classpath", diff --git a/src/features/run/remote.test.tsx b/src/features/run/remote.test.tsx index d18cd2b3f..4c96812fa 100644 --- a/src/features/run/remote.test.tsx +++ b/src/features/run/remote.test.tsx @@ -15,6 +15,9 @@ vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; }); +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ listen: async () => () => {} }), +})); vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ ...(await importOriginal()), Channel: class { diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index 27c4cb67a..d37509ca5 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -16,7 +16,7 @@ import { import { typeRows } from "./AddConfigPopup"; import { runBeforeLaunch } from "./beforeLaunch"; import { buildRows } from "./ConfigChooser"; -import { memberRows } from "./ConfigForm"; +import { ConfigForm, memberRows } from "./ConfigForm"; import { compoundChildren, compoundCycle, formatEnv, parseEnv, validateConfig } from "./configs"; import { contextConfig } from "./context"; import { EditConfigsOverlay, moveConfig, treeRows } from "./EditConfigsDialog"; @@ -70,6 +70,22 @@ beforeEach(() => { afterEach(() => disposeTypes()); describe("configuration types", () => { + it.each(["spring-boot", "tomcat"])("edits shared update policies in the %s form", async (kind) => { + const type = typeFor(kind)!; + const original = newConfigOfType(type, []); + const config = + kind === "spring-boot" ? withParams(original, { ...original.params, mainClass: "a.Main" }) : original; + const changed = vi.fn(); + render(); + expect(screen.getByRole("button", { name: "On 'Update' action" }).textContent).toContain("Show chooser"); + expect(screen.getByRole("button", { name: "On frame deactivation" }).textContent).toContain("Do nothing"); + fireEvent.click(screen.getByRole("button", { name: "On frame deactivation" })); + fireEvent.click(await screen.findByRole("option", { name: "Update resources" })); + expect(changed).toHaveBeenCalledWith( + expect.objectContaining({ params: expect.objectContaining({ onDeactivation: "resources" }) }), + ); + }); + it("are contributed through the registry", () => { expect(runConfigTypes().map((t) => t.id)).toEqual(expect.arrayContaining(BUILTIN_TYPES.map((t) => t.id))); }); diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index 7f13a708f..3d31c201f 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -27,6 +27,8 @@ import { } from "@/workbench/contributions"; import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs"; import { SessionPanel } from "./RunView"; +import { showApplicationUpdate } from "./applicationUpdate"; +import { isUpdatable } from "./updatePolicies"; import { startRun, useRun, type RunSession } from "./store"; import { currentServiceSession, @@ -307,6 +309,17 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] { }, { id: "debug", title: "Debug", icon: "debug", disabled: !!debugging && !debugging.rerun, run: () => debug(r) }, stop, + ...(isUpdatable(r.config) + ? [ + { + id: "update", + title: "Update Application", + icon: "refresh", + disabled: !live, + run: () => showApplicationUpdate(r.session?.run?.id ?? (debugging ? `debug:${debugging.id}` : undefined)), + }, + ] + : []), ]; } diff --git a/src/features/run/tomcat.ts b/src/features/run/tomcat.ts index c6a899150..7676b7787 100644 --- a/src/features/run/tomcat.ts +++ b/src/features/run/tomcat.ts @@ -1,5 +1,7 @@ import { call, commands, type RunConfig } from "@/platform/ipc"; import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { updatePolicyDefaults, updatePolicyFields } from "./updatePolicies"; +import { runUpdateBuild } from "./buildUpdate"; export const tomcatType: RunConfigTypeContribution = { id: "tomcat", @@ -9,8 +11,15 @@ export const tomcatType: RunConfigTypeContribution = { description: "Run a WAR application in a local Tomcat server", debugAdapter: "java-debug", terminal: false, - defaults: { deployment: "artifact", httpPort: "8080", buildArtifact: "true", openBrowser: "true" }, + defaults: { + deployment: "artifact", + httpPort: "8080", + buildArtifact: "true", + openBrowser: "true", + ...updatePolicyDefaults, + }, fields: [ + ...updatePolicyFields(true), { key: "tomcatHome", label: "Tomcat installation directory", required: true, mono: true }, { key: "deployment", @@ -82,15 +91,7 @@ export const tomcatType: RunConfigTypeContribution = { export async function buildTomcatArtifact(config: RunConfig): Promise { const build = await call(commands.runTomcatBuild(config)); if (!build) return; - const { startRun } = await import("./store"); - let finish!: (ok: boolean) => void; - const exited = new Promise((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"); + await runUpdateBuild(build); } export function tomcatBrowserUrl(config: RunConfig): string { diff --git a/src/features/run/tomcatUpdate.test.ts b/src/features/run/tomcatUpdate.test.ts deleted file mode 100644 index 631b3a20b..000000000 --- a/src/features/run/tomcatUpdate.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -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"); - }); -}); diff --git a/src/features/run/tomcatUpdate.ts b/src/features/run/tomcatUpdate.ts deleted file mode 100644 index bf93a3d04..000000000 --- a/src/features/run/tomcatUpdate.ts +++ /dev/null @@ -1,64 +0,0 @@ -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(); -export async function updateTomcat(session: RunSession, mode: string): Promise { - 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); - }, - })), - ]); -} diff --git a/src/features/run/updatePolicies.test.ts b/src/features/run/updatePolicies.test.ts new file mode 100644 index 000000000..324495350 --- /dev/null +++ b/src/features/run/updatePolicies.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import { applicationType, springBootType } from "./jvmTypes"; +import { tomcatType } from "./tomcat"; +import { isUpdatable, updateAllowed } from "./updatePolicies"; + +const config = (kind: string, params = {}, target?: RunConfig["target"]) => ({ kind, params, target }) as RunConfig; + +describe("application update policies", () => { + it.each([tomcatType, springBootType])("shares safe defaults and policy fields in $id", (type) => { + expect(type.defaults).toMatchObject({ onUpdate: "ask", onDeactivation: "none" }); + const update = type.fields?.find((f) => f.key === "onUpdate"); + const deactivate = type.fields?.find((f) => f.key === "onDeactivation"); + expect(update?.options?.map((o) => o.value)).toEqual( + type.id === "tomcat" + ? ["ask", "resources", "classes", "redeploy", "restart"] + : ["ask", "resources", "classes", "restart"], + ); + expect(deactivate?.options?.map((o) => o.value)).toEqual(["none", "resources", "classes"]); + }); + + it("keeps plain Java applications and build-tool goals outside the feature", () => { + expect(applicationType.fields?.some((f) => f.key === "onUpdate")).toBe(false); + for (const kind of ["application", "maven", "gradle", "java-file"]) expect(isUpdatable(config(kind))).toBe(false); + }); + + it.each(["tomcat", "spring-boot"])("enables local %s sessions", (kind) => { + expect(isUpdatable(config(kind))).toBe(true); + expect(updateAllowed(config(kind), "resources")).toBe(true); + expect(updateAllowed(config(kind), "classes")).toBe(true); + expect(updateAllowed(config(kind), "restart")).toBe(true); + expect(updateAllowed(config(kind), "invalid")).toBe(false); + expect(isUpdatable(config(kind, {}, "remote"))).toBe(false); + }); + + it("preserves WAR restrictions and limits redeploy to Tomcat", () => { + expect(updateAllowed(config("tomcat", { deployment: "war" }), "resources")).toBe(false); + expect(updateAllowed(config("tomcat", { deployment: "war" }), "classes")).toBe(false); + expect(updateAllowed(config("tomcat", { deployment: "war" }), "redeploy")).toBe(true); + expect(updateAllowed(config("spring-boot"), "redeploy")).toBe(false); + }); +}); diff --git a/src/features/run/updatePolicies.ts b/src/features/run/updatePolicies.ts new file mode 100644 index 000000000..ffa6aa2a5 --- /dev/null +++ b/src/features/run/updatePolicies.ts @@ -0,0 +1,49 @@ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeField } from "@/workbench/contributions"; + +export type UpdateMode = "resources" | "classes" | "redeploy" | "restart"; + +export const updatePolicyDefaults = { onUpdate: "ask", onDeactivation: "none" }; + +const incrementalOptions = [ + { value: "resources", label: "Update resources" }, + { value: "classes", label: "Update classes and resources" }, +]; + +export function updatePolicyFields(tomcat: boolean): RunConfigTypeField[] { + return [ + { + key: "onUpdate", + label: "On 'Update' action", + control: "select", + options: [ + { value: "ask", label: "Show chooser" }, + ...incrementalOptions, + ...(tomcat ? [{ value: "redeploy", label: "Redeploy" }] : []), + { value: "restart", label: "Restart" }, + ], + }, + { + key: "onDeactivation", + label: "On frame deactivation", + control: "select", + options: [{ value: "none", label: "Do nothing" }, ...incrementalOptions], + hint: "When all project windows lose focus to another application", + }, + ]; +} + +export function isUpdatable(config: RunConfig): boolean { + return !config.target && (config.kind === "tomcat" || config.kind === "spring-boot"); +} + +export function updateAllowed(config: RunConfig, mode: string): mode is UpdateMode { + return ( + isUpdatable(config) && + (mode === "resources" || + mode === "classes" || + mode === "restart" || + (mode === "redeploy" && config.kind === "tomcat")) && + !((mode === "resources" || mode === "classes") && config.kind === "tomcat" && config.params?.deployment === "war") + ); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d63459460..80c3cf0b0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4963,7 +4963,6 @@ "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 构件", @@ -5352,6 +5351,15 @@ "The attached process may not provide standard output to the debugger.": "附加的进程可能不会向调试器提供标准输出。", "Show in Debug Tool Window": "在调试工具窗口中显示", "Suspended": "已暂停", + "On 'Update' action": "执行“更新”操作时", + "Show chooser": "显示选择菜单", + "On frame deactivation": "窗口失去焦点时", + "When all project windows lose focus to another application": "当所有项目窗口失去焦点并切换到其他应用时", + "Resources updated": "资源已更新", + "Classes were compiled. Restart the application to load them, or launch with the debugger to use HotSwap.": "类已编译。重启应用以加载新类,或通过调试启动以使用 HotSwap。", + "Application update build failed": "应用更新构建失败", + "The Spring Boot configuration has no main class": "Spring Boot 配置未指定主类", + "Do nothing": "不执行任何操作", "Cannot compare {root} against {label}: {error}": "无法将 {root} 与 {label} 比较:{error}", "Compare with Branch or Revision": "与分支或修订比较", "No changes against {revision} in 1 repository": "在 1 个仓库中与 {revision} 相比没有更改",