//! Nushell-inspired navigation helpers: a boxed table listing of files and //! directories (ls), and directory/path completion for Tab. use crate::tables::{Cell, DataTable}; use anyhow::{Context, Result}; use colored::Colorize; use std::path::{Path, PathBuf}; /// Human-readable byte size, like Nushell ("512 B", "1.2 KB", "3.4 MB"). pub fn format_size(bytes: u64) -> String { const KB: f64 = 1024.0; const MB: f64 = 1024.0 * 1024.0; const GB: f64 = 1024.0 * 1024.0 * 1024.0; let b = bytes as f64; if b < KB { format!("{bytes} B") } else if b < MB { format!("{:.1} KB", b / KB) } else if b < GB { format!("{:.1} MB", b / MB) } else { format!("{:.1} GB", b / GB) } } /// Recursive size of a directory, tightly bounded: beyond the entry cap the /// size is reported as unknown (None). The cap is deliberately small so a /// listing stays snappy even when it contains huge trees. pub fn dir_size(path: &Path) -> Option { const CAP: usize = 500; let mut total: u64 = 0; let mut stack: Vec = vec![path.to_path_buf()]; let mut visited: usize = 0; while let Some(dir) = stack.pop() { let rd = std::fs::read_dir(&dir).ok()?; for entry in rd.flatten() { visited += 1; if visited > CAP { return None; } let p = entry.path(); match std::fs::symlink_metadata(&p) { Ok(m) if m.is_dir() => stack.push(p), Ok(m) if m.is_file() => { total = total.saturating_add(m.len()); } _ => {} } } } Some(total) } fn modified_text(meta: &std::fs::Metadata) -> String { meta.modified() .ok() .map(|t| { let dt: chrono::DateTime = t.into(); dt.format("%Y-%m-%d %H:%M").to_string() }) .unwrap_or_else(|| "—".to_string()) } fn paint(s: &str, color: bool, style: u8) -> String { if !color { return s.to_string(); } match style { 1 => s.blue().bold().to_string(), // directory 2 => s.green().to_string(), // executable 3 => s.cyan().to_string(), // symlink 4 => s.red().to_string(), // broken symlink _ => s.to_string(), } } fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; meta.permissions().mode() & 0o111 != 0 } #[cfg(windows)] { let _ = meta; matches!( path.extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()) .as_deref(), Some("exe") | Some("bat") | Some("cmd") | Some("com") ) } } /// One ls row: (sort key, cells). A single symlink_metadata call per entry /// (the modification time comes from the same metadata). fn row_for(p: &Path, color: bool) -> Option<(String, Vec)> { let meta = std::fs::symlink_metadata(p).ok()?; let file_type = meta.file_type(); let name = p.file_name().and_then(|n| n.to_str())?.to_string(); let (kind, style) = if file_type.is_symlink() { ("symlink", if p.exists() { 3 } else { 4 }) } else if file_type.is_dir() { ("dir", 1) } else if is_executable(p, &meta) { ("file", 2) } else { ("file", 0) }; let (size_text, size_bytes) = if file_type.is_dir() { match dir_size(p) { Some(s) => (format_size(s), Some(s)), None => ("—".to_string(), None), } } else { (format_size(meta.len()), Some(meta.len())) }; let mut display = name.clone(); if file_type.is_dir() { display.push('/'); } else if file_type.is_symlink() { if let Ok(target) = std::fs::read_link(p) { display.push_str(&format!(" -> {}", target.display())); } } Some(( name.to_lowercase(), vec![ Cell::str(paint(&display, color, style)), Cell::str(kind.to_string()), Cell::bytes(size_bytes.unwrap_or(0), size_text), Cell::str(modified_text(&meta)), ], )) } /// Build the ls table (columns: name, type, size, modified). Directories /// come first, then alphabetical order. /// /// Rows are built in parallel: recursive directory sizes dominate the cost, /// so a listing with many subdirectories stays fast (like Nushell, which /// also parallelizes its listing). pub fn ls_table(path: &Path, color: bool) -> Result { let rd = std::fs::read_dir(path) .with_context(|| format!("cannot read {}", path.display()))?; let entries: Vec = rd.flatten().map(|e| e.path()).collect(); let threads = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4) .min(8); let chunk_size = entries.len().div_ceil(threads).max(1); let rows: Vec<(String, Vec)> = std::thread::scope(|scope| { let mut handles = Vec::new(); for chunk in entries.chunks(chunk_size) { let chunk: Vec = chunk.to_vec(); handles.push(scope.spawn(move || { chunk .into_iter() .filter_map(|p| row_for(&p, color)) .collect::>() })); } let mut out = Vec::new(); for handle in handles { if let Ok(mut rows) = handle.join() { out.append(&mut rows); } } out }); // Directories first, then alphabetical. let mut rows = rows; rows.sort_by(|a, b| { let a_dir = a.1[1].text == "dir"; let b_dir = b.1[1].text == "dir"; b_dir.cmp(&a_dir).then_with(|| a.0.cmp(&b.0)) }); Ok(DataTable { columns: vec![ "name".into(), "type".into(), "size".into(), "modified".into(), ], rows: rows.into_iter().map(|(_, r)| r).collect(), }) } /// Render a Nushell-style boxed table of the entries of a path (unbounded /// width — used by tests; the REPL renders with the terminal width). pub fn render_ls(path: &Path, color: bool) -> Result { Ok(ls_table(path, color)?.render(color, 200)) } /// Completion candidates for a path fragment typed after cd or ls. /// /// The last segment is matched against the entries of the base directory; /// directory candidates end with a separator so the next Tab dives one level /// deeper, like Nushell. With dirs_only, files are excluded (cd). pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec { let sep = std::path::MAIN_SEPARATOR; let alt = if sep == '\\' { '/' } else { '\\' }; let idx = input.rfind(|c| c == sep || c == alt); let (base_part, stem) = match idx { Some(i) => (&input[..=i], &input[i + 1..]), None => ("", input), }; let use_sep = if input.contains(alt) && !input.contains(sep) { alt } else { sep }; let base: PathBuf = if base_part.starts_with('~') { crate::config::expand_path(base_part) } else if base_part.is_empty() { base_dir.to_path_buf() } else { PathBuf::from(base_part) }; let mut names: Vec<(String, bool)> = Vec::new(); if let Ok(rd) = std::fs::read_dir(&base) { for entry in rd.flatten() { let p = entry.path(); let is_dir = p.is_dir(); if dirs_only && !is_dir { continue; } let Some(name) = p.file_name().and_then(|n| n.to_str()) else { continue; }; if !name.starts_with(stem) { continue; } names.push((name.to_string(), is_dir)); } } names.sort_by(|a, b| { b.1.cmp(&a.1) .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) }); names .into_iter() .map(|(name, is_dir)| { let mut full = format!("{base_part}{name}"); if is_dir { full.push(use_sep); } full }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn formats_sizes() { assert_eq!(format_size(0), "0 B"); assert_eq!(format_size(512), "512 B"); assert_eq!(format_size(2048), "2.0 KB"); assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB"); assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB"); } #[test] fn dir_size_is_bounded() { let dir = tempfile::tempdir().unwrap(); let big = dir.path().join("big"); std::fs::create_dir_all(&big).unwrap(); for i in 0..520 { std::fs::write(big.join(format!("f{i}.txt")), "x").unwrap(); } // Beyond the cap the size is unknown, and the walk stays cheap. assert_eq!(dir_size(&big), None); let small = dir.path().join("small"); std::fs::create_dir_all(&small).unwrap(); std::fs::write(small.join("a.txt"), "hello").unwrap(); assert_eq!(dir_size(&small), Some(5)); } #[test] fn completes_directories_only() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("src")).unwrap(); std::fs::create_dir_all(dir.path().join("src2")).unwrap(); std::fs::write(dir.path().join("readme.txt"), "x").unwrap(); let cands = path_candidates(dir.path(), "", true); assert!(cands.iter().any(|c| c == "src\\" || c == "src/")); assert!(cands.iter().any(|c| c == "src2\\" || c == "src2/")); assert!(!cands.iter().any(|c| c.contains("readme"))); let cands = path_candidates(dir.path(), "s", true); assert_eq!(cands.len(), 2, "{cands:?}"); // Files appear for ls completion. let cands = path_candidates(dir.path(), "", false); assert!(cands.iter().any(|c| c.contains("readme"))); } #[test] fn completes_nested_paths() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("src").join("commands")).unwrap(); std::fs::create_dir_all(dir.path().join("src").join("installers")).unwrap(); let sep = std::path::MAIN_SEPARATOR; let prefix = format!("src{}", sep); let cands = path_candidates(dir.path(), &format!("src{}co", sep), true); assert!(cands.iter().any(|c| c == &format!("{prefix}commands{}", sep))); assert!(!cands.iter().any(|c| c.contains("installers"))); } #[test] fn renders_ls_table() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("subdir")).unwrap(); std::fs::write(dir.path().join("file.txt"), "hello").unwrap(); let table = render_ls(dir.path(), false).unwrap(); assert!(table.contains("╭")); assert!(table.contains("╰")); assert!(table.contains("name")); assert!(table.contains("type")); assert!(table.contains("modified")); assert!(table.contains("subdir/")); assert!(table.contains("file.txt")); assert!(table.contains("dir")); assert!(table.contains("file")); assert!(table.contains("5 B")); // Every line of the table has the same width. let widths: Vec = table.lines().map(|l| l.chars().count()).collect(); assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}"); } }