Skip to content

Commit

Permalink
Support multi-selection of tree items in tree views
Browse files Browse the repository at this point in the history
Fixes #9074

Contributed on behalf of STMicroelectronics

Signed-off-by: Thomas Mäder <[email protected]>
  • Loading branch information
tsmaeder committed Jan 23, 2023
1 parent ca59f1d commit b96bb28
Show file tree
Hide file tree
Showing 7 changed files with 74 additions and 52 deletions.
14 changes: 7 additions & 7 deletions packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ export interface TreeViewRevealOptions {
}

export interface TreeViewsMain {
$registerTreeDataProvider(treeViewId: string, dragMimetypes: string[] | undefined, dropMimetypes: string[] | undefined): void;
$registerTreeDataProvider(treeViewId: string, canSelectMany: boolean | undefined, dragMimetypes: string[] | undefined, dropMimetypes: string[] | undefined): void;
$readDroppedFile(contentId: string): Promise<BinaryBuffer>;
$unregisterTreeDataProvider(treeViewId: string): void;
$refresh(treeViewId: string): Promise<void>;
Expand Down Expand Up @@ -785,13 +785,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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand Down
25 changes: 15 additions & 10 deletions packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -164,6 +164,7 @@ export namespace CompositeTreeViewNode {
@injectable()
export class TreeViewWidgetOptions {
id: string;
multiSelect: boolean | undefined;
dragMimeTypes: string[] | undefined;
dropMimeTypes: string[] | undefined;
}
Expand Down Expand Up @@ -699,38 +700,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 <React.Fragment>
{inlineCommands.length > 0 && <div className={TREE_NODE_SEGMENT_CLASS + ' flex'}>
{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))}
</div>}
{tailDecorations !== undefined && <div className={TREE_NODE_SEGMENT_CLASS + ' flex'}>{tailDecorations}</div>}
</React.Fragment>;
});
}

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 <div key={index} className={className} title={node.label} tabIndex={tabIndex} onClick={e => {
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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-ext/src/main/browser/view/tree-views-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ export class TreeViewsMainImpl implements TreeViewsMain, Disposable {
this.toDispose.dispose();
}

async $registerTreeDataProvider(treeViewId: string, dragMimeTypes: string[] | undefined, dropMimeTypes: string[] | undefined): Promise<void> {
async $registerTreeDataProvider(treeViewId: string, canSelectMany: boolean | undefined, dragMimeTypes: string[] | undefined, dropMimeTypes: string[] | undefined): Promise<void> {
this.treeViewProviders.set(treeViewId, this.viewRegistry.registerViewDataProvider(treeViewId, async ({ state, viewInfo }) => {
const options: TreeViewWidgetOptions = {
id: treeViewId,
multiSelect: canSelectMany,
dragMimeTypes,
dropMimeTypes
};
Expand Down
61 changes: 34 additions & 27 deletions packages/plugin-ext/src/plugin/tree/tree-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand All @@ -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);
}
});
}
Expand All @@ -60,6 +62,12 @@ export class TreeViewsExtImpl implements TreeViewsExt {
return this.getTreeView(treeViewId).handleDrop!(treeItemId, dataTransferItems, token);
}

protected toTreeItem(arg: TreeViewItemReference): any | undefined {
const { viewId, itemId } = arg;
const treeView = this.treeViews.get(viewId);
return treeView && treeView.getTreeItem(itemId);
}

registerTreeDataProvider<T>(plugin: Plugin, treeViewId: string, treeDataProvider: TreeDataProvider<T>): PluginDisposable {
const treeView = this.createTreeView(plugin, treeViewId, { treeDataProvider });

Expand All @@ -69,12 +77,12 @@ export class TreeViewsExtImpl implements TreeViewsExt {
});
}

createTreeView<T>(plugin: Plugin, treeViewId: string, options: { treeDataProvider: TreeDataProvider<T>, dragAndDropController?: TreeDragAndDropController<T> }): TreeView<T> {
createTreeView<T>(plugin: Plugin, treeViewId: string, options: TreeViewOptions<T>): TreeView<T> {
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<T>(plugin, treeViewId, options, this.proxy, this.commandRegistry.converter);
this.treeViews.set(treeViewId, treeView);

return {
Expand Down Expand Up @@ -209,20 +217,19 @@ class TreeViewExtImpl<T> implements Disposable {
constructor(
private plugin: Plugin,
private treeViewId: string,
private treeDataProvider: TreeDataProvider<T>,
private dragAndDropController: TreeDragAndDropController<T> | undefined,

private options: TreeViewOptions<T>,
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);
const dragTypes = options.dragAndDropController?.dragMimeTypes ? [...options.dragAndDropController.dragMimeTypes] : undefined;
const dropTypes = options.dragAndDropController?.dropMimeTypes ? [...options.dragAndDropController.dropMimeTypes] : undefined;

proxy.$registerTreeDataProvider(treeViewId, options.canSelectMany, dragTypes, dropTypes);
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);
});
}
Expand Down Expand Up @@ -292,15 +299,15 @@ class TreeViewExtImpl<T> 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) {
// 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);
}
Expand Down Expand Up @@ -367,13 +374,13 @@ class TreeViewExtImpl<T> 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);
Expand Down Expand Up @@ -490,13 +497,13 @@ class TreeViewExtImpl<T> implements Disposable {
}

async resolveTreeItem(treeItemId: string, token: CancellationToken): Promise<TreeViewItem | undefined> {
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;
Expand All @@ -506,7 +513,7 @@ class TreeViewExtImpl<T> implements Disposable {
}

hasResolveTreeItem(): boolean {
return !!this.treeDataProvider.resolveTreeItem;
return !!this.options.treeDataProvider.resolveTreeItem;
}

private selectedItemIds = new Set<string>();
Expand Down Expand Up @@ -559,9 +566,9 @@ class TreeViewExtImpl<T> implements Disposable {
treeItems.push(item);
}
}
if (this.dragAndDropController && this.dragAndDropController.handleDrag) {
if (this.options.dragAndDropController && 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));
Expand All @@ -573,7 +580,7 @@ class TreeViewExtImpl<T> implements Disposable {
async handleDrop(treeItemId: string | undefined, dataTransferItems: [string, string | DataTransferFileDTO][], token: CancellationToken): Promise<void> {
const treeItem = treeItemId ? this.getTreeItem(treeItemId) : undefined;
const dropTransfer = new DataTransfer();
if (this.dragAndDropController && this.dragAndDropController.handleDrop) {
if (this.options.dragAndDropController && this.options.dragAndDropController.handleDrop) {
this.localDataTransfer.forEach((item, type) => {
dropTransfer.set(type, item);
});
Expand All @@ -600,7 +607,7 @@ class TreeViewExtImpl<T> implements Disposable {
}
}

return Promise.resolve(this.dragAndDropController.handleDrop(treeItem, dropTransfer, token));
return Promise.resolve(this.options.dragAndDropController.handleDrop(treeItem, dropTransfer, token));
}
}
}
6 changes: 6 additions & 0 deletions packages/plugin/src/theia.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5670,6 +5670,12 @@ export module '@theia/plugin' {
* An optional interface to implement drag and drop in the tree view.
*/
dragAndDropController?: TreeDragAndDropController<T>;
/**
* 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;
}

/**
Expand Down

0 comments on commit b96bb28

Please sign in to comment.