GitHub Files Filter

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

目前为 2017-10-08 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Files Filter
  3. // @version 0.1.2
  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. // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=198500
  13. // @icon https://github.com/fluidicon.png
  14. // ==/UserScript==
  15. (() => {
  16. "use strict";
  17.  
  18. let settings,
  19. list = {};
  20. const types = {
  21. // including ":" in key since it isn't allowed in a file name
  22. ":all": {
  23. // return false to prevent adding files under this type
  24. is: () => false,
  25. text: "\u00ABall\u00BB"
  26. },
  27. ":noExt": {
  28. is: name => !/\./.test(name),
  29. text: "\u00ABno-ext\u00BB"
  30. },
  31. ":dot": {
  32. // this will include ".travis.yml"... should we add to "yml" instead?
  33. is: name => /^\./.test(name),
  34. text: "\u00ABdot-files\u00BB"
  35. },
  36. ":min": {
  37. is: name => /\.min\./.test(name),
  38. text: "\u00ABmin\u00BB"
  39. }
  40. },
  41. // TODO: add toggle for submodule and dot-folders
  42. folderIconClasses = [
  43. ".octicon-file-directory",
  44. ".octicon-file-symlink-directory",
  45. ".octicon-file-submodule"
  46. ].join(",");
  47.  
  48. // default to all file types visible; remember settings between sessions
  49. list[":all"] = true; // list gets cleared in buildList function
  50. settings = GM_getValue("gff-filter-settings", list);
  51.  
  52. function updateFilter(event) {
  53. event.preventDefault();
  54. event.stopPropagation();
  55. const el = event.target;
  56. toggleBlocks(
  57. el.getAttribute("data-ext"),
  58. el.classList.contains("selected") ? "hide" : "show"
  59. );
  60. }
  61.  
  62. function updateSettings(name, mode) {
  63. settings[name] = mode === "show";
  64. GM_setValue("gff-filter-settings", settings);
  65. }
  66.  
  67. function updateAllButton() {
  68. if ($(".gff-filter")) {
  69. const buttons = $(".file-wrap .gff-filter"),
  70. filters = $$(".btn:not(.gff-all)", buttons),
  71. selected = $$(".btn:not(.gff-all).selected", buttons);
  72. // set "all" button
  73. $(".gff-all", buttons).classList.toggle(
  74. "selected",
  75. filters.length === selected.length
  76. );
  77. }
  78. }
  79.  
  80. function toggleImagePreview(ext, mode) {
  81. if ($(".ghip-image-previews")) {
  82. let selector = "a",
  83. hasType = types[ext];
  84. if (!hasType) {
  85. selector += `[href$="${ext}"]`;
  86. }
  87. $$(`.ghip-image-previews ${selector}`).forEach(el => {
  88. if (!$(".ghip-folder", el)) {
  89. if (hasType && ext !== ":all") {
  90. // image preview includes the filename
  91. let elm = $(".ghip-file-name", el);
  92. if (elm && !hasType.is(elm.textContent)) {
  93. return;
  94. }
  95. }
  96. el.style.display = mode === "show" ? "" : "none";
  97. }
  98. });
  99. }
  100. }
  101.  
  102. function toggleRow(el, mode) {
  103. const row = closest("tr.js-navigation-item", el);
  104. // don't toggle folders
  105. if (row && !$(folderIconClasses, row)) {
  106. row.style.display = mode === "show" ? "" : "none";
  107. }
  108. }
  109.  
  110. function toggleAll(mode) {
  111. const files = $(".file-wrap");
  112. // Toggle "all" blocks
  113. $$("td.content .js-navigation-open", files).forEach(el => {
  114. toggleRow(el, mode);
  115. });
  116. // update filter buttons
  117. $$(".gff-filter .btn", files).forEach(el => {
  118. el.classList.toggle("selected", mode === "show");
  119. });
  120. updateSettings(":all", mode);
  121. }
  122.  
  123. function toggleFilter(filter, mode) {
  124. const files = $(".file-wrap"),
  125. elm = $(`.gff-filter .btn[data-ext="${filter}"]`, files);
  126. /* list[filter] contains an array of file names */
  127. list[filter].forEach(name => {
  128. const el = $(`a[title="${name}"]`, files);
  129. if (el) {
  130. toggleRow(el, mode);
  131. }
  132. });
  133. if (elm) {
  134. elm.classList.toggle("selected", mode === "show");
  135. }
  136. updateSettings(filter, mode);
  137. }
  138.  
  139. function toggleBlocks(filter, mode) {
  140. if (filter === ":all") {
  141. toggleAll(mode);
  142. } else if (list[filter]) {
  143. toggleFilter(filter, mode);
  144. }
  145. // update view for github-image-preview.user.js
  146. toggleImagePreview(filter, mode);
  147. updateAllButton();
  148. }
  149.  
  150. function buildList() {
  151. list = {};
  152. Object.keys(types).forEach(item => {
  153. if (item !== ":all") {
  154. list[item] = [];
  155. }
  156. });
  157. // get all files
  158. $$("table.files tr.js-navigation-item").forEach(file => {
  159. if ($("td.icon .octicon-file-text", file)) {
  160. let ext,
  161. link = $("td.content .js-navigation-open", file),
  162. txt = (link.title || link.textContent || "").trim(),
  163. name = txt.split("/").slice(-1)[0];
  164. // test extension types; fallback to regex extraction
  165. ext = Object.keys(types).find(item => {
  166. return types[item].is(name);
  167. }) || /[^./\\]*$/.exec(name)[0];
  168. if (ext) {
  169. if (!list[ext]) {
  170. list[ext] = [];
  171. }
  172. list[ext].push(txt);
  173. }
  174. }
  175. });
  176. }
  177.  
  178. function sortList() {
  179. return Object.keys(list).sort((a, b) => {
  180. // move ":" filters to the beginning, then sort the rest of the
  181. // extensions; test on https://github.com/rbsec/sslscan, where
  182. // the ".1" extension *was* appearing between ":" filters
  183. if (a[0] === ":") {
  184. return -1;
  185. }
  186. if (b[0] === ":") {
  187. return 1;
  188. }
  189. return a > b;
  190. });
  191. }
  192.  
  193. function makeFilter() {
  194. let filters = 0;
  195. // get length, but don't count empty arrays
  196. Object.keys(list).forEach(ext => {
  197. filters += list[ext].length > 0 ? 1 : 0;
  198. });
  199. // Don't bother if only one extension is found
  200. const files = $(".file-wrap");
  201. if (files && filters > 1) {
  202. filters = $(".gff-filter-wrapper");
  203. if (!filters) {
  204. filters = document.createElement("div");
  205. // "commitinfo" allows GitHub-Dark styling
  206. filters.className = "gff-filter-wrapper commitinfo";
  207. filters.style = "padding:3px 5px 2px;border-bottom:1px solid #eaecef";
  208. files.insertBefore(filters, files.firstChild);
  209. filters.addEventListener("click", updateFilter);
  210. }
  211. fixWidth();
  212. buildHTML();
  213. applyInitSettings();
  214. }
  215. }
  216.  
  217. function buildButton(name, label, ext, text) {
  218. return `<button type="button" ` +
  219. `class="btn btn-sm selected BtnGroup-item tooltipped tooltipped-n` +
  220. (name ? name : "") + `" ` +
  221. `data-ext="${ext}" aria-label="${label}">${text}</button>`;
  222. }
  223.  
  224. function buildHTML() {
  225. let len,
  226. html = `<div class="BtnGroup gff-filter">` +
  227. // add a filter "all" button to the beginning
  228. buildButton(" gff-all", "Toggle all files", ":all", types[":all"].text);
  229. sortList().forEach(ext => {
  230. len = list[ext].length;
  231. if (len) {
  232. html += buildButton("", len, ext, types[ext] && types[ext].text || ext);
  233. }
  234. });
  235. // prepend filter buttons
  236. $(".gff-filter-wrapper").innerHTML = html + "</div>";
  237. }
  238.  
  239. function getWidth(el) {
  240. return parseFloat(window.getComputedStyle(el).width);
  241. }
  242.  
  243. // lock-in the table cell widths, or the navigation up link jumps when you
  244. // hide all files... using percentages in case someone is using GitHub wide
  245. function fixWidth() {
  246. let group, width,
  247. html = "",
  248. table = $("table.files"),
  249. tableWidth = getWidth(table),
  250. cells = $$("tbody:last-child tr:last-child td", table);
  251. if (table && cells.length > 1 && !$("colgroup", table)) {
  252. group = document.createElement("colgroup");
  253. table.insertBefore(group, table.childNodes[0]);
  254. cells.forEach(el => {
  255. // keep two decimal point accuracy
  256. width = parseInt(getWidth(el) / tableWidth * 1e4, 10) / 100;
  257. html += `<col style="width:${width}%">`;
  258. });
  259. group.innerHTML = html;
  260. }
  261. }
  262.  
  263. function applyInitSettings() {
  264. // list doesn't include type.all entry
  265. if (settings[":all"] === false) {
  266. toggleBlocks(":all", "hide");
  267. } else {
  268. Object.keys(list).forEach(name => {
  269. if (settings[name] === false) {
  270. toggleBlocks(name, "hide");
  271. }
  272. });
  273. }
  274. }
  275.  
  276. function init() {
  277. if ($("table.files")) {
  278. buildList();
  279. makeFilter();
  280. }
  281. }
  282.  
  283. function $(str, el) {
  284. return (el || document).querySelector(str);
  285. }
  286.  
  287. function $$(str, el) {
  288. return Array.from((el || document).querySelectorAll(str));
  289. }
  290.  
  291. function closest(selector, el) {
  292. while (el && el.nodeType === 1) {
  293. if (el.matches(selector)) {
  294. return el;
  295. }
  296. el = el.parentNode;
  297. }
  298. return null;
  299. }
  300.  
  301. document.addEventListener("ghmo:container", () => {
  302. // init after a short delay to allow rendering of file list
  303. setTimeout(() => {
  304. init();
  305. }, 200);
  306. });
  307. init();
  308.  
  309. })();