From 6fe0f68c91948dcd2f2deb4bc753a616f22fb282 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 1 Jul 2026 11:24:06 +0200 Subject: [PATCH] fix: load watchable repos in the background and cache them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Repos page rebuilt the full available-repo list (personal repos plus every org's repos, across each connected platform) from scratch on every open, inside the popup. That made it slow every time — and because the work ran in the popup, closing it aborted the fetch and the next open started over. On slower connections (e.g. Firefox) this looked like an endless spinner. Move the fetch into the service worker (FETCH_AVAILABLE_REPOS, dedup'd via an in-flight promise) and cache the result in chrome.storage. The popup now renders cache-first, shows an "Updating…" indicator, and re-reads the cache once the refresh resolves. Because the fetch runs in the SW it keeps going after the popup closes, and subsequent opens are instant. A refresh that loads zero repos across all accounts caches an error the page surfaces instead of spinning forever. Fixes #23 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 +- src/background/service-worker.ts | 56 ++++++++++++- src/popup/pages/Repos.tsx | 137 +++++++++++++++++-------------- src/shared/storage.ts | 33 ++++++++ src/shared/types.ts | 3 +- 5 files changed, 168 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index feeb033..7e5127a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,14 +74,14 @@ To update: rebuild, then click refresh on the extension card (or remove + re-add ``` src/ background/ - service-worker.ts # Polling, badge updates, notifications, sound trigger + service-worker.ts # Polling, badge updates, notifications, sound trigger, background available-repo fetch (cached, dedup'd) popup/ App.tsx # Main app shell with routing pages/ Setup.tsx # Multi-platform connection (GitHub/GitLab/Bitbucket PAT auth, scope guide, connected state) Dashboard.tsx # PR list with tabs (Mine/Review/All), cache-first rendering Settings.tsx # Notifications, sound, polling, stale PR config, accounts, test button - Repos.tsx # Watched repo selector with platform filter, select all, pin/fav stars, token scope callouts + Repos.tsx # Watched repo selector with platform filter, select all, pin/fav stars, token scope callouts; cache-first, refreshes via background service worker components/ Header.tsx # Navigation header with extension icon + "by DeployHQ" PRItem.tsx # PR row: badges, diff stats, description preview, deployment URL, pinned star, stale/reviewed dimming @@ -96,7 +96,7 @@ src/ shared/ types.ts # TypeScript types (PullRequest, Platform, Message, UrgencyCategory, etc.) constants.ts # Status colors, platform labels, sound options - storage.ts # Chrome storage wrapper (accounts, settings, repos, PR cache, CI statuses) + storage.ts # Chrome storage wrapper (accounts, settings, repos, PR cache, available-repos cache, CI statuses) api/ github.ts # GitHub REST + GraphQL API (PRs, CI, reviews, threads, deployments, orgs, merge) gitlab.ts # GitLab REST API (MRs, CI pipelines, discussions, approvals, deployments, merge) @@ -158,6 +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) - **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) - **GraphQL for comments** — REST API doesn't expose thread resolution; GraphQL `reviewThreads.isResolved` is accurate diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index dbffa1d..17cbe01 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -1,6 +1,7 @@ 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 } from '@/shared/storage'; +import { getSettings, getAccounts, getWatchedRepos, getCachedPRs, saveCachedPRs, setInstallDate, getDeployHQAccount, saveDeployHQAccount, getDeployHQRepoMapping, saveDeployHQRepoMapping, savePollErrors, saveRateLimits, getRateLimits, saveAccount, setWhatsNewSeenVersion, saveCachedAvailableRepos } from '@/shared/storage'; +import type { AvailableRepo } from '@/shared/storage'; import * as github from '@/shared/api/github'; import * as gitlab from '@/shared/api/gitlab'; import * as bitbucket from '@/shared/api/bitbucket'; @@ -79,10 +80,63 @@ chrome.alarms.onAlarm.addListener((alarm) => { } }); +// === Available repos === +// Fetching the full list of watchable repos is slow, so we do it here in the +// service worker (not the popup) and cache the result. Running it here means the +// fetch keeps going even after the popup closes. See issue #23. + +let availableReposRefresh: Promise | null = null; + +async function doRefreshAvailableRepos(): Promise { + const accounts = await getAccounts(); + 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 }); + } + } 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; + + await saveCachedAvailableRepos({ repos, updatedAt: Date.now(), error }); +} + +// Deduplicate concurrent refreshes: a popup reopened mid-fetch awaits the same +// in-flight run rather than kicking off a second one. +function refreshAvailableRepos(): Promise { + if (!availableReposRefresh) { + availableReposRefresh = doRefreshAvailableRepos().finally(() => { + availableReposRefresh = null; + }); + } + return availableReposRefresh; +} + chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => { if (message.type === 'POLL_NOW') { pollPRs().then(() => sendResponse({ done: true })); return true; // keep channel open for async sendResponse + } else if (message.type === 'FETCH_AVAILABLE_REPOS') { + refreshAvailableRepos().then(() => sendResponse({ done: true })); + return true; // keep channel open for async sendResponse } else if (message.type === 'REFRESH_SETTINGS') { setupPolling(); } else if (message.type === 'MERGE_PR') { diff --git a/src/popup/pages/Repos.tsx b/src/popup/pages/Repos.tsx index a002888..2b8322d 100644 --- a/src/popup/pages/Repos.tsx +++ b/src/popup/pages/Repos.tsx @@ -1,82 +1,86 @@ import { useState, useEffect } from 'react'; import type { Platform, WatchedRepo } from '@/shared/types'; -import { getAccounts, getWatchedRepos, saveWatchedRepos } from '@/shared/storage'; -import * as github from '@/shared/api/github'; -import * as gitlab from '@/shared/api/gitlab'; -import * as bitbucket from '@/shared/api/bitbucket'; +import type { AvailableRepo } from '@/shared/storage'; +import { getAccounts, getWatchedRepos, saveWatchedRepos, getCachedAvailableRepos } from '@/shared/storage'; import PlatformIcon from '../components/PlatformIcon'; +// 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[] { + const watchedMap = new Map(watched.map((r) => [`${r.platform}:${r.fullName}`, r])); + const list = available.map((r) => { + const saved = watchedMap.get(`${r.platform}:${r.fullName}`); + return { + platform: r.platform, + fullName: r.fullName, + enabled: saved?.enabled ?? false, + pinned: saved?.pinned ?? false, + } satisfies WatchedRepo; + }); + list.sort((a, b) => { + const aRank = a.enabled && a.pinned ? 0 : a.enabled ? 1 : 2; + const bRank = b.enabled && b.pinned ? 0 : b.enabled ? 1 : 2; + if (aRank !== bRank) return aRank - bRank; + return a.fullName.localeCompare(b.fullName); + }); + return list; +} + export default function Repos() { const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); const [filter, setFilter] = useState(''); const [platformFilter, setPlatformFilter] = useState('all'); const [connectedPlatforms, setConnectedPlatforms] = useState>(new Set()); useEffect(() => { - async function load() { - const [accounts, watched] = await Promise.all([getAccounts(), getWatchedRepos()]); - const watchedMap = new Map(watched.map((r) => [`${r.platform}:${r.fullName}`, r])); + let cancelled = false; - const allRepos: WatchedRepo[] = []; - - for (const account of accounts) { - try { - if (account.platform === 'github') { - const ghRepos = await github.getUserRepos(account.token); - for (const r of ghRepos) { - const key = `github:${r.full_name}`; - const saved = watchedMap.get(key); - allRepos.push({ - platform: 'github', - fullName: r.full_name, - enabled: saved?.enabled ?? false, - pinned: saved?.pinned ?? false, - }); - } - } else if (account.platform === 'gitlab') { - const glRepos = await gitlab.getUserProjects(account.token); - for (const r of glRepos) { - const key = `gitlab:${r.path_with_namespace}`; - const saved = watchedMap.get(key); - allRepos.push({ - platform: 'gitlab', - fullName: r.path_with_namespace, - enabled: saved?.enabled ?? false, - pinned: saved?.pinned ?? false, - }); - } - } else if (account.platform === 'bitbucket') { - const bbRepos = await bitbucket.getUserRepositories(account.token); - for (const r of bbRepos) { - const key = `bitbucket:${r.full_name}`; - const saved = watchedMap.get(key); - allRepos.push({ - platform: 'bitbucket', - fullName: r.full_name, - enabled: saved?.enabled ?? false, - pinned: saved?.pinned ?? false, - }); - } - } - } catch (err) { - console.error(`Failed to fetch repos for ${account.platform}:`, err); - } + 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([ + getAccounts(), + getCachedAvailableRepos(), + getWatchedRepos(), + ]); + if (cancelled) return; + setConnectedPlatforms(new Set(accounts.map((a) => a.platform))); + if (cache) { + setRepos(buildRepoList(cache.repos, watched)); + setError(cache.error ?? null); + setLoading(false); } - // Sort: pinned+enabled first, then enabled, then disabled — alphabetical within each group - allRepos.sort((a, b) => { - const aRank = a.enabled && a.pinned ? 0 : a.enabled ? 1 : 2; - const bRank = b.enabled && b.pinned ? 0 : b.enabled ? 1 : 2; - if (aRank !== bRank) return aRank - bRank; - return a.fullName.localeCompare(b.fullName); - }); - - setRepos(allRepos); - setConnectedPlatforms(new Set(accounts.map((a) => a.platform))); + // Refresh via the service worker so the (slow) fetch survives the popup + // being closed — it keeps running in the background and writes to the + // cache, which we re-read once it resolves. See issue #23. + setRefreshing(true); + try { + await chrome.runtime.sendMessage({ type: 'FETCH_AVAILABLE_REPOS' }); + } catch { + // Service worker unreachable; fall back to whatever cache we rendered. + } + if (cancelled) return; + const [freshCache, freshWatched] = await Promise.all([ + getCachedAvailableRepos(), + getWatchedRepos(), + ]); + if (cancelled) return; + if (freshCache) { + setRepos(buildRepoList(freshCache.repos, freshWatched)); + setError(freshCache.error ?? null); + } setLoading(false); + setRefreshing(false); } - load(); + + run(); + return () => { + cancelled = true; + }; }, []); async function handleToggle(fullName: string, platform: string) { @@ -170,8 +174,11 @@ export default function Repos() { )}
-

+

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

{filtered.length > 0 && (