@xterm/addon-search
Version:
An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables searching the buffer. This addon requires xterm.js v4+.
242 lines (205 loc) • 8.89 kB
text/typescript
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent } from '@xterm/addon-search';
import { Event } from 'vs/base/common/event';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { disposableTimeout } from 'vs/base/common/async';
import { SearchLineCache } from './SearchLineCache';
import { SearchState } from './SearchState';
import { SearchEngine, type ISearchResult } from './SearchEngine';
import { DecorationManager } from './DecorationManager';
import { SearchResultTracker } from './SearchResultTracker';
interface IInternalSearchOptions {
noScroll: boolean;
}
/**
* Configuration constants for the search addon functionality.
*/
const enum Constants {
/**
* Default maximum number of search results to highlight simultaneously. This limit prevents
* performance degradation when searching for very common terms that would result in excessive
* highlighting decorations.
*/
DEFAULT_HIGHLIGHT_LIMIT = 1000
}
export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {
private _terminal: Terminal | undefined;
private _highlightLimit: number;
private _highlightTimeout = this._register(new MutableDisposable<IDisposable>());
private _lineCache = this._register(new MutableDisposable<SearchLineCache>());
// Component instances
private _state = new SearchState();
private _engine: SearchEngine | undefined;
private _decorationManager: DecorationManager | undefined;
private _resultTracker = this._register(new SearchResultTracker());
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
return this._resultTracker.onDidChangeResults;
}
constructor(options?: Partial<ISearchAddonOptions>) {
super();
this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;
}
public activate(terminal: Terminal): void {
this._terminal = terminal;
this._lineCache.value = new SearchLineCache(terminal);
this._engine = new SearchEngine(terminal, this._lineCache.value);
this._decorationManager = new DecorationManager(terminal);
this._register(this._terminal.onWriteParsed(() => this._updateMatches()));
this._register(this._terminal.onResize(() => this._updateMatches()));
this._register(toDisposable(() => this.clearDecorations()));
}
private _updateMatches(): void {
this._highlightTimeout.clear();
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 clearDecorations(retainCachedSearchTerm?: boolean): void {
this._resultTracker.clearSelectedDecoration();
this._decorationManager?.clearHighlightDecorations();
this._resultTracker.clearResults();
if (!retainCachedSearchTerm) {
this._state.clearCachedTerm();
}
}
public clearActiveDecoration(): void {
this._resultTracker.clearSelectedDecoration();
}
/**
* Find the next instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
* @param term The search term.
* @param searchOptions Search options.
* @returns Whether a result was found.
*/
public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal || !this._engine) {
throw new Error('Cannot use addon until it has been loaded');
}
this._state.lastSearchOptions = searchOptions;
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
this._highlightAllMatches(term, searchOptions!);
}
const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);
this._fireResults(searchOptions);
this._state.cachedSearchTerm = term;
return found;
}
private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
if (!this._terminal || !this._engine || !this._decorationManager) {
throw new Error('Cannot use addon until it has been loaded');
}
if (!this._state.isValidSearchTerm(term)) {
this.clearDecorations();
return;
}
// new search, clear out the old decorations
this.clearDecorations(true);
const results: ISearchResult[] = [];
let prevResult: ISearchResult | undefined = undefined;
let result = this._engine.find(term, 0, 0, searchOptions);
while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {
if (results.length >= this._highlightLimit) {
break;
}
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
);
}
this._resultTracker.updateResults(results, this._highlightLimit);
if (searchOptions.decorations) {
this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);
}
}
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal || !this._engine) {
return false;
}
if (!this._state.isValidSearchTerm(term)) {
this._terminal.clearSelection();
this.clearDecorations();
return false;
}
const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
}
/**
* Find the previous instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
* @param term The search term.
* @param searchOptions Search options.
* @returns Whether a result was found.
*/
public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal || !this._engine) {
throw new Error('Cannot use addon until it has been loaded');
}
this._state.lastSearchOptions = searchOptions;
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
this._highlightAllMatches(term, searchOptions!);
}
const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);
this._fireResults(searchOptions);
this._state.cachedSearchTerm = term;
return found;
}
private _fireResults(searchOptions?: ISearchOptions): void {
this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);
}
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
if (!this._terminal || !this._engine) {
return false;
}
if (!this._state.isValidSearchTerm(term)) {
this._terminal.clearSelection();
this.clearDecorations();
return false;
}
const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
}
/**
* Selects and scrolls to a result.
* @param result The result to select.
* @returns Whether a result was selected.
*/
private _selectResult(result: ISearchResult | undefined, options?: any, noScroll?: boolean): boolean {
if (!this._terminal || !this._decorationManager) {
return false;
}
this._resultTracker.clearSelectedDecoration();
if (!result) {
this._terminal.clearSelection();
return false;
}
this._terminal.select(result.col, result.row, result.size);
if (options) {
const activeDecoration = this._decorationManager.createActiveDecoration(result, options);
if (activeDecoration) {
this._resultTracker.selectedDecoration = activeDecoration;
}
}
if (!noScroll) {
// If it is not in the viewport then we scroll else it just gets selected
if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {
let scroll = result.row - this._terminal.buffer.active.viewportY;
scroll -= Math.floor(this._terminal.rows / 2);
this._terminal.scrollLines(scroll);
}
}
return true;
}
}