-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Expand file tree
/
Copy pathTreeDiff.ts
More file actions
128 lines (111 loc) · 3.31 KB
/
TreeDiff.ts
File metadata and controls
128 lines (111 loc) · 3.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
interface Node<T> {
id: string;
children: T[];
}
export interface DiffNode<T> {
type: 'same' | 'added' | 'removed' | 'modified';
node: T;
oldNode?: T;
children: Array<DiffNode<T>>;
}
export class TreeDiff {
static compute<T extends Node<T>>(oldNode: T, newNode: T): DiffNode<T> {
if (oldNode.id !== newNode.id) {
// Different IDs implies a replacement (remove old, add new).
// We return 'modified' to represent this at the root level,
// but strictly speaking it's a swap.
return {
type: 'modified',
node: newNode,
oldNode: oldNode,
children: [],
};
}
const childrenDiff = this.#diffChildren(oldNode.children, newNode.children);
return {
type: 'same',
node: newNode,
oldNode: oldNode,
children: childrenDiff,
};
}
static #diffChildren<T extends Node<T>>(
oldChildren: T[],
newChildren: T[],
): Array<DiffNode<T>> {
const result: Array<DiffNode<T>> = [];
// Index old children for O(1) lookup
const oldMap = new Map<string, {node: T; index: number}>();
oldChildren.forEach((node, index) => {
oldMap.set(node.id, {node, index});
});
// Set of new keys for quick existence check
const newKeys = new Set(newChildren.map(n => n.id));
let cursor = 0;
for (const newChild of newChildren) {
const oldEntry = oldMap.get(newChild.id);
if (oldEntry) {
// Matched by ID
const {node: oldChild, index: oldIndex} = oldEntry;
// Check for removals of nodes skipped in the old list
if (oldIndex >= cursor) {
for (let i = cursor; i < oldIndex; i++) {
const candidate = oldChildren[i];
// If the candidate is NOT in the new list, it was removed.
// If it IS in the new list, it was moved (we'll see it later).
if (!newKeys.has(candidate.id)) {
result.push({
type: 'removed',
node: candidate,
children: this.#allRemoved(candidate.children),
});
}
}
cursor = oldIndex + 1;
}
// Recurse on the match
result.push(this.compute(oldChild, newChild));
} else {
// Added
result.push({
type: 'added',
node: newChild,
children: this.#allAdded(newChild.children),
});
}
}
// Append any remaining removals from the end of the old list
if (cursor < oldChildren.length) {
for (let i = cursor; i < oldChildren.length; i++) {
const candidate = oldChildren[i];
if (!newKeys.has(candidate.id)) {
result.push({
type: 'removed',
node: candidate,
children: this.#allRemoved(candidate.children),
});
}
}
}
return result;
}
static #allAdded<T extends Node<T>>(nodes: T[]): Array<DiffNode<T>> {
return nodes.map(node => ({
type: 'added',
node: node,
children: this.#allAdded(node.children),
}));
}
static #allRemoved<T extends Node<T>>(nodes: T[]): Array<DiffNode<T>> {
return nodes.map(node => ({
type: 'removed',
node: node,
children: this.#allRemoved(node.children),
}));
}
}