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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 08:35:18 +08:00
1 джерело 0458424f9c
коміт c3d569d3e3
24 files changed
+964 -152

No files matched your search

+218
Переглянути файл
@@ -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<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
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<ResourceBuild> {
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::<PathBuf>().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<String> = 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(&current).map_err(|e| IdeError::internal(e.to_string()))?,
)?;
Ok(())
}
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> 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());
}
}
+1
Переглянути файл
@@ -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;
+78
Переглянути файл
@@ -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<RunConfig> {
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<String>,
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<Vec<RunConfig>> {
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 });
+1 -1
Переглянути файл
@@ -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"] }
+120
Переглянути файл
@@ -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::<ApplicationFocus>().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::<ApplicationFocus>().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<Vec<Retained<ProtocolObject<dyn NSObjectProtocol>>>> = 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));
}
}
+2
Переглянути файл
@@ -311,6 +311,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+23
Переглянути файл
@@ -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<RunConfig> {
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<String>,
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]
+6
Переглянути файл
@@ -3,6 +3,7 @@
//! This crate is glue only. Each `commands/<domain>.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::<WindowsState>() {
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);
+2
Переглянути файл
@@ -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<RunConfig_Serialize, IdeError>(__TAURI_INVOKE("run_application_build", { config, module })),
runApplicationPublish: (config: RunConfig_Deserialize, classPaths: string[], mainClass: string) => typedError<null, IdeError>(__TAURI_INVOKE("run_application_publish", { config, classPaths, mainClass })),
runTomcatUpdate: (config: RunConfig_Deserialize, mode: string) => typedError<null, IdeError>(__TAURI_INVOKE("run_tomcat_update", { config, mode })),
/**
* Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
+6 -4
Переглянути файл
@@ -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) && (
<IconButton
icon="refresh"
title="Update Application"
shortcut={shortcutLabel("run.updateApplication")}
disabled={!running}
onClick={() => showTomcatUpdate(session.id)}
disabled={session.status !== "running" || updating}
onClick={() => showApplicationUpdate(session.id)}
/>
)}
<div className={styles.toolbarSep} />
+190
Переглянути файл
@@ -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<typeof import("@/platform/javaLaunch")>()),
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<void>((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]);
});
});
+149
Переглянути файл
@@ -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<RunSession, "id" | "config"> & { debug?: DebugServiceSession };
export const useApplicationUpdates = create<{ ids: ReadonlySet<string> }>(() => ({ ids: new Set() }));
const updatingConfigs = new Set<string>();
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<void> {
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());
};
}
+15
Переглянути файл
@@ -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<void> {
let finish!: (ok: boolean) => void;
const exited = new Promise<boolean>((resolve) => {
finish = resolve;
});
const session = await startRun(build, undefined, undefined, {
onExit: (code, stopped) => finish(code === 0 && !stopped),
});
if (!session || session.status === "failed" || !(await exited))
throw new Error(tx("Application update build failed"));
}
+6 -7
Переглянути файл
@@ -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;
+3 -1
Переглянути файл
@@ -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",
+3
Переглянути файл
@@ -15,6 +15,9 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
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<typeof import("@tauri-apps/api/core")>()),
Channel: class {
+17 -1
Переглянути файл
@@ -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(<ConfigForm config={config} all={[config]} type={type} errors={{}} onChange={changed} modifyRequest={0} />);
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)));
});
+13
Переглянути файл
@@ -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)),
},
]
: []),
];
}
+11 -10
Переглянути файл
@@ -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<void> {
const build = await call(commands.runTomcatBuild(config));
if (!build) return;
const { startRun } = await import("./store");
let finish!: (ok: boolean) => void;
const exited = new Promise<boolean>((resolve) => {
finish = resolve;
});
const session = await startRun(build, undefined, undefined, {
onExit: (code, stopped) => finish(code === 0 && !stopped),
});
if (!session || session.status === "failed" || !(await exited)) throw new Error("WAR artifact build failed");
await runUpdateBuild(build);
}
export function tomcatBrowserUrl(config: RunConfig): string {
-63
Переглянути файл
@@ -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");
});
});
-64
Переглянути файл
@@ -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<string>();
export async function updateTomcat(session: RunSession, mode: string): Promise<void> {
if (updating.has(session.id)) return;
updating.add(session.id);
try {
const debugging = useDebugRunSessions.getState().sessions.some((s) => s.configId === session.config.id);
if (mode === "restart") {
if (debugging) {
await stopSession(session.id);
await waitForExit([session.id]);
await executeAction("run.debugConfig", session.config);
} else await startRun(session.config, session.id);
return;
}
await maybeEditorService()?.saveAll();
await buildTomcatArtifact(session.config);
await call(commands.runTomcatUpdate(session.config, mode));
if (mode === "classes" && debugging) {
const swapped =
getAction("debug.reloadChangedClasses") &&
(await executeAction("debug.reloadChangedClasses", { configId: session.config.id }));
if (!swapped) await call(commands.runTomcatUpdate(session.config, "redeploy"));
}
useRun.setState({ activeSession: session.id });
notifications().notify({ group: "run", level: "info", title: "Application updated" });
} catch (e) {
notifications().notify({ group: "run", level: "error", title: "Application update failed", body: String(e) });
} finally {
updating.delete(session.id);
}
}
export function showTomcatUpdate(sessionId?: string): void {
const state = useRun.getState();
const candidates = state.sessions.filter((s) => s.config.kind === "tomcat" && s.status === "running");
const session = candidates.find((s) => s.id === (sessionId || state.activeSession)) ?? candidates[0];
if (!session) return;
showContextMenu({ x: window.innerWidth / 2 - 140, y: 100 }, [
{ kind: "header", label: "Update Application" },
...(
[
["resources", "Update resources"],
["classes", "Update classes and resources"],
["redeploy", "Redeploy"],
["restart", "Restart server"],
] as const
).map(([mode, label]) => ({
label,
disabled:
updating.has(session.id) ||
((mode === "resources" || mode === "classes") && session.config.params?.deployment === "war"),
run: () => {
void updateTomcat(session, mode);
},
})),
]);
}
+42
Переглянути файл
@@ -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);
});
});
+49
Переглянути файл
@@ -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")
);
}
+9 -1
Переглянути файл
@@ -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} 相比没有更改",