From 43af99d3ecc9eef06cec8300bd87e1a4c96698aa Mon Sep 17 00:00:00 2001 From: ppxb <317842449@qq.com> Date: Thu, 5 Nov 2026 12:34:35 +0800 Subject: [PATCH] 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 <17081240+ppxb@users.noreply.github.com> --- src/features/app/__tests__/closeGuard.test.ts | 61 ++++++++++++++- src/features/app/closeGuard.ts | 77 +++++++++++++++++-- src/features/run/index.ts | 9 +++ src/platform/locales/en.json | 6 ++ src/platform/locales/zh.json | 6 ++ src/workbench/contributions.ts | 36 +++++++++ src/workbench/runningProcesses.test.ts | 35 +++++++++ 7 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 src/workbench/runningProcesses.test.ts diff --git a/src/features/app/__tests__/closeGuard.test.ts b/src/features/app/__tests__/closeGuard.test.ts index 9774d8a4e..c703c1b9c 100644 --- a/src/features/app/__tests__/closeGuard.test.ts +++ b/src/features/app/__tests__/closeGuard.test.ts @@ -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"); +}); diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index c08195de0..dff728230 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -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; disableConfirmExit: () => Promise; + disableConfirmTerminate: () => Promise; + /** Running Run/Debug process names in this window. */ + runningProcesses: () => Promise; onSaveFailed: (e: unknown) => void; /** Stops custom document edits/execution before reading their final dirty state. */ prepareClose?: () => Promise; @@ -54,6 +67,24 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise {}); } + 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 { + return [...new Set(await deps.runningProcesses().catch(() => []))].filter(Boolean); +} + let closing = false; async function onCloseRequested(deps: CloseDeps): Promise { @@ -120,11 +161,14 @@ async function onCloseRequested(deps: CloseDeps): Promise { 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 { 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 { 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; diff --git a/src/features/run/index.ts b/src/features/run/index.ts index a663c368c..10ffa6113 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -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(), diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 05325107b..18b229171 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -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", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 3dbaef005..6cf81d316 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -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": "不保存", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 7b7c2a633..8f09062c8 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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; +} + +const runningProcessesProviders = new Map(); + +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 { + const all = (await Promise.all([...runningProcessesProviders.values()].map((p) => p.running()))).flat(); + const seen = new Set(); + 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. diff --git a/src/workbench/runningProcesses.test.ts b/src/workbench/runningProcesses.test.ts new file mode 100644 index 000000000..8a53c7b22 --- /dev/null +++ b/src/workbench/runningProcesses.test.ts @@ -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([]); +});