/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { IIdentityProvider } from 'vs/base/browser/ui/list/list'; import { ICollapseStateChangeEvent, ITreeElement, ITreeFilter, ITreeFilterDataResult, ITreeModel, ITreeNode, TreeVisibility, ITreeModelSpliceEvent, TreeError } from 'vs/base/browser/ui/tree/tree'; import { tail2 } from 'vs/base/common/arrays'; import { LcsDiff } from 'vs/base/common/diff/diff'; import { Emitter, Event, EventBufferer } from 'vs/base/common/event'; import { Iterable } from 'vs/base/common/iterator'; import { ISpliceable } from 'vs/base/common/sequence'; // Exported for tests export interface IIndexTreeNode extends ITreeNode { readonly parent: IIndexTreeNode | undefined; readonly children: IIndexTreeNode[]; visibleChildrenCount: number; visibleChildIndex: number; collapsible: boolean; collapsed: boolean; renderNodeCount: number; visibility: TreeVisibility; visible: boolean; filterData: TFilterData | undefined; } export function isFilterResult(obj: any): obj is ITreeFilterDataResult { return typeof obj === 'object' && 'visibility' in obj && 'data' in obj; } export function getVisibleState(visibility: boolean | TreeVisibility): TreeVisibility { switch (visibility) { case true: return TreeVisibility.Visible; case false: return TreeVisibility.Hidden; default: return visibility; } } export interface IIndexTreeModelOptions { readonly collapseByDefault?: boolean; // defaults to false readonly filter?: ITreeFilter; readonly autoExpandSingleChildren?: boolean; } export interface IIndexedSpliceOptions { /** * If set, child updates will recurse the given number of levels even if * items in the splice operation are unchanged. `Infinity` is a valid value. */ readonly diffDeep?: number; /** * Identity provider used to optimize splice() calls in the IndexTree. If * this is not present, optimized splicing is not enabled. * * Warning: if this is present, calls to `setChildren()` will not replace * or update nodes if their identity is the same, even if the elements are * different. For this, you should call `rerender()`. */ readonly diffIdentityProvider?: IIdentityProvider; /** * Callback for when a node is created. */ onDidCreateNode?: (node: ITreeNode) => void; /** * Callback for when a node is deleted. */ onDidDeleteNode?: (node: ITreeNode) => void } interface CollapsibleStateUpdate { readonly collapsible: boolean; } interface CollapsedStateUpdate { readonly collapsed: boolean; readonly recursive: boolean; } type CollapseStateUpdate = CollapsibleStateUpdate | CollapsedStateUpdate; function isCollapsibleStateUpdate(update: CollapseStateUpdate): update is CollapsibleStateUpdate { return typeof (update as any).collapsible === 'boolean'; } export interface IList extends ISpliceable { updateElementHeight(index: number, height: number): void; } export class IndexTreeModel, TFilterData = void> implements ITreeModel { readonly rootRef = []; private root: IIndexTreeNode; private eventBufferer = new EventBufferer(); private readonly _onDidChangeCollapseState = new Emitter>(); readonly onDidChangeCollapseState: Event> = this.eventBufferer.wrapEvent(this._onDidChangeCollapseState.event); private readonly _onDidChangeRenderNodeCount = new Emitter>(); readonly onDidChangeRenderNodeCount: Event> = this.eventBufferer.wrapEvent(this._onDidChangeRenderNodeCount.event); private collapseByDefault: boolean; private filter?: ITreeFilter; private autoExpandSingleChildren: boolean; private readonly _onDidSplice = new Emitter>(); readonly onDidSplice = this._onDidSplice.event; constructor( private user: string, private list: IList>, rootElement: T, options: IIndexTreeModelOptions = {} ) { this.collapseByDefault = typeof options.collapseByDefault === 'undefined' ? false : options.collapseByDefault; this.filter = options.filter; this.autoExpandSingleChildren = typeof options.autoExpandSingleChildren === 'undefined' ? false : options.autoExpandSingleChildren; this.root = { parent: undefined, element: rootElement, children: [], depth: 0, visibleChildrenCount: 0, visibleChildIndex: -1, collapsible: false, collapsed: false, renderNodeCount: 0, visibility: TreeVisibility.Visible, visible: true, filterData: undefined }; } splice( location: number[], deleteCount: number, toInsert: Iterable> = Iterable.empty(), options: IIndexedSpliceOptions = {}, ): void { if (location.length === 0) { throw new TreeError(this.user, 'Invalid tree location'); } if (options.diffIdentityProvider) { this.spliceSmart(options.diffIdentityProvider, location, deleteCount, toInsert, options); } else { this.spliceSimple(location, deleteCount, toInsert, options); } } private spliceSmart( identity: IIdentityProvider, location: number[], deleteCount: number, toInsertIterable: Iterable> = Iterable.empty(), options: IIndexedSpliceOptions, recurseLevels = options.diffDeep ?? 0, ) { const { parentNode } = this.getParentNodeWithListIndex(location); const toInsert = [...toInsertIterable]; const index = location[location.length - 1]; const diff = new LcsDiff( { getElements: () => parentNode.children.map(e => identity.getId(e.element).toString()) }, { getElements: () => [ ...parentNode.children.slice(0, index), ...toInsert, ...parentNode.children.slice(index + deleteCount), ].map(e => identity.getId(e.element).toString()) }, ).ComputeDiff(false); // if we were given a 'best effort' diff, use default behavior if (diff.quitEarly) { return this.spliceSimple(location, deleteCount, toInsert, options); } const locationPrefix = location.slice(0, -1); const recurseSplice = (fromOriginal: number, fromModified: number, count: number) => { if (recurseLevels > 0) { for (let i = 0; i < count; i++) { fromOriginal--; fromModified--; this.spliceSmart( identity, [...locationPrefix, fromOriginal, 0], Number.MAX_SAFE_INTEGER, toInsert[fromModified].children, options, recurseLevels - 1, ); } } }; let lastStartO = Math.min(parentNode.children.length, index + deleteCount); let lastStartM = toInsert.length; for (const change of diff.changes.sort((a, b) => b.originalStart - a.originalStart)) { recurseSplice(lastStartO, lastStartM, lastStartO - (change.originalStart + change.originalLength)); lastStartO = change.originalStart; lastStartM = change.modifiedStart - index; this.spliceSimple( [...locationPrefix, lastStartO], change.originalLength, Iterable.slice(toInsert, lastStartM, lastStartM + change.modifiedLength), options, ); } // at this point, startO === startM === count since any remaining prefix should match recurseSplice(lastStartO, lastStartM, lastStartO); } private spliceSimple( location: number[], deleteCount: number, toInsert: Iterable> = Iterable.empty(), { onDidCreateNode, onDidDeleteNode }: IIndexedSpliceOptions, ) { const { parentNode, listIndex, revealed, visible } = this.getParentNodeWithListIndex(location); const treeListElementsToInsert: ITreeNode[] = []; const nodesToInsertIterator = Iterable.map(toInsert, el => this.createTreeNode(el, parentNode, parentNode.visible ? TreeVisibility.Visible : TreeVisibility.Hidden, revealed, treeListElementsToInsert, onDidCreateNode)); const lastIndex = location[location.length - 1]; const lastHadChildren = parentNode.children.length > 0; // figure out what's the visible child start index right before the // splice point let visibleChildStartIndex = 0; for (let i = lastIndex; i >= 0 && i < parentNode.children.length; i--) { const child = parentNode.children[i]; if (child.visible) { visibleChildStartIndex = child.visibleChildIndex; break; } } const nodesToInsert: IIndexTreeNode[] = []; let insertedVisibleChildrenCount = 0; let renderNodeCount = 0; for (const child of nodesToInsertIterator) { nodesToInsert.push(child); renderNodeCount += child.renderNodeCount; if (child.visible) { child.visibleChildIndex = visibleChildStartIndex + insertedVisibleChildrenCount++; } } const deletedNodes = parentNode.children.splice(lastIndex, deleteCount, ...nodesToInsert); // figure out what is the count of deleted visible children let deletedVisibleChildrenCount = 0; for (const child of deletedNodes) { if (child.visible) { deletedVisibleChildrenCount++; } } // and adjust for all visible children after the splice point if (deletedVisibleChildrenCount !== 0) { for (let i = lastIndex + nodesToInsert.length; i < parentNode.children.length; i++) { const child = parentNode.children[i]; if (child.visible) { child.visibleChildIndex -= deletedVisibleChildrenCount; } } } // update parent's visible children count parentNode.visibleChildrenCount += insertedVisibleChildrenCount - deletedVisibleChildrenCount; if (revealed && visible) { const visibleDeleteCount = deletedNodes.reduce((r, node) => r + (node.visible ? node.renderNodeCount : 0), 0); this._updateAncestorsRenderNodeCount(parentNode, renderNodeCount - visibleDeleteCount); this.list.splice(listIndex, visibleDeleteCount, treeListElementsToInsert); } if (deletedNodes.length > 0 && onDidDeleteNode) { const visit = (node: ITreeNode) => { onDidDeleteNode(node); node.children.forEach(visit); }; deletedNodes.forEach(visit); } const currentlyHasChildren = parentNode.children.length > 0; if (lastHadChildren !== currentlyHasChildren) { this.setCollapsible(location.slice(0, -1), currentlyHasChildren); } this._onDidSplice.fire({ insertedNodes: nodesToInsert, deletedNodes }); let node: IIndexTreeNode | undefined = parentNode; while (node) { if (node.visibility === TreeVisibility.Recurse) { this.refilter(); break; } node = node.parent; } } rerender(location: number[]): void { if (location.length === 0) { throw new TreeError(this.user, 'Invalid tree location'); } const { node, listIndex, revealed } = this.getTreeNodeWithListIndex(location); if (node.visible && revealed) { this.list.splice(listIndex, 1, [node]); } } updateElementHeight(location: number[], height: number): void { if (location.length === 0) { throw new TreeError(this.user, 'Invalid tree location'); } const { listIndex } = this.getTreeNodeWithListIndex(location); this.list.updateElementHeight(listIndex, height); } has(location: number[]): boolean { return this.hasTreeNode(location); } getListIndex(location: number[]): number { const { listIndex, visible, revealed } = this.getTreeNodeWithListIndex(location); return visible && revealed ? listIndex : -1; } getListRenderCount(location: number[]): number { return this.getTreeNode(location).renderNodeCount; } isCollapsible(location: number[]): boolean { return this.getTreeNode(location).collapsible; } setCollapsible(location: number[], collapsible?: boolean): boolean { const node = this.getTreeNode(location); if (typeof collapsible === 'undefined') { collapsible = !node.collapsible; } const update: CollapsibleStateUpdate = { collapsible }; return this.eventBufferer.bufferEvents(() => this._setCollapseState(location, update)); } isCollapsed(location: number[]): boolean { return this.getTreeNode(location).collapsed; } setCollapsed(location: number[], collapsed?: boolean, recursive?: boolean): boolean { const node = this.getTreeNode(location); if (typeof collapsed === 'undefined') { collapsed = !node.collapsed; } const update: CollapsedStateUpdate = { collapsed, recursive: recursive || false }; return this.eventBufferer.bufferEvents(() => this._setCollapseState(location, update)); } private _setCollapseState(location: number[], update: CollapseStateUpdate): boolean { const { node, listIndex, revealed } = this.getTreeNodeWithListIndex(location); const result = this._setListNodeCollapseState(node, listIndex, revealed, update); if (node !== this.root && this.autoExpandSingleChildren && result && !isCollapsibleStateUpdate(update) && node.collapsible && !node.collapsed && !update.recursive) { let onlyVisibleChildIndex = -1; for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; if (child.visible) { if (onlyVisibleChildIndex > -1) { onlyVisibleChildIndex = -1; break; } else { onlyVisibleChildIndex = i; } } } if (onlyVisibleChildIndex > -1) { this._setCollapseState([...location, onlyVisibleChildIndex], update); } } return result; } private _setListNodeCollapseState(node: IIndexTreeNode, listIndex: number, revealed: boolean, update: CollapseStateUpdate): boolean { const result = this._setNodeCollapseState(node, update, false); if (!revealed || !node.visible || !result) { return result; } const previousRenderNodeCount = node.renderNodeCount; const toInsert = this.updateNodeAfterCollapseChange(node); const deleteCount = previousRenderNodeCount - (listIndex === -1 ? 0 : 1); this.list.splice(listIndex + 1, deleteCount, toInsert.slice(1)); return result; } private _setNodeCollapseState(node: IIndexTreeNode, update: CollapseStateUpdate, deep: boolean): boolean { let result: boolean; if (node === this.root) { result = false; } else { if (isCollapsibleStateUpdate(update)) { result = node.collapsible !== update.collapsible; node.collapsible = update.collapsible; } else if (!node.collapsible) { result = false; } else { result = node.collapsed !== update.collapsed; node.collapsed = update.collapsed; } if (result) { this._onDidChangeCollapseState.fire({ node, deep }); } } if (!isCollapsibleStateUpdate(update) && update.recursive) { for (const child of node.children) { result = this._setNodeCollapseState(child, update, true) || result; } } return result; } expandTo(location: number[]): void { this.eventBufferer.bufferEvents(() => { let node = this.getTreeNode(location); while (node.parent) { node = node.parent; location = location.slice(0, location.length - 1); if (node.collapsed) { this._setCollapseState(location, { collapsed: false, recursive: false }); } } }); } refilter(): void { const previousRenderNodeCount = this.root.renderNodeCount; const toInsert = this.updateNodeAfterFilterChange(this.root); this.list.splice(0, previousRenderNodeCount, toInsert); } private createTreeNode( treeElement: ITreeElement, parent: IIndexTreeNode, parentVisibility: TreeVisibility, revealed: boolean, treeListElements: ITreeNode[], onDidCreateNode?: (node: ITreeNode) => void ): IIndexTreeNode { const node: IIndexTreeNode = { parent, element: treeElement.element, children: [], depth: parent.depth + 1, visibleChildrenCount: 0, visibleChildIndex: -1, collapsible: typeof treeElement.collapsible === 'boolean' ? treeElement.collapsible : (typeof treeElement.collapsed !== 'undefined'), collapsed: typeof treeElement.collapsed === 'undefined' ? this.collapseByDefault : treeElement.collapsed, renderNodeCount: 1, visibility: TreeVisibility.Visible, visible: true, filterData: undefined }; const visibility = this._filterNode(node, parentVisibility); node.visibility = visibility; if (revealed) { treeListElements.push(node); } const childElements = treeElement.children || Iterable.empty(); const childRevealed = revealed && visibility !== TreeVisibility.Hidden && !node.collapsed; const childNodes = Iterable.map(childElements, el => this.createTreeNode(el, node, visibility, childRevealed, treeListElements, onDidCreateNode)); let visibleChildrenCount = 0; let renderNodeCount = 1; for (const child of childNodes) { node.children.push(child); renderNodeCount += child.renderNodeCount; if (child.visible) { child.visibleChildIndex = visibleChildrenCount++; } } node.collapsible = node.collapsible || node.children.length > 0; node.visibleChildrenCount = visibleChildrenCount; node.visible = visibility === TreeVisibility.Recurse ? visibleChildrenCount > 0 : (visibility === TreeVisibility.Visible); if (!node.visible) { node.renderNodeCount = 0; if (revealed) { treeListElements.pop(); } } else if (!node.collapsed) { node.renderNodeCount = renderNodeCount; } if (onDidCreateNode) { onDidCreateNode(node); } return node; } private updateNodeAfterCollapseChange(node: IIndexTreeNode): ITreeNode[] { const previousRenderNodeCount = node.renderNodeCount; const result: ITreeNode[] = []; this._updateNodeAfterCollapseChange(node, result); this._updateAncestorsRenderNodeCount(node.parent, result.length - previousRenderNodeCount); return result; } private _updateNodeAfterCollapseChange(node: IIndexTreeNode, result: ITreeNode[]): number { if (node.visible === false) { return 0; } result.push(node); node.renderNodeCount = 1; if (!node.collapsed) { for (const child of node.children) { node.renderNodeCount += this._updateNodeAfterCollapseChange(child, result); } } this._onDidChangeRenderNodeCount.fire(node); return node.renderNodeCount; } private updateNodeAfterFilterChange(node: IIndexTreeNode): ITreeNode[] { const previousRenderNodeCount = node.renderNodeCount; const result: ITreeNode[] = []; this._updateNodeAfterFilterChange(node, node.visible ? TreeVisibility.Visible : TreeVisibility.Hidden, result); this._updateAncestorsRenderNodeCount(node.parent, result.length - previousRenderNodeCount); return result; } private _updateNodeAfterFilterChange(node: IIndexTreeNode, parentVisibility: TreeVisibility, result: ITreeNode[], revealed = true): boolean { let visibility: TreeVisibility; if (node !== this.root) { visibility = this._filterNode(node, parentVisibility); if (visibility === TreeVisibility.Hidden) { node.visible = false; node.renderNodeCount = 0; return false; } if (revealed) { result.push(node); } } const resultStartLength = result.length; node.renderNodeCount = node === this.root ? 0 : 1; let hasVisibleDescendants = false; if (!node.collapsed || visibility! !== TreeVisibility.Hidden) { let visibleChildIndex = 0; for (const child of node.children) { hasVisibleDescendants = this._updateNodeAfterFilterChange(child, visibility!, result, revealed && !node.collapsed) || hasVisibleDescendants; if (child.visible) { child.visibleChildIndex = visibleChildIndex++; } } node.visibleChildrenCount = visibleChildIndex; } else { node.visibleChildrenCount = 0; } if (node !== this.root) { node.visible = visibility! === TreeVisibility.Recurse ? hasVisibleDescendants : (visibility! === TreeVisibility.Visible); } if (!node.visible) { node.renderNodeCount = 0; if (revealed) { result.pop(); } } else if (!node.collapsed) { node.renderNodeCount += result.length - resultStartLength; } this._onDidChangeRenderNodeCount.fire(node); return node.visible; } private _updateAncestorsRenderNodeCount(node: IIndexTreeNode | undefined, diff: number): void { if (diff === 0) { return; } while (node) { node.renderNodeCount += diff; this._onDidChangeRenderNodeCount.fire(node); node = node.parent; } } private _filterNode(node: IIndexTreeNode, parentVisibility: TreeVisibility): TreeVisibility { const result = this.filter ? this.filter.filter(node.element, parentVisibility) : TreeVisibility.Visible; if (typeof result === 'boolean') { node.filterData = undefined; return result ? TreeVisibility.Visible : TreeVisibility.Hidden; } else if (isFilterResult(result)) { node.filterData = result.data; return getVisibleState(result.visibility); } else { node.filterData = undefined; return getVisibleState(result); } } // cheap private hasTreeNode(location: number[], node: IIndexTreeNode = this.root): boolean { if (!location || location.length === 0) { return true; } const [index, ...rest] = location; if (index < 0 || index > node.children.length) { return false; } return this.hasTreeNode(rest, node.children[index]); } // cheap private getTreeNode(location: number[], node: IIndexTreeNode = this.root): IIndexTreeNode { if (!location || location.length === 0) { return node; } const [index, ...rest] = location; if (index < 0 || index > node.children.length) { throw new TreeError(this.user, 'Invalid tree location'); } return this.getTreeNode(rest, node.children[index]); } // expensive private getTreeNodeWithListIndex(location: number[]): { node: IIndexTreeNode, listIndex: number, revealed: boolean, visible: boolean } { if (location.length === 0) { return { node: this.root, listIndex: -1, revealed: true, visible: false }; } const { parentNode, listIndex, revealed, visible } = this.getParentNodeWithListIndex(location); const index = location[location.length - 1]; if (index < 0 || index > parentNode.children.length) { throw new TreeError(this.user, 'Invalid tree location'); } const node = parentNode.children[index]; return { node, listIndex, revealed, visible: visible && node.visible }; } private getParentNodeWithListIndex(location: number[], node: IIndexTreeNode = this.root, listIndex: number = 0, revealed = true, visible = true): { parentNode: IIndexTreeNode; listIndex: number; revealed: boolean; visible: boolean; } { const [index, ...rest] = location; if (index < 0 || index > node.children.length) { throw new TreeError(this.user, 'Invalid tree location'); } // TODO@joao perf! for (let i = 0; i < index; i++) { listIndex += node.children[i].renderNodeCount; } revealed = revealed && !node.collapsed; visible = visible && node.visible; if (rest.length === 0) { return { parentNode: node, listIndex, revealed, visible }; } return this.getParentNodeWithListIndex(rest, node.children[index], listIndex + 1, revealed, visible); } getNode(location: number[] = []): ITreeNode { return this.getTreeNode(location); } // TODO@joao perf! getNodeLocation(node: ITreeNode): number[] { const location: number[] = []; let indexTreeNode = node as IIndexTreeNode; // typing woes while (indexTreeNode.parent) { location.push(indexTreeNode.parent.children.indexOf(indexTreeNode)); indexTreeNode = indexTreeNode.parent; } return location.reverse(); } getParentNodeLocation(location: number[]): number[] | undefined { if (location.length === 0) { return undefined; } else if (location.length === 1) { return []; } else { return tail2(location)[0]; } } getFirstElementChild(location: number[]): T | undefined { const node = this.getTreeNode(location); if (node.children.length === 0) { return undefined; } return node.children[0].element; } getLastElementAncestor(location: number[] = []): T | undefined { const node = this.getTreeNode(location); if (node.children.length === 0) { return undefined; } return this._getLastElementAncestor(node); } private _getLastElementAncestor(node: ITreeNode): T { if (node.children.length === 0) { return node.element; } return this._getLastElementAncestor(node.children[node.children.length - 1]); } }