NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name GitHub Custom Hotkeys // @version 1.1.5 // @description A userscript that allows you to add custom GitHub keyboard hotkeys // @license MIT // @author Rob Garrison // @namespace https://github.com/Mottie // @match https://github.com/* // @match https://*.github.com/* // @run-at document-idle // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @require https://greasyfork.org/scripts/398877-utils-js/code/utilsjs.js?version=1079637 // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163 // @icon https://github.githubassets.com/pinned-octocat.svg // @updateURL https://raw.githubusercontent.com/Mottie/GitHub-userscripts/master/github-custom-hotkeys.user.js // @downloadURL https://raw.githubusercontent.com/Mottie/GitHub-userscripts/master/github-custom-hotkeys.user.js // @supportURL https://github.com/Mottie/GitHub-userscripts/issues // ==/UserScript== /* global $ $$ on */ (() => { "use strict"; /* "g p" here overrides the GitHub default "g p" which takes you to the Pull Requests page { "all": [ { "f1" : "#hotkey-settings" }, { "g g": "{repo}/graphs/code-frequency" }, { "g p": "{repo}/pulse" }, { "g u": [ "{user}", true ] }, { "g s": "{upstream}" } ], "{repo}/issues": [ { "g right": "{issue+1}" }, { "g left" : "{issue-1}" } ], "{root}/search": [ { "g right": "{page+1}" }, { "g left" : "{page-1}" } ] } */ let data = GM_getValue("github-hotkeys", { all: [{ f1: "#hotkey-settings" }] }); let lastHref = window.location.href; const openHash = "#hotkey-settings"; const templates = { remove: `<svg class="octicon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 9 9"><path d="M9 1L5.4 4.4 9 8 8 9 4.6 5.4 1 9 0 8l3.6-3.5L0 1l1-1 3.5 3.6L8 0l1 1z"/></svg>`, hotkey: ` <label class="tooltipped tooltipped-n" aria-label="hotkey"><input type="text" class="ghch-hotkey form-control"></label> <label class="tooltipped tooltipped-n" aria-label="URL"><input type="text" class="ghch-url form-control"></label> <label class="tooltipped tooltipped-w" aria-label="Open in a new tab?"><input type="checkbox" class="ghch-new-tab"></label>`, scope: "<ul><li><button class='ghch-hotkey-add'>+ Click to add a new hotkey</button></li></ul>" }; // https://github.com/{nonUser} // see https://github.com/Mottie/github-reserved-names const nonUser = new RegExp("^(" + [ /* BUILD:RESERVED-NAMES-START (v2.0.4) */ "400", "401", "402", "403", "404", "405", "406", "407", "408", "409", "410", "411", "412", "413", "414", "415", "416", "417", "418", "419", "420", "421", "422", "423", "424", "425", "426", "427", "428", "429", "430", "431", "500", "501", "502", "503", "504", "505", "506", "507", "508", "509", "510", "511", "about", "access", "account", "admin", "advisories", "anonymous", "any", "api", "apps", "attributes", "auth", "billing", "blob", "blog", "bounty", "branches", "business", "businesses", "c", "cache", "case-studies", "categories", "central", "certification", "changelog", "cla", "cloud", "codereview", "collection", "collections", "comments", "commit", "commits", "community", "companies", "compare", "contact", "contributing", "cookbook", "coupons", "customer-stories", "customer", "customers", "dashboard", "dashboards", "design", "develop", "developer", "diff", "discover", "discussions", "docs", "downloads", "downtime", "editor", "editors", "edu", "enterprise", "events", "explore", "featured", "features", "files", "fixtures", "forked", "garage", "ghost", "gist", "gists", "graphs", "guide", "guides", "help", "help-wanted", "home", "hooks", "hosting", "hovercards", "identity", "images", "inbox", "individual", "info", "integration", "interfaces", "introduction", "invalid-email-address", "investors", "issues", "jobs", "join", "journal", "journals", "lab", "labs", "languages", "launch", "layouts", "learn", "legal", "library", "linux", "listings", "lists", "login", "logos", "logout", "mac", "maintenance", "malware", "man", "marketplace", "mention", "mentioned", "mentioning", "mentions", "migrating", "milestones", "mine", "mirrors", "mobile", "navigation", "network", "new", "news", "none", "nonprofit", "nonprofits", "notices", "notifications", "oauth", "offer", "open-source", "organisations", "organizations", "orgs", "pages", "partners", "payments", "personal", "plans", "plugins", "popular", "popularity", "posts", "press", "pricing", "professional", "projects", "pulls", "raw", "readme", "recommendations", "redeem", "releases", "render", "reply", "repositories", "resources", "restore", "revert", "save-net-neutrality", "saved", "scraping", "search", "security", "services", "sessions", "settings", "shareholders", "shop", "showcases", "signin", "signup", "site", "spam", "sponsors", "ssh", "staff", "starred", "stars", "static", "status", "statuses", "storage", "store", "stories", "styleguide", "subscriptions", "suggest", "suggestion", "suggestions", "support", "suspended", "talks", "teach", "teacher", "teachers", "teaching", "team", "teams", "ten", "terms", "timeline", "topic", "topics", "tos", "tour", "train", "training", "translations", "tree", "trending", "updates", "username", "users", "visualization", "w", "watching", "wiki", "windows", "works-with", "www0", "www1", "www2", "www3", "www4", "www5", "www6", "www7", "www8", "www9" /* BUILD:RESERVED-NAMES-END */ ].join("|") + ")$"); function getUrlParts() { const loc = window.location; const root = "https://github.com"; const parts = { root, origin: loc.origin, page: "" }; // me let tmp = $("meta[name='user-login']"); parts.m = tmp && tmp.getAttribute("content") || ""; parts.me = parts.m ? parts.root + "/" + parts.m : ""; // pathname "should" always start with a "/" tmp = loc.pathname.split("/"); // user name if (nonUser.test(tmp[1] || "")) { // invalid user! clear out the values tmp = []; } parts.u = tmp[1] || ""; parts.user = tmp[1] ? root + "/" + tmp[1] : ""; // repo name parts.r = tmp[2] || ""; parts.repo = tmp[1] && tmp[2] ? parts.user + "/" + tmp[2] : ""; // tab? parts.t = tmp[3] || ""; parts.tab = tmp[3] ? parts.repo + "/" + tmp[3] : ""; if (parts.t === "issues" || parts.t === "pulls") { // issue number parts.issue = tmp[4] || ""; } // branch/tag? if (parts.t === "tree" || parts.t === "blob") { parts.branch = tmp[4] || ""; } else if (parts.t === "releases" && tmp[4] === "tag") { parts.branch = tmp[5] || ""; } // commit hash? if (parts.t === "commit") { parts.commit = tmp[4] || ""; } // forked from tmp = $(".repohead .fork-flag a"); parts.upstream = tmp ? tmp.getAttribute("href") : ""; // current page tmp = loc.search.match(/[&?]p(?:age)?=(\d+)/); parts.page = tmp ? tmp[1] || "1" : ""; return parts; } // pass true to initialize; false to remove everything function checkScope() { removeElms($("body"), ".ghch-link"); const parts = getUrlParts(); Object.keys(data).forEach(key => { const url = fixUrl(parts, key === "all" ? "{root}" : key); if (window.location.href.indexOf(url) > -1) { debug("Checking custom hotkeys for " + key); addHotkeys(parts, url, data[key]); } }); } function fixUrl(parts, url = "") { let valid = true; // use true in case a full URL is used url = url // allow {issues+#} to go inc or desc .replace(/\{issue([\-+]\d+)?\}/, (s, n) => { const val = n ? parseInt(parts.issue || "", 10) + parseInt(n, 10) : ""; valid = val !== "" && val > 0; return valid ? parts.tab + "/" + val : ""; }) // allow {page+#} to change results page .replace(/\{page([\-+]\d+)?\}/, (s, n) => { const loc = window.location, val = n ? parseInt(parts.page || "", 10) + parseInt(n, 10) : ""; let search = ""; valid = val !== "" && val > 0; if (valid) { search = loc.origin + loc.pathname; if (loc.search.match(/[&?]p?=\d+/)) { search += loc.search.replace(/([&?]p=)\d+/, (s, n) => { return n + val; }); } else { // started on page 1 (no &p=1) available to replace search += loc.search + "&p=" + val; } } return valid ? search : ""; }) // replace placeholders .replace(/\{\w+\}/gi, matches => { const val = parts[matches.replace(/[{}]/g, "")] || ""; valid = val !== ""; return val; }); return valid ? url : ""; } function removeElms(src, selector) { const links = $$(selector, src); let len = links.length; while (len-- > 0) { src.removeChild(links[len]); } } function addHotkeys(parts, scope, hotkeys) { // Shhh, don't tell anyone, but GitHub checks the data-hotkey attribute // of any link on the page, so we only need to add dummy links :P let indx, url, key, entry, link, isArray; const len = hotkeys.length; const body = $("body"); for (indx = 0; indx < len; indx++) { key = Object.keys(hotkeys[indx])[0]; entry = hotkeys[indx][key]; isArray = Array.isArray(entry); url = fixUrl(parts, isArray ? entry[0] : entry); if (url) { link = document.createElement("a"); link.className = "ghch-link"; link.href = url; if (isArray) { link.target = "_blank"; } link.setAttribute("data-hotkey", key); body.appendChild(link); debug(`Adding "${key}" keyboard hotkey linked to "${url}"`); } } } function addHotkey(el) { const li = document.createElement("li"); li.className = "ghch-hotkey-set"; li.innerHTML = ` <div class="ghch-hotkey-wrap"> ${templates.hotkey} <button class="ghch-remove">${templates.remove}</button> </div>`; el.parentElement.before(li); return li; } function addScope(el) { const scope = document.createElement("fieldset"); scope.className = "ghch-scope-custom"; scope.innerHTML = ` <legend> <span class="simple-box" contenteditable>Enter Scope</span> <button class="ghch-remove">${templates.remove}</button> </legend> ${templates.scope} `; el.parentNode.insertBefore(scope, el); return scope; } function addMenu() { GM_addStyle(` #ghch-open-menu { cursor:pointer; } #ghch-menu { position:fixed; z-index:65535; top:0; bottom:0; left:0; right:0; opacity:0; display:none; } #ghch-menu.ghch-open { opacity:1; display:block; background:rgba(0,0,0,.5); } #ghch-settings-inner { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); width:25rem; box-shadow:0 .5rem 1rem #111; } #ghch-settings-inner h3 .btn { float:right; font-size:.8em; padding:0 6px 2px 6px; margin-left:3px; } .ghch-remove { background:transparent; border:0; white-space:initial; margin-bottom:6px; } .ghch-remove svg, #ghch-settings-inner .ghch-close svg { vertical-align:middle; pointer-events:none; } .ghch-menu-inner li .ghch-remove { margin-left:0; padding:0; } .ghch-menu-inner li .ghch-remove:hover, .ghch-menu-inner legend .ghch-remove:hover { color:#800; } .ghch-menu-inner { max-height:60vh; overflow-y:auto; } .ghch-menu-inner ul { list-style:none; } .ghch-hotkey-wrap, .ghch-hotkey-add { width:100%; display:flex; align-items:center; justify-content:space-evenly; white-space:pre; margin-bottom:4px; } .ghch-scope-all, .ghch-scope-add, .ghch-scope-custom { width:100%; border:2px solid rgba(85,85,85,0.5); border-radius:4px; padding:10px; margin:0; } .ghch-scope-add, .ghch-hotkey-add { background:transparent; border:2px dashed #555; border-radius:4px; opacity:0.6; text-align:center; cursor:pointer; margin-top:10px; } .ghch-scope-add:hover, .ghch-hotkey-add:hover { opacity:1; } .ghch-menu-inner legend span { padding:0 6px; min-width:30px; border:0; } .ghch-hotkey { width:80px; } .ghch-json-code { display:none; font-family:Menlo, Inconsolata, "Droid Mono", monospace; font-size:1em; } .ghch-json-code.ghch-open { position:absolute; top:37px; bottom:0; left:2px; right:2px; z-index:0; width:396px; max-width:396px; max-height:calc(100% - 37px); display:block; } .ghch-menu-inner textarea { resize:none; } `); // add menu const menu = document.createElement("div"); menu.id = "ghch-menu"; menu.innerHTML = ` <div id="ghch-settings-inner" class="boxed-group"> <h3> GitHub Custom Hotkey Settings <button type="button" class="btn btn-sm ghch-close tooltipped tooltipped-n" aria-label="Close"> ${templates.remove} </button> <button type="button" class="ghch-code btn btn-sm tooltipped tooltipped-n" aria-label="Toggle JSON data view">{ }</button> <a href="https://github.com/Mottie/GitHub-userscripts/wiki/GitHub-custom-hotkeys" class="ghch-help btn btn-sm tooltipped tooltipped-n" aria-label="Get Help">?</a> </h3> <div class="ghch-menu-inner boxed-group-inner"> <fieldset class="ghch-scope-all"> <legend> <span class="simple-box" data-scope="all">All of GitHub & subdomains</span> </legend> ${templates.scope} </fieldset> <button class="ghch-scope-add">+ Click to add a new scope</button> <textarea class="ghch-json-code form-control"></textarea> </div> </div> `; $("body").appendChild(menu); addBindings(); } function openPanel() { updateMenu(); $("#ghch-menu").classList.add("ghch-open"); return false; } function closePanel() { const menu = $("#ghch-menu"); if (menu?.classList.contains("ghch-open")) { // update data in case a "change" event didn't fire refreshData(); checkScope(); menu.classList.remove("ghch-open"); $(".ghch-json-code", menu).classList.remove("ghch-open"); window.location.hash = ""; return false; } } function addJSON() { const textarea = $(".ghch-json-code"); textarea.value = JSON .stringify(data, null, 2) // compress JSON a little .replace(/\n\s{4}\}/g, " }") .replace(/\{\n\s{6}/g, "{ ") .replace(/\[\s{9}/g, "[ ") .replace(/\,\s{9}/g, ", ") .replace(/\s{7}\]/g, " ]"); } function processJSON() { let val; const textarea = $(".ghch-json-code"); try { val = JSON.parse(textarea.value); data = val; } catch (err) {} } function updateMenu() { const menu = $(".ghch-menu-inner"); if (menu) { removeElms(menu, ".ghch-scope-custom"); removeElms($(".ghch-scope-all ul", menu), ".ghch-hotkey-set"); let scope, selector; // Add scopes Object.keys(data).forEach(key => { if (key === "all") { selector = "all"; scope = $(".ghch-scope-all .ghch-hotkey-add", menu); } else if (key !== selector) { selector = key; scope = addScope($(".ghch-scope-add")); $("legend span", scope).innerHTML = key; scope = $(".ghch-hotkey-add", scope); } // add hotkey entries // eslint-disable-next-line no-loop-func data[key].forEach(val => { const target = addHotkey(scope); const tmp = Object.keys(val)[0]; const entry = val[tmp]; $(".ghch-hotkey", target).value = tmp; if (Array.isArray(entry)) { $(".ghch-url", target).value = entry[0]; $(".ghch-new-tab", target).checked = entry[1] } else { $(".ghch-url", target).value = entry; } }); }); } } function refreshData() { data = {}; let tmp, scope, sIndx, hotkeys, scIndx, scLen, val; const menu = $(".ghch-menu-inner"); const scopes = $$("fieldset", menu); const sLen = scopes.length; for (sIndx = 0; sIndx < sLen; sIndx++) { tmp = $("legend span", scopes[sIndx]); if (tmp) { scope = tmp.getAttribute("data-scope") || tmp.textContent.trim(); hotkeys = $$(".ghch-hotkey-set", scopes[sIndx]); scLen = hotkeys.length; data[scope] = []; for (scIndx = 0; scIndx < scLen; scIndx++) { tmp = $$("input", hotkeys[scIndx]); val = (tmp[0] && tmp[0].value) || ""; if (val) { data[scope][scIndx] = {}; if (tmp[2].checked) { data[scope][scIndx][val] = [tmp[1].value || "", true]; } else { data[scope][scIndx][val] = tmp[1].value || ""; } } } } } GM_setValue("github-hotkeys", data); debug("Data refreshed", data); } function addDropdownLink() { if (!$("#ghch-open-menu")) { // Create our menu entry const menu = document.createElement("a"); menu.id = "ghch-open-menu"; menu.role = "menuitem"; menu.className = "dropdown-item"; menu.innerHTML = "GitHub Hotkey Settings"; menu.onclick = openPanel; const els = $$(".Header-item .dropdown-item[href='/settings/profile']"); if (els.length) { els[els.length - 1].after(menu); } } } function addBindings() { let tmp; const menu = $("#ghch-menu"); if (!menu) { return; } // close menu on(menu, "click", closePanel); on($("body"), "keydown", event => { if (event.which === 27) { closePanel(); } }); // stop propagation on($("#ghch-settings-inner", menu), "keydown", event => { event.stopPropagation(); }); on($("#ghch-settings-inner", menu), "click", event => { event.stopPropagation(); let target = event.target; // add hotkey if (target.classList.contains("ghch-hotkey-add")) { addHotkey(target); } else if (target.classList.contains("ghch-scope-add")) { addScope(target); } // svg & path nodeName may be lowercase tmp = target.nodeName.toLowerCase(); if (tmp === "path") { target = target.parentNode; } // target should now point at svg if (target.classList.contains("ghch-remove")) { tmp = target.parentNode; // remove fieldset if (tmp.nodeName === "LEGEND") { tmp = tmp.parentNode; } // remove li; but not the button in the header if (tmp.nodeName !== "BUTTON") { tmp.parentNode.removeChild(tmp); refreshData(); } } }); on(menu, "change", refreshData); // contenteditable scope title on(menu, "input", event => { if (event.target.classList.contains("simple-box")) { refreshData(); } }); on($("button.ghch-close", menu), "click", closePanel); // open JSON code textarea on($(".ghch-code", menu), "click", () => { $(".ghch-json-code", menu).classList.toggle("ghch-open"); addJSON(); }); // close JSON code textarea tmp = $(".ghch-json-code", menu); on(tmp, "focus", function () { this.select(); }); on(tmp, "paste", () => { setTimeout(() => { processJSON(); updateMenu(); $(".ghch-json-code").classList.remove("ghch-open"); }, 200); }); // This is crazy! But window.location.search changes do not fire the // "popstate" or "hashchange" event, so we're stuck with a setInterval setInterval(() => { const loc = window.location; if (lastHref !== loc.href) { lastHref = loc.href; checkScope(); // open panel via hash if (loc.hash === openHash) { openPanel(); } } }, 1000); } // include a "debug" anywhere in the browser URL search parameter to enable // debugging function debug() { if (/debug/.test(window.location.search)) { console.log.apply(console, arguments); } } on(document, "ghmo:menu", () => { // user menu needs to call an API now addDropdownLink(); }); // initialize checkScope(); addMenu(); })();