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