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.