From bc8efb16a757dd497f924515f9e39001b8081d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Tue, 29 Oct 2024 08:29:58 +0100 Subject: [PATCH 01/11] Refactor menu nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #14217 Makes menu nodes active object that can decide on visibility, enablement, etc. themselves. Contributed on behalf of STMicroelectronics Signed-off-by: Thomas Mäder --- examples/api-samples/package.json | 6 +- .../menu/sample-browser-menu-module.ts | 98 ----- .../browser/menu/sample-menu-contribution.ts | 40 +- .../menu/sample-electron-menu-module.ts | 42 -- .../browser/common-frontend-contribution.ts | 28 +- .../core/src/browser/context-menu-renderer.ts | 30 +- .../browser/frontend-application-module.ts | 6 - .../core/src/browser/menu/action-menu-node.ts | 128 ++++++ .../menu/browser-context-menu-renderer.ts | 20 +- .../src/browser/menu/browser-menu-module.ts | 4 + .../browser/menu/browser-menu-node-factory.ts | 48 +++ .../src/browser/menu/browser-menu-plugin.ts | 248 ++++------- .../src/browser/menu/composite-menu-node.ts | 144 +++++++ .../src/{common => browser}/menu/menu.spec.ts | 27 +- .../shell/sidebar-bottom-menu-widget.tsx | 3 +- .../src/browser/shell/sidebar-menu-widget.tsx | 14 +- .../tab-bar-toolbar-menu-adapters.ts | 31 -- .../tab-bar-toolbar-menu-adapters.tsx | 252 +++++++++++ .../tab-bar-toolbar-registry.ts | 155 +++---- .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 37 +- .../tab-bar-toolbar/tab-bar-toolbar.spec.ts | 28 +- .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 292 ++----------- .../tab-bar-toolbar/tab-toolbar-item.tsx | 238 ++++++++++ .../core/src/browser/style/view-container.css | 48 +-- packages/core/src/browser/view-container.ts | 72 ++-- .../core/src/common/menu/action-menu-node.ts | 65 --- .../common/menu/composite-menu-node.spec.ts | 67 --- .../src/common/menu/composite-menu-node.ts | 116 ----- packages/core/src/common/menu/index.ts | 5 +- packages/core/src/common/menu/menu-adapter.ts | 103 ----- .../src/common/menu/menu-model-registry.ts | 405 ++++++++---------- packages/core/src/common/menu/menu-types.ts | 211 ++++----- packages/core/src/common/test/mock-menu.ts | 35 -- .../menu/electron-context-menu-renderer.ts | 29 +- .../menu/electron-main-menu-factory.ts | 182 ++++---- .../menu/electron-menu-module.ts | 7 +- ...debug-frontend-application-contribution.ts | 11 +- .../debug/src/browser/view/debug-action.tsx | 5 +- .../src/browser/view/debug-toolbar-widget.tsx | 29 +- .../browser/editor-navigation-contribution.ts | 8 +- .../src/browser/diff/git-diff-contribution.ts | 4 +- packages/git/src/browser/git-contribution.ts | 4 +- .../git/src/browser/git-frontend-module.ts | 2 +- packages/monaco/src/browser/monaco-menu.ts | 9 +- .../src/browser/navigator-contribution.ts | 4 +- .../notebook-actions-contribution.ts | 16 +- .../notebook-cell-actions-contribution.ts | 29 +- .../service/notebook-context-manager.ts | 16 +- .../browser/view/notebook-cell-list-view.tsx | 43 +- .../view/notebook-cell-toolbar-factory.tsx | 58 ++- .../browser/view/notebook-cell-toolbar.tsx | 9 +- .../browser/view/notebook-main-toolbar.tsx | 74 ++-- .../plugin-vscode-commands-contribution.ts | 14 +- .../comments/comment-thread-widget.tsx | 122 +++--- ...ext-key-service.ts => comments-context.ts} | 21 +- .../browser/comments/comments-contribution.ts | 10 +- .../menus/menus-contribution-handler.ts | 111 ++--- .../menus/plugin-menu-command-adapter.ts | 133 +----- .../menus/vscode-theia-menu-mappings.ts | 10 +- .../browser/plugin-ext-frontend-module.ts | 6 +- .../main/browser/view/tree-view-widget.tsx | 13 +- .../src/browser/util/preference-types.ts | 2 +- .../browser/dirty-diff/dirty-diff-widget.ts | 22 +- packages/scm/src/browser/scm-contribution.ts | 4 +- packages/scm/src/browser/scm-tree-widget.tsx | 36 +- .../browser/terminal-frontend-contribution.ts | 12 +- .../src/browser/view/test-tree-widget.tsx | 12 +- .../browser/view/test-view-contribution.ts | 24 +- .../toolbar/src/browser/toolbar-controller.ts | 19 +- .../toolbar/src/browser/toolbar-interfaces.ts | 11 +- packages/toolbar/src/browser/toolbar.tsx | 75 +--- .../browser/vsx-extensions-contribution.ts | 6 +- .../browser/vsx-extensions-view-container.ts | 23 +- 73 files changed, 1954 insertions(+), 2317 deletions(-) delete mode 100644 examples/api-samples/src/browser/menu/sample-browser-menu-module.ts delete mode 100644 examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts create mode 100644 packages/core/src/browser/menu/action-menu-node.ts create mode 100644 packages/core/src/browser/menu/browser-menu-node-factory.ts create mode 100644 packages/core/src/browser/menu/composite-menu-node.ts rename packages/core/src/{common => browser}/menu/menu.spec.ts (80%) delete mode 100644 packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts create mode 100644 packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx create mode 100644 packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx delete mode 100644 packages/core/src/common/menu/action-menu-node.ts delete mode 100644 packages/core/src/common/menu/composite-menu-node.spec.ts delete mode 100644 packages/core/src/common/menu/composite-menu-node.ts delete mode 100644 packages/core/src/common/menu/menu-adapter.ts delete mode 100644 packages/core/src/common/test/mock-menu.ts rename packages/plugin-ext/src/main/browser/comments/{comments-context-key-service.ts => comments-context.ts} (74%) diff --git a/examples/api-samples/package.json b/examples/api-samples/package.json index aaa631e623d52..ffb32f66ab171 100644 --- a/examples/api-samples/package.json +++ b/examples/api-samples/package.json @@ -25,10 +25,6 @@ "frontend": "lib/browser/api-samples-frontend-module", "backend": "lib/node/api-samples-backend-module" }, - { - "frontend": "lib/browser/menu/sample-browser-menu-module", - "frontendElectron": "lib/electron-browser/menu/sample-electron-menu-module" - }, { "electronMain": "lib/electron-main/update/sample-updater-main-module", "frontendElectron": "lib/electron-browser/updater/sample-updater-frontend-module" @@ -65,4 +61,4 @@ "devDependencies": { "@theia/ext-scripts": "1.59.0" } -} +} \ No newline at end of file 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 deleted file mode 100644 index 71d08c0256bcf..0000000000000 --- a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts +++ /dev/null @@ -1,98 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2020 TypeFox and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { injectable, ContainerModule } from '@theia/core/shared/inversify'; -import { Menu as MenuWidget } from '@theia/core/shared/@lumino/widgets'; -import { Disposable } from '@theia/core/lib/common/disposable'; -import { MenuNode, CompoundMenuNode, 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) => { - rebind(BrowserMainMenuFactory).to(SampleBrowserMainMenuFactory).inSingletonScope(); -}); - -@injectable() -class SampleBrowserMainMenuFactory extends BrowserMainMenuFactory { - - 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); - } - } - - protected override createMenuCommandRegistry(menu: CompoundMenuNode, args: unknown[] = []): MenuCommandRegistry { - const menuCommandRegistry = new SampleMenuCommandRegistry(this.services); - this.registerMenu(menuCommandRegistry, menu, args); - return menuCommandRegistry; - } - - override createMenuWidget(menu: CompoundMenuNode, options: BrowserMenuOptions): DynamicMenuWidget { - return new SampleDynamicMenuWidget(menu, options, this.services); - } - -} - -class SampleMenuCommandRegistry extends MenuCommandRegistry { - - protected placeholders = new Map(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - registerPlaceholderMenu(menu: PlaceholderMenuNode): void { - const { id } = menu; - if (this.placeholders.has(id)) { - return; - } - this.placeholders.set(id, menu); - } - - override snapshot(menuPath: MenuPath): this { - super.snapshot(menuPath); - for (const menu of this.placeholders.values()) { - this.toDispose.push(this.registerPlaceholder(menu)); - } - return this; - } - - protected registerPlaceholder(menu: PlaceholderMenuNode): Disposable { - const { id } = menu; - return this.addCommand(id, { - execute: () => { /* NOOP */ }, - label: menu.label, - iconClass: menu.icon, - isEnabled: () => false, - isVisible: () => true - }); - } - -} - -class SampleDynamicMenuWidget extends DynamicMenuWidget { - - 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 parentItems; - } -} diff --git a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts index b334b75001ecc..ea8a89ef0b07c 100644 --- a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts +++ b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts @@ -18,8 +18,8 @@ import { ConfirmDialog, Dialog, QuickInputService } from '@theia/core/lib/browse import { ReactDialog } from '@theia/core/lib/browser/dialogs/react-dialog'; import { SelectComponent } from '@theia/core/lib/browser/widgets/select-component'; import { - Command, CommandContribution, CommandRegistry, MAIN_MENU_BAR, - MenuContribution, MenuModelRegistry, MenuNode, MessageService, SubMenuOptions + Command, CommandContribution, CommandMenu, CommandRegistry, ContextExpressionMatcher, MAIN_MENU_BAR, + MenuContribution, MenuModelRegistry, MenuPath, MessageService } from '@theia/core/lib/common'; import { inject, injectable, interfaces } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; @@ -227,9 +227,8 @@ export class SampleMenuContribution implements MenuContribution { registerMenus(menus: MenuModelRegistry): void { setTimeout(() => { const subMenuPath = [...MAIN_MENU_BAR, 'sample-menu']; - menus.registerSubmenu(subMenuPath, 'Sample Menu', { - order: '2' // that should put the menu right next to the File menu - }); + menus.registerSubmenu(subMenuPath, 'Sample Menu', '2'); // that should put the menu right next to the File menu + menus.registerMenuAction(subMenuPath, { commandId: SampleCommand.id, order: '0' @@ -239,7 +238,7 @@ export class SampleMenuContribution implements MenuContribution { order: '2' }); const subSubMenuPath = [...subMenuPath, 'sample-sub-menu']; - menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { order: '2' }); + menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', '2'); menus.registerMenuAction(subSubMenuPath, { commandId: SampleCommand.id, order: '1' @@ -248,8 +247,8 @@ export class SampleMenuContribution implements MenuContribution { commandId: SampleCommand2.id, order: '3' }); - const placeholder = new PlaceholderMenuNode([...subSubMenuPath, 'placeholder'].join('-'), 'Placeholder', { order: '0' }); - menus.registerMenuNode(subSubMenuPath, placeholder); + const placeholder = new PlaceholderMenuNode([...subSubMenuPath, 'placeholder'].join('-'), 'Placeholder', '0'); + menus.registerCommandMenu(subSubMenuPath, placeholder); /** * Register an action menu with an invalid command (un-registered and without a label) in order @@ -258,22 +257,35 @@ export class SampleMenuContribution implements MenuContribution { menus.registerMenuAction(subMenuPath, { commandId: 'invalid-command' }); }, 10000); } - } /** * Special menu node that is not backed by any commands and is always disabled. */ -export class PlaceholderMenuNode implements MenuNode { +export class PlaceholderMenuNode implements CommandMenu { - constructor(readonly id: string, public readonly label: string, protected options?: SubMenuOptions) { } + constructor(readonly id: string, public readonly label: string, readonly order?: string, readonly icon?: string) { } - get icon(): string | undefined { - return this.options?.iconClass; + isEnabled(effectiveMenuPath: MenuPath, ...args: any[]): boolean { + return false; + } + + isToggled(effectiveMenuPath: MenuPath): boolean { + return false; + } + run(effectiveMenuPath: MenuPath, ...args: any[]): Promise { + throw new Error('Should never happen'); + } + getAccelerator(context: HTMLElement | undefined): string[] { + return []; } get sortString(): string { - return this.options?.order || this.label; + return this.order || this.label; + } + + isVisible(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: any[]): boolean { + return true; } } 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 deleted file mode 100644 index d8e3e75183e2a..0000000000000 --- a/examples/api-samples/src/electron-browser/menu/sample-electron-menu-module.ts +++ /dev/null @@ -1,42 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2020 TypeFox and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { injectable, ContainerModule } from '@theia/core/shared/inversify'; -import { 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'; -import { MenuDto } from '@theia/core/lib/electron-common/electron-api'; - -export default new ContainerModule((bind, unbind, isBound, rebind) => { - rebind(ElectronMainMenuFactory).to(SampleElectronMainMenuFactory).inSingletonScope(); -}); - -@injectable() -class SampleElectronMainMenuFactory extends ElectronMainMenuFactory { - protected override fillMenuTemplate(parentItems: MenuDto[], - menu: MenuNode, - args: unknown[] = [], - options: ElectronMenuOptions, - skipRoot: boolean - ): MenuDto[] { - if (menu instanceof PlaceholderMenuNode) { - parentItems.push({ label: menu.label, enabled: false, visible: true }); - } else { - super.fillMenuTemplate(parentItems, menu, args, options, skipRoot); - } - return parentItems; - } -} diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 5dcfcacabc4d4..730dd63f44a56 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -18,7 +18,7 @@ import debounce = require('lodash.debounce'); import { injectable, inject, optional } from 'inversify'; -import { MAIN_MENU_BAR, MANAGE_MENU, MenuContribution, MenuModelRegistry, ACCOUNTS_MENU } from '../common/menu'; +import { MAIN_MENU_BAR, MANAGE_MENU, MenuContribution, MenuModelRegistry, ACCOUNTS_MENU, CompoundMenuNode, CommandMenu, Group, Submenu } from '../common/menu'; import { KeybindingContribution, KeybindingRegistry } from './keybinding'; import { FrontendApplication } from './frontend-application'; import { FrontendApplicationContribution, OnWillStopAction } from './frontend-application-contribution'; @@ -84,7 +84,7 @@ export namespace CommonMenus { export const FILE_SETTINGS_SUBMENU_THEME = [...FILE_SETTINGS_SUBMENU, '2_settings_submenu_theme']; export const FILE_CLOSE = [...FILE, '6_close']; - export const FILE_NEW_CONTRIBUTIONS = 'file/newFile'; + export const FILE_NEW_CONTRIBUTIONS = ['file', 'newFile']; export const EDIT = [...MAIN_MENU_BAR, '2_edit']; export const EDIT_UNDO = [...EDIT, '1_undo']; @@ -622,7 +622,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi registry.registerSubmenu(CommonMenus.HELP, nls.localizeByDefault('Help')); // For plugins contributing create new file commands/menu-actions - registry.registerIndependentSubmenu(CommonMenus.FILE_NEW_CONTRIBUTIONS, nls.localizeByDefault('New File...')); + registry.registerSubmenu(CommonMenus.FILE_NEW_CONTRIBUTIONS, nls.localizeByDefault('New File...')); registry.registerMenuAction(CommonMenus.FILE_SAVE, { commandId: CommonCommands.SAVE.id @@ -763,7 +763,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi commandId: CommonCommands.SELECT_ICON_THEME.id }); - registry.registerSubmenu(CommonMenus.MANAGE_SETTINGS_THEMES, nls.localizeByDefault('Themes'), { order: 'a50' }); + registry.registerSubmenu(CommonMenus.MANAGE_SETTINGS_THEMES, nls.localizeByDefault('Themes'), 'a50'); registry.registerMenuAction(CommonMenus.MANAGE_SETTINGS_THEMES, { commandId: CommonCommands.SELECT_COLOR_THEME.id, order: '0' @@ -1499,7 +1499,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi * @todo https://github.com/eclipse-theia/theia/issues/12824 */ protected async showNewFilePicker(): Promise { - const newFileContributions = this.menuRegistry.getMenuNode(CommonMenus.FILE_NEW_CONTRIBUTIONS); // Add menus + const newFileContributions = this.menuRegistry.getMenuNode(CommonMenus.FILE_NEW_CONTRIBUTIONS) as Submenu; // Add menus const items: QuickPickItemOrSeparator[] = [ { label: nls.localizeByDefault('New Text File'), @@ -1508,22 +1508,22 @@ export class CommonFrontendContribution implements FrontendApplicationContributi }, ...newFileContributions.children .flatMap(node => { - if (node.children && node.children.length > 0) { + if (CompoundMenuNode.is(node) && node.children.length > 0) { return node.children; } return node; }) - .filter(node => node.role || node.command) + .filter(node => Group.is(node) || CommandMenu.is(node)) .map(node => { - if (node.role) { + if (Group.is(node)) { return { type: 'separator' } as QuickPickSeparator; + } else { + const item = node as CommandMenu; + return { + label: item.label, + execute: () => item.run(CommonMenus.FILE_NEW_CONTRIBUTIONS) + }; } - const command = this.commandRegistry.getCommand(node.command!); - return { - label: command!.label!, - execute: async () => this.commandRegistry.executeCommand(command!.id!) - }; - }) ]; diff --git a/packages/core/src/browser/context-menu-renderer.ts b/packages/core/src/browser/context-menu-renderer.ts index c763bdfaa1ec2..913655d03cedc 100644 --- a/packages/core/src/browser/context-menu-renderer.ts +++ b/packages/core/src/browser/context-menu-renderer.ts @@ -16,10 +16,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { injectable } from 'inversify'; -import { MenuPath } from '../common/menu'; +import { injectable, inject } from 'inversify'; +import { CompoundMenuNode, MenuModelRegistry, MenuPath } from '../common/menu'; import { Disposable, DisposableCollection } from '../common/disposable'; -import { ContextMatcher } from './context-key-service'; +import { ContextKeyService, ContextMatcher } from './context-key-service'; export interface Coordinate { x: number; y: number; } export const Coordinate = Symbol('Coordinate'); @@ -53,6 +53,10 @@ export abstract class ContextMenuAccess implements Disposable { @injectable() export abstract class ContextMenuRenderer { + @inject(MenuModelRegistry) menuRegistry: MenuModelRegistry; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + protected _current: ContextMenuAccess | undefined; protected readonly toDisposeOnSetCurrent = new DisposableCollection(); /** @@ -77,13 +81,28 @@ export abstract class ContextMenuRenderer { } render(options: RenderContextMenuOptions): ContextMenuAccess { + let menu = CompoundMenuNode.is(options.menu) ? options.menu : this.menuRegistry.getMenu(options.menuPath); + const resolvedOptions = this.resolve(options); - const access = this.doRender(resolvedOptions); + + if (resolvedOptions.skipSingleRootNode) { + menu = MenuModelRegistry.removeSingleRootNode(menu); + } + + const access = this.doRender(options.menuPath, menu, resolvedOptions.anchor, options.contextKeyService || this.contextKeyService, resolvedOptions.args, resolvedOptions.context, resolvedOptions.onHide); this.setCurrent(access); return access; } - protected abstract doRender(options: RenderContextMenuOptions): ContextMenuAccess; + protected abstract doRender( + menuPath: MenuPath, + menu: CompoundMenuNode, + anchor: Anchor, + contextMatcher: ContextMatcher, + args?: any[], + context?: HTMLElement, + onHide?: () => void + ): ContextMenuAccess; protected resolve(options: RenderContextMenuOptions): RenderContextMenuOptions { const args: any[] = options.args ? options.args.slice() : []; @@ -99,6 +118,7 @@ export abstract class ContextMenuRenderer { } export interface RenderContextMenuOptions { + menu?: CompoundMenuNode, menuPath: MenuPath; anchor: Anchor; args?: any[]; diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 98e0e3b2131f6..ad924695f966f 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -32,10 +32,6 @@ import { messageServicePath, InMemoryTextResourceResolver, UntitledResourceResolver, - MenuCommandAdapterRegistry, - MenuCommandExecutor, - MenuCommandAdapterRegistryImpl, - MenuCommandExecutorImpl, MenuPath } from '../common'; import { KeybindingRegistry, KeybindingContext, KeybindingContribution } from './keybinding'; @@ -271,8 +267,6 @@ 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/action-menu-node.ts b/packages/core/src/browser/menu/action-menu-node.ts new file mode 100644 index 0000000000000..6b7e912c1f551 --- /dev/null +++ b/packages/core/src/browser/menu/action-menu-node.ts @@ -0,0 +1,128 @@ +// ***************************************************************************** +// 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { KeybindingRegistry } from '../keybinding'; +import { ContextKeyService } from '../context-key-service'; +import { DisposableCollection, isObject, CommandRegistry, Emitter } from '../../common'; +import { CommandMenu, ContextExpressionMatcher, MenuAction, MenuPath } from '../../common/menu/menu-types'; + +export interface AcceleratorSource { + getAccelerator(context: HTMLElement | undefined): string[]; +} + +export namespace AcceleratorSource { + export function is(node: unknown): node is AcceleratorSource { + return isObject(node) && typeof node.getAccelerator === 'function'; + } +} + +/** + * 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 CommandMenu { + + protected readonly disposables = new DisposableCollection(); + protected readonly onDidChangeEmitter = new Emitter(); + + onDidChange = this.onDidChangeEmitter.event; + + constructor( + protected readonly action: MenuAction, + protected readonly commands: CommandRegistry, + protected readonly keybindingRegistry: KeybindingRegistry, + protected readonly contextKeyService: ContextKeyService + ) { + this.commands.getAllHandlers(action.commandId).forEach(handler => { + if (handler.onDidChangeEnabled) { + this.disposables.push(handler.onDidChangeEnabled(() => this.onDidChangeEmitter.fire())); + } + }); + + if (action.when) { + const contextKeys = new Set(); + this.contextKeyService.parseKeys(action.when)?.forEach(key => contextKeys.add(key)); + if (contextKeys.size > 0) { + this.disposables.push(this.contextKeyService.onDidChange(change => { + if (change.affects(contextKeys)) { + this.onDidChangeEmitter.fire(); + } + })); + } + } + } + + dispose(): void { + this.disposables.dispose(); + } + + isVisible(effeciveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean { + if (!this.commands.isVisible(this.action.commandId, ...args)) { + return false; + } + if (this.action.when) { + return contextMatcher.match(this.action.when, context); + } + return true; + } + + getAccelerator(context: HTMLElement | undefined): string[] { + const bindings = this.keybindingRegistry.getKeybindingsForCommand(this.action.commandId); + // Only consider the first active keybinding. + if (bindings.length) { + const binding = bindings.find(b => this.keybindingRegistry.isEnabledInScope(b, context)); + if (binding) { + return this.keybindingRegistry.acceleratorFor(binding, '+', true); + } + } + return []; + } + + isEnabled(effeciveMenuPath: MenuPath, ...args: unknown[]): boolean { + return this.commands.isEnabled(this.action.commandId, ...args); + } + isToggled(effeciveMenuPath: MenuPath, ...args: unknown[]): boolean { + return this.commands.isToggled(this.action.commandId, ...args); + } + async run(effeciveMenuPath: MenuPath, ...args: unknown[]): Promise { + return this.commands.executeCommand(this.action.commandId, ...args); + } + + 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/browser/menu/browser-context-menu-renderer.ts b/packages/core/src/browser/menu/browser-context-menu-renderer.ts index baa5e28167873..88622c1ad1b52 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -16,8 +16,10 @@ import { inject, injectable } from 'inversify'; import { Menu } from '../widgets'; -import { ContextMenuAccess, ContextMenuRenderer, coordinateFromAnchor, RenderContextMenuOptions } from '../context-menu-renderer'; +import { Anchor, ContextMenuAccess, ContextMenuRenderer, coordinateFromAnchor } from '../context-menu-renderer'; import { BrowserMainMenuFactory } from './browser-menu-plugin'; +import { ContextMatcher } from '../context-key-service'; +import { CompoundMenuNode, MenuPath } from '../../common'; export class BrowserContextMenuAccess extends ContextMenuAccess { constructor( @@ -29,13 +31,17 @@ export class BrowserContextMenuAccess extends ContextMenuAccess { @injectable() export class BrowserContextMenuRenderer extends ContextMenuRenderer { + @inject(BrowserMainMenuFactory) private menuFactory: BrowserMainMenuFactory; - constructor(@inject(BrowserMainMenuFactory) private menuFactory: BrowserMainMenuFactory) { - super(); - } - - protected doRender({ menuPath, anchor, args, onHide, context, contextKeyService, skipSingleRootNode }: RenderContextMenuOptions): ContextMenuAccess { - const contextMenu = this.menuFactory.createContextMenu(menuPath, args, context, contextKeyService, skipSingleRootNode); + protected doRender(menuPath: MenuPath, + menu: CompoundMenuNode, + anchor: Anchor, + contextMatcher: ContextMatcher, + args?: unknown[], + context?: HTMLElement, + onHide?: () => void + ): ContextMenuAccess { + const contextMenu = this.menuFactory.createContextMenu(menuPath, menu, contextMatcher, args, context); const { x, y } = coordinateFromAnchor(anchor); if (onHide) { contextMenu.aboutToClose.connect(() => onHide!()); diff --git a/packages/core/src/browser/menu/browser-menu-module.ts b/packages/core/src/browser/menu/browser-menu-module.ts index f30a7f53dde69..b65a89460730e 100644 --- a/packages/core/src/browser/menu/browser-menu-module.ts +++ b/packages/core/src/browser/menu/browser-menu-module.ts @@ -19,10 +19,14 @@ import { FrontendApplicationContribution } from '../frontend-application-contrib import { ContextMenuRenderer } from '../context-menu-renderer'; import { BrowserMenuBarContribution, BrowserMainMenuFactory } from './browser-menu-plugin'; import { BrowserContextMenuRenderer } from './browser-context-menu-renderer'; +import { BrowserMenuNodeFactory } from './browser-menu-node-factory'; +import { MenuNodeFactory } from '../../common'; export default new ContainerModule(bind => { bind(BrowserMainMenuFactory).toSelf().inSingletonScope(); bind(ContextMenuRenderer).to(BrowserContextMenuRenderer).inSingletonScope(); bind(BrowserMenuBarContribution).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(BrowserMenuBarContribution); + bind(BrowserMenuNodeFactory).toSelf().inSingletonScope(); + bind(MenuNodeFactory).toService(BrowserMenuNodeFactory); }); diff --git a/packages/core/src/browser/menu/browser-menu-node-factory.ts b/packages/core/src/browser/menu/browser-menu-node-factory.ts new file mode 100644 index 0000000000000..dbe922812c55a --- /dev/null +++ b/packages/core/src/browser/menu/browser-menu-node-factory.ts @@ -0,0 +1,48 @@ +// ***************************************************************************** +// Copyright (C) 2024 STMicroelectronics and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable, inject } from 'inversify'; +import { + ActionMenuNode, CommandMenu, CommandRegistry, Group, GroupImpl, MenuAction, MenuNode, MenuNodeFactory, + MutableCompoundMenuNode, SubMenuLink, Submenu, SubmenuImpl +} from '../../common'; +import { ContextKeyService } from '../context-key-service'; +import { KeybindingRegistry } from '../keybinding'; + +@injectable() +export class BrowserMenuNodeFactory implements MenuNodeFactory { + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + @inject(KeybindingRegistry) + protected readonly keybindingRegistry: KeybindingRegistry; + + createGroup(id: string, orderString?: string, when?: string): Group & MutableCompoundMenuNode { + return new GroupImpl(this.contextKeyService, id, orderString, when); + } + + createCommandMenu(item: MenuAction): CommandMenu { + return new ActionMenuNode(item, this.commandRegistry, this.keybindingRegistry, this.contextKeyService); + } + createSubmenu(id: string, label: string, contextKeyOverlays: Record | undefined, orderString?: string, icon?: string, when?: string): + Submenu & MutableCompoundMenuNode { + return new SubmenuImpl(this.contextKeyService, id, label, contextKeyOverlays, orderString, icon, when); + } + createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode { + return new SubMenuLink(delegate, sortString, when); + } +} diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 83190a9d63215..4855c16838711 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 { Menu, MenuBar, Menu as MenuWidget, Widget } from '@lumino/widgets'; import { CommandRegistry as LuminoCommandRegistry } from '@lumino/commands'; import { - CommandRegistry, environment, DisposableCollection, Disposable, - MenuModelRegistry, MAIN_MENU_BAR, MenuPath, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode, + environment, DisposableCollection, + AcceleratorSource, ArrayUtils } from '../../common'; import { KeybindingRegistry } from '../keybinding'; @@ -32,6 +32,8 @@ import { ApplicationShell } from '../shell'; import { CorePreferences } from '../core-preferences'; import { PreferenceService } from '../preferences/preference-service'; import { ElementExt } from '@lumino/domutils'; +import { CommandMenu, CompoundMenuNode, MAIN_MENU_BAR, MenuNode, MenuPath, RenderedMenuNode, Submenu } from '../../common/menu/menu-types'; +import { MenuModelRegistry } from '../../common/menu/menu-model-registry'; export abstract class MenuBarWidget extends MenuBar { abstract activateMenu(label: string, ...labels: string[]): Promise; @@ -39,10 +41,7 @@ export abstract class MenuBarWidget extends MenuBar { } export interface BrowserMenuOptions extends MenuWidget.IOptions { - commands: MenuCommandRegistry, context?: HTMLElement, - contextKeyService?: ContextMatcher; - rootMenuPath: MenuPath }; @injectable() @@ -54,12 +53,6 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { @inject(ContextMenuContext) protected readonly context: ContextMenuContext; - @inject(CommandRegistry) - protected readonly commandRegistry: CommandRegistry; - - @inject(MenuCommandExecutor) - protected readonly menuCommandExecutor: MenuCommandExecutor; - @inject(CorePreferences) protected readonly corePreferences: CorePreferences; @@ -108,53 +101,31 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { } protected fillMenuBar(menuBar: MenuBarWidget): void { - const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR); - const menuCommandRegistry = this.createMenuCommandRegistry(menuModel); + const menuModel = this.menuProvider.getMenuNode(MAIN_MENU_BAR) as Submenu; + const menuCommandRegistry = new LuminoCommandRegistry(); for (const menu of menuModel.children) { - if (CompoundMenuNode.is(menu)) { - const menuWidget = this.createMenuWidget(menu, { commands: menuCommandRegistry, rootMenuPath: MAIN_MENU_BAR }); + if (CompoundMenuNode.is(menu) && RenderedMenuNode.is(menu)) { + const menuWidget = this.createMenuWidget(MAIN_MENU_BAR, menu, this.contextKeyService, { commands: menuCommandRegistry }); menuBar.addMenu(menuWidget); } } } - createContextMenu(path: MenuPath, args?: unknown[], context?: HTMLElement, contextKeyService?: ContextMatcher, skipSingleRootNode?: boolean): MenuWidget { - const menuModel = skipSingleRootNode ? this.menuProvider.removeSingleRootNode(this.menuProvider.getMenu(path), path) : this.menuProvider.getMenu(path); - const menuCommandRegistry = this.createMenuCommandRegistry(menuModel, args).snapshot(path); - const contextMenu = this.createMenuWidget(menuModel, { commands: menuCommandRegistry, context, rootMenuPath: path, contextKeyService }); + createContextMenu(effectiveMenuPath: MenuPath, menuModel: CompoundMenuNode, contextMatcher: ContextMatcher, args?: unknown[], context?: HTMLElement): MenuWidget { + const menuCommandRegistry = new LuminoCommandRegistry(); + const contextMenu = this.createMenuWidget(effectiveMenuPath, menuModel, contextMatcher, { commands: menuCommandRegistry, context }, args); return contextMenu; } - createMenuWidget(menu: CompoundMenuNode, options: BrowserMenuOptions): DynamicMenuWidget { - return new DynamicMenuWidget(menu, options, this.services); - } - - protected createMenuCommandRegistry(menu: CompoundMenuNode, args: unknown[] = []): MenuCommandRegistry { - const menuCommandRegistry = new MenuCommandRegistry(this.services); - this.registerMenu(menuCommandRegistry, menu, args); - return menuCommandRegistry; - } - - 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 (CommandMenuNode.hasAltHandler(menu)) { - menuCommandRegistry.registerActionMenu(menu.altNode, args); - } - - } + createMenuWidget(parentPath: MenuPath, menu: CompoundMenuNode, contextMatcher: ContextMatcher, options: BrowserMenuOptions, args?: unknown[]): DynamicMenuWidget { + return new DynamicMenuWidget(parentPath, menu, options, contextMatcher, this.services, args); } protected get services(): MenuServices { return { - context: this.context, contextKeyService: this.contextKeyService, - commandRegistry: this.commandRegistry, - keybindingRegistry: this.keybindingRegistry, + context: this.context, menuWidgetFactory: this, - commandExecutor: this.menuCommandExecutor, }; } @@ -235,41 +206,43 @@ export class DynamicMenuBarWidget extends MenuBarWidget { } export class MenuServices { - readonly commandRegistry: CommandRegistry; - readonly keybindingRegistry: KeybindingRegistry; readonly contextKeyService: ContextKeyService; readonly context: ContextMenuContext; readonly menuWidgetFactory: MenuWidgetFactory; - readonly commandExecutor: MenuCommandExecutor; } export interface MenuWidgetFactory { - createMenuWidget(menu: MenuNode & Required>, options: BrowserMenuOptions): MenuWidget; + createMenuWidget(effectiveMenuPath: MenuPath, menu: Submenu, contextMatcher: ContextMatcher, options: BrowserMenuOptions): MenuWidget; } /** * A menu widget that would recompute its items on update. */ export class DynamicMenuWidget extends MenuWidget { - + private static nextCommmandId = 0; /** * We want to restore the focus after the menu closes. */ protected previousFocusedElement: HTMLElement | undefined; constructor( + protected readonly effectiveMenuPath: MenuPath, protected menu: CompoundMenuNode, protected options: BrowserMenuOptions, - protected services: MenuServices + protected contextMatcher: ContextMatcher, + protected services: MenuServices, + protected args?: unknown[] ) { super(options); - if (menu.label) { - this.title.label = menu.label; - } - if (menu.icon) { - this.title.iconClass = menu.icon; + if (RenderedMenuNode.is(this.menu)) { + if (this.menu.label) { + this.title.label = this.menu.label; + } + if (this.menu.icon) { + this.title.iconClass = this.menu.icon; + } } - this.updateSubMenus(this, this.menu, this.options.commands); + this.updateSubMenus(this.effectiveMenuPath, this, this.menu, this.options.commands, this.contextMatcher, this.options.context); } protected override onAfterAttach(msg: Message): void { @@ -318,8 +291,7 @@ export class DynamicMenuWidget extends MenuWidget { this.preserveFocusedElement(previousFocusedElement); this.clearItems(); this.runWithPreservedFocusContext(() => { - this.options.commands.snapshot(this.options.rootMenuPath); - this.updateSubMenus(this, this.menu, this.options.commands); + this.updateSubMenus(this.effectiveMenuPath, this, this.menu, this.options.commands, this.contextMatcher, this.options.context); }); } @@ -333,8 +305,9 @@ export class DynamicMenuWidget extends MenuWidget { super.open(x, y, options); } - protected updateSubMenus(parent: MenuWidget, menu: CompoundMenuNode, commands: MenuCommandRegistry): void { - const items = this.buildSubMenus([], menu, commands); + protected updateSubMenus(parentPath: MenuPath, parent: MenuWidget, menu: CompoundMenuNode, commands: PhosphorCommandRegistry, + contextMatcher: ContextMatcher, context?: HTMLElement | undefined): void { + const items = this.createItems(parentPath, menu.children, commands, contextMatcher, context); while (items[items.length - 1]?.type === 'separator') { items.pop(); } @@ -350,43 +323,58 @@ export class DynamicMenuWidget extends MenuWidget { } } - protected buildSubMenus(parentItems: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { - if (CompoundMenuNode.is(menu) - && menu.children.length - && this.undefinedOrMatch(this.options.contextKeyService ?? this.services.contextKeyService, 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); - if (submenu.items.length > 0) { - 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' }); + protected createItems(parentPath: MenuPath, nodes: MenuNode[], phCommandRegistry: PhosphorCommandRegistry, + contextMatcher: ContextMatcher, context?: HTMLElement): MenuWidget.IItemOptions[] { + const result: MenuWidget.IItemOptions[] = []; + + for (const node of nodes) { + const nodePath = [...parentPath, node.id]; + if (node.isVisible(nodePath, contextMatcher, context, ...(this.args || []))) { + if (CompoundMenuNode.is(node)) { + if (RenderedMenuNode.is(node)) { + const submenu = this.services.menuWidgetFactory.createMenuWidget(nodePath, node, this.contextMatcher, this.options); + if (submenu.items.length > 0) { + result.push({ type: 'submenu', submenu }); + } + } else { + const items = this.createItems(nodePath, node.children, phCommandRegistry, contextMatcher, context); + if (items.length > 0) { + if (node.id !== 'inline') { + result.push({ type: 'separator' }); + } + result.push(...items); + if (node.id !== 'inline') { + result.push({ type: 'separator' }); + } + } + } + } else if (CommandMenu.is(node)) { + const id = `menuCommand:${DynamicMenuWidget.nextCommmandId++}`; + phCommandRegistry.addCommand(id, { + execute: () => { node.run(nodePath, ...(this.args || [])); }, + isEnabled: () => node.isEnabled(nodePath, ...(this.args || [])), + isToggled: () => node.isToggled ? !!node.isToggled(nodePath, ...(this.args || [])) : false, + isVisible: () => true, + label: node.label, + icon: node.icon, + }); + + const accelerator = (AcceleratorSource.is(node) ? node.getAccelerator(this.options.context) : []); + if (accelerator.length > 0) { + phCommandRegistry.addKeyBinding({ + command: id, + keys: accelerator, + selector: '.p-Widget' // We have the PhosphorJS dependency anyway. + }); } - parentItems.push(...myItems); - parentItems.push({ type: 'separator' }); + result.push({ + command: id, + type: 'command' + }); } } - } 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(this.options.contextKeyService ?? this.services.contextKeyService, node.when, this.options.context)) { - parentItems.push({ - command: node.command, - type: 'command' - }); - } } - return parentItems; - } - - protected undefinedOrMatch(contextKeyService: ContextMatcher, expression?: string, context?: HTMLElement): boolean { - if (expression) { return contextKeyService.match(expression, context); } - return true; + return result; } protected preserveFocusedElement(previousFocusedElement: Element | null = document.activeElement): boolean { @@ -473,79 +461,3 @@ export class BrowserMenuBarContribution implements FrontendApplicationContributi return logo; } } - -/** - * Stores Theia-specific action menu nodes instead of Lumino commands with their handlers. - */ -export class MenuCommandRegistry extends LuminoCommandRegistry { - - protected actions = new Map(); - protected toDispose = new DisposableCollection(); - - constructor(protected services: MenuServices) { - super(); - } - - registerActionMenu(menu: MenuNode & CommandMenuNode, args: unknown[]): void { - const { commandRegistry } = this.services; - const command = commandRegistry.getCommand(menu.command); - if (!command) { - return; - } - const { id } = command; - if (this.actions.has(id)) { - return; - } - this.actions.set(id, [menu, args]); - } - - snapshot(menuPath: MenuPath): this { - this.toDispose.dispose(); - for (const [menu, args] of this.actions.values()) { - this.toDispose.push(this.registerCommand(menu, args, menuPath)); - } - return this; - } - - 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; - } - const { id } = command; - if (this.hasCommand(id)) { - // several menu items can be registered for the same command in different contexts - return Disposable.NULL; - } - - // We freeze the `isEnabled`, `isVisible`, and `isToggled` states so they won't change. - 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: () => commandExecutor.executeCommand(menuPath, id, ...args), - label: menu.label, - iconClass: menu.icon, - isEnabled: () => enabled, - isVisible: () => visible, - isToggled: () => toggled - }); - - const bindings = keybindingRegistry.getKeybindingsForCommand(id); - // Only consider the first active keybinding. - if (bindings.length) { - const binding = bindings.length > 1 ? - bindings.find(b => !b.when || this.services.contextKeyService.match(b.when)) ?? bindings[0] : - bindings[0]; - const keys = keybindingRegistry.acceleratorFor(binding, ' ', true); - this.addKeyBinding({ - command: id, - keys, - selector: '.lm-Widget' // We have the Lumino dependency anyway. - }); - } - return Disposable.create(() => unregisterCommand.dispose()); - } - -} diff --git a/packages/core/src/browser/menu/composite-menu-node.ts b/packages/core/src/browser/menu/composite-menu-node.ts new file mode 100644 index 0000000000000..9936455437abe --- /dev/null +++ b/packages/core/src/browser/menu/composite-menu-node.ts @@ -0,0 +1,144 @@ +// ***************************************************************************** +// 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContextKeyService } from '../context-key-service'; +import { CompoundMenuNode, ContextExpressionMatcher, Group, MenuNode, MenuPath, Submenu } from '../../common/menu/menu-types'; +import { Event } from '../../common'; + +export class SubMenuLink implements CompoundMenuNode { + constructor(private readonly delegate: Submenu, private readonly _sortString?: string, private readonly _when?: string) { } + + get id(): string { return this.delegate.id; }; + get onDidChange(): Event | undefined { return this.delegate.onDidChange; }; + get children(): MenuNode[] { return this.delegate.children; } + get contextKeyOverlays(): Record | undefined { return this.delegate.contextKeyOverlays; } + get label(): string { return this.delegate.label; }; + get icon(): string | undefined { return this.delegate.icon; }; + + get sortString(): string { return this._sortString || this.delegate.sortString; }; + isVisible(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean { + return this.delegate.isVisible(effectiveMenuPath, contextMatcher, context) && this._when ? contextMatcher.match(this._when, context) : true; + } + + isEmpty(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean { + return this.delegate.isEmpty(effectiveMenuPath, contextMatcher, context, args); + } +} + +/** + * Node representing a (sub)menu in the menu tree structure. + */ +export abstract class AbstractCompoundMenuImpl implements MenuNode { + readonly children: MenuNode[] = []; + + protected constructor( + protected readonly contextKeyService: ContextKeyService, + readonly id: string, + protected readonly orderString?: string, + protected readonly when?: string + ) { + } + + getOrCreate(menuPath: MenuPath, pathIndex: number, endIndex: number): CompoundMenuImpl { + if (pathIndex === endIndex) { + return this; + } + let child = this.getNode(menuPath[pathIndex]); + if (!child) { + child = new GroupImpl(this.contextKeyService, menuPath[pathIndex]); + this.addNode(child); + } + if (child instanceof AbstractCompoundMenuImpl) { + return child.getOrCreate(menuPath, pathIndex + 1, endIndex); + } else { + throw new Error(`An item exists, but it's not a parent: ${menuPath} at ${pathIndex}`); + } + + } + + /** + * Menu nodes are sorted in ascending order based on their `sortString`. + */ + isVisible(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean { + return (!this.when || contextMatcher.match(this.when, context)); + } + + isEmpty(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean { + for (const child of this.children) { + if (child.isVisible(effectiveMenuPath, contextMatcher, context, args)) { + if (!CompoundMenuNode.is(child) || !child.isEmpty(effectiveMenuPath, contextMatcher, context, args)) { + return false; + } + } + } + return true; + } + + addNode(...node: MenuNode[]): void { + this.children.push(...node); + this.children.sort(CompoundMenuNode.sortChildren); + } + + getNode(id: string): MenuNode | undefined { + return this.children.find(node => node.id === id); + } + + removeById(id: string): void { + const idx = this.children.findIndex(node => node.id === id); + if (idx >= 0) { + this.children.splice(idx, 1); + } + } + + removeNode(node: MenuNode): void { + const idx = this.children.indexOf(node); + if (idx >= 0) { + this.children.splice(idx, 1); + } + } + + get sortString(): string { + return this.orderString || this.id; + } +} + +export class GroupImpl extends AbstractCompoundMenuImpl implements Group { + constructor( + contextKeyService: ContextKeyService, + id: string, + orderString?: string, + when?: string + ) { + super(contextKeyService, id, orderString, when); + } +} + +export class SubmenuImpl extends AbstractCompoundMenuImpl implements Submenu { + + constructor( + contextKeyService: ContextKeyService, + id: string, + readonly label: string, + readonly contextKeyOverlays: Record | undefined, + orderString?: string, + readonly icon?: string, + when?: string, + ) { + super(contextKeyService, id, orderString, when); + } +} + +export type CompoundMenuImpl = SubmenuImpl | GroupImpl; diff --git a/packages/core/src/common/menu/menu.spec.ts b/packages/core/src/browser/menu/menu.spec.ts similarity index 80% rename from packages/core/src/common/menu/menu.spec.ts rename to packages/core/src/browser/menu/menu.spec.ts index 650ae274574d0..c3e44be150343 100644 --- a/packages/core/src/common/menu/menu.spec.ts +++ b/packages/core/src/browser/menu/menu.spec.ts @@ -15,9 +15,8 @@ // ***************************************************************************** import * as chai from 'chai'; -import { CommandContribution, CommandRegistry } from '../command'; -import { CompositeMenuNode } from './composite-menu-node'; -import { MenuContribution, MenuModelRegistry } from './menu-model-registry'; +import { CommandContribution, CommandRegistry, CompoundMenuNode, MenuContribution, MenuModelRegistry, Submenu } from '../../common'; +import { BrowserMenuNodeFactory } from './browser-menu-node-factory'; const expect = chai.expect; @@ -49,15 +48,15 @@ describe('menu-model-registry', () => { }); } }); - const all = service.getMenu(); - const main = all.children[0] as CompositeMenuNode; + const all = service.getMenu([])!; + const main = all.children[0] as CompoundMenuNode; expect(main.children.length).equals(1); expect(main.id, 'main'); expect(all.children.length).equals(1); - const file = main.children[0] as CompositeMenuNode; + const file = main.children[0] as Submenu; expect(file.children.length).equals(1); expect(file.label, 'File'); - const openGroup = file.children[0] as CompositeMenuNode; + const openGroup = file.children[0] as Submenu; expect(openGroup.children.length).equals(2); expect(openGroup.label).undefined; }); @@ -70,15 +69,15 @@ describe('menu-model-registry', () => { registerMenus(menuRegistry: MenuModelRegistry): void { menuRegistry.registerSubmenu(fileMenu, 'File'); // open menu should not be added to open menu - menuRegistry.linkSubmenu(fileOpenMenu, fileOpenMenu); + menuRegistry.linkCompoundMenuNode(fileOpenMenu, fileOpenMenu); // close menu should be added - menuRegistry.linkSubmenu(fileOpenMenu, fileCloseMenu); + menuRegistry.linkCompoundMenuNode(fileOpenMenu, fileCloseMenu); } }, { registerCommands(reg: CommandRegistry): void { } }); - const all = service.getMenu() as CompositeMenuNode; - expect(menuStructureToString(all.children[0] as CompositeMenuNode)).equals('File(0_open(1_close),1_close())'); + const all = service.getMenu([]) as CompoundMenuNode; + expect(menuStructureToString(all.children[0] as CompoundMenuNode)).equals('File(0_open(1_close),1_close())'); }); }); }); @@ -86,14 +85,14 @@ describe('menu-model-registry', () => { function createMenuRegistry(menuContrib: MenuContribution, commandContrib: CommandContribution): MenuModelRegistry { const cmdReg = new CommandRegistry({ getContributions: () => [commandContrib] }); cmdReg.onStart(); - const menuReg = new MenuModelRegistry({ getContributions: () => [menuContrib] }, cmdReg); + const menuReg = new MenuModelRegistry({ getContributions: () => [menuContrib] }, cmdReg, new BrowserMenuNodeFactory()); menuReg.onStart(); return menuReg; } -function menuStructureToString(node: CompositeMenuNode): string { +function menuStructureToString(node: CompoundMenuNode): string { return node.children.map(c => { - if (c instanceof CompositeMenuNode) { + if (CompoundMenuNode.is(c)) { return `${c.id}(${menuStructureToString(c)})`; } return c.id; diff --git a/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx b/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx index e1229e0112db2..f43f2f36390f2 100644 --- a/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx +++ b/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx @@ -33,7 +33,8 @@ export class SidebarBottomMenuWidget extends SidebarMenuWidget { x: button.left + button.width, y: button.top + button.height, }, - context: e.currentTarget + context: e.currentTarget, + contextKeyService: this.contextKeyService }); } diff --git a/packages/core/src/browser/shell/sidebar-menu-widget.tsx b/packages/core/src/browser/shell/sidebar-menu-widget.tsx index b3cdf48e16dad..e5e39de542219 100644 --- a/packages/core/src/browser/shell/sidebar-menu-widget.tsx +++ b/packages/core/src/browser/shell/sidebar-menu-widget.tsx @@ -18,9 +18,10 @@ import { injectable, inject } from 'inversify'; import * as React from 'react'; import { ReactWidget } from '../widgets'; import { ContextMenuRenderer } from '../context-menu-renderer'; -import { MenuPath } from '../../common/menu'; +import { CompoundMenuNode, MenuModelRegistry, MenuPath } from '../../common/menu'; import { HoverService } from '../hover-service'; import { Event, Disposable, Emitter, DisposableCollection } from '../../common'; +import { ContextKeyService } from '../context-key-service'; export const SidebarTopMenuWidgetFactory = Symbol('SidebarTopMenuWidgetFactory'); export const SidebarBottomMenuWidgetFactory = Symbol('SidebarBottomMenuWidgetFactory'); @@ -90,9 +91,15 @@ export class SidebarMenuWidget extends ReactWidget { @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(MenuModelRegistry) + protected readonly menuRegistry: MenuModelRegistry; + @inject(HoverService) protected readonly hoverService: HoverService; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + constructor() { super(); this.items = []; @@ -145,14 +152,17 @@ export class SidebarMenuWidget extends ReactWidget { protected onClick(e: React.MouseEvent, menuPath: MenuPath): void { this.preservingContext = true; const button = e.currentTarget.getBoundingClientRect(); + const menu = this.menuRegistry.getMenuNode(menuPath) as CompoundMenuNode this.contextMenuRenderer.render({ - menuPath, + menuPath: menuPath, + menu: menu, includeAnchorArg: false, anchor: { x: button.left + button.width, y: button.top, }, context: e.currentTarget, + contextKeyService: this.contextKeyService, onHide: () => { this.preservingContext = false; if (this.preservedContext) { 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 deleted file mode 100644 index 261fbd4bbf9f5..0000000000000 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts +++ /dev/null @@ -1,31 +0,0 @@ -// ***************************************************************************** -// 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-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { MenuNode, MenuPath } from '../../../common'; -import { NAVIGATION, RenderedToolbarItem } from './tab-bar-toolbar-types'; - -export const TOOLBAR_WRAPPER_ID_SUFFIX = '-as-tabbar-toolbar-item'; - -export class ToolbarMenuNodeWrapper implements RenderedToolbarItem { - constructor(protected readonly menuNode: MenuNode, readonly group: string | undefined, readonly delegateMenuPath: MenuPath, 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-menu-adapters.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx new file mode 100644 index 0000000000000..d0f0db7997637 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx @@ -0,0 +1,252 @@ +// ***************************************************************************** +// 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-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Widget } from '@phosphor/widgets'; +import * as React from 'react'; +import { CommandRegistry, DisposableCollection, Event } from '../../../common'; +import { NAVIGATION, RenderedToolbarAction } from './tab-bar-toolbar-types'; +import { TabBarToolbar, toAnchor } from './tab-bar-toolbar'; +import { ACTION_ITEM, codicon } from '../../widgets'; +import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../../context-menu-renderer'; +import { TabBarToolbarItem } from './tab-toolbar-item'; +import { ContextKeyService, ContextMatcher } from '../../context-key-service'; +import { CommandMenu, CompoundMenuNode, MenuModelRegistry, MenuNode, MenuPath, RenderedMenuNode } from '../../../common/menu'; + +export const TOOLBAR_WRAPPER_ID_SUFFIX = '-as-tabbar-toolbar-item'; + +abstract class AbstractToolbarMenuWrapper { + + constructor( + protected readonly effectiveMenuPath: MenuPath, + protected readonly commandRegistry: CommandRegistry, + protected readonly menuRegistry: MenuModelRegistry, + protected readonly contextKeyService: ContextKeyService, + protected readonly contextMenuRenderer: ContextMenuRenderer) { + } + + protected abstract menuPath?: MenuPath; + protected abstract menuNode: MenuNode; + protected abstract id: string; + protected abstract icon: string | undefined; + protected abstract tooltip: string | undefined; + protected abstract text: string | undefined; + protected abstract executeCommand(e: React.MouseEvent): void; + + isEnabled(): boolean { + if (CommandMenu.is(this.menuNode)) { + return this.menuNode.isEnabled(this.effectiveMenuPath); + } + return true; + } + isToggled(): boolean { + if (CommandMenu.is(this.menuNode) && this.menuNode.isToggled) { + return !!this.menuNode.isToggled(this.effectiveMenuPath); + } + return false; + } + render(widget: Widget): React.ReactNode { + return this.renderMenuItem(widget); + } + + toMenuNode?(): MenuNode { + return this.menuNode; + } + + /** + * Presents the menu to popup on the `event` that is the clicking of + * a menu toolbar item. + * + * @param menuPath the path of the registered menu to show + * @param event the mouse event triggering the menu + */ + showPopupMenu(widget: Widget | undefined, menuPath: MenuPath, event: React.MouseEvent, contextMatcher: ContextMatcher): void { + event.stopPropagation(); + event.preventDefault(); + const anchor = toAnchor(event); + this.renderPopupMenu(widget, menuPath, this.menuNode as CompoundMenuNode, anchor, contextMatcher); + } + + /** + * Renders the menu popped up on a menu toolbar item. + * + * @param menuPath the path of the registered menu to render + * @param anchor a description of where to render the menu + * @returns platform-specific access to the rendered context menu + */ + protected renderPopupMenu(widget: Widget | undefined, menuPath: MenuPath, menu: CompoundMenuNode, anchor: Anchor, contextMatcher: ContextMatcher): ContextMenuAccess { + const toDisposeOnHide = new DisposableCollection(); + + return this.contextMenuRenderer.render({ + menuPath: menuPath, + menu: menu, + args: [widget], + anchor, + context: widget?.node, + contextKeyService: contextMatcher, + onHide: () => toDisposeOnHide.dispose() + }); + } + + /** + * Renders a toolbar item that is a menu, presenting it as a button with a little + * chevron decoration that pops up a floating menu when clicked. + * + * @param item a toolbar item that is a menu item + * @returns the rendered toolbar item + */ + protected renderMenuItem(widget: Widget): React.ReactNode { + const icon = this.icon || 'ellipsis'; + const contextMatcher: ContextMatcher = this.contextKeyService; + if (CompoundMenuNode.is(this.menuNode) && !this.menuNode.isEmpty(this.effectiveMenuPath, this.contextKeyService, widget.node)) { + + return
+
this.executeCommand(e)} + /> +
this.showPopupMenu(widget, this.menuPath!, event, contextMatcher)} > +
+
+
; + } else { + return
+
this.executeCommand(e)} + /> +
; + } + } +} + +export class ToolbarMenuNodeWrapper extends AbstractToolbarMenuWrapper implements TabBarToolbarItem { + constructor( + effectiveMenuPath: MenuPath, + commandRegistry: CommandRegistry, + menuRegistry: MenuModelRegistry, + contextKeyService: ContextKeyService, + contextMenuRenderer: ContextMenuRenderer, + protected readonly menuNode: MenuNode & RenderedMenuNode, + readonly group: string | undefined, + readonly menuPath?: MenuPath) { + super(effectiveMenuPath, commandRegistry, menuRegistry, contextKeyService, contextMenuRenderer); + } + + executeCommand(e: React.MouseEvent): void { + if (CommandMenu.is(this.menuNode)) { + this.menuNode.run(this.effectiveMenuPath); + } + } + + isVisible(widget: Widget): boolean { + const menuNodeVisible = this.menuNode.isVisible(this.effectiveMenuPath, this.contextKeyService, widget.node); + if (CommandMenu.is(this.menuNode)) { + return menuNodeVisible; + } else if (CompoundMenuNode.is(this.menuNode)) { + return menuNodeVisible && !MenuModelRegistry.isEmpty(this.menuNode); + } else { + return menuNodeVisible; + } + } + + get id(): string { return this.menuNode.id + TOOLBAR_WRAPPER_ID_SUFFIX; } + get icon(): string | undefined { return this.menuNode.icon; } + get tooltip(): string | undefined { return this.menuNode.label; } + get text(): string | undefined { + return (this.group === NAVIGATION || this.group === undefined) ? undefined : this.menuNode.label; + } + get onDidChange(): Event | undefined { + return this.menuNode.onDidChange; + } +} + +export class ToolbarSubmenuWrapper extends AbstractToolbarMenuWrapper implements TabBarToolbarItem { + constructor( + effectiveMenuPath: MenuPath, + commandRegistry: CommandRegistry, + menuRegistry: MenuModelRegistry, + contextKeyService: ContextKeyService, + contextMenuRenderer: ContextMenuRenderer, + protected readonly toolbarItem: RenderedToolbarAction + ) { + super(effectiveMenuPath, commandRegistry, menuRegistry, contextKeyService, contextMenuRenderer); + } + + override isEnabled(widget?: Widget): boolean { + return this.toolbarItem.command ? this.commandRegistry.isEnabled(this.toolbarItem.command, widget) : !!this.toolbarItem.menuPath; + } + + protected executeCommand(e: React.MouseEvent, widget?: Widget): void { + e.preventDefault(); + e.stopPropagation(); + + if (!this.isEnabled(widget)) { + return; + } + + if (this.toolbarItem.command) { + this.commandRegistry.executeCommand(this.toolbarItem.command, widget); + } + }; + + isVisible(widget: Widget): boolean { + const menuNode = this.menuNode; + if (this.toolbarItem.isVisible && !this.toolbarItem.isVisible(widget)) { + return false; + } + if (!menuNode.isVisible(this.effectiveMenuPath, this.contextKeyService, widget.node, widget)) { + return false; + } + if (this.toolbarItem.command) { + return true; + } + if (CompoundMenuNode.is(menuNode)) { + return !menuNode.isEmpty(this.effectiveMenuPath, this.contextKeyService, widget.node, widget); + } + return true; + } + group?: string | undefined; + priority?: number | undefined; + + get id(): string { return this.toolbarItem.id; } + get icon(): string | undefined { + if (typeof this.toolbarItem.icon === 'function') { + return this.toolbarItem.icon(); + } + if (this.toolbarItem.icon) { + return this.toolbarItem.icon; + } + if (this.toolbarItem.command) { + const command = this.commandRegistry.getCommand(this.toolbarItem.command); + return command?.iconClass; + } + return undefined; + } + get tooltip(): string | undefined { return this.toolbarItem.tooltip; } + get text(): string | undefined { return (this.toolbarItem.group === NAVIGATION || this.toolbarItem.group === undefined) ? undefined : this.toolbarItem.text; } + get onDidChange(): Event | undefined { + return this.menuNode.onDidChange; + } + + get menuPath(): MenuPath { + return this.toolbarItem.menuPath!; + } + + get menuNode(): MenuNode { + return this.menuRegistry.getMenu(this.menuPath); + } +} + diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index e10afb4a0c09e..82cb6461cd28d 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -17,12 +17,17 @@ import debounce = require('lodash.debounce'); import { inject, injectable, named } from 'inversify'; // eslint-disable-next-line max-len -import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuNode, MenuPath } from '../../../common'; +import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuPath } from '../../../common'; import { ContextKeyService } from '../../context-key-service'; import { FrontendApplicationContribution } from '../../frontend-application-contribution'; import { Widget } from '../../widgets'; -import { MenuDelegate, ReactTabBarToolbarItem, RenderedToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; -import { ToolbarMenuNodeWrapper } from './tab-bar-toolbar-menu-adapters'; +import { ReactTabBarToolbarAction, RenderedToolbarAction } from './tab-bar-toolbar-types'; +import { ToolbarMenuNodeWrapper, ToolbarSubmenuWrapper } from './tab-bar-toolbar-menu-adapters'; +import { KeybindingRegistry } from '../../keybinding'; +import { LabelParser } from '../../label-parser'; +import { ContextMenuRenderer } from '../../context-menu-renderer'; +import { CommandMenu, CompoundMenuNode, RenderedMenuNode } from '../../../common/menu'; +import { ReactToolbarItemImpl, RenderedToolbarItemImpl, TabBarToolbarItem } from './tab-toolbar-item'; /** * Clients should implement this interface if they want to contribute to the tab-bar toolbar. @@ -39,21 +44,26 @@ export interface TabBarToolbarContribution { registerToolbarItems(registry: TabBarToolbarRegistry): void; } -function yes(): true { return true; } const menuDelegateSeparator = '=@='; - +interface MenuDelegate { + menuPath: MenuPath; + isVisible(widget?: Widget): boolean; +} /** * Main, shared registry for tab-bar toolbar items. */ @injectable() export class TabBarToolbarRegistry implements FrontendApplicationContribution { - protected items = new Map(); + 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(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry; + @inject(LabelParser) protected readonly labelParser: LabelParser; + @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; @inject(ContributionProvider) @named(TabBarToolbarContribution) protected readonly contributionProvider: ContributionProvider; @@ -75,16 +85,30 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { * * @param item the item to register. */ - registerItem(item: RenderedToolbarItem | ReactTabBarToolbarItem): Disposable { - const { id } = item; - if (this.items.has(id)) { - throw new Error(`A toolbar item is already registered with the '${id}' ID.`); + registerItem(item: RenderedToolbarAction | ReactTabBarToolbarAction): Disposable { + if (ReactTabBarToolbarAction.is(item)) { + return this.doRegisterItem(new ReactToolbarItemImpl(this.commandRegistry, this.contextKeyService, item)); + } else { + if (item.menuPath) { + return this.doRegisterItem(new ToolbarSubmenuWrapper(item.menuPath, + this.commandRegistry, this.menuRegistry, this.contextKeyService, this.contextMenuRenderer, item)); + } else { + return this.doRegisterItem(new RenderedToolbarItemImpl(this.commandRegistry, this.contextKeyService, this.keybindingRegistry, this.labelParser, item)); + } + } + } + + doRegisterItem(item: TabBarToolbarItem): Disposable { + if (this.items.has(item.id)) { + throw new Error(`A toolbar item is already registered with the '${item.id}' ID.`); } - this.items.set(id, item); + this.items.set(item.id, item); this.fireOnDidChange(); const toDispose = new DisposableCollection( Disposable.create(() => this.fireOnDidChange()), - Disposable.create(() => this.items.delete(id)) + Disposable.create(() => { + this.items.delete(item.id); + }) ); if (item.onDidChange) { toDispose.push(item.onDidChange(() => this.fireOnDidChange())); @@ -97,31 +121,32 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { * * By default returns with all items where the command is enabled and `item.isVisible` is `true`. */ - visibleItems(widget: Widget): Array { + visibleItems(widget: Widget): Array { if (widget.isDisposed) { return []; } - const result: Array = []; + const result: Array = []; for (const item of this.items.values()) { - if (this.isItemVisible(item, widget)) { + if (item.isVisible(widget)) { result.push(item); } } + for (const delegate of this.menuDelegates.values()) { if (delegate.isVisible(widget)) { - const menu = this.menuRegistry.getMenu(delegate.menuPath); + const menu = this.menuRegistry.getMenu(delegate.menuPath)!; for (const child of menu.children) { - if (!child.when || this.contextKeyService.match(child.when, widget.node)) { - if (child.children) { + if (child.isVisible([...delegate.menuPath, child.id], this.contextKeyService, widget.node)) { + if (CompoundMenuNode.is(child)) { for (const grandchild of child.children) { - if (!grandchild.when || this.contextKeyService.match(grandchild.when, widget.node)) { - const menuPath = this.menuRegistry.getPath(grandchild); - result.push(new ToolbarMenuNodeWrapper(grandchild, child.id, delegate.menuPath, menuPath)); + if (grandchild.isVisible([...delegate.menuPath, child.id, grandchild.id], this.contextKeyService, widget.node) && RenderedMenuNode.is(grandchild)) { + result.push(new ToolbarMenuNodeWrapper([...delegate.menuPath, child.id, grandchild.id], this.commandRegistry, this.menuRegistry, + this.contextKeyService, this.contextMenuRenderer, grandchild, child.id, delegate.menuPath)); } } - } else if (child.command) { - const menuPath = this.menuRegistry.getPath(child); - result.push(new ToolbarMenuNodeWrapper(child, undefined, delegate.menuPath, menuPath)); + } else if (CommandMenu.is(child)) { + result.push(new ToolbarMenuNodeWrapper([...delegate.menuPath, child.id], this.commandRegistry, this.menuRegistry, + this.contextKeyService, this.contextMenuRenderer, child, undefined, delegate.menuPath)); } } } @@ -130,77 +155,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { return result; } - /** - * Query whether a toolbar `item` should be shown in the toolbar. - * This implementation delegates to item-specific checks according to their type. - * - * @param item a menu toolbar item - * @param widget the widget that is updating the toolbar - * @returns `false` if the `item` should be suppressed, otherwise `true` - */ - protected isItemVisible(item: TabBarToolbarItem | ReactTabBarToolbarItem, widget: Widget): boolean { - if (!this.isConditionalItemVisible(item, widget)) { - return false; - } - - if (item.command && !this.commandRegistry.isVisible(item.command, widget)) { - return false; - } - if (item.menuPath && !this.isNonEmptyMenu(item, widget)) { - return false; - } - - // The item is not vetoed. Accept it - return true; - } - - /** - * Query whether a conditional toolbar `item` should be shown in the toolbar. - * This implementation delegates to the `item`'s own intrinsic conditionality. - * - * @param item a menu toolbar item - * @param widget the widget that is updating the toolbar - * @returns `false` if the `item` should be suppressed, otherwise `true` - */ - protected isConditionalItemVisible(item: TabBarToolbarItem, widget: Widget): boolean { - if (item.isVisible && !item.isVisible(widget)) { - return false; - } - if (item.when && !this.contextKeyService.match(item.when, widget.node)) { - return false; - } - return true; - } - - /** - * Query whether a menu toolbar `item` should be shown in the toolbar. - * This implementation returns `false` if the `item` does not have any actual menu to show. - * - * @param item a menu toolbar item - * @param widget the widget that is updating the toolbar - * @returns `false` if the `item` should be suppressed, otherwise `true` - */ - isNonEmptyMenu(item: TabBarToolbarItem, widget: Widget | undefined): boolean { - if (!item.menuPath) { - return false; - } - const menu = this.menuRegistry.getMenu(item.menuPath); - const isVisible: (node: MenuNode) => boolean = node => - node.children?.length - // Either the node is a sub-menu that has some visible child ... - ? node.children?.some(isVisible) - // ... or there is a command ... - : !!node.command - // ... that is visible ... - && this.commandRegistry.isVisible(node.command, widget) - // ... and a "when" clause does not suppress the menu node. - && (!node.when || this.contextKeyService.match(node.when, widget?.node)); - - return isVisible(menu); - } - - unregisterItem(itemOrId: TabBarToolbarItem | ReactTabBarToolbarItem | string): void { - const id = typeof itemOrId === 'string' ? itemOrId : itemOrId.id; + unregisterItem(id: string): void { if (this.items.delete(id)) { this.fireOnDidChange(); } @@ -209,12 +164,10 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { registerMenuDelegate(menuPath: MenuPath, when?: ((widget: Widget) => boolean)): Disposable { const id = this.toElementId(menuPath); 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.menuDelegates.set(id, { + menuPath, isVisible: (widget: Widget) => !when || when(widget) + }); this.fireOnDidChange(); return { dispose: () => this.unregisterMenuDelegate(menuPath) }; } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts index c9db6e3b18027..f383689400b30 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -15,8 +15,9 @@ // ***************************************************************************** import * as React from 'react'; -import { ArrayUtils, Event, isFunction, isObject, MenuPath } from '../../../common'; +import { ArrayUtils, Event, isFunction, isObject } from '../../../common'; import { Widget } from '../../widgets'; +import { MenuPath } from '../../../common/menu'; /** Items whose group is exactly 'navigation' will be rendered inline. */ export const NAVIGATION = 'navigation'; @@ -32,12 +33,12 @@ export namespace TabBarDelegator { } } -export type TabBarToolbarItem = RenderedToolbarItem | ReactTabBarToolbarItem; +export type TabBarToolbarAction = RenderedToolbarAction | ReactTabBarToolbarAction; /** * Representation of an item in the tab */ -export interface TabBarToolbarItemBase { +export interface TabBarToolbarActionBase { /** * The unique ID of the toolbar item. */ @@ -55,6 +56,7 @@ export interface TabBarToolbarItemBase { * Checked before the item is shown. */ isVisible?(widget?: Widget): boolean; + /** * When defined, the container tool-bar will be updated if this event is fired. * @@ -69,22 +71,16 @@ export interface TabBarToolbarItemBase { group?: string; /** * 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; - /** - * The path of the menu delegate that contributed this toolbar item - */ - delegateMenuPath?: MenuPath; - contextKeyOverlays?: Record; + /** * Optional ordering string for placing the item within its group */ order?: string; } -export interface RenderedToolbarItem extends TabBarToolbarItemBase { +export interface RenderedToolbarAction extends TabBarToolbarActionBase { /** * Optional icon for the item. */ @@ -110,29 +106,24 @@ export interface RenderedToolbarItem extends TabBarToolbarItemBase { /** * Tab-bar toolbar item backed by a `React.ReactNode`. - * Unlike the `TabBarToolbarItem`, this item is not connected to the command service. + * Unlike the `TabBarToolbarAction`, this item is not connected to the command service. */ -export interface ReactTabBarToolbarItem extends TabBarToolbarItemBase { +export interface ReactTabBarToolbarAction extends TabBarToolbarActionBase { render(widget?: Widget): React.ReactNode; } -export namespace ReactTabBarToolbarItem { - export function is(item: TabBarToolbarItem): item is ReactTabBarToolbarItem { - return isObject(item) && typeof item.render === 'function'; +export namespace ReactTabBarToolbarAction { + export function is(item: TabBarToolbarAction): item is ReactTabBarToolbarAction { + return isObject(item) && typeof item.render === 'function'; } } -export interface MenuDelegate { - menuPath: MenuPath; - isVisible(widget?: Widget): boolean; -} - -export namespace TabBarToolbarItem { +export namespace TabBarToolbarAction { /** * Compares the items by `priority` in ascending. Undefined priorities will be treated as `0`. */ - export const PRIORITY_COMPARATOR = (left: TabBarToolbarItem, right: TabBarToolbarItem) => { + export const PRIORITY_COMPARATOR = (left: { group?: string, priority?: number }, right: { group?: string, priority?: number }) => { const leftGroup: string = left.group ?? NAVIGATION; const rightGroup: string = right.group ?? NAVIGATION; if (leftGroup === NAVIGATION && rightGroup !== NAVIGATION) { diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts index 7c4c0631d645a..6736f6b0b1f0f 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts @@ -18,7 +18,7 @@ import { enableJSDOM } from '../../test/jsdom'; let disableJSDOM = enableJSDOM(); import { expect } from 'chai'; -import { TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { TabBarToolbarAction } from './tab-bar-toolbar-types'; disableJSDOM(); @@ -34,27 +34,27 @@ describe('tab-bar-toolbar', () => { disableJSDOM(); }); - const testMe = TabBarToolbarItem.PRIORITY_COMPARATOR; + const testMe = TabBarToolbarAction.PRIORITY_COMPARATOR; it("should favour the 'navigation' group before everything else", () => { - expect(testMe({ id: 'a', command: 'a', group: 'navigation' }, { id: 'b', command: 'b', group: 'other' })).to.be.equal(-1); + expect(testMe({ group: 'navigation' }, { group: 'other' })).to.be.equal(-1); }); it("should treat 'undefined' groups as 'navigation'", () => { - expect(testMe({ id: 'a', command: 'a' }, { id: 'b', command: 'b' })).to.be.equal(0); - expect(testMe({ id: 'a', command: 'a', group: 'navigation' }, { id: 'b', command: 'b' })).to.be.equal(0); - expect(testMe({ id: 'a', command: 'a' }, { id: 'b', command: 'b', group: 'navigation' })).to.be.equal(0); - expect(testMe({ id: 'a', command: 'a' }, { id: 'b', command: 'b', group: 'other' })).to.be.equal(-1); + expect(testMe({}, {})).to.be.equal(0); + expect(testMe({ group: 'navigation' }, {})).to.be.equal(0); + expect(testMe({}, { group: 'navigation' })).to.be.equal(0); + expect(testMe({}, { group: 'other' })).to.be.equal(-1); }); it("should fall back to 'priority' if the groups are the same", () => { - expect(testMe({ id: 'a', command: 'a', priority: 1 }, { id: 'b', command: 'b', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', group: 'navigation', priority: 1 }, { id: 'b', command: 'b', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', priority: 1 }, { id: 'b', command: 'b', group: 'navigation', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', priority: 1, group: 'other' }, { id: 'b', command: 'b', priority: 2 })).to.be.equal(1); - expect(testMe({ id: 'a', command: 'a', group: 'other', priority: 1 }, { id: 'b', command: 'b', priority: 2, group: 'other' })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', priority: 10 }, { id: 'b', command: 'b', group: 'other', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', group: 'other', priority: 10 }, { id: 'b', command: 'b', group: 'other', priority: 10 })).to.be.equal(0); + expect(testMe({ priority: 1 }, { priority: 2 })).to.be.equal(-1); + expect(testMe({ group: 'navigation', priority: 1 }, { priority: 2 })).to.be.equal(-1); + expect(testMe({ priority: 1 }, { group: 'navigation', priority: 2 })).to.be.equal(-1); + expect(testMe({ priority: 1, group: 'other' }, { priority: 2 })).to.be.equal(1); + expect(testMe({ group: 'other', priority: 1 }, { priority: 2, group: 'other' })).to.be.equal(-1); + expect(testMe({ priority: 10 }, { group: 'other', priority: 2 })).to.be.equal(-1); + expect(testMe({ group: 'other', priority: 10 }, { group: 'other', priority: 10 })).to.be.equal(0); }); }); diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index ba21e14ada6e0..4a0db6f93bcd7 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -16,14 +16,16 @@ import { inject, injectable, postConstruct } from 'inversify'; import * as React from 'react'; -import { ContextKeyService, ContextMatcher } from '../../context-key-service'; -import { CommandRegistry, Disposable, DisposableCollection, MenuCommandExecutor, MenuModelRegistry, MenuPath, nls } from '../../../common'; +import { ContextKeyService } from '../../context-key-service'; +import { CommandRegistry, Disposable, DisposableCollection, 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 { LabelParser } from '../../label-parser'; +import { codicon, ReactWidget, Widget } from '../../widgets'; import { TabBarToolbarRegistry } from './tab-bar-toolbar-registry'; -import { ReactTabBarToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU, RenderedToolbarItem } from './tab-bar-toolbar-types'; +import { TabBarDelegator, TabBarToolbarAction } from './tab-bar-toolbar-types'; import { KeybindingRegistry } from '../..//keybinding'; +import { TabBarToolbarItem } from './tab-toolbar-item'; +import { GroupImpl, MenuModelRegistry } from '../../../common/menu'; /** * Factory for instantiating tab-bar toolbars. @@ -33,10 +35,10 @@ export interface TabBarToolbarFactory { (): TabBarToolbar; } -/** - * Class name indicating rendering of a toolbar item without an icon but instead with a text label. - */ -const NO_ICON_CLASS = 'no-icon'; +export function 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; +} /** * Tab-bar toolbar widget representing the active [tab-bar toolbar items](TabBarToolbarItem). @@ -45,7 +47,7 @@ const NO_ICON_CLASS = 'no-icon'; export class TabBarToolbar extends ReactWidget { protected current: Widget | undefined; - protected inline = new Map(); + protected inline = new Map(); protected more = new Map(); protected contextKeyListener: Disposable | undefined; @@ -56,7 +58,6 @@ export class TabBarToolbar extends ReactWidget { @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; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @@ -79,34 +80,25 @@ export class TabBarToolbar extends ReactWidget { })); } - updateItems(items: Array, current: Widget | undefined): void { + updateItems(items: Array, current: Widget | undefined): void { this.toDisposeOnUpdateItems.dispose(); this.toDisposeOnUpdateItems = new DisposableCollection(); this.inline.clear(); this.more.clear(); - const contextKeys = new Set(); - for (const item of items.sort(TabBarToolbarItem.PRIORITY_COMPARATOR).reverse()) { - if (item.command) { - this.commands.getAllHandlers(item.command).forEach(handler => { - if (handler.onDidChangeEnabled) { - this.toDisposeOnUpdateItems.push(handler.onDidChangeEnabled(() => this.maybeUpdate())); - } - }); - } - if ('render' in item || item.group === undefined || item.group === 'navigation') { + for (const item of items.sort(TabBarToolbarAction.PRIORITY_COMPARATOR).reverse()) { + + if (!('toMenuNode' in item) || item.group === undefined || item.group === 'navigation') { this.inline.set(item.id, item); } else { this.more.set(item.id, item); } - if (item.when) { - this.contextKeyService.parseKeys(item.when)?.forEach(key => contextKeys.add(key)); + if (item.onDidChange) { + this.toDisposeOnUpdateItems.push(item.onDidChange(() => this.maybeUpdate())); } } - this.updateContextKeyListener(contextKeys); - this.setCurrent(current); if (items.length) { this.show(); @@ -139,124 +131,14 @@ export class TabBarToolbar extends ReactWidget { } } - protected updateContextKeyListener(contextKeys: Set): void { - this.contextKeyListener?.dispose(); - if (contextKeys.size > 0) { - this.contextKeyListener = this.contextKeyService.onDidChange(event => { - if (event.affects(contextKeys)) { - this.maybeUpdate(); - } - }); - } - } - protected render(): React.ReactNode { this.keybindingContextKeys.clear(); return {this.renderMore()} - {[...this.inline.values()].map(item => { - if (ReactTabBarToolbarItem.is(item)) { - return item.render(this.current); - } else { - return (item.menuPath && this.toolbarRegistry.isNonEmptyMenu(item, this.current) ? this.renderMenuItem(item) : this.renderItem(item)); - } - })} + {[...this.inline.values()].map(item => item.render(this.current))} ; } - protected resolveKeybindingForCommand(command: string | undefined): string { - let result = ''; - if (command) { - const bindings = this.keybindings.getKeybindingsForCommand(command); - let found = false; - if (bindings && bindings.length > 0) { - bindings.forEach(binding => { - if (binding.when) { - this.contextKeyService.parseKeys(binding.when)?.forEach(key => this.keybindingContextKeys.add(key)); - } - if (!found && this.keybindings.isEnabledInScope(binding, this.current?.node)) { - found = true; - result = ` (${this.keybindings.acceleratorFor(binding, '+')})`; - } - }); - } - } - return result; - } - - protected renderItem(item: RenderedToolbarItem): React.ReactNode { - let innerText = ''; - const classNames = []; - const command = item.command ? this.commands.getCommand(item.command) : undefined; - // Fall back to the item ID in extremis so there is _something_ to render in the - // case that there is neither an icon nor a title - const itemText = item.text || command?.label || command?.id || item.id; - if (itemText) { - for (const labelPart of this.labelParser.parse(itemText)) { - if (LabelIcon.is(labelPart)) { - const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; - classNames.push(...className.split(' ')); - } else { - innerText = labelPart; - } - } - } - const iconClass = (typeof item.icon === 'function' && item.icon()) || item.icon as string || (command && command.iconClass); - if (iconClass) { - classNames.push(iconClass); - } - const tooltipText = item.tooltip || (command && command.label) || ''; - const tooltip = `${this.labelParser.stripIcons(tooltipText)}${this.resolveKeybindingForCommand(command?.id)}`; - - // Only present text if there is no icon - if (classNames.length) { - innerText = ''; - } else if (innerText) { - // Make room for the label text - classNames.push(NO_ICON_CLASS); - } - - // In any case, this is an action item, with or without icon. - classNames.push(ACTION_ITEM); - - const toolbarItemClassNames = this.getToolbarItemClassNames(item); - return
-
this.executeCommand(e, item)} - title={tooltip}>{innerText} -
-
; - } - - protected isEnabled(item: TabBarToolbarItem): boolean { - if (!!item.command) { - return this.commandIsEnabled(item.command) && this.evaluateWhenClause(item.when); - } else { - return !!item.menuPath; - } - } - - protected getToolbarItemClassNames(item: TabBarToolbarItem): string[] { - const classNames = [TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM]; - if (item.command) { - if (this.isEnabled(item)) { - classNames.push('enabled'); - } - if (this.commandIsToggled(item.command)) { - classNames.push('toggled'); - } - } else { - if (this.isEnabled(item)) { - classNames.push('enabled'); - } - } - return classNames; - } - protected renderMore(): React.ReactNode { return !!this.more.size &&
{ event.stopPropagation(); event.preventDefault(); - const anchor = this.toAnchor(event); + const anchor = 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 { + renderMoreContextMenu(anchor: Anchor): 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)); - } else { - for (const item of this.more.values()) { - if (item.menuPath && !item.command) { - toDisposeOnHide.push(this.menus.linkSubmenu(TAB_BAR_TOOLBAR_CONTEXT_MENU, item.menuPath, undefined, 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 as RenderedToolbarItem).tooltip, - commandId: item.command, - when: item.when, - order: item.order - })); + + const menu = new GroupImpl(this.contextKeyService, 'contextMenu'); + for (const item of this.more.values()) { + if (item.toMenuNode) { + const node = item.toMenuNode(); + if (node) { + menu.addNode(node); } } } return this.contextMenuRenderer.render({ - menuPath: TAB_BAR_TOOLBAR_CONTEXT_MENU, + menu: menu!, + menuPath: ['contextMenu'], args: [this.current], anchor, context: this.current?.node || this.node, + contextKeyService: this.contextKeyService, onHide: () => toDisposeOnHide.dispose(), skipSingleRootNode: true, }); } - /** - * Renders a toolbar item that is a menu, presenting it as a button with a little - * chevron decoration that pops up a floating menu when clicked. - * - * @param item a toolbar item that is a menu item - * @returns the rendered toolbar item - */ - protected renderMenuItem(item: RenderedToolbarItem): React.ReactNode { - const command = item.command ? this.commands.getCommand(item.command) : undefined; - const icon = (typeof item.icon === 'function' && item.icon()) || item.icon as string || (command && command.iconClass) || 'ellipsis'; - - let contextMatcher: ContextMatcher = this.contextKeyService; - if (item.contextKeyOverlays) { - contextMatcher = this.contextKeyService.createOverlay(Object.keys(item.contextKeyOverlays).map(key => [key, item.contextKeyOverlays![key]])); - } - - return
-
this.executeCommand(e, item)} - /> -
this.showPopupMenu(item.menuPath!, event, contextMatcher)}> -
-
- -
; - } - - /** - * Presents the menu to popup on the `event` that is the clicking of - * a menu toolbar item. - * - * @param menuPath the path of the registered menu to show - * @param event the mouse event triggering the menu - */ - protected showPopupMenu = (menuPath: MenuPath, event: React.MouseEvent, contextMatcher: ContextMatcher) => { - event.stopPropagation(); - event.preventDefault(); - const anchor = this.toAnchor(event); - this.renderPopupMenu(menuPath, anchor, contextMatcher); - }; - - /** - * Renders the menu popped up on a menu toolbar item. - * - * @param menuPath the path of the registered menu to render - * @param anchor a description of where to render the menu - * @returns platform-specific access to the rendered context menu - */ - protected renderPopupMenu(menuPath: MenuPath, anchor: Anchor, contextMatcher: ContextMatcher): ContextMenuAccess { - const toDisposeOnHide = new DisposableCollection(); - this.addClass('menu-open'); - toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open'))); - - return this.contextMenuRenderer.render({ - menuPath, - args: [this.current], - anchor, - context: this.current?.node || this.node, - contextKeyService: contextMatcher, - onHide: () => toDisposeOnHide.dispose() - }); - } - shouldHandleMouseEvent(event: MouseEvent): boolean { return event.target instanceof Element && this.node.contains(event.target); } @@ -397,39 +195,11 @@ export class TabBarToolbar extends ReactWidget { return whenClause ? this.contextKeyService.match(whenClause, this.current?.node) : true; } - protected executeCommand(e: React.MouseEvent, item: TabBarToolbarItem): void { - e.preventDefault(); - e.stopPropagation(); - - if (!item || !this.isEnabled(item)) { - return; - } - - if (item.command && item.delegateMenuPath) { - this.menuCommandExecutor.executeCommand(item.delegateMenuPath, 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.maybeUpdate(); - }; - protected maybeUpdate(): void { if (!this.isDisposed) { 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 { diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx new file mode 100644 index 0000000000000..4533e823884c3 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx @@ -0,0 +1,238 @@ +// ***************************************************************************** +// Copyright (C) 2024 STMicroelectronics and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContextKeyService } from '../../context-key-service'; +import { ReactTabBarToolbarAction, RenderedToolbarAction, TabBarToolbarActionBase } from './tab-bar-toolbar-types'; +import { Widget } from '@phosphor/widgets'; +import { LabelIcon, LabelParser } from '../../label-parser'; +import { CommandRegistry, Event, Disposable, Emitter } from '../../../common'; +import { KeybindingRegistry } from '../../keybinding'; +import { ACTION_ITEM } from '../../widgets'; +import { TabBarToolbar } from './tab-bar-toolbar'; +import * as React from 'react'; +import { ActionMenuNode, GroupImpl, MenuNode } from '../../../common/menu'; + +export interface TabBarToolbarItem { + id: string; + isVisible(widget: Widget): boolean; + isEnabled(widget?: Widget): boolean; + isToggled(): boolean; + render(widget?: Widget): React.ReactNode; + onDidChange?: Event; + group?: string; + priority?: number; + toMenuNode?(): MenuNode; +} + +/** + * Class name indicating rendering of a toolbar item without an icon but instead with a text label. + */ +const NO_ICON_CLASS = 'no-icon'; + +class AbstractToolbarItemImpl { + constructor( + protected readonly commandRegistry: CommandRegistry, + protected readonly contextKeyService: ContextKeyService, + protected readonly action: T) { + + } + + get id(): string { + return this.action.id; + } + get group(): string | undefined { + return this.action.group; + } + get priority(): number | undefined { + return this.action.priority; + } + + isVisible(widget: Widget): boolean { + if (this.action.isVisible) { + return this.action.isVisible(widget); + } + const actionVisible = !this.action.command || this.commandRegistry.isVisible(this.action.command, widget); + const contextMatches = !this.action.when || this.contextKeyService.match(this.action.when); + + return actionVisible && contextMatches; + } + + isEnabled(widget?: Widget): boolean { + return this.action.command ? this.commandRegistry.isEnabled(this.action.command, widget) : !!this.action.menuPath; + } + isToggled(): boolean { + return this.action.command ? this.commandRegistry.isToggled(this.action.command) : true; + } +} + +export class RenderedToolbarItemImpl extends AbstractToolbarItemImpl implements TabBarToolbarItem { + protected contextKeyListener: Disposable | undefined; + + constructor( + commandRegistry: CommandRegistry, + contextKeyService: ContextKeyService, + protected readonly keybindingRegistry: KeybindingRegistry, + protected readonly labelParser: LabelParser, + action: RenderedToolbarAction) { + super(commandRegistry, contextKeyService, action); + } + + updateContextKeyListener(when: string): void { + const contextKeys = new Set(); + this.contextKeyService.parseKeys(when)?.forEach(key => contextKeys.add(key)); + if (contextKeys.size > 0) { + this.contextKeyListener = this.contextKeyService.onDidChange(change => { + if (change.affects(contextKeys)) { + this.onDidChangeEmitter.fire(); + } + }); + } + } + + render(widget?: Widget | undefined): React.ReactNode { + return this.renderItem(widget); + } + + protected getToolbarItemClassNames(widget?: Widget): string[] { + const classNames = [TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM]; + if (this.isEnabled(widget)) { + classNames.push('enabled'); + } + if (this.isToggled()) { + classNames.push('toggled'); + } + return classNames; + } + + protected resolveKeybindingForCommand(widget: Widget | undefined, command: string | undefined): string { + let result = ''; + if (this.action.command) { + const bindings = this.keybindingRegistry.getKeybindingsForCommand(this.action.command); + let found = false; + if (bindings && bindings.length > 0) { + bindings.forEach(binding => { + if (binding.when) { + this.updateContextKeyListener(binding.when); + } + if (!found && this.keybindingRegistry.isEnabledInScope(binding, widget?.node)) { + found = true; + result = ` (${this.keybindingRegistry.acceleratorFor(binding, '+')})`; + } + }); + } + } + return result; + } + + protected readonly onDidChangeEmitter = new Emitter; + onDidChange: Event = this.onDidChangeEmitter.event; + + toMenuNode?(): MenuNode { + const action = new ActionMenuNode({ + label: this.action.tooltip, + commandId: this.action.command!, + when: this.action.when, + order: this.action.order + }, this.commandRegistry, this.keybindingRegistry, this.contextKeyService); + + // Register a submenu for the item, if the group is in format `//.../` + const menuPath = this.action.group?.split('/') || []; + if (menuPath.length > 1) { + let menu = new GroupImpl(this.contextKeyService, menuPath[0], this.action.order); + menu = menu.getOrCreate(menuPath, 1, menuPath.length); + menu.addNode(action); + return menu; + } + return action; + } + + protected onMouseDownEvent = (e: React.MouseEvent) => { + if (e.button === 0) { + e.currentTarget.classList.add('active'); + } + }; + + protected onMouseUpEvent = (e: React.MouseEvent) => { + e.currentTarget.classList.remove('active'); + }; + + protected executeCommand(e: React.MouseEvent, widget?: Widget): void { + e.preventDefault(); + e.stopPropagation(); + + if (!this.isEnabled(widget)) { + return; + } + + if (this.action.command) { + this.commandRegistry.executeCommand(this.action.command, widget); + } + }; + + protected renderItem(widget?: Widget): React.ReactNode { + let innerText = ''; + const classNames = []; + const command = this.action.command ? this.commandRegistry.getCommand(this.action.command) : undefined; + // Fall back to the item ID in extremis so there is _something_ to render in the + // case that there is neither an icon nor a title + const itemText = this.action.text || command?.label || command?.id || this.action.id; + if (itemText) { + for (const labelPart of this.labelParser.parse(itemText)) { + if (LabelIcon.is(labelPart)) { + const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; + classNames.push(...className.split(' ')); + } else { + innerText = labelPart; + } + } + } + const iconClass = (typeof this.action.icon === 'function' && this.action.icon()) || this.action.icon as string || (command && command.iconClass); + if (iconClass) { + classNames.push(iconClass); + } + const tooltipText = this.action.tooltip || (command && command.label) || ''; + const tooltip = `${this.labelParser.stripIcons(tooltipText)}${this.resolveKeybindingForCommand(widget, command?.id)}`; + + // Only present text if there is no icon + if (classNames.length) { + innerText = ''; + } else if (innerText) { + // Make room for the label text + classNames.push(NO_ICON_CLASS); + } + + // In any case, this is an action item, with or without icon. + classNames.push(ACTION_ITEM); + + const toolbarItemClassNames = this.getToolbarItemClassNames(widget); + return
+
this.executeCommand(e, widget)} + title={tooltip} > {innerText} +
+
; + } +} + +export class ReactToolbarItemImpl extends AbstractToolbarItemImpl implements TabBarToolbarItem { + render(widget?: Widget | undefined): React.ReactNode { + return this.action.render(widget); + } +} diff --git a/packages/core/src/browser/style/view-container.css b/packages/core/src/browser/style/view-container.css index 6b1614f101dca..50770536f1a25 100644 --- a/packages/core/src/browser/style/view-container.css +++ b/packages/core/src/browser/style/view-container.css @@ -16,9 +16,7 @@ :root { --theia-view-container-title-height: var(--theia-content-line-height); - --theia-view-container-content-height: calc( - 100% - var(--theia-view-container-title-height) - ); + --theia-view-container-content-height: calc(100% - var(--theia-view-container-title-height)); } .theia-view-container { @@ -27,36 +25,36 @@ flex-direction: column; } -.theia-view-container > .lm-SplitPanel { +.theia-view-container>.lm-SplitPanel { height: 100%; width: 100%; } -.theia-view-container > .lm-SplitPanel > .lm-SplitPanel-child { +.theia-view-container>.lm-SplitPanel>.lm-SplitPanel-child { min-width: 50px; min-height: var(--theia-content-line-height); } -.theia-view-container > .lm-SplitPanel > .lm-SplitPanel-handle::after { +.theia-view-container>.lm-SplitPanel>.lm-SplitPanel-handle::after { min-height: 2px; min-width: 2px; } -.lm-SplitPanel > .lm-SplitPanel-handle:hover::after { +.lm-SplitPanel>.lm-SplitPanel-handle:hover::after { background-color: var(--theia-sash-hoverBorder); transition-delay: var(--theia-sash-hoverDelay); } -.lm-SplitPanel > .lm-SplitPanel-handle:active::after { +.lm-SplitPanel>.lm-SplitPanel-handle:active::after { background-color: var(--theia-sash-activeBorder); transition-delay: 0s !important; } -.lm-SplitPanel[data-orientation="horizontal"] > .lm-SplitPanel-handle::after { +.lm-SplitPanel[data-orientation="horizontal"]>.lm-SplitPanel-handle::after { min-width: var(--theia-sash-width); } -.lm-SplitPanel[data-orientation="vertical"] > .lm-SplitPanel-handle::after { +.lm-SplitPanel[data-orientation="vertical"]>.lm-SplitPanel-handle::after { min-height: var(--theia-sash-width); } @@ -75,11 +73,11 @@ font-weight: 700; } -.lm-Widget > .theia-view-container-part-header { +.lm-Widget>.theia-view-container-part-header { box-shadow: 0 1px 0 var(--theia-sideBarSectionHeader-border) inset; } -.lm-Widget.lm-first-visible > .theia-view-container-part-header { +.lm-Widget.lm-first-visible>.theia-view-container-part-header { box-shadow: none; } @@ -87,20 +85,12 @@ padding-left: 4px; } -.theia-view-container - > .lm-SplitPanel[data-orientation="horizontal"] - .part - > .theia-header - .theia-ExpansionToggle::before { +.theia-view-container>.lm-SplitPanel[data-orientation="horizontal"] .part>.theia-header .theia-ExpansionToggle::before { display: none; padding-left: 0px; } -.theia-view-container - > .lm-SplitPanel[data-orientation="horizontal"] - .part - > .theia-header - .theia-ExpansionToggle { +.theia-view-container>.lm-SplitPanel[data-orientation="horizontal"] .part>.theia-header .theia-ExpansionToggle { padding-left: 0px; } @@ -126,19 +116,19 @@ margin-right: 12px; } -.theia-view-container .part > .body { +.theia-view-container .part>.body { height: var(--theia-view-container-content-height); min-width: 50px; min-height: 50px; position: relative; } -.theia-view-container .part > .body .theia-tree-source-node-placeholder { +.theia-view-container .part>.body .theia-tree-source-node-placeholder { padding-top: 4px; height: 100%; } -.theia-view-container .part:hover > .body { +.theia-view-container .part:hover>.body { display: block; } @@ -173,12 +163,8 @@ } .theia-view-container-part-title.menu-open, -.lm-Widget.part:not(.collapsed):hover - .theia-view-container-part-header - .theia-view-container-part-title, -.lm-Widget.part:not(.collapsed):focus-within - .theia-view-container-part-header - .theia-view-container-part-title { +.lm-Widget.part:not(.collapsed):hover .theia-view-container-part-header .theia-view-container-part-title, +.lm-Widget.part:not(.collapsed):focus-within .theia-view-container-part-header .theia-view-container-part-title { display: flex; } diff --git a/packages/core/src/browser/view-container.ts b/packages/core/src/browser/view-container.ts index 803a071be30b0..2f0e035067eb5 100644 --- a/packages/core/src/browser/view-container.ts +++ b/packages/core/src/browser/view-container.ts @@ -23,13 +23,13 @@ import { import { Event as CommonEvent, Emitter } from '../common/event'; import { Disposable, DisposableCollection } from '../common/disposable'; import { CommandRegistry } from '../common/command'; -import { MenuModelRegistry, MenuPath, MenuAction } from '../common/menu'; +import { MenuModelRegistry, MenuPath, MenuAction, SubmenuImpl, ActionMenuNode, MenuNode, RenderedMenuNode } from '../common/menu'; import { ApplicationShell, StatefulWidget, SplitPositionHandler, SplitPositionOptions, SIDE_PANEL_TOOLBAR_CONTEXT_MENU } from './shell'; import { MAIN_AREA_ID, BOTTOM_AREA_ID } from './shell/theia-dock-panel'; import { FrontendApplicationStateService } from './frontend-application-state'; import { ContextMenuRenderer, Anchor } from './context-menu-renderer'; import { parseCssMagnitude } from './browser'; -import { TabBarToolbarRegistry, TabBarToolbarFactory, TabBarToolbar, TabBarDelegator, RenderedToolbarItem } from './shell/tab-bar-toolbar'; +import { TabBarToolbarRegistry, TabBarToolbarFactory, TabBarToolbar, TabBarDelegator } from './shell/tab-bar-toolbar'; import { isEmpty, isObject, nls } from '../common'; import { WidgetManager } from './widget-manager'; import { Key } from './keys'; @@ -38,6 +38,9 @@ import { Drag } from '@lumino/dragdrop'; import { MimeData } from '@lumino/coreutils'; import { ElementExt } from '@lumino/domutils'; import { TabBarDecoratorService } from './shell/tab-bar-decorator'; +import { ContextKeyService } from './context-key-service'; +import { KeybindingRegistry } from './keybinding'; +import { ToolbarMenuNodeWrapper } from './shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters'; export interface ViewContainerTitleOptions { label: string; @@ -90,6 +93,26 @@ export namespace DynamicToolbarWidget { } } +class PartsMenuToolbarItem extends ToolbarMenuNodeWrapper { + constructor( + protected readonly target: () => Widget | undefined, + effectiveMenuPath: MenuPath, + commandRegistry: CommandRegistry, + menuRegistry: MenuModelRegistry, + contextKeyService: ContextKeyService, + contextMenuRenderer: ContextMenuRenderer, + menuNode: MenuNode & RenderedMenuNode, + group: string | undefined, + menuPath?: MenuPath, + ) { + super(effectiveMenuPath, commandRegistry, menuRegistry, contextKeyService, contextMenuRenderer, menuNode, group, menuPath); + } + + override isVisible(widget: Widget): boolean { + return widget === this.target() && super.isVisible(widget); + } +} + /** * A view container holds an arbitrary number of widgets inside a split panel. * Each widget is wrapped in a _part_ that displays the widget title and toolbar @@ -146,8 +169,15 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica @inject(TabBarDecoratorService) protected readonly decoratorService: TabBarDecoratorService; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + + @inject(KeybindingRegistry) + protected readonly keybindingRegistry: KeybindingRegistry; + @postConstruct() protected init(): void { + this.toDispose.push(Disposable.create(() => { this.toDisposeOnUpdateTitle.dispose(); })); this.id = this.options.id; this.addClass('theia-view-container'); const layout = new PanelLayout(); @@ -243,7 +273,7 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica this.updateTitle(); } - protected readonly toDisposeOnUpdateTitle = new DisposableCollection(); + protected toDisposeOnUpdateTitle = new DisposableCollection(); protected _tabBarDelegate: Widget = this; updateTabBarDelegate(): void { @@ -261,7 +291,7 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica protected updateTitle(): void { this.toDisposeOnUpdateTitle.dispose(); - this.toDispose.push(this.toDisposeOnUpdateTitle); + this.toDisposeOnUpdateTitle = new DisposableCollection(); this.updateTabBarDelegate(); let title = Object.assign({}, this.titleOptions); if (isEmpty(title)) { @@ -315,12 +345,21 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica protected updateToolbarItems(allParts: ViewContainerPart[]): void { if (allParts.length > 1) { - const group = this.getToggleVisibilityGroupLabel(); + const group = new SubmenuImpl(this.contextKeyService, `toggleParts-${this.id}`, this.getToggleVisibilityGroupLabel(), undefined); for (const part of allParts) { const existingId = this.toggleVisibilityCommandId(part); - const { caption, label, dataset: { visibilityCommandLabel } } = part.wrapped.title; - this.registerToolbarItem(existingId, { tooltip: visibilityCommandLabel || caption || label, group }); + const { label } = part.wrapped.title; + group.addNode(new ActionMenuNode({ + commandId: existingId, + label: label + }, this.commandRegistry, this.keybindingRegistry, this.contextKeyService)); } + + // widget === this.getTabBarDelegate() + + const toolbarItem = new PartsMenuToolbarItem(() => this.getTabBarDelegate(), [this.id], this.commandRegistry, this.menuRegistry, + this.contextKeyService, this.contextMenuRenderer, group, 'view', [this.id]); + this.toDisposeOnUpdateTitle.push(this.toolbarRegistry.doRegisterItem(toolbarItem)); } } @@ -328,25 +367,6 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica return 'view'; } - protected registerToolbarItem(commandId: string, options?: Partial>): void { - const newId = `${this.id}-tabbar-toolbar-${commandId}`; - const existingHandler = this.commandRegistry.getAllHandlers(commandId)[0]; - const existingCommand = this.commandRegistry.getCommand(commandId); - if (existingHandler && existingCommand) { - this.toDisposeOnUpdateTitle.push(this.commandRegistry.registerCommand({ ...existingCommand, id: newId }, { - execute: (_widget, ...args) => this.commandRegistry.executeCommand(commandId, ...args), - isToggled: (_widget, ...args) => this.commandRegistry.isToggled(commandId, ...args), - isEnabled: (_widget, ...args) => this.commandRegistry.isEnabled(commandId, ...args), - isVisible: (widget, ...args) => widget === this.getTabBarDelegate() && this.commandRegistry.isVisible(commandId, ...args), - })); - this.toDisposeOnUpdateTitle.push(this.toolbarRegistry.registerItem({ - ...options, - id: newId, - command: newId, - })); - } - } - protected findOriginalPart(): ViewContainerPart | undefined { return this.getParts().find(part => part.originalContainerId === this.id); } diff --git a/packages/core/src/common/menu/action-menu-node.ts b/packages/core/src/common/menu/action-menu-node.ts deleted file mode 100644 index 2da168d8c0da3..0000000000000 --- a/packages/core/src/common/menu/action-menu-node.ts +++ /dev/null @@ -1,65 +0,0 @@ -// ***************************************************************************** -// 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-only 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.spec.ts b/packages/core/src/common/menu/composite-menu-node.spec.ts deleted file mode 100644 index 24a002af1a526..0000000000000 --- a/packages/core/src/common/menu/composite-menu-node.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2024 EclipseSource and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** -import { expect } from 'chai'; -import { CompositeMenuNode } from './composite-menu-node'; -import { CompoundMenuNodeRole } from './menu-types'; - -describe('composite-menu-node', () => { - describe('updateOptions', () => { - it('should update undefined node properties', () => { - const node = new CompositeMenuNode('test-id'); - node.updateOptions({ label: 'node-label', icon: 'icon', order: 'a', role: CompoundMenuNodeRole.Flat, when: 'node-condition' }); - expect(node.label).to.equal('node-label'); - expect(node.icon).to.equal('icon'); - expect(node.order).to.equal('a'); - expect(node.role).to.equal(CompoundMenuNodeRole.Flat); - expect(node.when).to.equal('node-condition'); - }); - it('should update existing node properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a1', role: CompoundMenuNodeRole.Submenu, when: 'test-condition' }); - node.updateOptions({ label: 'NEW-label', icon: 'NEW-icon', order: 'a2', role: CompoundMenuNodeRole.Flat, when: 'NEW-condition' }); - expect(node.label).to.equal('NEW-label'); - expect(node.icon).to.equal('NEW-icon'); - expect(node.order).to.equal('a2'); - expect(node.role).to.equal(CompoundMenuNodeRole.Flat); - expect(node.when).to.equal('NEW-condition'); - }); - it('should update only the icon without affecting other properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a' }); - node.updateOptions({ icon: 'NEW-icon' }); - expect(node.label).to.equal('test-label'); - expect(node.icon).to.equal('NEW-icon'); - expect(node.order).to.equal('a'); - }); - it('should not allow to unset properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a' }); - node.updateOptions({ icon: undefined }); - expect(node.label).to.equal('test-label'); - expect(node.icon).to.equal('test-icon'); - expect(node.order).to.equal('a'); - }); - it('should allow to set empty strings in properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label'); - node.updateOptions({ label: '' }); - expect(node.label).to.equal(''); - }); - it('should not cause side effects when updating a property to its existing value', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a' }); - node.updateOptions({ icon: 'test-icon' }); - expect(node.label).to.equal('test-label'); - expect(node.icon).to.equal('test-icon'); - expect(node.order).to.equal('a'); - }); - }); -}); diff --git a/packages/core/src/common/menu/composite-menu-node.ts b/packages/core/src/common/menu/composite-menu-node.ts deleted file mode 100644 index 4c5751e1f7ab2..0000000000000 --- a/packages/core/src/common/menu/composite-menu-node.ts +++ /dev/null @@ -1,116 +0,0 @@ -// ***************************************************************************** -// 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-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { Disposable } from '../disposable'; -import { CompoundMenuNode, CompoundMenuNodeRole, MenuNode, MutableCompoundMenuNode, SubMenuOptions } from './menu-types'; - -/** - * Node representing a (sub)menu in the menu tree structure. - */ -export class CompositeMenuNode implements MutableCompoundMenuNode { - protected readonly _children: MenuNode[] = []; - public iconClass?: string; - public order?: string; - protected _when?: string; - protected _role?: CompoundMenuNodeRole; - - constructor( - public readonly id: string, - public label?: string, - options?: SubMenuOptions, - readonly parent?: MenuNode & CompoundMenuNode, - ) { - this.updateOptions(options); - } - - get when(): string | undefined { return this._when; } - 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); } - - 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); - } - } - }; - } - - removeNode(id: string): boolean { - const idx = this._children.findIndex(n => n.id === id); - if (idx >= 0) { - this._children.splice(idx, 1); - return true; - } - return false; - } - - updateOptions(options?: SubMenuOptions): void { - if (options) { - this.iconClass = options.icon ?? options.iconClass ?? this.iconClass; - this.label = options.label ?? this.label; - this.order = options.order ?? this.order; - this._role = options.role ?? this._role; - this._when = options.when ?? this._when; - } - } - - 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 MutableCompoundMenuNode { - constructor(protected readonly wrapped: Readonly, readonly parent: 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.options?.order || this.wrapped.sortString; } - - 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.icon; } - - get order(): string | undefined { return this.sortString; } - - get when(): string | undefined { return this.options?.when ?? this.wrapped.when; } - - get children(): ReadonlyArray { return this.wrapped.children; } - - addNode(node: MenuNode): Disposable { return this.wrapped.addNode(node); } - - removeNode(id: string): boolean { return this.wrapped.removeNode(id); } - - updateOptions(options: SubMenuOptions): void { return this.wrapped.updateOptions(options); } -} diff --git a/packages/core/src/common/menu/index.ts b/packages/core/src/common/menu/index.ts index 5c8f8b438437d..98a39a77cc312 100644 --- a/packages/core/src/common/menu/index.ts +++ b/packages/core/src/common/menu/index.ts @@ -14,8 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -export * from './action-menu-node'; -export * from './composite-menu-node'; -export * from './menu-adapter'; +export * from '../../browser/menu/action-menu-node'; +export * from '../../browser/menu/composite-menu-node'; 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 deleted file mode 100644 index 82c57b0648871..0000000000000 --- a/packages/core/src/common/menu/menu-adapter.ts +++ /dev/null @@ -1,103 +0,0 @@ -// ***************************************************************************** -// 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-only 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/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index d2cea1522ac73..e58ef29636948 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -15,13 +15,12 @@ // ***************************************************************************** import { inject, injectable, named } from 'inversify'; -import { Command, CommandRegistry } from '../command'; +import { CommandMenu, CompoundMenuNode, Group, MAIN_MENU_BAR, MenuAction, MenuNode, MenuPath, MutableCompoundMenuNode, Submenu } from './menu-types'; +import { Event } from 'vscode-languageserver-protocol'; import { ContributionProvider } from '../contribution-provider'; +import { Command, CommandRegistry } from '../command'; +import { Emitter } from '../event'; import { Disposable } from '../disposable'; -import { Emitter, Event } from '../event'; -import { ActionMenuNode } from './action-menu-node'; -import { CompositeMenuNode, CompositeMenuNodeWrapper } from './composite-menu-node'; -import { CompoundMenuNode, MenuAction, MenuNode, MenuNodeMetadata, MenuPath, MutableCompoundMenuNode, SubMenuOptions } from './menu-types'; export const MenuContribution = Symbol('MenuContribution'); @@ -81,6 +80,15 @@ export namespace StructuralMenuChange { return evt.kind !== ChangeKind.CHANGED; } } +export const MenuNodeFactory = Symbol('MenuNodeFactory'); + +export interface MenuNodeFactory { + createGroup(id: string, orderString?: string, when?: string): Group & MutableCompoundMenuNode; + createCommandMenu(item: MenuAction): CommandMenu; + createSubmenu(id: string, label: string, contextKeyOverlays: Record | undefined, + orderString?: string, icon?: string, when?: string): Submenu & MutableCompoundMenuNode + createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode; +} /** * The MenuModelRegistry allows to register and unregister menus, submenus and actions @@ -89,23 +97,27 @@ export namespace StructuralMenuChange { */ @injectable() export class MenuModelRegistry { - protected readonly root = new CompositeMenuNode(''); - protected readonly independentSubmenus = new Map(); + protected root: Group & MutableCompoundMenuNode; protected readonly onDidChangeEmitter = new Emitter(); + constructor( + @inject(ContributionProvider) @named(MenuContribution) + protected readonly contributions: ContributionProvider, + @inject(CommandRegistry) + protected readonly commands: CommandRegistry, + @inject(MenuNodeFactory) + protected readonly menuNodeFactory: MenuNodeFactory) { + this.root = this.menuNodeFactory.createGroup('root', 'root'); + this.root.addNode(this.menuNodeFactory.createGroup(MAIN_MENU_BAR[0])); + } + get onDidChange(): Event { return this.onDidChangeEmitter.event; } protected isReady = false; - constructor( - @inject(ContributionProvider) @named(MenuContribution) - protected readonly contributions: ContributionProvider, - @inject(CommandRegistry) protected readonly commands: CommandRegistry - ) { } - onStart(): void { for (const contrib of this.contributions.getContributions()) { contrib.registerMenus(this); @@ -118,47 +130,48 @@ export class MenuModelRegistry { * * @returns a disposable which, when called, will remove the menu action again. */ - registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { - const menuNode = new ActionMenuNode(item, this.commands); - return this.registerMenuNode(menuPath, menuNode); + registerCommandMenu(menuPath: MenuPath, item: CommandMenu): Disposable { + const parent = this.root.getOrCreate(menuPath, 0, menuPath.length); + const existing = parent.children.find(node => node.id === menuPath[menuPath.length - 1]); + if (existing) { + throw new Error(`A menu node with path ${JSON.stringify(menuPath)} already exists`); + } else { + parent.addNode(item); + return Disposable.create(() => { + parent.removeNode(item); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: item.id + }); + }); + } + } /** - * Adds the given menu node to the menu denoted by the given path. + * Adds the given menu action to the menu denoted by the given path. * - * @returns a disposable which, when called, will remove the menu node again. + * @returns a disposable which, when called, will remove the menu action again. */ - registerMenuNode(menuPath: MenuPath | string, menuNode: MenuNode, group?: string): Disposable { - const parent = this.getMenuNode(menuPath, group); - const disposable = parent.addNode(menuNode); - const parentPath = this.getParentPath(menuPath, group); - this.fireChangeEvent({ - kind: ChangeKind.ADDED, - path: parentPath, - affectedChildId: menuNode.id - }); - return this.changeEventOnDispose(parentPath, menuNode.id, disposable); - } - - protected getParentPath(menuPath: MenuPath | string, group?: string): string[] { - if (typeof menuPath === 'string') { - return group ? [menuPath, group] : [menuPath]; + registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { + const parent = this.root.getOrCreate(menuPath, 0, menuPath.length); + const existing = parent.children.find(node => node.id === item.commandId); + if (existing) { + throw new Error(`A menu node with id ${item.commandId} in path ${JSON.stringify(menuPath)} already exists`); } else { - return group ? menuPath.concat(group) : menuPath; + const node = this.menuNodeFactory.createCommandMenu(item); + parent.addNode(node); + return Disposable.create(() => { + parent.removeNode(node); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: node.id + }); + }); } - } - getMenuNode(menuPath: MenuPath | string, group?: string): MutableCompoundMenuNode { - 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); - } } /** @@ -176,72 +189,80 @@ export class MenuModelRegistry { * Note that if the menu already existed and was registered with a different label an error * will be thrown. */ - registerSubmenu(menuPath: MenuPath, label: string, options?: SubMenuOptions): Disposable { - if (menuPath.length === 0) { - throw new Error('The sub menu path cannot be empty.'); - } - const index = menuPath.length - 1; - const menuId = menuPath[index]; - const groupPath = index === 0 ? [] : menuPath.slice(0, index); - const parent = this.findGroup(groupPath, options); - let groupNode = this.findSubMenu(parent, menuId, options); - let disposable = Disposable.NULL; - if (!groupNode) { - groupNode = new CompositeMenuNode(menuId, label, options, parent); - disposable = this.changeEventOnDispose(groupPath, menuId, parent.addNode(groupNode)); + registerSubmenu(menuPath: MenuPath, label: string, sortString?: string, icon?: string, when?: string, contextKeyOverlay?: Record): Disposable { + const parent = this.root.getOrCreate(menuPath, 0, menuPath.length - 1); + const existing = parent.children.find(node => node.id === menuPath[menuPath.length - 1]); + if (Group.is(existing)) { + parent.removeNode(existing); + const newMenu = this.menuNodeFactory.createSubmenu(menuPath[menuPath.length - 1], label, contextKeyOverlay, sortString, icon, when); + newMenu.addNode(...existing.children); + parent.addNode(newMenu); this.fireChangeEvent({ - kind: ChangeKind.ADDED, - path: groupPath, - affectedChildId: menuId + kind: ChangeKind.CHANGED, + path: menuPath + }); + return Disposable.create(() => { + parent.removeNode(newMenu); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: newMenu.id + }); }); } else { + const newMenu = this.menuNodeFactory.createSubmenu(menuPath[menuPath.length - 1], label, contextKeyOverlay, sortString, icon, when); + parent.addNode(newMenu); this.fireChangeEvent({ - kind: ChangeKind.CHANGED, - path: groupPath, + kind: ChangeKind.ADDED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: newMenu.id + }); + return Disposable.create(() => { + parent.removeNode(newMenu); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: newMenu.id + }); }); - groupNode.updateOptions({ ...options, label }); } - return disposable; } - registerIndependentSubmenu(id: string, label: string, options?: SubMenuOptions): Disposable { - if (this.independentSubmenus.has(id)) { - console.debug(`Independent submenu with path ${id} registered, but given ID already exists.`); + linkCompoundMenuNode(newParentPath: MenuPath, submenuPath: MenuPath, order?: string, when?: string): Disposable { + // add a wrapper here + let i = 0; + while (i < newParentPath.length && i < submenuPath.length && newParentPath[i] === submenuPath[i]) { + i++; } - this.independentSubmenus.set(id, new CompositeMenuNode(id, label, options)); - return this.changeEventOnDispose([], id, Disposable.create(() => 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 affectedPath = this.getParentPath(parentPath, group); - - const isRecursive = (node: MenuNodeMetadata, childNode: MenuNodeMetadata): boolean => { - if (node.id === childNode.id) { - return true; - } - if (node.parent) { - return isRecursive(node.parent, childNode); - } - return false; - }; - - // check for menu contribution recursion - if (isRecursive(parent, child)) { - console.warn(`Recursive menu contribution detected: ${child.id} is already in hierarchy of ${parent.id}.`); - return Disposable.NULL; + if (i === newParentPath.length || i === submenuPath.length) { + throw new Error(`trying to recursively link ${JSON.stringify(submenuPath)} into ${JSON.stringify(newParentPath)}`); } - const wrapper = new CompositeMenuNodeWrapper(child, parent, options); - const disposable = parent.addNode(wrapper); - this.fireChangeEvent({ - kind: ChangeKind.LINKED, - path: affectedPath, - affectedChildId: child.id - - }); - return this.changeEventOnDispose(affectedPath, child.id, disposable); + const child = this.getMenu(submenuPath) as Submenu; + if (!child) { + throw new Error(`Not a menu node: ${JSON.stringify(submenuPath)}`); + } + const newParent = this.root.getOrCreate(newParentPath, 0, newParentPath.length); + if (MutableCompoundMenuNode.is(newParent)) { + const link = this.menuNodeFactory.createSubmenuLink(child, order, when); + newParent.addNode(link); + this.fireChangeEvent({ + kind: ChangeKind.LINKED, + path: newParentPath, + affectedChildId: child.id + }); + return Disposable.create(() => { + newParent.removeNode(link); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: newParentPath, + affectedChildId: child.id + }); + }); + } else { + throw new Error(`Not a compound menu node: ${JSON.stringify(newParentPath)}`); + } } /** @@ -265,89 +286,48 @@ export class MenuModelRegistry { * @param menuPath if specified only nodes within the path will be unregistered. */ unregisterMenuAction(id: string, menuPath?: MenuPath): void; - unregisterMenuAction(itemOrCommandOrId: MenuAction | Command | string, menuPath?: MenuPath): void { + unregisterMenuAction(itemOrCommandOrId: MenuAction | Command | string, menuPath: MenuPath = []): void { const id = MenuAction.is(itemOrCommandOrId) ? itemOrCommandOrId.commandId : Command.is(itemOrCommandOrId) ? itemOrCommandOrId.id : itemOrCommandOrId; - if (menuPath) { - const parent = this.findGroup(menuPath); - parent.removeNode(id); - this.fireChangeEvent({ - kind: ChangeKind.REMOVED, - path: menuPath, - affectedChildId: id - }); - } else { - this.unregisterMenuNode(id); + const parent = this.findInNode(this.root, menuPath, 0); + if (parent) { + this.removeActionInSubtree(parent, id); } } - /** - * Recurse all menus, removing any menus matching the `id`. - * - * @param id technical identifier of the `MenuNode`. - */ - unregisterMenuNode(id: string): void { - const parentPath: string[] = []; - const recurse = (root: MutableCompoundMenuNode) => { - root.children.forEach(node => { - if (CompoundMenuNode.isMutable(node)) { - if (node.removeNode(id)) { - this.fireChangeEvent({ - kind: ChangeKind.REMOVED, - path: parentPath, - affectedChildId: id - }); - } - parentPath.push(node.id); - recurse(node); - parentPath.pop(); - } - }); - }; - recurse(this.root); - } - - /** - * Finds a submenu as a descendant of the `root` node. - * See {@link MenuModelRegistry.findSubMenu findSubMenu}. - */ - protected findGroup(menuPath: MenuPath, options?: SubMenuOptions): MutableCompoundMenuNode { - let currentMenu: MutableCompoundMenuNode = this.root; - for (const segment of menuPath) { - currentMenu = this.findSubMenu(currentMenu, segment, options); + protected removeActionInSubtree(parent: MenuNode, id: string): void { + if (MutableCompoundMenuNode.is(parent) && CompoundMenuNode.is(parent)) { + const action = parent.children.find(child => child.id === id); + if (action) { + parent.removeNode(action); + } + parent.children.forEach(child => this.removeActionInSubtree(child, id)); } - 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 MutableCompoundMenuNode}. - */ - protected findSubMenu(current: MutableCompoundMenuNode, menuId: string, options?: SubMenuOptions): MutableCompoundMenuNode { - const sub = current.children.find(e => e.id === menuId); - if (CompoundMenuNode.isMutable(sub)) { - return sub; + protected findInNode(root: CompoundMenuNode, menuPath: MenuPath, pathIndex: number): MenuNode | undefined { + if (pathIndex === menuPath.length) { + return root; } - if (sub) { - throw new Error(`'${menuId}' is not a menu group.`); + const child = root.children.find(c => c.id === menuPath[pathIndex]); + if (CompoundMenuNode.is(child)) { + return this.findInNode(child, menuPath, pathIndex + 1); } - const newSub = new CompositeMenuNode(menuId, undefined, options, current); - current.addNode(newSub); - return newSub; + return undefined; } - /** - * Returns the menu at the given path. - * - * @param menuPath the path specifying the menu to return. If not given the empty path will be used. - * - * @returns the root menu when `menuPath` is empty. If `menuPath` is not empty the specified menu is - * returned if it exists, otherwise an error is thrown. - */ - getMenu(menuPath: MenuPath = []): MutableCompoundMenuNode { - return this.findGroup(menuPath); + getMenuNode(menuPath: string[]): MenuNode | undefined { + return this.findInNode(this.root, menuPath, 0); + } + + getMenu(menuPath: MenuPath): CompoundMenuNode { + const node = this.getMenuNode(menuPath); + if (!CompoundMenuNode.is(node)) { + throw new Error(`not a compound menu node: ${JSON.stringify(menuPath)}`); + } + return node; } /** @@ -358,82 +338,45 @@ export class MenuModelRegistry { * @returns if the menu will show a single submenu this returns a menu that will show the child elements of the submenu, * otherwise the given `fullMenuModel` is return */ - removeSingleRootNode(fullMenuModel: MutableCompoundMenuNode, menuPath: MenuPath): CompoundMenuNode { - // check whether all children are compound menus and that there is only one child that has further children - if (!this.allChildrenCompound(fullMenuModel.children)) { - return fullMenuModel; - } - let nonEmptyNode = undefined; + static removeSingleRootNode(fullMenuModel: CompoundMenuNode): CompoundMenuNode { + + let singleChild = undefined; + for (const child of fullMenuModel.children) { - if (!this.isEmpty(child.children || [])) { - if (nonEmptyNode === undefined) { - nonEmptyNode = child; - } else { - return fullMenuModel; + if (CompoundMenuNode.is(child)) { + if (!MenuModelRegistry.isEmpty(child)) { + if (singleChild) { + return fullMenuModel; + } else { + singleChild = child; + } } + } else { + return fullMenuModel; } } - - if (CompoundMenuNode.is(nonEmptyNode) && nonEmptyNode.children.length === 1 && CompoundMenuNode.is(nonEmptyNode.children[0])) { - nonEmptyNode = nonEmptyNode.children[0]; - } - - return CompoundMenuNode.is(nonEmptyNode) ? nonEmptyNode : fullMenuModel; + return singleChild || fullMenuModel; } - protected allChildrenCompound(children: ReadonlyArray): boolean { - return children.every(CompoundMenuNode.is); - } - - protected isEmpty(children: ReadonlyArray): boolean { - if (children.length === 0) { - return true; - } - if (!this.allChildrenCompound(children)) { - return false; - } - for (const child of children) { - if (!this.isEmpty(child.children || [])) { - return false; + static isEmpty(node: MenuNode): boolean { + if (CompoundMenuNode.is(node)) { + if (node.children.length === 0) { + return true; } + for (const child of node.children) { + if (!MenuModelRegistry.isEmpty(child)) { + return false; + } + } + } else { + return false; } return true; } - protected changeEventOnDispose(path: MenuPath, id: string, disposable: Disposable): Disposable { - return Disposable.create(() => { - disposable.dispose(); - this.fireChangeEvent({ - path, - affectedChildId: id, - kind: ChangeKind.REMOVED - }); - }); - } - protected fireChangeEvent(evt: T): void { if (this.isReady) { this.onDidChangeEmitter.fire(evt); } } - - /** - * Returns the {@link MenuPath path} at which a given menu node can be accessed from this registry, if it can be determined. - * Returns `undefined` if the `parent` of any node in the chain is unknown. - */ - getPath(node: MenuNode): MenuPath | undefined { - const identifiers = []; - const visited: MenuNode[] = []; - let next: MenuNode | undefined = node; - - while (next && !visited.includes(next)) { - if (next === this.root) { - return identifiers.reverse(); - } - visited.push(next); - identifiers.push(next.id); - next = next.parent; - } - return undefined; - } } diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts index 9e8b19e5fa195..d89949b231d42 100644 --- a/packages/core/src/common/menu/menu-types.ts +++ b/packages/core/src/common/menu/menu-types.ts @@ -14,19 +14,23 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Disposable } from '../disposable'; +import { Event } from '../event'; import { isObject } from '../types'; -export type MenuPath = string[]; export const MAIN_MENU_BAR: MenuPath = ['menubar']; +export type MenuPath = string[]; export const MANAGE_MENU: MenuPath = ['manage_menu']; export const ACCOUNTS_MENU: MenuPath = ['accounts_menu']; export const ACCOUNTS_SUBMENU = [...ACCOUNTS_MENU, '1_accounts_submenu']; +export interface ContextExpressionMatcher { + match(whenExpression: string, context: T | undefined): boolean; +} + /** * @internal For most use cases, refer to {@link MenuAction} or {@link MenuNode} */ -export interface MenuNodeMetadata { +export interface MenuNode { /** * technical identifier. */ @@ -35,126 +39,104 @@ export interface MenuNodeMetadata { * Menu nodes are sorted in ascending order based on their `sortString`. */ readonly sortString: string; + isVisible(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean; + onDidChange?: Event; +} + +export interface Action { + isEnabled(effectiveMenuPath: MenuPath, ...args: unknown[]): boolean; + isToggled(effectiveMenuPath: MenuPath, ...args: unknown[]): boolean; + run(effectiveMenuPath: MenuPath, ...args: unknown[]): Promise; +} + +export namespace Action { + export function is(node: object): node is Action { + return isObject(node) && typeof node.run === 'function' && typeof node.isEnabled === 'function'; + } +} + +export interface MenuAction { /** - * Condition under which the menu node should be rendered. - * See https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts + * The command to execute. */ - readonly when?: string; + readonly commandId: string; /** - * A reference to the parent node - useful for determining the menu path by which the node can be accessed. + * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined + * label will be used instead. */ - readonly parent?: MenuNode; -} + readonly order?: string; -/** - * Metadata for the visual presentation of a node. - * @internal For most uses cases, refer to {@link MenuNode}, {@link CommandMenuNode}, or {@link CompoundMenuNode} - */ -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; + + readonly when?: string; } -/** @internal For most use cases refer to {@link MenuNode}, {@link CommandMenuNode}, or {@link CompoundMenuNode} */ -export interface MenuNodeBase extends MenuNodeMetadata, MenuNodeRenderingData { } +export namespace MenuAction { + export function is(obj: unknown): obj is MenuAction { + return isObject(obj) && typeof obj.commandId === 'string'; + } +} /** - * A menu entry representing an action, e.g. "New File". + * Metadata for the visual presentation of a node. + * @internal For most uses cases, refer to {@link MenuNode}, {@link CommandMenuNode}, or {@link CompoundMenuNode} */ -export interface MenuAction extends MenuNodeRenderingData, Pick { - - /** - * The command to execute. - */ - commandId: string; +export interface RenderedMenuNode extends MenuNode { /** - * 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. + * Optional label. Will be rendered as text of the menu item. */ - alt?: string; + readonly label: string; /** - * Menu entries are sorted in ascending order based on their `order` strings. If omitted the determined - * label will be used instead. + * Icon classes for the menu node. If present, these will produce an icon to the left of the label in browser-style menus. */ - order?: string; + readonly icon?: string; } -export namespace MenuAction { - /* Determine whether object is a MenuAction */ - export function is(arg: unknown): arg is MenuAction { - return isObject(arg) && 'commandId' in arg; +export namespace RenderedMenuNode { + export function is(node: object): node is RenderedMenuNode { + return isObject(node) && typeof node.label === 'string'; } } -/** - * Additional options when creating a new submenu. - */ -export interface SubMenuOptions extends Pick, Pick, Partial> { - /** - * The class to use for the submenu icon. - * @deprecated use `icon` instead; - */ - iconClass?: string; -} +export interface CommandMenu extends MenuNode, RenderedMenuNode, Action { -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 namespace CommandMenu { + export function is(node: MenuNode): node is CommandMenu { + return RenderedMenuNode.is(node) && Action.is(node); + } } -export interface CompoundMenuNode extends MenuNodeBase { - /** - * Items that are grouped under this menu. - */ - readonly children: ReadonlyArray - /** - * @deprecated @since 1.28 use `role` instead. - * Whether the item should be rendered as a submenu. - */ - readonly isSubmenu: boolean; - /** - * How the node and its children should be rendered. See {@link CompoundMenuNodeRole}. - */ - readonly role: CompoundMenuNodeRole; +export type Group = CompoundMenuNode; +export namespace Group { + export function is(obj: unknown): obj is Group { + return CompoundMenuNode.is(obj) && !RenderedMenuNode.is(obj); + } } -export interface MutableCompoundMenuNode extends CompoundMenuNode { - /** - * Inserts the given node at the position indicated by `sortString`. - * - * @returns a disposable which, when called, will remove the given node again. - */ - addNode(node: MenuNode): Disposable; - /** - * Removes the first node with the given id. - * - * @param id node id. - * @returns true if the id was present - */ - removeNode(id: string): boolean; +export type Submenu = CompoundMenuNode & RenderedMenuNode; +export type CompoundMenuNode = MenuNode & { + children: MenuNode[]; + contextKeyOverlays?: Record; /** - * Fills any `undefined` fields with the values from the {@link options}. + * Whether the group or submenu contains any visible children + * + * @param effectiveMenuPath The menu path where visibility is checked + * @param contextMatcher The context matcher to use + * @param context the context to use + * @param args the command arguments, if applicable */ - updateOptions(options: SubMenuOptions): void; -} + isEmpty(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean; +}; export namespace CompoundMenuNode { - export function is(node?: MenuNode): node is CompoundMenuNode { return !!node && 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 is(node?: unknown): node is CompoundMenuNode { return isObject(node) && Array.isArray(node.children); } + 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)) { @@ -166,18 +148,6 @@ export namespace CompoundMenuNode { return m1.sortString.localeCompare(m2.sortString); } - /** Collapses the children of any subemenus with role {@link CompoundMenuNodeRole Flat} and sorts */ - export function getFlatChildren(children: ReadonlyArray): MenuNode[] { - const childrenToMerge: ReadonlyArray[] = []; - return children.filter(child => { - if (getRole(child) === CompoundMenuNodeRole.Flat) { - childrenToMerge.push((child as CompoundMenuNode).children); - return false; - } - return true; - }).concat(...childrenToMerge).sort(sortChildren); - } - /** * Indicates whether the given node is the special `navigation` menu. * @@ -188,34 +158,19 @@ export namespace CompoundMenuNode { export function isNavigationGroup(node: MenuNode): node is CompoundMenuNode { return is(node) && node.id === 'navigation'; } - - export function isMutable(node?: MenuNode): node is MutableCompoundMenuNode { - const candidate = node as MutableCompoundMenuNode; - return is(candidate) && typeof candidate.addNode === 'function' && typeof candidate.removeNode === 'function'; - } } -export interface CommandMenuNode extends MenuNodeBase { - command: string; -} - -export namespace CommandMenuNode { - export function is(candidate?: MenuNode): candidate is CommandMenuNode { return Boolean(candidate?.command); } - export function hasAltHandler(candidate?: MenuNode): candidate is AlternativeHandlerMenuNode { - const asAltNode = candidate as AlternativeHandlerMenuNode; - return is(asAltNode) && is(asAltNode?.altNode); +export interface MutableCompoundMenuNode { + addNode(...node: MenuNode[]): void; + removeNode(node: MenuNode): void; + getOrCreate(menuPath: MenuPath, pathIndex: number, endIndex: number): CompoundMenuNode & MutableCompoundMenuNode; +}; + +export namespace MutableCompoundMenuNode { + export function is(node: unknown): node is MutableCompoundMenuNode { + return isObject(node) + && typeof node.addNode === 'function' + && typeof node.removeNode === 'function' + && typeof node.getOrCreate === 'function'; } } - -export interface AlternativeHandlerMenuNode extends CommandMenuNode { - altNode: CommandMenuNode; -} - -/** - * Base interface of the nodes used in the menu tree structure. - */ -export type MenuNode = MenuNodeMetadata - & MenuNodeRenderingData - & Partial - & Partial - & Partial; diff --git a/packages/core/src/common/test/mock-menu.ts b/packages/core/src/common/test/mock-menu.ts deleted file mode 100644 index 8cdfc4727feb7..0000000000000 --- a/packages/core/src/common/test/mock-menu.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2018 Red Hat, Inc. and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { Disposable } from '../disposable'; -import { CommandRegistry } from '../command'; -import { MenuModelRegistry, MenuPath, MenuAction } from '../menu'; - -export class MockMenuModelRegistry extends MenuModelRegistry { - - constructor() { - const commands = new CommandRegistry({ getContributions: () => [] }); - super({ getContributions: () => [] }, commands); - } - - override registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { - return Disposable.NULL; - } - - override registerSubmenu(menuPath: MenuPath, label: string): Disposable { - return Disposable.NULL; - } -} 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 8c2a7f1f4fb56..cececea0da3b8 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 @@ -18,12 +18,14 @@ import { inject, injectable, postConstruct } from 'inversify'; import { - ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor, PreferenceService + ContextMenuRenderer, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor, PreferenceService, + Anchor } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ContextMenuContext } from '../../browser/menu/context-menu-context'; -import { MenuPath, MenuContribution, MenuModelRegistry } from '../../common'; import { BrowserContextMenuAccess, BrowserContextMenuRenderer } from '../../browser/menu/browser-context-menu-renderer'; +import { MenuPath, MenuContribution, MenuModelRegistry, CompoundMenuNode } from '../../common/menu'; +import { ContextKeyService, ContextMatcher } from '../../browser/context-key-service'; export class ElectronContextMenuAccess extends ContextMenuAccess { constructor(readonly menuHandle: Promise) { @@ -46,6 +48,9 @@ export class ElectronTextInputContextMenuContribution implements FrontendApplica @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + onStart(): void { window.document.addEventListener('contextmenu', event => { if (event.target instanceof HTMLElement) { @@ -55,6 +60,7 @@ export class ElectronTextInputContextMenuContribution implements FrontendApplica event.stopPropagation(); this.contextMenuRenderer.render({ anchor: event, + contextKeyService: this.contextKeyService, menuPath: ElectronTextInputContextMenu.MENU_PATH, context: event.target, onHide: () => target.focus() @@ -87,7 +93,7 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { protected useNativeStyle: boolean = true; constructor(@inject(ElectronMainMenuFactory) private electronMenuFactory: ElectronMainMenuFactory) { - super(electronMenuFactory); + super(); } @postConstruct() @@ -99,15 +105,20 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { this.useNativeStyle = await window.electronTheiaCore.getTitleBarStyleAtStartup() === 'native'; } - protected override doRender(options: RenderContextMenuOptions): ContextMenuAccess { + protected override doRender(menuPath: MenuPath, menu: CompoundMenuNode, + anchor: Anchor, + contextMatcher: ContextMatcher, + args?: any, + context?: HTMLElement, + onHide?: () => void + ): ContextMenuAccess { if (this.useNativeStyle) { - const { menuPath, anchor, args, onHide, context, contextKeyService, skipSingleRootNode } = options; - const menu = this.electronMenuFactory.createElectronContextMenu(menuPath, args, context, contextKeyService, skipSingleRootNode); + const contextMenu = this.electronMenuFactory.createElectronContextMenu(menuPath, menu, contextMatcher, args, context); const { x, y } = coordinateFromAnchor(anchor); - const windowName = options.context?.ownerDocument.defaultView?.Window.name; + const windowName = context?.ownerDocument.defaultView?.Window.name; - const menuHandle = window.electronTheiaCore.popup(menu, x, y, () => { + const menuHandle = window.electronTheiaCore.popup(contextMenu, x, y, () => { if (onHide) { onHide(); } @@ -116,7 +127,7 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { this.context.resetAltPressed(); return new ElectronContextMenuAccess(menuHandle); } else { - const menuAccess = super.doRender(options); + const menuAccess = super.doRender(menuPath, menu, anchor, contextMatcher, args, context, onHide); const node = (menuAccess as BrowserContextMenuAccess).menu.node; const topPanelHeight = document.getElementById('theia-top-panel')?.clientHeight ?? 0; // ensure the context menu is not displayed outside of the main area 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 3925b56df82f3..a1c8fc78b526f 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 @@ -17,8 +17,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { inject, injectable, postConstruct } from 'inversify'; -import { isOSX, MAIN_MENU_BAR, MenuPath, MenuNode, CommandMenuNode, CompoundMenuNode, CompoundMenuNodeRole } from '../../common'; -import { Keybinding } from '../../common/keybinding'; +import { isOSX, MAIN_MENU_BAR, MenuNode, CompoundMenuNode, Group, RenderedMenuNode, CommandMenu, AcceleratorSource, MenuPath } from '../../common'; import { PreferenceService, CommonCommands } from '../../browser'; import debounce = require('lodash.debounce'); import { MAXIMIZED_CLASS } from '../../browser/shell/theia-dock-panel'; @@ -51,10 +50,6 @@ export interface ElectronMenuOptions { * If none is provided, the global context will be used. */ contextKeyService?: ContextMatcher; - /** - * The root menu path for which the menu is being built. - */ - rootMenuPath: MenuPath } /** @@ -71,11 +66,28 @@ export type ElectronMenuItemRole = ('undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectNextTab' | 'selectPreviousTab' | 'mergeAllWindows' | 'clearRecentDocuments' | 'moveTabToNewWindow' | 'windowMenu'); +function traverseMenuDto(items: MenuDto[], callback: (item: MenuDto) => void): void { + for (const item of items) { + callback(item); + if (item.submenu) { + traverseMenuDto(item.submenu, callback); + } + } +} + +function traverseMenuModel(effectivePath: MenuPath, item: MenuNode, callback: (item: MenuNode, path: MenuPath) => void): void { + callback(item, effectivePath); + if (CompoundMenuNode.is(item)) { + for (const child of item.children) { + traverseMenuModel([...effectivePath, child.id], child, callback); + } + } +} + @injectable() export class ElectronMainMenuFactory extends BrowserMainMenuFactory { protected menu?: MenuDto[]; - protected toggledCommands: Set = new Set(); @inject(PreferenceService) protected preferencesService: PreferenceService; @@ -94,16 +106,33 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { this.preferencesService.onPreferenceChanged( debounce(e => { if (e.preferenceName === 'window.menuBarVisibility') { - this.doSetMenuBar(); + this.setMenuBar(); } if (this.menu) { - for (const cmd of this.toggledCommands) { - const menuItem = this.findMenuById(this.menu, cmd); - if (menuItem && (!!menuItem.checked !== this.commandRegistry.isToggled(cmd))) { - menuItem.checked = !menuItem.checked; + const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR)!; + const toggledMap = new Map(); + traverseMenuDto(this.menu, item => { + if (item.id) { + toggledMap.set(item.id, item); + } + }); + let anyChanged = false; + + traverseMenuModel(MAIN_MENU_BAR, menuModel, ((item, path) => { + if (CommandMenu.is(item)) { + const isToggled = item.isToggled(path); + const menuItem = toggledMap.get(item.id); + if (menuItem && isToggled !== menuItem.checked) { + anyChanged = true; + menuItem.type = isToggled ? 'checkbox' : 'normal'; + menuItem.checked = isToggled; + } } + })); + + if (anyChanged) { + window.electronTheiaCore.setMenu(this.menu); } - window.electronTheiaCore.setMenu(this.menu); } }, 10) ); @@ -119,8 +148,8 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { const preference = this.preferencesService.get('window.menuBarVisibility') || 'classic'; const maxWidget = document.getElementsByClassName(MAXIMIZED_CLASS); if (preference === 'visible' || (preference === 'classic' && maxWidget.length === 0)) { - const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR); - const menu = this.fillMenuTemplate([], menuModel, [], { honorDisabled: false, rootMenuPath: MAIN_MENU_BAR }, false); + const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR)!; + const menu = this.fillMenuTemplate([], MAIN_MENU_BAR, menuModel, [], this.contextKeyService, { honorDisabled: false }, false); if (isOSX) { menu.unshift(this.createOSXMenu()); } @@ -129,32 +158,37 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { return undefined; } - createElectronContextMenu(menuPath: MenuPath, args?: any[], context?: HTMLElement, contextKeyService?: ContextMatcher, skipSingleRootNode?: boolean): MenuDto[] { - const menuModel = skipSingleRootNode ? this.menuProvider.removeSingleRootNode(this.menuProvider.getMenu(menuPath), menuPath) : this.menuProvider.getMenu(menuPath); - return this.fillMenuTemplate([], menuModel, args, { showDisabled: true, context, rootMenuPath: menuPath, contextKeyService }, true); + createElectronContextMenu(menuPath: MenuPath, menu: CompoundMenuNode, contextMatcher: ContextMatcher, args?: any[], context?: HTMLElement, skipSingleRootNode?: boolean): MenuDto[] { + return this.fillMenuTemplate([], menuPath, menu, args, contextMatcher, { showDisabled: true, context }, true); } protected fillMenuTemplate(parentItems: MenuDto[], + menuPath: MenuPath, menu: MenuNode, args: unknown[] = [], + contextMatcher: ContextMatcher, options: ElectronMenuOptions, skipRoot: boolean ): MenuDto[] { const showDisabled = options?.showDisabled !== false; const honorDisabled = options?.honorDisabled !== false; - if (CompoundMenuNode.is(menu) && menu.children.length && this.undefinedOrMatch(options.contextKeyService ?? this.contextKeyService, menu.when, options.context)) { - const role = CompoundMenuNode.getRole(menu); - if (role === CompoundMenuNodeRole.Group && menu.id === 'inline') { + if (CompoundMenuNode.is(menu) && menu.children.length && menu.isVisible(menuPath, contextMatcher, options.context, ...args)) { + if (Group.is(menu) && menu.id === 'inline') { return parentItems; } - const children = CompoundMenuNode.getFlatChildren(menu.children); + + if (menu.contextKeyOverlays) { + const overlays = menu.contextKeyOverlays; + contextMatcher = this.services.contextKeyService.createOverlay(Object.keys(overlays).map(key => [key, overlays[key]])); + } + const children = menu.children; const myItems: MenuDto[] = []; - children.forEach(child => this.fillMenuTemplate(myItems, child, args, options, false)); + children.forEach(child => this.fillMenuTemplate(myItems, [...menuPath, child.id], child, args, contextMatcher, options, false)); if (myItems.length === 0) { return parentItems; } - if (!skipRoot && role === CompoundMenuNodeRole.Submenu) { + if (!skipRoot && RenderedMenuNode.is(menu)) { parentItems.push({ label: menu.label, submenu: myItems }); } else { if (parentItems.length && parentItems[parentItems.length - 1].type !== 'separator') { @@ -163,54 +197,46 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { parentItems.push(...myItems); parentItems.push({ type: 'separator' }); } - } else if (menu.command) { - const node = menu.altNode && this.context.altPressed ? menu.altNode : (menu as MenuNode & CommandMenuNode); - const commandId = node.command; - - // That is only a sanity check at application startup. - if (!this.commandRegistry.getCommand(commandId)) { - console.debug(`Skipping menu item with missing command: "${commandId}".`); - return parentItems; - } - - if ( - !this.menuCommandExecutor.isVisible(options.rootMenuPath, commandId, ...args) - || !this.undefinedOrMatch(options.contextKeyService ?? this.contextKeyService, node.when, options.context)) { + } else if (CommandMenu.is(menu)) { + if (!menu.isVisible(menuPath, contextMatcher, options.context, ...args)) { return parentItems; } // We should omit rendering context-menu items which are disabled. - if (!showDisabled && !this.menuCommandExecutor.isEnabled(options.rootMenuPath, commandId, ...args)) { + if (!showDisabled && !menu.isEnabled(menuPath, ...args)) { return parentItems; } - const bindings = this.keybindingRegistry.getKeybindingsForCommand(commandId); - - const accelerator = bindings[0] && this.acceleratorFor(bindings[0]); + const accelerator = AcceleratorSource.is(menu) ? menu.getAccelerator(options.context).join(' ') : undefined; const menuItem: MenuDto = { - id: node.id, - label: node.label, - type: this.commandRegistry.getToggledHandler(commandId, ...args) ? 'checkbox' : 'normal', - checked: this.commandRegistry.isToggled(commandId, ...args), - enabled: !honorDisabled || this.commandRegistry.isEnabled(commandId, ...args), // see https://github.com/eclipse-theia/theia/issues/446 + id: menu.id, + label: menu.label, + type: menu.isToggled(menuPath, ...args) ? 'checkbox' : 'normal', + checked: menu.isToggled(menuPath, ...args), + enabled: !honorDisabled || menu.isEnabled(menuPath, ...args), // see https://github.com/eclipse-theia/theia/issues/446 visible: true, accelerator, - execute: () => this.execute(commandId, args, options.rootMenuPath) + execute: async () => { + const wasToggled = menuItem.checked; + await menu.run(menuPath, ...args); + const isToggled = menu.isToggled(menuPath, ...args); + if (isToggled != wasToggled) { + menuItem.type = isToggled ? 'checkbox' : 'normal'; + menuItem.checked = isToggled; + window.electronTheiaCore.setMenu(this.menu); + } + } }; if (isOSX) { - const role = this.roleFor(node.id); + const role = this.roleFor(menu.id); if (role) { menuItem.role = role; delete menuItem.execute; } } parentItems.push(menuItem); - - if (this.commandRegistry.getToggledHandler(commandId, ...args)) { - this.toggledCommands.add(commandId); - } } return parentItems; } @@ -222,24 +248,6 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { return true; } - /** - * Return a user visible representation of a keybinding. - */ - protected acceleratorFor(keybinding: Keybinding): string { - const bindingKeySequence = this.keybindingRegistry.resolveKeybinding(keybinding); - // FIXME see https://github.com/electron/electron/issues/11740 - // Key Sequences can't be represented properly in the electron menu. - // - // We can do what VS Code does, and append the chords as a suffix to the menu label. - // https://github.com/eclipse-theia/theia/issues/1199#issuecomment-430909480 - if (bindingKeySequence.length > 1) { - return ''; - } - - const keyCode = bindingKeySequence[0]; - return this.keybindingRegistry.acceleratorForKeyCode(keyCode, '+', true); - } - protected roleFor(id: string): MenuRole | undefined { let role: MenuRole | undefined; switch (id) { @@ -267,40 +275,6 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { return role; } - protected async execute(cmd: 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.menuCommandExecutor.isEnabled(menuPath, cmd, ...args)) { - await this.menuCommandExecutor.executeCommand(menuPath, cmd, ...args); - if (this.menu && this.menuCommandExecutor.isVisible(menuPath, cmd, ...args)) { - const item = this.findMenuById(this.menu, cmd); - if (item) { - item.checked = this.menuCommandExecutor.isToggled(menuPath, cmd, ...args); - window.electronTheiaCore.setMenu(this.menu); - } - } - } - } catch { - // no-op - } - } - findMenuById(items: MenuDto[], id: string): MenuDto | undefined { - for (const item of items) { - if (item.id === id) { - return item; - } - if (item.submenu) { - const found = this.findMenuById(item.submenu, id); - if (found) { - return found; - } - } - } - return undefined; - } - protected createOSXMenu(): MenuDto { return { label: 'Theia', diff --git a/packages/core/src/electron-browser/menu/electron-menu-module.ts b/packages/core/src/electron-browser/menu/electron-menu-module.ts index e97022339ac68..467141d979a41 100644 --- a/packages/core/src/electron-browser/menu/electron-menu-module.ts +++ b/packages/core/src/electron-browser/menu/electron-menu-module.ts @@ -15,14 +15,17 @@ // ***************************************************************************** import { ContainerModule } from 'inversify'; -import { CommandContribution, MenuContribution } from '../../common'; +import { CommandContribution, MenuContribution, MenuNodeFactory } from '../../common'; import { FrontendApplicationContribution, ContextMenuRenderer, KeybindingContribution, KeybindingContext } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ElectronContextMenuRenderer, ElectronTextInputContextMenuContribution } from './electron-context-menu-renderer'; import { CustomTitleWidget, CustomTitleWidgetFactory, ElectronMenuContribution } from './electron-menu-contribution'; +import { BrowserMenuNodeFactory } from '../../browser/menu/browser-menu-node-factory'; +import { BrowserMainMenuFactory } from '../../browser/menu/browser-menu-plugin'; export default new ContainerModule(bind => { bind(ElectronMainMenuFactory).toSelf().inSingletonScope(); + bind(BrowserMainMenuFactory).toService(ElectronMainMenuFactory); bind(ContextMenuRenderer).to(ElectronContextMenuRenderer).inSingletonScope(); bind(KeybindingContext).toConstantValue({ id: 'theia.context', @@ -37,4 +40,6 @@ export default new ContainerModule(bind => { bind(CustomTitleWidgetFactory).toFactory(context => () => context.container.get(CustomTitleWidget)); bind(FrontendApplicationContribution).to(ElectronTextInputContextMenuContribution).inSingletonScope(); bind(MenuContribution).to(ElectronTextInputContextMenuContribution).inSingletonScope(); + bind(BrowserMenuNodeFactory).toSelf().inSingletonScope(); + bind(MenuNodeFactory).toService(BrowserMenuNodeFactory); }); diff --git a/packages/debug/src/browser/debug-frontend-application-contribution.ts b/packages/debug/src/browser/debug-frontend-application-contribution.ts index e02596058bbaa..be84fc45784a7 100644 --- a/packages/debug/src/browser/debug-frontend-application-contribution.ts +++ b/packages/debug/src/browser/debug-frontend-application-contribution.ts @@ -19,7 +19,7 @@ import { } from '@theia/core/lib/browser'; import { injectable, inject } from '@theia/core/shared/inversify'; import * as monaco from '@theia/monaco-editor-core'; -import { MenuModelRegistry, CommandRegistry, MAIN_MENU_BAR, Command, Emitter, Mutable, CompoundMenuNodeRole } from '@theia/core/lib/common'; +import { MenuModelRegistry, CommandRegistry, MAIN_MENU_BAR, Command, Emitter, Mutable } from '@theia/core/lib/common'; import { EDITOR_CONTEXT_MENU, EDITOR_LINENUMBER_CONTEXT_MENU, EditorManager } from '@theia/editor/lib/browser'; import { DebugSessionManager } from './debug-session-manager'; import { DebugWidget } from './view/debug-widget'; @@ -42,7 +42,7 @@ import { DebugConsoleContribution } from './console/debug-console-contribution'; import { DebugService } from '../common/debug-service'; import { DebugSchemaUpdater } from './debug-schema-updater'; import { DebugPreferences } from './debug-preferences'; -import { TabBarToolbarContribution, TabBarToolbarRegistry, RenderedToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { RenderedToolbarAction, TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { DebugWatchWidget } from './view/debug-watch-widget'; import { DebugWatchExpression } from './view/debug-watch-expression'; import { DebugWatchManager } from './debug-watch-manager'; @@ -55,6 +55,7 @@ import { nls } from '@theia/core/lib/common/nls'; import { DebugInstructionBreakpoint } from './model/debug-instruction-breakpoint'; import { DebugConfiguration } from '../common/debug-configuration'; import { DebugExceptionBreakpoint } from './view/debug-exception-breakpoint'; +import { DebugToolBar } from './view/debug-toolbar-widget'; export namespace DebugMenus { export const DEBUG = [...MAIN_MENU_BAR, '6_debug']; @@ -640,7 +641,9 @@ export class DebugFrontendApplicationContribution extends AbstractViewContributi { ...DebugEditorContextCommands.DISABLE_LOGPOINT, label: nlsDisableBreakpoint('Logpoint') }, { ...DebugEditorContextCommands.JUMP_TO_CURSOR, label: nls.localizeByDefault('Jump to Cursor') } ); - menus.linkSubmenu(EDITOR_LINENUMBER_CONTEXT_MENU, DebugEditorModel.CONTEXT_MENU, { role: CompoundMenuNodeRole.Group }); + menus.linkCompoundMenuNode(EDITOR_LINENUMBER_CONTEXT_MENU, DebugEditorModel.CONTEXT_MENU); + + menus.registerSubmenu(DebugToolBar.MENU, 'Debug Toolbar Menu'); } override registerCommands(registry: CommandRegistry): void { @@ -1116,7 +1119,7 @@ export class DebugFrontendApplicationContribution extends AbstractViewContributi registerToolbarItems(toolbar: TabBarToolbarRegistry): void { const onDidChangeToggleBreakpointsEnabled = new Emitter(); - const toggleBreakpointsEnabled: Mutable = { + const toggleBreakpointsEnabled: Mutable = { id: DebugCommands.TOGGLE_BREAKPOINTS_ENABLED.id, command: DebugCommands.TOGGLE_BREAKPOINTS_ENABLED.id, icon: codicon('activate-breakpoints'), diff --git a/packages/debug/src/browser/view/debug-action.tsx b/packages/debug/src/browser/view/debug-action.tsx index 9e29f5d45d923..627ed2c4abbd8 100644 --- a/packages/debug/src/browser/view/debug-action.tsx +++ b/packages/debug/src/browser/view/debug-action.tsx @@ -16,6 +16,7 @@ import * as React from '@theia/core/shared/react'; import { codiconArray, DISABLED_CLASS } from '@theia/core/lib/browser'; +import { MenuPath } from '@theia/core'; export class DebugAction extends React.Component { @@ -31,7 +32,7 @@ export class DebugAction extends React.Component { return { this.props.run([]) }} ref={this.setRef} > {!iconClass &&
{label}
}
; @@ -51,7 +52,7 @@ export namespace DebugAction { export interface Props { label: string iconClass: string - run: () => void + run: (effectiveMenuPath: MenuPath) => void enabled?: boolean } } diff --git a/packages/debug/src/browser/view/debug-toolbar-widget.tsx b/packages/debug/src/browser/view/debug-toolbar-widget.tsx index 519966217d284..21de8b343fac7 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 { CommandMenuNode, CommandRegistry, CompoundMenuNode, Disposable, DisposableCollection, MenuModelRegistry, MenuPath } from '@theia/core'; +import { CommandMenu, CommandRegistry, CompoundMenuNode, Disposable, DisposableCollection, MenuModelRegistry, MenuPath } from '@theia/core'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { ReactWidget } from '@theia/core/lib/browser/widgets'; import { DebugViewModel } from './debug-view-model'; @@ -85,11 +85,11 @@ export class DebugToolBar extends ReactWidget { protected renderContributedCommands(): React.ReactNode { const debugActions: React.ReactNode[] = []; // first, search for CompoundMenuNodes: - this.menuModelRegistry.getMenu(DebugToolBar.MENU).children.forEach(compoundMenuNode => { - if (CompoundMenuNode.is(compoundMenuNode) && this.matchContext(compoundMenuNode.when)) { + this.menuModelRegistry.getMenu(DebugToolBar.MENU)!.children.forEach(compoundMenuNode => { + if (CompoundMenuNode.is(compoundMenuNode) && compoundMenuNode.isVisible(DebugToolBar.MENU, this.contextKeyService, this.node)) { // second, search for nested CommandMenuNodes: compoundMenuNode.children.forEach(commandMenuNode => { - if (CommandMenuNode.is(commandMenuNode) && this.matchContext(commandMenuNode.when)) { + if (CommandMenu.is(commandMenuNode) && commandMenuNode.isVisible(DebugToolBar.MENU, this.contextKeyService, this.node)) { debugActions.push(this.debugAction(commandMenuNode)); } }); @@ -98,24 +98,13 @@ export class DebugToolBar extends ReactWidget { return debugActions; } - protected matchContext(when?: string): boolean { - return !when || this.contextKeyService.match(when); - } - - protected debugAction(commandMenuNode: CommandMenuNode): React.ReactNode { - const { command, icon = '', label = '' } = commandMenuNode; - if (!label && !icon) { - const { when } = commandMenuNode; - console.warn(`Neither 'label' nor 'icon' properties were defined for the command menu node. (${JSON.stringify({ command, when })}}. Skipping.`); - return; - } - const run = () => this.commandRegistry.executeCommand(command); + protected debugAction(commandMenuNode: CommandMenu): React.ReactNode { return ; + label={commandMenuNode.label} + iconClass={commandMenuNode.icon || ''} + run={commandMenuNode.run} />; } protected renderStart(): React.ReactNode { diff --git a/packages/editor/src/browser/editor-navigation-contribution.ts b/packages/editor/src/browser/editor-navigation-contribution.ts index bf4b5f38e7006..f8b3ae1173ba1 100644 --- a/packages/editor/src/browser/editor-navigation-contribution.ts +++ b/packages/editor/src/browser/editor-navigation-contribution.ts @@ -201,7 +201,7 @@ export class EditorNavigationContribution implements Disposable, FrontendApplica // Get the index of the current value, and toggle to the next available value. const index = values.indexOf(wordWrap) + 1; if (index > -1) { - this.preferenceService.set('editor.wordWrap', values[index % values.length], PreferenceScope.User); + await this.preferenceService.set('editor.wordWrap', values[index % values.length], PreferenceScope.User); } } @@ -210,7 +210,7 @@ export class EditorNavigationContribution implements Disposable, FrontendApplica */ protected async toggleStickyScroll(): Promise { const value: boolean | undefined = this.preferenceService.get('editor.stickyScroll.enabled'); - this.preferenceService.set('editor.stickyScroll.enabled', !value, PreferenceScope.User); + await this.preferenceService.set('editor.stickyScroll.enabled', !value, PreferenceScope.User); } /** @@ -218,7 +218,7 @@ export class EditorNavigationContribution implements Disposable, FrontendApplica */ protected async toggleMinimap(): Promise { const value: boolean | undefined = this.preferenceService.get('editor.minimap.enabled'); - this.preferenceService.set('editor.minimap.enabled', !value, PreferenceScope.User); + await this.preferenceService.set('editor.minimap.enabled', !value, PreferenceScope.User); } /** @@ -232,7 +232,7 @@ export class EditorNavigationContribution implements Disposable, FrontendApplica } else { updatedRenderWhitespace = 'none'; } - this.preferenceService.set('editor.renderWhitespace', updatedRenderWhitespace, PreferenceScope.User); + await this.preferenceService.set('editor.renderWhitespace', updatedRenderWhitespace, PreferenceScope.User); } protected onCurrentEditorChanged(editorWidget: EditorWidget | undefined): void { diff --git a/packages/git/src/browser/diff/git-diff-contribution.ts b/packages/git/src/browser/diff/git-diff-contribution.ts index 674b4ccccfdc5..d0825b07568bf 100644 --- a/packages/git/src/browser/diff/git-diff-contribution.ts +++ b/packages/git/src/browser/diff/git-diff-contribution.ts @@ -32,7 +32,7 @@ import { GIT_RESOURCE_SCHEME } from '../git-resource'; import { Git, Repository } from '../../common'; import { WorkspaceRootUriAwareCommandHandler } from '@theia/workspace/lib/browser/workspace-commands'; import { WorkspaceService } from '@theia/workspace/lib/browser'; -import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbarAction, TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { Emitter } from '@theia/core/lib/common/event'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { nls } from '@theia/core/lib/common/nls'; @@ -192,7 +192,7 @@ export class GitDiffContribution extends AbstractViewContribution }; const registerToggleViewItem = (command: Command, mode: 'tree' | 'list') => { const id = command.id; - const item: TabBarToolbarItem = { + const item: TabBarToolbarAction = { id, command: id, tooltip: command.label, diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts index 042f2f84cdf5e..0d494005efb51 100644 --- a/packages/git/src/browser/git-contribution.ts +++ b/packages/git/src/browser/git-contribution.ts @@ -28,8 +28,8 @@ import { } from '@theia/core'; import { codicon, DiffUris, Widget, open, OpenerService } from '@theia/core/lib/browser'; import { + TabBarToolbarAction, TabBarToolbarContribution, - TabBarToolbarItem, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { EditorContextMenu, EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser'; @@ -659,7 +659,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T tooltip: GIT_COMMANDS.INIT_REPOSITORY.label }); - const registerItem = (item: Mutable) => { + const registerItem = (item: Mutable) => { const commandId = item.command; const id = '__git.tabbar.toolbar.' + commandId; const command = this.commands.getCommand(commandId); diff --git a/packages/git/src/browser/git-frontend-module.ts b/packages/git/src/browser/git-frontend-module.ts index 3c10f70a8b732..0b9582e767db2 100644 --- a/packages/git/src/browser/git-frontend-module.ts +++ b/packages/git/src/browser/git-frontend-module.ts @@ -21,7 +21,7 @@ import { CommandContribution, MenuContribution, ResourceResolver } from '@theia/ import { WebSocketConnectionProvider, FrontendApplicationContribution, -} from '@theia/core/lib/browser'; + } from '@theia/core/lib/browser'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { Git, GitPath, GitWatcher, GitWatcherPath, GitWatcherServer, GitWatcherServerProxy, ReconnectingGitWatcherServer } from '../common'; import { GitContribution } from './git-contribution'; diff --git a/packages/monaco/src/browser/monaco-menu.ts b/packages/monaco/src/browser/monaco-menu.ts index 21cebc0d42af1..29f1e979f79e9 100644 --- a/packages/monaco/src/browser/monaco-menu.ts +++ b/packages/monaco/src/browser/monaco-menu.ts @@ -40,16 +40,17 @@ export class MonacoEditorMenuContribution implements MenuContribution { ) { } registerMenus(registry: MenuModelRegistry): void { + registry.registerSubmenu(EDITOR_CONTEXT_MENU, 'Editor Context Menu'); for (const item of MenuRegistry.getMenuItems(MenuId.EditorContext)) { if (!isIMenuItem(item)) { continue; } const commandId = this.commands.validate(item.command.id); if (commandId) { - const menuPath = [...EDITOR_CONTEXT_MENU, (item.group || '')]; - const coreId = MonacoCommands.COMMON_ACTIONS.get(commandId); - if (!(coreId && registry.getMenu(menuPath).children.some(it => it.id === coreId))) { - // Don't add additional actions if the item is already registered with a core ID. + const nodeId = MonacoCommands.COMMON_ACTIONS.get(commandId) || commandId; + const menuPath = item.group ? [...EDITOR_CONTEXT_MENU, item.group] : EDITOR_CONTEXT_MENU; + if (registry.getMenuNode([...menuPath, nodeId])) { + // Don't add additional actions if the item is already registered. registry.registerMenuAction(menuPath, this.buildMenuAction(commandId, item)); } } diff --git a/packages/navigator/src/browser/navigator-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index 956436fbafe2a..73d4498822bf5 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -57,7 +57,7 @@ import { FileNavigatorFilter } from './navigator-filter'; import { WorkspaceNode } from './navigator-tree'; import { NavigatorContextKeyService } from './navigator-context-key-service'; import { - RenderedToolbarItem, + RenderedToolbarAction, TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; @@ -588,7 +588,7 @@ export class FileNavigatorContribution extends AbstractViewContribution & { command: string }) => { + public registerMoreToolbarItem = (item: Mutable & { command: string }) => { const commandId = item.command; const id = 'navigator.tabbar.toolbar.' + commandId; const command = this.commandRegistry.getCommand(commandId); diff --git a/packages/notebook/src/browser/contributions/notebook-actions-contribution.ts b/packages/notebook/src/browser/contributions/notebook-actions-contribution.ts index 3a95c0c644d6b..e4a434d741076 100644 --- a/packages/notebook/src/browser/contributions/notebook-actions-contribution.ts +++ b/packages/notebook/src/browser/contributions/notebook-actions-contribution.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Command, CommandContribution, CommandHandler, CommandRegistry, CompoundMenuNodeRole, MenuContribution, MenuModelRegistry, nls, URI } from '@theia/core'; +import { Command, CommandContribution, CommandHandler, CommandRegistry, MenuContribution, MenuModelRegistry, nls, URI } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import { ApplicationShell, codicon, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser'; import { NotebookModel } from '../view-model/notebook-model'; @@ -294,9 +294,8 @@ export class NotebookActionsContribution implements CommandContribution, MenuCon registerMenus(menus: MenuModelRegistry): void { // independent submenu for plugins to add commands - menus.registerIndependentSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR, 'Notebook Main Toolbar'); + menus.registerSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR, 'Notebook Main Toolbar'); // Add Notebook Cell items - menus.registerSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP, 'Add Notebook Cell', { role: CompoundMenuNodeRole.Group }); menus.registerMenuAction(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP, { commandId: NotebookCommands.ADD_NEW_CODE_CELL_COMMAND.id, label: nls.localizeByDefault('Code'), @@ -309,7 +308,6 @@ export class NotebookActionsContribution implements CommandContribution, MenuCon }); // Execution related items - menus.registerSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP, 'Cell Execution', { role: CompoundMenuNodeRole.Group }); menus.registerMenuAction(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP, { commandId: NotebookCommands.EXECUTE_NOTEBOOK_COMMAND.id, label: nls.localizeByDefault('Run All'), @@ -324,7 +322,7 @@ export class NotebookActionsContribution implements CommandContribution, MenuCon when: NOTEBOOK_HAS_OUTPUTS }); - menus.registerIndependentSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU, ''); + menus.registerSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU, ''); } registerKeybindings(keybindings: KeybindingRegistry): void { @@ -372,8 +370,8 @@ export class NotebookActionsContribution implements CommandContribution, MenuCon } export namespace NotebookMenus { - export const NOTEBOOK_MAIN_TOOLBAR = 'notebook/toolbar'; - export const NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP = [NOTEBOOK_MAIN_TOOLBAR, 'cell-add-group']; - export const NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP = [NOTEBOOK_MAIN_TOOLBAR, 'cell-execution-group']; - export const NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU = 'notebook-main-toolbar-hidden-items-context-menu'; + export const NOTEBOOK_MAIN_TOOLBAR = ['notebook', 'toolbar']; + export const NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP = [...NOTEBOOK_MAIN_TOOLBAR, 'cell-add-group']; + export const NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP = [...NOTEBOOK_MAIN_TOOLBAR, 'cell-execution-group']; + export const NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU = ['notebook-main-toolbar-hidden-items-context-menu']; } diff --git a/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts b/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts index 6ede229a50338..d54f621f16926 100644 --- a/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts +++ b/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Command, CommandContribution, CommandHandler, CommandRegistry, CompoundMenuNodeRole, MenuContribution, MenuModelRegistry, nls } from '@theia/core'; +import { Command, CommandContribution, CommandHandler, CommandRegistry, MenuContribution, MenuModelRegistry, nls } from '@theia/core'; import { codicon, Key, KeybindingContribution, KeybindingRegistry, KeyCode, KeyModifier } from '@theia/core/lib/browser'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { NotebookModel } from '../view-model/notebook-model'; @@ -233,16 +233,13 @@ export class NotebookCellActionContribution implements MenuContribution, Command menus.registerSubmenu( NotebookCellActionContribution.ADDITIONAL_ACTION_MENU, nls.localizeByDefault('More'), - { - icon: codicon('ellipsis'), - role: CompoundMenuNodeRole.Submenu, - order: '30' - } + '30', + codicon('ellipsis'), ); - menus.registerIndependentSubmenu(NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU, '', { role: CompoundMenuNodeRole.Flat }); + menus.registerSubmenu(NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU, ''); // since contributions are adding to an independent submenu we have to manually add it to the more submenu - menus.getMenu(NotebookCellActionContribution.ADDITIONAL_ACTION_MENU).addNode(menus.getMenuNode(NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU)); + menus.linkCompoundMenuNode(NotebookCellActionContribution.ADDITIONAL_ACTION_MENU, NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU); // code cell sidebar menu menus.registerMenuAction(NotebookCellActionContribution.CODE_CELL_SIDEBAR_MENU, { @@ -259,19 +256,19 @@ export class NotebookCellActionContribution implements MenuContribution, Command }); // Notebook Cell extra execution options - menus.registerIndependentSubmenu(NotebookCellActionContribution.CONTRIBUTED_CELL_EXECUTION_MENU, + menus.registerSubmenu(NotebookCellActionContribution.CONTRIBUTED_CELL_EXECUTION_MENU, nls.localizeByDefault('More...'), - { role: CompoundMenuNodeRole.Flat, icon: codicon('chevron-down') }); + undefined, + codicon('chevron-down')); // menus.getMenu(NotebookCellActionContribution.CODE_CELL_SIDEBAR_MENU).addNode(menus.getMenuNode(NotebookCellActionContribution.CONTRIBUTED_CELL_EXECUTION_MENU)); // code cell output sidebar menu menus.registerSubmenu( NotebookCellActionContribution.ADDITIONAL_OUTPUT_SIDEBAR_MENU, nls.localizeByDefault('More'), - { - icon: codicon('ellipsis'), - role: CompoundMenuNodeRole.Submenu - }); + undefined, + codicon('ellipsis'), + ); menus.registerMenuAction(NotebookCellActionContribution.ADDITIONAL_OUTPUT_SIDEBAR_MENU, { commandId: NotebookCellCommands.CLEAR_OUTPUTS_COMMAND.id, label: nls.localizeByDefault('Clear Cell Outputs'), @@ -565,8 +562,8 @@ export class NotebookCellActionContribution implements MenuContribution, Command export namespace NotebookCellActionContribution { export const ACTION_MENU = ['notebook-cell-actions-menu']; export const ADDITIONAL_ACTION_MENU = [...ACTION_MENU, 'more']; - export const CONTRIBUTED_CELL_ACTION_MENU = 'notebook/cell/title'; - export const CONTRIBUTED_CELL_EXECUTION_MENU = 'notebook/cell/execute'; + export const CONTRIBUTED_CELL_ACTION_MENU = ['notebook/cell/title']; + export const CONTRIBUTED_CELL_EXECUTION_MENU = ['notebook/cell/execute']; export const CODE_CELL_SIDEBAR_MENU = ['code-cell-sidebar-menu']; export const OUTPUT_SIDEBAR_MENU = ['code-cell-output-sidebar-menu']; export const ADDITIONAL_OUTPUT_SIDEBAR_MENU = [...OUTPUT_SIDEBAR_MENU, 'more']; diff --git a/packages/notebook/src/browser/service/notebook-context-manager.ts b/packages/notebook/src/browser/service/notebook-context-manager.ts index 934c57e07d8f9..c51595a830b2b 100644 --- a/packages/notebook/src/browser/service/notebook-context-manager.ts +++ b/packages/notebook/src/browser/service/notebook-context-manager.ts @@ -16,7 +16,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { ContextKeyChangeEvent, ContextKeyService, ContextMatcher, ScopedValueStore } from '@theia/core/lib/browser/context-key-service'; -import { DisposableCollection, Emitter } from '@theia/core'; +import { DisposableCollection } from '@theia/core'; import { NotebookKernelService } from './notebook-kernel-service'; import { NOTEBOOK_CELL_EDITABLE, @@ -43,9 +43,6 @@ export class NotebookContextManager { protected readonly toDispose = new DisposableCollection(); - protected readonly onDidChangeContextEmitter = new Emitter(); - readonly onDidChangeContext = this.onDidChangeContextEmitter.event; - protected _context?: HTMLElement; scopedStore: ScopedValueStore; @@ -72,14 +69,12 @@ export class NotebookContextManager { if (e.notebook.toString() === widget?.getResourceUri()?.toString()) { this.scopedStore.setContext(NOTEBOOK_KERNEL_SELECTED, !!e.newKernel); this.scopedStore.setContext(NOTEBOOK_KERNEL, e.newKernel); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_KERNEL_SELECTED, NOTEBOOK_KERNEL])); } })); widget.model?.onDidChangeContent(events => { if (events.some(e => e.kind === NotebookCellsChangeType.ModelChange || e.kind === NotebookCellsChangeType.Output)) { this.scopedStore.setContext(NOTEBOOK_HAS_OUTPUTS, widget.model?.cells.some(cell => cell.outputs.length > 0)); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_HAS_OUTPUTS])); } }); @@ -91,23 +86,18 @@ export class NotebookContextManager { widget.model?.onDidChangeSelectedCell(e => { this.selectedCellChanged(e.cell); this.scopedStore.setContext(NOTEBOOK_CELL_FOCUSED, !!e); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_FOCUSED])); }); this.toDispose.push(this.executionStateService.onDidChangeExecution(e => { if (e.notebook.toString() === widget.model?.uri.toString()) { this.setCellContext(e.cellHandle, NOTEBOOK_CELL_EXECUTING, !!e.changed); this.setCellContext(e.cellHandle, NOTEBOOK_CELL_EXECUTION_STATE, e.changed?.state ?? 'idle'); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_EXECUTING, NOTEBOOK_CELL_EXECUTION_STATE])); } })); widget.onDidChangeOutputInputFocus(focus => { this.scopedStore.setContext(NOTEBOOK_OUTPUT_INPUT_FOCUSED, focus); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_OUTPUT_INPUT_FOCUSED])); }); - - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_VIEW_TYPE, NOTEBOOK_KERNEL_SELECTED, NOTEBOOK_KERNEL])); } protected cellDisposables = new DisposableCollection(); @@ -123,12 +113,8 @@ export class NotebookContextManager { this.cellDisposables.push(cell.onDidRequestCellEditChange(cellEdit => { this.scopedStore.setContext(NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, cellEdit); this.scopedStore.setContext(NOTEBOOK_CELL_EDITABLE, cell.cellKind === CellKind.Markup && !cellEdit); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_MARKDOWN_EDIT_MODE])); })); } - - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_TYPE])); - } protected setCellContext(cellHandle: number, key: string, value: unknown): void { diff --git a/packages/notebook/src/browser/view/notebook-cell-list-view.tsx b/packages/notebook/src/browser/view/notebook-cell-list-view.tsx index 39eeada382d0d..539c4e1a20517 100644 --- a/packages/notebook/src/browser/view/notebook-cell-list-view.tsx +++ b/packages/notebook/src/browser/view/notebook-cell-list-view.tsx @@ -19,7 +19,7 @@ import { NotebookCellModel } from '../view-model/notebook-cell-model'; import { NotebookModel } from '../view-model/notebook-model'; import { NotebookCellToolbarFactory } from './notebook-cell-toolbar-factory'; import { animationFrame, onDomEvent } from '@theia/core/lib/browser'; -import { CommandRegistry, DisposableCollection, MenuModelRegistry, MenuNode, nls } from '@theia/core'; +import { CommandMenu, CommandRegistry, DisposableCollection, MenuModelRegistry, nls } from '@theia/core'; import { NotebookCommands, NotebookMenus } from '../contributions/notebook-actions-contribution'; import { NotebookCellActionContribution } from '../contributions/notebook-cell-actions-contribution'; import { NotebookContextManager } from '../service/notebook-context-manager'; @@ -126,7 +126,7 @@ export class NotebookCellListView extends React.Component this.isEnabled()} - onAddNewCell={(commandId: string) => this.onAddNewCell(commandId, index)} + onAddNewCell={handler => this.onAddNewCell(handler, index)} onDrop={e => this.onDrop(e, index)} onDragOver={e => this.onDragOver(e, cell, 'top')} /> @@ -173,7 +173,7 @@ export class NotebookCellListView extends React.Component this.isEnabled()} - onAddNewCell={(commandId: string) => this.onAddNewCell(commandId, this.props.notebookModel.cells.length)} + onAddNewCell={handler => this.onAddNewCell(handler, this.props.notebookModel.cells.length)} onDrop={e => this.onDrop(e, this.props.notebookModel.cells.length - 1)} onDragOver={e => this.onDragOver(e, this.props.notebookModel.cells[this.props.notebookModel.cells.length - 1], 'bottom')} /> ; @@ -255,10 +255,10 @@ export class NotebookCellListView extends React.Component void, index: number): void { if (this.isEnabled()) { this.props.commandRegistry.executeCommand(NotebookCommands.CHANGE_SELECTED_CELL.id, index - 1); - this.props.commandRegistry.executeCommand(commandId, + handler( this.props.notebookModel, index ); @@ -276,7 +276,7 @@ export class NotebookCellListView extends React.Component boolean; - onAddNewCell: (commandId: string) => void; + onAddNewCell: (createCommand: (...args: any[]) => void) => void; onDrop: (event: React.DragEvent) => void; onDragOver: (event: React.DragEvent) => void; menuRegistry: MenuModelRegistry; @@ -286,21 +286,28 @@ export function NotebookCellDivider({ isVisible, onAddNewCell, onDrop, onDragOve const [hover, setHover] = React.useState(false); const menuPath = NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP; - const menuItems = menuRegistry.getMenuNode(menuPath).children; - - const renderItem = (item: MenuNode): React.ReactNode => ; + const menuItems: CommandMenu[] = menuRegistry.getMenu(menuPath).children.filter(item => CommandMenu.is(item)).map(item => item as CommandMenu); + + const renderItem = (item: CommandMenu): React.ReactNode => { + const execute = (...args: any[]) => { + if (CommandMenu.is(item)) { + item.run([...menuPath, item.id], ...args); + } + }; + return + }; return
  • setHover(true)} onMouseLeave={() => setHover(false)} onDrop={onDrop} onDragOver={onDragOver}> {hover && isVisible() &&
    - {menuItems.map((item: MenuNode) => renderItem(item))} + {menuItems.map((item: CommandMenu) => renderItem(item))}
    }
  • ; } diff --git a/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx b/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx index c7366949feb68..847d4ff01f17c 100644 --- a/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx +++ b/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx @@ -15,7 +15,7 @@ // ***************************************************************************** import * as React from '@theia/core/shared/react'; -import { CommandRegistry, CompoundMenuNodeRole, MenuModelRegistry, MenuNode } from '@theia/core'; +import { CommandMenu, CommandRegistry, CompoundMenuNode, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuPath, RenderedMenuNode } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { NotebookCellSidebar, NotebookCellToolbar } from './notebook-cell-toolbar'; @@ -29,7 +29,6 @@ export interface NotebookCellToolbarItem { label?: string; onClick: (e: React.MouseEvent) => void; isVisible: () => boolean; - contextKeys?: Set } export interface toolbarItemOptions { @@ -55,48 +54,61 @@ export class NotebookCellToolbarFactory { @inject(NotebookContextManager) protected readonly notebookContextManager: NotebookContextManager; + protected readonly onDidChangeContextEmitter = new Emitter + readonly onDidChangeContext: Event = this.onDidChangeContextEmitter.event; + + protected toDisposeOnRender = new DisposableCollection(); + renderCellToolbar(menuPath: string[], cell: NotebookCellModel, itemOptions: toolbarItemOptions): React.ReactNode { return this.getMenuItems(menuPath, cell, itemOptions)} - onContextKeysChanged={this.notebookContextManager.onDidChangeContext} />; + onContextChanged={this.onDidChangeContext} />; } renderSidebar(menuPath: string[], cell: NotebookCellModel, itemOptions: toolbarItemOptions): React.ReactNode { return this.getMenuItems(menuPath, cell, itemOptions)} - onContextKeysChanged={this.notebookContextManager.onDidChangeContext} />; + onContextChanged={this.onDidChangeContext} />; } private getMenuItems(menuItemPath: string[], cell: NotebookCellModel, itemOptions: toolbarItemOptions): NotebookCellToolbarItem[] { + this.toDisposeOnRender.dispose(); + this.toDisposeOnRender = new DisposableCollection(); const inlineItems: NotebookCellToolbarItem[] = []; for (const menuNode of this.menuRegistry.getMenu(menuItemPath).children) { - if (!menuNode.when || this.notebookContextManager.getCellContext(cell.handle).match(menuNode.when, this.notebookContextManager.context)) { - if (menuNode.role === CompoundMenuNodeRole.Flat) { - inlineItems.push(...menuNode.children?.map(child => this.createToolbarItem(child, itemOptions)) ?? []); - } else { - inlineItems.push(this.createToolbarItem(menuNode, itemOptions)); + + const itemPath = [...menuItemPath, menuNode.id]; + if (menuNode.isVisible(itemPath, this.notebookContextManager.getCellContext(cell.handle), this.notebookContextManager.context, itemOptions.commandArgs?.() ?? [])) { + if (RenderedMenuNode.is(menuNode)) { + if (menuNode.onDidChange) { + this.toDisposeOnRender.push(menuNode.onDidChange(() => this.onDidChangeContextEmitter.fire())); + } + inlineItems.push(this.createToolbarItem(itemPath, menuNode, itemOptions)); } } } return inlineItems; } - private createToolbarItem(menuNode: MenuNode, itemOptions: toolbarItemOptions): NotebookCellToolbarItem { - const menuPath = menuNode.role === CompoundMenuNodeRole.Submenu ? this.menuRegistry.getPath(menuNode) : undefined; + private createToolbarItem(menuPath: MenuPath, menuNode: RenderedMenuNode, itemOptions: toolbarItemOptions): NotebookCellToolbarItem { return { id: menuNode.id, icon: menuNode.icon, label: menuNode.label, - onClick: menuPath ? - e => this.contextMenuRenderer.render( - { - anchor: e.nativeEvent, - menuPath, - includeAnchorArg: false, - args: itemOptions.contextMenuArgs?.(), - context: this.notebookContextManager.context || (e.currentTarget as HTMLElement) - }) : - () => this.commandRegistry.executeCommand(menuNode.command!, ...(itemOptions.commandArgs?.() ?? [])), - isVisible: () => menuPath ? true : Boolean(this.commandRegistry.getVisibleHandler(menuNode.command!, ...(itemOptions.commandArgs?.() ?? []))), - contextKeys: menuNode.when ? this.contextKeyService.parseKeys(menuNode.when) : undefined + onClick: e => { + if (CompoundMenuNode.is(menuNode)) { + this.contextMenuRenderer.render( + { + anchor: e.nativeEvent, + menuPath: menuPath, + menu: menuNode, + includeAnchorArg: false, + args: itemOptions.contextMenuArgs?.(), + context: this.notebookContextManager.context || (e.currentTarget as HTMLElement) + }); + } else if (CommandMenu.is(menuNode)) { + menuNode.run(menuPath, itemOptions.commandArgs?.() ?? []); + }; + }, + isVisible: () => true }; } } diff --git a/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx b/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx index 426878d51005b..f81c5bca0c77e 100644 --- a/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx +++ b/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx @@ -17,11 +17,10 @@ import * as React from '@theia/core/shared/react'; import { ACTION_ITEM } from '@theia/core/lib/browser'; import { NotebookCellToolbarItem } from './notebook-cell-toolbar-factory'; import { DisposableCollection, Event } from '@theia/core'; -import { ContextKeyChangeEvent } from '@theia/core/lib/browser/context-key-service'; export interface NotebookCellToolbarProps { getMenuItems: () => NotebookCellToolbarItem[]; - onContextKeysChanged: Event; + onContextChanged: Event; } interface NotebookCellToolbarState { @@ -34,11 +33,9 @@ abstract class NotebookCellActionBar extends React.Component { + this.toDispose.push(props.onContextChanged(e => { const menuItems = this.props.getMenuItems(); - if (menuItems.some(item => item.contextKeys ? e.affects(item.contextKeys) : false)) { - this.setState({ inlineItems: menuItems }); - } + this.setState({ inlineItems: menuItems }); })); this.state = { inlineItems: this.props.getMenuItems() }; } diff --git a/packages/notebook/src/browser/view/notebook-main-toolbar.tsx b/packages/notebook/src/browser/view/notebook-main-toolbar.tsx index 59b04784d8b4e..9ff72cd2bc26c 100644 --- a/packages/notebook/src/browser/view/notebook-main-toolbar.tsx +++ b/packages/notebook/src/browser/view/notebook-main-toolbar.tsx @@ -13,7 +13,7 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { ArrayUtils, CommandRegistry, CompoundMenuNodeRole, DisposableCollection, MenuModelRegistry, MenuNode, nls } from '@theia/core'; +import { ArrayUtils, CommandMenu, CommandRegistry, DisposableCollection, Group, GroupImpl, MenuModelRegistry, MenuNode, MenuPath, nls } from '@theia/core'; import * as React from '@theia/core/shared/react'; import { codicon, ContextMenuRenderer } from '@theia/core/lib/browser'; import { NotebookCommands, NotebookMenus } from '../contributions/notebook-actions-contribution'; @@ -21,7 +21,6 @@ import { NotebookModel } from '../view-model/notebook-model'; import { NotebookKernelService } from '../service/notebook-kernel-service'; import { inject, injectable } from '@theia/core/shared/inversify'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; -import { NotebookCommand } from '../../common'; import { NotebookContextManager } from '../service/notebook-context-manager'; export interface NotebookMainToolbarProps { @@ -97,19 +96,12 @@ export class NotebookMainToolbar extends React.Component(); - this.getAllContextKeys(this.getMenuItems(), contextKeys); - props.notebookContextManager.onDidChangeContext(e => { - if (e.affects(contextKeys)) { - this.forceUpdate(); - } - }); - props.contextKeyService.onDidChange(e => { - if (e.affects(contextKeys)) { - this.forceUpdate(); + const menuItems = this.getMenuItems(); + for (const item of menuItems) { + if (item.onDidChange) { + item.onDidChange(() => this.forceUpdate()) } - }); - + } } override componentWillUnmount(): void { @@ -137,14 +129,16 @@ export class NotebookMainToolbar extends React.Component item.id).forEach(id => contextMenu.removeNode(id)); - hiddenItems.forEach(item => contextMenu.addNode(item)); + const menu = new GroupImpl(this.props.contextKeyService, NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU[0]) + + hiddenItems.forEach(item => menu.addNode(item)); this.props.contextMenuRenderer.render({ anchor: event, - menuPath: [NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU], + menuPath: NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU, + menu: menu, + contextKeyService: this.props.contextKeyService, context: this.props.editorNode, args: [this.props.notebookModel.uri] }); @@ -152,8 +146,8 @@ export class NotebookMainToolbar extends React.Component - {menuItems.slice(0, menuItems.length - this.calculateNumberOfHiddenItems(menuItems)).map(item => this.renderMenuItem(item))} + return
    + {menuItems.slice(0, menuItems.length - this.calculateNumberOfHiddenItems(menuItems)).map(item => this.renderMenuItem(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR, item))} { this.state.numberOfHiddenItems > 0 && this.renderContextMenu(e.nativeEvent, menuItems)} /> @@ -180,51 +174,31 @@ export class NotebookMainToolbar extends React.Component this.renderMenuItem(child, item.id)) ?? []); + protected renderMenuItem(itemPath: MenuPath, item: MenuNode, submenu?: string): React.ReactNode { + if (Group.is(item)) { + const itemNodes = ArrayUtils.coalesce(item.children?.map(child => this.renderMenuItem([...itemPath, child.id], child, item.id)) ?? []); return {itemNodes} {itemNodes && itemNodes.length > 0 && } ; - } else if ((this.nativeSubmenus.includes(submenu ?? '')) || !item.when || this.props.contextKeyService.match(item.when, this.props.editorNode)) { - const visibleCommand = Boolean(this.props.commandRegistry.getVisibleHandler(item.command ?? '', this.props.notebookModel)); - if (!visibleCommand) { - return undefined; - } - const command = this.props.commandRegistry.getCommand(item.command ?? '') as NotebookCommand | undefined; - const label = command?.shortTitle ?? item.label; - const title = command?.tooltip ?? item.label; - return
    { - if (item.command && (!item.when || this.props.contextKeyService.match(item.when, this.props.editorNode))) { - this.props.commandRegistry.executeCommand(item.command, this.props.notebookModel.uri); - } + item.run(itemPath, this.props.notebookModel.uri); }}> - {label} + {item.label}
    ; } return undefined; } protected getMenuItems(): readonly MenuNode[] { - const menuPath = NotebookMenus.NOTEBOOK_MAIN_TOOLBAR; - const pluginCommands = this.props.menuRegistry.getMenuNode(menuPath).children; - const theiaCommands = this.props.menuRegistry.getMenu([menuPath]).children; - return theiaCommands.concat(pluginCommands); + return this.props.menuRegistry.getMenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR).children; } - protected getAdditionalClasses(item: MenuNode): string { - return !item.when || this.props.contextKeyService.match(item.when, this.props.editorNode) ? '' : ' theia-mod-disabled'; - } - - protected getAllContextKeys(menus: readonly MenuNode[], keySet: Set): void { - menus.filter(item => item.when) - .forEach(item => this.props.contextKeyService.parseKeys(item.when!)?.forEach(key => keySet.add(key))); - - menus.filter(item => item.children && item.children.length > 0) - .forEach(item => this.getAllContextKeys(item.children!, keySet)); + protected getAdditionalClasses(itemPath: MenuPath, item: CommandMenu): string { + return item.isEnabled(itemPath, this.props.editorNode) ? '' : ' theia-mod-disabled'; } protected calculateNumberOfHiddenItems(allMenuItems: readonly MenuNode[]): number { 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 893863113d0a4..d887f27a97924 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 @@ -173,8 +173,6 @@ export class PluginVscodeCommandsContribution implements CommandContribution { protected readonly quickOpenWorkspace: QuickOpenWorkspace; @inject(TerminalService) protected readonly terminalService: TerminalService; - @inject(CodeEditorWidgetUtil) - protected readonly codeEditorWidgetUtil: CodeEditorWidgetUtil; @inject(PluginServer) protected readonly pluginServer: PluginServer; @inject(FileService) @@ -412,7 +410,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { return (resourceUri && resourceUri.toString()) === uriString; }); } - const toClose = this.shell.widgets.filter(widget => widget !== editor && this.codeEditorWidgetUtil.is(widget)); + const toClose = this.shell.widgets.filter(widget => widget !== editor && CodeEditorWidgetUtil.is(widget)); await this.shell.closeMany(toClose); } }); @@ -435,7 +433,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { if (editor) { const tabBar = this.shell.getTabBarFor(editor); if (tabBar) { - cb(tabBar, ({ owner }) => this.codeEditorWidgetUtil.is(owner)); + cb(tabBar, ({ owner }) => CodeEditorWidgetUtil.is(owner)); } } }; @@ -460,7 +458,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { for (const tabBar of this.shell.allTabBars) { if (tabBar !== editorTabBar) { this.shell.closeTabs(tabBar, - ({ owner }) => this.codeEditorWidgetUtil.is(owner) + ({ owner }) => CodeEditorWidgetUtil.is(owner) ); } } @@ -480,7 +478,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { left = false; return false; } - return left && this.codeEditorWidgetUtil.is(owner); + return left && CodeEditorWidgetUtil.is(owner); } ); } @@ -500,7 +498,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { left = false; return false; } - return !left && this.codeEditorWidgetUtil.is(owner); + return !left && CodeEditorWidgetUtil.is(owner); } ); } @@ -509,7 +507,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { }); commands.registerCommand({ id: 'workbench.action.closeAllEditors' }, { execute: async () => { - const toClose = this.shell.widgets.filter(widget => this.codeEditorWidgetUtil.is(widget)); + const toClose = this.shell.widgets.filter(widget => CodeEditorWidgetUtil.is(widget)); await this.shell.closeMany(toClose); } }); 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 bb539c75df31d..9ff83effdd306 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 @@ -27,16 +27,18 @@ import * as React from '@theia/core/shared/react'; import { MouseTargetType } from '@theia/editor/lib/browser'; import { CommentsService } from './comments-service'; import { - ActionMenuNode, + CommandMenu, CommandRegistry, CompoundMenuNode, + DisposableCollection, MenuModelRegistry, MenuPath } from '@theia/core/lib/common'; -import { CommentsContextKeyService } from './comments-context-key-service'; +import { CommentsContext } from './comments-context'; import { RefObject } from '@theia/core/shared/react'; import * as monaco from '@theia/monaco-editor-core'; import { createRoot, Root } from '@theia/core/shared/react-dom/client'; +import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. @@ -64,7 +66,8 @@ export class CommentThreadWidget extends BaseWidget { private _commentThread: CommentThread, private commentService: CommentsService, protected readonly menus: MenuModelRegistry, - protected readonly contextKeyService: CommentsContextKeyService, + protected readonly commentsContext: CommentsContext, + protected readonly contextKeyService: ContextKeyService, protected readonly commands: CommandRegistry ) { super(); @@ -84,14 +87,9 @@ export class CommentThreadWidget extends BaseWidget { return; } })); - this.contextKeyService.commentIsEmpty.set(true); + this.commentsContext.commentIsEmpty.set(true); this.toDispose.push(this.zoneWidget.editor.onMouseDown(e => this.onEditorMouseDown(e))); - this.toDispose.push(this.contextKeyService.onDidChange(() => { - const commentForm = this.commentFormRef.current; - if (commentForm) { - commentForm.update(); - } - })); + this.toDispose.push(this._commentThread.onDidChangeCanReply(_canReply => { const commentForm = this.commentFormRef.current; if (commentForm) { @@ -102,9 +100,14 @@ export class CommentThreadWidget extends BaseWidget { this.update(); })); this.contextMenu = this.menus.getMenu(COMMENT_THREAD_CONTEXT); - this.contextMenu.children.map(node => node instanceof ActionMenuNode && node.when).forEach(exp => { - if (typeof exp === 'string') { - this.contextKeyService.setExpression(exp); + this.contextMenu.children.forEach(node => { + if (node.onDidChange) { + this.toDispose.push(node.onDidChange(() => { + const commentForm = this.commentFormRef.current; + if (commentForm) { + commentForm.update(); + } + })); } }); } @@ -288,6 +291,7 @@ export class CommentThreadWidget extends BaseWidget { {this._commentThread.comments?.map((comment, index) => )}
    extend private inputRef: RefObject = React.createRef(); private inputValue: string = ''; private readonly getInput = () => this.inputValue; + private toDisposeOnUnmount = new DisposableCollection(); private readonly clearInput: () => void = () => { const input = this.inputRef.current; if (input) { this.inputValue = ''; input.value = this.inputValue; - this.props.contextKeyService.commentIsEmpty.set(true); + this.props.commentsContext.commentIsEmpty.set(true); } }; @@ -364,11 +371,15 @@ export class CommentForm

    extend }, 100); } + override componentWillUnmount(): void { + this.toDisposeOnUnmount.dispose(); + } + private readonly onInput: (event: React.FormEvent) => void = (event: React.FormEvent) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (event.target as any).value; if (this.inputValue.length === 0 || value.length === 0) { - this.props.contextKeyService.commentIsEmpty.set(value.length === 0); + this.props.commentsContext.commentIsEmpty.set(value.length === 0); } this.inputValue = value; }; @@ -383,17 +394,10 @@ export class CommentForm

    extend this.setState = newState => { setState(newState); }; - - this.menu = this.props.menus.getMenu(COMMENT_THREAD_CONTEXT); - this.menu.children.map(node => node instanceof ActionMenuNode && node.when).forEach(exp => { - if (typeof exp === 'string') { - this.props.contextKeyService.setExpression(exp); - } - }); } override render(): React.ReactNode { - const { commands, commentThread, contextKeyService } = this.props; + const { commentThread, commentsContext, contextKeyService } = this.props; const hasExistingComments = commentThread.comments && commentThread.comments.length > 0; return commentThread.canReply ?

    @@ -416,8 +420,9 @@ export class CommentForm

    extend

    ; } @@ -469,10 +475,10 @@ export class ReviewComment

    protected hideHover = () => this.setState({ hover: false }); override render(): React.ReactNode { - const { comment, commentForm, contextKeyService, menus, commands, commentThread } = this.props; + const { comment, commentForm, contextKeyService, commentsContext, menus, commands, commentThread } = this.props; const commentUniqueId = comment.uniqueIdInThread; const { hover } = this.state; - contextKeyService.comment.set(comment.contextValue); + commentsContext.comment.set(comment.contextValue); return

    {comment.label}
    - {hover && menus.getMenu(COMMENT_TITLE).children.map((node, index) => node instanceof ActionMenuNode && - )} + {hover && menus.getMenu(COMMENT_TITLE).children.map((node, index): React.ReactNode => CommandMenu.is(node) && + )}
    { namespace CommentEditContainer { export interface Props { - contextKeyService: CommentsContextKeyService + contextKeyService: ContextKeyService; + commentsContext: CommentsContext; menus: MenuModelRegistry, comment: Comment; commentThread: CommentThread; @@ -572,7 +583,7 @@ export class CommentEditContainer extends React.Component
    - {menus.getMenu(COMMENT_CONTEXT).children.map((node, index) => { + {menus.getMenu(COMMENT_CONTEXT).children.map((node, index): React.ReactNode => { const onClick = () => { commands.executeCommand(node.id, { commentControlHandle: commentThread.controllerHandle, @@ -595,8 +606,8 @@ export class CommentEditContainer extends React.Component; + return CommandMenu.is(node) && + ; } )}
    @@ -606,18 +617,20 @@ export class CommentEditContainer extends React.Component { override render(): React.ReactNode { - const { node, commands, contextKeyService, commentThread, commentUniqueId } = this.props; - if (node.when && !contextKeyService.match(node.when)) { + const { node, nodePath, commands, contextKeyService, commentThread, commentUniqueId } = this.props; + if (node.isVisible(nodePath, contextKeyService, undefined)) { return false; } return
    @@ -636,8 +649,9 @@ export class CommentsInlineAction extends React.Component string; @@ -647,30 +661,32 @@ namespace CommentActions { export class CommentActions extends React.Component { override render(): React.ReactNode { - const { contextKeyService, commands, menu, commentThread, getInput, clearInput } = this.props; + const { contextKeyService, commentsContext, menuPath, menu, commentThread, getInput, clearInput } = this.props; return
    - {menu.children.map((node, index) => node instanceof ActionMenuNode && + {menu.children.map((node, index) => CommandMenu.is(node) && { - commands.executeCommand(node.id, { - commentControlHandle: commentThread.controllerHandle, - commentThreadHandle: commentThread.commentThreadHandle, + node.run( + [...menuPath, menu.id], { + thread: commentThread, text: getInput() }); clearInput(); }} contextKeyService={contextKeyService} + commentsContext={commentsContext} />)}
    ; } } namespace CommentAction { export interface Props { - contextKeyService: CommentsContextKeyService; - commands: CommandRegistry; - node: ActionMenuNode; + contextKeyService: ContextKeyService; + commentsContext: CommentsContext; + nodePath: MenuPath, + node: CommandMenu; onClick: () => void; } } @@ -678,11 +694,11 @@ namespace CommentAction { 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.when && !contextKeyService.match(node.when)) { + const { node, nodePath, contextKeyService, onClick } = this.props; + if (!node.isVisible(nodePath, contextKeyService, undefined)) { return false; } - const isEnabled = commands.isEnabled(node.command); + const isEnabled = node.isEnabled(nodePath); if (!isEnabled) { classNames.push(DISABLED_CLASS); } diff --git a/packages/plugin-ext/src/main/browser/comments/comments-context-key-service.ts b/packages/plugin-ext/src/main/browser/comments/comments-context.ts similarity index 74% rename from packages/plugin-ext/src/main/browser/comments/comments-context-key-service.ts rename to packages/plugin-ext/src/main/browser/comments/comments-context.ts index c8094ae3d6bc8..e329f7301966b 100644 --- a/packages/plugin-ext/src/main/browser/comments/comments-context-key-service.ts +++ b/packages/plugin-ext/src/main/browser/comments/comments-context.ts @@ -16,16 +16,13 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service'; -import { Emitter } from '@theia/core/lib/common'; @injectable() -export class CommentsContextKeyService { +export class CommentsContext { @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; protected readonly contextKeys: Set = new Set(); - protected readonly onDidChangeEmitter = new Emitter(); - readonly onDidChange = this.onDidChangeEmitter.event; protected _commentIsEmpty: ContextKey; protected _commentController: ContextKey; protected _comment: ContextKey; @@ -48,21 +45,5 @@ export class CommentsContextKeyService { this._commentController = this.contextKeyService.createKey('commentController', undefined); this._comment = this.contextKeyService.createKey('comment', undefined); this._commentIsEmpty = this.contextKeyService.createKey('commentIsEmpty', true); - this.contextKeyService.onDidChange(event => { - if (event.affects(this.contextKeys)) { - this.onDidChangeEmitter.fire(); - } - }); } - - setExpression(expression: string): void { - this.contextKeyService.parseKeys(expression)?.forEach(key => { - this.contextKeys.add(key); - }); - } - - match(expression: string | undefined): boolean { - return !expression || this.contextKeyService.match(expression); - } - } diff --git a/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts b/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts index 872b054a618c9..c0475be716073 100644 --- a/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts +++ b/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts @@ -24,9 +24,9 @@ import { CommentsService, CommentInfoMain } from './comments-service'; import { CommentThread } from '../../../common/plugin-api-rpc-model'; import { CommandRegistry, DisposableCollection, MenuModelRegistry } from '@theia/core/lib/common'; import { URI } from '@theia/core/shared/vscode-uri'; -import { CommentsContextKeyService } from './comments-context-key-service'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { Uri } from '@theia/plugin'; +import { CommentsContext } from './comments-context'; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. @@ -43,7 +43,7 @@ export class CommentsContribution { private emptyThreadsToAddQueue: [number, EditorMouseEvent | undefined][] = []; @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; - @inject(CommentsContextKeyService) protected readonly commentsContextKeyService: CommentsContextKeyService; + @inject(CommentsContext) protected readonly commentsContext: CommentsContext; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(CommandRegistry) protected readonly commands: CommandRegistry; @@ -193,10 +193,10 @@ export class CommentsContribution { if (editor) { const provider = this.commentService.getCommentController(owner); if (provider) { - this.commentsContextKeyService.commentController.set(provider.id); + this.commentsContext.commentController.set(provider.id); } - const zoneWidget = new CommentThreadWidget(editor, owner, thread, this.commentService, this.menus, this.commentsContextKeyService, this.commands); - zoneWidget.display({ afterLineNumber: thread.range?.startLineNumber ?? 0, heightInLines: 5 }); // messages with no range are put on top of the editor + const zoneWidget = new CommentThreadWidget(editor, owner, thread, this.commentService, this.menus, this.commentsContext, this.contextKeyService, this.commands); + zoneWidget.display({ afterLineNumber: thread.range?.startLineNumber || 0, heightInLines: 5 }); const currentEditor = this.getCurrentEditor(); if (currentEditor) { currentEditor.onDispose(() => zoneWidget.dispose()); 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 ae3823e2a7c01..e81b01a3cd897 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -17,18 +17,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { inject, injectable, optional } from '@theia/core/shared/inversify'; -import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter, nls } from '@theia/core'; +import { MenuPath, CommandRegistry, Disposable, DisposableCollection, nls, CommandMenu, AcceleratorSource, ContextExpressionMatcher } from '@theia/core'; import { MenuModelRegistry } from '@theia/core/lib/common'; import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { DeployedPlugin, IconUrl, Menu } from '../../../common'; import { ScmWidget } from '@theia/scm/lib/browser/scm-widget'; -import { QuickCommandService } from '@theia/core/lib/browser'; +import { KeybindingRegistry, QuickCommandService } from '@theia/core/lib/browser'; import { CodeEditorWidgetUtil, codeToTheiaMappings, ContributionPoint, PLUGIN_EDITOR_TITLE_MENU, PLUGIN_EDITOR_TITLE_RUN_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 { PluginMenuCommandAdapter } from './plugin-menu-command-adapter'; 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/base/common/themables'; @@ -37,40 +36,36 @@ import { ThemeIcon } from '@theia/monaco-editor-core/esm/vs/base/common/themable export class MenusContributionPointHandler { @inject(MenuModelRegistry) private readonly menuRegistry: MenuModelRegistry; - @inject(CommandRegistry) private readonly commands: CommandRegistry; + @inject(CommandRegistry) private readonly commandRegistry: 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(PluginMenuCommandAdapter) pluginMenuCommandAdapter: PluginMenuCommandAdapter; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(PluginSharedStyle) protected readonly style: PluginSharedStyle; + @inject(KeybindingRegistry) keybindingRegistry: KeybindingRegistry; + @inject(QuickCommandService) @optional() 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); - this.tabBarToolbar.registerMenuDelegate(PLUGIN_EDITOR_TITLE_MENU, widget => this.codeEditorWidgetUtil.is(widget)); + this.tabBarToolbar.registerMenuDelegate(PLUGIN_EDITOR_TITLE_MENU, widget => CodeEditorWidgetUtil.is(widget)); + this.menuRegistry.registerSubmenu(PLUGIN_EDITOR_TITLE_RUN_MENU, 'EditorTitleRunMenu'); this.tabBarToolbar.registerItem({ - id: this.tabBarToolbar.toElementId(PLUGIN_EDITOR_TITLE_RUN_MENU), menuPath: PLUGIN_EDITOR_TITLE_RUN_MENU, - icon: 'debug-alt', text: nls.localizeByDefault('Run or Debug...'), - command: '', group: 'navigation', isVisible: widget => this.codeEditorWidgetUtil.is(widget) + id: this.tabBarToolbar.toElementId(PLUGIN_EDITOR_TITLE_RUN_MENU), + menuPath: PLUGIN_EDITOR_TITLE_RUN_MENU, + icon: 'debug-alt', + text: nls.localizeByDefault('Run or Debug...'), + command: '', + group: 'navigation', + isVisible: widget => CodeEditorWidgetUtil.is(widget) }); this.tabBarToolbar.registerMenuDelegate(PLUGIN_SCM_TITLE_MENU, widget => widget instanceof ScmWidget); - this.tabBarToolbar.registerMenuDelegate(PLUGIN_VIEW_TITLE_MENU, widget => !this.codeEditorWidgetUtil.is(widget)); - 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(); - } - }); + this.tabBarToolbar.registerMenuDelegate(PLUGIN_VIEW_TITLE_MENU, widget => !CodeEditorWidgetUtil.is(widget)); } - private getMatchingMenu(contributionPoint: ContributionPoint): MenuPath[] | undefined { + private getMatchingTheiaMenuPaths(contributionPoint: string): MenuPath[] | undefined { return codeToTheiaMappings.get(contributionPoint); } @@ -86,7 +81,7 @@ export class MenusContributionPointHandler { 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); + this.menuRegistry.registerSubmenu([submenu.id], submenu.label, undefined, iconClass); } for (const [contributionPoint, items] of Object.entries(allMenus)) { @@ -95,8 +90,10 @@ export class MenusContributionPointHandler { if (contributionPoint === 'commandPalette') { toDispose.push(this.registerCommandPaletteAction(item)); } else { - this.checkTitleContribution(contributionPoint, item, toDispose); - const targets = this.getMatchingMenu(contributionPoint as ContributionPoint) ?? [contributionPoint]; + let targets = this.getMatchingTheiaMenuPaths(contributionPoint as ContributionPoint); + if (!targets) { + targets = [[contributionPoint]]; + } const { group, order } = this.parseGroup(item.group); const { submenu, command } = item; if (submenu && command) { @@ -105,19 +102,50 @@ export class MenusContributionPointHandler { ); } if (command) { - toDispose.push(this.commandAdapter.addCommand(command)); + targets.forEach(target => { + const menuPath = group ? [...target, group] : target; - const node = new ActionMenuNode({ - commandId: command, - when: item.when, - order - }, this.commands); - const parent = this.menuRegistry.getMenuNode(target, group); - toDispose.push(parent.addNode(node)); + const cmd = this.commandRegistry.getCommand(command); + if (!cmd) { + console.debug(`No label for action menu node: No command "${command}" exists.`); + return; + } + const label = cmd.label || cmd.id; + const icon = cmd.iconClass; + const action: CommandMenu & AcceleratorSource = { + id: command, + sortString: order || '', + isVisible: (effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: any[]): boolean => { + if (item.when && !contextMatcher.match(item.when, context)) { + return false; + } + + return this.commandRegistry.isVisible(command, ...this.pluginMenuCommandAdapter.getArgumentAdapter(contributionPoint)(...args)); + }, + icon: icon, + label: label, + isEnabled: (effeciveMenuPath: MenuPath, ...args: any[]): boolean => + this.commandRegistry.isEnabled(command, ...this.pluginMenuCommandAdapter.getArgumentAdapter(contributionPoint)(...args)), + run: (effeciveMenuPath: MenuPath, ...args: any[]): Promise => + this.commandRegistry.executeCommand(command, ...this.pluginMenuCommandAdapter.getArgumentAdapter(contributionPoint)(...args)), + isToggled: (effectiveMenuPath: MenuPath) => false, + getAccelerator: (context: HTMLElement | undefined): string[] => { + const bindings = this.keybindingRegistry.getKeybindingsForCommand(command); + // Only consider the first active keybinding. + if (bindings.length) { + const binding = bindings.find(b => this.keybindingRegistry.isEnabledInScope(b, context)); + if (binding) { + return this.keybindingRegistry.acceleratorFor(binding, '+', true); + } + } + return []; + } + }; + toDispose.push(this.menuRegistry.registerCommandMenu(menuPath, action)); }); } else if (submenu) { - targets.forEach(target => toDispose.push(this.menuRegistry.linkSubmenu(target, submenu!, { order, when: item.when }, group))); + targets.forEach(target => toDispose.push(this.menuRegistry.linkCompoundMenuNode(group ? [...target, group] : target, [submenu!], order, item.when))); } } } catch (error) { @@ -145,19 +173,6 @@ export class MenusContributionPointHandler { return Disposable.NULL; } - 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))); - } - toDispose.push(Disposable.create(() => this.onDidChangeTitleContributionEmitter.fire())); - } - } - } - protected toIconClass(url: IconUrl, toDispose: DisposableCollection): string | undefined { if (typeof url === 'string') { const asThemeIcon = ThemeIcon.fromString(url); diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts index 2f1dc3c7e49ab..bac633166df56 100644 --- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { CommandRegistry, Disposable, MenuCommandAdapter, MenuPath, SelectionService, UriSelection } from '@theia/core'; +import { 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'; @@ -29,57 +29,20 @@ import { ScmCommandArg, TimelineCommandArg, TreeViewItemReference } from '../../ import { TestItemReference, TestMessageArg } from '../../../common/test-types'; 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'; +import { CodeEditorWidgetUtil, ContributionPoint } from './vscode-theia-menu-mappings'; import { TestItem, TestMessage } from '@theia/test/lib/browser/test-service'; 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); - } +function identity(...args: unknown[]) { + return args; } - @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; +export class PluginMenuCommandAdapter { + @inject(ScmService) private readonly scmService: ScmService; + @inject(SelectionService) private readonly selectionService: SelectionService; + @inject(ResourceContextKey) private readonly resourceContextKey: ResourceContextKey; - protected readonly commands = new ReferenceCountingSet(); protected readonly argumentAdapters = new Map(); - protected readonly separator = ':)(:'; @postConstruct() protected init(): void { @@ -89,8 +52,8 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { const noArgs: ArgumentAdapter = () => []; const toScmArgs: ArgumentAdapter = (...args) => this.toScmArgs(...args); const selectedResource = () => this.getSelectedResources(); - const widgetURI: ArgumentAdapter = widget => this.codeEditorUtil.is(widget) ? [this.codeEditorUtil.getResourceUri(widget)] : []; - (>[ + const widgetURI: ArgumentAdapter = widget => CodeEditorWidgetUtil.is(widget) ? [CodeEditorWidgetUtil.getResourceUri(widget)] : []; + (>[ ['comments/comment/context', toCommentArgs], ['comments/comment/title', toCommentArgs], ['comments/commentThread/context', toCommentArgs], @@ -117,82 +80,12 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { ['terminal/context', noArgs], ['terminal/title/context', noArgs], ]).forEach(([contributionPoint, adapter]) => { - if (adapter) { - const paths = codeToTheiaMappings.get(contributionPoint); - if (paths) { - paths.forEach(path => this.addArgumentAdapter(path, adapter)); - } - } + this.argumentAdapters.set(contributionPoint, 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 { - let result; - let length = 0; - for (const [key, value] of this.argumentAdapters.entries()) { - const candidate = key.split(this.separator); - if (this.isPrefixOf(candidate, menuPath) && candidate.length > length) { - result = value; - length = candidate.length; - } - } - return result; - } - isPrefixOf(candidate: string[], menuPath: MenuPath): boolean { - if (candidate.length > menuPath.length) { - return false; - } - for (let i = 0; i < candidate.length; i++) { - if (candidate[i] !== menuPath[i]) { - return false; - } - } - return true; } - protected addArgumentAdapter(menuPath: MenuPath, adapter: ArgumentAdapter): void { - this.argumentAdapters.set(menuPath.join(this.separator), adapter); + getArgumentAdapter(contributionPoint: string): ArgumentAdapter { + return this.argumentAdapters.get(contributionPoint) || identity; } /* eslint-disable @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 index 3a519199040f2..57ec701348099 100644 --- a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts +++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts @@ -17,7 +17,6 @@ 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'; @@ -74,7 +73,7 @@ export const implementedVSCodeContributionPoints = [ export type ContributionPoint = (typeof implementedVSCodeContributionPoints)[number]; /** The values are menu paths to which the VSCode contribution points correspond */ -export const codeToTheiaMappings = new Map([ +export const codeToTheiaMappings = new Map([ ['comments/comment/context', [COMMENT_CONTEXT]], ['comments/comment/title', [COMMENT_TITLE]], ['comments/commentThread/context', [COMMENT_THREAD_CONTEXT]], @@ -106,12 +105,11 @@ export const codeToTheiaMappings = new Map([ ]); type CodeEditorWidget = EditorWidget | WebviewWidget; -@injectable() -export class CodeEditorWidgetUtil { - is(arg: unknown): arg is CodeEditorWidget { +export namespace CodeEditorWidgetUtil { + export function is(arg: unknown): arg is CodeEditorWidget { return arg instanceof EditorWidget || arg instanceof WebviewWidget; } - getResourceUri(editor: CodeEditorWidget): CodeUri | undefined { + export function 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 ef2530a437d21..a40f40b4a5d58 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 @@ -68,7 +68,7 @@ import { WebviewWidgetFactory } from './webview/webview-widget-factory'; import { CommentsService, PluginCommentService } from './comments/comments-service'; import { CommentingRangeDecorator } from './comments/comments-decorator'; import { CommentsContribution } from './comments/comments-contribution'; -import { CommentsContextKeyService } from './comments/comments-context-key-service'; +import { CommentsContext } from './comments/comments-context'; import { PluginCustomEditorRegistry } from './custom-editors/plugin-custom-editor-registry'; import { CustomEditorWidgetFactory } from '../browser/custom-editors/custom-editor-widget-factory'; import { CustomEditorWidget } from './custom-editors/custom-editor-widget'; @@ -77,7 +77,6 @@ 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'; import './theme-icon-override'; import { PluginIconService } from './plugin-icon-service'; @@ -250,7 +249,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(MenusContributionPointHandler).toSelf().inSingletonScope(); bind(PluginMenuCommandAdapter).toSelf().inSingletonScope(); - bind(CodeEditorWidgetUtil).toSelf().inSingletonScope(); bind(KeybindingsContributionPointHandler).toSelf().inSingletonScope(); bind(PluginContributionHandler).toSelf().inSingletonScope(); @@ -266,7 +264,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(CommentsService).to(PluginCommentService).inSingletonScope(); bind(CommentingRangeDecorator).toSelf().inSingletonScope(); bind(CommentsContribution).toSelf().inSingletonScope(); - bind(CommentsContextKeyService).toSelf().inSingletonScope(); + bind(CommentsContext).toSelf().inSingletonScope(); bind(WebviewFrontendSecurityWarnings).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(WebviewFrontendSecurityWarnings); 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 f92b1f90a5bce..19b3737527147 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 @@ -35,7 +35,7 @@ import { ApplicationShell, KeybindingRegistry } from '@theia/core/lib/browser'; -import { MenuPath, MenuModelRegistry, ActionMenuNode } from '@theia/core/lib/common/menu'; +import { MenuPath, MenuModelRegistry, CommandMenu, AcceleratorSource } from '@theia/core/lib/common/menu'; import * as React from '@theia/core/shared/react'; import { PluginSharedStyle } from '../plugin-shared-style'; import { ACTION_ITEM, Widget } from '@theia/core/lib/browser/widgets/widget'; @@ -763,7 +763,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { return this.contextKeys.with({ view: this.id, viewItem: treeViewNode.contextValue }, () => { const menu = this.menus.getMenu(VIEW_ITEM_INLINE_MENU); const args = this.toContextMenuArgs(treeViewNode); - const inlineCommands = menu.children.filter((item): item is ActionMenuNode => item instanceof ActionMenuNode); + const inlineCommands = menu.children.filter((item): item is CommandMenu => CommandMenu.is(item)); const tailDecorations = super.renderTailDecorations(treeViewNode, props); return {inlineCommands.length > 0 &&
    @@ -796,17 +796,18 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected renderInlineCommand(actionMenuNode: ActionMenuNode, index: number, tabbable: boolean, args: any[]): React.ReactNode { - if (!actionMenuNode.icon || !this.commands.isVisible(actionMenuNode.command, ...args) || !actionMenuNode.when || !this.contextKeys.match(actionMenuNode.when)) { + protected renderInlineCommand(actionMenuNode: CommandMenu, index: number, tabbable: boolean, args: any[]): React.ReactNode { + const nodePath = [...VIEW_ITEM_INLINE_MENU, actionMenuNode.id]; + if (!actionMenuNode.icon || !actionMenuNode.isVisible(nodePath, this.contextKeys, undefined)) { return false; } const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, actionMenuNode.icon, ACTION_ITEM, 'theia-tree-view-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; - const titleString = actionMenuNode.label + this.resolveKeybindingForCommand(actionMenuNode.command); + const titleString = actionMenuNode.label + (AcceleratorSource.is(actionMenuNode) ? actionMenuNode.getAccelerator(undefined).join('+') : ''); return
    { e.stopPropagation(); - this.commands.executeCommand(actionMenuNode.command, ...args); + actionMenuNode.run(nodePath, ...args); }} />; } diff --git a/packages/preferences/src/browser/util/preference-types.ts b/packages/preferences/src/browser/util/preference-types.ts index 58adcb95bbd31..6e14d18973c28 100644 --- a/packages/preferences/src/browser/util/preference-types.ts +++ b/packages/preferences/src/browser/util/preference-types.ts @@ -22,7 +22,7 @@ import { SelectableTreeNode, PreferenceInspection, CommonCommands, -} from '@theia/core/lib/browser'; + } from '@theia/core/lib/browser'; import { Command, MenuPath } from '@theia/core'; import { JSONValue } from '@theia/core/shared/@lumino/coreutils'; import { JsonType } from '@theia/core/lib/common/json-schema'; diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index 4fe5d5b5e58e9..cbf509e7e0534 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -16,7 +16,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Position, Range } from '@theia/core/shared/vscode-languageserver-protocol'; -import { ActionMenuNode, Disposable, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; +import { CommandMenu, Disposable, Emitter, Event, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; import { codicon } from '@theia/core/lib/browser'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; @@ -56,7 +56,6 @@ export class DirtyDiffWidget implements Disposable { @inject(MonacoEditorProvider) readonly editorProvider: MonacoEditorProvider, @inject(ContextKeyService) readonly contextKeyService: ContextKeyService, @inject(MenuModelRegistry) readonly menuModelRegistry: MenuModelRegistry, - @inject(MenuCommandExecutor) readonly menuCommandExecutor: MenuCommandExecutor ) { } @postConstruct() @@ -262,9 +261,9 @@ class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { super.create(); const diffEditor = await this.diffEditorPromise!; return new Promise(resolve => { - // setTimeout is needed here because the non-side-by-side diff editor might still not have created the view zones; - // otherwise, the first change shown might not be properly revealed in the diff editor. - // see also https://github.com/microsoft/vscode/blob/b30900b56c4b3ca6c65d7ab92032651f4cb23f15/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L248 + // setTimeout is needed here because the non-side-by-side diff editor might still not have created the view zones; + // otherwise, the first change shown might not be properly revealed in the diff editor. + // see also https://github.com/microsoft/vscode/blob/b30900b56c4b3ca6c65d7ab92032651f4cb23f15/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L248 const disposable = diffEditor.diffEditor.onDidUpdateDiff(() => setTimeout(() => { resolve(diffEditor); disposable.dispose(); @@ -305,16 +304,17 @@ class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { private updateActions(): void { this.clearActions(); - const { contextKeyService, menuModelRegistry, menuCommandExecutor } = this.widget; + const { contextKeyService, menuModelRegistry } = this.widget; contextKeyService.with({ originalResourceScheme: this.widget.previousRevisionUri.scheme }, () => { for (const menuPath of [SCM_CHANGE_TITLE_MENU, PLUGIN_SCM_CHANGE_TITLE_MENU]) { const menu = menuModelRegistry.getMenu(menuPath); for (const item of menu.children) { - if (item instanceof ActionMenuNode) { - const { command, id, label, icon, when } = item; - if (icon && menuCommandExecutor.isVisible(menuPath, command, this.widget) && (!when || contextKeyService.match(when))) { - this.addAction(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => { - menuCommandExecutor.executeCommand(menuPath, command, this.widget); + if (CommandMenu.is(item)) { + const { id, label, icon } = item; + const itemPath = [...menuPath, id]; + if (icon && item.isVisible(itemPath, contextKeyService, undefined)) { + this.addAction(id, label, icon, item.isEnabled(itemPath), () => { + item.run(itemPath, this.widget); }); } } diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts index bcc0b5fbd4964..de964bbde720a 100644 --- a/packages/scm/src/browser/scm-contribution.ts +++ b/packages/scm/src/browser/scm-contribution.ts @@ -28,7 +28,7 @@ import { ColorTheme, CssStyleCollector } from '@theia/core/lib/browser'; -import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarAction } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { CommandRegistry, Command, Disposable, DisposableCollection, CommandService, MenuModelRegistry } from '@theia/core/lib/common'; import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service'; import { ScmService } from './scm-service'; @@ -259,7 +259,7 @@ export class ScmContribution extends AbstractViewContribution impleme const viewModeEmitter = new Emitter(); const registerToggleViewItem = (command: Command, mode: 'tree' | 'list') => { const id = command.id; - const item: TabBarToolbarItem = { + const item: TabBarToolbarAction = { id, command: id, tooltip: command.label, diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx index 732c395b8b371..65390c0d810d7 100644 --- a/packages/scm/src/browser/scm-tree-widget.tsx +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -23,7 +23,7 @@ import { isOSX } from '@theia/core/lib/common/os'; import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; import { TreeWidget, TreeNode, SelectableTreeNode, TreeModel, TreeProps, NodeProps, TREE_NODE_SEGMENT_CLASS, TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree'; import { ScmTreeModel, ScmFileChangeRootNode, ScmFileChangeGroupNode, ScmFileChangeFolderNode, ScmFileChangeNode } from './scm-tree-model'; -import { MenuCommandExecutor, MenuModelRegistry, ActionMenuNode, CompoundMenuNode, MenuPath } from '@theia/core/lib/common/menu'; +import { MenuModelRegistry, CompoundMenuNode, MenuPath, CommandMenu } from '@theia/core/lib/common/menu'; import { ScmResource } from './scm-provider'; import { ContextMenuRenderer, LabelProvider, CorePreferences, DiffUris, ACTION_ITEM } from '@theia/core/lib/browser'; import { ScmContextKeyService } from './scm-context-key-service'; @@ -48,7 +48,6 @@ export class ScmTreeWidget extends TreeWidget { static RESOURCE_CONTEXT_MENU = ['RESOURCE_CONTEXT_MENU']; static RESOURCE_INLINE_MENU = ['RESOURCE_CONTEXT_MENU', 'inline']; - @inject(MenuCommandExecutor) protected readonly menuCommandExecutor: MenuCommandExecutor; @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; @inject(ScmContextKeyService) protected readonly contextKeys: ScmContextKeyService; @inject(EditorManager) protected readonly editorManager: EditorManager; @@ -109,7 +108,6 @@ export class ScmTreeWidget extends TreeWidget { model={this.model} treeNode={node} renderExpansionToggle={() => this.renderExpansionToggle(node, props)} - commandExecutor={this.menuCommandExecutor} contextMenuRenderer={this.contextMenuRenderer} menus={this.menus} contextKeys={this.contextKeys} @@ -128,7 +126,6 @@ export class ScmTreeWidget extends TreeWidget { treeNode={node} sourceUri={node.sourceUri} renderExpansionToggle={() => this.renderExpansionToggle(node, props)} - commandExecutor={this.menuCommandExecutor} contextMenuRenderer={this.contextMenuRenderer} menus={this.menus} contextKeys={this.contextKeys} @@ -149,7 +146,6 @@ export class ScmTreeWidget extends TreeWidget { model={this.model} treeNode={node} contextMenuRenderer={this.contextMenuRenderer} - commandExecutor={this.menuCommandExecutor} menus={this.menus} contextKeys={this.contextKeys} labelProvider={this.labelProvider} @@ -536,7 +532,6 @@ export abstract class ScmElement

    export namespace ScmElement { export interface Props extends ScmTreeWidget.Props { renderExpansionToggle: () => React.ReactNode; - commandExecutor: MenuCommandExecutor; } export interface State { hover: boolean @@ -547,7 +542,7 @@ export class ScmResourceComponent extends ScmElement override render(): JSX.Element | undefined { const { hover } = this.state; - const { model, treeNode, colors, parentPath, sourceUri, decoration, labelProvider, commandExecutor, menus, contextKeys, caption, isLightTheme } = this.props; + const { model, treeNode, colors, parentPath, sourceUri, decoration, labelProvider, menus, contextKeys, caption, isLightTheme } = this.props; const resourceUri = new URI(sourceUri); const decorationIcon = treeNode.decorations; @@ -584,7 +579,6 @@ export class ScmResourceComponent extends ScmElement hover, menu: menus.getMenu(ScmTreeWidget.RESOURCE_INLINE_MENU), menuPath: ScmTreeWidget.RESOURCE_INLINE_MENU, - commandExecutor, args: this.contextMenuArgs, contextKeys, model, @@ -669,7 +663,7 @@ export class ScmResourceGroupElement extends ScmElement { override render(): React.ReactNode { - const { hover, menu, menuPath, args, commandExecutor, model, treeNode, contextKeys, children } = this.props; + const { hover, menu, menuPath, args, model, treeNode, contextKeys, children } = this.props; return

    {hover && menu.children - .map((node, index) => node instanceof ActionMenuNode && - )} + .map((node, index) => CommandMenu.is(node) && + )}
    {children}
    ; @@ -793,7 +785,6 @@ export namespace ScmInlineActions { hover: boolean; menu: CompoundMenuNode; menuPath: MenuPath; - commandExecutor: MenuCommandExecutor; model: ScmTreeModel; treeNode: TreeNode; contextKeys: ScmContextKeyService; @@ -804,14 +795,14 @@ export namespace ScmInlineActions { export class ScmInlineAction extends React.Component { override render(): React.ReactNode { - const { node, model, treeNode, args, commandExecutor, menuPath, contextKeys } = this.props; + const { node, menuPath, model, treeNode, args, contextKeys } = this.props; let isActive: boolean = false; model.execInNodeContext(treeNode, () => { - isActive = contextKeys.match(node.when); + isActive = node.isVisible(menuPath, contextKeys, undefined, ...args); }); - if (!commandExecutor.isVisible(menuPath, node.command, ...args) || !isActive) { + if (!isActive) { return false; } return
    @@ -822,14 +813,13 @@ export class ScmInlineAction extends React.Component { protected execute = (event: React.MouseEvent) => { event.stopPropagation(); - const { commandExecutor, menuPath, node, args } = this.props; - commandExecutor.executeCommand([menuPath[0]], node.command, ...args); + const { node, menuPath, args } = this.props; + node.run(menuPath, ...args); }; } export namespace ScmInlineAction { export interface Props { - node: ActionMenuNode; - commandExecutor: MenuCommandExecutor; + node: CommandMenu; menuPath: MenuPath; model: ScmTreeModel; treeNode: TreeNode; diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index ccd9f9a7254da..61bc45c1b61ec 100644 --- a/packages/terminal/src/browser/terminal-frontend-contribution.ts +++ b/packages/terminal/src/browser/terminal-frontend-contribution.ts @@ -28,7 +28,7 @@ import { Event, ViewColumn, OS, - CompoundMenuNodeRole + MAIN_MENU_BAR } from '@theia/core/lib/common'; import { ApplicationShell, KeybindingContribution, KeyCode, Key, WidgetManager, PreferenceService, @@ -43,7 +43,6 @@ import { ContributedTerminalProfileStore, NULL_PROFILE, TerminalProfile, Termina import { UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler'; import { ShellTerminalServerProxy } from '../common/shell-terminal-protocol'; import URI from '@theia/core/lib/common/uri'; -import { MAIN_MENU_BAR } from '@theia/core'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; @@ -738,14 +737,9 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu commandId: TerminalCommands.KILL_TERMINAL.id }); - menus.registerSubmenu(TerminalMenus.TERMINAL_CONTRIBUTIONS, '', { - role: CompoundMenuNodeRole.Group - }); + menus.registerSubmenu(TerminalMenus.TERMINAL_CONTRIBUTIONS, ''); - menus.registerSubmenu(TerminalMenus.TERMINAL_TITLE_CONTRIBUTIONS, '', { - role: CompoundMenuNodeRole.Group, - when: 'isTerminalTab' - }); + menus.registerSubmenu(TerminalMenus.TERMINAL_TITLE_CONTRIBUTIONS, '', undefined, undefined, 'isTerminalTab'); } registerToolbarItems(toolbar: TabBarToolbarRegistry): void { diff --git a/packages/test/src/browser/view/test-tree-widget.tsx b/packages/test/src/browser/view/test-tree-widget.tsx index 3c5e0c7852bb7..206382ff424e3 100644 --- a/packages/test/src/browser/view/test-tree-widget.tsx +++ b/packages/test/src/browser/view/test-tree-widget.tsx @@ -26,7 +26,7 @@ import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { TestController, TestExecutionState, TestItem, TestService } from '../test-service'; import * as React from '@theia/core/shared/react'; import { DeltaKind, TreeDelta } from '../../common/tree-delta'; -import { ActionMenuNode, CommandRegistry, Disposable, DisposableCollection, Event, MenuModelRegistry, nls } from '@theia/core'; +import { AcceleratorSource, CommandMenu, CommandRegistry, Disposable, DisposableCollection, Event, MenuModelRegistry, nls } from '@theia/core'; import { TestExecutionStateManager } from './test-execution-state-manager'; import { TestOutputUIModel } from './test-output-ui-model'; import { TEST_VIEW_INLINE_MENU } from './test-view-contribution'; @@ -301,7 +301,7 @@ export class TestTreeWidget extends TreeWidget { return this.contextKeys.with({ view: this.id, controllerId: node.controller.id, testId: testItem.id, testItemHasUri: !!testItem.uri }, () => { const menu = this.menus.getMenu(TEST_VIEW_INLINE_MENU); const args = [node.testItem]; - const inlineCommands = menu.children.filter((item): item is ActionMenuNode => item instanceof ActionMenuNode); + const inlineCommands = menu.children.filter((item): item is CommandMenu => CommandMenu.is(item)); const tailDecorations = super.renderTailDecorations(node, props); return {inlineCommands.length > 0 &&
    @@ -316,17 +316,17 @@ export class TestTreeWidget extends TreeWidget { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected renderInlineCommand(actionMenuNode: ActionMenuNode, index: number, tabbable: boolean, args: any[]): React.ReactNode { - if (!actionMenuNode.icon || !this.commands.isVisible(actionMenuNode.command, ...args) || (actionMenuNode.when && !this.contextKeys.match(actionMenuNode.when))) { + protected renderInlineCommand(actionMenuNode: CommandMenu, index: number, tabbable: boolean, args: any[]): React.ReactNode { + if (!actionMenuNode.icon || !actionMenuNode.isVisible(TEST_VIEW_INLINE_MENU, this.contextKeys, this.node, ...args)) { return false; } const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, actionMenuNode.icon, ACTION_ITEM, 'theia-test-tree-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; - const titleString = actionMenuNode.label + this.resolveKeybindingForCommand(actionMenuNode.command); + const titleString = actionMenuNode.label + (AcceleratorSource.is(actionMenuNode) ? actionMenuNode.getAccelerator(undefined).join(' ') : ''); return
    { e.stopPropagation(); - this.commands.executeCommand(actionMenuNode.command, ...args); + actionMenuNode.run(TEST_VIEW_INLINE_MENU, ...args); }} />; } diff --git a/packages/test/src/browser/view/test-view-contribution.ts b/packages/test/src/browser/view/test-view-contribution.ts index b28ba19ab162b..63bad7adfdec8 100644 --- a/packages/test/src/browser/view/test-view-contribution.ts +++ b/packages/test/src/browser/view/test-view-contribution.ts @@ -274,6 +274,14 @@ export class TestViewContribution extends AbstractViewContribution DeflatedToolbarTree; + @inject(CommandRegistry) commandRegistry: CommandRegistry; + @inject(ContextKeyService) contextKeyService: ContextKeyService; + @inject(KeybindingRegistry) keybindingRegistry: KeybindingRegistry; + @inject(LabelParser) labelParser: LabelParser; + @inject(ContributionProvider) @named(ToolbarContribution) protected widgetContributions: ContributionProvider; @@ -68,15 +75,15 @@ export class ToolbarController { for (const column of Object.keys(schema.items)) { const currentColumn = schema.items[column as ToolbarAlignment]; for (const group of currentColumn) { - const newGroup: ToolbarItem[] = []; + const newGroup: TabBarToolbarItem[] = []; for (const item of group) { if (item.group === 'contributed') { const contribution = this.getContributionByID(item.id); if (contribution) { - newGroup.push(contribution); + newGroup.push(new ReactToolbarItemImpl(this.commandRegistry, this.contextKeyService, contribution)); } } else { - newGroup.push({ ...item }); + newGroup.push(new RenderedToolbarItemImpl(this.commandRegistry, this.contextKeyService, this.keybindingRegistry, this.labelParser, item)); } } if (newGroup.length) { diff --git a/packages/toolbar/src/browser/toolbar-interfaces.ts b/packages/toolbar/src/browser/toolbar-interfaces.ts index 1d1f0776c64ed..787fbf18bbe3c 100644 --- a/packages/toolbar/src/browser/toolbar-interfaces.ts +++ b/packages/toolbar/src/browser/toolbar-interfaces.ts @@ -15,7 +15,8 @@ // ***************************************************************************** import { interfaces } from '@theia/core/shared/inversify'; -import { ReactTabBarToolbarItem, RenderedToolbarItem, TabBarToolbar, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { ReactTabBarToolbarAction, RenderedToolbarAction, TabBarToolbar } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar/tab-toolbar-item'; export enum ToolbarAlignment { LEFT = 'left', @@ -25,7 +26,7 @@ export enum ToolbarAlignment { export interface ToolbarTreeSchema { items: { - [key in ToolbarAlignment]: ToolbarItem[][]; + [key in ToolbarAlignment]: TabBarToolbarItem[][]; }; } @@ -44,7 +45,7 @@ export interface ToolbarContributionProperties { toJSON(): DeflatedContributedToolbarItem; } -export type ToolbarContribution = ReactTabBarToolbarItem & ToolbarContributionProperties; +export type ToolbarContribution = ReactTabBarToolbarAction & ToolbarContributionProperties; export const ToolbarContribution = Symbol('ToolbarContribution'); @@ -52,9 +53,9 @@ export const Toolbar = Symbol('Toolbar'); export const ToolbarFactory = Symbol('ToolbarFactory'); export type Toolbar = TabBarToolbar; -export type ToolbarItem = ToolbarContribution | RenderedToolbarItem; +export type ToolbarItem = ToolbarContribution | RenderedToolbarAction; export interface DeflatedContributedToolbarItem { id: string; group: 'contributed' }; -export type ToolbarItemDeflated = DeflatedContributedToolbarItem | TabBarToolbarItem; +export type ToolbarItemDeflated = DeflatedContributedToolbarItem | RenderedToolbarAction; export const LateInjector = Symbol('LateInjector'); diff --git a/packages/toolbar/src/browser/toolbar.tsx b/packages/toolbar/src/browser/toolbar.tsx index 6188e20f107cd..9b257af02d55b 100644 --- a/packages/toolbar/src/browser/toolbar.tsx +++ b/packages/toolbar/src/browser/toolbar.tsx @@ -16,21 +16,20 @@ import * as React from '@theia/core/shared/react'; import { Anchor, ContextMenuAccess, KeybindingRegistry, PreferenceService, Widget, WidgetManager } from '@theia/core/lib/browser'; -import { LabelIcon } from '@theia/core/lib/browser/label-parser'; -import { ReactTabBarToolbarItem, RenderedToolbarItem, TabBarToolbar, TabBarToolbarFactory } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbar, TabBarToolbarFactory } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; -import { MenuPath, ProgressService } from '@theia/core'; +import { DisposableCollection, MenuPath, ProgressService } from '@theia/core'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { - ToolbarItem, ToolbarAlignment, ToolbarAlignmentString, ToolbarItemPosition, } from './toolbar-interfaces'; import { ToolbarController } from './toolbar-controller'; import { ToolbarMenus } from './toolbar-constants'; +import { TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar/tab-toolbar-item'; const TOOLBAR_BACKGROUND_DATA_ID = 'toolbar-wrapper'; export const TOOLBAR_PROGRESSBAR_ID = 'main-toolbar-progress'; @@ -80,22 +79,21 @@ export class ToolbarImpl extends TabBarToolbar { } protected updateInlineItems(): void { + this.toDisposeOnUpdateItems.dispose(); + this.toDisposeOnUpdateItems = new DisposableCollection(); this.inline.clear(); const { items } = this.model.toolbarItems; - const contextKeys = new Set(); for (const column of Object.keys(items)) { for (const group of items[column as ToolbarAlignment]) { for (const item of group) { this.inline.set(item.id, item); - - if (item.when) { - this.contextKeyService.parseKeys(item.when)?.forEach(key => contextKeys.add(key)); + if (item.onDidChange) { + this.toDisposeOnUpdateItems.push(item.onDidChange(() => this.maybeUpdate())); } } } } - this.updateContextKeyListener(contextKeys); } protected handleContextMenu = (e: React.MouseEvent): ContextMenuAccess => this.doHandleContextMenu(e); @@ -142,7 +140,7 @@ export class ToolbarImpl extends TabBarToolbar { return args; } - protected renderGroupsInColumn(groups: ToolbarItem[][], alignment: ToolbarAlignment): React.ReactNode[] { + protected renderGroupsInColumn(groups: TabBarToolbarItem[][], alignment: ToolbarAlignment): React.ReactNode[] { const nodes: React.ReactNode[] = []; groups.forEach((group, groupIndex) => { if (nodes.length && group.length) { @@ -181,7 +179,7 @@ export class ToolbarImpl extends TabBarToolbar { ); } - protected renderColumnWrapper(alignment: ToolbarAlignment, columnGroup: ToolbarItem[][]): React.ReactNode { + protected renderColumnWrapper(alignment: ToolbarAlignment, columnGroup: TabBarToolbarItem[][]): React.ReactNode { let children: React.ReactNode; if (alignment === ToolbarAlignment.LEFT) { children = ( @@ -235,23 +233,11 @@ export class ToolbarImpl extends TabBarToolbar { ); } - protected renderItemWithDraggableWrapper(item: ToolbarItem, position: ToolbarItemPosition): React.ReactNode { + protected renderItemWithDraggableWrapper(item: TabBarToolbarItem, position: ToolbarItemPosition): React.ReactNode { const stringifiedPosition = JSON.stringify(position); let toolbarItemClassNames = ''; - let renderBody: React.ReactNode; + const renderBody = item.render(this); - if (!ReactTabBarToolbarItem.is(item)) { - toolbarItemClassNames = TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM; - if (this.evaluateWhenClause(item.when)) { - toolbarItemClassNames += ' enabled'; - } - renderBody = this.renderItem(item); - } else { - const contribution = this.model.getContributionByID(item.id); - if (contribution) { - renderBody = contribution.render(); - } - } return (
    this.executeCommand(e, item)} onDragOver={this.handleOnDragEnter} onDragLeave={this.handleOnDragLeave} onContextMenu={this.handleContextMenu} @@ -279,41 +261,6 @@ export class ToolbarImpl extends TabBarToolbar { ); } - protected override renderItem( - item: RenderedToolbarItem, - ): React.ReactNode { - 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(' ')); - } - } - } - const command = this.commands.getCommand(item.command!); - const iconClass = (typeof item.icon === 'function' && item.icon()) || item.icon || command?.iconClass; - if (iconClass) { - classNames.push(iconClass); - } - let itemTooltip = ''; - if (item.tooltip) { - itemTooltip = item.tooltip; - } else if (command?.label) { - itemTooltip = command.label; - } - const keybindingString = this.resolveKeybindingForCommand(command?.id); - itemTooltip = `${itemTooltip}${keybindingString}`; - - return ( -
    - ); - } - protected handleOnDragStart = (e: React.DragEvent): void => this.doHandleOnDragStart(e); protected doHandleOnDragStart(e: React.DragEvent): void { const draggedElement = e.currentTarget; diff --git a/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts b/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts index ecbe0ec5b8abc..7c93c554e8e76 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts @@ -21,7 +21,7 @@ import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; import { FrontendApplication } from '@theia/core/lib/browser/frontend-application'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution'; import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution'; -import { CompoundMenuNodeRole, MenuModelRegistry, MessageService, SelectionService, nls } from '@theia/core/lib/common'; +import { MenuModelRegistry, MessageService, SelectionService, nls } from '@theia/core/lib/common'; import { Color } from '@theia/core/lib/common/color'; import { Command, CommandRegistry } from '@theia/core/lib/common/command'; import URI from '@theia/core/lib/common/uri'; @@ -161,10 +161,6 @@ export class VSXExtensionsContribution extends AbstractViewContribution widget === this.getTabBarDelegate() + })); + + this.toDisposeOnUpdateTitle.push(this.toolbarRegistry.registerItem({ + id: VSXExtensionsCommands.CLEAR_ALL.id, + command: VSXExtensionsCommands.CLEAR_ALL.id, + text: VSXExtensionsCommands.CLEAR_ALL.label, + group: 'other_1', + priority: 1, + onDidChange: this.model.onDidChange, + isVisible: (widget: Widget) => widget === this.getTabBarDelegate() + })); } protected override getToggleVisibilityGroupLabel(): string { - return 'a/' + nls.localizeByDefault('Views'); + return nls.localizeByDefault('Views'); } } export namespace VSXExtensionsViewContainer { From f58e5d0089b3bcf788885ea4e4b5cd02a41eff32 Mon Sep 17 00:00:00 2001 From: Dennis Huebner Date: Fri, 3 Jan 2025 13:55:41 +0100 Subject: [PATCH 02/11] Fixed failing notebook tests and lint errors --- examples/playwright/src/theia-notebook-editor.ts | 2 +- .../src/browser/view/notebook-cell-toolbar-factory.tsx | 4 ++-- .../notebook/src/browser/view/notebook-main-toolbar.tsx | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/playwright/src/theia-notebook-editor.ts b/examples/playwright/src/theia-notebook-editor.ts index 31ddbd7c3ef42..547cb32c0beab 100644 --- a/examples/playwright/src/theia-notebook-editor.ts +++ b/examples/playwright/src/theia-notebook-editor.ts @@ -49,7 +49,7 @@ export class TheiaNotebookEditor extends TheiaEditor { } tabLocator(): Locator { - return this.page.locator(this.data.viewSelector); + return this.page.locator(this.data.tabSelector); } override async waitForVisible(): Promise { diff --git a/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx b/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx index 847d4ff01f17c..313b1ce243e17 100644 --- a/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx +++ b/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx @@ -54,7 +54,7 @@ export class NotebookCellToolbarFactory { @inject(NotebookContextManager) protected readonly notebookContextManager: NotebookContextManager; - protected readonly onDidChangeContextEmitter = new Emitter + protected readonly onDidChangeContextEmitter = new Emitter; readonly onDidChangeContext: Event = this.onDidChangeContextEmitter.event; protected toDisposeOnRender = new DisposableCollection(); @@ -105,7 +105,7 @@ export class NotebookCellToolbarFactory { context: this.notebookContextManager.context || (e.currentTarget as HTMLElement) }); } else if (CommandMenu.is(menuNode)) { - menuNode.run(menuPath, itemOptions.commandArgs?.() ?? []); + menuNode.run(menuPath, ...(itemOptions.commandArgs?.() ?? [])); }; }, isVisible: () => true diff --git a/packages/notebook/src/browser/view/notebook-main-toolbar.tsx b/packages/notebook/src/browser/view/notebook-main-toolbar.tsx index 9ff72cd2bc26c..25a7667432e9d 100644 --- a/packages/notebook/src/browser/view/notebook-main-toolbar.tsx +++ b/packages/notebook/src/browser/view/notebook-main-toolbar.tsx @@ -99,7 +99,7 @@ export class NotebookMainToolbar extends React.Component this.forceUpdate()) + item.onDidChange(() => this.forceUpdate()); } } } @@ -130,7 +130,7 @@ export class NotebookMainToolbar extends React.Component menu.addNode(item)); @@ -146,7 +146,7 @@ export class NotebookMainToolbar extends React.Component + return
    {menuItems.slice(0, menuItems.length - this.calculateNumberOfHiddenItems(menuItems)).map(item => this.renderMenuItem(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR, item))} { this.state.numberOfHiddenItems > 0 && @@ -182,7 +182,7 @@ export class NotebookMainToolbar extends React.Component 0 && } ; } else if (CommandMenu.is(item) && ((this.nativeSubmenus.includes(submenu ?? '')) || item.isVisible(itemPath, this.props.contextKeyService, this.props.editorNode))) { - return
    { item.run(itemPath, this.props.notebookModel.uri); }}> From 7eb08152b6c5483ef90eff779814c9e954bb8acc Mon Sep 17 00:00:00 2001 From: Dennis Huebner Date: Fri, 3 Jan 2025 15:36:37 +0100 Subject: [PATCH 03/11] Fix preferences tests --- packages/core/src/browser/menu/browser-menu-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 4855c16838711..8b205d265e541 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -349,7 +349,7 @@ export class DynamicMenuWidget extends MenuWidget { } } } else if (CommandMenu.is(node)) { - const id = `menuCommand:${DynamicMenuWidget.nextCommmandId++}`; + const id = !phCommandRegistry.hasCommand(node.id) ? node.id : `${node.id}:${DynamicMenuWidget.nextCommmandId++}`; phCommandRegistry.addCommand(id, { execute: () => { node.run(nodePath, ...(this.args || [])); }, isEnabled: () => node.isEnabled(nodePath, ...(this.args || [])), From b77a12e370ca4259f8f9517f7399d28ab30777af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Mon, 6 Jan 2025 17:11:43 +0100 Subject: [PATCH 04/11] Fix linter issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- .../src/browser/menu/sample-menu-contribution.ts | 6 +++--- packages/core/src/browser/context-menu-renderer.ts | 3 ++- packages/core/src/browser/shell/sidebar-menu-widget.tsx | 2 +- .../electron-browser/menu/electron-main-menu-factory.ts | 5 +++-- packages/debug/src/browser/view/debug-action.tsx | 2 +- .../notebook/src/browser/view/notebook-cell-list-view.tsx | 8 ++++---- .../src/main/browser/comments/comment-thread-widget.tsx | 4 ++-- .../src/main/browser/menus/plugin-menu-command-adapter.ts | 2 +- packages/toolbar/src/browser/toolbar.tsx | 2 +- 9 files changed, 18 insertions(+), 16 deletions(-) diff --git a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts index ea8a89ef0b07c..dc99bbeec6869 100644 --- a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts +++ b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts @@ -266,14 +266,14 @@ export class PlaceholderMenuNode implements CommandMenu { constructor(readonly id: string, public readonly label: string, readonly order?: string, readonly icon?: string) { } - isEnabled(effectiveMenuPath: MenuPath, ...args: any[]): boolean { + isEnabled(effectiveMenuPath: MenuPath, ...args: unknown[]): boolean { return false; } isToggled(effectiveMenuPath: MenuPath): boolean { return false; } - run(effectiveMenuPath: MenuPath, ...args: any[]): Promise { + run(effectiveMenuPath: MenuPath, ...args: unknown[]): Promise { throw new Error('Should never happen'); } getAccelerator(context: HTMLElement | undefined): string[] { @@ -284,7 +284,7 @@ export class PlaceholderMenuNode implements CommandMenu { return this.order || this.label; } - isVisible(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: any[]): boolean { + isVisible(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean { return true; } diff --git a/packages/core/src/browser/context-menu-renderer.ts b/packages/core/src/browser/context-menu-renderer.ts index 913655d03cedc..23e8d9f9be024 100644 --- a/packages/core/src/browser/context-menu-renderer.ts +++ b/packages/core/src/browser/context-menu-renderer.ts @@ -89,7 +89,8 @@ export abstract class ContextMenuRenderer { menu = MenuModelRegistry.removeSingleRootNode(menu); } - const access = this.doRender(options.menuPath, menu, resolvedOptions.anchor, options.contextKeyService || this.contextKeyService, resolvedOptions.args, resolvedOptions.context, resolvedOptions.onHide); + const access = this.doRender(options.menuPath, menu, resolvedOptions.anchor, options.contextKeyService || this.contextKeyService, resolvedOptions.args, + resolvedOptions.context, resolvedOptions.onHide); this.setCurrent(access); return access; } diff --git a/packages/core/src/browser/shell/sidebar-menu-widget.tsx b/packages/core/src/browser/shell/sidebar-menu-widget.tsx index e5e39de542219..9999c69adc012 100644 --- a/packages/core/src/browser/shell/sidebar-menu-widget.tsx +++ b/packages/core/src/browser/shell/sidebar-menu-widget.tsx @@ -152,7 +152,7 @@ export class SidebarMenuWidget extends ReactWidget { protected onClick(e: React.MouseEvent, menuPath: MenuPath): void { this.preservingContext = true; const button = e.currentTarget.getBoundingClientRect(); - const menu = this.menuRegistry.getMenuNode(menuPath) as CompoundMenuNode + const menu = this.menuRegistry.getMenuNode(menuPath) as CompoundMenuNode; this.contextMenuRenderer.render({ menuPath: menuPath, menu: menu, 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 a1c8fc78b526f..56b1ca1985d98 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 @@ -158,7 +158,8 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { return undefined; } - createElectronContextMenu(menuPath: MenuPath, menu: CompoundMenuNode, contextMatcher: ContextMatcher, args?: any[], context?: HTMLElement, skipSingleRootNode?: boolean): MenuDto[] { + createElectronContextMenu(menuPath: MenuPath, menu: CompoundMenuNode, contextMatcher: ContextMatcher, args?: any[], + context?: HTMLElement, skipSingleRootNode?: boolean): MenuDto[] { return this.fillMenuTemplate([], menuPath, menu, args, contextMatcher, { showDisabled: true, context }, true); } @@ -221,7 +222,7 @@ export class ElectronMainMenuFactory extends BrowserMainMenuFactory { const wasToggled = menuItem.checked; await menu.run(menuPath, ...args); const isToggled = menu.isToggled(menuPath, ...args); - if (isToggled != wasToggled) { + if (isToggled !== wasToggled) { menuItem.type = isToggled ? 'checkbox' : 'normal'; menuItem.checked = isToggled; window.electronTheiaCore.setMenu(this.menu); diff --git a/packages/debug/src/browser/view/debug-action.tsx b/packages/debug/src/browser/view/debug-action.tsx index 627ed2c4abbd8..bdb9f6beb337f 100644 --- a/packages/debug/src/browser/view/debug-action.tsx +++ b/packages/debug/src/browser/view/debug-action.tsx @@ -32,7 +32,7 @@ export class DebugAction extends React.Component { return { this.props.run([]) }} + onClick={() => { this.props.run([]); }} ref={this.setRef} > {!iconClass &&
    {label}
    }
    ; diff --git a/packages/notebook/src/browser/view/notebook-cell-list-view.tsx b/packages/notebook/src/browser/view/notebook-cell-list-view.tsx index 539c4e1a20517..c43e5ed64baae 100644 --- a/packages/notebook/src/browser/view/notebook-cell-list-view.tsx +++ b/packages/notebook/src/browser/view/notebook-cell-list-view.tsx @@ -255,7 +255,7 @@ export class NotebookCellListView extends React.Component void, index: number): void { + protected onAddNewCell(handler: (...args: unknown[]) => void, index: number): void { if (this.isEnabled()) { this.props.commandRegistry.executeCommand(NotebookCommands.CHANGE_SELECTED_CELL.id, index - 1); handler( @@ -276,7 +276,7 @@ export class NotebookCellListView extends React.Component boolean; - onAddNewCell: (createCommand: (...args: any[]) => void) => void; + onAddNewCell: (createCommand: (...args: unknown[]) => void) => void; onDrop: (event: React.DragEvent) => void; onDragOver: (event: React.DragEvent) => void; menuRegistry: MenuModelRegistry; @@ -289,7 +289,7 @@ export function NotebookCellDivider({ isVisible, onAddNewCell, onDrop, onDragOve const menuItems: CommandMenu[] = menuRegistry.getMenu(menuPath).children.filter(item => CommandMenu.is(item)).map(item => item as CommandMenu); const renderItem = (item: CommandMenu): React.ReactNode => { - const execute = (...args: any[]) => { + const execute = (...args: unknown[]) => { if (CommandMenu.is(item)) { item.run([...menuPath, item.id], ...args); } @@ -302,7 +302,7 @@ export function NotebookCellDivider({ isVisible, onAddNewCell, onDrop, onDragOve >
    {item.label}
    - + ; }; return
  • setHover(true)} onMouseLeave={() => setHover(false)} onDrop={onDrop} onDragOver={onDragOver}> 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 9ff83effdd306..b49b87a3b50ad 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 @@ -497,8 +497,8 @@ export class ReviewComment

    {hover && menus.getMenu(COMMENT_TITLE).children.map((node, index): React.ReactNode => CommandMenu.is(node) && )}
  • diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts index bac633166df56..3e2ced26c6167 100644 --- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -33,7 +33,7 @@ import { CodeEditorWidgetUtil, ContributionPoint } from './vscode-theia-menu-map import { TestItem, TestMessage } from '@theia/test/lib/browser/test-service'; export type ArgumentAdapter = (...args: unknown[]) => unknown[]; -function identity(...args: unknown[]) { +function identity(...args: unknown[]): unknown[] { return args; } @injectable() diff --git a/packages/toolbar/src/browser/toolbar.tsx b/packages/toolbar/src/browser/toolbar.tsx index 9b257af02d55b..932214932814a 100644 --- a/packages/toolbar/src/browser/toolbar.tsx +++ b/packages/toolbar/src/browser/toolbar.tsx @@ -235,7 +235,7 @@ export class ToolbarImpl extends TabBarToolbar { protected renderItemWithDraggableWrapper(item: TabBarToolbarItem, position: ToolbarItemPosition): React.ReactNode { const stringifiedPosition = JSON.stringify(position); - let toolbarItemClassNames = ''; + const toolbarItemClassNames = ''; const renderBody = item.render(this); return ( From f6610958e12117d48d8c7357c4539d053f76d001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Thu, 9 Jan 2025 15:53:06 +0100 Subject: [PATCH 05/11] Applied some review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- .../browser/menu/sample-menu-contribution.ts | 4 ++-- .../browser/common-frontend-contribution.ts | 2 +- .../core/src/browser/context-menu-renderer.ts | 15 +++++++++---- .../menu/browser-context-menu-renderer.ts | 15 +++++++------ packages/core/src/browser/menu/menu.spec.ts | 22 +++++++++++++++---- .../src/common/menu/menu-model-registry.ts | 8 +++++-- packages/core/src/common/menu/menu-types.ts | 5 ++--- .../menu/electron-context-menu-renderer.ts | 18 ++++++++------- ...debug-frontend-application-contribution.ts | 2 +- .../notebook-cell-actions-contribution.ts | 17 ++++++++------ .../menus/menus-contribution-handler.ts | 9 ++++++-- .../browser/terminal-frontend-contribution.ts | 2 +- .../browser/view/test-view-contribution.ts | 12 ++++++---- 13 files changed, 85 insertions(+), 46 deletions(-) diff --git a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts index dc99bbeec6869..b2596cf5a5c61 100644 --- a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts +++ b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts @@ -227,7 +227,7 @@ export class SampleMenuContribution implements MenuContribution { registerMenus(menus: MenuModelRegistry): void { setTimeout(() => { const subMenuPath = [...MAIN_MENU_BAR, 'sample-menu']; - menus.registerSubmenu(subMenuPath, 'Sample Menu', '2'); // that should put the menu right next to the File menu + menus.registerSubmenu(subMenuPath, 'Sample Menu', { sortString: '2' }); // that should put the menu right next to the File menu menus.registerMenuAction(subMenuPath, { commandId: SampleCommand.id, @@ -238,7 +238,7 @@ export class SampleMenuContribution implements MenuContribution { order: '2' }); const subSubMenuPath = [...subMenuPath, 'sample-sub-menu']; - menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', '2'); + menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { sortString: '2' }); menus.registerMenuAction(subSubMenuPath, { commandId: SampleCommand.id, order: '1' diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 730dd63f44a56..9a862b3cb4b90 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -763,7 +763,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi commandId: CommonCommands.SELECT_ICON_THEME.id }); - registry.registerSubmenu(CommonMenus.MANAGE_SETTINGS_THEMES, nls.localizeByDefault('Themes'), 'a50'); + registry.registerSubmenu(CommonMenus.MANAGE_SETTINGS_THEMES, nls.localizeByDefault('Themes'), { sortString: 'a50' }); registry.registerMenuAction(CommonMenus.MANAGE_SETTINGS_THEMES, { commandId: CommonCommands.SELECT_COLOR_THEME.id, order: '0' diff --git a/packages/core/src/browser/context-menu-renderer.ts b/packages/core/src/browser/context-menu-renderer.ts index 23e8d9f9be024..2bdf56d9418f3 100644 --- a/packages/core/src/browser/context-menu-renderer.ts +++ b/packages/core/src/browser/context-menu-renderer.ts @@ -89,13 +89,20 @@ export abstract class ContextMenuRenderer { menu = MenuModelRegistry.removeSingleRootNode(menu); } - const access = this.doRender(options.menuPath, menu, resolvedOptions.anchor, options.contextKeyService || this.contextKeyService, resolvedOptions.args, - resolvedOptions.context, resolvedOptions.onHide); + const access = this.doRender({ + menuPath: options.menuPath, + menu, + anchor: resolvedOptions.anchor, + contextMatcher: options.contextKeyService || this.contextKeyService, + args: resolvedOptions.args, + context: resolvedOptions.context, + onHide: resolvedOptions.onHide + }); this.setCurrent(access); return access; } - protected abstract doRender( + protected abstract doRender(params: { menuPath: MenuPath, menu: CompoundMenuNode, anchor: Anchor, @@ -103,7 +110,7 @@ export abstract class ContextMenuRenderer { args?: any[], context?: HTMLElement, onHide?: () => void - ): ContextMenuAccess; + }): ContextMenuAccess; protected resolve(options: RenderContextMenuOptions): RenderContextMenuOptions { const args: any[] = options.args ? options.args.slice() : []; 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 88622c1ad1b52..8e1c295f4a3de 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -33,20 +33,21 @@ export class BrowserContextMenuAccess extends ContextMenuAccess { export class BrowserContextMenuRenderer extends ContextMenuRenderer { @inject(BrowserMainMenuFactory) private menuFactory: BrowserMainMenuFactory; - protected doRender(menuPath: MenuPath, + protected doRender(params: { + menuPath: MenuPath, menu: CompoundMenuNode, anchor: Anchor, contextMatcher: ContextMatcher, args?: unknown[], context?: HTMLElement, onHide?: () => void - ): ContextMenuAccess { - const contextMenu = this.menuFactory.createContextMenu(menuPath, menu, contextMatcher, args, context); - const { x, y } = coordinateFromAnchor(anchor); - if (onHide) { - contextMenu.aboutToClose.connect(() => onHide!()); + }): ContextMenuAccess { + const contextMenu = this.menuFactory.createContextMenu(params.menuPath, params.menu, params.contextMatcher, params.args, params.context); + const { x, y } = coordinateFromAnchor(params.anchor); + if (params.onHide) { + contextMenu.aboutToClose.connect(() => params.onHide!()); } - contextMenu.open(x, y, { host: context?.ownerDocument.body}); + contextMenu.open(x, y, { host: context?.ownerDocument.body }); return new BrowserContextMenuAccess(contextMenu); } diff --git a/packages/core/src/browser/menu/menu.spec.ts b/packages/core/src/browser/menu/menu.spec.ts index c3e44be150343..5a98f33a8b826 100644 --- a/packages/core/src/browser/menu/menu.spec.ts +++ b/packages/core/src/browser/menu/menu.spec.ts @@ -15,11 +15,25 @@ // ***************************************************************************** import * as chai from 'chai'; -import { CommandContribution, CommandRegistry, CompoundMenuNode, MenuContribution, MenuModelRegistry, Submenu } from '../../common'; +import { CommandContribution, CommandMenu, CommandRegistry, CompoundMenuNode, MenuAction, MenuContribution, MenuModelRegistry, Submenu } from '../../common'; import { BrowserMenuNodeFactory } from './browser-menu-node-factory'; const expect = chai.expect; +class TestMenuNodeFactory extends BrowserMenuNodeFactory { + override createCommandMenu(item: MenuAction): CommandMenu { + return { + isVisible: () => true, + isEnabled: () => true, + isToggled: () => false, + id: item.commandId, + label: item.label || '', + sortString: item.order || '', + run: () => Promise.resolve() + }; + } +} + describe('menu-model-registry', () => { describe('01 #register', () => { @@ -69,9 +83,9 @@ describe('menu-model-registry', () => { registerMenus(menuRegistry: MenuModelRegistry): void { menuRegistry.registerSubmenu(fileMenu, 'File'); // open menu should not be added to open menu - menuRegistry.linkCompoundMenuNode(fileOpenMenu, fileOpenMenu); + menuRegistry.linkCompoundMenuNode({ newParentPath: fileOpenMenu, submenuPath: fileOpenMenu }); // close menu should be added - menuRegistry.linkCompoundMenuNode(fileOpenMenu, fileCloseMenu); + menuRegistry.linkCompoundMenuNode({ newParentPath: fileOpenMenu, submenuPath: fileCloseMenu }); } }, { registerCommands(reg: CommandRegistry): void { } @@ -85,7 +99,7 @@ describe('menu-model-registry', () => { function createMenuRegistry(menuContrib: MenuContribution, commandContrib: CommandContribution): MenuModelRegistry { const cmdReg = new CommandRegistry({ getContributions: () => [commandContrib] }); cmdReg.onStart(); - const menuReg = new MenuModelRegistry({ getContributions: () => [menuContrib] }, cmdReg, new BrowserMenuNodeFactory()); + const menuReg = new MenuModelRegistry({ getContributions: () => [menuContrib] }, cmdReg, new TestMenuNodeFactory()); menuReg.onStart(); return menuReg; } diff --git a/packages/core/src/common/menu/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index e58ef29636948..6080c45d0d361 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -189,7 +189,10 @@ export class MenuModelRegistry { * Note that if the menu already existed and was registered with a different label an error * will be thrown. */ - registerSubmenu(menuPath: MenuPath, label: string, sortString?: string, icon?: string, when?: string, contextKeyOverlay?: Record): Disposable { + registerSubmenu(menuPath: MenuPath, label: string, + options: { sortString?: string, icon?: string, when?: string, contextKeyOverlay?: Record } = {}): Disposable { + const { contextKeyOverlay, sortString, icon, when } = options; + const parent = this.root.getOrCreate(menuPath, 0, menuPath.length - 1); const existing = parent.children.find(node => node.id === menuPath[menuPath.length - 1]); if (Group.is(existing)) { @@ -228,7 +231,8 @@ export class MenuModelRegistry { } } - linkCompoundMenuNode(newParentPath: MenuPath, submenuPath: MenuPath, order?: string, when?: string): Disposable { + linkCompoundMenuNode(params: { newParentPath: MenuPath, submenuPath: MenuPath, order?: string, when?: string }): Disposable { + const { newParentPath, submenuPath, order, when } = params; // add a wrapper here let i = 0; while (i < newParentPath.length && i < submenuPath.length && newParentPath[i] === submenuPath[i]) { diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts index d89949b231d42..8f9d566cba25b 100644 --- a/packages/core/src/common/menu/menu-types.ts +++ b/packages/core/src/common/menu/menu-types.ts @@ -102,9 +102,8 @@ export namespace RenderedMenuNode { } } -export interface CommandMenu extends MenuNode, RenderedMenuNode, Action { +export type CommandMenu = MenuNode & RenderedMenuNode & Action; -} export namespace CommandMenu { export function is(node: MenuNode): node is CommandMenu { return RenderedMenuNode.is(node) && Action.is(node); @@ -120,7 +119,7 @@ export namespace Group { export type Submenu = CompoundMenuNode & RenderedMenuNode; -export type CompoundMenuNode = MenuNode & { +export interface CompoundMenuNode extends MenuNode { children: MenuNode[]; contextKeyOverlays?: Record; /** 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 cececea0da3b8..583a76c366e26 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 @@ -105,29 +105,31 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { this.useNativeStyle = await window.electronTheiaCore.getTitleBarStyleAtStartup() === 'native'; } - protected override doRender(menuPath: MenuPath, menu: CompoundMenuNode, + protected override doRender(params: { + menuPath: MenuPath, + menu: CompoundMenuNode, anchor: Anchor, contextMatcher: ContextMatcher, args?: any, context?: HTMLElement, onHide?: () => void - ): ContextMenuAccess { + }): ContextMenuAccess { if (this.useNativeStyle) { - const contextMenu = this.electronMenuFactory.createElectronContextMenu(menuPath, menu, contextMatcher, args, context); - const { x, y } = coordinateFromAnchor(anchor); + const contextMenu = this.electronMenuFactory.createElectronContextMenu(params.menuPath, params.menu, params.contextMatcher, params.args, params.context); + const { x, y } = coordinateFromAnchor(params.anchor); - const windowName = context?.ownerDocument.defaultView?.Window.name; + const windowName = params.context?.ownerDocument.defaultView?.Window.name; const menuHandle = window.electronTheiaCore.popup(contextMenu, x, y, () => { - if (onHide) { - onHide(); + if (params.onHide) { + params.onHide(); } }, windowName); // native context menu stops the event loop, so there is no keyboard events this.context.resetAltPressed(); return new ElectronContextMenuAccess(menuHandle); } else { - const menuAccess = super.doRender(menuPath, menu, anchor, contextMatcher, args, context, onHide); + const menuAccess = super.doRender(params); const node = (menuAccess as BrowserContextMenuAccess).menu.node; const topPanelHeight = document.getElementById('theia-top-panel')?.clientHeight ?? 0; // ensure the context menu is not displayed outside of the main area diff --git a/packages/debug/src/browser/debug-frontend-application-contribution.ts b/packages/debug/src/browser/debug-frontend-application-contribution.ts index be84fc45784a7..15334c81547a1 100644 --- a/packages/debug/src/browser/debug-frontend-application-contribution.ts +++ b/packages/debug/src/browser/debug-frontend-application-contribution.ts @@ -641,7 +641,7 @@ export class DebugFrontendApplicationContribution extends AbstractViewContributi { ...DebugEditorContextCommands.DISABLE_LOGPOINT, label: nlsDisableBreakpoint('Logpoint') }, { ...DebugEditorContextCommands.JUMP_TO_CURSOR, label: nls.localizeByDefault('Jump to Cursor') } ); - menus.linkCompoundMenuNode(EDITOR_LINENUMBER_CONTEXT_MENU, DebugEditorModel.CONTEXT_MENU); + menus.linkCompoundMenuNode({ newParentPath: EDITOR_LINENUMBER_CONTEXT_MENU, submenuPath: DebugEditorModel.CONTEXT_MENU }); menus.registerSubmenu(DebugToolBar.MENU, 'Debug Toolbar Menu'); } diff --git a/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts b/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts index d54f621f16926..c38d8ea9deacd 100644 --- a/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts +++ b/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts @@ -233,13 +233,18 @@ export class NotebookCellActionContribution implements MenuContribution, Command menus.registerSubmenu( NotebookCellActionContribution.ADDITIONAL_ACTION_MENU, nls.localizeByDefault('More'), - '30', - codicon('ellipsis'), + { + sortString: '30', + icon: codicon('ellipsis') + } ); menus.registerSubmenu(NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU, ''); // since contributions are adding to an independent submenu we have to manually add it to the more submenu - menus.linkCompoundMenuNode(NotebookCellActionContribution.ADDITIONAL_ACTION_MENU, NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU); + menus.linkCompoundMenuNode({ + newParentPath: NotebookCellActionContribution.ADDITIONAL_ACTION_MENU, + submenuPath: NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU + }); // code cell sidebar menu menus.registerMenuAction(NotebookCellActionContribution.CODE_CELL_SIDEBAR_MENU, { @@ -258,16 +263,14 @@ export class NotebookCellActionContribution implements MenuContribution, Command // Notebook Cell extra execution options menus.registerSubmenu(NotebookCellActionContribution.CONTRIBUTED_CELL_EXECUTION_MENU, nls.localizeByDefault('More...'), - undefined, - codicon('chevron-down')); + { icon: codicon('chevron-down') }); // menus.getMenu(NotebookCellActionContribution.CODE_CELL_SIDEBAR_MENU).addNode(menus.getMenuNode(NotebookCellActionContribution.CONTRIBUTED_CELL_EXECUTION_MENU)); // code cell output sidebar menu menus.registerSubmenu( NotebookCellActionContribution.ADDITIONAL_OUTPUT_SIDEBAR_MENU, nls.localizeByDefault('More'), - undefined, - codicon('ellipsis'), + { icon: codicon('ellipsis') } ); menus.registerMenuAction(NotebookCellActionContribution.ADDITIONAL_OUTPUT_SIDEBAR_MENU, { commandId: NotebookCellCommands.CLEAR_OUTPUTS_COMMAND.id, 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 e81b01a3cd897..98fdaecaac7d4 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 @@ -81,7 +81,7 @@ export class MenusContributionPointHandler { const submenus = plugin.contributes?.submenus ?? []; for (const submenu of submenus) { const iconClass = submenu.icon && this.toIconClass(submenu.icon, toDispose); - this.menuRegistry.registerSubmenu([submenu.id], submenu.label, undefined, iconClass); + this.menuRegistry.registerSubmenu([submenu.id], submenu.label, { icon: iconClass }); } for (const [contributionPoint, items] of Object.entries(allMenus)) { @@ -145,7 +145,12 @@ export class MenusContributionPointHandler { toDispose.push(this.menuRegistry.registerCommandMenu(menuPath, action)); }); } else if (submenu) { - targets.forEach(target => toDispose.push(this.menuRegistry.linkCompoundMenuNode(group ? [...target, group] : target, [submenu!], order, item.when))); + targets.forEach(target => toDispose.push(this.menuRegistry.linkCompoundMenuNode({ + newParentPath: group ? [...target, group] : target, + submenuPath: [submenu!], + order: order, + when: item.when + }))); } } } catch (error) { diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index 61bc45c1b61ec..4537d970c9880 100644 --- a/packages/terminal/src/browser/terminal-frontend-contribution.ts +++ b/packages/terminal/src/browser/terminal-frontend-contribution.ts @@ -739,7 +739,7 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu menus.registerSubmenu(TerminalMenus.TERMINAL_CONTRIBUTIONS, ''); - menus.registerSubmenu(TerminalMenus.TERMINAL_TITLE_CONTRIBUTIONS, '', undefined, undefined, 'isTerminalTab'); + menus.registerSubmenu(TerminalMenus.TERMINAL_TITLE_CONTRIBUTIONS, '', { when: 'isTerminalTab' }); } registerToolbarItems(toolbar: TabBarToolbarRegistry): void { diff --git a/packages/test/src/browser/view/test-view-contribution.ts b/packages/test/src/browser/view/test-view-contribution.ts index 63bad7adfdec8..26f1a5d261485 100644 --- a/packages/test/src/browser/view/test-view-contribution.ts +++ b/packages/test/src/browser/view/test-view-contribution.ts @@ -275,12 +275,16 @@ export class TestViewContribution extends AbstractViewContribution Date: Thu, 9 Jan 2025 16:46:34 +0100 Subject: [PATCH 06/11] Refactorings to enable menu tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- .../browser/menu/browser-menu-node-factory.ts | 4 +- .../src/browser/menu/composite-menu-node.ts | 10 ++--- packages/core/src/browser/menu/menu.spec.ts | 39 ++++++++++++++----- .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 2 +- .../tab-bar-toolbar/tab-toolbar-item.tsx | 2 +- packages/core/src/browser/view-container.ts | 2 +- .../browser/view/notebook-main-toolbar.tsx | 2 +- 7 files changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/core/src/browser/menu/browser-menu-node-factory.ts b/packages/core/src/browser/menu/browser-menu-node-factory.ts index dbe922812c55a..8572e0a39a0d6 100644 --- a/packages/core/src/browser/menu/browser-menu-node-factory.ts +++ b/packages/core/src/browser/menu/browser-menu-node-factory.ts @@ -32,7 +32,7 @@ export class BrowserMenuNodeFactory implements MenuNodeFactory { protected readonly keybindingRegistry: KeybindingRegistry; createGroup(id: string, orderString?: string, when?: string): Group & MutableCompoundMenuNode { - return new GroupImpl(this.contextKeyService, id, orderString, when); + return new GroupImpl(id, orderString, when); } createCommandMenu(item: MenuAction): CommandMenu { @@ -40,7 +40,7 @@ export class BrowserMenuNodeFactory implements MenuNodeFactory { } createSubmenu(id: string, label: string, contextKeyOverlays: Record | undefined, orderString?: string, icon?: string, when?: string): Submenu & MutableCompoundMenuNode { - return new SubmenuImpl(this.contextKeyService, id, label, contextKeyOverlays, orderString, icon, when); + return new SubmenuImpl(id, label, contextKeyOverlays, orderString, icon, when); } createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode { return new SubMenuLink(delegate, sortString, when); diff --git a/packages/core/src/browser/menu/composite-menu-node.ts b/packages/core/src/browser/menu/composite-menu-node.ts index 9936455437abe..be214582710c5 100644 --- a/packages/core/src/browser/menu/composite-menu-node.ts +++ b/packages/core/src/browser/menu/composite-menu-node.ts @@ -14,7 +14,6 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { ContextKeyService } from '../context-key-service'; import { CompoundMenuNode, ContextExpressionMatcher, Group, MenuNode, MenuPath, Submenu } from '../../common/menu/menu-types'; import { Event } from '../../common'; @@ -45,7 +44,6 @@ export abstract class AbstractCompoundMenuImpl implements MenuNode { readonly children: MenuNode[] = []; protected constructor( - protected readonly contextKeyService: ContextKeyService, readonly id: string, protected readonly orderString?: string, protected readonly when?: string @@ -58,7 +56,7 @@ export abstract class AbstractCompoundMenuImpl implements MenuNode { } let child = this.getNode(menuPath[pathIndex]); if (!child) { - child = new GroupImpl(this.contextKeyService, menuPath[pathIndex]); + child = new GroupImpl(menuPath[pathIndex]); this.addNode(child); } if (child instanceof AbstractCompoundMenuImpl) { @@ -117,19 +115,17 @@ export abstract class AbstractCompoundMenuImpl implements MenuNode { export class GroupImpl extends AbstractCompoundMenuImpl implements Group { constructor( - contextKeyService: ContextKeyService, id: string, orderString?: string, when?: string ) { - super(contextKeyService, id, orderString, when); + super(id, orderString, when); } } export class SubmenuImpl extends AbstractCompoundMenuImpl implements Submenu { constructor( - contextKeyService: ContextKeyService, id: string, readonly label: string, readonly contextKeyOverlays: Record | undefined, @@ -137,7 +133,7 @@ export class SubmenuImpl extends AbstractCompoundMenuImpl implements Submenu { readonly icon?: string, when?: string, ) { - super(contextKeyService, id, orderString, when); + super(id, orderString, when); } } diff --git a/packages/core/src/browser/menu/menu.spec.ts b/packages/core/src/browser/menu/menu.spec.ts index 5a98f33a8b826..1df928d46bce6 100644 --- a/packages/core/src/browser/menu/menu.spec.ts +++ b/packages/core/src/browser/menu/menu.spec.ts @@ -15,13 +15,28 @@ // ***************************************************************************** import * as chai from 'chai'; -import { CommandContribution, CommandMenu, CommandRegistry, CompoundMenuNode, MenuAction, MenuContribution, MenuModelRegistry, Submenu } from '../../common'; -import { BrowserMenuNodeFactory } from './browser-menu-node-factory'; +import { + CommandContribution, CommandMenu, CommandRegistry, CompoundMenuNode, Group, GroupImpl, MenuAction, MenuContribution, + MenuModelRegistry, MenuNode, MenuNodeFactory, MutableCompoundMenuNode, Submenu, + SubmenuImpl, + SubMenuLink +} from '../../common'; const expect = chai.expect; -class TestMenuNodeFactory extends BrowserMenuNodeFactory { - override createCommandMenu(item: MenuAction): CommandMenu { +class TestMenuNodeFactory implements MenuNodeFactory { + createGroup(id: string, orderString?: string, when?: string): Group & MutableCompoundMenuNode { + return new GroupImpl(id, orderString, when); + } + createSubmenu(id: string, label: string, contextKeyOverlays: Record | undefined, orderString?: string, icon?: string, when?: string): + Submenu & MutableCompoundMenuNode { + return new SubmenuImpl(id, label, contextKeyOverlays, orderString, icon, when); + } + createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode { + return new SubMenuLink(delegate, sortString, when); + } + + createCommandMenu(item: MenuAction): CommandMenu { return { isVisible: () => true, isEnabled: () => true, @@ -62,11 +77,9 @@ describe('menu-model-registry', () => { }); } }); - const all = service.getMenu([])!; - const main = all.children[0] as CompoundMenuNode; + const main = service.getMenu(['main'])!; expect(main.children.length).equals(1); expect(main.id, 'main'); - expect(all.children.length).equals(1); const file = main.children[0] as Submenu; expect(file.children.length).equals(1); expect(file.label, 'File'); @@ -82,16 +95,22 @@ describe('menu-model-registry', () => { const service = createMenuRegistry({ registerMenus(menuRegistry: MenuModelRegistry): void { menuRegistry.registerSubmenu(fileMenu, 'File'); + menuRegistry.registerSubmenu(fileOpenMenu, 'Open'); + menuRegistry.registerSubmenu(fileCloseMenu, 'Close'); // open menu should not be added to open menu - menuRegistry.linkCompoundMenuNode({ newParentPath: fileOpenMenu, submenuPath: fileOpenMenu }); + try { + menuRegistry.linkCompoundMenuNode({ newParentPath: fileOpenMenu, submenuPath: fileOpenMenu }); + } catch (e) { + // expected + } // close menu should be added menuRegistry.linkCompoundMenuNode({ newParentPath: fileOpenMenu, submenuPath: fileCloseMenu }); } }, { registerCommands(reg: CommandRegistry): void { } }); - const all = service.getMenu([]) as CompoundMenuNode; - expect(menuStructureToString(all.children[0] as CompoundMenuNode)).equals('File(0_open(1_close),1_close())'); + const main = service.getMenu(['main']) as CompoundMenuNode; + expect(menuStructureToString(main)).equals('File(0_open(1_close()),1_close())'); }); }); }); diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index 4a0db6f93bcd7..8101899d8f15e 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -158,7 +158,7 @@ export class TabBarToolbar extends ReactWidget { this.addClass('menu-open'); toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open'))); - const menu = new GroupImpl(this.contextKeyService, 'contextMenu'); + const menu = new GroupImpl('contextMenu'); for (const item of this.more.values()) { if (item.toMenuNode) { const node = item.toMenuNode(); diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx index 4533e823884c3..c444a12c51ed7 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx @@ -151,7 +151,7 @@ export class RenderedToolbarItemImpl extends AbstractToolbarItemImpl//.../` const menuPath = this.action.group?.split('/') || []; if (menuPath.length > 1) { - let menu = new GroupImpl(this.contextKeyService, menuPath[0], this.action.order); + let menu = new GroupImpl(menuPath[0], this.action.order); menu = menu.getOrCreate(menuPath, 1, menuPath.length); menu.addNode(action); return menu; diff --git a/packages/core/src/browser/view-container.ts b/packages/core/src/browser/view-container.ts index 2f0e035067eb5..994db0e7608e7 100644 --- a/packages/core/src/browser/view-container.ts +++ b/packages/core/src/browser/view-container.ts @@ -345,7 +345,7 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica protected updateToolbarItems(allParts: ViewContainerPart[]): void { if (allParts.length > 1) { - const group = new SubmenuImpl(this.contextKeyService, `toggleParts-${this.id}`, this.getToggleVisibilityGroupLabel(), undefined); + const group = new SubmenuImpl(`toggleParts-${this.id}`, this.getToggleVisibilityGroupLabel(), undefined); for (const part of allParts) { const existingId = this.toggleVisibilityCommandId(part); const { label } = part.wrapped.title; diff --git a/packages/notebook/src/browser/view/notebook-main-toolbar.tsx b/packages/notebook/src/browser/view/notebook-main-toolbar.tsx index 25a7667432e9d..508eed6fc1a8c 100644 --- a/packages/notebook/src/browser/view/notebook-main-toolbar.tsx +++ b/packages/notebook/src/browser/view/notebook-main-toolbar.tsx @@ -130,7 +130,7 @@ export class NotebookMainToolbar extends React.Component menu.addNode(item)); From eaec7fd1c23b2ae34e6d79a4d285f929b4010219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Mon, 3 Feb 2025 17:38:34 +0100 Subject: [PATCH 07/11] Fix playwright enablement check. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- examples/playwright/src/theia-toolbar-item.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/playwright/src/theia-toolbar-item.ts b/examples/playwright/src/theia-toolbar-item.ts index fe6408b245123..181dffc7cc75d 100644 --- a/examples/playwright/src/theia-toolbar-item.ts +++ b/examples/playwright/src/theia-toolbar-item.ts @@ -28,7 +28,8 @@ export class TheiaToolbarItem extends TheiaPageObject { } async isEnabled(): Promise { - const classAttribute = await this.element.getAttribute('class'); + const child = await this.element.$(':first-child'); + const classAttribute = child && await child.getAttribute('class'); if (classAttribute === undefined || classAttribute === null) { return false; } From 0cc4053ccc6835be0dbbafb46989640ee8f63629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Tue, 4 Feb 2025 06:33:47 +0100 Subject: [PATCH 08/11] Address review comment and add playwright test launch config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- .vscode/launch.json | 11 +++++++++++ .../menu/electron-context-menu-renderer.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 3539be4173f25..ee1cb6bc6fc38 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,17 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Playwright Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/@playwright/test/cli.js", + "cwd": "${workspaceFolder}/examples/playwright", + "args": [ + "test", + "--config=./configs/playwright.config.ts" + ] + }, { "type": "node", "request": "attach", 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 583a76c366e26..0c194a5a21695 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 @@ -90,9 +90,12 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + @inject(ElectronMainMenuFactory) + protected readonly electronMenuFactory: ElectronMainMenuFactory; + protected useNativeStyle: boolean = true; - constructor(@inject(ElectronMainMenuFactory) private electronMenuFactory: ElectronMainMenuFactory) { + constructor() { super(); } From 1e7eb2ce1307f4007f13403af662ccaa6cbb3196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Tue, 4 Feb 2025 07:28:12 +0100 Subject: [PATCH 09/11] Removed duplicate field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- .../src/main/browser/menus/menus-contribution-handler.ts | 1 - 1 file changed, 1 deletion(-) 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 98fdaecaac7d4..c5b119edb6485 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 @@ -38,7 +38,6 @@ export class MenusContributionPointHandler { @inject(MenuModelRegistry) private readonly menuRegistry: MenuModelRegistry; @inject(CommandRegistry) private readonly commandRegistry: CommandRegistry; @inject(TabBarToolbarRegistry) private readonly tabBarToolbar: TabBarToolbarRegistry; - @inject(PluginMenuCommandAdapter) protected readonly commandAdapter: PluginMenuCommandAdapter; @inject(PluginMenuCommandAdapter) pluginMenuCommandAdapter: PluginMenuCommandAdapter; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(PluginSharedStyle) protected readonly style: PluginSharedStyle; From 108ed959cbf84a71d64d9aa921bd9fc38e6e1805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Tue, 4 Feb 2025 07:59:13 +0100 Subject: [PATCH 10/11] Changelog info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- CHANGELOG.md | 1 + doc/Migration.md | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fa4bed00ebb..29b891b63bb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [core] fixed version `@types/express` to `^4.17.21` and `@types/express-serve-static-core` to `5.0.4`. This might be required for adopters as well if they run into typing issues. [#15147](https://github.com/eclipse-theia/theia/pull/15147) - [core] migration from deprecated `phosphorJs` to actively maintained fork `Lumino` [#14320](https://github.com/eclipse-theia/theia/pull/14320) - Contributed on behalf of STMicroelectronics Adopters importing `@phosphor` packages now need to import from `@lumino`. CSS selectors refering to `.p-` classes now need to refer to `.lm-` classes. There are also minor code adaptations, for example now using `iconClass` instead of `icon` in Lumino commands. +- [core] Refactor menu nodes [#14676](https://github.com/eclipse-theia/theia/pull/14676) - Contributed on behalf of STMicroelectronics [Breaking Changes:](#breaking_changes_1.60.0) diff --git a/doc/Migration.md b/doc/Migration.md index b921f8d839611..4a105e7d188ad 100644 --- a/doc/Migration.md +++ b/doc/Migration.md @@ -59,6 +59,14 @@ For example: } ``` +### v1.59.0 + +#### Refactor menu nodes [#14676](https://github.com/eclipse-theia/theia/pull/14676) + +This PR makes menu nodes and tab toolbar items into active object instead of pure data descriptors. This means they can polymorphically handle concerns like enablement, visibility, command execution and rendering. This keeps concerns like conversion of parameters out of the general tool bar and menu handling code. In this way, we could get rid of the MenuCommandExecutor and MenuCommandAdapter infrastructure. +If you are simply registering toolbar items and menus, little will change for you as a Theia adopter. Mainly, some of the paremeter types have changed in menu-model-registry.ts. Menu registration has been simplified in that an independent submenu is simply a menu that is registered under a path that does not start with the MAIN_MENU_BAR prefix. +If you override any of the toolbar or menu related implementations in your product, the biggest change will be that some functionality is now delegated to the menu and too bar item implementations. If this breaks your use case, please let us know. + ### v1.38.0 #### Inversify 6.0 From 06072bb34c27c35ed206d90873ce8ab895650eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=A4der?= Date: Thu, 20 Mar 2025 06:05:14 +0100 Subject: [PATCH 11/11] Post-rebase fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Mäder --- .../menu/browser-context-menu-renderer.ts | 2 +- .../src/browser/menu/browser-menu-plugin.ts | 6 ++--- .../tab-bar-toolbar-menu-adapters.tsx | 25 +++++-------------- .../tab-bar-toolbar-registry.ts | 10 +++++--- .../tab-bar-toolbar/tab-toolbar-item.tsx | 21 +++++++++++++--- 5 files changed, 34 insertions(+), 30 deletions(-) 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 8e1c295f4a3de..2c88296f6dc47 100644 --- a/packages/core/src/browser/menu/browser-context-menu-renderer.ts +++ b/packages/core/src/browser/menu/browser-context-menu-renderer.ts @@ -47,7 +47,7 @@ export class BrowserContextMenuRenderer extends ContextMenuRenderer { if (params.onHide) { contextMenu.aboutToClose.connect(() => params.onHide!()); } - contextMenu.open(x, y, { host: context?.ownerDocument.body }); + contextMenu.open(x, y, { host: params.context?.ownerDocument.body }); return new BrowserContextMenuAccess(contextMenu); } diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 8b205d265e541..f67ec8ab40df1 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -305,7 +305,7 @@ export class DynamicMenuWidget extends MenuWidget { super.open(x, y, options); } - protected updateSubMenus(parentPath: MenuPath, parent: MenuWidget, menu: CompoundMenuNode, commands: PhosphorCommandRegistry, + protected updateSubMenus(parentPath: MenuPath, parent: MenuWidget, menu: CompoundMenuNode, commands: LuminoCommandRegistry, contextMatcher: ContextMatcher, context?: HTMLElement | undefined): void { const items = this.createItems(parentPath, menu.children, commands, contextMatcher, context); while (items[items.length - 1]?.type === 'separator') { @@ -323,7 +323,7 @@ export class DynamicMenuWidget extends MenuWidget { } } - protected createItems(parentPath: MenuPath, nodes: MenuNode[], phCommandRegistry: PhosphorCommandRegistry, + protected createItems(parentPath: MenuPath, nodes: MenuNode[], phCommandRegistry: LuminoCommandRegistry, contextMatcher: ContextMatcher, context?: HTMLElement): MenuWidget.IItemOptions[] { const result: MenuWidget.IItemOptions[] = []; @@ -356,7 +356,7 @@ export class DynamicMenuWidget extends MenuWidget { isToggled: () => node.isToggled ? !!node.isToggled(nodePath, ...(this.args || [])) : false, isVisible: () => true, label: node.label, - icon: node.icon, + iconClass: node.icon, }); const accelerator = (AcceleratorSource.is(node) ? node.getAccelerator(this.options.context) : []); diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx index d0f0db7997637..575bb865c4aad 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx @@ -14,13 +14,13 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Widget } from '@phosphor/widgets'; +import { Widget } from '@lumino/widgets'; import * as React from 'react'; -import { CommandRegistry, DisposableCollection, Event } from '../../../common'; +import { CommandRegistry, Event } from '../../../common'; import { NAVIGATION, RenderedToolbarAction } from './tab-bar-toolbar-types'; import { TabBarToolbar, toAnchor } from './tab-bar-toolbar'; import { ACTION_ITEM, codicon } from '../../widgets'; -import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../../context-menu-renderer'; +import { ContextMenuRenderer } from '../../context-menu-renderer'; import { TabBarToolbarItem } from './tab-toolbar-item'; import { ContextKeyService, ContextMatcher } from '../../context-key-service'; import { CommandMenu, CompoundMenuNode, MenuModelRegistry, MenuNode, MenuPath, RenderedMenuNode } from '../../../common/menu'; @@ -76,27 +76,14 @@ abstract class AbstractToolbarMenuWrapper { event.stopPropagation(); event.preventDefault(); const anchor = toAnchor(event); - this.renderPopupMenu(widget, menuPath, this.menuNode as CompoundMenuNode, anchor, contextMatcher); - } - - /** - * Renders the menu popped up on a menu toolbar item. - * - * @param menuPath the path of the registered menu to render - * @param anchor a description of where to render the menu - * @returns platform-specific access to the rendered context menu - */ - protected renderPopupMenu(widget: Widget | undefined, menuPath: MenuPath, menu: CompoundMenuNode, anchor: Anchor, contextMatcher: ContextMatcher): ContextMenuAccess { - const toDisposeOnHide = new DisposableCollection(); - return this.contextMenuRenderer.render({ + this.contextMenuRenderer.render({ menuPath: menuPath, - menu: menu, + menu: this.menuNode as CompoundMenuNode, args: [widget], anchor, - context: widget?.node, + context: widget?.node || event.target as HTMLElement, contextKeyService: contextMatcher, - onHide: () => toDisposeOnHide.dispose() }); } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index 82cb6461cd28d..eba632a1c0db2 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -93,23 +93,27 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { return this.doRegisterItem(new ToolbarSubmenuWrapper(item.menuPath, this.commandRegistry, this.menuRegistry, this.contextKeyService, this.contextMenuRenderer, item)); } else { - return this.doRegisterItem(new RenderedToolbarItemImpl(this.commandRegistry, this.contextKeyService, this.keybindingRegistry, this.labelParser, item)); + const wrapper = new RenderedToolbarItemImpl(this.commandRegistry, this.contextKeyService, this.keybindingRegistry, this.labelParser, item); + const disposables = this.doRegisterItem(wrapper); + disposables.push(wrapper); + return disposables; } } } - doRegisterItem(item: TabBarToolbarItem): Disposable { + doRegisterItem(item: TabBarToolbarItem): DisposableCollection { if (this.items.has(item.id)) { throw new Error(`A toolbar item is already registered with the '${item.id}' ID.`); } this.items.set(item.id, item); this.fireOnDidChange(); const toDispose = new DisposableCollection( - Disposable.create(() => this.fireOnDidChange()), Disposable.create(() => { this.items.delete(item.id); + this.fireOnDidChange(); }) ); + if (item.onDidChange) { toDispose.push(item.onDidChange(() => this.fireOnDidChange())); } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx index c444a12c51ed7..34b89ed58c353 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx @@ -16,9 +16,9 @@ import { ContextKeyService } from '../../context-key-service'; import { ReactTabBarToolbarAction, RenderedToolbarAction, TabBarToolbarActionBase } from './tab-bar-toolbar-types'; -import { Widget } from '@phosphor/widgets'; +import { Widget } from '@lumino/widgets'; import { LabelIcon, LabelParser } from '../../label-parser'; -import { CommandRegistry, Event, Disposable, Emitter } from '../../../common'; +import { CommandRegistry, Event, Disposable, Emitter, DisposableCollection } from '../../../common'; import { KeybindingRegistry } from '../../keybinding'; import { ACTION_ITEM } from '../../widgets'; import { TabBarToolbar } from './tab-bar-toolbar'; @@ -47,7 +47,6 @@ class AbstractToolbarItemImpl { protected readonly commandRegistry: CommandRegistry, protected readonly contextKeyService: ContextKeyService, protected readonly action: T) { - } get id(): string { @@ -60,6 +59,10 @@ class AbstractToolbarItemImpl { return this.action.priority; } + get onDidChange(): Event | undefined { + return this.action.onDidChange; + } + isVisible(widget: Widget): boolean { if (this.action.isVisible) { return this.action.isVisible(widget); @@ -80,6 +83,7 @@ class AbstractToolbarItemImpl { export class RenderedToolbarItemImpl extends AbstractToolbarItemImpl implements TabBarToolbarItem { protected contextKeyListener: Disposable | undefined; + protected disposables = new DisposableCollection(); constructor( commandRegistry: CommandRegistry, @@ -88,6 +92,13 @@ export class RenderedToolbarItemImpl extends AbstractToolbarItemImpl this.onDidChangeEmitter.fire())); + } + } + + dispose(): void { + this.disposables.dispose(); } updateContextKeyListener(when: string): void { @@ -138,7 +149,9 @@ export class RenderedToolbarItemImpl extends AbstractToolbarItemImpl; - onDidChange: Event = this.onDidChangeEmitter.event; + override get onDidChange(): Event | undefined { + return this.onDidChangeEmitter.event; + } toMenuNode?(): MenuNode { const action = new ActionMenuNode({