Files
driftwood/src/ui/drop_dialog.rs
lashman 00a1ed3599 Add icon preview to drag-and-drop dialog
Fast icon extraction pulls .DirIcon from the squashfs without full
analysis. Single-file drops show a 64px preview in the dialog while
the user chooses an import option.
2026-02-27 23:58:09 +02:00

345 lines
12 KiB
Rust

use adw::prelude::*;
use gtk::gio;
use std::path::PathBuf;
use std::rc::Rc;
use crate::core::analysis;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::inspector;
use crate::i18n::{i18n, ni18n_f};
/// Registered file info returned by the fast registration phase.
struct RegisteredFile {
id: i64,
path: PathBuf,
appimage_type: discovery::AppImageType,
}
/// Show a dialog offering to add dropped AppImage files to the library.
///
/// `files` should already be validated as AppImages (magic bytes checked).
/// `toast_overlay` is used to show result toasts.
/// `on_complete` is called after files are registered to refresh the UI.
pub fn show_drop_dialog(
parent: &impl IsA<gtk::Widget>,
files: Vec<PathBuf>,
toast_overlay: &adw::ToastOverlay,
on_complete: impl Fn() + 'static,
) {
if files.is_empty() {
return;
}
let count = files.len();
// Build heading and body
let heading = if count == 1 {
let name = files[0]
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "AppImage".to_string());
crate::i18n::i18n_f("Add {name}?", &[("{name}", &name)])
} else {
ni18n_f(
"Add {} app?",
"Add {} apps?",
count as u32,
&[("{}", &count.to_string())],
)
};
let body = if count == 1 {
files[0].to_string_lossy().to_string()
} else {
files
.iter()
.filter_map(|f| f.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect::<Vec<_>>()
.join("\n")
};
let dialog = adw::AlertDialog::builder()
.heading(&heading)
.body(&body)
.build();
// For single file, try fast icon extraction for a preview
if count == 1 {
let file_path = files[0].clone();
let dialog_ref = dialog.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
inspector::extract_icon_fast(&file_path)
})
.await;
if let Ok(Some(icon_path)) = result {
let image = gtk::Image::builder()
.file(icon_path.to_string_lossy().as_ref())
.pixel_size(64)
.halign(gtk::Align::Center)
.margin_top(12)
.build();
dialog_ref.set_extra_child(Some(&image));
}
});
}
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("keep-in-place", &i18n("Keep in place"));
dialog.add_response("copy-only", &i18n("Copy to Applications"));
dialog.add_response("copy-and-integrate", &i18n("Copy & add to menu"));
dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("copy-and-integrate"));
dialog.set_close_response("cancel");
let toast_ref = toast_overlay.clone();
let on_complete = Rc::new(on_complete);
dialog.connect_response(None, move |_dialog, response| {
if response == "cancel" {
return;
}
let copy = response != "keep-in-place";
let integrate = response == "copy-and-integrate";
let files = files.clone();
let toast_ref = toast_ref.clone();
let on_complete_ref = on_complete.clone();
glib::spawn_future_local(async move {
// Phase 1: Fast registration (copy + DB upsert only)
let result = gio::spawn_blocking(move || {
register_dropped_files(&files, copy)
})
.await;
match result {
Ok(Ok(registered)) => {
let added = registered.len();
// Refresh UI immediately - apps appear with "Analyzing..." badge
on_complete_ref();
// Show toast
if added == 1 {
toast_ref.add_toast(adw::Toast::new(&i18n("Added to your apps")));
} else if added > 0 {
let msg = ni18n_f(
"Added {} app",
"Added {} apps",
added as u32,
&[("{}", &added.to_string())],
);
toast_ref.add_toast(adw::Toast::new(&msg));
}
// Phase 2: Background analysis for each file
let on_complete_bg = on_complete_ref.clone();
for reg in registered {
let on_complete_inner = on_complete_bg.clone();
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
analysis::run_background_analysis(
reg.id,
reg.path,
reg.appimage_type,
integrate,
);
})
.await;
// Refresh UI when each analysis completes
on_complete_inner();
});
}
}
Ok(Err(e)) => {
log::error!("Drop processing failed: {}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
}
Err(e) => {
log::error!("Drop task failed: {:?}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
}
}
});
});
dialog.present(Some(parent));
}
/// Fast registration of dropped files - copies to target dir and inserts into DB.
/// Returns a list of registered files for background analysis.
fn register_dropped_files(
files: &[PathBuf],
copy_to_target: bool,
) -> Result<Vec<RegisteredFile>, String> {
let db = Database::open().map_err(|e| format!("Failed to open database: {}", e))?;
let settings = gio::Settings::new(crate::config::APP_ID);
let scan_dirs: Vec<String> = settings
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.collect();
// Target directory: first scan directory (default ~/Applications)
let target_dir = scan_dirs
.first()
.map(|d| discovery::expand_tilde(d))
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("Applications")
});
// Ensure target directory exists
std::fs::create_dir_all(&target_dir)
.map_err(|e| format!("Failed to create {}: {}", target_dir.display(), e))?;
// Expand scan dirs for checking if file is already in a scan location
let expanded_scan_dirs: Vec<PathBuf> = scan_dirs
.iter()
.map(|d| discovery::expand_tilde(d))
.collect();
let mut registered = Vec::new();
for file in files {
// Determine if the file is already in a scan directory
let in_scan_dir = expanded_scan_dirs.iter().any(|scan_dir| {
file.parent()
.and_then(|p| p.canonicalize().ok())
.and_then(|parent| {
scan_dir.canonicalize().ok().map(|sd| parent == sd)
})
.unwrap_or(false)
});
let final_path = if in_scan_dir || !copy_to_target {
// Keep the file where it is; just ensure it's executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(file) {
if meta.permissions().mode() & 0o111 == 0 {
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(file, perms) {
log::warn!("Failed to set executable on {}: {}", file.display(), e);
}
}
}
}
file.clone()
} else {
let filename = file
.file_name()
.ok_or_else(|| "No filename".to_string())?;
let dest = target_dir.join(filename);
// Don't overwrite existing files - generate a unique name
let dest = if dest.exists() && dest != *file {
let stem = dest
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("app");
let ext = dest
.extension()
.and_then(|e| e.to_str())
.unwrap_or("AppImage");
let mut counter = 1;
loop {
let candidate = target_dir.join(format!("{}-{}.{}", stem, counter, ext));
if !candidate.exists() {
break candidate;
}
counter += 1;
}
} else {
dest
};
std::fs::copy(file, &dest)
.map_err(|e| format!("Failed to copy {}: {}", file.display(), e))?;
// Make executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(&dest, perms) {
log::warn!("Failed to set executable permissions on {}: {}", dest.display(), e);
}
}
dest
};
// Validate it's actually an AppImage
let appimage_type = match discovery::detect_appimage(&final_path) {
Some(t) => t,
None => {
log::warn!("Not a valid AppImage: {}", final_path.display());
continue;
}
};
let filename = final_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let metadata = std::fs::metadata(&final_path).ok();
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
let modified = metadata
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|dur| {
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
let is_executable = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata
.as_ref()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
true
}
};
// Register in database with pending analysis status
let id = db
.upsert_appimage(
&final_path.to_string_lossy(),
&filename,
Some(appimage_type.as_i32()),
size_bytes,
is_executable,
modified.as_deref(),
)
.map_err(|e| format!("Database error: {}", e))?;
if let Err(e) = db.update_analysis_status(id, "pending") {
log::warn!("Failed to set analysis status to 'pending' for id {}: {}", id, e);
}
registered.push(RegisteredFile {
id,
path: final_path,
appimage_type,
});
}
Ok(registered)
}