mirror of
https://github.com/GetOpenScript/DisplayRepoSizeGitHub.openscript.git
synced 2026-09-18 10:35:42 +00:00
Add directory downloads and fix GitHub SPA support
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
const ROW_ID = 'openscript-repo-info-about';
|
||||
const SIZE_CLASS = 'openscript-file-size';
|
||||
const TABLE_SELECTOR = 'table[aria-labelledby="folders-and-files"]';
|
||||
const SIZE_LOADS = new WeakMap();
|
||||
|
||||
const formatBytes = b => {
|
||||
const u = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -74,16 +75,27 @@ const fetchRepoInfo = async ({ owner, repo }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const decode = value => {
|
||||
try { return decodeURIComponent(value); }
|
||||
catch { return value; }
|
||||
};
|
||||
|
||||
const getRefPath = () => {
|
||||
const ref = document.querySelector('#ref-picker-repos-header-ref-selector')?.textContent.trim();
|
||||
if (!ref) return null;
|
||||
const parts = location.pathname.split('/').slice(1).map(decode);
|
||||
if (parts[2] !== 'tree') return { ref, path: '' };
|
||||
const tail = parts.slice(3).join('/');
|
||||
if (tail !== ref && !tail.startsWith(`${ref}/`)) return null;
|
||||
return { ref, path: tail.slice(ref.length).replace(/^\/+/, '') };
|
||||
};
|
||||
|
||||
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}` };
|
||||
const current = getRefPath();
|
||||
if (!info || !current) return null;
|
||||
return { ...info, ...current, key: `${info.owner}/${info.repo}:${current.ref}:${current.path}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -167,11 +179,13 @@ const updateFileSizes = async () => {
|
||||
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}`).length === links.length || SIZE_LOADS.get(table) === view.key)) return;
|
||||
|
||||
if (table.dataset.openscriptFileSizes !== view.key)
|
||||
table.querySelectorAll(`.${SIZE_CLASS}`).forEach(el => el.remove());
|
||||
table.dataset.openscriptFileSizes = view.key;
|
||||
SIZE_LOADS.set(table, view.key);
|
||||
|
||||
const path = view.path ? `/${view.path.split('/').map(encodeURIComponent).join('/')}` : '';
|
||||
try {
|
||||
@@ -183,15 +197,17 @@ const updateFileSizes = async () => {
|
||||
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]));
|
||||
const sizes = Object.fromEntries(entries.map(item => [item.name, item.size]));
|
||||
|
||||
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]];
|
||||
const bytes = sizes[link?.title];
|
||||
if (Number.isFinite(bytes)) addFileSize(row, link.title, bytes);
|
||||
}
|
||||
} catch {}
|
||||
finally {
|
||||
if (SIZE_LOADS.get(table) === view.key) SIZE_LOADS.delete(table);
|
||||
}
|
||||
};
|
||||
|
||||
let scheduled;
|
||||
@@ -208,9 +224,9 @@ const run = () => {
|
||||
);
|
||||
|
||||
const start = () => {
|
||||
new MutationObserver(run).observe(document.body, { childList: true, subtree: true });
|
||||
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
|
||||
run();
|
||||
};
|
||||
|
||||
if (document.body) start();
|
||||
if (document.documentElement) start();
|
||||
else window.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
|
||||
245
DownloadDir.os.js
Normal file
245
DownloadDir.os.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// ==UserScript==
|
||||
// @name Download GitHub Directory
|
||||
// @description Adds a rate-limit-friendly directory download option to GitHub.
|
||||
// @match https://github.com/*/*
|
||||
// @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
|
||||
// ==/UserScript==
|
||||
|
||||
const ITEM_ATTR = 'data-openscript-download-dir';
|
||||
const MENU_BUTTON = 'button[data-testid="tree-overflow-menu-anchor"]';
|
||||
let active = false;
|
||||
|
||||
const getPat = () => {
|
||||
const source = typeof OpenScript !== 'undefined' ? OpenScript.env : typeof env !== 'undefined' ? env : {};
|
||||
const [, value] = Object.entries(source || {}).find(([key, val]) =>
|
||||
/^(GH_PAT|GITHUB_PAT|GITHUB_TOKEN|PAT)$/i.test(key) && val
|
||||
) || [];
|
||||
return value ? String(value).trim() : '';
|
||||
};
|
||||
|
||||
const decode = value => {
|
||||
try { return decodeURIComponent(value); }
|
||||
catch { return value; }
|
||||
};
|
||||
|
||||
const getView = () => {
|
||||
try {
|
||||
const parts = location.pathname.split('/').slice(1).map(decode);
|
||||
if (parts[2] !== 'tree') return null;
|
||||
const ref = document.querySelector('#ref-picker-repos-header-ref-selector')?.textContent.trim();
|
||||
const tail = parts.slice(3).join('/');
|
||||
if (!ref || tail === ref || !tail.startsWith(`${ref}/`)) return null;
|
||||
|
||||
const app = document.querySelector('react-app[app-name="code-view"]');
|
||||
const payload = JSON.parse(app?.querySelector('script[type="application/json"]')?.textContent || '{}').payload;
|
||||
const info = payload?.codeViewLayoutRoute?.repo;
|
||||
const commit = document.querySelector('a[aria-label^="Commit "][href*="/commit/"]')
|
||||
?.getAttribute('href')?.match(/\/commit\/([0-9a-f]{40,64})(?:$|[/?#])/i)?.[1];
|
||||
return {
|
||||
owner: parts[0], repo: parts[1], ref,
|
||||
path: tail.slice(ref.length + 1), oid: commit || ref,
|
||||
private: info?.private === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const api = async (view, path, accept = 'application/vnd.github+json') => {
|
||||
const pat = getPat();
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${encodeURIComponent(view.owner)}/${encodeURIComponent(view.repo)}${path}`,
|
||||
{ headers: {
|
||||
Accept: accept,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...(pat && { Authorization: `Bearer ${pat}` }),
|
||||
} },
|
||||
);
|
||||
if (res.ok) return res;
|
||||
|
||||
if (res.status === 403 && res.headers.get('X-RateLimit-Remaining') === '0')
|
||||
throw new Error('GitHub API rate limit reached. Add GH_PAT in OpenScript or try again after the reset.');
|
||||
if (res.status === 404 && !pat)
|
||||
throw new Error('Directory data is unavailable. Private repositories require GH_PAT in OpenScript.');
|
||||
throw new Error(`GitHub API request failed (${res.status}).`);
|
||||
};
|
||||
|
||||
const tree = async (view, sha, recursive = false) =>
|
||||
(await api(view, `/git/trees/${encodeURIComponent(sha)}${recursive ? '?recursive=1' : ''}`)).json();
|
||||
|
||||
const walkTree = async (view) => {
|
||||
let sha = view.oid;
|
||||
for (const name of view.path.split('/')) {
|
||||
const data = await tree(view, sha);
|
||||
const next = data.tree?.find(item => item.type === 'tree' && item.path === name);
|
||||
if (!next) throw new Error('Could not locate this directory in the repository tree.');
|
||||
sha = next.sha;
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const queue = [{ sha, path: '' }];
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
const data = await tree(view, current.sha);
|
||||
for (const item of data.tree || []) {
|
||||
const path = current.path ? `${current.path}/${item.path}` : item.path;
|
||||
if (item.type === 'tree') queue.push({ sha: item.sha, path });
|
||||
else if (item.type === 'blob') files.push({ ...item, path: `${view.path}/${path}`, relative: path });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const listFiles = async view => {
|
||||
const data = await tree(view, view.oid, true);
|
||||
if (data.truncated) return walkTree(view);
|
||||
|
||||
const prefix = `${view.path}/`;
|
||||
return (data.tree || [])
|
||||
.filter(item => item.type === 'blob' && item.path?.startsWith(prefix))
|
||||
.map(item => ({ ...item, relative: item.path.slice(prefix.length) }));
|
||||
};
|
||||
|
||||
const fileData = async (view, file) => {
|
||||
if (!view.private) {
|
||||
const path = file.path.split('/').map(encodeURIComponent).join('/');
|
||||
try {
|
||||
const raw = await fetch(
|
||||
`https://raw.githubusercontent.com/${encodeURIComponent(view.owner)}/${encodeURIComponent(view.repo)}/${view.oid}/${path}`
|
||||
);
|
||||
if (raw.ok) return raw.arrayBuffer();
|
||||
} catch {}
|
||||
}
|
||||
return (await api(view, `/git/blobs/${encodeURIComponent(file.sha)}`, 'application/vnd.github.raw+json')).arrayBuffer();
|
||||
};
|
||||
|
||||
const notice = (message, error = false) => {
|
||||
const box = document.createElement('div');
|
||||
box.className = `flash ${error ? 'flash-error' : 'flash-success'}`;
|
||||
box.setAttribute('role', error ? 'alert' : 'status');
|
||||
box.textContent = message;
|
||||
Object.assign(box.style, {
|
||||
position: 'fixed', top: '72px', right: '16px', zIndex: 2147483647,
|
||||
maxWidth: '420px', boxShadow: 'var(--shadow-floating-small)',
|
||||
});
|
||||
document.body.append(box);
|
||||
setTimeout(() => box.remove(), error ? 9000 : 5000);
|
||||
};
|
||||
|
||||
const saveZip = async (view, setLabel) => {
|
||||
if (view.private && !getPat())
|
||||
throw new Error('Private repositories require GH_PAT in OpenScript.');
|
||||
if (!globalThis.JSZip) throw new Error('JSZip did not load. Re-save the script to refresh its @require cache.');
|
||||
|
||||
setLabel('Reading directory…');
|
||||
const files = await listFiles(view);
|
||||
if (!files.length) throw new Error('This directory has no downloadable files.');
|
||||
|
||||
const root = view.path.split('/').at(-1);
|
||||
const zip = new JSZip();
|
||||
let done = 0;
|
||||
let failure;
|
||||
const queue = [...files];
|
||||
const worker = async () => {
|
||||
while (queue.length && !failure) {
|
||||
const file = queue.shift();
|
||||
try {
|
||||
zip.file(`${root}/${file.relative}`, await fileData(view, file));
|
||||
setLabel(`Downloading ${++done}/${files.length}…`);
|
||||
} catch (error) {
|
||||
failure ||= error;
|
||||
queue.length = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(6, files.length) }, worker));
|
||||
if (failure) throw failure;
|
||||
|
||||
setLabel('Creating ZIP…');
|
||||
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const safe = `${view.repo}-${view.path}-${view.oid.slice(0, 7)}`.replace(/[^\w.-]+/g, '-');
|
||||
link.href = url;
|
||||
link.download = `${safe}.zip`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
notice(`Downloaded ${root} (${files.length.toLocaleString()} files).`);
|
||||
};
|
||||
|
||||
const download = async (item, label) => {
|
||||
if (active) return notice('Another directory download is already running.', true);
|
||||
const view = getView();
|
||||
if (!view) return notice('Could not read the current GitHub directory.', true);
|
||||
|
||||
active = true;
|
||||
item.setAttribute('aria-disabled', 'true');
|
||||
const setLabel = text => { if (label.isConnected) label.textContent = text; };
|
||||
try {
|
||||
await saveZip(view, setLabel);
|
||||
} catch (error) {
|
||||
notice(error?.message || 'Directory download failed.', true);
|
||||
} finally {
|
||||
active = false;
|
||||
item.removeAttribute('aria-disabled');
|
||||
setLabel('Download directory');
|
||||
}
|
||||
};
|
||||
|
||||
const inject = () => {
|
||||
const view = getView();
|
||||
const anchor = document.querySelector(MENU_BUTTON);
|
||||
if (!view || !anchor) return;
|
||||
|
||||
const labelledBy = anchor.getAttribute('aria-labelledby');
|
||||
const menu = [...document.querySelectorAll('ul[role="menu"]')].find(el =>
|
||||
el.getAttribute('aria-labelledby') === labelledBy
|
||||
);
|
||||
if (!menu || menu.querySelector(`[${ITEM_ATTR}]`)) return;
|
||||
|
||||
const source = [...menu.children].find(el =>
|
||||
el.getAttribute('role') === 'menuitem' && /Copy permalink/i.test(el.textContent)
|
||||
) || menu.querySelector(':scope > li[role="menuitem"]');
|
||||
if (!source) return;
|
||||
|
||||
const item = source.cloneNode(true);
|
||||
item.setAttribute(ITEM_ATTR, '');
|
||||
item.setAttribute('aria-label', 'Download directory');
|
||||
item.setAttribute('tabindex', '-1');
|
||||
item.removeAttribute('aria-labelledby');
|
||||
item.removeAttribute('aria-keyshortcuts');
|
||||
item.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
|
||||
item.querySelector('[data-component="ActionList.TrailingVisual"]')?.remove();
|
||||
const label = item.querySelector('[data-component="ActionList.Item.Label"]');
|
||||
if (!label) return;
|
||||
label.textContent = 'Download directory';
|
||||
item.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
download(item, label);
|
||||
});
|
||||
|
||||
const deletion = [...menu.children].find(el => el.querySelector('a[href*="/tree/delete/"]'));
|
||||
const divider = [...menu.children].find(el => el.dataset.component === 'ActionList.Divider');
|
||||
if (deletion) menu.insertBefore(item, deletion);
|
||||
else if (divider) divider.after(item);
|
||||
else menu.prepend(item);
|
||||
};
|
||||
|
||||
let scheduled;
|
||||
const run = () => {
|
||||
clearTimeout(scheduled);
|
||||
scheduled = setTimeout(inject, 30);
|
||||
};
|
||||
|
||||
['turbo:load', 'turbo:render', 'pjax:end', 'popstate'].forEach(event =>
|
||||
window.addEventListener(event, run)
|
||||
);
|
||||
|
||||
const start = () => {
|
||||
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
|
||||
run();
|
||||
};
|
||||
|
||||
if (document.documentElement) start();
|
||||
else window.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
71
README.md
71
README.md
@@ -1,28 +1,55 @@
|
||||
# Display Repo Info and File Sizes for GitHub
|
||||
# GitHub OpenScripts
|
||||
|
||||
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.
|
||||
Small, independent [OpenScript](https://github.com/GetOpenScript/OpenScript) enhancements for GitHub. Install either script or both.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
## Scripts
|
||||
|
||||
### Display Repo Info and File Sizes
|
||||
|
||||
[`DisplayRepoInfo.os.js`](./DisplayRepoInfo.os.js) adds repository information and file sizes directly to GitHub's interface.
|
||||
|
||||
- Shows total repository disk usage and repository age in the **About** sidebar.
|
||||
- Shows the exact repository creation date and time on hover.
|
||||
- Adds human-readable sizes beside files in root and nested directory listings.
|
||||
- Handles GitHub SPA navigation without requiring a page refresh.
|
||||
- Uses GitHub-native layout, colors, and responsive filename cells.
|
||||
- Supports private repositories through an optional `GH_PAT` secret.
|
||||
|
||||
This script requests repository metadata when the **About** section is rendered and directory contents when a GitHub file listing is rendered. It guards in-progress directory requests to avoid duplicate API calls during DOM updates.
|
||||
|
||||
### Download GitHub Directory
|
||||
|
||||
[`DownloadDir.os.js`](./DownloadDir.os.js) adds **Download directory** to the three-dot menu on GitHub directory pages.
|
||||
|
||||
- Makes no GitHub requests on page load, navigation, or menu opening.
|
||||
- Starts all download-related requests only after **Download directory** is clicked.
|
||||
- Uses one recursive Git Trees API request for a normal public repository, then retrieves file bytes from `raw.githubusercontent.com` without spending additional REST API quota.
|
||||
- Falls back to additional tree requests only when GitHub truncates an unusually large recursive tree response.
|
||||
- Downloads private repository files through authenticated Git blob requests.
|
||||
- Builds the ZIP locally in the browser with JSZip and preserves the selected directory as its root folder.
|
||||
- Handles GitHub SPA navigation and branch names containing slashes.
|
||||
|
||||
OpenScript downloads and caches the declared JSZip `@require` when the script is saved. It is not downloaded again on every GitHub page.
|
||||
|
||||
## Installation
|
||||
|
||||
Each file is a separate OpenScript:
|
||||
|
||||
## 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 [`DisplayRepoInfo.os.js`](./DisplayRepoInfo.os.js).
|
||||
2. Click **+ New**.
|
||||
3. Paste the contents of the script you want to install.
|
||||
4. Click **save script**.
|
||||
5. Repeat for the other script if you want both features.
|
||||
|
||||
## GitHub PAT Configuration (for Private Repos)
|
||||
1. Generate a GitHub Personal Access Token (`repo` scope for private repos) at [github.com/settings/tokens](https://github.com/settings/tokens).
|
||||
2. Open **OpenScript** and switch to the **Secrets** tab.
|
||||
3. Add a secret:
|
||||
- **Key**: `GH_PAT`
|
||||
- **Value**: `<your_token>`
|
||||
4. Click **+ Add**. OpenScript synchronizes the secret via `chrome.storage.sync` and securely provides it to your script as `OpenScript.env.GH_PAT`.
|
||||
After updating an installed script, save it again and refresh the current GitHub page once. Later GitHub navigation works without refreshing.
|
||||
|
||||
## Private Repositories
|
||||
|
||||
Both scripts support a GitHub personal access token stored in OpenScript:
|
||||
|
||||
1. Create a GitHub token with read access to the required private repositories.
|
||||
2. Open OpenScript and select the **Secrets** tab.
|
||||
3. Add `GH_PAT` as the key and the token as its value.
|
||||
4. Click **+ Add**, then re-save the scripts.
|
||||
|
||||
The scripts also recognize `GITHUB_PAT`, `GITHUB_TOKEN`, and `PAT`. Tokens are sent only to `api.github.com` and are never included in generated ZIP files.
|
||||
|
||||
Reference in New Issue
Block a user