diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 1913dea67..169954b4d 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -29,6 +29,7 @@ import { useStatusBarContributions } from "@/hooks/useStatusBarContributions"; import { ContextMenu, useContextMenu } from "@/components/shared/ContextMenu"; import { closeSession } from "@/services/closeSession"; import { sessionMenuItems } from "@/utils/sessionMenuItems"; +import { splitTabMenuItems } from "@/utils/splitTabMenuItems"; const appWindow = getCurrentWindow(); @@ -84,8 +85,9 @@ export default function TitleBar() { } = selectEffectiveSyncStatus({ voltius: syncState, gist: gistSyncState, accountMode, isPro, gistPluginEnabled }); const { pos: tabMenuPos, open: openTabMenu, close: closeTabMenu } = useContextMenu(); - const [menuSessionId, setMenuSessionId] = useState(null); - const menuSession = sessions.find((s) => s.id === menuSessionId) ?? null; + const [menuTarget, setMenuTarget] = useState<{ kind: "session" | "split"; id: string } | null>(null); + const menuSession = menuTarget?.kind === "session" ? sessions.find((s) => s.id === menuTarget.id) ?? null : null; + const menuSplitTab = menuTarget?.kind === "split" ? splitTabs.find((tab) => tab.id === menuTarget.id) ?? null : null; const [syncDropdownOpen, setSyncDropdownOpen] = useState(false); const syncButtonRef = useRef(null); @@ -152,18 +154,23 @@ export default function TitleBar() { closeTabById(sessionId); }; - const handleUnifiedTabClick = (tabId: string) => { + const handleUnifiedTabClick = (tabId: string, paneId?: string) => { if (shouldSuppressDragClick()) return; setSftpPanelOpen(false); activateSplitTab(tabId); + if (paneId) { + useLayoutStore.getState().setActivePane(paneId); + // A maximized sibling would otherwise keep the whole tab, so the pane the + // user just picked would stay hidden behind it. + if (useLayoutStore.getState().maximizedPaneId) useLayoutStore.getState().setMaximized(paneId); + } const layout = useLayoutStore.getState(); const leaf = findLeaf(layout.root, layout.activePaneId) ?? firstLeaf(layout.root); if (leaf) setActive(leaf.sessionId); setActiveNav("terminal"); }; - const handleUnifiedTabClose = (e: React.MouseEvent, tabId: string) => { - e.stopPropagation(); + const closeUnifiedTab = (tabId: string) => { const tab = useLayoutStore.getState().splitTabs.find((candidate) => candidate.id === tabId); const ids = tab ? getPaneSessionIds(tab.root) : []; closeSplitTab(tabId); @@ -171,6 +178,11 @@ export default function TitleBar() { if (sessions.length <= ids.length) setActiveNav("hosts"); }; + const handleUnifiedTabClose = (e: React.MouseEvent, tabId: string) => { + e.stopPropagation(); + closeUnifiedTab(tabId); + }; + const handleDragRegionMouseDown = (e: React.MouseEvent) => { // Left button only — other buttons handed the gesture to the window manager, // which swallowed the release. @@ -328,6 +340,7 @@ export default function TitleBar() { + + + {pos && ( + clicked.push(target) }]} + pos={pos} + onClose={close} + /> + )} + + ); +} + +// The swallowed-right-click bug itself is a hit-testing one (the old backdrop +// covered the page), and jsdom's fireEvent dispatches straight to the node, so +// only the live app can prove that half. What is checked here is the close +// semantics the fix relies on: an open menu retargets instead of going stale. +test("right-clicking another target while a menu is open retargets the menu", () => { + render(); + + fireEvent.contextMenu(screen.getByText("target-a")); + expect(screen.getByText("item-a")).toBeTruthy(); + + fireEvent.contextMenu(screen.getByText("target-b")); + expect(screen.getByText("item-b")).toBeTruthy(); + expect(screen.queryByText("item-a")).toBeNull(); +}); + +test("a press outside closes the menu, and one inside still runs the entry", () => { + render(); + + fireEvent.contextMenu(screen.getByText("target-a")); + fireEvent.mouseDown(screen.getByText("outsider")); + expect(screen.queryByText("item-a")).toBeNull(); + + fireEvent.contextMenu(screen.getByText("target-a")); + const entry = screen.getByText("item-a"); + fireEvent.mouseDown(entry); + expect(screen.queryByText("item-a")).toBeTruthy(); + fireEvent.click(entry); + expect(clicked).toEqual(["a"]); + expect(screen.queryByText("item-a")).toBeNull(); +}); diff --git a/src/components/shared/ContextMenu.tsx b/src/components/shared/ContextMenu.tsx index e09ee9bef..075a78ebd 100644 --- a/src/components/shared/ContextMenu.tsx +++ b/src/components/shared/ContextMenu.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Icon } from "@iconify/react"; import { useUIStore } from "@/stores/uiStore"; @@ -135,6 +135,26 @@ interface ContextMenuProps { export function ContextMenu({ items, pos, onClose, direction = "down" }: ContextMenuProps) { const uiScale = useUIStore((s) => s.uiScale); + const menuRef = useRef(null); + + // Closing from window capture rather than behind a full-screen backdrop: the + // backdrop swallowed the next right-click, so with one menu open, right- + // clicking another target did nothing at all. Capture runs before React's own + // handlers and does not preventDefault, so the event still reaches whatever + // is under the pointer and that target opens its own menu in the same event. + useEffect(() => { + const onOutside = (e: MouseEvent) => { + const target = e.target; + if (target instanceof Element && (menuRef.current?.contains(target) || target.closest("[data-menu-portal]"))) return; + onClose(); + }; + window.addEventListener("mousedown", onOutside, true); + window.addEventListener("contextmenu", onOutside, true); + return () => { + window.removeEventListener("mousedown", onOutside, true); + window.removeEventListener("contextmenu", onOutside, true); + }; + }, [onClose]); const maxHeight = direction === "up" ? pos.y - 8 : window.innerHeight - pos.y - 8; @@ -143,23 +163,18 @@ export function ContextMenu({ items, pos, onClose, direction = "down" }: Context : { top: pos.y, transformOrigin: "top left" }; return createPortal( - <> - {/* Backdrop at z-99: catches outside clicks without interfering with - submenu portals at z-101. useClickOutside on mousedown was causing - submenus to unmount before onClick fired — backdrop avoids that. */} -
-
- -
- , +
+ +
, document.body, ); } diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json index 7dbfe1290..b6c2aba60 100644 --- a/src/i18n/locales/en/layout.json +++ b/src/i18n/locales/en/layout.json @@ -184,6 +184,15 @@ "close": "Close", "newSession": "New session", "closeTab": "Close tab", + "splitMenu": { + "broadcastOn": "Broadcast input to all panes", + "broadcastOff": "Stop broadcasting input", + "panes": "Panes", + "pane": "{{index}} · {{name}}", + "splitApart": "Split apart into tabs", + "closeTab_one": "Close tab ({{count}} session)", + "closeTab_other": "Close tab ({{count}} sessions)" + }, "plan": { "proTrial": "Pro Trial — {{daysLeft}}d left", "teams": "Teams", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index 8ab908987..4c9c8a533 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -184,6 +184,15 @@ "close": "Fermer", "newSession": "Nouvelle session", "closeTab": "Fermer l'onglet", + "splitMenu": { + "broadcastOn": "Diffuser la saisie dans tous les panneaux", + "broadcastOff": "Arrêter la diffusion de la saisie", + "panes": "Panneaux", + "pane": "{{index}} · {{name}}", + "splitApart": "Séparer en onglets", + "closeTab_one": "Fermer l'onglet ({{count}} session)", + "closeTab_other": "Fermer l'onglet ({{count}} sessions)" + }, "plan": { "proTrial": "Essai Pro — {{daysLeft}}j restants", "teams": "Teams", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index 1f065f3da..59d7b5808 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -184,6 +184,16 @@ "close": "Закрыть", "newSession": "Новая сессия", "closeTab": "Закрыть вкладку", + "splitMenu": { + "broadcastOn": "Транслировать ввод во все панели", + "broadcastOff": "Остановить трансляцию ввода", + "panes": "Панели", + "pane": "{{index}} · {{name}}", + "splitApart": "Разделить на вкладки", + "closeTab_one": "Закрыть вкладку ({{count}} сессия)", + "closeTab_few": "Закрыть вкладку ({{count}} сессии)", + "closeTab_many": "Закрыть вкладку ({{count}} сессий)" + }, "plan": { "proTrial": "Pro триал — осталось {{daysLeft}} дн.", "teams": "Teams", diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json index 627071239..cd52b08a1 100644 --- a/src/i18n/locales/zh/layout.json +++ b/src/i18n/locales/zh/layout.json @@ -184,6 +184,15 @@ "close": "关闭", "newSession": "新建会话", "closeTab": "关闭标签页", + "splitMenu": { + "broadcastOn": "向所有窗格广播输入", + "broadcastOff": "停止广播输入", + "panes": "窗格", + "pane": "{{index}} · {{name}}", + "splitApart": "拆分为独立标签页", + "closeTab_one": "关闭标签页({{count}} 个会话)", + "closeTab_other": "关闭标签页({{count}} 个会话)" + }, "plan": { "proTrial": "Pro 试用——剩余 {{daysLeft}} 天", "teams": "Teams", diff --git a/src/stores/layoutStore.ts b/src/stores/layoutStore.ts index 5c6262fc7..ae2fc8bf8 100644 --- a/src/stores/layoutStore.ts +++ b/src/stores/layoutStore.ts @@ -70,10 +70,14 @@ const newPaneId = () => `pane-${crypto.randomUUID()}`; const newSplitId = () => `split-${crypto.randomUUID()}`; const newSplitTabId = () => `split-tab-${crypto.randomUUID()}`; -export function getPaneSessionIds(root: PaneNode | null): string[] { +export function getPaneLeaves(root: PaneNode | null): LeafNode[] { if (!root) return []; - if (root.type === "leaf") return [root.sessionId]; - return [...getPaneSessionIds(root.first), ...getPaneSessionIds(root.second)]; + if (root.type === "leaf") return [root]; + return [...getPaneLeaves(root.first), ...getPaneLeaves(root.second)]; +} + +export function getPaneSessionIds(root: PaneNode | null): string[] { + return getPaneLeaves(root).map((leaf) => leaf.sessionId); } /** diff --git a/src/utils/splitTabMenuItems.test.ts b/src/utils/splitTabMenuItems.test.ts new file mode 100644 index 000000000..3430b70e0 --- /dev/null +++ b/src/utils/splitTabMenuItems.test.ts @@ -0,0 +1,88 @@ +import { test, expect, vi, beforeEach } from "vitest"; +import { useLayoutStore, type SplitTab } from "@/stores/layoutStore"; +import type { TerminalSession } from "@/types"; + +const { sessions } = vi.hoisted(() => ({ sessions: [] as TerminalSession[] })); +vi.mock("@/stores/sessionStore", () => ({ useSessionStore: { getState: () => ({ sessions }) } })); + +import { splitTabMenuItems } from "./splitTabMenuItems"; + +const session = (id: string, name: string): TerminalSession => + ({ id, connectionId: "c1", connectionName: name, status: "connected", type: "local" }); + +const t = ((key: string, opts?: { count?: number; index?: number; name?: string }) => { + if (opts?.count !== undefined) return `${key}:${opts.count}`; + if (opts?.index !== undefined) return `${key}:${opts.index}:${opts.name}`; + return key; +}) as never; +const onFocusPane = vi.fn(); +const onClose = vi.fn(); + +const build = (over: Partial = {}) => { + const tab: SplitTab = { + id: "tab-1", + root: { + type: "split", id: "split-1", direction: "h", ratio: 0.5, + first: { type: "leaf", id: "pane-a", sessionId: "s1" }, + second: { type: "leaf", id: "pane-b", sessionId: "s2" }, + }, + activePaneId: "pane-a", + maximizedPaneId: null, + broadcastActive: false, + ...over, + }; + return { tab, items: splitTabMenuItems({ tab, t, onFocusPane, onClose }) }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + sessions.splice(0, sessions.length, session("s1", "bash"), session("s2", "zsh")); + useLayoutStore.setState({ root: null, activePaneId: null, maximizedPaneId: null, broadcastActive: false, splitTabActive: false, splitTabs: [], activeSplitTabId: null, titlebarOrder: [] }); +}); + +test("entries are tab-scoped, and the close entry counts the sessions it takes down", () => { + const { items } = build(); + expect(items.map((i) => i.label)).toEqual([ + "layout.titleBar.splitMenu.broadcastOn", + "layout.titleBar.splitMenu.panes", + "layout.titleBar.splitMenu.splitApart", + "layout.titleBar.splitMenu.closeTab:2", + ]); + expect(items[3].danger).toBe(true); + items[3].onClick!(); + expect(onClose).toHaveBeenCalled(); +}); + +test("the broadcast entry reflects the tab's own state and toggles it", () => { + const { tab, items } = build({ broadcastActive: true }); + useLayoutStore.setState({ splitTabs: [tab] }); + expect(items[0].label).toBe("layout.titleBar.splitMenu.broadcastOff"); + + items[0].onClick!(); + expect(useLayoutStore.getState().broadcastActive).toBe(false); + expect(useLayoutStore.getState().splitTabs[0].broadcastActive).toBe(false); +}); + +test("the panes submenu names every session and marks the active pane", () => { + const { items } = build(); + const panes = items[1].children!; + expect(panes.map((p) => p.label)).toEqual([ + "layout.titleBar.splitMenu.pane:1:bash", + "layout.titleBar.splitMenu.pane:2:zsh", + ]); + expect(panes[0].icon).toBe("lucide:dot"); + expect(panes[1].icon).toBe("lucide:square"); + + panes[1].onClick!(); + expect(onFocusPane).toHaveBeenCalledWith("pane-b"); +}); + +test("splitting apart detaches every pane, dissolving the tab", () => { + const { tab, items } = build(); + useLayoutStore.setState({ splitTabs: [tab], activeSplitTabId: null }); + + items[2].onClick!(); + + expect(useLayoutStore.getState().splitTabs).toEqual([]); + expect(useLayoutStore.getState().splitTabActive).toBe(false); +}); diff --git a/src/utils/splitTabMenuItems.ts b/src/utils/splitTabMenuItems.ts new file mode 100644 index 000000000..378273646 --- /dev/null +++ b/src/utils/splitTabMenuItems.ts @@ -0,0 +1,80 @@ +import type { TFunction } from "i18next"; +import type { ContextMenuItem } from "@/components/shared/ContextMenu"; +import { getPaneLeaves, useLayoutStore, type SplitTab } from "@/stores/layoutStore"; +import { useSessionStore } from "@/stores/sessionStore"; + +/** + * Tab-scope entries for a unified split tab. Everything that acts on a single + * session (duplicate, reconnect, close pane) stays on the pane header, where + * the target is unambiguous — a split tab holds several of them. + * + * Every layout mutation below targets the *active* split tab, so each one + * activates this tab first. + */ +export function splitTabMenuItems({ + tab, + t, + onFocusPane, + onClose, +}: { + tab: SplitTab; + t: TFunction; + /** Bring a pane of this tab to the front. */ + onFocusPane: (paneId: string) => void; + /** Close the tab and every session in it. */ + onClose: () => void; +}): ContextMenuItem[] { + const leaves = getPaneLeaves(tab.root); + const { sessions } = useSessionStore.getState(); + const paneLabel = (sessionId: string) => + sessions.find((session) => session.id === sessionId)?.connectionName ?? t("layout.titleBar.splitFallback"); + + return [ + { + label: tab.broadcastActive + ? t("layout.titleBar.splitMenu.broadcastOff") + : t("layout.titleBar.splitMenu.broadcastOn"), + icon: tab.broadcastActive ? "lucide:radio-tower" : "lucide:radio", + onClick: () => { + useLayoutStore.getState().activateSplitTab(tab.id); + useLayoutStore.getState().toggleBroadcast(); + }, + }, + { + label: t("layout.titleBar.splitMenu.panes"), + icon: "lucide:layout-dashboard", + // Numbered because a split of two shells on the same host would otherwise + // list the same label twice. + children: leaves.map((leaf, index) => ({ + label: t("layout.titleBar.splitMenu.pane", { index: index + 1, name: paneLabel(leaf.sessionId) }), + icon: leaf.id === tab.activePaneId ? "lucide:dot" : "lucide:square", + onClick: () => onFocusPane(leaf.id), + })), + }, + { + label: t("layout.titleBar.splitMenu.splitApart"), + icon: "lucide:between-horizontal-start", + onClick: () => splitApart(tab.id), + }, + { + label: t("layout.titleBar.splitMenu.closeTab", { count: leaves.length }), + icon: "lucide:x", + danger: true, + divider: true, + onClick: onClose, + }, + ]; +} + +/** + * Every pane becomes a tab of its own. Detaching the second-to-last pane + * dissolves the split tab and hands the survivor back to the titlebar, so the + * loop ends on a pane id the store no longer knows — a no-op detach. + */ +function splitApart(tabId: string): void { + const layout = useLayoutStore.getState(); + layout.activateSplitTab(tabId); + for (const leaf of getPaneLeaves(useLayoutStore.getState().root)) { + useLayoutStore.getState().detachPane(leaf.id); + } +}