From 4484f8d979d917b8606f083c8a68890d19e63d94 Mon Sep 17 00:00:00 2001 From: "Christian W. Damus" Date: Mon, 28 Aug 2023 11:11:14 -0400 Subject: [PATCH] vscode: independent editor/title/run menu (#12799) In VS Code, contributions to the "editor/title/run" menu contribution point are not added to the ellipsis menu but to a dedicated item on the toolbar. This commit makes Theia do the same, except that unlike VS Code, a pop-up menu is always presented, even if there is only one action available. Fixes #12687 Signed-off-by: Christian W. Damus --- .../tab-bar-toolbar-registry.ts | 102 ++++++++++++++++-- .../tab-bar-toolbar/tab-bar-toolbar-types.ts | 29 ++++- .../shell/tab-bar-toolbar/tab-bar-toolbar.tsx | 59 +++++++++- packages/core/src/browser/style/tabs.css | 21 ++++ .../menus/menus-contribution-handler.ts | 8 +- 5 files changed, 206 insertions(+), 13 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts index 8ea697a7a3ee5..92b3c5f219955 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,11 +17,11 @@ import debounce = require('lodash.debounce'); import { inject, injectable, named } from 'inversify'; // eslint-disable-next-line max-len -import { CommandMenuNode, CommandRegistry, CompoundMenuNode, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuPath } from '../../../common'; +import { CommandMenuNode, CommandRegistry, CompoundMenuNode, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuNode, MenuPath } from '../../../common'; import { ContextKeyService } from '../../context-key-service'; import { FrontendApplicationContribution } from '../../frontend-application'; import { Widget } from '../../widgets'; -import { MenuDelegate, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; +import { AnyToolbarItem, ConditionalToolbarItem, MenuDelegate, MenuToolbarItem, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types'; import { ToolbarMenuNodeWrapper } from './tab-bar-toolbar-menu-adapters'; /** @@ -103,10 +103,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } const result: Array = []; for (const item of this.items.values()) { - const visible = TabBarToolbarItem.is(item) - ? this.commandRegistry.isVisible(item.command, widget) - : (!item.isVisible || item.isVisible(widget)); - if (visible && (!item.when || this.contextKeyService.match(item.when, widget.node))) { + if (this.isItemVisible(item, widget)) { result.push(item); } } @@ -139,6 +136,83 @@ 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 (TabBarToolbarItem.is(item) && item.command && !this.isTabBarToolbarItemVisible(item, widget)) { + return false; + } + if (MenuToolbarItem.is(item) && !this.isMenuToolbarItemVisible(item, widget)) { + return false; + } + if (AnyToolbarItem.isConditional(item) && !this.isConditionalItemVisible(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: ConditionalToolbarItem, 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 tab-bar toolbar `item` that has a command should be shown in the toolbar. + * This implementation returns `false` if the `item`'s command is not visible in the + * `widget` according to the command registry. + * + * @param item a tab-bar toolbar item that has a non-empty `command` + * @param widget the widget that is updating the toolbar + * @returns `false` if the `item` should be suppressed, otherwise `true` + */ + protected isTabBarToolbarItemVisible(item: TabBarToolbarItem, widget: Widget): boolean { + return this.commandRegistry.isVisible(item.command, widget); + } + + /** + * 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` + */ + protected isMenuToolbarItemVisible(item: MenuToolbarItem, widget: Widget): boolean { + 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; if (this.items.delete(id)) { @@ -147,7 +221,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } registerMenuDelegate(menuPath: MenuPath, when?: string | ((widget: Widget) => boolean)): Disposable { - const id = menuPath.join(menuDelegateSeparator); + const id = this.toElementId(menuPath); if (!this.menuDelegates.has(id)) { const isVisible: MenuDelegate['isVisible'] = !when ? yes @@ -163,8 +237,20 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution { } unregisterMenuDelegate(menuPath: MenuPath): void { - if (this.menuDelegates.delete(menuPath.join(menuDelegateSeparator))) { + if (this.menuDelegates.delete(this.toElementId(menuPath))) { this.fireOnDidChange(); } } + + /** + * Generate a single ID string from a menu path that + * is likely to be unique amongst the items in the toolbar. + * + * @param menuPath a menubar path + * @returns a likely unique ID based on the path + */ + toElementId(menuPath: MenuPath): string { + return menuPath.join(menuDelegateSeparator); + } + } 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 0950dd0169520..00ad879b4d761 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 @@ -83,7 +83,7 @@ export interface MenuToolbarItem { menuPath: MenuPath; } -interface ConditionalToolbarItem { +export interface ConditionalToolbarItem { /** * https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts */ @@ -130,6 +130,7 @@ export interface TabBarToolbarItem extends RegisteredToolbarItem, RenderedToolbarItem, Omit, Pick, + Partial, Partial { } /** @@ -174,7 +175,33 @@ export namespace TabBarToolbarItem { } export namespace MenuToolbarItem { + /** + * Type guard for a toolbar item that actually is a menu item, amongst + * the other kinds of item that it may also be. + * + * @param item a toolbar item + * @returns whether the `item` is a menu item + */ + export function is(item: T): item is T & MenuToolbarItem { + return Array.isArray(item.menuPath); + } + export function getMenuPath(item: AnyToolbarItem): MenuPath | undefined { return Array.isArray(item.menuPath) ? item.menuPath : undefined; } } + +export namespace AnyToolbarItem { + /** + * Type guard for a toolbar item that actually manifests any of the + * features of a conditional toolbar item. + * + * @param item a toolbar item + * @returns whether the `item` is a conditional item + */ + export function isConditional(item: T): item is T & ConditionalToolbarItem { + return 'isVisible' in item && typeof item.isVisible === 'function' + || 'onDidChange' in item && typeof item.onDidChange === 'function' + || 'when' in item && typeof item.when === 'string'; + } +} 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 491a26a4f2529..53fbbda8698ca 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx @@ -22,7 +22,7 @@ import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../../context-me import { LabelIcon, LabelParser } from '../../label-parser'; import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../../widgets'; import { TabBarToolbarRegistry } from './tab-bar-toolbar-registry'; -import { AnyToolbarItem, ReactTabBarToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types'; +import { AnyToolbarItem, ReactTabBarToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU, MenuToolbarItem } from './tab-bar-toolbar-types'; import { KeybindingRegistry } from '../..//keybinding'; /** @@ -149,7 +149,9 @@ export class TabBarToolbar extends ReactWidget { this.keybindingContextKeys.clear(); return {this.renderMore()} - {[...this.inline.values()].map(item => TabBarToolbarItem.is(item) ? this.renderItem(item) : item.render(this.current))} + {[...this.inline.values()].map(item => TabBarToolbarItem.is(item) + ? (MenuToolbarItem.is(item) ? this.renderMenuItem(item) : this.renderItem(item)) + : item.render(this.current))} ; } @@ -290,6 +292,59 @@ export class TabBarToolbar extends ReactWidget { }); } + /** + * 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: TabBarToolbarItem & MenuToolbarItem): React.ReactNode { + const icon = typeof item.icon === 'function' ? item.icon() : item.icon ?? 'ellipsis'; + return
+
+
+
; + } + + /** + * 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) => { + event.stopPropagation(); + event.preventDefault(); + const anchor = this.toAnchor(event); + this.renderPopupMenu(menuPath, anchor); + }; + + /** + * 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): 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, + onHide: () => toDisposeOnHide.dispose() + }); + } + shouldHandleMouseEvent(event: MouseEvent): boolean { return event.target instanceof Element && this.node.contains(event.target); } diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index 7a8cc548080cc..01ca9da41786e 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -500,6 +500,27 @@ background: var(--theia-icon-close) no-repeat; } +/** Configure layout of a toolbar item that shows a pop-up menu. */ +.p-TabBar-toolbar .item.menu { + display: grid; +} + +/** The elements of the item that shows a pop-up menu are stack atop one other. */ +.p-TabBar-toolbar .item.menu > div { + grid-area: 1 / 1; +} + +/** + * The chevron for the pop-up menu indication is shrunk and + * stuffed in the bottom-right corner. + */ +.p-TabBar-toolbar .item.menu > .chevron { + scale: 50%; + align-self: end; + justify-self: end; + translate: 5px 3px; +} + #theia-main-content-panel .p-TabBar:not(.theia-tabBar-active) .p-TabBar-toolbar { 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 19adea7d2a3ca..2ffb27f5951cd 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -17,7 +17,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { inject, injectable, optional } from '@theia/core/shared/inversify'; -import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter } from '@theia/core'; +import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter, nls } 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'; @@ -55,7 +55,11 @@ export class MenusContributionPointHandler { 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_RUN_MENU, widget => this.codeEditorWidgetUtil.is(widget)); + 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) + }); 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 });