From e147414dfd2e96d4df8f5cc629e0049cf6e74e6e Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 7 Jun 2022 17:08:37 -0600 Subject: [PATCH 01/24] Refactor PluginMenuContributionHandler - Adds support for `when` contexts in submenus - Adds support for treating menu contributions as toolbar contributions --- .../menu/sample-browser-menu-module.ts | 8 +- .../core/src/browser/context-key-service.ts | 2 +- .../core/src/browser/context-menu-renderer.ts | 5 + .../browser/frontend-application-module.ts | 8 +- .../menu/browser-context-menu-renderer.ts | 4 +- .../src/browser/menu/browser-menu-plugin.ts | 81 +-- .../src/browser/shell/tab-bar-toolbar.tsx | 146 +++- packages/core/src/common/index.ts | 1 + packages/core/src/common/menu-adapter.ts | 103 +++ packages/core/src/common/menu.ts | 101 ++- .../menu/electron-context-menu-renderer.ts | 4 +- .../menu/electron-main-menu-factory.ts | 48 +- .../plugin-vscode-commands-contribution.ts | 2 +- .../plugin-ext/src/common/plugin-protocol.ts | 1 + .../menus/menus-contribution-handler.ts | 675 +++--------------- .../menus/plugin-menu-command-adapter.ts | 256 +++++++ .../menus/vscode-theia-menu-mappings.ts | 84 +++ .../browser/plugin-ext-frontend-module.ts | 5 +- packages/scm/src/browser/scm-tree-widget.tsx | 6 +- 19 files changed, 845 insertions(+), 695 deletions(-) create mode 100644 packages/core/src/common/menu-adapter.ts create mode 100644 packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts create mode 100644 packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts diff --git a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts index 3efae844355a7..9bf5e2fd620fc 100644 --- a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts +++ b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts @@ -17,7 +17,7 @@ import { injectable, ContainerModule } from '@theia/core/shared/inversify'; import { Menu as MenuWidget } from '@theia/core/shared/@phosphor/widgets'; import { Disposable } from '@theia/core/lib/common/disposable'; -import { MenuNode, CompositeMenuNode } from '@theia/core/lib/common/menu'; +import { MenuNode, CompositeMenuNode, MenuPath } from '@theia/core/lib/common/menu'; import { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget } from '@theia/core/lib/browser/menu/browser-menu-plugin'; import { PlaceholderMenuNode } from './sample-menu-contribution'; @@ -41,7 +41,7 @@ class SampleBrowserMainMenuFactory extends BrowserMainMenuFactory { return menuCommandRegistry; } - override createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): DynamicMenuWidget { + override createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry, rootMenuPath: MenuPath }): DynamicMenuWidget { return new SampleDynamicMenuWidget(menu, options, this.services); } @@ -60,8 +60,8 @@ class SampleMenuCommandRegistry extends MenuCommandRegistry { this.placeholders.set(id, menu); } - override snapshot(): this { - super.snapshot(); + override snapshot(menuPath: MenuPath): this { + super.snapshot(menuPath); for (const menu of this.placeholders.values()) { this.toDispose.push(this.registerPlaceholder(menu)); } diff --git a/packages/core/src/browser/context-key-service.ts b/packages/core/src/browser/context-key-service.ts index d81f37c213c96..801c2afb40311 100644 --- a/packages/core/src/browser/context-key-service.ts +++ b/packages/core/src/browser/context-key-service.ts @@ -34,7 +34,7 @@ export namespace ContextKey { } export interface ContextKeyChangeEvent { - affects(keys: Set): boolean; + affects(keys: { has(key: string): boolean }): boolean; } export const ContextKeyService = Symbol('ContextKeyService'); diff --git a/packages/core/src/browser/context-menu-renderer.ts b/packages/core/src/browser/context-menu-renderer.ts index 9ab830a6ec4c7..a996a2a948846 100644 --- a/packages/core/src/browser/context-menu-renderer.ts +++ b/packages/core/src/browser/context-menu-renderer.ts @@ -112,5 +112,10 @@ export interface RenderContextMenuOptions { * Default is `true`. */ includeAnchorArg?: boolean; + /** + * A DOM context to use when evaluating any `when` clauses + * of menu items registered for this item. + */ + context?: HTMLElement; onHide?: () => void; } diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 2dd97b10385a3..d04b95f077c87 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -31,7 +31,11 @@ import { InMemoryResources, messageServicePath, InMemoryTextResourceResolver, - UntitledResourceResolver + UntitledResourceResolver, + MenuCommandAdapterRegistry, + MenuCommandExecutor, + MenuCommandAdapterRegistryImpl, + MenuCommandExecutorImpl } from '../common'; import { KeybindingRegistry, KeybindingContext, KeybindingContribution } from './keybinding'; import { FrontendApplication, FrontendApplicationContribution, DefaultFrontendApplicationContribution } from './frontend-application'; @@ -241,6 +245,8 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is bind(MenuModelRegistry).toSelf().inSingletonScope(); bindContributionProvider(bind, MenuContribution); + bind(MenuCommandAdapterRegistry).to(MenuCommandAdapterRegistryImpl).inSingletonScope(); + bind(MenuCommandExecutor).to(MenuCommandExecutorImpl).inSingletonScope(); bind(KeyboardLayoutService).toSelf().inSingletonScope(); bind(KeybindingRegistry).toSelf().inSingletonScope(); 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 d33659025286a..469c08bc69408 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -36,8 +36,8 @@ export class BrowserContextMenuRenderer extends ContextMenuRenderer { super(); } - protected doRender({ menuPath, anchor, args, onHide }: RenderContextMenuOptions): ContextMenuAccess { - const contextMenu = this.menuFactory.createContextMenu(menuPath, args); + protected doRender({ menuPath, anchor, args, onHide, context }: RenderContextMenuOptions): ContextMenuAccess { + const contextMenu = this.menuFactory.createContextMenu(menuPath, args, context); const { x, y } = coordinateFromAnchor(anchor); if (onHide) { contextMenu.aboutToClose.connect(() => onHide!()); diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index ebf6b6e82ba06..2d05fed71fd73 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -19,7 +19,7 @@ import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets'; import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands'; import { CommandRegistry, ActionMenuNode, CompositeMenuNode, environment, - MenuModelRegistry, MAIN_MENU_BAR, MenuPath, DisposableCollection, Disposable, MenuNode + MenuModelRegistry, MAIN_MENU_BAR, MenuPath, DisposableCollection, Disposable, MenuNode, MenuCommandExecutor } from '../../common'; import { KeybindingRegistry } from '../keybinding'; import { FrontendApplicationContribution, FrontendApplication } from '../frontend-application'; @@ -47,6 +47,9 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; + @inject(MenuCommandExecutor) + protected readonly menuCommandExecutor: MenuCommandExecutor; + @inject(CorePreferences) protected readonly corePreferences: CorePreferences; @@ -92,41 +95,38 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { const menuCommandRegistry = this.createMenuCommandRegistry(menuModel); for (const menu of menuModel.children) { if (menu instanceof CompositeMenuNode) { - const menuWidget = this.createMenuWidget(menu, { commands: menuCommandRegistry }); + const menuWidget = this.createMenuWidget(menu, { commands: menuCommandRegistry, rootMenuPath: MAIN_MENU_BAR }); menuBar.addMenu(menuWidget); } } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createContextMenu(path: MenuPath, args?: any[]): MenuWidget { + createContextMenu(path: MenuPath, args?: unknown[], context?: HTMLElement): MenuWidget { const menuModel = this.menuProvider.getMenu(path); - const menuCommandRegistry = this.createMenuCommandRegistry(menuModel, args).snapshot(); - const contextMenu = this.createMenuWidget(menuModel, { commands: menuCommandRegistry }); + const menuCommandRegistry = this.createMenuCommandRegistry(menuModel, args).snapshot(path); + const contextMenu = this.createMenuWidget(menuModel, { commands: menuCommandRegistry, context, rootMenuPath: path }); return contextMenu; } - createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): DynamicMenuWidget { + createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry, context?: HTMLElement, rootMenuPath: MenuPath }): DynamicMenuWidget { return new DynamicMenuWidget(menu, options, this.services); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected createMenuCommandRegistry(menu: CompositeMenuNode, args: any[] = []): MenuCommandRegistry { + protected createMenuCommandRegistry(menu: CompositeMenuNode, args: unknown[] = []): MenuCommandRegistry { const menuCommandRegistry = new MenuCommandRegistry(this.services); this.registerMenu(menuCommandRegistry, menu, args); return menuCommandRegistry; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: CompositeMenuNode, args: any[]): void { + protected registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode & { children: ReadonlyArray }, args: unknown[]): void { for (const child of menu.children) { if (child instanceof ActionMenuNode) { menuCommandRegistry.registerActionMenu(child, args); if (child.altNode) { menuCommandRegistry.registerActionMenu(child.altNode, args); } - } else if (child instanceof CompositeMenuNode) { - this.registerMenu(menuCommandRegistry, child, args); + } else if (Array.isArray(child.children)) { + this.registerMenu(menuCommandRegistry, child as MenuNode & { children: MenuNode[] }, args); } else { this.handleDefault(menuCommandRegistry, child, args); } @@ -144,7 +144,8 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { contextKeyService: this.contextKeyService, commandRegistry: this.commandRegistry, keybindingRegistry: this.keybindingRegistry, - menuWidgetFactory: this + menuWidgetFactory: this, + commandExecutor: this.menuCommandExecutor, }; } @@ -225,10 +226,11 @@ export class MenuServices { readonly contextKeyService: ContextKeyService; readonly context: ContextMenuContext; readonly menuWidgetFactory: MenuWidgetFactory; + readonly commandExecutor: MenuCommandExecutor; } export interface MenuWidgetFactory { - createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): MenuWidget; + createMenuWidget(menu: MenuNode & Required>, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): MenuWidget; } /** @@ -243,7 +245,7 @@ export class DynamicMenuWidget extends MenuWidget { constructor( protected menu: CompositeMenuNode, - protected options: MenuWidget.IOptions & { commands: MenuCommandRegistry }, + protected options: MenuWidget.IOptions & { commands: MenuCommandRegistry, context?: HTMLElement, rootMenuPath: MenuPath }, protected services: MenuServices ) { super(options); @@ -260,7 +262,7 @@ export class DynamicMenuWidget extends MenuWidget { this.preserveFocusedElement(previousFocusedElement); this.clearItems(); this.runWithPreservedFocusContext(() => { - this.options.commands.snapshot(); + this.options.commands.snapshot(this.options.rootMenuPath); this.updateSubMenus(this, this.menu, this.options.commands); }); } @@ -282,12 +284,12 @@ export class DynamicMenuWidget extends MenuWidget { } } - private buildSubMenus(items: MenuWidget.IItemOptions[], menu: CompositeMenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { - for (const item of menu.children) { - if (item instanceof CompositeMenuNode) { + private buildSubMenus(items: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { + for (const item of (menu.children ?? [])) { + if (Array.isArray(item.children)) { if (item.children.length) { // do not render empty nodes if (item.isSubmenu) { // submenu node - const submenu = this.services.menuWidgetFactory.createMenuWidget(item, this.options); + const submenu = this.services.menuWidgetFactory.createMenuWidget(item as MenuNode & { children: MenuNode[] }, this.options); if (!submenu.items.length) { continue; } @@ -296,6 +298,9 @@ export class DynamicMenuWidget extends MenuWidget { submenu, }); } else { // group node + if (item.id === 'inline') { + continue; + } const submenu = this.buildSubMenus([], item, commands); if (!submenu.length) { continue; @@ -312,13 +317,12 @@ export class DynamicMenuWidget extends MenuWidget { const { context, contextKeyService } = this.services; const node = item.altNode && context.altPressed ? item.altNode : item; const { when } = node.action; - if (!(commands.isVisible(node.action.commandId) && (!when || contextKeyService.match(when)))) { - continue; + if (commands.isVisible(node.action.commandId) && (!when || contextKeyService.match(when, this.options.context))) { + items.push({ + command: node.action.commandId, + type: 'command' + }); } - items.push({ - command: node.action.commandId, - type: 'command' - }); } else { items.push(...this.handleDefault(item)); } @@ -418,16 +422,14 @@ export class BrowserMenuBarContribution implements FrontendApplicationContributi */ export class MenuCommandRegistry extends PhosphorCommandRegistry { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected actions = new Map(); + protected actions = new Map(); protected toDispose = new DisposableCollection(); constructor(protected services: MenuServices) { super(); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - registerActionMenu(menu: ActionMenuNode, args: any[]): void { + registerActionMenu(menu: ActionMenuNode, args: unknown[]): void { const { commandId } = menu.action; const { commandRegistry } = this.services; const command = commandRegistry.getCommand(commandId); @@ -441,17 +443,16 @@ export class MenuCommandRegistry extends PhosphorCommandRegistry { this.actions.set(id, [menu, args]); } - snapshot(): this { + snapshot(menuPath: MenuPath): this { this.toDispose.dispose(); for (const [menu, args] of this.actions.values()) { - this.toDispose.push(this.registerCommand(menu, args)); + this.toDispose.push(this.registerCommand(menu, args, menuPath)); } return this; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected registerCommand(menu: ActionMenuNode, args: any[]): Disposable { - const { commandRegistry, keybindingRegistry } = this.services; + protected registerCommand(menu: ActionMenuNode, args: unknown[], menuPath: MenuPath): Disposable { + const { commandRegistry, keybindingRegistry, commandExecutor } = this.services; const command = commandRegistry.getCommand(menu.action.commandId); if (!command) { return Disposable.NULL; @@ -463,11 +464,11 @@ export class MenuCommandRegistry extends PhosphorCommandRegistry { } // We freeze the `isEnabled`, `isVisible`, and `isToggled` states so they won't change. - const enabled = commandRegistry.isEnabled(id, ...args); - const visible = commandRegistry.isVisible(id, ...args); - const toggled = commandRegistry.isToggled(id, ...args); + const enabled = commandExecutor.isEnabled(menuPath, id, ...args); + const visible = commandExecutor.isVisible(menuPath, id, ...args); + const toggled = commandExecutor.isToggled(menuPath, id, ...args); const unregisterCommand = this.addCommand(id, { - execute: () => commandRegistry.executeCommand(id, ...args), + execute: () => commandExecutor.executeCommand(menuPath, id, ...args), label: menu.label, icon: menu.icon, isEnabled: () => enabled, diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar.tsx index 59a08c2d45b3a..051509d54600a 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar.tsx @@ -25,9 +25,10 @@ import { CommandRegistry } from '../../common/command'; import { Disposable, DisposableCollection } from '../../common/disposable'; import { ContextKeyService } from '../context-key-service'; import { Event, Emitter } from '../../common/event'; -import { ContextMenuRenderer, Anchor } from '../context-menu-renderer'; -import { MenuModelRegistry } from '../../common/menu'; +import { ContextMenuRenderer, Anchor, ContextMenuAccess } from '../context-menu-renderer'; +import { MenuModelRegistry, MenuNode, MenuPath } from '../../common/menu'; import { nls } from '../../common/nls'; +import { MenuCommandExecutor } from '../../common'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. @@ -57,7 +58,7 @@ export namespace TabBarDelegator { return false; }; } - +const menuDelegateSeparator = '@=@'; /** * Representation of an item in the tab */ @@ -130,6 +131,17 @@ export interface TabBarToolbarItem { } +export interface MenuDelegateToolbarItem extends TabBarToolbarItem { + menuPath: MenuPath; +} + +export namespace MenuDelegateToolbarItem { + export function getMenuPath(item: TabBarToolbarItem): MenuPath | undefined { + const asDelegate = item as MenuDelegateToolbarItem; + return Array.isArray(asDelegate.menuPath) ? asDelegate.menuPath : undefined; + } +} + /** * Tab-bar toolbar item backed by a `React.ReactNode`. * Unlike the `TabBarToolbarItem`, this item is not connected to the command service. @@ -183,22 +195,27 @@ export namespace TabBarToolbarItem { } +interface MenuDelegate { + menuPath: MenuPath; + isEnabled: (widget: Widget) => boolean; +} + +function yes(): true { return true; } + /** * Main, shared registry for tab-bar toolbar items. */ @injectable() export class TabBarToolbarRegistry implements FrontendApplicationContribution { - protected items: Map = new Map(); - - @inject(CommandRegistry) - protected readonly commandRegistry: CommandRegistry; + protected items = new Map(); + protected menuDelegates = new Map(); - @inject(ContextKeyService) - protected readonly contextKeyService: ContextKeyService; + @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; + @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; + @inject(MenuModelRegistry) protected readonly menuRegistry: MenuModelRegistry; - @inject(ContributionProvider) - @named(TabBarToolbarContribution) + @inject(ContributionProvider) @named(TabBarToolbarContribution) protected readonly contributionProvider: ContributionProvider; protected readonly onDidChangeEmitter = new Emitter(); @@ -244,7 +261,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { if (widget.isDisposed) { return []; } - const result = []; + const result: Array = []; for (const item of this.items.values()) { const visible = TabBarToolbarItem.is(item) ? this.commandRegistry.isVisible(item.command, widget) @@ -253,9 +270,53 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { result.push(item); } } + for (const delegate of this.menuDelegates.values()) { + if (delegate.isEnabled(widget)) { + const menu = this.menuRegistry.getMenu(delegate.menuPath); + const menuToTabbarItems = (item: MenuNode, group = '') => { + if (Array.isArray(item.children) && (!item.when || this.contextKeyService.match(item.when, widget.node))) { + const nextGroup = item === menu + ? group + : this.formatGroupForSubmenus(group, item.id, item.label); + item.children.forEach(child => menuToTabbarItems(child, nextGroup)); + } else if (!Array.isArray(item.children)) { + const asToolbarItem: MenuDelegateToolbarItem = { + id: `menu_as_toolbar_item_${item.id}`, + command: item.id, + when: item.when, + icon: item.icon, + tooltip: item.label ?? item.id, + menuPath: delegate.menuPath, + group, + }; + if (!asToolbarItem.when || this.contextKeyService.match(asToolbarItem.when, widget.node)) { + result.push(asToolbarItem); + } + } + }; + menuToTabbarItems(menu); + } + } return result; } + protected formatGroupForSubmenus(lastGroup: string, currentId?: string, currentLabel?: string): string { + const split = lastGroup.length ? lastGroup.split(menuDelegateSeparator).filter(segment => segment.length) : []; + // If the submenu is in the 'navigation' group, then it's an item that opens its own context menu, so it should be navigation/id/label... + const expectedParity = split[0] === 'navigation' ? 1 : 0; + if (split.length % 2 !== expectedParity && (currentId || currentLabel)) { + console.warn('Something went wrong with a contributed tabbar menu delegate.', lastGroup, currentId, currentLabel); + split.push(''); + } + if (currentId || currentLabel) { + split.push(currentId || (currentLabel + '_id')); + } + if (currentLabel) { + split.push(currentLabel); + } + return split.join(menuDelegateSeparator); + } + unregisterItem(itemOrId: TabBarToolbarItem | ReactTabBarToolbarItem | string): void { const id = typeof itemOrId === 'string' ? itemOrId : itemOrId.id; if (this.items.delete(id)) { @@ -263,6 +324,27 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } } + registerMenuDelegate(menuPath: MenuPath, when?: string | ((widget: Widget) => boolean)): Disposable { + const id = menuPath.join(menuDelegateSeparator); + if (!this.menuDelegates.has(id)) { + const isEnabled: MenuDelegate['isEnabled'] = !when + ? yes + : typeof when === 'function' + ? when + : widget => this.contextKeyService.match(when, widget.node); + this.menuDelegates.set(id, { menuPath, isEnabled }); + this.fireOnDidChange(); + return { dispose: () => this.unregisterMenuDelegate(menuPath) }; + } + console.warn('Unable to register menu delegate. Delegate has already been registered', menuPath); + return Disposable.NULL; + } + + unregisterMenuDelegate(menuPath: MenuPath): void { + if (this.menuDelegates.delete(menuPath.join(menuDelegateSeparator))) { + this.fireOnDidChange(); + } + } } /** @@ -273,6 +355,8 @@ export interface TabBarToolbarFactory { (): TabBarToolbar; } +export const TAB_BAR_TOOLBAR_CONTEXT_MENU = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; + /** * Tab-bar toolbar widget representing the active [tab-bar toolbar items](TabBarToolbarItem). */ @@ -283,20 +367,12 @@ export class TabBarToolbar extends ReactWidget { protected inline = new Map(); protected more = new Map(); - @inject(CommandRegistry) - protected readonly commands: CommandRegistry; - - @inject(LabelParser) - protected readonly labelParser: LabelParser; - - @inject(MenuModelRegistry) - protected readonly menus: MenuModelRegistry; - - @inject(ContextMenuRenderer) - protected readonly contextMenuRenderer: ContextMenuRenderer; - - @inject(TabBarToolbarRegistry) - protected readonly toolbarRegistry: TabBarToolbarRegistry; + @inject(CommandRegistry) protected readonly commands: CommandRegistry; + @inject(LabelParser) protected readonly labelParser: LabelParser; + @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; + @inject(MenuCommandExecutor) protected readonly menuCommandExecutor: MenuCommandExecutor; + @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(TabBarToolbarRegistry) protected readonly toolbarRegistry: TabBarToolbarRegistry; constructor() { super(); @@ -416,16 +492,16 @@ export class TabBarToolbar extends ReactWidget { this.renderMoreContextMenu(event.nativeEvent); }; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - renderMoreContextMenu(anchor: Anchor): any { - const menuPath = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; + renderMoreContextMenu(anchor: Anchor): ContextMenuAccess { + const menuPath = TAB_BAR_TOOLBAR_CONTEXT_MENU; const toDisposeOnHide = new DisposableCollection(); this.addClass('menu-open'); toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open'))); for (const item of this.more.values()) { + const separator = item.group && [menuDelegateSeparator, '/'].find(candidate => item.group?.includes(candidate)); // Register a submenu for the item, if the group is in format `//.../` - if (item.group?.includes('/')) { - const split = item.group.split('/'); + if (separator) { + const split = item.group.split(separator); const paths: string[] = []; for (let i = 0; i < split.length - 1; i += 2) { paths.push(split[i], split[i + 1]); @@ -444,6 +520,7 @@ export class TabBarToolbar extends ReactWidget { menuPath, args: [this.current], anchor, + context: this.current?.node, onHide: () => toDisposeOnHide.dispose() }); } @@ -466,7 +543,12 @@ export class TabBarToolbar extends ReactWidget { const item = this.inline.get(e.currentTarget.id); if (TabBarToolbarItem.is(item)) { - this.commands.executeCommand(item.command, this.current); + const menuPath = MenuDelegateToolbarItem.getMenuPath(item); + if (menuPath) { + this.menuCommandExecutor.executeCommand(menuPath, item.command, this.current); + } else { + this.commands.executeCommand(item.command, this.current); + } } this.update(); }; diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index cedfd254d0009..179b97537c6dd 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -21,6 +21,7 @@ export * from './event'; export * from './cancellation'; export * from './command'; export * from './menu'; +export * from './menu-adapter'; export * from './selection-service'; export * from './objects'; export * from './os'; diff --git a/packages/core/src/common/menu-adapter.ts b/packages/core/src/common/menu-adapter.ts new file mode 100644 index 0000000000000..028940b0802e4 --- /dev/null +++ b/packages/core/src/common/menu-adapter.ts @@ -0,0 +1,103 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { inject, injectable } from 'inversify'; +import { CommandRegistry } from './command'; +import { Disposable } from './disposable'; +import { MenuPath } from './menu'; + +export type MenuCommandArguments = [menuPath: MenuPath, command: string, ...commandArgs: unknown[]]; + +export const MenuCommandExecutor = Symbol('MenuCommandExecutor'); +export interface MenuCommandExecutor { + isVisible(...args: MenuCommandArguments): boolean; + isEnabled(...args: MenuCommandArguments): boolean; + isToggled(...args: MenuCommandArguments): boolean; + executeCommand(...args: MenuCommandArguments): Promise; +}; + +export const MenuCommandAdapter = Symbol('MenuCommandAdapter'); +export interface MenuCommandAdapter extends MenuCommandExecutor { + /** Return values less than or equal to 0 are treated as rejections. */ + canHandle(...args: MenuCommandArguments): number; +} + +export const MenuCommandAdapterRegistry = Symbol('MenuCommandAdapterRegistry'); +export interface MenuCommandAdapterRegistry { + registerAdapter(adapter: MenuCommandAdapter): Disposable; + getAdapterFor(...args: MenuCommandArguments): MenuCommandAdapter | undefined; +} + +@injectable() +export class MenuCommandExecutorImpl implements MenuCommandExecutor { + @inject(MenuCommandAdapterRegistry) protected readonly adapterRegistry: MenuCommandAdapterRegistry; + @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; + + executeCommand(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): Promise { + return this.delegate(menuPath, command, commandArgs, 'executeCommand'); + } + + isVisible(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { + return this.delegate(menuPath, command, commandArgs, 'isVisible'); + } + + isEnabled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { + return this.delegate(menuPath, command, commandArgs, 'isEnabled'); + } + + isToggled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { + return this.delegate(menuPath, command, commandArgs, 'isToggled'); + } + + protected delegate(menuPath: MenuPath, command: string, commandArgs: unknown[], method: T): ReturnType { + const adapter = this.adapterRegistry.getAdapterFor(menuPath, command, commandArgs); + return (adapter + ? adapter[method](menuPath, command, ...commandArgs) + : this.commandRegistry[method](command, ...commandArgs)) as ReturnType; + } +} + +@injectable() +export class MenuCommandAdapterRegistryImpl implements MenuCommandAdapterRegistry { + protected readonly adapters = new Array(); + + registerAdapter(adapter: MenuCommandAdapter): Disposable { + if (!this.adapters.includes(adapter)) { + this.adapters.push(adapter); + return Disposable.create(() => { + const index = this.adapters.indexOf(adapter); + if (index !== -1) { + this.adapters.splice(index, 1); + } + }); + } + return Disposable.NULL; + } + + getAdapterFor(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): MenuCommandAdapter | undefined { + let bestAdapter: MenuCommandAdapter | undefined = undefined; + let bestScore = 0; + let currentScore = 0; + for (const adapter of this.adapters) { + // Greater than or equal: favor later registrations over earlier. + if ((currentScore = adapter.canHandle(menuPath, command, ...commandArgs)) >= bestScore) { + bestScore = currentScore; + bestAdapter = adapter; + } + } + return bestAdapter; + } +} diff --git a/packages/core/src/common/menu.ts b/packages/core/src/common/menu.ts index f58b93b4a8536..00edebb9da8a3 100644 --- a/packages/core/src/common/menu.ts +++ b/packages/core/src/common/menu.ts @@ -26,7 +26,7 @@ export interface MenuAction { /** * The command to execute. */ - commandId: string + commandId: string; /** * In addition to the mandatory command property, an alternative command can be defined. * It will be shown and invoked when pressing Alt while opening a menu. @@ -35,22 +35,22 @@ export interface MenuAction { /** * A specific label for this action. If not specified the command label or command id will be used. */ - label?: string + label?: string; /** * Icon class(es). If not specified the icon class associated with the specified command * (i.e. `command.iconClass`) will be used if it exists. */ - icon?: string + icon?: string; /** * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined * label will be used instead. */ - order?: string + order?: string; /** * Optional expression which will be evaluated by the {@link ContextKeyService} to determine visibility * of the action, e.g. `resourceLangId == markdown`. */ - when?: string + when?: string; } export namespace MenuAction { @@ -68,12 +68,16 @@ export interface SubMenuOptions { /** * The class to use for the submenu icon. */ - iconClass?: string + iconClass?: string; /** * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined * label will be used instead. */ - order?: string + order?: string; + /** + * The conditions under which to include the specified submenu under the specified parent. + */ + when?: string; } export type MenuPath = string[]; @@ -128,6 +132,7 @@ export interface MenuContribution { @injectable() export class MenuModelRegistry { protected readonly root = new CompositeMenuNode(''); + protected readonly independentSubmenus = new Map(); constructor( @inject(ContributionProvider) @named(MenuContribution) @@ -156,11 +161,24 @@ export class MenuModelRegistry { * * @returns a disposable which, when called, will remove the menu node again. */ - registerMenuNode(menuPath: MenuPath, menuNode: MenuNode): Disposable { - const parent = this.findGroup(menuPath); + registerMenuNode(menuPath: MenuPath | string, menuNode: MenuNode, group?: string): Disposable { + const parent = this.getMenuNode(menuPath, group); return parent.addNode(menuNode); } + getMenuNode(menuPath: MenuPath | string, group?: string): CompositeMenuNode { + if (typeof menuPath === 'string') { + const target = this.independentSubmenus.get(menuPath); + if (!target) { throw new Error(`Could not find submenu with id ${menuPath}`); } + if (group) { + return this.findSubMenu(target, group); + } + return target; + } else { + return this.findGroup(group ? menuPath.concat(group) : menuPath); + } + } + /** * 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.) @@ -206,6 +224,27 @@ export class MenuModelRegistry { } } + registerIndependentSubmenu(id: string, label: string): Disposable { + if (this.independentSubmenus.has(id)) { + console.debug(`Independent submenu with path ${id} registered, but given ID already exists.`); + } + this.independentSubmenus.set(id, new CompositeMenuNode(id, label)); + return { dispose: () => this.independentSubmenus.delete(id) }; + } + + linkSubmenu(parentPath: MenuPath | string, childId: string, options?: SubMenuOptions, group?: string): Disposable { + const child = this.independentSubmenus.get(childId); + if (!child) { + throw new Error(`Attempted to link non-existent menu with id ${childId}`); + } + const parent = this.getMenuNode(parentPath, group); + if (!parent) { + throw new Error(`Attempted to link into a non-existent parent with path ${parentPath}`); + } + const wrapper = new CompositeMenuNodeWrapper(child, options); + return parent.addNode(wrapper); + } + /** * Unregister all menu nodes with the same id as the given menu action. * @@ -258,6 +297,10 @@ export class MenuModelRegistry { recurse(this.root); } + /** + * Finds a submenu as a descendant of the `root` node. + * See {@link MenuModelRegistry.findSubMenu findSubMenu}. + */ protected findGroup(menuPath: MenuPath, options?: SubMenuOptions): CompositeMenuNode { let currentMenu = this.root; for (const segment of menuPath) { @@ -266,6 +309,10 @@ export class MenuModelRegistry { return currentMenu; } + /** + * Finds or creates a submenu as an immediate child of `current`. + * @throws if a node with the given `menuId` exists but is not a {@link CompositeMenuNode}. + */ protected findSubMenu(current: CompositeMenuNode, menuId: string, options?: SubMenuOptions): CompositeMenuNode { const sub = current.children.find(e => e.id === menuId); if (sub instanceof CompositeMenuNode) { @@ -308,6 +355,16 @@ export interface MenuNode { * Menu nodes are sorted in ascending order based on their `sortString`. */ readonly sortString: string + /** + * Additional conditions determining the visibility of a menu node + */ + readonly when?: string; + + readonly children?: ReadonlyArray; + + readonly isSubmenu?: boolean; + + readonly icon?: string; } /** @@ -317,6 +374,7 @@ export class CompositeMenuNode implements MenuNode { protected readonly _children: MenuNode[] = []; public iconClass?: string; public order?: string; + readonly when?: string; constructor( public readonly id: string, @@ -326,6 +384,7 @@ export class CompositeMenuNode implements MenuNode { if (options) { this.iconClass = options.iconClass; this.order = options.order; + this.when = options.when; } } @@ -401,6 +460,26 @@ export class CompositeMenuNode implements MenuNode { } } +export class CompositeMenuNodeWrapper implements MenuNode { + constructor(protected readonly wrapped: Readonly, protected readonly options?: SubMenuOptions) { } + + get id(): string { return this.wrapped.id; } + + get label(): string | undefined { return this.wrapped.label; } + + get sortString(): string { return this.order || this.id; } + + get isSubmenu(): boolean { return this.label !== undefined; } + + get iconClass(): string | undefined { return this.options?.iconClass; } + + get order(): string | undefined { return this.options?.order; } + + get when(): string | undefined { return this.options?.when; } + + get children(): ReadonlyArray { return this.wrapped.children; } +} + /** * Node representing an action in the menu tree structure. * It's based on {@link MenuAction} for which it tries to determine the @@ -419,6 +498,10 @@ export class ActionMenuNode implements MenuNode { } } + get when(): string | undefined { + return this.action.when; + } + get id(): string { return this.action.commandId; } diff --git a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts index fe221c9e9f253..85087b3dee07a 100644 --- a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts +++ b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts @@ -101,8 +101,8 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { protected override doRender(options: RenderContextMenuOptions): ContextMenuAccess { if (this.useNativeStyle) { - const { menuPath, anchor, args, onHide } = options; - const menu = this.electronMenuFactory.createElectronContextMenu(menuPath, args); + const { menuPath, anchor, args, onHide, context } = options; + const menu = this.electronMenuFactory.createElectronContextMenu(menuPath, args, context); const { x, y } = coordinateFromAnchor(anchor); const zoom = electron.webFrame.getZoomFactor(); // TODO: Remove the offset once Electron fixes https://github.com/electron/electron/issues/31641 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 d11a8cae05b55..cd42e86b26662 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 @@ -19,7 +19,7 @@ import * as electronRemote from '../../../electron-shared/@electron/remote'; import { inject, injectable, postConstruct } from 'inversify'; import { - isOSX, ActionMenuNode, CompositeMenuNode, MAIN_MENU_BAR, MenuPath, MenuNode + isOSX, ActionMenuNode, MAIN_MENU_BAR, MenuPath, MenuNode } from '../../common'; import { Keybinding } from '../../common/keybinding'; import { PreferenceService, CommonCommands } from '../../browser'; @@ -36,6 +36,15 @@ export interface ElectronMenuOptions { * Defaults to `true`. */ readonly showDisabled?: boolean; + /** + * A DOM context to use when evaluating any `when` clauses + * of menu items registered for this item. + */ + context?: HTMLElement; + /** + * The root menu path for which the menu is being built. + */ + rootMenuPath: MenuPath } /** @@ -100,7 +109,7 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { const maxWidget = document.getElementsByClassName(MAXIMIZED_CLASS); if (preference === 'visible' || (preference === 'classic' && maxWidget.length === 0)) { const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR); - const template = this.fillMenuTemplate([], menuModel); + const template = this.fillMenuTemplate([], menuModel, [], { rootMenuPath: MAIN_MENU_BAR }); if (isOSX) { template.unshift(this.createOSXMenu()); } @@ -116,21 +125,21 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { return null; } - createElectronContextMenu(menuPath: MenuPath, args?: any[]): Electron.Menu { + createElectronContextMenu(menuPath: MenuPath, args?: any[], context?: HTMLElement): Electron.Menu { const menuModel = this.menuProvider.getMenu(menuPath); - const template = this.fillMenuTemplate([], menuModel, args, { showDisabled: false }); + const template = this.fillMenuTemplate([], menuModel, args, { showDisabled: false, context, rootMenuPath: menuPath }); return electronRemote.Menu.buildFromTemplate(template); } protected fillMenuTemplate(items: Electron.MenuItemConstructorOptions[], - menuModel: CompositeMenuNode, + menuModel: MenuNode, args: any[] = [], - options?: ElectronMenuOptions + options: ElectronMenuOptions ): Electron.MenuItemConstructorOptions[] { const showDisabled = (options?.showDisabled === undefined) ? true : options?.showDisabled; - for (const menu of menuModel.children) { - if (menu instanceof CompositeMenuNode) { - if (menu.children.length > 0) { + for (const menu of (menuModel.children ?? [])) { + if (menu.children) { + if (menu.children.length > 0 && (!menu.when || this.contextKeyService.match(menu.when, options?.context))) { // do not render empty nodes if (menu.isSubmenu) { // submenu node @@ -146,6 +155,9 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { }); } else { // group node + if (menu.id === 'inline') { + continue; + } // process children const submenu = this.fillMenuTemplate([], menu, args, options); @@ -175,13 +187,13 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { continue; } - if (!this.commandRegistry.isVisible(commandId, ...args) - || (!!node.action.when && !this.contextKeyService.match(node.action.when))) { + if (!this.menuCommandExecutor.isVisible(options.rootMenuPath, commandId, ...args) + || (node.action.when && !this.contextKeyService.match(node.action.when, options?.context))) { continue; } // We should omit rendering context-menu items which are disabled. - if (!showDisabled && !this.commandRegistry.isEnabled(commandId, ...args)) { + if (!showDisabled && !this.menuCommandExecutor.isEnabled(options.rootMenuPath, commandId, ...args)) { continue; } @@ -197,7 +209,7 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { enabled: true, // https://github.com/eclipse-theia/theia/issues/446 visible: true, accelerator, - click: () => this.execute(commandId, args) + click: () => this.execute(commandId, args, options.rootMenuPath) }; if (isOSX) { @@ -268,17 +280,17 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { return role; } - protected async execute(command: string, args: any[]): Promise { + protected async execute(command: string, args: any[], menuPath: MenuPath): Promise { try { // This is workaround for https://github.com/eclipse-theia/theia/issues/446. // Electron menus do not update based on the `isEnabled`, `isVisible` property of the command. // 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)) { + if (this.menuCommandExecutor.isEnabled(menuPath, command, ...args)) { + await this.menuCommandExecutor.executeCommand(menuPath, command, ...args); + if (this._menu && this.menuCommandExecutor.isVisible(menuPath, command, ...args)) { const item = this._menu.getMenuItemById(command); if (item) { - item.checked = this.commandRegistry.isToggled(command, ...args); + item.checked = this.menuCommandExecutor.isToggled(menuPath, command, ...args); electronRemote.getCurrentWindow().setMenu(this._menu); } } diff --git a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts index e99cc4f8a4f89..452435ec6a509 100755 --- a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts +++ b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts @@ -31,7 +31,6 @@ import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/appl import { CommandService } from '@theia/core/lib/common/command'; import TheiaURI from '@theia/core/lib/common/uri'; import { EditorManager, EditorCommands } from '@theia/editor/lib/browser'; -import { CodeEditorWidgetUtil } from '@theia/plugin-ext/lib/main/browser/menus/menus-contribution-handler'; import { TextDocumentShowOptions, Location, @@ -77,6 +76,7 @@ import { nls } from '@theia/core/lib/common/nls'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import * as monaco from '@theia/monaco-editor-core'; import { VSCodeExtensionUri } from '../common/plugin-vscode-uri'; +import { CodeEditorWidgetUtil } from '@theia/plugin-ext/lib/main/browser/menus/vscode-theia-menu-mappings'; export namespace VscodeCommands { export const OPEN: Command = { diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 714ce004b351a..d6ce7ea8953dd 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -758,6 +758,7 @@ export interface Menu { export interface Submenu { id: string; label: string; + icon?: IconUrl; } /** diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index 34ff4c591015a..e8a30af9d04df 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -16,623 +16,136 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { URI as CodeUri } from '@theia/core/shared/vscode-uri'; -import { injectable, inject, optional } from '@theia/core/shared/inversify'; -import { MenuPath, ILogger, CommandRegistry, Command, Mutable, MenuAction, SelectionService, CommandHandler, Disposable, DisposableCollection } from '@theia/core'; -import { EDITOR_CONTEXT_MENU, EditorWidget } from '@theia/editor/lib/browser'; +import { inject, injectable, optional } from '@theia/core/shared/inversify'; +import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter } from '@theia/core'; import { MenuModelRegistry } from '@theia/core/lib/common'; -import { Emitter, Event } from '@theia/core/lib/common/event'; -import { TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; -import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; -import { VIEW_ITEM_CONTEXT_MENU, TreeViewWidget, VIEW_ITEM_INLINE_MENU } from '../view/tree-view-widget'; -import { DeployedPlugin, Menu, ScmCommandArg, TimelineCommandArg, TreeViewSelection } from '../../../common'; -import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stack-frames-widget'; -import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; -import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection'; +import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { DeployedPlugin, Menu } from '../../../common'; import { ScmWidget } from '@theia/scm/lib/browser/scm-widget'; -import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; -import { ScmService } from '@theia/scm/lib/browser/scm-service'; -import { ScmRepository } from '@theia/scm/lib/browser/scm-repository'; -import { PluginScmProvider, PluginScmResourceGroup, PluginScmResource } from '../scm-main'; -import { ResourceContextKey } from '@theia/core/lib/browser/resource-context-key'; import { PluginViewWidget } from '../view/plugin-view-widget'; -import { ViewContextKeyService } from '../view/view-context-key-service'; -import { WebviewWidget } from '../webview/webview'; -import { Navigatable } from '@theia/core/lib/browser/navigatable'; -import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; -import { TIMELINE_ITEM_CONTEXT_MENU } from '@theia/timeline/lib/browser/timeline-tree-widget'; -import { TimelineItem } from '@theia/timeline/lib/common/timeline-model'; -import { COMMENT_CONTEXT, COMMENT_THREAD_CONTEXT, COMMENT_TITLE } from '../comments/comment-thread-widget'; import { QuickCommandService } from '@theia/core/lib/browser'; - -type CodeEditorWidget = EditorWidget | WebviewWidget; -@injectable() -export class CodeEditorWidgetUtil { - is(arg: any): arg is CodeEditorWidget { - return arg instanceof EditorWidget || arg instanceof WebviewWidget; - } - getResourceUri(editor: CodeEditorWidget): CodeUri | undefined { - const resourceUri = Navigatable.is(editor) && editor.getResourceUri(); - return resourceUri ? resourceUri['codeUri'] : undefined; - } -} +import { + CodeEditorWidgetUtil, codeToTheiaMappings, ContributionPoint, implementedVSCodeContributionPoints, + PLUGIN_EDITOR_TITLE_MENU, PLUGIN_SCM_TITLE_MENU, PLUGIN_VIEW_TITLE_MENU +} from './vscode-theia-menu-mappings'; +import { PluginMenuCommandAdapter, ReferenceCountingSet } from './plugin-menu-command-adapter'; +import { ContextKeyExpr } from '@theia/monaco-editor-core/esm/vs/platform/contextkey/common/contextkey'; +import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; @injectable() export class MenusContributionPointHandler { - @inject(MenuModelRegistry) - protected readonly menuRegistry: MenuModelRegistry; - - @inject(CommandRegistry) - protected readonly commands: CommandRegistry; - - @inject(ILogger) - protected readonly logger: ILogger; - - @inject(ScmService) - protected readonly scmService: ScmService; - + @inject(MenuModelRegistry) private readonly menuRegistry: MenuModelRegistry; + @inject(CommandRegistry) private readonly commands: CommandRegistry; + @inject(TabBarToolbarRegistry) private readonly tabBarToolbar: TabBarToolbarRegistry; + @inject(CodeEditorWidgetUtil) private readonly codeEditorWidgetUtil: CodeEditorWidgetUtil; + @inject(PluginMenuCommandAdapter) protected readonly commandAdapter: PluginMenuCommandAdapter; + @inject(MenuCommandAdapterRegistry) protected readonly commandAdapterRegistry: MenuCommandAdapterRegistry; + @inject(ContextKeyService) protected readonly contextKeySerivce: ContextKeyService; @inject(QuickCommandService) @optional() - protected readonly quickCommandService: QuickCommandService; - - @inject(TabBarToolbarRegistry) - protected readonly tabBarToolbar: TabBarToolbarRegistry; - - @inject(SelectionService) - protected readonly selectionService: SelectionService; - - @inject(ResourceContextKey) - protected readonly resourceContextKey: ResourceContextKey; - - @inject(ViewContextKeyService) - protected readonly viewContextKeys: ViewContextKeyService; - - @inject(ContextKeyService) - protected readonly contextKeyService: ContextKeyService; - - @inject(CodeEditorWidgetUtil) - protected readonly codeEditorWidgetUtil: CodeEditorWidgetUtil; - - handle(plugin: DeployedPlugin): Disposable { - const allMenus = plugin.contributes && plugin.contributes.menus; - if (!allMenus) { - return Disposable.NULL; - } - const toDispose = new DisposableCollection(); - - const tree = this.getMenusTree(plugin); - tree.forEach(rootMenu => { - - const registerMenuActions = (menus: MenuTree[], group: string | undefined, submenusOrder: string | undefined = '') => { - menus.forEach(menu => { - if (group) { - // Adding previous group to the start of current menu group. - menu.group = `${group}/${menu.group || '_'}`; - } - if (menu.isSubmenu) { - let [submenuGroup, submenuOrder = ''] = (menu.group || '_').split('@'); - // Generating group in format: `/` - submenuGroup = `${submenuGroup}/${menu.label}`; - if (submenusOrder) { - // Adding previous submenus order to the start of current submenu order - // in format: `/.../`. - submenuOrder = `${submenusOrder}/${submenuOrder}`; - } - registerMenuActions(menu.children, submenuGroup, submenuOrder); - } else { - menu.submenusOrder = submenusOrder; - toDispose.push( - this.registerAction(plugin, rootMenu.id!, menu) - ); - } - }); - }; - - registerMenuActions(rootMenu.children, undefined, undefined); - }); - - return toDispose; - } - - /** - * Transforms the structure of Menus & Submenus - * into something more tree-like. - */ - protected getMenusTree(plugin: DeployedPlugin): MenuTree[] { - const allMenus = plugin.contributes && plugin.contributes.menus; - if (!allMenus) { - return []; - } - const allSubmenus = plugin.contributes && plugin.contributes.submenus; - const tree: MenuTree[] = []; - - Object.keys(allMenus).forEach(location => { - // Don't build menus tree for a submenu declaration at root. - if (allSubmenus && allSubmenus.findIndex(submenu => submenu.id === location) > -1) { - return; + private readonly quickCommandService: QuickCommandService; + + protected readonly titleContributionContextKeys = new ReferenceCountingSet(); + protected readonly onDidChangeTitleContributionEmitter = new Emitter(); + + private initialized = false; + private initialize(): void { + this.initialized = true; + this.commandAdapterRegistry.registerAdapter(this.commandAdapter); + for (const contributionPoint of implementedVSCodeContributionPoints) { + this.menuRegistry.registerIndependentSubmenu(contributionPoint, ''); + this.getMatchingMenu(contributionPoint)!.forEach(menu => this.menuRegistry.linkSubmenu(menu, contributionPoint)); + } + this.tabBarToolbar.registerMenuDelegate(PLUGIN_EDITOR_TITLE_MENU, widget => this.codeEditorWidgetUtil.is(widget)); + this.tabBarToolbar.registerMenuDelegate(PLUGIN_SCM_TITLE_MENU, widget => widget instanceof ScmWidget); + this.tabBarToolbar.registerMenuDelegate(PLUGIN_VIEW_TITLE_MENU, widget => widget instanceof PluginViewWidget); + this.tabBarToolbar.registerItem({ id: 'plugin-menu-contribution-title-contribution', command: '_never_', onDidChange: this.onDidChangeTitleContributionEmitter.event }); + this.contextKeySerivce.onDidChange(event => { + if (event.affects(this.titleContributionContextKeys)) { + this.onDidChangeTitleContributionEmitter.fire(); } - - /** - * @param menus the menus to create a tree from. - * @param submenusIds contains all the previous submenus ids in the current tree. - * @returns {MenuTree[]} the trees for the given menus. - */ - const getChildren = (menus: Menu[], submenusIds: Set) => { - // Contains all the submenus ids of the current parent. - const parentSubmenusIds = new Set(); - - return menus.reduce((children: MenuTree[], menuItem) => { - if (menuItem.submenu) { - if (parentSubmenusIds.has(menuItem.submenu)) { - console.warn(`Submenu ${menuItem.submenu} already registered`); - } else if (submenusIds.has(menuItem.submenu)) { - console.warn(`Found submenu cycle: ${menuItem.submenu}`); - } else { - parentSubmenusIds.add(menuItem.submenu); - const submenu = allSubmenus!.find(s => s.id === menuItem.submenu)!; - const menuTree = new MenuTree({ ...menuItem }, menuItem.submenu, submenu.label); - menuTree.children = getChildren(allMenus[submenu.id], new Set([...submenusIds, menuItem.submenu])); - children.push(menuTree); - } - } else { - children.push(new MenuTree({ ...menuItem })); - } - return children; - }, []); - }; - - const rootMenu = new MenuTree(undefined, location); - rootMenu.children = getChildren(allMenus[location], new Set()); - tree.push(rootMenu); }); + } - return tree; + private getMatchingMenu(contributionPoint: ContributionPoint): MenuPath[] | undefined { + return codeToTheiaMappings.get(contributionPoint); } - protected registerAction(plugin: DeployedPlugin, location: string, action: MenuTree): Disposable { - const allMenus = plugin.contributes && plugin.contributes.menus; + handle(plugin: DeployedPlugin): Disposable { + const allMenus = plugin.contributes?.menus; if (!allMenus) { return Disposable.NULL; } - - switch (location) { - case 'commandPalette': return this.registerCommandPaletteAction(action); - case 'editor/title': return this.registerEditorTitleAction(location, action); - case 'view/title': return this.registerViewTitleAction(location, action); - case 'view/item/context': return this.registerViewItemContextAction(action); - case 'scm/title': return this.registerScmTitleAction(location, action); - case 'scm/resourceGroup/context': return this.registerScmResourceGroupAction(action); - case 'scm/resourceFolder/context': return this.registerScmResourceFolderAction(action); - case 'scm/resourceState/context': return this.registerScmResourceStateAction(action); - case 'timeline/item/context': return this.registerTimelineItemAction(action); - case 'comments/commentThread/context': return this.registerCommentThreadAction(action, plugin); - case 'comments/comment/title': return this.registerCommentTitleAction(action); - case 'comments/comment/context': return this.registerCommentContextAction(action); - case 'debug/callstack/context': return this.registerDebugCallstackAction(action); - - default: if (allMenus.hasOwnProperty(location)) { - return this.registerGlobalMenuAction(action, location, plugin); - } - return Disposable.NULL; - } - } - - protected static parseMenuPaths(value: string): MenuPath[] { - switch (value) { - case 'editor/context': return [EDITOR_CONTEXT_MENU]; - case 'explorer/context': return [NAVIGATOR_CONTEXT_MENU]; + if (!this.initialized) { + this.initialize(); } - return []; - } - - protected registerCommandPaletteAction(menu: Menu): Disposable { - if (menu.command && menu.when) { - return this.quickCommandService.pushCommandContext(menu.command, menu.when); + const toDispose = new DisposableCollection(); + const submenus = plugin.contributes?.submenus ?? []; + for (const submenu of submenus) { + this.menuRegistry.registerIndependentSubmenu(submenu.id, submenu.label); } - return Disposable.NULL; - } - - protected registerEditorTitleAction(location: string, action: Menu): Disposable { - return this.registerTitleAction(location, action, { - execute: widget => this.codeEditorWidgetUtil.is(widget) && - this.commands.executeCommand(action.command!, this.codeEditorWidgetUtil.getResourceUri(widget)), - isEnabled: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.isEnabled(action.command!, this.codeEditorWidgetUtil.getResourceUri(widget)), - isVisible: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.isVisible(action.command!, this.codeEditorWidgetUtil.getResourceUri(widget)) - }); - } - - protected registerViewTitleAction(location: string, action: Menu): Disposable { - return this.registerTitleAction(location, action, { - execute: widget => widget instanceof PluginViewWidget && this.commands.executeCommand(action.command!), - isEnabled: widget => widget instanceof PluginViewWidget && this.commands.isEnabled(action.command!), - isVisible: widget => widget instanceof PluginViewWidget && this.commands.isVisible(action.command!), - }); - } - - protected registerViewItemContextAction(menu: MenuTree): Disposable { - const inline = menu.group && /^inline/.test(menu.group) || false; - const menuPath = inline ? VIEW_ITEM_INLINE_MENU : VIEW_ITEM_CONTEXT_MENU; - return this.registerTreeMenuAction(menuPath, menu); - } - - protected registerScmResourceGroupAction(menu: MenuTree): Disposable { - const inline = menu.group && /^inline/.test(menu.group) || false; - const menuPath = inline ? ScmTreeWidget.RESOURCE_GROUP_INLINE_MENU : ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU; - return this.registerScmMenuAction(menuPath, menu); - } - - protected registerScmResourceFolderAction(menu: MenuTree): Disposable { - const inline = menu.group && /^inline/.test(menu.group) || false; - const menuPath = inline ? ScmTreeWidget.RESOURCE_FOLDER_INLINE_MENU : ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU; - return this.registerScmMenuAction(menuPath, menu); - } - protected registerScmResourceStateAction(menu: MenuTree): Disposable { - const inline = menu.group && /^inline/.test(menu.group) || false; - const menuPath = inline ? ScmTreeWidget.RESOURCE_INLINE_MENU : ScmTreeWidget.RESOURCE_CONTEXT_MENU; - return this.registerScmMenuAction(menuPath, menu); - } - - protected registerTimelineItemAction(menu: MenuTree): Disposable { - return this.registerMenuAction(TIMELINE_ITEM_CONTEXT_MENU, menu, - command => ({ - execute: (...args) => this.commands.executeCommand(command, ...this.toTimelineArgs(...args)), - isEnabled: (...args) => this.commands.isEnabled(command, ...this.toTimelineArgs(...args)), - isVisible: (...args) => this.commands.isVisible(command, ...this.toTimelineArgs(...args)) - })); - } - - protected registerCommentThreadAction(menu: MenuTree, plugin: DeployedPlugin): Disposable { - return this.registerMenuAction(COMMENT_THREAD_CONTEXT, menu, - command => ({ - execute: (...args) => this.commands.executeCommand(command, ...this.toCommentArgs(...args)), - isEnabled: () => { - const commandContributions = plugin.contributes?.commands; - if (commandContributions) { - const commandContribution = commandContributions.find(c => c.command === command); - if (commandContribution && commandContribution.enablement) { - return this.contextKeyService.match(commandContribution.enablement); + for (const [contributionPoint, items] of Object.entries(allMenus)) { + for (const item of items) { + try { + if (contributionPoint === 'commandPalette') { + toDispose.push(this.registerCommandPaletteAction(item)); + } else { + this.checkTitleContribution(contributionPoint, item, toDispose); + const targets = this.getMatchingMenu(contributionPoint as ContributionPoint) ?? [contributionPoint]; + if (item.submenu) { + const { group, order } = this.parseGroup(item.group); + targets.forEach(target => toDispose.push(this.menuRegistry.linkSubmenu(target, item.submenu!, { order, when: item.when }, group))); + } else if (item.command) { + toDispose.push(this.commandAdapter.addCommand(item.command)); + const { group, order } = this.parseGroup(item.group); + const node = new ActionMenuNode({ + commandId: item.command, + when: item.when, + order, + }, this.commands); + + targets.forEach(target => { + const parent = this.menuRegistry.getMenuNode(target, group); + toDispose.push(parent.addNode(node)); + }); } } - return true; - }, - isVisible: (...args) => this.commands.isVisible(command, ...this.toCommentArgs(...args)) - })); - } - - protected registerCommentTitleAction(menu: MenuTree): Disposable { - return this.registerMenuAction(COMMENT_TITLE, menu, - command => ({ - execute: (...args) => this.commands.executeCommand(command, ...this.toCommentArgs(...args)), - isEnabled: (...args) => this.commands.isEnabled(command, ...this.toCommentArgs(...args)), - isVisible: (...args) => this.commands.isVisible(command, ...this.toCommentArgs(...args)) - })); - } - - protected registerCommentContextAction(menu: MenuTree): Disposable { - return this.registerMenuAction(COMMENT_CONTEXT, menu, - command => ({ - execute: (...args) => this.commands.executeCommand(command, ...this.toCommentArgs(...args)), - isEnabled: () => true, - isVisible: (...args) => this.commands.isVisible(command, ...this.toCommentArgs(...args)) - })); - } - - protected registerDebugCallstackAction(menu: MenuTree): Disposable { - const toDispose = new DisposableCollection(); - [DebugStackFramesWidget.CONTEXT_MENU, DebugThreadsWidget.CONTEXT_MENU].forEach(menuPath => { - toDispose.push( - this.registerMenuAction(menuPath, menu, command => ({ - execute: (...args) => this.commands.executeCommand(command, args[0]), - isEnabled: (...args) => this.commands.isEnabled(command, args[0]), - isVisible: (...args) => this.commands.isVisible(command, args[0]) - }))); - }); - return toDispose; - } - - protected registerTreeMenuAction(menuPath: MenuPath, menu: MenuTree): Disposable { - return this.registerMenuAction(menuPath, menu, command => ({ - execute: (...args) => this.commands.executeCommand(command, ...this.toTreeArgs(...args)), - isEnabled: (...args) => this.commands.isEnabled(command, ...this.toTreeArgs(...args)), - isVisible: (...args) => this.commands.isVisible(command, ...this.toTreeArgs(...args)) - })); - } - protected toTreeArgs(...args: any[]): any[] { - const treeArgs: any[] = []; - for (const arg of args) { - if (TreeViewSelection.is(arg)) { - treeArgs.push(arg); - } - } - return treeArgs; - } - - protected registerTitleAction(location: string, action: Menu, handler: CommandHandler): Disposable { - if (!action.command) { - return Disposable.NULL; - } - const toDispose = new DisposableCollection(); - const id = this.createSyntheticCommandId(action.command, { prefix: `__plugin.${location.replace('/', '.')}.action.` }); - const command: Command = { id }; - toDispose.push(this.commands.registerCommand(command, handler)); - - const { when } = action; - const whenKeys = when && this.contextKeyService.parseKeys(when); - let onDidChange: Event | undefined; - if (whenKeys && whenKeys.size) { - const onDidChangeEmitter = new Emitter(); - toDispose.push(onDidChangeEmitter); - onDidChange = onDidChangeEmitter.event; - Event.addMaxListeners(this.contextKeyService.onDidChange, 1); - toDispose.push(Disposable.create(() => { - Event.addMaxListeners(this.contextKeyService.onDidChange, -1); - })); - toDispose.push(this.contextKeyService.onDidChange(event => { - if (event.affects(whenKeys)) { - onDidChangeEmitter.fire(undefined); + } catch (error) { + console.warn(`Failed to register a menu item for plugin ${plugin.metadata.model.id} contributed to ${contributionPoint}`, item); } - })); - } - - // handle group and priority - // if group is empty or white space is will be set to navigation - // ' ' => ['navigation', 0] - // 'navigation@1' => ['navigation', 1] - // '1_rest-client@2' => ['1_rest-client', 2] - // if priority is not a number it will be set to 0 - // navigation@test => ['navigation', 0] - const [group, sort] = (action.group || 'navigation').split('@'); - const item: Mutable = { id, command: id, group: group.trim() || 'navigation', priority: ~~sort || undefined, when, onDidChange }; - toDispose.push(this.tabBarToolbar.registerItem(item)); - - toDispose.push(this.onDidRegisterCommand(action.command, pluginCommand => { - command.category = pluginCommand.category; - item.tooltip = pluginCommand.label; - if (group === 'navigation') { - command.iconClass = pluginCommand.iconClass; - } - })); - return toDispose; - } - - protected registerScmTitleAction(location: string, action: Menu): Disposable { - if (!action.command) { - return Disposable.NULL; - } - const selectedRepository = () => this.toScmArg(this.scmService.selectedRepository); - return this.registerTitleAction(location, action, { - execute: widget => widget instanceof ScmWidget && this.commands.executeCommand(action.command!, selectedRepository()), - isEnabled: widget => widget instanceof ScmWidget && this.commands.isEnabled(action.command!, selectedRepository()), - isVisible: widget => widget instanceof ScmWidget && this.commands.isVisible(action.command!, selectedRepository()) - }); - } - protected registerScmMenuAction(menuPath: MenuPath, menu: MenuTree): Disposable { - return this.registerMenuAction(menuPath, menu, command => ({ - execute: (...args) => this.commands.executeCommand(command, ...this.toScmArgs(...args)), - isEnabled: (...args) => this.commands.isEnabled(command, ...this.toScmArgs(...args)), - isVisible: (...args) => this.commands.isVisible(command, ...this.toScmArgs(...args)) - })); - } - protected toScmArgs(...args: any[]): any[] { - const scmArgs: any[] = []; - for (const arg of args) { - const scmArg = this.toScmArg(arg); - if (scmArg) { - scmArgs.push(scmArg); } } - return scmArgs; - } - protected toScmArg(arg: any): ScmCommandArg | undefined { - if (arg instanceof ScmRepository && arg.provider instanceof PluginScmProvider) { - return { - sourceControlHandle: arg.provider.handle - }; - } - if (arg instanceof PluginScmResourceGroup) { - return { - sourceControlHandle: arg.provider.handle, - resourceGroupHandle: arg.handle - }; - } - if (arg instanceof PluginScmResource) { - return { - sourceControlHandle: arg.group.provider.handle, - resourceGroupHandle: arg.group.handle, - resourceStateHandle: arg.handle - }; - } - } - protected toTimelineArgs(...args: any[]): any[] { - const timelineArgs: any[] = []; - const arg = args[0]; - timelineArgs.push(this.toTimelineArg(arg)); - timelineArgs.push(CodeUri.parse(arg.uri)); - timelineArgs.push('source' in arg ? arg.source : ''); - return timelineArgs; - } - protected toTimelineArg(arg: TimelineItem): TimelineCommandArg { - return { - timelineHandle: arg.handle, - source: arg.source, - uri: arg.uri - }; + return toDispose; } - protected toCommentArgs(...args: any[]): any[] { - const arg = args[0]; - if ('text' in arg) { - if ('commentUniqueId' in arg) { - return [{ - commentControlHandle: arg.thread.controllerHandle, - commentThreadHandle: arg.thread.commentThreadHandle, - text: arg.text, - commentUniqueId: arg.commentUniqueId - }]; - } - return [{ - commentControlHandle: arg.thread.controllerHandle, - commentThreadHandle: arg.thread.commentThreadHandle, - text: arg.text - }]; + private parseGroup(rawGroup?: string): { group?: string, order?: string } { + if (!rawGroup) { return {}; } + const separatorIndex = rawGroup.lastIndexOf('@'); + if (separatorIndex > -1) { + return { group: rawGroup.substring(0, separatorIndex), order: rawGroup.substring(separatorIndex + 1) || undefined }; } - return [{ - commentControlHandle: arg.thread.controllerHandle, - commentThreadHandle: arg.thread.commentThreadHandle, - commentUniqueId: arg.commentUniqueId - }]; + return { group: rawGroup }; } - protected registerGlobalMenuAction(menu: MenuTree, location: string, plugin: DeployedPlugin): Disposable { - const menuPaths = MenusContributionPointHandler.parseMenuPaths(location); - if (!menuPaths.length) { - this.logger.warn(`'${plugin.metadata.model.id}' plugin contributes items to a menu with invalid identifier: ${location}`); - return Disposable.NULL; + private registerCommandPaletteAction(menu: Menu): Disposable { + if (menu.command && menu.when) { + return this.quickCommandService.pushCommandContext(menu.command, menu.when); } - - const selectedResource = () => { - const selection = this.selectionService.selection; - if (TreeWidgetSelection.is(selection) && selection.source instanceof TreeViewWidget && selection[0]) { - return selection.source.toTreeViewSelection(selection[0]); - } - const uri = this.resourceContextKey.get(); - return uri ? uri['codeUri'] : undefined; - }; - - const toDispose = new DisposableCollection(); - menuPaths.forEach(menuPath => { - toDispose.push(this.registerMenuAction(menuPath, menu, command => ({ - execute: () => this.commands.executeCommand(command, selectedResource()), - isEnabled: () => this.commands.isEnabled(command, selectedResource()), - isVisible: () => this.commands.isVisible(command, selectedResource()) - }))); - }); - return toDispose; + return Disposable.NULL; } - protected registerMenuAction(menuPath: MenuPath, menu: MenuTree, handler: (command: string) => CommandHandler): Disposable { - if (!menu.command) { - return Disposable.NULL; - } - const toDispose = new DisposableCollection(); - const commandId = this.createSyntheticCommandId(menu.command, { prefix: '__plugin.menu.action.' }); - const altId = menu.alt && this.createSyntheticCommandId(menu.alt, { prefix: '__plugin.menu.action.' }); - - const inline = Boolean(menu.group && /^inline/.test(menu.group)); - const [group, order = undefined] = (menu.group || '_').split('@'); - - const command: Command = { id: commandId }; - const action: MenuAction = { commandId, alt: altId, order, when: menu.when }; - - toDispose.push(this.commands.registerCommand(command, handler(menu.command))); - toDispose.push(this.quickCommandService?.pushCommandContext(commandId, 'false')); - toDispose.push(this.menuRegistry.registerMenuAction(inline ? menuPath : [...menuPath, ...group.split('/')], action)); - toDispose.push(this.onDidRegisterCommand(menu.command, pluginCommand => { - command.category = pluginCommand.category; - command.label = pluginCommand.label; - if (inline) { - command.iconClass = pluginCommand.iconClass; - } - })); - - if (menu.alt && altId) { - const alt: Command = { id: altId }; - toDispose.push(this.commands.registerCommand(alt, handler(menu.alt))); - toDispose.push(this.quickCommandService?.pushCommandContext(altId, 'false')); - toDispose.push(this.onDidRegisterCommand(menu.alt, pluginCommand => { - alt.category = pluginCommand.category; - alt.label = pluginCommand.label; - if (inline) { - alt.iconClass = pluginCommand.iconClass; + protected checkTitleContribution(contributionPoint: ContributionPoint | string, contribution: { when?: string }, toDispose: DisposableCollection): void { + if (contribution.when && contributionPoint.endsWith('title')) { + const expression = ContextKeyExpr.deserialize(contribution.when); + if (expression) { + for (const key of expression.keys()) { + this.titleContributionContextKeys.add(key); + toDispose.push(Disposable.create(() => this.titleContributionContextKeys.delete(key))); } - })); - } - - // Register a submenu if the group is in format `//.../` - if (group.includes('/')) { - const groupSplit = group.split('/'); - const orderSplit = (menu.submenusOrder || '').split('/'); - const paths: string[] = []; - for (let i = 0, j = 0; i < groupSplit.length - 1; i += 2, j += 1) { - const submenuGroup = groupSplit[i]; - const submenuLabel = groupSplit[i + 1]; - const submenuOrder = orderSplit[j]; - paths.push(submenuGroup, submenuLabel); - toDispose.push(this.menuRegistry.registerSubmenu([...menuPath, ...paths], submenuLabel, { order: submenuOrder })); + toDispose.push(Disposable.create(() => this.onDidChangeTitleContributionEmitter.fire())); } } - - return toDispose; - } - - protected createSyntheticCommandId(command: string, { prefix }: { prefix: string }): string { - let id = prefix + command; - let index = 0; - while (this.commands.getCommand(id)) { - id = prefix + command + ':' + index; - index++; - } - return id; - } - - protected onDidRegisterCommand(id: string, cb: (command: Command) => void): Disposable { - const command = this.commands.getCommand(id); - if (command) { - cb(command); - return Disposable.NULL; - } - const toDispose = new DisposableCollection(); - // Registering a menu action requires the related command to be already registered. - // But Theia plugin registers the commands dynamically via the Commands API. - // Let's wait for ~2 sec. It should be enough to finish registering all the contributed commands. - // FIXME: remove this workaround (timer) once the https://github.com/theia-ide/theia/issues/3344 is fixed - const handle = setTimeout(() => toDispose.push(this.onDidRegisterCommand(id, cb)), 2000); - toDispose.push(Disposable.create(() => clearTimeout(handle))); - return toDispose; - } - -} - -/** - * MenuTree representing a (sub)menu in the menu tree structure. - */ -export class MenuTree implements Menu { - - protected _children: MenuTree[] = []; - command?: string; - alt?: string; - group?: string; - when?: string; - /** The orders of the menu items which lead to the submenus */ - submenusOrder?: string; - - constructor(menu?: Menu, - /** The location where the menu item will be open from. */ - public readonly id?: string, - /** The label of the menu item which leads to the submenu. */ - public label?: string) { - if (menu) { - this.command = menu.command; - this.alt = menu.alt; - this.group = menu.group; - this.when = menu.when; - } - } - - get children(): MenuTree[] { - return this._children; - } - set children(items: MenuTree[]) { - this._children.push(...items); - } - - public addChild(node: MenuTree): void { - this._children.push(node); - } - - get isSubmenu(): boolean { - return this.label !== undefined; } } diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts new file mode 100644 index 0000000000000..9a8cc51aa1df0 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -0,0 +1,256 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { CommandRegistry, Disposable, MenuCommandAdapter, MenuPath, SelectionService, UriSelection } from '@theia/core'; +import { ResourceContextKey } from '@theia/core/lib/browser/resource-context-key'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { URI as CodeUri } from '@theia/core/shared/vscode-uri'; +import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection'; +import { ScmRepository } from '@theia/scm/lib/browser/scm-repository'; +import { ScmService } from '@theia/scm/lib/browser/scm-service'; +import { TimelineItem } from '@theia/timeline/lib/common/timeline-model'; +import { ScmCommandArg, TimelineCommandArg, TreeViewSelection } from '../../../common'; +import { PluginScmProvider, PluginScmResource, PluginScmResourceGroup } from '../scm-main'; +import { TreeViewWidget } from '../view/tree-view-widget'; +import { CodeEditorWidgetUtil, codeToTheiaMappings, ContributionPoint } from './vscode-theia-menu-mappings'; +import { TAB_BAR_TOOLBAR_CONTEXT_MENU } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; + +export type ArgumentAdapter = (...args: unknown[]) => unknown[]; + +export class ReferenceCountingSet { + protected readonly references: Map; + constructor(initialMembers?: Iterable) { + this.references = new Map(); + if (initialMembers) { + for (const member of initialMembers) { + this.add(member); + } + } + } + + add(newMember: T): ReferenceCountingSet { + const value = this.references.get(newMember) ?? 0; + this.references.set(newMember, value + 1); + return this; + } + + /** @returns true if the deletion results in the removal of the element from the set */ + delete(member: T): boolean { + const value = this.references.get(member); + if (value === undefined) { } else if (value <= 1) { + this.references.delete(member); + return true; + } else { + this.references.set(member, value - 1); + } + return false; + } + + has(maybeMember: T): boolean { + return this.references.has(maybeMember); + } +} + +@injectable() +export class PluginMenuCommandAdapter implements MenuCommandAdapter { + @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; + @inject(CodeEditorWidgetUtil) protected readonly codeEditorUtil: CodeEditorWidgetUtil; + @inject(ScmService) protected readonly scmService: ScmService; + @inject(SelectionService) protected readonly selectionService: SelectionService; + @inject(ResourceContextKey) protected readonly resourceContextKey: ResourceContextKey; + + protected readonly commands = new ReferenceCountingSet(); + protected readonly argumentAdapters = new Map(); + protected readonly separator = ':)(:'; + + @postConstruct() + protected init(): void { + const toCommentArgs: ArgumentAdapter = (...args) => this.toCommentArgs(...args); + const firstArgOnly: ArgumentAdapter = (...args) => [args[0]]; + const noArgs: ArgumentAdapter = () => []; + const toScmArgs: ArgumentAdapter = (...args) => this.toScmArgs(...args); + const selectedResource = () => this.getSelectedResource(); + const widgetURI: ArgumentAdapter = widget => this.codeEditorUtil.is(widget) ? [this.codeEditorUtil.getResourceUri(widget)] : []; + (>[ + ['comments/comment/context', toCommentArgs], + ['comments/comment/title', toCommentArgs], + ['comments/commentThread/context', toCommentArgs], + ['debug/callstack/context', firstArgOnly], + ['editor/context', selectedResource], + ['editor/title', widgetURI], + ['editor/title/context', selectedResource], + ['explorer/context', selectedResource], + ['scm/resourceFolder/context', toScmArgs], + ['scm/resourceGroup/context', toScmArgs], + ['scm/resourceState/context', toScmArgs], + ['scm/title', () => this.toScmArg(this.scmService.selectedRepository)], + ['timeline/item/context', (...args) => this.toTimelineArgs(...args)], + ['view/item/context', (...args) => this.toTreeArgs(...args)], + ['view/title', noArgs], + ]).forEach(([contributionPoint, adapter]) => { + if (adapter) { + const paths = codeToTheiaMappings.get(contributionPoint); + if (paths) { + paths.forEach(path => this.addArgumentAdapter(path, adapter)); + } + } + }); + this.addArgumentAdapter(TAB_BAR_TOOLBAR_CONTEXT_MENU, widgetURI); + } + + canHandle(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): number { + if (this.commands.has(command) && this.getArgumentAdapterForMenu(menuPath)) { + return 500; + } + return -1; + } + + executeCommand(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): Promise { + const argumentAdapter = this.getAdapterOrThrow(menuPath); + return this.commandRegistry.executeCommand(command, ...argumentAdapter(...commandArgs)); + } + + isVisible(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { + const argumentAdapter = this.getAdapterOrThrow(menuPath); + return this.commandRegistry.isVisible(command, ...argumentAdapter(...commandArgs)); + } + + isEnabled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { + const argumentAdapter = this.getAdapterOrThrow(menuPath); + return this.commandRegistry.isEnabled(command, ...argumentAdapter(...commandArgs)); + } + + isToggled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { + const argumentAdapter = this.getAdapterOrThrow(menuPath); + return this.commandRegistry.isToggled(command, ...argumentAdapter(...commandArgs)); + } + + protected getAdapterOrThrow(menuPath: MenuPath): ArgumentAdapter { + const argumentAdapter = this.getArgumentAdapterForMenu(menuPath); + if (!argumentAdapter) { + throw new Error('PluginMenuCommandAdapter attempted to execute command for unregistered menu: ' + JSON.stringify(menuPath)); + } + return argumentAdapter; + } + + addCommand(commandId: string): Disposable { + this.commands.add(commandId); + return Disposable.create(() => this.commands.delete(commandId)); + } + + protected getArgumentAdapterForMenu(menuPath: MenuPath): ArgumentAdapter | undefined { + return this.argumentAdapters.get(menuPath.join(this.separator)); + } + + protected addArgumentAdapter(menuPath: MenuPath, adapter: ArgumentAdapter): void { + this.argumentAdapters.set(menuPath.join(this.separator), adapter); + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + + protected toCommentArgs(...args: any[]): any[] { + const arg = args[0]; + if ('text' in arg) { + if ('commentUniqueId' in arg) { + return [{ + commentControlHandle: arg.thread.controllerHandle, + commentThreadHandle: arg.thread.commentThreadHandle, + text: arg.text, + commentUniqueId: arg.commentUniqueId + }]; + } + return [{ + commentControlHandle: arg.thread.controllerHandle, + commentThreadHandle: arg.thread.commentThreadHandle, + text: arg.text + }]; + } + return [{ + commentControlHandle: arg.thread.controllerHandle, + commentThreadHandle: arg.thread.commentThreadHandle, + commentUniqueId: arg.commentUniqueId + }]; + } + + protected toScmArgs(...args: any[]): any[] { + const scmArgs: any[] = []; + for (const arg of args) { + const scmArg = this.toScmArg(arg); + if (scmArg) { + scmArgs.push(scmArg); + } + } + return scmArgs; + } + + protected toScmArg(arg: any): ScmCommandArg | undefined { + if (arg instanceof ScmRepository && arg.provider instanceof PluginScmProvider) { + return { + sourceControlHandle: arg.provider.handle + }; + } + if (arg instanceof PluginScmResourceGroup) { + return { + sourceControlHandle: arg.provider.handle, + resourceGroupHandle: arg.handle + }; + } + if (arg instanceof PluginScmResource) { + return { + sourceControlHandle: arg.group.provider.handle, + resourceGroupHandle: arg.group.handle, + resourceStateHandle: arg.handle + }; + } + } + + protected toTimelineArgs(...args: any[]): any[] { + const timelineArgs: any[] = []; + const arg = args[0]; + timelineArgs.push(this.toTimelineArg(arg)); + timelineArgs.push(CodeUri.parse(arg.uri)); + timelineArgs.push('source' in arg ? arg.source : ''); + return timelineArgs; + } + + protected toTimelineArg(arg: TimelineItem): TimelineCommandArg { + return { + timelineHandle: arg.handle, + source: arg.source, + uri: arg.uri + }; + } + + protected toTreeArgs(...args: any[]): any[] { + const treeArgs: any[] = []; + for (const arg of args) { + if (TreeViewSelection.is(arg)) { + treeArgs.push(arg); + } + } + return treeArgs; + } + + protected getSelectedResource(): [CodeUri | TreeViewSelection | undefined] { + const selection = this.selectionService.selection; + if (TreeWidgetSelection.is(selection) && selection.source instanceof TreeViewWidget && selection[0]) { + return [selection.source.toTreeViewSelection(selection[0])]; + } + const uri = UriSelection.getUri(selection) ?? this.resourceContextKey.get(); + return [uri ? uri['codeUri'] : undefined]; + } + /* eslint-enable @typescript-eslint/no-explicit-any */ +} diff --git a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts new file mode 100644 index 0000000000000..774cb0574d54d --- /dev/null +++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts @@ -0,0 +1,84 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { MenuPath } from '@theia/core'; +import { SHELL_TABBAR_CONTEXT_MENU } from '@theia/core/lib/browser'; +import { Navigatable } from '@theia/core/lib/browser/navigatable'; +import { injectable } from '@theia/core/shared/inversify'; +import { URI as CodeUri } from '@theia/core/shared/vscode-uri'; +import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stack-frames-widget'; +import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; +import { EditorWidget, EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser'; +import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; +import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; +import { TIMELINE_ITEM_CONTEXT_MENU } from '@theia/timeline/lib/browser/timeline-tree-widget'; +import { COMMENT_CONTEXT, COMMENT_THREAD_CONTEXT, COMMENT_TITLE } from '../comments/comment-thread-widget'; +import { VIEW_ITEM_CONTEXT_MENU } from '../view/tree-view-widget'; +import { WebviewWidget } from '../webview/webview'; + +export const PLUGIN_EDITOR_TITLE_MENU = ['plugin_editor/title']; +export const PLUGIN_SCM_TITLE_MENU = ['plugin_scm/title']; +export const PLUGIN_VIEW_TITLE_MENU = ['plugin_view/title']; + +export const implementedVSCodeContributionPoints = [ + 'comments/comment/context', + 'comments/comment/title', + 'comments/commentThread/context', + 'debug/callstack/context', + 'editor/context', + 'editor/title', + 'editor/title/context', + 'explorer/context', + 'scm/resourceFolder/context', + 'scm/resourceGroup/context', + 'scm/resourceState/context', + 'scm/title', + 'timeline/item/context', + 'view/item/context', + 'view/title' +] as const; + +export type ContributionPoint = (typeof implementedVSCodeContributionPoints)[number]; + +export const codeToTheiaMappings = new Map([ + ['comments/comment/context', [COMMENT_CONTEXT]], + ['comments/comment/title', [COMMENT_TITLE]], + ['comments/commentThread/context', [COMMENT_THREAD_CONTEXT]], + ['debug/callstack/context', [DebugStackFramesWidget.CONTEXT_MENU, DebugThreadsWidget.CONTEXT_MENU]], + ['editor/context', [EDITOR_CONTEXT_MENU]], + ['editor/title', [PLUGIN_EDITOR_TITLE_MENU]], + ['editor/title/context', [SHELL_TABBAR_CONTEXT_MENU]], + ['explorer/context', [NAVIGATOR_CONTEXT_MENU]], + ['scm/resourceFolder/context', [ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU]], + ['scm/resourceGroup/context', [ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU]], + ['scm/resourceState/context', [ScmTreeWidget.RESOURCE_CONTEXT_MENU]], + ['scm/title', [PLUGIN_SCM_TITLE_MENU]], + ['timeline/item/context', [TIMELINE_ITEM_CONTEXT_MENU]], + ['view/item/context', [VIEW_ITEM_CONTEXT_MENU]], + ['view/title', [PLUGIN_VIEW_TITLE_MENU]], +]); + +type CodeEditorWidget = EditorWidget | WebviewWidget; +@injectable() +export class CodeEditorWidgetUtil { + is(arg: unknown): arg is CodeEditorWidget { + return arg instanceof EditorWidget || arg instanceof WebviewWidget; + } + getResourceUri(editor: CodeEditorWidget): CodeUri | undefined { + const resourceUri = Navigatable.is(editor) && editor.getResourceUri(); + return resourceUri ? resourceUri['codeUri'] : undefined; + } +} diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index a4dc837890360..3c560527cf4f8 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -35,7 +35,7 @@ import { PluginWidget } from './plugin-ext-widget'; import { PluginFrontendViewContribution } from './plugin-frontend-view-contribution'; import { PluginExtDeployCommandService } from './plugin-ext-deploy-command'; import { EditorModelService } from './text-editor-model-service'; -import { CodeEditorWidgetUtil, MenusContributionPointHandler } from './menus/menus-contribution-handler'; +import { MenusContributionPointHandler } from './menus/menus-contribution-handler'; import { PluginContributionHandler } from './plugin-contribution-handler'; import { PluginViewRegistry, PLUGIN_VIEW_CONTAINER_FACTORY_ID, PLUGIN_VIEW_FACTORY_ID, PLUGIN_VIEW_DATA_FACTORY_ID } from './view/plugin-view-registry'; import { TextContentResourceResolver } from './workspace-main'; @@ -78,6 +78,8 @@ import { WebviewFrontendSecurityWarnings } from './webview/webview-frontend-secu import { PluginAuthenticationServiceImpl } from './plugin-authentication-service'; import { AuthenticationService } from '@theia/core/lib/browser/authentication-service'; import { bindTreeViewDecoratorUtilities, TreeViewDecoratorService } from './view/tree-view-decorator-service'; +import { CodeEditorWidgetUtil } from './menus/vscode-theia-menu-mappings'; +import { PluginMenuCommandAdapter } from './menus/plugin-menu-command-adapter'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -213,6 +215,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(LabelProviderContribution).toService(PluginIconThemeService); bind(MenusContributionPointHandler).toSelf().inSingletonScope(); + bind(PluginMenuCommandAdapter).toSelf().inSingletonScope(); bind(CodeEditorWidgetUtil).toSelf().inSingletonScope(); bind(KeybindingsContributionPointHandler).toSelf().inSingletonScope(); bind(PluginContributionHandler).toSelf().inSingletonScope(); diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx index 612cd49a605c2..f49d60e0e4e26 100644 --- a/packages/scm/src/browser/scm-tree-widget.tsx +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -40,13 +40,13 @@ export class ScmTreeWidget extends TreeWidget { static ID = 'scm-resource-widget'; static RESOURCE_GROUP_CONTEXT_MENU = ['RESOURCE_GROUP_CONTEXT_MENU']; - static RESOURCE_GROUP_INLINE_MENU = ['RESOURCE_GROUP_INLINE_MENU']; + static RESOURCE_GROUP_INLINE_MENU = ['RESOURCE_GROUP_CONTEXT_MENU', 'inline']; static RESOURCE_FOLDER_CONTEXT_MENU = ['RESOURCE_FOLDER_CONTEXT_MENU']; - static RESOURCE_FOLDER_INLINE_MENU = ['RESOURCE_FOLDER_INLINE_MENU']; + static RESOURCE_FOLDER_INLINE_MENU = ['RESOURCE_FOLDER_CONTEXT_MENU', 'inline']; - static RESOURCE_INLINE_MENU = ['RESOURCE_INLINE_MENU']; static RESOURCE_CONTEXT_MENU = ['RESOURCE_CONTEXT_MENU']; + static RESOURCE_INLINE_MENU = ['RESOURCE_CONTEXT_MENU', 'inline']; @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; @inject(CommandRegistry) protected readonly commands: CommandRegistry; From 6b5d22386f24784c32b374eb5530bef1d9d79dc9 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 14 Jun 2022 14:53:13 -0600 Subject: [PATCH 02/24] support menus on tabbars --- .../src/browser/shell/tab-bar-toolbar.tsx | 35 ++++++++++++++++-- packages/core/src/common/menu.ts | 16 ++++++--- .../plugin-ext/src/common/plugin-protocol.ts | 1 + .../src/hosted/node/scanners/scanner-theia.ts | 36 +++++++++++-------- .../menus/menus-contribution-handler.ts | 20 +++++++++-- 5 files changed, 83 insertions(+), 25 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar.tsx index 051509d54600a..d4b819e47caa5 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar.tsx @@ -142,6 +142,16 @@ export namespace MenuDelegateToolbarItem { } } +interface SubmenuToolbarItem extends TabBarToolbarItem { + prefix: string; +} + +namespace SubmenuToolbarItem { + export function is(candidate: TabBarToolbarItem): candidate is SubmenuToolbarItem { + return typeof (candidate as SubmenuToolbarItem).prefix === 'string'; + } +} + /** * Tab-bar toolbar item backed by a `React.ReactNode`. * Unlike the `TabBarToolbarItem`, this item is not connected to the command service. @@ -278,6 +288,19 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { const nextGroup = item === menu ? group : this.formatGroupForSubmenus(group, item.id, item.label); + if (group === 'navigation') { + const asSubmenuItem: SubmenuToolbarItem = { + id: `submenu_as_toolbar_item_${item.id}`, + command: '_never_', + prefix: item.id, + when: item.when, + icon: item.icon, + group, + }; + if (!asSubmenuItem.when || this.contextKeyService.match(asSubmenuItem.when, widget.node)) { + result.push(asSubmenuItem); + } + } item.children.forEach(child => menuToTabbarItems(child, nextGroup)); } else if (!Array.isArray(item.children)) { const asToolbarItem: MenuDelegateToolbarItem = { @@ -452,7 +475,7 @@ export class TabBarToolbar extends ReactWidget { classNames.push(iconClass); } const tooltip = item.tooltip || (command && command.label); - const toolbarItemClassNames = this.getToolbarItemClassNames(command?.id); + const toolbarItemClassNames = this.getToolbarItemClassNames(command?.id ?? item.command); return
this.removeClass('menu-open'))); for (const item of this.more.values()) { const separator = item.group && [menuDelegateSeparator, '/'].find(candidate => item.group?.includes(candidate)); + if (prefix && !item.group?.startsWith(`navigation${separator}${prefix}`) || !prefix && item.group?.startsWith(`navigation${separator}`)) { + continue; + } // Register a submenu for the item, if the group is in format `//.../` if (separator) { const split = item.group.split(separator); @@ -543,6 +569,9 @@ export class TabBarToolbar extends ReactWidget { const item = this.inline.get(e.currentTarget.id); if (TabBarToolbarItem.is(item)) { + if (SubmenuToolbarItem.is(item)) { + return this.renderMoreContextMenu(e.nativeEvent, item.prefix); + } const menuPath = MenuDelegateToolbarItem.getMenuPath(item); if (menuPath) { this.menuCommandExecutor.executeCommand(menuPath, item.command, this.current); diff --git a/packages/core/src/common/menu.ts b/packages/core/src/common/menu.ts index 00edebb9da8a3..5342530eebb8a 100644 --- a/packages/core/src/common/menu.ts +++ b/packages/core/src/common/menu.ts @@ -224,11 +224,11 @@ export class MenuModelRegistry { } } - registerIndependentSubmenu(id: string, label: string): Disposable { + registerIndependentSubmenu(id: string, label: string, options?: SubMenuOptions): Disposable { if (this.independentSubmenus.has(id)) { console.debug(`Independent submenu with path ${id} registered, but given ID already exists.`); } - this.independentSubmenus.set(id, new CompositeMenuNode(id, label)); + this.independentSubmenus.set(id, new CompositeMenuNode(id, label, options)); return { dispose: () => this.independentSubmenus.delete(id) }; } @@ -388,6 +388,10 @@ export class CompositeMenuNode implements MenuNode { } } + get icon(): string | undefined { + return this.iconClass; + } + get children(): ReadonlyArray { return this._children; } @@ -471,11 +475,13 @@ export class CompositeMenuNodeWrapper implements MenuNode { get isSubmenu(): boolean { return this.label !== undefined; } - get iconClass(): string | undefined { return this.options?.iconClass; } + get icon(): string | undefined { return this.iconClass; } + + get iconClass(): string | undefined { return this.options?.iconClass ?? this.wrapped.iconClass; } - get order(): string | undefined { return this.options?.order; } + get order(): string | undefined { return this.options?.order ?? this.wrapped.order; } - get when(): string | undefined { return this.options?.when; } + get when(): string | undefined { return this.options?.when ?? this.wrapped.when; } get children(): ReadonlyArray { return this.wrapped.children; } } diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index d6ce7ea8953dd..d4aeea1ac05cc 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -171,6 +171,7 @@ export interface PluginPackageMenu { export interface PluginPackageSubmenu { id: string; label: string; + icon: IconUrl; } export interface PluginPackageKeybinding { diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index add65e68c4941..4f3329b8bedce 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -190,7 +190,7 @@ export class TheiaPluginScanner implements PluginScanner { try { if (rawPlugin.contributes!.submenus) { - contributions.submenus = this.readSubmenus(rawPlugin.contributes.submenus!); + contributions.submenus = this.readSubmenus(rawPlugin.contributes.submenus!, rawPlugin); } } catch (err) { console.error(`Could not read '${rawPlugin.name}' contribution 'submenus'.`, rawPlugin.contributes!.submenus, err); @@ -390,23 +390,27 @@ export class TheiaPluginScanner implements PluginScanner { } protected readCommand({ command, title, original, category, icon, enablement }: PluginPackageCommand, pck: PluginPackage): PluginCommand { - let themeIcon: string | undefined; - let iconUrl: IconUrl | undefined; - if (icon) { - if (typeof icon === 'string') { - if (icon.startsWith('$(')) { - themeIcon = icon; + const { themeIcon, iconUrl } = this.transformIconUrl(pck, icon) ?? {}; + return { command, title, originalTitle: original, category, iconUrl, themeIcon, enablement }; + } + + protected transformIconUrl(plugin: PluginPackage, original?: IconUrl): { iconUrl?: IconUrl; themeIcon?: string } | undefined { + if (original) { + if (typeof original === 'string') { + if (original.startsWith('$(')) { + return { themeIcon: original }; } else { - iconUrl = this.toPluginUrl(pck, icon); + return { iconUrl: this.toPluginUrl(plugin, original) }; } } else { - iconUrl = { - light: this.toPluginUrl(pck, icon.light), - dark: this.toPluginUrl(pck, icon.dark) + return { + iconUrl: { + light: this.toPluginUrl(plugin, original.light), + dark: this.toPluginUrl(plugin, original.dark) + } }; } } - return { command, title, originalTitle: original, category, iconUrl, themeIcon, enablement }; } protected toPluginUrl(pck: PluginPackage, relativePath: string): string { @@ -629,12 +633,14 @@ export class TheiaPluginScanner implements PluginScanner { return rawLanguages.map(language => this.readLanguage(language, pluginPath)); } - private readSubmenus(rawSubmenus: PluginPackageSubmenu[]): Submenu[] { - return rawSubmenus.map(submenu => this.readSubmenu(submenu)); + private readSubmenus(rawSubmenus: PluginPackageSubmenu[], plugin: PluginPackage): Submenu[] { + return rawSubmenus.map(submenu => this.readSubmenu(submenu, plugin)); } - private readSubmenu(rawSubmenu: PluginPackageSubmenu): Submenu { + private readSubmenu(rawSubmenu: PluginPackageSubmenu, plugin: PluginPackage): Submenu { + const icon = this.transformIconUrl(plugin, rawSubmenu.icon); return { + icon: icon?.iconUrl ?? icon?.themeIcon, id: rawSubmenu.id, label: rawSubmenu.label }; diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index e8a30af9d04df..bccc740ccdc26 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -20,7 +20,7 @@ import { inject, injectable, optional } from '@theia/core/shared/inversify'; import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter } from '@theia/core'; import { MenuModelRegistry } from '@theia/core/lib/common'; import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; -import { DeployedPlugin, Menu } from '../../../common'; +import { DeployedPlugin, IconUrl, Menu } from '../../../common'; import { ScmWidget } from '@theia/scm/lib/browser/scm-widget'; import { PluginViewWidget } from '../view/plugin-view-widget'; import { QuickCommandService } from '@theia/core/lib/browser'; @@ -31,6 +31,8 @@ import { import { PluginMenuCommandAdapter, ReferenceCountingSet } from './plugin-menu-command-adapter'; import { ContextKeyExpr } from '@theia/monaco-editor-core/esm/vs/platform/contextkey/common/contextkey'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; +import { PluginSharedStyle } from '../plugin-shared-style'; +import { ThemeIcon } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService'; @injectable() export class MenusContributionPointHandler { @@ -42,6 +44,7 @@ export class MenusContributionPointHandler { @inject(PluginMenuCommandAdapter) protected readonly commandAdapter: PluginMenuCommandAdapter; @inject(MenuCommandAdapterRegistry) protected readonly commandAdapterRegistry: MenuCommandAdapterRegistry; @inject(ContextKeyService) protected readonly contextKeySerivce: ContextKeyService; + @inject(PluginSharedStyle) protected readonly style: PluginSharedStyle; @inject(QuickCommandService) @optional() private readonly quickCommandService: QuickCommandService; @@ -82,7 +85,8 @@ export class MenusContributionPointHandler { const toDispose = new DisposableCollection(); const submenus = plugin.contributes?.submenus ?? []; for (const submenu of submenus) { - this.menuRegistry.registerIndependentSubmenu(submenu.id, submenu.label); + const iconClass = submenu.icon && this.toIconClass(submenu.icon, toDispose); + this.menuRegistry.registerIndependentSubmenu(submenu.id, submenu.label, iconClass ? { iconClass } : undefined); } for (const [contributionPoint, items] of Object.entries(allMenus)) { @@ -148,4 +152,16 @@ export class MenusContributionPointHandler { } } } + + protected toIconClass(url: IconUrl, toDispose: DisposableCollection): string | undefined { + if (typeof url === 'string') { + const asThemeIcon = ThemeIcon.fromString(url); + if (asThemeIcon) { + return ThemeIcon.asClassName(asThemeIcon); + } + } + const reference = this.style.toIconClass(url); + toDispose.push(reference); + return reference.object.iconClass; + } } From 6368e57d70b6edfbe189eb3418d8e99894808011 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 14 Jun 2022 16:11:28 -0600 Subject: [PATCH 03/24] Minor cleanup --- .../src/browser/menu/sample-browser-menu-module.ts | 4 ++-- .../core/src/browser/menu/browser-menu-plugin.ts | 12 +++++++++--- .../menu/electron-main-menu-factory.ts | 4 +--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts index 9bf5e2fd620fc..df2bf8530c768 100644 --- a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts +++ b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts @@ -18,7 +18,7 @@ import { injectable, ContainerModule } from '@theia/core/shared/inversify'; import { Menu as MenuWidget } from '@theia/core/shared/@phosphor/widgets'; import { Disposable } from '@theia/core/lib/common/disposable'; import { MenuNode, CompositeMenuNode, MenuPath } from '@theia/core/lib/common/menu'; -import { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget } from '@theia/core/lib/browser/menu/browser-menu-plugin'; +import { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget, BrowserMenuOptions } from '@theia/core/lib/browser/menu/browser-menu-plugin'; import { PlaceholderMenuNode } from './sample-menu-contribution'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -41,7 +41,7 @@ class SampleBrowserMainMenuFactory extends BrowserMainMenuFactory { return menuCommandRegistry; } - override createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry, rootMenuPath: MenuPath }): DynamicMenuWidget { + override createMenuWidget(menu: CompositeMenuNode, options: BrowserMenuOptions): DynamicMenuWidget { return new SampleDynamicMenuWidget(menu, options, this.services); } diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 2d05fed71fd73..82460a338eddb 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -35,6 +35,12 @@ export abstract class MenuBarWidget extends MenuBar { abstract triggerMenuItem(label: string, ...labels: string[]): Promise; } +export interface BrowserMenuOptions extends MenuWidget.IOptions { + commands: MenuCommandRegistry, + context?: HTMLElement, + rootMenuPath: MenuPath +}; + @injectable() export class BrowserMainMenuFactory implements MenuWidgetFactory { @@ -108,7 +114,7 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { return contextMenu; } - createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry, context?: HTMLElement, rootMenuPath: MenuPath }): DynamicMenuWidget { + createMenuWidget(menu: CompositeMenuNode, options: BrowserMenuOptions): DynamicMenuWidget { return new DynamicMenuWidget(menu, options, this.services); } @@ -230,7 +236,7 @@ export class MenuServices { } export interface MenuWidgetFactory { - createMenuWidget(menu: MenuNode & Required>, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): MenuWidget; + createMenuWidget(menu: MenuNode & Required>, options: BrowserMenuOptions): MenuWidget; } /** @@ -245,7 +251,7 @@ export class DynamicMenuWidget extends MenuWidget { constructor( protected menu: CompositeMenuNode, - protected options: MenuWidget.IOptions & { commands: MenuCommandRegistry, context?: HTMLElement, rootMenuPath: MenuPath }, + protected options: BrowserMenuOptions, protected services: MenuServices ) { super(options); 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 cd42e86b26662..10beccbc72dc7 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 @@ -18,9 +18,7 @@ import * as electronRemote from '../../../electron-shared/@electron/remote'; import { inject, injectable, postConstruct } from 'inversify'; -import { - isOSX, ActionMenuNode, MAIN_MENU_BAR, MenuPath, MenuNode -} from '../../common'; +import { isOSX, ActionMenuNode, MAIN_MENU_BAR, MenuPath, MenuNode } from '../../common'; import { Keybinding } from '../../common/keybinding'; import { PreferenceService, CommonCommands } from '../../browser'; import debounce = require('lodash.debounce'); From ffd5841c16f7442622c077aa28c0ff32f9093f77 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 22 Jun 2022 12:01:30 -0600 Subject: [PATCH 04/24] Check submenu when for Browser --- .../core/src/browser/menu/browser-menu-plugin.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 82460a338eddb..fba187baa7fa5 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -293,7 +293,7 @@ export class DynamicMenuWidget extends MenuWidget { private buildSubMenus(items: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { for (const item of (menu.children ?? [])) { if (Array.isArray(item.children)) { - if (item.children.length) { // do not render empty nodes + if (item.children.length && this.undefinedOrMatch(item.when, this.options.context)) { // do not render empty nodes if (item.isSubmenu) { // submenu node const submenu = this.services.menuWidgetFactory.createMenuWidget(item as MenuNode & { children: MenuNode[] }, this.options); if (!submenu.items.length) { @@ -320,10 +320,9 @@ export class DynamicMenuWidget extends MenuWidget { } } } else if (item instanceof ActionMenuNode) { - const { context, contextKeyService } = this.services; + const { context } = this.services; const node = item.altNode && context.altPressed ? item.altNode : item; - const { when } = node.action; - if (commands.isVisible(node.action.commandId) && (!when || contextKeyService.match(when, this.options.context))) { + if (commands.isVisible(node.action.commandId) && this.undefinedOrMatch(node.action.when, this.options.context)) { items.push({ command: node.action.commandId, type: 'command' @@ -336,6 +335,11 @@ export class DynamicMenuWidget extends MenuWidget { return items; } + protected undefinedOrMatch(expression?: string, context?: HTMLElement): boolean { + if (expression) { return this.services.contextKeyService.match(expression, context); } + return true; + } + protected handleDefault(menuNode: MenuNode): MenuWidget.IItemOptions[] { return []; } From 52096865a4ebeff1e90ddf3439acb25ae0260e89 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 22 Jun 2022 12:03:49 -0600 Subject: [PATCH 05/24] Typo --- .../src/main/browser/menus/menus-contribution-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index bccc740ccdc26..169591329acd5 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -43,7 +43,7 @@ export class MenusContributionPointHandler { @inject(CodeEditorWidgetUtil) private readonly codeEditorWidgetUtil: CodeEditorWidgetUtil; @inject(PluginMenuCommandAdapter) protected readonly commandAdapter: PluginMenuCommandAdapter; @inject(MenuCommandAdapterRegistry) protected readonly commandAdapterRegistry: MenuCommandAdapterRegistry; - @inject(ContextKeyService) protected readonly contextKeySerivce: ContextKeyService; + @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(PluginSharedStyle) protected readonly style: PluginSharedStyle; @inject(QuickCommandService) @optional() private readonly quickCommandService: QuickCommandService; @@ -63,7 +63,7 @@ export class MenusContributionPointHandler { this.tabBarToolbar.registerMenuDelegate(PLUGIN_SCM_TITLE_MENU, widget => widget instanceof ScmWidget); this.tabBarToolbar.registerMenuDelegate(PLUGIN_VIEW_TITLE_MENU, widget => widget instanceof PluginViewWidget); this.tabBarToolbar.registerItem({ id: 'plugin-menu-contribution-title-contribution', command: '_never_', onDidChange: this.onDidChangeTitleContributionEmitter.event }); - this.contextKeySerivce.onDidChange(event => { + this.contextKeyService.onDidChange(event => { if (event.affects(this.titleContributionContextKeys)) { this.onDidChangeTitleContributionEmitter.fire(); } From 68bca2edf49acbdac74846574b5f0d2f3df74ad7 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 23 Jun 2022 15:26:33 -0600 Subject: [PATCH 06/24] VF comments --- .../src/browser/shell/side-panel-toolbar.ts | 1 - .../src/browser/shell/tab-bar-toolbar.tsx | 76 +++++++++++++------ .../src/main/browser/plugin-shared-style.ts | 1 - 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/core/src/browser/shell/side-panel-toolbar.ts b/packages/core/src/browser/shell/side-panel-toolbar.ts index 2b5f8072a8854..bde900d714ae3 100644 --- a/packages/core/src/browser/shell/side-panel-toolbar.ts +++ b/packages/core/src/browser/shell/side-panel-toolbar.ts @@ -101,7 +101,6 @@ export class SidePanelToolbar extends BaseWidget { } } - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ showMoreContextMenu(anchor: Anchor): ContextMenuAccess { if (this.toolbar) { return this.toolbar.renderMoreContextMenu(anchor); diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar.tsx index d4b819e47caa5..76c0989ff56a9 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar.tsx @@ -15,20 +15,15 @@ // ***************************************************************************** import debounce = require('lodash.debounce'); -import * as React from 'react'; import { inject, injectable, named } from 'inversify'; -import { Widget, ReactWidget, codicon, ACTION_ITEM } from '../widgets'; -import { LabelParser, LabelIcon } from '../label-parser'; -import { ContributionProvider } from '../../common/contribution-provider'; -import { FrontendApplicationContribution } from '../frontend-application'; -import { CommandRegistry } from '../../common/command'; -import { Disposable, DisposableCollection } from '../../common/disposable'; +import * as React from 'react'; +// eslint-disable-next-line max-len +import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuNode, MenuPath, nls } from '../../common'; import { ContextKeyService } from '../context-key-service'; -import { Event, Emitter } from '../../common/event'; -import { ContextMenuRenderer, Anchor, ContextMenuAccess } from '../context-menu-renderer'; -import { MenuModelRegistry, MenuNode, MenuPath } from '../../common/menu'; -import { nls } from '../../common/nls'; -import { MenuCommandExecutor } from '../../common'; +import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../context-menu-renderer'; +import { FrontendApplicationContribution } from '../frontend-application'; +import { LabelIcon, LabelParser } from '../label-parser'; +import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../widgets'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. @@ -324,12 +319,11 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } protected formatGroupForSubmenus(lastGroup: string, currentId?: string, currentLabel?: string): string { - const split = lastGroup.length ? lastGroup.split(menuDelegateSeparator).filter(segment => segment.length) : []; + const split = lastGroup.length ? lastGroup.split(menuDelegateSeparator) : []; // If the submenu is in the 'navigation' group, then it's an item that opens its own context menu, so it should be navigation/id/label... const expectedParity = split[0] === 'navigation' ? 1 : 0; if (split.length % 2 !== expectedParity && (currentId || currentLabel)) { - console.warn('Something went wrong with a contributed tabbar menu delegate.', lastGroup, currentId, currentLabel); - split.push(''); + split.push(''); } if (currentId || currentLabel) { split.push(currentId || (currentLabel + '_id')); @@ -379,6 +373,7 @@ export interface TabBarToolbarFactory { } export const TAB_BAR_TOOLBAR_CONTEXT_MENU = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; +const submenuItemPrefix = `navigation${menuDelegateSeparator}`; /** * Tab-bar toolbar widget representing the active [tab-bar toolbar items](TabBarToolbarItem). @@ -389,6 +384,7 @@ export class TabBarToolbar extends ReactWidget { protected current: Widget | undefined; protected inline = new Map(); protected more = new Map(); + protected submenuItems = new Map>(); @inject(CommandRegistry) protected readonly commands: CommandRegistry; @inject(LabelParser) protected readonly labelParser: LabelParser; @@ -406,11 +402,16 @@ export class TabBarToolbar extends ReactWidget { updateItems(items: Array, current: Widget | undefined): void { this.inline.clear(); this.more.clear(); + this.submenuItems.clear(); for (const item of items.sort(TabBarToolbarItem.PRIORITY_COMPARATOR).reverse()) { if ('render' in item || item.group === undefined || item.group === 'navigation') { this.inline.set(item.id, item); } else { - this.more.set(item.id, item); + if (item.group?.startsWith(submenuItemPrefix)) { + this.addSubmenuItem(item); + } else { + this.more.set(item.id, item); + } } } this.setCurrent(current); @@ -425,6 +426,21 @@ export class TabBarToolbar extends ReactWidget { this.update(); } + protected addSubmenuItem(item: TabBarToolbarItem): void { + if (item.group) { + let doSet = false; + const secondElementEndIndex = item.group.indexOf(menuDelegateSeparator, submenuItemPrefix.length); + const prefix = secondElementEndIndex === -1 + ? item.group.substring(submenuItemPrefix.length) + : item.group.substring(submenuItemPrefix.length, secondElementEndIndex); + const prefixItems = this.submenuItems.get(prefix) ?? (doSet = true, new Map()); + prefixItems.set(item.id, item); + if (doSet) { + this.submenuItems.set(prefix, prefixItems); + } + } + } + updateTarget(current?: Widget): void { const operativeWidget = TabBarDelegator.is(current) ? current.getTabBarDelegate() : current; const items = operativeWidget ? this.toolbarRegistry.visibleItems(operativeWidget) : []; @@ -456,6 +472,9 @@ export class TabBarToolbar extends ReactWidget { } protected renderItem(item: TabBarToolbarItem): React.ReactNode { + if (SubmenuToolbarItem.is(item) && !this.submenuItems.get(item.prefix)?.size) { + return undefined; + } let innerText = ''; const classNames = []; if (item.text) { @@ -511,16 +530,21 @@ export class TabBarToolbar extends ReactWidget { protected showMoreContextMenu = (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); - - this.renderMoreContextMenu(event.nativeEvent); + const anchor = this.toAnchor(event); + this.renderMoreContextMenu(anchor); }; + protected toAnchor(event: React.MouseEvent): Anchor { + const itemBox = event.currentTarget.closest('.' + TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM)?.getBoundingClientRect(); + return itemBox ? { y: itemBox.bottom, x: itemBox.left } : event.nativeEvent; + } + renderMoreContextMenu(anchor: Anchor, prefix?: string): ContextMenuAccess { - const menuPath = TAB_BAR_TOOLBAR_CONTEXT_MENU; const toDisposeOnHide = new DisposableCollection(); this.addClass('menu-open'); toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open'))); - for (const item of this.more.values()) { + const items = (prefix ? this.submenuItems.get(prefix) ?? new Map() : this.more); + for (const item of items.values()) { const separator = item.group && [menuDelegateSeparator, '/'].find(candidate => item.group?.includes(candidate)); if (prefix && !item.group?.startsWith(`navigation${separator}${prefix}`) || !prefix && item.group?.startsWith(`navigation${separator}`)) { continue; @@ -532,18 +556,21 @@ export class TabBarToolbar extends ReactWidget { for (let i = 0; i < split.length - 1; i += 2) { paths.push(split[i], split[i + 1]); // TODO order is missing, items sorting will be alphabetic - toDisposeOnHide.push(this.menus.registerSubmenu([...menuPath, ...paths], split[i + 1])); + if (split[i + 1]) { + console.log('SENTINEL FOR REGISTERING A SUBMENU...', { group: item.group, paths, split, label: split[i + 1] }); + toDisposeOnHide.push(this.menus.registerSubmenu([...TAB_BAR_TOOLBAR_CONTEXT_MENU, ...paths], split[i + 1])); + } } } // TODO order is missing, items sorting will be alphabetic - toDisposeOnHide.push(this.menus.registerMenuAction([...menuPath, ...item.group!.split('/')], { + toDisposeOnHide.push(this.menus.registerMenuAction([...TAB_BAR_TOOLBAR_CONTEXT_MENU, ...item.group!.split(separator)], { label: item.tooltip, commandId: item.command, when: item.when })); } return this.contextMenuRenderer.render({ - menuPath, + menuPath: TAB_BAR_TOOLBAR_CONTEXT_MENU, args: [this.current], anchor, context: this.current?.node, @@ -570,7 +597,8 @@ export class TabBarToolbar extends ReactWidget { const item = this.inline.get(e.currentTarget.id); if (TabBarToolbarItem.is(item)) { if (SubmenuToolbarItem.is(item)) { - return this.renderMoreContextMenu(e.nativeEvent, item.prefix); + const anchor = this.toAnchor(e); + return this.renderMoreContextMenu(anchor, item.prefix); } const menuPath = MenuDelegateToolbarItem.getMenuPath(item); if (menuPath) { diff --git a/packages/plugin-ext/src/main/browser/plugin-shared-style.ts b/packages/plugin-ext/src/main/browser/plugin-shared-style.ts index 37e97fdb27d73..98daafcaa6450 100644 --- a/packages/plugin-ext/src/main/browser/plugin-shared-style.ts +++ b/packages/plugin-ext/src/main/browser/plugin-shared-style.ts @@ -111,7 +111,6 @@ export class PluginSharedStyle { const iconClass = 'plugin-icon-' + this.iconSequence++; const toDispose = new DisposableCollection(); toDispose.push(this.insertRule('.' + iconClass, theme => ` - display: inline-block; background-position: 2px; width: ${size}px; height: ${size}px; From 0483e0453632555f85d9c57e5577f952fd371ddf Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 28 Jun 2022 08:39:10 -0600 Subject: [PATCH 07/24] Ensure editor/title/context only appears on navigatable widgets - As a consequence of this approach, all plugin-contributed commands will be grouped together --- .../src/browser/menu/browser-menu-plugin.ts | 12 +++++++ .../core/src/browser/resource-context-key.ts | 15 +++++---- packages/core/src/common/logger.ts | 20 +++++------ packages/core/src/common/menu.ts | 5 +-- .../menus/menus-contribution-handler.ts | 15 ++++----- .../menus/plugin-menu-command-adapter.ts | 2 +- .../menus/vscode-theia-menu-mappings.ts | 33 ++++++++++--------- 7 files changed, 58 insertions(+), 44 deletions(-) diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index fba187baa7fa5..e1ea7c30a0d16 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -291,8 +291,10 @@ export class DynamicMenuWidget extends MenuWidget { } private buildSubMenus(items: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { + console.log('SENTINEL FOR THE VALUE OF RESOURCE PATH', (this.services.contextKeyService as any).contextKeyService.getContextKeyValue('resourcePath')); for (const item of (menu.children ?? [])) { if (Array.isArray(item.children)) { + console.log('SENTINEL FOR CHECKING A WHEN', item); if (item.children.length && this.undefinedOrMatch(item.when, this.options.context)) { // do not render empty nodes if (item.isSubmenu) { // submenu node const submenu = this.services.menuWidgetFactory.createMenuWidget(item as MenuNode & { children: MenuNode[] }, this.options); @@ -323,12 +325,14 @@ export class DynamicMenuWidget extends MenuWidget { const { context } = this.services; const node = item.altNode && context.altPressed ? item.altNode : item; if (commands.isVisible(node.action.commandId) && this.undefinedOrMatch(node.action.when, this.options.context)) { + console.log('SENTINEL FOR ADDING AN ITEM...', item.label, item, menu); items.push({ command: node.action.commandId, type: 'command' }); } } else { + console.log('SENTINEL FOR THE DEFAULT?', item); items.push(...this.handleDefault(item)); } } @@ -336,6 +340,14 @@ export class DynamicMenuWidget extends MenuWidget { } protected undefinedOrMatch(expression?: string, context?: HTMLElement): boolean { + const doTheThing = this.doUndefinedOrMatch(expression, context); + if (expression) { + console.log('SENTINEL FOR THE RESULT FOR', expression, context, doTheThing); + } + return doTheThing; + } + + protected doUndefinedOrMatch(expression?: string, context?: HTMLElement): boolean { if (expression) { return this.services.contextKeyService.match(expression, context); } return true; } diff --git a/packages/core/src/browser/resource-context-key.ts b/packages/core/src/browser/resource-context-key.ts index e4a513076b7fd..3882323b61727 100644 --- a/packages/core/src/browser/resource-context-key.ts +++ b/packages/core/src/browser/resource-context-key.ts @@ -36,6 +36,7 @@ export class ResourceContextKey { protected resourceLangId: ContextKey; protected resourceDirName: ContextKey; protected resourcePath: ContextKey; + protected resourceSet: ContextKey; @postConstruct() protected init(): void { @@ -46,6 +47,7 @@ export class ResourceContextKey { this.resourceLangId = this.contextKeyService.createKey('resourceLangId', undefined); this.resourceDirName = this.contextKeyService.createKey('resourceDirName', undefined); this.resourcePath = this.contextKeyService.createKey('resourcePath', undefined); + this.resourceSet = this.contextKeyService.createKey('resourceSet', false); } get(): URI | undefined { @@ -54,13 +56,14 @@ export class ResourceContextKey { } set(resourceUri: URI | undefined): void { - this.resource.set(resourceUri && resourceUri['codeUri']); - this.resourceSchemeKey.set(resourceUri && resourceUri.scheme); - this.resourceFileName.set(resourceUri && resourceUri.path.base); - this.resourceExtname.set(resourceUri && resourceUri.path.ext); + this.resource.set(resourceUri?.['codeUri']); + this.resourceSchemeKey.set(resourceUri?.scheme); + this.resourceFileName.set(resourceUri?.path.base); + this.resourceExtname.set(resourceUri?.path.ext); this.resourceLangId.set(resourceUri && this.getLanguageId(resourceUri)); - this.resourceDirName.set(resourceUri && resourceUri.path.dir.fsPath()); - this.resourcePath.set(resourceUri && resourceUri.path.fsPath()); + this.resourceDirName.set(resourceUri?.path.dir.fsPath()); + this.resourcePath.set(resourceUri?.path.fsPath()); + this.resourceSet.set(Boolean(resourceUri)); } protected getLanguageId(uri: URI | undefined): string | undefined { diff --git a/packages/core/src/common/logger.ts b/packages/core/src/common/logger.ts index 77bce3e717de9..3099fd5b53573 100644 --- a/packages/core/src/common/logger.ts +++ b/packages/core/src/common/logger.ts @@ -40,16 +40,16 @@ export function unsetRootLogger(): void { } export function setRootLogger(aLogger: ILogger): void { - logger = aLogger; - const log = (logLevel: number, message?: any, ...optionalParams: any[]) => - logger.log(logLevel, message, ...optionalParams); - - console.error = log.bind(undefined, LogLevel.ERROR); - console.warn = log.bind(undefined, LogLevel.WARN); - console.info = log.bind(undefined, LogLevel.INFO); - console.debug = log.bind(undefined, LogLevel.DEBUG); - console.trace = log.bind(undefined, LogLevel.TRACE); - console.log = log.bind(undefined, LogLevel.INFO); + // logger = aLogger; + // const log = (logLevel: number, message?: any, ...optionalParams: any[]) => + // logger.log(logLevel, message, ...optionalParams); + + // console.error = log.bind(undefined, LogLevel.ERROR); + // console.warn = log.bind(undefined, LogLevel.WARN); + // console.info = log.bind(undefined, LogLevel.INFO); + // console.debug = log.bind(undefined, LogLevel.DEBUG); + // console.trace = log.bind(undefined, LogLevel.TRACE); + // console.log = log.bind(undefined, LogLevel.INFO); } export type Log = (message: any, ...params: any[]) => void; diff --git a/packages/core/src/common/menu.ts b/packages/core/src/common/menu.ts index 5342530eebb8a..6545c5d89fd44 100644 --- a/packages/core/src/common/menu.ts +++ b/packages/core/src/common/menu.ts @@ -233,6 +233,7 @@ export class MenuModelRegistry { } linkSubmenu(parentPath: MenuPath | string, childId: string, options?: SubMenuOptions, group?: string): Disposable { + console.log('SENTINEL FOR LINKING A SUBMENU', childId, parentPath, options, group); const child = this.independentSubmenus.get(childId); if (!child) { throw new Error(`Attempted to link non-existent menu with id ${childId}`); @@ -449,7 +450,7 @@ export class CompositeMenuNode implements MenuNode { } get isSubmenu(): boolean { - return this.label !== undefined; + return Boolean(this.label); } /** @@ -473,7 +474,7 @@ export class CompositeMenuNodeWrapper implements MenuNode { get sortString(): string { return this.order || this.id; } - get isSubmenu(): boolean { return this.label !== undefined; } + get isSubmenu(): boolean { return Boolean(this.label); } get icon(): string | undefined { return this.iconClass; } diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index 169591329acd5..1ad1f6a320fab 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -57,7 +57,7 @@ export class MenusContributionPointHandler { this.commandAdapterRegistry.registerAdapter(this.commandAdapter); for (const contributionPoint of implementedVSCodeContributionPoints) { this.menuRegistry.registerIndependentSubmenu(contributionPoint, ''); - this.getMatchingMenu(contributionPoint)!.forEach(menu => this.menuRegistry.linkSubmenu(menu, contributionPoint)); + this.getMatchingMenu(contributionPoint)!.forEach(([menu, when]) => this.menuRegistry.linkSubmenu(menu, contributionPoint, when ? { when } : undefined)); } this.tabBarToolbar.registerMenuDelegate(PLUGIN_EDITOR_TITLE_MENU, widget => this.codeEditorWidgetUtil.is(widget)); this.tabBarToolbar.registerMenuDelegate(PLUGIN_SCM_TITLE_MENU, widget => widget instanceof ScmWidget); @@ -70,7 +70,7 @@ export class MenusContributionPointHandler { }); } - private getMatchingMenu(contributionPoint: ContributionPoint): MenuPath[] | undefined { + private getMatchingMenu(contributionPoint: ContributionPoint): Array<[MenuPath] | [MenuPath, string]> | undefined { return codeToTheiaMappings.get(contributionPoint); } @@ -96,10 +96,10 @@ export class MenusContributionPointHandler { toDispose.push(this.registerCommandPaletteAction(item)); } else { this.checkTitleContribution(contributionPoint, item, toDispose); - const targets = this.getMatchingMenu(contributionPoint as ContributionPoint) ?? [contributionPoint]; if (item.submenu) { + const targets = this.getMatchingMenu(contributionPoint as ContributionPoint) ?? [contributionPoint]; const { group, order } = this.parseGroup(item.group); - targets.forEach(target => toDispose.push(this.menuRegistry.linkSubmenu(target, item.submenu!, { order, when: item.when }, group))); + targets.forEach(([target]) => toDispose.push(this.menuRegistry.linkSubmenu(target, item.submenu!, { order, when: item.when }, group))); } else if (item.command) { toDispose.push(this.commandAdapter.addCommand(item.command)); const { group, order } = this.parseGroup(item.group); @@ -108,11 +108,8 @@ export class MenusContributionPointHandler { when: item.when, order, }, this.commands); - - targets.forEach(target => { - const parent = this.menuRegistry.getMenuNode(target, group); - toDispose.push(parent.addNode(node)); - }); + const parent = this.menuRegistry.getMenuNode(contributionPoint, group); + toDispose.push(parent.addNode(node)); } } } catch (error) { diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts index 9a8cc51aa1df0..27463c722ff96 100644 --- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -104,7 +104,7 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { if (adapter) { const paths = codeToTheiaMappings.get(contributionPoint); if (paths) { - paths.forEach(path => this.addArgumentAdapter(path, adapter)); + paths.forEach(([path]) => this.addArgumentAdapter(path, adapter)); } } }); diff --git a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts index 774cb0574d54d..e1c4aa4426342 100644 --- a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts +++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts @@ -53,22 +53,23 @@ export const implementedVSCodeContributionPoints = [ export type ContributionPoint = (typeof implementedVSCodeContributionPoints)[number]; -export const codeToTheiaMappings = new Map([ - ['comments/comment/context', [COMMENT_CONTEXT]], - ['comments/comment/title', [COMMENT_TITLE]], - ['comments/commentThread/context', [COMMENT_THREAD_CONTEXT]], - ['debug/callstack/context', [DebugStackFramesWidget.CONTEXT_MENU, DebugThreadsWidget.CONTEXT_MENU]], - ['editor/context', [EDITOR_CONTEXT_MENU]], - ['editor/title', [PLUGIN_EDITOR_TITLE_MENU]], - ['editor/title/context', [SHELL_TABBAR_CONTEXT_MENU]], - ['explorer/context', [NAVIGATOR_CONTEXT_MENU]], - ['scm/resourceFolder/context', [ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU]], - ['scm/resourceGroup/context', [ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU]], - ['scm/resourceState/context', [ScmTreeWidget.RESOURCE_CONTEXT_MENU]], - ['scm/title', [PLUGIN_SCM_TITLE_MENU]], - ['timeline/item/context', [TIMELINE_ITEM_CONTEXT_MENU]], - ['view/item/context', [VIEW_ITEM_CONTEXT_MENU]], - ['view/title', [PLUGIN_VIEW_TITLE_MENU]], +/** The values are combinations of MenuPath and `when` clause, if any */ +export const codeToTheiaMappings = new Map>([ + ['comments/comment/context', [[COMMENT_CONTEXT]]], + ['comments/comment/title', [[COMMENT_TITLE]]], + ['comments/commentThread/context', [[COMMENT_THREAD_CONTEXT]]], + ['debug/callstack/context', [[DebugStackFramesWidget.CONTEXT_MENU], [DebugThreadsWidget.CONTEXT_MENU]]], + ['editor/context', [[EDITOR_CONTEXT_MENU]]], + ['editor/title', [[PLUGIN_EDITOR_TITLE_MENU]]], + ['editor/title/context', [[SHELL_TABBAR_CONTEXT_MENU, 'resourceSet']]], + ['explorer/context', [[NAVIGATOR_CONTEXT_MENU]]], + ['scm/resourceFolder/context', [[ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU]]], + ['scm/resourceGroup/context', [[ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU]]], + ['scm/resourceState/context', [[ScmTreeWidget.RESOURCE_CONTEXT_MENU]]], + ['scm/title', [[PLUGIN_SCM_TITLE_MENU]]], + ['timeline/item/context', [[TIMELINE_ITEM_CONTEXT_MENU]]], + ['view/item/context', [[VIEW_ITEM_CONTEXT_MENU]]], + ['view/title', [[PLUGIN_VIEW_TITLE_MENU]]], ]); type CodeEditorWidget = EditorWidget | WebviewWidget; From ae90ed7e19e09100f64a7a01a5fb8c9abdb1669c Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 29 Jun 2022 11:35:10 -0600 Subject: [PATCH 08/24] First move to break up tab-bar-toolbar.tsx --- .../src/browser/shell/tab-bar-toolbar/index.ts | 17 +++++++++++++++++ .../tab-bar-toolbar/tab-bar-toolbar-registry.ts | 15 +++++++++++++++ .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 15 +++++++++++++++ .../tab-bar-toolbar.spec.ts | 2 +- .../{ => tab-bar-toolbar}/tab-bar-toolbar.tsx | 12 ++++++------ 5 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/browser/shell/tab-bar-toolbar/index.ts create mode 100644 packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts create mode 100644 packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts rename packages/core/src/browser/shell/{ => tab-bar-toolbar}/tab-bar-toolbar.spec.ts (98%) rename packages/core/src/browser/shell/{ => tab-bar-toolbar}/tab-bar-toolbar.tsx (98%) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/index.ts b/packages/core/src/browser/shell/tab-bar-toolbar/index.ts new file mode 100644 index 0000000000000..390398d098588 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/index.ts @@ -0,0 +1,17 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 +// ***************************************************************************** + +export * from './tab-bar-toolbar'; diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts new file mode 100644 index 0000000000000..1ec1b9b85cc33 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -0,0 +1,15 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 +// ***************************************************************************** diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts new file mode 100644 index 0000000000000..1ec1b9b85cc33 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -0,0 +1,15 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 +// ***************************************************************************** diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.spec.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts similarity index 98% rename from packages/core/src/browser/shell/tab-bar-toolbar.spec.ts rename to packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts index ff0362c122c3d..e2390ba1e7a47 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.spec.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { enableJSDOM } from '../test/jsdom'; +import { enableJSDOM } from '../../test/jsdom'; let disableJSDOM = enableJSDOM(); import { expect } from 'chai'; diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx similarity index 98% rename from packages/core/src/browser/shell/tab-bar-toolbar.tsx rename to packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index 76c0989ff56a9..29326cc32a25e 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -18,12 +18,12 @@ import debounce = require('lodash.debounce'); import { inject, injectable, named } from 'inversify'; import * as React from 'react'; // eslint-disable-next-line max-len -import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuNode, MenuPath, nls } from '../../common'; -import { ContextKeyService } from '../context-key-service'; -import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../context-menu-renderer'; -import { FrontendApplicationContribution } from '../frontend-application'; -import { LabelIcon, LabelParser } from '../label-parser'; -import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../widgets'; +import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuNode, MenuPath, nls } from '../../../common'; +import { ContextKeyService } from '../../context-key-service'; +import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../../context-menu-renderer'; +import { FrontendApplicationContribution } from '../../frontend-application'; +import { LabelIcon, LabelParser } from '../../label-parser'; +import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../../widgets'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. From 96726df8e63ff9940781fe1c246aca2a914b652d Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 29 Jun 2022 11:53:08 -0600 Subject: [PATCH 09/24] Break up the code --- .../browser/shell/tab-bar-toolbar/index.ts | 2 + .../tab-bar-toolbar-registry.ts | 182 +++++++++ .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 173 +++++++++ .../tab-bar-toolbar/tab-bar-toolbar.spec.ts | 2 +- .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 353 +----------------- 5 files changed, 363 insertions(+), 349 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/index.ts b/packages/core/src/browser/shell/tab-bar-toolbar/index.ts index 390398d098588..d0969ba049c78 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/index.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/index.ts @@ -15,3 +15,5 @@ // ***************************************************************************** export * from './tab-bar-toolbar'; +export * from './tab-bar-toolbar-registry'; +export * from './tab-bar-toolbar-types'; diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index 1ec1b9b85cc33..9bd5268cc3a7f 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -13,3 +13,185 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** + +import debounce = require('lodash.debounce'); +import { inject, injectable, named } from 'inversify'; +import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuNode, MenuPath } from '../../../common'; +import { ContextKeyService } from '../../context-key-service'; +import { FrontendApplicationContribution } from '../../frontend-application'; +import { Widget } from '../../widgets'; +import { MenuDelegate, menuDelegateSeparator, MenuDelegateToolbarItem, ReactTabBarToolbarItem, SubmenuToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; + +/** + * Clients should implement this interface if they want to contribute to the tab-bar toolbar. + */ +export const TabBarToolbarContribution = Symbol('TabBarToolbarContribution'); +/** + * Representation of a tabbar toolbar contribution. + */ +export interface TabBarToolbarContribution { + /** + * Registers toolbar items. + * @param registry the tabbar toolbar registry. + */ + registerToolbarItems(registry: TabBarToolbarRegistry): void; +} + +function yes(): true { return true; } + +/** + * Main, shared registry for tab-bar toolbar items. + */ +@injectable() +export class TabBarToolbarRegistry implements FrontendApplicationContribution { + + protected items = new Map(); + protected menuDelegates = new Map(); + + @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; + @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; + @inject(MenuModelRegistry) protected readonly menuRegistry: MenuModelRegistry; + + @inject(ContributionProvider) @named(TabBarToolbarContribution) + protected readonly contributionProvider: ContributionProvider; + + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + // debounce in order to avoid to fire more than once in the same tick + protected fireOnDidChange = debounce(() => this.onDidChangeEmitter.fire(undefined), 0); + + onStart(): void { + const contributions = this.contributionProvider.getContributions(); + for (const contribution of contributions) { + contribution.registerToolbarItems(this); + } + } + + /** + * Registers the given item. Throws an error, if the corresponding command cannot be found or an item has been already registered for the desired command. + * + * @param item the item to register. + */ + registerItem(item: TabBarToolbarItem | ReactTabBarToolbarItem): Disposable { + const { id } = item; + if (this.items.has(id)) { + throw new Error(`A toolbar item is already registered with the '${id}' ID.`); + } + this.items.set(id, item); + this.fireOnDidChange(); + const toDispose = new DisposableCollection( + Disposable.create(() => this.fireOnDidChange()), + Disposable.create(() => this.items.delete(id)) + ); + if (item.onDidChange) { + toDispose.push(item.onDidChange(() => this.fireOnDidChange())); + } + return toDispose; + } + + /** + * Returns an array of tab-bar toolbar items which are visible when the `widget` argument is the current one. + * + * By default returns with all items where the command is enabled and `item.isVisible` is `true`. + */ + visibleItems(widget: Widget): Array { + if (widget.isDisposed) { + return []; + } + const result: Array = []; + for (const item of this.items.values()) { + const visible = TabBarToolbarItem.is(item) + ? this.commandRegistry.isVisible(item.command, widget) + : (!item.isVisible || item.isVisible(widget)); + if (visible && (!item.when || this.contextKeyService.match(item.when, widget.node))) { + result.push(item); + } + } + for (const delegate of this.menuDelegates.values()) { + if (delegate.isEnabled(widget)) { + const menu = this.menuRegistry.getMenu(delegate.menuPath); + const menuToTabbarItems = (item: MenuNode, group = '') => { + if (Array.isArray(item.children) && (!item.when || this.contextKeyService.match(item.when, widget.node))) { + const nextGroup = item === menu + ? group + : this.formatGroupForSubmenus(group, item.id, item.label); + if (group === 'navigation') { + const asSubmenuItem: SubmenuToolbarItem = { + id: `submenu_as_toolbar_item_${item.id}`, + command: '_never_', + prefix: item.id, + when: item.when, + icon: item.icon, + group, + }; + if (!asSubmenuItem.when || this.contextKeyService.match(asSubmenuItem.when, widget.node)) { + result.push(asSubmenuItem); + } + } + item.children.forEach(child => menuToTabbarItems(child, nextGroup)); + } else if (!Array.isArray(item.children)) { + const asToolbarItem: MenuDelegateToolbarItem = { + id: `menu_as_toolbar_item_${item.id}`, + command: item.id, + when: item.when, + icon: item.icon, + tooltip: item.label ?? item.id, + menuPath: delegate.menuPath, + group, + }; + if (!asToolbarItem.when || this.contextKeyService.match(asToolbarItem.when, widget.node)) { + result.push(asToolbarItem); + } + } + }; + menuToTabbarItems(menu); + } + } + return result; + } + + protected formatGroupForSubmenus(lastGroup: string, currentId?: string, currentLabel?: string): string { + const split = lastGroup.length ? lastGroup.split(menuDelegateSeparator) : []; + // If the submenu is in the 'navigation' group, then it's an item that opens its own context menu, so it should be navigation/id/label... + const expectedParity = split[0] === 'navigation' ? 1 : 0; + if (split.length % 2 !== expectedParity && (currentId || currentLabel)) { + split.push(''); + } + if (currentId || currentLabel) { + split.push(currentId || (currentLabel + '_id')); + } + if (currentLabel) { + split.push(currentLabel); + } + return split.join(menuDelegateSeparator); + } + + unregisterItem(itemOrId: TabBarToolbarItem | ReactTabBarToolbarItem | string): void { + const id = typeof itemOrId === 'string' ? itemOrId : itemOrId.id; + if (this.items.delete(id)) { + this.fireOnDidChange(); + } + } + + registerMenuDelegate(menuPath: MenuPath, when?: string | ((widget: Widget) => boolean)): Disposable { + const id = menuPath.join(menuDelegateSeparator); + if (!this.menuDelegates.has(id)) { + const isEnabled: MenuDelegate['isEnabled'] = !when + ? yes + : typeof when === 'function' + ? when + : widget => this.contextKeyService.match(when, widget.node); + this.menuDelegates.set(id, { menuPath, isEnabled }); + this.fireOnDidChange(); + return { dispose: () => this.unregisterMenuDelegate(menuPath) }; + } + console.warn('Unable to register menu delegate. Delegate has already been registered', menuPath); + return Disposable.NULL; + } + + unregisterMenuDelegate(menuPath: MenuPath): void { + if (this.menuDelegates.delete(menuPath.join(menuDelegateSeparator))) { + this.fireOnDidChange(); + } + } +} diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts index 1ec1b9b85cc33..5b5ca8876ed44 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -13,3 +13,176 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** + +import * as React from 'react'; +import { Event, MenuPath } from '../../../common'; +import { Widget } from '../../widgets'; + +export interface TabBarDelegator extends Widget { + getTabBarDelegate(): Widget | undefined; +} + +export namespace TabBarDelegator { + export const is = (candidate?: Widget): candidate is TabBarDelegator => { + if (candidate) { + const asDelegator = candidate as TabBarDelegator; + return typeof asDelegator.getTabBarDelegate === 'function'; + } + return false; + }; +} + +export const menuDelegateSeparator = '@=@'; +/** + * Representation of an item in the tab + */ +export interface TabBarToolbarItem { + + /** + * The unique ID of the toolbar item. + */ + readonly id: string; + + /** + * The command to execute. + */ + readonly command: string; + + /** + * Optional text of the item. + * + * Shamelessly copied and reused from `status-bar`: + * + * More details about the available `fontawesome` icons and CSS class names can be hound [here](http://fontawesome.io/icons/). + * To set a text with icon use the following pattern in text string: + * ```typescript + * $(fontawesomeClassName) + * ``` + * + * To use animated icons use the following pattern: + * ```typescript + * $(fontawesomeClassName~typeOfAnimation) + * ```` + * The type of animation can be either `spin` or `pulse`. + * Look [here](http://fontawesome.io/examples/#animated) for more information to animated icons. + */ + readonly text?: string; + + /** + * Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default. + */ + readonly priority?: number; + + /** + * Optional group for the item. Default `navigation`. + * `navigation` group will be inlined, while all the others will be within the `...` dropdown. + * A group in format `submenu_group_1/submenu 1/.../submenu_group_n/ submenu n/item_group` means that the item will be located in a submenu(s) of the `...` dropdown. + * The submenu's title is named by the submenu section name, e.g. `group//subgroup`. + */ + readonly group?: string; + + /** + * Optional tooltip for the item. + */ + readonly tooltip?: string; + + /** + * Optional icon for the item. + */ + readonly icon?: string | (() => string); + + /** + * https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts + */ + readonly when?: string; + + /** + * When defined, the container tool-bar will be updated if this event is fired. + * + * Note: currently, each item of the container toolbar will be re-rendered if any of the items have changed. + */ + readonly onDidChange?: Event; + +} + +export interface MenuDelegateToolbarItem extends TabBarToolbarItem { + menuPath: MenuPath; +} + +export namespace MenuDelegateToolbarItem { + export function getMenuPath(item: TabBarToolbarItem): MenuPath | undefined { + const asDelegate = item as MenuDelegateToolbarItem; + return Array.isArray(asDelegate.menuPath) ? asDelegate.menuPath : undefined; + } +} + +export interface SubmenuToolbarItem extends TabBarToolbarItem { + prefix: string; +} + +export namespace SubmenuToolbarItem { + export function is(candidate: TabBarToolbarItem): candidate is SubmenuToolbarItem { + return typeof (candidate as SubmenuToolbarItem).prefix === 'string'; + } +} + +/** + * Tab-bar toolbar item backed by a `React.ReactNode`. + * Unlike the `TabBarToolbarItem`, this item is not connected to the command service. + */ +export interface ReactTabBarToolbarItem { + readonly id: string; + render(widget?: Widget): React.ReactNode; + + readonly onDidChange?: Event; + + // For the rest, see `TabBarToolbarItem`. + // For conditional visibility. + isVisible?(widget: Widget): boolean; + readonly when?: string; + + // Ordering and grouping. + readonly priority?: number; + /** + * Optional group for the item. Default `navigation`. Always inlined. + */ + readonly group?: string; +} + +export namespace TabBarToolbarItem { + + /** + * Compares the items by `priority` in ascending. Undefined priorities will be treated as `0`. + */ + export const PRIORITY_COMPARATOR = (left: TabBarToolbarItem, right: TabBarToolbarItem) => { + // The navigation group is special as it will always be sorted to the top/beginning of a menu. + const compareGroup = (leftGroup: string | undefined = 'navigation', rightGroup: string | undefined = 'navigation') => { + if (leftGroup === 'navigation') { + return rightGroup === 'navigation' ? 0 : -1; + } + if (rightGroup === 'navigation') { + return leftGroup === 'navigation' ? 0 : 1; + } + return leftGroup.localeCompare(rightGroup); + }; + const result = compareGroup(left.group, right.group); + if (result !== 0) { + return result; + } + return (left.priority || 0) - (right.priority || 0); + }; + + export function is(arg: Object | undefined): arg is TabBarToolbarItem { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return !!arg && 'command' in arg && typeof (arg as any).command === 'string'; + } + +} + +export interface MenuDelegate { + menuPath: MenuPath; + isEnabled: (widget: Widget) => boolean; +} + +export const TAB_BAR_TOOLBAR_CONTEXT_MENU = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; +export const submenuItemPrefix = `navigation${menuDelegateSeparator}`; diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts index e2390ba1e7a47..76df1486afda1 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts @@ -18,7 +18,7 @@ import { enableJSDOM } from '../../test/jsdom'; let disableJSDOM = enableJSDOM(); import { expect } from 'chai'; -import { TabBarToolbarItem } from './tab-bar-toolbar'; +import { TabBarToolbarItem } from './tab-bar-toolbar-types'; disableJSDOM(); diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index 29326cc32a25e..cc9ceee5aacaa 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -14,355 +14,15 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import debounce = require('lodash.debounce'); -import { inject, injectable, named } from 'inversify'; +import { inject, injectable } from 'inversify'; import * as React from 'react'; -// eslint-disable-next-line max-len -import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuNode, MenuPath, nls } from '../../../common'; -import { ContextKeyService } from '../../context-key-service'; +import { CommandRegistry, Disposable, DisposableCollection, MenuCommandExecutor, MenuModelRegistry, nls } from '../../../common'; import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../../context-menu-renderer'; -import { FrontendApplicationContribution } from '../../frontend-application'; import { LabelIcon, LabelParser } from '../../label-parser'; import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../../widgets'; - -/** - * Clients should implement this interface if they want to contribute to the tab-bar toolbar. - */ -export const TabBarToolbarContribution = Symbol('TabBarToolbarContribution'); -/** - * Representation of a tabbar toolbar contribution. - */ -export interface TabBarToolbarContribution { - /** - * Registers toolbar items. - * @param registry the tabbar toolbar registry. - */ - registerToolbarItems(registry: TabBarToolbarRegistry): void; -} - -export interface TabBarDelegator extends Widget { - getTabBarDelegate(): Widget | undefined; -} - -export namespace TabBarDelegator { - export const is = (candidate?: Widget): candidate is TabBarDelegator => { - if (candidate) { - const asDelegator = candidate as TabBarDelegator; - return typeof asDelegator.getTabBarDelegate === 'function'; - } - return false; - }; -} -const menuDelegateSeparator = '@=@'; -/** - * Representation of an item in the tab - */ -export interface TabBarToolbarItem { - - /** - * The unique ID of the toolbar item. - */ - readonly id: string; - - /** - * The command to execute. - */ - readonly command: string; - - /** - * Optional text of the item. - * - * Shamelessly copied and reused from `status-bar`: - * - * More details about the available `fontawesome` icons and CSS class names can be hound [here](http://fontawesome.io/icons/). - * To set a text with icon use the following pattern in text string: - * ```typescript - * $(fontawesomeClassName) - * ``` - * - * To use animated icons use the following pattern: - * ```typescript - * $(fontawesomeClassName~typeOfAnimation) - * ```` - * The type of animation can be either `spin` or `pulse`. - * Look [here](http://fontawesome.io/examples/#animated) for more information to animated icons. - */ - readonly text?: string; - - /** - * Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default. - */ - readonly priority?: number; - - /** - * Optional group for the item. Default `navigation`. - * `navigation` group will be inlined, while all the others will be within the `...` dropdown. - * A group in format `submenu_group_1/submenu 1/.../submenu_group_n/ submenu n/item_group` means that the item will be located in a submenu(s) of the `...` dropdown. - * The submenu's title is named by the submenu section name, e.g. `group//subgroup`. - */ - readonly group?: string; - - /** - * Optional tooltip for the item. - */ - readonly tooltip?: string; - - /** - * Optional icon for the item. - */ - readonly icon?: string | (() => string); - - /** - * https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts - */ - readonly when?: string; - - /** - * When defined, the container tool-bar will be updated if this event is fired. - * - * Note: currently, each item of the container toolbar will be re-rendered if any of the items have changed. - */ - readonly onDidChange?: Event; - -} - -export interface MenuDelegateToolbarItem extends TabBarToolbarItem { - menuPath: MenuPath; -} - -export namespace MenuDelegateToolbarItem { - export function getMenuPath(item: TabBarToolbarItem): MenuPath | undefined { - const asDelegate = item as MenuDelegateToolbarItem; - return Array.isArray(asDelegate.menuPath) ? asDelegate.menuPath : undefined; - } -} - -interface SubmenuToolbarItem extends TabBarToolbarItem { - prefix: string; -} - -namespace SubmenuToolbarItem { - export function is(candidate: TabBarToolbarItem): candidate is SubmenuToolbarItem { - return typeof (candidate as SubmenuToolbarItem).prefix === 'string'; - } -} - -/** - * Tab-bar toolbar item backed by a `React.ReactNode`. - * Unlike the `TabBarToolbarItem`, this item is not connected to the command service. - */ -export interface ReactTabBarToolbarItem { - readonly id: string; - render(widget?: Widget): React.ReactNode; - - readonly onDidChange?: Event; - - // For the rest, see `TabBarToolbarItem`. - // For conditional visibility. - isVisible?(widget: Widget): boolean; - readonly when?: string; - - // Ordering and grouping. - readonly priority?: number; - /** - * Optional group for the item. Default `navigation`. Always inlined. - */ - readonly group?: string; -} - -export namespace TabBarToolbarItem { - - /** - * Compares the items by `priority` in ascending. Undefined priorities will be treated as `0`. - */ - export const PRIORITY_COMPARATOR = (left: TabBarToolbarItem, right: TabBarToolbarItem) => { - // The navigation group is special as it will always be sorted to the top/beginning of a menu. - const compareGroup = (leftGroup: string | undefined = 'navigation', rightGroup: string | undefined = 'navigation') => { - if (leftGroup === 'navigation') { - return rightGroup === 'navigation' ? 0 : -1; - } - if (rightGroup === 'navigation') { - return leftGroup === 'navigation' ? 0 : 1; - } - return leftGroup.localeCompare(rightGroup); - }; - const result = compareGroup(left.group, right.group); - if (result !== 0) { - return result; - } - return (left.priority || 0) - (right.priority || 0); - }; - - export function is(arg: Object | undefined): arg is TabBarToolbarItem { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return !!arg && 'command' in arg && typeof (arg as any).command === 'string'; - } - -} - -interface MenuDelegate { - menuPath: MenuPath; - isEnabled: (widget: Widget) => boolean; -} - -function yes(): true { return true; } - -/** - * Main, shared registry for tab-bar toolbar items. - */ -@injectable() -export class TabBarToolbarRegistry implements FrontendApplicationContribution { - - protected items = new Map(); - protected menuDelegates = new Map(); - - @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; - @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; - @inject(MenuModelRegistry) protected readonly menuRegistry: MenuModelRegistry; - - @inject(ContributionProvider) @named(TabBarToolbarContribution) - protected readonly contributionProvider: ContributionProvider; - - protected readonly onDidChangeEmitter = new Emitter(); - readonly onDidChange: Event = this.onDidChangeEmitter.event; - // debounce in order to avoid to fire more than once in the same tick - protected fireOnDidChange = debounce(() => this.onDidChangeEmitter.fire(undefined), 0); - - onStart(): void { - const contributions = this.contributionProvider.getContributions(); - for (const contribution of contributions) { - contribution.registerToolbarItems(this); - } - } - - /** - * Registers the given item. Throws an error, if the corresponding command cannot be found or an item has been already registered for the desired command. - * - * @param item the item to register. - */ - registerItem(item: TabBarToolbarItem | ReactTabBarToolbarItem): Disposable { - const { id } = item; - if (this.items.has(id)) { - throw new Error(`A toolbar item is already registered with the '${id}' ID.`); - } - this.items.set(id, item); - this.fireOnDidChange(); - const toDispose = new DisposableCollection( - Disposable.create(() => this.fireOnDidChange()), - Disposable.create(() => this.items.delete(id)) - ); - if (item.onDidChange) { - toDispose.push(item.onDidChange(() => this.fireOnDidChange())); - } - return toDispose; - } - - /** - * Returns an array of tab-bar toolbar items which are visible when the `widget` argument is the current one. - * - * By default returns with all items where the command is enabled and `item.isVisible` is `true`. - */ - visibleItems(widget: Widget): Array { - if (widget.isDisposed) { - return []; - } - const result: Array = []; - for (const item of this.items.values()) { - const visible = TabBarToolbarItem.is(item) - ? this.commandRegistry.isVisible(item.command, widget) - : (!item.isVisible || item.isVisible(widget)); - if (visible && (!item.when || this.contextKeyService.match(item.when, widget.node))) { - result.push(item); - } - } - for (const delegate of this.menuDelegates.values()) { - if (delegate.isEnabled(widget)) { - const menu = this.menuRegistry.getMenu(delegate.menuPath); - const menuToTabbarItems = (item: MenuNode, group = '') => { - if (Array.isArray(item.children) && (!item.when || this.contextKeyService.match(item.when, widget.node))) { - const nextGroup = item === menu - ? group - : this.formatGroupForSubmenus(group, item.id, item.label); - if (group === 'navigation') { - const asSubmenuItem: SubmenuToolbarItem = { - id: `submenu_as_toolbar_item_${item.id}`, - command: '_never_', - prefix: item.id, - when: item.when, - icon: item.icon, - group, - }; - if (!asSubmenuItem.when || this.contextKeyService.match(asSubmenuItem.when, widget.node)) { - result.push(asSubmenuItem); - } - } - item.children.forEach(child => menuToTabbarItems(child, nextGroup)); - } else if (!Array.isArray(item.children)) { - const asToolbarItem: MenuDelegateToolbarItem = { - id: `menu_as_toolbar_item_${item.id}`, - command: item.id, - when: item.when, - icon: item.icon, - tooltip: item.label ?? item.id, - menuPath: delegate.menuPath, - group, - }; - if (!asToolbarItem.when || this.contextKeyService.match(asToolbarItem.when, widget.node)) { - result.push(asToolbarItem); - } - } - }; - menuToTabbarItems(menu); - } - } - return result; - } - - protected formatGroupForSubmenus(lastGroup: string, currentId?: string, currentLabel?: string): string { - const split = lastGroup.length ? lastGroup.split(menuDelegateSeparator) : []; - // If the submenu is in the 'navigation' group, then it's an item that opens its own context menu, so it should be navigation/id/label... - const expectedParity = split[0] === 'navigation' ? 1 : 0; - if (split.length % 2 !== expectedParity && (currentId || currentLabel)) { - split.push(''); - } - if (currentId || currentLabel) { - split.push(currentId || (currentLabel + '_id')); - } - if (currentLabel) { - split.push(currentLabel); - } - return split.join(menuDelegateSeparator); - } - - unregisterItem(itemOrId: TabBarToolbarItem | ReactTabBarToolbarItem | string): void { - const id = typeof itemOrId === 'string' ? itemOrId : itemOrId.id; - if (this.items.delete(id)) { - this.fireOnDidChange(); - } - } - - registerMenuDelegate(menuPath: MenuPath, when?: string | ((widget: Widget) => boolean)): Disposable { - const id = menuPath.join(menuDelegateSeparator); - if (!this.menuDelegates.has(id)) { - const isEnabled: MenuDelegate['isEnabled'] = !when - ? yes - : typeof when === 'function' - ? when - : widget => this.contextKeyService.match(when, widget.node); - this.menuDelegates.set(id, { menuPath, isEnabled }); - this.fireOnDidChange(); - return { dispose: () => this.unregisterMenuDelegate(menuPath) }; - } - console.warn('Unable to register menu delegate. Delegate has already been registered', menuPath); - return Disposable.NULL; - } - - unregisterMenuDelegate(menuPath: MenuPath): void { - if (this.menuDelegates.delete(menuPath.join(menuDelegateSeparator))) { - this.fireOnDidChange(); - } - } -} +import { TabBarToolbarRegistry } from './tab-bar-toolbar-registry'; +// eslint-disable-next-line max-len +import { menuDelegateSeparator, MenuDelegateToolbarItem, ReactTabBarToolbarItem, submenuItemPrefix, SubmenuToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types'; /** * Factory for instantiating tab-bar toolbars. @@ -372,9 +32,6 @@ export interface TabBarToolbarFactory { (): TabBarToolbar; } -export const TAB_BAR_TOOLBAR_CONTEXT_MENU = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; -const submenuItemPrefix = `navigation${menuDelegateSeparator}`; - /** * Tab-bar toolbar widget representing the active [tab-bar toolbar items](TabBarToolbarItem). */ From 97216d201ff84fb0cdf3a7ef4537b168cfddfd8b Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 29 Jun 2022 14:30:09 -0600 Subject: [PATCH 10/24] Refactor Tabbar Toolbars --- .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts index 5b5ca8876ed44..b62fd5e117c38 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import * as React from 'react'; -import { Event, MenuPath } from '../../../common'; +import { ArrayUtils, Event, MenuPath } from '../../../common'; import { Widget } from '../../widgets'; export interface TabBarDelegator extends Widget { @@ -149,26 +149,20 @@ export interface ReactTabBarToolbarItem { readonly group?: string; } +/** Items whose group is exactly 'navigation' will be rendered inline. */ +export const NAVIGATION = 'navigation'; + export namespace TabBarToolbarItem { /** * Compares the items by `priority` in ascending. Undefined priorities will be treated as `0`. */ export const PRIORITY_COMPARATOR = (left: TabBarToolbarItem, right: TabBarToolbarItem) => { - // The navigation group is special as it will always be sorted to the top/beginning of a menu. - const compareGroup = (leftGroup: string | undefined = 'navigation', rightGroup: string | undefined = 'navigation') => { - if (leftGroup === 'navigation') { - return rightGroup === 'navigation' ? 0 : -1; - } - if (rightGroup === 'navigation') { - return leftGroup === 'navigation' ? 0 : 1; - } - return leftGroup.localeCompare(rightGroup); - }; - const result = compareGroup(left.group, right.group); - if (result !== 0) { - return result; - } + const leftGroup = left.group ?? NAVIGATION; + const rightGroup = right.group ?? NAVIGATION; + if (leftGroup === NAVIGATION && rightGroup !== NAVIGATION) { return ArrayUtils.Sort.LeftBeforeRight; } + if (rightGroup === NAVIGATION && leftGroup !== NAVIGATION) { return ArrayUtils.Sort.RightBeforeLeft; } + if (leftGroup !== rightGroup) { return leftGroup.localeCompare(rightGroup); } return (left.priority || 0) - (right.priority || 0); }; From 20f0d08786104a89918e23aa66ff9c32bbee9676 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 29 Jun 2022 18:00:06 -0600 Subject: [PATCH 11/24] Interface segregation --- .../tab-bar-toolbar-registry.ts | 12 +- .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 170 +++++++++--------- 2 files changed, 94 insertions(+), 88 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index 9bd5268cc3a7f..f9f2194c2be89 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -20,7 +20,7 @@ import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection import { ContextKeyService } from '../../context-key-service'; import { FrontendApplicationContribution } from '../../frontend-application'; import { Widget } from '../../widgets'; -import { MenuDelegate, menuDelegateSeparator, MenuDelegateToolbarItem, ReactTabBarToolbarItem, SubmenuToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { MenuDelegate, menuDelegateSeparator, MenuToolbarItem, ReactTabBarToolbarItem, SubmenuToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. @@ -108,7 +108,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } } for (const delegate of this.menuDelegates.values()) { - if (delegate.isEnabled(widget)) { + if (delegate.isVisible(widget)) { const menu = this.menuRegistry.getMenu(delegate.menuPath); const menuToTabbarItems = (item: MenuNode, group = '') => { if (Array.isArray(item.children) && (!item.when || this.contextKeyService.match(item.when, widget.node))) { @@ -130,7 +130,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } item.children.forEach(child => menuToTabbarItems(child, nextGroup)); } else if (!Array.isArray(item.children)) { - const asToolbarItem: MenuDelegateToolbarItem = { + const asToolbarItem: MenuToolbarItem = { id: `menu_as_toolbar_item_${item.id}`, command: item.id, when: item.when, @@ -176,12 +176,12 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { registerMenuDelegate(menuPath: MenuPath, when?: string | ((widget: Widget) => boolean)): Disposable { const id = menuPath.join(menuDelegateSeparator); if (!this.menuDelegates.has(id)) { - const isEnabled: MenuDelegate['isEnabled'] = !when + const isVisible: MenuDelegate['isVisible'] = !when ? yes : typeof when === 'function' ? when - : widget => this.contextKeyService.match(when, widget.node); - this.menuDelegates.set(id, { menuPath, isEnabled }); + : widget => this.contextKeyService.match(when, widget?.node); + this.menuDelegates.set(id, { menuPath, isVisible }); this.fireOnDidChange(); return { dispose: () => this.unregisterMenuDelegate(menuPath) }; } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts index b62fd5e117c38..95ec63c0dda58 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -18,6 +18,12 @@ import * as React from 'react'; import { ArrayUtils, Event, MenuPath } from '../../../common'; import { Widget } from '../../widgets'; +/** Items whose group is exactly 'navigation' will be rendered inline. */ +export const NAVIGATION = 'navigation'; +export const TAB_BAR_TOOLBAR_CONTEXT_MENU = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; +export const menuDelegateSeparator = '@=@'; +export const submenuItemPrefix = `navigation${menuDelegateSeparator}`; + export interface TabBarDelegator extends Widget { getTabBarDelegate(): Widget | undefined; } @@ -32,125 +38,126 @@ export namespace TabBarDelegator { }; } -export const menuDelegateSeparator = '@=@'; -/** - * Representation of an item in the tab - */ -export interface TabBarToolbarItem { - +interface RegisteredToolbarItem { /** * The unique ID of the toolbar item. */ - readonly id: string; + id: string; +} +interface RenderedToolbarItem { /** - * The command to execute. + * Optional icon for the item. */ - readonly command: string; + icon?: string | (() => string); /** * Optional text of the item. * - * Shamelessly copied and reused from `status-bar`: - * - * More details about the available `fontawesome` icons and CSS class names can be hound [here](http://fontawesome.io/icons/). - * To set a text with icon use the following pattern in text string: - * ```typescript - * $(fontawesomeClassName) - * ``` + * Strings in the format `$(iconIdentifier~animationType) will be treated as icon references. + * If the iconIdentifier begins with fa-, Font Awesome icons will be used; otherwise it will be treated as Codicon name. * - * To use animated icons use the following pattern: - * ```typescript - * $(fontawesomeClassName~typeOfAnimation) - * ```` + * You can find Codicon classnames here: https://microsoft.github.io/vscode-codicons/dist/codicon.html + * You can find Font Awesome classnames here: http://fontawesome.io/icons/ * The type of animation can be either `spin` or `pulse`. - * Look [here](http://fontawesome.io/examples/#animated) for more information to animated icons. */ - readonly text?: string; + text?: string; /** - * Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default. + * Optional tooltip for the item. */ - readonly priority?: number; + tooltip?: string; +} - /** - * Optional group for the item. Default `navigation`. - * `navigation` group will be inlined, while all the others will be within the `...` dropdown. - * A group in format `submenu_group_1/submenu 1/.../submenu_group_n/ submenu n/item_group` means that the item will be located in a submenu(s) of the `...` dropdown. - * The submenu's title is named by the submenu section name, e.g. `group//subgroup`. - */ - readonly group?: string; +interface SelfRenderingToolbarItem { + render(widget?: Widget): React.ReactNode; +} +interface ExecutableToolbarItem { /** - * Optional tooltip for the item. + * The command to execute when the item is selected. */ - readonly tooltip?: string; + command: string; +} +interface MenuToolbarItem { /** - * Optional icon for the item. + * A menu path with which this item is associated. + * If accompanied by a command, this data will be passed to the {@link MenuCommandExecutor}. + * If no command is present, this menu will be opened. */ - readonly icon?: string | (() => string); + menuPath: MenuPath; +} +interface ConditionalToolbarItem { /** * https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts */ - readonly when?: string; - + when?: string; + /** + * Checked before the item is shown. + */ + isVisible?(widget?: Widget): boolean; /** * When defined, the container tool-bar will be updated if this event is fired. * * Note: currently, each item of the container toolbar will be re-rendered if any of the items have changed. */ - readonly onDidChange?: Event; - + onDidChange?: Event; } -export interface MenuDelegateToolbarItem extends TabBarToolbarItem { - menuPath: MenuPath; -} - -export namespace MenuDelegateToolbarItem { - export function getMenuPath(item: TabBarToolbarItem): MenuPath | undefined { - const asDelegate = item as MenuDelegateToolbarItem; - return Array.isArray(asDelegate.menuPath) ? asDelegate.menuPath : undefined; - } +interface InlineToolbarItemMetadata { + /** + * Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default. + */ + priority?: number; + group: 'navigation' | undefined; } -export interface SubmenuToolbarItem extends TabBarToolbarItem { - prefix: string; +interface MenuToolbarItemMetadata { + /** + * Optional group for the item. Default `navigation`. + * `navigation` group will be inlined, while all the others will appear in the `...` dropdown. + * A group in format `submenu_group_1/submenu 1/.../submenu_group_n/ submenu n/item_group` means that the item will be located in a submenu(s) of the `...` dropdown. + * The submenu's title is named by the submenu section name, e.g. `group//subgroup`. + */ + group: string; + /** + * Optional ordering string for placing the item within its group + */ + order?: string; } -export namespace SubmenuToolbarItem { - export function is(candidate: TabBarToolbarItem): candidate is SubmenuToolbarItem { - return typeof (candidate as SubmenuToolbarItem).prefix === 'string'; - } -} +/** + * Representation of an item in the tab + */ +export interface TabBarToolbarItem extends RegisteredToolbarItem, + ExecutableToolbarItem, + RenderedToolbarItem, + Omit, + Pick, + Partial { } /** * Tab-bar toolbar item backed by a `React.ReactNode`. * Unlike the `TabBarToolbarItem`, this item is not connected to the command service. */ -export interface ReactTabBarToolbarItem { - readonly id: string; - render(widget?: Widget): React.ReactNode; - - readonly onDidChange?: Event; - - // For the rest, see `TabBarToolbarItem`. - // For conditional visibility. - isVisible?(widget: Widget): boolean; - readonly when?: string; - - // Ordering and grouping. - readonly priority?: number; - /** - * Optional group for the item. Default `navigation`. Always inlined. - */ - readonly group?: string; -} - -/** Items whose group is exactly 'navigation' will be rendered inline. */ -export const NAVIGATION = 'navigation'; +export interface ReactTabBarToolbarItem extends RegisteredToolbarItem, + SelfRenderingToolbarItem, + ConditionalToolbarItem, + Pick, + Pick, 'group'> { } + +export interface AnyToolbarItem extends Partial, + Partial, + Partial, + Partial, + Partial, + Partial, + Pick, + Partial { } + +export interface MenuDelegate extends MenuToolbarItem, Required> { } export namespace TabBarToolbarItem { @@ -173,10 +180,9 @@ export namespace TabBarToolbarItem { } -export interface MenuDelegate { - menuPath: MenuPath; - isEnabled: (widget: Widget) => boolean; +export namespace MenuToolbarItem { + export function getMenuPath(item: AnyToolbarItem): MenuPath | undefined { + const asDelegate = item as MenuToolbarItem; + return Array.isArray(asDelegate.menuPath) ? asDelegate.menuPath : undefined; + } } - -export const TAB_BAR_TOOLBAR_CONTEXT_MENU = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; -export const submenuItemPrefix = `navigation${menuDelegateSeparator}`; From 8b1289fce87b0d87c43cb645084fd3ca17fc62b7 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 30 Jun 2022 09:53:19 -0600 Subject: [PATCH 12/24] Break up menu files --- .../tab-bar-toolbar-registry.ts | 8 +- .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 4 +- .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 25 +- packages/core/src/common/index.ts | 1 - .../core/src/common/menu/action-menu-node.ts | 69 +++++ .../src/common/menu/composite-menu-node.ts | 137 +++++++++ packages/core/src/common/menu/index.ts | 21 ++ .../src/common/{ => menu}/menu-adapter.ts | 6 +- .../{menu.ts => menu/menu-model-registry.ts} | 276 +----------------- packages/core/src/common/menu/menu-types.ts | 112 +++++++ .../core/src/common/{ => menu}/menu.spec.ts | 5 +- 11 files changed, 366 insertions(+), 298 deletions(-) create mode 100644 packages/core/src/common/menu/action-menu-node.ts create mode 100644 packages/core/src/common/menu/composite-menu-node.ts create mode 100644 packages/core/src/common/menu/index.ts rename packages/core/src/common/{ => menu}/menu-adapter.ts (97%) rename packages/core/src/common/{menu.ts => menu/menu-model-registry.ts} (57%) create mode 100644 packages/core/src/common/menu/menu-types.ts rename packages/core/src/common/{ => menu}/menu.spec.ts (93%) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index f9f2194c2be89..b45e7f47b5d68 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -20,7 +20,7 @@ import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection import { ContextKeyService } from '../../context-key-service'; import { FrontendApplicationContribution } from '../../frontend-application'; import { Widget } from '../../widgets'; -import { MenuDelegate, menuDelegateSeparator, MenuToolbarItem, ReactTabBarToolbarItem, SubmenuToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { MenuDelegate, menuDelegateSeparator, MenuToolbarItem, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. @@ -116,10 +116,10 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { ? group : this.formatGroupForSubmenus(group, item.id, item.label); if (group === 'navigation') { - const asSubmenuItem: SubmenuToolbarItem = { + const asSubmenuItem: TabBarToolbarItem & MenuToolbarItem = { id: `submenu_as_toolbar_item_${item.id}`, command: '_never_', - prefix: item.id, + menuPath: delegate.menuPath, when: item.when, icon: item.icon, group, @@ -130,7 +130,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } item.children.forEach(child => menuToTabbarItems(child, nextGroup)); } else if (!Array.isArray(item.children)) { - const asToolbarItem: MenuToolbarItem = { + const asToolbarItem: TabBarToolbarItem & MenuToolbarItem = { id: `menu_as_toolbar_item_${item.id}`, command: item.id, when: item.when, diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts index 95ec63c0dda58..c16244f61cdc9 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -80,7 +80,7 @@ interface ExecutableToolbarItem { command: string; } -interface MenuToolbarItem { +export interface MenuToolbarItem { /** * A menu path with which this item is associated. * If accompanied by a command, this data will be passed to the {@link MenuCommandExecutor}. @@ -148,7 +148,7 @@ export interface ReactTabBarToolbarItem extends RegisteredToolbarItem, Pick, Pick, 'group'> { } -export interface AnyToolbarItem extends Partial, +export interface AnyToolbarItem extends RegisteredToolbarItem, Partial, Partial, Partial, diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index cc9ceee5aacaa..025ad99d729f2 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -22,7 +22,7 @@ import { LabelIcon, LabelParser } from '../../label-parser'; import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../../widgets'; import { TabBarToolbarRegistry } from './tab-bar-toolbar-registry'; // eslint-disable-next-line max-len -import { menuDelegateSeparator, MenuDelegateToolbarItem, ReactTabBarToolbarItem, submenuItemPrefix, SubmenuToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types'; +import { AnyToolbarItem, menuDelegateSeparator, ReactTabBarToolbarItem, submenuItemPrefix, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types'; /** * Factory for instantiating tab-bar toolbars. @@ -129,9 +129,6 @@ export class TabBarToolbar extends ReactWidget { } protected renderItem(item: TabBarToolbarItem): React.ReactNode { - if (SubmenuToolbarItem.is(item) && !this.submenuItems.get(item.prefix)?.size) { - return undefined; - } let innerText = ''; const classNames = []; if (item.text) { @@ -251,18 +248,14 @@ export class TabBarToolbar extends ReactWidget { e.preventDefault(); e.stopPropagation(); - const item = this.inline.get(e.currentTarget.id); - if (TabBarToolbarItem.is(item)) { - if (SubmenuToolbarItem.is(item)) { - const anchor = this.toAnchor(e); - return this.renderMoreContextMenu(anchor, item.prefix); - } - const menuPath = MenuDelegateToolbarItem.getMenuPath(item); - if (menuPath) { - this.menuCommandExecutor.executeCommand(menuPath, item.command, this.current); - } else { - this.commands.executeCommand(item.command, this.current); - } + const item: AnyToolbarItem | undefined = this.inline.get(e.currentTarget.id); + if (item?.command && item.menuPath) { + this.menuCommandExecutor.executeCommand(item.menuPath, item.command, this.current); + } else if (item?.command) { + this.commands.executeCommand(item.command, this.current); + } else if (item?.menuPath) { + // TODO @CJG - this isn't the final plan! + this.renderMoreContextMenu(this.toAnchor(e), item.menuPath.join('')) } this.update(); }; diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index 179b97537c6dd..cedfd254d0009 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -21,7 +21,6 @@ export * from './event'; export * from './cancellation'; export * from './command'; export * from './menu'; -export * from './menu-adapter'; export * from './selection-service'; export * from './objects'; export * from './os'; diff --git a/packages/core/src/common/menu/action-menu-node.ts b/packages/core/src/common/menu/action-menu-node.ts new file mode 100644 index 0000000000000..a5c9475600187 --- /dev/null +++ b/packages/core/src/common/menu/action-menu-node.ts @@ -0,0 +1,69 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { CommandRegistry } from '../command'; +import { MenuAction, MenuNode } from './menu-types'; + +/** + * Node representing an action in the menu tree structure. + * It's based on {@link MenuAction} for which it tries to determine the + * best label, icon and sortString with the given data. + */ +export class ActionMenuNode implements MenuNode { + + readonly altNode: ActionMenuNode | undefined; + + constructor( + public readonly action: MenuAction, + protected readonly commands: CommandRegistry + ) { + if (action.alt) { + this.altNode = new ActionMenuNode({ commandId: action.alt }, commands); + } + } + + get when(): string | undefined { + return this.action.when; + } + + get id(): string { + return this.action.commandId; + } + + get label(): string { + if (this.action.label) { + return this.action.label; + } + const cmd = this.commands.getCommand(this.action.commandId); + if (!cmd) { + console.debug(`No label for action menu node: No command "${this.action.commandId}" exists.`); + return ''; + } + return cmd.label || cmd.id; + } + + get icon(): string | undefined { + if (this.action.icon) { + return this.action.icon; + } + const command = this.commands.getCommand(this.action.commandId); + return command && command.iconClass; + } + + get sortString(): string { + return this.action.order || this.label; + } +} diff --git a/packages/core/src/common/menu/composite-menu-node.ts b/packages/core/src/common/menu/composite-menu-node.ts new file mode 100644 index 0000000000000..8467869210dd5 --- /dev/null +++ b/packages/core/src/common/menu/composite-menu-node.ts @@ -0,0 +1,137 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { Disposable } from '../disposable'; +import { MenuNode, SubMenuOptions } from './menu-types'; + +/** + * Node representing a (sub)menu in the menu tree structure. + */ +export class CompositeMenuNode implements MenuNode { + protected readonly _children: MenuNode[] = []; + public iconClass?: string; + public order?: string; + readonly when?: string; + + constructor( + public readonly id: string, + public label?: string, + options?: SubMenuOptions + ) { + if (options) { + this.iconClass = options.iconClass; + this.order = options.order; + this.when = options.when; + } + } + + get icon(): string | undefined { + return this.iconClass; + } + + get children(): ReadonlyArray { + return this._children; + } + + /** + * Inserts the given node at the position indicated by `sortString`. + * + * @returns a disposable which, when called, will remove the given node again. + */ + public addNode(node: MenuNode): Disposable { + this._children.push(node); + this._children.sort((m1, m2) => { + // The navigation group is special as it will always be sorted to the top/beginning of a menu. + if (CompositeMenuNode.isNavigationGroup(m1)) { + return -1; + } + if (CompositeMenuNode.isNavigationGroup(m2)) { + return 1; + } + if (m1.sortString < m2.sortString) { + return -1; + } else if (m1.sortString > m2.sortString) { + return 1; + } else { + return 0; + } + }); + return { + dispose: () => { + const idx = this._children.indexOf(node); + if (idx >= 0) { + this._children.splice(idx, 1); + } + } + }; + } + + /** + * Removes the first node with the given id. + * + * @param id node id. + */ + public removeNode(id: string): void { + 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); + } + } + } + + get sortString(): string { + return this.order || this.id; + } + + get isSubmenu(): boolean { + return Boolean(this.label); + } + + /** + * Indicates whether the given node is the special `navigation` menu. + * + * @param node the menu node to check. + * @returns `true` when the given node is a {@link CompositeMenuNode} with id `navigation`, + * `false` otherwise. + */ + static isNavigationGroup(node: MenuNode): node is CompositeMenuNode { + return node instanceof CompositeMenuNode && node.id === 'navigation'; + } +} + +export class CompositeMenuNodeWrapper implements MenuNode { + constructor(protected readonly wrapped: Readonly, protected readonly options?: SubMenuOptions) { } + + get id(): string { return this.wrapped.id; } + + get label(): string | undefined { return this.wrapped.label; } + + get sortString(): string { return this.order || this.id; } + + get isSubmenu(): boolean { return Boolean(this.label); } + + get icon(): string | undefined { return this.iconClass; } + + get iconClass(): string | undefined { return this.options?.iconClass ?? this.wrapped.iconClass; } + + get order(): string | undefined { return this.options?.order ?? this.wrapped.order; } + + get when(): string | undefined { return this.options?.when ?? this.wrapped.when; } + + get children(): ReadonlyArray { return this.wrapped.children; } +} diff --git a/packages/core/src/common/menu/index.ts b/packages/core/src/common/menu/index.ts new file mode 100644 index 0000000000000..aed34ac3c0374 --- /dev/null +++ b/packages/core/src/common/menu/index.ts @@ -0,0 +1,21 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 +// ***************************************************************************** + +export * from './action-menu-node'; +export * from './composite-menu-node'; +export * from './menu-adapter'; +export * from './menu-model-registry'; +export * from './menu-types'; diff --git a/packages/core/src/common/menu-adapter.ts b/packages/core/src/common/menu/menu-adapter.ts similarity index 97% rename from packages/core/src/common/menu-adapter.ts rename to packages/core/src/common/menu/menu-adapter.ts index 028940b0802e4..1d62b10bb24ba 100644 --- a/packages/core/src/common/menu-adapter.ts +++ b/packages/core/src/common/menu/menu-adapter.ts @@ -15,9 +15,9 @@ // ***************************************************************************** import { inject, injectable } from 'inversify'; -import { CommandRegistry } from './command'; -import { Disposable } from './disposable'; -import { MenuPath } from './menu'; +import { CommandRegistry } from '../command'; +import { Disposable } from '../disposable'; +import { MenuPath } from './menu-types'; export type MenuCommandArguments = [menuPath: MenuPath, command: string, ...commandArgs: unknown[]]; diff --git a/packages/core/src/common/menu.ts b/packages/core/src/common/menu/menu-model-registry.ts similarity index 57% rename from packages/core/src/common/menu.ts rename to packages/core/src/common/menu/menu-model-registry.ts index 6545c5d89fd44..0d1c59d5cac9c 100644 --- a/packages/core/src/common/menu.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -15,78 +15,12 @@ // ***************************************************************************** import { injectable, inject, named } from 'inversify'; -import { Disposable } from './disposable'; -import { CommandRegistry, Command } from './command'; -import { ContributionProvider } from './contribution-provider'; - -/** - * A menu entry representing an action, e.g. "New File". - */ -export interface MenuAction { - /** - * The command to execute. - */ - commandId: string; - /** - * In addition to the mandatory command property, an alternative command can be defined. - * It will be shown and invoked when pressing Alt while opening a menu. - */ - alt?: string; - /** - * A specific label for this action. If not specified the command label or command id will be used. - */ - label?: string; - /** - * Icon class(es). If not specified the icon class associated with the specified command - * (i.e. `command.iconClass`) will be used if it exists. - */ - icon?: string; - /** - * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined - * label will be used instead. - */ - order?: string; - /** - * Optional expression which will be evaluated by the {@link ContextKeyService} to determine visibility - * of the action, e.g. `resourceLangId == markdown`. - */ - when?: string; -} - -export namespace MenuAction { - /* Determine whether object is a MenuAction */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - export function is(arg: MenuAction | any): arg is MenuAction { - return !!arg && arg === Object(arg) && 'commandId' in arg; - } -} - -/** - * Additional options when creating a new submenu. - */ -export interface SubMenuOptions { - /** - * The class to use for the submenu icon. - */ - iconClass?: string; - /** - * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined - * label will be used instead. - */ - order?: string; - /** - * The conditions under which to include the specified submenu under the specified parent. - */ - when?: string; -} - -export type MenuPath = string[]; - -export const MAIN_MENU_BAR: MenuPath = ['menubar']; - -export const SETTINGS_MENU: MenuPath = ['settings_menu']; -export const ACCOUNTS_MENU: MenuPath = ['accounts_menu']; -export const ACCOUNTS_SUBMENU = [...ACCOUNTS_MENU, '1_accounts_submenu']; +import { Disposable } from '../disposable'; +import { CommandRegistry, Command } from '../command'; +import { ContributionProvider } from '../contribution-provider'; +import { CompositeMenuNode, CompositeMenuNodeWrapper } from './composite-menu-node'; +import { MenuAction, MenuNode, MenuPath, SubMenuOptions } from './menu-types'; +import { ActionMenuNode } from './action-menu-node'; export const MenuContribution = Symbol('MenuContribution'); @@ -339,201 +273,3 @@ export class MenuModelRegistry { return this.findGroup(menuPath); } } - -/** - * Base interface of the nodes used in the menu tree structure. - */ -export interface MenuNode { - /** - * the optional label for this specific node. - */ - readonly label?: string - /** - * technical identifier. - */ - readonly id: string - /** - * Menu nodes are sorted in ascending order based on their `sortString`. - */ - readonly sortString: string - /** - * Additional conditions determining the visibility of a menu node - */ - readonly when?: string; - - readonly children?: ReadonlyArray; - - readonly isSubmenu?: boolean; - - readonly icon?: string; -} - -/** - * Node representing a (sub)menu in the menu tree structure. - */ -export class CompositeMenuNode implements MenuNode { - protected readonly _children: MenuNode[] = []; - public iconClass?: string; - public order?: string; - readonly when?: string; - - constructor( - public readonly id: string, - public label?: string, - options?: SubMenuOptions - ) { - if (options) { - this.iconClass = options.iconClass; - this.order = options.order; - this.when = options.when; - } - } - - get icon(): string | undefined { - return this.iconClass; - } - - get children(): ReadonlyArray { - return this._children; - } - - /** - * Inserts the given node at the position indicated by `sortString`. - * - * @returns a disposable which, when called, will remove the given node again. - */ - public addNode(node: MenuNode): Disposable { - this._children.push(node); - this._children.sort((m1, m2) => { - // The navigation group is special as it will always be sorted to the top/beginning of a menu. - if (CompositeMenuNode.isNavigationGroup(m1)) { - return -1; - } - if (CompositeMenuNode.isNavigationGroup(m2)) { - return 1; - } - if (m1.sortString < m2.sortString) { - return -1; - } else if (m1.sortString > m2.sortString) { - return 1; - } else { - return 0; - } - }); - return { - dispose: () => { - const idx = this._children.indexOf(node); - if (idx >= 0) { - this._children.splice(idx, 1); - } - } - }; - } - - /** - * Removes the first node with the given id. - * - * @param id node id. - */ - public removeNode(id: string): void { - 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); - } - } - } - - get sortString(): string { - return this.order || this.id; - } - - get isSubmenu(): boolean { - return Boolean(this.label); - } - - /** - * Indicates whether the given node is the special `navigation` menu. - * - * @param node the menu node to check. - * @returns `true` when the given node is a {@link CompositeMenuNode} with id `navigation`, - * `false` otherwise. - */ - static isNavigationGroup(node: MenuNode): node is CompositeMenuNode { - return node instanceof CompositeMenuNode && node.id === 'navigation'; - } -} - -export class CompositeMenuNodeWrapper implements MenuNode { - constructor(protected readonly wrapped: Readonly, protected readonly options?: SubMenuOptions) { } - - get id(): string { return this.wrapped.id; } - - get label(): string | undefined { return this.wrapped.label; } - - get sortString(): string { return this.order || this.id; } - - get isSubmenu(): boolean { return Boolean(this.label); } - - get icon(): string | undefined { return this.iconClass; } - - get iconClass(): string | undefined { return this.options?.iconClass ?? this.wrapped.iconClass; } - - get order(): string | undefined { return this.options?.order ?? this.wrapped.order; } - - get when(): string | undefined { return this.options?.when ?? this.wrapped.when; } - - get children(): ReadonlyArray { return this.wrapped.children; } -} - -/** - * Node representing an action in the menu tree structure. - * It's based on {@link MenuAction} for which it tries to determine the - * best label, icon and sortString with the given data. - */ -export class ActionMenuNode implements MenuNode { - - readonly altNode: ActionMenuNode | undefined; - - constructor( - public readonly action: MenuAction, - protected readonly commands: CommandRegistry - ) { - if (action.alt) { - this.altNode = new ActionMenuNode({ commandId: action.alt }, commands); - } - } - - get when(): string | undefined { - return this.action.when; - } - - get id(): string { - return this.action.commandId; - } - - get label(): string { - if (this.action.label) { - return this.action.label; - } - const cmd = this.commands.getCommand(this.action.commandId); - if (!cmd) { - console.debug(`No label for action menu node: No command "${this.action.commandId}" exists.`); - return ''; - } - return cmd.label || cmd.id; - } - - get icon(): string | undefined { - if (this.action.icon) { - return this.action.icon; - } - const command = this.commands.getCommand(this.action.commandId); - return command && command.iconClass; - } - - get sortString(): string { - return this.action.order || this.label; - } -} diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts new file mode 100644 index 0000000000000..262599499fd0e --- /dev/null +++ b/packages/core/src/common/menu/menu-types.ts @@ -0,0 +1,112 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 +// ***************************************************************************** + +/** + * A menu entry representing an action, e.g. "New File". + */ +export interface MenuAction { + /** + * The command to execute. + */ + commandId: string; + /** + * In addition to the mandatory command property, an alternative command can be defined. + * It will be shown and invoked when pressing Alt while opening a menu. + */ + alt?: string; + /** + * A specific label for this action. If not specified the command label or command id will be used. + */ + label?: string; + /** + * Icon class(es). If not specified the icon class associated with the specified command + * (i.e. `command.iconClass`) will be used if it exists. + */ + icon?: string; + /** + * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined + * label will be used instead. + */ + order?: string; + /** + * Optional expression which will be evaluated by the {@link ContextKeyService} to determine visibility + * of the action, e.g. `resourceLangId == markdown`. + */ + when?: string; +} + +export namespace MenuAction { + /* Determine whether object is a MenuAction */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export function is(arg: MenuAction | any): arg is MenuAction { + return !!arg && arg === Object(arg) && 'commandId' in arg; + } +} + +/** + * Additional options when creating a new submenu. + */ +export interface SubMenuOptions { + /** + * The class to use for the submenu icon. + */ + iconClass?: string; + /** + * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined + * label will be used instead. + */ + order?: string; + /** + * The conditions under which to include the specified submenu under the specified parent. + */ + when?: string; +} + +export type MenuPath = string[]; + +export const MAIN_MENU_BAR: MenuPath = ['menubar']; + +export const SETTINGS_MENU: MenuPath = ['settings_menu']; +export const ACCOUNTS_MENU: MenuPath = ['accounts_menu']; +export const ACCOUNTS_SUBMENU = [...ACCOUNTS_MENU, '1_accounts_submenu']; + +/** + * Base interface of the nodes used in the menu tree structure. + */ +export interface MenuNode { + /** + * the optional label for this specific node. + */ + readonly label?: string + /** + * technical identifier. + */ + readonly id: string + /** + * Menu nodes are sorted in ascending order based on their `sortString`. + */ + readonly sortString: string + /** + * Additional conditions determining the visibility of a menu node + */ + readonly when?: string; + + readonly children?: ReadonlyArray; + + readonly isSubmenu?: boolean; + + readonly icon?: string; +} diff --git a/packages/core/src/common/menu.spec.ts b/packages/core/src/common/menu/menu.spec.ts similarity index 93% rename from packages/core/src/common/menu.spec.ts rename to packages/core/src/common/menu/menu.spec.ts index 3bf84e710dc77..6b6d0ae47d613 100644 --- a/packages/core/src/common/menu.spec.ts +++ b/packages/core/src/common/menu/menu.spec.ts @@ -14,9 +14,10 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { CommandContribution, CommandRegistry } from './command'; -import { CompositeMenuNode, MenuContribution, MenuModelRegistry } from './menu'; +import { CommandContribution, CommandRegistry } from '../command'; +import { MenuContribution, MenuModelRegistry } from './menu-model-registry'; import * as chai from 'chai'; +import { CompositeMenuNode } from './composite-menu-node'; const expect = chai.expect; From 76a8b697389e364ca58177179893376956b597d7 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 30 Jun 2022 13:01:34 -0600 Subject: [PATCH 13/24] Interface segregation --- .../src/browser/menu/browser-menu-plugin.ts | 2 - .../src/common/menu/composite-menu-node.ts | 8 +- packages/core/src/common/menu/menu-types.ts | 92 +++++++++++-------- 3 files changed, 54 insertions(+), 48 deletions(-) diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index e1ea7c30a0d16..2bbebcb808e27 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -291,10 +291,8 @@ export class DynamicMenuWidget extends MenuWidget { } private buildSubMenus(items: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { - console.log('SENTINEL FOR THE VALUE OF RESOURCE PATH', (this.services.contextKeyService as any).contextKeyService.getContextKeyValue('resourcePath')); for (const item of (menu.children ?? [])) { if (Array.isArray(item.children)) { - console.log('SENTINEL FOR CHECKING A WHEN', item); if (item.children.length && this.undefinedOrMatch(item.when, this.options.context)) { // do not render empty nodes if (item.isSubmenu) { // submenu node const submenu = this.services.menuWidgetFactory.createMenuWidget(item as MenuNode & { children: MenuNode[] }, this.options); diff --git a/packages/core/src/common/menu/composite-menu-node.ts b/packages/core/src/common/menu/composite-menu-node.ts index 8467869210dd5..ef0aec91e23b1 100644 --- a/packages/core/src/common/menu/composite-menu-node.ts +++ b/packages/core/src/common/menu/composite-menu-node.ts @@ -61,13 +61,7 @@ export class CompositeMenuNode implements MenuNode { if (CompositeMenuNode.isNavigationGroup(m2)) { return 1; } - if (m1.sortString < m2.sortString) { - return -1; - } else if (m1.sortString > m2.sortString) { - return 1; - } else { - return 0; - } + return m1.sortString.localeCompare(m2.sortString); }); return { dispose: () => { diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts index 262599499fd0e..efc33d82c7d41 100644 --- a/packages/core/src/common/menu/menu-types.ts +++ b/packages/core/src/common/menu/menu-types.ts @@ -17,7 +17,7 @@ /** * A menu entry representing an action, e.g. "New File". */ -export interface MenuAction { +export interface MenuAction extends MenuNodeRenderingData, Pick { /** * The command to execute. */ @@ -27,25 +27,11 @@ export interface MenuAction { * It will be shown and invoked when pressing Alt while opening a menu. */ alt?: string; - /** - * A specific label for this action. If not specified the command label or command id will be used. - */ - label?: string; - /** - * Icon class(es). If not specified the icon class associated with the specified command - * (i.e. `command.iconClass`) will be used if it exists. - */ - icon?: string; /** * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined * label will be used instead. */ order?: string; - /** - * Optional expression which will be evaluated by the {@link ContextKeyService} to determine visibility - * of the action, e.g. `resourceLangId == markdown`. - */ - when?: string; } export namespace MenuAction { @@ -59,20 +45,11 @@ export namespace MenuAction { /** * Additional options when creating a new submenu. */ -export interface SubMenuOptions { +export interface SubMenuOptions extends Pick, Pick, Partial> { /** * The class to use for the submenu icon. */ iconClass?: string; - /** - * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined - * label will be used instead. - */ - order?: string; - /** - * The conditions under which to include the specified submenu under the specified parent. - */ - when?: string; } export type MenuPath = string[]; @@ -83,30 +60,67 @@ export const SETTINGS_MENU: MenuPath = ['settings_menu']; export const ACCOUNTS_MENU: MenuPath = ['accounts_menu']; export const ACCOUNTS_SUBMENU = [...ACCOUNTS_MENU, '1_accounts_submenu']; -/** - * Base interface of the nodes used in the menu tree structure. - */ -export interface MenuNode { - /** - * the optional label for this specific node. - */ - readonly label?: string +interface MenuNodeMetadata { /** * technical identifier. */ - readonly id: string + readonly id: string; /** * Menu nodes are sorted in ascending order based on their `sortString`. */ - readonly sortString: string + readonly sortString: string; /** - * Additional conditions determining the visibility of a menu node + * Condition under which the menu node should be rendered. + * See https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts */ readonly when?: string; +} - readonly children?: ReadonlyArray; +interface MenuNodeRenderingData { + /** + * Optional label. Will be rendered as text of the menu item. + */ + readonly label?: string; + /** + * Icon classes for the menu node. If present, these will produce an icon to the left of the label in browser-style menus. + */ + readonly icon?: string; +} - readonly isSubmenu?: boolean; +export const enum CompoundMenuNodeRole { + /** Indicates that the node should be rendered as submenu that opens a new menu on hover */ + Submenu, + /** Indicates that the node's children should be rendered as group separated from other items by a separator */ + Group, + /** Indicates that the node's children should be treated as though they were direct children of the node's parent */ + Flat, +} - readonly icon?: string; +interface CompoundMenuNode { + /** + * Items that are grouped under this menu. + */ + readonly children?: ReadonlyArray + /** + * @deprecated @since 1.28 use `role` instead. + * Whether the item should be rendered as a submenu. + */ + readonly isSubmenu: boolean; + /** + * How the node and its children should be rendered. See {@link CompoundMenuNodeRole}. + */ + readonly role: CompoundMenuNodeRole; +} + +interface CommandMenuNode { + command: string; +} + +interface AlternativeHandlerMenuNode { + altNode: MenuNodeMetadata & MenuNodeRenderingData & Partial; } + +/** + * Base interface of the nodes used in the menu tree structure. + */ +export interface MenuNode extends MenuNodeMetadata, MenuNodeRenderingData, Partial, Partial, Partial { } From 455d720809f04b42e70ee85e6239c80cbf354a5d Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 30 Jun 2022 17:02:02 -0600 Subject: [PATCH 14/24] Refactor to support flat menus & overriding buildSubmenus / fillMenuTemplate --- .../menu/sample-browser-menu-module.ts | 30 ++-- .../menu/sample-electron-menu-module.ts | 21 +-- .../src/browser/menu/browser-menu-plugin.ts | 121 ++++++-------- .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 1 - .../core/src/common/menu/action-menu-node.ts | 20 +-- .../src/common/menu/composite-menu-node.ts | 33 ++-- .../src/common/menu/menu-model-registry.ts | 1 - packages/core/src/common/menu/menu-types.ts | 55 ++++++- .../menu/electron-main-menu-factory.ts | 155 ++++++++---------- .../comments/comment-thread-widget.tsx | 10 +- .../menus/menus-contribution-handler.ts | 4 +- .../main/browser/view/tree-view-widget.tsx | 4 +- packages/scm/src/browser/scm-tree-widget.tsx | 6 +- 13 files changed, 221 insertions(+), 240 deletions(-) diff --git a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts index df2bf8530c768..eed404f2251e3 100644 --- a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts +++ b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts @@ -17,7 +17,7 @@ import { injectable, ContainerModule } from '@theia/core/shared/inversify'; import { Menu as MenuWidget } from '@theia/core/shared/@phosphor/widgets'; import { Disposable } from '@theia/core/lib/common/disposable'; -import { MenuNode, CompositeMenuNode, MenuPath } from '@theia/core/lib/common/menu'; +import { MenuNode, CompositeMenuNode, MenuPath, CompoundMenuNode } from '@theia/core/lib/common/menu'; import { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget, BrowserMenuOptions } from '@theia/core/lib/browser/menu/browser-menu-plugin'; import { PlaceholderMenuNode } from './sample-menu-contribution'; @@ -28,14 +28,15 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { @injectable() class SampleBrowserMainMenuFactory extends BrowserMainMenuFactory { - protected override handleDefault(menuCommandRegistry: MenuCommandRegistry, menuNode: MenuNode): void { - if (menuNode instanceof PlaceholderMenuNode && menuCommandRegistry instanceof SampleMenuCommandRegistry) { - menuCommandRegistry.registerPlaceholderMenu(menuNode); + protected override registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode & CompoundMenuNode, args: unknown[]): void { + if (menu instanceof PlaceholderMenuNode && menuCommandRegistry instanceof SampleMenuCommandRegistry) { + menuCommandRegistry.registerPlaceholderMenu(menu); + } else { + super.registerMenu(menuCommandRegistry, menu, args); } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected override createMenuCommandRegistry(menu: CompositeMenuNode, args: any[] = []): MenuCommandRegistry { + protected override createMenuCommandRegistry(menu: CompositeMenuNode, args: unknown[] = []): MenuCommandRegistry { const menuCommandRegistry = new SampleMenuCommandRegistry(this.services); this.registerMenu(menuCommandRegistry, menu, args); return menuCommandRegistry; @@ -84,14 +85,15 @@ class SampleMenuCommandRegistry extends MenuCommandRegistry { class SampleDynamicMenuWidget extends DynamicMenuWidget { - protected override handleDefault(menuNode: MenuNode): MenuWidget.IItemOptions[] { - if (menuNode instanceof PlaceholderMenuNode) { - return [{ - command: menuNode.id, - type: 'command' - }]; + protected override buildSubMenus(parentItems: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { + if (menu instanceof PlaceholderMenuNode) { + parentItems.push({ + command: menu.id, + type: 'command', + }); + } else { + super.buildSubMenus(parentItems, menu, commands); } - return []; + return parentItems; } - } diff --git a/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts b/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts index 13317cc81ead0..9484a739698ea 100644 --- a/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts +++ b/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { injectable, ContainerModule } from '@theia/core/shared/inversify'; -import { CompositeMenuNode } from '@theia/core/lib/common/menu'; +import { CompoundMenuNode, MenuNode } from '@theia/core/lib/common/menu'; import { ElectronMainMenuFactory, ElectronMenuOptions } from '@theia/core/lib/electron-browser/menu/electron-main-menu-factory'; import { PlaceholderMenuNode } from '../../browser/menu/sample-menu-contribution'; @@ -25,17 +25,14 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { @injectable() class SampleElectronMainMenuFactory extends ElectronMainMenuFactory { - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected override handleElectronDefault(menuNode: CompositeMenuNode, args: any[] = [], options?: ElectronMenuOptions): Electron.MenuItemConstructorOptions[] { - if (menuNode instanceof PlaceholderMenuNode) { - return [{ - label: menuNode.label, - enabled: false, - visible: true - }]; + protected override fillMenuTemplate( + parentItems: Electron.MenuItemConstructorOptions[], menuModel: MenuNode & CompoundMenuNode, args: unknown[] = [], options: ElectronMenuOptions + ): Electron.MenuItemConstructorOptions[] { + if (menuModel instanceof PlaceholderMenuNode) { + parentItems.push({ label: menuModel.label, enabled: false, visible: true }); + } else { + super.fillMenuTemplate(parentItems, menuModel, args, options); } - return []; + return parentItems; } - } diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 2bbebcb808e27..82d3d50403d4d 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -18,8 +18,8 @@ import { injectable, inject } from 'inversify'; import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets'; import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands'; import { - CommandRegistry, ActionMenuNode, CompositeMenuNode, environment, - MenuModelRegistry, MAIN_MENU_BAR, MenuPath, DisposableCollection, Disposable, MenuNode, MenuCommandExecutor + CommandRegistry, CompositeMenuNode, environment, + MenuModelRegistry, MAIN_MENU_BAR, MenuPath, DisposableCollection, Disposable, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode } from '../../common'; import { KeybindingRegistry } from '../keybinding'; import { FrontendApplicationContribution, FrontendApplication } from '../frontend-application'; @@ -124,26 +124,19 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { return menuCommandRegistry; } - protected registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode & { children: ReadonlyArray }, args: unknown[]): void { + protected registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode & CompoundMenuNode, args: unknown[]): void { for (const child of menu.children) { - if (child instanceof ActionMenuNode) { - menuCommandRegistry.registerActionMenu(child, args); + if (child.command) { + menuCommandRegistry.registerActionMenu(child as MenuNode & CommandMenuNode, args); if (child.altNode) { menuCommandRegistry.registerActionMenu(child.altNode, args); } - } else if (Array.isArray(child.children)) { - this.registerMenu(menuCommandRegistry, child as MenuNode & { children: MenuNode[] }, args); - } else { - this.handleDefault(menuCommandRegistry, child, args); + } else if (CompoundMenuNode.is(child)) { + this.registerMenu(menuCommandRegistry, child, args); } } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected handleDefault(menuCommandRegistry: MenuCommandRegistry, menuNode: MenuNode, args: any[]): void { - // NOOP - } - protected get services(): MenuServices { return { context: this.context, @@ -283,77 +276,62 @@ export class DynamicMenuWidget extends MenuWidget { super.open(x, y, options); } - private updateSubMenus(parent: MenuWidget, menu: CompositeMenuNode, commands: MenuCommandRegistry): void { + protected updateSubMenus(parent: MenuWidget, menu: CompositeMenuNode, commands: MenuCommandRegistry): void { const items = this.buildSubMenus([], menu, commands); + if (items[items.length - 1]?.type === 'separator') { + items.pop(); + } for (const item of items) { parent.addItem(item); } } - private buildSubMenus(items: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { - for (const item of (menu.children ?? [])) { - if (Array.isArray(item.children)) { - if (item.children.length && this.undefinedOrMatch(item.when, this.options.context)) { // do not render empty nodes - if (item.isSubmenu) { // submenu node - const submenu = this.services.menuWidgetFactory.createMenuWidget(item as MenuNode & { children: MenuNode[] }, this.options); - if (!submenu.items.length) { - continue; - } - items.push({ - type: 'submenu', - submenu, - }); - } else { // group node - if (item.id === 'inline') { - continue; - } - const submenu = this.buildSubMenus([], item, commands); - if (!submenu.length) { - continue; - } - if (items.length) { // do not put a separator above the first group - items.push({ - type: 'separator' - }); + protected buildSubmenusCalled = 0; + + protected buildSubMenus(parentItems: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { + if (CompoundMenuNode.is(menu) && menu.children.length && this.undefinedOrMatch(menu.when, this.options.context)) { + const role = menu === this.menu ? CompoundMenuNodeRole.Group : CompoundMenuNode.getRole(menu); + if (role === CompoundMenuNodeRole.Submenu) { + const submenu = this.services.menuWidgetFactory.createMenuWidget(menu, this.options); + parentItems.push({ type: 'submenu', submenu }); + } else if (role === CompoundMenuNodeRole.Group) { + const childrenToMerge: ReadonlyArray[] = []; + const children = menu.children.filter(child => { + if (CompoundMenuNode.getRole(child) === CompoundMenuNodeRole.Flat) { + if (this.undefinedOrMatch(child.when, this.options.context)) { + childrenToMerge.push((child as CompoundMenuNode).children); } - items.push(...submenu); // render children + return false; } + return true; + }).concat(...childrenToMerge).sort(CompoundMenuNode.sortChildren); + const myItems: MenuWidget.IItemOptions[] = []; + children.forEach(child => this.buildSubMenus(myItems, child, commands)); + if (parentItems.length && myItems.length && parentItems[parentItems.length - 1].type !== 'separator') { + parentItems.push({ type: 'separator' }); } - } else if (item instanceof ActionMenuNode) { - const { context } = this.services; - const node = item.altNode && context.altPressed ? item.altNode : item; - if (commands.isVisible(node.action.commandId) && this.undefinedOrMatch(node.action.when, this.options.context)) { - console.log('SENTINEL FOR ADDING AN ITEM...', item.label, item, menu); - items.push({ - command: node.action.commandId, - type: 'command' - }); + parentItems.push(...myItems); + if (myItems.length) { + parentItems.push({ type: 'separator' }); } - } else { - console.log('SENTINEL FOR THE DEFAULT?', item); - items.push(...this.handleDefault(item)); + } + } else if (menu.command) { + const node = menu.altNode && this.services.context.altPressed ? menu.altNode : (menu as MenuNode & CommandMenuNode); + if (commands.isVisible(node.command) && this.undefinedOrMatch(node.when, this.options.context)) { + parentItems.push({ + command: node.command, + type: 'command' + }); } } - return items; + return parentItems; } protected undefinedOrMatch(expression?: string, context?: HTMLElement): boolean { - const doTheThing = this.doUndefinedOrMatch(expression, context); - if (expression) { - console.log('SENTINEL FOR THE RESULT FOR', expression, context, doTheThing); - } - return doTheThing; - } - - protected doUndefinedOrMatch(expression?: string, context?: HTMLElement): boolean { if (expression) { return this.services.contextKeyService.match(expression, context); } return true; } - protected handleDefault(menuNode: MenuNode): MenuWidget.IItemOptions[] { - return []; - } - protected preserveFocusedElement(previousFocusedElement: Element | null = document.activeElement): boolean { if (!this.previousFocusedElement && previousFocusedElement instanceof HTMLElement) { this.previousFocusedElement = previousFocusedElement; @@ -442,17 +420,16 @@ export class BrowserMenuBarContribution implements FrontendApplicationContributi */ export class MenuCommandRegistry extends PhosphorCommandRegistry { - protected actions = new Map(); + protected actions = new Map(); protected toDispose = new DisposableCollection(); constructor(protected services: MenuServices) { super(); } - registerActionMenu(menu: ActionMenuNode, args: unknown[]): void { - const { commandId } = menu.action; + registerActionMenu(menu: MenuNode & CommandMenuNode, args: unknown[]): void { const { commandRegistry } = this.services; - const command = commandRegistry.getCommand(commandId); + const command = commandRegistry.getCommand(menu.command); if (!command) { return; } @@ -471,9 +448,9 @@ export class MenuCommandRegistry extends PhosphorCommandRegistry { return this; } - protected registerCommand(menu: ActionMenuNode, args: unknown[], menuPath: MenuPath): Disposable { + protected registerCommand(menu: MenuNode & CommandMenuNode, args: unknown[], menuPath: MenuPath): Disposable { const { commandRegistry, keybindingRegistry, commandExecutor } = this.services; - const command = commandRegistry.getCommand(menu.action.commandId); + const command = commandRegistry.getCommand(menu.command); if (!command) { return Disposable.NULL; } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index 025ad99d729f2..57df2cc26907a 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -211,7 +211,6 @@ export class TabBarToolbar extends ReactWidget { paths.push(split[i], split[i + 1]); // TODO order is missing, items sorting will be alphabetic if (split[i + 1]) { - console.log('SENTINEL FOR REGISTERING A SUBMENU...', { group: item.group, paths, split, label: split[i + 1] }); toDisposeOnHide.push(this.menus.registerSubmenu([...TAB_BAR_TOOLBAR_CONTEXT_MENU, ...paths], split[i + 1])); } } diff --git a/packages/core/src/common/menu/action-menu-node.ts b/packages/core/src/common/menu/action-menu-node.ts index a5c9475600187..b8ca94c764da7 100644 --- a/packages/core/src/common/menu/action-menu-node.ts +++ b/packages/core/src/common/menu/action-menu-node.ts @@ -15,19 +15,19 @@ // ***************************************************************************** import { CommandRegistry } from '../command'; -import { MenuAction, MenuNode } from './menu-types'; +import { AlternativeHandlerMenuNode, CommandMenuNode, MenuAction, MenuNode } from './menu-types'; /** * Node representing an action in the menu tree structure. * It's based on {@link MenuAction} for which it tries to determine the * best label, icon and sortString with the given data. */ -export class ActionMenuNode implements MenuNode { +export class ActionMenuNode implements MenuNode, CommandMenuNode, Partial { readonly altNode: ActionMenuNode | undefined; constructor( - public readonly action: MenuAction, + protected readonly action: MenuAction, protected readonly commands: CommandRegistry ) { if (action.alt) { @@ -35,13 +35,11 @@ export class ActionMenuNode implements MenuNode { } } - get when(): string | undefined { - return this.action.when; - } + get command(): string { return this.action.commandId; }; - get id(): string { - return this.action.commandId; - } + get when(): string | undefined { return this.action.when; } + + get id(): string { return this.action.commandId; } get label(): string { if (this.action.label) { @@ -63,7 +61,5 @@ export class ActionMenuNode implements MenuNode { return command && command.iconClass; } - get sortString(): string { - return this.action.order || this.label; - } + get sortString(): string { return this.action.order || this.label; } } diff --git a/packages/core/src/common/menu/composite-menu-node.ts b/packages/core/src/common/menu/composite-menu-node.ts index ef0aec91e23b1..42cc8fa93aadf 100644 --- a/packages/core/src/common/menu/composite-menu-node.ts +++ b/packages/core/src/common/menu/composite-menu-node.ts @@ -15,16 +15,17 @@ // ***************************************************************************** import { Disposable } from '../disposable'; -import { MenuNode, SubMenuOptions } from './menu-types'; +import { CompoundMenuNode, CompoundMenuNodeMetadata, CompoundMenuNodeRole, MenuNode, SubMenuOptions } from './menu-types'; /** * Node representing a (sub)menu in the menu tree structure. */ -export class CompositeMenuNode implements MenuNode { +export class CompositeMenuNode implements MenuNode, CompoundMenuNode, CompoundMenuNodeMetadata { protected readonly _children: MenuNode[] = []; public iconClass?: string; public order?: string; readonly when?: string; + readonly role: CompoundMenuNodeRole; constructor( public readonly id: string, @@ -36,6 +37,7 @@ export class CompositeMenuNode implements MenuNode { this.order = options.order; this.when = options.when; } + this.role = options?.role ?? CompoundMenuNode.getRole(this)!; } get icon(): string | undefined { @@ -53,16 +55,7 @@ export class CompositeMenuNode implements MenuNode { */ public addNode(node: MenuNode): Disposable { this._children.push(node); - this._children.sort((m1, m2) => { - // The navigation group is special as it will always be sorted to the top/beginning of a menu. - if (CompositeMenuNode.isNavigationGroup(m1)) { - return -1; - } - if (CompositeMenuNode.isNavigationGroup(m2)) { - return 1; - } - return m1.sortString.localeCompare(m2.sortString); - }); + this._children.sort(CompoundMenuNode.sortChildren); return { dispose: () => { const idx = this._children.indexOf(node); @@ -96,19 +89,11 @@ export class CompositeMenuNode implements MenuNode { return Boolean(this.label); } - /** - * Indicates whether the given node is the special `navigation` menu. - * - * @param node the menu node to check. - * @returns `true` when the given node is a {@link CompositeMenuNode} with id `navigation`, - * `false` otherwise. - */ - static isNavigationGroup(node: MenuNode): node is CompositeMenuNode { - return node instanceof CompositeMenuNode && node.id === 'navigation'; - } + /** @deprecated @since 1.28 use CompoundMenuNode.isNavigationGroup instead */ + static isNavigationGroup = CompoundMenuNode.isNavigationGroup; } -export class CompositeMenuNodeWrapper implements MenuNode { +export class CompositeMenuNodeWrapper implements MenuNode, CompoundMenuNodeMetadata { constructor(protected readonly wrapped: Readonly, protected readonly options?: SubMenuOptions) { } get id(): string { return this.wrapped.id; } @@ -119,6 +104,8 @@ export class CompositeMenuNodeWrapper implements MenuNode { get isSubmenu(): boolean { return Boolean(this.label); } + get role(): CompoundMenuNodeRole { return this.options?.role ?? this.wrapped.role; } + get icon(): string | undefined { return this.iconClass; } get iconClass(): string | undefined { return this.options?.iconClass ?? this.wrapped.iconClass; } diff --git a/packages/core/src/common/menu/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index 0d1c59d5cac9c..77b52431a6f97 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -167,7 +167,6 @@ export class MenuModelRegistry { } linkSubmenu(parentPath: MenuPath | string, childId: string, options?: SubMenuOptions, group?: string): Disposable { - console.log('SENTINEL FOR LINKING A SUBMENU', childId, parentPath, options, group); const child = this.independentSubmenus.get(childId); if (!child) { throw new Error(`Attempted to link non-existent menu with id ${childId}`); diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts index efc33d82c7d41..a04a8369966d7 100644 --- a/packages/core/src/common/menu/menu-types.ts +++ b/packages/core/src/common/menu/menu-types.ts @@ -45,7 +45,7 @@ export namespace MenuAction { /** * Additional options when creating a new submenu. */ -export interface SubMenuOptions extends Pick, Pick, Partial> { +export interface SubMenuOptions extends Pick, Pick, Partial> { /** * The class to use for the submenu icon. */ @@ -60,7 +60,7 @@ export const SETTINGS_MENU: MenuPath = ['settings_menu']; export const ACCOUNTS_MENU: MenuPath = ['accounts_menu']; export const ACCOUNTS_SUBMENU = [...ACCOUNTS_MENU, '1_accounts_submenu']; -interface MenuNodeMetadata { +export interface MenuNodeMetadata { /** * technical identifier. */ @@ -76,7 +76,7 @@ interface MenuNodeMetadata { readonly when?: string; } -interface MenuNodeRenderingData { +export interface MenuNodeRenderingData { /** * Optional label. Will be rendered as text of the menu item. */ @@ -96,11 +96,43 @@ export const enum CompoundMenuNodeRole { Flat, } -interface CompoundMenuNode { +export interface CompoundMenuNode { /** * Items that are grouped under this menu. */ - readonly children?: ReadonlyArray + readonly children: ReadonlyArray +} + +export namespace CompoundMenuNode { + export function is(node: MenuNode): node is MenuNode & CompoundMenuNode { return Array.isArray(node.children); } + export function getRole(node: MenuNode): CompoundMenuNodeRole | undefined { + if (!is(node)) { return undefined; } + return node.role ?? (node.label ? CompoundMenuNodeRole.Submenu : CompoundMenuNodeRole.Group); + } + export function sortChildren(m1: MenuNode, m2: MenuNode): number { + // The navigation group is special as it will always be sorted to the top/beginning of a menu. + if (isNavigationGroup(m1)) { + return -1; + } + if (isNavigationGroup(m2)) { + return 1; + } + return m1.sortString.localeCompare(m2.sortString); + } + + /** + * Indicates whether the given node is the special `navigation` menu. + * + * @param node the menu node to check. + * @returns `true` when the given node is a {@link CompoundMenuNode} with id `navigation`, + * `false` otherwise. + */ + export function isNavigationGroup(node: MenuNode): node is MenuNode & CompoundMenuNode { + return is(node) && node.id === 'navigation'; + } +} + +export interface CompoundMenuNodeMetadata { /** * @deprecated @since 1.28 use `role` instead. * Whether the item should be rendered as a submenu. @@ -112,15 +144,20 @@ interface CompoundMenuNode { readonly role: CompoundMenuNodeRole; } -interface CommandMenuNode { +export interface CommandMenuNode { command: string; } -interface AlternativeHandlerMenuNode { - altNode: MenuNodeMetadata & MenuNodeRenderingData & Partial; +export interface AlternativeHandlerMenuNode { + altNode: MenuNodeMetadata & MenuNodeRenderingData & CommandMenuNode; } /** * Base interface of the nodes used in the menu tree structure. */ -export interface MenuNode extends MenuNodeMetadata, MenuNodeRenderingData, Partial, Partial, Partial { } +export interface MenuNode extends MenuNodeMetadata, + MenuNodeRenderingData, + Partial, + Partial, + Partial, + Partial { } 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 10beccbc72dc7..37dc24147e2bf 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 @@ -18,7 +18,7 @@ import * as electronRemote from '../../../electron-shared/@electron/remote'; import { inject, injectable, postConstruct } from 'inversify'; -import { isOSX, ActionMenuNode, MAIN_MENU_BAR, MenuPath, MenuNode } from '../../common'; +import { isOSX, MAIN_MENU_BAR, MenuPath, MenuNode, CommandMenuNode, CompoundMenuNode, CompoundMenuNodeRole } from '../../common'; import { Keybinding } from '../../common/keybinding'; import { PreferenceService, CommonCommands } from '../../browser'; import debounce = require('lodash.debounce'); @@ -129,108 +129,95 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { return electronRemote.Menu.buildFromTemplate(template); } - protected fillMenuTemplate(items: Electron.MenuItemConstructorOptions[], - menuModel: MenuNode, - args: any[] = [], + protected fillMenuTemplate(parentItems: Electron.MenuItemConstructorOptions[], + menu: MenuNode, + args: unknown[] = [], options: ElectronMenuOptions ): Electron.MenuItemConstructorOptions[] { - const showDisabled = (options?.showDisabled === undefined) ? true : options?.showDisabled; - for (const menu of (menuModel.children ?? [])) { - if (menu.children) { - if (menu.children.length > 0 && (!menu.when || this.contextKeyService.match(menu.when, options?.context))) { - // do not render empty nodes + const showDisabled = options?.showDisabled !== false; - if (menu.isSubmenu) { // submenu node + if (CompoundMenuNode.is(menu) && menu.children.length && this.undefinedOrMatch(menu.when, options.context)) { + const role = CompoundMenuNode.getRole(menu); + if (role === CompoundMenuNodeRole.Group && menu.id === 'inline') { return parentItems; } - const submenu = this.fillMenuTemplate([], menu, args, options); - if (submenu.length === 0) { - continue; - } - - items.push({ - label: menu.label, - submenu - }); - - } else { // group node - if (menu.id === 'inline') { - continue; - } - - // process children - const submenu = this.fillMenuTemplate([], menu, args, options); - if (submenu.length === 0) { - continue; - } - - if (items.length > 0) { - // do not put a separator above the first group - - items.push({ - type: 'separator' - }); - } - - // render children - items.push(...submenu); + const childrenToMerge: ReadonlyArray[] = []; + const children = menu.children.filter(child => { + if (CompoundMenuNode.getRole(child) === CompoundMenuNodeRole.Flat) { + if (this.undefinedOrMatch(child.when, options.context)) { + childrenToMerge.push((child as CompoundMenuNode).children); } + return false; } - } else if (menu instanceof ActionMenuNode) { - const node = menu.altNode && this.context.altPressed ? menu.altNode : menu; - const commandId = node.action.commandId; - - // That is only a sanity check at application startup. - if (!this.commandRegistry.getCommand(commandId)) { - console.debug(`Skipping menu item with missing command: "${commandId}".`); - continue; + return true; + }).concat(...childrenToMerge).sort(CompoundMenuNode.sortChildren); + + const myItems: Electron.MenuItemConstructorOptions[] = []; + children.forEach(child => this.fillMenuTemplate(myItems, child, args, options)); + if (myItems.length === 0) { return parentItems; } + if (role === CompoundMenuNodeRole.Submenu) { + parentItems.push({ label: menu.label, submenu: myItems }); + } else if (role === CompoundMenuNodeRole.Group) { + if (parentItems.length && parentItems[parentItems.length - 1].type !== 'separator') { + parentItems.push({ type: 'separator' }); } + parentItems.push(...myItems); + parentItems.push({ type: 'separator' }); + } + } else if (menu.command) { + const node = menu.altNode && this.context.altPressed ? menu.altNode : (menu as MenuNode & CommandMenuNode); + const commandId = node.command; + + // That is only a sanity check at application startup. + if (!this.commandRegistry.getCommand(commandId)) { + console.debug(`Skipping menu item with missing command: "${commandId}".`); + return parentItems; + } - if (!this.menuCommandExecutor.isVisible(options.rootMenuPath, commandId, ...args) - || (node.action.when && !this.contextKeyService.match(node.action.when, options?.context))) { - continue; - } + if (!this.menuCommandExecutor.isVisible(options.rootMenuPath, commandId, ...args) || !this.undefinedOrMatch(node.when, options.context)) { + return parentItems; + } - // We should omit rendering context-menu items which are disabled. - if (!showDisabled && !this.menuCommandExecutor.isEnabled(options.rootMenuPath, commandId, ...args)) { - continue; - } + // We should omit rendering context-menu items which are disabled. + if (!showDisabled && !this.menuCommandExecutor.isEnabled(options.rootMenuPath, commandId, ...args)) { + return parentItems; + } - const bindings = this.keybindingRegistry.getKeybindingsForCommand(commandId); + const bindings = this.keybindingRegistry.getKeybindingsForCommand(commandId); - const accelerator = bindings[0] && this.acceleratorFor(bindings[0]); + const accelerator = bindings[0] && this.acceleratorFor(bindings[0]); - const menuItem: Electron.MenuItemConstructorOptions = { - id: node.id, - label: node.label, - type: this.commandRegistry.getToggledHandler(commandId, ...args) ? 'checkbox' : 'normal', - checked: this.commandRegistry.isToggled(commandId, ...args), - enabled: true, // https://github.com/eclipse-theia/theia/issues/446 - visible: true, - accelerator, - click: () => this.execute(commandId, args, options.rootMenuPath) - }; + const menuItem: Electron.MenuItemConstructorOptions = { + id: node.id, + label: node.label, + type: this.commandRegistry.getToggledHandler(commandId, ...args) ? 'checkbox' : 'normal', + checked: this.commandRegistry.isToggled(commandId, ...args), + enabled: true, // https://github.com/eclipse-theia/theia/issues/446 + visible: true, + accelerator, + click: () => this.execute(commandId, args, options.rootMenuPath) + }; - if (isOSX) { - const role = this.roleFor(node.id); - if (role) { - menuItem.role = role; - delete menuItem.click; - } + if (isOSX) { + const role = this.roleFor(node.id); + if (role) { + menuItem.role = role; + delete menuItem.click; } - items.push(menuItem); + } + parentItems.push(menuItem); - if (this.commandRegistry.getToggledHandler(commandId, ...args)) { - this._toggledCommands.add(commandId); - } - } else { - items.push(...this.handleElectronDefault(menu, args, options)); + if (this.commandRegistry.getToggledHandler(commandId, ...args)) { + this._toggledCommands.add(commandId); } } - return items; + return parentItems; } - protected handleElectronDefault(menuNode: MenuNode, args: any[] = [], options?: ElectronMenuOptions): Electron.MenuItemConstructorOptions[] { - return []; + protected undefinedOrMatch(expression?: string, context?: HTMLElement): boolean { + if (expression) { + return this.contextKeyService.match(expression, context); + } + return true; } /** diff --git a/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx b/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx index 9e934d428d476..a34d5f92032db 100644 --- a/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx +++ b/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx @@ -96,7 +96,7 @@ export class CommentThreadWidget extends BaseWidget { } })); this.contextMenu = this.menus.getMenu(COMMENT_THREAD_CONTEXT); - this.contextMenu.children.map(node => node instanceof ActionMenuNode && node.action.when).forEach(exp => { + this.contextMenu.children.map(node => node instanceof ActionMenuNode && node.when).forEach(exp => { if (typeof exp === 'string') { this.contextKeyService.setExpression(exp); } @@ -377,7 +377,7 @@ export class CommentForm

extend }; this.menu = this.props.menus.getMenu(COMMENT_THREAD_CONTEXT); - this.menu.children.map(node => node instanceof ActionMenuNode && node.action.when).forEach(exp => { + this.menu.children.map(node => node instanceof ActionMenuNode && node.when).forEach(exp => { if (typeof exp === 'string') { this.props.contextKeyService.setExpression(exp); } @@ -597,7 +597,7 @@ namespace CommentsInlineAction { export class CommentsInlineAction extends React.Component { override render(): React.ReactNode { const { node, commands, contextKeyService, commentThread, commentUniqueId } = this.props; - if (node.action.when && !contextKeyService.match(node.action.when)) { + if (node.when && !contextKeyService.match(node.when)) { return false; } return

@@ -657,10 +657,10 @@ export class CommentAction extends React.Component { override render(): React.ReactNode { const classNames = ['comments-button', 'comments-text-button', 'theia-button']; const { node, commands, contextKeyService, onClick } = this.props; - if (node.action.when && !contextKeyService.match(node.action.when)) { + if (node.when && !contextKeyService.match(node.when)) { return false; } - const isEnabled = commands.isEnabled(node.action.commandId); + const isEnabled = commands.isEnabled(node.command); if (!isEnabled) { classNames.push(DISABLED_CLASS); } diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index 1ad1f6a320fab..f803414aade2e 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -17,7 +17,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { inject, injectable, optional } from '@theia/core/shared/inversify'; -import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter } from '@theia/core'; +import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter, CompoundMenuNodeRole } from '@theia/core'; import { MenuModelRegistry } from '@theia/core/lib/common'; import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { DeployedPlugin, IconUrl, Menu } from '../../../common'; @@ -57,7 +57,7 @@ export class MenusContributionPointHandler { this.commandAdapterRegistry.registerAdapter(this.commandAdapter); for (const contributionPoint of implementedVSCodeContributionPoints) { this.menuRegistry.registerIndependentSubmenu(contributionPoint, ''); - this.getMatchingMenu(contributionPoint)!.forEach(([menu, when]) => this.menuRegistry.linkSubmenu(menu, contributionPoint, when ? { when } : undefined)); + this.getMatchingMenu(contributionPoint)!.forEach(([menu, when]) => this.menuRegistry.linkSubmenu(menu, contributionPoint, { role: CompoundMenuNodeRole.Flat, when })); } this.tabBarToolbar.registerMenuDelegate(PLUGIN_EDITOR_TITLE_MENU, widget => this.codeEditorWidgetUtil.is(widget)); this.tabBarToolbar.registerMenuDelegate(PLUGIN_SCM_TITLE_MENU, widget => widget instanceof ScmWidget); diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index b1d533493d1bc..e93bd87bf2f5a 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -399,14 +399,14 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { // eslint-disable-next-line @typescript-eslint/no-explicit-any protected renderInlineCommand(node: ActionMenuNode, index: number, tabbable: boolean, arg: any): React.ReactNode { const { icon } = node; - if (!icon || !this.commands.isVisible(node.action.commandId, arg) || !node.action.when || !this.contextKeys.match(node.action.when)) { + if (!icon || !this.commands.isVisible(node.command, arg) || !node.when || !this.contextKeys.match(node.when)) { return false; } const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, icon, ACTION_ITEM, 'theia-tree-view-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; return
{ e.stopPropagation(); - this.commands.executeCommand(node.action.commandId, arg); + this.commands.executeCommand(node.command, arg); }} />; } diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx index f49d60e0e4e26..e1df209eabb58 100644 --- a/packages/scm/src/browser/scm-tree-widget.tsx +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -766,10 +766,10 @@ export class ScmInlineAction extends React.Component { let isActive: boolean = false; model.execInNodeContext(treeNode, () => { - isActive = contextKeys.match(node.action.when); + isActive = contextKeys.match(node.when); }); - if (!commands.isVisible(node.action.commandId, ...args) || !isActive) { + if (!commands.isVisible(node.command, ...args) || !isActive) { return false; } return
@@ -781,7 +781,7 @@ export class ScmInlineAction extends React.Component { event.stopPropagation(); const { commands, node, args } = this.props; - commands.executeCommand(node.action.commandId, ...args); + commands.executeCommand(node.command, ...args); }; } export namespace ScmInlineAction { From e2dcacd9241c72d40c7d1247e5cadaf1598b8b86 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 30 Jun 2022 17:36:44 -0600 Subject: [PATCH 15/24] Start refactoring toolbar to use flat submenus TODO: Since the submenus can occur at different levels (navigator, other groups) We have to handle that. May not need to loop through everything, but we have to loop through some things. --- .../tab-bar-toolbar-registry.ts | 19 +---- .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 2 - .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 75 +++++++------------ .../src/common/menu/menu-model-registry.ts | 3 - 4 files changed, 28 insertions(+), 71 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index b45e7f47b5d68..a9f9b2d71e201 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -20,7 +20,7 @@ import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection import { ContextKeyService } from '../../context-key-service'; import { FrontendApplicationContribution } from '../../frontend-application'; import { Widget } from '../../widgets'; -import { MenuDelegate, menuDelegateSeparator, MenuToolbarItem, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { MenuDelegate, MenuToolbarItem, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. @@ -38,6 +38,7 @@ export interface TabBarToolbarContribution { } function yes(): true { return true; } +const menuDelegateSeparator = '=@='; /** * Main, shared registry for tab-bar toolbar items. @@ -150,22 +151,6 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { return result; } - protected formatGroupForSubmenus(lastGroup: string, currentId?: string, currentLabel?: string): string { - const split = lastGroup.length ? lastGroup.split(menuDelegateSeparator) : []; - // If the submenu is in the 'navigation' group, then it's an item that opens its own context menu, so it should be navigation/id/label... - const expectedParity = split[0] === 'navigation' ? 1 : 0; - if (split.length % 2 !== expectedParity && (currentId || currentLabel)) { - split.push(''); - } - if (currentId || currentLabel) { - split.push(currentId || (currentLabel + '_id')); - } - if (currentLabel) { - split.push(currentLabel); - } - return split.join(menuDelegateSeparator); - } - unregisterItem(itemOrId: TabBarToolbarItem | ReactTabBarToolbarItem | string): void { const id = typeof itemOrId === 'string' ? itemOrId : itemOrId.id; if (this.items.delete(id)) { diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts index c16244f61cdc9..3a4ebc623d87b 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -21,8 +21,6 @@ import { Widget } from '../../widgets'; /** Items whose group is exactly 'navigation' will be rendered inline. */ export const NAVIGATION = 'navigation'; export const TAB_BAR_TOOLBAR_CONTEXT_MENU = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; -export const menuDelegateSeparator = '@=@'; -export const submenuItemPrefix = `navigation${menuDelegateSeparator}`; export interface TabBarDelegator extends Widget { getTabBarDelegate(): Widget | undefined; diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index 57df2cc26907a..b08db36a763df 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -16,13 +16,12 @@ import { inject, injectable } from 'inversify'; import * as React from 'react'; -import { CommandRegistry, Disposable, DisposableCollection, MenuCommandExecutor, MenuModelRegistry, nls } from '../../../common'; +import { CommandRegistry, CompoundMenuNodeRole, Disposable, DisposableCollection, MenuCommandExecutor, MenuModelRegistry, MenuPath, nls } from '../../../common'; import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../../context-menu-renderer'; import { LabelIcon, LabelParser } from '../../label-parser'; import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../../widgets'; import { TabBarToolbarRegistry } from './tab-bar-toolbar-registry'; -// eslint-disable-next-line max-len -import { AnyToolbarItem, menuDelegateSeparator, ReactTabBarToolbarItem, submenuItemPrefix, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types'; +import { AnyToolbarItem, ReactTabBarToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types'; /** * Factory for instantiating tab-bar toolbars. @@ -41,7 +40,6 @@ export class TabBarToolbar extends ReactWidget { protected current: Widget | undefined; protected inline = new Map(); protected more = new Map(); - protected submenuItems = new Map>(); @inject(CommandRegistry) protected readonly commands: CommandRegistry; @inject(LabelParser) protected readonly labelParser: LabelParser; @@ -59,16 +57,11 @@ export class TabBarToolbar extends ReactWidget { updateItems(items: Array, current: Widget | undefined): void { this.inline.clear(); this.more.clear(); - this.submenuItems.clear(); for (const item of items.sort(TabBarToolbarItem.PRIORITY_COMPARATOR).reverse()) { if ('render' in item || item.group === undefined || item.group === 'navigation') { this.inline.set(item.id, item); } else { - if (item.group?.startsWith(submenuItemPrefix)) { - this.addSubmenuItem(item); - } else { - this.more.set(item.id, item); - } + this.more.set(item.id, item); } } this.setCurrent(current); @@ -83,21 +76,6 @@ export class TabBarToolbar extends ReactWidget { this.update(); } - protected addSubmenuItem(item: TabBarToolbarItem): void { - if (item.group) { - let doSet = false; - const secondElementEndIndex = item.group.indexOf(menuDelegateSeparator, submenuItemPrefix.length); - const prefix = secondElementEndIndex === -1 - ? item.group.substring(submenuItemPrefix.length) - : item.group.substring(submenuItemPrefix.length, secondElementEndIndex); - const prefixItems = this.submenuItems.get(prefix) ?? (doSet = true, new Map()); - prefixItems.set(item.id, item); - if (doSet) { - this.submenuItems.set(prefix, prefixItems); - } - } - } - updateTarget(current?: Widget): void { const operativeWidget = TabBarDelegator.is(current) ? current.getTabBarDelegate() : current; const items = operativeWidget ? this.toolbarRegistry.visibleItems(operativeWidget) : []; @@ -193,34 +171,34 @@ export class TabBarToolbar extends ReactWidget { return itemBox ? { y: itemBox.bottom, x: itemBox.left } : event.nativeEvent; } - renderMoreContextMenu(anchor: Anchor, prefix?: string): ContextMenuAccess { + renderMoreContextMenu(anchor: Anchor, subpath?: MenuPath): ContextMenuAccess { const toDisposeOnHide = new DisposableCollection(); this.addClass('menu-open'); toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open'))); - const items = (prefix ? this.submenuItems.get(prefix) ?? new Map() : this.more); - for (const item of items.values()) { - const separator = item.group && [menuDelegateSeparator, '/'].find(candidate => item.group?.includes(candidate)); - if (prefix && !item.group?.startsWith(`navigation${separator}${prefix}`) || !prefix && item.group?.startsWith(`navigation${separator}`)) { - continue; - } - // Register a submenu for the item, if the group is in format `//.../` - if (separator) { - const split = item.group.split(separator); - const paths: string[] = []; - for (let i = 0; i < split.length - 1; i += 2) { - paths.push(split[i], split[i + 1]); - // TODO order is missing, items sorting will be alphabetic - if (split[i + 1]) { - toDisposeOnHide.push(this.menus.registerSubmenu([...TAB_BAR_TOOLBAR_CONTEXT_MENU, ...paths], split[i + 1])); + if (subpath) { + toDisposeOnHide.push(this.menus.linkSubmenu(TAB_BAR_TOOLBAR_CONTEXT_MENU, subpath[0], { role: CompoundMenuNodeRole.Flat, when: '' })); + } else { + for (const item of this.more.values() as IterableIterator) { + if (item.menuPath && !item.command) { + toDisposeOnHide.push(this.menus.linkSubmenu(TAB_BAR_TOOLBAR_CONTEXT_MENU, item.menuPath[0], { role: CompoundMenuNodeRole.Flat, when: '' }, item.group)); + } else if (item.command) { + // Register a submenu for the item, if the group is in format `//.../` + if (item.group?.includes('/')) { + const split = item.group.split('/'); + const paths: string[] = []; + for (let i = 0; i < split.length - 1; i += 2) { + paths.push(split[i], split[i + 1]); + toDisposeOnHide.push(this.menus.registerSubmenu([...TAB_BAR_TOOLBAR_CONTEXT_MENU, ...paths], split[i + 1], { order: item.order })); + } } + toDisposeOnHide.push(this.menus.registerMenuAction([...TAB_BAR_TOOLBAR_CONTEXT_MENU, ...item.group!.split('/')], { + label: item.tooltip, + commandId: item.command, + when: item.when, + order: item.order, + })); } } - // TODO order is missing, items sorting will be alphabetic - toDisposeOnHide.push(this.menus.registerMenuAction([...TAB_BAR_TOOLBAR_CONTEXT_MENU, ...item.group!.split(separator)], { - label: item.tooltip, - commandId: item.command, - when: item.when - })); } return this.contextMenuRenderer.render({ menuPath: TAB_BAR_TOOLBAR_CONTEXT_MENU, @@ -253,8 +231,7 @@ export class TabBarToolbar extends ReactWidget { } else if (item?.command) { this.commands.executeCommand(item.command, this.current); } else if (item?.menuPath) { - // TODO @CJG - this isn't the final plan! - this.renderMoreContextMenu(this.toAnchor(e), item.menuPath.join('')) + this.renderMoreContextMenu(this.toAnchor(e), item.menuPath); } this.update(); }; diff --git a/packages/core/src/common/menu/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index 77b52431a6f97..6bd2600baca7a 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -172,9 +172,6 @@ export class MenuModelRegistry { throw new Error(`Attempted to link non-existent menu with id ${childId}`); } const parent = this.getMenuNode(parentPath, group); - if (!parent) { - throw new Error(`Attempted to link into a non-existent parent with path ${parentPath}`); - } const wrapper = new CompositeMenuNodeWrapper(child, options); return parent.addNode(wrapper); } From 3ee6fca7c7d64c07f08385283be0e47d1481902c Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 1 Jul 2022 12:07:15 -0600 Subject: [PATCH 16/24] Refactor tabbars for easier menu delegation --- .../src/browser/menu/browser-menu-plugin.ts | 11 +-- .../tab-bar-toolbar-menu-adapters.ts | 31 +++++++++ .../tab-bar-toolbar-registry.ts | 67 +++++++++---------- .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 17 ++--- .../core/src/common/menu/action-menu-node.ts | 2 +- .../src/common/menu/composite-menu-node.ts | 5 +- .../src/common/menu/menu-model-registry.ts | 31 ++++++--- packages/core/src/common/menu/menu-types.ts | 20 ++++++ .../menu/electron-main-menu-factory.ts | 13 +--- .../src/browser/view/debug-toolbar-widget.tsx | 4 +- .../menus/menus-contribution-handler.ts | 2 +- .../menus/vscode-theia-menu-mappings.ts | 3 + 12 files changed, 128 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 82d3d50403d4d..1cbbbeba7d6be 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -295,16 +295,7 @@ export class DynamicMenuWidget extends MenuWidget { const submenu = this.services.menuWidgetFactory.createMenuWidget(menu, this.options); parentItems.push({ type: 'submenu', submenu }); } else if (role === CompoundMenuNodeRole.Group) { - const childrenToMerge: ReadonlyArray[] = []; - const children = menu.children.filter(child => { - if (CompoundMenuNode.getRole(child) === CompoundMenuNodeRole.Flat) { - if (this.undefinedOrMatch(child.when, this.options.context)) { - childrenToMerge.push((child as CompoundMenuNode).children); - } - return false; - } - return true; - }).concat(...childrenToMerge).sort(CompoundMenuNode.sortChildren); + const children = CompoundMenuNode.getFlatChildren(menu.children); const myItems: MenuWidget.IItemOptions[] = []; children.forEach(child => this.buildSubMenus(myItems, child, commands)); if (parentItems.length && myItems.length && parentItems[parentItems.length - 1].type !== 'separator') { diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts new file mode 100644 index 0000000000000..3cee2c14fc328 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts @@ -0,0 +1,31 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { MenuNode, MenuPath } from '../../../common'; +import { NAVIGATION, TabBarToolbarItem } from './tab-bar-toolbar-types'; + +export const TOOLBAR_WRAPPER_ID_SUFFIX = '-as-tabbar-toolbar-item'; + +export class ToolbarMenuNodeWrapper implements TabBarToolbarItem { + constructor(protected readonly menuNode: MenuNode, readonly group?: string, readonly menuPath?: MenuPath) { } + get id(): string { return this.menuNode.id + TOOLBAR_WRAPPER_ID_SUFFIX; } + get command(): string { return this.menuNode.command ?? ''; }; + get icon(): string | undefined { return this.menuNode.icon; } + get tooltip(): string | undefined { return this.menuNode.label; } + get when(): string | undefined { return this.menuNode.when; } + get text(): string | undefined { return (!this.group || this.group === NAVIGATION) ? undefined : this.menuNode.label; } +} + diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index a9f9b2d71e201..b54aa7bfb6e89 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -16,11 +16,13 @@ import debounce = require('lodash.debounce'); import { inject, injectable, named } from 'inversify'; -import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuNode, MenuPath } from '../../../common'; +// eslint-disable-next-line max-len +import { CommandMenuNode, CommandRegistry, CompoundMenuNode, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuPath } from '../../../common'; import { ContextKeyService } from '../../context-key-service'; import { FrontendApplicationContribution } from '../../frontend-application'; import { Widget } from '../../widgets'; -import { MenuDelegate, MenuToolbarItem, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { MenuDelegate, NAVIGATION, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { ToolbarMenuNodeWrapper } from './tab-bar-toolbar-menu-adapters'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. @@ -99,6 +101,8 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { if (widget.isDisposed) { return []; } + const doLog = widget.id === 'plugin-view:npm'; + const log = (...stuff: unknown[]) => { if (doLog) { console.log(...stuff); } }; const result: Array = []; for (const item of this.items.values()) { const visible = TabBarToolbarItem.is(item) @@ -108,46 +112,39 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { result.push(item); } } + log('SENTINEL FOR THE RESULT AFTER PURE TOOLBAR ITEMS:', widget.id, result.slice()); for (const delegate of this.menuDelegates.values()) { + log('SENTINEL FOR CHECKING A DELEGATE', delegate.menuPath); if (delegate.isVisible(widget)) { + log('SENTINEL FOR A DELEGATE PASSING', delegate.menuPath); const menu = this.menuRegistry.getMenu(delegate.menuPath); - const menuToTabbarItems = (item: MenuNode, group = '') => { - if (Array.isArray(item.children) && (!item.when || this.contextKeyService.match(item.when, widget.node))) { - const nextGroup = item === menu - ? group - : this.formatGroupForSubmenus(group, item.id, item.label); - if (group === 'navigation') { - const asSubmenuItem: TabBarToolbarItem & MenuToolbarItem = { - id: `submenu_as_toolbar_item_${item.id}`, - command: '_never_', - menuPath: delegate.menuPath, - when: item.when, - icon: item.icon, - group, - }; - if (!asSubmenuItem.when || this.contextKeyService.match(asSubmenuItem.when, widget.node)) { - result.push(asSubmenuItem); + const children = CompoundMenuNode.getFlatChildren(menu.children); + log('SENTINEL FOR THE MENU AND THE CHILDREN', menu, children); + for (const child of children) { + if (!child.when || this.contextKeyService.match(child.when, widget.node)) { + log('SENTINEL FOR THIS CHILD PASSING', child); + if (child.children) { + for (const grandchild of child.children) { + if (!grandchild.when || this.contextKeyService.match(grandchild.when, widget.node)) { + log('SENTINEL FOR THIS GRANDCHILD PASSING', grandchild); + if (CommandMenuNode.is(grandchild)) { + result.push(new ToolbarMenuNodeWrapper(grandchild, child.id, delegate.menuPath)); + } else if (CompoundMenuNode.is(grandchild)) { + let menuPath; + if (menuPath = this.menuRegistry.getPath(grandchild)) { + result.push(new ToolbarMenuNodeWrapper(grandchild, child.id, menuPath)); + } + } + } else { log('SENTINEL FOR THIS GRANDCHILD FAILING', grandchild); } } + } else if (child.command) { + result.push(new ToolbarMenuNodeWrapper(child, NAVIGATION, delegate.menuPath)); } - item.children.forEach(child => menuToTabbarItems(child, nextGroup)); - } else if (!Array.isArray(item.children)) { - const asToolbarItem: TabBarToolbarItem & MenuToolbarItem = { - id: `menu_as_toolbar_item_${item.id}`, - command: item.id, - when: item.when, - icon: item.icon, - tooltip: item.label ?? item.id, - menuPath: delegate.menuPath, - group, - }; - if (!asToolbarItem.when || this.contextKeyService.match(asToolbarItem.when, widget.node)) { - result.push(asToolbarItem); - } - } - }; - menuToTabbarItems(menu); + } else { log('SENTINEL FOR THIS CHILD FAILING:', child); } + } } } + log('SENTINEL FOR THE RESULT AFTER MENU ITEMS:', widget.id, result.slice()); return result; } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index b08db36a763df..2c16235bf7a0d 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -106,7 +106,7 @@ export class TabBarToolbar extends ReactWidget { ; } - protected renderItem(item: TabBarToolbarItem): React.ReactNode { + protected renderItem(item: AnyToolbarItem): React.ReactNode { let innerText = ''; const classNames = []; if (item.text) { @@ -119,7 +119,7 @@ export class TabBarToolbar extends ReactWidget { } } } - const command = this.commands.getCommand(item.command); + const command = item.command ? this.commands.getCommand(item.command) : undefined; let iconClass = (typeof item.icon === 'function' && item.icon()) || item.icon as string || (command && command.iconClass); if (iconClass) { iconClass += ` ${ACTION_ITEM}`; @@ -127,8 +127,9 @@ export class TabBarToolbar extends ReactWidget { } const tooltip = item.tooltip || (command && command.label); const toolbarItemClassNames = this.getToolbarItemClassNames(command?.id ?? item.command); + if (item.menuPath && !item.command) { toolbarItemClassNames.push('enabled'); } return
@@ -139,17 +140,17 @@ export class TabBarToolbar extends ReactWidget {
; } - protected getToolbarItemClassNames(commandId: string | undefined): string { + protected getToolbarItemClassNames(commandId: string | undefined): string[] { const classNames = [TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM]; if (commandId) { - if (commandId === '_never_' || this.commandIsEnabled(commandId)) { + if (this.commandIsEnabled(commandId)) { classNames.push('enabled'); } if (this.commandIsToggled(commandId)) { classNames.push('toggled'); } } - return classNames.join(' '); + return classNames; } protected renderMore(): React.ReactNode { @@ -176,11 +177,11 @@ export class TabBarToolbar extends ReactWidget { this.addClass('menu-open'); toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open'))); if (subpath) { - toDisposeOnHide.push(this.menus.linkSubmenu(TAB_BAR_TOOLBAR_CONTEXT_MENU, subpath[0], { role: CompoundMenuNodeRole.Flat, when: '' })); + toDisposeOnHide.push(this.menus.linkSubmenu(TAB_BAR_TOOLBAR_CONTEXT_MENU, subpath, { role: CompoundMenuNodeRole.Flat, when: '' })); } else { for (const item of this.more.values() as IterableIterator) { if (item.menuPath && !item.command) { - toDisposeOnHide.push(this.menus.linkSubmenu(TAB_BAR_TOOLBAR_CONTEXT_MENU, item.menuPath[0], { role: CompoundMenuNodeRole.Flat, when: '' }, item.group)); + toDisposeOnHide.push(this.menus.linkSubmenu(TAB_BAR_TOOLBAR_CONTEXT_MENU, item.menuPath, { role: CompoundMenuNodeRole.Flat, when: '' }, item.group)); } else if (item.command) { // Register a submenu for the item, if the group is in format `//.../` if (item.group?.includes('/')) { diff --git a/packages/core/src/common/menu/action-menu-node.ts b/packages/core/src/common/menu/action-menu-node.ts index b8ca94c764da7..e51fad0a71d97 100644 --- a/packages/core/src/common/menu/action-menu-node.ts +++ b/packages/core/src/common/menu/action-menu-node.ts @@ -28,7 +28,7 @@ export class ActionMenuNode implements MenuNode, CommandMenuNode, Partial, protected readonly options?: SubMenuOptions) { } + constructor(protected readonly wrapped: Readonly, readonly parent: MenuNode & CompoundMenuNode, protected readonly options?: SubMenuOptions) { } get id(): string { return this.wrapped.id; } diff --git a/packages/core/src/common/menu/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index 6bd2600baca7a..89b12e2544443 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -138,7 +138,7 @@ export class MenuModelRegistry { const parent = this.findGroup(groupPath, options); let groupNode = this.findSubMenu(parent, menuId, options); if (!groupNode) { - groupNode = new CompositeMenuNode(menuId, label, options); + groupNode = new CompositeMenuNode(menuId, label, options, parent); return parent.addNode(groupNode); } else { if (!groupNode.label) { @@ -166,13 +166,10 @@ export class MenuModelRegistry { return { dispose: () => this.independentSubmenus.delete(id) }; } - linkSubmenu(parentPath: MenuPath | string, childId: string, options?: SubMenuOptions, group?: string): Disposable { - const child = this.independentSubmenus.get(childId); - if (!child) { - throw new Error(`Attempted to link non-existent menu with id ${childId}`); - } + linkSubmenu(parentPath: MenuPath | string, childId: string | MenuPath, options?: SubMenuOptions, group?: string): Disposable { + const child = this.getMenuNode(childId); const parent = this.getMenuNode(parentPath, group); - const wrapper = new CompositeMenuNodeWrapper(child, options); + const wrapper = new CompositeMenuNodeWrapper(child, parent, options); return parent.addNode(wrapper); } @@ -252,7 +249,7 @@ export class MenuModelRegistry { if (sub) { throw new Error(`'${menuId}' is not a menu group.`); } - const newSub = new CompositeMenuNode(menuId, undefined, options); + const newSub = new CompositeMenuNode(menuId, undefined, options, current); current.addNode(newSub); return newSub; } @@ -268,4 +265,22 @@ export class MenuModelRegistry { getMenu(menuPath: MenuPath = []): CompositeMenuNode { return this.findGroup(menuPath); } + + /** + * Returns the {@link MenuPath path} at which a given menu node can be accessed from this registry, if it can be determined. + * Returns `undefined` if the `parent` of any node in the chain is unknown. + */ + getPath(node: MenuNode): MenuPath | undefined { + const identifiers = []; + let next: MenuNode | undefined = node; + + while (next) { + if (next === this.root) { + return identifiers.reverse(); + } + identifiers.push(next.id); + next = next.parent; + } + return undefined; + } } diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts index a04a8369966d7..9c56a9279628f 100644 --- a/packages/core/src/common/menu/menu-types.ts +++ b/packages/core/src/common/menu/menu-types.ts @@ -74,6 +74,10 @@ export interface MenuNodeMetadata { * See https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts */ readonly when?: string; + /** + * A reference to the parent node - useful for determining the menu path by which the node can be accessed. + */ + readonly parent?: MenuNode; } export interface MenuNodeRenderingData { @@ -120,6 +124,18 @@ export namespace CompoundMenuNode { return m1.sortString.localeCompare(m2.sortString); } + /** Collapses the children of any subemenus with role {@link CompoundMenuNodeRole Flat} and sorts */ + export function getFlatChildren(children: ReadonlyArray): MenuNode[] { + const childrenToMerge: ReadonlyArray[] = []; + return children.filter(child => { + if (getRole(child) === CompoundMenuNodeRole.Flat) { + childrenToMerge.push((child as CompoundMenuNode).children); + return false; + } + return true; + }).concat(...childrenToMerge).sort(sortChildren); + } + /** * Indicates whether the given node is the special `navigation` menu. * @@ -148,6 +164,10 @@ export interface CommandMenuNode { command: string; } +export namespace CommandMenuNode { + export function is(candidate: MenuNode): candidate is MenuNode & CommandMenuNode { return Boolean(candidate.command); } +} + export interface AlternativeHandlerMenuNode { altNode: MenuNodeMetadata & MenuNodeRenderingData & CommandMenuNode; } 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 37dc24147e2bf..74ab58335a6cb 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 @@ -139,18 +139,7 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { if (CompoundMenuNode.is(menu) && menu.children.length && this.undefinedOrMatch(menu.when, options.context)) { const role = CompoundMenuNode.getRole(menu); if (role === CompoundMenuNodeRole.Group && menu.id === 'inline') { return parentItems; } - - const childrenToMerge: ReadonlyArray[] = []; - const children = menu.children.filter(child => { - if (CompoundMenuNode.getRole(child) === CompoundMenuNodeRole.Flat) { - if (this.undefinedOrMatch(child.when, options.context)) { - childrenToMerge.push((child as CompoundMenuNode).children); - } - return false; - } - return true; - }).concat(...childrenToMerge).sort(CompoundMenuNode.sortChildren); - + const children = CompoundMenuNode.getFlatChildren(menu.children); const myItems: Electron.MenuItemConstructorOptions[] = []; children.forEach(child => this.fillMenuTemplate(myItems, child, args, options)); if (myItems.length === 0) { return parentItems; } diff --git a/packages/debug/src/browser/view/debug-toolbar-widget.tsx b/packages/debug/src/browser/view/debug-toolbar-widget.tsx index 9a4d57d377b63..9cfe951a23dc0 100644 --- a/packages/debug/src/browser/view/debug-toolbar-widget.tsx +++ b/packages/debug/src/browser/view/debug-toolbar-widget.tsx @@ -16,7 +16,7 @@ import * as React from '@theia/core/shared/react'; import { inject, postConstruct, injectable } from '@theia/core/shared/inversify'; -import { Disposable } from '@theia/core'; +import { Disposable, MenuPath } from '@theia/core'; import { ReactWidget } from '@theia/core/lib/browser/widgets'; import { DebugViewModel } from './debug-view-model'; import { DebugState } from '../debug-session'; @@ -26,6 +26,8 @@ import { nls } from '@theia/core/lib/common/nls'; @injectable() export class DebugToolBar extends ReactWidget { + static readonly MENU: MenuPath = ['debug-toolbar-menu']; + @inject(DebugViewModel) protected readonly model: DebugViewModel; diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index f803414aade2e..e57e647a3abea 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -113,7 +113,7 @@ export class MenusContributionPointHandler { } } } catch (error) { - console.warn(`Failed to register a menu item for plugin ${plugin.metadata.model.id} contributed to ${contributionPoint}`, item); + console.warn(`Failed to register a menu item for plugin ${plugin.metadata.model.id} contributed to ${contributionPoint}`, item, error); } } } diff --git a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts index e1c4aa4426342..9498b4b7d0d5d 100644 --- a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts +++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts @@ -21,6 +21,7 @@ import { injectable } from '@theia/core/shared/inversify'; import { URI as CodeUri } from '@theia/core/shared/vscode-uri'; import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stack-frames-widget'; import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; +import { DebugToolBar } from '@theia/debug/lib/browser/view/debug-toolbar-widget'; import { EditorWidget, EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser'; import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; @@ -38,6 +39,7 @@ export const implementedVSCodeContributionPoints = [ 'comments/comment/title', 'comments/commentThread/context', 'debug/callstack/context', + 'debug/toolBar', // Unrendered, but registered 'editor/context', 'editor/title', 'editor/title/context', @@ -59,6 +61,7 @@ export const codeToTheiaMappings = new Map Date: Fri, 1 Jul 2022 16:04:56 -0600 Subject: [PATCH 17/24] Cleanup --- .../src/browser/menu/browser-menu-plugin.ts | 10 +++++----- .../tab-bar-toolbar-menu-adapters.ts | 2 +- .../tab-bar-toolbar/tab-bar-toolbar-registry.ts | 17 ++++------------- .../menu/electron-main-menu-factory.ts | 2 +- .../menus/plugin-menu-command-adapter.ts | 1 + .../browser/menus/vscode-theia-menu-mappings.ts | 3 --- 6 files changed, 12 insertions(+), 23 deletions(-) diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 1cbbbeba7d6be..d527b67816fb0 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -294,15 +294,15 @@ export class DynamicMenuWidget extends MenuWidget { if (role === CompoundMenuNodeRole.Submenu) { const submenu = this.services.menuWidgetFactory.createMenuWidget(menu, this.options); parentItems.push({ type: 'submenu', submenu }); - } else if (role === CompoundMenuNodeRole.Group) { + } else if (role === CompoundMenuNodeRole.Group && menu.id !== 'inline') { const children = CompoundMenuNode.getFlatChildren(menu.children); const myItems: MenuWidget.IItemOptions[] = []; children.forEach(child => this.buildSubMenus(myItems, child, commands)); - if (parentItems.length && myItems.length && parentItems[parentItems.length - 1].type !== 'separator') { - parentItems.push({ type: 'separator' }); - } - parentItems.push(...myItems); if (myItems.length) { + if (parentItems.length && parentItems[parentItems.length - 1].type !== 'separator') { + parentItems.push({ type: 'separator' }); + } + parentItems.push(...myItems); parentItems.push({ type: 'separator' }); } } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts index 3cee2c14fc328..cf62e95b264c4 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts @@ -26,6 +26,6 @@ export class ToolbarMenuNodeWrapper implements TabBarToolbarItem { get icon(): string | undefined { return this.menuNode.icon; } get tooltip(): string | undefined { return this.menuNode.label; } get when(): string | undefined { return this.menuNode.when; } - get text(): string | undefined { return (!this.group || this.group === NAVIGATION) ? undefined : this.menuNode.label; } + get text(): string | undefined { return (this.group === NAVIGATION || this.group === undefined) ? undefined : this.menuNode.label; } } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index b54aa7bfb6e89..0b1322d9f0266 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -21,7 +21,7 @@ import { CommandMenuNode, CommandRegistry, CompoundMenuNode, ContributionProvide import { ContextKeyService } from '../../context-key-service'; import { FrontendApplicationContribution } from '../../frontend-application'; import { Widget } from '../../widgets'; -import { MenuDelegate, NAVIGATION, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { MenuDelegate, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; import { ToolbarMenuNodeWrapper } from './tab-bar-toolbar-menu-adapters'; /** @@ -101,8 +101,6 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { if (widget.isDisposed) { return []; } - const doLog = widget.id === 'plugin-view:npm'; - const log = (...stuff: unknown[]) => { if (doLog) { console.log(...stuff); } }; const result: Array = []; for (const item of this.items.values()) { const visible = TabBarToolbarItem.is(item) @@ -112,21 +110,15 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { result.push(item); } } - log('SENTINEL FOR THE RESULT AFTER PURE TOOLBAR ITEMS:', widget.id, result.slice()); for (const delegate of this.menuDelegates.values()) { - log('SENTINEL FOR CHECKING A DELEGATE', delegate.menuPath); if (delegate.isVisible(widget)) { - log('SENTINEL FOR A DELEGATE PASSING', delegate.menuPath); const menu = this.menuRegistry.getMenu(delegate.menuPath); const children = CompoundMenuNode.getFlatChildren(menu.children); - log('SENTINEL FOR THE MENU AND THE CHILDREN', menu, children); for (const child of children) { if (!child.when || this.contextKeyService.match(child.when, widget.node)) { - log('SENTINEL FOR THIS CHILD PASSING', child); if (child.children) { for (const grandchild of child.children) { if (!grandchild.when || this.contextKeyService.match(grandchild.when, widget.node)) { - log('SENTINEL FOR THIS GRANDCHILD PASSING', grandchild); if (CommandMenuNode.is(grandchild)) { result.push(new ToolbarMenuNodeWrapper(grandchild, child.id, delegate.menuPath)); } else if (CompoundMenuNode.is(grandchild)) { @@ -135,16 +127,15 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { result.push(new ToolbarMenuNodeWrapper(grandchild, child.id, menuPath)); } } - } else { log('SENTINEL FOR THIS GRANDCHILD FAILING', grandchild); } + } } } else if (child.command) { - result.push(new ToolbarMenuNodeWrapper(child, NAVIGATION, delegate.menuPath)); + result.push(new ToolbarMenuNodeWrapper(child, '', delegate.menuPath)); } - } else { log('SENTINEL FOR THIS CHILD FAILING:', child); } + } } } } - log('SENTINEL FOR THE RESULT AFTER MENU ITEMS:', widget.id, result.slice()); return result; } 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 74ab58335a6cb..e47cb62e0d7b4 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 @@ -145,7 +145,7 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { if (myItems.length === 0) { return parentItems; } if (role === CompoundMenuNodeRole.Submenu) { parentItems.push({ label: menu.label, submenu: myItems }); - } else if (role === CompoundMenuNodeRole.Group) { + } else if (role === CompoundMenuNodeRole.Group && menu.id !== 'inline') { if (parentItems.length && parentItems[parentItems.length - 1].type !== 'separator') { parentItems.push({ type: 'separator' }); } diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts index 27463c722ff96..6346741134d88 100644 --- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -89,6 +89,7 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { ['comments/comment/title', toCommentArgs], ['comments/commentThread/context', toCommentArgs], ['debug/callstack/context', firstArgOnly], + ['debug/toolBar', noArgs], ['editor/context', selectedResource], ['editor/title', widgetURI], ['editor/title/context', selectedResource], diff --git a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts index 9498b4b7d0d5d..e1c4aa4426342 100644 --- a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts +++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts @@ -21,7 +21,6 @@ import { injectable } from '@theia/core/shared/inversify'; import { URI as CodeUri } from '@theia/core/shared/vscode-uri'; import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stack-frames-widget'; import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; -import { DebugToolBar } from '@theia/debug/lib/browser/view/debug-toolbar-widget'; import { EditorWidget, EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser'; import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; @@ -39,7 +38,6 @@ export const implementedVSCodeContributionPoints = [ 'comments/comment/title', 'comments/commentThread/context', 'debug/callstack/context', - 'debug/toolBar', // Unrendered, but registered 'editor/context', 'editor/title', 'editor/title/context', @@ -61,7 +59,6 @@ export const codeToTheiaMappings = new Map Date: Fri, 1 Jul 2022 16:44:10 -0600 Subject: [PATCH 18/24] Fix submenu handling upon update --- packages/core/src/common/menu/composite-menu-node.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/common/menu/composite-menu-node.ts b/packages/core/src/common/menu/composite-menu-node.ts index 490fd8f3f3d36..5677b7178aa0e 100644 --- a/packages/core/src/common/menu/composite-menu-node.ts +++ b/packages/core/src/common/menu/composite-menu-node.ts @@ -25,7 +25,7 @@ export class CompositeMenuNode implements MenuNode, CompoundMenuNode, CompoundMe public iconClass?: string; public order?: string; readonly when?: string; - readonly role: CompoundMenuNodeRole; + readonly _role?: CompoundMenuNodeRole; constructor( public readonly id: string, @@ -37,8 +37,8 @@ export class CompositeMenuNode implements MenuNode, CompoundMenuNode, CompoundMe this.iconClass = options.iconClass; this.order = options.order; this.when = options.when; + this._role = options?.role; } - this.role = options?.role ?? CompoundMenuNode.getRole(this)!; } get icon(): string | undefined { @@ -49,6 +49,8 @@ export class CompositeMenuNode implements MenuNode, CompoundMenuNode, CompoundMe return this._children; } + get role(): CompoundMenuNodeRole { return this._role ?? (this.label ? CompoundMenuNodeRole.Submenu : CompoundMenuNodeRole.Group); } + /** * Inserts the given node at the position indicated by `sortString`. * From cfa3d96cadf4d0e65bbf3c93d062352dd3eabfda Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 1 Jul 2022 17:10:38 -0600 Subject: [PATCH 19/24] Refactor register menus to be truly recursive --- .../browser/menu/sample-browser-menu-module.ts | 7 +++---- .../src/browser/menu/browser-menu-plugin.ts | 17 ++++++++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts index eed404f2251e3..54ac6869c6404 100644 --- a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts +++ b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts @@ -17,7 +17,7 @@ import { injectable, ContainerModule } from '@theia/core/shared/inversify'; import { Menu as MenuWidget } from '@theia/core/shared/@phosphor/widgets'; import { Disposable } from '@theia/core/lib/common/disposable'; -import { MenuNode, CompositeMenuNode, MenuPath, CompoundMenuNode } from '@theia/core/lib/common/menu'; +import { MenuNode, CompositeMenuNode, MenuPath } from '@theia/core/lib/common/menu'; import { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget, BrowserMenuOptions } from '@theia/core/lib/browser/menu/browser-menu-plugin'; import { PlaceholderMenuNode } from './sample-menu-contribution'; @@ -28,7 +28,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { @injectable() class SampleBrowserMainMenuFactory extends BrowserMainMenuFactory { - protected override registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode & CompoundMenuNode, args: unknown[]): void { + protected override registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode, args: unknown[]): void { if (menu instanceof PlaceholderMenuNode && menuCommandRegistry instanceof SampleMenuCommandRegistry) { menuCommandRegistry.registerPlaceholderMenu(menu); } else { @@ -71,14 +71,13 @@ class SampleMenuCommandRegistry extends MenuCommandRegistry { protected registerPlaceholder(menu: PlaceholderMenuNode): Disposable { const { id } = menu; - const unregisterCommand = this.addCommand(id, { + return this.addCommand(id, { execute: () => { /* NOOP */ }, label: menu.label, icon: menu.icon, isEnabled: () => false, isVisible: () => true }); - return Disposable.create(() => unregisterCommand.dispose()); } } diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index d527b67816fb0..f550e46267de4 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -124,16 +124,15 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { return menuCommandRegistry; } - protected registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode & CompoundMenuNode, args: unknown[]): void { - for (const child of menu.children) { - if (child.command) { - menuCommandRegistry.registerActionMenu(child as MenuNode & CommandMenuNode, args); - if (child.altNode) { - menuCommandRegistry.registerActionMenu(child.altNode, args); - } - } else if (CompoundMenuNode.is(child)) { - this.registerMenu(menuCommandRegistry, child, args); + protected registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode, args: unknown[]): void { + if (CompoundMenuNode.is(menu)) { + menu.children.forEach(child => this.registerMenu(menuCommandRegistry, child, args)); + } else if (CommandMenuNode.is(menu)) { + menuCommandRegistry.registerActionMenu(menu, args); + if (menu.altNode) { + menuCommandRegistry.registerActionMenu(menu.altNode, args); } + } } From b544e6ae989fd83e6c12a247b3dceb5fb27ae1af Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 1 Jul 2022 17:25:46 -0600 Subject: [PATCH 20/24] MS minor comments --- packages/core/src/common/logger.ts | 20 +++++++++---------- .../menus/plugin-menu-command-adapter.ts | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/src/common/logger.ts b/packages/core/src/common/logger.ts index 3099fd5b53573..77bce3e717de9 100644 --- a/packages/core/src/common/logger.ts +++ b/packages/core/src/common/logger.ts @@ -40,16 +40,16 @@ export function unsetRootLogger(): void { } export function setRootLogger(aLogger: ILogger): void { - // logger = aLogger; - // const log = (logLevel: number, message?: any, ...optionalParams: any[]) => - // logger.log(logLevel, message, ...optionalParams); - - // console.error = log.bind(undefined, LogLevel.ERROR); - // console.warn = log.bind(undefined, LogLevel.WARN); - // console.info = log.bind(undefined, LogLevel.INFO); - // console.debug = log.bind(undefined, LogLevel.DEBUG); - // console.trace = log.bind(undefined, LogLevel.TRACE); - // console.log = log.bind(undefined, LogLevel.INFO); + logger = aLogger; + const log = (logLevel: number, message?: any, ...optionalParams: any[]) => + logger.log(logLevel, message, ...optionalParams); + + console.error = log.bind(undefined, LogLevel.ERROR); + console.warn = log.bind(undefined, LogLevel.WARN); + console.info = log.bind(undefined, LogLevel.INFO); + console.debug = log.bind(undefined, LogLevel.DEBUG); + console.trace = log.bind(undefined, LogLevel.TRACE); + console.log = log.bind(undefined, LogLevel.INFO); } export type Log = (message: any, ...params: any[]) => void; diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts index 6346741134d88..b2536cd1264be 100644 --- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -223,7 +223,7 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { const arg = args[0]; timelineArgs.push(this.toTimelineArg(arg)); timelineArgs.push(CodeUri.parse(arg.uri)); - timelineArgs.push('source' in arg ? arg.source : ''); + timelineArgs.push(arg.source ?? ''); return timelineArgs; } From f5b6e99bc8c40d89ebf5cd1fe645c5084270d154 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 5 Jul 2022 09:06:30 -0600 Subject: [PATCH 21/24] No infinite loop for path lookups --- packages/core/src/common/menu/menu-model-registry.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/common/menu/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index 89b12e2544443..1a3248c6e35cf 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -272,12 +272,14 @@ export class MenuModelRegistry { */ getPath(node: MenuNode): MenuPath | undefined { const identifiers = []; + const visited: MenuNode[] = []; let next: MenuNode | undefined = node; - while (next) { + while (next && !visited.includes(next)) { if (next === this.root) { return identifiers.reverse(); } + visited.push(next); identifiers.push(next.id); next = next.parent; } From 4f084aeb8ded4b9f1bc0cf54fa506edf5f3e3d8e Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Mon, 11 Jul 2022 14:22:33 -0600 Subject: [PATCH 22/24] Update Playwright tests --- .../src/tests/theia-main-menu.test.ts | 2 +- examples/playwright/src/theia-menu-item.ts | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/examples/playwright/src/tests/theia-main-menu.test.ts b/examples/playwright/src/tests/theia-main-menu.test.ts index 2b137448a3337..6c341e63e8563 100644 --- a/examples/playwright/src/tests/theia-main-menu.test.ts +++ b/examples/playwright/src/tests/theia-main-menu.test.ts @@ -57,7 +57,7 @@ test.describe('Theia Main Menu', () => { expect(label).toBe('New File'); const shortCut = await menuItem?.shortCut(); - expect(shortCut).toBe(OSUtil.isMacOS ? 'N' : 'Alt+N'); + expect(shortCut).toBe(OSUtil.isMacOS ? '⌥ N' : 'Alt+N'); const hasSubmenu = await menuItem?.hasSubmenu(); expect(hasSubmenu).toBe(false); diff --git a/examples/playwright/src/theia-menu-item.ts b/examples/playwright/src/theia-menu-item.ts index d9e9b579572c3..9e1f1eea1e1b0 100644 --- a/examples/playwright/src/theia-menu-item.ts +++ b/examples/playwright/src/theia-menu-item.ts @@ -16,7 +16,7 @@ import { ElementHandle } from '@playwright/test'; -import { textContent } from './util'; +import { elementContainsClass, textContent } from './util'; export class TheiaMenuItem { @@ -30,15 +30,28 @@ export class TheiaMenuItem { return this.element.waitForSelector('.p-Menu-itemShortcut'); } + protected isHidden(): Promise { + return elementContainsClass(this.element, 'p-mod-collapsed'); + } + async label(): Promise { + if (await this.isHidden()) { + return undefined; + } return textContent(this.labelElementHandle()); } async shortCut(): Promise { + if (await this.isHidden()) { + return undefined; + } return textContent(this.shortCutElementHandle()); } async hasSubmenu(): Promise { + if (await this.isHidden()) { + return false; + } return (await this.element.getAttribute('data-type')) === 'submenu'; } @@ -47,7 +60,7 @@ export class TheiaMenuItem { if (classAttribute === undefined || classAttribute === null) { return false; } - return !classAttribute.includes('p-mod-disabled'); + return !classAttribute.includes('p-mod-disabled') && !classAttribute.includes('p-mod-collapsed'); } async click(): Promise { From 37088c63ba934169877768fcc2bde0b53b354b19 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Mon, 11 Jul 2022 16:15:45 -0600 Subject: [PATCH 23/24] But we also don't want separators at the end --- packages/core/src/browser/menu/browser-menu-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index f550e46267de4..6ebac56e784da 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -277,7 +277,7 @@ export class DynamicMenuWidget extends MenuWidget { protected updateSubMenus(parent: MenuWidget, menu: CompositeMenuNode, commands: MenuCommandRegistry): void { const items = this.buildSubMenus([], menu, commands); - if (items[items.length - 1]?.type === 'separator') { + while (items[items.length - 1]?.type === 'separator') { items.pop(); } for (const item of items) { From f6d2a2d0d6d0b4b31abd3606cf0a3fd8b1746bc9 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 14 Jul 2022 15:07:18 -0600 Subject: [PATCH 24/24] Add breaking changes --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6824caf10ceda..18289c1ffb614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ ## v1.28.0 - Unreleased - [plugin] added support for property `SourceControlInputBox#visible` [#11412](https://github.com/eclipse-theia/theia/pull/11412) - Contributed on behalf of STMicroelectronics +[Breaking Changes:](#breaking_changes_1.28.0) + +- [core] `handleDefault`, `handleElectronDefault` method no longer called in `BrowserMainMenuFactory.registerMenu()`, `DynamicMenuWidget.buildSubMenus()` or `ElectronMainMenuFactory.fillSubmenus()`. Override the respective calling function rather than `handleDefault`. The argument to each of the three methods listed above is now `MenuNode` and not `CompositeMenuNode`, and the methods are truly recursive and called on entire menu tree. `ActionMenuNode.action` removed; access relevant field on `ActionMenuNode.command`, `.when` etc. [#11290](https://github.com/eclipse-theia/theia/pull/11290) +- [plugin-ext] `CodeEditorWidgetUtil` moved to `packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts`. `MenusContributionPointHandler` extensively refactored. See PR description for details. [#11290](https://github.com/eclipse-theia/theia/pull/11290) + ## v1.27.0 - 6/30/2022 - [core] added better styling for active sidepanel borders [#11330](https://github.com/eclipse-theia/theia/pull/11330)