From b0e5feeef3f4be765c14d2c6589f5be008f5abc7 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 1/4] 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 | 39 +- .../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 ++++++++++++ packages/core/src/browser/shell/tab-bars.ts | 2 +- .../core/src/browser/style/view-container.css | 51 +-- packages/core/src/browser/view-container.ts | 72 ++-- packages/core/src/common/array-utils.ts | 24 ++ .../core/src/common/menu/action-menu-node.ts | 65 ---- .../common/menu/composite-menu-node.spec.ts | 67 ---- .../src/common/menu/composite-menu-node.ts | 114 ------ packages/core/src/common/menu/index.ts | 5 +- packages/core/src/common/menu/menu-adapter.ts | 103 ------ .../src/common/menu/menu-model-registry.ts | 350 ++++++++---------- packages/core/src/common/menu/menu-types.ts | 210 +++++------ 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 | 116 +++--- ...ext-key-service.ts => comments-context.ts} | 21 +- .../browser/comments/comments-contribution.ts | 8 +- .../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 | 77 +--- .../browser/vsx-extensions-contribution.ts | 6 +- .../browser/vsx-extensions-view-container.ts | 23 +- 75 files changed, 1954 insertions(+), 2281 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 c9ad12d8950b7..8fe1bbdd6cb8d 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" @@ -62,4 +58,4 @@ "devDependencies": { "@theia/ext-scripts": "1.57.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 ef3c61d0d5d02..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/@phosphor/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, - icon: 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 e1972603d9068..9024f660a8df7 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'; @@ -226,9 +226,8 @@ export class SampleCommandContribution implements CommandContribution { export class SampleMenuContribution implements MenuContribution { registerMenus(menus: MenuModelRegistry): void { 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' @@ -238,7 +237,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' @@ -247,8 +246,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 @@ -262,16 +261,30 @@ export class SampleMenuContribution implements MenuContribution { /** * 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) { } + + isEnabled(effectiveMenuPath: MenuPath, ...args: any[]): boolean { + return false; + } - get icon(): string | undefined { - return this.options?.iconClass; + 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 4368376590f08..4809070ecd2f9 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'; @@ -83,7 +83,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']; @@ -606,7 +606,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 @@ -747,7 +747,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' @@ -1458,7 +1458,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'), @@ -1467,22 +1467,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 dd9bad8463373..d47e4345ddc54 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 ea29eff0ebf87..001c910a0f271 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 1ae5f1f826878..877ae25139e33 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 d1ceac06b8220..5bdb5169ecaca 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -18,8 +18,8 @@ import { injectable, inject } from 'inversify'; import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets'; import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands'; import { - CommandRegistry, environment, DisposableCollection, Disposable, - MenuModelRegistry, MAIN_MENU_BAR, MenuPath, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode + environment, DisposableCollection, + AcceleratorSource } from '../../common'; import { KeybindingRegistry } from '../keybinding'; import { FrontendApplication } from '../frontend-application'; @@ -30,6 +30,8 @@ import { waitForRevealed } from '../widgets'; import { ApplicationShell } from '../shell'; import { CorePreferences } from '../core-preferences'; import { PreferenceService } from '../preferences/preference-service'; +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; @@ -37,10 +39,7 @@ export abstract class MenuBarWidget extends MenuBar { } export interface BrowserMenuOptions extends MenuWidget.IOptions { - commands: MenuCommandRegistry, context?: HTMLElement, - contextKeyService?: ContextMatcher; - rootMenuPath: MenuPath }; @injectable() @@ -52,12 +51,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; @@ -104,53 +97,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 PhosphorCommandRegistry(); 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 PhosphorCommandRegistry(); + 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, }; } @@ -226,49 +197,50 @@ 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); } public aboutToShow({ previousFocusedElement }: { previousFocusedElement: HTMLElement | undefined }): void { 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); }); } @@ -282,8 +254,9 @@ export class DynamicMenuWidget extends MenuWidget { super.open(x, y, options, anchor); } - 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(); } @@ -292,43 +265,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 { @@ -413,79 +401,3 @@ export class BrowserMenuBarContribution implements FrontendApplicationContributi return logo; } } - -/** - * Stores Theia-specific action menu nodes instead of PhosphorJS commands with their handlers. - */ -export class MenuCommandRegistry extends PhosphorCommandRegistry { - - 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, - icon: 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: '.p-Widget' // We have the PhosphorJS 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 d7bcc9a09eae8..64814cc9e6a09 100644 --- a/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx +++ b/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx @@ -32,7 +32,8 @@ export class SidebarBottomMenuWidget extends SidebarMenuWidget { anchor: { x: button.left + button.width, y: button.top + button.height, - } + }, + 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 b2faa89240fa9..d6af7bc1b93fb 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,13 +152,16 @@ 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, }, + 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 e5c65095477b0..9f55f4edea210 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, + contextKeyService: this.contextKeyService, context: this.current?.node, 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, - 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/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index 13980be53718f..94d2c17c3cb7e 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -653,7 +653,7 @@ export class TabBarRenderer extends TabBar.Renderer { menuPath: this.contextMenuPath!, anchor: event, args: [event], - contextKeyService: contextKeyServiceOverlay, + contextKeyService: contextKeyServiceOverlay!, // We'd like to wait until the command triggered by the context menu has been run, but this should let it get through the preamble, at least. onHide: () => setTimeout(() => { if (this.selectionService) { this.selectionService.selection = oldSelection; } }) }); diff --git a/packages/core/src/browser/style/view-container.css b/packages/core/src/browser/style/view-container.css index 59a9f9b5bd7e6..15530167d4f50 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 > .p-SplitPanel { +.theia-view-container>.p-SplitPanel { height: 100%; width: 100%; } -.theia-view-container > .p-SplitPanel > .p-SplitPanel-child { +.theia-view-container>.p-SplitPanel>.p-SplitPanel-child { min-width: 50px; min-height: var(--theia-content-line-height); } -.theia-view-container > .p-SplitPanel > .p-SplitPanel-handle::after { +.theia-view-container>.p-SplitPanel>.p-SplitPanel-handle::after { min-height: 2px; min-width: 2px; } -.p-SplitPanel > .p-SplitPanel-handle:hover::after { +.p-SplitPanel>.p-SplitPanel-handle:hover::after { background-color: var(--theia-sash-hoverBorder); transition-delay: var(--theia-sash-hoverDelay); } -.p-SplitPanel > .p-SplitPanel-handle:active::after { +.p-SplitPanel>.p-SplitPanel-handle:active::after { background-color: var(--theia-sash-activeBorder); transition-delay: 0s !important; } -.p-SplitPanel[data-orientation="horizontal"] > .p-SplitPanel-handle::after { +.p-SplitPanel[data-orientation="horizontal"]>.p-SplitPanel-handle::after { min-width: var(--theia-sash-width); } -.p-SplitPanel[data-orientation="vertical"] > .p-SplitPanel-handle::after { +.p-SplitPanel[data-orientation="vertical"]>.p-SplitPanel-handle::after { min-height: var(--theia-sash-width); } @@ -75,11 +73,11 @@ font-weight: 700; } -.p-Widget > .theia-view-container-part-header { +.p-Widget>.theia-view-container-part-header { box-shadow: 0 1px 0 var(--theia-sideBarSectionHeader-border) inset; } -.p-Widget.p-first-visible > .theia-view-container-part-header { +.p-Widget.p-first-visible>.theia-view-container-part-header { box-shadow: none; } @@ -87,20 +85,12 @@ padding-left: 4px; } -.theia-view-container - > .p-SplitPanel[data-orientation="horizontal"] - .part - > .theia-header - .theia-ExpansionToggle::before { +.theia-view-container>.p-SplitPanel[data-orientation="horizontal"] .part>.theia-header .theia-ExpansionToggle::before { display: none; padding-left: 0px; } -.theia-view-container - > .p-SplitPanel[data-orientation="horizontal"] - .part - > .theia-header - .theia-ExpansionToggle { +.theia-view-container>.p-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; } @@ -172,16 +162,11 @@ display: none; } -.theia-view-container-part-title.menu-open, -.p-Widget.part:not(.collapsed):hover - .theia-view-container-part-header - .theia-view-container-part-title, -.p-Widget.part:not(.collapsed):focus-within - .theia-view-container-part-header - .theia-view-container-part-title { +.p-Widget.part:not(.collapsed):hover .theia-view-container-part-header .theia-view-container-part-title, +.p-Widget.part:not(.collapsed):focus-within .theia-view-container-part-header .theia-view-container-part-title { display: flex; } .no-pointer-events { pointer-events: none; -} +} \ No newline at end of file diff --git a/packages/core/src/browser/view-container.ts b/packages/core/src/browser/view-container.ts index ef39d86d12980..1c1a0bbaf1b5a 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, IDragEvent } from '@phosphor/dragdrop'; import { MimeData } from '@phosphor/coreutils'; import { ElementExt } from '@phosphor/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(); @@ -239,7 +269,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 { @@ -257,7 +287,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)) { @@ -311,12 +341,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)); } } @@ -324,25 +363,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/array-utils.ts b/packages/core/src/common/array-utils.ts index 0d50e35058db0..205adc348abc7 100644 --- a/packages/core/src/common/array-utils.ts +++ b/packages/core/src/common/array-utils.ts @@ -126,4 +126,28 @@ export namespace ArrayUtils { } return result; } + + export function shallowEquals(left: Array, right: Array): boolean { + if (left.length !== right.length) { + return false; + } + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) { + return false; + } + } + return true; + } + + export function isPrefix(left: Array, of: Array): boolean { + if (left.length > of.length) { + return false; + } + for (let i = 0; i < left.length; i++) { + if (left[i] !== of[i]) { + return false; + } + } + return true; + } } 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 afc1819a3b058..0000000000000 --- a/packages/core/src/common/menu/composite-menu-node.ts +++ /dev/null @@ -1,114 +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): void { - const idx = this._children.findIndex(n => n.id === id); - if (idx >= 0) { - this._children.splice(idx, 1); - } - } - - 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): void { 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 e321440c45170..5d910010885b0 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'); @@ -59,6 +58,16 @@ export interface MenuContribution { registerMenus(menus: MenuModelRegistry): void; } +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 * via strings and {@link MenuAction}s without the need to access the underlying UI @@ -66,23 +75,27 @@ export interface MenuContribution { */ @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); @@ -95,34 +108,40 @@ 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(); + }); + } + } /** - * 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); - this.fireChangeEvent(); - return this.changeEventOnDispose(disposable); - } - - 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; + 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 this.findGroup(group ? menuPath.concat(group) : menuPath); + const node = this.menuNodeFactory.createCommandMenu(item); + parent.addNode(node); + return Disposable.create(() => { + parent.removeNode(node); + this.fireChangeEvent(); + }); } + } /** @@ -140,58 +159,55 @@ 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(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); + return Disposable.create(() => { + parent.removeNode(newMenu); + this.fireChangeEvent(); + }); } else { - groupNode.updateOptions({ ...options, label }); + const newMenu = this.menuNodeFactory.createSubmenu(menuPath[menuPath.length - 1], label, contextKeyOverlay, sortString, icon, when); + parent.addNode(newMenu); + return Disposable.create(() => { + parent.removeNode(newMenu); + this.fireChangeEvent(); + }); } - this.fireChangeEvent(); - 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(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 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(); - return this.changeEventOnDispose(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(); + return Disposable.create(() => { + newParent.removeNode(link); + this.fireChangeEvent(); + }); + } else { + throw new Error(`Not a compound menu node: ${JSON.stringify(newParentPath)}`); + } } /** @@ -215,78 +231,54 @@ 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(); - return; + const parent = this.findInNode(this.root, menuPath, 0); + if (parent) { + this.removeActionInSubtree(parent, id); } - - this.unregisterMenuNode(id); } - /** - * Recurse all menus, removing any menus matching the `id`. - * - * @param id technical identifier of the `MenuNode`. - */ - unregisterMenuNode(id: string): void { - const recurse = (root: MutableCompoundMenuNode) => { - root.children.forEach(node => { - if (CompoundMenuNode.isMutable(node)) { - node.removeNode(id); - recurse(node); - } - }); - }; - recurse(this.root); - this.fireChangeEvent(); + 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)); + } } - /** - * 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 findInNode(root: CompoundMenuNode, menuPath: MenuPath, pathIndex: number): MenuNode | undefined { + if (pathIndex === menuPath.length) { + return root; + } + const child = root.children.find(c => c.id === menuPath[pathIndex]); + if (CompoundMenuNode.is(child)) { + return this.findInNode(child, menuPath, pathIndex + 1); } - return currentMenu; + return undefined; } - /** - * 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; - } - if (sub) { - throw new Error(`'${menuId}' is not a menu group.`); + 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)}`); } - const newSub = new CompositeMenuNode(menuId, undefined, options, current); - current.addNode(newSub); - return newSub; + return node; } - /** - * 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); + protected fireChangeEvent(): void { + if (this.isReady) { + this.onDidChangeEmitter.fire(); + } } /** @@ -297,78 +289,40 @@ 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; - } - - protected allChildrenCompound(children: ReadonlyArray): boolean { - return children.every(CompoundMenuNode.is); + return singleChild || fullMenuModel; } - 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(disposable: Disposable): Disposable { - return Disposable.create(() => { - disposable.dispose(); - this.fireChangeEvent(); - }); - } - - protected fireChangeEvent(): void { - if (this.isReady) { - this.onDidChangeEmitter.fire(); - } - } - - /** - * 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 b3a443816a010..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,125 +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. - */ - removeNode(id: string): void; +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)) { @@ -165,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. * @@ -187,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 0c6f4b691a051..67cc9a382518b 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, onHide: () => target.focus() }); @@ -86,7 +92,7 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { protected useNativeStyle: boolean = true; constructor(@inject(ElectronMainMenuFactory) private electronMenuFactory: ElectronMainMenuFactory) { - super(electronMenuFactory); + super(); } @postConstruct() @@ -98,15 +104,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(); } @@ -115,7 +126,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 66751758004e1..9d360f058ebd8 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 38c50a753c21a..36e3a144e0d4f 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 9216285c303e4..790daec9b3d7b 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])); } }); @@ -90,23 +85,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(); @@ -122,12 +112,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 fe9f8bd4fac35..56ac1ffaec3f0 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 - }) : - () => 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 + }); + } 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 ce02e28545981..45688efb9808f 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 1ca8cb3bc1f20..2621f9229b7e1 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(); + } + })); } }); } @@ -287,6 +290,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); } }; @@ -363,11 +370,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; }; @@ -382,17 +393,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 ?

    @@ -415,8 +419,9 @@ export class CommentForm

    extend

    ; } @@ -468,10 +474,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; @@ -571,7 +579,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, { thread: commentThread, @@ -593,8 +601,8 @@ export class CommentEditContainer extends React.Component; + return CommandMenu.is(node) && + ; } )}
    @@ -604,18 +612,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
    @@ -633,8 +643,9 @@ export class CommentsInlineAction extends React.Component string; @@ -644,29 +655,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, { + 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; } } @@ -674,11 +688,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 ee144563b105d..0447e1948f5ab 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,9 +193,9 @@ 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); + const zoneWidget = new CommentThreadWidget(editor, owner, thread, this.commentService, this.menus, this.commentsContext, this.contextKeyService, this.commands); zoneWidget.display({ afterLineNumber: thread.range.startLineNumber, heightInLines: 5 }); const currentEditor = this.getCurrentEditor(); if (currentEditor) { 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 ad09bf104ba6f..90bb0a689f83b 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'; @@ -751,7 +751,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 &&
    @@ -784,17 +784,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 bf4a81521548c..b467d4df3fa46 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/@phosphor/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 f896363f313de..6c1456158ac30 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'; @@ -232,7 +232,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 3dd0b346c9bc9..91d0c8bd502c9 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} @@ -535,7 +531,6 @@ export abstract class ScmElement

    export namespace ScmElement { export interface Props extends ScmTreeWidget.Props { renderExpansionToggle: () => React.ReactNode; - commandExecutor: MenuCommandExecutor; } export interface State { hover: boolean @@ -546,7 +541,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; @@ -583,7 +578,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, @@ -668,7 +662,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}
    ; @@ -792,7 +784,6 @@ export namespace ScmInlineActions { hover: boolean; menu: CompoundMenuNode; menuPath: MenuPath; - commandExecutor: MenuCommandExecutor; model: ScmTreeModel; treeNode: TreeNode; contextKeys: ScmContextKeyService; @@ -803,14 +794,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
    @@ -821,14 +812,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 37f123be2590d..8e72b4d4959e8 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 7c4380386e596..7302c43f70512 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); @@ -106,7 +104,7 @@ export class ToolbarImpl extends TabBarToolbar { const { menuPath, anchor } = this.getMenuDetailsForClick(event); return this.contextMenuRenderer.render({ args: contextMenuArgs, - menuPath, + menuPath: menuPath, anchor, }); } @@ -141,7 +139,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) { @@ -180,7 +178,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 = ( @@ -234,23 +232,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} @@ -278,41 +260,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 f29d92a566a19..bc49af8f507f8 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 eb9e76dfcf6c5d15ad82ab9276a9ea4d9566da25 Mon Sep 17 00:00:00 2001 From: Dennis Huebner Date: Fri, 3 Jan 2025 13:55:41 +0100 Subject: [PATCH 2/4] 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 56ac1ffaec3f0..e902e7d1974c9 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 }); } 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 465cfc90775de05ca3275f41dd930bd17516b2fa Mon Sep 17 00:00:00 2001 From: Dennis Huebner Date: Fri, 3 Jan 2025 15:36:37 +0100 Subject: [PATCH 3/4] 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 5bdb5169ecaca..a93ecdaabd291 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -291,7 +291,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 2ecff6ba195f482d34936bad7ab332074120933a 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 4/4] 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 | 5 ++++- .../src/main/browser/menus/plugin-menu-command-adapter.ts | 2 +- packages/toolbar/src/browser/toolbar.tsx | 2 +- 9 files changed, 20 insertions(+), 15 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 9024f660a8df7..d21e214cbf431 100644 --- a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts +++ b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts @@ -265,14 +265,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[] { @@ -283,7 +283,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 d47e4345ddc54..06858ee8ef471 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 d6af7bc1b93fb..e325631d0429f 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 2621f9229b7e1..d8138511b4b26 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 @@ -495,7 +495,10 @@ 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 7302c43f70512..ebf2759ffc1c3 100644 --- a/packages/toolbar/src/browser/toolbar.tsx +++ b/packages/toolbar/src/browser/toolbar.tsx @@ -234,7 +234,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 (