Skip to content

Commit edfb8ab

Browse files
committed
vscode: Add support for resolveTreeItem to TreeDataProvider
* Extend `theia.d.ts` with optional method `resolveTreeItem` in `TreeDataProvider` * Add infrastructure on ext side to resolve tree items * Add resolvable tree view node classes * Add resolvement for tooltips and commands * Align TreeViewItem and TreeViewNode tooltip property type to include `MarkdownString` Implements #11147 Contributed on behalf of STMicroelectronics Signed-off-by: Lucas Koehler <[email protected]>
1 parent e96bb5b commit edfb8ab

File tree

5 files changed

+285
-32
lines changed

5 files changed

+285
-32
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
## v1.31.0
88

99
- [plugin] added support for the `InlineValues` feature [#11729](https://github.com/eclipse-theia/theia/pull/11729) - Contributed on behalf of STMicroelectronics
10+
- [plugin] Added support for `resolveTreeItem` of `TreeDataProvider` [#11708](https://github.com/eclipse-theia/theia/pull/11708) - Contributed on behalf of STMicroelectronics
1011

1112
<a name="breaking_changes_1.31.0">[Breaking Changes:](#breaking_changes_1.31.0)</a>
1213

packages/plugin-ext/src/common/plugin-api-rpc.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,8 @@ export interface TreeViewsMain {
729729

730730
export interface TreeViewsExt {
731731
$getChildren(treeViewId: string, treeItemId: string | undefined): Promise<TreeViewItem[] | undefined>;
732+
$hasResolveTreeItem(treeViewId: string): Promise<boolean>;
733+
$resolveTreeItem(treeViewId: string, treeItemId: string, token: CancellationToken): Promise<TreeViewItem | undefined>;
732734
$setExpanded(treeViewId: string, treeItemId: string, expanded: boolean): Promise<any>;
733735
$setSelection(treeViewId: string, treeItemIds: string[]): Promise<void>;
734736
$setVisible(treeViewId: string, visible: boolean): Promise<void>;
@@ -752,7 +754,7 @@ export interface TreeViewItem {
752754

753755
resourceUri?: UriComponents;
754756

755-
tooltip?: string;
757+
tooltip?: string | MarkdownString;
756758

757759
collapsibleState?: TreeViewItemCollapsibleState;
758760

packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx

Lines changed: 218 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ import { AccessibilityInformation } from '@theia/plugin';
4949
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
5050
import { DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator';
5151
import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration';
52+
import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common';
53+
import { mixin } from '../../../common/types';
54+
import { Deferred } from '@theia/core/lib/common/promise-util';
5255

5356
export const TREE_NODE_HYPERLINK = 'theia-TreeNodeHyperlink';
5457
export const VIEW_ITEM_CONTEXT_MENU: MenuPath = ['view-item-context-menu'];
@@ -64,7 +67,7 @@ export interface TreeViewNode extends SelectableTreeNode, DecoratedTreeNode {
6467
command?: Command;
6568
resourceUri?: string;
6669
themeIcon?: ThemeIcon;
67-
tooltip?: string;
70+
tooltip?: string | MarkdownString;
6871
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6972
description?: string | boolean | any;
7073
accessibilityInformation?: AccessibilityInformation;
@@ -75,6 +78,78 @@ export namespace TreeViewNode {
7578
}
7679
}
7780

81+
export class ResolvableTreeViewNode implements TreeViewNode {
82+
contextValue?: string;
83+
command?: Command;
84+
resourceUri?: string;
85+
themeIcon?: ThemeIcon;
86+
tooltip?: string | MarkdownString;
87+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
88+
description?: string | boolean | any;
89+
accessibilityInformation?: AccessibilityInformation;
90+
selected: boolean;
91+
focus?: boolean;
92+
id: string;
93+
name?: string;
94+
icon?: string;
95+
visible?: boolean;
96+
parent: Readonly<CompositeTreeNode>;
97+
previousSibling?: TreeNode;
98+
nextSibling?: TreeNode;
99+
busy?: number;
100+
decorationData: WidgetDecoration.Data;
101+
102+
resolve: ((token: CancellationToken) => Promise<void>);
103+
104+
private _resolved = false;
105+
private resolving: Deferred<void> | undefined;
106+
107+
constructor(treeViewNode: Partial<TreeViewNode>, resolve: (token: CancellationToken) => Promise<TreeViewItem | undefined>) {
108+
mixin(this, treeViewNode);
109+
this.resolve = async (token: CancellationToken) => {
110+
if (this.resolving) {
111+
return this.resolving.promise;
112+
}
113+
if (!this._resolved) {
114+
this.resolving = new Deferred();
115+
const resolvedTreeItem = await resolve(token);
116+
if (resolvedTreeItem) {
117+
this.command = this.command ?? resolvedTreeItem.command;
118+
this.tooltip = this.tooltip ?? resolvedTreeItem.tooltip;
119+
}
120+
this.resolving.resolve();
121+
this.resolving = undefined;
122+
}
123+
if (!token.isCancellationRequested) {
124+
this._resolved = true;
125+
}
126+
};
127+
}
128+
129+
reset(): void {
130+
this._resolved = false;
131+
this.resolving = undefined;
132+
this.command = undefined;
133+
this.tooltip = undefined;
134+
}
135+
136+
get resolved(): boolean {
137+
return this._resolved;
138+
}
139+
}
140+
141+
export class ResolvableCompositeTreeViewNode extends ResolvableTreeViewNode implements CompositeTreeViewNode {
142+
expanded: boolean;
143+
children: readonly TreeNode[];
144+
constructor(
145+
treeViewNode: Pick<CompositeTreeViewNode, 'children' | 'expanded'> & Partial<TreeViewNode>,
146+
resolve: (token: CancellationToken) => Promise<TreeViewItem | undefined>) {
147+
super(treeViewNode, resolve);
148+
this.expanded = treeViewNode.expanded;
149+
this.children = treeViewNode.children;
150+
}
151+
}
152+
78153
export interface CompositeTreeViewNode extends TreeViewNode, ExpandableTreeNode, CompositeTreeNode {
79154
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80155
description?: string | boolean | any;
@@ -108,14 +183,24 @@ export class PluginTree extends TreeImpl {
108183
private _proxy: TreeViewsExt | undefined;
109184
private _viewInfo: View | undefined;
110185
private _isEmpty: boolean;
186+
private _hasTreeItemResolve: Promise<boolean> = Promise.resolve(false);
111187

112188
set proxy(proxy: TreeViewsExt | undefined) {
113189
this._proxy = proxy;
190+
if (proxy) {
191+
this._hasTreeItemResolve = proxy.$hasResolveTreeItem(this.identifier.id);
192+
} else {
193+
this._hasTreeItemResolve = Promise.resolve(false);
194+
}
114195
}
115196
get proxy(): TreeViewsExt | undefined {
116197
return this._proxy;
117198
}
118199

200+
get hasTreeItemResolve(): Promise<boolean> {
201+
return this._hasTreeItemResolve;
202+
}
203+
119204
set viewInfo(viewInfo: View) {
120205
this._viewInfo = viewInfo;
121206
}
@@ -129,7 +214,8 @@ export class PluginTree extends TreeImpl {
129214
return super.resolveChildren(parent);
130215
}
131216
const children = await this.fetchChildren(this._proxy, parent);
132-
return children.map(value => this.createTreeNode(value, parent));
217+
const hasResolve = await this.hasTreeItemResolve;
218+
return children.map(value => hasResolve ? this.createResolvableTreeNode(value, parent) : this.createTreeNode(value, parent));
133219
}
134220

135221
protected async fetchChildren(proxy: TreeViewsExt, parent: CompositeTreeNode): Promise<TreeViewItem[]> {
@@ -152,22 +238,7 @@ export class PluginTree extends TreeImpl {
152238
}
153239

154240
protected createTreeNode(item: TreeViewItem, parent: CompositeTreeNode): TreeNode {
155-
const decorationData = this.toDecorationData(item);
156-
const icon = this.toIconClass(item);
157-
const resourceUri = item.resourceUri && URI.revive(item.resourceUri).toString();
158-
const themeIcon = item.themeIcon ? item.themeIcon : item.collapsibleState !== TreeViewItemCollapsibleState.None ? { id: 'folder' } : undefined;
159-
const update: Partial<TreeViewNode> = {
160-
name: item.label,
161-
decorationData,
162-
icon,
163-
description: item.description,
164-
themeIcon,
165-
resourceUri,
166-
tooltip: item.tooltip,
167-
contextValue: item.contextValue,
168-
command: item.command,
169-
accessibilityInformation: item.accessibilityInformation,
170-
};
241+
const update: Partial<TreeViewNode> = this.createTreeNodeUpdate(item);
171242
const node = this.getNode(item.id);
172243
if (item.collapsibleState !== undefined && item.collapsibleState !== TreeViewItemCollapsibleState.None) {
173244
if (CompositeTreeViewNode.is(node)) {
@@ -195,6 +266,66 @@ export class PluginTree extends TreeImpl {
195266
}, update);
196267
}
197268

269+
/** Creates a resolvable tree node. If a node already exists, reset it because the underlying TreeViewItem might have been disposed in the backend. */
270+
protected createResolvableTreeNode(item: TreeViewItem, parent: CompositeTreeNode): TreeNode {
271+
const update: Partial<TreeViewNode> = this.createTreeNodeUpdate(item);
272+
const node = this.getNode(item.id);
273+
274+
// Node is a composite node that might contain children
275+
if (item.collapsibleState !== undefined && item.collapsibleState !== TreeViewItemCollapsibleState.None) {
276+
// Reuse existing composite node and reset it
277+
if (node instanceof ResolvableCompositeTreeViewNode) {
278+
node.reset();
279+
return Object.assign(node, update);
280+
}
281+
// Create new composite node
282+
const compositeNode = Object.assign({
283+
id: item.id,
284+
parent,
285+
visible: true,
286+
selected: false,
287+
expanded: TreeViewItemCollapsibleState.Expanded === item.collapsibleState,
288+
children: [],
289+
command: item.command
290+
}, update);
291+
return new ResolvableCompositeTreeViewNode(compositeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token));
292+
}
293+
294+
// Node is a leaf
295+
// Reuse existing node and reset it.
296+
if (node instanceof ResolvableTreeViewNode && !ExpandableTreeNode.is(node)) {
297+
node.reset();
298+
return Object.assign(node, update);
299+
}
300+
const treeNode = Object.assign({
301+
id: item.id,
302+
parent,
303+
visible: true,
304+
selected: false,
305+
command: item.command,
306+
}, update);
307+
return new ResolvableTreeViewNode(treeNode, async (token: CancellationToken) => this._proxy?.$resolveTreeItem(this.identifier.id, item.id, token));
308+
}
309+
310+
protected createTreeNodeUpdate(item: TreeViewItem): Partial<TreeViewNode> {
311+
const decorationData = this.toDecorationData(item);
312+
const icon = this.toIconClass(item);
313+
const resourceUri = item.resourceUri && URI.revive(item.resourceUri).toString();
314+
const themeIcon = item.themeIcon ? item.themeIcon : item.collapsibleState !== TreeViewItemCollapsibleState.None ? { id: 'folder' } : undefined;
315+
return {
316+
name: item.label,
317+
decorationData,
318+
icon,
319+
description: item.description,
320+
themeIcon,
321+
resourceUri,
322+
tooltip: item.tooltip,
323+
contextValue: item.contextValue,
324+
command: item.command,
325+
accessibilityInformation: item.accessibilityInformation,
326+
};
327+
}
328+
198329
protected toDecorationData(item: TreeViewItem): WidgetDecoration.Data {
199330
let decoration: WidgetDecoration.Data = {};
200331
if (item.highlights) {
@@ -233,6 +364,10 @@ export class PluginTreeModel extends TreeModelImpl {
233364
return this.tree.proxy;
234365
}
235366

367+
get hasTreeItemResolve(): Promise<boolean> {
368+
return this.tree.hasTreeItemResolve;
369+
}
370+
236371
set viewInfo(viewInfo: View) {
237372
this.tree.viewInfo = viewInfo;
238373
}
@@ -245,6 +380,12 @@ export class PluginTreeModel extends TreeModelImpl {
245380
return this.tree.onDidChangeWelcomeState;
246381
}
247382

383+
override doOpenNode(node: TreeNode): void {
384+
super.doOpenNode(node);
385+
if (node instanceof ResolvableTreeViewNode) {
386+
node.resolve(CancellationToken.None);
387+
}
388+
}
248389
}
249390

250391
@injectable()
@@ -339,7 +480,40 @@ export class TreeViewWidget extends TreeViewWelcomeWidget {
339480
};
340481
}
341482

342-
if (node.tooltip && MarkdownString.is(node.tooltip)) {
483+
const elementRef = React.createRef<HTMLDivElement & Partial<TooltipAttributes>>();
484+
if (!node.tooltip && node instanceof ResolvableTreeViewNode) {
485+
let configuredTip = false;
486+
let source: CancellationTokenSource | undefined;
487+
attrs = {
488+
...attrs,
489+
'data-for': this.tooltipService.tooltipId,
490+
onMouseLeave: () => source?.cancel(),
491+
onMouseEnter: async () => {
492+
if (configuredTip) {
493+
return;
494+
}
495+
if (!node.resolved) {
496+
source = new CancellationTokenSource();
497+
const token = source.token;
498+
await node.resolve(token);
499+
if (token.isCancellationRequested) {
500+
return;
501+
}
502+
}
503+
if (elementRef.current) {
504+
// Set the resolved tooltip. After an HTML element was created data-* properties must be accessed via the dataset
505+
elementRef.current.dataset.tip = MarkdownString.is(node.tooltip) ? this.markdownIt.render(node.tooltip.value) : node.tooltip;
506+
this.tooltipService.update();
507+
configuredTip = true;
508+
// Manually fire another mouseenter event to get react-tooltip to update the tooltip content.
509+
// Without this, the resolved tooltip is only shown after re-entering the tree item with the mouse.
510+
elementRef.current.dispatchEvent(new MouseEvent('mouseenter'));
511+
} else {
512+
console.error(`Could not set resolved tooltip for tree node '${node.id}' because its React Ref was not set.`);
513+
}
514+
}
515+
};
516+
} else if (MarkdownString.is(node.tooltip)) {
343517
// Render markdown in custom tooltip
344518
const tooltip = this.markdownIt.render(node.tooltip.value);
345519

@@ -375,7 +549,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget {
375549
if (description) {
376550
children.push(<span className='theia-tree-view-description'>{description}</span>);
377551
}
378-
return React.createElement('div', attrs, ...children);
552+
return <div {...attrs} ref={elementRef}>{...children}</div>;
379553
}
380554

381555
protected override renderTailDecorations(node: TreeViewNode, props: NodeProps): React.ReactNode {
@@ -436,17 +610,18 @@ export class TreeViewWidget extends TreeViewWelcomeWidget {
436610

437611
protected override tapNode(node?: TreeNode): void {
438612
super.tapNode(node);
439-
const commandMap = this.findCommands(node);
440-
if (commandMap.size > 0) {
441-
this.tryExecuteCommandMap(commandMap);
442-
} else if (node && this.isExpandable(node)) {
443-
this.model.toggleNodeExpansion(node);
444-
}
613+
this.findCommands(node).then(commandMap => {
614+
if (commandMap.size > 0) {
615+
this.tryExecuteCommandMap(commandMap);
616+
} else if (node && this.isExpandable(node)) {
617+
this.model.toggleNodeExpansion(node);
618+
}
619+
});
445620
}
446621

447622
// execute TreeItem.command if present
448-
protected tryExecuteCommand(node?: TreeNode): void {
449-
this.tryExecuteCommandMap(this.findCommands(node));
623+
protected async tryExecuteCommand(node?: TreeNode): Promise<void> {
624+
this.tryExecuteCommandMap(await this.findCommands(node));
450625
}
451626

452627
protected tryExecuteCommandMap(commandMap: Map<string, unknown[]>): void {
@@ -455,9 +630,23 @@ export class TreeViewWidget extends TreeViewWelcomeWidget {
455630
});
456631
}
457632

458-
protected findCommands(node?: TreeNode): Map<string, unknown[]> {
633+
protected async findCommands(node?: TreeNode): Promise<Map<string, unknown[]>> {
459634
const commandMap = new Map<string, unknown[]>();
460635
const treeNodes = (node ? [node] : this.model.selectedNodes) as TreeViewNode[];
636+
if (await this.model.hasTreeItemResolve) {
637+
const cancellationToken = new CancellationTokenSource().token;
638+
// Resolve all resolvable nodes that don't have a command and haven't been resolved.
639+
const allResolved = Promise.all(treeNodes.map(maybeNeedsResolve => {
640+
if (!maybeNeedsResolve.command && maybeNeedsResolve instanceof ResolvableTreeViewNode && !maybeNeedsResolve.resolved) {
641+
return maybeNeedsResolve.resolve(cancellationToken).catch(err => {
642+
console.error(`Failed to resolve tree item '${maybeNeedsResolve.id}'`, err);
643+
});
644+
}
645+
return Promise.resolve(maybeNeedsResolve);
646+
}));
647+
// Only need to wait but don't need the values because tree items are resolved in place.
648+
await allResolved;
649+
}
461650
for (const treeNode of treeNodes) {
462651
if (treeNode && treeNode.command) {
463652
commandMap.set(treeNode.command.id, treeNode.command.arguments || []);

0 commit comments

Comments
 (0)