From 347e3525d2dc438bfdd36888abe9e136e5234656 Mon Sep 17 00:00:00 2001 From: iGink Date: Sat, 31 Oct 2026 23:38:44 +0800 Subject: [PATCH] feat(editor): close editor tabs with Shift+click (#581) Add Shift+left-click as a way to close editor tabs. The tab strip gets a closeOnShiftClick option, enabled for the editor area only, so tool window tabs such as Run and Terminal still select on Shift+click. Keyboard activation with Shift held still selects, non-closable tabs still select, and Shift+click does not start a tab drag. Co-authored-by: iGink <5266594+iGink@users.noreply.github.com> --- src/editor/core/view/EditorArea.tsx | 1 + src/ui/Tabs.test.tsx | 36 +++++++++++++++++++++++++++++ src/ui/Tabs.tsx | 16 +++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index aa67dec7..fd7394dc 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -279,6 +279,7 @@ function PaneView({ paneId }: { paneId: string }) { onSelect={(id) => ctrl.activate(paneId, id)} onClose={(id) => void ctrl.closeTab(paneId, id)} onCloseOthers={(id) => void ctrl.closeOthers(paneId, id)} + closeOnShiftClick onDoubleClick={(id) => { // IDEA: a preview tab becomes a regular one; any other tab maximizes the editor. if (pane.tabs.find((t) => t.id === id)?.preview) ctrl.keepTab(paneId, id); diff --git a/src/ui/Tabs.test.tsx b/src/ui/Tabs.test.tsx index 701429bb..c81c642a 100644 --- a/src/ui/Tabs.test.tsx +++ b/src/ui/Tabs.test.tsx @@ -71,6 +71,42 @@ describe("Tabs interactions", () => { expect(onClose).toHaveBeenCalledWith("b"); }); + it("Shift+left-click closes an editor tab without selecting it", () => { + const onClose = vi.fn(); + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole("tab", { name: "B" }), { shiftKey: true, detail: 1 }); + expect(onClose).toHaveBeenCalledWith("b"); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("Shift+click only closes closable tabs", () => { + const onClose = vi.fn(); + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole("tab", { name: "C" }), { shiftKey: true, detail: 1 }); + expect(onClose).not.toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith("c"); + }); + + it("keeps Shift+click selection where the strip does not opt in", () => { + const onClose = vi.fn(); + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole("tab", { name: "B" }), { shiftKey: true, detail: 1 }); + expect(onClose).not.toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith("b"); + }); + + it("does not treat a keyboard click with Shift held as a close", () => { + const onClose = vi.fn(); + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole("tab", { name: "B" }), { shiftKey: true }); + expect(onClose).not.toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith("b"); + }); + it("lets a container close the selected tab when it is closable", () => { const handles: TabStripHandle[] = []; const register = (h: TabStripHandle) => (handles.push(h), () => void handles.splice(handles.indexOf(h), 1)); diff --git a/src/ui/Tabs.tsx b/src/ui/Tabs.tsx index ad4e4b12..078118fc 100644 --- a/src/ui/Tabs.tsx +++ b/src/ui/Tabs.tsx @@ -88,6 +88,8 @@ export interface TabsProps { onClose?: (id: string) => void; /** Alt+click on a close button (IDEA: close all other tabs). */ onCloseOthers?: (id: string) => void; + /** Shift+left-click a tab closes it (editor tabs; plain clicks still select). */ + closeOnShiftClick?: boolean; onDoubleClick?: (id: string) => void; onContextMenu?: (id: string, e: MouseEvent) => void; /** `editor`: 30px with separators (editor tabs); `compact`: 28px plain (popups, tool window sub-tabs). */ @@ -114,6 +116,7 @@ export function Tabs({ onSelect, onClose, onCloseOthers, + closeOnShiftClick, onDoubleClick, onContextMenu, variant = "editor", @@ -287,7 +290,8 @@ export function Tabs({ onPointerDown={ dnd ? (e) => { - if ((e.target as Element).closest("[data-tab-close]")) return; + if ((closeOnShiftClick && e.shiftKey) || (e.target as Element).closest("[data-tab-close]")) + return; startPointerDrag( e, e.currentTarget, @@ -336,7 +340,15 @@ export function Tabs({ tabIndex={selected || (!activeId && index === 0) ? 0 : -1} className={styles.tabButton} style={item.color ? { color: item.color } : undefined} - onClick={() => onSelect(item.id)} + onClick={(e) => { + // Shift+click is mouse-only: a keyboard Enter with Shift held still selects. + if (closeOnShiftClick && e.shiftKey && e.detail > 0 && closable) { + e.preventDefault(); + onClose!(item.id); + return; + } + onSelect(item.id); + }} onDoubleClick={onDoubleClick ? () => onDoubleClick(item.id) : undefined} onKeyDown={(e) => onKeyDown(e, index, item)} {...tooltipAttrs(item.tooltip)}