Implement Driftwood AppImage manager - Phases 1 and 2

Phase 1 - Application scaffolding:
- GTK4/libadwaita application window with AdwNavigationView
- GSettings-backed window state persistence
- GResource-compiled CSS and schema
- Library view with grid/list toggle, search, sorting, filtering
- Detail view with file info, desktop integration controls
- Preferences window with scan directories, theme, behavior settings
- CLI with list, scan, integrate, remove, clean, inspect commands
- AppImage discovery, metadata extraction, desktop integration
- Orphaned desktop entry detection and cleanup
- AppImage packaging script

Phase 2 - Intelligence layer:
- Database schema v2 with migration for status tracking columns
- FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher)
- Wayland awareness engine (session type, toolkit detection, XWayland)
- Update info parsing from AppImage ELF sections (.upd_info)
- GitHub/GitLab Releases API integration for update checking
- Update download with progress tracking and atomic apply
- Launch wrapper with FUSE auto-detection and usage tracking
- Duplicate and multi-version detection with recommendations
- Dashboard with system health, library stats, disk usage
- Update check dialog (single and batch)
- Duplicate resolution dialog
- Status badges on library cards and detail view
- Extended CLI: status, check-updates, duplicates, launch commands

49 tests passing across all modules.
This commit is contained in:
lashman
2026-02-26 23:04:27 +02:00
parent 588b1b1525
commit fa28955919
33 changed files with 10401 additions and 0 deletions

157
src/ui/preferences.rs Normal file
View File

@@ -0,0 +1,157 @@
use adw::prelude::*;
use gtk::gio;
use crate::config::APP_ID;
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
let settings = gio::Settings::new(APP_ID);
// --- General page ---
let general_page = adw::PreferencesPage::builder()
.title("General")
.icon_name("emblem-system-symbolic")
.build();
// Appearance group
let appearance_group = adw::PreferencesGroup::builder()
.title("Appearance")
.build();
let theme_row = adw::ComboRow::builder()
.title("Color Scheme")
.subtitle("Choose light, dark, or follow system preference")
.build();
let model = gtk::StringList::new(&["Follow System", "Light", "Dark"]);
theme_row.set_model(Some(&model));
let current = settings.string("color-scheme");
theme_row.set_selected(match current.as_str() {
"force-light" => 1,
"force-dark" => 2,
_ => 0,
});
let settings_clone = settings.clone();
theme_row.connect_selected_notify(move |row| {
let value = match row.selected() {
1 => "force-light",
2 => "force-dark",
_ => "default",
};
settings_clone.set_string("color-scheme", value).ok();
});
appearance_group.add(&theme_row);
general_page.add(&appearance_group);
// Scan Locations group
let scan_group = adw::PreferencesGroup::builder()
.title("Scan Locations")
.description("Directories to scan for AppImage files")
.build();
let dirs = settings.strv("scan-directories");
let dir_list_box = gtk::ListBox::new();
dir_list_box.add_css_class("boxed-list");
dir_list_box.set_selection_mode(gtk::SelectionMode::None);
for dir in &dirs {
add_directory_row(&dir_list_box, &dir, &settings);
}
scan_group.add(&dir_list_box);
// Add location button
let add_button = gtk::Button::builder()
.label("Add Location")
.build();
add_button.add_css_class("flat");
let settings_add = settings.clone();
let list_box_ref = dir_list_box.clone();
let dialog_weak = dialog.downgrade();
add_button.connect_clicked(move |_| {
let file_dialog = gtk::FileDialog::builder()
.title("Choose a directory")
.modal(true)
.build();
let settings_ref = settings_add.clone();
let list_ref = list_box_ref.clone();
let dlg = dialog_weak.upgrade();
// Get the root window as the transient parent for the file dialog
let parent_window: Option<gtk::Window> = dlg
.as_ref()
.and_then(|d| d.root())
.and_then(|r| r.downcast::<gtk::Window>().ok());
file_dialog.select_folder(
parent_window.as_ref(),
None::<&gio::Cancellable>,
move |result| {
if let Ok(file) = result {
if let Some(path) = file.path() {
let path_str = path.to_string_lossy().to_string();
let mut current_dirs: Vec<String> = settings_ref
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.collect();
if !current_dirs.contains(&path_str) {
current_dirs.push(path_str.clone());
let refs: Vec<&str> =
current_dirs.iter().map(|s| s.as_str()).collect();
settings_ref.set_strv("scan-directories", refs).ok();
add_directory_row(&list_ref, &path_str, &settings_ref);
}
}
}
},
);
});
scan_group.add(&add_button);
general_page.add(&scan_group);
dialog.add(&general_page);
dialog.present(Some(parent));
}
fn add_directory_row(list_box: &gtk::ListBox, dir: &str, settings: &gio::Settings) {
let row = adw::ActionRow::builder()
.title(dir)
.build();
let remove_btn = gtk::Button::builder()
.icon_name("edit-delete-symbolic")
.valign(gtk::Align::Center)
.tooltip_text("Remove")
.build();
remove_btn.add_css_class("flat");
let list_ref = list_box.clone();
let settings_ref = settings.clone();
let dir_str = dir.to_string();
let row_ref = row.clone();
remove_btn.connect_clicked(move |_| {
let current_dirs: Vec<String> = settings_ref
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.filter(|s| s != &dir_str)
.collect();
let refs: Vec<&str> = current_dirs.iter().map(|s| s.as_str()).collect();
settings_ref.set_strv("scan-directories", refs).ok();
list_ref.remove(&row_ref);
});
row.add_suffix(&remove_btn);
list_box.append(&row);
}