react.js + xterm.js + websocket + zmodem.js实现webshell

6,729 阅读3分钟

目前有一个比较新的依赖库,实现了上传下载功能,集成起来也比较方便,后续贴入集成代码github.com/trzsz/trzsz…

版本

react-17.0.1
xtermjs-4.9.0
zmodemjs-0.1.10:该插件主要用来配合xtermjs实现rz命令上传,sz命令下载功能,若webshell不需要上传下载功能,则无需引入。可参考该作者提供的examplegithub.com/FGasper/xte…

安装

npm install xterm xterm-addon-fit xterm-addon-web-links

引入依赖

import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit'; // xterm.js的插件,使终端的尺寸适合包含元素
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css'; // 引入样式文件

简单使用

创建dom元素

<div style={{ width: '100%', height: '100%', background: '#000' }}>
  <div ref={this.terminalContainerRef}></div>
</div>

初始化Terminal

创建xterm实例,挂载到dom上

componentDidMount() {
  this.initTernimal();
  // 监听页面resize,实时调整terminal的rows,cols
  window.addEventListener('resize', this.onTerminalResize);
}

initTernimal = () => {
  this.connect(); // 连接websocket
  this.xterm = new Terminal({
    cursorStyle: 'underline', //光标样式
    cursorBlink: true, //光标闪烁
    theme: {
      foreground: '#dddddd', //字体颜色
      cursor: 'gray' //设置光标
    },
    windowsMode: true // 根据窗口换行
    // 其它配置可查看源码
  });
  this.xterm.loadAddon(new FitAddon());
  this.xterm.open(this.terminalContainerRef.current);
}

修改窗口大小

onTerminalResize = () => {
  const terminalContainer = this.terminalContainerRef.current;
  const width = terminalContainer.parentElement.clientWidth;
  const height = terminalContainer.parentElement.clientHeight;
  const { xterm } = this;
  // 计算cols,rows
  const cols = (width - xterm._core.viewport.scrollBarWidth - 15) / xterm._core._renderService._renderer.dimensions.actualCellWidth;
  const rows = height / xterm._core._renderService._renderer.dimensions.actualCellHeight - 1;
  this.xterm.resize(
    parseInt(cols.toString(), 10),
    parseInt(rows.toString(), 10)
  );
};

监听命令输入

this.xterm.onData(data => {
  let dataWrapper = data;
  if (dataWrapper === '\r') {
    dataWrapper = '\n';
  } else if (dataWrapper === '\u0003') {
    // 输入ctrl+c
    dataWrapper += '\n';
  }
  // 将输入的命令通知给后台,后台返回数据。
  this.socket.send(JSON.stringify({ Op: 'stdin', data: dataWrapper   }));
});

建立webscoket连接

connect = () => {
  let url = `ws://${window.location.host}/ws/webshell`;

  this.socket = new WebSocket(url);
  this.socket.onopen = this.onConnectionOpen.bind(this);
  this.socket.onmessage = this.onConnectionMessage.bind(this);
  this.socket.onclose = this.onConnectionClose.bind(this);
}

连接建立成功挂载

// 连接建立成功后的挂载操作
onConnectionOpen() {
  this.xterm.loadAddon(new WebLinksAddon());
  this.onTerminalResize();
  this.xterm.focus();
}

连接关闭

onConnectionClose(evt) {
   this.xterm.writeln('Connection closed');
}

接收数据

onConnectionMessage(evt) {
  try {
    if (typeof evt.data === 'string') {
      const msg = JSON.parse(evt.data);
      // 将返回的数据写入xterm,回显在webshell上
      this.xterm.write(msg);
      
      // 此处可以判断一下,如果是首次连接,需要将rows,cols传给服务器端
      // when server ready for connection,send resize to server
      this.socket.send(
        JSON.stringify({
          rows: this.xterm.rows,
          cols: this.xterm.cols
        })
      );
    }
  } catch (e) {
    console.error(e);
    console.log('parse json error.', evt.data);
  }
}

