From ca7e8ca3b7dcb791f00a1b4eec877fdb2ec6e921 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:38:42 -0700 Subject: [PATCH] refactor(addon-search): extract components - Extract SearchEngine for core search algorithms - Extract DecorationManager for visual decorations - Extract SearchState for state management - Extract SearchResultTracker for result tracking - Simplify SearchAddon by delegating to components - Improve modularity and maintainability --- addons/addon-search/src/DecorationManager.ts | 157 +++++ addons/addon-search/src/SearchAddon.ts | 609 +++--------------- addons/addon-search/src/SearchEngine.ts | 385 +++++++++++ addons/addon-search/src/SearchLineCache.ts | 2 +- .../addon-search/src/SearchResultTracker.ts | 119 ++++ addons/addon-search/src/SearchState.ts | 106 +++ 6 files changed, 852 insertions(+), 526 deletions(-) create mode 100644 addons/addon-search/src/DecorationManager.ts create mode 100644 addons/addon-search/src/SearchEngine.ts create mode 100644 addons/addon-search/src/SearchResultTracker.ts create mode 100644 addons/addon-search/src/SearchState.ts diff --git a/addons/addon-search/src/DecorationManager.ts b/addons/addon-search/src/DecorationManager.ts new file mode 100644 index 00000000..ba3848da --- /dev/null +++ b/addons/addon-search/src/DecorationManager.ts @@ -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. + */ +export interface IHighlight extends IDisposable { + decoration: IDecoration; + match: ISearchResult; +} + +/** + * Interface for managing multiple decorations for a single match. + */ +export 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 = 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; + } +} + + diff --git a/addons/addon-search/src/SearchAddon.ts b/addons/addon-search/src/SearchAddon.ts index 91e3bfe8..0b2788d2 100644 --- a/addons/addon-search/src/SearchAddon.ts +++ b/addons/addon-search/src/SearchAddon.ts @@ -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 = new Set(); - private _highlightDecorations: IHighlight[] = []; - private _searchResultsWithHighlight: ISearchResult[] = []; - private _selectedDecoration = this._register(new MutableDisposable()); private _highlightLimit: number; - private _lastSearchOptions: ISearchOptions | undefined; private _highlightTimeout = this._register(new MutableDisposable()); private _lineCache = this._register(new MutableDisposable()); - private readonly _onDidChangeResults = this._register(new Emitter()); - public get onDidChangeResults(): Event { 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 { + return this._resultTracker.onDidChangeResults; + } constructor(options?: Partial) { 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); 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); 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; - } } diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts new file mode 100644 index 00000000..ff0e861c --- /dev/null +++ b/addons/addon-search/src/SearchEngine.ts @@ -0,0 +1,385 @@ +/** + * 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. + */ +export 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. + * @returns The search result if found, undefined otherwise. + */ + public findNextWithSelection(term: string, searchOptions?: ISearchOptions): 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) { + startCol = prevSelectedPos.end.x; + startRow = prevSelectedPos.end.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. + * @returns The search result if found, undefined otherwise. + */ + public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions): 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; + // 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; + } +} diff --git a/addons/addon-search/src/SearchLineCache.ts b/addons/addon-search/src/SearchLineCache.ts index 94d9fb72..535a3cc5 100644 --- a/addons/addon-search/src/SearchLineCache.ts +++ b/addons/addon-search/src/SearchLineCache.ts @@ -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())); } diff --git a/addons/addon-search/src/SearchResultTracker.ts b/addons/addon-search/src/SearchResultTracker.ts new file mode 100644 index 00000000..b0e8faa7 --- /dev/null +++ b/addons/addon-search/src/SearchResultTracker.ts @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import type { ISearchResultChangeEvent } from '@xterm/addon-search'; +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. + */ +export interface ISelectedDecoration { + match: ISearchResult; + dispose(): void; +} + +/** + * 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()); + public get onDidChangeResults(): Event { return this._onDidChangeResults.event; } + + /** + * Gets the current search results. + */ + public get searchResults(): ReadonlyArray { + 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(); + } +} diff --git a/addons/addon-search/src/SearchState.ts b/addons/addon-search/src/SearchState.ts new file mode 100644 index 00000000..c5e2c888 --- /dev/null +++ b/addons/addon-search/src/SearchState.ts @@ -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; + } +}