Compare commits

..
Author SHA1 Message Date
Cursor AgentandDaniel Imms e331541fd1 Align browser registerMarker alt-buffer guard with headless
Block registerMarker on the alternate buffer in CoreBrowserTerminal,
matching headless addMarker behavior. Update public typings to
IMarker | undefined and add regression tests.

Fixes #5937

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:22:33 +00:00
Daniel Imms 2ab0f10a26 Merge pull request #5929 from xtermjs/cursor/atlas-cache-texture-size-7cad
fix(webgl): include texture size and cell dimensions in atlas cache equality
2026-05-27 10:31:32 -07:00
Daniel Imms 280dc0a36a Merge pull request #5927 from xtermjs/cursor/webgl-vao-dispose-ef2f
fix(addon-webgl): delete VAOs on renderer dispose
2026-05-27 09:34:46 -07:00
Daniel Imms ebe8cb749a Merge pull request #5928 from xtermjs/cursor/fix-dcs-osc-apc-payload-o-n-f60b
Fix O(n²) payload accumulation in DCS/OSC/APC handlers
2026-05-27 09:32:42 -07:00
Daniel Imms c28f3c8d73 Merge pull request #5922 from xtermjs/cursor/remove-stale-sgr-fixme-e362
Remove stale FIXME comments from charAttributes JSDoc
2026-05-27 09:28:29 -07:00
Daniel Imms 3bd7ac083a Merge pull request #5923 from xtermjs/cursor/texture-atlas-remove-emitter-leak-75ac
Fix TextureAtlas remove-emitter leak on dispose
2026-05-27 09:27:51 -07:00
Cursor AgentandDaniel Imms 30b42838fd Extract StringBuilder and LimitedStringBuilder with unit tests
Replace PayloadStringBuffer with LimitedStringBuilder from common.
Add thorough StringBuilder and LimitedStringBuilder test coverage.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 16:27:26 +00:00
Daniel Imms 80e832e417 Merge pull request #5930 from xtermjs/cursor/webgl-ilogservice-5921-3745
fix(addon-webgl): use ILogService instead of console for logging
2026-05-27 09:21:12 -07:00
Daniel Imms 470947abd8 Merge pull request #5931 from xtermjs/cursor/headless-clear-markers-ccc0
fix(headless): clear markers when clear() is called
2026-05-27 09:18:17 -07:00
Daniel Imms bb49a80ace Merge branch 'master' into cursor/headless-clear-markers-ccc0 2026-05-27 09:13:38 -07:00
Daniel Imms 65b87e0f40 Merge pull request #5933 from xtermjs/cursor/replace-clone-with-structuredclone-34fc
Replace custom clone utility with structuredClone
2026-05-27 09:12:41 -07:00
Daniel Imms 39fc0f259e Merge pull request #5932 from xtermjs/cursor/webgl-context-loss-timeout-dispose-3f04
Clear WebGL context-loss timeout on WebglRenderer dispose
2026-05-27 09:10:07 -07:00
Daniel Imms b787ac8b1b Merge pull request #5924 from xtermjs/cursor/selection-trim-listener-leak-135c
Fix SelectionService trim listener leak
2026-05-27 09:10:04 -07:00
Daniel Imms 457efbf55d Merge pull request #5925 from xtermjs/cursor/fix-onwillopen-error-logging-7e14
Log onWillOpen handler errors instead of swallowing them
2026-05-27 09:00:33 -07:00
Daniel Imms 42270a0b68 Merge pull request #5926 from xtermjs/cursor/fix-flushsync-onwriteparsed-7a21
Fix flushSync() to fire onWriteParsed
2026-05-27 09:00:13 -07:00
Cursor AgentandDaniel Imms 090b77f354 Replace custom clone utility with structuredClone
Remove common/Clone.ts and its tests. Use the native structuredClone API
for cloning default modes, options, and test fixtures.

