mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
3e88fa1f39
commit
43af99d3ec
7 files changed
+223
-7
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "不保存",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user