Azure DevOps PR Copy File Names v1.2.0

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Azure DevOps: PR Copy File Names
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.2.0
// @description  Adds copy buttons to PR file comment headers and Files tab file headers (single file and folder views) to copy filename and full file path
// @author       Tim Hilton using Copilot and Claude
// @match        https://dev.azure.com/**/pullrequest/*
// @match        https://*.visualstudio.com/**/pullrequest/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[Azure DevOps PR Copy File Names]';

    const COPY_ICON_SVG = `

`;

    const TICK_ICON_SVG = `

    

`;

    const injectStyles = () => {
        console.debug(`${LOG_PREFIX} Injecting styles`);
        
        const style = document.createElement('style');
        style.textContent = `
            .pr-copy-button {
                display: inline-flex;
                align-items: center;
                justify-content: center;
                width: 24px;
                height: 24px;
                border-radius: 4px;
                cursor: pointer;
                transition: background-color 0.25s ease-in-out;
                margin-left: 4px;
            }
            .pr-copy-button:hover {
                background-color: rgba(0, 0, 0, 0.1);
            }
            .pr-copy-button svg path {
                fill: currentColor;
            }
            .pr-copy-button.pr-copy-button-small {
                width: 16px;
                height: 16px;
            }
            .pr-copy-buttons-container {
                display: inline-flex;
                align-items: center;
                margin-left: 8px;
            }
            /* Files tab header: lay the path and its button out side by side without moving Azure DevOps' own nodes */
            .pr-copy-compare-header {
                display: grid;
                grid-template-columns: minmax(0, max-content) auto;
                align-items: center;
                & > :first-child {
                    grid-column: 1 / -1;
                }
            }
        `;
        document.head.appendChild(style);
    };

    const handleCopyClick = (button, getText) => {
        const text = getText();
        console.debug(`${LOG_PREFIX} Copy button clicked for: ${text}`);
        
        navigator.clipboard.writeText(text).then(() => {
            const originalContent = button.innerHTML;
            button.innerHTML = TICK_ICON_SVG;
            console.log(`${LOG_PREFIX} ✅ Text copied: ${text}`);
            setTimeout(() => {
                if (document.body.contains(button)) {
                    button.innerHTML = originalContent;
                }
            }, 1000);
        }).catch(err => {
            console.log(`${LOG_PREFIX} ❌ Failed to copy text: ${err}`);
        });
    };

    // getText is called on click, so the Files tab header (which Azure DevOps may update in place
    // when another file is selected) always copies the currently shown file
    const createCopyButton = (getText, title, small = false) => {
        const button = document.createElement('span');
        button.innerHTML = COPY_ICON_SVG;
        button.classList.add('pr-copy-button');
        if (small) {
            button.classList.add('pr-copy-button-small');
        }
        button.setAttribute('title', title);
        button.addEventListener('click', (e) => {
            e.stopPropagation();
            handleCopyClick(button, getText);
        });
        return button;
    };

    const getFileName = (filePath) => {
        if (!filePath) return null;
        const parts = filePath.split(/[/\\]/);
        return parts[parts.length - 1];
    };

    const addCopyButtons = (commentHeader) => {
        // Get the filename link element
        const fileNameLink = commentHeader.querySelector('a.comment-file-header-link');
        if (!fileNameLink) {
            console.debug(`${LOG_PREFIX} No filename link found in element`);
            return;
        }
        
        // Skip if buttons already added (check if link is already wrapped)
        if (fileNameLink.parentElement?.querySelector('.pr-copy-buttons-container')) {
            return;
        }
        
        // Get filename directly from link text
        const fileName = fileNameLink.textContent.trim();
        if (!fileName) {
            console.debug(`${LOG_PREFIX} No filename text found`);
            return;
        }
        
        // Get the filepath element (first sibling after the link)
        const filePathElement = fileNameLink.nextElementSibling;
        const filePath = filePathElement ? filePathElement.textContent.trim() : null;
        
        console.debug(`${LOG_PREFIX} Adding copy buttons for: ${fileName}`);

        // Create wrapper for filename link + button
        const fileNameWrapper = document.createElement('div');
        fileNameWrapper.style.display = 'flex';
        fileNameWrapper.style.alignItems = 'center';
        fileNameWrapper.style.gap = '4px';
        
        // Move the filename link into its wrapper
        fileNameLink.parentNode.insertBefore(fileNameWrapper, fileNameLink);
        fileNameWrapper.appendChild(fileNameLink);
        
        // Create and add filename button in its own span
        const fileNameButtonContainer = document.createElement('span');
        fileNameButtonContainer.classList.add('pr-copy-buttons-container');
        const fileNameButton = createCopyButton(() => fileName, 'Copy filename');
        fileNameButtonContainer.appendChild(fileNameButton);
        fileNameWrapper.appendChild(fileNameButtonContainer);

        // Add full path button with filepath element if we have a full path
        if (filePath && filePath !== fileName && filePathElement) {
            // Create wrapper for filepath element + button
            const filePathWrapper = document.createElement('div');
            filePathWrapper.style.display = 'flex';
            filePathWrapper.style.alignItems = 'center';
            filePathWrapper.style.gap = '4px';
            
            // Move the filepath element into its wrapper
            filePathElement.parentNode.insertBefore(filePathWrapper, filePathElement);
            filePathWrapper.appendChild(filePathElement);
            
            // Create and add filepath button in its own span (16px size)
            const filePathButtonContainer = document.createElement('span');
            filePathButtonContainer.classList.add('pr-copy-buttons-container');
            const filePathButton = createCopyButton(() => filePath, 'Copy full file path', true);
            filePathButtonContainer.appendChild(filePathButton);
            filePathWrapper.appendChild(filePathButtonContainer);
        }
    };

    // Files tab: the header above the diff of a file. Buttons are inserted alongside
    // Azure DevOps' own nodes rather than wrapping them, since this header is re-rendered when
    // another file is selected and moving its nodes could break that re-render
    const addCompareHeaderButtons = (fileNameHeading) => {
        const headingRow = fileNameHeading.parentElement;
        const headerColumn = headingRow?.parentElement;
        const filePathElement = headingRow?.nextElementSibling;
        if (!headerColumn || !filePathElement) {
            console.debug(`${LOG_PREFIX} Files tab header has unexpected structure, skipping`);
            return false;
        }

        let added = false;

        if (!fileNameHeading.nextElementSibling?.classList.contains('pr-copy-buttons-container')) {
            const container = document.createElement('span');
            container.classList.add('pr-copy-buttons-container');
            container.appendChild(createCopyButton(() => fileNameHeading.textContent.trim(), 'Copy filename'));
            fileNameHeading.after(container);
            added = true;
        }

        if (!headerColumn.lastElementChild.classList.contains('pr-copy-buttons-container')) {
            headerColumn.classList.add('pr-copy-compare-header');
            const container = document.createElement('span');
            container.classList.add('pr-copy-buttons-container');
            container.appendChild(createCopyButton(() => filePathElement.textContent.trim(), 'Copy full file path', true));
            headerColumn.appendChild(container);
            added = true;
        }

        return added;
    };

    const setupCopyButtons = () => {
        // Overview tab: comment file headers in the activity feed
        const commentHeaders = document.querySelectorAll('table.activity-feed-list div.comment-file-header-title');

        let addedCount = 0;
        commentHeaders.forEach(header => {
            // Check if we haven't already added buttons
            if (!header.querySelector('.pr-copy-buttons-container')) {
                addCopyButtons(header);
                addedCount++;
            }
        });

        // Files tab: the file header in the compare toolbar when a single file is selected, and the
        // header of each file card when a folder is selected (both share the same structure)
        const fileHeadings = document.querySelectorAll(
            '.repos-compare-toolbar [role="heading"][aria-level="2"], .repos-summary-header [role="heading"][aria-level="3"]'
        );
        fileHeadings.forEach(heading => {
            if (addCompareHeaderButtons(heading)) {
                addedCount++;
            }
        });

        if (addedCount > 0) {
            console.log(`${LOG_PREFIX} ✅ Added buttons to ${addedCount} file headers`);
        }
    };

    // Inject styles
    injectStyles();

    // Initial setup
    console.debug(`${LOG_PREFIX} Initializing script`);
    setupCopyButtons();

    // Azure DevOps is a SPA: comments load as the user scrolls, and switching between the Overview
    // and Files tabs (or selecting another file) replaces large parts of the page, so watch the body.
    // Batch mutations into one setup pass per animation frame to keep the callback cheap.
    let observerFireCount = 0;
    let setupPending = false;

    const observer = new MutationObserver(() => {
        observerFireCount++;
        if (setupPending) {
            return;
        }
        setupPending = true;
        requestAnimationFrame(() => {
            setupPending = false;
            console.debug(`${LOG_PREFIX} MutationObserver fired (count: ${observerFireCount}), re-running setup`);
            setupCopyButtons();
        });
    });

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

    console.log(`${LOG_PREFIX} 🎉 Script initialized successfully`);
})();