diff --git a/src/ui/batch_update_dialog.rs b/src/ui/batch_update_dialog.rs new file mode 100644 index 0000000..0d27bcd --- /dev/null +++ b/src/ui/batch_update_dialog.rs @@ -0,0 +1,273 @@ +use adw::prelude::*; +use gtk::gio; +use std::cell::Cell; +use std::rc::Rc; + +use crate::core::database::Database; +use crate::core::updater; +use crate::i18n::{i18n, i18n_f}; + +/// Show a dialog to update all AppImages that have updates available. +pub fn show_batch_update_dialog(parent: &impl IsA, db: &Rc) { + let records = db.get_all_appimages().unwrap_or_default(); + let updatable: Vec<_> = records + .into_iter() + .filter(|r| { + if let (Some(ref latest), Some(ref current)) = (&r.latest_version, &r.app_version) { + r.update_url.is_some() && updater::version_is_newer(latest, current) + } else { + false + } + }) + .collect(); + + if updatable.is_empty() { + let dialog = adw::AlertDialog::builder() + .heading(&i18n("No Updates")) + .body(&i18n("All AppImages are up to date.")) + .build(); + dialog.add_response("ok", &i18n("OK")); + dialog.present(Some(parent)); + return; + } + + let dialog = adw::Dialog::builder() + .title(&i18n("Update All")) + .content_width(500) + .content_height(450) + .build(); + + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + toolbar.add_top_bar(&header); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_start(18) + .margin_end(18) + .margin_top(12) + .margin_bottom(12) + .build(); + + let status_label = gtk::Label::builder() + .label(&i18n_f( + "{count} updates available", + &[("{count}", &updatable.len().to_string())], + )) + .xalign(0.0) + .build(); + status_label.add_css_class("title-4"); + content.append(&status_label); + + let overall_progress = gtk::ProgressBar::builder() + .show_text(true) + .text(&i18n("Ready")) + .build(); + content.append(&overall_progress); + + // List of apps to update + let list_box = gtk::ListBox::new(); + list_box.add_css_class("boxed-list"); + list_box.set_selection_mode(gtk::SelectionMode::None); + + let mut row_data: Vec<(i64, String, String, String, adw::ActionRow, gtk::Label)> = Vec::new(); + + for record in &updatable { + let name = record.app_name.as_deref().unwrap_or(&record.filename); + let current_ver = record.app_version.as_deref().unwrap_or("?"); + let latest_ver = record.latest_version.as_deref().unwrap_or("?"); + + let row = adw::ActionRow::builder() + .title(name) + .subtitle(&format!("{} -> {}", current_ver, latest_ver)) + .build(); + + let status_badge = gtk::Label::builder() + .label(&i18n("Pending")) + .valign(gtk::Align::Center) + .build(); + status_badge.add_css_class("dim-label"); + row.add_suffix(&status_badge); + + list_box.append(&row); + row_data.push(( + record.id, + record.path.clone(), + record.update_url.clone().unwrap_or_default(), + record.latest_version.clone().unwrap_or_default(), + row, + status_badge, + )); + } + + let scrolled = gtk::ScrolledWindow::builder() + .child(&list_box) + .vexpand(true) + .min_content_height(200) + .build(); + content.append(&scrolled); + + // Buttons + let button_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .halign(gtk::Align::End) + .margin_top(6) + .build(); + + let cancel_btn = gtk::Button::builder() + .label(&i18n("Cancel")) + .build(); + + let update_btn = gtk::Button::builder() + .label(&i18n("Update All")) + .build(); + update_btn.add_css_class("suggested-action"); + + button_box.append(&cancel_btn); + button_box.append(&update_btn); + content.append(&button_box); + + toolbar.set_content(Some(&content)); + dialog.set_child(Some(&toolbar)); + + let dialog_weak = dialog.downgrade(); + cancel_btn.connect_clicked(move |_| { + if let Some(d) = dialog_weak.upgrade() { + d.close(); + } + }); + + let cancelled = Rc::new(Cell::new(false)); + let cancelled_close = cancelled.clone(); + // Mark cancelled when dialog is closed during update + dialog.connect_closed(move |_| { + cancelled_close.set(true); + }); + + let dialog_update_weak = dialog.downgrade(); + update_btn.connect_clicked(move |btn| { + btn.set_sensitive(false); + + let total = row_data.len(); + let progress_bar = overall_progress.clone(); + let status = status_label.clone(); + let cancelled = cancelled.clone(); + let row_data_clone: Vec<(i64, String, String, String, adw::ActionRow, gtk::Label)> = + row_data.iter().map(|(id, path, url, ver, row, badge)| { + (*id, path.clone(), url.clone(), ver.clone(), row.clone(), badge.clone()) + }).collect(); + let dialog_weak = dialog_update_weak.clone(); + + glib::spawn_future_local(async move { + let mut completed = 0usize; + let mut succeeded = 0usize; + let mut failed = 0usize; + + for (record_id, path, url, new_version, _row, badge) in &row_data_clone { + if cancelled.get() { + break; + } + + badge.set_label(&i18n("Updating...")); + badge.remove_css_class("dim-label"); + badge.add_css_class("accent"); + + let path = path.clone(); + let url = url.clone(); + let version = new_version.clone(); + let record_id = *record_id; + + let result = gio::spawn_blocking(move || { + let p = std::path::Path::new(&path); + let update_result = updater::perform_update( + p, + Some(&url), + true, // keep old for rollback + None, + ); + + // On success, record rollback path and clear update flag + if let Ok(ref applied) = update_result { + let bg_db = Database::open().expect("DB open failed"); + if let Some(ref old_path) = applied.old_path_backup { + bg_db.set_previous_version( + record_id, + Some(&old_path.to_string_lossy()), + ).ok(); + } + let actual_ver = applied.new_version.as_deref() + .unwrap_or(&version); + bg_db.record_update( + record_id, + None, + Some(actual_ver), + Some("batch-update"), + None, + true, + ).ok(); + bg_db.clear_update_available(record_id).ok(); + } + + update_result + }) + .await; + + completed += 1; + let fraction = completed as f64 / total as f64; + progress_bar.set_fraction(fraction); + progress_bar.set_text(Some(&format!("{}/{}", completed, total))); + status.set_label(&i18n_f( + "Updating {current} of {total}...", + &[ + ("{current}", &completed.to_string()), + ("{total}", &total.to_string()), + ], + )); + + match result { + Ok(Ok(_)) => { + badge.set_label(&i18n("Done")); + badge.remove_css_class("accent"); + badge.add_css_class("success"); + succeeded += 1; + } + _ => { + badge.set_label(&i18n("Failed")); + badge.remove_css_class("accent"); + badge.add_css_class("error"); + failed += 1; + } + } + } + + // Final status + let summary = if failed == 0 { + i18n_f( + "All {count} updates applied successfully", + &[("{count}", &succeeded.to_string())], + ) + } else { + i18n_f( + "{ok} succeeded, {fail} failed", + &[ + ("{ok}", &succeeded.to_string()), + ("{fail}", &failed.to_string()), + ], + ) + }; + status.set_label(&summary); + progress_bar.set_text(Some(&i18n("Complete"))); + progress_bar.set_fraction(1.0); + + // Change cancel to Close + if let Some(d) = dialog_weak.upgrade() { + // The user can now close via the header bar + let _ = d; + } + }); + }); + + dialog.present(Some(parent)); +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 4545139..e16b482 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -269,6 +269,22 @@ fn build_updates_summary_group(db: &Rc) -> adw::PreferencesGroup { updates_row.add_suffix(&updates_arrow); group.add(&updates_row); + if with_updates > 0 { + let update_all_row = adw::ActionRow::builder() + .title("Update All") + .subtitle(&format!("Apply {} available updates", with_updates)) + .activatable(true) + .build(); + update_all_row.set_action_name(Some("win.update-all")); + let update_badge = widgets::status_badge("Go", "suggested"); + update_badge.set_valign(gtk::Align::Center); + update_all_row.add_suffix(&update_badge); + let arrow = gtk::Image::from_icon_name("go-next-symbolic"); + arrow.set_valign(gtk::Align::Center); + update_all_row.add_suffix(&arrow); + group.add(&update_all_row); + } + // Last checked timestamp let settings = gio::Settings::new(APP_ID); let last_check = settings.string("last-update-check"); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index de0b2fd..34aca71 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod app_card; +pub mod batch_update_dialog; pub mod cleanup_wizard; pub mod dashboard; pub mod detail_view; diff --git a/src/window.rs b/src/window.rs index f39271e..7f09242 100644 --- a/src/window.rs +++ b/src/window.rs @@ -666,6 +666,17 @@ impl DriftwoodWindow { } self.add_action(&batch_delete_action); + let update_all_action = gio::SimpleAction::new("update-all", None); + { + let window_weak = self.downgrade(); + update_all_action.connect_activate(move |_, _| { + let Some(window) = window_weak.upgrade() else { return }; + let db = window.database(); + crate::ui::batch_update_dialog::show_batch_update_dialog(&window, db); + }); + } + self.add_action(&update_all_action); + // --- Context menu actions (parameterized with record ID) --- let param_type = Some(glib::VariantTy::INT64);