Open GitHub files in VS Code

When viewing a file on a known GitHub repo with a local clone, pressing the `\` key will open the file in VS Code. If a line is highlighted, the file will be opened to that line in VS Code.

  1. // ==UserScript==
  2. // @name Open GitHub files in VS Code
  3. // @version 1.0.4
  4. // @author aminomancer
  5. // @homepageURL https://github.com/aminomancer/userscripts
  6. // @supportURL https://github.com/aminomancer/userscripts
  7. // @namespace https://github.com/aminomancer
  8. // @match https://github.com/*/*
  9. // @grant GM_listValues
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @description When viewing a file on a known GitHub repo with a local clone, pressing the `\` key will open the file in VS Code. If a line is highlighted, the file will be opened to that line in VS Code.
  13. // @license CC-BY-NC-SA-4.0
  14. // @icon https://cdn.jsdelivr.net/gh/aminomancer/userscripts@latest/icons/vscode.svg
  15. // ==/UserScript==
  16.  
  17. /* global GM_listValues, GM_getValue, GM_setValue */
  18.  
  19. // These are the default preference values. When the script is first installed,
  20. // these values will be used to populate the preferences, which are stored by
  21. // the userscript manager. To modify the preferences, don't edit the file here.
  22. // Go to the Values tab in the userscript manager and edit them there.
  23. const defaultPrefs = {
  24. // This script works by opening a URL with vscode's custom URL protocol. The
  25. // protocol name can be changed here. The default is "vscode", but if you use
  26. // VS Code Insiders, you should change it to "vscode-insiders".
  27. protocol_name: "vscode",
  28. // This is how the script knows what local file to open. This pref maps each
  29. // GitHub repo to the path of the local clone. If the repo name is "foo/bar",
  30. // then the path should be "/path/to/foo/bar". If a repo is not listed here,
  31. // it will not be opened in VS Code. Only use forward slashes, even on
  32. // Windows, since the path becomes part of a URL. You can also set a default
  33. // directory which will be used as a fallback if a repo is not specifically
  34. // listed here. If default_dir is set to "C:/Repos" then a repo called
  35. // "user123/example456" will be opened from "C:/Repos/example456".
  36. repos: {
  37. // default_dir: "/path/to/default_dir",
  38. // "user123/example456": "/path/to/user123/example456",
  39. },
  40. };
  41.  
  42. for (const [key, value] of Object.entries(defaultPrefs)) {
  43. if (GM_getValue(key) === undefined) {
  44. GM_setValue(key, value);
  45. }
  46. }
  47.  
  48. const prefs = {};
  49. for (const key of GM_listValues()) {
  50. prefs[key] = GM_getValue(key);
  51. }
  52.  
  53. function openInVSCode({ user, repo, filePath, lineNum }) {
  54. let repoPath = prefs.repos[`${user}/${repo}`];
  55. if (!repoPath) {
  56. if (prefs.repos.default_dir) {
  57. repoPath = `${prefs.repos.default_dir}/${repo}`;
  58. } else {
  59. return;
  60. }
  61. }
  62. let protocolURL = `${prefs.protocol_name}://file/${repoPath}/${filePath}`;
  63. if (lineNum) {
  64. protocolURL += `:${lineNum}`;
  65. }
  66. if (!protocolURL) {
  67. return;
  68. }
  69. let link = document.createElement("a");
  70. link.setAttribute("href", protocolURL);
  71. link.click();
  72. }
  73.  
  74. function getForFilesView() {
  75. let fileView;
  76. let fileHeader;
  77. const hash = location.hash?.match(/#diff-(.*)/)?.[1]?.split("-")[0];
  78. let targetDiff = hash && `diff-${hash}`;
  79. let targetFile = targetDiff && document.getElementById(targetDiff);
  80. while (targetFile) {
  81. if (!targetFile.classList.contains("file")) {
  82. if (targetFile.classList.contains("selected-line")) {
  83. targetFile = targetFile.closest(".file");
  84. continue;
  85. }
  86. break;
  87. }
  88. const header = targetFile.querySelector(".file-header");
  89. const rect = header.getBoundingClientRect();
  90. if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
  91. fileView = targetFile;
  92. fileHeader = header;
  93. }
  94. break;
  95. }
  96.  
  97. if (!fileView) {
  98. const fileHeaders = document.querySelectorAll(".file-header");
  99. for (const header of fileHeaders) {
  100. const rect = header.getBoundingClientRect();
  101. if (
  102. Math.floor(
  103. Math.abs(rect.top - parseInt(getComputedStyle(header).top))
  104. ) === 0
  105. ) {
  106. fileHeader = header;
  107. fileView = fileHeader.closest(".file");
  108. break;
  109. }
  110. }
  111. }
  112.  
  113. if (!fileView) {
  114. return null;
  115. }
  116.  
  117. const selectedLine = fileView.querySelector(".selected-line");
  118. const lineNum = selectedLine?.dataset?.lineNumber;
  119.  
  120. const fileMenu = fileHeader.querySelector(".dropdown details-menu");
  121. let fileDetails;
  122. for (const item of fileMenu.children) {
  123. let path = item.pathname;
  124. if (!path) continue;
  125. const match = path.match(/\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.*)/);
  126. if (!match) continue;
  127. const [, user, repo, , filePath] = match;
  128. fileDetails = { user, repo, filePath, lineNum };
  129. break;
  130. }
  131.  
  132. return fileDetails;
  133. }
  134.  
  135. function getForURL(url) {
  136. switch (typeof url) {
  137. case "string":
  138. url = new URL(url);
  139. break;
  140. case "object":
  141. if (url instanceof URL) break;
  142. if (url instanceof Location) break;
  143. if (url instanceof HTMLAnchorElement) {
  144. url = new URL(url.href);
  145. break;
  146. }
  147. // fall through
  148. default:
  149. return null;
  150. }
  151. const [, user, repo, , , ...pathParts] = url.pathname.split("/");
  152. if (!pathParts.length) return null;
  153. const lineNum = url.hash?.match(/^#L(\d+)/)?.[1];
  154. return { user, repo, filePath: pathParts.join("/"), lineNum };
  155. }
  156.  
  157. function handleKeydown(event) {
  158. if (event.key === "\\") {
  159. if (document.querySelector("#files.diff-view")) {
  160. const fileDetails = getForFilesView();
  161. if (!fileDetails) return;
  162. event.preventDefault();
  163. openInVSCode(fileDetails);
  164. } else if (location.pathname.match(/^\/[^/]+\/[^/]+\/blob\//)) {
  165. const fileDetails = getForURL(location);
  166. if (!fileDetails) return;
  167. event.preventDefault();
  168. openInVSCode(fileDetails);
  169. } else if (document.querySelector(".js-navigation-container")) {
  170. const focusedItem = document.querySelector(
  171. ".js-navigation-item.navigation-focus"
  172. );
  173. if (!focusedItem) return;
  174. const rect = focusedItem.getBoundingClientRect();
  175. if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
  176. const link = focusedItem.querySelector("a.rgh-quick-file-edit");
  177. if (!link) return;
  178. const fileDetails = getForURL(link);
  179. if (!fileDetails) return;
  180. event.preventDefault();
  181. openInVSCode(fileDetails);
  182. }
  183. }
  184. }
  185. }
  186.  
  187. document.addEventListener("keydown", handleKeydown);