Optimize addon-search search hot paths with reusable match and logical-line caches.
Avoid recomputing full-buffer scans on repeated queries and defer expensive offset mapping work until matches are found, reducing navigation and refresh benchmark runtimes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
|||||||
|
|
||||||
export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {
|
export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {
|
||||||
private static readonly _defaultHighlightLimit = 1000;
|
private static readonly _defaultHighlightLimit = 1000;
|
||||||
|
private static readonly _maxMatchCacheEntries = 16;
|
||||||
|
|
||||||
private _terminal: Terminal | undefined;
|
private _terminal: Terminal | undefined;
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
||||||
public readonly onDidChangeResults: IEvent<ISearchResultChangeEvent> = this._onDidChangeResults.event;
|
public readonly onDidChangeResults: IEvent<ISearchResultChangeEvent> = this._onDidChangeResults.event;
|
||||||
|
|
||||||
|
private readonly _bufferUpdateListener = this._register(new MutableDisposable<IDisposable>());
|
||||||
|
private readonly _resizeListener = this._register(new MutableDisposable<IDisposable>());
|
||||||
private readonly _resultsUpdateListener = this._register(new MutableDisposable<IDisposable>());
|
private readonly _resultsUpdateListener = this._register(new MutableDisposable<IDisposable>());
|
||||||
|
|
||||||
private readonly _matchDecorations: IDisposable[] = [];
|
private readonly _matchDecorations: IDisposable[] = [];
|
||||||
@@ -34,6 +37,9 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
private _lastSearchKey: string | undefined;
|
private _lastSearchKey: string | undefined;
|
||||||
private _resultCount = 0;
|
private _resultCount = 0;
|
||||||
private _resultIndex = -1;
|
private _resultIndex = -1;
|
||||||
|
private readonly _matchCache = new Map<string, IMatch[] | undefined>();
|
||||||
|
private _logicalLineCache: ILogicalLineCache | undefined;
|
||||||
|
private readonly _selectionIndexCache = new WeakMap<IMatch[], Map<string, number>>();
|
||||||
|
|
||||||
constructor(options?: Partial<ISearchAddonOptions>) {
|
constructor(options?: Partial<ISearchAddonOptions>) {
|
||||||
super();
|
super();
|
||||||
@@ -44,6 +50,13 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
|
|
||||||
public activate(terminal: Terminal): void {
|
public activate(terminal: Terminal): void {
|
||||||
this._terminal = terminal;
|
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 {
|
public findNext(term: string, searchOptions?: ISearchOptions): boolean {
|
||||||
@@ -74,7 +87,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = this._findAllMatches(term, searchOptions);
|
const matches = this._getMatches(term, searchOptions);
|
||||||
if (matches === undefined || matches.length === 0) {
|
if (matches === undefined || matches.length === 0) {
|
||||||
this._clearStateOnFailedSearch(term, searchOptions, direction);
|
this._clearStateOnFailedSearch(term, searchOptions, direction);
|
||||||
return false;
|
return false;
|
||||||
@@ -124,6 +137,18 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
this._onDidChangeResults.fire({ resultCount: 0, resultIndex: -1 });
|
this._onDidChangeResults.fire({ resultCount: 0, resultIndex: -1 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this._resultCount === 0 &&
|
||||||
|
this._resultIndex === -1 &&
|
||||||
|
this._lastSearchTerm === undefined &&
|
||||||
|
this._lastSearchOptions === undefined &&
|
||||||
|
this._lastSearchKey === undefined &&
|
||||||
|
this._matchDecorations.length === 0 &&
|
||||||
|
!this._activeDecoration.value &&
|
||||||
|
!this._resultsUpdateListener.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._disposeDecorations();
|
this._disposeDecorations();
|
||||||
this._resultsUpdateListener.clear();
|
this._resultsUpdateListener.clear();
|
||||||
this._resultCount = 0;
|
this._resultCount = 0;
|
||||||
@@ -151,7 +176,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
if (!terminal || !this._lastSearchTerm || !this._lastSearchOptions?.decorations) {
|
if (!terminal || !this._lastSearchTerm || !this._lastSearchOptions?.decorations) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const matches = this._findAllMatches(this._lastSearchTerm, this._lastSearchOptions);
|
const matches = this._getMatches(this._lastSearchTerm, this._lastSearchOptions);
|
||||||
if (!matches || matches.length === 0) {
|
if (!matches || matches.length === 0) {
|
||||||
this._disposeDecorations();
|
this._disposeDecorations();
|
||||||
this._resultCount = 0;
|
this._resultCount = 0;
|
||||||
@@ -172,69 +197,109 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
if (!terminal) {
|
if (!terminal) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const regex = this._buildRegex(term, searchOptions);
|
const isRegex = !!searchOptions?.regex;
|
||||||
if (!regex) {
|
const regex = isRegex ? this._buildRegex(term, searchOptions) : undefined;
|
||||||
|
if (isRegex && !regex) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const normalizedTerm = searchOptions?.caseSensitive ? term : term.toLowerCase();
|
||||||
const matches: IMatch[] = [];
|
const matches: IMatch[] = [];
|
||||||
const buffer = terminal.buffer.active;
|
|
||||||
const cols = terminal.cols;
|
const cols = terminal.cols;
|
||||||
|
const logicalLines = this._getLogicalLines(cols);
|
||||||
for (let row = 0; row < buffer.length; row++) {
|
for (const logicalLine of logicalLines) {
|
||||||
const firstLine = buffer.getLine(row);
|
let matchedOffsets: number[] | undefined;
|
||||||
if (!firstLine || firstLine.isWrapped) {
|
if (isRegex) {
|
||||||
continue;
|
regex!.lastIndex = 0;
|
||||||
}
|
while (true) {
|
||||||
|
const match = regex!.exec(logicalLine.text);
|
||||||
const lineRows: number[] = [row];
|
if (!match) {
|
||||||
let nextRow = row + 1;
|
break;
|
||||||
while (nextRow < buffer.length) {
|
}
|
||||||
const wrappedLine = buffer.getLine(nextRow);
|
if (match[0].length === 0) {
|
||||||
if (!wrappedLine?.isWrapped) {
|
regex!.lastIndex++;
|
||||||
break;
|
continue;
|
||||||
|
}
|
||||||
|
const startOffset = match.index;
|
||||||
|
const endOffset = startOffset + match[0].length;
|
||||||
|
if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, searchOptions)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matchedOffsets ??= [];
|
||||||
|
matchedOffsets.push(startOffset, endOffset);
|
||||||
}
|
}
|
||||||
lineRows.push(nextRow);
|
} else {
|
||||||
nextRow++;
|
const haystack = searchOptions?.caseSensitive ? logicalLine.text : logicalLine.text.toLowerCase();
|
||||||
}
|
let searchIndex = 0;
|
||||||
|
while (searchIndex < haystack.length) {
|
||||||
const logicalLine = this._buildLogicalLine(lineRows, cols);
|
const startOffset = haystack.indexOf(normalizedTerm, searchIndex);
|
||||||
regex.lastIndex = 0;
|
if (startOffset === -1) {
|
||||||
while (true) {
|
break;
|
||||||
const match = regex.exec(logicalLine.text);
|
}
|
||||||
if (!match) {
|
const endOffset = startOffset + term.length;
|
||||||
break;
|
if (this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, searchOptions)) {
|
||||||
}
|
matchedOffsets ??= [];
|
||||||
if (match[0].length === 0) {
|
matchedOffsets.push(startOffset, endOffset);
|
||||||
regex.lastIndex++;
|
}
|
||||||
continue;
|
searchIndex = startOffset + 1;
|
||||||
}
|
}
|
||||||
const startOffset = match.index;
|
}
|
||||||
const endOffset = startOffset + match[0].length;
|
if (matchedOffsets && matchedOffsets.length > 0) {
|
||||||
if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, searchOptions)) {
|
if (!logicalLine.offsetToPoint || !logicalLine.offsetToLinear) {
|
||||||
continue;
|
const offsets = this._buildLogicalLineOffsets(logicalLine.rows, cols);
|
||||||
}
|
logicalLine.offsetToPoint = offsets.offsetToPoint;
|
||||||
const start = logicalLine.offsetToPoint[startOffset];
|
logicalLine.offsetToLinear = offsets.offsetToLinear;
|
||||||
const end = logicalLine.offsetToPoint[endOffset];
|
}
|
||||||
const startLinear = logicalLine.offsetToLinear[startOffset];
|
const offsetToPoint = logicalLine.offsetToPoint;
|
||||||
const endLinear = logicalLine.offsetToLinear[endOffset];
|
const offsetToLinear = logicalLine.offsetToLinear;
|
||||||
matches.push({
|
for (let i = 0; i < matchedOffsets.length; i += 2) {
|
||||||
startX: start.x,
|
const startOffset = matchedOffsets[i];
|
||||||
startY: start.y,
|
const endOffset = matchedOffsets[i + 1];
|
||||||
endX: end.x,
|
const start = offsetToPoint[startOffset];
|
||||||
endY: end.y,
|
const end = offsetToPoint[endOffset];
|
||||||
cellLength: Math.max(1, endLinear - startLinear)
|
const startLinear = offsetToLinear[startOffset];
|
||||||
});
|
const endLinear = offsetToLinear[endOffset];
|
||||||
if (matches.length >= this._highlightLimit) {
|
matches.push({
|
||||||
return matches;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row = lineRows[lineRows.length - 1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getMatches(term: string, searchOptions?: ISearchOptions): IMatch[] | undefined {
|
||||||
|
const terminal = this._terminal;
|
||||||
|
if (!terminal) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const key = `${terminal.cols}|${this._createMatchKey(term, searchOptions)}`;
|
||||||
|
if (this._matchCache.has(key)) {
|
||||||
|
return this._matchCache.get(key);
|
||||||
|
}
|
||||||
|
const matches = this._findAllMatches(term, searchOptions);
|
||||||
|
if (!this._matchCache.has(key) && this._matchCache.size >= SearchAddon._maxMatchCacheEntries) {
|
||||||
|
const firstKey = this._matchCache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this._matchCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._matchCache.set(key, matches);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearMatchCache(): void {
|
||||||
|
this._matchCache.clear();
|
||||||
|
this._logicalLineCache = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private _buildRegex(term: string, searchOptions?: ISearchOptions): RegExp | undefined {
|
private _buildRegex(term: string, searchOptions?: ISearchOptions): RegExp | undefined {
|
||||||
const flags = searchOptions?.caseSensitive ? 'g' : 'gi';
|
const flags = searchOptions?.caseSensitive ? 'g' : 'gi';
|
||||||
if (searchOptions?.regex) {
|
if (searchOptions?.regex) {
|
||||||
@@ -247,17 +312,71 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
return new RegExp(escapeRegExp(term), flags);
|
return new RegExp(escapeRegExp(term), flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildLogicalLine(rows: number[], cols: number): ILogicalLine {
|
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 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;
|
||||||
|
}
|
||||||
|
textParts.push(loadedCell?.getChars() || ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return textParts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildLogicalLineOffsets(rows: number[], cols: number): ILogicalLineOffsets {
|
||||||
const terminal = this._terminal;
|
const terminal = this._terminal;
|
||||||
if (!terminal) {
|
if (!terminal) {
|
||||||
return {
|
return {
|
||||||
text: '',
|
|
||||||
offsetToPoint: [{ x: 0, y: rows[0] ?? 0 }],
|
offsetToPoint: [{ x: 0, y: rows[0] ?? 0 }],
|
||||||
offsetToLinear: [0]
|
offsetToLinear: [0]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const textParts: string[] = [];
|
|
||||||
const offsetToPoint: IPoint[] = [];
|
const offsetToPoint: IPoint[] = [];
|
||||||
const offsetToLinear: number[] = [];
|
const offsetToLinear: number[] = [];
|
||||||
let currentOffset = 0;
|
let currentOffset = 0;
|
||||||
@@ -275,21 +394,19 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const chars = loadedCell?.getChars() || ' ';
|
const chars = loadedCell?.getChars() || ' ';
|
||||||
textParts.push(chars);
|
|
||||||
const codeUnitCount = chars.length;
|
const codeUnitCount = chars.length;
|
||||||
for (let i = 1; i < codeUnitCount; i++) {
|
for (let i = 1; i < codeUnitCount; i++) {
|
||||||
offsetToPoint[currentOffset + i] = this._linearToPoint(rows, cols, linearOffset);
|
offsetToPoint[currentOffset + i] = this._linearToPoint(rows, cols, linearOffset);
|
||||||
offsetToLinear[currentOffset + i] = linearOffset;
|
offsetToLinear[currentOffset + i] = linearOffset;
|
||||||
}
|
}
|
||||||
currentOffset += codeUnitCount;
|
currentOffset += codeUnitCount;
|
||||||
linearOffset += Math.max(1, width);
|
linearOffset += width;
|
||||||
offsetToPoint[currentOffset] = this._linearToPoint(rows, cols, linearOffset);
|
offsetToPoint[currentOffset] = this._linearToPoint(rows, cols, linearOffset);
|
||||||
offsetToLinear[currentOffset] = linearOffset;
|
offsetToLinear[currentOffset] = linearOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: textParts.join(''),
|
|
||||||
offsetToPoint,
|
offsetToPoint,
|
||||||
offsetToLinear
|
offsetToLinear
|
||||||
};
|
};
|
||||||
@@ -344,14 +461,15 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
if (!selection) {
|
if (!selection) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
const x = selection.start.x;
|
let indexMap = this._selectionIndexCache.get(matches);
|
||||||
const y = selection.start.y;
|
if (!indexMap) {
|
||||||
for (let i = 0; i < matches.length; i++) {
|
indexMap = new Map<string, number>();
|
||||||
if (matches[i].startX === x && matches[i].startY === y) {
|
for (let i = 0; i < matches.length; i++) {
|
||||||
return i;
|
indexMap.set(`${matches[i].startY}:${matches[i].startX}`, i);
|
||||||
}
|
}
|
||||||
|
this._selectionIndexCache.set(matches, indexMap);
|
||||||
}
|
}
|
||||||
return -1;
|
return indexMap.get(`${selection.start.y}:${selection.start.x}`) ?? -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshDecorations(matches: IMatch[], decorationOptions: ISearchDecorationOptions, activeMatch: IMatch | undefined): void {
|
private _refreshDecorations(matches: IMatch[], decorationOptions: ISearchDecorationOptions, activeMatch: IMatch | undefined): void {
|
||||||
@@ -425,18 +543,31 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _createSearchKey(term: string, searchOptions: ISearchOptions | undefined, direction: 'next' | 'previous'): string {
|
private _createSearchKey(term: string, searchOptions: ISearchOptions | undefined, direction: 'next' | 'previous'): string {
|
||||||
return JSON.stringify({
|
return `${this._createMatchKey(term, searchOptions)}|${direction}`;
|
||||||
term,
|
}
|
||||||
direction,
|
|
||||||
caseSensitive: !!searchOptions?.caseSensitive,
|
private _createMatchKey(term: string, searchOptions: ISearchOptions | undefined): string {
|
||||||
regex: !!searchOptions?.regex,
|
const flags =
|
||||||
wholeWord: !!searchOptions?.wholeWord
|
(searchOptions?.caseSensitive ? 1 : 0) |
|
||||||
});
|
(searchOptions?.regex ? 2 : 0) |
|
||||||
|
(searchOptions?.wholeWord ? 4 : 0);
|
||||||
|
return `${term}|${flags}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ILogicalLine {
|
interface ILogicalLineCacheEntry {
|
||||||
|
rows: number[];
|
||||||
text: string;
|
text: string;
|
||||||
|
offsetToPoint?: IPoint[];
|
||||||
|
offsetToLinear?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ILogicalLineCache {
|
||||||
|
cols: number;
|
||||||
|
entries: ILogicalLineCacheEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ILogicalLineOffsets {
|
||||||
offsetToPoint: IPoint[];
|
offsetToPoint: IPoint[];
|
||||||
offsetToLinear: number[];
|
offsetToLinear: number[];
|
||||||
}
|
}
|
||||||
@@ -459,5 +590,14 @@ function escapeRegExp(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWordChar(value: string): boolean {
|
function isWordChar(value: string): boolean {
|
||||||
return /^[0-9A-Za-z_]$/.test(value);
|
if (value.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const code = value.charCodeAt(0);
|
||||||
|
return (
|
||||||
|
(code >= 48 && code <= 57) || // 0-9
|
||||||
|
(code >= 65 && code <= 90) || // A-Z
|
||||||
|
(code >= 97 && code <= 122) || // a-z
|
||||||
|
code === 95 // _
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user