diff --git a/src/ui/cleanup_wizard.rs b/src/ui/cleanup_wizard.rs new file mode 100644 index 0000000..6cb3475 --- /dev/null +++ b/src/ui/cleanup_wizard.rs @@ -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, _db: &Rc) { + 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>> = 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>>, + 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("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>> = + 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 = 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, + 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 { + 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 +}