diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts b/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts index 472d317fa1546..a4c064ed6c104 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts @@ -9,18 +9,15 @@ import 'vs/base/browser/ui/codicons/codiconStyles'; // The codicon symbol styles import { IAnchor } from 'vs/base/browser/ui/contextview/contextview'; import { IListEvent, IListMouseEvent, IListRenderer } from 'vs/base/browser/ui/list/list'; import { List } from 'vs/base/browser/ui/list/listWidget'; -import { Action, IAction } from 'vs/base/common/actions'; +import { IAction } from 'vs/base/common/actions'; import { Codicon } from 'vs/base/common/codicons'; -import { canceled } from 'vs/base/common/errors'; import { ResolvedKeybinding } from 'vs/base/common/keybindings'; import { Lazy } from 'vs/base/common/lazy'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import 'vs/css!./media/action'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { IPosition, Position } from 'vs/editor/common/core/position'; -import { IEditorContribution, ScrollType } from 'vs/editor/common/editorCommon'; +import { IEditorContribution } from 'vs/editor/common/editorCommon'; import { CodeAction, Command } from 'vs/editor/common/languages'; -import { ITextModel } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { codeActionCommandId, CodeActionItem, CodeActionSet, fixAllCommandId, organizeImportsCommandId, refactorCommandId, sourceActionCommandId } from 'vs/editor/contrib/codeAction/browser/codeAction'; import { CodeActionAutoApply, CodeActionCommandArgs, CodeActionKind, CodeActionTrigger, CodeActionTriggerSource } from 'vs/editor/contrib/codeAction/browser/types'; @@ -33,7 +30,6 @@ import { IContextViewService } from 'vs/platform/contextview/browser/contextView import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ResolvedKeybindingItem } from 'vs/platform/keybinding/common/resolvedKeybindingItem'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; export const Context = { Visible: new RawContextKey('codeActionMenuVisible', false, localize('codeActionMenuVisible', "Whether the code action list widget is visible")) @@ -52,15 +48,6 @@ interface ResolveCodeActionKeybinding { readonly resolvedKeybinding: ResolvedKeybinding; } -class CodeActionAction extends Action { - constructor( - public readonly action: CodeAction, - callback: () => Promise, - ) { - super(action.command ? action.command.id : action.title, stripNewlines(action.title), undefined, !action.disabled, callback); - } -} - function stripNewlines(str: string): string { return str.replace(/\r\n|\r|\n/g, ' '); } @@ -77,9 +64,8 @@ enum CodeActionListItemKind { interface CodeActionListItemCodeAction { readonly kind: CodeActionListItemKind.CodeAction; - readonly action: CodeActionAction; + readonly action: CodeActionItem; readonly index: number; - readonly params: ICodeActionMenuParameters; } interface CodeActionListItemHeader { @@ -90,32 +76,14 @@ interface CodeActionListItemHeader { type ICodeActionMenuItem = CodeActionListItemCodeAction | CodeActionListItemHeader; -export interface ICodeActionMenuParameters { - readonly options: CodeActionShowOptions; - readonly trigger: CodeActionTrigger; - readonly anchor: { x: number; y: number }; - readonly menuActions: IAction[]; - readonly documentationActions: readonly Command[]; - readonly codeActions: CodeActionSet; - readonly visible: boolean; - readonly showDisabled: boolean; -} - interface ICodeActionMenuTemplateData { readonly container: HTMLElement; readonly text: HTMLElement; readonly icon: HTMLElement; } -const codeActionLineHeight = 24; -const headerLineHeight = 26; - -// TODO: Take a look at user storage for this so it is preserved across windows and on reload. -let showDisabled = false; - class CodeActionItemRenderer implements IListRenderer { constructor( - private readonly acceptKeybindings: [string, string], @IKeybindingService private readonly keybindingService: IKeybindingService, ) { } @@ -136,7 +104,7 @@ class CodeActionItemRenderer implements IListRenderer { - const [accept, preview] = this.acceptKeybindings; - data.container.title = localize({ key: 'label', comment: ['placeholders are keybindings, e.g "F2 to Apply, Shift+F2 to Preview"'] }, "{0} to Apply, {1} to Preview", this.keybindingService.lookupKeybinding(accept)?.getLabel(), this.keybindingService.lookupKeybinding(preview)?.getLabel()); + data.container.title = localize({ key: 'label', comment: ['placeholders are keybindings, e.g "F2 to Apply, Shift+F2 to Preview"'] }, "{0} to Apply, {1} to Preview", this.keybindingService.lookupKeybinding(acceptSelectedCodeActionCommand)?.getLabel(), this.keybindingService.lookupKeybinding(previewSelectedCodeActionCommand)?.getLabel()); }; updateLabel(); } - if (!element.action.enabled) { + if (element.action.action.disabled) { data.container.classList.add('option-disabled'); data.container.style.backgroundColor = 'transparent !important'; data.icon.style.opacity = '0.4'; @@ -207,138 +174,44 @@ class HeaderRenderer implements IListRenderer()); - private codeActionList = this._register(new MutableDisposable>()); + public readonly domNode: HTMLElement; - private _visible: boolean = false; - private _ctxMenuWidgetVisible: IContextKey; - private viewItems: readonly CodeActionListItemCodeAction[] = []; - private focusedEnabledItem: number | undefined; - private currSelectedItem: number | undefined; - - public static readonly ID: string = 'editor.contrib.codeActionMenu'; + private readonly list: List; - public static get(editor: ICodeEditor): CodeActionMenu | null { - return editor.getContribution(CodeActionMenu.ID); - } + private readonly allMenuItems: ICodeActionMenuItem[]; + private readonly viewItems: readonly CodeActionListItemCodeAction[]; + private focusedEnabledItem?: number; + private currSelectedItem?: number; constructor( - private readonly _editor: ICodeEditor, - private readonly _delegate: CodeActionWidgetDelegate, - @ICommandService private readonly commandService: ICommandService, - @IKeybindingService private readonly keybindingService: IKeybindingService, - @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, - @IThemeService _themeService: IThemeService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IContextViewService private readonly _contextViewService: IContextViewService, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, + codeActions: readonly CodeActionItem[], + showHeaders: boolean, + private readonly onDidSelect: (action: CodeActionItem) => void, + @IKeybindingService keybindingService: IKeybindingService, ) { super(); - this._ctxMenuWidgetVisible = Context.Visible.bindTo(this._contextKeyService); - } - - get isVisible(): boolean { - return this._visible; - } - - /** - * Checks if the setting has disabled/enabled headers in the code action widget. - */ - private isCodeActionWidgetHeadersShown(model: ITextModel): boolean { - return this._configurationService.getValue('editor.codeActionWidget.showHeaders', { - resource: model.uri - }); - } - - private _onListSelection(e: IListEvent): void { - let didSelect = false; - for (const element of e.elements) { - if (element.kind === CodeActionListItemKind.CodeAction) { - didSelect = true; - element.action.run(); - } - } - - if (didSelect) { - this.hideCodeActionWidget(); - } - } - - private _onListHover(e: IListMouseEvent): void { - if (!e.element) { - this.currSelectedItem = undefined; - this.codeActionList.value?.setFocus([]); - } else { - if (e.element.kind === CodeActionListItemKind.CodeAction && e.element.action.enabled) { - this.codeActionList.value?.setFocus([e.element.index]); - this.focusedEnabledItem = this.viewItems.indexOf(e.element); - this.currSelectedItem = e.element.index; - } else { - this.currSelectedItem = undefined; - this.codeActionList.value?.setFocus([e.element.index]); - } - } - } - - private _onListClick(e: IListMouseEvent): void { - if (e.element && e.element.kind === CodeActionListItemKind.CodeAction && !e.element.action.enabled) { - this.currSelectedItem = undefined; - this.codeActionList.value?.setFocus([]); - } - } - - /** - * Renders the code action widget given the provided actions. - */ - private renderCodeActionMenuList(element: HTMLElement, inputCodeActions: readonly CodeActionAction[], inputDocumentation: readonly Command[], params: ICodeActionMenuParameters): IDisposable { - const renderDisposables = new DisposableStore(); - - const model = this._editor.getModel(); - if (!model) { - return renderDisposables; - } + this.domNode = document.createElement('div'); + this.domNode.classList.add('codeActionList'); - const widget = document.createElement('div'); - widget.classList.add('codeActionWidget'); - element.appendChild(widget); - - // Render invisible div to block mouse interaction in the rest of the UI - const menuBlock = document.createElement('div'); - const block = element.appendChild(menuBlock); - block.classList.add('context-view-block'); - block.style.position = 'fixed'; - block.style.cursor = 'initial'; - block.style.left = '0'; - block.style.top = '0'; - block.style.width = '100%'; - block.style.height = '100%'; - block.style.zIndex = '-1'; - renderDisposables.add(dom.addDisposableListener(block, dom.EventType.MOUSE_DOWN, e => e.stopPropagation())); - - const codeActionList = document.createElement('div'); - codeActionList.classList.add('codeActionList'); - widget.appendChild(codeActionList); - - this.codeActionList.value = new List('codeActionWidget', codeActionList, { - getHeight(element) { - return element.kind === CodeActionListItemKind.Header ? headerLineHeight : codeActionLineHeight; - }, + this.list = this._register(new List('codeActionWidget', this.domNode, { + getHeight: element => element.kind === CodeActionListItemKind.Header ? this.headerLineHeight : this.codeActionLineHeight, getTemplateId: element => element.kind, }, [ - new CodeActionItemRenderer([acceptSelectedCodeActionCommand, previewSelectedCodeActionCommand], this.keybindingService), + new CodeActionItemRenderer(keybindingService), new HeaderRenderer(), ], { keyboardSupport: false, accessibilityProvider: { getAriaLabel: element => { if (element.kind === CodeActionListItemKind.CodeAction) { - let label = element.action.label; - if (!element.action.enabled) { + let label = stripNewlines(element.action.action.title); + if (element.action.action.disabled) { label = localize({ key: 'customCodeActionWidget.labels', comment: ['Code action labels for accessibility.'] }, "{0}, Disabled Reason: {1}", label, element.action.action.disabled); } return label; @@ -349,155 +222,34 @@ export class CodeActionMenu extends Disposable implements IEditorContribution { getRole: () => 'option', getWidgetRole: () => 'code-action-widget' } - }); - - const pointerBlockDiv = document.createElement('div'); - const pointerBlock = element.appendChild(pointerBlockDiv); - pointerBlock.classList.add('context-view-pointerBlock'); - pointerBlock.style.position = 'fixed'; - pointerBlock.style.cursor = 'initial'; - pointerBlock.style.left = '0'; - pointerBlock.style.top = '0'; - pointerBlock.style.width = '100%'; - pointerBlock.style.height = '100%'; - pointerBlock.style.zIndex = '2'; - - // Removes block on click INSIDE widget or ANY mouse movement - renderDisposables.add(dom.addDisposableListener(pointerBlock, dom.EventType.POINTER_MOVE, () => pointerBlock.remove())); - renderDisposables.add(dom.addDisposableListener(pointerBlock, dom.EventType.MOUSE_DOWN, () => pointerBlock.remove())); - - renderDisposables.add(this.codeActionList.value.onMouseClick(e => this._onListClick(e))); - renderDisposables.add(this.codeActionList.value.onMouseOver(e => this._onListHover(e))); - renderDisposables.add(this.codeActionList.value.onDidChangeFocus(() => this.codeActionList.value?.domFocus())); - renderDisposables.add(this.codeActionList.value.onDidChangeSelection(e => this._onListSelection(e))); - renderDisposables.add(this._editor.onDidLayoutChange(() => this.hideCodeActionWidget())); - - let numHeaders = 0; - const allMenuItems: ICodeActionMenuItem[] = []; - - // Checks if headers are disabled. - if (!this.isCodeActionWidgetHeadersShown(model)) { - const items = inputCodeActions.map((item, index): ICodeActionMenuItem => ({ kind: CodeActionListItemKind.CodeAction, action: item, index, params })); - allMenuItems.push(...items); - } else { - // Filters and groups code actions by their group - - // Code Action Groups - const quickfixGroup: CodeActionAction[] = []; - const extractGroup: CodeActionAction[] = []; - const convertGroup: CodeActionAction[] = []; - const surroundGroup: CodeActionAction[] = []; - const sourceGroup: CodeActionAction[] = []; - const otherGroup: CodeActionAction[] = []; - - for (const action of inputCodeActions) { - const kind = action.action.kind ? new CodeActionKind(action.action.kind) : CodeActionKind.None; - if (CodeActionKind.SurroundWith.contains(kind)) { - surroundGroup.push(action); - } else if (CodeActionKind.QuickFix.contains(kind)) { - quickfixGroup.push(action); - } else if (CodeActionKind.Extract.contains(kind)) { - extractGroup.push(action); - } else if (CodeActionKind.Convert.contains(kind)) { - convertGroup.push(action); - } else if (CodeActionKind.Source.contains(kind)) { - sourceGroup.push(action); - } else { - // Pushes all the other actions to the "Other" group - otherGroup.push(action); - } - } + })); - const menuEntries: ReadonlyArray<{ title: string; actions: CodeActionAction[] }> = [ - { title: localize('codeAction.widget.id.quickfix', 'Quick Fix...'), actions: quickfixGroup }, - { title: localize('codeAction.widget.id.extract', 'Extract...'), actions: extractGroup }, - { title: localize('codeAction.widget.id.convert', 'Convert...'), actions: convertGroup }, - { title: localize('codeAction.widget.id.surround', 'Surround With...'), actions: surroundGroup }, - { title: localize('codeAction.widget.id.source', 'Source Action...'), actions: sourceGroup }, - { title: localize('codeAction.widget.id.more', 'More Actions...'), actions: otherGroup }, - ]; - - for (const menuEntry of menuEntries) { - if (menuEntry.actions.length) { - allMenuItems.push({ kind: CodeActionListItemKind.Header, headerTitle: menuEntry.title, index: allMenuItems.length }); - for (const action of menuEntry.actions) { - allMenuItems.push({ kind: CodeActionListItemKind.CodeAction, action, params, index: allMenuItems.length }); - } - numHeaders++; - } - } - } - - this.viewItems = allMenuItems.filter(item => item.kind === CodeActionListItemKind.CodeAction && item.action.enabled) as CodeActionListItemCodeAction[]; - this.codeActionList.value.splice(0, this.codeActionList.value.length, allMenuItems); + this._register(this.list.onMouseClick(e => this.onListClick(e))); + this._register(this.list.onMouseOver(e => this.onListHover(e))); + this._register(this.list.onDidChangeFocus(() => this.list.domFocus())); + this._register(this.list.onDidChangeSelection(e => this.onListSelection(e))); - // Updating list height, depending on how many separators and headers there are. - const height = allMenuItems.length * codeActionLineHeight; - const heightWithHeaders = height + numHeaders * headerLineHeight - numHeaders * codeActionLineHeight; - this.codeActionList.value.layout(heightWithHeaders); + this.allMenuItems = this.toMenuItems(codeActions, showHeaders); + this.viewItems = this.allMenuItems.filter(item => item.kind === CodeActionListItemKind.CodeAction && !item.action.action.disabled) as CodeActionListItemCodeAction[]; + this.list.splice(0, this.list.length, this.allMenuItems); - // List selection - if (this.viewItems.length < 1) { - this.currSelectedItem = undefined; - } else { + if (this.viewItems.length >= 1) { this.focusedEnabledItem = 0; this.currSelectedItem = this.viewItems[0].index; - this.codeActionList.value.setFocus([this.currSelectedItem]); + this.list.setFocus([this.currSelectedItem]); } + } - // List Focus - this.codeActionList.value.domFocus(); - const focusTracker = dom.trackFocus(element); - const blurListener = focusTracker.onDidBlur(() => { - this.hideCodeActionWidget(); - }); - renderDisposables.add(blurListener); - renderDisposables.add(focusTracker); - - // Action bar - let actionBarWidth = 0; - if (!params?.options.fromLightbulb) { - const actions = inputDocumentation.map((doc): IAction => ({ - id: doc.id, - label: doc.title, - tooltip: doc.tooltip ?? '', - class: undefined, - enabled: true, - run: () => this.commandService.executeCommand(doc.id, ...(doc.arguments ?? [])), - })); - - if (params.options.includeDisabledActions && params.codeActions.validActions.length > 0 && params.codeActions.allActions.length !== params.codeActions.validActions.length) { - actions.push(showDisabled ? { - id: 'hideMoreCodeActions', - label: localize('hideMoreCodeActions', 'Hide Disabled'), - enabled: true, - tooltip: '', - class: undefined, - run: () => this.toggleDisabledOptions(params, false) - } : { - id: 'showMoreCodeActions', - label: localize('showMoreCodeActions', 'Show Disabled'), - enabled: true, - tooltip: '', - class: undefined, - run: () => this.toggleDisabledOptions(params, true) - }); - } - - if (actions.length) { - const actionbarContainer = dom.append(widget, dom.$('.codeActionWidget-action-bar')); - const actionbar = renderDisposables.add(new ActionBar(actionbarContainer)); - actionbar.push(actions, { icon: false, label: true }); - actionBarWidth = actionbarContainer.offsetWidth; - } - } + public layout(minWidth: number): number { + // Updating list height, depending on how many separators and headers there are. + const numHeaders = this.allMenuItems.filter(item => item.kind === CodeActionListItemKind.Header).length; + const height = this.allMenuItems.length * this.codeActionLineHeight; + const heightWithHeaders = height + numHeaders * this.headerLineHeight - numHeaders * this.codeActionLineHeight; + this.list.layout(heightWithHeaders); // For finding width dynamically (not using resize observer) - const itemWidths: number[] = allMenuItems.map((_, index): number => { - if (!this.codeActionList.value) { - return 0; - } - const element = document.getElementById(this.codeActionList.value?.getElementID(index)); + const itemWidths: number[] = this.allMenuItems.map((_, index): number => { + const element = document.getElementById(this.list.getElementID(index)); if (element) { const textPadding = 10; const iconPadding = 10; @@ -507,20 +259,15 @@ export class CodeActionMenu extends Disposable implements IEditorContribution { }); // resize observer - can be used in the future since list widget supports dynamic height but not width - const width = Math.max(...itemWidths, actionBarWidth); - widget.style.width = width + 'px'; - this.codeActionList.value?.layout(heightWithHeaders, width); - - codeActionList.style.height = `${heightWithHeaders}px`; + const width = Math.max(...itemWidths, minWidth); + this.list.layout(heightWithHeaders, width); - this._ctxMenuWidgetVisible.set(true); + this.domNode.style.height = `${heightWithHeaders}px`; - return renderDisposables; + this.list.domFocus(); + return width; } - /** - * Focuses on the previous item in the list using the list widget. - */ public focusPrevious() { if (typeof this.focusedEnabledItem === 'undefined') { this.focusedEnabledItem = this.viewItems[0].index; @@ -537,14 +284,11 @@ export class CodeActionMenu extends Disposable implements IEditorContribution { this.focusedEnabledItem = this.viewItems.length - 1; } item = this.viewItems[this.focusedEnabledItem]; - this.codeActionList.value?.setFocus([item.index]); + this.list.setFocus([item.index]); this.currSelectedItem = item.index; - } while (this.focusedEnabledItem !== startIndex && !item.action.enabled); + } while (this.focusedEnabledItem !== startIndex && item.action.action.disabled); } - /** - * Focuses on the next item in the list using the list widget. - */ public focusNext() { if (typeof this.focusedEnabledItem === 'undefined') { this.focusedEnabledItem = this.viewItems.length - 1; @@ -558,26 +302,280 @@ export class CodeActionMenu extends Disposable implements IEditorContribution { do { this.focusedEnabledItem = (this.focusedEnabledItem + 1) % this.viewItems.length; item = this.viewItems[this.focusedEnabledItem]; - this.codeActionList.value?.setFocus([item.index]); + this.list.setFocus([item.index]); this.currSelectedItem = item.index; - } while (this.focusedEnabledItem !== startIndex && !item.action.enabled); + } while (this.focusedEnabledItem !== startIndex && item.action.action.disabled); } public onEnterSet() { if (typeof this.currSelectedItem === 'number') { - this.codeActionList.value?.setSelection([this.currSelectedItem]); + this.list.setSelection([this.currSelectedItem]); + } + } + + private onListSelection(e: IListEvent): void { + for (const element of e.elements) { + if (element.kind === CodeActionListItemKind.CodeAction && !element.action.action.disabled) { + this.onDidSelect(element.action); + } + } + } + + private onListHover(e: IListMouseEvent): void { + if (!e.element) { + this.currSelectedItem = undefined; + this.list.setFocus([]); + } else { + if (e.element.kind === CodeActionListItemKind.CodeAction && !e.element.action.action.disabled) { + this.list.setFocus([e.element.index]); + this.focusedEnabledItem = this.viewItems.indexOf(e.element); + this.currSelectedItem = e.element.index; + } else { + this.currSelectedItem = undefined; + this.list.setFocus([e.element.index]); + } + } + } + + private onListClick(e: IListMouseEvent): void { + if (e.element && e.element.kind === CodeActionListItemKind.CodeAction && e.element.action.action.disabled) { + this.currSelectedItem = undefined; + this.list.setFocus([]); + } + } + + private toMenuItems(inputCodeActions: readonly CodeActionItem[], showHeaders: boolean): ICodeActionMenuItem[] { + if (!showHeaders) { + return inputCodeActions.map((action, index): ICodeActionMenuItem => ({ kind: CodeActionListItemKind.CodeAction, action, index })); + } + + // Groups code actions by their kind + const quickfixGroup: CodeActionItem[] = []; + const extractGroup: CodeActionItem[] = []; + const convertGroup: CodeActionItem[] = []; + const surroundGroup: CodeActionItem[] = []; + const sourceGroup: CodeActionItem[] = []; + const otherGroup: CodeActionItem[] = []; + + for (const action of inputCodeActions) { + const kind = action.action.kind ? new CodeActionKind(action.action.kind) : CodeActionKind.None; + if (CodeActionKind.SurroundWith.contains(kind)) { + surroundGroup.push(action); + } else if (CodeActionKind.QuickFix.contains(kind)) { + quickfixGroup.push(action); + } else if (CodeActionKind.Extract.contains(kind)) { + extractGroup.push(action); + } else if (CodeActionKind.Convert.contains(kind)) { + convertGroup.push(action); + } else if (CodeActionKind.Source.contains(kind)) { + sourceGroup.push(action); + } else { + otherGroup.push(action); + } + } + + const menuEntries: ReadonlyArray<{ title: string; actions: CodeActionItem[] }> = [ + { title: localize('codeAction.widget.id.quickfix', 'Quick Fix...'), actions: quickfixGroup }, + { title: localize('codeAction.widget.id.extract', 'Extract...'), actions: extractGroup }, + { title: localize('codeAction.widget.id.convert', 'Convert...'), actions: convertGroup }, + { title: localize('codeAction.widget.id.surround', 'Surround With...'), actions: surroundGroup }, + { title: localize('codeAction.widget.id.source', 'Source Action...'), actions: sourceGroup }, + { title: localize('codeAction.widget.id.more', 'More Actions...'), actions: otherGroup }, + ]; + + const allMenuItems: ICodeActionMenuItem[] = []; + for (const menuEntry of menuEntries) { + if (menuEntry.actions.length) { + allMenuItems.push({ kind: CodeActionListItemKind.Header, headerTitle: menuEntry.title, index: allMenuItems.length }); + for (const action of menuEntry.actions) { + allMenuItems.push({ kind: CodeActionListItemKind.CodeAction, action, index: allMenuItems.length }); + } + } + } + + return allMenuItems; + } +} + +// TODO: Take a look at user storage for this so it is preserved across windows and on reload. +let showDisabled = false; + +export class CodeActionMenu extends Disposable implements IEditorContribution { + + public static readonly ID: string = 'editor.contrib.codeActionMenu'; + + public static get(editor: ICodeEditor): CodeActionMenu | null { + return editor.getContribution(CodeActionMenu.ID); + } + + private readonly codeActionList = this._register(new MutableDisposable()); + + private currentShowingContext?: { + readonly options: CodeActionShowOptions; + readonly trigger: CodeActionTrigger; + readonly anchor: IAnchor; + readonly codeActions: CodeActionSet; + }; + private _ctxMenuWidgetVisible: IContextKey; + + constructor( + private readonly _editor: ICodeEditor, + private readonly _delegate: CodeActionWidgetDelegate, + @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IContextViewService private readonly _contextViewService: IContextViewService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + + this._ctxMenuWidgetVisible = Context.Visible.bindTo(this._contextKeyService); + } + + get isVisible(): boolean { + return !!this.currentShowingContext; + } + + public async show(trigger: CodeActionTrigger, codeActions: CodeActionSet, anchor: IAnchor, options: CodeActionShowOptions): Promise { + this.currentShowingContext = undefined; + if (!this._editor.hasModel() || !this._editor.getDomNode()) { + return; } + + const actionsToShow = options.includeDisabledActions && (showDisabled || codeActions.validActions.length === 0) ? codeActions.allActions : codeActions.validActions; + if (!actionsToShow.length) { + return; + } + + this.currentShowingContext = { trigger, codeActions, anchor, options }; + + this._contextViewService.showContextView({ + getAnchor: () => anchor, + render: (container: HTMLElement) => this.renderWidget(container, trigger, codeActions, options, actionsToShow), + onHide: (didCancel: boolean) => this.onWidgetClosed(trigger, options, codeActions, didCancel), + }, this._editor.getDomNode()!, false); + } + + /** + * Focuses on the previous item in the list using the list widget. + */ + public focusPrevious() { + this.codeActionList.value?.focusPrevious(); + } + + /** + * Focuses on the next item in the list using the list widget. + */ + public focusNext() { + this.codeActionList.value?.focusNext(); + } + + public onEnterSet() { + this.codeActionList.value?.onEnterSet(); } public hideCodeActionWidget() { this._ctxMenuWidgetVisible.reset(); - this.viewItems = []; - this.focusedEnabledItem = 0; - this.currSelectedItem = undefined; + this.codeActionList.clear(); this._contextViewService.hideContextView(); } - private codeActionTelemetry(openedFromString: CodeActionTriggerSource, didCancel: boolean, CodeActions: CodeActionSet) { + private shouldShowHeaders(): boolean { + const model = this._editor.getModel(); + return this._configurationService.getValue('editor.codeActionWidget.showHeaders', { resource: model?.uri }); + } + + private renderWidget(element: HTMLElement, trigger: CodeActionTrigger, codeActions: CodeActionSet, options: CodeActionShowOptions, showingCodeActions: readonly CodeActionItem[]): IDisposable { + const renderDisposables = new DisposableStore(); + + const widget = document.createElement('div'); + widget.classList.add('codeActionWidget'); + element.appendChild(widget); + + this.codeActionList.value = new CodeActionList( + showingCodeActions, + this.shouldShowHeaders(), + action => { + this.hideCodeActionWidget(); + this._delegate.onSelectCodeAction(action, trigger); + }, + this._keybindingService); + + widget.appendChild(this.codeActionList.value.domNode); + + // Invisible div to block mouse interaction in the rest of the UI + const menuBlock = document.createElement('div'); + const block = element.appendChild(menuBlock); + block.classList.add('context-view-block'); + block.style.position = 'fixed'; + block.style.cursor = 'initial'; + block.style.left = '0'; + block.style.top = '0'; + block.style.width = '100%'; + block.style.height = '100%'; + block.style.zIndex = '-1'; + renderDisposables.add(dom.addDisposableListener(block, dom.EventType.MOUSE_DOWN, e => e.stopPropagation())); + + // Invisible div to block mouse interaction with the menu + const pointerBlockDiv = document.createElement('div'); + const pointerBlock = element.appendChild(pointerBlockDiv); + pointerBlock.classList.add('context-view-pointerBlock'); + pointerBlock.style.position = 'fixed'; + pointerBlock.style.cursor = 'initial'; + pointerBlock.style.left = '0'; + pointerBlock.style.top = '0'; + pointerBlock.style.width = '100%'; + pointerBlock.style.height = '100%'; + pointerBlock.style.zIndex = '2'; + + // Removes block on click INSIDE widget or ANY mouse movement + renderDisposables.add(dom.addDisposableListener(pointerBlock, dom.EventType.POINTER_MOVE, () => pointerBlock.remove())); + renderDisposables.add(dom.addDisposableListener(pointerBlock, dom.EventType.MOUSE_DOWN, () => pointerBlock.remove())); + + // Action bar + let actionBarWidth = 0; + if (!options.fromLightbulb) { + const actionBar = this.createActionBar(trigger, showingCodeActions, codeActions, options); + if (actionBar) { + widget.appendChild(actionBar.getContainer().parentElement!); + renderDisposables.add(actionBar); + actionBarWidth = actionBar.getContainer().offsetWidth; + } + } + + const width = this.codeActionList.value.layout(actionBarWidth); + widget.style.width = `${width}px`; + + renderDisposables.add(this._editor.onDidLayoutChange(() => this.hideCodeActionWidget())); + + const focusTracker = renderDisposables.add(dom.trackFocus(element)); + renderDisposables.add(focusTracker.onDidBlur(() => { + this.hideCodeActionWidget(); + })); + + this._ctxMenuWidgetVisible.set(true); + + return renderDisposables; + } + + /** + * Toggles whether the disabled actions in the code action widget are visible or not. + */ + private toggleShowDisabled(newShowDisabled: boolean): void { + const previouslyShowingActions = this.currentShowingContext; + + this.hideCodeActionWidget(); + + showDisabled = newShowDisabled; + + if (previouslyShowingActions) { + this.show(previouslyShowingActions.trigger, previouslyShowingActions.codeActions, previouslyShowingActions.anchor, previouslyShowingActions.options); + } + } + + private onWidgetClosed(trigger: CodeActionTrigger, options: CodeActionShowOptions, codeActions: CodeActionSet, didCancel: boolean): void { type ApplyCodeActionEvent = { codeActionFrom: CodeActionTriggerSource; validCodeActions: number; @@ -593,87 +591,55 @@ export class CodeActionMenu extends Disposable implements IEditorContribution { }; this._telemetryService.publicLog2('codeAction.applyCodeAction', { - codeActionFrom: openedFromString, - validCodeActions: CodeActions.validActions.length, + codeActionFrom: options.fromLightbulb ? CodeActionTriggerSource.Lightbulb : trigger.triggerAction, + validCodeActions: codeActions.validActions.length, cancelled: didCancel, }); - } - /** - * Helper function to create a context view item using code action `params`. - */ - private showContextViewHelper(params: ICodeActionMenuParameters, menuActions: readonly CodeActionAction[], documentation: readonly Command[]) { - this._contextViewService.showContextView({ - getAnchor: () => params.anchor, - render: (container: HTMLElement) => this.renderCodeActionMenuList(container, menuActions, documentation, params), - onHide: (didCancel: boolean) => { - const openedFromString = (params.options.fromLightbulb) ? CodeActionTriggerSource.Lightbulb : params.trigger.triggerAction; - this.codeActionTelemetry(openedFromString, didCancel, params.codeActions); - this._visible = false; - this._editor.focus(); - }, - }, - this._editor.getDomNode()!, false, - ); + this.currentShowingContext = undefined; + this._editor.focus(); } - /** - * Toggles whether the disabled actions in the code action widget are visible or not. - */ - public toggleDisabledOptions(params: ICodeActionMenuParameters, newShowDisabled: boolean): void { - this.hideCodeActionWidget(); - - showDisabled = newShowDisabled; - - const actionsToShow = showDisabled ? params.codeActions.allActions : params.codeActions.validActions; - - const menuActions = this.getMenuActions(params.trigger, actionsToShow); - const documentationActions = this.getDocumentation(params.trigger, actionsToShow, params.codeActions.documentation); + private createActionBar(trigger: CodeActionTrigger, inputCodeActions: readonly CodeActionItem[], codeActions: CodeActionSet, options: CodeActionShowOptions): ActionBar | undefined { + const actions = this.getActionBarActions(trigger, inputCodeActions, codeActions, options); + if (!actions.length) { + return undefined; + } - this.showContextViewHelper(params, menuActions, documentationActions); + const container = dom.$('.codeActionWidget-action-bar'); + const actionBar = new ActionBar(container); + actionBar.push(actions, { icon: false, label: true }); + return actionBar; } - public async show(trigger: CodeActionTrigger, codeActions: CodeActionSet, at: IAnchor | IPosition, options: CodeActionShowOptions): Promise { - const model = this._editor.getModel(); - if (!model) { - return; - } - - const actionsToShow = options.includeDisabledActions ? codeActions.allActions : codeActions.validActions; - if (!actionsToShow.length) { - this._visible = false; - return; - } + private getActionBarActions(trigger: CodeActionTrigger, inputCodeActions: readonly CodeActionItem[], codeActions: CodeActionSet, options: CodeActionShowOptions): IAction[] { + const actions = this.getDocumentationActions(trigger, inputCodeActions, codeActions.documentation); - if (!this._editor.getDomNode()) { - // cancel when editor went off-dom - this._visible = false; - throw canceled(); + if (options.includeDisabledActions && codeActions.validActions.length > 0 && codeActions.allActions.length !== codeActions.validActions.length) { + actions.push(showDisabled ? { + id: 'hideMoreCodeActions', + label: localize('hideMoreCodeActions', 'Hide Disabled'), + enabled: true, + tooltip: '', + class: undefined, + run: () => this.toggleShowDisabled(false) + } : { + id: 'showMoreCodeActions', + label: localize('showMoreCodeActions', 'Show Disabled'), + enabled: true, + tooltip: '', + class: undefined, + run: () => this.toggleShowDisabled(true) + }); } - - this._visible = true; - this._showingActions.value = codeActions; - - const menuActions = this.getMenuActions(trigger, actionsToShow); - const documentationActions = this.getDocumentation(trigger, actionsToShow, codeActions.documentation); - const anchor = Position.isIPosition(at) ? this._toCoords(at) : at; - - const params: ICodeActionMenuParameters = { options, trigger, codeActions, anchor, menuActions, documentationActions, showDisabled, visible: this._visible }; - this.showContextViewHelper(params, menuActions, documentationActions); - } - - private getMenuActions( - trigger: CodeActionTrigger, - actionsToShow: readonly CodeActionItem[] - ): CodeActionAction[] { - return actionsToShow.map(item => new CodeActionAction(item.action, () => this._delegate.onSelectCodeAction(item, trigger))); + return actions; } - private getDocumentation( + private getDocumentationActions( trigger: CodeActionTrigger, actionsToShow: readonly CodeActionItem[], documentation: readonly Command[], - ): Command[] { + ): IAction[] { const allDocumentation: Command[] = [...documentation]; const model = this._editor.getModel(); @@ -685,23 +651,14 @@ export class CodeActionMenu extends Disposable implements IEditorContribution { } } - return allDocumentation; - } - - private _toCoords(position: IPosition): { x: number; y: number } { - if (!this._editor.hasModel()) { - return { x: 0, y: 0 }; - } - this._editor.revealPosition(position, ScrollType.Immediate); - this._editor.render(); - - // Translate to absolute editor position - const cursorCoords = this._editor.getScrolledVisiblePosition(position); - const editorCoords = dom.getDomNodePagePosition(this._editor.getDomNode()); - const x = editorCoords.left + cursorCoords.left; - const y = editorCoords.top + cursorCoords.top + cursorCoords.height; - - return { x, y }; + return allDocumentation.map((command): IAction => ({ + id: command.id, + label: command.title, + tooltip: command.tooltip ?? '', + class: undefined, + enabled: true, + run: () => this._commandService.executeCommand(command.id, ...(command.arguments ?? [])), + })); } } diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionUi.ts b/src/vs/editor/contrib/codeAction/browser/codeActionUi.ts index b0d995eb59bde..5d69261987007 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionUi.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionUi.ts @@ -3,12 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { getDomNodePagePosition } from 'vs/base/browser/dom'; import { IAnchor } from 'vs/base/browser/ui/contextview/contextview'; import { onUnexpectedError } from 'vs/base/common/errors'; import { Lazy } from 'vs/base/common/lazy'; import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { IPosition } from 'vs/editor/common/core/position'; +import { IPosition, Position } from 'vs/editor/common/core/position'; +import { ScrollType } from 'vs/editor/common/editorCommon'; import { CodeActionTriggerType } from 'vs/editor/common/languages'; import { CodeActionItem, CodeActionSet } from 'vs/editor/contrib/codeAction/browser/codeAction'; import { MessageController } from 'vs/editor/contrib/message/browser/messageController'; @@ -61,7 +63,6 @@ export class CodeActionUi extends Disposable { override dispose() { this.#disposed = true; super.dispose(); - } public hideCodeActionWidget() { @@ -142,7 +143,7 @@ export class CodeActionUi extends Disposable { } this._activeCodeActions.value = actions; - this._codeActionWidget.getValue().show(newState.trigger, actions, newState.position, { includeDisabledActions, fromLightbulb: false }); + this._codeActionWidget.getValue().show(newState.trigger, actions, this.toCoords(newState.position), { includeDisabledActions, fromLightbulb: false }); } else { // auto magically triggered if (this._codeActionWidget.getValue().isVisible) { @@ -183,6 +184,24 @@ export class CodeActionUi extends Disposable { } public async showCodeActionList(trigger: CodeActionTrigger, actions: CodeActionSet, at: IAnchor | IPosition, options: CodeActionShowOptions): Promise { - this._codeActionWidget.getValue().show(trigger, actions, at, options); + const anchor = Position.isIPosition(at) ? this.toCoords(at) : at; + this._codeActionWidget.getValue().show(trigger, actions, anchor, options); + } + + private toCoords(position: IPosition): IAnchor { + if (!this._editor.hasModel()) { + return { x: 0, y: 0 }; + } + + this._editor.revealPosition(position, ScrollType.Immediate); + this._editor.render(); + + // Translate to absolute editor position + const cursorCoords = this._editor.getScrolledVisiblePosition(position); + const editorCoords = getDomNodePagePosition(this._editor.getDomNode()); + const x = editorCoords.left + cursorCoords.left; + const y = editorCoords.top + cursorCoords.top + cursorCoords.height; + + return { x, y }; } }