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 crate::i18n::{i18n, i18n_f}; 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) -> String { match self { ReclaimCategory::OrphanedDesktopEntry => i18n("Orphaned desktop entries"), ReclaimCategory::CacheData => i18n("Cache data"), ReclaimCategory::DuplicateAppImage => i18n("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, _db: &Rc) { let dialog = adw::Dialog::builder() .title(&i18n("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>> = 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(&i18n("Analysis Failed")) .description(&i18n("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(&i18n("Analyzing disk usage...")) .css_classes(["title-3"]) .build(); page.append(&label); let subtitle = gtk::Label::builder() .label(&i18n("Checking for orphaned files, cache data, and duplicates")) .css_classes(["dimmed"]) .build(); page.append(&subtitle); page } fn build_review_step( items: &Rc>>, on_confirm: impl Fn(Vec) + 'static, ) -> gtk::Box { let on_confirm: Rc)> = 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(&i18n("All Clean")) .description(&i18n("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(&i18n_f("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(&i18n("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>> = 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_text = cat.label(); let cat_label = gtk::Label::builder() .label(&format!("{} ({})", cat_label_text, 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_text), ]); 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(&i18n("Clean Selected")) .build(); clean_button.add_css_class("destructive-action"); clean_button.update_property(&[ gtk::accessible::Property::Label(&i18n("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 = 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(&i18n("Confirm Cleanup")) .body(&i18n_f( "Remove {} items?", &[("{}", &count.to_string())], )) .close_response("cancel") .default_response("clean") .build(); confirm.add_response("cancel", &i18n("Cancel")); confirm.add_response("clean", &i18n("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, 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(&i18n("Nothing Selected")) .description(&i18n("No items were selected for cleanup.")) .build(); page.append(&status); } else { let status = adw::StatusPage::builder() .icon_name("user-trash-symbolic") .title(&i18n("Cleanup Complete")) .description(&i18n_f( "Removed {count} items, freeing {size}", &[ ("{count}", &count.to_string()), ("{size}", &widgets::format_size(size as i64)), ], )) .build(); page.append(&status); } let close_button = gtk::Button::builder() .label(&i18n("Close")) .halign(gtk::Align::Center) .build(); close_button.add_css_class("pill"); close_button.update_property(&[ gtk::accessible::Property::Label(&i18n("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 { 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 }