From 1d8289d611d66c8773519ef74cae1a38ba822b8f Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Fri, 11 Sep 2026 13:07:54 -0700 Subject: [PATCH] Rename to DisplayRepoInfo.os.js and show repo age with creation time tooltip --- DisplayRepoInfo.os.js | 216 ++++++++++++++++++++++++++++++++++ DisplayRepoSizeGitHub.user.js | 214 --------------------------------- README.md | 7 +- 3 files changed, 220 insertions(+), 217 deletions(-) create mode 100644 DisplayRepoInfo.os.js delete mode 100644 DisplayRepoSizeGitHub.user.js diff --git a/DisplayRepoInfo.os.js b/DisplayRepoInfo.os.js new file mode 100644 index 0000000..0217b38 --- /dev/null +++ b/DisplayRepoInfo.os.js @@ -0,0 +1,216 @@ +// ==UserScript== +// @name Display Repo Info and File Sizes for GitHub +// @description Displays repository disk usage, age, and individual file sizes on GitHub. +// @match https://github.com/*/* +// ==/UserScript== + +const ROW_ID = 'openscript-repo-info-about'; +const SIZE_CLASS = 'openscript-file-size'; +const TABLE_SELECTOR = 'table[aria-labelledby="folders-and-files"]'; + +const formatBytes = b => { + const u = ['B', 'KB', 'MB', 'GB']; + let i = 0; + for (; b >= 1024 && i < 3; i++) b /= 1024; + return `${i && b < 10 ? b.toFixed(1) : Math.round(b)} ${u[i]}`; +}; + +const formatAge = d => { + const days = Math.max(0, Math.floor((Date.now() - new Date(d)) / 864e5)); + const y = Math.floor(days / 365.25); + const m = Math.floor((days % 365.25) / 30.44); + if (y) return `${y} year${y > 1 ? 's' : ''}${m ? `, ${m} month${m > 1 ? 's' : ''}` : ''}`; + if (m) return `${m} month${m > 1 ? 's' : ''}`; + return `${days || '< 1'} day${days === 1 ? '' : 's'}`; +}; + +const getRepoInfo = () => { + const [, owner, repo] = location.pathname.split('/'); + return owner && repo && !/^(settings|orgs|organizations|notifications|search|features|pricing|explore|marketplace|topics|trending)$/i.test(owner) + ? { owner, repo } : null; +}; + +const getPat = () => { + const envObj = typeof OpenScript !== 'undefined' ? OpenScript.env : typeof env !== 'undefined' ? env : {}; + const [, val] = Object.entries(envObj || {}).find(([k, v]) => /^(GH_PAT|GITHUB_PAT|GITHUB_TOKEN|PAT)$/i.test(k) && v) || []; + return val ? String(val).trim() : ''; +}; + +const getHeaders = () => { + const pat = getPat(); + return { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(pat && { Authorization: `Bearer ${pat}` }), + }; +}; + +const fetchRepoInfo = async ({ owner, repo }) => { + try { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: getHeaders(), + }); + if (res.status === 404) + return { err: 'private (missing GH_PAT)', tip: 'Set GH_PAT in OpenScript for private repository access' }; + if (res.status === 401) + return { err: 'invalid GH_PAT', tip: 'GitHub rejected the configured token' }; + if (!res.ok) + return { err: `API error (${res.status})`, tip: `GitHub API returned ${res.status}` }; + + const { size: kb = 0, created_at: created } = await res.json(); + return { + size: { + text: kb ? formatBytes(kb * 1024) : '0 KB (calculating...)', + tip: kb ? `Total repository disk usage (including full git history): ${kb.toLocaleString()} KB` + : 'GitHub is still calculating disk usage for this repository', + }, + age: created ? { + text: formatAge(created), + tip: `Created: ${new Date(created).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'long' })}`, + } : null, + }; + } catch { + return { err: 'failed to load', tip: '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 route = data.payload?.codeViewRepoRoute || data.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(h => + /^\s*About\s*$/i.test(h.textContent) && + h.closest('[data-component="SplitPageLayout.Pane"], .Layout-sidebar, .BorderGrid') +); + +const updateAboutInfo = 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-about')?.remove(); + document.getElementById('openscript-repo-size')?.remove(); + + const heading = findAboutHeading(); + if (!heading) return; + + const container = document.createElement('div'); + container.id = ROW_ID; + container.dataset.repo = key; + container.innerHTML = ` +
+ + calculating... repo size +
+
+ + calculating... repo age +
`; + + const sibling = heading.nextElementSibling; + (sibling || heading).insertAdjacentElement('afterend', container); + + const res = await fetchRepoInfo(info); + if (!container.isConnected) return; + const sRow = container.querySelector('.openscript-size-row'); + const aRow = container.querySelector('.openscript-age-row'); + if (res.err) { + sRow.querySelector('.size-val').textContent = res.err; + sRow.title = res.tip; + aRow.remove(); + } else { + sRow.querySelector('.size-val').textContent = res.size.text; + sRow.title = res.size.tip; + if (res.age) { + aRow.querySelector('.age-val').textContent = res.age.text; + aRow.title = res.age.tip; + } else aRow.remove(); + } +}; + +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 col = link?.closest('.react-directory-filename-column'); + if (!col || link.title !== name || col.querySelector(`:scope > .${SIZE_CLASS}`)) continue; + + const badge = document.createElement('span'); + badge.className = `${SIZE_CLASS} color-fg-muted`; + badge.textContent = formatBytes(bytes); + badge.title = `File size: ${bytes.toLocaleString()} bytes`; + Object.assign(badge.style, { flex: 'none', marginLeft: '8px', fontSize: '12px', fontWeight: '400', whiteSpace: 'nowrap' }); + col.append(badge); + } +}; + +const updateFileSizes = async () => { + const view = getCodeView(); + const table = document.querySelector(TABLE_SELECTOR); + if (!view || !table) return; + + const links = 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 === links.length) return; + + if (table.dataset.openscriptFileSizes !== view.key) + table.querySelectorAll(`.${SIZE_CLASS}`).forEach(el => el.remove()); + table.dataset.openscriptFileSizes = view.key; + + const path = view.path ? `/${view.path.split('/').map(encodeURIComponent).join('/')}` : ''; + try { + const res = await fetch( + `https://api.github.com/repos/${encodeURIComponent(view.owner)}/${encodeURIComponent(view.repo)}/contents${path}?ref=${encodeURIComponent(view.ref)}`, + { headers: getHeaders() }, + ); + if (!res.ok) return; + const entries = await res.json(); + if (!Array.isArray(entries) || getCodeView()?.key !== view.key || !table.isConnected) return; + + const sizes = Object.fromEntries(entries.map(i => [i.path, i.size])); + const paths = Object.fromEntries(view.items.filter(i => i.contentType === 'file').map(i => [i.name, i.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 bytes = sizes[paths[link?.title]]; + if (Number.isFinite(bytes)) addFileSize(row, link.title, bytes); + } + } catch {} +}; + +let scheduled; +const run = () => { + clearTimeout(scheduled); + scheduled = setTimeout(() => { + updateAboutInfo(); + updateFileSizes(); + }, 50); +}; + +['turbo:load', 'turbo:render', 'pjax:end', 'popstate'].forEach(ev => + window.addEventListener(ev, 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/DisplayRepoSizeGitHub.user.js b/DisplayRepoSizeGitHub.user.js deleted file mode 100644 index b55224a..0000000 --- a/DisplayRepoSizeGitHub.user.js +++ /dev/null @@ -1,214 +0,0 @@ -// ==UserScript== -// @name Display Repo and File Sizes for GitHub -// @description Displays repository disk usage and individual file sizes on GitHub. -// @match https://github.com/*/* -// ==/UserScript== - -const ROW_ID = 'openscript-repo-size-about'; -const SIZE_CLASS = 'openscript-file-size'; -const TABLE_SELECTOR = 'table[aria-labelledby="folders-and-files"]'; - -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]}`; -}; - -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; -}; - -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 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; -}; - -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 86b5cbf..49bc0c9 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -# Display Repo and File Sizes for GitHub +# Display Repo Info and File Sizes for GitHub -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. +A lightweight OpenScript that displays total repository disk usage and age 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. +- **Repository Age & Creation Timestamp**: Shows repository age in the About sidebar and displays the exact creation date and time with timezone on hover. - **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. @@ -15,7 +16,7 @@ A lightweight OpenScript that displays total repository disk usage in the **Abou ## Installation in OpenScript 1. Open the **OpenScript** extension popup. 2. Click **+ New** in the header (or click your existing script to edit). -3. Paste the contents of [`DisplayRepoSizeGitHub.user.js`](./DisplayRepoSizeGitHub.user.js). +3. Paste the contents of [`DisplayRepoInfo.os.js`](./DisplayRepoInfo.os.js). 4. Click **save script**. ## GitHub PAT Configuration (for Private Repos)