Add WCAG accessible labels and confirmation dialog to duplicate removal

This commit is contained in:
lashman
2026-02-27 10:07:34 +02:00
parent fae87a753a
commit 9c60dfb252

View File

@@ -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)
}