Skip to content

Commit ab8c437

Browse files
feat(site): open dev container in vscode (#17182)
Closes #16426 Adds a new button `VSCodeDevContainerButton` for connecting to a dev container with VSCode.
1 parent aa3d71d commit ab8c437

File tree

5 files changed

+281
-7
lines changed

5 files changed

+281
-7
lines changed

site/src/modules/resources/AgentDevcontainerCard.stories.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta, StoryObj } from "@storybook/react";
22
import {
33
MockWorkspace,
4+
MockWorkspaceAgent,
45
MockWorkspaceAgentContainer,
56
MockWorkspaceAgentContainerPorts,
67
} from "testHelpers/entities";
@@ -13,7 +14,7 @@ const meta: Meta<typeof AgentDevcontainerCard> = {
1314
container: MockWorkspaceAgentContainer,
1415
workspace: MockWorkspace,
1516
wildcardHostname: "*.wildcard.hostname",
16-
agentName: "dev",
17+
agent: MockWorkspaceAgent,
1718
},
1819
};
1920

site/src/modules/resources/AgentDevcontainerCard.tsx

+21-5
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
import Link from "@mui/material/Link";
22
import Tooltip, { type TooltipProps } from "@mui/material/Tooltip";
3-
import type { Workspace, WorkspaceAgentContainer } from "api/typesGenerated";
3+
import type {
4+
Workspace,
5+
WorkspaceAgent,
6+
WorkspaceAgentContainer,
7+
} from "api/typesGenerated";
48
import { ExternalLinkIcon } from "lucide-react";
59
import type { FC } from "react";
610
import { portForwardURL } from "utils/portForward";
711
import { AgentButton } from "./AgentButton";
812
import { AgentDevcontainerSSHButton } from "./SSHButton/SSHButton";
913
import { TerminalLink } from "./TerminalLink/TerminalLink";
14+
import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton/VSCodeDevContainerButton";
1015

1116
type AgentDevcontainerCardProps = {
17+
agent: WorkspaceAgent;
1218
container: WorkspaceAgentContainer;
1319
workspace: Workspace;
1420
wildcardHostname: string;
15-
agentName: string;
1621
};
1722

