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 crate::i18n::{i18n, i18n_f, ni18n_f}; use super::widgets; /// Show a dialog listing duplicate/multi-version AppImages with resolution options. pub fn show_duplicate_dialog( parent: &impl IsA, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { let groups = duplicates::detect_duplicates(db); if groups.is_empty() { let dialog = adw::AlertDialog::builder() .heading(&i18n("No Duplicates Found")) .body(&i18n("No duplicate or multi-version AppImages were detected.")) .build(); dialog.add_response("ok", &i18n("OK")); dialog.set_default_response(Some("ok")); dialog.present(Some(parent)); return; } let summary = duplicates::summarize_duplicates(&groups); let dialog = adw::Dialog::builder() .title(&i18n("Duplicates & Old Versions")) .content_width(600) .content_height(500) .build(); let toolbar = adw::ToolbarView::new(); let header = adw::HeaderBar::new(); // "Remove All Suggested" bulk action button let bulk_btn = gtk::Button::builder() .label(&i18n("Remove All Suggested")) .tooltip_text(&i18n("Delete all items recommended for removal")) .build(); bulk_btn.add_css_class("destructive-action"); bulk_btn.update_property(&[ gtk::accessible::Property::Label(&i18n("Remove all suggested duplicates")), ]); header.pack_end(&bulk_btn); toolbar.add_top_bar(&header); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) .build(); let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(16) .margin_top(16) .margin_bottom(16) .margin_start(16) .margin_end(16) .build(); // Summary banner let summary_text = i18n_f( "{groups} groups found ({exact} exact duplicates, {multi} with multiple versions). Potential savings: {savings}", &[ ("{groups}", &summary.total_groups.to_string()), ("{exact}", &summary.exact_duplicates.to_string()), ("{multi}", &summary.multi_version.to_string()), ("{savings}", &widgets::format_size(summary.total_potential_savings as i64)), ], ); let summary_label = gtk::Label::builder() .label(&summary_text) .wrap(true) .halign(gtk::Align::Start) .build(); summary_label.add_css_class("dimmed"); content.append(&summary_label); // 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 { 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 confirm = adw::AlertDialog::builder() .heading(&i18n("Confirm Removal")) .body(&ni18n_f( "Remove {count} suggested duplicate?", "Remove {count} suggested duplicates?", count as u32, &[("{count}", &count.to_string())], )) .build(); confirm.add_response("cancel", &i18n("Cancel")); confirm.add_response("remove", &i18n("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 { integrator::undo_all_modifications(&db_confirm, *record_id).ok(); 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(widgets::info_toast(&ni18n_f( "Removed {count} item", "Removed {count} items", removed_count as u32, &[("{count}", &removed_count.to_string())], ))); btn_ref.set_sensitive(false); btn_ref.set_label(&i18n("Done")); } }); confirm.present(Some(btn)); }); scrolled.set_child(Some(&content)); toolbar.set_content(Some(&scrolled)); dialog.set_child(Some(&toolbar)); dialog.present(Some(parent)); } 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 => i18n("Exact duplicate"), MatchReason::MultiVersion => i18n("Multiple versions"), MatchReason::SameVersionDifferentPath => i18n("Same version, different path"), }; let description = if group.potential_savings > 0 { i18n_f( "{reason} - Total: {total} - Potential savings: {savings}", &[ ("{reason}", &reason_text), ("{total}", &widgets::format_size(group.total_size as i64)), ("{savings}", &widgets::format_size(group.potential_savings as i64)), ], ) } else { i18n_f( "{reason} - Total: {total}", &[ ("{reason}", &reason_text), ("{total}", &widgets::format_size(group.total_size as i64)), ], ) }; let pref_group = adw::PreferencesGroup::builder() .title(&group.app_name) .description(&description) .build(); let mut removable = Vec::new(); for member in &group.members { let record = &member.record; let unknown = i18n("unknown"); let version = record.app_version.as_deref().unwrap_or(&unknown); let size = widgets::format_size(record.size_bytes); let title = if member.is_recommended { i18n_f("{version} ({size}) - Recommended", &[("{version}", version), ("{size}", &size)]) } else { format!("{} ({})", version, size) }; let row = adw::ActionRow::builder() .title(&title) .subtitle(&record.path) .build(); // Recommendation badge let badge_class = match member.recommendation { MemberRecommendation::KeepNewest | MemberRecommendation::KeepIntegrated => "success", MemberRecommendation::RemoveOlder | MemberRecommendation::RemoveDuplicate => "error", MemberRecommendation::UserChoice => "neutral", }; let badge = widgets::status_badge(member.recommendation.label(), badge_class); badge.set_valign(gtk::Align::Center); row.add_suffix(&badge); // 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(&i18n("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(&i18n_f("Delete {name}", &[("{name}", &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::undo_all_modifications(&db_ref, record_id).ok(); 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(widgets::info_toast(&i18n_f("Removed {name}", &[("{name}", &record_name)]))); }); row.add_suffix(&delete_btn); } pref_group.add(&row); } (pref_group, removable) }