1/* 2 * Copyright 2020, The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17// TODO (b/162300507): Get rid of cloning 18import cloneDeep from 'lodash.clonedeep'; 19 20export const DiffType = Object.freeze({ 21 NONE: 'none', 22 ADDED: 'added', 23 DELETED: 'deleted', 24 ADDED_MOVE: 'addedMove', 25 DELETED_MOVE: 'deletedMove', 26 MODIFIED: 'modified', 27}); 28 29export function asRawTreeViewObject(obj) { 30 const children = obj.children?.map(child => child.rawTreeViewObject) ?? [] 31 32 return { 33 kind: obj.kind, 34 name: obj.name, 35 shortName: obj.shortName, 36 stableId: obj.stableId, 37 chips: obj.chips, 38 obj: obj.obj, 39 children, 40 ref: obj, 41 }; 42} 43 44export function defaultModifiedCheck(newNode, oldNode) { 45 if (!newNode && !oldNode) { 46 return false; 47 } 48 49 if ((newNode && !oldNode) || (!newNode && oldNode)) { 50 return true; 51 } 52 53 return JSON.stringify(newNode.obj) !== JSON.stringify(oldNode.obj); 54} 55 56function isPrimitive(test) { 57 return test !== Object(test); 58} 59 60export class DiffGenerator { 61 constructor(tree) { 62 if (tree.rawTreeViewObject) { 63 this.tree = tree.rawTreeViewObject; 64 } else { 65 this.tree = tree; 66 } 67 } 68 69 compareWith(tree) { 70 if (tree?.rawTreeViewObject) { 71 this.diffWithTree = tree.rawTreeViewObject; 72 } else { 73 this.diffWithTree = tree; 74 } 75 76 return this; 77 } 78 79 withUniqueNodeId(getNodeId) { 80 this.getNodeId = (node) => { 81 const id = getNodeId(node); 82 if (id === null || id === undefined) { 83 console.error('Null node ID for node', node); 84 throw new Error('Node ID can\'t be null or undefined'); 85 } 86 return id; 87 }; 88 return this; 89 } 90 91 withModifiedCheck(isModified) { 92 this.isModified = isModified; 93 return this; 94 } 95 96 generateDiffTree() { 97 this.newMapping = this._generateIdToNodeMapping(this.tree); 98 this.oldMapping = this.diffWithTree ? 99 this._generateIdToNodeMapping(this.diffWithTree) : {}; 100 101 const diffTrees = 102 this._generateDiffTree(this.tree, this.diffWithTree, [], []); 103 104 let diffTree; 105 if (diffTrees.length > 1) { 106 diffTree = { 107 kind: '', 108 name: 'DiffTree', 109 children: diffTrees, 110 stableId: 'DiffTree', 111 }; 112 } else { 113 diffTree = diffTrees[0]; 114 } 115 116 return Object.freeze(diffTree); 117 } 118 119 _generateIdToNodeMapping(node, acc) { 120 acc = acc || {}; 121 122 const nodeId = this.getNodeId(node); 123 124 if (acc[nodeId]) { 125 throw new Error(`Duplicate node id '${nodeId}' detected...`); 126 } 127 128 acc[this.getNodeId(node)] = node; 129 130 if (node.children) { 131 for (const child of node.children) { 132 this._generateIdToNodeMapping(child, acc); 133 } 134 } 135 136 return acc; 137 } 138 139 _objIsEmpty(obj) { 140 return Object.keys(obj).length === 0 && obj.constructor === Object; 141 } 142 143 _cloneNodeWithoutChildren(node) { 144 const clone = {}; 145 146 for (const key in node) { 147 if (key !== 'children') { 148 const val = node[key]; 149 clone[key] = isPrimitive(val) ? val : cloneDeep(val); 150 } 151 } 152 153 return clone; 154 } 155 156 _generateDiffTree(newTree, oldTree, newTreeSiblings, oldTreeSiblings) { 157 const diffTrees = []; 158 159 // NOTE: A null ID represents a non existent node. 160 const newId = newTree ? this.getNodeId(newTree) : null; 161 const oldId = oldTree ? this.getNodeId(oldTree) : null; 162 163 const newTreeSiblingIds = newTreeSiblings.map(this.getNodeId); 164 const oldTreeSiblingIds = oldTreeSiblings.map(this.getNodeId); 165 166 if (newTree) { 167 // Deep clone newTree omitting children field 168 // Clone is required because trees are frozen objects — we can't 169 // modify the original tree object. Also means there is no side effect. 170 const diffTree = this._cloneNodeWithoutChildren(newTree); 171 172 // Default to no changes 173 diffTree.diff = {type: DiffType.NONE}; 174 175 if (newId !== oldId) { 176 // A move, addition, or deletion has occurred 177 let nextOldTree; 178 179 // Check if newTree has been added or moved 180 if (!oldTreeSiblingIds.includes(newId)) { 181 if (this.oldMapping[newId]) { 182 // Objected existed in old tree, the counter party 183 // DELETED_MOVE will be/has been flagged and added to the 184 // diffTree when visiting it in the oldTree. 185 diffTree.diff = {type: DiffType.ADDED_MOVE}; 186 187 // TODO: Figure out if oldTree is ever visited now... 188 189 // Switch out oldTree for new one to compare against 190 nextOldTree = this.oldMapping[newId]; 191 } else { 192 diffTree.diff = {type: DiffType.ADDED}; 193 194 // TODO: Figure out if oldTree is ever visited now... 195 196 // Stop comparing against oldTree 197 nextOldTree = null; 198 } 199 } 200 201 // Check if oldTree has been deleted of moved 202 if (oldTree && !newTreeSiblingIds.includes(oldId)) { 203 const deletedTreeDiff = this._cloneNodeWithoutChildren(oldTree); 204 205 if (this.newMapping[oldId]) { 206 deletedTreeDiff.diff = {type: DiffType.DELETED_MOVE}; 207 208 // Stop comparing against oldTree, will be/has been 209 // visited when object is seen in newTree 210 nextOldTree = null; 211 } else { 212 deletedTreeDiff.diff = {type: DiffType.DELETED}; 213 214 // Visit all children to check if they have been deleted or moved 215 deletedTreeDiff.children = this._visitChildren(null, oldTree); 216 } 217 218 diffTrees.push(deletedTreeDiff); 219 } 220 221 oldTree = nextOldTree; 222 } else { 223 // TODO: Always have modified check and add modified tags on top of 224 // others 225 // NOTE: Doesn't check for moved and modified at the same time 226 if (this.isModified && this.isModified(newTree, oldTree)) { 227 diffTree.diff = {type: DiffType.MODIFIED}; 228 } 229 } 230 231 diffTree.children = this._visitChildren(newTree, oldTree); 232 diffTrees.push(diffTree); 233 } else if (oldTree) { 234 if (!newTreeSiblingIds.includes(oldId)) { 235 // Deep clone oldTree omitting children field 236 const diffTree = this._cloneNodeWithoutChildren(oldTree); 237 238 // newTree doesn't exists, oldTree has either been moved or deleted. 239 if (this.newMapping[oldId]) { 240 diffTree.diff = {type: DiffType.DELETED_MOVE}; 241 } else { 242 diffTree.diff = {type: DiffType.DELETED}; 243 } 244 245 diffTree.children = this._visitChildren(null, oldTree); 246 diffTrees.push(diffTree); 247 } 248 } else { 249 throw new Error('Both newTree and oldTree are undefined...'); 250 } 251 252 return diffTrees; 253 } 254 255 _visitChildren(newTree, oldTree) { 256 // Recursively traverse all children of new and old tree. 257 const diffChildren = []; 258 259 // TODO: Try replacing this with some sort of zipWith. 260 const numOfChildren = Math.max( 261 newTree?.children.length ?? 0, oldTree?.children.length ?? 0); 262 for (let i = 0; i < numOfChildren; i++) { 263 const newChild = i < newTree?.children.length ? 264 newTree.children[i] : null; 265 266 const oldChild = i < oldTree?.children.length ? 267 oldTree.children[i] : null; 268 269 const childDiffTrees = this._generateDiffTree( 270 newChild, oldChild, 271 newTree?.children ?? [], oldTree?.children ?? [], 272 ); 273 diffChildren.push(...childDiffTrees); 274 } 275 276 return diffChildren; 277 } 278} 279