mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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:
54
site/src/modules/apps/apps.ts
Normal file
54
site/src/modules/apps/apps.ts
Normal 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");
|
||||||
|
};
|
@ -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 />
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Reference in New Issue
Block a user