Better GitHub Pull Requests

Improves the GitHub Pull Request UI and adds several features. "Better PRs"

  1. // ==UserScript==
  2. // @name Better GitHub Pull Requests
  3. // @namespace DougKrahmer
  4. // @version 0.1.3.3
  5. // @description Improves the GitHub Pull Request UI and adds several features. "Better PRs"
  6. // @author Doug Krahmer
  7. // @license GNU GPLv3
  8. // @include /^https:\/\/github\.com\/[^\/]+\/[^\/].*$/
  9. // @include /^https:\/\/git\.[^\/]+\/[^\/]+\/[^\/].*$/
  10. // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com
  11. // @grant GM_setValue
  12. // @grant GM_getValue
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
  14. // @run-at document-idle
  15. // ==/UserScript==
  16.  
  17. /*
  18. # Features
  19. - Show one file at a time. (can be disabled with on-page checkbox)
  20. - Show unviewed filenames in bold in file tree.
  21. - Show number of comments for each file in file tree.
  22. - Auto-mark file as viewed after viewing for 2 seconds. (only in "single file" mode, configurable delay time)
  23. - Filename pop-up tooltips in native GitHub file tree.
  24. - Automatically load large diffs that are hidden by default. (only in "single file" mode)
  25. - Enable adjusting the file tree width. (bottom-right corner of file tree pane)
  26. - Works with GitHub and GitHub Enterprise.
  27. - Supports native GitHub tree view and/or [Gitako Chrome Extension](https://chrome.google.com/webstore/detail/gitako-github-file-tree/giljefjcheohhamkjphiebfjnlphnokk) tree view.
  28. - On-page checkbox to disable/enable added styles.
  29.  
  30. # User Settings
  31. - `Enabled` - Set to false to disable this mod. A checkbox is added to the page to easliy change this setting on the fly.
  32. - `IntegrateWithGitako` - Integrate with [Gitako](https://chrome.google.com/webstore/detail/gitako-github-file-tree/giljefjcheohhamkjphiebfjnlphnokk) file tree in addition to GitHub native.
  33. - `MarkViewedAfterMs` - Mark file viewed after this many milliseconds. Set to 0 to disable.
  34. - `ShowOnlySingleFile` - Show only a single file at a time in the UI. A checkbox is added to the page to easliy change this setting on the fly.
  35.  
  36. # How to change User Settings
  37. Persistent user settings can be changed in the Storage tab in Tampermonkey.
  38. The Storage tab can be found near to the script Editor tab when editing this script in Tampermonkey.
  39. If the Storage tab is not visible, change config mode to Advanced in Tampermonkey's main Settings then refresh.
  40. Load at least one PR to populate default values before attempting to edit.
  41. These settings will persist even if the browser is closed or this Tampermonkey script is updated in the future.
  42.  
  43. # Notes
  44. This script will automatically run on github.com and any domain name that begins with "git." for GitHub Enterprise.
  45. If your GitHub Enterprise domain does not start with "git.", add your domain name to the "User Includes" section on the Settings tab for this script.
  46. */
  47.  
  48. (function() {
  49. 'use strict';
  50.  
  51. const FILE_SHOW_CLASS = "file-show";
  52. const UNVIEWED_CLASS = "file-unviewed";
  53. const FILE_LINK_CLASS = "file-link";
  54. const COMMENT_COUNT_CLASS = "comment-count";
  55. const INITIALIZE_TREE_ATTEMPTS = 600;
  56. const ATTACHED_ATTRIBUTE = "better-gh-attached";
  57. let _lastFileDiffHash = null;
  58. let _areTreesInitialized = false;
  59. const $ = window.$;
  60.  
  61. const initialize = () => {
  62. // add URL change events to browser
  63. var wr = (type) => {
  64. var orig = history[type];
  65. return function() {
  66. var rv = orig.apply(this, arguments);
  67. var e = new Event(type);
  68. e.arguments = arguments;
  69. window.dispatchEvent(e);
  70. return rv;
  71. };
  72. };
  73. history.pushState = wr('pushState');
  74. history.replaceState = wr('replaceState');
  75.  
  76. verifyStyles();
  77. // add URL change listeners
  78. window.addEventListener("pushState", handleUrlChange);
  79. window.addEventListener("popstate", handleUrlChange);
  80. window.addEventListener("replaceState", handleUrlChange);
  81. handleUrlChange();
  82. }
  83.  
  84. const handleUrlChange = (event) => {
  85. verifyAppendedSettings();
  86. if (!_areTreesInitialized && window.location.pathname.endsWith("/files")) {
  87. initializeTrees();
  88. _areTreesInitialized = true;
  89. }
  90. else {
  91. _areTreesInitialized = false;
  92. }
  93.  
  94. handleHashChange();
  95. }
  96.  
  97. const verifyAppendedSettings = () => {
  98. let container = $("div.diffbar div.flex-items-center[data-pjax='#repo-content-pjax-container']");
  99. if (container.length === 0) {
  100. // legacy DOM
  101. container = $("div.diffbar > .flex-auto > div:nth-child(2)");
  102. }
  103.  
  104. if (container.length == 0) {
  105. return;
  106. }
  107.  
  108. let enableBetterPrs = container.find("#enable-better-prs")[0];
  109. if (!enableBetterPrs) {
  110. container.append('<div class="diffbar-item form-checkbox" style="margin-top: 0; margin-bottom: 0;"><details class="details-reset"><summary><label class="Link--muted" style="cursor: inherit" title="Enable Better GitHub Pull Requests"><input id="enable-better-prs" type="checkbox" style="cursor: inherit">Better PRs</label></summary></details></div>');
  111. const enableBetterPrs = container.find("#enable-better-prs")[0];
  112. if (enableBetterPrs) {
  113. enableBetterPrs.checked = getValue("Enabled", true);
  114. enableBetterPrs.addEventListener("change", toggleBetterPr);
  115. }
  116. }
  117.  
  118. let singleFile = container.find("#enable-single-file")[0];
  119. if (!singleFile) {
  120. container.append('<div class="diffbar-item form-checkbox" style="margin-top: 0; margin-bottom: 0;"><details class="details-reset"><summary><label class="Link--muted" style="cursor: inherit" title="Show only the single selected file (when Better PRs is enabled)."><input id="enable-single-file" type="checkbox" style="cursor: inherit">Single File</label></summary></details></div>');
  121. const singleFile = container.find("#enable-single-file")[0];
  122. if (singleFile) {
  123. singleFile.checked = getValue("ShowOnlySingleFile", true);
  124. singleFile.addEventListener("change", toggleSingleFile);
  125. }
  126. }
  127. }
  128.  
  129. const toggleBetterPr = (event) => {
  130. setValue("Enabled", event.target.checked);
  131. verifyStyles();
  132. }
  133.  
  134. const toggleSingleFile = (event) => {
  135. setValue("ShowOnlySingleFile", event.target.checked);
  136. verifyStyles(true);
  137. }
  138.  
  139. const handleHashChange = () => {
  140. const hash = (window?.location?.hash || "").replace("#", "");
  141.  
  142. if (hash === _lastFileDiffHash || !hash.startsWith("diff-")) {
  143. return; // Nothing to do. Either already handled or no file to change to
  144. }
  145.  
  146. // hide previous file div (if any)
  147. if (_lastFileDiffHash) {
  148. const lastFileDiv = window.document.getElementById(_lastFileDiffHash);
  149. if (lastFileDiv) {
  150. lastFileDiv.className = lastFileDiv.className.replace(` ${FILE_SHOW_CLASS}`, "");
  151. }
  152. }
  153.  
  154. const fileDiv = window.document.getElementById(hash);
  155. if (!fileDiv) {
  156. _lastFileDiffHash = null;
  157. // The file element is missing and might not be loaded yet. Try again soon...
  158. setTimeout(() => handleHashChange(), 100);
  159. return;
  160. }
  161.  
  162. // show the selected file div
  163. addClassName(fileDiv, FILE_SHOW_CLASS);
  164. const button = fileDiv.querySelector(".js-diff-load-container button");
  165. if (button?.attributes["data-disable-with"]?.value?.startsWith("Loading")) {
  166. button.click();
  167. }
  168.  
  169. // Remove the class name that hides the block after collpsing or marking as viewed
  170. const detailsClassName = "Details-content--hidden";
  171. const containerDiv = fileDiv.querySelector(`div.${detailsClassName}`);
  172. if (containerDiv) {
  173. removeClassName(containerDiv, detailsClassName)
  174. }
  175.  
  176. _lastFileDiffHash = hash;
  177. const viewedCheckbox = attachToViewedCheckbox(fileDiv, hash);
  178.  
  179. if (getValue("Enabled", true) && getValue("ShowOnlySingleFile", true) && getValue("MarkViewedAfterMs", 2000) > 0 && viewedCheckbox?.checked === false) {
  180. setTimeout(() => {
  181. // check if the user is still viewing the same file
  182. const currentHash = (window?.location?.hash || "").replace("#", "");
  183. if (currentHash != hash) {
  184. // the user has moved on
  185. return;
  186. }
  187. viewedCheckbox.click(); // mark viewed
  188. }, getValue("MarkViewedAfterMs", 2000));
  189. }
  190. }
  191.  
  192. const initializeTrees = () => {
  193. initializeTree("file-tree", "link-", INITIALIZE_TREE_ATTEMPTS); // native tree
  194. if (getValue("IntegrateWithGitako", true)) {
  195. initializeGitakoTree(INITIALIZE_TREE_ATTEMPTS);
  196. }
  197. }
  198.  
  199. const initializeGitakoTree = (retryAttempts) => {
  200. const gitakoContainer = document.querySelector(".magic-size-container")?.querySelector("div")?.querySelector("div");
  201.  
  202. if (!gitakoContainer && retryAttempts > 0) {
  203. setTimeout(() => initializeGitakoTree(retryAttempts - 1), 500); // wait a bit for it to load then try again
  204. return;
  205. }
  206.  
  207. const initEvent = (event) => {
  208. initializeTree("div.gitako-side-bar-content", "link-gitako-", retryAttempts);
  209. }
  210.  
  211. initEvent();
  212. gitakoContainer.addEventListener("DOMNodeInserted", initEvent);
  213. }
  214.  
  215. const initializeTree = (containerSelector, linkPrefix, retryAttempts) => {
  216. const treeDiv = document.querySelector(containerSelector);
  217. const treeLinks = treeDiv?.querySelectorAll("a") ?? [];
  218.  
  219. let fileCount = 0;
  220. for (let i = 0; i < treeLinks.length; i++) {
  221. const treeLink = treeLinks[i];
  222. if (treeLink.id || !treeLink.href?.includes("#diff-")) {
  223. continue;
  224. }
  225.  
  226. const hash = treeLink.href.substring(treeLink.href.indexOf("#diff-") + 1);
  227. const fileDiv = window.document.getElementById(hash);
  228. if (!fileDiv) {
  229. setTimeout(() => initializeTree(containerSelector, linkPrefix, retryAttempts - 1), 100); // It may not have finished loading yet. Try again soon.
  230. return;
  231. }
  232. const viewedCheckbox = fileDiv.querySelector("input.js-reviewed-checkbox");
  233. addClassName(treeLink, FILE_LINK_CLASS)
  234. if (!viewedCheckbox?.checked) {
  235. addClassName(treeLink, UNVIEWED_CLASS)
  236. }
  237.  
  238. if (!treeLink.title){
  239. const fileLabelElement = treeLink.querySelector(".ActionList-item-label");
  240. const filename = fileLabelElement?.innerText;
  241. if (filename) {
  242. treeLink.title = filename; // add filename tooltip for native file tree
  243. }
  244. }
  245.  
  246. const handleCommentAddedRemoved = (event) => {
  247. if (!event?.target?.className?.includes("comment-holder")
  248. && !event?.target?.className?.includes("review-comment")
  249. && !event?.target?.className?.includes("js-comment-container")) {
  250. return;
  251. }
  252.  
  253. // handle after the current command stack to ensure an accurate count
  254. setTimeout(() => {
  255. updateCommentCount(treeLink, fileDiv);
  256. }, 0)
  257. }
  258.  
  259. fileDiv.addEventListener("DOMNodeInserted", handleCommentAddedRemoved);
  260. fileDiv.addEventListener("DOMNodeRemoved", handleCommentAddedRemoved);
  261.  
  262. updateCommentCount(treeLink, fileDiv);
  263.  
  264. // add an id so we know we already processed it
  265. treeLink.id = `${linkPrefix}${hash}`;
  266.  
  267. fileCount++;
  268. }
  269. }
  270.  
  271. const updateCommentCount = (treeLink, fileDiv) => {
  272. let commentCountElement = $(treeLink.querySelector(".comment-count"));
  273. if (commentCountElement.length === 0) {
  274. const container = $(treeLink.querySelector(".ActionList-item-visual--trailing"));
  275. if (container.length == 0) {
  276. return;
  277. }
  278. container.prepend('<span class="comment-count" title="comment count"></span>');
  279. commentCountElement = container.find(".comment-count");
  280. }
  281.  
  282. const commentElements = fileDiv.querySelectorAll(".review-comment");
  283. commentCountElement.text(commentElements.length ? `${commentElements.length}` : "");
  284. if (commentElements.length > 0) {
  285. commentCountElement.prepend('<svg role="img" class="octicon Comment" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" style="margin-right: 1px;"><path fill-rule="evenodd" d="M2.75 2.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 01.75.75v2.19l2.72-2.72a.75.75 0 01.53-.22h4.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25H2.75zM1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0113.25 12H9.06l-2.573 2.573A1.457 1.457 0 014 13.543V12H2.75A1.75 1.75 0 011 10.25v-7.5z"></path></svg>');
  286. }
  287. }
  288.  
  289. const handleViewedCheckboxChange = (fileDiv, viewedCheckbox, hash) => {
  290. initializeTrees();
  291. const link = window.document.getElementById(`link-${hash}`);
  292. const linkGitako = window.document.getElementById(`link-gitako-${hash}`);
  293.  
  294. if (viewedCheckbox.checked) {
  295. removeClassName(link, UNVIEWED_CLASS);
  296. removeClassName(linkGitako, UNVIEWED_CLASS);
  297. }
  298. else {
  299. addClassName(link, UNVIEWED_CLASS);
  300. addClassName(linkGitako, UNVIEWED_CLASS);
  301. }
  302. }
  303.  
  304. const attachToViewedCheckbox = (fileDiv, hash) => {
  305. const outerContainer = fileDiv?.querySelector(".flex-justify-end");
  306. const innerContainer = outerContainer?.querySelector(".js-replace-file-header-review");
  307. let viewedCheckbox = innerContainer?.querySelector("input.js-reviewed-checkbox");
  308.  
  309. const handler = (event) => {
  310. viewedCheckbox = event.srcElement;
  311. if (event.srcElement.type != "checkbox") {
  312. viewedCheckbox = event.srcElement?.querySelector("input.js-reviewed-checkbox");
  313. }
  314. handleViewedCheckboxChange(fileDiv, viewedCheckbox, hash);
  315. attachToViewedCheckbox(fileDiv, hash);
  316. };
  317.  
  318. attachEvent("change", outerContainer, handler);
  319. attachEvent("change", innerContainer, handler);
  320. attachEvent("change", viewedCheckbox, handler);
  321.  
  322. handleViewedCheckboxChange(fileDiv, viewedCheckbox, hash); // update now just in case it changed on us
  323.  
  324. return viewedCheckbox;
  325. }
  326.  
  327. const attachEvent = (event, element, handler) => {
  328. if (!element) {
  329. return;
  330. }
  331.  
  332. if (!element[ATTACHED_ATTRIBUTE]) {
  333. element.addEventListener(event, handler);
  334. element[ATTACHED_ATTRIBUTE] = "true";
  335. }
  336. }
  337.  
  338. const addClassName = (element, className) => {
  339. if (element) {
  340. removeClassName(element, className);
  341. element.className = element.className + ` ${className}`;
  342. }
  343. }
  344.  
  345. const removeClassName = (element, className) => {
  346. if (element) {
  347. element.className = element.className?.replace(className, "").trim();
  348. }
  349. }
  350.  
  351. let _style = null;
  352. const verifyStyles = (recreate) => {
  353. if (_style && recreate) {
  354. _style.remove();
  355. _style = null;
  356. }
  357.  
  358. if (!_style) {
  359. _style = document.createElement("style");
  360. _style.appendChild(document.createTextNode(""));
  361. document.head.appendChild(_style);
  362.  
  363. _style.sheet.insertRule(".Layout-sidebar { resize: horizontal; }");
  364. if (getValue("ShowOnlySingleFile", true)) {
  365. _style.sheet.insertRule(".file { display: none; }");
  366. }
  367. _style.sheet.insertRule(".file-header { border-bottom: 1px solid var(--color-border-default) !important; }");
  368. _style.sheet.insertRule(".ActionList-content, .node-item-label span { opacity: 75%;}");
  369. _style.sheet.insertRule(`.${FILE_SHOW_CLASS} { display: block !important; }`);
  370. _style.sheet.insertRule(`.${UNVIEWED_CLASS}, .${UNVIEWED_CLASS} div span, .${UNVIEWED_CLASS} span { font-weight: bold; opacity: 100% !important; }`);
  371. _style.sheet.insertRule(`span.${COMMENT_COUNT_CLASS} { margin-right: 3px; white-space: nowrap; font-weight: normal !important; }`);
  372. }
  373.  
  374. _style.sheet.disabled = !getValue("Enabled", true);
  375. }
  376.  
  377. const getValue = (settingName, defaultValue) => {
  378. let value = !!window.GM_getValue ? GM_getValue(settingName) : undefined;
  379.  
  380. if (value === undefined) {
  381. value = defaultValue;
  382. setValue(settingName, value);
  383. }
  384.  
  385. return value;
  386. }
  387.  
  388. const setValue = (settingName, newValue) => {
  389. if (window.GM_setValue) {
  390. window.GM_setValue(settingName, newValue);
  391. }
  392. }
  393.  
  394. initialize();
  395. })();