-
Notifications
You must be signed in to change notification settings - Fork 894
feat: display builtin apps on workspaces table #17695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
import { generateRandomString } from "utils/random"; | ||
|
||
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, | ||
`${name} - ${generateRandomString(12)}`, | ||
"width=900,height=600", | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,15 @@ const VSCodeButton: FC<VSCodeDesktopButtonProps> = ({ | |
setLoading(true); | ||
API.getApiKey() | ||
.then(({ key }) => { | ||
const query = new URLSearchParams({ | ||
const 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()}`; | ||
location.href = href; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: location.href = getVSCodeHref("vscode", {
owner: userName,
workspace: workspaceName,
token: key,
agent: agentName,
folder: folderPath,
}); |
||
}) | ||
.catch((ex) => { | ||
console.error(ex); | ||
|
@@ -163,20 +158,15 @@ const VSCodeInsidersButton: FC<VSCodeDesktopButtonProps> = ({ | |
setLoading(true); | ||
API.getApiKey() | ||
.then(({ key }) => { | ||
const query = new URLSearchParams({ | ||
const 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()}`; | ||
location.href = href; | ||
brettkolodny marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}) | ||
.catch((ex) => { | ||
console.error(ex); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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> | ||
)} | ||
|
||
Comment on lines
-560
to
-571
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we not want users to be able to stop workspaces from the table view anymore? I see in the designs there is a dropdown menu that will have this. Is that going to be a fast follow to this PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the "more options" menu will be implemented in a different PR. |
||
{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,120 @@ const PrimaryAction: FC<PrimaryActionProps> = ({ | |
</TooltipProvider> | ||
); | ||
}; | ||
|
||
type WorkspaceAppsProps = { | ||
workspace: Workspace; | ||
}; | ||
|
||
const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => { | ||
const buttons: ReactNode[] = []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if you want to use this variable I think it'd be better to have it closer to where it's actually used |
||
const { data: apiKeyRes } = useQuery(apiKey()); | ||
const token = apiKeyRes?.key; | ||
|
||
const resource = workspace.latest_build.resources | ||
.filter((r) => !r.hide) | ||
.at(0); | ||
if (!resource) { | ||
return null; | ||
} | ||
|
||
const agent = resource.agents?.at(0); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably out of the scope of this PR but can a workspace have more than one resource that can then also have more one agent? Do we need to check all the resources and all the agents? Right now we're only checking if the first resource's first agent has one these apps, do we need to check if all of them do? Something like: const agents = workspace.latest_build.resources
.filter((r) => !r.hide)
.flatMap((resource) => resource.agents ?? []);
if (agents.length === 0) {
return null;
}
const displayApps = new Set(agents.flatMap((agent) => agent.display_apps))
... But if this is the case a lot of the other code would need to be re-thought too There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes.
Nops.
Not right now. For each agent we have two type of apps, built in apps such as VSCode, VSCode insiders, and terminal, and we also have the apps defined by the user (it is coming in a next PR). If we want to show the apps from all the agents, we would need to include all of them, which could make the UI a bit confusing like having two or three terminal buttons. 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. From my experience dealing with customers, the most common scenario is to have one single compute resource with one single agent containing all the apps, but Idk this in numbers. My idea is to test this as it is, 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Understood! Left a comment above that I think this would be useful info to document into the code |
||
if (!agent) { | ||
return null; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: These two could be combined into one variable and one check There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeap, but I think this way the code make it more explicit that we are looking for the first non-hide resource and after, the first agent. What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Honestly I don't think either are particularly readable 😅 . I think my preference would be the one variable with a comment above explaining. I think a comment would be especially if it includes this info from your comment below:
|
||
|
||
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>, | ||
); | ||
} | ||
brettkolodny marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 | ||
href={href} | ||
onClick={(e) => { | ||
e.stopPropagation(); | ||
onClick?.(e); | ||
}} | ||
> | ||
<Spinner loading={isLoading}>{children}</Spinner> | ||
<span className="sr-only">{label}</span> | ||
</a> | ||
</Button> | ||
</TooltipTrigger> | ||
<TooltipContent>{label}</TooltipContent> | ||
</Tooltip> | ||
</TooltipProvider> | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does setting the target like this do? The MDN docs make it out like this is only useful if you wanted to target the new context with an anchor or form. More over it actually says that whitespace isn't allowed. Would one of the built in target keywords be better for this?