Path: blob/main/src/vs/platform/actions/browser/menuEntryActionViewItem.ts
3294 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 { asCSSUrl } from '../../../base/browser/cssValue.js';6import { $, addDisposableListener, append, EventType, ModifierKeyEmitter, prepend } from '../../../base/browser/dom.js';7import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js';8import { ActionViewItem, BaseActionViewItem, SelectActionViewItem } from '../../../base/browser/ui/actionbar/actionViewItems.js';9import { DropdownMenuActionViewItem, IDropdownMenuActionViewItemOptions } from '../../../base/browser/ui/dropdown/dropdownActionViewItem.js';10import { IHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegate.js';11import { ActionRunner, IAction, IRunEvent, Separator, SubmenuAction } from '../../../base/common/actions.js';12import { Event } from '../../../base/common/event.js';13import { UILabelProvider } from '../../../base/common/keybindingLabels.js';14import { ResolvedKeybinding } from '../../../base/common/keybindings.js';15import { KeyCode } from '../../../base/common/keyCodes.js';16import { combinedDisposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';17import { isLinux, isWindows, OS } from '../../../base/common/platform.js';18import { ThemeIcon } from '../../../base/common/themables.js';19import { assertType } from '../../../base/common/types.js';20import { localize } from '../../../nls.js';21import { IAccessibilityService } from '../../accessibility/common/accessibility.js';22import { ICommandAction, isICommandActionToggleInfo } from '../../action/common/action.js';23import { IConfigurationService } from '../../configuration/common/configuration.js';24import { IContextKeyService } from '../../contextkey/common/contextkey.js';25import { IContextMenuService, IContextViewService } from '../../contextview/browser/contextView.js';26import { IInstantiationService } from '../../instantiation/common/instantiation.js';27import { IKeybindingService } from '../../keybinding/common/keybinding.js';28import { INotificationService } from '../../notification/common/notification.js';29import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';30import { defaultSelectBoxStyles } from '../../theme/browser/defaultStyles.js';31import { asCssVariable, selectBorder } from '../../theme/common/colorRegistry.js';32import { isDark } from '../../theme/common/theme.js';33import { IThemeService } from '../../theme/common/themeService.js';34import { hasNativeContextMenu } from '../../window/common/window.js';35import { IMenuService, MenuItemAction, SubmenuItemAction } from '../common/actions.js';36import './menuEntryActionViewItem.css';3738export interface PrimaryAndSecondaryActions {39primary: IAction[];40secondary: IAction[];41}4243export function getContextMenuActions(44groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,45primaryGroup?: string46): PrimaryAndSecondaryActions {47const target: PrimaryAndSecondaryActions = { primary: [], secondary: [] };48getContextMenuActionsImpl(groups, target, primaryGroup);49return target;50}5152export function getFlatContextMenuActions(53groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,54primaryGroup?: string55): IAction[] {56const target: IAction[] = [];57getContextMenuActionsImpl(groups, target, primaryGroup);58return target;59}6061function getContextMenuActionsImpl(62groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,63target: IAction[] | PrimaryAndSecondaryActions,64primaryGroup?: string65) {66const modifierKeyEmitter = ModifierKeyEmitter.getInstance();67const useAlternativeActions = modifierKeyEmitter.keyStatus.altKey || ((isWindows || isLinux) && modifierKeyEmitter.keyStatus.shiftKey);68fillInActions(groups, target, useAlternativeActions, primaryGroup ? actionGroup => actionGroup === primaryGroup : actionGroup => actionGroup === 'navigation');69}707172export function getActionBarActions(73groups: [string, Array<MenuItemAction | SubmenuItemAction>][],74primaryGroup?: string | ((actionGroup: string) => boolean),75shouldInlineSubmenu?: (action: SubmenuAction, group: string, groupSize: number) => boolean,76useSeparatorsInPrimaryActions?: boolean77): PrimaryAndSecondaryActions {78const target: PrimaryAndSecondaryActions = { primary: [], secondary: [] };79fillInActionBarActions(groups, target, primaryGroup, shouldInlineSubmenu, useSeparatorsInPrimaryActions);80return target;81}8283export function getFlatActionBarActions(84groups: [string, Array<MenuItemAction | SubmenuItemAction>][],85primaryGroup?: string | ((actionGroup: string) => boolean),86shouldInlineSubmenu?: (action: SubmenuAction, group: string, groupSize: number) => boolean,87useSeparatorsInPrimaryActions?: boolean88): IAction[] {89const target: IAction[] = [];90fillInActionBarActions(groups, target, primaryGroup, shouldInlineSubmenu, useSeparatorsInPrimaryActions);91return target;92}9394export function fillInActionBarActions(95groups: [string, Array<MenuItemAction | SubmenuItemAction>][],96target: IAction[] | PrimaryAndSecondaryActions,97primaryGroup?: string | ((actionGroup: string) => boolean),98shouldInlineSubmenu?: (action: SubmenuAction, group: string, groupSize: number) => boolean,99useSeparatorsInPrimaryActions?: boolean100): void {101const isPrimaryAction = typeof primaryGroup === 'string' ? (actionGroup: string) => actionGroup === primaryGroup : primaryGroup;102103// Action bars handle alternative actions on their own so the alternative actions should be ignored104fillInActions(groups, target, false, isPrimaryAction, shouldInlineSubmenu, useSeparatorsInPrimaryActions);105}106107function fillInActions(108groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,109target: IAction[] | PrimaryAndSecondaryActions,110useAlternativeActions: boolean,111isPrimaryAction: (actionGroup: string) => boolean = actionGroup => actionGroup === 'navigation',112shouldInlineSubmenu: (action: SubmenuAction, group: string, groupSize: number) => boolean = () => false,113useSeparatorsInPrimaryActions: boolean = false114): void {115116let primaryBucket: IAction[];117let secondaryBucket: IAction[];118if (Array.isArray(target)) {119primaryBucket = target;120secondaryBucket = target;121} else {122primaryBucket = target.primary;123secondaryBucket = target.secondary;124}125126const submenuInfo = new Set<{ group: string; action: SubmenuAction; index: number }>();127128for (const [group, actions] of groups) {129130let target: IAction[];131if (isPrimaryAction(group)) {132target = primaryBucket;133if (target.length > 0 && useSeparatorsInPrimaryActions) {134target.push(new Separator());135}136} else {137target = secondaryBucket;138if (target.length > 0) {139target.push(new Separator());140}141}142143for (let action of actions) {144if (useAlternativeActions) {145action = action instanceof MenuItemAction && action.alt ? action.alt : action;146}147const newLen = target.push(action);148// keep submenu info for later inlining149if (action instanceof SubmenuAction) {150submenuInfo.add({ group, action, index: newLen - 1 });151}152}153}154155// ask the outside if submenu should be inlined or not. only ask when156// there would be enough space157for (const { group, action, index } of submenuInfo) {158const target = isPrimaryAction(group) ? primaryBucket : secondaryBucket;159160// inlining submenus with length 0 or 1 is easy,161// larger submenus need to be checked with the overall limit162const submenuActions = action.actions;163if (shouldInlineSubmenu(action, group, target.length)) {164target.splice(index, 1, ...submenuActions);165}166}167}168169export interface IMenuEntryActionViewItemOptions {170readonly draggable?: boolean;171readonly keybinding?: string | null;172readonly hoverDelegate?: IHoverDelegate;173readonly keybindingNotRenderedWithLabel?: boolean;174}175176export class MenuEntryActionViewItem<T extends IMenuEntryActionViewItemOptions = IMenuEntryActionViewItemOptions> extends ActionViewItem {177178private _wantsAltCommand: boolean = false;179private readonly _itemClassDispose = this._register(new MutableDisposable());180private readonly _altKey: ModifierKeyEmitter;181182constructor(183action: MenuItemAction,184protected readonly _options: T | undefined,185@IKeybindingService protected readonly _keybindingService: IKeybindingService,186@INotificationService protected readonly _notificationService: INotificationService,187@IContextKeyService protected readonly _contextKeyService: IContextKeyService,188@IThemeService protected readonly _themeService: IThemeService,189@IContextMenuService protected readonly _contextMenuService: IContextMenuService,190@IAccessibilityService private readonly _accessibilityService: IAccessibilityService191) {192super(undefined, action, { icon: !!(action.class || action.item.icon), label: !action.class && !action.item.icon, draggable: _options?.draggable, keybinding: _options?.keybinding, hoverDelegate: _options?.hoverDelegate, keybindingNotRenderedWithLabel: _options?.keybindingNotRenderedWithLabel });193this._altKey = ModifierKeyEmitter.getInstance();194}195196protected get _menuItemAction(): MenuItemAction {197return <MenuItemAction>this._action;198}199200protected get _commandAction(): MenuItemAction {201return this._wantsAltCommand && this._menuItemAction.alt || this._menuItemAction;202}203204override async onClick(event: MouseEvent): Promise<void> {205event.preventDefault();206event.stopPropagation();207208try {209await this.actionRunner.run(this._commandAction, this._context);210} catch (err) {211this._notificationService.error(err);212}213}214215override render(container: HTMLElement): void {216super.render(container);217container.classList.add('menu-entry');218219if (this.options.icon) {220this._updateItemClass(this._menuItemAction.item);221}222223if (this._menuItemAction.alt) {224let isMouseOver = false;225226const updateAltState = () => {227const wantsAltCommand = !!this._menuItemAction.alt?.enabled &&228(!this._accessibilityService.isMotionReduced() || isMouseOver) && (229this._altKey.keyStatus.altKey ||230(this._altKey.keyStatus.shiftKey && isMouseOver)231);232233if (wantsAltCommand !== this._wantsAltCommand) {234this._wantsAltCommand = wantsAltCommand;235this.updateLabel();236this.updateTooltip();237this.updateClass();238}239};240241this._register(this._altKey.event(updateAltState));242243this._register(addDisposableListener(container, 'mouseleave', _ => {244isMouseOver = false;245updateAltState();246}));247248this._register(addDisposableListener(container, 'mouseenter', _ => {249isMouseOver = true;250updateAltState();251}));252253updateAltState();254}255}256257protected override updateLabel(): void {258if (this.options.label && this.label) {259this.label.textContent = this._commandAction.label;260}261}262263protected override getTooltip() {264const keybinding = this._keybindingService.lookupKeybinding(this._commandAction.id, this._contextKeyService);265const keybindingLabel = keybinding && keybinding.getLabel();266267const tooltip = this._commandAction.tooltip || this._commandAction.label;268let title = keybindingLabel269? localize('titleAndKb', "{0} ({1})", tooltip, keybindingLabel)270: tooltip;271if (!this._wantsAltCommand && this._menuItemAction.alt?.enabled) {272const altTooltip = this._menuItemAction.alt.tooltip || this._menuItemAction.alt.label;273const altKeybinding = this._keybindingService.lookupKeybinding(this._menuItemAction.alt.id, this._contextKeyService);274const altKeybindingLabel = altKeybinding && altKeybinding.getLabel();275const altTitleSection = altKeybindingLabel276? localize('titleAndKb', "{0} ({1})", altTooltip, altKeybindingLabel)277: altTooltip;278279title = localize('titleAndKbAndAlt', "{0}\n[{1}] {2}", title, UILabelProvider.modifierLabels[OS].altKey, altTitleSection);280}281return title;282}283284protected override updateClass(): void {285if (this.options.icon) {286if (this._commandAction !== this._menuItemAction) {287if (this._menuItemAction.alt) {288this._updateItemClass(this._menuItemAction.alt.item);289}290} else {291this._updateItemClass(this._menuItemAction.item);292}293}294}295296private _updateItemClass(item: ICommandAction): void {297this._itemClassDispose.value = undefined;298299const { element, label } = this;300if (!element || !label) {301return;302}303304const icon = this._commandAction.checked && isICommandActionToggleInfo(item.toggled) && item.toggled.icon ? item.toggled.icon : item.icon;305306if (!icon) {307return;308}309310if (ThemeIcon.isThemeIcon(icon)) {311// theme icons312const iconClasses = ThemeIcon.asClassNameArray(icon);313label.classList.add(...iconClasses);314this._itemClassDispose.value = toDisposable(() => {315label.classList.remove(...iconClasses);316});317318} else {319// icon path/url320label.style.backgroundImage = (321isDark(this._themeService.getColorTheme().type)322? asCSSUrl(icon.dark)323: asCSSUrl(icon.light)324);325label.classList.add('icon');326this._itemClassDispose.value = combinedDisposable(327toDisposable(() => {328label.style.backgroundImage = '';329label.classList.remove('icon');330}),331this._themeService.onDidColorThemeChange(() => {332// refresh when the theme changes in case we go between dark <-> light333this.updateClass();334})335);336}337}338}339340export interface ITextOnlyMenuEntryActionViewItemOptions extends IMenuEntryActionViewItemOptions {341readonly conversational?: boolean;342readonly useComma?: boolean;343}344345export class TextOnlyMenuEntryActionViewItem extends MenuEntryActionViewItem<ITextOnlyMenuEntryActionViewItemOptions> {346347override render(container: HTMLElement): void {348this.options.label = true;349this.options.icon = false;350super.render(container);351container.classList.add('text-only');352container.classList.toggle('use-comma', this._options?.useComma ?? false);353}354355protected override updateLabel() {356const kb = this._keybindingService.lookupKeybinding(this._action.id, this._contextKeyService);357if (!kb) {358return super.updateLabel();359}360if (this.label) {361const kb2 = TextOnlyMenuEntryActionViewItem._symbolPrintEnter(kb);362363if (this._options?.conversational) {364this.label.textContent = localize({ key: 'content2', comment: ['A label with keybindg like "ESC to dismiss"'] }, '{1} to {0}', this._action.label, kb2);365366} else {367this.label.textContent = localize({ key: 'content', comment: ['A label', 'A keybinding'] }, '{0} ({1})', this._action.label, kb2);368}369}370}371372private static _symbolPrintEnter(kb: ResolvedKeybinding) {373return kb.getLabel()374?.replace(/\benter\b/gi, '\u23CE')375.replace(/\bEscape\b/gi, 'Esc');376}377}378379export class SubmenuEntryActionViewItem extends DropdownMenuActionViewItem {380381constructor(382action: SubmenuItemAction,383options: IDropdownMenuActionViewItemOptions | undefined,384@IKeybindingService protected _keybindingService: IKeybindingService,385@IContextMenuService protected _contextMenuService: IContextMenuService,386@IThemeService protected _themeService: IThemeService387) {388const dropdownOptions: IDropdownMenuActionViewItemOptions = {389...options,390menuAsChild: options?.menuAsChild ?? false,391classNames: options?.classNames ?? (ThemeIcon.isThemeIcon(action.item.icon) ? ThemeIcon.asClassName(action.item.icon) : undefined),392keybindingProvider: options?.keybindingProvider ?? (action => _keybindingService.lookupKeybinding(action.id))393};394395super(action, { getActions: () => action.actions }, _contextMenuService, dropdownOptions);396}397398override render(container: HTMLElement): void {399super.render(container);400assertType(this.element);401402container.classList.add('menu-entry');403const action = <SubmenuItemAction>this._action;404const { icon } = action.item;405if (icon && !ThemeIcon.isThemeIcon(icon)) {406this.element.classList.add('icon');407const setBackgroundImage = () => {408if (this.element) {409this.element.style.backgroundImage = (410isDark(this._themeService.getColorTheme().type)411? asCSSUrl(icon.dark)412: asCSSUrl(icon.light)413);414}415};416setBackgroundImage();417this._register(this._themeService.onDidColorThemeChange(() => {418// refresh when the theme changes in case we go between dark <-> light419setBackgroundImage();420}));421}422}423}424425export interface IDropdownWithDefaultActionViewItemOptions extends IDropdownMenuActionViewItemOptions {426renderKeybindingWithDefaultActionLabel?: boolean;427persistLastActionId?: boolean;428}429430export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {431private readonly _options: IDropdownWithDefaultActionViewItemOptions | undefined;432private _defaultAction: ActionViewItem;433private readonly _defaultActionDisposables = this._register(new DisposableStore());434private readonly _dropdown: DropdownMenuActionViewItem;435private _container: HTMLElement | null = null;436private readonly _storageKey: string;437438get onDidChangeDropdownVisibility(): Event<boolean> {439return this._dropdown.onDidChangeVisibility;440}441442constructor(443submenuAction: SubmenuItemAction,444options: IDropdownWithDefaultActionViewItemOptions | undefined,445@IKeybindingService protected readonly _keybindingService: IKeybindingService,446@INotificationService protected _notificationService: INotificationService,447@IContextMenuService protected _contextMenuService: IContextMenuService,448@IMenuService protected _menuService: IMenuService,449@IInstantiationService protected _instaService: IInstantiationService,450@IStorageService protected _storageService: IStorageService451) {452super(null, submenuAction);453this._options = options;454this._storageKey = `${submenuAction.item.submenu.id}_lastActionId`;455456// determine default action457let defaultAction: IAction | undefined;458const defaultActionId = options?.persistLastActionId ? _storageService.get(this._storageKey, StorageScope.WORKSPACE) : undefined;459if (defaultActionId) {460defaultAction = submenuAction.actions.find(a => defaultActionId === a.id);461}462if (!defaultAction) {463defaultAction = submenuAction.actions[0];464}465466this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, <MenuItemAction>defaultAction, { keybinding: this._getDefaultActionKeybindingLabel(defaultAction) }));467468const dropdownOptions: IDropdownMenuActionViewItemOptions = {469keybindingProvider: action => this._keybindingService.lookupKeybinding(action.id),470...options,471menuAsChild: options?.menuAsChild ?? true,472classNames: options?.classNames ?? ['codicon', 'codicon-chevron-down'],473actionRunner: options?.actionRunner ?? this._register(new ActionRunner()),474};475476this._dropdown = this._register(new DropdownMenuActionViewItem(submenuAction, submenuAction.actions, this._contextMenuService, dropdownOptions));477this._register(this._dropdown.actionRunner.onDidRun((e: IRunEvent) => {478if (e.action instanceof MenuItemAction) {479this.update(e.action);480}481}));482}483484private update(lastAction: MenuItemAction): void {485if (this._options?.persistLastActionId) {486this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);487}488489this._defaultActionDisposables.clear();490this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, lastAction, { keybinding: this._getDefaultActionKeybindingLabel(lastAction) }));491this._defaultAction.actionRunner = this._defaultActionDisposables.add(new class extends ActionRunner {492protected override async runAction(action: IAction, context?: unknown): Promise<void> {493await action.run(undefined);494}495}());496497if (this._container) {498this._defaultAction.render(prepend(this._container, $('.action-container')));499}500}501502private _getDefaultActionKeybindingLabel(defaultAction: IAction) {503let defaultActionKeybinding: string | undefined;504if (this._options?.renderKeybindingWithDefaultActionLabel) {505const kb = this._keybindingService.lookupKeybinding(defaultAction.id);506if (kb) {507defaultActionKeybinding = `(${kb.getLabel()})`;508}509}510return defaultActionKeybinding;511}512513override setActionContext(newContext: unknown): void {514super.setActionContext(newContext);515this._defaultAction.setActionContext(newContext);516this._dropdown.setActionContext(newContext);517}518519override render(container: HTMLElement): void {520this._container = container;521super.render(this._container);522523this._container.classList.add('monaco-dropdown-with-default');524525const primaryContainer = $('.action-container');526this._defaultAction.render(append(this._container, primaryContainer));527this._register(addDisposableListener(primaryContainer, EventType.KEY_DOWN, (e: KeyboardEvent) => {528const event = new StandardKeyboardEvent(e);529if (event.equals(KeyCode.RightArrow)) {530this._defaultAction.element!.tabIndex = -1;531this._dropdown.focus();532event.stopPropagation();533}534}));535536const dropdownContainer = $('.dropdown-action-container');537this._dropdown.render(append(this._container, dropdownContainer));538this._register(addDisposableListener(dropdownContainer, EventType.KEY_DOWN, (e: KeyboardEvent) => {539const event = new StandardKeyboardEvent(e);540if (event.equals(KeyCode.LeftArrow)) {541this._defaultAction.element!.tabIndex = 0;542this._dropdown.setFocusable(false);543this._defaultAction.element?.focus();544event.stopPropagation();545}546}));547}548549override focus(fromRight?: boolean): void {550if (fromRight) {551this._dropdown.focus();552} else {553this._defaultAction.element!.tabIndex = 0;554this._defaultAction.element!.focus();555}556}557558override blur(): void {559this._defaultAction.element!.tabIndex = -1;560this._dropdown.blur();561this._container!.blur();562}563564override setFocusable(focusable: boolean): void {565if (focusable) {566this._defaultAction.element!.tabIndex = 0;567} else {568this._defaultAction.element!.tabIndex = -1;569this._dropdown.setFocusable(false);570}571}572}573574class SubmenuEntrySelectActionViewItem extends SelectActionViewItem {575576constructor(577action: SubmenuItemAction,578@IContextViewService contextViewService: IContextViewService,579@IConfigurationService configurationService: IConfigurationService,580) {581super(null, action, action.actions.map(a => ({582text: a.id === Separator.ID ? '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500' : a.label,583isDisabled: !a.enabled,584})), 0, contextViewService, defaultSelectBoxStyles, { ariaLabel: action.tooltip, optionsAsChildren: true, useCustomDrawn: !hasNativeContextMenu(configurationService) });585this.select(Math.max(0, action.actions.findIndex(a => a.checked)));586}587588override render(container: HTMLElement): void {589super.render(container);590container.style.borderColor = asCssVariable(selectBorder);591}592593protected override runAction(option: string, index: number): void {594const action = (this.action as SubmenuItemAction).actions[index];595if (action) {596this.actionRunner.run(action);597}598}599600}601602/**603* Creates action view items for menu actions or submenu actions.604*/605export function createActionViewItem(instaService: IInstantiationService, action: IAction, options: IDropdownMenuActionViewItemOptions | IMenuEntryActionViewItemOptions | undefined): undefined | MenuEntryActionViewItem | SubmenuEntryActionViewItem | BaseActionViewItem {606if (action instanceof MenuItemAction) {607return instaService.createInstance(MenuEntryActionViewItem, action, options);608} else if (action instanceof SubmenuItemAction) {609if (action.item.isSelection) {610return instaService.createInstance(SubmenuEntrySelectActionViewItem, action);611} else {612if (action.item.rememberDefaultAction) {613return instaService.createInstance(DropdownWithDefaultActionViewItem, action, { ...options, persistLastActionId: true });614} else {615return instaService.createInstance(SubmenuEntryActionViewItem, action, options);616}617}618} else {619return undefined;620}621}622623624