mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-27 21:51:09 +02:00
feat(x): apps catalog cards, sidebar app pins, empty-state catalog landing (#759)
Apps section improvements: - Landing with no apps of your own now falls through to the Catalog tab with a banner pointing at installing from the catalog or building your own (links into the copilot app-builder flow). - Catalog renders as a card grid matching the My Apps visual language (shared card-theme helpers) instead of one-line rows, with star button, INSTALLED badge, and Install/Open actions on each card. - Apps can be pinned to the nav sidebar: right-click an app card to add/remove; pinned rows sit under the Apps nav item, click opens the app, right-click removes. Persisted in localStorage (x:pinned-apps). - My Apps shows a hint that right-click pins an app to the sidebar. - Uninstall/delete unpins the app, and the Apps view prunes pins for apps that no longer exist (only off an authoritative server list). GitHub stars rate-limit backoff (packages/core/src/apps/stars.ts): once GitHub reports the budget spent (403/429 + x-ratelimit-remaining: 0), gate all star fetches until the advertised reset, serving stale cached counts instead of burning requests on guaranteed 403s. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
70ddf19489
commit
826bce90ad
8 changed files with 304 additions and 89 deletions
|
|
@ -11,6 +11,28 @@ const countCache = new Map<string, { stars: number; at: number }>();
|
|||
|
||||
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
// Once GitHub says the hourly budget is spent (403/429 with
|
||||
// x-ratelimit-remaining: 0), every further request until the reset is a
|
||||
// guaranteed 403 — and the catalog re-requests counts on load and on every
|
||||
// search keystroke. Gate all fetches until the advertised reset instead of
|
||||
// burning requests on failures.
|
||||
let rateLimitedUntilMs = 0;
|
||||
|
||||
const isRateLimited = () => Date.now() < rateLimitedUntilMs;
|
||||
|
||||
function noteRateLimit(res: Response): void {
|
||||
if (res.status !== 403 && res.status !== 429) return;
|
||||
const remaining = res.headers.get('x-ratelimit-remaining');
|
||||
const resetSec = Number(res.headers.get('x-ratelimit-reset'));
|
||||
if (remaining === '0' && Number.isFinite(resetSec) && resetSec > 0) {
|
||||
rateLimitedUntilMs = Math.max(rateLimitedUntilMs, resetSec * 1000);
|
||||
} else {
|
||||
// Secondary rate limit / abuse detection — headers don't say when it
|
||||
// ends, so back off a few minutes.
|
||||
rateLimitedUntilMs = Math.max(rateLimitedUntilMs, Date.now() + 5 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function ghHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
|
|
@ -25,16 +47,19 @@ async function ghHeaders(): Promise<Record<string, string>> {
|
|||
export async function repoStars(repos: string[]): Promise<Record<string, number>> {
|
||||
const headers = await ghHeaders();
|
||||
const now = Date.now();
|
||||
const limited = isRateLimited();
|
||||
const out: Record<string, number> = {};
|
||||
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
||||
const cached = countCache.get(repo);
|
||||
if (cached && now - cached.at < COUNT_TTL_MS) {
|
||||
// While rate-limited, a stale count beats a guaranteed 403.
|
||||
if (cached && (limited || now - cached.at < COUNT_TTL_MS)) {
|
||||
out[repo] = cached.stars;
|
||||
return;
|
||||
}
|
||||
if (limited) return;
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}`, { headers });
|
||||
if (!res.ok) return; // deleted repo / rate limited — no count is fine
|
||||
if (!res.ok) { noteRateLimit(res); return; } // deleted repo / rate limited — no count is fine
|
||||
const body = await res.json() as { stargazers_count?: number };
|
||||
if (typeof body.stargazers_count === 'number') {
|
||||
countCache.set(repo, { stars: body.stargazers_count, at: now });
|
||||
|
|
@ -48,7 +73,7 @@ export async function repoStars(repos: string[]): Promise<Record<string, number>
|
|||
/** Which of these repos the signed-in user has starred. Empty when signed out. */
|
||||
export async function starredStatus(repos: string[]): Promise<Record<string, boolean>> {
|
||||
const auth = await getGithubToken();
|
||||
if (!auth) return {};
|
||||
if (!auth || isRateLimited()) return {};
|
||||
const headers = await ghHeaders();
|
||||
const out: Record<string, boolean> = {};
|
||||
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
||||
|
|
@ -56,6 +81,7 @@ export async function starredStatus(repos: string[]): Promise<Record<string, boo
|
|||
const res = await fetch(`https://api.github.com/user/starred/${repo}`, { headers });
|
||||
if (res.status === 204) out[repo] = true;
|
||||
else if (res.status === 404) out[repo] = false;
|
||||
else noteRateLimit(res);
|
||||
// other statuses (401 revoked token, 403 rate limit) → unknown, omit
|
||||
} catch { /* offline — unknown */ }
|
||||
}));
|
||||
|
|
@ -72,6 +98,7 @@ export async function setStar(repo: string, star: boolean): Promise<{ starred: b
|
|||
headers: { ...(await ghHeaders()), 'Content-Length': '0' },
|
||||
});
|
||||
if (res.status !== 204) {
|
||||
noteRateLimit(res);
|
||||
throw new Error(`star_failed: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
|
||||
}
|
||||
// Nudge the cached count so the UI reflects the action before the TTL expires.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue