Compare commits

..
Author SHA1 Message Date
Cursor AgentandDaniel Imms 3556f16138 feat(buffer): add logical line storage for wrapped rows
Introduce BufferOverflowLine rows that share cell storage with a parent
BufferLine head, implementing the logical-line model from issue #4800.

- Extend BufferLine with logical capacity and segment-aware indexing
- Add BufferOverflowLine, BufferLineUtils helpers, and unit tests
- Expose attachWrappedRow/clearWrappedRow on Buffer for future integration
- Keep InputHandler on isWrapped flag until reflow/SL paths are updated

Reflow still uses per-row buffers; materializeLogicalLinesForReflow helpers
are included for a follow-up that simplifies resize reflow.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-28 03:42:52 +00:00
Daniel Imms c2ecfe7446 Merge pull request #5895 from xtermjs/dependabot/npm_and_yarn/multi-f792d6d6d9
Bump qs and express
2026-05-27 18:26:29 -07:00
Daniel Imms 5656443163 Merge pull request #5957 from xtermjs/cursor/fix-progress-webkit-flake-5577
fix(addon-progress): stabilize flaky WebKit integration test harness
2026-05-27 17:32:25 -07:00
Cursor AgentandDaniel Imms 38174907f3 fix(addon-progress): stabilize WebKit integration test harness
Remove unnecessary unawaited setViewportSize from ProgressAddon tests,
which was the line reported in flaky CI failures. Use serial mode and
scoped hooks per Playwright shared-page guidance, and guard afterAll
teardown when setup fails.