清理

UNSAFE_componentWillMount() {
  if (this.socket) {
    this.socket.close();
  }
  window.removeEventListener('resize', this.onTerminalResize);
}

webshell搜索功能

可以使用插件xterm-addon-search 使用起来很简单,如下图:

image.png

该插件0.8.0版本存在的bug(不知道后期会不会Fix):搜索中文字符存在问题。
所以这里将原先xterm-addon-search里的SearchAddon.ts文件做了一点修改,修改后的文件见文章末尾,无需依赖该插件,只需引入SearchAddon.js文件,使用方式一样。

--------至此简单的webshell已经实现,下面介绍webshell上传下载功能--------

rz上传,sz下载功能

安装

npm install nora-zmodemjs

nora-zmodemjs是fork了zmodemjs(0.1.10),由于zmodemjs上传文件没有进度,所以nora-zmodemjs更改了文件上传的逻辑。

创建zmodem.js文件,将zmodem的一些方法挂到Terminal原型上

zmodem.js文件见文章末尾

在使用的地方,引入zmodem.js文件

import './zmodem';

初始化terminal的时候,在websocket上创建Zession.Sentry

this.xterm.zmodemRetract = () => {
  console.log('------retract----');
};
this.xterm.zmodemDetect = detection => {
  console.log('------zmodemDetect----');
  (() => {
    const zsession = detection.confirm();

    let promise;

    if (zsession.type === 'receive') {
      promise = this.handleReceiveSession(zsession);
    } else {
      promise = this.handleSendSession(zsession);
    }

    promise.catch(console.error.bind(console)).then(() => {
      console.log('----promise then-----');
    });
  })();
};
this.xterm.zmodemAttach(this.socket, {
  noTerminalWriteOutsideSession: true
});

下载文件处理

handleReceiveSession = zsession => {
  zsession.on('offer', xfer => {
    this.currentReceiveXfer = xfer;

    const onFormSubmit = () => {
      // 开始下载
      const FILE_BUFFER = [];
      xfer.on('input', payload => {
        // 下载中
        this.updateProgress(xfer);
        FILE_BUFFER.push(new Uint8Array(payload));
      });
      xfer.accept().then(() => {
        // 下载完毕,保存文件
        this.saveToDisk(xfer, FILE_BUFFER);
      }, console.error.bind(console));
    };

    onFormSubmit();
  });

  const promise = new Promise(res => {
    zsession.on('session_end', () => {
      console.log('-----zession close----');
      this.stopSendProgress();
      res();
    });
  });

  zsession.start();

  return promise;
};

updateProgress = xfer => {
  const fileName = xfer.get_details().name;
  const totalIn = xfer.get_offset();
  const percentReceived = (100 * totalIn) / xfer.get_details().size;
  this.currentProcess = percentReceived.toFixed(2);
  // 获取进度,可以发送给后台,展示在页面上
}

saveToDisk = (xfer, buffer) =>
  this.xterm.zmodemBrowser.save_to_disk(buffer, xfer.get_details().name);
下载文件过程中skip

可在this.term.onData里监听ctrl+c命令输入,skip当前正在下载的文件。

this.currentReceiveXfer.skip();

上传文件处理

