v0.2.8 : navigation facon Nushell — commande ls en tableau encadre (type, taille, date), completion Tab des dossiers pour cd (niveau par niveau) et des chemins pour ls, descriptions dans le menu Tab
This commit is contained in:
+383
@@ -0,0 +1,383 @@
|
||||
//! Nushell-inspired navigation helpers: a boxed table listing of files and
|
||||
//! directories (ls), and directory/path completion for Tab.
|
||||
|
||||
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, bounded: beyond the entry cap the size is
|
||||
/// reported as unknown (None) so huge trees never freeze the listing.
|
||||
pub fn dir_size(path: &Path) -> Option<u64> {
|
||||
const CAP: usize = 20_000;
|
||||
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)
|
||||
}
|
||||
|
||||
/// One entry of the ls table.
|
||||
struct LsRow {
|
||||
name: String,
|
||||
kind: &'static str,
|
||||
size: String,
|
||||
modified: String,
|
||||
}
|
||||
|
||||
fn modified_str(path: &Path) -> String {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a Nushell-style boxed table of the entries of a path, with the
|
||||
/// columns: index, name, type, size, modified. Directories come first, then
|
||||
/// alphabetical order; sizes are human-readable and directories show their
|
||||
/// recursive size (bounded).
|
||||
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
let rd = std::fs::read_dir(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut rows: Vec<LsRow> = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file_type = meta.file_type();
|
||||
let name = p
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
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 = if file_type.is_dir() {
|
||||
match dir_size(&p) {
|
||||
Some(s) => format_size(s),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
} else {
|
||||
format_size(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()));
|
||||
}
|
||||
}
|
||||
rows.push(LsRow {
|
||||
name: paint(&display, color, style),
|
||||
kind,
|
||||
size,
|
||||
modified: modified_str(&p),
|
||||
});
|
||||
}
|
||||
// Directories first, then alphabetical (case-insensitive).
|
||||
rows.sort_by(|a, b| {
|
||||
let a_dir = a.kind == "dir";
|
||||
let b_dir = b.kind == "dir";
|
||||
b_dir
|
||||
.cmp(&a_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
// Column contents: index, name, type, size, modified.
|
||||
let headers = vec!["#", "name", "type", "size", "modified"];
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
||||
let mut cells: Vec<Vec<String>> = Vec::new();
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let line = vec![
|
||||
i.to_string(),
|
||||
row.name.clone(),
|
||||
row.kind.to_string(),
|
||||
row.size.clone(),
|
||||
row.modified.clone(),
|
||||
];
|
||||
for (c, cell) in line.iter().enumerate() {
|
||||
widths[c] = widths[c].max(visible_len(cell));
|
||||
}
|
||||
cells.push(line);
|
||||
}
|
||||
|
||||
let pad = |s: &str, w: usize| -> String {
|
||||
let n = visible_len(s);
|
||||
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
|
||||
};
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
for (i, w) in widths.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(mid);
|
||||
}
|
||||
line.push_str(&"─".repeat(w + 2));
|
||||
}
|
||||
line.push(right);
|
||||
line
|
||||
};
|
||||
let row = |cells: &[String], bold: bool| -> String {
|
||||
let mut line = String::from("│");
|
||||
for (i, c) in cells.iter().enumerate() {
|
||||
line.push(' ');
|
||||
let cell = pad(c, widths[i]);
|
||||
let rendered = if bold {
|
||||
cell.bold().to_string()
|
||||
} else {
|
||||
cell
|
||||
};
|
||||
line.push_str(&rendered);
|
||||
line.push(' ');
|
||||
line.push('│');
|
||||
}
|
||||
line
|
||||
};
|
||||
|
||||
let dim = |s: &str| -> String {
|
||||
if color {
|
||||
s.dimmed().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&dim(&border('╭', '┬', '╮')));
|
||||
out.push('\n');
|
||||
out.push_str(&row(&headers.iter().map(|h| h.to_string()).collect::<Vec<_>>(), true));
|
||||
out.push('\n');
|
||||
out.push_str(&dim(&border('├', '┼', '┤')));
|
||||
out.push('\n');
|
||||
for line in &cells {
|
||||
out.push_str(&row(line, false));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&dim(&border('╰', '┴', '╯')));
|
||||
out.push('\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
for n in chars.by_ref() {
|
||||
if n == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
strip_ansi(s).chars().count()
|
||||
}
|
||||
|
||||
#[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 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user