Show app count with relative last-refreshed time in subtitle. Auto-refresh catalog on first visit when empty. Show "Installed" badge on catalog entries that match already-installed AppImages instead of the Install button.
477 lines
16 KiB
Rust
477 lines
16 KiB
Rust
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<Database>) -> 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");
|
|
update_catalog_subtitle(&title, 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<RefCell<Option<String>>> = 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::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())
|
|
} else {
|
|
Err("No catalog sources configured".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),
|
|
));
|
|
update_catalog_subtitle(&title_c, count as i64);
|
|
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);
|
|
|
|
// Auto-refresh on first visit when catalog is empty
|
|
if app_count == 0 {
|
|
refresh_btn.emit_clicked();
|
|
}
|
|
|
|
adw::NavigationPage::builder()
|
|
.title(&i18n("Catalog"))
|
|
.tag("catalog")
|
|
.child(&toolbar_view)
|
|
.build()
|
|
}
|
|
|
|
/// 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");
|
|
if last_refreshed.is_empty() {
|
|
title.set_subtitle(&format!("{} apps available", app_count));
|
|
} else {
|
|
let relative = widgets::relative_time(&last_refreshed);
|
|
title.set_subtitle(&format!("{} apps - Refreshed {}", app_count, relative));
|
|
}
|
|
}
|
|
|
|
fn populate_results(
|
|
db: &Rc<Database>,
|
|
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();
|
|
|
|
// Get installed app names for matching
|
|
let installed_names: std::collections::HashSet<String> = 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 &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);
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
row.add_suffix(&install_btn);
|
|
list_box.append(&row);
|
|
}
|
|
}
|
|
|
|
fn populate_categories(
|
|
db: &Rc<Database>,
|
|
category_box: >k::Box,
|
|
active_category: &Rc<RefCell<Option<String>>>,
|
|
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<RefCell<Vec<gtk::ToggleButton>>> = 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);
|
|
}
|
|
});
|
|
}
|
|
}
|