From 9da13cc075b79c7decddbf774a8c45093e13ca0c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:50:51 +0800 Subject: [PATCH] fix: Close the merge editor on Apply; confirm before terminating a running Run tab Like IDEA, applying a merge result closes its tab, and closing a Run tab whose process still runs asks before terminating it (with a Settings toggle and "Do not ask again"). --- crates/ide-core/src/workflow_settings.rs | 4 +++- src/bindings.ts | 2 ++ src/editor/core/controller.test.ts | 12 ++++++++++ src/editor/core/controller.ts | 4 ++++ src/editor/core/index.ts | 1 + src/features/run/RunView.tsx | 4 ++-- src/features/run/dialogs.tsx | 19 ++++++++++++++++ src/features/run/session.test.tsx | 27 ++++++++++++++++++++--- src/features/run/store.ts | 12 +++++++++- src/features/settings/pages/execution.tsx | 1 + src/features/vcs/MergeEditor.tsx | 2 ++ src/platform/workflowSettings.ts | 4 +++- src/workbench/contributions.ts | 2 ++ 13 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index c574b84fb..03053995c 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -212,6 +212,8 @@ impl Default for PythonDebuggerSettings { #[serde(default, rename_all = "camelCase")] pub struct RunSettings { pub confirm_rerun: bool, + /// Ask before closing a Run tab whose process is still running. + pub confirm_terminate: bool, pub show_on_start: bool, /// Grace period between interrupt and kill when stopping a process. pub termination_timeout_ms: u32, @@ -219,7 +221,7 @@ pub struct RunSettings { impl Default for RunSettings { fn default() -> Self { - Self { confirm_rerun: true, show_on_start: true, termination_timeout_ms: 3000 } + Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 } } } diff --git a/src/bindings.ts b/src/bindings.ts index 7c18bdfbc..23b2b8c13 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3522,6 +3522,8 @@ export type RunInfo = { /** Run/Debug configuration behaviour. */ export type RunSettings = { confirmRerun?: boolean, + /** Ask before closing a Run tab whose process is still running. */ + confirmTerminate?: boolean, showOnStart?: boolean, /** Grace period between interrupt and kill when stopping a process. */ terminationTimeoutMs?: number, diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 7e2e67999..33e7caae7 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -65,6 +65,18 @@ describe("EditorController", () => { expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts", "/p/b.ts"]); }); + it("closes a tab by its input key", async () => { + ctrl = new EditorController(createMemoryBackend()); + ctrl.start(); + await ctrl.openFile("/p/a.ts", { focus: false }); + await ctrl.openFile("/p/b.ts", { focus: false }); + const key = Object.values(useTabs.getState().panes) + .flatMap((p) => p.tabs) + .find((t) => t.input.path === "/p/b.ts")!.input.key; + ctrl.closeInput(key); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + }); + it("builds IDEA's editor tab menu", async () => { useProject.setState({ project: { name: "p", root: "/p", isGit: true, settings: {} } as never, settings: {} as never }); const ids = ["navigate.selectIn", "project.revealInFinder", "vcs.localHistory", "vcs.commit", "project.copyAbsolutePath", "project.rename", "file.settings"]; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 60b1a1f65..ea49d27bc 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -1003,6 +1003,10 @@ export class EditorController implements SlotHost { }); } + closeInput(key: string) { + this.apply((s) => closeKey(s, key)); + } + activePath(): string | null { return this.activeSlot?.model.path ?? this.activeTabOf()?.input.path ?? null; } diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index 1e4cd9715..85dc34797 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -40,6 +40,7 @@ export function activate(): void | (() => void) { focus: () => ctrl.focus(), openPaths: () => ctrl.openPaths(), closePath: (path) => ctrl.closePath(path), + closeInput: (key) => ctrl.closeInput(key), }); _provideDocumentAttacher((path) => attachDocument(ctrl.manager, path)); _provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor)); diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 3b017b3d8..ed0a1d508 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -9,7 +9,7 @@ import { runDirectory } from "./navigate"; import { acceptsInput, clearConsole, - closeSession, + closeSessionTab, closeStdin, failedTests, rerunFailed, @@ -217,7 +217,7 @@ export function RunTabs() { items={items} activeId={"session" in current ? current.session.id : current.trace.id} onSelect={(id) => useRun.setState({ activeSession: id })} - onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : closeSession(id))} + onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : void closeSessionTab(id))} panelIdPrefix="openidea-run" /> ); diff --git a/src/features/run/dialogs.tsx b/src/features/run/dialogs.tsx index 2f4aa98da..3bae27b36 100644 --- a/src/features/run/dialogs.tsx +++ b/src/features/run/dialogs.tsx @@ -36,6 +36,25 @@ export async function confirmRerun(name: string, instances = 1): Promise { + const answer = await confirmChecked({ + title: `Process '${name}' Is Running`, + message: `Do you want to terminate the process '${name}'?`, + ok: "Terminate", + checkbox: "Do not ask again", + }); + if (answer?.checked && isTauri()) { + try { + await call(commands.settingsSet("global", "run.confirmTerminate", "false")); + await loadSettings(); + } catch { + /* keep asking */ + } + } + return answer !== null; +} + /** Asks for the value of `$Prompt$`; null when cancelled. */ export function promptValue(title: string): Promise { return new Promise((resolve) => useRunDialog.setState({ pending: { title, resolve } })); diff --git a/src/features/run/session.test.tsx b/src/features/run/session.test.tsx index c4031cc4a..5d1d4a787 100644 --- a/src/features/run/session.test.tsx +++ b/src/features/run/session.test.tsx @@ -13,10 +13,11 @@ import { ConsoleModel } from "./console"; import { ConsoleView, type ConsoleInput } from "./ConsoleView"; import { buildRows, mnemonics } from "./ConfigChooser"; import { activate } from "./index"; -import { resetRuns, startRun, useRun, type RunSession } from "./store"; +import { closeSessionTab, resetRuns, startRun, useRun, type RunSession } from "./store"; const confirm = vi.hoisted(() => vi.fn(async () => true)); -vi.mock("./dialogs", () => ({ confirmRerun: confirm, promptValue: vi.fn(), RunDialogsOverlay: () => null })); +const confirmTerminate = vi.hoisted(() => vi.fn(async () => true)); +vi.mock("./dialogs", () => ({ confirmRerun: confirm, confirmTerminate, promptValue: vi.fn(), RunDialogsOverlay: () => null })); const config = (id: string, extra: Partial = {}): RunConfig => ({ id, @@ -51,7 +52,7 @@ beforeEach(() => { provideNotificationService({ notify: () => undefined }); }); -describe("single-instance runs (IDEA restartRunProfile)", () => { +describe("single-instance runs", () => { it("Run on a running configuration asks, then restarts it in its tab", async () => { const app = config("app"); useRun.setState({ sessions: [running("run1", app)] }); @@ -85,6 +86,26 @@ describe("single-instance runs (IDEA restartRunProfile)", () => { }); }); +describe("closing a Run tab", () => { + it("asks before terminating a running process and keeps the tab when cancelled", async () => { + const app = config("app"); + useRun.setState({ sessions: [running("run1", app)] }); + confirmTerminate.mockResolvedValueOnce(false); + expect(await closeSessionTab("run1")).toBe(false); + expect(confirmTerminate).toHaveBeenCalledWith("app"); + expect(useRun.getState().sessions).toHaveLength(1); + expect(await closeSessionTab("run1")).toBe(true); + expect(useRun.getState().sessions).toHaveLength(0); + }); + + it("closes a finished session without asking", async () => { + confirmTerminate.mockClear(); + useRun.setState({ sessions: [{ ...running("run1", config("app")), status: "finished", exitCode: 0 }] }); + expect(await closeSessionTab("run1")).toBe(true); + expect(confirmTerminate).not.toHaveBeenCalled(); + }); +}); + describe("Stop", () => { let dispose: () => void; beforeEach(() => { diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 0126dfd8e..62b468ffe 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -13,7 +13,7 @@ import { workflowSettings } from "@/platform/workflowSettings"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; import { ConsoleModel } from "./console"; import { jvmFrameTarget } from "./links"; -import { confirmRerun } from "./dialogs"; +import { confirmRerun, confirmTerminate } from "./dialogs"; import { createTestParser, frameworkFor, TestResults, withTestReporting, type TestEvent, type TestOutputParser } from "./testing"; import { recordTestStates } from "./testing/states"; import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun"; @@ -700,6 +700,16 @@ export function clearConsole(sessionId: string) { bump(sessionId); } +/** Closing a Run tab: a process still running is terminated once the user agrees, as IDEA asks. */ +export async function closeSessionTab(sessionId: string): Promise { + const session = getSession(sessionId); + if (!session) return false; + const running = session.status === "running" || session.status === "starting"; + if (running && workflowSettings().run.confirmTerminate && !(await confirmTerminate(session.config.name))) return false; + closeSession(sessionId); + return true; +} + export function closeSession(sessionId: string) { const session = getSession(sessionId); if (!session) return; diff --git a/src/features/settings/pages/execution.tsx b/src/features/settings/pages/execution.tsx index 1a3fec907..75c20f7ce 100644 --- a/src/features/settings/pages/execution.tsx +++ b/src/features/settings/pages/execution.tsx @@ -234,6 +234,7 @@ export const EXECUTION_PAGES: PageSpec[] = [ { options: [ { kind: "toggle", key: "run.confirmRerun", label: "Confirm rerun with process termination" }, + { kind: "toggle", key: "run.confirmTerminate", label: "Confirm closing a tab with a running process" }, { kind: "toggle", key: "run.showOnStart", diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4b8e64d55..560a6a6e9 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -269,6 +269,8 @@ export function MergeEditor({ input }: { input: EditorInput }) { if (ok !== undefined) { setResolved(true); await refreshNow(); + // Like IDEA, applying the result closes the merge editor. + editorService().closeInput?.(input.key); } }; diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 0c0760c0a..e606396c3 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -73,6 +73,7 @@ export interface DebuggerSettingsValid { export interface RunSettingsValid { confirmRerun: boolean; + confirmTerminate: boolean; showOnStart: boolean; terminationTimeoutMs: number; } @@ -211,7 +212,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { skipLibraryCode: true, python: { attachSubprocess: true, gevent: false }, }, - run: { confirmRerun: true, showOnStart: true, terminationTimeoutMs: 3000 }, + run: { confirmRerun: true, confirmTerminate: true, showOnStart: true, terminationTimeoutMs: 3000 }, terminal: { startDirectory: "", env: {}, @@ -341,6 +342,7 @@ export function validateRun(raw: unknown): RunSettingsValid { const v = obj(raw); return { confirmRerun: bool(v.confirmRerun, d.confirmRerun), + confirmTerminate: bool(v.confirmTerminate, d.confirmTerminate), showOnStart: bool(v.showOnStart, d.showOnStart), terminationTimeoutMs: num(v.terminationTimeoutMs, d.terminationTimeoutMs, WORKFLOW_LIMITS.terminationTimeoutMs, true), }; diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 21028fe91..91f97b1c6 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -311,6 +311,8 @@ export interface EditorService { openPaths?(): string[]; /** Closes every tab showing `path`. */ closePath?(path: string): void; + /** Closes the tab of the input with this `key` (e.g. a merge editor once applied). */ + closeInput?(key: string): void; } export interface ToolWindowService {