Skip to content

Commit d73aba3

Browse files
committed
feat: snapshot diff
1 parent a6cd2cd commit d73aba3

File tree

6 files changed

+877
-12
lines changed

6 files changed

+877
-12
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js';
8+
import {TreeDiff, type DiffNode} from '../utils/TreeDiff.js';
9+
10+
import {SnapshotFormatter} from './SnapshotFormatter.js';
11+
12+
export class SnapshotDiffFormatter {
13+
#root: DiffNode<TextSnapshotNode>;
14+
#oldFormatter: SnapshotFormatter;
15+
#newFormatter: SnapshotFormatter;
16+
17+
constructor(
18+
root: DiffNode<TextSnapshotNode>,
19+
oldFormatter: SnapshotFormatter,
20+
newFormatter: SnapshotFormatter,
21+
) {
22+
this.#root = root;
23+
this.#oldFormatter = oldFormatter;
24+
this.#newFormatter = newFormatter;
25+
}
26+
27+
toString(): string {
28+
const lines = this.#formatDiffNode(this.#root, 0);
29+
const hasChanges = lines.some(l => l.startsWith('+') || l.startsWith('-'));
30+
if (!hasChanges) {
31+
return '';
32+
}
33+
return lines.join('').trimEnd();
34+
}
35+
36+
#formatDiffNode(
37+
diffNode: DiffNode<TextSnapshotNode>,
38+
depth: number,
39+
): string[] {
40+
const chunks: string[] = [];
41+
42+
if (diffNode.type === 'same') {
43+
const oldLine = this.#oldFormatter.formatNodeSelf(
44+
diffNode.oldNode!,
45+
depth,
46+
);
47+
const newLine = this.#newFormatter.formatNodeSelf(diffNode.node, depth);
48+
if (oldLine === newLine) {
49+
chunks.push(' ' + newLine);
50+
} else {
51+
chunks.push('- ' + oldLine);
52+
chunks.push('+ ' + newLine);
53+
}
54+
// Children
55+
for (const child of diffNode.children) {
56+
chunks.push(...this.#formatDiffNode(child, depth + 1));
57+
}
58+
} else if (diffNode.type === 'added') {
59+
chunks.push(
60+
'+ ' + this.#newFormatter.formatNodeSelf(diffNode.node, depth),
61+
);
62+
// Recursively add children (they are also 'added' in the tree)
63+
for (const child of diffNode.children) {
64+
chunks.push(...this.#formatDiffNode(child, depth + 1));
65+
}
66+
} else if (diffNode.type === 'removed') {
67+
chunks.push(
68+
'- ' + this.#oldFormatter.formatNodeSelf(diffNode.node, depth),
69+
);
70+
// Recursively remove children (they are also 'removed' in the tree)
71+
for (const child of diffNode.children) {
72+
chunks.push(...this.#formatDiffNode(child, depth + 1));
73+
}
74+
} else if (diffNode.type === 'modified') {
75+
chunks.push(
76+
'- ' + this.#oldFormatter.formatNodeSelf(diffNode.oldNode!, depth),
77+
);
78+
chunks.push(
79+
'+ ' + this.#newFormatter.formatNodeSelf(diffNode.node, depth),
80+
);
81+
}
82+
83+
return chunks;
84+
}
85+
86+
toJSON(): object {
87+
return this.#nodeToJSON(this.#root) ?? {};
88+
}
89+
90+
#nodeToJSON(diffNode: DiffNode<TextSnapshotNode>): object | null {
91+
if (diffNode.type === 'same') {
92+
const oldJson = this.#oldFormatter.nodeToJSON(diffNode.oldNode!);
93+
const newJson = this.#newFormatter.nodeToJSON(diffNode.node);
94+
const childrenDiff = diffNode.children
95+
.map(child => this.#nodeToJSON(child))
96+
.filter(x => x !== null);
97+
98+
const contentChanged =
99+
JSON.stringify(oldJson) !== JSON.stringify(newJson);
100+
101+
if (!contentChanged && childrenDiff.length === 0) {
102+
return null;
103+
}
104+
105+
const result: Record<string, unknown> = {};
106+
if (contentChanged) {
107+
result.type = 'modified';
108+
result.oldAttributes = oldJson;
109+
result.newAttributes = newJson;
110+
} else {
111+
result.type = 'unchanged';
112+
result.id = diffNode.node.id;
113+
}
114+
115+
if (childrenDiff.length > 0) {
116+
result.children = childrenDiff;
117+
}
118+
return result;
119+
} else if (diffNode.type === 'added') {
120+
return {
121+
type: 'added',
122+
node: this.#newFormatter.nodeToJSON(diffNode.node),
123+
};
124+
} else if (diffNode.type === 'removed') {
125+
return {
126+
type: 'removed',
127+
node: this.#oldFormatter.nodeToJSON(diffNode.node),
128+
};
129+
} else if (diffNode.type === 'modified') {
130+
return {
131+
type: 'modified',
132+
oldNode: this.#oldFormatter.nodeToJSON(diffNode.oldNode!),
133+
newNode: this.#newFormatter.nodeToJSON(diffNode.node),
134+
};
135+
}
136+
return null;
137+
}
138+
139+
static diff(
140+
oldSnapshot: TextSnapshot,
141+
newSnapshot: TextSnapshot,
142+
): SnapshotDiffFormatter {
143+
const diffRoot = TreeDiff.compute(oldSnapshot.root, newSnapshot.root);
144+
const oldFormatter = new SnapshotFormatter(oldSnapshot);
145+
const newFormatter = new SnapshotFormatter(newSnapshot);
146+
return new SnapshotDiffFormatter(diffRoot, oldFormatter, newFormatter);
147+
}
148+
}

src/formatters/SnapshotFormatter.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,24 @@ export class SnapshotFormatter {
2727
Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
2828
}
2929

30-
chunks.push(this.#formatNode(root, 0));
30+
chunks.push(this.formatNode(root, 0));
3131
return chunks.join('');
3232
}
3333

3434
toJSON(): object {
35-
return this.#nodeToJSON(this.#snapshot.root);
35+
return this.nodeToJSON(this.#snapshot.root);
3636
}
3737

38-
#formatNode(node: TextSnapshotNode, depth = 0): string {
39-
const chunks: string[] = [];
38+
formatNode(node: TextSnapshotNode, depth = 0): string {
39+
const chunks: string[] = [this.formatNodeSelf(node, depth)];
40+
41+
for (const child of node.children) {
42+
chunks.push(this.formatNode(child, depth + 1));
43+
}
44+
return chunks.join('');
45+
}
46+
47+
formatNodeSelf(node: TextSnapshotNode, depth = 0): string {
4048
const attributes = this.#getAttributes(node);
4149
const line =
4250
' '.repeat(depth * 2) +
@@ -45,17 +53,12 @@ Get a verbose snapshot to include all elements if you are interested in the sele
4553
? ' [selected in the DevTools Elements panel]'
4654
: '') +
4755
'\n';
48-
chunks.push(line);
49-
50-
for (const child of node.children) {
51-
chunks.push(this.#formatNode(child, depth + 1));
52-
}
53-
return chunks.join('');
56+
return line;
5457
}
5558

56-
#nodeToJSON(node: TextSnapshotNode): object {
59+
nodeToJSON(node: TextSnapshotNode): object {
5760
const rawAttrs = this.#getAttributesMap(node);
58-
const children = node.children.map(child => this.#nodeToJSON(child));
61+
const children = node.children.map(child => this.nodeToJSON(child));
5962
const result: Record<string, unknown> = structuredClone(rawAttrs);
6063
if (children.length > 0) {
6164
result.children = children;

src/utils/TreeDiff.ts

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

0 commit comments

Comments
 (0)