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:
Daniel Imms
2026-05-25 20:37:49 -07:00
co-authored by Cursor
parent 3b56138d0a
commit d179d586e9
+214 -74
View File
@@ -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 // _
);
} }