Skip to content

Commit

Permalink
vscode: independent editor/title/run menu
Browse files Browse the repository at this point in the history
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 <cdamus.ext@eclipsesource.com>
  • Loading branch information
cdamus committed Aug 28, 2023
1 parent 9001508 commit 0e6186f
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -103,10 +103,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution {
}
const result: Array<TabBarToolbarItem | ReactTabBarToolbarItem> = [];
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);
}
}
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand All @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export interface MenuToolbarItem {
menuPath: MenuPath;
}

interface ConditionalToolbarItem {
export interface ConditionalToolbarItem {
/**
* https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts
*/
Expand Down Expand Up @@ -130,6 +130,7 @@ export interface TabBarToolbarItem extends RegisteredToolbarItem,
RenderedToolbarItem,
Omit<ConditionalToolbarItem, 'isVisible'>,
Pick<InlineToolbarItemMetadata, 'priority'>,
Partial<MenuToolbarItem>,
Partial<MenuToolbarItemMetadata> { }

/**
Expand Down Expand Up @@ -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<T extends AnyToolbarItem>(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<T extends AnyToolbarItem>(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';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -149,7 +149,9 @@ export class TabBarToolbar extends ReactWidget {
this.keybindingContextKeys.clear();
return <React.Fragment>
{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))}
</React.Fragment>;
}

Expand Down Expand Up @@ -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 <div key={item.id}
className={TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM + ' enabled menu'}
onClick={this.showPopupMenu.bind(this, item.menuPath)}>
<div id={item.id} className={codicon(icon, true)}
title={item.text} />
<div className={codicon('chevron-down') + ' chevron'} />
</div >;
}

/**
* 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);
}
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/browser/style/tabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down

0 comments on commit 0e6186f

Please sign in to comment.