diff --git a/site/src/hooks/useIsBelowMdViewport.ts b/site/src/hooks/useIsBelowMdViewport.ts new file mode 100644 index 0000000000..b6783bec3f --- /dev/null +++ b/site/src/hooks/useIsBelowMdViewport.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from "react"; +import { belowMdViewportMediaQuery, isBelowMdViewport } from "#/utils/mobile"; + +const subscribeBelowMdViewport = (onStoreChange: () => void) => { + const mediaQuery = window.matchMedia(belowMdViewportMediaQuery); + mediaQuery.addEventListener("change", onStoreChange); + return () => mediaQuery.removeEventListener("change", onStoreChange); +}; + +export const useIsBelowMdViewport = (): boolean => { + return useSyncExternalStore(subscribeBelowMdViewport, isBelowMdViewport); +}; diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx index 062623f2ac..4793830886 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx @@ -478,3 +478,105 @@ export const EmptyPorts: Story = { }); }, }; + +const mobilePortsStoryConfig = { + args: { + ...defaultProps, + workspace: MockWorkspace, + agent: { + ...MockWorkspaceAgent, + name: "a-workspace-agent", + }, + }, + parameters: { + viewport: { defaultViewport: "mobile1" }, + chromatic: { viewports: [375] }, + queries: [ + { key: ["me", "apiKey"], data: { key: "mock-api-key" } }, + { + key: ["portForward", MockWorkspaceAgent.id], + data: MockListeningPortsResponse, + }, + { + key: ["sharedPorts", MockWorkspace.id], + data: MockSharedPortsResponse, + }, + ], + }, +} satisfies Partial; + +const openMobilePortsPanel = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + const pill = await canvas.findByRole("button", { + name: /workspace menu/, + }); + await userEvent.click(pill); + + const body = within(document.body); + const portsItem = await body.findByText(/Ports \(\d+\)/); + await userEvent.click(portsItem); + + return { body, pill }; +}; + +export const MobilePortsInlinePanel: Story = { + ...mobilePortsStoryConfig, + play: async ({ canvasElement }) => { + const { body, pill } = await openMobilePortsPanel(canvasElement); + + await waitFor(() => { + expect(body.getByText("Listening Ports")).toBeInTheDocument(); + expect(body.getByText("Shared Ports")).toBeInTheDocument(); + expect(body.getByText("Manage sharing")).toBeInTheDocument(); + expect(body.getByRole("menuitem", { name: /Back/ })).toHaveFocus(); + expect(body.queryByText("View Workspace")).not.toBeInTheDocument(); + }); + + const portsHeader = body.getByText("Listening Ports"); + const dropdown: HTMLElement | null = portsHeader.closest( + "[data-radix-popper-content-wrapper]", + ); + expect(dropdown).not.toBeNull(); + if (dropdown === null) { + throw new Error("Expected dropdown wrapper to exist"); + } + const rect = dropdown.getBoundingClientRect(); + expect(rect.right).toBeLessThanOrEqual(innerWidth); + expect(rect.left).toBeGreaterThanOrEqual(0); + + await userEvent.click(body.getByRole("menuitem", { name: /Back/ })); + await waitFor(() => { + expect(body.getByText("View Workspace")).toBeInTheDocument(); + expect(body.getByRole("menuitem", { name: /Ports/ })).toHaveFocus(); + expect(body.queryByText("Listening Ports")).not.toBeInTheDocument(); + }); + + await userEvent.click(body.getByText(/Ports \(\d+\)/)); + await waitFor(() => { + expect(body.getByText("Listening Ports")).toBeInTheDocument(); + expect(body.getByRole("menuitem", { name: /Back/ })).toHaveFocus(); + }); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect(body.queryByText("Listening Ports")).not.toBeInTheDocument(); + }); + + await userEvent.click(pill); + await waitFor(() => { + expect(body.getByText("View Workspace")).toBeInTheDocument(); + expect(body.queryByText("Listening Ports")).not.toBeInTheDocument(); + }); + }, +}; + +export const MobilePortsInlinePanelOpen: Story = { + ...mobilePortsStoryConfig, + play: async ({ canvasElement }) => { + const { body } = await openMobilePortsPanel(canvasElement); + + await waitFor(() => { + expect(body.getByText("Listening Ports")).toBeInTheDocument(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.tsx index e5032c3900..98168f21e4 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePill.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePill.tsx @@ -1,30 +1,21 @@ import { - BuildingIcon, ChevronDownIcon, CopyIcon, - ExternalLinkIcon, LayoutGridIcon, - LockIcon, - LockOpenIcon, MonitorIcon, - NetworkIcon, - RadioIcon, SquareTerminalIcon, UnlinkIcon, } from "lucide-react"; import type { FC } from "react"; -import { useState } from "react"; -import { useMutation, useQuery } from "react-query"; +import { useEffect, useState } from "react"; +import { useMutation } from "react-query"; import { Link } from "react-router"; import { toast } from "sonner"; import { API } from "#/api/api"; import { getErrorMessage } from "#/api/errors"; -import { workspacePortShares } from "#/api/queries/workspaceportsharing"; import type { Workspace, WorkspaceAgent, - WorkspaceAgentListeningPort, - WorkspaceAgentPortShare, WorkspaceApp, } from "#/api/typesGenerated"; import { @@ -32,9 +23,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; @@ -47,6 +35,7 @@ import { } from "#/components/Tooltip/Tooltip"; import { useProxy } from "#/contexts/ProxyContext"; import { useClipboard } from "#/hooks/useClipboard"; +import { useIsBelowMdViewport } from "#/hooks/useIsBelowMdViewport"; import { getTerminalHref, getVSCodeHref, @@ -56,11 +45,12 @@ import { } from "#/modules/apps/apps"; import { useAppLink } from "#/modules/apps/useAppLink"; import { cn } from "#/utils/cn"; -import { - getWorkspaceListeningPortsProtocol, - portForwardURL, -} from "#/utils/portForward"; import { getWorkspaceStatus, StatusIcon } from "./StatusIcon"; +import { + MobilePortsPanel, + PortsMenuItem, + usePortsData, +} from "./WorkspacePillPorts"; interface WorkspacePillProps { workspace: Workspace; @@ -108,8 +98,36 @@ export const WorkspacePill: FC = ({ hasTerminal || portForwardingEnabled; + // Flyout sub-menus clip on mobile. + const [view, setView] = useState<"main" | "ports">("main"); + const [focusPortsOnMain, setFocusPortsOnMain] = useState(false); + const isBelowMd = useIsBelowMdViewport(); + const showPortsView = view === "ports" && isBelowMd; + + const portsData = usePortsData( + workspace, + agent, + open && agent.status === "connected" && portForwardingEnabled, + ); + + useEffect(() => { + if (!isBelowMd && view === "ports") { + setView("main"); + setFocusPortsOnMain(false); + } + }, [isBelowMd, view]); + return ( - + { + setOpen(next); + if (!next) { + setView("main"); + setFocusPortsOnMain(false); + } + }} + > = ({ align="start" className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5" > - {hasVSCode && ( - - )} - {hasVSCodeInsiders && ( - - )} - {userApps.map((app) => ( - - ))} - {hasTerminal && ( - - )} - {portForwardingEnabled && ( - { + setFocusPortsOnMain(true); + setView("main"); + }} /> - )} - {hasItemsAboveSeparator && } - - {sshCommand && } - - - - View Workspace - - - {onRemoveWorkspace && ( + ) : ( <> - - - - Detach workspace + {hasVSCode && ( + + )} + {hasVSCodeInsiders && ( + + )} + {userApps.map((app) => ( + + ))} + {hasTerminal && ( + + )} + {portForwardingEnabled && ( + setFocusPortsOnMain(false)} + onSelectInline={() => { + setFocusPortsOnMain(false); + setView("ports"); + }} + /> + )} + {hasItemsAboveSeparator && ( + + )} + + {sshCommand && } + + + + View Workspace + + {onRemoveWorkspace && ( + <> + + + + Detach workspace + + + )} )} @@ -229,183 +271,6 @@ export const WorkspacePill: FC = ({ ); }; -const PortsSubMenuItem: FC<{ - workspace: Workspace; - agent: WorkspaceAgent; - host: string; - isOpen: boolean; - isRunning: boolean; -}> = ({ workspace, agent, host, isOpen, isRunning }) => { - const route = `/@${workspace.owner_name}/${workspace.name}`; - const isConnected = agent.status === "connected"; - const enabled = isOpen && isConnected; - - const protocol = getWorkspaceListeningPortsProtocol(workspace.id); - - const { data: listeningPorts } = useQuery({ - queryKey: ["portForward", agent.id], - queryFn: () => API.getAgentListeningPorts(agent.id), - enabled, - refetchInterval: enabled ? 5_000 : false, - staleTime: 0, - select: (res) => res.ports, - }); - - const { data: sharedPorts } = useQuery({ - ...workspacePortShares(workspace.id), - enabled, - staleTime: 0, - select: (res) => res.shares.filter((s) => s.agent_name === agent.name), - }); - - // Listening ports that haven't been explicitly shared appear in their own - // section; shared ports bubble up to the "Shared" section. - const sharedPortNumbers = new Set((sharedPorts ?? []).map((s) => s.port)); - const privateListeningPorts = (listeningPorts ?? []).filter( - (p) => !sharedPortNumbers.has(p.port), - ); - - const totalCount = - listeningPorts !== undefined ? listeningPorts.length : undefined; - - return ( - - - - {totalCount !== undefined ? `Ports (${totalCount})` : "Ports"} - - - {/* Listening Ports header: only render when there are ports to list. */} - {privateListeningPorts.length > 0 && ( -
- - Listening Ports - -
- )} - - {privateListeningPorts.map((port) => ( - - ))} - - {listeningPorts !== undefined && - sharedPorts !== undefined && - privateListeningPorts.length === 0 && - sharedPorts.length === 0 && ( -

- No open ports detected. -

- )} - - {/* Shared Ports */} - {(sharedPorts ?? []).length > 0 && ( - <> - -
- - Shared Ports - -
- {(sharedPorts ?? []).map((share) => ( - - ))} - - )} - - - - - - Manage sharing - - -
-
- ); -}; - -const ListeningPortItem: FC<{ - port: WorkspaceAgentListeningPort; - host: string; - agentName: string; - workspaceName: string; - ownerName: string; - protocol: "http" | "https"; -}> = ({ port, host, agentName, workspaceName, ownerName, protocol }) => { - const url = portForwardURL( - host, - port.port, - agentName, - workspaceName, - ownerName, - protocol, - ); - return ( - - - - {port.port} - {port.process_name !== "" && ( - - {port.process_name} - - )} - - - - ); -}; - -const SharedPortItem: FC<{ - share: WorkspaceAgentPortShare; - host: string; - agentName: string; - workspaceName: string; - ownerName: string; -}> = ({ share, host, agentName, workspaceName, ownerName }) => { - const url = portForwardURL( - host, - share.port, - agentName, - workspaceName, - ownerName, - share.protocol, - ); - const ShareIcon = - share.share_level === "public" - ? LockOpenIcon - : share.share_level === "organization" - ? BuildingIcon - : LockIcon; - return ( - - - - {share.port} - - {share.share_level} - - - - - ); -}; - const VSCodeMenuItem: FC<{ variant: "vscode" | "vscode-insiders"; label: string; diff --git a/site/src/pages/AgentsPage/components/WorkspacePillPorts.tsx b/site/src/pages/AgentsPage/components/WorkspacePillPorts.tsx new file mode 100644 index 0000000000..2d4a6f8742 --- /dev/null +++ b/site/src/pages/AgentsPage/components/WorkspacePillPorts.tsx @@ -0,0 +1,329 @@ +import { + ArrowLeftIcon, + BuildingIcon, + ChevronRightIcon, + ExternalLinkIcon, + LockIcon, + LockOpenIcon, + NetworkIcon, + RadioIcon, +} from "lucide-react"; +import type { FC } from "react"; +import { useEffect, useRef } from "react"; +import { useQuery } from "react-query"; +import { Link } from "react-router"; +import { API } from "#/api/api"; +import { workspacePortShares } from "#/api/queries/workspaceportsharing"; +import type { + Workspace, + WorkspaceAgent, + WorkspaceAgentListeningPort, + WorkspaceAgentPortShare, +} from "#/api/typesGenerated"; +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { + getWorkspaceListeningPortsProtocol, + portForwardURL, +} from "#/utils/portForward"; + +interface PortsData { + listeningPorts: readonly WorkspaceAgentListeningPort[] | undefined; + sharedPorts: readonly WorkspaceAgentPortShare[] | undefined; + privateListeningPorts: readonly WorkspaceAgentListeningPort[]; + totalCount: number | undefined; + protocol: "http" | "https"; +} + +export const usePortsData = ( + workspace: Workspace, + agent: WorkspaceAgent, + enabled: boolean, +): PortsData => { + const protocol = getWorkspaceListeningPortsProtocol(workspace.id); + + const { data: listeningPorts } = useQuery({ + queryKey: ["portForward", agent.id], + queryFn: () => API.getAgentListeningPorts(agent.id), + enabled, + refetchInterval: enabled ? 5_000 : false, + staleTime: 0, + select: (res) => res.ports, + }); + + const { data: sharedPorts } = useQuery({ + ...workspacePortShares(workspace.id), + enabled, + staleTime: 0, + select: (res) => res.shares.filter((s) => s.agent_name === agent.name), + }); + + // Listening ports that haven't been explicitly shared appear in their own + // section; shared ports bubble up to the "Shared" section. + const sharedPortNumbers = new Set((sharedPorts ?? []).map((s) => s.port)); + const privateListeningPorts = (listeningPorts ?? []).filter( + (p) => !sharedPortNumbers.has(p.port), + ); + + const totalCount = + listeningPorts !== undefined ? listeningPorts.length : undefined; + + return { + listeningPorts, + sharedPorts, + privateListeningPorts, + totalCount, + protocol, + }; +}; + +export const PortsMenuItem: FC<{ + workspace: Workspace; + agent: WorkspaceAgent; + host: string; + portsData: PortsData; + isRunning: boolean; + isBelowMd: boolean; + focusOnMount: boolean; + onFocusApplied: () => void; + onSelectInline: () => void; +}> = ({ + workspace, + agent, + host, + portsData, + isRunning, + isBelowMd, + focusOnMount, + onFocusApplied, + onSelectInline, +}) => { + const itemRef = useRef(null); + + const label = + portsData.totalCount !== undefined + ? `Ports (${portsData.totalCount})` + : "Ports"; + + useEffect(() => { + if (!focusOnMount || !isBelowMd) { + return; + } + itemRef.current?.focus(); + onFocusApplied(); + }, [focusOnMount, isBelowMd, onFocusApplied]); + + if (isBelowMd) { + return ( + { + event.preventDefault(); + onSelectInline(); + }} + > + + {label} + + + ); + } + + return ( + + + + {label} + + + + + + ); +}; + +export const MobilePortsPanel: FC<{ + workspace: Workspace; + agent: WorkspaceAgent; + host: string; + portsData: PortsData; + onBack: () => void; +}> = ({ workspace, agent, host, portsData, onBack }) => { + const backRef = useRef(null); + + useEffect(() => { + backRef.current?.focus(); + }, []); + + return ( + <> + { + event.preventDefault(); + onBack(); + }} + > + + Back + + + + + ); +}; + +const PortsList: FC<{ + host: string; + agent: WorkspaceAgent; + workspace: Workspace; + data: PortsData; +}> = ({ host, agent, workspace, data }) => { + const route = `/@${workspace.owner_name}/${workspace.name}`; + const { listeningPorts, sharedPorts, privateListeningPorts, protocol } = data; + + return ( + <> + {privateListeningPorts.length > 0 && ( +
+ + Listening Ports + +
+ )} + + {privateListeningPorts.map((port) => ( + + ))} + + {listeningPorts !== undefined && + sharedPorts !== undefined && + privateListeningPorts.length === 0 && + sharedPorts.length === 0 && ( +

+ No open ports detected. +

+ )} + + {(sharedPorts ?? []).length > 0 && ( + <> + +
+ + Shared Ports + +
+ {(sharedPorts ?? []).map((share) => ( + + ))} + + )} + + + + + + Manage sharing + + + + ); +}; + +const ListeningPortItem: FC<{ + port: WorkspaceAgentListeningPort; + host: string; + agentName: string; + workspaceName: string; + ownerName: string; + protocol: "http" | "https"; +}> = ({ port, host, agentName, workspaceName, ownerName, protocol }) => { + const url = portForwardURL( + host, + port.port, + agentName, + workspaceName, + ownerName, + protocol, + ); + return ( + + + + {port.port} + {port.process_name !== "" && ( + + {port.process_name} + + )} + + + + ); +}; + +const SharedPortItem: FC<{ + share: WorkspaceAgentPortShare; + host: string; + agentName: string; + workspaceName: string; + ownerName: string; +}> = ({ share, host, agentName, workspaceName, ownerName }) => { + const url = portForwardURL( + host, + share.port, + agentName, + workspaceName, + ownerName, + share.protocol, + ); + const ShareIcon = + share.share_level === "public" + ? LockOpenIcon + : share.share_level === "organization" + ? BuildingIcon + : LockIcon; + return ( + + + + {share.port} + + {share.share_level} + + + + + ); +}; diff --git a/site/src/utils/mobile.ts b/site/src/utils/mobile.ts index dfc278c1b9..dc44656d53 100644 --- a/site/src/utils/mobile.ts +++ b/site/src/utils/mobile.ts @@ -8,6 +8,8 @@ export const isMobileViewport = (): boolean => { return window.matchMedia("(max-width: 639px)").matches; }; +export const belowMdViewportMediaQuery = "(max-width: 767px)"; + /** * Returns `true` when the viewport width is below the `md` Tailwind * breakpoint (< 768 px). Use this for layout branching that needs to @@ -17,5 +19,5 @@ export const isMobileViewport = (): boolean => { * mobile branch instead of the desktop flyout branch. */ export const isBelowMdViewport = (): boolean => { - return window.matchMedia("(max-width: 767px)").matches; + return window.matchMedia(belowMdViewportMediaQuery).matches; };