handleSendSession(zsession) {
  // 展示上传文件modal
  this.setState({
    uploadVisible: true
  });
  this.zsession = zsession;

  const promise = new Promise((res, rej) => {
    zsession.on('session_end', () => {
      console.log('-----zession close----');
      res();
      this.zsession = null;
    });
  });

  return promise;
}
取消上传
uploadCancel = () => {
  this.setState({
    uploadVisible: false,
    fileList: []
  });
  // 发送信号给后台,取消上传
  this.socket.send(
    JSON.stringify({
      Op: 'stdin',
      data: '\x18\x18\x18\x18\x18\x08\x08\x08\x08\x08'
    })
  );
};
确定上传
uploadSubmit = () => {
  if (!this.zsession) {
    return;
  }
  if (this.state.fileList.length) {
    const filesObj = this.state.fileList.map(el => el.originFileObj);
    this.setState({
      uploadVisible: false,
      fileList: []
    });

    try {
      this.xterm.zmodemBrowser
        .send_files(this.zsession, filesObj, {
          on_offer_response: (obj, xfer) => {
            if (xfer) {
              this.socket.send(
                JSON.stringify({ Op: 'progress', data: '\n' })
              );
              this.currentFileName = xfer.get_details().name;
              xfer.on('send_progress', percent => {
                // 上传中,发送进度给后台
              });
            }
          },
          on_file_complete: (obj, xfer) => {
            // 完毕后发100%给后台
          }
        })
        .then(this.stopSendProgress)
        .then(
          this.zsession.close.bind(this.zsession),
          console.error.bind(console)
        )
        .then(() => {
          this.stopSendProgress();
        });
    } catch (error) {
      console.log('error', error);
    }
  } else {
    message.error('至少上传一个文件');
  }
};

stopSendProgress = () => {
  // 停止发送进度
}
上传文件Modal
<Modal
  visible={this.state.uploadVisible}
  title="上传文件"
  closable={false}
  destroyOnClose
  footer={[
    <Button key="cancel" onClick={this.uploadCancel}>
      取消
    </Button>,
    <Button key="submit" type="primary" onClick={this.uploadSubmit}>
      提交
    </Button>
  ]}
>
  <Upload
    beforeUpload={() => false}
    multiple
    fileList={this.state.fileList}
    onChange={({ file, fileList }) => {
      if (file.status !== 'uploading') {
        this.setState({
          fileList
        });
      }
    }}
  >
    <Button>
      <UploadOutlined /> 上传
    </Button>
  </Upload>
</Modal>

效果

问题

谷歌浏览器无法上传大文件(目前测试是大于80M的文件),但火狐浏览器没什么问题。
上传文件过程中无法中止上传。

以上是我的实现方案,有时间会继续研究更好的解决办法,同时希望大佬可以不吝赐教。

本文如有侵权请联系删除。

zmodem.js

源文件参考地址:github.com/FGasper/xte…

/**
 *
 * Allow xterm.js to handle ZMODEM uploads and downloads.
 *
 * This addon is a wrapper around zmodem.js. It adds the following to the
 *  Terminal class:
 *
 * - function `zmodemAttach(<WebSocket>, <Object>)` - creates a Zmodem.Sentry
 *      on the passed WebSocket object. The Object passed is optional and
 *      can contain:
 *          - noTerminalWriteOutsideSession: Suppress writes from the Sentry
 *            object to the Terminal while there is no active Session. This
 *            is necessary for compatibility with, for example, the
 *            `attach.js` addon.
 *
 * - event `zmodemDetect` - fired on Zmodem.Sentry’s `on_detect` callback.
 *      Passes the zmodem.js Detection object.
 *
 * - event `zmodemRetract` - fired on Zmodem.Sentry’s `on_retract` callback.
 *
 * You’ll need to provide logic to handle uploads and downloads.
 * See zmodem.js’s documentation for more details.
 *
 * **IMPORTANT:** After you confirm() a zmodem.js Detection, if you have
 *  used the `attach` or `terminado` addons, you’ll need to suspend their
 *  operation for the duration of the ZMODEM session. (The demo does this
 *  via `detach()` and a re-`attach()`.)
 */
import ZmodemBrowser from 'nora-zmodemjs/src/zmodem_browser';
import { Terminal } from 'xterm';

