Remove old search impl
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
||||
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a highlight decoration.
|
||||
*/
|
||||
interface IHighlight extends IDisposable {
|
||||
decoration: IDecoration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for managing multiple decorations for a single match.
|
||||
*/
|
||||
interface IMultiHighlight extends IDisposable {
|
||||
decorations: IDecoration[];
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages visual decorations for search results including highlighting and active selection
|
||||
* indicators. This class handles the creation, styling, and disposal of search-related decorations.
|
||||
*/
|
||||
export class DecorationManager extends Disposable {
|
||||
private _highlightDecorations: IHighlight[] = [];
|
||||
private _highlightedLines: Set<number> = new Set();
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this.clearHighlightDecorations()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for all provided search results.
|
||||
* @param results The search results to create decorations for.
|
||||
* @param options The decoration options.
|
||||
*/
|
||||
public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {
|
||||
this.clearHighlightDecorations();
|
||||
|
||||
for (const match of results) {
|
||||
const decorations = this._createResultDecorations(match, options, false);
|
||||
if (decorations) {
|
||||
for (const decoration of decorations) {
|
||||
this._storeDecoration(decoration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for the currently active search result.
|
||||
* @param result The active search result.
|
||||
* @param options The decoration options.
|
||||
* @returns The multi-highlight decoration or undefined if creation failed.
|
||||
*/
|
||||
public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {
|
||||
const decorations = this._createResultDecorations(result, options, true);
|
||||
if (decorations) {
|
||||
return { decorations, match: result, dispose() { dispose(decorations); } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all highlight decorations.
|
||||
*/
|
||||
public clearHighlightDecorations(): void {
|
||||
dispose(this._highlightDecorations);
|
||||
this._highlightDecorations = [];
|
||||
this._highlightedLines.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a decoration and tracks it for management.
|
||||
* @param decoration The decoration to store.
|
||||
* @param match The search result this decoration represents.
|
||||
*/
|
||||
private _storeDecoration(decoration: IDecoration): void {
|
||||
this._highlightedLines.add(decoration.marker.line);
|
||||
this._highlightDecorations.push({ decoration, dispose() { decoration.dispose(); } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies styles to the decoration when it is rendered.
|
||||
* @param element The decoration's element.
|
||||
* @param borderColor The border color to apply.
|
||||
* @param isActiveResult Whether the element is part of the active search result.
|
||||
*/
|
||||
private _getOverviewRulerOptions(line: number, isActiveResult: boolean, options: ISearchDecorationOptions) {
|
||||
if (this._highlightedLines.has(line)) {
|
||||
return undefined;
|
||||
}
|
||||
const color = isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler;
|
||||
if (!color) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
color,
|
||||
position: 'center' as const
|
||||
};
|
||||
}
|
||||
|
||||
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
|
||||
if (!element.classList.contains('xterm-find-result-decoration')) {
|
||||
element.classList.add('xterm-find-result-decoration');
|
||||
if (borderColor) {
|
||||
element.style.outline = `1px solid ${borderColor}`;
|
||||
}
|
||||
}
|
||||
if (isActiveResult) {
|
||||
element.classList.add('xterm-find-active-result-decoration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a decoration for the result and applies styles
|
||||
* @param result the search result for which to create the decoration
|
||||
* @param options the options for the decoration
|
||||
* @param isActiveResult whether this is the currently active result
|
||||
* @returns the decorations or undefined if the marker has already been disposed of
|
||||
*/
|
||||
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
|
||||
const decorations: IDecoration[] = [];
|
||||
const cols = this._terminal.cols;
|
||||
const layer = isActiveResult ? 'top' : 'bottom';
|
||||
const backgroundColor = isActiveResult ? options.activeMatchBackground : options.matchBackground;
|
||||
const borderColor = isActiveResult ? options.activeMatchBorder : options.matchBorder;
|
||||
let currentCol = result.col;
|
||||
let remainingSize = result.size;
|
||||
let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;
|
||||
while (remainingSize > 0) {
|
||||
const amountThisRow = Math.min(cols - currentCol, remainingSize);
|
||||
const marker = this._terminal.registerMarker(markerOffset);
|
||||
const decoration = this._terminal.registerDecoration({
|
||||
marker,
|
||||
x: currentCol,
|
||||
width: amountThisRow,
|
||||
layer,
|
||||
backgroundColor,
|
||||
overviewRulerOptions: this._getOverviewRulerOptions(marker.line, isActiveResult, options)
|
||||
});
|
||||
if (decoration) {
|
||||
const renderDisposable = decoration.onRender((e) => this._applyStyles(e, borderColor, isActiveResult));
|
||||
decoration.onDispose(() => {
|
||||
marker.dispose();
|
||||
renderDisposable.dispose();
|
||||
});
|
||||
decorations.push(decoration);
|
||||
} else {
|
||||
marker.dispose();
|
||||
}
|
||||
currentCol = 0;
|
||||
remainingSize -= amountThisRow;
|
||||
markerOffset++;
|
||||
}
|
||||
|
||||
return decorations.length === 0 ? undefined : decorations;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
|
||||
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { SearchState } from './SearchState';
|
||||
import { SearchEngine, type ISearchResult } from './SearchEngine';
|
||||
import { DecorationManager } from './DecorationManager';
|
||||
import { SearchResultTracker } from './SearchResultTracker';
|
||||
|
||||
interface IInternalSearchOptions {
|
||||
noScroll: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration constants for the search addon functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Default maximum number of search results to highlight simultaneously. This limit prevents
|
||||
* performance degradation when searching for very common terms that would result in excessive
|
||||
* highlighting decorations.
|
||||
*/
|
||||
DEFAULT_HIGHLIGHT_LIMIT = 1000
|
||||
}
|
||||
|
||||
export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {
|
||||
private _terminal: Terminal | undefined;
|
||||
private _highlightLimit: number;
|
||||
private _highlightTimeout = this._register(new MutableDisposable<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());
|
||||
|
||||
private readonly _onAfterSearch = this._register(new Emitter<void>());
|
||||
public readonly onAfterSearch = this._onAfterSearch.event;
|
||||
private readonly _onBeforeSearch = this._register(new Emitter<void>());
|
||||
public readonly onBeforeSearch = this._onBeforeSearch.event;
|
||||
|
||||
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> {
|
||||
return this._resultTracker.onDidChangeResults;
|
||||
}
|
||||
|
||||
constructor(options?: Partial<ISearchAddonOptions>) {
|
||||
super();
|
||||
|
||||
const limit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;
|
||||
this._highlightLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : Constants.DEFAULT_HIGHLIGHT_LIMIT;
|
||||
}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
this._lineCache.value = new SearchLineCache(terminal);
|
||||
this._engine = new SearchEngine(terminal, this._lineCache.value);
|
||||
this._decorationManager = new DecorationManager(terminal);
|
||||
this._register(this._terminal.onWriteParsed(() => this._updateMatches()));
|
||||
this._register(this._terminal.onResize(() => this._updateMatches()));
|
||||
this._register(toDisposable(() => this.clearDecorations()));
|
||||
}
|
||||
|
||||
private _updateMatches(): void {
|
||||
this._highlightTimeout.clear();
|
||||
const lastOptions = this._state.lastSearchOptions;
|
||||
if (this._state.cachedSearchTerm && lastOptions?.decorations) {
|
||||
this._highlightTimeout.value = disposableTimeout(() => {
|
||||
const term = this._state.cachedSearchTerm!;
|
||||
this._refreshHighlights(term, lastOptions);
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
private _refreshHighlights(term: string, searchOptions: ISearchOptions): void {
|
||||
if (!this._terminal || !this._engine || !this._decorationManager) {
|
||||
return;
|
||||
}
|
||||
if (!this._state.isValidSearchTerm(term) || !searchOptions.decorations) {
|
||||
return;
|
||||
}
|
||||
this._highlightAllMatches(term, searchOptions);
|
||||
this._fireResults(searchOptions);
|
||||
}
|
||||
|
||||
public clearDecorations(retainCachedSearchTerm?: boolean): void {
|
||||
this._highlightTimeout.clear();
|
||||
const hadResults = this._resultTracker.searchResults.length > 0;
|
||||
this._resultTracker.clearSelectedDecoration();
|
||||
this._decorationManager?.clearHighlightDecorations();
|
||||
this._resultTracker.clearResults();
|
||||
if (!retainCachedSearchTerm) {
|
||||
this._state.clearCachedTerm();
|
||||
this._state.lastSearchOptions = undefined;
|
||||
}
|
||||
if (hadResults && this._state.hadDecorations) {
|
||||
this._resultTracker.fireResultsChanged(true);
|
||||
}
|
||||
}
|
||||
|
||||
public clearActiveDecoration(): void {
|
||||
this._resultTracker.clearSelectedDecoration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next instance of the term, then scroll to and select it. If it
|
||||
* doesn't exist, do nothing.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @returns Whether a result was found.
|
||||
*/
|
||||
public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
|
||||
this._highlightTimeout.clear();
|
||||
this._onBeforeSearch.fire();
|
||||
let found = false;
|
||||
try {
|
||||
if (this._state.shouldClearDecorations(searchOptions)) {
|
||||
this.clearDecorations(true);
|
||||
}
|
||||
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
|
||||
this._highlightAllMatches(term, searchOptions!);
|
||||
}
|
||||
|
||||
found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);
|
||||
this._state.cachedSearchTerm = term;
|
||||
this._state.lastSearchOptions = searchOptions;
|
||||
this._fireResults(searchOptions);
|
||||
} finally {
|
||||
this._onAfterSearch.fire();
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
|
||||
if (!this._terminal || !this._engine || !this._decorationManager) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
if (!this._state.isValidSearchTerm(term)) {
|
||||
this.clearDecorations();
|
||||
return;
|
||||
}
|
||||
|
||||
// new search, clear out the old decorations
|
||||
this.clearDecorations(true);
|
||||
|
||||
const results = this._engine.collectMatches(term, searchOptions, this._highlightLimit);
|
||||
|
||||
this._resultTracker.updateResults(results, this._highlightLimit);
|
||||
if (searchOptions.decorations) {
|
||||
this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);
|
||||
}
|
||||
}
|
||||
|
||||
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
return false;
|
||||
}
|
||||
if (!this._state.isValidSearchTerm(term)) {
|
||||
this._terminal.clearSelection();
|
||||
this.clearDecorations();
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);
|
||||
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the previous instance of the term, then scroll to and select it. If it
|
||||
* doesn't exist, do nothing.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @returns Whether a result was found.
|
||||
*/
|
||||
public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
|
||||
this._highlightTimeout.clear();
|
||||
this._onBeforeSearch.fire();
|
||||
let found = false;
|
||||
try {
|
||||
if (this._state.shouldClearDecorations(searchOptions)) {
|
||||
this.clearDecorations(true);
|
||||
}
|
||||
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
|
||||
this._highlightAllMatches(term, searchOptions!);
|
||||
}
|
||||
|
||||
found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);
|
||||
this._state.cachedSearchTerm = term;
|
||||
this._state.lastSearchOptions = searchOptions;
|
||||
this._fireResults(searchOptions);
|
||||
} finally {
|
||||
this._onAfterSearch.fire();
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private _fireResults(searchOptions?: ISearchOptions): void {
|
||||
this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);
|
||||
}
|
||||
|
||||
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
return false;
|
||||
}
|
||||
if (!this._state.isValidSearchTerm(term)) {
|
||||
this._terminal.clearSelection();
|
||||
this.clearDecorations();
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);
|
||||
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects and scrolls to a result.
|
||||
* @param result The result to select.
|
||||
* @returns Whether a result was selected.
|
||||
*/
|
||||
private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {
|
||||
if (!this._terminal || !this._decorationManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._resultTracker.clearSelectedDecoration();
|
||||
if (!result) {
|
||||
this._terminal.clearSelection();
|
||||
return false;
|
||||
}
|
||||
|
||||
this._terminal.select(result.col, result.row, result.size);
|
||||
if (options) {
|
||||
const activeDecoration = this._decorationManager.createActiveDecoration(result, options);
|
||||
if (activeDecoration) {
|
||||
this._resultTracker.selectedDecoration = activeDecoration;
|
||||
}
|
||||
}
|
||||
|
||||
if (!noScroll) {
|
||||
// If it is not in the viewport then we scroll else it just gets selected
|
||||
if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {
|
||||
let scroll = result.row - this._terminal.buffer.active.viewportY;
|
||||
scroll -= Math.floor(this._terminal.rows / 2);
|
||||
this._terminal.scrollLines(scroll);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchEngine } from './SearchEngine';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import { DisposableStore } from 'common/Lifecycle';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchEngine', () => {
|
||||
let store: DisposableStore;
|
||||
let terminal: Terminal;
|
||||
let lineCache: SearchLineCache;
|
||||
let searchEngine: SearchEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new DisposableStore();
|
||||
terminal = store.add(new Terminal({ cols: 80, rows: 24 }));
|
||||
lineCache = store.add(new SearchLineCache(terminal));
|
||||
searchEngine = new SearchEngine(terminal, lineCache);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
describe('find', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should find basic text in terminal content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text starting from specified position', async () => {
|
||||
await writeP(terminal, 'Hello Hello Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 7), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should search across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1\r\nLine 2 target\r\nLine 3');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 7,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when text is not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('NotFound', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should throw error for invalid column position', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.throws(() => {
|
||||
searchEngine.find('Hello', 0, 100);
|
||||
}, /Invalid col: 100 to search in terminal of 80 cols/);
|
||||
});
|
||||
|
||||
it('should handle search starting from last column', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('Hello', 0, 79), undefined);
|
||||
});
|
||||
|
||||
it('should handle search from middle of match', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('llo', 0, 3), undefined); // Should not find partial match that starts before search position
|
||||
});
|
||||
});
|
||||
|
||||
describe('search options', () => {
|
||||
describe('caseSensitive', () => {
|
||||
it('should find text with case-insensitive search (default)', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text with case-sensitive search when enabled', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find text with case-sensitive search when case differs', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { caseSensitive: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wholeWord', () => {
|
||||
it('should find whole word when enabled', async () => {
|
||||
await writeP(terminal, 'Hello world wonderful');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find partial word when wholeWord is enabled', async () => {
|
||||
await writeP(terminal, 'Hello wonderful');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
|
||||
it('should find later whole word after partial match on same line', async () => {
|
||||
await writeP(terminal, 'foobar foo');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('foo', 0, 0, { wholeWord: true }), {
|
||||
term: 'foo',
|
||||
col: 7,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('should find word at beginning of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'world is great');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find word at end of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'hello world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle word boundaries with punctuation', async () => {
|
||||
await writeP(terminal, 'hello,world!test');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not match when not whole word', async () => {
|
||||
await writeP(terminal, 'helloworld');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex', () => {
|
||||
it('should find text using simple regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello 123 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[0-9]+', 0, 0, { regex: true }), {
|
||||
term: '123',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-insensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { regex: true, caseSensitive: false }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-sensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex regex patterns', async () => {
|
||||
await writeP(terminal, 'Email: test@example.com and another@domain.org');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 0, 0, { regex: true }), {
|
||||
term: 'test@example.com',
|
||||
col: 7,
|
||||
row: 0,
|
||||
size: 16
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Invalid regex should be handled gracefully
|
||||
assert.throws(() => {
|
||||
searchEngine.find('[invalid', 0, 0, { regex: true });
|
||||
}, /Invalid regular expression/);
|
||||
});
|
||||
|
||||
it('should handle empty regex matches', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored
|
||||
});
|
||||
|
||||
it('should ignore empty regex matches when searching backwards', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection('.*?', { regex: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined options', () => {
|
||||
it('should handle regex + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[A-Z]+', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'H',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wholeWord + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD wonderful');
|
||||
|
||||
const result1 = searchEngine.find('WORLD', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.deepStrictEqual(result1, {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const result2 = searchEngine.find('world', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.strictEqual(result2, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find first occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find next occurrence after current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock the getSelectionPosition to return a selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around to beginning when reaching end', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at the end
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
}); // Should wrap to first occurrence
|
||||
});
|
||||
|
||||
it('should wrap across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1 test\r\nLine 2\r\nLine 3 test');
|
||||
|
||||
// Mock selection at first "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 7, y: 0 }, end: { x: 11, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('test', undefined, 'test'), {
|
||||
term: 'test',
|
||||
col: 7,
|
||||
row: 2,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
it('should return same selection if only one match exists', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Mock selection at "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPreviousWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find last occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findPreviousWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find previous occurrence before current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock selection at second "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// It may find the same selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should wrap around to end when reaching beginning', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Due to the expansion attempt, it may find the same Hello first
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should work across multiple rows in reverse', async () => {
|
||||
await writeP(terminal, 'test Line 1\r\nLine 2\r\ntest Line 3');
|
||||
|
||||
// Mock selection at last "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 4, y: 2 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('test');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm will find the current selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.row, 'number');
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
it('should handle selection expansion correctly', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm tries expansion first, so it may find the same Hello
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
describe('unicode and special characters', () => {
|
||||
it('should handle unicode characters correctly', async () => {
|
||||
await writeP(terminal, 'Hello 世界 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('世界', 0, 0), {
|
||||
term: '世界',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, '中文测试');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('测试', 0, 0), {
|
||||
term: '测试',
|
||||
col: 4,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('wrapped lines', () => {
|
||||
it('should handle search across wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(100) + 'target' + 'B'.repeat(50);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wrapped lines with unicode', async () => {
|
||||
const longText = '中'.repeat(50) + 'target' + '文'.repeat(30);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip wrapped lines correctly in findInLine', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText + '\r\nNext line with target');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 15,
|
||||
row: 3,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer boundaries', () => {
|
||||
|
||||
|
||||
it('should handle empty buffer gracefully', () => {
|
||||
assert.strictEqual(searchEngine.find('anything', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should handle search beyond buffer size', () => {
|
||||
assert.strictEqual(searchEngine.find('test', 1000, 0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should handle undefined search options gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, undefined), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative start positions', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', -1, -1), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search options with undefined properties', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
const options: ISearchOptions = {
|
||||
caseSensitive: undefined,
|
||||
regex: undefined,
|
||||
wholeWord: undefined
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, options), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private method behaviors (tested indirectly)', () => {
|
||||
describe('_isWholeWord behavior', () => {
|
||||
it('should recognize word boundaries with various punctuation', async () => {
|
||||
await writeP(terminal, 'word1 word2,word3(word4)word5[word6]word7{word8}');
|
||||
|
||||
const tests = [
|
||||
{ term: 'word1', expected: true },
|
||||
{ term: 'word2', expected: true },
|
||||
{ term: 'word3', expected: true },
|
||||
{ term: 'word4', expected: true },
|
||||
{ term: 'word5', expected: true },
|
||||
{ term: 'word6', expected: true },
|
||||
{ term: 'word7', expected: true },
|
||||
{ term: 'word8', expected: true }
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const result = searchEngine.find(test.term, 0, 0, { wholeWord: true });
|
||||
if (test.expected) {
|
||||
assert.notStrictEqual(result, undefined, `Should find whole word: ${test.term}`);
|
||||
} else {
|
||||
assert.strictEqual(result, undefined, `Should not find non-whole word: ${test.term}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle word boundaries at line start and end', async () => {
|
||||
await writeP(terminal, 'start middle end');
|
||||
|
||||
const startResult = searchEngine.find('start', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(startResult, {
|
||||
term: 'start',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const endResult = searchEngine.find('end', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(endResult, {
|
||||
term: 'end',
|
||||
col: 13,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
|
||||
const middleResult = searchEngine.find('middle', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(middleResult, {
|
||||
term: 'middle',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer offset calculations', () => {
|
||||
it('should handle wide character offset calculations', async () => {
|
||||
await writeP(terminal, '中文 test 测试');
|
||||
|
||||
const result = searchEngine.find('test', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.term, 'test');
|
||||
// Exact column position depends on wide character handling
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('string to buffer size conversions', () => {
|
||||
it('should correctly calculate size for simple text', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly calculate size for unicode text', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
|
||||
const result = searchEngine.find('世界', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Size should account for wide characters
|
||||
assert.strictEqual(typeof result!.size, 'number');
|
||||
assert.strictEqual(result!.size >= 2, true);
|
||||
});
|
||||
|
||||
it('should handle size calculation across wrapped lines', async () => {
|
||||
const longMatch = 'A'.repeat(100);
|
||||
await writeP(terminal, longMatch);
|
||||
|
||||
const result = searchEngine.find(longMatch, 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.size >= 100, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with SearchLineCache', () => {
|
||||
it('should use cache for line translation', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Initialize cache
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('World', 0, 0);
|
||||
const result2 = searchEngine.find('World', 0, 0);
|
||||
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
assert.notStrictEqual(result2, undefined);
|
||||
assert.deepStrictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('should handle cache misses gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Don't initialize cache
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should work correctly with cache invalidation', async () => {
|
||||
await writeP(terminal, 'Initial text');
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('Initial', 0, 0);
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
|
||||
// Change terminal content which should invalidate cache
|
||||
await writeP(terminal, '\r\nNew line');
|
||||
|
||||
const result2 = searchEngine.find('New', 0, 0);
|
||||
assert.deepStrictEqual(result2, {
|
||||
term: 'New',
|
||||
col: 0,
|
||||
row: 1,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,623 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import type { SearchLineCache } from './SearchLineCache';
|
||||
|
||||
/**
|
||||
* Represents the position to start a search from.
|
||||
*/
|
||||
interface ISearchPosition {
|
||||
startCol: number;
|
||||
startRow: number;
|
||||
}
|
||||
|
||||
interface INoMatchMemo {
|
||||
term: string;
|
||||
optionsKey: string;
|
||||
bufferGeneration: number;
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
isReverse: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a search result with its position and content.
|
||||
*/
|
||||
export interface ISearchResult {
|
||||
term: string;
|
||||
col: number;
|
||||
row: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration constants for the search engine functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Characters that are considered non-word characters for search boundary detection. These
|
||||
* characters are used to determine word boundaries when performing whole-word searches. Includes
|
||||
* common punctuation, symbols, and whitespace characters.
|
||||
*/
|
||||
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'
|
||||
}
|
||||
|
||||
/**
|
||||
* Core search engine that handles finding text within terminal content.
|
||||
* This class is responsible for the actual search algorithms and position calculations.
|
||||
*/
|
||||
export class SearchEngine {
|
||||
private _noMatchMemo: INoMatchMemo | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _lineCache: SearchLineCache
|
||||
) {}
|
||||
|
||||
private _searchOptionsKey(searchOptions?: ISearchOptions): string {
|
||||
return `${searchOptions?.caseSensitive ? 1 : 0}:${searchOptions?.regex ? 1 : 0}:${searchOptions?.wholeWord ? 1 : 0}`;
|
||||
}
|
||||
|
||||
private _matchesNoMatchMemo(term: string, startRow: number, startCol: number, isReverse: boolean, searchOptions?: ISearchOptions): boolean {
|
||||
const memo = this._noMatchMemo;
|
||||
if (!memo) {
|
||||
return false;
|
||||
}
|
||||
return memo.term === term &&
|
||||
memo.optionsKey === this._searchOptionsKey(searchOptions) &&
|
||||
memo.bufferGeneration === this._lineCache.cacheGeneration &&
|
||||
memo.startRow === startRow &&
|
||||
memo.startCol === startCol &&
|
||||
memo.isReverse === isReverse;
|
||||
}
|
||||
|
||||
private _recordNoMatchMemo(term: string, startRow: number, startCol: number, isReverse: boolean, searchOptions?: ISearchOptions): void {
|
||||
this._noMatchMemo = {
|
||||
term,
|
||||
optionsKey: this._searchOptionsKey(searchOptions),
|
||||
bufferGeneration: this._lineCache.cacheGeneration,
|
||||
startRow,
|
||||
startCol,
|
||||
isReverse
|
||||
};
|
||||
}
|
||||
|
||||
private _clearNoMatchMemo(): void {
|
||||
this._noMatchMemo = undefined;
|
||||
}
|
||||
|
||||
private _createLineSearchRegex(term: string, searchOptions?: ISearchOptions): RegExp | undefined {
|
||||
if (!searchOptions?.regex) {
|
||||
return undefined;
|
||||
}
|
||||
return RegExp(term, searchOptions.caseSensitive ? 'g' : 'gi');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first occurrence of a term starting from a specific position.
|
||||
* @param term The search term.
|
||||
* @param startRow The row to start searching from.
|
||||
* @param startCol The column to start searching from.
|
||||
* @param searchOptions Search options.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
if (startCol > this._terminal.cols) {
|
||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
if (this._matchesNoMatchMemo(term, startRow, startCol, false, searchOptions)) {
|
||||
return undefined;
|
||||
}
|
||||
const lineSearchRegex = this._createLineSearchRegex(term, searchOptions);
|
||||
const result = this._findFromPosition(term, startRow, startCol, searchOptions, lineSearchRegex);
|
||||
if (result) {
|
||||
this._clearNoMatchMemo();
|
||||
} else {
|
||||
this._recordNoMatchMemo(term, startRow, startCol, false, searchOptions);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect up to `maxResults` matches from the start of the buffer in a single search session.
|
||||
*/
|
||||
public collectMatches(term: string, searchOptions?: ISearchOptions, maxResults: number = Number.MAX_SAFE_INTEGER): ISearchResult[] {
|
||||
if (!term || term.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
const lineSearchRegex = this._createLineSearchRegex(term, searchOptions);
|
||||
const results: ISearchResult[] = [];
|
||||
let startRow = 0;
|
||||
let startCol = 0;
|
||||
const cols = this._terminal.cols;
|
||||
|
||||
while (results.length < maxResults) {
|
||||
const result = this._findFromPosition(term, startRow, startCol, searchOptions, lineSearchRegex);
|
||||
if (!result) {
|
||||
break;
|
||||
}
|
||||
const prevResult = results[results.length - 1];
|
||||
if (prevResult && prevResult.row === result.row && prevResult.col === result.col) {
|
||||
break;
|
||||
}
|
||||
results.push(result);
|
||||
if (result.col + result.size >= cols) {
|
||||
startRow = result.row + 1;
|
||||
startCol = 0;
|
||||
} else {
|
||||
startRow = result.row;
|
||||
startCol = result.col + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private _getBufferEndRow(): number {
|
||||
return this._terminal.buffer.active.baseY + this._terminal.rows;
|
||||
}
|
||||
|
||||
private _getPreparedPlainTerm(term: string, searchOptions?: ISearchOptions): string {
|
||||
return searchOptions?.caseSensitive ? term : term.toLowerCase();
|
||||
}
|
||||
|
||||
private _findFromPosition(
|
||||
term: string,
|
||||
startRow: number,
|
||||
startCol: number,
|
||||
searchOptions: ISearchOptions | undefined,
|
||||
lineSearchRegex: RegExp | undefined
|
||||
): ISearchResult | undefined {
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
const preparedPlainTerm = searchOptions?.regex ? undefined : this._getPreparedPlainTerm(term, searchOptions);
|
||||
|
||||
let result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex, preparedPlainTerm);
|
||||
if (!result) {
|
||||
const bufferEnd = this._getBufferEndRow();
|
||||
for (let y = startRow + 1; y < bufferEnd; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex, preparedPlainTerm);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine incremental behavior.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startCol = 0;
|
||||
let startRow = 0;
|
||||
if (prevSelectedPos) {
|
||||
if (cachedSearchTerm === term) {
|
||||
startCol = prevSelectedPos.end.x;
|
||||
startRow = prevSelectedPos.end.y;
|
||||
} else {
|
||||
startCol = prevSelectedPos.start.x;
|
||||
startRow = prevSelectedPos.start.y;
|
||||
}
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
if (this._matchesNoMatchMemo(term, startRow, startCol, false, searchOptions)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
const lineSearchRegex = this._createLineSearchRegex(term, searchOptions);
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the bottom and didn't search from the very top wrap back up
|
||||
if (!result && startRow !== 0) {
|
||||
for (let y = 0; y < startRow; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is only one result, wrap back and return selection if it exists.
|
||||
if (!result && prevSelectedPos) {
|
||||
searchPosition.startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
this._clearNoMatchMemo();
|
||||
} else {
|
||||
this._recordNoMatchMemo(term, startRow, startCol, false, searchOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the previous occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine if expansion should occur.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
||||
let startCol = this._terminal.cols;
|
||||
const isReverseSearch = true;
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
if (this._matchesNoMatchMemo(term, startRow, startCol, true, searchOptions)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
const lineSearchRegex = this._createLineSearchRegex(term, searchOptions);
|
||||
|
||||
let result: ISearchResult | undefined;
|
||||
if (prevSelectedPos) {
|
||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
||||
if (cachedSearchTerm !== term) {
|
||||
// Try to expand selection to right first.
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false, lineSearchRegex);
|
||||
if (!result) {
|
||||
// If selection was not able to be expanded to the right, then try reverse search
|
||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch, lineSearchRegex);
|
||||
|
||||
// Search from startRow - 1 to top
|
||||
if (!result) {
|
||||
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
|
||||
for (let y = startRow - 1; y >= 0; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch, lineSearchRegex);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the top and didn't search from the very bottom wrap back down
|
||||
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
|
||||
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch, lineSearchRegex);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
this._clearNoMatchMemo();
|
||||
} else {
|
||||
this._recordNoMatchMemo(term, startRow, startCol, true, searchOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A found substring is a whole word if it doesn't have an alphanumeric character directly
|
||||
* adjacent to it.
|
||||
* @param searchIndex starting index of the potential whole word substring
|
||||
* @param line entire string in which the potential whole word was found
|
||||
* @param term the substring that starts at searchIndex
|
||||
*/
|
||||
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
|
||||
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
|
||||
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a regex match in a line in a single pass, optionally enforcing whole-word boundaries.
|
||||
*/
|
||||
private _findRegexMatch(
|
||||
searchStringLine: string,
|
||||
searchRegex: RegExp,
|
||||
offset: number,
|
||||
isReverseSearch: boolean,
|
||||
wholeWord: boolean
|
||||
): { resultIndex: number; term: string } | undefined {
|
||||
let resultIndex = -1;
|
||||
let matchedTerm = '';
|
||||
|
||||
if (!isReverseSearch && !wholeWord) {
|
||||
searchRegex.lastIndex = offset;
|
||||
const foundTerm = searchRegex.exec(searchStringLine);
|
||||
if (foundTerm && foundTerm[0].length > 0) {
|
||||
return {
|
||||
resultIndex: searchRegex.lastIndex - foundTerm[0].length,
|
||||
term: foundTerm[0]
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
searchRegex.lastIndex = 0;
|
||||
if (isReverseSearch) {
|
||||
let foundTerm: RegExpExecArray | null;
|
||||
while (foundTerm = searchRegex.exec(searchStringLine)) {
|
||||
if (foundTerm[0].length === 0) {
|
||||
if (searchRegex.lastIndex >= searchStringLine.length) {
|
||||
break;
|
||||
}
|
||||
searchRegex.lastIndex++;
|
||||
continue;
|
||||
}
|
||||
const candidateIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
if (candidateIndex + foundTerm[0].length > offset) {
|
||||
break;
|
||||
}
|
||||
if (!wholeWord || this._isWholeWord(candidateIndex, searchStringLine, foundTerm[0])) {
|
||||
resultIndex = candidateIndex;
|
||||
matchedTerm = foundTerm[0];
|
||||
}
|
||||
searchRegex.lastIndex -= (foundTerm[0].length - 1);
|
||||
}
|
||||
} else {
|
||||
searchRegex.lastIndex = offset;
|
||||
let foundTerm: RegExpExecArray | null;
|
||||
while (foundTerm = searchRegex.exec(searchStringLine)) {
|
||||
if (foundTerm[0].length === 0) {
|
||||
if (searchRegex.lastIndex >= searchStringLine.length) {
|
||||
break;
|
||||
}
|
||||
searchRegex.lastIndex++;
|
||||
continue;
|
||||
}
|
||||
const candidateIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
if (this._isWholeWord(candidateIndex, searchStringLine, foundTerm[0])) {
|
||||
resultIndex = candidateIndex;
|
||||
matchedTerm = foundTerm[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resultIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { resultIndex, term: matchedTerm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
|
||||
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
|
||||
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
|
||||
* be properly searched when the terminal line that the text starts on is searched.
|
||||
* @param term The search term.
|
||||
* @param searchPosition The position to start the search.
|
||||
* @param searchOptions Search options.
|
||||
* @param isReverseSearch Whether the search should start from the right side of the terminal and
|
||||
* search to the left.
|
||||
* @returns The search result if it was found.
|
||||
*/
|
||||
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false, lineSearchRegex?: RegExp, preparedPlainTerm?: string): ISearchResult | undefined {
|
||||
const row = searchPosition.startRow;
|
||||
const col = searchPosition.startCol;
|
||||
const cols = this._terminal.cols;
|
||||
|
||||
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
||||
const firstLine = this._terminal.buffer.active.getLine(row);
|
||||
if (firstLine?.isWrapped) {
|
||||
if (isReverseSearch) {
|
||||
searchPosition.startCol += cols;
|
||||
return;
|
||||
}
|
||||
|
||||
// This will iterate until we find the line start.
|
||||
// When we find it, we will search using the calculated start column.
|
||||
searchPosition.startRow--;
|
||||
searchPosition.startCol += cols;
|
||||
return this._findInLine(term, searchPosition, searchOptions, isReverseSearch, lineSearchRegex, preparedPlainTerm);
|
||||
}
|
||||
let cache = this._lineCache.getLineFromCache(row);
|
||||
if (!cache) {
|
||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
||||
this._lineCache.setLineInCache(row, cache);
|
||||
}
|
||||
const [stringLine, offsets] = cache;
|
||||
|
||||
const offset = this._bufferColsToStringOffset(row, col);
|
||||
let searchTerm = term;
|
||||
let searchStringLine = stringLine;
|
||||
if (!searchOptions.regex) {
|
||||
searchTerm = preparedPlainTerm ?? this._getPreparedPlainTerm(term, searchOptions);
|
||||
if (searchOptions.caseSensitive) {
|
||||
searchStringLine = stringLine;
|
||||
} else {
|
||||
let lowerLine = cache[2];
|
||||
if (!lowerLine) {
|
||||
lowerLine = stringLine.toLowerCase();
|
||||
cache[2] = lowerLine;
|
||||
}
|
||||
searchStringLine = lowerLine;
|
||||
}
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (searchOptions.regex) {
|
||||
const searchRegex = lineSearchRegex ?? RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
|
||||
const regexMatch = this._findRegexMatch(
|
||||
searchStringLine,
|
||||
searchRegex,
|
||||
offset,
|
||||
isReverseSearch,
|
||||
!!searchOptions.wholeWord
|
||||
);
|
||||
if (regexMatch) {
|
||||
resultIndex = regexMatch.resultIndex;
|
||||
term = regexMatch.term;
|
||||
}
|
||||
} else if (isReverseSearch) {
|
||||
let searchEnd = offset - searchTerm.length;
|
||||
while (searchEnd >= 0) {
|
||||
resultIndex = searchStringLine.lastIndexOf(searchTerm, searchEnd);
|
||||
if (resultIndex < 0) {
|
||||
break;
|
||||
}
|
||||
if (!searchOptions.wholeWord || this._isWholeWord(resultIndex, searchStringLine, searchTerm)) {
|
||||
break;
|
||||
}
|
||||
searchEnd = resultIndex - 1;
|
||||
resultIndex = -1;
|
||||
}
|
||||
} else if (!searchOptions.wholeWord) {
|
||||
resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
||||
} else {
|
||||
let searchStart = offset;
|
||||
while (searchStart <= searchStringLine.length - searchTerm.length) {
|
||||
resultIndex = searchStringLine.indexOf(searchTerm, searchStart);
|
||||
if (resultIndex < 0) {
|
||||
break;
|
||||
}
|
||||
if (this._isWholeWord(resultIndex, searchStringLine, searchTerm)) {
|
||||
break;
|
||||
}
|
||||
searchStart = resultIndex + 1;
|
||||
resultIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (resultIndex >= 0) {
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
||||
// rows
|
||||
let startRowOffset = 0;
|
||||
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
|
||||
startRowOffset++;
|
||||
}
|
||||
let endRowOffset = startRowOffset;
|
||||
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
|
||||
endRowOffset++;
|
||||
}
|
||||
const startColOffset = resultIndex - offsets[startRowOffset];
|
||||
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
|
||||
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
|
||||
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
|
||||
const size = endColIndex - startColIndex + cols * (endRowOffset - startRowOffset);
|
||||
|
||||
return {
|
||||
term,
|
||||
col: startColIndex,
|
||||
row: row + startRowOffset,
|
||||
size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _stringLengthToBufferSize(row: number, offset: number): number {
|
||||
const line = this._terminal.buffer.active.getLine(row);
|
||||
if (!line) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
// Adjust the searchIndex to normalize emoji into single chars
|
||||
const char = cell.getChars();
|
||||
if (char.length > 1) {
|
||||
offset -= char.length - 1;
|
||||
}
|
||||
// Adjust the searchIndex for empty characters following wide unicode
|
||||
// chars (eg. CJK)
|
||||
const nextCell = line.getCell(i + 1);
|
||||
if (nextCell && nextCell.getWidth() === 0) {
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
||||
let lineIndex = startRow;
|
||||
let offset = 0;
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (cols > 0 && line) {
|
||||
for (let i = 0; i < cols && i < this._terminal.cols; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
if (cell.getWidth()) {
|
||||
// Treat null characters as whitespace to align with the translateToString API
|
||||
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
||||
}
|
||||
}
|
||||
lineIndex++;
|
||||
line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
if (line && !line.isWrapped) {
|
||||
break;
|
||||
}
|
||||
cols -= this._terminal.cols;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import { timeout } from 'common/Async';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchLineCache', () => {
|
||||
let terminal: Terminal;
|
||||
let cache: SearchLineCache;
|
||||
|
||||
beforeEach(() => {
|
||||
terminal = new Terminal({ cols: 80, rows: 24 });
|
||||
cache = new SearchLineCache(terminal);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cache.dispose();
|
||||
terminal.dispose();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a SearchLineCache instance', () => {
|
||||
assert.instanceOf(cache, SearchLineCache);
|
||||
});
|
||||
|
||||
it('should start with no cache initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initLinesCache', () => {
|
||||
it('should initialize the lines cache array', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty');
|
||||
});
|
||||
|
||||
it('should not reinitialize if cache already exists', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
cache.initLinesCache();
|
||||
|
||||
const entry = cache.getLineFromCache(0);
|
||||
assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry');
|
||||
});
|
||||
|
||||
it('should set up TTL timeout', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLineFromCache', () => {
|
||||
it('should return undefined when cache is not initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(10), undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for unset entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(50), undefined);
|
||||
});
|
||||
|
||||
it('should return cached entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(5, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(5), entry);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLineInCache', () => {
|
||||
it('should not set entries when cache is not initialized', () => {
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should set entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(10, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(10), entry);
|
||||
});
|
||||
|
||||
it('should overwrite existing entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry1: LineCacheEntry = ['first content', [0]];
|
||||
const entry2: LineCacheEntry = ['second content', [0]];
|
||||
|
||||
cache.setLineInCache(0, entry1);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry1);
|
||||
|
||||
cache.setLineInCache(0, entry2);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateBufferLineToStringWithWrap', () => {
|
||||
it('should translate a single line without wrapping', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello World');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle trimRight parameter', async () => {
|
||||
await writeP(terminal, 'Hello World ');
|
||||
const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false);
|
||||
|
||||
assert.equal(resultTrimmed[0].trimEnd(), 'Hello World');
|
||||
assert.isTrue(resultNotTrimmed[0].startsWith('Hello World '));
|
||||
assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer');
|
||||
});
|
||||
|
||||
it('should handle wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longText);
|
||||
assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping');
|
||||
assert.equal(result[1][0], 0, 'first offset should be 0');
|
||||
});
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello 世界');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle lines beyond buffer', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(1000, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle complex wrapped content', async () => {
|
||||
await writeP(terminal, 'Line 1\r\n');
|
||||
await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n');
|
||||
await writeP(terminal, 'Line 3');
|
||||
|
||||
const result1 = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const result2 = cache.translateBufferLineToStringWithWrap(1, true);
|
||||
const result3 = cache.translateBufferLineToStringWithWrap(2, true);
|
||||
|
||||
assert.equal(result1[0], 'Line 1');
|
||||
assert.equal(result2[0], 'Line 2 with some longer content that might wrap');
|
||||
assert.equal(result3[0], 'Line 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should invalidate cache on line feed', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.write('test\r\n');
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on cursor move', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
await writeP(terminal, 'some text');
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on resize', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.resize(100, 30);
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disposal', () => {
|
||||
it('should clean up resources on dispose', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal');
|
||||
});
|
||||
|
||||
it('should be safe to dispose multiple times', () => {
|
||||
cache.initLinesCache();
|
||||
cache.dispose();
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LineCacheEntry type', () => {
|
||||
it('should handle complex line offsets', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'A very long line that wraps multiple times across several terminal lines',
|
||||
[0, 20, 40, 60]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0].length, 72);
|
||||
assert.equal(retrieved![1].length, 4);
|
||||
});
|
||||
|
||||
it('should lazily cache lowercased line text via SearchEngine usage', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
cache.initLinesCache();
|
||||
const entry = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
cache.setLineInCache(0, entry);
|
||||
assert.equal(entry[2], undefined);
|
||||
entry[2] = entry[0].toLowerCase();
|
||||
cache.setLineInCache(0, entry);
|
||||
assert.strictEqual(cache.getLineFromCache(0)?.[2], 'hello world');
|
||||
});
|
||||
|
||||
it('should handle unicode characters in cache entries', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'Hello 世界 🌍 测试',
|
||||
[0]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0], 'Hello 世界 🌍 测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with real terminal content', () => {
|
||||
it('should correctly translate real buffer content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const cached = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || '';
|
||||
|
||||
assert.equal(cached[0], directTranslation);
|
||||
});
|
||||
|
||||
it('should handle real wrapped content correctly', async () => {
|
||||
const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache';
|
||||
await writeP(terminal, longContent);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longContent);
|
||||
assert.isTrue(result[1].length > 1, 'should have wrapped');
|
||||
});
|
||||
|
||||
it('should work with real escape sequences', async () => {
|
||||
await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.include(result[0], 'Before');
|
||||
assert.include(result[0], 'Red Text');
|
||||
assert.include(result[0], 'After');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
|
||||
export type LineCacheEntry = [
|
||||
/**
|
||||
* The string representation of a line (as opposed to the buffer cell representation).
|
||||
*/
|
||||
lineAsString: string,
|
||||
/**
|
||||
* The offsets where each line starts when the entry describes a wrapped line.
|
||||
*/
|
||||
lineOffsets: number[],
|
||||
/**
|
||||
* Lowercased line text for case-insensitive searches, lazily populated.
|
||||
*/
|
||||
lineAsLower?: string
|
||||
];
|
||||
|
||||
/**
|
||||
* Configuration constants for the search line cache functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Time-to-live for cached search results in milliseconds. After this duration, cached search
|
||||
* results will be invalidated to ensure they remain consistent with terminal content changes.
|
||||
*/
|
||||
LINES_CACHE_TIME_TO_LIVE = 15000
|
||||
}
|
||||
|
||||
export class SearchLineCache extends Disposable {
|
||||
/**
|
||||
* translateBufferLineToStringWithWrap is a fairly expensive call.
|
||||
* We memoize the calls into an array that has a time based ttl.
|
||||
* _linesCache is also invalidated when the terminal cursor moves.
|
||||
*/
|
||||
private _linesCache: LineCacheEntry[] | undefined;
|
||||
private _cacheGeneration = 0;
|
||||
private _linesCacheTimeout = this._register(new MutableDisposable());
|
||||
private _linesCacheDisposables = this._register(new MutableDisposable());
|
||||
// Track access to avoid recreating a timeout on every init call which occurs once per search
|
||||
// result (findNext/findPrevious -> _highlightAllMatches -> find loop).
|
||||
private _lastAccessTimestamp = 0;
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this._destroyLinesCache()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a line cache with a ttl
|
||||
*/
|
||||
public initLinesCache(): void {
|
||||
if (!this._linesCache) {
|
||||
this._linesCache = new Array(this._terminal.buffer.active.length);
|
||||
this._linesCacheDisposables.value = combinedDisposable(
|
||||
this._terminal.onLineFeed(() => this._destroyLinesCache()),
|
||||
this._terminal.onCursorMove(() => this._destroyLinesCache()),
|
||||
this._terminal.onResize(() => this._destroyLinesCache())
|
||||
);
|
||||
}
|
||||
|
||||
this._lastAccessTimestamp = Date.now();
|
||||
if (!this._linesCacheTimeout.value) {
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);
|
||||
}
|
||||
}
|
||||
|
||||
public get cacheGeneration(): number {
|
||||
return this._cacheGeneration;
|
||||
}
|
||||
|
||||
private _destroyLinesCache(): void {
|
||||
this._cacheGeneration++;
|
||||
this._linesCache = undefined;
|
||||
this._lastAccessTimestamp = 0;
|
||||
this._linesCacheDisposables.clear();
|
||||
this._linesCacheTimeout.clear();
|
||||
}
|
||||
|
||||
private _scheduleLinesCacheTimeout(delay: number): void {
|
||||
this._linesCacheTimeout.value = disposableTimeout(() => {
|
||||
if (!this._linesCache) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const elapsed = now - this._lastAccessTimestamp;
|
||||
if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {
|
||||
this._destroyLinesCache();
|
||||
return;
|
||||
}
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
public getLineFromCache(row: number): LineCacheEntry | undefined {
|
||||
return this._linesCache?.[row];
|
||||
}
|
||||
|
||||
public setLineInCache(row: number, entry: LineCacheEntry): void {
|
||||
if (this._linesCache) {
|
||||
this._linesCache[row] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a buffer line to a string, including subsequent lines if they are wraps.
|
||||
* Wide characters will count as two columns in the resulting string. This
|
||||
* function is useful for getting the actual text underneath the raw selection
|
||||
* position.
|
||||
* @param lineIndex The index of the line being translated.
|
||||
* @param trimRight Whether to trim whitespace to the right.
|
||||
*/
|
||||
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
||||
const strings = [];
|
||||
const lineOffsets = [0];
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (line) {
|
||||
const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);
|
||||
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
||||
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
||||
if (lineWrapsToNext && nextLine) {
|
||||
const lastCell = line.getCell(line.length - 1);
|
||||
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
|
||||
// a wide character wrapped to the next line
|
||||
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
|
||||
string = string.slice(0, -1);
|
||||
}
|
||||
}
|
||||
strings.push(string);
|
||||
if (lineWrapsToNext) {
|
||||
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
lineIndex++;
|
||||
line = nextLine;
|
||||
}
|
||||
return [strings.join(''), lineOffsets];
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchResultTracker } from './SearchResultTracker';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
describe('SearchResultTracker', () => {
|
||||
let tracker: SearchResultTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new SearchResultTracker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
describe('updateResults', () => {
|
||||
it('should store results without copying when within limit', () => {
|
||||
const results: ISearchResult[] = [{ term: 'a', row: 0, col: 0, size: 1 }];
|
||||
tracker.updateResults(results, 10);
|
||||
assert.strictEqual(tracker.searchResults, results);
|
||||
});
|
||||
|
||||
it('should cap results at maxResults', () => {
|
||||
const results: ISearchResult[] = [
|
||||
{ term: 'a', row: 0, col: 0, size: 1 },
|
||||
{ term: 'b', row: 0, col: 1, size: 1 },
|
||||
{ term: 'c', row: 0, col: 2, size: 1 }
|
||||
];
|
||||
tracker.updateResults(results, 2);
|
||||
assert.strictEqual(tracker.searchResults.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findResultIndex', () => {
|
||||
it('should return the index of a tracked result', () => {
|
||||
const results: ISearchResult[] = [
|
||||
{ term: 'a', row: 0, col: 0, size: 1 },
|
||||
{ term: 'b', row: 0, col: 2, size: 1 }
|
||||
];
|
||||
tracker.updateResults(results, 10);
|
||||
assert.strictEqual(tracker.findResultIndex(results[1]), 1);
|
||||
});
|
||||
|
||||
it('should return -1 when the result is not tracked', () => {
|
||||
tracker.updateResults([{ term: 'a', row: 0, col: 0, size: 1 }], 10);
|
||||
assert.strictEqual(tracker.findResultIndex({ term: 'z', row: 1, col: 0, size: 1 }), -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fireResultsChanged', () => {
|
||||
it('should not fire when decorations are disabled', () => {
|
||||
let fired = false;
|
||||
tracker.onDidChangeResults(() => fired = true);
|
||||
tracker.updateResults([{ term: 'a', row: 0, col: 0, size: 1 }], 10);
|
||||
tracker.fireResultsChanged(false);
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectedDecoration', () => {
|
||||
it('should dispose the previous decoration when replaced', () => {
|
||||
let disposeCount = 0;
|
||||
const first = { match: { term: 'a', row: 0, col: 0, size: 1 }, dispose: () => disposeCount++ };
|
||||
const second = { match: { term: 'b', row: 0, col: 1, size: 1 }, dispose: () => {} };
|
||||
tracker.selectedDecoration = first;
|
||||
tracker.selectedDecoration = second;
|
||||
assert.strictEqual(disposeCount, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
||||
import type { IDisposable } from '@xterm/xterm';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { Disposable, MutableDisposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a currently selected decoration.
|
||||
*/
|
||||
interface ISelectedDecoration extends IDisposable {
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks search results, manages result indexing, and fires events when results change.
|
||||
* This class provides centralized management of search result state and notifications.
|
||||
*/
|
||||
export class SearchResultTracker extends Disposable {
|
||||
private _searchResults: ISearchResult[] = [];
|
||||
private _resultIndexByKey = new Map<string, number>();
|
||||
private _selectedDecoration = this._register(new MutableDisposable<ISelectedDecoration>());
|
||||
|
||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
||||
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
||||
|
||||
/**
|
||||
* Gets the current search results.
|
||||
*/
|
||||
public get searchResults(): ReadonlyArray<ISearchResult> {
|
||||
return this._searchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected decoration.
|
||||
*/
|
||||
public get selectedDecoration(): ISelectedDecoration | undefined {
|
||||
return this._selectedDecoration.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the currently selected decoration.
|
||||
*/
|
||||
public set selectedDecoration(decoration: ISelectedDecoration | undefined) {
|
||||
this._selectedDecoration.value = decoration;
|
||||
}
|
||||
|
||||
private _resultKey(result: ISearchResult): string {
|
||||
return `${result.row}:${result.col}:${result.size}`;
|
||||
}
|
||||
|
||||
private _rebuildResultIndex(): void {
|
||||
this._resultIndexByKey.clear();
|
||||
for (let i = 0; i < this._searchResults.length; i++) {
|
||||
this._resultIndexByKey.set(this._resultKey(this._searchResults[i]), i);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the search results with a new set of results.
|
||||
* @param results The new search results.
|
||||
* @param maxResults The maximum number of results to track.
|
||||
*/
|
||||
public updateResults(results: ISearchResult[], maxResults: number): void {
|
||||
this._searchResults = results.length <= maxResults ? results : results.slice(0, maxResults);
|
||||
this._rebuildResultIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all search results.
|
||||
*/
|
||||
public clearResults(): void {
|
||||
this._searchResults = [];
|
||||
this._resultIndexByKey.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the selected decoration.
|
||||
*/
|
||||
public clearSelectedDecoration(): void {
|
||||
this._selectedDecoration.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of a result in the current results array.
|
||||
* @param result The result to find.
|
||||
* @returns The index of the result, or -1 if not found.
|
||||
*/
|
||||
public findResultIndex(result: ISearchResult): number {
|
||||
return this._resultIndexByKey.get(this._resultKey(result)) ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a result change event with the current state.
|
||||
* @param hasDecorations Whether decorations are enabled.
|
||||
*/
|
||||
public fireResultsChanged(hasDecorations: boolean): void {
|
||||
if (!hasDecorations) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (this._selectedDecoration.value) {
|
||||
resultIndex = this.findResultIndex(this._selectedDecoration.value.match);
|
||||
}
|
||||
|
||||
this._onDidChangeResults.fire({
|
||||
resultIndex,
|
||||
resultCount: this._searchResults.length
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.clearSelectedDecoration();
|
||||
this.clearResults();
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchState } from './SearchState';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
|
||||
const DECORATIONS_A: ISearchOptions = {
|
||||
decorations: {
|
||||
activeMatchColorOverviewRuler: '#ff0000',
|
||||
matchOverviewRuler: '#00ffff'
|
||||
}
|
||||
};
|
||||
|
||||
const DECORATIONS_B: ISearchOptions = {
|
||||
decorations: {
|
||||
activeMatchColorOverviewRuler: '#00ff00',
|
||||
matchOverviewRuler: '#00ffff'
|
||||
}
|
||||
};
|
||||
|
||||
describe('SearchState', () => {
|
||||
let state: SearchState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new SearchState();
|
||||
});
|
||||
|
||||
describe('didOptionsChange', () => {
|
||||
it('should detect caseSensitive changes', () => {
|
||||
state.lastSearchOptions = { caseSensitive: false };
|
||||
assert.strictEqual(state.didOptionsChange({ caseSensitive: true }), true);
|
||||
});
|
||||
|
||||
it('should detect decoration option changes', () => {
|
||||
state.lastSearchOptions = DECORATIONS_A;
|
||||
assert.strictEqual(state.didOptionsChange(DECORATIONS_B), true);
|
||||
});
|
||||
|
||||
it('should return false when semantic options are unchanged', () => {
|
||||
const options: ISearchOptions = { caseSensitive: false, regex: false, wholeWord: false };
|
||||
state.lastSearchOptions = options;
|
||||
assert.strictEqual(state.didOptionsChange({ caseSensitive: false, regex: false, wholeWord: false }), false);
|
||||
});
|
||||
|
||||
it('should treat omitted flags as equivalent to false', () => {
|
||||
state.lastSearchOptions = { caseSensitive: false, regex: false, wholeWord: false };
|
||||
assert.strictEqual(state.didOptionsChange({}), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldClearDecorations', () => {
|
||||
it('should clear when decorations are disabled after being enabled', () => {
|
||||
state.lastSearchOptions = DECORATIONS_A;
|
||||
assert.strictEqual(state.shouldClearDecorations({ caseSensitive: false }), true);
|
||||
});
|
||||
|
||||
it('should not clear when decorations remain enabled', () => {
|
||||
state.lastSearchOptions = DECORATIONS_A;
|
||||
assert.strictEqual(state.shouldClearDecorations(DECORATIONS_A), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldUpdateHighlighting', () => {
|
||||
it('should update when options change for the same term', () => {
|
||||
state.cachedSearchTerm = 'test';
|
||||
state.lastSearchOptions = { caseSensitive: false, decorations: DECORATIONS_A.decorations };
|
||||
assert.strictEqual(state.shouldUpdateHighlighting('test', { caseSensitive: true, decorations: DECORATIONS_A.decorations }), true);
|
||||
});
|
||||
|
||||
it('should not update without decorations', () => {
|
||||
state.cachedSearchTerm = 'test';
|
||||
state.lastSearchOptions = { caseSensitive: false };
|
||||
assert.strictEqual(state.shouldUpdateHighlighting('test', { caseSensitive: true }), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchDecorationOptions, ISearchOptions } from '@xterm/addon-search';
|
||||
|
||||
interface INormalizedSearchOptions {
|
||||
caseSensitive: boolean;
|
||||
regex: boolean;
|
||||
wholeWord: boolean;
|
||||
incremental: boolean;
|
||||
decorations?: ISearchDecorationOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages search state including cached search terms, options tracking, and validation.
|
||||
* This class provides a centralized way to handle search state consistency and option changes.
|
||||
*/
|
||||
export class SearchState {
|
||||
private _cachedSearchTerm: string | undefined;
|
||||
private _lastSearchOptions: INormalizedSearchOptions | undefined;
|
||||
private _hadDecorations: boolean = false;
|
||||
|
||||
/**
|
||||
* Gets the currently cached search term.
|
||||
*/
|
||||
public get cachedSearchTerm(): string | undefined {
|
||||
return this._cachedSearchTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cached search term.
|
||||
*/
|
||||
public set cachedSearchTerm(term: string | undefined) {
|
||||
this._cachedSearchTerm = term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last search options used.
|
||||
*/
|
||||
public get lastSearchOptions(): ISearchOptions | undefined {
|
||||
return this._toPublicOptions(this._lastSearchOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last search options used.
|
||||
*/
|
||||
public set lastSearchOptions(options: ISearchOptions | undefined) {
|
||||
this._lastSearchOptions = options ? this._normalizeOptions(options) : undefined;
|
||||
this._hadDecorations = !!this._lastSearchOptions?.decorations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the previous search used decorations.
|
||||
*/
|
||||
public get hadDecorations(): boolean {
|
||||
return this._hadDecorations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a search term to ensure it's not empty or invalid.
|
||||
* @param term The search term to validate.
|
||||
* @returns true if the term is valid for searching.
|
||||
*/
|
||||
public isValidSearchTerm(term: string): boolean {
|
||||
return !!(term && term.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if search options have changed compared to the last search.
|
||||
* @param newOptions The new search options to compare.
|
||||
* @returns true if the options have changed.
|
||||
*/
|
||||
public didOptionsChange(newOptions?: ISearchOptions): boolean {
|
||||
if (!this._lastSearchOptions) {
|
||||
return true;
|
||||
}
|
||||
if (!newOptions) {
|
||||
return false;
|
||||
}
|
||||
const normalized = this._normalizeOptions(newOptions);
|
||||
if (this._lastSearchOptions.caseSensitive !== normalized.caseSensitive) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.regex !== normalized.regex) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.wholeWord !== normalized.wholeWord) {
|
||||
return true;
|
||||
}
|
||||
if (this._didDecorationOptionsChange(this._lastSearchOptions.decorations, normalized.decorations)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _normalizeOptions(options: ISearchOptions): INormalizedSearchOptions {
|
||||
return {
|
||||
caseSensitive: !!options.caseSensitive,
|
||||
regex: !!options.regex,
|
||||
wholeWord: !!options.wholeWord,
|
||||
incremental: !!options.incremental,
|
||||
decorations: options.decorations ? { ...options.decorations } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private _toPublicOptions(options?: INormalizedSearchOptions): ISearchOptions | undefined {
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
caseSensitive: options.caseSensitive,
|
||||
regex: options.regex,
|
||||
wholeWord: options.wholeWord,
|
||||
incremental: options.incremental,
|
||||
decorations: options.decorations ? { ...options.decorations } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private _didDecorationOptionsChange(previous?: ISearchDecorationOptions, current?: ISearchDecorationOptions): boolean {
|
||||
if (!previous && !current) {
|
||||
return false;
|
||||
}
|
||||
if (!previous || !current) {
|
||||
return true;
|
||||
}
|
||||
return previous.matchBackground !== current.matchBackground ||
|
||||
previous.matchBorder !== current.matchBorder ||
|
||||
previous.matchOverviewRuler !== current.matchOverviewRuler ||
|
||||
previous.activeMatchBackground !== current.activeMatchBackground ||
|
||||
previous.activeMatchBorder !== current.activeMatchBorder ||
|
||||
previous.activeMatchColorOverviewRuler !== current.activeMatchColorOverviewRuler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a new search should trigger highlighting updates.
|
||||
* @param term The search term.
|
||||
* @param options The search options.
|
||||
* @returns true if highlighting should be updated.
|
||||
*/
|
||||
public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {
|
||||
if (!options?.decorations) {
|
||||
return false;
|
||||
}
|
||||
return this._cachedSearchTerm === undefined ||
|
||||
term !== this._cachedSearchTerm ||
|
||||
this.didOptionsChange(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether decorations were disabled after previously being enabled.
|
||||
*/
|
||||
public shouldClearDecorations(options?: ISearchOptions): boolean {
|
||||
return this._hadDecorations && !options?.decorations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached search term.
|
||||
*/
|
||||
public clearCachedTerm(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
this._lastSearchOptions = undefined;
|
||||
this._hadDecorations = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user