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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user