From 2d1abf32f59d3e95060d7b982962e371a423f57a Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 26 Jan 2026 12:10:13 +0100 Subject: [PATCH] Enhance light bulb functionality with observable state management and tooltip updates in inline chat editor affordance --- .../browser/codeActionController.ts | 11 +- .../codeAction/browser/lightBulbWidget.ts | 217 +++++++++--------- .../browser/inlineChatEditorAffordance.ts | 75 +++++- 3 files changed, 194 insertions(+), 109 deletions(-) diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts index f4fda50ed74c9..5124acc51a1fe 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts @@ -13,6 +13,7 @@ import { onUnexpectedError } from '../../../../base/common/errors.js'; import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IActionListDelegate } from '../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; @@ -38,7 +39,7 @@ import { ApplyCodeActionReason, applyCodeAction } from './codeAction.js'; import { CodeActionKeybindingResolver } from './codeActionKeybindingResolver.js'; import { toMenuItems } from './codeActionMenu.js'; import { CodeActionModel, CodeActionsState } from './codeActionModel.js'; -import { LightBulbWidget } from './lightBulbWidget.js'; +import { LightBulbInfo, LightBulbWidget } from './lightBulbWidget.js'; interface IActionShowOptions { readonly includeDisabledActions?: boolean; @@ -67,6 +68,14 @@ export class CodeActionController extends Disposable implements IEditorContribut private _disposed = false; + public readonly lightBulbState: IObservable = derived(this, reader => { + const widget = this._lightBulbWidget.rawValue; + if (!widget) { + return undefined; + } + return widget.lightBulbInfo.read(reader); + }); + constructor( editor: ICodeEditor, @IMarkerService markerService: IMarkerService, diff --git a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts index 39dd29629f57e..3de0481ef730a 100644 --- a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts +++ b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts @@ -8,6 +8,7 @@ import { Gesture } from '../../../../base/browser/touch.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import './lightBulbWidget.css'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from '../../../browser/editorBrowser.js'; @@ -29,6 +30,15 @@ const GUTTER_LIGHTBULB_AIFIX_ICON = registerIcon('gutter-lightbulb-sparkle', Cod 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.')); 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.')); +export interface LightBulbInfo { + readonly actions: CodeActionSet; + readonly trigger: CodeActionTrigger; + readonly icon: ThemeIcon; + readonly autoRun: boolean; + readonly title: string; + readonly isGutter: boolean; +} + namespace LightBulbState { export const enum Type { @@ -71,8 +81,23 @@ export class LightBulbWidget extends Disposable implements IContentWidget { private readonly _onClick = this._register(new Emitter<{ readonly x: number; readonly y: number; readonly actions: CodeActionSet; readonly trigger: CodeActionTrigger }>()); public readonly onClick = this._onClick.event; - private _state: LightBulbState.State = LightBulbState.Hidden; - private _gutterState: LightBulbState.State = LightBulbState.Hidden; + private readonly _state = observableValue(this, LightBulbState.Hidden); + private readonly _gutterState = observableValue(this, LightBulbState.Hidden); + + private readonly _combinedInfo = derived(this, reader => { + const gutterState = this._gutterState.read(reader); + if (gutterState.type === LightBulbState.Type.Showing) { + return LightBulbWidget._computeLightBulbInfo(gutterState, true, this._preferredKbLabel.read(reader), this._quickFixKbLabel.read(reader)); + } + const state = this._state.read(reader); + if (state.type === LightBulbState.Type.Showing) { + return LightBulbWidget._computeLightBulbInfo(state, false, this._preferredKbLabel.read(reader), this._quickFixKbLabel.read(reader)); + } + return undefined; + }); + + public readonly lightBulbInfo: IObservable = this._combinedInfo; + private _iconClasses: string[] = []; private readonly lightbulbClasses = [ @@ -83,11 +108,50 @@ export class LightBulbWidget extends Disposable implements IContentWidget { 'codicon-' + GUTTER_SPARKLE_FILLED_ICON.id ]; - private _preferredKbLabel?: string; - private _quickFixKbLabel?: string; + private readonly _preferredKbLabel = observableValue(this, undefined); + private readonly _quickFixKbLabel = observableValue(this, undefined); private gutterDecoration: ModelDecorationOptions = LightBulbWidget.GUTTER_DECORATION; + private static _computeLightBulbInfo(state: LightBulbState.State, forGutter: boolean, preferredKbLabel: string | undefined, quickFixKbLabel: string | undefined): LightBulbInfo | undefined { + if (state.type !== LightBulbState.Type.Showing) { + return undefined; + } + + const { actions, trigger } = state; + let icon: ThemeIcon; + let autoRun = false; + if (actions.allAIFixes) { + icon = forGutter ? GUTTER_SPARKLE_FILLED_ICON : Codicon.sparkleFilled; + if (actions.validActions.length === 1) { + autoRun = true; + } + } else if (actions.hasAutoFix) { + if (actions.hasAIFix) { + icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON : Codicon.lightbulbSparkleAutofix; + } else { + icon = forGutter ? GUTTER_LIGHTBULB_AUTO_FIX_ICON : Codicon.lightbulbAutofix; + } + } else if (actions.hasAIFix) { + icon = forGutter ? GUTTER_LIGHTBULB_AIFIX_ICON : Codicon.lightbulbSparkle; + } else { + icon = forGutter ? GUTTER_LIGHTBULB_ICON : Codicon.lightBulb; + } + + let title: string; + if (autoRun) { + title = nls.localize('codeActionAutoRun', "Run: {0}", actions.validActions[0].action.title); + } else if (actions.hasAutoFix && preferredKbLabel) { + title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", preferredKbLabel); + } else if (!actions.hasAutoFix && quickFixKbLabel) { + title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", quickFixKbLabel); + } else { + title = nls.localize('codeAction', "Show Code Actions"); + } + + return { actions, trigger, icon, autoRun, title, isGutter: forGutter }; + } + constructor( private readonly _editor: ICodeEditor, @IKeybindingService private readonly _keybindingService: IKeybindingService @@ -103,17 +167,20 @@ export class LightBulbWidget extends Disposable implements IContentWidget { this._register(this._editor.onDidChangeModelContent(_ => { // cancel when the line in question has been removed const editorModel = this._editor.getModel(); - if (this.state.type !== LightBulbState.Type.Showing || !editorModel || this.state.editorPosition.lineNumber >= editorModel.getLineCount()) { + const state = this._state.get(); + if (state.type !== LightBulbState.Type.Showing || !editorModel || state.editorPosition.lineNumber >= editorModel.getLineCount()) { this.hide(); } - if (this.gutterState.type !== LightBulbState.Type.Showing || !editorModel || this.gutterState.editorPosition.lineNumber >= editorModel.getLineCount()) { + const gutterState = this._gutterState.get(); + if (gutterState.type !== LightBulbState.Type.Showing || !editorModel || gutterState.editorPosition.lineNumber >= editorModel.getLineCount()) { this.gutterHide(); } })); this._register(dom.addStandardDisposableGenericMouseDownListener(this._domNode, e => { - if (this.state.type !== LightBulbState.Type.Showing) { + const state = this._state.get(); + if (state.type !== LightBulbState.Type.Showing) { return; } @@ -127,15 +194,15 @@ export class LightBulbWidget extends Disposable implements IContentWidget { const lineHeight = this._editor.getOption(EditorOption.lineHeight); let pad = Math.floor(lineHeight / 3); - if (this.state.widgetPosition.position !== null && this.state.widgetPosition.position.lineNumber < this.state.editorPosition.lineNumber) { + if (state.widgetPosition.position !== null && state.widgetPosition.position.lineNumber < state.editorPosition.lineNumber) { pad += lineHeight; } this._onClick.fire({ x: e.posx, y: top + height + pad, - actions: this.state.actions, - trigger: this.state.trigger, + actions: state.actions, + trigger: state.trigger, }); })); @@ -150,9 +217,15 @@ export class LightBulbWidget extends Disposable implements IContentWidget { this._register(Event.runAndSubscribe(this._keybindingService.onDidUpdateKeybindings, () => { - this._preferredKbLabel = this._keybindingService.lookupKeybinding(autoFixCommandId)?.getLabel() ?? undefined; - this._quickFixKbLabel = this._keybindingService.lookupKeybinding(quickFixCommandId)?.getLabel() ?? undefined; - this._updateLightBulbTitleAndIcon(); + this._preferredKbLabel.set(this._keybindingService.lookupKeybinding(autoFixCommandId)?.getLabel() ?? undefined, undefined); + this._quickFixKbLabel.set(this._keybindingService.lookupKeybinding(quickFixCommandId)?.getLabel() ?? undefined, undefined); + })); + + // Autorun to update the DOM based on state changes + this._register(autorun(reader => { + const info = this._combinedInfo.read(reader); + this._updateLightBulbTitleAndIcon(info); + this._updateGutterDecorationOptions(info); })); this._register(this._editor.onMouseDown(async (e: IEditorMouseEvent) => { @@ -161,7 +234,8 @@ export class LightBulbWidget extends Disposable implements IContentWidget { return; } - if (this.gutterState.type !== LightBulbState.Type.Showing) { + const gutterState = this._gutterState.get(); + if (gutterState.type !== LightBulbState.Type.Showing) { return; } @@ -174,15 +248,15 @@ export class LightBulbWidget extends Disposable implements IContentWidget { const lineHeight = this._editor.getOption(EditorOption.lineHeight); let pad = Math.floor(lineHeight / 3); - if (this.gutterState.widgetPosition.position !== null && this.gutterState.widgetPosition.position.lineNumber < this.gutterState.editorPosition.lineNumber) { + if (gutterState.widgetPosition.position !== null && gutterState.widgetPosition.position.lineNumber < gutterState.editorPosition.lineNumber) { pad += lineHeight; } this._onClick.fire({ x: e.event.posx, y: top + height + pad, - actions: this.gutterState.actions, - trigger: this.gutterState.trigger, + actions: gutterState.actions, + trigger: gutterState.trigger, }); })); } @@ -204,7 +278,8 @@ export class LightBulbWidget extends Disposable implements IContentWidget { } getPosition(): IContentWidgetPosition | null { - return this._state.type === LightBulbState.Type.Showing ? this._state.widgetPosition : null; + const state = this._state.get(); + return state.type === LightBulbState.Type.Showing ? state.widgetPosition : null; } public update(actions: CodeActionSet, trigger: CodeActionTrigger, atPosition: IPosition) { @@ -276,10 +351,10 @@ export class LightBulbWidget extends Disposable implements IContentWidget { // check above and below. if both are blocked, display lightbulb in the gutter. if (!nextLineEmptyOrIndented && !prevLineEmptyOrIndented && !hasDecoration) { - this.gutterState = new LightBulbState.Showing(actions, trigger, atPosition, { + this._gutterState.set(new LightBulbState.Showing(actions, trigger, atPosition, { position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber }, preference: LightBulbWidget._posPref - }); + }), undefined); this.renderGutterLightbub(); return this.hide(); } else if (prevLineEmptyOrIndented || endLine || (prevLineEmptyOrIndented && !currLineEmptyOrIndented)) { @@ -289,10 +364,10 @@ export class LightBulbWidget extends Disposable implements IContentWidget { } } else if (lineNumber === 1 && (lineNumber === model.getLineCount() || !isLineEmptyOrIndented(lineNumber + 1) && !isLineEmptyOrIndented(lineNumber))) { // special checks for first line blocked vs. not blocked. - this.gutterState = new LightBulbState.Showing(actions, trigger, atPosition, { + this._gutterState.set(new LightBulbState.Showing(actions, trigger, atPosition, { position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber }, preference: LightBulbWidget._posPref - }); + }), undefined); if (hasDecoration) { this.gutterHide(); @@ -310,10 +385,10 @@ export class LightBulbWidget extends Disposable implements IContentWidget { effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1; } - this.state = new LightBulbState.Showing(actions, trigger, atPosition, { + this._state.set(new LightBulbState.Showing(actions, trigger, atPosition, { position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber }, preference: LightBulbWidget._posPref - }); + }), undefined); if (this._gutterDecorationID) { this._removeGutterDecoration(this._gutterDecorationID); @@ -331,16 +406,16 @@ export class LightBulbWidget extends Disposable implements IContentWidget { } public hide(): void { - if (this.state === LightBulbState.Hidden) { + if (this._state.get() === LightBulbState.Hidden) { return; } - this.state = LightBulbState.Hidden; + this._state.set(LightBulbState.Hidden, undefined); this._editor.layoutContentWidget(this); } public gutterHide(): void { - if (this.gutterState === LightBulbState.Hidden) { + if (this._gutterState.get() === LightBulbState.Hidden) { return; } @@ -348,84 +423,31 @@ export class LightBulbWidget extends Disposable implements IContentWidget { this._removeGutterDecoration(this._gutterDecorationID); } - this.gutterState = LightBulbState.Hidden; + this._gutterState.set(LightBulbState.Hidden, undefined); } - private get state(): LightBulbState.State { return this._state; } - - private set state(value) { - this._state = value; - this._updateLightBulbTitleAndIcon(); - } - - private get gutterState(): LightBulbState.State { return this._gutterState; } - - private set gutterState(value) { - this._gutterState = value; - this._updateGutterLightBulbTitleAndIcon(); - } - - private _updateLightBulbTitleAndIcon(): void { + private _updateLightBulbTitleAndIcon(info: LightBulbInfo | undefined): void { this._domNode.classList.remove(...this._iconClasses); this._iconClasses = []; - if (this.state.type !== LightBulbState.Type.Showing) { + if (!info || info.isGutter) { return; } - let icon: ThemeIcon; - let autoRun = false; - if (this.state.actions.allAIFixes) { - icon = Codicon.sparkleFilled; - if (this.state.actions.validActions.length === 1) { - autoRun = true; - } - } else if (this.state.actions.hasAutoFix) { - if (this.state.actions.hasAIFix) { - icon = Codicon.lightbulbSparkleAutofix; - } else { - icon = Codicon.lightbulbAutofix; - } - } else if (this.state.actions.hasAIFix) { - icon = Codicon.lightbulbSparkle; - } else { - icon = Codicon.lightBulb; - } - this._updateLightbulbTitle(this.state.actions.hasAutoFix, autoRun); - this._iconClasses = ThemeIcon.asClassNameArray(icon); + this._domNode.title = info.title; + this._iconClasses = ThemeIcon.asClassNameArray(info.icon); this._domNode.classList.add(...this._iconClasses); } - private _updateGutterLightBulbTitleAndIcon(): void { - if (this.gutterState.type !== LightBulbState.Type.Showing) { + private _updateGutterDecorationOptions(info: LightBulbInfo | undefined): void { + if (!info || !info.isGutter) { return; } - let icon: ThemeIcon; - let autoRun = false; - if (this.gutterState.actions.allAIFixes) { - icon = GUTTER_SPARKLE_FILLED_ICON; - if (this.gutterState.actions.validActions.length === 1) { - autoRun = true; - } - } else if (this.gutterState.actions.hasAutoFix) { - if (this.gutterState.actions.hasAIFix) { - icon = GUTTER_LIGHTBULB_AIFIX_AUTO_FIX_ICON; - } else { - icon = GUTTER_LIGHTBULB_AUTO_FIX_ICON; - } - } else if (this.gutterState.actions.hasAIFix) { - icon = GUTTER_LIGHTBULB_AIFIX_ICON; - } else { - icon = GUTTER_LIGHTBULB_ICON; - } - this._updateLightbulbTitle(this.gutterState.actions.hasAutoFix, autoRun); - const GUTTER_DECORATION = ModelDecorationOptions.register({ + this.gutterDecoration = ModelDecorationOptions.register({ description: 'codicon-gutter-lightbulb-decoration', - glyphMarginClassName: ThemeIcon.asClassName(icon), + glyphMarginClassName: ThemeIcon.asClassName(info.icon), glyphMargin: { position: GlyphMarginLane.Left }, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }); - - this.gutterDecoration = GUTTER_DECORATION; } /* Gutter Helper Functions */ @@ -462,22 +484,5 @@ export class LightBulbWidget extends Disposable implements IContentWidget { }); } - private _updateLightbulbTitle(autoFix: boolean, autoRun: boolean): void { - if (this.state.type !== LightBulbState.Type.Showing) { - return; - } - if (autoRun) { - this.title = nls.localize('codeActionAutoRun', "Run: {0}", this.state.actions.validActions[0].action.title); - } else if (autoFix && this._preferredKbLabel) { - this.title = nls.localize('preferredcodeActionWithKb', "Show Code Actions. Preferred Quick Fix Available ({0})", this._preferredKbLabel); - } else if (!autoFix && this._quickFixKbLabel) { - this.title = nls.localize('codeActionWithKb', "Show Code Actions ({0})", this._quickFixKbLabel); - } else if (!autoFix) { - this.title = nls.localize('codeAction', "Show Code Actions"); - } - } - private set title(value: string) { - this._domNode.title = value; - } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index b7e09ff6fb7d4..8f2b2622e1917 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -6,14 +6,79 @@ import './media/inlineChatEditorAffordance.css'; import { IDimension } from '../../../../base/browser/dom.js'; import * as dom from '../../../../base/browser/dom.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; import { autorun, IObservable, ISettableObservable } from '../../../../base/common/observable.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { quickFixCommandId } from '../../../../editor/contrib/codeAction/browser/codeAction.js'; +import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { Codicon } from '../../../../base/common/codicons.js'; + +class QuickFixActionViewItem extends MenuEntryActionViewItem { + + private readonly _lightBulbStore = this._store.add(new MutableDisposable()); + private _currentTitle: string | undefined; + + constructor( + action: MenuItemAction, + private readonly _editor: ICodeEditor, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + @IContextMenuService contextMenuService: IContextMenuService, + @IAccessibilityService accessibilityService: IAccessibilityService + ) { + super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + } + + override render(container: HTMLElement): void { + super.render(container); + this._updateFromLightBulb(); + } + + protected override getTooltip(): string { + return this._currentTitle ?? super.getTooltip(); + } + + private _updateFromLightBulb(): void { + const controller = CodeActionController.get(this._editor); + if (!controller) { + return; + } + + const store = new DisposableStore(); + this._lightBulbStore.value = store; + + store.add(autorun(reader => { + const info = controller.lightBulbState.read(reader); + if (this.label) { + // Update icon + const icon = info?.icon ?? Codicon.lightBulb; + const iconClasses = ThemeIcon.asClassNameArray(icon); + this.label.className = ''; + this.label.classList.add('codicon', ...iconClasses); + } + + // Update tooltip + this._currentTitle = info?.title; + this.updateTooltip(); + })); + } +} /** * Content widget that shows a small sparkle icon at the cursor position. @@ -49,6 +114,12 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { renderShortTitle: true }, toolbarOptions: { primaryGroup: () => true }, + actionViewItemProvider: (action: IAction) => { + if (action instanceof MenuItemAction && action.id === quickFixCommandId) { + return instantiationService.createInstance(QuickFixActionViewItem, action, this._editor); + } + return undefined; + } })); this._store.add(autorun(r => {