Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions src/formatters/SnapshotDiffFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js';
import {TreeDiff, type DiffNode} from '../utils/TreeDiff.js';

import {SnapshotFormatter} from './SnapshotFormatter.js';

export class SnapshotDiffFormatter {
#root: DiffNode<TextSnapshotNode>;
#oldFormatter: SnapshotFormatter;
#newFormatter: SnapshotFormatter;

constructor(
root: DiffNode<TextSnapshotNode>,
oldFormatter: SnapshotFormatter,
newFormatter: SnapshotFormatter,
) {
this.#root = root;
this.#oldFormatter = oldFormatter;
this.#newFormatter = newFormatter;
}

toString(): string {
const lines = this.#formatDiffNode(this.#root, 0);
const hasChanges = lines.some(l => l.startsWith('+') || l.startsWith('-'));
if (!hasChanges) {
return '';
}
return lines.join('').trimEnd();
}

#formatDiffNode(
diffNode: DiffNode<TextSnapshotNode>,
depth: number,
): string[] {
const chunks: string[] = [];

if (diffNode.type === 'same') {
const oldLine = this.#oldFormatter.formatNodeSelf(
diffNode.oldNode!,
depth,
);
const newLine = this.#newFormatter.formatNodeSelf(diffNode.node, depth);
if (oldLine === newLine) {
chunks.push(' ' + newLine);
} else {
chunks.push('- ' + oldLine);
chunks.push('+ ' + newLine);
}
// Children
for (const child of diffNode.children) {
chunks.push(...this.#formatDiffNode(child, depth + 1));
}
} else if (diffNode.type === 'added') {
chunks.push(
'+ ' + this.#newFormatter.formatNodeSelf(diffNode.node, depth),
);
// Recursively add children (they are also 'added' in the tree)
for (const child of diffNode.children) {
chunks.push(...this.#formatDiffNode(child, depth + 1));
}
} else if (diffNode.type === 'removed') {
chunks.push(
'- ' + this.#oldFormatter.formatNodeSelf(diffNode.node, depth),
);
// Recursively remove children (they are also 'removed' in the tree)
for (const child of diffNode.children) {
chunks.push(...this.#formatDiffNode(child, depth + 1));
}
} else if (diffNode.type === 'modified') {
chunks.push(
'- ' + this.#oldFormatter.formatNodeSelf(diffNode.oldNode!, depth),
);
chunks.push(
'+ ' + this.#newFormatter.formatNodeSelf(diffNode.node, depth),
);
}

return chunks;
}

toJSON(): object {
return this.#nodeToJSON(this.#root) ?? {};
}

#nodeToJSON(diffNode: DiffNode<TextSnapshotNode>): object | null {
if (diffNode.type === 'same') {
const oldJson = this.#oldFormatter.nodeToJSON(diffNode.oldNode!);
const newJson = this.#newFormatter.nodeToJSON(diffNode.node);
const childrenDiff = diffNode.children
.map(child => this.#nodeToJSON(child))
.filter(x => x !== null);

const contentChanged =
JSON.stringify(oldJson) !== JSON.stringify(newJson);

if (!contentChanged && childrenDiff.length === 0) {
return null;
}

const result: Record<string, unknown> = {};
if (contentChanged) {
result.type = 'modified';
result.oldAttributes = oldJson;
result.newAttributes = newJson;
} else {
result.type = 'unchanged';
result.id = diffNode.node.id;
}

if (childrenDiff.length > 0) {
result.children = childrenDiff;
}
return result;
} else if (diffNode.type === 'added') {
return {
type: 'added',
node: this.#newFormatter.nodeToJSON(diffNode.node),
};
} else if (diffNode.type === 'removed') {
return {
type: 'removed',
node: this.#oldFormatter.nodeToJSON(diffNode.node),
};
} else if (diffNode.type === 'modified') {
return {
type: 'modified',
oldNode: this.#oldFormatter.nodeToJSON(diffNode.oldNode!),
newNode: this.#newFormatter.nodeToJSON(diffNode.node),
};
}
return null;
}

static diff(
oldSnapshot: TextSnapshot,
newSnapshot: TextSnapshot,
): SnapshotDiffFormatter {
const diffRoot = TreeDiff.compute(oldSnapshot.root, newSnapshot.root);
const oldFormatter = new SnapshotFormatter(oldSnapshot);
const newFormatter = new SnapshotFormatter(newSnapshot);
return new SnapshotDiffFormatter(diffRoot, oldFormatter, newFormatter);
}
}
27 changes: 15 additions & 12 deletions src/formatters/SnapshotFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,24 @@ export class SnapshotFormatter {
Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
}

chunks.push(this.#formatNode(root, 0));
chunks.push(this.formatNode(root, 0));
return chunks.join('');
}

toJSON(): object {
return this.#nodeToJSON(this.#snapshot.root);
return this.nodeToJSON(this.#snapshot.root);
}

#formatNode(node: TextSnapshotNode, depth = 0): string {
const chunks: string[] = [];
formatNode(node: TextSnapshotNode, depth = 0): string {
const chunks: string[] = [this.formatNodeSelf(node, depth)];

for (const child of node.children) {
chunks.push(this.formatNode(child, depth + 1));
}
return chunks.join('');
}

formatNodeSelf(node: TextSnapshotNode, depth = 0): string {
const attributes = this.#getAttributes(node);
const line =
' '.repeat(depth * 2) +
Expand All @@ -45,17 +53,12 @@ Get a verbose snapshot to include all elements if you are interested in the sele
? ' [selected in the DevTools Elements panel]'
: '') +
'\n';
chunks.push(line);

for (const child of node.children) {
chunks.push(this.#formatNode(child, depth + 1));
}
return chunks.join('');
return line;
}

#nodeToJSON(node: TextSnapshotNode): object {
nodeToJSON(node: TextSnapshotNode): object {
const rawAttrs = this.#getAttributesMap(node);
const children = node.children.map(child => this.#nodeToJSON(child));
const children = node.children.map(child => this.nodeToJSON(child));
const result: Record<string, unknown> = structuredClone(rawAttrs);
if (children.length > 0) {
result.children = children;
Expand Down
128 changes: 128 additions & 0 deletions src/utils/TreeDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,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),
}));
}
}
Loading