- // ==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
- // @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:
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 16" width="12" height="16" class="octicon" aria-hidden="true">
- <path d="M12 0H0v4h2V2h8v4H8l4 4M0 16h12v-4h-2v2H2v-4h2L0 6"/>
- </svg>`
- },
- ":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 (
- `<button
- type="button"
- class=" ${className}"
- data-ext="${ext}"
- aria-label="${title || data.title}"
- >${data.text || ext}</button>`
- );
- }
-
- function buildHTML() {
- let html = `<div class="gff-filter">` +
- // Add a filter "toggle" button to the beginning
- buildButton(":toggle") +
- // Separate toggle from other filters
- "<div class='BtnGroup'>";
- // Prepend filter buttons
- sortList().forEach(ext => {
- const len = list[ext].length;
- if (len) {
- html += buildButton(ext, len);
- }
- });
- $(".gff-filter-wrapper").innerHTML = html + "</div></div>";
- }
-
- 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();
-
- })();