GitHub Files Filter

A userscript that adds filters that toggle the view of repo files by extension

目前为 2021-07-21 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Files Filter
  3. // @version 2.1.3
  4. // @description A userscript that adds filters that toggle the view of repo files by extension
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @include https://github.com/*
  9. // @run-at document-idle
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @grant GM_addStyle
  13. // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=952601
  14. // @icon https://github.githubassets.com/pinned-octocat.svg
  15. // @supportURL https://github.com/Mottie/GitHub-userscripts/issues
  16. // ==/UserScript==
  17. (() => {
  18. "use strict";
  19.  
  20. // Emphasize selected buttons, disable hover when all selected and remove
  21. // animation delay; See #46
  22. GM_addStyle(`
  23. .gff-filter .btn.selected { font-variant: small-caps; }
  24. .gff-filter .btn:not(.selected) {
  25. text-decoration: line-through;
  26. }
  27. .gff-filter .gff-toggle:not(.selected):focus,
  28. .gff-filter .btn:focus,
  29. .gff-filter .btn.selected:focus,
  30. .gff-filter .gff-toggle:not(.selected):hover,
  31. .gff-filter .btn:hover,
  32. .gff-filter .btn.selected:hover {
  33. border-color: #777 !important;
  34. }
  35. .gff-filter .gff-toggle {
  36. margin-right: 4px;
  37. }
  38. .gff-filter .gff-toggle svg {
  39. pointer-events: none;
  40. }
  41. .gff-filter .btn:before,
  42. .gff-filter .btn:after {
  43. animation-delay: unset !important;
  44. filter: invert(10%);
  45. }
  46. .Box-row.hidden {
  47. display: none !important;
  48. }
  49. `);
  50.  
  51. // list[":dot"] = [".gitignore", ".gitattributes", ...]
  52. let list = {};
  53.  
  54. // Special filter buttons
  55. const types = {
  56. // Including ":" in these special keys since it isn't allowed in a file name
  57. ":toggle": {
  58. // Return false to prevent adding files under this type
  59. is: () => false,
  60. className: "gff-toggle",
  61. title: "Invert filter state",
  62. text:
  63. `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 16" width="12" height="16" class="octicon" aria-hidden="true">
  64. <path d="M12 0H0v4h2V2h8v4H8l4 4M0 16h12v-4h-2v2H2v-4h2L0 6"/>
  65. </svg>`
  66. },
  67. ":noExt": {
  68. is: name => !/\./.test(name),
  69. text: "\u00ABno-ext\u00BB"
  70. },
  71. ":dot": {
  72. // This will include ".travis.yml"... should we add to "yml" instead?
  73. is: name => /^\./.test(name),
  74. text: "\u00ABdot-files\u00BB"
  75. },
  76. ":min": {
  77. is: name => /\.min\./.test(name),
  78. text: "\u00ABmin\u00BB"
  79. }
  80. };
  81.  
  82. // TODO: add toggle for submodule and dot-folders
  83. const folderIconClasses = [
  84. ".octicon-file-directory",
  85. ".octicon-file-symlink-directory",
  86. ".octicon-file-submodule"
  87. ].join(",");
  88.  
  89. // Default to all file types visible; remember settings between sessions
  90. list[":toggle"] = false; // List gets cleared in buildList function
  91.  
  92. // settings[":dot"] = true; // dot files are visible
  93. let settings = GM_getValue("gff-filter-settings", list);
  94.  
  95. // Update filter button state using settings
  96. function updateAllFilters({ invert = false }) {
  97. $$(".gff-filter .btn").forEach(el => {
  98. const ext = el.dataset.ext;
  99. if (ext !== ":toggle") {
  100. const modeBool = invert ? !settings[ext] : settings[ext];
  101. settings[ext] = modeBool;
  102. el.classList.toggle("selected", modeBool);
  103. }
  104. });
  105. }
  106.  
  107. function updateSettings(ext, mode) {
  108. if (ext) {
  109. settings[ext] = mode === "show";
  110. }
  111. GM_setValue("gff-filter-settings", settings);
  112. }
  113.  
  114. function toggleRows(ext, mode) {
  115. const files = $(".gff-wrapper");
  116. /* The list[ext] contains an array of file names */
  117. list[ext].forEach(fileName => {
  118. const el = $(`a[title="${fileName}"]`, files);
  119. if (el) {
  120. toggleRow(el, mode);
  121. }
  122. });
  123. }
  124.  
  125. function toggleRow(el, mode) {
  126. const row = el.closest("div.Box-row");
  127. if (
  128. row &&
  129. // Don't toggle folders or link to parent folder row
  130. !($(folderIconClasses, row) || $("a[title*='parent dir']", row))
  131. ) {
  132. if (mode) {
  133. row.classList.toggle("hidden", mode !== "show");
  134. } else {
  135. // Toggle
  136. row.classList.toggle("hidden");
  137. }
  138. }
  139. }
  140.  
  141. function toggleAll() {
  142. const files = $(".gff-wrapper");
  143. // Toggle all blocks
  144. $$(".Box-row", files).forEach(el => {
  145. toggleRow(el);
  146. });
  147. updateAllFilters({ invert: true });
  148. updateSettings();
  149. }
  150.  
  151. function toggleFilter(ext, mode) {
  152. updateSettings(ext, mode);
  153. toggleRows(ext, mode);
  154. const elm = $(`.gff-filter .btn[data-ext="${ext}"]`);
  155. if (elm) {
  156. elm.classList.toggle("selected", mode === "show");
  157. }
  158. }
  159.  
  160. // Disable all except current filter (initial ctrl + click)
  161. function toggleSet(ext) {
  162. Object.keys(list).forEach(block => {
  163. const modeBool = block === ext;
  164. settings[block] = modeBool;
  165. toggleRows(block, modeBool ? "show" : "hide");
  166. });
  167. updateAllFilters({ invert: false });
  168. updateSettings();
  169. }
  170.  
  171. function toggleBlocks(ext, mode, modKey) {
  172. if (ext === ":toggle") {
  173. toggleAll();
  174. } else if (list[ext]) {
  175. if (modKey) {
  176. toggleSet(ext, mode);
  177. } else {
  178. toggleFilter(ext, mode);
  179. }
  180. }
  181. }
  182.  
  183. function addExt(ext, txt) {
  184. if (ext) {
  185. if (!list[ext]) {
  186. list[ext] = [];
  187. }
  188. list[ext].push(txt);
  189. }
  190. }
  191.  
  192. function buildList() {
  193. list = {};
  194. Object.keys(types).forEach(item => {
  195. if (item !== ":toggle") {
  196. list[item] = [];
  197. }
  198. });
  199. const wrapper = $(".gff-wrapper");
  200. if (wrapper) {
  201. // Get all files
  202. $$(".Box-row", wrapper).forEach(file => {
  203. const fileWrap = $("div[role='rowheader']", file);
  204. if (fileWrap) {
  205. let ext, parts, sub;
  206. const link = $("a, span[title]", fileWrap);
  207. const txt = link && (link.title || link.textContent || "").trim();
  208. const name = txt.split("/").slice(-1)[0];
  209. // Test extension types; fallback to regex extraction
  210. ext = Object.keys(types).find(item => {
  211. return types[item].is(name);
  212. }) || /[^./\\]*$/.exec(name)[0];
  213. parts = name.split(".");
  214. // Include sub-extension filters like "user.js" or "min.js"
  215. if (!ext.startsWith(":") && parts.length > 2 && parts[0] !== "") {
  216. sub = parts.slice(0, -1).join(".");
  217. // Prevent version numbers & "vs. " from adding a filter button
  218. // See https://github.com/tpn/pdfs
  219. if (!/[()]/.test(sub) && !/[\b\w]\.[\b\d]/.test(sub)) {
  220. addExt(ext, txt);
  221. ext = parts.slice(-2).join(".");
  222. }
  223. }
  224. addExt(ext, txt);
  225. }
  226. });
  227. }
  228. }
  229.  
  230. function sortList() {
  231. return Object.keys(list).sort((a, b) => {
  232. // Move ":" filters to the beginning, then sort the rest of the
  233. // extensions; test on https://github.com/rbsec/sslscan, where
  234. // the ".1" extension *was* appearing between ":" filters
  235. if (a[0] === ":") {
  236. return -1;
  237. }
  238. if (b[0] === ":") {
  239. return 1;
  240. }
  241. return a > b;
  242. });
  243. }
  244.  
  245. function makeFilter() {
  246. let filters = 0;
  247. // Get length, but don't count empty arrays
  248. Object.keys(list).forEach(ext => {
  249. filters += list[ext].length > 0 ? 1 : 0;
  250. });
  251. // Don't bother showing filter if only one extension type is found
  252. const wrapper = $(".gff-wrapper");
  253. if (wrapper && filters > 1) {
  254. filters = $(".gff-filter-wrapper");
  255. if (!filters) {
  256. filters = document.createElement("div");
  257. // Use "commitinfo" for GitHub-Dark styling
  258. filters.className = "gff-filter-wrapper commitinfo";
  259. filters.style = "padding:3px 5px 2px;border-bottom:1px solid #eaecef";
  260. wrapper.prepend(filters);
  261. }
  262. buildHTML();
  263. applyInitSettings();
  264. }
  265. }
  266.  
  267. function buildButton(ext, title) {
  268. const data = types[ext] || {};
  269. const className = "btn btn-sm tooltipped tooltipped-n gff-btn " +
  270. (data.className ? data.className : "BtnGroup-item selected");
  271. return (
  272. `<button
  273. type="button"
  274. class=" ${className}"
  275. data-ext="${ext}"
  276. aria-label="${title || data.title}"
  277. >${data.text || ext}</button>`
  278. );
  279. }
  280.  
  281. function buildHTML() {
  282. let html = `<div class="gff-filter">` +
  283. // Add a filter "toggle" button to the beginning
  284. buildButton(":toggle") +
  285. // Separate toggle from other filters
  286. "<div class='BtnGroup'>";
  287. // Prepend filter buttons
  288. sortList().forEach(ext => {
  289. const len = list[ext].length;
  290. if (len) {
  291. html += buildButton(ext, len);
  292. }
  293. });
  294. $(".gff-filter-wrapper").innerHTML = html + "</div></div>";
  295. }
  296.  
  297. function applyInitSettings() {
  298. Object.keys(list).forEach(ext => {
  299. if (ext !== ":toggle" && settings[ext] === false) {
  300. toggleBlocks(ext, "hide");
  301. }
  302. });
  303. }
  304.  
  305. function init() {
  306. const files = $("#files");
  307. // h2#files is a sibling of the div wrapping role="grid"
  308. const grid = $("div[role='grid']", files && files.parentElement);
  309. if (files && grid) {
  310. grid.parentElement.classList.add("gff-wrapper");
  311. buildList();
  312. makeFilter();
  313. }
  314. }
  315.  
  316. function $(str, el) {
  317. return (el || document).querySelector(str);
  318. }
  319.  
  320. function $$(str, el) {
  321. return [...(el || document).querySelectorAll(str)];
  322. }
  323.  
  324. document.addEventListener("click", event => {
  325. const el = event.target;
  326. if (el && el.classList.contains("gff-btn")) {
  327. event.preventDefault();
  328. event.stopPropagation();
  329. toggleBlocks(
  330. el.getAttribute("data-ext"),
  331. el.classList.contains("selected") ? "hide" : "show",
  332. event.ctrlKey
  333. );
  334. }
  335. });
  336.  
  337. document.addEventListener("ghmo:container", () => {
  338. // Init after a short delay to allow rendering of file list
  339. setTimeout(() => {
  340. init();
  341. }, 300);
  342. });
  343. init();
  344.  
  345. })();