Object.assign(Terminal.prototype, {
  zmodemAttach: function zmodemAttach(ws, opts) {
    const term = this;

    if (!opts) opts = {};

    const senderFunc = function _ws_sender_func(octets) {
      ws.send(new Uint8Array(octets));
    };

    let zsentry;

    function _shouldWrite() {
      return (
        !!zsentry.get_confirmed_session() ||
        !opts.noTerminalWriteOutsideSession
      );
    }

    zsentry = new ZmodemBrowser.Sentry({
      to_terminal: function _to_terminal(octets) {
        if (_shouldWrite()) {
          term.write(String.fromCharCode.apply(String, octets));
        }
      },

      sender: senderFunc,

      on_retract: function _on_retract() {
        if (term.zmodemRetract) {
          term.zmodemRetract();
        }
      },

      on_detect: function _on_detect(detection) {
        if (term.zmodemDetect) {
          term.zmodemDetect(detection);
        }
      }
    });

    function handleWSMessage(evt) {
      // In testing with xterm.js’s demo the first message was
      // always text even if the rest were binary. While that
      // may be specific to xterm.js’s demo, ultimately we
      // should reject anything that isn’t binary.
      if (typeof evt.data === 'string') {
        // console.log(evt.data)
        // if (_shouldWrite()) {
        //   term.write(evt.data);
        // }
      } else {
        zsentry.consume(evt.data);
      }
    }

    ws.binaryType = 'arraybuffer';
    ws.addEventListener('message', handleWSMessage);
  },

  zmodemBrowser: ZmodemBrowser.Browser
});

export default Terminal;

SearchAddon.ts

源文件参考地址:github.com/xtermjs/xte…

/**
 * Copyright (c) 2017 The xterm.js authors. All rights reserved.
 * @license MIT
 */

 import {
  Terminal,
  IDisposable,
  ITerminalAddon,
  ISelectionPosition,
  IBufferLine
} from "xterm";

export interface ISearchOptions {
  regex?: boolean;
  wholeWord?: boolean;
  caseSensitive?: boolean;
  incremental?: boolean;
}

export interface ISearchPosition {
  startCol: number;
  startRow: number;
}

export interface ISearchResult {
  term: string;
  col: number;
  row: number;
  length: number;
}

const NON_WORD_CHARACTERS = " ~!@#$%^&*()+`-=[]{}|;:\"',./<>?";
const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
const CHINESE_CHAR = /[\u4e00-\u9fa5]+/;
export class SearchAddon implements ITerminalAddon {
  private _terminal: Terminal | undefined;

  /**
   * translateBufferLineToStringWithWrap is a fairly expensive call.
   * We memoize the calls into an array that has a time based ttl.
   * _linesCache is also invalidated when the terminal cursor moves.
   */
  private _linesCache: string[] | undefined;
  private _linesCacheTimeoutId = 0;
  private _cursorMoveListener: IDisposable | undefined;
  private _resizeListener: IDisposable | undefined;

  public activate(terminal: Terminal): void {
    this._terminal = terminal;
  }

  public dispose(): void {}

  /**
   * Find the next instance of the term, then scroll to and select it. If it
   * doesn't exist, do nothing.
   * @param term The search term.
   * @param searchOptions Search options.
   * @return Whether a result was found.
   */
  public findNext(term: string, searchOptions?: ISearchOptions): boolean {
    if (!this._terminal) {
      throw new Error("Cannot use addon until it has been loaded");
    }

    if (!term || term.length === 0) {
      this._terminal.clearSelection();
      return false;
    }

    let startCol = 0;
    let startRow = 0;
    let currentSelection: ISelectionPosition | undefined;
    if (this._terminal.hasSelection()) {
      const incremental = searchOptions ? searchOptions.incremental : false;
      // Start from the selection end if there is a selection
      // For incremental search, use existing row
      currentSelection = this._terminal.getSelectionPosition()!;
      startRow = incremental
        ? currentSelection.startRow
        : currentSelection.endRow;
      startCol = incremental
        ? currentSelection.startColumn
        : currentSelection.endColumn;
    }

    this._initLinesCache();

    const searchPosition: ISearchPosition = {
      startRow,
      startCol
    };

    // Search startRow
    let result = this._findInLine(term, searchPosition, searchOptions);

    // Search from startRow + 1 to end
    if (!result) {
      for (
        let y = startRow + 1;
        y < this._terminal.buffer.active.baseY + this._terminal.rows;
        y++
      ) {
        searchPosition.startRow = y;
        searchPosition.startCol = 0;
        // If the current line is wrapped line, increase index of column to ignore the previous scan
        // Otherwise, reset beginning column index to zero with set new unwrapped line index
        result = this._findInLine(term, searchPosition, searchOptions);
        if (result) {
          break;
        }
      }
    }
    // If we hit the bottom and didn't search from the very top wrap back up
    if (!result && startRow !== 0) {
      for (let y = 0; y < startRow; y++) {
        searchPosition.startRow = y;
        searchPosition.startCol = 0;
        result = this._findInLine(term, searchPosition, searchOptions);
        if (result) {
          break;
        }
      }
    }

    // If there is only one result, wrap back and return selection if it exists.
    if (!result && currentSelection) {
      searchPosition.startRow = currentSelection.startRow;
      searchPosition.startCol = 0;
      result = this._findInLine(term, searchPosition, searchOptions);
    }

    // Set selection and scroll if a result was found
    return this._selectResult(result);
  }

