GitHub Files Filter

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

目前為 2020-07-13 提交的版本,檢視 最新版本

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