UNPKG

@finos/legend-application

Version:
1,072 lines (967 loc) 35.5 kB
/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { GenericLegendApplicationStore } from '../ApplicationStore.js'; import { Terminal as XTermTerminal, type ITheme as XTermTheme, type IDisposable as XTermDisposable, type IBufferCellPosition, type ILink, type ILinkProvider, } from 'xterm'; import { FitAddon as XTermFitAddon } from 'xterm-addon-fit'; import { type ISearchDecorationOptions as XTermSearchDecorationOptions, SearchAddon as XTermSearchAddon, } from 'xterm-addon-search'; import { Unicode11Addon as XTermUnicode11Addon } from 'xterm-addon-unicode11'; import { WebglAddon as XTermWebglAddon } from 'xterm-addon-webgl'; import { DEFAULT_MONOSPACED_FONT_FAMILY, DEFAULT_TAB_SIZE, } from '../ApplicationConfig.js'; import { Terminal, DISPLAY_ANSI_ESCAPE, ANSI_moveCursor, type TerminalWriteOption, type TerminalSetupConfiguration, type TerminalCommandConfiguration, } from './Terminal.js'; import { ActionState, guaranteeNonNullable, IllegalStateError, isMatchingKeyCombination, LogEvent, noop, prettyCONSTName, uniqBy, } from '@finos/legend-shared'; import { APPLICATION_EVENT } from '../../__lib__/LegendApplicationEvent.js'; import { forceDispatchKeyboardEvent } from '../../components/ApplicationComponentFrameworkProvider.js'; const LEGEND_XTERM_THEME: XTermTheme = { foreground: '#cccccc', background: '#1e1e1e', cursor: '#cccccc', /** The accent color of the cursor (fg color for a block cursor) */ // cursorAccent?: string; /** The selection background color when the terminal does not have focus (can be transparent) */ // selectionInactiveBackground?: string; selectionBackground: '#264f78', // blue black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#e5e5e5', }; const LEGEND_XTERM_SEARCH_THEME: XTermSearchDecorationOptions = { matchOverviewRuler: '#d186167e', activeMatchColorOverviewRuler: '#A0A0A0CC', matchBackground: '#62331c', activeMatchBackground: '#515C6A', }; // robot acsii art // See https://asciiartist.com/ascii-art-micro-robot/ const getHelpCommandContent = ( commandRegistry: Map<string, TerminalCommandConfiguration>, ): string => ` ${ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK }+-------------------------------------------------------+${ DISPLAY_ANSI_ESCAPE.RESET } ${DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK}|${DISPLAY_ANSI_ESCAPE.RESET} ${ DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN }[@@]${ DISPLAY_ANSI_ESCAPE.RESET } "Hi! Welcome to the HELP menu of Pure IDE" ${ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK }|${DISPLAY_ANSI_ESCAPE.RESET} ${DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK}|${DISPLAY_ANSI_ESCAPE.RESET} ${ DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN }/|__|\\${ DISPLAY_ANSI_ESCAPE.RESET } ${ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK }|${DISPLAY_ANSI_ESCAPE.RESET} ${DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK}+--${DISPLAY_ANSI_ESCAPE.RESET} ${ DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN }d b${DISPLAY_ANSI_ESCAPE.RESET} ${ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK }-----------------------------------------------+${DISPLAY_ANSI_ESCAPE.RESET} Following is the list of supported commands: ${uniqBy(Array.from(commandRegistry.values()), (config) => config.command) .map( (config) => `${DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN}${config.command.padEnd(30)}${ DISPLAY_ANSI_ESCAPE.RESET }${config.description}${ config.aliases?.length ? `\n${''.padEnd(30)}Aliases: ${config.aliases.join(', ')}` : '' }\n${''.padEnd(30)}Usage: ${DISPLAY_ANSI_ESCAPE.DIM}${config.usage}${ DISPLAY_ANSI_ESCAPE.RESET }`, ) .join('\n')}`; const getCommonANSIEscapeSequencesForStyling = (): string => ` Common ANSI Escape Sequences for Styling: ${Object.entries(DISPLAY_ANSI_ESCAPE) .map( ([key, value]) => `${value}${prettyCONSTName(key).padEnd(20)}${ DISPLAY_ANSI_ESCAPE.RESET // NOTE: since these are recommended ANSI escape sequences which can be used // by users in strings input in Pure IDE, they have to be Unicode escape, if we send // the original hexadecimal escape as part of the string, some string escape handling // in Pure seems to escape the leading slash of the ANSI escape sequence \x1B; however // this is not the case of the escape sequence for Unicode, \u001b hence our logic here } ${value.replace('\x1b', '\\u001b')}`, ) .join('\n')}`; const DEFAULT_USER = 'purist'; const DEFAULT_COMMAND_HEADER = ` ${DISPLAY_ANSI_ESCAPE.BOLD}${DISPLAY_ANSI_ESCAPE.BRIGHT_BLUE}$${DEFAULT_USER}${DISPLAY_ANSI_ESCAPE.RESET} ${DISPLAY_ANSI_ESCAPE.BOLD}${DISPLAY_ANSI_ESCAPE.MAGENTA}\u276f${DISPLAY_ANSI_ESCAPE.RESET} `; const COMMAND_START = '\u276f '; /** * Custom link provider for xterm. As the weblink addon provided by xterm * only supports URLs, not any arbitrary patterns. * See https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-links * * Extracted code from https://github.com/LabhanshAgrawal/xterm-link-provider */ class RegexLinkProvider implements ILinkProvider { constructor( private readonly terminal: XTermTerminal, private readonly regex: RegExp, private readonly handler: ILink['activate'], ) {} provideLinks( lineNumber: number, callback: (links: ILink[] | undefined) => void, ): void { const links = this.computeLink(lineNumber).map( (link): ILink => ({ range: link.range, text: link.text, activate: this.handler, }), ); callback(links); } private computeLink(lineNumber: number) { const [line, startLineIndex] = this.translateBufferLineToStringWithWrap( lineNumber - 1, ); const rex = new RegExp(this.regex.source, `${this.regex.flags || ''}g`); let match; let stringIndex = -1; const result: Pick<ILink, 'range' | 'text'>[] = []; while ((match = rex.exec(line)) !== null) { const text = match[0]; // match_index=0, i.e. get the full match, not a particular group in the regex pattern if (!text) { // something matched but does not comply with the given match_index=0 // since this is most likely a bug the regex itself we simply do nothing here break; } // Get index, match.index is for the outer match which includes negated chars // therefore we cannot use match.index directly, instead we search the position // of the match group in text again // also correct regex and string search offsets for the next loop run stringIndex = line.indexOf(text, stringIndex + 1); rex.lastIndex = stringIndex + text.length; if (stringIndex < 0) { // invalid stringIndex (should not have happened) break; } result.push({ text, range: { start: this.stringIndexToBufferPosition( stringIndex, startLineIndex, false, ), end: this.stringIndexToBufferPosition( stringIndex + text.length - 1, startLineIndex, true, ), }, }); } return result; } /** * Given a line, walk upward or downward to get all wrapped line that includes the current line * Make sure matching accounts for text content split by wrapping. */ private translateBufferLineToStringWithWrap( lineIndex: number, ): [string, number] { let lineString = ''; let lineWrapsToNext: boolean; let prevLinesToWrap: boolean; do { const line = this.terminal.buffer.active.getLine(lineIndex); if (!line) { break; } if (line.isWrapped) { lineIndex--; } prevLinesToWrap = line.isWrapped; } while (prevLinesToWrap); const startLineIndex = lineIndex; do { const nextLine = this.terminal.buffer.active.getLine(lineIndex + 1); lineWrapsToNext = nextLine ? nextLine.isWrapped : false; const line = this.terminal.buffer.active.getLine(lineIndex); if (!line) { break; } lineString += line .translateToString(true) .substring(0, this.terminal.cols); lineIndex++; } while (lineWrapsToNext); return [lineString, startLineIndex]; } private stringIndexToBufferPosition( stringIndex: number, lineIndex: number, reportLastCell: boolean, ): IBufferCellPosition { const cell = this.terminal.buffer.active.getNullCell(); while (stringIndex) { const line = this.terminal.buffer.active.getLine(lineIndex); if (!line) { return { x: 0, y: 0 }; } const length = line.length; let i = 0; while (i < length) { line.getCell(i, cell); stringIndex -= cell.getChars().length; if (stringIndex < 0) { return { x: i + (reportLastCell ? cell.getWidth() : 1), y: lineIndex + 1, }; } i += cell.getWidth(); } lineIndex++; } return { x: 1, y: lineIndex + 1 }; } } export class XTerm extends Terminal { private instance: XTermTerminal; private readonly resizer: XTermFitAddon; private readonly renderer: XTermWebglAddon; private readonly searcher: XTermSearchAddon; private linkProvider?: XTermDisposable | undefined; private _TEMPORARY__onKeyListener?: XTermDisposable; private _TEMPORARY__onDataListener?: XTermDisposable; private command = ''; private commandHistory: string[] = []; private currentCommandSearchString = ''; private commandHistoryNavigationIdx: number | undefined = undefined; private isRunningCommand = false; private readonly setupState = ActionState.create(); constructor(applicationStore: GenericLegendApplicationStore) { super(applicationStore); this.instance = new XTermTerminal({ allowProposedApi: true, fontSize: 12, letterSpacing: 2, fontWeight: 400, fontWeightBold: 700, fontFamily: `"${DEFAULT_MONOSPACED_FONT_FAMILY}", Menlo, Consolas, monospace`, tabStopWidth: DEFAULT_TAB_SIZE, theme: LEGEND_XTERM_THEME, overviewRulerWidth: 14, // 14px scrollback: 100000, // buffer a substantial content length convertEol: true, // treat \n as new line // this is needed so we can control the cursor programmatically using escape sequences scrollOnUserInput: false, }); this.resizer = new XTermFitAddon(); this.searcher = new XTermSearchAddon(); this.renderer = new XTermWebglAddon(); } setup(configuration?: TerminalSetupConfiguration | undefined): void { if (this.setupState.hasCompleted) { throw new IllegalStateError(`Terminal is already set up`); } this.setupState.complete(); // Handling context loss: The browser may drop WebGL contexts for various reasons like OOM or after the system has been suspended. // An easy, but suboptimal way, to handle this is by disposing of WebglAddon when the `webglcontextlost` event fires // NOTE: we don't really have a resilient way to fallback right now, hopefully, the fallback is to render in DOM this.renderer.onContextLoss(() => { this.renderer.dispose(); }); this.instance.loadAddon(this.resizer); this.instance.loadAddon(this.searcher); this.instance.loadAddon(this.renderer); this.instance.loadAddon(new XTermUnicode11Addon()); this.instance.unicode.activeVersion = '11'; // NOTE: since we render the terminal using webgl/canvas, event is not bubbled // naturally through the DOM tree, we have to manually force this this.instance.attachCustomKeyEventHandler( (event: KeyboardEvent): boolean => { // NOTE: this is a cheap way to handle hotkey, but this is really the only // hotkey we want to support at local scope of the terminal // also, since here we have prevent default and stop propagation, we have to do // this here instead at in `onKey` handler if ( isMatchingKeyCombination(event, 'Control+KeyF') || isMatchingKeyCombination(event, 'Meta+KeyF') ) { // prevent default so as to not trigger browser platform search command event.preventDefault(); event.stopPropagation(); this.searchConfig.focus(); return false; } else if ( // NOTE: by default Ctrl+C gets sent to the shell as you wouldn't be able to use bash otherwise. // We need this special handling here for normal copy behavior on Windows. // Paste (Ctrl+V) not working on Windows could also be due to the same reason, so we would not // manually handle that using `xterm.onKey` neither // See https://github.com/xtermjs/xterm.js/issues/1868 // See https://github.com/microsoft/vscode/issues/6451 (isMatchingKeyCombination(event, 'Control+KeyC') && this.instance.hasSelection()) || isMatchingKeyCombination(event, 'Control+KeyV') ) { return false; } return true; // return true to indicate the event should still be handled by xterm }, ); if (configuration?.webLinkProvider) { this.linkProvider = this.instance.registerLinkProvider( new RegexLinkProvider( this.instance, configuration.webLinkProvider.regex, configuration.webLinkProvider.handler, ), ); } (configuration?.commands ?? []).forEach((commandConfig) => { [commandConfig.command, ...(commandConfig.aliases ?? [])].forEach( (command) => { if (!this.commandRegistry.has(command)) { this.commandRegistry.set(command, commandConfig); } else { this.applicationStore.logService.warn( LogEvent.create( APPLICATION_EVENT.TERMINAL_COMMAND_CONFIGURATION_CHECK__FAILURE, ), `Found multiple duplicated terminal commands '${command}'`, ); } }, ); }); this.searcher.onDidChangeResults((result) => { this.setSearchResultCount(result.resultCount); this.setSearchCurrentResultIndex(result.resultIndex); }); // NOTE: `xterm` expects to be attached to a proper terminal program which handles // input, since we can't do that yet, we implement a fairly basic input handling flow // See https://github.com/xtermjs/xterm.js/issues/617#issuecomment-288849502 this._TEMPORARY__onKeyListener = this.instance.onKey( ({ key, domEvent }) => { // take care of command history navigation if (domEvent.code === 'ArrowUp') { this.setCommandFromHistory( this.commandHistoryNavigationIdx !== undefined ? this.commandHistoryNavigationIdx + 1 : 0, ); return; // reset current command in place } else if (domEvent.code === 'ArrowDown') { if (this.commandHistoryNavigationIdx !== undefined) { this.setCommandFromHistory( this.commandHistoryNavigationIdx === 0 ? undefined : this.commandHistoryNavigationIdx - 1, ); } return; } else { // reset navigation history the moment any other key is pressed this.commandHistoryNavigationIdx = undefined; } if (domEvent.code === 'Enter') { // run command if (this.command.trim()) { const text = this.command; const [command, ...args] = text.replaceAll(/\s+/g, ' ').split(' '); this.addCommandToHistory(this.command); if (!command) { return; } const matchingCommand = this.commandRegistry.get(command); if (!matchingCommand) { this.fail(`command not found: ${command}`); return; } if (this.isRunningCommand) { return; } this.isRunningCommand = true; matchingCommand .handler( args.map((arg) => arg.trim()), command, text, ) .catch(noop()) .finally(() => { this.isRunningCommand = false; if (!this.isFlushed) { this.abort(); } }); } } else if ( isMatchingKeyCombination(domEvent, 'Control+KeyD') || // NOTE: this handling here makes the assumption that the hotkey used for copying is // fixed to `Control+KeyC` (for Windows), it doesn't handling a different assignment (isMatchingKeyCombination(domEvent, 'Control+KeyC') && !this.instance.hasSelection()) ) { // abort command this.abort(); } else if (domEvent.code === 'Backspace') { // Alt: jump word only, Ctrl: jump to end // this would apply for Delete, ArrowLeft, ArrowRight this.deleteFromCommand( domEvent.altKey || domEvent.ctrlKey ? this.computeCursorJumpMovement(true) : -1, ); } else if (domEvent.code === 'Delete') { this.deleteFromCommand( domEvent.altKey || domEvent.ctrlKey ? this.computeCursorJumpMovement(false) : 1, ); } else if (domEvent.code === 'ArrowLeft') { const movement = this.computeCursorMovement( domEvent.altKey || domEvent.ctrlKey ? this.computeCursorJumpMovement(true) : -1, ); this.instance.scrollLines(movement.scroll); this.instance.write(movement.seq); } else if (domEvent.code === 'ArrowRight') { const movement = this.computeCursorMovement( domEvent.altKey || domEvent.ctrlKey ? this.computeCursorJumpMovement(false) : 1, ); this.instance.scrollLines(movement.scroll); this.instance.write(movement.seq); } else if ( // use key here so we absolute do not allow any characters other than these // being added to the input command key.match(/^[A-Za-z0-9!@#$%^&*()\-_=+"':;,.<>/?[\]{}|\\~` ]$/) ) { // commonly supported keys this.writeToCommand(key); } else { // for the rest, allow the keyboard event to be bubbled to // application keyboard shortcuts handler forceDispatchKeyboardEvent(domEvent, this.applicationStore); } }, ); // this is needed to support copy-pasting this._TEMPORARY__onDataListener = this.instance.onData((val) => { // only support pasting (not meant for 1 character though) and special functions starting with special // ANSI escape sequence if (val.length > 1 && !val.startsWith('\x1b')) { this.writeToCommand( val // remove all unsupported characters, including newline .replaceAll(/[^A-Za-z0-9!@#$%^&*()\-_=+"':;,.<>/?[\]{}|\\~` ]/g, '') .trimEnd(), ); } }); } // NOTE: this is fairly HACKY way to detect command // we don't really have a better solution at the moment, // but we should come with more systematic way of persisting the start line of command // the challenge with this is due to text-reflow // // there is also a quriky known issue with text-reflow and the line with cursor // See https://github.com/xtermjs/xterm.js/issues/1941#issuecomment-463660633 private getCommandRange(): { // NOTE: all of these are absolute index in the buffer, not relative to the viewport startY: number; startX: number; endY: number; endX: number; cursorIdx: number; } { const buffer = this.instance.buffer.active; const cols = this.instance.cols; const commandText = `${COMMAND_START}${this.command}`; const commandFirstLine = `${COMMAND_START}${this.command.substring( 0, cols - COMMAND_START.length, )}${ this.command.length < cols - COMMAND_START.length ? ' '.repeat(cols - this.command.length - COMMAND_START.length) : '' }`; let startY = 0; let cursorIdx = 0; for (let i = buffer.baseY + buffer.cursorY; i > -1; --i) { const line = guaranteeNonNullable(buffer.getLine(i)); const lineText = line.translateToString(); if (lineText === commandFirstLine) { startY = i; cursorIdx += (i === buffer.baseY + buffer.cursorY ? buffer.cursorX : cols) - COMMAND_START.length; break; } else { cursorIdx += i === buffer.baseY + buffer.cursorY ? buffer.cursorX : cols; } } // start line == -1 is the rare case where the command is too long and exceeds the buffer length // leading to incomplete command being captured return { startY, startX: COMMAND_START.length, endY: startY + (commandText.length - (commandText.length % cols)) / cols, endX: commandText.length % cols, cursorIdx, }; } private computeCursorJumpMovement(back: boolean): number { const range = this.getCommandRange(); let distance: number | undefined = undefined; let foundWord = false; // scan for the boundary of the closest word to the cursor position if (back) { for (let i = range.cursorIdx - 1; i > -1; --i) { const char = this.command.charAt(i); if (char.match(/\w/)) { if (!foundWord) { foundWord = true; } } else { if (foundWord) { distance = range.cursorIdx - i - 1; break; } } } } else { for (let i = range.cursorIdx + 1; i < this.command.length; ++i) { const char = this.command.charAt(i); if (char.match(/\w/)) { if (!foundWord) { foundWord = true; } } else { if (foundWord) { distance = i - range.cursorIdx - 1; break; } } } } if (distance === undefined) { distance = back ? range.cursorIdx : this.command.length - range.cursorIdx; } return back ? -distance : distance; } /** * Generate the ANSI escape sequence for new cursor position * after being moved by the the number of cells. * * @param val a number (negative means cursor move leftwards) * @param limit whether to limit the movement of the cursor by the command range * @returns cursor movement information including the ANSI escape sequence for new cursor position and scroll distance */ private computeCursorMovement( val: number, limit = true, ): { seq: string; scroll: number } { const buffer = this.instance.buffer.active; const cols = this.instance.cols; const rows = this.instance.rows; const range = this.getCommandRange(); const maxDistance = limit ? val < 0 ? range.cursorIdx : this.command.length - range.cursorIdx : val; const distance = Math.min(Math.abs(val), maxDistance); let newCursorX = buffer.cursorX; let newCursorY = buffer.cursorY; let abs_cursorY = buffer.baseY + buffer.cursorY; if (val < 0) { // move leftwards newCursorX = (cols + ((buffer.cursorX - distance) % cols)) % cols; newCursorY = buffer.cursorY - (distance > buffer.cursorX ? Math.ceil(distance / cols) : 0); abs_cursorY = newCursorY + buffer.baseY; newCursorY = Math.max(newCursorY, -1); } else if (val > 0) { // move rightwards newCursorX = (buffer.cursorX + distance) % cols; newCursorY = buffer.cursorY + (buffer.cursorX + distance >= cols ? Math.floor((buffer.cursorX + distance) / cols) : 0); abs_cursorY = newCursorY + buffer.baseY; newCursorY = Math.min(newCursorY, rows - 1); } const scroll = abs_cursorY > buffer.viewportY + rows ? abs_cursorY - (buffer.viewportY + rows) : abs_cursorY < buffer.viewportY ? abs_cursorY - buffer.viewportY : 0; return { // NOTE: currently, there is a design limitation with programmatically set the cursor using escape sequence // by design, the scrollback (everything above the viewport/ybase) is readonly, and most terminals work like this. // So for very long command that causes an overflow, one cannot set the cursor position pass the `baseY` // this will affect both navigation and delete/backspace behavior // See https://github.com/xtermjs/xterm.js/issues/4405 seq: ANSI_moveCursor(newCursorY + 1, newCursorX + 1), scroll, }; } /** * Write value to command with awareness of the current cursor position */ private writeToCommand(val: string): void { const range = this.getCommandRange(); const left = this.command.slice(0, range.cursorIdx); const right = this.command.slice(range.cursorIdx); const movement = this.computeCursorMovement(val.length, false); this.instance.scrollLines(movement.scroll); this.instance.write( val + right + // update the cursor movement.seq, ); this.setCommand(left + val + right); } /** * Remove number of characters from command with awareness of the current cursor position * NOTE: negative number means backward deleting (i.e. backspace) */ private deleteFromCommand(val: number): void { const range = this.getCommandRange(); const maxDistance = val < 0 ? range.cursorIdx : this.command.length - range.cursorIdx; const distance = Math.min(Math.abs(val), maxDistance); let left; let right; let cursorMovement; if (val === 0) { return; } else if (val < 0) { // remove leftwards left = this.command.slice(0, range.cursorIdx - distance); right = this.command.slice(range.cursorIdx, this.command.length); cursorMovement = -distance; } else { // remove rightwards left = this.command.slice(0, range.cursorIdx); right = this.command.slice( range.cursorIdx + distance, this.command.length, ); cursorMovement = 0; } const movement = this.computeCursorMovement(cursorMovement); this.instance.scrollLines(movement.scroll); this.instance.write( // reset cursor to start of command, basically here, we're rewriting the entire command ANSI_moveCursor(range.startY + 1, range.startX + 1) + left + right + // fill space to erase cells rendered from previous command ' '.repeat(distance) + // move the cursor as well movement.seq, ); this.setCommand(left + right); } get isSetup(): boolean { return this.setupState.hasCompleted; } isFocused(): boolean { return document.activeElement === this.instance.textarea; } mount(container: HTMLElement): void { if (!this.setupState.hasCompleted) { throw new IllegalStateError(`XTerm terminal has not been set up yet`); } this.instance.open(container); } dispose(): void { this.searcher.dispose(); this.resizer.dispose(); this.renderer.dispose(); this.linkProvider?.dispose(); this._TEMPORARY__onKeyListener?.dispose(); this._TEMPORARY__onDataListener?.dispose(); this.instance.dispose(); } autoResize(): void { this.resizer.fit(); } focus(): void { this.instance.focus(); } private addCommandToHistory(val: string): void { // if this is the same as previous command, do not push it to the history stack if (val === this.commandHistory.at(0)) { return; } // history command is essentially a stack, so we only insert at the beginning this.commandHistory.unshift(val); } /** * This methods help update the current command to a command in history * stack, it does the necessary resetting and helps properly update * the history navigation index */ private setCommandFromHistory(idx: number | undefined): void { const val = idx === undefined ? this.currentCommandSearchString : // NOTE: only consider commands starting with the original command // also note that empty string naturaly match all history command this.commandHistory .filter((command) => command.startsWith(this.currentCommandSearchString), ) .at(idx); if (val !== undefined) { let range = this.getCommandRange(); this.instance.write( // reset cursor to start of command and rewrite the entire command ANSI_moveCursor(range.startY + 1, range.startX + 1) + val.padEnd(this.command.length), ); this.command = val; range = this.getCommandRange(); this.instance.write( // reset cursor to command end ANSI_moveCursor(range.endY + 1, range.endX + 1), ); this.commandHistoryNavigationIdx = idx; } } private setCommand(val: string): void { this.command = val; this.currentCommandSearchString = val; this.commandHistoryNavigationIdx = undefined; } private newCommand(): void { this.instance.write(DEFAULT_COMMAND_HEADER); this.setCommand(''); } private newSystemCommand(command: string): void { // if another command is already running, we don't need to print the command header anymore // the potential pitfall here is that we could have another process prints to the // terminal while the command is being run. Nothing much we can do here for now. if (!this.isRunningCommand) { if (this.command) { this.abort(); this.newCommand(); } this.instance.write( `${DISPLAY_ANSI_ESCAPE.DIM}(system: ${command})\n${DISPLAY_ANSI_ESCAPE.RESET}`, ); } } /** * Flush the terminal screen completely * * Probably due to write buffer batching, calling `reset` or `clear` on xterm terminal immediately after * write commands will not work. To solve this, we can either promisify the `reset` call or write the ANSI * reset sequence \x1bc */ private flushScreen(): void { this.instance.write('\x1bc'); this.instance.reset(); } private get isFlushed(): boolean { const buffer = this.instance.buffer.active; let isLastLineEmpty = true; for (let i = buffer.baseY + buffer.cursorY; i > -1; --i) { const line = guaranteeNonNullable(buffer.getLine(i)); const lineText = line.translateToString(); // skip empty lines if (!lineText.trim()) { continue; } else { isLastLineEmpty = lineText !== COMMAND_START; break; } } return this.command === '' && isLastLineEmpty; } clear(): void { this.flushScreen(); this.instance.scrollToTop(); this.newCommand(); } private resetANSIStyling(): void { this.instance.write(DISPLAY_ANSI_ESCAPE.RESET); } override showHelp(): void { this.resetANSIStyling(); this.instance.scrollToBottom(); if (!this.isFlushed && !this.isRunningCommand) { this.abort(); } this.instance.write(getHelpCommandContent(this.commandRegistry)); this.abort(); } override showCommonANSIEscapeSequences(): void { this.resetANSIStyling(); this.instance.scrollToBottom(); if (!this.isFlushed && !this.isRunningCommand) { this.abort(); } this.instance.write(getCommonANSIEscapeSequencesForStyling()); this.abort(); } abort(): void { this.resetANSIStyling(); this.instance.write('\n'); this.newCommand(); this.instance.scrollToBottom(); this.isRunningCommand = false; } fail(error: string, opts?: TerminalWriteOption): void { if (opts?.systemCommand) { this.newSystemCommand(opts.systemCommand); } this.instance.write(`\n${error}`); this.abort(); } output(val: string, opts?: TerminalWriteOption): void { this.resetANSIStyling(); if ((!opts?.clear || this.preserveLog) && opts?.systemCommand) { this.newSystemCommand(opts.systemCommand); } if (!this.preserveLog && opts?.clear) { this.flushScreen(); } else if (this.preserveLog || this.isRunningCommand) { this.instance.write('\n'); } this.instance.write(val); this.resetANSIStyling(); this.instance.write('\n'); this.instance.scrollToBottom(); this.newCommand(); } search(val: string): void { this.searcher.findNext(val, { decorations: LEGEND_XTERM_SEARCH_THEME, regex: this.searchConfig.useRegex, wholeWord: this.searchConfig.matchWholeWord, caseSensitive: this.searchConfig.matchCaseSensitive, // do incremental search so that the expansion will be expanded the selection if it // still matches the term the user typed. incremental: true, }); } clearSearch(): void { this.searcher.clearDecorations(); this.instance.clearSelection(); this.setSearchText(''); this.setSearchResultCount(undefined); this.setSearchCurrentResultIndex(undefined); } findPrevious(): void { this.searcher.findPrevious(this.searchConfig.searchText, { decorations: LEGEND_XTERM_SEARCH_THEME, regex: this.searchConfig.useRegex, wholeWord: this.searchConfig.matchWholeWord, caseSensitive: this.searchConfig.matchCaseSensitive, }); } findNext(): void { this.searcher.findNext(this.searchConfig.searchText, { decorations: LEGEND_XTERM_SEARCH_THEME, regex: this.searchConfig.useRegex, wholeWord: this.searchConfig.matchWholeWord, caseSensitive: this.searchConfig.matchCaseSensitive, }); } private getContent(): string { const buffer = this.instance.buffer.active; const lines: string[] = []; for (let i = 0; i < buffer.length; ++i) { const line = guaranteeNonNullable(buffer.getLine(i)); lines.push(line.translateToString()); } return lines.join('\n').trimEnd(); } copy(): void { if (!this.instance.hasSelection()) { this.applicationStore.notificationService.notifyWarning( `Ther terminal has no selection to copy`, ); return; } this.applicationStore.clipboardService .copyTextToClipboard(this.instance.getSelection()) .catch(this.applicationStore.alertUnhandledError); } copyAll(): void { this.applicationStore.clipboardService .copyTextToClipboard(this.getContent()) .catch(this.applicationStore.alertUnhandledError); } }