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} 相比没有更改",