Skip to content
Draft
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
4 changes: 4 additions & 0 deletions src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
"featCommitDiffStatsDesc": {
"message": "Show additions, deletions, and file count (e.g. +223 −114 · 5 files) on the commits list page. Requires a token."
},
"featActionsInProgressCountName": { "message": "Actions In-Progress Count" },
"featActionsInProgressCountDesc": {
"message": "Show the number of currently running workflow runs beside the Actions tab."
},
"featReleaseTabName": { "message": "Releases Tab" },
"featReleaseTabDesc": {
"message": "Add a Releases tab to the repository navigation bar for quick access."
Expand Down
4 changes: 4 additions & 0 deletions src/_locales/zh_CN/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
"featCommitDiffStatsDesc": {
"message": "在 Commits 列表页显示新增、删除行数和文件数(如 +223 −114 · 5 files)。需要令牌。"
},
"featActionsInProgressCountName": { "message": "Actions 进行中数量" },
"featActionsInProgressCountDesc": {
"message": "在 Actions 标签页旁显示当前正在运行的工作流数量。"
},
"featReleaseTabName": { "message": "Releases 标签页" },
"featReleaseTabDesc": {
"message": "在仓库导航栏添加一个 Releases 标签页,方便快速访问。"
Expand Down
4 changes: 4 additions & 0 deletions src/_locales/zh_TW/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
"featCommitDiffStatsDesc": {
"message": "在 Commits 清單頁顯示新增、刪除行數和檔案數(如 +223 −114 · 5 files)。需要權杖。"
},
"featActionsInProgressCountName": { "message": "Actions 進行中數量" },
"featActionsInProgressCountDesc": {
"message": "在 Actions 分頁旁顯示目前正在執行的工作流程數量。"
},
"featReleaseTabName": { "message": "Releases 分頁" },
"featReleaseTabDesc": {
"message": "在儲存庫導覽列新增一個 Releases 分頁,方便快速存取。"
Expand Down
10 changes: 10 additions & 0 deletions src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
import { injectPRReviewStatus } from "./features/pr-review-status";
import { injectPRDiffStats } from "./features/pr-diff-stats";
import { injectReleasesTab } from "./features/release-tab";
import {
cleanupActionsInProgressCount,
injectActionsInProgressCount,
} from "./features/actions-in-progress-count";
import {
cleanupReleaseAssetDownloads,
injectReleaseAssetDownloads,
Expand All @@ -35,6 +39,7 @@ const FEATURE_KEYS = [
"feature-pr-review-status",
"feature-pr-diff-stats",
"feature-release-tab",
"feature-actions-in-progress-count",
"feature-release-downloads-count",
"feature-pr-label-position",
"feature-pr-approve-now",
Expand All @@ -56,6 +61,7 @@ const FEATURE_CLASSES: Record<FeatureKey, string[]> = {
"feature-pr-review-status": ["better-github-review-status"],
"feature-pr-diff-stats": ["better-github-diff-stats", "bg-skeleton-pill--pr-diff"],
"feature-release-tab": ["better-github-releases-tab"],
"feature-actions-in-progress-count": ["better-github-actions-in-progress-count"],
"feature-release-downloads-count": ["better-github-asset-downloads"],
"feature-pr-label-position": ["better-github-label-prefix"],
"feature-pr-approve-now": ["better-github-approve-now", "better-github-approve-dialog-overlay"],
Expand All @@ -77,6 +83,7 @@ const FEATURE_CLEANUPS: Partial<Record<FeatureKey, () => void>> = {
"feature-watch-fork-star-popup": cleanupWatchForkStarPopup,
"feature-release-downloads-count": cleanupReleaseAssetDownloads,
"feature-contributor-card": cleanupContributorCard,
"feature-actions-in-progress-count": cleanupActionsInProgressCount,
};

function isExtensionValid(): boolean {
Expand Down Expand Up @@ -128,6 +135,9 @@ function injectFeature(key: FeatureKey): void {
case "feature-release-tab":
injectReleasesTab();
break;
case "feature-actions-in-progress-count":
injectActionsInProgressCount();
break;
case "feature-release-downloads-count":
void injectReleaseAssetDownloads();
break;
Expand Down
116 changes: 116 additions & 0 deletions src/features/actions-in-progress-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchActionsInProgressCount } from "../lib/github-api";
import { setUrl } from "../test-utils/url";
import {
cleanupActionsInProgressCount,
injectActionsInProgressCount,
} from "./actions-in-progress-count";

vi.mock("../lib/github-api");

describe("injectActionsInProgressCount", () => {
afterEach(() => {
cleanupActionsInProgressCount();
vi.useRealTimers();
});

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchActionsInProgressCount).mockResolvedValue(null);
setUrl("https://github.com/owner/repo");
document.body.innerHTML = `
<nav aria-label="Repository">
<ul>
<li>
<a href="/owner/repo/pulls">
<span data-content="Pull requests">Pull requests</span>
<span data-component="counter">
<span class="native-counter" data-component="CounterLabel">14</span>
<span class="native-VisuallyHidden-label">&nbsp;(14)</span>
</span>
</a>
</li>
<li>
<a href="/owner/repo/actions">
<span data-content="Actions">Actions</span>
</a>
</li>
</ul>
</nav>
`;
});

it("adds the in-progress count to the Actions tab using GitHub's counter structure", async () => {
vi.mocked(fetchActionsInProgressCount).mockResolvedValue(7);

injectActionsInProgressCount();
injectActionsInProgressCount();

await vi.waitFor(() => {
expect(
document.querySelector(
'.better-github-actions-in-progress-count [data-component="CounterLabel"]',
)?.textContent,
).toBe("7");
});
expect(fetchActionsInProgressCount).toHaveBeenCalledOnce();
expect(document.querySelectorAll(".better-github-actions-in-progress-count")).toHaveLength(1);
expect(
document.querySelector('.better-github-actions-in-progress-count [class*="VisuallyHidden"]')
?.textContent,
).toBe("\u00a0(7)");
});

it("omits zero counts without refetching on repeated navigation polls", async () => {
vi.mocked(fetchActionsInProgressCount).mockResolvedValue(0);

injectActionsInProgressCount();
await vi.waitFor(() => expect(fetchActionsInProgressCount).toHaveBeenCalledOnce());
injectActionsInProgressCount();

expect(fetchActionsInProgressCount).toHaveBeenCalledOnce();
expect(document.querySelector(".better-github-actions-in-progress-count")).toBeNull();
});

it("can fetch and render again after the feature is disabled and re-enabled", async () => {
vi.mocked(fetchActionsInProgressCount).mockResolvedValue(3);

injectActionsInProgressCount();
await vi.waitFor(() =>
expect(document.querySelector(".better-github-actions-in-progress-count")).not.toBeNull(),
);

cleanupActionsInProgressCount();
expect(document.querySelector(".better-github-actions-in-progress-count")).toBeNull();

injectActionsInProgressCount();
await vi.waitFor(() => expect(fetchActionsInProgressCount).toHaveBeenCalledTimes(2));
expect(document.querySelector(".better-github-actions-in-progress-count")).not.toBeNull();
});

it("refreshes the count and removes the badge when runs finish", async () => {
vi.useFakeTimers();
vi.mocked(fetchActionsInProgressCount)
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(0);

injectActionsInProgressCount();
await vi.advanceTimersByTimeAsync(0);
expect(
document.querySelector(
'.better-github-actions-in-progress-count [data-component="CounterLabel"]',
)?.textContent,
).toBe("2");

await vi.advanceTimersByTimeAsync(61_000);
expect(
document.querySelector(
'.better-github-actions-in-progress-count [data-component="CounterLabel"]',
)?.textContent,
).toBe("1");
await vi.advanceTimersByTimeAsync(61_000);
expect(document.querySelector(".better-github-actions-in-progress-count")).toBeNull();
expect(fetchActionsInProgressCount).toHaveBeenCalledTimes(3);
});
});
112 changes: 112 additions & 0 deletions src/features/actions-in-progress-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { fetchActionsInProgressCount } from "../lib/github-api";
import { createNavCounter } from "../lib/nav-counter";
import { getRepoInfo, isRepoPage } from "../lib/page-detect";

const COUNTER_CLASS = "better-github-actions-in-progress-count";
const REFRESH_INTERVAL = 61 * 1000; // Longer than the service worker's 60-second cache.
const processedLinks = new WeakSet<HTMLAnchorElement>();
const pendingRequests = new WeakMap<HTMLAnchorElement, object>();
let activeLink: HTMLAnchorElement | null = null;
let activeRepo: string | null = null;
let refreshTimer: ReturnType<typeof setTimeout> | null = null;

function stopRefreshing(): void {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = null;
if (activeLink) {
processedLinks.delete(activeLink);
pendingRequests.delete(activeLink);
}
activeLink = null;
activeRepo = null;
}

function findActionsLink(owner: string, repo: string): HTMLAnchorElement | null {
const nav =
document.querySelector<HTMLElement>(".UnderlineNav-body") ||
document.querySelector<HTMLElement>("nav[aria-label='Repository'] ul");
if (!nav) return null;

const path = `/${owner}/${repo}/actions`;
return (
Array.from(nav.querySelectorAll<HTMLAnchorElement>("a")).find(
(link) => new URL(link.href, location.href).pathname === path,
) ?? null
);
}

export function injectActionsInProgressCount(): void {
if (!isRepoPage()) {
stopRefreshing();
return;
}

const info = getRepoInfo();
if (!info) return;

const link = findActionsLink(info.owner, info.repo);
if (!link) {
stopRefreshing();
return;
}
const repo = `${info.owner}/${info.repo}`;
if (activeLink !== link || activeRepo !== repo) stopRefreshing();
if (processedLinks.has(link)) return;

activeLink = link;
activeRepo = repo;
processedLinks.add(link);
const request = {};
pendingRequests.set(link, request);
void appendCount(link, request, info.owner, info.repo);
}

async function appendCount(
link: HTMLAnchorElement,
request: object,
owner: string,
repo: string,
): Promise<void> {
const count = await fetchActionsInProgressCount(owner, repo);
if (pendingRequests.get(link) !== request) return;
pendingRequests.delete(link);

const currentRepo = getRepoInfo();
if (!link.isConnected || currentRepo?.owner !== owner || currentRepo.repo !== repo) {
stopRefreshing();
return;
}

const existingCounter = link.querySelector<HTMLElement>(`.${COUNTER_CLASS}`);
if (count === null || count === 0) {
existingCounter?.remove();
} else if (existingCounter) {
const label = existingCounter.querySelector<HTMLElement>('[data-component="CounterLabel"]');
if (label) label.textContent = String(count);
const hiddenLabel = existingCounter.querySelector<HTMLElement>(
'[class*="VisuallyHidden"], .sr-only',
);
if (hiddenLabel) hiddenLabel.textContent = `\u00a0(${count})`;
} else {
const nav = link.closest<HTMLElement>(".UnderlineNav-body, nav[aria-label='Repository'] ul");
const nativeCounterTemplate = nav
?.querySelector<HTMLElement>('[data-component="counter"]')
?.cloneNode(true) as HTMLElement | undefined;
const counter = createNavCounter(nativeCounterTemplate, count);
counter.classList.add(COUNTER_CLASS);
link.appendChild(counter);
}

refreshTimer = setTimeout(() => {
const nextRequest = {};
pendingRequests.set(link, nextRequest);
void appendCount(link, nextRequest, owner, repo);
}, REFRESH_INTERVAL);
}

export function cleanupActionsInProgressCount(): void {
stopRefreshing();
document.querySelectorAll<HTMLElement>(`.${COUNTER_CLASS}`).forEach((counter) => {
counter.remove();
});
}
32 changes: 2 additions & 30 deletions src/features/release-tab.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isRepoPage, getRepoInfo, isReleasesPage } from "../lib/page-detect";
import { t } from "../lib/i18n";
import { fetchReleaseCount } from "../lib/github-api";
import { createNavCounter } from "../lib/nav-counter";

const TAB_CLASS = "better-github-releases-tab";

Expand Down Expand Up @@ -129,36 +130,7 @@ async function appendReleaseCount(
const currentRepo = getRepoInfo();
if (currentRepo?.owner !== owner || currentRepo.repo !== repo) return;

link.appendChild(createCounter(nativeCounterTemplate, count));
}

function createCounter(nativeCounterTemplate: HTMLElement | undefined, count: number): HTMLElement {
if (nativeCounterTemplate) {
const counter = nativeCounterTemplate.cloneNode(true) as HTMLElement;
const label = counter.querySelector<HTMLElement>('[data-component="CounterLabel"]');
if (label) label.textContent = String(count);

const hiddenLabel = counter.querySelector<HTMLElement>('[class*="VisuallyHidden"]');
if (hiddenLabel) hiddenLabel.textContent = `\u00a0(${count})`;
return counter;
}

const counter = document.createElement("span");
counter.dataset.component = "counter";

const label = document.createElement("span");
label.className = "Counter";
label.dataset.component = "CounterLabel";
label.dataset.variant = "secondary";
label.ariaHidden = "true";
label.textContent = String(count);

const hiddenLabel = document.createElement("span");
hiddenLabel.className = "sr-only";
hiddenLabel.textContent = `\u00a0(${count})`;

counter.append(label, hiddenLabel);
return counter;
link.appendChild(createNavCounter(nativeCounterTemplate, count));
}

function findReferenceTab(nav: HTMLElement): HTMLElement | null {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/github-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
approvePR,
fetchActionsInProgressCount,
fetchCommitDiffStats,
fetchForks,
fetchPRBranches,
Expand Down Expand Up @@ -93,6 +94,20 @@ describe("github-api bridge", () => {
expect(console.error).not.toHaveBeenCalled();
});

it("forwards Actions in-progress count requests and silently returns null on failure", async () => {
const runtime = mockRuntime({ response: { ok: true, data: 7 } });

expect(await fetchActionsInProgressCount("owner", "repo")).toBe(7);
expect(runtime.sendMessage).toHaveBeenCalledWith(
{ type: "FETCH_ACTIONS_IN_PROGRESS_COUNT", owner: "owner", repo: "repo" },
expect.any(Function),
);

mockRuntime({ response: { ok: false, error: "down" } });
expect(await fetchActionsInProgressCount("owner", "repo")).toBeNull();
expect(console.error).not.toHaveBeenCalled();
});

// Every wrapper forwards a distinct request type and resolves the worker's
// data on success — table-driven so each one is exercised.
const cases: Array<{ name: string; type: string; run: () => Promise<unknown> }> = [
Expand Down
Loading
Loading