Azure DevOps Work Item History Diff v1.1.0
← Back to User Scripts
Script Content
// ==UserScript==
// @name Azure DevOps: Work Item History Diff
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.1.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);
}
}
`;
// 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--;
}
// 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 as "…" rather than straddling
// the end of the previous item (".…" with the old full stop matched to the new one).
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());
}
}
return parts;
}
// Tags, entities, runs of whitespace, words and single punctuation characters are separate
// tokens, so the diff works word by word while markup passes through untouched.
const tokenise = (html) => html.match(/<[^>]+>|&[#\w]+;|\s+|[\p{L}\p{N}_]+|[^<&\s\p{L}\p{N}_]/gu) ?? [];
// Wraps the text between tags in /, leaving the tags themselves in place so the
// formatting (lists, code, bold, ...) of changed content is kept.
const mark = (tokens, tag) =>
tokens
.join('')
.split(/(<[^>]+>)/)
.map((part) => (part.startsWith('<') || !part.trim() ? part : `<${tag}>${part}${tag}>`))
.join('');
// Whitespace and punctuation only: no words and no tags
const isFiller = (tokens) => tokens.every((token) => !token.startsWith('<') && !/[\p{L}\p{N}]/u.test(token));
const isPlainWord =(tokens) => tokens.length === 1 && /^[^<&\s]+$/.test(tokens[0]);
// 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(oldTokens, newTokens) {
if (isPlainWord(oldTokens) && isPlainWord(newTokens)) {
const chars = diffArrays([...oldTokens[0]], [...newTokens[0]]);
const common = chars.filter((c) => c.type === 'same').reduce((n, c) => n + c.value.length, 0);
if (common * 2 >= Math.max(oldTokens[0].length, newTokens[0].length)) {
const tags = { added: 'ins', removed: 'del' };
return chars
.map((c) => (c.type === 'same' ? c.value.join('') : `<${tags[c.type]}>${c.value.join('')}${tags[c.type]}>`))
.join('');
}
}
return mark(oldTokens, 'del') + mark(newTokens, 'ins');
}
// ponytail: removed tags are kept alongside added ones, so a structural change (e.g. a numbered
// list becoming bulleted) can produce mis-nested markup that the browser repairs as best it can.
// A DOM-tree diff would handle that properly if it turns out to matter.
function diffHtml(oldHtml, newHtml) {
const parts = diffArrays(tokenise(oldHtml), tokenise(newHtml));
let html = '';
let removed = [];
let added = [];
const flush = () => {
if (removed.length > 0 && added.length > 0) {
html += markReplacement(removed, added);
} else if (removed.length > 0) {
html += mark(removed, 'del');
} else if (added.length > 0) {
html += mark(added, 'ins');
}
removed = [];
added = [];
};
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.type === 'removed') {
removed.push(...part.value);
} else if (part.type === 'added') {
added.push(...part.value);
} else if (removed.length + added.length > 0 && isFiller(part.value) && parts[i + 1] && parts[i + 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.value);
added.push(...part.value);
} else {
flush();
html += part.value.join('');
}
}
flush();
return html;
}
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 = ``;
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