/** * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ import type { Terminal, IDisposable, ITerminalAddon, IDecoration } from '@xterm/xterm'; import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search'; import { Emitter, type IEvent } from 'common/Event'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi { private _highlightLimit = SearchAddonConstants.DEFAULT_HIGHLIGHT_LIMIT; private _terminal: Terminal | undefined; private readonly _onBeforeSearch = this._register(new Emitter()); public readonly onBeforeSearch: IEvent = this._onBeforeSearch.event; private readonly _onAfterSearch = this._register(new Emitter()); public readonly onAfterSearch: IEvent = this._onAfterSearch.event; private readonly _onDidChangeResults = this._register(new Emitter()); public readonly onDidChangeResults: IEvent = this._onDidChangeResults.event; private readonly _bufferUpdateListener = this._register(new MutableDisposable()); private readonly _resizeListener = this._register(new MutableDisposable()); private readonly _resultsUpdateListener = this._register(new MutableDisposable()); private readonly _matchDecorationSetByMatches = new WeakMap(); private readonly _matchDecorationSetOrder: IMatchDecorationSet[] = []; private _activeMatchDecorationSet: IMatchDecorationSet | undefined; private readonly _activeDecoration = this._register(new MutableDisposable()); private _lastSearchTerm: string | undefined; private _lastSearchOptions: ISearchOptions | undefined; private _lastSearchKey: string | undefined; private _lastSuccessfulNonDecoratedSearchKey: string | undefined; private _resultCount = 0; private _resultIndex = -1; private _pendingResultRefresh = false; private readonly _matchCache = new Map>>(); private readonly _matchCacheOrder: IMatchCacheKey[] = []; private _logicalLineCache: ILogicalLineCache | undefined; private readonly _selectionIndexCache = new WeakMap>>(); private _lastResolvedNavigation: IResolvedNavigation | undefined; constructor(options?: Partial) { super(); if (typeof options?.highlightLimit === 'number' && Number.isFinite(options.highlightLimit) && options.highlightLimit > 0) { this._highlightLimit = Math.floor(options.highlightLimit); } } public activate(terminal: Terminal): void { this._terminal = terminal; this._clearMatchCache(); this._bufferUpdateListener.value = terminal.onWriteParsed(() => { this._clearMatchCache(); }); this._resizeListener.value = terminal.onResize(() => { this._clearMatchCache(); }); } public findNext(term: string, searchOptions?: ISearchOptions): boolean { return this._find(term, searchOptions, 'next'); } public findPrevious(term: string, searchOptions?: ISearchOptions): boolean { return this._find(term, searchOptions, 'previous'); } public clearDecorations(): void { this._disposeDecorations(); this._resultsUpdateListener.clear(); this._resultCount = 0; this._resultIndex = -1; } public clearActiveDecoration(): void { this._activeDecoration.clear(); } private _find(term: string, searchOptions: ISearchOptions | undefined, direction: SearchDirection): boolean { this._onBeforeSearch.fire(); try { const terminal = this._terminal; if (!terminal || term.length === 0) { this._clearStateOnFailedSearch(term, searchOptions, direction); return false; } const currentSearchKey = this._createSearchKey(term, searchOptions, direction); if (!searchOptions?.decorations && !searchOptions?.incremental && direction === 'next' && !this._logicalLineCache && this._lastSuccessfulNonDecoratedSearchKey === currentSearchKey) { const activeMatch = this._findSingleMatchForNext(term, searchOptions); if (!activeMatch) { this._clearStateOnFailedSearch(term, searchOptions, direction); return false; } terminal.select(activeMatch.startX, activeMatch.startY, activeMatch.cellLength); this._revealResult(activeMatch); this._lastSuccessfulNonDecoratedSearchKey = currentSearchKey; this._clearSearchState(); return true; } const matches = this._getMatches(term, searchOptions); if (matches === undefined || matches.length === 0) { this._clearStateOnFailedSearch(term, searchOptions, direction); return false; } const searchKey = currentSearchKey; const nextIndex = this._resolveResultIndex(matches, searchOptions, direction, searchKey); const activeMatch = matches[nextIndex]; terminal.select(activeMatch.startX, activeMatch.startY, activeMatch.cellLength); this._revealResult(activeMatch); this._lastResolvedNavigation = { searchKey, matches, index: nextIndex, selectionStartX: activeMatch.startX, selectionStartY: activeMatch.startY }; if (searchOptions?.decorations) { this._setDecoratedSearchState(term, searchOptions, searchKey, matches.length, nextIndex); this._registerResultRefreshListener(); this._refreshDecorations(matches, searchOptions.decorations, activeMatch); this._onDidChangeResults.fire({ resultCount: this._resultCount, resultIndex: this._resultIndex }); } else { this._lastSuccessfulNonDecoratedSearchKey = searchKey; this._clearSearchState(); } return true; } finally { this._onAfterSearch.fire(); } } private _clearStateOnFailedSearch(term: string, searchOptions: ISearchOptions | undefined, direction: SearchDirection): void { this._lastSuccessfulNonDecoratedSearchKey = undefined; this._lastResolvedNavigation = undefined; if (searchOptions?.decorations) { this._setDecoratedSearchState(term, searchOptions, this._createSearchKey(term, searchOptions, direction), 0, -1); this._registerResultRefreshListener(); this._disposeDecorations(); this._onDidChangeResults.fire({ resultCount: 0, resultIndex: -1 }); return; } if ( this._resultCount === 0 && this._resultIndex === -1 && this._lastSearchTerm === undefined && this._lastSearchOptions === undefined && this._lastSearchKey === undefined && this._matchDecorationSetOrder.length === 0 && !this._activeDecoration.value && !this._resultsUpdateListener.value ) { return; } this._clearSearchState(); } private _registerResultRefreshListener(): void { if (this._resultsUpdateListener.value) { return; } const terminal = this._terminal; if (!terminal) { return; } this._resultsUpdateListener.value = terminal.onWriteParsed(() => { if (this._pendingResultRefresh) { return; } this._pendingResultRefresh = true; queueMicrotask(() => { this._pendingResultRefresh = false; this._refreshResultsAfterBufferChange(); }); }); } private _refreshResultsAfterBufferChange(): void { const terminal = this._terminal; if (!terminal || !this._lastSearchTerm || !this._lastSearchOptions?.decorations) { return; } const matches = this._getMatches(this._lastSearchTerm, this._lastSearchOptions); if (!matches || matches.length === 0) { this._disposeDecorations(); this._resultCount = 0; this._resultIndex = -1; this._onDidChangeResults.fire({ resultCount: 0, resultIndex: -1 }); return; } this._resultCount = matches.length; this._resultIndex = this._findIndexFromSelection(matches); const activeMatch = this._resultIndex === -1 ? undefined : matches[this._resultIndex]; this._refreshDecorations(matches, this._lastSearchOptions.decorations, activeMatch); this._onDidChangeResults.fire({ resultCount: this._resultCount, resultIndex: this._resultIndex }); } private _findAllMatches(term: string, searchOptions?: ISearchOptions): IMatch[] | undefined { const terminal = this._terminal; if (!terminal) { return undefined; } const isRegex = !!searchOptions?.regex; const regex = isRegex ? this._buildRegex(term, searchOptions) : undefined; if (isRegex && !regex) { return undefined; } const normalizedTerm = searchOptions?.caseSensitive ? term : term.toLowerCase(); const wholeWord = !!searchOptions?.wholeWord; const matches: IMatch[] = []; const cols = terminal.cols; const logicalLines = this._getLogicalLines(cols); for (const logicalLine of logicalLines) { let matchedOffsets: number[] | undefined; if (isRegex) { regex!.lastIndex = 0; while (true) { const match = regex!.exec(logicalLine.text); if (!match) { break; } if (match[0].length === 0) { regex!.lastIndex++; continue; } const startOffset = match.index; const endOffset = startOffset + match[0].length; if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { continue; } matchedOffsets ??= []; matchedOffsets.push(startOffset, endOffset); } } else { const haystack = searchOptions?.caseSensitive ? logicalLine.text : (logicalLine.lowerText ??= logicalLine.text.toLowerCase()); let searchIndex = 0; while (searchIndex < haystack.length) { const startOffset = haystack.indexOf(normalizedTerm, searchIndex); if (startOffset === -1) { break; } const endOffset = startOffset + term.length; if (this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { matchedOffsets ??= []; matchedOffsets.push(startOffset, endOffset); } searchIndex = startOffset + 1; } } if (matchedOffsets && matchedOffsets.length > 0) { if (!logicalLine.offsetToPoint || !logicalLine.offsetToLinear) { const offsets = this._buildLogicalLineOffsets(logicalLine.rows, cols); logicalLine.offsetToPoint = offsets.offsetToPoint; logicalLine.offsetToLinear = offsets.offsetToLinear; } const offsetToPoint = logicalLine.offsetToPoint; const offsetToLinear = logicalLine.offsetToLinear; for (let i = 0; i < matchedOffsets.length; i += 2) { const startOffset = matchedOffsets[i]; const endOffset = matchedOffsets[i + 1]; const start = offsetToPoint[startOffset]; const end = offsetToPoint[endOffset]; const startLinear = offsetToLinear[startOffset]; const endLinear = offsetToLinear[endOffset]; matches.push({ startX: start.x, startY: start.y, endX: end.x, endY: end.y, cellLength: Math.max(1, endLinear - startLinear) }); if (matches.length >= this._highlightLimit) { return matches; } } } } return matches; } private _findSingleMatchForNext(term: string, searchOptions?: ISearchOptions): IMatch | undefined { const terminal = this._terminal; if (!terminal) { return undefined; } const selection = terminal.getSelectionPosition(); const selectionStart = selection?.start; const isRegex = !!searchOptions?.regex; const regex = isRegex ? this._buildRegex(term, searchOptions) : undefined; if (isRegex && !regex) { return undefined; } const normalizedTerm = searchOptions?.caseSensitive ? term : term.toLowerCase(); const wholeWord = !!searchOptions?.wholeWord; const cols = terminal.cols; let firstMatch: IMatch | undefined; let selectionMatched = !selectionStart; const buffer = terminal.buffer.active; for (let row = 0; row < buffer.length; row++) { const firstLine = buffer.getLine(row); if (!firstLine || firstLine.isWrapped) { continue; } const rows: number[] = [row]; let nextRow = row + 1; while (nextRow < buffer.length) { const wrappedLine = buffer.getLine(nextRow); if (!wrappedLine?.isWrapped) { break; } rows.push(nextRow); nextRow++; } const logicalLine: ILogicalLineCacheEntry = { rows, text: this._buildLogicalLineText(rows, cols) }; if (isRegex) { regex!.lastIndex = 0; while (true) { const match = regex!.exec(logicalLine.text); if (!match) { break; } if (match[0].length === 0) { regex!.lastIndex++; continue; } const startOffset = match.index; const endOffset = startOffset + match[0].length; if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { continue; } const nextMatch = this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset); if (!firstMatch) { firstMatch = nextMatch; if (selectionMatched) { return firstMatch; } } if (!selectionMatched && selectionStart && nextMatch.startX === selectionStart.x && nextMatch.startY === selectionStart.y) { selectionMatched = true; continue; } if (selectionMatched) { return nextMatch; } } } else { const haystack = searchOptions?.caseSensitive ? logicalLine.text : (logicalLine.lowerText ??= logicalLine.text.toLowerCase()); let searchIndex = 0; while (searchIndex < haystack.length) { const startOffset = haystack.indexOf(normalizedTerm, searchIndex); if (startOffset === -1) { break; } const endOffset = startOffset + term.length; searchIndex = startOffset + 1; if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) { continue; } const nextMatch = this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset); if (!firstMatch) { firstMatch = nextMatch; if (selectionMatched) { return firstMatch; } } if (!selectionMatched && selectionStart && nextMatch.startX === selectionStart.x && nextMatch.startY === selectionStart.y) { selectionMatched = true; continue; } if (selectionMatched) { return nextMatch; } } } row = rows[rows.length - 1]; } return firstMatch; } private _createMatchFromOffsets(logicalLine: ILogicalLineCacheEntry, cols: number, startOffset: number, endOffset: number): IMatch { if (!logicalLine.offsetToPoint || !logicalLine.offsetToLinear) { const offsets = this._buildLogicalLineOffsets(logicalLine.rows, cols); logicalLine.offsetToPoint = offsets.offsetToPoint; logicalLine.offsetToLinear = offsets.offsetToLinear; } const start = logicalLine.offsetToPoint[startOffset]; const end = logicalLine.offsetToPoint[endOffset]; const startLinear = logicalLine.offsetToLinear[startOffset]; const endLinear = logicalLine.offsetToLinear[endOffset]; return { startX: start.x, startY: start.y, endX: end.x, endY: end.y, cellLength: Math.max(1, endLinear - startLinear) }; } private _getMatches(term: string, searchOptions?: ISearchOptions): IMatch[] | undefined { const terminal = this._terminal; if (!terminal) { return undefined; } const cols = terminal.cols; const flags = this._getMatchFlags(searchOptions); const byFlags = this._matchCache.get(cols)?.get(flags); if (byFlags?.has(term)) { return byFlags.get(term); } const matches = this._findAllMatches(term, searchOptions); let byCols = this._matchCache.get(cols); if (!byCols) { byCols = new Map(); this._matchCache.set(cols, byCols); } let writableByFlags = byCols.get(flags); if (!writableByFlags) { writableByFlags = new Map(); byCols.set(flags, writableByFlags); } if (!writableByFlags.has(term)) { if (this._matchCacheOrder.length >= SearchAddonConstants.MAX_MATCH_CACHE_ENTRIES) { const oldest = this._matchCacheOrder.shift(); if (oldest) { const oldestByCols = this._matchCache.get(oldest.cols); const oldestByFlags = oldestByCols?.get(oldest.flags); oldestByFlags?.delete(oldest.term); if (oldestByFlags && oldestByFlags.size === 0) { oldestByCols?.delete(oldest.flags); } if (oldestByCols && oldestByCols.size === 0) { this._matchCache.delete(oldest.cols); } } } this._matchCacheOrder.push({ cols, flags, term }); } writableByFlags.set(term, matches); return matches; } private _clearMatchCache(): void { this._matchCache.clear(); this._matchCacheOrder.length = 0; this._logicalLineCache = undefined; this._lastResolvedNavigation = undefined; this._disposeMatchDecorationSets(); } private _buildRegex(term: string, searchOptions?: ISearchOptions): RegExp | undefined { const flags = searchOptions?.caseSensitive ? 'g' : 'gi'; if (searchOptions?.regex) { try { return new RegExp(term, flags); } catch { return undefined; } } return new RegExp(escapeRegExp(term), flags); } private _getLogicalLines(cols: number): ILogicalLineCacheEntry[] { if (this._logicalLineCache && this._logicalLineCache.cols === cols) { return this._logicalLineCache.entries; } const terminal = this._terminal; if (!terminal) { return []; } const entries: ILogicalLineCacheEntry[] = []; const buffer = terminal.buffer.active; for (let row = 0; row < buffer.length; row++) { const firstLine = buffer.getLine(row); if (!firstLine || firstLine.isWrapped) { continue; } const rows: number[] = [row]; let nextRow = row + 1; while (nextRow < buffer.length) { const wrappedLine = buffer.getLine(nextRow); if (!wrappedLine?.isWrapped) { break; } rows.push(nextRow); nextRow++; } entries.push({ rows, text: this._buildLogicalLineText(rows, cols) }); row = rows[rows.length - 1]; } this._logicalLineCache = { cols, entries }; return entries; } private _buildLogicalLineText(rows: number[], cols: number): string { const terminal = this._terminal; if (!terminal) { return ''; } const textParts: string[] = []; const blankLine = ' '.repeat(cols); for (const row of rows) { const line = terminal.buffer.active.getLine(row); textParts.push(line ? line.translateToString() : blankLine); } return textParts.join(''); } private _buildLogicalLineOffsets(rows: number[], cols: number): ILogicalLineOffsets { const terminal = this._terminal; if (!terminal) { return { offsetToPoint: [{ x: 0, y: rows[0] ?? 0 }], offsetToLinear: [0] }; } const offsetToPoint: IPoint[] = []; const offsetToLinear: number[] = []; let currentOffset = 0; let linearOffset = 0; let rowIndex = 0; let col = 0; const getPoint = (): IPoint => { if (rows.length === 0) { return { x: 0, y: 0 }; } if (rowIndex >= rows.length) { return { x: cols, y: rows[rows.length - 1] }; } return { x: col, y: rows[rowIndex] }; }; offsetToPoint[0] = getPoint(); offsetToLinear[0] = 0; const cell = terminal.buffer.active.getNullCell(); for (const row of rows) { const line = terminal.buffer.active.getLine(row); for (let x = 0; x < cols; x++) { const loadedCell = line?.getCell(x, cell); const width = loadedCell ? loadedCell.getWidth() : 1; if (width === 0) { continue; } const chars = loadedCell?.getChars() || ' '; const codeUnitCount = chars.length; for (let i = 1; i < codeUnitCount; i++) { offsetToPoint[currentOffset + i] = getPoint(); offsetToLinear[currentOffset + i] = linearOffset; } currentOffset += codeUnitCount; linearOffset += width; col += width; while (col >= cols) { col -= cols; rowIndex++; } offsetToPoint[currentOffset] = getPoint(); offsetToLinear[currentOffset] = linearOffset; } } return { offsetToPoint, offsetToLinear }; } private _isWholeWordMatch(text: string, startOffset: number, endOffset: number, wholeWord: boolean): boolean { if (!wholeWord) { return true; } return !isWordCharAt(text, startOffset - 1) && !isWordCharAt(text, endOffset); } private _resolveResultIndex(matches: IMatch[], searchOptions: ISearchOptions | undefined, direction: SearchDirection, currentSearchKey: string): number { const previousNavigation = this._lastResolvedNavigation; const selection = this._terminal?.getSelectionPosition(); if ( previousNavigation && previousNavigation.searchKey === currentSearchKey && previousNavigation.matches === matches ) { if (!selection) { return this._stepResultIndex(previousNavigation.index, matches.length, direction); } if ( selection.start.x === previousNavigation.selectionStartX && selection.start.y === previousNavigation.selectionStartY && previousNavigation.index >= 0 && previousNavigation.index < matches.length ) { return this._stepResultIndex(previousNavigation.index, matches.length, direction); } } const currentSelectionIndex = this._findIndexFromSelection(matches, selection); let isIncrementalUpdate = false; if (searchOptions?.incremental && this._lastSearchKey !== undefined) { isIncrementalUpdate = this._lastSearchKey !== currentSearchKey; } if (isIncrementalUpdate && currentSelectionIndex !== -1) { return currentSelectionIndex; } if (currentSelectionIndex !== -1) { return this._stepResultIndex(currentSelectionIndex, matches.length, direction); } return this._getDirectionalDefaultIndex(matches.length, direction); } private _findIndexFromSelection(matches: IMatch[], selection: ReturnType | undefined = this._terminal?.getSelectionPosition()): number { if (!selection) { return -1; } let byRow = this._selectionIndexCache.get(matches); if (!byRow) { byRow = new Map>(); for (let i = 0; i < matches.length; i++) { const match = matches[i]; let byColumn = byRow.get(match.startY); if (!byColumn) { byColumn = new Map(); byRow.set(match.startY, byColumn); } byColumn.set(match.startX, i); } this._selectionIndexCache.set(matches, byRow); } return byRow.get(selection.start.y)?.get(selection.start.x) ?? -1; } private _revealResult(match: IMatch): void { const terminal = this._terminal; if (!terminal) { return; } const viewportTop = terminal.buffer.active.viewportY; const viewportBottom = viewportTop + terminal.rows - 1; if (match.startY >= viewportTop && match.endY <= viewportBottom) { return; } const middleY = Math.floor((match.startY + match.endY) / 2); terminal.scrollToLine(Math.max(0, middleY - Math.floor(terminal.rows / 2))); } private _refreshDecorations(matches: IMatch[], decorationOptions: ISearchDecorationOptions, activeMatch: IMatch | undefined): void { const terminal = this._terminal; if (!terminal) { return; } const decorationSet = this._getOrCreateMatchDecorationSet(matches, decorationOptions, terminal); if (this._activeMatchDecorationSet !== decorationSet) { this._setMatchDecorationSetActive(this._activeMatchDecorationSet, false); this._activeMatchDecorationSet = decorationSet; this._setMatchDecorationSetActive(decorationSet, true); } this._activeDecoration.clear(); const activeMatchBorder = decorationOptions.activeMatchBorder; if (!activeMatch) { return; } const cursorLine = terminal.buffer.active.baseY + terminal.buffer.active.cursorY; const marker = terminal.registerMarker(activeMatch.startY - cursorLine); const width = this._getDecorationWidth(activeMatch, terminal.cols); const activeDecoration = terminal.registerDecoration({ marker, x: activeMatch.startX, width, backgroundColor: decorationOptions.activeMatchBackground, layer: 'top', overviewRulerOptions: decorationOptions.activeMatchColorOverviewRuler ? { color: decorationOptions.activeMatchColorOverviewRuler } : undefined }); if (!activeDecoration) { this._activeDecoration.clear(); return; } const disposables: IDisposable[] = [activeDecoration]; if (activeMatchBorder) { disposables.push(activeDecoration.onRender(element => { element.style.outline = `1px solid ${activeMatchBorder}`; })); } this._activeDecoration.value = toDisposable(() => { for (const disposable of disposables) { disposable.dispose(); } }); } private _getOrCreateMatchDecorationSet(matches: IMatch[], decorationOptions: ISearchDecorationOptions, terminal: Terminal): IMatchDecorationSet { const optionsKey = this._createDecorationOptionsKey(decorationOptions); const existing = this._matchDecorationSetByMatches.get(matches); if (existing && existing.optionsKey === optionsKey) { this._touchMatchDecorationSet(existing); return existing; } if (existing) { this._disposeMatchDecorationSet(existing); } const created = this._createMatchDecorationSet(matches, decorationOptions, terminal, optionsKey); this._matchDecorationSetByMatches.set(matches, created); this._matchDecorationSetOrder.push(created); this._trimMatchDecorationSetCache(); return created; } private _createMatchDecorationSet(matches: IMatch[], decorationOptions: ISearchDecorationOptions, terminal: Terminal, optionsKey: string): IMatchDecorationSet { const set: IMatchDecorationSet = { matches, options: decorationOptions, optionsKey, decorations: [], disposables: [], isActive: false }; const matchBorder = decorationOptions.matchBorder; const cursorLine = terminal.buffer.active.baseY + terminal.buffer.active.cursorY; const inactiveOverviewRulerOptions = undefined; for (const match of matches) { const marker = terminal.registerMarker(match.startY - cursorLine); const width = this._getDecorationWidth(match, terminal.cols); const decoration = terminal.registerDecoration({ marker, x: match.startX, width, backgroundColor: decorationOptions.matchBackground, layer: 'bottom', overviewRulerOptions: inactiveOverviewRulerOptions }); if (!decoration) { continue; } set.decorations.push(decoration); set.disposables.push(decoration); set.disposables.push(this._registerMatchDecorationRender(decoration, set, matchBorder)); } return set; } private _setMatchDecorationSetActive(set: IMatchDecorationSet | undefined, isActive: boolean): void { if (!set || set.isActive === isActive) { return; } if (!isActive) { this._disposeMatchDecorationSet(set); return; } set.isActive = isActive; const overviewRulerOptions = isActive && set.options.matchOverviewRuler ? { color: set.options.matchOverviewRuler } : undefined; for (const decoration of set.decorations) { decoration.options.overviewRulerOptions = overviewRulerOptions; if (decoration.element) { this._applyMatchDecorationStyle(decoration.element, isActive, set.options.matchBorder); } } } private _applyMatchDecorationStyle(element: HTMLElement, isActive: boolean, border: string | undefined): void { if (isActive) { element.style.outline = border ? `1px solid ${border}` : ''; } else { element.style.outline = ''; } } private _getDecorationWidth(match: IMatch, cols: number): number { return Math.max(1, Math.min(match.cellLength, cols - match.startX)); } private _registerMatchDecorationRender( decoration: IDecoration, set: IMatchDecorationSet, border: string | undefined ): IDisposable { return decoration.onRender(element => { this._applyMatchDecorationStyle(element, set.isActive, border); }); } private _createDecorationOptionsKey(decorationOptions: ISearchDecorationOptions): string { return [ decorationOptions.matchBackground, decorationOptions.matchBorder, decorationOptions.matchOverviewRuler, decorationOptions.activeMatchBackground, decorationOptions.activeMatchBorder, decorationOptions.activeMatchColorOverviewRuler ].join('|'); } private _touchMatchDecorationSet(set: IMatchDecorationSet): void { const index = this._matchDecorationSetOrder.indexOf(set); if (index !== -1) { this._matchDecorationSetOrder.splice(index, 1); this._matchDecorationSetOrder.push(set); } } private _trimMatchDecorationSetCache(): void { while (this._matchDecorationSetOrder.length > SearchAddonConstants.MAX_DECORATION_CACHE_ENTRIES) { const candidate = this._matchDecorationSetOrder[0]; if (candidate === this._activeMatchDecorationSet && this._matchDecorationSetOrder.length > 1) { this._matchDecorationSetOrder.push(this._matchDecorationSetOrder.shift()!); continue; } this._disposeMatchDecorationSet(this._matchDecorationSetOrder.shift()!); } } private _disposeMatchDecorationSet(set: IMatchDecorationSet): void { for (const disposable of set.disposables) { disposable.dispose(); } set.disposables.length = 0; set.decorations.length = 0; this._matchDecorationSetByMatches.delete(set.matches); const index = this._matchDecorationSetOrder.indexOf(set); if (index !== -1) { this._matchDecorationSetOrder.splice(index, 1); } if (this._activeMatchDecorationSet === set) { this._activeMatchDecorationSet = undefined; } } private _disposeMatchDecorationSets(): void { while (this._matchDecorationSetOrder.length > 0) { this._disposeMatchDecorationSet(this._matchDecorationSetOrder[0]); } this._activeMatchDecorationSet = undefined; } private _disposeDecorations(): void { this._activeDecoration.clear(); this._disposeMatchDecorationSets(); } private _createSearchKey(term: string, searchOptions: ISearchOptions | undefined, direction: SearchDirection): string { return `${this._createMatchKey(term, searchOptions)}|${direction}`; } private _createMatchKey(term: string, searchOptions: ISearchOptions | undefined): string { const flags = this._getMatchFlags(searchOptions); return `${term}|${flags}`; } private _getMatchFlags(searchOptions: ISearchOptions | undefined): MatchFlags { return ( (searchOptions?.caseSensitive ? MatchFlags.CASE_SENSITIVE : 0) | (searchOptions?.regex ? MatchFlags.REGEX : 0) | (searchOptions?.wholeWord ? MatchFlags.WHOLE_WORD : 0) ) as MatchFlags; } private _setDecoratedSearchState( term: string, searchOptions: ISearchOptions, searchKey: string, resultCount: number, resultIndex: number ): void { this._resultCount = resultCount; this._resultIndex = resultIndex >= 0 && resultIndex < resultCount ? resultIndex : -1; this._lastSearchTerm = term; this._lastSearchOptions = searchOptions; this._lastSearchKey = searchKey; } private _clearSearchState(): void { this._disposeDecorations(); this._resultsUpdateListener.clear(); this._resultCount = 0; this._resultIndex = -1; this._lastSearchTerm = undefined; this._lastSearchOptions = undefined; this._lastSearchKey = undefined; } private _stepResultIndex(index: number, length: number, direction: SearchDirection): number { if (direction === 'next') { return (index + 1) % length; } return (index + length - 1) % length; } private _getDirectionalDefaultIndex(length: number, direction: SearchDirection): number { return direction === 'next' ? 0 : length - 1; } } interface ILogicalLineCacheEntry { rows: number[]; text: string; lowerText?: string; offsetToPoint?: IPoint[]; offsetToLinear?: number[]; } interface ILogicalLineCache { cols: number; entries: ILogicalLineCacheEntry[]; } interface ILogicalLineOffsets { offsetToPoint: IPoint[]; offsetToLinear: number[]; } interface IPoint { x: number; y: number; } interface IMatch { startX: number; startY: number; endX: number; endY: number; cellLength: number; } interface IResolvedNavigation { searchKey: string; matches: IMatch[]; index: number; selectionStartX: number; selectionStartY: number; } type SearchDirection = 'next' | 'previous'; interface IMatchDecorationSet { matches: IMatch[]; options: ISearchDecorationOptions; optionsKey: string; decorations: IDecoration[]; disposables: IDisposable[]; isActive: boolean; } interface IMatchCacheKey { cols: number; flags: MatchFlags; term: string; } const enum SearchAddonConstants { /** Default maximum number of matches to highlight during search. */ DEFAULT_HIGHLIGHT_LIMIT = 1000, /** Maximum number of cached match-result entries across search keys. */ MAX_MATCH_CACHE_ENTRIES = 16, /** Maximum number of cached decoration sets retained for reuse. */ MAX_DECORATION_CACHE_ENTRIES = 2 } const enum MatchFlags { CASE_SENSITIVE = 1, REGEX = 2, WHOLE_WORD = 4 } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function isWordCharAt(value: string, index: number): boolean { if (index < 0 || index >= value.length) { return false; } return isWordCode(value.charCodeAt(index)); } function isWordCode(code: number): boolean { return ( (code >= 48 && code <= 57) || // 0-9 (code >= 65 && code <= 90) || // A-Z (code >= 97 && code <= 122) || // a-z code === 95 // _ ); }