дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
42968a39b3
коміт
2e6cda1d86
9 files changed
+267
-7
No files matched your search
@@ -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<String>,
|
||||
resources_only: bool,
|
||||
) -> IdeResult<bool> {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -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<bool> {
|
||||
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<String> =
|
||||
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<String>,
|
||||
) -> 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<PathBuf> {
|
||||
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");
|
||||
|
||||
@@ -332,6 +332,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
resources_only: bool,
|
||||
) -> IdeResult<bool> {
|
||||
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>,
|
||||
|
||||
@@ -623,6 +623,7 @@ export const commands = {
|
||||
}) & (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 })),
|
||||
runTomcatSync: (config: RunConfig_Deserialize, module: string, classPaths: string[], resourcesOnly: boolean) => typedError<boolean, IdeError>(__TAURI_INVOKE("run_tomcat_sync", { config, module, classPaths, resourcesOnly })),
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") &&
|
||||
|
||||
@@ -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<string, string>) => ({ ...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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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 =
|
||||
|
||||
Reference in new issue
Заблокувати користувача