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