Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/features/commit-diff-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<li data-testid="commit-row-item" id="row-a" class="listItem__X41oTe">
<div class="container__mDVuhO">
<h4><a href="/owner/repo/commit/${SHA_A}">commit message</a></h4>
</div>
<div class="container__VGL_Vt">
<div class="inner__Rm1Enx"><div id="metadata">author and checks</div></div>
</div>
<div class="container__1MF1B1"><a href="/owner/repo/commit/${SHA_A}">short SHA</a></div>
</li>
`;
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([
Expand Down
3 changes: 2 additions & 1 deletion src/features/commit-diff-stats.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
3 changes: 2 additions & 1 deletion src/features/commit-tags.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
130 changes: 129 additions & 1 deletion src/features/pr-collapse-expand.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 = `
<div ${sidebar}>
<div class="FileTreeScrollable__abc"><ul role="tree">
<li role="treeitem" aria-expanded="true" aria-level="1" id="parent">
<div class="prc-TreeView-TreeViewItemContent-abc"></div>
</li>
<li role="treeitem" aria-expanded="true" aria-level="2" id="child">
<div class="prc-TreeView-TreeViewItemContent-abc"></div>
</li>
</ul></div>
</div>
<section class="${dom === "prefixed" ? "PullRequestFilesToolbar-module__" : ""}toolbar__abc">
<div></div><div></div><div id="controls"><button id="review">Review</button></div>
</section>
<div class="${prefix}diff-file-header__abc"><div><button id="file-a"></button></div></div>
<div class="${prefix}diff-file-header__abc"><div><button id="file-b"></button></div></div>
`;
const folderOrder: string[] = [];
document.querySelectorAll<HTMLElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>(".better-github-toggle-tree")!;
const diffToggle = document.querySelector<HTMLButtonElement>(".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 = `
<div id="pr-file-tree"><div class="FileTreeScrollable__abc"></div></div>
`;
const expanded = new Set<string>();
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
? `<ul role="group">${folder(level + 1)}</ul>`
: "";
return `<li role="treeitem" id="${name}" aria-level="${level + 1}" aria-expanded="${expanded.has(name)}">
<div class="prc-TreeView-TreeViewItemContent-abc"></div>${children}
</li>`;
}
function renderTree(): void {
scrollable.innerHTML = `<ul role="tree">${folder(0)}</ul>`;
scrollable
.querySelectorAll<HTMLElement>("[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<HTMLButtonElement>(".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 = `
Expand Down
55 changes: 38 additions & 17 deletions src/features/pr-collapse-expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -24,20 +25,20 @@ export function injectPRCollapseExpand(): void {
// --- File tree folder collapse/expand (PR pages only) ---

interface TreeFolder {
element: Element;
isExpanded: boolean;
level: number;
toggle(): void;
}

function getTreeFolders(): TreeFolder[] {
// New experience: aria-expanded lives on the <li> 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: () =>
Expand All @@ -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(),
Expand All @@ -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"]'
Expand Down Expand Up @@ -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<Element>();
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);
Expand All @@ -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) => ({
Expand Down Expand Up @@ -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];
Expand Down
8 changes: 5 additions & 3 deletions src/features/pr-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,7 +23,8 @@ async function injectBadges(): Promise<void> {
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");
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 0 additions & 3 deletions src/lib/commit-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
// Covers both classic GitHub DOM (<li>) 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<string, Element> {
const pattern = new RegExp(`^/${owner}/${repo}/commit/([0-9a-f]{40})$`, "i");
const result = new Map<string, Element>();
Expand Down
21 changes: 21 additions & 0 deletions src/lib/info-row-skeleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<li data-testid="commit-row-item" id="row">
<div><h4><a href="/owner/repo/commit/${SHA}">commit</a></h4></div>
<div class="container__VGL_Vt"><div class="inner__Rm1Enx"><div id="metadata">author and checks</div></div></div>
</li>
`;

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();
});
});
3 changes: 2 additions & 1 deletion src/lib/info-row-skeleton.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading
Loading