Skip to main content

nexus_core/app/
images.rs

1// Casts here are on bounded values: token counts, byte sizes, and
2// selection indices — never on unbounded input. JSON-derived indices in
3// provider/tools go through try_from instead.
4#![allow(
5    clippy::cast_possible_truncation,
6    clippy::cast_possible_wrap,
7    clippy::cast_precision_loss,
8    clippy::cast_sign_loss
9)]
10use anyhow::{Context, Result};
11
12use super::{App, ImageMeta};
13
14/// Raster formats the app can render inline (plus `svg`, which the system
15/// viewer opens even though the half-block renderer can't draw it).
16pub fn is_image_name(name: &str) -> bool {
17    let ext = std::path::Path::new(name)
18        .extension()
19        .map(|e| e.to_string_lossy().to_ascii_lowercase())
20        .unwrap_or_default();
21    matches!(
22        ext.as_str(),
23        "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff" | "tif" | "avif" | "svg"
24    )
25}
26
27impl App {
28    /// Read the space's images dir and populate `images_cache` (name, size,
29    /// modified) with **image files only** — the Files tab owns everything
30    /// else. A missing or empty dir produces an empty cache, never an error.
31    pub fn refresh_images(&mut self) {
32        let dir = self.space.files_dir(&self.active_space.name);
33        let _ = std::fs::create_dir_all(&dir);
34        self.images_cache = match std::fs::read_dir(&dir) {
35            Err(_) => Vec::new(),
36            Ok(entries) => entries
37                .flatten()
38                .filter(|e| e.path().is_file())
39                .filter(|e| is_image_name(&e.file_name().to_string_lossy()))
40                .filter_map(|e| {
41                    let meta = e.metadata().ok()?;
42                    let modified = meta
43                        .modified()
44                        .ok()
45                        .and_then(|t| {
46                            chrono::DateTime::from_timestamp(
47                                t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64,
48                                0,
49                            )
50                        })
51                        .map(|dt| dt.to_rfc3339())
52                        .unwrap_or_default();
53                    Some(ImageMeta {
54                        name: e.file_name().to_string_lossy().to_string(),
55                        size: meta.len(),
56                        modified,
57                    })
58                })
59                .collect(),
60        };
61        self.images_cache.sort_by(|a, b| a.name.cmp(&b.name));
62    }
63
64    /// The popup's confirm-delete lives in the view; this is the disk half:
65    /// remove the file (if any) and refresh the cache. Returns whether a row
66    /// existed. `images_mode` reset is the view's job.
67    pub fn delete_image_file(&mut self, name: &str) -> Result<bool> {
68        let dir = self.space.files_dir(&self.active_space.name);
69        let path = dir.join(name);
70        if path.exists() {
71            std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
72            self.push_status(format!("removed {name}"));
73            self.refresh_images();
74            Ok(true)
75        } else {
76            Ok(false)
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::is_image_name;
84
85    #[test]
86    fn image_names_match_only_raster_formats() {
87        assert!(is_image_name("paste.png"));
88        assert!(is_image_name("photo.JPG"));
89        assert!(is_image_name("a.b.webp"));
90        assert!(is_image_name("diagram.svg"));
91        assert!(!is_image_name("notes.md"));
92        assert!(!is_image_name("script.py"));
93        assert!(!is_image_name("archive.tar.gz"));
94        assert!(!is_image_name("README"));
95        assert!(!is_image_name(".hidden"));
96    }
97}