diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 684f881c..00000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM node:14-buster - -# Configure apt -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ - && apt-get -y install --no-install-recommends apt-utils 2>&1 - -# Verify git and process tools are installed -RUN apt-get install -y git procps - -# Install yarn -RUN apt-get install -y curl apt-transport-https lsb-release \ - && curl -sS https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/pubkey.gpg | apt-key add - 2>/dev/null \ - && echo "deb https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ - && apt-get update \ - && apt-get -y install --no-install-recommends \ - yarn - -# Clean up -RUN apt-get autoremove -y \ - && apt-get clean -y \ - && rm -rf /var/lib/apt/lists/* -ENV DEBIAN_FRONTEND=dialog diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9aec3a02..c09a29bf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,10 +1,20 @@ { "name": "xterm.js", - "dockerFile": "Dockerfile", - "appPort": 3000, - "extensions": [ - "dbaeumer.vscode-eslint", - "editorconfig.editorconfig", - "hbenl.vscode-mocha-test-adapter" - ] + "image": "mcr.microsoft.com/devcontainers/typescript-node:0-18-buster", + "features": { + "ghcr.io/devcontainers/features/node:1": {} // yarn + }, + "forwardPorts": [ + 3000 + ], + "postCreateCommand": "yarn install", + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig", + "hbenl.vscode-mocha-test-adapter" + ] + } + } } diff --git a/README.md b/README.md index 789628b4..852fa7b6 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**Super Terminal**](https://github.com/bugwheels94/super-terminal): It is a http based terminal for developers who dont like repetition and save time. - [**graSSHopper**](https://grasshopper.coding.kiwi): A simple SSH client with file explorer, history and many more features. - [**DomTerm**](https://domterm.org/xtermjs.html): Tiles and tabs. Detachable sessions (like tmux). [Remote connections](https://domterm.org/Remoting-over-ssh.html) using a nice ssh wrapper with predictive echo. Qt, Electron, Tauri/Wry, or desktop browser front-ends. Choose between xterm.js engine (faster) or native DomTerm (more functionality and graphics) - or both. +- [**Cloudtutor.io**](https://cloudtutor.io): innovative online learning platform that offers users access to an interactive lab. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Note: Please add any new contributions to the end of the list only. diff --git a/addons/xterm-addon-canvas/package.json b/addons/xterm-addon-canvas/package.json index e2833fde..d747795a 100644 --- a/addons/xterm-addon-canvas/package.json +++ b/addons/xterm-addon-canvas/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-canvas", - "version": "0.3.0", + "version": "0.4.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-search/package.json b/addons/xterm-addon-search/package.json index afc59d85..603a72d1 100644 --- a/addons/xterm-addon-search/package.json +++ b/addons/xterm-addon-search/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-search", - "version": "0.11.0", + "version": "0.12.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-serialize/package.json b/addons/xterm-addon-serialize/package.json index de1201a7..ef0c0a9f 100644 --- a/addons/xterm-addon-serialize/package.json +++ b/addons/xterm-addon-serialize/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-serialize", - "version": "0.9.0", + "version": "0.10.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-webgl/package.json b/addons/xterm-addon-webgl/package.json index e4e848a8..a6f00ef2 100644 --- a/addons/xterm-addon-webgl/package.json +++ b/addons/xterm-addon-webgl/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-webgl", - "version": "0.14.0", + "version": "0.15.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 1bc414d1..354f72c2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,10 +1,10 @@ pr: branches: - include: ["main", "v5"] + include: ["main"] trigger: branches: - include: ["main", "v5"] + include: ["main"] jobs: - job: Linux @@ -13,7 +13,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: YarnInstaller@3 inputs: @@ -46,7 +46,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: CacheBeta@1 inputs: @@ -66,7 +66,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: CacheBeta@1 inputs: @@ -95,7 +95,7 @@ jobs: displayName: Install required packages - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: YarnInstaller@3 inputs: @@ -111,11 +111,11 @@ jobs: # Integration tests are too flaky on macOS https://github.com/xtermjs/xterm.js/issues/3590 # - job: macOS_IntegrationTests # pool: -# vmImage: 'macOS-10.15' +# vmImage: 'macOS-11' # steps: # - task: NodeTool@0 # inputs: -# versionSpec: '14.x' +# versionSpec: '18.x' # displayName: 'Install Node.js' # - script: yarn --frozen-lockfile # displayName: 'Install dependencies and build' @@ -132,7 +132,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - script: yarn --frozen-lockfile displayName: 'Install dependencies and build' @@ -155,7 +155,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: YarnInstaller@3 inputs: diff --git a/demo/client.ts b/demo/client.ts index 4852ddf2..b5106aad 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -248,7 +248,11 @@ function createTerminal(): void { const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0; term = new Terminal({ allowProposedApi: true, - windowsMode: isWindows, + windowsPty: isWindows ? { + // In a real scenario, these values should be verified on the backend + backend: 'conpty', + buildNumber: 22621 + } : undefined, fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', theme: xtermjsTheme } as ITerminalOptions); diff --git a/package.json b/package.json index 1423cab0..0c1b6281 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xterm", "description": "Full xterm terminal, in your browser", - "version": "5.1.0", + "version": "5.2.1", "main": "lib/xterm.js", "style": "css/xterm.css", "types": "typings/xterm.d.ts", @@ -58,7 +58,7 @@ "@types/glob": "^7.2.0", "@types/jsdom": "^16.2.13", "@types/mocha": "^9.0.0", - "@types/node": "^14.14.44", + "@types/node": "^18.16.0", "@types/utf8": "^3.0.0", "@types/webpack": "^5.28.0", "@types/ws": "^8.2.0", diff --git a/src/browser/AccessibilityManager.ts b/src/browser/AccessibilityManager.ts index 42242fef..0a2cbf23 100644 --- a/src/browser/AccessibilityManager.ts +++ b/src/browser/AccessibilityManager.ts @@ -8,16 +8,32 @@ import { ITerminal, IRenderDebouncer } from 'browser/Types'; import { isMac } from 'common/Platform'; import { TimeBasedDebouncer } from 'browser/TimeBasedDebouncer'; import { Disposable, toDisposable } from 'common/Lifecycle'; +import { ScreenDprMonitor } from 'browser/ScreenDprMonitor'; +import { IRenderService } from 'browser/services/Services'; +import { addDisposableDomListener } from 'browser/Lifecycle'; const MAX_ROWS_TO_READ = 20; +const enum BoundaryPosition { + TOP, + BOTTOM +} + export class AccessibilityManager extends Disposable { private _accessibilityContainer: HTMLElement; + private _rowContainer: HTMLElement; + private _rowElements: HTMLElement[]; + private _liveRegion: HTMLElement; private _liveRegionLineCount: number = 0; private _liveRegionDebouncer: IRenderDebouncer; + private _screenDprMonitor: ScreenDprMonitor; + + private _topBoundaryFocusListener: (e: FocusEvent) => void; + private _bottomBoundaryFocusListener: (e: FocusEvent) => void; + /** * This queue has a character pushed to it for keys that are pressed, if the * next character added to the terminal is equal to the key char then it is @@ -32,12 +48,30 @@ export class AccessibilityManager extends Disposable { private _charsToAnnounce: string = ''; constructor( - private readonly _terminal: ITerminal + private readonly _terminal: ITerminal, + @IRenderService private readonly _renderService: IRenderService ) { super(); this._accessibilityContainer = document.createElement('div'); this._accessibilityContainer.classList.add('xterm-accessibility'); + this._rowContainer = document.createElement('div'); + this._rowContainer.setAttribute('role', 'list'); + this._rowContainer.classList.add('xterm-accessibility-tree'); + this._rowElements = []; + for (let i = 0; i < this._terminal.rows; i++) { + this._rowElements[i] = this._createAccessibilityTreeNode(); + this._rowContainer.appendChild(this._rowElements[i]); + } + + this._topBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.TOP); + this._bottomBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.BOTTOM); + this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + + this._refreshRowsDimensions(); + this._accessibilityContainer.appendChild(this._rowContainer); + this._liveRegion = document.createElement('div'); this._liveRegion.classList.add('live-region'); this._liveRegion.setAttribute('aria-live', 'assertive'); @@ -50,6 +84,7 @@ export class AccessibilityManager extends Disposable { this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer); this.register(this._liveRegionDebouncer); + this.register(this._terminal.onResize(e => this._handleResize(e.rows))); this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end))); this.register(this._terminal.onScroll(() => this._refreshRows())); // Line feed is an issue as the prompt won't be read out after a command is run @@ -58,7 +93,20 @@ export class AccessibilityManager extends Disposable { this.register(this._terminal.onA11yTab(spaceCount => this._handleTab(spaceCount))); this.register(this._terminal.onKey(e => this._handleKey(e.key))); this.register(this._terminal.onBlur(() => this._clearLiveRegion())); - this.register(toDisposable(() => this._accessibilityContainer.remove())); + this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions())); + + this._screenDprMonitor = new ScreenDprMonitor(window); + this.register(this._screenDprMonitor); + this._screenDprMonitor.setListener(() => this._refreshRowsDimensions()); + // This shouldn't be needed on modern browsers but is present in case the + // media query that drives the ScreenDprMonitor isn't supported + this.register(addDisposableDomListener(window, 'resize', () => this._refreshRowsDimensions())); + + this._refreshRows(); + this.register(toDisposable(() => { + this._accessibilityContainer.remove(); + this._rowElements.length = 0; + })); } private _handleTab(spaceCount: number): void { @@ -126,4 +174,107 @@ export class AccessibilityManager extends Disposable { this._liveRegion.textContent += this._charsToAnnounce; this._charsToAnnounce = ''; } + + private _handleBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void { + const boundaryElement = e.target as HTMLElement; + const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2]; + + // Don't scroll if the buffer top has reached the end in that direction + const posInSet = boundaryElement.getAttribute('aria-posinset'); + const lastRowPos = position === BoundaryPosition.TOP ? '1' : `${this._terminal.buffer.lines.length}`; + if (posInSet === lastRowPos) { + return; + } + + // Don't scroll when the last focused item was not the second row (focus is going the other + // direction) + if (e.relatedTarget !== beforeBoundaryElement) { + return; + } + + // Remove old boundary element from array + let topBoundaryElement: HTMLElement; + let bottomBoundaryElement: HTMLElement; + if (position === BoundaryPosition.TOP) { + topBoundaryElement = boundaryElement; + bottomBoundaryElement = this._rowElements.pop()!; + this._rowContainer.removeChild(bottomBoundaryElement); + } else { + topBoundaryElement = this._rowElements.shift()!; + bottomBoundaryElement = boundaryElement; + this._rowContainer.removeChild(topBoundaryElement); + } + + // Remove listeners from old boundary elements + topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); + bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); + + // Add new element to array/DOM + if (position === BoundaryPosition.TOP) { + const newElement = this._createAccessibilityTreeNode(); + this._rowElements.unshift(newElement); + this._rowContainer.insertAdjacentElement('afterbegin', newElement); + } else { + const newElement = this._createAccessibilityTreeNode(); + this._rowElements.push(newElement); + this._rowContainer.appendChild(newElement); + } + + // Add listeners to new boundary elements + this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + + // Scroll up + this._terminal.scrollLines(position === BoundaryPosition.TOP ? -1 : 1); + + // Focus new boundary before element + this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2].focus(); + + // Prevent the standard behavior + e.preventDefault(); + e.stopImmediatePropagation(); + } + + private _handleResize(rows: number): void { + // Remove bottom boundary listener + this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener); + + // Grow rows as required + for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) { + this._rowElements[i] = this._createAccessibilityTreeNode(); + this._rowContainer.appendChild(this._rowElements[i]); + } + // Shrink rows as required + while (this._rowElements.length > rows) { + this._rowContainer.removeChild(this._rowElements.pop()!); + } + + // Add bottom boundary listener + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + + this._refreshRowsDimensions(); + } + + private _createAccessibilityTreeNode(): HTMLElement { + const element = document.createElement('div'); + element.setAttribute('role', 'listitem'); + element.tabIndex = -1; + this._refreshRowDimensions(element); + return element; + } + private _refreshRowsDimensions(): void { + if (!this._renderService.dimensions.css.cell.height) { + return; + } + this._accessibilityContainer.style.width = `${this._renderService.dimensions.css.canvas.width}px`; + if (this._rowElements.length !== this._terminal.rows) { + this._handleResize(this._terminal.rows); + } + for (let i = 0; i < this._terminal.rows; i++) { + this._refreshRowDimensions(this._rowElements[i]); + } + } + private _refreshRowDimensions(element: HTMLElement): void { + element.style.height = `${this._renderService.dimensions.css.cell.height}px`; + } } diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index 13b6c0e6..745d759a 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -1041,6 +1041,47 @@ describe('Terminal', () => { }); }); + describe('Windows Pty', () => { + it('should mark lines as wrapped when the line ends in a non-null character after a LF', async () => { + const data = [ + 'aaaaaaaaaa\n\r', // cannot wrap as it's the first + 'aaaaaaaaa\n\r', // wrapped (windows mode only) + 'aaaaaaaaa' // not wrapped + ]; + + const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: {} }); + await normalTerminal.writeP(data.join('')); + assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false); + + const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: { backend: 'conpty', buildNumber: 19000 } }); + await windowsModeTerminal.writeP(data.join('')); + assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); + assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false); + }); + + it('should mark lines as wrapped when the line ends in a non-null character after a CUP', async () => { + const data = [ + 'aaaaaaaaaa\x1b[2;1H', // cannot wrap as it's the first + 'aaaaaaaaa\x1b[3;1H', // wrapped (windows mode only) + 'aaaaaaaaa' // not wrapped + ]; + + const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: {} }); + await normalTerminal.writeP(data.join('')); + assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false); + + const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: { backend: 'conpty', buildNumber: 19000 } }); + await windowsModeTerminal.writeP(data.join('')); + assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); + assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false); + }); + }); describe('Windows Mode', () => { it('should mark lines as wrapped when the line ends in a non-null character after a LF', async () => { const data = [ diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 9cee75a8..54711ed8 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -393,8 +393,37 @@ export class DomRenderer extends Disposable implements IRenderer { } private _setCellUnderline(x: number, x2: number, y: number, y2: number, cols: number, enabled: boolean): void { - x = this._cellToRowElements[y][x]; - x2 = this._cellToRowElements[y2][x2]; + /** + * NOTE: The linkifier may send out of viewport y-values if: + * - negative y-value: the link started at a higher line + * - y-value >= maxY: the link ends at a line below viewport + * + * For negative y-values we can simply adjust x = 0, + * as higher up link start means, that everything from + * (0,0) is a link under top-down-left-right char progression + * + * Additionally there might be a small chance of out-of-sync x|y-values + * from a race condition of render updates vs. link event handler execution: + * - (sync) resize: chances terminal buffer in sync, schedules render update async + * - (async) link handler race condition: new buffer metrics, but still on old render state + * - (async) render update: brings term metrics and render state back in sync + */ + if (y < 0) x = 0; + if (y2 < 0) x2 = 0; + + // avoid out-of-sync y-values, simply clamp into valid area + const maxY = this._cellToRowElements.length - 1; + y = Math.max(Math.min(y, maxY), 0); + y2 = Math.max(Math.min(y2, maxY), 0); + const elemY = this._cellToRowElements[y]; + const elemY2 = this._cellToRowElements[y2]; + if (x >= elemY.length || x2 >= elemY2.length) { + // avoid out-of-sync x-values + // simply exit early, gets fixed by the next render update + return; + } + x = elemY[x]; + x2 = elemY2[x2]; if (x === -1 || x2 === -1) { return; diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 33637421..8ff9b988 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -57,7 +57,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { protected _inputHandler: InputHandler; private _writeBuffer: WriteBuffer; - private _windowsMode: IDisposable | undefined; + private _windowsWrappingHeuristics: IDisposable | undefined; private readonly _onBinary = this.register(new EventEmitter()); public readonly onBinary = this._onBinary.event; @@ -131,7 +131,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(forwardEvent(this.coreService.onBinary, this._onBinary)); this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom())); this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput())); - this.register(this.optionsService.onSpecificOptionChange('windowsMode', e => this._handleWindowsModeOptionChange(e))); + this.register(this.optionsService.onMultipleOptionChange(['windowsMode', 'windowsPty'], () => this._handleWindowsPtyOptionChange())); this.register(this._bufferService.onScroll(event => { this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL }); this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom); @@ -146,8 +146,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(forwardEvent(this._writeBuffer.onWriteParsed, this._onWriteParsed)); this.register(toDisposable(() => { - this._windowsMode?.dispose(); - this._windowsMode = undefined; + this._windowsWrappingHeuristics?.dispose(); + this._windowsWrappingHeuristics = undefined; })); } @@ -250,9 +250,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { } protected _setup(): void { - if (this.optionsService.rawOptions.windowsMode) { - this._enableWindowsMode(); - } + this._handleWindowsPtyOptionChange(); } public reset(): void { @@ -263,30 +261,36 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.coreMouseService.reset(); } - private _handleWindowsModeOptionChange(value: boolean): void { + + private _handleWindowsPtyOptionChange(): void { + let value = false; + const windowsPty = this.optionsService.rawOptions.windowsPty; + if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) { + value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376); + } else if (this.optionsService.rawOptions.windowsMode) { + value = true; + } if (value) { - this._enableWindowsMode(); + this._enableWindowsWrappingHeuristics(); } else { - this._windowsMode?.dispose(); - this._windowsMode = undefined; + this._windowsWrappingHeuristics?.dispose(); + this._windowsWrappingHeuristics = undefined; } } - protected _enableWindowsMode(): void { - if (!this._windowsMode) { + protected _enableWindowsWrappingHeuristics(): void { + if (!this._windowsWrappingHeuristics) { const disposables: IDisposable[] = []; disposables.push(this.onLineFeed(updateWindowsModeWrappedState.bind(null, this._bufferService))); disposables.push(this.registerCsiHandler({ final: 'H' }, () => { updateWindowsModeWrappedState(this._bufferService); return false; })); - this._windowsMode = { - dispose: () => { - for (const d of disposables) { - d.dispose(); - } + this._windowsWrappingHeuristics = toDisposable(() => { + for (const d of disposables) { + d.dispose(); } - }; + }); } } } diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 858019ff..09a6a714 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -2222,7 +2222,7 @@ describe('InputHandler', () => { }); it('ANSI 2 (keyboard action mode)', async () => { await inputHandler.parseP('\x1b[2$p'); - assert.deepEqual(reportStack.pop(), '\x1b[2;3$y'); // always set + assert.deepEqual(reportStack.pop(), '\x1b[2;4$y'); // always reset }); it('ANSI 4 (insert mode)', async () => { await inputHandler.parseP('\x1b[4$p'); @@ -2236,7 +2236,7 @@ describe('InputHandler', () => { }); it('ANSI 12 (send/receive)', async () => { await inputHandler.parseP('\x1b[12$p'); - assert.deepEqual(reportStack.pop(), '\x1b[12;4$y'); // always reset + assert.deepEqual(reportStack.pop(), '\x1b[12;3$y'); // always set }); it('ANSI 20 (newline mode)', async () => { await inputHandler.parseP('\x1b[20$p'); @@ -2280,7 +2280,7 @@ describe('InputHandler', () => { }); it('DEC privates perma modes', async () => { // [mode number, state value] - const perma = [[3, 0], [8, 3], [1005, 4], [1015, 4], [1048, 1]]; + const perma = [[3, 0], [8, 3], [67, 4], [1005, 4], [1015, 4], [1048, 1]]; for (const [mode, value] of perma) { await inputHandler.parseP(`\x1b[?${mode}$p`); assert.deepEqual(reportStack.pop(), `\x1b[?${mode};${value}$y`); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index bb8f9e51..f17deb14 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1163,10 +1163,12 @@ export class InputHandler extends Disposable implements IInputHandler { * @param y row index */ private _resetBufferLine(y: number, respectProtect: boolean = false): void { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; - line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect); - this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); - line.isWrapped = false; + const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y); + if (line) { + line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect); + this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); + line.isWrapped = false; + } } /** @@ -2234,9 +2236,9 @@ export class InputHandler extends Disposable implements IInputHandler { const p = params.params[0]; if (ansi) { - if (p === 2) return f(p, V.PERMANENTLY_SET); + if (p === 2) return f(p, V.PERMANENTLY_RESET); if (p === 4) return f(p, b2v(cs.modes.insertMode)); - if (p === 12) return f(p, V.PERMANENTLY_RESET); + if (p === 12) return f(p, V.PERMANENTLY_SET); if (p === 20) return f(p, b2v(opts.convertEol)); return f(p, V.NOT_RECOGNIZED); } @@ -2251,6 +2253,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (p === 25) return f(p, b2v(!cs.isCursorHidden)); if (p === 45) return f(p, b2v(dm.reverseWraparound)); if (p === 66) return f(p, b2v(dm.applicationKeypad)); + if (p === 67) return f(p, V.PERMANENTLY_RESET); if (p === 1000) return f(p, b2v(mouseProtocol === 'VT200')); if (p === 1002) return f(p, b2v(mouseProtocol === 'DRAG')); if (p === 1003) return f(p, b2v(mouseProtocol === 'ANY')); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index cabc88fa..f32ce385 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -177,7 +177,7 @@ export class Buffer implements IBuffer { if (this._rows < newRows) { for (let y = this._rows; y < newRows; y++) { if (this.lines.length < newRows + this.ybase) { - if (this._optionsService.rawOptions.windowsMode) { + if (this._optionsService.rawOptions.windowsMode || this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { // 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, nullCell)); @@ -290,6 +290,10 @@ export class Buffer implements IBuffer { } private get _isReflowEnabled(): boolean { + const windowsPty = this._optionsService.rawOptions.windowsPty; + if (windowsPty && windowsPty.buildNumber) { + return this._hasScrollback && windowsPty.backend === 'conpty' && windowsPty.buildNumber >= 21376; + } return this._hasScrollback && !this._optionsService.rawOptions.windowsMode; } diff --git a/src/common/services/LogService.ts b/src/common/services/LogService.ts index 4b56a097..b297049e 100644 --- a/src/common/services/LogService.ts +++ b/src/common/services/LogService.ts @@ -33,7 +33,8 @@ const LOG_PREFIX = 'xterm.js: '; export class LogService extends Disposable implements ILogService { public serviceBrand: any; - public logLevel: LogLevelEnum = LogLevelEnum.OFF; + private _logLevel: LogLevelEnum = LogLevelEnum.OFF; + public get logLevel(): LogLevelEnum { return this._logLevel; } constructor( @IOptionsService private readonly _optionsService: IOptionsService @@ -44,7 +45,7 @@ export class LogService extends Disposable implements ILogService { } private _updateLogLevel(): void { - this.logLevel = optionsKeyToLogLevel[this._optionsService.rawOptions.logLevel]; + this._logLevel = optionsKeyToLogLevel[this._optionsService.rawOptions.logLevel]; } private _evalLazyOptionalParams(optionalParams: any[]): void { @@ -57,30 +58,30 @@ export class LogService extends Disposable implements ILogService { private _log(type: LogType, message: string, optionalParams: any[]): void { this._evalLazyOptionalParams(optionalParams); - type.call(console, LOG_PREFIX + message, ...optionalParams); + type.call(console, (this._optionsService.options.logger ? '' : LOG_PREFIX) + message, ...optionalParams); } public debug(message: string, ...optionalParams: any[]): void { - if (this.logLevel <= LogLevelEnum.DEBUG) { - this._log(console.log, message, optionalParams); + if (this._logLevel <= LogLevelEnum.DEBUG) { + this._log(this._optionsService.options.logger?.debug.bind(this._optionsService.options.logger) ?? console.log, message, optionalParams); } } public info(message: string, ...optionalParams: any[]): void { - if (this.logLevel <= LogLevelEnum.INFO) { - this._log(console.info, message, optionalParams); + if (this._logLevel <= LogLevelEnum.INFO) { + this._log(this._optionsService.options.logger?.info.bind(this._optionsService.options.logger) ?? console.info, message, optionalParams); } } public warn(message: string, ...optionalParams: any[]): void { - if (this.logLevel <= LogLevelEnum.WARN) { - this._log(console.warn, message, optionalParams); + if (this._logLevel <= LogLevelEnum.WARN) { + this._log(this._optionsService.options.logger?.warn.bind(this._optionsService.options.logger) ?? console.warn, message, optionalParams); } } public error(message: string, ...optionalParams: any[]): void { - if (this.logLevel <= LogLevelEnum.ERROR) { - this._log(console.error, message, optionalParams); + if (this._logLevel <= LogLevelEnum.ERROR) { + this._log(this._optionsService.options.logger?.error.bind(this._optionsService.options.logger) ?? console.error, message, optionalParams); } } } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 9709f2a7..63ea2f1c 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -27,6 +27,7 @@ export const DEFAULT_OPTIONS: Readonly> = { letterSpacing: 0, linkHandler: null, logLevel: 'info', + logger: null, scrollback: 1000, scrollOnUserInput: true, scrollSensitivity: 1, @@ -43,6 +44,7 @@ export const DEFAULT_OPTIONS: Readonly> = { rightClickSelectsWord: isMac, windowOptions: {}, windowsMode: false, + windowsPty: {}, wordSeparator: ' ()[]{}\',"`', altClickMovesCursor: true, convertEol: false, @@ -177,12 +179,16 @@ export class OptionsService extends Disposable implements IOptionsService { if (value <= 0) { throw new Error(`${key} cannot be less than or equal to 0, value: ${value}`); } + break; case 'rows': case 'cols': if (!value && value !== 0) { throw new Error(`${key} must be numeric, value: ${value}`); } break; + case 'windowsPty': + value = value ?? {}; + break; } return value; } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 3e5900e1..adc692de 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -7,7 +7,7 @@ import { IEvent, IEventEmitter } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColor, CursorStyle, IOscLinkData } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; -import { IDecorationOptions, IDecoration, ILinkHandler } from 'xterm'; +import { IDecorationOptions, IDecoration, ILinkHandler, IWindowsPty, ILogger } from 'xterm'; export const IBufferService = createDecorator('BufferService'); export interface IBufferService { @@ -157,7 +157,7 @@ export const ILogService = createDecorator('LogService'); export interface ILogService { serviceBrand: undefined; - logLevel: LogLevelEnum; + readonly logLevel: LogLevelEnum; debug(message: any, ...optionalParams: any[]): void; info(message: any, ...optionalParams: any[]): void; @@ -229,6 +229,7 @@ export interface ITerminalOptions { lineHeight?: number; linkHandler?: ILinkHandler | null; logLevel?: LogLevel; + logger?: ILogger | null; macOptionIsMeta?: boolean; macOptionClickForcesSelection?: boolean; minimumContrastRatio?: number; @@ -242,6 +243,7 @@ export interface ITerminalOptions { tabStopWidth?: number; theme?: ITheme; windowsMode?: boolean; + windowsPty?: IWindowsPty; windowOptions?: IWindowOptions; wordSeparator?: string; overviewRulerWidth?: number; diff --git a/src/headless/public/Terminal.test.ts b/src/headless/public/Terminal.test.ts index c7acc818..f869a991 100644 --- a/src/headless/public/Terminal.test.ts +++ b/src/headless/public/Terminal.test.ts @@ -22,7 +22,7 @@ describe('Headless API Tests', function (): void { it('Proposed API check', async () => { term = new Terminal({ allowProposedApi: false }); - throws(() => term.markers, (error) => error.message === 'You must set the allowProposedApi option to true to use proposed API'); + throws(() => term.markers, (error: any) => error.message === 'You must set the allowProposedApi option to true to use proposed API'); }); it('write', async () => { diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index eab3bb13..4633883b 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -107,6 +107,11 @@ declare module 'xterm-headless' { */ logLevel?: LogLevel; + /** + * A logger to use instead of `console`. + */ + logger?: ILogger | null; + /** * Whether to treat option as the meta key. */ @@ -183,9 +188,34 @@ declare module 'xterm-headless' { * - Reflow is disabled. * - Lines are assumed to be wrapped if the last character of the line is * not whitespace. + * + * When using conpty on Windows 11 version >= 21376, it is recommended to + * disable this because native text wrapping sequences are output correctly + * thanks to https://github.com/microsoft/terminal/issues/405 + * + * @deprecated Use {@link windowsPty}. This value will be ignored if + * windowsPty is set. */ windowsMode?: boolean; + /** + * Compatibility information when the pty is known to be hosted on Windows. + * Setting this will turn on certain heuristics/workarounds depending on the + * values: + * + * - `if (!!windowsCompat)` + * - When increasing the rows in the terminal, the amount increased into + * the scrollback. This is done because ConPTY does not behave like + * expect scrollback to come back into the viewport, instead it makes + * empty rows at of the viewport. Not having this behavior can result in + * missing data as the rows get replaced. + * - `if !(backend === 'conpty' && buildNumber >= 21376)` + * - Reflow is disabled + * - Lines are assumed to be wrapped if the last character of the line is + * not whitespace. + */ + windowsPty?: IWindowsPty; + /** * A string containing all characters that are considered word separated by the * double click to select work logic. @@ -265,6 +295,46 @@ declare module 'xterm-headless' { extendedAnsi?: string[]; } + /** + * Pty information for Windows. + */ + export interface IWindowsPty { + /** + * What pty emulation backend is being used. + */ + backend?: 'conpty' | 'winpty'; + /** + * The Windows build version (eg. 19045) + */ + buildNumber?: number; + } + + /** + * A replacement logger for `console`. + */ + export interface ILogger { + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * debug. + */ + debug(message: string, ...args: any[]): void; + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * info or below. + */ + info(message: string, ...args: any[]): void; + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * warn or below. + */ + warn(message: string, ...args: any[]): void; + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * error or below. + */ + error(message: string | Error, ...args: any[]): void; + } + /** * An object that can be disposed via a dispose function. */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 03e4f1d0..7bcf1ac4 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -152,6 +152,11 @@ declare module 'xterm' { */ logLevel?: LogLevel; + /** + * A logger to use instead of `console`. + */ + logger?: ILogger | null; + /** * Whether to treat option as the meta key. */ @@ -238,9 +243,30 @@ declare module 'xterm' { * When using conpty on Windows 11 version >= 21376, it is recommended to * disable this because native text wrapping sequences are output correctly * thanks to https://github.com/microsoft/terminal/issues/405 + * + * @deprecated Use {@link windowsPty}. This value will be ignored if + * windowsPty is set. */ windowsMode?: boolean; + /** + * Compatibility information when the pty is known to be hosted on Windows. + * Setting this will turn on certain heuristics/workarounds depending on the + * values: + * + * - `if (backend !== undefined || buildNumber !== undefined)` + * - When increasing the rows in the terminal, the amount increased into + * the scrollback. This is done because ConPTY does not behave like + * expect scrollback to come back into the viewport, instead it makes + * empty rows at of the viewport. Not having this behavior can result in + * missing data as the rows get replaced. + * - `if !(backend === 'conpty' && buildNumber >= 21376)` + * - Reflow is disabled + * - Lines are assumed to be wrapped if the last character of the line is + * not whitespace. + */ + windowsPty?: IWindowsPty; + /** * A string containing all characters that are considered word separated by the * double click to select work logic. @@ -330,6 +356,46 @@ declare module 'xterm' { extendedAnsi?: string[]; } + /** + * Pty information for Windows. + */ + export interface IWindowsPty { + /** + * What pty emulation backend is being used. + */ + backend?: 'conpty' | 'winpty'; + /** + * The Windows build version (eg. 19045) + */ + buildNumber?: number; + } + + /** + * A replacement logger for `console`. + */ + export interface ILogger { + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * debug. + */ + debug(message: string, ...args: any[]): void; + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * info or below. + */ + info(message: string, ...args: any[]): void; + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * warn or below. + */ + warn(message: string, ...args: any[]): void; + /** + * Log a debug message, this will only be called if {@link ITerminalOptions.logLevel} is set to + * error or below. + */ + error(message: string | Error, ...args: any[]): void; + } + /** * An object that can be disposed via a dispose function. */ diff --git a/yarn.lock b/yarn.lock index 22dd31e5..ecf9507e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,10 +425,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.50.tgz#14ba5198f1754ffd0472a2f84ab433b45ee0b65e" integrity sha512-+9axpWx2b2JCVovr7Ilgt96uc6C1zBKOQMpGtRbWT9IoR/8ue32GGMfGA4woP8QyP2gBs6GQWEVM3tCybGCxDA== -"@types/node@^14.14.44": - version "14.14.44" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215" - integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA== +"@types/node@^18.16.0": + version "18.16.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.16.tgz#3b64862856c7874ccf7439e6bab872d245c86d8e" + integrity sha512-NpaM49IGQQAUlBhHMF82QH80J08os4ZmyF9MkpCzWAGuOHqE4gTEbhzd7L3l5LmWuZ6E0OiC1FweQ4tsiW35+g== "@types/parse5@*": version "6.0.2"