- // ==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://greatest.deepsurf.us/scripts/398877-utils-js/code/utilsjs.js?version=1079637
- // @require https://greatest.deepsurf.us/scripts/28721-mutations/code/mutations.js?version=1108163
- // @icon https://github.githubassets.com/pinned-octocat.svg
- // @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();
- })();