GitHub Files Filter

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

目前為 2020-03-24 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name GitHub Files Filter
  3. // @version 2.0.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=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 = $(".gff-wrapper");
  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 = $(".gff-wrapper");
  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. const files = $("table.files");
  267. if (files && filters > 1) {
  268. filters = $(".gff-filter-wrapper");
  269. if (!filters) {
  270. filters = document.createElement("div");
  271. // Use "commitinfo" for GitHub-Dark styling
  272. filters.className = "gff-filter-wrapper commitinfo";
  273. filters.style = "padding:3px 5px 2px;border-bottom:1px solid #eaecef";
  274. files.before(filters, files.firstChild);
  275. }
  276. fixWidth();
  277. buildHTML();
  278. applyInitSettings();
  279. }
  280. }
  281.  
  282. function buildButton(ext, title) {
  283. const data = types[ext] || {};
  284. const className = "btn btn-sm tooltipped tooltipped-n gff-btn " +
  285. (data.className ? data.className : "BtnGroup-item selected");
  286. return (
  287. `<button
  288. type="button"
  289. class=" ${className}"
  290. data-ext="${ext}"
  291. aria-label="${title || data.title}"
  292. >${data.text || ext}</button>`
  293. );
  294. }
  295.  
  296. function buildHTML() {
  297. let html = `<div class="gff-filter">` +
  298. // Add a filter "toggle" button to the beginning
  299. buildButton(":toggle") +
  300. // Separate toggle from other filters
  301. "<div class='BtnGroup'>";
  302. // Prepend filter buttons
  303. sortList().forEach(ext => {
  304. const len = list[ext].length;
  305. if (len) {
  306. html += buildButton(ext, len);
  307. }
  308. });
  309. $(".gff-filter-wrapper").innerHTML = html + "</div></div>";
  310. }
  311.  
  312. function getWidth(el) {
  313. return parseFloat(window.getComputedStyle(el).width);
  314. }
  315.  
  316. // Lock-in the table cell widths, or the navigation up link jumps when you
  317. // hide all files... using percentages in case someone is using GitHub wide
  318. function fixWidth() {
  319. let group;
  320. let html = "";
  321. const table = $("table.files");
  322. const tableWidth = getWidth(table);
  323. const cells = $$("tbody:last-child tr:last-child td", table);
  324. if (table && cells.length > 1 && !$("colgroup", table)) {
  325. group = document.createElement("colgroup");
  326. table.insertBefore(group, table.childNodes[0]);
  327. cells.forEach(el => {
  328. // Keep two decimal point accuracy
  329. const width = parseInt(getWidth(el) / tableWidth * 1e4, 10) / 100;
  330. html += `<col style="width:${width}%">`;
  331. });
  332. group.innerHTML = html;
  333. }
  334. }
  335.  
  336. function applyInitSettings() {
  337. Object.keys(list).forEach(ext => {
  338. if (ext !== ":toggle" && settings[ext] === false) {
  339. toggleBlocks(ext, "hide");
  340. }
  341. });
  342. }
  343.  
  344. function init() {
  345. const table = $("table.files");
  346. if (table) {
  347. table.parentElement.classList.add("gff-wrapper");
  348. buildList();
  349. makeFilter();
  350. }
  351. }
  352.  
  353. function $(str, el) {
  354. return (el || document).querySelector(str);
  355. }
  356.  
  357. function $$(str, el) {
  358. return [...(el || document).querySelectorAll(str)];
  359. }
  360.  
  361. document.addEventListener("click", event => {
  362. const el = event.target;
  363. if (el && el.classList.contains("gff-btn")) {
  364. event.preventDefault();
  365. event.stopPropagation();
  366. toggleBlocks(
  367. el.getAttribute("data-ext"),
  368. el.classList.contains("selected") ? "hide" : "show",
  369. event.ctrlKey
  370. );
  371. }
  372. });
  373.  
  374. document.addEventListener("ghmo:container", () => {
  375. // Init after a short delay to allow rendering of file list
  376. setTimeout(() => {
  377. init();
  378. }, 300);
  379. });
  380. init();
  381.  
  382. })();