feat: display builtin apps on workspaces table (#17695)

Related to https://github.com/coder/coder/issues/17311

<img width="1624" alt="Screenshot 2025-05-06 at 16 20 40"
src="https://github.com/user-attachments/assets/932f6034-9f8a-45d7-bf8d-d330dcca683d"
/>
This commit is contained in:
Bruno Quaresma
2025-05-07 14:26:21 -03:00
committed by GitHub
parent 9fe5b71d31
commit 6ac1bd807c
4 changed files with 214 additions and 54 deletions

View File

@ -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");
};

View File

@ -1,13 +1,9 @@
import { TerminalIcon } from "components/Icons/TerminalIcon"; import { TerminalIcon } from "components/Icons/TerminalIcon";
import { getTerminalHref, openAppInNewWindow } from "modules/apps/apps";
import type { FC, MouseEvent } from "react"; import type { FC, MouseEvent } from "react";
import { generateRandomString } from "utils/random";
import { AgentButton } from "../AgentButton"; import { AgentButton } from "../AgentButton";
import { DisplayAppNameMap } from "../AppLink/AppLink"; import { DisplayAppNameMap } from "../AppLink/AppLink";
const Language = {
terminalTitle: (identifier: string): string => `Terminal - ${identifier}`,
};
export interface TerminalLinkProps { export interface TerminalLinkProps {
workspaceName: string; workspaceName: string;
agentName?: string; agentName?: string;
@ -28,14 +24,12 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
workspaceName, workspaceName,
containerName, containerName,
}) => { }) => {
const params = new URLSearchParams(); const href = getTerminalHref({
if (containerName) { username: userName,
params.append("container", containerName); workspace: workspaceName,
} agent: agentName,
// Always use the primary for the terminal link. This is a relative link. container: containerName,
const href = `/@${userName}/${workspaceName}${ });
agentName ? `.${agentName}` : ""
}/terminal?${params.toString()}`;
return ( return (
<AgentButton asChild> <AgentButton asChild>
@ -43,11 +37,7 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
href={href} href={href}
onClick={(event: MouseEvent<HTMLElement>) => { onClick={(event: MouseEvent<HTMLElement>) => {
event.preventDefault(); event.preventDefault();
window.open( openAppInNewWindow("Terminal", href);
href,
Language.terminalTitle(generateRandomString(12)),
"width=900,height=600",
);
}} }}
> >
<TerminalIcon /> <TerminalIcon />

View File

@ -5,6 +5,7 @@ import type { DisplayApp } from "api/typesGenerated";
import { VSCodeIcon } from "components/Icons/VSCodeIcon"; import { VSCodeIcon } from "components/Icons/VSCodeIcon";
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon"; import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { getVSCodeHref } from "modules/apps/apps";
import { type FC, useRef, useState } from "react"; import { type FC, useRef, useState } from "react";
import { AgentButton } from "../AgentButton"; import { AgentButton } from "../AgentButton";
import { DisplayAppNameMap } from "../AppLink/AppLink"; import { DisplayAppNameMap } from "../AppLink/AppLink";
@ -118,21 +119,13 @@ const VSCodeButton: FC<VSCodeDesktopButtonProps> = ({
setLoading(true); setLoading(true);
API.getApiKey() API.getApiKey()
.then(({ key }) => { .then(({ key }) => {
const query = new URLSearchParams({ location.href = getVSCodeHref("vscode", {
owner: userName, owner: userName,
workspace: workspaceName, workspace: workspaceName,
url: location.origin,
token: key, 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) => { .catch((ex) => {
console.error(ex); console.error(ex);
@ -163,20 +156,13 @@ const VSCodeInsidersButton: FC<VSCodeDesktopButtonProps> = ({
setLoading(true); setLoading(true);
API.getApiKey() API.getApiKey()
.then(({ key }) => { .then(({ key }) => {
const query = new URLSearchParams({ location.href = getVSCodeHref("vscode-insiders", {
owner: userName, owner: userName,
workspace: workspaceName, workspace: workspaceName,
url: location.origin,
token: key, 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) => { .catch((ex) => {
console.error(ex); console.error(ex);

View File

@ -3,6 +3,7 @@ import Star from "@mui/icons-material/Star";
import Checkbox from "@mui/material/Checkbox"; import Checkbox from "@mui/material/Checkbox";
import Skeleton from "@mui/material/Skeleton"; import Skeleton from "@mui/material/Skeleton";
import { templateVersion } from "api/queries/templates"; import { templateVersion } from "api/queries/templates";
import { apiKey } from "api/queries/users";
import { import {
cancelBuild, cancelBuild,
deleteWorkspace, deleteWorkspace,
@ -19,6 +20,8 @@ import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
import { Button } from "components/Button/Button"; 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 { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
import { Spinner } from "components/Spinner/Spinner"; import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
@ -49,7 +52,17 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { useAuthenticated } from "hooks"; import { useAuthenticated } from "hooks";
import { useClickableTableRow } from "hooks/useClickableTableRow"; 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 { useDashboard } from "modules/dashboard/useDashboard";
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge"; import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge";
@ -59,6 +72,7 @@ import {
useWorkspaceUpdate, useWorkspaceUpdate,
} from "modules/workspaces/WorkspaceUpdateDialogs"; } from "modules/workspaces/WorkspaceUpdateDialogs";
import { abilitiesByWorkspaceStatus } from "modules/workspaces/actions"; import { abilitiesByWorkspaceStatus } from "modules/workspaces/actions";
import type React from "react";
import { import {
type FC, type FC,
type PropsWithChildren, type PropsWithChildren,
@ -534,6 +548,10 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
return ( return (
<TableCell> <TableCell>
<div className="flex gap-1 justify-end"> <div className="flex gap-1 justify-end">
{workspace.latest_build.status === "running" && (
<WorkspaceApps workspace={workspace} />
)}
{abilities.actions.includes("start") && ( {abilities.actions.includes("start") && (
<PrimaryAction <PrimaryAction
onClick={() => startWorkspaceMutation.mutate({})} onClick={() => startWorkspaceMutation.mutate({})}
@ -557,18 +575,6 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
</> </>
)} )}
{abilities.actions.includes("stop") && (
<PrimaryAction
onClick={() => {
stopWorkspaceMutation.mutate({});
}}
isLoading={stopWorkspaceMutation.isLoading}
label="Stop workspace"
>
<SquareIcon />
</PrimaryAction>
)}
{abilities.canCancel && ( {abilities.canCancel && (
<PrimaryAction <PrimaryAction
onClick={cancelBuildMutation.mutate} onClick={cancelBuildMutation.mutate}
@ -594,9 +600,9 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
}; };
type PrimaryActionProps = PropsWithChildren<{ type PrimaryActionProps = PropsWithChildren<{
onClick: () => void;
isLoading: boolean;
label: string; label: string;
isLoading?: boolean;
onClick: () => void;
}>; }>;
const PrimaryAction: FC<PrimaryActionProps> = ({ const PrimaryAction: FC<PrimaryActionProps> = ({
@ -626,3 +632,127 @@ const PrimaryAction: FC<PrimaryActionProps> = ({
</TooltipProvider> </TooltipProvider>
); );
}; };
type WorkspaceAppsProps = {
workspace: Workspace;
};
const WorkspaceApps: FC<WorkspaceAppsProps> = ({ 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(
<AppLink
isLoading={!token}
label="Open VSCode"
href={getVSCodeHref("vscode", {
owner: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
token: apiKeyRes?.key ?? "",
folder: agent.expanded_directory,
})}
>
<VSCodeIcon />
</AppLink>,
);
}
if (agent.display_apps.includes("vscode_insiders")) {
buttons.push(
<AppLink
label="Open VSCode Insiders"
isLoading={!token}
href={getVSCodeHref("vscode-insiders", {
owner: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
token: apiKeyRes?.key ?? "",
folder: agent.expanded_directory,
})}
>
<VSCodeInsidersIcon />
</AppLink>,
);
}
if (agent.display_apps.includes("web_terminal")) {
const href = getTerminalHref({
username: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
});
buttons.push(
<AppLink
href={href}
onClick={(e) => {
e.preventDefault();
openAppInNewWindow("Terminal", href);
}}
label="Open Terminal"
>
<SquareTerminalIcon />
</AppLink>,
);
}
return buttons;
};
type AppLinkProps = PropsWithChildren<{
label: string;
href: string;
isLoading?: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
}>;
const AppLink: FC<AppLinkProps> = ({
href,
isLoading,
label,
children,
onClick,
}) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon-lg" asChild>
<a
className={isLoading ? "animate-pulse" : ""}
href={href}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
>
{children}
<span className="sr-only">{label}</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};