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:
119
src/ui/app_card.rs
Normal file
119
src/ui/app_card.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::core::database::AppImageRecord;
|
||||
use crate::core::fuse::FuseStatus;
|
||||
use crate::core::wayland::WaylandStatus;
|
||||
use super::widgets;
|
||||
|
||||
/// Build a grid card for an AppImage.
|
||||
pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
card.add_css_class("app-card");
|
||||
card.set_size_request(160, -1);
|
||||
|
||||
// Icon (48x48)
|
||||
let icon = if let Some(ref icon_path) = record.icon_path {
|
||||
let path = std::path::Path::new(icon_path);
|
||||
if path.exists() {
|
||||
let paintable = gtk::gdk::Texture::from_filename(path).ok();
|
||||
let image = gtk::Image::builder()
|
||||
.pixel_size(48)
|
||||
.build();
|
||||
if let Some(texture) = paintable {
|
||||
image.set_paintable(Some(&texture));
|
||||
} else {
|
||||
image.set_icon_name(Some("application-x-executable-symbolic"));
|
||||
}
|
||||
image
|
||||
} else {
|
||||
gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(48)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(48)
|
||||
.build()
|
||||
};
|
||||
|
||||
// App name
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(name)
|
||||
.css_classes(["app-card-name"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(18)
|
||||
.build();
|
||||
|
||||
// Version
|
||||
let version_text = record.app_version.as_deref().unwrap_or("");
|
||||
let version_label = gtk::Label::builder()
|
||||
.label(version_text)
|
||||
.css_classes(["app-card-version"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
|
||||
card.append(&icon);
|
||||
card.append(&name_label);
|
||||
if !version_text.is_empty() {
|
||||
card.append(&version_label);
|
||||
}
|
||||
|
||||
// Status badges row
|
||||
let badges = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
badges.add_css_class("badge-row");
|
||||
|
||||
// Wayland status badge
|
||||
if let Some(ref ws) = record.wayland_status {
|
||||
let status = WaylandStatus::from_str(ws);
|
||||
if status != WaylandStatus::Unknown {
|
||||
badges.append(&widgets::status_badge(status.label(), status.badge_class()));
|
||||
}
|
||||
}
|
||||
|
||||
// FUSE status badge
|
||||
if let Some(ref fs) = record.fuse_status {
|
||||
let status = FuseStatus::from_str(fs);
|
||||
if !status.is_functional() {
|
||||
badges.append(&widgets::status_badge(status.label(), status.badge_class()));
|
||||
}
|
||||
}
|
||||
|
||||
// Update available badge
|
||||
if record.latest_version.is_some() {
|
||||
if let (Some(ref latest), Some(ref current)) =
|
||||
(&record.latest_version, &record.app_version)
|
||||
{
|
||||
if crate::core::updater::version_is_newer(latest, current) {
|
||||
badges.append(&widgets::status_badge("Update", "info"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Integration badge (only show if not integrated, to reduce clutter)
|
||||
if !record.integrated {
|
||||
badges.append(&widgets::status_badge("Not integrated", "neutral"));
|
||||
}
|
||||
|
||||
card.append(&badges);
|
||||
|
||||
let child = gtk::FlowBoxChild::builder()
|
||||
.child(&card)
|
||||
.build();
|
||||
|
||||
child
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
156
src/ui/duplicate_dialog.rs
Normal file
156
src/ui/duplicate_dialog.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use adw::prelude::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::Database;
|
||||
use crate::core::duplicates::{self, DuplicateGroup, MatchReason, MemberRecommendation};
|
||||
use super::widgets;
|
||||
|
||||
/// Show a dialog listing duplicate/multi-version AppImages with resolution options.
|
||||
pub fn show_duplicate_dialog(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
db: &Rc<Database>,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let groups = duplicates::detect_duplicates(db);
|
||||
|
||||
if groups.is_empty() {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("No Duplicates Found")
|
||||
.body("No duplicate or multi-version AppImages were detected.")
|
||||
.build();
|
||||
dialog.add_response("ok", "OK");
|
||||
dialog.set_default_response(Some("ok"));
|
||||
dialog.present(Some(parent));
|
||||
return;
|
||||
}
|
||||
|
||||
let summary = duplicates::summarize_duplicates(&groups);
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Duplicates & Old Versions")
|
||||
.content_width(600)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(16)
|
||||
.margin_top(16)
|
||||
.margin_bottom(16)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
|
||||
// Summary banner
|
||||
let summary_text = format!(
|
||||
"{} groups found ({} exact duplicates, {} with multiple versions). \
|
||||
Potential savings: {}",
|
||||
summary.total_groups,
|
||||
summary.exact_duplicates,
|
||||
summary.multi_version,
|
||||
widgets::format_size(summary.total_potential_savings as i64),
|
||||
);
|
||||
let summary_label = gtk::Label::builder()
|
||||
.label(&summary_text)
|
||||
.wrap(true)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
summary_label.add_css_class("dim-label");
|
||||
content.append(&summary_label);
|
||||
|
||||
// Build a list for each duplicate group
|
||||
for group in &groups {
|
||||
content.append(&build_group_widget(group));
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn build_group_widget(group: &DuplicateGroup) -> gtk::Box {
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
// Group header
|
||||
let header_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(&group.app_name)
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
header_box.append(&name_label);
|
||||
|
||||
let reason_badge = widgets::status_badge(
|
||||
group.match_reason.label(),
|
||||
match group.match_reason {
|
||||
MatchReason::ExactDuplicate => "error",
|
||||
MatchReason::MultiVersion => "warning",
|
||||
MatchReason::SameVersionDifferentPath => "warning",
|
||||
},
|
||||
);
|
||||
header_box.append(&reason_badge);
|
||||
|
||||
container.append(&header_box);
|
||||
|
||||
// Savings info
|
||||
if group.potential_savings > 0 {
|
||||
let savings_label = gtk::Label::builder()
|
||||
.label(&format!(
|
||||
"Potential savings: {}",
|
||||
widgets::format_size(group.potential_savings as i64)
|
||||
))
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
savings_label.add_css_class("dim-label");
|
||||
container.append(&savings_label);
|
||||
}
|
||||
|
||||
// Members list
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
for member in &group.members {
|
||||
let record = &member.record;
|
||||
let version = record.app_version.as_deref().unwrap_or("unknown");
|
||||
let size = widgets::format_size(record.size_bytes);
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&format!("{} ({})", version, size))
|
||||
.subtitle(&record.path)
|
||||
.build();
|
||||
|
||||
// Recommendation badge
|
||||
let badge_class = match member.recommendation {
|
||||
MemberRecommendation::KeepNewest | MemberRecommendation::KeepIntegrated => "success",
|
||||
MemberRecommendation::RemoveOlder | MemberRecommendation::RemoveDuplicate => "error",
|
||||
MemberRecommendation::UserChoice => "neutral",
|
||||
};
|
||||
let badge = widgets::status_badge(member.recommendation.label(), badge_class);
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
container.append(&list_box);
|
||||
container
|
||||
}
|
||||
480
src/ui/library_view.rs
Normal file
480
src/ui/library_view.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::AppImageRecord;
|
||||
use super::app_card;
|
||||
use super::widgets;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ViewMode {
|
||||
Grid,
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LibraryState {
|
||||
Loading,
|
||||
Empty,
|
||||
Populated,
|
||||
SearchEmpty,
|
||||
}
|
||||
|
||||
pub struct LibraryView {
|
||||
pub page: adw::NavigationPage,
|
||||
pub header_bar: adw::HeaderBar,
|
||||
stack: gtk::Stack,
|
||||
flow_box: gtk::FlowBox,
|
||||
list_box: gtk::ListBox,
|
||||
search_bar: gtk::SearchBar,
|
||||
search_entry: gtk::SearchEntry,
|
||||
subtitle_label: gtk::Label,
|
||||
view_mode: Rc<Cell<ViewMode>>,
|
||||
view_toggle: gtk::ToggleButton,
|
||||
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
||||
search_empty_page: adw::StatusPage,
|
||||
}
|
||||
|
||||
impl LibraryView {
|
||||
pub fn new(menu: >k::gio::Menu) -> Self {
|
||||
let records: Rc<RefCell<Vec<AppImageRecord>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let view_mode = Rc::new(Cell::new(ViewMode::Grid));
|
||||
|
||||
// --- Header bar ---
|
||||
let menu_button = gtk::MenuButton::builder()
|
||||
.icon_name("open-menu-symbolic")
|
||||
.menu_model(menu)
|
||||
.tooltip_text("Menu")
|
||||
.primary(true)
|
||||
.build();
|
||||
menu_button.add_css_class("flat");
|
||||
|
||||
let search_button = gtk::ToggleButton::builder()
|
||||
.icon_name("system-search-symbolic")
|
||||
.tooltip_text("Search")
|
||||
.build();
|
||||
search_button.add_css_class("flat");
|
||||
|
||||
let view_toggle = gtk::ToggleButton::builder()
|
||||
.icon_name("view-list-symbolic")
|
||||
.tooltip_text("Toggle list view")
|
||||
.build();
|
||||
view_toggle.add_css_class("flat");
|
||||
|
||||
let subtitle_label = gtk::Label::builder()
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
|
||||
let title_widget = adw::WindowTitle::builder()
|
||||
.title("Driftwood")
|
||||
.build();
|
||||
|
||||
let header_bar = adw::HeaderBar::builder()
|
||||
.title_widget(&title_widget)
|
||||
.build();
|
||||
header_bar.pack_end(&menu_button);
|
||||
header_bar.pack_end(&search_button);
|
||||
header_bar.pack_end(&view_toggle);
|
||||
|
||||
// --- Search bar ---
|
||||
let search_entry = gtk::SearchEntry::builder()
|
||||
.placeholder_text("Search AppImages...")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let search_clamp = adw::Clamp::builder()
|
||||
.maximum_size(500)
|
||||
.child(&search_entry)
|
||||
.build();
|
||||
|
||||
let search_bar = gtk::SearchBar::builder()
|
||||
.child(&search_clamp)
|
||||
.search_mode_enabled(false)
|
||||
.build();
|
||||
search_bar.connect_entry(&search_entry);
|
||||
|
||||
// Bind search button to search bar
|
||||
search_button
|
||||
.bind_property("active", &search_bar, "search-mode-enabled")
|
||||
.bidirectional()
|
||||
.build();
|
||||
|
||||
// --- Content stack ---
|
||||
let stack = gtk::Stack::builder()
|
||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Loading state
|
||||
let loading_page = adw::StatusPage::builder()
|
||||
.title("Scanning for AppImages...")
|
||||
.build();
|
||||
let spinner = gtk::Spinner::builder()
|
||||
.spinning(true)
|
||||
.width_request(32)
|
||||
.height_request(32)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
loading_page.set_child(Some(&spinner));
|
||||
stack.add_named(&loading_page, Some("loading"));
|
||||
|
||||
// Empty state
|
||||
let empty_button_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.halign(gtk::Align::Center)
|
||||
.spacing(12)
|
||||
.build();
|
||||
|
||||
let scan_now_btn = gtk::Button::builder()
|
||||
.label("Scan Now")
|
||||
.build();
|
||||
scan_now_btn.add_css_class("suggested-action");
|
||||
scan_now_btn.add_css_class("pill");
|
||||
|
||||
let prefs_btn = gtk::Button::builder()
|
||||
.label("Preferences")
|
||||
.build();
|
||||
prefs_btn.add_css_class("flat");
|
||||
prefs_btn.add_css_class("pill");
|
||||
|
||||
empty_button_box.append(&scan_now_btn);
|
||||
empty_button_box.append(&prefs_btn);
|
||||
|
||||
let empty_page = adw::StatusPage::builder()
|
||||
.icon_name("folder-saved-search-symbolic")
|
||||
.title("No AppImages Found")
|
||||
.description(
|
||||
"Driftwood looks for AppImages in ~/Applications and ~/Downloads.\n\
|
||||
Drop an AppImage file here, or add more scan locations in Preferences.",
|
||||
)
|
||||
.child(&empty_button_box)
|
||||
.build();
|
||||
stack.add_named(&empty_page, Some("empty"));
|
||||
|
||||
// Search empty state
|
||||
let search_empty_page = adw::StatusPage::builder()
|
||||
.icon_name("system-search-symbolic")
|
||||
.title("No Results")
|
||||
.description("No AppImages match your search. Try a different search term.")
|
||||
.build();
|
||||
stack.add_named(&search_empty_page, Some("search-empty"));
|
||||
|
||||
// Grid view
|
||||
let flow_box = gtk::FlowBox::builder()
|
||||
.valign(gtk::Align::Start)
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.row_spacing(12)
|
||||
.column_spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let grid_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&flow_box)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
stack.add_named(&grid_scroll, Some("grid"));
|
||||
|
||||
// List view
|
||||
let list_box = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.build();
|
||||
list_box.add_css_class("boxed-list");
|
||||
|
||||
let list_clamp = adw::Clamp::builder()
|
||||
.maximum_size(900)
|
||||
.child(&list_box)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let list_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&list_clamp)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
stack.add_named(&list_scroll, Some("list"));
|
||||
|
||||
// --- Assemble toolbar view ---
|
||||
let content_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.build();
|
||||
content_box.append(&search_bar);
|
||||
content_box.append(&stack);
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header_bar);
|
||||
toolbar_view.set_content(Some(&content_box));
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Driftwood")
|
||||
.tag("library")
|
||||
.child(&toolbar_view)
|
||||
.build();
|
||||
|
||||
// --- Wire up view toggle ---
|
||||
{
|
||||
let stack_ref = stack.clone();
|
||||
let view_mode_ref = view_mode.clone();
|
||||
let toggle_ref = view_toggle.clone();
|
||||
view_toggle.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
view_mode_ref.set(ViewMode::List);
|
||||
toggle_ref.set_icon_name("view-grid-symbolic");
|
||||
toggle_ref.set_tooltip_text(Some("Toggle grid view"));
|
||||
stack_ref.set_visible_child_name("list");
|
||||
} else {
|
||||
view_mode_ref.set(ViewMode::Grid);
|
||||
toggle_ref.set_icon_name("view-list-symbolic");
|
||||
toggle_ref.set_tooltip_text(Some("Toggle list view"));
|
||||
stack_ref.set_visible_child_name("grid");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Wire up search filtering ---
|
||||
{
|
||||
let flow_box_ref = flow_box.clone();
|
||||
let list_box_ref = list_box.clone();
|
||||
let records_ref = records.clone();
|
||||
let stack_ref = stack.clone();
|
||||
let view_mode_ref = view_mode.clone();
|
||||
let search_empty_ref = search_empty_page.clone();
|
||||
search_entry.connect_search_changed(move |entry| {
|
||||
let query = entry.text().to_string().to_lowercase();
|
||||
|
||||
if query.is_empty() {
|
||||
flow_box_ref.set_filter_func(|_| true);
|
||||
let mut i = 0;
|
||||
while let Some(row) = list_box_ref.row_at_index(i) {
|
||||
row.set_visible(true);
|
||||
i += 1;
|
||||
}
|
||||
if !records_ref.borrow().is_empty() {
|
||||
let view_name = if view_mode_ref.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
stack_ref.set_visible_child_name(view_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a snapshot of match results for the filter closure
|
||||
let recs = records_ref.borrow();
|
||||
let match_flags: Vec<bool> = recs
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
let name = rec.app_name.as_deref().unwrap_or(&rec.filename).to_lowercase();
|
||||
let path = rec.path.to_lowercase();
|
||||
name.contains(&query) || path.contains(&query)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let flags_clone = match_flags.clone();
|
||||
flow_box_ref.set_filter_func(move |child| {
|
||||
let idx = child.index() as usize;
|
||||
flags_clone.get(idx).copied().unwrap_or(false)
|
||||
});
|
||||
|
||||
let mut visible_count = 0;
|
||||
for (i, matches) in match_flags.iter().enumerate() {
|
||||
if let Some(row) = list_box_ref.row_at_index(i as i32) {
|
||||
row.set_visible(*matches);
|
||||
}
|
||||
if *matches {
|
||||
visible_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if visible_count == 0 && !recs.is_empty() {
|
||||
search_empty_ref.set_description(Some(
|
||||
&format!("No AppImages match '{}'. Try a different search term.", query)
|
||||
));
|
||||
stack_ref.set_visible_child_name("search-empty");
|
||||
} else {
|
||||
let view_name = if view_mode_ref.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
stack_ref.set_visible_child_name(view_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Wire up empty state buttons ---
|
||||
// These will be connected to actions externally via the public methods
|
||||
scan_now_btn.set_action_name(Some("win.scan"));
|
||||
prefs_btn.set_action_name(Some("win.preferences"));
|
||||
|
||||
Self {
|
||||
page,
|
||||
header_bar,
|
||||
stack,
|
||||
flow_box,
|
||||
list_box,
|
||||
search_bar,
|
||||
search_entry,
|
||||
subtitle_label,
|
||||
view_mode,
|
||||
view_toggle,
|
||||
records,
|
||||
search_empty_page,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_state(&self, state: LibraryState) {
|
||||
match state {
|
||||
LibraryState::Loading => {
|
||||
self.stack.set_visible_child_name("loading");
|
||||
}
|
||||
LibraryState::Empty => {
|
||||
self.stack.set_visible_child_name("empty");
|
||||
}
|
||||
LibraryState::Populated => {
|
||||
let view_name = if self.view_mode.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
self.stack.set_visible_child_name(view_name);
|
||||
}
|
||||
LibraryState::SearchEmpty => {
|
||||
self.stack.set_visible_child_name("search-empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn populate(&self, new_records: Vec<AppImageRecord>) {
|
||||
// Clear existing
|
||||
while let Some(child) = self.flow_box.first_child() {
|
||||
self.flow_box.remove(&child);
|
||||
}
|
||||
while let Some(row) = self.list_box.row_at_index(0) {
|
||||
self.list_box.remove(&row);
|
||||
}
|
||||
|
||||
// Build cards and list rows
|
||||
for record in &new_records {
|
||||
// Grid card
|
||||
let card = app_card::build_app_card(record);
|
||||
self.flow_box.append(&card);
|
||||
|
||||
// List row
|
||||
let row = self.build_list_row(record);
|
||||
self.list_box.append(&row);
|
||||
}
|
||||
|
||||
*self.records.borrow_mut() = new_records;
|
||||
let count = self.records.borrow().len();
|
||||
|
||||
if count == 0 {
|
||||
self.set_state(LibraryState::Empty);
|
||||
} else {
|
||||
self.set_state(LibraryState::Populated);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let subtitle = if let Some(ref ver) = record.app_version {
|
||||
format!("{} - {}", ver, widgets::format_size(record.size_bytes))
|
||||
} else {
|
||||
widgets::format_size(record.size_bytes)
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(name)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
// Icon prefix
|
||||
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(32)
|
||||
.build();
|
||||
image.set_paintable(Some(&texture));
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
} else {
|
||||
let image = gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
} else {
|
||||
let image = gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
|
||||
// Integration badge suffix
|
||||
let badge = widgets::integration_badge(record.integrated);
|
||||
row.add_suffix(&badge);
|
||||
|
||||
// Navigate arrow
|
||||
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
row.add_suffix(&arrow);
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
/// Get the record ID at a given flow box index.
|
||||
pub fn record_at_grid_index(&self, index: usize) -> Option<i64> {
|
||||
self.records.borrow().get(index).map(|r| r.id)
|
||||
}
|
||||
|
||||
/// Get the record ID at a given list box index.
|
||||
pub fn record_at_list_index(&self, index: i32) -> Option<i64> {
|
||||
self.records.borrow().get(index as usize).map(|r| r.id)
|
||||
}
|
||||
|
||||
/// Connect a callback for when a grid card is activated.
|
||||
pub fn connect_grid_activated<F: Fn(i64) + 'static>(&self, f: F) {
|
||||
let records = self.records.clone();
|
||||
self.flow_box.connect_child_activated(move |_, child| {
|
||||
let idx = child.index() as usize;
|
||||
if let Some(record) = records.borrow().get(idx) {
|
||||
f(record.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect a callback for when a list row is activated.
|
||||
pub fn connect_list_activated<F: Fn(i64) + 'static>(&self, f: F) {
|
||||
let records = self.records.clone();
|
||||
self.list_box.connect_row_activated(move |_, row| {
|
||||
let idx = row.index() as usize;
|
||||
if let Some(record) = records.borrow().get(idx) {
|
||||
f(record.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn current_view_mode(&self) -> ViewMode {
|
||||
self.view_mode.get()
|
||||
}
|
||||
|
||||
pub fn toggle_search(&self) {
|
||||
let active = self.search_bar.is_search_mode();
|
||||
self.search_bar.set_search_mode(!active);
|
||||
if !active {
|
||||
self.search_entry.grab_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/ui/mod.rs
Normal file
8
src/ui/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod app_card;
|
||||
pub mod dashboard;
|
||||
pub mod detail_view;
|
||||
pub mod duplicate_dialog;
|
||||
pub mod library_view;
|
||||
pub mod preferences;
|
||||
pub mod update_dialog;
|
||||
pub mod widgets;
|
||||
157
src/ui/preferences.rs
Normal file
157
src/ui/preferences.rs
Normal 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: >k::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);
|
||||
}
|
||||
147
src/ui/update_dialog.rs
Normal file
147
src/ui/update_dialog.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::gio;
|
||||
use std::rc::Rc;
|
||||
use crate::core::database::{AppImageRecord, Database};
|
||||
use crate::core::updater;
|
||||
|
||||
/// Show an update check + apply dialog for a single AppImage.
|
||||
pub fn show_update_dialog(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
record: &AppImageRecord,
|
||||
db: &Rc<Database>,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Check for Updates")
|
||||
.body(&format!(
|
||||
"Checking for updates for {}...",
|
||||
record.app_name.as_deref().unwrap_or(&record.filename)
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("close", "Close");
|
||||
dialog.set_default_response(Some("close"));
|
||||
dialog.set_close_response("close");
|
||||
|
||||
let record_clone = record.clone();
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
|
||||
// Start the update check in the background
|
||||
let record_id = record.id;
|
||||
let path = record.path.clone();
|
||||
let current_version = record.app_version.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let appimage_path = std::path::Path::new(&path);
|
||||
updater::check_appimage_for_update(
|
||||
appimage_path,
|
||||
current_version.as_deref(),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok((type_label, raw_info, Some(check_result))) => {
|
||||
// Store update info in DB
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
db_ref
|
||||
.update_update_info(
|
||||
record_id,
|
||||
raw_info.as_deref(),
|
||||
type_label.as_deref(),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if check_result.update_available {
|
||||
if let Some(ref version) = check_result.latest_version {
|
||||
db_ref.set_update_available(record_id, Some(version), check_result.download_url.as_deref()).ok();
|
||||
}
|
||||
|
||||
let body = format!(
|
||||
"{} -> {}\n\nA new version is available.",
|
||||
record_clone.app_version.as_deref().unwrap_or("unknown"),
|
||||
check_result.latest_version.as_deref().unwrap_or("unknown"),
|
||||
);
|
||||
dialog_ref.set_heading(Some("Update Available"));
|
||||
dialog_ref.set_body(&body);
|
||||
// Future: add "Update" response to trigger download
|
||||
} else {
|
||||
dialog_ref.set_heading(Some("Up to Date"));
|
||||
dialog_ref.set_body(&format!(
|
||||
"{} is already at the latest version ({}).",
|
||||
record_clone.app_name.as_deref().unwrap_or(&record_clone.filename),
|
||||
record_clone.app_version.as_deref().unwrap_or("unknown"),
|
||||
));
|
||||
db_ref.clear_update_available(record_id).ok();
|
||||
}
|
||||
}
|
||||
Ok((type_label, raw_info, None)) => {
|
||||
if raw_info.is_some() {
|
||||
db_ref.update_update_info(record_id, raw_info.as_deref(), type_label.as_deref()).ok();
|
||||
dialog_ref.set_heading(Some("Check Failed"));
|
||||
dialog_ref.set_body("Could not reach the update server. Try again later.");
|
||||
} else {
|
||||
dialog_ref.set_heading(Some("No Update Info"));
|
||||
dialog_ref.set_body(
|
||||
"This AppImage does not contain update information. \
|
||||
Updates must be downloaded manually.",
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
dialog_ref.set_heading(Some("Error"));
|
||||
dialog_ref.set_body("An error occurred while checking for updates.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
/// Batch check all AppImages for updates. Returns count of updates found.
|
||||
pub fn batch_check_updates(db: &Database) -> u32 {
|
||||
let records = match db.get_all_appimages() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get appimages for update check: {}", e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let mut updates_found = 0u32;
|
||||
|
||||
for record in &records {
|
||||
let appimage_path = std::path::Path::new(&record.path);
|
||||
if !appimage_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (type_label, raw_info, check_result) = updater::check_appimage_for_update(
|
||||
appimage_path,
|
||||
record.app_version.as_deref(),
|
||||
);
|
||||
|
||||
// Store update info
|
||||
if raw_info.is_some() || type_label.is_some() {
|
||||
db.update_update_info(
|
||||
record.id,
|
||||
raw_info.as_deref(),
|
||||
type_label.as_deref(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
if let Some(result) = check_result {
|
||||
if result.update_available {
|
||||
if let Some(ref version) = result.latest_version {
|
||||
db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok();
|
||||
updates_found += 1;
|
||||
}
|
||||
} else {
|
||||
db.clear_update_available(record.id).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates_found
|
||||
}
|
||||
24
src/ui/widgets.rs
Normal file
24
src/ui/widgets.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use gtk::prelude::*;
|
||||
|
||||
/// Create a status badge pill label with the given text and style class.
|
||||
/// Style classes: "success", "warning", "error", "info", "neutral"
|
||||
pub fn status_badge(text: &str, style_class: &str) -> gtk::Label {
|
||||
let label = gtk::Label::new(Some(text));
|
||||
label.add_css_class("status-badge");
|
||||
label.add_css_class(style_class);
|
||||
label
|
||||
}
|
||||
|
||||
/// Create a badge showing integration status.
|
||||
pub fn integration_badge(integrated: bool) -> gtk::Label {
|
||||
if integrated {
|
||||
status_badge("Integrated", "success")
|
||||
} else {
|
||||
status_badge("Not integrated", "neutral")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into a human-readable string.
|
||||
pub fn format_size(bytes: i64) -> String {
|
||||
humansize::format_size(bytes as u64, humansize::BINARY)
|
||||
}
|
||||
Reference in New Issue
Block a user