Greasy Fork is available in English.

Better GitHub Pull Requests

Improves the GitHub Pull Request UI and adds several features. "Better PRs"

// ==UserScript==
// @name         Better GitHub Pull Requests
// @namespace    DougKrahmer
// @version      0.1.3.3
// @description  Improves the GitHub Pull Request UI and adds several features. "Better PRs"
// @author       Doug Krahmer
// @license      GNU GPLv3
// @include      /^https:\/\/github\.com\/[^\/]+\/[^\/].*$/
// @include      /^https:\/\/git\.[^\/]+\/[^\/]+\/[^\/].*$/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_setValue
// @grant        GM_getValue
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @run-at       document-idle
// ==/UserScript==

/*
# Features
   - Show one file at a time. (can be disabled with on-page checkbox)
   - Show unviewed filenames in bold in file tree.
   - Show number of comments for each file in file tree.
   - Auto-mark file as viewed after viewing for 2 seconds. (only in "single file" mode, configurable delay time)
   - Filename pop-up tooltips in native GitHub file tree.
   - Automatically load large diffs that are hidden by default. (only in "single file" mode)
   - Enable adjusting the file tree width. (bottom-right corner of file tree pane)
   - Works with GitHub and GitHub Enterprise.
   - Supports native GitHub tree view and/or [Gitako Chrome Extension](https://chrome.google.com/webstore/detail/gitako-github-file-tree/giljefjcheohhamkjphiebfjnlphnokk) tree view.
   - On-page checkbox to disable/enable added styles.

# User Settings
   - `Enabled` - Set to false to disable this mod. A checkbox is added to the page to easliy change this setting on the fly.
   - `IntegrateWithGitako` - Integrate with [Gitako](https://chrome.google.com/webstore/detail/gitako-github-file-tree/giljefjcheohhamkjphiebfjnlphnokk) file tree in addition to GitHub native.
   - `MarkViewedAfterMs` - Mark file viewed after this many milliseconds. Set to 0 to disable.
   - `ShowOnlySingleFile` - Show only a single file at a time in the UI. A checkbox is added to the page to easliy change this setting on the fly.

# How to change User Settings
Persistent user settings can be changed in the Storage tab in Tampermonkey.
The Storage tab can be found near to the script Editor tab when editing this script in Tampermonkey.
If the Storage tab is not visible, change config mode to Advanced in Tampermonkey's main Settings then refresh.
Load at least one PR to populate default values before attempting to edit.
These settings will persist even if the browser is closed or this Tampermonkey script is updated in the future.

# Notes
This script will automatically run on github.com and any domain name that begins with "git." for GitHub Enterprise.
If your GitHub Enterprise domain does not start with "git.", add your domain name to the "User Includes" section on the Settings tab for this script.
*/

