Azure DevOps PR Copy File Names v1.3.0
← Back to User Scripts
Script Content
// ==UserScript==
// @name Azure DevOps: PR Copy File Names
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.3.0
// @description Adds copy buttons to PR file comment headers and Files tab file headers (single file and folder views) to copy the filename and full file path as links to the file
// @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 escapeHtml = (text) =>
text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
// Links to the file on this PR's Files tab, in the same form Azure DevOps uses for its own comment file links
const getFileUrl = (filePath) => {
const pullRequestUrl = location.href.match(/^.*?\/pullrequest\/\d+/)?.[0];
return pullRequestUrl && filePath
? `${pullRequestUrl}?_a=files&path=${encodeURIComponent(filePath).replace(/%2F/g, '/')}`
: null;
};
const writeClipboard = async (displayText, href) => {
if (!href || typeof ClipboardItem === 'undefined' || !navigator.clipboard.write) {
await navigator.clipboard.writeText(displayText);
return;
}
const plainBlob = new Blob([displayText], { type: 'text/plain' });
const htmlBlob = new Blob([`${escapeHtml(displayText)}`], {
type: 'text/html',
});
const markdownBlob = new Blob([`[${displayText}](${href})`], { type: 'web text/markdown' });
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': plainBlob,
'text/html': htmlBlob,
'web text/markdown': markdownBlob,
}),
]);
} catch (_) {
// A browser that doesn't support web custom formats rejects the whole write, so retry without it
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': plainBlob, 'text/html': htmlBlob })]);
}
};
const handleCopyClick = (button, getText, getPath) => {
const text = getText();
console.debug(`${LOG_PREFIX} Copy button clicked for: ${text}`);
writeClipboard(text, getFileUrl(getPath())).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 and getPath are 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. The copied text links
// to the file at getPath()
const createCopyButton = (getText, getPath, 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, getPath);
});
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, () => filePath || 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, () => 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(),
() => filePathElement.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');
const getFilePath = () => filePathElement.textContent.trim();
container.appendChild(createCopyButton(getFilePath, getFilePath, '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`);
})();