Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions site/src/pages/TerminalPage/TerminalAlerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import { Alert, AlertProps } from "components/Alert/Alert";
import { useState } from "react";
import { docs } from "utils/docs";

export const ErrorScriptAlert = () => {
return (
<TerminalAlert
severity="warning"
dismissible
actions={<RefreshSessionButton />}
>
The workspace{" "}
<Link
title="startup script has exited with an error"
href={docs("/templates#startup-script-exited-with-an-error")}
target="_blank"
rel="noreferrer"
>
startup script has exited with an error
</Link>
, we recommend reloading this session and{" "}
<Link
title=" debugging the startup script"
href={docs("/templates#debugging-the-startup-script")}
target="_blank"
rel="noreferrer"
>
debugging the startup script
</Link>{" "}
because{" "}
<Link
title="your workspace may be incomplete."
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
your workspace may be incomplete.
</Link>{" "}
</TerminalAlert>
);
};

export const LoadingScriptsAlert = () => {
return (
<TerminalAlert
dismissible
severity="info"
actions={<RefreshSessionButton />}
>
Startup scripts are still running. You can continue using this terminal,
but{" "}
<Link
title="your workspace may be incomplete."
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
{" "}
your workspace may be incomplete.
</Link>
</TerminalAlert>
);
};

export const LoadedScriptsAlert = () => {
return (
<TerminalAlert
severity="success"
dismissible
actions={<RefreshSessionButton />}
>
Startup scripts have completed successfully. The workspace is ready but
this{" "}
<Link
title="session was started before the startup scripts finished"
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
session was started before the startup script finished.
</Link>{" "}
To ensure your shell environment is up-to-date, we recommend reloading
this session.
</TerminalAlert>
);
};

const TerminalAlert = (props: AlertProps) => {
return (
<Alert
{...props}
sx={{
borderRadius: 0,
borderWidth: 0,
borderBottomWidth: 1,
borderBottomColor: (theme) => theme.palette.divider,
backgroundColor: (theme) => theme.palette.background.paperLight,
borderLeft: (theme) =>
`3px solid ${theme.palette[props.severity!].light}`,
marginBottom: 1,
}}
/>
);
};

export const DisconnectedAlert = (props: AlertProps) => {
return (
<TerminalAlert
{...props}
severity="warning"
actions={<RefreshSessionButton />}
>
Disconnected
</TerminalAlert>
);
};

const RefreshSessionButton = () => {
const [isRefreshing, setIsRefreshing] = useState(false);

return (
<Button
disabled={isRefreshing}
size="small"
variant="text"
onClick={() => {
setIsRefreshing(true);
window.location.reload();
}}
>
{isRefreshing ? "Refreshing session..." : "Refresh session"}
</Button>
);
};
128 changes: 18 additions & 110 deletions site/src/pages/TerminalPage/TerminalPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { makeStyles, useTheme } from "@mui/styles";
import { useMachine } from "@xstate/react";
import { Stack } from "components/Stack/Stack";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
Expand All @@ -18,45 +17,24 @@ import { terminalMachine } from "xServices/terminal/terminalXService";
import { useProxy } from "contexts/ProxyContext";
import Box from "@mui/material/Box";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { Region, WorkspaceAgent } from "api/typesGenerated";
import { Region } from "api/typesGenerated";
import { getLatencyColor } from "utils/latency";
import Popover from "@mui/material/Popover";
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency";
import TerminalPageAlert, { TerminalPageAlertType } from "./TerminalPageAlert";
import { portForwardURL } from "utils/portForward";
import {
DisconnectedAlert,
ErrorScriptAlert,
LoadedScriptsAlert,
LoadingScriptsAlert,
} from "./TerminalAlerts";

export const Language = {
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ",
websocketErrorMessagePrefix: "WebSocket failed: ",
};

const useTerminalWarning = ({ agent }: { agent?: WorkspaceAgent }) => {
const lifecycleState = agent?.lifecycle_state;
const [startupWarning, setStartupWarning] = useState<
TerminalPageAlertType | undefined
>(undefined);

useEffect(() => {
if (lifecycleState === "start_error") {
setStartupWarning("error");
} else if (lifecycleState === "starting") {
setStartupWarning("starting");
} else {
setStartupWarning((prev) => {
if (prev === "starting") {
return "success";
}
return undefined;
});
}
}, [lifecycleState]);

return {
startupWarning,
};
};

type TerminalPageProps = React.PropsWithChildren<{
renderer: "webgl" | "dom";
}>;
Expand Down Expand Up @@ -110,16 +88,18 @@ const TerminalPage: FC<TerminalPageProps> = ({ renderer }) => {
workspaceAgent,
websocketError,
} = terminalState.context;
const reloading = useReloading(isDisconnected);
const dashboard = useDashboard();
const proxyContext = useProxy();
const selectedProxy = proxyContext.proxy.proxy;
const latency = selectedProxy
? proxyContext.proxyLatencies[selectedProxy.id]
: undefined;
const { startupWarning } = useTerminalWarning({
agent: workspaceAgent,
});

const lifecycleState = workspaceAgent?.lifecycle_state;
const prevLifecycleState = useRef(lifecycleState);
useEffect(() => {
prevLifecycleState.current = lifecycleState;
}, [lifecycleState]);

// handleWebLink handles opening of URLs in the terminal!
const handleWebLink = useCallback(
Expand Down Expand Up @@ -326,29 +306,12 @@ const TerminalPage: FC<TerminalPageProps> = ({ renderer }) => {
: ""}
</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>
<Box display="flex" flexDirection="column" height="100vh">
{startupWarning && (
<TerminalPageAlert
alertType={startupWarning}
onDismiss={() => {
fitAddon?.fit();
}}
/>
)}
{lifecycleState === "start_error" && <ErrorScriptAlert />}
{lifecycleState === "starting" && <LoadingScriptsAlert />}
{lifecycleState === "ready" &&
prevLifecycleState.current === "starting" && <LoadedScriptsAlert />}
{isDisconnected && <DisconnectedAlert />}
<div
className={styles.terminal}
ref={xtermRef}
Expand Down Expand Up @@ -463,61 +426,6 @@ const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => {
);
};

const useReloading = (isDisconnected: boolean) => {
const [status, setStatus] = useState<"reloading" | "notReloading">(
"notReloading",
);

// Retry connection on key press when it is disconnected
useEffect(() => {
if (!isDisconnected || status === "reloading") {
return;
}

// Modifier keys should not trigger a reload.
const ignoredKeys = [
"Alt",
"AltGraph",
"CapsLock",
"Control",
"Fn",
"FnLock",
"Meta",
"NumLock",
"ScrollLock",
"Shift",
"Symbol",
"SymbolLock",
];

const keyDownHandler = (event: KeyboardEvent) => {
// In addition to ignored keys, avoid reloading while modifiers are held
// to cover cases where the terminal unexpectedly tries to reconnect like
// when pressing ctrl+w, ctrl+r, and so on.
if (
!ignoredKeys.includes(event.key) &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey
) {
setStatus("reloading");
window.location.reload();
}
};

document.addEventListener("keydown", keyDownHandler, true);

return () => {
document.removeEventListener("keydown", keyDownHandler, true);
};
}, [status, isDisconnected]);

return {
status,
};
};

const useStyles = makeStyles((theme) => ({
overlay: {
position: "absolute",
Expand Down
37 changes: 0 additions & 37 deletions site/src/pages/TerminalPage/TerminalPageAlert.stories.tsx

This file was deleted.

Loading