@xterm/addon-search 0.16.0-beta.98 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/addon-search.js +1 -1
- package/lib/addon-search.js.map +1 -1
- package/lib/addon-search.mjs +13 -13
- package/lib/addon-search.mjs.map +4 -4
- package/package.json +2 -5
- package/src/DecorationManager.ts +157 -0
- package/src/SearchAddon.ts +110 -620
- package/src/SearchEngine.ts +394 -0
- package/src/SearchLineCache.ts +114 -0
- package/src/SearchResultTracker.ts +119 -0
- package/src/SearchState.ts +106 -0
- package/typings/addon-search.d.ts +16 -2
package/src/SearchAddon.ts
CHANGED
|
@@ -3,124 +3,87 @@
|
|
|
3
3
|
* @license MIT
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { Terminal, IDisposable, ITerminalAddon
|
|
7
|
-
import type { SearchAddon as ISearchApi } from '@xterm/addon-search';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
|
|
7
|
+
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent } from '@xterm/addon-search';
|
|
8
|
+
import { Event } from 'vs/base/common/event';
|
|
9
|
+
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
|
10
|
+
import { disposableTimeout } from 'vs/base/common/async';
|
|
11
|
+
import { SearchLineCache } from './SearchLineCache';
|
|
12
|
+
import { SearchState } from './SearchState';
|
|
13
|
+
import { SearchEngine, type ISearchResult } from './SearchEngine';
|
|
14
|
+
import { DecorationManager } from './DecorationManager';
|
|
15
|
+
import { SearchResultTracker } from './SearchResultTracker';
|
|
16
|
+
|
|
17
|
+
interface IInternalSearchOptions {
|
|
18
|
+
noScroll: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
activeMatchBackground?: string;
|
|
25
|
-
activeMatchBorder?: string;
|
|
26
|
-
activeMatchColorOverviewRuler: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ISearchPosition {
|
|
30
|
-
startCol: number;
|
|
31
|
-
startRow: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface ISearchAddonOptions {
|
|
35
|
-
highlightLimit: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface ISearchResult {
|
|
39
|
-
term: string;
|
|
40
|
-
col: number;
|
|
41
|
-
row: number;
|
|
42
|
-
size: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
type LineCacheEntry = [
|
|
46
|
-
/**
|
|
47
|
-
* The string representation of a line (as opposed to the buffer cell representation).
|
|
48
|
-
*/
|
|
49
|
-
lineAsString: string,
|
|
21
|
+
/**
|
|
22
|
+
* Configuration constants for the search addon functionality.
|
|
23
|
+
*/
|
|
24
|
+
const enum Constants {
|
|
50
25
|
/**
|
|
51
|
-
*
|
|
26
|
+
* Default maximum number of search results to highlight simultaneously. This limit prevents
|
|
27
|
+
* performance degradation when searching for very common terms that would result in excessive
|
|
28
|
+
* highlighting decorations.
|
|
52
29
|
*/
|
|
53
|
-
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
interface IHighlight extends IDisposable {
|
|
57
|
-
decoration: IDecoration;
|
|
58
|
-
match: ISearchResult;
|
|
30
|
+
DEFAULT_HIGHLIGHT_LIMIT = 1000
|
|
59
31
|
}
|
|
60
32
|
|
|
61
|
-
|
|
62
|
-
const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
|
|
63
|
-
const DEFAULT_HIGHLIGHT_LIMIT = 1000;
|
|
64
|
-
|
|
65
|
-
export class SearchAddon extends Disposable implements ITerminalAddon , ISearchApi {
|
|
33
|
+
export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {
|
|
66
34
|
private _terminal: Terminal | undefined;
|
|
67
|
-
private _cachedSearchTerm: string | undefined;
|
|
68
|
-
private _highlightedLines: Set<number> = new Set();
|
|
69
|
-
private _highlightDecorations: IHighlight[] = [];
|
|
70
|
-
private _selectedDecoration: MutableDisposable<IHighlight> = this._register(new MutableDisposable());
|
|
71
35
|
private _highlightLimit: number;
|
|
72
|
-
private
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
private
|
|
80
|
-
private _linesCacheTimeoutId = 0;
|
|
81
|
-
private _linesCacheDisposables = new MutableDisposable();
|
|
36
|
+
private _highlightTimeout = this._register(new MutableDisposable<IDisposable>());
|
|
37
|
+
private _lineCache = this._register(new MutableDisposable<SearchLineCache>());
|
|
38
|
+
|
|
39
|
+
// Component instances
|
|
40
|
+
private _state = new SearchState();
|
|
41
|
+
private _engine: SearchEngine | undefined;
|
|
42
|
+
private _decorationManager: DecorationManager | undefined;
|
|
43
|
+
private _resultTracker = this._register(new SearchResultTracker());
|
|
82
44
|
|
|
83
|
-
|
|
84
|
-
|
|
45
|
+
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
|
|
46
|
+
return this._resultTracker.onDidChangeResults;
|
|
47
|
+
}
|
|
85
48
|
|
|
86
49
|
constructor(options?: Partial<ISearchAddonOptions>) {
|
|
87
50
|
super();
|
|
88
51
|
|
|
89
|
-
this._highlightLimit = options?.highlightLimit ?? DEFAULT_HIGHLIGHT_LIMIT;
|
|
52
|
+
this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;
|
|
90
53
|
}
|
|
91
54
|
|
|
92
55
|
public activate(terminal: Terminal): void {
|
|
93
56
|
this._terminal = terminal;
|
|
57
|
+
this._lineCache.value = new SearchLineCache(terminal);
|
|
58
|
+
this._engine = new SearchEngine(terminal, this._lineCache.value);
|
|
59
|
+
this._decorationManager = new DecorationManager(terminal);
|
|
94
60
|
this._register(this._terminal.onWriteParsed(() => this._updateMatches()));
|
|
95
61
|
this._register(this._terminal.onResize(() => this._updateMatches()));
|
|
96
62
|
this._register(toDisposable(() => this.clearDecorations()));
|
|
97
63
|
}
|
|
98
64
|
|
|
99
65
|
private _updateMatches(): void {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
this._cachedSearchTerm = undefined;
|
|
107
|
-
this.findPrevious(term!, { ...this._lastSearchOptions, incremental: true, noScroll: true });
|
|
66
|
+
this._highlightTimeout.clear();
|
|
67
|
+
if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {
|
|
68
|
+
this._highlightTimeout.value = disposableTimeout(() => {
|
|
69
|
+
const term = this._state.cachedSearchTerm;
|
|
70
|
+
this._state.clearCachedTerm();
|
|
71
|
+
this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });
|
|
108
72
|
}, 200);
|
|
109
73
|
}
|
|
110
74
|
}
|
|
111
75
|
|
|
112
76
|
public clearDecorations(retainCachedSearchTerm?: boolean): void {
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
this.
|
|
116
|
-
this._highlightedLines.clear();
|
|
77
|
+
this._resultTracker.clearSelectedDecoration();
|
|
78
|
+
this._decorationManager?.clearHighlightDecorations();
|
|
79
|
+
this._resultTracker.clearResults();
|
|
117
80
|
if (!retainCachedSearchTerm) {
|
|
118
|
-
this.
|
|
81
|
+
this._state.clearCachedTerm();
|
|
119
82
|
}
|
|
120
83
|
}
|
|
121
84
|
|
|
122
85
|
public clearActiveDecoration(): void {
|
|
123
|
-
this.
|
|
86
|
+
this._resultTracker.clearSelectedDecoration();
|
|
124
87
|
}
|
|
125
88
|
|
|
126
89
|
/**
|
|
@@ -130,168 +93,74 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
|
|
130
93
|
* @param searchOptions Search options.
|
|
131
94
|
* @returns Whether a result was found.
|
|
132
95
|
*/
|
|
133
|
-
public findNext(term: string, searchOptions?: ISearchOptions): boolean {
|
|
134
|
-
if (!this._terminal) {
|
|
96
|
+
public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
|
97
|
+
if (!this._terminal || !this._engine) {
|
|
135
98
|
throw new Error('Cannot use addon until it has been loaded');
|
|
136
99
|
}
|
|
137
|
-
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
100
|
+
|
|
101
|
+
this._state.lastSearchOptions = searchOptions;
|
|
102
|
+
|
|
103
|
+
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
|
|
104
|
+
this._highlightAllMatches(term, searchOptions!);
|
|
143
105
|
}
|
|
144
106
|
|
|
145
|
-
const found = this._findNextAndSelect(term, searchOptions);
|
|
107
|
+
const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);
|
|
146
108
|
this._fireResults(searchOptions);
|
|
147
|
-
this.
|
|
109
|
+
this._state.cachedSearchTerm = term;
|
|
148
110
|
|
|
149
111
|
return found;
|
|
150
112
|
}
|
|
151
113
|
|
|
152
114
|
private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
|
|
153
|
-
if (!this._terminal) {
|
|
115
|
+
if (!this._terminal || !this._engine || !this._decorationManager) {
|
|
154
116
|
throw new Error('Cannot use addon until it has been loaded');
|
|
155
117
|
}
|
|
156
|
-
if (!term
|
|
118
|
+
if (!this._state.isValidSearchTerm(term)) {
|
|
157
119
|
this.clearDecorations();
|
|
158
120
|
return;
|
|
159
121
|
}
|
|
160
|
-
searchOptions = searchOptions || {};
|
|
161
122
|
|
|
162
123
|
// new search, clear out the old decorations
|
|
163
124
|
this.clearDecorations(true);
|
|
164
125
|
|
|
165
|
-
const
|
|
126
|
+
const results: ISearchResult[] = [];
|
|
166
127
|
let prevResult: ISearchResult | undefined = undefined;
|
|
167
|
-
let result = this.
|
|
128
|
+
let result = this._engine.find(term, 0, 0, searchOptions);
|
|
129
|
+
|
|
168
130
|
while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {
|
|
169
|
-
if (
|
|
131
|
+
if (results.length >= this._highlightLimit) {
|
|
170
132
|
break;
|
|
171
133
|
}
|
|
172
134
|
prevResult = result;
|
|
173
|
-
|
|
174
|
-
result = this.
|
|
135
|
+
results.push(prevResult);
|
|
136
|
+
result = this._engine.find(
|
|
175
137
|
term,
|
|
176
138
|
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
|
|
177
139
|
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
|
|
178
140
|
searchOptions
|
|
179
141
|
);
|
|
180
142
|
}
|
|
181
|
-
for (const match of searchResultsWithHighlight) {
|
|
182
|
-
const decoration = this._createResultDecoration(match, searchOptions.decorations!);
|
|
183
|
-
if (decoration) {
|
|
184
|
-
this._highlightedLines.add(decoration.marker.line);
|
|
185
|
-
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
private _find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
|
|
191
|
-
if (!this._terminal || !term || term.length === 0) {
|
|
192
|
-
this._terminal?.clearSelection();
|
|
193
|
-
this.clearDecorations();
|
|
194
|
-
return undefined;
|
|
195
|
-
}
|
|
196
|
-
if (startCol > this._terminal.cols) {
|
|
197
|
-
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
|
198
|
-
}
|
|
199
143
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const searchPosition: ISearchPosition = {
|
|
205
|
-
startRow,
|
|
206
|
-
startCol
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
// Search startRow
|
|
210
|
-
result = this._findInLine(term, searchPosition, searchOptions);
|
|
211
|
-
// Search from startRow + 1 to end
|
|
212
|
-
if (!result) {
|
|
213
|
-
|
|
214
|
-
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
|
215
|
-
searchPosition.startRow = y;
|
|
216
|
-
searchPosition.startCol = 0;
|
|
217
|
-
// If the current line is wrapped line, increase index of column to ignore the previous scan
|
|
218
|
-
// Otherwise, reset beginning column index to zero with set new unwrapped line index
|
|
219
|
-
result = this._findInLine(term, searchPosition, searchOptions);
|
|
220
|
-
if (result) {
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
144
|
+
this._resultTracker.updateResults(results, this._highlightLimit);
|
|
145
|
+
if (searchOptions.decorations) {
|
|
146
|
+
this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);
|
|
224
147
|
}
|
|
225
|
-
return result;
|
|
226
148
|
}
|
|
227
149
|
|
|
228
|
-
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
|
|
229
|
-
if (!this._terminal || !
|
|
230
|
-
this._terminal?.clearSelection();
|
|
231
|
-
this.clearDecorations();
|
|
150
|
+
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
|
151
|
+
if (!this._terminal || !this._engine) {
|
|
232
152
|
return false;
|
|
233
153
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
let startCol = 0;
|
|
239
|
-
let startRow = 0;
|
|
240
|
-
if (prevSelectedPos) {
|
|
241
|
-
if (this._cachedSearchTerm === term) {
|
|
242
|
-
startCol = prevSelectedPos.end.x;
|
|
243
|
-
startRow = prevSelectedPos.end.y;
|
|
244
|
-
} else {
|
|
245
|
-
startCol = prevSelectedPos.start.x;
|
|
246
|
-
startRow = prevSelectedPos.start.y;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
this._initLinesCache();
|
|
251
|
-
|
|
252
|
-
const searchPosition: ISearchPosition = {
|
|
253
|
-
startRow,
|
|
254
|
-
startCol
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
// Search startRow
|
|
258
|
-
let result = this._findInLine(term, searchPosition, searchOptions);
|
|
259
|
-
// Search from startRow + 1 to end
|
|
260
|
-
if (!result) {
|
|
261
|
-
|
|
262
|
-
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
|
263
|
-
searchPosition.startRow = y;
|
|
264
|
-
searchPosition.startCol = 0;
|
|
265
|
-
// If the current line is wrapped line, increase index of column to ignore the previous scan
|
|
266
|
-
// Otherwise, reset beginning column index to zero with set new unwrapped line index
|
|
267
|
-
result = this._findInLine(term, searchPosition, searchOptions);
|
|
268
|
-
if (result) {
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// If we hit the bottom and didn't search from the very top wrap back up
|
|
274
|
-
if (!result && startRow !== 0) {
|
|
275
|
-
for (let y = 0; y < startRow; y++) {
|
|
276
|
-
searchPosition.startRow = y;
|
|
277
|
-
searchPosition.startCol = 0;
|
|
278
|
-
result = this._findInLine(term, searchPosition, searchOptions);
|
|
279
|
-
if (result) {
|
|
280
|
-
break;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// If there is only one result, wrap back and return selection if it exists.
|
|
286
|
-
if (!result && prevSelectedPos) {
|
|
287
|
-
searchPosition.startRow = prevSelectedPos.start.y;
|
|
288
|
-
searchPosition.startCol = 0;
|
|
289
|
-
result = this._findInLine(term, searchPosition, searchOptions);
|
|
154
|
+
if (!this._state.isValidSearchTerm(term)) {
|
|
155
|
+
this._terminal.clearSelection();
|
|
156
|
+
this.clearDecorations();
|
|
157
|
+
return false;
|
|
290
158
|
}
|
|
291
159
|
|
|
292
|
-
|
|
293
|
-
return this._selectResult(result, searchOptions?.decorations,
|
|
160
|
+
const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);
|
|
161
|
+
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
|
|
294
162
|
}
|
|
163
|
+
|
|
295
164
|
/**
|
|
296
165
|
* Find the previous instance of the term, then scroll to and select it. If it
|
|
297
166
|
* doesn't exist, do nothing.
|
|
@@ -299,357 +168,40 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
|
|
299
168
|
* @param searchOptions Search options.
|
|
300
169
|
* @returns Whether a result was found.
|
|
301
170
|
*/
|
|
302
|
-
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean {
|
|
303
|
-
if (!this._terminal) {
|
|
171
|
+
public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
|
172
|
+
if (!this._terminal || !this._engine) {
|
|
304
173
|
throw new Error('Cannot use addon until it has been loaded');
|
|
305
174
|
}
|
|
306
|
-
|
|
307
|
-
this.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
175
|
+
|
|
176
|
+
this._state.lastSearchOptions = searchOptions;
|
|
177
|
+
|
|
178
|
+
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
|
|
179
|
+
this._highlightAllMatches(term, searchOptions!);
|
|
312
180
|
}
|
|
313
181
|
|
|
314
|
-
const found = this._findPreviousAndSelect(term, searchOptions);
|
|
182
|
+
const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);
|
|
315
183
|
this._fireResults(searchOptions);
|
|
316
|
-
this.
|
|
184
|
+
this._state.cachedSearchTerm = term;
|
|
317
185
|
|
|
318
186
|
return found;
|
|
319
187
|
}
|
|
320
188
|
|
|
321
|
-
private _didOptionsChange(lastSearchOptions: ISearchOptions, searchOptions?: ISearchOptions): boolean {
|
|
322
|
-
if (!searchOptions) {
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
if (lastSearchOptions.caseSensitive !== searchOptions.caseSensitive) {
|
|
326
|
-
return true;
|
|
327
|
-
}
|
|
328
|
-
if (lastSearchOptions.regex !== searchOptions.regex) {
|
|
329
|
-
return true;
|
|
330
|
-
}
|
|
331
|
-
if (lastSearchOptions.wholeWord !== searchOptions.wholeWord) {
|
|
332
|
-
return true;
|
|
333
|
-
}
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
189
|
private _fireResults(searchOptions?: ISearchOptions): void {
|
|
338
|
-
|
|
339
|
-
let resultIndex = -1;
|
|
340
|
-
if (this._selectedDecoration.value) {
|
|
341
|
-
const selectedMatch = this._selectedDecoration.value.match;
|
|
342
|
-
for (let i = 0; i < this._highlightDecorations.length; i++) {
|
|
343
|
-
const match = this._highlightDecorations[i].match;
|
|
344
|
-
if (match.row === selectedMatch.row && match.col === selectedMatch.col && match.size === selectedMatch.size) {
|
|
345
|
-
resultIndex = i;
|
|
346
|
-
break;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
this._onDidChangeResults.fire({ resultIndex, resultCount: this._highlightDecorations.length });
|
|
351
|
-
}
|
|
190
|
+
this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);
|
|
352
191
|
}
|
|
353
192
|
|
|
354
|
-
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
|
|
355
|
-
if (!this._terminal) {
|
|
356
|
-
|
|
193
|
+
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
|
194
|
+
if (!this._terminal || !this._engine) {
|
|
195
|
+
return false;
|
|
357
196
|
}
|
|
358
|
-
if (!this.
|
|
359
|
-
this._terminal
|
|
197
|
+
if (!this._state.isValidSearchTerm(term)) {
|
|
198
|
+
this._terminal.clearSelection();
|
|
360
199
|
this.clearDecorations();
|
|
361
200
|
return false;
|
|
362
201
|
}
|
|
363
202
|
|
|
364
|
-
const
|
|
365
|
-
this.
|
|
366
|
-
|
|
367
|
-
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
|
368
|
-
let startCol = this._terminal.cols;
|
|
369
|
-
const isReverseSearch = true;
|
|
370
|
-
|
|
371
|
-
this._initLinesCache();
|
|
372
|
-
const searchPosition: ISearchPosition = {
|
|
373
|
-
startRow,
|
|
374
|
-
startCol
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
let result: ISearchResult | undefined;
|
|
378
|
-
if (prevSelectedPos) {
|
|
379
|
-
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
|
380
|
-
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
|
381
|
-
if (this._cachedSearchTerm !== term) {
|
|
382
|
-
// Try to expand selection to right first.
|
|
383
|
-
result = this._findInLine(term, searchPosition, searchOptions, false);
|
|
384
|
-
if (!result) {
|
|
385
|
-
// If selection was not able to be expanded to the right, then try reverse search
|
|
386
|
-
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
|
387
|
-
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (!result) {
|
|
393
|
-
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Search from startRow - 1 to top
|
|
397
|
-
if (!result) {
|
|
398
|
-
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
|
|
399
|
-
for (let y = startRow - 1; y >= 0; y--) {
|
|
400
|
-
searchPosition.startRow = y;
|
|
401
|
-
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
|
402
|
-
if (result) {
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
// If we hit the top and didn't search from the very bottom wrap back down
|
|
408
|
-
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
|
|
409
|
-
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
|
|
410
|
-
searchPosition.startRow = y;
|
|
411
|
-
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
|
412
|
-
if (result) {
|
|
413
|
-
break;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Set selection and scroll if a result was found
|
|
419
|
-
return this._selectResult(result, searchOptions?.decorations, searchOptions?.noScroll);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Sets up a line cache with a ttl
|
|
424
|
-
*/
|
|
425
|
-
private _initLinesCache(): void {
|
|
426
|
-
const terminal = this._terminal!;
|
|
427
|
-
if (!this._linesCache) {
|
|
428
|
-
this._linesCache = new Array(terminal.buffer.active.length);
|
|
429
|
-
this._linesCacheDisposables.value = combinedDisposable(
|
|
430
|
-
terminal.onLineFeed(() => this._destroyLinesCache()),
|
|
431
|
-
terminal.onCursorMove(() => this._destroyLinesCache()),
|
|
432
|
-
terminal.onResize(() => this._destroyLinesCache())
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
window.clearTimeout(this._linesCacheTimeoutId);
|
|
437
|
-
this._linesCacheTimeoutId = window.setTimeout(() => this._destroyLinesCache(), LINES_CACHE_TIME_TO_LIVE);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
private _destroyLinesCache(): void {
|
|
441
|
-
this._linesCache = undefined;
|
|
442
|
-
this._linesCacheDisposables.clear();
|
|
443
|
-
if (this._linesCacheTimeoutId) {
|
|
444
|
-
window.clearTimeout(this._linesCacheTimeoutId);
|
|
445
|
-
this._linesCacheTimeoutId = 0;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* A found substring is a whole word if it doesn't have an alphanumeric character directly
|
|
451
|
-
* adjacent to it.
|
|
452
|
-
* @param searchIndex starting indext of the potential whole word substring
|
|
453
|
-
* @param line entire string in which the potential whole word was found
|
|
454
|
-
* @param term the substring that starts at searchIndex
|
|
455
|
-
*/
|
|
456
|
-
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
|
|
457
|
-
return ((searchIndex === 0) || (NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
|
|
458
|
-
(((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
|
|
463
|
-
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
|
|
464
|
-
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
|
|
465
|
-
* be properly searched when the terminal line that the text starts on is searched.
|
|
466
|
-
* @param term The search term.
|
|
467
|
-
* @param searchPosition The position to start the search.
|
|
468
|
-
* @param searchOptions Search options.
|
|
469
|
-
* @param isReverseSearch Whether the search should start from the right side of the terminal and
|
|
470
|
-
* search to the left.
|
|
471
|
-
* @returns The search result if it was found.
|
|
472
|
-
*/
|
|
473
|
-
protected _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
|
474
|
-
const terminal = this._terminal!;
|
|
475
|
-
const row = searchPosition.startRow;
|
|
476
|
-
const col = searchPosition.startCol;
|
|
477
|
-
|
|
478
|
-
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
|
479
|
-
const firstLine = terminal.buffer.active.getLine(row);
|
|
480
|
-
if (firstLine?.isWrapped) {
|
|
481
|
-
if (isReverseSearch) {
|
|
482
|
-
searchPosition.startCol += terminal.cols;
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// This will iterate until we find the line start.
|
|
487
|
-
// When we find it, we will search using the calculated start column.
|
|
488
|
-
searchPosition.startRow--;
|
|
489
|
-
searchPosition.startCol += terminal.cols;
|
|
490
|
-
return this._findInLine(term, searchPosition, searchOptions);
|
|
491
|
-
}
|
|
492
|
-
let cache = this._linesCache?.[row];
|
|
493
|
-
if (!cache) {
|
|
494
|
-
cache = this._translateBufferLineToStringWithWrap(row, true);
|
|
495
|
-
if (this._linesCache) {
|
|
496
|
-
this._linesCache[row] = cache;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
const [stringLine, offsets] = cache;
|
|
500
|
-
|
|
501
|
-
const offset = this._bufferColsToStringOffset(row, col);
|
|
502
|
-
let searchTerm = term;
|
|
503
|
-
let searchStringLine = stringLine;
|
|
504
|
-
if (!searchOptions.regex) {
|
|
505
|
-
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
|
|
506
|
-
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
let resultIndex = -1;
|
|
510
|
-
if (searchOptions.regex) {
|
|
511
|
-
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
|
|
512
|
-
let foundTerm: RegExpExecArray | null;
|
|
513
|
-
if (isReverseSearch) {
|
|
514
|
-
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
|
|
515
|
-
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
|
|
516
|
-
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
|
|
517
|
-
term = foundTerm[0];
|
|
518
|
-
searchRegex.lastIndex -= (term.length - 1);
|
|
519
|
-
}
|
|
520
|
-
} else {
|
|
521
|
-
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
|
|
522
|
-
if (foundTerm && foundTerm[0].length > 0) {
|
|
523
|
-
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
|
|
524
|
-
term = foundTerm[0];
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
} else {
|
|
528
|
-
if (isReverseSearch) {
|
|
529
|
-
if (offset - searchTerm.length >= 0) {
|
|
530
|
-
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
|
|
531
|
-
}
|
|
532
|
-
} else {
|
|
533
|
-
resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
if (resultIndex >= 0) {
|
|
538
|
-
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
|
543
|
-
// rows
|
|
544
|
-
let startRowOffset = 0;
|
|
545
|
-
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
|
|
546
|
-
startRowOffset++;
|
|
547
|
-
}
|
|
548
|
-
let endRowOffset = startRowOffset;
|
|
549
|
-
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
|
|
550
|
-
endRowOffset++;
|
|
551
|
-
}
|
|
552
|
-
const startColOffset = resultIndex - offsets[startRowOffset];
|
|
553
|
-
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
|
|
554
|
-
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
|
|
555
|
-
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
|
|
556
|
-
const size = endColIndex - startColIndex + terminal.cols * (endRowOffset - startRowOffset);
|
|
557
|
-
|
|
558
|
-
return {
|
|
559
|
-
term,
|
|
560
|
-
col: startColIndex,
|
|
561
|
-
row: row + startRowOffset,
|
|
562
|
-
size
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
private _stringLengthToBufferSize(row: number, offset: number): number {
|
|
568
|
-
const line = this._terminal!.buffer.active.getLine(row);
|
|
569
|
-
if (!line) {
|
|
570
|
-
return 0;
|
|
571
|
-
}
|
|
572
|
-
for (let i = 0; i < offset; i++) {
|
|
573
|
-
const cell = line.getCell(i);
|
|
574
|
-
if (!cell) {
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
// Adjust the searchIndex to normalize emoji into single chars
|
|
578
|
-
const char = cell.getChars();
|
|
579
|
-
if (char.length > 1) {
|
|
580
|
-
offset -= char.length - 1;
|
|
581
|
-
}
|
|
582
|
-
// Adjust the searchIndex for empty characters following wide unicode
|
|
583
|
-
// chars (eg. CJK)
|
|
584
|
-
const nextCell = line.getCell(i + 1);
|
|
585
|
-
if (nextCell && nextCell.getWidth() === 0) {
|
|
586
|
-
offset++;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
return offset;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
|
593
|
-
const terminal = this._terminal!;
|
|
594
|
-
let lineIndex = startRow;
|
|
595
|
-
let offset = 0;
|
|
596
|
-
let line = terminal.buffer.active.getLine(lineIndex);
|
|
597
|
-
while (cols > 0 && line) {
|
|
598
|
-
for (let i = 0; i < cols && i < terminal.cols; i++) {
|
|
599
|
-
const cell = line.getCell(i);
|
|
600
|
-
if (!cell) {
|
|
601
|
-
break;
|
|
602
|
-
}
|
|
603
|
-
if (cell.getWidth()) {
|
|
604
|
-
// Treat null characters as whitespace to align with the translateToString API
|
|
605
|
-
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
lineIndex++;
|
|
609
|
-
line = terminal.buffer.active.getLine(lineIndex);
|
|
610
|
-
if (line && !line.isWrapped) {
|
|
611
|
-
break;
|
|
612
|
-
}
|
|
613
|
-
cols -= terminal.cols;
|
|
614
|
-
}
|
|
615
|
-
return offset;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* Translates a buffer line to a string, including subsequent lines if they are wraps.
|
|
620
|
-
* Wide characters will count as two columns in the resulting string. This
|
|
621
|
-
* function is useful for getting the actual text underneath the raw selection
|
|
622
|
-
* position.
|
|
623
|
-
* @param lineIndex The index of the line being translated.
|
|
624
|
-
* @param trimRight Whether to trim whitespace to the right.
|
|
625
|
-
*/
|
|
626
|
-
private _translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
|
627
|
-
const terminal = this._terminal!;
|
|
628
|
-
const strings = [];
|
|
629
|
-
const lineOffsets = [0];
|
|
630
|
-
let line = terminal.buffer.active.getLine(lineIndex);
|
|
631
|
-
while (line) {
|
|
632
|
-
const nextLine = terminal.buffer.active.getLine(lineIndex + 1);
|
|
633
|
-
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
|
634
|
-
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
|
635
|
-
if (lineWrapsToNext && nextLine) {
|
|
636
|
-
const lastCell = line.getCell(line.length - 1);
|
|
637
|
-
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
|
|
638
|
-
// a wide character wrapped to the next line
|
|
639
|
-
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
|
|
640
|
-
string = string.slice(0, -1);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
strings.push(string);
|
|
644
|
-
if (lineWrapsToNext) {
|
|
645
|
-
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
|
|
646
|
-
} else {
|
|
647
|
-
break;
|
|
648
|
-
}
|
|
649
|
-
lineIndex++;
|
|
650
|
-
line = nextLine;
|
|
651
|
-
}
|
|
652
|
-
return [strings.join(''), lineOffsets];
|
|
203
|
+
const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);
|
|
204
|
+
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
|
|
653
205
|
}
|
|
654
206
|
|
|
655
207
|
/**
|
|
@@ -657,95 +209,33 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
|
|
657
209
|
* @param result The result to select.
|
|
658
210
|
* @returns Whether a result was selected.
|
|
659
211
|
*/
|
|
660
|
-
private _selectResult(result: ISearchResult | undefined, options?:
|
|
661
|
-
|
|
662
|
-
|
|
212
|
+
private _selectResult(result: ISearchResult | undefined, options?: any, noScroll?: boolean): boolean {
|
|
213
|
+
if (!this._terminal || !this._decorationManager) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
this._resultTracker.clearSelectedDecoration();
|
|
663
218
|
if (!result) {
|
|
664
|
-
|
|
219
|
+
this._terminal.clearSelection();
|
|
665
220
|
return false;
|
|
666
221
|
}
|
|
667
|
-
|
|
222
|
+
|
|
223
|
+
this._terminal.select(result.col, result.row, result.size);
|
|
668
224
|
if (options) {
|
|
669
|
-
const
|
|
670
|
-
if (
|
|
671
|
-
|
|
672
|
-
marker,
|
|
673
|
-
x: result.col,
|
|
674
|
-
width: result.size,
|
|
675
|
-
backgroundColor: options.activeMatchBackground,
|
|
676
|
-
layer: 'top',
|
|
677
|
-
overviewRulerOptions: {
|
|
678
|
-
color: options.activeMatchColorOverviewRuler
|
|
679
|
-
}
|
|
680
|
-
});
|
|
681
|
-
if (decoration) {
|
|
682
|
-
const disposables: IDisposable[] = [];
|
|
683
|
-
disposables.push(marker);
|
|
684
|
-
disposables.push(decoration.onRender((e) => this._applyStyles(e, options.activeMatchBorder, true)));
|
|
685
|
-
disposables.push(decoration.onDispose(() => dispose(disposables)));
|
|
686
|
-
this._selectedDecoration.value = { decoration, match: result, dispose() { decoration.dispose(); } };
|
|
687
|
-
}
|
|
225
|
+
const activeDecoration = this._decorationManager.createActiveDecoration(result, options);
|
|
226
|
+
if (activeDecoration) {
|
|
227
|
+
this._resultTracker.selectedDecoration = activeDecoration;
|
|
688
228
|
}
|
|
689
229
|
}
|
|
690
230
|
|
|
691
231
|
if (!noScroll) {
|
|
692
232
|
// If it is not in the viewport then we scroll else it just gets selected
|
|
693
|
-
if (result.row >= (
|
|
694
|
-
let scroll = result.row -
|
|
695
|
-
scroll -= Math.floor(
|
|
696
|
-
|
|
233
|
+
if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {
|
|
234
|
+
let scroll = result.row - this._terminal.buffer.active.viewportY;
|
|
235
|
+
scroll -= Math.floor(this._terminal.rows / 2);
|
|
236
|
+
this._terminal.scrollLines(scroll);
|
|
697
237
|
}
|
|
698
238
|
}
|
|
699
239
|
return true;
|
|
700
240
|
}
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Applies styles to the decoration when it is rendered.
|
|
704
|
-
* @param element The decoration's element.
|
|
705
|
-
* @param borderColor The border color to apply.
|
|
706
|
-
* @param isActiveResult Whether the element is part of the active search result.
|
|
707
|
-
* @returns
|
|
708
|
-
*/
|
|
709
|
-
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
|
|
710
|
-
if (!element.classList.contains('xterm-find-result-decoration')) {
|
|
711
|
-
element.classList.add('xterm-find-result-decoration');
|
|
712
|
-
if (borderColor) {
|
|
713
|
-
element.style.outline = `1px solid ${borderColor}`;
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
if (isActiveResult) {
|
|
717
|
-
element.classList.add('xterm-find-active-result-decoration');
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Creates a decoration for the result and applies styles
|
|
723
|
-
* @param result the search result for which to create the decoration
|
|
724
|
-
* @param options the options for the decoration
|
|
725
|
-
* @returns the {@link IDecoration} or undefined if the marker has already been disposed of
|
|
726
|
-
*/
|
|
727
|
-
private _createResultDecoration(result: ISearchResult, options: ISearchDecorationOptions): IDecoration | undefined {
|
|
728
|
-
const terminal = this._terminal!;
|
|
729
|
-
const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
|
|
730
|
-
if (!marker) {
|
|
731
|
-
return undefined;
|
|
732
|
-
}
|
|
733
|
-
const findResultDecoration = terminal.registerDecoration({
|
|
734
|
-
marker,
|
|
735
|
-
x: result.col,
|
|
736
|
-
width: result.size,
|
|
737
|
-
backgroundColor: options.matchBackground,
|
|
738
|
-
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
|
|
739
|
-
color: options.matchOverviewRuler,
|
|
740
|
-
position: 'center'
|
|
741
|
-
}
|
|
742
|
-
});
|
|
743
|
-
if (findResultDecoration) {
|
|
744
|
-
const disposables: IDisposable[] = [];
|
|
745
|
-
disposables.push(marker);
|
|
746
|
-
disposables.push(findResultDecoration.onRender((e) => this._applyStyles(e, options.matchBorder, false)));
|
|
747
|
-
disposables.push(findResultDecoration.onDispose(() => dispose(disposables)));
|
|
748
|
-
}
|
|
749
|
-
return findResultDecoration;
|
|
750
|
-
}
|
|
751
241
|
}
|