211 lines
6.0 KiB
Rust
211 lines
6.0 KiB
Rust
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<u64>,
|
|
pub modified_ms: Option<u128>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FileExplorerListResponse {
|
|
pub current_path: String,
|
|
pub parent_path: Option<String>,
|
|
pub roots: Vec<FileExplorerRoot>,
|
|
pub entries: Vec<FileExplorerEntry>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn file_explorer_list(
|
|
app: AppHandle,
|
|
path: Option<String>,
|
|
extensions: Option<Vec<String>>,
|
|
) -> Result<FileExplorerListResponse, String> {
|
|
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::<Vec<_>>();
|
|
|
|
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<Vec<String>>) -> HashSet<String> {
|
|
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<String>) -> Result<PathBuf, String> {
|
|
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<PathBuf, String> {
|
|
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<FileExplorerRoot> {
|
|
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
|
|
}
|