GitHub PR Copy Diff

Adds a "Copy Diff" button to the PR nav that copies the unified diff to the clipboard

Устаревшая версия за 19.06.2026. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Advertisement:

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

Advertisement:

// ==UserScript==
// @name         GitHub PR Copy Diff
// @namespace    http://tampermonkey.net/
// @icon         https://github.githubassets.com/favicons/favicon-dark.png
// @version      2026.06.19
// @description  Adds a "Copy Diff" button to the PR nav that copies the unified diff to the clipboard
// @author       KakkoiDev
// @match        https://github.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @connect      github.com
// @connect      githubusercontent.com
// @license      MIT
// ==/UserScript==

// Broad @match needed: GitHub uses SPA navigation, so the MutationObserver
// re-adds the button as the PR header re-renders.
//
// GM_xmlhttpRequest + GM_setClipboard (not fetch + navigator.clipboard):
// github.com/<o>/<r>/pull/<n>.diff 302-redirects cross-origin to
// patch-diff.githubusercontent.com with no CORS header, so a page fetch is
// blocked. GM_xmlhttpRequest bypasses CORS and sends cookies (private PRs).

(function() {
    'use strict';

    // === OPEN BUG + DIAGNOSTICS - do NOT commit/release to Greasy Fork as-is (logs to every user's console) ===
    // Symptom (reported 2026-06-18): the "Copy Diff" button vanishes when GitHub re-renders the PR
    //   header (e.g. switching tabs) and does not come back.
    // Status: NOT reproduced yet. Single instance self-heals - the log shows the button briefly removed
    //   on the first re-render then re-added (INSERTED, btns=1, visible=1) and stable across /changes,
    //   /commits, /checks, /pull/N. The earlier "two copies fighting (GF install + dev loader)" guess
    //   was WRONG - user confirmed only one instance was running.
    // Still open: some re-render / scroll (sticky header?) / browser-tab-switch path leaves it gone.
    // This logging stays ON in the dev loader to capture the next occurrence. Read the [copy-diff] lines
    //   around the moment it disappears:
    //     "skip: no filesTab"                     -> the Files-tab selector stopped matching the new header
    //     "skip: btn already exists" + visible=0  -> button detached/hidden, but the GLOBAL getElementById
    //                                                guard blocks re-adding it to the visible nav (lead suspect)
    //     btns 1 -> 0 with no following INSERTED   -> the observer/re-add never fired for that mutation
    //     filesTabs=2                             -> a second (sticky?) tab nav exists; button is in the wrong one
    // Likely fix once confirmed: drop the global getElementById guard; keep a button adjacent to the
    //   CURRENTLY VISIBLE Files tab and remove any stray/detached copies.
    const DEBUG = true;
    let _dbgLast = '';
    function dbg(action) {
        if (!DEBUG) return;
        const filesTabs = document.querySelectorAll('#prs-files-anchor-tab, a[href*="/pull/"][href$="/files"]').length;
        const all = [...document.querySelectorAll('#gh-copy-diff-btn')];
        const sig = `${action} | path=${location.pathname} filesTabs=${filesTabs} btns=${all.length} visible=${all.filter((b) => b.offsetParent !== null).length}`;
        if (sig === _dbgLast) return;   // the observer fires constantly; only log when the state changes
        _dbgLast = sig;
        console.log('[copy-diff]', sig);
    }
    // === END TEMP DEBUG ===

    function getPrInfo() {
        const m = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
        if (!m) return null;
        return { owner: m[1], repo: m[2], number: m[3] };
    }

    function flash(btn, msg) {
        btn.textContent = msg;
        btn.disabled = false;
        setTimeout(() => { btn.textContent = 'Copy Diff'; }, 1500);
    }

    function copyDiff(btn) {
        const pr = getPrInfo();
        if (!pr) return;
        const diffUrl = `${location.origin}/${pr.owner}/${pr.repo}/pull/${pr.number}.diff`;
        btn.disabled = true;
        btn.textContent = 'Copying...';
        GM_xmlhttpRequest({
            method: 'GET',
            url: diffUrl,
            onload: (res) => {
                if (res.status >= 200 && res.status < 300) {
                    GM_setClipboard(res.responseText, 'text');
                    flash(btn, 'Copied!');
                } else {
                    flash(btn, 'Failed');
                }
            },
            onerror: () => flash(btn, 'Failed'),
        });
    }

    function addCopyDiffButton() {
        const pr = getPrInfo();
        if (!pr) return;

        const filesTab = document.querySelector('#prs-files-anchor-tab')
            || document.querySelector(`a[href$="/pull/${pr.number}/files"]`);
        if (!filesTab) { dbg('skip: no filesTab'); return; }
        if (document.getElementById('gh-copy-diff-btn')) { dbg('skip: btn already exists'); return; }

        const btn = document.createElement('button');
        btn.id = 'gh-copy-diff-btn';
        btn.type = 'button';
        btn.className = 'btn btn-sm';
        btn.textContent = 'Copy Diff';
        btn.style.marginLeft = '8px';
        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            copyDiff(btn);
        });

        filesTab.insertAdjacentElement('afterend', btn);
        dbg('INSERTED');
    }

    addCopyDiffButton();

    const observer = new MutationObserver(() => {
        addCopyDiffButton();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();