use adw::prelude::*; use adw::subclass::prelude::*; use gtk::gio; use std::cell::OnceCell; use std::rc::Rc; use std::time::Instant; use crate::config::APP_ID; use crate::core::database::Database; use crate::core::discovery; use crate::core::inspector; use crate::core::orphan; use crate::ui::dashboard; use crate::ui::detail_view; use crate::ui::duplicate_dialog; use crate::ui::library_view::{LibraryState, LibraryView}; use crate::ui::preferences; use crate::ui::update_dialog; mod imp { use super::*; pub struct DriftwoodWindow { pub settings: OnceCell, pub toast_overlay: OnceCell, pub navigation_view: OnceCell, pub library_view: OnceCell, pub database: OnceCell>, } impl Default for DriftwoodWindow { fn default() -> Self { Self { settings: OnceCell::new(), toast_overlay: OnceCell::new(), navigation_view: OnceCell::new(), library_view: OnceCell::new(), database: OnceCell::new(), } } } #[glib::object_subclass] impl ObjectSubclass for DriftwoodWindow { const NAME: &'static str = "DriftwoodWindow"; type Type = super::DriftwoodWindow; type ParentType = adw::ApplicationWindow; } impl ObjectImpl for DriftwoodWindow { fn constructed(&self) { self.parent_constructed(); let window = self.obj(); window.setup_settings(); window.setup_database(); window.setup_ui(); window.restore_window_state(); window.load_initial_data(); } } impl WidgetImpl for DriftwoodWindow {} impl WindowImpl for DriftwoodWindow { fn close_request(&self) -> glib::Propagation { self.obj().save_window_state(); self.parent_close_request() } } impl ApplicationWindowImpl for DriftwoodWindow {} impl AdwApplicationWindowImpl for DriftwoodWindow {} } glib::wrapper! { pub struct DriftwoodWindow(ObjectSubclass) @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; } impl DriftwoodWindow { pub fn new(app: &crate::application::DriftwoodApplication) -> Self { glib::Object::builder() .property("application", app) .build() } fn setup_settings(&self) { let settings = gio::Settings::new(APP_ID); self.imp() .settings .set(settings) .expect("Settings already initialized"); } fn settings(&self) -> &gio::Settings { self.imp().settings.get().expect("Settings not initialized") } fn setup_database(&self) { let db = Database::open().expect("Failed to open database"); if self.imp().database.set(Rc::new(db)).is_err() { panic!("Database already initialized"); } } fn database(&self) -> &Rc { self.imp().database.get().expect("Database not initialized") } fn setup_ui(&self) { // Build the hamburger menu model let menu = gio::Menu::new(); menu.append(Some("Dashboard"), Some("win.dashboard")); menu.append(Some("Preferences"), Some("win.preferences")); let section2 = gio::Menu::new(); section2.append(Some("Scan for AppImages"), Some("win.scan")); section2.append(Some("Check for Updates"), Some("win.check-updates")); section2.append(Some("Find Duplicates"), Some("win.find-duplicates")); menu.append_section(None, §ion2); let section3 = gio::Menu::new(); section3.append(Some("About Driftwood"), Some("app.about")); menu.append_section(None, §ion3); // Library view (contains header bar, search, grid/list, empty state) let library_view = LibraryView::new(&menu); // Navigation view let navigation_view = adw::NavigationView::new(); navigation_view.push(&library_view.page); // Toast overlay wraps everything let toast_overlay = adw::ToastOverlay::new(); toast_overlay.set_child(Some(&navigation_view)); self.set_content(Some(&toast_overlay)); // Wire up card/row activation to push detail view { let nav = navigation_view.clone(); let db = self.database().clone(); library_view.connect_grid_activated(move |record_id| { if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let page = detail_view::build_detail_page(&record, &db); nav.push(&page); } }); } { let nav = navigation_view.clone(); let db = self.database().clone(); library_view.connect_list_activated(move |record_id| { if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let page = detail_view::build_detail_page(&record, &db); nav.push(&page); } }); } // Store references self.imp() .toast_overlay .set(toast_overlay) .expect("ToastOverlay already set"); self.imp() .navigation_view .set(navigation_view) .expect("NavigationView already set"); if self.imp().library_view.set(library_view).is_err() { panic!("LibraryView already set"); } // Set up window actions self.setup_window_actions(); } fn setup_window_actions(&self) { let dashboard_action = gio::ActionEntry::builder("dashboard") .activate(|window: &Self, _, _| { let db = window.database().clone(); let nav = window.imp().navigation_view.get().unwrap(); let page = dashboard::build_dashboard_page(&db); nav.push(&page); }) .build(); // Preferences action let preferences_action = gio::ActionEntry::builder("preferences") .activate(|window: &Self, _, _| { preferences::show_preferences_dialog(window); }) .build(); // Scan action - runs real scan let scan_action = gio::ActionEntry::builder("scan") .activate(|window: &Self, _, _| { window.trigger_scan(); }) .build(); // Clean orphans action let clean_toast = self.imp().toast_overlay.get().unwrap().clone(); let clean_action = gio::ActionEntry::builder("clean-orphans") .activate(move |_window: &Self, _, _| { let toast_ref = clean_toast.clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(|| { orphan::clean_all_orphans() }) .await; match result { Ok(Ok(summary)) => { let msg = format!( "Cleaned {} desktop entries, {} icons", summary.entries_removed, summary.icons_removed, ); toast_ref.add_toast(adw::Toast::new(&msg)); } _ => { toast_ref.add_toast(adw::Toast::new("Failed to clean orphaned entries")); } } }); }) .build(); // Search action - toggles search bar let search_action = gio::ActionEntry::builder("search") .activate(|window: &Self, _, _| { let lib_view = window.imp().library_view.get().unwrap(); lib_view.toggle_search(); }) .build(); // Check for updates action let updates_toast = self.imp().toast_overlay.get().unwrap().clone(); let check_updates_action = gio::ActionEntry::builder("check-updates") .activate(move |window: &Self, _, _| { let toast_ref = updates_toast.clone(); let db = window.database().clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); update_dialog::batch_check_updates(&bg_db) }) .await; match result { Ok(0) => { toast_ref.add_toast(adw::Toast::new("All AppImages are up to date")); } Ok(n) => { let msg = format!("{} update{} available", n, if n == 1 { "" } else { "s" }); toast_ref.add_toast(adw::Toast::new(&msg)); } Err(_) => { toast_ref.add_toast(adw::Toast::new("Failed to check for updates")); } } }); }) .build(); // Find duplicates action let find_duplicates_action = gio::ActionEntry::builder("find-duplicates") .activate(|window: &Self, _, _| { let db = window.database().clone(); let toast_overlay = window.imp().toast_overlay.get().unwrap(); duplicate_dialog::show_duplicate_dialog(window, &db, toast_overlay); }) .build(); self.add_action_entries([ dashboard_action, preferences_action, scan_action, clean_action, search_action, check_updates_action, find_duplicates_action, ]); // Keyboard shortcuts if let Some(app) = self.application() { let gtk_app = app.downcast_ref::().unwrap(); gtk_app.set_accels_for_action("win.scan", &["r", "F5"]); gtk_app.set_accels_for_action("win.search", &["f"]); gtk_app.set_accels_for_action("win.preferences", &["comma"]); } } fn load_initial_data(&self) { let db = self.database(); let library_view = self.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) if !records.is_empty() => { library_view.populate(records); } _ => { // Empty database - show empty state library_view.set_state(LibraryState::Empty); } } // Check for orphaned desktop entries in the background let toast_overlay = self.imp().toast_overlay.get().unwrap().clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(|| { orphan::detect_orphans().len() }) .await; if let Ok(count) = result { if count > 0 { let msg = if count == 1 { "1 orphaned desktop entry found. Use 'Clean' to remove it.".to_string() } else { format!("{} orphaned desktop entries found. Use 'Clean' to remove them.", count) }; let toast = adw::Toast::builder() .title(&msg) .timeout(5) .button_label("Clean") .action_name("win.clean-orphans") .build(); toast_overlay.add_toast(toast); } } }); } fn trigger_scan(&self) { let library_view = self.imp().library_view.get().unwrap(); library_view.set_state(LibraryState::Loading); let settings = self.settings(); let dirs: Vec = settings .strv("scan-directories") .iter() .map(|s| s.to_string()) .collect(); let toast_overlay = self.imp().toast_overlay.get().unwrap().clone(); let window_weak = self.downgrade(); // Run scan in a background thread (opens its own DB connection), // then update UI on main thread using the window's DB. glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database for scan"); let start = Instant::now(); let discovered = discovery::scan_directories(&dirs); let mut new_count = 0i32; let total = discovered.len() as i32; for d in &discovered { let existing = bg_db .get_appimage_by_path(&d.path.to_string_lossy()) .ok() .flatten(); let modified = d.modified_time .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|dur| { chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) }); let id = bg_db.upsert_appimage( &d.path.to_string_lossy(), &d.filename, Some(d.appimage_type.as_i32()), d.size_bytes as i64, d.is_executable, modified.as_deref(), ).unwrap_or(0); if existing.is_none() { new_count += 1; } let needs_metadata = existing .as_ref() .map(|r| r.app_name.is_none()) .unwrap_or(true); if needs_metadata { if let Ok(metadata) = inspector::inspect_appimage(&d.path, &d.appimage_type) { let categories = if metadata.categories.is_empty() { None } else { Some(metadata.categories.join(";")) }; bg_db.update_metadata( id, metadata.app_name.as_deref(), metadata.app_version.as_deref(), metadata.description.as_deref(), metadata.developer.as_deref(), categories.as_deref(), metadata.architecture.as_deref(), metadata.cached_icon_path.as_ref().map(|p| p.to_string_lossy()).as_deref(), Some(&metadata.desktop_entry_content), ).ok(); } } } let duration = start.elapsed().as_millis() as i64; bg_db.log_scan( "manual", &dirs.iter().map(|s| s.to_string()).collect::>(), total, new_count, 0, duration, ).ok(); (total, new_count) }) .await; if let Ok((total, new_count)) = result { // Refresh the library view from the window's main-thread DB if let Some(window) = window_weak.upgrade() { let db = window.database(); let lib_view = window.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) => lib_view.populate(records), Err(_) => lib_view.set_state(LibraryState::Empty), } } let msg = match new_count { 0 if total == 0 => "No AppImages found".to_string(), 0 => format!("{} AppImages up to date", total), 1 => "Found 1 new AppImage".to_string(), n => format!("Found {} new AppImages", n), }; toast_overlay.add_toast(adw::Toast::new(&msg)); } }); } fn save_window_state(&self) { let settings = self.settings(); let (width, height) = self.default_size(); settings .set_int("window-width", width) .expect("Failed to save window width"); settings .set_int("window-height", height) .expect("Failed to save window height"); settings .set_boolean("window-maximized", self.is_maximized()) .expect("Failed to save maximized state"); } fn restore_window_state(&self) { let settings = self.settings(); let width = settings.int("window-width"); let height = settings.int("window-height"); let maximized = settings.boolean("window-maximized"); self.set_default_size(width, height); if maximized { self.maximize(); } } }