Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions src/ui/components/attachments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use egui_extras::image::load_svg_bytes_with_size;
use usvg::Options;

use crate::models::attachment::Attachment;
use crate::utils::sanitize_component;
use crate::utils::{icon_for, sanitize_component};

/// User-selected attachment with original path and sanitized display name.
pub struct AttachmentItem {
Expand Down Expand Up @@ -46,6 +46,7 @@ pub struct AttachmentsModel {
attachments: Vec<AttachmentItem>,
thumbnail_cache: HashMap<PathBuf, egui::TextureHandle>,
thumbnail_failures: HashSet<PathBuf>,
thumbnail_loading: HashSet<PathBuf>,
hashes: HashSet<String>,
editing_index: Option<usize>,
editing_buffer: String,
Expand Down Expand Up @@ -134,7 +135,10 @@ pub fn update(
})
}
AttachmentsMsg::LoadThumbnail(path) => {
cmds.push(AttachmentsCommand::LoadThumbnail { path });
// Avoid queuing duplicate thumbnail loads.
if model.thumbnail_loading.insert(path.clone()) {
cmds.push(AttachmentsCommand::LoadThumbnail { path });
}
None
}
AttachmentsMsg::HashComputed {
Expand All @@ -154,11 +158,13 @@ pub fn update(
})
}
AttachmentsMsg::ThumbnailReady { path, texture } => {
model.thumbnail_cache.insert(path, texture);
model.thumbnail_cache.insert(path.clone(), texture);
model.thumbnail_loading.remove(&path);
None
}
AttachmentsMsg::ThumbnailFailed { path } => {
model.thumbnail_failures.insert(path);
model.thumbnail_failures.insert(path.clone());
model.thumbnail_loading.remove(&path);
None
}
AttachmentsMsg::Remove(index) => {
Expand Down Expand Up @@ -248,16 +254,29 @@ fn render_attachment_list(
};

ui.horizontal(|ui| {
let icon_for_mime = icon_for(&mime, &path);

let _thumb_slot = if let Some(texture) = model.thumbnail_cache.get(&path) {
let size = texture.size_vec2();
let max = 96.0;
let scale = (max / size.x).min(max / size.y).min(1.0);
ui.add(egui::Image::new((texture.id(), size * scale))).rect
} else {
if !model.thumbnail_failures.contains(&path) && is_image(&path) {
msgs.push(AttachmentsMsg::LoadThumbnail(path.clone()));
let thumb_rect = ui.allocate_space(egui::vec2(96.0, 72.0)).1;

if is_image(&path) {
if !model.thumbnail_failures.contains(&path)
&& !model.thumbnail_loading.contains(&path)
{
msgs.push(AttachmentsMsg::LoadThumbnail(path.clone()));
}
// Always show a placeholder while the image is loading or failed.
render_placeholder_icon(ui, thumb_rect, icon_for_mime);
} else {
render_placeholder_icon(ui, thumb_rect, icon_for_mime);
}
ui.allocate_space(egui::vec2(96.0, 72.0)).1

thumb_rect
};

ui.vertical(|ui| {
Expand Down Expand Up @@ -509,6 +528,28 @@ fn format_bytes(bytes: u64) -> String {
}
}

/// Render a centered placeholder icon inside the provided rectangle.
fn render_placeholder_icon(ui: &mut egui::Ui, rect: egui::Rect, icon: &'static str) {
let painter = ui.painter();
let color = ui.visuals().weak_text_color();
let mut font_id = egui::TextStyle::Heading.resolve(ui.style());
font_id.size *= 1.6;
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
icon,
font_id,
color,
);
// Light outline to hint at the thumbnail slot.
painter.rect_stroke(
rect,
4.0,
egui::Stroke::new(1.0, color),
egui::StrokeKind::Inside,
);
}

/// Load and resize an image to a thumbnail-friendly `ColorImage`.
pub(crate) fn load_image_thumbnail(path: &Path) -> Result<egui::ColorImage, String> {
const MAX: u32 = 256;
Expand Down
163 changes: 163 additions & 0 deletions src/utils/file_icons.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Alexander Minges
//! Phosphor file-icon mapping based on MIME type and filename.
//!
//! This helper is intentionally UI-agnostic so both UI components and
//! non-UI logic can choose a representative icon for a file. It favors
//! MIME matches and falls back to extension/name checks for archive
//! composites (e.g., `*.tar.bz2`).

use std::path::Path;

/// Return a Phosphor file icon matching the MIME type or filename.
pub fn icon_for(mime: &str, path: &Path) -> &'static str {
let mime = mime
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
.unwrap_or_default();
let fname = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_ascii_lowercase())
.unwrap_or_default();

if mime.starts_with("image/") {
return match ext.as_str() {
"png" => egui_phosphor::regular::FILE_PNG,
"jpg" | "jpeg" => egui_phosphor::regular::FILE_JPG,
"svg" => egui_phosphor::regular::FILE_SVG,
_ => egui_phosphor::regular::FILE_IMAGE,
};
}
if mime.starts_with("video/") {
return egui_phosphor::regular::FILE_VIDEO;
}
if mime.starts_with("audio/") {
return egui_phosphor::regular::FILE_AUDIO;
}
if mime == "application/pdf" {
return egui_phosphor::regular::FILE_PDF;
}
if mime == "text/csv" || ext == "csv" {
return egui_phosphor::regular::FILE_CSV;
}
if is_archive_mime(&mime, &ext, &fname) {
return egui_phosphor::regular::FILE_ARCHIVE;
}
if mime == "application/msword"
|| mime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|| ext == "doc"
|| ext == "docx"
{
return egui_phosphor::regular::FILE_DOC;
}
if mime == "application/vnd.ms-excel"
|| mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|| ext == "xls"
|| ext == "xlsx"
{
return egui_phosphor::regular::FILE_XLS;
}
if mime == "application/vnd.ms-powerpoint"
|| mime == "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|| ext == "ppt"
|| ext == "pptx"
{
return egui_phosphor::regular::FILE_PPT;
}
if mime == "application/vnd.oasis.opendocument.text" || ext == "odt" {
return egui_phosphor::regular::FILE_TEXT;
}
if mime == "application/vnd.oasis.opendocument.spreadsheet" || ext == "ods" {
return egui_phosphor::regular::FILE_TEXT;
}
if mime == "application/vnd.oasis.opendocument.presentation" || ext == "odp" {
return egui_phosphor::regular::FILE_TEXT;
}
if mime == "application/json" || ext == "json" {
return egui_phosphor::regular::FILE_CODE;
}
if mime == "application/xml" || mime == "text/xml" || ext == "xml" {
return egui_phosphor::regular::FILE_CODE;
}
if ext == "ini" {
return egui_phosphor::regular::FILE_INI;
}
if mime == "text/html" || ext == "html" || ext == "htm" {
return egui_phosphor::regular::FILE_HTML;
}
if mime == "text/markdown" || ext == "md" {
return egui_phosphor::regular::FILE_MD;
}
if mime == "text/css" || ext == "css" {
return egui_phosphor::regular::FILE_CSS;
}
if mime == "application/javascript" || mime == "text/javascript" || ext == "js" {
return egui_phosphor::regular::FILE_JS;
}
if ext == "jsx" {
return egui_phosphor::regular::FILE_JSX;
}
if mime == "application/typescript" || ext == "ts" {
return egui_phosphor::regular::FILE_TS;
}
if ext == "tsx" {
return egui_phosphor::regular::FILE_TSX;
}
if ext == "rs" {
return egui_phosphor::regular::FILE_RS;
}
if ext == "py" {
return egui_phosphor::regular::FILE_PY;
}
if ext == "c" {
return egui_phosphor::regular::FILE_C;
}
if ext == "cpp" || ext == "cc" || ext == "cxx" {
return egui_phosphor::regular::FILE_CPP;
}
if ext == "cs" {
return egui_phosphor::regular::FILE_C_SHARP;
}
if ext == "sql" {
return egui_phosphor::regular::FILE_SQL;
}
if ext == "vue" {
return egui_phosphor::regular::FILE_VUE;
}
if ext == "txt" || mime.starts_with("text/") {
return egui_phosphor::regular::FILE_TXT;
}

egui_phosphor::regular::FILE
}

fn is_archive_mime(mime: &str, ext: &str, fname: &str) -> bool {
mime == "application/zip"
|| mime == "application/gzip"
|| mime == "application/x-7z-compressed"
|| mime == "application/x-rar-compressed"
|| mime == "application/x-gtar"
|| mime == "application/x-tar"
|| mime == "application/x-bzip2"
|| mime == "application/x-xz"
|| mime == "application/zstd"
|| ext == "rar"
|| ext == "7z"
|| ext == "xz"
|| ext == "zst"
|| ext == "bz2"
|| fname.ends_with(".tar.gz")
|| fname.ends_with(".tgz")
|| fname.ends_with(".tar.bz2")
|| fname.ends_with(".tbz2")
|| fname.ends_with(".tar.xz")
|| fname.ends_with(".tar.zst")
}
3 changes: 3 additions & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@

//! Shared helper utilities reused by UI and business logic.

pub mod file_icons;
pub mod hash;
pub mod sanitize_component;

/// Select a Phosphor icon for the given MIME/path.
pub use file_icons::icon_for;
/// Compute the SHA-256 hash of a file.
pub use hash::hash_file;
/// Sanitize user-provided strings into filesystem-safe path components.
Expand Down