Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts
5221 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 * as dom from '../../../../base/browser/dom.js';
7
import { Gesture } from '../../../../base/browser/touch.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { Emitter, Event } from '../../../../base/common/event.js';
10
import { Disposable } from '../../../../base/common/lifecycle.js';
11
import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js';
12
import { ThemeIcon } from '../../../../base/common/themables.js';
13
import './lightBulbWidget.css';
14
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from '../../../browser/editorBrowser.js';
15
import { EditorOption } from '../../../common/config/editorOptions.js';
16
import { IPosition } from '../../../common/core/position.js';
17
import { GlyphMarginLane, IModelDecorationsChangeAccessor, TrackedRangeStickiness } from '../../../common/model.js';
18
import { ModelDecorationOptions } from '../../../common/model/textModel.js';
19
import { computeIndentLevel } from '../../../common/model/utils.js';
20
import { autoFixCommandId, quickFixCommandId } from './codeAction.js';
21
import { CodeActionSet, CodeActionTrigger } from '../common/types.js';
22
import * as nls from '../../../../nls.js';
23
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
24
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
25
import { Range } from '../../../common/core/range.js';
26
27
const GUTTER_LIGHTBULB_ICON = registerIcon('gutter-lightbulb', Codicon.lightBulb, nls.localize('gutterLightbulbWidget', 'Icon which spawns code actions menu from the gutter when there is no space in the editor.'));
28
const GUTTER_LIGHTBULB_AUTO_FIX_ICON = registerIcon('gutter-lightbulb-auto-fix', Codicon.lightbulbAutofix, nls.localize('gutterLightbulbAutoFixWidget', 'Icon which spawns code actions menu from the gutter when there is no space in the editor and a quick fix is available.'));
29
const GUTTER_LIGHTBULB_AIFIX_ICON = registerIcon('gutter-lightbulb-sparkle', Codicon.lightbulbSparkle, nls.localize('gutterLightbulbAIFixWidget', 'Icon which spawns code actions menu from the gutter when there is no space in the editor and an AI fix is available.'));
30
const GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON = registerIcon('gutter-lightbulb-aifix-auto-fix', Codicon.lightbulbSparkleAutofix, nls.localize('gutterLightbulbAIFixAutoFixWidget', 'Icon which spawns code actions menu from the gutter when there is no space in the editor and an AI fix and a quick fix is available.'));
31
const GUTTER_SPARKLE_FILLED_ICON = registerIcon('gutter-lightbulb-sparkle-filled', Codicon.sparkleFilled, nls.localize('gutterLightbulbSparkleFilledWidget', 'Icon which spawns code actions menu from the gutter when there is no space in the editor and an AI fix and a quick fix is available.'));
32
33
export interface LightBulbInfo {
34
readonly actions: CodeActionSet;
35
readonly trigger: CodeActionTrigger;
36
readonly icon: ThemeIcon;
37
readonly autoRun: boolean;
38
readonly title: string;
39
readonly isGutter: boolean;
40
}
41
42
namespace LightBulbState {
43
44
export const enum Type {
45
Hidden,
46
Showing,
47
}
48
49
export const Hidden = { type: Type.Hidden } as const;
50
51
export class Showing {
52
readonly type = Type.Showing;
53
54
constructor(
55
public readonly actions: CodeActionSet,
56
public readonly trigger: CodeActionTrigger,
57
public readonly editorPosition: IPosition,
58
public readonly widgetPosition: IContentWidgetPosition,
59
) { }
60
}
61
62
export type State = typeof Hidden | Showing;
63
}
64
65
export class LightBulbWidget extends Disposable implements IContentWidget {
66
private _gutterDecorationID: string | undefined;
67
68
private static readonly GUTTER_DECORATION = ModelDecorationOptions.register({
69
description: 'codicon-gutter-lightbulb-decoration',
70
glyphMarginClassName: ThemeIcon.asClassName(Codicon.lightBulb),
71
glyphMargin: { position: GlyphMarginLane.Left },
72
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
73
});
74
75
public static readonly ID = 'editor.contrib.lightbulbWidget';
76
77
private static readonly _posPref = [ContentWidgetPositionPreference.EXACT];
78
79
private readonly _domNode: HTMLElement;
80
81
private readonly _onClick = this._register(new Emitter<{ readonly x: number; readonly y: number; readonly actions: CodeActionSet; readonly trigger: CodeActionTrigger }>());
82
public readonly onClick = this._onClick.event;
83
84
private readonly _state = observableValue<LightBulbState.State>(this, LightBulbState.Hidden);
85
private readonly _gutterState = observableValue<LightBulbState.State>(this, LightBulbState.Hidden);
86
87
private readonly _combinedInfo = derived(this, reader => {
88
const gutterState = this._gutterState.read(reader);
89
if (gutterState.type === LightBulbState.Type.Showing) {
90
return LightBulbWidget._computeLightBulbInfo(gutterState, true, this._preferredKbLabel.read(reader), this._quickFixKbLabel.read(reader));
91
}
92
const state = this._state.read(reader);
93
if (state.type === LightBulbState.Type.Showing) {
94
return LightBulbWidget._computeLightBulbInfo(state, false, this._preferredKbLabel.read(reader), this._quickFixKbLabel.read(reader));
95
}
96
return undefined;
97
});
98
99
public readonly lightBulbInfo: IObservable<LightBulbInfo | undefined> = this._combinedInfo;
100
101
private _iconClasses: string[] = [];
102
103
private readonly lightbulbClasses = [
104
'codicon-' + GUTTER_LIGHTBULB_ICON.id,
105
'codicon-' + GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON.id,
106
'codicon-' + GUTTER_LIGHTBULB_AUTO_FIX_ICON.id,
107
'codicon-' + GUTTER_LIGHTBULB_AIFIX_ICON.id,
108
'codicon-' + GUTTER_SPARKLE_FILLED_ICON.id
109
];
110
111
private readonly _preferredKbLabel = observableValue<string | undefined>(this, undefined);
112
private readonly _quickFixKbLabel = observableValue<string | undefined>(this, undefined);
113
114
private gutterDecoration: ModelDecorationOptions = LightBulbWidget.GUTTER_DECORATION;
115
116
private static _computeLightBulbInfo(state: LightBulbState.State, forGutter: boolean, preferredKbLabel: string | undefined, quickFixKbLabel: string | undefined): LightBulbInfo | undefined {
117
if (state.type !== LightBulbState.Type.Showing) {
118
return undefined;
119
}
120
121
const { actions, trigger } = state;
122
let icon: ThemeIcon;
123
let autoRun = false;
124
if (actions.allAIFixes) {
125
icon = forGutter ? GUTTER_SPARKLE_FILLED_ICON : Codicon.sparkleFilled;
126
if (actions.validActions.length === 1) {
127
autoRun = true;
128
}
129
} else if (actions.hasAutoFix) {
130
if (actions.hasAIFix) {
131
icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON : Codicon.lightbulbSparkleAutofix;
132
} else {
133
icon = forGutter ? GUTTER_LIGHTBULB_AUTO_FIX_ICON : Codicon.lightbulbAutofix;
134
}
135
} else if (actions.hasAIFix) {
136
icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_ICON : Codicon.lightbulbSparkle;
137
} else {
138
icon = forGutter ? GUTTER_LIGHTBULB_ICON : Codicon.lightBulb;
139
}
140
141
let title: string;
142
if (autoRun) {
143
title = nls.localize('codeActionAutoRun', "Run: {0}", actions.validActions[0].action.title);
144
} else if (actions.hasAutoFix && preferredKbLabel) {
145
title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", preferredKbLabel);
146
} else if (!actions.hasAutoFix && quickFixKbLabel) {
147
title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", quickFixKbLabel);
148
} else {
149
title = nls.localize('codeAction', "Show Code Actions");
150
}
151
152
return { actions, trigger, icon, autoRun, title, isGutter: forGutter };
153
}
154
155
constructor(
156
private readonly _editor: ICodeEditor,
157
@IKeybindingService private readonly _keybindingService: IKeybindingService
158
) {
159
super();
160
161
this._domNode = dom.$('div.lightBulbWidget');
162
this._domNode.role = 'listbox';
163
this._register(Gesture.ignoreTarget(this._domNode));
164
165
this._editor.addContentWidget(this);
166
167
this._register(this._editor.onDidChangeModelContent(_ => {
168
// cancel when the line in question has been removed
169
const editorModel = this._editor.getModel();
170
const state = this._state.get();
171
if (state.type !== LightBulbState.Type.Showing || !editorModel || state.editorPosition.lineNumber >= editorModel.getLineCount()) {
172
this.hide();
173
}
174
175
const gutterState = this._gutterState.get();
176
if (gutterState.type !== LightBulbState.Type.Showing || !editorModel || gutterState.editorPosition.lineNumber >= editorModel.getLineCount()) {
177
this.gutterHide();
178
}
179
}));
180
181
this._register(dom.addStandardDisposableGenericMouseDownListener(this._domNode, e => {
182
const state = this._state.get();
183
if (state.type !== LightBulbState.Type.Showing) {
184
return;
185
}
186
187
// Make sure that focus / cursor location is not lost when clicking widget icon
188
this._editor.focus();
189
e.preventDefault();
190
191
// a bit of extra work to make sure the menu
192
// doesn't cover the line-text
193
const { top, height } = dom.getDomNodePagePosition(this._domNode);
194
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
195
196
let pad = Math.floor(lineHeight / 3);
197
if (state.widgetPosition.position !== null && state.widgetPosition.position.lineNumber < state.editorPosition.lineNumber) {
198
pad += lineHeight;
199
}
200
201
this._onClick.fire({
202
x: e.posx,
203
y: top + height + pad,
204
actions: state.actions,
205
trigger: state.trigger,
206
});
207
}));
208
209
this._register(dom.addDisposableListener(this._domNode, 'mouseenter', (e: MouseEvent) => {
210
if ((e.buttons & 1) !== 1) {
211
return;
212
}
213
// mouse enters lightbulb while the primary/left button
214
// is being pressed -> hide the lightbulb
215
this.hide();
216
}));
217
218
219
this._register(Event.runAndSubscribe(this._keybindingService.onDidUpdateKeybindings, () => {
220
this._preferredKbLabel.set(this._keybindingService.lookupKeybinding(autoFixCommandId)?.getLabel() ?? undefined, undefined);
221
this._quickFixKbLabel.set(this._keybindingService.lookupKeybinding(quickFixCommandId)?.getLabel() ?? undefined, undefined);
222
}));
223
224
// Autorun to update the DOM based on state changes
225
this._register(autorun(reader => {
226
const info = this._combinedInfo.read(reader);
227
this._updateLightBulbTitleAndIcon(info);
228
this._updateGutterDecorationOptions(info);
229
}));
230
231
this._register(this._editor.onMouseDown(async (e: IEditorMouseEvent) => {
232
233
if (!e.target.element || !this.lightbulbClasses.some(cls => e.target.element && e.target.element.classList.contains(cls))) {
234
return;
235
}
236
237
const gutterState = this._gutterState.get();
238
if (gutterState.type !== LightBulbState.Type.Showing) {
239
return;
240
}
241
242
// Make sure that focus / cursor location is not lost when clicking widget icon
243
this._editor.focus();
244
245
// a bit of extra work to make sure the menu
246
// doesn't cover the line-text
247
const { top, height } = dom.getDomNodePagePosition(e.target.element);
248
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
249
250
let pad = Math.floor(lineHeight / 3);
251
if (gutterState.widgetPosition.position !== null && gutterState.widgetPosition.position.lineNumber < gutterState.editorPosition.lineNumber) {
252
pad += lineHeight;
253
}
254
255
this._onClick.fire({
256
x: e.event.posx,
257
y: top + height + pad,
258
actions: gutterState.actions,
259
trigger: gutterState.trigger,
260
});
261
}));
262
}
263
264
override dispose(): void {
265
super.dispose();
266
this._editor.removeContentWidget(this);
267
if (this._gutterDecorationID) {
268
this._removeGutterDecoration(this._gutterDecorationID);
269
}
270
}
271
272
getId(): string {
273
return 'LightBulbWidget';
274
}
275
276
getDomNode(): HTMLElement {
277
return this._domNode;
278
}
279
280
getPosition(): IContentWidgetPosition | null {
281
const state = this._state.get();
282
return state.type === LightBulbState.Type.Showing ? state.widgetPosition : null;
283
}
284
285
public update(actions: CodeActionSet, trigger: CodeActionTrigger, atPosition: IPosition) {
286
if (actions.validActions.length <= 0) {
287
this.gutterHide();
288
return this.hide();
289
}
290
291
const hasTextFocus = this._editor.hasTextFocus();
292
if (!hasTextFocus) {
293
this.gutterHide();
294
return this.hide();
295
}
296
297
const options = this._editor.getOptions();
298
if (!options.get(EditorOption.lightbulb).enabled) {
299
this.gutterHide();
300
return this.hide();
301
}
302
303
304
const model = this._editor.getModel();
305
if (!model) {
306
this.gutterHide();
307
return this.hide();
308
}
309
310
const { lineNumber, column } = model.validatePosition(atPosition);
311
312
const tabSize = model.getOptions().tabSize;
313
const fontInfo = this._editor.getOptions().get(EditorOption.fontInfo);
314
const lineContent = model.getLineContent(lineNumber);
315
const indent = computeIndentLevel(lineContent, tabSize);
316
const lineHasSpace = fontInfo.spaceWidth * indent > 22;
317
const isFolded = (lineNumber: number) => {
318
return lineNumber > 2 && this._editor.getTopForLineNumber(lineNumber) === this._editor.getTopForLineNumber(lineNumber - 1);
319
};
320
321
// Check for glyph margin decorations of any kind
322
const currLineDecorations = this._editor.getLineDecorations(lineNumber);
323
let hasDecoration = false;
324
if (currLineDecorations) {
325
for (const decoration of currLineDecorations) {
326
const glyphClass = decoration.options.glyphMarginClassName;
327
328
if (glyphClass && !this.lightbulbClasses.some(className => glyphClass.includes(className))) {
329
hasDecoration = true;
330
break;
331
}
332
}
333
}
334
335
let effectiveLineNumber = lineNumber;
336
let effectiveColumnNumber = 1;
337
if (!lineHasSpace) {
338
// Checks if line is empty or starts with any amount of whitespace
339
const isLineEmptyOrIndented = (lineNumber: number): boolean => {
340
const lineContent = model.getLineContent(lineNumber);
341
return /^\s*$|^\s+/.test(lineContent) || lineContent.length <= effectiveColumnNumber;
342
};
343
344
if (lineNumber > 1 && !isFolded(lineNumber - 1)) {
345
const lineCount = model.getLineCount();
346
const endLine = lineNumber === lineCount;
347
const prevLineEmptyOrIndented = lineNumber > 1 && isLineEmptyOrIndented(lineNumber - 1);
348
const nextLineEmptyOrIndented = !endLine && isLineEmptyOrIndented(lineNumber + 1);
349
const currLineEmptyOrIndented = isLineEmptyOrIndented(lineNumber);
350
const notEmpty = !nextLineEmptyOrIndented && !prevLineEmptyOrIndented;
351
352
// check above and below. if both are blocked, display lightbulb in the gutter.
353
if (!nextLineEmptyOrIndented && !prevLineEmptyOrIndented && !hasDecoration) {
354
this._gutterState.set(new LightBulbState.Showing(actions, trigger, atPosition, {
355
position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },
356
preference: LightBulbWidget._posPref
357
}), undefined);
358
this.renderGutterLightbub();
359
return this.hide();
360
} else if (prevLineEmptyOrIndented || endLine || (prevLineEmptyOrIndented && !currLineEmptyOrIndented)) {
361
effectiveLineNumber -= 1;
362
} else if (nextLineEmptyOrIndented || (notEmpty && currLineEmptyOrIndented)) {
363
effectiveLineNumber += 1;
364
}
365
} else if (lineNumber === 1 && (lineNumber === model.getLineCount() || !isLineEmptyOrIndented(lineNumber + 1) && !isLineEmptyOrIndented(lineNumber))) {
366
// special checks for first line blocked vs. not blocked.
367
this._gutterState.set(new LightBulbState.Showing(actions, trigger, atPosition, {
368
position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },
369
preference: LightBulbWidget._posPref
370
}), undefined);
371
372
if (hasDecoration) {
373
this.gutterHide();
374
} else {
375
this.renderGutterLightbub();
376
return this.hide();
377
}
378
} else if ((lineNumber < model.getLineCount()) && !isFolded(lineNumber + 1)) {
379
effectiveLineNumber += 1;
380
} else if (column * fontInfo.spaceWidth < 22) {
381
// cannot show lightbulb above/below and showing
382
// it inline would overlay the cursor...
383
return this.hide();
384
}
385
effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1;
386
}
387
388
this._state.set(new LightBulbState.Showing(actions, trigger, atPosition, {
389
position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },
390
preference: LightBulbWidget._posPref
391
}), undefined);
392
393
if (this._gutterDecorationID) {
394
this._removeGutterDecoration(this._gutterDecorationID);
395
this.gutterHide();
396
}
397
398
const validActions = actions.validActions;
399
const actionKind = actions.validActions[0].action.kind;
400
if (validActions.length !== 1 || !actionKind) {
401
this._editor.layoutContentWidget(this);
402
return;
403
}
404
405
this._editor.layoutContentWidget(this);
406
}
407
408
public hide(): void {
409
if (this._state.get() === LightBulbState.Hidden) {
410
return;
411
}
412
413
this._state.set(LightBulbState.Hidden, undefined);
414
this._editor.layoutContentWidget(this);
415
}
416
417
public gutterHide(): void {
418
if (this._gutterState.get() === LightBulbState.Hidden) {
419
return;
420
}
421
422
if (this._gutterDecorationID) {
423
this._removeGutterDecoration(this._gutterDecorationID);
424
}
425
426
this._gutterState.set(LightBulbState.Hidden, undefined);
427
}
428
429
private _updateLightBulbTitleAndIcon(info: LightBulbInfo | undefined): void {
430
this._domNode.classList.remove(...this._iconClasses);
431
this._iconClasses = [];
432
if (!info || info.isGutter) {
433
return;
434
}
435
this._domNode.title = info.title;
436
this._iconClasses = ThemeIcon.asClassNameArray(info.icon);
437
this._domNode.classList.add(...this._iconClasses);
438
}
439
440
private _updateGutterDecorationOptions(info: LightBulbInfo | undefined): void {
441
if (!info || !info.isGutter) {
442
return;
443
}
444
445
this.gutterDecoration = ModelDecorationOptions.register({
446
description: 'codicon-gutter-lightbulb-decoration',
447
glyphMarginClassName: ThemeIcon.asClassName(info.icon),
448
glyphMargin: { position: GlyphMarginLane.Left },
449
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
450
});
451
}
452
453
/* Gutter Helper Functions */
454
private renderGutterLightbub(): void {
455
const selection = this._editor.getSelection();
456
if (!selection) {
457
return;
458
}
459
460
if (this._gutterDecorationID === undefined) {
461
this._addGutterDecoration(selection.startLineNumber);
462
} else {
463
this._updateGutterDecoration(this._gutterDecorationID, selection.startLineNumber);
464
}
465
}
466
467
private _addGutterDecoration(lineNumber: number) {
468
this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {
469
this._gutterDecorationID = accessor.addDecoration(new Range(lineNumber, 0, lineNumber, 0), this.gutterDecoration);
470
});
471
}
472
473
private _removeGutterDecoration(decorationId: string) {
474
this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {
475
accessor.removeDecoration(decorationId);
476
this._gutterDecorationID = undefined;
477
});
478
}
479
480
private _updateGutterDecoration(decorationId: string, lineNumber: number) {
481
this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {
482
accessor.changeDecoration(decorationId, new Range(lineNumber, 0, lineNumber, 0));
483
accessor.changeDecorationOptions(decorationId, this.gutterDecoration);
484
});
485
}
486
487
488
}
489
490