GitHub PR Copy Diff

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

目前為 2026-06-19 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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
    });
})();