Skip to content

Commit 060033e

Browse files
fix(site): fix terminal size when displaying alerts (#12444)
Before - The terminal size does not fit the available space so the bottom is hidden. https://github.com/coder/coder/assets/3165839/d08470b9-9fc6-476c-a551-8a3e13fc25bf After - The terminal adjusts when there are alert changes. https://github.com/coder/coder/assets/3165839/8cc32bfb-056f-47cb-97f2-3bb18c5fe906 Unfortunately, I don't think there is a sane way to automate tests for this but open to suggestions. Close #7914
1 parent d2a5b31 commit 060033e

File tree

4 files changed

+104
-15
lines changed

4 files changed

+104
-15
lines changed

site/.storybook/preview.jsx

+7
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ export const parameters = {
6969
},
7070
type: "tablet",
7171
},
72+
terminal: {
73+
name: "Terminal",
74+
styles: {
75+
height: "400",
76+
width: "400",
77+
},
78+
},
7279
},
7380
},
7481
};

site/src/@types/storybook.d.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import type { QueryKey } from "react-query";
33
import type { Experiments, FeatureName } from "api/typesGenerated";
44

55
declare module "@storybook/react" {
6-
type WebSocketEvent = { event: "message"; data: string } | { event: "error" };
6+
type WebSocketEvent =
7+
| { event: "message"; data: string }
8+
| { event: "error" | "close" };
79
interface Parameters {
810
features?: FeatureName[];
911
experiments?: Experiments;

site/src/pages/TerminalPage/TerminalPage.stories.tsx

+24
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,27 @@ export const ConnectionError: Story = {
142142
queries: [...meta.parameters.queries, createWorkspaceWithAgent("ready")],
143143
},
144144
};
145+
146+
// Check if the terminal is not getting hide when the bottom message is shown
147+
// together with the error message
148+
export const BottomMessage: Story = {
149+
decorators: [withWebSocket],
150+
parameters: {
151+
...meta.parameters,
152+
// Forcing smaller viewport to make it easier to identify the issue
153+
viewport: {
154+
defaultViewport: "terminal",
155+
},
156+
webSocket: [
157+
{
158+
event: "message",
159+
// This outputs text in the bottom left and right corners of the terminal.
160+
data: "\x1b[1000BLEFT\x1b[1000C\x1b[4DRIGHT",
161+
},
162+
{
163+
event: "close",
164+
},
165+
],
166+
queries: [...meta.parameters.queries, createWorkspaceWithAgent("ready")],
167+
},
168+
};

site/src/pages/TerminalPage/TerminalPage.tsx

+70-14
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { WebLinksAddon } from "xterm-addon-web-links";
1313
import { WebglAddon } from "xterm-addon-webgl";
1414
import { deploymentConfig } from "api/queries/deployment";
1515
import { workspaceByOwnerAndName } from "api/queries/workspaces";
16+
import type { WorkspaceAgent } from "api/typesGenerated";
1617
import { useProxy } from "contexts/ProxyContext";
1718
import { ThemeOverride } from "contexts/ThemeProvider";
1819
import themes from "theme";
@@ -34,6 +35,8 @@ export const Language = {
3435
websocketErrorMessagePrefix: "WebSocket failed: ",
3536
};
3637

38+
type TerminalState = "connected" | "disconnected" | "initializing";
39+
3740
const TerminalPage: FC = () => {
3841
// Maybe one day we'll support a light themed terminal, but terminal coloring
3942
// is notably a pain because of assumptions certain programs might make about your
@@ -45,9 +48,8 @@ const TerminalPage: FC = () => {
4548
const username = params.username.replace("@", "");
4649
const xtermRef = useRef<HTMLDivElement>(null);
4750
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null);
48-
const [terminalState, setTerminalState] = useState<
49-
"connected" | "disconnected" | "initializing"
50-
>("initializing");
51+
const [terminalState, setTerminalState] =
52+
useState<TerminalState>("initializing");
5153
const [searchParams] = useSearchParams();
5254
const isDebugging = searchParams.has("debug");
5355
// The reconnection token is a unique token that identifies
@@ -67,12 +69,6 @@ const TerminalPage: FC = () => {
6769
const selectedProxy = proxy.proxy;
6870
const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined;
6971

70-
const lifecycleState = workspaceAgent?.lifecycle_state;
71-
const prevLifecycleState = useRef(lifecycleState);
72-
useEffect(() => {
73-
prevLifecycleState.current = lifecycleState;
74-
}, [lifecycleState]);
75-
7672
const config = useQuery(deploymentConfig());
7773
const renderer = config.data?.config.web_terminal_renderer;
7874

@@ -95,6 +91,7 @@ const TerminalPage: FC = () => {
9591
}, [handleWebLink]);
9692

9793
// Create the terminal!
94+
const fitAddonRef = useRef<FitAddon>();
9895
useEffect(() => {
9996
if (!xtermRef.current || config.isLoading) {
10097
return;
@@ -115,6 +112,7 @@ const TerminalPage: FC = () => {
115112
terminal.loadAddon(new CanvasAddon());
116113
}
117114
const fitAddon = new FitAddon();
115+
fitAddonRef.current = fitAddon;
118116
terminal.loadAddon(fitAddon);
119117
terminal.loadAddon(new Unicode11Addon());
120118
terminal.unicode.activeVersion = "11";
@@ -303,11 +301,13 @@ const TerminalPage: FC = () => {
303301
</title>
304302
</Helmet>
305303
<div css={{ display: "flex", flexDirection: "column", height: "100vh" }}>
306-
{lifecycleState === "start_error" && <ErrorScriptAlert />}
307-
{lifecycleState === "starting" && <LoadingScriptsAlert />}
308-
{lifecycleState === "ready" &&
309-
prevLifecycleState.current === "starting" && <LoadedScriptsAlert />}
310-
{terminalState === "disconnected" && <DisconnectedAlert />}
304+
<TerminalAlerts
305+
agent={workspaceAgent}
306+
state={terminalState}
307+
onAlertChange={() => {
308+
fitAddonRef.current?.fit();
309+
}}
310+
/>
311311
<div css={styles.terminal} ref={xtermRef} data-testid="terminal" />
312312
</div>
313313

@@ -328,6 +328,62 @@ const TerminalPage: FC = () => {
328328
);
329329
};
330330

331+
type TerminalAlertsProps = {
332+
agent: WorkspaceAgent | undefined;
333+
state: TerminalState;
334+
onAlertChange: () => void;
335+
};
336+
337+
const TerminalAlerts = ({
338+
agent,
339+
state,
340+
onAlertChange,
341+
}: TerminalAlertsProps) => {
342+
const lifecycleState = agent?.lifecycle_state;
343+
const prevLifecycleState = useRef(lifecycleState);
344+
useEffect(() => {
345+
prevLifecycleState.current = lifecycleState;
346+
}, [lifecycleState]);
347+
348+
// We want to observe the children of the wrapper to detect when the alert
349+
// changes. So the terminal page can resize itself.
350+
//
351+
// Would it be possible to just always call fit() when this component
352+
// re-renders instead of using an observer?
353+
//
354+
// This is a good question and the why this does not work is that the .fit()
355+
// needs to run after the render so in this case, I just think the mutation
356+
// observer is more reliable. I could use some hacky setTimeout inside of
357+
// useEffect to do that, I guess, but I don't think it would be any better.
358+
const wrapperRef = useRef<HTMLDivElement>(null);
359+
useEffect(() => {
360+
if (!wrapperRef.current) {
361+
return;
362+
}
363+
const observer = new MutationObserver(onAlertChange);
364+
observer.observe(wrapperRef.current, { childList: true });
365+
366+
return () => {
367+
observer.disconnect();
368+
};
369+
}, [onAlertChange]);
370+
371+
return (
372+
<div ref={wrapperRef}>
373+
{state === "disconnected" ? (
374+
<DisconnectedAlert />
375+
) : lifecycleState === "start_error" ? (
376+
<ErrorScriptAlert />
377+
) : lifecycleState === "starting" ? (
378+
<LoadingScriptsAlert />
379+
) : lifecycleState === "ready" &&
380+
prevLifecycleState.current === "starting" ? (
381+
<LoadedScriptsAlert />
382+
) : null}
383+
</div>
384+
);
385+
};
386+
331387
const styles = {
332388
terminal: (theme) => ({
333389
width: "100%",

0 commit comments

Comments
 (0)