From 41841d0aa87cc5534a2bc871e76c1b6a86072aaa Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Mon, 25 May 2026 19:58:20 -0700 Subject: [PATCH] Remove old search impl --- addons/addon-search/src/DecorationManager.ts | 168 ---- addons/addon-search/src/SearchAddon.ts | 266 ------- addons/addon-search/src/SearchEngine.test.ts | 725 ------------------ addons/addon-search/src/SearchEngine.ts | 623 --------------- .../addon-search/src/SearchLineCache.test.ts | 298 ------- addons/addon-search/src/SearchLineCache.ts | 146 ---- .../src/SearchResultTracker.test.ts | 74 -- .../addon-search/src/SearchResultTracker.ts | 124 --- addons/addon-search/src/SearchState.test.ts | 78 -- addons/addon-search/src/SearchState.ts | 173 ----- 10 files changed, 2675 deletions(-) delete mode 100644 addons/addon-search/src/DecorationManager.ts delete mode 100644 addons/addon-search/src/SearchAddon.ts delete mode 100644 addons/addon-search/src/SearchEngine.test.ts delete mode 100644 addons/addon-search/src/SearchEngine.ts delete mode 100644 addons/addon-search/src/SearchLineCache.test.ts delete mode 100644 addons/addon-search/src/SearchLineCache.ts delete mode 100644 addons/addon-search/src/SearchResultTracker.test.ts delete mode 100644 addons/addon-search/src/SearchResultTracker.ts delete mode 100644 addons/addon-search/src/SearchState.test.ts delete mode 100644 addons/addon-search/src/SearchState.ts diff --git a/addons/addon-search/src/DecorationManager.ts b/addons/addon-search/src/DecorationManager.ts deleted file mode 100644 index 88a67424c..000000000 --- a/addons/addon-search/src/DecorationManager.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * 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 'common/Lifecycle'; -import type { ISearchResult } from './SearchEngine'; - -/** - * Interface for managing a highlight decoration. - */ -interface IHighlight extends IDisposable { - decoration: IDecoration; -} - -/** - * 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); - } - } - } - } - - /** - * 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): void { - this._highlightedLines.add(decoration.marker.line); - this._highlightDecorations.push({ decoration, 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 _getOverviewRulerOptions(line: number, isActiveResult: boolean, options: ISearchDecorationOptions) { - if (this._highlightedLines.has(line)) { - return undefined; - } - const color = isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler; - if (!color) { - return undefined; - } - return { - color, - position: 'center' as const - }; - } - - 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 { - const decorations: IDecoration[] = []; - const cols = this._terminal.cols; - const layer = isActiveResult ? 'top' : 'bottom'; - const backgroundColor = isActiveResult ? options.activeMatchBackground : options.matchBackground; - const borderColor = isActiveResult ? options.activeMatchBorder : options.matchBorder; - 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(cols - currentCol, remainingSize); - const marker = this._terminal.registerMarker(markerOffset); - const decoration = this._terminal.registerDecoration({ - marker, - x: currentCol, - width: amountThisRow, - layer, - backgroundColor, - overviewRulerOptions: this._getOverviewRulerOptions(marker.line, isActiveResult, options) - }); - if (decoration) { - const renderDisposable = decoration.onRender((e) => this._applyStyles(e, borderColor, isActiveResult)); - decoration.onDispose(() => { - marker.dispose(); - renderDisposable.dispose(); - }); - decorations.push(decoration); - } else { - marker.dispose(); - } - currentCol = 0; - remainingSize -= amountThisRow; - markerOffset++; - } - - return decorations.length === 0 ? undefined : decorations; - } -} - - diff --git a/addons/addon-search/src/SearchAddon.ts b/addons/addon-search/src/SearchAddon.ts deleted file mode 100644 index 5f6e2b570..000000000 --- a/addons/addon-search/src/SearchAddon.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm'; -import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search'; -import { Emitter, type IEvent } from 'common/Event'; -import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; -import { disposableTimeout } from '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; -} - -/** - * Configuration constants for the search addon functionality. - */ -const enum Constants { - /** - * 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 - * highlighting decorations. - */ - DEFAULT_HIGHLIGHT_LIMIT = 1000 -} - -export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi { - private _terminal: Terminal | undefined; - private _highlightLimit: number; - private _highlightTimeout = this._register(new MutableDisposable()); - private _lineCache = this._register(new MutableDisposable()); - - // Component instances - private _state = new SearchState(); - private _engine: SearchEngine | undefined; - private _decorationManager: DecorationManager | undefined; - private _resultTracker = this._register(new SearchResultTracker()); - - private readonly _onAfterSearch = this._register(new Emitter()); - public readonly onAfterSearch = this._onAfterSearch.event; - private readonly _onBeforeSearch = this._register(new Emitter()); - public readonly onBeforeSearch = this._onBeforeSearch.event; - - public get onDidChangeResults(): IEvent { - return this._resultTracker.onDidChangeResults; - } - - constructor(options?: Partial) { - super(); - - const limit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT; - this._highlightLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : Constants.DEFAULT_HIGHLIGHT_LIMIT; - } - - 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())); - } - - private _updateMatches(): void { - this._highlightTimeout.clear(); - const lastOptions = this._state.lastSearchOptions; - if (this._state.cachedSearchTerm && lastOptions?.decorations) { - this._highlightTimeout.value = disposableTimeout(() => { - const term = this._state.cachedSearchTerm!; - this._refreshHighlights(term, lastOptions); - }, 200); - } - } - - private _refreshHighlights(term: string, searchOptions: ISearchOptions): void { - if (!this._terminal || !this._engine || !this._decorationManager) { - return; - } - if (!this._state.isValidSearchTerm(term) || !searchOptions.decorations) { - return; - } - this._highlightAllMatches(term, searchOptions); - this._fireResults(searchOptions); - } - - public clearDecorations(retainCachedSearchTerm?: boolean): void { - this._highlightTimeout.clear(); - const hadResults = this._resultTracker.searchResults.length > 0; - this._resultTracker.clearSelectedDecoration(); - this._decorationManager?.clearHighlightDecorations(); - this._resultTracker.clearResults(); - if (!retainCachedSearchTerm) { - this._state.clearCachedTerm(); - this._state.lastSearchOptions = undefined; - } - if (hadResults && this._state.hadDecorations) { - this._resultTracker.fireResultsChanged(true); - } - } - - public clearActiveDecoration(): void { - this._resultTracker.clearSelectedDecoration(); - } - - /** - * Find the next instance of the term, then scroll to and select it. If it - * doesn't exist, do nothing. - * @param term The search term. - * @param searchOptions Search options. - * @returns Whether a result was found. - */ - public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { - if (!this._terminal || !this._engine) { - throw new Error('Cannot use addon until it has been loaded'); - } - - this._highlightTimeout.clear(); - this._onBeforeSearch.fire(); - let found = false; - try { - if (this._state.shouldClearDecorations(searchOptions)) { - this.clearDecorations(true); - } - if (this._state.shouldUpdateHighlighting(term, searchOptions)) { - this._highlightAllMatches(term, searchOptions!); - } - - found = this._findNextAndSelect(term, searchOptions, internalSearchOptions); - this._state.cachedSearchTerm = term; - this._state.lastSearchOptions = searchOptions; - this._fireResults(searchOptions); - } finally { - this._onAfterSearch.fire(); - } - - return found; - } - - private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void { - if (!this._terminal || !this._engine || !this._decorationManager) { - throw new Error('Cannot use addon until it has been loaded'); - } - if (!this._state.isValidSearchTerm(term)) { - this.clearDecorations(); - return; - } - - // new search, clear out the old decorations - this.clearDecorations(true); - - const results = this._engine.collectMatches(term, searchOptions, this._highlightLimit); - - this._resultTracker.updateResults(results, this._highlightLimit); - if (searchOptions.decorations) { - this._decorationManager.createHighlightDecorations(results, searchOptions.decorations); - } - } - - private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { - if (!this._terminal || !this._engine) { - return false; - } - if (!this._state.isValidSearchTerm(term)) { - this._terminal.clearSelection(); - this.clearDecorations(); - return false; - } - - 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. - * @param term The search term. - * @param searchOptions Search options. - * @returns Whether a result was found. - */ - public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { - if (!this._terminal || !this._engine) { - throw new Error('Cannot use addon until it has been loaded'); - } - - this._highlightTimeout.clear(); - this._onBeforeSearch.fire(); - let found = false; - try { - if (this._state.shouldClearDecorations(searchOptions)) { - this.clearDecorations(true); - } - if (this._state.shouldUpdateHighlighting(term, searchOptions)) { - this._highlightAllMatches(term, searchOptions!); - } - - found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions); - this._state.cachedSearchTerm = term; - this._state.lastSearchOptions = searchOptions; - this._fireResults(searchOptions); - } finally { - this._onAfterSearch.fire(); - } - - return found; - } - - private _fireResults(searchOptions?: ISearchOptions): void { - this._resultTracker.fireResultsChanged(!!searchOptions?.decorations); - } - - private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { - if (!this._terminal || !this._engine) { - return false; - } - if (!this._state.isValidSearchTerm(term)) { - this._terminal.clearSelection(); - this.clearDecorations(); - return false; - } - - const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm); - return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll); - } - - /** - * 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 { - if (!this._terminal || !this._decorationManager) { - return false; - } - - this._resultTracker.clearSelectedDecoration(); - if (!result) { - this._terminal.clearSelection(); - return false; - } - - this._terminal.select(result.col, result.row, result.size); - if (options) { - 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 >= (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; - } -} diff --git a/addons/addon-search/src/SearchEngine.test.ts b/addons/addon-search/src/SearchEngine.test.ts deleted file mode 100644 index 2c5cc9543..000000000 --- a/addons/addon-search/src/SearchEngine.test.ts +++ /dev/null @@ -1,725 +0,0 @@ -/** - * 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 '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 later whole word after partial match on same line', async () => { - await writeP(terminal, 'foobar foo'); - - assert.deepStrictEqual(searchEngine.find('foo', 0, 0, { wholeWord: true }), { - term: 'foo', - col: 7, - row: 0, - size: 3 - }); - }); - - 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 - }); - - it('should ignore empty regex matches when searching backwards', async () => { - await writeP(terminal, 'Hello World'); - - assert.strictEqual(searchEngine.findPreviousWithSelection('.*?', { regex: true }), undefined); - }); - }); - - 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 deleted file mode 100644 index 6df9f6d08..000000000 --- a/addons/addon-search/src/SearchEngine.ts +++ /dev/null @@ -1,623 +0,0 @@ -/** - * 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; -} - -interface INoMatchMemo { - term: string; - optionsKey: string; - bufferGeneration: number; - startRow: number; - startCol: number; - isReverse: boolean; -} - -/** - * 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 { - private _noMatchMemo: INoMatchMemo | undefined; - - constructor( - private readonly _terminal: Terminal, - private readonly _lineCache: SearchLineCache - ) {} - - private _searchOptionsKey(searchOptions?: ISearchOptions): string { - return `${searchOptions?.caseSensitive ? 1 : 0}:${searchOptions?.regex ? 1 : 0}:${searchOptions?.wholeWord ? 1 : 0}`; - } - - private _matchesNoMatchMemo(term: string, startRow: number, startCol: number, isReverse: boolean, searchOptions?: ISearchOptions): boolean { - const memo = this._noMatchMemo; - if (!memo) { - return false; - } - return memo.term === term && - memo.optionsKey === this._searchOptionsKey(searchOptions) && - memo.bufferGeneration === this._lineCache.cacheGeneration && - memo.startRow === startRow && - memo.startCol === startCol && - memo.isReverse === isReverse; - } - - private _recordNoMatchMemo(term: string, startRow: number, startCol: number, isReverse: boolean, searchOptions?: ISearchOptions): void { - this._noMatchMemo = { - term, - optionsKey: this._searchOptionsKey(searchOptions), - bufferGeneration: this._lineCache.cacheGeneration, - startRow, - startCol, - isReverse - }; - } - - private _clearNoMatchMemo(): void { - this._noMatchMemo = undefined; - } - - private _createLineSearchRegex(term: string, searchOptions?: ISearchOptions): RegExp | undefined { - if (!searchOptions?.regex) { - return undefined; - } - return RegExp(term, searchOptions.caseSensitive ? 'g' : 'gi'); - } - - /** - * 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(); - if (this._matchesNoMatchMemo(term, startRow, startCol, false, searchOptions)) { - return undefined; - } - const lineSearchRegex = this._createLineSearchRegex(term, searchOptions); - const result = this._findFromPosition(term, startRow, startCol, searchOptions, lineSearchRegex); - if (result) { - this._clearNoMatchMemo(); - } else { - this._recordNoMatchMemo(term, startRow, startCol, false, searchOptions); - } - return result; - } - - /** - * Collect up to `maxResults` matches from the start of the buffer in a single search session. - */ - public collectMatches(term: string, searchOptions?: ISearchOptions, maxResults: number = Number.MAX_SAFE_INTEGER): ISearchResult[] { - if (!term || term.length === 0) { - return []; - } - - this._lineCache.initLinesCache(); - const lineSearchRegex = this._createLineSearchRegex(term, searchOptions); - const results: ISearchResult[] = []; - let startRow = 0; - let startCol = 0; - const cols = this._terminal.cols; - - while (results.length < maxResults) { - const result = this._findFromPosition(term, startRow, startCol, searchOptions, lineSearchRegex); - if (!result) { - break; - } - const prevResult = results[results.length - 1]; - if (prevResult && prevResult.row === result.row && prevResult.col === result.col) { - break; - } - results.push(result); - if (result.col + result.size >= cols) { - startRow = result.row + 1; - startCol = 0; - } else { - startRow = result.row; - startCol = result.col + 1; - } - } - - return results; - } - - private _getBufferEndRow(): number { - return this._terminal.buffer.active.baseY + this._terminal.rows; - } - - private _getPreparedPlainTerm(term: string, searchOptions?: ISearchOptions): string { - return searchOptions?.caseSensitive ? term : term.toLowerCase(); - } - - private _findFromPosition( - term: string, - startRow: number, - startCol: number, - searchOptions: ISearchOptions | undefined, - lineSearchRegex: RegExp | undefined - ): ISearchResult | undefined { - const searchPosition: ISearchPosition = { - startRow, - startCol - }; - const preparedPlainTerm = searchOptions?.regex ? undefined : this._getPreparedPlainTerm(term, searchOptions); - - let result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex, preparedPlainTerm); - if (!result) { - const bufferEnd = this._getBufferEndRow(); - for (let y = startRow + 1; y < bufferEnd; y++) { - searchPosition.startRow = y; - searchPosition.startCol = 0; - result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex, preparedPlainTerm); - 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(); - if (this._matchesNoMatchMemo(term, startRow, startCol, false, searchOptions)) { - return undefined; - } - - const searchPosition: ISearchPosition = { - startRow, - startCol - }; - - const lineSearchRegex = this._createLineSearchRegex(term, searchOptions); - - // Search startRow - let result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex); - // 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, false, lineSearchRegex); - 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, false, lineSearchRegex); - 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, false, lineSearchRegex); - } - - if (result) { - this._clearNoMatchMemo(); - } else { - this._recordNoMatchMemo(term, startRow, startCol, false, 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(); - if (this._matchesNoMatchMemo(term, startRow, startCol, true, searchOptions)) { - return undefined; - } - - const searchPosition: ISearchPosition = { - startRow, - startCol - }; - - const lineSearchRegex = this._createLineSearchRegex(term, searchOptions); - - 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, lineSearchRegex); - 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; - } - } - } - - result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch, lineSearchRegex); - - // 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, lineSearchRegex); - 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, lineSearchRegex); - if (result) { - break; - } - } - } - - if (result) { - this._clearNoMatchMemo(); - } else { - this._recordNoMatchMemo(term, startRow, startCol, true, searchOptions); - } - - 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]))); - } - - /** - * Finds a regex match in a line in a single pass, optionally enforcing whole-word boundaries. - */ - private _findRegexMatch( - searchStringLine: string, - searchRegex: RegExp, - offset: number, - isReverseSearch: boolean, - wholeWord: boolean - ): { resultIndex: number; term: string } | undefined { - let resultIndex = -1; - let matchedTerm = ''; - - if (!isReverseSearch && !wholeWord) { - searchRegex.lastIndex = offset; - const foundTerm = searchRegex.exec(searchStringLine); - if (foundTerm && foundTerm[0].length > 0) { - return { - resultIndex: searchRegex.lastIndex - foundTerm[0].length, - term: foundTerm[0] - }; - } - return undefined; - } - - searchRegex.lastIndex = 0; - if (isReverseSearch) { - let foundTerm: RegExpExecArray | null; - while (foundTerm = searchRegex.exec(searchStringLine)) { - if (foundTerm[0].length === 0) { - if (searchRegex.lastIndex >= searchStringLine.length) { - break; - } - searchRegex.lastIndex++; - continue; - } - const candidateIndex = searchRegex.lastIndex - foundTerm[0].length; - if (candidateIndex + foundTerm[0].length > offset) { - break; - } - if (!wholeWord || this._isWholeWord(candidateIndex, searchStringLine, foundTerm[0])) { - resultIndex = candidateIndex; - matchedTerm = foundTerm[0]; - } - searchRegex.lastIndex -= (foundTerm[0].length - 1); - } - } else { - searchRegex.lastIndex = offset; - let foundTerm: RegExpExecArray | null; - while (foundTerm = searchRegex.exec(searchStringLine)) { - if (foundTerm[0].length === 0) { - if (searchRegex.lastIndex >= searchStringLine.length) { - break; - } - searchRegex.lastIndex++; - continue; - } - const candidateIndex = searchRegex.lastIndex - foundTerm[0].length; - if (this._isWholeWord(candidateIndex, searchStringLine, foundTerm[0])) { - resultIndex = candidateIndex; - matchedTerm = foundTerm[0]; - break; - } - } - } - - if (resultIndex < 0) { - return undefined; - } - return { resultIndex, term: matchedTerm }; - } - - /** - * 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, lineSearchRegex?: RegExp, preparedPlainTerm?: string): ISearchResult | undefined { - const row = searchPosition.startRow; - const col = searchPosition.startCol; - const cols = this._terminal.cols; - - // 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 += 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 += cols; - return this._findInLine(term, searchPosition, searchOptions, isReverseSearch, lineSearchRegex, preparedPlainTerm); - } - 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 = preparedPlainTerm ?? this._getPreparedPlainTerm(term, searchOptions); - if (searchOptions.caseSensitive) { - searchStringLine = stringLine; - } else { - let lowerLine = cache[2]; - if (!lowerLine) { - lowerLine = stringLine.toLowerCase(); - cache[2] = lowerLine; - } - searchStringLine = lowerLine; - } - } - - let resultIndex = -1; - if (searchOptions.regex) { - const searchRegex = lineSearchRegex ?? RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi'); - const regexMatch = this._findRegexMatch( - searchStringLine, - searchRegex, - offset, - isReverseSearch, - !!searchOptions.wholeWord - ); - if (regexMatch) { - resultIndex = regexMatch.resultIndex; - term = regexMatch.term; - } - } else if (isReverseSearch) { - let searchEnd = offset - searchTerm.length; - while (searchEnd >= 0) { - resultIndex = searchStringLine.lastIndexOf(searchTerm, searchEnd); - if (resultIndex < 0) { - break; - } - if (!searchOptions.wholeWord || this._isWholeWord(resultIndex, searchStringLine, searchTerm)) { - break; - } - searchEnd = resultIndex - 1; - resultIndex = -1; - } - } else if (!searchOptions.wholeWord) { - resultIndex = searchStringLine.indexOf(searchTerm, offset); - } else { - let searchStart = offset; - while (searchStart <= searchStringLine.length - searchTerm.length) { - resultIndex = searchStringLine.indexOf(searchTerm, searchStart); - if (resultIndex < 0) { - break; - } - if (this._isWholeWord(resultIndex, searchStringLine, searchTerm)) { - break; - } - searchStart = resultIndex + 1; - resultIndex = -1; - } - } - - if (resultIndex >= 0) { - // 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 + 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 deleted file mode 100644 index c8a39f49b..000000000 --- a/addons/addon-search/src/SearchLineCache.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * 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 '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 lazily cache lowercased line text via SearchEngine usage', async () => { - await writeP(terminal, 'Hello WORLD'); - cache.initLinesCache(); - const entry = cache.translateBufferLineToStringWithWrap(0, true); - cache.setLineInCache(0, entry); - assert.equal(entry[2], undefined); - entry[2] = entry[0].toLowerCase(); - cache.setLineInCache(0, entry); - assert.strictEqual(cache.getLineFromCache(0)?.[2], 'hello world'); - }); - - 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 deleted file mode 100644 index a8a4a2ede..000000000 --- a/addons/addon-search/src/SearchLineCache.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import type { Terminal } from '@xterm/xterm'; -import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; -import { disposableTimeout } from 'common/Async'; - -export type LineCacheEntry = [ - /** - * The string representation of a line (as opposed to the buffer cell representation). - */ - lineAsString: string, - /** - * The offsets where each line starts when the entry describes a wrapped line. - */ - lineOffsets: number[], - /** - * Lowercased line text for case-insensitive searches, lazily populated. - */ - lineAsLower?: string -]; - -/** - * Configuration constants for the search line cache functionality. - */ -const enum Constants { - /** - * Time-to-live for cached search results in milliseconds. After this duration, cached search - * results will be invalidated to ensure they remain consistent with terminal content changes. - */ - LINES_CACHE_TIME_TO_LIVE = 15000 -} - -export class SearchLineCache extends Disposable { - /** - * translateBufferLineToStringWithWrap is a fairly expensive call. - * We memoize the calls into an array that has a time based ttl. - * _linesCache is also invalidated when the terminal cursor moves. - */ - private _linesCache: LineCacheEntry[] | undefined; - private _cacheGeneration = 0; - private _linesCacheTimeout = this._register(new MutableDisposable()); - private _linesCacheDisposables = this._register(new MutableDisposable()); - // Track access to avoid recreating a timeout on every init call which occurs once per search - // result (findNext/findPrevious -> _highlightAllMatches -> find loop). - private _lastAccessTimestamp = 0; - - constructor(private readonly _terminal: Terminal) { - super(); - this._register(toDisposable(() => this._destroyLinesCache())); - } - - /** - * Sets up a line cache with a ttl - */ - public initLinesCache(): void { - if (!this._linesCache) { - this._linesCache = new Array(this._terminal.buffer.active.length); - this._linesCacheDisposables.value = combinedDisposable( - this._terminal.onLineFeed(() => this._destroyLinesCache()), - this._terminal.onCursorMove(() => this._destroyLinesCache()), - this._terminal.onResize(() => this._destroyLinesCache()) - ); - } - - this._lastAccessTimestamp = Date.now(); - if (!this._linesCacheTimeout.value) { - this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE); - } - } - - public get cacheGeneration(): number { - return this._cacheGeneration; - } - - private _destroyLinesCache(): void { - this._cacheGeneration++; - this._linesCache = undefined; - this._lastAccessTimestamp = 0; - this._linesCacheDisposables.clear(); - this._linesCacheTimeout.clear(); - } - - private _scheduleLinesCacheTimeout(delay: number): void { - this._linesCacheTimeout.value = disposableTimeout(() => { - if (!this._linesCache) { - return; - } - const now = Date.now(); - const elapsed = now - this._lastAccessTimestamp; - if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) { - this._destroyLinesCache(); - return; - } - this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed); - }, delay); - } - - public getLineFromCache(row: number): LineCacheEntry | undefined { - return this._linesCache?.[row]; - } - - public setLineInCache(row: number, entry: LineCacheEntry): void { - if (this._linesCache) { - this._linesCache[row] = entry; - } - } - - /** - * Translates a buffer line to a string, including subsequent lines if they are wraps. - * Wide characters will count as two columns in the resulting string. This - * function is useful for getting the actual text underneath the raw selection - * position. - * @param lineIndex The index of the line being translated. - * @param trimRight Whether to trim whitespace to the right. - */ - public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry { - const strings = []; - const lineOffsets = [0]; - let line = this._terminal.buffer.active.getLine(lineIndex); - while (line) { - const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1); - const lineWrapsToNext = nextLine ? nextLine.isWrapped : false; - let string = line.translateToString(!lineWrapsToNext && trimRight); - if (lineWrapsToNext && nextLine) { - const lastCell = line.getCell(line.length - 1); - const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1; - // a wide character wrapped to the next line - if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) { - string = string.slice(0, -1); - } - } - strings.push(string); - if (lineWrapsToNext) { - lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length); - } else { - break; - } - lineIndex++; - line = nextLine; - } - return [strings.join(''), lineOffsets]; - } -} diff --git a/addons/addon-search/src/SearchResultTracker.test.ts b/addons/addon-search/src/SearchResultTracker.test.ts deleted file mode 100644 index 68133fd32..000000000 --- a/addons/addon-search/src/SearchResultTracker.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) 2026 The xterm.js authors. All rights reserved. - * @license MIT - */ -import { assert } from 'chai'; -import { SearchResultTracker } from './SearchResultTracker'; -import type { ISearchResult } from './SearchEngine'; - -describe('SearchResultTracker', () => { - let tracker: SearchResultTracker; - - beforeEach(() => { - tracker = new SearchResultTracker(); - }); - - afterEach(() => { - tracker.dispose(); - }); - - describe('updateResults', () => { - it('should store results without copying when within limit', () => { - const results: ISearchResult[] = [{ term: 'a', row: 0, col: 0, size: 1 }]; - tracker.updateResults(results, 10); - assert.strictEqual(tracker.searchResults, results); - }); - - it('should cap results at maxResults', () => { - const results: ISearchResult[] = [ - { term: 'a', row: 0, col: 0, size: 1 }, - { term: 'b', row: 0, col: 1, size: 1 }, - { term: 'c', row: 0, col: 2, size: 1 } - ]; - tracker.updateResults(results, 2); - assert.strictEqual(tracker.searchResults.length, 2); - }); - }); - - describe('findResultIndex', () => { - it('should return the index of a tracked result', () => { - const results: ISearchResult[] = [ - { term: 'a', row: 0, col: 0, size: 1 }, - { term: 'b', row: 0, col: 2, size: 1 } - ]; - tracker.updateResults(results, 10); - assert.strictEqual(tracker.findResultIndex(results[1]), 1); - }); - - it('should return -1 when the result is not tracked', () => { - tracker.updateResults([{ term: 'a', row: 0, col: 0, size: 1 }], 10); - assert.strictEqual(tracker.findResultIndex({ term: 'z', row: 1, col: 0, size: 1 }), -1); - }); - }); - - describe('fireResultsChanged', () => { - it('should not fire when decorations are disabled', () => { - let fired = false; - tracker.onDidChangeResults(() => fired = true); - tracker.updateResults([{ term: 'a', row: 0, col: 0, size: 1 }], 10); - tracker.fireResultsChanged(false); - assert.strictEqual(fired, false); - }); - }); - - describe('selectedDecoration', () => { - it('should dispose the previous decoration when replaced', () => { - let disposeCount = 0; - const first = { match: { term: 'a', row: 0, col: 0, size: 1 }, dispose: () => disposeCount++ }; - const second = { match: { term: 'b', row: 0, col: 1, size: 1 }, dispose: () => {} }; - tracker.selectedDecoration = first; - tracker.selectedDecoration = second; - assert.strictEqual(disposeCount, 1); - }); - }); -}); diff --git a/addons/addon-search/src/SearchResultTracker.ts b/addons/addon-search/src/SearchResultTracker.ts deleted file mode 100644 index 7747909df..000000000 --- a/addons/addon-search/src/SearchResultTracker.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 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, type IEvent } from 'common/Event'; -import { Disposable, MutableDisposable } from '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 _resultIndexByKey = new Map(); - private _selectedDecoration = this._register(new MutableDisposable()); - - private readonly _onDidChangeResults = this._register(new Emitter()); - public get onDidChangeResults(): IEvent { 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.value; - } - - /** - * Sets the currently selected decoration. - */ - public set selectedDecoration(decoration: ISelectedDecoration | undefined) { - this._selectedDecoration.value = decoration; - } - - private _resultKey(result: ISearchResult): string { - return `${result.row}:${result.col}:${result.size}`; - } - - private _rebuildResultIndex(): void { - this._resultIndexByKey.clear(); - for (let i = 0; i < this._searchResults.length; i++) { - this._resultIndexByKey.set(this._resultKey(this._searchResults[i]), i); - } - } - - /** - * 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.length <= maxResults ? results : results.slice(0, maxResults); - this._rebuildResultIndex(); - } - - /** - * Clears all search results. - */ - public clearResults(): void { - this._searchResults = []; - this._resultIndexByKey.clear(); - } - - /** - * Clears the selected decoration. - */ - public clearSelectedDecoration(): void { - this._selectedDecoration.clear(); - } - - /** - * 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 { - return this._resultIndexByKey.get(this._resultKey(result)) ?? -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.value) { - resultIndex = this.findResultIndex(this._selectedDecoration.value.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.test.ts b/addons/addon-search/src/SearchState.test.ts deleted file mode 100644 index 01708a0e3..000000000 --- a/addons/addon-search/src/SearchState.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright (c) 2026 The xterm.js authors. All rights reserved. - * @license MIT - */ -import { assert } from 'chai'; -import { SearchState } from './SearchState'; -import type { ISearchOptions } from '@xterm/addon-search'; - -const DECORATIONS_A: ISearchOptions = { - decorations: { - activeMatchColorOverviewRuler: '#ff0000', - matchOverviewRuler: '#00ffff' - } -}; - -const DECORATIONS_B: ISearchOptions = { - decorations: { - activeMatchColorOverviewRuler: '#00ff00', - matchOverviewRuler: '#00ffff' - } -}; - -describe('SearchState', () => { - let state: SearchState; - - beforeEach(() => { - state = new SearchState(); - }); - - describe('didOptionsChange', () => { - it('should detect caseSensitive changes', () => { - state.lastSearchOptions = { caseSensitive: false }; - assert.strictEqual(state.didOptionsChange({ caseSensitive: true }), true); - }); - - it('should detect decoration option changes', () => { - state.lastSearchOptions = DECORATIONS_A; - assert.strictEqual(state.didOptionsChange(DECORATIONS_B), true); - }); - - it('should return false when semantic options are unchanged', () => { - const options: ISearchOptions = { caseSensitive: false, regex: false, wholeWord: false }; - state.lastSearchOptions = options; - assert.strictEqual(state.didOptionsChange({ caseSensitive: false, regex: false, wholeWord: false }), false); - }); - - it('should treat omitted flags as equivalent to false', () => { - state.lastSearchOptions = { caseSensitive: false, regex: false, wholeWord: false }; - assert.strictEqual(state.didOptionsChange({}), false); - }); - }); - - describe('shouldClearDecorations', () => { - it('should clear when decorations are disabled after being enabled', () => { - state.lastSearchOptions = DECORATIONS_A; - assert.strictEqual(state.shouldClearDecorations({ caseSensitive: false }), true); - }); - - it('should not clear when decorations remain enabled', () => { - state.lastSearchOptions = DECORATIONS_A; - assert.strictEqual(state.shouldClearDecorations(DECORATIONS_A), false); - }); - }); - - describe('shouldUpdateHighlighting', () => { - it('should update when options change for the same term', () => { - state.cachedSearchTerm = 'test'; - state.lastSearchOptions = { caseSensitive: false, decorations: DECORATIONS_A.decorations }; - assert.strictEqual(state.shouldUpdateHighlighting('test', { caseSensitive: true, decorations: DECORATIONS_A.decorations }), true); - }); - - it('should not update without decorations', () => { - state.cachedSearchTerm = 'test'; - state.lastSearchOptions = { caseSensitive: false }; - assert.strictEqual(state.shouldUpdateHighlighting('test', { caseSensitive: true }), false); - }); - }); -}); diff --git a/addons/addon-search/src/SearchState.ts b/addons/addon-search/src/SearchState.ts deleted file mode 100644 index f966d9d2d..000000000 --- a/addons/addon-search/src/SearchState.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import type { ISearchDecorationOptions, ISearchOptions } from '@xterm/addon-search'; - -interface INormalizedSearchOptions { - caseSensitive: boolean; - regex: boolean; - wholeWord: boolean; - incremental: boolean; - decorations?: ISearchDecorationOptions; -} - -/** - * 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: INormalizedSearchOptions | undefined; - private _hadDecorations: boolean = false; - - /** - * 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._toPublicOptions(this._lastSearchOptions); - } - - /** - * Sets the last search options used. - */ - public set lastSearchOptions(options: ISearchOptions | undefined) { - this._lastSearchOptions = options ? this._normalizeOptions(options) : undefined; - this._hadDecorations = !!this._lastSearchOptions?.decorations; - } - - /** - * Whether the previous search used decorations. - */ - public get hadDecorations(): boolean { - return this._hadDecorations; - } - - /** - * 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; - } - const normalized = this._normalizeOptions(newOptions); - if (this._lastSearchOptions.caseSensitive !== normalized.caseSensitive) { - return true; - } - if (this._lastSearchOptions.regex !== normalized.regex) { - return true; - } - if (this._lastSearchOptions.wholeWord !== normalized.wholeWord) { - return true; - } - if (this._didDecorationOptionsChange(this._lastSearchOptions.decorations, normalized.decorations)) { - return true; - } - return false; - } - - private _normalizeOptions(options: ISearchOptions): INormalizedSearchOptions { - return { - caseSensitive: !!options.caseSensitive, - regex: !!options.regex, - wholeWord: !!options.wholeWord, - incremental: !!options.incremental, - decorations: options.decorations ? { ...options.decorations } : undefined - }; - } - - private _toPublicOptions(options?: INormalizedSearchOptions): ISearchOptions | undefined { - if (!options) { - return undefined; - } - return { - caseSensitive: options.caseSensitive, - regex: options.regex, - wholeWord: options.wholeWord, - incremental: options.incremental, - decorations: options.decorations ? { ...options.decorations } : undefined - }; - } - - private _didDecorationOptionsChange(previous?: ISearchDecorationOptions, current?: ISearchDecorationOptions): boolean { - if (!previous && !current) { - return false; - } - if (!previous || !current) { - return true; - } - return previous.matchBackground !== current.matchBackground || - previous.matchBorder !== current.matchBorder || - previous.matchOverviewRuler !== current.matchOverviewRuler || - previous.activeMatchBackground !== current.activeMatchBackground || - previous.activeMatchBorder !== current.activeMatchBorder || - previous.activeMatchColorOverviewRuler !== current.activeMatchColorOverviewRuler; - } - - /** - * 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); - } - - /** - * Whether decorations were disabled after previously being enabled. - */ - public shouldClearDecorations(options?: ISearchOptions): boolean { - return this._hadDecorations && !options?.decorations; - } - - /** - * Clears the cached search term. - */ - public clearCachedTerm(): void { - this._cachedSearchTerm = undefined; - } - - /** - * Resets all state. - */ - public reset(): void { - this._cachedSearchTerm = undefined; - this._lastSearchOptions = undefined; - this._hadDecorations = false; - } -}