fix(addon-search): align navigation and result events with integration tests

- Track navigated index separately from reported highlight index
- Report resultIndex -1 when the active match is outside the highlight list
- Fix findPrevious buffer search from a saved selection position
- Preserve result index when changing search terms with decorations
- Fix viewport centering integration test page.evaluate syntax

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-05-27 05:04:12 +00:00
co-authored by Daniel Imms
parent 22acf01d43
commit e60aa4bf97
2 changed files with 284 additions and 34 deletions
+283 -25
View File
@@ -34,9 +34,11 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
private _lastSearchTerm: string | undefined;
private _lastSearchOptions: ISearchOptions | undefined;
private _lastSearchKey: string | undefined;
private _lastMatchKey: string | undefined;
private _lastSuccessfulNonDecoratedSearchKey: string | undefined;
private _resultCount = 0;
private _resultIndex = -1;
private _navigatedResultIndex = -1;
private _pendingResultRefresh = false;
private readonly _matchCache = new Map<number, Map<MatchFlags, Map<string, IMatch[] | undefined>>>();
private readonly _matchCacheOrder: IMatchCacheKey[] = [];
@@ -75,6 +77,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
this._resultsUpdateListener.clear();
this._resultCount = 0;
this._resultIndex = -1;
this._navigatedResultIndex = -1;
}
public clearActiveDecoration(): void {
@@ -85,6 +88,10 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
this._onBeforeSearch.fire();
try {
const terminal = this._terminal;
const selectionBeforeClear = direction === 'previous' ? terminal?.getSelectionPosition() : undefined;
if (direction === 'previous' && terminal) {
terminal.clearSelection();
}
if (!terminal || term.length === 0) {
this._clearStateOnFailedSearch(term, searchOptions, direction);
return false;
@@ -111,20 +118,20 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
}
const searchKey = currentSearchKey;
const nextIndex = this._resolveResultIndex(matches, searchOptions, direction, searchKey);
const activeMatch = matches[nextIndex];
const { match: activeMatch, navigatedIndex } = this._resolveActiveMatch(term, matches, searchOptions, direction, searchKey, selectionBeforeClear);
const reportedIndex = this._getReportedResultIndex(matches, activeMatch);
terminal.select(activeMatch.startX, activeMatch.startY, activeMatch.cellLength);
this._revealResult(activeMatch);
this._lastResolvedNavigation = {
searchKey,
matches,
index: nextIndex,
index: reportedIndex,
selectionStartX: activeMatch.startX,
selectionStartY: activeMatch.startY
};
if (searchOptions?.decorations) {
this._setDecoratedSearchState(term, searchOptions, searchKey, matches.length, nextIndex);
this._setDecoratedSearchState(term, searchOptions, searchKey, matches.length, reportedIndex, navigatedIndex);
this._registerResultRefreshListener();
this._refreshDecorations(matches, searchOptions.decorations, activeMatch);
this._onDidChangeResults.fire({ resultCount: this._resultCount, resultIndex: this._resultIndex });
@@ -143,7 +150,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
this._lastSuccessfulNonDecoratedSearchKey = undefined;
this._lastResolvedNavigation = undefined;
if (searchOptions?.decorations) {
this._setDecoratedSearchState(term, searchOptions, this._createSearchKey(term, searchOptions, direction), 0, -1);
this._setDecoratedSearchState(term, searchOptions, this._createSearchKey(term, searchOptions, direction), 0, -1, -1);
this._registerResultRefreshListener();
this._disposeDecorations();
this._onDidChangeResults.fire({ resultCount: 0, resultIndex: -1 });
@@ -155,6 +162,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
this._lastSearchTerm === undefined &&
this._lastSearchOptions === undefined &&
this._lastSearchKey === undefined &&
this._lastMatchKey === undefined &&
this._matchDecorationSetOrder.length === 0 &&
!this._activeDecoration.value &&
!this._resultsUpdateListener.value
@@ -388,6 +396,183 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
return firstMatch;
}
private _findSingleMatchForPrevious(term: string, searchOptions?: ISearchOptions, selectionStart?: { x: number, y: number }): IMatch | undefined {
const terminal = this._terminal;
if (!terminal) {
return undefined;
}
if (!selectionStart) {
return this._findLastMatchInBuffer(term, searchOptions);
}
return this._findPreviousFromPosition(term, searchOptions, selectionStart);
}
private _findPreviousFromPosition(term: string, searchOptions: ISearchOptions | undefined, position: { x: number, y: number }): 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 cols = terminal.cols;
const isBeforePosition = (match: IMatch): boolean => {
return match.startY < position.y || (match.startY === position.y && match.startX < position.x);
};
const isAtPosition = (match: IMatch): boolean => match.startX === position.x && match.startY === position.y;
let wrapMatch: IMatch | undefined;
const buffer = terminal.buffer.active;
const logicalLines: ILogicalLineCacheEntry[] = [];
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++;
}
logicalLines.push({ rows, text: this._buildLogicalLineText(rows, cols) });
row = rows[rows.length - 1];
}
const collectLineMatches = (logicalLine: ILogicalLineCacheEntry): IMatch[] => {
const lineMatches: IMatch[] = [];
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;
}
lineMatches.push(this._createMatchFromOffsets(logicalLine, cols, 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;
searchIndex = startOffset + 1;
if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) {
continue;
}
lineMatches.push(this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset));
}
}
return lineMatches;
};
for (let lineIndex = logicalLines.length - 1; lineIndex >= 0; lineIndex--) {
const lineMatches = collectLineMatches(logicalLines[lineIndex]);
for (let i = lineMatches.length - 1; i >= 0; i--) {
const match = lineMatches[i];
if (isAtPosition(match)) {
continue;
}
if (isBeforePosition(match)) {
return match;
}
wrapMatch ??= match;
}
}
return wrapMatch ?? this._findLastMatchInBuffer(term, searchOptions);
}
private _findLastMatchInBuffer(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 cols = terminal.cols;
let lastMatch: IMatch | undefined;
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;
}
lastMatch = this._createMatchFromOffsets(logicalLine, cols, 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;
searchIndex = startOffset + 1;
if (!this._isWholeWordMatch(logicalLine.text, startOffset, endOffset, wholeWord)) {
continue;
}
lastMatch = this._createMatchFromOffsets(logicalLine, cols, startOffset, endOffset);
}
}
row = rows[rows.length - 1];
}
return lastMatch;
}
private _createMatchFromOffsets(logicalLine: ILogicalLineCacheEntry, cols: number, startOffset: number, endOffset: number): IMatch {
if (!logicalLine.offsetToPoint || !logicalLine.offsetToLinear) {
const offsets = this._buildLogicalLineOffsets(logicalLine.rows, cols);
@@ -586,41 +771,109 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
return !isWordCharAt(text, startOffset - 1) && !isWordCharAt(text, endOffset);
}
private _resolveResultIndex(matches: IMatch[], searchOptions: ISearchOptions | undefined, direction: SearchDirection, currentSearchKey: string): number {
private _resolveActiveMatch(
term: string,
matches: IMatch[],
searchOptions: ISearchOptions | undefined,
direction: SearchDirection,
currentSearchKey: string,
selectionOverride?: ReturnType<Terminal['getSelectionPosition']>
): { match: IMatch, navigatedIndex: number } {
const currentMatchKey = this._createMatchKey(term, searchOptions);
const selection = selectionOverride ?? this._terminal?.getSelectionPosition();
const currentSelectionIndex = this._findIndexFromSelection(matches, selection);
const navigatedIndex = this._navigatedResultIndex >= 0 ? this._navigatedResultIndex : this._resultIndex;
const fromMatchIndex = (index: number): { match: IMatch, navigatedIndex: number } => ({
match: matches[index],
navigatedIndex: index
});
if (searchOptions?.decorations && this._lastSearchKey === currentSearchKey) {
if (navigatedIndex >= 0) {
return fromMatchIndex(this._stepResultIndex(navigatedIndex, matches.length, direction));
}
if (direction === 'previous') {
const bufferMatch = this._findSingleMatchForPrevious(term, searchOptions, selection?.start);
if (bufferMatch) {
return { match: bufferMatch, navigatedIndex: -1 };
}
}
}
if (
searchOptions?.decorations &&
this._lastMatchKey !== undefined &&
this._lastMatchKey !== currentMatchKey &&
navigatedIndex >= 0 &&
selection
) {
const clampedIndex = Math.min(navigatedIndex, matches.length - 1);
let preservedIndex = clampedIndex;
if (direction === 'previous' && searchOptions.incremental && this._lastSearchTerm && !term.startsWith(this._lastSearchTerm)) {
preservedIndex = matches.length - 1 - clampedIndex;
}
return fromMatchIndex(preservedIndex);
}
if (searchOptions?.incremental && this._lastSearchKey !== undefined && this._lastSearchKey !== currentSearchKey) {
if (currentSelectionIndex !== -1) {
return fromMatchIndex(currentSelectionIndex);
}
const clampedIndex = navigatedIndex < 0 ? 0 : Math.min(navigatedIndex, matches.length - 1);
let preservedIndex = clampedIndex;
if (direction === 'previous' && this._lastSearchTerm && !term.startsWith(this._lastSearchTerm)) {
preservedIndex = matches.length - 1 - clampedIndex;
}
return fromMatchIndex(preservedIndex);
}
const previousNavigation = this._lastResolvedNavigation;
const selection = this._terminal?.getSelectionPosition();
if (
previousNavigation &&
previousNavigation.searchKey === currentSearchKey &&
previousNavigation.matches === matches
previousNavigation.matches === matches &&
previousNavigation.index >= 0 &&
previousNavigation.index < matches.length
) {
if (!selection) {
return this._stepResultIndex(previousNavigation.index, matches.length, direction);
return fromMatchIndex(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
selection.start.y === previousNavigation.selectionStartY
) {
return this._stepResultIndex(previousNavigation.index, matches.length, direction);
return fromMatchIndex(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 fromMatchIndex(this._stepResultIndex(currentSelectionIndex, matches.length, direction));
}
return this._getDirectionalDefaultIndex(matches.length, direction);
if (direction === 'previous') {
const bufferMatch = this._findSingleMatchForPrevious(term, searchOptions, selection?.start);
if (bufferMatch) {
return { match: bufferMatch, navigatedIndex: -1 };
}
} else {
const bufferMatch = this._findSingleMatchForNext(term, searchOptions);
if (bufferMatch) {
return { match: bufferMatch, navigatedIndex: -1 };
}
}
return fromMatchIndex(this._getDirectionalDefaultIndex(matches.length, direction));
}
private _getReportedResultIndex(matches: IMatch[], activeMatch: IMatch): number {
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
if (match.startX === activeMatch.startX && match.startY === activeMatch.startY) {
return i;
}
}
return -1;
}
private _findIndexFromSelection(matches: IMatch[], selection: ReturnType<Terminal['getSelectionPosition']> | undefined = this._terminal?.getSelectionPosition()): number {
@@ -872,13 +1125,16 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
searchOptions: ISearchOptions,
searchKey: string,
resultCount: number,
resultIndex: number
resultIndex: number,
navigatedIndex: number
): void {
this._resultCount = resultCount;
this._resultIndex = resultIndex >= 0 && resultIndex < resultCount ? resultIndex : -1;
this._navigatedResultIndex = navigatedIndex >= 0 && navigatedIndex < resultCount ? navigatedIndex : this._resultIndex;
this._lastSearchTerm = term;
this._lastSearchOptions = searchOptions;
this._lastSearchKey = searchKey;
this._lastMatchKey = this._createMatchKey(term, searchOptions);
}
private _clearSearchState(): void {
@@ -886,9 +1142,11 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
this._resultsUpdateListener.clear();
this._resultCount = 0;
this._resultIndex = -1;
this._navigatedResultIndex = -1;
this._lastSearchTerm = undefined;
this._lastSearchOptions = undefined;
this._lastSearchKey = undefined;
this._lastMatchKey = undefined;
}
private _stepResultIndex(index: number, length: number, direction: SearchDirection): number {
+1 -9
View File
@@ -79,15 +79,7 @@ test.describe('Search Tests', () => {
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-center')`), true);
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-center');
deepStrictEqual(await ctx.page.evaluate(`
const viewportY = window.term.buffer.active.viewportY;
const rows = window.term.rows;
const selection = window.term.getSelectionPosition();
return {
viewportY,
expectedViewportY: selection.start.y - Math.floor(rows / 2)
};
`), {
deepStrictEqual(await ctx.page.evaluate('(() => { const viewportY = window.term.buffer.active.viewportY; const rows = window.term.rows; const selection = window.term.getSelectionPosition(); return { viewportY: viewportY, expectedViewportY: selection.start.y - Math.floor(rows / 2) }; })()'), {
viewportY: 48,
expectedViewportY: 48
});