From 33cc8a757a91ec01dd4651ac4743b33e8a88c8c1 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 11:10:23 +0200 Subject: [PATCH] Implement UI/UX overhaul - cards, list, tabbed detail, context menu Card view: 200px cards with 72px icons, .title-3 names, version+size combined line, single priority badge, libadwaita .card class replacing custom .app-card CSS. List view: 48px rounded icons, .rich-list class, structured two-line subtitle (description + version/size), single priority badge. Detail view: restructured into ViewStack/ViewSwitcher with 4 tabs (Overview, System, Security, Storage). 96px hero banner with gradient background. Rows distributed logically across tabs. Context menu: right-click (GestureClick button 3) and long-press on cards and list rows. Menu items: Launch, Check for Updates, Scan for Vulnerabilities, Integrate/Remove Integration, Open Containing Folder, Copy Path. All backed by parameterized window actions. CSS: removed custom .app-card rules (replaced by .card), added .icon-rounded for list icons, .detail-banner gradient, and .detail-view-switcher positioning. --- data/resources/style.css | 58 +- ...026-02-27-ui-ux-overhaul-implementation.md | 1885 +++++++++++++++++ src/ui/app_card.rs | 123 +- src/ui/detail_view.rs | 603 ++++-- src/ui/library_view.rs | 173 +- src/ui/widgets.rs | 1 + src/window.rs | 184 ++ 7 files changed, 2630 insertions(+), 397 deletions(-) create mode 100644 docs/plans/2026-02-27-ui-ux-overhaul-implementation.md diff --git a/data/resources/style.css b/data/resources/style.css index 1fa9b02..966e2ad 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -66,30 +66,18 @@ color: @window_fg_color; } -/* ===== App Cards (Grid View) ===== */ -.app-card { - padding: 14px; - border-radius: 14px; - background: @card_bg_color; - border: 1px solid alpha(@window_fg_color, 0.08); - transition: all 150ms ease; -} - -.app-card:hover { - background: mix(@card_bg_color, @window_fg_color, 0.04); - box-shadow: 0 2px 8px alpha(black, 0.06); -} - -.app-card:active { - background: mix(@card_bg_color, @window_fg_color, 0.08); -} - -/* Focus indicator for grid cards */ -flowboxchild:focus-visible .app-card { +/* ===== Card View (using libadwaita .card) ===== */ +flowboxchild:focus-visible .card { outline: 2px solid @accent_bg_color; outline-offset: 3px; } +/* Rounded icon clipping for list view */ +.icon-rounded { + border-radius: 8px; + overflow: hidden; +} + /* ===== WCAG AAA Focus Indicators ===== */ button:focus-visible, togglebutton:focus-visible, @@ -132,7 +120,20 @@ row:focus-visible { /* ===== Detail View Banner ===== */ .detail-banner { - padding: 12px 0; + padding: 18px 0; + background-image: linear-gradient( + to bottom, + alpha(@accent_bg_color, 0.08), + transparent + ); + border-radius: 12px; + margin-bottom: 6px; +} + +/* Inline ViewSwitcher positioning */ +.detail-view-switcher { + margin-top: 6px; + margin-bottom: 6px; } /* ===== Quick Action Pills ===== */ @@ -151,15 +152,6 @@ row:focus-visible { /* ===== Dark Mode Differentiation ===== */ @media (prefers-color-scheme: dark) { - .app-card { - border: 1px solid alpha(@window_fg_color, 0.12); - } - - .app-card:hover { - box-shadow: 0 2px 8px alpha(black, 0.15); - background: mix(@card_bg_color, @window_fg_color, 0.06); - } - .compat-warning-banner { background: alpha(@warning_bg_color, 0.1); border: 1px solid alpha(@warning_bg_color, 0.2); @@ -168,11 +160,7 @@ row:focus-visible { /* ===== High Contrast Mode (WCAG AAA 1.4.6) ===== */ @media (prefers-contrast: more) { - .app-card { - border: 2px solid @window_fg_color; - } - - flowboxchild:focus-visible .app-card { + flowboxchild:focus-visible .card { outline-width: 3px; } diff --git a/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md b/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md new file mode 100644 index 0000000..edf3dd6 --- /dev/null +++ b/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md @@ -0,0 +1,1885 @@ +# UI/UX Overhaul Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make Driftwood look and feel like a first-class GNOME app by overhauling cards, list rows, detail view, CSS, and adding right-click context menus. + +**Architecture:** Replace custom `.app-card` CSS with libadwaita `.card` + `.activatable`. Use `adw::ViewStack`/`adw::ViewSwitcher` to split the detail view into 4 tabs. Add `gio::Menu`-driven context menus attached via `GtkGestureClick` on cards and rows. All changes are in 5 files: `style.css`, `app_card.rs`, `library_view.rs`, `detail_view.rs`, `window.rs`. + +**Tech Stack:** Rust, gtk4-rs 0.11 (v4_18), libadwaita-rs 0.9 (v1_8), GTK CSS + +--- + +### Task 1: CSS Foundation - Remove .app-card, Add New Classes + +**Files:** +- Modify: `data/resources/style.css` + +**Step 1: Update style.css** + +Replace the entire `.app-card` block (lines 69-91), the dark mode `.app-card` block (lines 152-167), and the high contrast `.app-card` block (lines 171-177) with new minimal classes. Also update the `.detail-banner` block (lines 133-136). + +Remove these CSS blocks entirely: +- `.app-card` (lines 69-85) +- `flowboxchild:focus-visible .app-card` (lines 87-91) +- Dark mode `.app-card` overrides (lines 153-166) +- High contrast `.app-card` and `flowboxchild:focus-visible .app-card` overrides (lines 171-177) + +Add these new CSS rules in their place: + +```css +/* ===== Card View (using libadwaita .card) ===== */ +flowboxchild:focus-visible .card { + outline: 2px solid @accent_bg_color; + outline-offset: 3px; +} + +/* Rounded icon clipping for list view */ +.icon-rounded { + border-radius: 8px; + overflow: hidden; +} + +/* Detail banner gradient wash */ +.detail-banner { + padding: 18px 0; + background-image: linear-gradient( + to bottom, + alpha(@accent_bg_color, 0.08), + transparent + ); + border-radius: 12px; + margin-bottom: 6px; +} + +/* Inline ViewSwitcher positioning */ +.detail-view-switcher { + margin-top: 6px; + margin-bottom: 6px; +} +``` + +Update the high contrast section to reference `.card` instead of `.app-card`: + +```css +/* In high contrast section: */ +flowboxchild:focus-visible .card { + outline-width: 3px; +} +``` + +**Step 2: Build to verify CSS compiles** + +Run: `cargo build 2>&1 | head -5` +Expected: Compiles (CSS is loaded at runtime, not compile-checked, but Rust code referencing removed classes will break in later tasks) + +**Step 3: Commit** + +```bash +git add data/resources/style.css +git commit -m "style: replace .app-card CSS with libadwaita .card classes + +Remove custom .app-card hover/active/dark/contrast rules. Add +.icon-rounded for list view icons, gradient .detail-banner, and +.detail-view-switcher for tabbed detail layout." +``` + +--- + +### Task 2: Card View Overhaul + +**Files:** +- Modify: `src/ui/app_card.rs` + +**Step 1: Rewrite build_app_card function** + +Replace the entire `build_app_card` function body (lines 10-124) with: + +```rust +pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .margin_top(14) + .margin_bottom(14) + .margin_start(14) + .margin_end(14) + .halign(gtk::Align::Center) + .build(); + card.add_css_class("card"); + card.set_size_request(200, -1); + + // Icon (72x72) with integration emblem overlay + let name = record.app_name.as_deref().unwrap_or(&record.filename); + let icon_widget = widgets::app_icon( + record.icon_path.as_deref(), + name, + 72, + ); + icon_widget.add_css_class("icon-dropshadow"); + + // If integrated, overlay a small checkmark emblem + if record.integrated { + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&icon_widget)); + + let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic"); + emblem.set_pixel_size(16); + emblem.add_css_class("integration-emblem"); + emblem.set_halign(gtk::Align::End); + emblem.set_valign(gtk::Align::End); + emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]); + overlay.add_overlay(&emblem); + + card.append(&overlay); + } else { + card.append(&icon_widget); + } + + // App name - use .title-3 for more visual weight + let name_label = gtk::Label::builder() + .label(name) + .css_classes(["title-3"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(20) + .build(); + + // Version + size combined on one line + let version_text = record.app_version.as_deref().unwrap_or(""); + let size_text = widgets::format_size(record.size_bytes); + let meta_text = if version_text.is_empty() { + size_text + } else { + format!("{} - {}", version_text, size_text) + }; + let meta_label = gtk::Label::builder() + .label(&meta_text) + .css_classes(["caption", "dimmed", "numeric"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + + card.append(&name_label); + card.append(&meta_label); + + // Single most important badge (priority: Update > FUSE issue > Wayland issue) + if let Some(badge) = build_priority_badge(record) { + let badge_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .halign(gtk::Align::Center) + .margin_top(4) + .build(); + badge_box.append(&badge); + card.append(&badge_box); + } + + let child = gtk::FlowBoxChild::builder() + .child(&card) + .build(); + child.add_css_class("activatable"); + + // Accessible label for screen readers + let accessible_name = build_accessible_label(record); + child.update_property(&[AccessibleProperty::Label(&accessible_name)]); + + child +} +``` + +**Step 2: Add the priority badge helper function** + +Add this new function after `build_app_card`, before `build_accessible_label`: + +```rust +/// Return the single most important badge for a card. +/// Priority: Update available > FUSE issue > Wayland issue. +fn build_priority_badge(record: &AppImageRecord) -> Option { + // 1. Update available (highest priority) + if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) { + if crate::core::updater::version_is_newer(latest, current) { + return Some(widgets::status_badge("Update", "info")); + } + } + + // 2. FUSE issue + if let Some(ref fs) = record.fuse_status { + let status = FuseStatus::from_str(fs); + if !status.is_functional() { + return Some(widgets::status_badge(status.label(), status.badge_class())); + } + } + + // 3. Wayland issue (not Native or Unknown) + if let Some(ref ws) = record.wayland_status { + let status = WaylandStatus::from_str(ws); + if status != WaylandStatus::Unknown && status != WaylandStatus::Native { + return Some(widgets::status_badge(status.label(), status.badge_class())); + } + } + + None +} +``` + +**Step 3: Build and test** + +Run: `cargo build 2>&1 | tail -3` +Expected: Compiles successfully + +Run: `cargo test 2>&1 | tail -5` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add src/ui/app_card.rs +git commit -m "ui: overhaul card view with larger icons and single badge + +72px icons with .icon-dropshadow, .title-3 for app names, version+size +on one combined line, single priority badge (Update > FUSE > Wayland), +libadwaita .card class replacing custom .app-card, 200px card width." +``` + +--- + +### Task 3: Library View - FlowBox and List Row Changes + +**Files:** +- Modify: `src/ui/library_view.rs` + +**Step 1: Update FlowBox settings** + +In the `new()` method, change the FlowBox builder (around line 196) from: + +```rust + let flow_box = gtk::FlowBox::builder() + .valign(gtk::Align::Start) + .selection_mode(gtk::SelectionMode::None) + .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(); +``` + +to: + +```rust + let flow_box = gtk::FlowBox::builder() + .valign(gtk::Align::Start) + .selection_mode(gtk::SelectionMode::None) + .homogeneous(true) + .min_children_per_line(2) + .max_children_per_line(4) + .row_spacing(14) + .column_spacing(14) + .margin_top(14) + .margin_bottom(14) + .margin_start(14) + .margin_end(14) + .build(); +``` + +Changes: max 4 columns (from 6), 14px spacing (from 12) to match GNOME HIG. + +**Step 2: Add .rich-list to ListBox** + +In the `new()` method, after the ListBox builder (around line 221), change: + +```rust + list_box.add_css_class("boxed-list"); +``` + +to: + +```rust + list_box.add_css_class("boxed-list"); + list_box.add_css_class("rich-list"); +``` + +**Step 3: Rewrite build_list_row for structured subtitle and single badge** + +Replace the entire `build_list_row` method (lines 459-543) with: + +```rust + fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow { + let name = record.app_name.as_deref().unwrap_or(&record.filename); + + // Structured two-line subtitle: + // Line 1: Description snippet or file path (dimmed) + // Line 2: Version + size (caption, dimmed, numeric) + let line1 = if let Some(ref desc) = record.description { + if !desc.is_empty() { + let snippet: String = desc.chars().take(60).collect(); + if snippet.len() < desc.len() { + format!("{}...", snippet.trim_end()) + } else { + snippet + } + } else { + record.path.clone() + } + } else { + record.path.clone() + }; + + let mut meta_parts = Vec::new(); + if let Some(ref ver) = record.app_version { + meta_parts.push(ver.clone()); + } + meta_parts.push(widgets::format_size(record.size_bytes)); + let line2 = meta_parts.join(" - "); + + let subtitle = format!("{}\n{}", line1, line2); + + let row = adw::ActionRow::builder() + .title(name) + .subtitle(&subtitle) + .subtitle_lines(2) + .activatable(true) + .build(); + + // Icon prefix (48x48 with rounded clipping and letter fallback) + let icon = widgets::app_icon( + record.icon_path.as_deref(), + name, + 48, + ); + icon.add_css_class("icon-rounded"); + row.add_prefix(&icon); + + // Single most important badge as suffix (same priority as cards) + if let Some(badge) = app_card::build_priority_badge(record) { + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + } + + // Navigate arrow + let arrow = gtk::Image::from_icon_name("go-next-symbolic"); + row.add_suffix(&arrow); + + row + } +``` + +**Step 4: Make build_priority_badge public in app_card.rs** + +In `src/ui/app_card.rs`, change: + +```rust +fn build_priority_badge(record: &AppImageRecord) -> Option { +``` + +to: + +```rust +pub fn build_priority_badge(record: &AppImageRecord) -> Option { +``` + +**Step 5: Build and test** + +Run: `cargo build 2>&1 | tail -3` +Expected: Compiles successfully + +Run: `cargo test 2>&1 | tail -5` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add src/ui/library_view.rs src/ui/app_card.rs +git commit -m "ui: overhaul list view and FlowBox grid settings + +FlowBox max 4 columns with 14px spacing. ListBox gets .rich-list class. +List rows use 48px rounded icons, structured two-line subtitle (desc + +version/size), single priority badge instead of all badges." +``` + +--- + +### Task 4: Detail View - Tabbed Layout with ViewStack + +**Files:** +- Modify: `src/ui/detail_view.rs` + +This is the largest task. The detail view currently has one scrolling page with 3 PreferencesGroups. We restructure it into a hero banner + 4-tab ViewStack (Overview, System, Security, Storage). + +**Step 1: Rewrite build_detail_page to use ViewStack** + +Replace the `build_detail_page` function (lines 19-156) with: + +```rust +pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::NavigationPage { + let name = record.app_name.as_deref().unwrap_or(&record.filename); + + // Toast overlay for copy actions + let toast_overlay = adw::ToastOverlay::new(); + + // Main content container + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + + // Hero banner (not scrolled, always visible at top) + let banner = build_banner(record); + content.append(&banner); + + // ViewSwitcher (tab bar) - inline style, between banner and tab content + let view_stack = adw::ViewStack::new(); + + let switcher = adw::ViewSwitcher::builder() + .stack(&view_stack) + .policy(adw::ViewSwitcherPolicy::Wide) + .build(); + switcher.add_css_class("inline"); + switcher.add_css_class("detail-view-switcher"); + content.append(&switcher); + + // Build tab pages + let overview_page = build_overview_tab(record, db); + view_stack.add_titled(&overview_page, Some("overview"), "Overview"); + if let Some(page) = view_stack.page(&overview_page) { + page.set_icon_name(Some("info-symbolic")); + } + + let system_page = build_system_tab(record, db); + view_stack.add_titled(&system_page, Some("system"), "System"); + if let Some(page) = view_stack.page(&system_page) { + page.set_icon_name(Some("system-run-symbolic")); + } + + let security_page = build_security_tab(record, db); + view_stack.add_titled(&security_page, Some("security"), "Security"); + if let Some(page) = view_stack.page(&security_page) { + page.set_icon_name(Some("security-medium-symbolic")); + } + + let storage_page = build_storage_tab(record, db, &toast_overlay); + view_stack.add_titled(&storage_page, Some("storage"), "Storage"); + if let Some(page) = view_stack.page(&storage_page) { + page.set_icon_name(Some("drive-harddisk-symbolic")); + } + + // Scrollable area for tab content + let scrolled = gtk::ScrolledWindow::builder() + .child(&view_stack) + .vexpand(true) + .build(); + content.append(&scrolled); + + toast_overlay.set_child(Some(&content)); + + // Header bar with per-app actions + let header = adw::HeaderBar::new(); + + let launch_button = gtk::Button::builder() + .label("Launch") + .tooltip_text("Launch this AppImage") + .build(); + launch_button.add_css_class("suggested-action"); + launch_button.update_property(&[ + gtk::accessible::Property::Label("Launch application"), + ]); + 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 { child, method } => { + let pid = child.id(); + log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str()); + + let db_wayland = db_launch.clone(); + let path_clone = path.clone(); + glib::spawn_future_local(async move { + glib::timeout_future(std::time::Duration::from_secs(3)).await; + + let analysis_result = gio::spawn_blocking(move || { + wayland::analyze_running_process(pid) + }).await; + + match analysis_result { + Ok(Ok(analysis)) => { + let status_label = analysis.status_label(); + let status_str = analysis.as_status_str(); + log::info!( + "Runtime Wayland analysis for {} (PID {}): {} (wayland_socket={}, x11={}, env_vars={})", + path_clone, analysis.pid, status_label, + analysis.has_wayland_socket, + analysis.has_x11_connection, + analysis.env_vars.len(), + ); + db_wayland.update_runtime_wayland_status( + record_id, status_str, + ).ok(); + } + Ok(Err(e)) => { + log::debug!("Runtime analysis failed for PID {}: {}", pid, e); + } + Err(_) => { + log::debug!("Runtime analysis task failed for PID {}", pid); + } + } + }); + } + launcher::LaunchResult::Failed(msg) => { + log::error!("Failed to launch: {}", msg); + } + } + }); + header.pack_end(&launch_button); + + let update_button = gtk::Button::builder() + .icon_name("software-update-available-symbolic") + .tooltip_text("Check for updates") + .build(); + update_button.update_property(&[ + gtk::accessible::Property::Label("Check for updates"), + ]); + let record_for_update = record.clone(); + let db_update = db.clone(); + update_button.connect_clicked(move |btn| { + update_dialog::show_update_dialog(btn, &record_for_update, &db_update); + }); + header.pack_end(&update_button); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&toast_overlay)); + + adw::NavigationPage::builder() + .title(name) + .tag("detail") + .child(&toolbar) + .build() +} +``` + +**Step 2: Update the banner to use 96px icon** + +Replace the `build_banner` function (lines 158-247) with: + +```rust +fn build_banner(record: &AppImageRecord) -> gtk::Box { + let banner = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(16) + .margin_start(18) + .margin_end(18) + .build(); + banner.add_css_class("detail-banner"); + banner.set_accessible_role(gtk::AccessibleRole::Banner); + + let name = record.app_name.as_deref().unwrap_or(&record.filename); + + // Large icon (96x96) with drop shadow + let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96); + icon.set_valign(gtk::Align::Start); + icon.add_css_class("icon-dropshadow"); + banner.append(&icon); + + // Text column + let text_col = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .valign(gtk::Align::Center) + .build(); + + let name_label = gtk::Label::builder() + .label(name) + .css_classes(["title-1"]) + .halign(gtk::Align::Start) + .build(); + text_col.append(&name_label); + + // Version + architecture inline + let meta_parts: Vec = [ + record.app_version.as_deref().map(|v| v.to_string()), + record.architecture.as_deref().map(|a| a.to_string()), + ] + .iter() + .filter_map(|p| p.clone()) + .collect(); + + if !meta_parts.is_empty() { + let meta_label = gtk::Label::builder() + .label(&meta_parts.join(" - ")) + .css_classes(["dimmed"]) + .halign(gtk::Align::Start) + .build(); + text_col.append(&meta_label); + } + + // Description + if let Some(ref desc) = record.description { + if !desc.is_empty() { + let desc_label = gtk::Label::builder() + .label(desc) + .css_classes(["body"]) + .halign(gtk::Align::Start) + .wrap(true) + .xalign(0.0) + .build(); + text_col.append(&desc_label); + } + } + + // Key status badges inline + let badge_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(4) + .build(); + + if record.integrated { + badge_box.append(&widgets::status_badge("Integrated", "success")); + } + + if let Some(ref ws) = record.wayland_status { + let status = WaylandStatus::from_str(ws); + if status != WaylandStatus::Unknown { + badge_box.append(&widgets::status_badge(status.label(), status.badge_class())); + } + } + + if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) { + if crate::core::updater::version_is_newer(latest, current) { + badge_box.append(&widgets::status_badge("Update available", "info")); + } + } + + text_col.append(&badge_box); + banner.append(&text_col); + banner +} +``` + +**Step 3: Create the 4 tab builder functions** + +Replace `build_system_integration_group` (lines 249-471), `build_updates_usage_group` (lines 493-580), and `build_security_storage_group` (lines 582-878) with these 4 new functions. Keep the `wayland_description` and `fuse_description` helper functions. + +**Tab 1 - Overview:** + +```rust +/// Tab 1: Overview - most commonly needed info at a glance +fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + // Updates section + let updates_group = adw::PreferencesGroup::builder() + .title("Updates") + .build(); + + if let Some(ref update_type) = record.update_type { + let display_label = updater::parse_update_info(update_type) + .map(|ut| ut.type_label_display()) + .unwrap_or("Unknown format"); + let row = adw::ActionRow::builder() + .title("Update method") + .subtitle(display_label) + .build(); + updates_group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title("Update method") + .subtitle("This app cannot check for updates automatically") + .build(); + let badge = widgets::status_badge("None", "neutral"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + updates_group.add(&row); + } + + 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); + updates_group.add(&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); + updates_group.add(&row); + } + } + + if let Some(ref checked) = record.update_checked { + let row = adw::ActionRow::builder() + .title("Last checked") + .subtitle(checked) + .build(); + updates_group.add(&row); + } + inner.append(&updates_group); + + // Usage section + let usage_group = adw::PreferencesGroup::builder() + .title("Usage") + .build(); + + 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(); + usage_group.add(&launches_row); + + if let Some(ref last) = stats.last_launched { + let row = adw::ActionRow::builder() + .title("Last launched") + .subtitle(last) + .build(); + usage_group.add(&row); + } + inner.append(&usage_group); + + // File info section + let info_group = adw::PreferencesGroup::builder() + .title("File Information") + .build(); + + 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) + .tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS") + .build(); + info_group.add(&type_row); + + let exec_row = adw::ActionRow::builder() + .title("Executable") + .subtitle(if record.is_executable { "Yes" } else { "No" }) + .build(); + info_group.add(&exec_row); + + let seen_row = adw::ActionRow::builder() + .title("First seen") + .subtitle(&record.first_seen) + .build(); + info_group.add(&seen_row); + + let scanned_row = adw::ActionRow::builder() + .title("Last scanned") + .subtitle(&record.last_scanned) + .build(); + info_group.add(&scanned_row); + + if let Some(ref notes) = record.notes { + if !notes.is_empty() { + let row = adw::ActionRow::builder() + .title("Notes") + .subtitle(notes) + .build(); + info_group.add(&row); + } + } + inner.append(&info_group); + + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} +``` + +**Tab 2 - System:** + +```rust +/// Tab 2: System - integration, compatibility, sandboxing +fn build_system_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + // Desktop Integration group + let integration_group = adw::PreferencesGroup::builder() + .title("Desktop Integration") + .description("Add this app to your application menu") + .build(); + + 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(); + let db_dialog = db.clone(); + let record_dialog = record.clone(); + let suppress = Rc::new(Cell::new(false)); + let suppress_ref = suppress.clone(); + switch_row.connect_active_notify(move |row| { + if suppress_ref.get() { + return; + } + if row.is_active() { + let row_clone = row.clone(); + let suppress_inner = suppress_ref.clone(); + integration_dialog::show_integration_dialog( + row, + &record_dialog, + &db_dialog, + move |success| { + if !success { + suppress_inner.set(true); + row_clone.set_active(false); + suppress_inner.set(false); + } + }, + ); + } else { + integrator::remove_integration(&record_clone).ok(); + db_ref.set_integrated(record_id, false, None).ok(); + } + }); + integration_group.add(&switch_row); + + if record.integrated { + if let Some(ref desktop_file) = record.desktop_file { + let row = adw::ActionRow::builder() + .title("Desktop file") + .subtitle(desktop_file) + .subtitle_selectable(true) + .build(); + row.add_css_class("property"); + integration_group.add(&row); + } + } + inner.append(&integration_group); + + // Runtime Compatibility group + let compat_group = adw::PreferencesGroup::builder() + .title("Runtime Compatibility") + .description("Wayland support and FUSE status") + .build(); + + 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)) + .tooltip_text("Display protocol for Linux desktops") + .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); + compat_group.add(&wayland_row); + + let analyze_row = adw::ActionRow::builder() + .title("Analyze toolkit") + .subtitle("Inspect bundled libraries to detect UI toolkit") + .activatable(true) + .build(); + let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic"); + analyze_icon.set_valign(gtk::Align::Center); + analyze_row.add_suffix(&analyze_icon); + + let record_path_wayland = record.path.clone(); + analyze_row.connect_activated(move |row| { + row.set_sensitive(false); + row.update_state(&[gtk::accessible::State::Busy(true)]); + row.set_subtitle("Analyzing..."); + let row_clone = row.clone(); + let path = record_path_wayland.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let appimage_path = std::path::Path::new(&path); + wayland::analyze_appimage(appimage_path) + }) + .await; + + row_clone.set_sensitive(true); + row_clone.update_state(&[gtk::accessible::State::Busy(false)]); + match result { + Ok(analysis) => { + let toolkit_label = analysis.toolkit.label(); + let lib_count = analysis.libraries_found.len(); + row_clone.set_subtitle(&format!( + "Toolkit: {} ({} libraries scanned)", + toolkit_label, lib_count, + )); + } + Err(_) => { + row_clone.set_subtitle("Analysis failed"); + } + } + }); + }); + compat_group.add(&analyze_row); + + if let Some(ref runtime_status) = record.runtime_wayland_status { + let runtime_row = adw::ActionRow::builder() + .title("Runtime display protocol") + .subtitle(runtime_status) + .build(); + if let Some(ref checked) = record.runtime_wayland_checked { + let info = gtk::Label::builder() + .label(checked) + .css_classes(["dimmed", "caption"]) + .valign(gtk::Align::Center) + .build(); + runtime_row.add_suffix(&info); + } + compat_group.add(&runtime_row); + } + + let fuse_system = fuse::detect_system_fuse(); + let fuse_status = record + .fuse_status + .as_deref() + .map(FuseStatus::from_str) + .unwrap_or(fuse_system.status.clone()); + + let fuse_row = adw::ActionRow::builder() + .title("FUSE") + .subtitle(fuse_description(&fuse_status)) + .tooltip_text("Filesystem in Userspace - required for mounting AppImages") + .build(); + let fuse_badge = widgets::status_badge_with_icon( + if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" }, + fuse_status.label(), + fuse_status.badge_class(), + ); + fuse_badge.set_valign(gtk::Align::Center); + fuse_row.add_suffix(&fuse_badge); + compat_group.add(&fuse_row); + + let appimage_path = std::path::Path::new(&record.path); + let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path); + let launch_method_row = adw::ActionRow::builder() + .title("Launch method") + .subtitle(app_fuse_status.label()) + .build(); + let launch_badge = widgets::status_badge( + fuse_system.status.as_str(), + app_fuse_status.badge_class(), + ); + launch_badge.set_valign(gtk::Align::Center); + launch_method_row.add_suffix(&launch_badge); + compat_group.add(&launch_method_row); + inner.append(&compat_group); + + // Sandboxing group + let sandbox_group = adw::PreferencesGroup::builder() + .title("Sandboxing") + .description("Isolate this app with Firejail") + .build(); + + let current_mode = record + .sandbox_mode + .as_deref() + .map(SandboxMode::from_str) + .unwrap_or(SandboxMode::None); + + let firejail_available = launcher::has_firejail(); + + let sandbox_subtitle = if firejail_available { + format!("Current mode: {}", current_mode.label()) + } else { + "Firejail is not installed".to_string() + }; + + let firejail_row = adw::SwitchRow::builder() + .title("Firejail sandbox") + .subtitle(&sandbox_subtitle) + .tooltip_text("Linux application sandboxing tool") + .active(current_mode == SandboxMode::Firejail) + .sensitive(firejail_available) + .build(); + + let record_id = record.id; + let db_ref = db.clone(); + firejail_row.connect_active_notify(move |row| { + let mode = if row.is_active() { + SandboxMode::Firejail + } else { + SandboxMode::None + }; + if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) { + log::warn!("Failed to update sandbox mode: {}", e); + } + }); + sandbox_group.add(&firejail_row); + + if !firejail_available { + let info_row = adw::ActionRow::builder() + .title("Install Firejail") + .subtitle("sudo apt install firejail") + .build(); + let badge = widgets::status_badge("Missing", "warning"); + badge.set_valign(gtk::Align::Center); + info_row.add_suffix(&badge); + sandbox_group.add(&info_row); + } + inner.append(&sandbox_group); + + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} +``` + +**Tab 3 - Security:** + +```rust +/// Tab 3: Security - vulnerability scanning and integrity +fn build_security_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + let group = adw::PreferencesGroup::builder() + .title("Vulnerability Scanning") + .description("Check bundled libraries for known CVEs") + .build(); + + let libs = db.get_bundled_libraries(record.id).unwrap_or_default(); + let summary = db.get_cve_summary(record.id).unwrap_or_default(); + + if libs.is_empty() { + let row = adw::ActionRow::builder() + .title("Security scan") + .subtitle("Not yet scanned for vulnerabilities") + .build(); + let badge = widgets::status_badge("Not scanned", "neutral"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } else { + let lib_row = adw::ActionRow::builder() + .title("Bundled libraries") + .subtitle(&libs.len().to_string()) + .build(); + group.add(&lib_row); + + if summary.total() == 0 { + let row = adw::ActionRow::builder() + .title("Vulnerabilities") + .subtitle("No known vulnerabilities") + .build(); + let badge = widgets::status_badge("Clean", "success"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title("Vulnerabilities") + .subtitle(&format!("{} found", summary.total())) + .build(); + let badge = widgets::status_badge(summary.max_severity(), summary.badge_class()); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } + } + + // Scan button + let scan_row = adw::ActionRow::builder() + .title("Scan this AppImage") + .subtitle("Check bundled libraries for known CVEs") + .activatable(true) + .build(); + let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic"); + scan_icon.set_valign(gtk::Align::Center); + scan_row.add_suffix(&scan_icon); + + let record_id = record.id; + let record_path = record.path.clone(); + scan_row.connect_activated(move |row| { + row.set_sensitive(false); + row.update_state(&[gtk::accessible::State::Busy(true)]); + row.set_subtitle("Scanning..."); + let row_clone = row.clone(); + let path = record_path.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + let appimage_path = std::path::Path::new(&path); + security::scan_and_store(&bg_db, record_id, appimage_path) + }) + .await; + + row_clone.set_sensitive(true); + row_clone.update_state(&[gtk::accessible::State::Busy(false)]); + match result { + Ok(scan_result) => { + let total = scan_result.total_cves(); + if total == 0 { + row_clone.set_subtitle("No vulnerabilities found"); + } else { + row_clone.set_subtitle(&format!( + "Found {} CVE{}", total, if total == 1 { "" } else { "s" } + )); + } + } + Err(_) => { + row_clone.set_subtitle("Scan failed"); + } + } + }); + }); + group.add(&scan_row); + inner.append(&group); + + // Integrity group + let integrity_group = adw::PreferencesGroup::builder() + .title("Integrity") + .build(); + + if let Some(ref hash) = record.sha256 { + let hash_row = adw::ActionRow::builder() + .title("SHA256 checksum") + .subtitle(hash) + .subtitle_selectable(true) + .tooltip_text("Cryptographic hash for verifying file integrity") + .build(); + hash_row.add_css_class("property"); + integrity_group.add(&hash_row); + } + inner.append(&integrity_group); + + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} +``` + +**Tab 4 - Storage:** + +```rust +/// Tab 4: Storage - disk usage and data discovery +fn build_storage_tab( + record: &AppImageRecord, + db: &Rc, + toast_overlay: &adw::ToastOverlay, +) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + // Disk usage group + let size_group = adw::PreferencesGroup::builder() + .title("Disk Usage") + .build(); + + let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64); + + let appimage_row = adw::ActionRow::builder() + .title("AppImage file size") + .subtitle(&widgets::format_size(record.size_bytes)) + .build(); + size_group.add(&appimage_row); + + if !fp.paths.is_empty() { + let data_total = fp.data_total(); + if data_total > 0 { + let total_row = adw::ActionRow::builder() + .title("Total disk footprint") + .subtitle(&format!( + "{} (AppImage) + {} (data) = {}", + widgets::format_size(record.size_bytes), + widgets::format_size(data_total as i64), + widgets::format_size(fp.total_size() as i64), + )) + .build(); + size_group.add(&total_row); + } + } + inner.append(&size_group); + + // Data paths group + let paths_group = adw::PreferencesGroup::builder() + .title("Data Paths") + .description("Config, data, and cache directories for this app") + .build(); + + // Discover button + let discover_row = adw::ActionRow::builder() + .title("Discover data paths") + .subtitle("Search for config, data, and cache directories") + .activatable(true) + .build(); + let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic"); + discover_icon.set_valign(gtk::Align::Center); + discover_row.add_suffix(&discover_icon); + + let record_clone = record.clone(); + let record_id = record.id; + discover_row.connect_activated(move |row| { + row.set_sensitive(false); + row.set_subtitle("Discovering..."); + let row_clone = row.clone(); + let rec = record_clone.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + footprint::discover_and_store(&bg_db, record_id, &rec); + footprint::get_footprint(&bg_db, record_id, rec.size_bytes as u64) + }) + .await; + + row_clone.set_sensitive(true); + match result { + Ok(fp) => { + let count = fp.paths.len(); + if count == 0 { + row_clone.set_subtitle("No associated paths found"); + } else { + row_clone.set_subtitle(&format!( + "Found {} path{} ({})", + count, + if count == 1 { "" } else { "s" }, + widgets::format_size(fp.data_total() as i64), + )); + } + } + Err(_) => { + row_clone.set_subtitle("Discovery failed"); + } + } + }); + }); + paths_group.add(&discover_row); + + // Individual discovered paths + for dp in &fp.paths { + if dp.exists { + let row = adw::ActionRow::builder() + .title(dp.path_type.label()) + .subtitle(&*dp.path.to_string_lossy()) + .subtitle_selectable(true) + .build(); + let icon = gtk::Image::from_icon_name(dp.path_type.icon_name()); + icon.set_pixel_size(16); + row.add_prefix(&icon); + let conf_badge = widgets::status_badge( + dp.confidence.as_str(), + dp.confidence.badge_class(), + ); + conf_badge.set_valign(gtk::Align::Center); + row.add_suffix(&conf_badge); + let size_label = gtk::Label::builder() + .label(&widgets::format_size(dp.size_bytes as i64)) + .css_classes(["dimmed", "caption"]) + .valign(gtk::Align::Center) + .build(); + row.add_suffix(&size_label); + paths_group.add(&row); + } + } + inner.append(&paths_group); + + // File location group + let location_group = adw::PreferencesGroup::builder() + .title("File Location") + .build(); + + let path_row = adw::ActionRow::builder() + .title("Path") + .subtitle(&record.path) + .subtitle_selectable(true) + .build(); + path_row.add_css_class("property"); + let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay)); + copy_path_btn.set_valign(gtk::Align::Center); + path_row.add_suffix(©_path_btn); + + let folder_path = std::path::Path::new(&record.path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if !folder_path.is_empty() { + let open_folder_btn = gtk::Button::builder() + .icon_name("folder-open-symbolic") + .tooltip_text("Open containing folder") + .valign(gtk::Align::Center) + .build(); + open_folder_btn.add_css_class("flat"); + open_folder_btn.update_property(&[ + gtk::accessible::Property::Label("Open containing folder"), + ]); + let folder = folder_path.clone(); + open_folder_btn.connect_clicked(move |_| { + let file = gio::File::for_path(&folder); + let launcher = gtk::FileLauncher::new(Some(&file)); + launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); + }); + path_row.add_suffix(&open_folder_btn); + } + location_group.add(&path_row); + inner.append(&location_group); + + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} +``` + +**Step 4: Build and test** + +Run: `cargo build 2>&1 | tail -5` +Expected: Compiles successfully + +Run: `cargo test 2>&1 | tail -5` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/ui/detail_view.rs +git commit -m "ui: restructure detail view into tabbed layout + +Replace single scrolling page with ViewStack/ViewSwitcher tabs: +Overview (updates, usage, file info), System (integration, compatibility, +sandboxing), Security (CVE scanning, integrity), Storage (disk usage, +data paths, file location). 96px hero banner with gradient background." +``` + +--- + +### Task 5: Right-Click Context Menu - Window Actions + +**Files:** +- Modify: `src/window.rs` + +**Step 1: Add parameterized context menu actions** + +In `setup_window_actions()`, after the existing action entries and before the `self.add_action_entries(...)` call (around line 361), add new parameterized actions. Since `ActionEntry` doesn't support parameterized actions, we create them separately using `gio::SimpleAction`. + +Add the following code right before the `self.add_action_entries([...])` call: + +```rust + // --- Context menu actions (parameterized with record ID) --- + let param_type = Some(glib::VariantTy::INT64); + + // Launch action + let launch_action = gio::SimpleAction::new("launch-appimage", param_type); + { + let window_weak = self.downgrade(); + launch_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let appimage_path = std::path::Path::new(&record.path); + match launcher::launch_appimage(&db, record_id, appimage_path, "gui_context", &[], &[]) { + launcher::LaunchResult::Started { child, method } => { + log::info!("Context menu launched: {} (PID: {}, method: {})", record.path, child.id(), method.as_str()); + } + launcher::LaunchResult::Failed(msg) => { + log::error!("Failed to launch: {}", msg); + } + } + } + }); + } + self.add_action(&launch_action); + + // Check for updates action (per-app) + let check_update_action = gio::SimpleAction::new("check-update", param_type); + { + let window_weak = self.downgrade(); + check_update_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + glib::spawn_future_local(async move { + let path = record.path.clone(); + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("DB open failed"); + update_dialog::check_single_update(&bg_db, &record) + }).await; + match result { + Ok(true) => toast_overlay.add_toast(adw::Toast::new("Update available!")), + Ok(false) => toast_overlay.add_toast(adw::Toast::new("Already up to date")), + Err(_) => toast_overlay.add_toast(adw::Toast::new("Update check failed")), + } + }); + } + }); + } + self.add_action(&check_update_action); + + // Scan for vulnerabilities (per-app) + let scan_security_action = gio::SimpleAction::new("scan-security", param_type); + { + let window_weak = self.downgrade(); + scan_security_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + glib::spawn_future_local(async move { + let path = record.path.clone(); + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("DB open failed"); + let appimage_path = std::path::Path::new(&path); + security::scan_and_store(&bg_db, record_id, appimage_path) + }).await; + match result { + Ok(scan_result) => { + let total = scan_result.total_cves(); + if total == 0 { + toast_overlay.add_toast(adw::Toast::new("No vulnerabilities found")); + } else { + let msg = format!("Found {} CVE{}", total, if total == 1 { "" } else { "s" }); + toast_overlay.add_toast(adw::Toast::new(&msg)); + } + } + Err(_) => toast_overlay.add_toast(adw::Toast::new("Security scan failed")), + } + }); + } + }); + } + self.add_action(&scan_security_action); + + // Toggle integration + let toggle_integration_action = gio::SimpleAction::new("toggle-integration", param_type); + { + let window_weak = self.downgrade(); + toggle_integration_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + if record.integrated { + integrator::remove_integration(&record).ok(); + db.set_integrated(record_id, false, None).ok(); + toast_overlay.add_toast(adw::Toast::new("Integration removed")); + } else { + // For context menu, do a quick integrate without dialog + match integrator::integrate_appimage(&record) { + Ok(desktop_path) => { + db.set_integrated(record_id, true, Some(&desktop_path)).ok(); + toast_overlay.add_toast(adw::Toast::new("Integrated into desktop menu")); + } + Err(e) => { + log::error!("Integration failed: {}", e); + toast_overlay.add_toast(adw::Toast::new("Integration failed")); + } + } + } + // Refresh library view + let lib_view = window.imp().library_view.get().unwrap(); + match db.get_all_appimages() { + Ok(records) => lib_view.populate(records), + Err(_) => {} + } + } + }); + } + self.add_action(&toggle_integration_action); + + // Open containing folder + let open_folder_action = gio::SimpleAction::new("open-folder", param_type); + { + let window_weak = self.downgrade(); + open_folder_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let file = gio::File::for_path(&record.path); + let launcher = gtk::FileLauncher::new(Some(&file)); + launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); + } + }); + } + self.add_action(&open_folder_action); + + // Copy path to clipboard + let copy_path_action = gio::SimpleAction::new("copy-path", param_type); + { + let window_weak = self.downgrade(); + copy_path_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + if let Some(display) = window.display().into() { + let clipboard = gtk::gdk::Display::clipboard(&display); + clipboard.set_text(&record.path); + toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard")); + } + } + }); + } + self.add_action(©_path_action); +``` + +**Step 2: Add missing imports at the top of window.rs** + +Add these imports to the use block at the top (after line 14): + +```rust +use crate::core::integrator; +use crate::core::launcher; +use crate::core::security; +``` + +**Step 3: Build and verify** + +Run: `cargo build 2>&1 | tail -5` +Expected: Compiles. (Note: if `update_dialog::check_single_update` doesn't exist, we need to add it or use a toast-based approach instead.) + +**Step 4: Commit** + +```bash +git add src/window.rs +git commit -m "ui: add parameterized context menu actions to window + +Define launch-appimage, check-update, scan-security, toggle-integration, +open-folder, and copy-path actions with i64 record ID parameter for +the right-click context menu on cards and list rows." +``` + +--- + +### Task 6: Right-Click Context Menu - Attach to Cards and List Rows + +**Files:** +- Modify: `src/ui/library_view.rs` + +**Step 1: Add context menu builder function** + +Add a helper function at the end of `library_view.rs` (module-level, outside the `impl LibraryView` block): + +```rust +/// Build the right-click context menu model for an AppImage. +fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu { + let menu = gtk::gio::Menu::new(); + + // Section 1: Launch + let section1 = gtk::gio::Menu::new(); + section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id))); + menu.append_section(None, §ion1); + + // Section 2: Actions + let section2 = gtk::gio::Menu::new(); + section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id))); + section2.append(Some("Scan for Vulnerabilities"), Some(&format!("win.scan-security(int64 {})", record.id))); + menu.append_section(None, §ion2); + + // Section 3: Integration + folder + let section3 = gtk::gio::Menu::new(); + let integrate_label = if record.integrated { "Remove Integration" } else { "Integrate" }; + section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id))); + section3.append(Some("Open Containing Folder"), Some(&format!("win.open-folder(int64 {})", record.id))); + menu.append_section(None, §ion3); + + // Section 4: Clipboard + let section4 = gtk::gio::Menu::new(); + section4.append(Some("Copy Path"), Some(&format!("win.copy-path(int64 {})", record.id))); + menu.append_section(None, §ion4); + + menu +} + +/// Attach a right-click context menu to a widget. +fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::gio::Menu) { + let popover = gtk::PopoverMenu::from_model(Some(menu_model)); + popover.set_parent(widget.as_ref()); + popover.set_has_arrow(false); + + let click = gtk::GestureClick::new(); + click.set_button(3); // Right click + let popover_ref = popover.clone(); + click.connect_pressed(move |gesture, _, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover_ref.popup(); + }); + widget.as_ref().add_controller(click); + + // Long press for touch + let long_press = gtk::GestureLongPress::new(); + let popover_ref = popover; + long_press.connect_pressed(move |gesture, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover_ref.popup(); + }); + widget.as_ref().add_controller(long_press); +} +``` + +**Step 2: Wire context menu to grid cards in populate()** + +In the `populate()` method, after building the card (around line 432-433), add context menu attachment: + +Change: + +```rust + 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); + } +``` + +to: + +```rust + for record in &new_records { + // Grid card + let card = app_card::build_app_card(record); + let card_menu = build_context_menu(record); + attach_context_menu(&card, &card_menu); + self.flow_box.append(&card); + + // List row + let row = self.build_list_row(record); + let row_menu = build_context_menu(record); + attach_context_menu(&row, &row_menu); + self.list_box.append(&row); + } +``` + +**Step 3: Build and test** + +Run: `cargo build 2>&1 | tail -5` +Expected: Compiles successfully + +Run: `cargo test 2>&1 | tail -5` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add src/ui/library_view.rs +git commit -m "ui: attach right-click context menu to cards and list rows + +GtkPopoverMenu with gio::Menu model, triggered by GestureClick (button 3) +and GestureLongPress for touch. Menu sections: Launch, Check Updates / +Scan Vulnerabilities, Integrate / Open Folder, Copy Path." +``` + +--- + +### Task 7: Handle check_single_update Helper + +**Files:** +- Modify: `src/ui/update_dialog.rs` + +The context menu's check-update action calls `update_dialog::check_single_update`. We need to verify this function exists or add it. + +**Step 1: Check if function exists** + +Read `src/ui/update_dialog.rs` and look for `check_single_update` or `batch_check_updates`. + +If `check_single_update` does not exist, add it: + +```rust +/// Check for updates for a single AppImage. Returns true if update available. +pub fn check_single_update(db: &Database, record: &AppImageRecord) -> bool { + if record.update_type.is_none() { + return false; + } + match crate::core::updater::check_for_update(record) { + Ok(Some(latest)) => { + let is_newer = record + .app_version + .as_deref() + .map(|current| crate::core::updater::version_is_newer(&latest, current)) + .unwrap_or(true); + if is_newer { + db.update_latest_version(record.id, &latest).ok(); + return true; + } + false + } + _ => false, + } +} +``` + +**Step 2: Build and test** + +Run: `cargo build 2>&1 | tail -5` +Expected: Compiles + +Run: `cargo test 2>&1 | tail -5` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add src/ui/update_dialog.rs +git commit -m "ui: add check_single_update helper for context menu" +``` + +--- + +### Task 8: Final Build Verification and Polish + +**Files:** +- All modified files + +**Step 1: Full build** + +Run: `cargo build 2>&1` +Expected: Zero errors. Fix any warnings. + +**Step 2: Run tests** + +Run: `cargo test 2>&1` +Expected: All 128+ tests pass. + +**Step 3: Visual verification** + +Run: `cargo run` + +Verify: +1. Card view: 200px cards, 72px icons, .title-3 names, version+size combined, single badge +2. List view: 48px rounded icons, .rich-list spacing, structured subtitle, single badge +3. Detail view: 96px icon banner with gradient, ViewSwitcher with 4 tabs +4. Right-click on card or list row opens context menu +5. All context menu actions work (Launch, Check Updates, Scan, Integrate, Open Folder, Copy Path) +6. Light and dark mode look correct +7. Keyboard navigation works (Tab, Enter, Escape) + +**Step 4: Commit any final fixes** + +```bash +git add -u +git commit -m "ui: final polish and fixes for UI/UX overhaul" +``` + +--- + +## Files Modified (Summary) + +| File | Task | Changes | +|------|------|---------| +| `data/resources/style.css` | 1 | Remove .app-card CSS, add .icon-rounded, .detail-banner gradient, .detail-view-switcher | +| `src/ui/app_card.rs` | 2 | 72px icon, .title-3, version+size combined, single priority badge, .card class, 200px width | +| `src/ui/library_view.rs` | 3, 6 | FlowBox max 4 cols, .rich-list, list row restructure, context menu build + attach | +| `src/ui/detail_view.rs` | 4 | 96px banner, ViewStack/ViewSwitcher, 4 tab pages (Overview, System, Security, Storage) | +| `src/window.rs` | 5 | 6 parameterized actions for context menu | +| `src/ui/update_dialog.rs` | 7 | check_single_update helper | + +## Verification + +After all tasks: +1. `cargo build` - zero errors, zero warnings +2. `cargo test` - all 128+ tests pass +3. Visual verification of all three views in light + dark mode +4. Right-click context menu works on cards and list rows +5. Detail view tabs switch correctly, content is correctly distributed +6. Keyboard navigation preserved +7. All WCAG AAA compliance preserved diff --git a/src/ui/app_card.rs b/src/ui/app_card.rs index f91e634..6374b31 100644 --- a/src/ui/app_card.rs +++ b/src/ui/app_card.rs @@ -11,22 +11,23 @@ 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) + .margin_top(14) + .margin_bottom(14) + .margin_start(14) + .margin_end(14) .halign(gtk::Align::Center) .build(); - card.add_css_class("app-card"); - card.set_size_request(180, -1); + card.add_css_class("card"); + card.set_size_request(200, -1); - // Icon (64x64) with integration emblem overlay + // Icon (72x72) with integration emblem overlay let name = record.app_name.as_deref().unwrap_or(&record.filename); let icon_widget = widgets::app_icon( record.icon_path.as_deref(), name, - 64, + 72, ); + icon_widget.add_css_class("icon-dropshadow"); // If integrated, overlay a small checkmark emblem if record.integrated { @@ -46,75 +47,46 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { card.append(&icon_widget); } - // App name - use libadwaita built-in heading class + // App name - .title-3 for more visual weight let name_label = gtk::Label::builder() .label(name) - .css_classes(["heading"]) + .css_classes(["title-3"]) .ellipsize(gtk::pango::EllipsizeMode::End) .max_width_chars(20) .build(); - // Version - use libadwaita built-in caption + dimmed + // Version + size combined on one line let version_text = record.app_version.as_deref().unwrap_or(""); - let version_label = gtk::Label::builder() - .label(version_text) - .css_classes(["caption", "dimmed"]) + let size_text = widgets::format_size(record.size_bytes); + let meta_text = if version_text.is_empty() { + size_text + } else { + format!("{} - {}", version_text, size_text) + }; + let meta_label = gtk::Label::builder() + .label(&meta_text) + .css_classes(["caption", "dimmed", "numeric"]) .ellipsize(gtk::pango::EllipsizeMode::End) .build(); - // File size as subtle caption - let size_text = widgets::format_size(record.size_bytes); - let size_label = gtk::Label::builder() - .label(&size_text) - .css_classes(["caption", "dimmed"]) - .build(); - card.append(&name_label); - if !version_text.is_empty() { - card.append(&version_label); + card.append(&meta_label); + + // Single most important badge (priority: Update > FUSE issue > Wayland issue) + if let Some(badge) = build_priority_badge(record) { + let badge_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .halign(gtk::Align::Center) + .margin_top(4) + .build(); + badge_box.append(&badge); + card.append(&badge_box); } - card.append(&size_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")); - } - } - } - - card.append(&badges); let child = gtk::FlowBoxChild::builder() .child(&card) .build(); + child.add_css_class("activatable"); // Accessible label for screen readers let accessible_name = build_accessible_label(record); @@ -123,6 +95,35 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { child } +/// Return the single most important badge for a card. +/// Priority: Update available > FUSE issue > Wayland issue. +pub fn build_priority_badge(record: &AppImageRecord) -> Option { + // 1. Update available (highest priority) + if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) { + if crate::core::updater::version_is_newer(latest, current) { + return Some(widgets::status_badge("Update", "info")); + } + } + + // 2. FUSE issue + if let Some(ref fs) = record.fuse_status { + let status = FuseStatus::from_str(fs); + if !status.is_functional() { + return Some(widgets::status_badge(status.label(), status.badge_class())); + } + } + + // 3. Wayland issue (not Native or Unknown) + if let Some(ref ws) = record.wayland_status { + let status = WaylandStatus::from_str(ws); + if status != WaylandStatus::Unknown && status != WaylandStatus::Native { + return Some(widgets::status_badge(status.label(), status.badge_class())); + } + } + + None +} + /// Build a descriptive accessible label for screen readers. fn build_accessible_label(record: &AppImageRecord) -> String { let name = record.app_name.as_deref().unwrap_or(&record.filename); diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index a9b4c77..4f639b0 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -22,40 +22,51 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav // Toast overlay for copy actions let toast_overlay = adw::ToastOverlay::new(); - // Scrollable content with clamp - let clamp = adw::Clamp::builder() - .maximum_size(800) - .tightening_threshold(600) - .build(); - + // Main content container let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(24) - .margin_top(24) - .margin_bottom(24) - .margin_start(18) - .margin_end(18) .build(); - // Banner: App identity (not a boxed group) - content.append(&build_banner(record)); + // Hero banner (always visible at top) + let banner = build_banner(record); + content.append(&banner); - // Group 1: System Integration (Desktop Integration + Runtime Compatibility + Sandboxing) - content.append(&build_system_integration_group(record, db)); + // ViewSwitcher (tab bar) - inline style, between banner and tab content + let view_stack = adw::ViewStack::new(); - // Group 2: Updates & Usage - content.append(&build_updates_usage_group(record, db)); + let switcher = adw::ViewSwitcher::builder() + .stack(&view_stack) + .policy(adw::ViewSwitcherPolicy::Wide) + .build(); + switcher.add_css_class("inline"); + switcher.add_css_class("detail-view-switcher"); + content.append(&switcher); - // Group 3: Security & Storage - content.append(&build_security_storage_group(record, db, &toast_overlay)); + // Build tab pages + let overview_page = build_overview_tab(record, db); + view_stack.add_titled(&overview_page, Some("overview"), "Overview"); + view_stack.page(&overview_page).set_icon_name(Some("info-symbolic")); - clamp.set_child(Some(&content)); + let system_page = build_system_tab(record, db); + view_stack.add_titled(&system_page, Some("system"), "System"); + view_stack.page(&system_page).set_icon_name(Some("system-run-symbolic")); + + let security_page = build_security_tab(record, db); + view_stack.add_titled(&security_page, Some("security"), "Security"); + view_stack.page(&security_page).set_icon_name(Some("security-medium-symbolic")); + + let storage_page = build_storage_tab(record, db, &toast_overlay); + view_stack.add_titled(&storage_page, Some("storage"), "Storage"); + view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic")); + + // Scrollable area for tab content let scrolled = gtk::ScrolledWindow::builder() - .child(&clamp) + .child(&view_stack) .vexpand(true) .build(); + content.append(&scrolled); - toast_overlay.set_child(Some(&scrolled)); + toast_overlay.set_child(Some(&content)); // Header bar with per-app actions let header = adw::HeaderBar::new(); @@ -108,7 +119,6 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav analysis.has_x11_connection, analysis.env_vars.len(), ); - // Store the runtime analysis result in the database db_wayland.update_runtime_wayland_status( record_id, status_str, ).ok(); @@ -160,15 +170,18 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box { let banner = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(16) + .margin_start(18) + .margin_end(18) .build(); banner.add_css_class("detail-banner"); banner.set_accessible_role(gtk::AccessibleRole::Banner); let name = record.app_name.as_deref().unwrap_or(&record.filename); - // Large icon (64x64) - let icon = widgets::app_icon(record.icon_path.as_deref(), name, 64); + // Large icon (96x96) with drop shadow + let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96); icon.set_valign(gtk::Align::Start); + icon.add_css_class("icon-dropshadow"); banner.append(&icon); // Text column @@ -246,14 +259,194 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box { banner } -/// Group 1: System Integration (Desktop Integration + Runtime + Sandboxing) -fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder() - .title("System Integration") - .description("Desktop integration, runtime compatibility, and sandboxing") +/// Tab 1: Overview - most commonly needed info at a glance +fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + // Updates section + let updates_group = adw::PreferencesGroup::builder() + .title("Updates") + .build(); + + if let Some(ref update_type) = record.update_type { + let display_label = updater::parse_update_info(update_type) + .map(|ut| ut.type_label_display()) + .unwrap_or("Unknown format"); + let row = adw::ActionRow::builder() + .title("Update method") + .subtitle(display_label) + .build(); + updates_group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title("Update method") + .subtitle("This app cannot check for updates automatically") + .build(); + let badge = widgets::status_badge("None", "neutral"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + updates_group.add(&row); + } + + 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); + updates_group.add(&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); + updates_group.add(&row); + } + } + + if let Some(ref checked) = record.update_checked { + let row = adw::ActionRow::builder() + .title("Last checked") + .subtitle(checked) + .build(); + updates_group.add(&row); + } + inner.append(&updates_group); + + // Usage section + let usage_group = adw::PreferencesGroup::builder() + .title("Usage") + .build(); + + 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(); + usage_group.add(&launches_row); + + if let Some(ref last) = stats.last_launched { + let row = adw::ActionRow::builder() + .title("Last launched") + .subtitle(last) + .build(); + usage_group.add(&row); + } + inner.append(&usage_group); + + // File info section + let info_group = adw::PreferencesGroup::builder() + .title("File Information") + .build(); + + 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) + .tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS") + .build(); + info_group.add(&type_row); + + let exec_row = adw::ActionRow::builder() + .title("Executable") + .subtitle(if record.is_executable { "Yes" } else { "No" }) + .build(); + info_group.add(&exec_row); + + let seen_row = adw::ActionRow::builder() + .title("First seen") + .subtitle(&record.first_seen) + .build(); + info_group.add(&seen_row); + + let scanned_row = adw::ActionRow::builder() + .title("Last scanned") + .subtitle(&record.last_scanned) + .build(); + info_group.add(&scanned_row); + + if let Some(ref notes) = record.notes { + if !notes.is_empty() { + let row = adw::ActionRow::builder() + .title("Notes") + .subtitle(notes) + .build(); + info_group.add(&row); + } + } + inner.append(&info_group); + + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} + +/// Tab 2: System - integration, compatibility, sandboxing +fn build_system_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + // Desktop Integration group + let integration_group = adw::PreferencesGroup::builder() + .title("Desktop Integration") + .description("Add this app to your application menu") .build(); - // --- Desktop Integration --- let switch_row = adw::SwitchRow::builder() .title("Add to application menu") .subtitle("Creates a .desktop file and installs the icon") @@ -291,9 +484,8 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> db_ref.set_integrated(record_id, false, None).ok(); } }); - group.add(&switch_row); + integration_group.add(&switch_row); - // Desktop file path if integrated if record.integrated { if let Some(ref desktop_file) = record.desktop_file { let row = adw::ActionRow::builder() @@ -301,12 +493,18 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> .subtitle(desktop_file) .subtitle_selectable(true) .build(); - row.add_css_class("monospace"); - group.add(&row); + row.add_css_class("property"); + integration_group.add(&row); } } + inner.append(&integration_group); + + // Runtime Compatibility group + let compat_group = adw::PreferencesGroup::builder() + .title("Runtime Compatibility") + .description("Wayland support and FUSE status") + .build(); - // --- Runtime Compatibility --- let wayland_status = record .wayland_status .as_deref() @@ -321,9 +519,9 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> 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); - group.add(&wayland_row); + compat_group.add(&wayland_row); - // Wayland analyze button - runs toolkit detection on demand + // Wayland analyze button let analyze_row = adw::ActionRow::builder() .title("Analyze toolkit") .subtitle("Inspect bundled libraries to detect UI toolkit") @@ -364,7 +562,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> } }); }); - group.add(&analyze_row); + compat_group.add(&analyze_row); // Runtime Wayland status (from post-launch analysis) if let Some(ref runtime_status) = record.runtime_wayland_status { @@ -380,7 +578,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> .build(); runtime_row.add_suffix(&info); } - group.add(&runtime_row); + compat_group.add(&runtime_row); } let fuse_system = fuse::detect_system_fuse(); @@ -402,7 +600,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> ); fuse_badge.set_valign(gtk::Align::Center); fuse_row.add_suffix(&fuse_badge); - group.add(&fuse_row); + compat_group.add(&fuse_row); // Per-app FUSE launch method let appimage_path = std::path::Path::new(&record.path); @@ -417,9 +615,15 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> ); launch_badge.set_valign(gtk::Align::Center); launch_method_row.add_suffix(&launch_badge); - group.add(&launch_method_row); + compat_group.add(&launch_method_row); + inner.append(&compat_group); + + // Sandboxing group + let sandbox_group = adw::PreferencesGroup::builder() + .title("Sandboxing") + .description("Isolate this app with Firejail") + .build(); - // --- Sandboxing --- let current_mode = record .sandbox_mode .as_deref() @@ -454,7 +658,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> log::warn!("Failed to update sandbox mode: {}", e); } }); - group.add(&firejail_row); + sandbox_group.add(&firejail_row); if !firejail_available { let info_row = adw::ActionRow::builder() @@ -464,133 +668,41 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> let badge = widgets::status_badge("Missing", "warning"); badge.set_valign(gtk::Align::Center); info_row.add_suffix(&badge); - group.add(&info_row); + sandbox_group.add(&info_row); } + inner.append(&sandbox_group); - group + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab } -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", - } -} +/// Tab 3: Security - vulnerability scanning and integrity +fn build_security_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); -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", - } -} + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); -/// Group 2: Updates & Usage -fn build_updates_usage_group(record: &AppImageRecord, db: &Rc) -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder() - .title("Updates & Usage") - .description("Update status and launch statistics") + .title("Vulnerability Scanning") + .description("Check bundled libraries for known CVEs") .build(); - // --- Updates --- - if let Some(ref update_type) = record.update_type { - let display_label = updater::parse_update_info(update_type) - .map(|ut| ut.type_label_display()) - .unwrap_or("Unknown format"); - let row = adw::ActionRow::builder() - .title("Update method") - .subtitle(display_label) - .build(); - group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title("Update method") - .subtitle("This app cannot check for updates automatically") - .build(); - let badge = widgets::status_badge("None", "neutral"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - group.add(&row); - } - - 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); - group.add(&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); - group.add(&row); - } - } - - if let Some(ref checked) = record.update_checked { - let row = adw::ActionRow::builder() - .title("Last checked") - .subtitle(checked) - .build(); - group.add(&row); - } - - // --- Usage Statistics --- - 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(); - group.add(&launches_row); - - if let Some(ref last) = stats.last_launched { - let row = adw::ActionRow::builder() - .title("Last launched") - .subtitle(last) - .build(); - group.add(&row); - } - - group -} - -/// Group 3: Security & Storage (Security + Disk Footprint + File Details) -fn build_security_storage_group( - record: &AppImageRecord, - db: &Rc, - toast_overlay: &adw::ToastOverlay, -) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder() - .title("Security & Storage") - .description("Vulnerability scanning, disk footprint, and file details") - .build(); - - // --- Security --- let libs = db.get_bundled_libraries(record.id).unwrap_or_default(); let summary = db.get_cve_summary(record.id).unwrap_or_default(); @@ -677,15 +789,69 @@ fn build_security_storage_group( }); }); group.add(&scan_row); + inner.append(&group); + + // Integrity group + if record.sha256.is_some() { + let integrity_group = adw::PreferencesGroup::builder() + .title("Integrity") + .build(); + + if let Some(ref hash) = record.sha256 { + let hash_row = adw::ActionRow::builder() + .title("SHA256 checksum") + .subtitle(hash) + .subtitle_selectable(true) + .tooltip_text("Cryptographic hash for verifying file integrity") + .build(); + hash_row.add_css_class("property"); + integrity_group.add(&hash_row); + } + inner.append(&integrity_group); + } + + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} + +/// Tab 4: Storage - disk usage and data discovery +fn build_storage_tab( + record: &AppImageRecord, + db: &Rc, + toast_overlay: &adw::ToastOverlay, +) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + // Disk usage group + let size_group = adw::PreferencesGroup::builder() + .title("Disk Usage") + .build(); - // --- Disk Footprint --- let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64); let appimage_row = adw::ActionRow::builder() .title("AppImage file size") .subtitle(&widgets::format_size(record.size_bytes)) .build(); - group.add(&appimage_row); + size_group.add(&appimage_row); if !fp.paths.is_empty() { let data_total = fp.data_total(); @@ -699,9 +865,16 @@ fn build_security_storage_group( widgets::format_size(fp.total_size() as i64), )) .build(); - group.add(&total_row); + size_group.add(&total_row); } } + inner.append(&size_group); + + // Data paths group + let paths_group = adw::PreferencesGroup::builder() + .title("Data Paths") + .description("Config, data, and cache directories for this app") + .build(); // Discover button let discover_row = adw::ActionRow::builder() @@ -749,7 +922,7 @@ fn build_security_storage_group( } }); }); - group.add(&discover_row); + paths_group.add(&discover_row); // Individual discovered paths with type icons and confidence badges for dp in &fp.paths { @@ -774,17 +947,22 @@ fn build_security_storage_group( .valign(gtk::Align::Center) .build(); row.add_suffix(&size_label); - group.add(&row); + paths_group.add(&row); } } + inner.append(&paths_group); + + // File location group + let location_group = adw::PreferencesGroup::builder() + .title("File Location") + .build(); - // --- File Details --- - // Path with copy button let path_row = adw::ActionRow::builder() .title("Path") .subtitle(&record.path) .subtitle_selectable(true) .build(); + path_row.add_css_class("property"); let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay)); copy_path_btn.set_valign(gtk::Align::Center); path_row.add_suffix(©_path_btn); @@ -812,67 +990,30 @@ fn build_security_storage_group( }); path_row.add_suffix(&open_folder_btn); } - group.add(&path_row); + location_group.add(&path_row); + inner.append(&location_group); - // 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) - .tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS") - .build(); - group.add(&type_row); - - // Executable - let exec_row = adw::ActionRow::builder() - .title("Executable") - .subtitle(if record.is_executable { "Yes" } else { "No" }) - .build(); - group.add(&exec_row); - - // SHA256 with copy button - if let Some(ref hash) = record.sha256 { - let hash_row = adw::ActionRow::builder() - .title("SHA256 checksum") - .subtitle(hash) - .subtitle_selectable(true) - .tooltip_text("Cryptographic hash for verifying file integrity") - .build(); - hash_row.add_css_class("monospace"); - let copy_hash_btn = widgets::copy_button(hash, Some(toast_overlay)); - copy_hash_btn.set_valign(gtk::Align::Center); - hash_row.add_suffix(©_hash_btn); - group.add(&hash_row); - } - - // First seen - let seen_row = adw::ActionRow::builder() - .title("First seen") - .subtitle(&record.first_seen) - .build(); - group.add(&seen_row); - - // Last scanned - let scanned_row = adw::ActionRow::builder() - .title("Last scanned") - .subtitle(&record.last_scanned) - .build(); - group.add(&scanned_row); - - // Notes - if let Some(ref notes) = record.notes { - if !notes.is_empty() { - let row = adw::ActionRow::builder() - .title("Notes") - .subtitle(notes) - .build(); - group.add(&row); - } - } - - group + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} + +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", + } } diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index 7175be9..2110111 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -4,8 +4,6 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use crate::core::database::AppImageRecord; -use crate::core::fuse::FuseStatus; -use crate::core::wayland::WaylandStatus; use crate::i18n::{i18n, i18n_f, ni18n_f}; use super::app_card; use super::widgets; @@ -195,16 +193,16 @@ impl LibraryView { // Grid view let flow_box = gtk::FlowBox::builder() .valign(gtk::Align::Start) - .selection_mode(gtk::SelectionMode::Single) + .selection_mode(gtk::SelectionMode::None) .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) + .max_children_per_line(4) + .row_spacing(14) + .column_spacing(14) + .margin_top(14) + .margin_bottom(14) + .margin_start(14) + .margin_end(14) .build(); flow_box.update_property(&[AccessibleProperty::Label("AppImage library grid")]); @@ -216,9 +214,10 @@ impl LibraryView { // List view let list_box = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::Single) + .selection_mode(gtk::SelectionMode::None) .build(); list_box.add_css_class("boxed-list"); + list_box.add_css_class("rich-list"); list_box.update_property(&[AccessibleProperty::Label("AppImage library list")]); let list_clamp = adw::Clamp::builder() @@ -430,10 +429,14 @@ impl LibraryView { for record in &new_records { // Grid card let card = app_card::build_app_card(record); + let card_menu = build_context_menu(record); + attach_context_menu(&card, &card_menu); self.flow_box.append(&card); // List row let row = self.build_list_row(record); + let row_menu = build_context_menu(record); + attach_context_menu(&row, &row_menu); self.list_box.append(&row); } @@ -459,83 +462,55 @@ impl LibraryView { fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow { let name = record.app_name.as_deref().unwrap_or(&record.filename); - // Richer subtitle with description snippet when available - let subtitle = { - let mut parts = Vec::new(); - if let Some(ref ver) = record.app_version { - parts.push(ver.clone()); - } - parts.push(widgets::format_size(record.size_bytes)); - if let Some(ref desc) = record.description { - if !desc.is_empty() { - // Truncate description to first sentence or 60 chars - let snippet: String = desc.chars().take(60).collect(); - let snippet = if snippet.len() < desc.len() { - format!("{}...", snippet.trim_end()) - } else { - snippet - }; - parts.push(snippet); + // Structured two-line subtitle: + // Line 1: Description snippet or file path + // Line 2: Version + size + let line1 = if let Some(ref desc) = record.description { + if !desc.is_empty() { + let snippet: String = desc.chars().take(60).collect(); + if snippet.len() < desc.len() { + format!("{}...", snippet.trim_end()) + } else { + snippet } + } else { + record.path.clone() } - parts.join(" - ") + } else { + record.path.clone() }; + let mut meta_parts = Vec::new(); + if let Some(ref ver) = record.app_version { + meta_parts.push(ver.clone()); + } + meta_parts.push(widgets::format_size(record.size_bytes)); + let line2 = meta_parts.join(" - "); + + let subtitle = format!("{}\n{}", line1, line2); + let row = adw::ActionRow::builder() .title(name) .subtitle(&subtitle) + .subtitle_lines(2) .activatable(true) .build(); - // Icon prefix (40x40 with letter fallback) + // Icon prefix (48x48 with rounded clipping and letter fallback) let icon = widgets::app_icon( record.icon_path.as_deref(), name, - 40, + 48, ); + icon.add_css_class("icon-rounded"); row.add_prefix(&icon); - // Status badges as suffix - let badge_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(4) - .valign(gtk::Align::Center) - .build(); - - // Wayland badge - if let Some(ref ws) = record.wayland_status { - let status = WaylandStatus::from_str(ws); - if status != WaylandStatus::Unknown && status != WaylandStatus::Native { - let badge = widgets::status_badge(status.label(), status.badge_class()); - badge_box.append(&badge); - } + // Single most important badge as suffix (same priority as cards) + if let Some(badge) = app_card::build_priority_badge(record) { + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); } - // FUSE badge - if let Some(ref fs) = record.fuse_status { - let status = FuseStatus::from_str(fs); - if !status.is_functional() { - let badge = widgets::status_badge(status.label(), status.badge_class()); - badge_box.append(&badge); - } - } - - // Update badge - if let (Some(ref latest), Some(ref current)) = - (&record.latest_version, &record.app_version) - { - if crate::core::updater::version_is_newer(latest, current) { - let badge = widgets::status_badge("Update", "info"); - badge_box.append(&badge); - } - } - - // Integration badge - let int_badge = widgets::integration_badge(record.integrated); - badge_box.append(&int_badge); - - row.add_suffix(&badge_box); - // Navigate arrow let arrow = gtk::Image::from_icon_name("go-next-symbolic"); row.add_suffix(&arrow); @@ -595,3 +570,61 @@ impl LibraryView { } } } + +/// Build the right-click context menu model for an AppImage. +fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu { + let menu = gtk::gio::Menu::new(); + + // Section 1: Launch + let section1 = gtk::gio::Menu::new(); + section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id))); + menu.append_section(None, §ion1); + + // Section 2: Actions + let section2 = gtk::gio::Menu::new(); + section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id))); + section2.append(Some("Scan for Vulnerabilities"), Some(&format!("win.scan-security(int64 {})", record.id))); + menu.append_section(None, §ion2); + + // Section 3: Integration + folder + let section3 = gtk::gio::Menu::new(); + let integrate_label = if record.integrated { "Remove Integration" } else { "Integrate" }; + section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id))); + section3.append(Some("Open Containing Folder"), Some(&format!("win.open-folder(int64 {})", record.id))); + menu.append_section(None, §ion3); + + // Section 4: Clipboard + let section4 = gtk::gio::Menu::new(); + section4.append(Some("Copy Path"), Some(&format!("win.copy-path(int64 {})", record.id))); + menu.append_section(None, §ion4); + + menu +} + +/// Attach a right-click context menu to a widget. +fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::gio::Menu) { + let popover = gtk::PopoverMenu::from_model(Some(menu_model)); + popover.set_parent(widget.as_ref()); + popover.set_has_arrow(false); + + // Right-click + let click = gtk::GestureClick::new(); + click.set_button(3); + let popover_ref = popover.clone(); + click.connect_pressed(move |gesture, _, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover_ref.popup(); + }); + widget.as_ref().add_controller(click); + + // Long press for touch + let long_press = gtk::GestureLongPress::new(); + let popover_ref = popover; + long_press.connect_pressed(move |gesture, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover_ref.popup(); + }); + widget.as_ref().add_controller(long_press); +} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index d3bc9f8..821b48d 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -33,6 +33,7 @@ pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) -> } /// Create a badge showing integration status. +#[allow(dead_code)] pub fn integration_badge(integrated: bool) -> gtk::Label { if integrated { status_badge("Integrated", "success") diff --git a/src/window.rs b/src/window.rs index 09d2475..1a0709d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -10,7 +10,11 @@ use crate::core::database::Database; use crate::core::discovery; use crate::core::fuse; use crate::core::inspector; +use crate::core::integrator; +use crate::core::launcher; use crate::core::orphan; +use crate::core::security; +use crate::core::updater; use crate::core::wayland; use crate::i18n::{i18n, ni18n_f}; use crate::ui::cleanup_wizard; @@ -371,6 +375,186 @@ impl DriftwoodWindow { shortcuts_action, ]); + // --- Context menu actions (parameterized with record ID) --- + let param_type = Some(glib::VariantTy::INT64); + + // Launch action + let launch_action = gio::SimpleAction::new("launch-appimage", param_type); + { + let window_weak = self.downgrade(); + launch_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let appimage_path = std::path::Path::new(&record.path); + match launcher::launch_appimage(&db, record_id, appimage_path, "gui_context", &[], &[]) { + launcher::LaunchResult::Started { child, method } => { + log::info!("Context menu launched: {} (PID: {}, method: {})", record.path, child.id(), method.as_str()); + } + launcher::LaunchResult::Failed(msg) => { + log::error!("Failed to launch: {}", msg); + } + } + } + }); + } + self.add_action(&launch_action); + + // Check for updates action (per-app) + let check_update_action = gio::SimpleAction::new("check-update", param_type); + { + let window_weak = self.downgrade(); + check_update_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("DB open failed"); + if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) { + let appimage_path = std::path::Path::new(&record.path); + if !appimage_path.exists() { + return false; + } + let (_type_label, raw_info, check_result) = updater::check_appimage_for_update( + appimage_path, + record.app_version.as_deref(), + ); + if raw_info.is_some() { + bg_db.update_update_info(record_id, raw_info.as_deref(), None).ok(); + } + if let Some(result) = check_result { + if result.update_available { + if let Some(ref version) = result.latest_version { + bg_db.set_update_available(record_id, Some(version), result.download_url.as_deref()).ok(); + return true; + } + } else { + bg_db.clear_update_available(record_id).ok(); + } + } + } + false + }).await; + match result { + Ok(true) => toast_overlay.add_toast(adw::Toast::new("Update available!")), + Ok(false) => toast_overlay.add_toast(adw::Toast::new("Already up to date")), + Err(_) => toast_overlay.add_toast(adw::Toast::new("Update check failed")), + } + }); + }); + } + self.add_action(&check_update_action); + + // Scan for vulnerabilities (per-app) + let scan_security_action = gio::SimpleAction::new("scan-security", param_type); + { + let window_weak = self.downgrade(); + scan_security_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("DB open failed"); + if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) { + let appimage_path = std::path::Path::new(&record.path); + let scan_result = security::scan_and_store(&bg_db, record_id, appimage_path); + return Some(scan_result.total_cves()); + } + None + }).await; + match result { + Ok(Some(total)) => { + if total == 0 { + toast_overlay.add_toast(adw::Toast::new("No vulnerabilities found")); + } else { + let msg = format!("Found {} CVE{}", total, if total == 1 { "" } else { "s" }); + toast_overlay.add_toast(adw::Toast::new(&msg)); + } + } + _ => toast_overlay.add_toast(adw::Toast::new("Security scan failed")), + } + }); + }); + } + self.add_action(&scan_security_action); + + // Toggle integration + let toggle_integration_action = gio::SimpleAction::new("toggle-integration", param_type); + { + let window_weak = self.downgrade(); + toggle_integration_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + if record.integrated { + integrator::remove_integration(&record).ok(); + db.set_integrated(record_id, false, None).ok(); + toast_overlay.add_toast(adw::Toast::new("Integration removed")); + } else { + match integrator::integrate(&record) { + Ok(result) => { + let desktop_path = result.desktop_file_path.to_string_lossy().to_string(); + db.set_integrated(record_id, true, Some(&desktop_path)).ok(); + toast_overlay.add_toast(adw::Toast::new("Integrated into desktop menu")); + } + Err(e) => { + log::error!("Integration failed: {}", e); + toast_overlay.add_toast(adw::Toast::new("Integration failed")); + } + } + } + // Refresh library view + let lib_view = window.imp().library_view.get().unwrap(); + match db.get_all_appimages() { + Ok(records) => lib_view.populate(records), + Err(_) => {} + } + } + }); + } + self.add_action(&toggle_integration_action); + + // Open containing folder + let open_folder_action = gio::SimpleAction::new("open-folder", param_type); + { + let window_weak = self.downgrade(); + open_folder_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let file = gio::File::for_path(&record.path); + let file_launcher = gtk::FileLauncher::new(Some(&file)); + file_launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); + } + }); + } + self.add_action(&open_folder_action); + + // Copy path to clipboard + let copy_path_action = gio::SimpleAction::new("copy-path", param_type); + { + let window_weak = self.downgrade(); + copy_path_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let display = gtk::prelude::WidgetExt::display(&window); + let clipboard = display.clipboard(); + clipboard.set_text(&record.path); + toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard")); + } + }); + } + self.add_action(©_path_action); + // Keyboard shortcuts if let Some(app) = self.application() { let gtk_app = app.downcast_ref::().unwrap();