From f12e74ba2ba275a3b72352f82c6af042e33a6af7 Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 28 Feb 2026 00:23:36 +0200 Subject: [PATCH] Add AppImageHub in-app catalog browser with search, categories, and install --- data/app.driftwood.Driftwood.gschema.xml | 5 + src/core/database.rs | 268 ++++++++++++++ src/core/mod.rs | 1 + src/ui/catalog_view.rs | 442 +++++++++++++++++++++++ src/ui/mod.rs | 1 + src/window.rs | 16 + 6 files changed, 733 insertions(+) create mode 100644 src/ui/catalog_view.rs diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml index 379a2b4..b5a1908 100644 --- a/data/app.driftwood.Driftwood.gschema.xml +++ b/data/app.driftwood.Driftwood.gschema.xml @@ -124,6 +124,11 @@ Security notification threshold Minimum CVE severity for desktop notifications: critical, high, medium, or low. + + '' + Catalog last refreshed + ISO timestamp of the last catalog refresh. + false Watch removable media diff --git a/src/core/database.rs b/src/core/database.rs index 79f7ce3..a24591b 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -88,6 +88,42 @@ pub struct SystemModification { pub previous_value: Option, } +#[derive(Debug, Clone)] +pub struct CatalogApp { + pub id: i64, + pub name: String, + pub description: Option, + pub categories: Option, + pub download_url: String, + pub icon_url: Option, + pub homepage: Option, + pub license: Option, +} + +#[derive(Debug, Clone)] +pub struct CatalogSourceRecord { + pub id: i64, + pub name: String, + pub url: String, + pub source_type: String, + pub enabled: bool, + pub last_synced: Option, + pub app_count: i32, +} + +#[derive(Debug, Clone)] +pub struct CatalogAppRecord { + pub name: String, + pub description: Option, + pub categories: Option, + pub latest_version: Option, + pub download_url: String, + pub icon_url: Option, + pub homepage: Option, + pub file_size: Option, + pub architecture: Option, +} + #[derive(Debug, Clone)] pub struct OrphanedEntry { pub id: i64, @@ -641,6 +677,8 @@ impl Database { CREATE INDEX IF NOT EXISTS idx_catalog_apps_source ON catalog_apps(source_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_apps_source_name + ON catalog_apps(source_id, name); CREATE INDEX IF NOT EXISTS idx_sandbox_profiles_app ON sandbox_profiles(app_name); CREATE INDEX IF NOT EXISTS idx_runtime_updates_appimage @@ -1954,6 +1992,236 @@ impl Database { self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?; Ok(()) } + + // --- Catalog methods --- + + pub fn upsert_catalog_source( + &self, + name: &str, + url: &str, + source_type: &str, + ) -> SqlResult { + self.conn.execute( + "INSERT INTO catalog_sources (name, url, source_type, last_synced) + VALUES (?1, ?2, ?3, datetime('now')) + ON CONFLICT(url) DO UPDATE SET last_synced = datetime('now')", + params![name, url, source_type], + )?; + self.conn.query_row( + "SELECT id FROM catalog_sources WHERE url = ?1", + params![url], + |row| row.get(0), + ) + } + + pub fn upsert_catalog_app( + &self, + source_id: i64, + name: &str, + description: Option<&str>, + categories: Option<&str>, + download_url: &str, + icon_url: Option<&str>, + homepage: Option<&str>, + license: Option<&str>, + ) -> SqlResult { + self.conn.execute( + "INSERT INTO catalog_apps (source_id, name, description, categories, download_url, icon_url, homepage, cached_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now')) + ON CONFLICT(source_id, name) DO UPDATE SET + description = COALESCE(?3, description), + categories = COALESCE(?4, categories), + download_url = ?5, + icon_url = COALESCE(?6, icon_url), + homepage = COALESCE(?7, homepage), + cached_at = datetime('now')", + params![source_id, name, description, categories, download_url, icon_url, homepage], + )?; + // Store license as architecture field (reusing available column) + // until a proper column is added + if let Some(lic) = license { + self.conn.execute( + "UPDATE catalog_apps SET architecture = ?1 WHERE source_id = ?2 AND name = ?3", + params![lic, source_id, name], + )?; + } + self.conn.query_row( + "SELECT id FROM catalog_apps WHERE source_id = ?1 AND name = ?2", + params![source_id, name], + |row| row.get(0), + ) + } + + pub fn search_catalog( + &self, + query: &str, + category: Option<&str>, + limit: i32, + ) -> SqlResult> { + let mut sql = String::from( + "SELECT id, name, description, categories, download_url, icon_url, homepage, architecture + FROM catalog_apps WHERE 1=1" + ); + let mut params_list: Vec> = Vec::new(); + + if !query.is_empty() { + sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1)"); + params_list.push(Box::new(format!("%{}%", query))); + } + + if let Some(cat) = category { + let idx = params_list.len() + 1; + sql.push_str(&format!(" AND categories LIKE ?{}", idx)); + params_list.push(Box::new(format!("%{}%", cat))); + } + + sql.push_str(&format!(" ORDER BY name LIMIT {}", limit)); + + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + params_list.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map(params_refs.as_slice(), |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)?, + }) + })?; + + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + 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 + FROM catalog_apps WHERE id = ?1", + params![id], + |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)?, + }) + }, + ); + match result { + Ok(app) => Ok(Some(app)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + 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 != ''" + )?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + + let mut counts = std::collections::HashMap::new(); + for row in rows { + if let Ok(cats_str) = row { + for cat in cats_str.split(';').filter(|s| !s.is_empty()) { + *counts.entry(cat.to_string()).or_insert(0u32) += 1; + } + } + } + + let mut result: Vec<(String, u32)> = counts.into_iter().collect(); + result.sort_by(|a, b| b.1.cmp(&a.1)); + Ok(result) + } + + pub fn catalog_app_count(&self) -> SqlResult { + self.conn.query_row("SELECT COUNT(*) FROM catalog_apps", [], |row| row.get(0)) + } + + pub fn insert_catalog_app( + &self, + source_id: i64, + name: &str, + description: Option<&str>, + categories: Option<&str>, + latest_version: Option<&str>, + download_url: &str, + icon_url: Option<&str>, + homepage: Option<&str>, + file_size: Option, + architecture: 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], + )?; + Ok(()) + } + + pub fn search_catalog_apps(&self, query: &str) -> SqlResult> { + let pattern = format!("%{}%", query); + let mut stmt = self.conn.prepare( + "SELECT name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture + FROM catalog_apps + WHERE name LIKE ?1 OR description LIKE ?1 + ORDER BY name + LIMIT 50" + )?; + let rows = stmt.query_map(params![pattern], |row| { + Ok(CatalogAppRecord { + name: row.get(0)?, + description: row.get(1)?, + categories: row.get(2)?, + latest_version: row.get(3)?, + download_url: row.get(4)?, + icon_url: row.get(5)?, + homepage: row.get(6)?, + file_size: row.get(7)?, + architecture: row.get(8)?, + }) + })?; + rows.collect() + } + + pub fn update_catalog_source_sync(&self, source_id: i64, app_count: i32) -> SqlResult<()> { + self.conn.execute( + "UPDATE catalog_sources SET last_synced = datetime('now'), app_count = ?2 WHERE id = ?1", + params![source_id, app_count], + )?; + Ok(()) + } + + pub fn get_catalog_sources(&self) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, name, url, source_type, enabled, last_synced, app_count FROM catalog_sources ORDER BY name" + )?; + let rows = stmt.query_map([], |row| { + Ok(CatalogSourceRecord { + id: row.get(0)?, + name: row.get(1)?, + url: row.get(2)?, + source_type: row.get(3)?, + enabled: row.get::<_, i32>(4).unwrap_or(1) != 0, + last_synced: row.get(5)?, + app_count: row.get(6)?, + }) + })?; + rows.collect() + } } #[cfg(test)] diff --git a/src/core/mod.rs b/src/core/mod.rs index 56e97b3..2f35e98 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,6 +1,7 @@ pub mod analysis; pub mod appstream; pub mod backup; +pub mod catalog; pub mod database; pub mod discovery; pub mod duplicates; diff --git a/src/ui/catalog_view.rs b/src/ui/catalog_view.rs new file mode 100644 index 0000000..d1529a4 --- /dev/null +++ b/src/ui/catalog_view.rs @@ -0,0 +1,442 @@ +use adw::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; + +use gtk::gio; + +use crate::core::catalog; +use crate::core::database::Database; +use crate::i18n::i18n; +use super::widgets; + +/// Build the catalog browser page. +pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { + let toast_overlay = adw::ToastOverlay::new(); + + let header = adw::HeaderBar::new(); + let title = adw::WindowTitle::builder() + .title(&i18n("App Catalog")) + .subtitle(&i18n("Browse available AppImages")) + .build(); + header.set_title_widget(Some(&title)); + + // Search entry + let search_entry = gtk::SearchEntry::builder() + .placeholder_text(&i18n("Search apps...")) + .hexpand(true) + .build(); + + let search_bar = gtk::SearchBar::builder() + .child(&search_entry) + .search_mode_enabled(true) + .build(); + + // Category filter + let category_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_start(18) + .margin_end(18) + .build(); + + let category_scroll = gtk::ScrolledWindow::builder() + .child(&category_box) + .vscrollbar_policy(gtk::PolicyType::Never) + .hscrollbar_policy(gtk::PolicyType::Automatic) + .max_content_height(40) + .build(); + + // Results list + let results_box = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .margin_start(18) + .margin_end(18) + .margin_top(12) + .margin_bottom(24) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .build(); + + content.append(&search_bar); + content.append(&category_scroll); + content.append(&results_box); + clamp.set_child(Some(&content)); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&clamp) + .vexpand(true) + .build(); + + // Status page for empty state + let empty_page = adw::StatusPage::builder() + .icon_name("system-software-install-symbolic") + .title(&i18n("App Catalog")) + .description(&i18n("Refresh the catalog to browse available AppImages")) + .build(); + + let refresh_btn = gtk::Button::builder() + .label(&i18n("Refresh Catalog")) + .halign(gtk::Align::Center) + .css_classes(["suggested-action", "pill"]) + .build(); + empty_page.set_child(Some(&refresh_btn)); + + let stack = gtk::Stack::new(); + 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"); + title.set_subtitle(&format!("{} apps available", app_count)); + } else { + stack.set_visible_child_name("empty"); + } + + toast_overlay.set_child(Some(&stack)); + + let toolbar_view = adw::ToolbarView::new(); + toolbar_view.add_top_bar(&header); + toolbar_view.set_content(Some(&toast_overlay)); + + // Refresh button in header + let refresh_header_btn = gtk::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text(&i18n("Refresh catalog")) + .build(); + header.pack_end(&refresh_header_btn); + + // Category chips state + let active_category: Rc>> = Rc::new(RefCell::new(None)); + + // Populate categories + populate_categories(db, &category_box, &active_category, &results_box, &search_entry); + + // Initial population + populate_results(db, "", None, &results_box, &toast_overlay); + + // Search handler + { + let db_ref = db.clone(); + let results_ref = results_box.clone(); + let toast_ref = toast_overlay.clone(); + let cat_ref = active_category.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); + }); + } + + // Refresh handler (both buttons) + let wire_refresh = |btn: >k::Button| { + let db_ref = db.clone(); + let stack_ref = stack.clone(); + let results_ref = results_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(); + + 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 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(); + + glib::spawn_future_local(async move { + let db_bg = Database::open().ok(); + let result = gio::spawn_blocking(move || { + if let Some(ref db) = db_bg { + catalog::sync_catalog(db, &catalog::CatalogSource { + id: Some(1), + name: "AppImageHub".to_string(), + url: "https://appimage.github.io/feed.json".to_string(), + source_type: catalog::CatalogType::AppImageHub, + enabled: true, + last_synced: None, + app_count: 0, + }).map_err(|e| e.to_string()) + } else { + Err("Failed to open database".to_string()) + } + }).await; + + btn_c.set_sensitive(true); + + match result { + Ok(Ok(count)) => { + toast_c.add_toast(adw::Toast::new( + &format!("Catalog refreshed: {} apps", count), + )); + title_c.set_subtitle(&format!("{} apps available", count)); + 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); + + // Store refresh timestamp + let settings = gio::Settings::new(crate::config::APP_ID); + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + settings.set_string("catalog-last-refreshed", &now).ok(); + } + Ok(Err(e)) => { + log::error!("Catalog refresh failed: {}", e); + toast_c.add_toast(adw::Toast::new("Catalog refresh failed")); + } + Err(_) => { + log::error!("Catalog refresh thread panicked"); + toast_c.add_toast(adw::Toast::new("Catalog refresh failed")); + } + } + }); + }); + }; + + wire_refresh(&refresh_btn); + wire_refresh(&refresh_header_btn); + + adw::NavigationPage::builder() + .title(&i18n("Catalog")) + .tag("catalog") + .child(&toolbar_view) + .build() +} + +fn populate_results( + db: &Rc, + query: &str, + category: Option<&str>, + list_box: >k::ListBox, + toast_overlay: &adw::ToastOverlay, +) { + // Clear existing + while let Some(row) = list_box.row_at_index(0) { + list_box.remove(&row); + } + + let results = db.search_catalog(query, category, 50).unwrap_or_default(); + + 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; + } + + let toast_ref = toast_overlay.clone(); + let db_ref = db.clone(); + + for app in &results { + let row = adw::ActionRow::builder() + .title(&app.name) + .activatable(true) + .build(); + + 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); + } + } + + // 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"); + } + } + }); + }); + + row.add_suffix(&install_btn); + list_box.append(&row); + } +} + +fn populate_categories( + db: &Rc, + category_box: >k::Box, + active_category: &Rc>>, + results_box: >k::ListBox, + search_entry: >k::SearchEntry, +) { + // Clear existing + while let Some(child) = category_box.first_child() { + category_box.remove(&child); + } + + let categories = db.get_catalog_categories().unwrap_or_default(); + if categories.is_empty() { + return; + } + + // "All" chip + let all_btn = gtk::ToggleButton::builder() + .label(&i18n("All")) + .active(true) + .css_classes(["pill"]) + .build(); + category_box.append(&all_btn); + + // Top 10 category chips + 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); + buttons.borrow_mut().push(btn.clone()); + + let cat_str = cat.clone(); + let active_ref = active_category.clone(); + let results_ref = results_box.clone(); + let search_ref = search_entry.clone(); + let db_ref = db.clone(); + let buttons_ref = buttons.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()); + 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); + } + }); + } + + // "All" button handler + { + let active_ref = active_category.clone(); + let results_ref = results_box.clone(); + let search_ref = search_entry.clone(); + let db_ref = db.clone(); + let buttons_ref = buttons.clone(); + all_btn.connect_toggled(move |btn| { + if btn.is_active() { + for other in buttons_ref.borrow().iter() { + if other != btn { + other.set_active(false); + } + } + *active_ref.borrow_mut() = None; + let query = search_ref.text().to_string(); + let toast = adw::ToastOverlay::new(); + populate_results(&db_ref, &query, None, &results_ref, &toast); + } + }); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 22bce26..0a37497 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod app_card; pub mod batch_update_dialog; +pub mod catalog_view; pub mod cleanup_wizard; pub mod dashboard; pub mod detail_view; diff --git a/src/window.rs b/src/window.rs index f348270..9c56b45 100644 --- a/src/window.rs +++ b/src/window.rs @@ -17,6 +17,7 @@ use crate::core::security; use crate::core::updater; use crate::core::watcher; use crate::i18n::{i18n, ni18n_f}; +use crate::ui::catalog_view; use crate::ui::cleanup_wizard; use crate::ui::dashboard; use crate::ui::detail_view; @@ -150,6 +151,7 @@ impl DriftwoodWindow { section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates")); section2.append(Some(&i18n("Security Report")), Some("win.security-report")); section2.append(Some(&i18n("Disk Cleanup")), Some("win.cleanup")); + section2.append(Some(&i18n("Browse Catalog")), Some("win.catalog")); menu.append_section(None, §ion2); let section3 = gio::Menu::new(); @@ -566,6 +568,16 @@ impl DriftwoodWindow { }) .build(); + // Catalog browser action + let catalog_action = gio::ActionEntry::builder("catalog") + .activate(|window: &Self, _, _| { + let db = window.database().clone(); + let catalog_page = catalog_view::build_catalog_page(&db); + let nav = window.imp().navigation_view.get().unwrap(); + nav.push(&catalog_page); + }) + .build(); + // Show keyboard shortcuts dialog let shortcuts_action = gio::ActionEntry::builder("show-shortcuts") .activate(|window: &Self, _, _| { @@ -595,6 +607,7 @@ impl DriftwoodWindow { find_duplicates_action, security_report_action, cleanup_action, + catalog_action, shortcuts_action, show_drop_hint_action, ]); @@ -929,6 +942,9 @@ impl DriftwoodWindow { let db = self.database(); let library_view = self.imp().library_view.get().unwrap(); + // Ensure default catalog sources exist + crate::core::catalog::ensure_default_sources(db); + match db.get_all_appimages() { Ok(records) if !records.is_empty() => { library_view.populate(records);