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