Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e331541fd1 | ||
|
|
2ab0f10a26 | ||
|
|
280dc0a36a | ||
|
|
ebe8cb749a | ||
|
|
c28f3c8d73 | ||
|
|
3bd7ac083a | ||
|
|
30b42838fd | ||
|
|
80e832e417 | ||
|
|
470947abd8 | ||
|
|
bb49a80ace | ||
|
|
65b87e0f40 | ||
|
|
39fc0f259e | ||
|
|
b787ac8b1b | ||
|
|
457efbf55d | ||
|
|
42270a0b68 | ||
|
|
090b77f354 | ||
|
|
534ce1365b | ||
|
|
f757c965ee | ||
|
|
4aa02f3fd9 | ||
|
|
f43273b3f0 | ||
|
|
c06c3025f2 | ||
|
|
1064e4f783 | ||
|
|
fad4821fd9 | ||
|
|
306c3d73ec | ||
|
|
8f2bb32c9c | ||
|
|
7b6c814566 | ||
|
|
adc9dea083 | ||
|
|
d565a2750e | ||
|
|
c5ab83d697 | ||
|
|
c62e6dd765 | ||
|
|
29a07f1d8f | ||
|
|
88cef97049 | ||
|
|
0e823004e6 | ||
|
|
df3422fb0b | ||
|
|
4ec85e67ba | ||
|
|
1a67b6f4b3 | ||
|
|
d195dc9a3b | ||
|
|
253d1e9beb |
@@ -3,7 +3,6 @@ node_modules/
|
||||
.lock-wscript
|
||||
lib/
|
||||
out/
|
||||
out-benchmark/
|
||||
out-demo/
|
||||
out-test/
|
||||
out-esbuild/
|
||||
|
||||
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
const height = this._dec.height;
|
||||
|
||||
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
||||
if (!width || ! height) {
|
||||
if (!width || !height) {
|
||||
if (height) {
|
||||
this._storage.advanceCursor(height);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
lib
|
||||
node_modules
|
||||
out-benchmark
|
||||
node_modules
|
||||
@@ -20,13 +20,4 @@ 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.
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"APP_PATH": ".benchmark",
|
||||
"evalConfig": {
|
||||
"tolerance": {
|
||||
"*": [0.9, 1.2],
|
||||
"SearchAddon.benchmark.js.*.averageRuntime.mean": [0.93, 1.12]
|
||||
},
|
||||
"skip": [
|
||||
"*.median",
|
||||
"*.runs"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -19,9 +19,6 @@
|
||||
"prepackage": "../../node_modules/.bin/tsgo -p .",
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"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"
|
||||
"start": "node ../../demo/start"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
||||
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a highlight decoration.
|
||||
*/
|
||||
interface IHighlight extends IDisposable {
|
||||
decoration: IDecoration;
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for managing multiple decorations for a single match.
|
||||
*/
|
||||
interface IMultiHighlight extends IDisposable {
|
||||
decorations: IDecoration[];
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages visual decorations for search results including highlighting and active selection
|
||||
* indicators. This class handles the creation, styling, and disposal of search-related decorations.
|
||||
*/
|
||||
export class DecorationManager extends Disposable {
|
||||
private _highlightDecorations: IHighlight[] = [];
|
||||
private _highlightedLines: Set<number> = new Set();
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this.clearHighlightDecorations()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for all provided search results.
|
||||
* @param results The search results to create decorations for.
|
||||
* @param options The decoration options.
|
||||
*/
|
||||
public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {
|
||||
this.clearHighlightDecorations();
|
||||
|
||||
for (const match of results) {
|
||||
const decorations = this._createResultDecorations(match, options, false);
|
||||
if (decorations) {
|
||||
for (const decoration of decorations) {
|
||||
this._storeDecoration(decoration, match);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for the currently active search result.
|
||||
* @param result The active search result.
|
||||
* @param options The decoration options.
|
||||
* @returns The multi-highlight decoration or undefined if creation failed.
|
||||
*/
|
||||
public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {
|
||||
const decorations = this._createResultDecorations(result, options, true);
|
||||
if (decorations) {
|
||||
return { decorations, match: result, dispose() { dispose(decorations); } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all highlight decorations.
|
||||
*/
|
||||
public clearHighlightDecorations(): void {
|
||||
dispose(this._highlightDecorations);
|
||||
this._highlightDecorations = [];
|
||||
this._highlightedLines.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a decoration and tracks it for management.
|
||||
* @param decoration The decoration to store.
|
||||
* @param match The search result this decoration represents.
|
||||
*/
|
||||
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
|
||||
this._highlightedLines.add(decoration.marker.line);
|
||||
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies styles to the decoration when it is rendered.
|
||||
* @param element The decoration's element.
|
||||
* @param borderColor The border color to apply.
|
||||
* @param isActiveResult Whether the element is part of the active search result.
|
||||
*/
|
||||
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
|
||||
if (!element.classList.contains('xterm-find-result-decoration')) {
|
||||
element.classList.add('xterm-find-result-decoration');
|
||||
if (borderColor) {
|
||||
element.style.outline = `1px solid ${borderColor}`;
|
||||
}
|
||||
}
|
||||
if (isActiveResult) {
|
||||
element.classList.add('xterm-find-active-result-decoration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a decoration for the result and applies styles
|
||||
* @param result the search result for which to create the decoration
|
||||
* @param options the options for the decoration
|
||||
* @param isActiveResult whether this is the currently active result
|
||||
* @returns the decorations or undefined if the marker has already been disposed of
|
||||
*/
|
||||
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
|
||||
// Gather decoration ranges for this match as it could wrap
|
||||
const decorationRanges: [number, number, number][] = [];
|
||||
let currentCol = result.col;
|
||||
let remainingSize = result.size;
|
||||
let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;
|
||||
while (remainingSize > 0) {
|
||||
const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);
|
||||
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
|
||||
currentCol = 0;
|
||||
remainingSize -= amountThisRow;
|
||||
markerOffset++;
|
||||
}
|
||||
|
||||
// Create the decorations
|
||||
const decorations: IDecoration[] = [];
|
||||
for (const range of decorationRanges) {
|
||||
const marker = this._terminal.registerMarker(range[0]);
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
const decoration = this._terminal.registerDecoration({
|
||||
marker,
|
||||
x: range[1],
|
||||
width: range[2],
|
||||
layer: isActiveResult ? 'top' : 'bottom',
|
||||
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
|
||||
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
|
||||
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
|
||||
position: 'center'
|
||||
}
|
||||
});
|
||||
if (decoration) {
|
||||
const disposables: IDisposable[] = [];
|
||||
disposables.push(marker);
|
||||
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
|
||||
disposables.push(decoration.onDispose(() => dispose(disposables)));
|
||||
decorations.push(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
return decorations.length === 0 ? undefined : decorations;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+202
-1201
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchEngine } from './SearchEngine';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import { DisposableStore } from 'common/Lifecycle';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchEngine', () => {
|
||||
let store: DisposableStore;
|
||||
let terminal: Terminal;
|
||||
let lineCache: SearchLineCache;
|
||||
let searchEngine: SearchEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new DisposableStore();
|
||||
terminal = store.add(new Terminal({ cols: 80, rows: 24 }));
|
||||
lineCache = store.add(new SearchLineCache(terminal));
|
||||
searchEngine = new SearchEngine(terminal, lineCache);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
describe('find', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should find basic text in terminal content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text starting from specified position', async () => {
|
||||
await writeP(terminal, 'Hello Hello Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 7), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should search across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1\r\nLine 2 target\r\nLine 3');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 7,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when text is not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('NotFound', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should throw error for invalid column position', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.throws(() => {
|
||||
searchEngine.find('Hello', 0, 100);
|
||||
}, /Invalid col: 100 to search in terminal of 80 cols/);
|
||||
});
|
||||
|
||||
it('should handle search starting from last column', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('Hello', 0, 79), undefined);
|
||||
});
|
||||
|
||||
it('should handle search from middle of match', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('llo', 0, 3), undefined); // Should not find partial match that starts before search position
|
||||
});
|
||||
});
|
||||
|
||||
describe('search options', () => {
|
||||
describe('caseSensitive', () => {
|
||||
it('should find text with case-insensitive search (default)', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text with case-sensitive search when enabled', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find text with case-sensitive search when case differs', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { caseSensitive: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wholeWord', () => {
|
||||
it('should find whole word when enabled', async () => {
|
||||
await writeP(terminal, 'Hello world wonderful');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find partial word when wholeWord is enabled', async () => {
|
||||
await writeP(terminal, 'Hello wonderful');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
|
||||
it('should find word at beginning of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'world is great');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find word at end of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'hello world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle word boundaries with punctuation', async () => {
|
||||
await writeP(terminal, 'hello,world!test');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not match when not whole word', async () => {
|
||||
await writeP(terminal, 'helloworld');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex', () => {
|
||||
it('should find text using simple regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello 123 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[0-9]+', 0, 0, { regex: true }), {
|
||||
term: '123',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-insensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { regex: true, caseSensitive: false }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-sensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex regex patterns', async () => {
|
||||
await writeP(terminal, 'Email: test@example.com and another@domain.org');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 0, 0, { regex: true }), {
|
||||
term: 'test@example.com',
|
||||
col: 7,
|
||||
row: 0,
|
||||
size: 16
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Invalid regex should be handled gracefully
|
||||
assert.throws(() => {
|
||||
searchEngine.find('[invalid', 0, 0, { regex: true });
|
||||
}, /Invalid regular expression/);
|
||||
});
|
||||
|
||||
it('should handle empty regex matches', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined options', () => {
|
||||
it('should handle regex + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[A-Z]+', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'H',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wholeWord + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD wonderful');
|
||||
|
||||
const result1 = searchEngine.find('WORLD', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.deepStrictEqual(result1, {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const result2 = searchEngine.find('world', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.strictEqual(result2, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find first occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find next occurrence after current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock the getSelectionPosition to return a selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around to beginning when reaching end', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at the end
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
}); // Should wrap to first occurrence
|
||||
});
|
||||
|
||||
it('should wrap across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1 test\r\nLine 2\r\nLine 3 test');
|
||||
|
||||
// Mock selection at first "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 7, y: 0 }, end: { x: 11, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('test', undefined, 'test'), {
|
||||
term: 'test',
|
||||
col: 7,
|
||||
row: 2,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
it('should return same selection if only one match exists', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Mock selection at "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPreviousWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find last occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findPreviousWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find previous occurrence before current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock selection at second "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// It may find the same selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should wrap around to end when reaching beginning', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Due to the expansion attempt, it may find the same Hello first
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should work across multiple rows in reverse', async () => {
|
||||
await writeP(terminal, 'test Line 1\r\nLine 2\r\ntest Line 3');
|
||||
|
||||
// Mock selection at last "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 4, y: 2 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('test');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm will find the current selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.row, 'number');
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
it('should handle selection expansion correctly', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm tries expansion first, so it may find the same Hello
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
describe('unicode and special characters', () => {
|
||||
it('should handle unicode characters correctly', async () => {
|
||||
await writeP(terminal, 'Hello 世界 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('世界', 0, 0), {
|
||||
term: '世界',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, '中文测试');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('测试', 0, 0), {
|
||||
term: '测试',
|
||||
col: 4,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('wrapped lines', () => {
|
||||
it('should handle search across wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(100) + 'target' + 'B'.repeat(50);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wrapped lines with unicode', async () => {
|
||||
const longText = '中'.repeat(50) + 'target' + '文'.repeat(30);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip wrapped lines correctly in findInLine', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText + '\r\nNext line with target');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 15,
|
||||
row: 3,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer boundaries', () => {
|
||||
|
||||
|
||||
it('should handle empty buffer gracefully', () => {
|
||||
assert.strictEqual(searchEngine.find('anything', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should handle search beyond buffer size', () => {
|
||||
assert.strictEqual(searchEngine.find('test', 1000, 0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should handle undefined search options gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, undefined), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative start positions', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', -1, -1), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search options with undefined properties', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
const options: ISearchOptions = {
|
||||
caseSensitive: undefined,
|
||||
regex: undefined,
|
||||
wholeWord: undefined
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, options), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private method behaviors (tested indirectly)', () => {
|
||||
describe('_isWholeWord behavior', () => {
|
||||
it('should recognize word boundaries with various punctuation', async () => {
|
||||
await writeP(terminal, 'word1 word2,word3(word4)word5[word6]word7{word8}');
|
||||
|
||||
const tests = [
|
||||
{ term: 'word1', expected: true },
|
||||
{ term: 'word2', expected: true },
|
||||
{ term: 'word3', expected: true },
|
||||
{ term: 'word4', expected: true },
|
||||
{ term: 'word5', expected: true },
|
||||
{ term: 'word6', expected: true },
|
||||
{ term: 'word7', expected: true },
|
||||
{ term: 'word8', expected: true }
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const result = searchEngine.find(test.term, 0, 0, { wholeWord: true });
|
||||
if (test.expected) {
|
||||
assert.notStrictEqual(result, undefined, `Should find whole word: ${test.term}`);
|
||||
} else {
|
||||
assert.strictEqual(result, undefined, `Should not find non-whole word: ${test.term}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle word boundaries at line start and end', async () => {
|
||||
await writeP(terminal, 'start middle end');
|
||||
|
||||
const startResult = searchEngine.find('start', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(startResult, {
|
||||
term: 'start',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const endResult = searchEngine.find('end', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(endResult, {
|
||||
term: 'end',
|
||||
col: 13,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
|
||||
const middleResult = searchEngine.find('middle', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(middleResult, {
|
||||
term: 'middle',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer offset calculations', () => {
|
||||
it('should handle wide character offset calculations', async () => {
|
||||
await writeP(terminal, '中文 test 测试');
|
||||
|
||||
const result = searchEngine.find('test', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.term, 'test');
|
||||
// Exact column position depends on wide character handling
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('string to buffer size conversions', () => {
|
||||
it('should correctly calculate size for simple text', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly calculate size for unicode text', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
|
||||
const result = searchEngine.find('世界', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Size should account for wide characters
|
||||
assert.strictEqual(typeof result!.size, 'number');
|
||||
assert.strictEqual(result!.size >= 2, true);
|
||||
});
|
||||
|
||||
it('should handle size calculation across wrapped lines', async () => {
|
||||
const longMatch = 'A'.repeat(100);
|
||||
await writeP(terminal, longMatch);
|
||||
|
||||
const result = searchEngine.find(longMatch, 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.size >= 100, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with SearchLineCache', () => {
|
||||
it('should use cache for line translation', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Initialize cache
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('World', 0, 0);
|
||||
const result2 = searchEngine.find('World', 0, 0);
|
||||
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
assert.notStrictEqual(result2, undefined);
|
||||
assert.deepStrictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('should handle cache misses gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Don't initialize cache
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should work correctly with cache invalidation', async () => {
|
||||
await writeP(terminal, 'Initial text');
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('Initial', 0, 0);
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
|
||||
// Change terminal content which should invalidate cache
|
||||
await writeP(terminal, '\r\nNew line');
|
||||
|
||||
const result2 = searchEngine.find('New', 0, 0);
|
||||
assert.deepStrictEqual(result2, {
|
||||
term: 'New',
|
||||
col: 0,
|
||||
row: 1,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import type { SearchLineCache } from './SearchLineCache';
|
||||
|
||||
/**
|
||||
* Represents the position to start a search from.
|
||||
*/
|
||||
interface ISearchPosition {
|
||||
startCol: number;
|
||||
startRow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a search result with its position and content.
|
||||
*/
|
||||
export interface ISearchResult {
|
||||
term: string;
|
||||
col: number;
|
||||
row: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration constants for the search engine functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Characters that are considered non-word characters for search boundary detection. These
|
||||
* characters are used to determine word boundaries when performing whole-word searches. Includes
|
||||
* common punctuation, symbols, and whitespace characters.
|
||||
*/
|
||||
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'
|
||||
}
|
||||
|
||||
/**
|
||||
* Core search engine that handles finding text within terminal content.
|
||||
* This class is responsible for the actual search algorithms and position calculations.
|
||||
*/
|
||||
export class SearchEngine {
|
||||
constructor(
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _lineCache: SearchLineCache
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find the first occurrence of a term starting from a specific position.
|
||||
* @param term The search term.
|
||||
* @param startRow The row to start searching from.
|
||||
* @param startCol The column to start searching from.
|
||||
* @param searchOptions Search options.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
if (startCol > this._terminal.cols) {
|
||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine incremental behavior.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startCol = 0;
|
||||
let startRow = 0;
|
||||
if (prevSelectedPos) {
|
||||
if (cachedSearchTerm === term) {
|
||||
startCol = prevSelectedPos.end.x;
|
||||
startRow = prevSelectedPos.end.y;
|
||||
} else {
|
||||
startCol = prevSelectedPos.start.x;
|
||||
startRow = prevSelectedPos.start.y;
|
||||
}
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is only one result, wrap back and return selection if it exists.
|
||||
if (!result && prevSelectedPos) {
|
||||
searchPosition.startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the previous occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine if expansion should occur.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
||||
let startCol = this._terminal.cols;
|
||||
const isReverseSearch = true;
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
let result: ISearchResult | undefined;
|
||||
if (prevSelectedPos) {
|
||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
||||
if (cachedSearchTerm !== term) {
|
||||
// Try to expand selection to right first.
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false);
|
||||
if (!result) {
|
||||
// If selection was not able to be expanded to the right, then try reverse search
|
||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
|
||||
// Search from startRow - 1 to top
|
||||
if (!result) {
|
||||
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
|
||||
for (let y = startRow - 1; y >= 0; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the top and didn't search from the very bottom wrap back down
|
||||
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
|
||||
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A found substring is a whole word if it doesn't have an alphanumeric character directly
|
||||
* adjacent to it.
|
||||
* @param searchIndex starting index of the potential whole word substring
|
||||
* @param line entire string in which the potential whole word was found
|
||||
* @param term the substring that starts at searchIndex
|
||||
*/
|
||||
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
|
||||
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
|
||||
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
|
||||
* be properly searched when the terminal line that the text starts on is searched.
|
||||
* @param term The search term.
|
||||
* @param searchPosition The position to start the search.
|
||||
* @param searchOptions Search options.
|
||||
* @param isReverseSearch Whether the search should start from the right side of the terminal and
|
||||
* search to the left.
|
||||
* @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) {
|
||||
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);
|
||||
}
|
||||
let cache = this._lineCache.getLineFromCache(row);
|
||||
if (!cache) {
|
||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
||||
this._lineCache.setLineInCache(row, cache);
|
||||
}
|
||||
const [stringLine, offsets] = cache;
|
||||
|
||||
const offset = this._bufferColsToStringOffset(row, col);
|
||||
let searchTerm = term;
|
||||
let searchStringLine = stringLine;
|
||||
if (!searchOptions.regex) {
|
||||
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
|
||||
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (searchOptions.regex) {
|
||||
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
|
||||
let foundTerm: RegExpExecArray | null;
|
||||
if (isReverseSearch) {
|
||||
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
|
||||
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
|
||||
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
term = foundTerm[0];
|
||||
searchRegex.lastIndex -= (term.length - 1);
|
||||
}
|
||||
} else {
|
||||
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
|
||||
if (foundTerm && foundTerm[0].length > 0) {
|
||||
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
|
||||
term = foundTerm[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isReverseSearch) {
|
||||
if (offset - searchTerm.length >= 0) {
|
||||
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
|
||||
}
|
||||
} else {
|
||||
resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultIndex >= 0) {
|
||||
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
||||
// rows
|
||||
let startRowOffset = 0;
|
||||
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
|
||||
startRowOffset++;
|
||||
}
|
||||
let endRowOffset = startRowOffset;
|
||||
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
|
||||
endRowOffset++;
|
||||
}
|
||||
const startColOffset = resultIndex - offsets[startRowOffset];
|
||||
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
|
||||
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
|
||||
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
|
||||
const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);
|
||||
|
||||
return {
|
||||
term,
|
||||
col: startColIndex,
|
||||
row: row + startRowOffset,
|
||||
size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _stringLengthToBufferSize(row: number, offset: number): number {
|
||||
const line = this._terminal.buffer.active.getLine(row);
|
||||
if (!line) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
// Adjust the searchIndex to normalize emoji into single chars
|
||||
const char = cell.getChars();
|
||||
if (char.length > 1) {
|
||||
offset -= char.length - 1;
|
||||
}
|
||||
// Adjust the searchIndex for empty characters following wide unicode
|
||||
// chars (eg. CJK)
|
||||
const nextCell = line.getCell(i + 1);
|
||||
if (nextCell && nextCell.getWidth() === 0) {
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
||||
let lineIndex = startRow;
|
||||
let offset = 0;
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (cols > 0 && line) {
|
||||
for (let i = 0; i < cols && i < this._terminal.cols; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
if (cell.getWidth()) {
|
||||
// Treat null characters as whitespace to align with the translateToString API
|
||||
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
||||
}
|
||||
}
|
||||
lineIndex++;
|
||||
line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
if (line && !line.isWrapped) {
|
||||
break;
|
||||
}
|
||||
cols -= this._terminal.cols;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import { timeout } from 'common/Async';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchLineCache', () => {
|
||||
let terminal: Terminal;
|
||||
let cache: SearchLineCache;
|
||||
|
||||
beforeEach(() => {
|
||||
terminal = new Terminal({ cols: 80, rows: 24 });
|
||||
cache = new SearchLineCache(terminal);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cache.dispose();
|
||||
terminal.dispose();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a SearchLineCache instance', () => {
|
||||
assert.instanceOf(cache, SearchLineCache);
|
||||
});
|
||||
|
||||
it('should start with no cache initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initLinesCache', () => {
|
||||
it('should initialize the lines cache array', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty');
|
||||
});
|
||||
|
||||
it('should not reinitialize if cache already exists', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
cache.initLinesCache();
|
||||
|
||||
const entry = cache.getLineFromCache(0);
|
||||
assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry');
|
||||
});
|
||||
|
||||
it('should set up TTL timeout', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLineFromCache', () => {
|
||||
it('should return undefined when cache is not initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(10), undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for unset entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(50), undefined);
|
||||
});
|
||||
|
||||
it('should return cached entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(5, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(5), entry);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLineInCache', () => {
|
||||
it('should not set entries when cache is not initialized', () => {
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should set entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(10, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(10), entry);
|
||||
});
|
||||
|
||||
it('should overwrite existing entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry1: LineCacheEntry = ['first content', [0]];
|
||||
const entry2: LineCacheEntry = ['second content', [0]];
|
||||
|
||||
cache.setLineInCache(0, entry1);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry1);
|
||||
|
||||
cache.setLineInCache(0, entry2);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateBufferLineToStringWithWrap', () => {
|
||||
it('should translate a single line without wrapping', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello World');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle trimRight parameter', async () => {
|
||||
await writeP(terminal, 'Hello World ');
|
||||
const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false);
|
||||
|
||||
assert.equal(resultTrimmed[0].trimEnd(), 'Hello World');
|
||||
assert.isTrue(resultNotTrimmed[0].startsWith('Hello World '));
|
||||
assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer');
|
||||
});
|
||||
|
||||
it('should handle wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longText);
|
||||
assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping');
|
||||
assert.equal(result[1][0], 0, 'first offset should be 0');
|
||||
});
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello 世界');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle lines beyond buffer', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(1000, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle complex wrapped content', async () => {
|
||||
await writeP(terminal, 'Line 1\r\n');
|
||||
await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n');
|
||||
await writeP(terminal, 'Line 3');
|
||||
|
||||
const result1 = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const result2 = cache.translateBufferLineToStringWithWrap(1, true);
|
||||
const result3 = cache.translateBufferLineToStringWithWrap(2, true);
|
||||
|
||||
assert.equal(result1[0], 'Line 1');
|
||||
assert.equal(result2[0], 'Line 2 with some longer content that might wrap');
|
||||
assert.equal(result3[0], 'Line 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should invalidate cache on line feed', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.write('test\r\n');
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on cursor move', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
await writeP(terminal, 'some text');
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on resize', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.resize(100, 30);
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disposal', () => {
|
||||
it('should clean up resources on dispose', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal');
|
||||
});
|
||||
|
||||
it('should be safe to dispose multiple times', () => {
|
||||
cache.initLinesCache();
|
||||
cache.dispose();
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LineCacheEntry type', () => {
|
||||
it('should handle complex line offsets', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'A very long line that wraps multiple times across several terminal lines',
|
||||
[0, 20, 40, 60]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0].length, 72);
|
||||
assert.equal(retrieved![1].length, 4);
|
||||
});
|
||||
|
||||
it('should handle unicode characters in cache entries', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'Hello 世界 🌍 测试',
|
||||
[0]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0], 'Hello 世界 🌍 测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with real terminal content', () => {
|
||||
it('should correctly translate real buffer content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const cached = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || '';
|
||||
|
||||
assert.equal(cached[0], directTranslation);
|
||||
});
|
||||
|
||||
it('should handle real wrapped content correctly', async () => {
|
||||
const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache';
|
||||
await writeP(terminal, longContent);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longContent);
|
||||
assert.isTrue(result[1].length > 1, 'should have wrapped');
|
||||
});
|
||||
|
||||
it('should work with real escape sequences', async () => {
|
||||
await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.include(result[0], 'Before');
|
||||
assert.include(result[0], 'Red Text');
|
||||
assert.include(result[0], 'After');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
|
||||
export type LineCacheEntry = [
|
||||
/**
|
||||
* The string representation of a line (as opposed to the buffer cell representation).
|
||||
*/
|
||||
lineAsString: string,
|
||||
/**
|
||||
* The offsets where each line starts when the entry describes a wrapped line.
|
||||
*/
|
||||
lineOffsets: number[]
|
||||
];
|
||||
|
||||
/**
|
||||
* Configuration constants for the search line cache functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Time-to-live for cached search results in milliseconds. After this duration, cached search
|
||||
* results will be invalidated to ensure they remain consistent with terminal content changes.
|
||||
*/
|
||||
LINES_CACHE_TIME_TO_LIVE = 15000
|
||||
}
|
||||
|
||||
export class SearchLineCache extends Disposable {
|
||||
/**
|
||||
* translateBufferLineToStringWithWrap is a fairly expensive call.
|
||||
* We memoize the calls into an array that has a time based ttl.
|
||||
* _linesCache is also invalidated when the terminal cursor moves.
|
||||
*/
|
||||
private _linesCache: LineCacheEntry[] | undefined;
|
||||
private _linesCacheTimeout = this._register(new MutableDisposable());
|
||||
private _linesCacheDisposables = this._register(new MutableDisposable());
|
||||
// Track access to avoid recreating a timeout on every init call which occurs once per search
|
||||
// result (findNext/findPrevious -> _highlightAllMatches -> find loop).
|
||||
private _lastAccessTimestamp = 0;
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this._destroyLinesCache()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a line cache with a ttl
|
||||
*/
|
||||
public initLinesCache(): void {
|
||||
if (!this._linesCache) {
|
||||
this._linesCache = new Array(this._terminal.buffer.active.length);
|
||||
this._linesCacheDisposables.value = combinedDisposable(
|
||||
this._terminal.onLineFeed(() => this._destroyLinesCache()),
|
||||
this._terminal.onCursorMove(() => this._destroyLinesCache()),
|
||||
this._terminal.onResize(() => this._destroyLinesCache())
|
||||
);
|
||||
}
|
||||
|
||||
this._lastAccessTimestamp = Date.now();
|
||||
if (!this._linesCacheTimeout.value) {
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);
|
||||
}
|
||||
}
|
||||
|
||||
private _destroyLinesCache(): void {
|
||||
this._linesCache = undefined;
|
||||
this._lastAccessTimestamp = 0;
|
||||
this._linesCacheDisposables.clear();
|
||||
this._linesCacheTimeout.clear();
|
||||
}
|
||||
|
||||
private _scheduleLinesCacheTimeout(delay: number): void {
|
||||
this._linesCacheTimeout.value = disposableTimeout(() => {
|
||||
if (!this._linesCache) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const elapsed = now - this._lastAccessTimestamp;
|
||||
if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {
|
||||
this._destroyLinesCache();
|
||||
return;
|
||||
}
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
public getLineFromCache(row: number): LineCacheEntry | undefined {
|
||||
return this._linesCache?.[row];
|
||||
}
|
||||
|
||||
public setLineInCache(row: number, entry: LineCacheEntry): void {
|
||||
if (this._linesCache) {
|
||||
this._linesCache[row] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a buffer line to a string, including subsequent lines if they are wraps.
|
||||
* Wide characters will count as two columns in the resulting string. This
|
||||
* function is useful for getting the actual text underneath the raw selection
|
||||
* position.
|
||||
* @param lineIndex The index of the line being translated.
|
||||
* @param trimRight Whether to trim whitespace to the right.
|
||||
*/
|
||||
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
||||
const strings = [];
|
||||
const lineOffsets = [0];
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (line) {
|
||||
const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);
|
||||
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
||||
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
||||
if (lineWrapsToNext && nextLine) {
|
||||
const lastCell = line.getCell(line.length - 1);
|
||||
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
|
||||
// a wide character wrapped to the next line
|
||||
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
|
||||
string = string.slice(0, -1);
|
||||
}
|
||||
}
|
||||
strings.push(string);
|
||||
if (lineWrapsToNext) {
|
||||
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
lineIndex++;
|
||||
line = nextLine;
|
||||
}
|
||||
return [strings.join(''), lineOffsets];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
||||
import type { IDisposable } from '@xterm/xterm';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a currently selected decoration.
|
||||
*/
|
||||
interface ISelectedDecoration extends IDisposable {
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks search results, manages result indexing, and fires events when results change.
|
||||
* This class provides centralized management of search result state and notifications.
|
||||
*/
|
||||
export class SearchResultTracker extends Disposable {
|
||||
private _searchResults: ISearchResult[] = [];
|
||||
private _selectedDecoration: ISelectedDecoration | undefined;
|
||||
|
||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
||||
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
||||
|
||||
/**
|
||||
* Gets the current search results.
|
||||
*/
|
||||
public get searchResults(): ReadonlyArray<ISearchResult> {
|
||||
return this._searchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected decoration.
|
||||
*/
|
||||
public get selectedDecoration(): ISelectedDecoration | undefined {
|
||||
return this._selectedDecoration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the currently selected decoration.
|
||||
*/
|
||||
public set selectedDecoration(decoration: ISelectedDecoration | undefined) {
|
||||
this._selectedDecoration = decoration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the search results with a new set of results.
|
||||
* @param results The new search results.
|
||||
* @param maxResults The maximum number of results to track.
|
||||
*/
|
||||
public updateResults(results: ISearchResult[], maxResults: number): void {
|
||||
this._searchResults = results.slice(0, maxResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all search results.
|
||||
*/
|
||||
public clearResults(): void {
|
||||
this._searchResults = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the selected decoration.
|
||||
*/
|
||||
public clearSelectedDecoration(): void {
|
||||
if (this._selectedDecoration) {
|
||||
this._selectedDecoration.dispose();
|
||||
this._selectedDecoration = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of a result in the current results array.
|
||||
* @param result The result to find.
|
||||
* @returns The index of the result, or -1 if not found.
|
||||
*/
|
||||
public findResultIndex(result: ISearchResult): number {
|
||||
for (let i = 0; i < this._searchResults.length; i++) {
|
||||
const match = this._searchResults[i];
|
||||
if (match.row === result.row && match.col === result.col && match.size === result.size) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a result change event with the current state.
|
||||
* @param hasDecorations Whether decorations are enabled.
|
||||
*/
|
||||
public fireResultsChanged(hasDecorations: boolean): void {
|
||||
if (!hasDecorations) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (this._selectedDecoration) {
|
||||
resultIndex = this.findResultIndex(this._selectedDecoration.match);
|
||||
}
|
||||
|
||||
this._onDidChangeResults.fire({
|
||||
resultIndex,
|
||||
resultCount: this._searchResults.length
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.clearSelectedDecoration();
|
||||
this.clearResults();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
|
||||
/**
|
||||
* Manages search state including cached search terms, options tracking, and validation.
|
||||
* This class provides a centralized way to handle search state consistency and option changes.
|
||||
*/
|
||||
export class SearchState {
|
||||
private _cachedSearchTerm: string | undefined;
|
||||
private _lastSearchOptions: ISearchOptions | undefined;
|
||||
|
||||
/**
|
||||
* Gets the currently cached search term.
|
||||
*/
|
||||
public get cachedSearchTerm(): string | undefined {
|
||||
return this._cachedSearchTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cached search term.
|
||||
*/
|
||||
public set cachedSearchTerm(term: string | undefined) {
|
||||
this._cachedSearchTerm = term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last search options used.
|
||||
*/
|
||||
public get lastSearchOptions(): ISearchOptions | undefined {
|
||||
return this._lastSearchOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last search options used.
|
||||
*/
|
||||
public set lastSearchOptions(options: ISearchOptions | undefined) {
|
||||
this._lastSearchOptions = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a search term to ensure it's not empty or invalid.
|
||||
* @param term The search term to validate.
|
||||
* @returns true if the term is valid for searching.
|
||||
*/
|
||||
public isValidSearchTerm(term: string): boolean {
|
||||
return !!(term && term.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if search options have changed compared to the last search.
|
||||
* @param newOptions The new search options to compare.
|
||||
* @returns true if the options have changed.
|
||||
*/
|
||||
public didOptionsChange(newOptions?: ISearchOptions): boolean {
|
||||
if (!this._lastSearchOptions) {
|
||||
return true;
|
||||
}
|
||||
if (!newOptions) {
|
||||
return false;
|
||||
}
|
||||
if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.regex !== newOptions.regex) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a new search should trigger highlighting updates.
|
||||
* @param term The search term.
|
||||
* @param options The search options.
|
||||
* @returns true if highlighting should be updated.
|
||||
*/
|
||||
public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {
|
||||
if (!options?.decorations) {
|
||||
return false;
|
||||
}
|
||||
return this._cachedSearchTerm === undefined ||
|
||||
term !== this._cachedSearchTerm ||
|
||||
this.didOptionsChange(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached search term.
|
||||
*/
|
||||
public clearCachedTerm(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
this._lastSearchOptions = undefined;
|
||||
}
|
||||
}
|
||||
@@ -45,45 +45,6 @@ test.describe('Search Tests', () => {
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.search.findNext('$^1_3{}test$#')`), true);
|
||||
deepStrictEqual(await ctx.proxy.getSelection(), '$^1_3{}test$#');
|
||||
});
|
||||
test('Search should not scroll when match is already in viewport', async () => {
|
||||
let dataString = '';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (i === 40) {
|
||||
dataString += `line ${i} needle-in-view`;
|
||||
} else {
|
||||
dataString += `line ${i}`;
|
||||
}
|
||||
dataString += '\n\r';
|
||||
}
|
||||
await ctx.proxy.write(dataString);
|
||||
await ctx.page.evaluate('window.term.scrollToLine(35)');
|
||||
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
|
||||
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-in-view')`), true);
|
||||
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-in-view');
|
||||
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
|
||||
});
|
||||
test('Search should center the match when it is outside the viewport', async () => {
|
||||
let dataString = '';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (i === 60) {
|
||||
dataString += `line ${i} needle-center`;
|
||||
} else {
|
||||
dataString += `line ${i}`;
|
||||
}
|
||||
dataString += '\n\r';
|
||||
}
|
||||
await ctx.proxy.write(dataString);
|
||||
await ctx.page.evaluate('window.term.scrollToLine(0)');
|
||||
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 0);
|
||||
|
||||
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: viewportY, expectedViewportY: selection.start.y - Math.floor(rows / 2) }; })()'), {
|
||||
viewportY: 48,
|
||||
expectedViewportY: 48
|
||||
});
|
||||
});
|
||||
test('Incremental Find Previous', async () => {
|
||||
await ctx.proxy.writeln(`package.jsonc\n`);
|
||||
await ctx.proxy.write('package.json pack package.lock');
|
||||
@@ -316,7 +277,8 @@ test.describe('Search Tests', () => {
|
||||
{ resultCount: 1, resultIndex: 0 },
|
||||
{ resultCount: 2, resultIndex: 1 }
|
||||
]);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
|
||||
await timeout(2000);
|
||||
strictEqual(await ctx.page.evaluate(`debugger; window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 },
|
||||
{ resultCount: 2, resultIndex: 1 },
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"include": [],
|
||||
"references": [
|
||||
{ "path": "./src" },
|
||||
{ "path": "./test" },
|
||||
{ "path": "./benchmark" }
|
||||
{ "path": "./test" }
|
||||
]
|
||||
}
|
||||
|
||||
+12
-14
@@ -17,8 +17,7 @@ declare module '@xterm/addon-search' {
|
||||
|
||||
/**
|
||||
* Whether to search for a whole word, the result is only valid if it's
|
||||
* surrounded by start/end of line or non-word separators such as whitespace
|
||||
* and punctuation (for example `(`, `)`, or space).
|
||||
* surrounded in "non-word" characters such as `_`, `(`, `)` or space.
|
||||
*/
|
||||
wholeWord?: boolean;
|
||||
|
||||
@@ -29,7 +28,8 @@ declare module '@xterm/addon-search' {
|
||||
|
||||
/**
|
||||
* Whether to do an incremental search, this will expand the selection if it
|
||||
* still matches the term the user typed.
|
||||
* still matches the term the user typed. Note that this only affects
|
||||
* `findNext`, not `findPrevious`.
|
||||
*/
|
||||
incremental?: boolean;
|
||||
|
||||
@@ -43,7 +43,7 @@ declare module '@xterm/addon-search' {
|
||||
/**
|
||||
* Options for showing decorations when searching.
|
||||
*/
|
||||
export interface ISearchDecorationOptions {
|
||||
interface ISearchDecorationOptions {
|
||||
/**
|
||||
* The background color of a match, this must use #RRGGBB format.
|
||||
*/
|
||||
@@ -57,7 +57,7 @@ declare module '@xterm/addon-search' {
|
||||
/**
|
||||
* The overview ruler color of a match.
|
||||
*/
|
||||
matchOverviewRuler?: string;
|
||||
matchOverviewRuler: string;
|
||||
|
||||
/**
|
||||
* The background color for the currently active match, this must use #RRGGBB format.
|
||||
@@ -72,7 +72,7 @@ declare module '@xterm/addon-search' {
|
||||
/**
|
||||
* The overview ruler color of the currently active match.
|
||||
*/
|
||||
activeMatchColorOverviewRuler?: string;
|
||||
activeMatchColorOverviewRuler: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,13 +80,12 @@ declare module '@xterm/addon-search' {
|
||||
*/
|
||||
export interface ISearchResultChangeEvent {
|
||||
/**
|
||||
* The index of the currently active result within tracked highlights, or -1 when
|
||||
* the active match is not in the tracked result set.
|
||||
* The index of the currently active result, -1 when the threshold of matches is exceeded.
|
||||
*/
|
||||
resultIndex: number;
|
||||
|
||||
/**
|
||||
* The number of tracked search results (capped by `highlightLimit` when decorations are enabled).
|
||||
* The total number of search results found.
|
||||
*/
|
||||
resultCount: number;
|
||||
}
|
||||
@@ -97,9 +96,9 @@ declare module '@xterm/addon-search' {
|
||||
export interface ISearchAddonOptions {
|
||||
/**
|
||||
* Max number of matches highlighted when decorations are enabled.
|
||||
* Defaults to 1000. Invalid values fall back to the default.
|
||||
* Defaults to 1000 highlighted matches
|
||||
*/
|
||||
highlightLimit?: number;
|
||||
highlightLimit: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,7 +140,7 @@ declare module '@xterm/addon-search' {
|
||||
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;
|
||||
|
||||
/**
|
||||
* Clears highlight decorations and tracked results.
|
||||
* Clears the decorations and selection
|
||||
*/
|
||||
public clearDecorations(): void;
|
||||
|
||||
@@ -163,8 +162,7 @@ declare module '@xterm/addon-search' {
|
||||
readonly onBeforeSearch: IEvent<void>;
|
||||
|
||||
/**
|
||||
* When decorations are enabled, fires after `findNext`/`findPrevious` when tracked
|
||||
* search results change. Does not fire from `clearDecorations()`.
|
||||
* When decorations are enabled, fires when the search results change.
|
||||
*/
|
||||
readonly onDidChangeResults: IEvent<ISearchResultChangeEvent>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { configEquals } from './CharAtlasUtils';
|
||||
import { ICharAtlasConfig } from './Types';
|
||||
import { NULL_COLOR } from 'common/Color';
|
||||
import { IColor } from 'common/Types';
|
||||
|
||||
function createTestConfig(overrides: Partial<ICharAtlasConfig> = {}): ICharAtlasConfig {
|
||||
const color: IColor = { css: '#ffffff', rgba: 0xffffffff };
|
||||
const contrastCache = {
|
||||
clear: () => {},
|
||||
setCss: () => {},
|
||||
getCss: () => undefined,
|
||||
setColor: () => {},
|
||||
getColor: () => undefined
|
||||
};
|
||||
const colors = {
|
||||
foreground: color,
|
||||
background: color,
|
||||
cursor: NULL_COLOR,
|
||||
cursorAccent: NULL_COLOR,
|
||||
selectionForeground: undefined,
|
||||
selectionBackgroundTransparent: NULL_COLOR,
|
||||
selectionBackgroundOpaque: NULL_COLOR,
|
||||
selectionInactiveBackgroundTransparent: NULL_COLOR,
|
||||
selectionInactiveBackgroundOpaque: NULL_COLOR,
|
||||
overviewRulerBorder: NULL_COLOR,
|
||||
scrollbarSliderBackground: NULL_COLOR,
|
||||
scrollbarSliderHoverBackground: NULL_COLOR,
|
||||
scrollbarSliderActiveBackground: NULL_COLOR,
|
||||
ansi: new Array(256).fill(color),
|
||||
contrastCache,
|
||||
halfContrastCache: contrastCache
|
||||
};
|
||||
return {
|
||||
customGlyphs: true,
|
||||
devicePixelRatio: 1,
|
||||
deviceMaxTextureSize: 4096,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
fontSize: 15,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
deviceCellWidth: 10,
|
||||
deviceCellHeight: 20,
|
||||
deviceCharWidth: 8,
|
||||
deviceCharHeight: 16,
|
||||
allowTransparency: false,
|
||||
drawBoldTextInBrightColors: true,
|
||||
minimumContrastRatio: 1,
|
||||
colors,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('CharAtlasUtils', () => {
|
||||
describe('configEquals', () => {
|
||||
it('should return true for identical configs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig();
|
||||
assert.ok(configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceMaxTextureSize differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceMaxTextureSize: 8192 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellWidth differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellWidth: 11 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellHeight differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellHeight: 21 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
}
|
||||
}
|
||||
return a.devicePixelRatio === b.devicePixelRatio &&
|
||||
a.deviceMaxTextureSize === b.deviceMaxTextureSize &&
|
||||
a.customGlyphs === b.customGlyphs &&
|
||||
a.lineHeight === b.lineHeight &&
|
||||
a.letterSpacing === b.letterSpacing &&
|
||||
@@ -67,6 +68,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
a.fontWeight === b.fontWeight &&
|
||||
a.fontWeightBold === b.fontWeightBold &&
|
||||
a.allowTransparency === b.allowTransparency &&
|
||||
a.deviceCellWidth === b.deviceCellWidth &&
|
||||
a.deviceCellHeight === b.deviceCellHeight &&
|
||||
a.deviceCharWidth === b.deviceCharWidth &&
|
||||
a.deviceCharHeight === b.deviceCharHeight &&
|
||||
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types';
|
||||
import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import type { IOptionsService } from 'common/services/Services';
|
||||
import type { ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
|
||||
interface IVertices {
|
||||
@@ -114,7 +114,8 @@ export class GlyphRenderer extends Disposable {
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _optionsService: IOptionsService
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -127,7 +128,7 @@ export class GlyphRenderer extends Disposable {
|
||||
TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null);
|
||||
}
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages)));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages), this._logService));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
@@ -136,8 +137,9 @@ export class GlyphRenderer extends Disposable {
|
||||
this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { RenderModelConstants } from './RenderModel';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
|
||||
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
const enum VertexAttribLocations {
|
||||
POSITION = 0,
|
||||
@@ -89,21 +90,23 @@ export class RectangleRenderer extends Disposable {
|
||||
private _terminal: Terminal,
|
||||
private _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _themeService: IThemeService
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
|
||||
const gl = this._gl;
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource, this._logService));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
|
||||
@@ -109,6 +109,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
page.canvas.remove();
|
||||
}
|
||||
this._onAddTextureAtlasCanvas.dispose();
|
||||
this._onRemoveTextureAtlasCanvas.dispose();
|
||||
}
|
||||
|
||||
public warmUp(): void {
|
||||
@@ -529,7 +530,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
let customGlyph = false;
|
||||
if (this._config.customGlyphs !== false) {
|
||||
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset);
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, this._logService, backgroundColor.css, variantOffset);
|
||||
}
|
||||
|
||||
// Whether to clear pixels based on a threshold difference between the glyph color and the
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ICursorRenderModel {
|
||||
export interface IWebGL2RenderingContext extends WebGLRenderingContext {
|
||||
vertexAttribDivisor(index: number, divisor: number): void;
|
||||
createVertexArray(): IWebGLVertexArrayObject;
|
||||
deleteVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
bindVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRender
|
||||
import { ITerminal } from 'browser/Types';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { getSafariVersion, isSafari } from 'common/Platform';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { IWebGL2RenderingContext } from './Types';
|
||||
import { WebglRenderer } from './WebglRenderer';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
@@ -65,6 +65,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
|
||||
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
|
||||
const decorationService: IDecorationService = unsafeCore._decorationService;
|
||||
const logService: ILogService = unsafeCore._logService;
|
||||
const themeService: IThemeService = unsafeCore._themeService;
|
||||
|
||||
this._renderer = this._register(new WebglRenderer(
|
||||
@@ -74,6 +75,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
coreBrowserService,
|
||||
coreService,
|
||||
decorationService,
|
||||
logService,
|
||||
optionsService,
|
||||
themeService,
|
||||
this._customGlyphs,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { GlyphRenderer } from './GlyphRenderer';
|
||||
import { RectangleRenderer } from './RectangleRenderer';
|
||||
@@ -77,6 +77,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private readonly _coreBrowserService: ICoreBrowserService,
|
||||
private readonly _coreService: ICoreService,
|
||||
private readonly _decorationService: IDecorationService,
|
||||
private readonly _logService: ILogService,
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _customGlyphs: boolean = true,
|
||||
@@ -122,19 +123,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
|
||||
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => {
|
||||
console.log('webglcontextlost event received');
|
||||
this._logService.debug('webglcontextlost event received');
|
||||
// Prevent the default behavior in order to enable WebGL context restoration.
|
||||
e.preventDefault();
|
||||
// Wait a few seconds to see if the 'webglcontextrestored' event is fired.
|
||||
// If not, dispatch the onContextLoss notification to observers.
|
||||
this._contextRestorationTimeout = setTimeout(() => {
|
||||
this._contextRestorationTimeout = undefined;
|
||||
console.warn('webgl context not restored; firing onContextLoss');
|
||||
this._logService.warn('webgl context not restored; firing onContextLoss');
|
||||
this._onContextLoss.fire(e);
|
||||
}, 3000 /* ms */);
|
||||
}));
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => {
|
||||
console.warn('webglcontextrestored event received');
|
||||
this._logService.warn('webglcontextrestored event received');
|
||||
clearTimeout(this._contextRestorationTimeout);
|
||||
this._contextRestorationTimeout = undefined;
|
||||
// The texture atlas and glyph renderer must be fully reinitialized
|
||||
@@ -158,6 +159,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._isAttached = this._core.screenElement!.isConnected;
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
clearTimeout(this._contextRestorationTimeout);
|
||||
this._contextRestorationTimeout = undefined;
|
||||
for (const l of this._renderLayers) {
|
||||
l.dispose();
|
||||
}
|
||||
@@ -274,8 +277,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
* Initializes members dependent on WebGL context state.
|
||||
*/
|
||||
private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] {
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService);
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService, this._logService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService, this._logService);
|
||||
|
||||
// Update dimensions and acquire char atlas
|
||||
this.handleCharSizeChanged();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
/**
|
||||
* A matrix that when multiplies will translate 0-1 coordinates (left to right,
|
||||
@@ -16,21 +17,21 @@ export const PROJECTION_MATRIX = new Float32Array([
|
||||
-1, 1, 0, 1
|
||||
]);
|
||||
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined {
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string, logService: ILogService): WebGLProgram | undefined {
|
||||
const program = throwIfFalsy(gl.createProgram());
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, logService)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, logService)));
|
||||
gl.linkProgram(program);
|
||||
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
||||
if (success) {
|
||||
return program;
|
||||
}
|
||||
|
||||
console.error(gl.getProgramInfoLog(program));
|
||||
logService.error(gl.getProgramInfoLog(program));
|
||||
gl.deleteProgram(program);
|
||||
}
|
||||
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string, logService: ILogService): WebGLShader | undefined {
|
||||
const shader = throwIfFalsy(gl.createShader(type));
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
@@ -39,7 +40,7 @@ export function createShader(gl: WebGLRenderingContext, type: number, source: st
|
||||
return shader;
|
||||
}
|
||||
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
logService.error(gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
|
||||
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
|
||||
|
||||
@@ -22,6 +23,7 @@ export function tryDrawCustomGlyph(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): boolean {
|
||||
@@ -30,7 +32,7 @@ export function tryDrawCustomGlyph(
|
||||
// Normalize to array for uniform handling
|
||||
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
|
||||
for (const part of parts) {
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset);
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, logService, backgroundColor, variantOffset);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -49,6 +51,7 @@ function drawDefinitionPart(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): void {
|
||||
@@ -79,7 +82,7 @@ function drawDefinitionPart(
|
||||
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH_FUNCTION:
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, logService, part.strokeWidth);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH:
|
||||
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
@@ -88,7 +91,7 @@ function drawDefinitionPart(
|
||||
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.VECTOR_SHAPE:
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio);
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio, logService);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.BRAILLE:
|
||||
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
||||
@@ -510,6 +513,7 @@ function drawPathFunctionCharacter(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
strokeWidth?: number
|
||||
): void {
|
||||
ctx.save();
|
||||
@@ -536,7 +540,7 @@ function drawPathFunctionCharacter(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
@@ -598,7 +602,8 @@ function drawVectorShape(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService
|
||||
): void {
|
||||
// Clip the cell to make sure drawing doesn't occur beyond bounds
|
||||
const clipRegion = new Path2D();
|
||||
@@ -619,7 +624,7 @@ function drawVectorShape(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
|
||||
@@ -753,7 +753,7 @@ async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promi
|
||||
|
||||
function addDecoration(term: Terminal, dim: number = 1): void {
|
||||
term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} };
|
||||
const marker = term.registerMarker(1);
|
||||
const marker = term.registerMarker(1)!;
|
||||
const decoration = term.registerDecoration({
|
||||
marker,
|
||||
height: dim,
|
||||
@@ -770,14 +770,14 @@ function addDecoration(term: Terminal, dim: number = 1): void {
|
||||
|
||||
function addOverviewRuler(term: Terminal): void {
|
||||
term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} };
|
||||
term.registerDecoration({ marker: term.registerMarker(1), overviewRulerOptions: { color: '#ef2929' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(3), overviewRulerOptions: { color: '#8ae234' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(5), overviewRulerOptions: { color: '#729fcf' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(7), overviewRulerOptions: { color: '#ef2929', position: 'left' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(7), overviewRulerOptions: { color: '#8ae234', position: 'center' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(7), overviewRulerOptions: { color: '#729fcf', position: 'right' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(10), overviewRulerOptions: { color: '#8ae234', position: 'center' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(10), overviewRulerOptions: { color: '#ffffff80', position: 'full' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(1)!, overviewRulerOptions: { color: '#ef2929' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(3)!, overviewRulerOptions: { color: '#8ae234' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(5)!, overviewRulerOptions: { color: '#729fcf' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(7)!, overviewRulerOptions: { color: '#ef2929', position: 'left' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(7)!, overviewRulerOptions: { color: '#8ae234', position: 'center' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(7)!, overviewRulerOptions: { color: '#729fcf', position: 'right' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(10)!, overviewRulerOptions: { color: '#8ae234', position: 'center' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(10)!, overviewRulerOptions: { color: '#ffffff80', position: 'full' } });
|
||||
}
|
||||
|
||||
let decorationStressTestDecorations: IDisposable[] | undefined;
|
||||
@@ -795,7 +795,7 @@ function decorationStressTest(term: Terminal): void {
|
||||
for (let y = 0; y < term.buffer.active.length; y++) {
|
||||
const cursorOffsetY = y - cursorY;
|
||||
const decoration = term.registerDecoration({
|
||||
marker: term.registerMarker(cursorOffsetY),
|
||||
marker: term.registerMarker(cursorOffsetY)!,
|
||||
x,
|
||||
width: 4,
|
||||
backgroundColor: '#FF0000',
|
||||
|
||||
@@ -43,6 +43,7 @@ export default tseslint.config(
|
||||
singleline: { delimiter: 'comma', requireLast: false }
|
||||
}],
|
||||
'@stylistic/type-annotation-spacing': 'warn',
|
||||
'@stylistic/space-unary-ops': 'warn',
|
||||
|
||||
'@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }],
|
||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||
@@ -85,6 +86,7 @@ export default tseslint.config(
|
||||
'eqeqeq': ['warn', 'always'],
|
||||
'jsdoc/check-alignment': 'warn',
|
||||
'jsdoc/check-param-names': 'warn',
|
||||
'jsdoc/multiline-blocks': 'warn',
|
||||
'jsdoc/no-multi-asterisks': 'warn',
|
||||
'keyword-spacing': 'warn',
|
||||
'max-len': ['warn', {
|
||||
|
||||
@@ -563,8 +563,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
|
||||
try {
|
||||
this._onWillOpen.fire(this.element);
|
||||
} catch (e) {
|
||||
this._logService.error('onWillOpen handler threw an exception', e);
|
||||
}
|
||||
catch { /* fails to load addon for some reason */ }
|
||||
if (!this._renderService.hasRenderer()) {
|
||||
this._renderService.setRenderer(this._createRenderer());
|
||||
}
|
||||
@@ -767,7 +768,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
return this.buffer.markers;
|
||||
}
|
||||
|
||||
public registerMarker(cursorYOffset: number): IMarker {
|
||||
public registerMarker(cursorYOffset: number): IMarker | undefined {
|
||||
// Disallow markers on the alt buffer
|
||||
if (this.buffer !== this.buffers.normal) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.buffer.addMarker(this.buffer.ybase + this.buffer.y + cursorYOffset);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,20 @@ describe('Linkifier2', () => {
|
||||
},
|
||||
activate: () => { }
|
||||
};
|
||||
const multiLineLink: ILink = {
|
||||
text: 'foo',
|
||||
range: {
|
||||
start: {
|
||||
x: 2,
|
||||
y: 1
|
||||
},
|
||||
end: {
|
||||
x: 4,
|
||||
y: 2
|
||||
}
|
||||
},
|
||||
activate: () => { }
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new jsdom.JSDOM();
|
||||
@@ -86,4 +100,30 @@ describe('Linkifier2', () => {
|
||||
linkifier.linkLeave({ classList: { add: () => { } } } as any, link, {} as any);
|
||||
});
|
||||
|
||||
it('onShowLinkUnderline event range is correct for wrapped links', done => {
|
||||
linkifier.onShowLinkUnderline(e => {
|
||||
assert.equal(multiLineLink.range.start.x - 1, e.x1);
|
||||
assert.equal(multiLineLink.range.start.y - 1, e.y1);
|
||||
assert.equal(multiLineLink.range.end.x, e.x2);
|
||||
assert.equal(multiLineLink.range.end.y - 1, e.y2);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
linkifier.linkHover({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
|
||||
});
|
||||
|
||||
it('onHideLinkUnderline event range is correct for wrapped links', done => {
|
||||
linkifier.onHideLinkUnderline(e => {
|
||||
assert.equal(multiLineLink.range.start.x - 1, e.x1);
|
||||
assert.equal(multiLineLink.range.start.y - 1, e.y1);
|
||||
assert.equal(multiLineLink.range.end.x, e.x2);
|
||||
assert.equal(multiLineLink.range.end.y - 1, e.y2);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
linkifier.linkLeave({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { OscLinkProvider } from 'browser/OscLinkProvider';
|
||||
import { ILink } from 'browser/Types';
|
||||
import { createCellData, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
|
||||
import { IBufferService, IOscLinkService } from 'common/services/Services';
|
||||
import { IBufferLine, IOscLinkData } from 'common/Types';
|
||||
|
||||
class TestOscLinkService implements IOscLinkService {
|
||||
public serviceBrand: any;
|
||||
public registerLink(_linkData: IOscLinkData): number { return 0; }
|
||||
public addLineToLink(_linkId: number, _y: number): void { }
|
||||
public getLinkData(linkId: number): IOscLinkData | undefined {
|
||||
return { uri: `https://example.com/${linkId}` };
|
||||
}
|
||||
}
|
||||
|
||||
function setText(line: IBufferLine | undefined, x: number, text: string): void {
|
||||
if (!line) {
|
||||
throw new Error('Missing buffer line');
|
||||
}
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
line.setCell(x + i, createCellData(0, text[i], 1));
|
||||
}
|
||||
}
|
||||
|
||||
function setUrl(line: IBufferLine | undefined, x: number, text: string, linkId: number): void {
|
||||
if (!line) {
|
||||
throw new Error('Missing buffer line');
|
||||
}
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const cell = createCellData(0, text[i], 1);
|
||||
cell.extended.urlId = linkId;
|
||||
cell.updateExtended();
|
||||
line.setCell(x + i, cell);
|
||||
}
|
||||
}
|
||||
|
||||
function getLinks(provider: OscLinkProvider, y: number): Promise<ILink[]> {
|
||||
return new Promise(resolve => provider.provideLinks(y, links => resolve(links ?? [])));
|
||||
}
|
||||
|
||||
describe('OscLinkProvider', () => {
|
||||
let bufferService: IBufferService;
|
||||
let provider: OscLinkProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
const optionsService = new MockOptionsService();
|
||||
bufferService = new MockBufferService(5, 5, optionsService);
|
||||
provider = new OscLinkProvider(bufferService, optionsService, new TestOscLinkService());
|
||||
});
|
||||
|
||||
it('expands a wrapped link range backward to the previous line', async () => {
|
||||
const line1 = bufferService.buffer.lines.get(0);
|
||||
const line2 = bufferService.buffer.lines.get(1);
|
||||
setText(line1, 0, 'aa');
|
||||
setUrl(line1, 2, 'bbb', 1);
|
||||
setUrl(line2, 0, 'cccc', 1);
|
||||
setText(line2, 4, 'x');
|
||||
line2!.isWrapped = true;
|
||||
|
||||
const links = await getLinks(provider, 2);
|
||||
assert.lengthOf(links, 1);
|
||||
assert.deepEqual(links[0].range, {
|
||||
start: { x: 3, y: 1 },
|
||||
end: { x: 4, y: 2 }
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a wrapped link range forward when a link ends at line boundary', async () => {
|
||||
const line1 = bufferService.buffer.lines.get(0);
|
||||
const line2 = bufferService.buffer.lines.get(1);
|
||||
setUrl(line1, 0, 'aaaaa', 1);
|
||||
setUrl(line2, 0, 'bb', 1);
|
||||
setText(line2, 2, 'ccc');
|
||||
line2!.isWrapped = true;
|
||||
|
||||
const links = await getLinks(provider, 1);
|
||||
assert.lengthOf(links, 1);
|
||||
assert.deepEqual(links[0].range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 2, y: 2 }
|
||||
});
|
||||
});
|
||||
|
||||
it('does not merge wrapped links with different url ids', async () => {
|
||||
const line1 = bufferService.buffer.lines.get(0);
|
||||
const line2 = bufferService.buffer.lines.get(1);
|
||||
setUrl(line1, 0, 'aaaaa', 1);
|
||||
setUrl(line2, 0, 'bbb', 2);
|
||||
setText(line2, 3, 'cc');
|
||||
line2!.isWrapped = true;
|
||||
|
||||
const links = await getLinks(provider, 1);
|
||||
assert.lengthOf(links, 1);
|
||||
assert.deepEqual(links[0].range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 5, y: 1 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
import { IBufferRange, ILink } from 'browser/Types';
|
||||
import { ILinkProvider } from 'browser/services/Services';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
|
||||
|
||||
export class OscLinkProvider implements ILinkProvider {
|
||||
@@ -57,19 +58,8 @@ export class OscLinkProvider implements ILinkProvider {
|
||||
if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
|
||||
const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
|
||||
if (text) {
|
||||
// These ranges are 1-based
|
||||
const range: IBufferRange = {
|
||||
start: {
|
||||
x: currentStart + 1,
|
||||
y
|
||||
},
|
||||
end: {
|
||||
// Offset end x if it's a link that ends on the last cell in the line
|
||||
x: x + (!finishLink && x === lineLength - 1 ? 1 : 0),
|
||||
y
|
||||
}
|
||||
};
|
||||
|
||||
const endX = x + (!finishLink && x === lineLength - 1 ? 1 : 0);
|
||||
const range = this._getRangeWithLineWrap(y, currentStart, endX, currentLinkId);
|
||||
let ignoreLink = false;
|
||||
if (!linkHandler?.allowNonHttpProtocols) {
|
||||
try {
|
||||
@@ -111,6 +101,82 @@ export class OscLinkProvider implements ILinkProvider {
|
||||
// id
|
||||
callback(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a single-line OSC 8 range to a contiguous wrapped range for the same link id.
|
||||
*/
|
||||
private _getRangeWithLineWrap(y: number, startX: number, endX: number, linkId: number): IBufferRange {
|
||||
let startY = y;
|
||||
let finalStartX = startX;
|
||||
let endY = y;
|
||||
let finalEndX = endX;
|
||||
|
||||
// Expand upward only when this segment starts at column 0 and the current line is wrapped.
|
||||
while (finalStartX === 0) {
|
||||
const currentLine = this._bufferService.buffer.lines.get(startY - 1);
|
||||
if (!currentLine?.isWrapped) {
|
||||
break;
|
||||
}
|
||||
const previousLine = this._bufferService.buffer.lines.get(startY - 2);
|
||||
if (!previousLine) {
|
||||
break;
|
||||
}
|
||||
const previousLineLength = previousLine.getTrimmedLength();
|
||||
if (previousLineLength === 0 || !this._hasUrlId(previousLine, previousLineLength - 1, linkId)) {
|
||||
break;
|
||||
}
|
||||
let previousStartX = previousLineLength - 1;
|
||||
while (previousStartX > 0 && this._hasUrlId(previousLine, previousStartX - 1, linkId)) {
|
||||
previousStartX--;
|
||||
}
|
||||
startY--;
|
||||
finalStartX = previousStartX;
|
||||
}
|
||||
|
||||
// Expand downward only when this segment reaches trimmed EOL and the next line is wrapped.
|
||||
while (true) {
|
||||
const currentLine = this._bufferService.buffer.lines.get(endY - 1);
|
||||
if (!currentLine) {
|
||||
break;
|
||||
}
|
||||
const currentLineLength = currentLine.getTrimmedLength();
|
||||
if (finalEndX !== currentLineLength) {
|
||||
break;
|
||||
}
|
||||
const nextLine = this._bufferService.buffer.lines.get(endY);
|
||||
if (!nextLine?.isWrapped) {
|
||||
break;
|
||||
}
|
||||
const nextLineLength = nextLine.getTrimmedLength();
|
||||
if (nextLineLength === 0 || !this._hasUrlId(nextLine, 0, linkId)) {
|
||||
break;
|
||||
}
|
||||
let nextEndX = 1;
|
||||
while (nextEndX < nextLineLength && this._hasUrlId(nextLine, nextEndX, linkId)) {
|
||||
nextEndX++;
|
||||
}
|
||||
endY++;
|
||||
finalEndX = nextEndX;
|
||||
}
|
||||
|
||||
// IBufferRange uses 1-based coordinates.
|
||||
return {
|
||||
start: {
|
||||
x: finalStartX + 1,
|
||||
y: startY
|
||||
},
|
||||
end: {
|
||||
x: finalEndX,
|
||||
y: endY
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _hasUrlId(line: IBufferLine, x: number, linkId: number): boolean {
|
||||
const cell = this._workCell;
|
||||
line.loadCell(x, cell);
|
||||
return !!cell.hasExtendedAttrs() && cell.extended.urlId === linkId;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultActivate(e: MouseEvent, uri: string): void {
|
||||
|
||||
@@ -1146,6 +1146,16 @@ describe('Terminal', () => {
|
||||
assert.equal(termConverting.buffer.lines.get(1)!.translateToString(true), 'World');
|
||||
});
|
||||
|
||||
describe('registerMarker', () => {
|
||||
it('should return undefined on the alt buffer', async () => {
|
||||
assert.isDefined(term.registerMarker(0));
|
||||
await term.writeP('\x1b[?47h');
|
||||
assert.isUndefined(term.registerMarker(0));
|
||||
await term.writeP('\x1b[?47l');
|
||||
assert.isDefined(term.registerMarker(0));
|
||||
});
|
||||
});
|
||||
|
||||
// FIXME: move to common/CoreTerminal.test once the trimming is moved over
|
||||
describe('marker lifecycle', () => {
|
||||
// create a 10x5 terminal with markers on every line
|
||||
|
||||
@@ -166,7 +166,7 @@ export class Terminal extends Disposable implements ITerminalApi {
|
||||
public deregisterCharacterJoiner(joinerId: number): void {
|
||||
this._core.deregisterCharacterJoiner(joinerId);
|
||||
}
|
||||
public registerMarker(cursorYOffset: number = 0): IMarker {
|
||||
public registerMarker(cursorYOffset: number = 0): IMarker | undefined {
|
||||
this._verifyIntegers(cursorYOffset);
|
||||
return this._core.registerMarker(cursorYOffset);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { moveToCellSequence } from 'browser/input/MoveToCell';
|
||||
import { SelectionModel } from 'browser/selection/SelectionModel';
|
||||
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
|
||||
import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import * as Browser from 'common/Platform';
|
||||
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
|
||||
import { getRangeLength } from 'common/buffer/BufferRange';
|
||||
@@ -102,7 +102,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
|
||||
private _mouseMoveListener: EventListener;
|
||||
private _mouseUpListener: EventListener;
|
||||
private _trimListener: IDisposable;
|
||||
private readonly _trimListener = this._register(new MutableDisposable<IDisposable>());
|
||||
private _workCell: CellData = new CellData();
|
||||
|
||||
private _mouseDownTimeStamp: number = 0;
|
||||
@@ -140,7 +140,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
this.clearSelection();
|
||||
}
|
||||
});
|
||||
this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._trimListener.value = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._register(this._bufferService.buffers.onBufferActivate(e => this._handleBufferActivate(e)));
|
||||
|
||||
this.enable();
|
||||
@@ -769,8 +769,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
// reverseIndex) and delete in a splice is only ever used when the same
|
||||
// number of elements was just added. Given this is could actually be
|
||||
// beneficial to leave the selection as is for these cases.
|
||||
this._trimListener.dispose();
|
||||
this._trimListener = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._trimListener.value = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { clone } from 'common/Clone';
|
||||
|
||||
describe('clone', () => {
|
||||
it('should clone simple objects', () => {
|
||||
const test = {
|
||||
a: 1,
|
||||
b: 2
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), { a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('should clone nested objects', () => {
|
||||
const test = {
|
||||
bar: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), {
|
||||
bar: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone array values', () => {
|
||||
const test = {
|
||||
a: [1, 2, 3],
|
||||
b: [1, null, 'test', { foo: 'bar' }]
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), {
|
||||
a: [1, 2, 3],
|
||||
b: [1, null, 'test', { foo: 'bar' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop mutation from occuring on the original object', () => {
|
||||
const test = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test);
|
||||
|
||||
test.a = 5;
|
||||
test.c.foo = 'barbaz';
|
||||
|
||||
assert.deepEqual(cloned, {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone to a maximum depth of 5 by default', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: {
|
||||
e: {
|
||||
f: 'foo'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test);
|
||||
|
||||
test.a.b.c.d.e.f = 'bar';
|
||||
|
||||
// The values at a greater depth then 5 should not be cloned
|
||||
assert.equal((cloned as any).a.b.c.d.e.f, 'bar');
|
||||
});
|
||||
|
||||
it('should allow an optional maximum depth to be set', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: 'foo'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test, 2);
|
||||
|
||||
test.a.b.c = 'bar';
|
||||
|
||||
// The values at a greater depth then 2 should not be cloned
|
||||
assert.equal((cloned as any).a.b.c, 'bar');
|
||||
});
|
||||
|
||||
it('should not throw when cloning a recursive reference', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test.a.b.c = test;
|
||||
|
||||
assert.doesNotThrow(() => clone(test));
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*
|
||||
* A simple utility for cloning values
|
||||
*/
|
||||
export function clone<T>(val: T, depth: number = 5): T {
|
||||
if (typeof val !== 'object') {
|
||||
return val;
|
||||
}
|
||||
|
||||
// If we're cloning an array, use an array as the base, otherwise use an object
|
||||
const clonedObject: any = Array.isArray(val) ? [] : {};
|
||||
|
||||
for (const key in val) {
|
||||
// Recursively clone eack item unless we're at the maximum depth
|
||||
clonedObject[key] = depth <= 1 ? val[key] : (val[key] && clone(val[key], depth - 1));
|
||||
}
|
||||
|
||||
return clonedObject as T;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { Params } from 'common/parser/Params';
|
||||
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockMouseStateService, MockCharsetService, MockUnicodeService, MockOscLinkService, extendedAttributes } from 'common/TestUtils.test';
|
||||
import { IBufferService, ICoreService, type IOscLinkService } from 'common/services/Services';
|
||||
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
|
||||
import { clone } from 'common/Clone';
|
||||
import { BufferService } from 'common/services/BufferService';
|
||||
import { CoreService } from 'common/services/CoreService';
|
||||
import { OscLinkService } from 'common/services/OscLinkService';
|
||||
@@ -227,32 +226,32 @@ describe('InputHandler', () => {
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, undefined);
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, undefined);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([1]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([2]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([3]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([4]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([5]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([6]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
@@ -2590,10 +2590,6 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
* | 3 | CMY color. | #N |
|
||||
* | 4 | CMYK color. | #N |
|
||||
* | 5 | Indexed (256 colors) as `Ps ; 5 ; INDEX` or `Ps : 5 : INDEX`. | #Y |
|
||||
*
|
||||
*
|
||||
* FIXME: blinking is implemented in attrs, but not working in renderers?
|
||||
* FIXME: remove dead branch for p=100
|
||||
*/
|
||||
public charAttributes(params: IParams): boolean {
|
||||
// Optimize a single SGR0.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { LimitedStringBuilder, StringBuilder } from 'common/StringBuilder';
|
||||
|
||||
describe('StringBuilder', () => {
|
||||
it('should start empty', () => {
|
||||
const builder = new StringBuilder();
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should append a single chunk', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('hello');
|
||||
assert.strictEqual(builder.length, 5);
|
||||
assert.strictEqual(builder.toString(), 'hello');
|
||||
});
|
||||
|
||||
it('should join multiple chunks in order', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('foo');
|
||||
builder.append('bar');
|
||||
builder.append('baz');
|
||||
assert.strictEqual(builder.length, 9);
|
||||
assert.strictEqual(builder.toString(), 'foobarbaz');
|
||||
});
|
||||
|
||||
it('should handle empty chunks', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('');
|
||||
builder.append('a');
|
||||
builder.append('');
|
||||
assert.strictEqual(builder.length, 1);
|
||||
assert.strictEqual(builder.toString(), 'a');
|
||||
});
|
||||
|
||||
it('should reset accumulated data', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('hello');
|
||||
builder.reset();
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should allow appending after reset', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('old');
|
||||
builder.reset();
|
||||
builder.append('new');
|
||||
assert.strictEqual(builder.toString(), 'new');
|
||||
});
|
||||
|
||||
it('should accumulate many small chunks without quadratic concatenation', () => {
|
||||
const builder = new StringBuilder();
|
||||
const chunk = 'x';
|
||||
const count = 10000;
|
||||
for (let i = 0; i < count; i++) {
|
||||
builder.append(chunk);
|
||||
}
|
||||
assert.strictEqual(builder.length, count);
|
||||
assert.strictEqual(builder.toString(), 'x'.repeat(count));
|
||||
});
|
||||
});
|
||||
|
||||
describe('LimitedStringBuilder', () => {
|
||||
it('should expose the configured limit', () => {
|
||||
const builder = new LimitedStringBuilder(42);
|
||||
assert.strictEqual(builder.limit, 42);
|
||||
});
|
||||
|
||||
it('should start empty', () => {
|
||||
const builder = new LimitedStringBuilder(10);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should accept data up to the limit', () => {
|
||||
const builder = new LimitedStringBuilder(10);
|
||||
assert.strictEqual(builder.append('12345'), false);
|
||||
assert.strictEqual(builder.append('67890'), false);
|
||||
assert.strictEqual(builder.length, 10);
|
||||
assert.strictEqual(builder.toString(), '1234567890');
|
||||
});
|
||||
|
||||
it('should accept a single chunk exactly at the limit', () => {
|
||||
const builder = new LimitedStringBuilder(5);
|
||||
assert.strictEqual(builder.append('abcde'), false);
|
||||
assert.strictEqual(builder.length, 5);
|
||||
assert.strictEqual(builder.toString(), 'abcde');
|
||||
});
|
||||
|
||||
it('should reject data exceeding the limit and clear the buffer', () => {
|
||||
const builder = new LimitedStringBuilder(5);
|
||||
builder.append('abc');
|
||||
assert.strictEqual(builder.append('def'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should reject a single chunk larger than the limit', () => {
|
||||
const builder = new LimitedStringBuilder(3);
|
||||
assert.strictEqual(builder.append('toolong'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should allow appending again after reset following a limit breach', () => {
|
||||
const builder = new LimitedStringBuilder(3);
|
||||
assert.strictEqual(builder.append('abcd'), true);
|
||||
builder.reset();
|
||||
assert.strictEqual(builder.append('ab'), false);
|
||||
assert.strictEqual(builder.toString(), 'ab');
|
||||
});
|
||||
|
||||
it('should accumulate many chunks before hitting the limit', () => {
|
||||
const limit = 100;
|
||||
const builder = new LimitedStringBuilder(limit);
|
||||
const chunk = 'A';
|
||||
for (let i = 0; i < limit; i++) {
|
||||
assert.strictEqual(builder.append(chunk), false);
|
||||
}
|
||||
assert.strictEqual(builder.toString(), 'A'.repeat(limit));
|
||||
assert.strictEqual(builder.append('B'), true);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should reject when limit is zero and any data is appended', () => {
|
||||
const builder = new LimitedStringBuilder(0);
|
||||
assert.strictEqual(builder.append('a'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
});
|
||||
|
||||
it('should allow zero-length appends at the limit', () => {
|
||||
const builder = new LimitedStringBuilder(0);
|
||||
assert.strictEqual(builder.append(''), false);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accumulates string data from multiple chunks without O(n²) string concatenation.
|
||||
*/
|
||||
export class StringBuilder {
|
||||
private _chunks: string[] = [];
|
||||
private _length = 0;
|
||||
|
||||
public get length(): number {
|
||||
return this._length;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._chunks.length = 0;
|
||||
this._length = 0;
|
||||
}
|
||||
|
||||
public append(chunk: string): void {
|
||||
this._chunks.push(chunk);
|
||||
this._length += chunk.length;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this._chunks.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String builder that rejects payloads larger than a fixed limit.
|
||||
*/
|
||||
export class LimitedStringBuilder {
|
||||
private readonly _builder = new StringBuilder();
|
||||
|
||||
constructor(private readonly _limit: number) { }
|
||||
|
||||
public get length(): number {
|
||||
return this._builder.length;
|
||||
}
|
||||
|
||||
public get limit(): number {
|
||||
return this._limit;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._builder.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the limit was exceeded (buffer is cleared in that case)
|
||||
*/
|
||||
public append(chunk: string): boolean {
|
||||
this._builder.append(chunk);
|
||||
if (this._builder.length > this._limit) {
|
||||
this._builder.reset();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this._builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IMouseStateService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
|
||||
import { UnicodeService } from 'common/services/UnicodeService';
|
||||
import { clone } from 'common/Clone';
|
||||
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
|
||||
import { IBufferSet, IBuffer } from 'common/buffer/Types';
|
||||
import { BufferSet } from 'common/buffer/BufferSet';
|
||||
@@ -157,7 +156,7 @@ export class MockLogService implements ILogService {
|
||||
|
||||
export class MockOptionsService implements IOptionsService {
|
||||
public serviceBrand: any;
|
||||
public readonly rawOptions: Required<ITerminalOptions> = clone(DEFAULT_OPTIONS);
|
||||
public readonly rawOptions: Required<ITerminalOptions> = structuredClone(DEFAULT_OPTIONS);
|
||||
public options: Required<ITerminalOptions> = this.rawOptions;
|
||||
public onOptionChange: IEvent<keyof ITerminalOptions> = new Emitter<keyof ITerminalOptions>().event;
|
||||
constructor(testOptions?: Partial<ITerminalOptions>) {
|
||||
|
||||
+16
-16
@@ -528,8 +528,8 @@ export interface IInputHandler {
|
||||
/** CSI ' } */ insertColumns(params: IParams): boolean;
|
||||
/** CSI ' ~ */ deleteColumns(params: IParams): boolean;
|
||||
|
||||
/** OSC 0
|
||||
OSC 2 */ setTitle(data: string): boolean;
|
||||
/** OSC 0 */
|
||||
/** OSC 2 */ setTitle(data: string): boolean;
|
||||
/** OSC 4 */ setOrReportIndexedColor(data: string): boolean;
|
||||
/** OSC 10 */ setOrReportFgColor(data: string): boolean;
|
||||
/** OSC 11 */ setOrReportBgColor(data: string): boolean;
|
||||
@@ -542,24 +542,24 @@ export interface IInputHandler {
|
||||
/** ESC E */ nextLine(): boolean;
|
||||
/** ESC = */ keypadApplicationMode(): boolean;
|
||||
/** ESC > */ keypadNumericMode(): boolean;
|
||||
/** ESC % G
|
||||
ESC % @ */ selectDefaultCharset(): boolean;
|
||||
/** ESC ( C
|
||||
ESC ) C
|
||||
ESC * C
|
||||
ESC + C
|
||||
ESC - C
|
||||
ESC . C
|
||||
ESC / C */ selectCharset(collectAndFlag: string): boolean;
|
||||
/** ESC % G */
|
||||
/** ESC % @ */ selectDefaultCharset(): boolean;
|
||||
/** ESC ( C */
|
||||
/** ESC ) C */
|
||||
/** ESC * C */
|
||||
/** ESC + C */
|
||||
/** ESC - C */
|
||||
/** ESC . C */
|
||||
/** ESC / C */ selectCharset(collectAndFlag: string): boolean;
|
||||
/** ESC D */ index(): boolean;
|
||||
/** ESC H */ tabSet(): boolean;
|
||||
/** ESC M */ reverseIndex(): boolean;
|
||||
/** ESC c */ fullReset(): boolean;
|
||||
/** ESC n
|
||||
ESC o
|
||||
ESC |
|
||||
ESC }
|
||||
ESC ~ */ setgLevel(level: number): boolean;
|
||||
/** ESC n */
|
||||
/** ESC o */
|
||||
/** ESC | */
|
||||
/** ESC } */
|
||||
/** ESC ~ */ setgLevel(level: number): boolean;
|
||||
/** ESC # 8 */ screenAlignmentPattern(): boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,5 +125,20 @@ describe('WriteBuffer', () => {
|
||||
assert.deepEqual(stack, []);
|
||||
assert.deepEqual(cbStack, []);
|
||||
});
|
||||
it('flushSync fires onWriteParsed', () => {
|
||||
let parsed = 0;
|
||||
wb.onWriteParsed(() => parsed++);
|
||||
wb.write('a');
|
||||
wb.write('b');
|
||||
assert.equal(parsed, 0);
|
||||
wb.flushSync();
|
||||
assert.equal(parsed, 1);
|
||||
});
|
||||
it('flushSync with no pending writes does not fire onWriteParsed', () => {
|
||||
let parsed = 0;
|
||||
wb.onWriteParsed(() => parsed++);
|
||||
wb.flushSync();
|
||||
assert.equal(parsed, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,9 @@ export class WriteBuffer extends Disposable {
|
||||
|
||||
// Process all pending chunks synchronously
|
||||
let chunk: string | Uint8Array | undefined;
|
||||
let didProcess = false;
|
||||
while (chunk = this._writeBuffer.shift()) {
|
||||
didProcess = true;
|
||||
this._action(chunk);
|
||||
const cb = this._callbacks.shift();
|
||||
if (cb) cb();
|
||||
@@ -84,6 +86,9 @@ export class WriteBuffer extends Disposable {
|
||||
this._callbacks.length = 0;
|
||||
|
||||
this._isSyncWriting = false;
|
||||
if (didProcess) {
|
||||
this._onWriteParsed.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, IS
|
||||
import { ParserConstants } from 'common/parser/Constants';
|
||||
import { utf32ToString } from 'common/input/TextDecoder';
|
||||
import { IDisposable } from 'common/Types';
|
||||
import { LimitedStringBuilder } from 'common/StringBuilder';
|
||||
|
||||
const EMPTY_HANDLERS: IApcHandler[] = [];
|
||||
|
||||
@@ -153,13 +154,13 @@ export class ApcParser implements IApcParser {
|
||||
export class ApcHandler implements IApcHandler {
|
||||
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
|
||||
|
||||
private _data = '';
|
||||
private _data = new LimitedStringBuilder(ApcHandler._payloadLimit);
|
||||
private _hitLimit: boolean = false;
|
||||
|
||||
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
|
||||
|
||||
public start(): void {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
}
|
||||
|
||||
@@ -167,9 +168,7 @@ export class ApcHandler implements IApcHandler {
|
||||
if (this._hitLimit) {
|
||||
return;
|
||||
}
|
||||
this._data += utf32ToString(data, start, end);
|
||||
if (this._data.length > ApcHandler._payloadLimit) {
|
||||
this._data = '';
|
||||
if (this._data.append(utf32ToString(data, start, end))) {
|
||||
this._hitLimit = true;
|
||||
}
|
||||
}
|
||||
@@ -179,18 +178,18 @@ export class ApcHandler implements IApcHandler {
|
||||
if (this._hitLimit) {
|
||||
ret = false;
|
||||
} else if (success) {
|
||||
ret = this._handler(this._data);
|
||||
ret = this._handler(this._data.toString());
|
||||
if (ret instanceof Promise) {
|
||||
// need to hold data until `ret` got resolved
|
||||
// dont care for errors, data will be freed anyway on next start
|
||||
return ret.then(res => {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IDcsHandler, IParams, IHandlerCollection, IDcsParser, DcsFallbackHandle
|
||||
import { utf32ToString } from 'common/input/TextDecoder';
|
||||
import { Params } from 'common/parser/Params';
|
||||
import { ParserConstants } from 'common/parser/Constants';
|
||||
import { LimitedStringBuilder } from 'common/StringBuilder';
|
||||
|
||||
const EMPTY_HANDLERS: IDcsHandler[] = [];
|
||||
|
||||
@@ -140,7 +141,7 @@ EMPTY_PARAMS.addParam(0);
|
||||
export class DcsHandler implements IDcsHandler {
|
||||
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
|
||||
|
||||
private _data = '';
|
||||
private _data = new LimitedStringBuilder(DcsHandler._payloadLimit);
|
||||
private _params: IParams = EMPTY_PARAMS;
|
||||
private _hitLimit: boolean = false;
|
||||
|
||||
@@ -152,7 +153,7 @@ export class DcsHandler implements IDcsHandler {
|
||||
// perf optimization:
|
||||
// clone only, if we have non empty params, otherwise stick with default
|
||||
this._params = (params.length > 1 || params.params[0]) ? params.clone() : EMPTY_PARAMS;
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
}
|
||||
|
||||
@@ -160,9 +161,7 @@ export class DcsHandler implements IDcsHandler {
|
||||
if (this._hitLimit) {
|
||||
return;
|
||||
}
|
||||
this._data += utf32ToString(data, start, end);
|
||||
if (this._data.length > DcsHandler._payloadLimit) {
|
||||
this._data = '';
|
||||
if (this._data.append(utf32ToString(data, start, end))) {
|
||||
this._hitLimit = true;
|
||||
}
|
||||
}
|
||||
@@ -172,20 +171,20 @@ export class DcsHandler implements IDcsHandler {
|
||||
if (this._hitLimit) {
|
||||
ret = false;
|
||||
} else if (success) {
|
||||
ret = this._handler(this._data, this._params);
|
||||
ret = this._handler(this._data.toString(), this._params);
|
||||
if (ret instanceof Promise) {
|
||||
// need to hold data and params until `ret` got resolved
|
||||
// dont care for errors, data will be freed anyway on next start
|
||||
return ret.then(res => {
|
||||
this._params = EMPTY_PARAMS;
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
this._params = EMPTY_PARAMS;
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IOscHandler, IHandlerCollection, OscFallbackHandlerType, IOscParser, IS
|
||||
import { OscState, ParserConstants } from 'common/parser/Constants';
|
||||
import { utf32ToString } from 'common/input/TextDecoder';
|
||||
import { IDisposable } from 'common/Types';
|
||||
import { LimitedStringBuilder } from 'common/StringBuilder';
|
||||
|
||||
const EMPTY_HANDLERS: IOscHandler[] = [];
|
||||
|
||||
@@ -194,13 +195,13 @@ export class OscParser implements IOscParser {
|
||||
export class OscHandler implements IOscHandler {
|
||||
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
|
||||
|
||||
private _data = '';
|
||||
private _data = new LimitedStringBuilder(OscHandler._payloadLimit);
|
||||
private _hitLimit: boolean = false;
|
||||
|
||||
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
|
||||
|
||||
public start(): void {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
}
|
||||
|
||||
@@ -208,9 +209,7 @@ export class OscHandler implements IOscHandler {
|
||||
if (this._hitLimit) {
|
||||
return;
|
||||
}
|
||||
this._data += utf32ToString(data, start, end);
|
||||
if (this._data.length > OscHandler._payloadLimit) {
|
||||
this._data = '';
|
||||
if (this._data.append(utf32ToString(data, start, end))) {
|
||||
this._hitLimit = true;
|
||||
}
|
||||
}
|
||||
@@ -220,18 +219,18 @@ export class OscHandler implements IOscHandler {
|
||||
if (this._hitLimit) {
|
||||
ret = false;
|
||||
} else if (success) {
|
||||
ret = this._handler(this._data);
|
||||
ret = this._handler(this._data.toString());
|
||||
if (ret instanceof Promise) {
|
||||
// need to hold data until `ret` got resolved
|
||||
// dont care for errors, data will be freed anyway on next start
|
||||
return ret.then(res => {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class BufferNamespaceApi extends Disposable implements IBufferNamespaceAp
|
||||
super();
|
||||
this._normal = new BufferApiView(this._core.buffers.normal, 'normal');
|
||||
this._alternate = new BufferApiView(this._core.buffers.alt, 'alternate');
|
||||
this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active));
|
||||
this._register(this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active)));
|
||||
}
|
||||
public get active(): IBufferApi {
|
||||
if (this._core.buffers.active === this._core.buffers.normal) { return this.normal; }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clone } from 'common/Clone';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { IDecPrivateModes, IKittyKeyboardState, IModes } from 'common/Types';
|
||||
import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
@@ -61,14 +60,14 @@ export class CoreService extends Disposable implements ICoreService {
|
||||
) {
|
||||
super();
|
||||
this.isCursorInitialized = _optionsService.rawOptions.showCursorImmediately ?? false;
|
||||
this.modes = clone(DEFAULT_MODES);
|
||||
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.modes = structuredClone(DEFAULT_MODES);
|
||||
this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.modes = clone(DEFAULT_MODES);
|
||||
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.modes = structuredClone(DEFAULT_MODES);
|
||||
this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE();
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export class Terminal extends CoreTerminal {
|
||||
// Don't clear if it's already clear
|
||||
return;
|
||||
}
|
||||
this.buffer.clearAllMarkers();
|
||||
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
|
||||
this.buffer.lines.length = 1;
|
||||
this.buffer.ydisp = 0;
|
||||
|
||||
@@ -114,6 +114,29 @@ describe('Headless API Tests', function (): void {
|
||||
}
|
||||
});
|
||||
|
||||
it('clear disposes markers', async () => {
|
||||
term = new Terminal({ rows: 5, allowProposedApi: true });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await writeSync('\n\rtest' + i);
|
||||
}
|
||||
const markers = [
|
||||
term.registerMarker(1)!,
|
||||
term.registerMarker(2)!,
|
||||
term.registerMarker(3)!,
|
||||
term.registerMarker(4)!
|
||||
];
|
||||
let disposeCount = 0;
|
||||
for (const marker of markers) {
|
||||
marker.onDispose(() => disposeCount++);
|
||||
}
|
||||
term.clear();
|
||||
strictEqual(disposeCount, markers.length);
|
||||
for (const marker of markers) {
|
||||
strictEqual(marker.isDisposed, true);
|
||||
}
|
||||
strictEqual(term.markers.length, 0);
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
const termOptions = {
|
||||
cols: 80,
|
||||
@@ -396,6 +419,18 @@ describe('Headless API Tests', function (): void {
|
||||
strictEqual(term.buffer.normal.getLine(0)!.translateToString(), 'norm ');
|
||||
strictEqual(term.buffer.alternate.getLine(0), undefined);
|
||||
});
|
||||
|
||||
it('registerMarker returns undefined on the alt buffer', async () => {
|
||||
term = new Terminal({ cols: 5, allowProposedApi: true });
|
||||
strictEqual(term.buffer.active.type, 'normal');
|
||||
strictEqual(term.registerMarker(0)!.line, 0);
|
||||
await writeSync('\x1b[?47h');
|
||||
strictEqual(term.buffer.active.type, 'alternate');
|
||||
strictEqual(term.registerMarker(0), undefined);
|
||||
await writeSync('\x1b[?47l');
|
||||
strictEqual(term.buffer.active.type, 'normal');
|
||||
strictEqual(term.registerMarker(0)!.line, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modes', () => {
|
||||
|
||||
@@ -284,7 +284,7 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi
|
||||
}
|
||||
public async input(data: string, wasUserInput: boolean = true): Promise<void> { return this.evaluate(([term]) => term.input(data, wasUserInput)); }
|
||||
public async resize(cols: number, rows: number): Promise<void> { return this._page.evaluate(([term, cols, rows]) => term.resize(cols, rows), [await this.getHandle(), cols, rows] as const); }
|
||||
public async registerMarker(y?: number | undefined): Promise<IMarker> { return this._page.evaluate(([term, y]) => term.registerMarker(y), [await this.getHandle(), y] as const); }
|
||||
public async registerMarker(y?: number | undefined): Promise<IMarker | undefined> { return this._page.evaluate(([term, y]) => term.registerMarker(y), [await this.getHandle(), y] as const); }
|
||||
public async registerDecoration(decorationOptions: IDecorationOptions): Promise<IDecoration | undefined> { return this._page.evaluate(([term, decorationOptions]) => term.registerDecoration(decorationOptions), [await this.getHandle(), decorationOptions] as const); }
|
||||
public async clearTextureAtlas(): Promise<void> { return this.evaluate(([term]) => term.clearTextureAtlas()); }
|
||||
// #endregion
|
||||
|
||||
Vendored
+3
-2
@@ -1257,11 +1257,12 @@ declare module '@xterm/xterm' {
|
||||
deregisterCharacterJoiner(joinerId: number): void;
|
||||
|
||||
/**
|
||||
* Adds a marker to the normal buffer and returns it.
|
||||
* Adds a marker to the normal buffer and returns it. If the alt buffer is
|
||||
* active, undefined is returned.
|
||||
* @param cursorYOffset The y position offset of the marker from the cursor.
|
||||
* @returns The new marker or undefined.
|
||||
*/
|
||||
registerMarker(cursorYOffset?: number): IMarker;
|
||||
registerMarker(cursorYOffset?: number): IMarker | undefined;
|
||||
|
||||
/**
|
||||
* Registers a decoration to the terminal.
|
||||
|
||||
Reference in New Issue
Block a user