Add WCAG accessible labels and confirmation dialog to duplicate removal
This commit is contained in:
@@ -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<RefCell<Vec<(i64, String, String, bool)>>> =
|
||||
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<Database>,
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user