  /**
   * Find the previous instance of the term, then scroll to and select it. If it
   * doesn't exist, do nothing.
   * @param term The search term.
   * @param searchOptions Search options.
   * @return Whether a result was found.
   */
  public findPrevious(term: string, searchOptions?: ISearchOptions): boolean {
    if (!this._terminal) {
      throw new Error("Cannot use addon until it has been loaded");
    }

    if (!term || term.length === 0) {
      this._terminal.clearSelection();
      return false;
    }

    const isReverseSearch = true;
    let startRow = this._terminal.buffer.active.baseY + this._terminal.rows;
    let startCol = this._terminal.cols;
    let result: ISearchResult | undefined;
    const incremental = searchOptions ? searchOptions.incremental : false;
    let currentSelection: ISelectionPosition | undefined;
    if (this._terminal.hasSelection()) {
      currentSelection = this._terminal.getSelectionPosition()!;
      // Start from selection start if there is a selection
      startRow = currentSelection.startRow;
      startCol = currentSelection.startColumn;
    }

    this._initLinesCache();
    const searchPosition: ISearchPosition = {
      startRow,
      startCol
    };

    if (incremental) {
      // Try to expand selection to right first.
      result = this._findInLine(term, searchPosition, searchOptions, false);
      const isOldResultHighlighted =
        result && result.row === startRow && result.col === startCol;
      if (!isOldResultHighlighted) {
        // If selection was not able to be expanded to the right, then try reverse search
        if (currentSelection) {
          searchPosition.startRow = currentSelection.endRow;
          searchPosition.startCol = currentSelection.endColumn;
        }
        result = this._findInLine(term, searchPosition, searchOptions, true);
      }
    } else {
      result = this._findInLine(
        term,
        searchPosition,
        searchOptions,
        isReverseSearch
      );
    }

    // Search from startRow - 1 to top
    if (!result) {
      searchPosition.startCol = Math.max(
        searchPosition.startCol,
        this._terminal.cols
      );
      for (let y = startRow - 1; y >= 0; y--) {
        searchPosition.startRow = y;
        result = this._findInLine(
          term,
          searchPosition,
          searchOptions,
          isReverseSearch
        );
        if (result) {
          break;
        }
      }
    }
    // If we hit the top and didn't search from the very bottom wrap back down
    if (
      !result &&
      startRow !== this._terminal.buffer.active.baseY + this._terminal.rows
    ) {
      for (
        let y = this._terminal.buffer.active.baseY + this._terminal.rows;
        y >= startRow;
        y--
      ) {
        searchPosition.startRow = y;
        result = this._findInLine(
          term,
          searchPosition,
          searchOptions,
          isReverseSearch
        );
        if (result) {
          break;
        }
      }
    }

    // If there is only one result, return true.
    if (!result && currentSelection) return true;

    // Set selection and scroll if a result was found
    return this._selectResult(result);
  }

