diff --git a/.vscode/launch.json b/.vscode/launch.json index 3539be4173f25..ee1cb6bc6fc38 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,17 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Playwright Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/@playwright/test/cli.js", + "cwd": "${workspaceFolder}/examples/playwright", + "args": [ + "test", + "--config=./configs/playwright.config.ts" + ] + }, { "type": "node", "request": "attach", diff --git a/CHANGELOG.md b/CHANGELOG.md index d399a4071e40e..a978991198273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ - [Previous Changelogs](https://github.com/eclipse-theia/theia/tree/master/doc/changelogs/) +## 1.62.0 - + + +[Breaking Changes:](#breaking_changes_1.62.0) + +- [core] Refactor menu nodes [#14676](https://github.com/eclipse-theia/theia/pull/14676) - Contributed on behalf of STMicroelectronics + + ## 1.61.0 - 4/29/2025 - [ai-anthropic] fix: do not set anthropic tool choice without tools [#15329](https://github.com/eclipse-theia/theia/pull/15329) @@ -199,11 +207,14 @@ [Breaking Changes:](#breaking_changes_1.60.0) - [core] fixed version `@types/express` to `^4.17.21` and `@types/express-serve-static-core` to `5.0.4`. This might be required for adopters as well if they run into typing issues. [#15147](https://github.com/eclipse-theia/theia/pull/15147) -- [core] migrated from deprecated `phosphorJs` to `Lumino`; adopters must update imports from `@phosphor` to `@lumino`, adjust CSS selectors from `.p-` to `.lm-`, and make minor code changes such as replacing `icon` with `iconClass` in commands [#14320](https://github.com/eclipse-theia/theia/pull/14320) - Contributed on behalf of STMicroelectronics -- [core] corrected typing of `addKeyListener` and `Widget.addKeyListener` to reflect events for `additionalEventTypes`; adopters explicitly expecting `KeyboardEvent` may need to update type declarations [#15210](https://github.com/eclipse-theia/theia/pull/15210) -- [ai] changed the format of `ai-features.modelSettings.requestSettings`; also improved message types and updated the request object for LLMs [#15092](https://github.com/eclipse-theia/theia/pull/15092) -- [ai-chat] changed `ParsedChatRequest.variables` to be `ResolvedAIVariable[]` instead of `Map` [#15196](https://github.com/eclipse-theia/theia/pull/15196) -- [ai-chat] made `ChatRequestParser.parseChatRequest` asynchronous and added a `ChatContext` parameter [#15196](https://github.com/eclipse-theia/theia/pull/15196) +- [core] migration from deprecated `phosphorJs` to actively maintained fork `Lumino` [#14320](https://github.com/eclipse-theia/theia/pull/14320) - Contributed on behalf of STMicroelectronics + Adopters importing `@phosphor` packages now need to import from `@lumino`. CSS selectors refering to `.p-` classes now need to refer to `.lm-` classes. There are also minor code adaptations, for example now using `iconClass` instead of `icon` in Lumino commands. +- [core] Refactor menu nodes [#14676](https://github.com/eclipse-theia/theia/pull/14676) - Contributed on behalf of STMicroelectronics + +[Breaking Changes:](#breaking_changes_1.60.0) + +- [ai-chat] `ParsedChatRequest.variables` is now `ResolvedAIVariable[]` instead of a `Map` [#15196](https://github.com/eclipse-theia/theia/pull/15196) +- [ai-chat] `ChatRequestParser.parseChatRequest` is now asynchronous and expects an additional `ChatContext` parameter [#15196](https://github.com/eclipse-theia/theia/pull/15196) ## 1.59.0 - 02/27/2025 diff --git a/doc/Migration.md b/doc/Migration.md index b921f8d839611..d1eb9f433717b 100644 --- a/doc/Migration.md +++ b/doc/Migration.md @@ -59,6 +59,14 @@ For example: } ``` +### v1.62.0 + +#### Refactor menu nodes [#14676](https://github.com/eclipse-theia/theia/pull/14676) + +This PR makes menu nodes and tab toolbar items into active object instead of pure data descriptors. This means they can polymorphically handle concerns like enablement, visibility, command execution and rendering. This keeps concerns like conversion of parameters out of the general tool bar and menu handling code. In this way, we could get rid of the MenuCommandExecutor and MenuCommandAdapter infrastructure. +If you are simply registering toolbar items and menus, little will change for you as a Theia adopter. Mainly, some of the paremeter types have changed in menu-model-registry.ts. Menu registration has been simplified in that an independent submenu is simply a menu that is registered under a path that does not start with the MAIN_MENU_BAR prefix. +If you override any of the toolbar or menu related implementations in your product, the biggest change will be that some functionality is now delegated to the menu and too bar item implementations. If this breaks your use case, please let us know. + ### v1.38.0 #### Inversify 6.0 diff --git a/examples/api-samples/package.json b/examples/api-samples/package.json index 76e3053d47739..a060619c9be60 100644 --- a/examples/api-samples/package.json +++ b/examples/api-samples/package.json @@ -25,10 +25,6 @@ "frontend": "lib/browser/api-samples-frontend-module", "backend": "lib/node/api-samples-backend-module" }, - { - "frontend": "lib/browser/menu/sample-browser-menu-module", - "frontendElectron": "lib/electron-browser/menu/sample-electron-menu-module" - }, { "electronMain": "lib/electron-main/update/sample-updater-main-module", "frontendElectron": "lib/electron-browser/updater/sample-updater-frontend-module" @@ -65,4 +61,4 @@ "devDependencies": { "@theia/ext-scripts": "1.61.0" } -} +} \ No newline at end of file diff --git a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts b/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts deleted file mode 100644 index 71d08c0256bcf..0000000000000 --- a/examples/api-samples/src/browser/menu/sample-browser-menu-module.ts +++ /dev/null @@ -1,98 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2020 TypeFox and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { injectable, ContainerModule } from '@theia/core/shared/inversify'; -import { Menu as MenuWidget } from '@theia/core/shared/@lumino/widgets'; -import { Disposable } from '@theia/core/lib/common/disposable'; -import { MenuNode, CompoundMenuNode, MenuPath } from '@theia/core/lib/common/menu'; -import { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget, BrowserMenuOptions } from '@theia/core/lib/browser/menu/browser-menu-plugin'; -import { PlaceholderMenuNode } from './sample-menu-contribution'; - -export default new ContainerModule((bind, unbind, isBound, rebind) => { - rebind(BrowserMainMenuFactory).to(SampleBrowserMainMenuFactory).inSingletonScope(); -}); - -@injectable() -class SampleBrowserMainMenuFactory extends BrowserMainMenuFactory { - - protected override registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode, args: unknown[]): void { - if (menu instanceof PlaceholderMenuNode && menuCommandRegistry instanceof SampleMenuCommandRegistry) { - menuCommandRegistry.registerPlaceholderMenu(menu); - } else { - super.registerMenu(menuCommandRegistry, menu, args); - } - } - - protected override createMenuCommandRegistry(menu: CompoundMenuNode, args: unknown[] = []): MenuCommandRegistry { - const menuCommandRegistry = new SampleMenuCommandRegistry(this.services); - this.registerMenu(menuCommandRegistry, menu, args); - return menuCommandRegistry; - } - - override createMenuWidget(menu: CompoundMenuNode, options: BrowserMenuOptions): DynamicMenuWidget { - return new SampleDynamicMenuWidget(menu, options, this.services); - } - -} - -class SampleMenuCommandRegistry extends MenuCommandRegistry { - - protected placeholders = new Map(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - registerPlaceholderMenu(menu: PlaceholderMenuNode): void { - const { id } = menu; - if (this.placeholders.has(id)) { - return; - } - this.placeholders.set(id, menu); - } - - override snapshot(menuPath: MenuPath): this { - super.snapshot(menuPath); - for (const menu of this.placeholders.values()) { - this.toDispose.push(this.registerPlaceholder(menu)); - } - return this; - } - - protected registerPlaceholder(menu: PlaceholderMenuNode): Disposable { - const { id } = menu; - return this.addCommand(id, { - execute: () => { /* NOOP */ }, - label: menu.label, - iconClass: menu.icon, - isEnabled: () => false, - isVisible: () => true - }); - } - -} - -class SampleDynamicMenuWidget extends DynamicMenuWidget { - - protected override buildSubMenus(parentItems: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { - if (menu instanceof PlaceholderMenuNode) { - parentItems.push({ - command: menu.id, - type: 'command', - }); - } else { - super.buildSubMenus(parentItems, menu, commands); - } - return parentItems; - } -} diff --git a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts index b334b75001ecc..b2596cf5a5c61 100644 --- a/examples/api-samples/src/browser/menu/sample-menu-contribution.ts +++ b/examples/api-samples/src/browser/menu/sample-menu-contribution.ts @@ -18,8 +18,8 @@ import { ConfirmDialog, Dialog, QuickInputService } from '@theia/core/lib/browse import { ReactDialog } from '@theia/core/lib/browser/dialogs/react-dialog'; import { SelectComponent } from '@theia/core/lib/browser/widgets/select-component'; import { - Command, CommandContribution, CommandRegistry, MAIN_MENU_BAR, - MenuContribution, MenuModelRegistry, MenuNode, MessageService, SubMenuOptions + Command, CommandContribution, CommandMenu, CommandRegistry, ContextExpressionMatcher, MAIN_MENU_BAR, + MenuContribution, MenuModelRegistry, MenuPath, MessageService } from '@theia/core/lib/common'; import { inject, injectable, interfaces } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; @@ -227,9 +227,8 @@ export class SampleMenuContribution implements MenuContribution { registerMenus(menus: MenuModelRegistry): void { setTimeout(() => { const subMenuPath = [...MAIN_MENU_BAR, 'sample-menu']; - menus.registerSubmenu(subMenuPath, 'Sample Menu', { - order: '2' // that should put the menu right next to the File menu - }); + menus.registerSubmenu(subMenuPath, 'Sample Menu', { sortString: '2' }); // that should put the menu right next to the File menu + menus.registerMenuAction(subMenuPath, { commandId: SampleCommand.id, order: '0' @@ -239,7 +238,7 @@ export class SampleMenuContribution implements MenuContribution { order: '2' }); const subSubMenuPath = [...subMenuPath, 'sample-sub-menu']; - menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { order: '2' }); + menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { sortString: '2' }); menus.registerMenuAction(subSubMenuPath, { commandId: SampleCommand.id, order: '1' @@ -248,8 +247,8 @@ export class SampleMenuContribution implements MenuContribution { commandId: SampleCommand2.id, order: '3' }); - const placeholder = new PlaceholderMenuNode([...subSubMenuPath, 'placeholder'].join('-'), 'Placeholder', { order: '0' }); - menus.registerMenuNode(subSubMenuPath, placeholder); + const placeholder = new PlaceholderMenuNode([...subSubMenuPath, 'placeholder'].join('-'), 'Placeholder', '0'); + menus.registerCommandMenu(subSubMenuPath, placeholder); /** * Register an action menu with an invalid command (un-registered and without a label) in order @@ -258,22 +257,35 @@ export class SampleMenuContribution implements MenuContribution { menus.registerMenuAction(subMenuPath, { commandId: 'invalid-command' }); }, 10000); } - } /** * Special menu node that is not backed by any commands and is always disabled. */ -export class PlaceholderMenuNode implements MenuNode { +export class PlaceholderMenuNode implements CommandMenu { - constructor(readonly id: string, public readonly label: string, protected options?: SubMenuOptions) { } + constructor(readonly id: string, public readonly label: string, readonly order?: string, readonly icon?: string) { } - get icon(): string | undefined { - return this.options?.iconClass; + isEnabled(effectiveMenuPath: MenuPath, ...args: unknown[]): boolean { + return false; + } + + isToggled(effectiveMenuPath: MenuPath): boolean { + return false; + } + run(effectiveMenuPath: MenuPath, ...args: unknown[]): 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: unknown[]): 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/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/examples/playwright/src/theia-toolbar-item.ts b/examples/playwright/src/theia-toolbar-item.ts index fe6408b245123..181dffc7cc75d 100644 --- a/examples/playwright/src/theia-toolbar-item.ts +++ b/examples/playwright/src/theia-toolbar-item.ts @@ -28,7 +28,8 @@ export class TheiaToolbarItem extends TheiaPageObject { } async isEnabled(): Promise { - const classAttribute = await this.element.getAttribute('class'); + const child = await this.element.$(':first-child'); + const classAttribute = child && await child.getAttribute('class'); if (classAttribute === undefined || classAttribute === null) { return false; } diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 5dcfcacabc4d4..9a862b3cb4b90 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -18,7 +18,7 @@ import debounce = require('lodash.debounce'); import { injectable, inject, optional } from 'inversify'; -import { MAIN_MENU_BAR, MANAGE_MENU, MenuContribution, MenuModelRegistry, ACCOUNTS_MENU } from '../common/menu'; +import { MAIN_MENU_BAR, MANAGE_MENU, MenuContribution, MenuModelRegistry, ACCOUNTS_MENU, CompoundMenuNode, CommandMenu, Group, Submenu } from '../common/menu'; import { KeybindingContribution, KeybindingRegistry } from './keybinding'; import { FrontendApplication } from './frontend-application'; import { FrontendApplicationContribution, OnWillStopAction } from './frontend-application-contribution'; @@ -84,7 +84,7 @@ export namespace CommonMenus { export const FILE_SETTINGS_SUBMENU_THEME = [...FILE_SETTINGS_SUBMENU, '2_settings_submenu_theme']; export const FILE_CLOSE = [...FILE, '6_close']; - export const FILE_NEW_CONTRIBUTIONS = 'file/newFile'; + export const FILE_NEW_CONTRIBUTIONS = ['file', 'newFile']; export const EDIT = [...MAIN_MENU_BAR, '2_edit']; export const EDIT_UNDO = [...EDIT, '1_undo']; @@ -622,7 +622,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi registry.registerSubmenu(CommonMenus.HELP, nls.localizeByDefault('Help')); // For plugins contributing create new file commands/menu-actions - registry.registerIndependentSubmenu(CommonMenus.FILE_NEW_CONTRIBUTIONS, nls.localizeByDefault('New File...')); + registry.registerSubmenu(CommonMenus.FILE_NEW_CONTRIBUTIONS, nls.localizeByDefault('New File...')); registry.registerMenuAction(CommonMenus.FILE_SAVE, { commandId: CommonCommands.SAVE.id @@ -763,7 +763,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi commandId: CommonCommands.SELECT_ICON_THEME.id }); - registry.registerSubmenu(CommonMenus.MANAGE_SETTINGS_THEMES, nls.localizeByDefault('Themes'), { order: 'a50' }); + registry.registerSubmenu(CommonMenus.MANAGE_SETTINGS_THEMES, nls.localizeByDefault('Themes'), { sortString: 'a50' }); registry.registerMenuAction(CommonMenus.MANAGE_SETTINGS_THEMES, { commandId: CommonCommands.SELECT_COLOR_THEME.id, order: '0' @@ -1499,7 +1499,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi * @todo https://github.com/eclipse-theia/theia/issues/12824 */ protected async showNewFilePicker(): Promise { - const newFileContributions = this.menuRegistry.getMenuNode(CommonMenus.FILE_NEW_CONTRIBUTIONS); // Add menus + const newFileContributions = this.menuRegistry.getMenuNode(CommonMenus.FILE_NEW_CONTRIBUTIONS) as Submenu; // Add menus const items: QuickPickItemOrSeparator[] = [ { label: nls.localizeByDefault('New Text File'), @@ -1508,22 +1508,22 @@ export class CommonFrontendContribution implements FrontendApplicationContributi }, ...newFileContributions.children .flatMap(node => { - if (node.children && node.children.length > 0) { + if (CompoundMenuNode.is(node) && node.children.length > 0) { return node.children; } return node; }) - .filter(node => node.role || node.command) + .filter(node => Group.is(node) || CommandMenu.is(node)) .map(node => { - if (node.role) { + if (Group.is(node)) { return { type: 'separator' } as QuickPickSeparator; + } else { + const item = node as CommandMenu; + return { + label: item.label, + execute: () => item.run(CommonMenus.FILE_NEW_CONTRIBUTIONS) + }; } - const command = this.commandRegistry.getCommand(node.command!); - return { - label: command!.label!, - execute: async () => this.commandRegistry.executeCommand(command!.id!) - }; - }) ]; diff --git a/packages/core/src/browser/context-menu-renderer.ts b/packages/core/src/browser/context-menu-renderer.ts index 56cb81a86b981..885d6f505575e 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 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(); /** @@ -80,13 +84,36 @@ 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({ + menuPath: options.menuPath, + menu, + anchor: resolvedOptions.anchor, + contextMatcher: options.contextKeyService || this.contextKeyService, + args: resolvedOptions.args, + context: resolvedOptions.context, + onHide: resolvedOptions.onHide + }); this.setCurrent(access); return access; } - protected abstract doRender(options: RenderContextMenuOptions): ContextMenuAccess; + protected abstract doRender(params: { + 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() : []; @@ -102,6 +129,7 @@ export abstract class ContextMenuRenderer { } export interface RenderContextMenuOptions { + menu?: CompoundMenuNode, menuPath: MenuPath; anchor: Anchor; args?: any[]; diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 98e0e3b2131f6..ad924695f966f 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -32,10 +32,6 @@ import { messageServicePath, InMemoryTextResourceResolver, UntitledResourceResolver, - MenuCommandAdapterRegistry, - MenuCommandExecutor, - MenuCommandAdapterRegistryImpl, - MenuCommandExecutorImpl, MenuPath } from '../common'; import { KeybindingRegistry, KeybindingContext, KeybindingContribution } from './keybinding'; @@ -271,8 +267,6 @@ export const frontendApplicationModule = new ContainerModule((bind, _unbind, _is bind(MenuModelRegistry).toSelf().inSingletonScope(); bindContributionProvider(bind, MenuContribution); - bind(MenuCommandAdapterRegistry).to(MenuCommandAdapterRegistryImpl).inSingletonScope(); - bind(MenuCommandExecutor).to(MenuCommandExecutorImpl).inSingletonScope(); bind(KeyboardLayoutService).toSelf().inSingletonScope(); bind(KeybindingRegistry).toSelf().inSingletonScope(); diff --git a/packages/core/src/browser/menu/action-menu-node.ts b/packages/core/src/browser/menu/action-menu-node.ts new file mode 100644 index 0000000000000..6b7e912c1f551 --- /dev/null +++ b/packages/core/src/browser/menu/action-menu-node.ts @@ -0,0 +1,128 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { KeybindingRegistry } from '../keybinding'; +import { ContextKeyService } from '../context-key-service'; +import { DisposableCollection, isObject, CommandRegistry, Emitter } from '../../common'; +import { CommandMenu, ContextExpressionMatcher, MenuAction, MenuPath } from '../../common/menu/menu-types'; + +export interface AcceleratorSource { + getAccelerator(context: HTMLElement | undefined): string[]; +} + +export namespace AcceleratorSource { + export function is(node: unknown): node is AcceleratorSource { + return isObject(node) && typeof node.getAccelerator === 'function'; + } +} + +/** + * Node representing an action in the menu tree structure. + * It's based on {@link MenuAction} for which it tries to determine the + * best label, icon and sortString with the given data. + */ +export class ActionMenuNode implements CommandMenu { + + protected readonly disposables = new DisposableCollection(); + protected readonly onDidChangeEmitter = new Emitter(); + + onDidChange = this.onDidChangeEmitter.event; + + constructor( + protected readonly action: MenuAction, + protected readonly commands: CommandRegistry, + protected readonly keybindingRegistry: KeybindingRegistry, + protected readonly contextKeyService: ContextKeyService + ) { + this.commands.getAllHandlers(action.commandId).forEach(handler => { + if (handler.onDidChangeEnabled) { + this.disposables.push(handler.onDidChangeEnabled(() => this.onDidChangeEmitter.fire())); + } + }); + + if (action.when) { + const contextKeys = new Set(); + this.contextKeyService.parseKeys(action.when)?.forEach(key => contextKeys.add(key)); + if (contextKeys.size > 0) { + this.disposables.push(this.contextKeyService.onDidChange(change => { + if (change.affects(contextKeys)) { + this.onDidChangeEmitter.fire(); + } + })); + } + } + } + + dispose(): void { + this.disposables.dispose(); + } + + isVisible(effeciveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean { + if (!this.commands.isVisible(this.action.commandId, ...args)) { + return false; + } + if (this.action.when) { + return contextMatcher.match(this.action.when, context); + } + return true; + } + + getAccelerator(context: HTMLElement | undefined): string[] { + const bindings = this.keybindingRegistry.getKeybindingsForCommand(this.action.commandId); + // Only consider the first active keybinding. + if (bindings.length) { + const binding = bindings.find(b => this.keybindingRegistry.isEnabledInScope(b, context)); + if (binding) { + return this.keybindingRegistry.acceleratorFor(binding, '+', true); + } + } + return []; + } + + isEnabled(effeciveMenuPath: MenuPath, ...args: unknown[]): boolean { + return this.commands.isEnabled(this.action.commandId, ...args); + } + isToggled(effeciveMenuPath: MenuPath, ...args: unknown[]): boolean { + return this.commands.isToggled(this.action.commandId, ...args); + } + async run(effeciveMenuPath: MenuPath, ...args: unknown[]): Promise { + return this.commands.executeCommand(this.action.commandId, ...args); + } + + get id(): string { return this.action.commandId; } + + get label(): string { + if (this.action.label) { + return this.action.label; + } + const cmd = this.commands.getCommand(this.action.commandId); + if (!cmd) { + console.debug(`No label for action menu node: No command "${this.action.commandId}" exists.`); + return ''; + } + return cmd.label || cmd.id; + } + + get icon(): string | undefined { + if (this.action.icon) { + return this.action.icon; + } + const command = this.commands.getCommand(this.action.commandId); + return command && command.iconClass; + } + + get sortString(): string { return this.action.order || this.label; } +} diff --git a/packages/core/src/browser/menu/browser-context-menu-renderer.ts b/packages/core/src/browser/menu/browser-context-menu-renderer.ts index baa5e28167873..2c88296f6dc47 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,18 +31,23 @@ 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); - const { x, y } = coordinateFromAnchor(anchor); - if (onHide) { - contextMenu.aboutToClose.connect(() => onHide!()); + protected doRender(params: { + menuPath: MenuPath, + menu: CompoundMenuNode, + anchor: Anchor, + contextMatcher: ContextMatcher, + args?: unknown[], + context?: HTMLElement, + onHide?: () => void + }): ContextMenuAccess { + const contextMenu = this.menuFactory.createContextMenu(params.menuPath, params.menu, params.contextMatcher, params.args, params.context); + const { x, y } = coordinateFromAnchor(params.anchor); + if (params.onHide) { + contextMenu.aboutToClose.connect(() => params.onHide!()); } - contextMenu.open(x, y, { host: context?.ownerDocument.body}); + contextMenu.open(x, y, { host: params.context?.ownerDocument.body }); return new BrowserContextMenuAccess(contextMenu); } 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..8572e0a39a0d6 --- /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(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(id, label, contextKeyOverlays, orderString, icon, when); + } + createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode { + return new SubMenuLink(delegate, sortString, when); + } +} diff --git a/packages/core/src/browser/menu/browser-menu-plugin.ts b/packages/core/src/browser/menu/browser-menu-plugin.ts index 83190a9d63215..f67ec8ab40df1 100644 --- a/packages/core/src/browser/menu/browser-menu-plugin.ts +++ b/packages/core/src/browser/menu/browser-menu-plugin.ts @@ -18,8 +18,8 @@ import { injectable, inject } from 'inversify'; import { Menu, MenuBar, Menu as MenuWidget, Widget } from '@lumino/widgets'; import { CommandRegistry as LuminoCommandRegistry } from '@lumino/commands'; import { - CommandRegistry, environment, DisposableCollection, Disposable, - MenuModelRegistry, MAIN_MENU_BAR, MenuPath, MenuNode, MenuCommandExecutor, CompoundMenuNode, CompoundMenuNodeRole, CommandMenuNode, + environment, DisposableCollection, + AcceleratorSource, ArrayUtils } from '../../common'; import { KeybindingRegistry } from '../keybinding'; @@ -32,6 +32,8 @@ import { ApplicationShell } from '../shell'; import { CorePreferences } from '../core-preferences'; import { PreferenceService } from '../preferences/preference-service'; import { ElementExt } from '@lumino/domutils'; +import { CommandMenu, CompoundMenuNode, MAIN_MENU_BAR, MenuNode, MenuPath, RenderedMenuNode, Submenu } from '../../common/menu/menu-types'; +import { MenuModelRegistry } from '../../common/menu/menu-model-registry'; export abstract class MenuBarWidget extends MenuBar { abstract activateMenu(label: string, ...labels: string[]): Promise; @@ -39,10 +41,7 @@ export abstract class MenuBarWidget extends MenuBar { } export interface BrowserMenuOptions extends MenuWidget.IOptions { - commands: MenuCommandRegistry, context?: HTMLElement, - contextKeyService?: ContextMatcher; - rootMenuPath: MenuPath }; @injectable() @@ -54,12 +53,6 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { @inject(ContextMenuContext) protected readonly context: ContextMenuContext; - @inject(CommandRegistry) - protected readonly commandRegistry: CommandRegistry; - - @inject(MenuCommandExecutor) - protected readonly menuCommandExecutor: MenuCommandExecutor; - @inject(CorePreferences) protected readonly corePreferences: CorePreferences; @@ -108,53 +101,31 @@ export class BrowserMainMenuFactory implements MenuWidgetFactory { } protected fillMenuBar(menuBar: MenuBarWidget): void { - const menuModel = this.menuProvider.getMenu(MAIN_MENU_BAR); - const menuCommandRegistry = this.createMenuCommandRegistry(menuModel); + const menuModel = this.menuProvider.getMenuNode(MAIN_MENU_BAR) as Submenu; + const menuCommandRegistry = new LuminoCommandRegistry(); for (const menu of menuModel.children) { - if (CompoundMenuNode.is(menu)) { - const menuWidget = this.createMenuWidget(menu, { commands: menuCommandRegistry, rootMenuPath: MAIN_MENU_BAR }); + if (CompoundMenuNode.is(menu) && RenderedMenuNode.is(menu)) { + const menuWidget = this.createMenuWidget(MAIN_MENU_BAR, menu, this.contextKeyService, { commands: menuCommandRegistry }); menuBar.addMenu(menuWidget); } } } - createContextMenu(path: MenuPath, args?: unknown[], context?: HTMLElement, contextKeyService?: ContextMatcher, skipSingleRootNode?: boolean): MenuWidget { - const menuModel = skipSingleRootNode ? this.menuProvider.removeSingleRootNode(this.menuProvider.getMenu(path), path) : this.menuProvider.getMenu(path); - const menuCommandRegistry = this.createMenuCommandRegistry(menuModel, args).snapshot(path); - const contextMenu = this.createMenuWidget(menuModel, { commands: menuCommandRegistry, context, rootMenuPath: path, contextKeyService }); + createContextMenu(effectiveMenuPath: MenuPath, menuModel: CompoundMenuNode, contextMatcher: ContextMatcher, args?: unknown[], context?: HTMLElement): MenuWidget { + const menuCommandRegistry = new LuminoCommandRegistry(); + const contextMenu = this.createMenuWidget(effectiveMenuPath, menuModel, contextMatcher, { commands: menuCommandRegistry, context }, args); return contextMenu; } - createMenuWidget(menu: CompoundMenuNode, options: BrowserMenuOptions): DynamicMenuWidget { - return new DynamicMenuWidget(menu, options, this.services); - } - - protected createMenuCommandRegistry(menu: CompoundMenuNode, args: unknown[] = []): MenuCommandRegistry { - const menuCommandRegistry = new MenuCommandRegistry(this.services); - this.registerMenu(menuCommandRegistry, menu, args); - return menuCommandRegistry; - } - - protected registerMenu(menuCommandRegistry: MenuCommandRegistry, menu: MenuNode, args: unknown[]): void { - if (CompoundMenuNode.is(menu)) { - menu.children.forEach(child => this.registerMenu(menuCommandRegistry, child, args)); - } else if (CommandMenuNode.is(menu)) { - menuCommandRegistry.registerActionMenu(menu, args); - if (CommandMenuNode.hasAltHandler(menu)) { - menuCommandRegistry.registerActionMenu(menu.altNode, args); - } - - } + createMenuWidget(parentPath: MenuPath, menu: CompoundMenuNode, contextMatcher: ContextMatcher, options: BrowserMenuOptions, args?: unknown[]): DynamicMenuWidget { + return new DynamicMenuWidget(parentPath, menu, options, contextMatcher, this.services, args); } protected get services(): MenuServices { return { - context: this.context, contextKeyService: this.contextKeyService, - commandRegistry: this.commandRegistry, - keybindingRegistry: this.keybindingRegistry, + context: this.context, menuWidgetFactory: this, - commandExecutor: this.menuCommandExecutor, }; } @@ -235,41 +206,43 @@ export class DynamicMenuBarWidget extends MenuBarWidget { } export class MenuServices { - readonly commandRegistry: CommandRegistry; - readonly keybindingRegistry: KeybindingRegistry; readonly contextKeyService: ContextKeyService; readonly context: ContextMenuContext; readonly menuWidgetFactory: MenuWidgetFactory; - readonly commandExecutor: MenuCommandExecutor; } export interface MenuWidgetFactory { - createMenuWidget(menu: MenuNode & Required>, options: BrowserMenuOptions): MenuWidget; + createMenuWidget(effectiveMenuPath: MenuPath, menu: Submenu, contextMatcher: ContextMatcher, options: BrowserMenuOptions): MenuWidget; } /** * A menu widget that would recompute its items on update. */ export class DynamicMenuWidget extends MenuWidget { - + private static nextCommmandId = 0; /** * We want to restore the focus after the menu closes. */ protected previousFocusedElement: HTMLElement | undefined; constructor( + protected readonly effectiveMenuPath: MenuPath, protected menu: CompoundMenuNode, protected options: BrowserMenuOptions, - protected services: MenuServices + protected contextMatcher: ContextMatcher, + protected services: MenuServices, + protected args?: unknown[] ) { super(options); - if (menu.label) { - this.title.label = menu.label; - } - if (menu.icon) { - this.title.iconClass = menu.icon; + if (RenderedMenuNode.is(this.menu)) { + if (this.menu.label) { + this.title.label = this.menu.label; + } + if (this.menu.icon) { + this.title.iconClass = this.menu.icon; + } } - this.updateSubMenus(this, this.menu, this.options.commands); + this.updateSubMenus(this.effectiveMenuPath, this, this.menu, this.options.commands, this.contextMatcher, this.options.context); } protected override onAfterAttach(msg: Message): void { @@ -318,8 +291,7 @@ export class DynamicMenuWidget extends MenuWidget { this.preserveFocusedElement(previousFocusedElement); this.clearItems(); this.runWithPreservedFocusContext(() => { - this.options.commands.snapshot(this.options.rootMenuPath); - this.updateSubMenus(this, this.menu, this.options.commands); + this.updateSubMenus(this.effectiveMenuPath, this, this.menu, this.options.commands, this.contextMatcher, this.options.context); }); } @@ -333,8 +305,9 @@ export class DynamicMenuWidget extends MenuWidget { super.open(x, y, options); } - protected updateSubMenus(parent: MenuWidget, menu: CompoundMenuNode, commands: MenuCommandRegistry): void { - const items = this.buildSubMenus([], menu, commands); + protected updateSubMenus(parentPath: MenuPath, parent: MenuWidget, menu: CompoundMenuNode, commands: LuminoCommandRegistry, + contextMatcher: ContextMatcher, context?: HTMLElement | undefined): void { + const items = this.createItems(parentPath, menu.children, commands, contextMatcher, context); while (items[items.length - 1]?.type === 'separator') { items.pop(); } @@ -350,43 +323,58 @@ export class DynamicMenuWidget extends MenuWidget { } } - protected buildSubMenus(parentItems: MenuWidget.IItemOptions[], menu: MenuNode, commands: MenuCommandRegistry): MenuWidget.IItemOptions[] { - if (CompoundMenuNode.is(menu) - && menu.children.length - && this.undefinedOrMatch(this.options.contextKeyService ?? this.services.contextKeyService, menu.when, this.options.context)) { - const role = menu === this.menu ? CompoundMenuNodeRole.Group : CompoundMenuNode.getRole(menu); - if (role === CompoundMenuNodeRole.Submenu) { - const submenu = this.services.menuWidgetFactory.createMenuWidget(menu, this.options); - if (submenu.items.length > 0) { - parentItems.push({ type: 'submenu', submenu }); - } - } else if (role === CompoundMenuNodeRole.Group && menu.id !== 'inline') { - const children = CompoundMenuNode.getFlatChildren(menu.children); - const myItems: MenuWidget.IItemOptions[] = []; - children.forEach(child => this.buildSubMenus(myItems, child, commands)); - if (myItems.length) { - if (parentItems.length && parentItems[parentItems.length - 1].type !== 'separator') { - parentItems.push({ type: 'separator' }); + protected createItems(parentPath: MenuPath, nodes: MenuNode[], phCommandRegistry: LuminoCommandRegistry, + 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 = !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 || [])), + isToggled: () => node.isToggled ? !!node.isToggled(nodePath, ...(this.args || [])) : false, + isVisible: () => true, + label: node.label, + iconClass: node.icon, + }); + + const accelerator = (AcceleratorSource.is(node) ? node.getAccelerator(this.options.context) : []); + if (accelerator.length > 0) { + phCommandRegistry.addKeyBinding({ + command: id, + keys: accelerator, + selector: '.p-Widget' // We have the PhosphorJS dependency anyway. + }); } - parentItems.push(...myItems); - parentItems.push({ type: 'separator' }); + result.push({ + command: id, + type: 'command' + }); } } - } else if (menu.command) { - const node = menu.altNode && this.services.context.altPressed ? menu.altNode : (menu as MenuNode & CommandMenuNode); - if (commands.isVisible(node.command) && this.undefinedOrMatch(this.options.contextKeyService ?? this.services.contextKeyService, node.when, this.options.context)) { - parentItems.push({ - command: node.command, - type: 'command' - }); - } } - return parentItems; - } - - protected undefinedOrMatch(contextKeyService: ContextMatcher, expression?: string, context?: HTMLElement): boolean { - if (expression) { return contextKeyService.match(expression, context); } - return true; + return result; } protected preserveFocusedElement(previousFocusedElement: Element | null = document.activeElement): boolean { @@ -473,79 +461,3 @@ export class BrowserMenuBarContribution implements FrontendApplicationContributi return logo; } } - -/** - * Stores Theia-specific action menu nodes instead of Lumino commands with their handlers. - */ -export class MenuCommandRegistry extends LuminoCommandRegistry { - - protected actions = new Map(); - protected toDispose = new DisposableCollection(); - - constructor(protected services: MenuServices) { - super(); - } - - registerActionMenu(menu: MenuNode & CommandMenuNode, args: unknown[]): void { - const { commandRegistry } = this.services; - const command = commandRegistry.getCommand(menu.command); - if (!command) { - return; - } - const { id } = command; - if (this.actions.has(id)) { - return; - } - this.actions.set(id, [menu, args]); - } - - snapshot(menuPath: MenuPath): this { - this.toDispose.dispose(); - for (const [menu, args] of this.actions.values()) { - this.toDispose.push(this.registerCommand(menu, args, menuPath)); - } - return this; - } - - protected registerCommand(menu: MenuNode & CommandMenuNode, args: unknown[], menuPath: MenuPath): Disposable { - const { commandRegistry, keybindingRegistry, commandExecutor } = this.services; - const command = commandRegistry.getCommand(menu.command); - if (!command) { - return Disposable.NULL; - } - const { id } = command; - if (this.hasCommand(id)) { - // several menu items can be registered for the same command in different contexts - return Disposable.NULL; - } - - // We freeze the `isEnabled`, `isVisible`, and `isToggled` states so they won't change. - const enabled = commandExecutor.isEnabled(menuPath, id, ...args); - const visible = commandExecutor.isVisible(menuPath, id, ...args); - const toggled = commandExecutor.isToggled(menuPath, id, ...args); - const unregisterCommand = this.addCommand(id, { - execute: () => commandExecutor.executeCommand(menuPath, id, ...args), - label: menu.label, - iconClass: menu.icon, - isEnabled: () => enabled, - isVisible: () => visible, - isToggled: () => toggled - }); - - const bindings = keybindingRegistry.getKeybindingsForCommand(id); - // Only consider the first active keybinding. - if (bindings.length) { - const binding = bindings.length > 1 ? - bindings.find(b => !b.when || this.services.contextKeyService.match(b.when)) ?? bindings[0] : - bindings[0]; - const keys = keybindingRegistry.acceleratorFor(binding, ' ', true); - this.addKeyBinding({ - command: id, - keys, - selector: '.lm-Widget' // We have the Lumino dependency anyway. - }); - } - return Disposable.create(() => unregisterCommand.dispose()); - } - -} diff --git a/packages/core/src/browser/menu/composite-menu-node.ts b/packages/core/src/browser/menu/composite-menu-node.ts new file mode 100644 index 0000000000000..be214582710c5 --- /dev/null +++ b/packages/core/src/browser/menu/composite-menu-node.ts @@ -0,0 +1,140 @@ +// ***************************************************************************** +// 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 { 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( + 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(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( + id: string, + orderString?: string, + when?: string + ) { + super(id, orderString, when); + } +} + +export class SubmenuImpl extends AbstractCompoundMenuImpl implements Submenu { + + constructor( + id: string, + readonly label: string, + readonly contextKeyOverlays: Record | undefined, + orderString?: string, + readonly icon?: string, + when?: string, + ) { + super(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 60% rename from packages/core/src/common/menu/menu.spec.ts rename to packages/core/src/browser/menu/menu.spec.ts index 650ae274574d0..1df928d46bce6 100644 --- a/packages/core/src/common/menu/menu.spec.ts +++ b/packages/core/src/browser/menu/menu.spec.ts @@ -15,12 +15,40 @@ // ***************************************************************************** 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, CommandMenu, CommandRegistry, CompoundMenuNode, Group, GroupImpl, MenuAction, MenuContribution, + MenuModelRegistry, MenuNode, MenuNodeFactory, MutableCompoundMenuNode, Submenu, + SubmenuImpl, + SubMenuLink +} from '../../common'; const expect = chai.expect; +class TestMenuNodeFactory implements MenuNodeFactory { + createGroup(id: string, orderString?: string, when?: string): Group & MutableCompoundMenuNode { + return new GroupImpl(id, orderString, when); + } + createSubmenu(id: string, label: string, contextKeyOverlays: Record | undefined, orderString?: string, icon?: string, when?: string): + Submenu & MutableCompoundMenuNode { + return new SubmenuImpl(id, label, contextKeyOverlays, orderString, icon, when); + } + createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode { + return new SubMenuLink(delegate, sortString, when); + } + + createCommandMenu(item: MenuAction): CommandMenu { + return { + isVisible: () => true, + isEnabled: () => true, + isToggled: () => false, + id: item.commandId, + label: item.label || '', + sortString: item.order || '', + run: () => Promise.resolve() + }; + } +} + describe('menu-model-registry', () => { describe('01 #register', () => { @@ -49,15 +77,13 @@ describe('menu-model-registry', () => { }); } }); - const all = service.getMenu(); - const main = all.children[0] as CompositeMenuNode; + const main = service.getMenu(['main'])!; expect(main.children.length).equals(1); expect(main.id, 'main'); - expect(all.children.length).equals(1); - const file = main.children[0] as 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; }); @@ -69,16 +95,22 @@ describe('menu-model-registry', () => { const service = createMenuRegistry({ registerMenus(menuRegistry: MenuModelRegistry): void { menuRegistry.registerSubmenu(fileMenu, 'File'); + menuRegistry.registerSubmenu(fileOpenMenu, 'Open'); + menuRegistry.registerSubmenu(fileCloseMenu, 'Close'); // open menu should not be added to open menu - menuRegistry.linkSubmenu(fileOpenMenu, fileOpenMenu); + try { + menuRegistry.linkCompoundMenuNode({ newParentPath: fileOpenMenu, submenuPath: fileOpenMenu }); + } catch (e) { + // expected + } // close menu should be added - menuRegistry.linkSubmenu(fileOpenMenu, fileCloseMenu); + menuRegistry.linkCompoundMenuNode({ newParentPath: fileOpenMenu, submenuPath: 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 main = service.getMenu(['main']) as CompoundMenuNode; + expect(menuStructureToString(main)).equals('File(0_open(1_close()),1_close())'); }); }); }); @@ -86,14 +118,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 TestMenuNodeFactory()); menuReg.onStart(); return menuReg; } -function menuStructureToString(node: CompositeMenuNode): string { +function menuStructureToString(node: CompoundMenuNode): string { return node.children.map(c => { - if (c instanceof CompositeMenuNode) { + if (CompoundMenuNode.is(c)) { return `${c.id}(${menuStructureToString(c)})`; } return c.id; diff --git a/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx b/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx index e1229e0112db2..f43f2f36390f2 100644 --- a/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx +++ b/packages/core/src/browser/shell/sidebar-bottom-menu-widget.tsx @@ -33,7 +33,8 @@ export class SidebarBottomMenuWidget extends SidebarMenuWidget { x: button.left + button.width, y: button.top + button.height, }, - context: e.currentTarget + context: e.currentTarget, + contextKeyService: this.contextKeyService }); } diff --git a/packages/core/src/browser/shell/sidebar-menu-widget.tsx b/packages/core/src/browser/shell/sidebar-menu-widget.tsx index b3cdf48e16dad..9999c69adc012 100644 --- a/packages/core/src/browser/shell/sidebar-menu-widget.tsx +++ b/packages/core/src/browser/shell/sidebar-menu-widget.tsx @@ -18,9 +18,10 @@ import { injectable, inject } from 'inversify'; import * as React from 'react'; import { ReactWidget } from '../widgets'; import { ContextMenuRenderer } from '../context-menu-renderer'; -import { MenuPath } from '../../common/menu'; +import { CompoundMenuNode, MenuModelRegistry, MenuPath } from '../../common/menu'; import { HoverService } from '../hover-service'; import { Event, Disposable, Emitter, DisposableCollection } from '../../common'; +import { ContextKeyService } from '../context-key-service'; export const SidebarTopMenuWidgetFactory = Symbol('SidebarTopMenuWidgetFactory'); export const SidebarBottomMenuWidgetFactory = Symbol('SidebarBottomMenuWidgetFactory'); @@ -90,9 +91,15 @@ export class SidebarMenuWidget extends ReactWidget { @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(MenuModelRegistry) + protected readonly menuRegistry: MenuModelRegistry; + @inject(HoverService) protected readonly hoverService: HoverService; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + constructor() { super(); this.items = []; @@ -145,14 +152,17 @@ export class SidebarMenuWidget extends ReactWidget { protected onClick(e: React.MouseEvent, menuPath: MenuPath): void { this.preservingContext = true; const button = e.currentTarget.getBoundingClientRect(); + const menu = this.menuRegistry.getMenuNode(menuPath) as CompoundMenuNode; this.contextMenuRenderer.render({ - menuPath, + menuPath: menuPath, + menu: menu, includeAnchorArg: false, anchor: { x: button.left + button.width, y: button.top, }, context: e.currentTarget, + contextKeyService: this.contextKeyService, onHide: () => { this.preservingContext = false; if (this.preservedContext) { diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts deleted file mode 100644 index 261fbd4bbf9f5..0000000000000 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.ts +++ /dev/null @@ -1,31 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2022 Ericsson and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { MenuNode, MenuPath } from '../../../common'; -import { NAVIGATION, RenderedToolbarItem } from './tab-bar-toolbar-types'; - -export const TOOLBAR_WRAPPER_ID_SUFFIX = '-as-tabbar-toolbar-item'; - -export class ToolbarMenuNodeWrapper implements RenderedToolbarItem { - constructor(protected readonly menuNode: MenuNode, readonly group: string | undefined, readonly delegateMenuPath: MenuPath, readonly menuPath?: MenuPath) { } - get id(): string { return this.menuNode.id + TOOLBAR_WRAPPER_ID_SUFFIX; } - get command(): string { return this.menuNode.command ?? ''; }; - get icon(): string | undefined { return this.menuNode.icon; } - get tooltip(): string | undefined { return this.menuNode.label; } - get when(): string | undefined { return this.menuNode.when; } - get text(): string | undefined { return (this.group === NAVIGATION || this.group === undefined) ? undefined : this.menuNode.label; } -} - diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx new file mode 100644 index 0000000000000..575bb865c4aad --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx @@ -0,0 +1,239 @@ +// ***************************************************************************** +// 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 '@lumino/widgets'; +import * as React from 'react'; +import { CommandRegistry, Event } from '../../../common'; +import { NAVIGATION, RenderedToolbarAction } from './tab-bar-toolbar-types'; +import { TabBarToolbar, toAnchor } from './tab-bar-toolbar'; +import { ACTION_ITEM, codicon } from '../../widgets'; +import { 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.contextMenuRenderer.render({ + menuPath: menuPath, + menu: this.menuNode as CompoundMenuNode, + args: [widget], + anchor, + context: widget?.node || event.target as HTMLElement, + contextKeyService: contextMatcher, + }); + } + + /** + * 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..eba632a1c0db2 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts @@ -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,17 +85,35 @@ 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 { + const wrapper = new RenderedToolbarItemImpl(this.commandRegistry, this.contextKeyService, this.keybindingRegistry, this.labelParser, item); + const disposables = this.doRegisterItem(wrapper); + disposables.push(wrapper); + return disposables; + } + } + } + + doRegisterItem(item: TabBarToolbarItem): DisposableCollection { + 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); + this.fireOnDidChange(); + }) ); + if (item.onDidChange) { toDispose.push(item.onDidChange(() => this.fireOnDidChange())); } @@ -97,31 +125,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 +159,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 +168,10 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { registerMenuDelegate(menuPath: MenuPath, when?: ((widget: Widget) => boolean)): Disposable { const id = this.toElementId(menuPath); if (!this.menuDelegates.has(id)) { - const isVisible: MenuDelegate['isVisible'] = !when - ? yes - : typeof when === 'function' - ? when - : widget => this.contextKeyService.match(when, widget?.node); - this.menuDelegates.set(id, { menuPath, isVisible }); + + this.menuDelegates.set(id, { + menuPath, isVisible: (widget: Widget) => !when || when(widget) + }); this.fireOnDidChange(); return { dispose: () => this.unregisterMenuDelegate(menuPath) }; } diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts index c9db6e3b18027..f383689400b30 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts @@ -15,8 +15,9 @@ // ***************************************************************************** import * as React from 'react'; -import { ArrayUtils, Event, isFunction, isObject, MenuPath } from '../../../common'; +import { ArrayUtils, Event, isFunction, isObject } from '../../../common'; import { Widget } from '../../widgets'; +import { MenuPath } from '../../../common/menu'; /** Items whose group is exactly 'navigation' will be rendered inline. */ export const NAVIGATION = 'navigation'; @@ -32,12 +33,12 @@ export namespace TabBarDelegator { } } -export type TabBarToolbarItem = RenderedToolbarItem | ReactTabBarToolbarItem; +export type TabBarToolbarAction = RenderedToolbarAction | ReactTabBarToolbarAction; /** * Representation of an item in the tab */ -export interface TabBarToolbarItemBase { +export interface TabBarToolbarActionBase { /** * The unique ID of the toolbar item. */ @@ -55,6 +56,7 @@ export interface TabBarToolbarItemBase { * Checked before the item is shown. */ isVisible?(widget?: Widget): boolean; + /** * When defined, the container tool-bar will be updated if this event is fired. * @@ -69,22 +71,16 @@ export interface TabBarToolbarItemBase { group?: string; /** * A menu path with which this item is associated. - * If accompanied by a command, this data will be passed to the {@link MenuCommandExecutor}. - * If no command is present, this menu will be opened. */ menuPath?: MenuPath; - /** - * The path of the menu delegate that contributed this toolbar item - */ - delegateMenuPath?: MenuPath; - contextKeyOverlays?: Record; + /** * Optional ordering string for placing the item within its group */ order?: string; } -export interface RenderedToolbarItem extends TabBarToolbarItemBase { +export interface RenderedToolbarAction extends TabBarToolbarActionBase { /** * Optional icon for the item. */ @@ -110,29 +106,24 @@ export interface RenderedToolbarItem extends TabBarToolbarItemBase { /** * Tab-bar toolbar item backed by a `React.ReactNode`. - * Unlike the `TabBarToolbarItem`, this item is not connected to the command service. + * Unlike the `TabBarToolbarAction`, this item is not connected to the command service. */ -export interface ReactTabBarToolbarItem extends TabBarToolbarItemBase { +export interface ReactTabBarToolbarAction extends TabBarToolbarActionBase { render(widget?: Widget): React.ReactNode; } -export namespace ReactTabBarToolbarItem { - export function is(item: TabBarToolbarItem): item is ReactTabBarToolbarItem { - return isObject(item) && typeof item.render === 'function'; +export namespace ReactTabBarToolbarAction { + export function is(item: TabBarToolbarAction): item is ReactTabBarToolbarAction { + return isObject(item) && typeof item.render === 'function'; } } -export interface MenuDelegate { - menuPath: MenuPath; - isVisible(widget?: Widget): boolean; -} - -export namespace TabBarToolbarItem { +export namespace TabBarToolbarAction { /** * Compares the items by `priority` in ascending. Undefined priorities will be treated as `0`. */ - export const PRIORITY_COMPARATOR = (left: TabBarToolbarItem, right: TabBarToolbarItem) => { + export const PRIORITY_COMPARATOR = (left: { group?: string, priority?: number }, right: { group?: string, priority?: number }) => { const leftGroup: string = left.group ?? NAVIGATION; const rightGroup: string = right.group ?? NAVIGATION; if (leftGroup === NAVIGATION && rightGroup !== NAVIGATION) { diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts index 7c4c0631d645a..6736f6b0b1f0f 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.spec.ts @@ -18,7 +18,7 @@ import { enableJSDOM } from '../../test/jsdom'; let disableJSDOM = enableJSDOM(); import { expect } from 'chai'; -import { TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { TabBarToolbarAction } from './tab-bar-toolbar-types'; disableJSDOM(); @@ -34,27 +34,27 @@ describe('tab-bar-toolbar', () => { disableJSDOM(); }); - const testMe = TabBarToolbarItem.PRIORITY_COMPARATOR; + const testMe = TabBarToolbarAction.PRIORITY_COMPARATOR; it("should favour the 'navigation' group before everything else", () => { - expect(testMe({ id: 'a', command: 'a', group: 'navigation' }, { id: 'b', command: 'b', group: 'other' })).to.be.equal(-1); + expect(testMe({ group: 'navigation' }, { group: 'other' })).to.be.equal(-1); }); it("should treat 'undefined' groups as 'navigation'", () => { - expect(testMe({ id: 'a', command: 'a' }, { id: 'b', command: 'b' })).to.be.equal(0); - expect(testMe({ id: 'a', command: 'a', group: 'navigation' }, { id: 'b', command: 'b' })).to.be.equal(0); - expect(testMe({ id: 'a', command: 'a' }, { id: 'b', command: 'b', group: 'navigation' })).to.be.equal(0); - expect(testMe({ id: 'a', command: 'a' }, { id: 'b', command: 'b', group: 'other' })).to.be.equal(-1); + expect(testMe({}, {})).to.be.equal(0); + expect(testMe({ group: 'navigation' }, {})).to.be.equal(0); + expect(testMe({}, { group: 'navigation' })).to.be.equal(0); + expect(testMe({}, { group: 'other' })).to.be.equal(-1); }); it("should fall back to 'priority' if the groups are the same", () => { - expect(testMe({ id: 'a', command: 'a', priority: 1 }, { id: 'b', command: 'b', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', group: 'navigation', priority: 1 }, { id: 'b', command: 'b', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', priority: 1 }, { id: 'b', command: 'b', group: 'navigation', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', priority: 1, group: 'other' }, { id: 'b', command: 'b', priority: 2 })).to.be.equal(1); - expect(testMe({ id: 'a', command: 'a', group: 'other', priority: 1 }, { id: 'b', command: 'b', priority: 2, group: 'other' })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', priority: 10 }, { id: 'b', command: 'b', group: 'other', priority: 2 })).to.be.equal(-1); - expect(testMe({ id: 'a', command: 'a', group: 'other', priority: 10 }, { id: 'b', command: 'b', group: 'other', priority: 10 })).to.be.equal(0); + expect(testMe({ priority: 1 }, { priority: 2 })).to.be.equal(-1); + expect(testMe({ group: 'navigation', priority: 1 }, { priority: 2 })).to.be.equal(-1); + expect(testMe({ priority: 1 }, { group: 'navigation', priority: 2 })).to.be.equal(-1); + expect(testMe({ priority: 1, group: 'other' }, { priority: 2 })).to.be.equal(1); + expect(testMe({ group: 'other', priority: 1 }, { priority: 2, group: 'other' })).to.be.equal(-1); + expect(testMe({ priority: 10 }, { group: 'other', priority: 2 })).to.be.equal(-1); + expect(testMe({ group: 'other', priority: 10 }, { group: 'other', priority: 10 })).to.be.equal(0); }); }); diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx index ba21e14ada6e0..8101899d8f15e 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -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('contextMenu'); + for (const item of this.more.values()) { + if (item.toMenuNode) { + const node = item.toMenuNode(); + if (node) { + menu.addNode(node); } } } return this.contextMenuRenderer.render({ - menuPath: TAB_BAR_TOOLBAR_CONTEXT_MENU, + menu: menu!, + menuPath: ['contextMenu'], args: [this.current], anchor, context: this.current?.node || this.node, + contextKeyService: this.contextKeyService, onHide: () => toDisposeOnHide.dispose(), skipSingleRootNode: true, }); } - /** - * Renders a toolbar item that is a menu, presenting it as a button with a little - * chevron decoration that pops up a floating menu when clicked. - * - * @param item a toolbar item that is a menu item - * @returns the rendered toolbar item - */ - protected renderMenuItem(item: RenderedToolbarItem): React.ReactNode { - const command = item.command ? this.commands.getCommand(item.command) : undefined; - const icon = (typeof item.icon === 'function' && item.icon()) || item.icon as string || (command && command.iconClass) || 'ellipsis'; - - let contextMatcher: ContextMatcher = this.contextKeyService; - if (item.contextKeyOverlays) { - contextMatcher = this.contextKeyService.createOverlay(Object.keys(item.contextKeyOverlays).map(key => [key, item.contextKeyOverlays![key]])); - } - - return
-
this.executeCommand(e, item)} - /> -
this.showPopupMenu(item.menuPath!, event, contextMatcher)}> -
-
- -
; - } - - /** - * Presents the menu to popup on the `event` that is the clicking of - * a menu toolbar item. - * - * @param menuPath the path of the registered menu to show - * @param event the mouse event triggering the menu - */ - protected showPopupMenu = (menuPath: MenuPath, event: React.MouseEvent, contextMatcher: ContextMatcher) => { - event.stopPropagation(); - event.preventDefault(); - const anchor = this.toAnchor(event); - this.renderPopupMenu(menuPath, anchor, contextMatcher); - }; - - /** - * Renders the menu popped up on a menu toolbar item. - * - * @param menuPath the path of the registered menu to render - * @param anchor a description of where to render the menu - * @returns platform-specific access to the rendered context menu - */ - protected renderPopupMenu(menuPath: MenuPath, anchor: Anchor, contextMatcher: ContextMatcher): ContextMenuAccess { - const toDisposeOnHide = new DisposableCollection(); - this.addClass('menu-open'); - toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open'))); - - return this.contextMenuRenderer.render({ - menuPath, - args: [this.current], - anchor, - context: this.current?.node || this.node, - contextKeyService: contextMatcher, - onHide: () => toDisposeOnHide.dispose() - }); - } - shouldHandleMouseEvent(event: MouseEvent): boolean { return event.target instanceof Element && this.node.contains(event.target); } @@ -397,39 +195,11 @@ export class TabBarToolbar extends ReactWidget { return whenClause ? this.contextKeyService.match(whenClause, this.current?.node) : true; } - protected executeCommand(e: React.MouseEvent, item: TabBarToolbarItem): void { - e.preventDefault(); - e.stopPropagation(); - - if (!item || !this.isEnabled(item)) { - return; - } - - if (item.command && item.delegateMenuPath) { - this.menuCommandExecutor.executeCommand(item.delegateMenuPath, item.command, this.current); - } else if (item.command) { - this.commands.executeCommand(item.command, this.current); - } else if (item.menuPath) { - this.renderMoreContextMenu(this.toAnchor(e), item.menuPath); - } - this.maybeUpdate(); - }; - protected maybeUpdate(): void { if (!this.isDisposed) { this.update(); } } - - protected onMouseDownEvent = (e: React.MouseEvent) => { - if (e.button === 0) { - e.currentTarget.classList.add('active'); - } - }; - - protected onMouseUpEvent = (e: React.MouseEvent) => { - e.currentTarget.classList.remove('active'); - }; } export namespace TabBarToolbar { diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx new file mode 100644 index 0000000000000..34b89ed58c353 --- /dev/null +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-toolbar-item.tsx @@ -0,0 +1,251 @@ +// ***************************************************************************** +// 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 '@lumino/widgets'; +import { LabelIcon, LabelParser } from '../../label-parser'; +import { CommandRegistry, Event, Disposable, Emitter, DisposableCollection } 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; + } + + get onDidChange(): Event | undefined { + return this.action.onDidChange; + } + + 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; + protected disposables = new DisposableCollection(); + + constructor( + commandRegistry: CommandRegistry, + contextKeyService: ContextKeyService, + protected readonly keybindingRegistry: KeybindingRegistry, + protected readonly labelParser: LabelParser, + action: RenderedToolbarAction) { + super(commandRegistry, contextKeyService, action); + if (action.onDidChange) { + this.disposables.push(action.onDidChange(() => this.onDidChangeEmitter.fire())); + } + } + + dispose(): void { + this.disposables.dispose(); + } + + 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; + override get onDidChange(): Event | undefined { + return 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(menuPath[0], this.action.order); + menu = menu.getOrCreate(menuPath, 1, menuPath.length); + menu.addNode(action); + return menu; + } + return action; + } + + protected onMouseDownEvent = (e: React.MouseEvent) => { + if (e.button === 0) { + e.currentTarget.classList.add('active'); + } + }; + + protected onMouseUpEvent = (e: React.MouseEvent) => { + e.currentTarget.classList.remove('active'); + }; + + protected executeCommand(e: React.MouseEvent, widget?: Widget): void { + e.preventDefault(); + e.stopPropagation(); + + if (!this.isEnabled(widget)) { + return; + } + + if (this.action.command) { + this.commandRegistry.executeCommand(this.action.command, widget); + } + }; + + protected renderItem(widget?: Widget): React.ReactNode { + let innerText = ''; + const classNames = []; + const command = this.action.command ? this.commandRegistry.getCommand(this.action.command) : undefined; + // Fall back to the item ID in extremis so there is _something_ to render in the + // case that there is neither an icon nor a title + const itemText = this.action.text || command?.label || command?.id || this.action.id; + if (itemText) { + for (const labelPart of this.labelParser.parse(itemText)) { + if (LabelIcon.is(labelPart)) { + const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; + classNames.push(...className.split(' ')); + } else { + innerText = labelPart; + } + } + } + const iconClass = (typeof this.action.icon === 'function' && this.action.icon()) || this.action.icon as string || (command && command.iconClass); + if (iconClass) { + classNames.push(iconClass); + } + const tooltipText = this.action.tooltip || (command && command.label) || ''; + const tooltip = `${this.labelParser.stripIcons(tooltipText)}${this.resolveKeybindingForCommand(widget, command?.id)}`; + + // Only present text if there is no icon + if (classNames.length) { + innerText = ''; + } else if (innerText) { + // Make room for the label text + classNames.push(NO_ICON_CLASS); + } + + // In any case, this is an action item, with or without icon. + classNames.push(ACTION_ITEM); + + const toolbarItemClassNames = this.getToolbarItemClassNames(widget); + return
+
this.executeCommand(e, widget)} + title={tooltip} > {innerText} +
+
; + } +} + +export class ReactToolbarItemImpl extends AbstractToolbarItemImpl implements TabBarToolbarItem { + render(widget?: Widget | undefined): React.ReactNode { + return this.action.render(widget); + } +} diff --git a/packages/core/src/browser/style/view-container.css b/packages/core/src/browser/style/view-container.css index da628e480a1e3..ae4c7f2ad98e8 100644 --- a/packages/core/src/browser/style/view-container.css +++ b/packages/core/src/browser/style/view-container.css @@ -16,9 +16,7 @@ :root { --theia-view-container-title-height: var(--theia-content-line-height); - --theia-view-container-content-height: calc( - 100% - var(--theia-view-container-title-height) - ); + --theia-view-container-content-height: calc(100% - var(--theia-view-container-title-height)); } .theia-view-container { @@ -27,36 +25,36 @@ flex-direction: column; } -.theia-view-container > .lm-SplitPanel { +.theia-view-container>.lm-SplitPanel { height: 100%; width: 100%; } -.theia-view-container > .lm-SplitPanel > .lm-SplitPanel-child { +.theia-view-container>.lm-SplitPanel>.lm-SplitPanel-child { min-width: 50px; min-height: var(--theia-content-line-height); } -.theia-view-container > .lm-SplitPanel > .lm-SplitPanel-handle::after { +.theia-view-container>.lm-SplitPanel>.lm-SplitPanel-handle::after { min-height: 2px; min-width: 2px; } -.lm-SplitPanel > .lm-SplitPanel-handle:hover::after { +.lm-SplitPanel>.lm-SplitPanel-handle:hover::after { background-color: var(--theia-sash-hoverBorder); transition-delay: var(--theia-sash-hoverDelay); } -.lm-SplitPanel > .lm-SplitPanel-handle:active::after { +.lm-SplitPanel>.lm-SplitPanel-handle:active::after { background-color: var(--theia-sash-activeBorder); transition-delay: 0s !important; } -.lm-SplitPanel[data-orientation="horizontal"] > .lm-SplitPanel-handle::after { +.lm-SplitPanel[data-orientation="horizontal"]>.lm-SplitPanel-handle::after { min-width: var(--theia-sash-width); } -.lm-SplitPanel[data-orientation="vertical"] > .lm-SplitPanel-handle::after { +.lm-SplitPanel[data-orientation="vertical"]>.lm-SplitPanel-handle::after { min-height: var(--theia-sash-width); } @@ -76,11 +74,11 @@ overflow: hidden; } -.lm-Widget > .theia-view-container-part-header { +.lm-Widget>.theia-view-container-part-header { box-shadow: 0 1px 0 var(--theia-sideBarSectionHeader-border) inset; } -.lm-Widget.lm-first-visible > .theia-view-container-part-header { +.lm-Widget.lm-first-visible>.theia-view-container-part-header { box-shadow: none; } @@ -88,20 +86,12 @@ padding-left: 4px; } -.theia-view-container - > .lm-SplitPanel[data-orientation="horizontal"] - .part - > .theia-header - .theia-ExpansionToggle::before { +.theia-view-container>.lm-SplitPanel[data-orientation="horizontal"] .part>.theia-header .theia-ExpansionToggle::before { display: none; padding-left: 0px; } -.theia-view-container - > .lm-SplitPanel[data-orientation="horizontal"] - .part - > .theia-header - .theia-ExpansionToggle { +.theia-view-container>.lm-SplitPanel[data-orientation="horizontal"] .part>.theia-header .theia-ExpansionToggle { padding-left: 0px; } @@ -127,19 +117,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; } @@ -174,12 +164,8 @@ } .theia-view-container-part-title.menu-open, -.lm-Widget.part:not(.collapsed):hover - .theia-view-container-part-header - .theia-view-container-part-title, -.lm-Widget.part:not(.collapsed):focus-within - .theia-view-container-part-header - .theia-view-container-part-title { +.lm-Widget.part:not(.collapsed):hover .theia-view-container-part-header .theia-view-container-part-title, +.lm-Widget.part:not(.collapsed):focus-within .theia-view-container-part-header .theia-view-container-part-title { display: flex; } diff --git a/packages/core/src/browser/view-container.ts b/packages/core/src/browser/view-container.ts index 803a071be30b0..994db0e7608e7 100644 --- a/packages/core/src/browser/view-container.ts +++ b/packages/core/src/browser/view-container.ts @@ -23,13 +23,13 @@ import { import { Event as CommonEvent, Emitter } from '../common/event'; import { Disposable, DisposableCollection } from '../common/disposable'; import { CommandRegistry } from '../common/command'; -import { MenuModelRegistry, MenuPath, MenuAction } from '../common/menu'; +import { MenuModelRegistry, MenuPath, MenuAction, SubmenuImpl, ActionMenuNode, MenuNode, RenderedMenuNode } from '../common/menu'; import { ApplicationShell, StatefulWidget, SplitPositionHandler, SplitPositionOptions, SIDE_PANEL_TOOLBAR_CONTEXT_MENU } from './shell'; import { MAIN_AREA_ID, BOTTOM_AREA_ID } from './shell/theia-dock-panel'; import { FrontendApplicationStateService } from './frontend-application-state'; import { ContextMenuRenderer, Anchor } from './context-menu-renderer'; import { parseCssMagnitude } from './browser'; -import { TabBarToolbarRegistry, TabBarToolbarFactory, TabBarToolbar, TabBarDelegator, RenderedToolbarItem } from './shell/tab-bar-toolbar'; +import { TabBarToolbarRegistry, TabBarToolbarFactory, TabBarToolbar, TabBarDelegator } from './shell/tab-bar-toolbar'; import { isEmpty, isObject, nls } from '../common'; import { WidgetManager } from './widget-manager'; import { Key } from './keys'; @@ -38,6 +38,9 @@ import { Drag } from '@lumino/dragdrop'; import { MimeData } from '@lumino/coreutils'; import { ElementExt } from '@lumino/domutils'; import { TabBarDecoratorService } from './shell/tab-bar-decorator'; +import { ContextKeyService } from './context-key-service'; +import { KeybindingRegistry } from './keybinding'; +import { ToolbarMenuNodeWrapper } from './shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters'; export interface ViewContainerTitleOptions { label: string; @@ -90,6 +93,26 @@ export namespace DynamicToolbarWidget { } } +class PartsMenuToolbarItem extends ToolbarMenuNodeWrapper { + constructor( + protected readonly target: () => Widget | undefined, + effectiveMenuPath: MenuPath, + commandRegistry: CommandRegistry, + menuRegistry: MenuModelRegistry, + contextKeyService: ContextKeyService, + contextMenuRenderer: ContextMenuRenderer, + menuNode: MenuNode & RenderedMenuNode, + group: string | undefined, + menuPath?: MenuPath, + ) { + super(effectiveMenuPath, commandRegistry, menuRegistry, contextKeyService, contextMenuRenderer, menuNode, group, menuPath); + } + + override isVisible(widget: Widget): boolean { + return widget === this.target() && super.isVisible(widget); + } +} + /** * A view container holds an arbitrary number of widgets inside a split panel. * Each widget is wrapped in a _part_ that displays the widget title and toolbar @@ -146,8 +169,15 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica @inject(TabBarDecoratorService) protected readonly decoratorService: TabBarDecoratorService; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + + @inject(KeybindingRegistry) + protected readonly keybindingRegistry: KeybindingRegistry; + @postConstruct() protected init(): void { + this.toDispose.push(Disposable.create(() => { this.toDisposeOnUpdateTitle.dispose(); })); this.id = this.options.id; this.addClass('theia-view-container'); const layout = new PanelLayout(); @@ -243,7 +273,7 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica this.updateTitle(); } - protected readonly toDisposeOnUpdateTitle = new DisposableCollection(); + protected toDisposeOnUpdateTitle = new DisposableCollection(); protected _tabBarDelegate: Widget = this; updateTabBarDelegate(): void { @@ -261,7 +291,7 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica protected updateTitle(): void { this.toDisposeOnUpdateTitle.dispose(); - this.toDispose.push(this.toDisposeOnUpdateTitle); + this.toDisposeOnUpdateTitle = new DisposableCollection(); this.updateTabBarDelegate(); let title = Object.assign({}, this.titleOptions); if (isEmpty(title)) { @@ -315,12 +345,21 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica protected updateToolbarItems(allParts: ViewContainerPart[]): void { if (allParts.length > 1) { - const group = this.getToggleVisibilityGroupLabel(); + const group = new SubmenuImpl(`toggleParts-${this.id}`, this.getToggleVisibilityGroupLabel(), undefined); for (const part of allParts) { const existingId = this.toggleVisibilityCommandId(part); - const { caption, label, dataset: { visibilityCommandLabel } } = part.wrapped.title; - this.registerToolbarItem(existingId, { tooltip: visibilityCommandLabel || caption || label, group }); + const { label } = part.wrapped.title; + group.addNode(new ActionMenuNode({ + commandId: existingId, + label: label + }, this.commandRegistry, this.keybindingRegistry, this.contextKeyService)); } + + // widget === this.getTabBarDelegate() + + const toolbarItem = new PartsMenuToolbarItem(() => this.getTabBarDelegate(), [this.id], this.commandRegistry, this.menuRegistry, + this.contextKeyService, this.contextMenuRenderer, group, 'view', [this.id]); + this.toDisposeOnUpdateTitle.push(this.toolbarRegistry.doRegisterItem(toolbarItem)); } } @@ -328,25 +367,6 @@ export class ViewContainer extends BaseWidget implements StatefulWidget, Applica return 'view'; } - protected registerToolbarItem(commandId: string, options?: Partial>): void { - const newId = `${this.id}-tabbar-toolbar-${commandId}`; - const existingHandler = this.commandRegistry.getAllHandlers(commandId)[0]; - const existingCommand = this.commandRegistry.getCommand(commandId); - if (existingHandler && existingCommand) { - this.toDisposeOnUpdateTitle.push(this.commandRegistry.registerCommand({ ...existingCommand, id: newId }, { - execute: (_widget, ...args) => this.commandRegistry.executeCommand(commandId, ...args), - isToggled: (_widget, ...args) => this.commandRegistry.isToggled(commandId, ...args), - isEnabled: (_widget, ...args) => this.commandRegistry.isEnabled(commandId, ...args), - isVisible: (widget, ...args) => widget === this.getTabBarDelegate() && this.commandRegistry.isVisible(commandId, ...args), - })); - this.toDisposeOnUpdateTitle.push(this.toolbarRegistry.registerItem({ - ...options, - id: newId, - command: newId, - })); - } - } - protected findOriginalPart(): ViewContainerPart | undefined { return this.getParts().find(part => part.originalContainerId === this.id); } diff --git a/packages/core/src/common/menu/action-menu-node.ts b/packages/core/src/common/menu/action-menu-node.ts deleted file mode 100644 index 2da168d8c0da3..0000000000000 --- a/packages/core/src/common/menu/action-menu-node.ts +++ /dev/null @@ -1,65 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2022 Ericsson and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { CommandRegistry } from '../command'; -import { AlternativeHandlerMenuNode, CommandMenuNode, MenuAction, MenuNode } from './menu-types'; - -/** - * Node representing an action in the menu tree structure. - * It's based on {@link MenuAction} for which it tries to determine the - * best label, icon and sortString with the given data. - */ -export class ActionMenuNode implements MenuNode, CommandMenuNode, Partial { - - readonly altNode: ActionMenuNode | undefined; - - constructor( - protected readonly action: MenuAction, - protected readonly commands: CommandRegistry, - ) { - if (action.alt) { - this.altNode = new ActionMenuNode({ commandId: action.alt }, commands); - } - } - - get command(): string { return this.action.commandId; }; - - get when(): string | undefined { return this.action.when; } - - get id(): string { return this.action.commandId; } - - get label(): string { - if (this.action.label) { - return this.action.label; - } - const cmd = this.commands.getCommand(this.action.commandId); - if (!cmd) { - console.debug(`No label for action menu node: No command "${this.action.commandId}" exists.`); - return ''; - } - return cmd.label || cmd.id; - } - - get icon(): string | undefined { - if (this.action.icon) { - return this.action.icon; - } - const command = this.commands.getCommand(this.action.commandId); - return command && command.iconClass; - } - - get sortString(): string { return this.action.order || this.label; } -} diff --git a/packages/core/src/common/menu/composite-menu-node.spec.ts b/packages/core/src/common/menu/composite-menu-node.spec.ts deleted file mode 100644 index 24a002af1a526..0000000000000 --- a/packages/core/src/common/menu/composite-menu-node.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2024 EclipseSource and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** -import { expect } from 'chai'; -import { CompositeMenuNode } from './composite-menu-node'; -import { CompoundMenuNodeRole } from './menu-types'; - -describe('composite-menu-node', () => { - describe('updateOptions', () => { - it('should update undefined node properties', () => { - const node = new CompositeMenuNode('test-id'); - node.updateOptions({ label: 'node-label', icon: 'icon', order: 'a', role: CompoundMenuNodeRole.Flat, when: 'node-condition' }); - expect(node.label).to.equal('node-label'); - expect(node.icon).to.equal('icon'); - expect(node.order).to.equal('a'); - expect(node.role).to.equal(CompoundMenuNodeRole.Flat); - expect(node.when).to.equal('node-condition'); - }); - it('should update existing node properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a1', role: CompoundMenuNodeRole.Submenu, when: 'test-condition' }); - node.updateOptions({ label: 'NEW-label', icon: 'NEW-icon', order: 'a2', role: CompoundMenuNodeRole.Flat, when: 'NEW-condition' }); - expect(node.label).to.equal('NEW-label'); - expect(node.icon).to.equal('NEW-icon'); - expect(node.order).to.equal('a2'); - expect(node.role).to.equal(CompoundMenuNodeRole.Flat); - expect(node.when).to.equal('NEW-condition'); - }); - it('should update only the icon without affecting other properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a' }); - node.updateOptions({ icon: 'NEW-icon' }); - expect(node.label).to.equal('test-label'); - expect(node.icon).to.equal('NEW-icon'); - expect(node.order).to.equal('a'); - }); - it('should not allow to unset properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a' }); - node.updateOptions({ icon: undefined }); - expect(node.label).to.equal('test-label'); - expect(node.icon).to.equal('test-icon'); - expect(node.order).to.equal('a'); - }); - it('should allow to set empty strings in properties', () => { - const node = new CompositeMenuNode('test-id', 'test-label'); - node.updateOptions({ label: '' }); - expect(node.label).to.equal(''); - }); - it('should not cause side effects when updating a property to its existing value', () => { - const node = new CompositeMenuNode('test-id', 'test-label', { icon: 'test-icon', order: 'a' }); - node.updateOptions({ icon: 'test-icon' }); - expect(node.label).to.equal('test-label'); - expect(node.icon).to.equal('test-icon'); - expect(node.order).to.equal('a'); - }); - }); -}); diff --git a/packages/core/src/common/menu/composite-menu-node.ts b/packages/core/src/common/menu/composite-menu-node.ts deleted file mode 100644 index 4c5751e1f7ab2..0000000000000 --- a/packages/core/src/common/menu/composite-menu-node.ts +++ /dev/null @@ -1,116 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2022 Ericsson and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { Disposable } from '../disposable'; -import { CompoundMenuNode, CompoundMenuNodeRole, MenuNode, MutableCompoundMenuNode, SubMenuOptions } from './menu-types'; - -/** - * Node representing a (sub)menu in the menu tree structure. - */ -export class CompositeMenuNode implements MutableCompoundMenuNode { - protected readonly _children: MenuNode[] = []; - public iconClass?: string; - public order?: string; - protected _when?: string; - protected _role?: CompoundMenuNodeRole; - - constructor( - public readonly id: string, - public label?: string, - options?: SubMenuOptions, - readonly parent?: MenuNode & CompoundMenuNode, - ) { - this.updateOptions(options); - } - - get when(): string | undefined { return this._when; } - get icon(): string | undefined { return this.iconClass; } - get children(): ReadonlyArray { return this._children; } - get role(): CompoundMenuNodeRole { return this._role ?? (this.label ? CompoundMenuNodeRole.Submenu : CompoundMenuNodeRole.Group); } - - addNode(node: MenuNode): Disposable { - this._children.push(node); - this._children.sort(CompoundMenuNode.sortChildren); - return { - dispose: () => { - const idx = this._children.indexOf(node); - if (idx >= 0) { - this._children.splice(idx, 1); - } - } - }; - } - - removeNode(id: string): boolean { - const idx = this._children.findIndex(n => n.id === id); - if (idx >= 0) { - this._children.splice(idx, 1); - return true; - } - return false; - } - - updateOptions(options?: SubMenuOptions): void { - if (options) { - this.iconClass = options.icon ?? options.iconClass ?? this.iconClass; - this.label = options.label ?? this.label; - this.order = options.order ?? this.order; - this._role = options.role ?? this._role; - this._when = options.when ?? this._when; - } - } - - get sortString(): string { - return this.order || this.id; - } - - get isSubmenu(): boolean { - return Boolean(this.label); - } - - /** @deprecated @since 1.28 use CompoundMenuNode.isNavigationGroup instead */ - static isNavigationGroup = CompoundMenuNode.isNavigationGroup; -} - -export class CompositeMenuNodeWrapper implements MutableCompoundMenuNode { - constructor(protected readonly wrapped: Readonly, readonly parent: CompoundMenuNode, protected readonly options?: SubMenuOptions) { } - - get id(): string { return this.wrapped.id; } - - get label(): string | undefined { return this.wrapped.label; } - - get sortString(): string { return this.options?.order || this.wrapped.sortString; } - - get isSubmenu(): boolean { return Boolean(this.label); } - - get role(): CompoundMenuNodeRole { return this.options?.role ?? this.wrapped.role; } - - get icon(): string | undefined { return this.iconClass; } - - get iconClass(): string | undefined { return this.options?.iconClass ?? this.wrapped.icon; } - - get order(): string | undefined { return this.sortString; } - - get when(): string | undefined { return this.options?.when ?? this.wrapped.when; } - - get children(): ReadonlyArray { return this.wrapped.children; } - - addNode(node: MenuNode): Disposable { return this.wrapped.addNode(node); } - - removeNode(id: string): boolean { return this.wrapped.removeNode(id); } - - updateOptions(options: SubMenuOptions): void { return this.wrapped.updateOptions(options); } -} diff --git a/packages/core/src/common/menu/index.ts b/packages/core/src/common/menu/index.ts index 5c8f8b438437d..98a39a77cc312 100644 --- a/packages/core/src/common/menu/index.ts +++ b/packages/core/src/common/menu/index.ts @@ -14,8 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -export * from './action-menu-node'; -export * from './composite-menu-node'; -export * from './menu-adapter'; +export * from '../../browser/menu/action-menu-node'; +export * from '../../browser/menu/composite-menu-node'; export * from './menu-model-registry'; export * from './menu-types'; diff --git a/packages/core/src/common/menu/menu-adapter.ts b/packages/core/src/common/menu/menu-adapter.ts deleted file mode 100644 index 82c57b0648871..0000000000000 --- a/packages/core/src/common/menu/menu-adapter.ts +++ /dev/null @@ -1,103 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2022 Ericsson and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { inject, injectable } from 'inversify'; -import { CommandRegistry } from '../command'; -import { Disposable } from '../disposable'; -import { MenuPath } from './menu-types'; - -export type MenuCommandArguments = [menuPath: MenuPath, command: string, ...commandArgs: unknown[]]; - -export const MenuCommandExecutor = Symbol('MenuCommandExecutor'); -export interface MenuCommandExecutor { - isVisible(...args: MenuCommandArguments): boolean; - isEnabled(...args: MenuCommandArguments): boolean; - isToggled(...args: MenuCommandArguments): boolean; - executeCommand(...args: MenuCommandArguments): Promise; -}; - -export const MenuCommandAdapter = Symbol('MenuCommandAdapter'); -export interface MenuCommandAdapter extends MenuCommandExecutor { - /** Return values less than or equal to 0 are treated as rejections. */ - canHandle(...args: MenuCommandArguments): number; -} - -export const MenuCommandAdapterRegistry = Symbol('MenuCommandAdapterRegistry'); -export interface MenuCommandAdapterRegistry { - registerAdapter(adapter: MenuCommandAdapter): Disposable; - getAdapterFor(...args: MenuCommandArguments): MenuCommandAdapter | undefined; -} - -@injectable() -export class MenuCommandExecutorImpl implements MenuCommandExecutor { - @inject(MenuCommandAdapterRegistry) protected readonly adapterRegistry: MenuCommandAdapterRegistry; - @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; - - executeCommand(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): Promise { - return this.delegate(menuPath, command, commandArgs, 'executeCommand'); - } - - isVisible(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { - return this.delegate(menuPath, command, commandArgs, 'isVisible'); - } - - isEnabled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { - return this.delegate(menuPath, command, commandArgs, 'isEnabled'); - } - - isToggled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { - return this.delegate(menuPath, command, commandArgs, 'isToggled'); - } - - protected delegate(menuPath: MenuPath, command: string, commandArgs: unknown[], method: T): ReturnType { - const adapter = this.adapterRegistry.getAdapterFor(menuPath, command, commandArgs); - return (adapter - ? adapter[method](menuPath, command, ...commandArgs) - : this.commandRegistry[method](command, ...commandArgs)) as ReturnType; - } -} - -@injectable() -export class MenuCommandAdapterRegistryImpl implements MenuCommandAdapterRegistry { - protected readonly adapters = new Array(); - - registerAdapter(adapter: MenuCommandAdapter): Disposable { - if (!this.adapters.includes(adapter)) { - this.adapters.push(adapter); - return Disposable.create(() => { - const index = this.adapters.indexOf(adapter); - if (index !== -1) { - this.adapters.splice(index, 1); - } - }); - } - return Disposable.NULL; - } - - getAdapterFor(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): MenuCommandAdapter | undefined { - let bestAdapter: MenuCommandAdapter | undefined = undefined; - let bestScore = 0; - let currentScore = 0; - for (const adapter of this.adapters) { - // Greater than or equal: favor later registrations over earlier. - if ((currentScore = adapter.canHandle(menuPath, command, ...commandArgs)) >= bestScore) { - bestScore = currentScore; - bestAdapter = adapter; - } - } - return bestAdapter; - } -} diff --git a/packages/core/src/common/menu/menu-model-registry.ts b/packages/core/src/common/menu/menu-model-registry.ts index d2cea1522ac73..6080c45d0d361 100644 --- a/packages/core/src/common/menu/menu-model-registry.ts +++ b/packages/core/src/common/menu/menu-model-registry.ts @@ -15,13 +15,12 @@ // ***************************************************************************** import { inject, injectable, named } from 'inversify'; -import { Command, CommandRegistry } from '../command'; +import { CommandMenu, CompoundMenuNode, Group, MAIN_MENU_BAR, MenuAction, MenuNode, MenuPath, MutableCompoundMenuNode, Submenu } from './menu-types'; +import { Event } from 'vscode-languageserver-protocol'; import { ContributionProvider } from '../contribution-provider'; +import { Command, CommandRegistry } from '../command'; +import { Emitter } from '../event'; import { Disposable } from '../disposable'; -import { Emitter, Event } from '../event'; -import { ActionMenuNode } from './action-menu-node'; -import { CompositeMenuNode, CompositeMenuNodeWrapper } from './composite-menu-node'; -import { CompoundMenuNode, MenuAction, MenuNode, MenuNodeMetadata, MenuPath, MutableCompoundMenuNode, SubMenuOptions } from './menu-types'; export const MenuContribution = Symbol('MenuContribution'); @@ -81,6 +80,15 @@ export namespace StructuralMenuChange { return evt.kind !== ChangeKind.CHANGED; } } +export const MenuNodeFactory = Symbol('MenuNodeFactory'); + +export interface MenuNodeFactory { + createGroup(id: string, orderString?: string, when?: string): Group & MutableCompoundMenuNode; + createCommandMenu(item: MenuAction): CommandMenu; + createSubmenu(id: string, label: string, contextKeyOverlays: Record | undefined, + orderString?: string, icon?: string, when?: string): Submenu & MutableCompoundMenuNode + createSubmenuLink(delegate: Submenu, sortString?: string, when?: string): MenuNode; +} /** * The MenuModelRegistry allows to register and unregister menus, submenus and actions @@ -89,23 +97,27 @@ export namespace StructuralMenuChange { */ @injectable() export class MenuModelRegistry { - protected readonly root = new CompositeMenuNode(''); - protected readonly independentSubmenus = new Map(); + protected root: Group & MutableCompoundMenuNode; protected readonly onDidChangeEmitter = new Emitter(); + constructor( + @inject(ContributionProvider) @named(MenuContribution) + protected readonly contributions: ContributionProvider, + @inject(CommandRegistry) + protected readonly commands: CommandRegistry, + @inject(MenuNodeFactory) + protected readonly menuNodeFactory: MenuNodeFactory) { + this.root = this.menuNodeFactory.createGroup('root', 'root'); + this.root.addNode(this.menuNodeFactory.createGroup(MAIN_MENU_BAR[0])); + } + get onDidChange(): Event { return this.onDidChangeEmitter.event; } protected isReady = false; - constructor( - @inject(ContributionProvider) @named(MenuContribution) - protected readonly contributions: ContributionProvider, - @inject(CommandRegistry) protected readonly commands: CommandRegistry - ) { } - onStart(): void { for (const contrib of this.contributions.getContributions()) { contrib.registerMenus(this); @@ -118,47 +130,48 @@ export class MenuModelRegistry { * * @returns a disposable which, when called, will remove the menu action again. */ - registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { - const menuNode = new ActionMenuNode(item, this.commands); - return this.registerMenuNode(menuPath, menuNode); + registerCommandMenu(menuPath: MenuPath, item: CommandMenu): Disposable { + const parent = this.root.getOrCreate(menuPath, 0, menuPath.length); + const existing = parent.children.find(node => node.id === menuPath[menuPath.length - 1]); + if (existing) { + throw new Error(`A menu node with path ${JSON.stringify(menuPath)} already exists`); + } else { + parent.addNode(item); + return Disposable.create(() => { + parent.removeNode(item); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: item.id + }); + }); + } + } /** - * Adds the given menu node to the menu denoted by the given path. + * Adds the given menu action to the menu denoted by the given path. * - * @returns a disposable which, when called, will remove the menu node again. + * @returns a disposable which, when called, will remove the menu action again. */ - registerMenuNode(menuPath: MenuPath | string, menuNode: MenuNode, group?: string): Disposable { - const parent = this.getMenuNode(menuPath, group); - const disposable = parent.addNode(menuNode); - const parentPath = this.getParentPath(menuPath, group); - this.fireChangeEvent({ - kind: ChangeKind.ADDED, - path: parentPath, - affectedChildId: menuNode.id - }); - return this.changeEventOnDispose(parentPath, menuNode.id, disposable); - } - - protected getParentPath(menuPath: MenuPath | string, group?: string): string[] { - if (typeof menuPath === 'string') { - return group ? [menuPath, group] : [menuPath]; + registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { + const parent = this.root.getOrCreate(menuPath, 0, menuPath.length); + const existing = parent.children.find(node => node.id === item.commandId); + if (existing) { + throw new Error(`A menu node with id ${item.commandId} in path ${JSON.stringify(menuPath)} already exists`); } else { - return group ? menuPath.concat(group) : menuPath; + const node = this.menuNodeFactory.createCommandMenu(item); + parent.addNode(node); + return Disposable.create(() => { + parent.removeNode(node); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: node.id + }); + }); } - } - getMenuNode(menuPath: MenuPath | string, group?: string): MutableCompoundMenuNode { - if (typeof menuPath === 'string') { - const target = this.independentSubmenus.get(menuPath); - if (!target) { throw new Error(`Could not find submenu with id ${menuPath}`); } - if (group) { - return this.findSubMenu(target, group); - } - return target; - } else { - return this.findGroup(group ? menuPath.concat(group) : menuPath); - } } /** @@ -176,72 +189,84 @@ export class MenuModelRegistry { * Note that if the menu already existed and was registered with a different label an error * will be thrown. */ - registerSubmenu(menuPath: MenuPath, label: string, options?: SubMenuOptions): Disposable { - if (menuPath.length === 0) { - throw new Error('The sub menu path cannot be empty.'); - } - const index = menuPath.length - 1; - const menuId = menuPath[index]; - const groupPath = index === 0 ? [] : menuPath.slice(0, index); - const parent = this.findGroup(groupPath, options); - let groupNode = this.findSubMenu(parent, menuId, options); - let disposable = Disposable.NULL; - if (!groupNode) { - groupNode = new CompositeMenuNode(menuId, label, options, parent); - disposable = this.changeEventOnDispose(groupPath, menuId, parent.addNode(groupNode)); + registerSubmenu(menuPath: MenuPath, label: string, + options: { sortString?: string, icon?: string, when?: string, contextKeyOverlay?: Record } = {}): Disposable { + const { contextKeyOverlay, sortString, icon, when } = options; + + const parent = this.root.getOrCreate(menuPath, 0, menuPath.length - 1); + const existing = parent.children.find(node => node.id === menuPath[menuPath.length - 1]); + if (Group.is(existing)) { + parent.removeNode(existing); + const newMenu = this.menuNodeFactory.createSubmenu(menuPath[menuPath.length - 1], label, contextKeyOverlay, sortString, icon, when); + newMenu.addNode(...existing.children); + parent.addNode(newMenu); this.fireChangeEvent({ - kind: ChangeKind.ADDED, - path: groupPath, - affectedChildId: menuId + kind: ChangeKind.CHANGED, + path: menuPath + }); + return Disposable.create(() => { + parent.removeNode(newMenu); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: newMenu.id + }); }); } else { + const newMenu = this.menuNodeFactory.createSubmenu(menuPath[menuPath.length - 1], label, contextKeyOverlay, sortString, icon, when); + parent.addNode(newMenu); this.fireChangeEvent({ - kind: ChangeKind.CHANGED, - path: groupPath, + kind: ChangeKind.ADDED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: newMenu.id + }); + return Disposable.create(() => { + parent.removeNode(newMenu); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: menuPath.slice(0, menuPath.length - 1), + affectedChildId: newMenu.id + }); }); - groupNode.updateOptions({ ...options, label }); } - return disposable; } - registerIndependentSubmenu(id: string, label: string, options?: SubMenuOptions): Disposable { - if (this.independentSubmenus.has(id)) { - console.debug(`Independent submenu with path ${id} registered, but given ID already exists.`); + linkCompoundMenuNode(params: { newParentPath: MenuPath, submenuPath: MenuPath, order?: string, when?: string }): Disposable { + const { newParentPath, submenuPath, order, when } = params; + // add a wrapper here + let i = 0; + while (i < newParentPath.length && i < submenuPath.length && newParentPath[i] === submenuPath[i]) { + i++; } - this.independentSubmenus.set(id, new CompositeMenuNode(id, label, options)); - return this.changeEventOnDispose([], id, Disposable.create(() => this.independentSubmenus.delete(id))); - } - linkSubmenu(parentPath: MenuPath | string, childId: string | MenuPath, options?: SubMenuOptions, group?: string): Disposable { - const child = this.getMenuNode(childId); - const parent = this.getMenuNode(parentPath, group); - const affectedPath = this.getParentPath(parentPath, group); - - const isRecursive = (node: MenuNodeMetadata, childNode: MenuNodeMetadata): boolean => { - if (node.id === childNode.id) { - return true; - } - if (node.parent) { - return isRecursive(node.parent, childNode); - } - return false; - }; - - // check for menu contribution recursion - if (isRecursive(parent, child)) { - console.warn(`Recursive menu contribution detected: ${child.id} is already in hierarchy of ${parent.id}.`); - return Disposable.NULL; + if (i === newParentPath.length || i === submenuPath.length) { + throw new Error(`trying to recursively link ${JSON.stringify(submenuPath)} into ${JSON.stringify(newParentPath)}`); } - const wrapper = new CompositeMenuNodeWrapper(child, parent, options); - const disposable = parent.addNode(wrapper); - this.fireChangeEvent({ - kind: ChangeKind.LINKED, - path: affectedPath, - affectedChildId: child.id - - }); - return this.changeEventOnDispose(affectedPath, child.id, disposable); + const child = this.getMenu(submenuPath) as Submenu; + if (!child) { + throw new Error(`Not a menu node: ${JSON.stringify(submenuPath)}`); + } + const newParent = this.root.getOrCreate(newParentPath, 0, newParentPath.length); + if (MutableCompoundMenuNode.is(newParent)) { + const link = this.menuNodeFactory.createSubmenuLink(child, order, when); + newParent.addNode(link); + this.fireChangeEvent({ + kind: ChangeKind.LINKED, + path: newParentPath, + affectedChildId: child.id + }); + return Disposable.create(() => { + newParent.removeNode(link); + this.fireChangeEvent({ + kind: ChangeKind.REMOVED, + path: newParentPath, + affectedChildId: child.id + }); + }); + } else { + throw new Error(`Not a compound menu node: ${JSON.stringify(newParentPath)}`); + } } /** @@ -265,89 +290,48 @@ export class MenuModelRegistry { * @param menuPath if specified only nodes within the path will be unregistered. */ unregisterMenuAction(id: string, menuPath?: MenuPath): void; - unregisterMenuAction(itemOrCommandOrId: MenuAction | Command | string, menuPath?: MenuPath): void { + unregisterMenuAction(itemOrCommandOrId: MenuAction | Command | string, menuPath: MenuPath = []): void { const id = MenuAction.is(itemOrCommandOrId) ? itemOrCommandOrId.commandId : Command.is(itemOrCommandOrId) ? itemOrCommandOrId.id : itemOrCommandOrId; - if (menuPath) { - const parent = this.findGroup(menuPath); - parent.removeNode(id); - this.fireChangeEvent({ - kind: ChangeKind.REMOVED, - path: menuPath, - affectedChildId: id - }); - } else { - this.unregisterMenuNode(id); + const parent = this.findInNode(this.root, menuPath, 0); + if (parent) { + this.removeActionInSubtree(parent, id); } } - /** - * Recurse all menus, removing any menus matching the `id`. - * - * @param id technical identifier of the `MenuNode`. - */ - unregisterMenuNode(id: string): void { - const parentPath: string[] = []; - const recurse = (root: MutableCompoundMenuNode) => { - root.children.forEach(node => { - if (CompoundMenuNode.isMutable(node)) { - if (node.removeNode(id)) { - this.fireChangeEvent({ - kind: ChangeKind.REMOVED, - path: parentPath, - affectedChildId: id - }); - } - parentPath.push(node.id); - recurse(node); - parentPath.pop(); - } - }); - }; - recurse(this.root); - } - - /** - * Finds a submenu as a descendant of the `root` node. - * See {@link MenuModelRegistry.findSubMenu findSubMenu}. - */ - protected findGroup(menuPath: MenuPath, options?: SubMenuOptions): MutableCompoundMenuNode { - let currentMenu: MutableCompoundMenuNode = this.root; - for (const segment of menuPath) { - currentMenu = this.findSubMenu(currentMenu, segment, options); + protected removeActionInSubtree(parent: MenuNode, id: string): void { + if (MutableCompoundMenuNode.is(parent) && CompoundMenuNode.is(parent)) { + const action = parent.children.find(child => child.id === id); + if (action) { + parent.removeNode(action); + } + parent.children.forEach(child => this.removeActionInSubtree(child, id)); } - return currentMenu; } - /** - * Finds or creates a submenu as an immediate child of `current`. - * @throws if a node with the given `menuId` exists but is not a {@link MutableCompoundMenuNode}. - */ - protected findSubMenu(current: MutableCompoundMenuNode, menuId: string, options?: SubMenuOptions): MutableCompoundMenuNode { - const sub = current.children.find(e => e.id === menuId); - if (CompoundMenuNode.isMutable(sub)) { - return sub; + protected findInNode(root: CompoundMenuNode, menuPath: MenuPath, pathIndex: number): MenuNode | undefined { + if (pathIndex === menuPath.length) { + return root; } - if (sub) { - throw new Error(`'${menuId}' is not a menu group.`); + const child = root.children.find(c => c.id === menuPath[pathIndex]); + if (CompoundMenuNode.is(child)) { + return this.findInNode(child, menuPath, pathIndex + 1); } - const newSub = new CompositeMenuNode(menuId, undefined, options, current); - current.addNode(newSub); - return newSub; + return undefined; } - /** - * Returns the menu at the given path. - * - * @param menuPath the path specifying the menu to return. If not given the empty path will be used. - * - * @returns the root menu when `menuPath` is empty. If `menuPath` is not empty the specified menu is - * returned if it exists, otherwise an error is thrown. - */ - getMenu(menuPath: MenuPath = []): MutableCompoundMenuNode { - return this.findGroup(menuPath); + getMenuNode(menuPath: string[]): MenuNode | undefined { + return this.findInNode(this.root, menuPath, 0); + } + + getMenu(menuPath: MenuPath): CompoundMenuNode { + const node = this.getMenuNode(menuPath); + if (!CompoundMenuNode.is(node)) { + throw new Error(`not a compound menu node: ${JSON.stringify(menuPath)}`); + } + return node; } /** @@ -358,82 +342,45 @@ export class MenuModelRegistry { * @returns if the menu will show a single submenu this returns a menu that will show the child elements of the submenu, * otherwise the given `fullMenuModel` is return */ - removeSingleRootNode(fullMenuModel: MutableCompoundMenuNode, menuPath: MenuPath): CompoundMenuNode { - // check whether all children are compound menus and that there is only one child that has further children - if (!this.allChildrenCompound(fullMenuModel.children)) { - return fullMenuModel; - } - let nonEmptyNode = undefined; + static removeSingleRootNode(fullMenuModel: CompoundMenuNode): CompoundMenuNode { + + let singleChild = undefined; + for (const child of fullMenuModel.children) { - if (!this.isEmpty(child.children || [])) { - if (nonEmptyNode === undefined) { - nonEmptyNode = child; - } else { - return fullMenuModel; + if (CompoundMenuNode.is(child)) { + if (!MenuModelRegistry.isEmpty(child)) { + if (singleChild) { + return fullMenuModel; + } else { + singleChild = child; + } } + } else { + return fullMenuModel; } } - - if (CompoundMenuNode.is(nonEmptyNode) && nonEmptyNode.children.length === 1 && CompoundMenuNode.is(nonEmptyNode.children[0])) { - nonEmptyNode = nonEmptyNode.children[0]; - } - - return CompoundMenuNode.is(nonEmptyNode) ? nonEmptyNode : fullMenuModel; + return singleChild || fullMenuModel; } - protected allChildrenCompound(children: ReadonlyArray): boolean { - return children.every(CompoundMenuNode.is); - } - - protected isEmpty(children: ReadonlyArray): boolean { - if (children.length === 0) { - return true; - } - if (!this.allChildrenCompound(children)) { - return false; - } - for (const child of children) { - if (!this.isEmpty(child.children || [])) { - return false; + static isEmpty(node: MenuNode): boolean { + if (CompoundMenuNode.is(node)) { + if (node.children.length === 0) { + return true; } + for (const child of node.children) { + if (!MenuModelRegistry.isEmpty(child)) { + return false; + } + } + } else { + return false; } return true; } - protected changeEventOnDispose(path: MenuPath, id: string, disposable: Disposable): Disposable { - return Disposable.create(() => { - disposable.dispose(); - this.fireChangeEvent({ - path, - affectedChildId: id, - kind: ChangeKind.REMOVED - }); - }); - } - protected fireChangeEvent(evt: T): void { if (this.isReady) { this.onDidChangeEmitter.fire(evt); } } - - /** - * Returns the {@link MenuPath path} at which a given menu node can be accessed from this registry, if it can be determined. - * Returns `undefined` if the `parent` of any node in the chain is unknown. - */ - getPath(node: MenuNode): MenuPath | undefined { - const identifiers = []; - const visited: MenuNode[] = []; - let next: MenuNode | undefined = node; - - while (next && !visited.includes(next)) { - if (next === this.root) { - return identifiers.reverse(); - } - visited.push(next); - identifiers.push(next.id); - next = next.parent; - } - return undefined; - } } diff --git a/packages/core/src/common/menu/menu-types.ts b/packages/core/src/common/menu/menu-types.ts index 9e8b19e5fa195..8f9d566cba25b 100644 --- a/packages/core/src/common/menu/menu-types.ts +++ b/packages/core/src/common/menu/menu-types.ts @@ -14,19 +14,23 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Disposable } from '../disposable'; +import { Event } from '../event'; import { isObject } from '../types'; -export type MenuPath = string[]; export const MAIN_MENU_BAR: MenuPath = ['menubar']; +export type MenuPath = string[]; export const MANAGE_MENU: MenuPath = ['manage_menu']; export const ACCOUNTS_MENU: MenuPath = ['accounts_menu']; export const ACCOUNTS_SUBMENU = [...ACCOUNTS_MENU, '1_accounts_submenu']; +export interface ContextExpressionMatcher { + match(whenExpression: string, context: T | undefined): boolean; +} + /** * @internal For most use cases, refer to {@link MenuAction} or {@link MenuNode} */ -export interface MenuNodeMetadata { +export interface MenuNode { /** * technical identifier. */ @@ -35,126 +39,103 @@ 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 { - +export interface RenderedMenuNode extends MenuNode { /** - * The command to execute. - */ - commandId: string; - /** - * In addition to the mandatory command property, an alternative command can be defined. - * It will be shown and invoked when pressing Alt while opening a menu. + * 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 type CommandMenu = MenuNode & RenderedMenuNode & Action; -export const enum CompoundMenuNodeRole { - /** Indicates that the node should be rendered as submenu that opens a new menu on hover */ - Submenu, - /** Indicates that the node's children should be rendered as group separated from other items by a separator */ - Group, - /** Indicates that the node's children should be treated as though they were direct children of the node's parent */ - Flat, +export namespace CommandMenu { + export function is(node: MenuNode): node is CommandMenu { + return RenderedMenuNode.is(node) && Action.is(node); + } } -export interface CompoundMenuNode extends MenuNodeBase { - /** - * Items that are grouped under this menu. - */ - readonly children: ReadonlyArray - /** - * @deprecated @since 1.28 use `role` instead. - * Whether the item should be rendered as a submenu. - */ - readonly isSubmenu: boolean; - /** - * How the node and its children should be rendered. See {@link CompoundMenuNodeRole}. - */ - readonly role: CompoundMenuNodeRole; +export type Group = CompoundMenuNode; +export namespace Group { + export function is(obj: unknown): obj is Group { + return CompoundMenuNode.is(obj) && !RenderedMenuNode.is(obj); + } } -export interface MutableCompoundMenuNode extends CompoundMenuNode { - /** - * Inserts the given node at the position indicated by `sortString`. - * - * @returns a disposable which, when called, will remove the given node again. - */ - addNode(node: MenuNode): Disposable; - /** - * Removes the first node with the given id. - * - * @param id node id. - * @returns true if the id was present - */ - removeNode(id: string): boolean; +export type Submenu = CompoundMenuNode & RenderedMenuNode; +export interface CompoundMenuNode extends MenuNode { + children: MenuNode[]; + contextKeyOverlays?: Record; /** - * Fills any `undefined` fields with the values from the {@link options}. + * Whether the group or submenu contains any visible children + * + * @param effectiveMenuPath The menu path where visibility is checked + * @param contextMatcher The context matcher to use + * @param context the context to use + * @param args the command arguments, if applicable */ - updateOptions(options: SubMenuOptions): void; -} + isEmpty(effectiveMenuPath: MenuPath, contextMatcher: ContextExpressionMatcher, context: T | undefined, ...args: unknown[]): boolean; +}; export namespace CompoundMenuNode { - export function is(node?: MenuNode): node is CompoundMenuNode { return !!node && Array.isArray(node.children); } - export function getRole(node: MenuNode): CompoundMenuNodeRole | undefined { - if (!is(node)) { return undefined; } - return node.role ?? (node.label ? CompoundMenuNodeRole.Submenu : CompoundMenuNodeRole.Group); - } + export function is(node?: unknown): node is CompoundMenuNode { return isObject(node) && Array.isArray(node.children); } + export function sortChildren(m1: MenuNode, m2: MenuNode): number { // The navigation group is special as it will always be sorted to the top/beginning of a menu. if (isNavigationGroup(m1)) { @@ -166,18 +147,6 @@ export namespace CompoundMenuNode { return m1.sortString.localeCompare(m2.sortString); } - /** Collapses the children of any subemenus with role {@link CompoundMenuNodeRole Flat} and sorts */ - export function getFlatChildren(children: ReadonlyArray): MenuNode[] { - const childrenToMerge: ReadonlyArray[] = []; - return children.filter(child => { - if (getRole(child) === CompoundMenuNodeRole.Flat) { - childrenToMerge.push((child as CompoundMenuNode).children); - return false; - } - return true; - }).concat(...childrenToMerge).sort(sortChildren); - } - /** * Indicates whether the given node is the special `navigation` menu. * @@ -188,34 +157,19 @@ export namespace CompoundMenuNode { export function isNavigationGroup(node: MenuNode): node is CompoundMenuNode { return is(node) && node.id === 'navigation'; } - - export function isMutable(node?: MenuNode): node is MutableCompoundMenuNode { - const candidate = node as MutableCompoundMenuNode; - return is(candidate) && typeof candidate.addNode === 'function' && typeof candidate.removeNode === 'function'; - } } -export interface CommandMenuNode extends MenuNodeBase { - command: string; -} - -export namespace CommandMenuNode { - export function is(candidate?: MenuNode): candidate is CommandMenuNode { return Boolean(candidate?.command); } - export function hasAltHandler(candidate?: MenuNode): candidate is AlternativeHandlerMenuNode { - const asAltNode = candidate as AlternativeHandlerMenuNode; - return is(asAltNode) && is(asAltNode?.altNode); +export interface MutableCompoundMenuNode { + addNode(...node: MenuNode[]): void; + removeNode(node: MenuNode): void; + getOrCreate(menuPath: MenuPath, pathIndex: number, endIndex: number): CompoundMenuNode & MutableCompoundMenuNode; +}; + +export namespace MutableCompoundMenuNode { + export function is(node: unknown): node is MutableCompoundMenuNode { + return isObject(node) + && typeof node.addNode === 'function' + && typeof node.removeNode === 'function' + && typeof node.getOrCreate === 'function'; } } - -export interface AlternativeHandlerMenuNode extends CommandMenuNode { - altNode: CommandMenuNode; -} - -/** - * Base interface of the nodes used in the menu tree structure. - */ -export type MenuNode = MenuNodeMetadata - & MenuNodeRenderingData - & Partial - & Partial - & Partial; diff --git a/packages/core/src/common/test/mock-menu.ts b/packages/core/src/common/test/mock-menu.ts deleted file mode 100644 index 8cdfc4727feb7..0000000000000 --- a/packages/core/src/common/test/mock-menu.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2018 Red Hat, Inc. and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { Disposable } from '../disposable'; -import { CommandRegistry } from '../command'; -import { MenuModelRegistry, MenuPath, MenuAction } from '../menu'; - -export class MockMenuModelRegistry extends MenuModelRegistry { - - constructor() { - const commands = new CommandRegistry({ getContributions: () => [] }); - super({ getContributions: () => [] }, commands); - } - - override registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable { - return Disposable.NULL; - } - - override registerSubmenu(menuPath: MenuPath, label: string): Disposable { - return Disposable.NULL; - } -} diff --git a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts index 8c2a7f1f4fb56..0c194a5a21695 100644 --- a/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts +++ b/packages/core/src/electron-browser/menu/electron-context-menu-renderer.ts @@ -18,12 +18,14 @@ import { inject, injectable, postConstruct } from 'inversify'; import { - ContextMenuRenderer, RenderContextMenuOptions, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor, PreferenceService + ContextMenuRenderer, ContextMenuAccess, FrontendApplicationContribution, CommonCommands, coordinateFromAnchor, PreferenceService, + Anchor } from '../../browser'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ContextMenuContext } from '../../browser/menu/context-menu-context'; -import { MenuPath, MenuContribution, MenuModelRegistry } from '../../common'; import { BrowserContextMenuAccess, BrowserContextMenuRenderer } from '../../browser/menu/browser-context-menu-renderer'; +import { MenuPath, MenuContribution, MenuModelRegistry, CompoundMenuNode } from '../../common/menu'; +import { ContextKeyService, ContextMatcher } from '../../browser/context-key-service'; export class ElectronContextMenuAccess extends ContextMenuAccess { constructor(readonly menuHandle: Promise) { @@ -46,6 +48,9 @@ export class ElectronTextInputContextMenuContribution implements FrontendApplica @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + onStart(): void { window.document.addEventListener('contextmenu', event => { if (event.target instanceof HTMLElement) { @@ -55,6 +60,7 @@ export class ElectronTextInputContextMenuContribution implements FrontendApplica event.stopPropagation(); this.contextMenuRenderer.render({ anchor: event, + contextKeyService: this.contextKeyService, menuPath: ElectronTextInputContextMenu.MENU_PATH, context: event.target, onHide: () => target.focus() @@ -84,10 +90,13 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + @inject(ElectronMainMenuFactory) + protected readonly electronMenuFactory: ElectronMainMenuFactory; + protected useNativeStyle: boolean = true; - constructor(@inject(ElectronMainMenuFactory) private electronMenuFactory: ElectronMainMenuFactory) { - super(electronMenuFactory); + constructor() { + super(); } @postConstruct() @@ -99,24 +108,31 @@ export class ElectronContextMenuRenderer extends BrowserContextMenuRenderer { this.useNativeStyle = await window.electronTheiaCore.getTitleBarStyleAtStartup() === 'native'; } - protected override doRender(options: RenderContextMenuOptions): ContextMenuAccess { + protected override doRender(params: { + 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 { x, y } = coordinateFromAnchor(anchor); + const contextMenu = this.electronMenuFactory.createElectronContextMenu(params.menuPath, params.menu, params.contextMatcher, params.args, params.context); + const { x, y } = coordinateFromAnchor(params.anchor); - const windowName = options.context?.ownerDocument.defaultView?.Window.name; + const windowName = params.context?.ownerDocument.defaultView?.Window.name; - const menuHandle = window.electronTheiaCore.popup(menu, x, y, () => { - if (onHide) { - onHide(); + const menuHandle = window.electronTheiaCore.popup(contextMenu, x, y, () => { + if (params.onHide) { + params.onHide(); } }, windowName); // native context menu stops the event loop, so there is no keyboard events this.context.resetAltPressed(); return new ElectronContextMenuAccess(menuHandle); } else { - const menuAccess = super.doRender(options); + const menuAccess = super.doRender(params); const node = (menuAccess as BrowserContextMenuAccess).menu.node; const topPanelHeight = document.getElementById('theia-top-panel')?.clientHeight ?? 0; // ensure the context menu is not displayed outside of the main area diff --git a/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts b/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts index 3925b56df82f3..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 @@ -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,38 @@ 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 +198,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 +249,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 +276,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..15334c81547a1 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({ newParentPath: EDITOR_LINENUMBER_CONTEXT_MENU, submenuPath: 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..bdb9f6beb337f 100644 --- a/packages/debug/src/browser/view/debug-action.tsx +++ b/packages/debug/src/browser/view/debug-action.tsx @@ -16,6 +16,7 @@ import * as React from '@theia/core/shared/react'; import { codiconArray, DISABLED_CLASS } from '@theia/core/lib/browser'; +import { MenuPath } from '@theia/core'; export class DebugAction extends React.Component { @@ -31,7 +32,7 @@ export class DebugAction extends React.Component { return { this.props.run([]); }} ref={this.setRef} > {!iconClass &&
{label}
}
; @@ -51,7 +52,7 @@ export namespace DebugAction { export interface Props { label: string iconClass: string - run: () => void + run: (effectiveMenuPath: MenuPath) => void enabled?: boolean } } diff --git a/packages/debug/src/browser/view/debug-toolbar-widget.tsx b/packages/debug/src/browser/view/debug-toolbar-widget.tsx index 519966217d284..21de8b343fac7 100644 --- a/packages/debug/src/browser/view/debug-toolbar-widget.tsx +++ b/packages/debug/src/browser/view/debug-toolbar-widget.tsx @@ -16,7 +16,7 @@ import * as React from '@theia/core/shared/react'; import { inject, postConstruct, injectable } from '@theia/core/shared/inversify'; -import { CommandMenuNode, CommandRegistry, CompoundMenuNode, Disposable, DisposableCollection, MenuModelRegistry, MenuPath } from '@theia/core'; +import { CommandMenu, CommandRegistry, CompoundMenuNode, Disposable, DisposableCollection, MenuModelRegistry, MenuPath } from '@theia/core'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { ReactWidget } from '@theia/core/lib/browser/widgets'; import { DebugViewModel } from './debug-view-model'; @@ -85,11 +85,11 @@ export class DebugToolBar extends ReactWidget { protected renderContributedCommands(): React.ReactNode { const debugActions: React.ReactNode[] = []; // first, search for CompoundMenuNodes: - this.menuModelRegistry.getMenu(DebugToolBar.MENU).children.forEach(compoundMenuNode => { - if (CompoundMenuNode.is(compoundMenuNode) && this.matchContext(compoundMenuNode.when)) { + this.menuModelRegistry.getMenu(DebugToolBar.MENU)!.children.forEach(compoundMenuNode => { + if (CompoundMenuNode.is(compoundMenuNode) && compoundMenuNode.isVisible(DebugToolBar.MENU, this.contextKeyService, this.node)) { // second, search for nested CommandMenuNodes: compoundMenuNode.children.forEach(commandMenuNode => { - if (CommandMenuNode.is(commandMenuNode) && this.matchContext(commandMenuNode.when)) { + if (CommandMenu.is(commandMenuNode) && commandMenuNode.isVisible(DebugToolBar.MENU, this.contextKeyService, this.node)) { debugActions.push(this.debugAction(commandMenuNode)); } }); @@ -98,24 +98,13 @@ export class DebugToolBar extends ReactWidget { return debugActions; } - protected matchContext(when?: string): boolean { - return !when || this.contextKeyService.match(when); - } - - protected debugAction(commandMenuNode: CommandMenuNode): React.ReactNode { - const { command, icon = '', label = '' } = commandMenuNode; - if (!label && !icon) { - const { when } = commandMenuNode; - console.warn(`Neither 'label' nor 'icon' properties were defined for the command menu node. (${JSON.stringify({ command, when })}}. Skipping.`); - return; - } - const run = () => this.commandRegistry.executeCommand(command); + protected debugAction(commandMenuNode: CommandMenu): React.ReactNode { return ; + label={commandMenuNode.label} + iconClass={commandMenuNode.icon || ''} + run={commandMenuNode.run} />; } protected renderStart(): React.ReactNode { diff --git a/packages/editor/src/browser/editor-navigation-contribution.ts b/packages/editor/src/browser/editor-navigation-contribution.ts index 6ade54978c234..e7b159b2ede93 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 6330e52d04f61..7c70ee886d6ee 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'; @@ -297,9 +297,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'), @@ -312,7 +311,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'), @@ -327,7 +325,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 { @@ -375,8 +373,8 @@ export class NotebookActionsContribution implements CommandContribution, MenuCon } export namespace NotebookMenus { - export const NOTEBOOK_MAIN_TOOLBAR = 'notebook/toolbar'; - export const NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP = [NOTEBOOK_MAIN_TOOLBAR, 'cell-add-group']; - export const NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP = [NOTEBOOK_MAIN_TOOLBAR, 'cell-execution-group']; - export const NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU = 'notebook-main-toolbar-hidden-items-context-menu'; + export const NOTEBOOK_MAIN_TOOLBAR = ['notebook', 'toolbar']; + export const NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP = [...NOTEBOOK_MAIN_TOOLBAR, 'cell-add-group']; + export const NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP = [...NOTEBOOK_MAIN_TOOLBAR, 'cell-execution-group']; + export const NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU = ['notebook-main-toolbar-hidden-items-context-menu']; } diff --git a/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts b/packages/notebook/src/browser/contributions/notebook-cell-actions-contribution.ts index 6ede229a50338..c38d8ea9deacd 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'; @@ -234,15 +234,17 @@ export class NotebookCellActionContribution implements MenuContribution, Command NotebookCellActionContribution.ADDITIONAL_ACTION_MENU, nls.localizeByDefault('More'), { - icon: codicon('ellipsis'), - role: CompoundMenuNodeRole.Submenu, - order: '30' + sortString: '30', + icon: 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({ + newParentPath: NotebookCellActionContribution.ADDITIONAL_ACTION_MENU, + submenuPath: NotebookCellActionContribution.CONTRIBUTED_CELL_ACTION_MENU + }); // code cell sidebar menu menus.registerMenuAction(NotebookCellActionContribution.CODE_CELL_SIDEBAR_MENU, { @@ -259,19 +261,17 @@ 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') }); + { icon: codicon('chevron-down') }); // menus.getMenu(NotebookCellActionContribution.CODE_CELL_SIDEBAR_MENU).addNode(menus.getMenuNode(NotebookCellActionContribution.CONTRIBUTED_CELL_EXECUTION_MENU)); // code cell output sidebar menu menus.registerSubmenu( NotebookCellActionContribution.ADDITIONAL_OUTPUT_SIDEBAR_MENU, nls.localizeByDefault('More'), - { - icon: codicon('ellipsis'), - role: CompoundMenuNodeRole.Submenu - }); + { icon: codicon('ellipsis') } + ); menus.registerMenuAction(NotebookCellActionContribution.ADDITIONAL_OUTPUT_SIDEBAR_MENU, { commandId: NotebookCellCommands.CLEAR_OUTPUTS_COMMAND.id, label: nls.localizeByDefault('Clear Cell Outputs'), @@ -565,8 +565,8 @@ export class NotebookCellActionContribution implements MenuContribution, Command export namespace NotebookCellActionContribution { export const ACTION_MENU = ['notebook-cell-actions-menu']; export const ADDITIONAL_ACTION_MENU = [...ACTION_MENU, 'more']; - export const CONTRIBUTED_CELL_ACTION_MENU = 'notebook/cell/title'; - export const CONTRIBUTED_CELL_EXECUTION_MENU = 'notebook/cell/execute'; + export const CONTRIBUTED_CELL_ACTION_MENU = ['notebook/cell/title']; + export const CONTRIBUTED_CELL_EXECUTION_MENU = ['notebook/cell/execute']; export const CODE_CELL_SIDEBAR_MENU = ['code-cell-sidebar-menu']; export const OUTPUT_SIDEBAR_MENU = ['code-cell-output-sidebar-menu']; export const ADDITIONAL_OUTPUT_SIDEBAR_MENU = [...OUTPUT_SIDEBAR_MENU, 'more']; diff --git a/packages/notebook/src/browser/service/notebook-context-manager.ts b/packages/notebook/src/browser/service/notebook-context-manager.ts index 934c57e07d8f9..c51595a830b2b 100644 --- a/packages/notebook/src/browser/service/notebook-context-manager.ts +++ b/packages/notebook/src/browser/service/notebook-context-manager.ts @@ -16,7 +16,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { ContextKeyChangeEvent, ContextKeyService, ContextMatcher, ScopedValueStore } from '@theia/core/lib/browser/context-key-service'; -import { DisposableCollection, Emitter } from '@theia/core'; +import { DisposableCollection } from '@theia/core'; import { NotebookKernelService } from './notebook-kernel-service'; import { NOTEBOOK_CELL_EDITABLE, @@ -43,9 +43,6 @@ export class NotebookContextManager { protected readonly toDispose = new DisposableCollection(); - protected readonly onDidChangeContextEmitter = new Emitter(); - readonly onDidChangeContext = this.onDidChangeContextEmitter.event; - protected _context?: HTMLElement; scopedStore: ScopedValueStore; @@ -72,14 +69,12 @@ export class NotebookContextManager { if (e.notebook.toString() === widget?.getResourceUri()?.toString()) { this.scopedStore.setContext(NOTEBOOK_KERNEL_SELECTED, !!e.newKernel); this.scopedStore.setContext(NOTEBOOK_KERNEL, e.newKernel); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_KERNEL_SELECTED, NOTEBOOK_KERNEL])); } })); widget.model?.onDidChangeContent(events => { if (events.some(e => e.kind === NotebookCellsChangeType.ModelChange || e.kind === NotebookCellsChangeType.Output)) { this.scopedStore.setContext(NOTEBOOK_HAS_OUTPUTS, widget.model?.cells.some(cell => cell.outputs.length > 0)); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_HAS_OUTPUTS])); } }); @@ -91,23 +86,18 @@ export class NotebookContextManager { widget.model?.onDidChangeSelectedCell(e => { this.selectedCellChanged(e.cell); this.scopedStore.setContext(NOTEBOOK_CELL_FOCUSED, !!e); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_FOCUSED])); }); this.toDispose.push(this.executionStateService.onDidChangeExecution(e => { if (e.notebook.toString() === widget.model?.uri.toString()) { this.setCellContext(e.cellHandle, NOTEBOOK_CELL_EXECUTING, !!e.changed); this.setCellContext(e.cellHandle, NOTEBOOK_CELL_EXECUTION_STATE, e.changed?.state ?? 'idle'); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_EXECUTING, NOTEBOOK_CELL_EXECUTION_STATE])); } })); widget.onDidChangeOutputInputFocus(focus => { this.scopedStore.setContext(NOTEBOOK_OUTPUT_INPUT_FOCUSED, focus); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_OUTPUT_INPUT_FOCUSED])); }); - - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_VIEW_TYPE, NOTEBOOK_KERNEL_SELECTED, NOTEBOOK_KERNEL])); } protected cellDisposables = new DisposableCollection(); @@ -123,12 +113,8 @@ export class NotebookContextManager { this.cellDisposables.push(cell.onDidRequestCellEditChange(cellEdit => { this.scopedStore.setContext(NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, cellEdit); this.scopedStore.setContext(NOTEBOOK_CELL_EDITABLE, cell.cellKind === CellKind.Markup && !cellEdit); - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_MARKDOWN_EDIT_MODE])); })); } - - this.onDidChangeContextEmitter.fire(this.createContextKeyChangedEvent([NOTEBOOK_CELL_TYPE])); - } protected setCellContext(cellHandle: number, key: string, value: unknown): void { diff --git a/packages/notebook/src/browser/view/notebook-cell-list-view.tsx b/packages/notebook/src/browser/view/notebook-cell-list-view.tsx index 39eeada382d0d..c43e5ed64baae 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: unknown[]) => 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: unknown[]) => { + if (CommandMenu.is(item)) { + item.run([...menuPath, item.id], ...args); + } + }; + return ; + }; return
  • setHover(true)} onMouseLeave={() => setHover(false)} onDrop={onDrop} onDragOver={onDragOver}> {hover && isVisible() &&
    - {menuItems.map((item: MenuNode) => renderItem(item))} + {menuItems.map((item: CommandMenu) => renderItem(item))}
    }
  • ; } diff --git a/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx b/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx index c7366949feb68..313b1ce243e17 100644 --- a/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx +++ b/packages/notebook/src/browser/view/notebook-cell-toolbar-factory.tsx @@ -15,7 +15,7 @@ // ***************************************************************************** import * as React from '@theia/core/shared/react'; -import { CommandRegistry, CompoundMenuNodeRole, MenuModelRegistry, MenuNode } from '@theia/core'; +import { CommandMenu, CommandRegistry, CompoundMenuNode, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuPath, RenderedMenuNode } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { NotebookCellSidebar, NotebookCellToolbar } from './notebook-cell-toolbar'; @@ -29,7 +29,6 @@ export interface NotebookCellToolbarItem { label?: string; onClick: (e: React.MouseEvent) => void; isVisible: () => boolean; - contextKeys?: Set } export interface toolbarItemOptions { @@ -55,48 +54,61 @@ export class NotebookCellToolbarFactory { @inject(NotebookContextManager) protected readonly notebookContextManager: NotebookContextManager; + protected readonly onDidChangeContextEmitter = new Emitter; + readonly onDidChangeContext: Event = this.onDidChangeContextEmitter.event; + + protected toDisposeOnRender = new DisposableCollection(); + renderCellToolbar(menuPath: string[], cell: NotebookCellModel, itemOptions: toolbarItemOptions): React.ReactNode { return this.getMenuItems(menuPath, cell, itemOptions)} - onContextKeysChanged={this.notebookContextManager.onDidChangeContext} />; + onContextChanged={this.onDidChangeContext} />; } renderSidebar(menuPath: string[], cell: NotebookCellModel, itemOptions: toolbarItemOptions): React.ReactNode { return this.getMenuItems(menuPath, cell, itemOptions)} - onContextKeysChanged={this.notebookContextManager.onDidChangeContext} />; + onContextChanged={this.onDidChangeContext} />; } private getMenuItems(menuItemPath: string[], cell: NotebookCellModel, itemOptions: toolbarItemOptions): NotebookCellToolbarItem[] { + this.toDisposeOnRender.dispose(); + this.toDisposeOnRender = new DisposableCollection(); const inlineItems: NotebookCellToolbarItem[] = []; for (const menuNode of this.menuRegistry.getMenu(menuItemPath).children) { - if (!menuNode.when || this.notebookContextManager.getCellContext(cell.handle).match(menuNode.when, this.notebookContextManager.context)) { - if (menuNode.role === CompoundMenuNodeRole.Flat) { - inlineItems.push(...menuNode.children?.map(child => this.createToolbarItem(child, itemOptions)) ?? []); - } else { - inlineItems.push(this.createToolbarItem(menuNode, itemOptions)); + + const itemPath = [...menuItemPath, menuNode.id]; + if (menuNode.isVisible(itemPath, this.notebookContextManager.getCellContext(cell.handle), this.notebookContextManager.context, itemOptions.commandArgs?.() ?? [])) { + if (RenderedMenuNode.is(menuNode)) { + if (menuNode.onDidChange) { + this.toDisposeOnRender.push(menuNode.onDidChange(() => this.onDidChangeContextEmitter.fire())); + } + inlineItems.push(this.createToolbarItem(itemPath, menuNode, itemOptions)); } } } return inlineItems; } - private createToolbarItem(menuNode: MenuNode, itemOptions: toolbarItemOptions): NotebookCellToolbarItem { - const menuPath = menuNode.role === CompoundMenuNodeRole.Submenu ? this.menuRegistry.getPath(menuNode) : undefined; + private createToolbarItem(menuPath: MenuPath, menuNode: RenderedMenuNode, itemOptions: toolbarItemOptions): NotebookCellToolbarItem { return { id: menuNode.id, icon: menuNode.icon, label: menuNode.label, - onClick: menuPath ? - e => this.contextMenuRenderer.render( - { - anchor: e.nativeEvent, - menuPath, - includeAnchorArg: false, - args: itemOptions.contextMenuArgs?.(), - context: this.notebookContextManager.context || (e.currentTarget as HTMLElement) - }) : - () => this.commandRegistry.executeCommand(menuNode.command!, ...(itemOptions.commandArgs?.() ?? [])), - isVisible: () => menuPath ? true : Boolean(this.commandRegistry.getVisibleHandler(menuNode.command!, ...(itemOptions.commandArgs?.() ?? []))), - contextKeys: menuNode.when ? this.contextKeyService.parseKeys(menuNode.when) : undefined + onClick: e => { + if (CompoundMenuNode.is(menuNode)) { + this.contextMenuRenderer.render( + { + anchor: e.nativeEvent, + menuPath: menuPath, + menu: menuNode, + includeAnchorArg: false, + args: itemOptions.contextMenuArgs?.(), + context: this.notebookContextManager.context || (e.currentTarget as HTMLElement) + }); + } else if (CommandMenu.is(menuNode)) { + menuNode.run(menuPath, ...(itemOptions.commandArgs?.() ?? [])); + }; + }, + isVisible: () => true }; } } diff --git a/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx b/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx index 426878d51005b..f81c5bca0c77e 100644 --- a/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx +++ b/packages/notebook/src/browser/view/notebook-cell-toolbar.tsx @@ -17,11 +17,10 @@ import * as React from '@theia/core/shared/react'; import { ACTION_ITEM } from '@theia/core/lib/browser'; import { NotebookCellToolbarItem } from './notebook-cell-toolbar-factory'; import { DisposableCollection, Event } from '@theia/core'; -import { ContextKeyChangeEvent } from '@theia/core/lib/browser/context-key-service'; export interface NotebookCellToolbarProps { getMenuItems: () => NotebookCellToolbarItem[]; - onContextKeysChanged: Event; + onContextChanged: Event; } interface NotebookCellToolbarState { @@ -34,11 +33,9 @@ abstract class NotebookCellActionBar extends React.Component { + this.toDispose.push(props.onContextChanged(e => { const menuItems = this.props.getMenuItems(); - if (menuItems.some(item => item.contextKeys ? e.affects(item.contextKeys) : false)) { - this.setState({ inlineItems: menuItems }); - } + this.setState({ inlineItems: menuItems }); })); this.state = { inlineItems: this.props.getMenuItems() }; } diff --git a/packages/notebook/src/browser/view/notebook-main-toolbar.tsx b/packages/notebook/src/browser/view/notebook-main-toolbar.tsx index 59b04784d8b4e..508eed6fc1a8c 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(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] }); @@ -153,7 +147,7 @@ export class NotebookMainToolbar extends React.Component - {menuItems.slice(0, menuItems.length - this.calculateNumberOfHiddenItems(menuItems)).map(item => this.renderMenuItem(item))} + {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 99791e089c006..970b03055b982 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 @@ -182,8 +182,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) @@ -450,7 +448,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); } }); @@ -473,7 +471,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)); } } }; @@ -498,7 +496,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) ); } } @@ -518,7 +516,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { left = false; return false; } - return left && this.codeEditorWidgetUtil.is(owner); + return left && CodeEditorWidgetUtil.is(owner); } ); } @@ -538,7 +536,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { left = false; return false; } - return !left && this.codeEditorWidgetUtil.is(owner); + return !left && CodeEditorWidgetUtil.is(owner); } ); } @@ -547,7 +545,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution { }); commands.registerCommand({ id: 'workbench.action.closeAllEditors' }, { execute: async () => { - const toClose = this.shell.widgets.filter(widget => this.codeEditorWidgetUtil.is(widget)); + const toClose = this.shell.widgets.filter(widget => CodeEditorWidgetUtil.is(widget)); await this.shell.closeMany(toClose); } }); diff --git a/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx b/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx index bb539c75df31d..b49b87a3b50ad 100644 --- a/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx +++ b/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx @@ -27,16 +27,18 @@ import * as React from '@theia/core/shared/react'; import { MouseTargetType } from '@theia/editor/lib/browser'; import { CommentsService } from './comments-service'; import { - ActionMenuNode, + CommandMenu, CommandRegistry, CompoundMenuNode, + DisposableCollection, MenuModelRegistry, MenuPath } from '@theia/core/lib/common'; -import { CommentsContextKeyService } from './comments-context-key-service'; +import { CommentsContext } from './comments-context'; import { RefObject } from '@theia/core/shared/react'; import * as monaco from '@theia/monaco-editor-core'; import { createRoot, Root } from '@theia/core/shared/react-dom/client'; +import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. @@ -64,7 +66,8 @@ export class CommentThreadWidget extends BaseWidget { private _commentThread: CommentThread, private commentService: CommentsService, protected readonly menus: MenuModelRegistry, - protected readonly contextKeyService: CommentsContextKeyService, + protected readonly commentsContext: CommentsContext, + protected readonly contextKeyService: ContextKeyService, protected readonly commands: CommandRegistry ) { super(); @@ -84,14 +87,9 @@ export class CommentThreadWidget extends BaseWidget { return; } })); - this.contextKeyService.commentIsEmpty.set(true); + this.commentsContext.commentIsEmpty.set(true); this.toDispose.push(this.zoneWidget.editor.onMouseDown(e => this.onEditorMouseDown(e))); - this.toDispose.push(this.contextKeyService.onDidChange(() => { - const commentForm = this.commentFormRef.current; - if (commentForm) { - commentForm.update(); - } - })); + this.toDispose.push(this._commentThread.onDidChangeCanReply(_canReply => { const commentForm = this.commentFormRef.current; if (commentForm) { @@ -102,9 +100,14 @@ export class CommentThreadWidget extends BaseWidget { this.update(); })); this.contextMenu = this.menus.getMenu(COMMENT_THREAD_CONTEXT); - this.contextMenu.children.map(node => node instanceof ActionMenuNode && node.when).forEach(exp => { - if (typeof exp === 'string') { - this.contextKeyService.setExpression(exp); + this.contextMenu.children.forEach(node => { + if (node.onDidChange) { + this.toDispose.push(node.onDidChange(() => { + const commentForm = this.commentFormRef.current; + if (commentForm) { + commentForm.update(); + } + })); } }); } @@ -288,6 +291,7 @@ export class CommentThreadWidget extends BaseWidget { {this._commentThread.comments?.map((comment, index) => )}
    extend private inputRef: RefObject = React.createRef(); private inputValue: string = ''; private readonly getInput = () => this.inputValue; + private toDisposeOnUnmount = new DisposableCollection(); private readonly clearInput: () => void = () => { const input = this.inputRef.current; if (input) { this.inputValue = ''; input.value = this.inputValue; - this.props.contextKeyService.commentIsEmpty.set(true); + this.props.commentsContext.commentIsEmpty.set(true); } }; @@ -364,11 +371,15 @@ export class CommentForm

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

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

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

    extend

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

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

    {comment.label}
    - {hover && menus.getMenu(COMMENT_TITLE).children.map((node, index) => node instanceof ActionMenuNode && - )} + {hover && menus.getMenu(COMMENT_TITLE).children.map((node, index): React.ReactNode => CommandMenu.is(node) && + )}
    { namespace CommentEditContainer { export interface Props { - contextKeyService: CommentsContextKeyService + contextKeyService: ContextKeyService; + commentsContext: CommentsContext; menus: MenuModelRegistry, comment: Comment; commentThread: CommentThread; @@ -572,7 +583,7 @@ export class CommentEditContainer extends React.Component
    - {menus.getMenu(COMMENT_CONTEXT).children.map((node, index) => { + {menus.getMenu(COMMENT_CONTEXT).children.map((node, index): React.ReactNode => { const onClick = () => { commands.executeCommand(node.id, { commentControlHandle: commentThread.controllerHandle, @@ -595,8 +606,8 @@ export class CommentEditContainer extends React.Component; + return CommandMenu.is(node) && + ; } )}
    @@ -606,18 +617,20 @@ export class CommentEditContainer extends React.Component { override render(): React.ReactNode { - const { node, commands, contextKeyService, commentThread, commentUniqueId } = this.props; - if (node.when && !contextKeyService.match(node.when)) { + const { node, nodePath, commands, contextKeyService, commentThread, commentUniqueId } = this.props; + if (node.isVisible(nodePath, contextKeyService, undefined)) { return false; } return
    @@ -636,8 +649,9 @@ export class CommentsInlineAction extends React.Component string; @@ -647,30 +661,32 @@ namespace CommentActions { export class CommentActions extends React.Component { override render(): React.ReactNode { - const { contextKeyService, commands, menu, commentThread, getInput, clearInput } = this.props; + const { contextKeyService, commentsContext, menuPath, menu, commentThread, getInput, clearInput } = this.props; return
    - {menu.children.map((node, index) => node instanceof ActionMenuNode && + {menu.children.map((node, index) => CommandMenu.is(node) && { - commands.executeCommand(node.id, { - commentControlHandle: commentThread.controllerHandle, - commentThreadHandle: commentThread.commentThreadHandle, + node.run( + [...menuPath, menu.id], { + thread: commentThread, text: getInput() }); clearInput(); }} contextKeyService={contextKeyService} + commentsContext={commentsContext} />)}
    ; } } namespace CommentAction { export interface Props { - contextKeyService: CommentsContextKeyService; - commands: CommandRegistry; - node: ActionMenuNode; + contextKeyService: ContextKeyService; + commentsContext: CommentsContext; + nodePath: MenuPath, + node: CommandMenu; onClick: () => void; } } @@ -678,11 +694,11 @@ namespace CommentAction { export class CommentAction extends React.Component { override render(): React.ReactNode { const classNames = ['comments-button', 'comments-text-button', 'theia-button']; - const { node, commands, contextKeyService, onClick } = this.props; - if (node.when && !contextKeyService.match(node.when)) { + const { node, nodePath, contextKeyService, onClick } = this.props; + if (!node.isVisible(nodePath, contextKeyService, undefined)) { return false; } - const isEnabled = commands.isEnabled(node.command); + const isEnabled = node.isEnabled(nodePath); if (!isEnabled) { classNames.push(DISABLED_CLASS); } diff --git a/packages/plugin-ext/src/main/browser/comments/comments-context-key-service.ts b/packages/plugin-ext/src/main/browser/comments/comments-context.ts similarity index 74% rename from packages/plugin-ext/src/main/browser/comments/comments-context-key-service.ts rename to packages/plugin-ext/src/main/browser/comments/comments-context.ts index c8094ae3d6bc8..e329f7301966b 100644 --- a/packages/plugin-ext/src/main/browser/comments/comments-context-key-service.ts +++ b/packages/plugin-ext/src/main/browser/comments/comments-context.ts @@ -16,16 +16,13 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service'; -import { Emitter } from '@theia/core/lib/common'; @injectable() -export class CommentsContextKeyService { +export class CommentsContext { @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; protected readonly contextKeys: Set = new Set(); - protected readonly onDidChangeEmitter = new Emitter(); - readonly onDidChange = this.onDidChangeEmitter.event; protected _commentIsEmpty: ContextKey; protected _commentController: ContextKey; protected _comment: ContextKey; @@ -48,21 +45,5 @@ export class CommentsContextKeyService { this._commentController = this.contextKeyService.createKey('commentController', undefined); this._comment = this.contextKeyService.createKey('comment', undefined); this._commentIsEmpty = this.contextKeyService.createKey('commentIsEmpty', true); - this.contextKeyService.onDidChange(event => { - if (event.affects(this.contextKeys)) { - this.onDidChangeEmitter.fire(); - } - }); } - - setExpression(expression: string): void { - this.contextKeyService.parseKeys(expression)?.forEach(key => { - this.contextKeys.add(key); - }); - } - - match(expression: string | undefined): boolean { - return !expression || this.contextKeyService.match(expression); - } - } diff --git a/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts b/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts index 872b054a618c9..c0475be716073 100644 --- a/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts +++ b/packages/plugin-ext/src/main/browser/comments/comments-contribution.ts @@ -24,9 +24,9 @@ import { CommentsService, CommentInfoMain } from './comments-service'; import { CommentThread } from '../../../common/plugin-api-rpc-model'; import { CommandRegistry, DisposableCollection, MenuModelRegistry } from '@theia/core/lib/common'; import { URI } from '@theia/core/shared/vscode-uri'; -import { CommentsContextKeyService } from './comments-context-key-service'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { Uri } from '@theia/plugin'; +import { CommentsContext } from './comments-context'; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. @@ -43,7 +43,7 @@ export class CommentsContribution { private emptyThreadsToAddQueue: [number, EditorMouseEvent | undefined][] = []; @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; - @inject(CommentsContextKeyService) protected readonly commentsContextKeyService: CommentsContextKeyService; + @inject(CommentsContext) protected readonly commentsContext: CommentsContext; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(CommandRegistry) protected readonly commands: CommandRegistry; @@ -193,10 +193,10 @@ export class CommentsContribution { if (editor) { const provider = this.commentService.getCommentController(owner); if (provider) { - this.commentsContextKeyService.commentController.set(provider.id); + this.commentsContext.commentController.set(provider.id); } - const zoneWidget = new CommentThreadWidget(editor, owner, thread, this.commentService, this.menus, this.commentsContextKeyService, this.commands); - zoneWidget.display({ afterLineNumber: thread.range?.startLineNumber ?? 0, heightInLines: 5 }); // messages with no range are put on top of the editor + const zoneWidget = new CommentThreadWidget(editor, owner, thread, this.commentService, this.menus, this.commentsContext, this.contextKeyService, this.commands); + zoneWidget.display({ afterLineNumber: thread.range?.startLineNumber || 0, heightInLines: 5 }); const currentEditor = this.getCurrentEditor(); if (currentEditor) { currentEditor.onDispose(() => zoneWidget.dispose()); diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index 5ee29c9cb39ad..34777b14ecfc7 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,35 @@ 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 +80,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, { icon: iconClass }); } for (const [contributionPoint, items] of Object.entries(allMenus)) { @@ -95,8 +89,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 +101,55 @@ 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({ + newParentPath: group ? [...target, group] : target, + submenuPath: [submenu!], + order: order, + when: item.when + }))); } } } catch (error) { @@ -146,19 +178,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..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 @@ -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[]): unknown[] { + return args; } - @injectable() -export class PluginMenuCommandAdapter implements MenuCommandAdapter { - @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; - @inject(CodeEditorWidgetUtil) protected readonly codeEditorUtil: CodeEditorWidgetUtil; - @inject(ScmService) protected readonly scmService: ScmService; - @inject(SelectionService) protected readonly selectionService: SelectionService; - @inject(ResourceContextKey) protected readonly resourceContextKey: ResourceContextKey; +export class PluginMenuCommandAdapter { + @inject(ScmService) private readonly scmService: ScmService; + @inject(SelectionService) private readonly selectionService: SelectionService; + @inject(ResourceContextKey) private readonly resourceContextKey: ResourceContextKey; - protected readonly commands = new ReferenceCountingSet(); protected readonly argumentAdapters = new Map(); - protected readonly separator = ':)(:'; @postConstruct() protected init(): void { @@ -89,8 +52,8 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { const noArgs: ArgumentAdapter = () => []; const toScmArgs: ArgumentAdapter = (...args) => this.toScmArgs(...args); const selectedResource = () => this.getSelectedResources(); - const widgetURI: ArgumentAdapter = widget => this.codeEditorUtil.is(widget) ? [this.codeEditorUtil.getResourceUri(widget)] : []; - (>[ + const widgetURI: ArgumentAdapter = widget => CodeEditorWidgetUtil.is(widget) ? [CodeEditorWidgetUtil.getResourceUri(widget)] : []; + (>[ ['comments/comment/context', toCommentArgs], ['comments/comment/title', toCommentArgs], ['comments/commentThread/context', toCommentArgs], @@ -117,82 +80,12 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { ['terminal/context', noArgs], ['terminal/title/context', noArgs], ]).forEach(([contributionPoint, adapter]) => { - if (adapter) { - const paths = codeToTheiaMappings.get(contributionPoint); - if (paths) { - paths.forEach(path => this.addArgumentAdapter(path, adapter)); - } - } + this.argumentAdapters.set(contributionPoint, adapter); }); - this.addArgumentAdapter(TAB_BAR_TOOLBAR_CONTEXT_MENU, widgetURI); - } - - canHandle(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): number { - if (this.commands.has(command) && this.getArgumentAdapterForMenu(menuPath)) { - return 500; - } - return -1; - } - - executeCommand(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): Promise { - const argumentAdapter = this.getAdapterOrThrow(menuPath); - return this.commandRegistry.executeCommand(command, ...argumentAdapter(...commandArgs)); - } - - isVisible(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { - const argumentAdapter = this.getAdapterOrThrow(menuPath); - return this.commandRegistry.isVisible(command, ...argumentAdapter(...commandArgs)); - } - - isEnabled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { - const argumentAdapter = this.getAdapterOrThrow(menuPath); - return this.commandRegistry.isEnabled(command, ...argumentAdapter(...commandArgs)); - } - - isToggled(menuPath: MenuPath, command: string, ...commandArgs: unknown[]): boolean { - const argumentAdapter = this.getAdapterOrThrow(menuPath); - return this.commandRegistry.isToggled(command, ...argumentAdapter(...commandArgs)); - } - - protected getAdapterOrThrow(menuPath: MenuPath): ArgumentAdapter { - const argumentAdapter = this.getArgumentAdapterForMenu(menuPath); - if (!argumentAdapter) { - throw new Error('PluginMenuCommandAdapter attempted to execute command for unregistered menu: ' + JSON.stringify(menuPath)); - } - return argumentAdapter; - } - - addCommand(commandId: string): Disposable { - this.commands.add(commandId); - return Disposable.create(() => this.commands.delete(commandId)); - } - - protected getArgumentAdapterForMenu(menuPath: MenuPath): ArgumentAdapter | undefined { - let result; - let length = 0; - for (const [key, value] of this.argumentAdapters.entries()) { - const candidate = key.split(this.separator); - if (this.isPrefixOf(candidate, menuPath) && candidate.length > length) { - result = value; - length = candidate.length; - } - } - return result; - } - isPrefixOf(candidate: string[], menuPath: MenuPath): boolean { - if (candidate.length > menuPath.length) { - return false; - } - for (let i = 0; i < candidate.length; i++) { - if (candidate[i] !== menuPath[i]) { - return false; - } - } - return true; } - protected addArgumentAdapter(menuPath: MenuPath, adapter: ArgumentAdapter): void { - this.argumentAdapters.set(menuPath.join(this.separator), adapter); + getArgumentAdapter(contributionPoint: string): ArgumentAdapter { + return this.argumentAdapters.get(contributionPoint) || identity; } /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts index 3a519199040f2..57ec701348099 100644 --- a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts +++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts @@ -17,7 +17,6 @@ import { MenuPath } from '@theia/core'; import { SHELL_TABBAR_CONTEXT_MENU } from '@theia/core/lib/browser'; import { Navigatable } from '@theia/core/lib/browser/navigatable'; -import { injectable } from '@theia/core/shared/inversify'; import { URI as CodeUri } from '@theia/core/shared/vscode-uri'; import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stack-frames-widget'; import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; @@ -74,7 +73,7 @@ export const implementedVSCodeContributionPoints = [ export type ContributionPoint = (typeof implementedVSCodeContributionPoints)[number]; /** The values are menu paths to which the VSCode contribution points correspond */ -export const codeToTheiaMappings = new Map([ +export const codeToTheiaMappings = new Map([ ['comments/comment/context', [COMMENT_CONTEXT]], ['comments/comment/title', [COMMENT_TITLE]], ['comments/commentThread/context', [COMMENT_THREAD_CONTEXT]], @@ -106,12 +105,11 @@ export const codeToTheiaMappings = new Map([ ]); type CodeEditorWidget = EditorWidget | WebviewWidget; -@injectable() -export class CodeEditorWidgetUtil { - is(arg: unknown): arg is CodeEditorWidget { +export namespace CodeEditorWidgetUtil { + export function is(arg: unknown): arg is CodeEditorWidget { return arg instanceof EditorWidget || arg instanceof WebviewWidget; } - getResourceUri(editor: CodeEditorWidget): CodeUri | undefined { + export function getResourceUri(editor: CodeEditorWidget): CodeUri | undefined { const resourceUri = Navigatable.is(editor) && editor.getResourceUri(); return resourceUri ? resourceUri['codeUri'] : undefined; } diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index ef2530a437d21..a40f40b4a5d58 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -68,7 +68,7 @@ import { WebviewWidgetFactory } from './webview/webview-widget-factory'; import { CommentsService, PluginCommentService } from './comments/comments-service'; import { CommentingRangeDecorator } from './comments/comments-decorator'; import { CommentsContribution } from './comments/comments-contribution'; -import { CommentsContextKeyService } from './comments/comments-context-key-service'; +import { CommentsContext } from './comments/comments-context'; import { PluginCustomEditorRegistry } from './custom-editors/plugin-custom-editor-registry'; import { CustomEditorWidgetFactory } from '../browser/custom-editors/custom-editor-widget-factory'; import { CustomEditorWidget } from './custom-editors/custom-editor-widget'; @@ -77,7 +77,6 @@ import { WebviewFrontendSecurityWarnings } from './webview/webview-frontend-secu import { PluginAuthenticationServiceImpl } from './plugin-authentication-service'; import { AuthenticationService } from '@theia/core/lib/browser/authentication-service'; import { bindTreeViewDecoratorUtilities, TreeViewDecoratorService } from './view/tree-view-decorator-service'; -import { CodeEditorWidgetUtil } from './menus/vscode-theia-menu-mappings'; import { PluginMenuCommandAdapter } from './menus/plugin-menu-command-adapter'; import './theme-icon-override'; import { PluginIconService } from './plugin-icon-service'; @@ -250,7 +249,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(MenusContributionPointHandler).toSelf().inSingletonScope(); bind(PluginMenuCommandAdapter).toSelf().inSingletonScope(); - bind(CodeEditorWidgetUtil).toSelf().inSingletonScope(); bind(KeybindingsContributionPointHandler).toSelf().inSingletonScope(); bind(PluginContributionHandler).toSelf().inSingletonScope(); @@ -266,7 +264,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(CommentsService).to(PluginCommentService).inSingletonScope(); bind(CommentingRangeDecorator).toSelf().inSingletonScope(); bind(CommentsContribution).toSelf().inSingletonScope(); - bind(CommentsContextKeyService).toSelf().inSingletonScope(); + bind(CommentsContext).toSelf().inSingletonScope(); bind(WebviewFrontendSecurityWarnings).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(WebviewFrontendSecurityWarnings); diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index f92b1f90a5bce..19b3737527147 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -35,7 +35,7 @@ import { ApplicationShell, KeybindingRegistry } from '@theia/core/lib/browser'; -import { MenuPath, MenuModelRegistry, ActionMenuNode } from '@theia/core/lib/common/menu'; +import { MenuPath, MenuModelRegistry, CommandMenu, AcceleratorSource } from '@theia/core/lib/common/menu'; import * as React from '@theia/core/shared/react'; import { PluginSharedStyle } from '../plugin-shared-style'; import { ACTION_ITEM, Widget } from '@theia/core/lib/browser/widgets/widget'; @@ -763,7 +763,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { return this.contextKeys.with({ view: this.id, viewItem: treeViewNode.contextValue }, () => { const menu = this.menus.getMenu(VIEW_ITEM_INLINE_MENU); const args = this.toContextMenuArgs(treeViewNode); - const inlineCommands = menu.children.filter((item): item is ActionMenuNode => item instanceof ActionMenuNode); + const inlineCommands = menu.children.filter((item): item is CommandMenu => CommandMenu.is(item)); const tailDecorations = super.renderTailDecorations(treeViewNode, props); return {inlineCommands.length > 0 &&
    @@ -796,17 +796,18 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected renderInlineCommand(actionMenuNode: ActionMenuNode, index: number, tabbable: boolean, args: any[]): React.ReactNode { - if (!actionMenuNode.icon || !this.commands.isVisible(actionMenuNode.command, ...args) || !actionMenuNode.when || !this.contextKeys.match(actionMenuNode.when)) { + protected renderInlineCommand(actionMenuNode: CommandMenu, index: number, tabbable: boolean, args: any[]): React.ReactNode { + const nodePath = [...VIEW_ITEM_INLINE_MENU, actionMenuNode.id]; + if (!actionMenuNode.icon || !actionMenuNode.isVisible(nodePath, this.contextKeys, undefined)) { return false; } const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, actionMenuNode.icon, ACTION_ITEM, 'theia-tree-view-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; - const titleString = actionMenuNode.label + this.resolveKeybindingForCommand(actionMenuNode.command); + const titleString = actionMenuNode.label + (AcceleratorSource.is(actionMenuNode) ? actionMenuNode.getAccelerator(undefined).join('+') : ''); return
    { e.stopPropagation(); - this.commands.executeCommand(actionMenuNode.command, ...args); + actionMenuNode.run(nodePath, ...args); }} />; } diff --git a/packages/preferences/src/browser/util/preference-types.ts b/packages/preferences/src/browser/util/preference-types.ts index 58adcb95bbd31..6e14d18973c28 100644 --- a/packages/preferences/src/browser/util/preference-types.ts +++ b/packages/preferences/src/browser/util/preference-types.ts @@ -22,7 +22,7 @@ import { SelectableTreeNode, PreferenceInspection, CommonCommands, -} from '@theia/core/lib/browser'; + } from '@theia/core/lib/browser'; import { Command, MenuPath } from '@theia/core'; import { JSONValue } from '@theia/core/shared/@lumino/coreutils'; import { JsonType } from '@theia/core/lib/common/json-schema'; diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index af4195c02bbc3..3de5cb02353a8 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() @@ -337,18 +336,19 @@ 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))) { + if (CommandMenu.is(item)) { + const { id, label, icon } = item; + const itemPath = [...menuPath, id]; + if (icon && item.isVisible(itemPath, contextKeyService, undefined)) { // Close editor on successful contributed action. // https://github.com/microsoft/vscode/blob/11b1500e0a2e8b5ba12e98a3905f9d120b8646a0/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts#L356-L361 - this.addAction(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => { - menuCommandExecutor.executeCommand(menuPath, command, this.widget).then(() => this.dispose()); + this.addAction(id, label, icon, item.isEnabled(itemPath), () => { + item.run(itemPath, this.widget).then(() => this.dispose()); }); } } diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts index 20fc130ce3773..33debe597f85f 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'; @@ -261,7 +261,7 @@ export class ScmContribution extends AbstractViewContribution impleme const viewModeEmitter = new Emitter(); const registerToggleViewItem = (command: Command, mode: 'tree' | 'list') => { const id = command.id; - const item: TabBarToolbarItem = { + const item: TabBarToolbarAction = { id, command: id, tooltip: command.label, diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx index 732c395b8b371..65390c0d810d7 100644 --- a/packages/scm/src/browser/scm-tree-widget.tsx +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -23,7 +23,7 @@ import { isOSX } from '@theia/core/lib/common/os'; import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; import { TreeWidget, TreeNode, SelectableTreeNode, TreeModel, TreeProps, NodeProps, TREE_NODE_SEGMENT_CLASS, TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree'; import { ScmTreeModel, ScmFileChangeRootNode, ScmFileChangeGroupNode, ScmFileChangeFolderNode, ScmFileChangeNode } from './scm-tree-model'; -import { MenuCommandExecutor, MenuModelRegistry, ActionMenuNode, CompoundMenuNode, MenuPath } from '@theia/core/lib/common/menu'; +import { MenuModelRegistry, CompoundMenuNode, MenuPath, CommandMenu } from '@theia/core/lib/common/menu'; import { ScmResource } from './scm-provider'; import { ContextMenuRenderer, LabelProvider, CorePreferences, DiffUris, ACTION_ITEM } from '@theia/core/lib/browser'; import { ScmContextKeyService } from './scm-context-key-service'; @@ -48,7 +48,6 @@ export class ScmTreeWidget extends TreeWidget { static RESOURCE_CONTEXT_MENU = ['RESOURCE_CONTEXT_MENU']; static RESOURCE_INLINE_MENU = ['RESOURCE_CONTEXT_MENU', 'inline']; - @inject(MenuCommandExecutor) protected readonly menuCommandExecutor: MenuCommandExecutor; @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; @inject(ScmContextKeyService) protected readonly contextKeys: ScmContextKeyService; @inject(EditorManager) protected readonly editorManager: EditorManager; @@ -109,7 +108,6 @@ export class ScmTreeWidget extends TreeWidget { model={this.model} treeNode={node} renderExpansionToggle={() => this.renderExpansionToggle(node, props)} - commandExecutor={this.menuCommandExecutor} contextMenuRenderer={this.contextMenuRenderer} menus={this.menus} contextKeys={this.contextKeys} @@ -128,7 +126,6 @@ export class ScmTreeWidget extends TreeWidget { treeNode={node} sourceUri={node.sourceUri} renderExpansionToggle={() => this.renderExpansionToggle(node, props)} - commandExecutor={this.menuCommandExecutor} contextMenuRenderer={this.contextMenuRenderer} menus={this.menus} contextKeys={this.contextKeys} @@ -149,7 +146,6 @@ export class ScmTreeWidget extends TreeWidget { model={this.model} treeNode={node} contextMenuRenderer={this.contextMenuRenderer} - commandExecutor={this.menuCommandExecutor} menus={this.menus} contextKeys={this.contextKeys} labelProvider={this.labelProvider} @@ -536,7 +532,6 @@ export abstract class ScmElement

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

    {hover && menu.children - .map((node, index) => node instanceof ActionMenuNode && - )} + .map((node, index) => CommandMenu.is(node) && + )}
    {children}
    ; @@ -793,7 +785,6 @@ export namespace ScmInlineActions { hover: boolean; menu: CompoundMenuNode; menuPath: MenuPath; - commandExecutor: MenuCommandExecutor; model: ScmTreeModel; treeNode: TreeNode; contextKeys: ScmContextKeyService; @@ -804,14 +795,14 @@ export namespace ScmInlineActions { export class ScmInlineAction extends React.Component { override render(): React.ReactNode { - const { node, model, treeNode, args, commandExecutor, menuPath, contextKeys } = this.props; + const { node, menuPath, model, treeNode, args, contextKeys } = this.props; let isActive: boolean = false; model.execInNodeContext(treeNode, () => { - isActive = contextKeys.match(node.when); + isActive = node.isVisible(menuPath, contextKeys, undefined, ...args); }); - if (!commandExecutor.isVisible(menuPath, node.command, ...args) || !isActive) { + if (!isActive) { return false; } return
    @@ -822,14 +813,13 @@ export class ScmInlineAction extends React.Component { protected execute = (event: React.MouseEvent) => { event.stopPropagation(); - const { commandExecutor, menuPath, node, args } = this.props; - commandExecutor.executeCommand([menuPath[0]], node.command, ...args); + const { node, menuPath, args } = this.props; + node.run(menuPath, ...args); }; } export namespace ScmInlineAction { export interface Props { - node: ActionMenuNode; - commandExecutor: MenuCommandExecutor; + node: CommandMenu; menuPath: MenuPath; model: ScmTreeModel; treeNode: TreeNode; diff --git a/packages/terminal/src/browser/terminal-frontend-contribution.ts b/packages/terminal/src/browser/terminal-frontend-contribution.ts index 0921fc1faa7a2..4e4ae1b5c5740 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, '', { when: 'isTerminalTab' }); } registerToolbarItems(toolbar: TabBarToolbarRegistry): void { diff --git a/packages/test/src/browser/view/test-tree-widget.tsx b/packages/test/src/browser/view/test-tree-widget.tsx index 3c5e0c7852bb7..206382ff424e3 100644 --- a/packages/test/src/browser/view/test-tree-widget.tsx +++ b/packages/test/src/browser/view/test-tree-widget.tsx @@ -26,7 +26,7 @@ import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { TestController, TestExecutionState, TestItem, TestService } from '../test-service'; import * as React from '@theia/core/shared/react'; import { DeltaKind, TreeDelta } from '../../common/tree-delta'; -import { ActionMenuNode, CommandRegistry, Disposable, DisposableCollection, Event, MenuModelRegistry, nls } from '@theia/core'; +import { AcceleratorSource, CommandMenu, CommandRegistry, Disposable, DisposableCollection, Event, MenuModelRegistry, nls } from '@theia/core'; import { TestExecutionStateManager } from './test-execution-state-manager'; import { TestOutputUIModel } from './test-output-ui-model'; import { TEST_VIEW_INLINE_MENU } from './test-view-contribution'; @@ -301,7 +301,7 @@ export class TestTreeWidget extends TreeWidget { return this.contextKeys.with({ view: this.id, controllerId: node.controller.id, testId: testItem.id, testItemHasUri: !!testItem.uri }, () => { const menu = this.menus.getMenu(TEST_VIEW_INLINE_MENU); const args = [node.testItem]; - const inlineCommands = menu.children.filter((item): item is ActionMenuNode => item instanceof ActionMenuNode); + const inlineCommands = menu.children.filter((item): item is CommandMenu => CommandMenu.is(item)); const tailDecorations = super.renderTailDecorations(node, props); return {inlineCommands.length > 0 &&
    @@ -316,17 +316,17 @@ export class TestTreeWidget extends TreeWidget { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected renderInlineCommand(actionMenuNode: ActionMenuNode, index: number, tabbable: boolean, args: any[]): React.ReactNode { - if (!actionMenuNode.icon || !this.commands.isVisible(actionMenuNode.command, ...args) || (actionMenuNode.when && !this.contextKeys.match(actionMenuNode.when))) { + protected renderInlineCommand(actionMenuNode: CommandMenu, index: number, tabbable: boolean, args: any[]): React.ReactNode { + if (!actionMenuNode.icon || !actionMenuNode.isVisible(TEST_VIEW_INLINE_MENU, this.contextKeys, this.node, ...args)) { return false; } const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, actionMenuNode.icon, ACTION_ITEM, 'theia-test-tree-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; - const titleString = actionMenuNode.label + this.resolveKeybindingForCommand(actionMenuNode.command); + const titleString = actionMenuNode.label + (AcceleratorSource.is(actionMenuNode) ? actionMenuNode.getAccelerator(undefined).join(' ') : ''); return
    { e.stopPropagation(); - this.commands.executeCommand(actionMenuNode.command, ...args); + actionMenuNode.run(TEST_VIEW_INLINE_MENU, ...args); }} />; } diff --git a/packages/test/src/browser/view/test-view-contribution.ts b/packages/test/src/browser/view/test-view-contribution.ts index b28ba19ab162b..26f1a5d261485 100644 --- a/packages/test/src/browser/view/test-view-contribution.ts +++ b/packages/test/src/browser/view/test-view-contribution.ts @@ -274,6 +274,18 @@ export class TestViewContribution extends AbstractViewContribution DeflatedToolbarTree; + @inject(CommandRegistry) commandRegistry: CommandRegistry; + @inject(ContextKeyService) contextKeyService: ContextKeyService; + @inject(KeybindingRegistry) keybindingRegistry: KeybindingRegistry; + @inject(LabelParser) labelParser: LabelParser; + @inject(ContributionProvider) @named(ToolbarContribution) protected widgetContributions: ContributionProvider; @@ -68,15 +75,15 @@ export class ToolbarController { for (const column of Object.keys(schema.items)) { const currentColumn = schema.items[column as ToolbarAlignment]; for (const group of currentColumn) { - const newGroup: ToolbarItem[] = []; + const newGroup: TabBarToolbarItem[] = []; for (const item of group) { if (item.group === 'contributed') { const contribution = this.getContributionByID(item.id); if (contribution) { - newGroup.push(contribution); + newGroup.push(new ReactToolbarItemImpl(this.commandRegistry, this.contextKeyService, contribution)); } } else { - newGroup.push({ ...item }); + newGroup.push(new RenderedToolbarItemImpl(this.commandRegistry, this.contextKeyService, this.keybindingRegistry, this.labelParser, item)); } } if (newGroup.length) { diff --git a/packages/toolbar/src/browser/toolbar-interfaces.ts b/packages/toolbar/src/browser/toolbar-interfaces.ts index 1d1f0776c64ed..787fbf18bbe3c 100644 --- a/packages/toolbar/src/browser/toolbar-interfaces.ts +++ b/packages/toolbar/src/browser/toolbar-interfaces.ts @@ -15,7 +15,8 @@ // ***************************************************************************** import { interfaces } from '@theia/core/shared/inversify'; -import { ReactTabBarToolbarItem, RenderedToolbarItem, TabBarToolbar, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { ReactTabBarToolbarAction, RenderedToolbarAction, TabBarToolbar } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar/tab-toolbar-item'; export enum ToolbarAlignment { LEFT = 'left', @@ -25,7 +26,7 @@ export enum ToolbarAlignment { export interface ToolbarTreeSchema { items: { - [key in ToolbarAlignment]: ToolbarItem[][]; + [key in ToolbarAlignment]: TabBarToolbarItem[][]; }; } @@ -44,7 +45,7 @@ export interface ToolbarContributionProperties { toJSON(): DeflatedContributedToolbarItem; } -export type ToolbarContribution = ReactTabBarToolbarItem & ToolbarContributionProperties; +export type ToolbarContribution = ReactTabBarToolbarAction & ToolbarContributionProperties; export const ToolbarContribution = Symbol('ToolbarContribution'); @@ -52,9 +53,9 @@ export const Toolbar = Symbol('Toolbar'); export const ToolbarFactory = Symbol('ToolbarFactory'); export type Toolbar = TabBarToolbar; -export type ToolbarItem = ToolbarContribution | RenderedToolbarItem; +export type ToolbarItem = ToolbarContribution | RenderedToolbarAction; export interface DeflatedContributedToolbarItem { id: string; group: 'contributed' }; -export type ToolbarItemDeflated = DeflatedContributedToolbarItem | TabBarToolbarItem; +export type ToolbarItemDeflated = DeflatedContributedToolbarItem | RenderedToolbarAction; export const LateInjector = Symbol('LateInjector'); diff --git a/packages/toolbar/src/browser/toolbar.tsx b/packages/toolbar/src/browser/toolbar.tsx index 6188e20f107cd..932214932814a 100644 --- a/packages/toolbar/src/browser/toolbar.tsx +++ b/packages/toolbar/src/browser/toolbar.tsx @@ -16,21 +16,20 @@ import * as React from '@theia/core/shared/react'; import { Anchor, ContextMenuAccess, KeybindingRegistry, PreferenceService, Widget, WidgetManager } from '@theia/core/lib/browser'; -import { LabelIcon } from '@theia/core/lib/browser/label-parser'; -import { ReactTabBarToolbarItem, RenderedToolbarItem, TabBarToolbar, TabBarToolbarFactory } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbar, TabBarToolbarFactory } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; -import { MenuPath, ProgressService } from '@theia/core'; +import { DisposableCollection, MenuPath, ProgressService } from '@theia/core'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { - ToolbarItem, ToolbarAlignment, ToolbarAlignmentString, ToolbarItemPosition, } from './toolbar-interfaces'; import { ToolbarController } from './toolbar-controller'; import { ToolbarMenus } from './toolbar-constants'; +import { TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar/tab-toolbar-item'; const TOOLBAR_BACKGROUND_DATA_ID = 'toolbar-wrapper'; export const TOOLBAR_PROGRESSBAR_ID = 'main-toolbar-progress'; @@ -80,22 +79,21 @@ export class ToolbarImpl extends TabBarToolbar { } protected updateInlineItems(): void { + this.toDisposeOnUpdateItems.dispose(); + this.toDisposeOnUpdateItems = new DisposableCollection(); this.inline.clear(); const { items } = this.model.toolbarItems; - const contextKeys = new Set(); for (const column of Object.keys(items)) { for (const group of items[column as ToolbarAlignment]) { for (const item of group) { this.inline.set(item.id, item); - - if (item.when) { - this.contextKeyService.parseKeys(item.when)?.forEach(key => contextKeys.add(key)); + if (item.onDidChange) { + this.toDisposeOnUpdateItems.push(item.onDidChange(() => this.maybeUpdate())); } } } } - this.updateContextKeyListener(contextKeys); } protected handleContextMenu = (e: React.MouseEvent): ContextMenuAccess => this.doHandleContextMenu(e); @@ -142,7 +140,7 @@ export class ToolbarImpl extends TabBarToolbar { return args; } - protected renderGroupsInColumn(groups: ToolbarItem[][], alignment: ToolbarAlignment): React.ReactNode[] { + protected renderGroupsInColumn(groups: TabBarToolbarItem[][], alignment: ToolbarAlignment): React.ReactNode[] { const nodes: React.ReactNode[] = []; groups.forEach((group, groupIndex) => { if (nodes.length && group.length) { @@ -181,7 +179,7 @@ export class ToolbarImpl extends TabBarToolbar { ); } - protected renderColumnWrapper(alignment: ToolbarAlignment, columnGroup: ToolbarItem[][]): React.ReactNode { + protected renderColumnWrapper(alignment: ToolbarAlignment, columnGroup: TabBarToolbarItem[][]): React.ReactNode { let children: React.ReactNode; if (alignment === ToolbarAlignment.LEFT) { children = ( @@ -235,23 +233,11 @@ export class ToolbarImpl extends TabBarToolbar { ); } - protected renderItemWithDraggableWrapper(item: ToolbarItem, position: ToolbarItemPosition): React.ReactNode { + protected renderItemWithDraggableWrapper(item: TabBarToolbarItem, position: ToolbarItemPosition): React.ReactNode { const stringifiedPosition = JSON.stringify(position); - let toolbarItemClassNames = ''; - let renderBody: React.ReactNode; + const toolbarItemClassNames = ''; + const renderBody = item.render(this); - if (!ReactTabBarToolbarItem.is(item)) { - toolbarItemClassNames = TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM; - if (this.evaluateWhenClause(item.when)) { - toolbarItemClassNames += ' enabled'; - } - renderBody = this.renderItem(item); - } else { - const contribution = this.model.getContributionByID(item.id); - if (contribution) { - renderBody = contribution.render(); - } - } return (
    this.executeCommand(e, item)} onDragOver={this.handleOnDragEnter} onDragLeave={this.handleOnDragLeave} onContextMenu={this.handleContextMenu} @@ -279,41 +261,6 @@ export class ToolbarImpl extends TabBarToolbar { ); } - protected override renderItem( - item: RenderedToolbarItem, - ): React.ReactNode { - const classNames = []; - if (item.text) { - for (const labelPart of this.labelParser.parse(item.text)) { - if (typeof labelPart !== 'string' && LabelIcon.is(labelPart)) { - const className = `fa fa-${labelPart.name}${labelPart.animation ? ' fa-' + labelPart.animation : ''}`; - classNames.push(...className.split(' ')); - } - } - } - const command = this.commands.getCommand(item.command!); - const iconClass = (typeof item.icon === 'function' && item.icon()) || item.icon || command?.iconClass; - if (iconClass) { - classNames.push(iconClass); - } - let itemTooltip = ''; - if (item.tooltip) { - itemTooltip = item.tooltip; - } else if (command?.label) { - itemTooltip = command.label; - } - const keybindingString = this.resolveKeybindingForCommand(command?.id); - itemTooltip = `${itemTooltip}${keybindingString}`; - - return ( -
    - ); - } - protected handleOnDragStart = (e: React.DragEvent): void => this.doHandleOnDragStart(e); protected doHandleOnDragStart(e: React.DragEvent): void { const draggedElement = e.currentTarget; diff --git a/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts b/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts index 08782d516a2d5..9daf957f8b7a6 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'; @@ -182,10 +182,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 {