mirror of
https://github.com/GetOpenScript/GitHub.openscript.git
synced 2026-09-18 08:25:42 +00:00
Show file sizes in GitHub repository views
This commit is contained in:
@@ -1,155 +1,214 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Display Repo Size for GitHub
|
// @name Display Repo and File Sizes for GitHub
|
||||||
// @version 1.3.0
|
// @description Displays repository disk usage and individual file sizes on GitHub.
|
||||||
// @description Displays total repository size (including full git history) inside the About section on GitHub using GH_PAT.
|
|
||||||
// @author OpenScript
|
|
||||||
// @match https://github.com/*/*
|
// @match https://github.com/*/*
|
||||||
// @grant none
|
|
||||||
// ==/UserScript==
|
// ==/UserScript==
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const cache = new Map();
|
|
||||||
const ROW_ID = 'openscript-repo-size-about';
|
const ROW_ID = 'openscript-repo-size-about';
|
||||||
|
const SIZE_CLASS = 'openscript-file-size';
|
||||||
|
const TABLE_SELECTOR = 'table[aria-labelledby="folders-and-files"]';
|
||||||
|
|
||||||
// Format KB to readable size
|
const formatBytes = bytes => {
|
||||||
const formatKb = kb => {
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
if (kb <= 0) return '0 KB (calculating...)';
|
let size = bytes;
|
||||||
if (kb < 1024) return `${kb} KB`;
|
let unit = 0;
|
||||||
if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB`;
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
return `${(kb / (1024 * 1024)).toFixed(2)} GB`;
|
size /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${unit && size < 10 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract owner & repo from path
|
|
||||||
const getRepoInfo = () => {
|
const getRepoInfo = () => {
|
||||||
const [, owner, repo] = location.pathname.split('/');
|
const [, owner, repo] = location.pathname.split('/');
|
||||||
const reserved = new Set([
|
const reserved = new Set([
|
||||||
'settings', 'orgs', 'organizations', 'notifications', 'search',
|
'settings', 'orgs', 'organizations', 'notifications', 'search',
|
||||||
'features', 'pricing', 'explore', 'marketplace', 'topics', 'trending'
|
'features', 'pricing', 'explore', 'marketplace', 'topics', 'trending',
|
||||||
]);
|
]);
|
||||||
return (owner && repo && !reserved.has(owner)) ? { owner, repo } : null;
|
return owner && repo && !reserved.has(owner) ? { owner, repo } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Safely retrieve GH_PAT from OpenScript environment
|
|
||||||
const getPat = () => {
|
const getPat = () => {
|
||||||
try {
|
const values = typeof OpenScript !== 'undefined' ? OpenScript.env :
|
||||||
const envObj = (typeof OpenScript !== 'undefined' && OpenScript?.env) ||
|
typeof env !== 'undefined' ? env : {};
|
||||||
(typeof env !== 'undefined' && env) ||
|
const found = Object.entries(values).find(([key, value]) =>
|
||||||
window.OpenScript?.env ||
|
/^(GH_PAT|GITHUB_PAT|GITHUB_TOKEN|PAT)$/i.test(key) && value
|
||||||
window.env ||
|
);
|
||||||
{};
|
return found ? String(found[1]).trim() : '';
|
||||||
for (const [k, v] of Object.entries(envObj)) {
|
|
||||||
if (/^(GH_PAT|GITHUB_PAT|GITHUB_TOKEN|PAT)$/i.test(k) && v) return String(v).trim();
|
|
||||||
}
|
|
||||||
if (typeof GM_getValue === 'function') {
|
|
||||||
const gm = GM_getValue('GH_PAT') || GM_getValue('gh_pat');
|
|
||||||
if (gm) return String(gm).trim();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch full repository size from GitHub API (no blobs API fallback)
|
const getHeaders = () => {
|
||||||
const fetchRepoSize = async (owner, repo) => {
|
const headers = {
|
||||||
const key = `${owner}/${repo}`;
|
Accept: 'application/vnd.github+json',
|
||||||
if (cache.has(key)) return cache.get(key);
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
};
|
||||||
const pat = getPat();
|
const pat = getPat();
|
||||||
const headers = { Accept: 'application/vnd.github.v3+json' };
|
|
||||||
if (pat) headers.Authorization = `Bearer ${pat}`;
|
if (pat) headers.Authorization = `Bearer ${pat}`;
|
||||||
|
return headers;
|
||||||
try {
|
|
||||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers });
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 404) return { text: 'private (missing GH_PAT)', title: 'Set GH_PAT secret in OpenScript for private repository access' };
|
|
||||||
if (res.status === 401) return { text: 'invalid GH_PAT', title: 'GH_PAT token was rejected by GitHub API' };
|
|
||||||
return { text: `API error (${res.status})`, title: `GitHub API returned ${res.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const kb = data.size ?? 0;
|
|
||||||
const isZero = kb === 0;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
text: formatKb(kb),
|
|
||||||
title: isZero
|
|
||||||
? 'GitHub is still calculating disk usage for this new/recent repo. Check back in a few minutes.'
|
|
||||||
: `Total repository disk usage (including full git history): ${kb.toLocaleString()} KB`
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isZero) cache.set(key, result);
|
const fetchRepoSize = async ({ owner, repo }) => {
|
||||||
return result;
|
try {
|
||||||
|
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
if (response.status === 404)
|
||||||
|
return { text: 'private (missing GH_PAT)', title: 'Set GH_PAT in OpenScript for private repository access' };
|
||||||
|
if (response.status === 401)
|
||||||
|
return { text: 'invalid GH_PAT', title: 'GitHub rejected the configured token' };
|
||||||
|
if (!response.ok)
|
||||||
|
return { text: `API error (${response.status})`, title: `GitHub API returned ${response.status}` };
|
||||||
|
|
||||||
|
const kb = (await response.json()).size || 0;
|
||||||
|
return kb ? {
|
||||||
|
text: formatBytes(kb * 1024),
|
||||||
|
title: `Total repository disk usage (including full git history): ${kb.toLocaleString()} KB`,
|
||||||
|
} : {
|
||||||
|
text: '0 KB (calculating...)',
|
||||||
|
title: 'GitHub is still calculating disk usage for this repository',
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { text: 'failed to load', title: 'Network or fetch error' };
|
return { text: 'failed to load', title: 'Network or fetch error' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Locate the GitHub "About" heading (works on React and classic GitHub pages)
|
const getCodeView = () => {
|
||||||
const findAboutHeading = () => {
|
try {
|
||||||
for (const h of document.querySelectorAll('h2')) {
|
const app = document.querySelector('react-app[app-name="code-view"]');
|
||||||
if (/^\s*About\s*$/i.test(h.textContent.trim())) return h;
|
const data = JSON.parse(app?.querySelector('script[type="application/json"]')?.textContent || '{}');
|
||||||
}
|
const payload = data.payload || {};
|
||||||
|
const route = payload.codeViewRepoRoute || payload.codeViewTreeRoute;
|
||||||
|
const info = getRepoInfo();
|
||||||
|
if (!info || !route?.tree?.items || !route.refInfo) return null;
|
||||||
|
|
||||||
|
const path = String(route.path || '').replace(/^\/+|\/+$/g, '');
|
||||||
|
const ref = route.refInfo.currentOid || route.refInfo.name;
|
||||||
|
return {
|
||||||
|
...info, path, ref, items: route.tree.items,
|
||||||
|
key: `${info.owner}/${info.repo}:${ref}:${path}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Insert or update size row in About section
|
const findAboutHeading = () => [...document.querySelectorAll('h2')].find(heading =>
|
||||||
|
/^\s*About\s*$/i.test(heading.textContent) &&
|
||||||
|
heading.closest('[data-component="SplitPageLayout.Pane"], .Layout-sidebar, .BorderGrid')
|
||||||
|
);
|
||||||
|
|
||||||
const updateAboutSize = async () => {
|
const updateAboutSize = async () => {
|
||||||
const info = getRepoInfo();
|
const info = getRepoInfo();
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
|
|
||||||
// Clean up any legacy badges
|
const key = `${info.owner}/${info.repo}`;
|
||||||
|
const existing = document.getElementById(ROW_ID);
|
||||||
|
if (existing?.dataset.repo === key) return;
|
||||||
|
existing?.remove();
|
||||||
document.getElementById('openscript-repo-size')?.remove();
|
document.getElementById('openscript-repo-size')?.remove();
|
||||||
|
|
||||||
if (document.getElementById(ROW_ID)) return;
|
|
||||||
|
|
||||||
const heading = findAboutHeading();
|
const heading = findAboutHeading();
|
||||||
if (!heading) return;
|
if (!heading) return;
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.id = ROW_ID;
|
row.id = ROW_ID;
|
||||||
|
row.dataset.repo = key;
|
||||||
row.className = 'mt-2 text-small color-fg-muted d-flex flex-items-center';
|
row.className = 'mt-2 text-small color-fg-muted d-flex flex-items-center';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" width="16" class="octicon octicon-database mr-2 color-fg-muted" fill="currentColor">
|
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" width="16" class="octicon octicon-database mr-2 color-fg-muted" fill="currentColor">
|
||||||
<path d="M1 3.5c0-.83.67-1.5 1.5-1.5h11c.83 0 1.5.67 1.5 1.5v9c0 .83-.67 1.5-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5v-9Zm1.5-.5a.5.5 0 0 0-.5.5V5h12V3.5a.5.5 0 0 0-.5-.5h-11ZM14 6H2v2h12V6Zm0 3H2v3.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V9Z"></path>
|
<path d="M1 3.5c0-.83.67-1.5 1.5-1.5h11c.83 0 1.5.67 1.5 1.5v9c0 .83-.67 1.5-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5v-9Zm1.5-.5a.5.5 0 0 0-.5.5V5h12V3.5a.5.5 0 0 0-.5-.5h-11ZM14 6H2v2h12V6Zm0 3H2v3.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V9Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span><strong class="size-val color-fg-default font-semibold">calculating...</strong> repo size</span>
|
<span><strong class="size-val color-fg-default font-semibold">calculating...</strong> repo size</span>`;
|
||||||
`;
|
|
||||||
|
|
||||||
// Place directly after description / about heading
|
|
||||||
const sibling = heading.nextElementSibling;
|
const sibling = heading.nextElementSibling;
|
||||||
if (sibling) sibling.insertAdjacentElement('afterend', row);
|
(sibling || heading).insertAdjacentElement('afterend', row);
|
||||||
else heading.insertAdjacentElement('afterend', row);
|
|
||||||
|
|
||||||
const sizeInfo = await fetchRepoSize(info.owner, info.repo);
|
const size = await fetchRepoSize(info);
|
||||||
const valEl = row.querySelector('.size-val');
|
if (!row.isConnected) return;
|
||||||
if (valEl) {
|
row.querySelector('.size-val').textContent = size.text;
|
||||||
valEl.textContent = sizeInfo.text;
|
row.title = size.title;
|
||||||
if (sizeInfo.title) row.setAttribute('title', sizeInfo.title);
|
};
|
||||||
|
|
||||||
|
const addFileSize = (row, name, bytes) => {
|
||||||
|
for (const cell of row.querySelectorAll(
|
||||||
|
'td.react-directory-row-name-cell-small-screen, td.react-directory-row-name-cell-large-screen'
|
||||||
|
)) {
|
||||||
|
const link = cell.querySelector('a.Link--primary[href*="/blob/"]');
|
||||||
|
const column = link?.closest('.react-directory-filename-column');
|
||||||
|
if (!column || link.title !== name || column.querySelector(`:scope > .${SIZE_CLASS}`)) continue;
|
||||||
|
|
||||||
|
const text = formatBytes(bytes);
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = `${SIZE_CLASS} color-fg-muted`;
|
||||||
|
badge.textContent = text;
|
||||||
|
badge.title = `File size: ${bytes.toLocaleString()} bytes`;
|
||||||
|
Object.assign(badge.style, {
|
||||||
|
flex: 'none', marginLeft: '8px', fontSize: '12px', fontWeight: '400', whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
column.append(badge);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Navigation handlers & periodic check during dynamic React sidebar hydration
|
const updateFileSizes = async () => {
|
||||||
const run = () => {
|
const view = getCodeView();
|
||||||
document.getElementById(ROW_ID)?.remove();
|
const table = document.querySelector(TABLE_SELECTOR);
|
||||||
updateAboutSize();
|
if (!view || !table) return;
|
||||||
let count = 0;
|
|
||||||
const timer = setInterval(() => {
|
const fileLinks = table.querySelectorAll(
|
||||||
if (document.getElementById(ROW_ID) || ++count > 10) clearInterval(timer);
|
'td[class*="react-directory-row-name-cell"] a.Link--primary[href*="/blob/"]'
|
||||||
else updateAboutSize();
|
);
|
||||||
}, 250);
|
if (table.dataset.openscriptFileSizes === view.key &&
|
||||||
|
table.querySelectorAll(`.${SIZE_CLASS}`).length === fileLinks.length) return;
|
||||||
|
|
||||||
|
if (table.dataset.openscriptFileSizes !== view.key)
|
||||||
|
table.querySelectorAll(`.${SIZE_CLASS}`).forEach(size => size.remove());
|
||||||
|
table.dataset.openscriptFileSizes = view.key;
|
||||||
|
|
||||||
|
const path = view.path ? `/${view.path.split('/').map(encodeURIComponent).join('/')}` : '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/${encodeURIComponent(view.owner)}/${encodeURIComponent(view.repo)}/contents${path}?ref=${encodeURIComponent(view.ref)}`,
|
||||||
|
{ headers: getHeaders() },
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const entries = await response.json();
|
||||||
|
if (!Array.isArray(entries) || getCodeView()?.key !== view.key || !table.isConnected) return;
|
||||||
|
|
||||||
|
const sizes = Object.fromEntries(entries.map(item => [item.path, item.size]));
|
||||||
|
const paths = Object.fromEntries(view.items
|
||||||
|
.filter(item => item.contentType === 'file')
|
||||||
|
.map(item => [item.name, item.path]));
|
||||||
|
|
||||||
|
for (const row of table.querySelectorAll('tbody tr')) {
|
||||||
|
const link = row.querySelector(
|
||||||
|
'td[class*="react-directory-row-name-cell"] a.Link--primary[href*="/blob/"]'
|
||||||
|
);
|
||||||
|
const name = link?.title;
|
||||||
|
const bytes = sizes[paths[name]];
|
||||||
|
if (Number.isFinite(bytes)) addFileSize(row, name, bytes);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave GitHub's UI untouched when the API is unavailable.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
['turbo:load', 'turbo:render', 'pjax:end', 'popstate'].forEach(ev =>
|
let scheduled;
|
||||||
window.addEventListener(ev, run)
|
const run = () => {
|
||||||
|
clearTimeout(scheduled);
|
||||||
|
scheduled = setTimeout(() => {
|
||||||
|
updateAboutSize();
|
||||||
|
updateFileSizes();
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
['turbo:load', 'turbo:render', 'pjax:end', 'popstate'].forEach(event =>
|
||||||
|
window.addEventListener(event, run)
|
||||||
);
|
);
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
const start = () => {
|
||||||
if (!document.getElementById(ROW_ID)) updateAboutSize();
|
new MutationObserver(run).observe(document.body, { childList: true, subtree: true });
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
run();
|
run();
|
||||||
})();
|
};
|
||||||
|
|
||||||
|
if (document.body) start();
|
||||||
|
else window.addEventListener('DOMContentLoaded', start, { once: true });
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
# Display Repo Size for GitHub (OpenScript Userscript)
|
# Display Repo and File Sizes for GitHub
|
||||||
|
|
||||||
A lightweight OpenScript userscript that displays the total repository size (including all commit history, branches, tags, and packfiles) directly in the **About** section on GitHub repository pages (for both public and private repositories).
|
A lightweight OpenScript that displays total repository disk usage in the **About** section and each file's size beside its name in GitHub's file browser.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **Accurate Git History Size**: Uses GitHub's native `repo.size` disk usage measurement, accurately reflecting the full Git history, delta compression, and packfiles rather than loose blobs.
|
- **Accurate Git History Size**: Uses GitHub's native `repo.size` disk usage measurement, accurately reflecting the full Git history, delta compression, and packfiles rather than loose blobs.
|
||||||
- **New Repo Handling**: If a repository was just pushed and GitHub is still computing initial disk usage (`0 KB`), it provides a friendly indicator (`0 KB (calculating...)`) until GitHub finishes indexing.
|
- **New Repo Handling**: If a repository was just pushed and GitHub is still computing initial disk usage (`0 KB`), it provides a friendly indicator (`0 KB (calculating...)`) until GitHub finishes indexing.
|
||||||
- **Integrated in About Section**: Injects cleanly under repository details in the right sidebar.
|
- **Integrated in About Section**: Injects cleanly under repository details in the right sidebar.
|
||||||
|
- **Individual File Sizes**: Shows human-readable byte sizes beside files in root and nested directory listings.
|
||||||
|
- **GitHub-Native Layout**: Targets only responsive filename cells, leaving commit messages, dates, links, and directory rows untouched.
|
||||||
- **Private Repositories Supported**: Uses `GH_PAT` from OpenScript secrets for private repositories and increased rate limits.
|
- **Private Repositories Supported**: Uses `GH_PAT` from OpenScript secrets for private repositories and increased rate limits.
|
||||||
- **Turbo / SPA Compatible**: Seamlessly persists across GitHub's Turbo and client-side page transitions.
|
- **Turbo / SPA Compatible**: Seamlessly persists across GitHub's Turbo and client-side page transitions.
|
||||||
|
- **No Cache or Script Storage**: Reads fresh repository and directory data from GitHub's API for each rendered view.
|
||||||
|
|
||||||
## Installation in OpenScript
|
## Installation in OpenScript
|
||||||
1. Open the **OpenScript** extension popup.
|
1. Open the **OpenScript** extension popup.
|
||||||
|
|||||||
Reference in New Issue
Block a user