diff --git a/src/features/commit-diff-stats.test.ts b/src/features/commit-diff-stats.test.ts
index a4fbe2b..14aeae7 100644
--- a/src/features/commit-diff-stats.test.ts
+++ b/src/features/commit-diff-stats.test.ts
@@ -77,6 +77,32 @@ describe("injectCommitDiffStats", () => {
expect(badge.title).toBe("10 additions, 2 deletions");
});
+ it("keeps stats below the metadata in GitHub's grid rows with unprefixed CSS classes", async () => {
+ document.body.innerHTML = `
+
+
+
+
+
+ `;
+ vi.mocked(fetchCommitDiffStats).mockResolvedValue([
+ { sha: SHA_A, additions: 24, deletions: 2, changedFiles: 5 },
+ ]);
+
+ await injectCommitDiffStats();
+ await injectCommitDiffStats();
+
+ const badge = document.querySelector(BADGE)!;
+ expect(badge.parentElement).toBe(document.querySelector(".inner__Rm1Enx"));
+ expect(badge.previousElementSibling).toBe(document.getElementById("metadata"));
+ expect(document.querySelectorAll(BADGE)).toHaveLength(1);
+ expect(document.querySelector(`#row-a > ${BADGE}`)).toBeNull();
+ });
+
it("leaves rows whose SHA the API did not return without a badge", async () => {
document.body.innerHTML = commitRow("row-a", SHA_A) + commitRow("row-b", SHA_B);
vi.mocked(fetchCommitDiffStats).mockResolvedValue([
diff --git a/src/features/commit-diff-stats.ts b/src/features/commit-diff-stats.ts
index ffc8ee7..093a688 100644
--- a/src/features/commit-diff-stats.ts
+++ b/src/features/commit-diff-stats.ts
@@ -1,6 +1,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 { collectCommitRows } from "../lib/commit-dom";
+import { MAIN_CONTENT_INNER_SELECTOR } from "../lib/list-view-dom";
import { buildDiffStatsBadge } from "../lib/diff-stats-badge";
import { clearSkeletons } from "../lib/info-row-skeleton";
diff --git a/src/features/commit-tags.ts b/src/features/commit-tags.ts
index 4c7d5d5..3eda343 100644
--- a/src/features/commit-tags.ts
+++ b/src/features/commit-tags.ts
@@ -1,7 +1,8 @@
import { isCommitsListPage, getRepoInfo } from "../lib/page-detect";
import { fetchRepoTags } from "../lib/github-api";
import { escapeHtml } from "../lib/utils";
-import { collectCommitRows, MAIN_CONTENT_INNER_SELECTOR } from "../lib/commit-dom";
+import { collectCommitRows } from "../lib/commit-dom";
+import { MAIN_CONTENT_INNER_SELECTOR } from "../lib/list-view-dom";
import { t } from "../lib/i18n";
const TAG_CLASS = "better-github-commit-tag";
diff --git a/src/features/pr-collapse-expand.test.ts b/src/features/pr-collapse-expand.test.ts
index 120ce05..098c131 100644
--- a/src/features/pr-collapse-expand.test.ts
+++ b/src/features/pr-collapse-expand.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setUrl } from "../test-utils/url";
import { injectPRCollapseExpand } from "./pr-collapse-expand";
@@ -9,6 +9,134 @@ describe("injectPRCollapseExpand", () => {
document.body.innerHTML = "";
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it.each(["prefixed", "current"])("toggles React files and folders with %s CSS classes", (dom) => {
+ setUrl(`${GH}/owner/repo/pull/42/changes`);
+ const prefix = dom === "prefixed" ? "DiffFileHeader-module__" : "";
+ const sidebar =
+ dom === "prefixed"
+ ? 'class="PullRequestFileTree-module__sidebar__abc"'
+ : 'id="pr-file-tree" class="sidebar__abc"';
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ const folderOrder: string[] = [];
+ document.querySelectorAll("[class*='TreeViewItemContent']").forEach((toggle) => {
+ toggle.addEventListener("click", () => {
+ const folder = toggle.closest("li")!;
+ folderOrder.push(folder.id);
+ folder.setAttribute(
+ "aria-expanded",
+ String(folder.getAttribute("aria-expanded") !== "true"),
+ );
+ });
+ });
+ document.querySelectorAll("[id^='file-']").forEach((toggle) => {
+ toggle.addEventListener("click", () =>
+ toggle
+ .closest("[class*='diff-file-header']")!
+ .classList.toggle(`${prefix}diff-file-header-collapsed__abc`),
+ );
+ });
+
+ vi.useFakeTimers();
+ injectPRCollapseExpand();
+ injectPRCollapseExpand();
+ const treeToggle = document.querySelector(".better-github-toggle-tree")!;
+ const diffToggle = document.querySelector(".better-github-collapse-expand")!;
+ expect(treeToggle.nextElementSibling?.className).toContain("FileTreeScrollable");
+ expect(diffToggle.nextElementSibling?.id).toBe("review");
+ expect(document.querySelectorAll(".better-github-toggle-tree")).toHaveLength(1);
+ expect(document.querySelectorAll(".better-github-collapse-expand")).toHaveLength(1);
+
+ treeToggle.click();
+ diffToggle.click();
+ vi.advanceTimersByTime(100);
+ expect(folderOrder).toEqual(["child", "parent"]);
+ expect(
+ [...document.querySelectorAll("[aria-expanded]")].map((e) => e.getAttribute("aria-expanded")),
+ ).toEqual(["false", "false"]);
+ expect(document.querySelectorAll("[class*='diff-file-header-collapsed']")).toHaveLength(2);
+
+ treeToggle.click();
+ diffToggle.click();
+ vi.advanceTimersByTime(100);
+ expect(folderOrder.slice(2)).toEqual(["parent", "child"]);
+ expect(document.querySelectorAll("[class*='diff-file-header-collapsed']")).toHaveLength(0);
+ });
+
+ it("expands child folders that React mounts after their parent opens", () => {
+ setUrl(`${GH}/owner/repo/pull/42/changes`);
+ document.body.innerHTML = `
+
+ `;
+ const expanded = new Set();
+ const order: string[] = [];
+ const names = ["parent", "child", "grandchild"];
+ const scrollable = document.querySelector("[class*='FileTreeScrollable']")!;
+ function folder(level: number): string {
+ const name = names[level];
+ const children =
+ expanded.has(name) && level + 1 < names.length
+ ? ``
+ : "";
+ return `
+ ${children}
+ `;
+ }
+ function renderTree(): void {
+ scrollable.innerHTML = ``;
+ scrollable
+ .querySelectorAll("[class*='TreeViewItemContent']")
+ .forEach((toggle) => {
+ toggle.addEventListener("click", () => {
+ const name = toggle.closest("li")!.id;
+ order.push(name);
+ if (expanded.has(name)) expanded.delete(name);
+ else expanded.add(name);
+ renderTree();
+ });
+ });
+ }
+ renderTree();
+ vi.useFakeTimers();
+ injectPRCollapseExpand();
+ const toggle = document.querySelector(".better-github-toggle-tree")!;
+
+ toggle.click();
+ vi.advanceTimersByTime(100);
+ expect(order).toEqual(names);
+ expect([...expanded]).toEqual(names);
+ expect(scrollable.querySelectorAll('[aria-expanded="true"]')).toHaveLength(3);
+
+ toggle.click();
+ vi.advanceTimersByTime(100);
+ expect(order.slice(3)).toEqual(["grandchild", "child", "parent"]);
+ expect(expanded.size).toBe(0);
+ expect(scrollable.querySelectorAll("[role='treeitem']")).toHaveLength(1);
+
+ toggle.click();
+ vi.advanceTimersByTime(100);
+ expect([...expanded]).toEqual(names);
+ });
+
it("injects one diff toggle and toggles classic diff files", () => {
setUrl(`${GH}/owner/repo/commit/abc123`);
document.body.innerHTML = `
diff --git a/src/features/pr-collapse-expand.ts b/src/features/pr-collapse-expand.ts
index a618f44..8759b29 100644
--- a/src/features/pr-collapse-expand.ts
+++ b/src/features/pr-collapse-expand.ts
@@ -3,6 +3,7 @@ import { t } from "../lib/i18n";
const TREE_BTN_CLASS = "better-github-toggle-tree";
const DIFF_BTN_CLASS = "better-github-collapse-expand";
+const TREE_SIDEBAR_SELECTOR = "#pr-file-tree, [class*='PullRequestFileTree-module__sidebar']";
// 16x16 Octicon SVG paths
const ICON_FOLD_DOWN =
@@ -24,6 +25,7 @@ export function injectPRCollapseExpand(): void {
// --- File tree folder collapse/expand (PR pages only) ---
interface TreeFolder {
+ element: Element;
isExpanded: boolean;
level: number;
toggle(): void;
@@ -31,13 +33,12 @@ interface TreeFolder {
function getTreeFolders(): TreeFolder[] {
// New experience: aria-expanded lives on the itself
- const newSidebar = document.querySelector(
- '[class*="PullRequestFileTree-module__sidebar"]'
- );
+ const newSidebar = document.querySelector(TREE_SIDEBAR_SELECTOR);
if (newSidebar) {
return Array.from(
newSidebar.querySelectorAll('li[role="treeitem"][aria-expanded]')
).map((el) => ({
+ element: el,
isExpanded: el.getAttribute("aria-expanded") === "true",
level: parseInt(el.getAttribute("aria-level") || "0", 10),
toggle: () =>
@@ -55,6 +56,7 @@ function getTreeFolders(): TreeFolder[] {
"button.ActionList-content[aria-expanded]"
);
return {
+ element: el,
isExpanded: btn?.getAttribute("aria-expanded") === "true",
level: parseInt(el.getAttribute("aria-level") || "0", 10),
toggle: () => btn?.click(),
@@ -67,9 +69,7 @@ function findTreeInjectionPoint(): {
reference: Element;
} | null {
// New experience
- const newSidebar = document.querySelector(
- '[class*="PullRequestFileTree-module__sidebar"]'
- );
+ const newSidebar = document.querySelector(TREE_SIDEBAR_SELECTOR);
if (newSidebar) {
const scrollable = newSidebar.querySelector(
'[class*="FileTreeScrollable"]'
@@ -119,23 +119,44 @@ function injectTreeToggle(): void {
updateLabel();
+ let expansionFrame: number | null = null;
btn.addEventListener("click", () => {
// Release focus so Space keeps scrolling the PR instead of re-triggering the button.
btn.blur();
+ if (expansionFrame !== null) cancelAnimationFrame(expansionFrame);
+ expansionFrame = null;
const folders = getTreeFolders();
const shouldCollapse = areMajorityExpanded();
- // Sort by depth: collapse deepest first, expand shallowest first
- const sorted = [...folders].sort((a, b) =>
- shouldCollapse ? b.level - a.level : a.level - b.level
- );
+ if (!shouldCollapse) {
+ // React unmounts children of collapsed folders. Expand one depth per frame
+ // so the next pass sees the newly rendered child folders.
+ const attempted = new WeakSet();
+ const expandNextLevel = () => {
+ expansionFrame = null;
+ if (!btn.isConnected) return;
+ const collapsed = getTreeFolders().filter(
+ (folder) => !folder.isExpanded && !attempted.has(folder.element)
+ );
+ if (collapsed.length === 0) {
+ updateLabel();
+ return;
+ }
+ const level = Math.min(...collapsed.map((folder) => folder.level));
+ for (const folder of collapsed.filter((folder) => folder.level === level)) {
+ attempted.add(folder.element);
+ folder.toggle();
+ }
+ expansionFrame = requestAnimationFrame(expandNextLevel);
+ };
+ expandNextLevel();
+ return;
+ }
+ // Collapse deepest first, before React unmounts child folders.
+ const sorted = [...folders].sort((a, b) => b.level - a.level);
for (const folder of sorted) {
- if (shouldCollapse && folder.isExpanded) {
- folder.toggle();
- } else if (!shouldCollapse && !folder.isExpanded) {
- folder.toggle();
- }
+ if (folder.isExpanded) folder.toggle();
}
setTimeout(updateLabel, 100);
@@ -155,7 +176,7 @@ interface DiffFile {
function getDiffFiles(): DiffFile[] {
// New experience
const newHeaders = document.querySelectorAll(
- '[class*="DiffFileHeader-module__diff-file-header"]'
+ '[class*="diff-file-header"]'
);
if (newHeaders.length > 0) {
return Array.from(newHeaders).map((header) => ({
@@ -184,7 +205,7 @@ function findDiffInjectionPoint(): {
} | null {
// New experience: prepend inside the right controls area
const prToolbar = document.querySelector(
- 'section[class*="PullRequestFilesToolbar-module__toolbar"]'
+ 'section[class*="toolbar__"]'
);
if (prToolbar) {
const rightControls = prToolbar.children[2];
diff --git a/src/features/pr-dashboard.test.ts b/src/features/pr-dashboard.test.ts
index b4e3310..64c3dd6 100644
--- a/src/features/pr-dashboard.test.ts
+++ b/src/features/pr-dashboard.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { dashboardRow, repoDashboard } from "../test-utils/pr-dashboard";
+import { dashboardRow as buildDashboardRow, repoDashboard } from "../test-utils/pr-dashboard";
import { setUrl } from "../test-utils/url";
import { collectPRRows } from "../lib/pr-list-dom";
import { reserveInfoRowSkeletons } from "../lib/info-row-skeleton";
@@ -23,7 +23,8 @@ async function injectBadges(): Promise {
await Promise.all([injectPRBranchNames(), injectPRDiffStats(), injectPRReviewStatus()]);
}
-describe("repository PR dashboard preview", () => {
+describe.each(["prefixed", "current"] as const)("repository PR dashboard (%s DOM)", (dom) => {
+ const dashboardRow = (number: number, label?: string) => buildDashboardRow(number, label, dom);
beforeEach(() => {
vi.clearAllMocks();
setUrl("https://github.com/owner/repo/pulls");
@@ -51,7 +52,8 @@ describe("repository PR dashboard preview", () => {
expect(document.querySelectorAll(".bg-skeleton-pill")).toHaveLength(2);
await injectBadges();
const info = document.querySelector(".better-github-info-row")!;
- expect(info.previousElementSibling?.className).toContain("Description-module__container");
+ expect(info.previousElementSibling?.textContent).toBe("#7 ยท author opened yesterday");
+ expect(document.querySelector(".inner__BgjhSm .better-github-info-row")).toBeNull();
expect([...info.children].map((el) => (el as HTMLElement).dataset.bgInfoRowItem)).toEqual([
"branch",
"diff",
diff --git a/src/lib/commit-dom.ts b/src/lib/commit-dom.ts
index 137d9cc..3e3ffb5 100644
--- a/src/lib/commit-dom.ts
+++ b/src/lib/commit-dom.ts
@@ -3,9 +3,6 @@
// Covers both classic GitHub DOM () and new React DOM (div.TimelineItem-body / div[data-testid]).
export const COMMIT_ROW_SELECTOR = "li, div.TimelineItem-body, div[data-testid]";
-// Hashed CSS-module class on new GitHub's commit-row inner content wrapper.
-export const MAIN_CONTENT_INNER_SELECTOR = "[class*='MainContent-module__inner']";
-
export function collectCommitRows(owner: string, repo: string): Map {
const pattern = new RegExp(`^/${owner}/${repo}/commit/([0-9a-f]{40})$`, "i");
const result = new Map();
diff --git a/src/lib/info-row-skeleton.test.ts b/src/lib/info-row-skeleton.test.ts
index c6c0b93..a65b77e 100644
--- a/src/lib/info-row-skeleton.test.ts
+++ b/src/lib/info-row-skeleton.test.ts
@@ -100,4 +100,25 @@ describe("reserveInfoRowSkeletons", () => {
vi.useRealTimers();
}
});
+
+ it("reserves commit stats inside the unprefixed content wrapper instead of the grid row", () => {
+ setUrl(`${GH}/owner/repo/commits/main`);
+ document.body.innerHTML = `
+
+
+
+
+ `;
+
+ reserveInfoRowSkeletons({ "feature-commit-diff-stats": true });
+ reserveInfoRowSkeletons({ "feature-commit-diff-stats": true });
+
+ const skeleton = document.querySelector(".bg-skeleton-pill--commit-diff")!;
+ expect(skeleton.parentElement).toBe(document.querySelector(".inner__Rm1Enx"));
+ expect(skeleton.previousElementSibling).toBe(document.getElementById("metadata"));
+ expect(document.querySelectorAll(".bg-skeleton-pill--commit-diff")).toHaveLength(1);
+ expect(document.querySelector("#row > .bg-skeleton-pill--commit-diff")).toBeNull();
+ clearSkeletons("commitDiff");
+ expect(document.querySelector(".bg-skeleton-pill--commit-diff")).toBeNull();
+ });
});
diff --git a/src/lib/info-row-skeleton.ts b/src/lib/info-row-skeleton.ts
index af943ff..6aff9d6 100644
--- a/src/lib/info-row-skeleton.ts
+++ b/src/lib/info-row-skeleton.ts
@@ -1,6 +1,7 @@
import { collectPRRows, PR_DASHBOARD_SELECTOR } from "./pr-list-dom";
import { isPRListPage, isCommitsListPage, getRepoInfo } from "./page-detect";
-import { collectCommitRows, MAIN_CONTENT_INNER_SELECTOR } from "./commit-dom";
+import { collectCommitRows } from "./commit-dom";
+import { MAIN_CONTENT_INNER_SELECTOR } from "./list-view-dom";
import { INFO_ROW_CLASS, insertInfoRowItem } from "./info-row";
export type SkeletonKind = "branch" | "prDiff" | "commitDiff";
diff --git a/src/lib/info-row.test.ts b/src/lib/info-row.test.ts
index 7e70f18..8bee646 100644
--- a/src/lib/info-row.test.ts
+++ b/src/lib/info-row.test.ts
@@ -35,6 +35,21 @@ describe("getOrCreateInfoRow", () => {
expect(meta.nextElementSibling).toBe(infoRow);
});
+ it("uses the current content wrapper without mounting inside the status icon", () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+
+ const infoRow = getOrCreateInfoRow(document.querySelector("li")!);
+
+ expect(infoRow).not.toBeNull();
+ expect(infoRow?.parentElement).toBe(document.querySelector(".inner__Rm1Enx"));
+ expect(document.querySelector(".inner__BgjhSm .better-github-info-row")).toBeNull();
+ });
+
it("falls back to inserting after the title link", () => {
document.body.innerHTML = `
diff --git a/src/lib/info-row.ts b/src/lib/info-row.ts
index 93cb13a..4de8fbd 100644
--- a/src/lib/info-row.ts
+++ b/src/lib/info-row.ts
@@ -1,5 +1,6 @@
import { isCompactPRRow } from "./pr-list-dom";
import { isPRListReady } from "./pr-list-ready";
+import { MAIN_CONTENT_INNER_SELECTOR } from "./list-view-dom";
/**
* Shared "info row" below the meta line in PR/issue list items.
@@ -26,7 +27,7 @@ export function getOrCreateInfoRow(row: Element): HTMLElement | null {
infoRow.className = INFO_ROW_CLASS;
// New GitHub DOM: append inside MainContent inner container
- const mainInner = row.querySelector("[class*='MainContent-module__inner']");
+ const mainInner = row.querySelector(MAIN_CONTENT_INNER_SELECTOR);
if (mainInner) {
mainInner.appendChild(infoRow);
return infoRow;
diff --git a/src/lib/list-view-dom.ts b/src/lib/list-view-dom.ts
new file mode 100644
index 0000000..baca192
--- /dev/null
+++ b/src/lib/list-view-dom.ts
@@ -0,0 +1,4 @@
+// GitHub now omits CSS-module name prefixes. The parent distinguishes main
+// content from the status icon's separate inner__ wrapper.
+export const MAIN_CONTENT_INNER_SELECTOR =
+ "[class*='MainContent-module__inner'], div[class^='container__'] > div[class^='inner__']";
diff --git a/src/styles/skeleton-reserve.css b/src/styles/skeleton-reserve.css
index 31a41ae..adc7b36 100644
--- a/src/styles/skeleton-reserve.css
+++ b/src/styles/skeleton-reserve.css
@@ -13,14 +13,25 @@
*/
[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="pr-list"]
+ li[role="listitem"]
+ :is(
+ [class*="MainContent-module__inner"],
+ div[class^="container__"] > div[class^="inner__"]
+ )::after,
[data-bg-page="pr-list"]
react-app:is([app-name="pull-requests"], [app-name="repo"])
li
- [class*="MainContent-module__inner"]::after,
+ :is(
+ [class*="MainContent-module__inner"],
+ div[class^="container__"] > div[class^="inner__"]
+ )::after,
[data-bg-page="commits-list"]
li[data-testid="commit-row-item"]
- [class*="MainContent-module__inner"]::after {
+ :is(
+ [class*="MainContent-module__inner"],
+ div[class^="container__"] > div[class^="inner__"]
+ )::after {
content: "";
display: block;
height: 20px;
@@ -31,17 +42,24 @@
[data-bg-page="pr-list"]
react-app:is([app-name="pull-requests"], [app-name="repo"])
[data-density="compact"]
- [class*="MainContent-module__inner"]::after,
+ :is(
+ [class*="MainContent-module__inner"],
+ div[class^="container__"] > div[class^="inner__"]
+ )::after,
[data-bg-page="pr-list"]
li[role="listitem"]
- [class*="MainContent-module__inner"]:has(.better-github-info-row)::after,
+ :is([class*="MainContent-module__inner"], div[class^="container__"] > div[class^="inner__"]):has(
+ .better-github-info-row
+ )::after,
[data-bg-page="pr-list"]
react-app:is([app-name="pull-requests"], [app-name="repo"])
li
- [class*="MainContent-module__inner"]:has(.better-github-info-row)::after,
+ :is([class*="MainContent-module__inner"], div[class^="container__"] > div[class^="inner__"]):has(
+ .better-github-info-row
+ )::after,
[data-bg-page="commits-list"]
li[data-testid="commit-row-item"]
- [class*="MainContent-module__inner"]:has(
+ :is([class*="MainContent-module__inner"], div[class^="container__"] > div[class^="inner__"]):has(
.better-github-commit-diff-stats,
.bg-skeleton-pill--commit-diff
)::after {
diff --git a/src/test-utils/pr-dashboard.ts b/src/test-utils/pr-dashboard.ts
index e631b15..b7eb297 100644
--- a/src/test-utils/pr-dashboard.ts
+++ b/src/test-utils/pr-dashboard.ts
@@ -1,7 +1,11 @@
// Reduced from the repository pull-requests preview DOM. Rows have neither
// issue_ IDs nor explicit listitem roles; labels are React buttons.
-export function dashboardRow(number: number, label = "Feature"): string {
- return `
+export function dashboardRow(
+ number: number,
+ label = "Feature",
+ dom: "prefixed" | "current" = "prefixed",
+): string {
+ const row = `
@@ -14,6 +18,19 @@ export function dashboardRow(number: number, label = "Feature"): string {
`;
+ if (dom === "prefixed") return row;
+ return row
+ .replace("Title-module__trailingBadgesContainer__INeSa", "trailingBadgesContainer__4AEjpg")
+ .replace(
+ "MainContent-module__container__uXXH2",
+ "container__VGL_Vt inlineChecksMainContent__kclwVu",
+ )
+ .replace("MainContent-module__inner__C80co", "inner__Rm1Enx")
+ .replace("Description-module__container__hpqJz", "container__W45oI6 description__mZjLFE")
+ .replace(
+ '',
+ '
',
+ );
}
export function dashboard(...rows: string[]): string {