Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
2558596
Add search benchmark
Tyriar May 25, 2026
bf055dd
fix(addon-search): rehighlight when search options change
Tyriar May 26, 2026
865f835
fix(addon-search): refresh highlights when decoration options change
Tyriar May 26, 2026
0b1cacd
fix(addon-search): apply active result decoration CSS class
Tyriar May 26, 2026
11720c9
fix(addon-search): continue whole-word search within same line
Tyriar May 26, 2026
c7baa96
fix(addon-search): ignore zero-length matches in reverse regex search
Tyriar May 26, 2026
9902f54
perf(addon-search): avoid repeated substring allocation in reverse regex
Tyriar May 26, 2026
a7467a9
perf(addon-search): reuse compiled regex across line scans
Tyriar May 26, 2026
e4ebd08
perf(addon-search): cache lowercased line text in line cache
Tyriar May 26, 2026
f28bfb7
perf(addon-search): avoid redundant result array copy in tracker
Tyriar May 26, 2026
d998032
fix(addon-search): cancel pending highlight refresh on clear
Tyriar May 26, 2026
2146e49
fix(addon-search): normalize invalid highlightLimit values
Tyriar May 26, 2026
17dd1ed
chore(addon-search): improve benchmark coverage and eval thresholds
Tyriar May 26, 2026
811be7b
fix(addon-search): prevent infinite loop on empty reverse regex matches
Tyriar May 26, 2026
ee322c8
fix(addon-search): skip overview ruler when colors are omitted
Tyriar May 26, 2026
6d08f59
test(addon-search): add regression tests for search state and edge cases
Tyriar May 26, 2026
74bec25
docs(addon-search): align API docs with runtime behavior
Tyriar May 26, 2026
510a1b0
test(addon-search): remove debugger from integration tests
Tyriar May 26, 2026
e5ffbe3
perf(addon-search): optimize regex search hot path
Tyriar May 26, 2026
73c99a8
perf(addon-search): collect highlight matches in one search session
Tyriar May 26, 2026
0440861
perf(addon-search): memoize repeated no-match searches
Tyriar May 26, 2026
3ee7df2
perf(addon-search): SearchEngine micro-optimizations
Tyriar May 26, 2026
8405d58
perf(addon-search): streamline decoration creation
Tyriar May 26, 2026
d4a8e5f
perf(addon-search): O(1) tracked result index lookup
Tyriar May 26, 2026
7fbe5a2
fix(addon-search): harden search state and lifecycle
Tyriar May 26, 2026
fedf29e
test(addon-search): add wholeWord and reverse-regex benchmarks
Tyriar May 26, 2026
a344c36
docs(addon-search): align README and public typings
Tyriar May 26, 2026
3bebe6c
chore(addon-search): ignore generated benchmark output
Tyriar May 26, 2026
41841d0
Remove old search impl
Tyriar May 26, 2026
950f1be
Stub SearchAddon.ts
Tyriar May 26, 2026
3b56138
Re-implement addon-search
Tyriar May 26, 2026
d179d58
Optimize addon-search search hot paths with reusable match and logica…
Tyriar May 26, 2026
9bd72ab
Optimize SearchAddon hot paths and skip headless decoration work.
Tyriar May 26, 2026
dc01e4e
Add test around viewport scrolling behavior
Tyriar May 26, 2026
1f3ebe1
Add element back to benchmark common case
Tyriar May 26, 2026
eb55fd4
Avoid selection lookups when navigation state already identifies the …
Tyriar May 26, 2026
2da6c9b
Cache match decoration sets across searches with a small LRU.
Tyriar May 26, 2026
ef84237
Coalesce write-triggered decoration refreshes within the same tick.
Tyriar May 26, 2026
5ff85db
Refactor SearchAddon state/navigation helpers for readability.
Tyriar May 26, 2026
7ddca3a
Move SearchAddon limits to documented const enum.
Tyriar May 26, 2026
ecd846c
Remove unused SearchAddon state and parameters.
Tyriar May 26, 2026
9309ccc
Prevent SearchAddon decorations from obscuring canvas text.
Tyriar May 26, 2026
0f0c615
Use BufferLine translation for search logical text rebuild.
Tyriar May 26, 2026
c55a248
Use canonical BufferLine translation for search line text.
Tyriar May 26, 2026
9add778
Short-circuit cold findNext to first navigable match.
Tyriar May 26, 2026
c6d72b7
Guard and streamline cold-path next-match scanning.
Tyriar May 26, 2026
fe9ebdd
New benchmark case for cold cache
Tyriar May 26, 2026
22acf01
Merge remote-tracking branch 'origin/master' into tyriar/search_bench…
Tyriar May 26, 2026
f06d8df
Fix SearchAddon result navigation and onDidChangeResults indexing
Tyriar May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/
.lock-wscript
lib/
out/
out-benchmark/
out-demo/
out-test/
out-esbuild/
Expand Down
3 changes: 2 additions & 1 deletion addons/addon-search/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
lib
node_modules
node_modules
out-benchmark
9 changes: 9 additions & 0 deletions addons/addon-search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,13 @@ terminal.loadAddon(searchAddon);
searchAddon.findNext('foo');
```

### Search options

- `decorations` (`ISearchDecorationOptions`): Enables match highlighting and result tracking; pass a decoration options object (colors may be omitted).
- `highlightLimit` (constructor option): Caps how many matches are tracked/highlighted (default `1000`).
- `incremental`: Expands the current selection when the term still matches.
- `wholeWord`, `regex`, `caseSensitive`: Control match semantics.

`onDidChangeResults` fires after `findNext`/`findPrevious` when `decorations` is set. `clearDecorations()` clears highlights and tracked results but does not emit this event.

See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-search/typings/addon-search.d.ts) for more advanced usage.
284 changes: 284 additions & 0 deletions addons/addon-search/benchmark/SearchAddon.benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/

import { perfContext, before, RuntimeCase } from 'xterm-benchmark';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { Terminal } from 'browser/public/Terminal';
import { SearchAddon } from 'SearchAddon';
import type { ISearchOptions } from '@xterm/addon-search';

const enum Constants {
COLS = 120,
ROWS = 40,
SCROLLBACK = 8000,
NAVIGATION_ITERATIONS = 250,
COLD_CACHE_ITERATIONS = 75,
INCREMENTAL_ITERATIONS = 75,
DECORATION_REFRESH_ITERATIONS = 25
}

const DECORATION_OPTIONS: ISearchOptions = {
decorations: {
activeMatchBackground: '#0000ff',
activeMatchColorOverviewRuler: '#ff0000',
matchBackground: '#ffff00',
matchBorder: '#000000',
matchOverviewRuler: '#00ffff'
}
};

class TestTerminal extends Terminal {
public writeSync(data: string): void {
(this as any)._core.writeSync(data);
}

public override select(_column: number, _row: number, _length: number): void {
// SearchAddon always selects matches; skip selection internals in headless benchmark runs.
}

public override clearSelection(): void {
// SearchAddon clears the previous selection before selecting another match.
}

public override scrollLines(_amount: number): void {
// No viewport scrolling is needed for API runtime measurement.
}
}

function buildRealWorldBuffer(): string {
const fixture = readFileSync(resolve(__dirname, '../../fixtures/issue-2444'), 'utf8')
.replace(/\r?\n/g, '\r\n');
const lines: string[] = [fixture];
for (let i = 0; i < 2500; i++) {
const minute = (i % 60).toString().padStart(2, '0');
const second = ((i * 7) % 60).toString().padStart(2, '0');
lines.push(`2026-05-25T16:${minute}:${second}Z INFO [api-${i % 8}] requestId=req-${i.toString(16).padStart(6, '0')} path=/v1/projects/${i % 120}/search?q=opencv status=200 durationMs=${10 + (i % 90)}`);
if (i % 5 === 0) {
lines.push(`2026-05-25T16:${minute}:${second}Z WARN [worker-${i % 4}] slow query detected query="SELECT * FROM runs WHERE status='pending'" elapsedMs=${500 + (i % 1200)}`);
}
if (i % 11 === 0) {
lines.push(`2026-05-25T16:${minute}:${second}Z ERROR [worker-${i % 4}] command failed: package.json parse error at column ${10 + (i % 70)}; retrying in ${2 + (i % 7)}s`);
}
if (i % 17 === 0) {
lines.push(`https://example.internal/run/${i}/details?source=terminal-search&state=error`);
}
}
return lines.join('\r\n') + '\r\n';
}

