Path: blob/main/src/vs/platform/actions/common/menuService.ts
3296 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 { RunOnceScheduler } from '../../../base/common/async.js';6import { DebounceEmitter, Emitter, Event } from '../../../base/common/event.js';7import { DisposableStore } from '../../../base/common/lifecycle.js';8import { IMenu, IMenuActionOptions, IMenuChangeEvent, IMenuCreateOptions, IMenuItem, IMenuItemHide, IMenuService, isIMenuItem, isISubmenuItem, ISubmenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from './actions.js';9import { ICommandAction, ILocalizedString } from '../../action/common/action.js';10import { ICommandService } from '../../commands/common/commands.js';11import { ContextKeyExpression, IContextKeyService } from '../../contextkey/common/contextkey.js';12import { IAction, Separator, toAction } from '../../../base/common/actions.js';13import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';14import { removeFastWithoutKeepingOrder } from '../../../base/common/arrays.js';15import { localize } from '../../../nls.js';16import { IKeybindingService } from '../../keybinding/common/keybinding.js';1718export class MenuService implements IMenuService {1920declare readonly _serviceBrand: undefined;2122private readonly _hiddenStates: PersistedMenuHideState;2324constructor(25@ICommandService private readonly _commandService: ICommandService,26@IKeybindingService private readonly _keybindingService: IKeybindingService,27@IStorageService storageService: IStorageService,28) {29this._hiddenStates = new PersistedMenuHideState(storageService);30}3132createMenu(id: MenuId, contextKeyService: IContextKeyService, options?: IMenuCreateOptions): IMenu {33return new MenuImpl(id, this._hiddenStates, { emitEventsForSubmenuChanges: false, eventDebounceDelay: 50, ...options }, this._commandService, this._keybindingService, contextKeyService);34}3536getMenuActions(id: MenuId, contextKeyService: IContextKeyService, options?: IMenuActionOptions): [string, Array<MenuItemAction | SubmenuItemAction>][] {37const menu = new MenuImpl(id, this._hiddenStates, { emitEventsForSubmenuChanges: false, eventDebounceDelay: 50, ...options }, this._commandService, this._keybindingService, contextKeyService);38const actions = menu.getActions(options);39menu.dispose();40return actions;41}4243getMenuContexts(id: MenuId): ReadonlySet<string> {44const menuInfo = new MenuInfoSnapshot(id, false);45return new Set<string>([...menuInfo.structureContextKeys, ...menuInfo.preconditionContextKeys, ...menuInfo.toggledContextKeys]);46}4748resetHiddenStates(ids?: MenuId[]): void {49this._hiddenStates.reset(ids);50}51}5253class PersistedMenuHideState {5455private static readonly _key = 'menu.hiddenCommands';5657private readonly _disposables = new DisposableStore();58private readonly _onDidChange = new Emitter<void>();59readonly onDidChange: Event<void> = this._onDidChange.event;6061private _ignoreChangeEvent: boolean = false;62private _data: Record<string, string[] | undefined>;6364private _hiddenByDefaultCache = new Map<string, boolean>();6566constructor(@IStorageService private readonly _storageService: IStorageService) {67try {68const raw = _storageService.get(PersistedMenuHideState._key, StorageScope.PROFILE, '{}');69this._data = JSON.parse(raw);70} catch (err) {71this._data = Object.create(null);72}7374this._disposables.add(_storageService.onDidChangeValue(StorageScope.PROFILE, PersistedMenuHideState._key, this._disposables)(() => {75if (!this._ignoreChangeEvent) {76try {77const raw = _storageService.get(PersistedMenuHideState._key, StorageScope.PROFILE, '{}');78this._data = JSON.parse(raw);79} catch (err) {80console.log('FAILED to read storage after UPDATE', err);81}82}83this._onDidChange.fire();84}));85}8687dispose() {88this._onDidChange.dispose();89this._disposables.dispose();90}9192private _isHiddenByDefault(menu: MenuId, commandId: string) {93return this._hiddenByDefaultCache.get(`${menu.id}/${commandId}`) ?? false;94}9596setDefaultState(menu: MenuId, commandId: string, hidden: boolean): void {97this._hiddenByDefaultCache.set(`${menu.id}/${commandId}`, hidden);98}99100isHidden(menu: MenuId, commandId: string): boolean {101const hiddenByDefault = this._isHiddenByDefault(menu, commandId);102const state = this._data[menu.id]?.includes(commandId) ?? false;103return hiddenByDefault ? !state : state;104}105106updateHidden(menu: MenuId, commandId: string, hidden: boolean): void {107const hiddenByDefault = this._isHiddenByDefault(menu, commandId);108if (hiddenByDefault) {109hidden = !hidden;110}111const entries = this._data[menu.id];112if (!hidden) {113// remove and cleanup114if (entries) {115const idx = entries.indexOf(commandId);116if (idx >= 0) {117removeFastWithoutKeepingOrder(entries, idx);118}119if (entries.length === 0) {120delete this._data[menu.id];121}122}123} else {124// add unless already added125if (!entries) {126this._data[menu.id] = [commandId];127} else {128const idx = entries.indexOf(commandId);129if (idx < 0) {130entries.push(commandId);131}132}133}134this._persist();135}136137reset(menus?: MenuId[]): void {138if (menus === undefined) {139// reset all140this._data = Object.create(null);141this._persist();142} else {143// reset only for a specific menu144for (const { id } of menus) {145if (this._data[id]) {146delete this._data[id];147}148}149this._persist();150}151}152153private _persist(): void {154try {155this._ignoreChangeEvent = true;156const raw = JSON.stringify(this._data);157this._storageService.store(PersistedMenuHideState._key, raw, StorageScope.PROFILE, StorageTarget.USER);158} finally {159this._ignoreChangeEvent = false;160}161}162}163164type MenuItemGroup = [string, Array<IMenuItem | ISubmenuItem>];165166class MenuInfoSnapshot {167protected _menuGroups: MenuItemGroup[] = [];168private _allMenuIds: Set<MenuId> = new Set();169private _structureContextKeys: Set<string> = new Set();170private _preconditionContextKeys: Set<string> = new Set();171private _toggledContextKeys: Set<string> = new Set();172173constructor(174protected readonly _id: MenuId,175protected readonly _collectContextKeysForSubmenus: boolean,176) {177this.refresh();178}179180get allMenuIds(): ReadonlySet<MenuId> {181return this._allMenuIds;182}183184get structureContextKeys(): ReadonlySet<string> {185return this._structureContextKeys;186}187188get preconditionContextKeys(): ReadonlySet<string> {189return this._preconditionContextKeys;190}191192get toggledContextKeys(): ReadonlySet<string> {193return this._toggledContextKeys;194}195196refresh(): void {197198// reset199this._menuGroups.length = 0;200this._allMenuIds.clear();201this._structureContextKeys.clear();202this._preconditionContextKeys.clear();203this._toggledContextKeys.clear();204205const menuItems = this._sort(MenuRegistry.getMenuItems(this._id));206let group: MenuItemGroup | undefined;207208for (const item of menuItems) {209// group by groupId210const groupName = item.group || '';211if (!group || group[0] !== groupName) {212group = [groupName, []];213this._menuGroups.push(group);214}215group[1].push(item);216217// keep keys and submenu ids for eventing218this._collectContextKeysAndSubmenuIds(item);219}220this._allMenuIds.add(this._id);221}222223protected _sort(menuItems: (IMenuItem | ISubmenuItem)[]) {224// no sorting needed in snapshot225return menuItems;226}227228private _collectContextKeysAndSubmenuIds(item: IMenuItem | ISubmenuItem): void {229230MenuInfoSnapshot._fillInKbExprKeys(item.when, this._structureContextKeys);231232if (isIMenuItem(item)) {233// keep precondition keys for event if applicable234if (item.command.precondition) {235MenuInfoSnapshot._fillInKbExprKeys(item.command.precondition, this._preconditionContextKeys);236}237// keep toggled keys for event if applicable238if (item.command.toggled) {239const toggledExpression: ContextKeyExpression = (item.command.toggled as { condition: ContextKeyExpression }).condition || item.command.toggled;240MenuInfoSnapshot._fillInKbExprKeys(toggledExpression, this._toggledContextKeys);241}242243} else if (this._collectContextKeysForSubmenus) {244// recursively collect context keys from submenus so that this245// menu fires events when context key changes affect submenus246MenuRegistry.getMenuItems(item.submenu).forEach(this._collectContextKeysAndSubmenuIds, this);247248this._allMenuIds.add(item.submenu);249}250}251252private static _fillInKbExprKeys(exp: ContextKeyExpression | undefined, set: Set<string>): void {253if (exp) {254for (const key of exp.keys()) {255set.add(key);256}257}258}259260}261262class MenuInfo extends MenuInfoSnapshot {263264constructor(265_id: MenuId,266private readonly _hiddenStates: PersistedMenuHideState,267_collectContextKeysForSubmenus: boolean,268@ICommandService private readonly _commandService: ICommandService,269@IKeybindingService private readonly _keybindingService: IKeybindingService,270@IContextKeyService private readonly _contextKeyService: IContextKeyService271) {272super(_id, _collectContextKeysForSubmenus);273this.refresh();274}275276createActionGroups(options: IMenuActionOptions | undefined): [string, Array<MenuItemAction | SubmenuItemAction>][] {277const result: [string, Array<MenuItemAction | SubmenuItemAction>][] = [];278279for (const group of this._menuGroups) {280const [id, items] = group;281282let activeActions: Array<MenuItemAction | SubmenuItemAction> | undefined;283for (const item of items) {284if (this._contextKeyService.contextMatchesRules(item.when)) {285const isMenuItem = isIMenuItem(item);286if (isMenuItem) {287this._hiddenStates.setDefaultState(this._id, item.command.id, !!item.isHiddenByDefault);288}289290const menuHide = createMenuHide(this._id, isMenuItem ? item.command : item, this._hiddenStates);291if (isMenuItem) {292// MenuItemAction293const menuKeybinding = createConfigureKeybindingAction(this._commandService, this._keybindingService, item.command.id, item.when);294(activeActions ??= []).push(new MenuItemAction(item.command, item.alt, options, menuHide, menuKeybinding, this._contextKeyService, this._commandService));295} else {296// SubmenuItemAction297const groups = new MenuInfo(item.submenu, this._hiddenStates, this._collectContextKeysForSubmenus, this._commandService, this._keybindingService, this._contextKeyService).createActionGroups(options);298const submenuActions = Separator.join(...groups.map(g => g[1]));299if (submenuActions.length > 0) {300(activeActions ??= []).push(new SubmenuItemAction(item, menuHide, submenuActions));301}302}303}304}305if (activeActions && activeActions.length > 0) {306result.push([id, activeActions]);307}308}309return result;310}311312protected override _sort(menuItems: (IMenuItem | ISubmenuItem)[]): (IMenuItem | ISubmenuItem)[] {313return menuItems.sort(MenuInfo._compareMenuItems);314}315316private static _compareMenuItems(a: IMenuItem | ISubmenuItem, b: IMenuItem | ISubmenuItem): number {317318const aGroup = a.group;319const bGroup = b.group;320321if (aGroup !== bGroup) {322323// Falsy groups come last324if (!aGroup) {325return 1;326} else if (!bGroup) {327return -1;328}329330// 'navigation' group comes first331if (aGroup === 'navigation') {332return -1;333} else if (bGroup === 'navigation') {334return 1;335}336337// lexical sort for groups338const value = aGroup.localeCompare(bGroup);339if (value !== 0) {340return value;341}342}343344// sort on priority - default is 0345const aPrio = a.order || 0;346const bPrio = b.order || 0;347if (aPrio < bPrio) {348return -1;349} else if (aPrio > bPrio) {350return 1;351}352353// sort on titles354return MenuInfo._compareTitles(355isIMenuItem(a) ? a.command.title : a.title,356isIMenuItem(b) ? b.command.title : b.title357);358}359360private static _compareTitles(a: string | ILocalizedString, b: string | ILocalizedString) {361const aStr = typeof a === 'string' ? a : a.original;362const bStr = typeof b === 'string' ? b : b.original;363return aStr.localeCompare(bStr);364}365}366367class MenuImpl implements IMenu {368369private readonly _menuInfo: MenuInfo;370private readonly _disposables = new DisposableStore();371372private readonly _onDidChange: Emitter<IMenuChangeEvent>;373readonly onDidChange: Event<IMenuChangeEvent>;374375constructor(376id: MenuId,377hiddenStates: PersistedMenuHideState,378options: Required<IMenuCreateOptions>,379@ICommandService commandService: ICommandService,380@IKeybindingService keybindingService: IKeybindingService,381@IContextKeyService contextKeyService: IContextKeyService382) {383this._menuInfo = new MenuInfo(id, hiddenStates, options.emitEventsForSubmenuChanges, commandService, keybindingService, contextKeyService);384385// Rebuild this menu whenever the menu registry reports an event for this MenuId.386// This usually happen while code and extensions are loaded and affects the over387// structure of the menu388const rebuildMenuSoon = new RunOnceScheduler(() => {389this._menuInfo.refresh();390this._onDidChange.fire({ menu: this, isStructuralChange: true, isEnablementChange: true, isToggleChange: true });391}, options.eventDebounceDelay);392this._disposables.add(rebuildMenuSoon);393this._disposables.add(MenuRegistry.onDidChangeMenu(e => {394for (const id of this._menuInfo.allMenuIds) {395if (e.has(id)) {396rebuildMenuSoon.schedule();397break;398}399}400}));401402// When context keys or storage state changes we need to check if the menu also has changed. However,403// we only do that when someone listens on this menu because (1) these events are404// firing often and (2) menu are often leaked405const lazyListener = this._disposables.add(new DisposableStore());406407const merge = (events: IMenuChangeEvent[]): IMenuChangeEvent => {408409let isStructuralChange = false;410let isEnablementChange = false;411let isToggleChange = false;412413for (const item of events) {414isStructuralChange = isStructuralChange || item.isStructuralChange;415isEnablementChange = isEnablementChange || item.isEnablementChange;416isToggleChange = isToggleChange || item.isToggleChange;417if (isStructuralChange && isEnablementChange && isToggleChange) {418// everything is TRUE, no need to continue iterating419break;420}421}422423return { menu: this, isStructuralChange, isEnablementChange, isToggleChange };424};425426const startLazyListener = () => {427428lazyListener.add(contextKeyService.onDidChangeContext(e => {429const isStructuralChange = e.affectsSome(this._menuInfo.structureContextKeys);430const isEnablementChange = e.affectsSome(this._menuInfo.preconditionContextKeys);431const isToggleChange = e.affectsSome(this._menuInfo.toggledContextKeys);432if (isStructuralChange || isEnablementChange || isToggleChange) {433this._onDidChange.fire({ menu: this, isStructuralChange, isEnablementChange, isToggleChange });434}435}));436lazyListener.add(hiddenStates.onDidChange(e => {437this._onDidChange.fire({ menu: this, isStructuralChange: true, isEnablementChange: false, isToggleChange: false });438}));439};440441this._onDidChange = new DebounceEmitter({442// start/stop context key listener443onWillAddFirstListener: startLazyListener,444onDidRemoveLastListener: lazyListener.clear.bind(lazyListener),445delay: options.eventDebounceDelay,446merge447});448this.onDidChange = this._onDidChange.event;449}450451getActions(options?: IMenuActionOptions | undefined): [string, (MenuItemAction | SubmenuItemAction)[]][] {452return this._menuInfo.createActionGroups(options);453}454455dispose(): void {456this._disposables.dispose();457this._onDidChange.dispose();458}459}460461function createMenuHide(menu: MenuId, command: ICommandAction | ISubmenuItem, states: PersistedMenuHideState): IMenuItemHide {462463const id = isISubmenuItem(command) ? command.submenu.id : command.id;464const title = typeof command.title === 'string' ? command.title : command.title.value;465466const hide = toAction({467id: `hide/${menu.id}/${id}`,468label: localize('hide.label', 'Hide \'{0}\'', title),469run() { states.updateHidden(menu, id, true); }470});471472const toggle = toAction({473id: `toggle/${menu.id}/${id}`,474label: title,475get checked() { return !states.isHidden(menu, id); },476run() { states.updateHidden(menu, id, !!this.checked); }477});478479return {480hide,481toggle,482get isHidden() { return !toggle.checked; },483};484}485486export function createConfigureKeybindingAction(commandService: ICommandService, keybindingService: IKeybindingService, commandId: string, when: ContextKeyExpression | undefined = undefined, enabled = true): IAction {487return toAction({488id: `configureKeybinding/${commandId}`,489label: localize('configure keybinding', "Configure Keybinding"),490enabled,491run() {492// Only set the when clause when there is no keybinding493// It is possible that the action and the keybinding have different when clauses494const hasKeybinding = !!keybindingService.lookupKeybinding(commandId); // This may only be called inside the `run()` method as it can be expensive on startup. #210529495const whenValue = !hasKeybinding && when ? when.serialize() : undefined;496commandService.executeCommand('workbench.action.openGlobalKeybindings', `@command:${commandId}` + (whenValue ? ` +when:${whenValue}` : ''));497}498});499}500501502