339 lines
11 KiB
Rust
339 lines
11 KiB
Rust
//! 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<u64> {
|
|
const CAP: usize = 500;
|
|
let mut total: u64 = 0;
|
|
let mut stack: Vec<PathBuf> = 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<chrono::Local> = 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<Cell>)> {
|
|
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<DataTable> {
|
|
let rd = std::fs::read_dir(path)
|
|
.with_context(|| format!("cannot read {}", path.display()))?;
|
|
let entries: Vec<PathBuf> = 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<Cell>)> = std::thread::scope(|scope| {
|
|
let mut handles = Vec::new();
|
|
for chunk in entries.chunks(chunk_size) {
|
|
let chunk: Vec<PathBuf> = chunk.to_vec();
|
|
handles.push(scope.spawn(move || {
|
|
chunk
|
|
.into_iter()
|
|
.filter_map(|p| row_for(&p, color))
|
|
.collect::<Vec<_>>()
|
|
}));
|
|
}
|
|
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<String> {
|
|
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<String> {
|
|
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<usize> = table.lines().map(|l| l.chars().count()).collect();
|
|
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}");
|
|
}
|
|
}
|