mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: inline ports panel in workspace pill on mobile (#25042)
closes CODAGT-326 <img width="335" height="658" alt="Screenshot 2026-06-02 at 18 29 39" src="https://github.com/user-attachments/assets/ee103047-67ad-403c-b67b-eb2147726db3" /> On viewports below the `md` Tailwind breakpoint, the agents-chat workspace pill becomes full width via the existing `mobile-full-width-dropdown` CSS hook. The `Ports (X)` item used a Radix `DropdownMenuSub` which opened a flyout sub-content to the right of the parent menu, so on mobile the sub-content had nowhere to render and clipped off the right edge. Mirror the inline sub-panel pattern already used by the agent chat input plus menu: lift a `view: "main" | "ports"` state into `WorkspacePill`, and on mobile swap the same dropdown's contents to a `Back` + ports list panel instead of opening a flyout. Desktop keeps the existing flyout sub-menu unchanged. Closes [CODAGT-326](https://linear.app/codercom/issue/CODAGT-326/port-forward-menu-is-cut-off-on-mobile-viewports). Visual coverage is added via two new Storybook stories at the `mobile1` (375 px) viewport: `MobilePortsInlinePanel` (full interaction + assertions) and `MobilePortsInlinePanelOpen` (visual / Chromatic capture stop point). <details> <summary>Implementation plan</summary> ### Why The dropdown content already gets full viewport width on `< 768 px` via the `mobile-full-width-dropdown` CSS hook in `site/src/index.css`. A Radix `DropdownMenuSub` opens to the right of its trigger, so on mobile it has no room and clips. Forcing the sub-content to also be full-width would overlap the parent and break keyboard / focus flow. The existing convention for nested menus on mobile in this codebase is the inline sub-panel (see `plusMenuView` in `AgentChatInput.tsx`). ### `WorkspacePill.tsx` - Add `view: "main" | "ports"` state and reset to `"main"` on close. - Extract `usePortsData(workspace, agent, enabled)` and a shared `PortsList` so desktop sub-content and mobile inline panel share one renderer. - Replace `PortsSubMenuItem` with `PortsMenuItem`, which uses `useIsBelowMdViewport()` to render either: - **Mobile:** a regular `DropdownMenuItem` whose `onSelect` calls `event.preventDefault()` (keeps the dropdown open) and switches to the inline view. - **Desktop:** the existing `DropdownMenuSub` flyout, behavior unchanged. - Add `MobilePortsPanel` rendered inside the parent `DropdownMenuContent` when `view === "ports"`. Includes a `Back` item that returns to the main view. - Add a small reactive `useIsBelowMdViewport()` hook around the existing `isBelowMdViewport` helper. </details> > Created on behalf of @jaayden by Coder Agents.
This commit is contained in:
@@ -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);
|
||||
};
|
||||
@@ -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<Story>;
|
||||
|
||||
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();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<WorkspacePillProps> = ({
|
||||
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 (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setView("main");
|
||||
setFocusPortsOnMain(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center overflow-hidden rounded-full bg-surface-secondary text-xs font-medium text-content-secondary md:min-w-[2.75rem]">
|
||||
<Tooltip
|
||||
open={tooltipOpen}
|
||||
@@ -152,76 +170,100 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
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 && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode"
|
||||
label="VS Code"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{hasVSCodeInsiders && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode-insiders"
|
||||
label="VS Code Insiders"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{userApps.map((app) => (
|
||||
<AppMenuItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
))}
|
||||
{hasTerminal && (
|
||||
<TerminalMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
)}
|
||||
{portForwardingEnabled && (
|
||||
<PortsSubMenuItem
|
||||
{showPortsView ? (
|
||||
<MobilePortsPanel
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
host={host}
|
||||
isOpen={open}
|
||||
isRunning={isRunning}
|
||||
portsData={portsData}
|
||||
onBack={() => {
|
||||
setFocusPortsOnMain(true);
|
||||
setView("main");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasItemsAboveSeparator && <DropdownMenuSeparator className="my-1" />}
|
||||
|
||||
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
<MonitorIcon className="size-3.5" />
|
||||
View Workspace
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{onRemoveWorkspace && (
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={onRemoveWorkspace}
|
||||
>
|
||||
<UnlinkIcon className="size-3.5" />
|
||||
Detach workspace
|
||||
{hasVSCode && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode"
|
||||
label="VS Code"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{hasVSCodeInsiders && (
|
||||
<VSCodeMenuItem
|
||||
variant="vscode-insiders"
|
||||
label="VS Code Insiders"
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
chatId={chatId}
|
||||
folder={folder}
|
||||
isRunning={isRunning}
|
||||
generateKey={generateKey}
|
||||
isGeneratingKey={isGeneratingKey}
|
||||
/>
|
||||
)}
|
||||
{userApps.map((app) => (
|
||||
<AppMenuItem
|
||||
key={app.id}
|
||||
app={app}
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
))}
|
||||
{hasTerminal && (
|
||||
<TerminalMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
)}
|
||||
{portForwardingEnabled && (
|
||||
<PortsMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
host={host}
|
||||
portsData={portsData}
|
||||
isRunning={isRunning}
|
||||
isBelowMd={isBelowMd}
|
||||
focusOnMount={focusPortsOnMain}
|
||||
onFocusApplied={() => setFocusPortsOnMain(false)}
|
||||
onSelectInline={() => {
|
||||
setFocusPortsOnMain(false);
|
||||
setView("ports");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasItemsAboveSeparator && (
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
)}
|
||||
|
||||
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
<MonitorIcon className="size-3.5" />
|
||||
View Workspace
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{onRemoveWorkspace && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={onRemoveWorkspace}
|
||||
>
|
||||
<UnlinkIcon className="size-3.5" />
|
||||
Detach workspace
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -229,183 +271,6 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={!isRunning}>
|
||||
<NetworkIcon className="size-3.5" />
|
||||
{totalCount !== undefined ? `Ports (${totalCount})` : "Ports"}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5">
|
||||
{/* Listening Ports header: only render when there are ports to list. */}
|
||||
{privateListeningPorts.length > 0 && (
|
||||
<div className="px-2 pb-1.5 pt-1">
|
||||
<span className="text-xs font-semibold text-content-secondary">
|
||||
Listening Ports
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{privateListeningPorts.map((port) => (
|
||||
<ListeningPortItem
|
||||
key={port.port}
|
||||
port={port}
|
||||
host={host}
|
||||
agentName={agent.name}
|
||||
workspaceName={workspace.name}
|
||||
ownerName={workspace.owner_name}
|
||||
protocol={protocol}
|
||||
/>
|
||||
))}
|
||||
|
||||
{listeningPorts !== undefined &&
|
||||
sharedPorts !== undefined &&
|
||||
privateListeningPorts.length === 0 &&
|
||||
sharedPorts.length === 0 && (
|
||||
<p className="px-2 py-2 text-center text-xs text-content-tertiary">
|
||||
No open ports detected.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Shared Ports */}
|
||||
{(sharedPorts ?? []).length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<div className="px-2 pb-1.5 pt-1">
|
||||
<span className="text-xs font-semibold text-content-secondary">
|
||||
Shared Ports
|
||||
</span>
|
||||
</div>
|
||||
{(sharedPorts ?? []).map((share) => (
|
||||
<SharedPortItem
|
||||
key={share.port}
|
||||
share={share}
|
||||
host={host}
|
||||
agentName={agent.name}
|
||||
workspaceName={workspace.name}
|
||||
ownerName={workspace.owner_name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Manage sharing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<RadioIcon className="size-3.5 shrink-0" />
|
||||
<span className="font-mono tabular-nums">{port.port}</span>
|
||||
{port.process_name !== "" && (
|
||||
<span className="truncate text-content-tertiary">
|
||||
{port.process_name}
|
||||
</span>
|
||||
)}
|
||||
<ExternalLinkIcon className="ml-auto size-3.5 shrink-0 opacity-50" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ShareIcon className="size-3.5 shrink-0" />
|
||||
<span className="font-mono tabular-nums">{share.port}</span>
|
||||
<span className="truncate capitalize text-content-tertiary">
|
||||
{share.share_level}
|
||||
</span>
|
||||
<ExternalLinkIcon className="ml-auto size-3.5 shrink-0 opacity-50" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const VSCodeMenuItem: FC<{
|
||||
variant: "vscode" | "vscode-insiders";
|
||||
label: string;
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<DropdownMenuItem
|
||||
ref={itemRef}
|
||||
disabled={!isRunning}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSelectInline();
|
||||
}}
|
||||
>
|
||||
<NetworkIcon className="size-3.5" />
|
||||
{label}
|
||||
<ChevronRightIcon className="ml-auto size-3.5" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={!isRunning}>
|
||||
<NetworkIcon className="size-3.5" />
|
||||
{label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5">
|
||||
<PortsList
|
||||
host={host}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
data={portsData}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobilePortsPanel: FC<{
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
host: string;
|
||||
portsData: PortsData;
|
||||
onBack: () => void;
|
||||
}> = ({ workspace, agent, host, portsData, onBack }) => {
|
||||
const backRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
backRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
ref={backRef}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onBack();
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5" />
|
||||
Back
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<PortsList
|
||||
host={host}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
data={portsData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<div className="px-2 pb-1.5 pt-1">
|
||||
<span className="text-xs font-semibold text-content-secondary">
|
||||
Listening Ports
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{privateListeningPorts.map((port) => (
|
||||
<ListeningPortItem
|
||||
key={port.port}
|
||||
port={port}
|
||||
host={host}
|
||||
agentName={agent.name}
|
||||
workspaceName={workspace.name}
|
||||
ownerName={workspace.owner_name}
|
||||
protocol={protocol}
|
||||
/>
|
||||
))}
|
||||
|
||||
{listeningPorts !== undefined &&
|
||||
sharedPorts !== undefined &&
|
||||
privateListeningPorts.length === 0 &&
|
||||
sharedPorts.length === 0 && (
|
||||
<p className="px-2 py-2 text-center text-xs text-content-tertiary">
|
||||
No open ports detected.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(sharedPorts ?? []).length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<div className="px-2 pb-1.5 pt-1">
|
||||
<span className="text-xs font-semibold text-content-secondary">
|
||||
Shared Ports
|
||||
</span>
|
||||
</div>
|
||||
{(sharedPorts ?? []).map((share) => (
|
||||
<SharedPortItem
|
||||
key={share.port}
|
||||
share={share}
|
||||
host={host}
|
||||
agentName={agent.name}
|
||||
workspaceName={workspace.name}
|
||||
ownerName={workspace.owner_name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
Manage sharing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<RadioIcon className="size-3.5 shrink-0" />
|
||||
<span className="font-mono tabular-nums">{port.port}</span>
|
||||
{port.process_name !== "" && (
|
||||
<span className="truncate text-content-tertiary">
|
||||
{port.process_name}
|
||||
</span>
|
||||
)}
|
||||
<ExternalLinkIcon className="ml-auto size-3.5 shrink-0 opacity-50" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ShareIcon className="size-3.5 shrink-0" />
|
||||
<span className="font-mono tabular-nums">{share.port}</span>
|
||||
<span className="truncate capitalize text-content-tertiary">
|
||||
{share.share_level}
|
||||
</span>
|
||||
<ExternalLinkIcon className="ml-auto size-3.5 shrink-0 opacity-50" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user