Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vscode: Add support for resolveTreeItem to TreeDataProvider #11708

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,8 @@ export interface TreeViewsMain {

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

resourceUri?: UriComponents;

tooltip?: string;
tooltip?: string | MarkdownString;

collapsibleState?: TreeViewItemCollapsibleState;

Expand Down
247 changes: 218 additions & 29 deletions packages/plugin-ext/src/main/browser/view/tree-view-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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;
Expand All @@ -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<CompositeTreeNode>;
previousSibling?: TreeNode;
nextSibling?: TreeNode;
busy?: number;
decorationData: WidgetDecoration.Data;

resolve: ((token: CancellationToken) => Promise<void>);

private _resolved = false;
private resolving: Deferred<void> | undefined;

constructor(treeViewNode: Partial<TreeViewNode>, resolve: (token: CancellationToken) => Promise<TreeViewItem | undefined>) {
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<CompositeTreeViewNode, 'children' | 'expanded'> & Partial<TreeViewNode>,
resolve: (token: CancellationToken) => Promise<TreeViewItem | undefined>) {
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;
Expand Down Expand Up @@ -108,14 +183,24 @@ export class PluginTree extends TreeImpl {
private _proxy: TreeViewsExt | undefined;
private _viewInfo: View | undefined;
private _isEmpty: boolean;
private _hasTreeItemResolve: Promise<boolean> = 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<boolean> {
return this._hasTreeItemResolve;
}

set viewInfo(viewInfo: View) {
this._viewInfo = viewInfo;
}
Expand All @@ -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<TreeViewItem[]> {
Expand All @@ -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<TreeViewNode> = {
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<TreeViewNode> = this.createTreeNodeUpdate(item);
const node = this.getNode(item.id);
if (item.collapsibleState !== undefined && item.collapsibleState !== TreeViewItemCollapsibleState.None) {
if (CompositeTreeViewNode.is(node)) {
Expand Down Expand Up @@ -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<TreeViewNode> = 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<TreeViewNode> {
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) {
Expand Down Expand Up @@ -233,6 +364,10 @@ export class PluginTreeModel extends TreeModelImpl {
return this.tree.proxy;
}

get hasTreeItemResolve(): Promise<boolean> {
return this.tree.hasTreeItemResolve;
}

set viewInfo(viewInfo: View) {
this.tree.viewInfo = viewInfo;
}
Expand All @@ -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()
Expand Down Expand Up @@ -339,7 +480,40 @@ export class TreeViewWidget extends TreeViewWelcomeWidget {
};
}

if (node.tooltip && MarkdownString.is(node.tooltip)) {
const elementRef = React.createRef<HTMLDivElement & Partial<TooltipAttributes>>();
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);

Expand Down Expand Up @@ -375,7 +549,7 @@ export class TreeViewWidget extends TreeViewWelcomeWidget {
if (description) {
children.push(<span className='theia-tree-view-description'>{description}</span>);
}
return React.createElement('div', attrs, ...children);
return <div {...attrs} ref={elementRef}>{...children}</div>;
}

protected override renderTailDecorations(node: TreeViewNode, props: NodeProps): React.ReactNode {
Expand Down Expand Up @@ -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<void> {
this.tryExecuteCommandMap(await this.findCommands(node));
}

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

protected findCommands(node?: TreeNode): Map<string, unknown[]> {
protected async findCommands(node?: TreeNode): Promise<Map<string, unknown[]>> {
const commandMap = new Map<string, unknown[]>();
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 || []);
Expand Down
Loading