GitHub — View File Before Commit

Adds "View file before commit" to GitHub's "View file" menu on commit pages, linking to the previous revision of that file (if any). This is useful to trace code chunks when they are moved between files. Uses a MutationObserver and fetches the per-file commits page to find the previous SHA.

// ==UserScript==
// @name         GitHub — View File Before Commit
// @namespace    https://github.com/jwbth
// @version      0.1
// @description  Adds "View file before commit" to GitHub's "View file" menu on commit pages, linking to the previous revision of that file (if any). This is useful to trace code chunks when they are moved between files. Uses a MutationObserver and fetches the per-file commits page to find the previous SHA.
// @author       jwbth
// @match        https://github.com/*/*/commit/*
// @match        https://github.com/*/*/pull/*/commits/*
// @grant        none
// @run-at       document-end
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const SCRIPT_NAME = 'View File Before Commit';

  // configuration: how many distinct shas we want to find (we need 2: current and previous)
  const NEED_SHAS = 2;

  // Helper: parse blob URL path into owner, repo, ref, filePath
  // Expects a path like: /owner/repo/blob/<ref>/path/to/file
  function parseBlobPath(pathname) {
    const m = pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
    if (!m) return null;
    return { owner: m[1], repo: m[2], ref: m[3], filePath: m[4] };
  }

  // Helper: given owner/repo/ref/filePath, build commits URL (relative)
  function commitsUrl({ owner, repo, ref, filePath }) {
    return `/${owner}/${repo}/commits/${ref}/${filePath}`;
  }

  /**
   * Extract up to NEED_SHAS distinct commit SHAs for the given file
   * from a `commits` page Document. SHAs are returned in order of appearance.
   */
  function extractShasFromDoc(doc, owner, repo, filePath) {
    const SHA_RE = /^[0-9a-f]{7,40}$/;
    const prefix = `/${owner}/${repo}/blob/`;
    const shas = new Set();

    for (const a of doc.querySelectorAll('a')) {
      try {
        const href = a.getAttribute('href') || a.href;
        if (!href) continue;

        const url = new URL(href, location.origin);
        if (!url.pathname.startsWith(prefix)) continue;

        const rest = url.pathname.slice(prefix.length); // "<sha>/path/to/file"
        const slashIndex = rest.indexOf('/');
        if (slashIndex < 7) continue; // sha should be at least 7 chars

        const sha = rest.slice(0, slashIndex);
        if (!SHA_RE.test(sha)) continue;

        const pathPart = rest.slice(slashIndex + 1);
        // Compare decoded path parts to avoid %-encoding mismatches
        if (decodeURIComponent(pathPart) !== decodeURIComponent(filePath)) continue;

        shas.add(sha);
        if (shas.size >= NEED_SHAS) break;
      } catch {
        // ignore malformed anchors / URLs
      }
    }

    return Array.from(shas);
  }

  // Insert a new menu item by cloning the existing list item and modifying it.
  function insertPrevMenuItem(existingAnchor, prevSha, filePath) {
    const li = existingAnchor.closest('li');
    if (!li) return;

    // Avoid inserting twice: mark processed anchors with data attribute
    if (li.dataset.prevInserted === '1') return;

    // Clone the LI, change anchor href and label
    const newLi = li.cloneNode(true);

    // Find the anchor inside the clone
    const a = newLi.querySelector('a');
    if (!a) return;

    // Remove id to avoid duplicates
    a.removeAttribute('id');

    // Compute new href absolute URL
    // Keep relative path (/owner/repo/blob/<sha>/filePath) but preserve hostname
    // Get owner/repo from existingAnchor.href
    const parsed = parseBlobPath(existingAnchor.getAttribute('href') || existingAnchor.href);
    if (!parsed) return;
    const newHref = `/${parsed.owner}/${parsed.repo}/blob/${prevSha}/${filePath}`;
    a.setAttribute('href', newHref);

    // Update label text (find the element that contains the visible text; fallback to anchor text)
    // Many GitHub UI elements nest the visible label inside a span with a specific class; be permissive
    const labelElement = Array.from(newLi.querySelectorAll('span,div')).find(
      (el) => el.textContent && el.textContent.trim() === 'View file'
    );
    if (labelElement) {
      labelElement.textContent = 'View file before commit';
    } else {
      // fallback
      a.textContent = 'View file before commit';
    }

    // Insert after the original li
    li.parentNode.insertBefore(newLi, li.nextSibling);

    // Mark original li so we don't insert again for the same menu
    li.dataset.prevInserted = '1';
  }

  // Main worker: given the found "View file" anchor element, find previous commit and insert item
  async function handleViewFileAnchor(anchor) {
    if (!anchor || !anchor.getAttribute) return;
    // already processed?
    if (anchor.dataset.prevProcessed === '1') return;
    anchor.dataset.prevProcessed = '1';

    // parse href
    const href = anchor.getAttribute('href') || anchor.href;
    const parsed = parseBlobPath(href);
    if (!parsed) {
      // not a blob URL
      return;
    }

    const cUrl = commitsUrl(parsed);
    try {
      const resp = await fetch(cUrl, { credentials: 'same-origin' });
      if (!resp.ok) {
        console.warn(`${SCRIPT_NAME}: failed to fetch commits page`, cUrl, resp.status);
        return;
      }
      const html = await resp.text();
      const doc = new DOMParser().parseFromString(html, 'text/html');
      const shas = extractShasFromDoc(doc, parsed.owner, parsed.repo, parsed.filePath);
      if (shas.length < 2) {
        // no previous commit for this file found
        return;
      }
      const previousSha = shas[1]; // second one is previous commit
      insertPrevMenuItem(anchor, previousSha, parsed.filePath);
    } catch (e) {
      console.error(`${SCRIPT_NAME}: error`, e);
    }
  }

  // Scans a node subtree for "View file" anchors and calls handler for each one found.
  function scanForViewFile(node) {
    // find anchors that *look like* the "View file" menu entry
    // attempt a few selection strategies:
    // 1) exact class from your DOM sample
    // 2) role=menuitem and inner label 'View file'
    // 3) any anchor with href containing '/blob/' and closest list indicates a menu
    const candidates = [];

    // strategy 1: specific classes (fast)
    Array.from(
      (node.querySelectorAll &&
        node.querySelectorAll('a.prc-ActionList-ActionListContent-sg9-x.prc-Link-Link-85e08')) ||
        []
    ).forEach((a) => candidates.push(a));

    // strategy 2: anchors with role=menuitem and visible text "View file"
    Array.from(
      (node.querySelectorAll && node.querySelectorAll('a[role="menuitem"]')) || []
    ).forEach((a) => {
      const text = (a.textContent || '').trim();
      if (text === 'View file') candidates.push(a);
    });

    // strategy 3: anchors inside menus whose href contains '/blob/'
    Array.from((node.querySelectorAll && node.querySelectorAll('a[href*="/blob/"]')) || []).forEach(
      (a) => {
        // ensure anchor is inside something that looks like an action list/menu
        if (
          a.closest &&
          a.closest(
            '.prc-ActionList-ActionList-X4RiC, .prc-ActionMenu-ActionMenuContainer-XdFHv, [role="menu"], .js-file-action'
          )
        ) {
          candidates.push(a);
        }
      }
    );

    // deduplicate
    const uniq = Array.from(new Set(candidates));

    for (const a of uniq) {
      // only handle anchors that point to /<owner>/<repo>/blob/...
      if (!/\/[^/]+\/[^/]+\/blob\//.test(a.getAttribute('href') || a.href)) continue;
      handleViewFileAnchor(a);
    }
  }

  // Mutation observer: watch for overlay/menu nodes being added
  const observer = new MutationObserver((muts) => {
    for (const mut of muts) {
      if (!mut.addedNodes || mut.addedNodes.length === 0) continue;
      for (const node of mut.addedNodes) {
        // Some menus are wrapped in small containers; scan subtree for the menu anchor
        try {
          if (node.nodeType === Node.ELEMENT_NODE) {
            // quick textual heuristic: only search in nodes that contain "View file" or have 'prc-ActionMenu' classes
            const el = /** @type {Element} */ (node);
            if (el.textContent && el.textContent.includes('View file')) {
              scanForViewFile(el);
            } else if (
              el.classList &&
              (el.classList.contains('prc-ActionMenu-ActionMenuContainer-XdFHv') ||
                el.classList.contains('prc-ActionList-ActionList-X4RiC'))
            ) {
              scanForViewFile(el);
            } else {
              // for safety, do a light scan (limits to anchors under this node)
              // but only if there are anchors present
              if (el.querySelector && el.querySelector('a')) {
                scanForViewFile(el);
              }
            }
          }
        } catch (e) {
          // swallow errors from scanning unknown structures
          console.error(`${SCRIPT_NAME}: scan error`, e);
        }
      }
    }
  });

  // Start observing the document body
  function start() {
    const root = document.body;
    if (!root) return;
    observer.observe(root, { childList: true, subtree: true });

    // Also do an initial scan in case the menu is already present
    scanForViewFile(document);
    console.info(`${SCRIPT_NAME}: observer started`);
  }

  // Start after a small delay to allow GitHub's dynamic HTML to initialize
  setTimeout(start, 400);
})();