feat(app): ask before closing with running Run/Debug processes (#1445)

Closing a window, closing or switching a project, or quitting while Run/Debug sessions are still running now asks before terminating them (Terminate / Cancel / Don't ask again, sharing run.confirmTerminate with the Run tab prompt). Run publishes its live sessions through a RunningProcessesProvider contribution, so the close guard collects them without importing the run feature; the prompt comes before the unsaved-changes step so cancelling never discards edits.

Closes #1433

Co-authored-by: ppxb <[email protected]>
This commit is contained in:
ppxbandppxb authored and GitHub committed 2026-11-05 12:34:35 +08:00
1 parent 3e88fa1f39
commit 43af99d3ec
7 files changed
+223 -7

No files matched your search

+60 -1
View File
@@ -12,12 +12,22 @@ function deps(answers: PromptResult[]) {
}),
saveAll: vi.fn(async () => {}),
disableConfirmExit: vi.fn(async () => {}),
disableConfirmTerminate: vi.fn(async () => {}),
runningProcesses: vi.fn(async () => []),
onSaveFailed: vi.fn(),
};
return { d, asked };
}
const base: CloseInput = { quitting: false, windows: 1, confirmExit: false, dirty: 0, project: "openidea" };
const base: CloseInput = {
quitting: false,
windows: 1,
confirmExit: false,
confirmTerminate: false,
processes: [],
dirty: 0,
project: "openidea",
};
describe("decideClose", () => {
it("closes silently when nothing needs asking", async () => {
@@ -50,6 +60,46 @@ describe("decideClose", () => {
expect(asked).toHaveLength(0);
});
it("asks before terminating a running process", async () => {
const { d, asked } = deps([{ button: "terminate", checked: false }]);
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d)).resolves.toBe(true);
expect(asked).toHaveLength(1);
expect(asked[0]?.title).toContain("app");
expect(asked[0]?.body).toContain("app");
});
it("names every running process in the prompt", async () => {
const { d, asked } = deps([{ button: "terminate", checked: false }]);
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app", "worker"] }, d)).resolves.toBe(true);
expect(asked[0]?.body).toContain("app, worker");
});
it("cancelling the terminate prompt keeps the window", async () => {
const { d } = deps([{ button: "cancel", checked: false }]);
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d)).resolves.toBe(false);
});
it("asks about processes before discarding unsaved changes", async () => {
const { d, asked } = deps([{ button: "cancel", checked: false }]);
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"], dirty: 1 }, d)).resolves.toBe(
false,
);
expect(asked).toHaveLength(1);
expect(d.saveAll).not.toHaveBeenCalled();
});
it("'don't ask again' turns process confirmation off", async () => {
const { d } = deps([{ button: "terminate", checked: true }]);
await decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d);
expect(d.disableConfirmTerminate).toHaveBeenCalled();
});
it("does not ask about processes when the setting is off", async () => {
const { d, asked } = deps([]);
await decideClose({ ...base, confirmTerminate: false, processes: ["app"] }, d);
expect(asked).toHaveLength(0);
});
it("saves all on Save", async () => {
const { d } = deps([{ button: "save", checked: false }]);
await expect(decideClose({ ...base, dirty: 2 }, d)).resolves.toBe(true);
@@ -179,3 +229,12 @@ it("includes pending local edits and restored dirty buffers before backend dirty
spy.mockRestore();
}
});
it("confirmLeave asks about running processes before closing", async () => {
const { confirmLeave } = await import("../closeGuard");
const { d, asked } = deps([{ button: "cancel", checked: false }]);
d.runningProcesses = vi.fn(async () => ["app"]);
await expect(confirmLeave(d, "openidea")).resolves.toBe(false);
expect(asked[0]?.title).toContain("app");
expect(asked[0]?.body).toContain("app");
});
+71 -6
View File
@@ -3,14 +3,21 @@
*
* Closing a window (traffic light, Quit closing every window) runs
* `decideClose`: confirm exit when this is the last window and
* `general.confirmExit` is on, then Save All / Don't Save / Cancel when
* documents have unsaved changes. Quit confirms once, then the backend closes
* every window, each of which runs only its unsaved-changes check.
* `general.confirmExit` is on, then Terminate / Cancel when Run/Debug processes
* still run and `run.confirmTerminate` is on, then Save All / Don't Save / Cancel
* when documents have unsaved changes. Quit confirms once, then the backend
* closes every window, each of which runs its own checks.
*/
import { commands, isTauri, onIdeEvent, type Settings } from "@/platform/ipc";
import { t } from "@/platform/i18n";
import { closeProject, provideLeaveProjectGuard, useProject } from "@/platform/project";
import { maybeEditorService, notifications, type EditorCloseSession } from "@/workbench/contributions";
import { workflowSettings } from "@/platform/workflowSettings";
import {
maybeEditorService,
notifications,
runningProcesses as liveProcesses,
type EditorCloseSession,
} from "@/workbench/contributions";
import { ask, type Ask } from "@/workbench/messages";
import { AUX_CLOSED, auxiliaryWindow, closeDocumentWindows, type AuxCloseResult } from "@/platform/auxWindow";
@@ -29,6 +36,9 @@ export interface CloseInput {
quitting: boolean;
windows: number;
confirmExit: boolean;
confirmTerminate: boolean;
/** Running Run/Debug process names this close would terminate. */
processes: readonly string[];
dirty: number;
project: string | null;
}
@@ -37,6 +47,9 @@ export interface CloseDeps {
ask: Ask;
saveAll: () => Promise<void>;
disableConfirmExit: () => Promise<void>;
disableConfirmTerminate: () => Promise<void>;
/** Running Run/Debug process names in this window. */
runningProcesses: () => Promise<readonly string[]>;
onSaveFailed: (e: unknown) => void;
/** Stops custom document edits/execution before reading their final dirty state. */
prepareClose?: () => Promise<EditorCloseSession | undefined>;
@@ -54,6 +67,24 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise<b
if (r.button !== "exit") return false;
if (r.checked) await deps.disableConfirmExit().catch(() => {});
}
if (input.confirmTerminate && input.processes.length > 0) {
const names = input.processes;
const r = await deps.ask({
title:
names.length === 1
? t("app.terminateProcesses.titleOne", { name: names[0]! })
: t("app.terminateProcesses.titleMany", { count: names.length }),
body:
names.length === 1
? t("app.terminateProcesses.bodyOne", { name: names[0]! })
: t("app.terminateProcesses.bodyMany", { count: names.length, names: names.join(", ") }),
buttons: [{ id: "terminate", label: t("app.terminateProcesses.terminate"), primary: true }],
checkbox: t("app.terminateProcesses.dontAsk"),
icon: "warning",
});
if (r.button !== "terminate") return false;
if (r.checked) await deps.disableConfirmTerminate().catch(() => {});
}
let session: EditorCloseSession | undefined;
let authorized = false;
try {
@@ -94,6 +125,11 @@ const defaultDeps: CloseDeps = {
const r = await commands.settingsSet("global", "general.confirmExit", "false");
if (r.status === "ok") useProject.setState({ settings: r.data });
},
disableConfirmTerminate: async () => {
const r = await commands.settingsSet("global", "run.confirmTerminate", "false");
if (r.status === "ok") useProject.setState({ settings: r.data });
},
runningProcesses: async () => (await liveProcesses()).map((p) => p.name),
onSaveFailed: (e) =>
notifications().notify({
level: "error",
@@ -102,6 +138,11 @@ const defaultDeps: CloseDeps = {
}),
};
/** Process names this close would terminate; an unavailable feature list never blocks closing. */
async function processNames(deps: CloseDeps): Promise<string[]> {
return [...new Set(await deps.runningProcesses().catch(() => []))].filter(Boolean);
}
let closing = false;
async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
@@ -120,11 +161,14 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
if (await confirmLeave(deps)) await closeProject();
return false;
}
const confirmTerminate = workflowSettings().run.confirmTerminate;
const ok = await decideClose(
{
quitting: ctx.quitting,
windows: ctx.windows,
confirmExit: confirmExitEnabled(s.settings),
confirmTerminate,
processes: confirmTerminate ? await processNames(deps) : [],
dirty: dirtyCount(),
project: s.project?.name ?? null,
},
@@ -151,15 +195,36 @@ export async function confirmLeave(
project = useProject.getState().project?.name ?? null,
): Promise<boolean> {
if (!(await closeDocumentWindows())) return false;
return decideClose({ quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project }, deps);
const confirmTerminate = workflowSettings().run.confirmTerminate;
return decideClose(
{
quitting: false,
windows: 2,
confirmExit: false,
confirmTerminate,
processes: confirmTerminate ? await processNames(deps) : [],
dirty: dirtyCount(),
project,
},
deps,
);
}
/** Quit: confirm once (if enabled), then let the backend close every window. */
export async function quitApp(deps: CloseDeps = defaultDeps): Promise<void> {
if (!isTauri()) return;
if (confirmExitEnabled(useProject.getState().settings)) {
// Each window asks about its own processes as the backend closes it.
const ok = await decideClose(
{ quitting: false, windows: 1, confirmExit: true, dirty: 0, project: null },
{
quitting: false,
windows: 1,
confirmExit: true,
confirmTerminate: false,
processes: [],
dirty: 0,
project: null,
},
{ ...deps, prepareClose: undefined },
);
if (!ok) return;
+9
View File
@@ -16,6 +16,7 @@ import {
registerMenuGroup,
registerOverlay,
registerRunAnythingProvider,
registerRunningProcessesProvider,
registerServiceProvider,
registerTitleItem,
registerToolWindow,
@@ -42,6 +43,7 @@ import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { cleanupRemovedProject, recordRun, runServiceProvider } from "./services";
import { liveServiceSession, serviceSessions } from "./serviceSessions";
import { runBeforeLaunch } from "./beforeLaunch";
import { compoundChildren } from "./configs";
import { remoteDebugBlocked } from "./remote";
@@ -294,6 +296,13 @@ export function activate(): () => void {
registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }),
registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }),
registerServiceProvider(runServiceProvider),
registerRunningProcessesProvider({
id: "run",
running: () =>
serviceSessions(useRun.getState().sessions, useDebugRunSessions.getState().serviceSessions)
.filter(liveServiceSession)
.map((s) => ({ name: s.config.name })),
}),
onWorkflowSettingsChange((w) => w.console, applyConsoleSettings),
registerExternalTools(),
activateStackTrace(),
+6
View File
@@ -50,6 +50,12 @@
"app.lightEdit.openProjectTooltip": "Open {folder} as a project",
"app.log.failed": "Cannot show the log",
"app.project.opening": "Opening {name}…",
"app.terminateProcesses.bodyMany": "Do you want to terminate these {count} processes: {names}?",
"app.terminateProcesses.bodyOne": "Do you want to terminate the process '{name}'?",
"app.terminateProcesses.dontAsk": "Don't ask again",
"app.terminateProcesses.terminate": "Terminate",
"app.terminateProcesses.titleMany": "Processes Are Running",
"app.terminateProcesses.titleOne": "Process '{name}' Is Running",
"app.title.default": "OpenIDEA",
"app.unsaved.body": "{count, plural, one {# file has} other {# files have}} unsaved changes in {project}. Save before closing?",
"app.unsaved.discard": "Don't Save",
+6
View File
@@ -50,6 +50,12 @@
"app.lightEdit.openProjectTooltip": "将 {folder} 作为项目打开",
"app.log.failed": "无法显示日志",
"app.project.opening": "正在打开 {name}…",
"app.terminateProcesses.bodyMany": "是否要终止这 {count} 个进程:{names}?",
"app.terminateProcesses.bodyOne": "是否要终止进程“{name}”?",
"app.terminateProcesses.dontAsk": "不再询问",
"app.terminateProcesses.terminate": "终止",
"app.terminateProcesses.titleMany": "有进程正在运行",
"app.terminateProcesses.titleOne": "进程“{name}”正在运行",
"app.title.default": "OpenIDEA",
"app.unsaved.body": "{project} 中有 {count} 个文件尚未保存。关闭前是否保存?",
"app.unsaved.discard": "不保存",
+36
View File
@@ -1459,6 +1459,42 @@ export const useDebugRunSessions = create<{
Console: ComponentType<{ sessionId: number }> | null;
}>(() => ({ sessions: [], serviceSessions: [], Console: null }));
// ---------------------------------------------------------------------------
// Processes a closing window or project would terminate. Features that own
// child processes (Run/Debug sessions) publish them here, so the close guard can
// ask first (IDEA's "Terminate processes?").
export interface RunningProcess {
/** Configuration or session name shown in the prompt. */
name: string;
}
export interface RunningProcessesProvider {
id: string;
/** Processes this feature is running in the window right now. */
running(): RunningProcess[] | Promise<RunningProcess[]>;
}
const runningProcessesProviders = new Map<string, RunningProcessesProvider>();
export function registerRunningProcessesProvider(p: RunningProcessesProvider): () => void {
runningProcessesProviders.set(p.id, p);
return () => {
if (runningProcessesProviders.get(p.id) === p) runningProcessesProviders.delete(p.id);
};
}
/** Live processes of every provider, one entry per name. */
export async function runningProcesses(): Promise<RunningProcess[]> {
const all = (await Promise.all([...runningProcessesProviders.values()].map((p) => p.running()))).flat();
const seen = new Set<string>();
return all.filter((p) => {
if (!p.name || seen.has(p.name)) return false;
seen.add(p.name);
return true;
});
}
// ---------------------------------------------------------------------------
// "Get from Version Control" sources besides a plain URL (IDEA: GitHub, GitLab…),
// listed in the Clone Repository dialog's sidebar.
+35
View File
@@ -0,0 +1,35 @@
import { expect, it, vi } from "vitest";
import { registerRunningProcessesProvider, runningProcesses } from "./contributions";
it("collects live processes from every provider, one entry per name", async () => {
const first = vi.fn(async () => [{ name: "app" }, { name: "worker" }]);
const second = vi.fn(() => [{ name: "app" }, { name: "docs" }]);
const disposers = [
registerRunningProcessesProvider({ id: "test.first", running: first }),
registerRunningProcessesProvider({ id: "test.second", running: second }),
];
try {
expect(await runningProcesses()).toEqual([{ name: "app" }, { name: "worker" }, { name: "docs" }]);
expect(first).toHaveBeenCalledOnce();
expect(second).toHaveBeenCalledOnce();
} finally {
disposers.forEach((dispose) => dispose());
}
expect(await runningProcesses()).toEqual([]);
});
it("keeps a replacement provider registered when its predecessor is disposed", async () => {
const disposeOld = registerRunningProcessesProvider({ id: "test.replaced", running: async () => [] });
const disposeNew = registerRunningProcessesProvider({
id: "test.replaced",
running: async () => [{ name: "app" }],
});
try {
disposeOld();
expect(await runningProcesses()).toEqual([{ name: "app" }]);
} finally {
disposeOld();
disposeNew();
}
expect(await runningProcesses()).toEqual([]);
});