您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork镜像 is available in English.
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); })();