GitHub Files Filter

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

当前为 2018-07-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Files Filter
  3. // @version 1.1.3
  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, .ghip-up-tree", 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 addExt(ext, txt) {
  170. if (ext) {
  171. if (!list[ext]) {
  172. list[ext] = [];
  173. }
  174. list[ext].push(txt);
  175. }
  176. }
  177.  
  178. function buildList() {
  179. list = {};
  180. Object.keys(types).forEach(item => {
  181. if (item !== ":all") {
  182. list[item] = [];
  183. }
  184. });
  185. // get all files
  186. $$("table.files tr.js-navigation-item").forEach(file => {
  187. if ($("td.icon .octicon-file", file)) {
  188. let ext, parts, sub,
  189. link = $("td.content .js-navigation-open", file),
  190. txt = (link.title || link.textContent || "").trim().toLowerCase(),
  191. name = txt.split("/").slice(-1)[0];
  192. // test extension types; fallback to regex extraction
  193. ext = Object.keys(types).find(item => {
  194. return types[item].is(name);
  195. }) || /[^./\\]*$/.exec(name)[0];
  196. parts = name.split(".");
  197. if (!ext.startsWith(":") && parts.length > 2 && parts[0] !== "") {
  198. sub = parts.slice(0, -1).join(".");
  199. // Prevent version numbers & "vs. " from adding a filter button
  200. // See https://github.com/tpn/pdfs
  201. if (!/[()]/.test(sub) && !/[\b\w]\.[\b\d]/.test(sub)) {
  202. addExt(ext, txt);
  203. ext = parts.slice(-2).join(".");
  204. }
  205. }
  206. addExt(ext, txt);
  207. }
  208. });
  209. }
  210.  
  211. function sortList() {
  212. return Object.keys(list).sort((a, b) => {
  213. // move ":" filters to the beginning, then sort the rest of the
  214. // extensions; test on https://github.com/rbsec/sslscan, where
  215. // the ".1" extension *was* appearing between ":" filters
  216. if (a[0] === ":") {
  217. return -1;
  218. }
  219. if (b[0] === ":") {
  220. return 1;
  221. }
  222. return a > b;
  223. });
  224. }
  225.  
  226. function makeFilter() {
  227. let filters = 0;
  228. // get length, but don't count empty arrays
  229. Object.keys(list).forEach(ext => {
  230. filters += list[ext].length > 0 ? 1 : 0;
  231. });
  232. // Don't bother if only one extension is found
  233. const files = $(".file-wrap");
  234. if (files && filters > 1) {
  235. filters = $(".gff-filter-wrapper");
  236. if (!filters) {
  237. filters = document.createElement("div");
  238. // "commitinfo" allows GitHub-Dark styling
  239. filters.className = "gff-filter-wrapper commitinfo";
  240. filters.style = "padding:3px 5px 2px;border-bottom:1px solid #eaecef";
  241. files.insertBefore(filters, files.firstChild);
  242. filters.addEventListener("click", updateFilter);
  243. }
  244. fixWidth();
  245. buildHTML();
  246. applyInitSettings();
  247. }
  248. }
  249.  
  250. function buildButton(name, label, ext, text) {
  251. return `<button type="button" ` +
  252. `class="btn btn-sm selected BtnGroup-item tooltipped tooltipped-n` +
  253. (name ? name : "") + `" ` +
  254. `data-ext="${ext}" aria-label="${label}">${text}</button>`;
  255. }
  256.  
  257. function buildHTML() {
  258. let len,
  259. html = `<div class="BtnGroup gff-filter">` +
  260. // add a filter "all" button to the beginning
  261. buildButton(" gff-all", "Toggle all files", ":all", types[":all"].text);
  262. sortList().forEach(ext => {
  263. len = list[ext].length;
  264. if (len) {
  265. html += buildButton("", len, ext, types[ext] && types[ext].text || ext);
  266. }
  267. });
  268. // prepend filter buttons
  269. $(".gff-filter-wrapper").innerHTML = html + "</div>";
  270. }
  271.  
  272. function getWidth(el) {
  273. return parseFloat(window.getComputedStyle(el).width);
  274. }
  275.  
  276. // lock-in the table cell widths, or the navigation up link jumps when you
  277. // hide all files... using percentages in case someone is using GitHub wide
  278. function fixWidth() {
  279. let group, width,
  280. html = "",
  281. table = $("table.files"),
  282. tableWidth = getWidth(table),
  283. cells = $$("tbody:last-child tr:last-child td", table);
  284. if (table && cells.length > 1 && !$("colgroup", table)) {
  285. group = document.createElement("colgroup");
  286. table.insertBefore(group, table.childNodes[0]);
  287. cells.forEach(el => {
  288. // keep two decimal point accuracy
  289. width = parseInt(getWidth(el) / tableWidth * 1e4, 10) / 100;
  290. html += `<col style="width:${width}%">`;
  291. });
  292. group.innerHTML = html;
  293. }
  294. }
  295.  
  296. function applyInitSettings() {
  297. // list doesn't include type.all entry
  298. if (settings[":all"] === false) {
  299. toggleBlocks(":all", "hide");
  300. } else {
  301. Object.keys(list).forEach(name => {
  302. if (settings[name] === false) {
  303. toggleBlocks(name, "hide");
  304. }
  305. });
  306. }
  307. }
  308.  
  309. function init() {
  310. if ($("table.files")) {
  311. buildList();
  312. makeFilter();
  313. }
  314. }
  315.  
  316. function $(str, el) {
  317. return (el || document).querySelector(str);
  318. }
  319.  
  320. function $$(str, el) {
  321. return Array.from((el || document).querySelectorAll(str));
  322. }
  323.  
  324. document.addEventListener("ghmo:container", () => {
  325. // init after a short delay to allow rendering of file list
  326. setTimeout(() => {
  327. init();
  328. }, 200);
  329. });
  330. init();
  331.  
  332. })();