Skip to content

Commit a53c6f1

Browse files
authored
Merge pull request #1826 from nojvek/master
Fix #1660: Search as you type
2 parents 4feed2d + 6a5b5c2 commit a53c6f1

File tree

4 files changed

+93
-42
lines changed

4 files changed

+93
-42
lines changed

demo/client.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as fullscreen from '../lib/addons/fullscreen/fullscreen';
1414
import * as search from '../lib/addons/search/search';
1515
import * as webLinks from '../lib/addons/webLinks/webLinks';
1616
import * as winptyCompat from '../lib/addons/winptyCompat/winptyCompat';
17+
import { ISearchOptions } from '../lib/addons/search/Interfaces';
1718

1819
// Pulling in the module's types relies on the <reference> above, it's looks a
1920
// little weird here as we're importing "this" module
@@ -50,6 +51,14 @@ function setPadding(): void {
5051
term.fit();
5152
}
5253

54+
function getSearchOptions(): ISearchOptions {
55+
return {
56+
regex: (document.getElementById('regex') as HTMLInputElement).checked,
57+
wholeWord: (document.getElementById('whole-word') as HTMLInputElement).checked,
58+
caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked
59+
};
60+
}
61+
5362
createTerminal();
5463

5564
const disposeRecreateButtonHandler = () => {
@@ -97,27 +106,16 @@ function createTerminal(): void {
97106

98107
addDomListener(paddingElement, 'change', setPadding);
99108

100-
addDomListener(actionElements.findNext, 'keypress', (e) => {
101-
if (e.key === 'Enter') {
102-
e.preventDefault();
103-
const searchOptions = {
104-
regex: (document.getElementById('regex') as HTMLInputElement).checked,
105-
wholeWord: (document.getElementById('whole-word') as HTMLInputElement).checked,
106-
caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked
107-
};
108-
term.findNext(actionElements.findNext.value, searchOptions);
109-
}
109+
addDomListener(actionElements.findNext, 'keyup', (e) => {
110+
const searchOptions = getSearchOptions();
111+
searchOptions.incremental = e.key !== `Enter`;
112+
term.findNext(actionElements.findNext.value, searchOptions);
110113
});
111-
addDomListener(actionElements.findPrevious, 'keypress', (e) => {
112-
if (e.key === 'Enter') {
113-
e.preventDefault();
114-
const searchOptions = {
115-
regex: (document.getElementById('regex') as HTMLInputElement).checked,
116-
wholeWord: (document.getElementById('whole-word') as HTMLInputElement).checked,
117-
caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked
118-
};
119-
term.findPrevious(actionElements.findPrevious.value, searchOptions);
120-
}
114+
115+
addDomListener(actionElements.findPrevious, 'keyup', (e) => {
116+
const searchOptions = getSearchOptions();
117+
searchOptions.incremental = e.key !== `Enter`;
118+
term.findPrevious(actionElements.findPrevious.value, searchOptions);
121119
});
122120

123121
// fit is called within a setTimeout, cols and rows need this.

src/addons/search/Interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface ISearchOptions {
2525
regex?: boolean;
2626
wholeWord?: boolean;
2727
caseSensitive?: boolean;
28+
/** Assume caller implements 'search as you type' where findNext gets called when search input changes */
29+
incremental?: boolean;
2830
}
2931

3032
export interface ISearchResult {

src/addons/search/SearchHelper.ts

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@
44
*/
55

66
import { ISearchHelper, ISearchAddonTerminal, ISearchOptions, ISearchResult } from './Interfaces';
7-
const nonWordCharacters = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?';
7+
8+
const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?';
9+
const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
810

911
/**
1012
* A class that knows how to search the terminal and how to display the results.
1113
*/
1214
export class SearchHelper implements ISearchHelper {
15+
/**
16+
* translateBufferLineToStringWithWrap is a fairly expensive call.
17+
* We memoize the calls into an array that has a time based ttl.
18+
* _linesCache is also invalidated when the terminal cursor moves.
19+
*/
20+
private _linesCache: string[] = null;
21+
private _linesCacheTimeoutId = 0;
22+
1323
constructor(private _terminal: ISearchAddonTerminal) {
14-
// TODO: Search for multiple instances on 1 line
15-
// TODO: Don't use the actual selection, instead use a "find selection" so multiple instances can be highlighted
16-
// TODO: Highlight other instances in the viewport
24+
this._destroyLinesCache = this._destroyLinesCache.bind(this);
1725
}
1826

1927
/**
@@ -24,29 +32,36 @@ export class SearchHelper implements ISearchHelper {
2432
* @return Whether a result was found.
2533
*/
2634
public findNext(term: string, searchOptions?: ISearchOptions): boolean {
35+
const selectionManager = this._terminal._core.selectionManager;
36+
const {incremental} = searchOptions;
37+
let result: ISearchResult;
38+
2739
if (!term || term.length === 0) {
40+
selectionManager.clearSelection();
2841
return false;
2942
}
3043

31-
let result: ISearchResult;
32-
3344
let startRow = this._terminal._core.buffer.ydisp;
34-
if (this._terminal._core.selectionManager.selectionEnd) {
45+
46+
if (selectionManager.selectionEnd) {
3547
// Start from the selection end if there is a selection
48+
// For incremental search, use existing row
3649
if (this._terminal.getSelection().length !== 0) {
37-
startRow = this._terminal._core.selectionManager.selectionEnd[1];
50+
startRow = incremental ? selectionManager.selectionStart[1] : selectionManager.selectionEnd[1];
3851
}
3952
}
4053

41-
// Search from ydisp + 1 to end
42-
for (let y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) {
54+
this._initLinesCache();
55+
56+
// Search from startRow to end
57+
for (let y = incremental ? startRow : startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) {
4358
result = this._findInLine(term, y, searchOptions);
4459
if (result) {
4560
break;
4661
}
4762
}
4863

49-
// Search from the top to the current ydisp
64+
// Search from the top to the startRow
5065
if (!result) {
5166
for (let y = 0; y < startRow; y++) {
5267
result = this._findInLine(term, y, searchOptions);
@@ -68,29 +83,35 @@ export class SearchHelper implements ISearchHelper {
6883
* @return Whether a result was found.
6984
*/
7085
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean {
86+
const selectionManager = this._terminal._core.selectionManager;
87+
const {incremental} = searchOptions;
88+
let result: ISearchResult;
89+
7190
if (!term || term.length === 0) {
91+
selectionManager.clearSelection();
7292
return false;
7393
}
7494

75-
let result: ISearchResult;
76-
7795
let startRow = this._terminal._core.buffer.ydisp;
78-
if (this._terminal._core.selectionManager.selectionStart) {
79-
// Start from the selection end if there is a selection
96+
97+
if (selectionManager.selectionStart) {
98+
// Start from the selection start if there is a selection
8099
if (this._terminal.getSelection().length !== 0) {
81-
startRow = this._terminal._core.selectionManager.selectionStart[1];
100+
startRow = selectionManager.selectionStart[1];
82101
}
83102
}
84103

85-
// Search from ydisp + 1 to end
86-
for (let y = startRow - 1; y >= 0; y--) {
104+
this._initLinesCache();
105+
106+
// Search from startRow to top
107+
for (let y = incremental ? startRow : startRow - 1; y >= 0; y--) {
87108
result = this._findInLine(term, y, searchOptions);
88109
if (result) {
89110
break;
90111
}
91112
}
92113

93-
// Search from the top to the current ydisp
114+
// Search from the bottom to startRow
94115
if (!result) {
95116
for (let y = this._terminal._core.buffer.ybase + this._terminal.rows - 1; y > startRow; y--) {
96117
result = this._findInLine(term, y, searchOptions);
@@ -104,15 +125,37 @@ export class SearchHelper implements ISearchHelper {
104125
return this._selectResult(result);
105126
}
106127

128+
/**
129+
* Sets up a line cache with a ttl
130+
*/
131+
private _initLinesCache(): void {
132+
if (!this._linesCache) {
133+
this._linesCache = new Array(this._terminal._core.buffer.length);
134+
this._terminal.on('cursormove', this._destroyLinesCache);
135+
}
136+
137+
window.clearTimeout(this._linesCacheTimeoutId);
138+
this._linesCacheTimeoutId = window.setTimeout(() => this._destroyLinesCache(), LINES_CACHE_TIME_TO_LIVE);
139+
}
140+
141+
private _destroyLinesCache(): void {
142+
this._linesCache = null;
143+
this._terminal.off('cursormove', this._destroyLinesCache);
144+
if (this._linesCacheTimeoutId) {
145+
window.clearTimeout(this._linesCacheTimeoutId);
146+
this._linesCacheTimeoutId = 0;
147+
}
148+
}
149+
107150
/**
108151
* A found substring is a whole word if it doesn't have an alphanumeric character directly adjacent to it.
109152
* @param searchIndex starting indext of the potential whole word substring
110153
* @param line entire string in which the potential whole word was found
111154
* @param term the substring that starts at searchIndex
112155
*/
113156
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
114-
return (((searchIndex === 0) || (nonWordCharacters.indexOf(line[searchIndex - 1]) !== -1)) &&
115-
(((searchIndex + term.length) === line.length) || (nonWordCharacters.indexOf(line[searchIndex + term.length]) !== -1)));
157+
return (((searchIndex === 0) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex - 1]) !== -1)) &&
158+
(((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex + term.length]) !== -1)));
116159
}
117160

118161
/**
@@ -130,7 +173,14 @@ export class SearchHelper implements ISearchHelper {
130173
return;
131174
}
132175

133-
const stringLine = this.translateBufferLineToStringWithWrap(y, true);
176+
let stringLine = this._linesCache ? this._linesCache[y] : void 0;
177+
if (stringLine === void 0) {
178+
stringLine = this.translateBufferLineToStringWithWrap(y, true);
179+
if (this._linesCache) {
180+
this._linesCache[y] = stringLine;
181+
}
182+
}
183+
134184
const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
135185
const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
136186
let searchIndex = -1;

src/addons/search/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"module": "commonjs",
44
"target": "es5",
55
"lib": [
6+
"dom",
67
"es5"
78
],
89
"rootDir": ".",

0 commit comments

Comments
 (0)