fix(terminal): hide the tool window or refocus when a session ends (#3290)

With "Close session when it ends" on, a session that exited on its own closed its tab but left an empty Terminal tool window open, and the pane menu's Close Session did the same. Both paths now follow the tab close button: the tool window hides when no tabs remain; otherwise the active session takes the caret, which for a shell exiting on its own happens only when a terminal had the caret, so a background exit doesn't pull focus out of the editor.

Co-authored-by: zhzhsx <1379025+zhzhsx@users.noreply.github.com>
This commit is contained in:
zhzhsx
2026-11-14 12:53:37 +08:00
committed by GitHub
co-authored by zhzhsx
parent ae542b3044
commit 08333bcff8
3 changed files with 110 additions and 2 deletions
+3 -1
View File
@@ -128,7 +128,9 @@ function paneMenu(e: MouseEvent, key: string) {
icon: "close",
run: () =>
void requestCloseSession(key).then((closed) => {
if (closed) requestAnimationFrame(() => activeSession()?.focus());
if (!closed) return;
if (useTerminals.getState().tabs.length === 0) toolWindowService().hide("terminal");
else requestAnimationFrame(() => activeSession()?.focus());
}),
},
];
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { provideToolWindowService, type ToolWindowService } from "@/workbench/contributions";
import { closeAll, getSession, newTab, useTerminals } from "./store";
const { focused } = vi.hoisted(() => ({ focused: [] as string[] }));
vi.mock("./session", () => ({
TerminalSession: class {
key: string;
state = { cwd: "/repo" };
host = document.createElement("div");
private readonly onExit: ((key: string) => void) | undefined;
constructor(opts: { key: string; onExit?: (key: string) => void }) {
this.key = opts.key;
this.onExit = opts.onExit;
}
exited() {
this.onExit?.(this.key);
}
focus() {
focused.push(this.key);
}
dispose() {}
},
}));
const hide = vi.fn();
beforeEach(() => {
hide.mockClear();
focused.length = 0;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
provideToolWindowService({ hide } as unknown as ToolWindowService);
closeAll();
});
afterEach(() => vi.unstubAllGlobals());
function shellExit(key: string) {
(getSession(key) as unknown as { exited(): void }).exited();
}
describe("shell exit closes the last tab", () => {
it("hides the Terminal tool window", () => {
const tab = newTab("/repo");
shellExit(tab.session);
expect(useTerminals.getState().tabs).toHaveLength(0);
expect(hide).toHaveBeenCalledWith("terminal");
});
it("keeps it open while another session is running", () => {
const a = newTab("/repo");
newTab("/repo");
shellExit(a.session);
expect(hide).not.toHaveBeenCalled();
});
});
describe("shell exit with sessions left", () => {
/** Puts the caret in `key`'s terminal, as typing into it would. */
function typeInto(key: string) {
const host = (getSession(key) as unknown as { host: HTMLDivElement }).host;
const input = document.createElement("textarea");
host.appendChild(input);
document.body.appendChild(host);
input.focus();
}
it("focuses the session that takes over the active tab", () => {
const a = newTab("/repo");
const b = newTab("/repo");
typeInto(b.session);
shellExit(b.session);
expect(useTerminals.getState().activeTab).toBe(a.id);
expect(focused).toContain(a.session);
});
it("refocuses the active session when the terminal has the caret and a background shell exits", () => {
const a = newTab("/repo");
const b = newTab("/repo");
typeInto(b.session);
shellExit(a.session);
expect(focused).toContain(b.session);
});
it("leaves the caret where it is when the terminal does not have it", () => {
const a = newTab("/repo");
newTab("/repo");
const editor = document.createElement("input");
document.body.appendChild(editor);
editor.focus();
shellExit(a.session);
expect(focused).toEqual([]);
expect(document.activeElement).toBe(editor);
});
});
+10 -1
View File
@@ -8,6 +8,7 @@ import { setContext } from "@/platform/context";
import { t } from "@/platform/i18n";
import { basename } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { toolWindowService } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { readStored, writeStored, type StoredTerminals } from "./persist";
import { TerminalSession, type SessionOptions, type SessionSnapshot } from "./session";
@@ -96,7 +97,15 @@ function createSession(cwd: string, command?: SessionOptions["command"], fallbac
},
onExit: (k) => {
const tab = tabOfSession(k);
if (tab && workflowSettings().terminal.closeOnExit) closeTab(tab.id);
if (!tab || !workflowSettings().terminal.closeOnExit) return;
// A session ending on its own only takes the caret back when the terminal had it: a
// background shell exiting must not pull focus out of the editor.
const focus = document.activeElement;
const hadFocus = !!focus && [...sessions.values()].some((s) => s.host?.contains(focus));
closeTab(tab.id);
if (useTerminals.getState().tabs.length === 0) toolWindowService().hide("terminal");
// Wait for the surviving pane to re-mount before giving xterm the caret.
else if (hadFocus) requestAnimationFrame(() => activeSession()?.focus());
},
});
sessions.set(key, session);