From 9add7789b23e44947c40389e47509543e80ec26f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 26 May 2026 07:31:55 -0700 Subject: [PATCH] Short-circuit cold findNext to first navigable match. This adds a cold-path fast search for non-decorated findNext so cache-cleared scans can stop after finding the next selection target instead of materializing all matches. Co-authored-by: Cursor --- addons/addon-search/src/SearchAddon.ts | 114 +++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/addons/addon-search/src/SearchAddon.ts b/addons/addon-search/src/SearchAddon.ts index bfc217347..802d43290 100644 --- a/addons/addon-search/src/SearchAddon.ts +++ b/addons/addon-search/src/SearchAddon.ts @@ -89,6 +89,18 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp return false; } + if (!searchOptions?.decorations && !searchOptions?.incremental && direction === 'next' && !this._logicalLineCache) { + 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._clearSearchState(); + return true; + } + const matches = this._getMatches(term, searchOptions); if (matches === undefined || matches.length === 0) { this._clearStateOnFailedSearch(term, searchOptions, direction); @@ -272,6 +284,108 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp 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; + const logicalLines = this._getLogicalLines(cols); + let firstMatch: IMatch | undefined; + let selectionMatched = !selectionStart; + for (const logicalLine of logicalLines) { + 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; + } + } + } + } + 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) {