Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vscode: independent editor/title/run menu #12799

Merged
merged 1 commit into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading