Skip to content

Commit d8acc5b

Browse files
authored
feat(ui): decouple tree nodes from the UI representation (#2783)
## Problem While prototyping features, I kept running into the situation where I wanted to update how a node was rendered independently of the node's children. The current implementation does not easily support this use-case, forcing us to propagate events up to a parent node to get the desired behavior. ## Solution Fully generalize the TreeNode interface. It was already very close to the general solution, it just needed a few tweaks to get it there. Of course, implementing this correctly with a tree data provider required more substantial changes which tests were added for. I wouldn't recommend that all tree node implementations use this pattern because it can quickly spiral out of control from a maintainability point of view. Still, I'd rather have the proper solution readily available than a bunch of one-off bespoke solutions.
1 parent 0dc18a5 commit d8acc5b

File tree

15 files changed

+239
-99
lines changed

15 files changed

+239
-99
lines changed

src/awsexplorer/localExplorer.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55

66
import * as vscode from 'vscode'
77
import * as telemetry from '../shared/telemetry/telemetry'
8-
import { cdkNode } from '../cdk/explorer/rootNode'
8+
import { cdkNode, CdkRootNode } from '../cdk/explorer/rootNode'
99
import { ResourceTreeDataProvider, TreeNode } from '../shared/treeview/resourceTreeDataProvider'
1010
import { once } from '../shared/utilities/functionUtils'
11-
import { AppNode } from '../cdk/explorer/nodes/appNode'
1211
import { isCloud9 } from '../shared/extensionUtilities'
1312
import { codewhispererNode } from '../codewhisperer/explorer/codewhispererNode'
1413

@@ -49,7 +48,7 @@ export function createLocalExplorerView(): vscode.TreeView<TreeNode> {
4948
// Legacy CDK metric, remove this when we add something generic
5049
const recordExpandCdkOnce = once(telemetry.recordCdkAppExpanded)
5150
view.onDidExpandElement(e => {
52-
if (e.element.resource instanceof AppNode) {
51+
if (e.element.resource instanceof CdkRootNode) {
5352
recordExpandCdkOnce()
5453
}
5554
})

src/cdk/explorer/nodes/appNode.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ import { getIcon } from '../../../shared/icons'
2121
export class AppNode implements TreeNode {
2222
public readonly id = this.location.cdkJsonUri.toString()
2323
public readonly resource = this.location
24-
public readonly treeItem: vscode.TreeItem
24+
public readonly label = vscode.workspace.asRelativePath(vscode.Uri.joinPath(this.location.cdkJsonUri, '..'))
2525

26-
public constructor(private readonly location: CdkAppLocation) {
27-
this.treeItem = this.createTreeItem()
28-
}
26+
public constructor(private readonly location: CdkAppLocation) {}
2927

3028
public async getChildren(): Promise<(ConstructNode | TreeNode)[]> {
3129
const constructs = []
@@ -59,9 +57,8 @@ export class AppNode implements TreeNode {
5957
}
6058
}
6159

62-
private createTreeItem() {
63-
const label = vscode.workspace.asRelativePath(vscode.Uri.joinPath(this.location.cdkJsonUri, '..'))
64-
const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Collapsed)
60+
public getTreeItem() {
61+
const item = new vscode.TreeItem(this.label, vscode.TreeItemCollapsibleState.Collapsed)
6562

6663
item.contextValue = 'awsCdkAppNode'
6764
item.iconPath = getIcon('aws-cdk-logo')

src/cdk/explorer/nodes/constructNode.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,10 @@ import { generatePropertyNodes } from './propertyNode'
1313

1414
export class ConstructNode implements TreeNode {
1515
public readonly id = this.construct.id
16-
public readonly treeItem: vscode.TreeItem
1716
private readonly type = treeInspector.getTypeAttributeOrDefault(this.construct, '')
1817
private readonly properties = treeInspector.getProperties(this.construct)
1918

20-
public constructor(private readonly location: CdkAppLocation, private readonly construct: ConstructTreeEntity) {
21-
this.treeItem = this.createTreeItem()
22-
}
19+
public constructor(private readonly location: CdkAppLocation, private readonly construct: ConstructTreeEntity) {}
2320

2421
public get resource() {
2522
return {
@@ -36,7 +33,7 @@ export class ConstructNode implements TreeNode {
3633
return [...propertyNodes, ...constructNodes]
3734
}
3835

39-
private createTreeItem() {
36+
public getTreeItem() {
4037
const collapsibleState =
4138
this.construct.children || this.construct.attributes
4239
? vscode.TreeItemCollapsibleState.Collapsed

src/cdk/explorer/nodes/propertyNode.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,8 @@ import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider'
1414
export class PropertyNode implements TreeNode {
1515
public readonly id = this.key
1616
public readonly resource = this.value
17-
public readonly treeItem: vscode.TreeItem
1817

19-
public constructor(private readonly key: string, private readonly value: unknown) {
20-
this.treeItem = this.createTreeItem()
21-
}
18+
public constructor(private readonly key: string, private readonly value: unknown) {}
2219

2320
public async getChildren(): Promise<TreeNode[]> {
2421
if (this.value instanceof Array || this.value instanceof Object) {
@@ -28,7 +25,7 @@ export class PropertyNode implements TreeNode {
2825
}
2926
}
3027

31-
private createTreeItem() {
28+
public getTreeItem() {
3229
const item = new vscode.TreeItem(`${this.key}: ${this.value}`)
3330

3431
item.contextValue = 'awsCdkPropertyNode'

src/cdk/explorer/rootNode.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,24 @@ export async function getAppNodes(): Promise<TreeNode[]> {
2020
return [createPlaceholderItem(localize('AWS.cdk.explorerNode.noApps', '[No CDK Apps found in Workspaces]'))]
2121
}
2222

23-
return appsFound.map(appLocation => new AppNode(appLocation))
23+
return appsFound.map(appLocation => new AppNode(appLocation)).sort((a, b) => a.label.localeCompare(b.label) ?? 0)
2424
}
2525

2626
export class CdkRootNode implements TreeNode {
2727
public readonly id = 'cdk'
28-
public readonly treeItem = this.createTreeItem()
2928
public readonly resource = this
3029
private readonly onDidChangeChildrenEmitter = new vscode.EventEmitter<void>()
3130
public readonly onDidChangeChildren = this.onDidChangeChildrenEmitter.event
3231

33-
public async getChildren() {
34-
return (await getAppNodes()).sort((a, b) => a.treeItem?.label?.localeCompare(b.treeItem?.label ?? '') ?? 0)
32+
public getChildren() {
33+
return getAppNodes()
3534
}
3635

3736
public refresh(): void {
3837
this.onDidChangeChildrenEmitter.fire()
3938
}
4039

41-
private createTreeItem() {
40+
public getTreeItem() {
4241
const item = new vscode.TreeItem('CDK')
4342
item.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed
4443
item.contextValue = 'awsCdkRootNode'

src/codewhisperer/explorer/codewhispererNode.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { isCloud9 } from '../../shared/extensionUtilities'
2424
import { Cloud9AccessState } from '../models/model'
2525
export class CodeWhispererNode implements RootNode {
2626
public readonly id = 'codewhisperer'
27-
public readonly treeItem = this.createTreeItem()
2827
public readonly resource = this
2928
private readonly onDidChangeChildrenEmitter = new vscode.EventEmitter<void>()
3029
public readonly onDidChangeChildren = this.onDidChangeChildrenEmitter.event
@@ -45,7 +44,7 @@ export class CodeWhispererNode implements RootNode {
4544
})
4645
}
4746

48-
private createTreeItem() {
47+
public getTreeItem() {
4948
const item = new vscode.TreeItem('CodeWhisperer (Preview)')
5049
item.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed
5150
item.contextValue = 'awsCodeWhispererNode'

src/shared/treeview/resource.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export interface ResourceProvider<T extends Resource = Resource> {
2424

2525
export class ResourceTreeNode<T extends Resource> implements TreeNode<T> {
2626
public readonly id = this.resource.id
27-
public readonly treeItem = this.createTreeItem()
2827

2928
public constructor(
3029
public readonly resource: T,
@@ -40,7 +39,7 @@ export class ResourceTreeNode<T extends Resource> implements TreeNode<T> {
4039
return this.children?.listResources() ?? []
4140
}
4241

43-
private createTreeItem(): vscode.TreeItem {
42+
public getTreeItem(): vscode.TreeItem {
4443
const collapsed =
4544
this.children !== undefined
4645
? vscode.TreeItemCollapsibleState.Collapsed

src/shared/treeview/resourceTreeDataProvider.ts

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,19 @@ export interface TreeNode<T = unknown> {
2525
readonly resource: T
2626

2727
/**
28-
* A tree item used to display the node in a tree view.
28+
* An optional event to signal that this node's children has changed.
2929
*/
30-
readonly treeItem: vscode.TreeItem
30+
readonly onDidChangeChildren?: vscode.Event<void>
3131

3232
/**
33-
* Optional event to signal that this node's children has changed.
33+
* An optional event to signal that this node's tree item has changed.
3434
*/
35-
readonly onDidChangeChildren?: vscode.Event<void>
35+
readonly onDidChangeTreeItem?: vscode.Event<void>
36+
37+
/**
38+
* Returns a tree item used to display the node in a tree view.
39+
*/
40+
getTreeItem(): Promise<vscode.TreeItem> | vscode.TreeItem
3641

3742
/**
3843
* Optional method to provide child nodes.
@@ -46,38 +51,52 @@ export function isTreeNode(obj: unknown): obj is TreeNode {
4651
typeof obj === 'object' &&
4752
'resource' in obj &&
4853
typeof (obj as TreeNode).id === 'string' &&
49-
(obj as TreeNode).treeItem instanceof vscode.TreeItem
54+
typeof (obj as TreeNode).getTreeItem === 'function'
5055
)
5156
}
5257

5358
function copyNode<T>(id: string, node: Omit<TreeNode<T>, 'id'>): TreeNode<T> {
5459
return {
5560
id,
5661
resource: node.resource,
57-
treeItem: node.treeItem,
5862
onDidChangeChildren: node.onDidChangeChildren,
63+
onDidChangeTreeItem: node.onDidChangeTreeItem,
64+
getTreeItem: node.getTreeItem?.bind(node),
5965
getChildren: node.getChildren?.bind(node),
6066
}
6167
}
6268

6369
export class ResourceTreeDataProvider implements vscode.TreeDataProvider<TreeNode> {
64-
private readonly children: Map<string, TreeNode[]> = new Map()
65-
private readonly listeners: Map<string, vscode.Disposable> = new Map()
70+
private readonly items = new Map<string, vscode.TreeItem>()
71+
private readonly children = new Map<string, TreeNode[]>()
72+
private readonly listeners = new Map<string, vscode.Disposable>()
6673
private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter<TreeNode | void>()
6774
public readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event
6875

6976
public constructor(private readonly root: Required<Pick<TreeNode, 'getChildren'>>) {}
7077

71-
public getTreeItem(element: TreeNode): vscode.TreeItem {
72-
const item = element.treeItem
78+
public async getTreeItem(element: TreeNode): Promise<vscode.TreeItem> {
79+
const previousItem = this.items.get(element.id)
80+
if (previousItem) {
81+
return previousItem
82+
}
83+
84+
const item = await element.getTreeItem()
7385
item.id = element.id
86+
this.items.set(element.id, item)
7487

7588
return item
7689
}
7790

7891
public async getChildren(element?: TreeNode): Promise<TreeNode[]> {
7992
if (element) {
80-
this.children.get(element.id)?.forEach(n => this.clear(n))
93+
const previousChildren = this.children.get(element.id)
94+
95+
if (previousChildren !== undefined) {
96+
return previousChildren
97+
} else {
98+
this.children.get(element.id)?.forEach(n => this.clear(n))
99+
}
81100
}
82101

83102
const getId = (id: string) => (element ? `${element.id}/${id}` : id)
@@ -91,6 +110,7 @@ export class ResourceTreeDataProvider implements vscode.TreeDataProvider<TreeNod
91110
public refresh(): void {
92111
vscode.Disposable.from(...this.listeners.values()).dispose()
93112

113+
this.items.clear()
94114
this.children.clear()
95115
this.listeners.clear()
96116
this.onDidChangeTreeDataEmitter.fire()
@@ -99,6 +119,7 @@ export class ResourceTreeDataProvider implements vscode.TreeDataProvider<TreeNod
99119
private clear(node: TreeNode): void {
100120
const children = this.children.get(node.id)
101121

122+
this.items.delete(node.id)
102123
this.children.delete(node.id)
103124
this.listeners.get(node.id)?.dispose()
104125
this.listeners.delete(node.id)
@@ -108,14 +129,29 @@ export class ResourceTreeDataProvider implements vscode.TreeDataProvider<TreeNod
108129

109130
private insert(id: string, resource: TreeNode): TreeNode {
110131
const node = copyNode(id, resource)
132+
const listeners: vscode.Disposable[] = []
111133

112134
if (node.onDidChangeChildren) {
113-
const listener = node.onDidChangeChildren?.(() => {
114-
this.children.get(node.id)?.forEach(n => this.clear(n))
115-
this.onDidChangeTreeDataEmitter.fire(node)
116-
})
135+
listeners.push(
136+
node.onDidChangeChildren?.(() => {
137+
this.children.get(node.id)?.forEach(n => this.clear(n))
138+
this.children.delete(node.id)
139+
this.onDidChangeTreeDataEmitter.fire(node)
140+
})
141+
)
142+
}
143+
144+
if (node.onDidChangeTreeItem) {
145+
listeners.push(
146+
node.onDidChangeTreeItem?.(() => {
147+
this.items.delete(node.id)
148+
this.onDidChangeTreeDataEmitter.fire(node)
149+
})
150+
)
151+
}
117152

118-
this.listeners.set(id, listener)
153+
if (listeners.length !== 0) {
154+
this.listeners.set(id, vscode.Disposable.from(...listeners))
119155
}
120156

121157
return node

src/shared/treeview/utils.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function createPlaceholderItem(message: string): TreeNode {
7777
return {
7878
id: 'placeholder',
7979
resource: message,
80-
treeItem: new vscode.TreeItem(message, vscode.TreeItemCollapsibleState.None),
80+
getTreeItem: () => new vscode.TreeItem(message, vscode.TreeItemCollapsibleState.None),
8181
}
8282
}
8383

@@ -96,16 +96,43 @@ export function unboxTreeNode<T>(node: TreeNode, predicate: (resource: unknown)
9696
* would be passed in as-is.
9797
*/
9898
export class TreeShim extends AWSTreeNodeBase {
99-
public constructor(public readonly node: TreeNode) {
100-
super(node.treeItem.label ?? '[No label]')
101-
assign(node.treeItem, this)
99+
private children?: AWSTreeNodeBase[]
102100

103-
this.node.onDidChangeChildren?.(() => this.refresh())
101+
public constructor(public readonly node: TreeNode) {
102+
super('Loading...')
103+
this.updateTreeItem()
104+
105+
this.node.onDidChangeChildren?.(() => {
106+
this.children = undefined
107+
this.refresh()
108+
})
109+
110+
this.node.onDidChangeTreeItem?.(async () => {
111+
const { didRefresh } = await this.updateTreeItem()
112+
!didRefresh && this.refresh()
113+
})
104114
}
105115

106116
public override async getChildren(): Promise<AWSTreeNodeBase[]> {
117+
if (this.children) {
118+
return this.children
119+
}
120+
107121
const children = (await this.node.getChildren?.()) ?? []
108122

109-
return children.map(n => new TreeShim(n))
123+
return (this.children = children.map(n => new TreeShim(n)))
124+
}
125+
126+
private async updateTreeItem(): Promise<{ readonly didRefresh: boolean }> {
127+
const item = this.node.getTreeItem()
128+
if (item instanceof Promise) {
129+
assign(await item, this)
130+
this.refresh()
131+
132+
return { didRefresh: true }
133+
}
134+
135+
assign(item, this)
136+
return { didRefresh: false }
110137
}
111138
}

src/shared/vscode/commands2.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,15 @@ class CommandResource<T extends Callback = Callback, U extends any[] = any[]> {
283283
private buildTreeNode(id: string, args: unknown[]) {
284284
return (content: PartialTreeItem) => {
285285
const treeItem = new vscode.TreeItem(content.label, vscode.TreeItemCollapsibleState.None)
286-
treeItem.command = { command: id, arguments: args, title: content.label }
286+
Object.assign(treeItem, {
287+
...content,
288+
command: { command: id, arguments: args, title: content.label },
289+
})
287290

288291
return {
289292
id: `${id}-${(this.idCounter += 1)}`,
290-
treeItem: Object.assign(treeItem, content),
291293
resource: this,
294+
getTreeItem: () => treeItem,
292295
}
293296
}
294297
}

0 commit comments

Comments
 (0)