Buckets:
| /** | |
| * SerializeAddon - Serialize terminal buffer contents | |
| * | |
| * Port of xterm.js addon-serialize for ghostty-web. | |
| * Enables serialization of terminal contents to a string that can | |
| * be written back to restore terminal state. | |
| * | |
| * Usage: | |
| * ```typescript | |
| * const serializeAddon = new SerializeAddon(); | |
| * term.loadAddon(serializeAddon); | |
| * const content = serializeAddon.serialize(); | |
| * ``` | |
| */ | |
| import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web" | |
| // ============================================================================ | |
| // Buffer Types (matching ghostty-web internal interfaces) | |
| // ============================================================================ | |
| interface IBuffer { | |
| readonly type: "normal" | "alternate" | |
| readonly cursorX: number | |
| readonly cursorY: number | |
| readonly viewportY: number | |
| readonly baseY: number | |
| readonly length: number | |
| getLine(y: number): IBufferLine | undefined | |
| getNullCell(): IBufferCell | |
| } | |
| interface IBufferLine { | |
| readonly length: number | |
| readonly isWrapped: boolean | |
| getCell(x: number): IBufferCell | undefined | |
| translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string | |
| } | |
| interface IBufferCell { | |
| getChars(): string | |
| getCode(): number | |
| getWidth(): number | |
| getFgColorMode(): number | |
| getBgColorMode(): number | |
| getFgColor(): number | |
| getBgColor(): number | |
| isBold(): number | |
| isItalic(): number | |
| isUnderline(): number | |
| isStrikethrough(): number | |
| isBlink(): number | |
| isInverse(): number | |
| isInvisible(): number | |
| isFaint(): number | |
| isDim(): boolean | |
| } | |
| type TerminalBuffers = { | |
| active?: IBuffer | |
| normal?: IBuffer | |
| alternate?: IBuffer | |
| } | |
| const isRecord = (value: unknown): value is Record<string, unknown> => { | |
| return typeof value === "object" && value !== null | |
| } | |
| const isBuffer = (value: unknown): value is IBuffer => { | |
| if (!isRecord(value)) return false | |
| if (typeof value.length !== "number") return false | |
| if (typeof value.cursorX !== "number") return false | |
| if (typeof value.cursorY !== "number") return false | |
| if (typeof value.baseY !== "number") return false | |
| if (typeof value.viewportY !== "number") return false | |
| if (typeof value.getLine !== "function") return false | |
| if (typeof value.getNullCell !== "function") return false | |
| return true | |
| } | |
| const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => { | |
| if (!isRecord(value)) return | |
| const raw = value.buffer | |
| if (!isRecord(raw)) return | |
| const active = isBuffer(raw.active) ? raw.active : undefined | |
| const normal = isBuffer(raw.normal) ? raw.normal : undefined | |
| const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined | |
| if (!active && !normal) return | |
| return { active, normal, alternate } | |
| } | |
| // ============================================================================ | |
| // Types | |
| // ============================================================================ | |
| export interface ISerializeOptions { | |
| /** | |
| * The row range to serialize. When an explicit range is specified, the cursor | |
| * will get its final repositioning. | |
| */ | |
| range?: ISerializeRange | |
| /** | |
| * The number of rows in the scrollback buffer to serialize, starting from | |
| * the bottom of the scrollback buffer. When not specified, all available | |
| * rows in the scrollback buffer will be serialized. | |
| */ | |
| scrollback?: number | |
| /** | |
| * Whether to exclude the terminal modes from the serialization. | |
| * Default: false | |
| */ | |
| excludeModes?: boolean | |
| /** | |
| * Whether to exclude the alt buffer from the serialization. | |
| * Default: false | |
| */ | |
| excludeAltBuffer?: boolean | |
| } | |
| export interface ISerializeRange { | |
| /** | |
| * The line to start serializing (inclusive). | |
| */ | |
| start: number | |
| /** | |
| * The line to end serializing (inclusive). | |
| */ | |
| end: number | |
| } | |
| export interface IHTMLSerializeOptions { | |
| /** | |
| * The number of rows in the scrollback buffer to serialize, starting from | |
| * the bottom of the scrollback buffer. | |
| */ | |
| scrollback?: number | |
| /** | |
| * Whether to only serialize the selection. | |
| * Default: false | |
| */ | |
| onlySelection?: boolean | |
| /** | |
| * Whether to include the global background of the terminal. | |
| * Default: false | |
| */ | |
| includeGlobalBackground?: boolean | |
| /** | |
| * The range to serialize. This is prioritized over onlySelection. | |
| */ | |
| range?: { | |
| startLine: number | |
| endLine: number | |
| startCol: number | |
| } | |
| } | |
| // ============================================================================ | |
| // Helper Functions | |
| // ============================================================================ | |
| function constrain(value: number, low: number, high: number): number { | |
| return Math.max(low, Math.min(value, high)) | |
| } | |
| function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean { | |
| return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor() | |
| } | |
| function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean { | |
| return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor() | |
| } | |
| function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { | |
| return ( | |
| !!cell1.isInverse() === !!cell2.isInverse() && | |
| !!cell1.isBold() === !!cell2.isBold() && | |
| !!cell1.isUnderline() === !!cell2.isUnderline() && | |
| !!cell1.isBlink() === !!cell2.isBlink() && | |
| !!cell1.isInvisible() === !!cell2.isInvisible() && | |
| !!cell1.isItalic() === !!cell2.isItalic() && | |
| !!cell1.isDim() === !!cell2.isDim() && | |
| !!cell1.isStrikethrough() === !!cell2.isStrikethrough() | |
| ) | |
| } | |
| // ============================================================================ | |
| // Base Serialize Handler | |
| // ============================================================================ | |
| abstract class BaseSerializeHandler { | |
| constructor(protected readonly _buffer: IBuffer) {} | |
| public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string { | |
| let oldCell = this._buffer.getNullCell() | |
| const startRow = range.start.y | |
| const endRow = range.end.y | |
| const startColumn = range.start.x | |
| const endColumn = range.end.x | |
| this._beforeSerialize(endRow - startRow + 1, startRow, endRow) | |
| for (let row = startRow; row <= endRow; row++) { | |
| const line = this._buffer.getLine(row) | |
| if (line) { | |
| const startLineColumn = row === range.start.y ? startColumn : 0 | |
| const endLineColumn = Math.min(endColumn, line.length) | |
| for (let col = startLineColumn; col < endLineColumn; col++) { | |
| const c = line.getCell(col) | |
| if (!c) { | |
| continue | |
| } | |
| this._nextCell(c, oldCell, row, col) | |
| oldCell = c | |
| } | |
| } | |
| this._rowEnd(row, row === endRow) | |
| } | |
| this._afterSerialize() | |
| return this._serializeString(excludeFinalCursorPosition) | |
| } | |
| protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {} | |
| protected _rowEnd(_row: number, _isLastRow: boolean): void {} | |
| protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {} | |
| protected _afterSerialize(): void {} | |
| protected _serializeString(_excludeFinalCursorPosition?: boolean): string { | |
| return "" | |
| } | |
| } | |
| // ============================================================================ | |
| // String Serialize Handler | |
| // ============================================================================ | |
| class StringSerializeHandler extends BaseSerializeHandler { | |
| private _rowIndex: number = 0 | |
| private _allRows: string[] = [] | |
| private _allRowSeparators: string[] = [] | |
| private _currentRow: string = "" | |
| private _nullCellCount: number = 0 | |
| private _cursorStyle: IBufferCell | |
| private _firstRow: number = 0 | |
| private _lastCursorRow: number = 0 | |
| private _lastCursorCol: number = 0 | |
| private _lastContentCursorRow: number = 0 | |
| private _lastContentCursorCol: number = 0 | |
| constructor( | |
| buffer: IBuffer, | |
| private readonly _terminal: ITerminalCore, | |
| ) { | |
| super(buffer) | |
| this._cursorStyle = this._buffer.getNullCell() | |
| } | |
| protected _beforeSerialize(rows: number, start: number, _end: number): void { | |
| this._allRows = Array.from<string>({ length: rows }) | |
| this._allRowSeparators = Array.from<string>({ length: rows }) | |
| this._rowIndex = 0 | |
| this._currentRow = "" | |
| this._nullCellCount = 0 | |
| this._cursorStyle = this._buffer.getNullCell() | |
| this._lastContentCursorRow = start | |
| this._lastCursorRow = start | |
| this._firstRow = start | |
| } | |
| protected _rowEnd(row: number, isLastRow: boolean): void { | |
| let rowSeparator = "" | |
| const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1) | |
| const wrapped = !!nextLine?.isWrapped | |
| if (this._nullCellCount > 0 && wrapped) { | |
| this._currentRow += " ".repeat(this._nullCellCount) | |
| } | |
| this._nullCellCount = 0 | |
| if (!isLastRow && !wrapped) { | |
| rowSeparator = "\r\n" | |
| this._lastCursorRow = row + 1 | |
| this._lastCursorCol = 0 | |
| } | |
| this._allRows[this._rowIndex] = this._currentRow | |
| this._allRowSeparators[this._rowIndex++] = rowSeparator | |
| this._currentRow = "" | |
| this._nullCellCount = 0 | |
| } | |
| private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] { | |
| const sgrSeq: number[] = [] | |
| const fgChanged = !equalFg(cell, oldCell) | |
| const bgChanged = !equalBg(cell, oldCell) | |
| const flagsChanged = !equalFlags(cell, oldCell) | |
| if (fgChanged || bgChanged || flagsChanged) { | |
| if (this._isAttributeDefault(cell)) { | |
| if (!this._isAttributeDefault(oldCell)) { | |
| sgrSeq.push(0) | |
| } | |
| } else { | |
| if (flagsChanged) { | |
| if (!!cell.isInverse() !== !!oldCell.isInverse()) { | |
| sgrSeq.push(cell.isInverse() ? 7 : 27) | |
| } | |
| if (!!cell.isBold() !== !!oldCell.isBold()) { | |
| sgrSeq.push(cell.isBold() ? 1 : 22) | |
| } | |
| if (!!cell.isUnderline() !== !!oldCell.isUnderline()) { | |
| sgrSeq.push(cell.isUnderline() ? 4 : 24) | |
| } | |
| 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) | |
| } | |
| if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) { | |
| sgrSeq.push(cell.isStrikethrough() ? 9 : 29) | |
| } | |
| } | |
| if (fgChanged) { | |
| const color = cell.getFgColor() | |
| const mode = cell.getFgColorMode() | |
| if (mode === 2 || mode === 3 || mode === -1) { | |
| sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) | |
| } else if (mode === 1) { | |
| // Palette | |
| if (color >= 16) { | |
| sgrSeq.push(38, 5, color) | |
| } else { | |
| sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)) | |
| } | |
| } else { | |
| sgrSeq.push(39) | |
| } | |
| } | |
| if (bgChanged) { | |
| const color = cell.getBgColor() | |
| const mode = cell.getBgColorMode() | |
| if (mode === 2 || mode === 3 || mode === -1) { | |
| sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) | |
| } else if (mode === 1) { | |
| // Palette | |
| if (color >= 16) { | |
| sgrSeq.push(48, 5, color) | |
| } else { | |
| sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)) | |
| } | |
| } else { | |
| sgrSeq.push(49) | |
| } | |
| } | |
| } | |
| } | |
| return sgrSeq | |
| } | |
| private _isAttributeDefault(cell: IBufferCell): boolean { | |
| const mode = cell.getFgColorMode() | |
| const bgMode = cell.getBgColorMode() | |
| if (mode === 0 && bgMode === 0) { | |
| return ( | |
| !cell.isBold() && | |
| !cell.isItalic() && | |
| !cell.isUnderline() && | |
| !cell.isBlink() && | |
| !cell.isInverse() && | |
| !cell.isInvisible() && | |
| !cell.isDim() && | |
| !cell.isStrikethrough() | |
| ) | |
| } | |
| const fgColor = cell.getFgColor() | |
| const bgColor = cell.getBgColor() | |
| const nullCell = this._buffer.getNullCell() | |
| const nullFg = nullCell.getFgColor() | |
| const nullBg = nullCell.getBgColor() | |
| return ( | |
| fgColor === nullFg && | |
| bgColor === nullBg && | |
| !cell.isBold() && | |
| !cell.isItalic() && | |
| !cell.isUnderline() && | |
| !cell.isBlink() && | |
| !cell.isInverse() && | |
| !cell.isInvisible() && | |
| !cell.isDim() && | |
| !cell.isStrikethrough() | |
| ) | |
| } | |
| protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void { | |
| const isPlaceHolderCell = cell.getWidth() === 0 | |
| if (isPlaceHolderCell) { | |
| return | |
| } | |
| const codepoint = cell.getCode() | |
| const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff) | |
| const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1) | |
| const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage | |
| const sgrSeq = this._diffStyle(cell, this._cursorStyle) | |
| const styleChanged = sgrSeq.length > 0 | |
| if (styleChanged) { | |
| if (this._nullCellCount > 0) { | |
| this._currentRow += " ".repeat(this._nullCellCount) | |
| this._nullCellCount = 0 | |
| } | |
| this._lastContentCursorRow = this._lastCursorRow = row | |
| this._lastContentCursorCol = this._lastCursorCol = col | |
| this._currentRow += `\u001b[${sgrSeq.join(";")}m` | |
| const line = this._buffer.getLine(row) | |
| const cellFromLine = line?.getCell(col) | |
| if (cellFromLine) { | |
| this._cursorStyle = cellFromLine | |
| } | |
| } | |
| if (isEmptyCell) { | |
| this._nullCellCount += cell.getWidth() | |
| } else { | |
| if (this._nullCellCount > 0) { | |
| this._currentRow += " ".repeat(this._nullCellCount) | |
| this._nullCellCount = 0 | |
| } | |
| this._currentRow += cell.getChars() | |
| this._lastContentCursorRow = this._lastCursorRow = row | |
| this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth() | |
| } | |
| } | |
| protected _serializeString(excludeFinalCursorPosition?: boolean): string { | |
| let rowEnd = this._allRows.length | |
| if (this._buffer.length - this._firstRow <= this._terminal.rows) { | |
| rowEnd = this._lastContentCursorRow + 1 - this._firstRow | |
| this._lastCursorCol = this._lastContentCursorCol | |
| this._lastCursorRow = this._lastContentCursorRow | |
| } | |
| let content = "" | |
| for (let i = 0; i < rowEnd; i++) { | |
| content += this._allRows[i] | |
| if (i + 1 < rowEnd) { | |
| content += this._allRowSeparators[i] | |
| } | |
| } | |
| if (excludeFinalCursorPosition) return content | |
| const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY | |
| const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) | |
| const cursorCol = this._buffer.cursorX + 1 | |
| content += `\u001b[${cursorRow};${cursorCol}H` | |
| const line = this._buffer.getLine(absoluteCursorRow) | |
| const cell = line?.getCell(this._buffer.cursorX) | |
| const style = (() => { | |
| if (!cell) return this._buffer.getNullCell() | |
| if (cell.getWidth() !== 0) return cell | |
| if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell | |
| return cell | |
| })() | |
| const sgrSeq = this._diffStyle(style, this._cursorStyle) | |
| if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m` | |
| return content | |
| } | |
| } | |
| // ============================================================================ | |
| // SerializeAddon Class | |
| // ============================================================================ | |
| export class SerializeAddon implements ITerminalAddon { | |
| private _terminal?: ITerminalCore | |
| /** | |
| * Activate the addon (called by Terminal.loadAddon) | |
| */ | |
| public activate(terminal: ITerminalCore): void { | |
| this._terminal = terminal | |
| } | |
| /** | |
| * Dispose the addon and clean up resources | |
| */ | |
| public dispose(): void { | |
| this._terminal = undefined | |
| } | |
| /** | |
| * Serializes terminal rows into a string that can be written back to the | |
| * terminal to restore the state. The cursor will also be positioned to the | |
| * correct cell. | |
| * | |
| * @param options Custom options to allow control over what gets serialized. | |
| */ | |
| public serialize(options?: ISerializeOptions): string { | |
| if (!this._terminal) { | |
| throw new Error("Cannot use addon until it has been loaded") | |
| } | |
| const buffer = getTerminalBuffers(this._terminal) | |
| if (!buffer) { | |
| return "" | |
| } | |
| const normalBuffer = buffer.normal ?? buffer.active | |
| const altBuffer = buffer.alternate | |
| if (!normalBuffer) { | |
| return "" | |
| } | |
| let content = options?.range | |
| ? this._serializeBufferByRange(normalBuffer, options.range, true) | |
| : this._serializeBufferByScrollback(normalBuffer, options?.scrollback) | |
| if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) { | |
| const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined) | |
| content += `\u001b[?1049h\u001b[H${alternateContent}` | |
| } | |
| return content | |
| } | |
| /** | |
| * Serializes terminal content as plain text (no escape sequences) | |
| * @param options Custom options to allow control over what gets serialized. | |
| */ | |
| public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string { | |
| if (!this._terminal) { | |
| throw new Error("Cannot use addon until it has been loaded") | |
| } | |
| const buffer = getTerminalBuffers(this._terminal) | |
| if (!buffer) { | |
| return "" | |
| } | |
| const activeBuffer = buffer.active ?? buffer.normal | |
| if (!activeBuffer) { | |
| return "" | |
| } | |
| const maxRows = activeBuffer.length | |
| const scrollback = options?.scrollback | |
| const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows) | |
| const startRow = maxRows - correctRows | |
| const endRow = maxRows - 1 | |
| const lines: string[] = [] | |
| for (let row = startRow; row <= endRow; row++) { | |
| const line = activeBuffer.getLine(row) | |
| if (line) { | |
| const text = line.translateToString(options?.trimWhitespace ?? true) | |
| lines.push(text) | |
| } | |
| } | |
| // Trim trailing empty lines if requested | |
| if (options?.trimWhitespace) { | |
| while (lines.length > 0 && lines[lines.length - 1] === "") { | |
| lines.pop() | |
| } | |
| } | |
| return lines.join("\n") | |
| } | |
| private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string { | |
| const maxRows = buffer.length | |
| const rows = this._terminal?.rows ?? 24 | |
| const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows) | |
| return this._serializeBufferByRange( | |
| buffer, | |
| { | |
| start: maxRows - correctRows, | |
| end: maxRows - 1, | |
| }, | |
| false, | |
| ) | |
| } | |
| private _serializeBufferByRange( | |
| buffer: IBuffer, | |
| range: ISerializeRange, | |
| excludeFinalCursorPosition: boolean, | |
| ): string { | |
| const handler = new StringSerializeHandler(buffer, this._terminal!) | |
| const cols = this._terminal?.cols ?? 80 | |
| return handler.serialize( | |
| { | |
| start: { x: 0, y: range.start }, | |
| end: { x: cols, y: range.end }, | |
| }, | |
| excludeFinalCursorPosition, | |
| ) | |
| } | |
| } | |
Xet Storage Details
- Size:
- 19.4 kB
- Xet hash:
- 8fd2248effc819d65c30ebc69828316efd6d80ca35e221fe204d7baeb6f96cf4
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.