diff --git a/addons/addon-search/src/DecorationManager.ts b/addons/addon-search/src/DecorationManager.ts new file mode 100644 index 0000000000..d347f77a3a --- /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. + */ +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 = 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 91e3bfe805..451434d9aa 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); - } - } - } - } - 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; + this._resultTracker.updateResults(results, this._highlightLimit); + if (searchOptions.decorations) { + this._decorationManager.createHighlightDecorations(results, searchOptions.decorations); } - 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(); - this.clearDecorations(); + if (!this._terminal || !this._engine) { 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); + if (!this._state.isValidSearchTerm(term)) { + this._terminal.clearSelection(); + this.clearDecorations(); + return false; } - // 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(); + private _selectResult(result: ISearchResult | undefined, options?: any, noScroll?: boolean): boolean { + if (!this._terminal || !this._decorationManager) { + return false; + } + + this._resultTracker.clearSelectedDecoration(); if (!result) { - terminal.clearSelection(); + this._terminal.clearSelection(); return false; } - terminal.select(result.col, result.row, result.size); + + 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.test.ts b/addons/addon-search/src/SearchEngine.test.ts new file mode 100644 index 0000000000..dfdef12571 --- /dev/null +++ b/addons/addon-search/src/SearchEngine.test.ts @@ -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 { + 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 + }); + }); + }); +}); diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts new file mode 100644 index 0000000000..a78be61ee9 --- /dev/null +++ b/addons/addon-search/src/SearchEngine.ts @@ -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; + } +} diff --git a/addons/addon-search/src/SearchLineCache.test.ts b/addons/addon-search/src/SearchLineCache.test.ts new file mode 100644 index 0000000000..3e0ec36210 --- /dev/null +++ b/addons/addon-search/src/SearchLineCache.test.ts @@ -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 { + 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'); + }); + }); +}); diff --git a/addons/addon-search/src/SearchLineCache.ts b/addons/addon-search/src/SearchLineCache.ts index 94d9fb725d..535a3cc5e9 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 0000000000..f81d529154 --- /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 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()); + 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 0000000000..c5e2c8881e --- /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; + } +} diff --git a/addons/addon-search/src/tsconfig.json b/addons/addon-search/src/tsconfig.json index a926a66f24..03f148d1f4 100644 --- a/addons/addon-search/src/tsconfig.json +++ b/addons/addon-search/src/tsconfig.json @@ -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" } diff --git a/package.json b/package.json index 003d3ff010..8e0063735f 100644 --- a/package.json +++ b/package.json @@ -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",