mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
Port the xterm, webgl, search, serialize and image source patches to upstream 08829c7. Where upstream fixed the same bug (#6204 mouse NaN guard, #6226 warm-up cancel on dispose, #6235/#6237 serialize width clamp) take upstream and keep only Orca's remaining delta (domContainer warm-up gate, clipped wide-cell blanking). Bump mobile's webgl subset patch alongside. Drop serialize fuzz seed normal:5, which now round-trips.
267 lines
13 KiB
Diff
267 lines
13 KiB
Diff
diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts
|
|
index 1bfa6f01567b50437cd7bdbd83ba17cd79af83af..f27e76ea1ebe3fd646d2adde2188f29385c1914e 100644
|
|
--- a/src/SerializeAddon.ts
|
|
+++ b/src/SerializeAddon.ts
|
|
@@ -5,13 +5,59 @@
|
|
* (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 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));
|
|
}
|
|
@@ -52,11 +98,14 @@ abstract class BaseSerializeHandler {
|
|
// A line can be longer than cols, so stop at the edge of the grid
|
|
const endLineColumn = row === range.end.y ? endColumn : Math.min(line.length, this._terminal.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._terminal.cols, c)) {
|
|
+ c = blankClippedWideCell(c);
|
|
+ }
|
|
this._nextCell(c, oldCell, row, col);
|
|
oldCell = c;
|
|
}
|
|
@@ -150,12 +199,16 @@ 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;
|
|
|
|
protected _beforeSerialize(rows: number, start: number, end: number): void {
|
|
this._allRows = new Array<string>(rows);
|
|
@@ -173,6 +226,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 = '';
|
|
@@ -199,7 +255,10 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|
this._lastCursorCol = 0;
|
|
} else {
|
|
rowSeparator = '';
|
|
- const thisRowLastChar = currentLine.getCell(currentLineLength - 1, this._thisRowLastChar)!;
|
|
+ let thisRowLastChar = currentLine.getCell(currentLineLength - 1, this._thisRowLastChar)!;
|
|
+ if (isClippedWideCell(currentLine, currentLineLength - 1, this._terminal.cols, thisRowLastChar)) {
|
|
+ thisRowLastChar = blankClippedWideCell(thisRowLastChar);
|
|
+ }
|
|
const thisRowLastSecondChar = currentLine.getCell(currentLineLength - 2, this._thisRowLastSecondChar)!;
|
|
const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;
|
|
const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;
|
|
@@ -313,7 +372,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) {
|
|
@@ -340,7 +412,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); }
|
|
}
|
|
}
|
|
@@ -349,6 +421,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;
|
|
@@ -359,12 +445,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
|
|
@@ -398,7 +493,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|
/**
|
|
* handles actual content
|
|
*/
|
|
- if (isEmptyCell) {
|
|
+ if (isEmptyCell && !materializeEmptyCell) {
|
|
this._nullCellCount += cell.getWidth();
|
|
} else {
|
|
if (this._nullCellCount > 0) {
|
|
@@ -414,7 +509,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;
|
|
@@ -428,6 +538,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;
|
|
@@ -442,6 +561,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;
|
|
@@ -619,6 +741,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;
|
|
}
|
|
|