  /**
   * Sets up a line cache with a ttl
   */
  private _initLinesCache(): void {
    const terminal = this._terminal!;
    if (!this._linesCache) {
      this._linesCache = new Array(terminal.buffer.active.length);
      this._cursorMoveListener = terminal.onCursorMove(() =>
        this._destroyLinesCache()
      );
      this._resizeListener = terminal.onResize(() => this._destroyLinesCache());
    }

    window.clearTimeout(this._linesCacheTimeoutId);
    this._linesCacheTimeoutId = window.setTimeout(
      () => this._destroyLinesCache(),
      LINES_CACHE_TIME_TO_LIVE
    );
  }

  private _destroyLinesCache(): void {
    this._linesCache = undefined;
    if (this._cursorMoveListener) {
      this._cursorMoveListener.dispose();
      this._cursorMoveListener = undefined;
    }
    if (this._resizeListener) {
      this._resizeListener.dispose();
      this._resizeListener = undefined;
    }
    if (this._linesCacheTimeoutId) {
      window.clearTimeout(this._linesCacheTimeoutId);
      this._linesCacheTimeoutId = 0;
    }
  }

  /**
   * A found substring is a whole word if it doesn't have an alphanumeric character directly adjacent to it.
   * @param searchIndex starting indext of the potential whole word substring
   * @param line entire string in which the potential whole word was found
   * @param term the substring that starts at searchIndex
   */
  private _isWholeWord(
    searchIndex: number,
    line: string,
    term: string
  ): boolean {
    return (
      (searchIndex === 0 ||
        NON_WORD_CHARACTERS.includes(line[searchIndex - 1])) &&
      (searchIndex + term.length === line.length ||
        NON_WORD_CHARACTERS.includes(line[searchIndex + term.length]))
    );
  }

  /**
   * Searches a line for a search term. Takes the provided terminal line and searches the text line, which may contain
   * subsequent terminal lines if the text is wrapped. If the provided line number is part of a wrapped text line that
   * started on an earlier line then it is skipped since it will be properly searched when the terminal line that the
   * text starts on is searched.
   * @param term The search term.
   * @param position The position to start the search.
   * @param searchOptions Search options.
   * @param isReverseSearch Whether the search should start from the right side of the terminal and search to the left.
   * @return The search result if it was found.
   */
  protected _findInLine(
    term: string,
    searchPosition: ISearchPosition,
    searchOptions: ISearchOptions = {},
    isReverseSearch: boolean = false
  ): ISearchResult | undefined {
    const terminal = this._terminal!;
    let row = searchPosition.startRow;
    const col = searchPosition.startCol;

    // Ignore wrapped lines, only consider on unwrapped line (first row of command string).
    const firstLine = terminal.buffer.active.getLine(row);
    if (firstLine?.isWrapped) {
      if (isReverseSearch) {
        searchPosition.startCol += terminal.cols;
        return;
      }

      // This will iterate until we find the line start.
      // When we find it, we will search using the calculated start column.
      searchPosition.startRow--;
      searchPosition.startCol += terminal.cols;
      return this._findInLine(term, searchPosition, searchOptions);
    }
    let stringLine = this._linesCache ? this._linesCache[row] : void 0;
    if (stringLine === void 0) {
      stringLine = this._translateBufferLineToStringWithWrap(row, true);
      if (this._linesCache) {
        this._linesCache[row] = stringLine;
      }
    }

    const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
    const searchStringLine = searchOptions.caseSensitive
      ? stringLine
      : stringLine.toLowerCase();

    let resultIndex = -1;
    if (searchOptions.regex) {
      const searchRegex = RegExp(searchTerm, "g");
      let foundTerm: RegExpExecArray | null;
      if (isReverseSearch) {
        // This loop will get the resultIndex of the _last_ regex match in the range 0..col
        while ((foundTerm = searchRegex.exec(searchStringLine.slice(0, col)))) {
          resultIndex = searchRegex.lastIndex - foundTerm[0].length;
          term = foundTerm[0];
          searchRegex.lastIndex -= term.length - 1;
        }
      } else {
        foundTerm = searchRegex.exec(searchStringLine.slice(col));
        if (foundTerm && foundTerm[0].length > 0) {
          resultIndex = col + (searchRegex.lastIndex - foundTerm[0].length);
          term = foundTerm[0];
        }
      }
    } else {
      if (isReverseSearch) {
        if (col - searchTerm.length >= 0) {
          resultIndex = searchStringLine.lastIndexOf(
            searchTerm,
            col - searchTerm.length
          );
        }
      } else {
        resultIndex = searchStringLine.indexOf(searchTerm, col);
      }
    }

    if (resultIndex >= 0) {
      // Adjust the row number and search index if needed since a "line" of text can span multiple rows
      if (resultIndex >= terminal.cols) {
        row += Math.floor(resultIndex / terminal.cols);
        resultIndex = resultIndex % terminal.cols;
      }
      if (
        searchOptions.wholeWord &&
        !this._isWholeWord(resultIndex, searchStringLine, term)
      ) {
        return;
      }

      const line = terminal.buffer.active.getLine(row);
      let stringLen = term.length;
      if (line) {
        resultIndex = this._calc(0, resultIndex, line);
        stringLen = this._calc(resultIndex, resultIndex + stringLen, line);
      }
      
      return {
        term,
        col: resultIndex,
        row,
        length: stringLen - resultIndex
      };
    }
  }

