Skip to content

Commit 771a690

Browse files
committed
feat: display builtin apps on workspaces table
1 parent df0c6ed commit 771a690

File tree

4 files changed

+215
-52
lines changed

4 files changed

+215
-52
lines changed

site/src/modules/apps/apps.ts

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { generateRandomString } from "utils/random";
2+
3+
type GetVSCodeHrefParams = {
4+
owner: string;
5+
workspace: string;
6+
token: string;
7+
agent?: string;
8+
folder?: string;
9+
};
10+
11+
export const getVSCodeHref = (
12+
app: "vscode" | "vscode-insiders",
13+
{ owner, workspace, token, agent, folder }: GetVSCodeHrefParams,
14+
) => {
15+
const query = new URLSearchParams({
16+
owner,
17+
workspace,
18+
url: location.origin,
19+
token,
20+
openRecent: "true",
21+
});
22+
if (agent) {
23+
query.set("agent", agent);
24+
}
25+
if (folder) {
26+
query.set("folder", folder);
27+
}
28+
return `${app}://coder.coder-remote/open?${query}`;
29+
};
30+
31+
type GetTerminalHrefParams = {
32+
username: string;
33+
workspace: string;
34+
agent?: string;
35+
container?: string;
36+
};
37+
38+
export const getTerminalHref = ({
39+
username,
40+
workspace,
41+
agent,
42+
container,
43+
}: GetTerminalHrefParams) => {
44+
const params = new URLSearchParams();
45+
if (container) {
46+
params.append("container", container);
47+
}
48+
// Always use the primary for the terminal link. This is a relative link.
49+
return `/@${username}/${workspace}${
50+
agent ? `.${agent}` : ""
51+
}/terminal?${params}`;
52+
};
53+
54+
export const openAppInNewWindow = (name: string, href: string) => {
55+
window.open(
56+
href,
57+
`${name} - ${generateRandomString(12)}`,
58+
"width=900,height=600",
59+
);
60+
};

site/src/modules/resources/TerminalLink/TerminalLink.tsx

+8-18
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { TerminalIcon } from "components/Icons/TerminalIcon";
2+
import { getTerminalHref, openAppInNewWindow } from "modules/apps/apps";
23
import type { FC, MouseEvent } from "react";
3-
import { generateRandomString } from "utils/random";
44
import { AgentButton } from "../AgentButton";
55
import { DisplayAppNameMap } from "../AppLink/AppLink";
66

