diff --git a/.gitignore b/.gitignore index 81fe7a1478..32e8955641 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .lock-wscript lib/ out/ +out-benchmark/ out-demo/ out-test/ out-esbuild/ diff --git a/addons/addon-search/.gitignore b/addons/addon-search/.gitignore index a9f4ed5456..a4d41ad6ae 100644 --- a/addons/addon-search/.gitignore +++ b/addons/addon-search/.gitignore @@ -1,2 +1,3 @@ lib -node_modules \ No newline at end of file +node_modules +out-benchmark \ No newline at end of file diff --git a/addons/addon-search/README.md b/addons/addon-search/README.md index fab0b03f59..88982b2efa 100644 --- a/addons/addon-search/README.md +++ b/addons/addon-search/README.md @@ -20,4 +20,13 @@ terminal.loadAddon(searchAddon); searchAddon.findNext('foo'); ``` +### Search options + +- `decorations` (`ISearchDecorationOptions`): Enables match highlighting and result tracking; pass a decoration options object (colors may be omitted). +- `highlightLimit` (constructor option): Caps how many matches are tracked/highlighted (default `1000`). +- `incremental`: Expands the current selection when the term still matches. +- `wholeWord`, `regex`, `caseSensitive`: Control match semantics. + +`onDidChangeResults` fires after `findNext`/`findPrevious` when `decorations` is set. `clearDecorations()` clears highlights and tracked results but does not emit this event. + See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-search/typings/addon-search.d.ts) for more advanced usage. diff --git a/addons/addon-search/benchmark/SearchAddon.benchmark.ts b/addons/addon-search/benchmark/SearchAddon.benchmark.ts new file mode 100644 index 0000000000..4dbf21a5a1 --- /dev/null +++ b/addons/addon-search/benchmark/SearchAddon.benchmark.ts @@ -0,0 +1,284 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { Terminal } from 'browser/public/Terminal'; +import { SearchAddon } from 'SearchAddon'; +import type { ISearchOptions } from '@xterm/addon-search'; + +const enum Constants { + COLS = 120, + ROWS = 40, + SCROLLBACK = 8000, + NAVIGATION_ITERATIONS = 250, + COLD_CACHE_ITERATIONS = 75, + INCREMENTAL_ITERATIONS = 75, + DECORATION_REFRESH_ITERATIONS = 25 +} + +const DECORATION_OPTIONS: ISearchOptions = { + decorations: { + activeMatchBackground: '#0000ff', + activeMatchColorOverviewRuler: '#ff0000', + matchBackground: '#ffff00', + matchBorder: '#000000', + matchOverviewRuler: '#00ffff' + } +}; + +class TestTerminal extends Terminal { + public writeSync(data: string): void { + (this as any)._core.writeSync(data); + } + + public override select(_column: number, _row: number, _length: number): void { + // SearchAddon always selects matches; skip selection internals in headless benchmark runs. + } + + public override clearSelection(): void { + // SearchAddon clears the previous selection before selecting another match. + } + + public override scrollLines(_amount: number): void { + // No viewport scrolling is needed for API runtime measurement. + } +} + +function buildRealWorldBuffer(): string { + const fixture = readFileSync(resolve(__dirname, '../../fixtures/issue-2444'), 'utf8') + .replace(/\r?\n/g, '\r\n'); + const lines: string[] = [fixture]; + for (let i = 0; i < 2500; i++) { + const minute = (i % 60).toString().padStart(2, '0'); + const second = ((i * 7) % 60).toString().padStart(2, '0'); + lines.push(`2026-05-25T16:${minute}:${second}Z INFO [api-${i % 8}] requestId=req-${i.toString(16).padStart(6, '0')} path=/v1/projects/${i % 120}/search?q=opencv status=200 durationMs=${10 + (i % 90)}`); + if (i % 5 === 0) { + lines.push(`2026-05-25T16:${minute}:${second}Z WARN [worker-${i % 4}] slow query detected query="SELECT * FROM runs WHERE status='pending'" elapsedMs=${500 + (i % 1200)}`); + } + if (i % 11 === 0) { + lines.push(`2026-05-25T16:${minute}:${second}Z ERROR [worker-${i % 4}] command failed: package.json parse error at column ${10 + (i % 70)}; retrying in ${2 + (i % 7)}s`); + } + if (i % 17 === 0) { + lines.push(`https://example.internal/run/${i}/details?source=terminal-search&state=error`); + } + } + return lines.join('\r\n') + '\r\n'; +} + +perfContext('SearchAddon API on real-world terminal content', () => { + const bufferContent = buildRealWorldBuffer(); + + perfContext('findNext/plain navigation', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findNext('opencv')) { + foundCount++; + } + } + if (foundCount !== Constants.NAVIGATION_ITERATIONS) { + throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`); + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/no match full scan', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findNext('zzzznotfoundzzzz')) { + foundCount++; + } + } + if (foundCount !== 0) { + throw new Error(`Expected 0 matches, got ${foundCount}`); + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/case insensitive navigation', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findNext('OPENCV', { caseSensitive: false })) { + foundCount++; + } + } + if (foundCount !== Constants.NAVIGATION_ITERATIONS) { + throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`); + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/regex navigation', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findNext('https://example\\.internal/run/\\d+/details\\?source=terminal-search', { regex: true })) { + foundCount++; + } + } + if (foundCount !== Constants.NAVIGATION_ITERATIONS) { + throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`); + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findPrevious/incremental typing flow', () => { + let terminal: TestTerminal; + let search: SearchAddon; + const terms = ['e', 'er', 'err', 'erro', 'error']; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.INCREMENTAL_ITERATIONS; i++) { + for (const term of terms) { + if (search.findPrevious(term, { incremental: true })) { + foundCount++; + } + } + } + return { payloadSize: Constants.INCREMENTAL_ITERATIONS * terms.length, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/cold cache', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.COLD_CACHE_ITERATIONS; i++) { + // Force a fresh logical-line reconstruction and match scan each search. + (search as any)._clearMatchCache(); + if (search.findNext('opencv')) { + foundCount++; + } + } + if (foundCount !== Constants.COLD_CACHE_ITERATIONS) { + throw new Error(`Expected ${Constants.COLD_CACHE_ITERATIONS} matches, got ${foundCount}`); + } + return { payloadSize: Constants.COLD_CACHE_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/wholeWord dense-punctuation', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findNext('error', { wholeWord: true })) { + foundCount++; + } + } + if (foundCount !== Constants.NAVIGATION_ITERATIONS) { + throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`); + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findPrevious/regex wholeWord reverse', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon(); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) { + if (search.findPrevious('error', { regex: true, wholeWord: true })) { + foundCount++; + } + } + if (foundCount !== Constants.NAVIGATION_ITERATIONS) { + throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`); + } + return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('findNext/decorations refresh', () => { + let terminal: TestTerminal; + let search: SearchAddon; + before(() => { + terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK }); + search = new SearchAddon({ highlightLimit: 1500 }); + search.activate(terminal); + terminal.writeSync(bufferContent); + }); + new RuntimeCase('', () => { + let foundCount = 0; + for (let i = 0; i < Constants.DECORATION_REFRESH_ITERATIONS; i++) { + const term = i % 2 === 0 ? 'WARN' : 'ERROR'; + if (search.findNext(term, DECORATION_OPTIONS)) { + foundCount++; + } + } + if (foundCount !== Constants.DECORATION_REFRESH_ITERATIONS) { + throw new Error(`Expected ${Constants.DECORATION_REFRESH_ITERATIONS} matches, got ${foundCount}`); + } + return { payloadSize: Constants.DECORATION_REFRESH_ITERATIONS, foundCount }; + }, { fork: false }).showAverageRuntime(); + }); +}); diff --git a/addons/addon-search/benchmark/benchmark.json b/addons/addon-search/benchmark/benchmark.json new file mode 100644 index 0000000000..6b71937cde --- /dev/null +++ b/addons/addon-search/benchmark/benchmark.json @@ -0,0 +1,13 @@ +{ + "APP_PATH": ".benchmark", + "evalConfig": { + "tolerance": { + "*": [0.9, 1.2], + "SearchAddon.benchmark.js.*.averageRuntime.mean": [0.93, 1.12] + }, + "skip": [ + "*.median", + "*.runs" + ] + } +} diff --git a/addons/addon-search/benchmark/tsconfig.json b/addons/addon-search/benchmark/tsconfig.json new file mode 100644 index 0000000000..58afd6f948 --- /dev/null +++ b/addons/addon-search/benchmark/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "es2021" + ], + "rootDir": "..", + "outDir": "../out-benchmark", + "types": ["../../../node_modules/@types/node"], + "moduleResolution": "node", + "strict": false, + "target": "es2021", + "module": "commonjs", + "paths": { + "common/*": ["../../../src/common/*"], + "browser/*": ["../../../src/browser/*"], + "SearchAddon": ["../src/SearchAddon"], + "@xterm/addon-search": [ + "../typings/addon-search.d.ts" + ] + } + }, + "include": ["../**/*", "../../../typings/xterm.d.ts"], + "exclude": ["../../../**/*test.ts", "../../**/*api.ts"], + "references": [ + { "path": "../../../src/common" }, + { "path": "../../../src/browser" } + ] +} diff --git a/addons/addon-search/package.json b/addons/addon-search/package.json index ebec5a8b8d..70b2d5e5a4 100644 --- a/addons/addon-search/package.json +++ b/addons/addon-search/package.json @@ -19,6 +19,9 @@ "prepackage": "../../node_modules/.bin/tsgo -p .", "package": "../../node_modules/.bin/webpack", "prepublishOnly": "npm run package", - "start": "node ../../demo/start" + "start": "node ../../demo/start", + "benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js", + "benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js", + "benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --eval -F out-benchmark/benchmark/*benchmark.js" } } diff --git a/addons/addon-search/src/DecorationManager.ts b/addons/addon-search/src/DecorationManager.ts deleted file mode 100644 index a8ec12017a..0000000000 --- a/addons/addon-search/src/DecorationManager.ts +++ /dev/null @@ -1,158 +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; - 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], - layer: isActiveResult ? 'top' : 'bottom', - 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 cb40947430..d25d0e059a 100644 --- a/addons/addon-search/src/SearchAddon.ts +++ b/addons/addon-search/src/SearchAddon.ts @@ -3,252 +3,1153 @@ * @license MIT */ -import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm'; +import type { Terminal, IDisposable, ITerminalAddon, IDecoration } 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 _highlightLimit = SearchAddonConstants.DEFAULT_HIGHLIGHT_LIMIT; + 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 _onBeforeSearch = this._register(new Emitter()); + public readonly onBeforeSearch: IEvent = this._onBeforeSearch.event; 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 readonly onAfterSearch: IEvent = this._onAfterSearch.event; - public get onDidChangeResults(): IEvent { - return this._resultTracker.onDidChangeResults; - } + private readonly _onDidChangeResults = this._register(new Emitter()); + public readonly onDidChangeResults: IEvent = this._onDidChangeResults.event; + + private readonly _bufferUpdateListener = this._register(new MutableDisposable()); + private readonly _resizeListener = this._register(new MutableDisposable()); + private readonly _resultsUpdateListener = this._register(new MutableDisposable()); + + private readonly _matchDecorationSetByMatches = new WeakMap(); + private readonly _matchDecorationSetOrder: IMatchDecorationSet[] = []; + private _activeMatchDecorationSet: IMatchDecorationSet | undefined; + private readonly _activeDecoration = this._register(new MutableDisposable()); + + private _lastSearchTerm: string | undefined; + private _lastSearchOptions: ISearchOptions | undefined; + private _lastSearchKey: string | undefined; + private _lastSuccessfulNonDecoratedSearchKey: string | undefined; + private _resultCount = 0; + private _resultIndex = -1; + private _pendingResultRefresh = false; + private readonly _matchCache = new Map>>(); + private readonly _matchCacheOrder: IMatchCacheKey[] = []; + private _logicalLineCache: ILogicalLineCache | undefined; + private readonly _selectionIndexCache = new WeakMap>>(); + private _lastResolvedNavigation: IResolvedNavigation | undefined; constructor(options?: Partial) { super(); - - this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT; + if (typeof options?.highlightLimit === 'number' && Number.isFinite(options.highlightLimit) && options.highlightLimit > 0) { + this._highlightLimit = Math.floor(options.highlightLimit); + } } 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())); + this._clearMatchCache(); + this._bufferUpdateListener.value = terminal.onWriteParsed(() => { + this._clearMatchCache(); + }); + this._resizeListener.value = terminal.onResize(() => { + this._clearMatchCache(); + }); + } + + public findNext(term: string, searchOptions?: ISearchOptions): boolean { + return this._find(term, searchOptions, 'next'); + } + + public findPrevious(term: string, searchOptions?: ISearchOptions): boolean { + return this._find(term, searchOptions, 'previous'); + } + + public clearDecorations(): void { + this._disposeDecorations(); + this._resultsUpdateListener.clear(); + this._resultCount = 0; + this._resultIndex = -1; } - private _updateMatches(): void { - this._highlightTimeout.clear(); - if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) { - this._highlightTimeout.value = disposableTimeout(() => { - const term = this._state.cachedSearchTerm; - this._state.clearCachedTerm(); - this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true }); - }, 200); + public clearActiveDecoration(): void { + this._activeDecoration.clear(); + } + + private _find(term: string, searchOptions: ISearchOptions | undefined, direction: SearchDirection): boolean { + this._onBeforeSearch.fire(); + try { + const terminal = this._terminal; + if (!terminal || term.length === 0) { + this._clearStateOnFailedSearch(term, searchOptions, direction); + return false; + } + const currentSearchKey = this._createSearchKey(term, searchOptions, direction); + + if (!searchOptions?.decorations && !searchOptions?.incremental && direction === 'next' && !this._logicalLineCache && this._lastSuccessfulNonDecoratedSearchKey === currentSearchKey) { + const activeMatch = this._findSingleMatchForNext(term, searchOptions); + if (!activeMatch) { + this._clearStateOnFailedSearch(term, searchOptions, direction); + return false; + } + terminal.select(activeMatch.startX, activeMatch.startY, activeMatch.cellLength); + this._revealResult(activeMatch); + this._lastSuccessfulNonDecoratedSearchKey = currentSearchKey; + this._clearSearchState(); + return true; + } + + const matches = this._getMatches(term, searchOptions); + if (matches === undefined || matches.length === 0) { + this._clearStateOnFailedSearch(term, searchOptions, direction); + return false; + } + + const searchKey = currentSearchKey; + const nextIndex = this._resolveResultIndex(matches, searchOptions, direction, searchKey); + const activeMatch = this._getActiveMatch(term, searchOptions, direction, matches, nextIndex); + terminal.select(activeMatch.startX, activeMatch.startY, activeMatch.cellLength); + this._revealResult(activeMatch); + const resultIndex = this._findIndexFromSelection(matches); + this._lastResolvedNavigation = { + searchKey, + matches, + index: resultIndex === -1 ? nextIndex : resultIndex, + selectionStartX: activeMatch.startX, + selectionStartY: activeMatch.startY + }; + + if (searchOptions?.decorations) { + this._setDecoratedSearchState(term, searchOptions, searchKey, matches.length, resultIndex); + this._registerResultRefreshListener(); + this._refreshDecorations(matches, searchOptions.decorations, activeMatch); + this._onDidChangeResults.fire({ resultCount: this._resultCount, resultIndex: this._resultIndex }); + } else { + this._lastSuccessfulNonDecoratedSearchKey = searchKey; + this._clearSearchState(); + } + + return true; + } finally { + this._onAfterSearch.fire(); } } - public clearDecorations(retainCachedSearchTerm?: boolean): void { - this._resultTracker.clearSelectedDecoration(); - this._decorationManager?.clearHighlightDecorations(); - this._resultTracker.clearResults(); - if (!retainCachedSearchTerm) { - this._state.clearCachedTerm(); + private _clearStateOnFailedSearch(term: string, searchOptions: ISearchOptions | undefined, direction: SearchDirection): void { + this._lastSuccessfulNonDecoratedSearchKey = undefined; + this._lastResolvedNavigation = undefined; + this._terminal?.clearSelection(); + if (searchOptions?.decorations) { + this._setDecoratedSearchState(term, searchOptions, this._createSearchKey(term, searchOptions, direction), 0, -1); + this._registerResultRefreshListener(); + this._disposeDecorations(); + this._onDidChangeResults.fire({ resultCount: 0, resultIndex: -1 }); + return; + } + if ( + this._resultCount === 0 && + this._resultIndex === -1 && + this._lastSearchTerm === undefined && + this._lastSearchOptions === undefined && + this._lastSearchKey === undefined && + this._matchDecorationSetOrder.length === 0 && + !this._activeDecoration.value && + !this._resultsUpdateListener.value + ) { + return; } + this._clearSearchState(); } - public clearActiveDecoration(): void { - this._resultTracker.clearSelectedDecoration(); + private _registerResultRefreshListener(): void { + if (this._resultsUpdateListener.value) { + return; + } + const terminal = this._terminal; + if (!terminal) { + return; + } + this._resultsUpdateListener.value = terminal.onWriteParsed(() => { + if (this._pendingResultRefresh) { + return; + } + this._pendingResultRefresh = true; + queueMicrotask(() => { + this._pendingResultRefresh = false; + this._refreshResultsAfterBufferChange(); + }); + }); } - /** - * 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'); + private _refreshResultsAfterBufferChange(): void { + const terminal = this._terminal; + if (!terminal || !this._lastSearchTerm || !this._lastSearchOptions?.decorations) { + return; + } + const matches = this._getMatches(this._lastSearchTerm, this._lastSearchOptions); + if (!matches || matches.length === 0) { + this._disposeDecorations(); + this._resultCount = 0; + this._resultIndex = -1; + this._onDidChangeResults.fire({ resultCount: 0, resultIndex: -1 }); + return; } - this._onBeforeSearch.fire(); + this._resultCount = matches.length; + this._resultIndex = this._findIndexFromSelection(matches); + const activeMatch = this._resultIndex === -1 ? undefined : matches[this._resultIndex]; + this._refreshDecorations(matches, this._lastSearchOptions.decorations, activeMatch); + this._onDidChangeResults.fire({ resultCount: this._resultCount, resultIndex: this._resultIndex }); + } + + private _findAllMatches(term: string, searchOptions?: ISearchOptions): IMatch[] | undefined { + const terminal = this._terminal; + if (!terminal) { + return undefined; + } + const isRegex = !!searchOptions?.regex; + const regex = isRegex ? this._buildRegex(term, searchOptions) : undefined; + if (isRegex && !regex) { + return undefined; + } + const normalizedTerm = searchOptions?.caseSensitive ? term : term.toLowerCase(); + const wholeWord = !!searchOptions?.wholeWord; + const matches: IMatch[] = []; + const cols = terminal.cols; + const logicalLines = this._getLogicalLines(cols); + for (const logicalLine of logicalLines) { + let matchedOffsets: number[] | undefined; + if (isRegex) { + regex!.lastIndex = 0; + while (true) { + const match = regex!.exec(logicalLine.text); + if (!match) { + break; + } + if (match[0].length === 0) { + regex!.lastIndex++; + continue; + } + const startOffset = match.index; + const endOffset = startOffset + match[0].length; + if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { + continue; + } + matchedOffsets ??= []; + matchedOffsets.push(startOffset, endOffset); + } + } else { + const haystack = searchOptions?.caseSensitive ? logicalLine.text : (logicalLine.lowerText ??= logicalLine.text.toLowerCase()); + let searchIndex = 0; + while (searchIndex < haystack.length) { + const startOffset = haystack.indexOf(normalizedTerm, searchIndex); + if (startOffset === -1) { + break; + } + const endOffset = startOffset + term.length; + if (this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { + matchedOffsets ??= []; + matchedOffsets.push(startOffset, endOffset); + } + searchIndex = startOffset + 1; + } + } + if (matchedOffsets && matchedOffsets.length > 0) { + if (!logicalLine.offsetToPoint || !logicalLine.offsetToLinear) { + const offsets = this._buildLogicalLineOffsets(logicalLine.rows, cols); + logicalLine.offsetToPoint = offsets.offsetToPoint; + logicalLine.offsetToLinear = offsets.offsetToLinear; + } + const offsetToPoint = logicalLine.offsetToPoint; + const offsetToLinear = logicalLine.offsetToLinear; + for (let i = 0; i < matchedOffsets.length; i += 2) { + const startOffset = matchedOffsets[i]; + const endOffset = matchedOffsets[i + 1]; + const start = offsetToPoint[startOffset]; + const end = offsetToPoint[endOffset]; + const startLinear = offsetToLinear[startOffset]; + const endLinear = offsetToLinear[endOffset]; + matches.push({ + startX: start.x, + startY: start.y, + endX: end.x, + endY: end.y, + cellLength: Math.max(1, endLinear - startLinear) + }); + if (matches.length >= this._highlightLimit) { + return matches; + } + } + } + } - this._state.lastSearchOptions = searchOptions; + return matches; + } - if (this._state.shouldUpdateHighlighting(term, searchOptions)) { - this._highlightAllMatches(term, searchOptions!); + private _findSingleMatchForNext(term: string, searchOptions?: ISearchOptions): IMatch | undefined { + const terminal = this._terminal; + if (!terminal) { + return undefined; + } + const selection = terminal.getSelectionPosition(); + const selectionStart = selection?.start; + const isRegex = !!searchOptions?.regex; + const regex = isRegex ? this._buildRegex(term, searchOptions) : undefined; + if (isRegex && !regex) { + return undefined; } + const normalizedTerm = searchOptions?.caseSensitive ? term : term.toLowerCase(); + const wholeWord = !!searchOptions?.wholeWord; + const cols = terminal.cols; + let firstMatch: IMatch | undefined; + let selectionMatched = !selectionStart; + const buffer = terminal.buffer.active; + for (let row = 0; row < buffer.length; row++) { + const firstLine = buffer.getLine(row); + if (!firstLine || firstLine.isWrapped) { + continue; + } + const rows: number[] = [row]; + let nextRow = row + 1; + while (nextRow < buffer.length) { + const wrappedLine = buffer.getLine(nextRow); + if (!wrappedLine?.isWrapped) { + break; + } + rows.push(nextRow); + nextRow++; + } + const logicalLine: ILogicalLineCacheEntry = { rows, text: this._buildLogicalLineText(rows, cols) }; + if (isRegex) { + regex!.lastIndex = 0; + while (true) { + const match = regex!.exec(logicalLine.text); + if (!match) { + break; + } + if (match[0].length === 0) { + regex!.lastIndex++; + continue; + } + const startOffset = match.index; + const endOffset = startOffset + match[0].length; + if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { + continue; + } + const nextMatch = this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset); + if (!firstMatch) { + firstMatch = nextMatch; + if (selectionMatched) { + return firstMatch; + } + } + if (!selectionMatched && selectionStart && nextMatch.startX === selectionStart.x && nextMatch.startY === selectionStart.y) { + selectionMatched = true; + continue; + } + if (selectionMatched) { + return nextMatch; + } + } + } else { + const haystack = searchOptions?.caseSensitive ? logicalLine.text : (logicalLine.lowerText ??= logicalLine.text.toLowerCase()); + let searchIndex = 0; + while (searchIndex < haystack.length) { + const startOffset = haystack.indexOf(normalizedTerm, searchIndex); + if (startOffset === -1) { + break; + } + const endOffset = startOffset + term.length; + searchIndex = startOffset + 1; + if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { + continue; + } + const nextMatch = this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset); + if (!firstMatch) { + firstMatch = nextMatch; + if (selectionMatched) { + return firstMatch; + } + } + if (!selectionMatched && selectionStart && nextMatch.startX === selectionStart.x && nextMatch.startY === selectionStart.y) { + selectionMatched = true; + continue; + } + if (selectionMatched) { + return nextMatch; + } + } + } + row = rows[rows.length - 1]; + } + return firstMatch; + } - const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions); - this._fireResults(searchOptions); - this._state.cachedSearchTerm = term; + private _findSingleMatchForPrevious(term: string, searchOptions?: ISearchOptions): IMatch | undefined { + const terminal = this._terminal; + if (!terminal) { + return undefined; + } + const selection = terminal.getSelectionPosition(); + const selectionStart = selection?.start; + const isRegex = !!searchOptions?.regex; + const regex = isRegex ? this._buildRegex(term, searchOptions) : undefined; + if (isRegex && !regex) { + return undefined; + } + const normalizedTerm = searchOptions?.caseSensitive ? term : term.toLowerCase(); + const wholeWord = !!searchOptions?.wholeWord; + const cols = terminal.cols; + let lastMatch: IMatch | undefined; + let matchBeforeSelection: IMatch | undefined; + let selectionMatched = !selectionStart; + let wrapToLastMatch = false; + const buffer = terminal.buffer.active; + for (let row = 0; row < buffer.length; row++) { + const firstLine = buffer.getLine(row); + if (!firstLine || firstLine.isWrapped) { + continue; + } + const rows: number[] = [row]; + let nextRow = row + 1; + while (nextRow < buffer.length) { + const wrappedLine = buffer.getLine(nextRow); + if (!wrappedLine?.isWrapped) { + break; + } + rows.push(nextRow); + nextRow++; + } + const logicalLine: ILogicalLineCacheEntry = { rows, text: this._buildLogicalLineText(rows, cols) }; + if (isRegex) { + regex!.lastIndex = 0; + while (true) { + const match = regex!.exec(logicalLine.text); + if (!match) { + break; + } + if (match[0].length === 0) { + regex!.lastIndex++; + continue; + } + const startOffset = match.index; + const endOffset = startOffset + match[0].length; + if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { + continue; + } + const previousMatch = this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset); + if (!selectionMatched && selectionStart && previousMatch.startX === selectionStart.x && previousMatch.startY === selectionStart.y) { + selectionMatched = true; + if (matchBeforeSelection) { + return matchBeforeSelection; + } + wrapToLastMatch = true; + } else if (!selectionMatched) { + matchBeforeSelection = previousMatch; + } + lastMatch = previousMatch; + } + } else { + const haystack = searchOptions?.caseSensitive ? logicalLine.text : (logicalLine.lowerText ??= logicalLine.text.toLowerCase()); + let searchIndex = 0; + while (searchIndex < haystack.length) { + const startOffset = haystack.indexOf(normalizedTerm, searchIndex); + if (startOffset === -1) { + break; + } + const endOffset = startOffset + term.length; + searchIndex = startOffset + 1; + if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { + continue; + } + const previousMatch = this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset); + if (!selectionMatched && selectionStart && previousMatch.startX === selectionStart.x && previousMatch.startY === selectionStart.y) { + selectionMatched = true; + if (matchBeforeSelection) { + return matchBeforeSelection; + } + wrapToLastMatch = true; + } else if (!selectionMatched) { + matchBeforeSelection = previousMatch; + } + lastMatch = previousMatch; + } + } + row = rows[rows.length - 1]; + } + if (wrapToLastMatch) { + return lastMatch; + } + return lastMatch; + } - this._onAfterSearch.fire(); + private _getActiveMatch(term: string, searchOptions: ISearchOptions | undefined, direction: SearchDirection, matches: IMatch[], resolvedIndex: number): IMatch { + if (direction === 'previous' && matches.length >= this._highlightLimit && searchOptions?.decorations) { + const selection = this._terminal?.getSelectionPosition(); + const selectionInMatches = this._findIndexFromSelection(matches, selection) !== -1; + if (!selectionInMatches) { + const fromBuffer = this._findSingleMatchForPrevious(term, searchOptions); + if (fromBuffer) { + return fromBuffer; + } + } + } + return matches[resolvedIndex]; + } - return found; + private _createMatchFromOffsets(logicalLine: ILogicalLineCacheEntry, cols: number, startOffset: number, endOffset: number): IMatch { + if (!logicalLine.offsetToPoint || !logicalLine.offsetToLinear) { + const offsets = this._buildLogicalLineOffsets(logicalLine.rows, cols); + logicalLine.offsetToPoint = offsets.offsetToPoint; + logicalLine.offsetToLinear = offsets.offsetToLinear; + } + const start = logicalLine.offsetToPoint[startOffset]; + const end = logicalLine.offsetToPoint[endOffset]; + const startLinear = logicalLine.offsetToLinear[startOffset]; + const endLinear = logicalLine.offsetToLinear[endOffset]; + return { + startX: start.x, + startY: start.y, + endX: end.x, + endY: end.y, + cellLength: Math.max(1, endLinear - startLinear) + }; } - 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'); + private _getMatches(term: string, searchOptions?: ISearchOptions): IMatch[] | undefined { + const terminal = this._terminal; + if (!terminal) { + return undefined; } - if (!this._state.isValidSearchTerm(term)) { - this.clearDecorations(); - return; + const cols = terminal.cols; + const flags = this._getMatchFlags(searchOptions); + const byFlags = this._matchCache.get(cols)?.get(flags); + if (byFlags?.has(term)) { + return byFlags.get(term); + } + const matches = this._findAllMatches(term, searchOptions); + let byCols = this._matchCache.get(cols); + if (!byCols) { + byCols = new Map(); + this._matchCache.set(cols, byCols); + } + let writableByFlags = byCols.get(flags); + if (!writableByFlags) { + writableByFlags = new Map(); + byCols.set(flags, writableByFlags); + } + if (!writableByFlags.has(term)) { + if (this._matchCacheOrder.length >= SearchAddonConstants.MAX_MATCH_CACHE_ENTRIES) { + const oldest = this._matchCacheOrder.shift(); + if (oldest) { + const oldestByCols = this._matchCache.get(oldest.cols); + const oldestByFlags = oldestByCols?.get(oldest.flags); + oldestByFlags?.delete(oldest.term); + if (oldestByFlags && oldestByFlags.size === 0) { + oldestByCols?.delete(oldest.flags); + } + if (oldestByCols && oldestByCols.size === 0) { + this._matchCache.delete(oldest.cols); + } + } + } + this._matchCacheOrder.push({ cols, flags, term }); } + writableByFlags.set(term, matches); + return matches; + } - // new search, clear out the old decorations - this.clearDecorations(true); + private _clearMatchCache(): void { + this._matchCache.clear(); + this._matchCacheOrder.length = 0; + this._logicalLineCache = undefined; + this._lastResolvedNavigation = undefined; + this._disposeMatchDecorationSets(); + } - const results: ISearchResult[] = []; - let prevResult: ISearchResult | undefined = undefined; - let result = this._engine.find(term, 0, 0, searchOptions); + private _buildRegex(term: string, searchOptions?: ISearchOptions): RegExp | undefined { + const flags = searchOptions?.caseSensitive ? 'g' : 'gi'; + if (searchOptions?.regex) { + try { + return new RegExp(term, flags); + } catch { + return undefined; + } + } + return new RegExp(escapeRegExp(term), flags); + } - while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) { - if (results.length >= this._highlightLimit) { - break; + private _getLogicalLines(cols: number): ILogicalLineCacheEntry[] { + if (this._logicalLineCache && this._logicalLineCache.cols === cols) { + return this._logicalLineCache.entries; + } + const terminal = this._terminal; + if (!terminal) { + return []; + } + const entries: ILogicalLineCacheEntry[] = []; + const buffer = terminal.buffer.active; + for (let row = 0; row < buffer.length; row++) { + const firstLine = buffer.getLine(row); + if (!firstLine || firstLine.isWrapped) { + continue; } - prevResult = result; - 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 - ); + const rows: number[] = [row]; + let nextRow = row + 1; + while (nextRow < buffer.length) { + const wrappedLine = buffer.getLine(nextRow); + if (!wrappedLine?.isWrapped) { + break; + } + rows.push(nextRow); + nextRow++; + } + entries.push({ + rows, + text: this._buildLogicalLineText(rows, cols) + }); + row = rows[rows.length - 1]; } + this._logicalLineCache = { cols, entries }; + return entries; + } - this._resultTracker.updateResults(results, this._highlightLimit); - if (searchOptions.decorations) { - this._decorationManager.createHighlightDecorations(results, searchOptions.decorations); + private _buildLogicalLineText(rows: number[], cols: number): string { + const terminal = this._terminal; + if (!terminal) { + return ''; + } + const textParts: string[] = []; + const blankLine = ' '.repeat(cols); + for (const row of rows) { + const line = terminal.buffer.active.getLine(row); + textParts.push(line ? line.translateToString() : blankLine); } + return textParts.join(''); } - private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { - if (!this._terminal || !this._engine) { - return false; + private _buildLogicalLineOffsets(rows: number[], cols: number): ILogicalLineOffsets { + const terminal = this._terminal; + if (!terminal) { + return { + offsetToPoint: [{ x: 0, y: rows[0] ?? 0 }], + offsetToLinear: [0] + }; } - if (!this._state.isValidSearchTerm(term)) { - this._terminal.clearSelection(); - this.clearDecorations(); - return false; + + const offsetToPoint: IPoint[] = []; + const offsetToLinear: number[] = []; + let currentOffset = 0; + let linearOffset = 0; + let rowIndex = 0; + let col = 0; + const getPoint = (): IPoint => { + if (rows.length === 0) { + return { x: 0, y: 0 }; + } + if (rowIndex >= rows.length) { + return { x: cols, y: rows[rows.length - 1] }; + } + return { x: col, y: rows[rowIndex] }; + }; + offsetToPoint[0] = getPoint(); + offsetToLinear[0] = 0; + + const cell = terminal.buffer.active.getNullCell(); + for (const row of rows) { + const line = terminal.buffer.active.getLine(row); + for (let x = 0; x < cols; x++) { + const loadedCell = line?.getCell(x, cell); + const width = loadedCell ? loadedCell.getWidth() : 1; + if (width === 0) { + continue; + } + const chars = loadedCell?.getChars() || ' '; + const codeUnitCount = chars.length; + for (let i = 1; i < codeUnitCount; i++) { + offsetToPoint[currentOffset + i] = getPoint(); + offsetToLinear[currentOffset + i] = linearOffset; + } + currentOffset += codeUnitCount; + linearOffset += width; + col += width; + while (col >= cols) { + col -= cols; + rowIndex++; + } + offsetToPoint[currentOffset] = getPoint(); + offsetToLinear[currentOffset] = linearOffset; + } } - const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm); - return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll); + return { + offsetToPoint, + offsetToLinear + }; } - /** - * 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'); + private _isWholeWordMatch(text: string, startOffset: number, endOffset: number, wholeWord: boolean): boolean { + if (!wholeWord) { + return true; } + return !isWordCharAt(text, startOffset - 1) && !isWordCharAt(text, endOffset); + } - this._onBeforeSearch.fire(); + private _resolveResultIndex(matches: IMatch[], searchOptions: ISearchOptions | undefined, direction: SearchDirection, currentSearchKey: string): number { + const previousNavigation = this._lastResolvedNavigation; + const selection = this._terminal?.getSelectionPosition(); + const currentSelectionIndex = this._findIndexFromSelection(matches, selection); + + if ( + previousNavigation && + previousNavigation.searchKey === currentSearchKey && + previousNavigation.matches === matches + ) { + const previousMatch = matches[previousNavigation.index]; + if ( + !selection || + (selection.start.x === previousMatch.startX && selection.start.y === previousMatch.startY) + ) { + return this._stepResultIndex(previousNavigation.index, matches.length, direction); + } + if (currentSelectionIndex !== -1) { + return this._stepResultIndex(currentSelectionIndex, matches.length, direction); + } + } - this._state.lastSearchOptions = searchOptions; + const searchKeyChanged = this._lastSearchKey !== undefined && this._lastSearchKey !== currentSearchKey; + const isIncrementalUpdate = !!(searchOptions?.incremental && searchKeyChanged); - if (this._state.shouldUpdateHighlighting(term, searchOptions)) { - this._highlightAllMatches(term, searchOptions!); + if (isIncrementalUpdate) { + if (currentSelectionIndex !== -1) { + return currentSelectionIndex; + } + if (selection) { + return this._findIncrementalMatchIndexFromSelection(matches, selection, direction); + } } - const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions); - this._fireResults(searchOptions); - this._state.cachedSearchTerm = term; + if (searchKeyChanged && !searchOptions?.incremental) { + if (currentSelectionIndex !== -1) { + return currentSelectionIndex; + } + if (direction === 'next' && selection) { + return this._findMatchIndexFromSelectionPosition(matches, selection, direction); + } + } - this._onAfterSearch.fire(); + if (currentSelectionIndex !== -1) { + return this._stepResultIndex(currentSelectionIndex, matches.length, direction); + } - return found; + return this._getDirectionalDefaultIndex(matches.length, direction); } - private _fireResults(searchOptions?: ISearchOptions): void { - this._resultTracker.fireResultsChanged(!!searchOptions?.decorations); + private _findIncrementalMatchIndexFromSelection(matches: IMatch[], selection: NonNullable>, direction: SearchDirection): number { + if (direction === 'previous') { + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + if (match.startY > selection.start.y || (match.startY === selection.start.y && match.startX >= selection.start.x)) { + return i; + } + } + } + return this._findMatchIndexFromSelectionPosition(matches, selection, direction); } - private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { - if (!this._terminal || !this._engine) { - return false; + private _findMatchIndexFromSelectionPosition(matches: IMatch[], selection: NonNullable>, direction: SearchDirection): number { + const selY = selection.start.y; + const selX = selection.start.x; + if (direction === 'next') { + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + if (match.startY > selY || (match.startY === selY && match.startX >= selX)) { + return i; + } + } + return 0; } - if (!this._state.isValidSearchTerm(term)) { - this._terminal.clearSelection(); - this.clearDecorations(); - return false; + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]; + if (match.startY < selY || (match.startY === selY && match.startX <= selX)) { + return i; + } } + return matches.length - 1; + } - const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm); - return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll); + private _findIndexFromSelection(matches: IMatch[], selection: ReturnType | undefined = this._terminal?.getSelectionPosition()): number { + if (!selection) { + return -1; + } + let byRow = this._selectionIndexCache.get(matches); + if (!byRow) { + byRow = new Map>(); + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + let byColumn = byRow.get(match.startY); + if (!byColumn) { + byColumn = new Map(); + byRow.set(match.startY, byColumn); + } + byColumn.set(match.startX, i); + } + this._selectionIndexCache.set(matches, byRow); + } + return byRow.get(selection.start.y)?.get(selection.start.x) ?? -1; } - /** - * 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; + private _revealResult(match: IMatch): void { + const terminal = this._terminal; + if (!terminal) { + return; + } + const viewportTop = terminal.buffer.active.viewportY; + const viewportBottom = viewportTop + terminal.rows - 1; + if (match.startY >= viewportTop && match.endY <= viewportBottom) { + return; } + const middleY = Math.floor((match.startY + match.endY) / 2); + terminal.scrollToLine(Math.max(0, middleY - Math.floor(terminal.rows / 2))); + } - this._resultTracker.clearSelectedDecoration(); - if (!result) { - this._terminal.clearSelection(); - return false; + private _refreshDecorations(matches: IMatch[], decorationOptions: ISearchDecorationOptions, activeMatch: IMatch | undefined): void { + const terminal = this._terminal; + if (!terminal) { + return; + } + const decorationSet = this._getOrCreateMatchDecorationSet(matches, decorationOptions, terminal); + if (this._activeMatchDecorationSet !== decorationSet) { + this._setMatchDecorationSetActive(this._activeMatchDecorationSet, false); + this._activeMatchDecorationSet = decorationSet; + this._setMatchDecorationSetActive(decorationSet, true); + } + this._activeDecoration.clear(); + const activeMatchBorder = decorationOptions.activeMatchBorder; + if (!activeMatch) { + return; + } + const cursorLine = terminal.buffer.active.baseY + terminal.buffer.active.cursorY; + const marker = terminal.registerMarker(activeMatch.startY - cursorLine); + const width = this._getDecorationWidth(activeMatch, terminal.cols); + const activeDecoration = terminal.registerDecoration({ + marker, + x: activeMatch.startX, + width, + backgroundColor: decorationOptions.activeMatchBackground, + layer: 'top', + overviewRulerOptions: decorationOptions.activeMatchColorOverviewRuler ? { color: decorationOptions.activeMatchColorOverviewRuler } : undefined + }); + if (!activeDecoration) { + this._activeDecoration.clear(); + return; } + const disposables: IDisposable[] = [activeDecoration]; + if (activeMatchBorder) { + disposables.push(activeDecoration.onRender(element => { + element.style.outline = `1px solid ${activeMatchBorder}`; + })); + } + this._activeDecoration.value = toDisposable(() => { + for (const disposable of disposables) { + disposable.dispose(); + } + }); + } - this._terminal.select(result.col, result.row, result.size); - if (options) { - const activeDecoration = this._decorationManager.createActiveDecoration(result, options); - if (activeDecoration) { - this._resultTracker.selectedDecoration = activeDecoration; + private _getOrCreateMatchDecorationSet(matches: IMatch[], decorationOptions: ISearchDecorationOptions, terminal: Terminal): IMatchDecorationSet { + const optionsKey = this._createDecorationOptionsKey(decorationOptions); + const existing = this._matchDecorationSetByMatches.get(matches); + if (existing && existing.optionsKey === optionsKey) { + this._touchMatchDecorationSet(existing); + return existing; + } + if (existing) { + this._disposeMatchDecorationSet(existing); + } + const created = this._createMatchDecorationSet(matches, decorationOptions, terminal, optionsKey); + this._matchDecorationSetByMatches.set(matches, created); + this._matchDecorationSetOrder.push(created); + this._trimMatchDecorationSetCache(); + return created; + } + + private _createMatchDecorationSet(matches: IMatch[], decorationOptions: ISearchDecorationOptions, terminal: Terminal, optionsKey: string): IMatchDecorationSet { + const set: IMatchDecorationSet = { + matches, + options: decorationOptions, + optionsKey, + decorations: [], + disposables: [], + isActive: false + }; + const matchBorder = decorationOptions.matchBorder; + const cursorLine = terminal.buffer.active.baseY + terminal.buffer.active.cursorY; + const inactiveOverviewRulerOptions = undefined; + for (const match of matches) { + const marker = terminal.registerMarker(match.startY - cursorLine); + const width = this._getDecorationWidth(match, terminal.cols); + const decoration = terminal.registerDecoration({ + marker, + x: match.startX, + width, + backgroundColor: decorationOptions.matchBackground, + layer: 'bottom', + overviewRulerOptions: inactiveOverviewRulerOptions + }); + if (!decoration) { + continue; } + set.decorations.push(decoration); + set.disposables.push(decoration); + set.disposables.push(this._registerMatchDecorationRender(decoration, set, matchBorder)); } + return set; + } - 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); + private _setMatchDecorationSetActive(set: IMatchDecorationSet | undefined, isActive: boolean): void { + if (!set || set.isActive === isActive) { + return; + } + if (!isActive) { + this._disposeMatchDecorationSet(set); + return; + } + set.isActive = isActive; + const overviewRulerOptions = isActive && set.options.matchOverviewRuler ? { color: set.options.matchOverviewRuler } : undefined; + for (const decoration of set.decorations) { + decoration.options.overviewRulerOptions = overviewRulerOptions; + if (decoration.element) { + this._applyMatchDecorationStyle(decoration.element, isActive, set.options.matchBorder); } } - return true; } + + private _applyMatchDecorationStyle(element: HTMLElement, isActive: boolean, border: string | undefined): void { + if (isActive) { + element.style.outline = border ? `1px solid ${border}` : ''; + } else { + element.style.outline = ''; + } + } + + private _getDecorationWidth(match: IMatch, cols: number): number { + return Math.max(1, Math.min(match.cellLength, cols - match.startX)); + } + + private _registerMatchDecorationRender( + decoration: IDecoration, + set: IMatchDecorationSet, + border: string | undefined + ): IDisposable { + return decoration.onRender(element => { + this._applyMatchDecorationStyle(element, set.isActive, border); + }); + } + + private _createDecorationOptionsKey(decorationOptions: ISearchDecorationOptions): string { + return [ + decorationOptions.matchBackground, + decorationOptions.matchBorder, + decorationOptions.matchOverviewRuler, + decorationOptions.activeMatchBackground, + decorationOptions.activeMatchBorder, + decorationOptions.activeMatchColorOverviewRuler + ].join('|'); + } + + private _touchMatchDecorationSet(set: IMatchDecorationSet): void { + const index = this._matchDecorationSetOrder.indexOf(set); + if (index !== -1) { + this._matchDecorationSetOrder.splice(index, 1); + this._matchDecorationSetOrder.push(set); + } + } + + private _trimMatchDecorationSetCache(): void { + while (this._matchDecorationSetOrder.length > SearchAddonConstants.MAX_DECORATION_CACHE_ENTRIES) { + const candidate = this._matchDecorationSetOrder[0]; + if (candidate === this._activeMatchDecorationSet && this._matchDecorationSetOrder.length > 1) { + this._matchDecorationSetOrder.push(this._matchDecorationSetOrder.shift()!); + continue; + } + this._disposeMatchDecorationSet(this._matchDecorationSetOrder.shift()!); + } + } + + private _disposeMatchDecorationSet(set: IMatchDecorationSet): void { + for (const disposable of set.disposables) { + disposable.dispose(); + } + set.disposables.length = 0; + set.decorations.length = 0; + this._matchDecorationSetByMatches.delete(set.matches); + const index = this._matchDecorationSetOrder.indexOf(set); + if (index !== -1) { + this._matchDecorationSetOrder.splice(index, 1); + } + if (this._activeMatchDecorationSet === set) { + this._activeMatchDecorationSet = undefined; + } + } + + private _disposeMatchDecorationSets(): void { + while (this._matchDecorationSetOrder.length > 0) { + this._disposeMatchDecorationSet(this._matchDecorationSetOrder[0]); + } + this._activeMatchDecorationSet = undefined; + } + + private _disposeDecorations(): void { + this._activeDecoration.clear(); + this._disposeMatchDecorationSets(); + } + + private _createSearchKey(term: string, searchOptions: ISearchOptions | undefined, direction: SearchDirection): string { + return `${this._createMatchKey(term, searchOptions)}|${direction}`; + } + + private _createMatchKey(term: string, searchOptions: ISearchOptions | undefined): string { + const flags = this._getMatchFlags(searchOptions); + return `${term}|${flags}`; + } + + private _getMatchFlags(searchOptions: ISearchOptions | undefined): MatchFlags { + return ( + (searchOptions?.caseSensitive ? MatchFlags.CASE_SENSITIVE : 0) | + (searchOptions?.regex ? MatchFlags.REGEX : 0) | + (searchOptions?.wholeWord ? MatchFlags.WHOLE_WORD : 0) + ) as MatchFlags; + } + + private _setDecoratedSearchState( + term: string, + searchOptions: ISearchOptions, + searchKey: string, + resultCount: number, + resultIndex: number + ): void { + this._resultCount = resultCount; + this._resultIndex = resultIndex >= 0 && resultIndex < resultCount ? resultIndex : -1; + this._lastSearchTerm = term; + this._lastSearchOptions = searchOptions; + this._lastSearchKey = searchKey; + } + + private _clearSearchState(): void { + this._disposeDecorations(); + this._resultsUpdateListener.clear(); + this._resultCount = 0; + this._resultIndex = -1; + this._lastSearchTerm = undefined; + this._lastSearchOptions = undefined; + this._lastSearchKey = undefined; + } + + private _stepResultIndex(index: number, length: number, direction: SearchDirection): number { + if (direction === 'next') { + return (index + 1) % length; + } + return (index + length - 1) % length; + } + + private _getDirectionalDefaultIndex(length: number, direction: SearchDirection): number { + return direction === 'next' ? 0 : length - 1; + } +} + +interface ILogicalLineCacheEntry { + rows: number[]; + text: string; + lowerText?: string; + offsetToPoint?: IPoint[]; + offsetToLinear?: number[]; +} + +interface ILogicalLineCache { + cols: number; + entries: ILogicalLineCacheEntry[]; +} + +interface ILogicalLineOffsets { + offsetToPoint: IPoint[]; + offsetToLinear: number[]; +} + +interface IPoint { + x: number; + y: number; +} + +interface IMatch { + startX: number; + startY: number; + endX: number; + endY: number; + cellLength: number; +} + +interface IResolvedNavigation { + searchKey: string; + matches: IMatch[]; + index: number; + selectionStartX: number; + selectionStartY: number; +} + +type SearchDirection = 'next' | 'previous'; + +interface IMatchDecorationSet { + matches: IMatch[]; + options: ISearchDecorationOptions; + optionsKey: string; + decorations: IDecoration[]; + disposables: IDisposable[]; + isActive: boolean; +} + +interface IMatchCacheKey { + cols: number; + flags: MatchFlags; + term: string; +} + +const enum SearchAddonConstants { + /** Default maximum number of matches to highlight during search. */ + DEFAULT_HIGHLIGHT_LIMIT = 1000, + /** Maximum number of cached match-result entries across search keys. */ + MAX_MATCH_CACHE_ENTRIES = 16, + /** Maximum number of cached decoration sets retained for reuse. */ + MAX_DECORATION_CACHE_ENTRIES = 2 +} + +const enum MatchFlags { + CASE_SENSITIVE = 1, + REGEX = 2, + WHOLE_WORD = 4 +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function isWordCharAt(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + return isWordCode(value.charCodeAt(index)); +} + +function isWordCode(code: number): boolean { + return ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + code === 95 // _ + ); } diff --git a/addons/addon-search/src/SearchEngine.test.ts b/addons/addon-search/src/SearchEngine.test.ts deleted file mode 100644 index b3cea8f54f..0000000000 --- a/addons/addon-search/src/SearchEngine.test.ts +++ /dev/null @@ -1,708 +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 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 deleted file mode 100644 index b9991974d3..0000000000 --- a/addons/addon-search/src/SearchEngine.ts +++ /dev/null @@ -1,392 +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; -} - -/** - * 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; - } - } - } - - 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 deleted file mode 100644 index 77f64c81ec..0000000000 --- a/addons/addon-search/src/SearchLineCache.test.ts +++ /dev/null @@ -1,287 +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 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 526f4bfcc7..0000000000 --- a/addons/addon-search/src/SearchLineCache.ts +++ /dev/null @@ -1,136 +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[] -]; - -/** - * 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 _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); - } - } - - private _destroyLinesCache(): void { - 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.ts b/addons/addon-search/src/SearchResultTracker.ts deleted file mode 100644 index 285345b7a4..0000000000 --- a/addons/addon-search/src/SearchResultTracker.ts +++ /dev/null @@ -1,119 +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 } 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 _selectedDecoration: ISelectedDecoration | undefined; - - 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; - } - - /** - * 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 deleted file mode 100644 index c5e2c8881e..0000000000 --- a/addons/addon-search/src/SearchState.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * 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/test/SearchAddon.test.ts b/addons/addon-search/test/SearchAddon.test.ts index a4ccb6c0ef..940d1efdc7 100644 --- a/addons/addon-search/test/SearchAddon.test.ts +++ b/addons/addon-search/test/SearchAddon.test.ts @@ -45,6 +45,46 @@ test.describe('Search Tests', () => { deepStrictEqual(await ctx.page.evaluate(`window.search.findNext('$^1_3{}test$#')`), true); deepStrictEqual(await ctx.proxy.getSelection(), '$^1_3{}test$#'); }); + test('Search should not scroll when match is already in viewport', async () => { + let dataString = ''; + for (let i = 0; i < 100; i++) { + if (i === 40) { + dataString += `line ${i} needle-in-view`; + } else { + dataString += `line ${i}`; + } + dataString += '\n\r'; + } + await ctx.proxy.write(dataString); + await ctx.page.evaluate('window.term.scrollToLine(35)'); + strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35); + + strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-in-view')`), true); + deepStrictEqual(await ctx.proxy.getSelection(), 'needle-in-view'); + strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35); + }); + test('Search should center the match when it is outside the viewport', async () => { + let dataString = ''; + for (let i = 0; i < 100; i++) { + if (i === 60) { + dataString += `line ${i} needle-center`; + } else { + dataString += `line ${i}`; + } + dataString += '\n\r'; + } + await ctx.proxy.write(dataString); + await ctx.page.evaluate('window.term.scrollToLine(0)'); + strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 0); + + strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-center')`), true); + deepStrictEqual(await ctx.proxy.getSelection(), 'needle-center'); + const viewportY = await ctx.page.evaluate('window.term.buffer.active.viewportY'); + const selectionPosition = await ctx.proxy.getSelectionPosition(); + const rows = await ctx.proxy.rows; + deepStrictEqual(viewportY, 48); + deepStrictEqual(selectionPosition!.start.y - Math.floor(rows / 2), 48); + }); test('Incremental Find Previous', async () => { await ctx.proxy.writeln(`package.jsonc\n`); await ctx.proxy.write('package.json pack package.lock'); @@ -277,8 +317,7 @@ test.describe('Search Tests', () => { { resultCount: 1, resultIndex: 0 }, { resultCount: 2, resultIndex: 1 } ]); - await timeout(2000); - strictEqual(await ctx.page.evaluate(`debugger; window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false); + strictEqual(await ctx.page.evaluate(`window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false); deepStrictEqual(await ctx.page.evaluate('window.calls'), [ { resultCount: 1, resultIndex: 0 }, { resultCount: 2, resultIndex: 1 }, diff --git a/addons/addon-search/tsconfig.json b/addons/addon-search/tsconfig.json index 2d820dd1a6..0e7b5c3502 100644 --- a/addons/addon-search/tsconfig.json +++ b/addons/addon-search/tsconfig.json @@ -3,6 +3,7 @@ "include": [], "references": [ { "path": "./src" }, - { "path": "./test" } + { "path": "./test" }, + { "path": "./benchmark" } ] } diff --git a/addons/addon-search/typings/addon-search.d.ts b/addons/addon-search/typings/addon-search.d.ts index 40034da9a5..c1c56d582b 100644 --- a/addons/addon-search/typings/addon-search.d.ts +++ b/addons/addon-search/typings/addon-search.d.ts @@ -17,7 +17,8 @@ declare module '@xterm/addon-search' { /** * Whether to search for a whole word, the result is only valid if it's - * surrounded in "non-word" characters such as `_`, `(`, `)` or space. + * surrounded by start/end of line or non-word separators such as whitespace + * and punctuation (for example `(`, `)`, or space). */ wholeWord?: boolean; @@ -28,8 +29,7 @@ declare module '@xterm/addon-search' { /** * Whether to do an incremental search, this will expand the selection if it - * still matches the term the user typed. Note that this only affects - * `findNext`, not `findPrevious`. + * still matches the term the user typed. */ incremental?: boolean; @@ -43,7 +43,7 @@ declare module '@xterm/addon-search' { /** * Options for showing decorations when searching. */ - interface ISearchDecorationOptions { + export interface ISearchDecorationOptions { /** * The background color of a match, this must use #RRGGBB format. */ @@ -57,7 +57,7 @@ declare module '@xterm/addon-search' { /** * The overview ruler color of a match. */ - matchOverviewRuler: string; + matchOverviewRuler?: string; /** * The background color for the currently active match, this must use #RRGGBB format. @@ -72,7 +72,7 @@ declare module '@xterm/addon-search' { /** * The overview ruler color of the currently active match. */ - activeMatchColorOverviewRuler: string; + activeMatchColorOverviewRuler?: string; } /** @@ -80,12 +80,13 @@ declare module '@xterm/addon-search' { */ export interface ISearchResultChangeEvent { /** - * The index of the currently active result, -1 when the threshold of matches is exceeded. + * The index of the currently active result within tracked highlights, or -1 when + * the active match is not in the tracked result set. */ resultIndex: number; /** - * The total number of search results found. + * The number of tracked search results (capped by `highlightLimit` when decorations are enabled). */ resultCount: number; } @@ -96,9 +97,9 @@ declare module '@xterm/addon-search' { export interface ISearchAddonOptions { /** * Max number of matches highlighted when decorations are enabled. - * Defaults to 1000 highlighted matches + * Defaults to 1000. Invalid values fall back to the default. */ - highlightLimit: number + highlightLimit?: number; } /** @@ -140,7 +141,7 @@ declare module '@xterm/addon-search' { public findPrevious(term: string, searchOptions?: ISearchOptions): boolean; /** - * Clears the decorations and selection + * Clears highlight decorations and tracked results. */ public clearDecorations(): void; @@ -162,7 +163,8 @@ declare module '@xterm/addon-search' { readonly onBeforeSearch: IEvent; /** - * When decorations are enabled, fires when the search results change. + * When decorations are enabled, fires after `findNext`/`findPrevious` when tracked + * search results change. Does not fire from `clearDecorations()`. */ readonly onDidChangeResults: IEvent; }