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 eclipse-theia#9074

Contributed on behalf of STMicroelectronics

Signed-off-by: Thomas Mäder <[email protected]>
  • Loading branch information
tsmaeder committed Jan 19, 2023
1 parent 945a97b commit fc1c9b9
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 60 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): void;
$registerTreeDataProvider(treeViewId: string, canSelectMany: boolean | undefined): void;
$unregisterTreeDataProvider(treeViewId: string): void;
$refresh(treeViewId: string): Promise<void>;
$reveal(treeViewId: string, elementParentChain: string[], options: TreeViewRevealOptions): Promise<any>;
Expand Down Expand Up @@ -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;
}
}

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 @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
51 changes: 28 additions & 23 deletions packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -161,8 +161,9 @@ export namespace CompositeTreeViewNode {
}

@injectable()
export class TreeViewWidgetIdentifier {
export class TreeViewOptions {
id: string;
multiSelect?: boolean;
}

@injectable()
Expand All @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -220,7 +221,7 @@ export class PluginTree extends TreeImpl {

protected async fetchChildren(proxy: TreeViewsExt, parent: CompositeTreeNode): Promise<TreeViewItem[]> {
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) {
Expand All @@ -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 [];
Expand Down Expand Up @@ -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
Expand All @@ -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<TreeViewNode> {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 <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 Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions packages/plugin-ext/src/main/browser/view/tree-views-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ export class TreeViewsMainImpl implements TreeViewsMain, Disposable {
this.toDispose.dispose();
}

async $registerTreeDataProvider(treeViewId: string): Promise<void> {
async $registerTreeDataProvider(treeViewId: string, canSelectMany: boolean | undefined): Promise<void> {
this.treeViewProviders.set(treeViewId, this.viewRegistry.registerViewDataProvider(treeViewId, async ({ state, viewInfo }) => {
const widget = await this.widgetManager.getOrCreateWidget<TreeViewWidget>(PLUGIN_VIEW_DATA_FACTORY_ID, { id: treeViewId });
const widget = await this.widgetManager.getOrCreateWidget<TreeViewWidget>(PLUGIN_VIEW_DATA_FACTORY_ID, {
id: treeViewId,
multiSelect: canSelectMany
});
widget.model.viewInfo = viewInfo;
if (state) {
widget.restoreState(state);
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-ext/src/plugin/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ export function createAPIFactory(
registerTreeDataProvider<T>(viewId: string, treeDataProvider: theia.TreeDataProvider<T>): Disposable {
return treeViewsExt.registerTreeDataProvider(plugin, viewId, treeDataProvider);
},
createTreeView<T>(viewId: string, options: { treeDataProvider: theia.TreeDataProvider<T> }): theia.TreeView<T> {
createTreeView<T>(viewId: string, options: theia.TreeViewOptions<T>): theia.TreeView<T> {
return treeViewsExt.createTreeView(plugin, viewId, options);
},
withScmProgress<R>(task: (progress: theia.Progress<number>) => Thenable<R>) {
Expand Down
Loading

0 comments on commit fc1c9b9

Please sign in to comment.