(function() {
    'use strict';

    const FILE_SHOW_CLASS = "file-show";
    const UNVIEWED_CLASS = "file-unviewed";
    const FILE_LINK_CLASS = "file-link";
    const COMMENT_COUNT_CLASS = "comment-count";
    const INITIALIZE_TREE_ATTEMPTS = 600;
    const ATTACHED_ATTRIBUTE = "better-gh-attached";
    let _lastFileDiffHash = null;
    let _areTreesInitialized = false;
    const $ = window.$;

    const initialize = () => {
        // add URL change events to browser
        var wr = (type) => {
            var orig = history[type];
            return function() {
                var rv = orig.apply(this, arguments);
                var e = new Event(type);
                e.arguments = arguments;
                window.dispatchEvent(e);
                return rv;
            };
        };
        history.pushState = wr('pushState');
        history.replaceState = wr('replaceState');

        verifyStyles();
        // add URL change listeners
        window.addEventListener("pushState", handleUrlChange);
        window.addEventListener("popstate", handleUrlChange);
        window.addEventListener("replaceState", handleUrlChange);
        handleUrlChange();
    }

    const handleUrlChange = (event) => {
        verifyAppendedSettings();
        if (!_areTreesInitialized && window.location.pathname.endsWith("/files")) {
            initializeTrees();
            _areTreesInitialized = true;
        }
        else {
            _areTreesInitialized = false;
        }

        handleHashChange();
    }

    const verifyAppendedSettings = () => {
        let container = $("div.diffbar div.flex-items-center[data-pjax='#repo-content-pjax-container']");
        if (container.length === 0) {
            // legacy DOM
            container = $("div.diffbar > .flex-auto > div:nth-child(2)");
        }

        if (container.length == 0) {
            return;
        }

        let enableBetterPrs = container.find("#enable-better-prs")[0];
        if (!enableBetterPrs) {
            container.append('<div class="diffbar-item form-checkbox" style="margin-top: 0; margin-bottom: 0;"><details class="details-reset"><summary><label class="Link--muted" style="cursor: inherit" title="Enable Better GitHub Pull Requests"><input id="enable-better-prs" type="checkbox" style="cursor: inherit">Better PRs</label></summary></details></div>');
            const enableBetterPrs = container.find("#enable-better-prs")[0];
            if (enableBetterPrs) {
                enableBetterPrs.checked = getValue("Enabled", true);
                enableBetterPrs.addEventListener("change", toggleBetterPr);
            }
        }

        let singleFile = container.find("#enable-single-file")[0];
        if (!singleFile) {
            container.append('<div class="diffbar-item form-checkbox" style="margin-top: 0; margin-bottom: 0;"><details class="details-reset"><summary><label class="Link--muted" style="cursor: inherit" title="Show only the single selected file (when Better PRs is enabled)."><input id="enable-single-file" type="checkbox" style="cursor: inherit">Single File</label></summary></details></div>');
            const singleFile = container.find("#enable-single-file")[0];
            if (singleFile) {
                singleFile.checked = getValue("ShowOnlySingleFile", true);
                singleFile.addEventListener("change", toggleSingleFile);
            }
        }
    }

    const toggleBetterPr = (event) => {
        setValue("Enabled", event.target.checked);
        verifyStyles();
    }

    const toggleSingleFile = (event) => {
        setValue("ShowOnlySingleFile", event.target.checked);
        verifyStyles(true);
    }

    const handleHashChange = () => {
        const hash = (window?.location?.hash || "").replace("#", "");

        if (hash === _lastFileDiffHash || !hash.startsWith("diff-")) {
            return; // Nothing to do. Either already handled or no file to change to
        }

        // hide previous file div (if any)
        if (_lastFileDiffHash) {
            const lastFileDiv = window.document.getElementById(_lastFileDiffHash);
            if (lastFileDiv) {
                lastFileDiv.className = lastFileDiv.className.replace(` ${FILE_SHOW_CLASS}`, "");
            }
        }

        const fileDiv = window.document.getElementById(hash);
        if (!fileDiv) {
            _lastFileDiffHash = null;
            // The file element is missing and might not be loaded yet. Try again soon...
            setTimeout(() => handleHashChange(), 100);
            return;
        }

        // show the selected file div
        addClassName(fileDiv, FILE_SHOW_CLASS);
        const button = fileDiv.querySelector(".js-diff-load-container button");
        if (button?.attributes["data-disable-with"]?.value?.startsWith("Loading")) {
            button.click();
        }

        // Remove the class name that hides the block after collpsing or marking as viewed
        const detailsClassName = "Details-content--hidden";
        const containerDiv = fileDiv.querySelector(`div.${detailsClassName}`);
        if (containerDiv) {
            removeClassName(containerDiv, detailsClassName)
        }

        _lastFileDiffHash = hash;
        const viewedCheckbox = attachToViewedCheckbox(fileDiv, hash);

        if (getValue("Enabled", true) && getValue("ShowOnlySingleFile", true) && getValue("MarkViewedAfterMs", 2000) > 0 && viewedCheckbox?.checked === false) {
            setTimeout(() => {
                // check if the user is still viewing the same file
                const currentHash = (window?.location?.hash || "").replace("#", "");
                if (currentHash != hash) {
                    // the user has moved on
                    return;
                }
                viewedCheckbox.click(); // mark viewed
            }, getValue("MarkViewedAfterMs", 2000));
        }
    }

    const initializeTrees = () => {
        initializeTree("file-tree", "link-", INITIALIZE_TREE_ATTEMPTS); // native tree
        if (getValue("IntegrateWithGitako", true)) {
            initializeGitakoTree(INITIALIZE_TREE_ATTEMPTS);
        }
    }

    const initializeGitakoTree = (retryAttempts) => {
        const gitakoContainer = document.querySelector(".magic-size-container")?.querySelector("div")?.querySelector("div");

        if (!gitakoContainer && retryAttempts > 0) {
            setTimeout(() => initializeGitakoTree(retryAttempts - 1), 500); // wait a bit for it to load then try again
            return;
        }

        const initEvent = (event) => {
            initializeTree("div.gitako-side-bar-content", "link-gitako-", retryAttempts);
        }

        initEvent();
        gitakoContainer.addEventListener("DOMNodeInserted", initEvent);
    }

    const initializeTree = (containerSelector, linkPrefix, retryAttempts) => {
        const treeDiv = document.querySelector(containerSelector);
        const treeLinks = treeDiv?.querySelectorAll("a") ?? [];

        let fileCount = 0;
        for (let i = 0; i < treeLinks.length; i++) {
            const treeLink = treeLinks[i];
            if (treeLink.id || !treeLink.href?.includes("#diff-")) {
                continue;
            }

            const hash = treeLink.href.substring(treeLink.href.indexOf("#diff-") + 1);
            const fileDiv = window.document.getElementById(hash);
            if (!fileDiv) {
                setTimeout(() => initializeTree(containerSelector, linkPrefix, retryAttempts - 1), 100); // It may not have finished loading yet. Try again soon.
                return;
            }
            const viewedCheckbox = fileDiv.querySelector("input.js-reviewed-checkbox");
            addClassName(treeLink, FILE_LINK_CLASS)
            if (!viewedCheckbox?.checked) {
                addClassName(treeLink, UNVIEWED_CLASS)
            }

            if (!treeLink.title){
                const fileLabelElement = treeLink.querySelector(".ActionList-item-label");
                const filename = fileLabelElement?.innerText;
                if (filename) {
                    treeLink.title = filename; // add filename tooltip for native file tree
                }
            }

            const handleCommentAddedRemoved = (event) => {
                if (!event?.target?.className?.includes("comment-holder")
                    && !event?.target?.className?.includes("review-comment")
                    && !event?.target?.className?.includes("js-comment-container")) {
                    return;
                }

                // handle after the current command stack to ensure an accurate count
                setTimeout(() => {
                    updateCommentCount(treeLink, fileDiv);
                }, 0)
            }

            fileDiv.addEventListener("DOMNodeInserted", handleCommentAddedRemoved);
            fileDiv.addEventListener("DOMNodeRemoved", handleCommentAddedRemoved);

            updateCommentCount(treeLink, fileDiv);

            // add an id so we know we already processed it
            treeLink.id = `${linkPrefix}${hash}`;

            fileCount++;
        }
    }

    const updateCommentCount = (treeLink, fileDiv) => {
        let commentCountElement = $(treeLink.querySelector(".comment-count"));
        if (commentCountElement.length === 0) {
            const container = $(treeLink.querySelector(".ActionList-item-visual--trailing"));
            if (container.length == 0) {
                return;
            }
            container.prepend('<span class="comment-count" title="comment count"></span>');
            commentCountElement = container.find(".comment-count");
        }

        const commentElements = fileDiv.querySelectorAll(".review-comment");
        commentCountElement.text(commentElements.length ? `${commentElements.length}` : "");
        if (commentElements.length > 0) {
            commentCountElement.prepend('<svg role="img" class="octicon Comment" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" style="margin-right: 1px;"><path fill-rule="evenodd" d="M2.75 2.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 01.75.75v2.19l2.72-2.72a.75.75 0 01.53-.22h4.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25H2.75zM1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0113.25 12H9.06l-2.573 2.573A1.457 1.457 0 014 13.543V12H2.75A1.75 1.75 0 011 10.25v-7.5z"></path></svg>');
        }
    }

    const handleViewedCheckboxChange = (fileDiv, viewedCheckbox, hash) => {
        initializeTrees();
        const link = window.document.getElementById(`link-${hash}`);
        const linkGitako = window.document.getElementById(`link-gitako-${hash}`);

        if (viewedCheckbox.checked) {
            removeClassName(link, UNVIEWED_CLASS);
            removeClassName(linkGitako, UNVIEWED_CLASS);
        }
        else {
            addClassName(link, UNVIEWED_CLASS);
            addClassName(linkGitako, UNVIEWED_CLASS);
        }
    }

    const attachToViewedCheckbox = (fileDiv, hash) => {
        const outerContainer = fileDiv?.querySelector(".flex-justify-end");
        const innerContainer = outerContainer?.querySelector(".js-replace-file-header-review");
        let viewedCheckbox = innerContainer?.querySelector("input.js-reviewed-checkbox");

        const handler = (event) => {
            viewedCheckbox = event.srcElement;
            if (event.srcElement.type != "checkbox") {
                viewedCheckbox = event.srcElement?.querySelector("input.js-reviewed-checkbox");
            }
            handleViewedCheckboxChange(fileDiv, viewedCheckbox, hash);
            attachToViewedCheckbox(fileDiv, hash);
        };

        attachEvent("change", outerContainer, handler);
        attachEvent("change", innerContainer, handler);
        attachEvent("change", viewedCheckbox, handler);

        handleViewedCheckboxChange(fileDiv, viewedCheckbox, hash); // update now just in case it changed on us

        return viewedCheckbox;
    }

    const attachEvent = (event, element, handler) => {
        if (!element) {
            return;
        }

        if (!element[ATTACHED_ATTRIBUTE]) {
            element.addEventListener(event, handler);
            element[ATTACHED_ATTRIBUTE] = "true";
        }
    }

    const addClassName = (element, className) => {
        if (element) {
            removeClassName(element, className);
            element.className = element.className + ` ${className}`;
        }
    }

    const removeClassName = (element, className) => {
        if (element) {
            element.className = element.className?.replace(className, "").trim();
        }
    }

    let _style = null;
    const verifyStyles = (recreate) => {
        if (_style && recreate) {
            _style.remove();
            _style = null;
        }

        if (!_style) {
            _style = document.createElement("style");
            _style.appendChild(document.createTextNode(""));
            document.head.appendChild(_style);

            _style.sheet.insertRule(".Layout-sidebar { resize: horizontal; }");
            if (getValue("ShowOnlySingleFile", true)) {
                _style.sheet.insertRule(".file { display: none; }");
            }
            _style.sheet.insertRule(".file-header { border-bottom: 1px solid var(--color-border-default) !important; }");
            _style.sheet.insertRule(".ActionList-content, .node-item-label span { opacity: 75%;}");
            _style.sheet.insertRule(`.${FILE_SHOW_CLASS} { display: block !important; }`);
            _style.sheet.insertRule(`.${UNVIEWED_CLASS}, .${UNVIEWED_CLASS} div span, .${UNVIEWED_CLASS} span { font-weight: bold; opacity: 100% !important; }`);
            _style.sheet.insertRule(`span.${COMMENT_COUNT_CLASS} { margin-right: 3px; white-space: nowrap; font-weight: normal !important; }`);
        }

        _style.sheet.disabled = !getValue("Enabled", true);
    }

    const getValue = (settingName, defaultValue) => {
        let value = !!window.GM_getValue ? GM_getValue(settingName) : undefined;

        if (value === undefined) {
            value = defaultValue;
            setValue(settingName, value);
        }

        return value;
    }

    const setValue = (settingName, newValue) => {
        if (window.GM_setValue) {
            window.GM_setValue(settingName, newValue);
        }
    }

    initialize();
})();