Files
orca/config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch
T
Neil 6fddfd04e5 perf(terminal): stop terminal search rescanning every row of a wrapped line
SearchEngine's forward scans walk every buffer row and _findInLine rewinds each
wrapped row back to the start of its line, so a single long un-newlined line
costs O(rows^2) on the renderer's main thread. The find bar re-runs that scan on
every keystroke and again 200ms after every onWriteParsed, so a pane holding one
long line freezes repeatedly rather than searching.

A wrapped row's text was already searched when the first row of its line was, so
skip it. Only sound for a plain search: wholeWord and regex can reject at one
offset and match at a later one in the same line, so both keep the old walk.

Measured, one un-newlined line at 80 cols filling the default 5000-row
scrollback, no match (real Terminal + SearchAddon, happy-dom):

  upstream                 17046 ms
  with the recursion fix   18500 ms
  with this commit             6 ms
2026-09-02 22:42:08 -07:00

86 lines
4.0 KiB
Diff

diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts
index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..4ce664a7b913c5c19d6bbf7feba92cbdaef38c6f 100644
--- a/src/SearchEngine.ts
+++ b/src/SearchEngine.ts
@@ -76,6 +76,9 @@ export class SearchEngine {
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
+ if (this._isRowCoveredByLineStart(y, searchOptions)) {
+ continue;
+ }
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
@@ -127,6 +130,9 @@ export class SearchEngine {
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
+ if (this._isRowCoveredByLineStart(y, searchOptions)) {
+ continue;
+ }
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
@@ -138,6 +144,9 @@ export class SearchEngine {
// If we hit the bottom and didn't search from the very top wrap back up
if (!result && startRow !== 0) {
for (let y = 0; y < startRow; y++) {
+ if (this._isRowCoveredByLineStart(y, searchOptions)) {
+ continue;
+ }
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
@@ -237,6 +246,19 @@ export class SearchEngine {
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
}
+ /**
+ * A wrapped row's text was already searched in full when the first row of its line was, so a
+ * full-buffer scan that visits every row does O(rows^2) work over one long line. Skipping is
+ * only sound for a plain search: wholeWord and regex can both reject at one offset and match at
+ * a later one within the same line.
+ */
+ private _isRowCoveredByLineStart(row: number, searchOptions?: ISearchOptions): boolean {
+ if (searchOptions?.regex || searchOptions?.wholeWord) {
+ return false;
+ }
+ return this._terminal.buffer.active.getLine(row)?.isWrapped === true;
+ }
+
/**
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
@@ -250,23 +272,19 @@ export class SearchEngine {
* @returns The search result if it was found.
*/
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
- const row = searchPosition.startRow;
- const col = searchPosition.startCol;
-
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
- const firstLine = this._terminal.buffer.active.getLine(row);
- if (firstLine?.isWrapped) {
+ // This walks back to the line start rather than recursing into it: one frame per wrapped
+ // row overflows the stack on a line long enough to fill the scrollback.
+ while (this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {
+ searchPosition.startCol += this._terminal.cols;
if (isReverseSearch) {
- searchPosition.startCol += this._terminal.cols;
return;
}
-
- // This will iterate until we find the line start.
- // When we find it, we will search using the calculated start column.
searchPosition.startRow--;
- searchPosition.startCol += this._terminal.cols;
- return this._findInLine(term, searchPosition, searchOptions);
}
+ const row = searchPosition.startRow;
+ const col = searchPosition.startCol;
+
let cache = this._lineCache.getLineFromCache(row);
if (!cache) {
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);