Azure DevOps Copy Pipeline Details v1.0.0

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Azure DevOps: Copy Pipeline Details
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.0
// @description  Adds copy icons to pipeline run titles, branch names and commit hashes so their value can be copied as a link
// @author       Tim Hilton using Claude
// @match        https://dev.azure.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[Azure DevOps: Copy Pipeline Details]';

    const LINK_SELECTOR = [
        'a.bolt-table-link',
        'a.branch-link[aria-label^="Branch "]',
        'a[aria-label^="Source version "]',
    ].join(', ');

    const COPY_ICON_SVG = `

`;

    const TICK_ICON_SVG = `

    

`;

    const injectStyles = () => {
        console.debug(`${LOG_PREFIX} Injecting styles`);

        const style = document.createElement('style');
        style.textContent = `
            .pipeline-copy-icon {
                display: inline-flex;
                flex: none;
                align-items: center;
                justify-content: center;
                width: 20px;
                height: 20px;
                border-radius: 4px;
                cursor: pointer;
                margin-left: 4px;
            }
            .pipeline-copy-icon:hover {
                background-color: rgba(0, 0, 0, 0.1);
            }
            .pipeline-copy-icon svg path {
                fill: currentColor;
            }
        `;
        document.head.appendChild(style);
    };

    const escapeHtml = (text) =>
        text
            .replace(/&/g, '&')
            .replace(//g, '>')
            .replace(/"/g, '"');

    const writeClipboard = async (displayText, href) => {
        const htmlString = `${escapeHtml(displayText)}`;
        if (typeof ClipboardItem !== 'undefined' && navigator.clipboard.write) {
            const plainBlob = new Blob([displayText], { type: 'text/plain' });
            const htmlBlob = new Blob([htmlString], { type: 'text/html' });
            await navigator.clipboard.write([
                new ClipboardItem({
                    'text/plain': plainBlob,
                    'text/html': htmlBlob,
                }),
            ]);
        } else {
            await navigator.clipboard.writeText(displayText);
        }
    };

    const handleCopyClick = (icon) => {
        // Read the anchor's current text/href at click time, rather than trusting values
        // captured when the icon was created — Azure DevOps's virtualised grid can silently
        // recycle a row's DOM nodes for different data, so a stale closure could copy the wrong
        // value even though the icon element itself was never recreated.
        const anchor = icon.previousElementSibling;
        const displayText = anchor?.textContent.trim();
        const href = anchor?.href;
        if (!displayText || !href) {
            console.debug(`${LOG_PREFIX} Cannot copy: adjacent link no longer present`);
            return;
        }

        console.debug(`${LOG_PREFIX} Copy icon clicked for: ${displayText}`);

        writeClipboard(displayText, href)
            .then(() => {
                console.log(`${LOG_PREFIX} ✅ Copied to clipboard: ${displayText}`);
                const originalContent = icon.innerHTML;
                icon.innerHTML = TICK_ICON_SVG;
                setTimeout(() => {
                    if (document.body.contains(icon)) {
                        icon.innerHTML = originalContent;
                    }
                }, 1000);
            })
            .catch((err) => {
                console.log(`${LOG_PREFIX} ❌ Failed to copy to clipboard: ${err}`);
            });
    };

    const hasCopyIcon = (anchor) => anchor.nextElementSibling?.classList.contains('pipeline-copy-icon') ?? false;

    const addCopyIcon = (anchor) => {
        const displayText = anchor.textContent.trim();
        if (!displayText) {
            console.debug(`${LOG_PREFIX} Skipping link with no display text`);
            return;
        }

        const icon = document.createElement('span');
        icon.innerHTML = COPY_ICON_SVG;
        icon.classList.add('pipeline-copy-icon');
        icon.setAttribute('title', `Copy ${displayText}`);
        icon.addEventListener('click', (e) => {
            e.stopPropagation();
            handleCopyClick(icon);
        });

        anchor.insertAdjacentElement('afterend', icon);

        console.debug(`${LOG_PREFIX} Added copy icon for: ${displayText}`);
    };

    const scanAndInject = () => {
        // Check for an existing icon sibling rather than marking the anchor itself as
        // "processed": Azure DevOps's virtualised grid can recycle a row's DOM nodes (resetting
        // their attributes) without removing our injected icon, which previously made a
        // processed-marker attribute on the anchor unreliable and caused duplicate icons to pile
        // up indefinitely.
        const anchors = Array.from(document.querySelectorAll(LINK_SELECTOR)).filter((anchor) => !hasCopyIcon(anchor));

        if (anchors.length === 0) {
            return;
        }

        console.debug(`${LOG_PREFIX} Found ${anchors.length} link(s) without a copy icon`);
        anchors.forEach(addCopyIcon);
        console.log(`${LOG_PREFIX} 🎉 Added ${anchors.length} copy icon(s)`);
    };

    injectStyles();
    scanAndInject();

    let scanScheduled = false;
    const scheduleScan = () => {
        if (scanScheduled) {
            return;
        }
        scanScheduled = true;
        requestAnimationFrame(() => {
            scanScheduled = false;
            scanAndInject();
        });
    };

    const observer = new MutationObserver(() => {
        scheduleScan();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });

    console.debug(`${LOG_PREFIX} Script initialized`);
})();