Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/codeAction/browser/codeActionModel.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 { CancelablePromise, createCancelablePromise, TimeoutTimer } from '../../../../base/common/async.js';
7
import { isCancellationError } from '../../../../base/common/errors.js';
8
import { Emitter } from '../../../../base/common/event.js';
9
import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js';
10
import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
11
import { isEqual } from '../../../../base/common/resources.js';
12
import { URI } from '../../../../base/common/uri.js';
13
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
14
import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
15
import { IMarkerService } from '../../../../platform/markers/common/markers.js';
16
import { IEditorProgressService, Progress } from '../../../../platform/progress/common/progress.js';
17
import { ICodeEditor } from '../../../browser/editorBrowser.js';
18
import { EditorOption, ShowLightbulbIconMode } from '../../../common/config/editorOptions.js';
19
import { Position } from '../../../common/core/position.js';
20
import { Selection } from '../../../common/core/selection.js';
21
import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';
22
import { CodeActionProvider, CodeActionTriggerType } from '../../../common/languages.js';
23
import { CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource } from '../common/types.js';
24
import { getCodeActions } from './codeAction.js';
25
26
export const SUPPORTED_CODE_ACTIONS = new RawContextKey<string>('supportedCodeAction', '');
27
28
export const APPLY_FIX_ALL_COMMAND_ID = '_typescript.applyFixAllCodeAction';
29
30
type TriggeredCodeAction = {
31
readonly selection: Selection;
32
readonly trigger: CodeActionTrigger;
33
};
34
35
class CodeActionOracle extends Disposable {
36
37
private readonly _autoTriggerTimer = this._register(new TimeoutTimer());
38
39
constructor(
40
private readonly _editor: ICodeEditor,
41
private readonly _markerService: IMarkerService,
42
private readonly _signalChange: (triggered: TriggeredCodeAction | undefined) => void,
43
private readonly _delay: number = 250,
44
) {
45
super();
46
this._register(this._markerService.onMarkerChanged(e => this._onMarkerChanges(e)));
47
this._register(this._editor.onDidChangeCursorPosition(() => this._tryAutoTrigger()));
48
}
49
50
public trigger(trigger: CodeActionTrigger): void {
51
const selection = this._getRangeOfSelectionUnlessWhitespaceEnclosed(trigger);
52
this._signalChange(selection ? { trigger, selection } : undefined);
53
}
54
55
private _onMarkerChanges(resources: readonly URI[]): void {
56
const model = this._editor.getModel();
57
if (model && resources.some(resource => isEqual(resource, model.uri))) {
58
this._tryAutoTrigger();
59
}
60
}
61
62
private _tryAutoTrigger() {
63
this._autoTriggerTimer.cancelAndSet(() => {
64
this.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default });
65
}, this._delay);
66
}
67
68
private _getRangeOfSelectionUnlessWhitespaceEnclosed(trigger: CodeActionTrigger): Selection | undefined {
69
if (!this._editor.hasModel()) {
70
return undefined;
71
}
72
const selection = this._editor.getSelection();
73
if (trigger.type === CodeActionTriggerType.Invoke) {
74
return selection;
75
}
76
const enabled = this._editor.getOption(EditorOption.lightbulb).enabled;
77
if (enabled === ShowLightbulbIconMode.Off) {
78
return undefined;
79
} else if (enabled === ShowLightbulbIconMode.On) {
80
return selection;
81
} else if (enabled === ShowLightbulbIconMode.OnCode) {
82
const isSelectionEmpty = selection.isEmpty();
83
if (!isSelectionEmpty) {
84
return selection;
85
}
86
const model = this._editor.getModel();
87
const { lineNumber, column } = selection.getPosition();
88
const line = model.getLineContent(lineNumber);
89
if (line.length === 0) {
90
// empty line
91
return undefined;
92
} else if (column === 1) {
93
// look only right
94
if (/\s/.test(line[0])) {
95
return undefined;
96
}
97
} else if (column === model.getLineMaxColumn(lineNumber)) {
98
// look only left
99
if (/\s/.test(line[line.length - 1])) {
100
return undefined;
101
}
102
} else {
103
// look left and right
104
if (/\s/.test(line[column - 2]) && /\s/.test(line[column - 1])) {
105
return undefined;
106
}
107
}
108
}
109
return selection;
110
}
111
}
112
113
export namespace CodeActionsState {
114
115
export const enum Type { Empty, Triggered }
116
117
export const Empty = { type: Type.Empty } as const;
118
119
export class Triggered {
120
readonly type = Type.Triggered;
121
122
public readonly actions: Promise<CodeActionSet>;
123
124
constructor(
125
public readonly trigger: CodeActionTrigger,
126
public readonly position: Position,
127
private readonly _cancellablePromise: CancelablePromise<CodeActionSet>,
128
) {
129
this.actions = _cancellablePromise.catch((e): CodeActionSet => {
130
if (isCancellationError(e)) {
131
return emptyCodeActionSet;
132
}
133
throw e;
134
});
135
}
136
137
public cancel() {
138
this._cancellablePromise.cancel();
139
}
140
}
141
142
export type State = typeof Empty | Triggered;
143
}
144
145
const emptyCodeActionSet = Object.freeze<CodeActionSet>({
146
allActions: [],
147
validActions: [],
148
dispose: () => { },
149
documentation: [],
150
hasAutoFix: false,
151
hasAIFix: false,
152
allAIFixes: false,
153
});
154
155
156
export class CodeActionModel extends Disposable {
157
158
private readonly _codeActionOracle = this._register(new MutableDisposable<CodeActionOracle>());
159
private _state: CodeActionsState.State = CodeActionsState.Empty;
160
161
private readonly _supportedCodeActions: IContextKey<string>;
162
163
private readonly _onDidChangeState = this._register(new Emitter<CodeActionsState.State>());
164
public readonly onDidChangeState = this._onDidChangeState.event;
165
166
private readonly codeActionsDisposable: MutableDisposable<IDisposable> = this._register(new MutableDisposable());
167
168
private _disposed = false;
169
170
constructor(
171
private readonly _editor: ICodeEditor,
172
private readonly _registry: LanguageFeatureRegistry<CodeActionProvider>,
173
private readonly _markerService: IMarkerService,
174
contextKeyService: IContextKeyService,
175
private readonly _progressService?: IEditorProgressService,
176
private readonly _configurationService?: IConfigurationService,
177
) {
178
super();
179
this._supportedCodeActions = SUPPORTED_CODE_ACTIONS.bindTo(contextKeyService);
180
181
this._register(this._editor.onDidChangeModel(() => this._update()));
182
this._register(this._editor.onDidChangeModelLanguage(() => this._update()));
183
this._register(this._registry.onDidChange(() => this._update()));
184
this._register(this._editor.onDidChangeConfiguration((e) => {
185
if (e.hasChanged(EditorOption.lightbulb)) {
186
this._update();
187
}
188
}));
189
this._update();
190
}
191
192
override dispose(): void {
193
if (this._disposed) {
194
return;
195
}
196
this._disposed = true;
197
198
super.dispose();
199
this.setState(CodeActionsState.Empty, true);
200
}
201
202
private _settingEnabledNearbyQuickfixes(): boolean {
203
const model = this._editor?.getModel();
204
return this._configurationService ? this._configurationService.getValue('editor.codeActionWidget.includeNearbyQuickFixes', { resource: model?.uri }) : false;
205
}
206
207
private _update(): void {
208
if (this._disposed) {
209
return;
210
}
211
212
this._codeActionOracle.value = undefined;
213
214
this.setState(CodeActionsState.Empty);
215
216
const model = this._editor.getModel();
217
if (model
218
&& this._registry.has(model)
219
&& !this._editor.getOption(EditorOption.readOnly)
220
) {
221
const supportedActions: string[] = this._registry.all(model).flatMap(provider => provider.providedCodeActionKinds ?? []);
222
this._supportedCodeActions.set(supportedActions.join(' '));
223
224
this._codeActionOracle.value = new CodeActionOracle(this._editor, this._markerService, trigger => {
225
if (!trigger) {
226
this.setState(CodeActionsState.Empty);
227
return;
228
}
229
230
const startPosition = trigger.selection.getStartPosition();
231
232
const actions = createCancelablePromise(async token => {
233
if (this._settingEnabledNearbyQuickfixes() && trigger.trigger.type === CodeActionTriggerType.Invoke && (trigger.trigger.triggerAction === CodeActionTriggerSource.QuickFix || trigger.trigger.filter?.include?.contains(CodeActionKind.QuickFix))) {
234
const codeActionSet = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token);
235
this.codeActionsDisposable.value = codeActionSet;
236
const allCodeActions = [...codeActionSet.allActions];
237
if (token.isCancellationRequested) {
238
codeActionSet.dispose();
239
return emptyCodeActionSet;
240
}
241
242
// Search for non-AI quickfixes in the current code action set - if AI code actions are the only thing found, continue searching for diagnostics in line.
243
const foundQuickfix = codeActionSet.validActions?.some(action => {
244
return action.action.kind &&
245
CodeActionKind.QuickFix.contains(new HierarchicalKind(action.action.kind)) &&
246
!action.action.isAI;
247
});
248
const allMarkers = this._markerService.read({ resource: model.uri });
249
if (foundQuickfix) {
250
for (const action of codeActionSet.validActions) {
251
if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) {
252
action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)];
253
}
254
}
255
return { validActions: codeActionSet.validActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => { this.codeActionsDisposable.value = codeActionSet; } };
256
} else if (!foundQuickfix) {
257
// If markers exist, and there are no quickfixes found or length is zero, check for quickfixes on that line.
258
if (allMarkers.length > 0) {
259
const currPosition = trigger.selection.getPosition();
260
let trackedPosition = currPosition;
261
let distance = Number.MAX_VALUE;
262
const currentActions = [...codeActionSet.validActions];
263
264
for (const marker of allMarkers) {
265
const col = marker.endColumn;
266
const row = marker.endLineNumber;
267
const startRow = marker.startLineNumber;
268
269
// Found quickfix on the same line and check relative distance to other markers
270
if ((row === currPosition.lineNumber || startRow === currPosition.lineNumber)) {
271
trackedPosition = new Position(row, col);
272
const newCodeActionTrigger: CodeActionTrigger = {
273
type: trigger.trigger.type,
274
triggerAction: trigger.trigger.triggerAction,
275
filter: { include: trigger.trigger.filter?.include ? trigger.trigger.filter?.include : CodeActionKind.QuickFix },
276
autoApply: trigger.trigger.autoApply,
277
context: { notAvailableMessage: trigger.trigger.context?.notAvailableMessage || '', position: trackedPosition }
278
};
279
280
const selectionAsPosition = new Selection(trackedPosition.lineNumber, trackedPosition.column, trackedPosition.lineNumber, trackedPosition.column);
281
const actionsAtMarker = await getCodeActions(this._registry, model, selectionAsPosition, newCodeActionTrigger, Progress.None, token);
282
if (token.isCancellationRequested) {
283
actionsAtMarker.dispose();
284
return emptyCodeActionSet;
285
}
286
287
if (actionsAtMarker.validActions.length !== 0) {
288
for (const action of actionsAtMarker.validActions) {
289
if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) {
290
action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)];
291
}
292
}
293
294
if (codeActionSet.allActions.length === 0) {
295
allCodeActions.push(...actionsAtMarker.allActions);
296
}
297
298
// Already filtered through to only get quickfixes, so no need to filter again.
299
if (Math.abs(currPosition.column - col) < distance) {
300
currentActions.unshift(...actionsAtMarker.validActions);
301
} else {
302
currentActions.push(...actionsAtMarker.validActions);
303
}
304
}
305
distance = Math.abs(currPosition.column - col);
306
}
307
}
308
const filteredActions = currentActions.filter((action, index, self) =>
309
self.findIndex((a) => a.action.title === action.action.title) === index);
310
311
filteredActions.sort((a, b) => {
312
if (a.action.isPreferred && !b.action.isPreferred) {
313
return -1;
314
} else if (!a.action.isPreferred && b.action.isPreferred) {
315
return 1;
316
} else if (a.action.isAI && !b.action.isAI) {
317
return 1;
318
} else if (!a.action.isAI && b.action.isAI) {
319
return -1;
320
} else {
321
return 0;
322
}
323
});
324
325
// Only retriggers if actually found quickfix on the same line as cursor
326
return { validActions: filteredActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => { this.codeActionsDisposable.value = codeActionSet; } };
327
}
328
}
329
}
330
331
// Case for manual triggers - specifically Source Actions and Refactors
332
if (trigger.trigger.type === CodeActionTriggerType.Invoke) {
333
const codeActions = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token);
334
this.codeActionsDisposable.value = codeActions;
335
return codeActions;
336
}
337
338
const codeActionSet = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token);
339
this.codeActionsDisposable.value = codeActionSet;
340
return codeActionSet;
341
});
342
343
if (trigger.trigger.type === CodeActionTriggerType.Invoke) {
344
this._progressService?.showWhile(actions, 250);
345
}
346
const newState = new CodeActionsState.Triggered(trigger.trigger, startPosition, actions);
347
let isManualToAutoTransition = false;
348
if (this._state.type === CodeActionsState.Type.Triggered) {
349
// Check if the current state is manual and the new state is automatic
350
isManualToAutoTransition = this._state.trigger.type === CodeActionTriggerType.Invoke &&
351
newState.type === CodeActionsState.Type.Triggered &&
352
newState.trigger.type === CodeActionTriggerType.Auto &&
353
this._state.position !== newState.position;
354
}
355
356
// Do not trigger state if current state is manual and incoming state is automatic
357
if (!isManualToAutoTransition) {
358
this.setState(newState);
359
} else {
360
// Reset the new state after getting code actions back.
361
setTimeout(() => {
362
this.setState(newState);
363
}, 500);
364
}
365
}, undefined);
366
this._codeActionOracle.value.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default });
367
} else {
368
this._supportedCodeActions.reset();
369
}
370
}
371
372
public trigger(trigger: CodeActionTrigger) {
373
this._codeActionOracle.value?.trigger(trigger);
374
this.codeActionsDisposable.dispose();
375
}
376
377
private setState(newState: CodeActionsState.State, skipNotify?: boolean) {
378
if (newState === this._state) {
379
return;
380
}
381
382
// Cancel old request
383
if (this._state.type === CodeActionsState.Type.Triggered) {
384
this._state.cancel();
385
}
386
387
this._state = newState;
388
389
if (!skipNotify && !this._disposed) {
390
this._onDidChangeState.fire(newState);
391
}
392
}
393
}
394
395