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 { 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<TerminalLinkProps> = ({
|
||||
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 (
|
||||
<AgentButton asChild>
|
||||
@ -43,11 +37,7 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
|
||||
href={href}
|
||||
onClick={(event: MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
window.open(
|
||||
href,
|
||||
Language.terminalTitle(generateRandomString(12)),
|
||||
"width=900,height=600",
|
||||
);
|
||||
openAppInNewWindow("Terminal", href);
|
||||
}}
|
||||
>
|
||||
<TerminalIcon />
|
||||
|
@ -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<VSCodeDesktopButtonProps> = ({
|
||||
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<VSCodeDesktopButtonProps> = ({
|
||||
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);
|
||||
|
@ -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<WorkspaceActionsCellProps> = ({
|
||||
return (
|
||||
<TableCell>
|
||||
<div className="flex gap-1 justify-end">
|
||||
{workspace.latest_build.status === "running" && (
|
||||
<WorkspaceApps workspace={workspace} />
|
||||
)}
|
||||
|
||||
{abilities.actions.includes("start") && (
|
||||
<PrimaryAction
|
||||
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 && (
|
||||
<PrimaryAction
|
||||
onClick={cancelBuildMutation.mutate}
|
||||
@ -594,9 +600,9 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
|
||||
};
|
||||
|
||||
type PrimaryActionProps = PropsWithChildren<{
|
||||
onClick: () => void;
|
||||
isLoading: boolean;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
const PrimaryAction: FC<PrimaryActionProps> = ({
|
||||
@ -626,3 +632,127 @@ const PrimaryAction: FC<PrimaryActionProps> = ({
|
||||
</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