// ==UserScript== // @name GitHub Plus // @name:zh-CN GitHub 增强 // @namespace http://tampermonkey.net/ // @version 0.7.0 // @description Enhance GitHub with extra features: Release downloads/uploader/histogram, customizable UI, extended user/repo info, archived repo stars, code improvements, anti-tracking & PAT boost. // @description:zh-CN 增强GitHub功能:Release下载量/上传者/直方图,外观定制(仪表盘/侧栏/图标),扩展用户/仓库信息,归档仓库标星,代码编辑增强(Tab大小/光标动画),反追踪及PAT提速。 // @author PRO-2684 // @match https://github.com/* // @match https://*.github.com/* // @run-at document-start // @icon http://github.com/favicon.ico // @license gpl-3.0 // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_addValueChangeListener // @grant GM_addElement // @grant GM_getResourceText // @grant unsafeWindow // @require https://github.com/PRO-2684/GM_config/releases/download/v1.2.2/config.min.js#md5=c45f9b0d19ba69bb2d44918746c4d7ae // @resource catppuccin-associations https://raw.githubusercontent.com/PRO-2684/gadgets/refs/heads/main/github_plus/catppuccin/associations.json // @resource catppuccin-icons https://raw.githubusercontent.com/PRO-2684/gadgets/refs/heads/main/github_plus/catppuccin/icons.json // @resource catppuccin-palette https://raw.githubusercontent.com/PRO-2684/gadgets/refs/heads/main/github_plus/catppuccin/palette.json // ==/UserScript== (function () { "use strict"; /** * @typedef {Object} StyleDefinition * @property {string} id Relative style ID, without the userscript prefix. * @property {string} [setting] Configuration property that controls the style. * @property {(value: unknown) => string} render Converts a setting value to CSS. * @property {(value: unknown) => boolean} [disabled] Determines whether the style is disabled. */ /** * @typedef {Object} StyleSheetOptions * @property {Document} document Document whose head owns the styles. * @property {Promise} ready Resolves when document.head is available. * @property {{get: (prop: string) => unknown}} settings Configuration reader. * @property {string} idPrefix Prefix applied to every owned style ID. * @property {Record>} catppuccinPalette Catppuccin colors by flavor. */ /** * @typedef {Object} StyleSheetModule * @property {() => Promise} mount Mounts every owned style once. * @property {(prop: string, value: unknown) => Promise} applySetting Updates an owned setting style. */ /** * @typedef {Object} SettingChange * @property {string} prop Configuration property name. * @property {unknown} [before] Previous value. * @property {unknown} [after] New value. */ /** * @typedef {Object} LifecycleSettings * @property {(prop: string) => unknown} get Reads a configuration value. * @property {(listener: (change: SettingChange) => void) => void} onGet Subscribes to reads. * @property {(listener: (change: SettingChange) => void) => void} onSet Subscribes to changes. */ /** * @typedef {Object} LifecycleActions * @property {StyleSheetModule} styleSheets * @property {() => void} refreshIcons * @property {(onConnected: (setIcon: (icon: unknown) => void) => void) => void} connectCustomMenu * @property {() => void} refreshReleases * @property {() => void} refreshUserInfo * @property {() => void} refreshRepoInfo * @property {(root?: Document | Element) => boolean} patchArchivedRepoStars * @property {() => void} clearTrackingMetadata * @property {() => void} protectFetch * @property {() => void} showRateLimit * @property {(name: string, event: Event) => void} logEvent */ /** * @typedef {Object} FeatureLifecycleOptions * @property {Promise} ready Resolves when document-level initialization may run. * @property {LifecycleSettings} settings Configuration adapter. * @property {{on: (name: string, listener: (event: Event) => void, options: AddEventListenerOptions) => void}} events Document-event adapter. * @property {(callback: () => void) => void} scheduleFrame Frame scheduler. * @property {{isMainSite: boolean, readyState: DocumentReadyState}} environment Stable startup environment. * @property {LifecycleActions} actions Feature implementations invoked by lifecycle policy. */ /** * @typedef {Object} FeatureLifecycleModule * @property {() => Promise} start Installs lifecycle behavior once. */ /** * @param {StyleSheetOptions} options * @returns {StyleSheetModule} */ function createStyleSheetModule({ document, ready, settings, idPrefix, catppuccinPalette, }) { const conditionalStyles = { "code.cursorBlink": "[data-testid='navigation-cursor'] { animation: blink 1s step-end infinite; }", "code.cursorAnimation": "[data-testid='navigation-cursor'] { transition: top 0.1s ease-in-out, left 0.1s ease-in-out; }", "code.fullWidth": "#copilot-button-positioner { padding-right: 0; }", "code.hideReadonlyTip": "[class^='CodeBlob-module__cursorContainer__'] .position-absolute.color-bg-subtle { display: none; }", "appearance.stickyAvatar": ` .pull-discussion-timeline .TimelineItem-avatar { position: relative; margin-left: -40px; left: -32px; & > a[data-hovercard-type='user'], & > a[href^="/apps/"], & > img.avatar { position: sticky; top: 5em; } } #issue-timeline [class*='Avatar-module__avatarOuter__'] { position: sticky; top: 3em; } [data-testid='issue-viewer-issue-container'] [class*='Avatar-module__avatarOuter__'] { position: sticky; top: 4em; } `, "appearance.stickyMore": ` .react-issue-body [class^='IssueBodyHeader-module__IssueBodyHeaderContainer__'], .react-issue-comment [data-testid="comment-header"] { position: sticky; top: 4em; z-index: 1; backdrop-filter: brightness(0.1); } .timeline-comment-group .timeline-comment-header { position: sticky; top: 5em; z-index: 1; backdrop-filter: brightness(0.1); }`, "appearance.hideHeaderUnderline": ".markdown-heading > .heading-element { border-bottom: none; }", "appearance.visibleDetails": ` .markdown-body details { padding: 0 1em; border: 1px solid var(--borderColor-default,var(--color-border-default)); border-radius: 0.5em; > summary { padding: 0.5em 1em; margin: 0 -1em; } &:open > summary { margin: 0 -1em 1em -1em; border-bottom: 1px dashed var(--borderColor-default,var(--color-border-default)); } } `, "appearance.customMenuIcon": ` [data-testid="top-nav-left"] button[data-component="IconButton"] span.ghp-custom-menu-icon { width: var(--base-size-16); height: var(--base-size-16); font-size: var(--text-title-size-medium); justify-content: center; align-items: center; display: inline-flex; } `, }; const enumStyles = { "appearance.dashboard": [ "", "/* Hide Copilot */ #dashboard > .news > .copilotPreview__container { display: none; }", "/* Hide Feed */ #dashboard > .news > feed-container { display: none; }", `/* Mobile-Like */ .application-main > div > aside[aria-label="Account context"] { display: block !important; } #dashboard > .news { > .copilotPreview__container { display: none; } > feed-container { display: none; } > .d-block.d-md-none { display: block !important; } }`, ], "appearance.leftSidebar": [ "", "/* Hidden */ .application-main .feed-background > aside.feed-left-sidebar { display: none; }", ], "appearance.rightSidebar": [ "", "/* Hide 'Latest changes' */ aside.feed-right-sidebar > .dashboard-changelog { display: none; }", "/* Hide 'Explore repositories' */ aside.feed-right-sidebar > [aria-label='Explore repositories'] { display: none; }", "/* Hide Completely */ aside.feed-right-sidebar { display: none; }", ], }; const fixedStyles = { "catppuccin-icons-hide": ".ghp-catppuccin-icon + svg.octicon { display: none; }", "extended-repo-info": ".ghp-extended-repo-info { color: var(--fgColor-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }", release: ` @media (min-width: 1012px) { .ghp-release-asset .col-lg-6 { width: 40%; } } .nowrap { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ghp-release-asset { background: linear-gradient(to right, var(--bgColor-accent-muted) var(--percent, 0%), transparent 0); } `, }; const flavors = ["default", "latte", "frappe", "macchiato", "mocha"]; /** @type {StyleDefinition[]} */ const definitions = [ ...Object.entries(conditionalStyles).map(([setting, css]) => ({ id: setting, setting, render: () => css, disabled: (value) => !value, })), ...Object.entries(enumStyles).map(([setting, choices]) => ({ id: setting, setting, render(value) { const index = /** @type {number} */ (value); if (!(index in choices)) throw new RangeError(`Invalid value for ${setting}`); return choices[index]; }, })), ...Object.entries(fixedStyles).map(([id, css]) => ({ id, render: () => css, })), { id: "tabSize", setting: "code.tabSize", render: (size) => `pre, code { tab-size: ${size}; }`, }, { id: "catppuccin-icons-css-variables", setting: "appearance.catppuccinIcons", render(value) { const flavor = flavors[/** @type {number} */ (value)]; if (!flavor) throw new RangeError( "Invalid value for appearance.catppuccinIcons", ); if (flavor === "default") return ""; const colors = catppuccinPalette[flavor]; if (!colors) throw new RangeError(`Missing palette for ${flavor}`); const variables = Object.entries(colors) .map(([name, hex]) => ` --ctp-${name}: ${hex};`) .join("\n"); return `:root {\n${variables}\n}`; }, }, ]; /** @type {Map} */ const bySetting = new Map(); for (const definition of definitions) { if (definition.setting) bySetting.set(definition.setting, definition); } /** @type {Map} */ const pendingValues = new Map(); /** @type {Promise | undefined} */ let mountPromise; /** * @param {StyleDefinition} definition * @returns {HTMLStyleElement} */ function getStyle(definition) { const id = idPrefix + definition.id; const head = document.head; if (!head) throw new Error("Document head is unavailable"); let style = /** @type {HTMLStyleElement | null} */ ( document.getElementById(id) ); if (style && style.tagName !== "STYLE") throw new TypeError(`#${id} is not a style element`); if (!style) { style = document.createElement("style"); style.id = id; head.appendChild(style); } return style; } /** * @param {StyleDefinition} definition * @param {unknown} value */ function update(definition, value) { const style = getStyle(definition); style.textContent = definition.render(value); style.disabled = definition.disabled?.(value) ?? false; } /** * @param {string} setting * @returns {unknown} */ function valueFor(setting) { return pendingValues.has(setting) ? pendingValues.get(setting) : settings.get(setting); } /** @returns {Promise} */ function mount() { mountPromise ??= Promise.resolve(ready).then(() => { for (const definition of definitions) { update( definition, definition.setting ? valueFor(definition.setting) : undefined, ); } }); return mountPromise; } /** * @param {string} prop * @param {unknown} value * @returns {Promise} */ async function applySetting(prop, value) { const definition = bySetting.get(prop); if (!definition) return false; pendingValues.set(prop, value); await mount(); update(definition, value); return true; } return { mount, applySetting }; } /** * @param {FeatureLifecycleOptions} options * @returns {FeatureLifecycleModule} */ function createFeatureLifecycleModule({ ready, settings, events, scheduleFrame, environment, actions, }) { const debugEvents = [ "turbo:before-cache", "turbo:before-render", "turbo:before-morph-element", "turbo:before-frame-render", "turbo:load", "turbo:reload", "turbo:render", "turbo:morph", "turbo:morph-element", "turbo:frame-load", "turbo:frame-render", "turbo:visit", "turbo:before-fetch-request", "turbo:before-fetch-response", "soft-nav:initial", "soft-nav:start", "soft-nav:render", "soft-nav:end", "soft-nav:react-done", "soft-nav:replace-mechanism", "soft-nav:frame-update", "soft-nav:progress-bar:end", "soft-nav:external:success", "filterable:change", "menu:activated", "beforetoggle", "load", "textInput", "toggle", ]; const passive = { passive: true }; /** @type {Promise | undefined} */ let startPromise; /** * @param {string} name * @param {(event: Event) => void} listener */ function on(name, listener) { events.on(name, listener, passive); } /** @returns {Promise} */ function start() { if (startPromise) return startPromise; const tasks = [actions.styleSheets.mount()]; if (settings.get("additional.archivedRepoStars")) { if (environment.readyState === "loading") { events.on( "readystatechange", (event) => { if (event.target.readyState === "interactive") actions.patchArchivedRepoStars(); }, { once: true, passive: true }, ); } else { actions.patchArchivedRepoStars(); } on("turbo:before-render", (event) => actions.patchArchivedRepoStars(event.detail.newBody), ); } on("soft-nav:react-done", () => actions.refreshIcons()); on("turbo:load", () => actions.refreshIcons()); tasks.push( Promise.resolve(ready).then(() => { actions.connectCustomMenu((setIcon) => { setIcon(settings.get("appearance.customMenuIcon")); settings.onSet(({ prop, after }) => { if (prop === "appearance.customMenuIcon") setIcon(after); }); }); }), ); if (environment.isMainSite) { tasks.push( Promise.resolve(ready).then(() => actions.refreshReleases(), ), ); on("turbo:load", () => actions.refreshReleases()); } if (settings.get("additional.extendedUserInfo")) { on("soft-nav:end", () => actions.refreshUserInfo()); on("turbo:load", () => actions.refreshUserInfo()); } if (settings.get("additional.extendedRepoInfo")) { on("soft-nav:react-done", () => actions.refreshRepoInfo()); on("turbo:load", () => scheduleFrame(() => actions.refreshRepoInfo()), ); } if (settings.get("additional.trackingPrevention")) { actions.clearTrackingMetadata(); on("turbo:before-render", () => actions.clearTrackingMetadata(), ); actions.protectFetch(); } if (settings.get("advanced.debug")) { for (const name of debugEvents) { on(name, (event) => actions.logEvent(name, event)); } } settings.onGet(({ prop }) => { if (prop === "advanced.rateLimit") actions.showRateLimit(); }); tasks.push( Promise.resolve(ready).then(() => { settings.onSet(({ prop, after }) => { void actions.styleSheets.applySetting(prop, after); }); }), ); startPromise = Promise.all(tasks).then(() => undefined); return startPromise; } return { start }; } /** * Enable GitHub's native star action for an archived repository. * @param {Document | Element} root Root containing the React app payload. * @returns {boolean} Whether the payload was patched. */ function patchArchivedRepoStarData(root) { const script = root.querySelector( 'react-app[app-name="code-view"] script[data-target="react-app.embeddedData"]', ); if (!script) return false; let data; try { data = JSON.parse(script.textContent); } catch { return false; } const { repo, star, viewer } = data.payload?.sidebarAbout ?? {}; if ( !repo?.isArchived || star?.canStar !== false || !viewer?.isLoggedIn || viewer?.emuContributionBlocked ) return false; star.canStar = true; script.textContent = JSON.stringify(data); return true; } const testHook = globalThis.__GHP_TEST_HOOK__; if (typeof testHook === "function") { testHook({ createStyleSheetModule, createFeatureLifecycleModule, patchArchivedRepoStarData, }); return; } const { name, version } = GM_info.script; const idPrefix = "ghp-"; // Prefix for the IDs of the elements /** * The top domain of the current page. * @type {string} */ const topDomain = location.hostname.split(".").slice(-2).join("."); /** * A promise that resolves when the document is ready. * @type {Promise} */ const documentReady = new Promise((resolve) => { if (document.readyState === "loading") { document.addEventListener( "DOMContentLoaded", () => { resolve(); }, { once: true, passive: true }, ); } else { resolve(); } }); /** * The official domain of GitHub. * @type {string} */ const officialDomain = "github.com"; /** * The color used for logging. Matches the color of the GitHub. * @type {string} */ const themeColor = "#f78166"; /** * Regular expression to match the expanded assets URL. (https://///releases/expanded_assets/) */ const expandedAssetsRegex = new RegExp( `https://${topDomain.replaceAll(".", "\\.")}/([^/]+)/([^/]+)/releases/expanded_assets/([^/]+)`, ); /** * Data about the release. Maps `owner`, `repo` and `version` to the details of a release. Details are `Promise` objects if exist. */ let releaseData = {}; /** * Rate limit data for the GitHub API. * @type {Object} * @property {number} limit The maximum number of requests that the consumer is permitted to make per hour. * @property {number} remaining The number of requests remaining in the current rate limit window. * @property {number} reset The time at which the current rate limit window resets in UTC epoch seconds. */ let rateLimit = { limit: -1, remaining: -1, reset: -1, }; // Configuration const configDesc = { $default: { autoClose: false, }, code: { name: "🔢 代码功能", type: "folder", items: { tabSize: { name: "➡️ 制表符大小", title: "设置制表符缩进大小", type: "int", min: 0, value: 4, }, cursorBlink: { name: "😉 光标闪烁", title: "启用光标闪烁", type: "bool", value: false, }, cursorAnimation: { name: "🌊 光标动画", title: "使光标平滑移动", type: "bool", value: false, }, fullWidth: { name: "🔲 全宽显示", title: "使代码块占满宽度(其他按钮可能遮挡行尾)", type: "bool", value: false, }, hideReadonlyTip: { name: "🫥 隐藏只读提示", title: "隐藏代码块中的“代码视图只读”提示", type: "bool", value: false, }, }, }, appearance: { name: "🎨 外观设置", type: "folder", items: { dashboard: { name: "📰 仪表盘", title: "配置仪表盘样式", type: "enum", options: [ "默认", "隐藏 Copilot", "隐藏动态流", "移动端风格", ], }, leftSidebar: { name: "↖️ 左侧栏", title: "配置左侧栏", type: "enum", options: ["默认", "隐藏"], }, rightSidebar: { name: "↗️ 右侧栏", title: "配置右侧栏", type: "enum", options: [ "默认", "隐藏“最新动态”", "隐藏“探索仓库”", "完全隐藏", ], }, stickyAvatar: { name: "📌 固定头像", title: "使头像固定(滚动时悬停)", type: "bool", value: false, }, stickyMore: { name: "📌 更多固定元素", title: "使更多元素固定(如议题和评论头部)", type: "bool", value: false, }, hideHeaderUnderline: { name: "🫥 隐藏标题下划线", title: "隐藏标题下方的下划线边框", type: "bool", value: false, }, catppuccinIcons: { name: "🐱 Catppuccin 图标", title: "使用 Catppuccin 主题的文件夹和文件图标(高度实验性),需刷新生效", type: "enum", options: [ "🚫 默认", "🌻 拿铁", "🪴 法式", "🌺 玛奇朵", "🌿 摩卡", ], }, visibleDetails: { name: "👀 可见的详情块", title: "为
元素添加缩进和边框,使其更醒目", type: "bool", value: false, }, customMenuIcon: { name: "🥞 自定义菜单图标", title: "使用自定义图标作为菜单按钮,留空则使用默认", type: "str", value: "", }, }, }, release: { name: "📦 发布功能", type: "folder", items: { uploader: { name: "⬆️ 发布上传者", title: "显示发布资源的上传者", type: "bool", value: true, }, downloads: { name: "📥 下载次数", title: "显示发布资源的下载次数", type: "bool", value: true, }, histogram: { name: "📊 下载直方图", title: "为每个发布资源显示下载量的直方图", type: "bool", }, hideArchives: { name: "🫥 隐藏源码压缩包", title: "在发布资源中隐藏源码压缩包(zip、tar.gz)", type: "bool", }, }, }, additional: { name: "🪄 附加功能", type: "folder", items: { extendedUserInfo: { name: "👤 扩展用户信息", title: "显示用户的扩展信息", type: "bool", value: false, }, extendedRepoInfo: { name: "📁 扩展仓库信息", title: "显示仓库的扩展信息", type: "bool", value: false, }, archivedRepoStars: { name: "⭐ 已归档仓库标星", title: "允许为已归档的仓库标星", type: "bool", value: false, }, trackingPrevention: { name: "🎭 跟踪防护", title: () => { return `阻止 GitHub 的部分跟踪行为(${name} 已阻止跟踪 ${GM_getValue("trackingPrevented", 0)} 次)`; }, type: "bool", value: false, }, }, }, advanced: { name: "⚙️ 高级设置", type: "folder", items: { token: { name: "🔑 个人访问令牌", title: "你的 GitHub API 个人访问令牌(以 `github_pat_` 开头,用于提高速率限制)", type: "str", }, rateLimit: { name: "📈 速率限制", title: "查看当前 API 速率限制状态", type: "action", }, debug: { name: "🐞 调试模式", title: "启用调试模式", type: "bool", }, }, }, }; const config = new GM_config(configDesc); // General functions const $ = document.querySelector.bind(document); const $$ = document.querySelectorAll.bind(document); /** * Log the given arguments if debug mode is enabled. * @param {...any} args The arguments to log. */ function log(...args) { if (config.get("advanced.debug")) console.log( `%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args, ); } /** * Warn the given arguments. * @param {...any} args The arguments to warn. */ function warn(...args) { console.warn( `%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args, ); } /** * Replace the domain of the given URL with the top domain if needed. * @param {string} url The URL to fix. * @returns {string} The fixed URL. */ function fixDomain(url) { return topDomain === officialDomain ? url : url.replace( `https://${officialDomain}/`, `https://${topDomain}/`, ); // Replace top domain } /** * Fetch the given URL with the personal access token, if given. Also updates rate limit. * @param {string} url The URL to fetch. * @param {RequestInit} options The options to pass to `fetch`. * @returns {Promise} The response from the fetch. */ async function fetchWithToken(url, options) { const token = config.get("advanced.token"); if (token) { if (!options) options = {}; if (!options.headers) options.headers = {}; options.headers.accept = "application/vnd.github+json"; options.headers["X-GitHub-Api-Version"] = "2022-11-28"; options.headers.Authorization = `Bearer ${token}`; } const r = await fetch(url, options); function parseRateLimit(suffix, defaultValue = -1) { const parsed = parseInt(r.headers.get(`X-RateLimit-${suffix}`)); return isNaN(parsed) ? defaultValue : parsed; } // Update rate limit for (const key of Object.keys(rateLimit)) { rateLimit[key] = parseRateLimit(key); // Case-insensitive } const resetDate = new Date(rateLimit.reset * 1000).toLocaleString(); log( `Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}`, ); if (r.status === 403 || r.status === 429) { // If we get 403 or 429, we've hit the rate limit. throw new Error(`Rate limit exceeded! Will reset at ${resetDate}`); } else if (rateLimit.remaining === 0) { warn(`Rate limit has been exhausted! Will reset at ${resetDate}`); } return r; } // Catppuccin icons const catppuccinPalette = JSON.parse( GM_getResourceText("catppuccin-palette"), ); const styleSheets = createStyleSheetModule({ document, ready: documentReady, settings: { get: (prop) => config.get(prop) }, idPrefix, catppuccinPalette, }); const associations = JSON.parse( GM_getResourceText("catppuccin-associations"), ); const icons = JSON.parse(GM_getResourceText("catppuccin-icons")); // https://github.com/catppuccin/web-file-explorer-icons/blob/2eded13cf948ad05d20d9de91f12fd1f75ff0c23/src/entries/content/lib.ts#L64-L102 function getIconName(filename, filetype = "file") { if (filetype === "submodule") { return "folder_git"; } if (filetype === "folder" || filetype === "folder-open") { let iconName = "_folder"; if (filename in associations.folderNames) { iconName = associations.folderNames[filename]; } else if (filename.toLowerCase() in associations.folderNames) { iconName = associations.folderNames[filename.toLowerCase()]; } if (filetype === "folder-open") { return iconName + "_open"; } else { return iconName; } } // Match by exact file name (case-sensitive first, then case-insensitive) if (filename in associations.fileNames) return associations.fileNames[filename]; if (filename.toLowerCase() in associations.fileNames) return associations.fileNames[filename.toLowerCase()]; // Match by exact file name (case-sensitive first, then case-insensitive) if (filename in associations.fileNames) return associations.fileNames[filename]; if (filename.toLowerCase() in associations.fileNames) return associations.fileNames[filename.toLowerCase()]; // Compute all possible extensions (e.g. "foo.test.ts" → ["test.ts", "ts"]) const fileExtensions = []; if (filename.length <= 255) { for (let i = 0; i < filename.length; i++) { if (filename[i] === ".") { fileExtensions.push(filename.toLowerCase().slice(i + 1)); } } } // Match by extension, then language ID for (const ext of fileExtensions) { if (ext in associations.fileExtensions) return associations.fileExtensions[ext]; if (ext in associations.languageIds) return associations.languageIds[ext]; } // Fallback return "_file"; } const iconClass = "ghp-catppuccin-icon"; const selectors = [ { // Main file explorer rows: ".react-directory-row .react-directory-filename-column", icon: "svg.octicon", filename: ".react-directory-filename-cell a", }, { // Main file explorer - parent directory rows: "#folder-row-0 a", icon: "svg.octicon", filename: "div", }, { // Sidebar file explorer rows: ".PRIVATE_TreeView-item > .PRIVATE_TreeView-item-container > .PRIVATE_TreeView-item-content", icon: ".PRIVATE_TreeView-item-visual svg.octicon", filename: ".PRIVATE_TreeView-item-content-text", }, ]; function updateIcons(body = document.body) { if (config.get("appearance.catppuccinIcons") === 0) return; selectors.forEach(({ rows, icon, filename }) => { body.querySelectorAll(rows).forEach((row) => { const iconEl = row.querySelector(icon); const filenameEl = row.querySelector(filename); if (!iconEl || !filenameEl) return; let filetype = "file"; if ( iconEl.classList.contains("octicon-file-directory") || iconEl.classList.contains("octicon-file-directory-fill") ) { filetype = "folder"; } else if ( iconEl.classList.contains("octicon-file-directory-open") || iconEl.classList.contains( "octicon-file-directory-open-fill", ) ) { filetype = "folder-open"; } else if ( iconEl.classList.contains("octicon-file-submodule") ) { filetype = "submodule"; } const name = filenameEl.textContent.trim(); const iconName = getIconName(name, filetype); log(`${name} -> ${iconName}`); const svg = icons[iconName]; const newIcon = new DOMParser() .parseFromString(svg, "image/svg+xml") .querySelector("svg"); if (newIcon) { newIcon.setAttribute("width", "16"); newIcon.setAttribute("height", "16"); newIcon.classList.add(iconClass); // Remove existing custom icon if any const existingIcon = row.querySelector(`.${iconClass}`); existingIcon?.remove(); // Insert the new icon iconEl.insertAdjacentElement("beforebegin", newIcon); } else { warn(`Icon "${iconName}" not found for file "${name}"`); } }); }); } // Custom Menu Icon function connectCustomMenu(onConnected) { const partial = $('react-partial[partial-name="global-nav-bar"]'); const obs = new MutationObserver(init); obs.observe(partial, { attributeFilter: ["class"] }); function init() { obs.disconnect(); const menuBtn = $( '[data-testid="top-nav-left"] button[data-component="IconButton"]', ); const originalIcon = menuBtn.children[0]; log("Replacing menu icon:", originalIcon); const customIcon = document.createElement("span"); customIcon.classList.add("ghp-custom-menu-icon"); function customMenuIcon(icon) { if (icon) { customIcon.textContent = icon; originalIcon.remove(); menuBtn.appendChild(customIcon); } else { customIcon.remove(); menuBtn.appendChild(originalIcon); } } onConnected(customMenuIcon); } } // Release features /** * Get the release data for the given owner, repo and version. * @param {string} owner The owner of the repository. * @param {string} repo The repository name. * @param {string} version The version tag of the release. * @returns {Promise} The release data, which resolves to an object mapping download link to details. */ async function getReleaseData(owner, repo, version) { if (!releaseData[owner]) releaseData[owner] = {}; if (!releaseData[owner][repo]) releaseData[owner][repo] = {}; if (!releaseData[owner][repo][version]) { const url = `https://api.${topDomain}/repos/${owner}/${repo}/releases/tags/${version}`; const promise = fetchWithToken(url) .then((response) => response.json()) .then((data) => { log( `Fetched release data for ${owner}/${repo}@${version}:`, data, ); const assets = {}; for (const asset of data.assets) { assets[fixDomain(asset.browser_download_url)] = { downloads: asset.download_count, uploader: { name: asset.uploader.login, url: fixDomain(asset.uploader.html_url), }, }; } log( `Processed release data for ${owner}/${repo}@${version}:`, assets, ); return assets; }); releaseData[owner][repo][version] = promise; } return releaseData[owner][repo][version]; } /** * Create a link to the uploader's profile. * @param {Object} uploader The uploader information. * @param {string} uploader.name The name of the uploader. * @param {string} uploader.url The URL to the uploader's profile. */ function createUploaderLink(uploader) { const link = document.createElement("a"); link.href = uploader.url; link.setAttribute("class", "text-sm-left flex-auto ml-md-3 nowrap"); if (uploader.url.startsWith(`https://${topDomain}/apps/`)) { link.classList.add("color-fg-success"); // Remove suffix `[bot]` from the name if exists const name = uploader.name.endsWith("[bot]") ? uploader.name.slice(0, -5) : uploader.name; link.title = `Uploaded by GitHub App @${name}`; link.textContent = `@${name}`; } else { link.classList.add("color-fg-muted"); link.setAttribute( "data-hovercard-url", `/users/${uploader.name}/hovercard`, ); link.title = `Uploaded by @${uploader.name}`; link.textContent = `@${uploader.name}`; } return link; } /** * Create a span element with the given download count. * @param {number} downloads The download count. */ function createDownloadCount(downloads) { const downloadCount = document.createElement("span"); downloadCount.textContent = `${downloads} DL`; downloadCount.title = `${downloads} downloads`; downloadCount.setAttribute( "class", "color-fg-muted text-sm-left flex-shrink-0 flex-grow-0 ml-md-3 nowrap", ); return downloadCount; } /** * Show a histogram of the download counts for the given release entry. * @param {HTMLElement} asset One of the release assets. * @param {number} value The download count of the asset. * @param {number} max The maximum download count of all assets. */ function showHistogram(asset, value, max) { asset.style.setProperty("--percent", `${(value / max) * 100}%`); } /** * Adding additional info (download count) to the release entries under the given element. * @param {HTMLElement} el The element to search for release entries. * @param {Object} info Additional information about the release (owner, repo, version). * @param {string} info.owner The owner of the repository. * @param {string} info.repo The repository name. * @param {string} info.version The version of the release. */ async function addAdditionalInfoToRelease(el, info) { const entries = el.querySelectorAll("ul > li"); const assets = []; const hideArchives = config.get("release.hideArchives"); entries.forEach((asset) => { if (asset.querySelector("svg.octicon-package")) { // Release asset assets.push(asset); } else if (hideArchives) { // Source code archive asset.remove(); } }); const releaseData = await getReleaseData( info.owner, info.repo, info.version, ); if (!releaseData) return; const maxDownloads = Math.max( 0, ...Object.values(releaseData).map((asset) => asset.downloads), ); assets.forEach((asset) => { const downloadLink = asset.children[0].querySelector("a")?.href; const statistics = asset.children[1]; const assetInfo = releaseData[downloadLink]; if (!assetInfo) return; asset.classList.add("ghp-release-asset"); if (config.get("release.downloads")) { const downloadCount = createDownloadCount(assetInfo.downloads); statistics.prepend(downloadCount); } if (config.get("release.uploader")) { const uploaderLink = createUploaderLink(assetInfo.uploader); statistics.prepend(uploaderLink); } if ( config.get("release.histogram") && maxDownloads > 0 && assets.length > 1 ) { showHistogram(asset, assetInfo.downloads, maxDownloads); } }); } /** * Handle the `include-fragment-replace` event. * @param {CustomEvent} event The event object. */ function onFragmentReplace(event) { const self = event.target; const src = self.src; const match = expandedAssetsRegex.exec(src); if (!match) return; const [_, owner, repo, version] = match; const info = { owner, repo, version }; const fragment = event.detail.fragment; log("Found expanded assets:", fragment); for (const child of fragment.children) { addAdditionalInfoToRelease(child, info); } } /** * Find all release entries and setup listeners to show the download count. */ function setupListeners() { log("Calling setupListeners"); if ( !config.get("release.downloads") && !config.get("release.uploader") && !config.get("release.histogram") ) return; // No need to run // IncludeFragmentElement: https://github.com/github/include-fragment-element/blob/main/src/include-fragment-element.ts const fragments = document.querySelectorAll( '[data-hpc] details[data-view-component="true"] include-fragment', ); fragments.forEach((fragment) => { if (!fragment.hasAttribute("data-ghp-listening")) { fragment.toggleAttribute("data-ghp-listening", true); fragment.addEventListener( "include-fragment-replace", onFragmentReplace, { once: true, passive: true }, ); if (config.get("release.hideArchives")) { // Fix assets count const summary = fragment.parentElement.previousElementSibling; if ( summary.tagName === "SUMMARY" && summary.firstElementChild.textContent === "Assets" ) { const counter = summary.querySelector("span.Counter"); if (counter) { const count = parseInt(counter.textContent) - 2; // Exclude the source code archives log(counter, count + 2, count); counter.textContent = count.toString(); counter.title = count.toString(); } } } } }); } // `include-fragment-replace` avoids reparsing responses or observing the // entire document. Feature lifecycle owns initial and Turbo scheduling. // Extended user & repo info const octicons = { repo: '', calendar: '', id_badge: '', file_zip: '', }; /** * Uses `relative-time` element to format the given timestamp. * @param {number} timestamp The timestamp to format. * @return {string} The HTML string of the formatted relative time. */ function relativeTime(timestamp) { // Convert timestamp to ISO 8601 formatted timestamp const date = new Date(timestamp); const iso = date.toISOString(); const fallback = date.toLocaleString(); return `${fallback}`; } function extendedUserInfo() { const profile = $( ".js-profile-editable-replace .js-profile-editable-area > ul.vcard-details", ); const username = $("meta[property='profile:username']")?.content; const existingInfo = profile?.querySelector?.( ".ghp-extended-user-info", ); if (!profile || !username || existingInfo) return; const fetchPromise = fetchWithToken( `https://api.${topDomain}/users/${username}`, ) .then((response) => response.json()) .catch((error) => { warn("Failed to fetch user info:", error); return null; }); function addInfoRow(icon_name, name, lambda) { const icon = octicons[icon_name] || ""; const row = document.createElement("li"); row.classList.add("vcard-detail", "pt-1", "ghp-extended-user-info"); row.innerHTML = `${icon}${name}: Loading...`; row.querySelector("svg").classList.add("octicon"); profile.appendChild(row); fetchPromise.then((info) => { const span = row.querySelector("span"); if (info) { span.innerHTML = `${name}: ${lambda(info)}`; } else { span.textContent = `${name}: Error`; } }); return row; } addInfoRow("repo", "Public", (info) => { function formatCount(count, label) { return `${count} ${label}${count !== 1 ? "s" : ""}`; } const repos = `${formatCount(info.public_repos, "repo")}`; const gists = `${formatCount(info.public_gists, "gist")}`; return `${repos}, ${gists}`; }); addInfoRow("calendar", "Joined", (info) => relativeTime(info.created_at), ); addInfoRow("calendar", "Updated", (info) => relativeTime(info.updated_at), ); addInfoRow("id_badge", "Node ID", (info) => info.node_id); } function extendedRepoInfo() { log("Fetching extended repository info"); const repoSidebar = $("[data-component='SplitPageLayout.Pane']"); const properties = repoSidebar?.querySelector(".hide-sm.hide-md"); const repoLink = $("#code-view-repo-link"); if (!properties || !repoLink) return; const repoName = repoLink.getAttribute("href").slice(1); // Remove leading slash if (properties.dataset.ghpExtendedRepoInfo === repoName) return; properties.dataset.ghpExtendedRepoInfo = repoName; properties .querySelectorAll(".ghp-extended-repo-info") .forEach((element) => { element.remove(); }); const reportBtn = properties.querySelector( "a[href^='/contact/report-content']", ); const fetchPromise = fetchWithToken( `https://api.${topDomain}/repos/${repoName}`, ) .then((response) => response.json()) .catch((error) => { warn("Failed to fetch repository info:", error); return null; }); function addRow(icon_name, name, html_cb, title_cb = null) { const h3 = document.createElement("h3"); h3.classList.add("sr-only", "ghp-extended-repo-info"); h3.textContent = name; const icon = octicons[icon_name] || ""; const container = document.createElement("div"); container.classList.add("mt-2", "ghp-extended-repo-info"); const entry = document.createElement("span"); entry.innerHTML = `${icon} Loading...`; entry .querySelector("svg") .classList.add("octicon", "mr-2", "tmp-mr-2"); entry.title = name; container.appendChild(entry); if (reportBtn) { reportBtn.insertAdjacentElement("beforebegin", h3); reportBtn.insertAdjacentElement("beforebegin", container); } else { properties.appendChild(h3); properties.appendChild(container); } fetchPromise.then((info) => { if (info) { entry.innerHTML = `${icon} ${html_cb(info)}`; if (title_cb) { entry.title = title_cb(info); } } else { entry.textContent = `${icon} Error`; } entry .querySelector("svg") .classList.add("octicon", "mr-2", "tmp-mr-2"); }); } function formatRepoSize(sizeInKb) { const units = ["KB", "MB", "GB", "TB"]; let size = sizeInKb; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } const maximumFractionDigits = unitIndex === 0 ? 0 : 2; return `${size.toLocaleString(undefined, { maximumFractionDigits, })} ${units[unitIndex]}`; } function addRows() { log("Adding extended repository info rows"); addRow( "file_zip", "Size", (info) => `${formatRepoSize(info.size)}`, (info) => `Size: ${info.size} KB`, // Raw size in title ); addRow( "calendar", "Created", (info) => "Created: " + relativeTime(info.created_at), ); addRow( "calendar", "Updated", (info) => "Updated: " + relativeTime(info.updated_at), ); addRow( "calendar", "Pushed", (info) => "Pushed: " + relativeTime(info.pushed_at), ); addRow("id_badge", "Node ID", (info) => info.node_id); } addRows(); } // Tracking prevention function preventTracking() { log("Calling preventTracking"); const elements = [ // Prevents tracking data from being sent to https://collector.github.com/github/collect // https://github.githubassets.com/assets/node_modules/@github/hydro-analytics-client/dist/meta-helpers.js // Breakpoint on function `getOptionsFromMeta` to see the argument `prefix`, which is `octolytics` // Or investigate `hydro-analytics.ts` mentioned above, you may find: `const options = getOptionsFromMeta('octolytics')` // Later, this script gathers information from `meta[name^="${prefix}-"]` elements, so we can remove them. // If `collectorUrl` is not set, the script will throw an error, thus preventing tracking. ...$$("meta[name^=octolytics-]"), // Prevents tracking data from being sent to `https://api.github.com/_private/browser/stats` // From "Network" tab, we can find that this request is sent by `https://github.githubassets.com/assets/ui/packages/stats/stats.ts` at function `safeSend`, who accepts two arguments: `url` and `data` // Search for this function in the current script, and you will find that it is only called once by function `flushStats` // `url` parameter is set in this function, by: `const url = ssrSafeDocument?.head?.querySelector('meta[name="browser-stats-url"]')?.content` // After removing the meta tag, the script will return, so we can remove this meta tag to prevent tracking. $("meta[name=browser-stats-url]"), ]; elements.forEach((el) => { if (el) { log("Preventing tracking:", el.name, el.content); el.content = ""; } }); // Clear contents instead of removing, to prevent potential issues if (elements.some((el) => el)) { GM_setValue( "trackingPrevented", GM_getValue("trackingPrevented", 0) + 1, ); } } function preventFetchPatching() { Object.defineProperty(unsafeWindow, "fetch", { writable: false, }); log("Unhooked window.fetch"); } function showRateLimit() { const resetDate = new Date(rateLimit.reset * 1000).toLocaleString(); alert( `Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}.\nIf you see -1, it means the rate limit has not been fetched yet, or GitHub has not provided the rate limit information.`, ); } const featureLifecycle = createFeatureLifecycleModule({ ready: documentReady, settings: { get: (prop) => config.get(prop), onGet(listener) { config.addEventListener("get", (event) => listener(event.detail), ); }, onSet(listener) { config.addEventListener("set", (event) => listener(event.detail), ); }, }, events: { on: (name, listener, options) => document.addEventListener(name, listener, options), }, scheduleFrame: (callback) => requestAnimationFrame(callback), environment: { isMainSite: location.hostname === topDomain, readyState: document.readyState, }, actions: { styleSheets, refreshIcons: updateIcons, connectCustomMenu, refreshReleases: setupListeners, refreshUserInfo: extendedUserInfo, refreshRepoInfo: extendedRepoInfo, patchArchivedRepoStars: (root = document) => patchArchivedRepoStarData(root), clearTrackingMetadata: preventTracking, protectFetch: preventFetchPatching, showRateLimit, logEvent: (eventName, event) => log(`Event: ${eventName}`, event), }, }); void featureLifecycle.start(); log(`${name} v${version} has been loaded 🎉`); })();