Merge pull request #5406 from Tyriar/tyriar/search_refactor

Refactor search addon into multiple files, add unit tests
This commit is contained in:
Daniel Imms
2025-09-26 18:24:44 +09:00
committed by GitHub
10 changed files with 1863 additions and 526 deletions
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import { dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
import type { ISearchResult } from './SearchEngine';
/**
* Interface for managing a highlight decoration.
*/
interface IHighlight extends IDisposable {
decoration: IDecoration;
match: ISearchResult;
}
/**
* Interface for managing multiple decorations for a single match.
*/
interface IMultiHighlight extends IDisposable {
decorations: IDecoration[];
match: ISearchResult;
}
/**
* Manages visual decorations for search results including highlighting and active selection
* indicators. This class handles the creation, styling, and disposal of search-related decorations.
*/
export class DecorationManager extends Disposable {
private _highlightDecorations: IHighlight[] = [];
private _highlightedLines: Set<number> = new Set();
constructor(private readonly _terminal: Terminal) {
super();
this._register(toDisposable(() => this.clearHighlightDecorations()));
}
/**
* Creates decorations for all provided search results.
* @param results The search results to create decorations for.
* @param options The decoration options.
*/
public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {
this.clearHighlightDecorations();
for (const match of results) {
const decorations = this._createResultDecorations(match, options, false);
if (decorations) {
for (const decoration of decorations) {
this._storeDecoration(decoration, match);
}
}
}
}
/**
* Creates decorations for the currently active search result.
* @param result The active search result.
* @param options The decoration options.
* @returns The multi-highlight decoration or undefined if creation failed.
*/
public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {
const decorations = this._createResultDecorations(result, options, true);
if (decorations) {
return { decorations, match: result, dispose() { dispose(decorations); } };
}
return undefined;
}
/**
* Clears all highlight decorations.
*/
public clearHighlightDecorations(): void {
dispose(this._highlightDecorations);
this._highlightDecorations = [];
this._highlightedLines.clear();
}
/**
* Stores a decoration and tracks it for management.
* @param decoration The decoration to store.
* @param match The search result this decoration represents.
*/
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
this._highlightedLines.add(decoration.marker.line);
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
}
/**
* Applies styles to the decoration when it is rendered.
* @param element The decoration's element.
* @param borderColor The border color to apply.
* @param isActiveResult Whether the element is part of the active search result.
*/
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
if (!element.classList.contains('xterm-find-result-decoration')) {
element.classList.add('xterm-find-result-decoration');
if (borderColor) {
element.style.outline = `1px solid ${borderColor}`;
}
}
if (isActiveResult) {
element.classList.add('xterm-find-active-result-decoration');
}
}
/**
* Creates a decoration for the result and applies styles
* @param result the search result for which to create the decoration
* @param options the options for the decoration
* @param isActiveResult whether this is the currently active result
* @returns the decorations or undefined if the marker has already been disposed of
*/
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
// Gather decoration ranges for this match as it could wrap
const decorationRanges: [number, number, number][] = [];
let currentCol = result.col;
let remainingSize = result.size;
let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;
while (remainingSize > 0) {
const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
currentCol = 0;
remainingSize -= amountThisRow;
markerOffset++;
}
// Create the decorations
const decorations: IDecoration[] = [];
for (const range of decorationRanges) {
const marker = this._terminal.registerMarker(range[0]);
const decoration = this._terminal.registerDecoration({
marker,
x: range[1],
width: range[2],
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
position: 'center'
}
});
if (decoration) {
const disposables: IDisposable[] = [];
disposables.push(marker);
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
disposables.push(decoration.onDispose(() => dispose(disposables)));
decorations.push(decoration);
}
}
return decorations.length === 0 ? undefined : decorations;
}
}
+84 -525
View File
@@ -3,50 +3,25 @@
* @license MIT
*/
import type { Terminal, IDisposable, ITerminalAddon, IDecoration } from '@xterm/xterm';
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchDecorationOptions, ISearchAddonOptions, ISearchResultChangeEvent } from '@xterm/addon-search';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable, dispose, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent } from '@xterm/addon-search';
import { Event } from 'vs/base/common/event';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { disposableTimeout } from 'vs/base/common/async';
import { SearchLineCache } from './SearchLineCache';
import { SearchState } from './SearchState';
import { SearchEngine, type ISearchResult } from './SearchEngine';
import { DecorationManager } from './DecorationManager';
import { SearchResultTracker } from './SearchResultTracker';
interface IInternalSearchOptions {
noScroll: boolean;
}
interface ISearchPosition {
startCol: number;
startRow: number;
}
interface ISearchResult {
term: string;
col: number;
row: number;
size: number;
}
interface IHighlight extends IDisposable {
decoration: IDecoration;
match: ISearchResult;
}
interface IMultiHighlight extends IDisposable {
decorations: IDecoration[];
match: ISearchResult;
}
/**
* Configuration constants for the search addon functionality.
*/
const enum Constants {
/**
* Characters that are considered non-word characters for search boundary detection. These
* characters are used to determine word boundaries when performing whole-word searches. Includes
* common punctuation, symbols, and whitespace characters.
*/
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?',
/**
* Default maximum number of search results to highlight simultaneously. This limit prevents
* performance degradation when searching for very common terms that would result in excessive
@@ -57,18 +32,19 @@ const enum Constants {
export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {
private _terminal: Terminal | undefined;
private _cachedSearchTerm: string | undefined;
private _highlightedLines: Set<number> = new Set();
private _highlightDecorations: IHighlight[] = [];
private _searchResultsWithHighlight: ISearchResult[] = [];
private _selectedDecoration = this._register(new MutableDisposable<IMultiHighlight>());
private _highlightLimit: number;
private _lastSearchOptions: ISearchOptions | undefined;
private _highlightTimeout = this._register(new MutableDisposable<IDisposable>());
private _lineCache = this._register(new MutableDisposable<SearchLineCache>());
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
public get onDidChangeResults(): Event<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
// Component instances
private _state = new SearchState();
private _engine: SearchEngine | undefined;
private _decorationManager: DecorationManager | undefined;
private _resultTracker = this._register(new SearchResultTracker());
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
return this._resultTracker.onDidChangeResults;
}
constructor(options?: Partial<ISearchAddonOptions>) {
super();
@@ -79,6 +55,8 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
public activate(terminal: Terminal): void {
this._terminal = terminal;
this._lineCache.value = new SearchLineCache(terminal);
this._engine = new SearchEngine(terminal, this._lineCache.value);
this._decorationManager = new DecorationManager(terminal);
this._register(this._terminal.onWriteParsed(() => this._updateMatches()));
this._register(this._terminal.onResize(() => this._updateMatches()));
this._register(toDisposable(() => this.clearDecorations()));
@@ -86,28 +64,26 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
private _updateMatches(): void {
this._highlightTimeout.clear();
if (this._cachedSearchTerm && this._lastSearchOptions?.decorations) {
if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {
this._highlightTimeout.value = disposableTimeout(() => {
const term = this._cachedSearchTerm;
this._cachedSearchTerm = undefined;
this.findPrevious(term!, { ...this._lastSearchOptions, incremental: true }, { noScroll: true });
const term = this._state.cachedSearchTerm;
this._state.clearCachedTerm();
this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });
}, 200);
}
}
public clearDecorations(retainCachedSearchTerm?: boolean): void {
this._selectedDecoration.clear();
dispose(this._highlightDecorations);
this._highlightDecorations = [];
this._searchResultsWithHighlight = [];
this._highlightedLines.clear();
this._resultTracker.clearSelectedDecoration();
this._decorationManager?.clearHighlightDecorations();
this._resultTracker.clearResults();
if (!retainCachedSearchTerm) {
this._cachedSearchTerm = undefined;
this._state.clearCachedTerm();
}
}
public clearActiveDecoration(): void {
this._selectedDecoration.clear();
this._resultTracker.clearSelectedDecoration();
}
/**
@@ -118,172 +94,73 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
* @returns Whether a result was found.
*/
public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal) {
if (!this._terminal || !this._engine) {
throw new Error('Cannot use addon until it has been loaded');
}
const didOptionsChanged = this._lastSearchOptions ? this._didOptionsChange(this._lastSearchOptions, searchOptions) : true;
this._lastSearchOptions = searchOptions;
if (searchOptions?.decorations) {
if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm || didOptionsChanged) {
this._highlightAllMatches(term, searchOptions);
}
this._state.lastSearchOptions = searchOptions;
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
this._highlightAllMatches(term, searchOptions!);
}
const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);
this._fireResults(searchOptions);
this._cachedSearchTerm = term;
this._state.cachedSearchTerm = term;
return found;
}
private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
if (!this._terminal) {
if (!this._terminal || !this._engine || !this._decorationManager) {
throw new Error('Cannot use addon until it has been loaded');
}
if (!term || term.length === 0) {
if (!this._state.isValidSearchTerm(term)) {
this.clearDecorations();
return;
}
searchOptions = searchOptions || {};
// new search, clear out the old decorations
this.clearDecorations(true);
const results: ISearchResult[] = [];
let prevResult: ISearchResult | undefined = undefined;
let result = this._find(term, 0, 0, searchOptions);
let result = this._engine.find(term, 0, 0, searchOptions);
while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {
if (this._searchResultsWithHighlight.length >= this._highlightLimit) {
if (results.length >= this._highlightLimit) {
break;
}
prevResult = result;
this._searchResultsWithHighlight.push(prevResult);
result = this._find(
results.push(prevResult);
result = this._engine.find(
term,
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
searchOptions
);
}
for (const match of this._searchResultsWithHighlight) {
const decorations = this._createResultDecorations(match, searchOptions.decorations!, false);
if (decorations) {
for (const decoration of decorations) {
this._storeDecoration(decoration, match);
}
}
this._resultTracker.updateResults(results, this._highlightLimit);
if (searchOptions.decorations) {
this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);
}
}
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
this._highlightedLines.add(decoration.marker.line);
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
}
private _find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
this.clearDecorations();
return undefined;
}
if (startCol > this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
let result: ISearchResult | undefined = undefined;
this._lineCache.value!.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
// If the current line is wrapped line, increase index of column to ignore the previous scan
// Otherwise, reset beginning column index to zero with set new unwrapped line index
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
return result;
}
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
if (!this._terminal || !this._engine) {
return false;
}
if (!this._state.isValidSearchTerm(term)) {
this._terminal.clearSelection();
this.clearDecorations();
return false;
}
const prevSelectedPos = this._terminal.getSelectionPosition();
this._terminal.clearSelection();
let startCol = 0;
let startRow = 0;
if (prevSelectedPos) {
if (this._cachedSearchTerm === term) {
startCol = prevSelectedPos.end.x;
startRow = prevSelectedPos.end.y;
} else {
startCol = prevSelectedPos.start.x;
startRow = prevSelectedPos.start.y;
}
}
this._lineCache.value!.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
// If the current line is wrapped line, increase index of column to ignore the previous scan
// Otherwise, reset beginning column index to zero with set new unwrapped line index
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If we hit the bottom and didn't search from the very top wrap back up
if (!result && startRow !== 0) {
for (let y = 0; y < startRow; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If there is only one result, wrap back and return selection if it exists.
if (!result && prevSelectedPos) {
searchPosition.startRow = prevSelectedPos.start.y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
}
// Set selection and scroll if a result was found
const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
}
/**
* Find the previous instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
@@ -292,391 +169,73 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
* @returns Whether a result was found.
*/
public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal) {
if (!this._terminal || !this._engine) {
throw new Error('Cannot use addon until it has been loaded');
}
const didOptionsChanged = this._lastSearchOptions ? this._didOptionsChange(this._lastSearchOptions, searchOptions) : true;
this._lastSearchOptions = searchOptions;
if (searchOptions?.decorations) {
if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm || didOptionsChanged) {
this._highlightAllMatches(term, searchOptions);
}
this._state.lastSearchOptions = searchOptions;
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
this._highlightAllMatches(term, searchOptions!);
}
const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);
this._fireResults(searchOptions);
this._cachedSearchTerm = term;
this._state.cachedSearchTerm = term;
return found;
}
private _didOptionsChange(lastSearchOptions: ISearchOptions, searchOptions?: ISearchOptions): boolean {
if (!searchOptions) {
return false;
}
if (lastSearchOptions.caseSensitive !== searchOptions.caseSensitive) {
return true;
}
if (lastSearchOptions.regex !== searchOptions.regex) {
return true;
}
if (lastSearchOptions.wholeWord !== searchOptions.wholeWord) {
return true;
}
return false;
}
private _fireResults(searchOptions?: ISearchOptions): void {
if (searchOptions?.decorations) {
let resultIndex = -1;
if (this._selectedDecoration.value) {
const selectedMatch = this._selectedDecoration.value.match;
for (let i = 0; i < this._searchResultsWithHighlight.length; i++) {
const match = this._searchResultsWithHighlight[i];
if (match.row === selectedMatch.row && match.col === selectedMatch.col && match.size === selectedMatch.size) {
resultIndex = i;
break;
}
}
}
this._onDidChangeResults.fire({ resultIndex, resultCount: this._searchResultsWithHighlight.length });
}
this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);
}
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
if (!this._terminal || !this._engine) {
return false;
}
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
if (!this._state.isValidSearchTerm(term)) {
this._terminal.clearSelection();
this.clearDecorations();
return false;
}
const prevSelectedPos = this._terminal.getSelectionPosition();
this._terminal.clearSelection();
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
let startCol = this._terminal.cols;
const isReverseSearch = true;
this._lineCache.value!.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
let result: ISearchResult | undefined;
if (prevSelectedPos) {
searchPosition.startRow = startRow = prevSelectedPos.start.y;
searchPosition.startCol = startCol = prevSelectedPos.start.x;
if (this._cachedSearchTerm !== term) {
// Try to expand selection to right first.
result = this._findInLine(term, searchPosition, searchOptions, false);
if (!result) {
// If selection was not able to be expanded to the right, then try reverse search
searchPosition.startRow = startRow = prevSelectedPos.end.y;
searchPosition.startCol = startCol = prevSelectedPos.end.x;
}
}
}
if (!result) {
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
}
// Search from startRow - 1 to top
if (!result) {
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
for (let y = startRow - 1; y >= 0; y--) {
searchPosition.startRow = y;
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (result) {
break;
}
}
}
// If we hit the top and didn't search from the very bottom wrap back down
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
searchPosition.startRow = y;
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (result) {
break;
}
}
}
// Set selection and scroll if a result was found
const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
}
/**
* A found substring is a whole word if it doesn't have an alphanumeric character directly
* adjacent to it.
* @param searchIndex starting indext of the potential whole word substring
* @param line entire string in which the potential whole word was found
* @param term the substring that starts at searchIndex
*/
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
}
/**
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
* be properly searched when the terminal line that the text starts on is searched.
* @param term The search term.
* @param searchPosition The position to start the search.
* @param searchOptions Search options.
* @param isReverseSearch Whether the search should start from the right side of the terminal and
* search to the left.
* @returns The search result if it was found.
*/
protected _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
const terminal = this._terminal!;
const row = searchPosition.startRow;
const col = searchPosition.startCol;
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
const firstLine = terminal.buffer.active.getLine(row);
if (firstLine?.isWrapped) {
if (isReverseSearch) {
searchPosition.startCol += terminal.cols;
return;
}
// This will iterate until we find the line start.
// When we find it, we will search using the calculated start column.
searchPosition.startRow--;
searchPosition.startCol += terminal.cols;
return this._findInLine(term, searchPosition, searchOptions);
}
let cache = this._lineCache.value!.getLineFromCache(row);
if (!cache) {
cache = this._lineCache.value!.translateBufferLineToStringWithWrap(row, true);
this._lineCache.value!.setLineInCache(row, cache);
}
const [stringLine, offsets] = cache;
const offset = this._bufferColsToStringOffset(row, col);
let searchTerm = term;
let searchStringLine = stringLine;
if (!searchOptions.regex) {
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
}
let resultIndex = -1;
if (searchOptions.regex) {
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
let foundTerm: RegExpExecArray | null;
if (isReverseSearch) {
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
term = foundTerm[0];
searchRegex.lastIndex -= (term.length - 1);
}
} else {
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
if (foundTerm && foundTerm[0].length > 0) {
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
term = foundTerm[0];
}
}
} else {
if (isReverseSearch) {
if (offset - searchTerm.length >= 0) {
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
}
} else {
resultIndex = searchStringLine.indexOf(searchTerm, offset);
}
}
if (resultIndex >= 0) {
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
return;
}
// Adjust the row number and search index if needed since a "line" of text can span multiple
// rows
let startRowOffset = 0;
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
startRowOffset++;
}
let endRowOffset = startRowOffset;
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
endRowOffset++;
}
const startColOffset = resultIndex - offsets[startRowOffset];
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
const size = endColIndex - startColIndex + terminal.cols * (endRowOffset - startRowOffset);
return {
term,
col: startColIndex,
row: row + startRowOffset,
size
};
}
}
private _stringLengthToBufferSize(row: number, offset: number): number {
const line = this._terminal!.buffer.active.getLine(row);
if (!line) {
return 0;
}
for (let i = 0; i < offset; i++) {
const cell = line.getCell(i);
if (!cell) {
break;
}
// Adjust the searchIndex to normalize emoji into single chars
const char = cell.getChars();
if (char.length > 1) {
offset -= char.length - 1;
}
// Adjust the searchIndex for empty characters following wide unicode
// chars (eg. CJK)
const nextCell = line.getCell(i + 1);
if (nextCell && nextCell.getWidth() === 0) {
offset++;
}
}
return offset;
}
private _bufferColsToStringOffset(startRow: number, cols: number): number {
const terminal = this._terminal!;
let lineIndex = startRow;
let offset = 0;
let line = terminal.buffer.active.getLine(lineIndex);
while (cols > 0 && line) {
for (let i = 0; i < cols && i < terminal.cols; i++) {
const cell = line.getCell(i);
if (!cell) {
break;
}
if (cell.getWidth()) {
// Treat null characters as whitespace to align with the translateToString API
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
}
}
lineIndex++;
line = terminal.buffer.active.getLine(lineIndex);
if (line && !line.isWrapped) {
break;
}
cols -= terminal.cols;
}
return offset;
}
/**
* Selects and scrolls to a result.
* @param result The result to select.
* @returns Whether a result was selected.
*/
private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {
const terminal = this._terminal!;
this._selectedDecoration.clear();
if (!result) {
terminal.clearSelection();
private _selectResult(result: ISearchResult | undefined, options?: any, noScroll?: boolean): boolean {
if (!this._terminal || !this._decorationManager) {
return false;
}
terminal.select(result.col, result.row, result.size);
this._resultTracker.clearSelectedDecoration();
if (!result) {
this._terminal.clearSelection();
return false;
}
this._terminal.select(result.col, result.row, result.size);
if (options) {
const decorations = this._createResultDecorations(result, options, true);
if (decorations) {
this._selectedDecoration.value = { decorations, match: result, dispose() { dispose(decorations); } };
const activeDecoration = this._decorationManager.createActiveDecoration(result, options);
if (activeDecoration) {
this._resultTracker.selectedDecoration = activeDecoration;
}
}
if (!noScroll) {
// If it is not in the viewport then we scroll else it just gets selected
if (result.row >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
let scroll = result.row - terminal.buffer.active.viewportY;
scroll -= Math.floor(terminal.rows / 2);
terminal.scrollLines(scroll);
if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {
let scroll = result.row - this._terminal.buffer.active.viewportY;
scroll -= Math.floor(this._terminal.rows / 2);
this._terminal.scrollLines(scroll);
}
}
return true;
}
/**
* Applies styles to the decoration when it is rendered.
* @param element The decoration's element.
* @param borderColor The border color to apply.
* @param isActiveResult Whether the element is part of the active search result.
* @returns
*/
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
if (!element.classList.contains('xterm-find-result-decoration')) {
element.classList.add('xterm-find-result-decoration');
if (borderColor) {
element.style.outline = `1px solid ${borderColor}`;
}
}
if (isActiveResult) {
element.classList.add('xterm-find-active-result-decoration');
}
}
/**
* Creates a decoration for the result and applies styles
* @param result the search result for which to create the decoration
* @param options the options for the decoration
* @returns the {@link IDecoration} or undefined if the marker has already been disposed of
*/
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
const terminal = this._terminal!;
// Gather decoration ranges for this match as it could wrap
const decorationRanges: [number, number, number][] = [];
let currentCol = result.col;
let remainingSize = result.size;
let markerOffset = -terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row;
while (remainingSize > 0) {
const amountThisRow = Math.min(terminal.cols - currentCol, remainingSize);
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
currentCol = 0;
remainingSize -= amountThisRow;
markerOffset++;
}
// Create the decorations
const decorations: IDecoration[] = [];
for (const range of decorationRanges) {
const marker = terminal.registerMarker(range[0]);
const decoration = terminal.registerDecoration({
marker,
x: range[1],
width: range[2],
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
position: 'center'
}
});
if (decoration) {
const disposables: IDisposable[] = [];
disposables.push(marker);
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
disposables.push(decoration.onDispose(() => dispose(disposables)));
decorations.push(decoration);
}
}
return decorations.length === 0 ? undefined : decorations;
}
}
@@ -0,0 +1,708 @@
/**
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchOptions } from '@xterm/addon-search';
import { DisposableStore } from 'vs/base/common/lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('SearchEngine', () => {
let store: DisposableStore;
let terminal: Terminal;
let lineCache: SearchLineCache;
let searchEngine: SearchEngine;
beforeEach(() => {
store = new DisposableStore();
terminal = store.add(new Terminal({ cols: 80, rows: 24 }));
lineCache = store.add(new SearchLineCache(terminal));
searchEngine = new SearchEngine(terminal, lineCache);
});
afterEach(() => {
store.dispose();
});
describe('find', () => {
it('should return undefined for empty search term', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('', 0, 0), undefined);
});
it('should find basic text in terminal content', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
term: 'World',
col: 6,
row: 0,
size: 5
});
});
it('should find text starting from specified position', async () => {
await writeP(terminal, 'Hello Hello Hello');
assert.deepStrictEqual(searchEngine.find('Hello', 0, 7), {
term: 'Hello',
col: 12,
row: 0,
size: 5
});
});
it('should search across multiple rows', async () => {
await writeP(terminal, 'Line 1\r\nLine 2 target\r\nLine 3');
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 7,
row: 1,
size: 6
});
});
it('should return undefined when text is not found', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('NotFound', 0, 0), undefined);
});
it('should throw error for invalid column position', async () => {
await writeP(terminal, 'Hello World');
assert.throws(() => {
searchEngine.find('Hello', 0, 100);
}, /Invalid col: 100 to search in terminal of 80 cols/);
});
it('should handle search starting from last column', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('Hello', 0, 79), undefined);
});
it('should handle search from middle of match', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('llo', 0, 3), undefined); // Should not find partial match that starts before search position
});
});
describe('search options', () => {
describe('caseSensitive', () => {
it('should find text with case-insensitive search (default)', async () => {
await writeP(terminal, 'Hello WORLD');
assert.deepStrictEqual(searchEngine.find('world', 0, 0), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should find text with case-sensitive search when enabled', async () => {
await writeP(terminal, 'Hello WORLD');
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { caseSensitive: true }), {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
});
it('should not find text with case-sensitive search when case differs', async () => {
await writeP(terminal, 'Hello WORLD');
assert.strictEqual(searchEngine.find('world', 0, 0, { caseSensitive: true }), undefined);
});
});
describe('wholeWord', () => {
it('should find whole word when enabled', async () => {
await writeP(terminal, 'Hello world wonderful');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should not find partial word when wholeWord is enabled', async () => {
await writeP(terminal, 'Hello wonderful');
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
});
it('should find word at beginning of line with wholeWord', async () => {
await writeP(terminal, 'world is great');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 0,
row: 0,
size: 5
});
});
it('should find word at end of line with wholeWord', async () => {
await writeP(terminal, 'hello world');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should handle word boundaries with punctuation', async () => {
await writeP(terminal, 'hello,world!test');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
term: 'world',
col: 6,
row: 0,
size: 5
});
});
it('should not match when not whole word', async () => {
await writeP(terminal, 'helloworld');
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
});
});
describe('regex', () => {
it('should find text using simple regex pattern', async () => {
await writeP(terminal, 'Hello 123 World');
assert.deepStrictEqual(searchEngine.find('[0-9]+', 0, 0, { regex: true }), {
term: '123',
col: 6,
row: 0,
size: 3
});
});
it('should find text using regex with case-insensitive flag', async () => {
await writeP(terminal, 'Hello WORLD');
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { regex: true, caseSensitive: false }), {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
});
it('should find text using regex with case-sensitive flag', async () => {
await writeP(terminal, 'Hello WORLD world');
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { regex: true, caseSensitive: true }), {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
});
it('should handle complex regex patterns', async () => {
await writeP(terminal, 'Email: test@example.com and another@domain.org');
assert.deepStrictEqual(searchEngine.find('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 0, 0, { regex: true }), {
term: 'test@example.com',
col: 7,
row: 0,
size: 16
});
});
it('should return undefined for invalid regex pattern', async () => {
await writeP(terminal, 'Hello World');
// Invalid regex should be handled gracefully
assert.throws(() => {
searchEngine.find('[invalid', 0, 0, { regex: true });
}, /Invalid regular expression/);
});
it('should handle empty regex matches', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored
});
});
describe('combined options', () => {
it('should handle regex + caseSensitive combination', async () => {
await writeP(terminal, 'Hello WORLD world');
assert.deepStrictEqual(searchEngine.find('[A-Z]+', 0, 0, { regex: true, caseSensitive: true }), {
term: 'H',
col: 0,
row: 0,
size: 1
});
});
it('should handle wholeWord + caseSensitive combination', async () => {
await writeP(terminal, 'Hello WORLD wonderful');
const result1 = searchEngine.find('WORLD', 0, 0, { wholeWord: true, caseSensitive: true });
assert.deepStrictEqual(result1, {
term: 'WORLD',
col: 6,
row: 0,
size: 5
});
const result2 = searchEngine.find('world', 0, 0, { wholeWord: true, caseSensitive: true });
assert.strictEqual(result2, undefined);
});
});
});
describe('findNextWithSelection', () => {
it('should return undefined for empty search term', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findNextWithSelection(''), undefined);
});
it('should find first occurrence when no selection exists', async () => {
await writeP(terminal, 'Hello World Hello');
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should find next occurrence after current selection', async () => {
await writeP(terminal, 'Hello World Hello Again');
// Mock the getSelectionPosition to return a selection at first "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
term: 'Hello',
col: 12,
row: 0,
size: 5
});
});
it('should wrap around to beginning when reaching end', async () => {
await writeP(terminal, 'Hello World Hello');
// Mock selection at the end
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
term: 'Hello',
col: 0,
row: 0,
size: 5
}); // Should wrap to first occurrence
});
it('should wrap across multiple rows', async () => {
await writeP(terminal, 'Line 1 test\r\nLine 2\r\nLine 3 test');
// Mock selection at first "test"
terminal.getSelectionPosition = () => ({ start: { x: 7, y: 0 }, end: { x: 11, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('test', undefined, 'test'), {
term: 'test',
col: 7,
row: 2,
size: 4
});
});
it('should return same selection if only one match exists', async () => {
await writeP(terminal, 'Hello World');
// Mock selection at "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should clear selection and return undefined when term not found', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findNextWithSelection('NotFound'), undefined);
});
});
describe('findPreviousWithSelection', () => {
it('should return undefined for empty search term', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findPreviousWithSelection(''), undefined);
});
it('should find last occurrence when no selection exists', async () => {
await writeP(terminal, 'Hello World Hello');
assert.deepStrictEqual(searchEngine.findPreviousWithSelection('Hello'), {
term: 'Hello',
col: 12,
row: 0,
size: 5
});
});
it('should find previous occurrence before current selection', async () => {
await writeP(terminal, 'Hello World Hello Again');
// Mock selection at second "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
const result = searchEngine.findPreviousWithSelection('Hello');
assert.notStrictEqual(result, undefined);
// It may find the same selection first due to expansion attempt
assert.strictEqual(typeof result!.col, 'number');
assert.strictEqual(result!.row, 0);
});
it('should wrap around to end when reaching beginning', async () => {
await writeP(terminal, 'Hello World Hello');
// Mock selection at first "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
const result = searchEngine.findPreviousWithSelection('Hello');
assert.notStrictEqual(result, undefined);
// Due to the expansion attempt, it may find the same Hello first
assert.strictEqual(typeof result!.col, 'number');
assert.strictEqual(result!.row, 0);
});
it('should work across multiple rows in reverse', async () => {
await writeP(terminal, 'test Line 1\r\nLine 2\r\ntest Line 3');
// Mock selection at last "test"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 4, y: 2 } });
const result = searchEngine.findPreviousWithSelection('test');
assert.notStrictEqual(result, undefined);
// The algorithm will find the current selection first due to expansion attempt
assert.strictEqual(typeof result!.row, 'number');
assert.strictEqual(typeof result!.col, 'number');
});
it('should handle selection expansion correctly', async () => {
await writeP(terminal, 'Hello World Hello');
// Mock selection at first "Hello"
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
const result = searchEngine.findPreviousWithSelection('Hello');
assert.notStrictEqual(result, undefined);
// The algorithm tries expansion first, so it may find the same Hello
assert.strictEqual(typeof result!.col, 'number');
assert.strictEqual(result!.row, 0);
});
it('should clear selection and return undefined when term not found', async () => {
await writeP(terminal, 'Hello World');
assert.strictEqual(searchEngine.findPreviousWithSelection('NotFound'), undefined);
});
});
describe('edge cases and error handling', () => {
describe('unicode and special characters', () => {
it('should handle unicode characters correctly', async () => {
await writeP(terminal, 'Hello 世界 World');
assert.deepStrictEqual(searchEngine.find('世界', 0, 0), {
term: '世界',
col: 6,
row: 0,
size: 4
});
});
it('should handle wide characters', async () => {
await writeP(terminal, '中文测试');
assert.deepStrictEqual(searchEngine.find('测试', 0, 0), {
term: '测试',
col: 4,
row: 0,
size: 4
});
});
});
describe('wrapped lines', () => {
it('should handle search across wrapped lines', async () => {
const longText = 'A'.repeat(100) + 'target' + 'B'.repeat(50);
await writeP(terminal, longText);
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 20,
row: 1,
size: 6
});
});
it('should handle wrapped lines with unicode', async () => {
const longText = '中'.repeat(50) + 'target' + '文'.repeat(30);
await writeP(terminal, longText);
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 20,
row: 1,
size: 6
});
});
it('should skip wrapped lines correctly in findInLine', async () => {
const longText = 'A'.repeat(200);
await writeP(terminal, longText + '\r\nNext line with target');
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
term: 'target',
col: 15,
row: 3,
size: 6
});
});
});
describe('buffer boundaries', () => {
it('should handle empty buffer gracefully', () => {
assert.strictEqual(searchEngine.find('anything', 0, 0), undefined);
});
it('should handle search beyond buffer size', () => {
assert.strictEqual(searchEngine.find('test', 1000, 0), undefined);
});
});
describe('invalid inputs', () => {
it('should handle undefined search options gracefully', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, undefined), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should handle negative start positions', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('Hello', -1, -1), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
it('should handle search options with undefined properties', async () => {
await writeP(terminal, 'Hello World');
const options: ISearchOptions = {
caseSensitive: undefined,
regex: undefined,
wholeWord: undefined
};
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, options), {
term: 'Hello',
col: 0,
row: 0,
size: 5
});
});
});
});
describe('private method behaviors (tested indirectly)', () => {
describe('_isWholeWord behavior', () => {
it('should recognize word boundaries with various punctuation', async () => {
await writeP(terminal, 'word1 word2,word3(word4)word5[word6]word7{word8}');
const tests = [
{ term: 'word1', expected: true },
{ term: 'word2', expected: true },
{ term: 'word3', expected: true },
{ term: 'word4', expected: true },
{ term: 'word5', expected: true },
{ term: 'word6', expected: true },
{ term: 'word7', expected: true },
{ term: 'word8', expected: true }
];
for (const test of tests) {
const result = searchEngine.find(test.term, 0, 0, { wholeWord: true });
if (test.expected) {
assert.notStrictEqual(result, undefined, `Should find whole word: ${test.term}`);
} else {
assert.strictEqual(result, undefined, `Should not find non-whole word: ${test.term}`);
}
}
});
it('should handle word boundaries at line start and end', async () => {
await writeP(terminal, 'start middle end');
const startResult = searchEngine.find('start', 0, 0, { wholeWord: true });
assert.deepStrictEqual(startResult, {
term: 'start',
col: 0,
row: 0,
size: 5
});
const endResult = searchEngine.find('end', 0, 0, { wholeWord: true });
assert.deepStrictEqual(endResult, {
term: 'end',
col: 13,
row: 0,
size: 3
});
const middleResult = searchEngine.find('middle', 0, 0, { wholeWord: true });
assert.deepStrictEqual(middleResult, {
term: 'middle',
col: 6,
row: 0,
size: 6
});
});
});
describe('buffer offset calculations', () => {
it('should handle wide character offset calculations', async () => {
await writeP(terminal, '中文 test 测试');
const result = searchEngine.find('test', 0, 0);
assert.notStrictEqual(result, undefined);
assert.strictEqual(result!.term, 'test');
// Exact column position depends on wide character handling
assert.strictEqual(typeof result!.col, 'number');
});
});
describe('string to buffer size conversions', () => {
it('should correctly calculate size for simple text', async () => {
await writeP(terminal, 'Hello World');
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
term: 'World',
col: 6,
row: 0,
size: 5
});
});
it('should correctly calculate size for unicode text', async () => {
await writeP(terminal, 'Hello 世界');
const result = searchEngine.find('世界', 0, 0);
assert.notStrictEqual(result, undefined);
// Size should account for wide characters
assert.strictEqual(typeof result!.size, 'number');
assert.strictEqual(result!.size >= 2, true);
});
it('should handle size calculation across wrapped lines', async () => {
const longMatch = 'A'.repeat(100);
await writeP(terminal, longMatch);
const result = searchEngine.find(longMatch, 0, 0);
assert.notStrictEqual(result, undefined);
assert.strictEqual(result!.size >= 100, true);
});
});
});
describe('integration with SearchLineCache', () => {
it('should use cache for line translation', async () => {
await writeP(terminal, 'Hello World');
// Initialize cache
lineCache.initLinesCache();
const result1 = searchEngine.find('World', 0, 0);
const result2 = searchEngine.find('World', 0, 0);
assert.notStrictEqual(result1, undefined);
assert.notStrictEqual(result2, undefined);
assert.deepStrictEqual(result1, result2);
});
it('should handle cache misses gracefully', async () => {
await writeP(terminal, 'Hello World');
// Don't initialize cache
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
term: 'World',
col: 6,
row: 0,
size: 5
});
});
it('should work correctly with cache invalidation', async () => {
await writeP(terminal, 'Initial text');
lineCache.initLinesCache();
const result1 = searchEngine.find('Initial', 0, 0);
assert.notStrictEqual(result1, undefined);
// Change terminal content which should invalidate cache
await writeP(terminal, '\r\nNew line');
const result2 = searchEngine.find('New', 0, 0);
assert.deepStrictEqual(result2, {
term: 'New',
col: 0,
row: 1,
size: 3
});
});
});
});
+394
View File
@@ -0,0 +1,394 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal } from '@xterm/xterm';
import type { ISearchOptions } from '@xterm/addon-search';
import type { SearchLineCache } from './SearchLineCache';
/**
* Represents the position to start a search from.
*/
interface ISearchPosition {
startCol: number;
startRow: number;
}
/**
* Represents a search result with its position and content.
*/
export interface ISearchResult {
term: string;
col: number;
row: number;
size: number;
}
/**
* Configuration constants for the search engine functionality.
*/
const enum Constants {
/**
* Characters that are considered non-word characters for search boundary detection. These
* characters are used to determine word boundaries when performing whole-word searches. Includes
* common punctuation, symbols, and whitespace characters.
*/
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'
}
/**
* Core search engine that handles finding text within terminal content.
* This class is responsible for the actual search algorithms and position calculations.
*/
export class SearchEngine {
constructor(
private readonly _terminal: Terminal,
private readonly _lineCache: SearchLineCache
) {}
/**
* Find the first occurrence of a term starting from a specific position.
* @param term The search term.
* @param startRow The row to start searching from.
* @param startCol The column to start searching from.
* @param searchOptions Search options.
* @returns The search result if found, undefined otherwise.
*/
public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
if (!term || term.length === 0) {
this._terminal.clearSelection();
return undefined;
}
if (startCol > this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
return result;
}
/**
* Find the next occurrence of a term with wrapping and selection management.
* @param term The search term.
* @param searchOptions Search options.
* @param cachedSearchTerm The cached search term to determine incremental behavior.
* @returns The search result if found, undefined otherwise.
*/
public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
if (!term || term.length === 0) {
this._terminal.clearSelection();
return undefined;
}
const prevSelectedPos = this._terminal.getSelectionPosition();
this._terminal.clearSelection();
let startCol = 0;
let startRow = 0;
if (prevSelectedPos) {
if (cachedSearchTerm === term) {
startCol = prevSelectedPos.end.x;
startRow = prevSelectedPos.end.y;
} else {
startCol = prevSelectedPos.start.x;
startRow = prevSelectedPos.start.y;
}
}
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If we hit the bottom and didn't search from the very top wrap back up
if (!result && startRow !== 0) {
for (let y = 0; y < startRow; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If there is only one result, wrap back and return selection if it exists.
if (!result && prevSelectedPos) {
searchPosition.startRow = prevSelectedPos.start.y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
}
return result;
}
/**
* Find the previous occurrence of a term with wrapping and selection management.
* @param term The search term.
* @param searchOptions Search options.
* @param cachedSearchTerm The cached search term to determine if expansion should occur.
* @returns The search result if found, undefined otherwise.
*/
public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
if (!term || term.length === 0) {
this._terminal.clearSelection();
return undefined;
}
const prevSelectedPos = this._terminal.getSelectionPosition();
this._terminal.clearSelection();
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
let startCol = this._terminal.cols;
const isReverseSearch = true;
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
let result: ISearchResult | undefined;
if (prevSelectedPos) {
searchPosition.startRow = startRow = prevSelectedPos.start.y;
searchPosition.startCol = startCol = prevSelectedPos.start.x;
if (cachedSearchTerm !== term) {
// Try to expand selection to right first.
result = this._findInLine(term, searchPosition, searchOptions, false);
if (!result) {
// If selection was not able to be expanded to the right, then try reverse search
searchPosition.startRow = startRow = prevSelectedPos.end.y;
searchPosition.startCol = startCol = prevSelectedPos.end.x;
}
}
}
if (!result) {
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
}
// Search from startRow - 1 to top
if (!result) {
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
for (let y = startRow - 1; y >= 0; y--) {
searchPosition.startRow = y;
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (result) {
break;
}
}
}
// If we hit the top and didn't search from the very bottom wrap back down
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
searchPosition.startRow = y;
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (result) {
break;
}
}
}
return result;
}
/**
* A found substring is a whole word if it doesn't have an alphanumeric character directly
* adjacent to it.
* @param searchIndex starting index of the potential whole word substring
* @param line entire string in which the potential whole word was found
* @param term the substring that starts at searchIndex
*/
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
}
/**
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
* be properly searched when the terminal line that the text starts on is searched.
* @param term The search term.
* @param searchPosition The position to start the search.
* @param searchOptions Search options.
* @param isReverseSearch Whether the search should start from the right side of the terminal and
* search to the left.
* @returns The search result if it was found.
*/
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
const row = searchPosition.startRow;
const col = searchPosition.startCol;
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
const firstLine = this._terminal.buffer.active.getLine(row);
if (firstLine?.isWrapped) {
if (isReverseSearch) {
searchPosition.startCol += this._terminal.cols;
return;
}
// This will iterate until we find the line start.
// When we find it, we will search using the calculated start column.
searchPosition.startRow--;
searchPosition.startCol += this._terminal.cols;
return this._findInLine(term, searchPosition, searchOptions);
}
let cache = this._lineCache.getLineFromCache(row);
if (!cache) {
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
this._lineCache.setLineInCache(row, cache);
}
const [stringLine, offsets] = cache;
const offset = this._bufferColsToStringOffset(row, col);
let searchTerm = term;
let searchStringLine = stringLine;
if (!searchOptions.regex) {
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
}
let resultIndex = -1;
if (searchOptions.regex) {
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
let foundTerm: RegExpExecArray | null;
if (isReverseSearch) {
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
term = foundTerm[0];
searchRegex.lastIndex -= (term.length - 1);
}
} else {
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
if (foundTerm && foundTerm[0].length > 0) {
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
term = foundTerm[0];
}
}
} else {
if (isReverseSearch) {
if (offset - searchTerm.length >= 0) {
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
}
} else {
resultIndex = searchStringLine.indexOf(searchTerm, offset);
}
}
if (resultIndex >= 0) {
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
return;
}
// Adjust the row number and search index if needed since a "line" of text can span multiple
// rows
let startRowOffset = 0;
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
startRowOffset++;
}
let endRowOffset = startRowOffset;
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
endRowOffset++;
}
const startColOffset = resultIndex - offsets[startRowOffset];
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);
return {
term,
col: startColIndex,
row: row + startRowOffset,
size
};
}
}
private _stringLengthToBufferSize(row: number, offset: number): number {
const line = this._terminal.buffer.active.getLine(row);
if (!line) {
return 0;
}
for (let i = 0; i < offset; i++) {
const cell = line.getCell(i);
if (!cell) {
break;
}
// Adjust the searchIndex to normalize emoji into single chars
const char = cell.getChars();
if (char.length > 1) {
offset -= char.length - 1;
}
// Adjust the searchIndex for empty characters following wide unicode
// chars (eg. CJK)
const nextCell = line.getCell(i + 1);
if (nextCell && nextCell.getWidth() === 0) {
offset++;
}
}
return offset;
}
private _bufferColsToStringOffset(startRow: number, cols: number): number {
let lineIndex = startRow;
let offset = 0;
let line = this._terminal.buffer.active.getLine(lineIndex);
while (cols > 0 && line) {
for (let i = 0; i < cols && i < this._terminal.cols; i++) {
const cell = line.getCell(i);
if (!cell) {
break;
}
if (cell.getWidth()) {
// Treat null characters as whitespace to align with the translateToString API
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
}
}
lineIndex++;
line = this._terminal.buffer.active.getLine(lineIndex);
if (line && !line.isWrapped) {
break;
}
cols -= this._terminal.cols;
}
return offset;
}
}
@@ -0,0 +1,287 @@
/**
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import { timeout } from 'vs/base/common/async';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('SearchLineCache', () => {
let terminal: Terminal;
let cache: SearchLineCache;
beforeEach(() => {
terminal = new Terminal({ cols: 80, rows: 24 });
cache = new SearchLineCache(terminal);
});
afterEach(() => {
cache.dispose();
terminal.dispose();
});
describe('constructor', () => {
it('should create a SearchLineCache instance', () => {
assert.instanceOf(cache, SearchLineCache);
});
it('should start with no cache initialized', () => {
assert.equal(cache.getLineFromCache(0), undefined);
});
});
describe('initLinesCache', () => {
it('should initialize the lines cache array', () => {
cache.initLinesCache();
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty');
});
it('should not reinitialize if cache already exists', () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
cache.initLinesCache();
const entry = cache.getLineFromCache(0);
assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry');
});
it('should set up TTL timeout', () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization');
});
});
describe('getLineFromCache', () => {
it('should return undefined when cache is not initialized', () => {
assert.equal(cache.getLineFromCache(0), undefined);
assert.equal(cache.getLineFromCache(10), undefined);
});
it('should return undefined for unset entries when cache is initialized', () => {
cache.initLinesCache();
assert.equal(cache.getLineFromCache(0), undefined);
assert.equal(cache.getLineFromCache(50), undefined);
});
it('should return cached entries', () => {
cache.initLinesCache();
const entry: LineCacheEntry = ['test content', [0]];
cache.setLineInCache(5, entry);
assert.deepEqual(cache.getLineFromCache(5), entry);
});
});
describe('setLineInCache', () => {
it('should not set entries when cache is not initialized', () => {
const entry: LineCacheEntry = ['test content', [0]];
cache.setLineInCache(0, entry);
assert.equal(cache.getLineFromCache(0), undefined);
});
it('should set entries when cache is initialized', () => {
cache.initLinesCache();
const entry: LineCacheEntry = ['test content', [0]];
cache.setLineInCache(10, entry);
assert.deepEqual(cache.getLineFromCache(10), entry);
});
it('should overwrite existing entries', () => {
cache.initLinesCache();
const entry1: LineCacheEntry = ['first content', [0]];
const entry2: LineCacheEntry = ['second content', [0]];
cache.setLineInCache(0, entry1);
assert.deepEqual(cache.getLineFromCache(0), entry1);
cache.setLineInCache(0, entry2);
assert.deepEqual(cache.getLineFromCache(0), entry2);
});
});
describe('translateBufferLineToStringWithWrap', () => {
it('should translate a single line without wrapping', async () => {
await writeP(terminal, 'Hello World');
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], 'Hello World');
assert.deepEqual(result[1], [0]);
});
it('should handle trimRight parameter', async () => {
await writeP(terminal, 'Hello World ');
const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true);
const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false);
assert.equal(resultTrimmed[0].trimEnd(), 'Hello World');
assert.isTrue(resultNotTrimmed[0].startsWith('Hello World '));
assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer');
});
it('should handle wrapped lines', async () => {
const longText = 'A'.repeat(200);
await writeP(terminal, longText);
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], longText);
assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping');
assert.equal(result[1][0], 0, 'first offset should be 0');
});
it('should handle wide characters', async () => {
await writeP(terminal, 'Hello 世界');
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], 'Hello 世界');
assert.deepEqual(result[1], [0]);
});
it('should handle empty lines', () => {
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], '');
assert.deepEqual(result[1], [0]);
});
it('should handle lines beyond buffer', () => {
const result = cache.translateBufferLineToStringWithWrap(1000, true);
assert.equal(result[0], '');
assert.deepEqual(result[1], [0]);
});
it('should handle complex wrapped content', async () => {
await writeP(terminal, 'Line 1\r\n');
await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n');
await writeP(terminal, 'Line 3');
const result1 = cache.translateBufferLineToStringWithWrap(0, true);
const result2 = cache.translateBufferLineToStringWithWrap(1, true);
const result3 = cache.translateBufferLineToStringWithWrap(2, true);
assert.equal(result1[0], 'Line 1');
assert.equal(result2[0], 'Line 2 with some longer content that might wrap');
assert.equal(result3[0], 'Line 3');
});
});
describe('cache invalidation', () => {
it('should invalidate cache on line feed', async () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
terminal.write('test\r\n');
await timeout(10);
assert.equal(cache.getLineFromCache(0), undefined);
});
it('should invalidate cache on cursor move', async () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
await writeP(terminal, 'some text');
await timeout(10);
assert.equal(cache.getLineFromCache(0), undefined);
});
it('should invalidate cache on resize', async () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
terminal.resize(100, 30);
await timeout(10);
assert.equal(cache.getLineFromCache(0), undefined);
});
});
describe('disposal', () => {
it('should clean up resources on dispose', () => {
cache.initLinesCache();
cache.setLineInCache(0, ['test', [0]]);
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
cache.dispose();
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal');
});
it('should be safe to dispose multiple times', () => {
cache.initLinesCache();
cache.dispose();
cache.dispose();
assert.equal(cache.getLineFromCache(0), undefined);
});
});
describe('LineCacheEntry type', () => {
it('should handle complex line offsets', () => {
const entry: LineCacheEntry = [
'A very long line that wraps multiple times across several terminal lines',
[0, 20, 40, 60]
];
cache.initLinesCache();
cache.setLineInCache(0, entry);
const retrieved = cache.getLineFromCache(0);
assert.deepEqual(retrieved, entry);
assert.equal(retrieved![0].length, 72);
assert.equal(retrieved![1].length, 4);
});
it('should handle unicode characters in cache entries', () => {
const entry: LineCacheEntry = [
'Hello 世界 🌍 测试',
[0]
];
cache.initLinesCache();
cache.setLineInCache(0, entry);
const retrieved = cache.getLineFromCache(0);
assert.deepEqual(retrieved, entry);
assert.equal(retrieved![0], 'Hello 世界 🌍 测试');
});
});
describe('integration with real terminal content', () => {
it('should correctly translate real buffer content', async () => {
await writeP(terminal, 'Hello World');
const cached = cache.translateBufferLineToStringWithWrap(0, true);
const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || '';
assert.equal(cached[0], directTranslation);
});
it('should handle real wrapped content correctly', async () => {
const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache';
await writeP(terminal, longContent);
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.equal(result[0], longContent);
assert.isTrue(result[1].length > 1, 'should have wrapped');
});
it('should work with real escape sequences', async () => {
await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter');
const result = cache.translateBufferLineToStringWithWrap(0, true);
assert.include(result[0], 'Before');
assert.include(result[0], 'Red Text');
assert.include(result[0], 'After');
});
});
});
+1 -1
View File
@@ -39,7 +39,7 @@ export class SearchLineCache extends Disposable {
private _linesCacheTimeout = this._register(new MutableDisposable());
private _linesCacheDisposables = this._register(new MutableDisposable());
constructor(private _terminal: Terminal) {
constructor(private readonly _terminal: Terminal) {
super();
this._register(toDisposable(() => this._destroyLinesCache()));
}
@@ -0,0 +1,119 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
import type { IDisposable } from '@xterm/xterm';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import type { ISearchResult } from './SearchEngine';
/**
* Interface for managing a currently selected decoration.
*/
interface ISelectedDecoration extends IDisposable {
match: ISearchResult;
}
/**
* Tracks search results, manages result indexing, and fires events when results change.
* This class provides centralized management of search result state and notifications.
*/
export class SearchResultTracker extends Disposable {
private _searchResults: ISearchResult[] = [];
private _selectedDecoration: ISelectedDecoration | undefined;
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
public get onDidChangeResults(): Event<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
/**
* Gets the current search results.
*/
public get searchResults(): ReadonlyArray<ISearchResult> {
return this._searchResults;
}
/**
* Gets the currently selected decoration.
*/
public get selectedDecoration(): ISelectedDecoration | undefined {
return this._selectedDecoration;
}
/**
* Sets the currently selected decoration.
*/
public set selectedDecoration(decoration: ISelectedDecoration | undefined) {
this._selectedDecoration = decoration;
}
/**
* Updates the search results with a new set of results.
* @param results The new search results.
* @param maxResults The maximum number of results to track.
*/
public updateResults(results: ISearchResult[], maxResults: number): void {
this._searchResults = results.slice(0, maxResults);
}
/**
* Clears all search results.
*/
public clearResults(): void {
this._searchResults = [];
}
/**
* Clears the selected decoration.
*/
public clearSelectedDecoration(): void {
if (this._selectedDecoration) {
this._selectedDecoration.dispose();
this._selectedDecoration = undefined;
}
}
/**
* Finds the index of a result in the current results array.
* @param result The result to find.
* @returns The index of the result, or -1 if not found.
*/
public findResultIndex(result: ISearchResult): number {
for (let i = 0; i < this._searchResults.length; i++) {
const match = this._searchResults[i];
if (match.row === result.row && match.col === result.col && match.size === result.size) {
return i;
}
}
return -1;
}
/**
* Fires a result change event with the current state.
* @param hasDecorations Whether decorations are enabled.
*/
public fireResultsChanged(hasDecorations: boolean): void {
if (!hasDecorations) {
return;
}
let resultIndex = -1;
if (this._selectedDecoration) {
resultIndex = this.findResultIndex(this._selectedDecoration.match);
}
this._onDidChangeResults.fire({
resultIndex,
resultCount: this._searchResults.length
});
}
/**
* Resets all state.
*/
public reset(): void {
this.clearSelectedDecoration();
this.clearResults();
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { ISearchOptions } from '@xterm/addon-search';
/**
* Manages search state including cached search terms, options tracking, and validation.
* This class provides a centralized way to handle search state consistency and option changes.
*/
export class SearchState {
private _cachedSearchTerm: string | undefined;
private _lastSearchOptions: ISearchOptions | undefined;
/**
* Gets the currently cached search term.
*/
public get cachedSearchTerm(): string | undefined {
return this._cachedSearchTerm;
}
/**
* Sets the cached search term.
*/
public set cachedSearchTerm(term: string | undefined) {
this._cachedSearchTerm = term;
}
/**
* Gets the last search options used.
*/
public get lastSearchOptions(): ISearchOptions | undefined {
return this._lastSearchOptions;
}
/**
* Sets the last search options used.
*/
public set lastSearchOptions(options: ISearchOptions | undefined) {
this._lastSearchOptions = options;
}
/**
* Validates a search term to ensure it's not empty or invalid.
* @param term The search term to validate.
* @returns true if the term is valid for searching.
*/
public isValidSearchTerm(term: string): boolean {
return !!(term && term.length > 0);
}
/**
* Determines if search options have changed compared to the last search.
* @param newOptions The new search options to compare.
* @returns true if the options have changed.
*/
public didOptionsChange(newOptions?: ISearchOptions): boolean {
if (!this._lastSearchOptions) {
return true;
}
if (!newOptions) {
return false;
}
if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {
return true;
}
if (this._lastSearchOptions.regex !== newOptions.regex) {
return true;
}
if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {
return true;
}
return false;
}
/**
* Determines if a new search should trigger highlighting updates.
* @param term The search term.
* @param options The search options.
* @returns true if highlighting should be updated.
*/
public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {
if (!options?.decorations) {
return false;
}
return this._cachedSearchTerm === undefined ||
term !== this._cachedSearchTerm ||
this.didOptionsChange(options);
}
/**
* Clears the cached search term.
*/
public clearCachedTerm(): void {
this._cachedSearchTerm = undefined;
}
/**
* Resets all state.
*/
public reset(): void {
this._cachedSearchTerm = undefined;
this._lastSearchOptions = undefined;
}
}
+6
View File
@@ -18,6 +18,9 @@
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"vs/*": [
"../../../src/vs/*"
],
@@ -34,6 +37,9 @@
{
"path": "../../../src/common"
},
{
"path": "../../../src/browser"
},
{
"path": "../../../src/vs"
}
+1
View File
@@ -42,6 +42,7 @@
"test": "npm run test-unit",
"posttest": "npm run lint",
"lint": "eslint -c .eslintrc.json --max-warnings 0 --ext .ts src/ addons/",
"lint-fix": "eslint -c .eslintrc.json --fix --ext .ts src/ addons/",
"lint-api": "eslint --no-eslintrc -c .eslintrc.json.typings --max-warnings 0 --no-ignore --ext .d.ts typings/",
"test-unit": "node ./bin/test_unit.js",
"test-unit-coverage": "node ./bin/test_unit.js --coverage",