GitHub Repository Size Checker

Displays the repo size without .git

目前為 2025-04-17 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         GitHub Repository Size Checker
// @namespace	 https://github.com/yookibooki
// @version      1.0
// @description  Displays the repo size without .git
// @match        *://github.com/*/*
// @exclude      *://github.com/*/issues*
// @exclude      *://github.com/*/pulls*
// @exclude      *://github.com/*/actions*
// @exclude      *://github.com/*/projects*
// @exclude      *://github.com/*/wiki*
// @exclude      *://github.com/*/security*
// @exclude      *://github.com/*/pulse*
// @exclude      *://github.com/*/settings*
// @exclude      *://github.com/*/branches*
// @exclude      *://github.com/*/tags*
// @exclude      *://github.com/*/*/commit*
// @exclude      *://github.com/*/*/tree*
// @exclude      *://github.com/*/*/blob*
// @exclude      *://github.com/settings*
// @exclude      *://github.com/notifications*
// @exclude      *://github.com/marketplace*
// @exclude      *://github.com/explore*
// @exclude      *://github.com/topics*
// @exclude      *://github.com/sponsors*
// @exclude      *://github.com/dashboard*
// @exclude      *://github.com/new*
// @exclude      *://github.com/codespaces*
// @exclude      *://github.com/account*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      api.github.com
// ==/UserScript==

