Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-file/src/lib.rs

//! **facett-file** — a file-attachment facet for ordning.
//!
//! A **drop zone** + an **attachment list** (name, size, type) with remove
//! buttons. In the ärende profile an attachment is a **handling**; this widget is
//! the upload/list surface, while the legal metadata (direction, diarienummer,
//! sekretess) lives on the case. The widget reports *intents* (files dropped,
//! a file removed) for the host to apply; it does no file I/O itself.
//!
//! Design: `ordning/.nornir/widgets/facett-file.md`. Built in ordning;
//! **destined for facett**.

use egui::{Color32, Sense, Ui, Vec2};
use serde::{Deserialize, Serialize};

/// A file already attached to the case.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRef {
    pub name: String,
    pub size: u64,
    /// MIME type or extension hint.
    pub mime: String,
}

impl FileRef {
    pub fn new(name: impl Into<String>, size: u64, mime: impl Into<String>) -> Self {
        Self { name: name.into(), size, mime: mime.into() }
    }
}

/// An attachment list + drop zone. Drain [`take_added`](Files::take_added) and
/// [`take_removed`](Files::take_removed) each frame to apply changes to the case.
#[derive(Default)]
pub struct Files {
    files: Vec<FileRef>,
    added: Vec<FileRef>,
    removed: Vec<String>,
    read_only: bool,
}

impl Files {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn set_files(&mut self, files: Vec<FileRef>) {
        self.files = files;
    }
    pub fn read_only(mut self, ro: bool) -> Self {
        self.read_only = ro;
        self
    }
    pub fn files(&self) -> &[FileRef] {
        &self.files
    }

    /// Files dropped/added since the last call (the host persists them, then
    /// re-`set_files` with the authoritative list).
    pub fn take_added(&mut self) -> Vec<FileRef> {
        std::mem::take(&mut self.added)
    }
    /// Names removed since the last call.
    pub fn take_removed(&mut self) -> Vec<String> {
        std::mem::take(&mut self.removed)
    }

    /// Attach a file **headlessly** — the robot counterpart of an OS drag-drop.
    /// It lands in `files` AND in the `take_added` outbox, exactly like a drop.
    /// Rejected (returns `false`) when the pane is read-only or the name already
    /// exists, so a robot script cannot fake a live input surface.
    pub fn attach(&mut self, file: FileRef) -> bool {
        if self.read_only || self.files.iter().any(|f| f.name == file.name) {
            return false;
        }
        self.files.push(file.clone());
        self.added.push(file);
        true
    }

    /// Detach a file by name — the robot counterpart of the ✕ button. `false`
    /// when read-only or the name is unknown.
    pub fn detach(&mut self, name: &str) -> bool {
        if self.read_only {
            return false;
        }
        match self.files.iter().position(|f| f.name == name) {
            Some(i) => {
                self.files.remove(i);
                self.removed.push(name.to_string());
                true
            }
            None => false,
        }
    }

    /// The **shared demo fixture** — three attachments spanning the mime icons
    /// the painter branches on (pdf / image / data). Consumed by the robot and
    /// matrix tests (L5: one fixture, not one per test file).
    pub fn local() -> Self {
        let mut f = Self::new();
        f.set_files(vec![
            FileRef::new("ansokan.pdf", 184_320, "application/pdf"),
            FileRef::new("situationsplan.png", 962_144, "image/png"),
            FileRef::new("matningar.csv", 4_096, "text/csv"),
        ]);
        f
    }

