From 2a18600d18bc010104859bcfdf1955dde0a69f68 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 1 May 2026 14:06:39 -0700 Subject: [PATCH] Cache translateToString calls inside BufferLine Fixes #5863 --- src/common/buffer/BufferLine.test.ts | 57 ++++++++++++++++++++++++++++ src/common/buffer/BufferLine.ts | 26 ++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index dce68ebbf..10781745d 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -807,5 +807,62 @@ describe('BufferLine', function(): void { assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3)); assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4)); }); + + it('should cache canonical trimmed string translations', () => { + const line = new TestBufferLine(3); + line.setCell(0, createCellData(1, 'a', 1)); + line.setCell(1, createCellData(1, 'b', 1)); + line.setCell(2, createCellData(1, 'c', 1)); + + const originalGetTrimmedLength = line.getTrimmedLength.bind(line); + let trimmedLengthCalls = 0; + line.getTrimmedLength = (): number => { + trimmedLengthCalls++; + return originalGetTrimmedLength(); + }; + + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); + assert.equal(trimmedLengthCalls, 1); + }); + + it('should invalidate cached trimmed strings on line mutations', () => { + const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => { + const line = new TestBufferLine(5); + line.fill(createCellData(1, 'a', 1)); + const originalGetTrimmedLength = line.getTrimmedLength.bind(line); + let trimmedLengthCalls = 0; + line.getTrimmedLength = (): number => { + trimmedLengthCalls++; + return originalGetTrimmedLength(); + }; + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'aaaaa'); + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'aaaaa'); + assert.equal(trimmedLengthCalls, 1); + mutate(line); + line.translateToString(true, undefined, undefined, undefined); + assert.equal(trimmedLengthCalls, 2); + }; + + assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)])); + assertCacheInvalidated(line => line.setCell(0, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.setCellFromCodepoint(0, 'b'.charCodeAt(0), 1, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.addCodepointToCell(0, 0x301, 0)); + assertCacheInvalidated(line => line.insertCells(1, 1, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.deleteCells(1, 1, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.replaceCells(1, 3, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.resize(6, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.fill(createCellData(1, 'b', 1))); + assertCacheInvalidated(line => { + const src = new TestBufferLine(5); + src.fill(createCellData(1, 'x', 1)); + line.copyFrom(src); + }); + assertCacheInvalidated(line => { + const src = new TestBufferLine(5); + src.fill(createCellData(1, 'x', 1)); + line.copyCellsFrom(src, 0, 0, 2, false); + }); + }); }); }); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index e415851a0..d8e911906 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -63,6 +63,7 @@ export class BufferLine implements IBufferLine { protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; + private _cachedTrimmedString: string | undefined; public length: number; constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { @@ -98,6 +99,7 @@ export class BufferLine implements IBufferLine { * @deprecated */ public set(index: number, value: CharData): void { + this._invalidateStringCache(); this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; if (value[CHAR_DATA_CHAR_INDEX].length > 1) { this._combined[index] = value[1]; @@ -197,6 +199,7 @@ export class BufferLine implements IBufferLine { * Set data at `index` to `cell`. */ public setCell(index: number, cell: ICellData): void { + this._invalidateStringCache(); if (cell.content & Content.IS_COMBINED_MASK) { this._combined[index] = cell.combinedData; } @@ -214,6 +217,7 @@ export class BufferLine implements IBufferLine { * it gets an optimized access method. */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { + this._invalidateStringCache(); if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } @@ -229,6 +233,7 @@ export class BufferLine implements IBufferLine { * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(index: number, codePoint: number, width: number): void { + this._invalidateStringCache(); let content = this._data[index * CELL_SIZE + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add @@ -255,6 +260,7 @@ export class BufferLine implements IBufferLine { } public insertCells(pos: number, n: number, fillCellData: ICellData): void { + this._invalidateStringCache(); pos %= this.length; // handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char @@ -282,6 +288,7 @@ export class BufferLine implements IBufferLine { } public deleteCells(pos: number, n: number, fillCellData: ICellData): void { + this._invalidateStringCache(); pos %= this.length; if (n < this.length - pos) { for (let i = 0; i < this.length - pos - n; ++i) { @@ -308,6 +315,7 @@ export class BufferLine implements IBufferLine { } public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void { + this._invalidateStringCache(); // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) { @@ -347,6 +355,7 @@ export class BufferLine implements IBufferLine { * excess memory (true after shrinking > CLEANUP_THRESHOLD). */ public resize(cols: number, fillCellData: ICellData): boolean { + this._invalidateStringCache(); if (cols === this.length) { return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength; } @@ -406,6 +415,7 @@ export class BufferLine implements IBufferLine { /** fill a line with fillCharData */ public fill(fillCellData: ICellData, respectProtect: boolean = false): void { + this._invalidateStringCache(); // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { for (let i = 0; i < this.length; ++i) { @@ -424,6 +434,7 @@ export class BufferLine implements IBufferLine { /** alter to a full copy of line */ public copyFrom(line: BufferLine): void { + this._invalidateStringCache(); if (this.length !== line.length) { this._data = new Uint32Array(line._data); } else { @@ -476,6 +487,7 @@ export class BufferLine implements IBufferLine { } public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { + this._invalidateStringCache(); const srcData = src._data; if (applyInReverse) { for (let cell = length - 1; cell >= 0; cell--) { @@ -508,7 +520,8 @@ export class BufferLine implements IBufferLine { } /** - * Translates the buffer line to a string. + * Translates the buffer line to a string. Using any of the optional arguments will disable the + * cache. * * @param trimRight Whether to trim any empty cells on the right. * @param startCol The column to start the string (0-based inclusive). @@ -521,6 +534,10 @@ export class BufferLine implements IBufferLine { * returned string, the corresponding entries in `outColumns` will have the same column number. */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { + const isCanonicalTrimmedRequest = !trimRight && !startCol && endCol === undefined && outColumns === undefined; + if (isCanonicalTrimmedRequest && this._cachedTrimmedString !== undefined) { + return this._cachedTrimmedString; + } startCol = startCol ?? 0; endCol = endCol ?? this.length; if (trimRight) { @@ -545,6 +562,13 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.push(startCol); } + if (isCanonicalTrimmedRequest) { + this._cachedTrimmedString = result; + } return result; } + + private _invalidateStringCache(): void { + this._cachedTrimmedString = undefined; + } }