Path: blob/main/src/vs/workbench/contrib/codeEditor/browser/saveParticipants.ts
5237 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';6import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js';7import { createCommandUri } from '../../../../base/common/htmlContent.js';8import { Disposable } from '../../../../base/common/lifecycle.js';9import * as strings from '../../../../base/common/strings.js';10import { IActiveCodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js';11import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';12import { trimTrailingWhitespace } from '../../../../editor/common/commands/trimTrailingWhitespaceCommand.js';13import { EditOperation } from '../../../../editor/common/core/editOperation.js';14import { Position } from '../../../../editor/common/core/position.js';15import { Range } from '../../../../editor/common/core/range.js';16import { Selection } from '../../../../editor/common/core/selection.js';17import { CodeActionProvider, CodeActionTriggerType } from '../../../../editor/common/languages.js';18import { ITextModel } from '../../../../editor/common/model.js';19import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';20import { ApplyCodeActionReason, applyCodeAction, getCodeActions } from '../../../../editor/contrib/codeAction/browser/codeAction.js';21import { CodeActionKind, CodeActionTriggerSource } from '../../../../editor/contrib/codeAction/common/types.js';22import { FormattingMode, formatDocumentRangesWithSelectedProvider, formatDocumentWithSelectedProvider } from '../../../../editor/contrib/format/browser/format.js';23import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js';24import { localize } from '../../../../nls.js';25import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';26import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';27import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';28import { IProgress, IProgressStep, Progress } from '../../../../platform/progress/common/progress.js';29import { Registry } from '../../../../platform/registry/common/platform.js';30import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchContributionsExtensions } from '../../../common/contributions.js';31import { SaveReason } from '../../../common/editor.js';32import { IEditorService } from '../../../services/editor/common/editorService.js';33import { IHostService } from '../../../services/host/browser/host.js';34import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';35import { ITextFileEditorModel, ITextFileSaveParticipant, ITextFileSaveParticipantContext, ITextFileService } from '../../../services/textfile/common/textfiles.js';36import { getModifiedRanges } from '../../format/browser/formatModified.js';3738export class TrimWhitespaceParticipant implements ITextFileSaveParticipant {3940constructor(41@IConfigurationService private readonly configurationService: IConfigurationService,42@ICodeEditorService private readonly codeEditorService: ICodeEditorService43) {44// Nothing45}4647async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {48if (!model.textEditorModel) {49return;50}5152const trimTrailingWhitespaceOption = this.configurationService.getValue<boolean>('files.trimTrailingWhitespace', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource });53const trimInRegexAndStrings = this.configurationService.getValue<boolean>('files.trimTrailingWhitespaceInRegexAndStrings', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource });54if (trimTrailingWhitespaceOption) {55this.doTrimTrailingWhitespace(model.textEditorModel, context.reason === SaveReason.AUTO, trimInRegexAndStrings);56}57}5859private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean, trimInRegexesAndStrings: boolean): void {60let prevSelection: Selection[] = [];61let cursors: Position[] = [];6263const editor = findEditor(model, this.codeEditorService);64if (editor) {65// Find `prevSelection` in any case do ensure a good undo stack when pushing the edit66// Collect active cursors in `cursors` only if `isAutoSaved` to avoid having the cursors jump67prevSelection = editor.getSelections();68if (isAutoSaved) {69cursors = prevSelection.map(s => s.getPosition());70const snippetsRange = SnippetController2.get(editor)?.getSessionEnclosingRange();71if (snippetsRange) {72for (let lineNumber = snippetsRange.startLineNumber; lineNumber <= snippetsRange.endLineNumber; lineNumber++) {73cursors.push(new Position(lineNumber, model.getLineMaxColumn(lineNumber)));74}75}76}77}7879const ops = trimTrailingWhitespace(model, cursors, trimInRegexesAndStrings);80if (!ops.length) {81return; // Nothing to do82}8384model.pushEditOperations(prevSelection, ops, (_edits) => prevSelection);85}86}8788function findEditor(model: ITextModel, codeEditorService: ICodeEditorService): IActiveCodeEditor | null {89let candidate: IActiveCodeEditor | null = null;9091if (model.isAttachedToEditor()) {92for (const editor of codeEditorService.listCodeEditors()) {93if (editor.hasModel() && editor.getModel() === model) {94if (editor.hasTextFocus()) {95return editor; // favour focused editor if there are multiple96}9798candidate = editor;99}100}101}102103return candidate;104}105106export class FinalNewLineParticipant implements ITextFileSaveParticipant {107108constructor(109@IConfigurationService private readonly configurationService: IConfigurationService,110@ICodeEditorService private readonly codeEditorService: ICodeEditorService111) {112// Nothing113}114115async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {116if (!model.textEditorModel) {117return;118}119120if (this.configurationService.getValue('files.insertFinalNewline', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource })) {121this.doInsertFinalNewLine(model.textEditorModel);122}123}124125private doInsertFinalNewLine(model: ITextModel): void {126const lineCount = model.getLineCount();127const lastLine = model.getLineContent(lineCount);128const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1;129130if (!lineCount || lastLineIsEmptyOrWhitespace) {131return;132}133134const edits = [EditOperation.insert(new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL())];135const editor = findEditor(model, this.codeEditorService);136if (editor) {137editor.executeEdits('insertFinalNewLine', edits, editor.getSelections());138} else {139model.pushEditOperations([], edits, () => null);140}141}142}143144export class TrimFinalNewLinesParticipant implements ITextFileSaveParticipant {145146constructor(147@IConfigurationService private readonly configurationService: IConfigurationService,148@ICodeEditorService private readonly codeEditorService: ICodeEditorService149) {150// Nothing151}152153async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {154if (!model.textEditorModel) {155return;156}157158if (this.configurationService.getValue('files.trimFinalNewlines', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource })) {159this.doTrimFinalNewLines(model.textEditorModel, context.reason === SaveReason.AUTO);160}161}162163/**164* returns 0 if the entire file is empty165*/166private findLastNonEmptyLine(model: ITextModel): number {167for (let lineNumber = model.getLineCount(); lineNumber >= 1; lineNumber--) {168const lineLength = model.getLineLength(lineNumber);169if (lineLength > 0) {170// this line has content171return lineNumber;172}173}174// no line has content175return 0;176}177178private doTrimFinalNewLines(model: ITextModel, isAutoSaved: boolean): void {179const lineCount = model.getLineCount();180181// Do not insert new line if file does not end with new line182if (lineCount === 1) {183return;184}185186let prevSelection: Selection[] = [];187let cannotTouchLineNumber = 0;188const editor = findEditor(model, this.codeEditorService);189if (editor) {190prevSelection = editor.getSelections();191if (isAutoSaved) {192for (let i = 0, len = prevSelection.length; i < len; i++) {193const positionLineNumber = prevSelection[i].positionLineNumber;194if (positionLineNumber > cannotTouchLineNumber) {195cannotTouchLineNumber = positionLineNumber;196}197}198}199}200201const lastNonEmptyLine = this.findLastNonEmptyLine(model);202const deleteFromLineNumber = Math.max(lastNonEmptyLine + 1, cannotTouchLineNumber + 1);203const deletionRange = model.validateRange(new Range(deleteFromLineNumber, 1, lineCount, model.getLineMaxColumn(lineCount)));204205if (deletionRange.isEmpty()) {206return;207}208209model.pushEditOperations(prevSelection, [EditOperation.delete(deletionRange)], _edits => prevSelection);210211editor?.setSelections(prevSelection);212}213}214215class FormatOnSaveParticipant implements ITextFileSaveParticipant {216217constructor(218@IConfigurationService private readonly configurationService: IConfigurationService,219@ICodeEditorService private readonly codeEditorService: ICodeEditorService,220@IInstantiationService private readonly instantiationService: IInstantiationService,221) {222// Nothing223}224225async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {226if (!model.textEditorModel) {227return;228}229if (context.reason === SaveReason.AUTO) {230return undefined;231}232233const textEditorModel = model.textEditorModel;234const overrides = { overrideIdentifier: textEditorModel.getLanguageId(), resource: textEditorModel.uri };235236const nestedProgress = new Progress<{ displayName?: string; extensionId?: ExtensionIdentifier }>(provider => {237progress.report({238message: localize(239{ key: 'formatting2', comment: ['[configure]({1}) is a link. Only translate `configure`. Do not change brackets and parentheses or {1}'] },240"Running '{0}' Formatter ([configure]({1})).",241provider.displayName || provider.extensionId && provider.extensionId.value || '???',242createCommandUri('workbench.action.openSettings', 'editor.formatOnSave').toString(),243)244});245});246247const enabled = this.configurationService.getValue<boolean>('editor.formatOnSave', overrides);248if (!enabled) {249return undefined;250}251252const editorOrModel = findEditor(textEditorModel, this.codeEditorService) || textEditorModel;253const mode = this.configurationService.getValue<'file' | 'modifications' | 'modificationsIfAvailable'>('editor.formatOnSaveMode', overrides);254255if (mode === 'file') {256await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, nestedProgress, token);257258} else {259const ranges = await this.instantiationService.invokeFunction(getModifiedRanges, isCodeEditor(editorOrModel) ? editorOrModel.getModel() : editorOrModel);260if (ranges === null && mode === 'modificationsIfAvailable') {261// no SCM, fallback to formatting the whole file iff wanted262await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, nestedProgress, token);263264} else if (ranges) {265// formatted modified ranges266await this.instantiationService.invokeFunction(formatDocumentRangesWithSelectedProvider, editorOrModel, ranges, FormattingMode.Silent, nestedProgress, token, false);267}268}269}270}271272class CodeActionOnSaveParticipant extends Disposable implements ITextFileSaveParticipant {273274constructor(275@IConfigurationService private readonly configurationService: IConfigurationService,276@IInstantiationService private readonly instantiationService: IInstantiationService,277@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,278@IHostService private readonly hostService: IHostService,279@IEditorService private readonly editorService: IEditorService,280@ICodeEditorService private readonly codeEditorService: ICodeEditorService,281) {282super();283284this._register(this.hostService.onDidChangeFocus(() => { this.triggerCodeActionsCommand(); }));285this._register(this.editorService.onDidActiveEditorChange(() => { this.triggerCodeActionsCommand(); }));286}287288private async triggerCodeActionsCommand() {289if (this.configurationService.getValue<boolean>('editor.codeActions.triggerOnFocusChange') && this.configurationService.getValue<string>('files.autoSave') === 'afterDelay') {290const model = this.codeEditorService.getActiveCodeEditor()?.getModel();291if (!model) {292return undefined;293}294295const settingsOverrides = { overrideIdentifier: model.getLanguageId(), resource: model.uri };296const setting = this.configurationService.getValue<{ [kind: string]: string | boolean } | string[]>('editor.codeActionsOnSave', settingsOverrides);297298if (!setting) {299return undefined;300}301302if (Array.isArray(setting)) {303return undefined;304}305306const settingItems: string[] = Object.keys(setting).filter(x => setting[x] && setting[x] === 'always' && CodeActionKind.Source.contains(new HierarchicalKind(x)));307308const cancellationTokenSource = new CancellationTokenSource();309310const codeActionKindList = [];311for (const item of settingItems) {312codeActionKindList.push(new HierarchicalKind(item));313}314315// run code actions based on what is found from setting === 'always', no exclusions.316await this.applyOnSaveActions(model, codeActionKindList, [], Progress.None, cancellationTokenSource.token);317}318}319320async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {321if (!model.textEditorModel) {322return;323}324325const textEditorModel = model.textEditorModel;326const settingsOverrides = { overrideIdentifier: textEditorModel.getLanguageId(), resource: textEditorModel.uri };327328// Convert boolean values to strings329const setting = this.configurationService.getValue<{ [kind: string]: string | boolean } | string[]>('editor.codeActionsOnSave', settingsOverrides);330if (!setting) {331return undefined;332}333334if (context.reason === SaveReason.AUTO) {335return undefined;336}337338if (context.reason !== SaveReason.EXPLICIT && Array.isArray(setting)) {339return undefined;340}341342const settingItems: string[] = Array.isArray(setting)343? setting344: Object.keys(setting).filter(x => setting[x] && setting[x] !== 'never');345346const codeActionsOnSave = this.createCodeActionsOnSave(settingItems);347348if (!Array.isArray(setting)) {349codeActionsOnSave.sort((a, b) => {350if (CodeActionKind.SourceFixAll.contains(a)) {351if (CodeActionKind.SourceFixAll.contains(b)) {352return 0;353}354return -1;355}356if (CodeActionKind.SourceFixAll.contains(b)) {357return 1;358}359return 0;360});361}362363if (!codeActionsOnSave.length) {364return undefined;365}366const excludedActions = Array.isArray(setting)367? []368: Object.keys(setting)369.filter(x => setting[x] === 'never' || false)370.map(x => new HierarchicalKind(x));371372progress.report({ message: localize('codeaction', "Quick Fixes") });373374const filteredSaveList = Array.isArray(setting) ? codeActionsOnSave : codeActionsOnSave.filter(x => setting[x.value] === 'always' || ((setting[x.value] === 'explicit' || setting[x.value] === true) && context.reason === SaveReason.EXPLICIT));375376await this.applyOnSaveActions(textEditorModel, filteredSaveList, excludedActions, progress, token);377}378379private createCodeActionsOnSave(settingItems: readonly string[]): HierarchicalKind[] {380const kinds = settingItems.map(x => new HierarchicalKind(x));381382// Remove subsets383return kinds.filter(kind => {384return kinds.every(otherKind => otherKind.equals(kind) || !otherKind.contains(kind));385});386}387388private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly HierarchicalKind[], excludes: readonly HierarchicalKind[], progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {389390const getActionProgress = new class implements IProgress<CodeActionProvider> {391private _names = new Set<string>();392private _report(): void {393progress.report({394message: localize(395{ key: 'codeaction.get2', comment: ['[configure]({1}) is a link. Only translate `configure`. Do not change brackets and parentheses or {1}'] },396"Getting code actions from {0} ([configure]({1})).",397[...this._names].map(name => `'${name}'`).join(', '),398createCommandUri('workbench.action.openSettings', 'editor.codeActionsOnSave').toString()399)400});401}402report(provider: CodeActionProvider) {403if (provider.displayName && !this._names.has(provider.displayName)) {404this._names.add(provider.displayName);405this._report();406}407}408};409410for (const codeActionKind of codeActionsOnSave) {411const actionsToRun = await this.getActionsToRun(model, codeActionKind, excludes, getActionProgress, token);412413if (token.isCancellationRequested) {414actionsToRun.dispose();415return;416}417418try {419for (const action of actionsToRun.validActions) {420progress.report({ message: localize('codeAction.apply', "Applying code action '{0}'.", action.action.title) });421await this.instantiationService.invokeFunction(applyCodeAction, action, ApplyCodeActionReason.OnSave, {}, token);422if (token.isCancellationRequested) {423return;424}425}426} catch {427// Failure to apply a code action should not block other on save actions428} finally {429actionsToRun.dispose();430}431}432}433434private getActionsToRun(model: ITextModel, codeActionKind: HierarchicalKind, excludes: readonly HierarchicalKind[], progress: IProgress<CodeActionProvider>, token: CancellationToken) {435return getCodeActions(this.languageFeaturesService.codeActionProvider, model, model.getFullModelRange(), {436type: CodeActionTriggerType.Auto,437triggerAction: CodeActionTriggerSource.OnSave,438filter: { include: codeActionKind, excludes: excludes, includeSourceActions: true },439}, progress, token);440}441}442443export class SaveParticipantsContribution extends Disposable implements IWorkbenchContribution {444445constructor(446@IInstantiationService private readonly instantiationService: IInstantiationService,447@ITextFileService private readonly textFileService: ITextFileService448) {449super();450451this.registerSaveParticipants();452}453454private registerSaveParticipants(): void {455this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimWhitespaceParticipant)));456this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(CodeActionOnSaveParticipant)));457this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FormatOnSaveParticipant)));458this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FinalNewLineParticipant)));459this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimFinalNewLinesParticipant)));460}461}462463const workbenchContributionsRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchContributionsExtensions.Workbench);464workbenchContributionsRegistry.registerWorkbenchContribution(SaveParticipantsContribution, LifecyclePhase.Restored);465466467