Files
driftwood/src/ui/detail_view.rs

879 lines
30 KiB
Rust

use adw::prelude::*;
use std::cell::Cell;
use std::rc::Rc;
use gtk::gio;
use crate::core::database::{AppImageRecord, Database};
use crate::core::footprint;
use crate::core::fuse::{self, FuseStatus};
use crate::core::integrator;
use crate::core::launcher::{self, SandboxMode};
use crate::core::security;
use crate::core::updater;
use crate::core::wayland::{self, WaylandStatus};
use super::integration_dialog;
use super::update_dialog;
use super::widgets;
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::NavigationPage {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// 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();
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));
// Group 1: System Integration (Desktop Integration + Runtime Compatibility + Sandboxing)
content.append(&build_system_integration_group(record, db));
// Group 2: Updates & Usage
content.append(&build_updates_usage_group(record, db));
// Group 3: Security & Storage
content.append(&build_security_storage_group(record, db, &toast_overlay));
clamp.set_child(Some(&content));
let scrolled = gtk::ScrolledWindow::builder()
.child(&clamp)
.vexpand(true)
.build();
toast_overlay.set_child(Some(&scrolled));
// 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());
// Run post-launch Wayland runtime analysis after a short delay
let db_wayland = db_launch.clone();
let path_clone = path.clone();
glib::spawn_future_local(async move {
// Wait 3 seconds for the process to initialize
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(),
);
// Store the runtime analysis result in the database
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);
// Check for Update 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()
}
/// Rich banner at top: large icon + app name + version + badges
fn build_banner(record: &AppImageRecord) -> gtk::Box {
let banner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(16)
.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);
icon.set_valign(gtk::Align::Start);
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
}
/// Group 1: System Integration (Desktop Integration + Runtime + Sandboxing)
fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder()
.title("System Integration")
.description("Desktop integration, runtime compatibility, and sandboxing")
.build();
// --- Desktop Integration ---
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();
}
});
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()
.title("Desktop file")
.subtitle(desktop_file)
.subtitle_selectable(true)
.build();
row.add_css_class("monospace");
group.add(&row);
}
}
// --- Runtime Compatibility ---
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);
group.add(&wayland_row);
// Wayland analyze button - runs toolkit detection on demand
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");
}
}
});
});
group.add(&analyze_row);
// Runtime Wayland status (from post-launch analysis)
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);
}
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);
group.add(&fuse_row);
// Per-app FUSE launch method
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);
group.add(&launch_method_row);
// --- Sandboxing ---
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);
}
});
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);
group.add(&info_row);
}
group
}
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",
}
}
/// Group 2: Updates & Usage
fn build_updates_usage_group(record: &AppImageRecord, db: &Rc<Database>) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder()
.title("Updates & Usage")
.description("Update status and launch statistics")
.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<Database>,
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();
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);
// --- 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);
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();
group.add(&total_row);
}
}
// 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");
}
}
});
});
group.add(&discover_row);
// Individual discovered paths with type icons and confidence badges
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);
group.add(&row);
}
}
// --- File Details ---
// Path with copy button
let path_row = adw::ActionRow::builder()
.title("Path")
.subtitle(&record.path)
.subtitle_selectable(true)
.build();
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);
// Open folder button
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);
}
group.add(&path_row);
// Type
let type_str = match record.appimage_type {
Some(1) => "Type 1",
Some(2) => "Type 2",
_ => "Unknown",
};
let type_row = adw::ActionRow::builder()
.title("AppImage type")
.subtitle(type_str)
.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(&copy_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
}