diff --git a/site/src/modules/apps/apps.ts b/site/src/modules/apps/apps.ts new file mode 100644 index 0000000000000..1c0b0a4a54937 --- /dev/null +++ b/site/src/modules/apps/apps.ts @@ -0,0 +1,54 @@ +type GetVSCodeHrefParams = { + owner: string; + workspace: string; + token: string; + agent?: string; + folder?: string; +}; + +export const getVSCodeHref = ( + app: "vscode" | "vscode-insiders", + { owner, workspace, token, agent, folder }: GetVSCodeHrefParams, +) => { + const query = new URLSearchParams({ + owner, + workspace, + url: location.origin, + token, + openRecent: "true", + }); + if (agent) { + query.set("agent", agent); + } + if (folder) { + query.set("folder", folder); + } + return `${app}://coder.coder-remote/open?${query}`; +}; + +type GetTerminalHrefParams = { + username: string; + workspace: string; + agent?: string; + container?: string; +}; + +export const getTerminalHref = ({ + username, + workspace, + agent, + container, +}: GetTerminalHrefParams) => { + const params = new URLSearchParams(); + if (container) { + params.append("container", container); + } + // Always use the primary for the terminal link. This is a relative link. + return `/@${username}/${workspace}${ + agent ? `.${agent}` : "" + }/terminal?${params}`; +}; + +export const openAppInNewWindow = (name: string, href: string) => { + window.open(href, "_blank", "width=900,height=600"); +}; diff --git a/site/src/modules/resources/TerminalLink/TerminalLink.tsx b/site/src/modules/resources/TerminalLink/TerminalLink.tsx index 23204bd819dfe..fc977bf6951e8 100644 --- a/site/src/modules/resources/TerminalLink/TerminalLink.tsx +++ b/site/src/modules/resources/TerminalLink/TerminalLink.tsx @@ -1,13 +1,9 @@ import { TerminalIcon } from "components/Icons/TerminalIcon"; +import { getTerminalHref, openAppInNewWindow } from "modules/apps/apps"; import type { FC, MouseEvent } from "react"; -import { generateRandomString } from "utils/random"; import { AgentButton } from "../AgentButton"; import { DisplayAppNameMap } from "../AppLink/AppLink"; -const Language = { - terminalTitle: (identifier: string): string => `Terminal - ${identifier}`, -}; - export interface TerminalLinkProps { workspaceName: string; agentName?: string; @@ -28,14 +24,12 @@ export const TerminalLink: FC = ({ workspaceName, containerName, }) => { - const params = new URLSearchParams(); - if (containerName) { - params.append("container", containerName); - } - // Always use the primary for the terminal link. This is a relative link. - const href = `/@${userName}/${workspaceName}${ - agentName ? `.${agentName}` : "" - }/terminal?${params.toString()}`; + const href = getTerminalHref({ + username: userName, + workspace: workspaceName, + agent: agentName, + container: containerName, + }); return ( @@ -43,11 +37,7 @@ export const TerminalLink: FC = ({ href={href} onClick={(event: MouseEvent) => { event.preventDefault(); - window.open( - href, - Language.terminalTitle(generateRandomString(12)), - "width=900,height=600", - ); + openAppInNewWindow("Terminal", href); }} > diff --git a/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx b/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx index 8397305ef153f..1c5c3578682e1 100644 --- a/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx +++ b/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx @@ -5,6 +5,7 @@ import type { DisplayApp } from "api/typesGenerated"; import { VSCodeIcon } from "components/Icons/VSCodeIcon"; import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon"; import { ChevronDownIcon } from "lucide-react"; +import { getVSCodeHref } from "modules/apps/apps"; import { type FC, useRef, useState } from "react"; import { AgentButton } from "../AgentButton"; import { DisplayAppNameMap } from "../AppLink/AppLink"; @@ -118,21 +119,13 @@ const VSCodeButton: FC = ({ setLoading(true); API.getApiKey() .then(({ key }) => { - const query = new URLSearchParams({ + location.href = getVSCodeHref("vscode", { owner: userName, workspace: workspaceName, - url: location.origin, token: key, - openRecent: "true", + agent: agentName, + folder: folderPath, }); - if (agentName) { - query.set("agent", agentName); - } - if (folderPath) { - query.set("folder", folderPath); - } - - location.href = `vscode://coder.coder-remote/open?${query.toString()}`; }) .catch((ex) => { console.error(ex); @@ -163,20 +156,13 @@ const VSCodeInsidersButton: FC = ({ setLoading(true); API.getApiKey() .then(({ key }) => { - const query = new URLSearchParams({ + location.href = getVSCodeHref("vscode-insiders", { owner: userName, workspace: workspaceName, - url: location.origin, token: key, + agent: agentName, + folder: folderPath, }); - if (agentName) { - query.set("agent", agentName); - } - if (folderPath) { - query.set("folder", folderPath); - } - - location.href = `vscode-insiders://coder.coder-remote/open?${query.toString()}`; }) .catch((ex) => { console.error(ex); diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 2fe94e0260a8f..92dcee60dec96 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -3,6 +3,7 @@ import Star from "@mui/icons-material/Star"; import Checkbox from "@mui/material/Checkbox"; import Skeleton from "@mui/material/Skeleton"; import { templateVersion } from "api/queries/templates"; +import { apiKey } from "api/queries/users"; import { cancelBuild, deleteWorkspace, @@ -19,6 +20,8 @@ import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; import { Button } from "components/Button/Button"; +import { VSCodeIcon } from "components/Icons/VSCodeIcon"; +import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon"; import { InfoTooltip } from "components/InfoTooltip/InfoTooltip"; import { Spinner } from "components/Spinner/Spinner"; import { Stack } from "components/Stack/Stack"; @@ -49,7 +52,17 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { useAuthenticated } from "hooks"; import { useClickableTableRow } from "hooks/useClickableTableRow"; -import { BanIcon, PlayIcon, RefreshCcwIcon, SquareIcon } from "lucide-react"; +import { + BanIcon, + PlayIcon, + RefreshCcwIcon, + SquareTerminalIcon, +} from "lucide-react"; +import { + getTerminalHref, + getVSCodeHref, + openAppInNewWindow, +} from "modules/apps/apps"; import { useDashboard } from "modules/dashboard/useDashboard"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge"; @@ -59,6 +72,7 @@ import { useWorkspaceUpdate, } from "modules/workspaces/WorkspaceUpdateDialogs"; import { abilitiesByWorkspaceStatus } from "modules/workspaces/actions"; +import type React from "react"; import { type FC, type PropsWithChildren, @@ -534,6 +548,10 @@ const WorkspaceActionsCell: FC = ({ return (
+ {workspace.latest_build.status === "running" && ( + + )} + {abilities.actions.includes("start") && ( startWorkspaceMutation.mutate({})} @@ -557,18 +575,6 @@ const WorkspaceActionsCell: FC = ({ )} - {abilities.actions.includes("stop") && ( - { - stopWorkspaceMutation.mutate({}); - }} - isLoading={stopWorkspaceMutation.isLoading} - label="Stop workspace" - > - - - )} - {abilities.canCancel && ( = ({ }; type PrimaryActionProps = PropsWithChildren<{ - onClick: () => void; - isLoading: boolean; label: string; + isLoading?: boolean; + onClick: () => void; }>; const PrimaryAction: FC = ({ @@ -626,3 +632,127 @@ const PrimaryAction: FC = ({ ); }; + +type WorkspaceAppsProps = { + workspace: Workspace; +}; + +const WorkspaceApps: FC = ({ workspace }) => { + const { data: apiKeyRes } = useQuery(apiKey()); + const token = apiKeyRes?.key; + + /** + * Coder is pretty flexible and allows an enormous variety of use cases, such + * as having multiple resources with many agents, but they are not common. The + * most common scenario is to have one single compute resource with one single + * agent containing all the apps. Lets test this getting the apps for the + * first resource, and first agent - they are sorted to return the compute + * resource first - and see what customers and ourselves, using dogfood, think + * about that. + */ + const agent = workspace.latest_build.resources + .filter((r) => !r.hide) + .at(0) + ?.agents?.at(0); + if (!agent) { + return null; + } + + const buttons: ReactNode[] = []; + + if (agent.display_apps.includes("vscode")) { + buttons.push( + + + , + ); + } + + if (agent.display_apps.includes("vscode_insiders")) { + buttons.push( + + + , + ); + } + + if (agent.display_apps.includes("web_terminal")) { + const href = getTerminalHref({ + username: workspace.owner_name, + workspace: workspace.name, + agent: agent.name, + }); + buttons.push( + { + e.preventDefault(); + openAppInNewWindow("Terminal", href); + }} + label="Open Terminal" + > + + , + ); + } + + return buttons; +}; + +type AppLinkProps = PropsWithChildren<{ + label: string; + href: string; + isLoading?: boolean; + onClick?: (e: React.MouseEvent) => void; +}>; + +const AppLink: FC = ({ + href, + isLoading, + label, + children, + onClick, +}) => { + return ( + + + + + + {label} + + + ); +};