Files
driftwood/src/ui/duplicate_dialog.rs

284 lines
10 KiB
Rust

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<gtk::Widget>,
db: &Rc<Database>,
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<RefCell<Vec<(i64, String, String, bool)>>> =
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<Database>,
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)
}