diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml index d3a0b0b..805cf82 100644 --- a/data/app.driftwood.Driftwood.gschema.xml +++ b/data/app.driftwood.Driftwood.gschema.xml @@ -142,5 +142,15 @@ Watch removable media Scan removable drives for AppImages when mounted. + + '' + GitHub personal access token + Optional GitHub token for higher API rate limits (5,000 vs 60 requests per hour). + + + true + Auto-enrich catalog apps + Automatically fetch GitHub metadata (stars, version, downloads) for catalog apps in the background. + diff --git a/data/resources/style.css b/data/resources/style.css index 8f9d95d..45f64c5 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -175,6 +175,53 @@ button.flat:not(.pill):not(.suggested-action):not(.destructive-action) { min-height: 24px; } +/* ===== Catalog Tile Cards ===== */ +.catalog-tile { + border: 1px solid alpha(@window_fg_color, 0.12); + border-radius: 12px; +} + +.catalog-tile:hover { + border-color: alpha(@accent_bg_color, 0.5); +} + +/* ===== Featured Banner Cards ===== */ +.catalog-featured-card { + border-radius: 12px; + border: 1px solid alpha(@window_fg_color, 0.15); + padding: 0; +} + +.catalog-featured-card:hover { + border-color: alpha(@accent_bg_color, 0.5); +} + +/* Screenshot area inside featured card */ +.catalog-featured-screenshot { + border-radius: 11px 11px 0 0; + border: none; + background: alpha(@window_fg_color, 0.04); +} + +.catalog-featured-screenshot picture { + border-radius: 11px 11px 0 0; +} + +/* ===== Destructive Context Menu Item ===== */ +.destructive-context-item { + color: @error_fg_color; + background: alpha(@error_bg_color, 0.85); + border: none; + box-shadow: none; + padding: 6px 12px; + border-radius: 6px; + min-height: 28px; +} + +.destructive-context-item:hover { + background: @error_bg_color; +} + /* ===== Screenshot Lightbox ===== */ window.lightbox { background-color: rgba(0, 0, 0, 0.92); @@ -193,3 +240,75 @@ window.lightbox .lightbox-nav { min-width: 48px; min-height: 48px; } + +/* ===== Catalog Tile Stats Row ===== */ +.catalog-stats-row { + font-size: 0.8em; + color: alpha(@window_fg_color, 0.7); +} + +.catalog-stats-row image { + opacity: 0.65; +} + +/* ===== Detail Page Stat Cards ===== */ +.stat-card { + background: alpha(@window_fg_color, 0.06); + border-radius: 12px; + padding: 14px 16px; + border: 1px solid alpha(@window_fg_color, 0.08); +} + +.stat-card.stat-stars { + background: alpha(@warning_bg_color, 0.12); + border-color: alpha(@warning_bg_color, 0.2); +} + +.stat-card.stat-stars image { + color: @warning_bg_color; + opacity: 0.85; +} + +.stat-card.stat-version { + background: alpha(@accent_bg_color, 0.1); + border-color: alpha(@accent_bg_color, 0.18); +} + +.stat-card.stat-version image { + color: @accent_bg_color; + opacity: 0.85; +} + +.stat-card.stat-downloads { + background: alpha(@success_bg_color, 0.1); + border-color: alpha(@success_bg_color, 0.18); +} + +.stat-card.stat-downloads image { + color: @success_bg_color; + opacity: 0.85; +} + +.stat-card.stat-released { + background: alpha(@purple_3, 0.12); + border-color: alpha(@purple_3, 0.2); +} + +.stat-card.stat-released image { + color: @purple_3; + opacity: 0.85; +} + +.stat-card .stat-value { + font-weight: 700; + font-size: 1.15em; +} + +.stat-card .stat-label { + font-size: 0.8em; + color: alpha(@window_fg_color, 0.6); +} + +.stat-card image { + opacity: 0.55; +} diff --git a/src/core/catalog.rs b/src/core/catalog.rs index ea2d9c4..822f95a 100644 --- a/src/core/catalog.rs +++ b/src/core/catalog.rs @@ -3,6 +3,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use super::database::Database; +use super::github_enrichment; /// A catalog source that can be synced to discover available AppImages. #[derive(Debug, Clone)] @@ -53,27 +54,71 @@ pub struct CatalogApp { pub homepage: Option, pub file_size: Option, pub architecture: Option, + pub screenshots: Vec, + pub license: Option, + /// GitHub link URL from the feed (e.g. "https://github.com/user/repo") + pub github_link: Option, } /// Default AppImageHub registry URL. const APPIMAGEHUB_API_URL: &str = "https://appimage.github.io/feed.json"; /// Sync a catalog source - fetch the index and store entries in the database. +/// Progress updates sent during catalog sync. +#[derive(Debug, Clone)] +pub enum SyncProgress { + /// Fetching the feed from the remote source. + FetchingFeed, + /// Feed fetched, total number of apps found. + FeedFetched { total: u32 }, + /// Caching icon for an app. + CachingIcon { current: u32, total: u32, app_name: String }, + /// Saving apps to the database. + SavingApps { current: u32, total: u32 }, + /// Sync complete. + Done { total: u32 }, +} + pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result { + sync_catalog_with_progress(db, source, &|_| {}) +} + +pub fn sync_catalog_with_progress( + db: &Database, + source: &CatalogSource, + on_progress: &dyn Fn(SyncProgress), +) -> Result { + on_progress(SyncProgress::FetchingFeed); + let apps = match source.source_type { CatalogType::AppImageHub => fetch_appimage_hub()?, CatalogType::Custom => fetch_custom_catalog(&source.url)?, CatalogType::GitHubSearch => { - // GitHub search requires a token and is more complex - stub for now log::warn!("GitHub catalog search not yet implemented"); Vec::new() } }; + let total = apps.len() as u32; + on_progress(SyncProgress::FeedFetched { total }); + + // Cache icons with progress reporting + let icon_count = cache_catalog_icons_with_progress(&apps, on_progress); + log::info!("Cached {} catalog icons", icon_count); + let source_id = source.id.ok_or(CatalogError::NoSourceId)?; let mut count = 0u32; for app in &apps { + count += 1; + on_progress(SyncProgress::SavingApps { current: count, total }); + + let screenshots_str = if app.screenshots.is_empty() { + None + } else { + Some(app.screenshots.join(";")) + }; + db.insert_catalog_app( source_id, &app.name, @@ -85,12 +130,25 @@ pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result Result, CatalogError> { let apps: Vec = feed.items.into_iter().filter_map(|item| { // AppImageHub items need at least a name and a link let name = item.name?; - let download_url = item.links.unwrap_or_default().into_iter() + let links = item.links.unwrap_or_default(); + let download_url = links.iter() .find(|l| l.r#type == "Download") - .map(|l| l.url)?; + .map(|l| l.url.clone())?; + + // Extract GitHub link from feed links + let github_link = links.iter() + .find(|l| l.r#type.to_lowercase().contains("github")) + .map(|l| l.url.clone()); Some(CatalogApp { name, @@ -172,6 +236,9 @@ fn fetch_appimage_hub() -> Result, CatalogError> { }), file_size: None, architecture: None, + screenshots: item.screenshots.unwrap_or_default().into_iter().flatten().collect(), + license: item.license, + github_link, }) }).collect(); @@ -200,6 +267,9 @@ fn fetch_custom_catalog(url: &str) -> Result, CatalogError> { homepage: item.homepage, file_size: item.file_size, architecture: item.architecture, + screenshots: Vec::new(), + license: None, + github_link: None, }).collect()) } @@ -226,6 +296,126 @@ pub fn get_sources(db: &Database) -> Vec { }).collect() } +/// Base URL for AppImageHub database assets (icons, screenshots). +pub const APPIMAGEHUB_DATABASE_URL: &str = "https://appimage.github.io/database/"; + +/// Get the icon cache directory, creating it if needed. +pub fn icon_cache_dir() -> PathBuf { + let dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("driftwood") + .join("icons"); + fs::create_dir_all(&dir).ok(); + dir +} + +/// Get the screenshot cache directory, creating it if needed. +pub fn screenshot_cache_dir() -> PathBuf { + let dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("driftwood") + .join("screenshots"); + fs::create_dir_all(&dir).ok(); + dir +} + +/// Resolve an asset path to a full URL (handles relative paths from AppImageHub). +fn resolve_asset_url(path: &str) -> String { + if path.starts_with("http://") || path.starts_with("https://") { + path.to_string() + } else { + format!("{}{}", APPIMAGEHUB_DATABASE_URL, path) + } +} + +/// Download a file from a URL to a local path. +fn download_file(url: &str, dest: &Path) -> Result<(), CatalogError> { + let response = ureq::get(url) + .call() + .map_err(|e| CatalogError::Network(e.to_string()))?; + + let mut file = fs::File::create(dest) + .map_err(|e| CatalogError::Io(e.to_string()))?; + + let mut reader = response.into_body().into_reader(); + let mut buf = [0u8; 65536]; + loop { + let n = reader.read(&mut buf) + .map_err(|e| CatalogError::Network(e.to_string()))?; + if n == 0 { break; } + file.write_all(&buf[..n]) + .map_err(|e| CatalogError::Io(e.to_string()))?; + } + + Ok(()) +} + +/// Sanitize a name for use as a filename. +pub fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect() +} + +/// Download icons for all catalog apps that have icon_url set. +/// Saves to ~/.cache/driftwood/icons/{sanitized_name}.png +fn cache_catalog_icons(apps: &[CatalogApp]) -> u32 { + cache_catalog_icons_with_progress(apps, &|_| {}) +} + +fn cache_catalog_icons_with_progress(apps: &[CatalogApp], on_progress: &dyn Fn(SyncProgress)) -> u32 { + let cache_dir = icon_cache_dir(); + let mut count = 0u32; + let total = apps.len() as u32; + + for (i, app) in apps.iter().enumerate() { + on_progress(SyncProgress::CachingIcon { + current: i as u32 + 1, + total, + app_name: app.name.clone(), + }); + + if let Some(ref icon_url) = app.icon_url { + let sanitized = sanitize_filename(&app.name); + let dest = cache_dir.join(format!("{}.png", sanitized)); + + // Skip if already cached + if dest.exists() { + count += 1; + continue; + } + + let url = resolve_asset_url(icon_url); + match download_file(&url, &dest) { + Ok(_) => { + count += 1; + log::debug!("Cached icon for {}", app.name); + } + Err(e) => { + log::debug!("Failed to cache icon for {}: {}", app.name, e); + } + } + } + } + + count +} + +/// Download a screenshot to the cache. Returns the local path on success. +pub fn cache_screenshot(app_name: &str, screenshot_path: &str, index: usize) -> Result { + let cache_dir = screenshot_cache_dir(); + let sanitized = sanitize_filename(app_name); + let dest = cache_dir.join(format!("{}_{}.png", sanitized, index)); + + if dest.exists() { + return Ok(dest); + } + + let url = resolve_asset_url(screenshot_path); + download_file(&url, &dest)?; + Ok(dest) +} + // --- AppImageHub feed format --- #[derive(Debug, serde::Deserialize)] @@ -241,6 +431,8 @@ struct AppImageHubItem { authors: Option>, links: Option>, icons: Option>>, + screenshots: Option>>, + license: Option, } #[derive(Debug, serde::Deserialize)] diff --git a/src/core/database.rs b/src/core/database.rs index be5b78c..1c74fff 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -98,6 +98,16 @@ pub struct CatalogApp { pub icon_url: Option, pub homepage: Option, pub license: Option, + pub screenshots: Option, + pub github_owner: Option, + pub github_repo: Option, + pub github_stars: Option, + pub github_downloads: Option, + pub latest_version: Option, + pub release_date: Option, + pub github_enriched_at: Option, + pub github_download_url: Option, + pub github_release_assets: Option, } #[derive(Debug, Clone)] @@ -400,6 +410,22 @@ impl Database { self.migrate_to_v11()?; } + if current_version < 12 { + self.migrate_to_v12()?; + } + + if current_version < 13 { + self.migrate_to_v13()?; + } + + if current_version < 14 { + self.migrate_to_v14()?; + } + + if current_version < 15 { + self.migrate_to_v15()?; + } + // Ensure all expected columns exist (repairs DBs where a migration // was updated after it had already run on this database) self.ensure_columns()?; @@ -838,6 +864,72 @@ impl Database { Ok(()) } + fn migrate_to_v12(&self) -> SqlResult<()> { + let new_columns = [ + "screenshots TEXT", + "license TEXT", + ]; + for col in &new_columns { + let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col); + self.conn.execute(&sql, []).ok(); + } + self.conn.execute( + "UPDATE schema_version SET version = ?1", + params![12], + )?; + Ok(()) + } + + fn migrate_to_v13(&self) -> SqlResult<()> { + // Remove duplicate catalog_apps entries, keeping the row with the highest id + // (most recent insert) per (source_id, name) pair + self.conn.execute_batch( + "DELETE FROM catalog_apps WHERE id NOT IN ( + SELECT MAX(id) FROM catalog_apps GROUP BY source_id, name + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_apps_source_name + ON catalog_apps(source_id, name); + UPDATE schema_version SET version = 13;" + )?; + Ok(()) + } + + fn migrate_to_v14(&self) -> SqlResult<()> { + let new_columns = [ + "github_owner TEXT", + "github_repo TEXT", + "github_stars INTEGER", + "github_downloads INTEGER", + "release_date TEXT", + "github_enriched_at TEXT", + ]; + for col in &new_columns { + let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col); + self.conn.execute(&sql, []).ok(); + } + self.conn.execute( + "UPDATE schema_version SET version = ?1", + params![14], + )?; + Ok(()) + } + + fn migrate_to_v15(&self) -> SqlResult<()> { + let new_columns = [ + "github_download_url TEXT", + "github_release_assets TEXT", + ]; + for col in &new_columns { + let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col); + self.conn.execute(&sql, []).ok(); + } + self.conn.execute( + "UPDATE schema_version SET version = ?1", + params![15], + )?; + Ok(()) + } + pub fn upsert_appimage( &self, path: &str, @@ -2069,7 +2161,8 @@ impl Database { limit: i32, ) -> SqlResult> { let mut sql = String::from( - "SELECT id, name, description, categories, download_url, icon_url, homepage, architecture + "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, + github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets FROM catalog_apps WHERE 1=1" ); let mut params_list: Vec> = Vec::new(); @@ -2101,6 +2194,16 @@ impl Database { icon_url: row.get(5)?, homepage: row.get(6)?, license: row.get(7)?, + screenshots: row.get(8)?, + github_owner: row.get(9)?, + github_repo: row.get(10)?, + github_stars: row.get(11)?, + github_downloads: row.get(12)?, + latest_version: row.get(13)?, + release_date: row.get(14)?, + github_enriched_at: row.get(15)?, + github_download_url: row.get(16)?, + github_release_assets: row.get(17)?, }) })?; @@ -2113,7 +2216,8 @@ impl Database { pub fn get_catalog_app(&self, id: i64) -> SqlResult> { let result = self.conn.query_row( - "SELECT id, name, description, categories, download_url, icon_url, homepage, architecture + "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, + github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets FROM catalog_apps WHERE id = ?1", params![id], |row| { @@ -2126,6 +2230,16 @@ impl Database { icon_url: row.get(5)?, homepage: row.get(6)?, license: row.get(7)?, + screenshots: row.get(8)?, + github_owner: row.get(9)?, + github_repo: row.get(10)?, + github_stars: row.get(11)?, + github_downloads: row.get(12)?, + latest_version: row.get(13)?, + release_date: row.get(14)?, + github_enriched_at: row.get(15)?, + github_download_url: row.get(16)?, + github_release_assets: row.get(17)?, }) }, ); @@ -2136,6 +2250,65 @@ impl Database { } } + /// Get featured catalog apps. Apps with GitHub stars sort first (by stars desc), + /// then unenriched apps get a deterministic shuffle that rotates every 15 minutes. + pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult> { + // Time seed rotates every 15 minutes (900 seconds) + let time_seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() / 900; + + let mut stmt = self.conn.prepare( + "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, + github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets + FROM catalog_apps + WHERE icon_url IS NOT NULL AND icon_url != '' + AND description IS NOT NULL AND description != '' + AND screenshots IS NOT NULL AND screenshots != ''" + )?; + let rows = stmt.query_map([], |row| { + Ok(CatalogApp { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + categories: row.get(3)?, + download_url: row.get(4)?, + icon_url: row.get(5)?, + homepage: row.get(6)?, + license: row.get(7)?, + screenshots: row.get(8)?, + github_owner: row.get(9)?, + github_repo: row.get(10)?, + github_stars: row.get(11)?, + github_downloads: row.get(12)?, + latest_version: row.get(13)?, + release_date: row.get(14)?, + github_enriched_at: row.get(15)?, + github_download_url: row.get(16)?, + github_release_assets: row.get(17)?, + }) + })?; + let mut apps: Vec = rows.collect::>>()?; + + // Enriched apps (with stars) sort first by stars descending, + // unenriched apps get the deterministic shuffle after them + apps.sort_by(|a, b| { + match (a.github_stars, b.github_stars) { + (Some(sa), Some(sb)) => sb.cmp(&sa), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => { + let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95); + let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95); + ha.cmp(&hb) + } + } + }); + apps.truncate(limit as usize); + Ok(apps) + } + pub fn get_catalog_categories(&self) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != ''" @@ -2172,12 +2345,26 @@ impl Database { homepage: Option<&str>, file_size: Option, architecture: Option<&str>, + screenshots: Option<&str>, + license: Option<&str>, ) -> SqlResult<()> { self.conn.execute( - "INSERT OR REPLACE INTO catalog_apps - (source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, cached_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))", - params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture], + "INSERT INTO catalog_apps + (source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, screenshots, license, cached_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, datetime('now')) + ON CONFLICT(source_id, name) DO UPDATE SET + description = COALESCE(excluded.description, description), + categories = COALESCE(excluded.categories, categories), + latest_version = COALESCE(excluded.latest_version, latest_version), + download_url = excluded.download_url, + icon_url = COALESCE(excluded.icon_url, icon_url), + homepage = COALESCE(excluded.homepage, homepage), + file_size = COALESCE(excluded.file_size, file_size), + architecture = COALESCE(excluded.architecture, architecture), + screenshots = COALESCE(excluded.screenshots, screenshots), + license = COALESCE(excluded.license, license), + cached_at = datetime('now')", + params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, screenshots, license], )?; Ok(()) } @@ -2232,6 +2419,125 @@ impl Database { })?; rows.collect() } + + pub fn get_catalog_app_by_source_and_name(&self, source_id: i64, name: &str) -> SqlResult> { + let result = self.conn.query_row( + "SELECT id FROM catalog_apps WHERE source_id = ?1 AND name = ?2", + params![source_id, name], + |row| row.get(0), + ); + match result { + Ok(id) => Ok(Some(id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + // --- GitHub enrichment methods --- + + pub fn update_catalog_app_github_repo( + &self, + app_id: i64, + owner: &str, + repo: &str, + ) -> SqlResult<()> { + self.conn.execute( + "UPDATE catalog_apps SET github_owner = ?2, github_repo = ?3 WHERE id = ?1", + params![app_id, owner, repo], + )?; + Ok(()) + } + + pub fn update_catalog_app_github_metadata( + &self, + app_id: i64, + stars: i64, + pushed_at: Option<&str>, + ) -> SqlResult<()> { + self.conn.execute( + "UPDATE catalog_apps SET github_stars = ?2, github_enriched_at = datetime('now') WHERE id = ?1", + params![app_id, stars], + )?; + // Store pushed_at in release_date if no release info yet + if let Some(pushed) = pushed_at { + self.conn.execute( + "UPDATE catalog_apps SET release_date = COALESCE(release_date, ?2) WHERE id = ?1", + params![app_id, pushed], + )?; + } + Ok(()) + } + + pub fn update_catalog_app_release_info( + &self, + app_id: i64, + version: Option<&str>, + date: Option<&str>, + downloads: Option, + github_download_url: Option<&str>, + github_release_assets: Option<&str>, + ) -> SqlResult<()> { + self.conn.execute( + "UPDATE catalog_apps SET + latest_version = COALESCE(?2, latest_version), + release_date = COALESCE(?3, release_date), + github_downloads = COALESCE(?4, github_downloads), + github_download_url = COALESCE(?5, github_download_url), + github_release_assets = COALESCE(?6, github_release_assets), + github_enriched_at = datetime('now') + WHERE id = ?1", + params![app_id, version, date, downloads, github_download_url, github_release_assets], + )?; + Ok(()) + } + + pub fn get_unenriched_catalog_apps(&self, limit: i32) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, + github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets + FROM catalog_apps + WHERE github_owner IS NOT NULL AND github_enriched_at IS NULL + ORDER BY id + LIMIT ?1" + )?; + let rows = stmt.query_map(params![limit], |row| { + Ok(CatalogApp { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + categories: row.get(3)?, + download_url: row.get(4)?, + icon_url: row.get(5)?, + homepage: row.get(6)?, + license: row.get(7)?, + screenshots: row.get(8)?, + github_owner: row.get(9)?, + github_repo: row.get(10)?, + github_stars: row.get(11)?, + github_downloads: row.get(12)?, + latest_version: row.get(13)?, + release_date: row.get(14)?, + github_enriched_at: row.get(15)?, + github_download_url: row.get(16)?, + github_release_assets: row.get(17)?, + }) + })?; + rows.collect() + } + + pub fn catalog_enrichment_progress(&self) -> SqlResult<(i64, i64)> { + let enriched: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL AND github_enriched_at IS NOT NULL", + [], + |row| row.get(0), + )?; + let total_with_github: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL", + [], + |row| row.get(0), + )?; + Ok((enriched, total_with_github)) + } } #[cfg(test)] @@ -2402,7 +2708,7 @@ mod tests { [], |row| row.get(0), ).unwrap(); - assert_eq!(version, 11); + assert_eq!(version, 15); // All tables that should exist after the full v1-v7 migration chain let expected_tables = [ diff --git a/src/core/github_enrichment.rs b/src/core/github_enrichment.rs new file mode 100644 index 0000000..9419de2 --- /dev/null +++ b/src/core/github_enrichment.rs @@ -0,0 +1,394 @@ +use super::database::Database; + +// --- API response structs --- + +#[derive(Debug, serde::Deserialize)] +pub struct GitHubRepoInfo { + pub stargazers_count: i64, + pub pushed_at: Option, + pub description: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub struct GitHubReleaseInfo { + pub tag_name: String, + pub published_at: Option, + pub assets: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct GitHubReleaseAsset { + pub name: String, + pub browser_download_url: String, + pub download_count: i64, + pub size: i64, +} + +// --- URL parsing --- + +/// Extract (owner, repo) from a GitHub URL. +/// Tries download_url first (most reliable for GitHub releases), then homepage. +pub fn extract_github_repo(homepage: Option<&str>, download_url: &str) -> Option<(String, String)> { + // Try download URL first - most AppImageHub entries point to GitHub releases + if let Some(pair) = parse_github_url(download_url) { + return Some(pair); + } + // Fallback to homepage + if let Some(hp) = homepage { + if let Some(pair) = parse_github_url(hp) { + return Some(pair); + } + } + None +} + +/// Parse `github.com/{owner}/{repo}` from a URL, stripping .git suffix if present. +fn parse_github_url(url: &str) -> Option<(String, String)> { + let stripped = url.trim_start_matches("https://") + .trim_start_matches("http://"); + + if !stripped.starts_with("github.com/") { + return None; + } + + let path = stripped.strip_prefix("github.com/")?; + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() < 2 { + return None; + } + + let owner = parts[0]; + let repo = parts[1] + .trim_end_matches(".git") + .split('?').next().unwrap_or(parts[1]); + + if owner.is_empty() || repo.is_empty() { + return None; + } + + Some((owner.to_string(), repo.to_string())) +} + +// --- API calls --- + +fn github_get(url: &str, token: &str) -> Result<(String, u32), String> { + let mut req = ureq::get(url) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Driftwood-AppImage-Manager"); + if !token.is_empty() { + req = req.header("Authorization", &format!("Bearer {}", token)); + } + let mut response = req.call() + .map_err(|e| format!("GitHub API error: {}", e))?; + + // Parse rate limit header + let remaining: u32 = response.headers() + .get("x-ratelimit-remaining") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let body = response.body_mut().read_to_string() + .map_err(|e| format!("Read error: {}", e))?; + + Ok((body, remaining)) +} + +pub fn fetch_repo_info(owner: &str, repo: &str, token: &str) -> Result<(GitHubRepoInfo, u32), String> { + let url = format!("https://api.github.com/repos/{}/{}", owner, repo); + let (body, remaining) = github_get(&url, token)?; + let info: GitHubRepoInfo = serde_json::from_str(&body) + .map_err(|e| format!("Parse error: {}", e))?; + Ok((info, remaining)) +} + +pub fn fetch_release_info(owner: &str, repo: &str, token: &str) -> Result<(GitHubReleaseInfo, u32), String> { + let url = format!("https://api.github.com/repos/{}/{}/releases/latest", owner, repo); + let (body, remaining) = github_get(&url, token)?; + let info: GitHubReleaseInfo = serde_json::from_str(&body) + .map_err(|e| format!("Parse error: {}", e))?; + Ok((info, remaining)) +} + +// --- AppImage asset filtering --- + +/// A simplified release asset for storage (JSON-serializable). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AppImageAsset { + pub name: String, + pub url: String, + pub size: i64, +} + +/// Filter release assets to only AppImage files. +pub fn filter_appimage_assets(assets: &[GitHubReleaseAsset]) -> Vec { + assets.iter() + .filter(|a| { + let lower = a.name.to_lowercase(); + lower.ends_with(".appimage") || lower.ends_with(".appimage.zsync") + }) + .filter(|a| !a.name.to_lowercase().ends_with(".zsync")) + .map(|a| AppImageAsset { + name: a.name.clone(), + url: a.browser_download_url.clone(), + size: a.size, + }) + .collect() +} + +/// Detect the current system architecture string as used in AppImage filenames. +pub fn detect_arch() -> &'static str { + #[cfg(target_arch = "x86_64")] + { "x86_64" } + #[cfg(target_arch = "aarch64")] + { "aarch64" } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { std::env::consts::ARCH } +} + +/// Pick the best AppImage asset for the current architecture. +/// Returns the matching asset, or the first one if no arch match. +pub fn pick_best_asset(assets: &[AppImageAsset]) -> Option<&AppImageAsset> { + if assets.is_empty() { + return None; + } + let arch = detect_arch(); + // Prefer exact arch match in filename + let arch_match = assets.iter().find(|a| { + let lower = a.name.to_lowercase(); + lower.contains(&arch.to_lowercase()) + }); + arch_match.or(assets.first()) +} + +// --- Enrichment logic --- + +/// Enrich a catalog app with repo-level info (stars, pushed_at). +pub fn enrich_app_repo_info( + db: &Database, + app_id: i64, + owner: &str, + repo: &str, + token: &str, +) -> Result { + let (info, remaining) = fetch_repo_info(owner, repo, token)?; + db.update_catalog_app_github_metadata(app_id, info.stargazers_count, info.pushed_at.as_deref()) + .map_err(|e| format!("DB error: {}", e))?; + Ok(remaining) +} + +/// Enrich a catalog app with release info (version, date, downloads, assets). +pub fn enrich_app_release_info( + db: &Database, + app_id: i64, + owner: &str, + repo: &str, + token: &str, +) -> Result { + let (info, remaining) = fetch_release_info(owner, repo, token)?; + + // Clean version string (strip leading "v") + let version = info.tag_name.strip_prefix('v') + .unwrap_or(&info.tag_name) + .to_string(); + + // Sum download counts across all assets + let total_downloads: i64 = info.assets.iter().map(|a| a.download_count).sum(); + + // Extract AppImage assets and pick the best download URL + let appimage_assets = filter_appimage_assets(&info.assets); + let best_url = pick_best_asset(&appimage_assets).map(|a| a.url.as_str()); + let assets_json = if appimage_assets.is_empty() { + None + } else { + serde_json::to_string(&appimage_assets).ok() + }; + + db.update_catalog_app_release_info( + app_id, + Some(&version), + info.published_at.as_deref(), + if total_downloads > 0 { Some(total_downloads) } else { None }, + best_url, + assets_json.as_deref(), + ).map_err(|e| format!("DB error: {}", e))?; + + Ok(remaining) +} + +/// Background enrichment: process a batch of unenriched apps. +/// Returns (count_enriched, should_continue). +pub fn background_enrich_batch( + db: &Database, + token: &str, + batch_size: i32, + on_progress: &dyn Fn(i64, i64), +) -> Result<(u32, bool), String> { + let apps = db.get_unenriched_catalog_apps(batch_size) + .map_err(|e| format!("DB error: {}", e))?; + + if apps.is_empty() { + return Ok((0, false)); + } + + let mut enriched = 0u32; + + for app in &apps { + let owner = match app.github_owner.as_deref() { + Some(o) => o, + None => continue, + }; + let repo = match app.github_repo.as_deref() { + Some(r) => r, + None => continue, + }; + + match enrich_app_repo_info(db, app.id, owner, repo, token) { + Ok(remaining) => { + enriched += 1; + + // Report progress + if let Ok((done, total)) = db.catalog_enrichment_progress() { + on_progress(done, total); + } + + // Stop if rate limit is getting low + if remaining < 5 { + log::info!("GitHub rate limit low ({}), pausing enrichment", remaining); + return Ok((enriched, false)); + } + } + Err(e) => { + log::warn!("Failed to enrich {}/{}: {}", owner, repo, e); + // Mark as enriched anyway so we don't retry forever + db.update_catalog_app_github_metadata(app.id, 0, None).ok(); + } + } + + // Sleep between calls to be respectful + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + Ok((enriched, enriched > 0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_github_repo_from_download() { + let result = extract_github_repo( + None, + "https://github.com/nickvdp/deno-spreadsheets/releases/download/v0.3.0/app.AppImage", + ); + assert_eq!(result, Some(("nickvdp".to_string(), "deno-spreadsheets".to_string()))); + } + + #[test] + fn test_extract_github_repo_from_homepage() { + let result = extract_github_repo( + Some("https://github.com/nickvdp/deno-spreadsheets"), + "https://example.com/download.AppImage", + ); + assert_eq!(result, Some(("nickvdp".to_string(), "deno-spreadsheets".to_string()))); + } + + #[test] + fn test_extract_github_repo_with_git_suffix() { + let result = extract_github_repo( + Some("https://github.com/user/repo.git"), + "https://example.com/download.AppImage", + ); + assert_eq!(result, Some(("user".to_string(), "repo".to_string()))); + } + + #[test] + fn test_extract_github_repo_non_github() { + let result = extract_github_repo( + Some("https://gitlab.com/user/repo"), + "https://sourceforge.net/download.AppImage", + ); + assert_eq!(result, None); + } + + #[test] + fn test_parse_github_url_empty() { + assert_eq!(parse_github_url(""), None); + assert_eq!(parse_github_url("https://github.com/"), None); + assert_eq!(parse_github_url("https://github.com/user"), None); + } + + #[test] + fn test_filter_appimage_assets() { + let assets = vec![ + GitHubReleaseAsset { + name: "app-x86_64.AppImage".to_string(), + browser_download_url: "https://github.com/u/r/releases/download/v1/app-x86_64.AppImage".to_string(), + download_count: 100, + size: 50_000_000, + }, + GitHubReleaseAsset { + name: "app-aarch64.AppImage".to_string(), + browser_download_url: "https://github.com/u/r/releases/download/v1/app-aarch64.AppImage".to_string(), + download_count: 20, + size: 48_000_000, + }, + GitHubReleaseAsset { + name: "app-x86_64.AppImage.zsync".to_string(), + browser_download_url: "https://github.com/u/r/releases/download/v1/app-x86_64.AppImage.zsync".to_string(), + download_count: 5, + size: 1000, + }, + GitHubReleaseAsset { + name: "source.tar.gz".to_string(), + browser_download_url: "https://github.com/u/r/releases/download/v1/source.tar.gz".to_string(), + download_count: 10, + size: 2_000_000, + }, + ]; + let filtered = filter_appimage_assets(&assets); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].name, "app-x86_64.AppImage"); + assert_eq!(filtered[1].name, "app-aarch64.AppImage"); + } + + #[test] + fn test_pick_best_asset_prefers_arch() { + let assets = vec![ + AppImageAsset { + name: "app-aarch64.AppImage".to_string(), + url: "https://example.com/aarch64".to_string(), + size: 48_000_000, + }, + AppImageAsset { + name: "app-x86_64.AppImage".to_string(), + url: "https://example.com/x86_64".to_string(), + size: 50_000_000, + }, + ]; + let best = pick_best_asset(&assets).unwrap(); + // On x86_64 systems this should pick x86_64, on aarch64 it picks aarch64 + let arch = detect_arch(); + assert!(best.name.contains(arch)); + } + + #[test] + fn test_pick_best_asset_empty() { + let assets: Vec = vec![]; + assert!(pick_best_asset(&assets).is_none()); + } + + #[test] + fn test_pick_best_asset_single() { + let assets = vec![ + AppImageAsset { + name: "app.AppImage".to_string(), + url: "https://example.com/app".to_string(), + size: 50_000_000, + }, + ]; + let best = pick_best_asset(&assets).unwrap(); + assert_eq!(best.name, "app.AppImage"); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 2f35e98..ee9bbdb 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,6 +4,7 @@ pub mod backup; pub mod catalog; pub mod database; pub mod discovery; +pub mod github_enrichment; pub mod duplicates; pub mod footprint; pub mod fuse; diff --git a/src/ui/catalog_detail.rs b/src/ui/catalog_detail.rs new file mode 100644 index 0000000..6219d13 --- /dev/null +++ b/src/ui/catalog_detail.rs @@ -0,0 +1,812 @@ +use adw::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +use gtk::gio; + +use crate::core::catalog; +use crate::core::database::{CatalogApp, Database}; +use crate::core::github_enrichment; +use crate::core::github_enrichment::AppImageAsset; +use crate::i18n::i18n; +use super::catalog_tile; +use super::detail_view; +use super::widgets; + +use crate::config::APP_ID; + +/// Build a catalog app detail page for the given CatalogApp. +/// Returns an adw::NavigationPage that can be pushed onto a NavigationView. +pub fn build_catalog_detail_page( + app: &CatalogApp, + db: &Rc, + toast_overlay: &adw::ToastOverlay, +) -> adw::NavigationPage { + let page_title = app.name.clone(); + + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + toolbar.add_top_bar(&header); + + let scrolled = gtk::ScrolledWindow::builder() + .vexpand(true) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(24) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + // --- Header section: icon + name + author + buttons --- + let header_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(18) + .build(); + + let icon = widgets::app_icon(None, &app.name, 96); + icon.add_css_class("icon-dropshadow"); + icon.set_valign(gtk::Align::Start); + header_box.append(&icon); + + let info_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .valign(gtk::Align::Center) + .hexpand(true) + .build(); + + let name_label = gtk::Label::builder() + .label(&app.name) + .css_classes(["title-1"]) + .xalign(0.0) + .halign(gtk::Align::Start) + .build(); + info_box.append(&name_label); + + // Author (from homepage URL domain) + if let Some(ref homepage) = app.homepage { + let author_text = extract_author(homepage); + let author_label = gtk::Label::builder() + .label(&format!("by {}", author_text)) + .css_classes(["dim-label"]) + .xalign(0.0) + .halign(gtk::Align::Start) + .build(); + info_box.append(&author_label); + } + + // Button row + let button_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_top(8) + .build(); + + // Check early if GitHub data needs loading (affects install button behavior) + let has_github = app.github_owner.is_some() && app.github_repo.is_some(); + let needs_enrichment = has_github + && (app.latest_version.is_none() || is_enrichment_stale(app.github_enriched_at.as_deref())); + let awaiting_github = needs_enrichment && app.github_download_url.is_none(); + + // Check if already installed + let installed_names: std::collections::HashSet = db + .get_all_appimages() + .unwrap_or_default() + .iter() + .filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase())) + .collect(); + let is_installed = installed_names.contains(&app.name.to_lowercase()); + + let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0); + + if is_installed { + let installed_badge = widgets::status_badge(&i18n("Installed"), "success"); + installed_badge.set_valign(gtk::Align::Center); + button_box.append(&installed_badge); + } else { + button_box.append(&install_slot); + + if awaiting_github { + // GitHub release data not yet loaded - show disabled placeholder + let placeholder = gtk::Button::builder() + .label(&i18n("Loading...")) + .css_classes(["suggested-action", "pill"]) + .sensitive(false) + .build(); + install_slot.append(&placeholder); + } else { + populate_install_slot( + &install_slot, + &app.name, + &app.download_url, + app.github_download_url.as_deref(), + app.github_release_assets.as_deref(), + toast_overlay, + db, + app.homepage.as_deref(), + ); + } + } + + // Homepage button + if let Some(ref homepage) = app.homepage { + let homepage_btn = gtk::Button::builder() + .label(&i18n("Homepage")) + .css_classes(["flat", "pill"]) + .build(); + let hp_clone = homepage.clone(); + homepage_btn.connect_clicked(move |btn| { + let launcher = gtk::UriLauncher::new(&hp_clone); + let root = btn.root().and_downcast::(); + launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); + }); + button_box.append(&homepage_btn); + } + + info_box.append(&button_box); + header_box.append(&info_box); + content.append(&header_box); + + // --- GitHub stat cards row (between header and screenshots) --- + let stars_value_label = gtk::Label::builder() + .label(app.github_stars.filter(|&s| s > 0).map(|s| format_count(s)).as_deref().unwrap_or("-")) + .css_classes(["stat-value"]) + .xalign(0.0) + .build(); + let version_value_label = gtk::Label::builder() + .label(app.latest_version.as_deref().unwrap_or("-")) + .css_classes(["stat-value"]) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(14) + .build(); + let downloads_value_label = gtk::Label::builder() + .label(app.github_downloads.filter(|&d| d > 0).map(|d| format_count(d)).as_deref().unwrap_or("-")) + .css_classes(["stat-value"]) + .xalign(0.0) + .build(); + let released_value_label = gtk::Label::builder() + .label(app.release_date.as_deref().map(|d| widgets::relative_time(d)).as_deref().unwrap_or("-")) + .css_classes(["stat-value"]) + .xalign(0.0) + .build(); + + if has_github { + let stats_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(10) + .homogeneous(true) + .build(); + + let stars_card = build_stat_card("starred-symbolic", &stars_value_label, &i18n("Stars")); + stars_card.add_css_class("stat-stars"); + stats_row.append(&stars_card); + + let version_card = build_stat_card("tag-symbolic", &version_value_label, &i18n("Latest")); + version_card.add_css_class("stat-version"); + stats_row.append(&version_card); + + let downloads_card = build_stat_card("folder-download-symbolic", &downloads_value_label, &i18n("Downloads")); + downloads_card.add_css_class("stat-downloads"); + stats_row.append(&downloads_card); + + let released_card = build_stat_card("month-symbolic", &released_value_label, &i18n("Released")); + released_card.add_css_class("stat-released"); + stats_row.append(&released_card); + + content.append(&stats_row); + } + + // Enrichment spinner (small, shown next to stats row while loading) + let enrich_spinner = gtk::Spinner::builder() + .spinning(false) + .visible(false) + .halign(gtk::Align::Start) + .width_request(16) + .height_request(16) + .build(); + if has_github { + content.append(&enrich_spinner); + } + + // On-demand enrichment: fetch release info if stale or missing + if has_github && needs_enrichment { + let app_id = app.id; + let owner = app.github_owner.clone().unwrap_or_default(); + let repo = app.github_repo.clone().unwrap_or_default(); + let db_ref = db.clone(); + let stars_ref = stars_value_label.clone(); + let version_ref = version_value_label.clone(); + let downloads_ref = downloads_value_label.clone(); + let released_ref = released_value_label.clone(); + let spinner_ref = enrich_spinner.clone(); + let install_slot_ref = install_slot.clone(); + let toast_ref = toast_overlay.clone(); + + spinner_ref.set_visible(true); + spinner_ref.set_spinning(true); + + let settings = gio::Settings::new(APP_ID); + let token = settings.string("github-token").to_string(); + + glib::spawn_future_local(async move { + let owner_c = owner.clone(); + let repo_c = repo.clone(); + let token_c = token.clone(); + + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().ok(); + if let Some(ref db) = bg_db { + let _ = github_enrichment::enrich_app_release_info( + db, app_id, &owner_c, &repo_c, &token_c, + ); + let _ = github_enrichment::enrich_app_repo_info( + db, app_id, &owner_c, &repo_c, &token_c, + ); + } + }).await; + + spinner_ref.set_spinning(false); + spinner_ref.set_visible(false); + + if let Ok(Some(updated)) = db_ref.get_catalog_app(app_id) { + if let Some(stars) = updated.github_stars.filter(|&s| s > 0) { + stars_ref.set_label(&format_count(stars)); + } + if let Some(ref ver) = updated.latest_version { + version_ref.set_label(ver); + } + if let Some(downloads) = updated.github_downloads.filter(|&d| d > 0) { + downloads_ref.set_label(&format_count(downloads)); + } + if let Some(ref date) = updated.release_date { + released_ref.set_label(&widgets::relative_time(date)); + } + + // Rebuild install button now that GitHub data is available + if awaiting_github && !is_installed { + populate_install_slot( + &install_slot_ref, + &updated.name, + &updated.download_url, + updated.github_download_url.as_deref(), + updated.github_release_assets.as_deref(), + &toast_ref, + &db_ref, + updated.homepage.as_deref(), + ); + } + } + + if result.is_err() { + log::warn!("On-demand enrichment thread error"); + } + }); + } + + // --- Screenshots section (click to open lightbox) --- + if let Some(ref screenshots_str) = app.screenshots { + let paths: Vec<&str> = screenshots_str.split(';').filter(|s| !s.is_empty()).collect(); + if !paths.is_empty() { + let screenshots_label = gtk::Label::builder() + .label(&i18n("Screenshots")) + .css_classes(["title-2"]) + .xalign(0.0) + .halign(gtk::Align::Start) + .margin_top(12) + .build(); + content.append(&screenshots_label); + + let screenshot_scroll = gtk::ScrolledWindow::builder() + .vscrollbar_policy(gtk::PolicyType::Never) + .hscrollbar_policy(gtk::PolicyType::Automatic) + .height_request(360) + .build(); + + let screenshot_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .build(); + + // Store textures for lightbox access + let textures: Rc>>> = + Rc::new(RefCell::new(vec![None; paths.len()])); + + for (i, path) in paths.iter().enumerate() { + let frame = gtk::Frame::new(None); + frame.add_css_class("card"); + frame.set_width_request(480); + frame.set_height_request(340); + + // Spinner placeholder + let spinner = gtk::Spinner::builder() + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .spinning(true) + .width_request(48) + .height_request(48) + .build(); + frame.set_child(Some(&spinner)); + + screenshot_box.append(&frame); + + // Click handler for lightbox + let textures_click = textures.clone(); + let click = gtk::GestureClick::new(); + let idx = i; + click.connect_released(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + let t = textures_click.borrow(); + if t.get(idx).is_some_and(|t| t.is_some()) { + if let Some(widget) = gesture.widget() { + if let Some(root) = gtk::prelude::WidgetExt::root(&widget) { + if let Ok(window) = root.downcast::() { + detail_view::show_screenshot_lightbox( + &window, + &textures_click, + idx, + ); + } + } + } + } + }); + frame.add_controller(click); + + // Load screenshot asynchronously + let app_name = app.name.clone(); + let screenshot_path = path.to_string(); + let frame_ref = frame.clone(); + let textures_ref = textures.clone(); + + glib::spawn_future_local(async move { + let name = app_name.clone(); + let spath = screenshot_path.clone(); + let load_idx = i; + + let result = gio::spawn_blocking(move || { + catalog::cache_screenshot(&name, &spath, load_idx) + .map_err(|e| e.to_string()) + }).await; + + match result { + Ok(Ok(local_path)) => { + if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) { + let picture = gtk::Picture::builder() + .paintable(&texture) + .content_fit(gtk::ContentFit::Contain) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + frame_ref.set_child(Some(&picture)); + textures_ref.borrow_mut()[i] = Some(texture); + } + } + _ => { + let fallback = gtk::Label::builder() + .label("Screenshot unavailable") + .css_classes(["dim-label"]) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + frame_ref.set_child(Some(&fallback)); + } + } + }); + } + + screenshot_scroll.set_child(Some(&screenshot_box)); + content.append(&screenshot_scroll); + } + } + + // --- About section --- + if let Some(ref desc) = app.description { + if !desc.is_empty() { + let about_group = adw::PreferencesGroup::builder() + .title(&i18n("About")) + .build(); + + let plain_desc = catalog_tile::strip_html(desc); + let desc_label = gtk::Label::builder() + .label(&plain_desc) + .wrap(true) + .xalign(0.0) + .selectable(true) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build(); + + let row = adw::ActionRow::new(); + row.set_child(Some(&desc_label)); + about_group.add(&row); + + content.append(&about_group); + } + } + + // --- Details section --- + let details_group = adw::PreferencesGroup::builder() + .title(&i18n("Details")) + .build(); + + if let Some(ref license) = app.license { + if !license.is_empty() { + let row = adw::ActionRow::builder() + .title(&i18n("License")) + .subtitle(license) + .subtitle_selectable(true) + .build(); + details_group.add(&row); + } + } + + if let Some(ref cats) = app.categories { + if !cats.is_empty() { + let row = adw::ActionRow::builder() + .title(&i18n("Categories")) + .subtitle(cats) + .build(); + details_group.add(&row); + } + } + + if let Some(ref homepage) = app.homepage { + let row = adw::ActionRow::builder() + .title(&i18n("Homepage")) + .subtitle(homepage) + .subtitle_selectable(true) + .activatable(true) + .build(); + let arrow = gtk::Image::from_icon_name("external-link-symbolic"); + arrow.set_valign(gtk::Align::Center); + row.add_suffix(&arrow); + + let hp = homepage.clone(); + row.connect_activated(move |row| { + let launcher = gtk::UriLauncher::new(&hp); + let root = row.root().and_downcast::(); + launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); + }); + details_group.add(&row); + } + + // Download URL + let dl_row = adw::ActionRow::builder() + .title(&i18n("Download")) + .subtitle(&app.download_url) + .subtitle_selectable(true) + .activatable(true) + .build(); + let dl_arrow = gtk::Image::from_icon_name("external-link-symbolic"); + dl_arrow.set_valign(gtk::Align::Center); + dl_row.add_suffix(&dl_arrow); + let dl_url = app.download_url.clone(); + dl_row.connect_activated(move |row| { + let launcher = gtk::UriLauncher::new(&dl_url); + let root = row.root().and_downcast::(); + launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); + }); + details_group.add(&dl_row); + + content.append(&details_group); + + // --- Status section --- + let status_group = adw::PreferencesGroup::builder() + .title(&i18n("Status")) + .build(); + + let status_row = adw::ActionRow::builder() + .title(&i18n("Installed")) + .build(); + let status_badge = if is_installed { + widgets::status_badge(&i18n("Yes"), "success") + } else { + widgets::status_badge(&i18n("No"), "neutral") + }; + status_badge.set_valign(gtk::Align::Center); + status_row.add_suffix(&status_badge); + status_group.add(&status_row); + + content.append(&status_group); + + clamp.set_child(Some(&content)); + scrolled.set_child(Some(&clamp)); + toolbar.set_content(Some(&scrolled)); + + adw::NavigationPage::builder() + .title(&page_title) + .tag("catalog-detail") + .child(&toolbar) + .build() +} + +/// Trigger an install from a URL. Handles async download, DB registration, and UI feedback. +fn do_install( + url: String, + app_name: String, + homepage: Option, + toast_overlay: adw::ToastOverlay, + db: Rc, + widget: gtk::Widget, +) { + glib::spawn_future_local(async move { + let name = app_name.clone(); + let hp = homepage.clone(); + let dl_url = url.clone(); + + let result = gio::spawn_blocking(move || { + let install_dir = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) + .join("Applications"); + std::fs::create_dir_all(&install_dir).ok(); + + let cat_app = catalog::CatalogApp { + name, + description: None, + categories: Vec::new(), + latest_version: None, + download_url: dl_url, + icon_url: None, + homepage: hp, + file_size: None, + architecture: None, + screenshots: Vec::new(), + license: None, + github_link: None, + }; + + catalog::install_from_catalog(&cat_app, &install_dir) + .map_err(|e| e.to_string()) + }).await; + + match result { + Ok(Ok(path)) => { + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let size = std::fs::metadata(&path) + .map(|m| m.len() as i64) + .unwrap_or(0); + db.upsert_appimage( + &path.to_string_lossy(), + &filename, + Some(2), + size, + true, + None, + ).ok(); + toast_overlay.add_toast(adw::Toast::new("Installed successfully")); + if let Some(btn) = widget.downcast_ref::() { + btn.set_label("Installed"); + } else if let Some(split) = widget.downcast_ref::() { + split.set_label("Installed"); + } + } + Ok(Err(e)) => { + log::error!("Install failed: {}", e); + toast_overlay.add_toast(adw::Toast::new("Install failed")); + widget.set_sensitive(true); + if let Some(btn) = widget.downcast_ref::() { + btn.set_label("Install"); + } else if let Some(split) = widget.downcast_ref::() { + split.set_label("Install"); + } + } + Err(_) => { + log::error!("Install thread panicked"); + toast_overlay.add_toast(adw::Toast::new("Install failed")); + widget.set_sensitive(true); + if let Some(btn) = widget.downcast_ref::() { + btn.set_label("Install"); + } else if let Some(split) = widget.downcast_ref::() { + split.set_label("Install"); + } + } + } + }); +} + +/// Populate the install button slot with the appropriate button (plain or split). +/// Clears any existing children first, so it can be called to rebuild after enrichment. +fn populate_install_slot( + slot: >k::Box, + app_name: &str, + download_url: &str, + github_download_url: Option<&str>, + github_release_assets: Option<&str>, + toast_overlay: &adw::ToastOverlay, + db: &Rc, + homepage: Option<&str>, +) { + // Clear existing children (e.g. the "Loading..." placeholder) + while let Some(child) = slot.first_child() { + slot.remove(&child); + } + + let assets: Vec = github_release_assets + .and_then(|json| serde_json::from_str(json).ok()) + .unwrap_or_default(); + + let default_url = github_download_url + .unwrap_or(download_url) + .to_string(); + + if assets.len() > 1 { + // Multiple assets available - use SplitButton with dropdown + let menu = gio::Menu::new(); + for asset in &assets { + let label = format_asset_label(&asset.name, asset.size); + menu.append(Some(&label), Some(&format!("install.asset::{}", asset.url))); + } + menu.append( + Some(&i18n("AppImageHub (original)")), + Some(&format!("install.asset::{}", download_url)), + ); + + let split_btn = adw::SplitButton::builder() + .label(&i18n("Install")) + .menu_model(&menu) + .css_classes(["suggested-action", "pill"]) + .build(); + + let url_for_click = default_url; + let name_for_click = app_name.to_string(); + let hp_for_click = homepage.map(|s| s.to_string()); + let toast_for_click = toast_overlay.clone(); + let db_for_click = db.clone(); + + split_btn.connect_clicked(move |btn| { + btn.set_sensitive(false); + btn.set_label("Installing..."); + do_install( + url_for_click.clone(), + name_for_click.clone(), + hp_for_click.clone(), + toast_for_click.clone(), + db_for_click.clone(), + btn.upcast_ref::().clone(), + ); + }); + + let action_group = gio::SimpleActionGroup::new(); + let asset_action = gio::SimpleAction::new("asset", Some(glib::VariantTy::STRING)); + let name_for_asset = app_name.to_string(); + let hp_for_asset = homepage.map(|s| s.to_string()); + let toast_for_asset = toast_overlay.clone(); + let db_for_asset = db.clone(); + let split_ref = split_btn.clone(); + asset_action.connect_activate(move |_, param| { + if let Some(url) = param.and_then(|p| p.str()) { + split_ref.set_sensitive(false); + split_ref.set_label("Installing..."); + do_install( + url.to_string(), + name_for_asset.clone(), + hp_for_asset.clone(), + toast_for_asset.clone(), + db_for_asset.clone(), + split_ref.upcast_ref::().clone(), + ); + } + }); + action_group.add_action(&asset_action); + split_btn.insert_action_group("install", Some(&action_group)); + + slot.append(&split_btn); + } else { + // Single asset or no GitHub assets - plain button + let install_btn = gtk::Button::builder() + .label(&i18n("Install")) + .css_classes(["suggested-action", "pill"]) + .build(); + + let url_clone = default_url; + let name_clone = app_name.to_string(); + let hp_clone = homepage.map(|s| s.to_string()); + let toast_clone = toast_overlay.clone(); + let db_clone = db.clone(); + + install_btn.connect_clicked(move |btn| { + btn.set_sensitive(false); + btn.set_label("Installing..."); + do_install( + url_clone.clone(), + name_clone.clone(), + hp_clone.clone(), + toast_clone.clone(), + db_clone.clone(), + btn.upcast_ref::().clone(), + ); + }); + + slot.append(&install_btn); + } +} + +/// Format an asset filename with size for the dropdown menu. +fn format_asset_label(name: &str, size: i64) -> String { + if size > 0 { + format!("{} ({})", name, widgets::format_size(size)) + } else { + name.to_string() + } +} + +/// Build a single stat card widget with icon, value label, and description label. +fn build_stat_card(icon_name: &str, value_label: >k::Label, label_text: &str) -> gtk::Box { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .hexpand(true) + .build(); + card.add_css_class("stat-card"); + + let icon_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .build(); + + let icon = gtk::Image::from_icon_name(icon_name); + icon.set_pixel_size(14); + icon_row.append(&icon); + icon_row.append(value_label); + card.append(&icon_row); + + let label = gtk::Label::builder() + .label(label_text) + .css_classes(["stat-label"]) + .xalign(0.0) + .build(); + card.append(&label); + + card +} + +/// Check if enrichment data is stale (>24 hours old). +fn is_enrichment_stale(enriched_at: Option<&str>) -> bool { + let Some(ts) = enriched_at else { return true }; + let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") else { + return true; + }; + let now = chrono::Utc::now().naive_utc(); + let elapsed = now.signed_duration_since(parsed); + elapsed.num_hours() >= 24 +} + +/// Re-export format_count from widgets for use in this module. +fn format_count(n: i64) -> String { + widgets::format_count(n) +} + +/// Extract author/org from a URL for display. +/// For GitHub/GitLab URLs, extracts the username/org from the path. +/// For other URLs, returns the domain. +fn extract_author(url: &str) -> String { + let stripped = url.trim_start_matches("https://") + .trim_start_matches("http://"); + let parts: Vec<&str> = stripped.splitn(3, '/').collect(); + let domain = parts.first().copied().unwrap_or(""); + + // For GitHub/GitLab, extract the org/user from the first path segment + if domain == "github.com" || domain == "gitlab.com" { + if let Some(org) = parts.get(1) { + if !org.is_empty() { + return org.to_string(); + } + } + } + + domain.to_string() +} diff --git a/src/ui/catalog_tile.rs b/src/ui/catalog_tile.rs new file mode 100644 index 0000000..df373c0 --- /dev/null +++ b/src/ui/catalog_tile.rs @@ -0,0 +1,338 @@ +use gtk::prelude::*; + +use crate::core::database::CatalogApp; +use super::widgets; + +/// Build a catalog tile for the browse grid. +/// Left-aligned layout: icon (48px) at top, name, description, category badge. +/// Card fills its entire FlowBoxChild cell. +pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .halign(gtk::Align::Fill) + .valign(gtk::Align::Fill) + .hexpand(true) + .vexpand(true) + .build(); + card.add_css_class("card"); + card.add_css_class("catalog-tile"); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .margin_top(14) + .margin_bottom(14) + .margin_start(14) + .margin_end(14) + .vexpand(true) + .build(); + + // Icon (48px) - left aligned + let icon = widgets::app_icon(None, &app.name, 48); + icon.add_css_class("icon-dropshadow"); + icon.set_halign(gtk::Align::Start); + inner.append(&icon); + + // App name - left aligned + let name_label = gtk::Label::builder() + .label(&app.name) + .css_classes(["heading"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(20) + .xalign(0.0) + .halign(gtk::Align::Start) + .build(); + inner.append(&name_label); + + // Description (always 2 lines for uniform height) + let plain = app.description.as_deref() + .filter(|d| !d.is_empty()) + .map(|d| strip_html(d)) + .unwrap_or_default(); + let snippet: String = plain.chars().take(80).collect(); + let text = if plain.is_empty() { + // Non-breaking space placeholder to reserve 2 lines + "\u{00a0}".to_string() + } else if snippet.len() < plain.len() { + format!("{}...", snippet.trim_end()) + } else { + snippet + }; + let desc_label = gtk::Label::builder() + .label(&text) + .css_classes(["caption", "dim-label"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .lines(2) + .wrap(true) + .xalign(0.0) + .max_width_chars(24) + .halign(gtk::Align::Start) + .build(); + // Force 2-line height + desc_label.set_height_request(desc_label.preferred_size().1.height().max(36)); + inner.append(&desc_label); + + // Stats row (stars + version) - only if data exists + let has_stars = app.github_stars.is_some_and(|s| s > 0); + let has_version = app.latest_version.is_some(); + if has_stars || has_version { + let stats_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .halign(gtk::Align::Start) + .build(); + stats_row.add_css_class("catalog-stats-row"); + + if let Some(stars) = app.github_stars.filter(|&s| s > 0) { + let star_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .build(); + let star_icon = gtk::Image::from_icon_name("starred-symbolic"); + star_icon.set_pixel_size(12); + star_box.append(&star_icon); + let star_label = gtk::Label::new(Some(&widgets::format_count(stars))); + star_box.append(&star_label); + stats_row.append(&star_box); + } + + if let Some(ref ver) = app.latest_version { + let ver_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .build(); + let ver_icon = gtk::Image::from_icon_name("tag-symbolic"); + ver_icon.set_pixel_size(12); + ver_box.append(&ver_icon); + let ver_label = gtk::Label::builder() + .label(ver.as_str()) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(12) + .build(); + ver_box.append(&ver_label); + stats_row.append(&ver_box); + } + + inner.append(&stats_row); + } + + // Category badge - left aligned + if let Some(ref cats) = app.categories { + let first_cat: String = cats.split(';') + .next() + .or_else(|| cats.split(',').next()) + .unwrap_or("") + .trim() + .to_string(); + if !first_cat.is_empty() { + let badge = widgets::status_badge(&first_cat, "neutral"); + badge.set_halign(gtk::Align::Start); + badge.set_margin_top(2); + inner.append(&badge); + } + } + + card.append(&inner); + + let child = gtk::FlowBoxChild::builder() + .child(&card) + .build(); + child.add_css_class("activatable"); + + child +} + +/// Build a featured banner card for the carousel. +/// Layout: screenshot preview on top, then icon + name + description + badge below. +/// Width is set dynamically by the carousel layout. +pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) + .halign(gtk::Align::Fill) + .valign(gtk::Align::Fill) + .hexpand(true) + .vexpand(true) + .build(); + card.add_css_class("card"); + card.add_css_class("catalog-featured-card"); + card.add_css_class("activatable"); + card.set_widget_name(&format!("featured-{}", app.id)); + + // Screenshot preview area (top) + let screenshot_frame = gtk::Frame::new(None); + screenshot_frame.add_css_class("catalog-featured-screenshot"); + screenshot_frame.set_height_request(160); + screenshot_frame.set_hexpand(true); + + // Spinner placeholder until image loads + let spinner = gtk::Spinner::builder() + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .spinning(true) + .width_request(32) + .height_request(32) + .build(); + screenshot_frame.set_child(Some(&spinner)); + card.append(&screenshot_frame); + + // Info section below screenshot + let info_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .margin_top(10) + .margin_bottom(10) + .margin_start(12) + .margin_end(12) + .build(); + + // Icon (48px) + let icon = widgets::app_icon(None, &app.name, 48); + icon.add_css_class("icon-dropshadow"); + icon.set_valign(gtk::Align::Start); + info_box.append(&icon); + + // Text column + let text_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .valign(gtk::Align::Center) + .hexpand(true) + .build(); + + // App name + let name_label = gtk::Label::builder() + .label(&app.name) + .css_classes(["heading"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(28) + .xalign(0.0) + .halign(gtk::Align::Start) + .build(); + text_box.append(&name_label); + + // Description (1 line in featured since space is tight) + if let Some(ref desc) = app.description { + if !desc.is_empty() { + let plain = strip_html(desc); + let snippet: String = plain.chars().take(60).collect(); + let text = if snippet.len() < plain.len() { + format!("{}...", snippet.trim_end()) + } else { + snippet + }; + let desc_label = gtk::Label::builder() + .label(&text) + .css_classes(["caption", "dim-label"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .lines(1) + .xalign(0.0) + .max_width_chars(35) + .halign(gtk::Align::Start) + .build(); + text_box.append(&desc_label); + } + } + + // Badge row: category + stars + let badge_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(2) + .build(); + + if let Some(ref cats) = app.categories { + let first_cat: String = cats.split(';') + .next() + .or_else(|| cats.split(',').next()) + .unwrap_or("") + .trim() + .to_string(); + if !first_cat.is_empty() { + let badge = widgets::status_badge(&first_cat, "info"); + badge.set_halign(gtk::Align::Start); + badge_row.append(&badge); + } + } + + if let Some(stars) = app.github_stars.filter(|&s| s > 0) { + let star_badge = widgets::status_badge_with_icon( + "starred-symbolic", + &widgets::format_count(stars), + "neutral", + ); + star_badge.set_halign(gtk::Align::Start); + badge_row.append(&star_badge); + } + + text_box.append(&badge_row); + + info_box.append(&text_box); + card.append(&info_box); + card +} + +/// Strip HTML tags from a string, returning plain text. +pub fn strip_html(html: &str) -> String { + let mut result = String::with_capacity(html.len()); + let mut in_tag = false; + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => result.push(ch), + _ => {} + } + } + // Collapse whitespace + let collapsed: String = result.split_whitespace().collect::>().join(" "); + // Decode common HTML entities + collapsed + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_html_basic() { + assert_eq!(strip_html("

Hello world

"), "Hello world"); + } + + #[test] + fn test_strip_html_nested() { + assert_eq!( + strip_html("

Hello bold world

"), + "Hello bold world" + ); + } + + #[test] + fn test_strip_html_entities() { + assert_eq!(strip_html("& < > ""), "& < > \""); + } + + #[test] + fn test_strip_html_multiline() { + let input = "

