diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts index e1728feb219c362dfa2ecb602ff99f830d520757..265b9792403601c286bc341f9d046dcd3062e1be 100644 --- a/src/SerializeAddon.ts +++ b/src/SerializeAddon.ts @@ -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'; +type OscLinkData = { id?: string; uri: string }; +type OscLinkedCell = { extended?: { urlId?: number } }; +type TerminalWithOscLinks = Terminal & { + _core?: { + _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell }; + _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined }; + }; +}; + +function getOscLinkId(cell: IBufferCell | IAttributeData): number { + return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0; +} + +function getOscLinkOpenSequence(terminal: Terminal, linkId: number): string { + if (!linkId) { + return ''; + } + const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId); + if (!data) { + return ''; + } + const params = data.id === undefined ? '' : `id=${data.id}`; + return `\u001b]8;${params};${data.uri}\u001b\\`; +} + +function getActiveOscLinkSequence(terminal: Terminal): string { + 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)); } @@ -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); + } 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(); + private _defaultCell: IBufferCell = this._buffer.getNullCell(); private _firstRow: number = 0; private _lastCursorRow: number = 0; private _lastCursorCol: number = 0; private _lastContentCursorRow: number = 0; private _lastContentCursorCol: number = 0; + private _activeOscLinkId: number = 0; + private _lastBackgroundCursorRow: number = -1; + private _lastBackgroundCursorCol: number = 0; constructor( buffer: IBuffer, 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() && - isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0 + (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0) ) { if ( // the last character can't be null, @@ -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 = currentLineCells - this._nullCellCount; + if (contentCellCount > 0) { + rowSeparator += `\u001b[${contentCellCount}C`; + } rowSeparator += `\u001b[${this._nullCellCount}X`; - rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`; + if (contentCellCount > 0) { + rowSeparator += `\u001b[${contentCellCount}D`; + } rowSeparator += '\u001b[B'; } @@ -310,7 +383,20 @@ class StringSerializeHandler extends BaseSerializeHandler { } if (flagsChanged) { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } - if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } + // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so + // they must be diffed as one intensity group with the clearing 22 emitted + // BEFORE any re-set. Upstream's independent per-flag diff could emit + // "1;22" (bold set, then wiped by dim's clear — \x1b[2mA\x1b[22m\x1b[1mB + // loses B's bold on round-trip) or a bare "22" that drops a still-set + // bold/dim, garbling Orca's hidden-terminal snapshot restores. + const boldChanged = cell.isBold() !== oldCell.isBold(); + const dimChanged = cell.isDim() !== oldCell.isDim(); + if (boldChanged || dimChanged) { + const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim()); + if (clearsIntensity) { sgrSeq.push(22); } + if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); } + if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); } + } if (!equalUnderline(cell, oldCell)) { const style = cell.getUnderlineStyle(); if (style === UnderlineStyle.NONE) { @@ -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); } - if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); } + // PATCH(orca): dim handled in the intensity group above. if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } } } @@ -346,6 +432,20 @@ class StringSerializeHandler extends BaseSerializeHandler { return sgrSeq; } + private _setOscLink(linkId: number): string { + if (linkId === this._activeOscLinkId) { + return ''; + } + let sequence = this._activeOscLinkId ? '\u001b]8;;\u001b\\' : ''; + this._activeOscLinkId = 0; + const openSequence = getOscLinkOpenSequence(this._terminal, linkId); + if (openSequence) { + this._activeOscLinkId = linkId; + sequence += openSequence; + } + return sequence; + } + 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 +456,21 @@ class StringSerializeHandler extends BaseSerializeHandler { // this cell don't have content const isEmptyCell = cell.getChars() === ''; + const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined; + const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar); + // A pending wide glyph recreates its own final-column padding during replay. + const isWideWrapPadding = col === this._terminal.cols - 1 && + nextLine?.isWrapped && + (nextRowFirstCell?.getWidth() ?? 0) > 1 && + !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell); + // Cursor movement cannot reproduce an inverse cell's visible background. + const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding; const sgrSeq = this._diffStyle(cell, this._cursorStyle); - // the empty cell style is only assumed to be changed when background changed, because - // foreground is always 0. - const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0; + const styleChanged = isEmptyCell + ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell) + : sgrSeq.length > 0; /** * handles style change @@ -395,7 +504,7 @@ class StringSerializeHandler extends BaseSerializeHandler { /** * handles actual content */ - if (isEmptyCell) { + if (isEmptyCell && !materializeEmptyCell) { this._nullCellCount += cell.getWidth(); } else { if (this._nullCellCount > 0) { @@ -411,7 +520,22 @@ class StringSerializeHandler extends BaseSerializeHandler { this._nullCellCount = 0; } - this._currentRow += cell.getChars(); + // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable. + this._currentRow += this._setOscLink(getOscLinkId(cell)); + + if (materializeEmptyCell) { + const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline(); + if (hasDecoration) { + this._currentRow += '\u001b[24;29;55m'; + } + this._currentRow += ' '.repeat(cell.getWidth()); + if (hasDecoration) { + const restoreSgrSeq = this._diffStyle(cell, this._defaultCell); + this._currentRow += `\u001b[0m\u001b[${restoreSgrSeq.join(';')}m`; + } + } else { + this._currentRow += cell.getChars(); + } // update cursor this._lastContentCursorRow = this._lastCursorRow = row; @@ -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 { } } + // Each buffer is self-contained so links cannot leak into a following buffer. + content += this._setOscLink(0); + // restore the cursor if (!excludeFinalCursorPosition) { const realCursorRow = this._buffer.baseY + this._buffer.cursorY; @@ -616,6 +752,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi { content += this._serializeScrollRegion(this._terminal); } + // Restore the source terminal's live OSC pen only after all buffers are complete. + content += getActiveOscLinkSequence(this._terminal); + return content; } @@ -642,7 +781,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { private readonly _terminal: Terminal, private readonly _options: Partial ) { - super(buffer); + super(buffer, _terminal.cols); // For xterm headless: fallback to ansi colors if ((_terminal as any)._core._themeService) {