use serde::Serialize; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use tauri::{AppHandle, Manager}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct FileExplorerRoot { pub label: String, pub path: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct FileExplorerEntry { pub name: String, pub path: String, pub is_dir: bool, pub size_bytes: Option, pub modified_ms: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct FileExplorerListResponse { pub current_path: String, pub parent_path: Option, pub roots: Vec, pub entries: Vec, } #[tauri::command] pub fn file_explorer_list( app: AppHandle, path: Option, extensions: Option>, ) -> Result { let current_path = resolve_start_path(&app, path)?; let extension_filter = normalize_extensions(extensions); let mut entries = fs::read_dir(¤t_path) .map_err(|err| format!("Failed to read '{}': {err}", current_path.display()))? .filter_map(Result::ok) .filter_map(|entry| { let file_type = entry.file_type().ok()?; let metadata = entry.metadata().ok(); let is_dir = file_type.is_dir(); let path = entry.path(); if !is_dir && !extension_filter.is_empty() { let extension = path .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.to_ascii_lowercase()) .unwrap_or_default(); if !extension_filter.contains(&extension) { return None; } } let name = entry.file_name().to_string_lossy().to_string(); let size_bytes = if is_dir { None } else { metadata.as_ref().map(|value| value.len()) }; let modified_ms = metadata .as_ref() .and_then(|value| value.modified().ok()) .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) .map(|value| value.as_millis()); Some(FileExplorerEntry { name, path: path.display().to_string(), is_dir, size_bytes, modified_ms, }) }) .collect::>(); entries.sort_by(|left, right| { if left.is_dir != right.is_dir { return right.is_dir.cmp(&left.is_dir); } left.name .to_ascii_lowercase() .cmp(&right.name.to_ascii_lowercase()) }); Ok(FileExplorerListResponse { current_path: current_path.display().to_string(), parent_path: current_path .parent() .map(|parent| parent.display().to_string()), roots: collect_roots(&app), entries, }) } fn normalize_extensions(extensions: Option>) -> HashSet { extensions .unwrap_or_default() .into_iter() .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase()) .filter(|value| !value.is_empty()) .collect() } fn resolve_start_path(app: &AppHandle, raw_path: Option) -> Result { if let Some(value) = raw_path { let trimmed = value.trim(); if trimmed.is_empty() { return resolve_default_path(app); } let mut candidate = PathBuf::from(trimmed); if candidate.is_relative() { candidate = std::env::current_dir() .map_err(|err| format!("Failed to read current dir: {err}"))? .join(candidate); } if !candidate.exists() { return Err(format!("Path does not exist: {}", candidate.display())); } if candidate.is_file() { return candidate .parent() .map(|parent| parent.to_path_buf()) .ok_or_else(|| format!("No parent directory for {}", candidate.display())); } return Ok(candidate); } resolve_default_path(app) } fn resolve_default_path(app: &AppHandle) -> Result { if let Ok(path) = app.path().desktop_dir() { return Ok(path); } if let Ok(path) = app.path().document_dir() { return Ok(path); } if let Ok(path) = app.path().download_dir() { return Ok(path); } if let Ok(path) = app.path().home_dir() { return Ok(path); } std::env::current_dir().map_err(|err| format!("Failed to resolve default path: {err}")) } fn collect_roots(app: &AppHandle) -> Vec { let mut roots = Vec::new(); let mut seen = HashSet::new(); let mut push_root = |label: &str, path: PathBuf| { let normalized = path.display().to_string(); if normalized.is_empty() || !Path::new(&normalized).exists() { return; } if seen.insert(normalized.clone()) { roots.push(FileExplorerRoot { label: label.to_string(), path: normalized, }); } }; if let Ok(path) = app.path().desktop_dir() { push_root("Desktop", path); } if let Ok(path) = app.path().document_dir() { push_root("Documents", path); } if let Ok(path) = app.path().download_dir() { push_root("Downloads", path); } if let Ok(path) = app.path().home_dir() { push_root("Home", path); } #[cfg(target_os = "windows")] { for letter in b'A'..=b'Z' { let drive = format!("{}:\\", letter as char); let drive_path = PathBuf::from(&drive); if drive_path.exists() { push_root(&format!("{}:", letter as char), drive_path); } } } #[cfg(not(target_os = "windows"))] { push_root("Root", PathBuf::from("/")); } roots }