Mottie / GitHub Diff Files Filter

// ==UserScript==
// @name        GitHub Diff Files Filter
// @version     2.1.5
// @description A userscript that adds filters that toggle diff & PR folders, and files by extension
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @match       https://github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @require     https://greasyfork.org/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==

(() => {
	"use strict";

	// Example page: https://github.com/julmot/mark.js/pull/250/files
	GM_addStyle(".gdf-extension-hidden, .gdf-folder-hidden { display: none; }");

	const allLabel = "\u00ABall\u00BB",
		rootLabel = "\u00ABroot\u00BB",
		noExtLabel = "\u00ABno-ext\u00BB",
		dotExtLabel = "\u00ABdot-files\u00BB",
		renameFileLabel = "\u00ABrenamed\u00BB",
		minFileLabel = "\u00ABmin\u00BB";

	let exts = {};
	let folders = {};

	function toggleBlocks({subgroup, type, show}) {
		if (type === allLabel) {
			// Toggle "all" blocks
			$$("#files div[id*='diff']").forEach(el => {
				el.classList.toggle(`gdf-${subgroup}-hidden`, !show);
			});
			// update filter buttons
			$$(`#files .gdf-${subgroup}-filter a`).forEach(el => {
				el.classList.toggle("selected", show);
			});
		} else if (subgroup === "folder") {
			Object.keys(folders)
				.reduce((acc, folder) => {
					if (folders[folder].length && !folder.includes("→")) {
						acc.push({
							folder,
							show: $(`.gdf-folder-filter a[data-item=${folder}]`).classList.contains("selected")
						});
					}
					return acc;
				}, [])
				// sort show:true to the end; to fix hiding files that should be shown
				.sort((a, b) => {
					if (a.show && b.show) {
						return 0;
					}
					return a.show && !b.show ? 1 : -1;
				})
				.forEach(({folder, show}) => {
					toggleGroup({group: folders[folder], subgroup, show });
				});
		} else if (exts[type]) {
			toggleGroup({group: exts[type], subgroup, show});
		}
		updateAllButton(subgroup);
	}

	function toggleGroup({group, subgroup, show}) {
		const files = $("#files");
		/* group contains an array of div ids used to target the
		 * hidden link added immediately above each file div container
		 * <a name="diff-xxxxx"></a>
		 * <div id="diff-#" class="file js-file js-details container">
		 */
		group.forEach(id => {
			const file = $(`#${id}`, files);
			if (file) {
				file.classList.toggle(`gdf-${subgroup}-hidden`, !show);
			}
		});
	}

	function updateAllButton(subgroup) {
		const buttons = $(`#files .gdf-${subgroup}-filter`),
			filters = $$(`a:not(.gdf-${subgroup}-all)`, buttons),
			selected = $$(`a:not(.gdf-${subgroup}-all).selected`, buttons);
		// set "all" button
		$(`.gdf-${subgroup}-all`, buttons).classList.toggle(
			"selected",
			filters.length === selected.length
		);
	}

	function getSHA(file) {
		return file.hash
			// #toc points to "a"
			? file.hash.slice(1)
			// .pr-toolbar points to "a > div > div.filename"
			: file.closest("a").hash.slice(1);
	}

	function buildList() {
		exts = {};
		folders = {};
		// make noExtLabel the first element in the object
		exts[noExtLabel] = [];
		exts[dotExtLabel] = [];
		exts[renameFileLabel] = [];
		exts[minFileLabel] = [];
		folders[rootLabel] = [];
		// TOC in file diffs and pr-toolbar in Pull requests
		$$(".file-header .file-info > a").forEach(file => {
			let txt = (file.title || file.textContent || "").trim();
			if (txt) {
				const path = txt.split("/");
				const filename = path.splice(-1)[0];
				// test for no extension, then get extension name
				// regexp from https://github.com/silverwind/file-extension
				let ext = /\./.test(filename) ? /[^./\\]*$/.exec(filename)[0] : noExtLabel;
				const min = /\.min\./.test(filename);
				// Add filter for renamed files: {old path} → {new path}
				if (txt.indexOf(" → ") > -1) {
					ext = renameFileLabel;
				} else if (ext === filename.slice(1)) {
					ext = dotExtLabel;
				}
				const sha = getSHA(file);
				if (ext) {
					if (!exts[ext]) {
						exts[ext] = [];
					}
					exts[ext].push(sha);
					if (min) {
						exts[minFileLabel].push(sha);
					}
				}
				if (path.length > 0) {
					path.forEach(folder => {
						if (!folders[folder]) {
							folders[folder] = [];
						}
						folders[folder].push(sha);
					});
				} else {
					folders[rootLabel].push(sha);
				}
			}
		});
	}

	function makeFilter({subgroup, label}) {
		const files = $("#files");
		let filters = 0;
		const group = subgroup === "folder" ? folders : exts;
		const keys = Object.keys(group);
		let html = `${label}: <div class="BtnGroup gdf-${subgroup}-filter">`;
		const btnClass = "btn btn-sm selected BtnGroup-item tooltipped tooltipped-n";
		// get length, but don't count empty arrays
		keys.forEach(item => {
			filters += group[item].length > 0 ? 1 : 0;
		});
		// Don't bother showing the filter if only one extension is found
		if (files && filters > 1) {
			filters = $(`.gdf-${subgroup}-filter-wrapper`);
			if (!filters) {
				filters = document.createElement("p");
				filters.className = `gdf-${subgroup}-filter-wrapper`;
				files.insertBefore(filters, files.firstChild);
				filters.addEventListener("click", event => {
					if (event.target.nodeName === "A") {
						event.preventDefault();
						event.stopPropagation();
						const el = event.target;
						el.classList.toggle("selected");
						toggleBlocks({
							subgroup: el.dataset.subgroup,
							type: el.textContent.trim(),
							show: el.classList.contains("selected")
						});
					}
				});
			}
			// add a filter "all" button to the beginning
			html += `
				<a class="${btnClass} gdf-${subgroup}-all" data-subgroup="${subgroup}" data-item="${allLabel}" aria-label="Toggle all files" href="#">
					${allLabel}
				</a>`;
			keys.forEach(item => {
				if (group[item].length) {
					html += `
						<a class="${btnClass}" aria-label="${group[item].length}" data-subgroup="${subgroup}" data-item="${item}" href="#">
							${item}
						</a>`;
				}
			});
			// prepend filter buttons
			filters.innerHTML = html + "</div>";
		}
	}

	function init() {
		if ($("#files.diff-view") || $(".pr-toolbar")) {
			buildList();
			makeFilter({subgroup: "folder", label: "Filter file folder"});
			makeFilter({subgroup: "extension", label: "Filter file extension"});
		}
	}

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

	function $$(str, el) {
		return [...(el || document).querySelectorAll(str)];
	}

	document.addEventListener("ghmo:container", init);
	document.addEventListener("ghmo:diff", init);
	init();

})();