@theia/output
Version:
Theia - Output Extension
367 lines (323 loc) • 16.1 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { Resource, ResourceResolver } from '@theia/core/lib/common/resource';
import { Emitter, Event, Disposable, DisposableCollection } from '@theia/core';
import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
import { OutputUri } from '../common/output-uri';
import { OutputResource } from '../browser/output-resource';
import { OutputPreferences } from './output-preferences';
import { IReference } from '@theia/monaco-editor-core/esm/vs/base/common/lifecycle';
import * as monaco from '@theia/monaco-editor-core';
import PQueue from 'p-queue';
export class OutputChannelManager implements Disposable, ResourceResolver {
protected readonly textModelService: MonacoTextModelService;
protected readonly preferences: OutputPreferences;
protected readonly channels = new Map<string, OutputChannel>();
protected readonly resources = new Map<string, OutputResource>();
protected _selectedChannel: OutputChannel | undefined;
protected readonly channelAddedEmitter = new Emitter<{ name: string }>();
protected readonly channelDeletedEmitter = new Emitter<{ name: string }>();
protected readonly channelWasShownEmitter = new Emitter<{ name: string, preserveFocus?: boolean }>();
protected readonly channelWasHiddenEmitter = new Emitter<{ name: string }>();
protected readonly selectedChannelChangedEmitter = new Emitter<{ name: string } | undefined>();
readonly onChannelAdded = this.channelAddedEmitter.event;
readonly onChannelDeleted = this.channelDeletedEmitter.event;
readonly onChannelWasShown = this.channelWasShownEmitter.event;
readonly onChannelWasHidden = this.channelWasHiddenEmitter.event;
readonly onSelectedChannelChanged = this.selectedChannelChangedEmitter.event;
protected readonly toDispose = new DisposableCollection();
protected readonly toDisposeOnChannelDeletion = new Map<string, Disposable>();
getChannel(name: string): OutputChannel {
const existing = this.channels.get(name);
if (existing) {
return existing;
}
// We have to register the resource first, because `textModelService#createModelReference` will require it
// right after creating the monaco.editor.ITextModel.
// All `append` and `appendLine` will be deferred until the underlying text-model instantiation.
let resource = this.resources.get(name);
if (!resource) {
const uri = OutputUri.create(name);
const editorModelRef = new Deferred<IReference<MonacoEditorModel>>();
resource = this.createResource({ uri, editorModelRef });
this.resources.set(name, resource);
this.textModelService.createModelReference(uri).then(ref => editorModelRef.resolve(ref));
}
const channel = this.createChannel(resource);
this.channels.set(name, channel);
this.toDisposeOnChannelDeletion.set(name, this.registerListeners(channel));
if (!this.selectedChannel) {
this.selectedChannel = channel;
}
this.channelAddedEmitter.fire(channel);
return channel;
}
protected registerListeners(channel: OutputChannel): Disposable {
const { name } = channel;
return new DisposableCollection(
channel,
channel.onVisibilityChange(({ isVisible, preserveFocus }) => {
if (isVisible) {
this.selectedChannel = channel;
this.channelWasShownEmitter.fire({ name, preserveFocus });
} else {
if (channel === this.selectedChannel) {
this.selectedChannel = this.getVisibleChannels()[0];
}
this.channelWasHiddenEmitter.fire({ name });
}
}),
channel.onDisposed(() => this.deleteChannel(name)),
Disposable.create(() => {
const resource = this.resources.get(name);
if (resource) {
resource.dispose();
this.resources.delete(name);
} else {
console.warn(`Could not dispose. No resource was for output channel: '${name}'.`);
}
}),
Disposable.create(() => {
const toDispose = this.channels.get(name);
if (!toDispose) {
console.warn(`Could not dispose. No channel exist with name: '${name}'.`);
return;
}
this.channels.delete(name);
toDispose.dispose();
this.channelDeletedEmitter.fire({ name });
if (this.selectedChannel && this.selectedChannel.name === name) {
this.selectedChannel = this.getVisibleChannels()[0];
}
})
);
}
deleteChannel(name: string): void {
const toDispose = this.toDisposeOnChannelDeletion.get(name);
if (toDispose) {
toDispose.dispose();
}
}
getChannels(): OutputChannel[] {
return Array.from(this.channels.values()).sort(this.channelComparator);
}
getVisibleChannels(): OutputChannel[] {
return this.getChannels().filter(channel => channel.isVisible);
}
protected get channelComparator(): (left: OutputChannel, right: OutputChannel) => number {
return (left, right) => {
if (left.isVisible !== right.isVisible) {
return left.isVisible ? -1 : 1;
}
return left.name.toLocaleLowerCase().localeCompare(right.name.toLocaleLowerCase());
};
}
dispose(): void {
this.toDispose.dispose();
}
get selectedChannel(): OutputChannel | undefined {
return this._selectedChannel;
}
set selectedChannel(channel: OutputChannel | undefined) {
this._selectedChannel = channel;
if (this._selectedChannel) {
this.selectedChannelChangedEmitter.fire({ name: this._selectedChannel.name });
} else {
this.selectedChannelChangedEmitter.fire(undefined);
}
}
/**
* Non-API: do not call directly.
*/
async resolve(uri: URI): Promise<Resource> {
if (!OutputUri.is(uri)) {
throw new Error(`Expected '${OutputUri.SCHEME}' URI scheme. Got: ${uri} instead.`);
}
const resource = this.resources.get(OutputUri.channelName(uri));
if (!resource) {
throw new Error(`No output resource was registered with URI: ${uri.toString()}`);
}
return resource;
}
protected createResource({ uri, editorModelRef }: { uri: URI, editorModelRef: Deferred<IReference<MonacoEditorModel>> }): OutputResource {
return new OutputResource(uri, editorModelRef);
}
protected createChannel(resource: OutputResource): OutputChannel {
return new OutputChannel(resource, this.preferences);
}
}
export enum OutputChannelSeverity {
Error = 1,
Warning = 2,
Info = 3
}
export class OutputChannel implements Disposable {
protected readonly contentChangeEmitter = new Emitter<void>();
protected readonly visibilityChangeEmitter = new Emitter<{ isVisible: boolean, preserveFocus?: boolean }>();
protected readonly disposedEmitter = new Emitter<void>();
protected readonly textModifyQueue = new PQueue({ autoStart: true, concurrency: 1 });
protected readonly toDispose = new DisposableCollection(
Disposable.create(() => this.textModifyQueue.clear()),
this.contentChangeEmitter,
this.visibilityChangeEmitter,
this.disposedEmitter
);
protected disposed = false;
protected visible = true;
protected _maxLineNumber: number;
protected decorationIds = new Set<string>();
readonly onVisibilityChange: Event<{ isVisible: boolean, preserveFocus?: boolean }> = this.visibilityChangeEmitter.event;
readonly onContentChange: Event<void> = this.contentChangeEmitter.event;
readonly onDisposed: Event<void> = this.disposedEmitter.event;
constructor(protected readonly resource: OutputResource, protected readonly preferences: OutputPreferences) {
this._maxLineNumber = this.preferences['output.maxChannelHistory'];
this.toDispose.push(resource);
this.toDispose.push(Disposable.create(() => this.decorationIds.clear()));
this.toDispose.push(this.preferences.onPreferenceChanged(event => {
if (event.preferenceName === 'output.maxChannelHistory') {
const maxLineNumber = event.newValue;
if (this.maxLineNumber !== maxLineNumber) {
this.maxLineNumber = maxLineNumber;
}
}
}));
}
get name(): string {
return OutputUri.channelName(this.uri);
}
get uri(): URI {
return this.resource.uri;
}
hide(): void {
this.visible = false;
this.visibilityChangeEmitter.fire({ isVisible: this.isVisible });
}
/**
* If `preserveFocus` is `true`, the channel will not take focus. It is `false` by default.
* - Calling `show` without args or with `preserveFocus: false` will reveal **and** activate the `Output` widget.
* - Calling `show` with `preserveFocus: true` will reveal the `Output` widget but **won't** activate it.
*/
show({ preserveFocus }: { preserveFocus: boolean } = { preserveFocus: false }): void {
this.visible = true;
this.visibilityChangeEmitter.fire({ isVisible: this.isVisible, preserveFocus });
}
/**
* Note: if `false` it does not meant it is disposed or not available, it is only hidden from the UI.
*/
get isVisible(): boolean {
return this.visible;
}
clear(): void {
this.textModifyQueue.add(async () => {
const textModel = (await this.resource.editorModelRef.promise).object.textEditorModel;
textModel.deltaDecorations(Array.from(this.decorationIds), []);
this.decorationIds.clear();
textModel.setValue('');
this.contentChangeEmitter.fire();
});
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.toDispose.dispose();
this.disposedEmitter.fire();
}
append(content: string, severity: OutputChannelSeverity = OutputChannelSeverity.Info): void {
this.textModifyQueue.add(() => this.doAppend({ content, severity }));
}
appendLine(content: string, severity: OutputChannelSeverity = OutputChannelSeverity.Info): void {
this.textModifyQueue.add(() => this.doAppend({ content, severity, appendEol: true }));
}
protected async doAppend({ content, severity, appendEol }: { content: string, severity: OutputChannelSeverity, appendEol?: boolean }): Promise<void> {
const textModel = (await this.resource.editorModelRef.promise).object.textEditorModel;
const lastLine = textModel.getLineCount();
const lastLineMaxColumn = textModel.getLineMaxColumn(lastLine);
const position = new monaco.Position(lastLine, lastLineMaxColumn);
const range = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
const edits = [{
range,
text: !!appendEol ? `${content}${textModel.getEOL()}` : content,
forceMoveMarkers: true
}];
// We do not use `pushEditOperations` as we do not need undo/redo support. VS Code uses `applyEdits` too.
// https://github.com/microsoft/vscode/blob/dc348340fd1a6c583cb63a1e7e6b4fd657e01e01/src/vs/workbench/services/output/common/outputChannelModel.ts#L108-L115
textModel.applyEdits(edits);
if (severity !== OutputChannelSeverity.Info) {
const inlineClassName = severity === OutputChannelSeverity.Error ? 'theia-output-error' : 'theia-output-warning';
let endLineNumber = textModel.getLineCount();
// If last line is empty (the first non-whitespace is 0), apply decorator to previous line's last non-whitespace instead
// Note: if the user appends `inlineWarning `, the new decorator's range includes the trailing whitespace.
if (!textModel.getLineFirstNonWhitespaceColumn(endLineNumber)) {
endLineNumber--;
}
const endColumn = textModel.getLineLastNonWhitespaceColumn(endLineNumber);
const newDecorations = [{
range: new monaco.Range(range.startLineNumber, range.startColumn, endLineNumber, endColumn), options: {
inlineClassName
}
}];
for (const decorationId of textModel.deltaDecorations([], newDecorations)) {
this.decorationIds.add(decorationId);
}
}
this.ensureMaxChannelHistory(textModel);
this.contentChangeEmitter.fire();
}
protected ensureMaxChannelHistory(textModel: monaco.editor.ITextModel): void {
this.contentChangeEmitter.fire();
const linesToRemove = textModel.getLineCount() - this.maxLineNumber - 1; // -1 as the last line is usually empty -> `appendLine`.
if (linesToRemove > 0) {
const endColumn = textModel.getLineMaxColumn(linesToRemove);
// `endLineNumber` is `linesToRemove` + 1 as monaco is one based.
const range = new monaco.Range(1, 1, linesToRemove, endColumn + 1);
// eslint-disable-next-line no-null/no-null
const text = null;
const decorationsToRemove = textModel.getLinesDecorations(range.startLineNumber, range.endLineNumber)
.filter(({ id }) => this.decorationIds.has(id)).map(({ id }) => id); // Do we need to filter here? Who else can put decorations to the output model?
if (decorationsToRemove.length) {
for (const newId of textModel.deltaDecorations(decorationsToRemove, [])) {
this.decorationIds.add(newId);
}
for (const toRemoveId of decorationsToRemove) {
this.decorationIds.delete(toRemoveId);
}
}
textModel.applyEdits([
{
range: new monaco.Range(1, 1, linesToRemove + 1, textModel.getLineFirstNonWhitespaceColumn(linesToRemove + 1)),
text,
forceMoveMarkers: true
}
]);
}
}
protected get maxLineNumber(): number {
return this._maxLineNumber;
}
protected set maxLineNumber(maxLineNumber: number) {
this._maxLineNumber = maxLineNumber;
this.append(''); // will trigger an `ensureMaxChannelHistory` call and will refresh the content.
}
}