From 2e6cda1d865a908bc3fe14a80b7ed091c0dd8375 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:43:54 +0800 Subject: [PATCH] feat(run): update a Tomcat deployment from compiled classes instead of rebuilding the WAR (#1140) "Update classes and resources" / "Update resources" on a Tomcat configuration deploying the project's WAR artifact ran a full package build and re-exploded the archive. They now compile the WAR module incrementally through jdtls and copy only the changed classes and resources into WEB-INF/classes and changed web files (src/main/webapp) into the exploded deployment; Tomcat reloads the context, or HotSwap applies the classes when debugging. Falls back to rebuilding the WAR when a dependency module was compiled since the last update (its WEB-INF/lib jar would be stale), the deployment doesn't exist yet, or no Java language server is available. Redeploy still rebuilds the WAR. Closes #970 --- crates/ide-host/src/ops/run/commands.rs | 40 ++++++ crates/ide-run/src/tomcat.rs | 136 +++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/run.rs | 12 ++ src/bindings.ts | 1 + src/features/run/applicationUpdate.test.ts | 15 ++- src/features/run/applicationUpdate.ts | 8 +- src/features/run/tomcat.test.tsx | 39 +++++- src/features/run/tomcat.ts | 22 +++- 9 files changed, 267 insertions(+), 7 deletions(-) diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index 6bd695895..6d2ba830c 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -241,6 +241,35 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod .map_err(|e| IdeError::internal(e.to_string()))? } +/// Updates a running Tomcat artifact deployment from the module's compiled output and web +/// resources (`ide_run::tomcat::sync_outputs`). `false`: rebuild the WAR instead. +pub async fn run_tomcat_sync( + host: &Host, + window: &str, + config: RunConfig, + module: std::path::PathBuf, + class_paths: Vec, + resources_only: bool, +) -> IdeResult { + host.require_trust(window)?; + let project = host.project(window)?; + if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) { + return Err(IdeError::invalid("Start this Tomcat configuration before updating it")); + } + if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") { + return Ok(false); + } + tokio::task::spawn_blocking(move || { + let module = dunce::canonicalize(project.root.join(module))?; + let class_paths: Vec<_> = + class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect(); + let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id); + ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("run_dotnet_resolve", |c| async move { @@ -255,6 +284,17 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await }); d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await }); d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await }); + d.add("run_tomcat_sync", |c| async move { + run_tomcat_sync( + &c.host, + &c.window, + c.arg("config")?, + c.arg("module")?, + c.arg("classPaths")?, + c.arg("resourcesOnly")?, + ) + .await + }); d.add("run_tomcat_update", |c| async move { run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await }); diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs index fadc4259b..4eb198b37 100644 --- a/crates/ide-run/src/tomcat.rs +++ b/crates/ide-run/src/tomcat.rs @@ -203,6 +203,94 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul remove_stale(destination, destination, &expected, resources_only) } +/// Updates the exploded artifact deployment from the module's own class output directories and +/// its web resources (`src/main/webapp`), file by file and only what changed, instead of +/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute. +/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output +/// isn't on the classpath, or a module it depends on was compiled since the last update, whose +/// archive in `WEB-INF/lib` would be stale) — rebuild the WAR instead. +pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resources_only: bool) -> IdeResult { + let dest = base.join("deployment"); + let manifest = base.join("synced-files.json"); + let since = [base.join("artifact-path.txt"), manifest.clone()] + .iter() + .filter_map(|p| fs::metadata(p).and_then(|m| m.modified()).ok()) + .max(); + let Some(since) = since else { return Ok(false) }; + if !dest.join("WEB-INF").is_dir() { + return Ok(false); + } + let (own, others): (Vec<&PathBuf>, Vec<&PathBuf>) = + class_paths.iter().filter(|p| p.is_dir()).partition(|p| p.starts_with(module)); + if own.is_empty() || others.iter().any(|d| modified_since(d, since)) { + return Ok(false); + } + let previous: Vec = + fs::read(&manifest).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + let mut current = std::collections::BTreeSet::new(); + let classes = dest.join("WEB-INF/classes"); + for dir in own { + sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?; + } + let webapp = module.join("src/main/webapp"); + if webapp.is_dir() { + sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?; + } + for rel in previous.iter().filter(|p| !current.contains(*p)) { + let path = dest.join(rel); + if path.is_file() { + fs::remove_file(path)?; + } + } + fs::write(manifest, serde_json::to_vec(¤t).map_err(invalid)?)?; + Ok(true) +} + +fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool { + let Ok(entries) = fs::read_dir(dir) else { return false }; + entries.flatten().any(|e| match e.file_type() { + Ok(t) if t.is_dir() => modified_since(&e.path(), since), + Ok(t) if t.is_file() => e.metadata().and_then(|m| m.modified()).is_ok_and(|m| m > since), + _ => false, + }) +} + +/// Copies changed files of `from` (below `root`) to `to`; unchanged files keep their timestamps, +/// so Tomcat only reloads when a class really changed. Records each source file as `prefix/rel`. +fn sync_tree( + root: &Path, + from: &Path, + to: &Path, + prefix: &Path, + resources_only: bool, + seen: &mut std::collections::BTreeSet, +) -> IdeResult<()> { + for entry in fs::read_dir(from)? { + let entry = entry?; + let path = entry.path(); + let kind = entry.file_type()?; + if kind.is_dir() { + sync_tree(root, &path, to, prefix, resources_only, seen)?; + continue; + } + if !kind.is_file() { + continue; + } + let rel = path.strip_prefix(root).map_err(invalid)?; + seen.insert(prefix.join(rel).to_string_lossy().replace('\\', "/")); + if resources_only && rel.extension().is_some_and(|e| e == "class") { + continue; + } + let target = to.join(rel); + let bytes = fs::read(&path)?; + if fs::read(&target).ok().as_deref() != Some(&bytes) { + fs::create_dir_all(target.parent().unwrap_or(to))?; + fs::write(target, bytes)?; + } + } + Ok(()) +} + pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult { let p = &config.extras.params; match p.get("deployment").map(String::as_str).unwrap_or("artifact") { @@ -439,6 +527,54 @@ mod tests { assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent")); } #[test] + fn updates_the_exploded_deployment_from_module_outputs() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (base, module) = (root.join("base"), root.join("web")); + let (dest, out, dep) = + (base.join("deployment"), module.join("target/classes"), root.join("core/target/classes")); + let webapp = module.join("src/main/webapp"); + for dir in [dest.join("WEB-INF/classes/a"), out.join("a"), webapp.clone(), dep.clone()] { + fs::create_dir_all(dir).unwrap(); + } + for (path, text) in [ + (dest.join("WEB-INF/classes/a/Same.class"), "same"), + (dest.join("WEB-INF/classes/a/App.class"), "old"), + (dest.join("index.jsp"), "old"), + (out.join("a/Same.class"), "same"), + (out.join("a/App.class"), "new"), + (out.join("app.properties"), "new"), + (webapp.join("index.jsp"), "new"), + ] { + fs::write(path, text).unwrap(); + } + let paths = [out.clone(), dep.clone(), root.join("lib.jar")]; + // Not deployed from the artifact yet. + assert!(!sync_outputs(&base, &module, &paths, false).unwrap()); + fs::write(base.join("artifact-path.txt"), "web.war").unwrap(); + let same = || fs::metadata(dest.join("WEB-INF/classes/a/Same.class")).unwrap().modified().unwrap(); + let before = same(); + + assert!(sync_outputs(&base, &module, &paths, true).unwrap()); + assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "old"); + assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/app.properties")).unwrap(), "new"); + assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "new"); + + assert!(sync_outputs(&base, &module, &paths, false).unwrap()); + assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "new"); + assert_eq!(same(), before); + + fs::remove_file(out.join("app.properties")).unwrap(); + assert!(sync_outputs(&base, &module, &paths, false).unwrap()); + assert!(!dest.join("WEB-INF/classes/app.properties").exists()); + assert!(dest.join("WEB-INF/classes/a/Same.class").exists()); + + // A dependency module compiled since: its archive in WEB-INF/lib is stale. + let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60); + fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap(); + assert!(!sync_outputs(&base, &module, &paths, false).unwrap()); + } + #[test] fn deployment_errors_identify_the_checked_path() { let tmp = tempfile::tempdir().unwrap(); let base = tmp.path().join("cache"); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e0d6d051d..16e6d465b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -332,6 +332,7 @@ pub fn builder() -> tauri_specta::Builder { run::run_tomcat_build, run::run_application_build, run::run_application_publish, + run::run_tomcat_sync, 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 b5e0a9025..14f5f3d16 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -202,6 +202,18 @@ pub async fn run_tomcat_build( } #[tauri::command] #[specta::specta] +pub async fn run_tomcat_sync( + window: Window, + state: State<'_, AppState>, + config: RunConfig, + module: std::path::PathBuf, + class_paths: Vec, + resources_only: bool, +) -> IdeResult { + ide_host::ops::run::run_tomcat_sync(&state.host, window.label(), config, module, class_paths, resources_only).await +} +#[tauri::command] +#[specta::specta] pub async fn run_tomcat_update( window: Window, state: State<'_, AppState>, diff --git a/src/bindings.ts b/src/bindings.ts index bc3842b3f..17ffc6c10 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -623,6 +623,7 @@ export const commands = { }) & (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 })), + runTomcatSync: (config: RunConfig_Deserialize, module: string, classPaths: string[], resourcesOnly: boolean) => typedError(__TAURI_INVOKE("run_tomcat_sync", { config, module, classPaths, resourcesOnly })), 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/applicationUpdate.test.ts b/src/features/run/applicationUpdate.test.ts index 834bee8dc..58616bc95 100644 --- a/src/features/run/applicationUpdate.test.ts +++ b/src/features/run/applicationUpdate.test.ts @@ -3,6 +3,7 @@ import type { RunSession } from "./store"; const mocks = vi.hoisted(() => ({ action: vi.fn(async () => true), build: vi.fn(async () => {}), + sync: vi.fn(async () => false), update: vi.fn(async () => ({ status: "ok", data: null })), start: vi.fn(async () => {}), stop: vi.fn(async () => {}), @@ -47,7 +48,7 @@ vi.mock("@/platform/javaLaunch", async (original) => ({ resolveJavaModule: async () => "app", })); vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild })); -vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build })); +vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build, syncTomcatOutputs: mocks.sync })); vi.mock("./store", () => ({ startRun: mocks.start, stopSession: mocks.stop, @@ -66,6 +67,7 @@ beforeEach(() => { vi.clearAllMocks(); mocks.debug = true; mocks.action.mockResolvedValue(true); + mocks.sync.mockResolvedValue(false); mocks.state.configs = []; mocks.state.sessions = []; mocks.state.activeSession = null; @@ -80,6 +82,17 @@ describe("Tomcat application updates", () => { 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("copies the compiled outputs into the deployment without rebuilding the WAR when it can", async () => { + mocks.sync.mockResolvedValue(true); + await updateTomcat(session, "classes"); + expect(mocks.sync).toHaveBeenCalledWith(session.config, false); + expect(mocks.build).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "tomcat:one" }); + await updateTomcat(session, "redeploy"); + expect(mocks.build).toHaveBeenCalledWith(session.config); + expect(mocks.update).toHaveBeenCalledWith(session.config, "redeploy"); + }); it("does not replace classes when updating only resources", async () => { await updateTomcat(session, "resources"); expect(mocks.update).toHaveBeenCalledWith(session.config, "resources"); diff --git a/src/features/run/applicationUpdate.ts b/src/features/run/applicationUpdate.ts index 51fb0c51b..280b97201 100644 --- a/src/features/run/applicationUpdate.ts +++ b/src/features/run/applicationUpdate.ts @@ -11,7 +11,7 @@ import { type DebugServiceSession, } from "@/workbench/contributions"; import { showContextMenu } from "@/ui"; -import { buildTomcatArtifact } from "./tomcat"; +import { buildTomcatArtifact, syncTomcatOutputs } from "./tomcat"; import { runUpdateBuild } from "./buildUpdate"; import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store"; import { isUpdatable, updateAllowed, type UpdateMode } from "./updatePolicies"; @@ -86,8 +86,10 @@ export async function updateApplication(session: UpdateSession, mode: string): P 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 === "redeploy" || !(await syncTomcatOutputs(session.config, mode === "resources"))) { + await buildTomcatArtifact(session.config); + await call(commands.runTomcatUpdate(session.config, mode)); + } if (mode === "classes" && debugging) { const swapped = getAction("debug.reloadChangedClasses") && diff --git a/src/features/run/tomcat.test.tsx b/src/features/run/tomcat.test.tsx index 2e8472bb4..a06bf316b 100644 --- a/src/features/run/tomcat.test.tsx +++ b/src/features/run/tomcat.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { render, screen, waitFor, cleanup } from "@testing-library/react"; import { newConfigOfType, missingParams } from "./types"; -import { tomcatType, tomcatBrowserUrl } from "./tomcat"; +import { tomcatType, tomcatBrowserUrl, syncTomcatOutputs } from "./tomcat"; import { jdwpLaunch } from "./javaDebug"; import { TomcatSettings } from "./TomcatSettings"; import { typeRows } from "./AddConfigPopup"; @@ -13,8 +13,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({ runTomcatHome: vi.fn(async () => ({ status: "ok", data: { version: "Apache Tomcat/10.1.61" } })), runTomcatArtifacts: vi.fn(async () => ({ status: "ok", - data: [{ id: "maven:pom.xml", name: "hello", module: "", tool: "maven" }], + data: [ + { id: "maven:pom.xml", name: "hello", module: "", tool: "maven" }, + { id: "maven:web/pom.xml", name: "web", module: "web", tool: "maven" }, + ], })), + javaResolveLaunch: vi.fn(async () => ({ + status: "ok", + data: { build: "succeeded", classPaths: ["/p/web/target/classes", "/m2/lib.jar"], pathListSeparator: ":" }, + })), + runTomcatSync: vi.fn(async () => ({ status: "ok", data: true })), }, })); afterEach(cleanup); @@ -83,3 +91,30 @@ describe("Tomcat Local", () => { expect(await screen.findByRole("alert")).toHaveTextContent("Invalid Tomcat installation"); }); }); + +describe("updating a running Tomcat deployment", () => { + const config = (params: Record) => ({ ...newConfigOfType(tomcatType, []), id: "t", params }); + + it("compiles the selected WAR module and copies its outputs instead of rebuilding the WAR", async () => { + const c = config({ deployment: "artifact", artifact: "maven:web/pom.xml" }); + expect(await syncTomcatOutputs(c, false)).toBe(true); + expect(commands.javaResolveLaunch).toHaveBeenCalledWith("web", false, true, expect.anything()); + expect(commands.runTomcatSync).toHaveBeenCalledWith(c, "web", ["/p/web/target/classes", "/m2/lib.jar"], false); + }); + + it("leaves WAR files, exploded directories and failed compilations to the WAR path", async () => { + vi.mocked(commands.runTomcatSync).mockClear(); + expect(await syncTomcatOutputs(config({ deployment: "war" }), true)).toBe(false); + vi.mocked(commands.javaResolveLaunch).mockResolvedValueOnce({ + status: "error", + error: { kind: "unavailable" }, + } as never); + expect(await syncTomcatOutputs(config({ deployment: "artifact" }), true)).toBe(false); + expect(commands.runTomcatSync).not.toHaveBeenCalled(); + vi.mocked(commands.javaResolveLaunch).mockResolvedValueOnce({ + status: "ok", + data: { build: "withErrors", classPaths: [] }, + } as never); + await expect(syncTomcatOutputs(config({ deployment: "artifact" }), false)).rejects.toThrow(); + }); +}); diff --git a/src/features/run/tomcat.ts b/src/features/run/tomcat.ts index d4bfca2e0..c3b2eefc1 100644 --- a/src/features/run/tomcat.ts +++ b/src/features/run/tomcat.ts @@ -1,4 +1,5 @@ -import { call, commands, type RunConfig } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { call, commands, nextRequestId, type RunConfig } from "@/platform/ipc"; import type { RunConfigTypeContribution } from "@/workbench/contributions"; import { updatePolicyDefaults, updatePolicyFields } from "./updatePolicies"; import { runUpdateBuild } from "./buildUpdate"; @@ -96,6 +97,25 @@ export async function buildTomcatArtifact(config: RunConfig): Promise { await runUpdateBuild(build); } +/** + * Updates a running artifact deployment without rebuilding the WAR: compiles the WAR module + * incrementally and copies its changed classes, resources and web files into the deployment. + * `false` when that isn't possible (other deployment kinds, no Java language server, a changed + * dependency module): the caller rebuilds the WAR. + */ +export async function syncTomcatOutputs(config: RunConfig, resourcesOnly: boolean): Promise { + const p = config.params ?? {}; + if ((p.deployment ?? "artifact") !== "artifact") return false; + const artifacts = await call(commands.runTomcatArtifacts()); + const artifact = p.artifact ? artifacts.find((a) => a.id === p.artifact) : artifacts[0]; + if (!artifact) return false; + const info = await call(commands.javaResolveLaunch(artifact.module, false, true, nextRequestId())).catch(() => null); + if (!info) return false; + if (info.build === "failed" || info.build === "withErrors" || info.build === "cancelled") + throw new Error(tx("Application update build failed")); + return call(commands.runTomcatSync(config, artifact.module, info.classPaths, resourcesOnly)); +} + export function tomcatBrowserUrl(config: RunConfig): string { const p = config.params ?? {}; const name =