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>
This commit is contained in:
ntbang0901
2026-11-02 20:37:03 +08:00
committed by GitHub
parent a623e5d385
commit 8ea8c98a39
3 changed files with 121 additions and 1 deletions
@@ -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<typeof import("@/platform/ipc")>();
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<typeof import("@tauri-apps/api/core")>()),
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);
});
+13 -1
View File
@@ -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.
+40
View File
@@ -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<string, number>();
/** 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<void> {
@@ -167,6 +202,11 @@ export async function loadModel(): Promise<void> {
]);
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) });
}