From 1972f30a95c279cb6f014af3bf6eb56fb9ee1e57 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 1 Jul 2026 11:41:59 +0200 Subject: [PATCH] feat: show live progress while loading the repo list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background repo fetch could take a while with nothing to show for it but a spinner. Report progress from the service worker as it works — per platform, plus the current org name for GitHub — via a pr_radar_available_repos_progress storage key the popup watches through chrome.storage.onChanged. The Repos page now shows "Loading GitHub repos… (some-org)" under the spinner on first load and inline while refreshing. Implements the reporter's suggestion on #23. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src/background/service-worker.ts | 53 +++++++++++++++++++------------- src/popup/pages/Repos.tsx | 48 ++++++++++++++++++++++++----- src/shared/api/github.ts | 6 +++- src/shared/storage.ts | 25 +++++++++++++++ 5 files changed, 102 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f75bdc8..8a5c723 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ The extension works fully without DeployHQ. This integration is entirely opt-in - **No backend** — PATs stored in `chrome.storage.local`, all API calls direct from browser - **No tab required** — Background polling via service worker + `chrome.alarms` - **Cache-first rendering** — PR data cached in `chrome.storage`; popup shows cache instantly, refreshes in background with "Updating..." indicator -- **Background available-repo fetch** — The Repos page's list of watchable repos (personal + every org's repos, per platform) is slow to fetch, so the service worker fetches it (`FETCH_AVAILABLE_REPOS` message, dedup'd via an in-flight promise) and caches it in `chrome.storage`. The popup renders cache-first and shows "Updating…"; because the fetch runs in the SW it survives the popup closing. A refresh that loads zero repos across all accounts caches an error the page surfaces instead of an endless spinner (issue #23) +- **Background available-repo fetch** — The Repos page's list of watchable repos (personal + every org's repos, per platform) is slow to fetch, so the service worker fetches it (`FETCH_AVAILABLE_REPOS` message, dedup'd via an in-flight promise) and caches it in `chrome.storage`. The popup renders cache-first and shows "Updating…"; because the fetch runs in the SW it survives the popup closing. A refresh that loads zero repos across all accounts caches an error the page surfaces instead of an endless spinner (issue #23). Live progress is reported through a `pr_radar_available_repos_progress` storage key the SW writes as it works (per-platform, plus the current org name for GitHub) and the popup watches via `chrome.storage.onChanged` — surfaced as "Loading GitHub repos… (org)" - **Add repo by name** — The enumerated list is paginated (GitHub caps at ~2000 repos/list), so in very large orgs a repo may never appear. The Repos page has an "Add a repo by name" form: enter `owner/repo` (or a pasted URL), which is verified with a single API call via the `VERIFY_REPO` message (`github.getRepo` / `gitlab.getProject` / `bitbucket.getRepository`) and added straight to the watched list. `buildRepoList` unions watched-but-not-enumerated repos (enabled or pinned only) so added repos survive cache refreshes and don't resurrect stale disabled entries (issue #23) - **Persisted CI statuses** — Stored in `chrome.storage` (not in-memory) so status change detection survives service worker restarts - **Offscreen API for audio** — MV3 service workers can't play audio; uses `public/offscreen.html` + `public/offscreen.js` (no inline scripts due to CSP) diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index ff81894..02429d6 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -1,6 +1,6 @@ import type { PullRequest, CIStatus, Message, PollError, PollErrorKind, Platform, RateLimitInfo } from '@/shared/types'; import { CI_STATUS_LABELS } from '@/shared/constants'; -import { getSettings, getAccounts, getWatchedRepos, getCachedPRs, saveCachedPRs, setInstallDate, getDeployHQAccount, saveDeployHQAccount, getDeployHQRepoMapping, saveDeployHQRepoMapping, savePollErrors, saveRateLimits, getRateLimits, saveAccount, setWhatsNewSeenVersion, saveCachedAvailableRepos } from '@/shared/storage'; +import { getSettings, getAccounts, getWatchedRepos, getCachedPRs, saveCachedPRs, setInstallDate, getDeployHQAccount, saveDeployHQAccount, getDeployHQRepoMapping, saveDeployHQRepoMapping, savePollErrors, saveRateLimits, getRateLimits, saveAccount, setWhatsNewSeenVersion, saveCachedAvailableRepos, setAvailableReposProgress } from '@/shared/storage'; import type { AvailableRepo } from '@/shared/storage'; import * as github from '@/shared/api/github'; import * as gitlab from '@/shared/api/gitlab'; @@ -92,31 +92,40 @@ async function doRefreshAvailableRepos(): Promise { const repos: AvailableRepo[] = []; const failed: Platform[] = []; - for (const account of accounts) { - try { - if (account.platform === 'github') { - const ghRepos = await github.getUserRepos(account.token); - for (const r of ghRepos) repos.push({ platform: 'github', fullName: r.full_name }); - } else if (account.platform === 'gitlab') { - const glRepos = await gitlab.getUserProjects(account.token); - for (const r of glRepos) repos.push({ platform: 'gitlab', fullName: r.path_with_namespace }); - } else if (account.platform === 'bitbucket') { - const bbRepos = await bitbucket.getUserRepositories(account.token); - for (const r of bbRepos) repos.push({ platform: 'bitbucket', fullName: r.full_name }); + try { + for (const account of accounts) { + try { + if (account.platform === 'github') { + await setAvailableReposProgress({ platform: 'github' }); + const ghRepos = await github.getUserRepos(account.token, (org) => + setAvailableReposProgress({ platform: 'github', detail: org }), + ); + for (const r of ghRepos) repos.push({ platform: 'github', fullName: r.full_name }); + } else if (account.platform === 'gitlab') { + await setAvailableReposProgress({ platform: 'gitlab' }); + const glRepos = await gitlab.getUserProjects(account.token); + for (const r of glRepos) repos.push({ platform: 'gitlab', fullName: r.path_with_namespace }); + } else if (account.platform === 'bitbucket') { + await setAvailableReposProgress({ platform: 'bitbucket' }); + const bbRepos = await bitbucket.getUserRepositories(account.token); + for (const r of bbRepos) repos.push({ platform: 'bitbucket', fullName: r.full_name }); + } + } catch (err) { + console.error(`[PR Radar] Failed to fetch repos for ${account.platform}:`, err); + failed.push(account.platform); } - } catch (err) { - console.error(`[PR Radar] Failed to fetch repos for ${account.platform}:`, err); - failed.push(account.platform); } - } - // Only surface an error when nothing loaded — a partial failure still shows - // the repos we did get. - const error = repos.length === 0 && failed.length > 0 - ? `Couldn't load repos for: ${failed.join(', ')}` - : undefined; + // Only surface an error when nothing loaded — a partial failure still shows + // the repos we did get. + const error = repos.length === 0 && failed.length > 0 + ? `Couldn't load repos for: ${failed.join(', ')}` + : undefined; - await saveCachedAvailableRepos({ repos, updatedAt: Date.now(), error }); + await saveCachedAvailableRepos({ repos, updatedAt: Date.now(), error }); + } finally { + await setAvailableReposProgress(null); + } } // Deduplicate concurrent refreshes: a popup reopened mid-fetch awaits the same diff --git a/src/popup/pages/Repos.tsx b/src/popup/pages/Repos.tsx index 5834846..1abd203 100644 --- a/src/popup/pages/Repos.tsx +++ b/src/popup/pages/Repos.tsx @@ -1,9 +1,21 @@ import { useState, useEffect } from 'react'; import type { Platform, WatchedRepo } from '@/shared/types'; -import type { AvailableRepo } from '@/shared/storage'; -import { getAccounts, getWatchedRepos, saveWatchedRepos, getCachedAvailableRepos } from '@/shared/storage'; +import type { AvailableRepo, AvailableReposProgress } from '@/shared/storage'; +import { + getAccounts, + getWatchedRepos, + saveWatchedRepos, + getCachedAvailableRepos, + getAvailableReposProgress, + AVAILABLE_REPOS_PROGRESS_KEY, +} from '@/shared/storage'; import PlatformIcon from '../components/PlatformIcon'; +function formatProgress(p: AvailableReposProgress): string { + const label = p.platform === 'github' ? 'GitHub' : p.platform === 'gitlab' ? 'GitLab' : 'Bitbucket'; + return p.detail ? `Loading ${label} repos… (${p.detail})` : `Loading ${label} repos…`; +} + // Merge the cached available-repo list with saved watch state, then sort: // pinned+enabled first, then enabled, then the rest — alphabetical within each group. function buildRepoList(available: AvailableRepo[], watched: WatchedRepo[]): WatchedRepo[] { @@ -57,6 +69,7 @@ export default function Repos() { const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [progress, setProgress] = useState(null); const [error, setError] = useState(null); const [filter, setFilter] = useState(''); const [platformFilter, setPlatformFilter] = useState('all'); @@ -70,16 +83,28 @@ export default function Repos() { useEffect(() => { let cancelled = false; + // Live progress from the background fetch ("Loading GitHub repos… (org)"). + function onStorageChange( + changes: Record, + area: string, + ) { + if (area !== 'local' || !changes[AVAILABLE_REPOS_PROGRESS_KEY]) return; + setProgress((changes[AVAILABLE_REPOS_PROGRESS_KEY].newValue as AvailableReposProgress) ?? null); + } + chrome.storage.onChanged.addListener(onStorageChange); + async function run() { // Cache-first: render whatever we have instantly so reopening the popup is // never a blank spinner. - const [accounts, cache, watched] = await Promise.all([ + const [accounts, cache, watched, initialProgress] = await Promise.all([ getAccounts(), getCachedAvailableRepos(), getWatchedRepos(), + getAvailableReposProgress(), ]); if (cancelled) return; setConnectedPlatforms(new Set(accounts.map((a) => a.platform))); + setProgress(initialProgress); if (cache) { setRepos(buildRepoList(cache.repos, watched)); setError(cache.error ?? null); @@ -107,11 +132,13 @@ export default function Repos() { } setLoading(false); setRefreshing(false); + setProgress(null); } run(); return () => { cancelled = true; + chrome.storage.onChanged.removeListener(onStorageChange); }; }, []); @@ -253,11 +280,8 @@ export default function Repos() { )}
-

+

{enabledCount} of {repos.length} repos watched - {refreshing && repos.length > 0 && ( - · Updating… - )}

{filtered.length > 0 && (
+ {refreshing && repos.length > 0 && ( +

+ {progress ? formatProgress(progress) : 'Updating…'} +

+ )} {connectedPlatforms.size > 0 && (
@@ -350,8 +379,11 @@ export default function Repos() {
{loading ? ( -
+
+

+ {progress ? formatProgress(progress) : 'Loading repositories…'} +

) : error && repos.length === 0 ? (
diff --git a/src/shared/api/github.ts b/src/shared/api/github.ts index f054db2..999489c 100644 --- a/src/shared/api/github.ts +++ b/src/shared/api/github.ts @@ -181,7 +181,10 @@ export async function getRepo(token: string, fullName: string): Promise<{ full_n } } -export async function getUserRepos(token: string): Promise<{ full_name: string }[]> { +export async function getUserRepos( + token: string, + onProgress?: (detail: string) => void, +): Promise<{ full_name: string }[]> { // Fetch personal repos const userRepos = await ghPaginate<{ full_name: string }>( '/user/repos?sort=pushed&per_page=100&affiliation=owner,collaborator,organization_member', @@ -196,6 +199,7 @@ export async function getUserRepos(token: string): Promise<{ full_name: string } orgs, ORG_FETCH_CONCURRENCY, async (org) => { + onProgress?.(org.login); const [memberRepos, publicRepos] = await Promise.all([ ghPaginate<{ full_name: string }>( `/orgs/${org.login}/repos?sort=pushed&per_page=100&type=member`, diff --git a/src/shared/storage.ts b/src/shared/storage.ts index f537cf9..b6a35e2 100644 --- a/src/shared/storage.ts +++ b/src/shared/storage.ts @@ -134,6 +134,30 @@ export async function saveCachedAvailableRepos(cache: AvailableReposCache): Prom await chrome.storage.local.set({ [AVAILABLE_REPOS_CACHE_KEY]: cache }); } +// Live progress for the background available-repos fetch, so the Repos page can +// show "Loading GitHub repos… (some-org)" instead of an opaque spinner. Written +// by the service worker and watched by the popup via chrome.storage.onChanged. +export const AVAILABLE_REPOS_PROGRESS_KEY = 'pr_radar_available_repos_progress'; + +export interface AvailableReposProgress { + platform: Platform; + // Optional finer-grained context, e.g. the org currently being fetched. + detail?: string; +} + +export async function setAvailableReposProgress(progress: AvailableReposProgress | null): Promise { + if (progress) { + await chrome.storage.local.set({ [AVAILABLE_REPOS_PROGRESS_KEY]: progress }); + } else { + await chrome.storage.local.remove(AVAILABLE_REPOS_PROGRESS_KEY); + } +} + +export async function getAvailableReposProgress(): Promise { + const result = await chrome.storage.local.get(AVAILABLE_REPOS_PROGRESS_KEY); + return result[AVAILABLE_REPOS_PROGRESS_KEY] ?? null; +} + // === PR cache === import type { PullRequest } from './types'; @@ -244,6 +268,7 @@ export async function clearAll(): Promise { RATE_LIMIT_DISMISSED_KEY, AI_SUMMARY_CACHE_KEY, AVAILABLE_REPOS_CACHE_KEY, + AVAILABLE_REPOS_PROGRESS_KEY, ]); }