diff --git a/src/vs/editor/contrib/format/format.ts b/src/vs/editor/contrib/format/format.ts index 05fb559c61b..8271bc373c3 100644 --- a/src/vs/editor/contrib/format/format.ts +++ b/src/vs/editor/contrib/format/format.ts @@ -23,8 +23,8 @@ import { FormattingEdit } from 'vs/editor/contrib/format/formattingEdit'; import * as nls from 'vs/nls'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IStatusbarService } from 'vs/platform/statusbar/common/statusbar'; -import { ILabelService } from 'vs/platform/label/common/label'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { LinkedList } from 'vs/base/common/linkedList'; export function alertFormattingEdits(edits: ISingleEditOperation[]): void { @@ -86,30 +86,51 @@ export function getRealAndSyntheticDocumentFormattersOrdered(model: ITextModel): return result; } -export async function formatDocumentRangeWithFirstProvider( +export const enum FormattingMode { + Explicit = 1, + Silent = 2 +} + +export interface IFormattingEditProviderSelector { + (formatter: T[], document: ITextModel, mode: FormattingMode): Promise; +} + +export abstract class FormattingConflicts { + + private static readonly _selectors = new LinkedList(); + + static setFormatterSelector(selector: IFormattingEditProviderSelector): IDisposable { + const remove = FormattingConflicts._selectors.unshift(selector); + return { dispose: remove }; + } + + static async select(formatter: T[], document: ITextModel, mode: FormattingMode): Promise { + if (formatter.length === 0) { + return undefined; + } + const { value: selector } = FormattingConflicts._selectors.iterator().next(); + if (selector) { + return await selector(formatter, document, mode); + } + return formatter[0]; + } +} + +export async function formatDocumentRangeWithSelectedProvider( accessor: ServicesAccessor, editorOrModel: ITextModel | IActiveCodeEditor, range: Range, + mode: FormattingMode, token: CancellationToken -): Promise { +): Promise { const instaService = accessor.get(IInstantiationService); - const statusBarService = accessor.get(IStatusbarService); - const labelService = accessor.get(ILabelService); - const model = isCodeEditor(editorOrModel) ? editorOrModel.getModel() : editorOrModel; - const [best, ...rest] = DocumentRangeFormattingEditProviderRegistry.ordered(model); - if (!best) { - return false; + const provider = DocumentRangeFormattingEditProviderRegistry.ordered(model); + const selected = await FormattingConflicts.select(provider, model, mode); + if (selected) { + await instaService.invokeFunction(formatDocumentRangeWithProvider, selected, editorOrModel, range, token); } - const ret = await instaService.invokeFunction(formatDocumentRangeWithProvider, best, editorOrModel, range, token); - if (rest.length > 0) { - statusBarService.setStatusMessage( - nls.localize('random.pick', "$(tasklist) Formatted '{0}' with '{1}'", labelService.getUriLabel(model.uri, { relative: true }), best.displayName), - 5 * 1000 - ); - } - return ret; } export async function formatDocumentRangeWithProvider( @@ -181,29 +202,20 @@ export async function formatDocumentRangeWithProvider( return true; } -export async function formatDocumentWithFirstProvider( +export async function formatDocumentWithSelectedProvider( accessor: ServicesAccessor, editorOrModel: ITextModel | IActiveCodeEditor, + mode: FormattingMode, token: CancellationToken -): Promise { +): Promise { const instaService = accessor.get(IInstantiationService); - const statusBarService = accessor.get(IStatusbarService); - const labelService = accessor.get(ILabelService); - const model = isCodeEditor(editorOrModel) ? editorOrModel.getModel() : editorOrModel; - const [best, ...rest] = getRealAndSyntheticDocumentFormattersOrdered(model); - if (!best) { - return false; + const provider = getRealAndSyntheticDocumentFormattersOrdered(model); + const selected = await FormattingConflicts.select(provider, model, mode); + if (selected) { + await instaService.invokeFunction(formatDocumentWithProvider, selected, editorOrModel, token); } - const ret = await instaService.invokeFunction(formatDocumentWithProvider, best, editorOrModel, token); - if (rest.length > 0) { - statusBarService.setStatusMessage( - nls.localize('random.pick', "$(tasklist) Formatted '{0}' with '{1}'", labelService.getUriLabel(model.uri, { relative: true }), best.displayName), - 5 * 1000 - ); - } - return ret; } export async function formatDocumentWithProvider( diff --git a/src/vs/editor/contrib/format/formatActions.ts b/src/vs/editor/contrib/format/formatActions.ts index ddcfb66b107..dff549fa55a 100644 --- a/src/vs/editor/contrib/format/formatActions.ts +++ b/src/vs/editor/contrib/format/formatActions.ts @@ -16,7 +16,7 @@ import * as editorCommon from 'vs/editor/common/editorCommon'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { DocumentRangeFormattingEditProviderRegistry, OnTypeFormattingEditProviderRegistry } from 'vs/editor/common/modes'; import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService'; -import { getOnTypeFormattingEdits, alertFormattingEdits, formatDocumentRangeWithFirstProvider, formatDocumentWithFirstProvider } from 'vs/editor/contrib/format/format'; +import { getOnTypeFormattingEdits, alertFormattingEdits, formatDocumentRangeWithSelectedProvider, formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/format'; import { FormattingEdit } from 'vs/editor/contrib/format/formattingEdit'; import * as nls from 'vs/nls'; import { CommandsRegistry, ICommandService } from 'vs/platform/commands/common/commands'; @@ -211,7 +211,7 @@ class FormatOnPaste implements editorCommon.IEditorContribution { if (this.editor.getSelections().length > 1) { return; } - this._instantiationService.invokeFunction(formatDocumentRangeWithFirstProvider, this.editor, range, CancellationToken.None).catch(onUnexpectedError); + this._instantiationService.invokeFunction(formatDocumentRangeWithSelectedProvider, this.editor, range, FormattingMode.Explicit, CancellationToken.None).catch(onUnexpectedError); } } @@ -240,7 +240,7 @@ class FormatDocumentAction extends EditorAction { async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { if (editor.hasModel()) { const instaService = accessor.get(IInstantiationService); - await instaService.invokeFunction(formatDocumentWithFirstProvider, editor, CancellationToken.None); + await instaService.invokeFunction(formatDocumentWithSelectedProvider, editor, FormattingMode.Explicit, CancellationToken.None); } } } @@ -276,7 +276,7 @@ class FormatSelectionAction extends EditorAction { if (range.isEmpty()) { range = new Range(range.startLineNumber, 1, range.startLineNumber, model.getLineMaxColumn(range.startLineNumber)); } - await instaService.invokeFunction(formatDocumentRangeWithFirstProvider, editor, range, CancellationToken.None); + await instaService.invokeFunction(formatDocumentRangeWithSelectedProvider, editor, range, FormattingMode.Explicit, CancellationToken.None); } } diff --git a/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts b/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts index c3f29d4a493..9a629ea8eed 100644 --- a/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts +++ b/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts @@ -21,7 +21,7 @@ import { shouldSynchronizeModel } from 'vs/editor/common/services/modelService'; import { getCodeActions } from 'vs/editor/contrib/codeAction/codeAction'; import { applyCodeAction } from 'vs/editor/contrib/codeAction/codeActionCommands'; import { CodeActionKind } from 'vs/editor/contrib/codeAction/codeActionTrigger'; -import { formatDocumentWithFirstProvider } from 'vs/editor/contrib/format/format'; +import { formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/format'; import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2'; import { localize } from 'vs/nls'; import { ICommandService } from 'vs/platform/commands/common/commands'; @@ -228,7 +228,7 @@ class FormatOnSaveParticipant implements ISaveParticipantParticipant { return new Promise((resolve, reject) => { const source = new CancellationTokenSource(); const timeout = this._configurationService.getValue('editor.formatOnSaveTimeout', overrides); - const request = this._instantiationService.invokeFunction(formatDocumentWithFirstProvider, model, source.token); + const request = this._instantiationService.invokeFunction(formatDocumentWithSelectedProvider, model, FormattingMode.Silent, source.token); setTimeout(() => { reject(localize('timeout.formatOnSave', "Aborted format on save after {0}ms", timeout)); diff --git a/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts b/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts index 4032c2459bd..33da7b20418 100644 --- a/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts +++ b/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts @@ -6,18 +6,138 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction, registerEditorAction, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { DocumentRangeFormattingEditProviderRegistry } from 'vs/editor/common/modes'; +import { DocumentRangeFormattingEditProviderRegistry, DocumentFormattingEditProvider, DocumentRangeFormattingEditProvider } from 'vs/editor/common/modes'; import * as nls from 'vs/nls'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { IQuickInputService, IQuickPickItem, IQuickInputButton } from 'vs/platform/quickinput/common/quickInput'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { formatDocumentRangeWithProvider, formatDocumentWithProvider, getRealAndSyntheticDocumentFormattersOrdered } from 'vs/editor/contrib/format/format'; +import { formatDocumentRangeWithProvider, formatDocumentWithProvider, getRealAndSyntheticDocumentFormattersOrdered, FormattingConflicts, FormattingMode } from 'vs/editor/contrib/format/format'; import { Range } from 'vs/editor/common/core/range'; -import { showExtensionQuery } from 'vs/workbench/contrib/format/browser/showExtensionQuery'; -import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from 'vs/platform/configuration/common/configurationRegistry'; +import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry, IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ITextModel } from 'vs/editor/common/model'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import { IModeService } from 'vs/editor/common/services/modeService'; + +class DefaultFormatter extends Disposable implements IWorkbenchContribution { + + static configName = 'editor.defaultFormatter'; + + static extensionIds: string[] = []; + static extensionDescriptions: string[] = []; + + constructor( + @IExtensionService private readonly _extensionService: IExtensionService, + @IConfigurationService private readonly _configService: IConfigurationService, + @INotificationService private readonly _notificationService: INotificationService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + @IModeService private readonly _modeService: IModeService, + ) { + super(); + this._register(this._extensionService.onDidChangeExtensions(this._updateConfigValues, this)); + this._register(FormattingConflicts.setFormatterSelector((formatter, document, mode) => this._selectFormatter(formatter, document, mode))); + this._updateConfigValues(); + } + + private async _updateConfigValues(): Promise { + const extensions = await this._extensionService.getExtensions(); + + DefaultFormatter.extensionIds.length = 0; + DefaultFormatter.extensionDescriptions.length = 0; + for (const extension of extensions) { + DefaultFormatter.extensionIds.push(extension.identifier.value); + DefaultFormatter.extensionDescriptions.push(extension.description || ''); + } + } + + private static _maybeQuotes(s: string): string { + return s.match(/\s/) ? `'${s}'` : s; + } + + private async _selectFormatter(formatter: T[], document: ITextModel, mode: FormattingMode): Promise { + + const defaultFormatterId = this._configService.getValue(DefaultFormatter.configName, { + resource: document.uri, + overrideIdentifier: document.getModeId() + }); + + if (defaultFormatterId) { + // good -> formatter configured + const [defaultFormatter] = formatter.filter(formatter => formatter.extensionId && ExtensionIdentifier.equals(formatter.extensionId, defaultFormatterId)); + if (defaultFormatter) { + // good -> formatter configured and available + return defaultFormatter; + } + } + + const langName = this._modeService.getLanguageName(document.getModeId()) || document.getModeId(); + const message = defaultFormatterId + ? nls.localize('config.bad', "The configured default formatter is not available. Select a different default formatter to continue.") + : nls.localize('config.needed', "There are multiple formatters for {0}-files. Select a default formatter to continue.", DefaultFormatter._maybeQuotes(langName)); + + return new Promise((resolve, reject) => { + this._notificationService.prompt( + Severity.Info, + message, + [{ label: nls.localize('do.config', "Configure..."), run: () => this._pickAndPersistDefaultFormatter(formatter, document).then(resolve, reject) }], + { silent: mode === FormattingMode.Silent, onCancel: resolve } + ); + + if (mode === FormattingMode.Silent) { + // don't wait when formatting happens without interaction + // but pick some formatter... + resolve(formatter[0]); + } + }); + } + + private async _pickAndPersistDefaultFormatter(formatter: T[], document: ITextModel): Promise { + const picks = formatter.map((formatter, index) => { + return { + index, + label: formatter.displayName || formatter.extensionId || '?' + }; + }); + const langName = this._modeService.getLanguageName(document.getModeId()) || document.getModeId(); + const pick = await this._quickInputService.pick(picks, { placeHolder: nls.localize('select', "Select a default formatter for {0}-files", DefaultFormatter._maybeQuotes(langName)) }); + if (!pick || !formatter[pick.index].extensionId) { + return undefined; + } + this._configService.updateValue(DefaultFormatter.configName, formatter[pick.index].extensionId!.value, { + resource: document.uri, + overrideIdentifier: document.getModeId() + }); + return formatter[pick.index]; + } +} + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution( + DefaultFormatter, + LifecyclePhase.Restored +); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'editor', + order: 5, + type: 'object', + overridable: true, + properties: { + [DefaultFormatter.configName]: { + description: nls.localize('formatter.default', "Defines a default formatter takes precedence over all other formatter settings. Must be the identifier of an extension contributing a formatter."), + type: 'string', + enum: DefaultFormatter.extensionIds, + markdownEnumDescriptions: DefaultFormatter.extensionDescriptions + } + } +}); interface IIndexedPick extends IQuickPickItem { index: number; @@ -69,25 +189,27 @@ registerEditorAction(class FormatDocumentMultipleAction extends EditorAction { } const instaService = accessor.get(IInstantiationService); const quickPickService = accessor.get(IQuickInputService); - const viewletService = accessor.get(IViewletService); const telemetryService = accessor.get(ITelemetryService); + const configService = accessor.get(IConfigurationService); + const model = editor.getModel(); + const defaultFormatter = configService.getValue(DefaultFormatter.configName, { + resource: model.uri, + overrideIdentifier: model.getModeId() + }); const provider = getRealAndSyntheticDocumentFormattersOrdered(model); const picks = provider.map((provider, index) => { return { index, label: provider.displayName || '', + description: ExtensionIdentifier.equals(provider.extensionId, defaultFormatter) ? nls.localize('def', "(default)") : undefined, buttons: [openExtensionAction] }; }); const pick = await quickPickService.pick(picks, { - placeHolder: nls.localize('format.placeHolder', "Select a formatter"), - onDidTriggerItemButton: (e) => { - const { extensionId } = provider[e.item.index]; - return showExtensionQuery(viewletService, `@id:${extensionId!.value}`); - } + placeHolder: nls.localize('format.placeHolder', "Select a formatter") }); if (pick) { await instaService.invokeFunction(formatDocumentWithProvider, provider[pick.index], editor, CancellationToken.None); @@ -119,9 +241,14 @@ registerEditorAction(class FormatSelectionMultipleAction extends EditorAction { } const instaService = accessor.get(IInstantiationService); const quickPickService = accessor.get(IQuickInputService); - const viewletService = accessor.get(IViewletService); const telemetryService = accessor.get(ITelemetryService); + const configService = accessor.get(IConfigurationService); + const model = editor.getModel(); + const defaultFormatter = configService.getValue(DefaultFormatter.configName, { + resource: model.uri, + overrideIdentifier: model.getModeId() + }); let range: Range = editor.getSelection(); if (range.isEmpty()) { @@ -133,16 +260,13 @@ registerEditorAction(class FormatSelectionMultipleAction extends EditorAction { return { index, label: provider.displayName || '', + description: ExtensionIdentifier.equals(provider.extensionId, defaultFormatter) ? nls.localize('def', "(default)") : undefined, buttons: [openExtensionAction] }; }); const pick = await quickPickService.pick(picks, { - placeHolder: nls.localize('format.placeHolder', "Select a formatter"), - onDidTriggerItemButton: (e) => { - const { extensionId } = provider[e.item.index]; - return showExtensionQuery(viewletService, `@id:${extensionId!.value}`); - } + placeHolder: nls.localize('format.placeHolder', "Select a formatter") }); if (pick) { await instaService.invokeFunction(formatDocumentRangeWithProvider, provider[pick.index], editor, range, CancellationToken.None);