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