diff --git a/src/content.ts b/src/content.ts index aa76553..e4ef360 100644 --- a/src/content.ts +++ b/src/content.ts @@ -12,6 +12,8 @@ import { injectCommitDiffStats } from "./features/commit-diff-stats"; import { injectBetterTopRepos } from "./features/better-top-repos"; import { injectWatchForkStarPopup } from "./features/watch-fork-star-popup"; import { injectPRCollapseExpand } from "./features/pr-collapse-expand"; +import { reserveInfoRowSkeletons } from "./lib/info-row-skeleton"; +import { applyPageMarker } from "./lib/page-marker"; const FEATURE_KEYS = [ "feature-pr-branch-names", @@ -32,15 +34,15 @@ type FeatureKey = (typeof FEATURE_KEYS)[number]; // CSS classes used by each feature's injected elements const FEATURE_CLASSES: Record = { - "feature-pr-branch-names": ["better-github-branch-badge"], + "feature-pr-branch-names": ["better-github-branch-badge", "bg-skeleton-pill--branch"], "feature-pr-review-status": ["better-github-review-status"], - "feature-pr-diff-stats": ["better-github-diff-stats"], + "feature-pr-diff-stats": ["better-github-diff-stats", "bg-skeleton-pill--pr-diff"], "feature-release-tab": ["better-github-releases-tab"], "feature-pr-label-position": ["better-github-label-prefix"], "feature-pr-approve-now": ["better-github-approve-now", "better-github-approve-dialog-overlay"], "feature-default-sort": [], "feature-commit-tags": ["better-github-commit-tag"], - "feature-commit-diff-stats": ["better-github-commit-diff-stats"], + "feature-commit-diff-stats": ["better-github-commit-diff-stats", "bg-skeleton-pill--commit-diff"], "feature-better-top-repos": [], "feature-watch-fork-star-popup": ["bg-wfs-counter-wrap"], "feature-pr-collapse-expand": ["better-github-toggle-tree", "better-github-collapse-expand"], @@ -140,11 +142,20 @@ if (isExtensionValid()) { // On each navigation, inject enabled features onPageReady(async () => { + // Keep in sync with the current URL so + // skeleton-reserve.css matches the right row selector after SPA navs. + applyPageMarker(); + // Always-on features injectFileAgeColor(); // Toggleable features const flags = await getFeatureFlags(); + + // Reserve row height with skeleton placeholders before any async fetch starts + // — avoids layout-shift "flash" when real badges arrive. + reserveInfoRowSkeletons(flags); + for (const key of FEATURE_KEYS) { if (flags[key]) { injectFeature(key); diff --git a/src/early-sort-redirect.ts b/src/early-sort-redirect.ts index 3546075..7538365 100644 --- a/src/early-sort-redirect.ts +++ b/src/early-sort-redirect.ts @@ -1,9 +1,14 @@ // Runs at document_start — before page renders, so no visible flicker. // Handles early redirects that must happen before the page renders. // Respects the feature toggles in storage. +import { applyPageMarker } from "./lib/page-marker"; + (function () { const path = location.pathname; + // Must run sync before body parse so skeleton-reserve.css matches. + applyPageMarker(); + // --- Default sort for PR/issue lists --- if (!/^\/[^/]+\/[^/]+\/(pulls|issues)\/?$/.test(path)) return; diff --git a/src/features/commit-diff-stats.ts b/src/features/commit-diff-stats.ts index 6582d42..ffc8ee7 100644 --- a/src/features/commit-diff-stats.ts +++ b/src/features/commit-diff-stats.ts @@ -2,6 +2,7 @@ import { isCommitsListPage, getRepoInfo } from "../lib/page-detect"; import { fetchCommitDiffStats } from "../lib/github-api"; import { collectCommitRows, MAIN_CONTENT_INNER_SELECTOR } from "../lib/commit-dom"; import { buildDiffStatsBadge } from "../lib/diff-stats-badge"; +import { clearSkeletons } from "../lib/info-row-skeleton"; const BADGE_CLASS = "better-github-commit-diff-stats"; @@ -14,18 +15,22 @@ export async function injectCommitDiffStats(): Promise { const shaToContainer = collectCommitRows(info.owner, info.repo); if (shaToContainer.size === 0) return; - const stats = await fetchCommitDiffStats(info.owner, info.repo, [...shaToContainer.keys()]); - if (stats.length === 0) return; + try { + const stats = await fetchCommitDiffStats(info.owner, info.repo, [...shaToContainer.keys()]); + if (stats.length === 0) return; - const statsMap = new Map(stats.map((s) => [s.sha, s])); + const statsMap = new Map(stats.map((s) => [s.sha, s])); - for (const [sha, container] of shaToContainer) { - const stat = statsMap.get(sha); - if (!stat) continue; - if (container.querySelector(`.${BADGE_CLASS}`)) continue; + for (const [sha, container] of shaToContainer) { + const stat = statsMap.get(sha); + if (!stat) continue; + if (container.querySelector(`.${BADGE_CLASS}`)) continue; - const mainInner = container.querySelector(MAIN_CONTENT_INNER_SELECTOR); - const parent = mainInner || container; - parent.appendChild(buildDiffStatsBadge(stat, BADGE_CLASS)); + const mainInner = container.querySelector(MAIN_CONTENT_INNER_SELECTOR); + const parent = mainInner || container; + parent.appendChild(buildDiffStatsBadge(stat, BADGE_CLASS)); + } + } finally { + clearSkeletons("commitDiff"); } } diff --git a/src/features/pr-branch-names.ts b/src/features/pr-branch-names.ts index 51bb11c..8990d59 100644 --- a/src/features/pr-branch-names.ts +++ b/src/features/pr-branch-names.ts @@ -1,6 +1,7 @@ import { isPRListPage, getRepoInfo, getPRListParams } from "../lib/page-detect"; import { fetchPRBranches } from "../lib/github-api"; import { getOrCreateInfoRow } from "../lib/info-row"; +import { clearSkeletons } from "../lib/info-row-skeleton"; const BADGE_CLASS = "better-github-branch-badge"; const COPIED_CLASS = "better-github-branch-copied"; @@ -48,36 +49,37 @@ export async function injectPRBranchNames(): Promise { const existing = document.querySelectorAll(`.${BADGE_CLASS}`); if (existing.length > 0) return; - const { state, page } = getPRListParams(); - const branches = await fetchPRBranches(info.owner, info.repo, state, page); + try { + const { state, page } = getPRListParams(); + const branches = await fetchPRBranches(info.owner, info.repo, state, page); - if (branches.length === 0) return; + if (branches.length === 0) return; - const branchMap = new Map(branches.map((b) => [b.number, b.headRef])); + const branchMap = new Map(branches.map((b) => [b.number, b.headRef])); + const prRows = document.querySelectorAll("[id^='issue_']"); - // Find PR rows — GitHub uses [id^='issue_'] for PR list items - const prRows = document.querySelectorAll("[id^='issue_']"); + for (const row of prRows) { + const id = row.getAttribute("id"); + if (!id) continue; - for (const row of prRows) { - const id = row.getAttribute("id"); - if (!id) continue; + const prNumber = parseInt(id.replace("issue_", ""), 10); + const branchName = branchMap.get(prNumber); + if (!branchName) continue; - const prNumber = parseInt(id.replace("issue_", ""), 10); - const branchName = branchMap.get(prNumber); - if (!branchName) continue; + if (row.querySelector(`.${BADGE_CLASS}`)) continue; - // Don't inject if already present - if (row.querySelector(`.${BADGE_CLASS}`)) continue; + const infoRow = getOrCreateInfoRow(row); + if (!infoRow) continue; - const infoRow = getOrCreateInfoRow(row); - if (!infoRow) continue; + const badge = document.createElement("span"); + badge.className = BADGE_CLASS; + badge.textContent = branchName; + badge.dataset.branch = branchName; + badge.title = "Click to copy branch name"; - const badge = document.createElement("span"); - badge.className = BADGE_CLASS; - badge.textContent = branchName; - badge.dataset.branch = branchName; - badge.title = "Click to copy branch name"; - - infoRow.appendChild(badge); + infoRow.appendChild(badge); + } + } finally { + clearSkeletons("branch"); } } diff --git a/src/features/pr-diff-stats.ts b/src/features/pr-diff-stats.ts index b52af51..7fa734c 100644 --- a/src/features/pr-diff-stats.ts +++ b/src/features/pr-diff-stats.ts @@ -2,6 +2,7 @@ import { isPRListPage, getRepoInfo } from "../lib/page-detect"; import { fetchPRDiffStats } from "../lib/github-api"; import { getOrCreateInfoRow } from "../lib/info-row"; import { buildDiffStatsBadge } from "../lib/diff-stats-badge"; +import { clearSkeletons } from "../lib/info-row-skeleton"; const BADGE_CLASS = "better-github-diff-stats"; @@ -21,24 +22,28 @@ export async function injectPRDiffStats(): Promise { if (prNumbers.length === 0) return; - const stats = await fetchPRDiffStats(info.owner, info.repo, prNumbers); - if (stats.length === 0) return; + try { + const stats = await fetchPRDiffStats(info.owner, info.repo, prNumbers); + if (stats.length === 0) return; - const statsMap = new Map(stats.map((s) => [s.number, s])); + const statsMap = new Map(stats.map((s) => [s.number, s])); - for (const row of prRows) { - const id = row.getAttribute("id"); - if (!id) continue; + for (const row of prRows) { + const id = row.getAttribute("id"); + if (!id) continue; - const prNumber = parseInt(id.replace("issue_", ""), 10); - const stat = statsMap.get(prNumber); - if (!stat) continue; + const prNumber = parseInt(id.replace("issue_", ""), 10); + const stat = statsMap.get(prNumber); + if (!stat) continue; - if (row.querySelector(`.${BADGE_CLASS}`)) continue; + if (row.querySelector(`.${BADGE_CLASS}`)) continue; - const infoRow = getOrCreateInfoRow(row); - if (!infoRow) continue; + const infoRow = getOrCreateInfoRow(row); + if (!infoRow) continue; - infoRow.appendChild(buildDiffStatsBadge(stat, BADGE_CLASS)); + infoRow.appendChild(buildDiffStatsBadge(stat, BADGE_CLASS)); + } + } finally { + clearSkeletons("prDiff"); } } diff --git a/src/lib/info-row-skeleton.ts b/src/lib/info-row-skeleton.ts new file mode 100644 index 0000000..d042a98 --- /dev/null +++ b/src/lib/info-row-skeleton.ts @@ -0,0 +1,89 @@ +import { isPRListPage, isCommitsListPage, getRepoInfo } from "./page-detect"; +import { collectCommitRows, MAIN_CONTENT_INNER_SELECTOR } from "./commit-dom"; +import { getOrCreateInfoRow } from "./info-row"; + +export type SkeletonKind = "branch" | "prDiff" | "commitDiff"; + +const SKELETONS: Record = { + branch: { skeleton: "bg-skeleton-pill--branch", real: "better-github-branch-badge" }, + prDiff: { skeleton: "bg-skeleton-pill--pr-diff", real: "better-github-diff-stats" }, + commitDiff: { + skeleton: "bg-skeleton-pill--commit-diff", + real: "better-github-commit-diff-stats", + }, +}; + +const SKELETON_BASE_CLASS = "bg-skeleton-pill"; + +export interface SkeletonFlags { + "feature-pr-branch-names"?: boolean; + "feature-pr-diff-stats"?: boolean; + "feature-commit-diff-stats"?: boolean; +} + +function buildPill(extraClass: string): HTMLSpanElement { + const span = document.createElement("span"); + span.className = `${SKELETON_BASE_CLASS} ${extraClass}`; + span.setAttribute("aria-hidden", "true"); + return span; +} + +function hasChild(scope: Element, cls: string): boolean { + return scope.querySelector(`.${cls}`) !== null; +} + +export function reserveInfoRowSkeletons(flags: SkeletonFlags): void { + if (isPRListPage()) { + reservePRListSkeletons(flags); + return; + } + if (isCommitsListPage()) { + reserveCommitsListSkeletons(flags); + } +} + +function reservePRListSkeletons(flags: SkeletonFlags): void { + const wantBranch = !!flags["feature-pr-branch-names"]; + const wantDiff = !!flags["feature-pr-diff-stats"]; + if (!wantBranch && !wantDiff) return; + + const branch = SKELETONS.branch; + const prDiff = SKELETONS.prDiff; + const probeSelector = [branch.real, branch.skeleton, prDiff.real, prDiff.skeleton] + .map((c) => `.${c}`) + .join(", "); + + for (const row of document.querySelectorAll("[id^='issue_']")) { + const present = new Set( + [...row.querySelectorAll(probeSelector)].flatMap((el) => [...el.classList]), + ); + const needBranch = wantBranch && !present.has(branch.real) && !present.has(branch.skeleton); + const needDiff = wantDiff && !present.has(prDiff.real) && !present.has(prDiff.skeleton); + if (!needBranch && !needDiff) continue; + + const infoRow = getOrCreateInfoRow(row); + if (!infoRow) continue; + + if (needBranch) infoRow.appendChild(buildPill(branch.skeleton)); + if (needDiff) infoRow.appendChild(buildPill(prDiff.skeleton)); + } +} + +function reserveCommitsListSkeletons(flags: SkeletonFlags): void { + if (!flags["feature-commit-diff-stats"]) return; + + const info = getRepoInfo(); + if (!info) return; + + const { skeleton, real } = SKELETONS.commitDiff; + for (const container of collectCommitRows(info.owner, info.repo).values()) { + if (hasChild(container, real) || hasChild(container, skeleton)) continue; + + const mainInner = container.querySelector(MAIN_CONTENT_INNER_SELECTOR); + (mainInner || container).appendChild(buildPill(skeleton)); + } +} + +export function clearSkeletons(kind: SkeletonKind): void { + document.querySelectorAll(`.${SKELETONS[kind].skeleton}`).forEach((el) => el.remove()); +} diff --git a/src/lib/page-marker.ts b/src/lib/page-marker.ts new file mode 100644 index 0000000..89f896b --- /dev/null +++ b/src/lib/page-marker.ts @@ -0,0 +1,29 @@ +/** + * Page-type marker on ``. Read by `skeleton-reserve.css` + * (loaded at document_start) to pre-reserve info-row height before GitHub + * paints — so the row that content.js mounts at document_idle no longer + * pushes everything down. + * + * Must stay sync-friendly: called from early-sort-redirect.ts at + * document_start, and from content.ts on every SPA navigation. + */ + +export type PageMarker = "pr-list" | "commits-list"; + +export function detectPageMarker(path: string): PageMarker | null { + // Mirror isPRListPage / isCommitsListPage in page-detect.ts so the CSS + // reservation only kicks in where the corresponding feature actually runs. + if (/^\/[^/]+\/[^/]+\/pulls\/?$/.test(path)) return "pr-list"; + if (/^\/[^/]+\/[^/]+\/commits(\/|$)/.test(path)) return "commits-list"; + return null; +} + +export function applyPageMarker(): void { + const marker = detectPageMarker(location.pathname); + const root = document.documentElement; + if (marker) { + if (root.dataset.bgPage !== marker) root.dataset.bgPage = marker; + } else if (root.dataset.bgPage) { + delete root.dataset.bgPage; + } +} diff --git a/src/styles/content.css b/src/styles/content.css index 4326107..436647b 100644 --- a/src/styles/content.css +++ b/src/styles/content.css @@ -473,13 +473,18 @@ padding: 6px 16px; } +.bg-wfs-skeleton-avatar, +.bg-wfs-skeleton-line, +.bg-skeleton-pill { + background: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa)); + animation: bg-wfs-skeleton-pulse 1.5s ease-in-out infinite; +} + .bg-wfs-skeleton-avatar { width: 28px; height: 28px; border-radius: 50%; - background: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa)); flex-shrink: 0; - animation: bg-wfs-skeleton-pulse 1.5s ease-in-out infinite; } .bg-wfs-skeleton-lines { @@ -492,8 +497,6 @@ .bg-wfs-skeleton-line { height: 10px; border-radius: 3px; - background: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa)); - animation: bg-wfs-skeleton-pulse 1.5s ease-in-out infinite; } .bg-wfs-skeleton-line-long { @@ -509,6 +512,28 @@ 50% { opacity: 0.4; } } +/* Height matches the real badges' outer box: line-height 18px + 1px border × 2 = 20px. */ +.bg-skeleton-pill { + display: inline-block; + height: 20px; + border-radius: 2em; + vertical-align: middle; + flex-shrink: 0; +} + +.bg-skeleton-pill--branch { + width: 120px; +} + +.bg-skeleton-pill--pr-diff { + width: 96px; +} + +.bg-skeleton-pill--commit-diff { + width: 96px; + margin-top: 4px; +} + /* PR File Tree Toggle */ .better-github-toggle-tree { display: inline-flex; diff --git a/src/styles/skeleton-reserve.css b/src/styles/skeleton-reserve.css new file mode 100644 index 0000000..39087bf --- /dev/null +++ b/src/styles/skeleton-reserve.css @@ -0,0 +1,37 @@ +/* + * Pre-reserve info-row height on PR list and commits list pages. + * + * content.js runs at document_idle — AFTER GitHub's first paint. When it + * then mounts the info-row + skeleton, the row grows by 20px, visible as + * a layout-shift flicker. This file is loaded at document_start with the + * `` marker (set sync by applyPageMarker), so GitHub's + * first paint already includes the slot. `:has()` hides the pseudo once + * the real info-row / commit-diff badge mounts. + * + * Reserved height = real badges' outer box: line-height 18px + 1px + * border × 2 = 20px. Using 18px caused a 2px shift on swap. + */ + +[data-bg-page="pr-list"] .js-issue-row::after, +[data-bg-page="pr-list"] li[role="listitem"] [class*="MainContent-module__inner"]::after, +[data-bg-page="commits-list"] + li[data-testid="commit-row-item"] + [class*="MainContent-module__inner"]::after { + content: ""; + display: block; + height: 20px; + margin-top: 4px; +} + +[data-bg-page="pr-list"] .js-issue-row:has(.better-github-info-row)::after, +[data-bg-page="pr-list"] + li[role="listitem"] + [class*="MainContent-module__inner"]:has(.better-github-info-row)::after, +[data-bg-page="commits-list"] + li[data-testid="commit-row-item"] + [class*="MainContent-module__inner"]:has( + .better-github-commit-diff-stats, + .bg-skeleton-pill--commit-diff + )::after { + display: none; +} diff --git a/static/manifest.json b/static/manifest.json index a632ab9..49f774f 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -19,6 +19,7 @@ { "matches": ["https://github.com/*"], "js": ["early-sort-redirect.js"], + "css": ["styles/skeleton-reserve.css"], "run_at": "document_start" }, {