7-
const Language = {
8-
terminalTitle: (identifier: string): string => `Terminal - ${identifier}`,
9-
};
10-
117
export interface TerminalLinkProps {
128
workspaceName: string;
139
agentName?: string;
@@ -28,26 +24,20 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
2824
workspaceName,
2925
containerName,
3026
}) => {
31-
const params = new URLSearchParams();
32-
if (containerName) {
33-
params.append("container", containerName);
34-
}
35-
// Always use the primary for the terminal link. This is a relative link.
36-
const href = `/@${userName}/${workspaceName}${
37-
agentName ? `.${agentName}` : ""
38-
}/terminal?${params.toString()}`;
27+
const href = getTerminalHref({
28+
username: userName,
29+
workspace: workspaceName,
30+
agent: agentName,
31+
container: containerName,
32+
});
3933

4034
return (
4135
<AgentButton asChild>
4236
<a
4337
href={href}
4438
onClick={(event: MouseEvent<HTMLElement>) => {
4539
event.preventDefault();
46-
window.open(
47-
href,
48-
Language.terminalTitle(generateRandomString(12)),
49-
"width=900,height=600",
50-
);
40+
openAppInNewWindow("Terminal", href);
5141
}}
5242
>
5343
<TerminalIcon />

site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx

+9-19
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { DisplayApp } from "api/typesGenerated";
55
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
66
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
77
import { ChevronDownIcon } from "lucide-react";
8+
import { getVSCodeHref } from "modules/apps/apps";
89
import { type FC, useRef, useState } from "react";
910
import { AgentButton } from "../AgentButton";
1011
import { DisplayAppNameMap } from "../AppLink/AppLink";
@@ -118,21 +119,15 @@ const VSCodeButton: FC<VSCodeDesktopButtonProps> = ({
118119
setLoading(true);
119120
API.getApiKey()
120121
.then(({ key }) => {
121-
const query = new URLSearchParams({
122+
const href = getVSCodeHref("vscode", {
122123
owner: userName,
123124
workspace: workspaceName,
124-
url: location.origin,
125125
token: key,
126-
openRecent: "true",
126+
agent: agentName,
127+
folder: folderPath,
127128
});
128-
if (agentName) {
129-
query.set("agent", agentName);
130-
}
131-
if (folderPath) {
132-
query.set("folder", folderPath);
133-
}
134129

135-
location.href = `vscode://coder.coder-remote/open?${query.toString()}`;
130+
location.href = href;
136131
})
137132
.catch((ex) => {
138133
console.error(ex);
@@ -163,20 +158,15 @@ const VSCodeInsidersButton: FC<VSCodeDesktopButtonProps> = ({
163158
setLoading(true);
164159
API.getApiKey()
165160
.then(({ key }) => {
166-
const query = new URLSearchParams({
161+
const href = getVSCodeHref("vscode-insiders", {
167162
owner: userName,
168163
workspace: workspaceName,
169-
url: location.origin,
170164
token: key,
165+
agent: agentName,
166+
folder: folderPath,
171167
});
172-
if (agentName) {
173-
query.set("agent", agentName);
174-
}
175-
if (folderPath) {
176-
query.set("folder", folderPath);
177-
}
178168

179-
location.href = `vscode-insiders://coder.coder-remote/open?${query.toString()}`;
169+
location.href = href;
180170
})
181171
.catch((ex) => {
182172
console.error(ex);

site/src/pages/WorkspacesPage/WorkspacesTable.tsx

+138-15
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Star from "@mui/icons-material/Star";
33
import Checkbox from "@mui/material/Checkbox";
44
import Skeleton from "@mui/material/Skeleton";
55
import { templateVersion } from "api/queries/templates";
6+
import { apiKey } from "api/queries/users";
67
import {
78
cancelBuild,
89
deleteWorkspace,
@@ -19,6 +20,8 @@ import { Avatar } from "components/Avatar/Avatar";
1920
import { AvatarData } from "components/Avatar/AvatarData";
2021
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
2122
import { Button } from "components/Button/Button";
23+
import { VSCodeIcon } from "components/Icons/VSCodeIcon";
24+
import { VSCodeInsidersIcon } from "components/Icons/VSCodeInsidersIcon";
2225
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
2326
import { Spinner } from "components/Spinner/Spinner";
2427
import { Stack } from "components/Stack/Stack";
@@ -49,7 +52,17 @@ import dayjs from "dayjs";
4952
import relativeTime from "dayjs/plugin/relativeTime";
5053
import { useAuthenticated } from "hooks";
5154
import { useClickableTableRow } from "hooks/useClickableTableRow";
52-
import { BanIcon, PlayIcon, RefreshCcwIcon, SquareIcon } from "lucide-react";
55+
import {
56+
BanIcon,
57+
PlayIcon,
58+
RefreshCcwIcon,
59+
SquareTerminalIcon,
60+
} from "lucide-react";
61+
import {
62+
getTerminalHref,
63+
getVSCodeHref,
64+
openAppInNewWindow,
65+
} from "modules/apps/apps";
5366
import { useDashboard } from "modules/dashboard/useDashboard";
5467
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
5568
import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge";
@@ -59,6 +72,7 @@ import {
5972
useWorkspaceUpdate,
6073
} from "modules/workspaces/WorkspaceUpdateDialogs";
6174
import { abilitiesByWorkspaceStatus } from "modules/workspaces/actions";
75+
import type React from "react";
6276
import {
6377
type FC,
6478
type PropsWithChildren,
@@ -534,6 +548,10 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
534548
return (
535549
<TableCell>
536550
<div className="flex gap-1 justify-end">
551+
{workspace.latest_build.status === "running" && (
552+
<WorkspaceApps workspace={workspace} />
553+
)}
554+
537555
{abilities.actions.includes("start") && (
538556
<PrimaryAction
539557
onClick={() => startWorkspaceMutation.mutate({})}
@@ -557,18 +575,6 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
557575
</>
558576
)}
559577

560-
{abilities.actions.includes("stop") && (
561-
<PrimaryAction
562-
onClick={() => {
563-
stopWorkspaceMutation.mutate({});
564-
}}
565-
isLoading={stopWorkspaceMutation.isLoading}
566-
label="Stop workspace"
567-
>
568-
<SquareIcon />
569-
</PrimaryAction>
570-
)}
571-
572578
{abilities.canCancel && (
573579
<PrimaryAction
574580
onClick={cancelBuildMutation.mutate}
@@ -594,9 +600,9 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
594600
};
595601

596602
type PrimaryActionProps = PropsWithChildren<{
597-
onClick: () => void;
598-
isLoading: boolean;
599603
label: string;
604+
isLoading?: boolean;
605+
onClick: () => void;
600606
}>;
601607

602608
const PrimaryAction: FC<PrimaryActionProps> = ({
@@ -626,3 +632,120 @@ const PrimaryAction: FC<PrimaryActionProps> = ({
626632
</TooltipProvider>
627633
);
628634
};
635+
636+
type WorkspaceAppsProps = {
637+
workspace: Workspace;
638+
};
639+
640+
const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
641+
const buttons: ReactNode[] = [];
642+
const { data: apiKeyRes } = useQuery(apiKey());
643+
const token = apiKeyRes?.key;
644+
645+
const resource = workspace.latest_build.resources
646+
.filter((r) => !r.hide)
647+
.at(0);
648+
if (!resource) {
649+
return null;
650+
}
651+
652+
const agent = resource.agents?.at(0);
653+
if (!agent) {
654+
return null;
655+
}
656+
657+
if (agent.display_apps.includes("vscode")) {
658+
buttons.push(
659+
<AppLink
660+
isLoading={!token}
661+
label="Open VSCode"
662+
href={getVSCodeHref("vscode", {
663+
owner: workspace.owner_name,
664+
workspace: workspace.name,
665+
agent: agent.name,
666+
token: apiKeyRes?.key ?? "",
667+
folder: agent.expanded_directory,
668+
})}
669+
>
670+
<VSCodeIcon />
671+
</AppLink>,
672+
);
673+
}
674+
675+
if (agent.display_apps.includes("vscode_insiders")) {
676+
buttons.push(
677+
<AppLink
678+
label="Open VSCode Insiders"
679+
isLoading={!token}
680+
href={getVSCodeHref("vscode-insiders", {
681+
owner: workspace.owner_name,
682+
workspace: workspace.name,
683+
agent: agent.name,
684+
token: apiKeyRes?.key ?? "",
685+
folder: agent.expanded_directory,
686+
})}
687+
>
688+
<VSCodeInsidersIcon />
689+
</AppLink>,
690+
);
691+
}
692+
693+
if (agent.display_apps.includes("web_terminal")) {
694+
const href = getTerminalHref({
695+
username: workspace.owner_name,
696+
workspace: workspace.name,
697+
agent: agent.name,
698+
});
699+
buttons.push(
700+
<AppLink
701+
href={href}
702+
onClick={(e) => {
703+
e.preventDefault();
704+
openAppInNewWindow("Terminal", href);
705+
}}
706+
label="Open Terminal"
707+
>
708+
<SquareTerminalIcon />
709+
</AppLink>,
710+
);
711+
}
712+
713+
return buttons;
714+
};
715+
716+
type AppLinkProps = PropsWithChildren<{
717+
label: string;
718+
href: string;
719+
isLoading?: boolean;
720+
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
721+
}>;
722+
723+
const AppLink: FC<AppLinkProps> = ({
724+
href,
725+
isLoading,
726+
label,
727+
children,
728+
onClick,
729+
}) => {
730+
return (
731+
<TooltipProvider>
732+
<Tooltip>
733+
<TooltipTrigger asChild>
734+
<Button variant="outline" size="icon-lg" asChild>
735+
<a
736+
href={href}
737+
onClick={(e) => {
738+
e.stopPropagation();
739+
onClick?.(e);
740+
}}
741+
>
742+
<Spinner loading={isLoading}>{children}</Spinner>
743+
<span className="sr-only">{label}</span>
744+
</a>
745+
</Button>
746+
</TooltipTrigger>
747+
<TooltipContent>{label}</TooltipContent>
748+
</Tooltip>
749+
</TooltipProvider>
750+
);
751+
};

0 commit comments

Comments
 (0)