Azure DevOps Work Item History Diff v1.0.0

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Azure DevOps: Work Item History Diff
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.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}`)) .join(''); 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('')}`)) .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 = ''; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (part.type === 'removed' && parts[i + 1]?.type === 'added') { html += markReplacement(part.value, parts[i + 1].value); i++; } else if (part.type === 'same') { html += part.value.join(''); } else { html += mark(part.value, part.type === 'added' ? 'ins' : 'del'); } } 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 element (rather than its content) is a click on the backdrop dialog.addEventListener('click', (e) => e.target === dialog && dialog.close()); dialog.addEventListener('close', () => dialog.remove()); document.body.append(dialog); dialog.showModal(); console.log(`${LOG_PREFIX} ✅ Opened diff for "${title}"`); } function addButtons() { for (const field of document.querySelectorAll('.html-field-diff')) { const heading = field.firstElementChild; if (!heading || heading.querySelector(`.${BUTTON_CLASS}`)) { continue; } const button = document.createElement('button'); button.type = 'button'; button.className = BUTTON_CLASS; button.textContent = 'View diff'; // Azure DevOps reuses these elements when another revision is selected, so the title and // values are read at click time rather than captured now. button.addEventListener('click', () => openDiff(field, heading.firstChild.textContent.trim())); heading.append(button); console.debug(`${LOG_PREFIX} Added diff button for "${heading.firstChild.textContent.trim()}"`); } } const style = document.createElement('style'); style.textContent = STYLES; document.head.append(style); addButtons(); let scanScheduled = false; new MutationObserver(() => { if (scanScheduled) { return; } scanScheduled = true; requestAnimationFrame(() => { scanScheduled = false; addButtons(); }); }).observe(document.body, { childList: true, subtree: true }); console.debug(`${LOG_PREFIX} Script initialized`); })();