/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { ComposedTreeDelegate, IAbstractTreeOptions, IAbstractTreeOptionsUpdate } from 'vs/base/browser/ui/tree/abstractTree'; import { ObjectTree, IObjectTreeOptions, CompressibleObjectTree, ICompressibleTreeRenderer, ICompressibleKeyboardNavigationLabelProvider, ICompressibleObjectTreeOptions } from 'vs/base/browser/ui/tree/objectTree'; import { IListVirtualDelegate, IIdentityProvider, IListDragAndDrop, IListDragOverReaction, ListAriaRootRole } from 'vs/base/browser/ui/list/list'; import { ITreeElement, ITreeNode, ITreeRenderer, ITreeEvent, ITreeMouseEvent, ITreeContextMenuEvent, ITreeSorter, ICollapseStateChangeEvent, IAsyncDataSource, ITreeDragAndDrop, TreeError, WeakMapper, ITreeFilter, TreeVisibility, TreeFilterResult } from 'vs/base/browser/ui/tree/tree'; import { IDisposable, dispose, DisposableStore } from 'vs/base/common/lifecycle'; import { Emitter, Event } from 'vs/base/common/event'; import { timeout, CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; import { IListStyles } from 'vs/base/browser/ui/list/listWidget'; import { Iterator } from 'vs/base/common/iterator'; import { IDragAndDropData } from 'vs/base/browser/dnd'; import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; import { isPromiseCanceledError, onUnexpectedError } from 'vs/base/common/errors'; import { toggleClass } from 'vs/base/browser/dom'; import { values } from 'vs/base/common/map'; import { ScrollEvent } from 'vs/base/common/scrollable'; import { ICompressedTreeNode, ICompressedTreeElement } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { IThemable } from 'vs/base/common/styler'; import { isFilterResult, getVisibleState } from 'vs/base/browser/ui/tree/indexTreeModel'; interface IAsyncDataTreeNode { element: TInput | T; readonly parent: IAsyncDataTreeNode | null; readonly children: IAsyncDataTreeNode[]; readonly id?: string | null; refreshPromise: Promise | undefined; hasChildren: boolean; stale: boolean; slow: boolean; collapsedByDefault: boolean | undefined; } interface IAsyncDataTreeNodeRequiredProps extends Partial> { readonly element: TInput | T; readonly parent: IAsyncDataTreeNode | null; readonly hasChildren: boolean; } function createAsyncDataTreeNode(props: IAsyncDataTreeNodeRequiredProps): IAsyncDataTreeNode { return { ...props, children: [], refreshPromise: undefined, stale: true, slow: false, collapsedByDefault: undefined }; } function isAncestor(ancestor: IAsyncDataTreeNode, descendant: IAsyncDataTreeNode): boolean { if (!descendant.parent) { return false; } else if (descendant.parent === ancestor) { return true; } else { return isAncestor(ancestor, descendant.parent); } } function intersects(node: IAsyncDataTreeNode, other: IAsyncDataTreeNode): boolean { return node === other || isAncestor(node, other) || isAncestor(other, node); } interface IDataTreeListTemplateData { templateData: T; } type AsyncDataTreeNodeMapper = WeakMapper | null, TFilterData>, ITreeNode>; class AsyncDataTreeNodeWrapper implements ITreeNode { get element(): T { return this.node.element!.element as T; } get children(): ITreeNode[] { return this.node.children.map(node => new AsyncDataTreeNodeWrapper(node)); } get depth(): number { return this.node.depth; } get visibleChildrenCount(): number { return this.node.visibleChildrenCount; } get visibleChildIndex(): number { return this.node.visibleChildIndex; } get collapsible(): boolean { return this.node.collapsible; } get collapsed(): boolean { return this.node.collapsed; } get visible(): boolean { return this.node.visible; } get filterData(): TFilterData | undefined { return this.node.filterData; } constructor(private node: ITreeNode | null, TFilterData>) { } } class AsyncDataTreeRenderer implements ITreeRenderer, TFilterData, IDataTreeListTemplateData> { readonly templateId: string; private renderedNodes = new Map, IDataTreeListTemplateData>(); constructor( protected renderer: ITreeRenderer, protected nodeMapper: AsyncDataTreeNodeMapper, readonly onDidChangeTwistieState: Event> ) { this.templateId = renderer.templateId; } renderTemplate(container: HTMLElement): IDataTreeListTemplateData { const templateData = this.renderer.renderTemplate(container); return { templateData }; } renderElement(node: ITreeNode, TFilterData>, index: number, templateData: IDataTreeListTemplateData, height: number | undefined): void { this.renderer.renderElement(this.nodeMapper.map(node) as ITreeNode, index, templateData.templateData, height); } renderTwistie(element: IAsyncDataTreeNode, twistieElement: HTMLElement): boolean { toggleClass(twistieElement, 'codicon-loading', element.slow); return false; } disposeElement(node: ITreeNode, TFilterData>, index: number, templateData: IDataTreeListTemplateData, height: number | undefined): void { if (this.renderer.disposeElement) { this.renderer.disposeElement(this.nodeMapper.map(node) as ITreeNode, index, templateData.templateData, height); } } disposeTemplate(templateData: IDataTreeListTemplateData): void { this.renderer.disposeTemplate(templateData.templateData); } dispose(): void { this.renderedNodes.clear(); } } function asTreeEvent(e: ITreeEvent | null>): ITreeEvent { return { browserEvent: e.browserEvent, elements: e.elements.map(e => e!.element as T) }; } function asTreeMouseEvent(e: ITreeMouseEvent | null>): ITreeMouseEvent { return { browserEvent: e.browserEvent, element: e.element && e.element.element as T, target: e.target }; } function asTreeContextMenuEvent(e: ITreeContextMenuEvent | null>): ITreeContextMenuEvent { return { browserEvent: e.browserEvent, element: e.element && e.element.element as T, anchor: e.anchor }; } class AsyncDataTreeElementsDragAndDropData extends ElementsDragAndDropData { set context(context: TContext | undefined) { this.data.context = context; } get context(): TContext | undefined { return this.data.context; } constructor(private data: ElementsDragAndDropData, TContext>) { super(data.elements.map(node => node.element as T)); } } function asAsyncDataTreeDragAndDropData(data: IDragAndDropData): IDragAndDropData { if (data instanceof ElementsDragAndDropData) { return new AsyncDataTreeElementsDragAndDropData(data); } return data; } class AsyncDataTreeNodeListDragAndDrop implements IListDragAndDrop> { constructor(private dnd: ITreeDragAndDrop) { } getDragURI(node: IAsyncDataTreeNode): string | null { return this.dnd.getDragURI(node.element as T); } getDragLabel(nodes: IAsyncDataTreeNode[], originalEvent: DragEvent): string | undefined { if (this.dnd.getDragLabel) { return this.dnd.getDragLabel(nodes.map(node => node.element as T), originalEvent); } return undefined; } onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void { if (this.dnd.onDragStart) { this.dnd.onDragStart(asAsyncDataTreeDragAndDropData(data), originalEvent); } } onDragOver(data: IDragAndDropData, targetNode: IAsyncDataTreeNode | undefined, targetIndex: number | undefined, originalEvent: DragEvent, raw = true): boolean | IListDragOverReaction { return this.dnd.onDragOver(asAsyncDataTreeDragAndDropData(data), targetNode && targetNode.element as T, targetIndex, originalEvent); } drop(data: IDragAndDropData, targetNode: IAsyncDataTreeNode | undefined, targetIndex: number | undefined, originalEvent: DragEvent): void { this.dnd.drop(asAsyncDataTreeDragAndDropData(data), targetNode && targetNode.element as T, targetIndex, originalEvent); } onDragEnd(originalEvent: DragEvent): void { if (this.dnd.onDragEnd) { this.dnd.onDragEnd(originalEvent); } } } function asObjectTreeOptions(options?: IAsyncDataTreeOptions): IObjectTreeOptions, TFilterData> | undefined { return options && { ...options, collapseByDefault: true, identityProvider: options.identityProvider && { getId(el) { return options.identityProvider!.getId(el.element as T); } }, dnd: options.dnd && new AsyncDataTreeNodeListDragAndDrop(options.dnd), multipleSelectionController: options.multipleSelectionController && { isSelectionSingleChangeEvent(e) { return options.multipleSelectionController!.isSelectionSingleChangeEvent({ ...e, element: e.element } as any); }, isSelectionRangeChangeEvent(e) { return options.multipleSelectionController!.isSelectionRangeChangeEvent({ ...e, element: e.element } as any); } }, accessibilityProvider: options.accessibilityProvider && { ...options.accessibilityProvider, getAriaLabel(e) { return options.accessibilityProvider!.getAriaLabel(e.element as T); }, getAriaLevel: options.accessibilityProvider!.getAriaLevel && (node => { return options.accessibilityProvider!.getAriaLevel!(node.element as T); }), getActiveDescendantId: options.accessibilityProvider.getActiveDescendantId && (node => { return options.accessibilityProvider!.getActiveDescendantId!(node.element as T); }) }, filter: options.filter && { filter(e, parentVisibility) { return options.filter!.filter(e.element as T, parentVisibility); } }, keyboardNavigationLabelProvider: options.keyboardNavigationLabelProvider && { ...options.keyboardNavigationLabelProvider, getKeyboardNavigationLabel(e) { return options.keyboardNavigationLabelProvider!.getKeyboardNavigationLabel(e.element as T); } }, sorter: undefined, expandOnlyOnTwistieClick: typeof options.expandOnlyOnTwistieClick === 'undefined' ? undefined : ( typeof options.expandOnlyOnTwistieClick !== 'function' ? options.expandOnlyOnTwistieClick : ( e => (options.expandOnlyOnTwistieClick as ((e: T) => boolean))(e.element as T) ) ), ariaProvider: options.ariaProvider && { getPosInSet(el, index) { return options.ariaProvider!.getPosInSet(el.element as T, index); }, getSetSize(el, index, listLength) { return options.ariaProvider!.getSetSize(el.element as T, index, listLength); }, getRole: options.ariaProvider!.getRole ? (el) => { return options.ariaProvider!.getRole!(el.element as T); } : () => 'treeitem', isChecked: options.ariaProvider!.isChecked ? (e) => { return !!(options.ariaProvider?.isChecked!(e.element as T)); } : undefined }, ariaRole: ListAriaRootRole.TREE, additionalScrollHeight: options.additionalScrollHeight }; } export interface IAsyncDataTreeOptionsUpdate extends IAbstractTreeOptionsUpdate { } export interface IAsyncDataTreeOptions extends IAsyncDataTreeOptionsUpdate, Pick, Exclude, 'collapseByDefault'>> { readonly collapseByDefault?: { (e: T): boolean; }; readonly identityProvider?: IIdentityProvider; readonly sorter?: ITreeSorter; readonly autoExpandSingleChildren?: boolean; } export interface IAsyncDataTreeViewState { readonly focus?: string[]; readonly selection?: string[]; readonly expanded?: string[]; readonly scrollTop?: number; } interface IAsyncDataTreeViewStateContext { readonly viewState: IAsyncDataTreeViewState; readonly selection: IAsyncDataTreeNode[]; readonly focus: IAsyncDataTreeNode[]; } function dfs(node: IAsyncDataTreeNode, fn: (node: IAsyncDataTreeNode) => void): void { fn(node); node.children.forEach(child => dfs(child, fn)); } export class AsyncDataTree implements IDisposable, IThemable { protected readonly tree: ObjectTree, TFilterData>; protected readonly root: IAsyncDataTreeNode; private readonly nodes = new Map>(); private readonly sorter?: ITreeSorter; private readonly collapseByDefault?: { (e: T): boolean; }; private readonly subTreeRefreshPromises = new Map, Promise>(); private readonly refreshPromises = new Map, CancelablePromise>(); protected readonly identityProvider?: IIdentityProvider; private readonly autoExpandSingleChildren: boolean; private readonly _onDidRender = new Emitter(); protected readonly _onDidChangeNodeSlowState = new Emitter>(); protected readonly nodeMapper: AsyncDataTreeNodeMapper = new WeakMapper(node => new AsyncDataTreeNodeWrapper(node)); protected readonly disposables = new DisposableStore(); get onDidScroll(): Event { return this.tree.onDidScroll; } get onDidChangeFocus(): Event> { return Event.map(this.tree.onDidChangeFocus, asTreeEvent); } get onDidChangeSelection(): Event> { return Event.map(this.tree.onDidChangeSelection, asTreeEvent); } get onDidOpen(): Event> { return Event.map(this.tree.onDidOpen, asTreeEvent); } get onKeyDown(): Event { return this.tree.onKeyDown; } get onMouseClick(): Event> { return Event.map(this.tree.onMouseClick, asTreeMouseEvent); } get onMouseDblClick(): Event> { return Event.map(this.tree.onMouseDblClick, asTreeMouseEvent); } get onContextMenu(): Event> { return Event.map(this.tree.onContextMenu, asTreeContextMenuEvent); } get onDidFocus(): Event { return this.tree.onDidFocus; } get onDidBlur(): Event { return this.tree.onDidBlur; } get onDidChangeCollapseState(): Event | null, TFilterData>> { return this.tree.onDidChangeCollapseState; } get onDidUpdateOptions(): Event { return this.tree.onDidUpdateOptions; } get filterOnType(): boolean { return this.tree.filterOnType; } get openOnSingleClick(): boolean { return this.tree.openOnSingleClick; } get expandOnlyOnTwistieClick(): boolean | ((e: T) => boolean) { if (typeof this.tree.expandOnlyOnTwistieClick === 'boolean') { return this.tree.expandOnlyOnTwistieClick; } const fn = this.tree.expandOnlyOnTwistieClick; return element => fn(this.nodes.get((element === this.root.element ? null : element) as T) || null); } get onDidDispose(): Event { return this.tree.onDidDispose; } constructor( protected user: string, container: HTMLElement, delegate: IListVirtualDelegate, renderers: ITreeRenderer[], private dataSource: IAsyncDataSource, options: IAsyncDataTreeOptions = {} ) { this.identityProvider = options.identityProvider; this.autoExpandSingleChildren = typeof options.autoExpandSingleChildren === 'undefined' ? false : options.autoExpandSingleChildren; this.sorter = options.sorter; this.collapseByDefault = options.collapseByDefault; this.tree = this.createTree(user, container, delegate, renderers, options); this.root = createAsyncDataTreeNode({ element: undefined!, parent: null, hasChildren: true }); if (this.identityProvider) { this.root = { ...this.root, id: null }; } this.nodes.set(null, this.root); this.tree.onDidChangeCollapseState(this._onDidChangeCollapseState, this, this.disposables); } protected createTree( user: string, container: HTMLElement, delegate: IListVirtualDelegate, renderers: ITreeRenderer[], options: IAsyncDataTreeOptions ): ObjectTree, TFilterData> { const objectTreeDelegate = new ComposedTreeDelegate>(delegate); const objectTreeRenderers = renderers.map(r => new AsyncDataTreeRenderer(r, this.nodeMapper, this._onDidChangeNodeSlowState.event)); const objectTreeOptions = asObjectTreeOptions(options) || {}; return new ObjectTree(user, container, objectTreeDelegate, objectTreeRenderers, objectTreeOptions); } updateOptions(options: IAsyncDataTreeOptionsUpdate = {}): void { this.tree.updateOptions(options); } // Widget getHTMLElement(): HTMLElement { return this.tree.getHTMLElement(); } get contentHeight(): number { return this.tree.contentHeight; } get onDidChangeContentHeight(): Event { return this.tree.onDidChangeContentHeight; } get scrollTop(): number { return this.tree.scrollTop; } set scrollTop(scrollTop: number) { this.tree.scrollTop = scrollTop; } get scrollLeft(): number { return this.tree.scrollLeft; } set scrollLeft(scrollLeft: number) { this.tree.scrollLeft = scrollLeft; } get scrollHeight(): number { return this.tree.scrollHeight; } get renderHeight(): number { return this.tree.renderHeight; } get lastVisibleElement(): T { return this.tree.lastVisibleElement!.element as T; } domFocus(): void { this.tree.domFocus(); } layout(height?: number, width?: number): void { this.tree.layout(height, width); } style(styles: IListStyles): void { this.tree.style(styles); } // Model getInput(): TInput | undefined { return this.root.element as TInput; } async setInput(input: TInput, viewState?: IAsyncDataTreeViewState): Promise { this.refreshPromises.forEach(promise => promise.cancel()); this.refreshPromises.clear(); this.root.element = input!; const viewStateContext = viewState && { viewState, focus: [], selection: [] } as IAsyncDataTreeViewStateContext; await this._updateChildren(input, true, false, viewStateContext); if (viewStateContext) { this.tree.setFocus(viewStateContext.focus); this.tree.setSelection(viewStateContext.selection); } if (viewState && typeof viewState.scrollTop === 'number') { this.scrollTop = viewState.scrollTop; } } async updateChildren(element: TInput | T = this.root.element, recursive = true, rerender = false): Promise { await this._updateChildren(element, recursive, rerender); } private async _updateChildren(element: TInput | T = this.root.element, recursive = true, rerender = false, viewStateContext?: IAsyncDataTreeViewStateContext): Promise { if (typeof this.root.element === 'undefined') { throw new TreeError(this.user, 'Tree input not set'); } if (this.root.refreshPromise) { await this.root.refreshPromise; await Event.toPromise(this._onDidRender.event); } const node = this.getDataNode(element); await this.refreshAndRenderNode(node, recursive, viewStateContext); if (rerender) { try { this.tree.rerender(node); } catch { // missing nodes are fine, this could've resulted from // parallel refresh calls, removing `node` altogether } } } resort(element: TInput | T = this.root.element, recursive = true): void { this.tree.resort(this.getDataNode(element), recursive); } hasNode(element: TInput | T): boolean { return element === this.root.element || this.nodes.has(element as T); } // View rerender(element?: T): void { if (element === undefined || element === this.root.element) { this.tree.rerender(); return; } const node = this.getDataNode(element); this.tree.rerender(node); } updateWidth(element: T): void { const node = this.getDataNode(element); this.tree.updateWidth(node); } // Tree getNode(element: TInput | T = this.root.element): ITreeNode { const dataNode = this.getDataNode(element); const node = this.tree.getNode(dataNode === this.root ? null : dataNode); return this.nodeMapper.map(node); } collapse(element: T, recursive: boolean = false): boolean { const node = this.getDataNode(element); return this.tree.collapse(node === this.root ? null : node, recursive); } async expand(element: T, recursive: boolean = false): Promise { if (typeof this.root.element === 'undefined') { throw new TreeError(this.user, 'Tree input not set'); } if (this.root.refreshPromise) { await this.root.refreshPromise; await Event.toPromise(this._onDidRender.event); } const node = this.getDataNode(element); if (this.tree.hasElement(node) && !this.tree.isCollapsible(node)) { return false; } if (node.refreshPromise) { await this.root.refreshPromise; await Event.toPromise(this._onDidRender.event); } if (node !== this.root && !node.refreshPromise && !this.tree.isCollapsed(node)) { return false; } const result = this.tree.expand(node === this.root ? null : node, recursive); if (node.refreshPromise) { await this.root.refreshPromise; await Event.toPromise(this._onDidRender.event); } return result; } toggleCollapsed(element: T, recursive: boolean = false): boolean { return this.tree.toggleCollapsed(this.getDataNode(element), recursive); } expandAll(): void { this.tree.expandAll(); } collapseAll(): void { this.tree.collapseAll(); } isCollapsible(element: T): boolean { return this.tree.isCollapsible(this.getDataNode(element)); } isCollapsed(element: T): boolean { return this.tree.isCollapsed(this.getDataNode(element)); } toggleKeyboardNavigation(): void { this.tree.toggleKeyboardNavigation(); } refilter(): void { this.tree.refilter(); } setSelection(elements: T[], browserEvent?: UIEvent): void { const nodes = elements.map(e => this.getDataNode(e)); this.tree.setSelection(nodes, browserEvent); } getSelection(): T[] { const nodes = this.tree.getSelection(); return nodes.map(n => n!.element as T); } setFocus(elements: T[], browserEvent?: UIEvent): void { const nodes = elements.map(e => this.getDataNode(e)); this.tree.setFocus(nodes, browserEvent); } focusNext(n = 1, loop = false, browserEvent?: UIEvent): void { this.tree.focusNext(n, loop, browserEvent); } focusPrevious(n = 1, loop = false, browserEvent?: UIEvent): void { this.tree.focusPrevious(n, loop, browserEvent); } focusNextPage(browserEvent?: UIEvent): void { this.tree.focusNextPage(browserEvent); } focusPreviousPage(browserEvent?: UIEvent): void { this.tree.focusPreviousPage(browserEvent); } focusLast(browserEvent?: UIEvent): void { this.tree.focusLast(browserEvent); } focusFirst(browserEvent?: UIEvent): void { this.tree.focusFirst(browserEvent); } getFocus(): T[] { const nodes = this.tree.getFocus(); return nodes.map(n => n!.element as T); } open(elements: T[], browserEvent?: UIEvent): void { const nodes = elements.map(e => this.getDataNode(e)); this.tree.open(nodes, browserEvent); } reveal(element: T, relativeTop?: number): void { this.tree.reveal(this.getDataNode(element), relativeTop); } getRelativeTop(element: T): number | null { return this.tree.getRelativeTop(this.getDataNode(element)); } // Tree navigation getParentElement(element: T): TInput | T { const node = this.tree.getParentElement(this.getDataNode(element)); return (node && node.element)!; } getFirstElementChild(element: TInput | T = this.root.element): TInput | T | undefined { const dataNode = this.getDataNode(element); const node = this.tree.getFirstElementChild(dataNode === this.root ? null : dataNode); return (node && node.element)!; } // Implementation private getDataNode(element: TInput | T): IAsyncDataTreeNode { const node: IAsyncDataTreeNode | undefined = this.nodes.get((element === this.root.element ? null : element) as T); if (!node) { throw new TreeError(this.user, `Data tree node not found: ${element}`); } return node; } private async refreshAndRenderNode(node: IAsyncDataTreeNode, recursive: boolean, viewStateContext?: IAsyncDataTreeViewStateContext): Promise { await this.refreshNode(node, recursive, viewStateContext); this.render(node, viewStateContext); } private async refreshNode(node: IAsyncDataTreeNode, recursive: boolean, viewStateContext?: IAsyncDataTreeViewStateContext): Promise { let result: Promise | undefined; this.subTreeRefreshPromises.forEach((refreshPromise, refreshNode) => { if (!result && intersects(refreshNode, node)) { result = refreshPromise.then(() => this.refreshNode(node, recursive, viewStateContext)); } }); if (result) { return result; } return this.doRefreshSubTree(node, recursive, viewStateContext); } private async doRefreshSubTree(node: IAsyncDataTreeNode, recursive: boolean, viewStateContext?: IAsyncDataTreeViewStateContext): Promise { let done: () => void; node.refreshPromise = new Promise(c => done = c); this.subTreeRefreshPromises.set(node, node.refreshPromise); node.refreshPromise.finally(() => { node.refreshPromise = undefined; this.subTreeRefreshPromises.delete(node); }); try { const childrenToRefresh = await this.doRefreshNode(node, recursive, viewStateContext); node.stale = false; await Promise.all(childrenToRefresh.map(child => this.doRefreshSubTree(child, recursive, viewStateContext))); } finally { done!(); } } private async doRefreshNode(node: IAsyncDataTreeNode, recursive: boolean, viewStateContext?: IAsyncDataTreeViewStateContext): Promise[]> { node.hasChildren = !!this.dataSource.hasChildren(node.element!); let childrenPromise: Promise; if (!node.hasChildren) { childrenPromise = Promise.resolve([]); } else { const slowTimeout = timeout(800); slowTimeout.then(() => { node.slow = true; this._onDidChangeNodeSlowState.fire(node); }, _ => null); childrenPromise = this.doGetChildren(node) .finally(() => slowTimeout.cancel()); } try { const children = await childrenPromise; return this.setChildren(node, children, recursive, viewStateContext); } catch (err) { if (node !== this.root) { this.tree.collapse(node === this.root ? null : node); } if (isPromiseCanceledError(err)) { return []; } throw err; } finally { if (node.slow) { node.slow = false; this._onDidChangeNodeSlowState.fire(node); } } } private doGetChildren(node: IAsyncDataTreeNode): Promise { let result = this.refreshPromises.get(node); if (result) { return result; } result = createCancelablePromise(async () => { const children = await this.dataSource.getChildren(node.element!); return this.processChildren(children); }); this.refreshPromises.set(node, result); return result.finally(() => { this.refreshPromises.delete(node); }); } private _onDidChangeCollapseState({ node, deep }: ICollapseStateChangeEvent | null, any>): void { if (node.element === null) { return; } if (!node.collapsed && node.element.stale) { if (deep) { this.collapse(node.element.element as T); } else { this.refreshAndRenderNode(node.element, false) .catch(onUnexpectedError); } } } private setChildren(node: IAsyncDataTreeNode, childrenElements: T[], recursive: boolean, viewStateContext?: IAsyncDataTreeViewStateContext): IAsyncDataTreeNode[] { // perf: if the node was and still is a leaf, avoid all this hassle if (node.children.length === 0 && childrenElements.length === 0) { return []; } const nodesToForget = new Map>(); const childrenTreeNodesById = new Map, collapsed: boolean }>(); for (const child of node.children) { nodesToForget.set(child.element as T, child); if (this.identityProvider) { const collapsed = this.tree.isCollapsed(child); childrenTreeNodesById.set(child.id!, { node: child, collapsed }); } } const childrenToRefresh: IAsyncDataTreeNode[] = []; const children = childrenElements.map>(element => { const hasChildren = !!this.dataSource.hasChildren(element); if (!this.identityProvider) { const asyncDataTreeNode = createAsyncDataTreeNode({ element, parent: node, hasChildren }); if (hasChildren && this.collapseByDefault && !this.collapseByDefault(element)) { asyncDataTreeNode.collapsedByDefault = false; childrenToRefresh.push(asyncDataTreeNode); } return asyncDataTreeNode; } const id = this.identityProvider.getId(element).toString(); const result = childrenTreeNodesById.get(id); if (result) { const asyncDataTreeNode = result.node; nodesToForget.delete(asyncDataTreeNode.element as T); this.nodes.delete(asyncDataTreeNode.element as T); this.nodes.set(element, asyncDataTreeNode); asyncDataTreeNode.element = element; asyncDataTreeNode.hasChildren = hasChildren; if (recursive) { if (result.collapsed) { asyncDataTreeNode.children.forEach(node => dfs(node, node => this.nodes.delete(node.element as T))); asyncDataTreeNode.children.splice(0, asyncDataTreeNode.children.length); asyncDataTreeNode.stale = true; } else { childrenToRefresh.push(asyncDataTreeNode); } } else if (hasChildren && this.collapseByDefault && !this.collapseByDefault(element)) { asyncDataTreeNode.collapsedByDefault = false; childrenToRefresh.push(asyncDataTreeNode); } return asyncDataTreeNode; } const childAsyncDataTreeNode = createAsyncDataTreeNode({ element, parent: node, id, hasChildren }); if (viewStateContext && viewStateContext.viewState.focus && viewStateContext.viewState.focus.indexOf(id) > -1) { viewStateContext.focus.push(childAsyncDataTreeNode); } if (viewStateContext && viewStateContext.viewState.selection && viewStateContext.viewState.selection.indexOf(id) > -1) { viewStateContext.selection.push(childAsyncDataTreeNode); } if (viewStateContext && viewStateContext.viewState.expanded && viewStateContext.viewState.expanded.indexOf(id) > -1) { childrenToRefresh.push(childAsyncDataTreeNode); } else if (hasChildren && this.collapseByDefault && !this.collapseByDefault(element)) { childAsyncDataTreeNode.collapsedByDefault = false; childrenToRefresh.push(childAsyncDataTreeNode); } return childAsyncDataTreeNode; }); for (const node of values(nodesToForget)) { dfs(node, node => this.nodes.delete(node.element as T)); } for (const child of children) { this.nodes.set(child.element as T, child); } node.children.splice(0, node.children.length, ...children); // TODO@joao this doesn't take filter into account if (node !== this.root && this.autoExpandSingleChildren && children.length === 1 && childrenToRefresh.length === 0) { children[0].collapsedByDefault = false; childrenToRefresh.push(children[0]); } return childrenToRefresh; } protected render(node: IAsyncDataTreeNode, viewStateContext?: IAsyncDataTreeViewStateContext): void { const children = node.children.map(node => this.asTreeElement(node, viewStateContext)); this.tree.setChildren(node === this.root ? null : node, children); if (node !== this.root) { this.tree.setCollapsible(node, node.hasChildren); } this._onDidRender.fire(); } protected asTreeElement(node: IAsyncDataTreeNode, viewStateContext?: IAsyncDataTreeViewStateContext): ITreeElement> { if (node.stale) { return { element: node, collapsible: node.hasChildren, collapsed: true }; } let collapsed: boolean | undefined; if (viewStateContext && viewStateContext.viewState.expanded && node.id && viewStateContext.viewState.expanded.indexOf(node.id) > -1) { collapsed = false; } else { collapsed = node.collapsedByDefault; } node.collapsedByDefault = undefined; return { element: node, children: node.hasChildren ? Iterator.map(Iterator.fromArray(node.children), child => this.asTreeElement(child, viewStateContext)) : [], collapsible: node.hasChildren, collapsed }; } protected processChildren(children: T[]): T[] { if (this.sorter) { children.sort(this.sorter.compare.bind(this.sorter)); } return children; } // view state getViewState(): IAsyncDataTreeViewState { if (!this.identityProvider) { throw new TreeError(this.user, 'Can\'t get tree view state without an identity provider'); } const getId = (element: T) => this.identityProvider!.getId(element).toString(); const focus = this.getFocus().map(getId); const selection = this.getSelection().map(getId); const expanded: string[] = []; const root = this.tree.getNode(); const queue = [root]; while (queue.length > 0) { const node = queue.shift()!; if (node !== root && node.collapsible && !node.collapsed) { expanded.push(getId(node.element!.element as T)); } queue.push(...node.children); } return { focus, selection, expanded, scrollTop: this.scrollTop }; } dispose(): void { this.disposables.dispose(); } } type CompressibleAsyncDataTreeNodeMapper = WeakMapper>, TFilterData>, ITreeNode, TFilterData>>; class CompressibleAsyncDataTreeNodeWrapper implements ITreeNode, TFilterData> { get element(): ICompressedTreeNode { return { elements: this.node.element.elements.map(e => e.element), incompressible: this.node.element.incompressible }; } get children(): ITreeNode, TFilterData>[] { return this.node.children.map(node => new CompressibleAsyncDataTreeNodeWrapper(node)); } get depth(): number { return this.node.depth; } get visibleChildrenCount(): number { return this.node.visibleChildrenCount; } get visibleChildIndex(): number { return this.node.visibleChildIndex; } get collapsible(): boolean { return this.node.collapsible; } get collapsed(): boolean { return this.node.collapsed; } get visible(): boolean { return this.node.visible; } get filterData(): TFilterData | undefined { return this.node.filterData; } constructor(private node: ITreeNode>, TFilterData>) { } } class CompressibleAsyncDataTreeRenderer implements ICompressibleTreeRenderer, TFilterData, IDataTreeListTemplateData> { readonly templateId: string; private renderedNodes = new Map, IDataTreeListTemplateData>(); private disposables: IDisposable[] = []; constructor( protected renderer: ICompressibleTreeRenderer, protected nodeMapper: AsyncDataTreeNodeMapper, private compressibleNodeMapperProvider: () => CompressibleAsyncDataTreeNodeMapper, readonly onDidChangeTwistieState: Event> ) { this.templateId = renderer.templateId; } renderTemplate(container: HTMLElement): IDataTreeListTemplateData { const templateData = this.renderer.renderTemplate(container); return { templateData }; } renderElement(node: ITreeNode, TFilterData>, index: number, templateData: IDataTreeListTemplateData, height: number | undefined): void { this.renderer.renderElement(this.nodeMapper.map(node) as ITreeNode, index, templateData.templateData, height); } renderCompressedElements(node: ITreeNode>, TFilterData>, index: number, templateData: IDataTreeListTemplateData, height: number | undefined): void { this.renderer.renderCompressedElements(this.compressibleNodeMapperProvider().map(node) as ITreeNode, TFilterData>, index, templateData.templateData, height); } renderTwistie(element: IAsyncDataTreeNode, twistieElement: HTMLElement): boolean { toggleClass(twistieElement, 'codicon-loading', element.slow); return false; } disposeElement(node: ITreeNode, TFilterData>, index: number, templateData: IDataTreeListTemplateData, height: number | undefined): void { if (this.renderer.disposeElement) { this.renderer.disposeElement(this.nodeMapper.map(node) as ITreeNode, index, templateData.templateData, height); } } disposeCompressedElements(node: ITreeNode>, TFilterData>, index: number, templateData: IDataTreeListTemplateData, height: number | undefined): void { if (this.renderer.disposeCompressedElements) { this.renderer.disposeCompressedElements(this.compressibleNodeMapperProvider().map(node) as ITreeNode, TFilterData>, index, templateData.templateData, height); } } disposeTemplate(templateData: IDataTreeListTemplateData): void { this.renderer.disposeTemplate(templateData.templateData); } dispose(): void { this.renderedNodes.clear(); this.disposables = dispose(this.disposables); } } export interface ITreeCompressionDelegate { isIncompressible(element: T): boolean; } function asCompressibleObjectTreeOptions(options?: ICompressibleAsyncDataTreeOptions): ICompressibleObjectTreeOptions, TFilterData> | undefined { const objectTreeOptions = options && asObjectTreeOptions(options); return objectTreeOptions && { ...objectTreeOptions, keyboardNavigationLabelProvider: objectTreeOptions.keyboardNavigationLabelProvider && { ...objectTreeOptions.keyboardNavigationLabelProvider, getCompressedNodeKeyboardNavigationLabel(els) { return options!.keyboardNavigationLabelProvider!.getCompressedNodeKeyboardNavigationLabel(els.map(e => e.element as T)); } } }; } export interface ICompressibleAsyncDataTreeOptions extends IAsyncDataTreeOptions { readonly compressionEnabled?: boolean; readonly keyboardNavigationLabelProvider?: ICompressibleKeyboardNavigationLabelProvider; } export interface ICompressibleAsyncDataTreeOptionsUpdate extends IAsyncDataTreeOptionsUpdate { readonly compressionEnabled?: boolean; } export class CompressibleAsyncDataTree extends AsyncDataTree { protected readonly tree!: CompressibleObjectTree, TFilterData>; protected readonly compressibleNodeMapper: CompressibleAsyncDataTreeNodeMapper = new WeakMapper(node => new CompressibleAsyncDataTreeNodeWrapper(node)); private filter?: ITreeFilter; constructor( user: string, container: HTMLElement, virtualDelegate: IListVirtualDelegate, private compressionDelegate: ITreeCompressionDelegate, renderers: ICompressibleTreeRenderer[], dataSource: IAsyncDataSource, options: ICompressibleAsyncDataTreeOptions = {} ) { super(user, container, virtualDelegate, renderers, dataSource, options); this.filter = options.filter; } protected createTree( user: string, container: HTMLElement, delegate: IListVirtualDelegate, renderers: ICompressibleTreeRenderer[], options: ICompressibleAsyncDataTreeOptions ): ObjectTree, TFilterData> { const objectTreeDelegate = new ComposedTreeDelegate>(delegate); const objectTreeRenderers = renderers.map(r => new CompressibleAsyncDataTreeRenderer(r, this.nodeMapper, () => this.compressibleNodeMapper, this._onDidChangeNodeSlowState.event)); const objectTreeOptions = asCompressibleObjectTreeOptions(options) || {}; return new CompressibleObjectTree(user, container, objectTreeDelegate, objectTreeRenderers, objectTreeOptions); } protected asTreeElement(node: IAsyncDataTreeNode, viewStateContext?: IAsyncDataTreeViewStateContext): ICompressedTreeElement> { return { incompressible: this.compressionDelegate.isIncompressible(node.element as T), ...super.asTreeElement(node, viewStateContext) }; } updateOptions(options: ICompressibleAsyncDataTreeOptionsUpdate = {}): void { this.tree.updateOptions(options); } getViewState(): IAsyncDataTreeViewState { if (!this.identityProvider) { throw new TreeError(this.user, 'Can\'t get tree view state without an identity provider'); } const getId = (element: T) => this.identityProvider!.getId(element).toString(); const focus = this.getFocus().map(getId); const selection = this.getSelection().map(getId); const expanded: string[] = []; const root = this.tree.getCompressedTreeNode(); const queue = [root]; while (queue.length > 0) { const node = queue.shift()!; if (node !== root && node.collapsible && !node.collapsed) { for (const asyncNode of node.element!.elements) { expanded.push(getId(asyncNode.element as T)); } } queue.push(...node.children); } return { focus, selection, expanded, scrollTop: this.scrollTop }; } protected render(node: IAsyncDataTreeNode, viewStateContext?: IAsyncDataTreeViewStateContext): void { if (!this.identityProvider) { return super.render(node, viewStateContext); } // Preserve traits across compressions. Hacky but does the trick. // This is hard to fix properly since it requires rewriting the traits // across trees and lists. Let's just keep it this way for now. const getId = (element: T) => this.identityProvider!.getId(element).toString(); const getUncompressedIds = (nodes: IAsyncDataTreeNode[]): Set => { const result = new Set(); for (const node of nodes) { const compressedNode = this.tree.getCompressedTreeNode(node === this.root ? null : node); if (!compressedNode.element) { continue; } for (const node of compressedNode.element.elements) { result.add(getId(node.element as T)); } } return result; }; const oldSelection = getUncompressedIds(this.tree.getSelection() as IAsyncDataTreeNode[]); const oldFocus = getUncompressedIds(this.tree.getFocus() as IAsyncDataTreeNode[]); super.render(node, viewStateContext); const selection = this.getSelection(); let didChangeSelection = false; const focus = this.getFocus(); let didChangeFocus = false; const visit = (node: ITreeNode> | null, TFilterData>) => { const compressedNode = node.element; if (compressedNode) { for (let i = 0; i < compressedNode.elements.length; i++) { const id = getId(compressedNode.elements[i].element as T); const element = compressedNode.elements[compressedNode.elements.length - 1].element as T; // github.com/microsoft/vscode/issues/85938 if (oldSelection.has(id) && selection.indexOf(element) === -1) { selection.push(element); didChangeSelection = true; } if (oldFocus.has(id) && focus.indexOf(element) === -1) { focus.push(element); didChangeFocus = true; } } } node.children.forEach(visit); }; visit(this.tree.getCompressedTreeNode(node === this.root ? null : node)); if (didChangeSelection) { this.setSelection(selection); } if (didChangeFocus) { this.setFocus(focus); } } // For compressed async data trees, `TreeVisibility.Recurse` doesn't currently work // and we have to filter everything beforehand // Related to #85193 and #85835 protected processChildren(children: T[]): T[] { if (this.filter) { children = children.filter(e => { const result = this.filter!.filter(e, TreeVisibility.Visible); const visibility = getVisibility(result); if (visibility === TreeVisibility.Recurse) { throw new Error('Recursive tree visibility not supported in async data compressed trees'); } return visibility === TreeVisibility.Visible; }); } return super.processChildren(children); } } function getVisibility(filterResult: TreeFilterResult): TreeVisibility { if (typeof filterResult === 'boolean') { return filterResult ? TreeVisibility.Visible : TreeVisibility.Hidden; } else if (isFilterResult(filterResult)) { return getVisibleState(filterResult.visibility); } else { return getVisibleState(filterResult); } }