Add one-click Update All with batch progress dialog
New batch_update_dialog shows all updatable AppImages with per-app status badges and overall progress bar. Wired as win.update-all action from dashboard when updates are available.
This commit is contained in:
273
src/ui/batch_update_dialog.rs
Normal file
273
src/ui/batch_update_dialog.rs
Normal file
@@ -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<gtk::Widget>, db: &Rc<Database>) {
|
||||
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));
|
||||
}
|
||||
@@ -269,6 +269,22 @@ fn build_updates_summary_group(db: &Rc<Database>) -> 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");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app_card;
|
||||
pub mod batch_update_dialog;
|
||||
pub mod cleanup_wizard;
|
||||
pub mod dashboard;
|
||||
pub mod detail_view;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user