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 <[email protected]>
Цей коміт міститься в:
时雨遥 authored and GitHub committed 2026-11-03 23:20:11 +08:00
1 джерело 9100187fc8
коміт f5e12238bb
10 files changed
+726 -249

No files matched your search

+169 -91
Переглянути файл
@@ -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>
);