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);
|
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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user