diff --git a/AGENTS.md b/AGENTS.md index 1706320..28cc43a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,5 +29,9 @@ - 默认使用中文 - 如果要求生成git提交信息,使用英文 +## 文案要求 +- `Description` 与 `README` 面向最终用户,不要介绍实现细节。 +- 文案应优先突出使用收益、使用场景与核心卖点,表达要有吸引力,让用户看完有安装和使用脚本的欲望。 + ## 防呆设计 - 如果编辑前发现处在main分支,请提醒用户切换到dev分支来开发。 diff --git a/README.md b/README.md index 2527ed9..02560d4 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,62 @@ ## 中文说明 -**Better GitHub Navigation** 是一款专为 GitHub 用户设计的 Tampermonkey(油猴)脚本。它旨在通过优化顶部导航栏,为你提供更高效、更直观的 GitHub 浏览体验。 +**Better GitHub Navigation** 是一款给 GitHub 补效率的 Tampermonkey(油猴)脚本。 + +如果你经常在 GitHub 里来回切 `Dashboard`、`Explore`、`Trending`、`Stars`,或者总要去首页左侧翻那几个最常开的仓库,这个脚本就是拿来解决这些问题的。它会把常用入口放到更顺手的位置,让你少找一步、少点一下、少绕一圈。 ### 🚀 核心功能 -- **快捷入口补全**:在顶部导航栏直接集成 `Dashboard`、`Trending`、`Explore`、`Collections` 和 `Stars` 等常用功能按钮。 -- **自由定制显示**:在设置面板中通过勾选框决定哪些按钮出现在导航栏,随时隐藏不需要的项。 -- **直观拖拽排序**:支持通过鼠标拖拽直接调整导航按钮的先后顺序,打造你的专属布局。 -- **智能高亮**:自动识别当前页面并精准高亮对应的导航按钮。 -- **布局优化**:在仓库详情页采用更符合逻辑的 `用户名 / 仓库名 / 功能项...` 排序。 -- **无缝衔接**:完美兼容 GitHub 的 Turbo/PJAX 机制,切页时按钮瞬间加载,无需刷新。 -- **双语支持**:界面支持中英文,可根据浏览器语言自动切换或手动指定。 +- **常用页面一键直达**:把 `Dashboard`、`Explore`、`Trending`、`Collections`、`Stars` 这些高频入口补到顶部导航,少绕路。 +- **快捷入口按你习惯来**:想显示哪些、放什么顺序,都可以自己调整,导航栏终于能配合你的工作流。 +- **常用仓库固定在手边**:GitHub 首页左侧 `Top repositories` 支持一键置顶,把你真正常开的仓库留在最前面。 +- **展开后也继续可用**:点开 `Show more` 之后,新显示出来的仓库也一样可以直接置顶。 +- **熟悉的 GitHub 感还在**:脚本只帮你把常用内容提到更近的位置,不会把整套使用习惯打乱。 +- **跨页浏览依然顺滑**:在 GitHub 里切页面时,增强入口会持续生效。 +- **双语界面更省心**:支持中文和英文,自动跟随页面语言,也能手动切换。 ### 🛠️ 如何使用 1. 安装脚本后,在 GitHub 页面点击油猴扩展图标。 2. 在脚本菜单中选择 **"Better GitHub Nav: 打开设置面板"**。 -3. 在弹出的面板中勾选你需要的按钮,并拖动行来调整顺序。 -4. 点击“保存并刷新”即可生效。 +3. 勾选你想保留的快捷入口,并拖动顺序,整理出最适合自己的导航栏。 +4. 回到 GitHub 首页,点击 `Top repositories` 每个仓库后方的置顶按钮,把最常用的仓库固定到前面。 +5. 需要查看更多仓库时,展开 `Show more`,新增显示的仓库也能继续置顶。 +6. 之后常用页面和常用仓库都会更靠近你,日常切换会明显更顺。 ### 📦 安装地址 请前往 GreasyFork 安装最新版本: 👉 [**GreasyFork: Better GitHub Navigation**](https://greasyfork.org/scripts/567335-better-github-navigation) ### 🔍 关键词 -GitHub 优化, 导航栏增强, 快捷按钮, 油猴脚本, 自定义布局, GitHub UI Enhancement, Navigation Shortcuts, Tampermonkey Script, Efficiency. +GitHub 首页增强, GitHub 导航增强, Top repositories 置顶, 常用仓库固定, Dashboard 快捷入口, Stars 快速访问, Tampermonkey 油猴脚本, GitHub 效率工具. --- ## English Description -**Better GitHub Navigation** is a userscript tailored for GitHub power users. It enhances the top navigation bar to provide a faster and more streamlined browsing experience. +**Better GitHub Navigation** is a Tampermonkey userscript built to make everyday GitHub navigation faster. + +If you keep jumping between `Dashboard`, `Explore`, `Trending`, `Stars`, and a handful of repositories you open all the time, this script brings those places closer. It adds better shortcuts where you already look and keeps your favorite repos easy to reach from the home page. ### 🚀 Key Features -- **Navigation Shortcuts**: Adds direct buttons for `Dashboard`, `Trending`, `Explore`, `Collections`, and `Stars` to the top header. -- **Customizable Buttons**: Toggle specific navigation buttons on or off via the settings panel to keep your header clean. -- **Drag & Drop Reordering**: Rearrange your navigation buttons by simply dragging them in the built-in settings panel. -- **Active State Highlighting**: Automatically highlights the correct navigation button based on your current page. -- **Optimized Layout**: Enhances the repository breadcrumb order to a more natural `Owner / Repo / Buttons...`. -- **Fast & Reliable**: Fully compatible with GitHub's Turbo/PJAX navigation—no page refreshes required. -- **Multilingual**: Supports both English and Chinese with automatic detection. +- **Jump to the pages you use most**: Add quick access to `Dashboard`, `Explore`, `Trending`, `Collections`, and `Stars` right in the header. +- **Shape the header around your workflow**: Keep only the shortcuts you want and arrange them in the order that makes sense for you. +- **Pin the repos you actually use**: The home-page `Top repositories` list gets one-click pinning, so your real favorites stay at the top. +- **Still works after `Show more`**: Expand the list and newly revealed repositories can be pinned too. +- **Feels like GitHub, just more convenient**: The script helps you surface what matters without making GitHub feel unfamiliar. +- **Stays with you while browsing**: The shortcuts keep working as you move around GitHub. +- **Comfortable in both Chinese and English**: The UI follows the page language and can also be switched manually. ### 🛠️ How to Use 1. After installation, click the Tampermonkey icon on any GitHub page. 2. Select **"Better GitHub Nav: Open Settings Panel"** from the script menu. -3. Toggle the buttons you want and drag rows to reorder your navigation bar. -4. Click "Save and Refresh" to apply your changes. +3. Keep the shortcuts you want, drag them into the order you like, and shape the header around your workflow. +4. On the GitHub home page, use the pin button next to any repo in `Top repositories` to keep it at the top. +5. If you open `Show more`, the newly revealed repositories can be pinned as well. +6. From then on, the pages and repos you use most stay much closer. ### 📦 Installation Install the latest version via GreasyFork: 👉 [**GreasyFork: Better GitHub Navigation**](https://greasyfork.org/scripts/567335-better-github-navigation) ### 🔍 Keywords -GitHub UI, Navigation Enhancement, Quick Buttons, Tampermonkey, Userscript, Custom Layout, GitHub Workflow, Efficiency. +GitHub navigation shortcuts, GitHub home sidebar, Top repositories pinning, favorite repositories, GitHub productivity, GitHub userscript, Tampermonkey, GitHub workflow. diff --git a/better-github-nav.user.js b/better-github-nav.user.js index d2f166b..483d38e 100644 --- a/better-github-nav.user.js +++ b/better-github-nav.user.js @@ -2,9 +2,9 @@ // @name Better GitHub Navigation // @name:zh-CN 更好的 GitHub 导航栏 // @namespace https://github.com/ImXiangYu/better-github-nav -// @version 0.1.38 -// @description Add quick access to Dashboard, Trending, Explore, Collections, and Stars from GitHub's top navigation. -// @description:zh-CN 在 GitHub 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。 +// @version 0.1.41 +// @description Add Dashboard, Trending, Explore, Collections, and Stars shortcuts to the top navigation bar for one-tap access to frequently used pages. Pin your most-used repositories to convenient locations. +// @description:zh-CN 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。并把你最常用的仓库固定在顺手的位置。 // @author Ayubass // @license MIT // @match https://github.com/* @@ -16,12 +16,13 @@ (() => { // src/constants.js - var SCRIPT_VERSION = "0.1.38"; + var SCRIPT_VERSION = "0.1.41"; var CUSTOM_BUTTON_CLASS = "custom-gh-nav-btn"; var CUSTOM_BUTTON_ACTIVE_CLASS = "custom-gh-nav-btn-active"; var CUSTOM_BUTTON_COMPACT_CLASS = "custom-gh-nav-btn-compact"; var QUICK_LINK_MARK_ATTR = "data-better-gh-nav-quick-link"; var CONFIG_STORAGE_KEY = "better-gh-nav-config-v1"; + var TOP_REPOSITORIES_PIN_STORAGE_KEY = "better-gh-nav-top-repositories-pins-v1"; var UI_LANG_STORAGE_KEY = "better-gh-nav-ui-lang-v1"; var SETTINGS_OVERLAY_ID = "custom-gh-nav-settings-overlay"; var SETTINGS_PANEL_ID = "custom-gh-nav-settings-panel"; @@ -57,7 +58,9 @@ restoredPendingSave: "已恢复默认,点击保存后生效。", atLeastOneLink: "至少保留 1 个快捷链接。", dragHandleTitle: "拖动调整顺序", - dragRowTitle: "拖动整行调整顺序" + dragRowTitle: "拖动整行调整顺序", + pinTopRepository: "置顶仓库:{repo}", + unpinTopRepository: "取消置顶仓库:{repo}" }, en: { menuOpenSettings: "Better GitHub Nav: Open Settings Panel", @@ -74,7 +77,9 @@ restoredPendingSave: "Defaults restored. Click save to apply.", atLeastOneLink: "Keep at least 1 quick link.", dragHandleTitle: "Drag to reorder", - dragRowTitle: "Drag row to reorder" + dragRowTitle: "Drag row to reorder", + pinTopRepository: "Pin repository: {repo}", + unpinTopRepository: "Unpin repository: {repo}" } }; @@ -354,6 +359,57 @@ color: var(--color-attention-fg, #9a6700); font-size: 12px; } + .custom-gh-top-repos-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + .custom-gh-top-repos-link { + flex: 1 1 auto; + min-width: 0; + } + .custom-gh-top-repos-pin { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 20px; + height: 20px; + border: none; + border-radius: 6px; + padding: 0; + background: transparent; + color: var(--color-fg-muted, #656d76); + cursor: pointer; + opacity: 0.75; + } + .custom-gh-top-repos-pin-icon { + width: 12px; + height: 12px; + overflow: visible; + } + .custom-gh-top-repos-pin:hover { + background: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + color: var(--color-fg-default, #1f2328); + opacity: 1; + } + .custom-gh-top-repos-pin:focus-visible { + outline: 2px solid var(--color-accent-fg, #0969da); + outline-offset: 1px; + } + .custom-gh-top-repos-pin.${CUSTOM_BUTTON_ACTIVE_CLASS}, + .custom-gh-top-repos-pin-active { + color: var(--color-accent-fg, #0969da); + background: var(--color-accent-subtle, rgba(9, 105, 218, 0.08)); + opacity: 1; + } + .custom-gh-top-repos-divider { + list-style: none; + height: 1px; + margin: 6px 0; + background: var(--color-border-muted, rgba(208, 215, 222, 0.8)); + } `; document.head.appendChild(style); } @@ -1071,19 +1127,375 @@ }); } + // src/top-repositories.js + var TOP_REPOSITORIES_HEADING_TEXT = "top repositories"; + var TOP_REPOSITORIES_BUTTON_CLASS = "custom-gh-top-repos-pin"; + var TOP_REPOSITORIES_BUTTON_ACTIVE_CLASS = "custom-gh-top-repos-pin-active"; + var TOP_REPOSITORIES_BUTTON_ICON_CLASS = "custom-gh-top-repos-pin-icon"; + var TOP_REPOSITORIES_DIVIDER_CLASS = "custom-gh-top-repos-divider"; + var TOP_REPOSITORIES_ROW_CLASS = "custom-gh-top-repos-row"; + var TOP_REPOSITORIES_LINK_CLASS = "custom-gh-top-repos-link"; + var TOP_REPOSITORIES_SHOW_MORE_PREFIXES = ["show more", "show less"]; + var SVG_NS = "http://www.w3.org/2000/svg"; + var RESERVED_FIRST_SEGMENTS = /* @__PURE__ */ new Set([ + "about", + "account", + "apps", + "codespaces", + "collections", + "dashboard", + "explore", + "marketplace", + "new", + "notifications", + "organizations", + "orgs", + "pulls", + "repositories", + "search", + "sessions", + "settings", + "signup", + "site", + "sponsors", + "stars", + "topics", + "trending", + "users" + ]); + function normalizeText(text) { + return String(text || "").replace(/\s+/g, " ").trim().toLowerCase(); + } + function normalizeRepoKey(repoKey) { + return normalizeText(repoKey).replace(/\s+/g, ""); + } + function parseRepoInfoFromHref(href) { + try { + const url = new URL(href, location.origin); + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length !== 2) return null; + const owner = decodeURIComponent(segments[0] || ""); + const repo = decodeURIComponent(segments[1] || ""); + if (!owner || !repo) return null; + if (RESERVED_FIRST_SEGMENTS.has(owner.toLowerCase())) return null; + return { + key: normalizeRepoKey(`${owner}/${repo}`), + label: `${owner}/${repo}` + }; + } catch (error) { + return null; + } + } + function getNodeDepth(node) { + let depth = 0; + let current = node; + while (current && current.parentElement) { + depth += 1; + current = current.parentElement; + } + return depth; + } + function getDirectRepoRows(container) { + if (!container) return []; + const rowsByNode = /* @__PURE__ */ new Map(); + const anchors = Array.from(container.querySelectorAll('a[href^="/"]')); + anchors.forEach((anchor) => { + let rowNode = anchor; + while (rowNode.parentElement && rowNode.parentElement !== container) { + rowNode = rowNode.parentElement; + } + if (!rowNode.parentElement || rowNode.parentElement !== container) return; + const repoInfo = parseRepoInfoFromHref(anchor.getAttribute("href") || ""); + if (!repoInfo) return; + const existing = rowsByNode.get(rowNode); + if (!existing) { + rowsByNode.set(rowNode, { + node: rowNode, + anchor, + repoKey: repoInfo.key, + repoLabel: repoInfo.label + }); + return; + } + if (anchor.textContent.trim().length > existing.anchor.textContent.trim().length) { + existing.anchor = anchor; + existing.repoKey = repoInfo.key; + existing.repoLabel = repoInfo.label; + } + }); + return Array.from(rowsByNode.values()); + } + function getTopRepositoriesHeading() { + const candidates = Array.from( + document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"], summary, span, strong') + ); + return candidates.find((node) => normalizeText(node.textContent) === TOP_REPOSITORIES_HEADING_TEXT) || null; + } + function findTopRepositoriesList() { + const heading = getTopRepositoriesHeading(); + if (!heading) return null; + const roots = []; + const sectionRoot = heading.closest("section, aside"); + if (sectionRoot) roots.push(sectionRoot); + if (heading.parentElement && !roots.includes(heading.parentElement)) { + roots.push(heading.parentElement); + } + for (const root of roots) { + const semanticCandidates = [ + ...root.matches("ul, ol, nav") ? [root] : [], + ...Array.from(root.querySelectorAll("ul, ol, nav")) + ]; + let bestSemanticMatch = null; + semanticCandidates.forEach((candidate) => { + const items = getDirectRepoRows(candidate); + if (!items.length) return; + const score = items.length * 1e3 + (items.length === candidate.children.length ? 100 : 0) + getNodeDepth(candidate); + if (!bestSemanticMatch || score > bestSemanticMatch.score) { + bestSemanticMatch = { container: candidate, items, score }; + } + }); + if (bestSemanticMatch) { + return { container: bestSemanticMatch.container, items: bestSemanticMatch.items }; + } + const genericCandidates = [ + ...root.matches("div") ? [root] : [], + ...Array.from(root.querySelectorAll("div")) + ]; + let bestMatch = null; + genericCandidates.forEach((candidate) => { + const items = getDirectRepoRows(candidate); + if (!items.length) return; + const score = items.length * 1e3 + (items.length === candidate.children.length ? 100 : 0) + getNodeDepth(candidate); + if (!bestMatch || score > bestMatch.score) { + bestMatch = { container: candidate, items, score }; + } + }); + if (bestMatch) { + return { container: bestMatch.container, items: bestMatch.items }; + } + } + return null; + } + function sanitizePinnedRepositories(rawValue) { + if (!Array.isArray(rawValue)) return []; + const seen = /* @__PURE__ */ new Set(); + const result = []; + rawValue.forEach((item) => { + const repoKey = normalizeRepoKey(item); + if (!repoKey || seen.has(repoKey)) return; + seen.add(repoKey); + result.push(repoKey); + }); + return result; + } + function loadPinnedRepositories() { + try { + const raw = localStorage.getItem(TOP_REPOSITORIES_PIN_STORAGE_KEY); + if (!raw) return []; + return sanitizePinnedRepositories(JSON.parse(raw)); + } catch (error) { + return []; + } + } + function savePinnedRepositories(repoKeys) { + const pinnedRepositories = sanitizePinnedRepositories(repoKeys); + try { + localStorage.setItem(TOP_REPOSITORIES_PIN_STORAGE_KEY, JSON.stringify(pinnedRepositories)); + } catch (error) { + } + } + function togglePinnedRepository(repoKey) { + const normalizedRepoKey = normalizeRepoKey(repoKey); + if (!normalizedRepoKey) return; + const pinnedSet = new Set(loadPinnedRepositories()); + if (pinnedSet.has(normalizedRepoKey)) { + pinnedSet.delete(normalizedRepoKey); + } else { + pinnedSet.add(normalizedRepoKey); + } + savePinnedRepositories(Array.from(pinnedSet)); + } + function createDividerElement(container) { + const tagName = container.tagName.toLowerCase(); + const divider = document.createElement(tagName === "ul" || tagName === "ol" ? "li" : "div"); + divider.className = TOP_REPOSITORIES_DIVIDER_CLASS; + divider.setAttribute("aria-hidden", "true"); + return divider; + } + function ensureRowNodeIsWrappable(item, container) { + if (item.node.tagName.toLowerCase() !== "a") return item; + const wrapperTag = container.tagName.toLowerCase() === "ul" || container.tagName.toLowerCase() === "ol" ? "li" : "div"; + const wrapper = document.createElement(wrapperTag); + wrapper.className = TOP_REPOSITORIES_ROW_CLASS; + item.node.replaceWith(wrapper); + wrapper.appendChild(item.node); + item.node = wrapper; + return item; + } + function createSvgElement(name, attrs = {}) { + const node = document.createElementNS(SVG_NS, name); + Object.entries(attrs).forEach(([key, value]) => { + node.setAttribute(key, String(value)); + }); + return node; + } + function createPinIcon(isPinned) { + const svg = createSvgElement("svg", { + viewBox: "0 0 16 16", + "aria-hidden": "true", + class: TOP_REPOSITORIES_BUTTON_ICON_CLASS + }); + const head = createSvgElement("circle", { + cx: "10", + cy: "4", + r: "1.9", + fill: isPinned ? "currentColor" : "none", + stroke: "currentColor", + "stroke-width": "1.2" + }); + const body = createSvgElement("rect", { + x: "6.8", + y: "5.4", + width: "5.1", + height: "2.5", + rx: "0.8", + fill: isPinned ? "currentColor" : "none", + stroke: "currentColor", + "stroke-width": "1.2", + transform: "rotate(32 9.35 6.65)" + }); + const needle = createSvgElement("path", { + d: "M8.5 8.9 4.3 13.1", + fill: "none", + stroke: "currentColor", + "stroke-width": "1.2", + "stroke-linecap": "round" + }); + svg.appendChild(head); + svg.appendChild(body); + svg.appendChild(needle); + return svg; + } + function getPrimaryContentNode(item) { + let current = item.anchor; + while (current.parentElement && current.parentElement !== item.node) { + if (current.parentElement.children.length !== 1) break; + current = current.parentElement; + } + return current; + } + function renderPinButtons(container, items, pinnedSet) { + items.forEach((item) => { + ensureRowNodeIsWrappable(item, container); + item.node.classList.add(TOP_REPOSITORIES_ROW_CLASS); + getPrimaryContentNode(item).classList.add(TOP_REPOSITORIES_LINK_CLASS); + item.node.querySelectorAll(`.${TOP_REPOSITORIES_BUTTON_CLASS}`).forEach((button) => button.remove()); + const isPinned = pinnedSet.has(item.repoKey); + const pinButton = document.createElement("button"); + pinButton.type = "button"; + pinButton.className = TOP_REPOSITORIES_BUTTON_CLASS; + pinButton.appendChild(createPinIcon(isPinned)); + pinButton.setAttribute("aria-pressed", isPinned ? "true" : "false"); + pinButton.title = isPinned ? t("unpinTopRepository", { repo: item.repoLabel }) : t("pinTopRepository", { repo: item.repoLabel }); + pinButton.setAttribute("aria-label", pinButton.title); + if (isPinned) { + pinButton.classList.add(TOP_REPOSITORIES_BUTTON_ACTIVE_CLASS); + } + pinButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + togglePinnedRepository(item.repoKey); + enhanceTopRepositories(); + }); + item.node.appendChild(pinButton); + }); + } + function reorderRows(container, items, pinnedSet) { + const pinnedItems = items.filter((item) => pinnedSet.has(item.repoKey)); + const regularItems = items.filter((item) => !pinnedSet.has(item.repoKey)); + container.querySelectorAll(`:scope > .${TOP_REPOSITORIES_DIVIDER_CLASS}`).forEach((node) => node.remove()); + const children = Array.from(container.children); + const repoNodes = new Set(items.map((item) => item.node)); + const firstRepoIndex = children.findIndex((child) => repoNodes.has(child)); + const beforeRepoChildren = firstRepoIndex < 0 ? [] : children.slice(0, firstRepoIndex).filter((child) => !repoNodes.has(child)); + const afterRepoChildren = firstRepoIndex < 0 ? children.filter((child) => !repoNodes.has(child)) : children.slice(firstRepoIndex).filter((child) => !repoNodes.has(child)); + const orderedNodes = [ + ...pinnedItems.map((item) => item.node), + ...pinnedItems.length && regularItems.length ? [createDividerElement(container)] : [], + ...regularItems.map((item) => item.node) + ]; + const fragment = document.createDocumentFragment(); + beforeRepoChildren.forEach((node) => fragment.appendChild(node)); + orderedNodes.forEach((node) => fragment.appendChild(node)); + afterRepoChildren.forEach((node) => fragment.appendChild(node)); + container.replaceChildren(fragment); + } + function isDashboardHomePage() { + const path = location.pathname.replace(/\/+$/, "") || "/"; + return path === "/" || path === "/dashboard"; + } + function hasTopRepositoriesHeading() { + return Boolean(getTopRepositoriesHeading()); + } + function needsTopRepositoriesEnhancement() { + if (!isDashboardHomePage()) return false; + const listMatch = findTopRepositoriesList(); + if (!listMatch || !listMatch.items.length) return false; + return listMatch.items.some((item) => !item.node.querySelector(`.${TOP_REPOSITORIES_BUTTON_CLASS}`)); + } + function isTopRepositoriesToggleTarget(target) { + if (!(target instanceof Element)) return false; + const heading = getTopRepositoriesHeading(); + if (!heading) return false; + const root = heading.closest("section, aside") || heading.parentElement; + if (!root) return false; + const trigger = target.closest('button, a, summary, [role="button"]'); + if (!trigger || !root.contains(trigger)) return false; + const expanded = trigger.getAttribute("aria-expanded"); + if (expanded === "true" || expanded === "false") return true; + const text = normalizeText(trigger.textContent); + return TOP_REPOSITORIES_SHOW_MORE_PREFIXES.some((prefix) => text.startsWith(prefix)); + } + function enhanceTopRepositories() { + if (!isDashboardHomePage()) return; + const listMatch = findTopRepositoriesList(); + if (!listMatch || !listMatch.items.length) return; + const pinnedSet = new Set(loadPinnedRepositories()); + renderPinButtons(listMatch.container, listMatch.items, pinnedSet); + reorderRows(listMatch.container, listMatch.items, pinnedSet); + } + // src/main.js + var renderQueued = false; + function applyEnhancements() { + ensureStyles(); + addCustomButtons(); + enhanceTopRepositories(); + } + function scheduleEnhancements() { + if (renderQueued) return; + renderQueued = true; + requestAnimationFrame(() => { + renderQueued = false; + applyEnhancements(); + }); + } console.info(`[Better GitHub Navigation] loaded v${SCRIPT_VERSION}`); window.__betterGithubNavVersion = SCRIPT_VERSION; window.__openBetterGithubNavSettings = openConfigPanel; registerConfigMenu(); - ensureStyles(); - addCustomButtons(); - document.addEventListener("turbo:load", addCustomButtons); - document.addEventListener("pjax:end", addCustomButtons); + scheduleEnhancements(); + document.addEventListener("turbo:load", scheduleEnhancements); + document.addEventListener("pjax:end", scheduleEnhancements); + document.addEventListener("click", (event) => { + if (!isTopRepositoriesToggleTarget(event.target)) return; + setTimeout(scheduleEnhancements, 0); + }); var observer = new MutationObserver(() => { - if (!document.querySelector('[id^="custom-gh-btn-"]') && document.querySelector("header")) { - addCustomButtons(); - } + const hasHeader = Boolean(document.querySelector("header")); + const missingNavButtons = hasHeader && !document.querySelector('[id^="custom-gh-btn-"]'); + const missingTopRepoPins = isDashboardHomePage() && hasTopRepositoriesHeading() && needsTopRepositoriesEnhancement(); + if (missingNavButtons || missingTopRepoPins) scheduleEnhancements(); }); observer.observe(document.body, { childList: true, subtree: true }); })(); diff --git a/package-lock.json b/package-lock.json index 4bcc89f..930f70c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "better-github-nav", - "version": "0.1.38", + "version": "0.1.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "better-github-nav", - "version": "0.1.38", + "version": "0.1.41", "license": "MIT", "devDependencies": { "esbuild": "^0.27.3" diff --git a/package.json b/package.json index 769d7af..4d94711 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "better-github-nav", - "version": "0.1.38", - "description": "Build tools for the Better GitHub Navigation userscript.", + "version": "0.1.41", + "description": "Add Dashboard, Trending, Explore, Collections, and Stars shortcuts to the top navigation bar for one-tap access to frequently used pages. Pin your most-used repositories to convenient locations.", "private": true, "scripts": { "build": "node scripts/build.mjs", diff --git a/scripts/userscript-header.txt b/scripts/userscript-header.txt index 4bb9962..74e44ec 100644 --- a/scripts/userscript-header.txt +++ b/scripts/userscript-header.txt @@ -3,8 +3,8 @@ // @name:zh-CN 更好的 GitHub 导航栏 // @namespace https://github.com/ImXiangYu/better-github-nav // @version __VERSION__ -// @description Add quick access to Dashboard, Trending, Explore, Collections, and Stars from GitHub's top navigation. -// @description:zh-CN 在 GitHub 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。 +// @description Add Dashboard, Trending, Explore, Collections, and Stars shortcuts to the top navigation bar for one-tap access to frequently used pages. Pin your most-used repositories to convenient locations. +// @description:zh-CN 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。并把你最常用的仓库固定在顺手的位置。 // @author Ayubass // @license MIT // @match https://github.com/* diff --git a/src/constants.js b/src/constants.js index a125107..03b0460 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,6 +5,7 @@ export const CUSTOM_BUTTON_ACTIVE_CLASS = 'custom-gh-nav-btn-active'; export const CUSTOM_BUTTON_COMPACT_CLASS = 'custom-gh-nav-btn-compact'; export const QUICK_LINK_MARK_ATTR = 'data-better-gh-nav-quick-link'; export const CONFIG_STORAGE_KEY = 'better-gh-nav-config-v1'; +export const TOP_REPOSITORIES_PIN_STORAGE_KEY = 'better-gh-nav-top-repositories-pins-v1'; export const UI_LANG_STORAGE_KEY = 'better-gh-nav-ui-lang-v1'; export const SETTINGS_OVERLAY_ID = 'custom-gh-nav-settings-overlay'; export const SETTINGS_PANEL_ID = 'custom-gh-nav-settings-panel'; @@ -43,7 +44,9 @@ export const I18N = { restoredPendingSave: '已恢复默认,点击保存后生效。', atLeastOneLink: '至少保留 1 个快捷链接。', dragHandleTitle: '拖动调整顺序', - dragRowTitle: '拖动整行调整顺序' + dragRowTitle: '拖动整行调整顺序', + pinTopRepository: '置顶仓库:{repo}', + unpinTopRepository: '取消置顶仓库:{repo}' }, en: { menuOpenSettings: 'Better GitHub Nav: Open Settings Panel', @@ -60,6 +63,8 @@ export const I18N = { restoredPendingSave: 'Defaults restored. Click save to apply.', atLeastOneLink: 'Keep at least 1 quick link.', dragHandleTitle: 'Drag to reorder', - dragRowTitle: 'Drag row to reorder' + dragRowTitle: 'Drag row to reorder', + pinTopRepository: 'Pin repository: {repo}', + unpinTopRepository: 'Unpin repository: {repo}' } }; diff --git a/src/main.js b/src/main.js index 14ade66..8e11e76 100644 --- a/src/main.js +++ b/src/main.js @@ -2,23 +2,52 @@ import { SCRIPT_VERSION } from './constants.js'; import { addCustomButtons } from './navigation.js'; import { openConfigPanel, registerConfigMenu } from './settings-panel.js'; import { ensureStyles } from './styles.js'; +import { + enhanceTopRepositories, + hasTopRepositoriesHeading, + isDashboardHomePage, + isTopRepositoriesToggleTarget, + needsTopRepositoriesEnhancement +} from './top-repositories.js'; + +let renderQueued = false; + +function applyEnhancements() { + ensureStyles(); + addCustomButtons(); + enhanceTopRepositories(); +} + +function scheduleEnhancements() { + if (renderQueued) return; + renderQueued = true; + requestAnimationFrame(() => { + renderQueued = false; + applyEnhancements(); + }); +} // 1. 页面初次加载时执行 console.info(`[Better GitHub Navigation] loaded v${SCRIPT_VERSION}`); window.__betterGithubNavVersion = SCRIPT_VERSION; window.__openBetterGithubNavSettings = openConfigPanel; registerConfigMenu(); -ensureStyles(); -addCustomButtons(); +scheduleEnhancements(); // 2. 监听 GitHub 的 Turbo/PJAX 页面跳转事件,防止切换页面后按钮消失 -document.addEventListener('turbo:load', addCustomButtons); -document.addEventListener('pjax:end', addCustomButtons); +document.addEventListener('turbo:load', scheduleEnhancements); +document.addEventListener('pjax:end', scheduleEnhancements); +document.addEventListener('click', event => { + if (!isTopRepositoriesToggleTarget(event.target)) return; + setTimeout(scheduleEnhancements, 0); +}); // 3. 终极备用方案:使用 MutationObserver 监听 DOM 变化 const observer = new MutationObserver(() => { - if (!document.querySelector('[id^="custom-gh-btn-"]') && document.querySelector('header')) { - addCustomButtons(); - } + const hasHeader = Boolean(document.querySelector('header')); + const missingNavButtons = hasHeader && !document.querySelector('[id^="custom-gh-btn-"]'); + const missingTopRepoPins = isDashboardHomePage() && hasTopRepositoriesHeading() + && needsTopRepositoriesEnhancement(); + if (missingNavButtons || missingTopRepoPins) scheduleEnhancements(); }); observer.observe(document.body, { childList: true, subtree: true }); diff --git a/src/styles.js b/src/styles.js index 79e43f7..2b8834e 100644 --- a/src/styles.js +++ b/src/styles.js @@ -227,6 +227,57 @@ export function ensureStyles() { color: var(--color-attention-fg, #9a6700); font-size: 12px; } + .custom-gh-top-repos-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + .custom-gh-top-repos-link { + flex: 1 1 auto; + min-width: 0; + } + .custom-gh-top-repos-pin { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 20px; + height: 20px; + border: none; + border-radius: 6px; + padding: 0; + background: transparent; + color: var(--color-fg-muted, #656d76); + cursor: pointer; + opacity: 0.75; + } + .custom-gh-top-repos-pin-icon { + width: 12px; + height: 12px; + overflow: visible; + } + .custom-gh-top-repos-pin:hover { + background: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + color: var(--color-fg-default, #1f2328); + opacity: 1; + } + .custom-gh-top-repos-pin:focus-visible { + outline: 2px solid var(--color-accent-fg, #0969da); + outline-offset: 1px; + } + .custom-gh-top-repos-pin.${CUSTOM_BUTTON_ACTIVE_CLASS}, + .custom-gh-top-repos-pin-active { + color: var(--color-accent-fg, #0969da); + background: var(--color-accent-subtle, rgba(9, 105, 218, 0.08)); + opacity: 1; + } + .custom-gh-top-repos-divider { + list-style: none; + height: 1px; + margin: 6px 0; + background: var(--color-border-muted, rgba(208, 215, 222, 0.8)); + } `; document.head.appendChild(style); } diff --git a/src/top-repositories.js b/src/top-repositories.js new file mode 100644 index 0000000..434d4c5 --- /dev/null +++ b/src/top-repositories.js @@ -0,0 +1,412 @@ +import { TOP_REPOSITORIES_PIN_STORAGE_KEY } from './constants.js'; +import { t } from './i18n.js'; + +const TOP_REPOSITORIES_HEADING_TEXT = 'top repositories'; +const TOP_REPOSITORIES_BUTTON_CLASS = 'custom-gh-top-repos-pin'; +const TOP_REPOSITORIES_BUTTON_ACTIVE_CLASS = 'custom-gh-top-repos-pin-active'; +const TOP_REPOSITORIES_BUTTON_ICON_CLASS = 'custom-gh-top-repos-pin-icon'; +const TOP_REPOSITORIES_DIVIDER_CLASS = 'custom-gh-top-repos-divider'; +const TOP_REPOSITORIES_ROW_CLASS = 'custom-gh-top-repos-row'; +const TOP_REPOSITORIES_LINK_CLASS = 'custom-gh-top-repos-link'; +const TOP_REPOSITORIES_SHOW_MORE_PREFIXES = ['show more', 'show less']; +const SVG_NS = 'http://www.w3.org/2000/svg'; +const RESERVED_FIRST_SEGMENTS = new Set([ + 'about', + 'account', + 'apps', + 'codespaces', + 'collections', + 'dashboard', + 'explore', + 'marketplace', + 'new', + 'notifications', + 'organizations', + 'orgs', + 'pulls', + 'repositories', + 'search', + 'sessions', + 'settings', + 'signup', + 'site', + 'sponsors', + 'stars', + 'topics', + 'trending', + 'users' +]); + +function normalizeText(text) { + return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase(); +} + +function normalizeRepoKey(repoKey) { + return normalizeText(repoKey).replace(/\s+/g, ''); +} + +function parseRepoInfoFromHref(href) { + try { + const url = new URL(href, location.origin); + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return null; + + const owner = decodeURIComponent(segments[0] || ''); + const repo = decodeURIComponent(segments[1] || ''); + if (!owner || !repo) return null; + if (RESERVED_FIRST_SEGMENTS.has(owner.toLowerCase())) return null; + + return { + key: normalizeRepoKey(`${owner}/${repo}`), + label: `${owner}/${repo}` + }; + } catch (error) { + return null; + } +} + +function getNodeDepth(node) { + let depth = 0; + let current = node; + while (current && current.parentElement) { + depth += 1; + current = current.parentElement; + } + return depth; +} + +function getDirectRepoRows(container) { + if (!container) return []; + + const rowsByNode = new Map(); + const anchors = Array.from(container.querySelectorAll('a[href^="/"]')); + anchors.forEach(anchor => { + let rowNode = anchor; + while (rowNode.parentElement && rowNode.parentElement !== container) { + rowNode = rowNode.parentElement; + } + if (!rowNode.parentElement || rowNode.parentElement !== container) return; + + const repoInfo = parseRepoInfoFromHref(anchor.getAttribute('href') || ''); + if (!repoInfo) return; + + const existing = rowsByNode.get(rowNode); + if (!existing) { + rowsByNode.set(rowNode, { + node: rowNode, + anchor, + repoKey: repoInfo.key, + repoLabel: repoInfo.label + }); + return; + } + + if (anchor.textContent.trim().length > existing.anchor.textContent.trim().length) { + existing.anchor = anchor; + existing.repoKey = repoInfo.key; + existing.repoLabel = repoInfo.label; + } + }); + + return Array.from(rowsByNode.values()); +} + +function getTopRepositoriesHeading() { + const candidates = Array.from( + document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"], summary, span, strong') + ); + + return candidates.find(node => normalizeText(node.textContent) === TOP_REPOSITORIES_HEADING_TEXT) || null; +} + +function findTopRepositoriesList() { + const heading = getTopRepositoriesHeading(); + if (!heading) return null; + + const roots = []; + const sectionRoot = heading.closest('section, aside'); + if (sectionRoot) roots.push(sectionRoot); + if (heading.parentElement && !roots.includes(heading.parentElement)) { + roots.push(heading.parentElement); + } + + for (const root of roots) { + const semanticCandidates = [ + ...(root.matches('ul, ol, nav') ? [root] : []), + ...Array.from(root.querySelectorAll('ul, ol, nav')) + ]; + let bestSemanticMatch = null; + semanticCandidates.forEach(candidate => { + const items = getDirectRepoRows(candidate); + if (!items.length) return; + + const score = (items.length * 1000) + + (items.length === candidate.children.length ? 100 : 0) + + getNodeDepth(candidate); + + if (!bestSemanticMatch || score > bestSemanticMatch.score) { + bestSemanticMatch = { container: candidate, items, score }; + } + }); + if (bestSemanticMatch) { + return { container: bestSemanticMatch.container, items: bestSemanticMatch.items }; + } + + const genericCandidates = [ + ...(root.matches('div') ? [root] : []), + ...Array.from(root.querySelectorAll('div')) + ]; + let bestMatch = null; + genericCandidates.forEach(candidate => { + const items = getDirectRepoRows(candidate); + if (!items.length) return; + + const score = (items.length * 1000) + + (items.length === candidate.children.length ? 100 : 0) + + getNodeDepth(candidate); + + if (!bestMatch || score > bestMatch.score) { + bestMatch = { container: candidate, items, score }; + } + }); + + if (bestMatch) { + return { container: bestMatch.container, items: bestMatch.items }; + } + } + + return null; +} + +function sanitizePinnedRepositories(rawValue) { + if (!Array.isArray(rawValue)) return []; + + const seen = new Set(); + const result = []; + rawValue.forEach(item => { + const repoKey = normalizeRepoKey(item); + if (!repoKey || seen.has(repoKey)) return; + seen.add(repoKey); + result.push(repoKey); + }); + return result; +} + +function loadPinnedRepositories() { + try { + const raw = localStorage.getItem(TOP_REPOSITORIES_PIN_STORAGE_KEY); + if (!raw) return []; + return sanitizePinnedRepositories(JSON.parse(raw)); + } catch (error) { + return []; + } +} + +function savePinnedRepositories(repoKeys) { + const pinnedRepositories = sanitizePinnedRepositories(repoKeys); + try { + localStorage.setItem(TOP_REPOSITORIES_PIN_STORAGE_KEY, JSON.stringify(pinnedRepositories)); + } catch (error) { + // ignore storage write failures and keep the current session functional + } +} + +function togglePinnedRepository(repoKey) { + const normalizedRepoKey = normalizeRepoKey(repoKey); + if (!normalizedRepoKey) return; + + const pinnedSet = new Set(loadPinnedRepositories()); + if (pinnedSet.has(normalizedRepoKey)) { + pinnedSet.delete(normalizedRepoKey); + } else { + pinnedSet.add(normalizedRepoKey); + } + savePinnedRepositories(Array.from(pinnedSet)); +} + +function createDividerElement(container) { + const tagName = container.tagName.toLowerCase(); + const divider = document.createElement(tagName === 'ul' || tagName === 'ol' ? 'li' : 'div'); + divider.className = TOP_REPOSITORIES_DIVIDER_CLASS; + divider.setAttribute('aria-hidden', 'true'); + return divider; +} + +function ensureRowNodeIsWrappable(item, container) { + if (item.node.tagName.toLowerCase() !== 'a') return item; + + const wrapperTag = container.tagName.toLowerCase() === 'ul' || container.tagName.toLowerCase() === 'ol' + ? 'li' + : 'div'; + const wrapper = document.createElement(wrapperTag); + wrapper.className = TOP_REPOSITORIES_ROW_CLASS; + item.node.replaceWith(wrapper); + wrapper.appendChild(item.node); + item.node = wrapper; + return item; +} + +function createSvgElement(name, attrs = {}) { + const node = document.createElementNS(SVG_NS, name); + Object.entries(attrs).forEach(([key, value]) => { + node.setAttribute(key, String(value)); + }); + return node; +} + +function createPinIcon(isPinned) { + const svg = createSvgElement('svg', { + viewBox: '0 0 16 16', + 'aria-hidden': 'true', + class: TOP_REPOSITORIES_BUTTON_ICON_CLASS + }); + + const head = createSvgElement('circle', { + cx: '10', + cy: '4', + r: '1.9', + fill: isPinned ? 'currentColor' : 'none', + stroke: 'currentColor', + 'stroke-width': '1.2' + }); + const body = createSvgElement('rect', { + x: '6.8', + y: '5.4', + width: '5.1', + height: '2.5', + rx: '0.8', + fill: isPinned ? 'currentColor' : 'none', + stroke: 'currentColor', + 'stroke-width': '1.2', + transform: 'rotate(32 9.35 6.65)' + }); + const needle = createSvgElement('path', { + d: 'M8.5 8.9 4.3 13.1', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '1.2', + 'stroke-linecap': 'round' + }); + + svg.appendChild(head); + svg.appendChild(body); + svg.appendChild(needle); + return svg; +} + +function getPrimaryContentNode(item) { + let current = item.anchor; + while (current.parentElement && current.parentElement !== item.node) { + if (current.parentElement.children.length !== 1) break; + current = current.parentElement; + } + return current; +} + +function renderPinButtons(container, items, pinnedSet) { + items.forEach(item => { + ensureRowNodeIsWrappable(item, container); + item.node.classList.add(TOP_REPOSITORIES_ROW_CLASS); + getPrimaryContentNode(item).classList.add(TOP_REPOSITORIES_LINK_CLASS); + + item.node.querySelectorAll(`.${TOP_REPOSITORIES_BUTTON_CLASS}`).forEach(button => button.remove()); + + const isPinned = pinnedSet.has(item.repoKey); + const pinButton = document.createElement('button'); + pinButton.type = 'button'; + pinButton.className = TOP_REPOSITORIES_BUTTON_CLASS; + pinButton.appendChild(createPinIcon(isPinned)); + pinButton.setAttribute('aria-pressed', isPinned ? 'true' : 'false'); + pinButton.title = isPinned + ? t('unpinTopRepository', { repo: item.repoLabel }) + : t('pinTopRepository', { repo: item.repoLabel }); + pinButton.setAttribute('aria-label', pinButton.title); + if (isPinned) { + pinButton.classList.add(TOP_REPOSITORIES_BUTTON_ACTIVE_CLASS); + } + pinButton.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + togglePinnedRepository(item.repoKey); + enhanceTopRepositories(); + }); + + item.node.appendChild(pinButton); + }); +} + +function reorderRows(container, items, pinnedSet) { + const pinnedItems = items.filter(item => pinnedSet.has(item.repoKey)); + const regularItems = items.filter(item => !pinnedSet.has(item.repoKey)); + + container.querySelectorAll(`:scope > .${TOP_REPOSITORIES_DIVIDER_CLASS}`).forEach(node => node.remove()); + + const children = Array.from(container.children); + const repoNodes = new Set(items.map(item => item.node)); + const firstRepoIndex = children.findIndex(child => repoNodes.has(child)); + const beforeRepoChildren = firstRepoIndex < 0 + ? [] + : children.slice(0, firstRepoIndex).filter(child => !repoNodes.has(child)); + const afterRepoChildren = firstRepoIndex < 0 + ? children.filter(child => !repoNodes.has(child)) + : children.slice(firstRepoIndex).filter(child => !repoNodes.has(child)); + const orderedNodes = [ + ...pinnedItems.map(item => item.node), + ...(pinnedItems.length && regularItems.length ? [createDividerElement(container)] : []), + ...regularItems.map(item => item.node) + ]; + + const fragment = document.createDocumentFragment(); + beforeRepoChildren.forEach(node => fragment.appendChild(node)); + orderedNodes.forEach(node => fragment.appendChild(node)); + afterRepoChildren.forEach(node => fragment.appendChild(node)); + container.replaceChildren(fragment); +} + +export function isDashboardHomePage() { + const path = location.pathname.replace(/\/+$/, '') || '/'; + return path === '/' || path === '/dashboard'; +} + +export function hasTopRepositoriesHeading() { + return Boolean(getTopRepositoriesHeading()); +} + +export function needsTopRepositoriesEnhancement() { + if (!isDashboardHomePage()) return false; + + const listMatch = findTopRepositoriesList(); + if (!listMatch || !listMatch.items.length) return false; + + return listMatch.items.some(item => !item.node.querySelector(`.${TOP_REPOSITORIES_BUTTON_CLASS}`)); +} + +export function isTopRepositoriesToggleTarget(target) { + if (!(target instanceof Element)) return false; + + const heading = getTopRepositoriesHeading(); + if (!heading) return false; + + const root = heading.closest('section, aside') || heading.parentElement; + if (!root) return false; + + const trigger = target.closest('button, a, summary, [role="button"]'); + if (!trigger || !root.contains(trigger)) return false; + + const expanded = trigger.getAttribute('aria-expanded'); + if (expanded === 'true' || expanded === 'false') return true; + + const text = normalizeText(trigger.textContent); + return TOP_REPOSITORIES_SHOW_MORE_PREFIXES.some(prefix => text.startsWith(prefix)); +} + +export function enhanceTopRepositories() { + if (!isDashboardHomePage()) return; + + const listMatch = findTopRepositoriesList(); + if (!listMatch || !listMatch.items.length) return; + + const pinnedSet = new Set(loadPinnedRepositories()); + renderPinButtons(listMatch.container, listMatch.items, pinnedSet); + reorderRows(listMatch.container, listMatch.items, pinnedSet); +}