Azure DevOps PR Copy File Names v1.0.0
← 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`);
})();