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

This file was deleted.

75 changes: 75 additions & 0 deletions src/components/port_forwarding/ActiveTunnelsSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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<TerminalSession>) =>
({ 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(<ActiveTunnelsSection />);
expect(screen.getByText("alice@web-01:2222")).toBeTruthy();
});

test("without a recorded user it shows the host's, as before", () => {
h.session = sessionOf({});
render(<ActiveTunnelsSection />);
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(<ActiveTunnelsSection />);
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(<ActiveTunnelsSection />);
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(<ActiveTunnelsSection />);
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(<ActiveTunnelsSection />);
expect(screen.queryByText("portForwarding.activeTunnels.errorCount")).toBeNull();
});
14 changes: 11 additions & 3 deletions src/components/port_forwarding/ActiveTunnelsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>(new Set());
const [hiddenPorts, setHiddenPorts] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -112,10 +115,15 @@ export function ActiveTunnelsSection() {
<SectionHeader
label={t("portForwarding.activeTunnels.sectionTitle")}
count={sessionCards.length}
aside={<StatusPill tone="connected">{t("portForwarding.activeTunnels.tunnelCount", { count: totalTunnelCount })}</StatusPill>}
aside={<>
{totalErrorCount > 0 && <StatusPill tone="error">{t("portForwarding.activeTunnels.errorCount", { count: totalErrorCount })}</StatusPill>}
<StatusPill tone="connected">{t("portForwarding.activeTunnels.tunnelCount", { count: totalTunnelCount })}</StatusPill>
</>}
collapsed={collapsed}
onToggle={toggleCollapsed}
/>

<div {...cardGridProps(layoutMode, "wide")}>
{!collapsed && <div {...cardGridProps(layoutMode, "wide")}>
{sessionCards.map(({ session, connection, tunnels, suppressedPorts, errorCount }) => {
const displayIcon = connection ? (connection.icon || connection.distro) : null;
const distroIcon = displayIcon ? getConnectionIcon(displayIcon) : null;
Expand Down Expand Up @@ -268,7 +276,7 @@ export function ActiveTunnelsSection() {
</div>
);
})}
</div>
</div>}
</div>
);
}
18 changes: 18 additions & 0 deletions src/components/shared/ContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ContextMenu items={[{ label: "Edit", onClick: () => {} }]} 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(<ContextMenu items={[{ label: "A long label", hint: "a hint", onClick: () => {} }]} pos={{ x: 0, y: 0 }} onClose={() => {}} />);
expect(screen.getByText("A long label").className).toContain("whitespace-nowrap");
Expand Down
25 changes: 14 additions & 11 deletions src/components/shared/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>,
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);
}

Expand Down Expand Up @@ -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}
>
Expand All @@ -196,6 +202,7 @@ export function ContextMenu({ items, pos, onClose, direction = "down" }: Context
const uiScale = useUIStore((s) => s.uiScale);
const menuRef = useRef<HTMLDivElement>(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-
Expand All @@ -216,22 +223,18 @@ 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(
<div
ref={menuRef}
data-menu-portal=""
className="surface-float fixed z-100 p-1.5 flex flex-col min-w-[12.667rem] overflow-y-auto"
style={{
left,
maxHeight,
top,
maxHeight: viewportMaxHeight(uiScale),
// The measured size is the scaled one, so the fitted top only holds from this origin.
transform: `scale(${uiScale})`,
...placement,
transformOrigin: "top left",
}}
>
<MenuItemList items={items} onClose={onClose} />
Expand Down
5 changes: 5 additions & 0 deletions src/stores/uiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ interface UIStore {
keychainSortMode: SortMode;
homePendingAction: HomePendingAction;
portForwardingLayoutMode: LayoutMode;
activeTunnelsCollapsed: boolean;
portForwardingSortMode: SortMode;
membersLayoutMode: LayoutMode;
membersSortMode: SortMode;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -237,6 +239,7 @@ export const useUIStore = create<UIStore>()(
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,
Expand Down Expand Up @@ -303,6 +306,7 @@ export const useUIStore = create<UIStore>()(
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 }),
Expand Down Expand Up @@ -347,6 +351,7 @@ export const useUIStore = create<UIStore>()(
keychainLayoutMode: state.keychainLayoutMode,
keychainSortMode: state.keychainSortMode,
portForwardingLayoutMode: state.portForwardingLayoutMode,
activeTunnelsCollapsed: state.activeTunnelsCollapsed,
portForwardingSortMode: state.portForwardingSortMode,
membersLayoutMode: state.membersLayoutMode,
membersSortMode: state.membersSortMode,
Expand Down
Loading