diff --git a/src/editor/core/view/pinnedTabsRow.test.tsx b/src/editor/core/view/pinnedTabsRow.test.tsx index 55bb45c0..4113fdff 100644 --- a/src/editor/core/view/pinnedTabsRow.test.tsx +++ b/src/editor/core/view/pinnedTabsRow.test.tsx @@ -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()), - editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }), + editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [], closeTab }), })); vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => })); vi.mock("./FindBar", () => ({ FindBar: () => null })); @@ -34,7 +35,10 @@ function seed(pinned: boolean[], tabs: Record) { 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(); expect(screen.getAllByRole("tablist", { name: "Editor tabs" })).toHaveLength(1); }); + + it("middle click closes an editor tab", () => { + seed([false, false, false], { placement: "top" }); + render(); + fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 }); + expect(closeTab).toHaveBeenCalledWith("p1", "t2"); + }); }); diff --git a/src/ui/Tabs.test.tsx b/src/ui/Tabs.test.tsx index bb8ca7b5..85854d67 100644 --- a/src/ui/Tabs.test.tsx +++ b/src/ui/Tabs.test.tsx @@ -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(); + 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(); + 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(); diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index cfd19e70..0a09cbaa 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -648,6 +648,14 @@ function HeaderRow({
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 ? ( <> diff --git a/src/workbench/toolwindows/header.test.tsx b/src/workbench/toolwindows/header.test.tsx new file mode 100644 index 00000000..143d1ca7 --- /dev/null +++ b/src/workbench/toolwindows/header.test.tsx @@ -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: () => ( +
+ {}} onClose={closeContentTab} /> +
+ ), + }), + ]; +}); + +afterEach(() => disposers.forEach((d) => d())); + +const header = (id: string) => document.querySelector(`[data-toolwindow="${id}"] header`)!; + +it("middle click on the header hides the tool window", () => { + showToolWindow("run"); + render(); + 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(); + 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(); + 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(); + fireEvent.mouseDown(screen.getByRole("tab", { name: "File" }), { button: 1 }); + expect(closeContentTab).toHaveBeenCalledWith("file"); + expect(isToolWindowVisible("problems")).toBe(true); +});