Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 3, 2025
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
3 changes: 2 additions & 1 deletion site/src/modules/resources/AgentDevcontainerCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceAgentContainer,
MockWorkspaceAgentContainerPorts,
} from "testHelpers/entities";
Expand All @@ -13,7 +14,7 @@ const meta: Meta<typeof AgentDevcontainerCard> = {
container: MockWorkspaceAgentContainer,
workspace: MockWorkspace,
wildcardHostname: "*.wildcard.hostname",
agentName: "dev",
agent: MockWorkspaceAgent,
},
};

Expand Down
26 changes: 21 additions & 5 deletions site/src/modules/resources/AgentDevcontainerCard.tsx
Original file line number Diff line number Diff line change
@@ -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 "
Expand All @@ -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}
/>
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion site/src/modules/resources/AgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export const AgentRow: FC<AgentRowProps> = ({
container={container}
workspace={workspace}
wildcardHostname={proxy.preferredWildcardHostname}
agentName={agent.name}
agent={agent}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
],
},
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading