@@ -49,6 +49,9 @@ import { AccessibilityInformation } from '@theia/plugin';
4949import { ColorRegistry } from '@theia/core/lib/browser/color-registry' ;
5050import { DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator' ;
5151import { 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
5356export const TREE_NODE_HYPERLINK = 'theia-TreeNodeHyperlink' ;
5457export 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+
78153export 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