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.0
// @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 log = (message, ...args) => {
console.log(LOG_PREFIX, message, ...args);
};
const logError = (message, ...args) => {
console.error(LOG_PREFIX, message, ...args);
};
const COPY_ICON_SVG = `
`;
const TICK_ICON_SVG = `
`;
const injectStyles = () => {
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);
log('Styles injected');
};
const handleCopyClick = (button, text) => {
log('Copy button clicked, copying:', text);
navigator.clipboard.writeText(text).then(() => {
const originalContent = button.innerHTML;
button.innerHTML = TICK_ICON_SVG;
log('Text copied successfully:', text);
setTimeout(() => {
if (document.body.contains(button)) {
button.innerHTML = originalContent;
}
}, 1000);
}).catch(err => {
logError('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) {
log('No filename link found in element:', commentHeader);
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) {
log('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;
log('Adding copy buttons. Filename:', fileName, 'Full path:', filePath);
// 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);
}
log('Copy buttons added successfully');
};
const setupCopyButtons = () => {
log('Setting up copy buttons...');
// Find all comment file headers
const commentHeaders = document.querySelectorAll('table.activity-feed-list div.comment-file-header-title');
log(`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++;
}
});
log(`Setup complete. Added buttons to ${addedCount} elements.`);
};
// Inject styles
injectStyles();
// Initial setup
log('Initializing...');
setupCopyButtons();
// Use MutationObserver to handle dynamically loaded content
// Azure DevOps loads comments as user scrolls or adds new comments
let mainObserver = null;
const setupMainObserver = (activityFeedList) => {
if (mainObserver) {
mainObserver.disconnect();
}
mainObserver = new MutationObserver((mutations) => {
// 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) {
log('New comments detected, re-running setup');
setupCopyButtons();
}
});
log('Observing activity feed list for new comments');
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) {
setupMainObserver(activityFeedList);
} else {
log('Warning: table.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) {
log('table.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,
});
}
log('Initialized successfully');
})();