use crate::core::{FileEntry, FileType};
use crate::utils::{
clean_display_path, normalize_relative_path, shorten_home_path, with_lowered_stack,
};
use chrono::{DateTime, Local};
use humansize::{DECIMAL, format_size};
use unicode_width::UnicodeWidthChar;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs::{File, Metadata};
use std::io::{BufRead, BufReader, ErrorKind, Read, Seek};
use std::path::Path;
use std::sync::Arc;
use std::time::SystemTime;
const MIN_PREVIEW_LINES: usize = 3;
const MAX_PREVIEW_SIZE: u64 = 5_000 * 1024 * 1024;
const HEADER_PEEK_BYTES: usize = 8;
const BINARY_PEEK_BYTES: usize = 1024;
pub(crate) struct Formatter {
dirs_first: bool,
show_hidden: bool,
show_symlink: bool,
show_system: bool,
case_insensitive: bool,
always_show: Arc<HashSet<OsString>>,
always_show_lowercase: Arc<HashSet<String>>,
}
impl Formatter {
const PRIO_DIR: u8 = 0;
const PRIO_FILE: u8 = 1;
pub(crate) fn new(
dirs_first: bool,
show_hidden: bool,
show_symlink: bool,
show_system: bool,
case_insensitive: bool,
always_show: Arc<HashSet<OsString>>,
) -> Self {
let always_show_lowercase = Arc::new(
always_show
.iter()
.map(|s| s.to_string_lossy().to_lowercase())
.collect::<HashSet<String>>(),
);
Self {
dirs_first,
show_hidden,
show_symlink,
show_system,
case_insensitive,
always_show,
always_show_lowercase,
}
}
#[inline(always)]
fn get_prio(&self, entry: &FileEntry) -> u8 {
if self.dirs_first && (entry.flags() & FileEntry::IS_DIR) != 0 {
Self::PRIO_DIR
} else {
Self::PRIO_FILE
}
}
pub(crate) fn sort_entries(&self, entries: &mut [FileEntry]) {
if self.case_insensitive {
entries.sort_by_cached_key(|e| (self.get_prio(e), e.name_str().to_lowercase()));
} else {
entries.sort_by(|a, b| {
if self.dirs_first {
let prio_a = self.get_prio(a);
let prio_b = self.get_prio(b);
if prio_a != prio_b {
return prio_a.cmp(&prio_b);
}
}
a.name().cmp(b.name())
});
}
}
pub(crate) fn filter_entries(&self, entries: &mut Vec<FileEntry>) {
let mut hide = 0u8;
if !self.show_hidden {
hide |= FileEntry::IS_HIDDEN;
}
if !self.show_system {
hide |= FileEntry::IS_SYSTEM;
}
if !self.show_symlink {
hide |= FileEntry::IS_SYMLINK;
}
entries.retain(|e| {
let flags = e.flags();
if (flags & hide) != 0 {
if self.case_insensitive {
return with_lowered_stack(e.name_str().as_ref(), |lowered| {
self.always_show_lowercase.contains(lowered)
});
} else {
return self.always_show.contains(e.name());
}
}
true
});
}
}
pub(crate) fn format_attributes(meta: &Metadata) -> String {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let file_type = meta.file_type();
let first = if file_type.is_dir() {
'd'
} else if file_type.is_symlink() {
'l'
} else {
'-'
};
let mode = meta.permissions().mode();
let mut chars = [first, '-', '-', '-', '-', '-', '-', '-', '-', '-'];
let shifts = [6, 3, 0];
for (i, &shift) in shifts.iter().enumerate() {
let base = 1 + i * 3;
if (mode >> (shift + 2)) & 1u32 != 0 {
chars[base] = 'r';
}
if (mode >> (shift + 1)) & 1u32 != 0 {
chars[base + 1] = 'w';
}
if (mode >> shift) & 1u32 != 0 {
chars[base + 2] = 'x';
}
}
chars.iter().collect()
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
let attr = meta.file_attributes();
let mut out = String::with_capacity(7);
out.push(if attr & 0x10 != 0 {
'd'
} else if attr & 0x400 != 0 {
'l'
} else {
'-'
});
out.push(if attr & 0x02 != 0 { 'h' } else { '-' });
out.push(if attr & 0x04 != 0 { 's' } else { '-' });
out.push(if attr & 0x20 != 0 { 'a' } else { '-' });
out.push(if attr & 0x01 != 0 { 'r' } else { '-' });
out
}
}
pub(crate) fn format_file_type(file_type: &FileType) -> &'static str {
match file_type {
FileType::File => "File",
FileType::Directory => "Directory",
FileType::Symlink => "Symlink",
FileType::Other => "Other",
}
}
pub(crate) fn format_file_size(size: Option<u64>, is_dir: bool) -> String {
if is_dir {
"-".into()
} else if let Some(sz) = size {
format_size(sz, DECIMAL)
} else {
"-".to_string()
}
}
pub(crate) fn format_file_time(modified: Option<SystemTime>) -> String {
modified
.map(|mtime| {
let dt: DateTime<Local> = DateTime::from(mtime);
dt.format("%Y-%m-%d %H:%M:%S").to_string()
})
.unwrap_or_else(|| "-".to_string())
}
pub(crate) fn format_display_path(path: &Path) -> String {
let path_short = shorten_home_path(path);
let path_norm = normalize_relative_path(Path::new(&path_short));
clean_display_path(&path_norm).to_string()
}
pub(crate) fn sanitize_to_exact_width(line: &str, pane_width: usize) -> String {
let mut out = String::with_capacity(pane_width);
let mut current_w = 0;
for ch in line.chars() {
if ch == '\t' {
let space_count = 4 - (current_w % 4);
if current_w + space_count > pane_width {
break;
}
for _ in 0..space_count {
out.push(' ');
}
current_w += space_count;
continue;
}
if ch.is_control() {
continue;
}
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_w + ch_width > pane_width {
break;
}
out.push(ch);
current_w += ch_width;
}
while current_w < pane_width {
out.push(' ');
current_w += 1;
}
out
}
pub(crate) fn safe_read_preview(path: &Path, max_lines: usize, pane_width: usize) -> Vec<String> {
let max_lines = std::cmp::max(max_lines, MIN_PREVIEW_LINES);
let Ok(meta) = std::fs::metadata(path) else {
return vec![sanitize_to_exact_width(
"[Error: Access Denied]",
pane_width,
)];
};
if meta.len() > MAX_PREVIEW_SIZE {
return vec![sanitize_to_exact_width(
"[File too large for preview]",
pane_width,
)];
}
if !meta.is_file() {
return vec![sanitize_to_exact_width("[Not a regular file]", pane_width)];
}
match File::open(path) {
Ok(mut file) => {
let mut buffer = [0u8; BINARY_PEEK_BYTES];
let n = file.read(&mut buffer).unwrap_or(0);
let header_len = std::cmp::min(n, HEADER_PEEK_BYTES);
let header = &buffer[..header_len];
if header.len() >= 5 && &header[..5] == b"%PDF-" {
return vec![sanitize_to_exact_width(
"[Binary file - preview hidden]",
pane_width,
)];
}
if buffer[..n].contains(&0) {
return vec![sanitize_to_exact_width(
"[Binary file - preview hidden]",
pane_width,
)];
}
let _ = file.rewind();
let reader = BufReader::new(file);
let mut preview_lines = Vec::with_capacity(max_lines);
for line_result in reader.lines().take(max_lines) {
match line_result {
Ok(line) => {
preview_lines.push(sanitize_to_exact_width(&line, pane_width));
}
Err(_) => break,
}
}
if preview_lines.is_empty() {
preview_lines.push(sanitize_to_exact_width("[Empty file]", pane_width));
}
preview_lines
}
Err(e) => {
let msg = match e.kind() {
ErrorKind::PermissionDenied => "[Error: Permission Denied]",
ErrorKind::NotFound => "[Error: File Not Found]",
_ => {
return vec![sanitize_to_exact_width(
&format!("[Error reading file: {}]", e),
pane_width,
)];
}
};
vec![sanitize_to_exact_width(msg, pane_width)]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core;
use tempfile::tempdir;
#[test]
fn ui_sanitization_and_exact_width() {
let pane_width = 10;
let cases = vec![
("short.txt", 10),
("very_long_filename.txt", 10),
("🦀_crab.rs", 10),
("\t_tab", 10),
];
for (input, expected_width) in cases {
let result = sanitize_to_exact_width(input, pane_width);
let actual_width = unicode_width::UnicodeWidthStr::width(result.as_str());
assert_eq!(
actual_width, expected_width,
"Failed to produce exact width for input: '{}'. Result was: '{}' (width: {})",
input, result, actual_width
);
assert!(
!result.chars().any(|c| c.is_control() && c != ' '),
"Result contains control characters: {:?}",
result
);
}
}
#[test]
fn core_empty_dir() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let entries = core::browse_dir(temp_dir.path())?;
assert!(entries.is_empty(), "Directory should be empty");
Ok(())
}
}