feat(ui): close tabs and panel headers with middle mouse button (#1613)

A middle click on a tool window header hides the window, as its Hide button does; presses on the header's tabs and buttons keep their own behaviour. Editor tabs already closed on a middle click, except when Settings › Editor Tabs hides the close button: that marked the tabs as not closable, so a middle click (and Delete) did nothing. A tab item now hides only its close button and still closes.

Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com>
This commit is contained in:
Vadim Lazuk
2026-11-06 02:55:26 +08:00
committed by GitHub
co-authored by Inf1mum
parent 8689271e7c
commit 653f2ff276
4 changed files with 112 additions and 3 deletions
+14 -3
View File
@@ -1,13 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { useProject } from "@/platform/project";
import { useTabs } from "../controller";
import type { Tab } from "../model/tabs";
import { EditorAreaView } from "./EditorArea";
const { closeTab } = vi.hoisted(() => ({ closeTab: vi.fn() }));
vi.mock("../controller", async (original) => ({
...(await original<object>()),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [], closeTab }),
}));
vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => <input aria-label="Source" /> }));
vi.mock("./FindBar", () => ({ FindBar: () => null }));
@@ -34,7 +35,10 @@ function seed(pinned: boolean[], tabs: Record<string, unknown>) {
useProject.setState({ settings: { editor: { tabs } } as never });
}
beforeEach(() => cleanup());
beforeEach(() => {
cleanup();
closeTab.mockClear();
});
afterEach(() => {
cleanup();
useTabs.setState(originalTabs, true);
@@ -69,4 +73,11 @@ describe("pinned tabs in a separate row", () => {
render(<EditorAreaView />);
expect(screen.getAllByRole("tablist", { name: "Editor tabs" })).toHaveLength(1);
});
it("middle click closes an editor tab", () => {
seed([false, false, false], { placement: "top" });
render(<EditorAreaView />);
fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 });
expect(closeTab).toHaveBeenCalledWith("p1", "t2");
});
});
+18
View File
@@ -117,6 +117,24 @@ describe("Tabs interactions", () => {
expect(onSelect).toHaveBeenCalledWith("c");
});
it("middle click closes a tab without selecting it", () => {
const onClose = vi.fn();
const onSelect = vi.fn();
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} />);
fireEvent.mouseDown(screen.getByRole("tab", { name: "B" }), { button: 1 });
expect(onClose).toHaveBeenCalledWith("b");
expect(onSelect).not.toHaveBeenCalled();
});
it("middle click ignores tabs that cannot close", () => {
const onClose = vi.fn();
const onSelect = vi.fn();
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} />);
fireEvent.mouseDown(screen.getByRole("tab", { name: "C" }), { button: 1 });
expect(onClose).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
it("keeps Shift+click selection where the strip does not opt in", () => {
const onClose = vi.fn();
const onSelect = vi.fn();
@@ -648,6 +648,14 @@ function HeaderRow({
<header
className={clsx(styles.header, tw.headerContent && styles.headerTabbed)}
onDoubleClick={(e) => e.target === e.currentTarget && toggleMaximizeToolWindow(tw.id)}
onMouseDown={(e) => {
// A middle click on the header hides the window (like the Hide button).
// Not a press on the header's tabs or buttons, which close and act
// themselves: their event would otherwise hide the whole window too.
if (e.button !== 1 || (e.target as Element).closest('button, a, [role="tablist"], [role="separator"]')) return;
e.preventDefault();
hideToolWindow(tw.id);
}}
>
{!showTitle ? null : titleItems.length > 0 ? (
<>
+72
View File
@@ -0,0 +1,72 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { Tabs } from "@/ui";
import { registerToolWindow } from "../contributions";
import { isToolWindowVisible, resetToolWindowLayout, showToolWindow } from "./store";
import { ToolWindowLayout } from "./ToolWindows";
const Empty = () => null;
const closeContentTab = vi.fn();
let disposers: Array<() => void> = [];
beforeEach(() => {
resetToolWindowLayout();
closeContentTab.mockClear();
disposers = [
registerToolWindow({
id: "run",
title: "Run",
icon: "terminal",
anchor: "bottom",
requiresProject: false,
component: Empty,
}),
registerToolWindow({
id: "problems",
title: "Problems",
icon: "file",
anchor: "bottom",
requiresProject: false,
component: Empty,
headerContent: () => (
<div role="tablist" aria-label="Views">
<Tabs items={[{ id: "file", label: "File" }]} activeId="file" onSelect={() => {}} onClose={closeContentTab} />
</div>
),
}),
];
});
afterEach(() => disposers.forEach((d) => d()));
const header = (id: string) => document.querySelector<HTMLElement>(`[data-toolwindow="${id}"] header`)!;
it("middle click on the header hides the tool window", () => {
showToolWindow("run");
render(<ToolWindowLayout editor={null} />);
expect(isToolWindowVisible("run")).toBe(true);
fireEvent.mouseDown(header("run"), { button: 1 });
expect(isToolWindowVisible("run")).toBe(false);
});
it("middle click on the header title hides the tool window", () => {
showToolWindow("run");
render(<ToolWindowLayout editor={null} />);
fireEvent.mouseDown(screen.getByText("Run"), { button: 1 });
expect(isToolWindowVisible("run")).toBe(false);
});
it("middle click on a header button leaves the tool window open", () => {
showToolWindow("run");
render(<ToolWindowLayout editor={null} />);
fireEvent.mouseDown(screen.getByRole("button", { name: "Hide" }), { button: 1 });
expect(isToolWindowVisible("run")).toBe(true);
});
it("middle click on a header content tab closes the tab, not the window", () => {
showToolWindow("problems");
render(<ToolWindowLayout editor={null} />);
fireEvent.mouseDown(screen.getByRole("tab", { name: "File" }), { button: 1 });
expect(closeContentTab).toHaveBeenCalledWith("file");
expect(isToolWindowVisible("problems")).toBe(true);
});