From 8ea8c98a39c5d862e030fc2faea93c282cb9d05b Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:37:03 +0700 Subject: [PATCH] feat(maven): reload a stale Maven build after external changes to its files (#780) When a pom.xml changes outside the IDE (a VCS update, another tool) and the model is stale afterwards, the Maven build now reloads on its own; edits saved from the editor still only show "Load Maven Changes". A change counts as external unless the IDE itself saved that file moments before, so a build file open in a tab that a tool rewrites also reloads. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/build-tools/autoReload.test.ts | 68 +++++++++++++++++++++ src/features/build-tools/index.ts | 14 ++++- src/features/build-tools/store.ts | 40 ++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/features/build-tools/autoReload.test.ts diff --git a/src/features/build-tools/autoReload.test.ts b/src/features/build-tools/autoReload.test.ts new file mode 100644 index 00000000..90eaa015 --- /dev/null +++ b/src/features/build-tools/autoReload.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { BuildModel, MavenModel, ProjectInfo } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { changedOutsideIde, loadModel, noteExternalBuildChange, noteIdeSave, useBuild } from "./store"; + +const mocks = vi.hoisted(() => ({ model: vi.fn(), sync: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + call: (value: unknown) => value, + commands: { + ...real.commands, + buildModel: mocks.model, + runConfigs: () => Promise.resolve([]), + buildSync: mocks.sync, + cancelRequest: () => Promise.resolve(), + }, + }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage: unknown = null; + }, +})); + +const maven = (stale: boolean) => + ({ syncedAt: 1, stale, syncError: null, profiles: [], projects: [] }) as unknown as MavenModel; +const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: null }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sync.mockImplementation(() => new Promise(() => {})); + useBuild.setState({ syncing: {} }); + useProject.setState({ project: { root: "/p", trusted: true } as ProjectInfo }); +}); + +it("reloads a build made stale by a change outside the editor, not by an edit in it", async () => { + mocks.model.mockResolvedValue(model(true)); + await loadModel(); + expect(mocks.sync).not.toHaveBeenCalled(); + + noteExternalBuildChange(); + await loadModel(); + expect(mocks.sync).toHaveBeenCalledTimes(1); + expect(mocks.sync.mock.calls[0]![0]).toBe("maven"); + expect(useBuild.getState().model?.maven?.stale).toBe(true); +}); + +it("leaves an up-to-date build alone after an external change", async () => { + mocks.model.mockResolvedValue(model(false)); + noteExternalBuildChange(); + await loadModel(); + expect(mocks.sync).not.toHaveBeenCalled(); +}); + +it("tells the IDE's own saves of a build file apart from changes made outside it", () => { + noteIdeSave("/p/pom.xml", 1000); + expect(changedOutsideIde(["/p/pom.xml"], 2000)).toBe(false); + // A tool rewrites the same file later, while it is still open in a tab. + expect(changedOutsideIde(["/p/pom.xml"], 10_000)).toBe(true); + // Never saved by the IDE: a VCS update. + expect(changedOutsideIde(["/p/core/pom.xml"], 2000)).toBe(true); + noteIdeSave("/p/pom.xml", 20_000); + expect(changedOutsideIde(["/p/pom.xml", "/p/core/pom.xml"], 20_500)).toBe(true); +}); diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index e80e103e..172d3e3a 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -3,6 +3,7 @@ * project has that build system, like IDEA. The model is re-read when build * files change; running goals / tasks goes through the run feature's actions. */ +import { onEditorEvent } from "@/editor/api"; import { executeAction, registerActions } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc"; @@ -25,6 +26,9 @@ import { unlinkProjects, cancelSync, loadModel, + changedOutsideIde, + noteExternalBuildChange, + noteIdeSave, openAnalyzer, resetBuild, setExpanded, @@ -158,8 +162,16 @@ export function activate(): () => void { resetBuild(); if (s.project) void loadModel(); }), + onEditorEvent((e) => { + if (e.type === "docSaved" && BUILD_FILE.test(e.path)) noteIdeSave(e.path); + }), onIdeEvent("fsChanged", (e) => { - if ([...e.created, ...e.modified, ...e.removed].some((p) => BUILD_FILE.test(p))) scheduleLoad(); + const changed = [...e.created, ...e.modified, ...e.removed].filter((p) => BUILD_FILE.test(p)); + if (!changed.length) return; + // Changes the IDE's own saves didn't make came from outside (a VCS update, another tool), + // also for a build file open in a tab. + if (changedOutsideIde(changed)) noteExternalBuildChange(); + scheduleLoad(); }), onIdeEvent("projectRootsChanged", scheduleLoad), // Project JDK / Maven home and options are applied to the model's launchers. diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 2b6c8a5d..f3555e4c 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -142,6 +142,41 @@ export function pushHistory(tool: BuildTool, line: string) { // Model let generation = 0; +let externalChange = false; + +/** + * A build file changed outside the editor (VCS update, another tool): the next model load + * reloads a build it finds stale, IDEA's default "Reload after external changes". Edits in + * the editor only show "Load … Changes". + */ +export function noteExternalBuildChange() { + externalChange = true; +} + +/** How long after the IDE saves a build file its file-system events still count as that save. */ +const IDE_SAVE_WINDOW_MS = 5000; +/** Build files the IDE saved, with when: their file-system events are edits made in the IDE. */ +const savedByIde = new Map(); + +/** The IDE saved `path` (an editor save, not a tool rewriting it). */ +export function noteIdeSave(path: string, now = Date.now()) { + savedByIde.set(path, now); +} + +/** + * Whether any of the changed build files was written by something other than the IDE's own + * save: a VCS update or a tool, also when the file is open in an editor tab. + */ +export function changedOutsideIde(paths: readonly string[], now = Date.now()): boolean { + let external = false; + for (const path of paths) { + const at = savedByIde.get(path); + if (at !== undefined && now - at <= IDE_SAVE_WINDOW_MS) continue; + savedByIde.delete(path); + external = true; + } + return external; +} /** Re-reads the build model (and the run configurations) of the open project. */ export async function loadModel(): Promise { @@ -167,6 +202,11 @@ export async function loadModel(): Promise { ]); if (gen !== generation) return; useBuild.setState({ model, configs, loading: false, error: null }); + if (externalChange) { + externalChange = false; + const maven = model.maven; + if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven"); + } } catch (e) { if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) }); }