From e5efcfbf60b76495eb68c96895ad7da00f8abef1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:08:31 +0800 Subject: [PATCH] feat(toolwindows): Pin / Unpin Tab pins a tool window's selected tab (#2807) Pin / Unpin Tab only acted on editor tabs and was disabled while a tool window had focus, so a shortcut couldn't pin a Find or Usages tab. With a tool window focused it now toggles the pin of that window's selected content tab, through the same content tab registry Close Tool Window Tab uses; Find and Usages tabs support it. Closes #2786 --- src/editor/core/actions.ts | 7 ++++++- src/features/code-insight/UsagesView.tsx | 4 ++++ src/features/search/find/FindToolWindow.tsx | 4 ++++ src/ui/Tabs.test.tsx | 16 ++++++++++++++++ src/ui/Tabs.tsx | 16 +++++++++++++--- src/workbench/toolwindows/content.test.ts | 18 +++++++++++++++--- src/workbench/toolwindows/content.ts | 6 ++++++ 7 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index fbd0973b8..181f1011b 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -75,6 +75,8 @@ import { useProject } from "@/platform/project"; import { call, commands } from "@/platform/ipc"; import { editorPrefs } from "@/platform/editorSettings"; import { notifications } from "@/workbench/contributions"; +import { togglePinSelectedContent } from "@/workbench/toolwindows/content"; +import { useToolWindowLayout } from "@/workbench/toolwindows/store"; import { toggleReadOnly } from "./fileAttributes"; import { openNavBarPopup } from "./view/NavBarPopup"; import { @@ -1057,8 +1059,11 @@ export function registerEditorActions(): () => void { title: "Pin / Unpin Tab", category: "Window", icon: "pin", - when: TABS_WHEN, + // IDEA: in a focused tool window, its selected content tab (Find, Usages). + when: "editorTabsOpen || toolWindowFocus", run: () => { + const tw = useToolWindowLayout.getState().focused; + if (tw) return void togglePinSelectedContent(tw); const t = activePaneTab(); if (t) ctrl.togglePin(t.paneId, t.tabId); }, diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx index e509ec0f3..a8fb62736 100644 --- a/src/features/code-insight/UsagesView.tsx +++ b/src/features/code-insight/UsagesView.tsx @@ -90,6 +90,10 @@ export function UsagesTabs() { activeId={String(search.id)} onSelect={(id) => useUsages.setState({ active: Number(id) })} onClose={(id) => closeUsageTab(Number(id))} + onTogglePin={(id) => { + const t = tabs.find((x) => x.id === Number(id)); + if (t) pinUsageTab(t.id, !t.pinned); + }} onContextMenu={(id, e) => { const t = tabs.find((x) => x.id === Number(id)); if (!t) return; diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 6b83cab93..ab44db78f 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -180,6 +180,10 @@ export function FindTabs() { activeId={String(tab.id)} onSelect={(id) => useFindWindow.setState({ active: Number(id) })} onClose={(id) => closeFindTab(Number(id))} + onTogglePin={(id) => { + const t = tabs.find((x) => x.id === Number(id)); + if (t) pinFindTab(t.id, !t.pinned); + }} onContextMenu={(id, e) => { const t = tabs.find((x) => x.id === Number(id)); if (!t) return; diff --git a/src/ui/Tabs.test.tsx b/src/ui/Tabs.test.tsx index ca76b2d44..260599bd5 100644 --- a/src/ui/Tabs.test.tsx +++ b/src/ui/Tabs.test.tsx @@ -171,6 +171,22 @@ describe("Tabs interactions", () => { expect(handles).toHaveLength(0); }); + it("lets a container pin the selected tab when the strip supports pinning", () => { + const handles: TabStripHandle[] = []; + const register = (h: TabStripHandle) => (handles.push(h), () => void handles.splice(handles.indexOf(h), 1)); + const onTogglePin = vi.fn(); + const ui = (pin?: (id: string) => void) => ( + + {}} onTogglePin={pin} /> + + ); + const { rerender } = render(ui(onTogglePin)); + expect(handles[0]!.togglePinSelected()).toBe(true); + expect(onTogglePin).toHaveBeenCalledWith("b"); + rerender(ui()); + expect(handles[0]!.togglePinSelected()).toBe(false); + }); + it("a vertical strip moves with ↑/↓, shows actions above the tabs and has no overflow dropdown", () => { const onSelect = vi.fn(); render( diff --git a/src/ui/Tabs.tsx b/src/ui/Tabs.tsx index 2ad4afdbd..4e3e3f4b2 100644 --- a/src/ui/Tabs.tsx +++ b/src/ui/Tabs.tsx @@ -92,9 +92,10 @@ function previewOrder(ids: readonly string[], id: string, index: number): Map [it, i])); } -/** A tab strip as seen from its container: close the selected tab if it can be closed. */ +/** A tab strip as seen from its container: close or pin the selected tab when it can be. */ export interface TabStripHandle { closeSelected(): boolean; + togglePinSelected(): boolean; } /** @@ -110,6 +111,8 @@ export interface TabsProps { onClose?: (id: string) => void; /** Alt+click on a close button (IDEA: close all other tabs). */ onCloseOthers?: (id: string) => void; + /** Pin / Unpin Tab on the selected tab, from the container (a tool window's content tabs). */ + onTogglePin?: (id: string) => void; /** Shift+left-click a tab closes it (editor tabs; plain clicks still select). */ closeOnShiftClick?: boolean; onDoubleClick?: (id: string) => void; @@ -138,6 +141,7 @@ export function Tabs({ onSelect, onClose, onCloseOthers, + onTogglePin, closeOnShiftClick, onDoubleClick, onContextMenu, @@ -162,9 +166,9 @@ export function Tabs({ const [dragging, setDragging] = useState(null); const register = useContext(TabStripRegistry); - const latest = useRef({ items, activeId, onClose }); + const latest = useRef({ items, activeId, onClose, onTogglePin }); useLayoutEffect(() => { - latest.current = { items, activeId, onClose }; + latest.current = { items, activeId, onClose, onTogglePin }; }); useEffect( () => @@ -176,6 +180,12 @@ export function Tabs({ onClose(item.id); return true; }, + togglePinSelected: () => { + const { items, activeId, onTogglePin } = latest.current; + if (!onTogglePin || !items.some((it) => it.id === activeId)) return false; + onTogglePin(activeId!); + return true; + }, }), [register], ); diff --git a/src/workbench/toolwindows/content.test.ts b/src/workbench/toolwindows/content.test.ts index dc1d1e0cd..32c2c9af7 100644 --- a/src/workbench/toolwindows/content.test.ts +++ b/src/workbench/toolwindows/content.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import { closeSelectedContent, contentTabRegistry } from "./content"; +import { closeSelectedContent, contentTabRegistry, togglePinSelectedContent } from "./content"; describe("tool window content tabs", () => { it("closes the selected tab of the first strip that can, else reports false", () => { const closed: string[] = []; const register = contentTabRegistry("run"); expect(contentTabRegistry("run")).toBe(register); - const disposeA = register({ closeSelected: () => false }); - const disposeB = register({ closeSelected: () => (closed.push("b"), true) }); + const disposeA = register({ closeSelected: () => false, togglePinSelected: () => false }); + const disposeB = register({ closeSelected: () => (closed.push("b"), true), togglePinSelected: () => false }); expect(closeSelectedContent("run")).toBe(true); expect(closed).toEqual(["b"]); disposeB(); @@ -15,4 +15,16 @@ describe("tool window content tabs", () => { disposeA(); expect(closeSelectedContent("other")).toBe(false); }); + + it("pins the selected tab of the first strip that can, else reports false", () => { + const pinned: string[] = []; + const register = contentTabRegistry("find"); + const disposeA = register({ closeSelected: () => false, togglePinSelected: () => false }); + const disposeB = register({ closeSelected: () => false, togglePinSelected: () => (pinned.push("b"), true) }); + expect(togglePinSelectedContent("find")).toBe(true); + expect(pinned).toEqual(["b"]); + disposeB(); + expect(togglePinSelectedContent("find")).toBe(false); + disposeA(); + }); }); diff --git a/src/workbench/toolwindows/content.ts b/src/workbench/toolwindows/content.ts index 04fa219a2..fd9a499be 100644 --- a/src/workbench/toolwindows/content.ts +++ b/src/workbench/toolwindows/content.ts @@ -19,6 +19,12 @@ export function contentTabRegistry(id: string): (handle: TabStripHandle) => () = return fn; } +/** Pins or unpins the selected content tab of tool window `id`; false when it has none that can be pinned. */ +export function togglePinSelectedContent(id: string): boolean { + for (const strip of strips.get(id) ?? []) if (strip.togglePinSelected()) return true; + return false; +} + /** Closes the selected content tab of tool window `id`; false when it has none that can be closed. */ export function closeSelectedContent(id: string): boolean { for (const strip of strips.get(id) ?? []) if (strip.closeSelected()) return true;