- // ==UserScript==
- // @name GitHub Files Filter
- // @version 0.1.2
- // @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
- // @include https://github.com/*
- // @run-at document-idle
- // @grant GM_getValue
- // @grant GM_setValue
- // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=198500
- // @icon https://github.com/fluidicon.png
- // ==/UserScript==
- (() => {
- "use strict";
-
- let settings,
- list = {};
- const types = {
- // including ":" in key since it isn't allowed in a file name
- ":all": {
- // return false to prevent adding files under this type
- is: () => false,
- text: "\u00ABall\u00BB"
- },
- ":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
- folderIconClasses = [
- ".octicon-file-directory",
- ".octicon-file-symlink-directory",
- ".octicon-file-submodule"
- ].join(",");
-
- // default to all file types visible; remember settings between sessions
- list[":all"] = true; // list gets cleared in buildList function
- settings = GM_getValue("gff-filter-settings", list);
-
- function updateFilter(event) {
- event.preventDefault();
- event.stopPropagation();
- const el = event.target;
- toggleBlocks(
- el.getAttribute("data-ext"),
- el.classList.contains("selected") ? "hide" : "show"
- );
- }
-
- function updateSettings(name, mode) {
- settings[name] = mode === "show";
- GM_setValue("gff-filter-settings", settings);
- }
-
- function updateAllButton() {
- if ($(".gff-filter")) {
- const buttons = $(".file-wrap .gff-filter"),
- filters = $$(".btn:not(.gff-all)", buttons),
- selected = $$(".btn:not(.gff-all).selected", buttons);
- // set "all" button
- $(".gff-all", buttons).classList.toggle(
- "selected",
- filters.length === selected.length
- );
- }
- }
-
- function toggleImagePreview(ext, mode) {
- if ($(".ghip-image-previews")) {
- let selector = "a",
- hasType = types[ext];
- if (!hasType) {
- selector += `[href$="${ext}"]`;
- }
- $$(`.ghip-image-previews ${selector}`).forEach(el => {
- if (!$(".ghip-folder", el)) {
- if (hasType && ext !== ":all") {
- // image preview includes the filename
- let elm = $(".ghip-file-name", el);
- if (elm && !hasType.is(elm.textContent)) {
- return;
- }
- }
- el.style.display = mode === "show" ? "" : "none";
- }
- });
- }
- }
-
- function toggleRow(el, mode) {
- const row = closest("tr.js-navigation-item", el);
- // don't toggle folders
- if (row && !$(folderIconClasses, row)) {
- row.style.display = mode === "show" ? "" : "none";
- }
- }
-
- function toggleAll(mode) {
- const files = $(".file-wrap");
- // Toggle "all" blocks
- $$("td.content .js-navigation-open", files).forEach(el => {
- toggleRow(el, mode);
- });
- // update filter buttons
- $$(".gff-filter .btn", files).forEach(el => {
- el.classList.toggle("selected", mode === "show");
- });
- updateSettings(":all", mode);
- }
-
- function toggleFilter(filter, mode) {
- const files = $(".file-wrap"),
- elm = $(`.gff-filter .btn[data-ext="${filter}"]`, files);
- /* list[filter] contains an array of file names */
- list[filter].forEach(name => {
- const el = $(`a[title="${name}"]`, files);
- if (el) {
- toggleRow(el, mode);
- }
- });
- if (elm) {
- elm.classList.toggle("selected", mode === "show");
- }
- updateSettings(filter, mode);
- }
-
- function toggleBlocks(filter, mode) {
- if (filter === ":all") {
- toggleAll(mode);
- } else if (list[filter]) {
- toggleFilter(filter, mode);
- }
- // update view for github-image-preview.user.js
- toggleImagePreview(filter, mode);
- updateAllButton();
- }
-
- function buildList() {
- list = {};
- Object.keys(types).forEach(item => {
- if (item !== ":all") {
- list[item] = [];
- }
- });
- // get all files
- $$("table.files tr.js-navigation-item").forEach(file => {
- if ($("td.icon .octicon-file-text", file)) {
- let ext,
- link = $("td.content .js-navigation-open", file),
- txt = (link.title || link.textContent || "").trim(),
- 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];
- if (ext) {
- if (!list[ext]) {
- list[ext] = [];
- }
- list[ext].push(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 if only one extension is found
- const files = $(".file-wrap");
- if (files && filters > 1) {
- filters = $(".gff-filter-wrapper");
- if (!filters) {
- filters = document.createElement("div");
- // "commitinfo" allows GitHub-Dark styling
- filters.className = "gff-filter-wrapper commitinfo";
- filters.style = "padding:3px 5px 2px;border-bottom:1px solid #eaecef";
- files.insertBefore(filters, files.firstChild);
- filters.addEventListener("click", updateFilter);
- }
- fixWidth();
- buildHTML();
- applyInitSettings();
- }
- }
-
- function buildButton(name, label, ext, text) {
- return `<button type="button" ` +
- `class="btn btn-sm selected BtnGroup-item tooltipped tooltipped-n` +
- (name ? name : "") + `" ` +
- `data-ext="${ext}" aria-label="${label}">${text}</button>`;
- }
-
- function buildHTML() {
- let len,
- html = `<div class="BtnGroup gff-filter">` +
- // add a filter "all" button to the beginning
- buildButton(" gff-all", "Toggle all files", ":all", types[":all"].text);
- sortList().forEach(ext => {
- len = list[ext].length;
- if (len) {
- html += buildButton("", len, ext, types[ext] && types[ext].text || ext);
- }
- });
- // prepend filter buttons
- $(".gff-filter-wrapper").innerHTML = html + "</div>";
- }
-
- function getWidth(el) {
- return parseFloat(window.getComputedStyle(el).width);
- }
-
- // lock-in the table cell widths, or the navigation up link jumps when you
- // hide all files... using percentages in case someone is using GitHub wide
- function fixWidth() {
- let group, width,
- html = "",
- table = $("table.files"),
- tableWidth = getWidth(table),
- cells = $$("tbody:last-child tr:last-child td", table);
- if (table && cells.length > 1 && !$("colgroup", table)) {
- group = document.createElement("colgroup");
- table.insertBefore(group, table.childNodes[0]);
- cells.forEach(el => {
- // keep two decimal point accuracy
- width = parseInt(getWidth(el) / tableWidth * 1e4, 10) / 100;
- html += `<col style="width:${width}%">`;
- });
- group.innerHTML = html;
- }
- }
-
- function applyInitSettings() {
- // list doesn't include type.all entry
- if (settings[":all"] === false) {
- toggleBlocks(":all", "hide");
- } else {
- Object.keys(list).forEach(name => {
- if (settings[name] === false) {
- toggleBlocks(name, "hide");
- }
- });
- }
- }
-
- function init() {
- if ($("table.files")) {
- buildList();
- makeFilter();
- }
- }
-
- function $(str, el) {
- return (el || document).querySelector(str);
- }
-
- function $$(str, el) {
- return Array.from((el || document).querySelectorAll(str));
- }
-
- function closest(selector, el) {
- while (el && el.nodeType === 1) {
- if (el.matches(selector)) {
- return el;
- }
- el = el.parentNode;
- }
- return null;
- }
-
- document.addEventListener("ghmo:container", () => {
- // init after a short delay to allow rendering of file list
- setTimeout(() => {
- init();
- }, 200);
- });
- init();
-
- })();