Merge remote-tracking branch 'ups/master' into pr/schrej/3205

This commit is contained in:
Daniel Imms
2021-04-01 08:36:14 -07:00
64 changed files with 4666 additions and 2205 deletions
+3
View File
@@ -170,6 +170,9 @@ Xterm.js is used in several world-class applications to provide great terminal e
- [**Intervue**](https://www.intervue.io): Pair programming for interviews. Multiple programming languages supported, with results displayed by xterm.js.
- [**TRASA**](https://trasa.io): Zero trust access to Web, SSH, RDP and Database services.
- [**Commas**](https://github.com/CyanSalt/commas): Commas is a hackable terminal and command runner.
- [**Devtron**](https://github.com/devtron-labs/devtron): Software Delivery Workflow For Kubernetes.
- [**NxShell**](https://github.com/nxshell/nxshell): An easy to use new terminal for SSH.
- [**gifcast**](https://dstein64.github.io/gifcast/): Converts an asciinema cast to an animated GIF.
[And much more...](https://github.com/xtermjs/xterm.js/network/dependents)
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it in our list. Note: Please add any new contributions to the end of the list only.
+3 -1
View File
@@ -41,7 +41,9 @@ export class AttachAddon implements ITerminalAddon {
}
public dispose(): void {
this._disposables.forEach(d => d.dispose());
for (const d of this._disposables) {
d.dispose();
}
}
private _sendData(data: string): void {
@@ -18,7 +18,7 @@ describe('AttachAddon', () => {
before(async function(): Promise<any> {
const browserType = getBrowserType();
browser = await browserType.launch({
headless: process.argv.indexOf('--headless') !== -1
headless: process.argv.includes('--headless')
});
page = await (await browser.newContext()).newPage();
await page.setViewportSize({ width, height });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-addon-fit",
"version": "0.4.0",
"version": "0.5.0",
"author": {
"name": "The xterm.js authors",
"url": "https://xtermjs.org/"
+3 -3
View File
@@ -18,7 +18,7 @@ describe('FitAddon', () => {
before(async function(): Promise<any> {
const browserType = getBrowserType();
browser = await browserType.launch({
headless: process.argv.indexOf('--headless') !== -1
headless: process.argv.includes('--headless')
});
page = await (await browser.newContext()).newPage();
await page.setViewportSize({ width, height });
@@ -45,7 +45,7 @@ describe('FitAddon', () => {
describe('proposeDimensions', () => {
afterEach(async () => {
return unloadFit();
return await unloadFit();
});
it('default', async function(): Promise<any> {
@@ -84,7 +84,7 @@ describe('FitAddon', () => {
describe('fit', () => {
afterEach(async () => {
return unloadFit();
return await unloadFit();
});
it('default', async function(): Promise<any> {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-addon-ligatures",
"version": "0.3.0",
"version": "0.4.0",
"description": "Add support for programming ligatures to xterm.js",
"author": {
"name": "The xterm.js authors",
+3 -3
View File
@@ -68,7 +68,7 @@ function parseString(context: IParseContext, quoteChar: '\'' | '"'): string {
while (context.offset < context.input.length) {
const char = context.input[context.offset++];
if (escaped) {
if (/[0-9a-fA-F]/.test(char)) {
if (/[\dA-Fa-f]/.test(char)) {
// Unicode escape
context.offset--;
str += parseUnicode(context);
@@ -107,7 +107,7 @@ function parseIdentifier(context: IParseContext): string {
while (context.offset < context.input.length) {
const char = context.input[context.offset++];
if (escaped) {
if (/[0-9a-fA-F]/.test(char)) {
if (/[\dA-Fa-f]/.test(char)) {
// Unicode escape
context.offset--;
str += parseUnicode(context);
@@ -156,7 +156,7 @@ function parseUnicode(context: IParseContext): string {
// of the escape and is swallowed.
return unicodeToString(str);
}
if (str.length >= 6 || !/[0-9a-fA-F]/.test(char)) {
if (str.length >= 6 || !/[\dA-Fa-f]/.test(char)) {
// If the next character is not a valid hex digit or we have reached the
// maximum of 6 digits in the escape, terminate the escape.
context.offset--;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-addon-search",
"version": "0.7.0",
"version": "0.8.0",
"author": {
"name": "The xterm.js authors",
"url": "https://xtermjs.org/"
+3 -3
View File
@@ -240,8 +240,8 @@ export class SearchAddon implements ITerminalAddon {
* @param term the substring that starts at searchIndex
*/
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
return (((searchIndex === 0) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex - 1]) !== -1)) &&
(((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex + term.length]) !== -1)));
return ((searchIndex === 0) || (NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
(((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
}
/**
@@ -262,7 +262,7 @@ export class SearchAddon implements ITerminalAddon {
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
const firstLine = terminal.buffer.active.getLine(row);
if (firstLine && firstLine.isWrapped) {
if (firstLine?.isWrapped) {
if (isReverseSearch) {
searchPosition.startCol += terminal.cols;
return;
@@ -20,7 +20,7 @@ describe('Search Tests', function(): void {
before(async function(): Promise<any> {
const browserType = getBrowserType();
browser = await browserType.launch({
headless: process.argv.indexOf('--headless') !== -1
headless: process.argv.includes('--headless')
});
page = await (await browser.newContext()).newPage();
await page.setViewportSize({ width, height });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-addon-serialize",
"version": "0.4.0",
"version": "0.5.0",
"author": {
"name": "The xterm.js authors",
"url": "https://xtermjs.org/"
@@ -21,7 +21,7 @@ abstract class BaseSerializeHandler {
const cell2 = this._buffer.getNullCell();
let oldCell = cell1;
this._beforeSerialize(endRow - startRow);
this._beforeSerialize(endRow - startRow, startRow, endRow);
for (let row = startRow; row < endRow; row++) {
const line = this._buffer.getLine(row);
@@ -36,7 +36,7 @@ abstract class BaseSerializeHandler {
oldCell = c;
}
}
this._rowEnd(row);
this._rowEnd(row, row === endRow - 1);
}
this._afterSerialize();
@@ -45,8 +45,8 @@ abstract class BaseSerializeHandler {
}
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }
protected _rowEnd(row: number): void { }
protected _beforeSerialize(rows: number): void { }
protected _rowEnd(row: number, isLastRow: boolean): void { }
protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }
protected _afterSerialize(): void { }
protected _serializeString(): string { return ''; }
}
@@ -71,27 +71,152 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
&& cell1.isDim() === cell2.isDim();
}
class StringSerializeHandler extends BaseSerializeHandler {
private _rowIndex: number = 0;
private _allRows: string[] = new Array<string>();
private _allRowSeparators: string[] = new Array<string>();
private _currentRow: string = '';
private _nullCellCount: number = 0;
constructor(buffer: IBuffer) {
super(buffer);
// we can see a full colored cell and a null cell that only have background the same style
// but the information isn't preserved by null cell itself
// so wee need to record it when required.
private _cursorStyle: IBufferCell = this._buffer1.getNullCell();
// where exact the cursor styles comes from
// because we can't copy the cell directly
// so we remember where the content comes from instead
private _cursorStyleRow: number = 0;
private _cursorStyleCol: number = 0;
// this is a null cell for reference for checking whether background is empty or not
private _backgroundCell: IBufferCell = this._buffer1.getNullCell();
private _firstRow: number = 0;
private _lastCursorRow: number = 0;
private _lastCursorCol: number = 0;
private _lastContentCursorRow: number = 0;
private _lastContentCursorCol: number = 0;
constructor(private _buffer1: IBuffer, private _terminal: Terminal) {
super(_buffer1);
}
protected _beforeSerialize(rows: number): void {
protected _beforeSerialize(rows: number, start: number, end: number): void {
this._allRows = new Array<string>(rows);
this._lastContentCursorRow = start;
this._lastCursorRow = start;
this._firstRow = start;
}
protected _rowEnd(row: number): void {
this._allRows[this._rowIndex++] = this._currentRow;
private _thisRowLastChar: IBufferCell = this._buffer1.getNullCell();
private _thisRowLastSecondChar: IBufferCell = this._buffer1.getNullCell();
private _nextRowFirstChar: IBufferCell = this._buffer1.getNullCell();
protected _rowEnd(row: number, isLastRow: boolean): void {
// if there is colorful empty cell at line end, whe must pad it back, or the the color block will missing
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
// use clear right to set background.
this._currentRow += `\x1b[${this._nullCellCount}X`;
}
let rowSeparator = '';
// handle row separator
if (!isLastRow) {
// Enable BCE
if (row - this._firstRow >= this._terminal.rows) {
this._buffer1.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);
}
// Fetch current line
const currentLine = this._buffer1.getLine(row)!;
// Fetch next line
const nextLine = this._buffer1.getLine(row + 1)!;
if (!nextLine.isWrapped) {
// just insert the line break
rowSeparator = '\r\n';
// we sended the enter
this._lastCursorRow = row + 1;
this._lastCursorCol = 0;
} else {
rowSeparator = '';
const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!;
const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!;
const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;
const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;
// validate whether this line wrap is ever possible
// which mean whether cursor can placed at a overflow position (x === row) naturally
let isValid = false;
if (
// you must output character to cause overflow, control sequence can't do this
nextRowFirstChar.getChars() &&
isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0
) {
if (
// the last character can't be null,
// you can't use control sequence to move cursor to (x === row)
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simply the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar)
) {
isValid = true;
}
if (
// the second to last character can't be null if the next line starts with CJK,
// you can't use control sequence to move cursor to (x === row)
isNextRowFirstCharDoubleWidth &&
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simply the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar) &&
equalBg(thisRowLastSecondChar, nextRowFirstChar)
) {
isValid = true;
}
}
if (!isValid) {
// force the wrap with magic
// insert enough character to force the wrap
rowSeparator = '-'.repeat(this._nullCellCount + 1);
// move back and erase next line head
rowSeparator += '\x1b[1D\x1b[1X';
if (this._nullCellCount > 0) {
// do these because we filled the last several null slot, which we shouldn't
rowSeparator += '\x1b[A';
rowSeparator += `\x1b[${currentLine.length - this._nullCellCount}C`;
rowSeparator += `\x1b[${this._nullCellCount}X`;
rowSeparator += `\x1b[${currentLine.length - this._nullCellCount}D`;
rowSeparator += '\x1b[B';
}
// This is content and need the be serialized even it is invisible.
// without this, wrap will be missing from outputs.
this._lastContentCursorRow = row + 1;
this._lastContentCursorCol = 0;
// force commit the cursor position
this._lastCursorRow = row + 1;
this._lastCursorCol = 0;
}
}
}
this._allRows[this._rowIndex] = this._currentRow;
this._allRowSeparators[this._rowIndex++] = rowSeparator;
this._currentRow = '';
this._nullCellCount = 0;
}
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
const sgrSeq: number[] = [];
const fgChanged = !equalFg(cell, oldCell);
const bgChanged = !equalBg(cell, oldCell);
@@ -99,7 +224,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (fgChanged || bgChanged || flagsChanged) {
if (cell.isAttributeDefault()) {
this._currentRow += '\x1b[0m';
if (!oldCell.isAttributeDefault()) {
sgrSeq.push(0);
}
} else {
if (fgChanged) {
const color = cell.getFgColor();
@@ -131,30 +258,129 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}
if (sgrSeq.length) {
return sgrSeq;
}
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {
// a width 0 cell don't need to be count because it is just a placeholder after a CJK character;
const isPlaceHolderCell = cell.getWidth() === 0;
if (isPlaceHolderCell) {
return;
}
// this cell don't have content
const isEmptyCell = cell.getChars() === '';
const sgrSeq = this._diffStyle(cell, this._cursorStyle);
// the empty cell style is only assumed to be changed when background changed, because foreground is always 0.
const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0;
/**
* handles style change
*/
if (styleChanged) {
// before update the style, we need to fill empty cell back
if (this._nullCellCount > 0) {
// use clear right to set background.
if (!equalBg(this._cursorStyle, this._backgroundCell)) {
this._currentRow += `\x1b[${this._nullCellCount}X`;
}
// use move right to move cursor.
this._currentRow += `\x1b[${this._nullCellCount}C`;
this._nullCellCount = 0;
}
this._lastContentCursorRow = this._lastCursorRow = row;
this._lastContentCursorCol = this._lastCursorCol = col;
this._currentRow += `\x1b[${sgrSeq.join(';')}m`;
// update the last cursor style
const line = this._buffer1.getLine(row);
if (line !== undefined) {
line.getCell(col, this._cursorStyle);
this._cursorStyleRow = row;
this._cursorStyleCol = col;
}
}
// Count number of null cells encountered after the last non-null cell and move the cursor
// if a non-null cell is found (eg. \t or cursor move)
if (cell.getChars() === '') {
/**
* handles actual content
*/
if (isEmptyCell) {
this._nullCellCount += cell.getWidth();
} else if (this._nullCellCount > 0) {
this._currentRow += `\x1b[${this._nullCellCount}C`;
this._nullCellCount = 0;
}
} else {
if (this._nullCellCount > 0) {
// we can just assume we have same style with previous one here
// because style change is handled by previous stage
// use move right when background is empty, use clear right when there is background.
if (equalBg(this._cursorStyle, this._backgroundCell)) {
this._currentRow += `\x1b[${this._nullCellCount}C`;
} else {
this._currentRow += `\x1b[${this._nullCellCount}X`;
this._currentRow += `\x1b[${this._nullCellCount}C`;
}
this._nullCellCount = 0;
}
this._currentRow += cell.getChars();
this._currentRow += cell.getChars();
// update cursor
this._lastContentCursorRow = this._lastCursorRow = row;
this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();
}
}
protected _serializeString(): string {
let rowEnd = this._allRows.length;
for (; rowEnd > 0; rowEnd--) {
if (this._allRows[rowEnd - 1]) {
break;
// the fixup is only required for data without scrollback
// because it will always be placed at last line otherwise
if (this._buffer1.length - this._firstRow <= this._terminal.rows) {
rowEnd = this._lastContentCursorRow + 1 - this._firstRow;
this._lastCursorCol = this._lastContentCursorCol;
this._lastCursorRow = this._lastContentCursorRow;
}
let content = '';
for (let i = 0; i < rowEnd; i++) {
content += this._allRows[i];
if (i + 1 < rowEnd) {
content += this._allRowSeparators[i];
}
}
return this._allRows.slice(0, rowEnd).join('\r\n');
// restore the cursor
const realCursorRow = this._buffer1.baseY + this._buffer1.cursorY;
const realCursorCol = this._buffer1.cursorX;
const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);
const moveRight = (offset: number): void => {
if (offset > 0) {
content += `\u001b[${offset}C`;
} else if (offset < 0) {
content += `\u001b[${-offset}D`;
}
};
const moveDown = (offset: number): void => {
if (offset > 0) {
content += `\u001b[${offset}B`;
} else if (offset < 0) {
content += `\u001b[${-offset}A`;
}
};
if (cursorMoved) {
moveDown(realCursorRow - this._lastCursorRow);
moveRight(realCursorCol - this._lastCursorCol);
}
return content;
}
}
@@ -167,20 +393,33 @@ export class SerializeAddon implements ITerminalAddon {
this._terminal = terminal;
}
public serialize(rows?: number): string {
// TODO: Add re-position cursor support
// TODO: Add word wrap mode support
private _getString(buffer: IBuffer, scrollback?: number): string {
const maxRows = buffer.length;
const handler = new StringSerializeHandler(buffer, this._terminal!);
const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + this!._terminal!.rows, 0, maxRows);
const result = handler.serialize(maxRows - correctRows, maxRows);
return result;
}
public serialize(scrollback?: number): string {
// TODO: Add combinedData support
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
const maxRows = this._terminal.buffer.active.length;
const handler = new StringSerializeHandler(this._terminal.buffer.active);
if (this._terminal.buffer.active.type === 'normal') {
return this._getString(this._terminal.buffer.active, scrollback);
}
rows = (rows === undefined) ? maxRows : constrain(rows, 0, maxRows);
const normalScreenContent = this._getString(this._terminal.buffer.normal, scrollback);
// alt screen don't have scrollback
const alternativeScreenContent = this._getString(this._terminal.buffer.alternate, undefined);
return handler.serialize(maxRows - rows, maxRows);
return normalScreenContent
+ '\u001b[?1049h\u001b[H'
+ alternativeScreenContent;
}
public dispose(): void { }
@@ -14,11 +14,27 @@ let page: Page;
const width = 800;
const height = 600;
const writeRawSync = (page: any, str: string): Promise<void> => writeSync(page, '\' +' + JSON.stringify(str) + '+ \'');
const testNormalScreenEqual = async (page: any, str: string): Promise<void> => {
await writeRawSync(page, str);
const originalBuffer = await page.evaluate(`inspectBuffer(term.buffer.normal);`);
const result = await page.evaluate(`serializeAddon.serialize();`) as string;
await page.evaluate(`term.reset();`);
await writeRawSync(page, result);
const newBuffer = await page.evaluate(`inspectBuffer(term.buffer.normal);`);
// chai decides -0 and 0 are different number...
// and firefox have a bug that output -0 for unknown reason
assert.equal(JSON.stringify(originalBuffer), JSON.stringify(newBuffer));
};
describe('SerializeAddon', () => {
before(async function(): Promise<any> {
const browserType = getBrowserType();
browser = await browserType.launch({
headless: process.argv.indexOf('--headless') !== -1
headless: process.argv.includes('--headless')
});
page = await (await browser.newContext()).newPage();
await page.setViewportSize({ width, height });
@@ -27,19 +43,74 @@ describe('SerializeAddon', () => {
await page.evaluate(`
window.serializeAddon = new SerializeAddon();
window.term.loadAddon(window.serializeAddon);
window.inspectBuffer = (buffer) => {
const lines = [];
for (let i = 0; i < buffer.length; i++) {
// Do this intentionally to get content of underlining source
const bufferLine = buffer.getLine(i)._line;
lines.push(JSON.stringify(bufferLine));
}
return {
x: buffer.cursorX,
y: buffer.cursorY,
data: lines
};
}
`);
});
after(async () => await browser.close());
beforeEach(async () => await page.evaluate(`window.term.reset()`));
it('produce different output when we call test util with different text', async function(): Promise<any> {
await writeRawSync(page, '12345');
const buffer1 = await page.evaluate(`inspectBuffer(term.buffer.normal);`);
await page.evaluate(`term.reset();`);
await writeRawSync(page, '67890');
const buffer2 = await page.evaluate(`inspectBuffer(term.buffer.normal);`);
assert.throw(() => {
assert.equal(JSON.stringify(buffer1), JSON.stringify(buffer2));
});
});
it('produce different output when we call test util with different line wrap', async function(): Promise<any> {
await writeRawSync(page, '1234567890\r\n12345');
const buffer3 = await page.evaluate(`inspectBuffer(term.buffer.normal);`);
await page.evaluate(`term.reset();`);
await writeRawSync(page, '1234567890n12345');
const buffer4 = await page.evaluate(`inspectBuffer(term.buffer.normal);`);
assert.throw(() => {
assert.equal(JSON.stringify(buffer3), JSON.stringify(buffer4));
});
});
it('empty content', async function(): Promise<any> {
const rows = 10;
const cols = 10;
assert.equal(await page.evaluate(`serializeAddon.serialize();`), '');
});
it('trim last empty lines', async function(): Promise<any> {
it('unwrap wrapped line', async function(): Promise<any> {
const lines = ['123456789123456789'];
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n'));
});
it('does not unwrap non-wrapped line', async function(): Promise<any> {
const lines = [
'123456789',
'123456789'
];
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n'));
});
it('preserve last empty lines', async function(): Promise<any> {
const cols = 10;
const lines = [
'',
@@ -55,7 +126,7 @@ describe('SerializeAddon', () => {
''
];
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.slice(0, 8).join('\r\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n'));
});
it('digits content', async function(): Promise<any> {
@@ -67,21 +138,22 @@ describe('SerializeAddon', () => {
assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n'));
});
it('serialize half rows of content', async function(): Promise<any> {
const rows = 10;
const halfRows = rows >> 1;
it('serialize with half of scrollback', async function(): Promise<any> {
const rows = 20;
const scrollback = rows - 10;
const halfScrollback = scrollback / 2;
const cols = 10;
const lines = newArray<string>((index: number) => digitsString(cols, index), rows);
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize(${halfRows});`), lines.slice(halfRows, 2 * halfRows).join('\r\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize(${halfScrollback});`), lines.slice(halfScrollback, rows).join('\r\n'));
});
it('serialize 0 rows of content', async function(): Promise<any> {
const rows = 10;
it('serialize 0 rows of scrollback', async function(): Promise<any> {
const rows = 20;
const cols = 10;
const lines = newArray<string>((index: number) => digitsString(cols, index), rows);
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize(0);`), '');
assert.equal(await page.evaluate(`serializeAddon.serialize(0);`), lines.slice(rows - 10, rows).join('\r\n'));
});
it('serialize all rows of content with color16', async function(): Promise<any> {
@@ -276,17 +348,15 @@ describe('SerializeAddon', () => {
'中文中文',
'12中文',
'中文12',
'1中文中文中' // this line is going to be wrapped at last character because it has line length of 11 (1+2*5)
];
const expected = [
'中文中文',
'12中文',
'中文12',
'1中文中文',
'中'
// This line is going to be wrapped at last character
// because it has line length of 11 (1+2*5).
// We concat it back without the null cell currently.
// But this may be incorrect.
// see also #3097
'1中文中文中'
];
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize();`), expected.join('\r\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n'));
});
it('serialize CJK Mixed with tab correctly', async () => {
@@ -299,6 +369,118 @@ describe('SerializeAddon', () => {
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`serializeAddon.serialize();`), expected.join('\r\n'));
});
it('serialize with alt screen correctly', async () => {
const SMCUP = '\u001b[?1049h';
const CUP = '\u001b[H';
const lines = [
`1${SMCUP}${CUP}2`
];
const expected = [
`1${SMCUP}${CUP}2`
];
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`window.term.buffer.active.type`), 'alternate');
assert.equal(JSON.stringify(await page.evaluate(`serializeAddon.serialize();`)), JSON.stringify(expected.join('\r\n')));
});
it('serialize without alt screen correctly', async () => {
const SMCUP = '\u001b[?1049h';
const RMCUP = '\u001b[?1049l';
const lines = [
`1${SMCUP}2${RMCUP}`
];
const expected = [
`1`
];
await writeSync(page, lines.join('\\r\\n'));
assert.equal(await page.evaluate(`window.term.buffer.active.type`), 'normal');
assert.equal(JSON.stringify(await page.evaluate(`serializeAddon.serialize();`)), JSON.stringify(expected.join('\r\n')));
});
it('serialize with background', async () => {
const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`;
const lines = [
`1\u001b[44m${CLEAR_RIGHT(5)}`,
`2${CLEAR_RIGHT(9)}`
];
await testNormalScreenEqual(page, lines.join('\r\n'));
});
it('cause the BCE on scroll', async () => {
const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`;
const padLines = newArray<string>(
(index: number) => digitsString(10, index),
10
);
const lines = [
...padLines,
`\u001b[44m${CLEAR_RIGHT(5)}1111111111111111`
];
await testNormalScreenEqual(page, lines.join('\r\n'));
});
it('handle invalid wrap before scroll', async () => {
const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`;
const MOVE_UP = (l: number): string => `\u001b[${l}A`;
const MOVE_DOWN = (l: number): string => `\u001b[${l}B`;
const MOVE_LEFT = (l: number): string => `\u001b[${l}D`;
// A line wrap happened after current line.
// But there is no content.
// so wrap shouldn't even be able to happen.
const segments = [
`123456789012345`,
MOVE_UP(1),
CLEAR_RIGHT(5),
MOVE_DOWN(1),
MOVE_LEFT(5),
CLEAR_RIGHT(5),
MOVE_UP(1),
'1'
];
await testNormalScreenEqual(page, segments.join(''));
});
it('handle invalid wrap after scroll', async () => {
const CLEAR_RIGHT = (l: number): string => `\u001b[${l}X`;
const MOVE_UP = (l: number): string => `\u001b[${l}A`;
const MOVE_DOWN = (l: number): string => `\u001b[${l}B`;
const MOVE_LEFT = (l: number): string => `\u001b[${l}D`;
const padLines = newArray<string>(
(index: number) => digitsString(10, index),
10
);
// A line wrap happened after current line.
// But there is no content.
// so wrap shouldn't even be able to happen.
const lines = [
padLines.join('\r\n'),
'\r\n',
`123456789012345`,
MOVE_UP(1),
CLEAR_RIGHT(5),
MOVE_DOWN(1),
MOVE_LEFT(5),
CLEAR_RIGHT(5),
MOVE_UP(1),
'1'
];
await testNormalScreenEqual(page, lines.join(''));
});
});
function newArray<T>(initial: T | ((index: number) => T), count: number): T[] {
@@ -25,10 +25,10 @@ declare module 'xterm-addon-serialize' {
* to restore the state. The cursor will also be positioned to the correct cell.
* When restoring a terminal it is best to do before `Terminal.open` is called
* to avoid wasting CPU cycles rendering incomplete frames.
* @param rows The number of rows to serialize, starting from the bottom of the
* terminal. This defaults to the number of rows in the viewport.
* @param scrollback The number of rows in scrollback buffer to serialize, starting from the bottom of the
* scrollback buffer. This defaults to the all available rows in the scrollback buffer.
*/
public serialize(rows?: number): string;
public serialize(scrollback?: number): string;
/**
* Disposes the addon.
@@ -18,7 +18,7 @@ describe('Unicode11Addon', () => {
before(async function(): Promise<any> {
const browserType = getBrowserType();
browser = await browserType.launch({
headless: process.argv.indexOf('--headless') !== -1
headless: process.argv.includes('--headless')
});
page = await (await browser.newContext()).newPage();
await page.setViewportSize({ width, height });
@@ -3,25 +3,45 @@
* @license MIT
*/
import { ILinkProvider, IBufferCellPosition, ILink, Terminal } from 'xterm';
import { ILinkProvider, ILink, Terminal, IViewportRange } from 'xterm';
interface ILinkProviderOptions {
hover?(event: MouseEvent, text: string, location: IViewportRange): void;
leave?(event: MouseEvent, text: string): void;
}
export class WebLinkProvider implements ILinkProvider {
constructor(
private readonly _terminal: Terminal,
private readonly _regex: RegExp,
private readonly _handler: (event: MouseEvent, uri: string) => void
private readonly _handler: (event: MouseEvent, uri: string) => void,
private readonly _options: ILinkProviderOptions = {}
) {
}
public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
callback(LinkComputer.computeLink(y, this._regex, this._terminal, this._handler));
const links = LinkComputer.computeLink(y, this._regex, this._terminal, this._handler);
callback(this._addCallbacks(links));
}
private _addCallbacks(links: ILink[]): ILink[] {
return links.map(link => {
link.leave = this._options.leave;
link.hover = (event: MouseEvent, uri: string): void => {
if (this._options.hover) {
const { range } = link;
this._options.hover(event, uri, range);
}
};
return link;
});
}
}
export class LinkComputer {
public static computeLink(y: number, regex: RegExp, terminal: Terminal, handler: (event: MouseEvent, uri: string) => void): ILink[] {
public static computeLink(y: number, regex: RegExp, terminal: Terminal, activate: (event: MouseEvent, uri: string) => void): ILink[] {
const rex = new RegExp(regex.source, (regex.flags || '') + 'g');
const [line, startLineIndex] = LinkComputer._translateBufferLineToStringWithWrap(y - 1, false, terminal);
@@ -69,7 +89,7 @@ export class LinkComputer {
}
};
result.push({ range, text, activate: handler });
result.push({ range, text, activate });
}
return result;
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Terminal, ILinkMatcherOptions, ITerminalAddon, IDisposable } from 'xterm';
import { Terminal, ILinkMatcherOptions, ITerminalAddon, IDisposable, IViewportRange } from 'xterm';
import { WebLinkProvider } from './WebLinkProvider';
const protocolClause = '(https?:\\/\\/)';
@@ -36,6 +36,11 @@ function handleLink(event: MouseEvent, uri: string): void {
}
}
interface ILinkProviderOptions {
hover?(event: MouseEvent, text: string, location: IViewportRange): void;
leave?(event: MouseEvent, text: string): void;
}
export class WebLinksAddon implements ITerminalAddon {
private _linkMatcherId: number | undefined;
private _terminal: Terminal | undefined;
@@ -43,20 +48,22 @@ export class WebLinksAddon implements ITerminalAddon {
constructor(
private _handler: (event: MouseEvent, uri: string) => void = handleLink,
private _options: ILinkMatcherOptions = {},
private _options: ILinkMatcherOptions | ILinkProviderOptions = {},
private _useLinkProvider: boolean = false
) {
this._options.matchIndex = 1;
}
public activate(terminal: Terminal): void {
this._terminal = terminal;
if (this._useLinkProvider && 'registerLinkProvider' in this._terminal) {
this._linkProvider = this._terminal.registerLinkProvider(new WebLinkProvider(this._terminal, strictUrlRegex, this._handler));
const options = this._options as ILinkProviderOptions;
this._linkProvider = this._terminal.registerLinkProvider(new WebLinkProvider(this._terminal, strictUrlRegex, this._handler, options));
} else {
// TODO: This should be removed eventually
this._linkMatcherId = (this._terminal as Terminal).registerLinkMatcher(strictUrlRegex, this._handler, this._options);
const options = this._options as ILinkMatcherOptions;
options.matchIndex = 1;
this._linkMatcherId = (this._terminal as Terminal).registerLinkMatcher(strictUrlRegex, this._handler, options);
}
}
@@ -18,7 +18,7 @@ describe('WebLinksAddon', () => {
before(async function(): Promise<any> {
const browserType = getBrowserType();
browser = await browserType.launch({
headless: process.argv.indexOf('--headless') !== -1
headless: process.argv.includes('--headless')
});
page = await (await browser.newContext()).newPage();
await page.setViewportSize({ width, height });
@@ -4,7 +4,7 @@
*/
import { Terminal, ILinkMatcherOptions, ITerminalAddon } from 'xterm';
import { Terminal, ILinkMatcherOptions, ITerminalAddon, IViewportRange } from 'xterm';
declare module 'xterm-addon-web-links' {
/**
@@ -20,7 +20,7 @@ declare module 'xterm-addon-web-links' {
* link provider (new) may cause issues. Link provider will eventually be
* the default and only option.
*/
constructor(handler?: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions, useLinkProvider?: boolean);
constructor(handler?: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions | ILinkProviderOptions, useLinkProvider?: boolean);
/**
* Activates the addon
@@ -33,4 +33,21 @@ declare module 'xterm-addon-web-links' {
*/
public dispose(): void;
}
/**
* An object containing options for a link provider.
*/
export interface ILinkProviderOptions {
/**
* A callback that fires when the mouse hovers over a link for a period of
* time (defined by {@link ITerminalOptions.linkTooltipHoverDuration}).
*/
hover?(event: MouseEvent, text: string, location: IViewportRange): void;
/**
* A callback that fires when the mouse leaves a link. Note that this can
* happen even when tooltipCallback hasn't fired for the link yet.
*/
leave?(event: MouseEvent, text: string): void;
}
}
+1 -3
View File
@@ -1,8 +1,6 @@
## xterm-addon-webgl
An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables a WebGL-based renderer. This addon requires xterm.js v4+.
⚠️ This is an experimental addon that is [missing some features and may be unstable](https://github.com/xtermjs/xterm.js/issues?q=is%3Aopen+is%3Aissue+label%3Aarea%2Faddon%2Fwebgl) ⚠️
An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables a WebGL2-based renderer. This addon requires xterm.js v4+.
### Install

Some files were not shown because too many files have changed in this diff Show More