mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -290,7 +290,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
container={container}
|
||||
workspace={workspace}
|
||||
wildcardHostname={proxy.preferredWildcardHostname}
|
||||
agentName={agent.name}
|
||||
agent={agent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+60
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user