@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.
@@ -3,124 +3,87 @@
3
3
  * @license MIT
4
4
  */
5
5
 
6
- import type { Terminal, IDisposable, ITerminalAddon, IDecoration } from '@xterm/xterm';
7
- import type { SearchAddon as ISearchApi } from '@xterm/addon-search';
8
- import { Emitter } from 'vs/base/common/event';
9
- import { combinedDisposable, Disposable, dispose, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
10
-
11
- export interface ISearchOptions {
12
- regex?: boolean;
13
- wholeWord?: boolean;
14
- caseSensitive?: boolean;
15
- incremental?: boolean;
16
- decorations?: ISearchDecorationOptions;
17
- noScroll?: boolean;
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
- interface ISearchDecorationOptions {
21
- matchBackground?: string;
22
- matchBorder?: string;
23
- matchOverviewRuler: string;
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
- * The offsets where each line starts when the entry describes a wrapped line.
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
- lineOffsets: number[]
54
- ];
55
-
56
- interface IHighlight extends IDisposable {
57
- decoration: IDecoration;
58
- match: ISearchResult;
30
+ DEFAULT_HIGHLIGHT_LIMIT = 1000
59
31
  }
60
32
 
61
- const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?';
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 _lastSearchOptions: ISearchOptions | undefined;
73
- private _highlightTimeout: number | undefined;
74
- /**
75
- * translateBufferLineToStringWithWrap is a fairly expensive call.
76
- * We memoize the calls into an array that has a time based ttl.
77
- * _linesCache is also invalidated when the terminal cursor moves.
78
- */
79
- private _linesCache: LineCacheEntry[] | undefined;
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
- private readonly _onDidChangeResults = this._register(new Emitter<{ resultIndex: number, resultCount: number }>());
84
- public readonly onDidChangeResults = this._onDidChangeResults.event;
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
- if (this._highlightTimeout) {
101
- window.clearTimeout(this._highlightTimeout);
102
- }
103
- if (this._cachedSearchTerm && this._lastSearchOptions?.decorations) {
104
- this._highlightTimeout = setTimeout(() => {
105
- const term = this._cachedSearchTerm;
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._selectedDecoration.clear();
114
- dispose(this._highlightDecorations);
115
- this._highlightDecorations = [];
116
- this._highlightedLines.clear();
77
+ this._resultTracker.clearSelectedDecoration();
78
+ this._decorationManager?.clearHighlightDecorations();
79
+ this._resultTracker.clearResults();
117
80
  if (!retainCachedSearchTerm) {
118
- this._cachedSearchTerm = undefined;
81
+ this._state.clearCachedTerm();
119
82
  }
120
83
  }
121
84
 
122
85
  public clearActiveDecoration(): void {
123
- this._selectedDecoration.clear();
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
- const didOptionsChanged = this._lastSearchOptions ? this._didOptionsChange(this._lastSearchOptions, searchOptions) : true;
138
- this._lastSearchOptions = searchOptions;
139
- if (searchOptions?.decorations) {
140
- if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm || didOptionsChanged) {
141
- this._highlightAllMatches(term, searchOptions);
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._cachedSearchTerm = term;
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 || term.length === 0) {
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 searchResultsWithHighlight: ISearchResult[] = [];
126
+ const results: ISearchResult[] = [];
166
127
  let prevResult: ISearchResult | undefined = undefined;
167
- let result = this._find(term, 0, 0, searchOptions);
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 (searchResultsWithHighlight.length >= this._highlightLimit) {
131
+ if (results.length >= this._highlightLimit) {
170
132
  break;
171
133
  }
172
134
  prevResult = result;
173
- searchResultsWithHighlight.push(prevResult);
174
- result = this._find(
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
- let result: ISearchResult | undefined = undefined;
201
-
202
- this._initLinesCache();
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 || !term || term.length === 0) {
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
- const prevSelectedPos = this._terminal.getSelectionPosition();
236
- this._terminal.clearSelection();
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
- // Set selection and scroll if a result was found
293
- return this._selectResult(result, searchOptions?.decorations, searchOptions?.noScroll);
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
- const didOptionsChanged = this._lastSearchOptions ? this._didOptionsChange(this._lastSearchOptions, searchOptions) : true;
307
- this._lastSearchOptions = searchOptions;
308
- if (searchOptions?.decorations) {
309
- if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm || didOptionsChanged) {
310
- this._highlightAllMatches(term, searchOptions);
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._cachedSearchTerm = term;
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
- if (searchOptions?.decorations) {
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
- throw new Error('Cannot use addon until it has been loaded');
193
+ private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
194
+ if (!this._terminal || !this._engine) {
195
+ return false;
357
196
  }
358
- if (!this._terminal || !term || term.length === 0) {
359
- this._terminal?.clearSelection();
197
+ if (!this._state.isValidSearchTerm(term)) {
198
+ this._terminal.clearSelection();
360
199
  this.clearDecorations();
361
200
  return false;
362
201
  }
363
202
 
364
- const prevSelectedPos = this._terminal.getSelectionPosition();
365
- this._terminal.clearSelection();
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?: ISearchDecorationOptions, noScroll?: boolean): boolean {
661
- const terminal = this._terminal!;
662
- this._selectedDecoration.clear();
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
- terminal.clearSelection();
219
+ this._terminal.clearSelection();
665
220
  return false;
666
221
  }
667
- terminal.select(result.col, result.row, result.size);
222
+
223
+ this._terminal.select(result.col, result.row, result.size);
668
224
  if (options) {
669
- const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
670
- if (marker) {
671
- const decoration = terminal.registerDecoration({
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 >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
694
- let scroll = result.row - terminal.buffer.active.viewportY;
695
- scroll -= Math.floor(terminal.rows / 2);
696
- terminal.scrollLines(scroll);
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
  }