Azure DevOps Copy Pipeline Details v1.0.0
← Back to User Scripts
Script Content
// ==UserScript==
// @name Azure DevOps: Copy Pipeline Details
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.0
// @description Adds copy icons to pipeline run titles, branch names and commit hashes so their value can be copied as a link
// @author Tim Hilton using Claude
// @match https://dev.azure.com/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const LOG_PREFIX = '[Azure DevOps: Copy Pipeline Details]';
const LINK_SELECTOR = [
'a.bolt-table-link',
'a.branch-link[aria-label^="Branch "]',
'a[aria-label^="Source version "]',
].join(', ');
const COPY_ICON_SVG = `
`;
const TICK_ICON_SVG = `
`;
const injectStyles = () => {
console.debug(`${LOG_PREFIX} Injecting styles`);
const style = document.createElement('style');
style.textContent = `
.pipeline-copy-icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: pointer;
margin-left: 4px;
}
.pipeline-copy-icon:hover {
background-color: rgba(0, 0, 0, 0.1);
}
.pipeline-copy-icon svg path {
fill: currentColor;
}
`;
document.head.appendChild(style);
};
const escapeHtml = (text) =>
text
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
const writeClipboard = async (displayText, href) => {
const htmlString = `${escapeHtml(displayText)}`;
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard.write) {
const plainBlob = new Blob([displayText], { type: 'text/plain' });
const htmlBlob = new Blob([htmlString], { type: 'text/html' });
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': plainBlob,
'text/html': htmlBlob,
}),
]);
} else {
await navigator.clipboard.writeText(displayText);
}
};
const handleCopyClick = (icon) => {
// Read the anchor's current text/href at click time, rather than trusting values
// captured when the icon was created — Azure DevOps's virtualised grid can silently
// recycle a row's DOM nodes for different data, so a stale closure could copy the wrong
// value even though the icon element itself was never recreated.
const anchor = icon.previousElementSibling;
const displayText = anchor?.textContent.trim();
const href = anchor?.href;
if (!displayText || !href) {
console.debug(`${LOG_PREFIX} Cannot copy: adjacent link no longer present`);
return;
}
console.debug(`${LOG_PREFIX} Copy icon clicked for: ${displayText}`);
writeClipboard(displayText, href)
.then(() => {
console.log(`${LOG_PREFIX} ✅ Copied to clipboard: ${displayText}`);
const originalContent = icon.innerHTML;
icon.innerHTML = TICK_ICON_SVG;
setTimeout(() => {
if (document.body.contains(icon)) {
icon.innerHTML = originalContent;
}
}, 1000);
})
.catch((err) => {
console.log(`${LOG_PREFIX} ❌ Failed to copy to clipboard: ${err}`);
});
};
const hasCopyIcon = (anchor) => anchor.nextElementSibling?.classList.contains('pipeline-copy-icon') ?? false;
const addCopyIcon = (anchor) => {
const displayText = anchor.textContent.trim();
if (!displayText) {
console.debug(`${LOG_PREFIX} Skipping link with no display text`);
return;
}
const icon = document.createElement('span');
icon.innerHTML = COPY_ICON_SVG;
icon.classList.add('pipeline-copy-icon');
icon.setAttribute('title', `Copy ${displayText}`);
icon.addEventListener('click', (e) => {
e.stopPropagation();
handleCopyClick(icon);
});
anchor.insertAdjacentElement('afterend', icon);
console.debug(`${LOG_PREFIX} Added copy icon for: ${displayText}`);
};
const scanAndInject = () => {
// Check for an existing icon sibling rather than marking the anchor itself as
// "processed": Azure DevOps's virtualised grid can recycle a row's DOM nodes (resetting
// their attributes) without removing our injected icon, which previously made a
// processed-marker attribute on the anchor unreliable and caused duplicate icons to pile
// up indefinitely.
const anchors = Array.from(document.querySelectorAll(LINK_SELECTOR)).filter((anchor) => !hasCopyIcon(anchor));
if (anchors.length === 0) {
return;
}
console.debug(`${LOG_PREFIX} Found ${anchors.length} link(s) without a copy icon`);
anchors.forEach(addCopyIcon);
console.log(`${LOG_PREFIX} 🎉 Added ${anchors.length} copy icon(s)`);
};
injectStyles();
scanAndInject();
let scanScheduled = false;
const scheduleScan = () => {
if (scanScheduled) {
return;
}
scanScheduled = true;
requestAnimationFrame(() => {
scanScheduled = false;
scanAndInject();
});
};
const observer = new MutationObserver(() => {
scheduleScan();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
console.debug(`${LOG_PREFIX} Script initialized`);
})();