mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site/src): add ports submenu to WorkspacePill in agents chat (#24887)
Adds a **Ports (n) >** submenu item to the `WorkspacePill` dropdown shown in the `/agents` chat UI when a workspace is attached, sitting alongside VS Code, Terminal, and other app items. The submenu shows a **Listening Ports** section with clickable port links that open in a new tab, a **Shared Ports** section with sharing-level icons when any ports are shared, and a **Manage sharing** footer link to the workspace detail page. Port data is fetched on a 5s polling interval while the dropdown is open and only when the agent is connected. The trigger is disabled when the workspace is not running. Also adds `DropdownMenuSub`, `DropdownMenuSubTrigger`, and `DropdownMenuSubContent` to the shared `DropdownMenu` component for use here and future consumers.  <details> <summary>Implementation notes</summary> - `host` for port-forward URL construction comes from `useProxy().proxy.preferredWildcardHostname`, the same source used by `PortForwardButton` on the workspace detail page. - Port queries are gated on `isOpen && agent.status === "connected"` so no requests fire when the dropdown is closed or the agent is disconnected. - Shared ports that overlap with listening ports are deduplicated: they only appear in the Shared section. - Port sharing controls (create/update/delete) are intentionally excluded to keep the agents toolbar lightweight; the "Manage sharing" link surfaces the workspace page for that. - The port count badge on the trigger (`Ports (n)`) uses the raw listening-port count, matching the existing `PortForwardButton` behavior on the workspace detail page. </details> > Generated by [Coder Agent](https://coder.com)
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* This component was updated to match the styles from the Figma design:
|
||||
* @see {@link https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=656-2354&t=CiGt5le3yJEwMH4M-0}
|
||||
*/
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
import {
|
||||
@@ -79,6 +79,37 @@ export const DropdownMenuRadioItem: React.FC<
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
export const DropdownMenuSubTrigger: React.FC<
|
||||
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
> = ({ className, inset, children, ...props }) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(menuItemClass, inset && "pl-8", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-3.5" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenuSubContent: React.FC<
|
||||
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(menuContentClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenuSeparator: React.FC<
|
||||
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Separator>
|
||||
> = ({ className, ...props }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockWorkspace, MockWorkspaceAgent } from "#/testHelpers/entities";
|
||||
import { withProxyProvider } from "#/testHelpers/storybook";
|
||||
import {
|
||||
AgentChatInput,
|
||||
type AgentContextUsage,
|
||||
@@ -25,6 +26,7 @@ const defaultModelOptions = [
|
||||
const meta: Meta<typeof AgentChatInput> = {
|
||||
title: "pages/AgentsPage/AgentChatInput",
|
||||
component: AgentChatInput,
|
||||
decorators: [withProxyProvider()],
|
||||
args: {
|
||||
onSend: fn(),
|
||||
onContentChange: fn(),
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { WorkspaceApp } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockListeningPortsResponse,
|
||||
MockSharedPortsResponse,
|
||||
MockStoppedWorkspace,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
@@ -62,7 +64,12 @@ const hiddenApp: WorkspaceApp = {
|
||||
|
||||
const agentWithApps = {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode", "vscode_insiders", "web_terminal"] as const,
|
||||
display_apps: [
|
||||
"vscode",
|
||||
"vscode_insiders",
|
||||
"web_terminal",
|
||||
"port_forwarding_helper",
|
||||
] as const,
|
||||
apps: [externalApp, cursorApp],
|
||||
};
|
||||
|
||||
@@ -97,9 +104,20 @@ const agentWithHiddenApp = {
|
||||
const meta: Meta<typeof WorkspacePill> = {
|
||||
title: "pages/AgentsPage/WorkspacePill",
|
||||
component: WorkspacePill,
|
||||
// useAppLink calls useProxy(), so we need the proxy provider for
|
||||
// stories that render AppMenuItem.
|
||||
decorators: [withProxyProvider()],
|
||||
// useAppLink and useProxy are called inside sub-components, so we need the
|
||||
// proxy provider for all stories. A non-empty wildcard hostname is required
|
||||
// so the Ports sub-trigger renders (it is hidden when port-forwarding is not
|
||||
// configured).
|
||||
decorators: [
|
||||
withProxyProvider({
|
||||
proxy: {
|
||||
proxy: undefined,
|
||||
preferredPathAppURL: "",
|
||||
preferredWildcardHostname: "*.coder.com",
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
queries: [{ key: ["me", "apiKey"], data: { key: "mock-api-key" } }],
|
||||
@@ -277,6 +295,141 @@ export const WithStoppedWorkspace: Story = {
|
||||
|
||||
// View Workspace link should still be accessible.
|
||||
expect(body.getByText("View Workspace")).toBeInTheDocument();
|
||||
|
||||
// Ports sub-trigger should be disabled when workspace is stopped.
|
||||
const portsItem = body.getByText("Ports").closest("[role=menuitem]");
|
||||
expect(portsItem).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithListeningPorts: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: MockWorkspaceAgent,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{ key: ["me", "apiKey"], data: { key: "mock-api-key" } },
|
||||
{
|
||||
key: ["portForward", MockWorkspaceAgent.id],
|
||||
data: MockListeningPortsResponse,
|
||||
},
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: { shares: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
const body = within(document.body);
|
||||
await waitFor(() => {
|
||||
// The ports sub-trigger should show the count.
|
||||
expect(body.getByText(/Ports \(\d+\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Hover over the ports item to open the submenu.
|
||||
await userEvent.hover(body.getByText(/Ports \(\d+\)/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(body.getByText("Listening Ports")).toBeInTheDocument();
|
||||
expect(body.getByText("8080")).toBeInTheDocument();
|
||||
expect(body.getByText("gogo")).toBeInTheDocument();
|
||||
expect(body.getByText("30000")).toBeInTheDocument();
|
||||
expect(body.getByText("webb")).toBeInTheDocument();
|
||||
expect(body.getByText("Manage sharing")).toBeInTheDocument();
|
||||
// Port items render as anchor links.
|
||||
const port8080Anchor = body.getByText("8080").closest("a");
|
||||
expect(port8080Anchor).toHaveAttribute("href");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSharedPorts: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
name: "a-workspace-agent",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{ key: ["me", "apiKey"], data: { key: "mock-api-key" } },
|
||||
{
|
||||
key: ["portForward", MockWorkspaceAgent.id],
|
||||
data: MockListeningPortsResponse,
|
||||
},
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: MockSharedPortsResponse,
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
const body = within(document.body);
|
||||
await waitFor(() => {
|
||||
expect(body.getByText(/Ports/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(body.getByText(/Ports/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(body.getByText("Listening Ports")).toBeInTheDocument();
|
||||
expect(body.getByText("Shared Ports")).toBeInTheDocument();
|
||||
// Shared ports from MockSharedPortsResponse for this agent.
|
||||
expect(body.getByText("4000")).toBeInTheDocument();
|
||||
expect(body.getByText("Manage sharing")).toBeInTheDocument();
|
||||
// Port 8081 is both listening and shared; deduplication ensures it
|
||||
// appears only in the Shared Ports section, not in Listening Ports.
|
||||
expect(body.getAllByText("8081")).toHaveLength(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyPorts: Story = {
|
||||
args: {
|
||||
...defaultProps,
|
||||
workspace: MockWorkspace,
|
||||
agent: MockWorkspaceAgent,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{ key: ["me", "apiKey"], data: { key: "mock-api-key" } },
|
||||
{
|
||||
key: ["portForward", MockWorkspaceAgent.id],
|
||||
data: { ports: [] },
|
||||
},
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: { shares: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pill = canvas.getByText("Test-Workspace");
|
||||
await userEvent.click(pill);
|
||||
|
||||
const body = within(document.body);
|
||||
await waitFor(() => {
|
||||
expect(body.getByText("Ports (0)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(body.getByText("Ports (0)"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(body.getByText("No open ports detected.")).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import {
|
||||
BuildingIcon,
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
LayoutGridIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MonitorIcon,
|
||||
NetworkIcon,
|
||||
RadioIcon,
|
||||
SquareTerminalIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { useMutation, useQuery } 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 {
|
||||
@@ -22,6 +31,9 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
@@ -32,6 +44,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
import {
|
||||
getTerminalHref,
|
||||
@@ -42,6 +55,10 @@ 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";
|
||||
|
||||
interface WorkspacePillProps {
|
||||
@@ -69,16 +86,24 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
const { mutate: generateKey, isPending: isGeneratingKey } = useMutation({
|
||||
mutationFn: () => API.getApiKey(),
|
||||
});
|
||||
const { proxy } = useProxy();
|
||||
const host = proxy.preferredWildcardHostname;
|
||||
|
||||
const builtinApps = new Set(agent.display_apps);
|
||||
const hasVSCode = builtinApps.has("vscode");
|
||||
const hasVSCodeInsiders = builtinApps.has("vscode_insiders");
|
||||
const hasTerminal = builtinApps.has("web_terminal");
|
||||
const portForwardingEnabled =
|
||||
host !== "" && builtinApps.has("port_forwarding_helper");
|
||||
|
||||
const userApps = agent.apps.filter((app) => !app.hidden);
|
||||
|
||||
const hasItemsAboveSeparator =
|
||||
hasVSCode || hasVSCodeInsiders || userApps.length > 0 || hasTerminal;
|
||||
hasVSCode ||
|
||||
hasVSCodeInsiders ||
|
||||
userApps.length > 0 ||
|
||||
hasTerminal ||
|
||||
portForwardingEnabled;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -165,7 +190,17 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
)}
|
||||
{portForwardingEnabled && (
|
||||
<PortsSubMenuItem
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
host={host}
|
||||
isOpen={open}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
)}
|
||||
{hasItemsAboveSeparator && <DropdownMenuSeparator className="my-1" />}
|
||||
|
||||
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={route} target="_blank" rel="noreferrer">
|
||||
@@ -178,6 +213,183 @@ 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;
|
||||
|
||||
@@ -38,14 +38,20 @@ export const portForwardURL = (
|
||||
const subdomain = `${port}${suffix}--${agentName}--${workspaceName}--${username}`;
|
||||
|
||||
const baseUrl = `${location.protocol}//${host.replace(/\*/g, subdomain)}`;
|
||||
const url = new URL(baseUrl);
|
||||
if (pathname) {
|
||||
url.pathname = pathname;
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
if (pathname) {
|
||||
url.pathname = pathname;
|
||||
}
|
||||
if (search) {
|
||||
url.search = search;
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
// When the proxy host is empty or invalid, return a do-nothing anchor
|
||||
// so the link renders without navigating anywhere.
|
||||
return "#";
|
||||
}
|
||||
if (search) {
|
||||
url.search = search;
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user