Azure Portal Copy resource link v1.0.0

← Back to User Scripts

Adds a copy button next to a resource's title in the Azure Portal that copies a link to the resource, with the resource name as the link text.

Script Content

// ==UserScript==
// @name         Azure Portal: Copy Resource Link
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.0
// @description  Adds a copy button next to a resource's blade title that copies a link to the resource, with the resource name as its text
// @author       Tim Hilton using Claude
// @match        https://portal.azure.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[Azure Portal: Copy Resource Link]';

    const COPY_ICON_SVG = `

`;

    const TICK_ICON_SVG = `

    

`;

    const injectStyles = () => {
        console.debug(`${LOG_PREFIX} Injecting styles`);
        const style = document.createElement('style');
        style.textContent = `
            .azure-copy-resource-link {
                display: inline-flex;
                flex: none;
                align-items: center;
                justify-content: center;
                align-self: center;
                width: 24px;
                height: 24px;
                margin-left: 4px;
                border-radius: 4px;
                cursor: pointer;
                transition: background-color 0.25s ease-in-out;
                &:hover {
                    background-color: rgba(128, 128, 128, 0.2);
                }
                & svg path {
                    fill: currentColor;
                }
            }
        `;
        document.head.appendChild(style);
    };

    // A resource blade's URL hash looks like
    // #@tenant/resource/subscriptions/{sub}/resourceGroups/{rg}[/providers/{ns}/{type}/{name}...][/{menu item}]
    // Returns the portal link prefix (up to and including "/resource") and the resource ID with any
    // trailing menu item (e.g. "/overview", "/eventlogs") removed.
    const parseResourceHash = (hash) => {
        const match = hash.match(/^(#[^/]*\/resource)(\/subscriptions\/[^?]+)/i);
        if (!match) {
            return null;
        }
        const segments = decodeURIComponent(match[2]).split('/').filter(Boolean);
        if (segments[2]?.toLowerCase() !== 'resourcegroups' || segments.length < 4) {
            return null;
        }

        let idLength = 4;
        const providersIndex = segments.findIndex((s) => s.toLowerCase() === 'providers');
        if (providersIndex === 4) {
            // After the namespace, a resource ID has type/name pairs, so an odd count means a menu item follows
            const afterNamespace = segments.length - 6;
            idLength = afterNamespace % 2 === 0 ? segments.length : segments.length - 1;
        }
        const idSegments = segments.slice(0, idLength);
        return {
            prefix: match[1],
            resourceId: `/${idSegments.join('/')}`,
            name: idSegments[idSegments.length - 1],
        };
    };

    // The title is the resource name, followed by a span holding e.g. " | Activity log" on menu pages
    const getBladeName = (titleHeading) => titleHeading.firstChild?.nodeType === Node.TEXT_NODE ? titleHeading.firstChild.textContent.trim() : '';

    const escapeHtml = (text) =>
        text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');

    // Rich-text editors paste the HTML link, apps reading the web Markdown format paste the Markdown link,
    // and anything else pastes the name
    const writeClipboard = async (displayText, href) => {
        if (typeof ClipboardItem === 'undefined' || !navigator.clipboard.write) {
            await navigator.clipboard.writeText(displayText);
            return;
        }
        const plain = new Blob([displayText], { type: 'text/plain' });
        const html = new Blob([`${escapeHtml(displayText)}`], { type: 'text/html' });
        const markdown = new Blob([`[${displayText}](${href})`], { type: 'web text/markdown' });
        try {
            await navigator.clipboard.write([
                new ClipboardItem({ 'text/plain': plain, 'text/html': html, 'web text/markdown': markdown }),
            ]);
        } 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': plain, 'text/html': html })]);
        }
    };

    const setButtonResource = (button, name, href) => {
        button.dataset.name = name;
        button.dataset.href = href;
        button.setAttribute('aria-label', `Copy link to ${name}`);
    };

    // The name and link are read from the button when clicked, as setupCopyButtons updates them when
    // the portal reuses the blade for a different resource
    const createCopyButton = () => {
        const button = document.createElement('span');
        button.classList.add('azure-copy-resource-link');
        button.innerHTML = COPY_ICON_SVG;
        button.setAttribute('role', 'button');
        button.setAttribute('tabindex', '0');
        button.setAttribute('title', 'Copy link to resource');

        const copy = (e) => {
            e.stopPropagation();
            const { name, href } = button.dataset;
            console.debug(`${LOG_PREFIX} Copy button clicked for: ${name}`);
            writeClipboard(name, href)
                .then(() => {
                    console.log(`${LOG_PREFIX} ✅ Copied link to ${name}: ${href}`);
                    button.innerHTML = TICK_ICON_SVG;
                    setTimeout(() => {
                        button.innerHTML = COPY_ICON_SVG;
                    }, 1000);
                })
                .catch((err) => console.log(`${LOG_PREFIX} ❌ Failed to copy: ${err}`));
        };
        button.addEventListener('click', copy);
        button.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                copy(e);
            }
        });
        return button;
    };

    const setupCopyButtons = () => {
        const resource = parseResourceHash(location.hash);
        if (!resource) {
            return;
        }

        // The URL only describes the most recently opened blade, so only the blade titled with this
        // resource's name is given its link. Earlier blades keep the link they were given when they were
        // the latest. The portal reuses a blade's title element when another resource is picked (e.g. from
        // the All resources list), so a button whose title has changed is removed until the URL catches up.
        document.querySelectorAll('h2.fxs-blade-title-titleText').forEach((titleHeading) => {
            const name = getBladeName(titleHeading);
            let button = titleHeading.nextElementSibling?.classList.contains('azure-copy-resource-link')
                ? titleHeading.nextElementSibling
                : null;
            if (button?.dataset.name === name) {
                return;
            }
            if (name.toLowerCase() !== resource.name.toLowerCase()) {
                if (button) {
                    console.debug(`${LOG_PREFIX} Blade title changed from ${button.dataset.name}, removing button`);
                    button.remove();
                }
                return;
            }
            if (!button) {
                button = createCopyButton();
                titleHeading.after(button);
            }
            setButtonResource(button, name, `${location.origin}/${resource.prefix}${resource.resourceId}`);
            console.log(`${LOG_PREFIX} ✅ Copy button set for ${name}`);
        });
    };

    injectStyles();
    console.debug(`${LOG_PREFIX} Initialising script`);
    setupCopyButtons();

    // The portal is a SPA: blades open, close and re-render without a page load, 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 scheduleSetup = () => {
        observerFireCount++;
        if (setupPending) {
            return;
        }
        setupPending = true;
        requestAnimationFrame(() => {
            setupPending = false;
            console.debug(`${LOG_PREFIX} Change detected (count: ${observerFireCount}), re-running setup`);
            setupCopyButtons();
        });
    };

    // characterData catches a blade title's text being changed in place
    new MutationObserver(scheduleSetup).observe(document.body, { childList: true, subtree: true, characterData: true });
    window.addEventListener('hashchange', scheduleSetup);

    console.log(`${LOG_PREFIX} 🎉 Script initialised successfully`);
})();