UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

391 lines (356 loc) • 14.9 kB
// ***************************************************************************** // Copyright (C) 2017 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { inject, injectable, named } from 'inversify'; import { CommandMenu, CompoundMenuNode, Group, MAIN_MENU_BAR, MenuAction, MenuNode, MenuPath, MutableCompoundMenuNode, Submenu } from './menu-types'; import { Event } from 'vscode-languageserver-protocol'; import { ContributionProvider } from '../contribution-provider'; import { Command, CommandRegistry } from '../command'; import { Emitter } from '../event'; import { Disposable } from '../disposable'; export const MenuContribution = Symbol('MenuContribution'); /** * Representation of a menu contribution. * * Note that there are also convenience classes which combine multiple contributions into one. * For example to register a view together with a menu and keybinding you could use * {@link AbstractViewContribution} instead. * * ### Example usage * * ```ts * import { MenuContribution, MenuModelRegistry, MAIN_MENU_BAR } from '@theia/core'; * * @injectable() * export class NewMenuContribution implements MenuContribution { * registerMenus(menus: MenuModelRegistry): void { * const menuPath = [...MAIN_MENU_BAR, '99_mymenu']; * menus.registerSubmenu(menuPath, 'My Menu'); * * menus.registerMenuAction(menuPath, { * commandId: MyCommand.id, * label: 'My Action' * }); * } * } * ``` */ export interface MenuContribution { /** * Registers menus. * @param menus the menu model registry. */ registerMenus(menus: MenuModelRegistry): void; } export enum ChangeKind { ADDED, REMOVED, CHANGED, LINKED } export interface MenuChangedEvent { kind: ChangeKind; path: MenuPath } export interface StructuralMenuChange extends MenuChangedEvent { kind: ChangeKind.ADDED | ChangeKind.REMOVED | ChangeKind.LINKED; affectedChildId: string } export namespace StructuralMenuChange { export function is(evt: MenuChangedEvent): evt is StructuralMenuChange { return evt.kind !== ChangeKind.CHANGED; } } export const MenuNodeFactory = Symbol('MenuNodeFactory'); export interface MenuNodeFactory { createGroup(id: string, orderString?: string, when?: string): Group & MutableCompoundMenuNode; createCommandMenu(item: MenuAction): CommandMenu; createSubmenu(id: string, label: string, contextKeyOverlays: Record<string, string> | undefined, orderString?: string, icon?: string, when?: string): Submenu & MutableCompoundMenuNode createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode; } /** * The MenuModelRegistry allows to register and unregister menus, submenus and actions * via strings and {@link MenuAction}s without the need to access the underlying UI * representation. */ @injectable() export class MenuModelRegistry { protected root: Group & MutableCompoundMenuNode; protected readonly onDidChangeEmitter = new Emitter<MenuChangedEvent>(); constructor( @inject(ContributionProvider) @named(MenuContribution) protected readonly contributions: ContributionProvider<MenuContribution>, @inject(CommandRegistry) protected readonly commands: CommandRegistry, @inject(MenuNodeFactory) protected readonly menuNodeFactory: MenuNodeFactory) { this.root = this.menuNodeFactory.createGroup('root', 'root'); this.root.addNode(this.menuNodeFactory.createGroup(MAIN_MENU_BAR[0])); } get onDidChange(): Event<MenuChangedEvent> { return this.onDidChangeEmitter.event; } protected isReady = false; onStart(): void { for (const contrib of this.contributions.getContributions()) { contrib.registerMenus(this); } this.isReady = true; } /** * Adds the given menu action to the menu denoted by the given path. * * @returns a disposable which, when called, will remove the menu action again. */ registerCommandMenu(menuPath: MenuPath, item: CommandMenu): Disposable { const parent = this.root.getOrCreate(menuPath, 0, menuPath.length); parent.addNode(item); return Disposable.create(() => { parent.removeNode(item); this.fireChangeEvent({ kind: ChangeKind.REMOVED, path: menuPath.slice(0, menuPath.length - 1), affectedChildId: item.id }); }); } /** * Adds the given menu action to the menu denoted by the given path. * * @returns a disposable which, when called, will remove the menu action again. */ registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { const parent = this.root.getOrCreate(menuPath, 0, menuPath.length); const node = this.menuNodeFactory.createCommandMenu(item); parent.addNode(node); return Disposable.create(() => { parent.removeNode(node); this.fireChangeEvent({ kind: ChangeKind.REMOVED, path: menuPath.slice(0, menuPath.length - 1), affectedChildId: node.id }); }); } /** * Register a new menu at the given path with the given label. * (If the menu already exists without a label, iconClass or order this method can be used to set them.) * * @param menuPath the path for which a new submenu shall be registered. * @param label the label to be used for the new submenu. * @param options optionally allows to set an icon class and specify the order of the new menu. * * @returns if the menu was successfully created a disposable will be returned which, * when called, will remove the menu again. If the menu already existed a no-op disposable * will be returned. * * Note that if the menu already existed and was registered with a different label an error * will be thrown. */ registerSubmenu(menuPath: MenuPath, label: string, options: { sortString?: string, icon?: string, when?: string, contextKeyOverlay?: Record<string, string> } = {}): Disposable { const { contextKeyOverlay, sortString, icon, when } = options; const parent = this.root.getOrCreate(menuPath, 0, menuPath.length - 1); const existing = parent.children.find(node => node.id === menuPath[menuPath.length - 1]); if (Group.is(existing)) { parent.removeNode(existing); const newMenu = this.menuNodeFactory.createSubmenu(menuPath[menuPath.length - 1], label, contextKeyOverlay, sortString, icon, when); newMenu.addNode(...existing.children); parent.addNode(newMenu); this.fireChangeEvent({ kind: ChangeKind.CHANGED, path: menuPath }); return Disposable.create(() => { parent.removeNode(newMenu); this.fireChangeEvent({ kind: ChangeKind.REMOVED, path: menuPath.slice(0, menuPath.length - 1), affectedChildId: newMenu.id }); }); } else { const newMenu = this.menuNodeFactory.createSubmenu(menuPath[menuPath.length - 1], label, contextKeyOverlay, sortString, icon, when); parent.addNode(newMenu); this.fireChangeEvent({ kind: ChangeKind.ADDED, path: menuPath.slice(0, menuPath.length - 1), affectedChildId: newMenu.id }); return Disposable.create(() => { parent.removeNode(newMenu); this.fireChangeEvent({ kind: ChangeKind.REMOVED, path: menuPath.slice(0, menuPath.length - 1), affectedChildId: newMenu.id }); }); } } linkCompoundMenuNode(params: { newParentPath: MenuPath, submenuPath: MenuPath, order?: string, when?: string }): Disposable { const { newParentPath, submenuPath, order, when } = params; // add a wrapper here let i = 0; while (i < newParentPath.length && i < submenuPath.length && newParentPath[i] === submenuPath[i]) { i++; } if (i === newParentPath.length || i === submenuPath.length) { throw new Error(`trying to recursively link ${JSON.stringify(submenuPath)} into ${JSON.stringify(newParentPath)}`); } const child = this.getMenu(submenuPath) as Submenu; if (!child) { throw new Error(`Not a menu node: ${JSON.stringify(submenuPath)}`); } const newParent = this.root.getOrCreate(newParentPath, 0, newParentPath.length); if (MutableCompoundMenuNode.is(newParent)) { const link = this.menuNodeFactory.createSubmenuLink(child, order, when); newParent.addNode(link); this.fireChangeEvent({ kind: ChangeKind.LINKED, path: newParentPath, affectedChildId: child.id }); return Disposable.create(() => { newParent.removeNode(link); this.fireChangeEvent({ kind: ChangeKind.REMOVED, path: newParentPath, affectedChildId: child.id }); }); } else { throw new Error(`Not a compound menu node: ${JSON.stringify(newParentPath)}`); } } /** * Unregister all menu nodes with the same id as the given menu action. * * @param item the item whose id will be used. * @param menuPath if specified only nodes within the path will be unregistered. */ unregisterMenuAction(item: MenuAction, menuPath?: MenuPath): void; /** * Unregister all menu nodes with the same id as the given command. * * @param command the command whose id will be used. * @param menuPath if specified only nodes within the path will be unregistered. */ unregisterMenuAction(command: Command, menuPath?: MenuPath): void; /** * Unregister all menu nodes with the given id. * * @param id the id which shall be removed. * @param menuPath if specified only nodes within the path will be unregistered. */ unregisterMenuAction(id: string, menuPath?: MenuPath): void; unregisterMenuAction(itemOrCommandOrId: MenuAction | Command | string, menuPath: MenuPath = []): void { const id = MenuAction.is(itemOrCommandOrId) ? itemOrCommandOrId.commandId : Command.is(itemOrCommandOrId) ? itemOrCommandOrId.id : itemOrCommandOrId; const parent = this.findInNode(this.root, menuPath, 0); if (parent) { this.removeActionInSubtree(parent, id); } } protected removeActionInSubtree(parent: MenuNode, id: string): void { if (MutableCompoundMenuNode.is(parent) && CompoundMenuNode.is(parent)) { const action = parent.children.find(child => child.id === id); if (action) { parent.removeNode(action); } parent.children.forEach(child => this.removeActionInSubtree(child, id)); } } protected findInNode(root: MenuNode, menuPath: MenuPath, pathIndex: number): MenuNode | undefined { if (pathIndex === menuPath.length) { return root; } if (CompoundMenuNode.is(root)) { const child = root.children.find(c => c.id === menuPath[pathIndex]); if (child) { return this.findInNode(child, menuPath, pathIndex + 1); } } return undefined; } getMenuNode(menuPath: string[]): MenuNode | undefined { return this.findInNode(this.root, menuPath, 0); } getMenu(menuPath: MenuPath): CompoundMenuNode | undefined { const node = this.getMenuNode(menuPath); if (!node) { return undefined; } if (!CompoundMenuNode.is(node)) { throw new Error(`not a compound menu node: ${JSON.stringify(menuPath)}`); } return node; } static removeSingleRootNodes(fullMenuModel: CompoundMenuNode): CompoundMenuNode { let current = fullMenuModel; let previous = undefined; while (current !== previous) { previous = current; current = this.removeSingleRootNode(current); } return current; } /** * Checks the given menu model whether it will show a menu with a single submenu. * * @param fullMenuModel the menu model to analyze * @param menuPath the menu's path * @returns if the menu will show a single submenu this returns a menu that will show the child elements of the submenu, * otherwise the given `fullMenuModel` is return */ static removeSingleRootNode(fullMenuModel: CompoundMenuNode): CompoundMenuNode { let singleChild = undefined; for (const child of fullMenuModel.children) { if (CompoundMenuNode.is(child)) { if (!MenuModelRegistry.isEmpty(child)) { if (singleChild) { return fullMenuModel; } else { singleChild = child; } } } else { return fullMenuModel; } } return singleChild || fullMenuModel; } static isEmpty(node: MenuNode): boolean { if (CompoundMenuNode.is(node)) { if (node.children.length === 0) { return true; } for (const child of node.children) { if (!MenuModelRegistry.isEmpty(child)) { return false; } } } else { return false; } return true; } protected fireChangeEvent<T extends MenuChangedEvent>(evt: T): void { if (this.isReady) { this.onDidChangeEmitter.fire(evt); } } }