diff --git a/src/ui/duplicate_dialog.rs b/src/ui/duplicate_dialog.rs index 53ec2d0..9bd2056 100644 --- a/src/ui/duplicate_dialog.rs +++ b/src/ui/duplicate_dialog.rs @@ -1,8 +1,10 @@ use adw::prelude::*; +use std::cell::RefCell; use std::rc::Rc; use crate::core::database::Database; use crate::core::duplicates::{self, DuplicateGroup, MatchReason, MemberRecommendation}; +use crate::core::integrator; use super::widgets; /// Show a dialog listing duplicate/multi-version AppImages with resolution options. @@ -34,6 +36,18 @@ pub fn show_duplicate_dialog( let toolbar = adw::ToolbarView::new(); let header = adw::HeaderBar::new(); + + // "Remove All Suggested" bulk action button + let bulk_btn = gtk::Button::builder() + .label("Remove All Suggested") + .tooltip_text("Delete all items recommended for removal") + .build(); + bulk_btn.add_css_class("destructive-action"); + bulk_btn.update_property(&[ + gtk::accessible::Property::Label("Remove all suggested duplicates"), + ]); + header.pack_end(&bulk_btn); + toolbar.add_top_bar(&header); let scrolled = gtk::ScrolledWindow::builder() @@ -63,14 +77,74 @@ pub fn show_duplicate_dialog( .wrap(true) .halign(gtk::Align::Start) .build(); - summary_label.add_css_class("dim-label"); + summary_label.add_css_class("dimmed"); content.append(&summary_label); - // Build a list for each duplicate group + // Collect all removable records for bulk action + let removable_records: Rc>> = + Rc::new(RefCell::new(Vec::new())); + + // Build a PreferencesGroup for each duplicate group for group in &groups { - content.append(&build_group_widget(group)); + let (group_widget, group_removable) = build_group_widget(group, db, toast_overlay); + content.append(&group_widget); + removable_records.borrow_mut().extend(group_removable); } + // Wire bulk action with confirmation dialog + let db_bulk = db.clone(); + let toast_bulk = toast_overlay.clone(); + let removable = removable_records; + bulk_btn.connect_clicked(move |btn| { + let count = removable.borrow().len(); + if count == 0 { + return; + } + + let plural = if count == 1 { "" } else { "s" }; + let confirm = adw::AlertDialog::builder() + .heading("Confirm Removal") + .body(&format!("Remove {} suggested duplicate{}?", count, plural)) + .build(); + confirm.add_response("cancel", "Cancel"); + confirm.add_response("remove", "Remove"); + confirm.set_response_appearance("remove", adw::ResponseAppearance::Destructive); + confirm.set_default_response(Some("cancel")); + confirm.set_close_response("cancel"); + + let db_confirm = db_bulk.clone(); + let toast_confirm = toast_bulk.clone(); + let removable_confirm = removable.clone(); + let btn_ref = btn.clone(); + + confirm.connect_response(None, move |_dlg, response| { + if response != "remove" { + return; + } + let records = removable_confirm.borrow(); + let mut removed_count = 0; + for (record_id, record_path, _record_name, integrated) in records.iter() { + if *integrated { + // Fetch the full record to properly remove integration + if let Ok(Some(full_record)) = db_confirm.get_appimage_by_id(*record_id) { + integrator::remove_integration(&full_record).ok(); + } + db_confirm.set_integrated(*record_id, false, None).ok(); + } + std::fs::remove_file(record_path).ok(); + db_confirm.remove_appimage(*record_id).ok(); + removed_count += 1; + } + if removed_count > 0 { + toast_confirm.add_toast(adw::Toast::new(&format!("Removed {} items", removed_count))); + btn_ref.set_sensitive(false); + btn_ref.set_label("Done"); + } + }); + + confirm.present(Some(btn)); + }); + scrolled.set_child(Some(&content)); toolbar.set_content(Some(&scrolled)); dialog.set_child(Some(&toolbar)); @@ -78,63 +152,51 @@ pub fn show_duplicate_dialog( dialog.present(Some(parent)); } -fn build_group_widget(group: &DuplicateGroup) -> gtk::Box { - let container = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) +fn build_group_widget( + group: &DuplicateGroup, + db: &Rc, + toast_overlay: &adw::ToastOverlay, +) -> (adw::PreferencesGroup, Vec<(i64, String, String, bool)>) { + let reason_text = match group.match_reason { + MatchReason::ExactDuplicate => "Exact duplicate", + MatchReason::MultiVersion => "Multiple versions", + MatchReason::SameVersionDifferentPath => "Same version, different path", + }; + + let description = if group.potential_savings > 0 { + format!( + "{} - Total: {} - Potential savings: {}", + reason_text, + widgets::format_size(group.total_size as i64), + widgets::format_size(group.potential_savings as i64), + ) + } else { + format!( + "{} - Total: {}", + reason_text, + widgets::format_size(group.total_size as i64), + ) + }; + + let pref_group = adw::PreferencesGroup::builder() + .title(&group.app_name) + .description(&description) .build(); - // Group header - let header_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .build(); - - let name_label = gtk::Label::builder() - .label(&group.app_name) - .css_classes(["heading"]) - .halign(gtk::Align::Start) - .hexpand(true) - .build(); - header_box.append(&name_label); - - let reason_badge = widgets::status_badge( - group.match_reason.label(), - match group.match_reason { - MatchReason::ExactDuplicate => "error", - MatchReason::MultiVersion => "warning", - MatchReason::SameVersionDifferentPath => "warning", - }, - ); - header_box.append(&reason_badge); - - container.append(&header_box); - - // Savings info - if group.potential_savings > 0 { - let savings_label = gtk::Label::builder() - .label(&format!( - "Potential savings: {}", - widgets::format_size(group.potential_savings as i64) - )) - .halign(gtk::Align::Start) - .build(); - savings_label.add_css_class("dim-label"); - container.append(&savings_label); - } - - // Members list - let list_box = gtk::ListBox::new(); - list_box.add_css_class("boxed-list"); - list_box.set_selection_mode(gtk::SelectionMode::None); + let mut removable = Vec::new(); for member in &group.members { let record = &member.record; let version = record.app_version.as_deref().unwrap_or("unknown"); let size = widgets::format_size(record.size_bytes); + let title = if member.is_recommended { + format!("{} ({}) - Recommended", version, size) + } else { + format!("{} ({})", version, size) + }; let row = adw::ActionRow::builder() - .title(&format!("{} ({})", version, size)) + .title(&title) .subtitle(&record.path) .build(); @@ -148,9 +210,57 @@ fn build_group_widget(group: &DuplicateGroup) -> gtk::Box { badge.set_valign(gtk::Align::Center); row.add_suffix(&badge); - list_box.append(&row); + // Add delete button for removable members + let is_removable = matches!( + member.recommendation, + MemberRecommendation::RemoveOlder | MemberRecommendation::RemoveDuplicate + ); + if is_removable { + removable.push(( + record.id, + record.path.clone(), + record.app_name.clone().unwrap_or_else(|| record.filename.clone()), + record.integrated, + )); + + let delete_btn = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Delete this AppImage") + .css_classes(["flat", "circular"]) + .valign(gtk::Align::Center) + .build(); + delete_btn.add_css_class("destructive-action"); + + let record_id = record.id; + let record_path = record.path.clone(); + let record_name = record.app_name.clone().unwrap_or_else(|| record.filename.clone()); + delete_btn.update_property(&[ + gtk::accessible::Property::Label(&format!("Delete {}", record_name)), + ]); + let db_ref = db.clone(); + let toast_ref = toast_overlay.clone(); + let record_clone = record.clone(); + + delete_btn.connect_clicked(move |btn| { + // Remove integration if any + if record_clone.integrated { + integrator::remove_integration(&record_clone).ok(); + db_ref.set_integrated(record_id, false, None).ok(); + } + // Delete the AppImage file + std::fs::remove_file(&record_path).ok(); + // Remove from database + db_ref.remove_appimage(record_id).ok(); + // Update UI + btn.set_sensitive(false); + toast_ref.add_toast(adw::Toast::new(&format!("Removed {}", record_name))); + }); + + row.add_suffix(&delete_btn); + } + + pref_group.add(&row); } - container.append(&list_box); - container + (pref_group, removable) }