perfContext('SearchAddon API on real-world terminal content', () => {
const bufferContent = buildRealWorldBuffer();

perfContext('findNext/plain navigation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('opencv')) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findNext/no match full scan', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('zzzznotfoundzzzz')) {
foundCount++;
}
}
if (foundCount !== 0) {
throw new Error(`Expected 0 matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findNext/case insensitive navigation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('OPENCV', { caseSensitive: false })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findNext/regex navigation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('https://example\\.internal/run/\\d+/details\\?source=terminal-search', { regex: true })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findPrevious/incremental typing flow', () => {
let terminal: TestTerminal;
let search: SearchAddon;
const terms = ['e', 'er', 'err', 'erro', 'error'];
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.INCREMENTAL_ITERATIONS; i++) {
for (const term of terms) {
if (search.findPrevious(term, { incremental: true })) {
foundCount++;
}
}
}
return { payloadSize: Constants.INCREMENTAL_ITERATIONS * terms.length, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findNext/cold cache', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.COLD_CACHE_ITERATIONS; i++) {
// Force a fresh logical-line reconstruction and match scan each search.
(search as any)._clearMatchCache();
if (search.findNext('opencv')) {
foundCount++;
}
}
if (foundCount !== Constants.COLD_CACHE_ITERATIONS) {
throw new Error(`Expected ${Constants.COLD_CACHE_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.COLD_CACHE_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findNext/wholeWord dense-punctuation', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findNext('error', { wholeWord: true })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findPrevious/regex wholeWord reverse', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon();
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
if (search.findPrevious('error', { regex: true, wholeWord: true })) {
foundCount++;
}
}
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});

perfContext('findNext/decorations refresh', () => {
let terminal: TestTerminal;
let search: SearchAddon;
before(() => {
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
search = new SearchAddon({ highlightLimit: 1500 });
search.activate(terminal);
terminal.writeSync(bufferContent);
});
new RuntimeCase('', () => {
let foundCount = 0;
for (let i = 0; i < Constants.DECORATION_REFRESH_ITERATIONS; i++) {
const term = i % 2 === 0 ? 'WARN' : 'ERROR';
if (search.findNext(term, DECORATION_OPTIONS)) {
foundCount++;
}
}
if (foundCount !== Constants.DECORATION_REFRESH_ITERATIONS) {
throw new Error(`Expected ${Constants.DECORATION_REFRESH_ITERATIONS} matches, got ${foundCount}`);
}
return { payloadSize: Constants.DECORATION_REFRESH_ITERATIONS, foundCount };
}, { fork: false }).showAverageRuntime();
});
});
13 changes: 13 additions & 0 deletions addons/addon-search/benchmark/benchmark.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"APP_PATH": ".benchmark",
"evalConfig": {
"tolerance": {
"*": [0.9, 1.2],
"SearchAddon.benchmark.js.*.averageRuntime.mean": [0.93, 1.12]
},
"skip": [
"*.median",
"*.runs"
]
}
}
29 changes: 29 additions & 0 deletions addons/addon-search/benchmark/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"lib": [
"dom",
"es2021"
],
"rootDir": "..",
"outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"],
"moduleResolution": "node",
"strict": false,
"target": "es2021",
"module": "commonjs",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
"SearchAddon": ["../src/SearchAddon"],
"@xterm/addon-search": [
"../typings/addon-search.d.ts"
]
}
},
"include": ["../**/*", "../../../typings/xterm.d.ts"],
"exclude": ["../../../**/*test.ts", "../../**/*api.ts"],
"references": [
{ "path": "../../../src/common" },
{ "path": "../../../src/browser" }
]
}
5 changes: 4 additions & 1 deletion addons/addon-search/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"prepackage": "../../node_modules/.bin/tsgo -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
"start": "node ../../demo/start",
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --eval -F out-benchmark/benchmark/*benchmark.js"
}
}
Loading
Loading