mirror of
https://github.com/coder/coder.git
synced 2026-06-06 14:38:23 +00:00
feat: add terminal link component (#1538)
* Fix not being able to specify agent when connecting to terminal The `workspace.agent` syntax was only used when fetching the agent and not the workspace so it would try to fetch a workspace called `workspace.agent` instead of just `workspace`. * Add terminal link component Currently it does not show anywhere but we can drop it into the resources card later.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { Story } from "@storybook/react"
|
||||
import React from "react"
|
||||
import { MockWorkspace } from "../../testHelpers/renderHelpers"
|
||||
import { TerminalLink, TerminalLinkProps } from "./TerminalLink"
|
||||
|
||||
export default {
|
||||
title: "components/TerminalLink",
|
||||
component: TerminalLink,
|
||||
}
|
||||
|
||||
const Template: Story<TerminalLinkProps> = (args) => <TerminalLink {...args} />
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
workspaceName: MockWorkspace.name,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "@material-ui/core/Link"
|
||||
import React from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
|
||||
export const Language = {
|
||||
linkText: "Open in terminal",
|
||||
}
|
||||
|
||||
export interface TerminalLinkProps {
|
||||
agentName?: TypesGen.WorkspaceAgent["name"]
|
||||
userName?: TypesGen.User["username"]
|
||||
workspaceName: TypesGen.Workspace["name"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a link to a terminal connected to the provided workspace agent. If
|
||||
* no agent is provided connect to the first agent.
|
||||
*
|
||||
* If no user name is provided "me" is used however it makes the link not
|
||||
* shareable.
|
||||
*/
|
||||
export const TerminalLink: React.FC<TerminalLinkProps> = ({ agentName, userName = "me", workspaceName }) => {
|
||||
return (
|
||||
<Link href={`/${userName}/${workspaceName}${agentName ? `.${agentName}` : ""}/terminal`} target="_blank">
|
||||
{Language.linkText}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import React from "react"
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
import { TextDecoder, TextEncoder } from "util"
|
||||
import { ReconnectingPTYRequest } from "../../api/types"
|
||||
import { history, MockWorkspaceAgent, render } from "../../testHelpers/renderHelpers"
|
||||
import { history, MockWorkspace, MockWorkspaceAgent, render } from "../../testHelpers/renderHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import TerminalPage, { Language } from "./TerminalPage"
|
||||
|
||||
@@ -52,7 +52,7 @@ const expectTerminalText = (container: HTMLElement, text: string) => {
|
||||
|
||||
describe("TerminalPage", () => {
|
||||
beforeEach(() => {
|
||||
history.push("/some-user/my-workspace/terminal")
|
||||
history.push(`/some-user/${MockWorkspace.name}/terminal`)
|
||||
})
|
||||
|
||||
it("shows an error if fetching organizations fails", async () => {
|
||||
@@ -146,4 +146,20 @@ describe("TerminalPage", () => {
|
||||
expect(req.width).toBeGreaterThan(0)
|
||||
server.close()
|
||||
})
|
||||
|
||||
it("supports workspace.agent syntax", async () => {
|
||||
// Given
|
||||
const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty")
|
||||
const text = "something to render"
|
||||
|
||||
// When
|
||||
history.push(`/some-user/${MockWorkspace.name}.${MockWorkspaceAgent.name}/terminal`)
|
||||
const { container } = renderTerminal()
|
||||
|
||||
// Then
|
||||
await server.connected
|
||||
server.send(text)
|
||||
await expectTerminalText(container, text)
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,10 +34,14 @@ const TerminalPage: React.FC<{
|
||||
const search = new URLSearchParams(location.search)
|
||||
return search.get("reconnect") ?? uuidv4()
|
||||
})
|
||||
// The workspace name is in the format:
|
||||
// <workspace name>[.<agent name>]
|
||||
const workspaceNameParts = workspace?.split(".")
|
||||
const [terminalState, sendEvent] = useMachine(terminalMachine, {
|
||||
context: {
|
||||
agentName: workspaceNameParts?.[1],
|
||||
reconnection: reconnectionToken,
|
||||
workspaceName: workspace,
|
||||
workspaceName: workspaceNameParts?.[0],
|
||||
username: username,
|
||||
},
|
||||
actions: {
|
||||
|
||||
@@ -80,7 +80,16 @@ export const handlers = [
|
||||
|
||||
// workspaces
|
||||
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||
if (req.params.workspaceName !== M.MockWorkspace.name) {
|
||||
return res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
message: "workspace not found",
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||
}
|
||||
}),
|
||||
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||
|
||||
@@ -14,13 +14,16 @@ export interface TerminalContext {
|
||||
websocketError?: Error | unknown
|
||||
|
||||
// Assigned by connecting!
|
||||
// The workspace agent is entirely optional. If the agent is omitted the
|
||||
// first agent will be used.
|
||||
agentName?: string
|
||||
username?: string
|
||||
workspaceName?: string
|
||||
reconnection?: string
|
||||
}
|
||||
|
||||
export type TerminalEvent =
|
||||
| { type: "CONNECT"; reconnection?: string; workspaceName?: string; username?: string }
|
||||
| { type: "CONNECT"; agentName?: string; reconnection?: string; workspaceName?: string; username?: string }
|
||||
| { type: "WRITE"; request: Types.ReconnectingPTYRequest }
|
||||
| { type: "READ"; data: ArrayBuffer }
|
||||
| { type: "DISCONNECT" }
|
||||
@@ -153,7 +156,7 @@ export const terminalMachine =
|
||||
getOrganizations: API.getOrganizations,
|
||||
getWorkspace: async (context) => {
|
||||
if (!context.organizations || !context.workspaceName) {
|
||||
throw new Error("organizations or workspace not set")
|
||||
throw new Error("organizations or workspace name not set")
|
||||
}
|
||||
return API.getWorkspaceByOwnerAndName(context.organizations[0].id, context.username, context.workspaceName)
|
||||
},
|
||||
@@ -161,11 +164,6 @@ export const terminalMachine =
|
||||
if (!context.workspace || !context.workspaceName) {
|
||||
throw new Error("workspace or workspace name is not set")
|
||||
}
|
||||
// The workspace name is in the format:
|
||||
// <workspace name>[.<agent name>]
|
||||
// The workspace agent is entirely optional.
|
||||
const workspaceNameParts = context.workspaceName.split(".")
|
||||
const agentName = workspaceNameParts[1]
|
||||
|
||||
const resources = await API.getWorkspaceResources(context.workspace.latest_build.id)
|
||||
|
||||
@@ -174,10 +172,10 @@ export const terminalMachine =
|
||||
if (!resource.agents || resource.agents.length < 1) {
|
||||
return
|
||||
}
|
||||
if (!agentName) {
|
||||
if (!context.agentName) {
|
||||
return resource.agents[0]
|
||||
}
|
||||
return resource.agents.find((agent) => agent.name === agentName)
|
||||
return resource.agents.find((agent) => agent.name === context.agentName)
|
||||
})
|
||||
.filter((a) => a)[0]
|
||||
if (!agent) {
|
||||
@@ -218,6 +216,7 @@ export const terminalMachine =
|
||||
actions: {
|
||||
assignConnection: assign((context, event) => ({
|
||||
...context,
|
||||
agentName: event.agentName ?? context.agentName,
|
||||
reconnection: event.reconnection ?? context.reconnection,
|
||||
workspaceName: event.workspaceName ?? context.workspaceName,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user