@difizen/libro-terminal 0.1.0 → 0.1.2

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/view.tsx CHANGED
@@ -1,109 +1,158 @@
1
1
  import { CodeOutlined } from '@ant-design/icons';
2
- import { isFirefox } from '@difizen/mana-app';
3
2
  import {
4
- BaseView,
5
- view,
6
- transient,
7
- inject,
8
3
  Disposable,
9
- KeyCode,
10
- isOSX,
11
4
  DisposableCollection,
12
5
  Emitter,
6
+ KeyCode,
7
+ ViewInstance,
8
+ ViewOption,
9
+ inject,
10
+ isFirefox,
11
+ isOSX,
12
+ transient,
13
+ useInject,
14
+ view,
13
15
  } from '@difizen/mana-app';
14
16
  import { forwardRef } from 'react';
15
- import type { RendererType } from 'xterm';
17
+ import type { ITerminalOptions } from 'xterm';
16
18
  import { Terminal } from 'xterm';
19
+ import { CanvasAddon } from 'xterm-addon-canvas';
17
20
  import { FitAddon } from 'xterm-addon-fit';
21
+ import { WebLinksAddon } from 'xterm-addon-web-links';
22
+ import { WebglAddon } from 'xterm-addon-webgl';
18
23
 
19
24
  import type { CursorStyle, TerminalRendererType } from './configuration.js';
20
25
  import {
21
26
  DEFAULT_TERMINAL_RENDERER_TYPE,
27
+ TerminalConfiguration,
22
28
  isTerminalRendererType,
23
29
  } from './configuration.js';
24
- import { TerminalConfiguration } from './configuration.js';
25
30
  import type { TerminalConnection } from './connection.js';
26
31
  import { TerminalManager } from './manager.js';
27
- import type { TerminalMessage } from './protocol.js';
28
- import { TerminalViewOption } from './protocol.js';
32
+ import type { TerminalMessage, TerminalViewOption } from './protocol.js';
33
+ import 'xterm/css/xterm.css';
34
+ import { BaseStatefulView } from './stateful-view.js';
29
35
  import { TerminalThemeService } from './theme-service.js';
30
36
 
31
37
  export const TerminalComponent = forwardRef<HTMLDivElement>(function TerminalComponent(
32
38
  _props: { top?: number },
33
39
  ref,
34
40
  ) {
35
- // const instance = useInject<LibroTerminalView>(ViewInstance);
41
+ const instance = useInject<LibroTerminalView>(ViewInstance);
42
+
36
43
  return (
37
- <div tabIndex={1} className="libro-terminal" ref={ref}>
38
- /
39
- </div>
44
+ <div
45
+ id={instance.id}
46
+ // tabIndex={1}
47
+ className="libro-terminal"
48
+ ref={ref}
49
+ ></div>
40
50
  );
41
51
  });
42
52
 
43
53
  @transient()
44
54
  @view('libro-terminal-view')
