import { CodeOutlined } from '@ant-design/icons'; import { ConfigurationService } from '@difizen/mana-app'; import { Disposable, DisposableCollection, Emitter, KeyCode, ViewInstance, ViewOption, inject, isFirefox, isOSX, transient, useInject, view, } from '@difizen/mana-app'; import { l10n } from '@difizen/mana-l10n'; import { forwardRef } from 'react'; import type { FontWeight, ITerminalOptions } from 'xterm'; import { Terminal } from 'xterm'; import { CanvasAddon } from 'xterm-addon-canvas'; import { FitAddon } from 'xterm-addon-fit'; import { WebLinksAddon } from 'xterm-addon-web-links'; import { WebglAddon } from 'xterm-addon-webgl'; import type { CursorStyle, TerminalRendererType } from './configuration.js'; import { terminalIntegratedCursorStyle } from './configuration.js'; import { terminalIntegratedCopyOnSelection } from './configuration.js'; import { terminalEnablePaste } from './configuration.js'; import { terminalEnableCopy } from './configuration.js'; import { terminalIntegratedRendererType } from './configuration.js'; import { terminalIntegratedFastScrollSensitivity } from './configuration.js'; import { terminalIntegratedScrollback } from './configuration.js'; import { terminalIntegratedLineHeight } from './configuration.js'; import { terminalIntegratedLetterSpacing } from './configuration.js'; import { terminalIntegratedDrawBoldTextInBrightColors } from './configuration.js'; import { terminalIntegratedCursorBlinking, terminalIntegratedCursorWidth, terminalIntegratedFontFamily, terminalIntegratedFontSize, terminalIntegratedFontWeight, terminalIntegratedFontWeightBold, } from './configuration.js'; import { DEFAULT_TERMINAL_RENDERER_TYPE, isTerminalRendererType, } from './configuration.js'; import type { TerminalConnection } from './connection.js'; import { TerminalManager } from './manager.js'; import type { TerminalMessage, TerminalViewOption } from './protocol.js'; import 'xterm/css/xterm.css'; import { BaseStatefulView } from './stateful-view.js'; import { TerminalThemeService } from './theme-service.js'; export const TerminalComponent = forwardRef(function TerminalComponent( _props: { top?: number }, ref, ) { const instance = useInject(ViewInstance); return (
); }); @transient() @view('libro-terminal-view') export class LibroTerminalView extends BaseStatefulView { protected term: Terminal; override view = TerminalComponent; protected options: TerminalViewOption; protected termOpened = false; protected initialData = ''; protected fitAddon: FitAddon; protected readonly config: ConfigurationService; protected readonly themeService: TerminalThemeService; protected readonly terminalManager: TerminalManager; protected readonly toDisposeOnConnect = new DisposableCollection(); protected readonly onDidOpenEmitter = new Emitter(); readonly onDidOpen = this.onDidOpenEmitter.event; protected readonly onDidOpenFailureEmitter = new Emitter(); protected readonly onSizeChangedEmitter = new Emitter<{ cols: number; rows: number; }>(); protected readonly onDataEmitter = new Emitter(); protected readonly onKeyEmitter = new Emitter<{ key: string; domEvent: KeyboardEvent; }>(); protected readonly onDidCloseEmitter = new Emitter(); protected readonly onTitleChangeEmitter = new Emitter(); protected connection?: TerminalConnection; protected restoreObj?: object; protected _isReady = false; protected onReadyEmitter = new Emitter(); constructor( @inject(ViewOption) options: TerminalViewOption, // 这里是 server需要的配置 @inject(ConfigurationService) config: ConfigurationService, @inject(TerminalThemeService) themeService: TerminalThemeService, @inject(TerminalManager) terminalManager: TerminalManager, ) { super(); this.options = options; this.title.icon = CodeOutlined; this.config = config; this.themeService = themeService; this.terminalManager = terminalManager; this.createTerm(); // 设置自定义事件 this.term.attachCustomKeyEventHandler((e) => { return this.customKeyHandler(e); }); // 输入 this.onData((data) => { if (this.isDisposed) { return; } if (this.connection) { this.connection.send({ type: 'stdin', content: [data], }); } }); // dispose tern if (this.options.destroyOnClose === true) { this.toDispose.push( Disposable.create(() => { this.term.dispose(); }), ); } // 主题 this.toDispose.push( this.themeService.onDidChange( () => (this.term.options.theme = this.themeService.getTheme()), ), ); // server title this.toDispose.push( this.term.onTitleChange((title: string) => { if (this.options.useServerTitle) { this.title.label = title; this.onTitleChangeEmitter.fire(title); } }), ); // dispose Emitter this.toDispose.push(this.onDidCloseEmitter); this.toDispose.push(this.onDidOpenEmitter); this.toDispose.push(this.onDidOpenFailureEmitter); this.toDispose.push(this.onSizeChangedEmitter); this.toDispose.push(this.onDataEmitter); this.toDispose.push(this.onKeyEmitter); // bind onSizeChanged this.toDispose.push( this.term.onResize((data) => { this.onSizeChangedEmitter.fire(data); this.setSessionSize(); }), ); // bind ondata this.toDispose.push( this.term.onData((data) => { this.onDataEmitter.fire(data); }), ); this.toDispose.push( (() => { // xterm 的 SGR(Select Graphic Rendition)鼠标模式(SGR Mouse Mode),对于特定类型的鼠标事件,xterm 将会发送相应的二进制数据到终端 /** * Adds an event listener for when a binary event fires. This is used to * enable non UTF-8 conformant binary messages to be sent to the backend. * Currently this is only used for a certain type of mouse reports that * happen to be not UTF-8 compatible. * The event value is a JS string, pass it to the underlying pty as * binary data, e.g. `pty.write(Buffer.from(data, 'binary'))`. * @returns an `IDisposable` to stop listening. */ return this.term.onBinary((data) => { this.onDataEmitter.fire(data); }); })(), ); // bind onKey this.toDispose.push( this.term.onKey((data) => { this.onKeyEmitter.fire(data); }), ); } override afterRestore() { this.initConnection() .then((connection) => { this.terminalManager.terminalOptionsCache.set(connection.name, this.options); this._isReady = true; this.connection = connection; this.onReadyEmitter.fire(true); this.toDispose.push(connection.messageReceived(this.onMessage)); this.toDispose.push(connection); const dispose = connection.connectionStatusChanged(() => { this.initOnceAfterConnected(dispose); }); return connection; }) .catch((e) => { console.error(e); }); } async initConnection() { if (!this.restoreObj) { const connection = await this.createConnection(); return connection; } else { const options = { ...this.options, ...this.restoreObj }; const restoreConnection = await this.terminalManager.getOrCreate(options); return restoreConnection; } } storeState(): object { return { name: this.name }; } restoreState(oldState: object): void { const state = oldState as { name: string }; this.restoreObj = state; } // todo merge options to initcommand override dispose(): void { if (!this.connection?.disposed) { this.connection?.shutdown().catch((reason) => { console.error(`Terminal not shut down: ${reason}`); }); } this.terminalManager.terminalOptionsCache.delete(this.name); super.dispose(); } protected createConnection = async () => { const connection = await this.terminalManager.getOrCreate(this.options); return connection; }; /** * Handle a message from the terminal session. */ protected onMessage = (msg: TerminalMessage) => { switch (msg.type) { case 'stdout': if (msg.content) { this.term.write(msg.content[0] as string); } break; case 'disconnect': this.term.write(l10n.t('[Finished… Term Session]')); break; default: break; } }; protected initOnceAfterConnected(dispose: Disposable) { if (this.isDisposed) { return; } if (this.connection?.connectionStatus !== 'connected') { return; } this.initialTitle(); this.setSessionSize(); this.initialCommand(); // 只执行一次 if (dispose) { dispose.dispose(); } } // 设置初始命令 protected initialCommand = () => { if (this.connection && this.options.initialCommand) { this.connection?.send({ type: 'stdin', content: [this.options.initialCommand + '\r'], }); } }; // default title protected initialTitle() { if (this.connection?.connectionStatus !== 'connected') { return; } const title = `Terminal ${this.connection.name}`; this.title.label = title; this.onTitleChangeEmitter.fire(title); } protected createTerm = () => { const options = { cursorBlink: terminalIntegratedCursorBlinking.defaultValue, cursorStyle: this.getCursorStyle(), cursorWidth: terminalIntegratedCursorWidth.defaultValue, fontFamily: terminalIntegratedFontFamily.defaultValue, fontSize: terminalIntegratedFontSize.defaultValue, fontWeight: terminalIntegratedFontWeight.defaultValue as FontWeight, fontWeightBold: terminalIntegratedFontWeightBold.defaultValue as FontWeight, drawBoldTextInBrightColors: terminalIntegratedDrawBoldTextInBrightColors.defaultValue, letterSpacing: terminalIntegratedLetterSpacing.defaultValue, lineHeight: terminalIntegratedLineHeight.defaultValue, scrollback: terminalIntegratedScrollback.defaultValue, fastScrollSensitivity: terminalIntegratedFastScrollSensitivity.defaultValue, rendererType: this.getTerminalRendererType( terminalIntegratedRendererType.defaultValue, ), theme: this.themeService.getTheme(), }; const [term, fitAddon] = this.createTerminal(options); this.fitAddon = fitAddon; this.term = term; }; protected get enableCopy(): boolean { return terminalEnableCopy.defaultValue; } protected get enablePaste(): boolean { return terminalEnablePaste.defaultValue; } protected get copyOnSelection(): boolean { return terminalIntegratedCopyOnSelection.defaultValue; } protected customKeyHandler = (event: KeyboardEvent): boolean => { const keycode = KeyCode.createKeyCode(event); const keyBindings = keycode.toString(); const ctrlCmdCopy = (isOSX && keyBindings === 'meta+c') || (!isOSX && keyBindings === 'ctrl+c'); const ctrlCmdPaste = (isOSX && keyBindings === 'meta+v') || (!isOSX && keyBindings === 'ctrl+v'); if (ctrlCmdCopy && this.enableCopy && this.term.hasSelection()) { return false; } if (ctrlCmdPaste && this.enablePaste) { return false; } return true; }; /** * Get the cursor style compatible with `xterm`. * @returns CursorStyle */ protected getCursorStyle = (): CursorStyle => { const value = terminalIntegratedCursorStyle.defaultValue as CursorStyle | 'line'; return value === 'line' ? 'bar' : value; }; /** * Returns given renderer type if it is valid and supported or default renderer otherwise. * * @param terminalRendererType desired terminal renderer type */ protected getTerminalRendererType = ( terminalRendererType?: string | TerminalRendererType, ): TerminalRendererType => { if (terminalRendererType && isTerminalRendererType(terminalRendererType)) { return terminalRendererType; } return DEFAULT_TERMINAL_RENDERER_TYPE; }; override onViewMount(): void { this.open(); } override onViewUnmount(): void { this.termOpened = false; } /** * Refresh the terminal session. * * #### Notes * Failure to reconnect to the session should be caught appropriately */ public refresh = async (): Promise => { if (!this.isDisposed && this._isReady) { await this.connection?.reconnect(); this.term.clear(); } }; protected resizeTerminal = (): void => { this.fitAddon.fit(); }; /** * Set the size of the terminal in the session. */ protected setSessionSize(): void { if (this.container && this.container.current) { const content = [ this.term.rows, this.term.cols, this.container.current.offsetHeight, this.container.current.offsetWidth, ]; if (!this.isDisposed && this.connection) { this.connection.send({ type: 'set_size', content }); } } } override onViewResize = (): void => { // todo 这里为什么没有触发isAttached // if (!this.isVisible || !this.isAttached) { // return; // } if (!this.isVisible) { return; } if (this.container?.current) { if (this.container.current.offsetHeight === 0) { return; } if (this.container.current.offsetWidth === 0) { return; } } // 触发term的resize 事件 this.processResize(); }; protected processResize = () => { this.open(); this.resizeTerminal(); }; protected open = (): void => { try { if (this.termOpened) { return; } const node = this.container?.current; if (node) { this.term.open(node); } if (this.initialData) { this.term.write(this.initialData); } this.termOpened = true; this.onDidOpenEmitter.fire(true); this.initialData = ''; if (isFirefox) { // The software scrollbars don't work with xterm.js, so we disable the scrollbar if we are on firefox. if (this.term.element) { (this.term.element?.children.item(0) as HTMLElement).style.overflow = 'hidden'; } } } catch (e) { this.onDidOpenFailureEmitter.fire(e); throw e; } }; protected hasWebGLContext(): boolean { // Create canvas element. The canvas is not added to the // document itself, so it is never displayed in the // browser window. const canvas = document.createElement('canvas'); // Get WebGLRenderingContext from canvas element. const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); // Report the result. try { return gl instanceof WebGLRenderingContext; } catch (error) { return false; } } /** * Create a xterm.js terminal */ protected createTerminal(options: ITerminalOptions): [Terminal, FitAddon] { const term = new Terminal(options); this.addRenderer(term); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.loadAddon(new WebLinksAddon()); return [term, fitAddon]; } protected addRenderer(term: Terminal): void { const supportWebGL = this.hasWebGLContext(); const renderer = this.hasWebGLContext() ? new WebglAddon() : new CanvasAddon(); term.loadAddon(renderer); if (supportWebGL) { (renderer as WebglAddon).onContextLoss((event) => { // console.debug('WebGL context lost - reinitialize Xtermjs renderer.'); renderer.dispose(); // If the Webgl context is lost, reinitialize the addon this.addRenderer(term); }); } } public readonly onDidOpenFailure = this.onDidOpenFailureEmitter.event; public readonly onSizeChanged = this.onSizeChangedEmitter.event; public readonly onData = this.onDataEmitter.event; public readonly onKey = this.onKeyEmitter.event; public readonly onDidClose = this.onDidCloseEmitter.event; public readonly onTitleChange = this.onTitleChangeEmitter.event; /** * Terminal is ready event */ public get name() { return this.connection?.name || ''; } public onReady = this.onReadyEmitter.event; /** * Check if terminal has any text selected. */ public hasSelection(): boolean { if (!this.isDisposed && this._isReady) { return this.term.hasSelection(); } return false; } /** * Paste text into terminal. */ public paste(data: string): void { if (!this.isDisposed && this._isReady) { return this.term.paste(data); } } /** * Get selected text from terminal. */ public getSelection(): string | null { if (!this.isDisposed && this._isReady) { return this.term.getSelection(); } return null; } public scrollLineUp = (): void => { this.term.scrollLines(-1); }; public scrollLineDown = (): void => { this.term.scrollLines(1); }; public scrollToTop = (): void => { this.term.scrollToTop(); }; public scrollToBottom = (): void => { this.term.scrollToBottom(); }; public scrollPageUp = (): void => { this.term.scrollPages(-1); }; public scrollPageDown = (): void => { this.term.scrollPages(1); }; public resetTerminal = (): void => { this.term.reset(); }; public writeLine = (data: string): void => { this.term.writeln(data); }; public write = (data: string | Uint8Array): void => { this.term.write(data); }; }