Path: blob/main/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts
5221 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 { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js';11import { ThemeIcon } from '../../../../base/common/themables.js';12import './lightBulbWidget.css';13import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from '../../../browser/editorBrowser.js';14import { EditorOption } from '../../../common/config/editorOptions.js';15import { IPosition } from '../../../common/core/position.js';16import { GlyphMarginLane, IModelDecorationsChangeAccessor, TrackedRangeStickiness } from '../../../common/model.js';17import { ModelDecorationOptions } from '../../../common/model/textModel.js';18import { computeIndentLevel } from '../../../common/model/utils.js';19import { autoFixCommandId, quickFixCommandId } from './codeAction.js';20import { CodeActionSet, CodeActionTrigger } from '../common/types.js';21import * as nls from '../../../../nls.js';22import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';23import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';24import { Range } from '../../../common/core/range.js';2526const 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.'));27const 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.'));28const 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.'));29const 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.'));30const 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.'));3132export interface LightBulbInfo {33readonly actions: CodeActionSet;34readonly trigger: CodeActionTrigger;35readonly icon: ThemeIcon;36readonly autoRun: boolean;37readonly title: string;38readonly isGutter: boolean;39}4041namespace LightBulbState {4243export const enum Type {44Hidden,45Showing,46}4748export const Hidden = { type: Type.Hidden } as const;4950export class Showing {51readonly type = Type.Showing;5253constructor(54public readonly actions: CodeActionSet,55public readonly trigger: CodeActionTrigger,56public readonly editorPosition: IPosition,57public readonly widgetPosition: IContentWidgetPosition,58) { }59}6061export type State = typeof Hidden | Showing;62}6364export class LightBulbWidget extends Disposable implements IContentWidget {65private _gutterDecorationID: string | undefined;6667private static readonly GUTTER_DECORATION = ModelDecorationOptions.register({68description: 'codicon-gutter-lightbulb-decoration',69glyphMarginClassName: ThemeIcon.asClassName(Codicon.lightBulb),70glyphMargin: { position: GlyphMarginLane.Left },71stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,72});7374public static readonly ID = 'editor.contrib.lightbulbWidget';7576private static readonly _posPref = [ContentWidgetPositionPreference.EXACT];7778private readonly _domNode: HTMLElement;7980private readonly _onClick = this._register(new Emitter<{ readonly x: number; readonly y: number; readonly actions: CodeActionSet; readonly trigger: CodeActionTrigger }>());81public readonly onClick = this._onClick.event;8283private readonly _state = observableValue<LightBulbState.State>(this, LightBulbState.Hidden);84private readonly _gutterState = observableValue<LightBulbState.State>(this, LightBulbState.Hidden);8586private readonly _combinedInfo = derived(this, reader => {87const gutterState = this._gutterState.read(reader);88if (gutterState.type === LightBulbState.Type.Showing) {89return LightBulbWidget._computeLightBulbInfo(gutterState, true, this._preferredKbLabel.read(reader), this._quickFixKbLabel.read(reader));90}91const state = this._state.read(reader);92if (state.type === LightBulbState.Type.Showing) {93return LightBulbWidget._computeLightBulbInfo(state, false, this._preferredKbLabel.read(reader), this._quickFixKbLabel.read(reader));94}95return undefined;96});9798public readonly lightBulbInfo: IObservable<LightBulbInfo | undefined> = this._combinedInfo;99100private _iconClasses: string[] = [];101102private readonly lightbulbClasses = [103'codicon-' + GUTTER_LIGHTBULB_ICON.id,104'codicon-' + GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON.id,105'codicon-' + GUTTER_LIGHTBULB_AUTO_FIX_ICON.id,106'codicon-' + GUTTER_LIGHTBULB_AIFIX_ICON.id,107'codicon-' + GUTTER_SPARKLE_FILLED_ICON.id108];109110private readonly _preferredKbLabel = observableValue<string | undefined>(this, undefined);111private readonly _quickFixKbLabel = observableValue<string | undefined>(this, undefined);112113private gutterDecoration: ModelDecorationOptions = LightBulbWidget.GUTTER_DECORATION;114115private static _computeLightBulbInfo(state: LightBulbState.State, forGutter: boolean, preferredKbLabel: string | undefined, quickFixKbLabel: string | undefined): LightBulbInfo | undefined {116if (state.type !== LightBulbState.Type.Showing) {117return undefined;118}119120const { actions, trigger } = state;121let icon: ThemeIcon;122let autoRun = false;123if (actions.allAIFixes) {124icon = forGutter ? GUTTER_SPARKLE_FILLED_ICON : Codicon.sparkleFilled;125if (actions.validActions.length === 1) {126autoRun = true;127}128} else if (actions.hasAutoFix) {129if (actions.hasAIFix) {130icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON : Codicon.lightbulbSparkleAutofix;131} else {132icon = forGutter ? GUTTER_LIGHTBULB_AUTO_FIX_ICON : Codicon.lightbulbAutofix;133}134} else if (actions.hasAIFix) {135icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_ICON : Codicon.lightbulbSparkle;136} else {137icon = forGutter ? GUTTER_LIGHTBULB_ICON : Codicon.lightBulb;138}139140let title: string;141if (autoRun) {142title = nls.localize('codeActionAutoRun', "Run: {0}", actions.validActions[0].action.title);143} else if (actions.hasAutoFix && preferredKbLabel) {144title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", preferredKbLabel);145} else if (!actions.hasAutoFix && quickFixKbLabel) {146title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", quickFixKbLabel);147} else {148title = nls.localize('codeAction', "Show Code Actions");149}150151return { actions, trigger, icon, autoRun, title, isGutter: forGutter };152}153154constructor(155private readonly _editor: ICodeEditor,156@IKeybindingService private readonly _keybindingService: IKeybindingService157) {158super();159160this._domNode = dom.$('div.lightBulbWidget');161this._domNode.role = 'listbox';162this._register(Gesture.ignoreTarget(this._domNode));163164this._editor.addContentWidget(this);165166this._register(this._editor.onDidChangeModelContent(_ => {167// cancel when the line in question has been removed168const editorModel = this._editor.getModel();169const state = this._state.get();170if (state.type !== LightBulbState.Type.Showing || !editorModel || state.editorPosition.lineNumber >= editorModel.getLineCount()) {171this.hide();172}173174const gutterState = this._gutterState.get();175if (gutterState.type !== LightBulbState.Type.Showing || !editorModel || gutterState.editorPosition.lineNumber >= editorModel.getLineCount()) {176this.gutterHide();177}178}));179180this._register(dom.addStandardDisposableGenericMouseDownListener(this._domNode, e => {181const state = this._state.get();182if (state.type !== LightBulbState.Type.Showing) {183return;184}185186// Make sure that focus / cursor location is not lost when clicking widget icon187this._editor.focus();188e.preventDefault();189190// a bit of extra work to make sure the menu191// doesn't cover the line-text192const { top, height } = dom.getDomNodePagePosition(this._domNode);193const lineHeight = this._editor.getOption(EditorOption.lineHeight);194195let pad = Math.floor(lineHeight / 3);196if (state.widgetPosition.position !== null && state.widgetPosition.position.lineNumber < state.editorPosition.lineNumber) {197pad += lineHeight;198}199200this._onClick.fire({201x: e.posx,202y: top + height + pad,203actions: state.actions,204trigger: state.trigger,205});206}));207208this._register(dom.addDisposableListener(this._domNode, 'mouseenter', (e: MouseEvent) => {209if ((e.buttons & 1) !== 1) {210return;211}212// mouse enters lightbulb while the primary/left button213// is being pressed -> hide the lightbulb214this.hide();215}));216217218this._register(Event.runAndSubscribe(this._keybindingService.onDidUpdateKeybindings, () => {219this._preferredKbLabel.set(this._keybindingService.lookupKeybinding(autoFixCommandId)?.getLabel() ?? undefined, undefined);220this._quickFixKbLabel.set(this._keybindingService.lookupKeybinding(quickFixCommandId)?.getLabel() ?? undefined, undefined);221}));222223// Autorun to update the DOM based on state changes224this._register(autorun(reader => {225const info = this._combinedInfo.read(reader);226this._updateLightBulbTitleAndIcon(info);227this._updateGutterDecorationOptions(info);228}));229230this._register(this._editor.onMouseDown(async (e: IEditorMouseEvent) => {231232if (!e.target.element || !this.lightbulbClasses.some(cls => e.target.element && e.target.element.classList.contains(cls))) {233return;234}235236const gutterState = this._gutterState.get();237if (gutterState.type !== LightBulbState.Type.Showing) {238return;239}240241// Make sure that focus / cursor location is not lost when clicking widget icon242this._editor.focus();243244// a bit of extra work to make sure the menu245// doesn't cover the line-text246const { top, height } = dom.getDomNodePagePosition(e.target.element);247const lineHeight = this._editor.getOption(EditorOption.lineHeight);248249let pad = Math.floor(lineHeight / 3);250if (gutterState.widgetPosition.position !== null && gutterState.widgetPosition.position.lineNumber < gutterState.editorPosition.lineNumber) {251pad += lineHeight;252}253254this._onClick.fire({255x: e.event.posx,256y: top + height + pad,257actions: gutterState.actions,258trigger: gutterState.trigger,259});260}));261}262263override dispose(): void {264super.dispose();265this._editor.removeContentWidget(this);266if (this._gutterDecorationID) {267this._removeGutterDecoration(this._gutterDecorationID);268}269}270271getId(): string {272return 'LightBulbWidget';273}274275getDomNode(): HTMLElement {276return this._domNode;277}278279getPosition(): IContentWidgetPosition | null {280const state = this._state.get();281return state.type === LightBulbState.Type.Showing ? state.widgetPosition : null;282}283284public update(actions: CodeActionSet, trigger: CodeActionTrigger, atPosition: IPosition) {285if (actions.validActions.length <= 0) {286this.gutterHide();287return this.hide();288}289290const hasTextFocus = this._editor.hasTextFocus();291if (!hasTextFocus) {292this.gutterHide();293return this.hide();294}295296const options = this._editor.getOptions();297if (!options.get(EditorOption.lightbulb).enabled) {298this.gutterHide();299return this.hide();300}301302303const model = this._editor.getModel();304if (!model) {305this.gutterHide();306return this.hide();307}308309const { lineNumber, column } = model.validatePosition(atPosition);310311const tabSize = model.getOptions().tabSize;312const fontInfo = this._editor.getOptions().get(EditorOption.fontInfo);313const lineContent = model.getLineContent(lineNumber);314const indent = computeIndentLevel(lineContent, tabSize);315const lineHasSpace = fontInfo.spaceWidth * indent > 22;316const isFolded = (lineNumber: number) => {317return lineNumber > 2 && this._editor.getTopForLineNumber(lineNumber) === this._editor.getTopForLineNumber(lineNumber - 1);318};319320// Check for glyph margin decorations of any kind321const currLineDecorations = this._editor.getLineDecorations(lineNumber);322let hasDecoration = false;323if (currLineDecorations) {324for (const decoration of currLineDecorations) {325const glyphClass = decoration.options.glyphMarginClassName;326327if (glyphClass && !this.lightbulbClasses.some(className => glyphClass.includes(className))) {328hasDecoration = true;329break;330}331}332}333334let effectiveLineNumber = lineNumber;335let effectiveColumnNumber = 1;336if (!lineHasSpace) {337// Checks if line is empty or starts with any amount of whitespace338const isLineEmptyOrIndented = (lineNumber: number): boolean => {339const lineContent = model.getLineContent(lineNumber);340return /^\s*$|^\s+/.test(lineContent) || lineContent.length <= effectiveColumnNumber;341};342343if (lineNumber > 1 && !isFolded(lineNumber - 1)) {344const lineCount = model.getLineCount();345const endLine = lineNumber === lineCount;346const prevLineEmptyOrIndented = lineNumber > 1 && isLineEmptyOrIndented(lineNumber - 1);347const nextLineEmptyOrIndented = !endLine && isLineEmptyOrIndented(lineNumber + 1);348const currLineEmptyOrIndented = isLineEmptyOrIndented(lineNumber);349const notEmpty = !nextLineEmptyOrIndented && !prevLineEmptyOrIndented;350351// check above and below. if both are blocked, display lightbulb in the gutter.352if (!nextLineEmptyOrIndented && !prevLineEmptyOrIndented && !hasDecoration) {353this._gutterState.set(new LightBulbState.Showing(actions, trigger, atPosition, {354position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },355preference: LightBulbWidget._posPref356}), undefined);357this.renderGutterLightbub();358return this.hide();359} else if (prevLineEmptyOrIndented || endLine || (prevLineEmptyOrIndented && !currLineEmptyOrIndented)) {360effectiveLineNumber -= 1;361} else if (nextLineEmptyOrIndented || (notEmpty && currLineEmptyOrIndented)) {362effectiveLineNumber += 1;363}364} else if (lineNumber === 1 && (lineNumber === model.getLineCount() || !isLineEmptyOrIndented(lineNumber + 1) && !isLineEmptyOrIndented(lineNumber))) {365// special checks for first line blocked vs. not blocked.366this._gutterState.set(new LightBulbState.Showing(actions, trigger, atPosition, {367position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },368preference: LightBulbWidget._posPref369}), undefined);370371if (hasDecoration) {372this.gutterHide();373} else {374this.renderGutterLightbub();375return this.hide();376}377} else if ((lineNumber < model.getLineCount()) && !isFolded(lineNumber + 1)) {378effectiveLineNumber += 1;379} else if (column * fontInfo.spaceWidth < 22) {380// cannot show lightbulb above/below and showing381// it inline would overlay the cursor...382return this.hide();383}384effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1;385}386387this._state.set(new LightBulbState.Showing(actions, trigger, atPosition, {388position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },389preference: LightBulbWidget._posPref390}), undefined);391392if (this._gutterDecorationID) {393this._removeGutterDecoration(this._gutterDecorationID);394this.gutterHide();395}396397const validActions = actions.validActions;398const actionKind = actions.validActions[0].action.kind;399if (validActions.length !== 1 || !actionKind) {400this._editor.layoutContentWidget(this);401return;402}403404this._editor.layoutContentWidget(this);405}406407public hide(): void {408if (this._state.get() === LightBulbState.Hidden) {409return;410}411412this._state.set(LightBulbState.Hidden, undefined);413this._editor.layoutContentWidget(this);414}415416public gutterHide(): void {417if (this._gutterState.get() === LightBulbState.Hidden) {418return;419}420421if (this._gutterDecorationID) {422this._removeGutterDecoration(this._gutterDecorationID);423}424425this._gutterState.set(LightBulbState.Hidden, undefined);426}427428private _updateLightBulbTitleAndIcon(info: LightBulbInfo | undefined): void {429this._domNode.classList.remove(...this._iconClasses);430this._iconClasses = [];431if (!info || info.isGutter) {432return;433}434this._domNode.title = info.title;435this._iconClasses = ThemeIcon.asClassNameArray(info.icon);436this._domNode.classList.add(...this._iconClasses);437}438439private _updateGutterDecorationOptions(info: LightBulbInfo | undefined): void {440if (!info || !info.isGutter) {441return;442}443444this.gutterDecoration = ModelDecorationOptions.register({445description: 'codicon-gutter-lightbulb-decoration',446glyphMarginClassName: ThemeIcon.asClassName(info.icon),447glyphMargin: { position: GlyphMarginLane.Left },448stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,449});450}451452/* Gutter Helper Functions */453private renderGutterLightbub(): void {454const selection = this._editor.getSelection();455if (!selection) {456return;457}458459if (this._gutterDecorationID === undefined) {460this._addGutterDecoration(selection.startLineNumber);461} else {462this._updateGutterDecoration(this._gutterDecorationID, selection.startLineNumber);463}464}465466private _addGutterDecoration(lineNumber: number) {467this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {468this._gutterDecorationID = accessor.addDecoration(new Range(lineNumber, 0, lineNumber, 0), this.gutterDecoration);469});470}471472private _removeGutterDecoration(decorationId: string) {473this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {474accessor.removeDecoration(decorationId);475this._gutterDecorationID = undefined;476});477}478479private _updateGutterDecoration(decorationId: string, lineNumber: number) {480this._editor.changeDecorations((accessor: IModelDecorationsChangeAccessor) => {481accessor.changeDecoration(decorationId, new Range(lineNumber, 0, lineNumber, 0));482accessor.changeDecorationOptions(decorationId, this.gutterDecoration);483});484}485486487}488489490