1
1
import { makeStyles , useTheme } from "@mui/styles" ;
2
- import { useMachine } from "@xstate/react" ;
3
2
import { FC , useCallback , useEffect , useRef , useState } from "react" ;
4
3
import { Helmet } from "react-helmet-async" ;
5
4
import { useNavigate , useParams , useSearchParams } from "react-router-dom" ;
@@ -14,14 +13,15 @@ import { Unicode11Addon } from "xterm-addon-unicode11";
14
13
import "xterm/css/xterm.css" ;
15
14
import { MONOSPACE_FONT_FAMILY } from "theme/constants" ;
16
15
import { pageTitle } from "utils/page" ;
17
- import { terminalMachine } from "xServices/terminal/terminalXService" ;
18
16
import { useProxy } from "contexts/ProxyContext" ;
19
17
import Box from "@mui/material/Box" ;
20
18
import { useDashboard } from "components/Dashboard/DashboardProvider" ;
21
19
import { Region } from "api/typesGenerated" ;
22
20
import { getLatencyColor } from "utils/latency" ;
23
21
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency" ;
24
22
import { portForwardURL } from "utils/portForward" ;
23
+ import { terminalWebsocketUrl } from "utils/terminal" ;
24
+ import { getMatchingAgentOrFirst } from "utils/workspace" ;
25
25
import {
26
26
DisconnectedAlert ,
27
27
ErrorScriptAlert ,
@@ -30,6 +30,7 @@ import {
30
30
} from "./TerminalAlerts" ;
31
31
import { useQuery } from "react-query" ;
32
32
import { deploymentConfig } from "api/queries/deployment" ;
33
+ import { workspaceByOwnerAndName } from "api/queries/workspaces" ;
33
34
import {
34
35
Popover ,
35
36
PopoverContent ,
@@ -48,9 +49,11 @@ const TerminalPage: FC = () => {
48
49
const { proxy } = useProxy ( ) ;
49
50
const params = useParams ( ) as { username : string ; workspace : string } ;
50
51
const username = params . username . replace ( "@" , "" ) ;
51
- const workspaceName = params . workspace ;
52
52
const xtermRef = useRef < HTMLDivElement > ( null ) ;
53
53
const [ terminal , setTerminal ] = useState < XTerm . Terminal | null > ( null ) ;
54
+ const [ terminalState , setTerminalState ] = useState <
55
+ "connected" | "disconnected" | "initializing"
56
+ > ( "initializing" ) ;
54
57
const [ fitAddon , setFitAddon ] = useState < FitAddon | null > ( null ) ;
55
58
const [ searchParams ] = useSearchParams ( ) ;
56
59
// The reconnection token is a unique token that identifies
@@ -60,37 +63,13 @@ const TerminalPage: FC = () => {
60
63
const command = searchParams . get ( "command" ) || undefined ;
61
64
// The workspace name is in the format:
62
65
// <workspace name>[.<agent name>]
63
- const workspaceNameParts = workspaceName ?. split ( "." ) ;
64
- const [ terminalState , sendEvent ] = useMachine ( terminalMachine , {
65
- context : {
66
- agentName : workspaceNameParts ?. [ 1 ] ,
67
- reconnection : reconnectionToken ,
68
- workspaceName : workspaceNameParts ?. [ 0 ] ,
69
- username : username ,
70
- command : command ,
71
- baseURL : proxy . preferredPathAppURL ,
72
- } ,
73
- actions : {
74
- readMessage : ( _ , event ) => {
75
- if ( typeof event . data === "string" ) {
76
- // This exclusively occurs when testing.
77
- // "jest-websocket-mock" doesn't support ArrayBuffer.
78
- terminal ?. write ( event . data ) ;
79
- } else {
80
- terminal ?. write ( new Uint8Array ( event . data ) ) ;
81
- }
82
- } ,
83
- } ,
84
- } ) ;
85
- const isConnected = terminalState . matches ( "connected" ) ;
86
- const isDisconnected = terminalState . matches ( "disconnected" ) ;
87
- const {
88
- workspaceError,
89
- workspace,
90
- workspaceAgentError,
91
- workspaceAgent,
92
- websocketError,
93
- } = terminalState . context ;
66
+ const workspaceNameParts = params . workspace ?. split ( "." ) ;
67
+ const workspace = useQuery (
68
+ workspaceByOwnerAndName ( username , workspaceNameParts ?. [ 0 ] ) ,
69
+ ) ;
70
+ const workspaceAgent = workspace . data
71
+ ? getMatchingAgentOrFirst ( workspace . data , workspaceNameParts ?. [ 1 ] )
72
+ : undefined ;
94
73
const dashboard = useDashboard ( ) ;
95
74
const proxyContext = useProxy ( ) ;
96
75
const selectedProxy = proxyContext . proxy . proxy ;
@@ -111,7 +90,7 @@ const TerminalPage: FC = () => {
111
90
( uri : string ) => {
112
91
if (
113
92
! workspaceAgent ||
114
- ! workspace ||
93
+ ! workspace . data ||
115
94
! username ||
116
95
! proxy . preferredWildcardHostname
117
96
) {
@@ -145,15 +124,15 @@ const TerminalPage: FC = () => {
145
124
proxy . preferredWildcardHostname ,
146
125
parseInt ( url . port ) ,
147
126
workspaceAgent . name ,
148
- workspace . name ,
127
+ workspace . data . name ,
149
128
username ,
150
129
) + url . pathname ,
151
130
) ;
152
131
} catch ( ex ) {
153
132
open ( uri ) ;
154
133
}
155
134
} ,
156
- [ workspaceAgent , workspace , username , proxy . preferredWildcardHostname ] ,
135
+ [ workspaceAgent , workspace . data , username , proxy . preferredWildcardHostname ] ,
157
136
) ;
158
137
159
138
// Create the terminal!
@@ -186,23 +165,6 @@ const TerminalPage: FC = () => {
186
165
handleWebLink ( uri ) ;
187
166
} ) ,
188
167
) ;
189
- terminal . onData ( ( data ) => {
190
- sendEvent ( {
191
- type : "WRITE" ,
192
- request : {
193
- data : data ,
194
- } ,
195
- } ) ;
196
- } ) ;
197
- terminal . onResize ( ( event ) => {
198
- sendEvent ( {
199
- type : "WRITE" ,
200
- request : {
201
- height : event . rows ,
202
- width : event . cols ,
203
- } ,
204
- } ) ;
205
- } ) ;
206
168
setTerminal ( terminal ) ;
207
169
terminal . open ( xtermRef . current ) ;
208
170
const listener = ( ) => {
@@ -214,11 +176,9 @@ const TerminalPage: FC = () => {
214
176
window . removeEventListener ( "resize" , listener ) ;
215
177
terminal . dispose ( ) ;
216
178
} ;
217
- } , [ config . data , config . isLoading , sendEvent , xtermRef , handleWebLink ] ) ;
179
+ } , [ config . data , config . isLoading , xtermRef , handleWebLink ] ) ;
218
180
219
- // Triggers the initial terminal connection using
220
- // the reconnection token and workspace name found
221
- // from the router.
181
+ // Updates the reconnection token into the URL if necessary.
222
182
useEffect ( ( ) => {
223
183
if ( searchParams . get ( "reconnect" ) === reconnectionToken ) {
224
184
return ;
@@ -234,7 +194,7 @@ const TerminalPage: FC = () => {
234
194
) ;
235
195
} , [ searchParams , navigate , reconnectionToken ] ) ;
236
196
237
- // Apply terminal options based on connection state .
197
+ // Hook up the terminal through a web socket .
238
198
useEffect ( ( ) => {
239
199
if ( ! terminal || ! fitAddon ) {
240
200
return ;
@@ -246,68 +206,136 @@ const TerminalPage: FC = () => {
246
206
fitAddon . fit ( ) ;
247
207
fitAddon . fit ( ) ;
248
208
249
- if ( ! isConnected ) {
250
- // Disable user input when not connected.
251
- terminal . options = {
252
- disableStdin : true ,
253
- } ;
254
- if ( workspaceError instanceof Error ) {
255
- terminal . writeln (
256
- Language . workspaceErrorMessagePrefix + workspaceError . message ,
257
- ) ;
258
- }
259
- if ( workspaceAgentError instanceof Error ) {
260
- terminal . writeln (
261
- Language . workspaceAgentErrorMessagePrefix +
262
- workspaceAgentError . message ,
263
- ) ;
264
- }
265
- if ( websocketError instanceof Error ) {
266
- terminal . writeln (
267
- Language . websocketErrorMessagePrefix + websocketError . message ,
268
- ) ;
269
- }
270
- return ;
271
- }
272
-
273
209
// The terminal should be cleared on each reconnect
274
210
// because all data is re-rendered from the backend.
275
211
terminal . clear ( ) ;
276
212
277
- // Focusing on connection allows users to reload the
278
- // page and start typing immediately.
213
+ // Focusing on connection allows users to reload the page and start
214
+ // typing immediately.
279
215
terminal . focus ( ) ;
280
- terminal . options = {
281
- disableStdin : false ,
282
- windowsMode : workspaceAgent ?. operating_system === "windows" ,
283
- } ;
284
216
285
- // Update the terminal size post-fit.
286
- sendEvent ( {
287
- type : "WRITE" ,
288
- request : {
289
- height : terminal . rows ,
290
- width : terminal . cols ,
291
- } ,
292
- } ) ;
217
+ // Disable input while we connect.
218
+ terminal . options . disableStdin = true ;
219
+
220
+ // Show a message if we failed to find the workspace or agent.
221
+ if ( workspace . isLoading ) {
222
+ return ;
223
+ } else if ( workspace . error instanceof Error ) {
224
+ terminal . writeln (
225
+ Language . workspaceErrorMessagePrefix + workspace . error . message ,
226
+ ) ;
227
+ return ;
228
+ } else if ( ! workspaceAgent ) {
229
+ terminal . writeln (
230
+ Language . workspaceAgentErrorMessagePrefix + "no agent found with ID" ,
231
+ ) ;
232
+ return ;
233
+ }
234
+
235
+ // Hook up terminal events to the websocket.
236
+ let websocket : WebSocket | null ;
237
+ const disposers = [
238
+ terminal . onData ( ( data ) => {
239
+ websocket ?. send (
240
+ new TextEncoder ( ) . encode ( JSON . stringify ( { data : data } ) ) ,
241
+ ) ;
242
+ } ) ,
243
+ terminal . onResize ( ( event ) => {
244
+ websocket ?. send (
245
+ new TextEncoder ( ) . encode (
246
+ JSON . stringify ( {
247
+ height : event . rows ,
248
+ width : event . cols ,
249
+ } ) ,
250
+ ) ,
251
+ ) ;
252
+ } ) ,
253
+ ] ;
254
+
255
+ let disposed = false ;
256
+
257
+ // Open the web socket and hook it up to the terminal.
258
+ terminalWebsocketUrl (
259
+ proxy . preferredPathAppURL ,
260
+ reconnectionToken ,
261
+ workspaceAgent . id ,
262
+ command ,
263
+ )
264
+ . then ( ( url ) => {
265
+ if ( disposed ) {
266
+ return ; // Unmounted while we waited for the async call.
267
+ }
268
+ websocket = new WebSocket ( url ) ;
269
+ websocket . binaryType = "arraybuffer" ;
270
+ websocket . addEventListener ( "open" , ( ) => {
271
+ // Now that we are connected, allow user input.
272
+ terminal . options = {
273
+ disableStdin : false ,
274
+ windowsMode : workspaceAgent ?. operating_system === "windows" ,
275
+ } ;
276
+ // Send the initial size.
277
+ websocket ?. send (
278
+ new TextEncoder ( ) . encode (
279
+ JSON . stringify ( {
280
+ height : terminal . rows ,
281
+ width : terminal . cols ,
282
+ } ) ,
283
+ ) ,
284
+ ) ;
285
+ setTerminalState ( "connected" ) ;
286
+ } ) ;
287
+ websocket . addEventListener ( "error" , ( ) => {
288
+ terminal . options . disableStdin = true ;
289
+ terminal . writeln (
290
+ Language . websocketErrorMessagePrefix + "socket errored" ,
291
+ ) ;
292
+ setTerminalState ( "disconnected" ) ;
293
+ } ) ;
294
+ websocket . addEventListener ( "close" , ( ) => {
295
+ terminal . options . disableStdin = true ;
296
+ setTerminalState ( "disconnected" ) ;
297
+ } ) ;
298
+ websocket . addEventListener ( "message" , ( event ) => {
299
+ if ( typeof event . data === "string" ) {
300
+ // This exclusively occurs when testing.
301
+ // "jest-websocket-mock" doesn't support ArrayBuffer.
302
+ terminal . write ( event . data ) ;
303
+ } else {
304
+ terminal . write ( new Uint8Array ( event . data ) ) ;
305
+ }
306
+ } ) ;
307
+ } )
308
+ . catch ( ( error ) => {
309
+ if ( disposed ) {
310
+ return ; // Unmounted while we waited for the async call.
311
+ }
312
+ terminal . writeln ( Language . websocketErrorMessagePrefix + error . message ) ;
313
+ setTerminalState ( "disconnected" ) ;
314
+ } ) ;
315
+
316
+ return ( ) => {
317
+ disposed = true ; // Could use AbortController instead?
318
+ disposers . forEach ( ( d ) => d . dispose ( ) ) ;
319
+ websocket ?. close ( 1000 ) ;
320
+ } ;
293
321
} , [
294
- workspaceError ,
295
- workspaceAgentError ,
296
- websocketError ,
297
- workspaceAgent ,
298
- terminal ,
322
+ command ,
299
323
fitAddon ,
300
- isConnected ,
301
- sendEvent ,
324
+ proxy . preferredPathAppURL ,
325
+ reconnectionToken ,
326
+ terminal ,
327
+ workspace . isLoading ,
328
+ workspace . error ,
329
+ workspaceAgent ,
302
330
] ) ;
303
331
304
332
return (
305
333
< >
306
334
< Helmet >
307
335
< title >
308
- { terminalState . context . workspace
336
+ { workspace . data
309
337
? pageTitle (
310
- `Terminal · ${ terminalState . context . workspace . owner_name } /${ terminalState . context . workspace . name } ` ,
338
+ `Terminal · ${ workspace . data . owner_name } /${ workspace . data . name } ` ,
311
339
)
312
340
: "" }
313
341
</ title >
@@ -317,7 +345,7 @@ const TerminalPage: FC = () => {
317
345
{ lifecycleState === "starting" && < LoadingScriptsAlert /> }
318
346
{ lifecycleState === "ready" &&
319
347
prevLifecycleState . current === "starting" && < LoadedScriptsAlert /> }
320
- { isDisconnected && < DisconnectedAlert /> }
348
+ { terminalState === "disconnected" && < DisconnectedAlert /> }
321
349
< div
322
350
className = { styles . terminal }
323
351
ref = { xtermRef }
0 commit comments