/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * 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 { Action, IAction, Separator } from 'vs/base/common/actions'; import { canceled } from 'vs/base/common/errors'; import { ResolvedKeybinding } from 'vs/base/common/keyCodes'; import { Lazy } from 'vs/base/common/lazy'; import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IPosition, Position } from 'vs/editor/common/core/position'; import { ScrollType } from 'vs/editor/common/editorCommon'; import { CodeAction, CodeActionProviderRegistry, Command } from 'vs/editor/common/modes'; import { codeActionCommandId, CodeActionItem, CodeActionSet, fixAllCommandId, organizeImportsCommandId, refactorCommandId, sourceActionCommandId } from 'vs/editor/contrib/codeAction/codeAction'; import { CodeActionAutoApply, CodeActionCommandArgs, CodeActionTrigger, CodeActionKind } from 'vs/editor/contrib/codeAction/types'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ResolvedKeybindingItem } from 'vs/platform/keybinding/common/resolvedKeybindingItem'; interface CodeActionWidgetDelegate { onSelectCodeAction: (action: CodeActionItem) => Promise; } interface ResolveCodeActionKeybinding { readonly kind: CodeActionKind; readonly preferred: boolean; 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, ' '); } export interface CodeActionShowOptions { readonly includeDisabledActions: boolean; } export class CodeActionMenu extends Disposable { private _visible: boolean = false; private readonly _showingActions = this._register(new MutableDisposable()); private readonly _keybindingResolver: CodeActionKeybindingResolver; constructor( private readonly _editor: ICodeEditor, private readonly _delegate: CodeActionWidgetDelegate, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IKeybindingService keybindingService: IKeybindingService, ) { super(); this._keybindingResolver = new CodeActionKeybindingResolver({ getKeybindings: () => keybindingService.getKeybindings() }); } get isVisible(): boolean { return this._visible; } public async show(trigger: CodeActionTrigger, codeActions: CodeActionSet, at: IAnchor | IPosition, options: CodeActionShowOptions): Promise { const actionsToShow = options.includeDisabledActions ? codeActions.allActions : codeActions.validActions; if (!actionsToShow.length) { this._visible = false; return; } if (!this._editor.getDomNode()) { // cancel when editor went off-dom this._visible = false; throw canceled(); } this._visible = true; this._showingActions.value = codeActions; const menuActions = this.getMenuActions(trigger, actionsToShow, codeActions.documentation); const anchor = Position.isIPosition(at) ? this._toCoords(at) : at || { x: 0, y: 0 }; const resolver = this._keybindingResolver.getResolver(); const useShadowDOM = this._editor.getOption(EditorOption.useShadowDOM); this._contextMenuService.showContextMenu({ domForShadowRoot: useShadowDOM ? this._editor.getDomNode()! : undefined, getAnchor: () => anchor, getActions: () => menuActions, onHide: () => { this._visible = false; this._editor.focus(); }, autoSelectFirstItem: true, getKeyBinding: action => action instanceof CodeActionAction ? resolver(action.action) : undefined, }); } private getMenuActions( trigger: CodeActionTrigger, actionsToShow: readonly CodeActionItem[], documentation: readonly Command[] ): IAction[] { const toCodeActionAction = (item: CodeActionItem): CodeActionAction => new CodeActionAction(item.action, () => this._delegate.onSelectCodeAction(item)); const result: IAction[] = actionsToShow .map(toCodeActionAction); const allDocumentation: Command[] = [...documentation]; const model = this._editor.getModel(); if (model && result.length) { for (const provider of CodeActionProviderRegistry.all(model)) { if (provider._getAdditionalMenuItems) { allDocumentation.push(...provider._getAdditionalMenuItems({ trigger: trigger.type, only: trigger.filter?.include?.value }, actionsToShow.map(item => item.action))); } } } if (allDocumentation.length) { result.push(new Separator(), ...allDocumentation.map(command => toCodeActionAction(new CodeActionItem({ title: command.title, command: command, }, undefined)))); } return result; } 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 = getDomNodePagePosition(this._editor.getDomNode()); const x = editorCoords.left + cursorCoords.left; const y = editorCoords.top + cursorCoords.top + cursorCoords.height; return { x, y }; } } export class CodeActionKeybindingResolver { private static readonly codeActionCommands: readonly string[] = [ refactorCommandId, codeActionCommandId, sourceActionCommandId, organizeImportsCommandId, fixAllCommandId ]; constructor( private readonly _keybindingProvider: { getKeybindings(): readonly ResolvedKeybindingItem[], }, ) { } public getResolver(): (action: CodeAction) => ResolvedKeybinding | undefined { // Lazy since we may not actually ever read the value const allCodeActionBindings = new Lazy(() => this._keybindingProvider.getKeybindings() .filter(item => CodeActionKeybindingResolver.codeActionCommands.indexOf(item.command!) >= 0) .filter(item => item.resolvedKeybinding) .map((item): ResolveCodeActionKeybinding => { // Special case these commands since they come built-in with VS Code and don't use 'commandArgs' let commandArgs = item.commandArgs; if (item.command === organizeImportsCommandId) { commandArgs = { kind: CodeActionKind.SourceOrganizeImports.value }; } else if (item.command === fixAllCommandId) { commandArgs = { kind: CodeActionKind.SourceFixAll.value }; } return { resolvedKeybinding: item.resolvedKeybinding!, ...CodeActionCommandArgs.fromUser(commandArgs, { kind: CodeActionKind.None, apply: CodeActionAutoApply.Never }) }; })); return (action) => { if (action.kind) { const binding = this.bestKeybindingForCodeAction(action, allCodeActionBindings.getValue()); return binding?.resolvedKeybinding; } return undefined; }; } private bestKeybindingForCodeAction( action: CodeAction, candidates: readonly ResolveCodeActionKeybinding[], ): ResolveCodeActionKeybinding | undefined { if (!action.kind) { return undefined; } const kind = new CodeActionKind(action.kind); return candidates .filter(candidate => candidate.kind.contains(kind)) .filter(candidate => { if (candidate.preferred) { // If the candidate keybinding only applies to preferred actions, the this action must also be preferred return action.isPreferred; } return true; }) .reduceRight((currentBest, candidate) => { if (!currentBest) { return candidate; } // Select the more specific binding return currentBest.kind.contains(candidate.kind) ? candidate : currentBest; }, undefined as ResolveCodeActionKeybinding | undefined); } }