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:
lashman
2026-02-27 23:55:04 +02:00
parent c311fb27c3
commit 1a6eb4ec99
4 changed files with 301 additions and 0 deletions

View 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));
}

View File

@@ -269,6 +269,22 @@ fn build_updates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
updates_row.add_suffix(&updates_arrow); updates_row.add_suffix(&updates_arrow);
group.add(&updates_row); 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 // Last checked timestamp
let settings = gio::Settings::new(APP_ID); let settings = gio::Settings::new(APP_ID);
let last_check = settings.string("last-update-check"); let last_check = settings.string("last-update-check");

View File

@@ -1,4 +1,5 @@
pub mod app_card; pub mod app_card;
pub mod batch_update_dialog;
pub mod cleanup_wizard; pub mod cleanup_wizard;
pub mod dashboard; pub mod dashboard;
pub mod detail_view; pub mod detail_view;

View File

@@ -666,6 +666,17 @@ impl DriftwoodWindow {
} }
self.add_action(&batch_delete_action); 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) --- // --- Context menu actions (parameterized with record ID) ---
let param_type = Some(glib::VariantTy::INT64); let param_type = Some(glib::VariantTy::INT64);