GitHub Custom Hotkeys

A userscript that allows you to add custom GitHub keyboard hotkeys

Pada tanggal 22 Agustus 2017. Lihat %(latest_version_link).

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        GitHub Custom Hotkeys
// @version     1.0.7
// @description A userscript that allows you to add custom GitHub keyboard hotkeys
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @include     https://github.com/*
// @include     https://*.github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @icon        https://github.com/fluidicon.png
// ==/UserScript==
(() => {
	"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" },
			{ "g p": "{repo}/pulse" },
			{ "g u": "{user}" },
			{ "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"
			}]
		}),
		lastHref = window.location.href;

	const openHash = "#hotkey-settings",

		templates = {
			remove: "<svg class='ghch-remove 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: "Hotkey: <input type='text' class='ghch-hotkey form-control'>&nbsp; URL: <input type='text' class='ghch-url form-control'>",
			scope: "<ul><li class='ghch-hotkey-add'>+ Click to add a new hotkey</li></ul>"
		},

		// https://github.com/{nonUser}
		// see https://github.com/Mottie/github-reserved-names
		nonUser = new RegExp("(" + [
			"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",
			"anonymous", "api", "apps", "auth", "billing", "blog", "business",
			"cache", "categories", "changelog", "codereview", "comments", "community",
			"compare", "contact", "dashboard", "design", "developer", "docs",
			"downloads", "editor", "edu", "enterprise", "events", "explore",
			"features", "files", "gist", "gists", "graphs", "help", "home", "hosting",
			"images", "info", "integrations", "issues", "jobs", "join", "languages",
			"legal", "linux", "lists", "login", "logout", "mac", "maintenance",
			"marketplace", "mine", "mirrors", "mobile", "navigation", "network",
			"new", "news", "notifications", "oauth", "offer", "open-source",
			"organizations", "orgs", "pages", "payments", "personal", "plans",
			"plugins", "popular", "posts", "press", "pricing", "projects", "pulls",
			"readme", "releases", "repositories", "search", "security", "services",
			"sessions", "settings", "shop", "showcases", "signin", "signup", "site",
			"ssh", "staff", "stars", "static", "status", "store", "stories",
			"styleguide", "subscriptions", "support", "talks", "teams", "terms",
			"tos", "tour", "translations", "trending", "updates", "username", "users",
			"watching", "wiki", "windows", "works-with", "www1", "www2", "www3",
			"www4", "www5", "www6", "www7", "www8", "www9"
		].join("|") + ")");

	function getUrlParts() {
		const loc = window.location,
			root = "https://github.com",
			parts = {
				root,
				origin: loc.origin,
				page: ""
			};
		// me
		let tmp = $("meta[name='user-login']");
		parts.m = tmp && tmp.getAttribute("content") || "";
		parts.me = parts.me ? 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") {
			// issue number
			parts.issue = tmp[4] || "";
		}
		// forked from
		tmp = $(".repohead .fork-flag a");
		parts.upstream = tmp ? tmp.getAttribute("href") : "";
		// current page
		if (loc.search.match(/[&?]q=/)) {
			tmp = loc.search.match(/[&?]p=(\d+)/);
			parts.page = tmp ? tmp[1] || "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--) {
			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, link;
		const len = hotkeys.length,
			body = $("body");
		for (indx = 0; indx < len; indx++) {
			key = Object.keys(hotkeys[indx])[0];
			url = fixUrl(parts, hotkeys[indx][key]);
			if (url) {
				link = document.createElement("a");
				link.className = "ghch-link";
				link.href = url;
				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 = templates.hotkey + templates.remove;
		el.parentNode.insertBefore(li, el);
		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>&nbsp;
				${templates.remove}
			</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; visibility:hidden; }
			#ghch-menu.ghch-open { opacity:1; visibility:visible; 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, .ghch-remove svg, #ghch-settings-inner .ghch-close svg { vertical-align:middle; cursor:pointer; }
			.ghch-menu-inner { max-height:60vh; overflow-y:auto; }
			.ghch-menu-inner ul { list-style:none; }
			.ghch-menu-inner li { 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 { 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:60px; }
			.ghch-menu-inner li .ghch-remove { margin-left:10px; }
			.ghch-menu-inner li .ghch-remove:hover, .ghch-menu-inner legend .ghch-remove:hover { color:#800; }
			.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; }
		`);

		// add menu
		let 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 &amp; subdomains</span>
						</legend>
						${templates.scope}
					</fieldset>
					<div class="ghch-scope-add">+ Click to add a new scope</div>
					<textarea class="ghch-json-code"></textarea>
				</div>
			</div>
		`;
		$("body").appendChild(menu);
		// Create our menu entry
		menu = document.createElement("a");
		menu.id = "ghch-open-menu";
		menu.className = "dropdown-item";
		menu.innerHTML = "GitHub Hotkey Settings";

		const els = $$(`
			.header .dropdown-item[href="/settings/profile"],
			.header .dropdown-item[data-ga-click*="go to profile"],
			.Header .dropdown-item[href="/settings/profile"],
			.Header .dropdown-item[data-ga-click*="go to profile"]
		`);
		if (els.length) {
			els[els.length - 1].parentNode.insertBefore(menu, els[els.length - 1].nextSibling);
		}
		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, "{ ");
	}

	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");
		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),
					tmp = Object.keys(val)[0];
				$(".ghch-hotkey", target).value = tmp;
				$(".ghch-url", target).value = val[tmp];
			});
		});
	}

	function refreshData() {
		data = {};
		let tmp, scope, sIndx, hotkeys, scIndx, scLen, val;
		const menu = $(".ghch-menu-inner"),
			scopes = $$("fieldset", menu),
			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] = {};
						data[scope][scIndx][val] = tmp[1].value || "";
					}
				}
			}
		}
		GM_setValue("github-hotkeys", data);
		debug("Data refreshed", data);
	}

	function addBindings() {
		let tmp;
		const menu = $("#ghch-menu");

		// open menu
		on($("#ghch-open-menu"), "click", openPanel);
		// 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", () => {
			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);
	}

	function $(str, el) {
		return (el || document).querySelector(str);
	}

	function $$(str, el) {
		return Array.from((el || document).querySelectorAll(str));
	}

	function on(els, name, callback) {
		els = Array.isArray(els) ? els : [els];
		const events = name.split(/\s+/);
		els.forEach(el => {
			if (el) {
				events.forEach(ev => {
					el.addEventListener(ev, callback);
				});
			}
		});
	}

	// 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);
		}
	}

	// initialize
	checkScope();
	addMenu();
})();