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

src/services/fs.rs (27.9K)

  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     ReadPreview(PathBuf),
 36     /// Move to the freedesktop trash (the default Delete).
 37     TrashPath(PathBuf),
 38     /// Unrecoverable delete — the trash's own rows, and "Delete Permanently".
 39     DeletePath(PathBuf, bool), // (path, is_dir)
 40     /// Restore a trashed item (a path under Trash/files) to its origin.
 41     RestorePath(PathBuf),
 42     EmptyTrash,
 43     /// Walk a whole subtree for the Space view. The flag is the caller's
 44     /// cancel token — raising it abandons a scan whose answer is no longer
 45     /// wanted (see `SpaceState::begin_scan`).
 46     ScanTree(PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>),
 47     ReadLastDir,
 48     SaveLastDir(PathBuf),
 49 }
 50 
 51 pub struct FsService {
 52     pub sender: mpsc::Sender<FsRequest>,
 53 }
 54 
 55 impl FsService {
 56     pub fn new(app_sender: calloop::channel::Sender<crate::Message>) -> Self {
 57         let (tx, mut rx) = mpsc::channel::<FsRequest>(100);
 58 
 59         tokio::spawn(async move {
 60             while let Some(req) = rx.recv().await {
 61                 let app_sender = app_sender.clone();
 62                 match req {
 63                     FsRequest::ReadDirectory(path) => {
 64                         tokio::spawn(async move {
 65                             let entries = read_directory_internal(&path);
 66                             let _ = app_sender.send(crate::Message::Browse(
 67                                 crate::pages::browse::BrowseMessage::DirectoryLoaded(path, entries),
 68                             ));
 69                         });
 70                     }
 71                     FsRequest::RefreshDirectory(path) => {
 72                         tokio::spawn(async move {
 73                             let entries = read_directory_internal(&path);
 74                             let _ = app_sender.send(crate::Message::Browse(
 75                                 crate::pages::browse::BrowseMessage::DirectoryRefreshed(path, entries),
 76                             ));
 77                         });
 78                     }
 79                     FsRequest::ReadPreview(path) => {
 80                         tokio::spawn(async move {
 81                             let preview_data = load_preview_data_internal(&path);
 82                             let _ = app_sender.send(crate::Message::Preview(
 83                                 crate::pages::preview::PreviewMessage::PreviewLoaded { path, data: preview_data },
 84                             ));
 85                         });
 86                     }
 87                     FsRequest::TrashPath(path) => {
 88                         tokio::spawn(async move {
 89                             let result = super::trash::move_to_trash(&path).map_err(|e| e.to_string());
 90                             let _ = app_sender.send(crate::Message::Browse(
 91                                 crate::pages::browse::BrowseMessage::Deleted(path, result),
 92                             ));
 93                         });
 94                     }
 95                     FsRequest::DeletePath(path, is_dir) => {
 96                         tokio::spawn(async move {
 97                             let res = if is_dir {
 98                                 fs::remove_dir_all(&path)
 99                             } else {
100                                 fs::remove_file(&path)
101                             };
102                             let result = res.map_err(|e| e.to_string());
103                             let _ = app_sender.send(crate::Message::Browse(
104                                 crate::pages::browse::BrowseMessage::Deleted(path, result),
105                             ));
106                         });
107                     }
108                     FsRequest::RestorePath(path) => {
109                         tokio::spawn(async move {
110                             // A restored row leaves the trash listing exactly like a
111                             // deleted row leaves its directory — same message.
112                             let result = super::trash::restore(&path).map(|_| ()).map_err(|e| e.to_string());
113                             let _ = app_sender.send(crate::Message::Browse(
114                                 crate::pages::browse::BrowseMessage::Deleted(path, result),
115                             ));
116                         });
117                     }
118                     FsRequest::EmptyTrash => {
119                         tokio::spawn(async move {
120                             let result = super::trash::empty().map(|_| ()).map_err(|e| e.to_string());
121                             let _ = app_sender.send(crate::Message::Browse(
122                                 crate::pages::browse::BrowseMessage::TrashEmptied(result),
123                             ));
124                         });
125                     }
126                     FsRequest::ScanTree(path, cancel) => {
127                         // Minutes of blocking recursion on a large tree, so
128                         // this goes to the blocking pool rather than tying up
129                         // an async worker the way the short reads above can.
130                         tokio::task::spawn_blocking(move || {
131                             let progress_sender = app_sender.clone();
132                             let progress_dir = path.clone();
133                             let mut on_progress = |files, bytes| {
134                                 let _ = progress_sender.send(crate::Message::Space(
135                                     crate::pages::space::SpaceMessage::Progress {
136                                         dir: progress_dir.clone(),
137                                         files,
138                                         bytes,
139                                     },
140                                 ));
141                             };
142                             let result = super::scan::scan(&path, &cancel, &mut on_progress);
143                             match result {
144                                 Some(res) if !res.cancelled => {
145                                     let _ = app_sender.send(crate::Message::Space(
146                                         crate::pages::space::SpaceMessage::Scanned {
147                                             dir: path,
148                                             tree: res.tree,
149                                         },
150                                     ));
151                                 }
152                                 // A cancelled scan's tree is partial. Drop it
153                                 // silently — the scan that superseded it is
154                                 // already on its way with the real one.
155                                 Some(_) => {}
156                                 None => {
157                                     let _ = app_sender.send(crate::Message::Space(
158                                         crate::pages::space::SpaceMessage::Failed(format!(
159                                             "Cannot scan {}",
160                                             path.display()
161                                         )),
162                                     ));
163                                 }
164                             }
165                         });
166                     }
167                     FsRequest::ReadLastDir => {
168                         tokio::spawn(async move {
169                             let last_dir = read_last_dir_internal();
170                             let _ = app_sender.send(crate::Message::Browse(
171                                 crate::pages::browse::BrowseMessage::LastDirLoaded(last_dir),
172                             ));
173                         });
174                     }
175                     FsRequest::SaveLastDir(dir) => {
176                         tokio::spawn(async move {
177                             save_last_dir_internal(&dir);
178                         });
179                     }
180                 }
181             }
182         });
183 
184         Self { sender: tx }
185     }
186 
187     pub fn send(&self, req: FsRequest) {
188         let sender = self.sender.clone();
189         tokio::spawn(async move {
190             let _ = sender.send(req).await;
191         });
192     }
193 }
194 
195 // ── Internal Helper Functions ───────────────────────────────────────
196 
197 pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
198     let in_trash = super::trash::is_trash_files_dir(path);
199     let mut entries: Vec<DirEntry> = match fs::read_dir(path) {
200         Ok(rd) => rd
201             .filter_map(|e| e.ok())
202             .filter_map(|e| {
203                 let meta = e.metadata().ok()?;
204                 let name = e.file_name().to_string_lossy().to_string();
205                 let is_dir = meta.is_dir();
206                 let size = meta.len();
207                 let permissions = meta.permissions().mode();
208                 let modified = meta
209                     .modified()
210                     .ok()
211                     .and_then(|t| {
212                         let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
213                         let datetime =
214                             chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
215                         Some(datetime.format("%Y-%m-%d %H:%M").to_string())
216                     })
217                     .unwrap_or_else(|| "—".to_string());
218                 let origin = if in_trash {
219                     super::trash::origin_of(&e.path()).map(|p| p.display().to_string())
220                 } else {
221                     None
222                 };
223                 Some(DirEntry {
224                     name,
225                     path: e.path(),
226                     is_dir,
227                     size,
228                     permissions,
229                     modified,
230                     origin,
231                 })
232             })
233             .collect(),
234         Err(_) => return Vec::new(),
235     };
236 
237     // Sort: directories first, then files; alphabetically within each group
238     entries.sort_by(|a, b| {
239         match (a.is_dir, b.is_dir) {
240             (true, false) => std::cmp::Ordering::Less,
241             (false, true) => std::cmp::Ordering::Greater,
242             _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
243         }
244     });
245 
246     entries
247 }
248 
249 fn load_image_preview(path: &Path) -> Option<ImagePreviewData> {
250     let img = image::open(path).ok()?;
251     let (orig_w, orig_h) = img.dimensions();
252     if orig_w == 0 || orig_h == 0 {
253         return None;
254     }
255     // GPU-textured previews: 512px is crisp at pane size for one live image
256     // (1 MB RGBA) while keeping the Triangle resize quick per selection.
257     let max_dim = 512.0;
258     let ratio = (max_dim / orig_w as f32).min(max_dim / orig_h as f32).min(1.0);
259     let target_w = (orig_w as f32 * ratio).round() as u32;
260     let target_h = (orig_h as f32 * ratio).round() as u32;
261     if target_w == 0 || target_h == 0 {
262         return None;
263     }
264     
265     let resized = if target_w == orig_w && target_h == orig_h {
266         img
267     } else {
268         img.resize(target_w, target_h, image::imageops::FilterType::Triangle)
269     };
270     
271     let pixels = resized.to_rgba8().into_raw();
272 
273     Some(ImagePreviewData {
274         width: target_w,
275         height: target_h,
276         pixels,
277     })
278 }
279 
280 /// Vector previews rasterize at the same 512px pane budget via cce-ui's shared
281 /// resvg path (fontdb-backed, thread-safe — this runs on the FsService thread)
282 /// and hand back the same straight-RGBA ImagePreviewData as raster files.
283 fn load_svg_preview(path: &Path) -> Option<ImagePreviewData> {
284     let data = fs::read(path).ok()?;
285     let (pixels, width, height) = cce_ui::rasterize_svg(&data, 512)?;
286     Some(ImagePreviewData { width, height, pixels })
287 }
288 
289 /// Path-traced thumbnail for a cce-designer project directory, cached as a
290 /// PNG under `~/.cache/cce/thumbnails/` keyed on the project path and its
291 /// `state.json` mtime — the GPU renders only on cache misses. Rendering is
292 /// delegated to `cce-designer --thumbnail` (the geometry, OpenCL nodes
293 /// included, lives there); returns None if the binary is missing or fails,
294 /// and the caller falls back to the plain directory listing.
295 fn load_project_thumbnail(path: &Path) -> Option<ImagePreviewData> {
296     use std::hash::{Hash, Hasher};
297 
298     let state_file = path.join("state.json");
299     let mtime = fs::metadata(&state_file)
300         .ok()?
301         .modified()
302         .ok()?
303         .duration_since(std::time::UNIX_EPOCH)
304         .ok()?
305         .as_secs();
306     let mut hasher = std::collections::hash_map::DefaultHasher::new();
307     path.to_string_lossy().hash(&mut hasher);
308     mtime.hash(&mut hasher);
309 
310     let cache_dir = std::env::var_os("XDG_CACHE_HOME")
311         .map(PathBuf::from)
312         .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?
313         .join("cce")
314         .join("thumbnails");
315     fs::create_dir_all(&cache_dir).ok()?;
316     let cached = cache_dir.join(format!("{:016x}.png", hasher.finish()));
317 
318     if !cached.exists() {
319         // Same ~/.local/bin-first resolution as spawn_command_for_path.
320         let mut program = PathBuf::from("cce-designer");
321         if let Ok(home) = std::env::var("HOME") {
322             let local_bin = PathBuf::from(home).join(".local").join("bin").join("cce-designer");
323             if local_bin.exists() {
324                 program = local_bin;
325             }
326         }
327         let output = std::process::Command::new(program)
328             .arg("--thumbnail")
329             .arg(path)
330             .arg(&cached)
331             .args(["--size", "256"])
332             .output()
333             .ok()?;
334         if !output.status.success() {
335             return None;
336         }
337     }
338     load_image_preview(&cached)
339 }
340 
341 fn load_preview_data_internal(path: &Path) -> PreviewData {
342     let meta = fs::symlink_metadata(path).ok();
343     let name = path
344         .file_name()
345         .map(|n| n.to_string_lossy().to_string())
346         .unwrap_or_else(|| path.to_string_lossy().to_string());
347 
348     let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
349     let size = meta.as_ref().map(|m| format_size(m.len())).unwrap_or_else(|| "—".to_string());
350     let permissions = meta
351         .as_ref()
352         .map(|m| format_permissions(m.permissions().mode()))
353         .unwrap_or_else(|| "—".to_string());
354     let modified = meta
355         .as_ref()
356         .and_then(|m| m.modified().ok())
357         .and_then(|t| {
358             let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
359             let datetime = chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
360             Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
361         })
362         .unwrap_or_else(|| "—".to_string());
363 
364     let mut file_type = infer_file_type(&name, is_dir);
365     if !is_dir {
366         if let Some(mime) = get_mime_type(path) {
367             if let Some((app_name, _)) = get_default_application(&mime) {
368                 file_type = format!("{} ({}) [Open with: {}]", file_type, mime, app_name);
369             } else {
370                 file_type = format!("{} ({})", file_type, mime);
371             }
372         }
373     }
374 
375 
376     // Check symlink target
377     let target = if meta.as_ref().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
378         fs::read_link(path)
379             .map(|t| t.to_string_lossy().to_string())
380             .unwrap_or_default()
381     } else {
382         String::new()
383     };
384 
385     let mut content_preview = None;
386     let mut image_preview = None;
387 
388     if is_dir {
389         // cce-designer project: show a path-traced thumbnail of its geometry.
390         if path.join("state.json").exists() {
391             image_preview = load_project_thumbnail(path);
392         }
393         if image_preview.is_none() {
394             if let Ok(entries) = fs::read_dir(path) {
395                 let mut names = Vec::new();
396                 for entry in entries.flatten().take(100) {
397                     let name = entry.file_name().to_string_lossy().to_string();
398                     let is_sub_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
399                     let icon = if is_sub_dir { "📁" } else { "📄" };
400                     names.push(format!("{} {}", icon, name));
401                 }
402                 if names.is_empty() {
403                     content_preview = Some("[Empty directory]".to_string())
404                 } else {
405                     content_preview = Some(names.join("\n"))
406                 }
407             }
408         }
409     } else {
410         let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
411         let is_img_ext = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico");
412         
413         if is_img_ext {
414             image_preview = load_image_preview(path);
415         } else if matches!(ext.as_str(), "svg" | "svgz") {
416             // On parse failure this stays None and the text fallback below
417             // shows the SVG source.
418             image_preview = load_svg_preview(path);
419         }
420 
421         if image_preview.is_none() {
422             if let Ok(mut file) = fs::File::open(path) {
423                 use std::io::Read;
424                 let mut buf = vec![0u8; 65536];
425                 if let Ok(n) = file.read(&mut buf) {
426                     buf.truncate(n);
427                     let is_text = match std::str::from_utf8(&buf) {
428                         Ok(_) => true,
429                         Err(err) => err.error_len().is_none() && err.valid_up_to() > 0,
430                     };
431                     if is_text {
432                         let utf8_str = String::from_utf8_lossy(&buf).into_owned();
433                         content_preview = Some(utf8_str);
434                     } else {
435                         content_preview = Some("[Binary file content]".to_string());
436                     }
437                 }
438             }
439         }
440     }
441 
442     PreviewData {
443         name,
444         is_dir,
445         size,
446         permissions,
447         modified,
448         file_type,
449         target,
450         content_preview,
451         image_preview,
452     }
453 }
454 
455 fn infer_file_type(name: &str, is_dir: bool) -> String {
456     if is_dir {
457         return "Directory".to_string();
458     }
459     match name.rsplit('.').next() {
460         Some("rs") => "Rust source".to_string(),
461         Some("toml") => "TOML config".to_string(),
462         Some("json") => "JSON data".to_string(),
463         Some("yaml") | Some("yml") => "YAML config".to_string(),
464         Some("png") => "PNG image".to_string(),
465         Some("jpg") | Some("jpeg") => "JPEG image".to_string(),
466         Some("svg") => "SVG image".to_string(),
467         Some("gif") => "GIF image".to_string(),
468         Some("mp3") => "MP3 audio".to_string(),
469         Some("wav") => "WAV audio".to_string(),
470         Some("flac") => "FLAC audio".to_string(),
471         Some("mp4") => "MP4 video".to_string(),
472         Some("mkv") => "Matroska video".to_string(),
473         Some("zip") => "ZIP archive".to_string(),
474         Some("tar") => "Tar archive".to_string(),
475         Some("gz") => "Gzip archive".to_string(),
476         Some("py") => "Python source".to_string(),
477         Some("sh") | Some("bash") => "Shell script".to_string(),
478         Some("md") => "Markdown".to_string(),
479         Some("txt") => "Plain text".to_string(),
480         Some("c") | Some("h") => "C source".to_string(),
481         Some("cpp") | Some("hpp") | Some("cc") => "C++ source".to_string(),
482         Some("hs") => "Haskell source".to_string(),
483         Some("exe") => "Windows executable".to_string(),
484         Some("pdf") => "PDF document".to_string(),
485         _ => "File".to_string(),
486     }
487 }
488 
489 /// Base config directory for cce: `$XDG_CONFIG_HOME/cce`, else `$HOME/.config/cce`.
490 /// Returns `None` only when neither variable is usable.
491 pub fn cce_config_dir() -> Option<PathBuf> {
492     Some(cce_ui::config::cce_config_dir())
493 }
494 
495 fn get_last_dir_file_path() -> Option<PathBuf> {
496     let dir = cce_config_dir()?.join("cce-files");
497     let _ = fs::create_dir_all(&dir);
498     Some(dir.join("cce-files-last-dir.txt"))
499 }
500 
501 pub fn read_last_dir_internal() -> Option<PathBuf> {
502     let path = get_last_dir_file_path()?;
503     if path.exists() {
504         let content = fs::read_to_string(path).ok()?;
505         let trimmed = content.trim();
506         if !trimmed.is_empty() {
507             let pb = PathBuf::from(trimmed);
508             if pb.exists() && pb.is_dir() {
509                 return Some(pb);
510             }
511         }
512     }
513     None
514 }
515 
516 pub fn save_last_dir_internal(dir: &Path) {
517     if let Some(path) = get_last_dir_file_path() {
518         let _ = fs::write(path, dir.to_string_lossy().as_bytes());
519     }
520 }
521 
522 pub fn get_mime_type(path: &Path) -> Option<String> {
523     // Matches `browse::is_project_dir`: a designer project is a dir holding a state.json.
524     if path.is_dir() && path.join("state.json").exists() {
525         return Some("application/x-cce-project".to_string());
526     }
527     if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
528         if ext.eq_ignore_ascii_case("kdl") {
529             return Some("application/x-kdl".to_string());
530         }
531     }
532     let output = std::process::Command::new("xdg-mime")
533         .args(&["query", "filetype"])
534         .arg(path)
535         .output()
536         .ok()?;
537     if output.status.success() {
538         let mime = String::from_utf8_lossy(&output.stdout).trim().to_string();
539         if !mime.is_empty() {
540             return Some(mime);
541         }
542     }
543     None
544 }
545 
546 pub fn load_kdl_associations() -> Option<std::collections::HashMap<String, String>> {
547     let path = cce_config_dir()?.join("mime.kdl");
548     if !path.exists() {
549         return None;
550     }
551     let content = fs::read_to_string(path).ok()?;
552     let doc: kdl::KdlDocument = content.parse().ok()?;
553     let mut map = std::collections::HashMap::new();
554 
555     if let Some(associations_node) = doc.get("associations") {
556         for child in associations_node.iter_children() {
557             if child.name().value() == "association" {
558                 let mime = child.get(0)
559                     .or_else(|| child.get("mime"))
560                     .and_then(|val| match val {
561                         kdl::KdlValue::String(s) => Some(s.clone()),
562                         _ => None,
563                     });
564                 let exec = child.get(1)
565                     .or_else(|| child.get("exec"))
566                     .and_then(|val| match val {
567                         kdl::KdlValue::String(s) => Some(s.clone()),
568                         _ => None,
569                     });
570                 if let (Some(m), Some(e)) = (mime, exec) {
571                     map.insert(m, e);
572                 }
573             }
574         }
575     }
576 
577     if map.is_empty() {
578         None
579     } else {
580         Some(map)
581     }
582 }
583 
584 pub fn get_default_application(mime: &str) -> Option<(String, String)> {
585     // 1. Check KDL configuration file override
586     if let Some(associations) = load_kdl_associations() {
587         if let Some(custom_exec) = associations.get(mime) {
588             return Some((custom_exec.clone(), custom_exec.clone()));
589         }
590     }
591 
592     // 2. Query default handler desktop file name
593     let output = std::process::Command::new("xdg-mime")
594         .args(&["query", "default", mime])
595         .output()
596         .ok()?;
597     if !output.status.success() {
598         return None;
599     }
600     let desktop_filename = String::from_utf8_lossy(&output.stdout).trim().to_string();
601     if desktop_filename.is_empty() {
602         return None;
603     }
604 
605     // Search for .desktop file in common directories. Skip the user-local path
606     // entirely when HOME is unset rather than emitting a bogus relative path.
607     let mut search_paths = vec![
608         PathBuf::from("/usr/share/applications"),
609         PathBuf::from("/usr/local/share/applications"),
610     ];
611     if let Ok(home) = std::env::var("HOME") {
612         search_paths.insert(0, PathBuf::from(home).join(".local/share/applications"));
613     }
614 
615     let mut desktop_path = None;
616     for dir in search_paths {
617         let path = dir.join(&desktop_filename);
618         if path.exists() {
619             desktop_path = Some(path);
620             break;
621         }
622     }
623 
624     let path = desktop_path?;
625     let content = fs::read_to_string(path).ok()?;
626 
627     let mut name = None;
628     let mut exec = None;
629 
630     for line in content.lines() {
631         let line = line.trim();
632         if line.starts_with("Name=") && name.is_none() {
633             name = Some(line["Name=".len()..].trim().to_string());
634         } else if line.starts_with("Exec=") && exec.is_none() {
635             let mut cmd = line["Exec=".len()..].trim().to_string();
636             // Strip standard desktop entry field codes (placeholders)
637             let placeholders = ["%f", "%F", "%u", "%U", "%d", "%D", "%n", "%N", "%i", "%c", "%k", "%v"];
638             for placeholder in &placeholders {
639                 cmd = cmd.replace(placeholder, "");
640             }
641             exec = Some(cmd.trim().to_string());
642         }
643     }
644 
645     match (name, exec) {
646         (Some(n), Some(e)) => Some((n, e)),
647         (None, Some(e)) => {
648             let n_fallback = desktop_filename.strip_suffix(".desktop").unwrap_or(&desktop_filename).to_string();
649             Some((n_fallback, e))
650         }
651         _ => None,
652     }
653 }
654 
655 /// Parse a command string, resolve a bare program name against `~/.local/bin`,
656 /// append `path` as the final argument, and spawn it detached.
657 /// Returns `true` if a process was spawned.
658 pub fn spawn_command_for_path(cmd: &str, path: &Path) -> bool {
659     let parts: Vec<&str> = cmd.split_whitespace().collect();
660     if parts.is_empty() {
661         return false;
662     }
663     let program = parts[0];
664     let mut program_path = PathBuf::from(program);
665     if !program_path.is_absolute() && !program.contains('/') {
666         if let Ok(home) = std::env::var("HOME") {
667             let local_bin = PathBuf::from(home).join(".local").join("bin").join(program);
668             if local_bin.exists() {
669                 program_path = local_bin;
670             }
671         }
672     }
673     let mut command = std::process::Command::new(program_path);
674     for arg in &parts[1..] {
675         command.arg(arg);
676     }
677     command.arg(path);
678     cce_ui::process::spawn_detached(command).is_ok()
679 }
680 
681 pub fn open_file(path: &Path) {
682     let opened = get_mime_type(path)
683         .and_then(|mime| get_default_application(&mime))
684         .map(|(_, cmd)| !cmd.is_empty() && spawn_command_for_path(&cmd, path))
685         .unwrap_or(false);
686 
687     if !opened {
688         let mut command = std::process::Command::new("xdg-open");
689         command.arg(path);
690         let _ = cce_ui::process::spawn_detached(command);
691     }
692 }
693 
694 #[cfg(test)]
695 mod tests {
696     use super::*;
697 
698     #[test]
699     #[serial_test::serial]
700     fn test_kdl() {
701         let unique_dir_name = format!("cce_test_kdl_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
702         let temp_path = std::env::temp_dir().join(unique_dir_name);
703         let config_dir = temp_path.join(".config").join("cce");
704         std::fs::create_dir_all(&config_dir).unwrap();
705         let mime_file = config_dir.join("mime.kdl");
706         std::fs::write(&mime_file, "associations { association \"text/plain\" \"cce-text-editor\" }").unwrap();
707         
708         let old_home = std::env::var("HOME").ok();
709         let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
710         unsafe {
711             std::env::set_var("HOME", &temp_path);
712             std::env::remove_var("XDG_CONFIG_HOME");
713         }
714 
715         let assoc = load_kdl_associations();
716 
717         unsafe {
718             if let Some(ref h) = old_home {
719                 std::env::set_var("HOME", h);
720             } else {
721                 std::env::remove_var("HOME");
722             }
723             if let Some(ref x) = old_xdg {
724                 std::env::set_var("XDG_CONFIG_HOME", x);
725             } else {
726                 std::env::remove_var("XDG_CONFIG_HOME");
727             }
728         }
729 
730         let _ = std::fs::remove_dir_all(&temp_path);
731         
732         println!("Parsed associations: {:?}", assoc);
733         assert!(assoc.is_some());
734     }
735 }
736 
737 
738