feat(site): open dev container in vscode (#17182)

Closes https://github.com/coder/coder/issues/16426

Adds a new button `VSCodeDevContainerButton` for connecting to a dev
container with VSCode.
This commit is contained in:
Danielle Maywood
2025-04-03 11:10:38 +01:00
committed by GitHub
parent aa3d71d169
commit ab8c437abc
5 changed files with 281 additions and 7 deletions
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceAgentContainer,
MockWorkspaceAgentContainerPorts,
} from "testHelpers/entities";
@@ -13,7 +14,7 @@ const meta: Meta<typeof AgentDevcontainerCard> = {
container: MockWorkspaceAgentContainer,
workspace: MockWorkspace,
wildcardHostname: "*.wildcard.hostname",
agentName: "dev",
agent: MockWorkspaceAgent,
},
};
@@ -1,26 +1,34 @@
import Link from "@mui/material/Link";
import Tooltip, { type TooltipProps } from "@mui/material/Tooltip";
import type { Workspace, WorkspaceAgentContainer } from "api/typesGenerated";
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentContainer,
} from "api/typesGenerated";
import { ExternalLinkIcon } from "lucide-react";
import type { FC } from "react";
import { portForwardURL } from "utils/portForward";
import { AgentButton } from "./AgentButton";
import { AgentDevcontainerSSHButton } from "./SSHButton/SSHButton";
import { TerminalLink } from "./TerminalLink/TerminalLink";
import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton/VSCodeDevContainerButton";
type AgentDevcontainerCardProps = {
agent: WorkspaceAgent;
container: WorkspaceAgentContainer;
workspace: Workspace;
wildcardHostname: string;
agentName: string;
};
export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
agent,
container,
workspace,
agentName,
wildcardHostname,
}) => {
const folderPath = container.labels["devcontainer.local_folder"];
const containerFolder = container.volumes[folderPath];
return (
<section
className="border border-border border-dashed rounded p-6 "
@@ -40,9 +48,17 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
<h4 className="m-0 text-xl font-semibold">Forwarded ports</h4>
<div className="flex gap-4 flex-wrap mt-4">
<VSCodeDevContainerButton
userName={workspace.owner_name}
workspaceName={workspace.name}
devContainerName={container.name}
devContainerFolder={containerFolder}
displayApps={agent.display_apps}
/>
<TerminalLink
workspaceName={workspace.name}
agentName={agentName}
agentName={agent.name}
containerName={container.name}
userName={workspace.owner_name}
/>
@@ -58,7 +74,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
? portForwardURL(
wildcardHostname,
port.host_port!,
agentName,
agent.name,
workspace.name,
workspace.owner_name,
location.protocol === "https" ? "https" : "http",
+1 -1
View File
@@ -290,7 +290,7 @@ export const AgentRow: FC<AgentRowProps> = ({
container={container}
workspace={workspace}
wildcardHostname={proxy.preferredWildcardHostname}
agentName={agent.name}
agent={agent}
/>
);
})}
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton";
const meta: Meta<typeof VSCodeDevContainerButton> = {
title: "modules/resources/VSCodeDevContainerButton",
component: VSCodeDevContainerButton,
};
export default meta;
type Story = StoryObj<typeof VSCodeDevContainerButton>;
export const Default: Story = {
args: {
userName: MockWorkspace.owner_name,
workspaceName: MockWorkspace.name,
agentName: MockWorkspaceAgent.name,
devContainerName: "musing_ride",
devContainerFolder: "/workspace/coder",
displayApps: [
"vscode",
"vscode_insiders",
"port_forwarding_helper",
"ssh_helper",
"web_terminal",
],
},
};
export const VSCodeOnly: Story = {
args: {
userName: MockWorkspace.owner_name,
workspaceName: MockWorkspace.name,
agentName: MockWorkspaceAgent.name,
devContainerName: "nifty_borg",
devContainerFolder: "/workspace/coder",
displayApps: [
"vscode",
"port_forwarding_helper",
"ssh_helper",
"web_terminal",
],
},
};
export const InsidersOnly: Story = {
args: {
userName: MockWorkspace.owner_name,
workspaceName: MockWorkspace.name,
agentName: MockWorkspaceAgent.name,
devContainerName: "amazing_swartz",
devContainerFolder: "/workspace/coder",
displayApps: [
"vscode_insiders",
"port_forwarding_helper",
"ssh_helper",
"web_terminal",
],
},
};
@@ -0,0 +1,197 @@
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import ButtonGroup from "@mui/material/ButtonGroup";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { API } from "api/api";
import type { DisplayApp } from "api/typesGenerated";
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
import { type FC, useRef, useState } from "react";
import { AgentButton } from "../AgentButton";
import { DisplayAppNameMap } from "../AppLink/AppLink";
export interface VSCodeDevContainerButtonProps {
userName: string;
workspaceName: string;
agentName?: string;
devContainerName: string;
devContainerFolder: string;
displayApps: readonly DisplayApp[];
}
type VSCodeVariant = "vscode" | "vscode-insiders";
const VARIANT_KEY = "vscode-variant";
export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
props,
) => {
const [isVariantMenuOpen, setIsVariantMenuOpen] = useState(false);
const previousVariant = localStorage.getItem(VARIANT_KEY);
const [variant, setVariant] = useState<VSCodeVariant>(() => {
if (!previousVariant) {
return "vscode";
}
return previousVariant as VSCodeVariant;
});
const menuAnchorRef = useRef<HTMLDivElement>(null);
const selectVariant = (variant: VSCodeVariant) => {
localStorage.setItem(VARIANT_KEY, variant);
setVariant(variant);
setIsVariantMenuOpen(false);
};
const includesVSCodeDesktop = props.displayApps.includes("vscode");
const includesVSCodeInsiders = props.displayApps.includes("vscode_insiders");
return includesVSCodeDesktop && includesVSCodeInsiders ? (
<div>
<ButtonGroup ref={menuAnchorRef} variant="outlined">
{variant === "vscode" ? (
<VSCodeButton {...props} />
) : (
<VSCodeInsidersButton {...props} />
)}
<AgentButton
aria-controls={
isVariantMenuOpen ? "vscode-variant-button-menu" : undefined
}
aria-expanded={isVariantMenuOpen ? "true" : undefined}
aria-label="select VSCode variant"
aria-haspopup="menu"
disableRipple
onClick={() => {
setIsVariantMenuOpen(true);
}}
css={{ paddingLeft: 0, paddingRight: 0 }}
>
<KeyboardArrowDownIcon css={{ fontSize: 16 }} />
</AgentButton>
</ButtonGroup>
<Menu
open={isVariantMenuOpen}
anchorEl={menuAnchorRef.current}
onClose={() => setIsVariantMenuOpen(false)}
css={{
"& .MuiMenu-paper": {
width: menuAnchorRef.current?.clientWidth,
},
}}
>
<MenuItem
css={{ fontSize: 14 }}
onClick={() => {
selectVariant("vscode");
}}
>
<VSCodeIcon css={{ width: 12, height: 12 }} />
{DisplayAppNameMap.vscode}
</MenuItem>
<MenuItem
css={{ fontSize: 14 }}
onClick={() => {
selectVariant("vscode-insiders");
}}
>
<VSCodeInsidersIcon css={{ width: 12, height: 12 }} />
{DisplayAppNameMap.vscode_insiders}
</MenuItem>
</Menu>
</div>
) : includesVSCodeDesktop ? (
<VSCodeButton {...props} />
) : (
<VSCodeInsidersButton {...props} />
);
};
const VSCodeButton: FC<VSCodeDevContainerButtonProps> = ({
userName,
workspaceName,
agentName,
devContainerName,
devContainerFolder,
}) => {
const [loading, setLoading] = useState(false);
return (
<AgentButton
startIcon={<VSCodeIcon />}
disabled={loading}
onClick={() => {
setLoading(true);
API.getApiKey()
.then(({ key }) => {
const query = new URLSearchParams({
owner: userName,
workspace: workspaceName,
url: location.origin,
token: key,
devContainerName,
devContainerFolder,
});
if (agentName) {
query.set("agent", agentName);
}
location.href = `vscode://coder.coder-remote/openDevContainer?${query.toString()}`;
})
.catch((ex) => {
console.error(ex);
})
.finally(() => {
setLoading(false);
});
}}
>
{DisplayAppNameMap.vscode}
</AgentButton>
);
};
const VSCodeInsidersButton: FC<VSCodeDevContainerButtonProps> = ({
userName,
workspaceName,
agentName,
devContainerName,
devContainerFolder,
}) => {
const [loading, setLoading] = useState(false);
return (
<AgentButton
startIcon={<VSCodeInsidersIcon />}
disabled={loading}
onClick={() => {
setLoading(true);
API.getApiKey()
.then(({ key }) => {
const query = new URLSearchParams({
owner: userName,
workspace: workspaceName,
url: location.origin,
token: key,
devContainerName,
devContainerFolder,
});
if (agentName) {
query.set("agent", agentName);
}
location.href = `vscode-insiders://coder.coder-remote/openDevContainer?${query.toString()}`;
})
.catch((ex) => {
console.error(ex);
})
.finally(() => {
setLoading(false);
});
}}
>
{DisplayAppNameMap.vscode_insiders}
</AgentButton>
);
};