Azure DevOps PR Summary Syntax Highlight v1.0.0

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Azure DevOps: PR Summary Syntax Highlight
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.0
// @description  Adds real syntax colouring to the plain-text multi-file pull request diff summary view
// @author       Tim Hilton using Claude
// @match        https://dev.azure.com/*/_git/*/pullrequest/*
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-clike.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-csharp.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-typescript.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-css.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-scss.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-json.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-sql.min.js
// @require      https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-yaml.min.js
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[Azure DevOps: PR Summary Syntax Highlight]';

    const LANGUAGE_BY_EXTENSION = {
        cs: 'csharp',
        ts: 'typescript',
        tsx: 'typescript',
        js: 'javascript',
        jsx: 'javascript',
        html: 'markup',
        cshtml: 'markup',
        razor: 'markup',
        xml: 'markup',
        csproj: 'markup',
        css: 'css',
        scss: 'scss',
        json: 'json',
        sql: 'sql',
        yml: 'yaml',
        yaml: 'yaml',
    };

    // Written by hand rather than @require-ing a theme stylesheet, since @require only fetches
    // JS, not CSS. Every rule here sets color/font-weight/font-style ONLY, deliberately never
    // background — that's the whole point of this script (see verifyThemeHasNoTokenBackground
    // below, which checks this assumption stays true at runtime).
    const THEME_CSS = `
        .token.comment, .token.prolog, .token.doctype, .token.cdata { color: #9e9e9e; }
        .token.punctuation { color: #d4d4d4; }
        .token.tag, .token.attr-name, .token.namespace, .token.deleted { color: #e2777a; }
        .token.function-name, .token.function { color: #6196cc; }
        .token.boolean, .token.number { color: #f08d49; }
        .token.property, .token.class-name, .token.constant, .token.symbol { color: #f8c555; }
        .token.selector, .token.important, .token.atrule, .token.keyword, .token.builtin { color: #cc99cd; }
        .token.string, .token.char, .token.attr-value, .token.regex, .token.variable { color: #7ec699; }
        .token.operator, .token.entity, .token.url { color: #67cdcc; }
        .token.important, .token.bold { font-weight: bold; }
        .token.italic { font-style: italic; }
    `;

    let trustedTypesPolicy = null;

    function getTrustedTypesPolicy() {
        if (trustedTypesPolicy) {
            return trustedTypesPolicy;
        }
        if (!window.trustedTypes || !window.trustedTypes.createPolicy) {
            return null;
        }
        try {
            trustedTypesPolicy = window.trustedTypes.createPolicy('ado-syntax-highlight', {
                createHTML: (s) => s,
            });
        } catch (error) {
            console.log(`${LOG_PREFIX} ❌ Failed to create Trusted Types policy: ${error.message}`);
        }
        return trustedTypesPolicy;
    }

    function setInnerHtml(el, html) {
        try {
            el.innerHTML = html;
        } catch (error) {
            const policy = getTrustedTypesPolicy();
            if (!policy) {
                console.log(`${LOG_PREFIX} ❌ Could not set innerHTML: ${error.message}`);
                return;
            }
            el.innerHTML = policy.createHTML(html);
        }
    }

    function injectThemeStylesheet() {
        const style = document.createElement('style');
        style.textContent = THEME_CSS;
        document.head.appendChild(style);
        console.debug(`${LOG_PREFIX} Injected token colour stylesheet`);
    }

    // Confirms THEME_CSS (and Prism's own token classes, which never carry an ancestor
    // pre[class*="language-"]/code[class*="language-"] in this DOM, so Prism's normal block-level
    // theme rules can't apply here anyway) never renders a background on a `.token` element.
    function verifyThemeHasNoTokenBackground() {
        const probe = document.createElement('span');
        probe.className = 'token keyword';
        probe.style.position = 'absolute';
        probe.style.visibility = 'hidden';
        probe.textContent = 'x';
        document.body.appendChild(probe);
        const backgroundColor = getComputedStyle(probe).backgroundColor;
        probe.remove();

        const hasBackground = backgroundColor && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent';
        if (hasBackground) {
            console.log(
                `${LOG_PREFIX} ❌ .token rules resolve to a background (${backgroundColor}) — this violates the text-colour-only requirement`,
            );
        }
        return !hasBackground;
    }

    // The header row's textContent has no whitespace between the file path and the trailing
    // "View" button label (e.g. ".../PaymentCalculator.csView"), so the extension must only
    // match lowercase/digit characters — real extensions in this UI are always lowercase, while
    // button labels are always title-case, so this stops the match before "View" starts.
    function extractFilePath(headerText) {
        const matches = headerText.match(/\/[^\s]+\.[a-z0-9]+/g);
        if (!matches || matches.length === 0) {
            return null;
        }
        return matches[matches.length - 1];
    }

    function getLanguageForPath(filePath) {
        const extensionMatch = filePath.match(/\.([A-Za-z0-9]+)$/);
        if (!extensionMatch) {
            return null;
        }
        const language = LANGUAGE_BY_EXTENSION[extensionMatch[1].toLowerCase()];
        if (!language) {
            return null;
        }
        if (!window.Prism || !window.Prism.languages || !window.Prism.languages[language]) {
            return null;
        }
        return language;
    }

    function escapeHtml(text) {
        return text.replace(/&/g, '&').replace(//g, '>');
    }

    // A line's actual code text is not always wrapped in a : an ordinary, uncommented
    // line renders it as a bare text node directly under .repos-line-content, sandwiched
    // between the screen-reader-only marker and a zero-length icon span. Only lines with special
    // handling (an in-line edit, or an attached comment thread — confirmed live via
    // `.diff-comment`) wrap the text in a . Segments must therefore walk childNodes (which
    // includes text nodes), not just children (elements only), or plain lines are silently
    // skipped entirely.
    function collectSegments(lineContentEl) {
        const segments = [];
        let offset = 0;
        for (const node of Array.from(lineContentEl.childNodes)) {
            if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('screen-reader-only')) {
                continue;
            }
            if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE) {
                continue;
            }
            const text = node.textContent;
            segments.push({ node, start: offset, end: offset + text.length });
            offset += text.length;
        }
        return segments;
    }

    // Prism.tokenize() returns top-level strings and Prism.Token instances. A Token's `.length`
    // is set from the original matched substring, so summing lengths in order reconstructs exact
    // offsets into lineText without needing to walk nested sub-tokens.
    function flattenTokenRanges(tokens) {
        const ranges = [];
        let offset = 0;
        for (const token of tokens) {
            const type = typeof token === 'string' ? null : token.type;
            ranges.push({ start: offset, end: offset + token.length, type });
            offset += token.length;
        }
        return ranges;
    }

    function buildSegmentHtml(segmentText, segmentStart, segmentEnd, ranges) {
        let html = '';
        for (const range of ranges) {
            const overlapStart = Math.max(range.start, segmentStart);
            const overlapEnd = Math.min(range.end, segmentEnd);
            if (overlapStart >= overlapEnd) {
                continue;
            }
            const piece = escapeHtml(segmentText.slice(overlapStart - segmentStart, overlapEnd - segmentStart));
            html += range.type ? `${piece}` : piece;
        }
        return html;
    }

    function processLineContent(lineContentEl, language) {
        if (lineContentEl.dataset.adoSyntaxDone) {
            return;
        }

        const segments = collectSegments(lineContentEl);
        const lineText = segments.map((segment) => segment.node.textContent).join('');

        if (lineText.trim().length === 0) {
            lineContentEl.dataset.adoSyntaxDone = '1';
            return;
        }

        let tokens;
        try {
            tokens = window.Prism.tokenize(lineText, window.Prism.languages[language]);
        } catch (error) {
            console.log(`${LOG_PREFIX} ❌ Failed to tokenize line: ${error.message}`);
            lineContentEl.dataset.adoSyntaxDone = '1';
            return;
        }

        const ranges = flattenTokenRanges(tokens);

        for (const segment of segments) {
            const text = segment.node.textContent;
            if (text.length === 0) {
                continue;
            }
            const html = buildSegmentHtml(text, segment.start, segment.end, ranges);
            if (segment.node.nodeType === Node.TEXT_NODE) {
                // A bare text node has no innerHTML to set — replace it with a plain 
                // carrying no class or style of its own, so the ancestor row's background
                // still shows through unchanged (only the newly-added nested `.token` spans set
                // a color/font-style).
                const wrapper = document.createElement('span');
                setInnerHtml(wrapper, html);
                segment.node.replaceWith(wrapper);
            } else {
                setInnerHtml(segment.node, html);
            }
        }

        lineContentEl.dataset.adoSyntaxDone = '1';
    }

    function getFilePathFromHeader(headerEl) {
        const flexRow = headerEl.querySelector(':scope > .flex-row');
        if (!flexRow) {
            return null;
        }
        return extractFilePath(flexRow.textContent);
    }

    function processHeaderCard(headerEl) {
        const filePath = getFilePathFromHeader(headerEl);
        if (!filePath) {
            return;
        }

        const language = getLanguageForPath(filePath);
        if (!language) {
            console.debug(`${LOG_PREFIX} ⏭️ Skipping ${filePath} (no supported Prism language)`);
            return;
        }

        const diffBody = headerEl.querySelector('.repos-summary-code-diff');
        if (!diffBody) {
            return;
        }

        const lineContents = diffBody.querySelectorAll('.repos-line-content:not([data-ado-syntax-done])');
        lineContents.forEach((lineContentEl) => processLineContent(lineContentEl, language));
    }

    function processAllVisibleCards() {
        const headers = document.querySelectorAll('.repos-summary-header');
        console.debug(`${LOG_PREFIX} Scanning ${headers.length} file card(s)`);
        headers.forEach(processHeaderCard);
    }

    function findObserverRoot() {
        return document.querySelector('.repos-changes-viewer') || document.querySelector('.repos-changes-explorer-splitter');
    }

    function startObserving(root) {
        let debounceTimer = null;
        let mutationCount = 0;
        const observer = new MutationObserver(() => {
            mutationCount++;
            console.debug(`${LOG_PREFIX} MutationObserver fired (count: ${mutationCount})`);
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(processAllVisibleCards, 75);
        });
        observer.observe(root, { childList: true, subtree: true });
        console.log(`${LOG_PREFIX} ✅ Observer attached to diff viewer`);
    }

    function init() {
        if (!window.Prism) {
            console.log(`${LOG_PREFIX} ❌ Prism.js did not load, skipping syntax highlighting`);
            return;
        }

        injectThemeStylesheet();
        verifyThemeHasNoTokenBackground();

        processAllVisibleCards();

        const root = findObserverRoot();
        if (root) {
            startObserving(root);
            console.log(`${LOG_PREFIX} 🎉 Script initialized successfully`);
            return;
        }

        console.debug(`${LOG_PREFIX} Diff viewer not found yet, waiting for it to appear`);
        const waitObserver = new MutationObserver(() => {
            const foundRoot = findObserverRoot();
            if (!foundRoot) {
                return;
            }
            waitObserver.disconnect();
            processAllVisibleCards();
            startObserving(foundRoot);
            console.log(`${LOG_PREFIX} 🎉 Script initialized successfully (delayed)`);
        });
        waitObserver.observe(document.body, { childList: true, subtree: true });
    }

    console.debug(`${LOG_PREFIX} Initializing script`);
    init();
})();