diff --git a/demo/client.ts b/demo/client.ts index 4d6cde20..3933a01c 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -113,9 +113,9 @@ function createTerminal(): void { }); addDomListener(actionElements.findPrevious, 'keyup', (e) => { - const searchOptions = getSearchOptions(); - searchOptions.incremental = e.key !== `Enter`; - term.findPrevious(actionElements.findPrevious.value, searchOptions); + if (e.key === `Enter`) { + term.findPrevious(actionElements.findPrevious.value, getSearchOptions()); + } }); // fit is called within a setTimeout, cols and rows need this. diff --git a/src/addons/search/Interfaces.ts b/src/addons/search/Interfaces.ts index 96788120..a1f05895 100644 --- a/src/addons/search/Interfaces.ts +++ b/src/addons/search/Interfaces.ts @@ -25,7 +25,10 @@ export interface ISearchOptions { regex?: boolean; wholeWord?: boolean; caseSensitive?: boolean; - /** Assume caller implements 'search as you type' where findNext gets called when search input changes */ + /** + * Use this when you want the selection to expand if it still matches as the + * user types. Note that this only affects findNext. + */ incremental?: boolean; } diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index 8e6e4e12..42a3a122 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -41,6 +41,7 @@ export class SearchHelper implements ISearchHelper { return false; } + let startCol: number = 0; let startRow = this._terminal._core.buffer.ydisp; if (selectionManager.selectionEnd) { @@ -48,23 +49,30 @@ export class SearchHelper implements ISearchHelper { // For incremental search, use existing row if (this._terminal.getSelection().length !== 0) { startRow = incremental ? selectionManager.selectionStart[1] : selectionManager.selectionEnd[1]; + startCol = incremental ? selectionManager.selectionStart[0] : selectionManager.selectionEnd[0]; } } this._initLinesCache(); - // Search from startRow to end - for (let y = incremental ? startRow : startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) { - result = this._findInLine(term, y, searchOptions); - if (result) { - break; + // Search startRow + result = this._findInLine(term, startRow, startCol, searchOptions); + + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) { + result = this._findInLine(term, y, 0, searchOptions); + if (result) { + break; + } } } - // Search from the top to the startRow + // Search from the top to the startRow (search the whole startRow again in + // case startCol > 0) if (!result) { - for (let y = 0; y < startRow; y++) { - result = this._findInLine(term, y, searchOptions); + for (let y = 0; y <= startRow; y++) { + result = this._findInLine(term, y, 0, searchOptions); if (result) { break; } @@ -84,7 +92,6 @@ export class SearchHelper implements ISearchHelper { */ public findPrevious(term: string, searchOptions?: ISearchOptions): boolean { const selectionManager = this._terminal._core.selectionManager; - const {incremental} = searchOptions; let result: ISearchResult; if (!term || term.length === 0) { @@ -92,29 +99,39 @@ export class SearchHelper implements ISearchHelper { return false; } + const isReverseSearch = true; let startRow = this._terminal._core.buffer.ydisp; + let startCol: number = this._terminal._core.buffer.lines.get(startRow).length; if (selectionManager.selectionStart) { // Start from the selection start if there is a selection if (this._terminal.getSelection().length !== 0) { startRow = selectionManager.selectionStart[1]; + startCol = selectionManager.selectionStart[0]; } } this._initLinesCache(); - // Search from startRow to top - for (let y = incremental ? startRow : startRow - 1; y >= 0; y--) { - result = this._findInLine(term, y, searchOptions); - if (result) { - break; + // Search startRow + result = this._findInLine(term, startRow, startCol, searchOptions, isReverseSearch); + + // Search from startRow - 1 to top + if (!result) { + for (let y = startRow - 1; y >= 0; y--) { + result = this._findInLine(term, y, this._terminal._core.buffer.lines.get(y).length, searchOptions, isReverseSearch); + if (result) { + break; + } } } - // Search from the bottom to startRow + // Search from the bottom to startRow (search the whole startRow again in + // case startCol > 0) if (!result) { - for (let y = this._terminal._core.buffer.ybase + this._terminal.rows - 1; y > startRow; y--) { - result = this._findInLine(term, y, searchOptions); + const searchFrom = this._terminal._core.buffer.ybase + this._terminal.rows - 1; + for (let y = searchFrom; y >= startRow; y--) { + result = this._findInLine(term, y, this._terminal._core.buffer.lines.get(y).length, searchOptions, isReverseSearch); if (result) { break; } @@ -164,72 +181,86 @@ export class SearchHelper implements ISearchHelper { * 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 y The line to search. + * @param row The line to start the search from. + * @param col The column to start the search from. * @param searchOptions Search options. * @return The search result if it was found. */ - protected _findInLine(term: string, y: number, searchOptions: ISearchOptions = {}): ISearchResult { - if (this._terminal._core.buffer.lines.get(y).isWrapped) { + protected _findInLine(term: string, row: number, col: number, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult { + if (this._terminal._core.buffer.lines.get(row).isWrapped) { return; } - let stringLine = this._linesCache ? this._linesCache[y] : void 0; + let stringLine = this._linesCache ? this._linesCache[row] : void 0; if (stringLine === void 0) { - stringLine = this.translateBufferLineToStringWithWrap(y, true); + stringLine = this.translateBufferLineToStringWithWrap(row, true); if (this._linesCache) { - this._linesCache[y] = stringLine; + this._linesCache[row] = stringLine; } } - const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); - let searchIndex = -1; + const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); + let resultIndex = -1; if (searchOptions.regex) { 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]; + let foundTerm: RegExpExecArray; + 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 { - searchIndex = searchStringLine.indexOf(searchTerm); + if (isReverseSearch) { + resultIndex = searchStringLine.lastIndexOf(searchTerm, col - searchTerm.length); + } else { + resultIndex = searchStringLine.indexOf(searchTerm, col); + } } - if (searchIndex >= 0) { + if (resultIndex >= 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; + if (resultIndex >= this._terminal.cols) { + row += Math.floor(resultIndex / this._terminal.cols); + resultIndex = resultIndex % this._terminal.cols; } - if (searchOptions.wholeWord && !this._isWholeWord(searchIndex, searchStringLine, term)) { + if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { return; } - const line = this._terminal._core.buffer.lines.get(y); + const line = this._terminal._core.buffer.lines.get(row); - for (let i = 0; i < searchIndex; i++) { + for (let i = 0; i < resultIndex; i++) { const charData = line.get(i); // Adjust the searchIndex to normalize emoji into single chars const char = charData[1/*CHAR_DATA_CHAR_INDEX*/]; if (char.length > 1) { - searchIndex -= char.length - 1; + resultIndex -= char.length - 1; } // Adjust the searchIndex for empty characters following wide unicode // chars (eg. CJK) const charWidth = charData[2/*CHAR_DATA_WIDTH_INDEX*/]; if (charWidth === 0) { - searchIndex++; + resultIndex++; } } return { term, - col: searchIndex, - row: y + col: resultIndex, + row }; } } - /** * 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 diff --git a/src/addons/search/search.test.ts b/src/addons/search/search.test.ts index a70fd115..6551fa8b 100644 --- a/src/addons/search/search.test.ts +++ b/src/addons/search/search.test.ts @@ -29,8 +29,11 @@ class MockTerminal { } class TestSearchHelper extends SearchHelper { - public findInLine(term: string, y: number, searchOptions?: ISearchOptions): ISearchResult { - return this._findInLine(term, y, searchOptions); + public findInLine(term: string, rowNumber: number, searchOptions?: ISearchOptions): ISearchResult { + return this._findInLine(term, rowNumber, 0, searchOptions); + } + public findFromIndex(term: string, row: number, col: number, searchOptions?: ISearchOptions, isReverseSearch?: boolean): ISearchResult { + return this._findInLine(term, row, col, searchOptions, isReverseSearch); } } @@ -245,5 +248,66 @@ describe('search addon', () => { expect(hello4).eql(undefined); expect(hello5).eql(undefined); }); + it('should find multiple matches in line', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 5}); + term.core.write('helloooo helloooo\r\naaaAAaaAAA'); + term.pushWriteData(); + const searchOptions = { + regex: false, + wholeWord: false, + caseSensitive: false + }; + const find0 = term.searchHelper.findFromIndex('hello', 0, 0, searchOptions); + const find1 = term.searchHelper.findFromIndex('hello', 0, find0.col + find0.term.length, searchOptions); + const find2 = term.searchHelper.findFromIndex('aaaa', 1, 0, searchOptions); + const find3 = term.searchHelper.findFromIndex('aaaa', 1, find2.col + find2.term.length, searchOptions); + const find4 = term.searchHelper.findFromIndex('aaaa', 1, find3.col + find3.term.length, searchOptions); + expect(find0).eql({col: 0, row: 0, term: 'hello'}); + expect(find1).eql({col: 9, row: 0, term: 'hello'}); + expect(find2).eql({col: 0, row: 1, term: 'aaaa'}); + expect(find3).eql({col: 4, row: 1, term: 'aaaa'}); + expect(find4).eql(undefined); + }); + it('should find multiple matches in line - reverse search', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 5}); + term.core.write('it is what it is'); + term.pushWriteData(); + const searchOptions = { + regex: false, + wholeWord: false, + caseSensitive: false + }; + const isReverseSearch = true; + const find0 = term.searchHelper.findFromIndex('is', 0, 16, searchOptions, isReverseSearch); + const find1 = term.searchHelper.findFromIndex('is', 0, find0.col, searchOptions, isReverseSearch); + const find2 = term.searchHelper.findFromIndex('it', 0, 16, searchOptions, isReverseSearch); + const find3 = term.searchHelper.findFromIndex('it', 0, find2.col, searchOptions, isReverseSearch); + expect(find0).eql({col: 14, row: 0, term: 'is'}); + expect(find1).eql({col: 3, row: 0, term: 'is'}); + expect(find2).eql({col: 11, row: 0, term: 'it'}); + expect(find3).eql({col: 0, row: 0, term: 'it'}); + }); + it('should find multiple matches in line - reverse search with regex', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 5}); + term.core.write('zzzABCzzzzABCABC'); + term.pushWriteData(); + const searchOptions = { + regex: true, + wholeWord: false, + caseSensitive: true + }; + const isReverseSearch = true; + const find0 = term.searchHelper.findFromIndex('[A-Z]{3}', 0, 16, searchOptions, isReverseSearch); + const find1 = term.searchHelper.findFromIndex('[A-Z]{3}', 0, find0.col, searchOptions, isReverseSearch); + const find2 = term.searchHelper.findFromIndex('[A-Z]{3}', 0, find1.col, searchOptions, isReverseSearch); + const find3 = term.searchHelper.findFromIndex('[A-Z]{3}', 0, find2.col, searchOptions, isReverseSearch); + expect(find0).eql({col: 13, row: 0, term: 'ABC'}); + expect(find1).eql({col: 10, row: 0, term: 'ABC'}); + expect(find2).eql({col: 3, row: 0, term: 'ABC'}); + expect(find3).eql(undefined); + }); }); }); diff --git a/src/addons/webLinks/webLinks.test.ts b/src/addons/webLinks/webLinks.test.ts index 8ada2510..1e8a4ae7 100644 --- a/src/addons/webLinks/webLinks.test.ts +++ b/src/addons/webLinks/webLinks.test.ts @@ -63,4 +63,28 @@ describe('webLinks addon', () => { assert.equal(uri, 'http://foo.com/colon:test'); }); + + it('should not allow " character at the end of a URI enclosed with ""', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '"http://foo.com/"'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/'); + }); + + it('should not allow \' character at the end of a URI enclosed with \'\'', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '\'http://foo.com/\''; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/'); + }); }); diff --git a/src/addons/webLinks/webLinks.ts b/src/addons/webLinks/webLinks.ts index 75d79104..f0d69cc5 100644 --- a/src/addons/webLinks/webLinks.ts +++ b/src/addons/webLinks/webLinks.ts @@ -14,7 +14,7 @@ const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})'; const localHostClause = '(localhost)'; const portClause = '(:\\d{1,5})'; const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?'; -const pathClause = '(\\/[\\/\\w\\.\\-%~:]*)*([^:\\s])'; +const pathClause = '(\\/[\\/\\w\\.\\-%~:]*)*([^:"\'\\s])'; const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*'; const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?'; const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?'; diff --git a/src/core/input/Keyboard.test.ts b/src/core/input/Keyboard.test.ts index e9846831..a0dc3cbc 100644 --- a/src/core/input/Keyboard.test.ts +++ b/src/core/input/Keyboard.test.ts @@ -281,5 +281,16 @@ describe('Keyboard', () => { assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputDownArrow' }).key, '\x1b[B'); assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputDownArrow' }, { applicationCursorMode: true }).key, '\x1bOB'); }); + + it('should handle lowercase letters', () => { + assert.equal(testEvaluateKeyboardEvent({ keyCode: 65, key: 'a' }).key, 'a'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 189, key: '-' }).key, '-'); + }); + + it('should handle uppercase letters', () => { + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 65, key: 'A' }).key, 'A'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 49, key: '!' }).key, '!'); + }); + }); }); diff --git a/src/core/input/Keyboard.ts b/src/core/input/Keyboard.ts index 8c6f3c59..9d86b349 100644 --- a/src/core/input/Keyboard.ts +++ b/src/core/input/Keyboard.ts @@ -349,6 +349,10 @@ export function evaluateKeyboardEvent( if (ev.keyCode === 65) { // cmd + a result.type = KeyboardResultType.SELECT_ALL; } + } else if (ev.key && !ev.ctrlKey && !ev.altKey && !ev.metaKey && + ev.keyCode >= 48 && ev.keyCode !== 144 && ev.keyCode !== 145) { + // Include only keys that that result in a character; don't include num lock and scroll lock + result.key = ev.key; } break; } diff --git a/src/ui/CharMeasure.ts b/src/ui/CharMeasure.ts index 7d1e5e48..2dfc4eb5 100644 --- a/src/ui/CharMeasure.ts +++ b/src/ui/CharMeasure.ts @@ -38,7 +38,7 @@ export class CharMeasure extends EventEmitter implements ICharMeasure { } public measure(options: ITerminalOptions): void { - this._measureElement.style.fontFamily = options.fontFamily; + this._measureElement.style.fontFamily = options.fontFamily; this._measureElement.style.fontSize = `${options.fontSize}px`; const geometry = this._measureElement.getBoundingClientRect(); // The element is likely currently display:none, we should retain the