// ==UserScript== // @name GitHub Files Filter // @version 2.1.4 // @description A userscript that adds filters that toggle the view of repo files by extension // @license MIT // @author Rob Garrison // @namespace https://github.com/Mottie // @match https://github.com/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @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-files-filter.user.js // @downloadURL https://raw.githubusercontent.com/Mottie/GitHub-userscripts/master/github-files-filter.user.js // @supportURL https://github.com/Mottie/GitHub-userscripts/issues // ==/UserScript== (() => { "use strict"; // Emphasize selected buttons, disable hover when all selected and remove // animation delay; See #46 GM_addStyle(` .gff-filter .btn.selected { font-variant: small-caps; } .gff-filter .btn:not(.selected) { text-decoration: line-through; } .gff-filter .gff-toggle:not(.selected):focus, .gff-filter .btn:focus, .gff-filter .btn.selected:focus, .gff-filter .gff-toggle:not(.selected):hover, .gff-filter .btn:hover, .gff-filter .btn.selected:hover { border-color: #777 !important; } .gff-filter .gff-toggle { margin-right: 4px; } .gff-filter .gff-toggle svg { pointer-events: none; } .gff-filter .btn:before, .gff-filter .btn:after { animation-delay: unset !important; filter: invert(10%); } .Box-row.hidden { display: none !important; } `); // list[":dot"] = [".gitignore", ".gitattributes", ...] let list = {}; // Special filter buttons const types = { // Including ":" in these special keys since it isn't allowed in a file name ":toggle": { // Return false to prevent adding files under this type is: () => false, className: "gff-toggle", title: "Invert filter state", text: `` }, ":noExt": { is: name => !/\./.test(name), text: "\u00ABno-ext\u00BB" }, ":dot": { // This will include ".travis.yml"... should we add to "yml" instead? is: name => /^\./.test(name), text: "\u00ABdot-files\u00BB" }, ":min": { is: name => /\.min\./.test(name), text: "\u00ABmin\u00BB" } }; // TODO: add toggle for submodule and dot-folders const folderIconClasses = [ ".octicon-file-directory", ".octicon-file-symlink-directory", ".octicon-file-submodule" ].join(","); // Default to all file types visible; remember settings between sessions list[":toggle"] = false; // List gets cleared in buildList function // settings[":dot"] = true; // dot files are visible let settings = GM_getValue("gff-filter-settings", list); // Update filter button state using settings function updateAllFilters({ invert = false }) { $$(".gff-filter .btn").forEach(el => { const ext = el.dataset.ext; if (ext !== ":toggle") { const modeBool = invert ? !settings[ext] : settings[ext]; settings[ext] = modeBool; el.classList.toggle("selected", modeBool); } }); } function updateSettings(ext, mode) { if (ext) { settings[ext] = mode === "show"; } GM_setValue("gff-filter-settings", settings); } function toggleRows(ext, mode) { const files = $(".gff-wrapper"); /* The list[ext] contains an array of file names */ list[ext].forEach(fileName => { const el = $(`a[title="${fileName}"]`, files); if (el) { toggleRow(el, mode); } }); } function toggleRow(el, mode) { const row = el.closest("div.Box-row"); if ( row && // Don't toggle folders or link to parent folder row !($(folderIconClasses, row) || $("a[title*='parent dir']", row)) ) { if (mode) { row.classList.toggle("hidden", mode !== "show"); } else { // Toggle row.classList.toggle("hidden"); } } } function toggleAll() { const files = $(".gff-wrapper"); // Toggle all blocks $$(".Box-row", files).forEach(el => { toggleRow(el); }); updateAllFilters({ invert: true }); updateSettings(); } function toggleFilter(ext, mode) { updateSettings(ext, mode); toggleRows(ext, mode); const elm = $(`.gff-filter .btn[data-ext="${ext}"]`); if (elm) { elm.classList.toggle("selected", mode === "show"); } } // Disable all except current filter (initial ctrl + click) function toggleSet(ext) { Object.keys(list).forEach(block => { const modeBool = block === ext; settings[block] = modeBool; toggleRows(block, modeBool ? "show" : "hide"); }); updateAllFilters({ invert: false }); updateSettings(); } function toggleBlocks(ext, mode, modKey) { if (ext === ":toggle") { toggleAll(); } else if (list[ext]) { if (modKey) { toggleSet(ext, mode); } else { toggleFilter(ext, mode); } } } function addExt(ext, txt) { if (ext) { if (!list[ext]) { list[ext] = []; } list[ext].push(txt); } } function buildList() { list = {}; Object.keys(types).forEach(item => { if (item !== ":toggle") { list[item] = []; } }); const wrapper = $(".gff-wrapper"); if (wrapper) { // Get all files $$(".Box-row", wrapper).forEach(file => { const fileWrap = $("div[role='rowheader']", file); if (fileWrap) { let ext, parts, sub; const link = $("a, span[title]", fileWrap); const txt = link && (link.title || link.textContent || "").trim(); const name = txt.split("/").slice(-1)[0]; // Test extension types; fallback to regex extraction ext = Object.keys(types).find(item => { return types[item].is(name); }) || /[^./\\]*$/.exec(name)[0]; parts = name.split("."); // Include sub-extension filters like "user.js" or "min.js" if (!ext.startsWith(":") && parts.length > 2 && parts[0] !== "") { sub = parts.slice(0, -1).join("."); // Prevent version numbers & "vs. " from adding a filter button // See https://github.com/tpn/pdfs if (!/[()]/.test(sub) && !/[\b\w]\.[\b\d]/.test(sub)) { addExt(ext, txt); ext = parts.slice(-2).join("."); } } addExt(ext, txt); } }); } } function sortList() { return Object.keys(list).sort((a, b) => { // Move ":" filters to the beginning, then sort the rest of the // extensions; test on https://github.com/rbsec/sslscan, where // the ".1" extension *was* appearing between ":" filters if (a[0] === ":") { return -1; } if (b[0] === ":") { return 1; } return a > b; }); } function makeFilter() { let filters = 0; // Get length, but don't count empty arrays Object.keys(list).forEach(ext => { filters += list[ext].length > 0 ? 1 : 0; }); // Don't bother showing filter if only one extension type is found const wrapper = $(".gff-wrapper"); if (wrapper && filters > 1) { filters = $(".gff-filter-wrapper"); if (!filters) { filters = document.createElement("div"); // Use "commitinfo" for GitHub-Dark styling filters.className = "gff-filter-wrapper commitinfo"; filters.style = "padding:3px 5px 2px;border-bottom:1px solid #eaecef"; wrapper.prepend(filters); } buildHTML(); applyInitSettings(); } } function buildButton(ext, title) { const data = types[ext] || {}; const className = "btn btn-sm tooltipped tooltipped-n gff-btn " + (data.className ? data.className : "BtnGroup-item selected"); return ( `` ); } function buildHTML() { let html = `
` + // Add a filter "toggle" button to the beginning buildButton(":toggle") + // Separate toggle from other filters "
"; // Prepend filter buttons sortList().forEach(ext => { const len = list[ext].length; if (len) { html += buildButton(ext, len); } }); $(".gff-filter-wrapper").innerHTML = html + "
"; } function applyInitSettings() { Object.keys(list).forEach(ext => { if (ext !== ":toggle" && settings[ext] === false) { toggleBlocks(ext, "hide"); } }); } function init() { const files = $("#files"); // h2#files is a sibling of the div wrapping role="grid" const grid = $("div[role='grid']", files && files.parentElement); if (files && grid) { grid.parentElement.classList.add("gff-wrapper"); buildList(); makeFilter(); } } function $(str, el) { return (el || document).querySelector(str); } function $$(str, el) { return [...(el || document).querySelectorAll(str)]; } document.addEventListener("click", event => { const el = event.target; if (el && el.classList.contains("gff-btn")) { event.preventDefault(); event.stopPropagation(); toggleBlocks( el.getAttribute("data-ext"), el.classList.contains("selected") ? "hide" : "show", event.ctrlKey ); } }); document.addEventListener("ghmo:container", () => { // Init after a short delay to allow rendering of file list setTimeout(() => { init(); }, 300); }); init(); })();