From 378333559518f33b0a4c28522b0a189022ef3646 Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 22 Sep 2026 18:41:22 +0800 Subject: [PATCH 1/2] feat: show in-progress Actions count --- src/_locales/en/messages.json | 4 + src/_locales/zh_CN/messages.json | 4 + src/_locales/zh_TW/messages.json | 4 + src/content.ts | 10 +++ .../actions-in-progress-count.test.ts | 85 +++++++++++++++++++ src/features/actions-in-progress-count.ts | 78 +++++++++++++++++ src/features/release-tab.ts | 32 +------ src/lib/github-api.test.ts | 15 ++++ src/lib/github-api.ts | 15 ++++ src/lib/messages.ts | 1 + src/lib/nav-counter.ts | 31 +++++++ src/options.test.ts | 1 + src/options.ts | 1 + src/service-worker.test.ts | 37 ++++++++ src/service-worker.ts | 34 +++++++- static/options.html | 14 +++ 16 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 src/features/actions-in-progress-count.test.ts create mode 100644 src/features/actions-in-progress-count.ts create mode 100644 src/lib/nav-counter.ts diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 35f763e..83b14dc 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -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." diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index 7ce0bd0..e68e855 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -109,6 +109,10 @@ "featCommitDiffStatsDesc": { "message": "在 Commits 列表页显示新增、删除行数和文件数(如 +223 −114 · 5 files)。需要令牌。" }, + "featActionsInProgressCountName": { "message": "Actions 进行中数量" }, + "featActionsInProgressCountDesc": { + "message": "在 Actions 标签页旁显示当前正在运行的工作流数量。" + }, "featReleaseTabName": { "message": "Releases 标签页" }, "featReleaseTabDesc": { "message": "在仓库导航栏添加一个 Releases 标签页,方便快速访问。" diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index 62dee3e..4888534 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -109,6 +109,10 @@ "featCommitDiffStatsDesc": { "message": "在 Commits 清單頁顯示新增、刪除行數和檔案數(如 +223 −114 · 5 files)。需要權杖。" }, + "featActionsInProgressCountName": { "message": "Actions 進行中數量" }, + "featActionsInProgressCountDesc": { + "message": "在 Actions 分頁旁顯示目前正在執行的工作流程數量。" + }, "featReleaseTabName": { "message": "Releases 分頁" }, "featReleaseTabDesc": { "message": "在儲存庫導覽列新增一個 Releases 分頁,方便快速存取。" diff --git a/src/content.ts b/src/content.ts index 329f43a..e142b68 100644 --- a/src/content.ts +++ b/src/content.ts @@ -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, @@ -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", @@ -56,6 +61,7 @@ const FEATURE_CLASSES: Record = { "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"], @@ -77,6 +83,7 @@ const FEATURE_CLEANUPS: Partial 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 { @@ -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; diff --git a/src/features/actions-in-progress-count.test.ts b/src/features/actions-in-progress-count.test.ts new file mode 100644 index 0000000..844343e --- /dev/null +++ b/src/features/actions-in-progress-count.test.ts @@ -0,0 +1,85 @@ +import { 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", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchActionsInProgressCount).mockResolvedValue(null); + setUrl("https://github.com/owner/repo"); + document.body.innerHTML = ` + + `; + }); + + 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(); + }); +}); diff --git a/src/features/actions-in-progress-count.ts b/src/features/actions-in-progress-count.ts new file mode 100644 index 0000000..9f48edb --- /dev/null +++ b/src/features/actions-in-progress-count.ts @@ -0,0 +1,78 @@ +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 processedLinks = new WeakSet(); +const pendingRequests = new WeakMap(); + +function findActionsLink(owner: string, repo: string): HTMLAnchorElement | null { + const nav = + document.querySelector(".UnderlineNav-body") || + document.querySelector("nav[aria-label='Repository'] ul"); + if (!nav) return null; + + const path = `/${owner}/${repo}/actions`; + return ( + Array.from(nav.querySelectorAll("a")).find( + (link) => new URL(link.href, location.href).pathname === path, + ) ?? null + ); +} + +export function injectActionsInProgressCount(): void { + if (!isRepoPage()) return; + + const info = getRepoInfo(); + if (!info) return; + + const link = findActionsLink(info.owner, info.repo); + if (!link || processedLinks.has(link)) return; + + 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 { + const count = await fetchActionsInProgressCount(owner, repo); + if (pendingRequests.get(link) !== request) return; + pendingRequests.delete(link); + + if (count === null || count === 0 || !link.isConnected) return; + + const currentRepo = getRepoInfo(); + if (currentRepo?.owner !== owner || currentRepo.repo !== repo) return; + + const nav = link.closest(".UnderlineNav-body, nav[aria-label='Repository'] ul"); + const nativeCounterTemplate = nav + ?.querySelector('[data-component="counter"]') + ?.cloneNode(true) as HTMLElement | undefined; + const counter = createNavCounter(nativeCounterTemplate, count); + counter.classList.add(COUNTER_CLASS); + link.appendChild(counter); +} + +export function cleanupActionsInProgressCount(): void { + document.querySelectorAll(`.${COUNTER_CLASS}`).forEach((counter) => { + const link = counter.closest("a"); + if (link) { + processedLinks.delete(link); + pendingRequests.delete(link); + } + counter.remove(); + }); + + const info = getRepoInfo(); + if (!info) return; + const link = findActionsLink(info.owner, info.repo); + if (!link) return; + processedLinks.delete(link); + pendingRequests.delete(link); +} diff --git a/src/features/release-tab.ts b/src/features/release-tab.ts index 8d9a825..42eed5f 100644 --- a/src/features/release-tab.ts +++ b/src/features/release-tab.ts @@ -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"; @@ -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('[data-component="CounterLabel"]'); - if (label) label.textContent = String(count); - - const hiddenLabel = counter.querySelector('[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 { diff --git a/src/lib/github-api.test.ts b/src/lib/github-api.test.ts index fcb9399..6a7f188 100644 --- a/src/lib/github-api.test.ts +++ b/src/lib/github-api.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { approvePR, + fetchActionsInProgressCount, fetchCommitDiffStats, fetchForks, fetchPRBranches, @@ -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 }> = [ diff --git a/src/lib/github-api.ts b/src/lib/github-api.ts index 8fba033..e6bcdd1 100644 --- a/src/lib/github-api.ts +++ b/src/lib/github-api.ts @@ -280,6 +280,21 @@ export async function fetchReleaseCount(owner: string, repo: string): Promise { + try { + return await sendMessage({ + type: "FETCH_ACTIONS_IN_PROGRESS_COUNT", + owner, + repo, + }); + } catch { + return null; + } +} + export async function fetchReleaseDownloads( owner: string, repo: string, diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 316485f..3a0e834 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -121,6 +121,7 @@ export type ServiceWorkerRequest = | { type: "FETCH_STARGAZERS"; owner: string; repo: string } | { type: "FETCH_WATCHERS"; owner: string; repo: string } | { type: "FETCH_FORKS"; owner: string; repo: string } + | { type: "FETCH_ACTIONS_IN_PROGRESS_COUNT"; owner: string; repo: string } | { type: "FETCH_RELEASE_COUNT"; owner: string; repo: string } | { type: "FETCH_RELEASE_DOWNLOADS"; owner: string; repo: string; tag: string } | { type: "FETCH_CONTRIBUTOR_INFO"; login: string; owner?: string; repo?: string }; diff --git a/src/lib/nav-counter.ts b/src/lib/nav-counter.ts new file mode 100644 index 0000000..2d6913f --- /dev/null +++ b/src/lib/nav-counter.ts @@ -0,0 +1,31 @@ +export function createNavCounter( + nativeCounterTemplate: HTMLElement | undefined, + count: number, +): HTMLElement { + if (nativeCounterTemplate) { + const counter = nativeCounterTemplate.cloneNode(true) as HTMLElement; + const label = counter.querySelector('[data-component="CounterLabel"]'); + if (label) label.textContent = String(count); + + const hiddenLabel = counter.querySelector('[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; +} diff --git a/src/options.test.ts b/src/options.test.ts index b6a80db..bfd6b8c 100644 --- a/src/options.test.ts +++ b/src/options.test.ts @@ -6,6 +6,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", diff --git a/src/options.ts b/src/options.ts index fe20b8a..01a0817 100644 --- a/src/options.ts +++ b/src/options.ts @@ -30,6 +30,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", diff --git a/src/service-worker.test.ts b/src/service-worker.test.ts index d4a0e28..a9a45ad 100644 --- a/src/service-worker.test.ts +++ b/src/service-worker.test.ts @@ -629,6 +629,43 @@ describe("service worker", () => { expect(body.query).toContain("totalCount"); }); + it("fetches and briefly caches the in-progress Actions count", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_000); + const state = await loadWorker("token"); + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse({ total_count: 7, workflow_runs: [] })) + .mockResolvedValueOnce(jsonResponse({ total_count: 4, workflow_runs: [] })); + const request: ServiceWorkerRequest = { + type: "FETCH_ACTIONS_IN_PROGRESS_COUNT", + owner: "owner", + repo: "repo", + }; + + expect(await sendMessage(state.messageListeners[0], request)).toEqual({ ok: true, data: 7 }); + expect(await sendMessage(state.messageListeners[0], request)).toEqual({ ok: true, data: 7 }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetch).mock.calls[0][0]).toBe( + "https://api.github.com/repos/owner/repo/actions/runs?status=in_progress&per_page=1", + ); + + vi.mocked(Date.now).mockReturnValue(61_001); + expect(await sendMessage(state.messageListeners[0], request)).toEqual({ ok: true, data: 4 }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("returns null for an invalid Actions count response", async () => { + const state = await loadWorker(); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ total_count: "7" })); + + const response = await sendMessage(state.messageListeners[0], { + type: "FETCH_ACTIONS_IN_PROGRESS_COUNT", + owner: "owner", + repo: "repo", + }); + + expect(response).toEqual({ ok: true, data: null }); + }); + it("derives anonymous release counts from REST pagination", async () => { const state = await loadWorker(); vi.mocked(fetch).mockResolvedValue( diff --git a/src/service-worker.ts b/src/service-worker.ts index 4ae9f72..104f150 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -18,6 +18,7 @@ import type { } from "./lib/messages"; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const LIVE_COUNT_CACHE_TTL = 60 * 1000; // 1 minute const CACHE_MISS = Symbol("cache-miss"); // In-memory map of in-flight fetches to coalesce concurrent requests for the same key @@ -28,11 +29,11 @@ interface CacheEntry { timestamp: number; } -async function getCached(key: string): Promise { +async function getCached(key: string, ttl = CACHE_TTL): Promise { const result = await chrome.storage.session.get(key); const entry = result[key] as CacheEntry | undefined; if (!entry) return CACHE_MISS; - if (Date.now() - entry.timestamp > CACHE_TTL) { + if (Date.now() - entry.timestamp > ttl) { chrome.storage.session.remove(key); return CACHE_MISS; } @@ -43,8 +44,8 @@ async function setCache(key: string, data: T): Promise { await chrome.storage.session.set({ [key]: { data, timestamp: Date.now() } }); } -async function cachedFetch(key: string, fetcher: () => Promise): Promise { - const cached = await getCached(key); +async function cachedFetch(key: string, fetcher: () => Promise, ttl = CACHE_TTL): Promise { + const cached = await getCached(key, ttl); if (cached !== CACHE_MISS) return cached; const existing = inflight.get(key); @@ -749,6 +750,26 @@ async function fetchReleaseCount(owner: string, repo: string): Promise { + const cacheKey = `cache:actions-in-progress:${owner}/${repo}`; + return cachedFetch( + cacheKey, + async () => { + const token = await getToken(); + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/actions/runs?status=in_progress&per_page=1`, + { headers: restHeaders(token, "application/vnd.github+json") }, + ); + if (!response.ok) return null; + + const data = (await response.json()) as { total_count?: unknown }; + const count = data.total_count; + return typeof count === "number" && Number.isInteger(count) && count >= 0 ? count : null; + }, + LIVE_COUNT_CACHE_TTL, + ); +} + async function fetchReleaseDownloads( owner: string, repo: string, @@ -959,6 +980,11 @@ async function handleMessage( return { ok: true, data: await fetchWatchers(request.owner, request.repo) }; case "FETCH_FORKS": return { ok: true, data: await fetchForks(request.owner, request.repo) }; + case "FETCH_ACTIONS_IN_PROGRESS_COUNT": + return { + ok: true, + data: await fetchActionsInProgressCount(request.owner, request.repo), + }; case "FETCH_RELEASE_COUNT": return { ok: true, data: await fetchReleaseCount(request.owner, request.repo) }; case "FETCH_RELEASE_DOWNLOADS": diff --git a/static/options.html b/static/options.html index e2b0547..9fcee95 100644 --- a/static/options.html +++ b/static/options.html @@ -711,6 +711,20 @@

Settings

Repository
    +
  • +
    +
    + Actions In-Progress Count +
    +
    + Show the number of currently running workflow runs beside the Actions tab. +
    +
    + +
  • Releases Tab
    From 88787effeaa28828feec82c2014d89b4a53a848b Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 23 Sep 2026 15:08:13 +0800 Subject: [PATCH 2/2] fix: refresh in-progress Actions count --- .../actions-in-progress-count.test.ts | 33 +++++++- src/features/actions-in-progress-count.ts | 82 +++++++++++++------ src/styles/content.css | 61 ++++++++++++++ 3 files changed, 151 insertions(+), 25 deletions(-) diff --git a/src/features/actions-in-progress-count.test.ts b/src/features/actions-in-progress-count.test.ts index 844343e..858352e 100644 --- a/src/features/actions-in-progress-count.test.ts +++ b/src/features/actions-in-progress-count.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fetchActionsInProgressCount } from "../lib/github-api"; import { setUrl } from "../test-utils/url"; import { @@ -9,6 +9,11 @@ import { vi.mock("../lib/github-api"); describe("injectActionsInProgressCount", () => { + afterEach(() => { + cleanupActionsInProgressCount(); + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); vi.mocked(fetchActionsInProgressCount).mockResolvedValue(null); @@ -82,4 +87,30 @@ describe("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); + }); }); diff --git a/src/features/actions-in-progress-count.ts b/src/features/actions-in-progress-count.ts index 9f48edb..602366b 100644 --- a/src/features/actions-in-progress-count.ts +++ b/src/features/actions-in-progress-count.ts @@ -3,8 +3,23 @@ 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(); const pendingRequests = new WeakMap(); +let activeLink: HTMLAnchorElement | null = null; +let activeRepo: string | null = null; +let refreshTimer: ReturnType | 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 = @@ -21,14 +36,25 @@ function findActionsLink(owner: string, repo: string): HTMLAnchorElement | null } export function injectActionsInProgressCount(): void { - if (!isRepoPage()) return; + if (!isRepoPage()) { + stopRefreshing(); + return; + } const info = getRepoInfo(); if (!info) return; const link = findActionsLink(info.owner, info.repo); - if (!link || processedLinks.has(link)) return; + 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); @@ -45,34 +71,42 @@ async function appendCount( if (pendingRequests.get(link) !== request) return; pendingRequests.delete(link); - if (count === null || count === 0 || !link.isConnected) return; - const currentRepo = getRepoInfo(); - if (currentRepo?.owner !== owner || currentRepo.repo !== repo) return; + if (!link.isConnected || currentRepo?.owner !== owner || currentRepo.repo !== repo) { + stopRefreshing(); + return; + } - const nav = link.closest(".UnderlineNav-body, nav[aria-label='Repository'] ul"); - const nativeCounterTemplate = nav - ?.querySelector('[data-component="counter"]') - ?.cloneNode(true) as HTMLElement | undefined; - const counter = createNavCounter(nativeCounterTemplate, count); - counter.classList.add(COUNTER_CLASS); - link.appendChild(counter); + const existingCounter = link.querySelector(`.${COUNTER_CLASS}`); + if (count === null || count === 0) { + existingCounter?.remove(); + } else if (existingCounter) { + const label = existingCounter.querySelector('[data-component="CounterLabel"]'); + if (label) label.textContent = String(count); + const hiddenLabel = existingCounter.querySelector( + '[class*="VisuallyHidden"], .sr-only', + ); + if (hiddenLabel) hiddenLabel.textContent = `\u00a0(${count})`; + } else { + const nav = link.closest(".UnderlineNav-body, nav[aria-label='Repository'] ul"); + const nativeCounterTemplate = nav + ?.querySelector('[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(`.${COUNTER_CLASS}`).forEach((counter) => { - const link = counter.closest("a"); - if (link) { - processedLinks.delete(link); - pendingRequests.delete(link); - } counter.remove(); }); - - const info = getRepoInfo(); - if (!info) return; - const link = findActionsLink(info.owner, info.repo); - if (!link) return; - processedLinks.delete(link); - pendingRequests.delete(link); } diff --git a/src/styles/content.css b/src/styles/content.css index 5b6ae37..aa38311 100644 --- a/src/styles/content.css +++ b/src/styles/content.css @@ -612,6 +612,67 @@ a.better-github-review-popover-item:hover { text-decoration: none; } +/* In-progress Actions count — animate an arc around the number so the progress + indicator and counter read as one badge while the number stays still. */ +@property --better-github-actions-count-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +.better-github-actions-in-progress-count { + position: relative; + display: inline-grid; + place-items: center; + isolation: isolate; +} + +.better-github-actions-in-progress-count > [data-component="CounterLabel"] { + position: relative; + z-index: 1; + color: var(--fgColor-attention, var(--color-attention-fg, #9a6700)); + background-color: var(--bgColor-attention-muted, var(--color-attention-subtle, #fff8c5)); + background-color: color-mix( + in srgb, + var(--fgColor-attention, var(--color-attention-fg, #9a6700)) 14%, + var(--bgColor-default, var(--color-canvas-default, #fff)) + ); +} + +.better-github-actions-in-progress-count::before { + content: ""; + box-sizing: border-box; + position: absolute; + inset: -2px; + padding: 2px; + border-radius: 999px; + background: conic-gradient( + from var(--better-github-actions-count-angle), + transparent 0 65%, + var(--fgColor-attention, var(--color-attention-fg, #9a6700)) 80% 100% + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + z-index: 0; + pointer-events: none; + animation: better-github-actions-count-spin 0.8s linear infinite; +} + +@keyframes better-github-actions-count-spin { + to { + --better-github-actions-count-angle: 360deg; + } +} + +@media (prefers-reduced-motion: reduce) { + .better-github-actions-in-progress-count::before { + animation: none; + } +} + /* Releases Tab */ .better-github-releases-tab { /* Inherits styling from cloned tab element */