// ***************************************************************************** // Copyright (C) 2023 STMicroelectronics and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { BaseWidget, Message, Widget, codicon, isFirefox } from '@theia/core/lib/browser'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Disposable, DisposableCollection } from '@theia/core'; import { TerminalPreferences } from '@theia/terminal/lib/common/terminal-preferences'; import { TerminalThemeService } from '@theia/terminal/lib/browser/terminal-theme-service'; import { TestOutputSource, TestOutputUIModel } from './test-output-ui-model'; import debounce = require('p-debounce'); @injectable() export class TestOutputWidget extends BaseWidget { @inject(TerminalPreferences) protected readonly preferences: TerminalPreferences; @inject(TerminalThemeService) protected readonly themeService: TerminalThemeService; @inject(TestOutputUIModel) protected readonly uiModel: TestOutputUIModel; static ID = 'test-output-view'; protected term: Terminal; protected disposeOnSetInput = new DisposableCollection(); protected fitAddon: FitAddon; constructor() { super(); this.id = TestOutputWidget.ID; this.title.label = 'Test Output'; this.title.caption = 'Test Output'; this.title.iconClass = codicon('symbol-keyword'); this.title.closable = true; } @postConstruct() init(): void { this.term = new Terminal({ disableStdin: true, cursorStyle: 'bar', fontFamily: this.preferences['terminal.integrated.fontFamily'], fontSize: this.preferences['terminal.integrated.fontSize'], fontWeight: this.preferences['terminal.integrated.fontWeight'], fontWeightBold: this.preferences['terminal.integrated.fontWeightBold'], drawBoldTextInBrightColors: this.preferences['terminal.integrated.drawBoldTextInBrightColors'], letterSpacing: this.preferences['terminal.integrated.letterSpacing'], lineHeight: this.preferences['terminal.integrated.lineHeight'], scrollback: this.preferences['terminal.integrated.scrollback'], fastScrollSensitivity: this.preferences['terminal.integrated.fastScrollSensitivity'], theme: this.themeService.theme }); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); this.setInput(this.uiModel.selectedOutputSource); this.uiModel.onDidChangeSelectedOutputSource(source => this.setInput(source)); this.toDispose.push(Disposable.create(() => this.term.dispose() )); } setInput(selectedOutputSource: TestOutputSource | undefined): void { this.disposeOnSetInput.dispose(); this.disposeOnSetInput = new DisposableCollection(); this.term.clear(); if (selectedOutputSource) { if (selectedOutputSource.output.length === 0 && selectedOutputSource.noOutputMessage) { this.term.writeln(selectedOutputSource.noOutputMessage); } else { selectedOutputSource.output.forEach(item => this.term.writeln(item.output)); } this.disposeOnSetInput.push(selectedOutputSource.onDidAddTestOutput(items => { items.forEach(item => this.term.writeln(item.output)); })); this.term.scrollToBottom(); } } protected override onAfterAttach(msg: Message): void { super.onAfterAttach(msg); this.term.open(this.node); if (isFirefox) { // monkey patching intersection observer handling for secondary window support // eslint-disable-next-line @typescript-eslint/no-explicit-any const renderService: any = (this.term as any)._core._renderService; const originalFunc: (entry: IntersectionObserverEntry) => void = renderService._onIntersectionChange.bind(renderService); const replacement = function (entry: IntersectionObserverEntry): void { if (entry.target.ownerDocument !== document) { // in Firefox, the intersection observer always reports the widget as non-intersecting if the dom element // is in a different document from when the IntersectionObserver started observing. Since we know // that the widget is always "visible" when in a secondary window, so we mark the entry as "intersecting" const patchedEvent: IntersectionObserverEntry = { ...entry, isIntersecting: true, }; originalFunc(patchedEvent); } else { originalFunc(entry); } }; renderService._onIntersectionChange = replacement; } if (isFirefox) { // The software scrollbars don't work with xterm.js, so we disable the scrollbar if we are on firefox. if (this.term.element) { (this.term.element.children.item(0) as HTMLElement).style.overflow = 'hidden'; } } } protected override onResize(msg: Widget.ResizeMessage): void { super.onResize(msg); this.resizeTerminal(); } protected resizeTerminal = debounce(() => this.doResizeTerminal(), 50); protected doResizeTerminal(): void { if (this.isDisposed) { return; } const geo = this.fitAddon.proposeDimensions(); if (geo) { const cols = geo.cols; const rows = geo.rows - 1; // subtract one row for margin this.term.resize(cols, rows); } } }