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]>
Цей коміт міститься в:
ppxbтаppxb authored and GitHub committed 2026-11-05 12:34:35 +08:00
1 джерело 3e88fa1f39
коміт 43af99d3ec
7 files changed
+223 -7

No files matched your search

+60 -1
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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(),