Skip to content

Commit 4484f8d

Browse files
authoredAug 28, 2023
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 <cdamus.ext@eclipsesource.com>
1 parent 72bd9c9 commit 4484f8d

File tree

5 files changed

+206
-13
lines changed

5 files changed

+206
-13
lines changed
 

‎packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-registry.ts

+94-8
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717
import debounce = require('lodash.debounce');
1818
import { inject, injectable, named } from 'inversify';
1919
// eslint-disable-next-line max-len
20-
import { CommandMenuNode, CommandRegistry, CompoundMenuNode, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuPath } from '../../../common';
20+
import { CommandMenuNode, CommandRegistry, CompoundMenuNode, ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MenuModelRegistry, MenuNode, MenuPath } from '../../../common';
2121
import { ContextKeyService } from '../../context-key-service';
2222
import { FrontendApplicationContribution } from '../../frontend-application';
2323
import { Widget } from '../../widgets';
24-
import { MenuDelegate, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types';
24+
import { AnyToolbarItem, ConditionalToolbarItem, MenuDelegate, MenuToolbarItem, ReactTabBarToolbarItem, TabBarToolbarItem } from './tab-bar-toolbar-types';
2525
import { ToolbarMenuNodeWrapper } from './tab-bar-toolbar-menu-adapters';
2626

2727
/**
@@ -103,10 +103,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution {
103103
}
104104
const result: Array<TabBarToolbarItem | ReactTabBarToolbarItem> = [];
105105
for (const item of this.items.values()) {
106-
const visible = TabBarToolbarItem.is(item)
107-
? this.commandRegistry.isVisible(item.command, widget)
108-
: (!item.isVisible || item.isVisible(widget));
109-
if (visible && (!item.when || this.contextKeyService.match(item.when, widget.node))) {
106+
if (this.isItemVisible(item, widget)) {
110107
result.push(item);
111108
}
112109
}
@@ -139,6 +136,83 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution {
139136
return result;
140137
}
141138

139+
/**
140+
* Query whether a toolbar `item` should be shown in the toolbar.
141+
* This implementation delegates to item-specific checks according to their type.
142+
*
143+
* @param item a menu toolbar item
144+
* @param widget the widget that is updating the toolbar
145+
* @returns `false` if the `item` should be suppressed, otherwise `true`
146+
*/
147+
protected isItemVisible(item: TabBarToolbarItem | ReactTabBarToolbarItem, widget: Widget): boolean {
148+
if (TabBarToolbarItem.is(item) && item.command && !this.isTabBarToolbarItemVisible(item, widget)) {
149+
return false;
150+
}
151+
if (MenuToolbarItem.is(item) && !this.isMenuToolbarItemVisible(item, widget)) {
152+
return false;
153+
}
154+
if (AnyToolbarItem.isConditional(item) && !this.isConditionalItemVisible(item, widget)) {
155+
return false;
156+
}
157+
// The item is not vetoed. Accept it
158+
return true;
159+
}
160+
161+
/**
162+
* Query whether a conditional toolbar `item` should be shown in the toolbar.
163+
* This implementation delegates to the `item`'s own intrinsic conditionality.
164+
*
165+
* @param item a menu toolbar item
166+
* @param widget the widget that is updating the toolbar
167+
* @returns `false` if the `item` should be suppressed, otherwise `true`
168+
*/
169+
protected isConditionalItemVisible(item: ConditionalToolbarItem, widget: Widget): boolean {
170+
if (item.isVisible && !item.isVisible(widget)) {
171+
return false;
172+
}
173+
if (item.when && !this.contextKeyService.match(item.when, widget.node)) {
174+
return false;
175+
}
176+
return true;
177+
}
178+
179+
/**
180+
* Query whether a tab-bar toolbar `item` that has a command should be shown in the toolbar.
181+
* This implementation returns `false` if the `item`'s command is not visible in the
182+
* `widget` according to the command registry.
183+
*
184+
* @param item a tab-bar toolbar item that has a non-empty `command`
185+
* @param widget the widget that is updating the toolbar
186+
* @returns `false` if the `item` should be suppressed, otherwise `true`
187+
*/
188+
protected isTabBarToolbarItemVisible(item: TabBarToolbarItem, widget: Widget): boolean {
189+
return this.commandRegistry.isVisible(item.command, widget);
190+
}
191+
192+
/**
193+
* Query whether a menu toolbar `item` should be shown in the toolbar.
194+
* This implementation returns `false` if the `item` does not have any actual menu to show.
195+
*
196+
* @param item a menu toolbar item
197+
* @param widget the widget that is updating the toolbar
198+
* @returns `false` if the `item` should be suppressed, otherwise `true`
199+
*/
200+
protected isMenuToolbarItemVisible(item: MenuToolbarItem, widget: Widget): boolean {
201+
const menu = this.menuRegistry.getMenu(item.menuPath);
202+
const isVisible: (node: MenuNode) => boolean = node =>
203+
node.children?.length
204+
// Either the node is a sub-menu that has some visible child ...
205+
? node.children?.some(isVisible)
206+
// ... or there is a command ...
207+
: !!node.command
208+
// ... that is visible ...
209+
&& this.commandRegistry.isVisible(node.command, widget)
210+
// ... and a "when" clause does not suppress the menu node.
211+
&& (!node.when || this.contextKeyService.match(node.when, widget?.node));
212+
213+
return isVisible(menu);
214+
}
215+
142216
unregisterItem(itemOrId: TabBarToolbarItem | ReactTabBarToolbarItem | string): void {
143217
const id = typeof itemOrId === 'string' ? itemOrId : itemOrId.id;
144218
if (this.items.delete(id)) {
@@ -147,7 +221,7 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution {
147221
}
148222

149223
registerMenuDelegate(menuPath: MenuPath, when?: string | ((widget: Widget) => boolean)): Disposable {
150-
const id = menuPath.join(menuDelegateSeparator);
224+
const id = this.toElementId(menuPath);
151225
if (!this.menuDelegates.has(id)) {
152226
const isVisible: MenuDelegate['isVisible'] = !when
153227
? yes
@@ -163,8 +237,20 @@ export class TabBarToolbarRegistry implements FrontendApplicationContribution {
163237
}
164238

165239
unregisterMenuDelegate(menuPath: MenuPath): void {
166-
if (this.menuDelegates.delete(menuPath.join(menuDelegateSeparator))) {
240+
if (this.menuDelegates.delete(this.toElementId(menuPath))) {
167241
this.fireOnDidChange();
168242
}
169243
}
244+
245+
/**
246+
* Generate a single ID string from a menu path that
247+
* is likely to be unique amongst the items in the toolbar.
248+
*
249+
* @param menuPath a menubar path
250+
* @returns a likely unique ID based on the path
251+
*/
252+
toElementId(menuPath: MenuPath): string {
253+
return menuPath.join(menuDelegateSeparator);
254+
}
255+
170256
}

‎packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-types.ts

+28-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export interface MenuToolbarItem {
8383
menuPath: MenuPath;
8484
}
8585

86-
interface ConditionalToolbarItem {
86+
export interface ConditionalToolbarItem {
8787
/**
8888
* https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts
8989
*/
@@ -130,6 +130,7 @@ export interface TabBarToolbarItem extends RegisteredToolbarItem,
130130
RenderedToolbarItem,
131131
Omit<ConditionalToolbarItem, 'isVisible'>,
132132
Pick<InlineToolbarItemMetadata, 'priority'>,
133+
Partial<MenuToolbarItem>,
133134
Partial<MenuToolbarItemMetadata> { }
134135

135136
/**
@@ -174,7 +175,33 @@ export namespace TabBarToolbarItem {
174175
}
175176

176177
export namespace MenuToolbarItem {
178+
/**
179+
* Type guard for a toolbar item that actually is a menu item, amongst
180+
* the other kinds of item that it may also be.
181+
*
182+
* @param item a toolbar item
183+
* @returns whether the `item` is a menu item
184+
*/
185+
export function is<T extends AnyToolbarItem>(item: T): item is T & MenuToolbarItem {
186+
return Array.isArray(item.menuPath);
187+
}
188+
177189
export function getMenuPath(item: AnyToolbarItem): MenuPath | undefined {
178190
return Array.isArray(item.menuPath) ? item.menuPath : undefined;
179191
}
180192
}
193+
194+
export namespace AnyToolbarItem {
195+
/**
196+
* Type guard for a toolbar item that actually manifests any of the
197+
* features of a conditional toolbar item.
198+
*
199+
* @param item a toolbar item
200+
* @returns whether the `item` is a conditional item
201+
*/
202+
export function isConditional<T extends AnyToolbarItem>(item: T): item is T & ConditionalToolbarItem {
203+
return 'isVisible' in item && typeof item.isVisible === 'function'
204+
|| 'onDidChange' in item && typeof item.onDidChange === 'function'
205+
|| 'when' in item && typeof item.when === 'string';
206+
}
207+
}

‎packages/core/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar.tsx

+57-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { Anchor, ContextMenuAccess, ContextMenuRenderer } from '../../context-me
2222
import { LabelIcon, LabelParser } from '../../label-parser';
2323
import { ACTION_ITEM, codicon, ReactWidget, Widget } from '../../widgets';
2424
import { TabBarToolbarRegistry } from './tab-bar-toolbar-registry';
25-
import { AnyToolbarItem, ReactTabBarToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU } from './tab-bar-toolbar-types';
25+
import { AnyToolbarItem, ReactTabBarToolbarItem, TabBarDelegator, TabBarToolbarItem, TAB_BAR_TOOLBAR_CONTEXT_MENU, MenuToolbarItem } from './tab-bar-toolbar-types';
2626
import { KeybindingRegistry } from '../..//keybinding';
2727

2828
/**
@@ -149,7 +149,9 @@ export class TabBarToolbar extends ReactWidget {
149149
this.keybindingContextKeys.clear();
150150
return <React.Fragment>
151151
{this.renderMore()}
152-
{[...this.inline.values()].map(item => TabBarToolbarItem.is(item) ? this.renderItem(item) : item.render(this.current))}
152+
{[...this.inline.values()].map(item => TabBarToolbarItem.is(item)
153+
? (MenuToolbarItem.is(item) ? this.renderMenuItem(item) : this.renderItem(item))
154+
: item.render(this.current))}
153155
</React.Fragment>;
154156
}
155157

@@ -290,6 +292,59 @@ export class TabBarToolbar extends ReactWidget {
290292
});
291293
}
292294

295+
/**
296+
* Renders a toolbar item that is a menu, presenting it as a button with a little
297+
* chevron decoration that pops up a floating menu when clicked.
298+
*
299+
* @param item a toolbar item that is a menu item
300+
* @returns the rendered toolbar item
301+
*/
302+
protected renderMenuItem(item: TabBarToolbarItem & MenuToolbarItem): React.ReactNode {
303+
const icon = typeof item.icon === 'function' ? item.icon() : item.icon ?? 'ellipsis';
304+
return <div key={item.id}
305+
className={TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM + ' enabled menu'}
306+
onClick={this.showPopupMenu.bind(this, item.menuPath)}>
307+
<div id={item.id} className={codicon(icon, true)}
308+
title={item.text} />
309+
<div className={codicon('chevron-down') + ' chevron'} />
310+
</div >;
311+
}
312+
313+
/**
314+
* Presents the menu to popup on the `event` that is the clicking of
315+
* a menu toolbar item.
316+
*
317+
* @param menuPath the path of the registered menu to show
318+
* @param event the mouse event triggering the menu
319+
*/
320+
protected showPopupMenu = (menuPath: MenuPath, event: React.MouseEvent) => {
321+
event.stopPropagation();
322+
event.preventDefault();
323+
const anchor = this.toAnchor(event);
324+
this.renderPopupMenu(menuPath, anchor);
325+
};
326+
327+
/**
328+
* Renders the menu popped up on a menu toolbar item.
329+
*
330+
* @param menuPath the path of the registered menu to render
331+
* @param anchor a description of where to render the menu
332+
* @returns platform-specific access to the rendered context menu
333+
*/
334+
protected renderPopupMenu(menuPath: MenuPath, anchor: Anchor): ContextMenuAccess {
335+
const toDisposeOnHide = new DisposableCollection();
336+
this.addClass('menu-open');
337+
toDisposeOnHide.push(Disposable.create(() => this.removeClass('menu-open')));
338+
339+
return this.contextMenuRenderer.render({
340+
menuPath,
341+
args: [this.current],
342+
anchor,
343+
context: this.current?.node,
344+
onHide: () => toDisposeOnHide.dispose()
345+
});
346+
}
347+
293348
shouldHandleMouseEvent(event: MouseEvent): boolean {
294349
return event.target instanceof Element && this.node.contains(event.target);
295350
}

‎packages/core/src/browser/style/tabs.css

+21
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,27 @@
500500
background: var(--theia-icon-close) no-repeat;
501501
}
502502

503+
/** Configure layout of a toolbar item that shows a pop-up menu. */
504+
.p-TabBar-toolbar .item.menu {
505+
display: grid;
506+
}
507+
508+
/** The elements of the item that shows a pop-up menu are stack atop one other. */
509+
.p-TabBar-toolbar .item.menu > div {
510+
grid-area: 1 / 1;
511+
}
512+
513+
/**
514+
* The chevron for the pop-up menu indication is shrunk and
515+
* stuffed in the bottom-right corner.
516+
*/
517+
.p-TabBar-toolbar .item.menu > .chevron {
518+
scale: 50%;
519+
align-self: end;
520+
justify-self: end;
521+
translate: 5px 3px;
522+
}
523+
503524
#theia-main-content-panel
504525
.p-TabBar:not(.theia-tabBar-active)
505526
.p-TabBar-toolbar {

‎packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
/* eslint-disable @typescript-eslint/no-explicit-any */
1818

1919
import { inject, injectable, optional } from '@theia/core/shared/inversify';
20-
import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter } from '@theia/core';
20+
import { MenuPath, CommandRegistry, Disposable, DisposableCollection, ActionMenuNode, MenuCommandAdapterRegistry, Emitter, nls } from '@theia/core';
2121
import { MenuModelRegistry } from '@theia/core/lib/common';
2222
import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
2323
import { DeployedPlugin, IconUrl, Menu } from '../../../common';
@@ -55,7 +55,11 @@ export class MenusContributionPointHandler {
5555
this.initialized = true;
5656
this.commandAdapterRegistry.registerAdapter(this.commandAdapter);
5757
this.tabBarToolbar.registerMenuDelegate(PLUGIN_EDITOR_TITLE_MENU, widget => this.codeEditorWidgetUtil.is(widget));
58-
this.tabBarToolbar.registerMenuDelegate(PLUGIN_EDITOR_TITLE_RUN_MENU, widget => this.codeEditorWidgetUtil.is(widget));
58+
this.tabBarToolbar.registerItem({
59+
id: this.tabBarToolbar.toElementId(PLUGIN_EDITOR_TITLE_RUN_MENU), menuPath: PLUGIN_EDITOR_TITLE_RUN_MENU,
60+
icon: 'debug-alt', text: nls.localizeByDefault('Run or Debug...'),
61+
command: '', group: 'navigation', isVisible: widget => this.codeEditorWidgetUtil.is(widget)
62+
});
5963
this.tabBarToolbar.registerMenuDelegate(PLUGIN_SCM_TITLE_MENU, widget => widget instanceof ScmWidget);
6064
this.tabBarToolbar.registerMenuDelegate(PLUGIN_VIEW_TITLE_MENU, widget => !this.codeEditorWidgetUtil.is(widget));
6165
this.tabBarToolbar.registerItem({ id: 'plugin-menu-contribution-title-contribution', command: '_never_', onDidChange: this.onDidChangeTitleContributionEmitter.event });

0 commit comments

Comments
 (0)
Please sign in to comment.