/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Event } from 'vs/base/common/event'; import { URI, UriComponents } from 'vs/base/common/uri'; import { sequence } from 'vs/base/common/async'; import { illegalState } from 'vs/base/common/errors'; import { ExtHostDocumentSaveParticipantShape, MainThreadTextEditorsShape, ResourceTextEditDto } from 'vs/workbench/api/node/extHost.protocol'; import { TextEdit } from 'vs/workbench/api/node/extHostTypes'; import { Range, TextDocumentSaveReason, EndOfLine } from 'vs/workbench/api/node/extHostTypeConverters'; import { ExtHostDocuments } from 'vs/workbench/api/node/extHostDocuments'; import { SaveReason } from 'vs/workbench/services/textfile/common/textfiles'; import * as vscode from 'vscode'; import { LinkedList } from 'vs/base/common/linkedList'; import { IExtensionDescription } from 'vs/workbench/services/extensions/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; type Listener = [Function, any, IExtensionDescription]; export class ExtHostDocumentSaveParticipant implements ExtHostDocumentSaveParticipantShape { private readonly _callbacks = new LinkedList(); private readonly _badListeners = new WeakMap(); constructor( private readonly _logService: ILogService, private readonly _documents: ExtHostDocuments, private readonly _mainThreadEditors: MainThreadTextEditorsShape, private readonly _thresholds: { timeout: number; errors: number; } = { timeout: 1500, errors: 3 } ) { // } dispose(): void { this._callbacks.clear(); } getOnWillSaveTextDocumentEvent(extension: IExtensionDescription): Event { return (listener, thisArg, disposables) => { const remove = this._callbacks.push([listener, thisArg, extension]); const result = { dispose: remove }; if (Array.isArray(disposables)) { disposables.push(result); } return result; }; } $participateInSave(data: UriComponents, reason: SaveReason): Promise { const resource = URI.revive(data); const entries = this._callbacks.toArray(); let didTimeout = false; const didTimeoutHandle = setTimeout(() => didTimeout = true, this._thresholds.timeout); const promise = sequence(entries.map(listener => { return () => { if (didTimeout) { // timeout - no more listeners return Promise.resolve(); } const document = this._documents.getDocument(resource); return this._deliverEventAsyncAndBlameBadListeners(listener, { document, reason: TextDocumentSaveReason.to(reason) }); }; })); return promise.finally(() => clearTimeout(didTimeoutHandle)); } private _deliverEventAsyncAndBlameBadListeners([listener, thisArg, extension]: Listener, stubEvent: vscode.TextDocumentWillSaveEvent): Promise { const errors = this._badListeners.get(listener); if (typeof errors === 'number' && errors > this._thresholds.errors) { // bad listener - ignore return Promise.resolve(false); } return this._deliverEventAsync(extension, listener, thisArg, stubEvent).then(() => { // don't send result across the wire return true; }, err => { this._logService.error(`onWillSaveTextDocument-listener from extension '${extension.identifier.value}' threw ERROR`); this._logService.error(err); if (!(err instanceof Error) || (err).message !== 'concurrent_edits') { const errors = this._badListeners.get(listener); this._badListeners.set(listener, !errors ? 1 : errors + 1); if (typeof errors === 'number' && errors > this._thresholds.errors) { this._logService.info(`onWillSaveTextDocument-listener from extension '${extension.identifier.value}' will now be IGNORED because of timeouts and/or errors`); } } return false; }); } private _deliverEventAsync(extension: IExtensionDescription, listener: Function, thisArg: any, stubEvent: vscode.TextDocumentWillSaveEvent): Promise { const promises: Promise[] = []; const t1 = Date.now(); const { document, reason } = stubEvent; const { version } = document; const event = Object.freeze({ document, reason, waitUntil(p: Promise) { if (Object.isFrozen(promises)) { throw illegalState('waitUntil can not be called async'); } promises.push(Promise.resolve(p)); } }); try { // fire event listener.apply(thisArg, [event]); } catch (err) { return Promise.reject(err); } // freeze promises after event call Object.freeze(promises); return new Promise((resolve, reject) => { // join on all listener promises, reject after timeout const handle = setTimeout(() => reject(new Error('timeout')), this._thresholds.timeout); return Promise.all(promises).then(edits => { this._logService.debug(`onWillSaveTextDocument-listener from extension '${extension.identifier.value}' finished after ${(Date.now() - t1)}ms`); clearTimeout(handle); resolve(edits); }).catch(err => { clearTimeout(handle); reject(err); }); }).then(values => { const resourceEdit: ResourceTextEditDto = { resource: document.uri, edits: [] }; for (const value of values) { if (Array.isArray(value) && (value).every(e => e instanceof TextEdit)) { for (const { newText, newEol, range } of value) { resourceEdit.edits.push({ range: range && Range.from(range), text: newText, eol: EndOfLine.from(newEol) }); } } } // apply edits if any and if document // didn't change somehow in the meantime if (resourceEdit.edits.length === 0) { return undefined; } if (version === document.version) { return this._mainThreadEditors.$tryApplyWorkspaceEdit({ edits: [resourceEdit] }); } return Promise.reject(new Error('concurrent_edits')); }); } }