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