Fixes #5956

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-28 00:24:06 +00:00
Daniel Imms 91fcb46a1a Merge pull request #5955 from xtermjs/cursor/gitignore-cursor-logs-5a86
Ignore .cursor/*.log files in git
2026-05-27 16:25:02 -07:00
Cursor AgentandDaniel Imms 1d569f873d Ignore .cursor/*.log files in git
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 23:22:32 +00:00
dependabot[bot] e89fbf2979 Bump qs and express
Bumps [qs](https://github.com/ljharb/qs) to 6.15.2 and updates ancestor dependency [express](https://github.com/expressjs/express). These dependencies need to be updated together.


Updates `qs` from 6.14.2 to 6.15.2
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.2...v6.15.2)

Updates `express` from 4.22.1 to 4.22.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/v4.22.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/v4.22.1...v4.22.2)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 4.22.2
  dependency-type: direct:development
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 21:53:01 +00:00
Daniel Imms db244ea5a8 Merge pull request #5954 from xtermjs/cursor/align-headless-marker-alt-buffer-2866
Allow markers on alt buffer in headless terminal
2026-05-27 14:51:35 -07:00
Cursor AgentandDaniel Imms fdcd1c2720 Align xterm-headless marker JSDoc with xterm.d.ts
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 21:46:20 +00:00
Cursor AgentandDaniel Imms 57b48f6048 Allow markers on alt buffer in headless terminal
Remove the alt-buffer guard in headless registerMarker so behavior
matches the browser terminal. Update headless public API and typings
to return IMarker instead of IMarker | undefined.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 21:28:36 +00:00
Daniel Imms ec0c93fd5a Merge pull request #5950 from xtermjs/cursor/writebuffer-dispose-timeout-124c
Cancel WriteBuffer innerWrite timers on dispose
2026-05-27 14:14:41 -07:00
Daniel Imms be339bf796 Merge pull request #5947 from xtermjs/cursor/decoration-bucket-merge-loop-7132
Avoid spread when merging decoration line buckets
2026-05-27 13:15:37 -07:00
Daniel Imms 7294124736 Merge pull request #5945 from xtermjs/cursor/bufferline-stringbuilder-5939-79f8
Use StringBuilder in BufferLine.translateToString
2026-05-27 13:11:02 -07:00
Daniel Imms 2c3fad39af Merge pull request #5948 from xtermjs/cursor/hoist-get-current-link-id-e29d
Hoist _getCurrentLinkId() in print() loop
2026-05-27 13:09:09 -07:00
Cursor AgentandDaniel Imms edb4f89dd8 Add WriteBuffer regression tests for dispose and hot paths
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 20:00:06 +00:00
Cursor AgentandDaniel Imms 224c113b64 Move linkId declaration above use site in print loop
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:36:45 +00:00
Cursor AgentandDaniel Imms 3e0c7d18e5 Register WriteBuffer cleanup via toDisposable in constructor
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:35:58 +00:00
Daniel Imms 95d9224b8c Merge pull request #5946 from xtermjs/cursor/fix-duplicate-buildnumber-guard-cbb8
Fix duplicate buildNumber guard in Windows ConPTY logic
2026-05-27 12:34:08 -07:00
Cursor AgentandDaniel Imms 7499a2181d Use DisposableStore.isDisposed instead of duplicate flag
Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:34:08 +00:00
Daniel Imms 7dc820f5fa Merge pull request #5949 from xtermjs/cursor/rename-headless-register-marker-22c3
Rename headless core addMarker → registerMarker
2026-05-27 12:33:02 -07:00
Cursor AgentandDaniel Imms e59a6c1f7b Reuse shared StringBuilder in BufferLine.translateToString
Avoid per-call allocation when translating many lines in sequence by
resetting a module-level work StringBuilder before and after each use.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 19:31:00 +00:00
Daniel Imms f3e2c08a5d Merge pull request #5944 from xtermjs/cursor/buffer-reflow-single-loop-sum-705e
perf: use single loop for cellsNeeded sum in BufferReflow
2026-05-27 12:28:08 -07:00
Daniel Imms b138bf38d0 Merge pull request #5943 from xtermjs/cursor/remove-duplicate-defineproperty-57b2
Remove duplicate Object.defineProperty loop in headless Terminal
2026-05-27 12:26:36 -07:00
Cursor AgentandDaniel Imms c6e4627b45 Hoist _getCurrentLinkId() in print() loop
Avoid calling _getCurrentLinkId() twice per codepoint when a hyperlink
is active by reading the link id once before the print loop.

Fixes #5940

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:24 +00:00
Cursor AgentandDaniel Imms d91045dc5f Cancel WriteBuffer innerWrite timers on dispose
Guard _innerWrite and public write methods after disposal so pending
setTimeout continuations cannot run against a disposed terminal.

Fixes #5936

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:22 +00:00
Cursor AgentandDaniel Imms 41dfdba2cf Avoid spread when merging decoration line buckets
Replace existing.push(...bucket) with an indexed loop in
_mergeLineBucket to avoid intermediate array allocations during
buffer trim/insert/delete re-indexing.

Reuse _mergeLineBucket in _handleBufferLinesTrim for consistency.

Fixes #5941

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:13 +00:00
Cursor AgentandDaniel Imms ba33decb68 Rename headless core addMarker to registerMarker
Align headless Terminal core API with browser CoreBrowserTerminal
and the public registerMarker naming.

Fixes #5938

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:18:01 +00:00
Cursor AgentandDaniel Imms 17e3b84bcc perf: use single loop for cellsNeeded sum in BufferReflow
Replace map().reduce() with one accumulator loop in
reflowSmallerGetNewLineLengths to avoid allocating an intermediate
array when summing wrapped line trimmed lengths.

Fixes #5942

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:17:17 +00:00
Cursor AgentandDaniel Imms 182ff2f635 Remove duplicate Object.defineProperty loop in headless Terminal
The constructor defined property getters/setters twice on _publicOptions;
the first block was immediately overwritten by the second. Align with the
browser public Terminal implementation.

Fixes #5935

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:17:08 +00:00
Cursor AgentandDaniel Imms bcb4e5298b Fix duplicate buildNumber guard in Windows ConPTY logic
The windowsPty option change handler checked buildNumber twice
instead of requiring both backend and buildNumber to be defined.
This aligns with Buffer.ts and ensures ConPTY wrapping heuristics
are evaluated only when both fields are present.

Fixes #5934

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:16:54 +00:00
Cursor AgentandDaniel Imms 42facac2d9 Use StringBuilder in BufferLine.translateToString
Replace O(n²) string concatenation in the translate loop with
StringBuilder.append, matching the pattern used elsewhere in the project.

Fixes #5939

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
2026-05-27 18:16:51 +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
67 changed files with 3585 additions and 2022 deletions
+1 -1
View File
@@ -3,7 +3,6 @@ node_modules/
.lock-wscript
lib/
out/
out-benchmark/
out-demo/
out-test/
out-esbuild/
@@ -25,6 +24,7 @@ test-results/
# Tracing/debug files
.cursor/nal-trace/
.cursor/*.log
# Keep bundled code out of Git
dist/
+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);
}
@@ -9,15 +9,18 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
ctx.page.setViewportSize({ width: 1024, height: 768 });
await openTerminal(ctx);
});
test.afterAll(async () => await ctx.page.close());
test.describe('ProgressAddon', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
await openTerminal(ctx);
});
test.afterAll(async () => {
await ctx?.page?.close();
});
test.beforeEach(async function(): Promise<any> {
await ctx.page.evaluate(`
window.progressStack = [];
+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,158 @@
/**
* 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]);
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(',');
+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', {
+33 -17
View File
@@ -2594,10 +2594,11 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
@@ -2607,7 +2608,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -2622,6 +2623,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
@@ -2630,7 +2632,8 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
@@ -2712,6 +2715,7 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -2749,6 +2753,7 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -3144,6 +3149,7 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -3780,14 +3786,15 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -3806,7 +3813,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -4521,6 +4528,7 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
@@ -5222,6 +5230,7 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -5706,6 +5715,7 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -6128,9 +6138,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6166,6 +6176,7 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
@@ -6604,6 +6615,7 @@
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -6619,13 +6631,14 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -6639,6 +6652,7 @@
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -6657,6 +6671,7 @@
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -7194,6 +7209,7 @@
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
+2 -1
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());
}
+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 {
+9
View File
@@ -258,6 +258,15 @@ export class MockBuffer implements IBuffer {
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return Buffer.prototype.getBlankLine.apply(this, arguments as any);
}
public attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
return Buffer.prototype.attachWrappedRow.apply(this, arguments as any);
}
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent?: boolean): void {
Buffer.prototype.clearWrappedRow.apply(this, arguments as any);
}
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
return Buffer.prototype.createScrollWrappedLine.apply(this, arguments as any);
}
public getNullCell(attr?: IAttributeData): ICellData {
throw new Error('Method not implemented.');
}
+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;
}
+1 -1
View File
@@ -263,7 +263,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
private _handleWindowsPtyOptionChange(): void {
let value = false;
const windowsPty = this.optionsService.rawOptions.windowsPty;
if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) {
if (windowsPty && windowsPty.backend !== undefined && windowsPty.buildNumber !== undefined) {
value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376);
}
if (value) {
+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);
+41 -24
View File
@@ -11,6 +11,8 @@ import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
import { Disposable } from 'common/Lifecycle';
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { getWrappedRangeEnd } from 'common/buffer/BufferLineUtils';
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
@@ -567,8 +569,9 @@ export class InputHandler extends Disposable implements IInputHandler {
if (screenReaderMode) {
this._onA11yChar.fire(stringFromCodePoint(code));
}
if (this._getCurrentLinkId()) {
this._oscLinkService.addLineToLink(this._getCurrentLinkId(), this._activeBuffer.ybase + this._activeBuffer.y);
const linkId = this._getCurrentLinkId();
if (linkId) {
this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y);
}
// goto next line if ch would overflow
@@ -589,8 +592,7 @@ export class InputHandler extends Disposable implements IInputHandler {
if (this._activeBuffer.y >= this._bufferService.rows) {
this._activeBuffer.y = this._bufferService.rows - 1;
}
// The line already exists (eg. the initial viewport), mark it as a
// wrapped line
// The line already exists (eg. the initial viewport), mark it as a wrapped line
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = true;
}
// row changed, get it again
@@ -755,7 +757,7 @@ export class InputHandler extends Disposable implements IInputHandler {
// reprint is common, especially on resize. Note that the windowsMode wrapped line heuristics
// can mess with this so windowsMode should be disabled, which is recommended on Windows build
// 21376 and above.
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
}
// If the end of the line is hit, prevent this action from wrapping around to the next line.
if (this._activeBuffer.x >= this._bufferService.cols) {
@@ -819,7 +821,7 @@ export class InputHandler extends Disposable implements IInputHandler {
&& this._activeBuffer.y > this._activeBuffer.scrollTop
&& this._activeBuffer.y <= this._activeBuffer.scrollBottom
&& this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)?.isWrapped) {
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
this._activeBuffer.y--;
this._activeBuffer.x = this._bufferService.cols - 1;
// find last taken cell - last cell can have 3 different states:
@@ -1183,21 +1185,43 @@ export class InputHandler extends Disposable implements IInputHandler {
respectProtect
);
if (clearWrap) {
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
}
private _unwrapBufferRow(y: number, preserveContent: boolean): void {
const line = this._activeBuffer.lines.get(y);
if (!line?.isWrapped) {
return;
}
if (line instanceof BufferOverflowLine) {
if (preserveContent) {
this._activeBuffer.lines.set(y, line.materializeToStandalone(this._activeBuffer.getNullCell(this._eraseAttrData())));
} else {
this._activeBuffer.clearWrappedRow(y, this._activeBuffer.getNullCell(this._eraseAttrData()), false);
}
return;
}
line.isWrapped = false;
}
/**
* Helper method to reset cells in a terminal row. The cell gets replaced with the eraseChar of
* the terminal and the isWrapped property is set to false.
* @param y row index
*/
private _resetBufferLine(y: number, respectProtect: boolean = false): void {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y);
const absY = this._activeBuffer.ybase + y;
const nullCell = this._activeBuffer.getNullCell(this._eraseAttrData());
const last = getWrappedRangeEnd(this._activeBuffer.lines, absY);
for (let i = last; i > absY; i--) {
this._activeBuffer.clearWrappedRow(i, nullCell);
}
const line = this._activeBuffer.lines.get(absY);
if (line) {
line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect);
this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y);
line.isWrapped = false;
line.fill(nullCell, respectProtect);
this._bufferService.buffer.clearMarkers(absY);
this._activeBuffer.clearWrappedRow(absY, nullCell);
}
}
@@ -1245,10 +1269,7 @@ export class InputHandler extends Disposable implements IInputHandler {
this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, true, respectProtect);
if (this._activeBuffer.x + 1 >= this._bufferService.cols) {
// Deleted entire previous line. This next line can no longer be wrapped.
const nextLine = this._activeBuffer.lines.get(j + 1);
if (nextLine) {
nextLine.isWrapped = false;
}
this._unwrapBufferRow(this._activeBuffer.ybase + j + 1, true);
}
while (j--) {
this._resetBufferLine(j, respectProtect);
@@ -1512,7 +1533,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.deleteCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -1545,7 +1566,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.insertCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -1568,7 +1589,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.insertCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -1591,7 +1612,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
line.deleteCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
line.isWrapped = false;
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
}
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
return true;
@@ -2590,10 +2611,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.
@@ -3479,7 +3496,7 @@ export class InputHandler extends Disposable implements IInputHandler {
const line = this._activeBuffer.lines.get(row);
if (line) {
line.fill(cell);
line.isWrapped = false;
this._unwrapBufferRow(row, true);
}
}
this._dirtyRowTracker.markAllDirty();
+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;
}
+14 -1
View File
@@ -9,6 +9,7 @@ import { IdleTaskQueue } from 'common/TaskQueue';
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { attachWrappedRow, clearWrappedRow, createScrollWrappedLine } from 'common/buffer/BufferLineUtils';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
import { CellData } from 'common/buffer/CellData';
@@ -100,7 +101,19 @@ export class Buffer extends Disposable implements IBuffer {
}
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped);
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), !!isWrapped);
}
public attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
return attachWrappedRow(this.lines, y, this._stringCache, this._cols, fillCellData);
}
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent: boolean = true): void {
clearWrappedRow(this.lines, y, fillCellData, this._stringCache, this._cols, preserveContent);
}
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
return createScrollWrappedLine(lineAbove, this._stringCache, this._cols, this.getNullCell(attr), attr);
}
public get hasScrollback(): boolean {
+276 -75
View File
@@ -8,6 +8,7 @@ import { AttributeData } from 'common/buffer/AttributeData';
import { CellData } from 'common/buffer/CellData';
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { StringBuilder } from 'common/StringBuilder';
// Buffer memory layout:
//
@@ -41,6 +42,7 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
// Work variables to avoid garbage collection
let $startIndex = 0;
const $workCell = new CellData();
const $translateToStringBuilder = new StringBuilder();
export interface IBufferLineStringCacheEntry {
value: string | undefined;
@@ -74,20 +76,106 @@ export class BufferLine implements IBufferLine {
protected _combined: {[index: number]: string} = {};
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
/** When set, this row is a viewport slice of the logical line owned by this buffer line. */
protected _logicalHead: BufferLine | undefined;
protected _segmentStart: number = 0;
protected _logicalCellCount: number = 0;
protected _overflowRowCount: number = 0;
protected _fillCellData: ICellData;
public length: number;
public isWrapped: boolean = false;
constructor(
protected readonly _stringCache: IBufferLineStringCache,
cols: number,
fillCellData?: ICellData,
public isWrapped: boolean = false
isWrapped: boolean = false,
logicalHead?: BufferLine,
segmentStart?: number
) {
this._data = new Uint32Array(cols * Constants.CELL_INDICIES);
const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
this._fillCellData = cell;
if (logicalHead) {
this._logicalHead = logicalHead;
this._segmentStart = segmentStart ?? 0;
this._data = logicalHead._data;
this._combined = logicalHead._combined;
this._extendedAttrs = logicalHead._extendedAttrs;
this.length = cols;
this.isWrapped = true;
return;
}
this._data = new Uint32Array(cols * Constants.CELL_INDICIES);
this._logicalCellCount = cols;
for (let i = 0; i < cols; ++i) {
this.setCell(i, cell);
}
this.length = cols;
this.isWrapped = isWrapped;
}
public get logicalCellCount(): number {
return this._logicalHead ? this._logicalHead._logicalCellCount : this._logicalCellCount;
}
public registerOverflowRow(): void {
if (this._logicalHead) {
this._logicalHead.registerOverflowRow();
} else {
this._overflowRowCount++;
}
}
public unregisterOverflowRow(): void {
if (this._logicalHead) {
this._logicalHead.unregisterOverflowRow();
} else if (this._overflowRowCount > 0) {
this._overflowRowCount--;
}
}
public clearOverflowRows(): void {
if (!this._logicalHead) {
this._overflowRowCount = 0;
}
}
/** After reflow merges wrapped rows into the head, reset overflow tracking. */
public clearReflowMergedState(): void {
if (!this._logicalHead) {
this._overflowRowCount = 0;
}
}
public ensureLogicalCapacity(minCells: number, fillCellData: ICellData): void {
const head = this._logicalHead ?? this;
if (minCells <= head._logicalCellCount) {
return;
}
head._invalidateStringCache();
const uint32Cells = minCells * Constants.CELL_INDICIES;
if (head._data.buffer.byteLength >= uint32Cells * 4) {
head._data = new Uint32Array(head._data.buffer, 0, uint32Cells);
} else {
const data = new Uint32Array(uint32Cells);
data.set(head._data);
head._data = data;
}
for (let i = head._logicalCellCount; i < minCells; ++i) {
const start = i * Constants.CELL_INDICIES;
head._data[start + Cell.CONTENT] = fillCellData.content;
head._data[start + Cell.FG] = fillCellData.fg;
head._data[start + Cell.BG] = fillCellData.bg;
}
head._logicalCellCount = minCells;
}
protected _absoluteColumn(column: number): number {
return this._segmentStart + column;
}
protected _cellStartIndex(column: number): number {
return this._absoluteColumn(column) * Constants.CELL_INDICIES;
}
/**
@@ -95,16 +183,18 @@ export class BufferLine implements IBufferLine {
* @deprecated
*/
public get(index: number): CharData {
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
const content = this._data[start + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
return [
this._data[index * Constants.CELL_INDICIES + Cell.FG],
this._data[start + Cell.FG],
(content & Content.IS_COMBINED_MASK)
? this._combined[index]
? this._combined[abs]
: (cp) ? stringFromCodePoint(cp) : '',
content >> Content.WIDTH_SHIFT,
(content & Content.IS_COMBINED_MASK)
? this._combined[index].charCodeAt(this._combined[index].length - 1)
? this._combined[abs].charCodeAt(this._combined[abs].length - 1)
: cp
];
}
@@ -115,12 +205,14 @@ export class BufferLine implements IBufferLine {
*/
public set(index: number, value: CharData): void {
this._invalidateStringCache();
this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
this._data[start + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
this._combined[index] = value[1];
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
this._combined[abs] = value[1];
this._data[start + Cell.CONTENT] = abs | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
} else {
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
this._data[start + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
}
}
@@ -129,22 +221,22 @@ export class BufferLine implements IBufferLine {
* use these when only one value is needed, otherwise use `loadCell`
*/
public getWidth(index: number): number {
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] >> Content.WIDTH_SHIFT;
}
/** Test whether content has width. */
public hasWidth(index: number): number {
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.WIDTH_MASK;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.WIDTH_MASK;
}
/** Get FG cell component. */
public getFg(index: number): number {
return this._data[index * Constants.CELL_INDICIES + Cell.FG];
return this._data[this._cellStartIndex(index) + Cell.FG];
}
/** Get BG cell component. */
public getBg(index: number): number {
return this._data[index * Constants.CELL_INDICIES + Cell.BG];
return this._data[this._cellStartIndex(index) + Cell.BG];
}
/**
@@ -153,7 +245,7 @@ export class BufferLine implements IBufferLine {
* from real empty cells.
*/
public hasContent(index: number): number {
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
}
/**
@@ -162,23 +254,25 @@ export class BufferLine implements IBufferLine {
* a single UTF32 codepoint or the last codepoint of a combined string.
*/
public getCodePoint(index: number): number {
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
const abs = this._absoluteColumn(index);
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index].charCodeAt(this._combined[index].length - 1);
return this._combined[abs].charCodeAt(this._combined[abs].length - 1);
}
return content & Content.CODEPOINT_MASK;
}
/** Test whether the cell contains a combined string. */
public isCombined(index: number): number {
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK;
return this._data[this._cellStartIndex(index) + Cell.CONTENT] & Content.IS_COMBINED_MASK;
}
/** Returns the string content of the cell. */
public getString(index: number): string {
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
const abs = this._absoluteColumn(index);
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index];
return this._combined[abs];
}
if (content & Content.CODEPOINT_MASK) {
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
@@ -189,7 +283,7 @@ export class BufferLine implements IBufferLine {
/** Get state of protected flag. */
public isProtected(index: number): number {
return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED;
return this._data[this._cellStartIndex(index) + Cell.BG] & BgFlags.PROTECTED;
}
/**
@@ -197,15 +291,16 @@ export class BufferLine implements IBufferLine {
* to GC as it significantly reduced the amount of new objects/references needed.
*/
public loadCell(index: number, cell: ICellData): ICellData {
$startIndex = index * Constants.CELL_INDICIES;
const abs = this._absoluteColumn(index);
$startIndex = this._cellStartIndex(index);
cell.content = this._data[$startIndex + Cell.CONTENT];
cell.fg = this._data[$startIndex + Cell.FG];
cell.bg = this._data[$startIndex + Cell.BG];
if (cell.content & Content.IS_COMBINED_MASK) {
cell.combinedData = this._combined[index];
cell.combinedData = this._combined[abs];
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
cell.extended = this._extendedAttrs[index]!;
cell.extended = this._extendedAttrs[abs]!;
}
return cell;
}
@@ -215,15 +310,17 @@ export class BufferLine implements IBufferLine {
*/
public setCell(index: number, cell: ICellData): void {
this._invalidateStringCache();
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
if (cell.content & Content.IS_COMBINED_MASK) {
this._combined[index] = cell.combinedData;
this._combined[abs] = cell.combinedData;
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = cell.extended;
this._extendedAttrs[abs] = cell.extended;
}
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = cell.content;
this._data[index * Constants.CELL_INDICIES + Cell.FG] = cell.fg;
this._data[index * Constants.CELL_INDICIES + Cell.BG] = cell.bg;
this._data[start + Cell.CONTENT] = cell.content;
this._data[start + Cell.FG] = cell.fg;
this._data[start + Cell.BG] = cell.bg;
}
/**
@@ -233,12 +330,14 @@ export class BufferLine implements IBufferLine {
*/
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
this._invalidateStringCache();
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = attrs.extended;
this._extendedAttrs[abs] = attrs.extended;
}
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg;
this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg;
this._data[start + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[start + Cell.FG] = attrs.fg;
this._data[start + Cell.BG] = attrs.bg;
}
/**
@@ -249,16 +348,18 @@ export class BufferLine implements IBufferLine {
*/
public addCodepointToCell(index: number, codePoint: number, width: number): void {
this._invalidateStringCache();
let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
const abs = this._absoluteColumn(index);
const start = this._cellStartIndex(index);
let content = this._data[start + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
// we already have a combined string, simply add
this._combined[index] += stringFromCodePoint(codePoint);
this._combined[abs] += stringFromCodePoint(codePoint);
} else {
if (content & Content.CODEPOINT_MASK) {
// normal case for combining chars:
// - move current leading char + new one into combined string
// - set combined flag
this._combined[index] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
this._combined[abs] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0
content |= Content.IS_COMBINED_MASK;
} else {
@@ -271,7 +372,7 @@ export class BufferLine implements IBufferLine {
content &= ~Content.WIDTH_MASK;
content |= width << Content.WIDTH_SHIFT;
}
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = content;
this._data[start + Cell.CONTENT] = content;
}
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
@@ -371,20 +472,28 @@ export class BufferLine implements IBufferLine {
*/
public resize(cols: number, fillCellData: ICellData): boolean {
this._invalidateStringCache();
if (this._logicalHead) {
if (cols > this.length) {
this._logicalHead.ensureLogicalCapacity(this._segmentStart + cols, fillCellData);
}
this.length = cols;
return false;
}
if (this._overflowRowCount > 0) {
if (cols > this.length) {
this.ensureLogicalCapacity(Math.max(this._logicalCellCount, this._segmentStart + cols), fillCellData);
}
this.length = cols;
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
if (cols === this.length) {
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
const uint32Cells = cols * Constants.CELL_INDICIES;
if (cols > this.length) {
if (this._data.buffer.byteLength >= uint32Cells * 4) {
// optimization: avoid alloc and data copy if buffer has enough room
this._data = new Uint32Array(this._data.buffer, 0, uint32Cells);
} else {
// slow path: new alloc and full data copy
const data = new Uint32Array(uint32Cells);
data.set(this._data);
this._data = data;
}
const data = new Uint32Array(uint32Cells);
data.set(this._data.subarray(0, this.length * Constants.CELL_INDICIES));
this._data = data;
for (let i = this.length; i < cols; ++i) {
this.setCell(i, fillCellData);
}
@@ -409,6 +518,7 @@ export class BufferLine implements IBufferLine {
}
}
this.length = cols;
this._logicalCellCount = cols;
return uint32Cells * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
@@ -419,10 +529,12 @@ export class BufferLine implements IBufferLine {
* Returns 0 or 1 indicating whether a cleanup happened.
*/
public cleanupMemory(): number {
if (this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
const data = new Uint32Array(this._data.length);
data.set(this._data);
this._data = data;
const head = this._logicalHead ?? this;
const usedCells = head._logicalCellCount * Constants.CELL_INDICIES;
if (usedCells * 4 * Constants.CLEANUP_THRESHOLD < head._data.buffer.byteLength) {
const data = new Uint32Array(usedCells);
data.set(head._data.subarray(0, usedCells));
head._data = data;
return 1;
}
return 0;
@@ -440,8 +552,10 @@ export class BufferLine implements IBufferLine {
}
return;
}
this._combined = {};
this._extendedAttrs = {};
if (!this._logicalHead) {
this._combined = {};
this._extendedAttrs = {};
}
for (let i = 0; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
@@ -449,30 +563,72 @@ export class BufferLine implements IBufferLine {
/** alter to a full copy of line */
public copyFrom(line: BufferLine): void {
if (this._logicalHead) {
this.materializeFromLogicalHead(this._fillCellData);
}
this._invalidateStringCache();
this._logicalHead = undefined;
this._segmentStart = 0;
this._overflowRowCount = 0;
if (line._logicalHead) {
const standalone = new BufferLine(this._stringCache, line.length, this._fillCellData, false);
standalone.copyCellsFrom(line, 0, 0, line.length, false);
line = standalone;
}
if (this.length !== line.length) {
this._data = new Uint32Array(line._data);
this._data = new Uint32Array(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
} else {
// use high speed copy if lengths are equal
this._data.set(line._data);
this._data.set(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
}
this.length = line.length;
this._logicalCellCount = line._logicalCellCount || line.length;
this._combined = {};
for (const el in line._combined) {
this._combined[el] = line._combined[el];
const key = parseInt(el, 10);
if (key < this._logicalCellCount) {
this._combined[key] = line._combined[el];
}
}
this._extendedAttrs = {};
for (const el in line._extendedAttrs) {
this._extendedAttrs[el] = line._extendedAttrs[el];
const key = parseInt(el, 10);
if (key < this._logicalCellCount) {
this._extendedAttrs[key] = line._extendedAttrs[el];
}
}
this.isWrapped = line.isWrapped;
}
public materializeFromLogicalHead(fillCellData: ICellData): BufferLine {
if (!this._logicalHead) {
return this;
}
const standalone = new BufferLine(this._stringCache, this.length, fillCellData, false);
standalone.copyCellsFrom(this._logicalHead, this._segmentStart, 0, this.length, false);
this._logicalHead.unregisterOverflowRow();
this._data = standalone._data;
this._combined = standalone._combined;
this._extendedAttrs = standalone._extendedAttrs;
this._logicalHead = undefined;
this._segmentStart = 0;
this._logicalCellCount = this.length;
this.isWrapped = false;
return this;
}
/** create a new clone */
public clone(): IBufferLine {
if (this._logicalHead) {
const newLine = new BufferLine(this._stringCache, 0, undefined, true, this._logicalHead, this._segmentStart);
newLine.length = this.length;
return newLine;
}
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
newLine._data = new Uint32Array(this._data);
newLine._data = new Uint32Array(this._data.subarray(0, this._logicalCellCount * Constants.CELL_INDICIES));
newLine.length = this.length;
newLine._logicalCellCount = this._logicalCellCount;
newLine._overflowRowCount = this._overflowRowCount;
for (const el in this._combined) {
newLine._combined[el] = this._combined[el];
}
@@ -485,8 +641,34 @@ export class BufferLine implements IBufferLine {
public getTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT);
const start = this._cellStartIndex(i);
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (this._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
/** Creates a standalone line containing the full logical line content. */
public toStandaloneLogicalLine(cols: number, fillCellData: ICellData, stringCache: IBufferLineStringCache): BufferLine {
const head = this._logicalHead ?? this;
const trimmed = head.getLogicalTrimmedLength();
const flat = new BufferLine(stringCache, cols, fillCellData, false);
if (trimmed > 0) {
flat.copyCellsFrom(head, 0, 0, trimmed, false);
flat._logicalCellCount = trimmed;
}
head.clearReflowMergedState();
return flat;
}
/** Trimmed length across the entire logical line (all segments). */
public getLogicalTrimmedLength(): number {
const head = this._logicalHead ?? this;
for (let i = head._logicalCellCount - 1; i >= 0; --i) {
const start = i * Constants.CELL_INDICIES;
if ((head._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (head._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
@@ -494,8 +676,9 @@ export class BufferLine implements IBufferLine {
public getNoBgTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * Constants.CELL_INDICIES + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT);
const start = this._cellStartIndex(i);
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[start + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
@@ -504,22 +687,32 @@ export class BufferLine implements IBufferLine {
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
this._invalidateStringCache();
const srcData = src._data;
const srcOffset = src._segmentStart;
const destOffset = this._segmentStart;
if (applyInReverse) {
for (let cell = length - 1; cell >= 0; cell--) {
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i];
this._data[destStart + i] = srcData[srcStart + i];
}
if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
const absSrc = srcOffset + srcCol + cell;
const absDest = destOffset + destCol + cell;
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
}
}
} else {
for (let cell = 0; cell < length; cell++) {
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i];
this._data[destStart + i] = srcData[srcStart + i];
}
if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
const absSrc = srcOffset + srcCol + cell;
const absDest = destOffset + destCol + cell;
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
}
}
}
@@ -528,8 +721,9 @@ export class BufferLine implements IBufferLine {
const srcCombinedKeys = Object.keys(src._combined);
for (let i = 0; i < srcCombinedKeys.length; i++) {
const key = parseInt(srcCombinedKeys[i], 10);
if (key >= srcCol) {
this._combined[key - srcCol + destCol] = src._combined[key];
const srcAbsCol = srcOffset + srcCol;
if (key >= srcAbsCol && key < srcAbsCol + length) {
this._combined[key - srcCol + destOffset + destCol] = src._combined[key];
}
}
}
@@ -570,12 +764,16 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.length = 0;
}
let result = '';
$translateToStringBuilder.reset();
while (startCol < endCol) {
const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT];
const abs = this._absoluteColumn(startCol);
const start = this._cellStartIndex(startCol);
const content = this._data[start + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
result += chars;
const chars = (content & Content.IS_COMBINED_MASK)
? (this._combined[abs] ?? '')
: (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
$translateToStringBuilder.append(chars);
if (outColumns) {
for (let i = 0; i < chars.length; ++i) {
outColumns.push(startCol);
@@ -586,6 +784,8 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.push(startCol);
}
const result = $translateToStringBuilder.toString();
$translateToStringBuilder.reset();
if (isCanonicalRequest) {
const cacheEntry = this._getStringCacheEntry(true)!;
cacheEntry.value = result;
@@ -609,8 +809,9 @@ export class BufferLine implements IBufferLine {
return cacheEntry;
}
private _invalidateStringCache(): void {
const cacheEntry = this._getStringCacheEntry(false);
protected _invalidateStringCache(): void {
const head = this._logicalHead ?? this;
const cacheEntry = head._getStringCacheEntry(false);
if (cacheEntry) {
cacheEntry.value = undefined;
cacheEntry.isTrimmed = false;
+171
View File
@@ -0,0 +1,171 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
import { IAttributeData, IBufferLine, ICellData } from 'common/Types';
import { IBufferLineStringCache } from 'common/buffer/BufferLine';
export function getLogicalHead(line: IBufferLine): BufferLine {
if (line instanceof BufferOverflowLine) {
return line.head;
}
return line as BufferLine;
}
export function isBufferOverflowLine(line: IBufferLine): line is BufferOverflowLine {
return line instanceof BufferOverflowLine;
}
/**
* Returns the logical cell offset for the start of a buffer row.
*/
export function getSegmentStart(line: IBufferLine): number {
if (line instanceof BufferOverflowLine) {
return line.segmentStart;
}
return 0;
}
/**
* Marks the row at `y` as a wrapped continuation of the logical line above.
* Extends the logical line storage and replaces the row with a {@link BufferOverflowLine}.
*/
export function attachWrappedRow(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
y: number,
stringCache: IBufferLineStringCache,
cols: number,
fillCellData: ICellData
): IBufferLine {
const headY = y > 0 ? getWrappedRangeStart(lines, y - 1) : y;
const head = getLogicalHead(lines.get(headY)!);
const segmentStart = head.logicalCellCount;
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
const existingLine = lines.get(y);
if (existingLine && !(existingLine instanceof BufferOverflowLine) && existingLine.getTrimmedLength() > 0) {
head.copyCellsFrom(existingLine as BufferLine, 0, segmentStart, existingLine.length, false);
}
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
lines.set(y, overflowLine);
head.registerOverflowRow();
return overflowLine;
}
/**
* Clears the wrapped state for a row, detaching overflow rows from their logical line.
*/
export function clearWrappedRow(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
y: number,
fillCellData: ICellData,
stringCache?: IBufferLineStringCache,
cols?: number,
preserveContent: boolean = true
): void {
const line = lines.get(y);
if (!line) {
return;
}
if (line.isWrapped) {
if (line instanceof BufferOverflowLine) {
if (preserveContent) {
lines.set(y, line.materializeToStandalone(fillCellData));
} else if (stringCache !== undefined && cols !== undefined) {
line.head.unregisterOverflowRow();
lines.set(y, new BufferLine(stringCache, cols, fillCellData, false));
} else {
lines.set(y, line.materializeToStandalone(fillCellData));
}
} else if (line instanceof BufferLine) {
(line as BufferLine).materializeFromLogicalHead(fillCellData);
}
return;
}
const head = line as BufferLine;
head.clearOverflowRows();
}
export function getWrappedRangeStart(lines: { get(index: number): IBufferLine | undefined }, y: number): number {
let first = y;
while (first > 0 && lines.get(first)?.isWrapped) {
first--;
}
return first;
}
export function getWrappedRangeEnd(lines: { get(index: number): IBufferLine | undefined; length: number }, y: number): number {
let last = y;
while (last + 1 < lines.length && lines.get(last + 1)?.isWrapped) {
last++;
}
return last;
}
/**
* Copies a logical line group into a single standalone buffer row and removes overflow rows.
* Used before reflow so existing reflow algorithms can operate on independent lines.
*/
export function flattenLogicalGroup(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
headY: number,
cols: number,
fillCellData: ICellData,
stringCache: IBufferLineStringCache
): void {
const last = getWrappedRangeEnd(lines, headY);
if (last <= headY) {
return;
}
const head = getLogicalHead(lines.get(headY)!);
lines.set(headY, head.toStandaloneLogicalLine(cols, fillCellData, stringCache));
for (let i = headY + 1; i <= last; i++) {
const line = lines.get(i);
if (line instanceof BufferOverflowLine) {
const standalone = line.materializeToStandalone(fillCellData);
standalone.isWrapped = true;
lines.set(i, standalone);
}
}
}
export function materializeLogicalLinesForReflow(
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
cols: number,
fillCellData: ICellData,
stringCache: IBufferLineStringCache
): void {
let y = 0;
while (y < lines.length) {
const line = lines.get(y);
const next = lines.get(y + 1);
if (line && next instanceof BufferOverflowLine && next.head === getLogicalHead(line)) {
flattenLogicalGroup(lines, y, cols, fillCellData, stringCache);
y++;
continue;
}
y++;
}
}
export function createScrollWrappedLine(
lineAbove: IBufferLine | undefined,
stringCache: IBufferLineStringCache,
cols: number,
fillCellData: ICellData,
attr: IAttributeData
): IBufferLine {
if (!lineAbove) {
return new BufferLine(stringCache, cols, fillCellData, true);
}
const head = getLogicalHead(lineAbove);
const segmentStart = head.logicalCellCount;
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
head.registerOverflowRow();
return overflowLine;
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { attachWrappedRow } from 'common/buffer/BufferLineUtils';
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH } from 'common/buffer/Constants';
import { IBufferLine } from 'common/Types';
const TEST_STRING_CACHE = new BufferLineStringCache();
const NULL_CELL = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
class LineList {
private _lines: IBufferLine[] = [];
public get(index: number): IBufferLine | undefined { return this._lines[index]; }
public set(index: number, line: IBufferLine): void { this._lines[index] = line; }
public get length(): number { return this._lines.length; }
}
describe('BufferLogicalLine', () => {
it('should share storage across wrapped rows', () => {
const cols = 5;
const lines = new LineList();
const head = new BufferLine(TEST_STRING_CACHE, cols, NULL_CELL, false);
lines.set(0, head);
for (let i = 0; i < cols; i++) {
head.setCellFromCodepoint(i, 'a'.charCodeAt(0) + i, 1, NULL_CELL);
}
attachWrappedRow(lines, 1, TEST_STRING_CACHE, cols, NULL_CELL);
const overflow = lines.get(1)!;
assert.instanceOf(overflow, BufferOverflowLine);
for (let i = 0; i < cols; i++) {
(overflow as BufferLine).setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
}
assert.equal(head.translateToString(true), 'abcde');
assert.equal(overflow.translateToString(true), 'fghij');
assert.equal(head.getLogicalTrimmedLength(), 10);
});
it('should reflow unwrap without copying when logical content fits', () => {
const lines = new LineList();
const head = new BufferLine(TEST_STRING_CACHE, 5, NULL_CELL, false);
lines.set(0, head);
for (let i = 0; i < 5; i++) {
head.setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
}
attachWrappedRow(lines, 1, TEST_STRING_CACHE, 2, NULL_CELL);
const overflow = lines.get(1)! as BufferOverflowLine;
for (let i = 0; i < 2; i++) {
overflow.setCellFromCodepoint(i, 'k'.charCodeAt(0) + i, 1, NULL_CELL);
}
assert.equal(head.getLogicalTrimmedLength(), 7);
assert.equal(overflow.translateToString(true), 'kl');
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICellData } from 'common/Types';
import { BufferLine, IBufferLineStringCache } from 'common/buffer/BufferLine';
/**
* A viewport row that is a view into a slice of a parent {@link BufferLine}'s logical storage.
*/
export class BufferOverflowLine extends BufferLine {
public readonly head: BufferLine;
public readonly segmentStart: number;
constructor(
head: BufferLine,
segmentStart: number,
stringCache: IBufferLineStringCache,
cols: number,
fillCellData: ICellData
) {
super(stringCache, cols, fillCellData, true, head, segmentStart);
this.head = head;
this.segmentStart = segmentStart;
}
/**
* Copies this row's segment into a standalone {@link BufferLine}.
*/
public materializeToStandalone(fillCellData: ICellData): BufferLine {
return this.materializeFromLogicalHead(fillCellData);
}
}
+4 -1
View File
@@ -178,7 +178,10 @@ export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, new
*/
export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] {
const newLineLengths: number[] = [];
const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c);
let cellsNeeded = 0;
for (let i = 0; i < wrappedLines.length; i++) {
cellsNeeded += getWrappedLineTrimmedLength(wrappedLines, i, oldCols);
}
// Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and
// linesNeeded
+3
View File
@@ -34,6 +34,9 @@ export interface IBuffer {
nextStop(x?: number): number;
prevStop(x?: number): number;
getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine;
attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine;
clearWrappedRow(y: number, fillCellData: ICellData, preserveContent?: boolean): void;
createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine;
getNullCell(attr?: IAttributeData): ICellData;
getWhitespaceCell(attr?: IAttributeData): ICellData;
addMarker(y: number): IMarker;
+81
View File
@@ -125,5 +125,86 @@ 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);
});
it('dispose cancels scheduled innerWrite', done => {
wb.write('a');
wb.dispose();
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('dispose does not fire onWriteParsed for pending writes', done => {
let parsed = 0;
wb.onWriteParsed(() => parsed++);
wb.write('a');
wb.dispose();
setTimeout(() => {
assert.equal(parsed, 0);
done();
}, 20);
});
it('write after dispose is a no-op', done => {
wb.dispose();
wb.write('a');
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('dispose is idempotent', done => {
wb.write('a');
wb.dispose();
wb.dispose();
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('async handler continuation is skipped after dispose', done => {
let resolve!: (value: boolean) => void;
const pending = new Promise<boolean>(r => { resolve = r; });
wb = new WriteBuffer(() => pending);
wb.write('a');
wb.dispose();
resolve(true);
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('handleUserInput still processes first chunk synchronously', () => {
wb.handleUserInput();
wb.write('a');
assert.deepEqual(stack, ['a']);
});
it('flushSync after dispose is a no-op', done => {
wb.write('a');
wb.dispose();
wb.flushSync();
setTimeout(() => {
assert.deepEqual(stack, []);
done();
}, 20);
});
it('writeSync after dispose is a no-op', () => {
wb.dispose();
wb.writeSync('a');
assert.deepEqual(stack, []);
});
});
});
+45 -8
View File
@@ -4,11 +4,10 @@
* @license MIT
*/
import { Disposable } from 'common/Lifecycle';
import { TimeoutTimer } from 'common/Async';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { Emitter } from 'common/Event';
declare const setTimeout: (handler: () => void, timeout?: number) => void;
const enum Constants {
/**
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
@@ -43,11 +42,18 @@ export class WriteBuffer extends Disposable {
private _syncCalls = 0;
private _didUserInput = false;
private readonly _innerWriteTimer = this._register(new TimeoutTimer());
private readonly _onWriteParsed = this._register(new Emitter<void>());
public readonly onWriteParsed = this._onWriteParsed.event;
constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise<boolean>) {
super();
this._register(toDisposable(() => {
this._writeBuffer.length = 0;
this._callbacks.length = 0;
this._pendingData = 0;
this._bufferOffset = 0;
}));
}
public handleUserInput(): void {
@@ -63,6 +69,9 @@ export class WriteBuffer extends Disposable {
* promises to resolve.
*/
public flushSync(): void {
if (this._store.isDisposed) {
return;
}
// exit early if another sync write loop is active
if (this._isSyncWriting) {
return;
@@ -71,7 +80,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,12 +95,18 @@ export class WriteBuffer extends Disposable {
this._callbacks.length = 0;
this._isSyncWriting = false;
if (didProcess) {
this._onWriteParsed.fire();
}
}
/**
* @deprecated Unreliable, to be removed soon.
*/
public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void {
if (this._store.isDisposed) {
return;
}
// stop writeSync recursions with maxSubsequentCalls argument
// This is dangerous to use as it will lose the current data chunk
// and return immediately.
@@ -133,6 +150,9 @@ export class WriteBuffer extends Disposable {
}
public write(data: string | Uint8Array, callback?: () => void): void {
if (this._store.isDisposed) {
return;
}
if (this._pendingData > Constants.DISCARD_WATERMARK) {
throw new Error('write data discarded, use flow control to avoid losing data');
}
@@ -153,7 +173,7 @@ export class WriteBuffer extends Disposable {
return;
}
setTimeout(() => this._innerWrite());
this._scheduleInnerWrite();
}
this._pendingData += data.length;
@@ -189,7 +209,17 @@ export class WriteBuffer extends Disposable {
*
* Note, for pure sync code `lastTime` and `promiseResult` have no meaning.
*/
private _scheduleInnerWrite(lastTime: number = 0, promiseResult: boolean = true): void {
if (this._store.isDisposed) {
return;
}
this._innerWriteTimer.cancelAndSet(() => this._innerWrite(lastTime, promiseResult), 0);
}
protected _innerWrite(lastTime: number = 0, promiseResult: boolean = true): void {
if (this._store.isDisposed) {
return;
}
const startTime = lastTime || performance.now();
while (this._writeBuffer.length > this._bufferOffset) {
const data = this._writeBuffer[this._bufferOffset];
@@ -218,9 +248,16 @@ export class WriteBuffer extends Disposable {
* responsibility to slice hard work), but we can at least schedule a screen update as we
* gain control.
*/
const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS
? setTimeout(() => this._innerWrite(0, r))
: this._innerWrite(startTime, r);
const continuation: (r: boolean) => void = (r: boolean) => {
if (this._store.isDisposed) {
return;
}
if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) {
this._scheduleInnerWrite(0, r);
} else {
this._innerWrite(startTime, r);
}
};
/**
* Optimization considerations:
@@ -267,7 +304,7 @@ export class WriteBuffer extends Disposable {
this._callbacks = this._callbacks.slice(this._bufferOffset);
this._bufferOffset = 0;
}
setTimeout(() => this._innerWrite());
this._scheduleInnerWrite();
} else {
this._writeBuffer.length = 0;
this._callbacks.length = 0;
+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; }
+5 -5
View File
@@ -69,17 +69,17 @@ export class BufferService extends Disposable implements IBufferService {
public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void {
const buffer = this.buffer;
let newLine: IBufferLine | undefined;
newLine = this._cachedBlankLine;
const topRow = buffer.ybase + buffer.scrollTop;
const bottomRow = buffer.ybase + buffer.scrollBottom;
let newLine: IBufferLine;
newLine = this._cachedBlankLine!;
if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) {
newLine = buffer.getBlankLine(eraseAttr, isWrapped);
this._cachedBlankLine = newLine;
}
newLine.isWrapped = isWrapped;
const topRow = buffer.ybase + buffer.scrollTop;
const bottomRow = buffer.ybase + buffer.scrollBottom;
if (buffer.scrollTop === 0) {
// Determine whether the buffer is going to be trimmed after insertion.
const willBufferBeTrimmed = buffer.lines.isFull;
+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();
}
+4 -7
View File
@@ -225,12 +225,7 @@ export class DecorationLineCache extends Disposable {
if (newLine < 0) {
continue;
}
const existing = newMap.get(newLine);
if (existing) {
existing.push(...bucket);
} else {
newMap.set(newLine, bucket.slice());
}
this._mergeLineBucket(newMap, newLine, bucket);
}
this._decorationsByLine.clear();
for (const [line, bucket] of newMap) {
@@ -254,7 +249,9 @@ export class DecorationLineCache extends Disposable {
private _mergeLineBucket(newMap: Map<number, IInternalDecoration[]>, line: number, bucket: IInternalDecoration[]): void {
const existing = newMap.get(line);
if (existing) {
existing.push(...bucket);
for (let i = 0, len = bucket.length; i < len; i++) {
existing.push(bucket[i]);
}
} else {
newMap.set(line, bucket.slice());
}
+2 -6
View File
@@ -69,12 +69,7 @@ export class Terminal extends CoreTerminal {
return this.buffer.markers;
}
public addMarker(cursorYOffset: number): IMarker | undefined {
// Disallow markers on the alt buffer
if (this.buffer !== this.buffers.normal) {
return;
}
public registerMarker(cursorYOffset: number): IMarker {
return this.buffer.addMarker(this.buffer.ybase + this.buffer.y + cursorYOffset);
}
@@ -108,6 +103,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;
+32
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,15 @@ describe('Headless API Tests', function (): void {
strictEqual(term.buffer.normal.getLine(0)!.translateToString(), 'norm ');
strictEqual(term.buffer.alternate.getLine(0), undefined);
});
it('registerMarker on alternate buffer', async () => {
term = new Terminal({ cols: 5, allowProposedApi: true });
await writeSync('\x1b[?47h');
const marker = term.registerMarker(0);
strictEqual(term.buffer.active.type, 'alternate');
strictEqual(term.markers.length, 1);
strictEqual(term.markers[0], marker);
});
});
describe('modes', () => {
+3 -12
View File
@@ -40,15 +40,6 @@ export class Terminal extends Disposable implements ITerminalApi {
};
for (const propName in this._core.options) {
Object.defineProperty(this._publicOptions, propName, {
get: () => {
return this._core.options[propName];
},
set: (value: any) => {
this._checkReadonlyOptions(propName);
this._core.options[propName] = value;
}
});
const desc = {
get: getter.bind(this, propName),
set: setter.bind(this, propName)
@@ -139,11 +130,11 @@ export class Terminal extends Disposable implements ITerminalApi {
this._verifyIntegers(columns, rows);
this._core.resize(columns, rows);
}
public registerMarker(cursorYOffset: number = 0): IMarker | undefined {
public registerMarker(cursorYOffset: number = 0): IMarker {
this._verifyIntegers(cursorYOffset);
return this._core.addMarker(cursorYOffset);
return this._core.registerMarker(cursorYOffset);
}
public addMarker(cursorYOffset: number): IMarker | undefined {
public addMarker(cursorYOffset: number): IMarker {
return this.registerMarker(cursorYOffset);
}
public dispose(): void {
+2 -3
View File
@@ -825,12 +825,11 @@ declare module '@xterm/headless' {
resize(columns: number, rows: number): void;
/**
* Adds a marker to the normal buffer and returns it. If the alt buffer is
* active, undefined is returned.
* Adds a marker to the normal buffer and returns it.
* @param cursorYOffset The y position offset of the marker from the cursor.
* @returns The new marker or undefined.
*/
registerMarker(cursorYOffset?: number): IMarker | undefined;
registerMarker(cursorYOffset?: number): IMarker;
/*
* Disposes of the terminal, detaching it from the DOM and removing any