diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index e93332d0481e1..e3a25a209c157 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -110,7 +110,7 @@ import type { import { SerializableEnvironmentVariableCollection } from '@theia/terminal/lib/common/base-terminal-protocol'; import { ThemeType } from '@theia/core/lib/common/theme'; import { Disposable } from '@theia/core/lib/common/disposable'; -import { isObject, PickOptions, QuickInputButtonHandle } from '@theia/core/lib/common'; +import { isString, isObject, PickOptions, QuickInputButtonHandle } from '@theia/core/lib/common'; import { Severity } from '@theia/core/lib/common/severity'; import { DebugConfiguration, DebugSessionOptions } from '@theia/debug/lib/common/debug-configuration'; @@ -720,6 +720,12 @@ export interface DialogsMain { $showUploadDialog(options: UploadDialogOptionsMain): Promise; } +export interface RegisterTreeDataProviderOptions { + canSelectMany?: boolean + dragMimeTypes?: string[] + dropMimeTypes?: string[] +} + export interface TreeViewRevealOptions { select: boolean focus: boolean @@ -727,7 +733,7 @@ export interface TreeViewRevealOptions { } export interface TreeViewsMain { - $registerTreeDataProvider(treeViewId: string, dragMimetypes: string[] | undefined, dropMimetypes: string[] | undefined): void; + $registerTreeDataProvider(treeViewId: string, options?: RegisterTreeDataProviderOptions): void; $readDroppedFile(contentId: string): Promise; $unregisterTreeDataProvider(treeViewId: string): void; $refresh(treeViewId: string): Promise; @@ -785,13 +791,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 isObject(arg) && isString(arg.viewId) && isString(arg.itemId); } } 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 29ac3b0537efc..2d72b2c6bd39c 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 @@ -148,14 +148,15 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(DnDFileContentStore).toSelf().inSingletonScope(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PLUGIN_VIEW_DATA_FACTORY_ID, - createWidget: (identifier: TreeViewWidgetOptions) => { + createWidget: (options: TreeViewWidgetOptions) => { 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, @@ -164,7 +165,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { widget: TreeViewWidget, decoratorService: TreeViewDecoratorService }); - child.bind(TreeViewWidgetOptions).toConstantValue(identifier); + child.bind(TreeViewWidgetOptions).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 3561985e3ab18..3752f79d1d8e8 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 @@ -15,7 +15,7 @@ // ***************************************************************************** import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; -import { TreeViewsExt, TreeViewItemCollapsibleState, TreeViewItem, TreeViewSelection, ThemeIcon, DataTransferFileDTO } from '../../../common/plugin-api-rpc'; +import { TreeViewsExt, TreeViewItemCollapsibleState, TreeViewItem, TreeViewItemReference, ThemeIcon, DataTransferFileDTO } from '../../../common/plugin-api-rpc'; import { Command } from '../../../common/plugin-api-rpc-model'; import { TreeNode, @@ -164,6 +164,7 @@ export namespace CompositeTreeViewNode { @injectable() export class TreeViewWidgetOptions { id: string; + multiSelect: boolean | undefined; dragMimeTypes: string[] | undefined; dropMimeTypes: string[] | undefined; } @@ -696,41 +697,44 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { } } - protected override renderTailDecorations(node: TreeViewNode, props: NodeProps): React.ReactNode { - return this.contextKeys.with({ view: this.id, viewItem: node.contextValue }, () => { + protected override renderTailDecorations(treeViewNode: TreeViewNode, props: NodeProps): React.ReactNode { + return this.contextKeys.with({ view: this.id, viewItem: treeViewNode.contextValue }, () => { const menu = this.menus.getMenu(VIEW_ITEM_INLINE_MENU); - const arg = this.toTreeViewSelection(node); + const args = this.toContextMenuArgs(treeViewNode); const inlineCommands = menu.children.filter((item): item is ActionMenuNode => item instanceof ActionMenuNode); - const tailDecorations = super.renderTailDecorations(node, props); + const tailDecorations = super.renderTailDecorations(treeViewNode, 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(treeViewNode), args))}
} {tailDecorations !== undefined &&
{tailDecorations}
}
; }); } - toTreeViewSelection(node: TreeNode): TreeViewSelection { - return { treeViewId: this.id, treeItemId: node.id }; + toTreeViewItemReference(treeNode: TreeNode): TreeViewItemReference { + return { viewId: this.id, itemId: treeNode.id }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected renderInlineCommand(node: ActionMenuNode, index: number, tabbable: boolean, arg: any): React.ReactNode { - const { icon } = node; - if (!icon || !this.commands.isVisible(node.command, arg) || !node.when || !this.contextKeys.match(node.when)) { + protected renderInlineCommand(actionMenuNode: ActionMenuNode, index: number, tabbable: boolean, args: any[]): React.ReactNode { + if (!actionMenuNode.icon || !this.commands.isVisible(actionMenuNode.command, ...args) || !actionMenuNode.when || !this.contextKeys.match(actionMenuNode.when)) { return false; } - const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, icon, ACTION_ITEM, 'theia-tree-view-inline-action'].join(' '); + const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, actionMenuNode.icon, ACTION_ITEM, 'theia-tree-view-inline-action'].join(' '); const tabIndex = tabbable ? 0 : undefined; - return
{ + return
{ e.stopPropagation(); - this.commands.executeCommand(node.command, arg); + this.commands.executeCommand(actionMenuNode.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 { 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 ec59db20f451c..7342d553b0592 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 @@ -15,7 +15,7 @@ // ***************************************************************************** import { interfaces } from '@theia/core/shared/inversify'; -import { MAIN_RPC_CONTEXT, TreeViewsMain, TreeViewsExt, TreeViewRevealOptions } from '../../../common/plugin-api-rpc'; +import { MAIN_RPC_CONTEXT, TreeViewsMain, TreeViewsExt, TreeViewRevealOptions, RegisterTreeDataProviderOptions } from '../../../common/plugin-api-rpc'; import { RPCProtocol } from '../../../common/rpc-protocol'; import { PluginViewRegistry, PLUGIN_VIEW_DATA_FACTORY_ID } from './plugin-view-registry'; import { @@ -58,14 +58,14 @@ export class TreeViewsMainImpl implements TreeViewsMain, Disposable { this.toDispose.dispose(); } - async $registerTreeDataProvider(treeViewId: string, dragMimeTypes: string[] | undefined, dropMimeTypes: string[] | undefined): Promise { + async $registerTreeDataProvider(treeViewId: string, $options: RegisterTreeDataProviderOptions): Promise { this.treeViewProviders.set(treeViewId, this.viewRegistry.registerViewDataProvider(treeViewId, async ({ state, viewInfo }) => { const options: TreeViewWidgetOptions = { id: treeViewId, - dragMimeTypes, - dropMimeTypes + multiSelect: $options.canSelectMany, + dragMimeTypes: $options.dragMimeTypes, + dropMimeTypes: $options.dropMimeTypes }; - const widget = await this.widgetManager.getOrCreateWidget(PLUGIN_VIEW_DATA_FACTORY_ID, options); widget.model.viewInfo = viewInfo; if (state) { diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts index 72163f35f9258..db440ba25128b 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, TreeDragAndDropController, DataTransferFile + TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent, CancellationToken, DataTransferFile, 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 { DataTransfer, DataTransferItem, Disposable as PluginDisposable, ThemeIc import { Plugin, PLUGIN_RPC_CONTEXT, TreeViewsExt, TreeViewsMain, TreeViewItem, TreeViewRevealOptions, DataTransferFileDTO } 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'; import { URI } from '@theia/core/shared/vscode-uri'; import { UriComponents } from '@theia/core/lib/common/uri'; @@ -41,14 +41,16 @@ 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); } }); } @@ -60,6 +62,10 @@ export class TreeViewsExtImpl implements TreeViewsExt { return this.getTreeView(treeViewId).handleDrop!(treeItemId, dataTransferItems, token); } + protected toTreeItem(treeViewItemRef: TreeViewItemReference): any { + return this.treeViews.get(treeViewItemRef.viewId)?.getTreeItem(treeViewItemRef.itemId); + } + registerTreeDataProvider(plugin: Plugin, treeViewId: string, treeDataProvider: TreeDataProvider): PluginDisposable { const treeView = this.createTreeView(plugin, treeViewId, { treeDataProvider }); @@ -69,12 +75,12 @@ export class TreeViewsExtImpl implements TreeViewsExt { }); } - createTreeView(plugin: Plugin, treeViewId: string, options: { treeDataProvider: TreeDataProvider, dragAndDropController?: TreeDragAndDropController }): 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, options.dragAndDropController, this.proxy, this.commandRegistry.converter); + const treeView = new TreeViewExtImpl(plugin, treeViewId, options, this.proxy, this.commandRegistry.converter); this.treeViews.set(treeViewId, treeView); return { @@ -209,23 +215,18 @@ class TreeViewExtImpl implements Disposable { constructor( private plugin: Plugin, private treeViewId: string, - private treeDataProvider: TreeDataProvider, - private dragAndDropController: TreeDragAndDropController | undefined, + private options: TreeViewOptions, private proxy: TreeViewsMain, - readonly commandsConverter: CommandsConverter) { - - const dragTypes = dragAndDropController?.dragMimeTypes ? [...dragAndDropController.dragMimeTypes] : undefined; - const dropTypes = dragAndDropController?.dropMimeTypes ? [...dragAndDropController.dropMimeTypes] : undefined; - - proxy.$registerTreeDataProvider(treeViewId, dragTypes, dropTypes); - + readonly commandsConverter: CommandsConverter + ) { + // make copies of optionally provided MIME types: + const dragMimeTypes = options.dragAndDropController?.dragMimeTypes?.slice(); + const dropMimeTypes = options.dragAndDropController?.dropMimeTypes?.slice(); + proxy.$registerTreeDataProvider(treeViewId, { canSelectMany: options.canSelectMany, dragMimeTypes, dropMimeTypes }); this.toDispose.push(Disposable.create(() => this.proxy.$unregisterTreeDataProvider(treeViewId))); - - if (treeDataProvider.onDidChangeTreeData) { - treeDataProvider.onDidChangeTreeData(() => { - this.pendingRefresh = proxy.$refresh(treeViewId); - }); - } + options.treeDataProvider.onDidChangeTreeData?.(() => { + this.pendingRefresh = proxy.$refresh(treeViewId); + }); } dispose(): void { @@ -274,8 +275,7 @@ class TreeViewExtImpl implements Disposable { } getTreeItem(treeItemId: string): T | undefined { - const element = this.nodes.get(treeItemId); - return element && element.value; + return this.nodes.get(treeItemId)?.value; } /** @@ -292,15 +292,14 @@ class TreeViewExtImpl implements Disposable { // root return []; } - const result = this.treeDataProvider.getParent && await this.treeDataProvider.getParent(element); - const parent = result ? result : undefined; + const parent = await this.options.treeDataProvider.getParent?.(element) ?? undefined; const chain = await this.calculateRevealParentChain(parent); if (!chain) { // parents are inconsistent 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); } @@ -310,7 +309,8 @@ class TreeViewExtImpl implements Disposable { // If not in cache, getChildren fills this.nodes and generate ids for them which are needed later const children = cachedParentNode?.children || await this.getChildren(parentId); if (!children) { - return undefined; // parent is inconsistent + // parent is inconsistent + return undefined; } const idLabel = this.getTreeItemIdLabel(treeItem); let possibleIndex = children.length; @@ -367,13 +367,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); @@ -490,13 +490,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; @@ -506,7 +506,7 @@ class TreeViewExtImpl implements Disposable { } hasResolveTreeItem(): boolean { - return !!this.treeDataProvider.resolveTreeItem; + return !!this.options.treeDataProvider.resolveTreeItem; } private selectedItemIds = new Set(); @@ -559,9 +559,9 @@ class TreeViewExtImpl implements Disposable { treeItems.push(item); } } - if (this.dragAndDropController && this.dragAndDropController.handleDrag) { + if (this.options.dragAndDropController?.handleDrag) { this.localDataTransfer.clear(); - await this.dragAndDropController.handleDrag(treeItems, this.localDataTransfer, token); + await this.options.dragAndDropController.handleDrag(treeItems, this.localDataTransfer, token); const uriList = await this.localDataTransfer.get('text/uri-list')?.asString(); if (uriList) { return uriList.split('\n').map(str => URI.parse(str)); @@ -573,7 +573,7 @@ class TreeViewExtImpl implements Disposable { async handleDrop(treeItemId: string | undefined, dataTransferItems: [string, string | DataTransferFileDTO][], token: CancellationToken): Promise { const treeItem = treeItemId ? this.getTreeItem(treeItemId) : undefined; const dropTransfer = new DataTransfer(); - if (this.dragAndDropController && this.dragAndDropController.handleDrop) { + if (this.options.dragAndDropController?.handleDrop) { this.localDataTransfer.forEach((item, type) => { dropTransfer.set(type, item); }); @@ -599,8 +599,7 @@ class TreeViewExtImpl implements Disposable { } } } - - return Promise.resolve(this.dragAndDropController.handleDrop(treeItem, dropTransfer, token)); + return this.options.dragAndDropController.handleDrop(treeItem, dropTransfer, token); } } } diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 7902f09e533cf..eecee8fc182fd 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -5670,6 +5670,12 @@ export module '@theia/plugin' { * An optional interface to implement drag and drop in the tree view. */ dragAndDropController?: TreeDragAndDropController; + /** + * 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; } /**