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) 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..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,8 +17,8 @@ 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 { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget } from '@theia/core/lib/browser/menu/browser-menu-plugin'; +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'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -28,20 +28,21 @@ 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, 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; } - override createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): DynamicMenuWidget { + override createMenuWidget(menu: CompositeMenuNode, options: BrowserMenuOptions): DynamicMenuWidget { return new SampleDynamicMenuWidget(menu, options, this.services); } @@ -60,8 +61,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)); } @@ -70,28 +71,28 @@ 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()); } } 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/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 { 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..6ebac56e784da 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 + 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'; @@ -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 { @@ -47,6 +53,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,50 +101,39 @@ 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: BrowserMenuOptions): 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 { - 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 { - this.handleDefault(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); } - } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected handleDefault(menuCommandRegistry: MenuCommandRegistry, menuNode: MenuNode, args: any[]): void { - // NOOP + } } protected get services(): MenuServices { @@ -144,7 +142,8 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { contextKeyService: this.contextKeyService, commandRegistry: this.commandRegistry, keybindingRegistry: this.keybindingRegistry, - menuWidgetFactory: this + menuWidgetFactory: this, + commandExecutor: this.menuCommandExecutor, }; } @@ -225,10 +224,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: BrowserMenuOptions): MenuWidget; } /** @@ -243,7 +243,7 @@ export class DynamicMenuWidget extends MenuWidget { constructor( protected menu: CompositeMenuNode, - protected options: MenuWidget.IOptions & { commands: MenuCommandRegistry }, + protected options: BrowserMenuOptions, protected services: MenuServices ) { super(options); @@ -260,7 +260,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); }); } @@ -275,59 +275,51 @@ 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); + while (items[items.length - 1]?.type === 'separator') { + items.pop(); + } for (const item of items) { parent.addItem(item); } } - private buildSubMenus(items: MenuWidget.IItemOptions[], menu: CompositeMenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { - for (const item of menu.children) { - if (item instanceof CompositeMenuNode) { - if (item.children.length) { // do not render empty nodes - if (item.isSubmenu) { // submenu node - const submenu = this.services.menuWidgetFactory.createMenuWidget(item, this.options); - if (!submenu.items.length) { - continue; - } - items.push({ - type: 'submenu', - submenu, - }); - } else { // group node - 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' - }); - } - items.push(...submenu); // render children + 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 && menu.id !== 'inline') { + const children = CompoundMenuNode.getFlatChildren(menu.children); + const myItems: MenuWidget.IItemOptions[] = []; + children.forEach(child => this.buildSubMenus(myItems, child, commands)); + if (myItems.length) { + if (parentItems.length && parentItems[parentItems.length - 1].type !== 'separator') { + parentItems.push({ type: 'separator' }); } + parentItems.push(...myItems); + parentItems.push({ type: 'separator' }); } - } else if (item instanceof ActionMenuNode) { - 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; - } - items.push({ - command: node.action.commandId, + } + } 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' }); - } else { - items.push(...this.handleDefault(item)); } } - return items; + return parentItems; } - protected handleDefault(menuNode: MenuNode): MenuWidget.IItemOptions[] { - return []; + protected undefinedOrMatch(expression?: string, context?: HTMLElement): boolean { + if (expression) { return this.services.contextKeyService.match(expression, context); } + return true; } protected preserveFocusedElement(previousFocusedElement: Element | null = document.activeElement): boolean { @@ -418,19 +410,16 @@ 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 { - 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; } @@ -441,18 +430,17 @@ 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; - const command = commandRegistry.getCommand(menu.action.commandId); + protected registerCommand(menu: MenuNode & CommandMenuNode, args: unknown[], menuPath: MenuPath): Disposable { + const { commandRegistry, keybindingRegistry, commandExecutor } = this.services; + const command = commandRegistry.getCommand(menu.command); if (!command) { return Disposable.NULL; } @@ -463,11 +451,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/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/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 deleted file mode 100644 index 59a08c2d45b3a..0000000000000 --- a/packages/core/src/browser/shell/tab-bar-toolbar.tsx +++ /dev/null @@ -1,495 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2018 TypeFox and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 -// ***************************************************************************** - -import 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 { ContextKeyService } from '../context-key-service'; -import { Event, Emitter } from '../../common/event'; -import { ContextMenuRenderer, Anchor } from '../context-menu-renderer'; -import { MenuModelRegistry } from '../../common/menu'; -import { nls } from '../../common/nls'; - -/** - * 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; - }; -} - -/** - * 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; - -} - -/** - * 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'; - } - -} - -/** - * 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; - - @inject(ContextKeyService) - protected readonly contextKeyService: ContextKeyService; - - @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 = []; - 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); - } - } - return result; - } - - unregisterItem(itemOrId: TabBarToolbarItem | ReactTabBarToolbarItem | string): void { - const id = typeof itemOrId === 'string' ? itemOrId : itemOrId.id; - if (this.items.delete(id)) { - this.fireOnDidChange(); - } - } - -} - -/** - * Factory for instantiating tab-bar toolbars. - */ -export const TabBarToolbarFactory = Symbol('TabBarToolbarFactory'); -export interface TabBarToolbarFactory { - (): TabBarToolbar; -} - -/** - * Tab-bar toolbar widget representing the active [tab-bar toolbar items](TabBarToolbarItem). - */ -@injectable() -export class TabBarToolbar extends ReactWidget { - - protected current: Widget | undefined; - 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; - - constructor() { - super(); - this.addClass(TabBarToolbar.Styles.TAB_BAR_TOOLBAR); - this.hide(); - } - - updateItems(items: Array, current: Widget | undefined): void { - this.inline.clear(); - this.more.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); - } - } - this.setCurrent(current); - if (!items.length) { - this.hide(); - } - this.onRender.push(Disposable.create(() => { - if (items.length) { - this.show(); - } - })); - this.update(); - } - - updateTarget(current?: Widget): void { - const operativeWidget = TabBarDelegator.is(current) ? current.getTabBarDelegate() : current; - const items = operativeWidget ? this.toolbarRegistry.visibleItems(operativeWidget) : []; - this.updateItems(items, operativeWidget); - } - - protected readonly toDisposeOnSetCurrent = new DisposableCollection(); - protected setCurrent(current: Widget | undefined): void { - this.toDisposeOnSetCurrent.dispose(); - this.toDispose.push(this.toDisposeOnSetCurrent); - this.current = current; - if (current) { - const resetCurrent = () => { - this.setCurrent(undefined); - this.update(); - }; - current.disposed.connect(resetCurrent); - this.toDisposeOnSetCurrent.push(Disposable.create(() => - current.disposed.disconnect(resetCurrent) - )); - } - } - - protected render(): React.ReactNode { - return - {this.renderMore()} - {[...this.inline.values()].map(item => TabBarToolbarItem.is(item) ? this.renderItem(item) : item.render(this.current))} - ; - } - - protected renderItem(item: TabBarToolbarItem): React.ReactNode { - let innerText = ''; - const classNames = []; - if (item.text) { - for (const labelPart of this.labelParser.parse(item.text)) { - if (typeof labelPart !== 'string' && LabelIcon.is(labelPart)) { - const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; - classNames.push(...className.split(' ')); - } else { - innerText = labelPart; - } - } - } - const command = this.commands.getCommand(item.command); - let iconClass = (typeof item.icon === 'function' && item.icon()) || item.icon as string || (command && command.iconClass); - if (iconClass) { - iconClass += ` ${ACTION_ITEM}`; - classNames.push(iconClass); - } - const tooltip = item.tooltip || (command && command.label); - const toolbarItemClassNames = this.getToolbarItemClassNames(command?.id); - return
-
{innerText} -
-
; - } - - protected getToolbarItemClassNames(commandId: string | undefined): string { - const classNames = [TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM]; - if (commandId) { - if (this.commandIsEnabled(commandId)) { - classNames.push('enabled'); - } - if (this.commandIsToggled(commandId)) { - classNames.push('toggled'); - } - } - return classNames.join(' '); - } - - protected renderMore(): React.ReactNode { - return !!this.more.size &&
-
-
; - } - - protected showMoreContextMenu = (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - - this.renderMoreContextMenu(event.nativeEvent); - }; - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - renderMoreContextMenu(anchor: Anchor): any { - 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()) { - // 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]); - // TODO order is missing, items sorting will be alphabetic - toDisposeOnHide.push(this.menus.registerSubmenu([...menuPath, ...paths], split[i + 1])); - } - } - // TODO order is missing, items sorting will be alphabetic - toDisposeOnHide.push(this.menus.registerMenuAction([...menuPath, ...item.group!.split('/')], { - label: item.tooltip, - commandId: item.command, - when: item.when - })); - } - return this.contextMenuRenderer.render({ - menuPath, - args: [this.current], - anchor, - onHide: () => toDisposeOnHide.dispose() - }); - } - - shouldHandleMouseEvent(event: MouseEvent): boolean { - return event.target instanceof Element && this.node.contains(event.target); - } - - protected commandIsEnabled(command: string): boolean { - return this.commands.isEnabled(command, this.current); - } - - protected commandIsToggled(command: string): boolean { - return this.commands.isToggled(command, this.current); - } - - protected executeCommand = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const item = this.inline.get(e.currentTarget.id); - if (TabBarToolbarItem.is(item)) { - this.commands.executeCommand(item.command, this.current); - } - this.update(); - }; - - protected onMouseDownEvent = (e: React.MouseEvent) => { - if (e.button === 0) { - e.currentTarget.classList.add('active'); - } - }; - - protected onMouseUpEvent = (e: React.MouseEvent) => { - e.currentTarget.classList.remove('active'); - }; - -} - -export namespace TabBarToolbar { - - export namespace Styles { - - export const TAB_BAR_TOOLBAR = 'p-TabBar-toolbar'; - export const TAB_BAR_TOOLBAR_ITEM = 'item'; - - } - -} 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..d0969ba049c78 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/index.ts @@ -0,0 +1,19 @@ +// ***************************************************************************** +// 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'; +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-menu-adapters.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts new file mode 100644 index 0000000000000..cf62e95b264c4 --- /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 === 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 new file mode 100644 index 0000000000000..0b1322d9f0266 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -0,0 +1,170 @@ +// ***************************************************************************** +// 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 debounce = require('lodash.debounce'); +import { inject, injectable, named } from 'inversify'; +// 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, 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. + */ +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; } +const menuDelegateSeparator = '=@='; + +/** + * 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.isVisible(widget)) { + const menu = this.menuRegistry.getMenu(delegate.menuPath); + const children = CompoundMenuNode.getFlatChildren(menu.children); + for (const child of children) { + if (!child.when || this.contextKeyService.match(child.when, widget.node)) { + if (child.children) { + for (const grandchild of child.children) { + if (!grandchild.when || this.contextKeyService.match(grandchild.when, widget.node)) { + 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 if (child.command) { + result.push(new ToolbarMenuNodeWrapper(child, '', delegate.menuPath)); + } + } + } + } + } + return result; + } + + 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 isVisible: MenuDelegate['isVisible'] = !when + ? yes + : typeof when === 'function' + ? when + : widget => this.contextKeyService.match(when, widget?.node); + this.menuDelegates.set(id, { menuPath, isVisible }); + 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 new file mode 100644 index 0000000000000..3a4ebc623d87b --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -0,0 +1,186 @@ +// ***************************************************************************** +// 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 * 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 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; + }; +} + +interface RegisteredToolbarItem { + /** + * The unique ID of the toolbar item. + */ + id: string; +} + +interface RenderedToolbarItem { + /** + * Optional icon for the item. + */ + icon?: string | (() => string); + + /** + * Optional text of the item. + * + * 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. + * + * 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`. + */ + text?: string; + + /** + * Optional tooltip for the item. + */ + tooltip?: string; +} + +interface SelfRenderingToolbarItem { + render(widget?: Widget): React.ReactNode; +} + +interface ExecutableToolbarItem { + /** + * The command to execute when the item is selected. + */ + command: string; +} + +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}. + * If no command is present, this menu will be opened. + */ + menuPath: MenuPath; +} + +interface ConditionalToolbarItem { + /** + * https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts + */ + 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. + */ + onDidChange?: Event; +} + +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; +} + +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; +} + +/** + * 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 extends RegisteredToolbarItem, + SelfRenderingToolbarItem, + ConditionalToolbarItem, + Pick, + Pick, 'group'> { } + +export interface AnyToolbarItem extends RegisteredToolbarItem, + Partial, + Partial, + Partial, + Partial, + Partial, + Pick, + Partial { } + +export interface MenuDelegate extends MenuToolbarItem, Required> { } + +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) => { + 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); + }; + + 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 namespace MenuToolbarItem { + export function getMenuPath(item: AnyToolbarItem): MenuPath | undefined { + const asDelegate = item as MenuToolbarItem; + return Array.isArray(asDelegate.menuPath) ? asDelegate.menuPath : undefined; + } +} 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 96% 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..76df1486afda1 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,11 +14,11 @@ // 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'; -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 new file mode 100644 index 0000000000000..2c16235bf7a0d --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -0,0 +1,261 @@ +// ***************************************************************************** +// Copyright (C) 2018 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from 'inversify'; +import * as React from 'react'; +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'; +import { AnyToolbarItem, ReactTabBarToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types'; + +/** + * Factory for instantiating tab-bar toolbars. + */ +export const TabBarToolbarFactory = Symbol('TabBarToolbarFactory'); +export interface TabBarToolbarFactory { + (): TabBarToolbar; +} + +/** + * Tab-bar toolbar widget representing the active [tab-bar toolbar items](TabBarToolbarItem). + */ +@injectable() +export class TabBarToolbar extends ReactWidget { + + protected current: Widget | undefined; + 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(MenuCommandExecutor) protected readonly menuCommandExecutor: MenuCommandExecutor; + @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(TabBarToolbarRegistry) protected readonly toolbarRegistry: TabBarToolbarRegistry; + + constructor() { + super(); + this.addClass(TabBarToolbar.Styles.TAB_BAR_TOOLBAR); + this.hide(); + } + + updateItems(items: Array, current: Widget | undefined): void { + this.inline.clear(); + this.more.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); + } + } + this.setCurrent(current); + if (!items.length) { + this.hide(); + } + this.onRender.push(Disposable.create(() => { + if (items.length) { + this.show(); + } + })); + this.update(); + } + + updateTarget(current?: Widget): void { + const operativeWidget = TabBarDelegator.is(current) ? current.getTabBarDelegate() : current; + const items = operativeWidget ? this.toolbarRegistry.visibleItems(operativeWidget) : []; + this.updateItems(items, operativeWidget); + } + + protected readonly toDisposeOnSetCurrent = new DisposableCollection(); + protected setCurrent(current: Widget | undefined): void { + this.toDisposeOnSetCurrent.dispose(); + this.toDispose.push(this.toDisposeOnSetCurrent); + this.current = current; + if (current) { + const resetCurrent = () => { + this.setCurrent(undefined); + this.update(); + }; + current.disposed.connect(resetCurrent); + this.toDisposeOnSetCurrent.push(Disposable.create(() => + current.disposed.disconnect(resetCurrent) + )); + } + } + + protected render(): React.ReactNode { + return + {this.renderMore()} + {[...this.inline.values()].map(item => TabBarToolbarItem.is(item) ? this.renderItem(item) : item.render(this.current))} + ; + } + + protected renderItem(item: AnyToolbarItem): React.ReactNode { + let innerText = ''; + const classNames = []; + if (item.text) { + for (const labelPart of this.labelParser.parse(item.text)) { + if (typeof labelPart !== 'string' && LabelIcon.is(labelPart)) { + const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; + classNames.push(...className.split(' ')); + } else { + innerText = labelPart; + } + } + } + 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}`; + classNames.push(iconClass); + } + const tooltip = item.tooltip || (command && command.label); + const toolbarItemClassNames = this.getToolbarItemClassNames(command?.id ?? item.command); + if (item.menuPath && !item.command) { toolbarItemClassNames.push('enabled'); } + return
+
{innerText} +
+
; + } + + protected getToolbarItemClassNames(commandId: string | undefined): string[] { + const classNames = [TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM]; + if (commandId) { + if (this.commandIsEnabled(commandId)) { + classNames.push('enabled'); + } + if (this.commandIsToggled(commandId)) { + classNames.push('toggled'); + } + } + return classNames; + } + + protected renderMore(): React.ReactNode { + return !!this.more.size &&
+
+
; + } + + protected showMoreContextMenu = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + 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, subpath?: MenuPath): ContextMenuAccess { + const toDisposeOnHide = new DisposableCollection(); + 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, { 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, { 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, + })); + } + } + } + return this.contextMenuRenderer.render({ + menuPath: TAB_BAR_TOOLBAR_CONTEXT_MENU, + args: [this.current], + anchor, + context: this.current?.node, + onHide: () => toDisposeOnHide.dispose() + }); + } + + shouldHandleMouseEvent(event: MouseEvent): boolean { + return event.target instanceof Element && this.node.contains(event.target); + } + + protected commandIsEnabled(command: string): boolean { + return this.commands.isEnabled(command, this.current); + } + + protected commandIsToggled(command: string): boolean { + return this.commands.isToggled(command, this.current); + } + + protected executeCommand = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + 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) { + this.renderMoreContextMenu(this.toAnchor(e), item.menuPath); + } + this.update(); + }; + + protected onMouseDownEvent = (e: React.MouseEvent) => { + if (e.button === 0) { + e.currentTarget.classList.add('active'); + } + }; + + protected onMouseUpEvent = (e: React.MouseEvent) => { + e.currentTarget.classList.remove('active'); + }; + +} + +export namespace TabBarToolbar { + + export namespace Styles { + + export const TAB_BAR_TOOLBAR = 'p-TabBar-toolbar'; + export const TAB_BAR_TOOLBAR_ITEM = 'item'; + + } + +} 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..e51fad0a71d97 --- /dev/null +++ b/packages/core/src/common/menu/action-menu-node.ts @@ -0,0 +1,65 @@ +// ***************************************************************************** +// 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 { 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, CommandMenuNode, Partial { + + readonly altNode: ActionMenuNode | undefined; + + constructor( + protected readonly action: MenuAction, + protected readonly commands: CommandRegistry, + ) { + if (action.alt) { + this.altNode = new ActionMenuNode({ commandId: action.alt }, commands); + } + } + + get command(): 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) { + 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..5677b7178aa0e --- /dev/null +++ b/packages/core/src/common/menu/composite-menu-node.ts @@ -0,0 +1,121 @@ +// ***************************************************************************** +// 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 { CompoundMenuNode, CompoundMenuNodeMetadata, CompoundMenuNodeRole, MenuNode, SubMenuOptions } from './menu-types'; + +/** + * Node representing a (sub)menu in the menu tree structure. + */ +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, + public label?: string, + options?: SubMenuOptions, + readonly parent?: MenuNode & CompoundMenuNode, + ) { + if (options) { + this.iconClass = options.iconClass; + this.order = options.order; + this.when = options.when; + this._role = options?.role; + } + } + + get icon(): string | undefined { + return this.iconClass; + } + + get children(): ReadonlyArray { + 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`. + * + * @returns a disposable which, when called, will remove the given node again. + */ + public addNode(node: MenuNode): Disposable { + this._children.push(node); + this._children.sort(CompoundMenuNode.sortChildren); + 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); + } + + /** @deprecated @since 1.28 use CompoundMenuNode.isNavigationGroup instead */ + static isNavigationGroup = CompoundMenuNode.isNavigationGroup; +} + +export class CompositeMenuNodeWrapper implements MenuNode, CompoundMenuNodeMetadata { + constructor(protected readonly wrapped: Readonly, readonly parent: MenuNode & CompoundMenuNode, 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 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; } + + 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/menu-adapter.ts b/packages/core/src/common/menu/menu-adapter.ts new file mode 100644 index 0000000000000..1d62b10bb24ba --- /dev/null +++ b/packages/core/src/common/menu/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-types'; + +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/menu-model-registry.ts similarity index 56% rename from packages/core/src/common/menu.ts rename to packages/core/src/common/menu/menu-model-registry.ts index f58b93b4a8536..1a3248c6e35cf 100644 --- a/packages/core/src/common/menu.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -15,74 +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 -} - -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'); @@ -128,6 +66,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 +95,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.) @@ -186,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) { @@ -206,6 +158,21 @@ export class MenuModelRegistry { } } + 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, options)); + return { dispose: () => this.independentSubmenus.delete(id) }; + } + + 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, parent, options); + return parent.addNode(wrapper); + } + /** * Unregister all menu nodes with the same id as the given menu action. * @@ -258,6 +225,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 +237,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) { @@ -274,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; } @@ -290,160 +265,24 @@ export class MenuModelRegistry { getMenu(menuPath: MenuPath = []): CompositeMenuNode { 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. + * 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. */ - readonly label?: string - /** - * technical identifier. - */ - readonly id: string - /** - * Menu nodes are sorted in ascending order based on their `sortString`. - */ - readonly sortString: 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; - - constructor( - public readonly id: string, - public label?: string, - options?: SubMenuOptions - ) { - if (options) { - this.iconClass = options.iconClass; - this.order = options.order; - } - } - - 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; + getPath(node: MenuNode): MenuPath | undefined { + const identifiers = []; + const visited: MenuNode[] = []; + let next: MenuNode | undefined = node; + + while (next && !visited.includes(next)) { + if (next === this.root) { + return identifiers.reverse(); } - 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 this.label !== undefined; - } - - /** - * 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'; - } -} - -/** - * 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 id(): string { - return this.action.commandId; - } - - get label(): string { - if (this.action.label) { - return this.action.label; + visited.push(next); + identifiers.push(next.id); + next = next.parent; } - 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; + return undefined; } } 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..9c56a9279628f --- /dev/null +++ b/packages/core/src/common/menu/menu-types.ts @@ -0,0 +1,183 @@ +// ***************************************************************************** +// 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 extends MenuNodeRenderingData, Pick { + /** + * 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; + /** + * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined + * label will be used instead. + */ + order?: 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 extends Pick, Pick, Partial> { + /** + * The class to use for the submenu icon. + */ + iconClass?: 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']; + +export interface MenuNodeMetadata { + /** + * technical identifier. + */ + readonly id: string; + /** + * Menu nodes are sorted in ascending order based on their `sortString`. + */ + readonly sortString: string; + /** + * Condition under which the menu node should be rendered. + * 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 { + /** + * 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; +} + +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, +} + +export interface CompoundMenuNode { + /** + * Items that are grouped under this menu. + */ + 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); + } + + /** 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. + * + * @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. + */ + readonly isSubmenu: boolean; + /** + * How the node and its children should be rendered. See {@link CompoundMenuNodeRole}. + */ + readonly role: CompoundMenuNodeRole; +} + +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; +} + +/** + * Base interface of the nodes used in the menu tree structure. + */ +export interface MenuNode extends MenuNodeMetadata, + MenuNodeRenderingData, + Partial, + Partial, + Partial, + Partial { } 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; 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..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 @@ -18,9 +18,7 @@ import * as electronRemote from '../../../electron-shared/@electron/remote'; import { inject, injectable, postConstruct } from 'inversify'; -import { - isOSX, ActionMenuNode, CompositeMenuNode, 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'); @@ -36,6 +34,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 +107,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,111 +123,90 @@ 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, - args: any[] = [], - options?: ElectronMenuOptions + 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 instanceof CompositeMenuNode) { - if (menu.children.length > 0) { - // do not render empty nodes - - if (menu.isSubmenu) { // submenu node - - const submenu = this.fillMenuTemplate([], menu, args, options); - if (submenu.length === 0) { - continue; - } - - items.push({ - label: menu.label, - submenu - }); - - } else { // group node - - // 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); - } - } - } 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; + const showDisabled = options?.showDisabled !== false; + + 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 children = CompoundMenuNode.getFlatChildren(menu.children); + 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 && menu.id !== 'inline') { + 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.commandRegistry.isVisible(commandId, ...args) - || (!!node.action.when && !this.contextKeyService.match(node.action.when))) { - 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.commandRegistry.isEnabled(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) - }; + 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; } /** @@ -268,17 +254,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/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-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..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 { @@ -758,6 +759,7 @@ export interface Menu { export interface Submenu { id: string; label: string; + icon?: IconUrl; } /** 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/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 34ff4c591015a..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 @@ -16,623 +16,149 @@ /* 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, CompoundMenuNodeRole } 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, IconUrl, 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'; +import { PluginSharedStyle } from '../plugin-shared-style'; +import { ThemeIcon } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService'; @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 contextKeyService: ContextKeyService; + @inject(PluginSharedStyle) protected readonly style: PluginSharedStyle; @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, 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); + 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.contextKeyService.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): Array<[MenuPath] | [MenuPath, string]> | 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) { + const iconClass = submenu.icon && this.toIconClass(submenu.icon, toDispose); + this.menuRegistry.registerIndependentSubmenu(submenu.id, submenu.label, iconClass ? { iconClass } : undefined); } - 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); + 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))); + } 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); + const parent = this.menuRegistry.getMenuNode(contributionPoint, 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, error); } - })); - } - - // 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; + protected toIconClass(url: IconUrl, toDispose: DisposableCollection): string | undefined { + if (typeof url === 'string') { + const asThemeIcon = ThemeIcon.fromString(url); + if (asThemeIcon) { + return ThemeIcon.asClassName(asThemeIcon); + } } - } - - 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; + const reference = this.style.toIconClass(url); + toDispose.push(reference); + return reference.object.iconClass; } } 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..b2536cd1264be --- /dev/null +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -0,0 +1,257 @@ +// ***************************************************************************** +// 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], + ['debug/toolBar', noArgs], + ['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(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..e1c4aa4426342 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts @@ -0,0 +1,85 @@ +// ***************************************************************************** +// 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]; + +/** 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; +@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/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; 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 612cd49a605c2..e1df209eabb58 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; @@ -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 {