Path: blob/main/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import { Gesture } from '../../../../base/browser/touch.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { Emitter, Event } from '../../../../base/common/event.js';9import { Disposable } from '../../../../base/common/lifecycle.js';10import { ThemeIcon } from '../../../../base/common/themables.js';11import './lightBulbWidget.css';12import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from '../../../browser/editorBrowser.js';13import { EditorOption } from '../../../common/config/editorOptions.js';14import { IPosition } from '../../../common/core/position.js';15import { GlyphMarginLane, IModelDecorationsChangeAccessor, TrackedRangeStickiness } from '../../../common/model.js';16import { ModelDecorationOptions } from '../../../common/model/textModel.js';17import { computeIndentLevel } from '../../../common/model/utils.js';18import { autoFixCommandId, quickFixCommandId } from './codeAction.js';19import { CodeActionSet, CodeActionTrigger } from '../common/types.js';20import * as nls from '../../../../nls.js';21import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';22import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';23import { Range } from '../../../common/core/range.js';2425const 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.'));26const 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.'));27const 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.'));28const 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.'));29const 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.'));3031namespace LightBulbState {3233export const enum Type {34Hidden,35Showing,36}3738export const Hidden = { type: Type.Hidden } as const;3940export class Showing {41readonly type = Type.Showing;4243constructor(44public readonly actions: CodeActionSet,45public readonly trigger: CodeActionTrigger,46public readonly editorPosition: IPosition,47public readonly widgetPosition: IContentWidgetPosition,48) { }49}5051export type State = typeof Hidden | Showing;52}5354export class LightBulbWidget extends Disposable implements IContentWidget {55private _gutterDecorationID: string | undefined;5657private static readonly GUTTER_DECORATION = ModelDecorationOptions.register({58description: 'codicon-gutter-lightbulb-decoration',59glyphMarginClassName: ThemeIcon.asClassName(Codicon.lightBulb),60glyphMargin: { position: GlyphMarginLane.Left },61stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,62});6364public static readonly ID = 'editor.contrib.lightbulbWidget';6566private static readonly _posPref = [ContentWidgetPositionPreference.EXACT];6768private readonly _domNode: HTMLElement;6970private readonly _onClick = this._register(new Emitter<{ readonly x: number; readonly y: number; readonly actions: CodeActionSet; readonly trigger: CodeActionTrigger }>());71public readonly onClick = this._onClick.event;7273private _state: LightBulbState.State = LightBulbState.Hidden;74private _gutterState: LightBulbState.State = LightBulbState.Hidden;75private _iconClasses: string[] = [];7677private readonly lightbulbClasses = [78'codicon-' + GUTTER_LIGHTBULB_ICON.id,79'codicon-' + GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON.id,80'codicon-' + GUTTER_LIGHTBULB_AUTO_FIX_ICON.id,81'codicon-' + GUTTER_LIGHTBULB_AIFIX_ICON.id,82'codicon-' + GUTTER_SPARKLE_FILLED_ICON.id83];8485private _preferredKbLabel?: string;86private _quickFixKbLabel?: string;8788private gutterDecoration: ModelDecorationOptions = LightBulbWidget.GUTTER_DECORATION;8990constructor(91private readonly _editor: ICodeEditor,92@IKeybindingService private readonly _keybindingService: IKeybindingService93) {94super();9596this._domNode = dom.$('div.lightBulbWidget');97this._domNode.role = 'listbox';98this._register(Gesture.ignoreTarget(this._domNode));99100this._editor.addContentWidget(this);101102this._register(this._editor.onDidChangeModelContent(_ => {103// cancel when the line in question has been removed104const editorModel = this._editor.getModel();105if (this.state.type !== LightBulbState.Type.Showing || !editorModel || this.state.editorPosition.lineNumber >= editorModel.getLineCount()) {106this.hide();107}108109if (this.gutterState.type !== LightBulbState.Type.Showing || !editorModel || this.gutterState.editorPosition.lineNumber >= editorModel.getLineCount()) {110this.gutterHide();111}112}));113114this._register(dom.addStandardDisposableGenericMouseDownListener(this._domNode, e => {115if (this.state.type !== LightBulbState.Type.Showing) {116return;117}118119// Make sure that focus / cursor location is not lost when clicking widget icon120this._editor.focus();121e.preventDefault();122123// a bit of extra work to make sure the menu124// doesn't cover the line-text125const { top, height } = dom.getDomNodePagePosition(this._domNode);126const lineHeight = this._editor.getOption(EditorOption.lineHeight);127128let pad = Math.floor(lineHeight / 3);129if (this.state.widgetPosition.position !== null && this.state.widgetPosition.position.lineNumber < this.state.editorPosition.lineNumber) {130pad += lineHeight;131}132133this._onClick.fire({134x: e.posx,135y: top + height + pad,136actions: this.state.actions,137trigger: this.state.trigger,138});139}));140141this._register(dom.addDisposableListener(this._domNode, 'mouseenter', (e: MouseEvent) => {142if ((e.buttons & 1) !== 1) {143return;144}145// mouse enters lightbulb while the primary/left button146// is being pressed -> hide the lightbulb147this.hide();148}));149150151this._register(Event.runAndSubscribe(this._keybindingService.onDidUpdateKeybindings, () => {152this._preferredKbLabel = this._keybindingService.lookupKeybinding(autoFixCommandId)?.getLabel() ?? undefined;153this._quickFixKbLabel = this._keybindingService.lookupKeybinding(quickFixCommandId)?.getLabel() ?? undefined;154this._updateLightBulbTitleAndIcon();155}));156157this._register(this._editor.onMouseDown(async (e: IEditorMouseEvent) => {158159if (!e.target.element || !this.lightbulbClasses.some(cls => e.target.element && e.target.element.classList.contains(cls))) {160return;161}162163if (this.gutterState.type !== LightBulbState.Type.Showing) {164return;165}166167// Make sure that focus / cursor location is not lost when clicking widget icon168this._editor.focus();169170// a bit of extra work to make sure the menu171// doesn't cover the line-text172const { top, height } = dom.getDomNodePagePosition(e.target.element);173const lineHeight = this._editor.getOption(EditorOption.lineHeight);174175let pad = Math.floor(lineHeight / 3);176if (this.gutterState.widgetPosition.position !== null && this.gutterState.widgetPosition.position.lineNumber < this.gutterState.editorPosition.lineNumber) {177pad += lineHeight;178}179180this._onClick.fire({181x: e.event.posx,182y: top + height + pad,183actions: this.gutterState.actions,184trigger: this.gutterState.trigger,185});186}));187}188189override dispose(): void {190super.dispose();191this._editor.removeContentWidget(this);192if (this._gutterDecorationID) {193this._removeGutterDecoration(this._gutterDecorationID);194}195}196197getId(): string {198return 'LightBulbWidget';199}200201getDomNode(): HTMLElement {202return this._domNode;203}204205getPosition(): IContentWidgetPosition | null {206return this._state.type === LightBulbState.Type.Showing ? this._state.widgetPosition : null;207}208209public update(actions: CodeActionSet, trigger: CodeActionTrigger, atPosition: IPosition) {210if (actions.validActions.length <= 0) {211this.gutterHide();212return this.hide();213}214215const hasTextFocus = this._editor.hasTextFocus();216if (!hasTextFocus) {217this.gutterHide();218return this.hide();219}220221const options = this._editor.getOptions();222if (!options.get(EditorOption.lightbulb).enabled) {223this.gutterHide();224return this.hide();225}226227228const model = this._editor.getModel();229if (!model) {230this.gutterHide();231return this.hide();232}233234const { lineNumber, column } = model.validatePosition(atPosition);235236const tabSize = model.getOptions().tabSize;237const fontInfo = this._editor.getOptions().get(EditorOption.fontInfo);238const lineContent = model.getLineContent(lineNumber);239const indent = computeIndentLevel(lineContent, tabSize);240const lineHasSpace = fontInfo.spaceWidth * indent > 22;241const isFolded = (lineNumber: number) => {242return lineNumber > 2 && this._editor.getTopForLineNumber(lineNumber) === this._editor.getTopForLineNumber(lineNumber - 1);243};244245// Check for glyph margin decorations of any kind246const currLineDecorations = this._editor.getLineDecorations(lineNumber);247let hasDecoration = false;248if (currLineDecorations) {249for (const decoration of currLineDecorations) {250const glyphClass = decoration.options.glyphMarginClassName;251252if (glyphClass && !this.lightbulbClasses.some(className => glyphClass.includes(className))) {253hasDecoration = true;254break;255}256}257}258259let effectiveLineNumber = lineNumber;260let effectiveColumnNumber = 1;261if (!lineHasSpace) {262// Checks if line is empty or starts with any amount of whitespace263const isLineEmptyOrIndented = (lineNumber: number): boolean => {264const lineContent = model.getLineContent(lineNumber);265return /^\s*$|^\s+/.test(lineContent) || lineContent.length <= effectiveColumnNumber;266};267268if (lineNumber > 1 && !isFolded(lineNumber - 1)) {269const lineCount = model.getLineCount();270const endLine = lineNumber === lineCount;271const prevLineEmptyOrIndented = lineNumber > 1 && isLineEmptyOrIndented(lineNumber - 1);272const nextLineEmptyOrIndented = !endLine && isLineEmptyOrIndented(lineNumber + 1);273const currLineEmptyOrIndented = isLineEmptyOrIndented(lineNumber);274const notEmpty = !nextLineEmptyOrIndented && !prevLineEmptyOrIndented;275276// check above and below. if both are blocked, display lightbulb in the gutter.277if (!nextLineEmptyOrIndented && !prevLineEmptyOrIndented && !hasDecoration) {278this.gutterState = new LightBulbState.Showing(actions, trigger, atPosition, {279position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },280preference: LightBulbWidget._posPref281});282this.renderGutterLightbub();283return this.hide();284} else if (prevLineEmptyOrIndented || endLine || (prevLineEmptyOrIndented && !currLineEmptyOrIndented)) {285effectiveLineNumber -= 1;286} else if (nextLineEmptyOrIndented || (notEmpty && currLineEmptyOrIndented)) {287effectiveLineNumber += 1;288}289} else if (lineNumber === 1 && (lineNumber === model.getLineCount() || !isLineEmptyOrIndented(lineNumber + 1) && !isLineEmptyOrIndented(lineNumber))) {290// special checks for first line blocked vs. not blocked.291this.gutterState = new LightBulbState.Showing(actions, trigger, atPosition, {292position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },293preference: LightBulbWidget._posPref294});295296if (hasDecoration) {297this.gutterHide();298} else {299this.renderGutterLightbub();300return this.hide();301}302} else if ((lineNumber < model.getLineCount()) && !isFolded(lineNumber + 1)) {303effectiveLineNumber += 1;304} else if (column * fontInfo.spaceWidth < 22) {305// cannot show lightbulb above/below and showing306// it inline would overlay the cursor...307return this.hide();308}309effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1;310}311312this.state = new LightBulbState.Showing(actions, trigger, atPosition, {313position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },314preference: LightBulbWidget._posPref315});316317if (this._gutterDecorationID) {318this._removeGutterDecoration(this._gutterDecorationID);319this.gutterHide();320}321322const validActions = actions.validActions;323const actionKind = actions.validActions[0].action.kind;324if (validActions.length !== 1 || !actionKind) {325this._editor.layoutContentWidget(this);326return;327}328329this._editor.layoutContentWidget(this);330}331332public hide(): void {333if (this.state === LightBulbState.Hidden) {334return;335}336337this.state = LightBulbState.Hidden;338this._editor.layoutContentWidget(this);339}340341public gutterHide(): void {342if (this.gutterState === LightBulbState.Hidden) {343return;344}345346if (this._gutterDecorationID) {347this._removeGutterDecoration(this._gutterDecorationID);348}349350this.gutterState = LightBulbState.Hidden;351}352353private get state(): LightBulbState.State { return this._state; }354355private set state(value) {356this._state = value;357this._updateLightBulbTitleAndIcon();358}359360private get gutterState(): LightBulbState.State { return this._gutterState; }361362private set gutterState(value) {363this._gutterState = value;364this._updateGutterLightBulbTitleAndIcon();365}366367private _updateLightBulbTitleAndIcon(): void {368this._domNode.classList.remove(...this._iconClasses);369this._iconClasses = [];370if (this.state.type !== LightBulbState.Type.Showing) {371return;372}373let icon: ThemeIcon;374let autoRun = false;375if (this.state.actions.allAIFixes) {376icon = Codicon.sparkleFilled;377if (this.state.actions.validActions.length === 1) {378autoRun = true;379}380} else if (this.state.actions.hasAutoFix) {381if (this.state.actions.hasAIFix) {382icon = Codicon.lightbulbSparkleAutofix;383} else {384icon = Codicon.lightbulbAutofix;385}386} else if (this.state.actions.hasAIFix) {387icon = Codicon.lightbulbSparkle;388} else {389icon = Codicon.lightBulb;390}391this._updateLightbulbTitle(this.state.actions.hasAutoFix, autoRun);392this._iconClasses = ThemeIcon.asClassNameArray(icon);393this._domNode.classList.add(...this._iconClasses);394}395396private _updateGutterLightBulbTitleAndIcon(): void {397if (this.gutterState.type !== LightBulbState.Type.Showing) {398return;399}400let icon: ThemeIcon;401let autoRun = false;402if (this.gutterState.actions.allAIFixes) {403icon = GUTTER_SPARKLE_FILLED_ICON;404if (this.gutterState.actions.validActions.length === 1) {405autoRun = true;406}407} else if (this.gutterState.actions.hasAutoFix) {408if (this.gutterState.actions.hasAIFix) {409icon = GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON;410} else {411icon = GUTTER_LIGHTBULB_AUTO_FIX_ICON;412}413} else if (this.gutterState.actions.hasAIFix) {414icon = GUTTER_LIGHTBULB_AIFIX_ICON;415} else {416icon = GUTTER_LIGHTBULB_ICON;417}418this._updateLightbulbTitle(this.gutterState.actions.hasAutoFix, autoRun);419420const GUTTER_DECORATION = ModelDecorationOptions.register({421description: 'codicon-gutter-lightbulb-decoration',422glyphMarginClassName: ThemeIcon.asClassName(icon),423glyphMargin: { position: GlyphMarginLane.Left },424stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,425});426427this.gutterDecoration = GUTTER_DECORATION;428}429430/* Gutter Helper Functions */431private renderGutterLightbub(): void {432const selection = this._editor.getSelection();433if (!selection) {434return;435}436437if (this._gutterDecorationID === undefined) {438this._addGutterDecoration(selection.startLineNumber);439} else {440this._updateGutterDecoration(this._gutterDecorationID, selection.startLineNumber);441}442}443444private _addGutterDecoration(lineNumber: number) {445this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {446this._gutterDecorationID = accessor.addDecoration(new Range(lineNumber, 0, lineNumber, 0), this.gutterDecoration);447});448}449450private _removeGutterDecoration(decorationId: string) {451this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {452accessor.removeDecoration(decorationId);453this._gutterDecorationID = undefined;454});455}456457private _updateGutterDecoration(decorationId: string, lineNumber: number) {458this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {459accessor.changeDecoration(decorationId, new Range(lineNumber, 0, lineNumber, 0));460accessor.changeDecorationOptions(decorationId, this.gutterDecoration);461});462}463464private _updateLightbulbTitle(autoFix: boolean, autoRun: boolean): void {465if (this.state.type !== LightBulbState.Type.Showing) {466return;467}468if (autoRun) {469this.title = nls.localize('codeActionAutoRun', "Run: {0}", this.state.actions.validActions[0].action.title);470} else if (autoFix && this._preferredKbLabel) {471this.title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", this._preferredKbLabel);472} else if (!autoFix && this._quickFixKbLabel) {473this.title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", this._quickFixKbLabel);474} else if (!autoFix) {475this.title = nls.localize('codeAction', "Show Code Actions");476}477}478479private set title(value: string) {480this._domNode.title = value;481}482}483484485