Azure DevOps PR Copy File Names latest version (currently v1.0.1)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Azure DevOps: PR Copy File Names
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.1
// @description  Adds copy buttons to PR file comment headers to copy filename and full file path
// @author       Tim Hilton using Copilot
// @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;
            }
        `;
        document.head.appendChild(style);
    };

    const handleCopyClick = (button, text) => {
        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}`);
        });
    };

    const createCopyButton = (text, 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, text);
        });
        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);
        }
    };

    const setupCopyButtons = () => {
        console.debug(`${LOG_PREFIX} Setting up copy buttons`);
        
        // Find all comment file headers
        const commentHeaders = document.querySelectorAll('table.activity-feed-list div.comment-file-header-title');
        
        console.debug(`${LOG_PREFIX} Found ${commentHeaders.length} comment file headers`);
        
        let addedCount = 0;
        commentHeaders.forEach(header => {
            // Check if we haven't already added buttons
            if (!header.querySelector('.pr-copy-buttons-container')) {
                addCopyButtons(header);
                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();

    // Use MutationObserver to handle dynamically loaded content
    // Azure DevOps loads comments as user scrolls or adds new comments
    let mainObserver = null;
    let observerFireCount = 0;
    
    const setupMainObserver = (activityFeedList) => {
        console.debug(`${LOG_PREFIX} Setting up main observer`);
        
        if (mainObserver) {
            mainObserver.disconnect();
        }
        
        mainObserver = new MutationObserver((mutations) => {
            observerFireCount++;
            console.debug(`${LOG_PREFIX} MutationObserver fired (count: ${observerFireCount})`);
            
            // Check if any comment headers were added
            const hasNewComments = mutations.some(mutation => 
                Array.from(mutation.addedNodes).some(node => 
                    node.nodeType === Node.ELEMENT_NODE && (
                        node.matches?.('div.comment-file-header-title') ||
                        node.querySelector?.('div.comment-file-header-title')
                    )
                )
            );
            
            if (hasNewComments) {
                console.debug(`${LOG_PREFIX} New comments detected, re-running setup`);
                setupCopyButtons();
            }
        });
        
        mainObserver.observe(activityFeedList, {
            childList: true,
            subtree: true,
        });
    };

    // Observe the activity feed list for new comments
    const activityFeedList = document.querySelector('table.activity-feed-list');
    
    if (activityFeedList) {
        console.debug(`${LOG_PREFIX} Activity feed list found`);
        setupMainObserver(activityFeedList);
    } else {
        console.debug(`${LOG_PREFIX} Activity feed list not found, watching for its creation`);
        
        // Set up a fallback observer to watch for the creation of table.activity-feed-list
        const fallbackObserver = new MutationObserver((mutations) => {
            const feedList = document.querySelector('table.activity-feed-list');
            if (feedList) {
                console.debug(`${LOG_PREFIX} Activity feed list appeared, switching to main observer`);
                fallbackObserver.disconnect();
                setupMainObserver(feedList);
                setupCopyButtons(); // Re-run setup now that we have the table
            }
        });
        
        fallbackObserver.observe(document.body, {
            childList: true,
            subtree: true,
        });
    }

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