Skip to content

Commit caa4e55

Browse files
authored
Implement "code actions on save" (#15555)
Fixes #14955 Contributed on behalf of STMicroelectronics
1 parent d5c10c8 commit caa4e55

File tree

9 files changed

+320
-156
lines changed

9 files changed

+320
-156
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
## History
44

55
- [Previous Changelogs](https://github.com/eclipse-theia/theia/tree/master/doc/changelogs/)
6+
## 1.62.0 -
7+
8+
<a name="breaking_changes_1.62.0">[Breaking Changes:](#breaking_changes_1.62.0)</a>
9+
10+
- [monaco] Implement "code actions on save" [#15555](https://github.com/eclipse-theia/theia/pull/15555)
11+
Replaced `MonacoEditorModel.onWillSave` "wait until event" event with `registerWillSaveModelListener` for simpler semantics. Also removed the `EditorModelService.onWillSave`
12+
as it's pure convenience and unused in framework code.
613

714
## 1.62.0 -
815

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2025 STMicroelectronics and others.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import { CancellationToken } from '@theia/core';
18+
import { SaveOptions, SaveReason } from '@theia/core/lib/browser';
19+
import { MonacoEditor } from './monaco-editor';
20+
import { SaveParticipant, SAVE_PARTICIPANT_DEFAULT_ORDER } from './monaco-editor-provider';
21+
import { inject, injectable } from '@theia/core/shared/inversify';
22+
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
23+
import { ILanguageFeaturesService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures';
24+
import { CodeActionKind, CodeActionSet, CodeActionTriggerSource } from '@theia/monaco-editor-core/esm/vs/editor/contrib/codeAction/common/types';
25+
import { applyCodeAction, ApplyCodeActionReason, getCodeActions } from '@theia/monaco-editor-core/esm/vs/editor/contrib/codeAction/browser/codeAction';
26+
27+
import { HierarchicalKind } from '@theia/monaco-editor-core/esm/vs/base/common/hierarchicalKind';
28+
import { EditorPreferences } from '@theia/editor/lib/browser';
29+
import { ITextModel } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
30+
import { CodeActionProvider, CodeActionTriggerType } from '@theia/monaco-editor-core/esm/vs/editor/common/languages';
31+
import { IProgress } from '@theia/monaco-editor-core/esm/vs/platform/progress/common/progress';
32+
import { IInstantiationService } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/instantiation';
33+
/*---------------------------------------------------------------------------------------------
34+
* Copyright (c) Microsoft Corporation. All rights reserved.
35+
* Licensed under the MIT License. See License.txt in the project root for license information.
36+
*--------------------------------------------------------------------------------------------*/
37+
// Partially copied from https://github.com/microsoft/vscode/blob/f66e839a38dfe39ee66a86619a790f9c2336d698/src/vs/workbench/contrib/codeEditor/browser/saveParticipants.ts#L272
38+
@injectable()
39+
export class MonacoCodeActionSaveParticipant implements SaveParticipant {
40+
@inject(EditorPreferences)
41+
protected readonly editorPreferences: EditorPreferences;
42+
43+
readonly order = SAVE_PARTICIPANT_DEFAULT_ORDER;
44+
45+
async applyChangesOnSave(editor: MonacoEditor, cancellationToken: CancellationToken, options?: SaveOptions): Promise<void> {
46+
if (options?.saveReason !== SaveReason.Manual) {
47+
return undefined;
48+
}
49+
50+
const setting = this.editorPreferences.get({
51+
preferenceName: 'editor.codeActionsOnSave',
52+
overrideIdentifier: editor.document.textEditorModel.getLanguageId()
53+
}, undefined, editor.document.textEditorModel.uri.toString());
54+
55+
if (!setting) {
56+
return undefined;
57+
}
58+
59+
const settingItems: string[] = Array.isArray(setting)
60+
? setting
61+
: Object.keys(setting).filter(x => setting[x]);
62+
63+
const codeActionsOnSave = this.createCodeActionsOnSave(settingItems);
64+
65+
if (!codeActionsOnSave.length) {
66+
return undefined;
67+
}
68+
69+
if (!Array.isArray(setting)) {
70+
codeActionsOnSave.sort((a, b) => {
71+
if (CodeActionKind.SourceFixAll.contains(a)) {
72+
if (CodeActionKind.SourceFixAll.contains(b)) {
73+
return 0;
74+
}
75+
return -1;
76+
}
77+
if (CodeActionKind.SourceFixAll.contains(b)) {
78+
return 1;
79+
}
80+
return 0;
81+
});
82+
}
83+
84+
const excludedActions = Array.isArray(setting)
85+
? []
86+
: Object.keys(setting)
87+
.filter(x => setting[x] === false)
88+
.map(x => new HierarchicalKind(x));
89+
90+
await this.applyOnSaveActions(editor.document.textEditorModel, codeActionsOnSave, excludedActions, cancellationToken);
91+
}
92+
93+
private createCodeActionsOnSave(settingItems: readonly string[]): HierarchicalKind[] {
94+
const kinds = settingItems.map(x => new HierarchicalKind(x));
95+
96+
// Remove subsets
97+
return kinds.filter(kind => kinds.every(otherKind => otherKind.equals(kind) || !otherKind.contains(kind)));
98+
}
99+
100+
private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly HierarchicalKind[],
101+
excludes: readonly HierarchicalKind[], token: CancellationToken): Promise<void> {
102+
103+
const instantiationService = StandaloneServices.get(IInstantiationService);
104+
105+
for (const codeActionKind of codeActionsOnSave) {
106+
const actionsToRun = await this.getActionsToRun(model, codeActionKind, excludes, token);
107+
108+
if (token.isCancellationRequested) {
109+
actionsToRun.dispose();
110+
return;
111+
}
112+
113+
try {
114+
for (const action of actionsToRun.validActions) {
115+
await instantiationService.invokeFunction(applyCodeAction, action, ApplyCodeActionReason.OnSave, {}, token);
116+
if (token.isCancellationRequested) {
117+
return;
118+
}
119+
}
120+
} catch {
121+
// Failure to apply a code action should not block other on save actions
122+
} finally {
123+
actionsToRun.dispose();
124+
}
125+
}
126+
}
127+
128+
private getActionsToRun(model: ITextModel, codeActionKind: HierarchicalKind, excludes: readonly HierarchicalKind[], token: CancellationToken): Promise<CodeActionSet> {
129+
const { codeActionProvider } = StandaloneServices.get(ILanguageFeaturesService);
130+
131+
const progress: IProgress<CodeActionProvider> = {
132+
report(item): void {
133+
// empty
134+
},
135+
};
136+
137+
return getCodeActions(codeActionProvider, model, model.getFullModelRange(), {
138+
type: CodeActionTriggerType.Auto,
139+
triggerAction: CodeActionTriggerSource.OnSave,
140+
filter: { include: codeActionKind, excludes: excludes, includeSourceActions: true },
141+
}, progress, token);
142+
}
143+
}

packages/monaco/src/browser/monaco-editor-model.ts

Lines changed: 25 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposa
2020
import { Emitter, Event } from '@theia/core/lib/common/event';
2121
import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common/cancellation';
2222
import { Resource, ResourceError, ResourceVersion } from '@theia/core/lib/common/resource';
23-
import { Saveable, SaveOptions } from '@theia/core/lib/browser/saveable';
23+
import { Saveable, SaveOptions, SaveReason } from '@theia/core/lib/browser/saveable';
2424
import { MonacoToProtocolConverter } from './monaco-to-protocol-converter';
2525
import { ProtocolToMonacoConverter } from './protocol-to-monaco-converter';
2626
import { ILogger, Loggable, Log } from '@theia/core/lib/common/logger';
27-
import { IIdentifiedSingleEditOperation, ITextBufferFactory, ITextModel, ITextSnapshot } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
27+
import { ITextBufferFactory, ITextModel, ITextSnapshot } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
2828
import { IResolvedTextEditorModel } from '@theia/monaco-editor-core/esm/vs/editor/common/services/resolverService';
2929
import * as monaco from '@theia/monaco-editor-core';
3030
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
@@ -39,12 +39,7 @@ export {
3939
TextDocumentSaveReason
4040
};
4141

42-
export interface WillSaveMonacoModelEvent {
43-
readonly model: MonacoEditorModel;
44-
readonly reason: TextDocumentSaveReason;
45-
readonly options?: SaveOptions;
46-
waitUntil(thenable: Thenable<IIdentifiedSingleEditOperation[]>): void;
47-
}
42+
export type WillSaveMonacoModelListener = (model: MonacoEditorModel, token: CancellationToken, options?: SaveOptions) => Promise<void>;
4843

4944
export interface MonacoModelContentChangedEvent {
5045
readonly model: MonacoEditorModel;
@@ -83,9 +78,6 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
8378
protected readonly onDidSaveModelEmitter = new Emitter<ITextModel>();
8479
readonly onDidSaveModel = this.onDidSaveModelEmitter.event;
8580

86-
protected readonly onWillSaveModelEmitter = new Emitter<WillSaveMonacoModelEvent>();
87-
readonly onWillSaveModel = this.onWillSaveModelEmitter.event;
88-
8981
protected readonly onDidChangeValidEmitter = new Emitter<void>();
9082
readonly onDidChangeValid = this.onDidChangeValidEmitter.event;
9183

@@ -99,6 +91,8 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
9991

10092
protected resourceVersion: ResourceVersion | undefined;
10193

94+
protected readonly willSaveModelListeners: WillSaveMonacoModelListener[] = [];
95+
10296
constructor(
10397
protected readonly resource: Resource,
10498
protected readonly m2p: MonacoToProtocolConverter,
@@ -110,7 +104,6 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
110104
this.toDispose.push(this.toDisposeOnAutoSave);
111105
this.toDispose.push(this.onDidChangeContentEmitter);
112106
this.toDispose.push(this.onDidSaveModelEmitter);
113-
this.toDispose.push(this.onWillSaveModelEmitter);
114107
this.toDispose.push(this.onDirtyChangedEmitter);
115108
this.toDispose.push(this.onDidChangeValidEmitter);
116109
this.toDispose.push(Disposable.create(() => this.cancelSave()));
@@ -154,7 +147,7 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
154147
if (mode === EncodingMode.Decode) {
155148
return this.sync();
156149
}
157-
return this.scheduleSave(TextDocumentSaveReason.Manual, this.cancelSave(), true);
150+
return this.scheduleSave(this.cancelSave(), true, { saveReason: SaveReason.Manual });
158151
}
159152

160153
getEncoding(): string | undefined {
@@ -386,7 +379,10 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
386379
}
387380

388381
save(options?: SaveOptions): Promise<void> {
389-
return this.scheduleSave(options?.saveReason ?? TextDocumentSaveReason.Manual, undefined, undefined, options);
382+
return this.scheduleSave(undefined, undefined, {
383+
saveReason: TextDocumentSaveReason.Manual,
384+
...options
385+
});
390386
}
391387

392388
protected pendingOperation = Promise.resolve();
@@ -485,8 +481,8 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
485481
return this.saveCancellationTokenSource.token;
486482
}
487483

488-
protected scheduleSave(reason: TextDocumentSaveReason, token: CancellationToken = this.cancelSave(), overwriteEncoding?: boolean, options?: SaveOptions): Promise<void> {
489-
return this.run(() => this.doSave(reason, token, overwriteEncoding, options));
484+
protected scheduleSave(token: CancellationToken = this.cancelSave(), overwriteEncoding?: boolean, options?: SaveOptions): Promise<void> {
485+
return this.run(() => this.doSave(token, overwriteEncoding, options));
490486
}
491487

492488
protected ignoreContentChanges = false;
@@ -546,18 +542,18 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
546542
}
547543
}
548544

549-
protected async doSave(reason: TextDocumentSaveReason, token: CancellationToken, overwriteEncoding?: boolean, options?: SaveOptions): Promise<void> {
545+
protected async doSave(token: CancellationToken, overwriteEncoding?: boolean, options?: SaveOptions): Promise<void> {
550546
if (token.isCancellationRequested || !this.resource.saveContents) {
551547
return;
552548
}
553549

554-
await this.fireWillSaveModel(reason, token, options);
550+
await this.fireWillSaveModel(token, options);
555551
if (token.isCancellationRequested) {
556552
return;
557553
}
558554

559555
const changes = [...this.contentChanges];
560-
if ((changes.length === 0 && !this.resource.initiallyDirty) && !overwriteEncoding && reason !== TextDocumentSaveReason.Manual) {
556+
if ((changes.length === 0 && !this.resource.initiallyDirty) && !overwriteEncoding && options?.saveReason !== TextDocumentSaveReason.Manual) {
561557
return;
562558
}
563559

@@ -586,64 +582,19 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
586582
}
587583
}
588584

589-
protected async fireWillSaveModel(reason: TextDocumentSaveReason, token: CancellationToken, options?: SaveOptions): Promise<void> {
590-
type EditContributor = Thenable<monaco.editor.IIdentifiedSingleEditOperation[]>;
591-
592-
const firing = this.onWillSaveModelEmitter.sequence(async listener => {
593-
if (token.isCancellationRequested) {
594-
return false;
595-
}
596-
const waitables: EditContributor[] = [];
597-
const { version } = this;
598-
599-
const event = {
600-
model: this, reason, options,
601-
waitUntil: (thenable: EditContributor) => {
602-
if (Object.isFrozen(waitables)) {
603-
throw new Error('waitUntil cannot be called asynchronously.');
604-
}
605-
waitables.push(thenable);
606-
}
607-
};
608-
609-
// Fire.
610-
try {
611-
listener(event);
612-
} catch (err) {
613-
console.error(err);
614-
return true;
615-
}
616-
617-
// Asynchronous calls to `waitUntil` should fail.
618-
Object.freeze(waitables);
619-
620-
// Wait for all promises.
621-
const edits = await Promise.all(waitables).then(allOperations =>
622-
([] as monaco.editor.IIdentifiedSingleEditOperation[]).concat(...allOperations)
623-
);
624-
if (token.isCancellationRequested) {
625-
return false;
626-
}
627-
628-
// In a perfect world, we should only apply edits if document is clean.
629-
if (version !== this.version) {
630-
console.error('onWillSave listeners should provide edits, not directly alter the document.');
631-
}
632-
633-
// Finally apply edits provided by this listener before firing the next.
634-
if (edits && edits.length > 0) {
635-
this.applyEdits(edits, {
636-
ignoreDirty: true,
637-
});
585+
registerWillSaveModelListener(listener: WillSaveMonacoModelListener): Disposable {
586+
this.willSaveModelListeners.push(listener);
587+
return Disposable.create(() => {
588+
const index = this.willSaveModelListeners.indexOf(listener);
589+
if (index >= 0) {
590+
this.willSaveModelListeners.splice(index, 1);
638591
}
639-
640-
return true;
641592
});
593+
}
642594

643-
try {
644-
await firing;
645-
} catch (e) {
646-
console.error(e);
595+
protected async fireWillSaveModel(token: CancellationToken, options?: SaveOptions): Promise<void> {
596+
for (const listener of this.willSaveModelListeners) {
597+
await listener(this, token, options);
647598
}
648599
}
649600

0 commit comments

Comments
 (0)