diff --git a/README.md b/README.md index 583faf7f..f2f16c98 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [![xterm.js logo](logo-full.png)](https://xtermjs.org) -[![VSTS Build status](https://xtermjs.visualstudio.com/_apis/public/build/definitions/3e323cf7-5760-460d-af64-ee5675baf366/1/badge)](https://xtermjs.visualstudio.com/xterm.js/_build/index?definitionId=1) +[![Build Status](https://dev.azure.com/xtermjs/xterm.js/_apis/build/status/xtermjs.xterm.js)](https://dev.azure.com/xtermjs/xterm.js/_build/latest?definitionId=3) [![Coverage Status](https://coveralls.io/repos/github/xtermjs/xterm.js/badge.svg?branch=master)](https://coveralls.io/github/xtermjs/xterm.js?branch=master) [![Gitter](https://badges.gitter.im/sourcelair/xterm.js.svg)](https://gitter.im/sourcelair/xterm.js?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![jsDelivr Hits](https://data.jsdelivr.com/v1/package/npm/xterm/badge?style=rounded)](https://www.jsdelivr.com/package/npm/xterm) diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000..4ab162c8 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,66 @@ +# Node.js +# Build a general Node.js application with npm. +# Add steps that analyze code, save build artifacts, deploy, and more: +# https://docs.microsoft.com/vsts/pipelines/languages/javascript + +jobs: +- job: Linux + pool: + vmImage: 'ubuntu-16.04' + steps: + - task: NodeTool@0 + inputs: + versionSpec: '8.x' + displayName: 'Install Node.js' + - task: geeklearningio.gl-vsts-tasks-yarn.yarn-installer-task.YarnInstaller@2 + inputs: + versionSpec: "1.9.4" + displayName: 'Install Yarn' + - script: | + yarn + displayName: 'Install dependencies and build' + - script: | + yarn mocha + displayName: 'Test' + - script: | + yarn lint + displayName: 'Lint' + - script: | + yarn test-coverage + displayName: 'Generate and publish coverage' + +- job: macOS + 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 mocha + displayName: 'Test' + - script: | + yarn lint + displayName: 'Lint' + +- job: Windows + pool: + vmImage: 'vs2017-win2016' + steps: + - task: NodeTool@0 + inputs: + versionSpec: '8.x' + displayName: 'Install Node.js' + - script: | + yarn + displayName: 'Install dependencies and build' + - script: | + yarn mocha + displayName: 'Test' + - script: | + yarn lint + displayName: 'Lint' diff --git a/demo/client.ts b/demo/client.ts index 50a16a4a..19fa7bee 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -8,12 +8,12 @@ /// import { Terminal } from '../lib/public/Terminal'; -import * as attach from '../build/addons/attach/attach'; -import * as fit from '../build/addons/fit/fit'; -import * as fullscreen from '../build/addons/fullscreen/fullscreen'; -import * as search from '../build/addons/search/search'; -import * as webLinks from '../build/addons/webLinks/webLinks'; -import * as winptyCompat from '../build/addons/winptyCompat/winptyCompat'; +import * as attach from '../lib/addons/attach/attach'; +import * as fit from '../lib/addons/fit/fit'; +import * as fullscreen from '../lib/addons/fullscreen/fullscreen'; +import * as search from '../lib/addons/search/search'; +import * as webLinks from '../lib/addons/webLinks/webLinks'; +import * as winptyCompat from '../lib/addons/winptyCompat/winptyCompat'; // Pulling in the module's types relies on the above, it's looks a // little weird here as we're importing "this" module @@ -100,10 +100,10 @@ function createTerminal(): void { addDomListener(actionElements.findNext, 'keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); - let searchOptions = { - regex: document.getElementById('regex').checked, + const searchOptions = { + regex: (document.getElementById('regex') as HTMLInputElement).checked, wholeWord: false, - caseSensitive: false + caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked }; term.findNext(actionElements.findNext.value, searchOptions); } @@ -111,10 +111,10 @@ function createTerminal(): void { addDomListener(actionElements.findPrevious, 'keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); - let searchOptions = { - regex: document.getElementById('regex').checked, + const searchOptions = { + regex: (document.getElementById('regex') as HTMLInputElement).checked, wholeWord: false, - caseSensitive: false + caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked }; term.findPrevious(actionElements.findPrevious.value, searchOptions); } diff --git a/demo/index.html b/demo/index.html index ae29d135..553b9a8b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -17,6 +17,7 @@ +

diff --git a/package.json b/package.json index f2591d03..468c027e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "scripts": { "start": "node demo/start", "start-zmodem": "node demo/zmodem/app", - "lint": "tslint 'src/**/*.ts'", + "lint": "tslint 'src/**/*.ts' './demo/**/*.ts'", "test": "npm-run-all mocha lint", "test-debug": "node --inspect-brk node_modules/.bin/gulp test", "test-suite": "gulp mocha-suite --test", diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 74fc72db..dbee6c45 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -220,8 +220,11 @@ export class Linkifier extends EventEmitter implements ILinkifier { // Get cell color const line = this._terminal.buffer.lines.get(this._terminal.buffer.ydisp + rowIndex); const char = line.get(index); - const attr: number = char[CHAR_DATA_ATTR_INDEX]; - const fg = (attr >> 9) & 0x1ff; + let fg: number | undefined; + if (char) { + const attr: number = char[CHAR_DATA_ATTR_INDEX]; + fg = (attr >> 9) & 0x1ff; + } // Ensure the link is valid before registering if (matcher.validationCallback) { diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index 0111de75..fd59144c 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -25,7 +25,7 @@ describe('term.js addons', () => { beforeEach(() => { term = new TestTerminal(termOptions); - term.refresh = () => {}; + term.refresh = () => { }; (term).renderer = new MockRenderer(); term.viewport = new MockViewport(); (term)._compositionHelper = new MockCompositionHelper(); @@ -36,8 +36,8 @@ describe('term.js addons', () => { }; (term).element = { classList: { - toggle: () => {}, - remove: () => {} + toggle: () => { }, + remove: () => { } } }; }); @@ -67,24 +67,142 @@ describe('term.js addons', () => { }); }); + describe('on', () => { + beforeEach(() => { + term.on('key', () => { }); + term.on('keypress', () => { }); + term.on('keydown', () => { }); + }); + + describe('data', () => { + it('should emit a data event', (done) => { + term.on('data', () => { + done(); + }); + + term.handler('fake'); + }); + }); + + describe(`keypress (including 'key' event)`, () => { + it('should receive a string and event object', (done) => { + let steps = 0; + + const finish = () => { + if ((++steps) === 2) { + done(); + } + }; + + const evKeyPress = { + preventDefault: () => { }, + stopPropagation: () => { }, + type: 'keypress', + keyCode: 13 + }; + + term.on('keypress', (key, event) => { + assert.equal(typeof key, 'string'); + expect(event).to.be.an.instanceof(Object); + finish(); + }); + + term.on('key', (key, event) => { + assert.equal(typeof key, 'string'); + expect(event).to.be.an.instanceof(Object); + finish(); + }); + + term.keyPress(evKeyPress); + }); + }); + + describe(`keydown (including 'key' event)`, () => { + it(`should receive an event object for 'keydown' and a string and event object for 'key'`, (done) => { + let steps = 0; + + const finish = () => { + if ((++steps) === 2) { + done(); + } + }; + + const evKeyDown = { + preventDefault: () => { }, + stopPropagation: () => { }, + type: 'keydown', + keyCode: 13 + }; + + term.on('keydown', (event) => { + expect(event).to.be.an.instanceof(Object); + finish(); + }); + + term.on('key', (key, event) => { + assert.equal(typeof key, 'string'); + expect(event).to.be.an.instanceof(Object); + finish(); + }); + + term.keyDown(evKeyDown); + }); + }); + + describe('resize', () => { + it('should receive an object: {cols: number, rows: number}', (done) => { + term.on('resize', (data) => { + expect(data).to.have.keys(['cols', 'rows']); + assert.equal(typeof data.cols, 'number'); + assert.equal(typeof data.rows, 'number'); + done(); + }); + + term.resize(1, 1); + }); + }); + + describe('scroll', () => { + it('should receive a number', (done) => { + term.on('scroll', (ydisp) => { + assert.equal(typeof ydisp, 'number'); + done(); + }); + + term.scroll(); + }); + }); + + describe('title', () => { + it('should receive a string', (done) => { + term.on('title', (title) => { + assert.equal(typeof title, 'string'); + done(); + }); + + term.handleTitle('title'); + }); + }); + }); + describe('attachCustomKeyEventHandler', () => { const evKeyDown = { - preventDefault: () => {}, - stopPropagation: () => {}, + preventDefault: () => { }, + stopPropagation: () => { }, type: 'keydown', keyCode: 77 }; const evKeyPress = { - preventDefault: () => {}, - stopPropagation: () => {}, + preventDefault: () => { }, + stopPropagation: () => { }, type: 'keypress', keyCode: 77 }; beforeEach(() => { - term.handler = () => {}; - term.showCursor = () => {}; - term.clearSelection = () => {}; + term.handler = () => { }; + term.showCursor = () => { }; + term.clearSelection = () => { }; }); it('should process the keydown/keypress event based on what the handler returns', () => { @@ -304,8 +422,8 @@ describe('term.js addons', () => { type: 'keydown', key: 'a', keyCode: 65, - preventDefault: () => {}, - stopPropagation: () => {} + preventDefault: () => { }, + stopPropagation: () => { } }; term.buffer.ydisp = 0; @@ -472,9 +590,9 @@ describe('term.js addons', () => { let evKeyPress: any; beforeEach(() => { - term.handler = () => {}; - term.showCursor = () => {}; - term.clearSelection = () => {}; + term.handler = () => { }; + term.showCursor = () => { }; + term.clearSelection = () => { }; // term.compositionHelper = { // isComposing: false, // keydown: { @@ -484,15 +602,15 @@ describe('term.js addons', () => { // } // }; evKeyDown = { - preventDefault: () => {}, - stopPropagation: () => {}, + preventDefault: () => { }, + stopPropagation: () => { }, type: 'keydown', altKey: null, keyCode: null }; evKeyPress = { - preventDefault: () => {}, - stopPropagation: () => {}, + preventDefault: () => { }, + stopPropagation: () => { }, type: 'keypress', altKey: null, charCode: null, diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index 63fd047b..f89eb77e 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -100,28 +100,44 @@ export class SearchHelper implements ISearchHelper { } /** - * Searches a line for a search term. - * @param term The search term. + * 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 Tne search term. * @param y The line to search. * @param searchOptions Search options. * @return The search result if it was found. */ protected _findInLine(term: string, y: number, searchOptions: ISearchOptions = {}): ISearchResult { - const lowerStringLine = this._terminal._core.buffer.translateBufferLineToString(y, true).toLowerCase(); - const lowerTerm = term.toLowerCase(); + if (this._terminal._core.buffer.lines.get(y).isWrapped) { + return; + } + + const stringLine = this.translateBufferLineToStringWithWrap(y, true); + const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); + const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); let searchIndex = -1; + if (searchOptions.regex) { - const searchRegex = RegExp(lowerTerm, 'g'); - const foundTerm = searchRegex.exec(lowerStringLine); - if (foundTerm) { + const searchRegex = RegExp(searchTerm, 'g'); + const foundTerm = searchRegex.exec(searchStringLine); + if (foundTerm && foundTerm[0].length > 0) { searchIndex = searchRegex.lastIndex - foundTerm[0].length; term = foundTerm[0]; } } else { - searchIndex = lowerStringLine.indexOf(lowerTerm); + searchIndex = searchStringLine.indexOf(searchTerm); } + if (searchIndex >= 0) { + // Adjust the row number and search index if needed since a "line" of text can span multiple rows + if (searchIndex >= this._terminal.cols) { + y += Math.floor(searchIndex / this._terminal.cols); + searchIndex = searchIndex % this._terminal.cols; + } const line = this._terminal._core.buffer.lines.get(y); + for (let i = 0; i < searchIndex; i++) { const charData = line.get(i); // Adjust the searchIndex to normalize emoji into single chars @@ -144,6 +160,28 @@ export class SearchHelper implements ISearchHelper { } } + /** + * 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. + */ + public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): string { + let lineString = ''; + let lineWrapsToNext: boolean; + + do { + const nextLine = this._terminal._core.buffer.lines.get(lineIndex + 1); + lineWrapsToNext = nextLine ? nextLine.isWrapped : false; + lineString += this._terminal._core.buffer.translateBufferLineToString(lineIndex, !lineWrapsToNext && trimRight); + lineIndex++; + } while (lineWrapsToNext); + + return lineString; + } + /** * Selects and scrolls to a result. * @param result The result to select. diff --git a/src/addons/search/search.test.ts b/src/addons/search/search.test.ts index 8fcde950..38a04df5 100644 --- a/src/addons/search/search.test.ts +++ b/src/addons/search/search.test.ts @@ -14,9 +14,11 @@ class MockTerminalPlain {} class MockTerminal { private _core: any; public searchHelper: TestSearchHelper; + public cols: number; constructor(options: any) { this._core = new (require('../../../lib/Terminal').Terminal)(options); this.searchHelper = new TestSearchHelper(this as any); + this.cols = options.cols; } get core(): any { return this._core; @@ -32,7 +34,7 @@ class TestSearchHelper extends SearchHelper { } } -describe('search addon', function(): void { +describe('search addon', () => { describe('apply', () => { it('should register findNext and findPrevious', () => { search.apply(MockTerminalPlain); @@ -40,45 +42,125 @@ describe('search addon', function(): void { assert.equal(typeof (MockTerminalPlain).prototype.findPrevious, 'function'); }); }); - it('Searchhelper - should find correct position', function(): void { - search.apply(MockTerminal); - const term = new MockTerminal({cols: 20, rows: 3}); - term.core.write('Hello World\r\ntest\n123....hello'); - term.pushWriteData(); - const hello0 = term.searchHelper.findInLine('Hello', 0); - const hello1 = term.searchHelper.findInLine('Hello', 1); - const hello2 = term.searchHelper.findInLine('Hello', 2); - expect(hello0).eql({col: 0, row: 0, term: 'Hello'}); - expect(hello1).eql(undefined); - expect(hello2).eql({col: 11, row: 2, term: 'Hello'}); - }); - it('should respect search regex', function(): void { - search.apply(MockTerminal); - const term = new MockTerminal({cols: 10, rows: 4}); - term.core.write('abcdefghijklmnopqrstuvwxyz\r\n~/dev '); - /* - abcdefghij - klmnopqrst - uvwxyz - ~/dev - */ - term.pushWriteData(); - const searchOptions = { - regex: true, - wholeWord: false, - caseSensitive: false - }; - const hello0 = term.searchHelper.findInLine('dee*', 0, searchOptions); - term.searchHelper.findInLine('jkk*', 0, searchOptions); - term.searchHelper.findInLine('mnn*', 1, searchOptions); - const tilda0 = term.searchHelper.findInLine('^~', 3, searchOptions); - const tilda1 = term.searchHelper.findInLine('^[~]', 3, searchOptions); - const tilda2 = term.searchHelper.findInLine('^\\~', 3, searchOptions); - expect(hello0).eql({col: 3, row: 0, term: 'de'}); - // TODO: uncomment this test when line wrap search is checked in expect(hello1).eql({col: 9, row: 0, term: 'jk'}); - // TODO: uncomment this test when line wrap search is checked in expect(hello2).eql(undefined); - expect(tilda0).eql({col: 0, row: 3, term: '~'}); - expect(tilda1).eql({col: 0, row: 3, term: '~'}); - expect(tilda2).eql({col: 0, row: 3, term: '~'}); + describe('find', () => { + it('Searchhelper - should find correct position', () => { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 3}); + term.core.write('Hello World\r\ntest\n123....hello'); + term.pushWriteData(); + const hello0 = term.searchHelper.findInLine('Hello', 0); + const hello1 = term.searchHelper.findInLine('Hello', 1); + const hello2 = term.searchHelper.findInLine('Hello', 2); + expect(hello0).eql({col: 0, row: 0, term: 'Hello'}); + expect(hello1).eql(undefined); + expect(hello2).eql({col: 11, row: 2, term: 'Hello'}); + }); + it('should find search term accross line wrap', () => { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 10, rows: 5}); + term.core.write('texttextHellotext\r\n'); + term.core.write('texttexttextHellotext goodbye'); + term.pushWriteData(); + /* + texttextHe + llotext + texttextte + xtHellotex + t (these spaces included intentionally) + goodbye + */ + + const hello0 = (term.searchHelper as any)._findInLine('Hello', 0); + const hello1 = (term.searchHelper as any)._findInLine('Hello', 1); + const hello2 = (term.searchHelper as any)._findInLine('Hello', 2); + const hello3 = (term.searchHelper as any)._findInLine('Hello', 3); + const llo = (term.searchHelper as any)._findInLine('llo', 1); + const goodbye = (term.searchHelper as any)._findInLine('goodbye', 2); + expect(hello0).eql({col: 8, row: 0, term: 'Hello'}); + expect(hello1).eql(undefined); + expect(hello2).eql({col: 2, row: 3, term: 'Hello'}); + expect(hello3).eql(undefined); + expect(llo).eql(undefined); + expect(goodbye).eql({col: 0, row: 5, term: 'goodbye'}); + }); + it('should respect search regex', () => { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 10, rows: 4}); + term.core.write('abcdefghijklmnopqrstuvwxyz\r\n~/dev '); + /* + abcdefghij + klmnopqrst + uvwxyz + ~/dev + */ + term.pushWriteData(); + const searchOptions = { + regex: true, + wholeWord: false, + caseSensitive: false + }; + const hello0 = term.searchHelper.findInLine('dee*', 0, searchOptions); + const hello1 = term.searchHelper.findInLine('jkk*', 0, searchOptions); + const hello2 = term.searchHelper.findInLine('mnn*', 1, searchOptions); + const tilda0 = term.searchHelper.findInLine('^~', 3, searchOptions); + const tilda1 = term.searchHelper.findInLine('^[~]', 3, searchOptions); + const tilda2 = term.searchHelper.findInLine('^\\~', 3, searchOptions); + expect(hello0).eql({col: 3, row: 0, term: 'de'}); + expect(hello1).eql({col: 9, row: 0, term: 'jk'}); + expect(hello2).eql(undefined); + expect(tilda0).eql({col: 0, row: 3, term: '~'}); + expect(tilda1).eql({col: 0, row: 3, term: '~'}); + expect(tilda2).eql({col: 0, row: 3, term: '~'}); + }); + it('should not select empty lines', () => { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 3}); + term.core.write(' '); + term.pushWriteData(); + const line = term.searchHelper.findInLine('^.*$', 0, { regex: true }); + expect(line).eql(undefined); + }); + it('should respect case sensitive', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 4}); + term.core.write('Hello World\r\n123....hello\r\nmoreTestHello'); + term.pushWriteData(); + const searchOptions = { + regex: false, + wholeWord: false, + caseSensitive: true + }; + const hello0 = (term.searchHelper as any)._findInLine('Hello', 0, searchOptions); + const hello1 = (term.searchHelper as any)._findInLine('Hello', 1, searchOptions); + const hello2 = (term.searchHelper as any)._findInLine('Hello', 2, searchOptions); + expect(hello0).eql({col: 0, row: 0, term: 'Hello'}); + expect(hello1).eql(undefined); + expect(hello2).eql({col: 8, row: 2, term: 'Hello'}); + }); + it('should respect case sensitive + regex', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 4}); + term.core.write('hellohello\r\nHelloHello'); + term.pushWriteData(); + + /** + * hellohello + * HelloHello + */ + + const searchOptions = { + regex: true, + wholeWord: false, + caseSensitive: true + }; + const hello0 = (term.searchHelper as any)._findInLine('Hello', 0, searchOptions); + const hello1 = (term.searchHelper as any)._findInLine('Hello$', 0, searchOptions); + const hello2 = (term.searchHelper as any)._findInLine('Hello', 1, searchOptions); + const hello3 = (term.searchHelper as any)._findInLine('Hello$', 1, searchOptions); + expect(hello0).eql(undefined); + expect(hello1).eql(undefined); + expect(hello2).eql({col: 0, row: 1, term: 'Hello'}); + expect(hello3).eql({col: 5, row: 1, term: 'Hello'}); + }); }); });