diff --git a/CHANGELOG.md b/CHANGELOG.md index 31cb971f0aaee..422d1bad85daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ ## v0.17.0 +- [core] From now on, one can dynamically create and remove menu actions and arbitrary submenu items at runtime. [#7276](https://github.com/eclipse-theia/theia/pull/7276) - [preferences] add a new preference to silence notifications [#7195](https://github.com/eclipse-theia/theia/pull/7195) Breaking changes: +- [core] Renamed the `browser-menu-plugin.ts` module to `browser-main-menu-factory.ts`. From now on, both the `BrowserMainMenuFactory` and the `ElectronMainMenuFactory` +have a no-args `constructor`. Renamed `BrowserMenuBarContribution` to `BrowserMenuContribution`. `BrowserMenuContribution` was moved to its own `browser-menu-contribution.ts` module. Renamed the `browserMenuBarContribution` field to `browserMenuContribution` in the `MonacoQuickOpenService`. - [scm][git] the History view (GitHistoryWidget) has moved from the git package to a new package, scm-extra, and renamed to ScmHistoryWidget. GitNavigableListWidget has also moved. CSS classes have been moved renamed accordingly. [6381](https://github.com/eclipse-theia/theia/pull/6381) diff --git a/examples/api-tests/src/menus.spec.js b/examples/api-tests/src/menus.spec.js index bbaa8d6547385..7a72776136860 100644 --- a/examples/api-tests/src/menus.spec.js +++ b/examples/api-tests/src/menus.spec.js @@ -17,7 +17,7 @@ // @ts-check describe('Menus', function () { - const { BrowserMenuBarContribution } = require('@theia/core/lib/browser/menu/browser-menu-plugin'); + const { BrowserMenuContribution } = require('@theia/core/lib/browser/menu/browser-menu-contribution'); const { ApplicationShell } = require('@theia/core/lib/browser/shell/application-shell'); const { CallHierarchyContribution } = require('@theia/callhierarchy/lib/browser/callhierarchy-contribution'); const { FileNavigatorContribution } = require('@theia/navigator/lib/browser/navigator-contribution'); @@ -32,8 +32,8 @@ describe('Menus', function () { /** @type {import('inversify').Container} */ const container = window['theia'].container; const shell = container.get(ApplicationShell); - const menuBarContribution = container.get(BrowserMenuBarContribution); - const menuBar = menuBarContribution.menuBar; + const menuContribution = container.get(BrowserMenuContribution); + const menuBar = menuContribution.menuBar; for (const contribution of [ container.get(CallHierarchyContribution), diff --git a/packages/core/src/browser/menu/browser-context-menu-renderer.ts b/packages/core/src/browser/menu/browser-context-menu-renderer.ts index 2a64ffe3f3591..35fde533253c1 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -19,7 +19,7 @@ import { inject, injectable } from 'inversify'; import { MenuPath } from '../../common/menu'; import { ContextMenuRenderer, Anchor, RenderContextMenuOptions } from '../context-menu-renderer'; -import { BrowserMainMenuFactory } from './browser-menu-plugin'; +import { BrowserMainMenuFactory } from './browser-main-menu-factory'; @injectable() export class BrowserContextMenuRenderer implements ContextMenuRenderer { diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-main-menu-factory.ts similarity index 86% rename from packages/core/src/browser/menu/browser-menu-plugin.ts rename to packages/core/src/browser/menu/browser-main-menu-factory.ts index 5784968aed67d..3fc30401053e8 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-main-menu-factory.ts @@ -17,18 +17,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { injectable, inject } from 'inversify'; -import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets'; +import { MenuBar, Menu as MenuWidget } from '@phosphor/widgets'; import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands'; import { CommandRegistry, ActionMenuNode, CompositeMenuNode, - MenuModelRegistry, MAIN_MENU_BAR, MenuPath, ILogger + MenuModelRegistry, MAIN_MENU_BAR, MenuPath } from '../../common'; import { KeybindingRegistry } from '../keybinding'; -import { FrontendApplicationContribution, FrontendApplication } from '../frontend-application'; import { ContextKeyService } from '../context-key-service'; import { ContextMenuContext } from './context-menu-context'; import { waitForRevealed } from '../widgets'; -import { ApplicationShell } from '../shell'; export abstract class MenuBarWidget extends MenuBar { abstract activateMenu(label: string, ...labels: string[]): Promise; @@ -38,20 +36,20 @@ export abstract class MenuBarWidget extends MenuBar { @injectable() export class BrowserMainMenuFactory { - @inject(ILogger) - protected readonly logger: ILogger; - @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(ContextMenuContext) protected readonly context: ContextMenuContext; - constructor( - @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry, - @inject(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry, - @inject(MenuModelRegistry) protected readonly menuProvider: MenuModelRegistry - ) { } + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(KeybindingRegistry) + protected readonly keybindingRegistry: KeybindingRegistry; + + @inject(MenuModelRegistry) + protected readonly menuRegistry: MenuModelRegistry; createMenuBar(): MenuBarWidget { const menuBar = new DynamicMenuBarWidget(); @@ -65,8 +63,8 @@ export class BrowserMainMenuFactory { return menuBar; } - protected fillMenuBar(menuBar: MenuBarWidget): void { - const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR); + fillMenuBar(menuBar: MenuBarWidget): void { + const menuModel = this.menuRegistry.getMenu(MAIN_MENU_BAR); const phosphorCommands = this.createPhosphorCommands(menuModel); // for the main menu we want all items to be visible. phosphorCommands.isVisible = () => true; @@ -80,7 +78,7 @@ export class BrowserMainMenuFactory { } createContextMenu(path: MenuPath, args?: any[]): MenuWidget { - const menuModel = this.menuProvider.getMenu(path); + const menuModel = this.menuRegistry.getMenu(path); const phosphorCommands = this.createPhosphorCommands(menuModel, args); const contextMenu = new DynamicMenuWidget(menuModel, { commands: phosphorCommands }, this.contextKeyService, this.context); @@ -298,32 +296,3 @@ class DynamicMenuWidget extends MenuWidget { } } - -@injectable() -export class BrowserMenuBarContribution implements FrontendApplicationContribution { - - @inject(ApplicationShell) - protected readonly shell: ApplicationShell; - - constructor( - @inject(BrowserMainMenuFactory) protected readonly factory: BrowserMainMenuFactory - ) { } - - onStart(app: FrontendApplication): void { - const logo = this.createLogo(); - app.shell.addWidget(logo, { area: 'top' }); - const menu = this.factory.createMenuBar(); - app.shell.addWidget(menu, { area: 'top' }); - } - - get menuBar(): MenuBarWidget | undefined { - return this.shell.topPanel.widgets.find(w => w instanceof MenuBarWidget) as MenuBarWidget | undefined; - } - - protected createLogo(): Widget { - const logo = new Widget(); - logo.id = 'theia:icon'; - logo.addClass('theia-icon'); - return logo; - } -} diff --git a/packages/core/src/browser/menu/browser-menu-contribution.ts b/packages/core/src/browser/menu/browser-menu-contribution.ts new file mode 100644 index 0000000000000..5c1f3f8355a0b --- /dev/null +++ b/packages/core/src/browser/menu/browser-menu-contribution.ts @@ -0,0 +1,64 @@ +/******************************************************************************** + * 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject } from 'inversify'; +import { Widget } from '@phosphor/widgets'; +import debounce = require('lodash.debounce'); +import { ApplicationShell } from '../shell'; +import { MenuModelRegistry } from '../../common'; +import { FrontendApplicationContribution, FrontendApplication } from '../frontend-application'; +import { BrowserMainMenuFactory, MenuBarWidget } from './browser-main-menu-factory'; + +@injectable() +export class BrowserMenuContribution implements FrontendApplicationContribution { + + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + + @inject(BrowserMainMenuFactory) + protected readonly factory: BrowserMainMenuFactory; + + @inject(MenuModelRegistry) + protected readonly menuRegistry: MenuModelRegistry; + + onStart(app: FrontendApplication): void { + const logo = this.createLogo(); + app.shell.addWidget(logo, { area: 'top' }); + const menuBar = this.factory.createMenuBar(); + app.shell.addWidget(menuBar, { area: 'top' }); + const update = debounce(() => this.update(), 100); + this.menuRegistry.onChanged(update); + } + + get menuBar(): MenuBarWidget | undefined { + return this.shell.topPanel.widgets.find(w => w instanceof MenuBarWidget) as MenuBarWidget | undefined; + } + + protected createLogo(): Widget { + const logo = new Widget(); + logo.id = 'theia:icon'; + logo.addClass('theia-icon'); + return logo; + } + + protected update(): void { + if (this.menuBar) { + this.menuBar.clearMenus(); + this.factory.fillMenuBar(this.menuBar); + } + } + +} diff --git a/packages/core/src/browser/menu/browser-menu-module.ts b/packages/core/src/browser/menu/browser-menu-module.ts index 76ee0f0eb995b..440cd3a06fe90 100644 --- a/packages/core/src/browser/menu/browser-menu-module.ts +++ b/packages/core/src/browser/menu/browser-menu-module.ts @@ -17,12 +17,13 @@ import { ContainerModule } from 'inversify'; import { FrontendApplicationContribution } from '../frontend-application'; import { ContextMenuRenderer } from '../context-menu-renderer'; -import { BrowserMenuBarContribution, BrowserMainMenuFactory } from './browser-menu-plugin'; +import { BrowserMainMenuFactory } from './browser-main-menu-factory'; +import { BrowserMenuContribution } from './browser-menu-contribution'; import { BrowserContextMenuRenderer } from './browser-context-menu-renderer'; export default new ContainerModule(bind => { bind(BrowserMainMenuFactory).toSelf().inSingletonScope(); bind(ContextMenuRenderer).to(BrowserContextMenuRenderer).inSingletonScope(); - bind(BrowserMenuBarContribution).toSelf().inSingletonScope(); - bind(FrontendApplicationContribution).toService(BrowserMenuBarContribution); + bind(BrowserMenuContribution).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(BrowserMenuContribution); }); diff --git a/packages/core/src/common/menu.ts b/packages/core/src/common/menu.ts index d3250ba7ee2c2..7143200aa1ed8 100644 --- a/packages/core/src/common/menu.ts +++ b/packages/core/src/common/menu.ts @@ -16,6 +16,7 @@ import { injectable, inject, named } from 'inversify'; import { Disposable } from './disposable'; +import { Event, Emitter } from './event'; import { CommandRegistry, Command } from './command'; import { ContributionProvider } from './contribution-provider'; @@ -41,7 +42,7 @@ export namespace MenuAction { } export interface SubMenuOptions { - iconClass: string + iconClass: string | undefined; } export type MenuPath = string[]; @@ -64,6 +65,7 @@ export interface MenuContribution { @injectable() export class MenuModelRegistry { protected readonly root = new CompositeMenuNode(''); + protected readonly onChangedEmitter = new Emitter(); constructor( @inject(ContributionProvider) @named(MenuContribution) @@ -80,7 +82,9 @@ export class MenuModelRegistry { registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { const parent = this.findGroup(menuPath); const actionNode = new ActionMenuNode(item, this.commands); - return parent.addNode(actionNode); + const toDispose = parent.addNode(actionNode); + this.fireChanged(); + return toDispose; } registerSubmenu(menuPath: MenuPath, label: string, options?: SubMenuOptions): Disposable { @@ -94,15 +98,25 @@ export class MenuModelRegistry { let groupNode = this.findSubMenu(parent, menuId); if (!groupNode) { groupNode = new CompositeMenuNode(menuId, label, options ? options.iconClass : undefined); - return parent.addNode(groupNode); + const toDispose = parent.addNode(groupNode); + this.fireChanged(); + return toDispose; } else { + let hasChanged = false; if (!groupNode.label) { + hasChanged = true; groupNode.label = label; } else if (groupNode.label !== label) { throw new Error("The group '" + menuPath.join('/') + "' already has a different label."); } - if (!groupNode.iconClass && options) { - groupNode.iconClass = options.iconClass; + if (options) { + if (groupNode.iconClass !== options.iconClass) { + groupNode.iconClass = options.iconClass; + hasChanged = true; + } + } + if (hasChanged) { + this.fireChanged(); } return { dispose: () => { } }; } @@ -133,7 +147,10 @@ export class MenuModelRegistry { if (menuPath) { const parent = this.findGroup(menuPath); - parent.removeNode(id); + const hasChanged = parent.removeNode(id); + if (hasChanged) { + this.fireChanged(); + } return; } @@ -141,7 +158,10 @@ export class MenuModelRegistry { const recurse = (root: CompositeMenuNode) => { root.children.forEach(node => { if (node instanceof CompositeMenuNode) { - node.removeNode(id); + const hasChanged = node.removeNode(id); + if (hasChanged) { + this.fireChanged(); + } recurse(node); } }); @@ -173,6 +193,18 @@ export class MenuModelRegistry { getMenu(menuPath: MenuPath = []): CompositeMenuNode { return this.findGroup(menuPath); } + + protected fireChanged(): void { + this.onChangedEmitter.fire(); + } + + /** + * An `onChanged` event is emitted when either the menu parentage or one of the menu node's property has changed. + */ + get onChanged(): Event { + return this.onChangedEmitter.event; + } + } export interface MenuNode { @@ -225,14 +257,19 @@ export class CompositeMenuNode implements MenuNode { }; } - public removeNode(id: string): void { + /** + * `true` if the composite node contained a child node with this `id`. Otherwise, `false`. + */ + public removeNode(id: string): boolean { const node = this._children.find(n => n.id === id); if (node) { const idx = this._children.indexOf(node); if (idx >= 0) { this._children.splice(idx, 1); + return true; } } + return false; } get sortString(): string { diff --git a/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts b/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts index 2e55f3e0f18b8..1d3ff28b68c09 100644 --- a/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts +++ b/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts @@ -24,60 +24,41 @@ import { } from '../../common'; import { PreferenceService, KeybindingRegistry, Keybinding } from '../../browser'; import { ContextKeyService } from '../../browser/context-key-service'; -import debounce = require('lodash.debounce'); import { ContextMenuContext } from '../../browser/menu/context-menu-context'; @injectable() export class ElectronMainMenuFactory { - protected _menu: Electron.Menu | undefined; - protected _toggledCommands: Set = new Set(); - @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(ContextMenuContext) protected readonly context: ContextMenuContext; - constructor( - @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry, - @inject(PreferenceService) protected readonly preferencesService: PreferenceService, - @inject(MenuModelRegistry) protected readonly menuProvider: MenuModelRegistry, - @inject(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry - ) { - preferencesService.onPreferenceChanged(debounce(() => { - if (this._menu) { - for (const item of this._toggledCommands) { - this._menu.getMenuItemById(item).checked = this.commandRegistry.isToggled(item); - } - electron.remote.getCurrentWindow().setMenu(this._menu); - } - }, 10)); - keybindingRegistry.onKeybindingsChanged(() => { - const createdMenuBar = this.createMenuBar(); - if (isOSX) { - electron.remote.Menu.setApplicationMenu(createdMenuBar); - } else { - electron.remote.getCurrentWindow().setMenu(createdMenuBar); - } - }); - } + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(PreferenceService) + protected readonly preferencesService: PreferenceService; + + @inject(MenuModelRegistry) + protected readonly menuModelRegistry: MenuModelRegistry; + + @inject(KeybindingRegistry) + protected readonly keybindingRegistry: KeybindingRegistry; createMenuBar(): Electron.Menu { - const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR); + const menuModel = this.menuModelRegistry.getMenu(MAIN_MENU_BAR); const template = this.fillMenuTemplate([], menuModel); if (isOSX) { template.unshift(this.createOSXMenu()); } - const menu = electron.remote.Menu.buildFromTemplate(template); - this._menu = menu; - return menu; + return electron.remote.Menu.buildFromTemplate(template); } createContextMenu(menuPath: MenuPath, args?: any[]): Electron.Menu { - const menuModel = this.menuProvider.getMenu(menuPath); + const menuModel = this.menuModelRegistry.getMenu(menuPath); const template = this.fillMenuTemplate([], menuModel, args); - return electron.remote.Menu.buildFromTemplate(template); } @@ -156,9 +137,6 @@ export class ElectronMainMenuFactory { click: () => this.execute(commandId, args), accelerator }); - if (this.commandRegistry.getToggledHandler(commandId, ...args)) { - this._toggledCommands.add(commandId); - } } } return items; @@ -189,10 +167,6 @@ export class ElectronMainMenuFactory { // We need to check if we can execute it. if (this.commandRegistry.isEnabled(command, ...args)) { await this.commandRegistry.executeCommand(command, ...args); - if (this._menu && this.commandRegistry.isVisible(command, ...args)) { - this._menu.getMenuItemById(command).checked = this.commandRegistry.isToggled(command, ...args); - electron.remote.getCurrentWindow().setMenu(this._menu); - } } } catch { // no-op diff --git a/packages/core/src/electron-browser/menu/electron-menu-contribution.ts b/packages/core/src/electron-browser/menu/electron-menu-contribution.ts index 91ce52b1f1b63..e8f23ea4e43e7 100644 --- a/packages/core/src/electron-browser/menu/electron-menu-contribution.ts +++ b/packages/core/src/electron-browser/menu/electron-menu-contribution.ts @@ -16,11 +16,12 @@ import * as electron from 'electron'; import { inject, injectable } from 'inversify'; +import debounce = require('lodash.debounce'); import { Command, CommandContribution, CommandRegistry, isOSX, isWindows, MenuModelRegistry, MenuContribution, Disposable } from '../../common'; -import { KeybindingContribution, KeybindingRegistry } from '../../browser'; +import { KeybindingContribution, KeybindingRegistry, PreferenceService } from '../../browser'; import { FrontendApplication, FrontendApplicationContribution, CommonMenus } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { FrontendApplicationStateService, FrontendApplicationState } from '../../browser/frontend-application-state'; @@ -71,25 +72,45 @@ export class ElectronMenuContribution implements FrontendApplicationContribution @inject(FrontendApplicationStateService) protected readonly stateService: FrontendApplicationStateService; - constructor( - @inject(ElectronMainMenuFactory) protected readonly factory: ElectronMainMenuFactory - ) { } + @inject(ElectronMainMenuFactory) + protected readonly factory: ElectronMainMenuFactory; + + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(PreferenceService) + protected readonly preferencesService: PreferenceService; + + @inject(MenuModelRegistry) + protected readonly menuModelRegistry: MenuModelRegistry; + + @inject(KeybindingRegistry) + protected readonly keybindingRegistry: KeybindingRegistry; onStart(app: FrontendApplication): void { + const update = debounce(() => this.update(), 100); + this.preferencesService.onPreferenceChanged(update); + this.keybindingRegistry.onKeybindingsChanged(update); + this.menuModelRegistry.onChanged(update); + this.commandRegistry.onWillExecuteCommand(({ commandId }) => { + if (this.commandRegistry.getToggledHandler(commandId)) { + update(); + } + }); this.hideTopPanel(app); - this.setMenu(); + this.update(); if (isOSX) { // OSX: Recreate the menus when changing windows. // OSX only has one menu bar for all windows, so we need to swap // between them as the user switches windows. - electron.remote.getCurrentWindow().on('focus', () => this.setMenu()); + electron.remote.getCurrentWindow().on('focus', () => this.update()); } // Make sure the application menu is complete, once the frontend application is ready. // https://github.com/theia-ide/theia/issues/5100 let onStateChange: Disposable | undefined = undefined; const stateServiceListener = (state: FrontendApplicationState) => { if (state === 'ready') { - this.setMenu(); + this.update(); } if (state === 'closing_window') { if (!!onStateChange) { @@ -120,7 +141,9 @@ export class ElectronMenuContribution implements FrontendApplicationContribution } } - private setMenu(menu: electron.Menu = this.factory.createMenuBar(), electronWindow: electron.BrowserWindow = electron.remote.getCurrentWindow()): void { + protected update(): void { + const menu = this.factory.createMenuBar(); + const electronWindow = electron.remote.getCurrentWindow(); if (isOSX) { electron.remote.Menu.setApplicationMenu(menu); } else { diff --git a/packages/monaco/src/browser/monaco-quick-open-service.ts b/packages/monaco/src/browser/monaco-quick-open-service.ts index 44e0fda2545c5..278302b6a9e65 100644 --- a/packages/monaco/src/browser/monaco-quick-open-service.ts +++ b/packages/monaco/src/browser/monaco-quick-open-service.ts @@ -25,7 +25,7 @@ import { ContextKey } from '@theia/core/lib/browser/context-key-service'; import { MonacoContextKeyService } from './monaco-context-key-service'; import { QuickOpenHideReason } from '@theia/core/lib/common/quick-open-service'; import { MonacoResolvedKeybinding } from './monaco-resolved-keybinding'; -import { BrowserMenuBarContribution } from '@theia/core/lib/browser/menu/browser-menu-plugin'; +import { BrowserMenuContribution } from '@theia/core/lib/browser/menu/browser-menu-contribution'; export interface MonacoQuickOpenControllerOpts extends monaco.quickOpen.IQuickOpenControllerOpts { valueSelection?: Readonly<[number, number]>; @@ -52,8 +52,8 @@ export class MonacoQuickOpenService extends QuickOpenService { @inject(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry; - @inject(BrowserMenuBarContribution) @optional() - protected readonly browserMenuBarContribution?: BrowserMenuBarContribution; + @inject(BrowserMenuContribution) @optional() + protected readonly browserMenuContribution?: BrowserMenuContribution; protected inQuickOpenKey: ContextKey; @@ -117,9 +117,9 @@ export class MonacoQuickOpenService extends QuickOpenService { } internalOpen(opts: MonacoQuickOpenControllerOpts): void { - const browserMenuBarContribution = this.browserMenuBarContribution; - if (browserMenuBarContribution) { - const browserMenuBar = browserMenuBarContribution.menuBar; + const browserMenuContribution = this.browserMenuContribution; + if (browserMenuContribution) { + const browserMenuBar = browserMenuContribution.menuBar; if (browserMenuBar) { const activeMenu = browserMenuBar.activeMenu; if (activeMenu) {