diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 4a4930b08909f..4cba0ea224e39 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -727,7 +727,7 @@ export interface TreeViewRevealOptions { } export interface TreeViewsMain { - $registerTreeDataProvider(treeViewId: string): void; + $registerTreeDataProvider(treeViewId: string, canSelectMany: boolean | undefined): void; $unregisterTreeDataProvider(treeViewId: string): void; $refresh(treeViewId: string): Promise; $reveal(treeViewId: string, elementParentChain: string[], options: TreeViewRevealOptions): Promise; @@ -775,13 +775,13 @@ export interface TreeViewItem { } -export interface TreeViewSelection { - treeViewId: string - treeItemId: string +export interface TreeViewItemReference { + viewId: string + itemId: string, } -export namespace TreeViewSelection { - export function is(arg: unknown): arg is TreeViewSelection { - return isObject(arg) && 'treeViewId' in arg && 'treeItemId' in arg; +export namespace TreeViewItemReference { + export function is(arg: unknown): arg is TreeViewItemReference { + return !!arg && typeof arg === 'object' && 'viewId' in arg && 'itemId' in arg; } } diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts index a76881060108c..7e4213225e674 100644 --- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts +++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts @@ -22,7 +22,7 @@ import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-se import { ScmRepository } from '@theia/scm/lib/browser/scm-repository'; import { ScmService } from '@theia/scm/lib/browser/scm-service'; import { TimelineItem } from '@theia/timeline/lib/common/timeline-model'; -import { ScmCommandArg, TimelineCommandArg, TreeViewSelection } from '../../../common'; +import { ScmCommandArg, TimelineCommandArg, TreeViewItemReference } from '../../../common'; import { PluginScmProvider, PluginScmResource, PluginScmResourceGroup } from '../scm-main'; import { TreeViewWidget } from '../view/tree-view-widget'; import { CodeEditorWidgetUtil, codeToTheiaMappings, ContributionPoint } from './vscode-theia-menu-mappings'; @@ -238,19 +238,21 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { protected toTreeArgs(...args: any[]): any[] { const treeArgs: any[] = []; for (const arg of args) { - if (TreeViewSelection.is(arg)) { + if (TreeViewItemReference.is(arg)) { treeArgs.push(arg); + } else if (Array.isArray(arg)) { + treeArgs.push(arg.filter(TreeViewItemReference.is)); } } return treeArgs; } - protected getSelectedResources(): [CodeUri | TreeViewSelection | undefined, CodeUri[] | undefined] { + protected getSelectedResources(): [CodeUri | TreeViewItemReference | undefined, CodeUri[] | undefined] { const selection = this.selectionService.selection; const resourceKey = this.resourceContextKey.get(); const resourceUri = resourceKey ? CodeUri.parse(resourceKey) : undefined; const firstMember = TreeWidgetSelection.is(selection) && selection.source instanceof TreeViewWidget && selection[0] - ? selection.source.toTreeViewSelection(selection[0]) + ? selection.source.toTreeViewItemReference(selection[0]) : UriSelection.getUri(selection)?.['codeUri'] ?? resourceUri; const secondMember = TreeWidgetSelection.is(selection) ? UriSelection.getUris(selection).map(uri => uri['codeUri']) diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 45cc023761982..6abf65973246d 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -50,7 +50,7 @@ import { SelectionProviderCommandContribution } from './selection-provider-comma import { ViewColumnService } from './view-column-service'; import { ViewContextKeyService } from './view/view-context-key-service'; import { PluginViewWidget, PluginViewWidgetIdentifier } from './view/plugin-view-widget'; -import { TreeViewWidgetIdentifier, VIEW_ITEM_CONTEXT_MENU, PluginTree, TreeViewWidget, PluginTreeModel } from './view/tree-view-widget'; +import { TreeViewOptions, VIEW_ITEM_CONTEXT_MENU, PluginTree, TreeViewWidget, PluginTreeModel } from './view/tree-view-widget'; import { RPCProtocol } from '../../common/rpc-protocol'; import { LanguagesMainFactory, OutputChannelRegistryFactory } from '../../common'; import { LanguagesMainImpl } from './languages-main'; @@ -146,14 +146,15 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(LabelProviderContribution).toService(PluginTreeViewNodeLabelProvider); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PLUGIN_VIEW_DATA_FACTORY_ID, - createWidget: (identifier: TreeViewWidgetIdentifier) => { + createWidget: (options: TreeViewOptions) => { const props = { contextMenuPath: VIEW_ITEM_CONTEXT_MENU, expandOnlyOnExpansionToggleClick: true, expansionTogglePadding: 22, globalSelection: true, leftPadding: 8, - search: true + search: true, + multiSelect: options.multiSelect }; const child = createTreeContainer(container, { props, @@ -162,7 +163,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { widget: TreeViewWidget, decoratorService: TreeViewDecoratorService }); - child.bind(TreeViewWidgetIdentifier).toConstantValue(identifier); + child.bind(TreeViewOptions).toConstantValue(options); return child.get(TreeWidget); } })).inSingletonScope(); diff --git a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx index 9355365f4047f..6d481b347e6df 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx +++ b/packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx @@ -16,7 +16,7 @@ import { URI } from '@theia/core/shared/vscode-uri'; import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; -import { TreeViewsExt, TreeViewItemCollapsibleState, TreeViewItem, TreeViewSelection, ThemeIcon } from '../../../common/plugin-api-rpc'; +import { TreeViewsExt, TreeViewItemCollapsibleState, TreeViewItem, ThemeIcon, TreeViewItemReference } from '../../../common/plugin-api-rpc'; import { Command } from '../../../common/plugin-api-rpc-model'; import { TreeNode, @@ -161,8 +161,9 @@ export namespace CompositeTreeViewNode { } @injectable() -export class TreeViewWidgetIdentifier { +export class TreeViewOptions { id: string; + multiSelect?: boolean; } @injectable() @@ -171,8 +172,8 @@ export class PluginTree extends TreeImpl { @inject(PluginSharedStyle) protected readonly sharedStyle: PluginSharedStyle; - @inject(TreeViewWidgetIdentifier) - protected readonly identifier: TreeViewWidgetIdentifier; + @inject(TreeViewOptions) + protected readonly options: TreeViewOptions; @inject(MessageService) protected readonly notification: MessageService; @@ -188,7 +189,7 @@ export class PluginTree extends TreeImpl { set proxy(proxy: TreeViewsExt | undefined) { this._proxy = proxy; if (proxy) { - this._hasTreeItemResolve = proxy.$hasResolveTreeItem(this.identifier.id); + this._hasTreeItemResolve = proxy.$hasResolveTreeItem(this.options.id); } else { this._hasTreeItemResolve = Promise.resolve(false); } @@ -220,7 +221,7 @@ export class PluginTree extends TreeImpl { protected async fetchChildren(proxy: TreeViewsExt, parent: CompositeTreeNode): Promise { try { - const children = await proxy.$getChildren(this.identifier.id, parent.id); + const children = await proxy.$getChildren(this.options.id, parent.id); const oldEmpty = this._isEmpty; this._isEmpty = !parent.id && (!children || children.length === 0); if (oldEmpty !== this._isEmpty) { @@ -229,8 +230,8 @@ export class PluginTree extends TreeImpl { return children || []; } catch (e) { if (e) { - console.error(`Failed to fetch children for '${this.identifier.id}'`, e); - const label = this._viewInfo ? this._viewInfo.name : this.identifier.id; + console.error(`Failed to fetch children for '${this.options.id}'`, e); + const label = this._viewInfo ? this._viewInfo.name : this.options.id; this.notification.error(`${label}: ${e.message}`); } return []; @@ -288,7 +289,7 @@ export class PluginTree extends TreeImpl { children: [], command: item.command }, update); - return new ResolvableCompositeTreeViewNode(compositeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token)); + return new ResolvableCompositeTreeViewNode(compositeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.options.id, item.id, token)); } // Node is a leaf @@ -304,7 +305,7 @@ export class PluginTree extends TreeImpl { selected: false, command: item.command, }, update); - return new ResolvableTreeViewNode(treeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token)); + return new ResolvableTreeViewNode(treeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.options.id, item.id, token)); } protected createTreeNodeUpdate(item: TreeViewItem): Partial { @@ -399,8 +400,8 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { @inject(ContextKeyService) protected readonly contextKeys: ContextKeyService; - @inject(TreeViewWidgetIdentifier) - readonly identifier: TreeViewWidgetIdentifier; + @inject(TreeViewOptions) + readonly options: TreeViewOptions; @inject(PluginTreeModel) override readonly model: PluginTreeModel; @@ -420,7 +421,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { @postConstruct() protected override init(): void { super.init(); - this.id = this.identifier.id; + this.id = this.options.id; this.addClass('theia-tree-view'); this.node.style.height = '100%'; this.model.onDidChangeWelcomeState(this.update, this); @@ -549,38 +550,42 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { protected override renderTailDecorations(node: TreeViewNode, props: NodeProps): React.ReactNode { return this.contextKeys.with({ view: this.id, viewItem: node.contextValue }, () => { const menu = this.menus.getMenu(VIEW_ITEM_INLINE_MENU); - const arg = this.toTreeViewSelection(node); + const args = this.toContextMenuArgs(node); const inlineCommands = menu.children.filter((item): item is ActionMenuNode => item instanceof ActionMenuNode); const tailDecorations = super.renderTailDecorations(node, props); return {inlineCommands.length > 0 &&
- {inlineCommands.map((item, index) => this.renderInlineCommand(item, index, this.focusService.hasFocus(node), arg))} + {inlineCommands.map((item, index) => this.renderInlineCommand(item, index, this.focusService.hasFocus(node), args))}
} {tailDecorations !== undefined &&
{tailDecorations}
}
; }); } - toTreeViewSelection(node: TreeNode): TreeViewSelection { - return { treeViewId: this.id, treeItemId: node.id }; + toTreeViewItemReference(node: TreeNode): TreeViewItemReference { + return { viewId: this.id, itemId: node.id }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected renderInlineCommand(node: ActionMenuNode, index: number, tabbable: boolean, arg: any): React.ReactNode { + protected renderInlineCommand(node: ActionMenuNode, index: number, tabbable: boolean, args: any[]): React.ReactNode { const { icon } = node; - if (!icon || !this.commands.isVisible(node.command, arg) || !node.when || !this.contextKeys.match(node.when)) { + if (!icon || !this.commands.isVisible(node.command, args) || !node.when || !this.contextKeys.match(node.when)) { return false; } const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, icon, ACTION_ITEM, 'theia-tree-view-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; return
{ e.stopPropagation(); - this.commands.executeCommand(node.command, arg); + this.commands.executeCommand(node.command, ...args); }} />; } - protected override toContextMenuArgs(node: SelectableTreeNode): [TreeViewSelection] { - return [this.toTreeViewSelection(node)]; + protected override toContextMenuArgs(target: SelectableTreeNode): [TreeViewItemReference, TreeViewItemReference[]] | [TreeViewItemReference] { + if (this.options.multiSelect) { + return [this.toTreeViewItemReference(target), this.model.selectedNodes.map(node => this.toTreeViewItemReference(node))]; + } else { + return [this.toTreeViewItemReference(target)]; + } } override setFlag(flag: Widget.Flag): void { @@ -688,7 +693,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { const args = this.toContextMenuArgs(node); const contextKeyService = this.contextKeyService.createOverlay([ ['viewItem', (TreeViewNode.is(node) && node.contextValue) || undefined], - ['view', this.identifier.id] + ['view', this.options.id] ]); setTimeout(() => this.contextMenuRenderer.render({ menuPath: contextMenuPath, diff --git a/packages/plugin-ext/src/main/browser/view/tree-views-main.ts b/packages/plugin-ext/src/main/browser/view/tree-views-main.ts index fcfbec0f1d6c2..4052858187102 100644 --- a/packages/plugin-ext/src/main/browser/view/tree-views-main.ts +++ b/packages/plugin-ext/src/main/browser/view/tree-views-main.ts @@ -54,9 +54,12 @@ export class TreeViewsMainImpl implements TreeViewsMain, Disposable { this.toDispose.dispose(); } - async $registerTreeDataProvider(treeViewId: string): Promise { + async $registerTreeDataProvider(treeViewId: string, canSelectMany: boolean | undefined): Promise { this.treeViewProviders.set(treeViewId, this.viewRegistry.registerViewDataProvider(treeViewId, async ({ state, viewInfo }) => { - const widget = await this.widgetManager.getOrCreateWidget(PLUGIN_VIEW_DATA_FACTORY_ID, { id: treeViewId }); + const widget = await this.widgetManager.getOrCreateWidget(PLUGIN_VIEW_DATA_FACTORY_ID, { + id: treeViewId, + multiSelect: canSelectMany + }); widget.model.viewInfo = viewInfo; if (state) { widget.restoreState(state); diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 03d829b74168e..107e32d1f9669 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -523,7 +523,7 @@ export function createAPIFactory( registerTreeDataProvider(viewId: string, treeDataProvider: theia.TreeDataProvider): Disposable { return treeViewsExt.registerTreeDataProvider(plugin, viewId, treeDataProvider); }, - createTreeView(viewId: string, options: { treeDataProvider: theia.TreeDataProvider }): theia.TreeView { + createTreeView(viewId: string, options: theia.TreeViewOptions): theia.TreeView { return treeViewsExt.createTreeView(plugin, viewId, options); }, withScmProgress(task: (progress: theia.Progress) => Thenable) { diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts index 9e4954ccf09ef..6091566a20856 100644 --- a/packages/plugin-ext/src/plugin/tree/tree-views.ts +++ b/packages/plugin-ext/src/plugin/tree/tree-views.ts @@ -18,7 +18,7 @@ import { TreeDataProvider, TreeView, TreeViewExpansionEvent, TreeItem, TreeItemLabel, - TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent, CancellationToken + TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent, CancellationToken, TreeViewOptions } from '@theia/plugin'; // TODO: extract `@theia/util` for event, disposable, cancellation and common types // don't use @theia/core directly from plugin host @@ -28,7 +28,7 @@ import { Disposable as PluginDisposable, ThemeIcon } from '../types-impl'; import { Plugin, PLUGIN_RPC_CONTEXT, TreeViewsExt, TreeViewsMain, TreeViewItem, TreeViewRevealOptions } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { CommandRegistryImpl, CommandsConverter } from '../command-registry'; -import { TreeViewSelection } from '../../common'; +import { TreeViewItemReference } from '../../common'; import { PluginIconPath } from '../plugin-icon-path'; export class TreeViewsExtImpl implements TreeViewsExt { @@ -39,18 +39,26 @@ export class TreeViewsExtImpl implements TreeViewsExt { constructor(rpc: RPCProtocol, readonly commandRegistry: CommandRegistryImpl) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.TREE_VIEWS_MAIN); + commandRegistry.registerArgumentProcessor({ processArgument: arg => { - if (!TreeViewSelection.is(arg)) { + if (TreeViewItemReference.is(arg)) { + return this.toTreeItem(arg); + } else if (Array.isArray(arg)) { + return arg.map(param => TreeViewItemReference.is(param) ? this.toTreeItem(param) : param); + } else { return arg; } - const { treeViewId, treeItemId } = arg; - const treeView = this.treeViews.get(treeViewId); - return treeView && treeView.getTreeItem(treeItemId); } }); } + protected toTreeItem(arg: TreeViewItemReference): any | undefined { + const { viewId, itemId } = arg; + const treeView = this.treeViews.get(viewId); + return treeView && treeView.getTreeItem(itemId); + } + registerTreeDataProvider(plugin: Plugin, treeViewId: string, treeDataProvider: TreeDataProvider): PluginDisposable { const treeView = this.createTreeView(plugin, treeViewId, { treeDataProvider }); @@ -60,12 +68,12 @@ export class TreeViewsExtImpl implements TreeViewsExt { }); } - createTreeView(plugin: Plugin, treeViewId: string, options: { treeDataProvider: TreeDataProvider }): TreeView { + createTreeView(plugin: Plugin, treeViewId: string, options: TreeViewOptions): TreeView { if (!options || !options.treeDataProvider) { throw new Error('Options with treeDataProvider is mandatory'); } - const treeView = new TreeViewExtImpl(plugin, treeViewId, options.treeDataProvider, this.proxy, this.commandRegistry.converter); + const treeView = new TreeViewExtImpl(plugin, treeViewId, options, this.proxy, this.commandRegistry.converter); this.treeViews.set(treeViewId, treeView); return { @@ -198,15 +206,15 @@ class TreeViewExtImpl implements Disposable { constructor( private plugin: Plugin, private treeViewId: string, - private treeDataProvider: TreeDataProvider, + private options: TreeViewOptions, private proxy: TreeViewsMain, readonly commandsConverter: CommandsConverter) { - proxy.$registerTreeDataProvider(treeViewId); + proxy.$registerTreeDataProvider(treeViewId, options.canSelectMany); this.toDispose.push(Disposable.create(() => this.proxy.$unregisterTreeDataProvider(treeViewId))); - if (treeDataProvider.onDidChangeTreeData) { - treeDataProvider.onDidChangeTreeData(() => { + if (options.treeDataProvider.onDidChangeTreeData) { + options.treeDataProvider.onDidChangeTreeData(() => { this.pendingRefresh = proxy.$refresh(treeViewId); }); } @@ -276,7 +284,7 @@ class TreeViewExtImpl implements Disposable { // root return []; } - const result = this.treeDataProvider.getParent && await this.treeDataProvider.getParent(element); + const result = this.options.treeDataProvider.getParent && await this.options.treeDataProvider.getParent(element); const parent = result ? result : undefined; const chain = await this.calculateRevealParentChain(parent); if (!chain) { @@ -284,7 +292,7 @@ class TreeViewExtImpl implements Disposable { return undefined; } const parentId = chain.length ? chain[chain.length - 1] : ''; - const treeItem = await this.treeDataProvider.getTreeItem(element); + const treeItem = await this.options.treeDataProvider.getTreeItem(element); if (treeItem.id) { return chain.concat(treeItem.id); } @@ -351,13 +359,13 @@ class TreeViewExtImpl implements Disposable { this.nodes.set(parentId, { id: '', disposables: rootNodeDisposables, dispose: () => { rootNodeDisposables.dispose(); } }); } // ask data provider for children for cached element - const result = await this.treeDataProvider.getChildren(parent); + const result = await this.options.treeDataProvider.getChildren(parent); if (result) { const treeItemPromises = result.map(async (value, index) => { // Ask data provider for a tree item for the value // Data provider must return theia.TreeItem - const treeItem = await this.treeDataProvider.getTreeItem(value); + const treeItem = await this.options.treeDataProvider.getTreeItem(value); // Convert theia.TreeItem to the TreeViewItem const label = this.getTreeItemLabel(treeItem); @@ -474,13 +482,13 @@ class TreeViewExtImpl implements Disposable { } async resolveTreeItem(treeItemId: string, token: CancellationToken): Promise { - if (!this.treeDataProvider.resolveTreeItem) { + if (!this.options.treeDataProvider.resolveTreeItem) { return undefined; } const node = this.nodes.get(treeItemId); if (node && node.treeViewItem && node.pluginTreeItem && node.value) { - const resolved = await this.treeDataProvider.resolveTreeItem(node.pluginTreeItem, node.value, token) ?? node.pluginTreeItem; + const resolved = await this.options.treeDataProvider.resolveTreeItem(node.pluginTreeItem, node.value, token) ?? node.pluginTreeItem; node.treeViewItem.command = this.commandsConverter.toSafeCommand(resolved.command, node.disposables); node.treeViewItem.tooltip = resolved.tooltip; return node.treeViewItem; @@ -490,7 +498,7 @@ class TreeViewExtImpl implements Disposable { } hasResolveTreeItem(): boolean { - return !!this.treeDataProvider.resolveTreeItem; + return !!this.options.treeDataProvider.resolveTreeItem; } private selectedItemIds = new Set(); diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 52511f5f9d240..881adbca98b3f 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -5665,6 +5665,14 @@ export module '@theia/plugin' { * Whether to show collapse all action or not. */ showCollapseAll?: boolean; + + /** + * Whether the tree supports multi-select. When the tree supports multi-select and a command is executed from the tree, + * the first argument to the command is the tree item that the command was executed on and the second argument is an + * array containing all selected tree items. + */ + canSelectMany?: boolean; + } /**