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)