GitHub Network Ninja

Full-viewport graph with searchable commit list on "GitHub repo network" pages.

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

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

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name           GitHub Network Ninja
// @version        2.0
// @namespace      https://github.com/maliayas
// @author         Ali Ayas <[email protected]>
// @description    Full-viewport graph with searchable commit list on "GitHub repo network" pages.
// @license        MIT
// @include        https://github.com/*/*/network
// @icon           data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAMAUExURQAAAB8hJR8iJiAiJiAjJyIkKCMlKSMmKSQmKiUnKyUoKyYoLCcqLSgqLSgrLiksLyosMCsuMCsuMSwuMCwuMS4wMy8xNC8xNTEyNjI1NzM1ODU4Oj0%2FQj5AQz9AREFCRUJDR0JER0dITEhJTElLTk9QU09RVFBRVFFSVVJUVlJUV1RWWFtcXltcX1xdYF1eYF5eYV5fYmFiZWNkZmRlZ2dpa2prbWprbmtsbmtsb2xucG5vcW9wc3JydXt7fnx8fnx9f3x%2Bf35%2BgH5%2FgYCAg4GChIKDhYOEhoaGiIaHiYeIioiJi4mKi4qLjY6OkI6PkZCQkpGRk5GSk5GSlJWVl5aWmJqanJycnp6dn5%2Ben56foKGhoqOjpKOjpaOkpaeoqaurrK2sra2trq2ur66vsLCwsba0tbW0tra2t7i4uby7vLy8vcHBwsTCw8TDxMXExcXFxsbGx8rKysvKy83MzM7Mzc3NztTS0tXV1dXV1tfW19jX19zb293c3N7d3t%2Fe39%2Ff4ODe3uLg4OLh4ePi4uTi4uXj4%2BXk5Obl5efm5ujl5ejm5unn5%2Bro6Ovp6evq6uzq6uzr6%2B3s7O7t7e%2Fu7u%2Fu7%2FDu7vHv7%2FHw8PLx8fLy8vPz8%2FTz8%2FX09PX19fb29vf39%2Fj39%2Fj4%2BPn5%2Bfr6%2Bvv7%2B%2Fz8%2FP39%2Ff7%2B%2Fv%2F%2F%2FwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC0HIHQAAAEAdFJOU%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwBT9wclAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGHRFWHRTb2Z0d2FyZQBwYWludC5uZXQgNC4xLjFjKpxLAAACEElEQVQ4T4WQCXPSQBiGiyQVL4g3nvVurVLvq94K3lpq61lvUcFirfdttbWF2kQJVIUIQbLBxl%2B5ft8mDEnGGZ%2BZZHff99nsThrof7ALetX4Axi6bgVIXdCNiVfXTx883BH7YBh1pSYQI9u1fI7F6mvqb80qLEGvXl4000ZTYoKYjSmQb1sEF2HNNJhAci1WbKNdY6egUP61IRAQhICN2fCEq2VL0M%2FC8mghvg6GBUF4besvh2BI4CdAUN%2FP9fv9twmp9L5UCBl%2F%2FogQchyiJkVlgnYAFv6Y5uAEZlfgjAb6czw4A%2BiuONiNWXMFBSUxHVgslx28noXpgAKCemoa0KW62InpnQIIpR04TZZcnMe0swRCMTQVeFx0cRXTSBHvEJoC3FdcdGMawTsUtvuAcwUXezDtzIOQPzkZWPsj70BaiGlMBiETb0RufndwDDPfWxS%2BSvN5QLiRqyOfacRsTY796uw%2BXri1medbLzwRs8CnZMcyrHm%2BJ8ME6U2AW%2FFwL8dxwXco9PEwRZaIEhOoHOW4XQ9aOW6%2FzFhl9vw96JkwKrV5ff0veuNPM4xNXkY4UxPo2GCzd1402ffsC2Mj69ulNHZMoOnBrZOAIxKjDaZcRGS9JdAR8WLQ4wmLjPUez9K7YspsLIGmxI%2FRlZdM4VBLTyo9ahU1gdKhsTqfh63QLuBNRrAdHqrtRuzCP6D0L3BZI7iJ8A14AAAAAElFTkSuQmCC
// @require        https://code.jquery.com/jquery-2.0.3.min.js
// @run-at         document-end
// ==/UserScript==

