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

383
src/ui/dashboard.rs Normal file
View File

@@ -0,0 +1,383 @@
use adw::prelude::*;
use gtk::gio;
use std::rc::Rc;
use crate::core::database::Database;
use crate::core::duplicates;
use crate::core::fuse;
use crate::core::wayland;
use super::widgets;
/// Build the dashboard page showing system health and statistics.
pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(24)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
// Section 1: System Status
content.append(&build_system_status_section());
// Section 2: Library Statistics
content.append(&build_library_stats_section(db));
// Section 3: Updates Summary
content.append(&build_updates_summary_section(db));
// Section 4: Duplicates Summary
content.append(&build_duplicates_summary_section(db));
// Section 5: Disk Usage
content.append(&build_disk_usage_section(db));
clamp.set_child(Some(&content));
let scrolled = gtk::ScrolledWindow::builder()
.child(&clamp)
.vexpand(true)
.build();
let header = adw::HeaderBar::new();
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled));
adw::NavigationPage::builder()
.title("Dashboard")
.tag("dashboard")
.child(&toolbar)
.build()
}
fn build_system_status_section() -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("System Status")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
// Session type
let session = wayland::detect_session_type();
let session_row = adw::ActionRow::builder()
.title("Display server")
.subtitle(session.label())
.build();
let session_badge = widgets::status_badge(
session.label(),
match session {
wayland::SessionType::Wayland => "success",
wayland::SessionType::X11 => "warning",
wayland::SessionType::Unknown => "neutral",
},
);
session_badge.set_valign(gtk::Align::Center);
session_row.add_suffix(&session_badge);
list_box.append(&session_row);
// Desktop environment
let de = wayland::detect_desktop_environment();
let de_row = adw::ActionRow::builder()
.title("Desktop environment")
.subtitle(&de)
.build();
list_box.append(&de_row);
// FUSE status
let fuse_info = fuse::detect_system_fuse();
let fuse_row = adw::ActionRow::builder()
.title("FUSE")
.subtitle(fuse_description(&fuse_info))
.build();
let fuse_badge = widgets::status_badge(
fuse_info.status.label(),
fuse_info.status.badge_class(),
);
fuse_badge.set_valign(gtk::Align::Center);
fuse_row.add_suffix(&fuse_badge);
list_box.append(&fuse_row);
// Install hint if FUSE not functional
if let Some(ref hint) = fuse_info.install_hint {
let hint_row = adw::ActionRow::builder()
.title("Fix FUSE")
.subtitle(hint)
.subtitle_selectable(true)
.css_classes(["monospace"])
.build();
list_box.append(&hint_row);
}
// XWayland
let has_xwayland = wayland::has_xwayland();
let xwayland_row = adw::ActionRow::builder()
.title("XWayland")
.subtitle(if has_xwayland { "Running" } else { "Not detected" })
.build();
let xwayland_badge = widgets::status_badge(
if has_xwayland { "Available" } else { "Unavailable" },
if has_xwayland { "success" } else { "neutral" },
);
xwayland_badge.set_valign(gtk::Align::Center);
xwayland_row.add_suffix(&xwayland_badge);
list_box.append(&xwayland_row);
// AppImageLauncher conflict check
if let Some(version) = fuse::detect_appimagelauncher() {
let ail_row = adw::ActionRow::builder()
.title("AppImageLauncher detected")
.subtitle(&format!(
"Version {} - may conflict with some AppImage runtimes",
version
))
.build();
let ail_badge = widgets::status_badge("Conflict", "warning");
ail_badge.set_valign(gtk::Align::Center);
ail_row.add_suffix(&ail_badge);
list_box.append(&ail_row);
}
section.append(&list_box);
section
}
fn fuse_description(info: &fuse::FuseSystemInfo) -> String {
let mut parts = Vec::new();
if info.has_libfuse2 {
parts.push("libfuse2");
}
if info.has_libfuse3 {
parts.push("libfuse3");
}
if info.has_fusermount {
parts.push("fusermount");
}
if info.has_dev_fuse {
parts.push("/dev/fuse");
}
if parts.is_empty() {
"No FUSE components detected".to_string()
} else {
format!("Available: {}", parts.join(", "))
}
}
fn build_library_stats_section(db: &Rc<Database>) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Library")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
let records = db.get_all_appimages().unwrap_or_default();
let total = records.len();
let integrated = records.iter().filter(|r| r.integrated).count();
let executable = records.iter().filter(|r| r.is_executable).count();
let total_row = adw::ActionRow::builder()
.title("Total AppImages")
.subtitle(&total.to_string())
.build();
list_box.append(&total_row);
let integrated_row = adw::ActionRow::builder()
.title("Integrated")
.subtitle(&format!("{} of {}", integrated, total))
.build();
list_box.append(&integrated_row);
let exec_row = adw::ActionRow::builder()
.title("Executable")
.subtitle(&format!("{} of {}", executable, total))
.build();
if executable < total {
let badge = widgets::status_badge(
&format!("{} not executable", total - executable),
"warning",
);
badge.set_valign(gtk::Align::Center);
exec_row.add_suffix(&badge);
}
list_box.append(&exec_row);
section.append(&list_box);
section
}
fn build_updates_summary_section(db: &Rc<Database>) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Updates")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
let records = db.get_all_appimages().unwrap_or_default();
let with_update_info = records
.iter()
.filter(|r| r.update_info.is_some())
.count();
let with_updates = records
.iter()
.filter(|r| {
if let (Some(ref latest), Some(ref current)) = (&r.latest_version, &r.app_version) {
crate::core::updater::version_is_newer(latest, current)
} else {
false
}
})
.count();
let info_row = adw::ActionRow::builder()
.title("With update info")
.subtitle(&format!("{} of {}", with_update_info, records.len()))
.build();
list_box.append(&info_row);
let updates_row = adw::ActionRow::builder()
.title("Updates available")
.subtitle(&with_updates.to_string())
.build();
if with_updates > 0 {
let badge = widgets::status_badge(&format!("{} updates", with_updates), "info");
badge.set_valign(gtk::Align::Center);
updates_row.add_suffix(&badge);
}
list_box.append(&updates_row);
section.append(&list_box);
section
}
fn build_duplicates_summary_section(db: &Rc<Database>) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Duplicates")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
let groups = duplicates::detect_duplicates(db);
let summary = duplicates::summarize_duplicates(&groups);
if summary.total_groups == 0 {
let row = adw::ActionRow::builder()
.title("No duplicates found")
.subtitle("All AppImages appear unique")
.build();
let badge = widgets::status_badge("Clean", "success");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
list_box.append(&row);
} else {
let groups_row = adw::ActionRow::builder()
.title("Duplicate groups")
.subtitle(&summary.total_groups.to_string())
.build();
list_box.append(&groups_row);
if summary.total_potential_savings > 0 {
let savings_row = adw::ActionRow::builder()
.title("Potential savings")
.subtitle(&widgets::format_size(summary.total_potential_savings as i64))
.build();
let badge = widgets::status_badge("Reclaimable", "warning");
badge.set_valign(gtk::Align::Center);
savings_row.add_suffix(&badge);
list_box.append(&savings_row);
}
}
section.append(&list_box);
section
}
fn build_disk_usage_section(db: &Rc<Database>) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Disk Usage")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
let records = db.get_all_appimages().unwrap_or_default();
let total_bytes: i64 = records.iter().map(|r| r.size_bytes).sum();
let total_row = adw::ActionRow::builder()
.title("Total disk usage")
.subtitle(&widgets::format_size(total_bytes))
.build();
list_box.append(&total_row);
// Largest AppImages
let mut sorted = records.clone();
sorted.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
for record in sorted.iter().take(3) {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let row = adw::ActionRow::builder()
.title(name)
.subtitle(&widgets::format_size(record.size_bytes))
.build();
list_box.append(&row);
}
section.append(&list_box);
section
}