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.

// ==UserScript==
// @name           Open GitHub files in VS Code
// @version        1.0.4
// @author         aminomancer
// @homepageURL    https://github.com/aminomancer/userscripts
// @supportURL     https://github.com/aminomancer/userscripts
// @namespace      https://github.com/aminomancer
// @match          https://github.com/*/*
// @grant          GM_listValues
// @grant          GM_getValue
// @grant          GM_setValue
// @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.
// @license        CC-BY-NC-SA-4.0
// @icon           https://cdn.jsdelivr.net/gh/aminomancer/userscripts@latest/icons/vscode.svg
// ==/UserScript==

/* global GM_listValues, GM_getValue, GM_setValue */

// These are the default preference values. When the script is first installed,
// these values will be used to populate the preferences, which are stored by
// the userscript manager. To modify the preferences, don't edit the file here.
// Go to the Values tab in the userscript manager and edit them there.
const defaultPrefs = {
  // This script works by opening a URL with vscode's custom URL protocol. The
  // protocol name can be changed here. The default is "vscode", but if you use
  // VS Code Insiders, you should change it to "vscode-insiders".
  protocol_name: "vscode",
  // This is how the script knows what local file to open. This pref maps each
  // GitHub repo to the path of the local clone. If the repo name is "foo/bar",
  // then the path should be "/path/to/foo/bar". If a repo is not listed here,
  // it will not be opened in VS Code. Only use forward slashes, even on
  // Windows, since the path becomes part of a URL. You can also set a default
  // directory which will be used as a fallback if a repo is not specifically
  // listed here. If default_dir is set to "C:/Repos" then a repo called
  // "user123/example456" will be opened from "C:/Repos/example456".
  repos: {
    // default_dir: "/path/to/default_dir",
    // "user123/example456": "/path/to/user123/example456",
  },
};

for (const [key, value] of Object.entries(defaultPrefs)) {
  if (GM_getValue(key) === undefined) {
    GM_setValue(key, value);
  }
}

const prefs = {};
for (const key of GM_listValues()) {
  prefs[key] = GM_getValue(key);
}

function openInVSCode({ user, repo, filePath, lineNum }) {
  let repoPath = prefs.repos[`${user}/${repo}`];
  if (!repoPath) {
    if (prefs.repos.default_dir) {
      repoPath = `${prefs.repos.default_dir}/${repo}`;
    } else {
      return;
    }
  }
  let protocolURL = `${prefs.protocol_name}://file/${repoPath}/${filePath}`;
  if (lineNum) {
    protocolURL += `:${lineNum}`;
  }
  if (!protocolURL) {
    return;
  }
  let link = document.createElement("a");
  link.setAttribute("href", protocolURL);
  link.click();
}

function getForFilesView() {
  let fileView;
  let fileHeader;
  const hash = location.hash?.match(/#diff-(.*)/)?.[1]?.split("-")[0];
  let targetDiff = hash && `diff-${hash}`;
  let targetFile = targetDiff && document.getElementById(targetDiff);
  while (targetFile) {
    if (!targetFile.classList.contains("file")) {
      if (targetFile.classList.contains("selected-line")) {
        targetFile = targetFile.closest(".file");
        continue;
      }
      break;
    }
    const header = targetFile.querySelector(".file-header");
    const rect = header.getBoundingClientRect();
    if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
      fileView = targetFile;
      fileHeader = header;
    }
    break;
  }

  if (!fileView) {
    const fileHeaders = document.querySelectorAll(".file-header");
    for (const header of fileHeaders) {
      const rect = header.getBoundingClientRect();
      if (
        Math.floor(
          Math.abs(rect.top - parseInt(getComputedStyle(header).top))
        ) === 0
      ) {
        fileHeader = header;
        fileView = fileHeader.closest(".file");
        break;
      }
    }
  }

  if (!fileView) {
    return null;
  }

  const selectedLine = fileView.querySelector(".selected-line");
  const lineNum = selectedLine?.dataset?.lineNumber;

  const fileMenu = fileHeader.querySelector(".dropdown details-menu");
  let fileDetails;
  for (const item of fileMenu.children) {
    let path = item.pathname;
    if (!path) continue;
    const match = path.match(/\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.*)/);
    if (!match) continue;
    const [, user, repo, , filePath] = match;
    fileDetails = { user, repo, filePath, lineNum };
    break;
  }

  return fileDetails;
}

function getForURL(url) {
  switch (typeof url) {
    case "string":
      url = new URL(url);
      break;
    case "object":
      if (url instanceof URL) break;
      if (url instanceof Location) break;
      if (url instanceof HTMLAnchorElement) {
        url = new URL(url.href);
        break;
      }
    // fall through
    default:
      return null;
  }
  const [, user, repo, , , ...pathParts] = url.pathname.split("/");
  if (!pathParts.length) return null;
  const lineNum = url.hash?.match(/^#L(\d+)/)?.[1];
  return { user, repo, filePath: pathParts.join("/"), lineNum };
}

function handleKeydown(event) {
  if (event.key === "\\") {
    if (document.querySelector("#files.diff-view")) {
      const fileDetails = getForFilesView();
      if (!fileDetails) return;
      event.preventDefault();
      openInVSCode(fileDetails);
    } else if (location.pathname.match(/^\/[^/]+\/[^/]+\/blob\//)) {
      const fileDetails = getForURL(location);
      if (!fileDetails) return;
      event.preventDefault();
      openInVSCode(fileDetails);
    } else if (document.querySelector(".js-navigation-container")) {
      const focusedItem = document.querySelector(
        ".js-navigation-item.navigation-focus"
      );
      if (!focusedItem) return;
      const rect = focusedItem.getBoundingClientRect();
      if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
        const link = focusedItem.querySelector("a.rgh-quick-file-edit");
        if (!link) return;
        const fileDetails = getForURL(link);
        if (!fileDetails) return;
        event.preventDefault();
        openInVSCode(fileDetails);
      }
    }
  }
}

document.addEventListener("keydown", handleKeydown);