mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(apps): star-ranked catalog, star from Rowboat, delete local apps
- catalog is ranked by GitHub star count (per-repo lookup with a 10-min cache; unauthenticated works, the publish token is used when present) - star button on each catalog card stars/unstars the app's repo as the signed-in user (public_repo scope covers it), optimistic with revert and a sign-in hint when signed out - the info panel now offers Delete for local apps (apps:delete already existed and cleans up app-owned agents; only installed apps had a removal button before)
This commit is contained in:
parent
26e2fde0a8
commit
fd91f4ea22
5 changed files with 165 additions and 3 deletions
|
|
@ -67,6 +67,7 @@ import * as appsServer from '@x/core/dist/apps/server.js';
|
||||||
import * as appsAgents from '@x/core/dist/apps/agents.js';
|
import * as appsAgents from '@x/core/dist/apps/agents.js';
|
||||||
import { capture } from '@x/core/dist/analytics/posthog.js';
|
import { capture } from '@x/core/dist/analytics/posthog.js';
|
||||||
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
|
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
|
||||||
|
import * as appsStars from '@x/core/dist/apps/stars.js';
|
||||||
import * as appsInstaller from '@x/core/dist/apps/installer.js';
|
import * as appsInstaller from '@x/core/dist/apps/installer.js';
|
||||||
import { registryClient } from '@x/core/dist/apps/registry.js';
|
import { registryClient } from '@x/core/dist/apps/registry.js';
|
||||||
import * as appsPublisher from '@x/core/dist/apps/publisher.js';
|
import * as appsPublisher from '@x/core/dist/apps/publisher.js';
|
||||||
|
|
@ -1616,6 +1617,18 @@ export function setupIpcHandlers() {
|
||||||
'apps:catalogSearch': async (_event, args) => {
|
'apps:catalogSearch': async (_event, args) => {
|
||||||
return { records: await registryClient.search(args.query) };
|
return { records: await registryClient.search(args.query) };
|
||||||
},
|
},
|
||||||
|
'apps:catalogStars': async (_event, args) => {
|
||||||
|
const [stars, starred] = await Promise.all([
|
||||||
|
appsStars.repoStars(args.repos),
|
||||||
|
appsStars.starredStatus(args.repos),
|
||||||
|
]);
|
||||||
|
return { stars, starred };
|
||||||
|
},
|
||||||
|
'apps:star': async (_event, args) => {
|
||||||
|
const result = await appsStars.setStar(args.repo, args.star);
|
||||||
|
capture('app_starred', { repo: args.repo, star: args.star });
|
||||||
|
return result;
|
||||||
|
},
|
||||||
'apps:catalogDetail': async (_event, args) => {
|
'apps:catalogDetail': async (_event, args) => {
|
||||||
const record = await registryClient.resolve(args.name);
|
const record = await registryClient.resolve(args.name);
|
||||||
if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
|
if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,15 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
||||||
onClose()
|
onClose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Local apps aren't "installed", so they get delete instead of uninstall.
|
||||||
|
const doDelete = () => runAction('delete', async () => {
|
||||||
|
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
|
||||||
|
const publishNote = app?.publish ? '\n\nThe published copy (GitHub repo + catalog listing) is not touched.' : ''
|
||||||
|
if (!window.confirm(`Delete this app? The whole folder, including data/, is removed from this machine.${publishNote}${agentNote}`)) return
|
||||||
|
await window.ipc.invoke('apps:delete', { folder })
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
|
@ -217,11 +226,16 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
||||||
<UploadCloud className="size-3.5" /> {app.publish ? 'Publish update' : 'Publish'}
|
<UploadCloud className="size-3.5" /> {app.publish ? 'Publish update' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{app.kind === 'installed' && (
|
{app.kind === 'installed' ? (
|
||||||
<button type="button" disabled={busyAction !== null} onClick={() => void doUninstall()}
|
<button type="button" disabled={busyAction !== null} onClick={() => void doUninstall()}
|
||||||
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
|
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
|
||||||
<Trash2 className="size-3.5" /> Uninstall
|
<Trash2 className="size-3.5" /> Uninstall
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={busyAction !== null} onClick={() => void doDelete()}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
|
||||||
|
<Trash2 className="size-3.5" /> Delete
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react'
|
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react'
|
||||||
import type { rowboatApp } from '@x/shared'
|
import type { rowboatApp } from '@x/shared'
|
||||||
|
|
||||||
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
||||||
|
|
@ -162,6 +162,35 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
||||||
const [enabling, setEnabling] = useState(false)
|
const [enabling, setEnabling] = useState(false)
|
||||||
// Registry name → local folder, for apps already installed from the catalog.
|
// Registry name → local folder, for apps already installed from the catalog.
|
||||||
const [installedByName, setInstalledByName] = useState<Map<string, string>>(new Map())
|
const [installedByName, setInstalledByName] = useState<Map<string, string>>(new Map())
|
||||||
|
// GitHub star counts rank the list; `starred` is the signed-in user's set.
|
||||||
|
const [stars, setStars] = useState<Record<string, number>>({})
|
||||||
|
const [starred, setStarred] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const loadStars = async (recs: rowboatApp.RegistryRecord[]) => {
|
||||||
|
if (recs.length === 0) return
|
||||||
|
try {
|
||||||
|
const r = await window.ipc.invoke('apps:catalogStars', { repos: recs.map((x) => x.repo) })
|
||||||
|
setStars((prev) => ({ ...prev, ...r.stars }))
|
||||||
|
setStarred((prev) => ({ ...prev, ...r.starred }))
|
||||||
|
} catch { /* unranked list is fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStar = async (repo: string) => {
|
||||||
|
const next = !starred[repo]
|
||||||
|
// Optimistic; revert on failure.
|
||||||
|
setStarred((prev) => ({ ...prev, [repo]: next }))
|
||||||
|
setStars((prev) => ({ ...prev, [repo]: Math.max(0, (prev[repo] ?? 0) + (next ? 1 : -1)) }))
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('apps:star', { repo, star: next })
|
||||||
|
} catch (e) {
|
||||||
|
setStarred((prev) => ({ ...prev, [repo]: !next }))
|
||||||
|
setStars((prev) => ({ ...prev, [repo]: Math.max(0, (prev[repo] ?? 0) + (next ? -1 : 1)) }))
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
setError(msg.includes('not_signed_in')
|
||||||
|
? 'Starring uses your GitHub account — sign in once via any app’s Publish flow, then try again.'
|
||||||
|
: msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadInstalled = async () => {
|
const loadInstalled = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -179,6 +208,7 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
||||||
const r = await window.ipc.invoke('apps:catalogIndex', { force })
|
const r = await window.ipc.invoke('apps:catalogIndex', { force })
|
||||||
setRecords(r.records)
|
setRecords(r.records)
|
||||||
setStale(r.stale)
|
setStale(r.stale)
|
||||||
|
void loadStars(r.records)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
}
|
}
|
||||||
|
|
@ -192,9 +222,14 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
||||||
? await window.ipc.invoke('apps:catalogSearch', { query: q })
|
? await window.ipc.invoke('apps:catalogSearch', { query: q })
|
||||||
: await window.ipc.invoke('apps:catalogIndex', {})
|
: await window.ipc.invoke('apps:catalogIndex', {})
|
||||||
setRecords(r.records)
|
setRecords(r.records)
|
||||||
|
void loadStars(r.records)
|
||||||
} catch { /* keep current list */ }
|
} catch { /* keep current list */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rank by stars (unknown counts sink), name as the stable tiebreak.
|
||||||
|
const ranked = [...records].sort((a, b) =>
|
||||||
|
((stars[b.repo] ?? -1) - (stars[a.repo] ?? -1)) || a.name.localeCompare(b.name))
|
||||||
|
|
||||||
const startInstall = async (name: string) => {
|
const startInstall = async (name: string) => {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -304,7 +339,7 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{records.map((r) => (
|
{ranked.map((r) => (
|
||||||
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
|
|
@ -313,6 +348,13 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
title={starred[r.repo] ? 'Unstar on GitHub' : 'Star on GitHub'}
|
||||||
|
onClick={() => void toggleStar(r.repo)}
|
||||||
|
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium hover:bg-accent ${starred[r.repo] ? 'text-amber-500' : 'text-muted-foreground'}`}>
|
||||||
|
<Star className={`size-3.5 ${starred[r.repo] ? 'fill-current' : ''}`} />
|
||||||
|
{stars[r.repo] ?? '—'}
|
||||||
|
</button>
|
||||||
{installedByName.has(r.name) ? (
|
{installedByName.has(r.name) ? (
|
||||||
<button type="button" title="Installed — open it"
|
<button type="button" title="Installed — open it"
|
||||||
onClick={() => onInstalled(installedByName.get(r.name)!)}
|
onClick={() => onInstalled(installedByName.get(r.name)!)}
|
||||||
|
|
|
||||||
81
apps/x/packages/core/src/apps/stars.ts
Normal file
81
apps/x/packages/core/src/apps/stars.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { getGithubToken } from './github-auth.js';
|
||||||
|
|
||||||
|
// GitHub stars for catalog apps. Star counts rank the catalog; starring an
|
||||||
|
// app stars its repo (the device-flow token's public_repo scope covers it).
|
||||||
|
// Counts are fetched per-repo with a short cache — unauthenticated GitHub API
|
||||||
|
// allows 60 req/h, so the cache is what keeps a busy catalog usable when the
|
||||||
|
// user never signed in.
|
||||||
|
|
||||||
|
const COUNT_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const countCache = new Map<string, { stars: number; at: number }>();
|
||||||
|
|
||||||
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||||
|
|
||||||
|
async function ghHeaders(): Promise<Record<string, string>> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Accept': 'application/vnd.github+json',
|
||||||
|
'User-Agent': 'rowboat-apps',
|
||||||
|
};
|
||||||
|
const auth = await getGithubToken();
|
||||||
|
if (auth) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Star counts for a set of "owner/repo" strings. Unknown/unreachable repos are omitted. */
|
||||||
|
export async function repoStars(repos: string[]): Promise<Record<string, number>> {
|
||||||
|
const headers = await ghHeaders();
|
||||||
|
const now = Date.now();
|
||||||
|
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) {
|
||||||
|
out[repo] = cached.stars;
|
||||||
|
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
|
||||||
|
const body = await res.json() as { stargazers_count?: number };
|
||||||
|
if (typeof body.stargazers_count === 'number') {
|
||||||
|
countCache.set(repo, { stars: body.stargazers_count, at: now });
|
||||||
|
out[repo] = body.stargazers_count;
|
||||||
|
}
|
||||||
|
} catch { /* offline — no count */ }
|
||||||
|
}));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {};
|
||||||
|
const headers = await ghHeaders();
|
||||||
|
const out: Record<string, boolean> = {};
|
||||||
|
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
// other statuses (401 revoked token, 403 rate limit) → unknown, omit
|
||||||
|
} catch { /* offline — unknown */ }
|
||||||
|
}));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Star/unstar a repo as the signed-in user. Throws not_signed_in without a token. */
|
||||||
|
export async function setStar(repo: string, star: boolean): Promise<{ starred: boolean }> {
|
||||||
|
if (!REPO_RE.test(repo)) throw new Error(`invalid repo: ${repo}`);
|
||||||
|
const auth = await getGithubToken();
|
||||||
|
if (!auth) throw new Error('not_signed_in: sign in with GitHub (used for publishing) to star apps');
|
||||||
|
const res = await fetch(`https://api.github.com/user/starred/${repo}`, {
|
||||||
|
method: star ? 'PUT' : 'DELETE',
|
||||||
|
headers: { ...(await ghHeaders()), 'Content-Length': '0' },
|
||||||
|
});
|
||||||
|
if (res.status !== 204) {
|
||||||
|
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.
|
||||||
|
const cached = countCache.get(repo);
|
||||||
|
if (cached) countCache.set(repo, { stars: Math.max(0, cached.stars + (star ? 1 : -1)), at: cached.at });
|
||||||
|
return { starred: star };
|
||||||
|
}
|
||||||
|
|
@ -1272,6 +1272,18 @@ const ipcSchemas = {
|
||||||
req: z.object({ query: z.string() }),
|
req: z.object({ query: z.string() }),
|
||||||
res: z.object({ records: z.array(RegistryRecordSchema) }),
|
res: z.object({ records: z.array(RegistryRecordSchema) }),
|
||||||
},
|
},
|
||||||
|
// GitHub star counts (catalog ranking) + the signed-in user's starred set.
|
||||||
|
'apps:catalogStars': {
|
||||||
|
req: z.object({ repos: z.array(z.string()) }),
|
||||||
|
res: z.object({
|
||||||
|
stars: z.record(z.string(), z.number()),
|
||||||
|
starred: z.record(z.string(), z.boolean()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'apps:star': {
|
||||||
|
req: z.object({ repo: z.string(), star: z.boolean() }),
|
||||||
|
res: z.object({ starred: z.boolean() }),
|
||||||
|
},
|
||||||
'apps:catalogDetail': {
|
'apps:catalogDetail': {
|
||||||
req: z.object({ name: z.string() }),
|
req: z.object({ name: z.string() }),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue