Remove the Go implementation; build and release the Rust version for every OS #2
+25
-5
@@ -25,25 +25,45 @@ fn bench<R>(name: &str, mut f: impl FnMut(u64) -> R) {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let items: Vec<Item> = (0..300).map(|i| Item { id: i, size: (1 + i * i) as f64 }).collect();
|
||||
let bounds = Rect { x: 0, y: 0, w: 200, h: 60 };
|
||||
let items: Vec<Item> = (0..300)
|
||||
.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)", |_| {
|
||||
black_box(treemap::squarify(black_box(&items), bounds));
|
||||
});
|
||||
let blocks = treemap::squarify(&items, bounds);
|
||||
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 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);
|
||||
bench("tui/BigColdFrame", |_| m.cold_frame());
|
||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||
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);
|
||||
m.zoom_to_child_named(b"flat");
|
||||
bench("tui/BigFlatDirCold", |_| m.cold_frame());
|
||||
|
||||
+92
-17
@@ -37,16 +37,35 @@ pub struct Config {
|
||||
impl Config {
|
||||
pub fn new(settings: Settings) -> Config {
|
||||
Config {
|
||||
paths: Vec::new(), scan_opts: Default::default(), no_ui: false, 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,
|
||||
paths: Vec::new(),
|
||||
scan_opts: Default::default(),
|
||||
no_ui: false,
|
||||
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.
|
||||
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
|
||||
/// 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() {
|
||||
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 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 {
|
||||
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)?;
|
||||
writeln!(out)?;
|
||||
}
|
||||
@@ -162,8 +194,14 @@ fn wait_with_progress(s: &Scanner, cancel: &Cancel) {
|
||||
fn run_tui(cfg: Config) -> Result<(), Error> {
|
||||
let (load, paths, opts) = (cfg.load.clone(), cfg.paths.clone(), cfg.scan_opts.clone());
|
||||
tui::run(tui::Options {
|
||||
read_only: cfg.read_only, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color,
|
||||
save: cfg.save, settings: cfg.settings, diff: None,
|
||||
read_only: cfg.read_only,
|
||||
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)),
|
||||
})
|
||||
}
|
||||
@@ -176,8 +214,14 @@ fn run_compare(cfg: Config) -> Result<(), Error> {
|
||||
if !cfg.report() {
|
||||
let tree = Arc::new(RwLock::new(nw));
|
||||
return tui::run(tui::Options {
|
||||
read_only: true, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color,
|
||||
save: String::new(), settings: cfg.settings, diff: Some(d),
|
||||
read_only: true,
|
||||
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()))),
|
||||
});
|
||||
}
|
||||
@@ -185,23 +229,54 @@ fn run_compare(cfg: Config) -> Result<(), Error> {
|
||||
let m = cfg.size_mode;
|
||||
let mut out = std::io::stdout().lock();
|
||||
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!(out, "Total {} → {} ({})\n", textutil::size(ro.total(m)), textutil::size(rn.total(m)), textutil::signed_size(rn.total(m) - ro.total(m)))?;
|
||||
writeln!(
|
||||
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) {
|
||||
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(())
|
||||
}
|
||||
|
||||
fn run_duplicates(out: &mut dyn Write, cancel: &Cancel, tree: &RwLock<Tree>) -> Result<(), Error> {
|
||||
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 mut wasted = 0;
|
||||
for g in &groups {
|
||||
wasted += g.wasted();
|
||||
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 {
|
||||
writeln!(out, " {}", textutil::sanitize_bytes(&t.path_bytes(id)))?;
|
||||
}
|
||||
|
||||
+5
-1
@@ -22,7 +22,11 @@ impl Default for Cancel {
|
||||
impl Cancel {
|
||||
pub fn new() -> Cancel {
|
||||
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) {
|
||||
|
||||
+8
-2
@@ -46,7 +46,11 @@ impl Default for Settings {
|
||||
|
||||
/// The config file location.
|
||||
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") {
|
||||
env("HOME")?.join("Library/Application Support")
|
||||
} else {
|
||||
@@ -57,7 +61,9 @@ pub fn path() -> Option<PathBuf> {
|
||||
|
||||
/// Reads the config file if present. A missing file is not an error.
|
||||
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) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Settings::default(), None),
|
||||
|
||||
+55
-11
@@ -104,7 +104,11 @@ impl Finder {
|
||||
let mut by_size: HashMap<i64, Vec<Cand>> = HashMap::new();
|
||||
t.walk(t.root(), |id, n| {
|
||||
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
|
||||
});
|
||||
@@ -133,11 +137,18 @@ impl Finder {
|
||||
sampled.push(g); // a sample would be the whole file
|
||||
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.
|
||||
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", |_| {});
|
||||
let mut out = Vec::new();
|
||||
for g in &sampled {
|
||||
@@ -148,21 +159,35 @@ impl Finder {
|
||||
if v.len() > 1 {
|
||||
let mut files: Vec<NodeId> = v.iter().map(|c| c.id).collect();
|
||||
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() {
|
||||
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);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Hashes every candidate with a bounded pool of reader threads and
|
||||
/// 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 results = Mutex::new(Vec::with_capacity(g.len()));
|
||||
std::thread::scope(|s| {
|
||||
@@ -244,7 +269,15 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
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.tree.clone()
|
||||
}
|
||||
@@ -270,7 +303,11 @@ mod tests {
|
||||
assert_eq!(groups.len(), 2);
|
||||
let t = tree.read().unwrap();
|
||||
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
|
||||
};
|
||||
@@ -278,7 +315,10 @@ mod tests {
|
||||
assert_eq!(groups[0].size, 200_000);
|
||||
assert_eq!(names(&groups[1]), ["small1", "small2"], "small group");
|
||||
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
|
||||
@@ -308,8 +348,12 @@ mod tests {
|
||||
let f = Arc::new(Finder::default());
|
||||
let (tx, rx) = crossbeam_channel::bounded(1);
|
||||
let (f2, t2) = (f.clone(), tree.clone());
|
||||
std::thread::spawn(move || 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"));
|
||||
std::thread::spawn(move || {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+119
-25
@@ -8,17 +8,27 @@ use std::io::{self, Write};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
const FLAG_NAMES: [(Flags, &str); 12] = [
|
||||
(inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"), (inv::FLAG_MOUNT_POINT, "mount-point"),
|
||||
(inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"), (inv::FLAG_SKIPPED_FS, "other-fs-skipped"),
|
||||
(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_HARDLINK_DUP, "hardlink-duplicate"),
|
||||
(inv::FLAG_MOUNT_POINT, "mount-point"),
|
||||
(inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"),
|
||||
(inv::FLAG_SKIPPED_FS, "other-fs-skipped"),
|
||||
(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"),
|
||||
];
|
||||
|
||||
/// Human-readable names for a node's flags.
|
||||
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 {
|
||||
@@ -41,7 +51,10 @@ pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Re
|
||||
.filter(|(_, &c)| c > 0)
|
||||
.map(|(k, &c)| (inv::ErrKind::from_u8(k as u8).name().to_string(), c.into()))
|
||||
.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!({
|
||||
"generator": format!("{} {}", brand::NAME, brand::VERSION),
|
||||
"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.
|
||||
fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Result<()> {
|
||||
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)] {
|
||||
if v != 0 {
|
||||
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"}");
|
||||
}
|
||||
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 {
|
||||
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")?;
|
||||
fn rec(bw: &mut impl Write, t: &Tree, id: NodeId, path: &[u8], depth: i64) -> io::Result<()> {
|
||||
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!(
|
||||
bw,
|
||||
"{},{},{},{},{},{},{},{},{}",
|
||||
csv_field(&textutil::sanitize_bytes(path)),
|
||||
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("|")
|
||||
)?;
|
||||
if depth == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
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(())
|
||||
}
|
||||
@@ -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")?;
|
||||
for &id in ids {
|
||||
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!(
|
||||
w,
|
||||
"{:>12} {:>6} {:>12} {}",
|
||||
@@ -152,30 +195,65 @@ pub fn summary(w: &mut dyn Write, t: &Tree) -> io::Result<()> {
|
||||
let st = &t.stats;
|
||||
let r = t.node(t.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, "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,
|
||||
"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()))?;
|
||||
for (k, &c) in st.err_counts.iter().enumerate() {
|
||||
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 {
|
||||
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 {
|
||||
writeln!(w, "Other FS skipped {}", textutil::count(st.skipped_mounts))?;
|
||||
}
|
||||
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 {
|
||||
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() {
|
||||
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(())
|
||||
}
|
||||
@@ -199,11 +277,27 @@ mod tests {
|
||||
fn outputs_are_terminal_safe() {
|
||||
let t = hostile_tree();
|
||||
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(); table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap(); b },
|
||||
{
|
||||
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();
|
||||
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();
|
||||
json(&mut b, &t, 0, -1).unwrap();
|
||||
|
||||
+12
-2
@@ -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 covered = vec![false; n];
|
||||
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);
|
||||
if q.uses_path {
|
||||
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]);
|
||||
}
|
||||
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
@@ -32,7 +32,16 @@ fn push_lower(buf: &mut String, name: &[u8]) {
|
||||
|
||||
impl<'a> 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.
|
||||
@@ -82,7 +91,12 @@ impl<'a> Ctx<'a> {
|
||||
pub(super) fn fill_path(&mut self) {
|
||||
self.has_path = true;
|
||||
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);
|
||||
if !pp.ends_with('/') {
|
||||
self.path.push('/');
|
||||
@@ -90,7 +104,8 @@ impl<'a> Ctx<'a> {
|
||||
push_lower(&mut self.path, &self.node.name);
|
||||
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
@@ -17,7 +17,11 @@ pub(super) struct 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.
|
||||
@@ -64,12 +68,18 @@ pub(super) fn lex(s: &str) -> Result<Vec<Token>, String> {
|
||||
}
|
||||
i += 1;
|
||||
// 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'|' => {
|
||||
let start = i;
|
||||
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];
|
||||
i += op.len();
|
||||
match op {
|
||||
|
||||
+52
-11
@@ -43,7 +43,10 @@ mod tests {
|
||||
let n = t.node_mut(id);
|
||||
n.size = 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);
|
||||
};
|
||||
add(vms, "ubuntu.qcow2", 80 << 30, 400 * DAY);
|
||||
@@ -58,7 +61,10 @@ mod tests {
|
||||
fn names(tr: &Tree, q: &str) -> HashSet<String> {
|
||||
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();
|
||||
(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]
|
||||
@@ -70,7 +76,10 @@ mod tests {
|
||||
("size > 1GB", &["vms", "ubuntu.qcow2", "win.vmdk"]),
|
||||
("size > 1GB AND type = file", &["ubuntu.qcow2", "win.vmdk"]),
|
||||
("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"]),
|
||||
("age > 365d", &["ubuntu.qcow2"]),
|
||||
("NOT type = dir AND size < 1k", &["notes.txt", "x.tmp"]),
|
||||
@@ -91,16 +100,32 @@ mod tests {
|
||||
let tr = tree();
|
||||
let q = parse("name = vms OR ext = qcow2", SystemTime::now()).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]
|
||||
fn parse_errors() {
|
||||
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();
|
||||
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-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);
|
||||
for bad in ["modified > 2024-02-30", "modified > 2024-01-31T10:00", "ext = 'a['"] {
|
||||
assert!(parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"), "{bad}");
|
||||
for bad in [
|
||||
"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, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0
|
||||
@@ -120,7 +155,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
+70
-17
@@ -43,7 +43,8 @@ fn field_by_name(s: &str) -> Option<Field> {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub struct Query {
|
||||
@@ -68,7 +69,12 @@ struct Parser {
|
||||
/// Compiles a query. now anchors relative ages (pass SystemTime::now()).
|
||||
pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
|
||||
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 {
|
||||
return Err("empty query".into());
|
||||
}
|
||||
@@ -77,7 +83,11 @@ pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
|
||||
if t.kind != TokKind::Eof {
|
||||
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 {
|
||||
@@ -193,7 +203,10 @@ impl Parser {
|
||||
if f == Field::Path {
|
||||
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() {
|
||||
"=" | "in" => NumOp::Eq,
|
||||
"!=" => NumOp::Ne,
|
||||
@@ -203,7 +216,10 @@ impl Parser {
|
||||
"<=" => NumOp::Le,
|
||||
_ => 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));
|
||||
}
|
||||
// String-like fields.
|
||||
@@ -244,7 +260,11 @@ impl Parser {
|
||||
} else {
|
||||
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)),
|
||||
}
|
||||
@@ -256,7 +276,9 @@ impl Parser {
|
||||
fn number(&self, f: Field, s: &str) -> Result<i64, String> {
|
||||
match f {
|
||||
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::Modified => {
|
||||
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);
|
||||
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()),
|
||||
}
|
||||
@@ -285,7 +309,11 @@ pub(super) fn name_match(v: &str) -> Expr {
|
||||
let lv = v.to_lowercase();
|
||||
if has_glob(v) {
|
||||
// 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)
|
||||
}
|
||||
@@ -365,7 +393,10 @@ fn flag_bit(s: &str) -> Option<Flags> {
|
||||
|
||||
/// Splits a leading run of digits and dots from s.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -374,8 +405,14 @@ fn split_num(s: &str) -> (&str, &str) {
|
||||
/// (textutil::set_si).
|
||||
pub fn parse_size(s: &str) -> Result<i64, String> {
|
||||
let (num, unit_raw) = split_num(s);
|
||||
let Ok(v) = num.parse::<f64>() else { return Err(format!("bad size {s:?}")) };
|
||||
let bare: f64 = if crate::textutil::si() { 1000.0 } else { 1024.0 };
|
||||
let Ok(v) = num.parse::<f64>() else {
|
||||
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() {
|
||||
"" | "b" => 1.0,
|
||||
"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.
|
||||
pub fn parse_age(s: &str) -> Result<Duration, String> {
|
||||
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;
|
||||
let secs = match unit.to_lowercase().as_str() {
|
||||
"s" | "sec" => 1.0,
|
||||
@@ -415,7 +454,11 @@ pub fn parse_age(s: &str) -> Result<Duration, String> {
|
||||
"w" => 7.0 * DAY,
|
||||
"m" | "mo" => 30.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.
|
||||
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 fixed = |i: usize, n: usize| -> Option<i64> {
|
||||
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)?);
|
||||
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;
|
||||
}
|
||||
if b.len() == 10 {
|
||||
@@ -473,7 +523,10 @@ fn parse_date(s: &str) -> Option<i64> {
|
||||
return None;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -23,14 +23,33 @@ pub enum Category {
|
||||
pub const NUM_CATEGORIES: usize = 12;
|
||||
|
||||
const CATEGORY_NAMES: [&str; NUM_CATEGORIES] = [
|
||||
"Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database",
|
||||
"Source code", "Document", "Executable", "Log", "Cache",
|
||||
"Other",
|
||||
"Image",
|
||||
"Video",
|
||||
"Audio",
|
||||
"Archive",
|
||||
"Disk image",
|
||||
"Database",
|
||||
"Source code",
|
||||
"Document",
|
||||
"Executable",
|
||||
"Log",
|
||||
"Cache",
|
||||
];
|
||||
|
||||
const ALL: [Category; NUM_CATEGORIES] = [
|
||||
Category::Other, Category::Image, Category::Video, Category::Audio, Category::Archive,
|
||||
Category::DiskImage, Category::Database, Category::Code, Category::Document,
|
||||
Category::Executable, Category::Log, Category::Cache,
|
||||
Category::Other,
|
||||
Category::Image,
|
||||
Category::Video,
|
||||
Category::Audio,
|
||||
Category::Archive,
|
||||
Category::DiskImage,
|
||||
Category::Database,
|
||||
Category::Code,
|
||||
Category::Document,
|
||||
Category::Executable,
|
||||
Category::Log,
|
||||
Category::Cache,
|
||||
];
|
||||
|
||||
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 {
|
||||
let c = ext_table().get(ext).copied();
|
||||
match c {
|
||||
_ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => Category::Cache,
|
||||
_ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => Category::Log,
|
||||
_ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => {
|
||||
Category::Cache
|
||||
}
|
||||
_ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => {
|
||||
Category::Log
|
||||
}
|
||||
Some(c) => c,
|
||||
None => Category::Other,
|
||||
}
|
||||
@@ -114,7 +137,8 @@ pub fn path_hints(path: &[u8]) -> (bool, bool) {
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
+45
-12
@@ -72,8 +72,8 @@ pub struct Node {
|
||||
/// Single path component (raw bytes); the root holds the full root path.
|
||||
pub name: Box<[u8]>,
|
||||
|
||||
pub size: i64, // own logical bytes
|
||||
pub alloc: i64, // own allocated bytes
|
||||
pub size: i64, // own logical bytes
|
||||
pub alloc: i64, // own allocated bytes
|
||||
pub tot_size: i64, // aggregates (hard-link duplicates contribute 0)
|
||||
pub tot_alloc: i64,
|
||||
pub mtime: i64, // unix nanoseconds
|
||||
@@ -81,7 +81,7 @@ pub struct Node {
|
||||
pub files: u32, // recursive counts beneath (dirs excludes self)
|
||||
pub dirs: u32,
|
||||
pub errors: u32, // recursive error count beneath and including self
|
||||
pub mode: u32, // Go fs.FileMode bit layout (see platform::mode)
|
||||
pub mode: u32, // Go fs.FileMode bit layout (see platform::mode)
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
pub nlink: u32,
|
||||
@@ -129,7 +129,10 @@ struct Chunked<T> {
|
||||
|
||||
impl<T> Chunked<T> {
|
||||
fn new() -> Self {
|
||||
Chunked { c: Vec::new(), n: 0 }
|
||||
Chunked {
|
||||
c: Vec::new(),
|
||||
n: 0,
|
||||
}
|
||||
}
|
||||
fn add(&mut self, v: T) -> u32 {
|
||||
if self.n & (CHUNK_SIZE - 1) == 0 && self.n >> CHUNK_BITS == self.c.len() {
|
||||
@@ -202,9 +205,22 @@ impl Tree {
|
||||
next_sibling: NO_NODE,
|
||||
dir: 0,
|
||||
name: name.into(),
|
||||
size: 0, alloc: 0, 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,
|
||||
size: 0,
|
||||
alloc: 0,
|
||||
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 {
|
||||
n.dir = self.dirs.add([0; NUM_CATEGORIES]);
|
||||
@@ -274,7 +290,10 @@ impl Tree {
|
||||
|
||||
/// Iterates the children of id (in reverse insertion order).
|
||||
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 {
|
||||
@@ -408,9 +427,17 @@ struct Shallow {
|
||||
impl Node {
|
||||
fn clone_shallow(&self) -> Shallow {
|
||||
Shallow {
|
||||
parent: self.parent, dir: self.dir, 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,
|
||||
parent: self.parent,
|
||||
dir: self.dir,
|
||||
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]
|
||||
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!(category_for("qcow2", false, false), Category::DiskImage);
|
||||
|
||||
+16
-3
@@ -47,7 +47,10 @@ pub struct TopK {
|
||||
|
||||
impl 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) {
|
||||
@@ -67,7 +70,11 @@ impl TopK {
|
||||
/// The collected IDs, largest key first (ties by ID).
|
||||
pub fn sorted(self) -> Vec<NodeId> {
|
||||
// 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,
|
||||
/// 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);
|
||||
self.walk(root, |id, n| {
|
||||
if keep(n) {
|
||||
|
||||
+41
-11
@@ -18,13 +18,25 @@ pub const NUM_ERR_KINDS: usize = 5;
|
||||
|
||||
impl ErrKind {
|
||||
pub fn from_u8(v: u8) -> ErrKind {
|
||||
[ErrKind::Permission, ErrKind::Vanished, ErrKind::Io, ErrKind::Loop, ErrKind::Other]
|
||||
.get(v as usize)
|
||||
.copied()
|
||||
.unwrap_or(ErrKind::Other)
|
||||
[
|
||||
ErrKind::Permission,
|
||||
ErrKind::Vanished,
|
||||
ErrKind::Io,
|
||||
ErrKind::Loop,
|
||||
ErrKind::Other,
|
||||
]
|
||||
.get(v as usize)
|
||||
.copied()
|
||||
.unwrap_or(ErrKind::Other)
|
||||
}
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +58,7 @@ pub fn classify(err: &io::Error) -> ErrKind {
|
||||
/// One recorded scan error.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ErrorRecord {
|
||||
pub node: NodeId, // node the error is attached to (the directory for listing failures)
|
||||
pub node: NodeId, // node the error is attached to (the directory for listing failures)
|
||||
pub name: Vec<u8>, // entry name when no node was created (e.g. vanished)
|
||||
pub kind: ErrKind,
|
||||
pub msg: String,
|
||||
@@ -89,11 +101,29 @@ pub struct Stats {
|
||||
impl Default for Stats {
|
||||
fn default() -> Self {
|
||||
Stats {
|
||||
root: Vec::new(), start: SystemTime::now(), end: None, complete: false, 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(),
|
||||
root: Vec::new(),
|
||||
start: SystemTime::now(),
|
||||
end: None,
|
||||
complete: false,
|
||||
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
@@ -7,11 +7,36 @@ use std::path::Path;
|
||||
|
||||
/// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`).
|
||||
const VALUE_FLAGS: &[&str] = &[
|
||||
"workers", "follow-symlinks", "depth", "top", "largest-files", "save", "snapshot", "load", "scan",
|
||||
"theme", "color", "log", "log-level", "exclude",
|
||||
"workers",
|
||||
"follow-symlinks",
|
||||
"depth",
|
||||
"top",
|
||||
"largest-files",
|
||||
"save",
|
||||
"snapshot",
|
||||
"load",
|
||||
"scan",
|
||||
"theme",
|
||||
"color",
|
||||
"log",
|
||||
"log-level",
|
||||
"exclude",
|
||||
];
|
||||
/// 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() {
|
||||
let n = brand::NAME;
|
||||
@@ -54,7 +79,10 @@ Flags:
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -74,7 +102,11 @@ fn run(args: Vec<String>) -> i32 {
|
||||
positional.extend(it.by_ref());
|
||||
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);
|
||||
continue;
|
||||
};
|
||||
@@ -90,10 +122,17 @@ fn run(args: Vec<String>) -> i32 {
|
||||
match inline.as_deref().map(str::parse::<bool>) {
|
||||
None => bools.insert(n, true),
|
||||
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) {
|
||||
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" {
|
||||
excludes.push(v);
|
||||
} else {
|
||||
@@ -107,11 +146,20 @@ fn run(args: Vec<String>) -> i32 {
|
||||
let b = |n: &str| bools.get(n).copied();
|
||||
let v = |n: &str| vals.get(n).cloned();
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
if let Some(e) = cfg_err {
|
||||
@@ -143,7 +191,11 @@ fn run(args: Vec<String>) -> i32 {
|
||||
color: v("color").unwrap_or_else(|| "auto".into()),
|
||||
load: v("load").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())
|
||||
};
|
||||
if let Some(p) = v("scan") {
|
||||
@@ -158,7 +210,10 @@ fn run(args: Vec<String>) -> i32 {
|
||||
if positional.len() > 1 {
|
||||
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);
|
||||
} else {
|
||||
cfg.paths = positional;
|
||||
@@ -168,14 +223,17 @@ fn run(args: Vec<String>) -> i32 {
|
||||
Ok(w) => w,
|
||||
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,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
cfg.scan_opts = scan::Options {
|
||||
workers,
|
||||
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,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+120
-24
@@ -117,7 +117,12 @@ fn cvt(r: libc::c_int) -> io::Result<libc::c_int> {
|
||||
|
||||
fn open_dir(path: &[u8]) -> io::Result<OwnedFd> {
|
||||
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) })
|
||||
}
|
||||
|
||||
@@ -129,7 +134,15 @@ fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result<Meta> {
|
||||
if !NO_STATX.load(Ordering::Relaxed) {
|
||||
let mut st: libc::statx = unsafe { std::mem::zeroed() };
|
||||
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 {
|
||||
let mut m = Meta {
|
||||
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
|
||||
/// each entry relative to the directory fd (no repeated path resolution).
|
||||
/// 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 raw = fd.as_raw_fd();
|
||||
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".." {
|
||||
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();
|
||||
out.push(m);
|
||||
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
|
||||
/// blocking open(2) forever; callers still check the file type.
|
||||
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
|
||||
/// from the terminal so it cannot disturb the TUI.
|
||||
fn start_detached(name: &str, arg: &Path) -> io::Result<()> {
|
||||
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());
|
||||
Ok(())
|
||||
}
|
||||
@@ -406,8 +434,16 @@ pub const TRASH_NAME: &str = "trash";
|
||||
pub fn reveal(path: &Path) -> io::Result<()> {
|
||||
check_abs(path)?;
|
||||
let md = std::fs::symlink_metadata(path)?;
|
||||
let target = if md.is_dir() { path } else { path.parent().unwrap_or(path) };
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
let target = if md.is_dir() {
|
||||
path
|
||||
} else {
|
||||
path.parent().unwrap_or(path)
|
||||
};
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
start_detached(opener, target)
|
||||
}
|
||||
|
||||
@@ -417,7 +453,8 @@ pub fn headless() -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
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)> {
|
||||
@@ -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> {
|
||||
let mut st: libc::stat = unsafe { std::mem::zeroed() };
|
||||
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));
|
||||
}
|
||||
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 cbase = cstr(base.as_bytes())?;
|
||||
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<()> {
|
||||
@@ -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(|_| ());
|
||||
}
|
||||
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) };
|
||||
// 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".." {
|
||||
let t = unsafe { (*ent).d_type };
|
||||
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,
|
||||
};
|
||||
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())?;
|
||||
check_expect(dfd.as_raw_fd(), &cbase, want)?;
|
||||
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
|
||||
@@ -529,7 +585,9 @@ fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> {
|
||||
fn mount_top(path: &Path, dev: u64) -> PathBuf {
|
||||
let mut cur = path.parent().unwrap_or(path).to_path_buf();
|
||||
loop {
|
||||
let Some(parent) = cur.parent() else { return cur };
|
||||
let Some(parent) = cur.parent() else {
|
||||
return cur;
|
||||
};
|
||||
match std::fs::metadata(parent) {
|
||||
Ok(m) if m.dev() == dev => cur = parent.to_path_buf(),
|
||||
_ => 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 tm: libc::tm = unsafe { std::mem::zeroed() };
|
||||
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
|
||||
@@ -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()) {
|
||||
Some(d) => PathBuf::from(d),
|
||||
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")
|
||||
}
|
||||
};
|
||||
@@ -575,21 +643,31 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
|
||||
_ => {
|
||||
let top = mount_top(path, dev);
|
||||
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;
|
||||
if !shared {
|
||||
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) {
|
||||
// On a shared filesystem another user could plant a symlink or a
|
||||
// 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"));
|
||||
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"))?;
|
||||
for i in 1..10000 {
|
||||
@@ -604,12 +682,23 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
|
||||
let mut info_name = name.clone();
|
||||
info_name.push(".trashinfo");
|
||||
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,
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
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);
|
||||
if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) {
|
||||
let _ = std::fs::remove_file(&ip);
|
||||
@@ -638,7 +727,14 @@ mod tests {
|
||||
std::fs::write(d.join("sub/inner/f"), b"x").unwrap();
|
||||
let uid = unsafe { libc::getuid() };
|
||||
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());
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
+211
-40
@@ -38,7 +38,9 @@ pub fn parse_follow(s: &str) -> Result<FollowMode, String> {
|
||||
"none" | "" => Ok(FollowMode::None),
|
||||
"same-filesystem" | "same-fs" => Ok(FollowMode::SameFs),
|
||||
"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)),
|
||||
_ => match mode.trim().parse::<usize>() {
|
||||
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 {
|
||||
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.
|
||||
@@ -235,7 +242,12 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result<Scann
|
||||
t.stats.complete = true;
|
||||
t.stats.end = Some(SystemTime::now());
|
||||
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;
|
||||
// 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();
|
||||
}
|
||||
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 workers = opts.workers;
|
||||
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(),
|
||||
visited,
|
||||
};
|
||||
std::thread::Builder::new().name("scan-controller".into()).spawn(move || {
|
||||
guard(|| c.run(cancel));
|
||||
drop(done_tx);
|
||||
})?;
|
||||
Ok(Scanner { tree, counters, workers, done })
|
||||
std::thread::Builder::new()
|
||||
.name("scan-controller".into())
|
||||
.spawn(move || {
|
||||
guard(|| c.run(cancel));
|
||||
drop(done_tx);
|
||||
})?;
|
||||
Ok(Scanner {
|
||||
tree,
|
||||
counters,
|
||||
workers,
|
||||
done,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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()
|
||||
};
|
||||
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 mut handles = Vec::with_capacity(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(
|
||||
std::thread::Builder::new()
|
||||
.name(format!("scan-worker-{i}"))
|
||||
@@ -332,7 +372,8 @@ impl Controller {
|
||||
let op = sel.select();
|
||||
let i = op.index();
|
||||
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;
|
||||
if self.head > 4096 && self.head * 2 > self.pending.len() {
|
||||
self.pending.drain(..self.head);
|
||||
@@ -366,7 +407,9 @@ impl Controller {
|
||||
next = None;
|
||||
}
|
||||
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);
|
||||
for h in handles {
|
||||
@@ -391,7 +434,13 @@ impl Controller {
|
||||
fn make_job(&self, (id, dev): (NodeId, u64)) -> Arc<Job> {
|
||||
let path = self.tree.read().unwrap().path_bytes(id);
|
||||
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
|
||||
@@ -399,7 +448,9 @@ impl Controller {
|
||||
fn apply(&mut self, t: &mut Tree, r: ScanResult) -> bool {
|
||||
let j = &*r.job;
|
||||
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 {
|
||||
self.add_entry(t, j, m, &mut d);
|
||||
}
|
||||
@@ -413,7 +464,12 @@ impl Controller {
|
||||
}
|
||||
Some(ReadErr::Io(e)) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -427,7 +483,9 @@ impl Controller {
|
||||
fn excluded(&self, path: &[u8], name: &[u8]) -> bool {
|
||||
self.excludes.iter().any(|(is_path, p)| {
|
||||
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 {
|
||||
platform::glob_match(p, name)
|
||||
}
|
||||
@@ -438,7 +496,12 @@ impl Controller {
|
||||
if let Some(e) = &m.err {
|
||||
let k = inv::classify(e);
|
||||
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;
|
||||
return;
|
||||
}
|
||||
@@ -446,7 +509,12 @@ impl Controller {
|
||||
let n = t.node_mut(id);
|
||||
n.flags |= inv::FLAG_ERROR;
|
||||
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.files += 1;
|
||||
t.stats.others += 1;
|
||||
@@ -472,7 +540,12 @@ impl Controller {
|
||||
t.stats.broken_links += 1;
|
||||
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);
|
||||
kind = Kind::Dir;
|
||||
}
|
||||
@@ -596,7 +669,11 @@ pub(crate) mod tests {
|
||||
impl TempDir {
|
||||
pub fn new(tag: &str) -> TempDir {
|
||||
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();
|
||||
TempDir(p)
|
||||
}
|
||||
@@ -609,7 +686,14 @@ pub(crate) mod tests {
|
||||
|
||||
/// Creates a fan-out tree: (files, dirs, bytes).
|
||||
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 {
|
||||
let sz = (i + 1) * 100;
|
||||
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 {
|
||||
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();
|
||||
t
|
||||
}
|
||||
|
||||
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]
|
||||
fn counts_and_aggregates() {
|
||||
let root = TempDir::new("scan");
|
||||
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);
|
||||
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!(t.stats.complete && !t.stats.incomplete());
|
||||
for i in 0..t.len() as NodeId {
|
||||
@@ -661,7 +760,11 @@ pub(crate) mod tests {
|
||||
}
|
||||
let before = totals(&t);
|
||||
t.recompute();
|
||||
assert_eq!(before, totals(&t), "incremental aggregates differ from recompute");
|
||||
assert_eq!(
|
||||
before,
|
||||
totals(&t),
|
||||
"incremental aggregates differ from recompute"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -669,8 +772,22 @@ pub(crate) mod tests {
|
||||
let root = TempDir::new("bp");
|
||||
let (nf, nd, _) = mk_tree(&root.0, 6, 4, 3);
|
||||
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() });
|
||||
assert_eq!((t.node(0).files as i64, t.node(0).dirs as i64), (nf, nd), "workers={w}");
|
||||
let t = run_scan(
|
||||
&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");
|
||||
mk_tree(&root.0, 8, 3, 2);
|
||||
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();
|
||||
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();
|
||||
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("nowhere", root.0.join("broken")).unwrap();
|
||||
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);
|
||||
assert!(r.files > 0 && r.tot_size > 5000, "{f:?}");
|
||||
assert_eq!(t.stats.broken_links, 1);
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -713,7 +852,13 @@ pub(crate) mod tests {
|
||||
std::fs::write(&p, vec![0u8; 100000]).unwrap();
|
||||
std::fs::create_dir(root.0.join("d")).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!(t.node(0).tot_size < 200000);
|
||||
}
|
||||
@@ -729,7 +874,13 @@ pub(crate) mod tests {
|
||||
std::fs::create_dir(&locked).unwrap();
|
||||
std::fs::write(locked.join("f"), b"x").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();
|
||||
assert_eq!(t.stats.err_counts[inv::ErrKind::Permission as usize], 1);
|
||||
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("skip/c.txt"), b"x").unwrap();
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_names_preserved() {
|
||||
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 {
|
||||
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();
|
||||
for n in names {
|
||||
assert!(got.contains(n), "missing {n:?}");
|
||||
|
||||
+26
-5
@@ -28,7 +28,8 @@ pub fn compare(old: Tree, new: &Tree) -> Diff {
|
||||
if !old.node(o).is_dir() || !new.node(n).is_dir() {
|
||||
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) {
|
||||
if let Some(oid) = by_name.remove(&c.name[..]) {
|
||||
stack.push((oid, id));
|
||||
@@ -43,7 +44,12 @@ pub fn compare(old: Tree, new: &Tree) -> Diff {
|
||||
}
|
||||
}
|
||||
removed.sort_unstable();
|
||||
Diff { old, old_of, removed, removed_under }
|
||||
Diff {
|
||||
old,
|
||||
old_of,
|
||||
removed,
|
||||
removed_under,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies a node.
|
||||
@@ -115,7 +121,10 @@ impl Diff {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
out.truncate(k);
|
||||
}
|
||||
|
||||
+292
-67
@@ -31,7 +31,9 @@ mod compare;
|
||||
pub use compare::*;
|
||||
|
||||
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 flate2::{bufread::GzDecoder, write::GzEncoder, Compression};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -91,7 +93,10 @@ struct Meta {
|
||||
/// Reports whether path starts with the snapshot magic.
|
||||
pub fn is_snapshot(path: &Path) -> bool {
|
||||
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.
|
||||
@@ -140,14 +145,21 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> {
|
||||
bw.write_all(brand::SNAPSHOT_MAGIC)?;
|
||||
bw.write_all(&VERSION.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 m = Meta {
|
||||
root: String::from_utf8_lossy(&st.root).into_owned(),
|
||||
created: GoTime::from_system(SystemTime::now()),
|
||||
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,
|
||||
cancelled: st.cancelled,
|
||||
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).
|
||||
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 {
|
||||
let p = dir.join(format!(".mapsize-{}.tmp", seed.wrapping_add(i.wrapping_mul(7919))));
|
||||
match OpenOptions::new().write(true).create_new(true).mode(0o600).open(&p) {
|
||||
let p = dir.join(format!(
|
||||
".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)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
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 {
|
||||
@@ -257,7 +284,12 @@ const UNEXPECTED_EOF: &str = "unexpected EOF";
|
||||
|
||||
impl 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.
|
||||
@@ -320,7 +352,8 @@ impl Body {
|
||||
|
||||
fn bytes_n(&mut self, n: u64) -> Result<Vec<u8>, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
let mut br = BufReader::with_capacity(1 << 20, input);
|
||||
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 {
|
||||
return Err(format!("snapshot: not a {} snapshot", brand::NAME));
|
||||
}
|
||||
let v = u16::from_le_bytes([hdr[8], hdr[9]]);
|
||||
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 {
|
||||
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);
|
||||
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?;
|
||||
// The checksum follows the gzip stream.
|
||||
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()[..] {
|
||||
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.cancelled = m.cancelled;
|
||||
(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;
|
||||
}
|
||||
(st.excluded, st.skipped_mounts, st.virtual_skipped) = (m.excluded, m.skipped_mounts, m.virtual_skipped);
|
||||
(st.loops_skipped, st.broken_links, st.hardlink_dups, st.unscanned) =
|
||||
(m.loops_skipped, m.broken_links, m.hardlink_dups, m.unscanned);
|
||||
(st.excluded, st.skipped_mounts, st.virtual_skipped) =
|
||||
(m.excluded, m.skipped_mounts, m.virtual_skipped);
|
||||
(
|
||||
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.one_file_system = m.one_file_system;
|
||||
st.follow = m.follow;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
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;
|
||||
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);
|
||||
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 name = r.str(MAX_NAME_LEN, "error name")?;
|
||||
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.
|
||||
if r.byte() != Err(EOF.into()) {
|
||||
@@ -571,19 +639,35 @@ const GO_ZERO_SECS: i64 = -62_135_596_800;
|
||||
|
||||
impl Default for GoTime {
|
||||
fn default() -> Self {
|
||||
GoTime { secs: GO_ZERO_SECS, nanos: 0, off: 0 }
|
||||
GoTime {
|
||||
secs: GO_ZERO_SECS,
|
||||
nanos: 0,
|
||||
off: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GoTime {
|
||||
fn from_system(t: SystemTime) -> GoTime {
|
||||
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) => {
|
||||
let d = e.duration();
|
||||
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) };
|
||||
GoTime { secs, nanos, off: 0 }
|
||||
let (secs, nanos) = if n == 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 {
|
||||
UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos)
|
||||
} 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 (y, mo, d) = civil_from_days(secs.div_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 {
|
||||
s += format!(".{:09}", self.nanos).trim_end_matches('0');
|
||||
}
|
||||
@@ -619,7 +709,12 @@ impl GoTime {
|
||||
return s + "Z";
|
||||
}
|
||||
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").
|
||||
@@ -627,12 +722,27 @@ impl GoTime {
|
||||
let b = s.as_bytes();
|
||||
let num = |r: std::ops::Range<usize>| -> Option<i64> {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
@@ -666,13 +776,26 @@ impl GoTime {
|
||||
_ => return None,
|
||||
};
|
||||
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.
|
||||
fn local_minutes(self) -> String {
|
||||
let Some(tm) = localtime(self.secs) else { 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)
|
||||
let Some(tm) = localtime(self.secs) else {
|
||||
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.
|
||||
match Option::<String>::deserialize(d)? {
|
||||
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.recompute();
|
||||
(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
|
||||
}
|
||||
|
||||
@@ -755,8 +884,12 @@ mod tests {
|
||||
for i in 0..src.len() as NodeId {
|
||||
let (a, b) = (src.node(i), got.node(i));
|
||||
assert!(
|
||||
a.name == b.name && 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),
|
||||
a.name == b.name
|
||||
&& 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:?}"
|
||||
);
|
||||
}
|
||||
@@ -802,8 +935,14 @@ mod tests {
|
||||
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/c"), Some(&Status::Added), "{ch:?}");
|
||||
assert!(!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");
|
||||
assert!(
|
||||
!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 {
|
||||
@@ -813,7 +952,11 @@ mod tests {
|
||||
}
|
||||
|
||||
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.buf
|
||||
}
|
||||
@@ -824,7 +967,10 @@ mod tests {
|
||||
assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v));
|
||||
}
|
||||
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.
|
||||
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);
|
||||
@@ -837,13 +983,38 @@ mod tests {
|
||||
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.format(), "2026-10-08T10:40:00.123456789+01:00");
|
||||
assert_eq!(GoTime { off: 0, ..t }.format(), "2026-10-08T09:40:00.123456789Z");
|
||||
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 { off: 0, ..t }.format(),
|
||||
"2026-10-08T09:40:00.123456789Z"
|
||||
);
|
||||
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::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::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}");
|
||||
}
|
||||
}
|
||||
@@ -852,21 +1023,34 @@ mod tests {
|
||||
/// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}).
|
||||
#[test]
|
||||
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);
|
||||
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.len(), 8);
|
||||
assert!(t.stats.root.ends_with(b"/tree"));
|
||||
assert!(t.stats.end.is_some() && t.stats.complete);
|
||||
assert!(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!(
|
||||
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.ext_name(t.node(iso)), "iso");
|
||||
assert!(t.path_string(iso).ends_with("/tree/a/x.iso"));
|
||||
// And it survives a Rust round trip unchanged.
|
||||
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.
|
||||
@@ -877,7 +1061,11 @@ mod tests {
|
||||
out.extend_from_slice(brand::SNAPSHOT_MAGIC);
|
||||
out.extend_from_slice(&VERSION.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.bytes(meta.as_bytes()).unwrap();
|
||||
w.uvarint(1).unwrap();
|
||||
@@ -908,49 +1096,80 @@ mod tests {
|
||||
}
|
||||
|
||||
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]
|
||||
fn deep_chain_rejected() {
|
||||
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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_limited() {
|
||||
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!(body_limit(flat.len() as i64) >= 64 << 20, "body_limit floor");
|
||||
assert!(
|
||||
body_limit(flat.len() as i64) >= 64 << 20,
|
||||
"body_limit floor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_sizes_do_not_wrap() {
|
||||
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();
|
||||
assert!(tr.node(0).tot_size >= 0, "aggregate wrapped negative");
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_metadata_sanitised_in_summary() {
|
||||
let data = craft(1, r#"{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}"#, |_, w| {
|
||||
node(w, 0, Kind::Dir, "", 0)
|
||||
});
|
||||
let data = craft(
|
||||
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();
|
||||
tr.stats.excluded = 1;
|
||||
let mut b = Vec::new();
|
||||
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
|
||||
@@ -971,7 +1190,9 @@ mod tests {
|
||||
fs::write(&p, &data).unwrap();
|
||||
let res = load_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}");
|
||||
}
|
||||
|
||||
@@ -983,7 +1204,11 @@ mod tests {
|
||||
save_file(&p, &sample()).unwrap();
|
||||
assert!(is_snapshot(&p));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-6
@@ -41,7 +41,11 @@ pub fn sanitize(s: &str) -> String {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -53,7 +57,11 @@ pub fn clusters(s: &str, mut f: impl FnMut(&str, usize) -> bool) {
|
||||
for c in s.graphemes(true) {
|
||||
let mut chars = c.chars();
|
||||
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 {
|
||||
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"];
|
||||
|
||||
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 mut v = (n as f64).abs();
|
||||
let mut i = 0;
|
||||
@@ -295,7 +307,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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!(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 m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
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
|
||||
@@ -365,7 +389,10 @@ mod time_tests {
|
||||
#[test]
|
||||
fn formats() {
|
||||
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_millis(250)), "250ms");
|
||||
assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s");
|
||||
|
||||
+93
-15
@@ -33,7 +33,12 @@ impl Rect {
|
||||
|
||||
/// Shrinks by n cells on each side.
|
||||
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() {
|
||||
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();
|
||||
if its.is_empty() || total <= 0.0 {
|
||||
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)));
|
||||
let scale = bounds.w as f64 * bounds.h as f64 / total;
|
||||
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());
|
||||
for (it, f) in its.iter().zip(frs) {
|
||||
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 y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h);
|
||||
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
|
||||
@@ -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;
|
||||
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];
|
||||
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;
|
||||
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];
|
||||
xx += ww;
|
||||
}
|
||||
@@ -203,7 +234,10 @@ pub(crate) mod tests {
|
||||
for bl in blocks {
|
||||
let br = bl.rect;
|
||||
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 x in br.x..br.x + br.w {
|
||||
let i = ((y - b.y) * b.w + (x - b.x)) as usize;
|
||||
@@ -222,7 +256,15 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn squarify_basic() {
|
||||
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);
|
||||
assert_eq!(blocks.len(), items.len());
|
||||
check_partition(&blocks, b, true);
|
||||
@@ -230,7 +272,11 @@ pub(crate) mod tests {
|
||||
for bl in &blocks {
|
||||
let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0;
|
||||
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.
|
||||
let mut rev = items.to_vec();
|
||||
@@ -241,9 +287,18 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn squarify_edge_cases() {
|
||||
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());
|
||||
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);
|
||||
if !b.empty() {
|
||||
check_partition(&bl, b, true);
|
||||
@@ -258,7 +313,13 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn resize_recomputes() {
|
||||
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);
|
||||
check_partition(&bl, b, true);
|
||||
assert_eq!(bl.len(), 3, "{b:?}");
|
||||
@@ -270,7 +331,11 @@ pub(crate) mod tests {
|
||||
fn squarify_random_partitions() {
|
||||
let mut rng = Rng::new(1);
|
||||
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)
|
||||
.map(|i| {
|
||||
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];
|
||||
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(&[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]
|
||||
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, 10, 2), None);
|
||||
}
|
||||
|
||||
+60
-11
@@ -33,10 +33,38 @@ pub fn neighbour(blocks: &[Block], sel: i64, d: Direction) -> Option<i64> {
|
||||
}
|
||||
let c = b.rect;
|
||||
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::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),
|
||||
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::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 {
|
||||
continue;
|
||||
@@ -68,7 +96,10 @@ mod tests {
|
||||
use super::Direction::*;
|
||||
|
||||
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:
|
||||
@@ -86,9 +117,18 @@ mod tests {
|
||||
let d = blk(4, 4, 4, 12, 4);
|
||||
let bs = [d, c, b, a]; // order must not matter
|
||||
let cases = [
|
||||
(1, Right, Some(2)), (2, Left, Some(1)), (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),
|
||||
(1, Right, Some(2)),
|
||||
(2, Left, Some(1)),
|
||||
(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 {
|
||||
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 r2 = blk(12, 10, 3, 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");
|
||||
}
|
||||
|
||||
@@ -122,7 +166,9 @@ mod tests {
|
||||
let bl = squarify(&items, r(0, 0, 120, 40));
|
||||
for s in &bl {
|
||||
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 s = s.rect;
|
||||
// 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,
|
||||
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
@@ -36,11 +36,25 @@ impl Model {
|
||||
}
|
||||
let n = tr.node(id);
|
||||
let path = tr.path(id);
|
||||
let want = Expect { uid: n.uid, 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()];
|
||||
let want = Expect {
|
||||
uid: n.uid,
|
||||
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() {
|
||||
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());
|
||||
self.open_modal(Box::new(ConfirmModal {
|
||||
ttl: "Delete permanently? Are you sure?".into(),
|
||||
@@ -50,13 +64,21 @@ impl Model {
|
||||
let t = m.tree.clone();
|
||||
Cmd::run(move || {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
ttl: "Move to trash?".into(),
|
||||
body,
|
||||
@@ -65,14 +87,25 @@ impl Model {
|
||||
let t = m.tree.clone();
|
||||
Cmd::run(move || {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return self.warn(format!("Delete failed: {}", textutil::sanitize(&e)));
|
||||
}
|
||||
@@ -84,7 +117,13 @@ impl Model {
|
||||
let name = {
|
||||
let mut t = self.tree.write().unwrap();
|
||||
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() {
|
||||
d.dirs -= 1;
|
||||
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(),
|
||||
"Only identical hashes are reported as duplicates.".into(),
|
||||
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(),
|
||||
on_yes: Some(Box::new(|m: &mut Model| {
|
||||
let cancel = Cancel::new();
|
||||
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();
|
||||
let t = m.tree.clone();
|
||||
let tick = m.start_ticking();
|
||||
Cmd::batch(vec![
|
||||
tick,
|
||||
Cmd::run(move || {
|
||||
let g = f.find(&cancel, &t, duplicate::Options { min_size: 1, workers: 4 });
|
||||
Msg::DupDone { finder: f, groups: g }
|
||||
let g = f.find(
|
||||
&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) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
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.err = groups.is_none().then(|| "cancelled".to_string());
|
||||
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.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) {
|
||||
let t = m.theme.clone();
|
||||
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;
|
||||
};
|
||||
if d.running {
|
||||
let p = d.finder.progress();
|
||||
cv.fill(r, " ", t.base());
|
||||
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(r.x, y + 2, r.w,
|
||||
&format!("{} candidate files in {} size groups", textutil::count(p.candidates), textutil::count(p.candidate_groups)), t.base());
|
||||
cv.text_centered(
|
||||
r.x,
|
||||
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 {
|
||||
let frac = p.bytes_hashed as f64 / p.bytes_to_hash as f64;
|
||||
let bw = 60.min(r.w - 10);
|
||||
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,
|
||||
&format!("{} of {} hashed · {} skipped", textutil::size(p.bytes_hashed), textutil::size(p.bytes_to_hash), textutil::count(p.skipped)),
|
||||
t.muted());
|
||||
cv.text_centered(
|
||||
r.x,
|
||||
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;
|
||||
}
|
||||
@@ -211,22 +314,44 @@ impl DupView {
|
||||
self.rows.push((gi, NO_NODE));
|
||||
let hash: String = g.hash[..8].iter().map(|b| format!("{b:02x}")).collect();
|
||||
rows.push(TableRow {
|
||||
cells: vec![textutil::size(g.wasted()), 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))],
|
||||
cells: vec![
|
||||
textutil::size(g.wasted()),
|
||||
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,
|
||||
..Default::default()
|
||||
});
|
||||
for &id in &g.files {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+82
-12
@@ -40,7 +40,11 @@ impl Color {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -82,7 +86,10 @@ impl Style {
|
||||
Style { fg, bg, attr: 0 }
|
||||
}
|
||||
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(crate) const BOX_LIGHT: BoxChars = BoxChars { h: "─", v: "│", tl: "┌", 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: "#" };
|
||||
pub(crate) const BOX_LIGHT: BoxChars = BoxChars {
|
||||
h: "─",
|
||||
v: "│",
|
||||
tl: "┌",
|
||||
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.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -154,7 +196,12 @@ impl Canvas {
|
||||
/// Creates a canvas filled with spaces in style st.
|
||||
pub fn new(w: i32, h: i32, st: Style) -> Canvas {
|
||||
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 {
|
||||
@@ -180,7 +227,12 @@ impl Canvas {
|
||||
self.big.push(s.to_string());
|
||||
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
|
||||
@@ -204,7 +256,12 @@ impl Canvas {
|
||||
}
|
||||
self.c[row + xu] = self.make(s, w, st);
|
||||
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) {
|
||||
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 {
|
||||
b.push_str(code);
|
||||
}
|
||||
@@ -351,7 +414,14 @@ fn sgr(b: &mut String, st: Style, depth: Depth) {
|
||||
}
|
||||
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);
|
||||
|
||||
+5
-1
@@ -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 (mut best, mut best_d) = (16, i32::MAX);
|
||||
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);
|
||||
if d < best_d {
|
||||
(best, best_d) = (16 + i, d);
|
||||
|
||||
+10
-3
@@ -39,8 +39,11 @@ impl Model {
|
||||
if let Some(k) = self.kids_cache.get(&id) {
|
||||
return k.clone();
|
||||
}
|
||||
let mut v: Vec<(i64, NodeId)> =
|
||||
tr.children(id).map(|(c, _)| (self.size_of(tr, c), c)).filter(|&(s, _)| s > 0).collect();
|
||||
let mut v: Vec<(i64, NodeId)> = tr
|
||||
.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)));
|
||||
let k = Rc::new(Kids {
|
||||
total: v.iter().map(|e| e.0).sum(),
|
||||
@@ -119,7 +122,11 @@ impl Model {
|
||||
let prev = self.zoom;
|
||||
self.zoom = dir;
|
||||
// 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.zoomed = true;
|
||||
self.dirty = true;
|
||||
|
||||
+161
-26
@@ -2,8 +2,8 @@
|
||||
|
||||
use super::canvas::{Canvas, Style, BOLD};
|
||||
use super::model::{Cmd, Model};
|
||||
use super::table::{col, Table, TableRow};
|
||||
use super::san;
|
||||
use super::table::{col, Table, TableRow};
|
||||
use crate::inventory::{ErrKind, Tree, FLAG_ALLOC_UNKNOWN};
|
||||
use crate::textutil;
|
||||
use crate::treemap::Rect;
|
||||
@@ -57,66 +57,193 @@ impl InfoView {
|
||||
} else {
|
||||
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;
|
||||
line(cv, &mut y, "Root", &san(&st.root), value);
|
||||
line(cv, &mut y, "State", state, state_st);
|
||||
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;
|
||||
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, " Directories", &textutil::count(st.dirs), value);
|
||||
line(cv, &mut y, " Symlinks", &textutil::count(st.symlinks), value);
|
||||
line(
|
||||
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, "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);
|
||||
line(
|
||||
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();
|
||||
if st.from_snapshot.is_empty() {
|
||||
line(cv, &mut y, "Elapsed", &textutil::duration(el), value);
|
||||
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());
|
||||
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");
|
||||
let mut any_issue = false;
|
||||
for (k, &c) in st.err_counts.iter().enumerate() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (k, v) in [("Unscanned dirs", st.unscanned), ("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)]
|
||||
{
|
||||
for (k, v) in [
|
||||
("Unscanned dirs", st.unscanned),
|
||||
("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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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");
|
||||
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, "One filesystem", &st.one_file_system.to_string(), value);
|
||||
line(cv, &mut y, "Follow symlinks", &textutil::sanitize(&st.follow), value);
|
||||
line(
|
||||
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, "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) {
|
||||
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).
|
||||
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 {
|
||||
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 {
|
||||
return;
|
||||
@@ -132,7 +259,11 @@ impl InfoView {
|
||||
}
|
||||
TableRow {
|
||||
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,
|
||||
..Default::default()
|
||||
}
|
||||
@@ -142,7 +273,11 @@ impl InfoView {
|
||||
if (st.errors.len() as i64) < st.total_errors() {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+23
-7
@@ -1,7 +1,7 @@
|
||||
//! 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::model::{is_group, Cmd, Key, Model, Msg};
|
||||
use super::theme::{load_theme, theme_names};
|
||||
use super::views::View;
|
||||
use super::{fmt_stamp_now, san};
|
||||
@@ -53,13 +53,19 @@ impl Model {
|
||||
}
|
||||
"tab" => self.set_view((self.view + 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),
|
||||
"space" | "right" => return self.zoom_into_selection(&tr),
|
||||
"backspace" | "left" => self.zoom_out(&tr),
|
||||
"home" => self.zoom_to(&tr, 0),
|
||||
"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();
|
||||
let mut cmds = Vec::new();
|
||||
if self.search.query.is_some() {
|
||||
@@ -88,7 +94,10 @@ impl Model {
|
||||
"d" | "delete" => return self.request_trash(&tr),
|
||||
"T" => {
|
||||
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 mut th = load_theme(names[(cur + 1) % names.len()], &self.opts.settings);
|
||||
th.ascii = ascii;
|
||||
@@ -200,7 +209,10 @@ impl Model {
|
||||
return self.info(msg);
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -210,7 +222,9 @@ impl Model {
|
||||
Cmd::run(move || {
|
||||
let err = {
|
||||
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 }
|
||||
})
|
||||
@@ -224,5 +238,7 @@ pub(crate) fn default_snapshot_name(root: &[u8]) -> String {
|
||||
None => "root".into(),
|
||||
};
|
||||
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
@@ -70,8 +70,14 @@ impl Model {
|
||||
}
|
||||
}
|
||||
let mut l = TmLayout {
|
||||
zoom: self.zoom, rect: r, ver: self.data_ver, blocks: Vec::new(), nested: Vec::new(),
|
||||
groups: HashMap::new(), visible: HashSet::new(), inner: HashSet::new(),
|
||||
zoom: self.zoom,
|
||||
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);
|
||||
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:
|
||||
/// 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);
|
||||
if k.ids.is_empty() || r.empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
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 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() {
|
||||
let size = k.sizes[vis..].iter().sum();
|
||||
let gid = group_id(parent);
|
||||
l.groups.insert(gid, Group { parent, kids: k.clone(), from: vis, size });
|
||||
items.push(Item { id: gid, size: size as f64 });
|
||||
l.groups.insert(
|
||||
gid,
|
||||
Group {
|
||||
parent,
|
||||
kids: k.clone(),
|
||||
from: vis,
|
||||
size,
|
||||
},
|
||||
);
|
||||
items.push(Item {
|
||||
id: gid,
|
||||
size: size as f64,
|
||||
});
|
||||
}
|
||||
treemap::squarify(&items, r)
|
||||
}
|
||||
|
||||
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.
|
||||
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() {
|
||||
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;
|
||||
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;
|
||||
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.
|
||||
fn parent_size(&self, tr: &Tree, id: i64) -> i64 {
|
||||
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;
|
||||
if p != NO_NODE {
|
||||
@@ -191,7 +252,15 @@ impl Model {
|
||||
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 base = self.color_of(tr, b.id);
|
||||
let fill = base.mix(t.bg, 0.62);
|
||||
@@ -234,7 +303,13 @@ impl Model {
|
||||
title.attr = BOLD;
|
||||
}
|
||||
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 pct = textutil::percent(size, total);
|
||||
@@ -245,7 +320,17 @@ impl Model {
|
||||
}
|
||||
let label = format!(" {label} ");
|
||||
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.
|
||||
let inn = r.inset(1);
|
||||
@@ -266,7 +351,13 @@ impl Model {
|
||||
}
|
||||
let y0 = inn.y + (inn.h - lines.len() as i32) / 2;
|
||||
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 {
|
||||
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 {
|
||||
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 };
|
||||
@@ -314,7 +423,13 @@ impl Model {
|
||||
if tw(&name) > lw && lw < 8 {
|
||||
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 {
|
||||
let size = self.block_size(tr, nb.id);
|
||||
let mut label = textutil::size_compact(size);
|
||||
@@ -322,7 +437,13 @@ impl Model {
|
||||
if tw(&label) + tw(&pct) + 3 <= lw {
|
||||
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 {
|
||||
"Scanning…".to_string()
|
||||
} 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 {
|
||||
"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);
|
||||
}
|
||||
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
@@ -55,7 +55,6 @@ pub(crate) fn san(b: &[u8]) -> String {
|
||||
textutil::sanitize_bytes(b)
|
||||
}
|
||||
|
||||
|
||||
fn local_tm(unix_nanos: i64) -> libc::tm {
|
||||
let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t;
|
||||
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").
|
||||
pub(crate) fn fmt_date(unix_nanos: i64) -> String {
|
||||
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").
|
||||
pub(crate) fn fmt_time(unix_nanos: i64) -> String {
|
||||
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".
|
||||
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);
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
+79
-20
@@ -17,15 +17,30 @@ pub(crate) struct Span {
|
||||
pub(crate) type Line = Vec<Span>;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
@@ -61,7 +76,12 @@ impl ModalFrame {
|
||||
|
||||
impl Model {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -119,16 +139,24 @@ impl Model {
|
||||
pub fn paint_modal(&mut self, tr: &Tree, cv: &mut Canvas) {
|
||||
let t = self.theme.clone();
|
||||
// Dim everything underneath.
|
||||
cv.restyle(Rect { x: 0, y: 0, w: cv.w, h: cv.h }, |mut s| {
|
||||
if t.mono {
|
||||
s.attr |= FAINT;
|
||||
return s;
|
||||
}
|
||||
s.fg = s.fg.mix(t.bg, 0.6);
|
||||
s.bg = s.bg.mix(rgb(0, 0, 0), 0.45);
|
||||
s.attr &= !BOLD;
|
||||
s
|
||||
});
|
||||
cv.restyle(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: cv.w,
|
||||
h: cv.h,
|
||||
},
|
||||
|mut s| {
|
||||
if t.mono {
|
||||
s.attr |= FAINT;
|
||||
return s;
|
||||
}
|
||||
s.fg = s.fg.mix(t.bg, 0.6);
|
||||
s.bg = s.bg.mix(rgb(0, 0, 0), 0.45);
|
||||
s.attr &= !BOLD;
|
||||
s
|
||||
},
|
||||
);
|
||||
let mut f = self.modals.pop().unwrap();
|
||||
let max_w = cv.w - 2;
|
||||
let w = (f.md.width() + 4).min(max_w).max(30.min(max_w));
|
||||
@@ -139,10 +167,16 @@ impl Model {
|
||||
if !foot.is_empty() {
|
||||
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 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);
|
||||
// Drop shadow.
|
||||
if !t.mono {
|
||||
@@ -150,13 +184,35 @@ impl Model {
|
||||
s.bg = rgb(0, 0, 0);
|
||||
s
|
||||
};
|
||||
cv.restyle(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.restyle(
|
||||
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);
|
||||
let border = Style::new(t.accent, bg).with(BOLD);
|
||||
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 { "─" };
|
||||
cv.hline(x0 + 1, y0 + 2, w - 2, rule, Style::new(t.faint, bg));
|
||||
f.view_h = h - chrome;
|
||||
@@ -201,7 +257,10 @@ impl Model {
|
||||
}
|
||||
self.modals.push(f);
|
||||
self.hits.clear();
|
||||
self.hits.push(Hit { r, act: HitAct::None });
|
||||
self.hits.push(Hit {
|
||||
r,
|
||||
act: HitAct::None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+195
-37
@@ -10,7 +10,10 @@ use super::search::LineEditor;
|
||||
use super::{fmt_time, pad_left, pad_right, san, trunc, tw};
|
||||
use crate::export;
|
||||
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::textutil;
|
||||
|
||||
@@ -42,7 +45,10 @@ impl Modal for InfoModal {
|
||||
}
|
||||
|
||||
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 {
|
||||
s += &format!(" · d {}", delete_verb());
|
||||
}
|
||||
@@ -71,25 +77,67 @@ impl Modal for InfoModal {
|
||||
out.push(styled(l, vs));
|
||||
}
|
||||
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(""));
|
||||
let mode = m.size_mode;
|
||||
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(&mut out, "Total allocated", format!("{} ({} bytes)", textutil::size(n.tot_alloc), textutil::count(n.tot_alloc)), bold);
|
||||
add_s(
|
||||
&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, "Directories", textutil::count(n.dirs as i64));
|
||||
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);
|
||||
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 {
|
||||
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) {
|
||||
"incomplete (not fully read)"
|
||||
@@ -102,15 +150,32 @@ impl Modal for InfoModal {
|
||||
};
|
||||
add(&mut out, "Scan state", state.into());
|
||||
} 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) {
|
||||
"unknown on this platform".to_string()
|
||||
} 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);
|
||||
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 {
|
||||
@@ -120,9 +185,19 @@ impl Modal for InfoModal {
|
||||
match d.old_size(self.id, mode) {
|
||||
Some(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(""));
|
||||
@@ -143,12 +218,23 @@ impl Modal for InfoModal {
|
||||
}
|
||||
out.push(txt(""));
|
||||
add(&mut out, "Mode", platform::mode::string(n.mode));
|
||||
add(&mut out, "Owner", textutil::sanitize(&platform::user_name(n.uid)));
|
||||
add(&mut out, "Group", textutil::sanitize(&platform::group_name(n.gid)));
|
||||
add(
|
||||
&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() {
|
||||
add(&mut out, "Hard links", n.nlink.to_string());
|
||||
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 {
|
||||
"No".into()
|
||||
};
|
||||
@@ -159,11 +245,17 @@ impl Modal for InfoModal {
|
||||
add_s(&mut out, "Flags", fl.join(", "), warn);
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -216,13 +308,22 @@ impl Modal for GroupModal {
|
||||
let t = m.theme.clone();
|
||||
let ids = self.g.ids();
|
||||
let mut out = vec![
|
||||
txt(format!("{} 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(format!(
|
||||
"{} 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(""),
|
||||
];
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
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;
|
||||
}
|
||||
let n = tr.node(id);
|
||||
@@ -231,8 +332,17 @@ impl Modal for GroupModal {
|
||||
name.push('/');
|
||||
}
|
||||
out.push(vec![
|
||||
Span { 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())) },
|
||||
Span {
|
||||
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
|
||||
@@ -281,10 +391,23 @@ impl Modal for HelpModal {
|
||||
out.push(styled(s, h));
|
||||
};
|
||||
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");
|
||||
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, "Space →", "zoom into the selected directory");
|
||||
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, "wheel", "zoom in/out (map), scroll (lists)");
|
||||
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");
|
||||
out.push(styled(" 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));
|
||||
out.push(styled(
|
||||
" 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 split = 7.min(fields.len());
|
||||
out.push(styled(format!(" fields: {}", fields[..split].join(" ")), 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));
|
||||
out.push(styled(
|
||||
format!(" fields: {}", fields[..split].join(" ")),
|
||||
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");
|
||||
row(&mut out, "Tab Shift+Tab", "next / previous view");
|
||||
row(&mut out, "1 – 7", "jump to a view");
|
||||
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, "D", "find duplicate files (reads file contents)");
|
||||
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, "q", "quit");
|
||||
out.push(txt(""));
|
||||
let units = if crate::textutil::si() { "SI (kB = 1000 bytes)" } else { "IEC (KiB = 1024 bytes)" };
|
||||
out.push(styled(format!("Sizes: {} · units: {units}", m.size_mode.name()), muted));
|
||||
let units = if crate::textutil::si() {
|
||||
"SI (kB = 1000 bytes)"
|
||||
} else {
|
||||
"IEC (KiB = 1024 bytes)"
|
||||
};
|
||||
out.push(styled(
|
||||
format!("Sizes: {} · units: {units}", m.size_mode.name()),
|
||||
muted,
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -371,7 +524,9 @@ struct SaveModal {
|
||||
}
|
||||
|
||||
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));
|
||||
Box::new(md)
|
||||
}
|
||||
@@ -399,7 +554,10 @@ impl Modal for SaveModal {
|
||||
}
|
||||
if m.scanning {
|
||||
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
|
||||
}
|
||||
|
||||
+95
-22
@@ -32,7 +32,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
/// Begins a scan (or returns a loaded tree with no scanner). Called again
|
||||
/// 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.
|
||||
pub struct Options {
|
||||
@@ -75,7 +76,11 @@ impl Key {
|
||||
_ if s.chars().count() == 1 => s.to_string(),
|
||||
_ => 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),
|
||||
Tick,
|
||||
ScanDone(Arc<RwLock<Tree>>), // identifies the scan by its tree
|
||||
Filter { gen: u64, res: Option<FilterResult> },
|
||||
Filter {
|
||||
gen: u64,
|
||||
res: Option<FilterResult>,
|
||||
},
|
||||
FilterDebounce(u64),
|
||||
DupDone { finder: Arc<duplicate::Finder>, groups: Option<Vec<duplicate::Group>> },
|
||||
TrashDone { tree: Arc<RwLock<Tree>>, id: NodeId, err: Option<String>, done: String },
|
||||
SaveDone { path: String, err: Option<String> },
|
||||
DupDone {
|
||||
finder: Arc<duplicate::Finder>,
|
||||
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),
|
||||
Click { x: i32, y: i32, button: Button },
|
||||
Wheel { x: i32, y: i32, up: bool },
|
||||
Motion { x: i32, y: i32 },
|
||||
Click {
|
||||
x: i32,
|
||||
y: i32,
|
||||
button: Button,
|
||||
},
|
||||
Wheel {
|
||||
x: i32,
|
||||
y: i32,
|
||||
up: bool,
|
||||
},
|
||||
Motion {
|
||||
x: i32,
|
||||
y: i32,
|
||||
},
|
||||
Quit, // SIGTERM
|
||||
}
|
||||
|
||||
@@ -239,8 +269,13 @@ impl Model {
|
||||
{
|
||||
theme.ascii = true;
|
||||
}
|
||||
let mut views = vec![View::Map, View::List(Default::default()), View::Ext(Default::default()),
|
||||
View::Top(Default::default()), View::Info(Default::default())];
|
||||
let mut views = vec![
|
||||
View::Map,
|
||||
View::List(Default::default()),
|
||||
View::Ext(Default::default()),
|
||||
View::Top(Default::default()),
|
||||
View::Info(Default::default()),
|
||||
];
|
||||
if opts.diff.is_some() {
|
||||
views.push(View::Changes(Default::default()));
|
||||
}
|
||||
@@ -298,7 +333,9 @@ impl Model {
|
||||
}
|
||||
|
||||
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());
|
||||
Cmd::run(move || {
|
||||
let _ = rx.recv();
|
||||
@@ -307,7 +344,11 @@ impl Model {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -321,7 +362,9 @@ impl Model {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -366,7 +409,10 @@ impl Model {
|
||||
self.ticking = false;
|
||||
if self.scanning {
|
||||
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.last_data = Some(Instant::now());
|
||||
}
|
||||
@@ -379,7 +425,10 @@ impl Model {
|
||||
let mut cmds = Vec::new();
|
||||
if self.scanning
|
||||
&& 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());
|
||||
}
|
||||
@@ -405,7 +454,12 @@ impl Model {
|
||||
Cmd::None
|
||||
}
|
||||
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 {
|
||||
Some(e) => self.warn(format!("Snapshot save failed: {}", textutil::sanitize(&e))),
|
||||
None => self.info(format!("Saved snapshot {}", textutil::sanitize(&path))),
|
||||
@@ -454,13 +508,24 @@ impl Model {
|
||||
let (total, files, dirs, errs, el, cancelled) = {
|
||||
let t = self.tree.read().unwrap();
|
||||
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 rate = (files + dirs) as f64 / el.as_secs_f64().max(0.001);
|
||||
let mut msg = format!(
|
||||
"✓ 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 {
|
||||
msg = "✗ Scan cancelled — totals are incomplete".into();
|
||||
@@ -507,7 +572,10 @@ impl Model {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
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();
|
||||
@@ -536,13 +604,18 @@ impl Model {
|
||||
/// Re-zooms into the remembered directory once the new scan has
|
||||
/// discovered it. Takes the read lock itself.
|
||||
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 t = arc.read().unwrap();
|
||||
let root = t.path_bytes(0);
|
||||
let rel: &[u8] = if pz == root {
|
||||
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
|
||||
} else {
|
||||
self.pending_zoom = None;
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ impl Model {
|
||||
for i in (0..self.hits.len()).rev() {
|
||||
if self.hits[i].r.contains(x, y) {
|
||||
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);
|
||||
if double {
|
||||
self.last_click_i = NO_SEL;
|
||||
|
||||
+137
-27
@@ -1,9 +1,9 @@
|
||||
//! Frame composition: panel geometry, tabs, breadcrumbs, info and status
|
||||
//! 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::{fmt_date, san, tw, trunc_left};
|
||||
use super::{fmt_date, san, trunc_left, tw};
|
||||
use crate::brand;
|
||||
use crate::inventory::{Kind, Node, Tree, FLAG_SCANNED, NO_NODE};
|
||||
use crate::textutil;
|
||||
@@ -46,21 +46,46 @@ pub(crate) fn compute_geometry(w: i32, h: i32) -> (SizeClass, Geometry) {
|
||||
};
|
||||
let mut y = 0;
|
||||
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;
|
||||
}
|
||||
g.crumbs = Rect { x: 0, y, w, h: 1 };
|
||||
y += 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 {
|
||||
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;
|
||||
}
|
||||
g.main = Rect { x: 0, y, w, h: bottom - y };
|
||||
g.main = Rect {
|
||||
x: 0,
|
||||
y,
|
||||
w,
|
||||
h: bottom - y,
|
||||
};
|
||||
if class == SizeClass::Large {
|
||||
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);
|
||||
}
|
||||
(class, g)
|
||||
@@ -114,7 +139,11 @@ impl Model {
|
||||
|
||||
fn render_too_small(&self, cv: &mut Canvas) {
|
||||
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);
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
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) {
|
||||
let t = self.theme.clone();
|
||||
cv.fill(r, " ", t.header());
|
||||
let badge = if t.ascii { 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()));
|
||||
let badge = if t.ascii {
|
||||
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;
|
||||
let names: Vec<&'static str> = self.views.iter().map(|v| v.name()).collect();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
@@ -148,7 +187,10 @@ impl Model {
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Right-aligned chips.
|
||||
@@ -177,7 +219,13 @@ impl Model {
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -206,10 +254,20 @@ impl Model {
|
||||
right = format!(" {} ", textutil::size_compact(size));
|
||||
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 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;
|
||||
// Drop leading segments until the rest fits; keep at least the last.
|
||||
let total = |from: usize| {
|
||||
@@ -231,17 +289,40 @@ impl Model {
|
||||
}
|
||||
let mut x = r.x + 1;
|
||||
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() {
|
||||
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 st = if last { 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 st = if last {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -279,9 +360,14 @@ impl Model {
|
||||
return "Nothing selected".into();
|
||||
}
|
||||
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 {
|
||||
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() {
|
||||
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) {
|
||||
let t = self.theme.clone();
|
||||
cv.fill(r, " ", t.status());
|
||||
let help = if class == SizeClass::Small { " ? " } 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 help = if class == SizeClass::Small {
|
||||
" ? "
|
||||
} 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;
|
||||
if !self.toast.is_empty() {
|
||||
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 mut parts: Vec<(String, Color)> = Vec::new();
|
||||
if self.scanning {
|
||||
let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis();
|
||||
let frame = if t.ascii { "*" } else { SPINNER[(ms / 100) as usize % SPINNER.len()] };
|
||||
let ms = std::time::SystemTime::now()
|
||||
.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));
|
||||
} else if st.cancelled {
|
||||
parts.push(("✗ cancelled".into(), t.warn));
|
||||
@@ -334,8 +437,15 @@ impl Model {
|
||||
parts.push((textutil::size(root.total(self.size_mode)), t.status_fg));
|
||||
if self.scanning {
|
||||
let el = st.elapsed();
|
||||
let p = self.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));
|
||||
let p = self
|
||||
.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 {
|
||||
parts.push((format!("{} workers", p.workers), t.muted));
|
||||
parts.push((format!("queue {}", textutil::count(p.pending)), t.muted));
|
||||
|
||||
+98
-18
@@ -11,7 +11,9 @@ use crate::brand;
|
||||
use crate::cancel::Cancel;
|
||||
use crate::textutil;
|
||||
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 std::fmt::Write as _;
|
||||
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,
|
||||
/// 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) {
|
||||
let mut b = s.as_bytes();
|
||||
@@ -84,7 +87,18 @@ fn detect_profile() -> Profile {
|
||||
if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" {
|
||||
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 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::sleep(Duration::from_millis(500));
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
@@ -127,7 +149,13 @@ fn enter_terminal(mouse: bool) -> io::Result<()> {
|
||||
terminal::enable_raw_mode()?;
|
||||
ACTIVE.store(true, Ordering::SeqCst);
|
||||
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 {
|
||||
execute!(out, event::EnableMouseCapture)?;
|
||||
}
|
||||
@@ -140,7 +168,11 @@ fn convert_key(ev: KeyEvent) -> Option<Key> {
|
||||
return None;
|
||||
}
|
||||
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();
|
||||
if ctrl {
|
||||
prefix += "ctrl+";
|
||||
@@ -148,14 +180,37 @@ fn convert_key(ev: KeyEvent) -> Option<Key> {
|
||||
if 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 {
|
||||
KeyCode::Char(c) => {
|
||||
let base = if c == ' ' { "space".to_string() } 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 })
|
||||
let base = if c == ' ' {
|
||||
"space".to_string()
|
||||
} 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::Esc => named("esc"),
|
||||
KeyCode::Tab => named("tab"),
|
||||
@@ -256,7 +311,9 @@ fn base64(b: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
|
||||
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 {
|
||||
if i <= c.len() {
|
||||
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 {
|
||||
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.push_str("\x1b[?2026h"); // synchronized output, where supported
|
||||
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));
|
||||
}
|
||||
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)));
|
||||
let init = m.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
|
||||
// frame, so bursts (resize storms) cost one render.
|
||||
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;
|
||||
for (_, msg) in due {
|
||||
let c = m.update(msg);
|
||||
@@ -438,7 +508,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -450,7 +527,10 @@ mod tests {
|
||||
s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap();
|
||||
out.clear();
|
||||
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.
|
||||
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";
|
||||
|
||||
+52
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::canvas::{Canvas, Style, BOLD};
|
||||
use super::model::{Cmd, Key, Model, Msg};
|
||||
use super::{tw, trunc};
|
||||
use super::{trunc, tw};
|
||||
use crate::cancel::Cancel;
|
||||
use crate::filter::{self, FilterResult, Query};
|
||||
use crate::inventory::Tree;
|
||||
@@ -74,7 +74,11 @@ impl LineEditor {
|
||||
if k.text.is_empty() || k.ctrl_alt {
|
||||
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 {
|
||||
return (false, true);
|
||||
}
|
||||
@@ -174,7 +178,9 @@ impl Model {
|
||||
/// Evaluates the current query in the background. The tree is
|
||||
/// read-locked by the worker; stale results are discarded by generation.
|
||||
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() {
|
||||
c.cancel();
|
||||
}
|
||||
@@ -235,7 +241,11 @@ impl Model {
|
||||
} else if s.busy {
|
||||
"filtering…".into()
|
||||
} 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() {
|
||||
"name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear".into()
|
||||
} else {
|
||||
@@ -245,14 +255,28 @@ impl Model {
|
||||
let avail = r.w - (x - r.x) - sw - 3;
|
||||
// Scroll the text so the cursor stays visible.
|
||||
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 vis: String = text[start..end].iter().collect();
|
||||
cv.text(x, r.y, &textutil::sanitize(&vis), avail, st);
|
||||
let before: String = text[start..s.ed.cur].iter().collect();
|
||||
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() };
|
||||
cv.text(cx, r.y, &under, 2, Style::new(t.panel_bg, t.fg).with(self.mono_rev()));
|
||||
let under = if s.ed.cur < text.len() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -260,9 +284,21 @@ impl Model {
|
||||
let t = &self.theme;
|
||||
let s = &self.search;
|
||||
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();
|
||||
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();
|
||||
if let (Some(res), false) = (&s.result, s.busy) {
|
||||
info = format!(
|
||||
@@ -272,6 +308,12 @@ impl Model {
|
||||
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
@@ -15,7 +15,16 @@ impl Model {
|
||||
let t = self.theme.clone();
|
||||
let ps = t.panel();
|
||||
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 x = r.x + 1;
|
||||
let mut y = r.y + 1;
|
||||
@@ -45,21 +54,43 @@ impl Model {
|
||||
}
|
||||
}
|
||||
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, "Allocated", &textutil::size(z.tot_alloc), 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 {
|
||||
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;
|
||||
|
||||
// Category breakdown with proportional bars.
|
||||
if let Some(cs) = tr.cat_sizes(self.zoom) {
|
||||
head(cv, &mut y, "CATEGORIES");
|
||||
let mut cats: Vec<(Category, i64)> =
|
||||
cs.iter().enumerate().filter(|(_, &v)| v > 0).map(|(i, &v)| (Category::from_u8(i as u8), v)).collect();
|
||||
let mut cats: Vec<(Category, i64)> = cs
|
||||
.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();
|
||||
cats.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
for &(c, v) in &cats {
|
||||
@@ -70,7 +101,13 @@ impl Model {
|
||||
let sw = if t.mono || t.ascii { " " } else { "██ " };
|
||||
cv.text(x, y, sw, 3, Style::new(col, t.panel_bg));
|
||||
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;
|
||||
self.paint_bar(cv, x + 3, y, w - 3, v as f64 / cats[0].1 as f64, col, false);
|
||||
y += 1;
|
||||
@@ -84,7 +121,10 @@ impl Model {
|
||||
let mut desc = Vec::new();
|
||||
if is_group(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));
|
||||
}
|
||||
} else {
|
||||
@@ -92,7 +132,11 @@ impl Model {
|
||||
if id != NO_NODE {
|
||||
let n = tr.node(id);
|
||||
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() {
|
||||
desc.push(textutil::count(n.files as i64) + " files");
|
||||
} else {
|
||||
|
||||
+71
-10
@@ -16,7 +16,12 @@ pub(crate) struct 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.
|
||||
@@ -69,7 +74,15 @@ impl Table {
|
||||
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();
|
||||
cv.fill(r, " ", t.base());
|
||||
if r.h < 3 {
|
||||
@@ -77,7 +90,13 @@ impl Table {
|
||||
}
|
||||
let mut y = r.y;
|
||||
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;
|
||||
}
|
||||
// Resolve flexible width.
|
||||
@@ -88,7 +107,11 @@ impl Table {
|
||||
let hdr = Style::new(t.muted, t.bg).with(BOLD);
|
||||
for c in cols {
|
||||
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);
|
||||
x += w + 1;
|
||||
}
|
||||
@@ -114,7 +137,16 @@ impl Table {
|
||||
let mut base = t.base();
|
||||
if sel {
|
||||
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;
|
||||
for (ci, c) in cols.iter().enumerate() {
|
||||
@@ -135,7 +167,15 @@ impl Table {
|
||||
}
|
||||
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;
|
||||
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());
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -152,10 +196,23 @@ impl Table {
|
||||
impl Model {
|
||||
/// Draws a proportional bar with eighth-block precision.
|
||||
#[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 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;
|
||||
const PARTS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
||||
for i in 0..w {
|
||||
@@ -170,7 +227,11 @@ impl Model {
|
||||
ch = if eighths >= 4 { "#" } else { " " };
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+209
-41
@@ -1,11 +1,11 @@
|
||||
//! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go).
|
||||
|
||||
use super::bench::opts_for;
|
||||
use super::color256::{quant16, quant256};
|
||||
use super::canvas::rgb;
|
||||
use super::color256::{quant16, quant256};
|
||||
use super::mapview::TOP_MIN_AREA;
|
||||
use super::model::*;
|
||||
use super::modals::new_info_modal;
|
||||
use super::model::*;
|
||||
use crate::cancel::Cancel;
|
||||
use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED};
|
||||
use crate::scan;
|
||||
@@ -39,7 +39,12 @@ fn test_tree() -> Tree {
|
||||
};
|
||||
let backups = add_dir(&mut t, 0, b"backups");
|
||||
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");
|
||||
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 photos = add_dir(&mut t, media, "photos 日本語".as_bytes());
|
||||
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, "👨👩👧 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"
|
||||
);
|
||||
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() {
|
||||
let got = textutil::width(&strip(l)) as i32;
|
||||
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 {
|
||||
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]
|
||||
@@ -151,19 +169,39 @@ fn resize_transitions_preserve_state() {
|
||||
check_frame(&mut m, 120, 40);
|
||||
send(&mut m, vec![key("right")]);
|
||||
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)]);
|
||||
check_frame(&mut m, w, h);
|
||||
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.
|
||||
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;
|
||||
for b in &l.blocks {
|
||||
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 rnd = 1u64;
|
||||
let mut intn = |n: u64| {
|
||||
rnd = rnd.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
rnd = rnd
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
(rnd >> 33) % n
|
||||
};
|
||||
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)] {
|
||||
send(&mut m, vec![size(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];
|
||||
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.
|
||||
send(&mut m, vec![size(45, 12)]);
|
||||
@@ -210,7 +257,12 @@ fn modal_survives_resize() {
|
||||
}
|
||||
check_frame(&mut m, 45, 12);
|
||||
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")]);
|
||||
assert!(m.modals.is_empty(), "Esc should close the modal");
|
||||
}
|
||||
@@ -228,14 +280,20 @@ fn spatial_navigation_uses_geometry() {
|
||||
if m.sel == before {
|
||||
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 {
|
||||
"right" => b.x >= a.x + a.w,
|
||||
"left" => b.x + b.w <= a.x,
|
||||
"down" => b.y >= a.y + a.h,
|
||||
_ => 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")]);
|
||||
assert_eq!(m.zoom, photos, "did not zoom into photos");
|
||||
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.
|
||||
send(&mut m, vec![size(42, 12)]);
|
||||
let out = check_frame(&mut m, 42, 12);
|
||||
assert!(out.contains("photos"), "breadcrumb tail missing:\n{out}");
|
||||
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")]);
|
||||
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();
|
||||
send(&mut m, vec![size(120, 40), key("/")]);
|
||||
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();
|
||||
send(&mut m, msgs);
|
||||
send(&mut m, vec![key("enter")]);
|
||||
assert!(m.search.result.is_some(), "no filter result");
|
||||
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");
|
||||
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);
|
||||
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]
|
||||
@@ -292,7 +378,10 @@ fn hostile_name_sanitised_in_modal() {
|
||||
let md = new_info_modal(&m, &t.read().unwrap(), id);
|
||||
m.open_modal(md);
|
||||
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]
|
||||
@@ -321,15 +410,26 @@ fn level_of_detail_groups_tiny_files() {
|
||||
m.zoom_to(&t.read().unwrap(), tiny);
|
||||
m.view();
|
||||
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!(l.groups.contains_key(&group_id(tiny)), "expected a group block for small items");
|
||||
assert!(
|
||||
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]
|
||||
fn ctrl_c_quits_when_idle() {
|
||||
let mut m = new_test_model();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -341,23 +441,45 @@ fn mouse() {
|
||||
// Click the centre of a block other than the selected one.
|
||||
let target = {
|
||||
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)
|
||||
.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()]);
|
||||
assert_eq!(m.sel, target.id, "click selected the wrong block");
|
||||
m.view();
|
||||
send(&mut m, vec![click()]); // second click within the double-click window
|
||||
assert_eq!(m.zoom as i64, target.id, "double click should zoom");
|
||||
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");
|
||||
// A modal swallows clicks; clicking outside closes it.
|
||||
send(&mut m, vec![key("?")]);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -370,12 +492,20 @@ fn quantizers() {
|
||||
assert!(n >= 16, "256 quantizer used palette-dependent index {n}");
|
||||
// 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).
|
||||
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]
|
||||
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();
|
||||
m.profile = p;
|
||||
send(&mut m, vec![size(100, 30)]);
|
||||
@@ -388,11 +518,23 @@ fn colour_depths_render_same_geometry() {
|
||||
fn copy_refuses_control_characters() {
|
||||
let mut m = new_test_model();
|
||||
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());
|
||||
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());
|
||||
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 -----------------------------------------------------------
|
||||
@@ -431,9 +573,18 @@ fn nesting_reaches_deep_media() {
|
||||
let mut m = model_for(root_tree());
|
||||
send(&mut m, vec![size(160, 45)]);
|
||||
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 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");
|
||||
}
|
||||
|
||||
@@ -453,7 +604,12 @@ fn reveal_headless_zooms_map() {
|
||||
m.snapshot = false; // reveal is refused for snapshots
|
||||
(m.sel, m.user_sel) = (mnt as i64, true);
|
||||
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 -----------------------------------------------------------
|
||||
@@ -467,12 +623,24 @@ fn live_scan_rendering() {
|
||||
let d = root.0.join(format!("d{i:02}")).join("sub");
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
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 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();
|
||||
Ok((Some(s), t))
|
||||
});
|
||||
|
||||
+5
-1
@@ -119,7 +119,11 @@ fn high_contrast_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.
|
||||
|
||||
+183
-38
@@ -4,11 +4,14 @@ use super::actions::DupView;
|
||||
use super::canvas::{Canvas, Color, Style, BOLD};
|
||||
use super::infoview::InfoView;
|
||||
use super::mapview::{map_key, map_paint, map_wheel};
|
||||
use super::model::{Cmd, Model};
|
||||
use super::modals::new_info_modal;
|
||||
use super::model::{Cmd, Model};
|
||||
use super::table::{col, Table, TableRow};
|
||||
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::textutil;
|
||||
use crate::treemap::Rect;
|
||||
@@ -159,16 +162,31 @@ impl ListView {
|
||||
name.push('/');
|
||||
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 mut name_st = Style::new(c.mix(t.fg, 0.45), t.bg);
|
||||
if n.is_dir() {
|
||||
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 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)],
|
||||
bar: frac,
|
||||
bar_col: c,
|
||||
@@ -178,15 +196,29 @@ impl ListView {
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
let mut cols = vec![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)];
|
||||
let mut cols = vec![
|
||||
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() {
|
||||
cols[4] = col("CHANGE", 12, true, false);
|
||||
}
|
||||
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() {
|
||||
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]];
|
||||
}
|
||||
}
|
||||
@@ -247,22 +279,56 @@ impl ExtView {
|
||||
let rows: Vec<TableRow> = st
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let ext = if s.ext.is_empty() { "(none)".to_string() } 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 };
|
||||
let ext = if s.ext.is_empty() {
|
||||
"(none)".to_string()
|
||||
} 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 {
|
||||
cells: vec![textutil::sanitize(&ext), s.cat.name().into(), 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],
|
||||
cells: vec![
|
||||
textutil::sanitize(&ext),
|
||||
s.cat.name().into(),
|
||||
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_col: c,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cols = [col("EXTENSION", 14, false, false), 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));
|
||||
let cols = [
|
||||
col("EXTENSION", 14, false, false),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -273,7 +339,11 @@ impl ExtView {
|
||||
}
|
||||
if (k == "enter" || k == "space") && self.tb.at() < st.len() {
|
||||
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");
|
||||
return (true, m.set_filter(&q));
|
||||
}
|
||||
@@ -295,10 +365,25 @@ enum TopMode {
|
||||
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",
|
||||
"Most files", "Sparse files", "Hard-linked files"];
|
||||
const TOP_NAMES: [&str; 7] = [
|
||||
"Largest files",
|
||||
"Largest directories (own files)",
|
||||
"Oldest large files",
|
||||
"Newest large files",
|
||||
"Most files",
|
||||
"Sparse files",
|
||||
"Hard-linked files",
|
||||
];
|
||||
|
||||
const TOP_LIMIT: usize = 500;
|
||||
|
||||
@@ -319,7 +404,9 @@ impl TopView {
|
||||
return;
|
||||
}
|
||||
(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 mut tk = TopK::new(TOP_LIMIT);
|
||||
let mut val: HashMap<NodeId, i64> = HashMap::new();
|
||||
@@ -358,7 +445,14 @@ impl TopView {
|
||||
}
|
||||
TopMode::Old | TopMode::New => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -390,8 +484,16 @@ impl TopView {
|
||||
let mut rows = Vec::with_capacity(self.ids.len());
|
||||
for (i, &id) in self.ids.iter().enumerate() {
|
||||
let n = tr.node(id);
|
||||
let val = if self.mode == TopMode::Count { 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 val = if self.mode == TopMode::Count {
|
||||
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 {
|
||||
self.vals[i] as f64 / first as f64
|
||||
} else {
|
||||
@@ -400,12 +502,20 @@ impl TopView {
|
||||
let c = m.color_of(tr, id as i64);
|
||||
rows.push(TableRow {
|
||||
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_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() {
|
||||
title += " · filtered";
|
||||
}
|
||||
@@ -414,7 +524,12 @@ impl TopView {
|
||||
TopMode::Sparse => "SAVED",
|
||||
_ => "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);
|
||||
}
|
||||
|
||||
@@ -487,18 +602,45 @@ impl ChangesView {
|
||||
Status::Unchanged => Color::default(),
|
||||
};
|
||||
TableRow {
|
||||
cells: vec![textutil::signed_size(c.delta), 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],
|
||||
cells: vec![
|
||||
textutil::signed_size(c.delta),
|
||||
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_col: col,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cols = [col("CHANGE", 13, true, false), col("STATUS", 9, false, false), 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));
|
||||
let cols = [
|
||||
col("CHANGE", 13, true, false),
|
||||
col("STATUS", 9, false, false),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -511,7 +653,10 @@ impl ChangesView {
|
||||
let c = &self.rows.as_ref().unwrap()[self.tb.at()];
|
||||
if c.node == NO_NODE {
|
||||
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 (false, Cmd::None);
|
||||
|
||||
Reference in New Issue
Block a user