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 && (