Add UX enhancements: carousel, filter chips, command palette, and more
- Replace featured section Stack with AdwCarousel + indicator dots - Convert category grid to horizontal scrollable filter chips - Add grid/list view toggle for catalog with compact row layout - Add quick launch button on library list rows - Add stale catalog banner when data is older than 7 days - Add command palette (Ctrl+K) for quick app search and launch - Show specific app names in update notifications - Add per-app auto-update toggle (skip updates switch) - Add keyboard shortcut hints to button tooltips - Add source trust badges (AppImageHub/Community) on catalog tiles - Add undo-based uninstall with toast and record restoration - Add type-to-search in library view - Use human-readable catalog source labels - Show Launch button for installed apps in catalog detail - Replace external browser link with inline AppImage explainer dialog
This commit is contained in:
@@ -12,6 +12,7 @@ use crate::core::fuse::{self, FuseStatus};
|
||||
use crate::core::integrator;
|
||||
use crate::core::launcher::{self, SandboxMode};
|
||||
use crate::core::notification;
|
||||
use crate::core::sandbox;
|
||||
use crate::core::security;
|
||||
use crate::core::updater;
|
||||
use crate::core::wayland::{self, WaylandStatus};
|
||||
@@ -296,11 +297,41 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
||||
}
|
||||
}
|
||||
|
||||
// Windows equivalent hint for novice users
|
||||
if let Some(equiv) = windows_equivalent(name) {
|
||||
let equiv_label = gtk::Label::builder()
|
||||
.label(equiv)
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
text_col.append(&equiv_label);
|
||||
}
|
||||
|
||||
text_col.append(&badge_box);
|
||||
banner.append(&text_col);
|
||||
banner
|
||||
}
|
||||
|
||||
fn windows_equivalent(app_name: &str) -> Option<&'static str> {
|
||||
match app_name.to_lowercase().as_str() {
|
||||
s if s.contains("vlc") => Some("Similar to Windows Media Player"),
|
||||
s if s.contains("gimp") => Some("Similar to Photoshop"),
|
||||
s if s.contains("libreoffice") => Some("Similar to Microsoft Office"),
|
||||
s if s.contains("firefox") => Some("Similar to Edge / Chrome"),
|
||||
s if s.contains("chromium") || s.contains("brave") => Some("Similar to Chrome"),
|
||||
s if s.contains("kdenlive") || s.contains("shotcut") => Some("Similar to Windows Video Editor"),
|
||||
s if s.contains("krita") => Some("Similar to Paint / Photoshop"),
|
||||
s if s.contains("thunderbird") => Some("Similar to Outlook"),
|
||||
s if s.contains("telegram") || s.contains("signal") => Some("Similar to WhatsApp Desktop"),
|
||||
s if s.contains("obs") => Some("Similar to OBS Studio (same app!)"),
|
||||
s if s.contains("blender") => Some("Similar to Blender (same app!)"),
|
||||
s if s.contains("audacity") => Some("Similar to Audacity (same app!)"),
|
||||
s if s.contains("inkscape") => Some("Similar to Illustrator"),
|
||||
s if s.contains("handbrake") => Some("Similar to HandBrake (same app!)"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab 1: Overview - about, description, links, updates, releases, usage,
|
||||
// capabilities, file info
|
||||
@@ -382,6 +413,35 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
about_group.add(&row);
|
||||
}
|
||||
|
||||
// Categories display
|
||||
if let Some(ref cats) = record.categories {
|
||||
if !cats.is_empty() {
|
||||
let cat_row = adw::ActionRow::builder()
|
||||
.title("Categories")
|
||||
.build();
|
||||
let cat_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
cat_box.set_valign(gtk::Align::Center);
|
||||
for cat in cats.split(';').filter(|c| !c.is_empty()) {
|
||||
let friendly = match cat.trim() {
|
||||
"AudioVideo" | "Audio" | "Video" => "Media",
|
||||
"Development" => "Developer Tools",
|
||||
"Education" | "Science" => "Science & Education",
|
||||
"Game" => "Games",
|
||||
"Graphics" => "Graphics",
|
||||
"Network" => "Internet",
|
||||
"Office" => "Office",
|
||||
"System" => "System Tools",
|
||||
"Utility" => "Utilities",
|
||||
other => other,
|
||||
};
|
||||
let badge = widgets::status_badge(friendly, "neutral");
|
||||
cat_box.append(&badge);
|
||||
}
|
||||
cat_row.add_suffix(&cat_box);
|
||||
about_group.add(&cat_row);
|
||||
}
|
||||
}
|
||||
|
||||
inner.append(&about_group);
|
||||
}
|
||||
|
||||
@@ -620,8 +680,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Update method")
|
||||
.subtitle(
|
||||
"This app does not include update information. \
|
||||
You will need to check for new versions manually."
|
||||
"This app does not include automatic update information. \
|
||||
To update manually, download a newer version from the \
|
||||
developer's website and drag it into Driftwood."
|
||||
)
|
||||
.tooltip_text(
|
||||
"AppImages can include built-in update information that tells Driftwood \
|
||||
@@ -675,6 +736,22 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
.build();
|
||||
updates_group.add(&row);
|
||||
}
|
||||
|
||||
// Per-app auto-update toggle (pinned = skip updates)
|
||||
let pin_row = adw::SwitchRow::builder()
|
||||
.title("Skip auto-updates")
|
||||
.subtitle("When enabled, this app will be excluded from batch update checks")
|
||||
.active(record.pinned)
|
||||
.build();
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let record_id = record.id;
|
||||
pin_row.connect_active_notify(move |row| {
|
||||
let _ = db_ref.set_pinned(record_id, row.is_active());
|
||||
});
|
||||
}
|
||||
updates_group.add(&pin_row);
|
||||
|
||||
inner.append(&updates_group);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -837,8 +914,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
.build();
|
||||
|
||||
let type_str = match record.appimage_type {
|
||||
Some(1) => "Type 1 - older format, still widely supported",
|
||||
Some(2) => "Type 2 - modern, compressed format",
|
||||
Some(1) => "Legacy format - an older packaging style. Still works but less common.",
|
||||
Some(2) => "Modern format - compressed and efficient. This is the standard format for most AppImages.",
|
||||
_ => "Unknown type",
|
||||
};
|
||||
let type_row = adw::ActionRow::builder()
|
||||
@@ -870,19 +947,19 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let sig_row = adw::ActionRow::builder()
|
||||
.title("Verified by developer")
|
||||
.subtitle(if record.has_signature {
|
||||
"Signed by the developer"
|
||||
"This app includes a developer signature, which helps verify it has not been tampered with."
|
||||
} else {
|
||||
"Not signed"
|
||||
"This is normal for most AppImages and does not mean the app is unsafe."
|
||||
})
|
||||
.tooltip_text(
|
||||
"This app was signed by its developer, which helps verify \
|
||||
it hasn't been tampered with since it was published."
|
||||
"Some developers sign their apps with a cryptographic key. \
|
||||
This helps verify the file hasn't been modified since it was published."
|
||||
)
|
||||
.build();
|
||||
let sig_badge = if record.has_signature {
|
||||
widgets::status_badge("Signed", "success")
|
||||
} else {
|
||||
widgets::status_badge("Unsigned", "neutral")
|
||||
widgets::status_badge("No signature", "neutral")
|
||||
};
|
||||
sig_badge.set_valign(gtk::Align::Center);
|
||||
sig_row.add_suffix(&sig_badge);
|
||||
@@ -1040,7 +1117,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
// Autostart toggle
|
||||
let autostart_row = adw::SwitchRow::builder()
|
||||
.title("Start at login")
|
||||
.subtitle("Launch this app automatically when you log in")
|
||||
.subtitle("Launch this app automatically when you log in, like a Windows Startup program")
|
||||
.active(record.autostart)
|
||||
.tooltip_text(
|
||||
"Creates an autostart entry so this app launches \
|
||||
@@ -1070,9 +1147,9 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
});
|
||||
integration_group.add(&autostart_row);
|
||||
|
||||
// StartupWMClass row with editable override
|
||||
// StartupWMClass row with editable override - hidden in Advanced expander
|
||||
let wm_class_row = adw::EntryRow::builder()
|
||||
.title("Window class (advanced)")
|
||||
.title("Window class")
|
||||
.text(record.startup_wm_class.as_deref().unwrap_or(""))
|
||||
.show_apply_button(true)
|
||||
.build();
|
||||
@@ -1090,7 +1167,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
}
|
||||
}
|
||||
});
|
||||
integration_group.add(&wm_class_row);
|
||||
let advanced_expander = adw::ExpanderRow::builder()
|
||||
.title("Advanced settings")
|
||||
.show_enable_switch(false)
|
||||
.build();
|
||||
advanced_expander.add_row(&wm_class_row);
|
||||
integration_group.add(&advanced_expander);
|
||||
|
||||
// System-wide install toggle
|
||||
if record.integrated {
|
||||
@@ -1409,11 +1491,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
let fuse_status = fuse_system.status.clone();
|
||||
|
||||
let fuse_row = adw::ActionRow::builder()
|
||||
.title("App mounting")
|
||||
.title("App startup system")
|
||||
.subtitle(fuse_user_explanation(&fuse_status))
|
||||
.tooltip_text(
|
||||
"FUSE lets apps like AppImages run directly without unpacking first. \
|
||||
Without it, apps still work but take a little longer to start."
|
||||
"Most AppImages need a system component called FUSE to mount and run. \
|
||||
This shows whether it is available."
|
||||
)
|
||||
.build();
|
||||
let fuse_badge = widgets::status_badge_with_icon(
|
||||
@@ -1434,12 +1516,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
// Per-app 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_subtitle = if app_fuse_status.as_str() == "extract_and_run" {
|
||||
"This app unpacks itself each time it starts. This is slower than normal but works without extra system setup.".to_string()
|
||||
} else {
|
||||
format!("This app will launch using: {}", app_fuse_status.label())
|
||||
};
|
||||
let launch_method_row = adw::ActionRow::builder()
|
||||
.title("Startup method")
|
||||
.subtitle(&format!(
|
||||
"This app will launch using: {}",
|
||||
app_fuse_status.label()
|
||||
))
|
||||
.subtitle(&launch_method_subtitle)
|
||||
.tooltip_text(
|
||||
"AppImages can start two ways: mounting (fast, instant startup) or \
|
||||
unpacking to a temporary folder first (slower, but works everywhere). \
|
||||
@@ -1457,10 +1541,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
|
||||
// Sandboxing group
|
||||
let sandbox_group = adw::PreferencesGroup::builder()
|
||||
.title("App Isolation")
|
||||
.title("Security Restrictions")
|
||||
.description(
|
||||
"Restrict what this app can access on your system \
|
||||
for extra security."
|
||||
"Control what this app can access on your system. \
|
||||
When enabled, the app can only reach your Documents and Downloads folders."
|
||||
)
|
||||
.build();
|
||||
|
||||
@@ -1490,8 +1574,51 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
)
|
||||
.build();
|
||||
|
||||
// Profile status row - shows current sandbox profile info
|
||||
let profile_row = adw::ActionRow::builder()
|
||||
.title("Sandbox profile")
|
||||
.build();
|
||||
|
||||
let app_name_for_sandbox = record
|
||||
.app_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(&record.filename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
// Show current profile status
|
||||
let loaded_profile = sandbox::load_profile(db, &app_name_for_sandbox)
|
||||
.ok()
|
||||
.flatten();
|
||||
let profile_badge = if loaded_profile.is_some() {
|
||||
widgets::status_badge("Active", "success")
|
||||
} else if current_mode == SandboxMode::Firejail {
|
||||
widgets::status_badge("Default", "neutral")
|
||||
} else {
|
||||
widgets::status_badge("None", "dim")
|
||||
};
|
||||
profile_badge.set_valign(gtk::Align::Center);
|
||||
profile_row.add_suffix(&profile_badge);
|
||||
|
||||
// Show total profile count across all apps in group description
|
||||
let all_profiles = sandbox::list_profiles(db);
|
||||
if !all_profiles.is_empty() {
|
||||
sandbox_group.set_description(Some(&format!(
|
||||
"Restrict what this app can access on your system \
|
||||
for extra security. {} sandbox {} configured across all apps.",
|
||||
all_profiles.len(),
|
||||
if all_profiles.len() == 1 { "profile" } else { "profiles" },
|
||||
)));
|
||||
}
|
||||
|
||||
let record_id = record.id;
|
||||
let db_ref = db.clone();
|
||||
let sandbox_name = app_name_for_sandbox.clone();
|
||||
let profile_row_ref = profile_row.clone();
|
||||
firejail_row.connect_active_notify(move |row| {
|
||||
let mode = if row.is_active() {
|
||||
SandboxMode::Firejail
|
||||
@@ -1501,9 +1628,72 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) {
|
||||
log::warn!("Failed to update sandbox mode: {}", e);
|
||||
}
|
||||
// Auto-generate a default sandbox profile when enabling isolation
|
||||
if mode == SandboxMode::Firejail {
|
||||
let existing = sandbox::load_profile(&db_ref, &sandbox_name)
|
||||
.ok()
|
||||
.flatten();
|
||||
if existing.is_none() {
|
||||
let profile = sandbox::generate_default_profile(&sandbox_name);
|
||||
match sandbox::save_profile(&db_ref, &profile) {
|
||||
Ok(path) => {
|
||||
log::info!("Created default sandbox profile at {}", path.display());
|
||||
// Update badge to show profile is active
|
||||
while let Some(child) = profile_row_ref.last_child() {
|
||||
profile_row_ref.remove(&child);
|
||||
}
|
||||
let badge = widgets::status_badge("Active", "success");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
profile_row_ref.add_suffix(&badge);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to create sandbox profile: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
sandbox_group.add(&firejail_row);
|
||||
|
||||
if firejail_available && current_mode == SandboxMode::Firejail {
|
||||
// Show profile explanation when sandbox is active
|
||||
if let Some(path) = sandbox::profile_path_for_app(&app_name_for_sandbox) {
|
||||
profile_row.set_subtitle(&format!(
|
||||
"Blocks access to most of your files, camera, microphone, USB devices, and other apps. Only Documents and Downloads folders are accessible.\n{}",
|
||||
path.display()
|
||||
));
|
||||
} else {
|
||||
profile_row.set_subtitle("Default restrictive profile will be created on next launch");
|
||||
}
|
||||
|
||||
// Reset button to delete current profile and regenerate default
|
||||
if let Some(ref profile) = loaded_profile {
|
||||
if let Some(profile_id) = profile.id {
|
||||
let reset_btn = gtk::Button::builder()
|
||||
.icon_name("edit-clear-symbolic")
|
||||
.tooltip_text("Reset to default profile")
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["flat"])
|
||||
.build();
|
||||
let db_reset = db.clone();
|
||||
let reset_name = app_name_for_sandbox.clone();
|
||||
reset_btn.connect_clicked(move |_btn| {
|
||||
if let Err(e) = sandbox::delete_profile(&db_reset, profile_id) {
|
||||
log::warn!("Failed to delete sandbox profile: {}", e);
|
||||
return;
|
||||
}
|
||||
// Regenerate a fresh default
|
||||
let profile = sandbox::generate_default_profile(&reset_name);
|
||||
if let Err(e) = sandbox::save_profile(&db_reset, &profile) {
|
||||
log::warn!("Failed to regenerate sandbox profile: {}", e);
|
||||
} else {
|
||||
log::info!("Reset sandbox profile for {}", reset_name);
|
||||
}
|
||||
});
|
||||
profile_row.add_suffix(&reset_btn);
|
||||
}
|
||||
}
|
||||
sandbox_group.add(&profile_row);
|
||||
}
|
||||
|
||||
if !firejail_available {
|
||||
let firejail_cmd = "sudo apt install firejail";
|
||||
let info_row = adw::ActionRow::builder()
|
||||
@@ -1892,18 +2082,19 @@ fn build_storage_tab(
|
||||
}
|
||||
|
||||
if !fp.paths.is_empty() {
|
||||
let categories = [
|
||||
("Configuration", fp.config_size),
|
||||
("Application data", fp.data_size),
|
||||
("Cache", fp.cache_size),
|
||||
("State", fp.state_size),
|
||||
("Other", fp.other_size),
|
||||
let categories: &[(&str, u64, &str)] = &[
|
||||
("Configuration", fp.config_size, "Your preferences and settings for this app"),
|
||||
("Application data", fp.data_size, "Files and data saved by this app"),
|
||||
("Cache", fp.cache_size, "Temporary files that can be safely deleted"),
|
||||
("State", fp.state_size, "Runtime state like window positions and recent files"),
|
||||
("Other", fp.other_size, "Other files associated with this app"),
|
||||
];
|
||||
for (label, size) in &categories {
|
||||
for (label, size, tooltip) in categories {
|
||||
if *size > 0 {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(*label)
|
||||
.subtitle(&widgets::format_size(*size as i64))
|
||||
.tooltip_text(*tooltip)
|
||||
.build();
|
||||
size_group.add(&row);
|
||||
}
|
||||
@@ -2001,11 +2192,40 @@ fn build_storage_tab(
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
row.add_suffix(&size_label);
|
||||
|
||||
// Open folder button
|
||||
let open_btn = gtk::Button::builder()
|
||||
.icon_name("folder-open-symbolic")
|
||||
.tooltip_text("Open in file manager")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
open_btn.add_css_class("flat");
|
||||
let path_str = dp.path.to_string_lossy().to_string();
|
||||
open_btn.connect_clicked(move |_| {
|
||||
let file = gio::File::for_path(&path_str);
|
||||
let launcher = gtk::FileLauncher::new(Some(&file));
|
||||
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
|
||||
});
|
||||
row.add_suffix(&open_btn);
|
||||
|
||||
paths_group.add(&row);
|
||||
}
|
||||
}
|
||||
inner.append(&paths_group);
|
||||
|
||||
// Backup estimate
|
||||
let est_size = fp.config_size + fp.data_size + fp.state_size;
|
||||
if est_size > 0 {
|
||||
let estimate_row = adw::ActionRow::builder()
|
||||
.title("Backup estimate")
|
||||
.subtitle(&format!(
|
||||
"Estimated backup size: {} (settings and data files)",
|
||||
widgets::format_size(est_size as i64)
|
||||
))
|
||||
.build();
|
||||
paths_group.add(&estimate_row);
|
||||
}
|
||||
|
||||
// Backups group
|
||||
inner.append(&build_backup_group(record.id, toast_overlay));
|
||||
|
||||
@@ -2357,19 +2577,19 @@ fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
|
||||
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
|
||||
match status {
|
||||
FuseStatus::FullyFunctional =>
|
||||
"Everything is set up - apps start instantly.",
|
||||
"This app can start normally using your system's app loader.",
|
||||
FuseStatus::Fuse3Only =>
|
||||
"A small system component is missing. Most apps will still work, \
|
||||
but some may need it. Copy the install command to fix this.",
|
||||
"A system component is needed for this app to start normally. \
|
||||
Without it, the app uses a slower startup method.",
|
||||
FuseStatus::NoFusermount =>
|
||||
"A system component is missing, so apps will take a little longer \
|
||||
to start. They'll still work fine.",
|
||||
"A system component is needed for this app to start normally. \
|
||||
Without it, the app uses a slower startup method.",
|
||||
FuseStatus::NoDevFuse =>
|
||||
"Your system doesn't support instant app mounting. Apps will unpack \
|
||||
before starting, which takes a bit longer.",
|
||||
"A system component is needed for this app to start normally. \
|
||||
Without it, the app uses a slower startup method.",
|
||||
FuseStatus::MissingLibfuse2 =>
|
||||
"A small system component is needed for fast startup. \
|
||||
Copy the install command to fix this.",
|
||||
"A system component is needed for this app to start normally. \
|
||||
Without it, the app uses a slower startup method.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2624,9 +2844,9 @@ pub fn show_uninstall_dialog_with_callback(
|
||||
db: &Rc<Database>,
|
||||
is_integrated: bool,
|
||||
data_paths: &[(String, String, u64)],
|
||||
on_complete: Option<Box<dyn FnOnce() + 'static>>,
|
||||
on_complete: Option<Rc<dyn Fn() + 'static>>,
|
||||
) {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename).to_string();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(&format!("Uninstall {}?", name))
|
||||
.body("Select what to remove:")
|
||||
@@ -2675,46 +2895,34 @@ pub fn show_uninstall_dialog_with_callback(
|
||||
|
||||
dialog.set_extra_child(Some(&extra));
|
||||
|
||||
let record_snapshot = record.clone();
|
||||
let record_id = record.id;
|
||||
let record_path = record.path.clone();
|
||||
let db_ref = db.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let on_complete = std::cell::Cell::new(on_complete);
|
||||
dialog.connect_response(Some("uninstall"), move |_dlg, _response| {
|
||||
// Remove integration if checked
|
||||
if let Some(ref check) = integration_check {
|
||||
if check.is_active() {
|
||||
integrator::undo_all_modifications(&db_ref, record_id).ok();
|
||||
if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) {
|
||||
integrator::remove_integration(&rec).ok();
|
||||
}
|
||||
// Capture deletion choices from checkboxes before dialog closes
|
||||
let delete_file = appimage_check.is_active();
|
||||
let should_remove_integration = integration_check.as_ref().map_or(false, |c| c.is_active());
|
||||
let paths_to_delete: Vec<String> = path_checks.iter()
|
||||
.filter(|(check, _)| check.is_active())
|
||||
.map(|(_, path)| path.clone())
|
||||
.collect();
|
||||
|
||||
// Remove integration immediately (before DB delete, since CASCADE
|
||||
// removes system_modifications entries we need for undo_all_modifications)
|
||||
if should_remove_integration {
|
||||
integrator::undo_all_modifications(&db_ref, record_id).ok();
|
||||
if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) {
|
||||
integrator::remove_integration(&rec).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove checked data paths
|
||||
for (check, path) in &path_checks {
|
||||
if check.is_active() {
|
||||
let p = std::path::Path::new(path);
|
||||
if p.is_dir() {
|
||||
std::fs::remove_dir_all(p).ok();
|
||||
} else if p.is_file() {
|
||||
std::fs::remove_file(p).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove AppImage file if checked
|
||||
if appimage_check.is_active() {
|
||||
std::fs::remove_file(&record_path).ok();
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
// Remove from database (so item vanishes from lists)
|
||||
db_ref.remove_appimage(record_id).ok();
|
||||
|
||||
toast_ref.add_toast(adw::Toast::new("AppImage uninstalled"));
|
||||
|
||||
// Run the completion callback if provided
|
||||
if let Some(cb) = on_complete.take() {
|
||||
// Run the completion callback to refresh the library view
|
||||
if let Some(ref cb) = on_complete {
|
||||
cb();
|
||||
}
|
||||
|
||||
@@ -2723,6 +2931,66 @@ pub fn show_uninstall_dialog_with_callback(
|
||||
let nav: adw::NavigationView = nav.downcast().unwrap();
|
||||
nav.pop();
|
||||
}
|
||||
|
||||
// Show undo toast - file deletion is deferred until toast dismisses
|
||||
let toast = adw::Toast::builder()
|
||||
.title(&format!("{} uninstalled", name))
|
||||
.button_label("Undo")
|
||||
.timeout(7)
|
||||
.build();
|
||||
|
||||
let undo_clicked = Rc::new(Cell::new(false));
|
||||
|
||||
// On Undo: restore record to DB, re-integrate if needed, refresh
|
||||
{
|
||||
let undo_flag = undo_clicked.clone();
|
||||
let db_undo = db_ref.clone();
|
||||
let snapshot = record_snapshot.clone();
|
||||
let toast_undo = toast_ref.clone();
|
||||
let on_complete_undo = on_complete.clone();
|
||||
let name_undo = name.clone();
|
||||
let was_integrated = should_remove_integration;
|
||||
toast.connect_button_clicked(move |_| {
|
||||
undo_flag.set(true);
|
||||
db_undo.restore_appimage_record(&snapshot).ok();
|
||||
// Re-integrate if it was previously integrated
|
||||
if was_integrated {
|
||||
integrator::integrate(&snapshot).ok();
|
||||
}
|
||||
if let Some(ref cb) = on_complete_undo {
|
||||
cb();
|
||||
}
|
||||
toast_undo.add_toast(adw::Toast::new(&format!("{} restored", name_undo)));
|
||||
});
|
||||
}
|
||||
|
||||
// On dismiss (timeout or any reason): perform actual file deletions if not undone
|
||||
{
|
||||
let undo_flag = undo_clicked.clone();
|
||||
let path = record_path.clone();
|
||||
toast.connect_dismissed(move |_| {
|
||||
if undo_flag.get() {
|
||||
return; // Undo was clicked, nothing to delete
|
||||
}
|
||||
|
||||
// Remove data paths
|
||||
for data_path in &paths_to_delete {
|
||||
let p = std::path::Path::new(data_path);
|
||||
if p.is_dir() {
|
||||
std::fs::remove_dir_all(p).ok();
|
||||
} else if p.is_file() {
|
||||
std::fs::remove_file(p).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove AppImage file
|
||||
if delete_file {
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast_ref.add_toast(toast);
|
||||
});
|
||||
|
||||
dialog.present(Some(toast_overlay));
|
||||
|
||||
Reference in New Issue
Block a user