var $canvasContainer = $(".js-network-graph-container");

if ($canvasContainer.length) {
    // If there is a scrollbar, we should hide it in order to get the correct window width and height.
    $("body").css({"overflow": "hidden"});

    // Make the container fixed to 0x0 point.
    $canvasContainer.removeClass("position-relative").css({
        "position": "fixed",
        "left": 0,
        "right": 0,
        "bottom": "-16px",
        "top": 0,
        "z-index": "1000",
        "background-color": "white"
    });

    var $canvas = $canvasContainer.find("canvas");

    var $win = $(window);
    var winWidth = $win.width();
    var winHeight = $win.height();

    $canvas.attr("width", winWidth);
    $canvas.attr("height", winHeight);
}

(function initCommitFilterBox() {
    var config = {
        /**
         * Number of commits to fetch in each API request.
         */
        "number_of_commits_per_request": 500,

        /**
         * Number of milliseconds to wait between API requests.
         */
        "api_request_interval": 1000,

        /**
         * Number of maximum commits to fetch from the API.
         */
        "number_of_max_commits": 10000
    };

    insertCss(
        ".commit-filter-box-mask {"
            + "width: 100%;"
            + "height: 100%;"

            + "position: fixed;"
            + "top: 0;"
            + "left: 0;"
            + "z-index: 1001;"

            + "background-color: #000000;"
            + "opacity: 0.5;"

            + "cursor: pointer;"
        + "}"
        + ".commit-filter-button {"
            + "position: fixed;"
            + "top: 5px;"
            + "right: 5px;"
            + "z-index: 1000;"
        + "}"
        + ".commit-filter-box {"
            + "padding: 20px;"
            + "border-radius: 10px;"

            + "position: fixed;"
            + "left: 10%;"
            + "right: 10%;"
            + "top: 10%;"
            + "bottom: 10%;"
            + "overflow: hidden;"
            + "z-index: 1002;"

            + "background-color: #eaf5ff;"
        + "}"
        + ".commit-filter-box input {"
            + "margin-bottom: 10px;"
        + "}"
        + ".commit-filter-box .table-container {"
            + "overflow: auto;"
        + "}"
        + ".commit-filter-box .loading {"
            + "position: relative;"
            + "top: 25%;"

            + "color: #96a3ae;"

            + "text-align: center;"
        + "}"
        + ".commit-filter-box .loading .flash {"
            + "margin-top: 20px;"

            + "display: inline-block;"
        + "}"
        + ".commit-filter-box table {"
            + "width: 100%;"

            + "display: none;"
        + "}"
        + ".commit-filter-box .author {"
            + "white-space: nowrap;"
        + "}"
        + ".commit-filter-box .message {"
            + "padding-left: 10px;"
        + "}"
        + ".commit-filter-box table tr.non-merged .message {"
            + "font-weight: bold;"
        + "}"
        + ".commit-filter-box .date {"
            + "padding-right: 10px;"

            + "white-space: nowrap;"
            + "text-align: right;"
        + "}"
    );

    var $commitFilterBoxMask, $commitFilterButton, $commitFilterBox, $commitFilterInput, $commitFilterLoading, $commitFilterTable;

    var numberOfTotalCommits = 0;

    var repo = getCurrentRepo();

    /**
     * This is a hash string that's specific to the repo. It's required in order to
     * make API request for list of commits. However, -to my knowledge- it's not
     * possible to get it directly from somewhere. GitHub somehow creates/calculates
     * it. So we'll simply steal it from GitHub's runtime. See below for more
     * details.
     */
    var nethash;

    /*
     * In order to find "nethash" value, we need to hook into GitHub's API requests.
     * So we're creating a proxy "fetch()" method below. Whenever we find a request
     * that includes the "nethash" in its URL we'll save it for future usage.
     */
    var originalFetch = fetch;
    fetch = function (input, init) {
        var match;

        if (typeof nethash === "undefined" && (match = /[?&]nethash=([^&]+)/.exec(input.url))) {
            nethash = match[1];
        }

        // Just do what regular fetch() does.
        return originalFetch(input, init);
    };

    /**
     * Meta data related to axises of the network graph. See `fetchNetworkMetaData()`.
     */
    var networkMetaData;

    $commitFilterButton = $(
        "<button class='commit-filter-button btn btn-sm btn-primary'>"
            + "List of commits"
        + "</button>").appendTo("body");

    $commitFilterButton.click(showCommitFilterBox);

    function showCommitFilterBox() {
        if (typeof $commitFilterBox !== "undefined") {
            // It's already created before.

            $commitFilterBoxMask.show();
            $commitFilterBox.show();
            return;
        }

        $commitFilterBoxMask = $("<div class='commit-filter-box-mask'></div>").appendTo("body");
        $commitFilterBoxMask.click(hideCommitFilterBox);

        $commitFilterBox = $(
            "<div class='commit-filter-box'>"
                + "<input type='text' class='form-control input-block' />"
                + "<div class='table-container'>"
                    + "<div class='loading'>"
                        + "<p class='h1'><span class='number'>0</span> commits fetched...</p>"
                        + "<p class='h3'>Please wait until all the commits are fetched.</p>"
                        + "<p class='h5'>(Limit is " + config.number_of_max_commits + " commits. You can configure it in the source.)</p>"
                    + "</div>"
                    + "<table></table>"
                + "</div>"
            + "</div>").appendTo("body");

        // Adjust height dynamically based on the viewport height. 44 is the total
        // height of the input box.
        $commitFilterBox.find(".table-container").height($commitFilterBox.height() - 44);

        $commitFilterInput      = $commitFilterBox.find("input");
        $commitFilterLoading    = $commitFilterBox.find(".loading");
        $commitFilterTable      = $commitFilterBox.find("table");

        bindFilterFunctionOnInput();

        fetchNetworkMetaData();

        // Close commit filter box on escape.
        $(document).bind("keyup", function (e) {
            if (e.which == 27) {
                hideCommitFilterBox();
                return false;
            }

            return true;
        });
    }

    function hideCommitFilterBox() {
        $commitFilterBox.hide();
        $commitFilterBoxMask.hide();
        $commitFilterInput.val("").trigger("input");
    }

    /**
     * Returns "user/repo".
     */
    function getCurrentRepo() {
        return document.location.href.replace(/^https:\/\/(www\.)?github\.com\/([^/]+\/[^/]+)(\/.*)?$/, "$2");
    }

    function insertCss(css) {
        var style = document.createElement("style");
        style.type = "text/css";
        style.innerHTML = css;
        document.head.appendChild(style);
    }

    var timeoutId;
    function bindFilterFunctionOnInput() {
        $commitFilterInput.on("input", function () {
            window.clearTimeout(timeoutId);

            timeoutId = window.setTimeout(function () {
                var query = $commitFilterInput.val().toLowerCase();

                $commitFilterTable.find("tr").each(function () {
                    if (query === "" || this.getAttribute("search-text").includes(query)) {
                        this.removeAttribute("hidden");

                    } else {
                        this.setAttribute("hidden", "");
                    }
                });
            }, 1000);
        });
    }

    function fetchNetworkMetaData(retry) {
        if (typeof retry === "undefined") {
            // Number of retries for failed requests.
            retry = 3;
        }

        $.getJSON("https://github.com/" + repo + "/network/meta")
            .done(function (response) {
                networkMetaData = response;

                fetchCommits();

            }).fail(function () {
                retry--;

                if (! retry) {
                    $commitFilterLoading.append("<div class='flash flash-error'>Network meta data could not be fetched. You can refresh the page to retry.</div>");
                    return;
                }

                // Retry.
                window.setTimeout(function () {
                    fetchNetworkMetaData(retry);
                }, config.api_request_interval);
            });
    }

    function fetchCommits(offset, retry) {
        if (typeof offset === "undefined") {
            offset = 0;
        }

        if (typeof retry === "undefined") {
            // Number of retries for failed requests.
            retry = 3;
        }

        /*
         * "start" and "end" URL parameters works this way: "start=0&end=2" returns
         * 3 items whose indexes are 0, 1 and 2. Hence the "-1" in the calculation
         * of "end".
         */
        $.getJSON("https://github.com/" + repo + "/network/chunk", {
            "nethash"   : nethash,
            "start"     : offset,
            "end"       : offset + config.number_of_commits_per_request - 1
        }).done(function (response) {
            numberOfTotalCommits += response.commits.length;

            $commitFilterLoading.find(".number").text(numberOfTotalCommits)

            // Template object.
            var $tr = $("<tr><td class='author'></td><td class='message'></td><td class='date'></td></tr>");

            for (var i = 0; i < response.commits.length; i++) {
                var $newTr = $tr.clone();

                var commitUrl = "https://github.com/" + getUserRepoBySpace(response.commits[i]["space"]) + "/commit/" + response.commits[i]["id"];
                var $commitLink = $("<a target='_blank'></a>").attr("href", commitUrl).text(response.commits[i]["message"]);

                // Check if the commit exists in the upstream repo.
                if (getUserBySpace(response.commits[i]["space"]) != getUserBySpace(0)) {
                    $newTr.addClass("non-merged");
                }

                $newTr.find(".author")      .text(response.commits[i]["login"] || "(" + response.commits[i]["author"] + ")"); // "login" may be empty sometimes.
                $newTr.find(".message")     .html($commitLink);
                $newTr.find(".date")        .text(response.commits[i]["date"]);

                $commitFilterTable.prepend($newTr);
            }

            var fetchCompleted = false;

            if (numberOfTotalCommits >= config.number_of_max_commits) {
                // We've reached the limit (defined by us).
                fetchCompleted = true;
            }

            if (response.commits.length < config.number_of_commits_per_request) {
                // All the commits are fetched. Let's show them.
                fetchCompleted = true;
            }

            if (fetchCompleted) {
                $commitFilterLoading.hide();
                $commitFilterTable.show();

                /*
                 * Searching uses `innerText` and `innerText` requires elements to
                 * be visible in order to work. This is why we cache them in the
                 * beginnig when all the rows are visible.
                 */
                buildSearchIndex();

                $commitFilterInput.attr("placeholder", "Search in " + numberOfTotalCommits + " commits from " + networkMetaData.users.length + " repos...")
                $commitFilterInput.focus();

            } else {
                // There is more...

                window.setTimeout(function () {
                    fetchCommits(offset + config.number_of_commits_per_request);
                }, config.api_request_interval);
            }

        }).fail(function () {
            retry--;

            if (! retry) {
                $commitFilterLoading.append("<div class='flash flash-error'>Fetch operation failed. You can refresh the page to retry.</div>");
                return;
            }

            // Retry the same request.
            window.setTimeout(function () {
                fetchCommits(offset, retry);
            }, config.api_request_interval);
        });
    }

    function buildSearchIndex() {
        $commitFilterTable.find("tr").each(function () {
            this.setAttribute("search-text", this.innerText.toLowerCase().replace(/\s+/g, " "));
        });
    }

    /**
     * "space" values are numbers returned from the GitHub API. They represent index
     * of the line which a commit's point resides on in the network graph. "0" is
     * the top (horizontal) line, "1" is the one below it, and so on.
     */
    function getUserBySpace(space) {
        for (var i = networkMetaData.blocks.length - 1; i >= 0; i--) {
            if (space < networkMetaData.blocks[i].start) {
                continue;
            }

            return networkMetaData.blocks[i].name;
        }

        return "_user_not_found_";
    }

    function getUserRepo(user) {
        for (var i = 0; i < networkMetaData.users.length; i++) {
            if (networkMetaData.users[i].name == user) {
                return networkMetaData.users[i].name + "/" + networkMetaData.users[i].repo;
            }
        }

        return user + "/_repo_not_found_";
    }

    function getUserRepoBySpace(space) {
        return getUserRepo(getUserBySpace(space));
    }
})();