From 69ee4ad23b10bdf8e3fe71a1644518f469ed0820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Mon, 13 Apr 2026 12:51:17 +0200 Subject: [PATCH 1/9] fix: use element window instead of global object --- src/browser/services/MouseCoordsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/services/MouseCoordsService.ts b/src/browser/services/MouseCoordsService.ts index e2455fd2b1..60f0a6ab95 100644 --- a/src/browser/services/MouseCoordsService.ts +++ b/src/browser/services/MouseCoordsService.ts @@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService { public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined { return getCoords( - window, + getWindow(element), event, element, colCount, From ff72c00219718c14e6aabb086b49746c7077256b Mon Sep 17 00:00:00 2001 From: lawrence3699 Date: Wed, 22 Apr 2026 12:57:27 +1000 Subject: [PATCH 2/9] fix(addon-ligatures): run package build on publish --- addons/addon-ligatures/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index 5fb0c4f664..cd70ada2b4 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -20,7 +20,7 @@ "package": "webpack", "pretest": "npm run build", "test": "nyc mocha out/**/*.test.js", - "prepublish": "npm run package" + "prepublishOnly": "npm run package" }, "keywords": [ "font", From 685434ca847cfca6152604b8be878f3e3a749ef5 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 11:35:56 -0400 Subject: [PATCH 3/9] Fix IntersectionObserver retention on dispose --- src/browser/services/RenderService.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 83e3d33796..e999b7eac2 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderDebouncer: IRenderDebouncerWithCallback; private _pausedResizeTask: DebouncedIdleTask; private _observerDisposable = this._register(new MutableDisposable()); + private _intersectionObserver: IntersectionObserver | undefined; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; @@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService { // and resume based on terminal visibility if so if ('IntersectionObserver' in w) { const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 }); + this._observerDisposable.value = toDisposable(() => { + this._intersectionObserver?.disconnect(); + this._intersectionObserver = undefined; + }); + this._intersectionObserver = observer; observer.observe(screenElement); - this._observerDisposable.value = toDisposable(() => observer.disconnect()); } } From 41d9c4080f759e95139aa3094f2dcae7f7f797c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 10:06:23 +0000 Subject: [PATCH 4/9] Bump fast-uri from 3.1.0 to 3.1.2 Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0ac4e7289..9f35e360c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3945,9 +3945,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -3958,7 +3958,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", From 17e7dafa17af28b1e0aaf9ce789db287546d19a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 10:06:53 +0000 Subject: [PATCH 5/9] Bump axios from 1.15.0 to 1.15.2 Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- addons/addon-ligatures/package.json | 2 +- package-lock.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index fc7a433de8..0e06add18d 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" diff --git a/package-lock.json b/package-lock.json index e0ac4e7289..a8072f3f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,7 +89,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" @@ -2578,9 +2578,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "dev": true, "license": "MIT", "dependencies": { From f484584b706d2a91363f17e98c61f07d713f1c34 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 26 May 2026 09:30:25 -0700 Subject: [PATCH 6/9] Speed up decoration cell lookup with a per-line index Refs #5176. forEachDecorationAtCell and getDecorationsAtCell previously scanned every registered decoration on each call. With thousands of search highlights this becomes O(total decorations) per cell and blocks rendering during viewport refreshes. Maintain a Map of buffer line -> decorations that cover that line. Multi-line decorations are indexed on every line in their span, so mixed single-line and multi-line workloads stay fast without falling back to a full scan. Keep the index aligned with buffer mutations: - Register and unregister decorations in the line buckets on add/dispose. - On trim, shift line keys synchronously (before marker handlers run). - On insert/delete, re-index affected decorations in a microtask after marker line updates are applied. Inject IBufferService so line listeners follow the active buffer. Add unit tests for trim, dispose-on-trim, insert, and dense single-line plus multi-line lookup. Add DecorationService.benchmark.ts to measure sparse hits and viewport-sized grid scans with 20k decorations. Co-authored-by: Cursor --- src/common/services/DecorationService.test.ts | 105 +++++++++- src/common/services/DecorationService.ts | 185 ++++++++++++++++-- src/common/services/Services.ts | 2 + test/benchmark/DecorationService.benchmark.ts | 109 +++++++++++ 4 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 test/benchmark/DecorationService.benchmark.ts diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 95c7c0e244..b70232f36c 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -8,7 +8,9 @@ import { DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; -import { MockLogService } from 'common/TestUtils.test'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +import { Buffer } from 'common/buffer/Buffer'; +import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; function createFakeMarker(line: number): IMarker { return Object.freeze(new class extends Disposable { @@ -19,11 +21,16 @@ function createFakeMarker(line: number): IMarker { }()); } +function createDecorationService(): DecorationService { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + return new DecorationService(new MockLogService(), bufferService); +} + const fakeMarker: IMarker = createFakeMarker(1); describe('DecorationService', () => { it('should set isDisposed to true after dispose', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: fakeMarker }); @@ -35,7 +42,7 @@ describe('DecorationService', () => { describe('forEachDecorationAtCell', () => { it('should find decoration at its marker line', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10 @@ -48,7 +55,7 @@ describe('DecorationService', () => { }); it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -74,7 +81,7 @@ describe('DecorationService', () => { }); it('should not find decoration outside its x range', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), x: 5, @@ -99,11 +106,35 @@ describe('DecorationService', () => { service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d)); assert.strictEqual(foundAtX8.length, 0); }); + + it('should find multi-line decoration when single-line decorations exist on other lines', () => { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + for (let i = 0; i < 100; i++) { + serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(i), + width: 5 + }); + } + const multiLine = serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(10), + width: 10, + height: 3 + }); + assert.ok(multiLine); + + const found: typeof multiLine[] = []; + serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d)); + assert.include(found, multiLine); + }); }); describe('getDecorationsAtCell', () => { it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -117,4 +148,66 @@ describe('DecorationService', () => { assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0); }); }); + + describe('line index maintenance', () => { + it('should keep lookups correct after buffer trim', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(buffer.lines.length - 1); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d)); + assert.strictEqual(found.length, 1); + }); + + it('should remove decoration from line index when marker is trimmed off buffer', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(0); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + assert.isTrue(marker.isDisposed); + assert.isTrue(decoration!.isDisposed); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d)); + assert.strictEqual(found.length, 0); + }); + + it('should keep multi-line decoration indexed after line insert', async () => { + const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(3); + const decoration = service.registerDecoration({ marker, width: 10, height: 3 }); + assert.ok(decoration); + + buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA)); + await new Promise(resolve => queueMicrotask(resolve)); + + const foundOnSpan: typeof decoration[] = []; + for (let line = marker.line; line < marker.line + 3; line++) { + service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d)); + } + assert.include(foundOnSpan, decoration); + + const foundOutsideSpan: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d)); + assert.strictEqual(foundOutsideSpan.length, 0); + }); + }); }); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 133e1a985e..2bac9717c2 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -4,18 +4,17 @@ */ import { css } from 'common/Color'; -import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; -import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; import { IColor } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; +import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; // Work variables to avoid garbage collection let $xmin = 0; let $xmax = 0; -let $ymin = 0; -let $ymax = 0; export class DecorationService extends Disposable implements IDecorationService { public serviceBrand: any; @@ -27,6 +26,14 @@ export class DecorationService extends Disposable implements IDecorationService */ private readonly _decorations: SortedList; + /** + * Decorations indexed by buffer line. Multi-line decorations are present in every line bucket + * they span so cell lookup only iterates decorations relevant to that line. + */ + private readonly _decorationsByLine: Map = new Map(); + + private readonly _bufferLineListeners = this._register(new MutableDisposable()); + private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; private readonly _onDecorationRemoved = this._register(new Emitter()); @@ -34,12 +41,17 @@ export class DecorationService extends Disposable implements IDecorationService public get decorations(): IterableIterator { return this._decorations.values(); } - constructor(@ILogService private readonly _logService: ILogService) { + constructor( + @ILogService private readonly _logService: ILogService, + @IBufferService private readonly _bufferService: IBufferService + ) { super(); this._decorations = new SortedList(e => e?.marker.line, this._logService); this._register(toDisposable(() => this.reset())); + this._register(this._bufferService.buffers.onBufferActivate(() => this._attachBufferLineListeners())); + this._attachBufferLineListeners(); } public registerDecoration(options: IDecorationOptions): IDecoration | undefined { @@ -53,12 +65,14 @@ export class DecorationService extends Disposable implements IDecorationService listener.dispose(); if (decoration) { if (this._decorations.delete(decoration)) { + this._removeFromLineIndex(decoration); this._onDecorationRemoved.fire(decoration); } markerDispose.dispose(); } }); this._decorations.insert(decoration); + this._addToLineIndex(decoration); this._onDecorationRegistered.fire(decoration); } return decoration; @@ -69,19 +83,17 @@ export class DecorationService extends Disposable implements IDecorationService d.dispose(); } this._decorations.clear(); + this._decorationsByLine.clear(); } public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + return; + } let xmin = 0; let xmax = 0; - let ymin = 0; - let ymax = 0; - for (const d of this._decorations.values()) { - ymin = d.marker.line; - ymax = ymin + (d.options.height ?? 1); - if (line < ymin || line >= ymax) { - continue; - } + for (const d of bucket) { xmin = d.options.x ?? 0; xmax = xmin + (d.options.width ?? 1); if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { @@ -91,12 +103,11 @@ export class DecorationService extends Disposable implements IDecorationService } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { - for (const d of this._decorations.values()) { - $ymin = d.marker.line; - $ymax = $ymin + (d.options.height ?? 1); - if (line < $ymin || line >= $ymax) { - continue; - } + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + return; + } + for (const d of bucket) { $xmin = d.options.x ?? 0; $xmax = $xmin + (d.options.width ?? 1); if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { @@ -104,12 +115,147 @@ export class DecorationService extends Disposable implements IDecorationService } } } + + private _attachBufferLineListeners(): void { + const store = new DisposableStore(); + this._bufferLineListeners.value = store; + const lines = this._bufferService.buffer.lines; + store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount))); + store.add(lines.onInsert(event => this._handleBufferLinesInsert(event))); + store.add(lines.onDelete(event => this._handleBufferLinesDelete(event))); + } + + private _getDecorationHeight(decoration: IInternalDecoration): number { + return decoration.options.height ?? 1; + } + + private _addToLineIndex(decoration: IInternalDecoration): void { + const start = decoration.marker.line; + if (start < 0) { + return; + } + decoration._indexedStartLine = start; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + let bucket = this._decorationsByLine.get(line); + if (!bucket) { + bucket = []; + this._decorationsByLine.set(line, bucket); + } + bucket.push(decoration); + } + } + + private _removeFromLineIndex(decoration: IInternalDecoration): void { + const start = decoration._indexedStartLine; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + continue; + } + const index = bucket.indexOf(decoration); + if (index !== -1) { + bucket.splice(index, 1); + } + if (bucket.length === 0) { + this._decorationsByLine.delete(line); + } + } + } + + private _reindexDecoration(decoration: IInternalDecoration): void { + this._removeFromLineIndex(decoration); + if (!decoration.marker.isDisposed && decoration.marker.line >= 0) { + this._addToLineIndex(decoration); + } + } + + private _lineIndexSyncCallbacks: (() => void)[] = []; + + /** Re-index after marker line updates (buffer listeners may run before markers). */ + private _scheduleLineIndexSync(callback: () => void): void { + this._lineIndexSyncCallbacks.push(callback); + if (this._lineIndexSyncCallbacks.length > 1) { + return; + } + queueMicrotask(() => { + const callbacks = this._lineIndexSyncCallbacks; + this._lineIndexSyncCallbacks = []; + for (const cb of callbacks) { + cb(); + } + }); + } + + private _handleBufferLinesTrim(amount: number): void { + if (amount <= 0) { + return; + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line - amount; + if (newLine < 0) { + continue; + } + const existing = newMap.get(newLine); + if (existing) { + existing.push(...bucket); + } else { + newMap.set(newLine, bucket.slice()); + } + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + for (const d of this._decorations.values()) { + if (!d.marker.isDisposed) { + d._indexedStartLine -= amount; + } + } + } + + private _handleBufferLinesInsert(event: IInsertEvent): void { + this._scheduleLineIndexSync(() => { + for (const d of this._decorations.values()) { + if (d.marker.isDisposed) { + continue; + } + const height = this._getDecorationHeight(d); + if (d.marker.line + height > event.index) { + this._reindexDecoration(d); + } + } + }); + } + + private _handleBufferLinesDelete(event: IDeleteEvent): void { + const deleteEnd = event.index + event.amount; + this._scheduleLineIndexSync(() => { + for (const d of this._decorations.values()) { + if (d.marker.isDisposed) { + continue; + } + const start = d.marker.line; + const end = start + this._getDecorationHeight(d); + if (start >= deleteEnd) { + this._reindexDecoration(d); + } else if (start < event.index && end > event.index) { + this._reindexDecoration(d); + } + } + }); + } } class Decoration extends DisposableStore implements IInternalDecoration { public readonly marker: IMarker; public element: HTMLElement | undefined; + /** Start line used for line-index removal when marker.line is cleared on dispose. */ + public _indexedStartLine: number; + public readonly onRenderEmitter = this.add(new Emitter()); public readonly onRender = this.onRenderEmitter.event; private readonly _onDispose = this.add(new Emitter()); @@ -144,6 +290,7 @@ class Decoration extends DisposableStore implements IInternalDecoration { ) { super(); this.marker = options.marker; + this._indexedStartLine = options.marker.line; if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) { this.options.overviewRulerOptions.position = 'full'; } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a15dc23dc6..a858193c3a 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration { readonly backgroundColorRGB: IColor | undefined; readonly foregroundColorRGB: IColor | undefined; readonly onRenderEmitter: Emitter; + /** @internal Start line for line-index removal; kept in sync on buffer line shifts. */ + _indexedStartLine: number; } diff --git a/test/benchmark/DecorationService.benchmark.ts b/test/benchmark/DecorationService.benchmark.ts new file mode 100644 index 0000000000..1a2033de1b --- /dev/null +++ b/test/benchmark/DecorationService.benchmark.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; +import { DecorationService } from 'common/services/DecorationService'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +const enum Constants { + COLS = 80, + ROWS = 30, + SINGLE_LINE_DECORATION_COUNT = 20_000, + MULTI_LINE_DECORATION_COUNT = 19_999, + MULTI_LINE_HEIGHT = 2, + VIEWPORT_SCAN_ITERATIONS = 20 +} + +function registerSingleLineDecorations(service: DecorationService, bufferService: MockBufferService, count: number): void { + const buffer = bufferService.buffer; + for (let i = 0; i < count; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } +} + +function registerMixedDecorations(service: DecorationService, bufferService: MockBufferService): void { + const buffer = bufferService.buffer; + for (let i = 0; i < Constants.MULTI_LINE_DECORATION_COUNT; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } + const multiLineMarker = buffer.addMarker(10); + service.registerDecoration({ + marker: multiLineMarker, + width: Constants.COLS, + height: Constants.MULTI_LINE_HEIGHT + }); +} + +function scanVisibleGrid(service: DecorationService, rows: number, cols: number): number { + let hitCount = 0; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + service.forEachDecorationAtCell(col, row, undefined, () => { + hitCount++; + }); + } + } + return hitCount; +} + +perfContext('DecorationService.forEachDecorationAtCell', () => { + perfContext('single-line dense / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 0, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('mixed single-line + multi-line / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerMixedDecorations(service, bufferService); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 10, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('viewport grid scan', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let totalHits = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + totalHits += scanVisibleGrid(service, Constants.ROWS, Constants.COLS); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS * Constants.ROWS * Constants.COLS, totalHits }; + }, { fork: false }).showAverageRuntime(); + }); +}); From 9e72a4b6d3376609216d6ba34624f9fbc0e6bce6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 26 May 2026 09:47:11 -0700 Subject: [PATCH 7/9] Refactor decoration line index into DecorationLineCache Extract the per-line lookup index from DecorationService into an exported DecorationLineCache class in the same module, with DecorationService listed first for readability. Optimize buffer insert/delete maintenance to shift map keys in O(unique indexed lines) instead of full re-indexing every affected decoration. Only multi-line decorations that span the mutation point get a full re-index. Add a direct DecorationLineCache unit test in DecorationService.test.ts. Co-authored-by: Cursor --- src/common/services/DecorationService.test.ts | 9 +- src/common/services/DecorationService.ts | 185 +++++++++++++----- 2 files changed, 144 insertions(+), 50 deletions(-) diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index b70232f36c..e3a6c2f045 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -4,7 +4,7 @@ */ import { assert } from 'chai'; -import { DecorationService } from './DecorationService'; +import { DecorationLineCache, DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; @@ -149,6 +149,13 @@ describe('DecorationService', () => { }); }); + describe('DecorationLineCache', () => { + it('should return undefined for lines with no indexed decorations', () => { + const cache = new DecorationLineCache(); + assert.isUndefined(cache.getDecorationsOnLine(0)); + }); + }); + describe('line index maintenance', () => { it('should keep lookups correct after buffer trim', () => { const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 2bac9717c2..645a63818c 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -3,14 +3,14 @@ * @license MIT */ +import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; import { css } from 'common/Color'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; -import { IColor } from 'common/Types'; +import { IColor, ICircularList } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; -import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; // Work variables to avoid garbage collection let $xmin = 0; @@ -26,13 +26,7 @@ export class DecorationService extends Disposable implements IDecorationService */ private readonly _decorations: SortedList; - /** - * Decorations indexed by buffer line. Multi-line decorations are present in every line bucket - * they span so cell lookup only iterates decorations relevant to that line. - */ - private readonly _decorationsByLine: Map = new Map(); - - private readonly _bufferLineListeners = this._register(new MutableDisposable()); + private readonly _lineCache = this._register(new DecorationLineCache()); private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; @@ -50,8 +44,10 @@ export class DecorationService extends Disposable implements IDecorationService this._decorations = new SortedList(e => e?.marker.line, this._logService); this._register(toDisposable(() => this.reset())); - this._register(this._bufferService.buffers.onBufferActivate(() => this._attachBufferLineListeners())); - this._attachBufferLineListeners(); + this._register(this._bufferService.buffers.onBufferActivate(() => { + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); + })); + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); } public registerDecoration(options: IDecorationOptions): IDecoration | undefined { @@ -65,14 +61,14 @@ export class DecorationService extends Disposable implements IDecorationService listener.dispose(); if (decoration) { if (this._decorations.delete(decoration)) { - this._removeFromLineIndex(decoration); + this._lineCache.remove(decoration); this._onDecorationRemoved.fire(decoration); } markerDispose.dispose(); } }); this._decorations.insert(decoration); - this._addToLineIndex(decoration); + this._lineCache.add(decoration); this._onDecorationRegistered.fire(decoration); } return decoration; @@ -83,11 +79,11 @@ export class DecorationService extends Disposable implements IDecorationService d.dispose(); } this._decorations.clear(); - this._decorationsByLine.clear(); + this._lineCache.clear(); } public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { - const bucket = this._decorationsByLine.get(line); + const bucket = this._lineCache.getDecorationsOnLine(line); if (!bucket) { return; } @@ -103,7 +99,7 @@ export class DecorationService extends Disposable implements IDecorationService } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { - const bucket = this._decorationsByLine.get(line); + const bucket = this._lineCache.getDecorationsOnLine(line); if (!bucket) { return; } @@ -115,11 +111,44 @@ export class DecorationService extends Disposable implements IDecorationService } } } +} + +/** + * Per-logical-line index of decorations for fast cell lookup. + * + * Keys are marker.line coordinates (logical buffer lines), not CircularList ring slots. + * Multi-line decorations appear in every line bucket they span. The index is kept aligned + * with marker.line updates via buffer line trim/insert/delete events. + */ +export class DecorationLineCache extends Disposable { + private readonly _decorationsByLine: Map = new Map(); + private readonly _decorations = new Set(); + private readonly _bufferLineListeners = this._register(new MutableDisposable()); + + private _lineIndexSyncCallbacks: (() => void)[] = []; + + public clear(): void { + this._decorationsByLine.clear(); + this._decorations.clear(); + } + + public add(decoration: IInternalDecoration): void { + this._decorations.add(decoration); + this._addToLineBuckets(decoration); + } + + public remove(decoration: IInternalDecoration): void { + this._decorations.delete(decoration); + this._removeFromLineBuckets(decoration); + } - private _attachBufferLineListeners(): void { + public getDecorationsOnLine(line: number): ReadonlyArray | undefined { + return this._decorationsByLine.get(line); + } + + public attachToBufferLines(lines: ICircularList): void { const store = new DisposableStore(); this._bufferLineListeners.value = store; - const lines = this._bufferService.buffer.lines; store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount))); store.add(lines.onInsert(event => this._handleBufferLinesInsert(event))); store.add(lines.onDelete(event => this._handleBufferLinesDelete(event))); @@ -129,7 +158,7 @@ export class DecorationService extends Disposable implements IDecorationService return decoration.options.height ?? 1; } - private _addToLineIndex(decoration: IInternalDecoration): void { + private _addToLineBuckets(decoration: IInternalDecoration): void { const start = decoration.marker.line; if (start < 0) { return; @@ -146,7 +175,7 @@ export class DecorationService extends Disposable implements IDecorationService } } - private _removeFromLineIndex(decoration: IInternalDecoration): void { + private _removeFromLineBuckets(decoration: IInternalDecoration): void { const start = decoration._indexedStartLine; const height = this._getDecorationHeight(decoration); for (let line = start; line < start + height; line++) { @@ -165,14 +194,12 @@ export class DecorationService extends Disposable implements IDecorationService } private _reindexDecoration(decoration: IInternalDecoration): void { - this._removeFromLineIndex(decoration); + this._removeFromLineBuckets(decoration); if (!decoration.marker.isDisposed && decoration.marker.line >= 0) { - this._addToLineIndex(decoration); + this._addToLineBuckets(decoration); } } - private _lineIndexSyncCallbacks: (() => void)[] = []; - /** Re-index after marker line updates (buffer listeners may run before markers). */ private _scheduleLineIndexSync(callback: () => void): void { this._lineIndexSyncCallbacks.push(callback); @@ -209,7 +236,7 @@ export class DecorationService extends Disposable implements IDecorationService for (const [line, bucket] of newMap) { this._decorationsByLine.set(line, bucket); } - for (const d of this._decorations.values()) { + for (const d of this._decorations) { if (!d.marker.isDisposed) { d._indexedStartLine -= amount; } @@ -217,35 +244,95 @@ export class DecorationService extends Disposable implements IDecorationService } private _handleBufferLinesInsert(event: IInsertEvent): void { - this._scheduleLineIndexSync(() => { - for (const d of this._decorations.values()) { - if (d.marker.isDisposed) { - continue; - } - const height = this._getDecorationHeight(d); - if (d.marker.line + height > event.index) { - this._reindexDecoration(d); - } - } - }); + this._scheduleLineIndexSync(() => this._applyBufferLinesInsert(event)); } private _handleBufferLinesDelete(event: IDeleteEvent): void { + this._scheduleLineIndexSync(() => this._applyBufferLinesDelete(event)); + } + + private _mergeLineBucket(newMap: Map, line: number, bucket: IInternalDecoration[]): void { + const existing = newMap.get(line); + if (existing) { + existing.push(...bucket); + } else { + newMap.set(line, bucket.slice()); + } + } + + /** + * Shift indexed line keys and sync start lines. O(unique indexed lines), not O(decoration count). + * Decorations that span the insert point are re-indexed individually (rare vs single-line hits). + */ + private _applyBufferLinesInsert(event: IInsertEvent): void { + const { index, amount } = event; + const spanCrossers: IInternalDecoration[] = []; + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + const start = d._indexedStartLine; + if (start < index && start + this._getDecorationHeight(d) > index) { + spanCrossers.push(d); + this._removeFromLineBuckets(d); + } + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line >= index ? line + amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + if (d._indexedStartLine >= index) { + d._indexedStartLine = d.marker.line; + } + } + for (const d of spanCrossers) { + this._addToLineBuckets(d); + } + } + + /** + * Drop deleted line keys, shift keys below, sync start lines. Full re-index only when a + * multi-line decoration spans across the deleted range but survives. + */ + private _applyBufferLinesDelete(event: IDeleteEvent): void { const deleteEnd = event.index + event.amount; - this._scheduleLineIndexSync(() => { - for (const d of this._decorations.values()) { - if (d.marker.isDisposed) { - continue; - } - const start = d.marker.line; - const end = start + this._getDecorationHeight(d); - if (start >= deleteEnd) { - this._reindexDecoration(d); - } else if (start < event.index && end > event.index) { - this._reindexDecoration(d); - } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + if (line >= event.index && line < deleteEnd) { + continue; } - }); + const newLine = line >= deleteEnd ? line - event.amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + const toReindex: IInternalDecoration[] = []; + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + const start = d._indexedStartLine; + const height = this._getDecorationHeight(d); + if (start >= deleteEnd) { + d._indexedStartLine = d.marker.line; + } else if (start < event.index && start + height > deleteEnd) { + toReindex.push(d); + } + } + for (const d of toReindex) { + this._reindexDecoration(d); + } } } From 4a0cb5ae6bd46bc7870d1c8351b754a57111e2d2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 26 May 2026 09:52:53 -0700 Subject: [PATCH 8/9] Add MicrotaskTimer and use it for decoration line index sync Introduce MicrotaskTimer as a TimeoutTimer-style helper that schedules a single runner via queueMicrotask, with set and cancel but no cancelAndSet. Use it in DecorationLineCache to batch insert/delete line-index updates after marker line adjustments, replacing ad-hoc queueMicrotask scheduling. Co-authored-by: Cursor --- src/common/Async.ts | 36 ++++++++++++++++++++++++ src/common/services/DecorationService.ts | 10 +++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/common/Async.ts b/src/common/Async.ts index 7345f19da8..276532fb88 100644 --- a/src/common/Async.ts +++ b/src/common/Async.ts @@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable { } } +/** + * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled + * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not + * run yet. + */ +export class MicrotaskTimer implements IDisposable { + private _isScheduled = false; + private _isDisposed = false; + + public dispose(): void { + this.cancel(); + this._isDisposed = true; + } + + public cancel(): void { + this._isScheduled = false; + } + + public set(runner: () => void): void { + if (this._isDisposed) { + throw new Error('Calling set on a disposed MicrotaskTimer'); + } + if (this._isScheduled) { + return; + } + this._isScheduled = true; + queueMicrotask(() => { + if (!this._isScheduled) { + return; + } + this._isScheduled = false; + runner(); + }); + } +} + export class IntervalTimer implements IDisposable { private _disposable: IDisposable | undefined; private _isDisposed = false; diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 645a63818c..a582c18afb 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -4,6 +4,7 @@ */ import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; +import { MicrotaskTimer } from 'common/Async'; import { css } from 'common/Color'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; @@ -124,10 +125,12 @@ export class DecorationLineCache extends Disposable { private readonly _decorationsByLine: Map = new Map(); private readonly _decorations = new Set(); private readonly _bufferLineListeners = this._register(new MutableDisposable()); - + private readonly _lineIndexSyncTimer = this._register(new MicrotaskTimer()); private _lineIndexSyncCallbacks: (() => void)[] = []; public clear(): void { + this._lineIndexSyncCallbacks.length = 0; + this._lineIndexSyncTimer.cancel(); this._decorationsByLine.clear(); this._decorations.clear(); } @@ -203,10 +206,7 @@ export class DecorationLineCache extends Disposable { /** Re-index after marker line updates (buffer listeners may run before markers). */ private _scheduleLineIndexSync(callback: () => void): void { this._lineIndexSyncCallbacks.push(callback); - if (this._lineIndexSyncCallbacks.length > 1) { - return; - } - queueMicrotask(() => { + this._lineIndexSyncTimer.set(() => { const callbacks = this._lineIndexSyncCallbacks; this._lineIndexSyncCallbacks = []; for (const cb of callbacks) { From d195dc9a3b2222b49970121f75879c8bccd986ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 18:03:15 +0000 Subject: [PATCH 9/9] 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 --- addons/addon-image/src/SixelHandler.ts | 2 +- eslint.config.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/addon-image/src/SixelHandler.ts b/addons/addon-image/src/SixelHandler.ts index 2f12fbe19a..1af2d85bcd 100644 --- a/addons/addon-image/src/SixelHandler.ts +++ b/addons/addon-image/src/SixelHandler.ts @@ -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); } diff --git a/eslint.config.mjs b/eslint.config.mjs index b73eece7a3..6c2f80e303 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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',