Fixes #5911

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:41:00 +00:00
Cursor AgentandDaniel Imms 534ce1365b Clear WebGL context-loss timeout on WebglRenderer dispose
Fixes #5919

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:40:10 +00:00
Cursor AgentandDaniel Imms f757c965ee fix(addon-webgl): keep VAO WebGL types non-nullable
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:38:58 +00:00
Cursor AgentandDaniel Imms 4aa02f3fd9 fix(addon-webgl): route logging through ILogService instead of console
Replace direct console.log/warn/error calls in the WebGL addon with
ILogService so messages respect the terminal logLevel and custom logger.

Fixes xtermjs/xterm.js#5921

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:37:09 +00:00
Cursor AgentandDaniel Imms f43273b3f0 fix(webgl): include texture size and cell dimensions in atlas cache equality
configEquals omitted deviceMaxTextureSize, deviceCellWidth, and
deviceCellHeight, which could cause the char atlas cache to reuse an
atlas built for a different GPU texture limit or cell size.

Fixes #5915

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:35:05 +00:00
Cursor AgentandDaniel Imms c06c3025f2 fix(addon-webgl): delete VAOs on renderer dispose
Register vertex array objects for disposal in GlyphRenderer and
RectangleRenderer, matching existing buffer and program cleanup.

Fixes #5918

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:35:04 +00:00
Daniel Imms 1064e4f783 Merge pull request #5812 from JeffreyCA/osc8-hover-fix
Underline entire wrapped OSC 8 links on hover
2026-05-27 08:34:50 -07:00
Cursor AgentandDaniel Imms fad4821fd9 Fix O(n²) payload accumulation in DCS/OSC/APC handlers
Replace repeated string concatenation with a chunk buffer that joins
once on completion. Fixes #5916.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:34:48 +00:00
Cursor AgentandDaniel Imms 306c3d73ec Fix flushSync() to fire onWriteParsed
flushSync() processes pending writes synchronously (e.g. during
CoreTerminal.resize()) but did not emit onWriteParsed, unlike the
async _innerWrite path. Addons listening for parse completion could
miss updates after resize.

Fixes #5912

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:34:26 +00:00
Cursor AgentandDaniel Imms 8f2bb32c9c fix(headless): clear markers when clear() is called
Headless clear() now calls buffer.clearAllMarkers() to match the browser
implementation, preventing markers from surviving clear() and pointing at
wrong lines.

Fixes #5910

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:34:00 +00:00
Cursor AgentandDaniel Imms 7b6c814566 Log errors from onWillOpen handlers instead of swallowing them
Fixes #5920

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:48 +00:00
Cursor AgentandDaniel Imms adc9dea083 Fix TextureAtlas remove-emitter leak on dispose
Dispose _onRemoveTextureAtlasCanvas in TextureAtlas.dispose() alongside
_onAddTextureAtlasCanvas to avoid leaking listeners when the atlas is torn down.

Fixes #5917

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:28 +00:00
Cursor AgentandDaniel Imms d565a2750e Fix SelectionService trim listener leak with MutableDisposable
Register _trimListener via _register(MutableDisposable) so it is disposed
when SelectionService is torn down. Use .value assignment on buffer
activate instead of manual dispose/swap.

