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