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;