45
- export class LibroTerminalView extends BaseView {
55
+ export class LibroTerminalView extends BaseStatefulView {
46
56
  protected term: Terminal;
47
57
  override view = TerminalComponent;
48
58
  protected options: TerminalViewOption;
49
59
  protected termOpened = false;
50
60
  protected initialData = '';
51
61
  protected fitAddon: FitAddon;
52
- @inject(TerminalConfiguration)
53
62
  protected readonly config: TerminalConfiguration;
54
- @inject(TerminalThemeService)
55
63
  protected readonly themeService: TerminalThemeService;
56
- @inject(TerminalManager)
57
64
  protected readonly terminalManager: TerminalManager;
58
65
 
59
66
  protected readonly toDisposeOnConnect = new DisposableCollection();
60
67
 
61
- protected readonly onDidOpenEmitter = new Emitter<void>();
68
+ protected readonly onDidOpenEmitter = new Emitter<boolean>();
62
69
  readonly onDidOpen = this.onDidOpenEmitter.event;
63
70
 
64
- protected readonly onDidOpenFailureEmitter = new Emitter<void>();
65
- readonly onDidOpenFailure = this.onDidOpenFailureEmitter.event;
71
+ protected readonly onDidOpenFailureEmitter = new Emitter<unknown>();
66
72
 
67
73
  protected readonly onSizeChangedEmitter = new Emitter<{
68
74
  cols: number;
69
75
  rows: number;
70
76
  }>();
71
- readonly onSizeChanged = this.onSizeChangedEmitter.event;
72
77
 
73
78
  protected readonly onDataEmitter = new Emitter<string>();
74
- readonly onData = this.onDataEmitter.event;
75
79
 
76
80
  protected readonly onKeyEmitter = new Emitter<{
77
81
  key: string;
78
82
  domEvent: KeyboardEvent;
79
83
  }>();
80
- readonly onKey = this.onKeyEmitter.event;
84
+
81
85
  protected readonly onDidCloseEmitter = new Emitter<LibroTerminalView>();
82
- readonly onDidClose = this.onDidCloseEmitter.event;
86
+
87
+ protected readonly onTitleChangeEmitter = new Emitter<string>();
83
88
 
84
89
  protected connection?: TerminalConnection;
85
90
 
86
- constructor(@inject(TerminalViewOption) options: TerminalViewOption) {
91
+ protected restoreObj?: object;
92
+
93
+ protected _isReady = false;
94
+
95
+ protected onReadyEmitter = new Emitter<boolean>();
96
+
97
+ constructor(
98
+ @inject(ViewOption) options: TerminalViewOption, // 这里是 server需要的配置
99
+ @inject(TerminalConfiguration) config: TerminalConfiguration,
100
+ @inject(TerminalThemeService) themeService: TerminalThemeService,
101
+ @inject(TerminalManager) terminalManager: TerminalManager,
102
+ ) {
87
103
  super();
88
104
  this.options = options;
89
105
  this.title.icon = CodeOutlined;
106
+ this.config = config;
107
+ this.themeService = themeService;
108
+ this.terminalManager = terminalManager;
109
+
90
110
  this.createTerm();
91
- this.createConnection();
111
+ // 设置自定义事件
112
+ this.term.attachCustomKeyEventHandler((e) => {
113
+ return this.customKeyHandler(e);
114
+ });
115
+
116
+ // 输入
117
+ this.onData((data) => {
118
+ if (this.isDisposed) {
119
+ return;
120
+ }
121
+ if (this.connection) {
122
+ this.connection.send({
123
+ type: 'stdin',
124
+ content: [data],
125
+ });
126
+ }
127
+ });
128
+
129
+ // dispose tern
92
130
  if (this.options.destroyOnClose === true) {
93
- this.toDispose.push(Disposable.create(() => this.term.dispose()));
131
+ this.toDispose.push(
132
+ Disposable.create(() => {
133
+ this.term.dispose();
134
+ }),
135
+ );
94
136
  }
137
+
138
+ // 主题
95
139
  this.toDispose.push(
96
- this.themeService.onDidChange(() =>
97
- this.term.setOption('theme', this.themeService.theme),
140
+ this.themeService.onDidChange(
141
+ () => (this.term.options.theme = this.themeService.getTheme()),
98
142
  ),
99
143
  );
144
+
145
+ // server title
100
146
  this.toDispose.push(
101
147
  this.term.onTitleChange((title: string) => {
102
148
  if (this.options.useServerTitle) {
103
149
  this.title.label = title;
150
+ this.onTitleChangeEmitter.fire(title);
104
151
  }
105
152
  }),
106
153
  );
154
+
155
+ // dispose Emitter
107
156
  this.toDispose.push(this.onDidCloseEmitter);
108
157
  this.toDispose.push(this.onDidOpenEmitter);
109
158
  this.toDispose.push(this.onDidOpenFailureEmitter);
@@ -111,12 +160,14 @@ export class LibroTerminalView extends BaseView {
111
160
  this.toDispose.push(this.onDataEmitter);
112
161
  this.toDispose.push(this.onKeyEmitter);
113
162
 
163
+ // bind onSizeChanged
114
164
  this.toDispose.push(
115
165
  this.term.onResize((data) => {
116
166
  this.onSizeChangedEmitter.fire(data);
117
167
  }),
118
168
  );
119
169
 
170
+ // bind ondata
120
171
  this.toDispose.push(
121
172
  this.term.onData((data) => {
122
173
  this.onDataEmitter.fire(data);
@@ -124,32 +175,90 @@ export class LibroTerminalView extends BaseView {
124
175
  );
125
176
 
126
177
  this.toDispose.push(
127
- this.term.onBinary((data) => {
128
- this.onDataEmitter.fire(data);
129
- }),
178
+ (() => {
179
+ // xterm 的 SGR(Select Graphic Rendition)鼠标模式(SGR Mouse Mode),对于特定类型的鼠标事件,xterm 将会发送相应的二进制数据到终端
180
+ /**
181
+ * Adds an event listener for when a binary event fires. This is used to
182
+ * enable non UTF-8 conformant binary messages to be sent to the backend.
183
+ * Currently this is only used for a certain type of mouse reports that
184
+ * happen to be not UTF-8 compatible.
185
+ * The event value is a JS string, pass it to the underlying pty as
186
+ * binary data, e.g. `pty.write(Buffer.from(data, 'binary'))`.
187
+ * @returns an `IDisposable` to stop listening.
188
+ */
189
+ return this.term.onBinary((data) => {
190
+ this.onDataEmitter.fire(data);
191
+ });
192
+ })(),
130
193
  );
131
194
 
195
+ // bind onKey
132
196
  this.toDispose.push(
133
197
  this.term.onKey((data) => {
134
198
  this.onKeyEmitter.fire(data);
135
199
  }),
136
200
  );
137
201
  }
202
+
203
+ override afterRestore() {
204
+ this.initConnection()
205
+ .then((connection) => {
206
+ this._isReady = true;
207
+ this.connection = connection;
208
+ this.onReadyEmitter.fire(true);
209
+
210
+ this.toDispose.push(connection.messageReceived(this.onMessage));
211
+ this.toDispose.push(connection);
212
+
213
+ const dispose = connection.connectionStatusChanged(() => {
214
+ this.initOnceAfterConnected(dispose);
215
+ });
216
+ return connection;
217
+ })
218
+ .catch((e) => {
219
+ console.error(e);
220
+ });
221
+ }
222
+ async initConnection() {
223
+ if (!this.restoreObj) {
224
+ const connection = await this.createConnection();
225
+ return connection;
226
+ } else {
227
+ const options = { ...this.options, ...this.restoreObj };
228
+ const restoreConnection = await this.terminalManager.getOrCreate(options);
229
+ return restoreConnection;
230
+ }
231
+ }
232
+
233
+ storeState(): object {
234
+ return { name: this.name };
235
+ }
236
+
237
+ restoreState(oldState: object): void {
238
+ const state = oldState as { name: string };
239
+ this.restoreObj = state;
240
+ }
241
+
242
+ // todo merge options to initcommand
243
+
244
+ override dispose(): void {
245
+ if (!this.connection?.disposed) {
246
+ this.connection?.shutdown().catch((reason) => {
247
+ console.error(`Terminal not shut down: ${reason}`);
248
+ });
249
+ }
250
+ super.dispose();
251
+ }
252
+
138
253
  protected createConnection = async () => {
139
254
  const connection = await this.terminalManager.getOrCreate(this.options);
140
- this.connection = connection;
141
- connection.messageReceived(this.onMessage);
142
- if (this.isDisposed) {
143
- return;
144
- }
145
- this.initialConnection();
146
- this.toDispose.push(connection.connectionStatusChanged(this.initialConnection));
255
+ return connection;
147
256
  };
148
257
 
149
258
  /**
150
259
  * Handle a message from the terminal session.
151
260
  */
152
- protected onMessage(msg: TerminalMessage): void {
261
+ protected onMessage = (msg: TerminalMessage) => {
153
262
  switch (msg.type) {
154
263
  case 'stdout':
155
264
  if (msg.content) {
@@ -162,26 +271,47 @@ export class LibroTerminalView extends BaseView {
162
271
  default:
163
272
  break;
164
273
  }
165
- }
274
+ };
166
275
 
167
- protected initialConnection() {
276
+ protected initOnceAfterConnected(dispose: Disposable) {
168
277
  if (this.isDisposed) {
169
278
  return;
170
279
  }
171
280
  if (this.connection?.connectionStatus !== 'connected') {
172
281
  return;
173
282
  }
174
- this.title.label = `Terminal ${this.connection.name}`;
283
+
284
+ this.initialTitle();
285
+ this.setSessionSize();
286
+ this.initialCommand();
287
+ // 只执行一次
288
+ if (dispose) {
289
+ dispose.dispose();
290
+ }
291
+ }
292
+
293
+ // 设置初始命令
294
+ protected initialCommand = () => {
175
295
  if (this.connection && this.options.initialCommand) {
176
296
  this.connection?.send({
177
297
  type: 'stdin',
178
298
  content: [this.options.initialCommand + '\r'],
179
299
  });
180
300
  }
301
+ };
302
+
303
+ // default title
304
+ protected initialTitle() {
305
+ if (this.connection?.connectionStatus !== 'connected') {
306
+ return;
307
+ }
308
+ const title = `Terminal ${this.connection.name}`;
309
+ this.title.label = title;
310
+ this.onTitleChangeEmitter.fire(title);
181
311
  }
182
312
 
183
313
  protected createTerm = () => {
184
- const term = new Terminal({
314
+ const options = {
185
315
  cursorBlink: this.config.get('terminal.integrated.cursorBlinking'),
186
316
  cursorStyle: this.getCursorStyle(),
187
317
  cursorWidth: this.config.get('terminal.integrated.cursorWidth'),
@@ -201,14 +331,14 @@ export class LibroTerminalView extends BaseView {
201
331
  rendererType: this.getTerminalRendererType(
202
332
  this.config.get('terminal.integrated.rendererType'),
203
333
  ),
204
- theme: this.themeService.theme,
205
- });
206
- const fitAddon = new FitAddon();
207
- term.loadAddon(fitAddon);
334
+ theme: this.themeService.getTheme(),
335
+ };
336
+
337
+ const [term, fitAddon] = this.createTerminal(options);
208
338
  this.fitAddon = fitAddon;
209
339
  this.term = term;
210
- this.term.attachCustomKeyEventHandler((e) => this.customKeyHandler(e));
211
340
  };
341
+
212
342
  protected get enableCopy(): boolean {
213
343
  return this.config.get('terminal.enableCopy');
214
344
  }
@@ -216,16 +346,19 @@ export class LibroTerminalView extends BaseView {
216
346
  protected get enablePaste(): boolean {
217
347
  return this.config.get('terminal.enablePaste');
218
348
  }
349
+
219
350
  protected get copyOnSelection(): boolean {
220
351
  return this.config.get('terminal.integrated.copyOnSelection');
221
352
  }
222
353
 
223
354
  protected customKeyHandler = (event: KeyboardEvent): boolean => {
224
- const keyBindings = KeyCode.createKeyCode(event).toString();
355
+ const keycode = KeyCode.createKeyCode(event);
356
+ const keyBindings = keycode.toString();
225
357
  const ctrlCmdCopy =
226
358
  (isOSX && keyBindings === 'meta+c') || (!isOSX && keyBindings === 'ctrl+c');
227
359
  const ctrlCmdPaste =
228
360
  (isOSX && keyBindings === 'meta+v') || (!isOSX && keyBindings === 'ctrl+v');
361
+
229
362
  if (ctrlCmdCopy && this.enableCopy && this.term.hasSelection()) {
230
363
  return false;
231
364
  }
@@ -249,79 +382,222 @@ export class LibroTerminalView extends BaseView {
249
382
  */
250
383
  protected getTerminalRendererType = (
251
384
  terminalRendererType?: string | TerminalRendererType,
252
- ): RendererType => {
385
+ ): TerminalRendererType => {
253
386
  if (terminalRendererType && isTerminalRendererType(terminalRendererType)) {
254
387
  return terminalRendererType;
255
388
  }
256
389
  return DEFAULT_TERMINAL_RENDERER_TYPE;
257
390
  };
258
391
 
392
+ override onViewMount(): void {
393
+ this.open();
394
+ }
395
+
396
+ override onViewUnmount(): void {
397
+ this.termOpened = false;
398
+ }
399
+
400
+ /**
401
+ * Refresh the terminal session.
402
+ *
403
+ * #### Notes
404
+ * Failure to reconnect to the session should be caught appropriately
405
+ */
406
+ public refresh = async (): Promise<void> => {
407
+ if (!this.isDisposed && this._isReady) {
408
+ await this.connection?.reconnect();
409
+ this.term.clear();
410
+ }
411
+ };
412
+
259
413
  protected resizeTerminal = (): void => {
260
- const geo = this.fitAddon.proposeDimensions();
261
- const cols = geo.cols;
262
- const rows = geo.rows - 1; // subtract one row for margin
263
- this.term.resize(cols, rows);
414
+ this.fitAddon.fit();
264
415
  };
265
416
 
417
+ /**
418
+ * Set the size of the terminal in the session.
419
+ */
420
+ protected setSessionSize(): void {
421
+ if (this.container && this.container.current) {
422
+ const content = [
423
+ this.term.rows,
424
+ this.term.cols,
425
+ this.container.current.offsetHeight,
426
+ this.container.current.offsetWidth,
427
+ ];
428
+ if (!this.isDisposed && this.connection) {
429
+ this.connection.send({ type: 'set_size', content });
430
+ }
431
+ }
432
+ }
433
+
266
434
  override onViewResize = (): void => {
267
- if (!this.isVisible || !this.isAttached) {
435
+ // todo 这里为什么没有触发isAttached
436
+ // if (!this.isVisible || !this.isAttached) {
437
+ // return;
438
+ // }
439
+
440
+ if (!this.isVisible) {
268
441
  return;
269
442
  }
443
+ this.processResize();
444
+ };
445
+
446
+ protected processResize = () => {
270
447
  this.open();
271
448
  this.resizeTerminal();
449
+ this.setSessionSize();
272
450
  };
273
451
 
274
452
  protected open = (): void => {
275
- if (this.termOpened) {
276
- return;
453
+ try {
454
+ if (this.termOpened) {
455
+ return;
456
+ }
457
+ const node = this.container?.current;
458
+ if (node) {
459
+ this.term.open(node);
460
+ }
461
+ if (this.initialData) {
462
+ this.term.write(this.initialData);
463
+ }
464
+ this.termOpened = true;
465
+ this.onDidOpenEmitter.fire(true);
466
+ this.initialData = '';
467
+
468
+ if (isFirefox) {
469
+ // The software scrollbars don't work with xterm.js, so we disable the scrollbar if we are on firefox.
470
+ if (this.term.element) {
471
+ (this.term.element?.children.item(0) as HTMLElement).style.overflow =
472
+ 'hidden';
473
+ }
474
+ }
475
+ } catch (e) {
476
+ this.onDidOpenFailureEmitter.fire(e);
477
+ throw e;
277
478
  }
278
- const node = this.container?.current;
279
- if (node) {
280
- this.term.open(node);
479
+ };
480
+
481
+ protected hasWebGLContext(): boolean {
482
+ // Create canvas element. The canvas is not added to the
483
+ // document itself, so it is never displayed in the
484
+ // browser window.
485
+ const canvas = document.createElement('canvas');
486
+
487
+ // Get WebGLRenderingContext from canvas element.
488
+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
489
+
490
+ // Report the result.
491
+ try {
492
+ return gl instanceof WebGLRenderingContext;
493
+ } catch (error) {
494
+ return false;
281
495
  }
282
- if (this.initialData) {
283
- this.term.write(this.initialData);
496
+ }
497
+
498
+ /**
499
+ * Create a xterm.js terminal
500
+ */
501
+ protected createTerminal(options: ITerminalOptions): [Terminal, FitAddon] {
502
+ const term = new Terminal(options);
503
+ this.addRenderer(term);
504
+ const fitAddon = new FitAddon();
505
+ term.loadAddon(fitAddon);
506
+ term.loadAddon(new WebLinksAddon());
507
+ return [term, fitAddon];
508
+ }
509
+
510
+ protected addRenderer(term: Terminal): void {
511
+ const supportWebGL = this.hasWebGLContext();
512
+ const renderer = this.hasWebGLContext() ? new WebglAddon() : new CanvasAddon();
513
+ term.loadAddon(renderer);
514
+ if (supportWebGL) {
515
+ (renderer as WebglAddon).onContextLoss((event) => {
516
+ // console.debug('WebGL context lost - reinitialize Xtermjs renderer.');
517
+ renderer.dispose();
518
+ // If the Webgl context is lost, reinitialize the addon
519
+ this.addRenderer(term);
520
+ });
284
521
  }
285
- this.termOpened = true;
286
- this.initialData = '';
522
+ }
287
523
 
288
- if (isFirefox) {
289
- // The software scrollbars don't work with xterm.js, so we disable the scrollbar if we are on firefox.
290
- if (this.term.element) {
291
- (this.term.element?.children.item(0) as HTMLElement).style.overflow = 'hidden';
292
- }
524
+ public readonly onDidOpenFailure = this.onDidOpenFailureEmitter.event;
525
+ public readonly onSizeChanged = this.onSizeChangedEmitter.event;
526
+ public readonly onData = this.onDataEmitter.event;
527
+ public readonly onKey = this.onKeyEmitter.event;
528
+ public readonly onDidClose = this.onDidCloseEmitter.event;
529
+ public readonly onTitleChange = this.onTitleChangeEmitter.event;
530
+ /**
531
+ * Terminal is ready event
532
+ */
533
+ public get name() {
534
+ return this.connection?.name || '';
535
+ }
536
+
537
+ public onReady = this.onReadyEmitter.event;
538
+
539
+ /**
540
+ * Check if terminal has any text selected.
541
+ */
542
+ public hasSelection(): boolean {
543
+ if (!this.isDisposed && this._isReady) {
544
+ return this.term.hasSelection();
293
545
  }
294
- };
546
+ return false;
547
+ }
548
+
549
+ /**
550
+ * Paste text into terminal.
551
+ */
552
+ public paste(data: string): void {
553
+ if (!this.isDisposed && this._isReady) {
554
+ return this.term.paste(data);
555
+ }
556
+ }
295
557
 
296
- scrollLineUp = (): void => {
558
+ /**
559
+ * Get selected text from terminal.
560
+ */
561
+ public getSelection(): string | null {
562
+ if (!this.isDisposed && this._isReady) {
563
+ return this.term.getSelection();
564
+ }
565
+ return null;
566
+ }
567
+
568
+ public scrollLineUp = (): void => {
297
569
  this.term.scrollLines(-1);
298
570
  };
299
571
 
300
- scrollLineDown = (): void => {
572
+ public scrollLineDown = (): void => {
301
573
  this.term.scrollLines(1);
302
574
  };
303
575
 
304
- scrollToTop = (): void => {
576
+ public scrollToTop = (): void => {
305
577
  this.term.scrollToTop();
306
578
  };
307
579
 
308
- scrollToBottom = (): void => {
580
+ public scrollToBottom = (): void => {
309
581
  this.term.scrollToBottom();
310
582
  };
311
583
 
312
- scrollPageUp = (): void => {
584
+ public scrollPageUp = (): void => {
313
585
  this.term.scrollPages(-1);
314
586
  };
315
587
 
316
- scrollPageDown = (): void => {
588
+ public scrollPageDown = (): void => {
317
589
  this.term.scrollPages(1);
318
590
  };
319
591
 
320
- resetTerminal = (): void => {
592
+ public resetTerminal = (): void => {
321
593
  this.term.reset();
322
594
  };
323
595
 
324
- writeLine = (text: string): void => {
325
- this.term.writeln(text);
596
+ public writeLine = (data: string): void => {
597
+ this.term.writeln(data);
598
+ };
599
+
600
+ public write = (data: string | Uint8Array): void => {
601
+ this.term.write(data);
326
602
  };
327
603
  }