// ==UserScript==
// @name Azure DevOps: Work Item History Diff
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.2.0
// @description Adds a "View diff" button to long-text field changes on a work item's History tab, showing the old and new values as a single formatted diff
// @author Tim Hilton using Claude
// @match https://dev.azure.com/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const LOG_PREFIX = '[Azure DevOps: Work Item History Diff]';
const BUTTON_CLASS = 'wihd-button';
const STYLES = `
.${BUTTON_CLASS} {
margin-left: 8px;
padding: 2px 8px;
font-size: 12px;
font-weight: normal;
vertical-align: middle;
color: inherit;
background: transparent;
border: 1px solid currentColor;
border-radius: 2px;
cursor: pointer;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
.wihd-dialog {
width: min(1100px, 90vw);
max-height: 85vh;
padding: 0;
color: var(--text-primary-color, #000);
background: var(--background-color, #fff);
border: 1px solid rgba(var(--palette-neutral-8, 0, 0, 0), 0.5);
border-radius: 4px;
&::backdrop {
background: rgba(0, 0, 0, 0.5);
}
header {
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
background: inherit;
border-bottom: 1px solid rgba(var(--palette-neutral-8, 0, 0, 0), 0.5);
}
header button {
font-size: 20px;
line-height: 1;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
.wihd-body {
padding: 8px 16px 16px;
}
ins {
text-decoration: none;
background: rgba(46, 160, 67, 0.35);
}
del {
text-decoration: line-through;
background: rgba(248, 81, 73, 0.35);
}
mark {
color: inherit;
background: rgba(210, 153, 34, 0.4);
}
.wihd-legend {
margin: 0 16px 0 auto;
font-size: 12px;
font-weight: normal;
> * {
margin-left: 8px;
padding: 0 4px;
}
}
}
`;
// Longest-common-subsequence diff of two token arrays, returned as runs of
// { type: 'same' | 'removed' | 'added', value: tokens[] } with removals before additions.
// A library such as jsdiff can't be @require'd here: Azure DevOps's AMD loader captures its
// UMD build, so it never becomes a global.
// ponytail: O(n×m) table after trimming the common start and end, fine for field-sized text;
// switch to Myers' algorithm if very long fields with scattered edits become slow.
function diffArrays(a, b) {
let start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) {
start++;
}
let endA = a.length;
let endB = b.length;
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
endA--;
endB--;
}
// Only whole trailing blocks are trimmed from the end: matching part of a block greedily from
// the end can pair a full stop with one in a newly added list item instead of its own item.
while (endA < a.length && a[endA] !== BOUNDARY) {
endA++;
endB++;
}
// lcs[i * w + j] is the LCS length of a[start + i, endA) and b[start + j, endB)
const n = endA - start;
const m = endB - start;
const w = m + 1;
const lcs = new Uint32Array((n + 1) * w);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i * w + j] =
a[start + i] === b[start + j]
? lcs[(i + 1) * w + j + 1] + 1
: Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
}
}
const parts = [];
const push = (type, token) => {
const last = parts.at(-1);
if (last?.type === type) {
last.value.push(token);
} else {
parts.push({ type, value: [token] });
}
};
a.slice(0, start).forEach((token) => push('same', token));
let i = 0;
let j = 0;
while (i < n || j < m) {
if (i < n && j < m && a[start + i] === b[start + j]) {
push('same', a[start + i]);
i++;
j++;
} else if (j === m || (i < n && lcs[(i + 1) * w + j] >= lcs[i * w + j + 1])) {
push('removed', a[start + i]);
i++;
} else {
push('added', b[start + j]);
j++;
}
}
a.slice(endA).forEach((token) => push('same', token));
// An insertion or removal can often sit in several equally valid places. Slide each one as
// late as it will go, so a new list item is reported whole rather than straddling the end of
// the previous item (with the old full stop matched to the new one). If that leaves a run that
// spans a block boundary without starting on one, slide it back until it does, so a removed
// paragraph is reported as its own paragraph rather than merged into the start of the next.
for (let k = 1; k < parts.length - 1; k++) {
const [prev, run, next] = parts.slice(k - 1, k + 2);
if (prev.type !== 'same' || run.type === 'same' || next.type !== 'same') {
continue;
}
while (next.value.length > 0 && run.value[0] === next.value[0]) {
prev.value.push(run.value.shift());
run.value.push(next.value.shift());
}
while (
run.value[0] !== BOUNDARY &&
run.value.includes(BOUNDARY) &&
prev.value.length > 0 &&
run.value.at(-1) === prev.value.at(-1)
) {
next.value.unshift(run.value.pop());
run.value.unshift(prev.value.pop());
}
}
return parts;
}
// Key for block boundaries: the Unicode paragraph separator, which never appears as a word
const BOUNDARY = String.fromCharCode(0x2029);
const BLOCKS = new Set(['blockquote', 'dd', 'div', 'dl', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ol', 'p', 'pre', 'table', 'td', 'th', 'tr', 'ul']);
const VOIDS = new Set(['br', 'hr', 'img', 'input']);
// Splits HTML into words, runs of whitespace and single punctuation characters, each remembering
// the elements it sits in (its "chain"). Void elements (line breaks, images, checkboxes) are
// tokens of their own, and each block starts with a BOUNDARY token so paragraphs and list items
// line up with each other in the diff.
function tokenise(html) {
const template = document.createElement('template');
template.innerHTML = html;
const tokens = [];
const walk = (node, chain) => {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
for (const text of child.textContent.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? []) {
tokens.push({ key: text, text, chain });
}
} else if (child.nodeType === Node.ELEMENT_NODE && VOIDS.has(child.localName)) {
tokens.push({ key: child.outerHTML, html: child.outerHTML, chain });
} else if (child.nodeType === Node.ELEMENT_NODE) {
const childChain = [...chain, child];
if (BLOCKS.has(child.localName)) {
tokens.push({ key: BOUNDARY, text: '', chain: childChain });
}
walk(child, childChain);
}
}
};
walk(template.content, []);
return tokens;
}
const MARKS = {
added: ['', ''],
removed: ['', ''],
format: ['', ''],
};
const escapeHtml = (text) => text.replace(/&/g, '&').replace(//g, '>');
const openTag = (element) => {
const html = element.cloneNode(false).outerHTML;
return html.slice(0, html.lastIndexOf(''));
};
const closeTags = (elements) => elements.reverse().map((element) => `${element.localName}>`).join('');
// Rebuilds HTML from tokens, opening and closing elements whenever the chain changes from one
// token to the next, so the output is well formed however much the structure changed.
function render(tokens) {
let html = '';
const stack = [];
let mark = null;
const closeMark = () => {
if (mark) {
html += mark[1];
mark = null;
}
};
for (const token of tokens) {
let depth = 0;
while (depth < stack.length && stack[depth] === token.chain[depth]) {
depth++;
}
if (depth < stack.length || depth < token.chain.length) {
closeMark();
html += closeTags(stack.splice(depth));
for (const element of token.chain.slice(depth)) {
html += openTag(element);
stack.push(element);
}
}
const content = token.html ?? escapeHtml(token.text);
if (!content) {
continue;
}
const wanted = MARKS[token.type] ?? null;
if (wanted !== mark) {
closeMark();
mark = wanted;
html += mark?.[0] ?? '';
}
html += content;
}
closeMark();
return html + closeTags(stack);
}
// Formatting that matters for comparison: element names, plus link targets
const signature = (chain) => chain.map((element) => element.localName + (element.getAttribute('href') ?? '')).join('>');
// Whitespace and punctuation only: no words, void elements or block boundaries
const isFiller = (token) => token.text !== undefined && token.key !== BOUNDARY && !/[\p{L}\p{N}]/u.test(token.text);
const isWord = (token) => token.text !== undefined && /^\S+$/.test(token.text);
// Orders a group's removed and added tokens, removed first where possible, but never letting one
// side close an element (e.g. a list item) the other still has text to show in, as rendering
// would then have to open a second copy of it. `chain` is where the previous token left off.
function interleave(removed, added, chain) {
const closesNeeded = (token, other) => {
let depth = 0;
while (depth < chain.length && chain[depth] === token.chain[depth]) {
depth++;
}
return chain.slice(depth).some((element) => other.chain.includes(element));
};
const result = [];
let r = 0;
let a = 0;
while (r < removed.length || a < added.length) {
const takeRemoved =
r < removed.length &&
(a >= added.length || !closesNeeded(removed[r], added[a]) || closesNeeded(added[a], removed[r]));
const token = takeRemoved ? removed[r] : added[a];
if (takeRemoved) {
r++;
} else {
a++;
}
result.push(token);
chain = token.chain;
}
return result;
}
// A single word changed into a similar word (e.g. "authorization" -> "authorisation") is shown
// character by character; anything else is shown as the old words struck out followed by the new.
function markReplacement(removed, added, chain) {
if (removed.length === 1 && added.length === 1 && isWord(removed[0]) && isWord(added[0])) {
const [from, to] = [removed[0], added[0]];
const chars = diffArrays([...from.text], [...to.text]);
const common = chars.filter((c) => c.type === 'same').reduce((n, c) => n + c.value.length, 0);
if (common * 2 >= Math.max(from.text.length, to.text.length)) {
return chars.map((c) => ({ text: c.value.join(''), chain: c.type === 'removed' ? from.chain : to.chain, type: c.type }));
}
}
return interleave(removed, added, chain);
}
function diffHtml(oldHtml, newHtml) {
const oldTokens = tokenise(oldHtml);
const newTokens = tokenise(newHtml);
const parts = diffArrays(
oldTokens.map((token) => token.key),
newTokens.map((token) => token.key),
);
// Swap each part's keys back for the tokens they came from
let i = 0;
let j = 0;
for (const part of parts) {
const count = part.value.length;
part.old = part.type === 'added' ? [] : oldTokens.slice(i, i + count);
part.new = part.type === 'removed' ? [] : newTokens.slice(j, j + count);
i += part.old.length;
j += part.new.length;
}
// Pair up old and new elements that hold the same text, so a removed word is shown inside the
// paragraph it was removed from rather than in a separate copy of it
const counterpart = new Map();
for (const part of parts.filter((p) => p.type === 'same')) {
part.old.forEach((oldToken, k) => {
const newChain = part.new[k].chain;
for (let d = 0; d < Math.min(oldToken.chain.length, newChain.length); d++) {
if (oldToken.chain[d].localName !== newChain[d].localName) {
break;
}
if (!counterpart.has(oldToken.chain[d])) {
counterpart.set(oldToken.chain[d], newChain[d]);
}
}
});
}
const asRemoved = (token) => ({ ...token, chain: token.chain.map((element) => counterpart.get(element) ?? element), type: 'removed' });
const asAdded = (token) => ({ ...token, type: 'added' });
const output = [];
let removed = [];
let added = [];
const flush = () => {
output.push(...markReplacement(removed, added, output.at(-1)?.chain ?? []));
removed = [];
added = [];
};
for (let p = 0; p < parts.length; p++) {
const part = parts[p];
if (part.type !== 'same') {
removed.push(...part.old.map(asRemoved));
added.push(...part.new.map(asAdded));
} else if (removed.length + added.length > 0 && part.old.every(isFiller) && parts[p + 1] && parts[p + 1].type !== 'same') {
// Changes separated only by spaces or punctuation are one rewritten phrase, so it's shown
// as "~~old old old~~ new new new" rather than alternating word by word
removed.push(...part.old.map(asRemoved));
added.push(...part.new.map(asAdded));
} else {
flush();
// Unchanged text is shown with its new formatting, highlighted if that formatting
// (bold, code, link, list type, ...) differs from before
part.new.forEach((token, k) => {
output.push({ ...token, type: signature(token.chain) === signature(part.old[k].chain) ? 'same' : 'format' });
});
}
}
flush();
return render(output);
}
function openDiff(field, title) {
const oldHtml = field.querySelector('.removed > del')?.innerHTML ?? '';
const newHtml = field.querySelector('.added > ins')?.innerHTML ?? '';
console.debug(`${LOG_PREFIX} Diffing "${title}" (${oldHtml.length} -> ${newHtml.length} chars)`);
const dialog = document.createElement('dialog');
dialog.className = 'wihd-dialog';
dialog.innerHTML = `AddedRemovedFormatting changed`;
dialog.querySelector('header span').textContent = `${title} changes`;
dialog.querySelector('.wihd-body').innerHTML = diffHtml(oldHtml, newHtml);
dialog.querySelector('header button').addEventListener('click', () => dialog.close());
// A click directly on the