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:
523
src/ui/detail_view.rs
Normal file
523
src/ui/detail_view.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
use adw::prelude::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::{AppImageRecord, Database};
|
||||
use crate::core::fuse::FuseStatus;
|
||||
use crate::core::integrator;
|
||||
use crate::core::launcher;
|
||||
use crate::core::wayland::WaylandStatus;
|
||||
use super::widgets;
|
||||
|
||||
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::NavigationPage {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
|
||||
// Scrollable content with clamp
|
||||
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: App Identity
|
||||
content.append(&build_identity_section(record));
|
||||
|
||||
// Section 2: Desktop Integration
|
||||
content.append(&build_integration_section(record, db));
|
||||
|
||||
// Section 3: Runtime Compatibility (Wayland + FUSE)
|
||||
content.append(&build_runtime_section(record));
|
||||
|
||||
// Section 4: Updates
|
||||
content.append(&build_updates_section(record));
|
||||
|
||||
// Section 5: Usage Statistics
|
||||
content.append(&build_usage_section(record, db));
|
||||
|
||||
// Section 6: File Details
|
||||
content.append(&build_file_details_section(record));
|
||||
|
||||
clamp.set_child(Some(&content));
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.child(&clamp)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Header bar with per-app actions
|
||||
let header = adw::HeaderBar::new();
|
||||
|
||||
let launch_button = gtk::Button::builder()
|
||||
.label("Launch")
|
||||
.build();
|
||||
launch_button.add_css_class("suggested-action");
|
||||
let record_id = record.id;
|
||||
let path = record.path.clone();
|
||||
let db_launch = db.clone();
|
||||
launch_button.connect_clicked(move |_| {
|
||||
let appimage_path = std::path::Path::new(&path);
|
||||
let result = launcher::launch_appimage(
|
||||
&db_launch,
|
||||
record_id,
|
||||
appimage_path,
|
||||
"gui_detail",
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
match result {
|
||||
launcher::LaunchResult::Started { .. } => {
|
||||
log::info!("Launched AppImage: {}", path);
|
||||
}
|
||||
launcher::LaunchResult::Failed(msg) => {
|
||||
log::error!("Failed to launch: {}", msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
header.pack_end(&launch_button);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title(name)
|
||||
.tag("detail")
|
||||
.child(&toolbar)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_identity_section(record: &AppImageRecord) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("App Info")
|
||||
.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 name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
|
||||
// Icon + name row
|
||||
let name_row = adw::ActionRow::builder()
|
||||
.title(name)
|
||||
.build();
|
||||
|
||||
if let Some(ref icon_path) = record.icon_path {
|
||||
let path = std::path::Path::new(icon_path);
|
||||
if path.exists() {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(path) {
|
||||
let image = gtk::Image::builder()
|
||||
.pixel_size(48)
|
||||
.build();
|
||||
image.set_paintable(Some(&texture));
|
||||
name_row.add_prefix(&image);
|
||||
}
|
||||
}
|
||||
}
|
||||
list_box.append(&name_row);
|
||||
|
||||
// Version
|
||||
if let Some(ref version) = record.app_version {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Version")
|
||||
.subtitle(version)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
// Description
|
||||
if let Some(ref desc) = record.description {
|
||||
if !desc.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Description")
|
||||
.subtitle(desc)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
// Architecture
|
||||
if let Some(ref arch) = record.architecture {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Architecture")
|
||||
.subtitle(arch)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
// Categories
|
||||
if let Some(ref cats) = record.categories {
|
||||
if !cats.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Categories")
|
||||
.subtitle(cats)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_integration_section(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Desktop Integration")
|
||||
.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 switch_row = adw::SwitchRow::builder()
|
||||
.title("Add to application menu")
|
||||
.subtitle("Creates a .desktop file and installs the icon")
|
||||
.active(record.integrated)
|
||||
.build();
|
||||
|
||||
let record_id = record.id;
|
||||
let record_clone = record.clone();
|
||||
let db_ref = db.clone();
|
||||
switch_row.connect_active_notify(move |row| {
|
||||
if row.is_active() {
|
||||
match integrator::integrate(&record_clone) {
|
||||
Ok(result) => {
|
||||
db_ref
|
||||
.set_integrated(
|
||||
record_id,
|
||||
true,
|
||||
Some(&result.desktop_file_path.to_string_lossy()),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Integration failed: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
integrator::remove_integration(&record_clone).ok();
|
||||
db_ref.set_integrated(record_id, false, None).ok();
|
||||
}
|
||||
});
|
||||
|
||||
list_box.append(&switch_row);
|
||||
|
||||
// Show desktop file path if integrated
|
||||
if record.integrated {
|
||||
if let Some(ref desktop_file) = record.desktop_file {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Desktop file")
|
||||
.subtitle(desktop_file)
|
||||
.css_classes(["monospace"])
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_runtime_section(record: &AppImageRecord) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Runtime Compatibility")
|
||||
.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);
|
||||
|
||||
// Wayland status
|
||||
let wayland_status = record
|
||||
.wayland_status
|
||||
.as_deref()
|
||||
.map(WaylandStatus::from_str)
|
||||
.unwrap_or(WaylandStatus::Unknown);
|
||||
|
||||
let wayland_row = adw::ActionRow::builder()
|
||||
.title("Wayland")
|
||||
.subtitle(wayland_description(&wayland_status))
|
||||
.build();
|
||||
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
|
||||
wayland_badge.set_valign(gtk::Align::Center);
|
||||
wayland_row.add_suffix(&wayland_badge);
|
||||
list_box.append(&wayland_row);
|
||||
|
||||
// FUSE status
|
||||
let fuse_status = record
|
||||
.fuse_status
|
||||
.as_deref()
|
||||
.map(FuseStatus::from_str)
|
||||
.unwrap_or(FuseStatus::MissingLibfuse2);
|
||||
|
||||
let fuse_row = adw::ActionRow::builder()
|
||||
.title("FUSE")
|
||||
.subtitle(fuse_description(&fuse_status))
|
||||
.build();
|
||||
let fuse_badge = widgets::status_badge(fuse_status.label(), fuse_status.badge_class());
|
||||
fuse_badge.set_valign(gtk::Align::Center);
|
||||
fuse_row.add_suffix(&fuse_badge);
|
||||
list_box.append(&fuse_row);
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn wayland_description(status: &WaylandStatus) -> &'static str {
|
||||
match status {
|
||||
WaylandStatus::Native => "Runs natively on Wayland",
|
||||
WaylandStatus::XWayland => "Runs via XWayland compatibility layer",
|
||||
WaylandStatus::Possible => "May run on Wayland with additional flags",
|
||||
WaylandStatus::X11Only => "X11 only - no Wayland support",
|
||||
WaylandStatus::Unknown => "Could not determine Wayland compatibility",
|
||||
}
|
||||
}
|
||||
|
||||
fn fuse_description(status: &FuseStatus) -> &'static str {
|
||||
match status {
|
||||
FuseStatus::FullyFunctional => "FUSE mount available - native AppImage launch",
|
||||
FuseStatus::Fuse3Only => "Only FUSE3 installed - may need libfuse2",
|
||||
FuseStatus::NoFusermount => "fusermount binary not found",
|
||||
FuseStatus::NoDevFuse => "/dev/fuse device not available",
|
||||
FuseStatus::MissingLibfuse2 => "libfuse2 not installed",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_updates_section(record: &AppImageRecord) -> 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);
|
||||
|
||||
// Update info type
|
||||
if let Some(ref update_type) = record.update_type {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Update method")
|
||||
.subtitle(update_type)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
} else {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Update method")
|
||||
.subtitle("No update information embedded")
|
||||
.build();
|
||||
let badge = widgets::status_badge("None", "neutral");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
// Latest version / update status
|
||||
if let Some(ref latest) = record.latest_version {
|
||||
let is_newer = record
|
||||
.app_version
|
||||
.as_deref()
|
||||
.map(|current| crate::core::updater::version_is_newer(latest, current))
|
||||
.unwrap_or(true);
|
||||
|
||||
if is_newer {
|
||||
let subtitle = format!(
|
||||
"{} -> {}",
|
||||
record.app_version.as_deref().unwrap_or("unknown"),
|
||||
latest
|
||||
);
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Update available")
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
let badge = widgets::status_badge("Update", "info");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
list_box.append(&row);
|
||||
} else {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Status")
|
||||
.subtitle("Up to date")
|
||||
.build();
|
||||
let badge = widgets::status_badge("Latest", "success");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
// Last checked
|
||||
if let Some(ref checked) = record.update_checked {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Last checked")
|
||||
.subtitle(checked)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_usage_section(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("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 stats = launcher::get_launch_stats(db, record.id);
|
||||
|
||||
let launches_row = adw::ActionRow::builder()
|
||||
.title("Total launches")
|
||||
.subtitle(&stats.total_launches.to_string())
|
||||
.build();
|
||||
list_box.append(&launches_row);
|
||||
|
||||
if let Some(ref last) = stats.last_launched {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Last launched")
|
||||
.subtitle(last)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_file_details_section(record: &AppImageRecord) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("File Details")
|
||||
.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);
|
||||
|
||||
// Path
|
||||
let path_row = adw::ActionRow::builder()
|
||||
.title("Path")
|
||||
.subtitle(&record.path)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
list_box.append(&path_row);
|
||||
|
||||
// Size
|
||||
let size_row = adw::ActionRow::builder()
|
||||
.title("Size")
|
||||
.subtitle(&widgets::format_size(record.size_bytes))
|
||||
.build();
|
||||
list_box.append(&size_row);
|
||||
|
||||
// Type
|
||||
let type_str = match record.appimage_type {
|
||||
Some(1) => "Type 1",
|
||||
Some(2) => "Type 2",
|
||||
_ => "Unknown",
|
||||
};
|
||||
let type_row = adw::ActionRow::builder()
|
||||
.title("AppImage type")
|
||||
.subtitle(type_str)
|
||||
.build();
|
||||
list_box.append(&type_row);
|
||||
|
||||
// Executable
|
||||
let exec_row = adw::ActionRow::builder()
|
||||
.title("Executable")
|
||||
.subtitle(if record.is_executable { "Yes" } else { "No" })
|
||||
.build();
|
||||
list_box.append(&exec_row);
|
||||
|
||||
// SHA256
|
||||
if let Some(ref hash) = record.sha256 {
|
||||
let hash_row = adw::ActionRow::builder()
|
||||
.title("SHA256")
|
||||
.subtitle(hash)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
list_box.append(&hash_row);
|
||||
}
|
||||
|
||||
// First seen
|
||||
let seen_row = adw::ActionRow::builder()
|
||||
.title("First seen")
|
||||
.subtitle(&record.first_seen)
|
||||
.build();
|
||||
list_box.append(&seen_row);
|
||||
|
||||
// Last scanned
|
||||
let scanned_row = adw::ActionRow::builder()
|
||||
.title("Last scanned")
|
||||
.subtitle(&record.last_scanned)
|
||||
.build();
|
||||
list_box.append(&scanned_row);
|
||||
|
||||
// Notes
|
||||
if let Some(ref notes) = record.notes {
|
||||
if !notes.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Notes")
|
||||
.subtitle(notes)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
Reference in New Issue
Block a user