⬆ (U+2B06), 🖼 (U+1F5BC) and ⚙ (U+2699) are East-Asian-Width Neutral: unicode-width measures 1 column but emoji fonts draw 2, so every row containing them shifted right by one column. Replaced with 真 Wide emoji (📷, 🔩, ⚡) or pure text glyphs (↑, ≡), plus a regression test asserting every emoji icon measures 2 columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::fs;
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum FileType {
|
|
Directory,
|
|
File,
|
|
Symlink,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct FileEntry {
|
|
pub name: String,
|
|
pub path: PathBuf,
|
|
pub file_type: FileType,
|
|
pub size: u64,
|
|
pub is_hidden: bool,
|
|
}
|
|
|
|
impl FileEntry {
|
|
pub fn icon(&self) -> &'static str {
|
|
match &self.file_type {
|
|
FileType::Directory => "📁",
|
|
FileType::Symlink => "🔗",
|
|
FileType::File => {
|
|
let ext = self
|
|
.name
|
|
.rsplit('.')
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
match ext.as_str() {
|
|
// OBS: alla emoji-ikoner MÅSTE vara East-Asian-Width Wide
|
|
// (mäts som 2 kolumner av unicode-width). Tecken med
|
|
// EAW=Neutral/Ambiguous (t.ex. 🖼 U+1F5BC, ⚙ U+2699)
|
|
// mäts som 1 men ritas 2 breda av emoji-fonter →
|
|
// raden förskjuts åt höger. Se icon_widths-testet.
|
|
"rs" => "🦀",
|
|
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "📷",
|
|
"mp4" | "mkv" | "avi" | "mov" | "webm" => "🎬",
|
|
"mp3" | "flac" | "ogg" | "wav" | "aac" => "🎵",
|
|
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "📦",
|
|
"pdf" => "📕",
|
|
"txt" | "md" | "log" => "📄",
|
|
"json" | "yaml" | "yml" | "toml" | "xml" => "🔩",
|
|
"sh" | "bash" | "zsh" | "fish" => "🐚",
|
|
"py" => "🐍",
|
|
"js" | "ts" => "📜",
|
|
"c" | "cpp" | "h" | "hpp" => "🔧",
|
|
"exe" | "bin" | "out" | "run" => "⚡",
|
|
_ => "📎",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ASCII fallback icons for terminals that don't support emoji (e.g. SSH with limited fonts)
|
|
pub fn icon_ascii(&self) -> &'static str {
|
|
match &self.file_type {
|
|
FileType::Directory => "[/]",
|
|
FileType::Symlink => "[~]",
|
|
FileType::File => {
|
|
let ext = self
|
|
.name
|
|
.rsplit('.')
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
match ext.as_str() {
|
|
"rs" | "py" | "js" | "ts" | "c" | "cpp" | "h" | "hpp"
|
|
| "sh" | "bash" | "zsh" | "fish" => "[s]",
|
|
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "[i]",
|
|
"mp4" | "mkv" | "avi" | "mov" | "webm" => "[v]",
|
|
"mp3" | "flac" | "ogg" | "wav" | "aac" => "[a]",
|
|
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "[z]",
|
|
"pdf" => "[p]",
|
|
"txt" | "md" | "log" => "[t]",
|
|
"json" | "yaml" | "yml" | "toml" | "xml" => "[c]",
|
|
"exe" | "bin" | "out" | "run" => "[x]",
|
|
_ => "[ ]",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn is_dir(&self) -> bool {
|
|
self.file_type == FileType::Directory
|
|
}
|
|
|
|
pub fn formatted_size(&self) -> String {
|
|
if self.file_type == FileType::Directory {
|
|
return String::from("<dir>");
|
|
}
|
|
format_size(self.size)
|
|
}
|
|
}
|
|
|
|
pub fn format_size(bytes: u64) -> String {
|
|
const KB: u64 = 1024;
|
|
const MB: u64 = 1024 * KB;
|
|
const GB: u64 = 1024 * MB;
|
|
|
|
if bytes >= GB {
|
|
format!("{:.1} GB", bytes as f64 / GB as f64)
|
|
} else if bytes >= MB {
|
|
format!("{:.1} MB", bytes as f64 / MB as f64)
|
|
} else if bytes >= KB {
|
|
format!("{} KB", bytes / KB)
|
|
} else {
|
|
format!("{} B", bytes)
|
|
}
|
|
}
|
|
|
|
pub fn read_dir(path: &Path) -> Vec<FileEntry> {
|
|
let mut entries = Vec::new();
|
|
let Ok(dir) = fs::read_dir(path) else {
|
|
return entries;
|
|
};
|
|
|
|
for entry in dir.flatten() {
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
let path = entry.path();
|
|
let is_hidden = name.starts_with('.');
|
|
|
|
let (file_type, size) = if let Ok(meta) = entry.metadata() {
|
|
let ft = if meta.is_symlink() {
|
|
FileType::Symlink
|
|
} else if meta.is_dir() {
|
|
FileType::Directory
|
|
} else {
|
|
FileType::File
|
|
};
|
|
(ft, meta.len())
|
|
} else {
|
|
(FileType::File, 0)
|
|
};
|
|
|
|
entries.push(FileEntry { name, path, file_type, size, is_hidden });
|
|
}
|
|
|
|
// Sort: dirs first, then files, both alphabetically
|
|
entries.sort_by(|a, b| {
|
|
match (&a.file_type, &b.file_type) {
|
|
(FileType::Directory, FileType::Directory) => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
(FileType::Directory, _) => std::cmp::Ordering::Less,
|
|
(_, FileType::Directory) => std::cmp::Ordering::Greater,
|
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
}
|
|
});
|
|
|
|
entries
|
|
}
|
|
|
|
/// Recursively compute total size and file count of a path
|
|
pub fn recursive_info(path: &Path) -> (u64, u64) {
|
|
let mut total_size: u64 = 0;
|
|
let mut file_count: u64 = 0;
|
|
|
|
if path.is_file() {
|
|
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
|
|
return (size, 1);
|
|
}
|
|
|
|
if let Ok(dir) = fs::read_dir(path) {
|
|
for entry in dir.flatten() {
|
|
let p = entry.path();
|
|
if p.is_dir() {
|
|
let (s, c) = recursive_info(&p);
|
|
total_size += s;
|
|
file_count += c;
|
|
} else {
|
|
total_size += p.metadata().map(|m| m.len()).unwrap_or(0);
|
|
file_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
(total_size, file_count)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub fn file_permissions(path: &Path) -> String {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mode = path.metadata().map(|m| m.permissions().mode()).unwrap_or(0);
|
|
format!("{:o}", mode & 0o777)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
pub fn file_permissions(_path: &Path) -> String {
|
|
String::from("N/A")
|
|
}
|
|
|
|
pub fn file_owner(path: &Path) -> (String, String) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
if let Ok(meta) = path.metadata() {
|
|
let uid = meta.uid();
|
|
let gid = meta.gid();
|
|
let user_name = lookup_name("/etc/passwd", uid)
|
|
.map(|n| format!("{} ({})", n, uid))
|
|
.unwrap_or_else(|| uid.to_string());
|
|
let group_name = lookup_name("/etc/group", gid)
|
|
.map(|n| format!("{} ({})", n, gid))
|
|
.unwrap_or_else(|| gid.to_string());
|
|
return (user_name, group_name);
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let _ = path;
|
|
}
|
|
(String::from("N/A"), String::from("N/A"))
|
|
}
|
|
|
|
/// Parse /etc/passwd or /etc/group and return the name for a given numeric id.
|
|
/// Both files share the same format: `name:x:id:...`
|
|
#[cfg(unix)]
|
|
fn lookup_name(db_file: &str, id: u32) -> Option<String> {
|
|
let content = std::fs::read_to_string(db_file).ok()?;
|
|
for line in content.lines() {
|
|
let mut parts = line.splitn(4, ':');
|
|
let name = parts.next()?;
|
|
parts.next(); // password/placeholder
|
|
let entry_id: u32 = parts.next()?.parse().ok()?;
|
|
if entry_id == id {
|
|
return Some(name.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Copy a file or directory recursively
|
|
pub fn copy_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
|
|
let name = src.file_name().ok_or("No filename")?;
|
|
let dst = dst_dir.join(name);
|
|
|
|
if src.is_dir() {
|
|
fs_extra::dir::copy(
|
|
src,
|
|
dst_dir,
|
|
&fs_extra::dir::CopyOptions::new(),
|
|
)
|
|
.map(|_| ())
|
|
.map_err(|e| e.to_string())
|
|
} else {
|
|
fs::copy(src, &dst).map(|_| ()).map_err(|e| e.to_string())
|
|
}
|
|
}
|
|
|
|
/// Move (cut/paste) a file or directory
|
|
pub fn move_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
|
|
let name = src.file_name().ok_or("No filename")?;
|
|
let dst = dst_dir.join(name);
|
|
|
|
if src.is_dir() {
|
|
fs_extra::dir::move_dir(
|
|
src,
|
|
dst_dir,
|
|
&fs_extra::dir::CopyOptions::new(),
|
|
)
|
|
.map(|_| ())
|
|
.map_err(|e| e.to_string())
|
|
} else {
|
|
fs::rename(src, &dst).map_err(|e| e.to_string())
|
|
}
|
|
}
|
|
|
|
pub fn delete_entry(path: &Path) -> Result<(), String> {
|
|
if path.is_dir() {
|
|
fs::remove_dir_all(path).map_err(|e| e.to_string())
|
|
} else {
|
|
fs::remove_file(path).map_err(|e| e.to_string())
|
|
}
|
|
}
|
|
|
|
pub fn get_root_dirs() -> Vec<PathBuf> {
|
|
#[cfg(unix)]
|
|
{
|
|
let roots = ["/", "/home", "/etc", "/usr", "/var", "/opt", "/tmp"];
|
|
roots
|
|
.iter()
|
|
.filter_map(|r| {
|
|
let p = PathBuf::from(r);
|
|
if p.exists() { Some(p) } else { None }
|
|
})
|
|
.collect()
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let mut drives = Vec::new();
|
|
for letter in b'A'..=b'Z' {
|
|
let d = format!("{}:\\", letter as char);
|
|
let p = PathBuf::from(&d);
|
|
if p.exists() {
|
|
drives.push(p);
|
|
}
|
|
}
|
|
drives
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
fn entry(name: &str, ft: FileType) -> FileEntry {
|
|
FileEntry {
|
|
name: name.to_string(),
|
|
path: PathBuf::from(name),
|
|
file_type: ft,
|
|
size: 0,
|
|
is_hidden: false,
|
|
}
|
|
}
|
|
|
|
/// Alla emoji-ikoner måste vara East-Asian-Width Wide (mäts som
|
|
/// 2 kolumner av unicode-width). En ikon med EAW=Neutral/Ambiguous
|
|
/// (t.ex. 🖼 U+1F5BC eller ⚙ U+2699) mäts som 1 men ritas 2 bred av
|
|
/// emoji-fonter — då förskjuts allt efter ikonen åt höger i UI:t.
|
|
#[test]
|
|
fn emoji_icons_are_wide() {
|
|
let mut samples: Vec<FileEntry> = vec![
|
|
entry("dir", FileType::Directory),
|
|
entry("link", FileType::Symlink),
|
|
];
|
|
for ext in [
|
|
"rs", "png", "mp4", "mp3", "zip", "pdf", "txt", "json", "sh",
|
|
"py", "js", "c", "exe", "unknown",
|
|
] {
|
|
samples.push(entry(&format!("f.{}", ext), FileType::File));
|
|
}
|
|
for e in &samples {
|
|
let icon = e.icon();
|
|
assert_eq!(
|
|
UnicodeWidthStr::width(icon), 2,
|
|
"ikonen {:?} (för {:?}) är inte EAW Wide — den kommer \
|
|
förskjuta raden åt höger i terminaler med emoji-font",
|
|
icon, e.name
|
|
);
|
|
}
|
|
}
|
|
|
|
/// ASCII-fallback-ikonerna ska alltid vara exakt 3 tecken ([/],
|
|
/// [s], ...) så att kolumnerna ligger fast i ASCII-läge.
|
|
#[test]
|
|
fn ascii_icons_are_three_cols() {
|
|
let e = entry("f.rs", FileType::File);
|
|
assert_eq!(UnicodeWidthStr::width(e.icon_ascii()), 3);
|
|
let d = entry("dir", FileType::Directory);
|
|
assert_eq!(UnicodeWidthStr::width(d.icon_ascii()), 3);
|
|
}
|
|
}
|