284 lines
10 KiB
Rust
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)
|
|
}
|