GitHub Files Filter

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

目前為 2019-09-23 提交的版本,檢視 最新版本

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