дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
10 files changed
+726
-249
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Заблокувати користувача