From b4270a3df7fa11374637ff10e29570f9f785b705 Mon Sep 17 00:00:00 2001 From: Igor Vinokur Date: Thu, 4 Feb 2021 16:16:49 +0200 Subject: [PATCH] Add submenu plugin contribution, fix context-keys ScmProvider update Signed-off-by: Igor Vinokur --- .../src/browser/shell/tab-bar-toolbar.tsx | 13 +++- .../plugin-ext/src/common/plugin-protocol.ts | 18 ++++- .../src/hosted/node/scanners/scanner-theia.ts | 77 +++++++++++++------ .../menus/menus-contribution-handler.ts | 55 +++++++++---- .../scm/src/browser/scm-groups-tree-model.ts | 1 + packages/scm/src/browser/scm-tree-model.ts | 4 - 6 files changed, 122 insertions(+), 46 deletions(-) diff --git a/packages/core/src/browser/shell/tab-bar-toolbar.tsx b/packages/core/src/browser/shell/tab-bar-toolbar.tsx index 704ba3602c951..e0f239677cce9 100644 --- a/packages/core/src/browser/shell/tab-bar-toolbar.tsx +++ b/packages/core/src/browser/shell/tab-bar-toolbar.tsx @@ -173,7 +173,16 @@ export class TabBarToolbar extends ReactWidget { const menuPath = ['TAB_BAR_TOOLBAR_CONTEXT_MENU']; const toDisposeOnHide = new DisposableCollection(); for (const [, item] of this.more) { - toDisposeOnHide.push(this.menus.registerMenuAction([...menuPath, item.group!], { + // Register a submenu for the item, if the group is in format `//.../` + if (item.group!.indexOf('/') !== -1) { + 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([...menuPath, ...paths], split[i + 1])); + } + } + toDisposeOnHide.push(this.menus.registerMenuAction([...menuPath, ...item.group!.split('/')], { label: item.tooltip, commandId: item.id, when: item.when @@ -291,6 +300,8 @@ export interface TabBarToolbarItem { /** * Optional group for the item. Default `navigation`. * `navigation` group will be inlined, while all the others will be within the `...` dropdown. + * A group in format `submenu_group_1/submenu 1/.../submenu_group_n/ submenu n/item_group` means that the item will be located in a submenu(s) of the `...` dropdown. + * The submenu's title is named by the submenu section name, e.g. `group//subgroup`. */ readonly group?: string; diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 7acbb69a25710..7eedadd6d16a1 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -76,6 +76,7 @@ export interface PluginPackageContribution { viewsWelcome?: PluginPackageViewWelcome[]; commands?: PluginPackageCommand | PluginPackageCommand[]; menus?: { [location: string]: PluginPackageMenu[] }; + submenus?: PluginPackageSubmenu[]; keybindings?: PluginPackageKeybinding | PluginPackageKeybinding[]; debuggers?: PluginPackageDebuggersContribution[]; snippets?: PluginPackageSnippetsContribution[]; @@ -116,12 +117,18 @@ export interface PluginPackageCommand { } export interface PluginPackageMenu { - command: string; + command?: string; + submenu?: string; alt?: string; group?: string; when?: string; } +export interface PluginPackageSubmenu { + id: string; + label: string; +} + export interface PluginPackageKeybinding { key?: string; command: string; @@ -487,6 +494,7 @@ export interface PluginContribution { viewsWelcome?: ViewWelcome[]; commands?: PluginCommand[] menus?: { [location: string]: Menu[] }; + submenus?: Submenu[]; keybindings?: Keybinding[]; debuggers?: DebuggerContribution[]; snippets?: SnippetContribution[]; @@ -647,12 +655,18 @@ export type IconUrl = string | { light: string; dark: string; }; * Menu contribution */ export interface Menu { - command: string; + command?: string; + submenu?: string alt?: string; group?: string; when?: string; } +export interface Submenu { + id: string; + label: string; +} + /** * Keybinding contribution */ diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index d450055d97fbd..9ae243867de7e 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -14,40 +14,42 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject } from 'inversify'; +import { inject, injectable } from 'inversify'; import { + AutoClosingPair, + AutoClosingPairConditional, + buildFrontendModuleName, + DebuggerContribution, + IconThemeContribution, + IconUrl, + Keybinding, + LanguageConfiguration, + LanguageContribution, + Menu, + PluginCommand, + PluginContribution, PluginEngine, + PluginLifecycle, PluginModel, PluginPackage, - PluginScanner, - PluginLifecycle, - buildFrontendModuleName, - PluginContribution, + PluginPackageCommand, + PluginPackageDebuggersContribution, + PluginPackageKeybinding, PluginPackageLanguageContribution, - LanguageContribution, PluginPackageLanguageContributionConfiguration, - LanguageConfiguration, - PluginTaskDefinitionContribution, - AutoClosingPairConditional, - AutoClosingPair, - ViewContainer, - Keybinding, - PluginPackageKeybinding, - PluginPackageViewContainer, - View, + PluginPackageMenu, + PluginPackageSubmenu, PluginPackageView, - ViewWelcome, + PluginPackageViewContainer, PluginPackageViewWelcome, - Menu, - PluginPackageMenu, - PluginPackageDebuggersContribution, - DebuggerContribution, + PluginScanner, + PluginTaskDefinitionContribution, SnippetContribution, - PluginPackageCommand, - PluginCommand, - IconUrl, + Submenu, ThemeContribution, - IconThemeContribution + View, + ViewContainer, + ViewWelcome } from '../../../common/plugin-protocol'; import * as fs from 'fs'; import * as path from 'path'; @@ -60,7 +62,11 @@ import { deepClone } from '@theia/core/lib/common/objects'; import { FileUri } from '@theia/core/lib/node/file-uri'; import { PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/common/preferences/preference-schema'; import { RecursivePartial } from '@theia/core/lib/common/types'; -import { ProblemMatcherContribution, ProblemPatternContribution, TaskDefinition } from '@theia/task/lib/common/task-protocol'; +import { + ProblemMatcherContribution, + ProblemPatternContribution, + TaskDefinition +} from '@theia/task/lib/common/task-protocol'; import { ColorDefinition } from '@theia/core/lib/browser/color-registry'; import { ResourceLabelFormatter } from '@theia/core/lib/common/label-protocol'; @@ -168,6 +174,14 @@ export class TheiaPluginScanner implements PluginScanner { console.error(`Could not read '${rawPlugin.name}' contribution 'languages'.`, rawPlugin.contributes!.languages, err); } + try { + if (rawPlugin.contributes!.submenus) { + contributions.submenus = this.readSubmenus(rawPlugin.contributes.submenus!); + } + } catch (err) { + console.error(`Could not read '${rawPlugin.name}' contribution 'submenus'.`, rawPlugin.contributes!.submenus, err); + } + try { if (rawPlugin.contributes!.grammars) { const grammars = this.grammarsReader.readGrammars(rawPlugin.contributes.grammars!, rawPlugin.packagePath); @@ -527,6 +541,7 @@ export class TheiaPluginScanner implements PluginScanner { private readMenu(rawMenu: PluginPackageMenu): Menu { const result: Menu = { command: rawMenu.command, + submenu: rawMenu.submenu, alt: rawMenu.alt, group: rawMenu.group, when: rawMenu.when @@ -538,6 +553,18 @@ export class TheiaPluginScanner implements PluginScanner { return rawLanguages.map(language => this.readLanguage(language, pluginPath)); } + private readSubmenus(rawSubmenus: PluginPackageSubmenu[]): Submenu[] { + return rawSubmenus.map(submenu => this.readSubmenu(submenu)); + } + + private readSubmenu(rawSubmenu: PluginPackageSubmenu): Submenu { + return { + id: rawSubmenu.id, + label: rawSubmenu.label + }; + + } + private readLanguage(rawLang: PluginPackageLanguageContribution, pluginPath: string): LanguageContribution { // TODO: add validation to all parameters const result: LanguageContribution = { 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 54cb4be97a34f..3abfeae236c81 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 @@ -26,7 +26,7 @@ import { TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browse import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; import { QuickCommandService } from '@theia/core/lib/browser/quick-open/quick-command-service'; import { VIEW_ITEM_CONTEXT_MENU, TreeViewWidget, VIEW_ITEM_INLINE_MENU } from '../view/tree-view-widget'; -import { DeployedPlugin, Menu, ScmCommandArg, TimelineCommandArg, TreeViewSelection } from '../../../common'; +import { DeployedPlugin, Menu, ScmCommandArg, Submenu, TimelineCommandArg, TreeViewSelection } from '../../../common'; import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stack-frames-widget'; import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection'; @@ -98,32 +98,39 @@ export class MenusContributionPointHandler { if (!allMenus) { return Disposable.NULL; } + const allSubmenus = plugin.contributes && plugin.contributes.submenus; const toDispose = new DisposableCollection(); for (const location in allMenus) { if (location === 'commandPalette') { for (const menu of allMenus[location]) { - if (menu.when) { + if (menu.command && menu.when) { toDispose.push(this.quickCommandService.pushCommandContext(menu.command, menu.when)); } } } else if (location === 'editor/title') { for (const action of allMenus[location]) { + if (!action.command) { + continue; + } toDispose.push(this.registerTitleAction(location, action, { - execute: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.executeCommand(action.command, this.codeEditorWidgetUtil.getResourceUri(widget)), - isEnabled: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.isEnabled(action.command, this.codeEditorWidgetUtil.getResourceUri(widget)), - isVisible: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.isVisible(action.command, this.codeEditorWidgetUtil.getResourceUri(widget)) + execute: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.executeCommand(action.command!, this.codeEditorWidgetUtil.getResourceUri(widget)), + isEnabled: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.isEnabled(action.command!, this.codeEditorWidgetUtil.getResourceUri(widget)), + isVisible: widget => this.codeEditorWidgetUtil.is(widget) && this.commands.isVisible(action.command!, this.codeEditorWidgetUtil.getResourceUri(widget)) })); } } else if (location === 'view/title') { for (const action of allMenus[location]) { + if (!action.command) { + continue; + } toDispose.push(this.registerTitleAction(location, { ...action, when: undefined }, { - execute: widget => widget instanceof PluginViewWidget && this.commands.executeCommand(action.command), + execute: widget => widget instanceof PluginViewWidget && this.commands.executeCommand(action.command!), isEnabled: widget => widget instanceof PluginViewWidget && this.viewContextKeys.with({ view: widget.options.viewId }, () => - this.commands.isEnabled(action.command) && this.viewContextKeys.match(action.when)), + this.commands.isEnabled(action.command!) && this.viewContextKeys.match(action.when)), isVisible: widget => widget instanceof PluginViewWidget && this.viewContextKeys.with({ view: widget.options.viewId }, () => - this.commands.isVisible(action.command) && this.viewContextKeys.match(action.when)) + this.commands.isVisible(action.command!) && this.viewContextKeys.match(action.when)) })); } } else if (location === 'view/item/context') { @@ -133,9 +140,20 @@ export class MenusContributionPointHandler { toDispose.push(this.registerTreeMenuAction(menuPath, menu)); } } else if (location === 'scm/title') { - for (const action of allMenus[location]) { - toDispose.push(this.registerScmTitleAction(location, action)); - } + const registerActions = (menus: Menu[], group: string | undefined) => { + for (const action of menus) { + if (group) { + action.group = group + (action.group ? '/' + action.group.split('@')[0] : '/_'); + } + if (action.submenu) { + const submenu: Submenu = allSubmenus!.find(s => s.id === action.submenu)!; + registerActions(allMenus[action.submenu], action.group!.split('@')[0] + '/' + submenu.label); + } else { + toDispose.push(this.registerScmTitleAction(location, action)); + } + } + }; + registerActions(allMenus[location], undefined); } else if (location === 'scm/resourceGroup/context') { for (const menu of allMenus[location]) { const inline = menu.group && /^inline/.test(menu.group) || false; @@ -256,6 +274,9 @@ export class MenusContributionPointHandler { } protected registerTitleAction(location: string, action: Menu, handler: CommandHandler): Disposable { + if (!action.command) { + return Disposable.NULL; + } const toDispose = new DisposableCollection(); const id = this.createSyntheticCommandId(action.command, { prefix: `__plugin.${location.replace('/', '.')}.action.` }); const command: Command = { id }; @@ -301,11 +322,14 @@ export class MenusContributionPointHandler { } protected registerScmTitleAction(location: string, action: Menu): Disposable { + if (!action.command) { + return Disposable.NULL; + } const selectedRepository = () => this.toScmArgs(this.scmService.selectedRepository); return this.registerTitleAction(location, action, { - execute: widget => widget instanceof ScmWidget && this.commands.executeCommand(action.command, selectedRepository()), - isEnabled: widget => widget instanceof ScmWidget && this.commands.isEnabled(action.command, selectedRepository()), - isVisible: widget => widget instanceof ScmWidget && this.commands.isVisible(action.command, selectedRepository()) + execute: widget => widget instanceof ScmWidget && this.commands.executeCommand(action.command!, selectedRepository()), + isEnabled: widget => widget instanceof ScmWidget && this.commands.isEnabled(action.command!, selectedRepository()), + isVisible: widget => widget instanceof ScmWidget && this.commands.isVisible(action.command!, selectedRepository()) }); } protected registerScmMenuAction(menuPath: MenuPath, menu: Menu): Disposable { @@ -403,6 +427,9 @@ export class MenusContributionPointHandler { } protected registerMenuAction(menuPath: MenuPath, menu: Menu, handler: (command: string) => CommandHandler): Disposable { + if (!menu.command) { + return Disposable.NULL; + } const toDispose = new DisposableCollection(); const commandId = this.createSyntheticCommandId(menu.command, { prefix: '__plugin.menu.action.' }); const command: Command = { id: commandId }; diff --git a/packages/scm/src/browser/scm-groups-tree-model.ts b/packages/scm/src/browser/scm-groups-tree-model.ts index e47d4a7ed43e5..b5c23d4538cf0 100644 --- a/packages/scm/src/browser/scm-groups-tree-model.ts +++ b/packages/scm/src/browser/scm-groups-tree-model.ts @@ -47,6 +47,7 @@ export class ScmGroupsTreeModel extends ScmTreeModel { protected changeRepository(provider: ScmProvider | undefined): void { this.toDisposeOnRepositoryChange.dispose(); + this.contextKeys.scmProvider.set(provider ? provider.id : undefined); this.provider = provider; if (provider) { this.toDisposeOnRepositoryChange.push(provider.onDidChange(() => { diff --git a/packages/scm/src/browser/scm-tree-model.ts b/packages/scm/src/browser/scm-tree-model.ts index 7919a00c180eb..dbd97c1606369 100644 --- a/packages/scm/src/browser/scm-tree-model.ts +++ b/packages/scm/src/browser/scm-tree-model.ts @@ -367,15 +367,11 @@ export abstract class ScmTreeModel extends TreeModelImpl { return; } - const currentScmProviderId = this.contextKeys.scmProvider.get(); - const currentScmResourceGroup = this.contextKeys.scmResourceGroup.get(); this.contextKeys.scmProvider.set(this.provider.id); this.contextKeys.scmResourceGroup.set(groupId); try { callback(); } finally { - this.contextKeys.scmProvider.set(currentScmProviderId); - this.contextKeys.scmResourceGroup.set(currentScmResourceGroup); } }