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").
This commit is contained in:
l0ng-ai committed 2026-10-25 09:34:36 +08:00
1 parent 7df5747418
commit 9da13cc075
13 files changed
+86 -8

No files matched your search

+3 -1
View File
@@ -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 }
}
}
+2
View File
@@ -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,
+12
View File
@@ -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"];
+4
View File
@@ -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;
}
+1
View File
@@ -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));
+2 -2
View File
@@ -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"
/>
);
+19
View File
@@ -36,6 +36,25 @@ export async function confirmRerun(name: string, instances = 1): Promise<boolean
return answer !== null;
}
/** IDEA's question before closing a Run tab whose process still runs. Resolves true for Terminate. */
export async function confirmTerminate(name: string): Promise<boolean> {
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<string | null> {
return new Promise((resolve) => useRunDialog.setState({ pending: { title, resolve } }));
+24 -3
View File
@@ -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> = {}): 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(() => {
+11 -1
View File
@@ -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<boolean> {
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;
@@ -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",
+2
View File
@@ -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);
}
};
+3 -1
View File
@@ -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),
};
+2
View File
@@ -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 {