Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordanceWidget.ts
13401 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 './media/inlineChatEditorAffordance.css';6import { IDimension } from '../../../../base/browser/dom.js';7import * as dom from '../../../../base/browser/dom.js';8import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';9import { Emitter, Event } from '../../../../base/common/event.js';10import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../../editor/browser/editorBrowser.js';11import { EditorOption } from '../../../../editor/common/config/editorOptions.js';12import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js';13import { computeIndentLevel } from '../../../../editor/common/model/utils.js';14import { autorun, IObservable } from '../../../../base/common/observable.js';15import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js';16import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';17import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { quickFixCommandId } from '../../../../editor/contrib/codeAction/browser/codeAction.js';19import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js';20import { IAction } from '../../../../base/common/actions.js';21import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';22import { ThemeIcon } from '../../../../base/common/themables.js';23import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';24import { INotificationService } from '../../../../platform/notification/common/notification.js';25import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';26import { IThemeService } from '../../../../platform/theme/common/themeService.js';27import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';28import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';29import { Codicon } from '../../../../base/common/codicons.js';30import { ACTION_START, ACTION_ASK_IN_CHAT } from '../common/inlineChat.js';31import { ICommandService } from '../../../../platform/commands/common/commands.js';3233class QuickFixActionViewItem extends MenuEntryActionViewItem {3435readonly #lightBulbStore = this._store.add(new MutableDisposable<DisposableStore>());36#currentTitle: string | undefined;37readonly #editor: ICodeEditor;3839constructor(40action: MenuItemAction,41editor: ICodeEditor,42@IKeybindingService keybindingService: IKeybindingService,43@INotificationService notificationService: INotificationService,44@IContextKeyService contextKeyService: IContextKeyService,45@IThemeService themeService: IThemeService,46@IContextMenuService contextMenuService: IContextMenuService,47@IAccessibilityService accessibilityService: IAccessibilityService,48@ICommandService commandService: ICommandService49) {50const wrappedAction = new class extends MenuItemAction {51constructor() {52super(action.item, action.alt?.item, {}, action.hideActions, action.menuKeybinding, contextKeyService, commandService);53}5455elementGetter: () => HTMLElement | undefined = () => undefined;5657override async run(...args: unknown[]): Promise<void> {58const controller = CodeActionController.get(editor);59const info = controller?.lightBulbState.get();60const element = this.elementGetter();61if (controller && info && element) {62const { bottom, left } = element.getBoundingClientRect();63await controller.showCodeActions(info.trigger, info.actions, { x: left, y: bottom });64}65}66};6768super(wrappedAction, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService);6970this.#editor = editor;71wrappedAction.elementGetter = () => this.element;72}7374override render(container: HTMLElement): void {75super.render(container);76this.#updateFromLightBulb();77}7879protected override getTooltip(): string {80return this.#currentTitle ?? super.getTooltip();81}8283#updateFromLightBulb(): void {84const controller = CodeActionController.get(this.#editor);85if (!controller) {86return;87}8889const store = new DisposableStore();90this.#lightBulbStore.value = store;9192store.add(autorun(reader => {93const info = controller.lightBulbState.read(reader);94if (this.label) {95// Update icon96const icon = info?.icon ?? Codicon.lightBulb;97const iconClasses = ThemeIcon.asClassNameArray(icon);98this.label.className = '';99this.label.classList.add('codicon', 'action-label', ...iconClasses);100}101102// Update tooltip103this.#currentTitle = info?.title;104this.updateTooltip();105}));106}107}108109class LabelWithKeybindingActionViewItem extends MenuEntryActionViewItem {110111readonly #kbLabel: string | undefined;112113constructor(114action: MenuItemAction,115@IKeybindingService keybindingService: IKeybindingService,116@INotificationService notificationService: INotificationService,117@IContextKeyService contextKeyService: IContextKeyService,118@IThemeService themeService: IThemeService,119@IContextMenuService contextMenuService: IContextMenuService,120@IAccessibilityService accessibilityService: IAccessibilityService121) {122super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService);123this.options.label = true;124this.options.icon = false;125this.#kbLabel = keybindingService.lookupKeybinding(action.id)?.getLabel() ?? undefined;126}127128protected override updateLabel(): void {129if (this.label) {130dom.reset(this.label,131this.action.label,132...(this.#kbLabel ? [dom.$('span.inline-chat-keybinding', undefined, this.#kbLabel)] : [])133);134}135}136}137138/**139* Content widget that shows a small sparkle icon at the cursor position.140* When clicked, it shows the overlay widget for inline chat.141*/142export class InlineChatAffordanceWidget extends Disposable implements IContentWidget {143144static #idPool = 0;145146readonly #id = `inline-chat-content-widget-${InlineChatAffordanceWidget.#idPool++}`;147readonly #domNode: HTMLElement;148#position: IContentWidgetPosition | null = null;149#isVisible = false;150151readonly #onDidRunAction = this._store.add(new Emitter<string>());152readonly onDidRunAction: Event<string> = this.#onDidRunAction.event;153154readonly allowEditorOverflow = true;155readonly suppressMouseDown = false;156157readonly #editor: ICodeEditor;158159constructor(160editor: ICodeEditor,161selection: IObservable<Selection | undefined>,162@IInstantiationService instantiationService: IInstantiationService,163) {164super();165166this.#editor = editor;167168// Create the widget DOM169this.#domNode = dom.$('.inline-chat-content-widget');170171// Create toolbar with the inline chat start action172const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this.#domNode, MenuId.InlineChatEditorAffordance, {173telemetrySource: 'inlineChatEditorAffordance',174hiddenItemStrategy: HiddenItemStrategy.Ignore,175menuOptions: { renderShortTitle: true },176toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true },177actionViewItemProvider: (action: IAction) => {178if (action instanceof MenuItemAction && action.id === quickFixCommandId) {179return instantiationService.createInstance(QuickFixActionViewItem, action, this.#editor);180}181if (action instanceof MenuItemAction && (action.id === ACTION_START || action.id === ACTION_ASK_IN_CHAT || action.id === 'inlineChat.fixDiagnostics')) {182return instantiationService.createInstance(LabelWithKeybindingActionViewItem, action);183}184return undefined;185}186}));187this._store.add(toolbar.actionRunner.onDidRun((e) => {188this.#onDidRunAction.fire(e.action.id);189this.#hide();190}));191192this._store.add(autorun(r => {193const sel = selection.read(r);194if (sel) {195this.#show(sel);196} else {197this.#hide();198}199}));200201this._store.add(this.#editor.onDidScrollChange(() => {202const sel = selection.get();203if (!sel) {204return;205}206const isInViewport = this.#isPositionInViewport();207if (isInViewport && !this.#isVisible) {208this.#show(sel);209} else if (!isInViewport && this.#isVisible) {210this.#hide();211}212}));213}214215#show(selection: Selection): void {216217if (selection.isEmpty()) {218this.#showAtLineStart(selection.getPosition().lineNumber);219} else {220this.#showAtSelection(selection);221}222223if (this.#isVisible) {224this.#editor.layoutContentWidget(this);225} else {226this.#editor.addContentWidget(this);227this.#isVisible = true;228}229}230231#showAtSelection(selection: Selection): void {232const cursorPosition = selection.getPosition();233const direction = selection.getDirection();234235const preference = direction === SelectionDirection.RTL236? ContentWidgetPositionPreference.ABOVE237: ContentWidgetPositionPreference.BELOW;238239this.#position = {240position: cursorPosition,241preference: [preference],242};243}244245#showAtLineStart(lineNumber: number): void {246const model = this.#editor.getModel();247if (!model) {248return;249}250251const tabSize = model.getOptions().tabSize;252const fontInfo = this.#editor.getOptions().get(EditorOption.fontInfo);253const lineContent = model.getLineContent(lineNumber);254const indent = computeIndentLevel(lineContent, tabSize);255const lineHasSpace = indent < 0 ? true : fontInfo.spaceWidth * indent > 22;256257let effectiveLineNumber = lineNumber;258259if (!lineHasSpace) {260const isLineEmptyOrIndented = (ln: number): boolean => {261const content = model.getLineContent(ln);262return /^\s*$|^\s+/.test(content);263};264265const lineCount = model.getLineCount();266if (lineNumber > 1 && isLineEmptyOrIndented(lineNumber - 1)) {267effectiveLineNumber = lineNumber - 1;268} else if (lineNumber < lineCount && isLineEmptyOrIndented(lineNumber + 1)) {269effectiveLineNumber = lineNumber + 1;270}271}272273const effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1;274275this.#position = {276position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber },277preference: [ContentWidgetPositionPreference.EXACT],278};279}280281#isPositionInViewport(): boolean {282const widgetPosition = this.#position?.position;283if (!widgetPosition) {284return false;285}286287// Check vertical visibility288const visibleRanges = this.#editor.getVisibleRanges();289const isLineVisible = visibleRanges.some(range =>290widgetPosition.lineNumber >= range.startLineNumber && widgetPosition.lineNumber <= range.endLineNumber291);292if (!isLineVisible) {293return false;294}295296// Check horizontal visibility297const scrolledPos = this.#editor.getScrolledVisiblePosition(widgetPosition);298if (!scrolledPos) {299return false;300}301const layoutInfo = this.#editor.getOptions().get(EditorOption.layoutInfo);302return scrolledPos.left >= 0 && scrolledPos.left <= layoutInfo.width;303}304305#hide(): void {306if (this.#isVisible) {307this.#isVisible = false;308this.#editor.removeContentWidget(this);309}310}311312getId(): string {313return this.#id;314}315316getDomNode(): HTMLElement {317return this.#domNode;318}319320getPosition(): IContentWidgetPosition | null {321return this.#position;322}323324beforeRender(): IDimension | null {325const position = this.#editor.getPosition();326const lineHeight = position ? this.#editor.getLineHeightForPosition(position) : this.#editor.getOption(EditorOption.lineHeight);327328this.#domNode.style.setProperty('--vscode-inline-chat-affordance-height', `${lineHeight}px`);329330return null;331}332333override dispose(): void {334if (this.#isVisible) {335this.#editor.removeContentWidget(this);336}337super.dispose();338}339}340341342