Wire backup, notification, report export, and file watcher modules into UI

- Add export button to security report page (HTML/JSON/CSV via FileDialog)
- Send desktop notifications after security scans when enabled in settings
- Add backup section to storage tab with create/restore/delete and toast feedback
- Start file watcher on launch to auto-refresh library on AppImage changes
- Fix detail view tabs requesting tallest tab height (set vhomogeneous=false)
- Disable tab transitions to avoid visual glitch with variable-height tabs
This commit is contained in:
lashman
2026-02-27 21:03:19 +02:00
parent df3efa3b51
commit c9f032292a
3 changed files with 407 additions and 5 deletions

View File

@@ -5,6 +5,7 @@ use std::rc::Rc;
use gtk::gio;
use crate::core::backup;
use crate::core::database::{AppImageRecord, Database};
use crate::core::footprint;
use crate::core::fuse::{self, FuseStatus};
@@ -23,10 +24,12 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
// Toast overlay for copy actions
let toast_overlay = adw::ToastOverlay::new();
// ViewStack for tabbed content with crossfade transitions
// ViewStack for tabbed content with crossfade transitions.
// vhomogeneous=false so the stack sizes to the visible child only,
// preventing shorter tabs from having excess scrollable empty space.
let view_stack = adw::ViewStack::new();
view_stack.set_enable_transitions(true);
view_stack.set_transition_duration(200);
view_stack.set_vhomogeneous(false);
view_stack.set_enable_transitions(false);
// Build tab pages
let overview_page = build_overview_tab(record, db);
@@ -1506,6 +1509,9 @@ fn build_storage_tab(
}
inner.append(&paths_group);
// Backups group
inner.append(&build_backup_group(record.id, toast_overlay));
// File location group
let location_group = adw::PreferencesGroup::builder()
.title("File Location")
@@ -1552,6 +1558,223 @@ fn build_storage_tab(
tab
}
fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder()
.title("Backups")
.description("Save and restore this app's settings and data files")
.build();
// Fetch existing backups
let db = Database::open().ok();
let backups = db.as_ref()
.map(|d| backup::list_backups(d, Some(record_id)))
.unwrap_or_default();
if backups.is_empty() {
let empty_row = adw::ActionRow::builder()
.title("No backups yet")
.subtitle("Create a backup to save this app's settings and data")
.build();
let empty_icon = gtk::Image::from_icon_name("document-open-symbolic");
empty_icon.set_valign(gtk::Align::Center);
empty_icon.add_css_class("dim-label");
empty_row.add_prefix(&empty_icon);
group.add(&empty_row);
} else {
for b in &backups {
let expander = adw::ExpanderRow::builder()
.title(&b.created_at)
.subtitle(&format!(
"v{} - {} - {} file{}",
b.app_version.as_deref().unwrap_or("unknown"),
widgets::format_size(b.archive_size),
b.path_count,
if b.path_count == 1 { "" } else { "s" },
))
.build();
// Exists/missing badge using icon + text (not color-only)
let badge = if b.exists {
let bx = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.valign(gtk::Align::Center)
.build();
let icon = gtk::Image::from_icon_name("emblem-ok-symbolic");
icon.add_css_class("success");
let label = gtk::Label::new(Some("Exists"));
label.add_css_class("caption");
label.add_css_class("success");
bx.append(&icon);
bx.append(&label);
bx
} else {
let bx = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.valign(gtk::Align::Center)
.build();
let icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
icon.add_css_class("warning");
let label = gtk::Label::new(Some("Missing"));
label.add_css_class("caption");
label.add_css_class("warning");
bx.append(&icon);
bx.append(&label);
bx
};
expander.add_suffix(&badge);
// Restore row
let restore_row = adw::ActionRow::builder()
.title("Restore")
.subtitle("Restore settings and data from this backup")
.activatable(true)
.tooltip_text("Overwrite current settings with this backup")
.build();
let restore_icon = gtk::Image::from_icon_name("edit-undo-symbolic");
restore_icon.set_valign(gtk::Align::Center);
restore_row.add_prefix(&restore_icon);
restore_row.update_property(&[
gtk::accessible::Property::Label("Restore this backup"),
]);
let archive_path = b.archive_path.clone();
let toast_restore = toast_overlay.clone();
restore_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Restoring...");
let row_clone = row.clone();
let path = archive_path.clone();
let toast = toast_restore.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
backup::restore_backup(std::path::Path::new(&path))
})
.await;
row_clone.set_sensitive(true);
match result {
Ok(Ok(res)) => {
row_clone.set_subtitle(&format!(
"Restored {} path{}",
res.paths_restored,
if res.paths_restored == 1 { "" } else { "s" },
));
toast.add_toast(adw::Toast::new("Backup restored"));
}
_ => {
row_clone.set_subtitle("Restore failed");
toast.add_toast(adw::Toast::new("Failed to restore backup"));
}
}
});
});
expander.add_row(&restore_row);
// Delete row
let delete_row = adw::ActionRow::builder()
.title("Delete")
.subtitle("Permanently remove this backup")
.activatable(true)
.tooltip_text("Delete this backup archive from disk")
.build();
let delete_icon = gtk::Image::from_icon_name("edit-delete-symbolic");
delete_icon.set_valign(gtk::Align::Center);
delete_row.add_prefix(&delete_icon);
delete_row.update_property(&[
gtk::accessible::Property::Label("Delete this backup"),
]);
let backup_id = b.id;
let toast_delete = toast_overlay.clone();
let group_ref = group.clone();
let expander_ref = expander.clone();
delete_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Deleting...");
let row_clone = row.clone();
let toast = toast_delete.clone();
let group_del = group_ref.clone();
let expander_del = expander_ref.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
backup::delete_backup(&bg_db, backup_id)
})
.await;
match result {
Ok(Ok(())) => {
group_del.remove(&expander_del);
toast.add_toast(adw::Toast::new("Backup deleted"));
}
_ => {
row_clone.set_sensitive(true);
row_clone.set_subtitle("Delete failed");
toast.add_toast(adw::Toast::new("Failed to delete backup"));
}
}
});
});
expander.add_row(&delete_row);
group.add(&expander);
}
}
// Create backup row (always shown at bottom)
let create_row = adw::ActionRow::builder()
.title("Create backup")
.subtitle("Save a snapshot of this app's settings and data")
.activatable(true)
.tooltip_text("Create a new backup of this app's configuration files")
.build();
let create_icon = gtk::Image::from_icon_name("list-add-symbolic");
create_icon.set_valign(gtk::Align::Center);
create_row.add_prefix(&create_icon);
create_row.update_property(&[
gtk::accessible::Property::Label("Create a new backup"),
]);
let toast_create = toast_overlay.clone();
create_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Creating backup...");
let row_clone = row.clone();
let toast = toast_create.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
backup::create_backup(&bg_db, record_id)
})
.await;
row_clone.set_sensitive(true);
match result {
Ok(Ok(path)) => {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("backup");
row_clone.set_subtitle(&format!("Created {}", filename));
toast.add_toast(adw::Toast::new("Backup created"));
}
Ok(Err(backup::BackupError::NoPaths)) => {
row_clone.set_subtitle("Try discovering app data first");
toast.add_toast(adw::Toast::new("No data paths found to back up"));
}
_ => {
row_clone.set_subtitle("Backup failed");
toast.add_toast(adw::Toast::new("Failed to create backup"));
}
}
});
});
group.add(&create_row);
group
}
// ---------------------------------------------------------------------------
// User-friendly explanations
// ---------------------------------------------------------------------------