Path: blob/main/src/vs/platform/actions/browser/menuEntryActionViewItem.ts
5220 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 { SeparatorSelectOption } from '../../../base/browser/ui/selectBox/selectBox.js';12import { ActionRunner, IAction, IActionRunner, IRunEvent, Separator, SubmenuAction } from '../../../base/common/actions.js';13import { Event } from '../../../base/common/event.js';14import { UILabelProvider } from '../../../base/common/keybindingLabels.js';15import { ResolvedKeybinding } from '../../../base/common/keybindings.js';16import { KeyCode } from '../../../base/common/keyCodes.js';17import { combinedDisposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';18import { isLinux, isWindows, OS } from '../../../base/common/platform.js';19import { ThemeIcon } from '../../../base/common/themables.js';20import { assertType } from '../../../base/common/types.js';21import { localize } from '../../../nls.js';22import { IAccessibilityService } from '../../accessibility/common/accessibility.js';23import { ICommandAction, isICommandActionToggleInfo } from '../../action/common/action.js';24import { IConfigurationService } from '../../configuration/common/configuration.js';25import { IContextKeyService } from '../../contextkey/common/contextkey.js';26import { IContextMenuService, IContextViewService } from '../../contextview/browser/contextView.js';27import { IInstantiationService } from '../../instantiation/common/instantiation.js';28import { IKeybindingService } from '../../keybinding/common/keybinding.js';29import { INotificationService } from '../../notification/common/notification.js';30import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';31import { defaultSelectBoxStyles } from '../../theme/browser/defaultStyles.js';32import { asCssVariable, selectBorder } from '../../theme/common/colorRegistry.js';33import { isDark } from '../../theme/common/theme.js';34import { IThemeService } from '../../theme/common/themeService.js';35import { hasNativeContextMenu } from '../../window/common/window.js';36import { IMenuService, MenuItemAction, SubmenuItemAction } from '../common/actions.js';37import './menuEntryActionViewItem.css';3839export interface PrimaryAndSecondaryActions {40primary: IAction[];41secondary: IAction[];42}4344export function getContextMenuActions(45groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,46primaryGroup?: string47): PrimaryAndSecondaryActions {48const target: PrimaryAndSecondaryActions = { primary: [], secondary: [] };49getContextMenuActionsImpl(groups, target, primaryGroup);50return target;51}5253export function getFlatContextMenuActions(54groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,55primaryGroup?: string56): IAction[] {57const target: IAction[] = [];58getContextMenuActionsImpl(groups, target, primaryGroup);59return target;60}6162function getContextMenuActionsImpl(63groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,64target: IAction[] | PrimaryAndSecondaryActions,65primaryGroup?: string66) {67const modifierKeyEmitter = ModifierKeyEmitter.getInstance();68const useAlternativeActions = modifierKeyEmitter.keyStatus.altKey || ((isWindows || isLinux) && modifierKeyEmitter.keyStatus.shiftKey);69fillInActions(groups, target, useAlternativeActions, primaryGroup ? actionGroup => actionGroup === primaryGroup : actionGroup => actionGroup === 'navigation');70}717273export function getActionBarActions(74groups: [string, Array<MenuItemAction | SubmenuItemAction>][],75primaryGroup?: string | ((actionGroup: string) => boolean),76shouldInlineSubmenu?: (action: SubmenuAction, group: string, groupSize: number) => boolean,77useSeparatorsInPrimaryActions?: boolean78): PrimaryAndSecondaryActions {79const target: PrimaryAndSecondaryActions = { primary: [], secondary: [] };80fillInActionBarActions(groups, target, primaryGroup, shouldInlineSubmenu, useSeparatorsInPrimaryActions);81return target;82}8384export function getFlatActionBarActions(85groups: [string, Array<MenuItemAction | SubmenuItemAction>][],86primaryGroup?: string | ((actionGroup: string) => boolean),87shouldInlineSubmenu?: (action: SubmenuAction, group: string, groupSize: number) => boolean,88useSeparatorsInPrimaryActions?: boolean89): IAction[] {90const target: IAction[] = [];91fillInActionBarActions(groups, target, primaryGroup, shouldInlineSubmenu, useSeparatorsInPrimaryActions);92return target;93}9495export function fillInActionBarActions(96groups: [string, Array<MenuItemAction | SubmenuItemAction>][],97target: IAction[] | PrimaryAndSecondaryActions,98primaryGroup?: string | ((actionGroup: string) => boolean),99shouldInlineSubmenu?: (action: SubmenuAction, group: string, groupSize: number) => boolean,100useSeparatorsInPrimaryActions?: boolean101): void {102const isPrimaryAction = typeof primaryGroup === 'string' ? (actionGroup: string) => actionGroup === primaryGroup : primaryGroup;103104// Action bars handle alternative actions on their own so the alternative actions should be ignored105fillInActions(groups, target, false, isPrimaryAction, shouldInlineSubmenu, useSeparatorsInPrimaryActions);106}107108function fillInActions(109groups: ReadonlyArray<[string, ReadonlyArray<MenuItemAction | SubmenuItemAction>]>,110target: IAction[] | PrimaryAndSecondaryActions,111useAlternativeActions: boolean,112isPrimaryAction: (actionGroup: string) => boolean = actionGroup => actionGroup === 'navigation',113shouldInlineSubmenu: (action: SubmenuAction, group: string, groupSize: number) => boolean = () => false,114useSeparatorsInPrimaryActions: boolean = false115): void {116117let primaryBucket: IAction[];118let secondaryBucket: IAction[];119if (Array.isArray(target)) {120primaryBucket = target;121secondaryBucket = target;122} else {123primaryBucket = target.primary;124secondaryBucket = target.secondary;125}126127const submenuInfo = new Set<{ group: string; action: SubmenuAction; index: number }>();128129for (const [group, actions] of groups) {130131let target: IAction[];132if (isPrimaryAction(group)) {133target = primaryBucket;134if (target.length > 0 && useSeparatorsInPrimaryActions) {135target.push(new Separator());136}137} else {138target = secondaryBucket;139if (target.length > 0) {140target.push(new Separator());141}142}143144for (let action of actions) {145if (useAlternativeActions) {146action = action instanceof MenuItemAction && action.alt ? action.alt : action;147}148const newLen = target.push(action);149// keep submenu info for later inlining150if (action instanceof SubmenuAction) {151submenuInfo.add({ group, action, index: newLen - 1 });152}153}154}155156// ask the outside if submenu should be inlined or not. only ask when157// there would be enough space158for (const { group, action, index } of submenuInfo) {159const target = isPrimaryAction(group) ? primaryBucket : secondaryBucket;160161// inlining submenus with length 0 or 1 is easy,162// larger submenus need to be checked with the overall limit163const submenuActions = action.actions;164if (shouldInlineSubmenu(action, group, target.length)) {165target.splice(index, 1, ...submenuActions);166}167}168}169170export interface IMenuEntryActionViewItemOptions {171readonly draggable?: boolean;172readonly keybinding?: string | null;173readonly hoverDelegate?: IHoverDelegate;174readonly keybindingNotRenderedWithLabel?: boolean;175}176177export class MenuEntryActionViewItem<T extends IMenuEntryActionViewItemOptions = IMenuEntryActionViewItemOptions> extends ActionViewItem {178179private _wantsAltCommand: boolean = false;180private readonly _itemClassDispose = this._register(new MutableDisposable());181private readonly _altKey: ModifierKeyEmitter;182183constructor(184action: MenuItemAction,185protected readonly _options: T | undefined,186@IKeybindingService protected readonly _keybindingService: IKeybindingService,187@INotificationService protected readonly _notificationService: INotificationService,188@IContextKeyService protected readonly _contextKeyService: IContextKeyService,189@IThemeService protected readonly _themeService: IThemeService,190@IContextMenuService protected readonly _contextMenuService: IContextMenuService,191@IAccessibilityService private readonly _accessibilityService: IAccessibilityService192) {193super(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 });194this._altKey = ModifierKeyEmitter.getInstance();195}196197protected get _menuItemAction(): MenuItemAction {198return <MenuItemAction>this._action;199}200201protected get _commandAction(): MenuItemAction {202return this._wantsAltCommand && this._menuItemAction.alt || this._menuItemAction;203}204205override async onClick(event: MouseEvent): Promise<void> {206event.preventDefault();207event.stopPropagation();208209try {210await this.actionRunner.run(this._commandAction, this._context);211} catch (err) {212this._notificationService.error(err);213}214}215216override render(container: HTMLElement): void {217super.render(container);218container.classList.add('menu-entry');219220if (this.options.icon) {221this._updateItemClass(this._menuItemAction.item);222}223224if (this._menuItemAction.alt) {225let isMouseOver = false;226227const updateAltState = () => {228const wantsAltCommand = !!this._menuItemAction.alt?.enabled &&229(!this._accessibilityService.isMotionReduced() || isMouseOver) && (230this._altKey.keyStatus.altKey ||231(this._altKey.keyStatus.shiftKey && isMouseOver)232);233234if (wantsAltCommand !== this._wantsAltCommand) {235this._wantsAltCommand = wantsAltCommand;236this.updateLabel();237this.updateTooltip();238this.updateClass();239}240};241242this._register(this._altKey.event(updateAltState));243244this._register(addDisposableListener(container, 'mouseleave', _ => {245isMouseOver = false;246updateAltState();247}));248249this._register(addDisposableListener(container, 'mouseenter', _ => {250isMouseOver = true;251updateAltState();252}));253254updateAltState();255}256}257258protected override updateLabel(): void {259if (this.options.label && this.label) {260this.label.textContent = this._commandAction.label;261}262}263264protected override getTooltip() {265const tooltip = this._commandAction.tooltip || this._commandAction.label;266let title = this._keybindingService.appendKeybinding(tooltip, this._commandAction.id, this._contextKeyService);267if (!this._wantsAltCommand && this._menuItemAction.alt?.enabled) {268const altTooltip = this._menuItemAction.alt.tooltip || this._menuItemAction.alt.label;269const altTitleSection = this._keybindingService.appendKeybinding(altTooltip, this._menuItemAction.alt.id, this._contextKeyService);270271title = localize('titleAndKbAndAlt', "{0}\n[{1}] {2}", title, UILabelProvider.modifierLabels[OS].altKey, altTitleSection);272}273return title;274}275276protected override updateClass(): void {277if (this.options.icon) {278if (this._commandAction !== this._menuItemAction) {279if (this._menuItemAction.alt) {280this._updateItemClass(this._menuItemAction.alt.item);281}282} else {283this._updateItemClass(this._menuItemAction.item);284}285}286}287288private _updateItemClass(item: ICommandAction): void {289this._itemClassDispose.value = undefined;290291const { element, label } = this;292if (!element || !label) {293return;294}295296const icon = this._commandAction.checked && isICommandActionToggleInfo(item.toggled) && item.toggled.icon ? item.toggled.icon : item.icon;297298if (!icon) {299return;300}301302if (ThemeIcon.isThemeIcon(icon)) {303// theme icons304const iconClasses = ThemeIcon.asClassNameArray(icon);305label.classList.add(...iconClasses);306this._itemClassDispose.value = toDisposable(() => {307label.classList.remove(...iconClasses);308});309310} else {311// icon path/url312label.style.backgroundImage = (313isDark(this._themeService.getColorTheme().type)314? asCSSUrl(icon.dark)315: asCSSUrl(icon.light)316);317label.classList.add('icon');318this._itemClassDispose.value = combinedDisposable(319toDisposable(() => {320label.style.backgroundImage = '';321label.classList.remove('icon');322}),323this._themeService.onDidColorThemeChange(() => {324// refresh when the theme changes in case we go between dark <-> light325this.updateClass();326})327);328}329}330}331332export interface ITextOnlyMenuEntryActionViewItemOptions extends IMenuEntryActionViewItemOptions {333readonly conversational?: boolean;334readonly useComma?: boolean;335}336337export class TextOnlyMenuEntryActionViewItem extends MenuEntryActionViewItem<ITextOnlyMenuEntryActionViewItemOptions> {338339override render(container: HTMLElement): void {340this.options.label = true;341this.options.icon = false;342super.render(container);343container.classList.add('text-only');344container.classList.toggle('use-comma', this._options?.useComma ?? false);345}346347protected override updateLabel() {348const kb = this._keybindingService.lookupKeybinding(this._action.id, this._contextKeyService);349if (!kb) {350return super.updateLabel();351}352if (this.label) {353const kb2 = TextOnlyMenuEntryActionViewItem._symbolPrintEnter(kb);354355if (this._options?.conversational) {356this.label.textContent = localize({ key: 'content2', comment: ['A label with keybindg like "ESC to dismiss"'] }, '{1} to {0}', this._action.label, kb2);357358} else {359this.label.textContent = localize({ key: 'content', comment: ['A label', 'A keybinding'] }, '{0} ({1})', this._action.label, kb2);360}361}362}363364private static _symbolPrintEnter(kb: ResolvedKeybinding) {365return kb.getLabel()366?.replace(/\benter\b/gi, '\u23CE')367.replace(/\bEscape\b/gi, 'Esc');368}369}370371export class SubmenuEntryActionViewItem extends DropdownMenuActionViewItem {372373constructor(374action: SubmenuItemAction,375options: IDropdownMenuActionViewItemOptions | undefined,376@IKeybindingService protected _keybindingService: IKeybindingService,377@IContextMenuService protected _contextMenuService: IContextMenuService,378@IThemeService protected _themeService: IThemeService379) {380const dropdownOptions: IDropdownMenuActionViewItemOptions = {381...options,382menuAsChild: options?.menuAsChild ?? false,383classNames: options?.classNames ?? (ThemeIcon.isThemeIcon(action.item.icon) ? ThemeIcon.asClassName(action.item.icon) : undefined),384keybindingProvider: options?.keybindingProvider ?? (action => _keybindingService.lookupKeybinding(action.id))385};386387super(action, { getActions: () => action.actions }, _contextMenuService, dropdownOptions);388}389390override render(container: HTMLElement): void {391super.render(container);392assertType(this.element);393394container.classList.add('menu-entry');395const action = <SubmenuItemAction>this._action;396const { icon } = action.item;397if (icon && !ThemeIcon.isThemeIcon(icon)) {398this.element.classList.add('icon');399const setBackgroundImage = () => {400if (this.element) {401this.element.style.backgroundImage = (402isDark(this._themeService.getColorTheme().type)403? asCSSUrl(icon.dark)404: asCSSUrl(icon.light)405);406}407};408setBackgroundImage();409this._register(this._themeService.onDidColorThemeChange(() => {410// refresh when the theme changes in case we go between dark <-> light411setBackgroundImage();412}));413}414}415}416417export interface IDropdownWithDefaultActionViewItemOptions extends IDropdownMenuActionViewItemOptions {418renderKeybindingWithDefaultActionLabel?: boolean;419togglePrimaryAction?: boolean;420}421422export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {423private readonly _options: IDropdownWithDefaultActionViewItemOptions | undefined;424private _defaultAction: ActionViewItem;425private readonly _defaultActionDisposables = this._register(new DisposableStore());426private readonly _dropdown: DropdownMenuActionViewItem;427private _container: HTMLElement | null = null;428private readonly _storageKey: string;429private readonly _primaryActionListener = this._register(new MutableDisposable());430431get onDidChangeDropdownVisibility(): Event<boolean> {432return this._dropdown.onDidChangeVisibility;433}434435constructor(436submenuAction: SubmenuItemAction,437options: IDropdownWithDefaultActionViewItemOptions | undefined,438@IKeybindingService protected readonly _keybindingService: IKeybindingService,439@INotificationService protected _notificationService: INotificationService,440@IContextMenuService protected _contextMenuService: IContextMenuService,441@IMenuService protected _menuService: IMenuService,442@IInstantiationService protected _instaService: IInstantiationService,443@IStorageService protected _storageService: IStorageService444) {445super(null, submenuAction);446this._options = options;447this._storageKey = `${submenuAction.item.submenu.id}_lastActionId`;448449// determine default action450let defaultAction: IAction | undefined;451const defaultActionId = options?.togglePrimaryAction ? _storageService.get(this._storageKey, StorageScope.WORKSPACE) : undefined;452if (defaultActionId) {453defaultAction = submenuAction.actions.find(a => defaultActionId === a.id);454}455if (!defaultAction) {456defaultAction = submenuAction.actions[0];457}458459this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, <MenuItemAction>defaultAction, { keybinding: this._getDefaultActionKeybindingLabel(defaultAction) }));460461const dropdownOptions: IDropdownMenuActionViewItemOptions = {462keybindingProvider: action => this._keybindingService.lookupKeybinding(action.id),463...options,464menuAsChild: options?.menuAsChild ?? true,465classNames: options?.classNames ?? ['codicon', 'codicon-chevron-down'],466actionRunner: options?.actionRunner ?? this._register(new ActionRunner()),467};468469this._dropdown = this._register(new DropdownMenuActionViewItem(submenuAction, submenuAction.actions, this._contextMenuService, dropdownOptions));470if (options?.togglePrimaryAction) {471this.registerTogglePrimaryActionListener();472}473}474475private registerTogglePrimaryActionListener(): void {476this._primaryActionListener.value = this._dropdown.actionRunner.onDidRun((e: IRunEvent) => {477if (e.action instanceof MenuItemAction) {478this.update(e.action);479}480});481}482483private update(lastAction: MenuItemAction): void {484if (this._options?.togglePrimaryAction) {485this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);486}487488this._defaultActionDisposables.clear();489this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, lastAction, { keybinding: this._getDefaultActionKeybindingLabel(lastAction) }));490this._defaultAction.actionRunner = this._defaultActionDisposables.add(new class extends ActionRunner {491protected override async runAction(action: IAction, context?: unknown): Promise<void> {492await action.run(undefined);493}494}());495496if (this._container) {497this._defaultAction.render(prepend(this._container, $('.action-container')));498}499}500501private _getDefaultActionKeybindingLabel(defaultAction: IAction) {502let defaultActionKeybinding: string | undefined;503if (this._options?.renderKeybindingWithDefaultActionLabel) {504const kb = this._keybindingService.lookupKeybinding(defaultAction.id);505if (kb) {506defaultActionKeybinding = `(${kb.getLabel()})`;507}508}509return defaultActionKeybinding;510}511512override setActionContext(newContext: unknown): void {513super.setActionContext(newContext);514this._defaultAction.setActionContext(newContext);515this._dropdown.setActionContext(newContext);516}517518override set actionRunner(actionRunner: IActionRunner) {519super.actionRunner = actionRunner;520521this._defaultAction.actionRunner = actionRunner;522this._dropdown.actionRunner = actionRunner;523if (this._primaryActionListener.value) {524this.registerTogglePrimaryActionListener();525}526}527528override get actionRunner(): IActionRunner {529return super.actionRunner;530}531532override render(container: HTMLElement): void {533this._container = container;534super.render(this._container);535536this._container.classList.add('monaco-dropdown-with-default');537538const primaryContainer = $('.action-container');539this._defaultAction.render(append(this._container, primaryContainer));540this._register(addDisposableListener(primaryContainer, EventType.KEY_DOWN, (e: KeyboardEvent) => {541const event = new StandardKeyboardEvent(e);542if (event.equals(KeyCode.RightArrow)) {543this._defaultAction.element!.tabIndex = -1;544this._dropdown.focus();545event.stopPropagation();546}547}));548549const dropdownContainer = $('.dropdown-action-container');550this._dropdown.render(append(this._container, dropdownContainer));551this._register(addDisposableListener(dropdownContainer, EventType.KEY_DOWN, (e: KeyboardEvent) => {552const event = new StandardKeyboardEvent(e);553if (event.equals(KeyCode.LeftArrow)) {554this._defaultAction.element!.tabIndex = 0;555this._dropdown.setFocusable(false);556this._defaultAction.element?.focus();557event.stopPropagation();558}559}));560}561562override focus(fromRight?: boolean): void {563if (fromRight) {564this._dropdown.focus();565} else {566this._defaultAction.element!.tabIndex = 0;567this._defaultAction.element!.focus();568}569}570571override blur(): void {572this._defaultAction.element!.tabIndex = -1;573this._dropdown.blur();574this._container!.blur();575}576577override setFocusable(focusable: boolean): void {578if (focusable) {579this._defaultAction.element!.tabIndex = 0;580} else {581this._defaultAction.element!.tabIndex = -1;582this._dropdown.setFocusable(false);583}584}585}586587class SubmenuEntrySelectActionViewItem extends SelectActionViewItem {588589constructor(590action: SubmenuItemAction,591@IContextViewService contextViewService: IContextViewService,592@IConfigurationService configurationService: IConfigurationService,593) {594super(null, action, action.actions.map(a => (a.id === Separator.ID ? SeparatorSelectOption : { text: a.label, isDisabled: !a.enabled, })), 0, contextViewService, defaultSelectBoxStyles, { ariaLabel: action.tooltip || action.label, optionsAsChildren: true, useCustomDrawn: !hasNativeContextMenu(configurationService) });595this.select(Math.max(0, action.actions.findIndex(a => a.checked)));596}597598override render(container: HTMLElement): void {599super.render(container);600container.style.borderColor = asCssVariable(selectBorder);601}602603protected override runAction(option: string, index: number): void {604const action = (this.action as SubmenuItemAction).actions[index];605if (action) {606this.actionRunner.run(action);607}608}609610}611612/**613* Creates action view items for menu actions or submenu actions.614*/615export function createActionViewItem(instaService: IInstantiationService, action: IAction, options: IDropdownMenuActionViewItemOptions | IMenuEntryActionViewItemOptions | undefined): undefined | MenuEntryActionViewItem | SubmenuEntryActionViewItem | BaseActionViewItem {616if (action instanceof MenuItemAction) {617return instaService.createInstance(MenuEntryActionViewItem, action, options);618} else if (action instanceof SubmenuItemAction) {619if (action.item.isSelection) {620return instaService.createInstance(SubmenuEntrySelectActionViewItem, action);621} else if (action.item.isSplitButton) {622return instaService.createInstance(DropdownWithDefaultActionViewItem, action, {623...options,624togglePrimaryAction: typeof action.item.isSplitButton !== 'boolean' ? action.item.isSplitButton.togglePrimaryAction : false,625});626} else {627return instaService.createInstance(SubmenuEntryActionViewItem, action, options);628}629} else {630return undefined;631}632}633634635