Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
# these carry real CR and CRLF bytes as the terminal emitted them, and line-ending
# normalisation on a Windows checkout would rewrite the evidence the fixture exists to be.
/src/main/runtime/__fixtures__/*.txt -text
/src/main/daemon/__fixtures__/pty-transcripts/*.txt -text
# Generated runtime English subset: compared byte-for-byte by
# verify:localization-runtime-catalog, so a CRLF checkout would fail the gate.
/src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf
Expand Down
169 changes: 144 additions & 25 deletions config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts
index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b01fdff0a 100644
index e1728feb219c362dfa2ecb602ff99f830d520757..265b9792403601c286bc341f9d046dcd3062e1be 100644
--- a/src/SerializeAddon.ts
+++ b/src/SerializeAddon.ts
@@ -12,6 +12,36 @@ import { IAttributeData } from 'common/buffer/Types';
@@ -5,13 +5,66 @@
* (EXPERIMENTAL) This Addon is still under development
*/

-import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
+import type { IBuffer, IBufferCell, IBufferLine, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
import { IColor } from 'common/Types';
import { IAttributeData } from 'common/buffer/Types';
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
import { UnderlineStyle } from 'common/buffer/Constants';

Expand Down Expand Up @@ -35,11 +43,63 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
+ const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;
+ return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';
+}
+
+// PATCH(orca): a non-reflowing shrink (always the alternate buffer) leaves each
+// line at its old length; cells past the grid are not on screen and would wrap
+// into extra rows on replay, so serialize only the terminal's width.
+function visibleCellCount(line: IBufferLine, cols: number): number {
+ return Math.min(line.length, cols);
+}
+
+// PATCH(orca): a wide glyph whose trailing half fell past the grid cannot be
+// replayed in the last column (it would wrap and shift every row below), so it
+// is serialized as a blank that keeps the row exactly the grid's width.
+function isClippedWideCell(line: IBufferLine, col: number, cols: number, cell: IBufferCell): boolean {
+ return col === cols - 1 && line.length > cols && cell.getWidth() > 1;
+}
+
+// Width 1 with no codepoint (CellData.content layout). A width-0 stand-in would read as a
+// wide glyph's trailer: skipped by _nextCell and treated as content by the wrap check.
+const BLANK_CELL_CONTENT = 1 << 22;
+
+function blankClippedWideCell(cell: IBufferCell): IBufferCell {
+ (cell as IBufferCell & { content: number }).content = BLANK_CELL_CONTENT;
+ return cell;
+}
+
function constrain(value: number, low: number, high: number): number {
return Math.max(low, Math.min(value, high));
}
@@ -148,12 +178,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -27,7 +80,8 @@ function escapeHTMLChar(c: string): string {
// TODO: Refine this template class later
abstract class BaseSerializeHandler {
constructor(
- protected readonly _buffer: IBuffer
+ protected readonly _buffer: IBuffer,
+ private readonly _cols: number
) {
}

@@ -48,13 +102,16 @@ abstract class BaseSerializeHandler {
const line = this._buffer.getLine(row);
if (line) {
const startLineColumn = row === range.start.y ? startColumn : 0;
- const endLineColumn = row === range.end.y ? endColumn: line.length;
+ const endLineColumn = row === range.end.y ? endColumn: visibleCellCount(line, this._cols);
for (let col = startLineColumn; col < endLineColumn; col++) {
- const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);
+ let c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);
if (!c) {
console.warn(`Can't get cell at row=${row}, col=${col}`);
continue;
}
+ if (isClippedWideCell(line, col, this._cols, c)) {
+ c = blankClippedWideCell(c);
+ }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this._nextCell(c, oldCell, row, col);
oldCell = c;
}
@@ -148,18 +205,22 @@ class StringSerializeHandler extends BaseSerializeHandler {

// this is a null cell for reference for checking whether background is empty or not
private _backgroundCell: IBufferCell = this._buffer.getNullCell();
Expand All @@ -51,10 +111,44 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
private _lastContentCursorRow: number = 0;
private _lastContentCursorCol: number = 0;
+ private _activeOscLinkId: number = 0;
+ private _lastBackgroundCursorRow: number = -1;
+ private _lastBackgroundCursorCol: number = 0;

constructor(
buffer: IBuffer,
@@ -214,7 +246,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
private readonly _terminal: Terminal
) {
- super(buffer);
+ super(buffer, _terminal.cols);
}

protected _beforeSerialize(rows: number, start: number, end: number): void {
@@ -178,6 +239,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
// use clear right to set background.
this._currentRow += `\u001b[${this._nullCellCount}X`;
+ // PATCH(orca): the erase paints this row, so _serializeString must not trim it as trailing blank.
+ this._lastBackgroundCursorRow = this._lastCursorRow;
+ this._lastBackgroundCursorCol = this._lastCursorCol;
}

let rowSeparator = '';
@@ -202,8 +266,12 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._lastCursorCol = 0;
} else {
rowSeparator = '';
- const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!;
- const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!;
+ const currentLineCells = visibleCellCount(currentLine, this._terminal.cols);
+ let thisRowLastChar = currentLine.getCell(currentLineCells - 1, this._thisRowLastChar)!;
+ if (isClippedWideCell(currentLine, currentLineCells - 1, this._terminal.cols, thisRowLastChar)) {
+ thisRowLastChar = blankClippedWideCell(thisRowLastChar);
+ }
+ const thisRowLastSecondChar = currentLine.getCell(currentLineCells - 2, this._thisRowLastSecondChar)!;
const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;
const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;

@@ -214,7 +282,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (
// you must output character to cause overflow, control sequence can't do this
nextRowFirstChar.getChars() &&
Expand All @@ -63,12 +157,12 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
) {
if (
// the last character can't be null,
@@ -251,9 +283,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -251,9 +319,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (this._nullCellCount > 0) {
// do these because we filled the last several null slot, which we shouldn't
rowSeparator += '\u001b[A';
- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`;
+ const contentCellCount = currentLine.length - this._nullCellCount;
+ const contentCellCount = currentLineCells - this._nullCellCount;
+ if (contentCellCount > 0) {
+ rowSeparator += `\u001b[${contentCellCount}C`;
+ }
Expand All @@ -80,7 +174,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
rowSeparator += '\u001b[B';
}

@@ -310,7 +347,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -310,7 +383,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
if (flagsChanged) {
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
Expand All @@ -102,7 +196,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
if (!equalUnderline(cell, oldCell)) {
const style = cell.getUnderlineStyle();
if (style === UnderlineStyle.NONE) {
@@ -337,7 +387,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -337,7 +423,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }
if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }
if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }
Expand All @@ -111,7 +205,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }
}
}
@@ -346,6 +396,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -346,6 +432,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
return sgrSeq;
}

Expand All @@ -132,7 +226,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {
// a width 0 cell don't need to be count because it is just a placeholder after a CJK character;
const isPlaceHolderCell = cell.getWidth() === 0;
@@ -356,12 +420,21 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -356,12 +456,21 @@ class StringSerializeHandler extends BaseSerializeHandler {

// this cell don't have content
const isEmptyCell = cell.getChars() === '';
Expand All @@ -157,7 +251,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b

/**
* handles style change
@@ -395,7 +468,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -395,7 +504,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
/**
* handles actual content
*/
Expand All @@ -166,7 +260,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
this._nullCellCount += cell.getWidth();
} else {
if (this._nullCellCount > 0) {
@@ -411,7 +484,22 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -411,7 +520,22 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount = 0;
}

Expand All @@ -190,7 +284,23 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b

// update cursor
this._lastContentCursorRow = this._lastCursorRow = row;
@@ -439,6 +527,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
@@ -425,6 +549,15 @@ class StringSerializeHandler extends BaseSerializeHandler {
// the fixup is only required for data without scrollback
// because it will always be placed at last line otherwise
if (this._buffer.length - this._firstRow <= this._terminal.rows) {
+ // PATCH(orca): keep trailing background-only rows, unless the cursor is wrap-pending:
+ // relative moves back from those rows cannot re-create that state.
+ if (
+ this._lastBackgroundCursorRow > this._lastContentCursorRow &&
+ this._buffer.cursorX < this._terminal.cols
+ ) {
+ this._lastContentCursorRow = this._lastBackgroundCursorRow;
+ this._lastContentCursorCol = this._lastBackgroundCursorCol;
+ }
rowEnd = this._lastContentCursorRow + 1 - this._firstRow;
this._lastCursorCol = this._lastContentCursorCol;
this._lastCursorRow = this._lastContentCursorRow;
@@ -439,6 +572,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}

Expand All @@ -200,7 +310,7 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
// restore the cursor
if (!excludeFinalCursorPosition) {
const realCursorRow = this._buffer.baseY + this._buffer.cursorY;
@@ -616,6 +707,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi {
@@ -616,6 +752,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi {
content += this._serializeScrollRegion(this._terminal);
}

Expand All @@ -210,3 +320,12 @@ index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b
return content;
}

@@ -642,7 +781,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
private readonly _terminal: Terminal,
private readonly _options: Partial<IHTMLSerializeOptions>
) {
- super(buffer);
+ super(buffer, _terminal.cols);

// For xterm headless: fallback to ansi colors
if ((_terminal as any)._core._themeService) {
123 changes: 123 additions & 0 deletions config/scripts/build-serialize-addon-at-ref.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env node
// Materializes the patched @xterm/addon-serialize dist of any git ref, for the
// differential serialize fuzz (src/main/daemon/serialize-grid.differential.fuzz.test.ts):
//
// node config/scripts/build-serialize-addon-at-ref.mjs --ref origin/main --out-dir /tmp/serialize-old
// ORCA_OLD_SERIALIZE_ADDON=<printed path> pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/serialize-grid.differential.fuzz.test.ts
//
// It recovers the pristine dist by reverse-applying whichever patch produced the
// installed node_modules copy, applies the ref's patch, and checks every step
// against the blob hashes in the patches' index lines.

import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync
} from 'node:fs'
import path from 'node:path'

const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const PACKAGE = '@xterm/addon-serialize'
const DIST_FILE = 'lib/addon-serialize.js'

function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, '')] = argv[i + 1]
}
if (!args.ref || !args['out-dir']) {
throw new Error('usage: build-serialize-addon-at-ref.mjs --ref <git-ref> --out-dir <dir>')
}
return args
}

function git(args, options = {}) {
return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options })
}

function blobHash(content) {
return createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex')
}

function patchFileName() {
const name = readdirSync(path.join(REPO_ROOT, 'config', 'patches')).find(
(file) => file.startsWith('@xterm__addon-serialize@') && file.endsWith('.patch')
)
if (!name) {
throw new Error('no @xterm/addon-serialize dist patch under config/patches')
}
return name
}

/** Pre- and post-image blob hashes the patch records for the dist file. */
function distHashes(patchText) {
const match = patchText.match(
new RegExp(`diff --git a/${DIST_FILE} b/${DIST_FILE}\\nindex ([0-9a-f]+)\\.\\.([0-9a-f]+)`)
)
if (!match) {
throw new Error(`patch does not touch ${DIST_FILE}`)
}
return { pristine: match[1], patched: match[2] }
}

function applyPatch(packageDir, patchPath, reverse) {
// Ceiling stops git from discovering an enclosing repository, so paths stay package-relative.
git(['apply', ...(reverse ? ['-R'] : []), `--include=${DIST_FILE}`, patchPath], {
cwd: packageDir,
env: { ...process.env, GIT_CEILING_DIRECTORIES: path.dirname(packageDir) }
})
}

function main() {
const args = parseArgs(process.argv.slice(2))
const outDir = path.resolve(args['out-dir'])
const name = patchFileName()
const relativePatch = path.posix.join('config', 'patches', name)
const refPatch = git(['show', `${args.ref}:${relativePatch}`], { cwd: REPO_ROOT })
const target = distHashes(refPatch)

const packageDir = path.join(outDir, 'package')
rmSync(outDir, { recursive: true, force: true })
mkdirSync(path.join(packageDir, 'lib'), { recursive: true })
const installed = path.join(REPO_ROOT, 'node_modules', PACKAGE, DIST_FILE)
cpSync(installed, path.join(packageDir, DIST_FILE))
const distPath = path.join(packageDir, DIST_FILE)
const installedHash = blobHash(readFileSync(distPath))

if (installedHash !== target.patched) {
const candidates = [
readFileSync(path.join(REPO_ROOT, relativePatch), 'utf8'),
git(['show', `HEAD:${relativePatch}`], { cwd: REPO_ROOT })
]
const source = candidates.find((text) => distHashes(text).patched === installedHash)
if (!source) {
throw new Error(
`installed ${DIST_FILE} (${installedHash}) matches neither the working-tree nor the HEAD patch; run pnpm install`
)
}
const sourcePath = path.join(outDir, 'installed.patch')
writeFileSync(sourcePath, source)
applyPatch(packageDir, sourcePath, true)
if (blobHash(readFileSync(distPath)) !== target.pristine) {
throw new Error(`reverse-applied dist is not the pristine ${target.pristine} the ref patches`)
}
const refPatchPath = path.join(outDir, 'ref.patch')
writeFileSync(refPatchPath, refPatch)
applyPatch(packageDir, refPatchPath, false)
}
const finalHash = blobHash(readFileSync(distPath))
if (finalHash !== target.patched || !existsSync(distPath)) {
throw new Error(
`built ${DIST_FILE} hashes to ${finalHash}, the ref patch expects ${target.patched}`
)
}
process.stdout.write(`${distPath}\n`)
}

main()
Loading
Loading