git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

src/services/fs.rs (36.2K)

  1 use std::fs;
  2 use std::os::unix::fs::PermissionsExt;
  3 use std::path::{Path, PathBuf};
  4 use tokio::sync::mpsc;
  5 use crate::pages::browse::DirEntry;
  6 use crate::util::{format_size, format_permissions};
  7 use image::GenericImageView;
  8 
  9 /// A downscaled RGBA thumbnail of an image file. `pixels` is flat RGBA8
 10 /// (width * height * 4 bytes) — exactly what `cce_ui::vk::upload_rgba` takes.
 11 #[derive(Debug, Clone, Default)]
 12 pub struct ImagePreviewData {
 13     pub width: u32,
 14     pub height: u32,
 15     pub pixels: Vec<u8>,
 16 }
 17 
 18 #[derive(Debug, Clone, Default)]
 19 pub struct PreviewData {
 20     pub name: String,
 21     pub is_dir: bool,
 22     pub size: String,
 23     pub permissions: String,
 24     pub modified: String,
 25     pub file_type: String,
 26     pub target: String,
 27     pub content_preview: Option<String>,
 28     pub image_preview: Option<ImagePreviewData>,
 29 }
 30 
 31 #[derive(Debug, Clone)]
 32 pub enum FsRequest {
 33     ReadDirectory(PathBuf),
 34     RefreshDirectory(PathBuf),
 35     /// Read a selection's preview. The number is the request's generation
 36     /// (`FsService::preview_latest` holds the newest): a read that has been
 37     /// superseded before it starts is skipped, and its result, should it
 38     /// finish anyway, is dropped by the app.
 39     ReadPreview(PathBuf, u64),
 40     /// Move to the freedesktop trash (the default Delete).
 41     TrashPath(PathBuf),
 42     /// Unrecoverable delete — the trash's own rows, and "Delete Permanently".
 43     DeletePath(PathBuf, bool), // (path, is_dir)
 44     /// Restore a trashed item (a path under Trash/files) to its origin.
 45     RestorePath(PathBuf),
 46     EmptyTrash,
 47     /// Create a new, uniquely named folder inside the given directory.
 48     CreateDir(PathBuf),
 49     /// Rename (from, to) — refused when `to` already exists.
 50     RenamePath(PathBuf, PathBuf),
 51     /// Walk a whole subtree for the Space view. The flag is the caller's
 52     /// cancel token — raising it abandons a scan whose answer is no longer
 53     /// wanted (see `SpaceState::begin_scan`).
 54     ScanTree(PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>),
 55     ReadLastDir,
 56     SaveLastDir(PathBuf),
 57 }
 58 
 59 pub struct FsService {
 60     pub sender: mpsc::Sender<FsRequest>,
 61     /// Generation of the newest preview request.
 62     pub preview_latest: std::sync::Arc<std::sync::atomic::AtomicU64>,
 63 }
 64 
 65 impl FsService {
 66     pub fn new(app_sender: calloop::channel::Sender<crate::Message>) -> Self {
 67         let (tx, mut rx) = mpsc::channel::<FsRequest>(100);
 68         let preview_latest = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
 69         let latest = preview_latest.clone();
 70 
 71         tokio::spawn(async move {
 72             while let Some(req) = rx.recv().await {
 73                 let app_sender = app_sender.clone();
 74                 match req {
 75                     FsRequest::ReadDirectory(path) => {
 76                         tokio::spawn(async move {
 77                             let entries = read_directory_internal(&path);
 78                             let _ = app_sender.send(crate::Message::Browse(
 79                                 crate::pages::browse::BrowseMessage::DirectoryLoaded(path, entries),
 80                             ));
 81                         });
 82                     }
 83                     FsRequest::RefreshDirectory(path) => {
 84                         tokio::spawn(async move {
 85                             let entries = read_directory_internal(&path);
 86                             let _ = app_sender.send(crate::Message::Browse(
 87                                 crate::pages::browse::BrowseMessage::DirectoryRefreshed(path, entries),
 88                             ));
 89                         });
 90                     }
 91                     FsRequest::ReadPreview(path, generation) => {
 92                         // A full image decode plus two `xdg-mime` spawns:
 93                         // blocking work, so the blocking pool. Arrowing down a
 94                         // folder of photos queues one per row; the ones passed
 95                         // over before their turn are never decoded.
 96                         let latest = latest.clone();
 97                         tokio::task::spawn_blocking(move || {
 98                             if latest.load(std::sync::atomic::Ordering::Relaxed) != generation {
 99                                 return;
100                             }
101                             let preview_data = load_preview_data_internal(&path);
102                             let _ = app_sender.send(crate::Message::Preview(
103                                 crate::pages::preview::PreviewMessage::PreviewLoaded {
104                                     path,
105                                     generation,
106                                     data: preview_data,
107                                 },
108                             ));
109                         });
110                     }
111                     FsRequest::TrashPath(path) => {
112                         tokio::spawn(async move {
113                             let result = super::trash::move_to_trash(&path).map_err(|e| e.to_string());
114                             let _ = app_sender.send(crate::Message::Browse(
115                                 crate::pages::browse::BrowseMessage::Deleted(path, result),
116                             ));
117                         });
118                     }
119                     FsRequest::DeletePath(path, is_dir) => {
120                         tokio::spawn(async move {
121                             let res = if is_dir {
122                                 fs::remove_dir_all(&path)
123                             } else {
124                                 fs::remove_file(&path)
125                             };
126                             let result = res.map_err(|e| e.to_string());
127                             let _ = app_sender.send(crate::Message::Browse(
128                                 crate::pages::browse::BrowseMessage::Deleted(path, result),
129                             ));
130                         });
131                     }
132                     FsRequest::RestorePath(path) => {
133                         tokio::spawn(async move {
134                             // A restored row leaves the trash listing exactly like a
135                             // deleted row leaves its directory — same message.
136                             let result = super::trash::restore(&path).map(|_| ()).map_err(|e| e.to_string());
137                             let _ = app_sender.send(crate::Message::Browse(
138                                 crate::pages::browse::BrowseMessage::Deleted(path, result),
139                             ));
140                         });
141                     }
142                     FsRequest::EmptyTrash => {
143                         tokio::spawn(async move {
144                             let result = super::trash::empty().map(|_| ()).map_err(|e| e.to_string());
145                             let _ = app_sender.send(crate::Message::Browse(
146                                 crate::pages::browse::BrowseMessage::TrashEmptied(result),
147                             ));
148                         });
149                     }
150                     FsRequest::CreateDir(parent) => {
151                         tokio::spawn(async move {
152                             let result = create_new_folder(&parent).map_err(|e| e.to_string());
153                             let _ = app_sender.send(crate::Message::Browse(
154                                 crate::pages::browse::BrowseMessage::FolderCreated(parent, result),
155                             ));
156                         });
157                     }
158                     FsRequest::RenamePath(from, to) => {
159                         tokio::spawn(async move {
160                             let result = rename_no_clobber(&from, &to).map(|_| to).map_err(|e| e.to_string());
161                             let _ = app_sender.send(crate::Message::Browse(
162                                 crate::pages::browse::BrowseMessage::Renamed(from, result),
163                             ));
164                         });
165                     }
166                     FsRequest::ScanTree(path, cancel) => {
167                         // Minutes of blocking recursion on a large tree, so
168                         // this goes to the blocking pool rather than tying up
169                         // an async worker the way the short reads above can.
170                         tokio::task::spawn_blocking(move || {
171                             let progress_sender = app_sender.clone();
172                             let progress_dir = path.clone();
173                             let mut on_progress = |files, bytes| {
174                                 let _ = progress_sender.send(crate::Message::Space(
175                                     crate::pages::space::SpaceMessage::Progress {
176                                         dir: progress_dir.clone(),
177                                         files,
178                                         bytes,
179                                     },
180                                 ));
181                             };
182                             let result = super::scan::scan(&path, &cancel, &mut on_progress);
183                             match result {
184                                 Some(res) if !res.cancelled => {
185                                     let _ = app_sender.send(crate::Message::Space(
186                                         crate::pages::space::SpaceMessage::Scanned {
187                                             dir: path,
188                                             tree: res.tree,
189                                         },
190                                     ));
191                                 }
192                                 // A cancelled scan's tree is partial. Drop it
193                                 // silently — the scan that superseded it is
194                                 // already on its way with the real one.
195                                 Some(_) => {}
196                                 None => {
197                                     let _ = app_sender.send(crate::Message::Space(
198                                         crate::pages::space::SpaceMessage::Failed(format!(
199                                             "Cannot scan {}",
200                                             path.display()
201                                         )),
202                                     ));
203                                 }
204                             }
205                         });
206                     }
207                     FsRequest::ReadLastDir => {
208                         tokio::spawn(async move {
209                             let last_dir = read_last_dir_internal();
210                             let _ = app_sender.send(crate::Message::Browse(
211                                 crate::pages::browse::BrowseMessage::LastDirLoaded(last_dir),
212                             ));
213                         });
214                     }
215                     FsRequest::SaveLastDir(dir) => {
216                         tokio::spawn(async move {
217                             save_last_dir_internal(&dir);
218                         });
219                     }
220                 }
221             }
222         });
223 
224         Self { sender: tx, preview_latest }
225     }
226 
227     pub fn send(&self, req: FsRequest) {
228         let sender = self.sender.clone();
229         tokio::spawn(async move {
230             let _ = sender.send(req).await;
231         });
232     }
233 }
234 
235 // ── Internal Helper Functions ───────────────────────────────────────
236 
237 /// `fs::rename`, except it will not replace an existing `to` — plain rename
238 /// silently overwrites a file of the same name.
239 pub fn rename_no_clobber(from: &Path, to: &Path) -> std::io::Result<()> {
240     use std::os::unix::fs::MetadataExt;
241     // The same inode is a case-only rename on a case-insensitive mount, not a clobber.
242     let same_file = |a: &fs::Metadata, b: &fs::Metadata| a.dev() == b.dev() && a.ino() == b.ino();
243     if let Ok(existing) = fs::symlink_metadata(to)
244         && !fs::symlink_metadata(from).is_ok_and(|m| same_file(&m, &existing))
245     {
246         return Err(std::io::Error::new(
247             std::io::ErrorKind::AlreadyExists,
248             format!("{} already exists", to.display()),
249         ));
250     }
251     fs::rename(from, to)
252 }
253 
254 /// Create "New Folder" in `parent`, or "New Folder 2", "New Folder 3", … when
255 /// the name is taken. `create_dir` itself is the existence check, so a name
256 /// claimed between two attempts just moves on to the next one.
257 pub fn create_new_folder(parent: &Path) -> std::io::Result<PathBuf> {
258     for n in 1u32.. {
259         let name = if n == 1 { "New Folder".to_string() } else { format!("New Folder {n}") };
260         let path = parent.join(name);
261         match fs::create_dir(&path) {
262             Ok(()) => return Ok(path),
263             Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
264             Err(e) => return Err(e),
265         }
266     }
267     unreachable!()
268 }
269 
270 pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
271     let in_trash = super::trash::is_trash_files_dir(path);
272     let mut entries: Vec<DirEntry> = match fs::read_dir(path) {
273         Ok(rd) => rd
274             .filter_map(|e| e.ok())
275             .filter_map(|e| {
276                 let meta = e.metadata().ok()?;
277                 let name = e.file_name().to_string_lossy().to_string();
278                 let is_dir = meta.is_dir();
279                 let size = meta.len();
280                 let permissions = meta.permissions().mode();
281                 let modified = meta
282                     .modified()
283                     .ok()
284                     .and_then(|t| {
285                         let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
286                         let datetime =
287                             chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
288                         Some(datetime.format("%Y-%m-%d %H:%M").to_string())
289                     })
290                     .unwrap_or_else(|| "—".to_string());
291                 let origin = if in_trash {
292                     super::trash::origin_of(&e.path()).map(|p| p.display().to_string())
293                 } else {
294                     None
295                 };
296                 Some(DirEntry {
297                     name,
298                     path: e.path(),
299                     is_dir,
300                     size,
301                     permissions,
302                     modified,
303                     origin,
304                 })
305             })
306             .collect(),
307         Err(_) => return Vec::new(),
308     };
309 
310     // Sort: directories first, then files; alphabetically within each group
311     entries.sort_by(|a, b| {
312         match (a.is_dir, b.is_dir) {
313             (true, false) => std::cmp::Ordering::Less,
314             (false, true) => std::cmp::Ordering::Greater,
315             _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
316         }
317     });
318 
319     entries
320 }
321 
322 fn load_image_preview(path: &Path) -> Option<ImagePreviewData> {
323     let img = image::open(path).ok()?;
324     let (orig_w, orig_h) = img.dimensions();
325     if orig_w == 0 || orig_h == 0 {
326         return None;
327     }
328     // GPU-textured previews: 512px is crisp at pane size for one live image
329     // (1 MB RGBA) while keeping the Triangle resize quick per selection.
330     let max_dim = 512.0;
331     let ratio = (max_dim / orig_w as f32).min(max_dim / orig_h as f32).min(1.0);
332     let target_w = (orig_w as f32 * ratio).round() as u32;
333     let target_h = (orig_h as f32 * ratio).round() as u32;
334     if target_w == 0 || target_h == 0 {
335         return None;
336     }
337     
338     let resized = if target_w == orig_w && target_h == orig_h {
339         img
340     } else {
341         img.resize(target_w, target_h, image::imageops::FilterType::Triangle)
342     };
343     
344     let pixels = resized.to_rgba8().into_raw();
345 
346     Some(ImagePreviewData {
347         width: target_w,
348         height: target_h,
349         pixels,
350     })
351 }
352 
353 /// Vector previews rasterize at the same 512px pane budget via cce-ui's shared
354 /// resvg path (fontdb-backed, thread-safe — this runs on the FsService thread)
355 /// and hand back the same straight-RGBA ImagePreviewData as raster files.
356 fn load_svg_preview(path: &Path) -> Option<ImagePreviewData> {
357     let data = fs::read(path).ok()?;
358     let (pixels, width, height) = cce_ui::rasterize_svg(&data, 512)?;
359     Some(ImagePreviewData { width, height, pixels })
360 }
361 
362 /// Path-traced thumbnail for a cce-designer project directory, cached as a
363 /// PNG under `~/.cache/cce/thumbnails/` keyed on the project path and its
364 /// `state.json` mtime — the GPU renders only on cache misses. Rendering is
365 /// delegated to `cce-designer --thumbnail` (the geometry, OpenCL nodes
366 /// included, lives there); returns None if the binary is missing or fails,
367 /// and the caller falls back to the plain directory listing.
368 fn load_project_thumbnail(path: &Path) -> Option<ImagePreviewData> {
369     use std::hash::{Hash, Hasher};
370 
371     let state_file = path.join("state.json");
372     let mtime = fs::metadata(&state_file)
373         .ok()?
374         .modified()
375         .ok()?
376         .duration_since(std::time::UNIX_EPOCH)
377         .ok()?
378         .as_secs();
379     let mut hasher = std::collections::hash_map::DefaultHasher::new();
380     path.to_string_lossy().hash(&mut hasher);
381     mtime.hash(&mut hasher);
382 
383     let cache_dir = std::env::var_os("XDG_CACHE_HOME")
384         .map(PathBuf::from)
385         .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?
386         .join("cce")
387         .join("thumbnails");
388     fs::create_dir_all(&cache_dir).ok()?;
389     let cached = cache_dir.join(format!("{:016x}.png", hasher.finish()));
390 
391     if !cached.exists() {
392         // Same ~/.local/bin-first resolution as spawn_command_for_path.
393         let mut program = PathBuf::from("cce-designer");
394         if let Ok(home) = std::env::var("HOME") {
395             let local_bin = PathBuf::from(home).join(".local").join("bin").join("cce-designer");
396             if local_bin.exists() {
397                 program = local_bin;
398             }
399         }
400         let output = std::process::Command::new(program)
401             .arg("--thumbnail")
402             .arg(path)
403             .arg(&cached)
404             .args(["--size", "256"])
405             .output()
406             .ok()?;
407         if !output.status.success() {
408             return None;
409         }
410     }
411     load_image_preview(&cached)
412 }
413 
414 fn load_preview_data_internal(path: &Path) -> PreviewData {
415     let meta = fs::symlink_metadata(path).ok();
416     let name = path
417         .file_name()
418         .map(|n| n.to_string_lossy().to_string())
419         .unwrap_or_else(|| path.to_string_lossy().to_string());
420 
421     let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
422     let size = meta.as_ref().map(|m| format_size(m.len())).unwrap_or_else(|| "—".to_string());
423     let permissions = meta
424         .as_ref()
425         .map(|m| format_permissions(m.permissions().mode()))
426         .unwrap_or_else(|| "—".to_string());
427     let modified = meta
428         .as_ref()
429         .and_then(|m| m.modified().ok())
430         .and_then(|t| {
431             let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
432             let datetime = chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
433             Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
434         })
435         .unwrap_or_else(|| "—".to_string());
436 
437     let mut file_type = infer_file_type(&name, is_dir);
438     if !is_dir {
439         if let Some(mime) = get_mime_type(path) {
440             if let Some((app_name, _)) = get_default_application(&mime) {
441                 file_type = format!("{} ({}) [Open with: {}]", file_type, mime, app_name);
442             } else {
443                 file_type = format!("{} ({})", file_type, mime);
444             }
445         }
446     }
447 
448 
449     // Check symlink target
450     let target = if meta.as_ref().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
451         fs::read_link(path)
452             .map(|t| t.to_string_lossy().to_string())
453             .unwrap_or_default()
454     } else {
455         String::new()
456     };
457 
458     let mut content_preview = None;
459     let mut image_preview = None;
460 
461     if is_dir {
462         // cce-designer project: show a path-traced thumbnail of its geometry.
463         if path.join("state.json").exists() {
464             image_preview = load_project_thumbnail(path);
465         }
466         if image_preview.is_none() {
467             if let Ok(entries) = fs::read_dir(path) {
468                 let mut names = Vec::new();
469                 for entry in entries.flatten().take(100) {
470                     let name = entry.file_name().to_string_lossy().to_string();
471                     let is_sub_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
472                     // A text body: a directory wears the plain-text marker,
473                     // a trailing slash, rather than a pictogram.
474                     names.push(if is_sub_dir { format!("{name}/") } else { name });
475                 }
476                 if names.is_empty() {
477                     content_preview = Some("[Empty directory]".to_string())
478                 } else {
479                     content_preview = Some(names.join("\n"))
480                 }
481             }
482         }
483     } else {
484         let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
485         let is_img_ext = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico");
486         
487         if is_img_ext {
488             image_preview = load_image_preview(path);
489         } else if matches!(ext.as_str(), "svg" | "svgz") {
490             // On parse failure this stays None and the text fallback below
491             // shows the SVG source.
492             image_preview = load_svg_preview(path);
493         }
494 
495         if image_preview.is_none() {
496             if let Ok(mut file) = fs::File::open(path) {
497                 use std::io::Read;
498                 let mut buf = vec![0u8; 65536];
499                 if let Ok(n) = file.read(&mut buf) {
500                     buf.truncate(n);
501                     let is_text = match std::str::from_utf8(&buf) {
502                         Ok(_) => true,
503                         Err(err) => err.error_len().is_none() && err.valid_up_to() > 0,
504                     };
505                     if is_text {
506                         let utf8_str = String::from_utf8_lossy(&buf).into_owned();
507                         content_preview = Some(utf8_str);
508                     } else {
509                         content_preview = Some("[Binary file content]".to_string());
510                     }
511                 }
512             }
513         }
514     }
515 
516     PreviewData {
517         name,
518         is_dir,
519         size,
520         permissions,
521         modified,
522         file_type,
523         target,
524         content_preview,
525         image_preview,
526     }
527 }
528 
529 fn infer_file_type(name: &str, is_dir: bool) -> String {
530     if is_dir {
531         return "Directory".to_string();
532     }
533     match name.rsplit('.').next() {
534         Some("rs") => "Rust source".to_string(),
535         Some("toml") => "TOML config".to_string(),
536         Some("json") => "JSON data".to_string(),
537         Some("yaml") | Some("yml") => "YAML config".to_string(),
538         Some("png") => "PNG image".to_string(),
539         Some("jpg") | Some("jpeg") => "JPEG image".to_string(),
540         Some("svg") => "SVG image".to_string(),
541         Some("gif") => "GIF image".to_string(),
542         Some("mp3") => "MP3 audio".to_string(),
543         Some("wav") => "WAV audio".to_string(),
544         Some("flac") => "FLAC audio".to_string(),
545         Some("mp4") => "MP4 video".to_string(),
546         Some("mkv") => "Matroska video".to_string(),
547         Some("zip") => "ZIP archive".to_string(),
548         Some("tar") => "Tar archive".to_string(),
549         Some("gz") => "Gzip archive".to_string(),
550         Some("py") => "Python source".to_string(),
551         Some("sh") | Some("bash") => "Shell script".to_string(),
552         Some("md") => "Markdown".to_string(),
553         Some("txt") => "Plain text".to_string(),
554         Some("c") | Some("h") => "C source".to_string(),
555         Some("cpp") | Some("hpp") | Some("cc") => "C++ source".to_string(),
556         Some("hs") => "Haskell source".to_string(),
557         Some("exe") => "Windows executable".to_string(),
558         Some("pdf") => "PDF document".to_string(),
559         _ => "File".to_string(),
560     }
561 }
562 
563 /// Base config directory for cce: `$XDG_CONFIG_HOME/cce`, else `$HOME/.config/cce`.
564 /// Returns `None` only when neither variable is usable.
565 pub fn cce_config_dir() -> Option<PathBuf> {
566     Some(cce_ui::config::cce_config_dir())
567 }
568 
569 /// The last-dir file under a cce config dir (`cce_config_dir()` in the app; a temp dir
570 /// in tests, which is why it is a parameter: setting HOME in a test points every test
571 /// running alongside it at the wrong config too).
572 fn last_dir_file_in(config_dir: &Path) -> PathBuf {
573     config_dir.join("cce-files").join("cce-files-last-dir.txt")
574 }
575 
576 pub fn read_last_dir_internal() -> Option<PathBuf> {
577     read_last_dir_in(&cce_config_dir()?)
578 }
579 
580 pub fn read_last_dir_in(config_dir: &Path) -> Option<PathBuf> {
581     let path = last_dir_file_in(config_dir);
582     if path.exists() {
583         let content = fs::read_to_string(path).ok()?;
584         let trimmed = content.trim();
585         if !trimmed.is_empty() {
586             let pb = PathBuf::from(trimmed);
587             if pb.exists() && pb.is_dir() {
588                 return Some(pb);
589             }
590         }
591     }
592     None
593 }
594 
595 pub fn save_last_dir_internal(dir: &Path) {
596     if let Some(config_dir) = cce_config_dir() {
597         save_last_dir_in(&config_dir, dir);
598     }
599 }
600 
601 pub fn save_last_dir_in(config_dir: &Path, dir: &Path) {
602     let path = last_dir_file_in(config_dir);
603     if let Some(parent) = path.parent() {
604         let _ = fs::create_dir_all(parent);
605     }
606     let _ = fs::write(path, dir.to_string_lossy().as_bytes());
607 }
608 
609 /// Types decided by extension alone, ahead of `xdg-mime`. On this machine
610 /// `xdg-mime query filetype` falls back to `file --mime-type`, which sniffs
611 /// content: .gltf reads as application/json, .obj as text/plain, binary .stl
612 /// and .ply as application/octet-stream, so Open never reached cce-model.
613 /// shared-mime-info registers no PLY type; model/x-ply is the one
614 /// cce-model.desktop claims. `ext` must already be lowercase.
615 fn mime_for_extension(ext: &str) -> Option<&'static str> {
616     Some(match ext {
617         "kdl" => "application/x-kdl",
618         "stl" => "model/stl",
619         "obj" => "model/obj",
620         "gltf" => "model/gltf+json",
621         "glb" => "model/gltf-binary",
622         "ply" => "model/x-ply",
623         _ => return None,
624     })
625 }
626 
627 pub fn get_mime_type(path: &Path) -> Option<String> {
628     // Matches `browse::is_project_dir`: a designer project is a dir holding a state.json.
629     if path.is_dir() && path.join("state.json").exists() {
630         return Some("application/x-cce-project".to_string());
631     }
632     if let Some(mime) = path
633         .extension()
634         .and_then(|e| e.to_str())
635         .and_then(|ext| mime_for_extension(&ext.to_ascii_lowercase()))
636     {
637         return Some(mime.to_string());
638     }
639     let output = std::process::Command::new("xdg-mime")
640         .args(&["query", "filetype"])
641         .arg(path)
642         .output()
643         .ok()?;
644     if output.status.success() {
645         let mime = String::from_utf8_lossy(&output.stdout).trim().to_string();
646         if !mime.is_empty() {
647             return Some(mime);
648         }
649     }
650     None
651 }
652 
653 pub fn load_kdl_associations() -> Option<std::collections::HashMap<String, String>> {
654     load_kdl_associations_in(&cce_config_dir()?)
655 }
656 
657 /// `mime.kdl` under `config_dir`; see `last_dir_file_in` for why it is a parameter.
658 pub fn load_kdl_associations_in(config_dir: &Path) -> Option<std::collections::HashMap<String, String>> {
659     let path = config_dir.join("mime.kdl");
660     if !path.exists() {
661         return None;
662     }
663     let content = fs::read_to_string(path).ok()?;
664     let doc: kdl::KdlDocument = content.parse().ok()?;
665     let mut map = std::collections::HashMap::new();
666 
667     if let Some(associations_node) = doc.get("associations") {
668         for child in associations_node.iter_children() {
669             if child.name().value() == "association" {
670                 let mime = child.get(0)
671                     .or_else(|| child.get("mime"))
672                     .and_then(|val| match val {
673                         kdl::KdlValue::String(s) => Some(s.clone()),
674                         _ => None,
675                     });
676                 let exec = child.get(1)
677                     .or_else(|| child.get("exec"))
678                     .and_then(|val| match val {
679                         kdl::KdlValue::String(s) => Some(s.clone()),
680                         _ => None,
681                     });
682                 if let (Some(m), Some(e)) = (mime, exec) {
683                     map.insert(m, e);
684                 }
685             }
686         }
687     }
688 
689     if map.is_empty() {
690         None
691     } else {
692         Some(map)
693     }
694 }
695 
696 /// Where `.desktop` files live, in XDG precedence order: `$XDG_DATA_HOME`
697 /// (else `~/.local/share`), then `$XDG_DATA_DIRS` (else /usr/local/share and
698 /// /usr/share), each with `applications` appended. It has to match the dirs
699 /// `xdg-mime query default` searched, or a handler it names is not found
700 /// here. Unset or relative values are skipped, per the spec.
701 fn applications_dirs() -> Vec<PathBuf> {
702     let var = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty());
703     let data_home = var("XDG_DATA_HOME")
704         .map(PathBuf::from)
705         .filter(|p| p.is_absolute())
706         .or_else(|| Some(PathBuf::from(var("HOME")?).join(".local/share")));
707     let data_dirs: Vec<PathBuf> = var("XDG_DATA_DIRS")
708         .map(|v| std::env::split_paths(&v).filter(|p| p.is_absolute()).collect())
709         .filter(|dirs: &Vec<PathBuf>| !dirs.is_empty())
710         .unwrap_or_else(|| vec!["/usr/local/share".into(), "/usr/share".into()]);
711     data_home.into_iter().chain(data_dirs).map(|d| d.join("applications")).collect()
712 }
713 
714 pub fn get_default_application(mime: &str) -> Option<(String, String)> {
715     // 1. Check KDL configuration file override
716     if let Some(associations) = load_kdl_associations() {
717         if let Some(custom_exec) = associations.get(mime) {
718             return Some((custom_exec.clone(), custom_exec.clone()));
719         }
720     }
721 
722     // 2. Query default handler desktop file name
723     let output = std::process::Command::new("xdg-mime")
724         .args(&["query", "default", mime])
725         .output()
726         .ok()?;
727     if !output.status.success() {
728         return None;
729     }
730     let desktop_filename = String::from_utf8_lossy(&output.stdout).trim().to_string();
731     if desktop_filename.is_empty() {
732         return None;
733     }
734 
735     let mut desktop_path = None;
736     for dir in applications_dirs() {
737         let path = dir.join(&desktop_filename);
738         if path.exists() {
739             desktop_path = Some(path);
740             break;
741         }
742     }
743 
744     let path = desktop_path?;
745     let content = fs::read_to_string(path).ok()?;
746 
747     let mut name = None;
748     let mut exec = None;
749 
750     for line in content.lines() {
751         let line = line.trim();
752         if line.starts_with("Name=") && name.is_none() {
753             name = Some(line["Name=".len()..].trim().to_string());
754         } else if line.starts_with("Exec=") && exec.is_none() {
755             let mut cmd = line["Exec=".len()..].trim().to_string();
756             // Strip standard desktop entry field codes (placeholders)
757             let placeholders = ["%f", "%F", "%u", "%U", "%d", "%D", "%n", "%N", "%i", "%c", "%k", "%v"];
758             for placeholder in &placeholders {
759                 cmd = cmd.replace(placeholder, "");
760             }
761             exec = Some(cmd.trim().to_string());
762         }
763     }
764 
765     match (name, exec) {
766         (Some(n), Some(e)) => Some((n, e)),
767         (None, Some(e)) => {
768             let n_fallback = desktop_filename.strip_suffix(".desktop").unwrap_or(&desktop_filename).to_string();
769             Some((n_fallback, e))
770         }
771         _ => None,
772     }
773 }
774 
775 /// Parse a command string, resolve a bare program name against `~/.local/bin`,
776 /// append `path` as the final argument, and spawn it detached.
777 /// Returns `true` if a process was spawned.
778 pub fn spawn_command_for_path(cmd: &str, path: &Path) -> bool {
779     let parts: Vec<&str> = cmd.split_whitespace().collect();
780     if parts.is_empty() {
781         return false;
782     }
783     let program = parts[0];
784     let mut program_path = PathBuf::from(program);
785     if !program_path.is_absolute() && !program.contains('/') {
786         if let Ok(home) = std::env::var("HOME") {
787             let local_bin = PathBuf::from(home).join(".local").join("bin").join(program);
788             if local_bin.exists() {
789                 program_path = local_bin;
790             }
791         }
792     }
793     let mut command = std::process::Command::new(program_path);
794     for arg in &parts[1..] {
795         command.arg(arg);
796     }
797     command.arg(path);
798     spawn_detached(command).is_ok()
799 }
800 
801 /// Spawn `cmd` and reap it on a background thread, so the child never lingers
802 /// as a zombie once it exits. This was `cce_ui::process::spawn_detached` until
803 /// the toolkit dropped that module (cce-ui 4e94236) as caller-less — it had two callers here.
804 fn spawn_detached(mut cmd: std::process::Command) -> std::io::Result<()> {
805     let mut child = cmd.spawn()?;
806     std::thread::spawn(move || {
807         let _ = child.wait();
808     });
809     Ok(())
810 }
811 
812 pub fn open_file(path: &Path) {
813     let opened = get_mime_type(path)
814         .and_then(|mime| get_default_application(&mime))
815         .map(|(_, cmd)| !cmd.is_empty() && spawn_command_for_path(&cmd, path))
816         .unwrap_or(false);
817 
818     if !opened {
819         let mut command = std::process::Command::new("xdg-open");
820         command.arg(path);
821         let _ = spawn_detached(command);
822     }
823 }
824 
825 #[cfg(test)]
826 mod tests {
827     use super::*;
828 
829     #[test]
830     fn test_rename_no_clobber() {
831         let dir = std::env::temp_dir().join(format!(
832             "cce_test_rename_{}",
833             std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
834         ));
835         fs::create_dir_all(&dir).unwrap();
836         let (a, b, c) = (dir.join("a.txt"), dir.join("b.txt"), dir.join("c.txt"));
837         fs::write(&a, "a").unwrap();
838         fs::write(&b, "b").unwrap();
839 
840         // An existing target is refused and left untouched.
841         assert!(rename_no_clobber(&a, &b).is_err());
842         assert_eq!(fs::read_to_string(&b).unwrap(), "b");
843 
844         rename_no_clobber(&a, &c).unwrap();
845         assert!(!a.exists());
846         assert_eq!(fs::read_to_string(&c).unwrap(), "a");
847 
848         let _ = fs::remove_dir_all(&dir);
849     }
850 
851     #[test]
852     fn test_create_new_folder_picks_unique_names() {
853         let dir = std::env::temp_dir().join(format!(
854             "cce_test_new_folder_{}",
855             std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
856         ));
857         fs::create_dir_all(&dir).unwrap();
858 
859         let first = create_new_folder(&dir).unwrap();
860         let second = create_new_folder(&dir).unwrap();
861         assert_eq!(first, dir.join("New Folder"));
862         assert_eq!(second, dir.join("New Folder 2"));
863         assert!(first.is_dir() && second.is_dir());
864 
865         let _ = fs::remove_dir_all(&dir);
866     }
867 
868     #[test]
869     fn test_kdl() {
870         let config_dir = std::env::temp_dir().join(format!(
871             "cce_test_kdl_{}",
872             std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
873         ));
874         fs::create_dir_all(&config_dir).unwrap();
875         fs::write(
876             config_dir.join("mime.kdl"),
877             "associations { association \"text/plain\" \"cce-text-editor\" }",
878         )
879         .unwrap();
880 
881         let assoc = load_kdl_associations_in(&config_dir);
882         let _ = fs::remove_dir_all(&config_dir);
883 
884         assert_eq!(
885             assoc.and_then(|m| m.get("text/plain").cloned()).as_deref(),
886             Some("cce-text-editor")
887         );
888     }
889 
890     #[test]
891     fn test_model_mime_by_extension() {
892         let dir = std::env::temp_dir().join(format!(
893             "cce_test_model_mime_{}",
894             std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
895         ));
896         fs::create_dir_all(&dir).unwrap();
897         // Content that content-sniffing reads as the wrong type, so a pass
898         // shows the extension decided it.
899         let cases: [(&str, &[u8], &str); 7] = [
900             ("part.stl", &[0u8; 84], "model/stl"),
901             ("PART.STL", &[0u8; 84], "model/stl"),
902             ("mesh.obj", b"v 0 0 0\n", "model/obj"),
903             ("scene.gltf", b"{\"asset\":{\"version\":\"2.0\"}}", "model/gltf+json"),
904             ("scene.Glb", b"glTF\x02\0\0\0", "model/gltf-binary"),
905             ("scan.ply", b"ply\nformat binary_little_endian 1.0\n\0\xff", "model/x-ply"),
906             ("config.KDL", b"node 1\n", "application/x-kdl"),
907         ];
908         let got: Vec<_> = cases
909             .iter()
910             .map(|(name, bytes, _)| {
911                 let path = dir.join(name);
912                 fs::write(&path, bytes).unwrap();
913                 get_mime_type(&path)
914             })
915             .collect();
916         let _ = fs::remove_dir_all(&dir);
917 
918         for ((name, _, want), got) in cases.iter().zip(got) {
919             assert_eq!(got.as_deref(), Some(*want), "{name}");
920         }
921     }
922 }
923 
924 
925