Skip to content

Commit 1fe82e8

Browse files
feat: add open in vscode button for devcontainers
1 parent 1360bfe commit 1fe82e8

File tree

4 files changed

+232
-7
lines changed

4 files changed

+232
-7
lines changed

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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
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 configFile = container.labels["devcontainer.config_file"];
31+
const containerFolder = container.volumes[folderPath];
32+
2433
return (
2534
<section
2635
className="border border-border border-dashed rounded p-6 "
@@ -40,9 +49,18 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
4049
<h4 className="m-0 text-xl font-semibold">Forwarded ports</h4>
4150

4251
<div className="flex gap-4 flex-wrap mt-4">
52+
<VSCodeDevContainerButton
53+
userName={workspace.owner_name}
54+
workspaceName={workspace.name}
55+
folderPath={folderPath}
56+
devContainerPath={configFile}
57+
devContainerFolder={containerFolder}
58+
displayApps={agent.display_apps}
59+
/>
60+
4361
<TerminalLink
4462
workspaceName={workspace.name}
45-
agentName={agentName}
63+
agentName={agent.name}
4664
containerName={container.name}
4765
userName={workspace.owner_name}
4866
/>
@@ -58,7 +76,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
5876
? portForwardURL(
5977
wildcardHostname,
6078
port.host_port!,
61-
agentName,
79+
agent.name,
6280
workspace.name,
6381
workspace.owner_name,
6482
location.protocol === "https" ? "https" : "http",

site/src/modules/resources/AgentRow.tsx

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

0 commit comments

Comments
 (0)