From f5e12238bb0cd964e09655b800f57cfe65d1a8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=B6=E9=9B=A8=E9=81=A5?= <59300016+StarryKira@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:20:11 +0800 Subject: [PATCH] feat(terminal): split the tool window into tab groups, as IDEA (#864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- e2e/toolwindows.spec.ts | 40 +++ src/features/terminal/FindBar.tsx | 8 +- src/features/terminal/Terminal.module.css | 9 + src/features/terminal/TerminalView.tsx | 175 +++++++++---- src/features/terminal/groups.test.ts | 120 +++++++++ src/features/terminal/index.ts | 51 +++- src/features/terminal/runtime.ts | 6 +- src/features/terminal/store.ts | 300 +++++++++++++++------- src/features/terminal/terminal.test.ts | 6 +- src/workbench/toolwindows/ToolWindows.tsx | 260 ++++++++++++------- 10 files changed, 726 insertions(+), 249 deletions(-) create mode 100644 src/features/terminal/groups.test.ts diff --git a/e2e/toolwindows.spec.ts b/e2e/toolwindows.spec.ts index c1b4f37b..e8eb67d9 100644 --- a/e2e/toolwindows.spec.ts +++ b/e2e/toolwindows.spec.ts @@ -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(); diff --git a/src/features/terminal/FindBar.tsx b/src/features/terminal/FindBar.tsx index 65b8b065..d7914040 100644 --- a/src/features/terminal/FindBar.tsx +++ b/src/features/terminal/FindBar.tsx @@ -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(null); const [result, setResult] = useState(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]); diff --git a/src/features/terminal/Terminal.module.css b/src/features/terminal/Terminal.module.css index 1fb240f5..eb80f471 100644 --- a/src/features/terminal/Terminal.module.css +++ b/src/features/terminal/Terminal.module.css @@ -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; diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index 37ec86b0..78e1b03e 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -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[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 ; +/** 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 ; - } +function LayoutNode({ node, corners }: { node: GroupLayout; corners: Corners | null }) { + if (typeof node === "string") return ; return ( {node.children.map((child, i) => { @@ -147,7 +157,7 @@ function LayoutNode({ node, tab, active }: { node: PaneLayout; tab: TerminalTab; {i > 0 && } - + ); @@ -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 ( +
+ {corners && ( + + + + )} + {active && } +
+ {tabs.map((t) => ( + + ))} +
+
+ ); +} + 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(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)} > - t.id === activeTab)?.activePane ?? null} /> -
- {tabs.map((t) => ( - - ))} -
+ ); } @@ -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 ; +} + +/** 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([]); @@ -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={
- + {/* New Tab and the menu's actions go to this group. */} + activateGroup(group)} + /> { - if (!menuOpen) void availableShells().then(setShells); + if (!menuOpen) { + activateGroup(group); + void availableShells().then(setShells); + } setMenuOpen((v) => !v); }} /> diff --git a/src/features/terminal/groups.test.ts b/src/features/terminal/groups.test.ts new file mode 100644 index 00000000..7ba472c2 --- /dev/null +++ b/src/features/terminal/groups.test.ts @@ -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"); + }); +}); diff --git a/src/features/terminal/index.ts b/src/features/terminal/index.ts index 75eddf2d..1c230f9a 100644 --- a/src/features/terminal/index.ts +++ b/src/features/terminal/index.ts @@ -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(); diff --git a/src/features/terminal/runtime.ts b/src/features/terminal/runtime.ts index 3839fd99..02dcf8f8 100644 --- a/src/features/terminal/runtime.ts +++ b/src/features/terminal/runtime.ts @@ -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"; diff --git a/src/features/terminal/store.ts b/src/features/terminal/store.ts index 16c02e9b..a8289750 100644 --- a/src/features/terminal/store.ts +++ b/src/features/terminal/store.ts @@ -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; + /** The selected tab of the group used last; the one New Tab, Split and Find act on. */ activeTab: string | null; sessions: Record; } -export const useTerminals = create(() => ({ tabs: [], activeTab: null, sessions: {} })); +let groupSeq = 0; +const newGroupId = () => `g${++groupSeq}`; + +export const useTerminals = create(() => ({ + tabs: [], + layout: newGroupId(), + selected: {}, + activeTab: null, + sessions: {}, +})); const sessions = new Map(); 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 | 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; + +/** + * `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 { 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 { - 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 { + 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(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). */ diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 1a5edf3c..0cafae6b 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -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 => ({ s1: { status: "running", exitCode: null, title: "zsh", appTitle, commandRunning, cwd: "/p", follow: true }, }); diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index d957e2a7..b6c7e4ec 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -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(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 ( + + + {children} + + + ); +} + +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(null); const titleRef = useRef(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("[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(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("[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 ( +
e.target === e.currentTarget && toggleMaximizeToolWindow(tw.id)} + > + {!showTitle ? null : titleItems.length > 0 ? ( + <> + + setTitleOpen(false)} + anchor={titleRef} + items={titleItems} + aria-label={titleMenu ? tx`${tw.title} views` : tx`${tw.title} view options`} + /> + + ) : ( + + {tw.title} + + )} + {children &&
{children}
} +
+ {actions && ( + <> +
+ {Actions && } + setMoreOpen((v) => !v)} + /> + hideToolWindow(tw.id)} + /> +
+ setMoreOpen(false)} + anchor={moreRef} + items={more} + placement="bottom-end" + aria-label={tx`${tw.title} options`} + /> + + )} +
+ ); +} + +function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: boolean }) { + const ref = useRef(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("[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(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("[data-autofocus]") ?? content ?? body ?? root; + target.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(raf); + }, [focusRequest, tw.id, hidden]); + return ( );