diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 6ac1d410616fb5dd0a0f37df4145f4f0420ab1d6..a674809e8b0a8401d120a3d15089959939c4d87d 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -27,6 +27,7 @@ "./vs/workbench/services/files/node/watcher/**/*", "./vs/workbench/services/themes/**/*.ts", "./vs/workbench/services/bulkEdit/**/*.ts", + "./vs/workbench/services/output/**/*.ts", "./vs/workbench/services/progress/**/*.ts", "./vs/workbench/services/preferences/**/*.ts", "./vs/workbench/services/timer/**/*.ts", @@ -313,9 +314,8 @@ "./vs/workbench/contrib/output/common/output.ts", "./vs/workbench/contrib/output/common/outputLinkComputer.ts", "./vs/workbench/contrib/output/common/outputLinkProvider.ts", - "./vs/workbench/contrib/output/electron-browser/output.contribution.ts", - "./vs/workbench/contrib/output/electron-browser/outputServices.ts", - "./vs/workbench/contrib/output/node/outputAppender.ts", + "./vs/workbench/contrib/output/browser/output.contribution.ts", + "./vs/workbench/contrib/output/browser/outputServices.ts", "./vs/workbench/contrib/preferences/browser/preferencesActions.ts", "./vs/workbench/contrib/preferences/browser/preferencesWidgets.ts", "./vs/workbench/contrib/preferences/browser/settingsLayout.ts", diff --git a/src/vs/workbench/api/node/extHostOutputService.ts b/src/vs/workbench/api/node/extHostOutputService.ts index e009ffb6dcbba5d9bbef8607fdeef8f449f222cc..25ebdd8137d650c21633e604a39bdf815e1006e4 100644 --- a/src/vs/workbench/api/node/extHostOutputService.ts +++ b/src/vs/workbench/api/node/extHostOutputService.ts @@ -7,7 +7,7 @@ import { MainContext, MainThreadOutputServiceShape, IMainContext, ExtHostOutputS import * as vscode from 'vscode'; import { URI } from 'vs/base/common/uri'; import { join } from 'vs/base/common/path'; -import { OutputAppender } from 'vs/workbench/contrib/output/node/outputAppender'; +import { OutputAppender } from 'vs/workbench/services/output/node/outputAppender'; import { toLocalISOString } from 'vs/base/common/date'; import { Event, Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle'; diff --git a/src/vs/workbench/contrib/output/electron-browser/output.contribution.ts b/src/vs/workbench/contrib/output/browser/output.contribution.ts similarity index 99% rename from src/vs/workbench/contrib/output/electron-browser/output.contribution.ts rename to src/vs/workbench/contrib/output/browser/output.contribution.ts index 65e36ae6aab4869626b43fd8322d99265356c7a1..273ac9f397ddfdddf2d3c855f3a583d8f4434861 100644 --- a/src/vs/workbench/contrib/output/electron-browser/output.contribution.ts +++ b/src/vs/workbench/contrib/output/browser/output.contribution.ts @@ -10,7 +10,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { MenuId, MenuRegistry, SyncActionDescriptor, registerAction } from 'vs/platform/actions/common/actions'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IWorkbenchActionRegistry, Extensions as ActionExtensions } from 'vs/workbench/common/actions'; -import { OutputService, LogContentProvider } from 'vs/workbench/contrib/output/electron-browser/outputServices'; +import { OutputService, LogContentProvider } from 'vs/workbench/contrib/output/browser/outputServices'; import { ToggleOutputAction, ClearOutputAction, OpenLogOutputFile, ShowLogsOutputChannelAction, OpenOutputLogFileAction } from 'vs/workbench/contrib/output/browser/outputActions'; import { OUTPUT_MODE_ID, OUTPUT_MIME, OUTPUT_PANEL_ID, IOutputService, CONTEXT_IN_OUTPUT, LOG_SCHEME, LOG_MODE_ID, LOG_MIME, CONTEXT_ACTIVE_LOG_OUTPUT } from 'vs/workbench/contrib/output/common/output'; import { PanelRegistry, Extensions, PanelDescriptor } from 'vs/workbench/browser/panel'; diff --git a/src/vs/workbench/contrib/output/browser/outputServices.ts b/src/vs/workbench/contrib/output/browser/outputServices.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f545ac8384ab91c60d07517acbca435c9ee5dd3 --- /dev/null +++ b/src/vs/workbench/contrib/output/browser/outputServices.ts @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import { Event, Emitter } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; +import { IDisposable, dispose, Disposable } from 'vs/base/common/lifecycle'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { EditorOptions } from 'vs/workbench/common/editor'; +import { IOutputChannelDescriptor, IOutputChannel, IOutputService, Extensions, OUTPUT_PANEL_ID, IOutputChannelRegistry, OUTPUT_SCHEME, LOG_SCHEME, CONTEXT_ACTIVE_LOG_OUTPUT, LOG_MIME, OUTPUT_MIME } from 'vs/workbench/contrib/output/common/output'; +import { OutputPanel } from 'vs/workbench/contrib/output/browser/outputPanel'; +import { IPanelService } from 'vs/workbench/services/panel/common/panelService'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { OutputLinkProvider } from 'vs/workbench/contrib/output/common/outputLinkProvider'; +import { ITextModelService, ITextModelContentProvider } from 'vs/editor/common/services/resolverService'; +import { ITextModel } from 'vs/editor/common/model'; +import { IPanel } from 'vs/workbench/common/panel'; +import { ResourceEditorInput } from 'vs/workbench/common/editor/resourceEditorInput'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IWindowService } from 'vs/platform/windows/common/windows'; +import { ILogService } from 'vs/platform/log/common/log'; +import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IOutputChannelModel, IOutputChannelModelService } from 'vs/workbench/services/output/common/outputChannelModel'; + +const OUTPUT_ACTIVE_CHANNEL_KEY = 'output.activechannel'; + +class OutputChannel extends Disposable implements IOutputChannel { + + scrollLock: boolean = false; + readonly model: IOutputChannelModel; + readonly id: string; + readonly label: string; + + constructor( + readonly outputChannelDescriptor: IOutputChannelDescriptor, + @IOutputChannelModelService outputChannelModelService: IOutputChannelModelService + ) { + super(); + this.id = outputChannelDescriptor.id; + this.label = outputChannelDescriptor.label; + this.model = this._register(outputChannelModelService.createOutputChannelModel(this.id, URI.from({ scheme: OUTPUT_SCHEME, path: this.id }), outputChannelDescriptor.log ? LOG_MIME : OUTPUT_MIME, outputChannelDescriptor.file)); + } + + append(output: string): void { + this.model.append(output); + } + + update(): void { + this.model.update(); + } + + clear(till?: number): void { + this.model.clear(till); + } +} + +export class OutputService extends Disposable implements IOutputService, ITextModelContentProvider { + + public _serviceBrand: any; + + private channels: Map = new Map(); + private activeChannelIdInStorage: string; + private activeChannel: OutputChannel | null; + + private readonly _onActiveOutputChannel = new Emitter(); + readonly onActiveOutputChannel: Event = this._onActiveOutputChannel.event; + + private _outputPanel: OutputPanel; + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IPanelService private readonly panelService: IPanelService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @ITextModelService textModelResolverService: ITextModelService, + @IEnvironmentService environmentService: IEnvironmentService, + @IWindowService windowService: IWindowService, + @ILogService private readonly logService: ILogService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(); + this.activeChannelIdInStorage = this.storageService.get(OUTPUT_ACTIVE_CHANNEL_KEY, StorageScope.WORKSPACE, ''); + + // Register as text model content provider for output + textModelResolverService.registerTextModelContentProvider(OUTPUT_SCHEME, this); + instantiationService.createInstance(OutputLinkProvider); + + // Create output channels for already registered channels + const registry = Registry.as(Extensions.OutputChannels); + for (const channelIdentifier of registry.getChannels()) { + this.onDidRegisterChannel(channelIdentifier.id); + } + this._register(registry.onDidRegisterChannel(this.onDidRegisterChannel, this)); + + this._register(panelService.onDidPanelOpen(({ panel, focus }) => this.onDidPanelOpen(panel, !focus), this)); + this._register(panelService.onDidPanelClose(this.onDidPanelClose, this)); + + // Set active channel to first channel if not set + if (!this.activeChannel) { + const channels = this.getChannelDescriptors(); + this.activeChannel = channels && channels.length > 0 ? this.getChannel(channels[0].id) : null; + } + + this._register(this.lifecycleService.onShutdown(() => this.dispose())); + this._register(this.storageService.onWillSaveState(() => this.saveState())); + } + + provideTextContent(resource: URI): Promise | null { + const channel = this.getChannel(resource.path); + if (channel) { + return channel.model.loadModel(); + } + return null; + } + + showChannel(id: string, preserveFocus?: boolean): Promise { + const channel = this.getChannel(id); + if (!channel || this.isChannelShown(channel)) { + if (this._outputPanel && !preserveFocus) { + this._outputPanel.focus(); + } + return Promise.resolve(undefined); + } + + this.activeChannel = channel; + let promise: Promise; + if (this.isPanelShown()) { + promise = this.doShowChannel(channel, !!preserveFocus); + } else { + this.panelService.openPanel(OUTPUT_PANEL_ID); + promise = this.doShowChannel(this.activeChannel, !!preserveFocus); + } + return promise.then(() => this._onActiveOutputChannel.fire(id)); + } + + getChannel(id: string): OutputChannel | null { + return this.channels.get(id) || null; + } + + getChannelDescriptors(): IOutputChannelDescriptor[] { + return Registry.as(Extensions.OutputChannels).getChannels(); + } + + getActiveChannel(): IOutputChannel | null { + return this.activeChannel; + } + + private onDidRegisterChannel(channelId: string): void { + const channel = this.createChannel(channelId); + this.channels.set(channelId, channel); + if (this.activeChannelIdInStorage === channelId) { + this.activeChannel = channel; + this.onDidPanelOpen(this.panelService.getActivePanel(), true) + .then(() => this._onActiveOutputChannel.fire(channelId)); + } + } + + private onDidPanelOpen(panel: IPanel | null, preserveFocus: boolean): Promise { + if (panel && panel.getId() === OUTPUT_PANEL_ID) { + this._outputPanel = this.panelService.getActivePanel(); + if (this.activeChannel) { + return this.doShowChannel(this.activeChannel, preserveFocus); + } + } + return Promise.resolve(undefined); + } + + private onDidPanelClose(panel: IPanel): void { + if (this._outputPanel && panel.getId() === OUTPUT_PANEL_ID) { + CONTEXT_ACTIVE_LOG_OUTPUT.bindTo(this.contextKeyService).set(false); + this._outputPanel.clearInput(); + } + } + + private createChannel(id: string): OutputChannel { + const channelDisposables: IDisposable[] = []; + const channel = this.instantiateChannel(id); + channel.model.onDidAppendedContent(() => { + if (!channel.scrollLock) { + const panel = this.panelService.getActivePanel(); + if (panel && panel.getId() === OUTPUT_PANEL_ID && this.isChannelShown(channel)) { + let outputPanel = panel; + outputPanel.revealLastLine(); + } + } + }, channelDisposables); + channel.model.onDispose(() => { + if (this.activeChannel === channel) { + const channels = this.getChannelDescriptors(); + const channel = channels.length ? this.getChannel(channels[0].id) : null; + if (channel && this.isPanelShown()) { + this.showChannel(channel.id, true); + } else { + this.activeChannel = channel; + if (this.activeChannel) { + this._onActiveOutputChannel.fire(this.activeChannel.id); + } + } + } + Registry.as(Extensions.OutputChannels).removeChannel(id); + dispose(channelDisposables); + }, channelDisposables); + + return channel; + } + + private instantiateChannel(id: string): OutputChannel { + const channelData = Registry.as(Extensions.OutputChannels).getChannel(id); + if (!channelData) { + this.logService.error(`Channel '${id}' is not registered yet`); + throw new Error(`Channel '${id}' is not registered yet`); + } + return this.instantiationService.createInstance(OutputChannel, channelData); + } + + private doShowChannel(channel: OutputChannel, preserveFocus: boolean): Promise { + if (this._outputPanel) { + CONTEXT_ACTIVE_LOG_OUTPUT.bindTo(this.contextKeyService).set(!!channel.outputChannelDescriptor.file && channel.outputChannelDescriptor.log); + return this._outputPanel.setInput(this.createInput(channel), EditorOptions.create({ preserveFocus }), CancellationToken.None) + .then(() => { + if (!preserveFocus) { + this._outputPanel.focus(); + } + }); + } + return Promise.resolve(undefined); + } + + private isChannelShown(channel: IOutputChannel): boolean { + return this.isPanelShown() && this.activeChannel === channel; + } + + private isPanelShown(): boolean { + const panel = this.panelService.getActivePanel(); + return !!panel && panel.getId() === OUTPUT_PANEL_ID; + } + + private createInput(channel: IOutputChannel): ResourceEditorInput { + const resource = URI.from({ scheme: OUTPUT_SCHEME, path: channel.id }); + return this.instantiationService.createInstance(ResourceEditorInput, nls.localize('output', "{0} - Output", channel.label), nls.localize('channel', "Output channel for '{0}'", channel.label), resource); + } + + private saveState(): void { + if (this.activeChannel) { + this.storageService.store(OUTPUT_ACTIVE_CHANNEL_KEY, this.activeChannel.id, StorageScope.WORKSPACE); + } + } +} + +export class LogContentProvider { + + private channelModels: Map = new Map(); + + constructor( + @IOutputService private readonly outputService: IOutputService, + @IOutputChannelModelService private readonly outputChannelModelService: IOutputChannelModelService + ) { + } + + provideTextContent(resource: URI): Promise | null { + if (resource.scheme === LOG_SCHEME) { + let channelModel = this.getChannelModel(resource); + if (channelModel) { + return channelModel.loadModel(); + } + } + return null; + } + + private getChannelModel(resource: URI): IOutputChannelModel | undefined { + const channelId = resource.path; + let channelModel = this.channelModels.get(channelId); + if (!channelModel) { + const channelDisposables: IDisposable[] = []; + const outputChannelDescriptor = this.outputService.getChannelDescriptors().filter(({ id }) => id === channelId)[0]; + if (outputChannelDescriptor && outputChannelDescriptor.file) { + channelModel = this.outputChannelModelService.createOutputChannelModel(channelId, resource, outputChannelDescriptor.log ? LOG_MIME : OUTPUT_MIME, outputChannelDescriptor.file); + channelModel.onDispose(() => dispose(channelDisposables), channelDisposables); + this.channelModels.set(channelId, channelModel); + } + } + return channelModel; + } +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/output/electron-browser/outputServices.ts b/src/vs/workbench/contrib/output/electron-browser/outputServices.ts deleted file mode 100644 index 60db5a4544de134d2e70f81cf407ce4848c9a8b8..0000000000000000000000000000000000000000 --- a/src/vs/workbench/contrib/output/electron-browser/outputServices.ts +++ /dev/null @@ -1,813 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as nls from 'vs/nls'; -import { join, dirname } from 'vs/base/common/path'; -import * as strings from 'vs/base/common/strings'; -import * as extfs from 'vs/base/node/extfs'; -import { Event, Emitter } from 'vs/base/common/event'; -import { URI } from 'vs/base/common/uri'; -import { IDisposable, dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; -import { Registry } from 'vs/platform/registry/common/platform'; -import { EditorOptions } from 'vs/workbench/common/editor'; -import { IOutputChannelDescriptor, IOutputChannel, IOutputService, Extensions, OUTPUT_PANEL_ID, IOutputChannelRegistry, OUTPUT_SCHEME, OUTPUT_MIME, LOG_SCHEME, LOG_MIME, CONTEXT_ACTIVE_LOG_OUTPUT, MAX_OUTPUT_LENGTH, IFileOutputChannelDescriptor } from 'vs/workbench/contrib/output/common/output'; -import { OutputPanel } from 'vs/workbench/contrib/output/browser/outputPanel'; -import { IPanelService } from 'vs/workbench/services/panel/common/panelService'; -import { IModelService } from 'vs/editor/common/services/modelService'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { OutputLinkProvider } from 'vs/workbench/contrib/output/common/outputLinkProvider'; -import { ITextModelService, ITextModelContentProvider } from 'vs/editor/common/services/resolverService'; -import { ITextModel } from 'vs/editor/common/model'; -import { IModeService } from 'vs/editor/common/services/modeService'; -import { RunOnceScheduler, ThrottledDelayer } from 'vs/base/common/async'; -import { EditOperation } from 'vs/editor/common/core/editOperation'; -import { Position } from 'vs/editor/common/core/position'; -import { IFileService } from 'vs/platform/files/common/files'; -import { IPanel } from 'vs/workbench/common/panel'; -import { ResourceEditorInput } from 'vs/workbench/common/editor/resourceEditorInput'; -import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -import { toLocalISOString } from 'vs/base/common/date'; -import { IWindowService } from 'vs/platform/windows/common/windows'; -import { ILogService } from 'vs/platform/log/common/log'; -import { binarySearch } from 'vs/base/common/arrays'; -import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { OutputAppender } from 'vs/workbench/contrib/output/node/outputAppender'; -import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { isNumber } from 'vs/base/common/types'; - -const OUTPUT_ACTIVE_CHANNEL_KEY = 'output.activechannel'; - -let watchingOutputDir = false; -let callbacks: ((eventType: string, fileName?: string) => void)[] = []; -function watchOutputDirectory(outputDir: string, logService: ILogService, onChange: (eventType: string, fileName: string) => void): IDisposable { - callbacks.push(onChange); - if (!watchingOutputDir) { - const watcherDisposable = extfs.watch(outputDir, (eventType, fileName) => { - for (const callback of callbacks) { - callback(eventType, fileName); - } - }, (error: string) => { - logService.error(error); - }); - watchingOutputDir = true; - return toDisposable(() => { - callbacks = []; - watcherDisposable.dispose(); - }); - } - return toDisposable(() => { }); -} - -interface OutputChannel extends IOutputChannel { - readonly file: URI | null; - readonly onDidAppendedContent: Event; - readonly onDispose: Event; - loadModel(): Promise; -} - -abstract class AbstractFileOutputChannel extends Disposable implements OutputChannel { - - scrollLock: boolean = false; - - protected _onDidAppendedContent = new Emitter(); - readonly onDidAppendedContent: Event = this._onDidAppendedContent.event; - - protected _onDispose = new Emitter(); - readonly onDispose: Event = this._onDispose.event; - - private readonly mimeType: string; - protected modelUpdater: RunOnceScheduler; - protected model: ITextModel | null; - readonly file: URI; - - protected startOffset: number = 0; - protected endOffset: number = 0; - - constructor( - readonly outputChannelDescriptor: IFileOutputChannelDescriptor, - private readonly modelUri: URI, - protected fileService: IFileService, - protected modelService: IModelService, - protected modeService: IModeService, - ) { - super(); - this.mimeType = outputChannelDescriptor.log ? LOG_MIME : OUTPUT_MIME; - this.file = this.outputChannelDescriptor.file; - this.modelUpdater = new RunOnceScheduler(() => this.updateModel(), 300); - this._register(toDisposable(() => this.modelUpdater.cancel())); - } - - get id(): string { - return this.outputChannelDescriptor.id; - } - - get label(): string { - return this.outputChannelDescriptor.label; - } - - clear(till?: number): void { - if (this.modelUpdater.isScheduled()) { - this.modelUpdater.cancel(); - this.onUpdateModelCancelled(); - } - if (this.model) { - this.model.setValue(''); - } - this.endOffset = isNumber(till) ? till : this.endOffset; - this.startOffset = this.endOffset; - } - - update(): void { } - - protected createModel(content: string): ITextModel { - if (this.model) { - this.model.setValue(content); - } else { - this.model = this.modelService.createModel(content, this.modeService.create(this.mimeType), this.modelUri); - this.onModelCreated(this.model); - const disposables: IDisposable[] = []; - disposables.push(this.model.onWillDispose(() => { - this.onModelWillDispose(this.model); - this.model = null; - dispose(disposables); - })); - } - return this.model; - } - - appendToModel(content: string): void { - if (this.model && content) { - const lastLine = this.model.getLineCount(); - const lastLineMaxColumn = this.model.getLineMaxColumn(lastLine); - this.model.applyEdits([EditOperation.insert(new Position(lastLine, lastLineMaxColumn), content)]); - this._onDidAppendedContent.fire(); - } - } - - abstract loadModel(): Promise; - abstract append(message: string); - - protected onModelCreated(model: ITextModel) { } - protected onModelWillDispose(model: ITextModel | null) { } - protected onUpdateModelCancelled() { } - protected updateModel() { } - - dispose(): void { - this._onDispose.fire(); - super.dispose(); - } -} - -/** - * An output channel that stores appended messages in a backup file. - */ -class OutputChannelBackedByFile extends AbstractFileOutputChannel implements OutputChannel { - - private appender: OutputAppender; - private appendedMessage = ''; - private loadingFromFileInProgress: boolean = false; - private resettingDelayer: ThrottledDelayer; - private readonly rotatingFilePath: string; - - constructor( - outputChannelDescriptor: IFileOutputChannelDescriptor, - modelUri: URI, - @IFileService fileService: IFileService, - @IModelService modelService: IModelService, - @IModeService modeService: IModeService, - @ILogService logService: ILogService - ) { - super(outputChannelDescriptor, modelUri, fileService, modelService, modeService); - - // Use one rotating file to check for main file reset - this.appender = new OutputAppender(this.id, this.file.fsPath); - this.rotatingFilePath = `${outputChannelDescriptor.id}.1.log`; - this._register(watchOutputDirectory(dirname(this.file.fsPath), logService, (eventType, file) => this.onFileChangedInOutputDirector(eventType, file))); - - this.resettingDelayer = new ThrottledDelayer(50); - } - - append(message: string): void { - // update end offset always as message is read - this.endOffset = this.endOffset + Buffer.from(message).byteLength; - if (this.loadingFromFileInProgress) { - this.appendedMessage += message; - } else { - this.write(message); - if (this.model) { - this.appendedMessage += message; - if (!this.modelUpdater.isScheduled()) { - this.modelUpdater.schedule(); - } - } - } - } - - clear(till?: number): void { - super.clear(till); - this.appendedMessage = ''; - } - - loadModel(): Promise { - this.loadingFromFileInProgress = true; - if (this.modelUpdater.isScheduled()) { - this.modelUpdater.cancel(); - } - this.appendedMessage = ''; - return this.loadFile() - .then(content => { - if (this.endOffset !== this.startOffset + Buffer.from(content).byteLength) { - // Queue content is not written into the file - // Flush it and load file again - this.flush(); - return this.loadFile(); - } - return content; - }) - .then(content => { - if (this.appendedMessage) { - this.write(this.appendedMessage); - this.appendedMessage = ''; - } - this.loadingFromFileInProgress = false; - return this.createModel(content); - }); - } - - private resetModel(): Promise { - this.startOffset = 0; - this.endOffset = 0; - if (this.model) { - return this.loadModel().then(() => undefined); - } - return Promise.resolve(undefined); - } - - private loadFile(): Promise { - return this.fileService.resolveContent(this.file, { position: this.startOffset, encoding: 'utf8' }) - .then(content => this.appendedMessage ? content.value + this.appendedMessage : content.value); - } - - protected updateModel(): void { - if (this.model && this.appendedMessage) { - this.appendToModel(this.appendedMessage); - this.appendedMessage = ''; - } - } - - private onFileChangedInOutputDirector(eventType: string, fileName?: string): void { - // Check if rotating file has changed. It changes only when the main file exceeds its limit. - if (this.rotatingFilePath === fileName) { - this.resettingDelayer.trigger(() => this.resetModel()); - } - } - - private write(content: string): void { - this.appender.append(content); - } - - private flush(): void { - this.appender.flush(); - } -} - -class OutputFileListener extends Disposable { - - private readonly _onDidContentChange = new Emitter(); - readonly onDidContentChange: Event = this._onDidContentChange.event; - - private watching: boolean = false; - private syncDelayer: ThrottledDelayer; - private etag: string | undefined; - - constructor( - private readonly file: URI, - private readonly fileService: IFileService - ) { - super(); - this.syncDelayer = new ThrottledDelayer(500); - } - - watch(eTag: string | undefined): void { - if (!this.watching) { - this.etag = eTag; - this.poll(); - this.watching = true; - } - } - - private poll(): void { - const loop = () => this.doWatch().then(() => this.poll()); - this.syncDelayer.trigger(loop); - } - - private doWatch(): Promise { - return this.fileService.resolveFile(this.file) - .then(stat => { - if (stat.etag !== this.etag) { - this.etag = stat.etag; - this._onDidContentChange.fire(stat.size); - } - }); - } - - unwatch(): void { - if (this.watching) { - this.syncDelayer.cancel(); - this.watching = false; - } - } - - dispose(): void { - this.unwatch(); - super.dispose(); - } -} - -/** - * An output channel driven by a file and does not support appending messages. - */ -class FileOutputChannel extends AbstractFileOutputChannel implements OutputChannel { - - private readonly fileHandler: OutputFileListener; - - private updateInProgress: boolean = false; - private etag: string | undefined = ''; - private loadModelPromise: Promise | null = null; - - constructor( - outputChannelDescriptor: IFileOutputChannelDescriptor, - modelUri: URI, - @IFileService fileService: IFileService, - @IModelService modelService: IModelService, - @IModeService modeService: IModeService - ) { - super(outputChannelDescriptor, modelUri, fileService, modelService, modeService); - - this.fileHandler = this._register(new OutputFileListener(this.file, this.fileService)); - this._register(this.fileHandler.onDidContentChange(size => this.update(size))); - this._register(toDisposable(() => this.fileHandler.unwatch())); - } - - loadModel(): Promise { - this.loadModelPromise = this.fileService.resolveContent(this.file, { position: this.startOffset, encoding: 'utf8' }) - .then(content => { - this.endOffset = this.startOffset + Buffer.from(content.value).byteLength; - this.etag = content.etag; - return this.createModel(content.value); - }); - return this.loadModelPromise; - } - - clear(till?: number): void { - const loadModelPromise: Promise = this.loadModelPromise ? this.loadModelPromise : Promise.resolve(); - loadModelPromise.then(() => { - super.clear(till); - this.update(); - }); - } - - append(message: string): void { - throw new Error('Not supported'); - } - - protected updateModel(): void { - if (this.model) { - this.fileService.resolveContent(this.file, { position: this.endOffset, encoding: 'utf8' }) - .then(content => { - this.etag = content.etag; - if (content.value) { - this.endOffset = this.endOffset + Buffer.from(content.value).byteLength; - this.appendToModel(content.value); - } - this.updateInProgress = false; - }, () => this.updateInProgress = false); - } else { - this.updateInProgress = false; - } - } - - protected onModelCreated(model: ITextModel): void { - this.fileHandler.watch(this.etag); - } - - protected onModelWillDispose(model: ITextModel | null): void { - this.fileHandler.unwatch(); - } - - protected onUpdateModelCancelled(): void { - this.updateInProgress = false; - } - - update(size?: number): void { - if (this.model) { - if (!this.updateInProgress) { - this.updateInProgress = true; - if (isNumber(size) && this.endOffset > size) { // Reset - Content is removed - this.startOffset = this.endOffset = 0; - this.model.setValue(''); - } - this.modelUpdater.schedule(); - } - } - } -} - -export class OutputService extends Disposable implements IOutputService, ITextModelContentProvider { - - public _serviceBrand: any; - - private channels: Map = new Map(); - private activeChannelIdInStorage: string; - private activeChannel: IOutputChannel | null; - private readonly outputDir: string; - - private readonly _onActiveOutputChannel = new Emitter(); - readonly onActiveOutputChannel: Event = this._onActiveOutputChannel.event; - - private _outputPanel: OutputPanel; - - constructor( - @IStorageService private readonly storageService: IStorageService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IPanelService private readonly panelService: IPanelService, - @IWorkspaceContextService contextService: IWorkspaceContextService, - @ITextModelService textModelResolverService: ITextModelService, - @IEnvironmentService environmentService: IEnvironmentService, - @IWindowService windowService: IWindowService, - @ILogService private readonly logService: ILogService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @ILifecycleService private readonly lifecycleService: ILifecycleService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - ) { - super(); - this.activeChannelIdInStorage = this.storageService.get(OUTPUT_ACTIVE_CHANNEL_KEY, StorageScope.WORKSPACE, ''); - this.outputDir = join(environmentService.logsPath, `output_${windowService.getCurrentWindowId()}_${toLocalISOString(new Date()).replace(/-|:|\.\d+Z$/g, '')}`); - - // Register as text model content provider for output - textModelResolverService.registerTextModelContentProvider(OUTPUT_SCHEME, this); - instantiationService.createInstance(OutputLinkProvider); - - // Create output channels for already registered channels - const registry = Registry.as(Extensions.OutputChannels); - for (const channelIdentifier of registry.getChannels()) { - this.onDidRegisterChannel(channelIdentifier.id); - } - this._register(registry.onDidRegisterChannel(this.onDidRegisterChannel, this)); - - this._register(panelService.onDidPanelOpen(({ panel, focus }) => this.onDidPanelOpen(panel, !focus), this)); - this._register(panelService.onDidPanelClose(this.onDidPanelClose, this)); - - // Set active channel to first channel if not set - if (!this.activeChannel) { - const channels = this.getChannelDescriptors(); - this.activeChannel = channels && channels.length > 0 ? this.getChannel(channels[0].id) : null; - } - - this._register(this.lifecycleService.onShutdown(() => this.dispose())); - this._register(this.storageService.onWillSaveState(() => this.saveState())); - } - - provideTextContent(resource: URI): Promise | null { - const channel = this.getChannel(resource.path); - if (channel) { - return channel.loadModel(); - } - return null; - } - - showChannel(id: string, preserveFocus?: boolean): Promise { - const channel = this.getChannel(id); - if (!channel || this.isChannelShown(channel)) { - if (this._outputPanel && !preserveFocus) { - this._outputPanel.focus(); - } - return Promise.resolve(undefined); - } - - this.activeChannel = channel; - let promise: Promise; - if (this.isPanelShown()) { - promise = this.doShowChannel(channel, !!preserveFocus); - } else { - this.panelService.openPanel(OUTPUT_PANEL_ID); - promise = this.doShowChannel(this.activeChannel, !!preserveFocus); - } - return promise.then(() => this._onActiveOutputChannel.fire(id)); - } - - getChannel(id: string): IOutputChannel | null { - return this.channels.get(id) || null; - } - - getChannelDescriptors(): IOutputChannelDescriptor[] { - return Registry.as(Extensions.OutputChannels).getChannels(); - } - - getActiveChannel(): IOutputChannel | null { - return this.activeChannel; - } - - private onDidRegisterChannel(channelId: string): void { - const channel = this.createChannel(channelId); - this.channels.set(channelId, channel); - if (this.activeChannelIdInStorage === channelId) { - this.activeChannel = channel; - this.onDidPanelOpen(this.panelService.getActivePanel(), true) - .then(() => this._onActiveOutputChannel.fire(channelId)); - } - } - - private onDidPanelOpen(panel: IPanel | null, preserveFocus: boolean): Promise { - if (panel && panel.getId() === OUTPUT_PANEL_ID) { - this._outputPanel = this.panelService.getActivePanel(); - if (this.activeChannel) { - return this.doShowChannel(this.activeChannel, preserveFocus); - } - } - return Promise.resolve(undefined); - } - - private onDidPanelClose(panel: IPanel): void { - if (this._outputPanel && panel.getId() === OUTPUT_PANEL_ID) { - CONTEXT_ACTIVE_LOG_OUTPUT.bindTo(this.contextKeyService).set(false); - this._outputPanel.clearInput(); - } - } - - private createChannel(id: string): OutputChannel { - const channelDisposables: IDisposable[] = []; - const channel = this.instantiateChannel(id); - channel.onDidAppendedContent(() => { - if (!channel.scrollLock) { - const panel = this.panelService.getActivePanel(); - if (panel && panel.getId() === OUTPUT_PANEL_ID && this.isChannelShown(channel)) { - let outputPanel = panel; - outputPanel.revealLastLine(); - } - } - }, channelDisposables); - channel.onDispose(() => { - if (this.activeChannel === channel) { - const channels = this.getChannelDescriptors(); - const channel = channels.length ? this.getChannel(channels[0].id) : null; - if (channel && this.isPanelShown()) { - this.showChannel(channel.id, true); - } else { - this.activeChannel = channel; - if (this.activeChannel) { - this._onActiveOutputChannel.fire(this.activeChannel.id); - } - } - } - Registry.as(Extensions.OutputChannels).removeChannel(id); - dispose(channelDisposables); - }, channelDisposables); - - return channel; - } - - private instantiateChannel(id: string): OutputChannel { - const channelData = Registry.as(Extensions.OutputChannels).getChannel(id); - if (!channelData) { - this.logService.error(`Channel '${id}' is not registered yet`); - throw new Error(`Channel '${id}' is not registered yet`); - } - - const uri = URI.from({ scheme: OUTPUT_SCHEME, path: id }); - if (channelData && channelData.file) { - return this.instantiationService.createInstance(FileOutputChannel, channelData, uri); - } - try { - const channelDescriptor: IFileOutputChannelDescriptor = { id, label: channelData ? channelData.label : '', log: false, file: URI.file(join(this.outputDir, `${id}.log`)) }; - return this.instantiationService.createInstance(OutputChannelBackedByFile, channelDescriptor, uri); - } catch (e) { - // Do not crash if spdlog rotating logger cannot be loaded (workaround for https://github.com/Microsoft/vscode/issues/47883) - this.logService.error(e); - /* __GDPR__ - "output.channel.creation.error" : {} - */ - this.telemetryService.publicLog('output.channel.creation.error'); - return this.instantiationService.createInstance(BufferredOutputChannel, { id, label: channelData ? channelData.label : '' }); - } - } - - private doShowChannel(channel: IOutputChannel, preserveFocus: boolean): Promise { - if (this._outputPanel) { - CONTEXT_ACTIVE_LOG_OUTPUT.bindTo(this.contextKeyService).set(channel instanceof FileOutputChannel && channel.outputChannelDescriptor.log); - return this._outputPanel.setInput(this.createInput(channel), EditorOptions.create({ preserveFocus }), CancellationToken.None) - .then(() => { - if (!preserveFocus) { - this._outputPanel.focus(); - } - }); - } - return Promise.resolve(undefined); - } - - private isChannelShown(channel: IOutputChannel): boolean { - return this.isPanelShown() && this.activeChannel === channel; - } - - private isPanelShown(): boolean { - const panel = this.panelService.getActivePanel(); - return !!panel && panel.getId() === OUTPUT_PANEL_ID; - } - - private createInput(channel: IOutputChannel): ResourceEditorInput { - const resource = URI.from({ scheme: OUTPUT_SCHEME, path: channel.id }); - return this.instantiationService.createInstance(ResourceEditorInput, nls.localize('output', "{0} - Output", channel.label), nls.localize('channel', "Output channel for '{0}'", channel.label), resource); - } - - private saveState(): void { - if (this.activeChannel) { - this.storageService.store(OUTPUT_ACTIVE_CHANNEL_KEY, this.activeChannel.id, StorageScope.WORKSPACE); - } - } -} - -export class LogContentProvider { - - private channels: Map = new Map(); - - constructor( - @IOutputService private readonly outputService: IOutputService, - @IInstantiationService private readonly instantiationService: IInstantiationService - ) { - } - - provideTextContent(resource: URI): Promise | null { - if (resource.scheme === LOG_SCHEME) { - let channel = this.getChannel(resource); - if (channel) { - return channel.loadModel(); - } - } - return null; - } - - private getChannel(resource: URI): OutputChannel | undefined { - const channelId = resource.path; - let channel = this.channels.get(channelId); - if (!channel) { - const channelDisposables: IDisposable[] = []; - const outputChannelDescriptor = this.outputService.getChannelDescriptors().filter(({ id }) => id === channelId)[0]; - if (outputChannelDescriptor && outputChannelDescriptor.file) { - channel = this.instantiationService.createInstance(FileOutputChannel, outputChannelDescriptor, resource); - channel.onDispose(() => dispose(channelDisposables), channelDisposables); - this.channels.set(channelId, channel); - } - } - return channel; - } -} -// Remove this channel when https://github.com/Microsoft/vscode/issues/47883 is fixed -class BufferredOutputChannel extends Disposable implements OutputChannel { - - readonly id: string; - readonly label: string; - readonly file: URI | null = null; - scrollLock: boolean = false; - - protected _onDidAppendedContent = new Emitter(); - readonly onDidAppendedContent: Event = this._onDidAppendedContent.event; - - private readonly _onDispose = new Emitter(); - readonly onDispose: Event = this._onDispose.event; - - private modelUpdater: RunOnceScheduler; - private model: ITextModel | null; - private readonly bufferredContent: BufferedContent; - private lastReadId: number | undefined = undefined; - - constructor( - protected readonly outputChannelIdentifier: IOutputChannelDescriptor, - @IModelService private readonly modelService: IModelService, - @IModeService private readonly modeService: IModeService - ) { - super(); - - this.id = outputChannelIdentifier.id; - this.label = outputChannelIdentifier.label; - - this.modelUpdater = new RunOnceScheduler(() => this.updateModel(), 300); - this._register(toDisposable(() => this.modelUpdater.cancel())); - - this.bufferredContent = new BufferedContent(); - this._register(toDisposable(() => this.bufferredContent.clear())); - } - - append(output: string) { - this.bufferredContent.append(output); - if (!this.modelUpdater.isScheduled()) { - this.modelUpdater.schedule(); - } - } - - update(): void { } - - clear(): void { - if (this.modelUpdater.isScheduled()) { - this.modelUpdater.cancel(); - } - if (this.model) { - this.model.setValue(''); - } - this.bufferredContent.clear(); - this.lastReadId = undefined; - } - - loadModel(): Promise { - const { value, id } = this.bufferredContent.getDelta(this.lastReadId); - if (this.model) { - this.model.setValue(value); - } else { - this.model = this.createModel(value); - } - this.lastReadId = id; - return Promise.resolve(this.model); - } - - private createModel(content: string): ITextModel { - const model = this.modelService.createModel(content, this.modeService.create(OUTPUT_MIME), URI.from({ scheme: OUTPUT_SCHEME, path: this.id })); - const disposables: IDisposable[] = []; - disposables.push(model.onWillDispose(() => { - this.model = null; - dispose(disposables); - })); - return model; - } - - private updateModel(): void { - if (this.model) { - const { value, id } = this.bufferredContent.getDelta(this.lastReadId); - this.lastReadId = id; - const lastLine = this.model.getLineCount(); - const lastLineMaxColumn = this.model.getLineMaxColumn(lastLine); - this.model.applyEdits([EditOperation.insert(new Position(lastLine, lastLineMaxColumn), value)]); - this._onDidAppendedContent.fire(); - } - } - - dispose(): void { - this._onDispose.fire(); - super.dispose(); - } -} - -class BufferedContent { - - private data: string[] = []; - private dataIds: number[] = []; - private idPool = 0; - private length = 0; - - public append(content: string): void { - this.data.push(content); - this.dataIds.push(++this.idPool); - this.length += content.length; - this.trim(); - } - - public clear(): void { - this.data.length = 0; - this.dataIds.length = 0; - this.length = 0; - } - - private trim(): void { - if (this.length < MAX_OUTPUT_LENGTH * 1.2) { - return; - } - - while (this.length > MAX_OUTPUT_LENGTH) { - this.dataIds.shift(); - const removed = this.data.shift(); - if (removed) { - this.length -= removed.length; - } - } - } - - public getDelta(previousId?: number): { value: string, id: number } { - let idx = -1; - if (previousId !== undefined) { - idx = binarySearch(this.dataIds, previousId, (a, b) => a - b); - } - - const id = this.idPool; - if (idx >= 0) { - const value = strings.removeAnsiEscapeCodes(this.data.slice(idx + 1).join('')); - return { value, id }; - } else { - const value = strings.removeAnsiEscapeCodes(this.data.join('')); - return { value, id }; - } - } -} diff --git a/src/vs/workbench/services/output/common/outputChannelModel.ts b/src/vs/workbench/services/output/common/outputChannelModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..aabcd3ffc54670d7bda35d1030439fa1ed17bfcb --- /dev/null +++ b/src/vs/workbench/services/output/common/outputChannelModel.ts @@ -0,0 +1,413 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import * as strings from 'vs/base/common/strings'; +import { ITextModel } from 'vs/editor/common/model'; +import { Emitter, Event } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; +import { RunOnceScheduler, ThrottledDelayer } from 'vs/base/common/async'; +import { IFileService } from 'vs/platform/files/common/files'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { Disposable, toDisposable, IDisposable, dispose } from 'vs/base/common/lifecycle'; +import { isNumber } from 'vs/base/common/types'; +import { EditOperation } from 'vs/editor/common/core/editOperation'; +import { Position } from 'vs/editor/common/core/position'; +import { binarySearch } from 'vs/base/common/arrays'; + +export interface IOutputChannelModel extends IDisposable { + readonly onDidAppendedContent: Event; + readonly onDispose: Event; + append(output: string): void; + update(): void; + loadModel(): Promise; + clear(till?: number): void; +} + +export const IOutputChannelModelService = createDecorator('outputChannelModelService'); + +export interface IOutputChannelModelService { + _serviceBrand: any; + + createOutputChannelModel(id: string, modelUri: URI, mimeType: string, file?: URI): IOutputChannelModel; + +} + +export abstract class AsbtractOutputChannelModelService { + + constructor( + @IInstantiationService protected readonly instantiationService: IInstantiationService + ) { } + + createOutputChannelModel(id: string, modelUri: URI, mimeType: string, file?: URI): IOutputChannelModel { + return file ? this.instantiationService.createInstance(FileOutputChannelModel, modelUri, mimeType, file) : this.instantiationService.createInstance(BufferredOutputChannel, modelUri, mimeType); + } + +} + +export abstract class AbstractFileOutputChannelModel extends Disposable implements IOutputChannelModel { + + protected _onDidAppendedContent = new Emitter(); + readonly onDidAppendedContent: Event = this._onDidAppendedContent.event; + + protected _onDispose = new Emitter(); + readonly onDispose: Event = this._onDispose.event; + + protected modelUpdater: RunOnceScheduler; + protected model: ITextModel | null; + + protected startOffset: number = 0; + protected endOffset: number = 0; + + constructor( + private readonly modelUri: URI, + private readonly mimeType: string, + protected readonly file: URI, + protected fileService: IFileService, + protected modelService: IModelService, + protected modeService: IModeService, + ) { + super(); + this.modelUpdater = new RunOnceScheduler(() => this.updateModel(), 300); + this._register(toDisposable(() => this.modelUpdater.cancel())); + } + + clear(till?: number): void { + if (this.modelUpdater.isScheduled()) { + this.modelUpdater.cancel(); + this.onUpdateModelCancelled(); + } + if (this.model) { + this.model.setValue(''); + } + this.endOffset = isNumber(till) ? till : this.endOffset; + this.startOffset = this.endOffset; + } + + update(): void { } + + protected createModel(content: string): ITextModel { + if (this.model) { + this.model.setValue(content); + } else { + this.model = this.modelService.createModel(content, this.modeService.create(this.mimeType), this.modelUri); + this.onModelCreated(this.model); + const disposables: IDisposable[] = []; + disposables.push(this.model.onWillDispose(() => { + this.onModelWillDispose(this.model); + this.model = null; + dispose(disposables); + })); + } + return this.model; + } + + appendToModel(content: string): void { + if (this.model && content) { + const lastLine = this.model.getLineCount(); + const lastLineMaxColumn = this.model.getLineMaxColumn(lastLine); + this.model.applyEdits([EditOperation.insert(new Position(lastLine, lastLineMaxColumn), content)]); + this._onDidAppendedContent.fire(); + } + } + + abstract loadModel(): Promise; + abstract append(message: string); + + protected onModelCreated(model: ITextModel) { } + protected onModelWillDispose(model: ITextModel | null) { } + protected onUpdateModelCancelled() { } + protected updateModel() { } + + dispose(): void { + this._onDispose.fire(); + super.dispose(); + } +} + +class OutputFileListener extends Disposable { + + private readonly _onDidContentChange = new Emitter(); + readonly onDidContentChange: Event = this._onDidContentChange.event; + + private watching: boolean = false; + private syncDelayer: ThrottledDelayer; + private etag: string | undefined; + + constructor( + private readonly file: URI, + private readonly fileService: IFileService + ) { + super(); + this.syncDelayer = new ThrottledDelayer(500); + } + + watch(eTag: string | undefined): void { + if (!this.watching) { + this.etag = eTag; + this.poll(); + this.watching = true; + } + } + + private poll(): void { + const loop = () => this.doWatch().then(() => this.poll()); + this.syncDelayer.trigger(loop); + } + + private doWatch(): Promise { + return this.fileService.resolveFile(this.file) + .then(stat => { + if (stat.etag !== this.etag) { + this.etag = stat.etag; + this._onDidContentChange.fire(stat.size); + } + }); + } + + unwatch(): void { + if (this.watching) { + this.syncDelayer.cancel(); + this.watching = false; + } + } + + dispose(): void { + this.unwatch(); + super.dispose(); + } +} + +/** + * An output channel driven by a file and does not support appending messages. + */ +export class FileOutputChannelModel extends AbstractFileOutputChannelModel implements IOutputChannelModel { + + private readonly fileHandler: OutputFileListener; + + private updateInProgress: boolean = false; + private etag: string | undefined = ''; + private loadModelPromise: Promise | null = null; + + constructor( + modelUri: URI, + mimeType: string, + file: URI, + @IFileService fileService: IFileService, + @IModelService modelService: IModelService, + @IModeService modeService: IModeService + ) { + super(modelUri, mimeType, file, fileService, modelService, modeService); + + this.fileHandler = this._register(new OutputFileListener(this.file, this.fileService)); + this._register(this.fileHandler.onDidContentChange(size => this.update(size))); + this._register(toDisposable(() => this.fileHandler.unwatch())); + } + + loadModel(): Promise { + this.loadModelPromise = this.fileService.resolveContent(this.file, { position: this.startOffset, encoding: 'utf8' }) + .then(content => { + this.endOffset = this.startOffset + Buffer.from(content.value).byteLength; + this.etag = content.etag; + return this.createModel(content.value); + }); + return this.loadModelPromise; + } + + clear(till?: number): void { + const loadModelPromise: Promise = this.loadModelPromise ? this.loadModelPromise : Promise.resolve(); + loadModelPromise.then(() => { + super.clear(till); + this.update(); + }); + } + + append(message: string): void { + throw new Error('Not supported'); + } + + protected updateModel(): void { + if (this.model) { + this.fileService.resolveContent(this.file, { position: this.endOffset, encoding: 'utf8' }) + .then(content => { + this.etag = content.etag; + if (content.value) { + this.endOffset = this.endOffset + Buffer.from(content.value).byteLength; + this.appendToModel(content.value); + } + this.updateInProgress = false; + }, () => this.updateInProgress = false); + } else { + this.updateInProgress = false; + } + } + + protected onModelCreated(model: ITextModel): void { + this.fileHandler.watch(this.etag); + } + + protected onModelWillDispose(model: ITextModel | null): void { + this.fileHandler.unwatch(); + } + + protected onUpdateModelCancelled(): void { + this.updateInProgress = false; + } + + update(size?: number): void { + if (this.model) { + if (!this.updateInProgress) { + this.updateInProgress = true; + if (isNumber(size) && this.endOffset > size) { // Reset - Content is removed + this.startOffset = this.endOffset = 0; + this.model.setValue(''); + } + this.modelUpdater.schedule(); + } + } + } +} + +class BufferredOutputChannel extends Disposable implements IOutputChannelModel { + + readonly file: URI | null = null; + scrollLock: boolean = false; + + protected _onDidAppendedContent = new Emitter(); + readonly onDidAppendedContent: Event = this._onDidAppendedContent.event; + + private readonly _onDispose = new Emitter(); + readonly onDispose: Event = this._onDispose.event; + + private modelUpdater: RunOnceScheduler; + private model: ITextModel | null; + private readonly bufferredContent: BufferedContent; + private lastReadId: number | undefined = undefined; + + constructor( + private readonly modelUri: URI, private readonly mimeType: string, + @IModelService private readonly modelService: IModelService, + @IModeService private readonly modeService: IModeService + ) { + super(); + + this.modelUpdater = new RunOnceScheduler(() => this.updateModel(), 300); + this._register(toDisposable(() => this.modelUpdater.cancel())); + + this.bufferredContent = new BufferedContent(); + this._register(toDisposable(() => this.bufferredContent.clear())); + } + + append(output: string) { + this.bufferredContent.append(output); + if (!this.modelUpdater.isScheduled()) { + this.modelUpdater.schedule(); + } + } + + update(): void { } + + clear(): void { + if (this.modelUpdater.isScheduled()) { + this.modelUpdater.cancel(); + } + if (this.model) { + this.model.setValue(''); + } + this.bufferredContent.clear(); + this.lastReadId = undefined; + } + + loadModel(): Promise { + const { value, id } = this.bufferredContent.getDelta(this.lastReadId); + if (this.model) { + this.model.setValue(value); + } else { + this.model = this.createModel(value); + } + this.lastReadId = id; + return Promise.resolve(this.model); + } + + private createModel(content: string): ITextModel { + const model = this.modelService.createModel(content, this.modeService.create(this.mimeType), this.modelUri); + const disposables: IDisposable[] = []; + disposables.push(model.onWillDispose(() => { + this.model = null; + dispose(disposables); + })); + return model; + } + + private updateModel(): void { + if (this.model) { + const { value, id } = this.bufferredContent.getDelta(this.lastReadId); + this.lastReadId = id; + const lastLine = this.model.getLineCount(); + const lastLineMaxColumn = this.model.getLineMaxColumn(lastLine); + this.model.applyEdits([EditOperation.insert(new Position(lastLine, lastLineMaxColumn), value)]); + this._onDidAppendedContent.fire(); + } + } + + dispose(): void { + this._onDispose.fire(); + super.dispose(); + } +} + +class BufferedContent { + + private static MAX_OUTPUT_LENGTH = 10000 /* Max. number of output lines to show in output */ * 100 /* Guestimated chars per line */; + + private data: string[] = []; + private dataIds: number[] = []; + private idPool = 0; + private length = 0; + + public append(content: string): void { + this.data.push(content); + this.dataIds.push(++this.idPool); + this.length += content.length; + this.trim(); + } + + public clear(): void { + this.data.length = 0; + this.dataIds.length = 0; + this.length = 0; + } + + private trim(): void { + if (this.length < BufferedContent.MAX_OUTPUT_LENGTH * 1.2) { + return; + } + + while (this.length > BufferedContent.MAX_OUTPUT_LENGTH) { + this.dataIds.shift(); + const removed = this.data.shift(); + if (removed) { + this.length -= removed.length; + } + } + } + + public getDelta(previousId?: number): { value: string, id: number } { + let idx = -1; + if (previousId !== undefined) { + idx = binarySearch(this.dataIds, previousId, (a, b) => a - b); + } + + const id = this.idPool; + if (idx >= 0) { + const value = strings.removeAnsiEscapeCodes(this.data.slice(idx + 1).join('')); + return { value, id }; + } else { + const value = strings.removeAnsiEscapeCodes(this.data.join('')); + return { value, id }; + } + } +} \ No newline at end of file diff --git a/src/vs/workbench/services/output/common/outputChannelModelService.ts b/src/vs/workbench/services/output/common/outputChannelModelService.ts new file mode 100644 index 0000000000000000000000000000000000000000..6aceb81e99e50d5ac721f00804a0788d269c5b8b --- /dev/null +++ b/src/vs/workbench/services/output/common/outputChannelModelService.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IOutputChannelModelService, AsbtractOutputChannelModelService } from 'vs/workbench/services/output/common/outputChannelModel'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; + +export class OutputChannelModelService extends AsbtractOutputChannelModelService implements IOutputChannelModelService { + _serviceBrand: any; +} + +registerSingleton(IOutputChannelModelService, OutputChannelModelService); + diff --git a/src/vs/workbench/contrib/output/node/outputAppender.ts b/src/vs/workbench/services/output/node/outputAppender.ts similarity index 100% rename from src/vs/workbench/contrib/output/node/outputAppender.ts rename to src/vs/workbench/services/output/node/outputAppender.ts diff --git a/src/vs/workbench/services/output/node/outputChannelModelService.ts b/src/vs/workbench/services/output/node/outputChannelModelService.ts new file mode 100644 index 0000000000000000000000000000000000000000..7709518de6fe53180050c323e36c9d4f8839271a --- /dev/null +++ b/src/vs/workbench/services/output/node/outputChannelModelService.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import * as extfs from 'vs/base/node/extfs'; +import { dirname, join } from 'vs/base/common/path'; +import { ITextModel } from 'vs/editor/common/model'; +import { URI } from 'vs/base/common/uri'; +import { ThrottledDelayer } from 'vs/base/common/async'; +import { IFileService } from 'vs/platform/files/common/files'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { toDisposable, IDisposable } from 'vs/base/common/lifecycle'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IOutputChannelModel, AbstractFileOutputChannelModel, IOutputChannelModelService, AsbtractOutputChannelModelService } from 'vs/workbench/services/output/common/outputChannelModel'; +import { OutputAppender } from 'vs/workbench/services/output/node/outputAppender'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IWindowService } from 'vs/platform/windows/common/windows'; +import { toLocalISOString } from 'vs/base/common/date'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; + +let watchingOutputDir = false; +let callbacks: ((eventType: string, fileName?: string) => void)[] = []; +function watchOutputDirectory(outputDir: string, logService: ILogService, onChange: (eventType: string, fileName: string) => void): IDisposable { + callbacks.push(onChange); + if (!watchingOutputDir) { + const watcherDisposable = extfs.watch(outputDir, (eventType, fileName) => { + for (const callback of callbacks) { + callback(eventType, fileName); + } + }, (error: string) => { + logService.error(error); + }); + watchingOutputDir = true; + return toDisposable(() => { + callbacks = []; + watcherDisposable.dispose(); + }); + } + return toDisposable(() => { }); +} + +class OutputChannelBackedByFile extends AbstractFileOutputChannelModel implements IOutputChannelModel { + + private appender: OutputAppender; + private appendedMessage: string; + private loadingFromFileInProgress: boolean; + private resettingDelayer: ThrottledDelayer; + private readonly rotatingFilePath: string; + + constructor( + id: string, + modelUri: URI, + mimeType: string, + @IWindowService windowService: IWindowService, + @IEnvironmentService environmentService: IEnvironmentService, + @IFileService fileService: IFileService, + @IModelService modelService: IModelService, + @IModeService modeService: IModeService, + @ILogService logService: ILogService + ) { + const outputDir = join(environmentService.logsPath, `output_${windowService.getCurrentWindowId()}_${toLocalISOString(new Date()).replace(/-|:|\.\d+Z$/g, '')}`); + super(modelUri, mimeType, URI.file(join(outputDir, `${id}.log`)), fileService, modelService, modeService); + this.appendedMessage = ''; + this.loadingFromFileInProgress = false; + + // Use one rotating file to check for main file reset + this.appender = new OutputAppender(id, this.file.fsPath); + this.rotatingFilePath = `${id}.1.log`; + this._register(watchOutputDirectory(dirname(this.file.fsPath), logService, (eventType, file) => this.onFileChangedInOutputDirector(eventType, file))); + + this.resettingDelayer = new ThrottledDelayer(50); + } + + append(message: string): void { + // update end offset always as message is read + this.endOffset = this.endOffset + Buffer.from(message).byteLength; + if (this.loadingFromFileInProgress) { + this.appendedMessage += message; + } else { + this.write(message); + if (this.model) { + this.appendedMessage += message; + if (!this.modelUpdater.isScheduled()) { + this.modelUpdater.schedule(); + } + } + } + } + + clear(till?: number): void { + super.clear(till); + this.appendedMessage = ''; + } + + loadModel(): Promise { + this.loadingFromFileInProgress = true; + if (this.modelUpdater.isScheduled()) { + this.modelUpdater.cancel(); + } + this.appendedMessage = ''; + return this.loadFile() + .then(content => { + if (this.endOffset !== this.startOffset + Buffer.from(content).byteLength) { + // Queue content is not written into the file + // Flush it and load file again + this.flush(); + return this.loadFile(); + } + return content; + }) + .then(content => { + if (this.appendedMessage) { + this.write(this.appendedMessage); + this.appendedMessage = ''; + } + this.loadingFromFileInProgress = false; + return this.createModel(content); + }); + } + + private resetModel(): Promise { + this.startOffset = 0; + this.endOffset = 0; + if (this.model) { + return this.loadModel().then(() => undefined); + } + return Promise.resolve(undefined); + } + + private loadFile(): Promise { + return this.fileService.resolveContent(this.file, { position: this.startOffset, encoding: 'utf8' }) + .then(content => this.appendedMessage ? content.value + this.appendedMessage : content.value); + } + + protected updateModel(): void { + if (this.model && this.appendedMessage) { + this.appendToModel(this.appendedMessage); + this.appendedMessage = ''; + } + } + + private onFileChangedInOutputDirector(eventType: string, fileName?: string): void { + // Check if rotating file has changed. It changes only when the main file exceeds its limit. + if (this.rotatingFilePath === fileName) { + this.resettingDelayer.trigger(() => this.resetModel()); + } + } + + private write(content: string): void { + this.appender.append(content); + } + + private flush(): void { + this.appender.flush(); + } +} + +export class OutputChannelModelService extends AsbtractOutputChannelModelService implements IOutputChannelModelService { + + _serviceBrand: any; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService + ) { + super(instantiationService); + } + + createOutputChannelModel(id: string, modelUri: URI, mimeType: string, file?: URI): IOutputChannelModel { + if (!file) { + try { + return this.instantiationService.createInstance(OutputChannelBackedByFile, id, modelUri, mimeType); + } catch (e) { + // Do not crash if spdlog rotating logger cannot be loaded (workaround for https://github.com/Microsoft/vscode/issues/47883) + this.logService.error(e); + /* __GDPR__ + "output.channel.creation.error" : {} + */ + this.telemetryService.publicLog('output.channel.creation.error'); + } + } + return super.createOutputChannelModel(id, modelUri, mimeType, file); + } + +} + +registerSingleton(IOutputChannelModelService, OutputChannelModelService); diff --git a/src/vs/workbench/workbench.main.ts b/src/vs/workbench/workbench.main.ts index d8bf039ff5db55ab1297dd5f2cefa64e237d4b94..af9408a8051ebed28df86aa6958b96a2afa5eb0f 100644 --- a/src/vs/workbench/workbench.main.ts +++ b/src/vs/workbench/workbench.main.ts @@ -69,6 +69,7 @@ import 'vs/workbench/services/progress/browser/progressService2'; import 'vs/workbench/services/editor/browser/codeEditorService'; import 'vs/workbench/services/broadcast/electron-browser/broadcastService'; import 'vs/workbench/services/preferences/browser/preferencesService'; +import 'vs/workbench/services/output/node/outputChannelModelService'; import 'vs/workbench/services/configuration/node/jsonEditingService'; import 'vs/workbench/services/textmodelResolver/common/textModelResolverService'; import 'vs/workbench/services/textfile/common/textFileService'; @@ -155,7 +156,7 @@ import 'vs/workbench/contrib/extensions/browser/extensionsQuickOpen'; import 'vs/workbench/contrib/extensions/electron-browser/extensionsViewlet'; // Output Panel -import 'vs/workbench/contrib/output/electron-browser/output.contribution'; +import 'vs/workbench/contrib/output/browser/output.contribution'; import 'vs/workbench/contrib/output/browser/outputPanel'; // Terminal