Files
driftwood/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md
lashman 33cc8a757a 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.
2026-02-27 11:10:23 +02:00

63 KiB

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:

/* ===== 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:

/* 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

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:

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:

/// Return the single most important badge for a card.
/// Priority: Update available > FUSE issue > Wayland issue.
fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
    // 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

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:

        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:

        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:

        list_box.add_css_class("boxed-list");

to:

        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:

    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:

fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {

to:

pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {

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

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:

pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> 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:

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<String> = [
        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:

/// Tab 1: Overview - most commonly needed info at a glance
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> 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:

/// Tab 2: System - integration, compatibility, sandboxing
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> 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:

/// Tab 3: Security - vulnerability scanning and integrity
fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> 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:

/// Tab 4: Storage - disk usage and data discovery
fn build_storage_tab(
    record: &AppImageRecord,
    db: &Rc<Database>,
    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(&copy_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

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:

        // --- 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::<i64>()) 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::<i64>()) 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::<i64>()) 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::<i64>()) 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::<i64>()) 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::<i64>()) 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(&copy_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):

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

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):

/// 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, &section1);

    // 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, &section2);

    // 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, &section3);

    // 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, &section4);

    menu
}

/// Attach a right-click context menu to a widget.
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: &gtk::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(&gtk::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(&gtk::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:

        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:

        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

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:

/// 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

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

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