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