diff --git a/README.md b/README.md index d01a0f33..2853696f 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**Bastillion**](https://www.bastillion.io): Bastillion is an open-source web-based SSH console that centrally manages administrative access to systems. - [**PHP App Server**](https://github.com/cubiclesoft/php-app-server/): Create lightweight, installable almost-native applications for desktop OSes. ExecTerminal (nicely wraps the xterm.js Terminal), TerminalManager, and RunProcessSDK are self-contained, reusable ES5+ compliant Javascript components. - [**NgTerminal**](https://github.com/qwefgh90/ng-terminal): NgTerminal is a web terminal that leverages xterm.js on Angular 7+. You can easily add it into your application by adding `` into your component. +- [**tty-share**](https://tty-share.com): Extremely simple terminal sharing over the Internet. [And much more...](https://github.com/xtermjs/xterm.js/network/dependents) diff --git a/addons/xterm-addon-search/src/SearchAddon.api.ts b/addons/xterm-addon-search/src/SearchAddon.api.ts new file mode 100644 index 00000000..f970fcc8 --- /dev/null +++ b/addons/xterm-addon-search/src/SearchAddon.api.ts @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import * as puppeteer from 'puppeteer'; +import { assert } from 'chai'; +import { ITerminalOptions } from 'xterm'; + +const APP = 'http://127.0.0.1:3000/test'; + +let browser: puppeteer.Browser; +let page: puppeteer.Page; +const width = 800; +const height = 600; + +describe('Search Tests', function (): void { + this.timeout(200000); + + before(async function (): Promise { + browser = await puppeteer.launch({ + headless: process.argv.indexOf('--headless') !== -1, + slowMo: 80, + args: [`--window-size=${width},${height}`] + }); + page = (await browser.pages())[0]; + await page.setViewport({ width, height }); + await page.goto(APP); + await openTerminal(); + await page.evaluate(`window.search = new SearchAddon();`); + await page.evaluate(`window.term.loadAddon(window.search);`); + }); + + after(() => { + browser.close(); + }); + + beforeEach(async () => { + await page.evaluate(`window.term.reset()`); + }); + + it('Simple Search', async () => { + await writeSync('dafhdjfldshafhldsahfkjhldhjkftestlhfdsakjfhdjhlfdsjkafhjdlk'); + assert.deepEqual(await page.evaluate(`window.search.findNext('test')`), true); + assert.deepEqual(await page.evaluate(`window.term.getSelection()`), 'test'); + }); + + it('Scrolling Search', async () => { + let dataString = ''; + for (let i = 0; i < 100; i++) { + if (i === 52) { + dataString += '$^1_3{}test$#'; + } + dataString += makeData(50); + } + await writeSync(dataString); + assert.deepEqual(await page.evaluate(`window.search.findNext('$^1_3{}test$#')`), true); + assert.deepEqual(await page.evaluate(`window.term.getSelection()`), '$^1_3{}test$#'); + }); + it ('Incremental Find Previous', async () => { + await page.evaluate(`window.term.writeln('package.jsonc\\n')`); + await writeSync('package.json pack package.lock'); + await page.evaluate(`window.search.findPrevious('pack', {incremental: true})`); + let line: string = await page.evaluate(`window.term.buffer.getLine(window.term.getSelectionPosition().startRow).translateToString()`); + let selectionPosition: {startColumn: number, startRow: number, endColumn: number, endRow: number} = await page.evaluate(`window.term.getSelectionPosition()`); + // We look further ahead in the line to ensure that pack was selected from package.lock + assert.deepEqual(line.substring(selectionPosition.startColumn, selectionPosition.endColumn + 8), 'package.lock'); + await page.evaluate(`window.search.findPrevious('package.j', {incremental: true})`); + selectionPosition = await page.evaluate(`window.term.getSelectionPosition()`); + assert.deepEqual(line.substring(selectionPosition.startColumn, selectionPosition.endColumn + 3), 'package.json'); + await page.evaluate(`window.search.findPrevious('package.jsonc', {incremental: true})`); + // We have to reevaluate line because it should have switched starting rows at this point + line = await page.evaluate(`window.term.buffer.getLine(window.term.getSelectionPosition().startRow).translateToString()`); + selectionPosition = await page.evaluate(`window.term.getSelectionPosition()`); + assert.deepEqual(line.substring(selectionPosition.startColumn, selectionPosition.endColumn), 'package.jsonc'); + }); + it ('Incremental Find Next', async () => { + await page.evaluate(`window.term.writeln('package.lock pack package.json package.ups\\n')`); + await writeSync('package.jsonc'); + await page.evaluate(`window.search.findNext('pack', {incremental: true})`); + let line: string = await page.evaluate(`window.term.buffer.getLine(window.term.getSelectionPosition().startRow).translateToString()`); + let selectionPosition: {startColumn: number, startRow: number, endColumn: number, endRow: number} = await page.evaluate(`window.term.getSelectionPosition()`); + // We look further ahead in the line to ensure that pack was selected from package.lock + assert.deepEqual(line.substring(selectionPosition.startColumn, selectionPosition.endColumn + 8), 'package.lock'); + await page.evaluate(`window.search.findNext('package.j', {incremental: true})`); + selectionPosition = await page.evaluate(`window.term.getSelectionPosition()`); + assert.deepEqual(line.substring(selectionPosition.startColumn, selectionPosition.endColumn + 3), 'package.json'); + await page.evaluate(`window.search.findNext('package.jsonc', {incremental: true})`); + // We have to reevaluate line because it should have switched starting rows at this point + line = await page.evaluate(`window.term.buffer.getLine(window.term.getSelectionPosition().startRow).translateToString()`); + selectionPosition = await page.evaluate(`window.term.getSelectionPosition()`); + assert.deepEqual(line.substring(selectionPosition.startColumn, selectionPosition.endColumn), 'package.jsonc'); + }); + it ('Simple Regex', async () => { + await writeSync('abc123defABCD'); + await page.evaluate(`window.search.findNext('[a-z]+', {regex: true})`); + assert.deepEqual(await page.evaluate(`window.term.getSelection()`), 'abc'); + await page.evaluate(`window.search.findNext('[A-Z]+', {regex: true, caseSensitive: true})`); + assert.deepEqual(await page.evaluate(`window.term.getSelection()`), 'ABCD'); + }); +}); + +async function openTerminal(options: ITerminalOptions = {}): Promise { + await page.evaluate(`window.term = new Terminal(${JSON.stringify(options)})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + if (options.rendererType === 'dom') { + await page.waitForSelector('.xterm-rows'); + } else { + await page.waitForSelector('.xterm-text-layer'); + } +} + +async function writeSync(data: string): Promise { + await page.evaluate(`window.term.write('${data}');`); + while (true) { + if (await page.evaluate(`window.term._core.writeBuffer.length === 0`)) { + break; + } + } +} + +function makeData(length: number): string { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; +} diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index 8fcfc690..e9f8a9b4 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -57,8 +57,8 @@ export class SearchAddon implements ITerminalAddon { return false; } - let startCol: number = 0; - let startRow = this._terminal.buffer.viewportY; + let startCol = 0; + let startRow = 0; if (this._terminal.hasSelection()) { const incremental = searchOptions ? searchOptions.incremental : false; @@ -71,20 +71,8 @@ export class SearchAddon implements ITerminalAddon { this._initLinesCache(); - // A row that has isWrapped = false - let findingRow = startRow; - // index of beginning column that _findInLine need to scan. - let cumulativeCols = startCol; - // If startRow is wrapped row, scan for unwrapped row above. - // So we can start matching on wrapped line from long unwrapped line. - let currentLine = this._terminal.buffer.getLine(findingRow); - while (currentLine && currentLine.isWrapped) { - cumulativeCols += this._terminal.cols; - currentLine = this._terminal.buffer.getLine(--findingRow); - } - // Search startRow - let result = this._findInLine(term, findingRow, cumulativeCols, searchOptions); + let result = this._findInLine(term, startRow, startCol, searchOptions); // Search from startRow + 1 to end if (!result) { @@ -99,11 +87,9 @@ export class SearchAddon implements ITerminalAddon { } } } - - // Search from the top to the startRow (search the whole startRow again in - // case startCol > 0) - if (!result) { - for (let y = 0; y < findingRow; y++) { + // 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++) { result = this._findInLine(term, y, 0, searchOptions); if (result) { break; @@ -133,61 +119,45 @@ export class SearchAddon implements ITerminalAddon { } const isReverseSearch = true; - let startRow = this._terminal.buffer.viewportY + this._terminal.rows - 1; + let startRow = this._terminal.buffer.baseY + this._terminal.rows; let startCol = this._terminal.cols; - + let result: ISearchResult | undefined = undefined; + const incremental = searchOptions ? searchOptions.incremental : false; if (this._terminal.hasSelection()) { - // Start from the selection start if there is a selection const currentSelection = this._terminal.getSelectionPosition()!; + // Start from selection start if there is a selection startRow = currentSelection.startRow; startCol = currentSelection.startColumn; } this._initLinesCache(); - // Search startRow - let result = this._findInLine(term, startRow, startCol, searchOptions, isReverseSearch); + if (incremental) { + result = this._findInLine(term, startRow, startCol, searchOptions, false); + if (!(result && result.row === startRow && result.col === startCol)) { + result = this._findInLine(term, startRow, startCol, searchOptions, true); + } + } else { + result = this._findInLine(term, startRow, startCol, searchOptions, isReverseSearch); + } // Search from startRow - 1 to top if (!result) { - // If the line is wrapped line, increase number of columns that is needed to be scanned - // Se we can scan on wrapped line from unwrapped line - let cumulativeCols = this._terminal.cols; - if (this._terminal.buffer.getLine(startRow)!.isWrapped) { - cumulativeCols += startCol; - } + startCol = this._terminal.cols; for (let y = startRow - 1; y >= 0; y--) { - result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch); + result = this._findInLine(term, y, startCol, searchOptions, isReverseSearch); if (result) { break; } - // If the current line is wrapped line, increase scanning range, - // preparing for scanning on unwrapped line - const line = this._terminal.buffer.getLine(y); - if (line && line.isWrapped) { - cumulativeCols += this._terminal.cols; - } else { - cumulativeCols = this._terminal.cols; - } } } - - // Search from the bottom to startRow (search the whole startRow again in - // case startCol > 0) - if (!result) { - const searchFrom = this._terminal.buffer.baseY + this._terminal.rows - 1; - let cumulativeCols = this._terminal.cols; - for (let y = searchFrom; y >= startRow; y--) { - result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch); + // If we hit the top and didn't search from the very bottom wrap back down + if (!result && startRow !== (this._terminal.buffer.baseY + this._terminal.rows)) { + for (let y = (this._terminal.buffer.baseY + this._terminal.rows); y > startRow; y--) { + result = this._findInLine(term, y, startCol, searchOptions, isReverseSearch); if (result) { break; } - const line = this._terminal.buffer.getLine(y); - if (line && line.isWrapped) { - cumulativeCols += this._terminal.cols; - } else { - cumulativeCols = this._terminal.cols; - } } } diff --git a/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts index df0cdbab..0e2d645e 100644 --- a/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts +++ b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts @@ -4,16 +4,12 @@ */ import { IRenderLayer } from './Types'; -import { ICellData } from 'common/Types'; -import { DEFAULT_COLOR, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE } from 'common/buffer/Constants'; import { IGlyphIdentifier } from '../atlas/Types'; -import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { acquireCharAtlas } from '../atlas/CharAtlasCache'; import { Terminal } from 'xterm'; import { IColorSet } from 'browser/Types'; import { IRenderDimensions } from 'browser/renderer/Types'; import { CellData } from 'common/buffer/CellData'; -import { AttributeData } from 'common/buffer/AttributeData'; import { WebglCharAtlas } from 'atlas/WebglCharAtlas'; import { throwIfFalsy } from '../WebglUtils'; diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e8b23d34..c784a611 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -62,7 +62,7 @@ jobs: yarn lint displayName: 'Lint' -- job: IntegrationTests +- job: Linux_IntegrationTests pool: vmImage: 'ubuntu-16.04' steps: @@ -81,14 +81,32 @@ jobs: yarn start & sleep 10 yarn test-api --headless - displayName: 'Integration tests' + displayName: 'Linux Integration tests' + +- job: macOS_IntegrationTests + pool: + vmImage: 'xcode9-macos10.13' + steps: + - task: NodeTool@0 + inputs: + versionSpec: '8.x' + displayName: 'Install Node.js' + - script: | + yarn + displayName: 'Install dependencies and build' + - script: | + yarn start & + sleep 10 + yarn test-api --headless + displayName: 'MacOS Integration tests' - job: Release dependsOn: - Linux - macOS - Windows - - IntegrationTests + - Linux_IntegrationTests + - macOS_IntegrationTests condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), eq(variables['Build.SourceBranch'], 'refs/heads/release/*'))) pool: vmImage: 'ubuntu-16.04' diff --git a/bin/test.js b/bin/test.js index 2d816097..7beb4f9a 100644 --- a/bin/test.js +++ b/bin/test.js @@ -22,7 +22,7 @@ if (process.argv.length > 2) { testFiles = process.argv.slice(2); } -cp.spawnSync( +const run = cp.spawnSync( path.resolve(__dirname, '../node_modules/.bin/mocha'), testFiles, { @@ -31,3 +31,5 @@ cp.spawnSync( stdio: 'inherit' } ); + +process.exit(run.status); \ No newline at end of file diff --git a/bin/test_mousemodes.js b/bin/test_mousemodes.js new file mode 100644 index 00000000..976a38e0 --- /dev/null +++ b/bin/test_mousemodes.js @@ -0,0 +1,212 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + * + * Script to test different mouse modes in terminal emulators. + * Tests for protocols DECSET 9, 1000, 1002, 1003 with different + * report encodings (default, UTF8, SGR, URXVT). + * + * VT200 Highlight mode (DECSET 1001) is not implemented. + * + * The test basically applies the report data to the cursor, thus + * a mouse report should move the cursor to the cell under the mouse. + * Furthermore the reports are printed in the left lower corner as + * raw data and their meaning. + * + * A failing test might show: + * - wrong coords: the cursor will jump to some other place + * - wrong buttons: see meaning output and check whether it makes sense + * - faulty reports: inspect the raw data and compare with other emulators + * - missing events: compare with spec / other emulators + */ + +let activeProtocol = 0; +let activeEnc = 0; + +const stdin = process.openStdin(); +process.stdin.setRawMode(true); + +// close handler - reset terminal on exit +stdin.addListener('data', function(data) { + if (data[0] === 0x03) { + process.stdin.setRawMode(false); + process.stdout.write('\x1bc'); + process.exit(); + } + if (data[0] === 0x01) { + switchActiveProtocol(); + printMenu(); + } + if (data[0] === 0x02) { + switchActiveEnc(); + printMenu(); + } + console.log('\x1b[100;H\x1b[2A\x1b[2KReport:', data, [data.toString('binary')]); + // filter mouse reports + if (data[0] === 0x1b && data[1] === '['.charCodeAt(0)) { + applyReportData(data); + } +}); + +// button definitions +const buttons = { + '': -1, + left: 0, + middle: 1, + right: 2, + released: 3, + wheelUp: 4, + wheelDown: 5, + wheelLeft: 6, + wheelRight: 7, + aux1: 8, + aux2: 9, + aux3: 10, + aux4: 11, + aux5: 12, + aux6: 13, + aux7: 14, + aux8: 15 +}; +const reverseButtons = {}; +for (const el in buttons) { + reverseButtons[buttons[el]] = el; +} + +// extract button data from buttonCode +function evalButtonCode(code) { + // more than 15 buttons are not supported + if (code > 255) { + return {button: 'invalid', action: 'invalid', modifier: {}}; + } + const modifier = {shift: !!(code & 4), meta: !!(code & 8), control: !!(code & 16)}; + const move = code & 32; + let button = code & 3; + if (code & 128) { + button |= 8; + } + if (code & 64) { + button |= 4 + } + let actionS = 'press'; + let buttonS = reverseButtons[button]; + if (button === 3) { + buttonS = ''; + actionS = 'release'; + } + if (move) { + actionS = 'move'; + } else if (4 <= button && button <= 7) { + buttonS = 'wheel'; + actionS = button === 4 ? 'up' : button === 5 ? 'down' : button === 6 ? 'left' : 'right'; + } + return {button: buttonS, action: actionS, modifier}; +} + +// protocols +const PROTOCOLS = { + '9 (X10: press only)': '\x1b[?9h', + '1000 (VT200: press, release, wheel)': '\x1b[?1000h', + // '1001 (VT200 highlight)': '\x1b[?1001h', // handle of backreport - not implemented + '1002 (press, release, move on pressed, wheel)': '\x1b[?1002h', + '1003 (press, relase, move, wheel)': '\x1b[?1003h' +} + +// encodings: ENCODING_NAME => [sequence, parse_report] +const ENC = { + 'DEFAULT' : [ + '', + // format: CSI M