1823
export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
24+
agent,
1925
container,
2026
workspace,
21-
agentName,
2227
wildcardHostname,
2328
}) => {
29+
const folderPath = container.labels["devcontainer.local_folder"];
30+
const containerFolder = container.volumes[folderPath];
31+
2432
return (
2533
<section
2634
className="border border-border border-dashed rounded p-6 "
@@ -40,9 +48,17 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
4048
<h4 className="m-0 text-xl font-semibold">Forwarded ports</h4>
4149

4250
<div className="flex gap-4 flex-wrap mt-4">
51+
<VSCodeDevContainerButton
52+
userName={workspace.owner_name}
53+
workspaceName={workspace.name}
54+
devContainerName={container.name}
55+
devContainerFolder={containerFolder}
56+
displayApps={agent.display_apps}
57+
/>
58+
4359
<TerminalLink
4460
workspaceName={workspace.name}
45-
agentName={agentName}
61+
agentName={agent.name}
4662
containerName={container.name}
4763
userName={workspace.owner_name}
4864
/>
@@ -58,7 +74,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
5874
? portForwardURL(
5975
wildcardHostname,
6076
port.host_port!,
61-
agentName,
77+
agent.name,
6278
workspace.name,
6379
workspace.owner_name,
6480
location.protocol === "https" ? "https" : "http",

site/src/modules/resources/AgentRow.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export const AgentRow: FC<AgentRowProps> = ({
290290
container={container}
291291
workspace={workspace}
292292
wildcardHostname={proxy.preferredWildcardHostname}
293-
agentName={agent.name}
293+
agent={agent}
294294
/>
295295
);
296296
})}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
3+
import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton";
4+
5+
const meta: Meta<typeof VSCodeDevContainerButton> = {
6+
title: "modules/resources/VSCodeDevContainerButton",
7+
component: VSCodeDevContainerButton,
8+
};
9+
10+
export default meta;
11+
type Story = StoryObj<typeof VSCodeDevContainerButton>;
12+
13+
export const Default: Story = {
14+
args: {
15+
userName: MockWorkspace.owner_name,
16+
workspaceName: MockWorkspace.name,
17+
agentName: MockWorkspaceAgent.name,
18+
devContainerName: "musing_ride",
19+
devContainerFolder: "/workspace/coder",
20+
displayApps: [
21+
"vscode",
22+
"vscode_insiders",
23+
"port_forwarding_helper",
24+
"ssh_helper",
25+
"web_terminal",
26+
],
27+
},
28+
};
29+
30+
export const VSCodeOnly: Story = {
31+
args: {
32+
userName: MockWorkspace.owner_name,
33+
workspaceName: MockWorkspace.name,
34+
agentName: MockWorkspaceAgent.name,
35+
devContainerName: "nifty_borg",
36+
devContainerFolder: "/workspace/coder",
37+
displayApps: [
38+
"vscode",
39+
"port_forwarding_helper",
40+
"ssh_helper",
41+
"web_terminal",
42+
],
43+
},
44+
};
45+
46+
export const InsidersOnly: Story = {
47+
args: {
48+
userName: MockWorkspace.owner_name,
49+
workspaceName: MockWorkspace.name,
50+
agentName: MockWorkspaceAgent.name,
51+
devContainerName: "amazing_swartz",
52+
devContainerFolder: "/workspace/coder",
53+
displayApps: [
54+
"vscode_insiders",
55+
"port_forwarding_helper",
56+
"ssh_helper",
57+
"web_terminal",
58+
],
59+
},
60+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
2+
import ButtonGroup from "@mui/material/ButtonGroup";
3+
import Menu from "@mui/material/Menu";
4+
import MenuItem from "@mui/material/MenuItem";
5+
import { API } from "api/api";
6+
import type { DisplayApp } from "api/typesGenerated";
7+
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
8+
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
9+
import { type FC, useRef, useState } from "react";
10+
import { AgentButton } from "../AgentButton";
11+
import { DisplayAppNameMap } from "../AppLink/AppLink";
12+
13+
export interface VSCodeDevContainerButtonProps {
14+
userName: string;
15+
workspaceName: string;
16+
agentName?: string;
17+
devContainerName: string;
18+
devContainerFolder: string;
19+
displayApps: readonly DisplayApp[];
20+
}
21+
22+
type VSCodeVariant = "vscode" | "vscode-insiders";
23+
24+
const VARIANT_KEY = "vscode-variant";
25+
26+
export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
27+
props,
28+
) => {
29+
const [isVariantMenuOpen, setIsVariantMenuOpen] = useState(false);
30+
const previousVariant = localStorage.getItem(VARIANT_KEY);
31+
const [variant, setVariant] = useState<VSCodeVariant>(() => {
32+
if (!previousVariant) {
33+
return "vscode";
34+
}
35+
return previousVariant as VSCodeVariant;
36+
});
37+
const menuAnchorRef = useRef<HTMLDivElement>(null);
38+
39+
const selectVariant = (variant: VSCodeVariant) => {
40+
localStorage.setItem(VARIANT_KEY, variant);
41+
setVariant(variant);
42+
setIsVariantMenuOpen(false);
43+
};
44+
45+
const includesVSCodeDesktop = props.displayApps.includes("vscode");
46+
const includesVSCodeInsiders = props.displayApps.includes("vscode_insiders");
47+
48+
return includesVSCodeDesktop && includesVSCodeInsiders ? (
49+
<div>
50+
<ButtonGroup ref={menuAnchorRef} variant="outlined">
51+
{variant === "vscode" ? (
52+
<VSCodeButton {...props} />
53+
) : (
54+
<VSCodeInsidersButton {...props} />
55+
)}
56+
57+
<AgentButton
58+
aria-controls={
59+
isVariantMenuOpen ? "vscode-variant-button-menu" : undefined
60+
}
61+
aria-expanded={isVariantMenuOpen ? "true" : undefined}
62+
aria-label="select VSCode variant"
63+
aria-haspopup="menu"
64+
disableRipple
65+
onClick={() => {
66+
setIsVariantMenuOpen(true);
67+
}}
68+
css={{ paddingLeft: 0, paddingRight: 0 }}
69+
>
70+
<KeyboardArrowDownIcon css={{ fontSize: 16 }} />
71+
</AgentButton>
72+
</ButtonGroup>
73+
74+
<Menu
75+
open={isVariantMenuOpen}
76+
anchorEl={menuAnchorRef.current}
77+
onClose={() => setIsVariantMenuOpen(false)}
78+
css={{
79+
"& .MuiMenu-paper": {
80+
width: menuAnchorRef.current?.clientWidth,
81+
},
82+
}}
83+
>
84+
<MenuItem
85+
css={{ fontSize: 14 }}
86+
onClick={() => {
87+
selectVariant("vscode");
88+
}}
89+
>
90+
<VSCodeIcon css={{ width: 12, height: 12 }} />
91+
{DisplayAppNameMap.vscode}
92+
</MenuItem>
93+
<MenuItem
94+
css={{ fontSize: 14 }}
95+
onClick={() => {
96+
selectVariant("vscode-insiders");
97+
}}
98+
>
99+
<VSCodeInsidersIcon css={{ width: 12, height: 12 }} />
100+
{DisplayAppNameMap.vscode_insiders}
101+
</MenuItem>
102+
</Menu>
103+
</div>
104+
) : includesVSCodeDesktop ? (
105+
<VSCodeButton {...props} />
106+
) : (
107+
<VSCodeInsidersButton {...props} />
108+
);
109+
};
110+
111+
const VSCodeButton: FC<VSCodeDevContainerButtonProps> = ({
112+
userName,
113+
workspaceName,
114+
agentName,
115+
devContainerName,
116+
devContainerFolder,
117+
}) => {
118+
const [loading, setLoading] = useState(false);
119+
120+
return (
121+
<AgentButton
122+
startIcon={<VSCodeIcon />}
123+
disabled={loading}
124+
onClick={() => {
125+
setLoading(true);
126+
API.getApiKey()
127+
.then(({ key }) => {
128+
const query = new URLSearchParams({
129+
owner: userName,
130+
workspace: workspaceName,
131+
url: location.origin,
132+
token: key,
133+
devContainerName,
134+
devContainerFolder,
135+
});
136+
if (agentName) {
137+
query.set("agent", agentName);
138+
}
139+
140+
location.href = `vscode://coder.coder-remote/openDevContainer?${query.toString()}`;
141+
})
142+
.catch((ex) => {
143+
console.error(ex);
144+
})
145+
.finally(() => {
146+
setLoading(false);
147+
});
148+
}}
149+
>
150+
{DisplayAppNameMap.vscode}
151+
</AgentButton>
152+
);
153+
};
154+
155+
const VSCodeInsidersButton: FC<VSCodeDevContainerButtonProps> = ({
156+
userName,
157+
workspaceName,
158+
agentName,
159+
devContainerName,
160+
devContainerFolder,
161+
}) => {
162+
const [loading, setLoading] = useState(false);
163+
164+
return (
165+
<AgentButton
166+
startIcon={<VSCodeInsidersIcon />}
167+
disabled={loading}
168+
onClick={() => {
169+
setLoading(true);
170+
API.getApiKey()
171+
.then(({ key }) => {
172+
const query = new URLSearchParams({
173+
owner: userName,
174+
workspace: workspaceName,
175+
url: location.origin,
176+
token: key,
177+
devContainerName,
178+
devContainerFolder,
179+
});
180+
if (agentName) {
181+
query.set("agent", agentName);
182+
}
183+
184+
location.href = `vscode-insiders://coder.coder-remote/openDevContainer?${query.toString()}`;
185+
})
186+
.catch((ex) => {
187+
console.error(ex);
188+
})
189+
.finally(() => {
190+
setLoading(false);
191+
});
192+
}}
193+
>
194+
{DisplayAppNameMap.vscode_insiders}
195+
</AgentButton>
196+
);
197+
};

0 commit comments

Comments
 (0)