Add WCAG accessible labels and confirmation dialog to cleanup wizard

This commit is contained in:
lashman
2026-02-27 10:07:17 +02:00
parent 2ea85ac700
commit fae87a753a

520
src/ui/cleanup_wizard.rs Normal file
View File

@@ -0,0 +1,520 @@
use adw::prelude::*;
use gtk::gio;
use std::cell::RefCell;
use std::rc::Rc;
use crate::core::database::Database;
use crate::core::duplicates;
use crate::core::footprint;
use crate::core::orphan;
use super::widgets;
/// A reclaimable item discovered during analysis.
#[derive(Debug, Clone)]
struct ReclaimableItem {
label: String,
subtitle: String,
path: String,
size_bytes: u64,
category: ReclaimCategory,
selected: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ReclaimCategory {
OrphanedDesktopEntry,
CacheData,
DuplicateAppImage,
}
impl ReclaimCategory {
fn label(&self) -> &'static str {
match self {
ReclaimCategory::OrphanedDesktopEntry => "Orphaned desktop entries",
ReclaimCategory::CacheData => "Cache data",
ReclaimCategory::DuplicateAppImage => "Duplicate AppImages",
}
}
fn icon_name(&self) -> &'static str {
match self {
ReclaimCategory::OrphanedDesktopEntry => "user-trash-symbolic",
ReclaimCategory::CacheData => "folder-templates-symbolic",
ReclaimCategory::DuplicateAppImage => "edit-copy-symbolic",
}
}
}
/// Show the disk space reclamation wizard as an AdwDialog.
pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
let dialog = adw::Dialog::builder()
.title("Disk Space Cleanup")
.content_width(500)
.content_height(550)
.build();
let stack = gtk::Stack::new();
stack.set_transition_type(gtk::StackTransitionType::SlideLeft);
// Shared state
let items: Rc<RefCell<Vec<ReclaimableItem>>> = Rc::new(RefCell::new(Vec::new()));
// Step 1: Analysis (scanning) - use adw::Spinner
let step1 = build_analysis_step();
stack.add_named(&step1, Some("analysis"));
// Step 2: Review placeholder
let step2_placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
stack.add_named(&step2_placeholder, Some("review"));
// Step 3: Complete placeholder
let step3_placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
stack.add_named(&step3_placeholder, Some("complete"));
// Header bar
let header = adw::HeaderBar::new();
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&stack));
dialog.set_child(Some(&toolbar));
dialog.present(Some(parent));
// Start analysis in background
let stack_ref = stack.clone();
let items_ref = items.clone();
let dialog_ref = dialog.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
analyze_reclaimable(&bg_db)
})
.await;
match result {
Ok(found_items) => {
*items_ref.borrow_mut() = found_items;
let items_for_review = items_ref.clone();
let stack_for_complete = stack_ref.clone();
let dialog_for_complete = dialog_ref.clone();
let review = build_review_step(
&items_for_review,
move |selected_items| {
let stack_c = stack_for_complete.clone();
let dialog_c = dialog_for_complete.clone();
execute_cleanup(selected_items, stack_c, dialog_c);
},
);
if let Some(child) = stack_ref.child_by_name("review") {
stack_ref.remove(&child);
}
stack_ref.add_named(&review, Some("review"));
stack_ref.set_visible_child_name("review");
}
Err(_) => {
let error_page = adw::StatusPage::builder()
.icon_name("dialog-error-symbolic")
.title("Analysis Failed")
.description("Could not analyze disk usage.")
.build();
if let Some(child) = stack_ref.child_by_name("review") {
stack_ref.remove(&child);
}
stack_ref.add_named(&error_page, Some("review"));
stack_ref.set_visible_child_name("review");
}
}
});
}
fn build_analysis_step() -> gtk::Box {
let page = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.valign(gtk::Align::Center)
.halign(gtk::Align::Center)
.spacing(24)
.build();
// Use adw::Spinner for a cleaner look
let spinner = adw::Spinner::builder()
.width_request(48)
.height_request(48)
.halign(gtk::Align::Center)
.build();
page.append(&spinner);
let label = gtk::Label::builder()
.label("Analyzing disk usage...")
.css_classes(["title-3"])
.build();
page.append(&label);
let subtitle = gtk::Label::builder()
.label("Checking for orphaned files, cache data, and duplicates")
.css_classes(["dimmed"])
.build();
page.append(&subtitle);
page
}
fn build_review_step(
items: &Rc<RefCell<Vec<ReclaimableItem>>>,
on_confirm: impl Fn(Vec<ReclaimableItem>) + 'static,
) -> gtk::Box {
let on_confirm: Rc<dyn Fn(Vec<ReclaimableItem>)> = Rc::new(on_confirm);
let page = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.build();
let items_ref = items.borrow();
if items_ref.is_empty() {
let empty = adw::StatusPage::builder()
.icon_name("emblem-ok-symbolic")
.title("All Clean")
.description("No reclaimable disk space found.")
.vexpand(true)
.build();
page.append(&empty);
return page;
}
// Summary header
let total_size: u64 = items_ref.iter().map(|i| i.size_bytes).sum();
let summary_label = gtk::Label::builder()
.label(&format!("Found {} reclaimable", widgets::format_size(total_size as i64)))
.css_classes(["title-3"])
.margin_top(12)
.margin_start(18)
.margin_end(18)
.halign(gtk::Align::Start)
.build();
page.append(&summary_label);
let desc_label = gtk::Label::builder()
.label("Select items to remove")
.css_classes(["dimmed"])
.margin_start(18)
.margin_end(18)
.margin_bottom(12)
.halign(gtk::Align::Start)
.build();
page.append(&desc_label);
// Scrollable list
let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_start(18)
.margin_end(18)
.margin_bottom(12)
.build();
let categories = [
ReclaimCategory::DuplicateAppImage,
ReclaimCategory::CacheData,
ReclaimCategory::OrphanedDesktopEntry,
];
let check_buttons: Rc<RefCell<Vec<(usize, gtk::CheckButton)>>> =
Rc::new(RefCell::new(Vec::new()));
for cat in &categories {
let cat_items: Vec<(usize, &ReclaimableItem)> = items_ref
.iter()
.enumerate()
.filter(|(_, i)| i.category == *cat)
.collect();
if cat_items.is_empty() {
continue;
}
let cat_size: u64 = cat_items.iter().map(|(_, i)| i.size_bytes).sum();
// Category header with icon
let cat_header = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.build();
let cat_icon = gtk::Image::from_icon_name(cat.icon_name());
cat_icon.set_pixel_size(16);
cat_header.append(&cat_icon);
let cat_label = gtk::Label::builder()
.label(&format!("{} ({})", cat.label(), widgets::format_size(cat_size as i64)))
.css_classes(["title-4"])
.halign(gtk::Align::Start)
.build();
cat_header.append(&cat_label);
content.append(&cat_header);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
list_box.update_property(&[
gtk::accessible::Property::Label(cat.label()),
]);
for (idx, item) in &cat_items {
let check = gtk::CheckButton::builder()
.active(item.selected)
.valign(gtk::Align::Center)
.build();
check_buttons.borrow_mut().push((*idx, check.clone()));
let row = adw::ActionRow::builder()
.title(&item.label)
.subtitle(&item.subtitle)
.activatable_widget(&check)
.build();
row.add_prefix(&check);
let size_label = gtk::Label::new(Some(&widgets::format_size(item.size_bytes as i64)));
size_label.add_css_class("dimmed");
size_label.set_valign(gtk::Align::Center);
row.add_suffix(&size_label);
list_box.append(&row);
}
content.append(&list_box);
}
scrolled.set_child(Some(&content));
page.append(&scrolled);
// Bottom bar
let bottom_bar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(18)
.margin_end(18)
.halign(gtk::Align::End)
.build();
let clean_button = gtk::Button::builder()
.label("Clean Selected")
.build();
clean_button.add_css_class("destructive-action");
clean_button.update_property(&[
gtk::accessible::Property::Label("Clean selected items"),
]);
let items_clone = items.clone();
let checks = check_buttons;
let on_confirm_ref = on_confirm.clone();
clean_button.connect_clicked(move |btn| {
let checks = checks.borrow();
let mut items_mut = items_clone.borrow_mut();
for (idx, check) in checks.iter() {
if *idx < items_mut.len() {
items_mut[*idx].selected = check.is_active();
}
}
let selected: Vec<ReclaimableItem> = items_mut
.iter()
.filter(|i| i.selected)
.cloned()
.collect();
drop(items_mut);
let count = selected.len();
if count == 0 {
on_confirm_ref(selected);
return;
}
let confirm = adw::AlertDialog::builder()
.heading("Confirm Cleanup")
.body(&format!(
"Remove {} item{}?",
count,
if count == 1 { "" } else { "s" }
))
.close_response("cancel")
.default_response("clean")
.build();
confirm.add_response("cancel", "Cancel");
confirm.add_response("clean", "Clean");
confirm.set_response_appearance("clean", adw::ResponseAppearance::Destructive);
let on_confirm_inner = on_confirm_ref.clone();
confirm.connect_response(None, move |_, response| {
if response == "clean" {
on_confirm_inner(selected.clone());
}
});
confirm.present(Some(btn));
});
bottom_bar.append(&clean_button);
page.append(&bottom_bar);
page
}
fn execute_cleanup(
items: Vec<ReclaimableItem>,
stack: gtk::Stack,
dialog: adw::Dialog,
) {
let total_count = items.len();
let total_size: u64 = items.iter().map(|i| i.size_bytes).sum();
for item in &items {
let path = std::path::Path::new(&item.path);
if !path.exists() {
continue;
}
match item.category {
ReclaimCategory::OrphanedDesktopEntry => {
std::fs::remove_file(path).ok();
}
ReclaimCategory::CacheData => {
if path.is_dir() {
std::fs::remove_dir_all(path).ok();
} else {
std::fs::remove_file(path).ok();
}
}
ReclaimCategory::DuplicateAppImage => {
std::fs::remove_file(path).ok();
}
}
}
let complete = build_complete_step(total_count, total_size, &dialog);
if let Some(child) = stack.child_by_name("complete") {
stack.remove(&child);
}
stack.add_named(&complete, Some("complete"));
stack.set_visible_child_name("complete");
}
fn build_complete_step(count: usize, size: u64, dialog: &adw::Dialog) -> gtk::Box {
let page = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.valign(gtk::Align::Center)
.halign(gtk::Align::Center)
.spacing(12)
.vexpand(true)
.build();
if count == 0 {
let status = adw::StatusPage::builder()
.icon_name("emblem-ok-symbolic")
.title("Nothing Selected")
.description("No items were selected for cleanup.")
.build();
page.append(&status);
} else {
let status = adw::StatusPage::builder()
.icon_name("user-trash-symbolic")
.title("Cleanup Complete")
.description(&format!(
"Removed {} item{}, freeing {}",
count,
if count == 1 { "" } else { "s" },
widgets::format_size(size as i64),
))
.build();
page.append(&status);
}
let close_button = gtk::Button::builder()
.label("Close")
.halign(gtk::Align::Center)
.build();
close_button.add_css_class("pill");
close_button.update_property(&[
gtk::accessible::Property::Label("Close cleanup dialog"),
]);
let dialog_ref = dialog.clone();
close_button.connect_clicked(move |_| {
dialog_ref.close();
});
page.append(&close_button);
page
}
/// Analyze disk for reclaimable items.
fn analyze_reclaimable(db: &Database) -> Vec<ReclaimableItem> {
let mut items = Vec::new();
// 1. Orphaned desktop entries
let orphans = orphan::detect_orphans();
for entry in &orphans {
let path_str = entry.desktop_file_path.to_string_lossy().to_string();
let size = std::fs::metadata(&entry.desktop_file_path).map(|m| m.len()).unwrap_or(0);
let name = entry.app_name.as_deref().unwrap_or("Unknown app");
items.push(ReclaimableItem {
label: format!("{} desktop entry", name),
subtitle: path_str.clone(),
path: path_str,
size_bytes: size,
category: ReclaimCategory::OrphanedDesktopEntry,
selected: true,
});
}
// 2. Cache data from footprint discovery
let records = db.get_all_appimages().unwrap_or_default();
for record in &records {
let paths = db.get_app_data_paths(record.id).unwrap_or_default();
for dp in &paths {
if dp.path_type == "cache" {
let path = std::path::Path::new(&dp.path);
if path.exists() {
let size = footprint::dir_size_pub(path);
if size > 0 {
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
items.push(ReclaimableItem {
label: format!("{} cache", app_name),
subtitle: dp.path.clone(),
path: dp.path.clone(),
size_bytes: size,
category: ReclaimCategory::CacheData,
selected: true,
});
}
}
}
}
}
// 3. Duplicate AppImages
let dupes = duplicates::detect_duplicates(db);
for group in &dupes {
for member in &group.members {
if member.recommendation == duplicates::MemberRecommendation::RemoveOlder
|| member.recommendation == duplicates::MemberRecommendation::RemoveDuplicate
{
items.push(ReclaimableItem {
label: member.record.filename.clone(),
subtitle: format!("Duplicate of {} - {}", group.app_name, member.record.path),
path: member.record.path.clone(),
size_bytes: member.record.size_bytes as u64,
category: ReclaimCategory::DuplicateAppImage,
selected: false, // Don't auto-select - user must explicitly choose
});
}
}
}
items
}