Line one

\n

Line two

"; + assert_eq!(strip_html(input), "Line one Line two"); + } + + #[test] + fn test_strip_html_list() { + let input = "
    \n
  • Item 1
  • \n
  • Item 2
  • \n
"; + assert_eq!(strip_html(input), "Item 1 Item 2"); + } + + #[test] + fn test_strip_html_plain_text() { + assert_eq!(strip_html("No HTML here"), "No HTML here"); + } +} diff --git a/src/ui/catalog_view.rs b/src/ui/catalog_view.rs index cd98cea..e2f8209 100644 --- a/src/ui/catalog_view.rs +++ b/src/ui/catalog_view.rs @@ -5,12 +5,24 @@ use std::rc::Rc; use gtk::gio; use crate::core::catalog; -use crate::core::database::Database; +use crate::core::database::{CatalogApp, Database}; use crate::i18n::i18n; +use super::catalog_detail; +use super::catalog_tile; use super::widgets; -/// Build the catalog browser page. -pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { +/// Build the catalog page with an internal NavigationView for drill-down. +/// Returns (NavigationView, enrichment_banner) so the window can control the banner. +pub fn build_catalog_page(db: &Rc) -> (adw::NavigationView, gtk::Box) { + let nav_view = adw::NavigationView::new(); + let (browse_page, enrichment_banner) = build_browse_page(db, &nav_view); + nav_view.push(&browse_page); + (nav_view, enrichment_banner) +} + +/// Build the main browse page with featured carousel + tile grid. +/// Returns (NavigationPage, enrichment_banner). +fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw::NavigationPage, gtk::Box) { let toast_overlay = adw::ToastOverlay::new(); let header = adw::HeaderBar::new(); @@ -31,44 +43,197 @@ pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { .search_mode_enabled(true) .build(); - // Category filter - let category_box = gtk::Box::builder() + // --- Featured section (paged carousel) --- + let featured_label = gtk::Label::builder() + .label(&i18n("Featured")) + .css_classes(["title-2"]) + .xalign(0.0) + .halign(gtk::Align::Start) + .margin_start(18) + .margin_top(6) + .build(); + + // Stack for crossfade page transitions + let featured_stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .transition_duration(250) + .hexpand(true) + .build(); + + // Page state: all featured apps and current page index + let featured_apps: Rc>> = Rc::new(RefCell::new(Vec::new())); + let featured_page: Rc> = Rc::new(std::cell::Cell::new(0)); + // Tracks which stack child name is active ("a" or "b") for crossfade toggling + let featured_flip: Rc> = Rc::new(std::cell::Cell::new(false)); + + // Navigation arrows + let left_arrow = gtk::Button::builder() + .icon_name("go-previous-symbolic") + .css_classes(["circular", "osd"]) + .valign(gtk::Align::Center) + .sensitive(false) + .build(); + + let right_arrow = gtk::Button::builder() + .icon_name("go-next-symbolic") + .css_classes(["circular", "osd"]) + .valign(gtk::Align::Center) + .build(); + + // Carousel row: [<] [stack] [>] + let carousel_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(8) .margin_start(18) .margin_end(18) .build(); + carousel_row.append(&left_arrow); + carousel_row.append(&featured_stack); + carousel_row.append(&right_arrow); - let category_scroll = gtk::ScrolledWindow::builder() - .child(&category_box) - .vscrollbar_policy(gtk::PolicyType::Never) - .hscrollbar_policy(gtk::PolicyType::Automatic) - .max_content_height(40) + // Wire arrow navigation (page through featured apps with crossfade) + { + let apps_ref = featured_apps.clone(); + let page_ref = featured_page.clone(); + let flip_ref = featured_flip.clone(); + let stack_ref = featured_stack.clone(); + let left_ref = left_arrow.clone(); + let right_ref = right_arrow.clone(); + let db_ref = db.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); + left_arrow.connect_clicked(move |_| { + let page = page_ref.get(); + if page > 0 { + page_ref.set(page - 1); + show_featured_page( + &apps_ref, page - 1, &stack_ref, &flip_ref, + &left_ref, &right_ref, + &db_ref, &nav_ref, &toast_ref, + ); + } + }); + } + { + let apps_ref = featured_apps.clone(); + let page_ref = featured_page.clone(); + let flip_ref = featured_flip.clone(); + let stack_ref = featured_stack.clone(); + let left_ref = left_arrow.clone(); + let right_ref = right_arrow.clone(); + let db_ref = db.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); + right_arrow.connect_clicked(move |_| { + let apps = apps_ref.borrow(); + let page = page_ref.get(); + let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE; + if page < max_page { + drop(apps); + page_ref.set(page + 1); + show_featured_page( + &apps_ref, page + 1, &stack_ref, &flip_ref, + &left_ref, &right_ref, + &db_ref, &nav_ref, &toast_ref, + ); + } + }); + } + + // Wrapping container for featured section + let featured_section = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) .build(); + featured_section.append(&featured_label); + featured_section.append(&carousel_row); - // Results list - let results_box = gtk::ListBox::builder() + // --- Category filter chips --- + let category_box = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .homogeneous(false) + .min_children_per_line(3) + .max_children_per_line(20) + .row_spacing(6) + .column_spacing(6) + .margin_start(18) + .margin_end(18) + .margin_top(6) + .build(); + + // --- "All Apps" section --- + let all_label = gtk::Label::builder() + .label(&i18n("All Apps")) + .css_classes(["title-2"]) + .xalign(0.0) + .halign(gtk::Align::Start) + .margin_start(18) + .margin_top(6) + .build(); + + // FlowBox grid + let flow_box = gtk::FlowBox::builder() + .homogeneous(true) + .min_children_per_line(2) + .max_children_per_line(5) .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) .margin_start(18) .margin_end(18) - .margin_top(12) .margin_bottom(24) + .row_spacing(12) + .column_spacing(12) .build(); let clamp = adw::Clamp::builder() - .maximum_size(800) - .tightening_threshold(600) + .maximum_size(1200) + .tightening_threshold(900) .build(); let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(12) + .spacing(8) .build(); + // Enrichment banner (hidden by default, shown by background enrichment) + let enrichment_banner = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_start(18) + .margin_end(18) + .margin_top(6) + .visible(false) + .build(); + enrichment_banner.add_css_class("card"); + enrichment_banner.set_halign(gtk::Align::Fill); + + let enrich_spinner = gtk::Spinner::builder() + .spinning(true) + .width_request(16) + .height_request(16) + .margin_start(12) + .valign(gtk::Align::Center) + .build(); + enrich_spinner.set_widget_name("enrich-spinner"); + enrichment_banner.append(&enrich_spinner); + + let enrich_label = gtk::Label::builder() + .label(&i18n("Enriching app data from GitHub...")) + .css_classes(["dim-label"]) + .hexpand(true) + .xalign(0.0) + .margin_top(8) + .margin_bottom(8) + .build(); + enrich_label.set_widget_name("enrich-label"); + enrichment_banner.append(&enrich_label); + + // Layout order: search -> enrichment banner -> featured carousel -> categories -> all apps grid content.append(&search_bar); - content.append(&category_scroll); - content.append(&results_box); + content.append(&enrichment_banner); + content.append(&featured_section); + content.append(&category_box.clone()); + content.append(&all_label); + content.append(&flow_box); clamp.set_child(Some(&content)); let scrolled = gtk::ScrolledWindow::builder() @@ -76,7 +241,7 @@ pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { .vexpand(true) .build(); - // Status page for empty state + // Empty state let empty_page = adw::StatusPage::builder() .icon_name("system-software-install-symbolic") .title(&i18n("App Catalog")) @@ -94,7 +259,6 @@ pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { stack.add_named(&empty_page, Some("empty")); stack.add_named(&scrolled, Some("results")); - // Show empty or results based on catalog data let app_count = db.catalog_app_count().unwrap_or(0); if app_count > 0 { stack.set_visible_child_name("results"); @@ -105,9 +269,25 @@ pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { toast_overlay.set_child(Some(&stack)); + // Progress bar for catalog sync + let progress_bar = gtk::ProgressBar::builder() + .show_text(true) + .visible(false) + .margin_start(18) + .margin_end(18) + .margin_top(6) + .margin_bottom(6) + .build(); + + let toolbar_content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + toolbar_content.append(&progress_bar); + toolbar_content.append(&toast_overlay); + let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); - toolbar_view.set_content(Some(&toast_overlay)); + toolbar_view.set_content(Some(&toolbar_content)); // Refresh button in header let refresh_header_btn = gtk::Button::builder() @@ -120,56 +300,162 @@ pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { let active_category: Rc>> = Rc::new(RefCell::new(None)); // Populate categories - populate_categories(db, &category_box, &active_category, &results_box, &search_entry); + populate_categories( + db, &category_box, &active_category, &flow_box, &search_entry, + &featured_section, &all_label, nav_view, &toast_overlay, + ); // Initial population - populate_results(db, "", None, &results_box, &toast_overlay); + populate_featured( + db, &featured_apps, &featured_page, &featured_stack, &featured_flip, + &left_arrow, &right_arrow, nav_view, &toast_overlay, + ); + populate_grid(db, "", None, &flow_box, &all_label, nav_view, &toast_overlay); // Search handler { let db_ref = db.clone(); - let results_ref = results_box.clone(); - let toast_ref = toast_overlay.clone(); + let flow_ref = flow_box.clone(); let cat_ref = active_category.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); + let all_label_ref = all_label.clone(); + let featured_section_ref = featured_section.clone(); search_entry.connect_search_changed(move |entry| { let query = entry.text().to_string(); let cat = cat_ref.borrow().clone(); - populate_results(&db_ref, &query, cat.as_deref(), &results_ref, &toast_ref); + let is_searching = !query.is_empty() || cat.is_some(); + featured_section_ref.set_visible(!is_searching); + populate_grid( + &db_ref, &query, cat.as_deref(), &flow_ref, + &all_label_ref, &nav_ref, &toast_ref, + ); }); } - // Refresh handler (both buttons) + // Tile click handler for grid + { + let db_ref = db.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); + flow_box.connect_child_activated(move |_, child| { + if let Some(app_id) = child.widget_name().strip_prefix("catalog-app-") { + if let Ok(id) = app_id.parse::() { + if let Ok(Some(app)) = db_ref.get_catalog_app(id) { + let detail = catalog_detail::build_catalog_detail_page( + &app, &db_ref, &toast_ref, + ); + nav_ref.push(&detail); + } + } + } + }); + } + + // Refresh handler let wire_refresh = |btn: >k::Button| { let db_ref = db.clone(); let stack_ref = stack.clone(); - let results_ref = results_box.clone(); + let flow_ref = flow_box.clone(); let toast_ref = toast_overlay.clone(); let title_ref = title.clone(); let cat_box_ref = category_box.clone(); let active_cat_ref = active_category.clone(); let search_ref = search_entry.clone(); + let featured_apps_ref = featured_apps.clone(); + let featured_page_ref = featured_page.clone(); + let featured_stack_ref = featured_stack.clone(); + let featured_flip_ref = featured_flip.clone(); + let left_arrow_ref = left_arrow.clone(); + let right_arrow_ref = right_arrow.clone(); + let all_label_ref = all_label.clone(); + let featured_section_ref = featured_section.clone(); + let nav_ref = nav_view.clone(); + let progress_ref = progress_bar.clone(); btn.connect_clicked(move |btn| { btn.set_sensitive(false); let db_c = db_ref.clone(); let stack_c = stack_ref.clone(); - let results_c = results_ref.clone(); + let flow_c = flow_ref.clone(); let toast_c = toast_ref.clone(); let title_c = title_ref.clone(); let btn_c = btn.clone(); let cat_box_c = cat_box_ref.clone(); let active_cat_c = active_cat_ref.clone(); let search_c = search_ref.clone(); + let featured_apps_c = featured_apps_ref.clone(); + let featured_page_c = featured_page_ref.clone(); + let featured_stack_c = featured_stack_ref.clone(); + let featured_flip_c = featured_flip_ref.clone(); + let left_arrow_c = left_arrow_ref.clone(); + let right_arrow_c = right_arrow_ref.clone(); + let all_label_c = all_label_ref.clone(); + let featured_section_c = featured_section_ref.clone(); + let nav_c = nav_ref.clone(); + let progress_c = progress_ref.clone(); + + // Capture app count before refresh for delta calculation + let count_before = db_c.catalog_app_count().unwrap_or(0); + + // Show progress bar + progress_c.set_visible(true); + progress_c.set_fraction(0.0); + progress_c.set_text(Some("Fetching catalog...")); + + // Channel for progress updates from background thread + let (tx, rx) = std::sync::mpsc::channel::(); + + // Listen for progress on the main thread + let progress_listen = progress_c.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + while let Ok(progress) = rx.try_recv() { + match progress { + catalog::SyncProgress::FetchingFeed => { + progress_listen.set_fraction(0.0); + progress_listen.set_text(Some("Fetching catalog feed...")); + } + catalog::SyncProgress::FeedFetched { total } => { + progress_listen.set_fraction(0.05); + progress_listen.set_text(Some(&format!("Found {} apps, caching icons...", total))); + } + catalog::SyncProgress::CachingIcon { current, total, .. } => { + let frac = 0.05 + 0.85 * (current as f64 / total.max(1) as f64); + progress_listen.set_fraction(frac); + progress_listen.set_text(Some( + &format!("Caching icons ({}/{})", current, total), + )); + } + catalog::SyncProgress::SavingApps { current, total } => { + let frac = 0.90 + 0.10 * (current as f64 / total.max(1) as f64); + progress_listen.set_fraction(frac); + progress_listen.set_text(Some( + &format!("Saving apps ({}/{})", current, total), + )); + } + catalog::SyncProgress::Done { .. } => { + progress_listen.set_fraction(1.0); + progress_listen.set_text(Some("Complete")); + return glib::ControlFlow::Break; + } + } + } + if !progress_listen.is_visible() { + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); glib::spawn_future_local(async move { - let db_bg = Database::open().ok(); let result = gio::spawn_blocking(move || { + let db_bg = Database::open().ok(); if let Some(ref db) = db_bg { catalog::ensure_default_sources(db); let sources = catalog::get_sources(db); if let Some(source) = sources.first() { - catalog::sync_catalog(db, source) - .map_err(|e| e.to_string()) + catalog::sync_catalog_with_progress(db, source, &move |p| { + tx.send(p).ok(); + }).map_err(|e| e.to_string()) } else { Err("No catalog sources configured".to_string()) } @@ -179,20 +465,37 @@ pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { }).await; btn_c.set_sensitive(true); + progress_c.set_visible(false); match result { - Ok(Ok(count)) => { - toast_c.add_toast(adw::Toast::new( - &format!("Catalog refreshed: {} apps", count), - )); - update_catalog_subtitle(&title_c, count as i64); + Ok(Ok(_count)) => { + let count_after = db_c.catalog_app_count().unwrap_or(0); + let new_apps = count_after - count_before; + let toast_msg = if new_apps > 0 { + format!("Catalog refreshed: {} new apps added", new_apps) + } else { + "Catalog refreshed, no new apps".to_string() + }; + toast_c.add_toast(adw::Toast::new(&toast_msg)); + update_catalog_subtitle(&title_c, count_after); stack_c.set_visible_child_name("results"); - populate_categories(&db_c, &cat_box_c, &active_cat_c, &results_c, &search_c); - populate_results(&db_c, "", None, &results_c, &toast_c); + populate_categories( + &db_c, &cat_box_c, &active_cat_c, &flow_c, &search_c, + &featured_section_c, &all_label_c, + &nav_c, &toast_c, + ); + populate_featured( + &db_c, &featured_apps_c, &featured_page_c, + &featured_stack_c, &featured_flip_c, + &left_arrow_c, &right_arrow_c, &nav_c, &toast_c, + ); + populate_grid( + &db_c, "", None, &flow_c, &all_label_c, &nav_c, &toast_c, + ); - // Store refresh timestamp let settings = gio::Settings::new(crate::config::APP_ID); - let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let now = chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); settings.set_string("catalog-last-refreshed", &now).ok(); } Ok(Err(e)) => { @@ -216,14 +519,15 @@ pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { refresh_btn.emit_clicked(); } - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title(&i18n("Catalog")) - .tag("catalog") + .tag("catalog-browse") .child(&toolbar_view) - .build() + .build(); + + (page, enrichment_banner) } -/// Update the catalog subtitle to show app count and relative last-refreshed time. fn update_catalog_subtitle(title: &adw::WindowTitle, app_count: i64) { let settings = gtk::gio::Settings::new(crate::config::APP_ID); let last_refreshed = settings.string("catalog-last-refreshed"); @@ -235,170 +539,188 @@ fn update_catalog_subtitle(title: &adw::WindowTitle, app_count: i64) { } } -fn populate_results( +const CARDS_PER_PAGE: usize = 3; + +/// Populate featured apps data and show the first page. +fn populate_featured( db: &Rc, - query: &str, - category: Option<&str>, - list_box: >k::ListBox, + featured_apps: &Rc>>, + featured_page: &Rc>, + featured_stack: >k::Stack, + featured_flip: &Rc>, + left_arrow: >k::Button, + right_arrow: >k::Button, + nav_view: &adw::NavigationView, toast_overlay: &adw::ToastOverlay, ) { - // Clear existing - while let Some(row) = list_box.row_at_index(0) { - list_box.remove(&row); - } + let apps = db.get_featured_catalog_apps(30).unwrap_or_default(); + *featured_apps.borrow_mut() = apps; + featured_page.set(0); + show_featured_page( + featured_apps, 0, featured_stack, featured_flip, + left_arrow, right_arrow, db, nav_view, toast_overlay, + ); +} - let results = db.search_catalog(query, category, 50).unwrap_or_default(); +/// Display a specific page of featured cards with crossfade transition. +fn show_featured_page( + featured_apps: &Rc>>, + page: usize, + stack: >k::Stack, + flip: &Rc>, + left_arrow: >k::Button, + right_arrow: >k::Button, + db: &Rc, + nav_view: &adw::NavigationView, + toast_overlay: &adw::ToastOverlay, +) { + let apps = featured_apps.borrow(); + let start = page * CARDS_PER_PAGE; + let end = (start + CARDS_PER_PAGE).min(apps.len()); + let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE; - if results.is_empty() { - let empty_row = adw::ActionRow::builder() - .title(&i18n("No results")) - .subtitle(&i18n("Try a different search or refresh the catalog")) - .build(); - list_box.append(&empty_row); - return; - } + left_arrow.set_sensitive(page > 0); + right_arrow.set_sensitive(page < max_page); - let toast_ref = toast_overlay.clone(); - let db_ref = db.clone(); + // Build a new page container with equal-width cards + let page_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .homogeneous(true) + .hexpand(true) + .build(); - // Get installed app names for matching - let installed_names: std::collections::HashSet = db - .get_all_appimages() - .unwrap_or_default() - .iter() - .filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase())) - .collect(); + for app in &apps[start..end] { + let tile = catalog_tile::build_featured_tile(app); - for app in &results { - let row = adw::ActionRow::builder() - .title(&app.name) - .activatable(true) - .build(); + // Load screenshot asynchronously into the frame + if let Some(ref screenshots_str) = app.screenshots { + let first_screenshot = screenshots_str.split(';') + .find(|s| !s.is_empty()); + if let Some(screenshot_path) = first_screenshot { + let app_name = app.name.clone(); + let spath = screenshot_path.to_string(); + let frame = tile.first_child() + .and_then(|w| w.downcast::().ok()); + if let Some(frame) = frame { + let frame_ref = frame.clone(); + glib::spawn_future_local(async move { + let name = app_name.clone(); + let sp = spath.clone(); + let result = gio::spawn_blocking(move || { + catalog::cache_screenshot(&name, &sp, 0) + .map_err(|e| e.to_string()) + }).await; - if let Some(ref desc) = app.description { - let snippet: String = desc.chars().take(80).collect(); - let subtitle = if snippet.len() < desc.len() { - format!("{}...", snippet.trim_end()) - } else { - snippet - }; - row.set_subtitle(&subtitle); - } - - // Category badges - if let Some(ref cats) = app.categories { - let first_cat: String = cats.split(';').next().unwrap_or("").to_string(); - if !first_cat.is_empty() { - let badge = widgets::status_badge(&first_cat, "neutral"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); + match result { + Ok(Ok(local_path)) => { + if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) { + let picture = gtk::Picture::builder() + .paintable(&texture) + .content_fit(gtk::ContentFit::Cover) + .halign(gtk::Align::Fill) + .valign(gtk::Align::Fill) + .build(); + frame_ref.set_child(Some(&picture)); + } + } + _ => { + let icon = gtk::Image::builder() + .icon_name("image-missing-symbolic") + .pixel_size(32) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .css_classes(["dim-label"]) + .build(); + frame_ref.set_child(Some(&icon)); + } + } + }); + } } } - // Show "Installed" badge if already installed, otherwise show Install button - let is_installed = installed_names.contains(&app.name.to_lowercase()); - if is_installed { - let installed_badge = widgets::status_badge(&i18n("Installed"), "success"); - installed_badge.set_valign(gtk::Align::Center); - row.add_suffix(&installed_badge); - list_box.append(&row); - continue; - } - - // Install button - let install_btn = gtk::Button::builder() - .label(&i18n("Install")) - .valign(gtk::Align::Center) - .css_classes(["suggested-action"]) - .build(); - - let download_url = app.download_url.clone(); - let app_name = app.name.clone(); - let homepage = app.homepage.clone(); - let toast_install = toast_ref.clone(); - let db_install = db_ref.clone(); - - install_btn.connect_clicked(move |btn| { - btn.set_sensitive(false); - btn.set_label("Installing..."); - - let url = download_url.clone(); - let name = app_name.clone(); - let hp = homepage.clone(); - let toast_c = toast_install.clone(); - let btn_c = btn.clone(); - let db_c = db_install.clone(); - - glib::spawn_future_local(async move { - let result = gio::spawn_blocking(move || { - let install_dir = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) - .join("Applications"); - std::fs::create_dir_all(&install_dir).ok(); - - let app = catalog::CatalogApp { - name: name.clone(), - description: None, - categories: Vec::new(), - latest_version: None, - download_url: url, - icon_url: None, - homepage: hp, - file_size: None, - architecture: None, - }; - - catalog::install_from_catalog(&app, &install_dir) - .map_err(|e| e.to_string()) - }).await; - - match result { - Ok(Ok(path)) => { - // Register in DB - let filename = path.file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let size = std::fs::metadata(&path) - .map(|m| m.len() as i64) - .unwrap_or(0); - db_c.upsert_appimage( - &path.to_string_lossy(), - &filename, - Some(2), - size, - true, - None, - ).ok(); - toast_c.add_toast(adw::Toast::new("Installed successfully")); - btn_c.set_label("Installed"); - } - Ok(Err(e)) => { - log::error!("Install failed: {}", e); - toast_c.add_toast(adw::Toast::new("Install failed")); - btn_c.set_sensitive(true); - btn_c.set_label("Install"); - } - Err(_) => { - log::error!("Install thread panicked"); - toast_c.add_toast(adw::Toast::new("Install failed")); - btn_c.set_sensitive(true); - btn_c.set_label("Install"); - } - } - }); + // Click handler + let db_ref = db.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); + let app_id = app.id; + let click = gtk::GestureClick::new(); + click.connect_released(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if let Ok(Some(catalog_app)) = db_ref.get_catalog_app(app_id) { + let detail = catalog_detail::build_catalog_detail_page( + &catalog_app, &db_ref, &toast_ref, + ); + nav_ref.push(&detail); + } }); + tile.add_controller(click); - row.add_suffix(&install_btn); - list_box.append(&row); + page_box.append(&tile); + } + + // Crossfade: alternate between "page-a" and "page-b" + let current = flip.get(); + let new_name = if current { "page-a" } else { "page-b" }; + flip.set(!current); + + // Remove stale child with this name (from 2 transitions ago) + if let Some(old) = stack.child_by_name(new_name) { + stack.remove(&old); + } + + stack.add_named(&page_box, Some(new_name)); + stack.set_visible_child_name(new_name); +} + +/// Populate the main grid with catalog tiles. +fn populate_grid( + db: &Rc, + query: &str, + category: Option<&str>, + flow_box: >k::FlowBox, + all_label: >k::Label, + _nav_view: &adw::NavigationView, + _toast_overlay: &adw::ToastOverlay, +) { + // Clear existing + while let Some(child) = flow_box.first_child() { + flow_box.remove(&child); + } + + let results = db.search_catalog(query, category, 200).unwrap_or_default(); + + if results.is_empty() { + all_label.set_label(&i18n("No results")); + return; + } + + let label_text = if query.is_empty() && category.is_none() { + format!("{} ({})", i18n("All Apps"), results.len()) + } else { + format!("{} ({})", i18n("Results"), results.len()) + }; + all_label.set_label(&label_text); + + for app in &results { + let tile = catalog_tile::build_catalog_tile(app); + // Store the app ID in the widget name for retrieval on click + tile.set_widget_name(&format!("catalog-app-{}", app.id)); + flow_box.append(&tile); } } fn populate_categories( db: &Rc, - category_box: >k::Box, + category_box: >k::FlowBox, active_category: &Rc>>, - results_box: >k::ListBox, + flow_box: >k::FlowBox, search_entry: >k::SearchEntry, + featured_section: >k::Box, + all_label: >k::Label, + nav_view: &adw::NavigationView, + toast_overlay: &adw::ToastOverlay, ) { // Clear existing while let Some(child) = category_box.first_child() { @@ -410,55 +732,62 @@ fn populate_categories( return; } - // "All" chip let all_btn = gtk::ToggleButton::builder() .label(&i18n("All")) .active(true) .css_classes(["pill"]) .build(); - category_box.append(&all_btn); + category_box.insert(&all_btn, -1); - // Top 10 category chips - let buttons: Rc>> = Rc::new(RefCell::new(vec![all_btn.clone()])); + let buttons: Rc>> = + Rc::new(RefCell::new(vec![all_btn.clone()])); for (cat, _count) in categories.iter().take(10) { let btn = gtk::ToggleButton::builder() .label(cat) .css_classes(["pill"]) .build(); - category_box.append(&btn); + category_box.insert(&btn, -1); buttons.borrow_mut().push(btn.clone()); let cat_str = cat.clone(); let active_ref = active_category.clone(); - let results_ref = results_box.clone(); + let flow_ref = flow_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); let buttons_ref = buttons.clone(); + let featured_section_ref = featured_section.clone(); + let all_label_ref = all_label.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); btn.connect_toggled(move |btn| { if btn.is_active() { - // Deactivate others for other in buttons_ref.borrow().iter() { if other != btn { other.set_active(false); } } *active_ref.borrow_mut() = Some(cat_str.clone()); + featured_section_ref.set_visible(false); let query = search_ref.text().to_string(); - // Use a dummy toast overlay for filtering - let toast = adw::ToastOverlay::new(); - populate_results(&db_ref, &query, Some(&cat_str), &results_ref, &toast); + populate_grid( + &db_ref, &query, Some(&cat_str), &flow_ref, + &all_label_ref, &nav_ref, &toast_ref, + ); } }); } - // "All" button handler { let active_ref = active_category.clone(); - let results_ref = results_box.clone(); + let flow_ref = flow_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); let buttons_ref = buttons.clone(); + let featured_section_ref = featured_section.clone(); + let all_label_ref = all_label.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); all_btn.connect_toggled(move |btn| { if btn.is_active() { for other in buttons_ref.borrow().iter() { @@ -467,9 +796,12 @@ fn populate_categories( } } *active_ref.borrow_mut() = None; + featured_section_ref.set_visible(true); let query = search_ref.text().to_string(); - let toast = adw::ToastOverlay::new(); - populate_results(&db_ref, &query, None, &results_ref, &toast); + populate_grid( + &db_ref, &query, None, &flow_ref, + &all_label_ref, &nav_ref, &toast_ref, + ); } }); } diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 407ca35..03e647b 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -2384,7 +2384,7 @@ fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> { /// Show a screenshot in a fullscreen lightbox window with prev/next navigation. /// Uses a separate gtk::Window to avoid parent scroll position interference. -fn show_screenshot_lightbox( +pub fn show_screenshot_lightbox( parent: >k::Window, textures: &Rc>>>, initial_index: usize, @@ -2607,12 +2607,23 @@ fn fetch_favicon_async(url: &str, image: >k::Image) { }); } -fn show_uninstall_dialog( +pub fn show_uninstall_dialog( toast_overlay: &adw::ToastOverlay, record: &AppImageRecord, db: &Rc, is_integrated: bool, data_paths: &[(String, String, u64)], +) { + show_uninstall_dialog_with_callback(toast_overlay, record, db, is_integrated, data_paths, None); +} + +pub fn show_uninstall_dialog_with_callback( + toast_overlay: &adw::ToastOverlay, + record: &AppImageRecord, + db: &Rc, + is_integrated: bool, + data_paths: &[(String, String, u64)], + on_complete: Option>, ) { let name = record.app_name.as_deref().unwrap_or(&record.filename); let dialog = adw::AlertDialog::builder() @@ -2667,6 +2678,7 @@ fn show_uninstall_dialog( let record_path = record.path.clone(); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); + let on_complete = std::cell::Cell::new(on_complete); dialog.connect_response(Some("uninstall"), move |_dlg, _response| { // Remove integration if checked if let Some(ref check) = integration_check { @@ -2700,6 +2712,11 @@ fn show_uninstall_dialog( toast_ref.add_toast(adw::Toast::new("AppImage uninstalled")); + // Run the completion callback if provided + if let Some(cb) = on_complete.take() { + cb(); + } + // Navigate back (the detail view is now stale) if let Some(nav) = toast_ref.ancestor(adw::NavigationView::static_type()) { let nav: adw::NavigationView = nav.downcast().unwrap(); diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index 70095f0..e273b1f 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -589,13 +589,13 @@ impl LibraryView { // Grid card let card = app_card::build_app_card(record); let card_menu = build_context_menu(record); - attach_context_menu(&card, &card_menu); + attach_context_menu(&card, &card_menu, record.id); self.flow_box.append(&card); // List row let row = self.build_list_row(record); let row_menu = build_context_menu(record); - attach_context_menu(&row, &row_menu); + attach_context_menu(&row, &row_menu, record.id); self.list_box.append(&row); } @@ -812,15 +812,39 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu { section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id))); menu.append_section(None, §ion4); + // Section 5: Destructive actions + let section5 = gtk::gio::Menu::new(); + let uninstall_item = gtk::gio::MenuItem::new(None, Some(&format!("win.uninstall-appimage(int64 {})", record.id))); + uninstall_item.set_attribute_value("custom", Some(&"uninstall".to_variant())); + section5.append_item(&uninstall_item); + menu.append_section(None, §ion5); + menu } /// Attach a right-click context menu to a widget. -fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::gio::Menu) { - let popover = gtk::PopoverMenu::from_model(Some(menu_model)); +fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::gio::Menu, record_id: i64) { + let popover = gtk::PopoverMenu::from_model_full(menu_model, gtk::PopoverMenuFlags::NESTED); popover.set_parent(widget.as_ref()); popover.set_has_arrow(false); + // Add custom destructive-styled uninstall button + let uninstall_btn = gtk::Button::builder() + .label("Uninstall") + .build(); + uninstall_btn.add_css_class("destructive-context-item"); + // Left-align the label to match other menu items + if let Some(label) = uninstall_btn.child().and_then(|c| c.downcast::().ok()) { + label.set_halign(gtk::Align::Start); + } + uninstall_btn.set_action_name(Some("win.uninstall-appimage")); + uninstall_btn.set_action_target_value(Some(&record_id.to_variant())); + let popover_ref = popover.clone(); + uninstall_btn.connect_clicked(move |_| { + popover_ref.popdown(); + }); + popover.add_child(&uninstall_btn, "uninstall"); + // Unparent the popover when the widget is destroyed to avoid GTK warnings let popover_cleanup = popover.clone(); widget.as_ref().connect_destroy(move |_| { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 642a686..de74de1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,7 @@ pub mod app_card; pub mod batch_update_dialog; +pub mod catalog_detail; +pub mod catalog_tile; pub mod catalog_view; pub mod cleanup_wizard; pub mod dashboard; diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs index 7202a30..a1f62c3 100644 --- a/src/ui/preferences.rs +++ b/src/ui/preferences.rs @@ -413,6 +413,44 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage { page.add(&security_group); + // Catalog Enrichment group + let enrichment_group = adw::PreferencesGroup::builder() + .title(&i18n("Catalog Enrichment")) + .description(&i18n("Fetch GitHub metadata (stars, version, downloads) for catalog apps")) + .build(); + + let auto_enrich_row = adw::SwitchRow::builder() + .title(&i18n("Auto-enrich catalog apps")) + .subtitle(&i18n("Fetch metadata from GitHub in the background")) + .active(settings.boolean("catalog-auto-enrich")) + .build(); + let settings_enrich = settings.clone(); + auto_enrich_row.connect_active_notify(move |row| { + settings_enrich.set_boolean("catalog-auto-enrich", row.is_active()).ok(); + }); + enrichment_group.add(&auto_enrich_row); + + let token_row = adw::PasswordEntryRow::builder() + .title(&i18n("GitHub token")) + .build(); + let current_token = settings.string("github-token"); + if !current_token.is_empty() { + token_row.set_text(¤t_token); + } + let settings_token = settings.clone(); + token_row.connect_changed(move |row| { + settings_token.set_string("github-token", &row.text()).ok(); + }); + enrichment_group.add(&token_row); + + let token_hint = adw::ActionRow::builder() + .title(&i18n("Optional - increases rate limit from 60 to 5,000 requests per hour")) + .css_classes(["dim-label"]) + .build(); + enrichment_group.add(&token_hint); + + page.add(&enrichment_group); + page } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index e0d14aa..b27a59c 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -105,7 +105,7 @@ pub fn format_size(bytes: i64) -> String { /// If the icon_path exists and is loadable, show the real icon. /// Otherwise, generate a colored circle with the first letter of the app name. pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget { - // Try to load from path + // Try to load from explicit path if let Some(icon_path) = icon_path { let path = std::path::Path::new(icon_path); if path.exists() { @@ -119,6 +119,20 @@ pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk } } + // Try cached catalog icon + let cache_dir = crate::core::catalog::icon_cache_dir(); + let sanitized = crate::core::catalog::sanitize_filename(app_name); + let cached_path = cache_dir.join(format!("{}.png", sanitized)); + if cached_path.exists() { + if let Ok(texture) = gtk::gdk::Texture::from_filename(&cached_path) { + let image = gtk::Image::builder() + .pixel_size(pixel_size) + .build(); + image.set_paintable(Some(&texture)); + return image.upcast(); + } + } + // Letter-circle fallback build_letter_icon(app_name, pixel_size) } @@ -383,6 +397,17 @@ pub fn relative_time(timestamp: &str) -> String { } } +/// Format a count with K/M suffixes for readability. +pub fn format_count(n: i64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}K", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + /// Create a screen-reader live region announcement. /// Inserts a hidden label with AccessibleRole::Alert into the given container, /// which causes AT-SPI to announce the text to screen readers. diff --git a/src/window.rs b/src/window.rs index bcabb88..0a39412 100644 --- a/src/window.rs +++ b/src/window.rs @@ -9,6 +9,7 @@ use crate::config::APP_ID; use crate::core::analysis; use crate::core::database::Database; use crate::core::discovery; +use crate::core::footprint; use crate::core::integrator; use crate::core::launcher; use crate::core::notification; @@ -43,6 +44,7 @@ mod imp { pub drop_overlay: OnceCell, pub drop_revealer: OnceCell, pub watcher_handle: std::cell::RefCell>, + pub enrichment_banner: OnceCell, } impl Default for DriftwoodWindow { @@ -57,6 +59,7 @@ mod imp { drop_overlay: OnceCell::new(), drop_revealer: OnceCell::new(), watcher_handle: std::cell::RefCell::new(None), + enrichment_banner: OnceCell::new(), } } } @@ -99,6 +102,22 @@ glib::wrapper! { gtk::Native, gtk::Root, gtk::ShortcutManager; } +/// Find a child widget by its widget name (breadth-first). +fn find_child_by_name(parent: &impl IsA, name: &str) -> Option { + let parent_widget = parent.upcast_ref::(); + let mut child = parent_widget.first_child(); + while let Some(c) = child { + if c.widget_name() == name { + return Some(c); + } + if let Some(found) = find_child_by_name(&c, name) { + return Some(found); + } + child = c.next_sibling(); + } + None +} + fn shortcut_row(accel: &str, description: &str) -> adw::ActionRow { let row = adw::ActionRow::builder() .title(description) @@ -166,8 +185,8 @@ impl DriftwoodWindow { let installed_nav = adw::NavigationView::new(); installed_nav.push(&library_view.page); - // Catalog view - let catalog_page = catalog_view::build_catalog_page(self.database()); + // Catalog view (has its own internal NavigationView for drill-down) + let (catalog_nav, enrichment_banner) = catalog_view::build_catalog_page(self.database()); // Updates view let updates_toolbar = updates_view::build_updates_view(self.database()); @@ -179,7 +198,7 @@ impl DriftwoodWindow { let installed_vs_page = view_stack.add_titled(&installed_nav, Some("installed"), &i18n("Installed")); installed_vs_page.set_icon_name(Some("view-grid-symbolic")); - let catalog_vs_page = view_stack.add_titled(&catalog_page, Some("catalog"), &i18n("Catalog")); + let catalog_vs_page = view_stack.add_titled(&catalog_nav, Some("catalog"), &i18n("Catalog")); catalog_vs_page.set_icon_name(Some("system-software-install-symbolic")); let updates_vs_page = view_stack.add_titled(&updates_toolbar, Some("updates"), &i18n("Updates")); @@ -481,6 +500,10 @@ impl DriftwoodWindow { if self.imp().library_view.set(library_view).is_err() { panic!("LibraryView already set"); } + self.imp() + .enrichment_banner + .set(enrichment_banner) + .expect("EnrichmentBanner already set"); // Set up window actions self.setup_window_actions(); @@ -1010,6 +1033,48 @@ impl DriftwoodWindow { } self.add_action(©_path_action); + // Uninstall action (from right-click context menu) + let uninstall_action = gio::SimpleAction::new("uninstall-appimage", param_type); + { + let window_weak = self.downgrade(); + uninstall_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let fp = footprint::get_footprint(&db, record.id, record.size_bytes as u64); + let fp_paths: Vec<(String, String, u64)> = fp.paths.iter() + .filter(|p| p.exists) + .map(|p| ( + p.path.to_string_lossy().to_string(), + p.path_type.label().to_string(), + p.size_bytes, + )) + .collect(); + let is_integrated = record.integrated; + let window_ref = window.clone(); + let db_refresh = db.clone(); + detail_view::show_uninstall_dialog_with_callback( + &toast_overlay, + &record, + &db, + is_integrated, + &fp_paths, + Some(Box::new(move || { + // Refresh the library view after uninstall + if let Some(lib_view) = window_ref.imp().library_view.get() { + if let Ok(records) = db_refresh.get_all_appimages() { + lib_view.populate(records); + } + } + })), + ); + } + }); + } + self.add_action(&uninstall_action); + // View switching actions for keyboard shortcuts let show_installed_action = gio::SimpleAction::new("show-installed", None); { @@ -1185,6 +1250,134 @@ impl DriftwoodWindow { // Update badge on Updates tab self.refresh_update_badge(); + + // Background GitHub enrichment + self.start_background_enrichment(); + } + + fn start_background_enrichment(&self) { + let settings = self.settings().clone(); + if !settings.boolean("catalog-auto-enrich") { + return; + } + + let db = self.database().clone(); + + // Check if there are unenriched apps + let (enriched, total) = db.catalog_enrichment_progress().unwrap_or((0, 0)); + if total == 0 || enriched >= total { + return; + } + + let banner = self.imp().enrichment_banner.get().cloned(); + let view_stack = self.imp().view_stack.get().cloned(); + + // Show banner initially + if let Some(ref b) = banner { + b.set_visible(true); + if let Some(label) = find_child_by_name(b, "enrich-label") { + if let Ok(l) = label.downcast::() { + l.set_label(&format!("Enriching app data from GitHub ({}/{})...", enriched, total)); + } + } + } + + // Channel for progress updates + let (tx, rx) = std::sync::mpsc::channel::<(i64, i64, bool)>(); + + let token = settings.string("github-token").to_string(); + + // Background thread: runs batch enrichment + glib::spawn_future_local(async move { + let tx_c = tx.clone(); + + gio::spawn_blocking(move || { + let bg_db = match crate::core::database::Database::open() { + Ok(db) => db, + Err(_) => return, + }; + + loop { + let result = crate::core::github_enrichment::background_enrich_batch( + &bg_db, + &token, + 20, + &|done, total| { + tx_c.send((done, total, false)).ok(); + }, + ); + + match result { + Ok((count, should_continue)) => { + if count == 0 || !should_continue { + // Done or rate limited + if let Ok((done, total)) = bg_db.catalog_enrichment_progress() { + tx_c.send((done, total, true)).ok(); + } + break; + } + } + Err(e) => { + log::warn!("Background enrichment error: {}", e); + tx_c.send((0, 0, true)).ok(); + break; + } + } + } + }).await.ok(); + }); + + // Poll progress on the UI thread. + // Timer keeps running until enrichment is truly complete (all apps enriched). + // When rate-limited, shows a paused message but keeps the banner visible. + let banner_ref = banner; + glib::timeout_add_local(std::time::Duration::from_millis(250), move || { + while let Ok((done, total, finished)) = rx.try_recv() { + if let Some(ref b) = banner_ref { + if finished { + if total == 0 || done >= total { + // Truly done - all apps enriched (or error with no data) + b.set_visible(false); + return glib::ControlFlow::Break; + } + // Rate limited or paused - keep banner visible with updated text + if let Some(spinner) = find_child_by_name(b, "enrich-spinner") { + if let Ok(s) = spinner.downcast::() { + s.set_spinning(false); + } + } + if let Some(label) = find_child_by_name(b, "enrich-label") { + if let Ok(l) = label.downcast::() { + l.set_label(&format!( + "Enriching paused - rate limit ({}/{} enriched)", done, total, + )); + } + } + } else { + // Active progress update + if let Some(label) = find_child_by_name(b, "enrich-label") { + if let Ok(l) = label.downcast::() { + l.set_label(&format!( + "Enriching app data from GitHub ({}/{})...", done, total, + )); + } + } + } + } + } + + // Show banner only when on the catalog tab + if let Some(ref vs) = view_stack { + let on_catalog = vs.visible_child_name() + .map(|n| n == "catalog") + .unwrap_or(false); + if let Some(ref b) = banner_ref { + b.set_visible(on_catalog); + } + } + + glib::ControlFlow::Continue + }); } fn trigger_scan(&self) {