Fixes #5914

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:20 +00:00
Cursor AgentandDaniel Imms c5ab83d697 Remove stale FIXME comments from charAttributes JSDoc
Fixes #5913. Blinking is supported in renderers via TextBlinkStateManager,
and bright background colors (100-107) are handled in the SGR loop.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 15:33:03 +00:00
c62e6dd765 Register BufferNamespaceApi onBufferActivate listener with disposables (#5906)
Wrap the onBufferActivate subscription in this._register so it is
disposed when BufferNamespaceApi is disposed, matching the pattern used
elsewhere in the codebase.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 04:39:27 +00:00
Daniel Imms 29a07f1d8f Merge pull request #5905 from xtermjs/cursor/multiline-comment-style-5899-d349
Add multiline-comment-style lint rule
2026-05-26 21:05:48 -07:00
Cursor AgentandDaniel Imms 88cef97049 Attach method signatures to last handler JSDoc line in Types.ts
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 04:01:00 +00:00
Cursor AgentandDaniel Imms 0e823004e6 Use single-line JSDoc comments per handler in Types.ts
Document multi-variant escape handlers with separate /** ... */ lines
instead of multiline blocks.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 03:59:42 +00:00
Cursor AgentandDaniel Imms df3422fb0b Use jsdoc/multiline-blocks instead of multiline-comment-style
Replace multiline-comment-style (starred-block), which also forces
multiline // comments into block comments, with jsdoc/multiline-blocks.
That rule only enforces starred JSDoc layout for /** blocks (no text on
the opening line) and leaves multiline // comments unchanged for
internal documentation.

Fix multiline JSDoc in Types.ts.

Fixes #5899

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-26 20:32:23 +00:00
Cursor AgentandDaniel Imms 4ec85e67ba Add multiline-comment-style lint rule and fix violations
Enable multiline-comment-style with starred-block to require JSDoc-style
block comments with content on separate lines from the opening delimiter.
Auto-fix existing violations across the codebase.

Fixes #5899

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-26 20:15:55 +00:00
Daniel Imms 1a67b6f4b3 Merge pull request #5903 from xtermjs/cursor/space-unary-ops-5898-604c
Add space-unary-ops lint rule to prevent space after !
2026-05-26 13:01:20 -07:00
Cursor AgentandDaniel Imms d195dc9a3b Add space-unary-ops lint rule and fix SixelHandler violation
Enable @stylistic/space-unary-ops to warn on spaces after unary
operators like ! in conditions. Fix existing violation in SixelHandler.

Fixes #5898

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-26 18:03:15 +00:00
Jeffrey Chen 253d1e9beb Underline entire wrapped OSC 8 link on hover 2026-04-12 20:06:43 -07:00
57 changed files with 2844 additions and 1874 deletions
-1
View File
@@ -3,7 +3,6 @@ node_modules/
.lock-wscript
lib/
out/
out-benchmark/
out-demo/
out-test/
out-esbuild/
+1 -1
View File
@@ -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 -2
View File
@@ -1,3 +1,2 @@
lib
node_modules
out-benchmark
node_modules
-9
View File
@@ -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" }
]
}
+1 -4
View File
@@ -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;
}
}
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
});
});
});
});
+392
View File
@@ -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');
});
});
});
+136
View File
@@ -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();
}
}
+106
View File
@@ -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;
}
}
+2 -40
View File
@@ -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 },
+1 -2
View File
@@ -3,7 +3,6 @@
"include": [],
"references": [
{ "path": "./src" },
{ "path": "./test" },
{ "path": "./benchmark" }
{ "path": "./test" }
]
}
+12 -14
View File
@@ -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));
});
});
});
+3
View File
@@ -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 &&
+7 -5
View File
@@ -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]);
+7 -4
View File
@@ -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]);
+2 -1
View File
@@ -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
+1
View File
@@ -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;
}
+3 -1
View File
@@ -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,
+9 -6
View File
@@ -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();
+7 -6
View File
@@ -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(',');
+10 -10
View File
@@ -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',
+2
View File
@@ -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', {
+8 -2
View File
@@ -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);
}
+40
View File
@@ -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);
});
});
+105
View File
@@ -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 }
});
});
});
+79 -13
View File
@@ -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 {
+10
View File
@@ -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
+1 -1
View File
@@ -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);
}
+4 -5
View File
@@ -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));
}
/**
-129
View File
@@ -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));
});
});
-23
View File
@@ -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;
}
+6 -7
View File
@@ -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);
-4
View File
@@ -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.
+143
View File
@@ -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(), '');
});
});
+67
View File
@@ -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();
}
}
+1 -2
View File
@@ -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
View File
@@ -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;
}
+15
View File
@@ -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);
});
});
});
+5
View File
@@ -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 -8
View File
@@ -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;
}
+7 -8
View File
@@ -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 -8
View File
@@ -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;
}
+1 -1
View File
@@ -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; }
+4 -5
View File
@@ -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();
}
+1
View File
@@ -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;
+35
View File
@@ -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', () => {
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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.