diff --git a/package.json b/package.json index 6b3f6910..6ed4f2bb 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "jsdom": "^18.0.1", "mocha": "^10.1.0", "mustache": "^4.2.0", - "node-pty": "1.1.0-beta19", + "node-pty": "^1.1.0-beta31", "nyc": "^15.1.0", "source-map-loader": "^3.0.0", "source-map-support": "^0.5.20", diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 58da9344..14ff9433 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -532,7 +532,13 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.textarea!.focus(); this.textarea!.select(); })); - this._register(this._onScroll.event(() => this._selectionService!.refresh())); + this._register(Event.any( + this._onScroll.event, + this._inputHandler.onScroll + )(() => { + this._selectionService!.refresh(); + this._viewport?.queueSync(); + })); this._register(this._instantiationService.createInstance(BufferDecorationRenderer, this.screenElement)); this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e))); diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 4cec08fd..20e4db09 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -93,8 +93,8 @@ export class Viewport extends Disposable { ].join('\n'); })); - this._register(this._bufferService.onResize(() => this._queueSync())); - this._register(this._bufferService.buffers.onBufferActivate(() => this._queueSync())); + this._register(this._bufferService.onResize(() => this.queueSync())); + this._register(this._bufferService.buffers.onBufferActivate(() => this.queueSync())); this._register(this._bufferService.onScroll(() => this._sync())); this._register(this._scrollableElement.onScroll(e => this._handleScroll(e))); @@ -126,7 +126,7 @@ export class Viewport extends Disposable { }; } - private _queueSync(ydisp?: number): void { + public queueSync(ydisp?: number): void { // Update state if (ydisp !== undefined) { this._latestYDisp = ydisp; diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 1280417b..78ce646a 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -438,6 +438,37 @@ describe('InputHandler', () => { inputHandler.eraseInLine(Params.fromArray([2])); assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, false); }); + it('ED2 with scrollOnEraseInDisplay turned on', async () => { + const inputHandler = new TestInputHandler( + bufferService, + new MockCharsetService(), + new MockCoreService(), + new MockLogService(), + new MockOptionsService({ scrollOnEraseInDisplay: true }), + new MockOscLinkService(), + new MockCoreMouseService(), + new MockUnicodeService() + ); + const aLine = Array(bufferService.cols + 1).join('a'); + // add 2 full lines of text. + await inputHandler.parseP(aLine); + await inputHandler.parseP(aLine); + + inputHandler.eraseInDisplay(Params.fromArray([2])); + // those 2 lines should have been pushed to scrollback. + assert.equal(bufferService.rows + 2, bufferService.buffer.lines.length); + assert.equal(bufferService.buffer.ybase, 2); + assert.equal(bufferService.buffer.lines.get(0)?.translateToString(), aLine); + assert.equal(bufferService.buffer.lines.get(1)?.translateToString(), aLine); + + // Move to last line and add more text. + bufferService.buffer.y = bufferService.rows - 1; + bufferService.buffer.x = 0; + await inputHandler.parseP(aLine); + inputHandler.eraseInDisplay(Params.fromArray([2])); + // Screen should have been scrolled by a full screen size. + assert.equal(bufferService.rows * 2 + 2, bufferService.buffer.lines.length); + }); it('eraseInDisplay', async () => { const bufferService = new MockBufferService(80, 7); const inputHandler = new TestInputHandler( diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 8308e6de..0e151173 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1220,12 +1220,27 @@ export class InputHandler extends Disposable implements IInputHandler { this._dirtyRowTracker.markDirty(0); break; case 2: - j = this._bufferService.rows; - this._dirtyRowTracker.markDirty(j - 1); - while (j--) { - this._resetBufferLine(j, respectProtect); + if (this._optionsService.rawOptions.scrollOnEraseInDisplay) { + j = this._bufferService.rows; + this._dirtyRowTracker.markRangeDirty(0, j - 1); + while (j--) { + const currentLine = this._activeBuffer.lines.get(this._activeBuffer.ybase + j); + if (currentLine?.getTrimmedLength()) { + break; + } + } + for (; j >= 0; j--) { + this._bufferService.scroll(this._eraseAttrData()); + } + } + else { + j = this._bufferService.rows; + this._dirtyRowTracker.markDirty(j - 1); + while (j--) { + this._resetBufferLine(j, respectProtect); + } + this._dirtyRowTracker.markDirty(0); } - this._dirtyRowTracker.markDirty(0); break; case 3: // Clear scrollback (everything not in viewport) diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 81ab156b..af4b240f 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -320,7 +320,37 @@ export class Buffer implements IBuffer { if (toRemove.length > 0) { const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove); reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout); + + // For conpty, it has its own copy of the buffer _without scrollback_ internally. Its behavior + // when reflowing larger is to insert empty lines at the bottom of the buffer as when lines + // unwrap conpty's view cannot pull scrollback down, so it adds empty lines at the end. + let removedInViewport = 0; + const isWindowsMode = this._optionsService.rawOptions.windowsMode || this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined; + if (isWindowsMode) { + for (let i = (toRemove.length / 2) - 1; i >= 0; i--) { + if (toRemove[i * 2 + 0] > this.ybase + removedInViewport) { + removedInViewport += toRemove[i * 2 + 1]; + } + } + } + this._reflowLargerAdjustViewport(newCols, newRows, newLayoutResult.countRemoved); + + // Apply empty lines for any removed in viewport for conpty. + if (isWindowsMode) { + if (removedInViewport > 0) { + for (let i = 0; i < removedInViewport; i++) { + // Just add the new missing rows on Windows as conpty reprints the screen with it's + // view of the world. Once a line enters scrollback for conpty it remains there + this.lines.push(new BufferLine(newCols, this.getNullCell(DEFAULT_ATTR_DATA))); + } + if (this.ybase === this.ydisp) { + this.ydisp += removedInViewport; + } + this.ybase += removedInViewport; + this.y -= removedInViewport; + } + } } } @@ -352,7 +382,7 @@ export class Buffer implements IBuffer { const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); // Gather all BufferLines that need to be inserted into the Buffer here so that they can be // batched up and only committed once - const toInsert = []; + const toInsert: { start: number, newLines: IBufferLine[] }[] = []; let countToInsert = 0; // Go backwards as many lines may be trimmed and this will avoid considering them for (let y = this.lines.length - 1; y >= 0; y--) { @@ -467,6 +497,20 @@ export class Buffer implements IBuffer { this.savedY = Math.min(this.savedY + linesToAdd, this.ybase + newRows - 1); } + // For conpty, it has its own copy of the buffer _without scrollback_ internally. Its behavior + // when reflowing smaller is to reflow all lines inside the viewport, and removing empty or + // whitespace only lines from the bottom, until non-whitespace is hit in order to prevent + // content from being pushed into the scrollback. + let addedInViewport = 0; + const isWindowsMode = this._optionsService.rawOptions.windowsMode || this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined; + if (isWindowsMode) { + for (let i = toInsert.length - 1; i >= 0; i--) { + if (toInsert[i].start > this.ybase + addedInViewport) { + addedInViewport += toInsert[i].newLines.length; + } + } + } + // Rearrange lines in the buffer if there are any insertions, this is done at the end rather // than earlier so that it's a single O(n) pass through the buffer, instead of O(n^2) from many // costly calls to CircularList.splice. @@ -520,6 +564,35 @@ export class Buffer implements IBuffer { this.lines.onTrimEmitter.fire(amountToTrim); } } + + // Apply empty lines to remove calculated earlier for conpty. + if (isWindowsMode) { + if (addedInViewport > 0) { + let emptyLinesAtBottom = 0; + for (let i = this.lines.length - 1; i >= this.ybase + this.y; i--) { + const line = this.lines.get(i) as BufferLine; + if (line.isWrapped || line.getTrimmedLength() > 0) { + break; + } + emptyLinesAtBottom++; + } + const emptyLinesToRemove = Math.min(addedInViewport, emptyLinesAtBottom); + if (emptyLinesToRemove > 0) { + for (let i = 0; i < emptyLinesToRemove; i++) { + this.lines.pop(); + } + if (this.ybase === this.ydisp) { + this.ydisp -= emptyLinesToRemove; + } + this.ybase -= emptyLinesToRemove; + this.y += emptyLinesToRemove; + this.lines.onDeleteEmitter.fire({ + index: this.lines.length - emptyLinesToRemove, + amount: emptyLinesToRemove + }); + } + } + } } /** diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 86d9ca71..6ad48b93 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -32,6 +32,7 @@ export const DEFAULT_OPTIONS: Readonly> = { logLevel: 'info', logger: null, scrollback: 1000, + scrollOnEraseInDisplay: false, scrollOnUserInput: true, scrollSensitivity: 1, screenReaderMode: false, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index be1dec06..9c3aebf7 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -254,6 +254,7 @@ export interface ITerminalOptions { windowOptions?: IWindowOptions; wordSeparator?: string; overviewRuler?: IOverviewRulerOptions; + scrollOnEraseInDisplay?: boolean; [key: string]: any; cancelEvents: boolean; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 1085db9b..8d1facc7 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -186,6 +186,13 @@ declare module '@xterm/headless' { */ scrollback?: number; + /** + * If enabled the Erase in Display All (ED2) escape sequence will push + * erased text to scrollback, instead of clearing only the viewport portion. + * This emulates PuTTY's default clear screen behavior. + */ + scrollOnEraseInDisplay?: boolean; + /** * The scrolling speed multiplier used for adjusting normal scrolling speed. */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 0b3b3977..154ea57f 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -257,6 +257,13 @@ declare module '@xterm/xterm' { */ scrollback?: number; + /** + * If enabled the Erase in Display All (ED2) escape sequence will push + * erased text to scrollback, instead of clearing only the viewport portion. + * This emulates PuTTY's default clear screen behavior. + */ + scrollOnEraseInDisplay?: boolean; + /** * Whether to scroll to the bottom whenever there is some user input. The * default is true. diff --git a/yarn.lock b/yarn.lock index 5ed534e8..794c4f35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3296,10 +3296,10 @@ node-preload@^0.2.1: dependencies: process-on-spawn "^1.0.0" -node-pty@1.1.0-beta19: - version "1.1.0-beta19" - resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta19.tgz#a74dc04429903c5ac49ee81a15a24590da67d4f3" - integrity sha512-/p4Zu56EYDdXjjaLWzrIlFyrBnND11LQGP0/L6GEVGURfCNkAlHc3Twg/2I4NPxghimHXgvDlwp7Z2GtvDIh8A== +node-pty@^1.1.0-beta31: + version "1.1.0-beta9" + resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d" + integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw== dependencies: node-addon-api "^7.1.0"