Azure DevOps PR Copy File Names v1.1.0
← Back to User Scripts
Script Content
// ==UserScript==
// @name Azure DevOps: PR Copy File Names
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.1.0
// @description Adds copy buttons to PR file comment headers and the Files tab file header 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 the selected 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: file header in the compare toolbar
document.querySelectorAll('.repos-compare-toolbar [role="heading"][aria-level="2"]').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`);
})();