GitHub Files Filter

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

目前為 2018-05-22 提交的版本,檢視 最新版本

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