UNPKG

@theia/output

Version:
367 lines (323 loc) • 16.1 kB
// ***************************************************************************** // 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 '../common/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'; @injectable() export class OutputChannelManager implements Disposable, ResourceResolver { @inject(MonacoTextModelService) protected readonly textModelService: MonacoTextModelService; @inject(OutputPreferences) 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 = this.preferences['output.maxChannelHistory']; 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. } }