diff --git a/DisplayRepoSizeGitHub.user.js b/DisplayRepoSizeGitHub.user.js index a7c8569..b55224a 100644 --- a/DisplayRepoSizeGitHub.user.js +++ b/DisplayRepoSizeGitHub.user.js @@ -1,155 +1,214 @@ // ==UserScript== -// @name Display Repo Size for GitHub -// @version 1.3.0 -// @description Displays total repository size (including full git history) inside the About section on GitHub using GH_PAT. -// @author OpenScript +// @name Display Repo and File Sizes for GitHub +// @description Displays repository disk usage and individual file sizes on GitHub. // @match https://github.com/*/* -// @grant none // ==/UserScript== -(function() { - 'use strict'; +const ROW_ID = 'openscript-repo-size-about'; +const SIZE_CLASS = 'openscript-file-size'; +const TABLE_SELECTOR = 'table[aria-labelledby="folders-and-files"]'; - const cache = new Map(); - const ROW_ID = 'openscript-repo-size-about'; +const formatBytes = bytes => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit++; + } + return `${unit && size < 10 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`; +}; - // Format KB to readable size - const formatKb = kb => { - if (kb <= 0) return '0 KB (calculating...)'; - if (kb < 1024) return `${kb} KB`; - if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB`; - return `${(kb / (1024 * 1024)).toFixed(2)} GB`; - }; +const getRepoInfo = () => { + const [, owner, repo] = location.pathname.split('/'); + const reserved = new Set([ + 'settings', 'orgs', 'organizations', 'notifications', 'search', + 'features', 'pricing', 'explore', 'marketplace', 'topics', 'trending', + ]); + return owner && repo && !reserved.has(owner) ? { owner, repo } : null; +}; - // Extract owner & repo from path - const getRepoInfo = () => { - const [, owner, repo] = location.pathname.split('/'); - const reserved = new Set([ - 'settings', 'orgs', 'organizations', 'notifications', 'search', - 'features', 'pricing', 'explore', 'marketplace', 'topics', 'trending' - ]); - return (owner && repo && !reserved.has(owner)) ? { owner, repo } : null; - }; - - // Safely retrieve GH_PAT from OpenScript environment - const getPat = () => { - try { - const envObj = (typeof OpenScript !== 'undefined' && OpenScript?.env) || - (typeof env !== 'undefined' && env) || - window.OpenScript?.env || - window.env || - {}; - 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 fetchRepoSize = async (owner, repo) => { - const key = `${owner}/${repo}`; - if (cache.has(key)) return cache.get(key); - - const pat = getPat(); - const headers = { Accept: 'application/vnd.github.v3+json' }; - if (pat) headers.Authorization = `Bearer ${pat}`; - - 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); - return result; - } catch { - return { text: 'failed to load', title: 'Network or fetch error' }; - } - }; - - // Locate the GitHub "About" heading (works on React and classic GitHub pages) - const findAboutHeading = () => { - for (const h of document.querySelectorAll('h2')) { - if (/^\s*About\s*$/i.test(h.textContent.trim())) return h; - } - return null; - }; - - // Insert or update size row in About section - const updateAboutSize = async () => { - const info = getRepoInfo(); - if (!info) return; - - // Clean up any legacy badges - document.getElementById('openscript-repo-size')?.remove(); - - if (document.getElementById(ROW_ID)) return; - - const heading = findAboutHeading(); - if (!heading) return; - - const row = document.createElement('div'); - row.id = ROW_ID; - row.className = 'mt-2 text-small color-fg-muted d-flex flex-items-center'; - row.innerHTML = ` - - calculating... repo size - `; - - // Place directly after description / about heading - const sibling = heading.nextElementSibling; - if (sibling) sibling.insertAdjacentElement('afterend', row); - else heading.insertAdjacentElement('afterend', row); - - const sizeInfo = await fetchRepoSize(info.owner, info.repo); - const valEl = row.querySelector('.size-val'); - if (valEl) { - valEl.textContent = sizeInfo.text; - if (sizeInfo.title) row.setAttribute('title', sizeInfo.title); - } - }; - - // Navigation handlers & periodic check during dynamic React sidebar hydration - const run = () => { - document.getElementById(ROW_ID)?.remove(); - updateAboutSize(); - let count = 0; - const timer = setInterval(() => { - if (document.getElementById(ROW_ID) || ++count > 10) clearInterval(timer); - else updateAboutSize(); - }, 250); - }; - - ['turbo:load', 'turbo:render', 'pjax:end', 'popstate'].forEach(ev => - window.addEventListener(ev, run) +const getPat = () => { + const values = typeof OpenScript !== 'undefined' ? OpenScript.env : + typeof env !== 'undefined' ? env : {}; + const found = Object.entries(values).find(([key, value]) => + /^(GH_PAT|GITHUB_PAT|GITHUB_TOKEN|PAT)$/i.test(key) && value ); + return found ? String(found[1]).trim() : ''; +}; - const observer = new MutationObserver(() => { - if (!document.getElementById(ROW_ID)) updateAboutSize(); - }); +const getHeaders = () => { + const headers = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + const pat = getPat(); + if (pat) headers.Authorization = `Bearer ${pat}`; + return headers; +}; - observer.observe(document.body, { childList: true, subtree: true }); +const fetchRepoSize = async ({ owner, repo }) => { + 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 { + return { text: 'failed to load', title: 'Network or fetch error' }; + } +}; + +const getCodeView = () => { + try { + const app = document.querySelector('react-app[app-name="code-view"]'); + 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; + } +}; + +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 info = getRepoInfo(); + if (!info) return; + + 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(); + + const heading = findAboutHeading(); + if (!heading) return; + + const row = document.createElement('div'); + row.id = ROW_ID; + row.dataset.repo = key; + row.className = 'mt-2 text-small color-fg-muted d-flex flex-items-center'; + row.innerHTML = ` + + calculating... repo size`; + + const sibling = heading.nextElementSibling; + (sibling || heading).insertAdjacentElement('afterend', row); + + const size = await fetchRepoSize(info); + if (!row.isConnected) return; + row.querySelector('.size-val').textContent = size.text; + row.title = size.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); + } +}; + +const updateFileSizes = async () => { + const view = getCodeView(); + const table = document.querySelector(TABLE_SELECTOR); + if (!view || !table) return; + + const fileLinks = table.querySelectorAll( + 'td[class*="react-directory-row-name-cell"] a.Link--primary[href*="/blob/"]' + ); + 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. + } +}; + +let scheduled; +const run = () => { + clearTimeout(scheduled); + scheduled = setTimeout(() => { + updateAboutSize(); + updateFileSizes(); + }, 50); +}; + +['turbo:load', 'turbo:render', 'pjax:end', 'popstate'].forEach(event => + window.addEventListener(event, run) +); + +const start = () => { + new MutationObserver(run).observe(document.body, { childList: true, subtree: true }); run(); -})(); +}; + +if (document.body) start(); +else window.addEventListener('DOMContentLoaded', start, { once: true }); diff --git a/README.md b/README.md index 1d9c313..86b5cbf 100644 --- a/README.md +++ b/README.md @@ -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 - **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. - **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. - **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 1. Open the **OpenScript** extension popup.