Add WCAG accessible labels and confirmation dialog to cleanup wizard
This commit is contained in:
520
src/ui/cleanup_wizard.rs
Normal file
520
src/ui/cleanup_wizard.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user