Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/codeEditor/browser/saveParticipants.ts
5237 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
7
import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js';
8
import { createCommandUri } from '../../../../base/common/htmlContent.js';
9
import { Disposable } from '../../../../base/common/lifecycle.js';
10
import * as strings from '../../../../base/common/strings.js';
11
import { IActiveCodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
12
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
13
import { trimTrailingWhitespace } from '../../../../editor/common/commands/trimTrailingWhitespaceCommand.js';
14
import { EditOperation } from '../../../../editor/common/core/editOperation.js';
15
import { Position } from '../../../../editor/common/core/position.js';
16
import { Range } from '../../../../editor/common/core/range.js';
17
import { Selection } from '../../../../editor/common/core/selection.js';
18
import { CodeActionProvider, CodeActionTriggerType } from '../../../../editor/common/languages.js';
19
import { ITextModel } from '../../../../editor/common/model.js';
20
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
21
import { ApplyCodeActionReason, applyCodeAction, getCodeActions } from '../../../../editor/contrib/codeAction/browser/codeAction.js';
22
import { CodeActionKind, CodeActionTriggerSource } from '../../../../editor/contrib/codeAction/common/types.js';
23
import { FormattingMode, formatDocumentRangesWithSelectedProvider, formatDocumentWithSelectedProvider } from '../../../../editor/contrib/format/browser/format.js';
24
import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js';
25
import { localize } from '../../../../nls.js';
26
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
27
import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';
28
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
29
import { IProgress, IProgressStep, Progress } from '../../../../platform/progress/common/progress.js';
30
import { Registry } from '../../../../platform/registry/common/platform.js';
31
import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchContributionsExtensions } from '../../../common/contributions.js';
32
import { SaveReason } from '../../../common/editor.js';
33
import { IEditorService } from '../../../services/editor/common/editorService.js';
34
import { IHostService } from '../../../services/host/browser/host.js';
35
import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';
36
import { ITextFileEditorModel, ITextFileSaveParticipant, ITextFileSaveParticipantContext, ITextFileService } from '../../../services/textfile/common/textfiles.js';
37
import { getModifiedRanges } from '../../format/browser/formatModified.js';
38
39
export class TrimWhitespaceParticipant implements ITextFileSaveParticipant {
40
41
constructor(
42
@IConfigurationService private readonly configurationService: IConfigurationService,
43
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
44
) {
45
// Nothing
46
}
47
48
async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {
49
if (!model.textEditorModel) {
50
return;
51
}
52
53
const trimTrailingWhitespaceOption = this.configurationService.getValue<boolean>('files.trimTrailingWhitespace', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource });
54
const trimInRegexAndStrings = this.configurationService.getValue<boolean>('files.trimTrailingWhitespaceInRegexAndStrings', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource });
55
if (trimTrailingWhitespaceOption) {
56
this.doTrimTrailingWhitespace(model.textEditorModel, context.reason === SaveReason.AUTO, trimInRegexAndStrings);
57
}
58
}
59
60
private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean, trimInRegexesAndStrings: boolean): void {
61
let prevSelection: Selection[] = [];
62
let cursors: Position[] = [];
63
64
const editor = findEditor(model, this.codeEditorService);
65
if (editor) {
66
// Find `prevSelection` in any case do ensure a good undo stack when pushing the edit
67
// Collect active cursors in `cursors` only if `isAutoSaved` to avoid having the cursors jump
68
prevSelection = editor.getSelections();
69
if (isAutoSaved) {
70
cursors = prevSelection.map(s => s.getPosition());
71
const snippetsRange = SnippetController2.get(editor)?.getSessionEnclosingRange();
72
if (snippetsRange) {
73
for (let lineNumber = snippetsRange.startLineNumber; lineNumber <= snippetsRange.endLineNumber; lineNumber++) {
74
cursors.push(new Position(lineNumber, model.getLineMaxColumn(lineNumber)));
75
}
76
}
77
}
78
}
79
80
const ops = trimTrailingWhitespace(model, cursors, trimInRegexesAndStrings);
81
if (!ops.length) {
82
return; // Nothing to do
83
}
84
85
model.pushEditOperations(prevSelection, ops, (_edits) => prevSelection);
86
}
87
}
88
89
function findEditor(model: ITextModel, codeEditorService: ICodeEditorService): IActiveCodeEditor | null {
90
let candidate: IActiveCodeEditor | null = null;
91
92
if (model.isAttachedToEditor()) {
93
for (const editor of codeEditorService.listCodeEditors()) {
94
if (editor.hasModel() && editor.getModel() === model) {
95
if (editor.hasTextFocus()) {
96
return editor; // favour focused editor if there are multiple
97
}
98
99
candidate = editor;
100
}
101
}
102
}
103
104
return candidate;
105
}
106
107
export class FinalNewLineParticipant implements ITextFileSaveParticipant {
108
109
constructor(
110
@IConfigurationService private readonly configurationService: IConfigurationService,
111
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
112
) {
113
// Nothing
114
}
115
116
async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {
117
if (!model.textEditorModel) {
118
return;
119
}
120
121
if (this.configurationService.getValue('files.insertFinalNewline', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource })) {
122
this.doInsertFinalNewLine(model.textEditorModel);
123
}
124
}
125
126
private doInsertFinalNewLine(model: ITextModel): void {
127
const lineCount = model.getLineCount();
128
const lastLine = model.getLineContent(lineCount);
129
const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1;
130
131
if (!lineCount || lastLineIsEmptyOrWhitespace) {
132
return;
133
}
134
135
const edits = [EditOperation.insert(new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL())];
136
const editor = findEditor(model, this.codeEditorService);
137
if (editor) {
138
editor.executeEdits('insertFinalNewLine', edits, editor.getSelections());
139
} else {
140
model.pushEditOperations([], edits, () => null);
141
}
142
}
143
}
144
145
export class TrimFinalNewLinesParticipant implements ITextFileSaveParticipant {
146
147
constructor(
148
@IConfigurationService private readonly configurationService: IConfigurationService,
149
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
150
) {
151
// Nothing
152
}
153
154
async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext): Promise<void> {
155
if (!model.textEditorModel) {
156
return;
157
}
158
159
if (this.configurationService.getValue('files.trimFinalNewlines', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource })) {
160
this.doTrimFinalNewLines(model.textEditorModel, context.reason === SaveReason.AUTO);
161
}
162
}
163
164
/**
165
* returns 0 if the entire file is empty
166
*/
167
private findLastNonEmptyLine(model: ITextModel): number {
168
for (let lineNumber = model.getLineCount(); lineNumber >= 1; lineNumber--) {
169
const lineLength = model.getLineLength(lineNumber);
170
if (lineLength > 0) {
171
// this line has content
172
return lineNumber;
173
}
174
}
175
// no line has content
176
return 0;
177
}
178
179
private doTrimFinalNewLines(model: ITextModel, isAutoSaved: boolean): void {
180
const lineCount = model.getLineCount();
181
182
// Do not insert new line if file does not end with new line
183
if (lineCount === 1) {
184
return;
185
}
186
187
let prevSelection: Selection[] = [];
188
let cannotTouchLineNumber = 0;
189
const editor = findEditor(model, this.codeEditorService);
190
if (editor) {
191
prevSelection = editor.getSelections();
192
if (isAutoSaved) {
193
for (let i = 0, len = prevSelection.length; i < len; i++) {
194
const positionLineNumber = prevSelection[i].positionLineNumber;
195
if (positionLineNumber > cannotTouchLineNumber) {
196
cannotTouchLineNumber = positionLineNumber;
197
}
198
}
199
}
200
}
201
202
const lastNonEmptyLine = this.findLastNonEmptyLine(model);
203
const deleteFromLineNumber = Math.max(lastNonEmptyLine + 1, cannotTouchLineNumber + 1);
204
const deletionRange = model.validateRange(new Range(deleteFromLineNumber, 1, lineCount, model.getLineMaxColumn(lineCount)));
205
206
if (deletionRange.isEmpty()) {
207
return;
208
}
209
210
model.pushEditOperations(prevSelection, [EditOperation.delete(deletionRange)], _edits => prevSelection);
211
212
editor?.setSelections(prevSelection);
213
}
214
}
215
216
class FormatOnSaveParticipant implements ITextFileSaveParticipant {
217
218
constructor(
219
@IConfigurationService private readonly configurationService: IConfigurationService,
220
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
221
@IInstantiationService private readonly instantiationService: IInstantiationService,
222
) {
223
// Nothing
224
}
225
226
async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
227
if (!model.textEditorModel) {
228
return;
229
}
230
if (context.reason === SaveReason.AUTO) {
231
return undefined;
232
}
233
234
const textEditorModel = model.textEditorModel;
235
const overrides = { overrideIdentifier: textEditorModel.getLanguageId(), resource: textEditorModel.uri };
236
237
const nestedProgress = new Progress<{ displayName?: string; extensionId?: ExtensionIdentifier }>(provider => {
238
progress.report({
239
message: localize(
240
{ key: 'formatting2', comment: ['[configure]({1}) is a link. Only translate `configure`. Do not change brackets and parentheses or {1}'] },
241
"Running '{0}' Formatter ([configure]({1})).",
242
provider.displayName || provider.extensionId && provider.extensionId.value || '???',
243
createCommandUri('workbench.action.openSettings', 'editor.formatOnSave').toString(),
244
)
245
});
246
});
247
248
const enabled = this.configurationService.getValue<boolean>('editor.formatOnSave', overrides);
249
if (!enabled) {
250
return undefined;
251
}
252
253
const editorOrModel = findEditor(textEditorModel, this.codeEditorService) || textEditorModel;
254
const mode = this.configurationService.getValue<'file' | 'modifications' | 'modificationsIfAvailable'>('editor.formatOnSaveMode', overrides);
255
256
if (mode === 'file') {
257
await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, nestedProgress, token);
258
259
} else {
260
const ranges = await this.instantiationService.invokeFunction(getModifiedRanges, isCodeEditor(editorOrModel) ? editorOrModel.getModel() : editorOrModel);
261
if (ranges === null && mode === 'modificationsIfAvailable') {
262
// no SCM, fallback to formatting the whole file iff wanted
263
await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, nestedProgress, token);
264
265
} else if (ranges) {
266
// formatted modified ranges
267
await this.instantiationService.invokeFunction(formatDocumentRangesWithSelectedProvider, editorOrModel, ranges, FormattingMode.Silent, nestedProgress, token, false);
268
}
269
}
270
}
271
}
272
273
class CodeActionOnSaveParticipant extends Disposable implements ITextFileSaveParticipant {
274
275
constructor(
276
@IConfigurationService private readonly configurationService: IConfigurationService,
277
@IInstantiationService private readonly instantiationService: IInstantiationService,
278
@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,
279
@IHostService private readonly hostService: IHostService,
280
@IEditorService private readonly editorService: IEditorService,
281
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
282
) {
283
super();
284
285
this._register(this.hostService.onDidChangeFocus(() => { this.triggerCodeActionsCommand(); }));
286
this._register(this.editorService.onDidActiveEditorChange(() => { this.triggerCodeActionsCommand(); }));
287
}
288
289
private async triggerCodeActionsCommand() {
290
if (this.configurationService.getValue<boolean>('editor.codeActions.triggerOnFocusChange') && this.configurationService.getValue<string>('files.autoSave') === 'afterDelay') {
291
const model = this.codeEditorService.getActiveCodeEditor()?.getModel();
292
if (!model) {
293
return undefined;
294
}
295
296
const settingsOverrides = { overrideIdentifier: model.getLanguageId(), resource: model.uri };
297
const setting = this.configurationService.getValue<{ [kind: string]: string | boolean } | string[]>('editor.codeActionsOnSave', settingsOverrides);
298
299
if (!setting) {
300
return undefined;
301
}
302
303
if (Array.isArray(setting)) {
304
return undefined;
305
}
306
307
const settingItems: string[] = Object.keys(setting).filter(x => setting[x] && setting[x] === 'always' && CodeActionKind.Source.contains(new HierarchicalKind(x)));
308
309
const cancellationTokenSource = new CancellationTokenSource();
310
311
const codeActionKindList = [];
312
for (const item of settingItems) {
313
codeActionKindList.push(new HierarchicalKind(item));
314
}
315
316
// run code actions based on what is found from setting === 'always', no exclusions.
317
await this.applyOnSaveActions(model, codeActionKindList, [], Progress.None, cancellationTokenSource.token);
318
}
319
}
320
321
async participate(model: ITextFileEditorModel, context: ITextFileSaveParticipantContext, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
322
if (!model.textEditorModel) {
323
return;
324
}
325
326
const textEditorModel = model.textEditorModel;
327
const settingsOverrides = { overrideIdentifier: textEditorModel.getLanguageId(), resource: textEditorModel.uri };
328
329
// Convert boolean values to strings
330
const setting = this.configurationService.getValue<{ [kind: string]: string | boolean } | string[]>('editor.codeActionsOnSave', settingsOverrides);
331
if (!setting) {
332
return undefined;
333
}
334
335
if (context.reason === SaveReason.AUTO) {
336
return undefined;
337
}
338
339
if (context.reason !== SaveReason.EXPLICIT && Array.isArray(setting)) {
340
return undefined;
341
}
342
343
const settingItems: string[] = Array.isArray(setting)
344
? setting
345
: Object.keys(setting).filter(x => setting[x] && setting[x] !== 'never');
346
347
const codeActionsOnSave = this.createCodeActionsOnSave(settingItems);
348
349
if (!Array.isArray(setting)) {
350
codeActionsOnSave.sort((a, b) => {
351
if (CodeActionKind.SourceFixAll.contains(a)) {
352
if (CodeActionKind.SourceFixAll.contains(b)) {
353
return 0;
354
}
355
return -1;
356
}
357
if (CodeActionKind.SourceFixAll.contains(b)) {
358
return 1;
359
}
360
return 0;
361
});
362
}
363
364
if (!codeActionsOnSave.length) {
365
return undefined;
366
}
367
const excludedActions = Array.isArray(setting)
368
? []
369
: Object.keys(setting)
370
.filter(x => setting[x] === 'never' || false)
371
.map(x => new HierarchicalKind(x));
372
373
progress.report({ message: localize('codeaction', "Quick Fixes") });
374
375
const filteredSaveList = Array.isArray(setting) ? codeActionsOnSave : codeActionsOnSave.filter(x => setting[x.value] === 'always' || ((setting[x.value] === 'explicit' || setting[x.value] === true) && context.reason === SaveReason.EXPLICIT));
376
377
await this.applyOnSaveActions(textEditorModel, filteredSaveList, excludedActions, progress, token);
378
}
379
380
private createCodeActionsOnSave(settingItems: readonly string[]): HierarchicalKind[] {
381
const kinds = settingItems.map(x => new HierarchicalKind(x));
382
383
// Remove subsets
384
return kinds.filter(kind => {
385
return kinds.every(otherKind => otherKind.equals(kind) || !otherKind.contains(kind));
386
});
387
}
388
389
private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly HierarchicalKind[], excludes: readonly HierarchicalKind[], progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
390
391
const getActionProgress = new class implements IProgress<CodeActionProvider> {
392
private _names = new Set<string>();
393
private _report(): void {
394
progress.report({
395
message: localize(
396
{ key: 'codeaction.get2', comment: ['[configure]({1}) is a link. Only translate `configure`. Do not change brackets and parentheses or {1}'] },
397
"Getting code actions from {0} ([configure]({1})).",
398
[...this._names].map(name => `'${name}'`).join(', '),
399
createCommandUri('workbench.action.openSettings', 'editor.codeActionsOnSave').toString()
400
)
401
});
402
}
403
report(provider: CodeActionProvider) {
404
if (provider.displayName && !this._names.has(provider.displayName)) {
405
this._names.add(provider.displayName);
406
this._report();
407
}
408
}
409
};
410
411
for (const codeActionKind of codeActionsOnSave) {
412
const actionsToRun = await this.getActionsToRun(model, codeActionKind, excludes, getActionProgress, token);
413
414
if (token.isCancellationRequested) {
415
actionsToRun.dispose();
416
return;
417
}
418
419
try {
420
for (const action of actionsToRun.validActions) {
421
progress.report({ message: localize('codeAction.apply', "Applying code action '{0}'.", action.action.title) });
422
await this.instantiationService.invokeFunction(applyCodeAction, action, ApplyCodeActionReason.OnSave, {}, token);
423
if (token.isCancellationRequested) {
424
return;
425
}
426
}
427
} catch {
428
// Failure to apply a code action should not block other on save actions
429
} finally {
430
actionsToRun.dispose();
431
}
432
}
433
}
434
435
private getActionsToRun(model: ITextModel, codeActionKind: HierarchicalKind, excludes: readonly HierarchicalKind[], progress: IProgress<CodeActionProvider>, token: CancellationToken) {
436
return getCodeActions(this.languageFeaturesService.codeActionProvider, model, model.getFullModelRange(), {
437
type: CodeActionTriggerType.Auto,
438
triggerAction: CodeActionTriggerSource.OnSave,
439
filter: { include: codeActionKind, excludes: excludes, includeSourceActions: true },
440
}, progress, token);
441
}
442
}
443
444
export class SaveParticipantsContribution extends Disposable implements IWorkbenchContribution {
445
446
constructor(
447
@IInstantiationService private readonly instantiationService: IInstantiationService,
448
@ITextFileService private readonly textFileService: ITextFileService
449
) {
450
super();
451
452
this.registerSaveParticipants();
453
}
454
455
private registerSaveParticipants(): void {
456
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimWhitespaceParticipant)));
457
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(CodeActionOnSaveParticipant)));
458
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FormatOnSaveParticipant)));
459
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FinalNewLineParticipant)));
460
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimFinalNewLinesParticipant)));
461
}
462
}
463
464
const workbenchContributionsRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchContributionsExtensions.Workbench);
465
workbenchContributionsRegistry.registerWorkbenchContribution(SaveParticipantsContribution, LifecyclePhase.Restored);
466
467