1388 lines
51 KiB
Rust
1388 lines
51 KiB
Rust
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::fuse;
|
|
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<Database>,
|
|
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 (prefer OCS personid, then homepage URL domain)
|
|
let author_text = app.ocs_personid.as_deref()
|
|
.filter(|p| !p.is_empty())
|
|
.map(|p| p.to_string())
|
|
.or_else(|| app.homepage.as_deref().map(|h| extract_author(h)));
|
|
if let Some(author) = author_text {
|
|
let author_label = gtk::Label::builder()
|
|
.label(&format!("by {}", author))
|
|
.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 (map name -> record id for launching)
|
|
let installed_map: std::collections::HashMap<String, i64> = db
|
|
.get_all_appimages()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|r| r.app_name.as_ref().map(|n| (n.to_lowercase(), r.id)))
|
|
.collect();
|
|
let installed_record_id = installed_map.get(&app.name.to_lowercase()).copied();
|
|
let is_installed = installed_record_id.is_some();
|
|
|
|
let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
|
|
// Shared OCS download files, populated by async fetch
|
|
let ocs_files_shared: Rc<RefCell<Vec<catalog::OcsDownloadFile>>> =
|
|
Rc::new(RefCell::new(Vec::new()));
|
|
|
|
// Whether we need to load OCS download files
|
|
let has_ocs = app.ocs_id.is_some();
|
|
let awaiting_ocs = has_ocs && !is_installed;
|
|
|
|
if is_installed {
|
|
// Show Launch button for installed apps
|
|
if let Some(record_id) = installed_record_id {
|
|
let launch_btn = gtk::Button::builder()
|
|
.label(&i18n("Launch"))
|
|
.css_classes(["suggested-action", "pill"])
|
|
.build();
|
|
launch_btn.set_action_name(Some("win.launch-appimage"));
|
|
launch_btn.set_action_target_value(Some(&record_id.to_variant()));
|
|
widgets::set_pointer_cursor(&launch_btn);
|
|
button_box.append(&launch_btn);
|
|
}
|
|
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 || awaiting_ocs {
|
|
// 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(),
|
|
app.ocs_id,
|
|
&[],
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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::<gtk::Window>();
|
|
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
|
|
});
|
|
button_box.append(&homepage_btn);
|
|
}
|
|
|
|
info_box.append(&button_box);
|
|
|
|
// "What you'll get" info and compatibility check for install
|
|
if !is_installed {
|
|
let size_hint = app.ocs_downloadsize.filter(|&s| s > 0)
|
|
.map(|s| format!(" ({})", widgets::format_size(s)))
|
|
.unwrap_or_default();
|
|
let install_info = gtk::Label::builder()
|
|
.label(&format!(
|
|
"Downloads to ~/Applications and adds to your app launcher{}",
|
|
size_hint
|
|
))
|
|
.css_classes(["caption", "dim-label"])
|
|
.wrap(true)
|
|
.xalign(0.0)
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
info_box.append(&install_info);
|
|
|
|
// System compatibility check
|
|
let fuse_info = fuse::detect_system_fuse();
|
|
let (compat_text, compat_class) = if fuse_info.status.is_functional() {
|
|
("Works with your system", "success")
|
|
} else {
|
|
("May need additional setup to run", "warning")
|
|
};
|
|
let compat_badge = widgets::status_badge(compat_text, compat_class);
|
|
compat_badge.set_halign(gtk::Align::Start);
|
|
compat_badge.set_margin_top(2);
|
|
info_box.append(&compat_badge);
|
|
}
|
|
|
|
header_box.append(&info_box);
|
|
content.append(&header_box);
|
|
|
|
// --- Stat cards row (between header and screenshots) ---
|
|
// Prefer OCS data where available, fall back to GitHub data
|
|
let display_downloads = app.ocs_downloads.filter(|&d| d > 0)
|
|
.or(app.github_downloads.filter(|&d| d > 0));
|
|
let display_version = app.ocs_version.as_deref()
|
|
.filter(|v| !v.is_empty())
|
|
.or(app.latest_version.as_deref());
|
|
let has_ocs = app.ocs_id.is_some();
|
|
let has_stats = has_github || has_ocs;
|
|
|
|
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(display_version.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(display_downloads.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_stats {
|
|
let stats_row = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(10)
|
|
.homogeneous(true)
|
|
.build();
|
|
|
|
// Show stars card if we have GitHub data, or score if we have OCS data
|
|
if has_github {
|
|
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);
|
|
} else if let Some(score) = app.ocs_score.filter(|&s| s > 0) {
|
|
let score_label = gtk::Label::builder()
|
|
.label(&format!("{}%", score))
|
|
.css_classes(["stat-value"])
|
|
.xalign(0.0)
|
|
.build();
|
|
let score_card = build_stat_card("starred-symbolic", &score_label, &i18n("Score"));
|
|
score_card.add_css_class("stat-stars");
|
|
stats_row.append(&score_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);
|
|
|
|
if has_github {
|
|
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);
|
|
} else if let Some(ref changed) = app.ocs_changed {
|
|
if !changed.is_empty() {
|
|
let changed_label = gtk::Label::builder()
|
|
.label(&widgets::relative_time(changed))
|
|
.css_classes(["stat-value"])
|
|
.xalign(0.0)
|
|
.build();
|
|
let changed_card = build_stat_card("month-symbolic", &changed_label, &i18n("Updated"));
|
|
changed_card.add_css_class("stat-released");
|
|
stats_row.append(&changed_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 && needs_enrichment {
|
|
content.append(&enrich_spinner);
|
|
}
|
|
|
|
// --- About section container (created early so enrichment callback can update it) ---
|
|
let about_container = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(0)
|
|
.build();
|
|
{
|
|
// Prefer long description for detail page (ocs_description is full HTML with
|
|
// features, changelog etc.; ocs_summary and description are short one-liners)
|
|
let display_desc = app.ocs_description.as_deref()
|
|
.filter(|d| !d.is_empty())
|
|
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
|
|
.or(app.description.as_deref().filter(|d| !d.is_empty()));
|
|
if let Some(desc) = display_desc {
|
|
let about_group = adw::PreferencesGroup::builder()
|
|
.title(&i18n("About"))
|
|
.build();
|
|
let plain_desc = catalog_tile::html_to_description(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);
|
|
about_container.append(&about_group);
|
|
}
|
|
}
|
|
|
|
// --- README section container (created early so enrichment callback can update it) ---
|
|
let readme_container = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(8)
|
|
.build();
|
|
if let Some(ref readme) = app.github_readme {
|
|
if !readme.is_empty() {
|
|
let readme_label = gtk::Label::builder()
|
|
.label(&i18n("README"))
|
|
.css_classes(["title-2"])
|
|
.xalign(0.0)
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
readme_container.append(&readme_label);
|
|
let rendered = widgets::build_markdown_view(readme);
|
|
readme_container.append(&rendered);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
let about_ref = about_container.clone();
|
|
let readme_ref = readme_container.clone();
|
|
let ocs_files_ref = ocs_files_shared.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,
|
|
);
|
|
let _ = github_enrichment::enrich_app_readme(
|
|
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 {
|
|
let files = ocs_files_ref.borrow();
|
|
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(),
|
|
updated.ocs_id,
|
|
&files,
|
|
);
|
|
}
|
|
|
|
// Update about description if GitHub description is now available
|
|
if let Some(ref desc) = updated.github_description {
|
|
if !desc.is_empty() && about_ref.first_child().is_none() {
|
|
let about_group = adw::PreferencesGroup::builder()
|
|
.title(&i18n("About"))
|
|
.build();
|
|
let desc_label = gtk::Label::builder()
|
|
.label(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);
|
|
about_ref.append(&about_group);
|
|
}
|
|
}
|
|
|
|
// Populate README if now available
|
|
if let Some(ref readme) = updated.github_readme {
|
|
if !readme.is_empty() && readme_ref.first_child().is_none() {
|
|
let readme_heading = gtk::Label::builder()
|
|
.label(&i18n("README"))
|
|
.css_classes(["title-2"])
|
|
.xalign(0.0)
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
readme_ref.append(&readme_heading);
|
|
let rendered = widgets::build_markdown_view(readme);
|
|
readme_ref.append(&rendered);
|
|
}
|
|
}
|
|
}
|
|
|
|
if result.is_err() {
|
|
log::warn!("On-demand enrichment thread error");
|
|
}
|
|
});
|
|
}
|
|
|
|
// On-demand OCS download files fetch: get all available versions for the install dropdown
|
|
if awaiting_ocs {
|
|
let ocs_id_val = app.ocs_id.unwrap();
|
|
let install_slot_ocs = install_slot.clone();
|
|
let toast_ocs = toast_overlay.clone();
|
|
let db_ocs = db.clone();
|
|
let app_name_ocs = app.name.clone();
|
|
let dl_url_ocs = app.download_url.clone();
|
|
let gh_dl_url = app.github_download_url.clone();
|
|
let gh_assets = app.github_release_assets.clone();
|
|
let hp_ocs = app.homepage.clone();
|
|
let ocs_id_opt = app.ocs_id;
|
|
let ocs_files_ocs = ocs_files_shared.clone();
|
|
|
|
glib::spawn_future_local(async move {
|
|
let id = ocs_id_val;
|
|
let result = gio::spawn_blocking(move || {
|
|
catalog::fetch_ocs_download_files(id)
|
|
}).await;
|
|
|
|
match result {
|
|
Ok(Ok(files)) => {
|
|
*ocs_files_ocs.borrow_mut() = files.clone();
|
|
populate_install_slot(
|
|
&install_slot_ocs,
|
|
&app_name_ocs,
|
|
&dl_url_ocs,
|
|
gh_dl_url.as_deref(),
|
|
gh_assets.as_deref(),
|
|
&toast_ocs,
|
|
&db_ocs,
|
|
hp_ocs.as_deref(),
|
|
ocs_id_opt,
|
|
&files,
|
|
);
|
|
}
|
|
_ => {
|
|
// Fetch failed - populate with basic button (no OCS file data)
|
|
populate_install_slot(
|
|
&install_slot_ocs,
|
|
&app_name_ocs,
|
|
&dl_url_ocs,
|
|
gh_dl_url.as_deref(),
|
|
gh_assets.as_deref(),
|
|
&toast_ocs,
|
|
&db_ocs,
|
|
hp_ocs.as_deref(),
|
|
ocs_id_opt,
|
|
&[],
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Screenshots section (paged carousel with arrows, click for lightbox) ---
|
|
// Use screenshots field (populated from either OCS preview pics or AppImageHub)
|
|
// Fall back to ocs_preview_url if screenshots is empty
|
|
let screenshots_source = app.screenshots.as_deref()
|
|
.filter(|s| !s.is_empty())
|
|
.or(app.ocs_preview_url.as_deref().filter(|s| !s.is_empty()));
|
|
if let Some(screenshots_str) = screenshots_source {
|
|
let paths: Vec<String> = screenshots_str.split(';')
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string())
|
|
.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);
|
|
|
|
const SCREENSHOTS_PER_PAGE: usize = 2;
|
|
let total_screenshots = paths.len();
|
|
let max_page = total_screenshots.saturating_sub(1) / SCREENSHOTS_PER_PAGE;
|
|
|
|
// Store all textures for lightbox (across all pages)
|
|
let textures: Rc<RefCell<Vec<Option<gtk::gdk::Texture>>>> =
|
|
Rc::new(RefCell::new(vec![None; total_screenshots]));
|
|
|
|
// Store all frame refs so we can show/hide pages
|
|
let all_paths: Rc<Vec<String>> = Rc::new(paths);
|
|
let screenshot_page: Rc<std::cell::Cell<usize>> = Rc::new(std::cell::Cell::new(0));
|
|
|
|
// Stack for crossfade page transitions
|
|
let ss_stack = gtk::Stack::builder()
|
|
.transition_type(gtk::StackTransitionType::Crossfade)
|
|
.transition_duration(200)
|
|
.hexpand(true)
|
|
.height_request(340)
|
|
.build();
|
|
|
|
// Navigation arrows
|
|
let ss_left = gtk::Button::builder()
|
|
.icon_name("go-previous-symbolic")
|
|
.css_classes(["circular", "osd"])
|
|
.valign(gtk::Align::Center)
|
|
.sensitive(false)
|
|
.build();
|
|
|
|
let ss_right = gtk::Button::builder()
|
|
.icon_name("go-next-symbolic")
|
|
.css_classes(["circular", "osd"])
|
|
.valign(gtk::Align::Center)
|
|
.sensitive(max_page > 0)
|
|
.build();
|
|
|
|
// Carousel row: [<] [stack] [>]
|
|
let ss_row = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(8)
|
|
.build();
|
|
ss_row.append(&ss_left);
|
|
ss_row.append(&ss_stack);
|
|
ss_row.append(&ss_right);
|
|
|
|
// Flip state for crossfade (alternates between "a" and "b" stack children)
|
|
let ss_flip: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
|
|
|
// Build and show a page of screenshots
|
|
let build_ss_page = {
|
|
let textures_ref = textures.clone();
|
|
let paths_ref = all_paths.clone();
|
|
let app_name = app.name.clone();
|
|
Rc::new(move |page: usize, stack: >k::Stack, flip: &Rc<std::cell::Cell<bool>>,
|
|
left: >k::Button, right: >k::Button| {
|
|
let start = page * SCREENSHOTS_PER_PAGE;
|
|
let end = (start + SCREENSHOTS_PER_PAGE).min(paths_ref.len());
|
|
|
|
left.set_sensitive(page > 0);
|
|
right.set_sensitive(page < max_page);
|
|
|
|
let page_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(12)
|
|
.homogeneous(true)
|
|
.hexpand(true)
|
|
.build();
|
|
|
|
for i in start..end {
|
|
let frame = gtk::Frame::new(None);
|
|
frame.add_css_class("card");
|
|
frame.set_height_request(320);
|
|
|
|
let spinner = gtk::Spinner::builder()
|
|
.halign(gtk::Align::Center)
|
|
.valign(gtk::Align::Center)
|
|
.spinning(true)
|
|
.width_request(32)
|
|
.height_request(32)
|
|
.build();
|
|
frame.set_child(Some(&spinner));
|
|
|
|
widgets::set_pointer_cursor(&frame);
|
|
|
|
// Click handler for lightbox
|
|
let textures_click = textures_ref.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::<gtk::Window>() {
|
|
detail_view::show_screenshot_lightbox(
|
|
&window,
|
|
&textures_click,
|
|
idx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
frame.add_controller(click);
|
|
|
|
// Load screenshot asynchronously
|
|
let name = app_name.clone();
|
|
let spath = paths_ref[i].clone();
|
|
let frame_ref = frame.clone();
|
|
let tex_ref = textures_ref.clone();
|
|
|
|
glib::spawn_future_local(async move {
|
|
let n = name.clone();
|
|
let sp = spath.clone();
|
|
let load_idx = i;
|
|
|
|
let result = gio::spawn_blocking(move || {
|
|
catalog::cache_screenshot(&n, &sp, 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));
|
|
tex_ref.borrow_mut()[load_idx] = 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));
|
|
}
|
|
}
|
|
});
|
|
|
|
page_box.append(&frame);
|
|
}
|
|
|
|
// Crossfade: alternate between "a" and "b" children
|
|
let current_flip = flip.get();
|
|
let child_name = if current_flip { "ss_b" } else { "ss_a" };
|
|
stack.add_named(&page_box, Some(child_name));
|
|
stack.set_visible_child_name(child_name);
|
|
// Remove the old child
|
|
let old_name = if current_flip { "ss_a" } else { "ss_b" };
|
|
if let Some(old_child) = stack.child_by_name(old_name) {
|
|
stack.remove(&old_child);
|
|
}
|
|
flip.set(!current_flip);
|
|
})
|
|
};
|
|
|
|
// Show initial page
|
|
build_ss_page(0, &ss_stack, &ss_flip, &ss_left, &ss_right);
|
|
|
|
// Wire arrow clicks
|
|
{
|
|
let page_ref = screenshot_page.clone();
|
|
let stack_ref = ss_stack.clone();
|
|
let flip_ref = ss_flip.clone();
|
|
let left_ref = ss_left.clone();
|
|
let right_ref = ss_right.clone();
|
|
let build_ref = build_ss_page.clone();
|
|
ss_left.connect_clicked(move |_| {
|
|
let page = page_ref.get();
|
|
if page > 0 {
|
|
page_ref.set(page - 1);
|
|
build_ref(page - 1, &stack_ref, &flip_ref, &left_ref, &right_ref);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let page_ref = screenshot_page.clone();
|
|
let stack_ref = ss_stack.clone();
|
|
let flip_ref = ss_flip.clone();
|
|
let left_ref = ss_left.clone();
|
|
let right_ref = ss_right.clone();
|
|
let build_ref = build_ss_page.clone();
|
|
ss_right.connect_clicked(move |_| {
|
|
let page = page_ref.get();
|
|
if page < max_page {
|
|
page_ref.set(page + 1);
|
|
build_ref(page + 1, &stack_ref, &flip_ref, &left_ref, &right_ref);
|
|
}
|
|
});
|
|
}
|
|
|
|
content.append(&ss_row);
|
|
}
|
|
}
|
|
|
|
content.append(&about_container);
|
|
content.append(&readme_container);
|
|
|
|
// --- Details section ---
|
|
let details_group = adw::PreferencesGroup::builder()
|
|
.title(&i18n("Details"))
|
|
.build();
|
|
|
|
// Type/Category from OCS
|
|
if let Some(ref typename) = app.ocs_typename {
|
|
if !typename.is_empty() {
|
|
let escaped = typename.replace('&', "&");
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("Type"))
|
|
.subtitle(&escaped)
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
if let Some(ref cats) = app.categories {
|
|
if !cats.is_empty() {
|
|
let escaped_cats = cats.replace('&', "&");
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("Categories"))
|
|
.subtitle(&escaped_cats)
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Architecture
|
|
if let Some(ref arch) = app.ocs_arch {
|
|
if !arch.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("Architecture"))
|
|
.subtitle(arch)
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
// File name
|
|
if let Some(ref dlname) = app.ocs_downloadname {
|
|
if !dlname.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("Filename"))
|
|
.subtitle(dlname)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
// File size
|
|
if let Some(size_kb) = app.ocs_downloadsize {
|
|
if size_kb > 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("File size"))
|
|
.subtitle(&widgets::format_size(size_kb * 1024))
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
// MD5 checksum
|
|
if let Some(ref md5) = app.ocs_md5sum {
|
|
if !md5.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("MD5"))
|
|
.subtitle(md5)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
if let Some(ref tags) = app.ocs_tags {
|
|
if !tags.is_empty() {
|
|
// Format tags nicely with commas
|
|
let formatted = tags.split(',')
|
|
.map(|t| t.trim())
|
|
.filter(|t| !t.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
.replace('&', "&");
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("Tags"))
|
|
.subtitle(&formatted)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
// Created date
|
|
if let Some(ref created) = app.ocs_created {
|
|
if !created.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("Published"))
|
|
.subtitle(&widgets::relative_time(created))
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
// Comments count
|
|
if let Some(comments) = app.ocs_comments {
|
|
if comments > 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("Comments"))
|
|
.subtitle(&format!("{}", comments))
|
|
.build();
|
|
details_group.add(&row);
|
|
}
|
|
}
|
|
|
|
content.append(&details_group);
|
|
|
|
// --- Links section ---
|
|
let links_group = adw::PreferencesGroup::builder()
|
|
.title(&i18n("Links"))
|
|
.build();
|
|
|
|
// GitHub link (extracted from description or already known)
|
|
if let Some(ref owner) = app.github_owner {
|
|
if let Some(ref repo) = app.github_repo {
|
|
let gh_url = format!("https://github.com/{}/{}", owner, repo);
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("GitHub"))
|
|
.subtitle(&gh_url)
|
|
.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 url = gh_url;
|
|
row.connect_activated(move |row| {
|
|
let launcher = gtk::UriLauncher::new(&url);
|
|
let root = row.root().and_downcast::<gtk::Window>();
|
|
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
|
|
});
|
|
links_group.add(&row);
|
|
}
|
|
}
|
|
|
|
// AppImageHub.com detail page
|
|
if let Some(ref detailpage) = app.ocs_detailpage {
|
|
if !detailpage.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("AppImageHub"))
|
|
.subtitle(detailpage)
|
|
.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 dp = detailpage.clone();
|
|
row.connect_activated(move |row| {
|
|
let launcher = gtk::UriLauncher::new(&dp);
|
|
let root = row.root().and_downcast::<gtk::Window>();
|
|
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
|
|
});
|
|
links_group.add(&row);
|
|
}
|
|
}
|
|
|
|
// Homepage (non-OCS apps or if we have a real homepage)
|
|
if let Some(ref homepage) = app.homepage {
|
|
// Don't duplicate if it's the same as the detailpage
|
|
let is_detailpage = app.ocs_detailpage.as_deref() == Some(homepage.as_str());
|
|
if !is_detailpage {
|
|
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::<gtk::Window>();
|
|
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
|
|
});
|
|
links_group.add(&row);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
content.append(&links_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));
|
|
widgets::apply_pointer_cursors(&toolbar);
|
|
|
|
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.
|
|
/// `ocs_slot` is the 1-based OCS download file slot (default 1).
|
|
fn do_install(
|
|
url: String,
|
|
app_name: String,
|
|
homepage: Option<String>,
|
|
toast_overlay: adw::ToastOverlay,
|
|
db: Rc<Database>,
|
|
widget: gtk::Widget,
|
|
ocs_id: Option<i64>,
|
|
ocs_slot: u32,
|
|
) {
|
|
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_with_ocs(&cat_app, &install_dir, ocs_id, ocs_slot)
|
|
.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::<gtk::Button>() {
|
|
btn.set_label("Installed");
|
|
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
|
|
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::<gtk::Button>() {
|
|
btn.set_label("Install");
|
|
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
|
|
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::<gtk::Button>() {
|
|
btn.set_label("Install");
|
|
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
|
|
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.
|
|
/// `ocs_files` - if provided, OCS download files to show in the dropdown.
|
|
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<Database>,
|
|
homepage: Option<&str>,
|
|
ocs_id: Option<i64>,
|
|
ocs_files: &[catalog::OcsDownloadFile],
|
|
) {
|
|
// Clear existing children (e.g. the "Loading..." placeholder)
|
|
while let Some(child) = slot.first_child() {
|
|
slot.remove(&child);
|
|
}
|
|
|
|
let assets: Vec<AppImageAsset> = 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();
|
|
|
|
// Determine the default OCS slot (first file = newest)
|
|
let default_ocs_slot = ocs_files.first().map(|f| f.slot).unwrap_or(1);
|
|
|
|
// Combine GitHub assets and OCS files into dropdown options
|
|
let has_gh_assets = assets.len() > 1;
|
|
let has_ocs_files = ocs_files.len() > 1;
|
|
|
|
if has_gh_assets || has_ocs_files {
|
|
let menu = gio::Menu::new();
|
|
|
|
// OCS version files (each needs slot-based install)
|
|
for file in ocs_files {
|
|
let label = format_ocs_file_label(file);
|
|
// Encode ocs_id and slot as action param: "ocs:{ocs_id}:{slot}"
|
|
menu.append(Some(&label), Some(&format!("install.ocs::{}:{}", file.ocs_id, file.slot)));
|
|
}
|
|
|
|
// GitHub release assets
|
|
for asset in &assets {
|
|
let label = format_asset_label(&asset.name, asset.size);
|
|
menu.append(Some(&label), Some(&format!("install.gh::{}", asset.url)));
|
|
}
|
|
|
|
// Fallback to original catalog URL
|
|
if has_gh_assets || has_ocs_files {
|
|
menu.append(
|
|
Some(&i18n("Direct download (original)")),
|
|
Some(&format!("install.gh::{}", download_url)),
|
|
);
|
|
}
|
|
|
|
let split_btn = adw::SplitButton::builder()
|
|
.label(&i18n("Install"))
|
|
.menu_model(&menu)
|
|
.css_classes(["suggested-action", "pill"])
|
|
.build();
|
|
|
|
// Default click: install the default version
|
|
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();
|
|
let ocs_id_click = ocs_id;
|
|
let default_slot = default_ocs_slot;
|
|
|
|
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::<gtk::Widget>().clone(),
|
|
ocs_id_click,
|
|
default_slot,
|
|
);
|
|
});
|
|
|
|
let action_group = gio::SimpleActionGroup::new();
|
|
|
|
// OCS slot-based install action
|
|
let ocs_action = gio::SimpleAction::new("ocs", Some(glib::VariantTy::STRING));
|
|
let name_ocs = app_name.to_string();
|
|
let hp_ocs = homepage.map(|s| s.to_string());
|
|
let toast_ocs = toast_overlay.clone();
|
|
let db_ocs = db.clone();
|
|
let split_ocs = split_btn.clone();
|
|
let dl_url_ocs = download_url.to_string();
|
|
ocs_action.connect_activate(move |_, param| {
|
|
if let Some(param_str) = param.and_then(|p| p.str()) {
|
|
// Parse "ocs_id:slot"
|
|
let parts: Vec<&str> = param_str.splitn(2, ':').collect();
|
|
if let (Some(id_str), Some(slot_str)) = (parts.first(), parts.get(1)) {
|
|
let ocs_id = id_str.parse::<i64>().ok();
|
|
let slot = slot_str.parse::<u32>().unwrap_or(1);
|
|
split_ocs.set_sensitive(false);
|
|
split_ocs.set_label("Installing...");
|
|
do_install(
|
|
dl_url_ocs.clone(),
|
|
name_ocs.clone(),
|
|
hp_ocs.clone(),
|
|
toast_ocs.clone(),
|
|
db_ocs.clone(),
|
|
split_ocs.upcast_ref::<gtk::Widget>().clone(),
|
|
ocs_id,
|
|
slot,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
action_group.add_action(&ocs_action);
|
|
|
|
// GitHub URL-based install action
|
|
let gh_action = gio::SimpleAction::new("gh", Some(glib::VariantTy::STRING));
|
|
let name_gh = app_name.to_string();
|
|
let hp_gh = homepage.map(|s| s.to_string());
|
|
let toast_gh = toast_overlay.clone();
|
|
let db_gh = db.clone();
|
|
let split_gh = split_btn.clone();
|
|
gh_action.connect_activate(move |_, param| {
|
|
if let Some(url) = param.and_then(|p| p.str()) {
|
|
split_gh.set_sensitive(false);
|
|
split_gh.set_label("Installing...");
|
|
do_install(
|
|
url.to_string(),
|
|
name_gh.clone(),
|
|
hp_gh.clone(),
|
|
toast_gh.clone(),
|
|
db_gh.clone(),
|
|
split_gh.upcast_ref::<gtk::Widget>().clone(),
|
|
None,
|
|
1,
|
|
);
|
|
}
|
|
});
|
|
action_group.add_action(&gh_action);
|
|
|
|
split_btn.insert_action_group("install", Some(&action_group));
|
|
|
|
widgets::set_pointer_cursor(&split_btn);
|
|
slot.append(&split_btn);
|
|
} else {
|
|
// Single asset - plain button
|
|
let install_btn = gtk::Button::builder()
|
|
.label(&i18n("Install"))
|
|
.css_classes(["suggested-action", "pill"])
|
|
.build();
|
|
widgets::set_pointer_cursor(&install_btn);
|
|
|
|
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();
|
|
let ocs_id_plain = ocs_id;
|
|
let slot_plain = default_ocs_slot;
|
|
|
|
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::<gtk::Widget>().clone(),
|
|
ocs_id_plain,
|
|
slot_plain,
|
|
);
|
|
});
|
|
|
|
slot.append(&install_btn);
|
|
}
|
|
}
|
|
|
|
/// Format an OCS download file label for the dropdown menu.
|
|
fn format_ocs_file_label(file: &catalog::OcsDownloadFile) -> String {
|
|
let mut parts = Vec::new();
|
|
if !file.version.is_empty() {
|
|
parts.push(format!("v{}", file.version));
|
|
}
|
|
if let Some(ref arch) = file.arch {
|
|
parts.push(arch.clone());
|
|
}
|
|
if !file.filename.is_empty() {
|
|
parts.push(file.filename.clone());
|
|
}
|
|
if let Some(ref pkg_type) = file.pkg_type {
|
|
if pkg_type != "appimage" {
|
|
parts.push(format!("[{}]", pkg_type));
|
|
}
|
|
}
|
|
if let Some(size_kb) = file.size_kb {
|
|
if size_kb > 0 {
|
|
parts.push(format!("({})", widgets::format_size(size_kb * 1024)));
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
format!("File {}", file.slot)
|
|
} else {
|
|
parts.join(" - ")
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|