feat(terminal): split the tool window into tab groups, as IDEA (#864)

The Terminal could only split panes inside one tab. Split Right / Split Down now create tab groups, each with its own tabs, + and ▾; tabs can be moved between groups by dragging or Split and Move Right/Down, Unsplit merges a group back, and closing a group's last tab focuses the neighbouring group. Split actions are registered as actions and appear in the tab context menu.

Fixes #774

Co-authored-by: StarryKira <59300016+StarryKira@users.noreply.github.com>
This commit is contained in:
时雨遥
2026-11-03 23:20:11 +08:00
committed by GitHub
parent 9100187fc8
commit f5e12238bb
10 changed files with 726 additions and 249 deletions
+40
View File
@@ -31,6 +31,46 @@ test("⌥F12 opens the Terminal tool window and toggles it closed", async ({ wor
await expect(terminal).toBeHidden();
});
test("Split Right gives the Terminal a tab group with its own header; Unsplit merges it back", async ({
workbench: page,
}) => {
const terminal = page.getByRole("region", { name: "Terminal" });
const strips = terminal.getByRole("tablist", { name: "Terminal sessions" });
await page.keyboard.press("Alt+F12");
await expect(strips.getByRole("tab", { name: "Local" })).toBeVisible();
await strips.getByRole("tab", { name: "Local" }).click({ button: "right" });
await page.getByRole("menuitem", { name: /^Split Right/ }).click();
await expect(strips).toHaveCount(2);
await expect(strips.nth(1).getByRole("tab", { name: "Local (2)" })).toHaveAttribute("aria-selected", "true");
// One header per group: the title in the first, the window's buttons in the last.
await expect(terminal.locator("header")).toHaveCount(2);
await expect(terminal.locator("header").nth(0)).toContainText("Terminal");
await expect(terminal.locator("header").nth(1).getByRole("button", { name: "Options", exact: true })).toHaveCount(1);
await expect(terminal.locator("header").nth(0).getByRole("button", { name: "Options", exact: true })).toHaveCount(0);
if (process.env.OPENIDEA_E2E_SHOTS) await terminal.screenshot({ path: process.env.OPENIDEA_E2E_SHOTS });
await strips.nth(1).getByRole("tab", { name: "Local (2)" }).click({ button: "right" });
await page.getByRole("menuitem", { name: "Unsplit" }).click();
await expect(strips).toHaveCount(1);
await expect(strips.getByRole("tab")).toHaveText(["Local", "Local (2)"]);
await expect(terminal.locator("header")).toHaveCount(1);
});
test("closing the last tab of a terminal group focuses its neighbour", async ({ workbench: page }) => {
const terminal = page.getByRole("region", { name: "Terminal" });
const strips = terminal.getByRole("tablist", { name: "Terminal sessions" });
await page.keyboard.press("Alt+F12");
await strips.getByRole("tab", { name: "Local" }).click({ button: "right" });
await page.getByRole("menuitem", { name: /^Split Right/ }).click();
await expect(strips).toHaveCount(2);
await strips.nth(1).getByRole("button", { name: "Close Tab", exact: true }).click();
await expect(strips).toHaveCount(1);
await expect(terminal.locator(".xterm-helper-textarea")).toBeFocused();
await page.keyboard.press("Meta+t");
await expect(strips.getByRole("tab")).toHaveCount(2);
});
test("⌘⇧F12 hides all tool windows and restores them", async ({ workbench: page }) => {
await page.keyboard.press("Meta+6");
await expect(page.getByRole("region", { name: "Problems" })).toBeVisible();
+7 -1
View File
@@ -44,13 +44,19 @@ export function matchCounter(r: TerminalSearchResult | null, query: string): str
return r.index >= 0 ? `${r.index + 1}/${r.count}` : `${r.count}+`;
}
/** The last open request the find bar took focus for. */
let focusedSeq = 0;
export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
const { open, seq, query, options } = useTerminalFind();
const input = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<TerminalSearchResult | null>(null);
// Only an open request focuses the field: the bar also re-mounts when another
// tab group becomes active, and must not take focus from its terminal then.
useEffect(() => {
if (!open) return;
if (!open || seq === focusedSeq) return;
focusedSeq = seq;
input.current?.focus();
input.current?.select();
}, [open, seq]);
@@ -25,6 +25,15 @@
padding: 0 2px;
}
/* A tab group: its header (while split) over its tabs' terminals. */
.group {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.panels {
flex: 1;
position: relative;
+123 -52
View File
@@ -1,7 +1,8 @@
import { Fragment, useEffect, useLayoutEffect, useRef, useState, type MouseEvent } from "react";
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import clsx from "clsx";
import { useProject } from "@/platform/project";
import { toolWindowService } from "@/workbench/contributions";
import { ToolWindowHeader, useOwnToolWindowHeader } from "@/workbench/toolwindows/ToolWindows";
import { usePlatform } from "@/platform/platformInfo";
import { commands, isTauri } from "@/platform/ipc";
import {
@@ -16,39 +17,41 @@ import {
Tabs,
type MenuItem,
type TabItem,
type TabsDnd,
} from "@/ui";
import {
activateGroup,
activeSession,
getSession,
groupTabs,
layoutGroups,
moveTab,
newTab,
publishFocus,
renameTab,
requestClosePane,
requestCloseSession,
requestCloseTab,
selectTab,
tabFullTitle,
tabTitle,
topRightGroup,
useTerminals,
type PaneLayout,
type GroupLayout,
type TerminalTab,
} from "./store";
import { TerminalFindBar } from "./FindBar";
import styles from "./Terminal.module.css";
const TAB_PREFIX = "openidea-terminal";
/** Dragging a tab reorders the sessions, like IDEA's terminal tabs. */
const TAB_DND: TabsDnd = {
type: "application/x-openidea-terminal-tab",
payload: (id) => id,
onDrop: (id, index) => moveTab(id, index),
};
/**
* Drag type of terminal tabs: dragging one reorders its group's strip or moves
* it to another group's, like IDEA's terminal tabs.
*/
const TAB_MIME = "application/x-openidea-terminal-tab";
/** Full title when the label is trimmed, then the working directory. */
function tabTooltip(tab: TerminalTab, sessions: Parameters<typeof tabTitle>[1]): string | undefined {
const full = tabFullTitle(tab, sessions);
const cwd = sessions?.[tab.activePane]?.cwd;
const cwd = sessions?.[tab.session]?.cwd;
if (full === tabTitle(tab, sessions)) return cwd;
return cwd ? `${full}\n${cwd}` : full;
}
@@ -119,7 +122,14 @@ function paneMenu(e: MouseEvent, key: string) {
{ kind: "separator" },
{ kind: "action", action: "terminal.splitRight" },
{ kind: "action", action: "terminal.splitDown" },
{ label: "Close Session", icon: "close", run: () => void requestClosePane(key) },
{
label: "Close Session",
icon: "close",
run: () =>
void requestCloseSession(key).then((closed) => {
if (closed) requestAnimationFrame(() => activeSession()?.focus());
}),
},
];
if (exited)
items.splice(
@@ -131,14 +141,14 @@ function paneMenu(e: MouseEvent, key: string) {
showContextMenu(e, items);
}
function TabPanes({ tab, active }: { tab: TerminalTab; active: boolean }) {
return <LayoutNode node={tab.layout} tab={tab} active={active} />;
/** The groups of a split window whose headers show its title and its buttons. */
interface Corners {
title: string;
actions: string;
}
function LayoutNode({ node, tab, active }: { node: PaneLayout; tab: TerminalTab; active: boolean }) {
if (typeof node === "string") {
return <TerminalPane sessionKey={node} autofocus={active && node === tab.activePane} />;
}
function LayoutNode({ node, corners }: { node: GroupLayout; corners: Corners | null }) {
if (typeof node === "string") return <TabGroup group={node} corners={corners} />;
return (
<SplitGroup orientation={node.orientation} className={styles.split} id={`terminal-split-${node.id}`}>
{node.children.map((child, i) => {
@@ -147,7 +157,7 @@ function LayoutNode({ node, tab, active }: { node: PaneLayout; tab: TerminalTab;
<Fragment key={id}>
{i > 0 && <SplitHandle />}
<SplitPanel id={id} minSize={60} className={styles.splitPanel}>
<LayoutNode node={child} tab={tab} active={active} />
<LayoutNode node={child} corners={corners} />
</SplitPanel>
</Fragment>
);
@@ -156,6 +166,39 @@ function LayoutNode({ node, tab, active }: { node: PaneLayout; tab: TerminalTab;
);
}
/** A tab group: its own header while the window is split, then its selected tab's terminal. */
function TabGroup({ group, corners }: { group: string; corners: Corners | null }) {
const allTabs = useTerminals((s) => s.tabs);
const selected = useTerminals((s) => s.selected[group] ?? null);
const activeTab = useTerminals((s) => s.activeTab);
const tabs = useMemo(() => groupTabs(allTabs, group), [allTabs, group]);
const active = tabs.find((t) => t.id === activeTab);
return (
<div className={styles.group}>
{corners && (
<ToolWindowHeader title={corners.title === group} actions={corners.actions === group} closeTarget={!!active}>
<GroupTabs group={group} />
</ToolWindowHeader>
)}
{active && <TerminalFindBar sessionKey={active.session} />}
<div className={styles.panels}>
{tabs.map((t) => (
<div
key={t.id}
role="tabpanel"
id={`${TAB_PREFIX}-panel-${t.id}`}
aria-labelledby={`${TAB_PREFIX}-tab-${t.id}`}
className={styles.panel}
hidden={t.id !== selected}
>
<TerminalPane sessionKey={t.session} autofocus={t.id === activeTab} />
</div>
))}
</div>
</div>
);
}
function RenamePopup({ tab, anchor, onClose }: { tab: TerminalTab; anchor: Element; onClose: () => void }) {
const [value, setValue] = useState(() => tabTitle(tab, useTerminals.getState().sessions));
return (
@@ -181,10 +224,12 @@ function RenamePopup({ tab, anchor, onClose }: { tab: TerminalTab; anchor: Eleme
}
export function TerminalView() {
const tabs = useTerminals((s) => s.tabs);
const activeTab = useTerminals((s) => s.activeTab);
const layout = useTerminals((s) => s.layout);
const root = useProject((s) => s.project?.root);
const rootRef = useRef<HTMLDivElement>(null);
const split = typeof layout !== "string";
// Split, each group shows a header of its own, as in IDEA.
useOwnToolWindowHeader(split);
// Showing an empty terminal window opens a "Local" tab, like IDEA. The window stays
// mounted while hidden, so wait until it takes space again (not right after the last
@@ -209,21 +254,10 @@ export function TerminalView() {
onFocusCapture={(e) => publishFocus(!!(e.target as Element).closest?.("[data-terminal-session]"))}
onBlurCapture={() => publishFocus(false)}
>
<TerminalFindBar sessionKey={tabs.find((t) => t.id === activeTab)?.activePane ?? null} />
<div className={styles.panels}>
{tabs.map((t) => (
<div
key={t.id}
role="tabpanel"
id={`${TAB_PREFIX}-panel-${t.id}`}
aria-labelledby={`${TAB_PREFIX}-tab-${t.id}`}
className={styles.panel}
hidden={t.id !== activeTab}
>
<TabPanes tab={t} active={t.id === activeTab} />
</div>
))}
</div>
<LayoutNode
node={layout}
corners={split ? { title: layoutGroups(layout)[0]!, actions: topRightGroup(layout) } : null}
/>
</div>
);
}
@@ -256,11 +290,25 @@ export function shellItems(paths: string[]): MenuItem[] {
}));
}
/** Session tabs shown in the tool window header: "Local × + ▾". */
/** Session tabs shown in the tool window header while it isn't split: "Local × + ▾". */
export function TerminalTabs() {
const tabs = useTerminals((s) => s.tabs);
const activeTab = useTerminals((s) => s.activeTab);
const group = useTerminals((s) => layoutGroups(s.layout)[0]!);
return <GroupTabs group={group} />;
}
/** Focuses the active tab's terminal once the panes have (re-)mounted. */
function focusActiveTab(raf: { current: number }) {
cancelAnimationFrame(raf.current);
raf.current = requestAnimationFrame(() => activeSession()?.focus());
}
/** A tab group's session tabs. */
function GroupTabs({ group }: { group: string }) {
const allTabs = useTerminals((s) => s.tabs);
const selected = useTerminals((s) => s.selected[group] ?? null);
const sessions = useTerminals((s) => s.sessions);
const split = useTerminals((s) => typeof s.layout !== "string");
const tabs = useMemo(() => groupTabs(allTabs, group), [allTabs, group]);
const [renaming, setRenaming] = useState<{ id: string; anchor: Element } | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [shells, setShells] = useState<string[]>([]);
@@ -268,8 +316,8 @@ export function TerminalTabs() {
const focusRaf = useRef(0);
const items: TabItem[] = tabs.map((t) => {
const status = t.panes.map((p) => sessions[p]?.status);
const dead = status.every((s) => s === "exited" || s === "error");
const status = sessions[t.session]?.status;
const dead = status === "exited" || status === "error";
return {
id: t.id,
label: tabTitle(t, sessions),
@@ -283,22 +331,32 @@ export function TerminalTabs() {
const onClose = async (id: string) => {
if (!(await requestCloseTab(id))) return;
if (useTerminals.getState().tabs.length === 0) toolWindowService().hide("terminal");
else focusActiveTab(focusRaf);
};
const tabMenu = (id: string, e: MouseEvent) => {
e.preventDefault();
const anchor = (e.currentTarget as Element) ?? null;
const others = tabs.length > 1;
showContextMenu(e, [
{ label: "Rename Session…", icon: "edit", run: () => setRenaming({ id, anchor }) },
{ kind: "separator" },
{ label: "Close Tab", icon: "close", run: () => void onClose(id) },
{
label: "Close Other Tabs",
disabled: tabs.length < 2,
disabled: !others,
run: async () => {
for (const t of tabs.filter((x) => x.id !== id)) if (!(await requestCloseTab(t.id))) return;
focusActiveTab(focusRaf);
},
},
{ kind: "separator" },
// IDEA's tool window splits: each part gets its own tabs.
{ kind: "action", action: "terminal.splitRight", arg: id },
...(others ? [{ kind: "action" as const, action: "terminal.splitAndMoveRight", arg: id }] : []),
{ kind: "action", action: "terminal.splitDown", arg: id },
...(others ? [{ kind: "action" as const, action: "terminal.splitAndMoveDown", arg: id }] : []),
...(split ? [{ kind: "action" as const, action: "terminal.unsplit", arg: group }] : []),
]);
};
@@ -333,22 +391,32 @@ export function TerminalTabs() {
className={styles.tabStrip}
aria-label="Terminal sessions"
items={items}
activeId={activeTab}
activeId={selected}
onSelect={(id) => {
selectTab(id);
cancelAnimationFrame(focusRaf.current);
focusRaf.current = requestAnimationFrame(() => {
const tab = useTerminals.getState().tabs.find((t) => t.id === id);
if (tab) getSession(tab.activePane)?.focus();
});
focusActiveTab(focusRaf);
}}
onClose={(id) => void onClose(id)}
onContextMenu={tabMenu}
panelIdPrefix={TAB_PREFIX}
dnd={TAB_DND}
dnd={{
type: TAB_MIME,
payload: (id) => id,
onDrop: (id, index) => {
moveTab(id, group, index);
focusActiveTab(focusRaf);
},
}}
trailing={
<div className={styles.tabActions}>
<IconButton icon="plus" size="small" action="terminal.new" title="New Tab" />
{/* New Tab and the menu's actions go to this group. */}
<IconButton
icon="plus"
size="small"
action="terminal.new"
title="New Tab"
onClick={() => activateGroup(group)}
/>
<IconButton
ref={menuRef}
icon="chevron-down"
@@ -357,7 +425,10 @@ export function TerminalTabs() {
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => {
if (!menuOpen) void availableShells().then(setShells);
if (!menuOpen) {
activateGroup(group);
void availableShells().then(setShells);
}
setMenuOpen((v) => !v);
}}
/>
+120
View File
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
closeAll,
closeTab,
getSession,
groupTabs,
moveTab,
neighbourGroup,
newTab,
splitTab,
topRightGroup,
unsplit,
useTerminals,
type GroupLayout,
} from "./store";
vi.mock("./session", () => ({
TerminalSession: class {
key: string;
state: { cwd: string };
constructor(opts: { key: string; cwd: string }) {
this.key = opts.key;
this.state = { cwd: opts.cwd };
}
dispose() {}
},
}));
const state = () => useTerminals.getState();
const tab = (id: string) => state().tabs.find((t) => t.id === id)!;
const strip = (group: string) => groupTabs(state().tabs, group).map((t) => t.id);
beforeEach(() => closeAll());
describe("terminal tab groups", () => {
it("splits right into a new group with a session in the tab's directory", () => {
const a = newTab("/repo/app");
const b = splitTab(a.id, "horizontal")!;
const g2 = tab(b).group;
expect(g2).not.toBe(a.group);
expect(state().layout).toMatchObject({ orientation: "horizontal", children: [a.group, g2] });
expect(tab(b).name).toBe("Local (2)");
expect(getSession(tab(b).session)?.state.cwd).toBe("/repo/app");
expect(state().selected).toEqual({ [a.group]: a.id, [g2]: b });
expect(state().activeTab).toBe(b);
// New tabs open in the active group.
expect(newTab("/repo").group).toBe(g2);
});
it("collapses a group when its last tab closes, activating the neighbour", () => {
const a = newTab("/p");
const b = splitTab(a.id, "vertical")!;
closeTab(b);
expect(state().layout).toBe(a.group);
expect(state().activeTab).toBe(a.id);
expect(Object.keys(state().selected)).toEqual([a.group]);
closeTab(a.id);
expect(state().layout).toBe(a.group);
expect(state().activeTab).toBeNull();
});
it("moves the tab itself with Split and Move, which needs another tab left behind", () => {
const a = newTab("/p");
expect(splitTab(a.id, "vertical", true)).toBeNull();
const c = newTab("/p").id;
expect(splitTab(c, "vertical", true)).toBe(c);
expect(tab(c).group).not.toBe(a.group);
expect(strip(a.group)).toEqual([a.id]);
expect(state().selected[a.group]).toBe(a.id);
expect(state().layout).toMatchObject({ orientation: "vertical", children: [a.group, tab(c).group] });
});
it("unsplits into the neighbouring group, in layout order, keeping the group's selection", () => {
const a = newTab("/p");
const b = splitTab(a.id, "horizontal")!;
const g2 = tab(b).group;
const c = newTab("/p").id;
unsplit(a.group);
expect(state().layout).toBe(g2);
expect(strip(g2)).toEqual([a.id, b, c]);
expect(state().selected).toEqual({ [g2]: a.id });
expect(state().activeTab).toBe(a.id);
});
it("drags tabs between groups and within one", () => {
const a = newTab("/p");
const b = splitTab(a.id, "horizontal")!;
moveTab(b, a.group, 0);
expect(state().layout).toBe(a.group);
expect(strip(a.group)).toEqual([b, a.id]);
expect(state().activeTab).toBe(b);
moveTab(b, a.group, 2);
expect(strip(a.group)).toEqual([a.id, b]);
// A group's only tab dropped on its own strip stays put.
const d = splitTab(a.id, "horizontal")!;
moveTab(d, tab(d).group, 0);
expect(typeof state().layout).toBe("object");
});
});
describe("group layout", () => {
const l: GroupLayout = {
id: "s1",
orientation: "horizontal",
children: ["A", { id: "s2", orientation: "vertical", children: ["B", "C"] }],
};
it("finds the group an unsplit merges into: the one before, else after, in the same split", () => {
expect(neighbourGroup(l, "A")).toBe("B");
expect(neighbourGroup(l, "B")).toBe("C");
expect(neighbourGroup(l, "C")).toBe("B");
expect(neighbourGroup("A", "A")).toBeNull();
});
it("finds the top right group", () => {
expect(topRightGroup(l)).toBe("B");
expect(topRightGroup({ id: "s", orientation: "vertical", children: [l, "D"] })).toBe("B");
expect(topRightGroup("A")).toBe("A");
});
});
+40 -11
View File
@@ -1,6 +1,6 @@
/**
* Integrated terminal: xterm.js sessions on backend PTYs in the bottom
* "Terminal" tool window (tabs, splits, links, IDEA-style shortcuts).
* "Terminal" tool window (tabs, split tab groups, links, IDEA-style shortcuts).
*/
import { registerActions } from "@/platform/actions";
import { onIdeEvent } from "@/platform/ipc";
@@ -42,6 +42,16 @@ async function openNewTab(dir?: string) {
focusActiveSoon();
}
/** Split Right / Split Down of tab `arg` (from its context menu), else of the active tab. */
async function split(arg: unknown, orientation: "horizontal" | "vertical", move = false) {
const rt = await loadTerminalRuntime();
const id = typeof arg === "string" && arg ? arg : rt.useTerminals.getState().activeTab;
if (!id) return move ? undefined : openNewTab();
rt.splitTab(id, orientation, move);
toolWindowService().show(WINDOW, true);
focusActiveSoon();
}
export function activate(): () => void {
const disposers: Array<() => void> = [
registerToolWindow({
@@ -117,12 +127,7 @@ export function activate(): () => void {
category: "Terminal",
icon: "split",
when: "projectOpen",
run: async () => {
const rt = await loadTerminalRuntime();
if (!rt.useTerminals.getState().activeTab) return openNewTab();
rt.splitActive("horizontal");
focusActiveSoon();
},
run: ({ arg }) => split(arg, "horizontal"),
},
{
id: "terminal.splitDown",
@@ -130,10 +135,34 @@ export function activate(): () => void {
category: "Terminal",
icon: "split-horizontal",
when: "projectOpen",
run: async () => {
run: ({ arg }) => split(arg, "vertical"),
},
{
id: "terminal.splitAndMoveRight",
title: "Split and Move Right",
category: "Terminal",
when: "projectOpen",
run: ({ arg }) => split(arg, "horizontal", true),
},
{
id: "terminal.splitAndMoveDown",
title: "Split and Move Down",
category: "Terminal",
when: "projectOpen",
run: ({ arg }) => split(arg, "vertical", true),
},
{
id: "terminal.unsplit",
title: "Unsplit",
category: "Terminal",
when: "projectOpen",
run: async ({ arg }) => {
const rt = await loadTerminalRuntime();
if (!rt.useTerminals.getState().activeTab) return openNewTab();
rt.splitActive("vertical");
const { tabs, activeTab } = rt.useTerminals.getState();
const group = typeof arg === "string" ? arg : tabs.find((t) => t.id === activeTab)?.group;
if (!group) return;
rt.unsplit(group);
toolWindowService().show(WINDOW, true);
focusActiveSoon();
},
},
@@ -167,7 +196,7 @@ export function activate(): () => void {
const rt = terminalRuntime();
const s = rt?.activeSession();
if (!rt || !s) return;
void rt.requestClosePane(s.key).then((closed) => {
void rt.requestCloseSession(s.key).then((closed) => {
if (!closed) return;
if (rt.useTerminals.getState().tabs.length === 0) toolWindowService().hide(WINDOW);
else focusActiveSoon();
+3 -3
View File
@@ -4,14 +4,14 @@ export {
applySettingsToAll,
applyThemeToAll,
closeAll,
closePane,
newTab,
newTabAt,
requestClosePane,
requestCloseSession,
sessionForTerminalId,
sessionFromElement,
splitActive,
splitTab,
startDirectory,
unsplit,
useTerminals,
} from "./store";
export { TerminalTabs, TerminalView } from "./TerminalView";
+212 -88
View File
@@ -1,6 +1,7 @@
/**
* Terminal tabs and panes. Tab metadata lives in a zustand store for React;
* the xterm sessions themselves live in a plain map (they own DOM + PTY).
* Terminal tabs and the tab groups the tool window splits into. Tab metadata
* lives in a zustand store for React; the xterm sessions themselves live in a
* plain map (they own DOM + PTY).
*/
import { create } from "zustand";
import { setContext } from "@/platform/context";
@@ -12,12 +13,12 @@ import { TerminalSession, type SessionOptions, type SessionSnapshot } from "./se
export type SplitOrientation = "horizontal" | "vertical";
/** A session key, or a split of nested layouts. `horizontal` = side by side, `vertical` = stacked. */
export type PaneLayout = string | PaneSplit;
export interface PaneSplit {
/** A tab group id, or a split of nested layouts. `horizontal` = side by side, `vertical` = stacked. */
export type GroupLayout = string | GroupSplit;
export interface GroupSplit {
id: string;
orientation: SplitOrientation;
children: PaneLayout[];
children: GroupLayout[];
}
export interface TerminalTab {
@@ -26,19 +27,37 @@ export interface TerminalTab {
name: string;
/** Set by the user via rename; wins over `name`. */
customName: string | null;
/** Session keys, in display order. */
panes: string[];
layout: PaneLayout;
activePane: string;
/** Key of the tab's session. */
session: string;
/** The tab group showing it. */
group: string;
}
interface TerminalState {
/** Every tab, in tab strip order within its group. */
tabs: TerminalTab[];
/**
* The tab groups the tool window is split into (IDEA's Split Right / Split
* Down), each with its own tab strip.
*/
layout: GroupLayout;
/** The selected tab of each group. */
selected: Record<string, string>;
/** The selected tab of the group used last; the one New Tab, Split and Find act on. */
activeTab: string | null;
sessions: Record<string, SessionSnapshot>;
}
export const useTerminals = create<TerminalState>(() => ({ tabs: [], activeTab: null, sessions: {} }));
let groupSeq = 0;
const newGroupId = () => `g${++groupSeq}`;
export const useTerminals = create<TerminalState>(() => ({
tabs: [],
layout: newGroupId(),
selected: {},
activeTab: null,
sessions: {},
}));
const sessions = new Map<string, TerminalSession>();
let seq = 0;
@@ -63,11 +82,12 @@ function createSession(cwd: string, command?: SessionOptions["command"]): Termin
command,
onChange: (k, snapshot) => useTerminals.setState((s) => ({ sessions: { ...s.sessions, [k]: snapshot } })),
onFocus: (k) => {
const tab = useTerminals.getState().tabs.find((t) => t.panes.includes(k));
if (tab) setActivePane(tab.id, k);
const tab = tabOfSession(k);
if (tab) selectTab(tab.id);
},
onExit: (k) => {
if (workflowSettings().terminal.closeOnExit) closePane(k);
const tab = tabOfSession(k);
if (tab && workflowSettings().terminal.closeOnExit) closeTab(tab.id);
},
});
sessions.set(key, session);
@@ -103,9 +123,9 @@ export function trimMiddle(text: string, max: number): string {
const APP_TITLE_MAX = 30;
/** The title the program in the tab's active pane set, while a command runs (at the prompt it is the shell's own). */
/** The title the program in the tab set, while a command runs (at the prompt it is the shell's own). */
function appTitle(tab: TerminalTab, sessions?: Record<string, SessionSnapshot>): string | null {
const s = sessions?.[tab.activePane];
const s = sessions?.[tab.session];
return s?.commandRunning ? s.appTitle : null;
}
@@ -133,21 +153,38 @@ export function startDirectory(root: string): string {
return `${root.replace(/\/+$/, "")}/${dir.replace(/^\.\//, "")}`;
}
/** The tabs of `group`, in strip order. */
export function groupTabs(tabs: readonly TerminalTab[], group: string): TerminalTab[] {
return tabs.filter((t) => t.group === group);
}
function tabOfSession(key: string): TerminalTab | undefined {
return useTerminals.getState().tabs.find((t) => t.session === key);
}
function defaultTabName(base = workflowSettings().terminal.tabName): string {
return nextTabName(
useTerminals.getState().tabs.map((t) => t.name),
base,
);
}
/** Opens a tab in the active tab's group. */
export function newTab(cwd: string, name?: string, command?: SessionOptions["command"]): TerminalTab {
const session = createSession(cwd, command);
const { tabs } = useTerminals.getState();
const { tabs, layout, activeTab } = useTerminals.getState();
const tab: TerminalTab = {
id: `t${seq}`,
name: nextTabName(
tabs.map((t) => t.name),
name ?? workflowSettings().terminal.tabName,
),
name: defaultTabName(name),
customName: null,
panes: [session.key],
layout: session.key,
activePane: session.key,
session: session.key,
group: tabs.find((t) => t.id === activeTab)?.group ?? layoutGroups(layout)[0]!,
};
useTerminals.setState((s) => ({ tabs: [...s.tabs, tab], activeTab: tab.id }));
useTerminals.setState((s) => ({
tabs: [...s.tabs, tab],
selected: { ...s.selected, [tab.group]: tab.id },
activeTab: tab.id,
}));
return tab;
}
@@ -155,15 +192,44 @@ export function newTabAt(dir: string): TerminalTab {
return newTab(dir, basename(dir) || "Local");
}
type Placement = Pick<TerminalState, "tabs" | "layout" | "selected" | "activeTab">;
/**
* `s` without tab `id`. Its group selects the neighbouring tab or, left empty,
* leaves the split (the last group stays); an active tab passes to the group's
* new selection, else to the neighbouring group's.
*/
function withoutTab(s: Placement, id: string): Placement {
const tab = s.tabs.find((t) => t.id === id);
if (!tab) return s;
const siblings = groupTabs(s.tabs, tab.group);
const rest = siblings.filter((t) => t.id !== id);
const selected = { ...s.selected };
let layout = s.layout;
let heir: string | null = null;
if (rest.length > 0) {
if (selected[tab.group] === id) selected[tab.group] = rest[Math.min(siblings.indexOf(tab), rest.length - 1)]!.id;
heir = tab.group;
} else {
delete selected[tab.group];
if (typeof layout !== "string") {
heir = neighbourGroup(layout, tab.group);
layout = removeFromLayout(layout, tab.group)!;
}
}
return {
tabs: s.tabs.filter((t) => t.id !== id),
layout,
selected,
activeTab: s.activeTab === id ? ((heir && selected[heir]) ?? null) : s.activeTab,
};
}
export function closeTab(id: string) {
const { tabs, activeTab } = useTerminals.getState();
const index = tabs.findIndex((t) => t.id === id);
const tab = tabs[index];
const tab = useTerminals.getState().tabs.find((t) => t.id === id);
if (!tab) return;
tab.panes.forEach(disposeSession);
const rest = tabs.filter((t) => t.id !== id);
const nextActive = activeTab === id ? (rest[Math.min(index, rest.length - 1)]?.id ?? null) : activeTab;
useTerminals.setState({ tabs: rest, activeTab: nextActive });
disposeSession(tab.session);
useTerminals.setState((s) => withoutTab(s, id));
}
/**
@@ -183,26 +249,35 @@ export async function confirmEndSessions(keys: readonly string[], name: string):
/** User-initiated Close Tab: confirms first when a command is running. Resolves true if closed. */
export async function requestCloseTab(id: string): Promise<boolean> {
const tab = useTerminals.getState().tabs.find((x) => x.id === id);
if (!tab || !(await confirmEndSessions(tab.panes, tabTitle(tab, useTerminals.getState().sessions)))) return false;
if (!tab || !(await confirmEndSessions([tab.session], tabTitle(tab, useTerminals.getState().sessions)))) return false;
closeTab(id);
return true;
}
/** User-initiated Close Session (one pane): confirms first when a command is running. */
export async function requestClosePane(key: string): Promise<boolean> {
const tab = useTerminals.getState().tabs.find((x) => x.panes.includes(key));
if (!tab || !(await confirmEndSessions([key], tabTitle(tab, useTerminals.getState().sessions)))) return false;
closePane(key);
return true;
/** User-initiated Close Session: closes the session's tab, confirming first when a command is running. */
export function requestCloseSession(key: string): Promise<boolean> {
const tab = tabOfSession(key);
return tab ? requestCloseTab(tab.id) : Promise.resolve(false);
}
export function closeAll() {
for (const t of useTerminals.getState().tabs) t.panes.forEach(disposeSession);
useTerminals.setState({ tabs: [], activeTab: null, sessions: {} });
for (const t of useTerminals.getState().tabs) disposeSession(t.session);
useTerminals.setState({ tabs: [], layout: newGroupId(), selected: {}, activeTab: null, sessions: {} });
}
/** Selects tab `id` in its group and makes it the active tab. */
export function selectTab(id: string) {
useTerminals.setState({ activeTab: id });
useTerminals.setState((s) => {
const tab = s.tabs.find((t) => t.id === id);
if (!tab || (s.activeTab === id && s.selected[tab.group] === id)) return s;
return { selected: { ...s.selected, [tab.group]: id }, activeTab: id };
});
}
/** Makes `group`'s selected tab the active one, for actions run from the group's header. */
export function activateGroup(group: string) {
const id = useTerminals.getState().selected[group];
if (id) selectTab(id);
}
/** `tabs` with tab `id` moved to insertion `index`, an index into `tabs` before the move. */
@@ -214,11 +289,6 @@ export function reorderTabs<T extends { id: string }>(tabs: readonly T[], id: st
return rest;
}
/** Drag & drop in the tab strip. */
export function moveTab(id: string, index: number) {
useTerminals.setState((s) => ({ tabs: reorderTabs(s.tabs, id, index) }));
}
export function renameTab(id: string, name: string) {
const trimmed = name.trim();
useTerminals.setState((s) => ({
@@ -226,26 +296,18 @@ export function renameTab(id: string, name: string) {
}));
}
function setActivePane(tabId: string, key: string) {
useTerminals.setState((s) => {
const tab = s.tabs.find((t) => t.id === tabId);
if (!tab || (tab.activePane === key && s.activeTab === tabId)) return s;
return { tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, activePane: key } : t)), activeTab: tabId };
});
}
export function layoutPanes(node: PaneLayout): string[] {
return typeof node === "string" ? [node] : node.children.flatMap(layoutPanes);
export function layoutGroups(node: GroupLayout): string[] {
return typeof node === "string" ? [node] : node.children.flatMap(layoutGroups);
}
/** Inserts `key` after `target`, nesting a new split (`id`) when the orientation differs from the parent's. */
export function splitLayout(
node: PaneLayout,
node: GroupLayout,
target: string,
key: string,
orientation: SplitOrientation,
id: string,
): PaneLayout {
): GroupLayout {
if (typeof node === "string") return node === target ? { id, orientation, children: [node, key] } : node;
const i = node.children.indexOf(target);
if (i >= 0 && node.orientation === orientation) {
@@ -255,7 +317,7 @@ export function splitLayout(
}
/** Removes `key`; splits left with one child collapse into it, and same-orientation children merge up. */
export function removeFromLayout(node: PaneLayout, key: string): PaneLayout | null {
export function removeFromLayout(node: GroupLayout, key: string): GroupLayout | null {
if (typeof node === "string") return node === key ? null : node;
const children = node.children
.map((c) => removeFromLayout(c, key))
@@ -265,43 +327,105 @@ export function removeFromLayout(node: PaneLayout, key: string): PaneLayout | nu
return { ...node, children };
}
let splitSeq = 0;
/** Splits the active pane; the new pane opens in its directory. */
export function splitActive(orientation: SplitOrientation): string | null {
const { tabs, activeTab } = useTerminals.getState();
const tab = tabs.find((t) => t.id === activeTab);
if (!tab) return null;
const current = sessions.get(tab.activePane);
const session = createSession(current?.state.cwd ?? "/");
const layout = splitLayout(tab.layout, tab.activePane, session.key, orientation, `g${++splitSeq}`);
const next = { ...tab, layout, panes: layoutPanes(layout), activePane: session.key };
useTerminals.setState((s) => ({ tabs: s.tabs.map((t) => (t.id === tab.id ? next : t)) }));
return session.key;
/** The group beside `group` in its split (the one before it, else after); null when not split. */
export function neighbourGroup(node: GroupLayout, group: string): string | null {
if (typeof node === "string") return null;
const i = node.children.indexOf(group);
if (i > 0) return layoutGroups(node.children[i - 1]!).at(-1)!;
if (i === 0) return layoutGroups(node.children[1]!)[0]!;
for (const child of node.children) {
const found = neighbourGroup(child, group);
if (found) return found;
}
return null;
}
/** Closes one pane; closing the last pane closes the tab. */
export function closePane(key: string) {
const tab = useTerminals.getState().tabs.find((t) => t.panes.includes(key));
if (!tab) return;
if (tab.panes.length === 1) {
closeTab(tab.id);
return;
/** The group in the top right corner, whose header has the tool window's buttons. */
export function topRightGroup(node: GroupLayout): string {
if (typeof node === "string") return node;
return topRightGroup((node.orientation === "horizontal" ? node.children.at(-1) : node.children[0])!);
}
let splitSeq = 0;
/**
* Split Right / Split Down, as IDEA: a new tab group beside tab `id`'s, with a
* new session in its directory, or with `move` (Split and Move) the tab itself,
* which needs another tab to stay behind. Returns the new group's tab.
*/
export function splitTab(id: string, orientation: SplitOrientation, move = false): string | null {
const s = useTerminals.getState();
const tab = s.tabs.find((t) => t.id === id);
if (!tab || (move && groupTabs(s.tabs, tab.group).length < 2)) return null;
const group = newGroupId();
const layout = splitLayout(s.layout, tab.group, group, orientation, `split${++splitSeq}`);
if (move) {
const rest = withoutTab(s, id);
useTerminals.setState({
tabs: [...rest.tabs, { ...tab, group }],
layout,
selected: { ...rest.selected, [group]: id },
activeTab: id,
});
return id;
}
disposeSession(key);
const i = tab.panes.indexOf(key);
const layout = removeFromLayout(tab.layout, key)!;
const panes = layoutPanes(layout);
const activePane = tab.activePane === key ? panes[Math.max(0, i - 1)]! : tab.activePane;
useTerminals.setState((s) => ({
tabs: s.tabs.map((t) => (t.id === tab.id ? { ...t, layout, panes, activePane } : t)),
const session = createSession(sessions.get(tab.session)?.state.cwd ?? "/");
const copy: TerminalTab = { id: `t${seq}`, name: defaultTabName(), customName: null, session: session.key, group };
useTerminals.setState((st) => ({
tabs: [...st.tabs, copy],
layout,
selected: { ...st.selected, [group]: copy.id },
activeTab: copy.id,
}));
return copy.id;
}
/** Unsplit: merges `group`'s tabs into the group beside it, keeping its selected tab. */
export function unsplit(group: string) {
useTerminals.setState((s) => {
const into = neighbourGroup(s.layout, group);
if (!into) return s;
const keep = s.selected[group] ?? s.selected[into]!;
const selected = { ...s.selected, [into]: keep };
delete selected[group];
return {
// In layout order, so the merged strip keeps the tabs left to right / top to bottom.
tabs: layoutGroups(s.layout)
.flatMap((g) => groupTabs(s.tabs, g))
.map((t) => (t.group === group ? { ...t, group: into } : t)),
layout: removeFromLayout(s.layout, group)!,
selected,
activeTab: keep,
};
});
}
/**
* Drag & drop: moves tab `id` to insertion `index` of `group`'s strip. Within
* its own group that reorders it; into another it also becomes the selected tab.
*/
export function moveTab(id: string, group: string, index: number) {
useTerminals.setState((s) => {
const tab = s.tabs.find((t) => t.id === id);
if (!tab || !layoutGroups(s.layout).includes(group)) return s;
const others = (tabs: readonly TerminalTab[]) => tabs.filter((t) => t.group !== group);
if (tab.group === group) return { tabs: [...others(s.tabs), ...reorderTabs(groupTabs(s.tabs, group), id, index)] };
const rest = withoutTab(s, id);
const strip = groupTabs(rest.tabs, group);
strip.splice(Math.max(0, Math.min(index, strip.length)), 0, { ...tab, group });
return {
tabs: [...others(rest.tabs), ...strip],
layout: rest.layout,
selected: { ...rest.selected, [group]: id },
activeTab: id,
};
});
}
export function activeSession(): TerminalSession | undefined {
const { tabs, activeTab } = useTerminals.getState();
const tab = tabs.find((t) => t.id === activeTab);
return tab ? sessions.get(tab.activePane) : undefined;
return tab ? sessions.get(tab.session) : undefined;
}
/** Session whose DOM contains `el` (e.g. the focused xterm textarea). */
+3 -3
View File
@@ -5,7 +5,7 @@ import { matchCounter } from "./FindBar";
import { buildSearchDecorations } from "./theme";
import { findTerminalLinks, resolvePath, trimUrl } from "./links";
import {
layoutPanes,
layoutGroups,
nextTabName,
removeFromLayout,
reorderTabs,
@@ -273,7 +273,7 @@ describe("split layout", () => {
children: ["a", { id: "g2", orientation: "horizontal", children: ["b", "c"] }],
});
l = splitLayout(l, "a", "d", "vertical", "g3");
expect(layoutPanes(l)).toEqual(["a", "d", "b", "c"]);
expect(layoutGroups(l)).toEqual(["a", "d", "b", "c"]);
});
it("collapses single-child splits on removal", () => {
@@ -387,7 +387,7 @@ describe("terminalFont", () => {
});
describe("terminal tab titles", () => {
const tab: TerminalTab = { id: "t1", name: "Local", customName: null, panes: ["s1"], layout: "s1", activePane: "s1" };
const tab: TerminalTab = { id: "t1", name: "Local", customName: null, session: "s1", group: "g1" };
const snap = (appTitle: string | null, commandRunning = true): Record<string, SessionSnapshot> => ({
s1: { status: "running", exitCode: null, title: "zsh", appTitle, commandRunning, cwd: "/p", follow: true },
});
+169 -91
View File
@@ -1,4 +1,6 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -553,35 +555,72 @@ function staticHeaderMenu(items: MenuItem[] | undefined): (id: string) => MenuIt
const noTitleMenu = (): { label: string; items: MenuItem[] } | null => null;
function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: boolean }) {
const ref = useRef<HTMLElement>(null);
const focusRequest = useToolWindowLayout((s) => s.focusRequest);
/** The tool window around its content, for `ToolWindowHeader` and `useOwnToolWindowHeader`. */
const ToolWindowFrame = createContext<{ tw: ToolWindowContribution; setOwnHeader: (own: boolean) => void } | null>(
null,
);
/**
* Takes the header over from the tool window while `own` is true: its content
* then places `ToolWindowHeader`s itself, as IDEA gives each cell of a split
* tool window (the Terminal's Split Right / Split Down) its own header.
*/
export function useOwnToolWindowHeader(own: boolean) {
const setOwnHeader = useContext(ToolWindowFrame)?.setOwnHeader;
useLayoutEffect(() => {
if (!setOwnHeader || !own) return;
setOwnHeader(true);
return () => setOwnHeader(false);
}, [setOwnHeader, own]);
}
export interface ToolWindowHeaderProps {
/** Shows the window's title (a split's first cell). */
title?: boolean;
/** Shows the window's own buttons: header actions, ⋮ options and hide (a split's last cell). */
actions?: boolean;
/** Its tab strip is the one Close Tab (⌘W) acts on. */
closeTarget?: boolean;
/** Content tabs, after the title. */
children?: ReactNode;
}
/** A header row of the tool window whose content renders it (see `useOwnToolWindowHeader`). */
export function ToolWindowHeader({
title = true,
actions = true,
closeTarget = false,
children,
}: ToolWindowHeaderProps) {
const tw = useContext(ToolWindowFrame)?.tw;
if (!tw) return null;
return (
<HeaderRow tw={tw} title={title} actions={actions}>
<TabStripRegistry.Provider value={closeTarget ? contentTabRegistry(tw.id) : null}>
{children}
</TabStripRegistry.Provider>
</HeaderRow>
);
}
function HeaderRow({
tw,
title,
actions,
children,
}: {
tw: ToolWindowContribution;
title: boolean;
actions: boolean;
children?: ReactNode;
}) {
const maximized = useToolWindowLayout((s) => s.maximized === tw.anchor);
const [moreOpen, setMoreOpen] = useState(false);
const [titleOpen, setTitleOpen] = useState(false);
const moreRef = useRef<HTMLButtonElement>(null);
const titleRef = useRef<HTMLButtonElement>(null);
const Content = tw.component;
const Actions = tw.headerActions;
const Tabs = tw.headerContent;
useEffect(() => {
if (!focusRequest || focusRequest.id !== tw.id || hidden) return;
const raf = requestAnimationFrame(() => {
const root = ref.current;
if (!root) return;
const body = root.querySelector<HTMLElement>("[data-toolwindow-body]");
// Like IDEA, activation focuses the content (a field, list or tree), never a button or
// link: a scripted focus on one draws its focus ring (an empty view's "New…" link).
const content = [...(body?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])].find(
(el) => !el.matches('button, a[href], [role="button"], [role="link"]'),
);
if (body && !content && !body.hasAttribute("tabindex")) body.tabIndex = -1;
const target = body?.querySelector<HTMLElement>("[data-autofocus]") ?? content ?? body ?? root;
target.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(raf);
}, [focusRequest, tw.id, hidden]);
const showTitle = title && !(tw.headerContent && tw.tabsReplaceTitle);
const useHeaderMenu = typeof tw.headerMenu === "function" ? tw.headerMenu : staticHeaderMenu(tw.headerMenu);
const own = useHeaderMenu(tw.id);
@@ -601,6 +640,104 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
{ label: "Hide", icon: "hide", shortcut: shortcutLabel("toolwindow.hideActive"), run: () => hideToolWindow(tw.id) },
];
return (
<header
className={clsx(styles.header, tw.headerContent && styles.headerTabbed)}
onDoubleClick={(e) => e.target === e.currentTarget && toggleMaximizeToolWindow(tw.id)}
>
{!showTitle ? null : titleItems.length > 0 ? (
<>
<button
ref={titleRef}
type="button"
className={styles.titleButton}
aria-haspopup="menu"
aria-expanded={titleOpen}
onClick={() => setTitleOpen((v) => !v)}
>
<span className={styles.title}>
<T>{titleMenu?.label ?? tw.title}</T>
</span>
<Icon name="chevron-down" size={12} className={styles.titleChevron} />
</button>
<Menu
open={titleOpen}
onClose={() => setTitleOpen(false)}
anchor={titleRef}
items={titleItems}
aria-label={titleMenu ? tx`${tw.title} views` : tx`${tw.title} view options`}
/>
</>
) : (
<span className={clsx(styles.title, styles.titlePlain)}>
<T>{tw.title}</T>
</span>
)}
{children && <div className={clsx(styles.headerContent, !showTitle && styles.headerContentOnly)}>{children}</div>}
<div className={styles.headerSpacer} />
{actions && (
<>
<div className={styles.headerActions}>
{Actions && <Actions id={tw.id} />}
<IconButton
ref={moreRef}
icon="more"
title="Options"
size="small"
iconSize={16}
aria-haspopup="menu"
aria-expanded={moreOpen}
onClick={() => setMoreOpen((v) => !v)}
/>
<IconButton
icon="minus"
title="Hide"
shortcut={shortcutLabel("toolwindow.hideActive")}
size="small"
iconSize={16}
onClick={() => hideToolWindow(tw.id)}
/>
</div>
<Menu
open={moreOpen}
onClose={() => setMoreOpen(false)}
anchor={moreRef}
items={more}
placement="bottom-end"
aria-label={tx`${tw.title} options`}
/>
</>
)}
</header>
);
}
function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: boolean }) {
const ref = useRef<HTMLElement>(null);
const focusRequest = useToolWindowLayout((s) => s.focusRequest);
const [ownHeader, setOwnHeader] = useState(false);
const frame = useMemo(() => ({ tw, setOwnHeader }), [tw]);
const Content = tw.component;
const Tabs = tw.headerContent;
useEffect(() => {
if (!focusRequest || focusRequest.id !== tw.id || hidden) return;
const raf = requestAnimationFrame(() => {
const root = ref.current;
if (!root) return;
const body = root.querySelector<HTMLElement>("[data-toolwindow-body]");
// Like IDEA, activation focuses the content (a field, list or tree), never a button or
// link: a scripted focus on one draws its focus ring (an empty view's "New…" link).
const content = [...(body?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])].find(
(el) => !el.matches('button, a[href], [role="button"], [role="link"]'),
);
if (body && !content && !body.hasAttribute("tabindex")) body.tabIndex = -1;
const target = body?.querySelector<HTMLElement>("[data-autofocus]") ?? content ?? body ?? root;
target.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(raf);
}, [focusRequest, tw.id, hidden]);
return (
<section
ref={ref}
@@ -610,78 +747,19 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
hidden={hidden}
tabIndex={-1}
>
<header
className={clsx(styles.header, Tabs && styles.headerTabbed)}
onDoubleClick={(e) => e.target === e.currentTarget && toggleMaximizeToolWindow(tw.id)}
>
{Tabs && tw.tabsReplaceTitle ? null : titleItems.length > 0 ? (
<>
<button
ref={titleRef}
type="button"
className={styles.titleButton}
aria-haspopup="menu"
aria-expanded={titleOpen}
onClick={() => setTitleOpen((v) => !v)}
>
<span className={styles.title}>
<T>{titleMenu?.label ?? tw.title}</T>
</span>
<Icon name="chevron-down" size={12} className={styles.titleChevron} />
</button>
<Menu
open={titleOpen}
onClose={() => setTitleOpen(false)}
anchor={titleRef}
items={titleItems}
aria-label={titleMenu ? tx`${tw.title} views` : tx`${tw.title} view options`}
/>
</>
) : (
<span className={clsx(styles.title, styles.titlePlain)}>
<T>{tw.title}</T>
</span>
)}
{Tabs && (
<div className={clsx(styles.headerContent, tw.tabsReplaceTitle && styles.headerContentOnly)}>
{!ownHeader && (
<HeaderRow tw={tw} title actions>
{Tabs && (
<TabStripRegistry.Provider value={contentTabRegistry(tw.id)}>
<Tabs id={tw.id} />
</TabStripRegistry.Provider>
</div>
)}
<div className={styles.headerSpacer} />
<div className={styles.headerActions}>
{Actions && <Actions id={tw.id} />}
<IconButton
ref={moreRef}
icon="more"
title="Options"
size="small"
iconSize={16}
aria-haspopup="menu"
aria-expanded={moreOpen}
onClick={() => setMoreOpen((v) => !v)}
/>
<IconButton
icon="minus"
title="Hide"
shortcut={shortcutLabel("toolwindow.hideActive")}
size="small"
iconSize={16}
onClick={() => hideToolWindow(tw.id)}
/>
</div>
<Menu
open={moreOpen}
onClose={() => setMoreOpen(false)}
anchor={moreRef}
items={more}
placement="bottom-end"
aria-label={tx`${tw.title} options`}
/>
</header>
)}
</HeaderRow>
)}
<div className={styles.body} data-toolwindow-body="">
<Content id={tw.id} />
<ToolWindowFrame.Provider value={frame}>
<Content id={tw.id} />
</ToolWindowFrame.Provider>
</div>
</section>
);