    pub fn title(&self) -> &str {
        "Attachments"
    }

    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "count": self.files.len(),
            "total_size": self.files.iter().map(|f| f.size).sum::<u64>(),
            "read_only": self.read_only,
            "files": self.files,
            "pending_added": self.added,
            "pending_removed": self.removed,
        })
    }

    pub fn ui(&mut self, ui: &mut Ui) {
        // Consume any OS file-drops onto the window (egui surfaces them on the ctx).
        if !self.read_only {
            let dropped = ui.ctx().input(|i| i.raw.dropped_files.clone());
            for d in dropped {
                // egui 0.36: a dropped file is a `DroppedFileHandle` (trait object). The
                // name is the path's file name (on the web the path IS the file name);
                // the size/MIME come from the browser `File` on the web, as the old
                // `bytes`/`mime` fields did. Native drops carried no bytes and no MIME
                // before either, so they still report size 0 and an extension MIME.
                let name = d
                    .path()
                    .file_name()
                    .map(|s| s.to_string_lossy().into_owned())
                    .filter(|s| !s.is_empty())
                    .unwrap_or_else(|| "fil".to_string());
                #[cfg(target_arch = "wasm32")]
                let (size, mime) = d
                    .web_file()
                    .map(|f| (f.size() as u64, f.type_()))
                    .unwrap_or((0, String::new()));
                #[cfg(not(target_arch = "wasm32"))]
                let (size, mime) = (0u64, String::new());
                let mime = if mime.is_empty() { ext_of(&name) } else { mime };
                self.added.push(FileRef::new(name, size, mime));
            }
        }

        // The drop zone.
        if !self.read_only {
            let (rect, resp) = ui.allocate_exact_size(
                Vec2::new(ui.available_width().min(360.0), 44.0),
                Sense::hover(),
            );
            let hovering_files = ui.ctx().input(|i| !i.raw.hovered_files.is_empty());
            let color = if hovering_files {
                Color32::from_rgb(247, 198, 0)
            } else {
                Color32::from_rgb(70, 74, 84)
            };
            ui.painter().rect_stroke(
                rect,
                6.0,
                egui::Stroke::new(1.5_f32, color),
                egui::StrokeKind::Inside,
            );
            ui.painter().text(
                rect.center(),
                egui::Align2::CENTER_CENTER,
                if hovering_files { "Släpp filer här" } else { "⬇ Dra filer hit för att bifoga" },
                egui::FontId::proportional(13.0),
                color,
            );
            resp.on_hover_text("Drag files from your file manager onto this zone");
        }

        ui.add_space(6.0);

        // The list.
        if self.files.is_empty() {
            ui.label(egui::RichText::new("Inga bifogade filer").small().color(Color32::GRAY));
        }
        let mut remove: Option<String> = None;
        for f in &self.files {
            ui.horizontal(|ui| {
                ui.label(file_icon(&f.mime));
                ui.label(&f.name);
                ui.label(egui::RichText::new(human_size(f.size)).small().color(Color32::GRAY));
                if !self.read_only && ui.small_button("🗑").clicked() {
                    remove = Some(f.name.clone());
                }
            });
        }
        if let Some(name) = remove {
            self.files.retain(|f| f.name != name);
            self.removed.push(name);
        }
    }
}

fn ext_of(name: &str) -> String {
    name.rsplit('.').next().map(|e| e.to_lowercase()).unwrap_or_default()
}

fn file_icon(mime: &str) -> &'static str {
    let m = mime.to_lowercase();
    if m.contains("pdf") {
        "📄"
    } else if m.contains("image") || ["png", "jpg", "jpeg", "gif", "webp", "tiff"].iter().any(|e| m.ends_with(e)) {
        "🖼"
    } else if m.contains("zip") || m.contains("compress") {
        "🗜"
    } else if ["doc", "docx", "odt", "txt", "md"].iter().any(|e| m.ends_with(e)) {
        "📝"
    } else {
        "📎"
    }
}

fn human_size(bytes: u64) -> String {
    const U: [&str; 4] = ["B", "KB", "MB", "GB"];
    let mut v = bytes as f64;
    let mut i = 0;
    while v >= 1024.0 && i < U.len() - 1 {
        v /= 1024.0;
        i += 1;
    }
    if i == 0 {
        format!("{bytes} B")
    } else {
        format!("{v:.1} {}", U[i])
    }
}

// ── The host-facing `Facet` contract ───────────────────────────────────────────
impl facett_core::Facet for Files {
    fn title(&self) -> &str {
        Files::title(self)
    }
    fn ui(&mut self, ui: &mut Ui) {
        Files::ui(self, ui)
    }
    fn state_json(&self) -> serde_json::Value {
        Files::state_json(self)
    }
    /// The **robot drive seam**: `{"attach":{"name":..,"size":..,"mime":..}}` is
    /// the headless twin of an OS drop; `{"detach":"name"}` the ✕ button.
    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        if let Some(name) = v.get("detach").and_then(|s| s.as_str()) {
            self.detach(name);
            return;
        }
        if let Some(a) = v.get("attach") {
            let (Some(name), Some(mime)) =
                (a.get("name").and_then(|s| s.as_str()), a.get("mime").and_then(|s| s.as_str()))
            else {
                return;
            };
            let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
            self.attach(FileRef::new(name, size, mime));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn human_size_scales() {
        assert_eq!(human_size(512), "512 B");
        assert_eq!(human_size(2048), "2.0 KB");
        assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
    }

    #[test]
    fn ext_and_icon() {
        assert_eq!(ext_of("ansökan.PDF"), "pdf");
        assert_eq!(file_icon("application/pdf"), "📄");
        assert_eq!(file_icon("image/png"), "🖼");
    }

    #[test]
    fn remove_emits_intent_and_state() {
        let mut f = Files::new();
        f.set_files(vec![FileRef::new("a.pdf", 1024, "application/pdf"), FileRef::new("b.png", 2048, "image/png")]);
        // simulate the remove path
        f.files.retain(|x| x.name != "a.pdf");
        f.removed.push("a.pdf".into());
        assert_eq!(f.take_removed(), vec!["a.pdf".to_string()]);
        let s = f.state_json();
        assert_eq!(s["count"], 1);
        assert_eq!(s["total_size"], 2048);
    }
}