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
This commit is contained in:
l0ng-ai
2026-11-12 03:08:31 +08:00
committed by GitHub
parent 6f62917730
commit e5efcfbf60
7 changed files with 64 additions and 7 deletions
+6 -1
View File
@@ -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);
},
+4
View File
@@ -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;
@@ -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;
+16
View File
@@ -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) => (
<TabStripRegistry.Provider value={register}>
<Tabs items={items} activeId="b" onSelect={() => {}} onTogglePin={pin} />
</TabStripRegistry.Provider>
);
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(
+13 -3
View File
@@ -92,9 +92,10 @@ function previewOrder(ids: readonly string[], id: string, index: number): Map<st
return new Map(order.map((it, i) => [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<string | null>(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],
);
+15 -3
View File
@@ -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();
});
});
+6
View File
@@ -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;