GitHub Files Filter

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

当前为 2019-02-18 提交的版本,查看 最新版本

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