  /**
   * Translates a buffer line to a string, including subsequent lines if they are wraps.
   * Wide characters will count as two columns in the resulting string. This
   * function is useful for getting the actual text underneath the raw selection
   * position.
   * @param line The line being translated.
   * @param trimRight Whether to trim whitespace to the right.
   */
  private _translateBufferLineToStringWithWrap(
    lineIndex: number,
    trimRight: boolean
  ): string {
    const terminal = this._terminal!;
    let lineString = "";
    let lineWrapsToNext: boolean;

    do {
      const nextLine = terminal.buffer.active.getLine(lineIndex + 1);
      lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
      const line = terminal.buffer.active.getLine(lineIndex);
      if (!line) {
        break;
      }
      lineString += line
        .translateToString(!lineWrapsToNext && trimRight)
        .substring(0, terminal.cols);
      lineIndex++;
    } while (lineWrapsToNext);

    return lineString;
  }

  /**
   * Selects and scrolls to a result.
   * @param result The result to select.
   * @return Whethera result was selected.
   */
  private _selectResult(result: ISearchResult | undefined): boolean {
    const terminal = this._terminal!;
    if (!result) {
      terminal.clearSelection();
      return false;
    }
    terminal.select(
      result.col,
      result.row,
      result.length || result.term.length
    );
    // If it is not in the viewport then we scroll else it just gets selected
    if (
      result.row >= terminal.buffer.active.viewportY + terminal.rows ||
      result.row < terminal.buffer.active.viewportY
    ) {
      let scroll = result.row - terminal.buffer.active.viewportY;
      scroll = scroll - Math.floor(terminal.rows / 2);
      terminal.scrollLines(scroll);
    }
    return true;
  }
  private _calc(start: number, len: number, line: IBufferLine) {
    let resultIndex = len;
    for (let i = start; i < resultIndex; i++) {
      const cell = line.getCell(i);
      if (!cell) {
        break;
      }
      // Adjust the searchIndex to normalize emoji into single chars
      const char = cell.getChars();
      if (char.length > 1) {
        resultIndex -= char.length - 1;
      } else if (CHINESE_CHAR.test(char)) {
        resultIndex++;
        i++;
      }
      // Adjust the searchIndex for empty characters following wide unicode
      // chars (eg. CJK)
      const charWidth = cell.getWidth();
      if (charWidth === 0) {
        resultIndex++;
      }
    }
    return resultIndex;
  }
}