From 7f26d88a7e7c5f26e83d059c181ac0040a7d7bc7 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Fri, 11 Sep 2026 14:05:39 -0700 Subject: [PATCH] Add directory downloads and fix GitHub SPA support --- DisplayRepoInfo.os.js | 42 +++++--- DownloadDir.os.js | 245 ++++++++++++++++++++++++++++++++++++++++++ README.md | 71 ++++++++---- 3 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 DownloadDir.os.js diff --git a/DisplayRepoInfo.os.js b/DisplayRepoInfo.os.js index 0217b38..694e7b5 100644 --- a/DisplayRepoInfo.os.js +++ b/DisplayRepoInfo.os.js @@ -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 }); diff --git a/DownloadDir.os.js b/DownloadDir.os.js new file mode 100644 index 0000000..273ff9c --- /dev/null +++ b/DownloadDir.os.js @@ -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 }); diff --git a/README.md b/README.md index 49bc0c9..f465ecf 100644 --- a/README.md +++ b/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**: `` -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.