From b1ea940f6db0f4499e05f484d437f4455807d395 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 08:54:52 +0000 Subject: [PATCH] fix(ui): keep card menus whole at the window edge, fold active forwards (#610) A card's "more options" menu opened under its button and was given only the height left below it. On a card near the bottom of the window that left a sliver showing one entry, or nothing at all. The menu was already moved sideways to fit the window; it is now moved vertically the same way, through the same helper, so it slides up until every entry is visible. Hosts, keys, snippets, rules, members and known hosts share this menu and all get the fix. The terminal status bar menu opens upward and had the mirror problem in the top pane of a split: it shrank to the space above the pointer. It goes through the same fit now and is pushed down instead. Where there is room it still ends at the pointer. On the Port Forwarding page the "Active session forwards" header folds the host cards away, so a host with many detected ports no longer pushes the saved rules off the page. The choice is kept on the device. The header keeps the host count and tunnel total, and shows an error count when a forward failed so a folded section cannot hide it. --- .../ActiveTunnelsSection.identity.test.tsx | 34 --------- .../ActiveTunnelsSection.test.tsx | 75 +++++++++++++++++++ .../port_forwarding/ActiveTunnelsSection.tsx | 14 +++- src/components/shared/ContextMenu.test.tsx | 18 +++++ src/components/shared/ContextMenu.tsx | 25 ++++--- src/stores/uiStore.ts | 5 ++ 6 files changed, 123 insertions(+), 48 deletions(-) delete mode 100644 src/components/port_forwarding/ActiveTunnelsSection.identity.test.tsx create mode 100644 src/components/port_forwarding/ActiveTunnelsSection.test.tsx diff --git a/src/components/port_forwarding/ActiveTunnelsSection.identity.test.tsx b/src/components/port_forwarding/ActiveTunnelsSection.identity.test.tsx deleted file mode 100644 index 9101236ad..000000000 --- a/src/components/port_forwarding/ActiveTunnelsSection.identity.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { test, expect, afterEach, vi } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; -import type { Connection, TerminalSession } from "@/types"; - -vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) })); -vi.mock("@iconify/react", () => ({ Icon: () => null })); -vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); - -const h = vi.hoisted(() => ({ session: null as unknown as TerminalSession })); -const host = { id: "c1", name: "web-1", host: "web-01", port: 2222, username: "root", connection_type: "ssh" } as unknown as Connection; - -vi.mock("@/hooks/useAllConnections", () => ({ useAllConnections: () => [host] })); -vi.mock("@/hooks/usePfStates", () => ({ - useConnectedSshPfStates: () => ({ sessions: [h.session], pfStates: new Map([["s1", { tunnels: [], suppressed_ports: [8080] }]]) }), -})); - -import { ActiveTunnelsSection } from "./ActiveTunnelsSection"; - -afterEach(cleanup); - -const sessionOf = (extra: Partial) => - ({ id: "s1", connectionId: "c1", connectionName: "web-1", status: "connected", type: "ssh", ...extra }) as TerminalSession; - -test("the card shows the user the session connected as", () => { - h.session = sessionOf({ connectedUsername: "alice" }); - render(); - expect(screen.getByText("alice@web-01:2222")).toBeTruthy(); -}); - -test("without a recorded user it shows the host's, as before", () => { - h.session = sessionOf({}); - render(); - expect(screen.getByText("root@web-01:2222")).toBeTruthy(); -}); diff --git a/src/components/port_forwarding/ActiveTunnelsSection.test.tsx b/src/components/port_forwarding/ActiveTunnelsSection.test.tsx new file mode 100644 index 000000000..4dda36247 --- /dev/null +++ b/src/components/port_forwarding/ActiveTunnelsSection.test.tsx @@ -0,0 +1,75 @@ +import { test, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import type { ActiveTunnel, Connection, TerminalSession } from "@/types"; + +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); + +const h = vi.hoisted(() => ({ session: null as unknown as TerminalSession, tunnels: [] as ActiveTunnel[] })); +const host = { id: "c1", name: "web-1", host: "web-01", port: 2222, username: "root", connection_type: "ssh" } as unknown as Connection; + +vi.mock("@/hooks/useAllConnections", () => ({ useAllConnections: () => [host] })); +vi.mock("@/hooks/usePfStates", () => ({ + useConnectedSshPfStates: () => ({ sessions: [h.session], pfStates: new Map([["s1", { tunnels: h.tunnels, suppressed_ports: [8080] }]]) }), +})); + +import { ActiveTunnelsSection } from "./ActiveTunnelsSection"; +import { useUIStore } from "@/stores/uiStore"; + +afterEach(() => { + cleanup(); + h.tunnels = []; + useUIStore.setState({ activeTunnelsCollapsed: false }); +}); + +const sessionOf = (extra: Partial) => + ({ id: "s1", connectionId: "c1", connectionName: "web-1", status: "connected", type: "ssh", ...extra }) as TerminalSession; + +test("the card shows the user the session connected as", () => { + h.session = sessionOf({ connectedUsername: "alice" }); + render(); + expect(screen.getByText("alice@web-01:2222")).toBeTruthy(); +}); + +test("without a recorded user it shows the host's, as before", () => { + h.session = sessionOf({}); + render(); + expect(screen.getByText("root@web-01:2222")).toBeTruthy(); +}); + +test("the section header folds the forwards away and keeps the totals in view", () => { + h.session = sessionOf({}); + render(); + const header = screen.getByText("portForwarding.activeTunnels.sectionTitle"); + fireEvent.click(header); + expect(screen.queryByText("root@web-01:2222")).toBeNull(); + expect(screen.getByText("portForwarding.activeTunnels.tunnelCount")).toBeTruthy(); + expect(useUIStore.getState().activeTunnelsCollapsed).toBe(true); + + fireEvent.click(header); + expect(screen.getByText("root@web-01:2222")).toBeTruthy(); +}); + +test("a folded section stays folded the next time the page opens", () => { + h.session = sessionOf({}); + useUIStore.setState({ activeTunnelsCollapsed: true }); + render(); + expect(screen.queryByText("root@web-01:2222")).toBeNull(); +}); + +test("a folded section still says a forward failed", () => { + h.session = sessionOf({}); + h.tunnels = [{ id: "t1", remote_port: 5432, local_port: 5432, origin: { type: "auto" }, state: { error: "address in use" } } as ActiveTunnel]; + useUIStore.setState({ activeTunnelsCollapsed: true }); + render(); + expect(screen.queryByText("address in use")).toBeNull(); + expect(screen.getByText("portForwarding.activeTunnels.errorCount")).toBeTruthy(); +}); + +test("a section with no failed forward shows no error count", () => { + h.session = sessionOf({}); + useUIStore.setState({ activeTunnelsCollapsed: true }); + render(); + expect(screen.queryByText("portForwarding.activeTunnels.errorCount")).toBeNull(); +}); diff --git a/src/components/port_forwarding/ActiveTunnelsSection.tsx b/src/components/port_forwarding/ActiveTunnelsSection.tsx index e6802bdde..02e6242b2 100644 --- a/src/components/port_forwarding/ActiveTunnelsSection.tsx +++ b/src/components/port_forwarding/ActiveTunnelsSection.tsx @@ -36,6 +36,8 @@ export function ActiveTunnelsSection() { const connections = useAllConnections(); const { sessions: relevantSessions, pfStates: pfStateMap } = useConnectedSshPfStates(); const layoutMode = useUIStore((s) => s.portForwardingLayoutMode); + const collapsed = useUIStore((s) => s.activeTunnelsCollapsed); + const toggleCollapsed = useUIStore((s) => s.toggleActiveTunnelsCollapsed); const [busy, setBusy] = useState>(new Set()); const [hiddenPorts, setHiddenPorts] = useState>(new Set()); @@ -66,6 +68,7 @@ export function ActiveTunnelsSection() { }, [hostSessions, connections, pfStateMap, hiddenPorts]); const totalTunnelCount = sessionCards.reduce((sum, card) => sum + card.tunnels.length + card.suppressedPorts.length, 0); + const totalErrorCount = sessionCards.reduce((sum, card) => sum + card.errorCount, 0); if (totalTunnelCount === 0) return null; @@ -112,10 +115,15 @@ export function ActiveTunnelsSection() { {t("portForwarding.activeTunnels.tunnelCount", { count: totalTunnelCount })}} + aside={<> + {totalErrorCount > 0 && {t("portForwarding.activeTunnels.errorCount", { count: totalErrorCount })}} + {t("portForwarding.activeTunnels.tunnelCount", { count: totalTunnelCount })} + } + collapsed={collapsed} + onToggle={toggleCollapsed} /> -
+ {!collapsed &&
{sessionCards.map(({ session, connection, tunnels, suppressedPorts, errorCount }) => { const displayIcon = connection ? (connection.icon || connection.distro) : null; const distroIcon = displayIcon ? getConnectionIcon(displayIcon) : null; @@ -268,7 +276,7 @@ export function ActiveTunnelsSection() {
); })} -
+ } ); } diff --git a/src/components/shared/ContextMenu.test.tsx b/src/components/shared/ContextMenu.test.tsx index a232742f7..535eaaf7e 100644 --- a/src/components/shared/ContextMenu.test.tsx +++ b/src/components/shared/ContextMenu.test.tsx @@ -87,6 +87,24 @@ test("a root menu opened near the right edge renders inside the window", () => { } }); +test.each([ + ["a menu opened low in the window is shifted up instead of squeezed", "down", 560, 292], + ["an upward menu ends at the pointer when there is room above", "up", 560, 260], + ["an upward menu opened high in the window is pushed down instead of squeezed", "up", 120, 8], +] as const)("%s", (_label, direction, y, expected) => { + const height = window.innerHeight; + window.innerHeight = 600; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ width: 190, height: 300 } as DOMRect); + try { + render( {} }]} pos={{ x: 40, y }} onClose={() => {}} direction={direction} />); + const menu = screen.getByText("Edit").closest("[data-menu-portal]") as HTMLElement; + expect(menu.style.top).toBe(`${expected}px`); + expect(menu.style.maxHeight).toBe("584px"); + } finally { + window.innerHeight = height; + } +}); + test("labels and hints never wrap, so the measured width is the final width", () => { render( {} }]} pos={{ x: 0, y: 0 }} onClose={() => {}} />); expect(screen.getByText("A long label").className).toContain("whitespace-nowrap"); diff --git a/src/components/shared/ContextMenu.tsx b/src/components/shared/ContextMenu.tsx index a6a6297c7..08658dde4 100644 --- a/src/components/shared/ContextMenu.tsx +++ b/src/components/shared/ContextMenu.tsx @@ -139,14 +139,20 @@ function useMeasuredSize( return size; } +function viewportMaxHeight(scale = 1): number { + return (window.innerHeight - 2 * VIEWPORT_MARGIN) / scale; +} + function useFittedStart( ref: React.RefObject, - start: number, + anchor: number, axis: "width" | "height", deps: unknown[], + endsAtAnchor = false, ): number { const size = useMeasuredSize(ref, axis, deps); - if (size === null) return start; + if (size === null) return anchor; + const start = endsAtAnchor ? Math.max(VIEWPORT_MARGIN, anchor - size) : anchor; return fitWithin(start, size, axis === "width" ? window.innerWidth : window.innerHeight); } @@ -176,7 +182,7 @@ function SubmenuPanel({ row, onMouseEnter, onMouseLeave, children }: { // PickerSurface treats this marker as inside-click; z-index keeps a flipped submenu above its parent. data-menu-portal="" className="surface-float fixed z-10000 p-1.5 flex flex-col min-w-[12.667rem] overflow-y-auto" - style={{ left, top, maxHeight: window.innerHeight - 2 * VIEWPORT_MARGIN }} + style={{ left, top, maxHeight: viewportMaxHeight() }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} > @@ -196,6 +202,7 @@ export function ContextMenu({ items, pos, onClose, direction = "down" }: Context const uiScale = useUIStore((s) => s.uiScale); const menuRef = useRef(null); const left = useFittedStart(menuRef, pos.x, "width", [uiScale, items]); + const top = useFittedStart(menuRef, pos.y, "height", [uiScale, items], direction === "up"); // Closing from window capture rather than behind a full-screen backdrop: the // backdrop swallowed the next right-click, so with one menu open, right- @@ -216,12 +223,6 @@ export function ContextMenu({ items, pos, onClose, direction = "down" }: Context }; }, [onClose]); - const maxHeight = direction === "up" ? pos.y - 8 : window.innerHeight - pos.y - 8; - - const placement = direction === "up" - ? { bottom: window.innerHeight - pos.y, transformOrigin: "bottom left" } - : { top: pos.y, transformOrigin: "top left" }; - return createPortal(
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 96dcc3fd0..e80f87557 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -124,6 +124,7 @@ interface UIStore { keychainSortMode: SortMode; homePendingAction: HomePendingAction; portForwardingLayoutMode: LayoutMode; + activeTunnelsCollapsed: boolean; portForwardingSortMode: SortMode; membersLayoutMode: LayoutMode; membersSortMode: SortMode; @@ -173,6 +174,7 @@ interface UIStore { setKeychainSortMode: (v: SortMode) => void; setHomePendingAction: (action: HomePendingAction) => void; setPortForwardingLayoutMode: (v: LayoutMode) => void; + toggleActiveTunnelsCollapsed: () => void; setPortForwardingSortMode: (v: SortMode) => void; setPortForwardingPendingAction: (action: PortForwardingPendingAction) => void; setKeychainPendingAction: (action: KeychainPendingAction) => void; @@ -237,6 +239,7 @@ export const useUIStore = create()( keychainSortMode: "newest" as SortMode, homePendingAction: null as HomePendingAction, portForwardingLayoutMode: "list" as LayoutMode, + activeTunnelsCollapsed: false, portForwardingSortMode: "newest" as SortMode, portForwardingPendingAction: null as PortForwardingPendingAction, membersLayoutMode: "list" as LayoutMode, @@ -303,6 +306,7 @@ export const useUIStore = create()( setKeychainSortMode: (v) => setPref({ keychainSortMode: v }), setHomePendingAction: (action) => set({ homePendingAction: action }), setPortForwardingLayoutMode: (v) => setPref({ portForwardingLayoutMode: v }), + toggleActiveTunnelsCollapsed: () => set((s) => ({ activeTunnelsCollapsed: !s.activeTunnelsCollapsed })), setPortForwardingSortMode: (v) => setPref({ portForwardingSortMode: v }), setPortForwardingPendingAction: (action) => set({ portForwardingPendingAction: action }), setKeychainPendingAction: (action) => set({ keychainPendingAction: action }), @@ -347,6 +351,7 @@ export const useUIStore = create()( keychainLayoutMode: state.keychainLayoutMode, keychainSortMode: state.keychainSortMode, portForwardingLayoutMode: state.portForwardingLayoutMode, + activeTunnelsCollapsed: state.activeTunnelsCollapsed, portForwardingSortMode: state.portForwardingSortMode, membersLayoutMode: state.membersLayoutMode, membersSortMode: state.membersSortMode,