Path: blob/main/src/vs/workbench/contrib/codeEditor/browser/saveParticipants.ts
3296 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 { Disposable } from '../../../../base/common/lifecycle.js';8import * as strings from '../../../../base/common/strings.js';9import { IActiveCodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js';10import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';11import { trimTrailingWhitespace } from '../../../../editor/common/commands/trimTrailingWhitespaceCommand.js';12import { EditOperation } from '../../../../editor/common/core/editOperation.js';13import { Position } from '../../../../editor/common/core/position.js';14import { Range } from '../../../../editor/common/core/range.js';15import { Selection } from '../../../../editor/common/core/selection.js';16import { CodeActionProvider, CodeActionTriggerType } from '../../../../editor/common/languages.js';17import { ITextModel } from '../../../../editor/common/model.js';18import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';19import { ApplyCodeActionReason, applyCodeAction, getCodeActions } from '../../../../editor/contrib/codeAction/browser/codeAction.js';20import { CodeActionKind, CodeActionTriggerSource } from '../../../../editor/contrib/codeAction/common/types.js';21import { FormattingMode, formatDocumentRangesWithSelectedProvider, formatDocumentWithSelectedProvider } from '../../../../editor/contrib/format/browser/format.js';22import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js';23import { localize } from '../../../../nls.js';24import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';25import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';26import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';27import { IProgress, IProgressStep, Progress } from '../../../../platform/progress/common/progress.js';28import { Registry } from '../../../../platform/registry/common/platform.js';29import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchContributionsExtensions } from '../../../common/contributions.js';30import { SaveReason } from '../../../common/editor.js';31import { IEditorService } from '../../../services/editor/common/editorService.js';32import { IHostService } from '../../../services/host/browser/host.js';33import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';34import { ITextFileEditorModel, ITextFileSaveParticipant, ITextFileSaveParticipantContext, ITextFileService } from '../../../services/textfile/common/textfiles.js';35import { getModifiedRanges } from '../../format/browser/formatModified.js';3637export class TrimWhitespaceParticipant implements ITextFileSaveParticipant {3839constructor(40@IConfigurationService private readonly configurationService: IConfigurationService,41@ICodeEditorService private readonly codeEditorService: ICodeEditorService42) {43// Nothing44}4546async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {47if (!model.textEditorModel) {48return;49}5051const trimTrailingWhitespaceOption = this.configurationService.getValue<boolean>('files.trimTrailingWhitespace', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource });52const trimInRegexAndStrings = this.configurationService.getValue<boolean>('files.trimTrailingWhitespaceInRegexAndStrings', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource });53if (trimTrailingWhitespaceOption) {54this.doTrimTrailingWhitespace(model.textEditorModel, context.reason === SaveReason.AUTO, trimInRegexAndStrings);55}56}5758private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean, trimInRegexesAndStrings: boolean): void {59let prevSelection: Selection[] = [];60let cursors: Position[] = [];6162const editor = findEditor(model, this.codeEditorService);63if (editor) {64// Find `prevSelection` in any case do ensure a good undo stack when pushing the edit65// Collect active cursors in `cursors` only if `isAutoSaved` to avoid having the cursors jump66prevSelection = editor.getSelections();67if (isAutoSaved) {68cursors = prevSelection.map(s => s.getPosition());69const snippetsRange = SnippetController2.get(editor)?.getSessionEnclosingRange();70if (snippetsRange) {71for (let lineNumber = snippetsRange.startLineNumber; lineNumber <= snippetsRange.endLineNumber; lineNumber++) {72cursors.push(new Position(lineNumber, model.getLineMaxColumn(lineNumber)));73}74}75}76}7778const ops = trimTrailingWhitespace(model, cursors, trimInRegexesAndStrings);79if (!ops.length) {80return; // Nothing to do81}8283model.pushEditOperations(prevSelection, ops, (_edits) => prevSelection);84}85}8687function findEditor(model: ITextModel, codeEditorService: ICodeEditorService): IActiveCodeEditor | null {88let candidate: IActiveCodeEditor | null = null;8990if (model.isAttachedToEditor()) {91for (const editor of codeEditorService.listCodeEditors()) {92if (editor.hasModel() && editor.getModel() === model) {93if (editor.hasTextFocus()) {94return editor; // favour focused editor if there are multiple95}9697candidate = editor;98}99}100}101102return candidate;103}104105export class FinalNewLineParticipant implements ITextFileSaveParticipant {106107constructor(108@IConfigurationService private readonly configurationService: IConfigurationService,109@ICodeEditorService private readonly codeEditorService: ICodeEditorService110) {111// Nothing112}113114async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {115if (!model.textEditorModel) {116return;117}118119if (this.configurationService.getValue('files.insertFinalNewline', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource })) {120this.doInsertFinalNewLine(model.textEditorModel);121}122}123124private doInsertFinalNewLine(model: ITextModel): void {125const lineCount = model.getLineCount();126const lastLine = model.getLineContent(lineCount);127const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1;128129if (!lineCount || lastLineIsEmptyOrWhitespace) {130return;131}132133const edits = [EditOperation.insert(new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL())];134const editor = findEditor(model, this.codeEditorService);135if (editor) {136editor.executeEdits('insertFinalNewLine', edits, editor.getSelections());137} else {138model.pushEditOperations([], edits, () => null);139}140}141}142143export class TrimFinalNewLinesParticipant implements ITextFileSaveParticipant {144145constructor(146@IConfigurationService private readonly configurationService: IConfigurationService,147@ICodeEditorService private readonly codeEditorService: ICodeEditorService148) {149// Nothing150}151152async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {153if (!model.textEditorModel) {154return;155}156157if (this.configurationService.getValue('files.trimFinalNewlines', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource })) {158this.doTrimFinalNewLines(model.textEditorModel, context.reason === SaveReason.AUTO);159}160}161162/**163* returns 0 if the entire file is empty164*/165private findLastNonEmptyLine(model: ITextModel): number {166for (let lineNumber = model.getLineCount(); lineNumber >= 1; lineNumber--) {167const lineLength = model.getLineLength(lineNumber);168if (lineLength > 0) {169// this line has content170return lineNumber;171}172}173// no line has content174return 0;175}176177private doTrimFinalNewLines(model: ITextModel, isAutoSaved: boolean): void {178const lineCount = model.getLineCount();179180// Do not insert new line if file does not end with new line181if (lineCount === 1) {182return;183}184185let prevSelection: Selection[] = [];186let cannotTouchLineNumber = 0;187const editor = findEditor(model, this.codeEditorService);188if (editor) {189prevSelection = editor.getSelections();190if (isAutoSaved) {191for (let i = 0, len = prevSelection.length; i < len; i++) {192const positionLineNumber = prevSelection[i].positionLineNumber;193if (positionLineNumber > cannotTouchLineNumber) {194cannotTouchLineNumber = positionLineNumber;195}196}197}198}199200const lastNonEmptyLine = this.findLastNonEmptyLine(model);201const deleteFromLineNumber = Math.max(lastNonEmptyLine + 1, cannotTouchLineNumber + 1);202const deletionRange = model.validateRange(new Range(deleteFromLineNumber, 1, lineCount, model.getLineMaxColumn(lineCount)));203204if (deletionRange.isEmpty()) {205return;206}207208model.pushEditOperations(prevSelection, [EditOperation.delete(deletionRange)], _edits => prevSelection);209210editor?.setSelections(prevSelection);211}212}213214class FormatOnSaveParticipant implements ITextFileSaveParticipant {215216constructor(217@IConfigurationService private readonly configurationService: IConfigurationService,218@ICodeEditorService private readonly codeEditorService: ICodeEditorService,219@IInstantiationService private readonly instantiationService: IInstantiationService,220) {221// Nothing222}223224async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {225if (!model.textEditorModel) {226return;227}228if (context.reason === SaveReason.AUTO) {229return undefined;230}231232const textEditorModel = model.textEditorModel;233const overrides = { overrideIdentifier: textEditorModel.getLanguageId(), resource: textEditorModel.uri };234235const nestedProgress = new Progress<{ displayName?: string; extensionId?: ExtensionIdentifier }>(provider => {236progress.report({237message: localize(238{ key: 'formatting2', comment: ['[configure]({1}) is a link. Only translate `configure`. Do not change brackets and parentheses or {1}'] },239"Running '{0}' Formatter ([configure]({1})).",240provider.displayName || provider.extensionId && provider.extensionId.value || '???',241'command:workbench.action.openSettings?%5B%22editor.formatOnSave%22%5D'242)243});244});245246const enabled = this.configurationService.getValue<boolean>('editor.formatOnSave', overrides);247if (!enabled) {248return undefined;249}250251const editorOrModel = findEditor(textEditorModel, this.codeEditorService) || textEditorModel;252const mode = this.configurationService.getValue<'file' | 'modifications' | 'modificationsIfAvailable'>('editor.formatOnSaveMode', overrides);253254if (mode === 'file') {255await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, nestedProgress, token);256257} else {258const ranges = await this.instantiationService.invokeFunction(getModifiedRanges, isCodeEditor(editorOrModel) ? editorOrModel.getModel() : editorOrModel);259if (ranges === null && mode === 'modificationsIfAvailable') {260// no SCM, fallback to formatting the whole file iff wanted261await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, nestedProgress, token);262263} else if (ranges) {264// formatted modified ranges265await this.instantiationService.invokeFunction(formatDocumentRangesWithSelectedProvider, editorOrModel, ranges, FormattingMode.Silent, nestedProgress, token, false);266}267}268}269}270271class CodeActionOnSaveParticipant extends Disposable implements ITextFileSaveParticipant {272273constructor(274@IConfigurationService private readonly configurationService: IConfigurationService,275@IInstantiationService private readonly instantiationService: IInstantiationService,276@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,277@IHostService private readonly hostService: IHostService,278@IEditorService private readonly editorService: IEditorService,279@ICodeEditorService private readonly codeEditorService: ICodeEditorService,280) {281super();282283this._register(this.hostService.onDidChangeFocus(() => { this.triggerCodeActionsCommand(); }));284this._register(this.editorService.onDidActiveEditorChange(() => { this.triggerCodeActionsCommand(); }));285}286287private async triggerCodeActionsCommand() {288if (this.configurationService.getValue<boolean>('editor.codeActions.triggerOnFocusChange') && this.configurationService.getValue<string>('files.autoSave') === 'afterDelay') {289const model = this.codeEditorService.getActiveCodeEditor()?.getModel();290if (!model) {291return undefined;292}293294const settingsOverrides = { overrideIdentifier: model.getLanguageId(), resource: model.uri };295const setting = this.configurationService.getValue<{ [kind: string]: string | boolean } | string[]>('editor.codeActionsOnSave', settingsOverrides);296297if (!setting) {298return undefined;299}300301if (Array.isArray(setting)) {302return undefined;303}304305const settingItems: string[] = Object.keys(setting).filter(x => setting[x] && setting[x] === 'always' && CodeActionKind.Source.contains(new HierarchicalKind(x)));306307const cancellationTokenSource = new CancellationTokenSource();308309const codeActionKindList = [];310for (const item of settingItems) {311codeActionKindList.push(new HierarchicalKind(item));312}313314// run code actions based on what is found from setting === 'always', no exclusions.315await this.applyOnSaveActions(model, codeActionKindList, [], Progress.None, cancellationTokenSource.token);316}317}318319async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {320if (!model.textEditorModel) {321return;322}323324const textEditorModel = model.textEditorModel;325const settingsOverrides = { overrideIdentifier: textEditorModel.getLanguageId(), resource: textEditorModel.uri };326327// Convert boolean values to strings328const setting = this.configurationService.getValue<{ [kind: string]: string | boolean } | string[]>('editor.codeActionsOnSave', settingsOverrides);329if (!setting) {330return undefined;331}332333if (context.reason === SaveReason.AUTO) {334return undefined;335}336337if (context.reason !== SaveReason.EXPLICIT && Array.isArray(setting)) {338return undefined;339}340341const settingItems: string[] = Array.isArray(setting)342? setting343: Object.keys(setting).filter(x => setting[x] && setting[x] !== 'never');344345const codeActionsOnSave = this.createCodeActionsOnSave(settingItems);346347if (!Array.isArray(setting)) {348codeActionsOnSave.sort((a, b) => {349if (CodeActionKind.SourceFixAll.contains(a)) {350if (CodeActionKind.SourceFixAll.contains(b)) {351return 0;352}353return -1;354}355if (CodeActionKind.SourceFixAll.contains(b)) {356return 1;357}358return 0;359});360}361362if (!codeActionsOnSave.length) {363return undefined;364}365const excludedActions = Array.isArray(setting)366? []367: Object.keys(setting)368.filter(x => setting[x] === 'never' || false)369.map(x => new HierarchicalKind(x));370371progress.report({ message: localize('codeaction', "Quick Fixes") });372373const filteredSaveList = Array.isArray(setting) ? codeActionsOnSave : codeActionsOnSave.filter(x => setting[x.value] === 'always' || ((setting[x.value] === 'explicit' || setting[x.value] === true) && context.reason === SaveReason.EXPLICIT));374375await this.applyOnSaveActions(textEditorModel, filteredSaveList, excludedActions, progress, token);376}377378private createCodeActionsOnSave(settingItems: readonly string[]): HierarchicalKind[] {379const kinds = settingItems.map(x => new HierarchicalKind(x));380381// Remove subsets382return kinds.filter(kind => {383return kinds.every(otherKind => otherKind.equals(kind) || !otherKind.contains(kind));384});385}386387private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly HierarchicalKind[], excludes: readonly HierarchicalKind[], progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {388389const getActionProgress = new class implements IProgress<CodeActionProvider> {390private _names = new Set<string>();391private _report(): void {392progress.report({393message: localize(394{ key: 'codeaction.get2', comment: ['[configure]({1}) is a link. Only translate `configure`. Do not change brackets and parentheses or {1}'] },395"Getting code actions from {0} ([configure]({1})).",396[...this._names].map(name => `'${name}'`).join(', '),397'command:workbench.action.openSettings?%5B%22editor.codeActionsOnSave%22%5D'398)399});400}401report(provider: CodeActionProvider) {402if (provider.displayName && !this._names.has(provider.displayName)) {403this._names.add(provider.displayName);404this._report();405}406}407};408409for (const codeActionKind of codeActionsOnSave) {410const actionsToRun = await this.getActionsToRun(model, codeActionKind, excludes, getActionProgress, token);411412if (token.isCancellationRequested) {413actionsToRun.dispose();414return;415}416417try {418for (const action of actionsToRun.validActions) {419progress.report({ message: localize('codeAction.apply', "Applying code action '{0}'.", action.action.title) });420await this.instantiationService.invokeFunction(applyCodeAction, action, ApplyCodeActionReason.OnSave, {}, token);421if (token.isCancellationRequested) {422return;423}424}425} catch {426// Failure to apply a code action should not block other on save actions427} finally {428actionsToRun.dispose();429}430}431}432433private getActionsToRun(model: ITextModel, codeActionKind: HierarchicalKind, excludes: readonly HierarchicalKind[], progress: IProgress<CodeActionProvider>, token: CancellationToken) {434return getCodeActions(this.languageFeaturesService.codeActionProvider, model, model.getFullModelRange(), {435type: CodeActionTriggerType.Auto,436triggerAction: CodeActionTriggerSource.OnSave,437filter: { include: codeActionKind, excludes: excludes, includeSourceActions: true },438}, progress, token);439}440}441442export class SaveParticipantsContribution extends Disposable implements IWorkbenchContribution {443444constructor(445@IInstantiationService private readonly instantiationService: IInstantiationService,446@ITextFileService private readonly textFileService: ITextFileService447) {448super();449450this.registerSaveParticipants();451}452453private registerSaveParticipants(): void {454this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimWhitespaceParticipant)));455this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(CodeActionOnSaveParticipant)));456this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FormatOnSaveParticipant)));457this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FinalNewLineParticipant)));458this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimFinalNewLinesParticipant)));459}460}461462const workbenchContributionsRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchContributionsExtensions.Workbench);463workbenchContributionsRegistry.registerWorkbenchContribution(SaveParticipantsContribution, LifecyclePhase.Restored);464465466