(function() {
    'use strict';

    const CACHE_KEY = 'repoSizeCache';
    const PAT_KEY = 'github_pat_repo_size';
    const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours

    // --- Configuration ---
    const GITHUB_API_BASE = 'https://api.github.com';
    // Target element selector (this might change if GitHub updates its layout)
    const TARGET_ELEMENT_SELECTOR = '#repo-title-component > span.Label.Label--secondary'; // The 'Public'/'Private' label
    const DISPLAY_ELEMENT_ID = 'repo-size-checker-display';

    // --- Styles ---
    const STYLE_LOADING = 'color: orange; margin-left: 6px; font-size: 12px; font-weight: 600;';
    const STYLE_ERROR = 'color: red; margin-left: 6px; font-size: 12px; font-weight: 600;';
    const STYLE_SIZE = 'color: #6a737d; margin-left: 6px; font-size: 12px; font-weight: 600;'; // Use GitHub's secondary text color

    let currentRepoInfo = null; // { owner, repo, key: 'owner/repo' }
    let pat = null;
    let displayElement = null;
    let observer = null; // MutationObserver to watch for page changes

    // --- Helper Functions ---

    function log(...args) {
        console.log('[RepoSizeChecker]', ...args);
    }

    function getRepoInfoFromUrl() {
        const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)(?:\/?$|\/tree\/|\/find\/|\/graphs\/|\/network\/|\/releases\/)/);
        if (match && match[1] && match[2]) {
            // Basic check to avoid non-code pages that might match the pattern
            if (document.querySelector('#repository-container-header')) {
                 return { owner: match[1], repo: match[2], key: `${match[1]}/${match[2]}` };
            }
        }
        return null;
    }

    function formatBytes(bytes, decimals = 1) {
        if (bytes === 0) return '0 Bytes';
        const k = 1024;
        const dm = decimals < 0 ? 0 : decimals;
        const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
    }

    function getPAT() {
        if (pat) return pat;
        pat = GM_getValue(PAT_KEY, null);
        return pat;
    }

    function setPAT(newPat) {
        if (newPat && typeof newPat === 'string' && newPat.trim().length > 0) {
            pat = newPat.trim();
            GM_setValue(PAT_KEY, pat);
            log('GitHub PAT saved.');
            // Clear current error message if any
            if (displayElement && displayElement.textContent?.includes('PAT Required')) {
                updateDisplay('', STYLE_LOADING); // Reset display
            }
            // Re-run the main logic if PAT was missing
            main();
            return true;
        } else {
             GM_setValue(PAT_KEY, ''); // Clear stored PAT if input is invalid/empty
             pat = null;
             log('Invalid PAT input. PAT cleared.');
             updateDisplay('Invalid PAT', STYLE_ERROR);
             return false;
        }
    }

    function promptForPAT() {
        const newPat = prompt('GitHub Personal Access Token (PAT) required for API access. Please enter your PAT (needs repo scope):\n\nIt will be stored locally by Tampermonkey.', '');
        if (newPat === null) { // User cancelled
            updateDisplay('PAT Required', STYLE_ERROR);
            return false;
        }
        return setPAT(newPat);
    }

    function getCache() {
        const cacheStr = GM_getValue(CACHE_KEY, '{}');
        try {
            return JSON.parse(cacheStr);
        } catch (e) {
            log('Error parsing cache, resetting.', e);
            GM_setValue(CACHE_KEY, '{}');
            return {};
        }
    }

    function setCache(repoKey, data) {
        try {
            const cache = getCache();
            cache[repoKey] = data;
            GM_setValue(CACHE_KEY, JSON.stringify(cache));
        } catch (e) {
             log('Error writing cache', e);
        }
    }

    function updateDisplay(text, style = STYLE_SIZE, isLoading = false) {
        if (!displayElement) {
            const targetElement = document.querySelector(TARGET_ELEMENT_SELECTOR);
            if (!targetElement) {
                log('Target element not found.');
                return; // Target element isn't on the page yet or selector is wrong
            }
            displayElement = document.createElement('span');
            displayElement.id = DISPLAY_ELEMENT_ID;
            targetElement.insertAdjacentElement('afterend', displayElement);
            log('Display element injected.');
        }

        displayElement.textContent = isLoading ? `(${text}...)` : text;
        displayElement.style.cssText = style;
    }

    function makeApiRequest(url, method = 'GET') {
        return new Promise((resolve, reject) => {
            const currentPat = getPAT();
            if (!currentPat) {
                reject(new Error('PAT Required'));
                return;
            }

            GM_xmlhttpRequest({
                method: method,
                url: url,
                headers: {
                    "Authorization": `token ${currentPat}`,
                    "Accept": "application/vnd.github.v3+json"
                },
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            resolve(JSON.parse(response.responseText));
                        } catch (e) {
                            reject(new Error(`Failed to parse API response: ${e.message}`));
                        }
                    } else if (response.status === 401) {
                        reject(new Error('Invalid PAT'));
                    } else if (response.status === 403) {
                        const rateLimitRemaining = response.responseHeaders.match(/x-ratelimit-remaining:\s*(\d+)/i);
                        const rateLimitReset = response.responseHeaders.match(/x-ratelimit-reset:\s*(\d+)/i);
                        let errorMsg = 'API rate limit exceeded or insufficient permissions.';
                        if (rateLimitRemaining && rateLimitRemaining[1] === '0' && rateLimitReset) {
                             const resetTime = new Date(parseInt(rateLimitReset[1], 10) * 1000);
                             errorMsg += ` Limit resets at ${resetTime.toLocaleTimeString()}.`;
                        } else {
                            errorMsg += ' Check PAT permissions (needs `repo` scope).';
                        }
                         reject(new Error(errorMsg));
                    } else if (response.status === 404) {
                         reject(new Error('Repository not found or PAT lacks access.'));
                    }
                    else {
                        reject(new Error(`API request failed with status ${response.status}: ${response.statusText}`));
                    }
                },
                onerror: function(response) {
                    reject(new Error(`Network error during API request: ${response.error || 'Unknown error'}`));
                },
                ontimeout: function() {
                    reject(new Error('API request timed out.'));
                }
            });
        });
    }

    async function fetchLatestDefaultBranchSha(owner, repo) {
        log(`Fetching repo info for ${owner}/${repo}`);
        const repoUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}`;
        try {
            const repoData = await makeApiRequest(repoUrl);
            const defaultBranch = repoData.default_branch;
            if (!defaultBranch) {
                throw new Error('Could not determine default branch.');
            }
            log(`Default branch: ${defaultBranch}. Fetching its latest SHA.`);
            const branchUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}/branches/${defaultBranch}`;
            const branchData = await makeApiRequest(branchUrl);
            return branchData.commit.sha;
        } catch (error) {
            log(`Error fetching latest SHA for ${owner}/${repo}:`, error);
            throw error; // Re-throw to be caught by the main logic
        }
    }

    async function fetchRepoTreeSize(owner, repo, sha) {
        log(`Fetching tree size for ${owner}/${repo} at SHA ${sha}`);
        const treeUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/trees/${sha}?recursive=1`;
        try {
            const treeData = await makeApiRequest(treeUrl);

            if (treeData.truncated && (!treeData.tree || treeData.tree.length === 0)) {
                // Handle extremely large repos where even the first page is truncated without file list
                 throw new Error('Repo likely too large for basic tree API. Size unavailable.');
            }

            let totalSize = 0;
            if (treeData.tree) {
                treeData.tree.forEach(item => {
                    if (item.type === 'blob' && item.size !== undefined && item.size !== null) {
                        totalSize += item.size;
                    }
                });
            }

            log(`Calculated size for ${owner}/${repo} (SHA: ${sha}): ${totalSize} bytes. Truncated: ${treeData.truncated}`);
            return {
                size: totalSize,
                truncated: treeData.truncated === true // Ensure boolean
            };
        } catch (error) {
            log(`Error fetching tree size for ${owner}/${repo}:`, error);
            // Special handling for empty repos which return 404 for the tree SHA
            if (error.message && error.message.includes('404') && error.message.includes('Not Found')) {
                 log(`Assuming empty repository for ${owner}/${repo} based on 404 for tree SHA ${sha}.`);
                 return { size: 0, truncated: false };
            }
            throw error; // Re-throw other errors
        }
    }

    async function main() {
        const repoInfo = getRepoInfoFromUrl();

        // Exit if not on a repo page or already processed this exact URL path
        if (!repoInfo || (currentRepoInfo && currentRepoInfo.key === repoInfo.key && currentRepoInfo.path === window.location.pathname)) {
            // log('Not a repo page or already processed:', window.location.pathname);
            return;
        }

        currentRepoInfo = { ...repoInfo, path: window.location.pathname }; // Store owner, repo, key, and full path
        log('Detected repository:', currentRepoInfo.key);

        // Ensure display element exists or create it
        updateDisplay('loading', STYLE_LOADING, true);

        // Check for PAT
        if (!getPAT()) {
            log('PAT not found.');
            updateDisplay('PAT Required', STYLE_ERROR);
            promptForPAT(); // Ask user for PAT
            // If promptForPAT fails or is cancelled, the display remains 'PAT Required'
            return; // Stop processing until PAT is provided
        }

        // --- Caching Logic ---
        const cache = getCache();
        const cachedData = cache[currentRepoInfo.key];
        const now = Date.now();

        if (cachedData) {
            const cacheAge = now - (cachedData.timestamp || 0);
            log(`Cache found for ${currentRepoInfo.key}: Age ${Math.round(cacheAge / 1000)}s, SHA ${cachedData.sha}`);

            // 1. Check if cache is fresh (less than 24 hours)
            if (cacheAge < CACHE_EXPIRY_MS) {
                log('Cache is fresh (<24h). Using cached size.');
                updateDisplay(
                    `${cachedData.truncated ? '~' : ''}${formatBytes(cachedData.size)}`,
                    STYLE_SIZE
                );
                return; // Use fresh cache
            }

            // 2. Cache is older than 24 hours, check if SHA matches current default branch head
            log('Cache is stale (>24h). Checking latest SHA...');
            updateDisplay('validating', STYLE_LOADING, true);
            try {
                const latestSha = await fetchLatestDefaultBranchSha(currentRepoInfo.owner, currentRepoInfo.repo);
                log(`Latest SHA: ${latestSha}, Cached SHA: ${cachedData.sha}`);

                if (latestSha === cachedData.sha) {
                    log('SHA matches. Reusing cached size and updating timestamp.');
                    // Update timestamp in cache
                    cachedData.timestamp = now;
                    setCache(currentRepoInfo.key, cachedData);
                    updateDisplay(
                        `${cachedData.truncated ? '~' : ''}${formatBytes(cachedData.size)}`,
                        STYLE_SIZE
                    );
                    return; // Use validated cache
                } else {
                    log('SHA mismatch. Cache invalid. Fetching new size.');
                }
            } catch (error) {
                log('Error validating SHA:', error);
                updateDisplay(`Error: ${error.message}`, STYLE_ERROR);
                // Optionally clear the stale cache entry if validation fails badly?
                // delete cache[currentRepoInfo.key];
                // GM_setValue(CACHE_KEY, JSON.stringify(cache));
                return; // Stop if we can't validate
            }
        } else {
            log(`No cache found for ${currentRepoInfo.key}.`);
        }

        // --- Fetching New Data ---
        updateDisplay('loading', STYLE_LOADING, true);
        try {
            // We might have already fetched the SHA during cache validation
            let latestSha = cachedData?.latestShaChecked; // Reuse if available from failed validation
             if (!latestSha) {
                 latestSha = await fetchLatestDefaultBranchSha(currentRepoInfo.owner, currentRepoInfo.repo);
            }

            const { size, truncated } = await fetchRepoTreeSize(currentRepoInfo.owner, currentRepoInfo.repo, latestSha);

            // Save to cache
            const newData = {
                size: size,
                sha: latestSha,
                timestamp: Date.now(),
                truncated: truncated
            };
            setCache(currentRepoInfo.key, newData);

            // Display result
            updateDisplay(
                `${truncated ? '~' : ''}${formatBytes(size)}`,
                STYLE_SIZE
            );

        } catch (error) {
            log('Error during main fetch process:', error);
            let errorMsg = `Error: ${error.message}`;
             if (error.message === 'Invalid PAT') {
                 errorMsg = 'Invalid PAT';
                 setPAT(''); // Clear invalid PAT
                 promptForPAT(); // Ask again
             } else if (error.message === 'PAT Required') {
                  errorMsg = 'PAT Required';
                  promptForPAT();
             }
            updateDisplay(errorMsg, STYLE_ERROR);
        }
    }

    // --- Initialization ---

    function init() {
        log("Script initializing...");

        // Register menu command to update PAT
        GM_registerMenuCommand('Set/Update GitHub PAT for Repo Size', () => {
             const currentPatValue = GM_getValue(PAT_KEY, '');
             const newPat = prompt('Enter your GitHub Personal Access Token (PAT) for Repo Size Checker (needs repo scope):', currentPatValue);
              if (newPat !== null) { // Handle cancel vs empty string
                  setPAT(newPat); // Validate and save
              }
        });

        // Use MutationObserver to detect navigation changes within GitHub (SPA behavior)
        // and when the target element appears after load.
        observer = new MutationObserver((mutationsList, observer) => {
            // Check if the repo title area is present, indicating a potential repo page load/update
            if (document.querySelector(TARGET_ELEMENT_SELECTOR) && !document.getElementById(DISPLAY_ELEMENT_ID)) {
                 // If the display element isn't there but the target is, try running main
                 log("Target element detected, running main logic.");
                 main();
            } else {
                // Also check if the URL path changed significantly enough to warrant a re-check
                const newRepoInfo = getRepoInfoFromUrl();
                if (newRepoInfo && (!currentRepoInfo || newRepoInfo.key !== currentRepoInfo.key)) {
                    log("Detected navigation to a new repository page.", newRepoInfo.key);
                    main();
                } else if (!newRepoInfo && currentRepoInfo) {
                     // Navigated away from a repo page where we were showing info
                     log("Navigated away from repo page.");
                     currentRepoInfo = null; // Reset state
                     if (displayElement) {
                         displayElement.remove(); // Clean up old display element
                         displayElement = null;
                     }
                }
            }
        });

        // Start observing the body for changes in subtree and child list
        observer.observe(document.body, { childList: true, subtree: true });

        // Initial run in case the page is already loaded
         main();
    }


    // Make sure the DOM is ready before trying to find elements
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();