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:
383
src/ui/dashboard.rs
Normal file
383
src/ui/dashboard.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user