// ==UserScript==
// @name GitHub Commit File Downloader
// @description Allows you to download individual files or all files as ZIP directly from commit pages.
// @icon https://github.githubassets.com/favicons/favicon-dark.svg
// @version 1.0
// @author afkarxyz
// @namespace https://github.com/afkarxyz/userscripts/
// @supportURL https://github.com/afkarxyz/userscripts/issues
// @license MIT
// @match https://github.com/*
// @grant none
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// ==/UserScript==
(function () {
'use strict';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const fileZipIconIndividual = '<svg aria-hidden="true" focusable="false" class="octicon octicon-file-zip" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" display="inline-block" overflow="visible" style="vertical-align: text-bottom;"><g><path d="M1,1.8C1,0.8,1.8,0,2.8,0h7.6c0.5,0,0.9,0.2,1.2,0.5l2.9,2.9C14.8,3.8,15,4.2,15,4.7v9.6c0,1-0.8,1.8-1.8,1.8H2.8c-1,0-1.8-0.8-1.8-1.8V1.8z M10.5,1.6c0,0-0.1-0.1-0.2-0.1H2.8c-0.1,0-0.2,0.1-0.2,0.2v12.5c0,0.1,0.1,0.2,0.2,0.2h10.5c0.1,0,0.2-0.1,0.2-0.2V4.7c0-0.1,0-0.1-0.1-0.2" /><path d="M8.7,9.2V5c0-0.4-0.4-0.7-0.7-0.7S7.3,4.7,7.3,5v4.1L5.5,7.5c-0.2-0.4-0.7-0.4-1,0c-0.2,0.2-0.2,0.7,0,1l3,3c0.2,0.2,0.7,0.2,1,0l0,0l3-3c0.2-0.2,0.2-0.7,0-1c-0.2-0.2-0.7-0.2-1,0L8.7,9.2z" /></g></svg>';
const fileZipIconAll = '<svg aria-hidden="true" focusable="false" class="octicon octicon-file-zip" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" display="inline-block" overflow="visible" style="vertical-align: text-bottom;"><path d="M3.5 1.75v11.5c0 .09.048.173.126.217a.75.75 0 0 1-.752 1.298A1.748 1.748 0 0 1 2 13.25V1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.185 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 12.25 15h-.5a.75.75 0 0 1 0-1.5h.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177L9.513 1.573a.25.25 0 0 0-.177-.073H7.25a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5h-3a.25.25 0 0 0-.25.25Zm3.75 8.75h.5c.966 0 1.75.784 1.75 1.75v3a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75v-3c0-.966.784-1.75 1.75-1.75ZM6 5.25a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 6 5.25Zm.75 2.25h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 6.75A.75.75 0 0 1 8.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 6.75ZM8.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 9.75A.75.75 0 0 1 8.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 9.75Zm-1 2.5v2.25h1v-2.25a.25.25 0 0 0-.25-.25h-.5a.25.25 0 0 0-.25.25Z"></path></svg>';
function createIndividualDownloadButtons() {
const fileRows = document.querySelectorAll('ul[role="tree"] > li[id]');
fileRows.forEach(row => {
const filename = row.id.replace(/\u200E/g, '');
if (row.querySelector('.gh-file-download')) return;
const fileIconDiv = row.querySelector('.PRIVATE_TreeView-item-visual');
if (!fileIconDiv) return;
fileIconDiv.innerHTML = fileZipIconIndividual;
fileIconDiv.style.cursor = 'pointer';
fileIconDiv.className += ' gh-file-download';
fileIconDiv.style.transition = 'transform 0.15s ease-in-out';
fileIconDiv.addEventListener('mouseenter', () => fileIconDiv.style.transform = 'scale(1.1)');
fileIconDiv.addEventListener('mouseleave', () => fileIconDiv.style.transform = 'scale(1)');
fileIconDiv.onclick = async (e) => {
e.stopPropagation();
const [_, user, repo, __, commit] = location.pathname.split('/');
const rawUrl = `https://raw.githubusercontent.com/${user}/${repo}/${commit}/${filename}`;
try {
const res = await fetch(rawUrl);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.split('/').pop();
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
alert(`Failed to download ${filename}`);
console.error(e);
}
};
});
}
function createDownloadAllZipButton() {
const titleEl = document.querySelector('h1[data-component="PH_Title"]');
if (!titleEl || titleEl.querySelector('.gh-download-all-zip')) return;
const btn = document.createElement('button');
btn.innerHTML = fileZipIconAll;
btn.className = 'gh-download-all-zip';
btn.style.marginLeft = '12px';
btn.style.cursor = 'pointer';
btn.style.border = 'none';
btn.style.background = 'none';
btn.style.display = 'inline-flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.style.padding = '0';
btn.style.transition = 'transform 0.15s ease-in-out';
btn.addEventListener('mouseenter', () => btn.style.transform = 'scale(1.2)');
btn.addEventListener('mouseleave', () => btn.style.transform = 'scale(1)');
btn.onclick = async () => {
const [_, user, repo, __, commit] = location.pathname.split('/');
const fileEls = document.querySelectorAll('ul[role="tree"] > li[id]');
if (!fileEls.length) return alert('No files found.');
const zip = new JSZip();
for (const li of fileEls) {
const filename = li.id.replace(/\u200E/g, '');
const rawUrl = `https://raw.githubusercontent.com/${user}/${repo}/${commit}/${filename}`;
try {
const res = await fetch(rawUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
zip.file(filename.split('/').pop(), arrayBuffer);
await sleep(200);
} catch (err) {
console.error(`Error downloading ${filename}:`, err);
}
}
const content = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = `${repo}-${commit.slice(0, 7)}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
};
titleEl.appendChild(btn);
}
function handleRouteChange() {
if (!location.pathname.match(/^\/[^\/]+\/[^\/]+\/commit\/[a-f0-9]+$/)) return;
createIndividualDownloadButtons();
createDownloadAllZipButton();
}
const observer = new MutationObserver(() => {
handleRouteChange();
});
observer.observe(document.body, { childList: true, subtree: true });
(function() {
const origPushState = history.pushState;
const origReplaceState = history.replaceState;
let lastPath = location.pathname;
function checkPathChange() {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
setTimeout(handleRouteChange, 100);
}
}
history.pushState = function(...args) {
origPushState.apply(this, args);
checkPathChange();
};
history.replaceState = function(...args) {
origReplaceState.apply(this, args);
checkPathChange();
};
window.addEventListener('popstate', checkPathChange);
})();
handleRouteChange();
})();