Files
agent-manager/src/nav.rs
T

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}");
}
}