mirror of https://github.com/coder/coder.git
322 lines
8.8 KiB
TypeScript
322 lines
8.8 KiB
TypeScript
import { makeStyles } from "@material-ui/core/styles"
|
|
import { useMachine } from "@xstate/react"
|
|
import { Stack } from "components/Stack/Stack"
|
|
import { FC, useEffect, useRef, useState } from "react"
|
|
import { Helmet } from "react-helmet-async"
|
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom"
|
|
import { colors } from "theme/colors"
|
|
import { v4 as uuidv4 } from "uuid"
|
|
import * as XTerm from "xterm"
|
|
import { FitAddon } from "xterm-addon-fit"
|
|
import { WebLinksAddon } from "xterm-addon-web-links"
|
|
import "xterm/css/xterm.css"
|
|
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
|
import { pageTitle } from "../../util/page"
|
|
import { terminalMachine } from "../../xServices/terminal/terminalXService"
|
|
|
|
export const Language = {
|
|
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
|
|
workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ",
|
|
websocketErrorMessagePrefix: "WebSocket failed: ",
|
|
}
|
|
|
|
const useReloading = (isDisconnected: boolean) => {
|
|
const [status, setStatus] = useState<"reloading" | "notReloading">(
|
|
"notReloading",
|
|
)
|
|
|
|
// Retry connection on key press when it is disconnected
|
|
useEffect(() => {
|
|
if (!isDisconnected) {
|
|
return
|
|
}
|
|
|
|
const keyDownHandler = () => {
|
|
setStatus("reloading")
|
|
window.location.reload()
|
|
}
|
|
|
|
document.addEventListener("keydown", keyDownHandler)
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", keyDownHandler)
|
|
}
|
|
}, [isDisconnected])
|
|
|
|
return {
|
|
status,
|
|
}
|
|
}
|
|
|
|
const TerminalPage: FC<
|
|
React.PropsWithChildren<{
|
|
readonly renderer?: XTerm.RendererType
|
|
}>
|
|
> = ({ renderer }) => {
|
|
const navigate = useNavigate()
|
|
const styles = useStyles()
|
|
const { username, workspace } = useParams()
|
|
const xtermRef = useRef<HTMLDivElement>(null)
|
|
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null)
|
|
const [fitAddon, setFitAddon] = useState<FitAddon | null>(null)
|
|
const [searchParams] = useSearchParams()
|
|
// The reconnection token is a unique token that identifies
|
|
// a terminal session. It's generated by the client to reduce
|
|
// a round-trip, and must be a UUIDv4.
|
|
const reconnectionToken = searchParams.get("reconnect") ?? uuidv4()
|
|
const command = searchParams.get("command") || undefined
|
|
// 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: workspaceNameParts?.[0],
|
|
username: username,
|
|
command: command,
|
|
},
|
|
actions: {
|
|
readMessage: (_, event) => {
|
|
if (typeof event.data === "string") {
|
|
// This exclusively occurs when testing.
|
|
// "jest-websocket-mock" doesn't support ArrayBuffer.
|
|
terminal?.write(event.data)
|
|
} else {
|
|
terminal?.write(new Uint8Array(event.data))
|
|
}
|
|
},
|
|
},
|
|
})
|
|
const isConnected = terminalState.matches("connected")
|
|
const isDisconnected = terminalState.matches("disconnected")
|
|
const {
|
|
workspaceError,
|
|
workspaceAgentError,
|
|
workspaceAgent,
|
|
websocketError,
|
|
} = terminalState.context
|
|
const reloading = useReloading(isDisconnected)
|
|
|
|
// Create the terminal!
|
|
useEffect(() => {
|
|
if (!xtermRef.current) {
|
|
return
|
|
}
|
|
const terminal = new XTerm.Terminal({
|
|
allowTransparency: true,
|
|
disableStdin: false,
|
|
fontFamily: MONOSPACE_FONT_FAMILY,
|
|
fontSize: 16,
|
|
theme: {
|
|
background: colors.gray[16],
|
|
},
|
|
rendererType: renderer,
|
|
})
|
|
const fitAddon = new FitAddon()
|
|
setFitAddon(fitAddon)
|
|
terminal.loadAddon(fitAddon)
|
|
terminal.loadAddon(new WebLinksAddon())
|
|
terminal.onData((data) => {
|
|
sendEvent({
|
|
type: "WRITE",
|
|
request: {
|
|
data: data,
|
|
},
|
|
})
|
|
})
|
|
terminal.onResize((event) => {
|
|
sendEvent({
|
|
type: "WRITE",
|
|
request: {
|
|
height: event.rows,
|
|
width: event.cols,
|
|
},
|
|
})
|
|
})
|
|
setTerminal(terminal)
|
|
terminal.open(xtermRef.current)
|
|
const listener = () => {
|
|
// This will trigger a resize event on the terminal.
|
|
fitAddon.fit()
|
|
}
|
|
window.addEventListener("resize", listener)
|
|
return () => {
|
|
window.removeEventListener("resize", listener)
|
|
terminal.dispose()
|
|
}
|
|
}, [renderer, sendEvent, xtermRef])
|
|
|
|
// Triggers the initial terminal connection using
|
|
// the reconnection token and workspace name found
|
|
// from the router.
|
|
useEffect(() => {
|
|
if (searchParams.get("reconnect") === reconnectionToken) {
|
|
return
|
|
}
|
|
searchParams.set("reconnect", reconnectionToken)
|
|
navigate(
|
|
{
|
|
search: searchParams.toString(),
|
|
},
|
|
{
|
|
replace: true,
|
|
},
|
|
)
|
|
}, [searchParams, navigate, reconnectionToken])
|
|
|
|
// Apply terminal options based on connection state.
|
|
useEffect(() => {
|
|
if (!terminal || !fitAddon) {
|
|
return
|
|
}
|
|
|
|
// We have to fit twice here. It's unknown why, but
|
|
// the first fit will overflow slightly in some
|
|
// scenarios. Applying a second fit resolves this.
|
|
fitAddon.fit()
|
|
fitAddon.fit()
|
|
|
|
if (!isConnected) {
|
|
// Disable user input when not connected.
|
|
terminal.options = {
|
|
disableStdin: true,
|
|
}
|
|
if (workspaceError instanceof Error) {
|
|
terminal.writeln(
|
|
Language.workspaceErrorMessagePrefix + workspaceError.message,
|
|
)
|
|
}
|
|
if (workspaceAgentError instanceof Error) {
|
|
terminal.writeln(
|
|
Language.workspaceAgentErrorMessagePrefix +
|
|
workspaceAgentError.message,
|
|
)
|
|
}
|
|
if (websocketError instanceof Error) {
|
|
terminal.writeln(
|
|
Language.websocketErrorMessagePrefix + websocketError.message,
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
// The terminal should be cleared on each reconnect
|
|
// because all data is re-rendered from the backend.
|
|
terminal.clear()
|
|
|
|
// Focusing on connection allows users to reload the
|
|
// page and start typing immediately.
|
|
terminal.focus()
|
|
terminal.options = {
|
|
disableStdin: false,
|
|
windowsMode: workspaceAgent?.operating_system === "windows",
|
|
}
|
|
|
|
// Update the terminal size post-fit.
|
|
sendEvent({
|
|
type: "WRITE",
|
|
request: {
|
|
height: terminal.rows,
|
|
width: terminal.cols,
|
|
},
|
|
})
|
|
}, [
|
|
workspaceError,
|
|
workspaceAgentError,
|
|
websocketError,
|
|
workspaceAgent,
|
|
terminal,
|
|
fitAddon,
|
|
isConnected,
|
|
sendEvent,
|
|
])
|
|
|
|
return (
|
|
<>
|
|
<Helmet>
|
|
<title>
|
|
{terminalState.context.workspace
|
|
? pageTitle(
|
|
`Terminal · ${terminalState.context.workspace.owner_name}/${terminalState.context.workspace.name}`,
|
|
)
|
|
: ""}
|
|
</title>
|
|
</Helmet>
|
|
{/* This overlay makes it more obvious that the terminal is disconnected. */}
|
|
{/* It's nice for situations where Coder restarts, and they are temporarily disconnected. */}
|
|
<div className={`${styles.overlay} ${isDisconnected ? "" : "connected"}`}>
|
|
{reloading.status === "reloading" ? (
|
|
<span className={styles.overlayText}>Reloading...</span>
|
|
) : (
|
|
<Stack spacing={0.5} alignItems="center">
|
|
<span className={styles.overlayText}>Disconnected</span>
|
|
<span className={styles.overlaySubtext}>
|
|
Press any key to retry
|
|
</span>
|
|
</Stack>
|
|
)}
|
|
</div>
|
|
<div className={styles.terminal} ref={xtermRef} data-testid="terminal" />
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default TerminalPage
|
|
|
|
const useStyles = makeStyles((theme) => ({
|
|
overlay: {
|
|
position: "absolute",
|
|
pointerEvents: "none",
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 0,
|
|
right: 0,
|
|
zIndex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
display: "flex",
|
|
color: "white",
|
|
fontSize: 16,
|
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
|
backdropFilter: "blur(4px)",
|
|
"&.connected": {
|
|
opacity: 0,
|
|
},
|
|
},
|
|
overlayText: {
|
|
fontSize: 16,
|
|
fontWeight: 600,
|
|
},
|
|
overlaySubtext: {
|
|
fontSize: 14,
|
|
color: theme.palette.text.secondary,
|
|
},
|
|
terminal: {
|
|
width: "100vw",
|
|
height: "100vh",
|
|
overflow: "hidden",
|
|
// These styles attempt to mimic the VS Code scrollbar.
|
|
"& .xterm": {
|
|
padding: 4,
|
|
width: "100vw",
|
|
height: "100vh",
|
|
},
|
|
"& .xterm-viewport": {
|
|
// This is required to force full-width on the terminal.
|
|
// Otherwise there's a small white bar to the right of the scrollbar.
|
|
width: "auto !important",
|
|
},
|
|
"& .xterm-viewport::-webkit-scrollbar": {
|
|
width: "10px",
|
|
},
|
|
"& .xterm-viewport::-webkit-scrollbar-track": {
|
|
backgroundColor: "inherit",
|
|
},
|
|
"& .xterm-viewport::-webkit-scrollbar-thumb": {
|
|
minHeight: 20,
|
|
backgroundColor: "rgba(255, 255, 255, 0.18)",
|
|
},
|
|
},
|
|
}))
|