@jupyterlab/terminal 4.0.0-alpha.2 → 4.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/widget.ts ADDED
@@ -0,0 +1,626 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+
4
+ import { Terminal as TerminalNS } from '@jupyterlab/services';
5
+ import {
6
+ ITranslator,
7
+ nullTranslator,
8
+ TranslationBundle
9
+ } from '@jupyterlab/translation';
10
+ import { PromiseDelegate } from '@lumino/coreutils';
11
+ import { Platform } from '@lumino/domutils';
12
+ import { Message, MessageLoop } from '@lumino/messaging';
13
+ import { Widget } from '@lumino/widgets';
14
+ import type {
15
+ ITerminalInitOnlyOptions,
16
+ ITerminalOptions,
17
+ Terminal as Xterm
18
+ } from 'xterm';
19
+ import type { CanvasAddon } from 'xterm-addon-canvas';
20
+ import type { FitAddon } from 'xterm-addon-fit';
21
+ import type { WebLinksAddon } from 'xterm-addon-web-links';
22
+ import type { WebglAddon } from 'xterm-addon-webgl';
23
+ import { ITerminal } from '.';
24
+
25
+ /**
26
+ * The class name added to a terminal widget.
27
+ */
28
+ const TERMINAL_CLASS = 'jp-Terminal';
29
+
30
+ /**
31
+ * The class name added to a terminal body.
32
+ */
33
+ const TERMINAL_BODY_CLASS = 'jp-Terminal-body';
34
+
35
+ /**
36
+ * A widget which manages a terminal session.
37
+ */
38
+ export class Terminal extends Widget implements ITerminal.ITerminal {
39
+ /**
40
+ * Construct a new terminal widget.
41
+ *
42
+ * @param session - The terminal session object.
43
+ *
44
+ * @param options - The terminal configuration options.
45
+ *
46
+ * @param translator - The language translator.
47
+ */
48
+ constructor(
49
+ session: TerminalNS.ITerminalConnection,
50
+ options: Partial<ITerminal.IOptions> = {},
51
+ translator?: ITranslator
52
+ ) {
53
+ super();
54
+ translator = translator || nullTranslator;
55
+ this._trans = translator.load('jupyterlab');
56
+ this.session = session;
57
+
58
+ // Initialize settings.
59
+ this._options = { ...ITerminal.defaultOptions, ...options };
60
+
61
+ const { theme, ...other } = this._options;
62
+ const xtermOptions = {
63
+ theme: Private.getXTermTheme(theme),
64
+ ...other
65
+ };
66
+
67
+ this.addClass(TERMINAL_CLASS);
68
+
69
+ this._setThemeAttribute(theme);
70
+
71
+ // Buffer session message while waiting for the terminal
72
+ let buffer = '';
73
+ const bufferMessage = (
74
+ sender: TerminalNS.ITerminalConnection,
75
+ msg: TerminalNS.IMessage
76
+ ): void => {
77
+ switch (msg.type) {
78
+ case 'stdout':
79
+ if (msg.content) {
80
+ buffer += msg.content[0] as string;
81
+ }
82
+ break;
83
+ default:
84
+ break;
85
+ }
86
+ };
87
+ session.messageReceived.connect(bufferMessage);
88
+ session.disposed.connect(() => {
89
+ if (this.getOption('closeOnExit')) {
90
+ this.dispose();
91
+ }
92
+ }, this);
93
+
94
+ // Create the xterm.
95
+ Private.createTerminal(xtermOptions)
96
+ .then(([term, fitAddon]) => {
97
+ this._term = term;
98
+ this._fitAddon = fitAddon;
99
+ this._initializeTerm();
100
+
101
+ this.id = `jp-Terminal-${Private.id++}`;
102
+ this.title.label = this._trans.__('Terminal');
103
+ this._isReady = true;
104
+ this._ready.resolve();
105
+
106
+ if (buffer) {
107
+ this._term.write(buffer);
108
+ }
109
+ session.messageReceived.disconnect(bufferMessage);
110
+ session.messageReceived.connect(this._onMessage, this);
111
+
112
+ if (session.connectionStatus === 'connected') {
113
+ this._initialConnection();
114
+ } else {
115
+ session.connectionStatusChanged.connect(
116
+ this._initialConnection,
117
+ this
118
+ );
119
+ }
120
+ this.update();
121
+ })
122
+ .catch(reason => {
123
+ console.error('Failed to create a terminal.\n', reason);
124
+ this._ready.reject(reason);
125
+ });
126
+ }
127
+
128
+ /**
129
+ * A promise that is fulfilled when the terminal is ready.
130
+ */
131
+ get ready(): Promise<void> {
132
+ return this._ready.promise;
133
+ }
134
+
135
+ /**
136
+ * The terminal session associated with the widget.
137
+ */
138
+ readonly session: TerminalNS.ITerminalConnection;
139
+
140
+ /**
141
+ * Get a config option for the terminal.
142
+ */
143
+ getOption<K extends keyof ITerminal.IOptions>(
144
+ option: K
145
+ ): ITerminal.IOptions[K] {
146
+ return this._options[option];
147
+ }
148
+
149
+ /**
150
+ * Set a config option for the terminal.
151
+ */
152
+ setOption<K extends keyof ITerminal.IOptions>(
153
+ option: K,
154
+ value: ITerminal.IOptions[K]
155
+ ): void {
156
+ if (
157
+ option !== 'theme' &&
158
+ (this._options[option] === value || option === 'initialCommand')
159
+ ) {
160
+ return;
161
+ }
162
+
163
+ this._options[option] = value;
164
+
165
+ switch (option) {
166
+ case 'fontFamily':
167
+ this._term.options.fontFamily = value as string | undefined;
168
+ break;
169
+ case 'fontSize':
170
+ this._term.options.fontSize = value as number | undefined;
171
+ break;
172
+ case 'lineHeight':
173
+ this._term.options.lineHeight = value as number | undefined;
174
+ break;
175
+ case 'screenReaderMode':
176
+ this._term.options.screenReaderMode = value as boolean | undefined;
177
+ break;
178
+ case 'scrollback':
179
+ this._term.options.scrollback = value as number | undefined;
180
+ break;
181
+ case 'theme':
182
+ this._term.options.theme = {
183
+ ...Private.getXTermTheme(value as ITerminal.Theme)
184
+ };
185
+ this._setThemeAttribute(value as ITerminal.Theme);
186
+ break;
187
+ case 'macOptionIsMeta':
188
+ this._term.options.macOptionIsMeta = value as boolean | undefined;
189
+ break;
190
+ default:
191
+ // Do not transmit options not listed above to XTerm
192
+ break;
193
+ }
194
+
195
+ this._needsResize = true;
196
+ this.update();
197
+ }
198
+
199
+ /**
200
+ * Dispose of the resources held by the terminal widget.
201
+ */
202
+ dispose(): void {
203
+ if (!this.session.isDisposed) {
204
+ if (this.getOption('shutdownOnClose')) {
205
+ this.session.shutdown().catch(reason => {
206
+ console.error(`Terminal not shut down: ${reason}`);
207
+ });
208
+ }
209
+ }
210
+ void this.ready.then(() => {
211
+ this._term.dispose();
212
+ });
213
+ super.dispose();
214
+ }
215
+
216
+ /**
217
+ * Refresh the terminal session.
218
+ *
219
+ * #### Notes
220
+ * Failure to reconnect to the session should be caught appropriately
221
+ */
222
+ async refresh(): Promise<void> {
223
+ if (!this.isDisposed && this._isReady) {
224
+ await this.session.reconnect();
225
+ this._term.clear();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Check if terminal has any text selected.
231
+ */
232
+ hasSelection(): boolean {
233
+ if (!this.isDisposed && this._isReady) {
234
+ return this._term.hasSelection();
235
+ }
236
+ return false;
237
+ }
238
+
239
+ /**
240
+ * Paste text into terminal.
241
+ */
242
+ paste(data: string): void {
243
+ if (!this.isDisposed && this._isReady) {
244
+ return this._term.paste(data);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Get selected text from terminal.
250
+ */
251
+ getSelection(): string | null {
252
+ if (!this.isDisposed && this._isReady) {
253
+ return this._term.getSelection();
254
+ }
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Process a message sent to the widget.
260
+ *
261
+ * @param msg - The message sent to the widget.
262
+ *
263
+ * #### Notes
264
+ * Subclasses may reimplement this method as needed.
265
+ */
266
+ processMessage(msg: Message): void {
267
+ super.processMessage(msg);
268
+ switch (msg.type) {
269
+ case 'fit-request':
270
+ this.onFitRequest(msg);
271
+ break;
272
+ default:
273
+ break;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Set the size of the terminal when attached if dirty.
279
+ */
280
+ protected onAfterAttach(msg: Message): void {
281
+ this.update();
282
+ }
283
+
284
+ /**
285
+ * Set the size of the terminal when shown if dirty.
286
+ */
287
+ protected onAfterShow(msg: Message): void {
288
+ this.update();
289
+ }
290
+
291
+ /**
292
+ * On resize, use the computed row and column sizes to resize the terminal.
293
+ */
294
+ protected onResize(msg: Widget.ResizeMessage): void {
295
+ this._offsetWidth = msg.width;
296
+ this._offsetHeight = msg.height;
297
+ this._needsResize = true;
298
+ this.update();
299
+ }
300
+
301
+ /**
302
+ * A message handler invoked on an `'update-request'` message.
303
+ */
304
+ protected onUpdateRequest(msg: Message): void {
305
+ if (!this.isVisible || !this.isAttached || !this._isReady) {
306
+ return;
307
+ }
308
+
309
+ // Open the terminal if necessary.
310
+ if (!this._termOpened) {
311
+ this._term.open(this.node);
312
+ this._term.element?.classList.add(TERMINAL_BODY_CLASS);
313
+ this._termOpened = true;
314
+ }
315
+
316
+ if (this._needsResize) {
317
+ this._resizeTerminal();
318
+ }
319
+ }
320
+
321
+ /**
322
+ * A message handler invoked on an `'fit-request'` message.
323
+ */
324
+ protected onFitRequest(msg: Message): void {
325
+ const resize = Widget.ResizeMessage.UnknownSize;
326
+ MessageLoop.sendMessage(this, resize);
327
+ }
328
+
329
+ /**
330
+ * Handle `'activate-request'` messages.
331
+ */
332
+ protected onActivateRequest(msg: Message): void {
333
+ this._term?.focus();
334
+ }
335
+
336
+ private _initialConnection() {
337
+ if (this.isDisposed) {
338
+ return;
339
+ }
340
+
341
+ if (this.session.connectionStatus !== 'connected') {
342
+ return;
343
+ }
344
+
345
+ this.title.label = this._trans.__('Terminal %1', this.session.name);
346
+ this._setSessionSize();
347
+ if (this._options.initialCommand) {
348
+ this.session.send({
349
+ type: 'stdin',
350
+ content: [this._options.initialCommand + '\r']
351
+ });
352
+ }
353
+
354
+ // Only run this initial connection logic once.
355
+ this.session.connectionStatusChanged.disconnect(
356
+ this._initialConnection,
357
+ this
358
+ );
359
+ }
360
+
361
+ /**
362
+ * Initialize the terminal object.
363
+ */
364
+ private _initializeTerm(): void {
365
+ const term = this._term;
366
+ term.onData((data: string) => {
367
+ if (this.isDisposed) {
368
+ return;
369
+ }
370
+ this.session.send({
371
+ type: 'stdin',
372
+ content: [data]
373
+ });
374
+ });
375
+
376
+ term.onTitleChange((title: string) => {
377
+ this.title.label = title;
378
+ });
379
+
380
+ // Do not add any Ctrl+C/Ctrl+V handling on macOS,
381
+ // where Cmd+C/Cmd+V works as intended.
382
+ if (Platform.IS_MAC) {
383
+ return;
384
+ }
385
+
386
+ term.attachCustomKeyEventHandler(event => {
387
+ if (event.ctrlKey && event.key === 'c' && term.hasSelection()) {
388
+ // Return so that the usual OS copy happens
389
+ // instead of interrupt signal.
390
+ return false;
391
+ }
392
+
393
+ if (event.ctrlKey && event.key === 'v' && this._options.pasteWithCtrlV) {
394
+ // Return so that the usual paste happens.
395
+ return false;
396
+ }
397
+
398
+ return true;
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Handle a message from the terminal session.
404
+ */
405
+ private _onMessage(
406
+ sender: TerminalNS.ITerminalConnection,
407
+ msg: TerminalNS.IMessage
408
+ ): void {
409
+ switch (msg.type) {
410
+ case 'stdout':
411
+ if (msg.content) {
412
+ this._term.write(msg.content[0] as string);
413
+ }
414
+ break;
415
+ case 'disconnect':
416
+ this._term.write('\r\n\r\n[Finished… Term Session]\r\n');
417
+ break;
418
+ default:
419
+ break;
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Resize the terminal based on computed geometry.
425
+ */
426
+ private _resizeTerminal() {
427
+ if (this._options.autoFit) {
428
+ this._fitAddon.fit();
429
+ }
430
+ if (this._offsetWidth === -1) {
431
+ this._offsetWidth = this.node.offsetWidth;
432
+ }
433
+ if (this._offsetHeight === -1) {
434
+ this._offsetHeight = this.node.offsetHeight;
435
+ }
436
+ this._setSessionSize();
437
+ this._needsResize = false;
438
+ }
439
+
440
+ /**
441
+ * Set the size of the terminal in the session.
442
+ */
443
+ private _setSessionSize(): void {
444
+ const content = [
445
+ this._term.rows,
446
+ this._term.cols,
447
+ this._offsetHeight,
448
+ this._offsetWidth
449
+ ];
450
+ if (!this.isDisposed) {
451
+ this.session.send({ type: 'set_size', content });
452
+ }
453
+ }
454
+
455
+ private _setThemeAttribute(theme: string | null | undefined) {
456
+ if (this.isDisposed) {
457
+ return;
458
+ }
459
+
460
+ this.node.setAttribute(
461
+ 'data-term-theme',
462
+ theme ? theme.toLowerCase() : 'inherit'
463
+ );
464
+ }
465
+
466
+ private _fitAddon: FitAddon;
467
+ private _needsResize = true;
468
+ private _offsetWidth = -1;
469
+ private _offsetHeight = -1;
470
+ private _options: ITerminal.IOptions;
471
+ private _isReady = false;
472
+ private _ready = new PromiseDelegate<void>();
473
+ private _term: Xterm;
474
+ private _termOpened = false;
475
+ private _trans: TranslationBundle;
476
+ }
477
+
478
+ /**
479
+ * A namespace for private data.
480
+ */
481
+ namespace Private {
482
+ /**
483
+ * An incrementing counter for ids.
484
+ */
485
+ export let id = 0;
486
+
487
+ /**
488
+ * The light terminal theme.
489
+ */
490
+ export const lightTheme: ITerminal.IThemeObject = {
491
+ foreground: '#000',
492
+ background: '#fff',
493
+ cursor: '#616161', // md-grey-700
494
+ cursorAccent: '#F5F5F5', // md-grey-100
495
+ selectionBackground: 'rgba(97, 97, 97, 0.3)', // md-grey-700
496
+ selectionInactiveBackground: 'rgba(189, 189, 189, 0.3)' // md-grey-400
497
+ };
498
+
499
+ /**
500
+ * The dark terminal theme.
501
+ */
502
+ export const darkTheme: ITerminal.IThemeObject = {
503
+ foreground: '#fff',
504
+ background: '#000',
505
+ cursor: '#fff',
506
+ cursorAccent: '#000',
507
+ selectionBackground: 'rgba(255, 255, 255, 0.3)',
508
+ selectionInactiveBackground: 'rgba(238, 238, 238, 0.3)' // md-grey-200
509
+ };
510
+
511
+ /**
512
+ * The current theme.
513
+ */
514
+ export const inheritTheme = (): ITerminal.IThemeObject => ({
515
+ foreground: getComputedStyle(document.body)
516
+ .getPropertyValue('--jp-ui-font-color0')
517
+ .trim(),
518
+ background: getComputedStyle(document.body)
519
+ .getPropertyValue('--jp-layout-color0')
520
+ .trim(),
521
+ cursor: getComputedStyle(document.body)
522
+ .getPropertyValue('--jp-ui-font-color1')
523
+ .trim(),
524
+ cursorAccent: getComputedStyle(document.body)
525
+ .getPropertyValue('--jp-ui-inverse-font-color0')
526
+ .trim(),
527
+ selectionBackground: getComputedStyle(document.body)
528
+ .getPropertyValue('--jp-layout-color3')
529
+ .trim(),
530
+ selectionInactiveBackground: getComputedStyle(document.body)
531
+ .getPropertyValue('--jp-layout-color2')
532
+ .trim()
533
+ });
534
+
535
+ export function getXTermTheme(
536
+ theme: ITerminal.Theme
537
+ ): ITerminal.IThemeObject {
538
+ switch (theme) {
539
+ case 'light':
540
+ return lightTheme;
541
+ case 'dark':
542
+ return darkTheme;
543
+ case 'inherit':
544
+ default:
545
+ return inheritTheme();
546
+ }
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Utility functions for creating a Terminal widget
552
+ */
553
+ namespace Private {
554
+ let supportWebGL: boolean = false;
555
+ let Xterm_: typeof Xterm;
556
+ let FitAddon_: typeof FitAddon;
557
+ let WeblinksAddon_: typeof WebLinksAddon;
558
+ let Renderer_: typeof CanvasAddon | typeof WebglAddon;
559
+
560
+ /**
561
+ * Detect if the browser supports WebGL or not.
562
+ *
563
+ * Reference: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/By_example/Detect_WebGL
564
+ */
565
+ function hasWebGLContext(): boolean {
566
+ // Create canvas element. The canvas is not added to the
567
+ // document itself, so it is never displayed in the
568
+ // browser window.
569
+ const canvas = document.createElement('canvas');
570
+
571
+ // Get WebGLRenderingContext from canvas element.
572
+ const gl =
573
+ canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
574
+
575
+ // Report the result.
576
+ try {
577
+ return gl instanceof WebGLRenderingContext;
578
+ } catch (error) {
579
+ return false;
580
+ }
581
+ }
582
+
583
+ function addRenderer(term: Xterm): void {
584
+ let renderer = new Renderer_();
585
+ term.loadAddon(renderer);
586
+ if (supportWebGL) {
587
+ (renderer as WebglAddon).onContextLoss(event => {
588
+ console.debug('WebGL context lost - reinitialize Xtermjs renderer.');
589
+ renderer.dispose();
590
+ // If the Webgl context is lost, reinitialize the addon
591
+ addRenderer(term);
592
+ });
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Create a xterm.js terminal asynchronously.
598
+ */
599
+ export async function createTerminal(
600
+ options: ITerminalOptions & ITerminalInitOnlyOptions
601
+ ): Promise<[Xterm, FitAddon]> {
602
+ if (!Xterm_) {
603
+ supportWebGL = hasWebGLContext();
604
+ const [xterm_, fitAddon_, renderer_, weblinksAddon_] = await Promise.all([
605
+ import('xterm'),
606
+ import('xterm-addon-fit'),
607
+ supportWebGL
608
+ ? import('xterm-addon-webgl')
609
+ : import('xterm-addon-canvas'),
610
+ import('xterm-addon-web-links')
611
+ ]);
612
+ Xterm_ = xterm_.Terminal;
613
+ FitAddon_ = fitAddon_.FitAddon;
614
+ Renderer_ =
615
+ (renderer_ as any).WebglAddon ?? (renderer_ as any).CanvasAddon;
616
+ WeblinksAddon_ = weblinksAddon_.WebLinksAddon;
617
+ }
618
+
619
+ const term = new Xterm_(options);
620
+ addRenderer(term);
621
+ const fitAddon = new FitAddon_();
622
+ term.loadAddon(fitAddon);
623
+ term.loadAddon(new WeblinksAddon_());
624
+ return [term, fitAddon];
625
+ }
626
+ }