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,