Add directory downloads and fix GitHub SPA support

This commit is contained in:
2026-09-11 14:05:39 -07:00
parent 1d8289d611
commit 7f26d88a7e
3 changed files with 323 additions and 35 deletions

View File

@@ -7,6 +7,7 @@
const ROW_ID = 'openscript-repo-info-about'; const ROW_ID = 'openscript-repo-info-about';
const SIZE_CLASS = 'openscript-file-size'; const SIZE_CLASS = 'openscript-file-size';
const TABLE_SELECTOR = 'table[aria-labelledby="folders-and-files"]'; const TABLE_SELECTOR = 'table[aria-labelledby="folders-and-files"]';
const SIZE_LOADS = new WeakMap();
const formatBytes = b => { const formatBytes = b => {
const u = ['B', 'KB', 'MB', 'GB']; 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 = () => { const getCodeView = () => {
try { 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(); const info = getRepoInfo();
if (!info || !route?.tree?.items || !route.refInfo) return null; const current = getRefPath();
const path = String(route.path || '').replace(/^\/+|\/+$/g, ''); if (!info || !current) return null;
const ref = route.refInfo.currentOid || route.refInfo.name; return { ...info, ...current, key: `${info.owner}/${info.repo}:${current.ref}:${current.path}` };
return { ...info, path, ref, items: route.tree.items, key: `${info.owner}/${info.repo}:${ref}:${path}` };
} catch { } catch {
return null; return null;
} }
@@ -167,11 +179,13 @@ const updateFileSizes = async () => {
if (!view || !table) return; if (!view || !table) return;
const links = table.querySelectorAll('td[class*="react-directory-row-name-cell"] a.Link--primary[href*="/blob/"]'); 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) if (table.dataset.openscriptFileSizes !== view.key)
table.querySelectorAll(`.${SIZE_CLASS}`).forEach(el => el.remove()); table.querySelectorAll(`.${SIZE_CLASS}`).forEach(el => el.remove());
table.dataset.openscriptFileSizes = view.key; table.dataset.openscriptFileSizes = view.key;
SIZE_LOADS.set(table, view.key);
const path = view.path ? `/${view.path.split('/').map(encodeURIComponent).join('/')}` : ''; const path = view.path ? `/${view.path.split('/').map(encodeURIComponent).join('/')}` : '';
try { try {
@@ -183,15 +197,17 @@ const updateFileSizes = async () => {
const entries = await res.json(); const entries = await res.json();
if (!Array.isArray(entries) || getCodeView()?.key !== view.key || !table.isConnected) return; if (!Array.isArray(entries) || getCodeView()?.key !== view.key || !table.isConnected) return;
const sizes = Object.fromEntries(entries.map(i => [i.path, i.size])); const sizes = Object.fromEntries(entries.map(item => [item.name, item.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')) { 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 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); if (Number.isFinite(bytes)) addFileSize(row, link.title, bytes);
} }
} catch {} } catch {}
finally {
if (SIZE_LOADS.get(table) === view.key) SIZE_LOADS.delete(table);
}
}; };
let scheduled; let scheduled;
@@ -208,9 +224,9 @@ const run = () => {
); );
const start = () => { const start = () => {
new MutationObserver(run).observe(document.body, { childList: true, subtree: true }); new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
run(); run();
}; };
if (document.body) start(); if (document.documentElement) start();
else window.addEventListener('DOMContentLoaded', start, { once: true }); else window.addEventListener('DOMContentLoaded', start, { once: true });

245
DownloadDir.os.js Normal file
View 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 });

View File

@@ -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 ## Scripts
- **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. ### Display Repo Info and File Sizes
- **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. [`DisplayRepoInfo.os.js`](./DisplayRepoInfo.os.js) adds repository information and file sizes directly to GitHub's interface.
- **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. - Shows total repository disk usage and repository age in the **About** sidebar.
- **Private Repositories Supported**: Uses `GH_PAT` from OpenScript secrets for private repositories and increased rate limits. - Shows the exact repository creation date and time on hover.
- **Turbo / SPA Compatible**: Seamlessly persists across GitHub's Turbo and client-side page transitions. - Adds human-readable sizes beside files in root and nested directory listings.
- **No Cache or Script Storage**: Reads fresh repository and directory data from GitHub's API for each rendered view. - 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. 1. Open the **OpenScript** extension popup.
2. Click **+ New** in the header (or click your existing script to edit). 2. Click **+ New**.
3. Paste the contents of [`DisplayRepoInfo.os.js`](./DisplayRepoInfo.os.js). 3. Paste the contents of the script you want to install.
4. Click **save script**. 4. Click **save script**.
5. Repeat for the other script if you want both features.
## GitHub PAT Configuration (for Private Repos) After updating an installed script, save it again and refresh the current GitHub page once. Later GitHub navigation works without refreshing.
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. ## Private Repositories
3. Add a secret:
- **Key**: `GH_PAT` Both scripts support a GitHub personal access token stored in OpenScript:
- **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`. 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.