Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/desktop/src-tauri/src/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<reqwest::Client>> = OnceLock::new();

#[derive(Debug, Serialize, Deserialize)]
pub struct RegistryCatalog {
pub marketplace: Vec<RegistryEntry>,
Expand Down Expand Up @@ -112,6 +119,63 @@ pub async fn fetch_extension_manifest(
Ok(json)
}

#[tauri::command]
pub async fn fetch_extension_stars(repo_url: String) -> Result<Option<u32>, 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),
};
Comment thread
matiaspalmac marked this conversation as resolved.

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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/components/MarketplaceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ const DetailsView: React.FC<{

<div className="flex items-center gap-5 text-[13px] text-[#cccccc] mb-4">
<span className="text-[#3794ff] hover:underline cursor-pointer">{entry.manifest?.author || "Unknown"}</span>
<span className="flex items-center gap-1.5" title={t('marketplace.github_stars')}><Star size={14} className="text-[#cccccc]" /> {t('marketplace.github_stars')}</span>
{entry.stars != null && (
<span className="flex items-center gap-1.5" title={t('marketplace.github_stars')}><Star size={14} className="text-[#cccccc]" /> {entry.stars.toLocaleString()}</span>
)}
</div>

<p className="text-[13px] text-[#cccccc] mb-5 font-medium">
Expand Down Expand Up @@ -315,7 +317,12 @@ const MarketplaceView: React.FC = () => {

<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-[11px] text-[#444]">
v{ext.manifest?.version || "1.0.0"}
<span>v{ext.manifest?.version || "1.0.0"}</span>
{ext.stars != null && (
<span className="flex items-center gap-1" title={t('marketplace.github_stars')}>
<Star size={10} /> {ext.stars.toLocaleString()}
</span>
)}
</div>
<div className="text-[11px] font-medium">
{installedIds.includes(ext.id) ? (
Expand Down
29 changes: 19 additions & 10 deletions apps/desktop/src/context/ExtensionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 || "" })
]);
Comment thread
matiaspalmac marked this conversation as resolved.

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 };
})
);

Expand Down
Loading