mirror of https://github.com/coder/coder.git
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import "xterm/css/xterm.css";
|
|
import type { Interpolation, Theme } from "@emotion/react";
|
|
import { type FC, useCallback, useEffect, useRef, useState } from "react";
|
|
import { Helmet } from "react-helmet-async";
|
|
import { useQuery } from "react-query";
|
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import * as XTerm from "xterm";
|
|
import { CanvasAddon } from "xterm-addon-canvas";
|
|
import { FitAddon } from "xterm-addon-fit";
|
|
import { Unicode11Addon } from "xterm-addon-unicode11";
|
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
|
import { WebglAddon } from "xterm-addon-webgl";
|
|
import { deploymentConfig } from "api/queries/deployment";
|
|
import { workspaceByOwnerAndName } from "api/queries/workspaces";
|
|
import { useProxy } from "contexts/ProxyContext";
|
|
import { ThemeOverride } from "contexts/ThemeProvider";
|
|
import themes from "theme";
|
|
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
|
import { pageTitle } from "utils/page";
|
|
import { openMaybePortForwardedURL } from "utils/portForward";
|
|
import { terminalWebsocketUrl } from "utils/terminal";
|
|
import { getMatchingAgentOrFirst } from "utils/workspace";
|
|
import { TerminalAlerts } from "./TerminalAlerts";
|
|
import type { ConnectionStatus } from "./types";
|
|
|
|
export const Language = {
|
|
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
|
|
workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ",
|
|
websocketErrorMessagePrefix: "WebSocket failed: ",
|
|
};
|
|
|
|
const TerminalPage: FC = () => {
|
|
// Maybe one day we'll support a light themed terminal, but terminal coloring
|
|
// is notably a pain because of assumptions certain programs might make about your
|
|
// background color.
|
|
const theme = themes.dark;
|
|
const navigate = useNavigate();
|
|
const { proxy, proxyLatencies } = useProxy();
|
|
const params = useParams() as { username: string; workspace: string };
|
|
const username = params.username.replace("@", "");
|
|
const terminalWrapperRef = useRef<HTMLDivElement>(null);
|
|
// The terminal is maintained as a state to trigger certain effects when it
|
|
// updates.
|
|
const [terminal, setTerminal] = useState<XTerm.Terminal>();
|
|
const [connectionStatus, setConnectionStatus] =
|
|
useState<ConnectionStatus>("initializing");
|
|
const [searchParams] = useSearchParams();
|
|
const isDebugging = searchParams.has("debug");
|
|
// 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 = params.workspace?.split(".");
|
|
const workspace = useQuery(
|
|
workspaceByOwnerAndName(username, workspaceNameParts?.[0]),
|
|
);
|
|
const workspaceAgent = workspace.data
|
|
? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1])
|
|
: undefined;
|
|
const selectedProxy = proxy.proxy;
|
|
const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined;
|
|
|
|
const config = useQuery(deploymentConfig());
|
|
const renderer = config.data?.config.web_terminal_renderer;
|
|
|
|
// handleWebLink handles opening of URLs in the terminal!
|
|
const handleWebLink = useCallback(
|
|
(uri: string) => {
|
|
openMaybePortForwardedURL(
|
|
uri,
|
|
proxy.preferredWildcardHostname,
|
|
workspaceAgent?.name,
|
|
workspace.data?.name,
|
|
username,
|
|
);
|
|
},
|
|
[workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname],
|
|
);
|
|
const handleWebLinkRef = useRef(handleWebLink);
|
|
useEffect(() => {
|
|
handleWebLinkRef.current = handleWebLink;
|
|
}, [handleWebLink]);
|
|
|
|
// Create the terminal!
|
|
const fitAddonRef = useRef<FitAddon>();
|
|
useEffect(() => {
|
|
if (!terminalWrapperRef.current || config.isLoading) {
|
|
return;
|
|
}
|
|
const terminal = new XTerm.Terminal({
|
|
allowProposedApi: true,
|
|
allowTransparency: true,
|
|
disableStdin: false,
|
|
fontFamily: MONOSPACE_FONT_FAMILY,
|
|
fontSize: 16,
|
|
theme: {
|
|
background: theme.palette.background.default,
|
|
},
|
|
});
|
|
if (renderer === "webgl") {
|
|
terminal.loadAddon(new WebglAddon());
|
|
} else if (renderer === "canvas") {
|
|
terminal.loadAddon(new CanvasAddon());
|
|
}
|
|
const fitAddon = new FitAddon();
|
|
fitAddonRef.current = fitAddon;
|
|
terminal.loadAddon(fitAddon);
|
|
terminal.loadAddon(new Unicode11Addon());
|
|
terminal.unicode.activeVersion = "11";
|
|
terminal.loadAddon(
|
|
new WebLinksAddon((_, uri) => {
|
|
handleWebLinkRef.current(uri);
|
|
}),
|
|
);
|
|
|
|
terminal.open(terminalWrapperRef.current);
|
|
|
|
// 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();
|
|
|
|
// This will trigger a resize event on the terminal.
|
|
const listener = () => fitAddon.fit();
|
|
window.addEventListener("resize", listener);
|
|
|
|
// Terminal is correctly sized and is ready to be used.
|
|
setTerminal(terminal);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", listener);
|
|
terminal.dispose();
|
|
};
|
|
}, [config.isLoading, renderer, theme.palette.background.default]);
|
|
|
|
// Updates the reconnection token into the URL if necessary.
|
|
useEffect(() => {
|
|
if (searchParams.get("reconnect") === reconnectionToken) {
|
|
return;
|
|
}
|
|
searchParams.set("reconnect", reconnectionToken);
|
|
navigate(
|
|
{
|
|
search: searchParams.toString(),
|
|
},
|
|
{
|
|
replace: true,
|
|
},
|
|
);
|
|
}, [navigate, reconnectionToken, searchParams]);
|
|
|
|
// Hook up the terminal through a web socket.
|
|
useEffect(() => {
|
|
if (!terminal) {
|
|
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();
|
|
|
|
// Disable input while we connect.
|
|
terminal.options.disableStdin = true;
|
|
|
|
// Show a message if we failed to find the workspace or agent.
|
|
if (workspace.isLoading) {
|
|
return;
|
|
} else if (workspace.error instanceof Error) {
|
|
terminal.writeln(
|
|
Language.workspaceErrorMessagePrefix + workspace.error.message,
|
|
);
|
|
setConnectionStatus("disconnected");
|
|
return;
|
|
} else if (!workspaceAgent) {
|
|
terminal.writeln(
|
|
Language.workspaceAgentErrorMessagePrefix +
|
|
"no agent found with ID, is the workspace started?",
|
|
);
|
|
setConnectionStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
// Hook up terminal events to the websocket.
|
|
let websocket: WebSocket | null;
|
|
const disposers = [
|
|
terminal.onData((data) => {
|
|
websocket?.send(
|
|
new TextEncoder().encode(JSON.stringify({ data: data })),
|
|
);
|
|
}),
|
|
terminal.onResize((event) => {
|
|
websocket?.send(
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
height: event.rows,
|
|
width: event.cols,
|
|
}),
|
|
),
|
|
);
|
|
}),
|
|
];
|
|
|
|
let disposed = false;
|
|
|
|
// Open the web socket and hook it up to the terminal.
|
|
terminalWebsocketUrl(
|
|
proxy.preferredPathAppURL,
|
|
reconnectionToken,
|
|
workspaceAgent.id,
|
|
command,
|
|
terminal.rows,
|
|
terminal.cols,
|
|
)
|
|
.then((url) => {
|
|
if (disposed) {
|
|
return; // Unmounted while we waited for the async call.
|
|
}
|
|
websocket = new WebSocket(url);
|
|
websocket.binaryType = "arraybuffer";
|
|
websocket.addEventListener("open", () => {
|
|
// Now that we are connected, allow user input.
|
|
terminal.options = {
|
|
disableStdin: false,
|
|
windowsMode: workspaceAgent?.operating_system === "windows",
|
|
};
|
|
// Send the initial size.
|
|
websocket?.send(
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
height: terminal.rows,
|
|
width: terminal.cols,
|
|
}),
|
|
),
|
|
);
|
|
setConnectionStatus("connected");
|
|
});
|
|
websocket.addEventListener("error", () => {
|
|
terminal.options.disableStdin = true;
|
|
terminal.writeln(
|
|
Language.websocketErrorMessagePrefix + "socket errored",
|
|
);
|
|
setConnectionStatus("disconnected");
|
|
});
|
|
websocket.addEventListener("close", () => {
|
|
terminal.options.disableStdin = true;
|
|
setConnectionStatus("disconnected");
|
|
});
|
|
websocket.addEventListener("message", (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));
|
|
}
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
if (disposed) {
|
|
return; // Unmounted while we waited for the async call.
|
|
}
|
|
terminal.writeln(Language.websocketErrorMessagePrefix + error.message);
|
|
setConnectionStatus("disconnected");
|
|
});
|
|
|
|
return () => {
|
|
disposed = true; // Could use AbortController instead?
|
|
disposers.forEach((d) => d.dispose());
|
|
websocket?.close(1000);
|
|
};
|
|
}, [
|
|
command,
|
|
proxy.preferredPathAppURL,
|
|
reconnectionToken,
|
|
terminal,
|
|
workspace.error,
|
|
workspace.isLoading,
|
|
workspaceAgent,
|
|
]);
|
|
|
|
return (
|
|
<ThemeOverride theme={theme}>
|
|
<Helmet>
|
|
<title>
|
|
{workspace.data
|
|
? pageTitle(
|
|
`Terminal · ${workspace.data.owner_name}/${workspace.data.name}`,
|
|
)
|
|
: ""}
|
|
</title>
|
|
</Helmet>
|
|
<div
|
|
css={{ display: "flex", flexDirection: "column", height: "100vh" }}
|
|
data-status={connectionStatus}
|
|
>
|
|
<TerminalAlerts
|
|
agent={workspaceAgent}
|
|
status={connectionStatus}
|
|
onAlertChange={() => {
|
|
fitAddonRef.current?.fit();
|
|
}}
|
|
/>
|
|
<div
|
|
css={styles.terminal}
|
|
ref={terminalWrapperRef}
|
|
data-testid="terminal"
|
|
/>
|
|
</div>
|
|
|
|
{latency && isDebugging && (
|
|
<span
|
|
css={{
|
|
position: "absolute",
|
|
bottom: 24,
|
|
right: 24,
|
|
color: theme.palette.text.disabled,
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
Latency: {latency.latencyMS.toFixed(0)}ms
|
|
</span>
|
|
)}
|
|
</ThemeOverride>
|
|
);
|
|
};
|
|
|
|
const styles = {
|
|
terminal: (theme) => ({
|
|
width: "100%",
|
|
overflow: "hidden",
|
|
backgroundColor: theme.palette.background.paper,
|
|
flex: 1,
|
|
// These styles attempt to mimic the VS Code scrollbar.
|
|
"& .xterm": {
|
|
padding: 4,
|
|
width: "100%",
|
|
height: "100%",
|
|
},
|
|
"& .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)",
|
|
},
|
|
}),
|
|
} satisfies Record<string, Interpolation<Theme>>;
|
|
|
|
export default TerminalPage;
|