version
React-17.0.1xtermjs-4.9.0zmodemjs-0.1.10: This plug-in is mainly used to work with XtermJS to implement rZ command upload and SZ command download functions. If webshell does not require upload and download functions, it is not required. Can provide reference to the author of the examplegithub.com/FGasper/xte…
The installation
npm install xterm xterm-addon-fit xterm-addon-web-links
Copy the code
Introduction of depend on
import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; Import {WebLinksAddon} from 'xterm-addon-web-links'; import {WebLinksAddon} from 'xterm-addon-web-links'; import 'xterm/css/xterm.css'; // Import the style fileCopy the code
Simple to use
Creating a DOM element
<div style={{ width: '100%', height: '100%', background: '#000' }}>
<div ref={this.terminalContainerRef}></div>
</div>
Copy the code
Initialize the Terminal
Create an instance of Xterm and mount it to the DOM
componentDidMount() { this.initTernimal(); Cols window.addeventListener ('resize', this.onterminalresize); } initTernimal = () => { this.connect(); // connect websocket this.xterm = new Terminal({cursorStyle: 'underline', // cursorBlink: true, // {foreground: '# DDDDDD ', // font color cursor: 'gray', windowsMode: true}); this.xterm.loadAddon(new FitAddon()); this.xterm.open(this.terminalContainerRef.current); }Copy the code
Modify window size
onTerminalResize = () => { const terminalContainer = this.terminalContainerRef.current; const width = terminalContainer.parentElement.clientWidth; const height = terminalContainer.parentElement.clientHeight; const { xterm } = this; // Calculate 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) ); };Copy the code
Listen for command input
this.xterm.onData(data => { let dataWrapper = data; if (dataWrapper === '\r') { dataWrapper = '\n'; } else if (dataWrapper === '\u0003') {CTRL + C dataWrapper += '\n'; } // The input command is notified to the background, and the background returns data. this.socket.send(JSON.stringify({ Op: 'stdin', data: dataWrapper })); });Copy the code
Establish a Webscoket connection
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);
}
Copy the code
Connection established successfully mount
OnConnectionOpen () {this.xterm.loadaddon (new WebLinksAddon()); this.onTerminalResize(); this.xterm.focus(); }Copy the code
Connection is closed
onConnectionClose(evt) {
this.xterm.writeln('Connection closed');
}
Copy the code
Receive data
onConnectionMessage(evt) { try { if (typeof evt.data === 'string') { const msg = JSON.parse(evt.data); // write the returned data to xterm, and the output is this.xterm.write(MSG); // Select * from 'rows'; // 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); }}Copy the code
Clean up the
UNSAFE_componentWillMount() {
if (this.socket) {
this.socket.close();
}
window.removeEventListener('resize', this.onTerminalResize);
}
Copy the code
Webshell search function
You can use the plug-in xterm-addon-search, which is very simple to use, as shown below:
This plugin has a bug in version 0.8.0: there is a problem in searching Chinese characters. So here we have modified the original searchaddon.ts file in xterm-addon-search a bit. The modified file is shown at the end of this article. We don’t need to rely on the plug-in, just import the searchaddon.js file and use it the same way.
— — — — — — — — this simple webshell is implemented, the following webshell upload download function — — — — — — — —
Rz upload, SZ download function
The installation
npm install nora-zmodemjs
Copy the code
Nora zmodemjs forked Zmodemjs (0.1.10), because zmodemjs didn’t make progress in uploading files, so Nora zmodemjs changed the logic of uploading files.
Create a zmodem.js file and attach some of the zmodem methods to the Terminal prototype
See the zmodem.js file at the end of this article
Import the zmodem.js file where it is used
import './zmodem';
Copy the code
When initializing terminal, create Zession.Sentry on websocket
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 });Copy the code
Download file processing
handleReceiveSession = zsession => { zsession.on('offer', xfer => { this.currentReceiveXfer = xfer; Const onFormSubmit = () => {// Start downloading const FILE_BUFFER = []; Xfer. on('input', payload => {// download this.updateProgress(xfer); FILE_BUFFER.push(new Uint8Array(payload)); }); Xfer.accept ().then(() => {// After downloading, save the file 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); // Get the progress, can be sent to the background, Displayed on the page} saveToDisk = (xfer, buffer) = > enclosing xterm. ZmodemBrowser. Save_to_disk (buffer, xfer get_details () name);Copy the code
Skip to download the file
You can listen for CTRL + C input in this.term.onData and skip the file currently being downloaded.
this.currentReceiveXfer.skip();
Copy the code
Uploading files
HandleSendSession (zsession) {// Display upload file 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; }Copy the code
Cancel the upload
uploadCancel = () => { this.setState({ uploadVisible: false, fileList: [] }); JSON. Stringify ({Op: 'stdin', data: '\x18\x18\ x08\x08\x08\x08\x08 \x08\x08\x08\x08\x08'})); };Copy the code
Determine the upload
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 => {// upload, send progress to background}); xfer.on('send_progress', percent => {// upload, send progress to background}); } }, on_file_complete: (obj, xfer) => {// send 100% to background}}).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(' Upload at least one file '); }}; StopSendProgress = () => {// Stop sending progress}Copy the code
Upload file Modal
<Modal Visible ={this.state.uploAdvisible} title=" Upload file "Closable ={false} destroyOnClose footer={[<Button key="cancel" OnClick = {this. UploadCancel} > cancel < / Button >, <Button key="submit" type="primary" onClick={this.uploadSubmit}> submit </Button>]} > <Upload beforeUpload={() => false} multiple fileList={this.state.fileList} onChange={({ file, fileList }) => { if (file.status ! == 'uploading') { this.setState({ fileList }); }}} > < frame > < uploadbank /> </ frame > </Modal>Copy the code
The effect
The problem
Chrome can’t upload large files (currently tested at 80MB or larger), but Firefox is fine. File uploading cannot be stopped. Procedure
The above is my implementation plan, I will continue to study a better solution when I have time, and I hope you can give me some advice.
This article if infringement please contact delete.
zmodem.js
Source file reference address: 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 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, 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;Copy the code
SearchAddon.ts
Source file reference address: 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;
}
}
Copy the code