Greasy Fork 支持简体中文。

GitHub Files Filter

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

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