diff --git a/CHANGELOG.md b/CHANGELOG.md index d780ce9f5d247..68b9cb0fd1d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## v1.31.0 - [plugin] added support for the `InlineValues` feature [#11729](https://github.com/eclipse-theia/theia/pull/11729) - Contributed on behalf of STMicroelectronics +- [plugin] Added support for `resolveTreeItem` of `TreeDataProvider` [#11708](https://github.com/eclipse-theia/theia/pull/11708) - Contributed on behalf of STMicroelectronics [Breaking Changes:](#breaking_changes_1.31.0) diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 4d4b78024b472..46c468c539977 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -729,6 +729,8 @@ export interface TreeViewsMain { export interface TreeViewsExt { $getChildren(treeViewId: string, treeItemId: string | undefined): Promise; + $hasResolveTreeItem(treeViewId: string): Promise; + $resolveTreeItem(treeViewId: string, treeItemId: string, token: CancellationToken): Promise; $setExpanded(treeViewId: string, treeItemId: string, expanded: boolean): Promise; $setSelection(treeViewId: string, treeItemIds: string[]): Promise; $setVisible(treeViewId: string, visible: boolean): Promise; @@ -752,7 +754,7 @@ export interface TreeViewItem { resourceUri?: UriComponents; - tooltip?: string; + tooltip?: string | MarkdownString; collapsibleState?: TreeViewItemCollapsibleState; 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 c4e3233a39251..efc0154068184 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 @@ -49,6 +49,9 @@ import { AccessibilityInformation } from '@theia/plugin'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; import { DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator'; import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; +import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common'; +import { mixin } from '../../../common/types'; +import { Deferred } from '@theia/core/lib/common/promise-util'; export const TREE_NODE_HYPERLINK = 'theia-TreeNodeHyperlink'; export const VIEW_ITEM_CONTEXT_MENU: MenuPath = ['view-item-context-menu']; @@ -64,7 +67,7 @@ export interface TreeViewNode extends SelectableTreeNode, DecoratedTreeNode { command?: Command; resourceUri?: string; themeIcon?: ThemeIcon; - tooltip?: string; + tooltip?: string | MarkdownString; // eslint-disable-next-line @typescript-eslint/no-explicit-any description?: string | boolean | any; accessibilityInformation?: AccessibilityInformation; @@ -75,6 +78,78 @@ export namespace TreeViewNode { } } +export class ResolvableTreeViewNode implements TreeViewNode { + contextValue?: string; + command?: Command; + resourceUri?: string; + themeIcon?: ThemeIcon; + tooltip?: string | MarkdownString; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + description?: string | boolean | any; + accessibilityInformation?: AccessibilityInformation; + selected: boolean; + focus?: boolean; + id: string; + name?: string; + icon?: string; + visible?: boolean; + parent: Readonly; + previousSibling?: TreeNode; + nextSibling?: TreeNode; + busy?: number; + decorationData: WidgetDecoration.Data; + + resolve: ((token: CancellationToken) => Promise); + + private _resolved = false; + private resolving: Deferred | undefined; + + constructor(treeViewNode: Partial, resolve: (token: CancellationToken) => Promise) { + mixin(this, treeViewNode); + this.resolve = async (token: CancellationToken) => { + if (this.resolving) { + return this.resolving.promise; + } + if (!this._resolved) { + this.resolving = new Deferred(); + const resolvedTreeItem = await resolve(token); + if (resolvedTreeItem) { + this.command = this.command ?? resolvedTreeItem.command; + this.tooltip = this.tooltip ?? resolvedTreeItem.tooltip; + } + this.resolving.resolve(); + this.resolving = undefined; + } + if (!token.isCancellationRequested) { + this._resolved = true; + } + }; + } + + reset(): void { + this._resolved = false; + this.resolving = undefined; + this.command = undefined; + this.tooltip = undefined; + } + + get resolved(): boolean { + return this._resolved; + } +} + +export class ResolvableCompositeTreeViewNode extends ResolvableTreeViewNode implements CompositeTreeViewNode { + expanded: boolean; + children: readonly TreeNode[]; + constructor( + treeViewNode: Pick & Partial, + resolve: (token: CancellationToken) => Promise) { + super(treeViewNode, resolve); + this.expanded = treeViewNode.expanded; + this.children = treeViewNode.children; + } +} + export interface CompositeTreeViewNode extends TreeViewNode, ExpandableTreeNode, CompositeTreeNode { // eslint-disable-next-line @typescript-eslint/no-explicit-any description?: string | boolean | any; @@ -108,14 +183,24 @@ export class PluginTree extends TreeImpl { private _proxy: TreeViewsExt | undefined; private _viewInfo: View | undefined; private _isEmpty: boolean; + private _hasTreeItemResolve: Promise = Promise.resolve(false); set proxy(proxy: TreeViewsExt | undefined) { this._proxy = proxy; + if (proxy) { + this._hasTreeItemResolve = proxy.$hasResolveTreeItem(this.identifier.id); + } else { + this._hasTreeItemResolve = Promise.resolve(false); + } } get proxy(): TreeViewsExt | undefined { return this._proxy; } + get hasTreeItemResolve(): Promise { + return this._hasTreeItemResolve; + } + set viewInfo(viewInfo: View) { this._viewInfo = viewInfo; } @@ -129,7 +214,8 @@ export class PluginTree extends TreeImpl { return super.resolveChildren(parent); } const children = await this.fetchChildren(this._proxy, parent); - return children.map(value => this.createTreeNode(value, parent)); + const hasResolve = await this.hasTreeItemResolve; + return children.map(value => hasResolve ? this.createResolvableTreeNode(value, parent) : this.createTreeNode(value, parent)); } protected async fetchChildren(proxy: TreeViewsExt, parent: CompositeTreeNode): Promise { @@ -152,22 +238,7 @@ export class PluginTree extends TreeImpl { } protected createTreeNode(item: TreeViewItem, parent: CompositeTreeNode): TreeNode { - const decorationData = this.toDecorationData(item); - const icon = this.toIconClass(item); - const resourceUri = item.resourceUri && URI.revive(item.resourceUri).toString(); - const themeIcon = item.themeIcon ? item.themeIcon : item.collapsibleState !== TreeViewItemCollapsibleState.None ? { id: 'folder' } : undefined; - const update: Partial = { - name: item.label, - decorationData, - icon, - description: item.description, - themeIcon, - resourceUri, - tooltip: item.tooltip, - contextValue: item.contextValue, - command: item.command, - accessibilityInformation: item.accessibilityInformation, - }; + const update: Partial = this.createTreeNodeUpdate(item); const node = this.getNode(item.id); if (item.collapsibleState !== undefined && item.collapsibleState !== TreeViewItemCollapsibleState.None) { if (CompositeTreeViewNode.is(node)) { @@ -195,6 +266,66 @@ export class PluginTree extends TreeImpl { }, update); } + /** Creates a resolvable tree node. If a node already exists, reset it because the underlying TreeViewItem might have been disposed in the backend. */ + protected createResolvableTreeNode(item: TreeViewItem, parent: CompositeTreeNode): TreeNode { + const update: Partial = this.createTreeNodeUpdate(item); + const node = this.getNode(item.id); + + // Node is a composite node that might contain children + if (item.collapsibleState !== undefined && item.collapsibleState !== TreeViewItemCollapsibleState.None) { + // Reuse existing composite node and reset it + if (node instanceof ResolvableCompositeTreeViewNode) { + node.reset(); + return Object.assign(node, update); + } + // Create new composite node + const compositeNode = Object.assign({ + id: item.id, + parent, + visible: true, + selected: false, + expanded: TreeViewItemCollapsibleState.Expanded === item.collapsibleState, + children: [], + command: item.command + }, update); + return new ResolvableCompositeTreeViewNode(compositeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token)); + } + + // Node is a leaf + // Reuse existing node and reset it. + if (node instanceof ResolvableTreeViewNode && !ExpandableTreeNode.is(node)) { + node.reset(); + return Object.assign(node, update); + } + const treeNode = Object.assign({ + id: item.id, + parent, + visible: true, + selected: false, + command: item.command, + }, update); + return new ResolvableTreeViewNode(treeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token)); + } + + protected createTreeNodeUpdate(item: TreeViewItem): Partial { + const decorationData = this.toDecorationData(item); + const icon = this.toIconClass(item); + const resourceUri = item.resourceUri && URI.revive(item.resourceUri).toString(); + const themeIcon = item.themeIcon ? item.themeIcon : item.collapsibleState !== TreeViewItemCollapsibleState.None ? { id: 'folder' } : undefined; + return { + name: item.label, + decorationData, + icon, + description: item.description, + themeIcon, + resourceUri, + tooltip: item.tooltip, + contextValue: item.contextValue, + command: item.command, + accessibilityInformation: item.accessibilityInformation, + }; + } + protected toDecorationData(item: TreeViewItem): WidgetDecoration.Data { let decoration: WidgetDecoration.Data = {}; if (item.highlights) { @@ -233,6 +364,10 @@ export class PluginTreeModel extends TreeModelImpl { return this.tree.proxy; } + get hasTreeItemResolve(): Promise { + return this.tree.hasTreeItemResolve; + } + set viewInfo(viewInfo: View) { this.tree.viewInfo = viewInfo; } @@ -245,6 +380,12 @@ export class PluginTreeModel extends TreeModelImpl { return this.tree.onDidChangeWelcomeState; } + override doOpenNode(node: TreeNode): void { + super.doOpenNode(node); + if (node instanceof ResolvableTreeViewNode) { + node.resolve(CancellationToken.None); + } + } } @injectable() @@ -339,7 +480,40 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { }; } - if (node.tooltip && MarkdownString.is(node.tooltip)) { + const elementRef = React.createRef>(); + if (!node.tooltip && node instanceof ResolvableTreeViewNode) { + let configuredTip = false; + let source: CancellationTokenSource | undefined; + attrs = { + ...attrs, + 'data-for': this.tooltipService.tooltipId, + onMouseLeave: () => source?.cancel(), + onMouseEnter: async () => { + if (configuredTip) { + return; + } + if (!node.resolved) { + source = new CancellationTokenSource(); + const token = source.token; + await node.resolve(token); + if (token.isCancellationRequested) { + return; + } + } + if (elementRef.current) { + // Set the resolved tooltip. After an HTML element was created data-* properties must be accessed via the dataset + elementRef.current.dataset.tip = MarkdownString.is(node.tooltip) ? this.markdownIt.render(node.tooltip.value) : node.tooltip; + this.tooltipService.update(); + configuredTip = true; + // Manually fire another mouseenter event to get react-tooltip to update the tooltip content. + // Without this, the resolved tooltip is only shown after re-entering the tree item with the mouse. + elementRef.current.dispatchEvent(new MouseEvent('mouseenter')); + } else { + console.error(`Could not set resolved tooltip for tree node '${node.id}' because its React Ref was not set.`); + } + } + }; + } else if (MarkdownString.is(node.tooltip)) { // Render markdown in custom tooltip const tooltip = this.markdownIt.render(node.tooltip.value); @@ -375,7 +549,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { if (description) { children.push({description}); } - return React.createElement('div', attrs, ...children); + return
{...children}
; } protected override renderTailDecorations(node: TreeViewNode, props: NodeProps): React.ReactNode { @@ -436,17 +610,18 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { protected override tapNode(node?: TreeNode): void { super.tapNode(node); - const commandMap = this.findCommands(node); - if (commandMap.size > 0) { - this.tryExecuteCommandMap(commandMap); - } else if (node && this.isExpandable(node)) { - this.model.toggleNodeExpansion(node); - } + this.findCommands(node).then(commandMap => { + if (commandMap.size > 0) { + this.tryExecuteCommandMap(commandMap); + } else if (node && this.isExpandable(node)) { + this.model.toggleNodeExpansion(node); + } + }); } // execute TreeItem.command if present - protected tryExecuteCommand(node?: TreeNode): void { - this.tryExecuteCommandMap(this.findCommands(node)); + protected async tryExecuteCommand(node?: TreeNode): Promise { + this.tryExecuteCommandMap(await this.findCommands(node)); } protected tryExecuteCommandMap(commandMap: Map): void { @@ -455,9 +630,23 @@ export class TreeViewWidget extends TreeViewWelcomeWidget { }); } - protected findCommands(node?: TreeNode): Map { + protected async findCommands(node?: TreeNode): Promise> { const commandMap = new Map(); const treeNodes = (node ? [node] : this.model.selectedNodes) as TreeViewNode[]; + if (await this.model.hasTreeItemResolve) { + const cancellationToken = new CancellationTokenSource().token; + // Resolve all resolvable nodes that don't have a command and haven't been resolved. + const allResolved = Promise.all(treeNodes.map(maybeNeedsResolve => { + if (!maybeNeedsResolve.command && maybeNeedsResolve instanceof ResolvableTreeViewNode && !maybeNeedsResolve.resolved) { + return maybeNeedsResolve.resolve(cancellationToken).catch(err => { + console.error(`Failed to resolve tree item '${maybeNeedsResolve.id}'`, err); + }); + } + return Promise.resolve(maybeNeedsResolve); + })); + // Only need to wait but don't need the values because tree items are resolved in place. + await allResolved; + } for (const treeNode of treeNodes) { if (treeNode && treeNode.command) { commandMap.set(treeNode.command.id, treeNode.command.arguments || []); diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts index 92e7da2e64a93..9e4954ccf09ef 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 + TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent, CancellationToken } from '@theia/plugin'; // TODO: extract `@theia/util` for event, disposable, cancellation and common types // don't use @theia/core directly from plugin host @@ -122,6 +122,14 @@ export class TreeViewsExtImpl implements TreeViewsExt { return treeView.getChildren(treeItemId); } + async $resolveTreeItem(treeViewId: string, treeItemId: string, token: CancellationToken): Promise { + return this.getTreeView(treeViewId).resolveTreeItem(treeItemId, token); + } + + async $hasResolveTreeItem(treeViewId: string): Promise { + return this.getTreeView(treeViewId).hasResolveTreeItem(); + } + async $setExpanded(treeViewId: string, treeItemId: string, expanded: boolean): Promise { const treeView = this.getTreeView(treeViewId); @@ -152,6 +160,12 @@ export class TreeViewsExtImpl implements TreeViewsExt { interface TreeExtNode extends Disposable { id: string + /** Collection of disposables. Must be disposed by an instance's `dispose` implementation. */ + disposables: DisposableCollection; + /** The original `TreeItem` provided by the plugin's tree data provider. */ + pluginTreeItem?: TreeItem; + /** The `TreeViewItem` used on the main side to render the tree node. */ + treeViewItem?: TreeViewItem; value?: T children?: TreeExtNode[] } @@ -333,7 +347,8 @@ class TreeViewExtImpl implements Disposable { // place root in the cache if (parentId === '') { - this.nodes.set(parentId, { id: '', dispose: () => { } }); + const rootNodeDisposables = new DisposableCollection(); + 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); @@ -356,7 +371,9 @@ class TreeViewExtImpl implements Disposable { const toDisposeElement = new DisposableCollection(); const node: TreeExtNode = { id, + pluginTreeItem: treeItem, value, + disposables: toDisposeElement, dispose: () => toDisposeElement.dispose() }; if (parentNode) { @@ -393,6 +410,7 @@ class TreeViewExtImpl implements Disposable { command: this.commandsConverter.toSafeCommand(treeItem.command, toDisposeElement), accessibilityInformation: treeItem.accessibilityInformation } as TreeViewItem; + node.treeViewItem = treeViewItem; return treeViewItem; }); @@ -455,6 +473,26 @@ class TreeViewExtImpl implements Disposable { } } + async resolveTreeItem(treeItemId: string, token: CancellationToken): Promise { + if (!this.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; + node.treeViewItem.command = this.commandsConverter.toSafeCommand(resolved.command, node.disposables); + node.treeViewItem.tooltip = resolved.tooltip; + return node.treeViewItem; + } + + return undefined; + } + + hasResolveTreeItem(): boolean { + return !!this.treeDataProvider.resolveTreeItem; + } + private selectedItemIds = new Set(); get selectedElements(): T[] { const items: T[] = []; diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 8d3a632aab7db..ce617f13ae43f 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -5586,6 +5586,29 @@ export module '@theia/plugin' { * @return Parent of `element`. */ getParent?(element: T): ProviderResult; + + /** + * Called on hover to resolve the {@link TreeItem.tooltip TreeItem} property if it is undefined. + * Called on tree item click/open to resolve the {@link TreeItem.command TreeItem} property if it is undefined. + * Only properties that were undefined can be resolved in `resolveTreeItem`. + * Functionality may be expanded later to include being called to resolve other missing + * properties on selection and/or on open. + * + * Will only ever be called once per TreeItem. + * + * onDidChangeTreeData should not be triggered from within resolveTreeItem. + * + * *Note* that this function is called when tree items are already showing in the UI. + * Because of that, no property that changes the presentation (label, description, etc.) + * can be changed. + * + * @param item Undefined properties of `item` should be set then `item` should be returned. + * @param element The object associated with the TreeItem. + * @param token A cancellation token. + * @return The resolved tree item or a thenable that resolves to such. It is OK to return the given + * `item`. When no result is returned, the given `item` will be used. + */ + resolveTreeItem?(item: TreeItem, element: T, token: CancellationToken): ProviderResult; } export class TreeItem {