diff --git a/apps/desktop/src-tauri/src/extensions.rs b/apps/desktop/src-tauri/src/extensions.rs index 75527c1d..3815814b 100644 --- a/apps/desktop/src-tauri/src/extensions.rs +++ b/apps/desktop/src-tauri/src/extensions.rs @@ -2,8 +2,15 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::PathBuf; use std::process::Command; +use std::sync::OnceLock; +use std::time::Duration; use tauri::{AppHandle, Manager}; +// Shared reqwest client for GitHub API calls. Built once on first use so that +// connection pooling and keep-alive work across the many per-entry calls that +// `fetch_extension_stars` makes during catalog refresh. +static GITHUB_CLIENT: OnceLock> = OnceLock::new(); + #[derive(Debug, Serialize, Deserialize)] pub struct RegistryCatalog { pub marketplace: Vec, @@ -112,6 +119,63 @@ pub async fn fetch_extension_manifest( Ok(json) } +#[tauri::command] +pub async fn fetch_extension_stars(repo_url: String) -> Result, String> { + // Parse owner/repo from a GitHub HTTPS URL. Anything else gracefully returns None. + let cleaned = repo_url.trim_end_matches(".git"); + let prefix = "https://github.com/"; + if !cleaned.starts_with(prefix) { + return Ok(None); + } + let rest = &cleaned[prefix.len()..]; + let mut parts = rest.splitn(3, '/'); + let owner = match parts.next().filter(|s| !s.is_empty()) { + Some(o) => o, + None => return Ok(None), + }; + let repo = match parts.next().filter(|s| !s.is_empty()) { + Some(r) => r, + None => return Ok(None), + }; + + let api_url = format!("https://api.github.com/repos/{}/{}", owner, repo); + + // GitHub API requires a User-Agent header; any failure (network, 403 rate limit, + // 404, parse, timeout) silently degrades to None so the UI falls back to no-stars display. + let client = match GITHUB_CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .user_agent("TrixtyIDE") + .timeout(Duration::from_secs(10)) + .build() + .ok() + }) + .as_ref() + { + Some(c) => c, + None => return Ok(None), + }; + + let response = match client.get(&api_url).send().await { + Ok(r) => r, + Err(_) => return Ok(None), + }; + + if !response.status().is_success() { + return Ok(None); + } + + let body: serde_json::Value = match response.json().await { + Ok(b) => b, + Err(_) => return Ok(None), + }; + + Ok(body + .get("stargazers_count") + .and_then(|v| v.as_u64()) + .and_then(|n| u32::try_from(n).ok())) +} + #[tauri::command] pub async fn fetch_extension_file( repo_url: String, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d882905f..5cbb2cc2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -717,6 +717,7 @@ pub fn run() { get_registry_catalog, fetch_extension_manifest, fetch_extension_file, + fetch_extension_stars, install_extension, uninstall_extension, update_extension, diff --git a/apps/desktop/src/api/tauri.ts b/apps/desktop/src/api/tauri.ts index c24afe88..cab5cf7c 100644 --- a/apps/desktop/src/api/tauri.ts +++ b/apps/desktop/src/api/tauri.ts @@ -31,6 +31,7 @@ export interface TauriInvokeMap { "toggle_extension_state": { args: { id: string; isActive: boolean }; return: void }; "fetch_extension_manifest": { args: { repoUrl: string; branch: string; dataUrl?: string; path?: string }; return: ExtensionManifest }; "fetch_extension_file": { args: { repoUrl: string; branch: string; path?: string; fileName: string }; return: string }; + "fetch_extension_stars": { args: { repoUrl: string }; return: number | null }; "read_file": { args: { path: string }; return: string }; "write_file": { args: { path: string; content: string }; return: void }; "read_directory": { args: { path: string }; return: DirEntry[] }; diff --git a/apps/desktop/src/components/MarketplaceView.tsx b/apps/desktop/src/components/MarketplaceView.tsx index 4eb0b627..62d5de92 100644 --- a/apps/desktop/src/components/MarketplaceView.tsx +++ b/apps/desktop/src/components/MarketplaceView.tsx @@ -133,7 +133,9 @@ const DetailsView: React.FC<{
{entry.manifest?.author || "Unknown"} - {t('marketplace.github_stars')} + {entry.stars != null && ( + {entry.stars.toLocaleString()} + )}

@@ -315,7 +317,12 @@ const MarketplaceView: React.FC = () => {

- v{ext.manifest?.version || "1.0.0"} + v{ext.manifest?.version || "1.0.0"} + {ext.stars != null && ( + + {ext.stars.toLocaleString()} + + )}
{installedIds.includes(ext.id) ? ( diff --git a/apps/desktop/src/context/ExtensionContext.tsx b/apps/desktop/src/context/ExtensionContext.tsx index d7f1f086..90757248 100644 --- a/apps/desktop/src/context/ExtensionContext.tsx +++ b/apps/desktop/src/context/ExtensionContext.tsx @@ -32,6 +32,7 @@ export interface MarketplaceEntry { path?: string; manifest?: ExtensionManifest; readme?: string; // Loaded dynamically + stars?: number; // GitHub stargazers_count, resolved at catalog load time } export interface ExtensionState { @@ -80,23 +81,31 @@ export const ExtensionProvider: React.FC<{ children: React.ReactNode }> = ({ chi const catalogData = await invoke("get_registry_catalog", { url: targetUrl }); const entries: MarketplaceEntry[] = catalogData.marketplace || []; - // 2. Fetch the metadata (package.json) for each to get the names, authors... - // Doing this concurrently using Promise.all + // 2. Fetch the manifest (package.json) and GitHub stars for each entry. + // Both requests run concurrently per entry, and allSettled is used so one + // failure (e.g. GitHub rate limit on stars) doesn't drop the other data. const enrichedEntries = await Promise.all( entries.map(async (entry) => { - try { - const manifest = await invoke("fetch_extension_manifest", { + const [manifestResult, starsResult] = await Promise.allSettled([ + invoke("fetch_extension_manifest", { repoUrl: entry.repository || "", branch: entry.branch || "main", dataUrl: entry.data, path: entry.path - }); - return { ...entry, manifest }; - } catch (e) { - console.error(`Error fetching manifest for ${entry.id}`, e); - // Return entry without manifest, UI will show generic fallback - return entry; + }), + invoke("fetch_extension_stars", { repoUrl: entry.repository || "" }) + ]); + + const manifest = manifestResult.status === "fulfilled" ? manifestResult.value : undefined; + const stars = starsResult.status === "fulfilled" && starsResult.value != null + ? starsResult.value + : undefined; + + if (manifestResult.status === "rejected") { + console.error(`Error fetching manifest for ${entry.id}`, manifestResult.reason); } + + return { ...entry, manifest, stars }; }) );