Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions src/components/layout/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<string | null>(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<HTMLButtonElement>(null);
Expand Down Expand Up @@ -152,25 +154,35 @@ 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);
ids.forEach(closeSession);
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.
Expand Down Expand Up @@ -328,6 +340,7 @@ export default function TitleBar() {
<button
data-titlebar-key={item.key}
onClick={() => handleUnifiedTabClick(tab.id)}
onContextMenu={(e) => { setMenuTarget({ kind: "split", id: tab.id }); openTabMenu(e); }}
onPointerDown={(e) => {
if (e.button === 0) useDragStore.getState().beginSplitTabDrag(tab.id, e.clientX, e.clientY);
if (e.button === 1) { e.preventDefault(); handleUnifiedTabClose(e, tab.id); }
Expand Down Expand Up @@ -379,7 +392,7 @@ export default function TitleBar() {
<button
data-titlebar-key={item.key}
onClick={() => handleTabClick(session.id)}
onContextMenu={(e) => { setMenuSessionId(session.id); openTabMenu(e); }}
onContextMenu={(e) => { setMenuTarget({ kind: "session", id: session.id }); openTabMenu(e); }}
onPointerDown={(e) => {
if (e.button === 0) useDragStore.getState().beginTabDrag(session.id, e.clientX, e.clientY, item.key);
if (e.button === 1) { e.preventDefault(); handleTabClose(e, session.id); }
Expand Down Expand Up @@ -450,14 +463,21 @@ export default function TitleBar() {
</div>
</div>

{tabMenuPos && menuSession && (
{tabMenuPos && (menuSession || menuSplitTab) && (
<ContextMenu
items={sessionMenuItems({
session: menuSession,
t,
closeLabel: t("layout.titleBar.closeTab"),
onClose: () => closeTabById(menuSession.id),
})}
items={menuSession
? sessionMenuItems({
session: menuSession,
t,
closeLabel: t("layout.titleBar.closeTab"),
onClose: () => closeTabById(menuSession.id),
})
: splitTabMenuItems({
tab: menuSplitTab!,
t,
onFocusPane: (paneId) => handleUnifiedTabClick(menuSplitTab!.id, paneId),
onClose: () => closeUnifiedTab(menuSplitTab!.id),
})}
pos={tabMenuPos}
onClose={closeTabMenu}
/>
Expand Down
60 changes: 60 additions & 0 deletions src/components/shared/ContextMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState } from "react";
import { test, expect, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ContextMenu, useContextMenu } from "./ContextMenu";

// vitest.config.ts sets no `globals: true`, so testing-library's automatic
// cleanup never registers; unmount explicitly between tests.
afterEach(() => cleanup());

const clicked: string[] = [];

function TwoTargets() {
const { pos, open, close } = useContextMenu();
const [target, setTarget] = useState("");
return (
<>
<button onContextMenu={(e) => { setTarget("a"); open(e); }}>target-a</button>
<button onContextMenu={(e) => { setTarget("b"); open(e); }}>target-b</button>
<button>outsider</button>
{pos && (
<ContextMenu
items={[{ label: `item-${target}`, onClick: () => 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(<TwoTargets />);

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(<TwoTargets />);

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();
});
51 changes: 33 additions & 18 deletions src/components/shared/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -135,6 +135,26 @@ interface ContextMenuProps {

export function ContextMenu({ items, pos, onClose, direction = "down" }: ContextMenuProps) {
const uiScale = useUIStore((s) => s.uiScale);
const menuRef = useRef<HTMLDivElement>(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;

Expand All @@ -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. */}
<div className="fixed inset-0 z-99" onMouseDown={onClose} />
<div
className="surface-float fixed z-100 p-1.5 flex flex-col min-w-[12.667rem] overflow-y-auto"
style={{
left: pos.x,
maxHeight,
transform: `scale(${uiScale})`,
...placement,
}}
>
<MenuItemList items={items} onClose={onClose} />
</div>
</>,
<div
ref={menuRef}
className="surface-float fixed z-100 p-1.5 flex flex-col min-w-[12.667rem] overflow-y-auto"
style={{
left: pos.x,
maxHeight,
transform: `scale(${uiScale})`,
...placement,
}}
>
<MenuItemList items={items} onClose={onClose} />
</div>,
document.body,
);
}
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/en/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/fr/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/locales/ru/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/zh/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions src/stores/layoutStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Loading