Apply rustfmt to the crate
This commit is contained in:
+25
-5
@@ -25,25 +25,45 @@ fn bench<R>(name: &str, mut f: impl FnMut(u64) -> R) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let items: Vec<Item> = (0..300).map(|i| Item { id: i, size: (1 + i * i) as f64 }).collect();
|
let items: Vec<Item> = (0..300)
|
||||||
let bounds = Rect { x: 0, y: 0, w: 200, h: 60 };
|
.map(|i| Item {
|
||||||
|
id: i,
|
||||||
|
size: (1 + i * i) as f64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let bounds = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 200,
|
||||||
|
h: 60,
|
||||||
|
};
|
||||||
bench("treemap/Squarify(300)", |_| {
|
bench("treemap/Squarify(300)", |_| {
|
||||||
black_box(treemap::squarify(black_box(&items), bounds));
|
black_box(treemap::squarify(black_box(&items), bounds));
|
||||||
});
|
});
|
||||||
let blocks = treemap::squarify(&items, bounds);
|
let blocks = treemap::squarify(&items, bounds);
|
||||||
bench("treemap/Neighbour(300)", |_| {
|
bench("treemap/Neighbour(300)", |_| {
|
||||||
black_box(treemap::neighbour(black_box(&blocks), 150, Direction::Right));
|
black_box(treemap::neighbour(
|
||||||
|
black_box(&blocks),
|
||||||
|
150,
|
||||||
|
Direction::Right,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let tree = Arc::new(RwLock::new(big_tree()));
|
let tree = Arc::new(RwLock::new(big_tree()));
|
||||||
println!("big tree: {} nodes built in {:.2?}", tree.read().unwrap().len(), t0.elapsed());
|
println!(
|
||||||
|
"big tree: {} nodes built in {:.2?}",
|
||||||
|
tree.read().unwrap().len(),
|
||||||
|
t0.elapsed()
|
||||||
|
);
|
||||||
|
|
||||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||||
bench("tui/BigColdFrame", |_| m.cold_frame());
|
bench("tui/BigColdFrame", |_| m.cold_frame());
|
||||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||||
m.cold_frame();
|
m.cold_frame();
|
||||||
bench("tui/BigResize", |i| m.resize(150 + (i % 100) as u16, 40 + (i % 30) as u16));
|
bench("tui/BigResize", |i| {
|
||||||
|
m.resize(150 + (i % 100) as u16, 40 + (i % 30) as u16)
|
||||||
|
});
|
||||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||||
m.zoom_to_child_named(b"flat");
|
m.zoom_to_child_named(b"flat");
|
||||||
bench("tui/BigFlatDirCold", |_| m.cold_frame());
|
bench("tui/BigFlatDirCold", |_| m.cold_frame());
|
||||||
|
|||||||
+92
-17
@@ -37,16 +37,35 @@ pub struct Config {
|
|||||||
impl Config {
|
impl Config {
|
||||||
pub fn new(settings: Settings) -> Config {
|
pub fn new(settings: Settings) -> Config {
|
||||||
Config {
|
Config {
|
||||||
paths: Vec::new(), scan_opts: Default::default(), no_ui: false, json: false, csv: false, depth: -1,
|
paths: Vec::new(),
|
||||||
top: 0, largest_files: 0, save: String::new(), load: String::new(), compare: None, duplicates: false,
|
scan_opts: Default::default(),
|
||||||
read_only: false, theme: settings.theme.clone(), size_mode: SizeMode::Allocated, mouse: settings.mouse,
|
no_ui: false,
|
||||||
color: "auto".into(), settings,
|
json: false,
|
||||||
|
csv: false,
|
||||||
|
depth: -1,
|
||||||
|
top: 0,
|
||||||
|
largest_files: 0,
|
||||||
|
save: String::new(),
|
||||||
|
load: String::new(),
|
||||||
|
compare: None,
|
||||||
|
duplicates: false,
|
||||||
|
read_only: false,
|
||||||
|
theme: settings.theme.clone(),
|
||||||
|
size_mode: SizeMode::Allocated,
|
||||||
|
mouse: settings.mouse,
|
||||||
|
color: "auto".into(),
|
||||||
|
settings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether any non-interactive output was requested.
|
/// Whether any non-interactive output was requested.
|
||||||
pub fn report(&self) -> bool {
|
pub fn report(&self) -> bool {
|
||||||
self.no_ui || self.json || self.csv || self.top > 0 || self.largest_files > 0 || self.duplicates
|
self.no_ui
|
||||||
|
|| self.json
|
||||||
|
|| self.csv
|
||||||
|
|| self.top > 0
|
||||||
|
|| self.largest_files > 0
|
||||||
|
|| self.duplicates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +82,17 @@ pub fn run(cfg: Config) -> Result<(), Error> {
|
|||||||
|
|
||||||
/// Begins a scan (or loads a snapshot). The scanner is None when the tree
|
/// Begins a scan (or loads a snapshot). The scanner is None when the tree
|
||||||
/// came from a snapshot.
|
/// came from a snapshot.
|
||||||
fn start_source(cancel: Cancel, load: &str, paths: &[String], opts: &scan::Options) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), Error> {
|
fn start_source(
|
||||||
|
cancel: Cancel,
|
||||||
|
load: &str,
|
||||||
|
paths: &[String],
|
||||||
|
opts: &scan::Options,
|
||||||
|
) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), Error> {
|
||||||
if !load.is_empty() {
|
if !load.is_empty() {
|
||||||
return Ok((None, Arc::new(RwLock::new(snapshot::load_file(Path::new(load))?))));
|
return Ok((
|
||||||
|
None,
|
||||||
|
Arc::new(RwLock::new(snapshot::load_file(Path::new(load))?)),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let root = paths.first().map_or(".", |s| s.as_str());
|
let root = paths.first().map_or(".", |s| s.as_str());
|
||||||
let s = scan::start(root.as_bytes(), opts.clone(), cancel)?;
|
let s = scan::start(root.as_bytes(), opts.clone(), cancel)?;
|
||||||
@@ -117,7 +144,12 @@ fn run_report(cfg: Config) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
if cfg.largest_files > 0 {
|
if cfg.largest_files > 0 {
|
||||||
let m = cfg.size_mode;
|
let m = cfg.size_mode;
|
||||||
let ids = t.top(t.root(), cfg.largest_files, |n| n.kind == Kind::File && n.flags & FLAG_HARDLINK_DUP == 0, |n| n.own(m));
|
let ids = t.top(
|
||||||
|
t.root(),
|
||||||
|
cfg.largest_files,
|
||||||
|
|n| n.kind == Kind::File && n.flags & FLAG_HARDLINK_DUP == 0,
|
||||||
|
|n| n.own(m),
|
||||||
|
);
|
||||||
export::table(&mut out, &t, &ids, m)?;
|
export::table(&mut out, &t, &ids, m)?;
|
||||||
writeln!(out)?;
|
writeln!(out)?;
|
||||||
}
|
}
|
||||||
@@ -162,8 +194,14 @@ fn wait_with_progress(s: &Scanner, cancel: &Cancel) {
|
|||||||
fn run_tui(cfg: Config) -> Result<(), Error> {
|
fn run_tui(cfg: Config) -> Result<(), Error> {
|
||||||
let (load, paths, opts) = (cfg.load.clone(), cfg.paths.clone(), cfg.scan_opts.clone());
|
let (load, paths, opts) = (cfg.load.clone(), cfg.paths.clone(), cfg.scan_opts.clone());
|
||||||
tui::run(tui::Options {
|
tui::run(tui::Options {
|
||||||
read_only: cfg.read_only, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color,
|
read_only: cfg.read_only,
|
||||||
save: cfg.save, settings: cfg.settings, diff: None,
|
theme: cfg.theme,
|
||||||
|
size_mode: cfg.size_mode,
|
||||||
|
mouse: cfg.mouse,
|
||||||
|
color: cfg.color,
|
||||||
|
save: cfg.save,
|
||||||
|
settings: cfg.settings,
|
||||||
|
diff: None,
|
||||||
start: Arc::new(move |c| start_source(c, &load, &paths, &opts)),
|
start: Arc::new(move |c| start_source(c, &load, &paths, &opts)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -176,8 +214,14 @@ fn run_compare(cfg: Config) -> Result<(), Error> {
|
|||||||
if !cfg.report() {
|
if !cfg.report() {
|
||||||
let tree = Arc::new(RwLock::new(nw));
|
let tree = Arc::new(RwLock::new(nw));
|
||||||
return tui::run(tui::Options {
|
return tui::run(tui::Options {
|
||||||
read_only: true, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color,
|
read_only: true,
|
||||||
save: String::new(), settings: cfg.settings, diff: Some(d),
|
theme: cfg.theme,
|
||||||
|
size_mode: cfg.size_mode,
|
||||||
|
mouse: cfg.mouse,
|
||||||
|
color: cfg.color,
|
||||||
|
save: String::new(),
|
||||||
|
settings: cfg.settings,
|
||||||
|
diff: Some(d),
|
||||||
start: Arc::new(move |_| Ok((None, tree.clone()))),
|
start: Arc::new(move |_| Ok((None, tree.clone()))),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -185,23 +229,54 @@ fn run_compare(cfg: Config) -> Result<(), Error> {
|
|||||||
let m = cfg.size_mode;
|
let m = cfg.size_mode;
|
||||||
let mut out = std::io::stdout().lock();
|
let mut out = std::io::stdout().lock();
|
||||||
let (ro, rn) = (d.old.node(0), nw.node(0));
|
let (ro, rn) = (d.old.node(0), nw.node(0));
|
||||||
writeln!(out, "{} → {}", textutil::sanitize(&d.old.stats.from_snapshot), textutil::sanitize(&nw.stats.from_snapshot))?;
|
writeln!(
|
||||||
writeln!(out, "Total {} → {} ({})\n", textutil::size(ro.total(m)), textutil::size(rn.total(m)), textutil::signed_size(rn.total(m) - ro.total(m)))?;
|
out,
|
||||||
|
"{} → {}",
|
||||||
|
textutil::sanitize(&d.old.stats.from_snapshot),
|
||||||
|
textutil::sanitize(&nw.stats.from_snapshot)
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"Total {} → {} ({})\n",
|
||||||
|
textutil::size(ro.total(m)),
|
||||||
|
textutil::size(rn.total(m)),
|
||||||
|
textutil::signed_size(rn.total(m) - ro.total(m))
|
||||||
|
)?;
|
||||||
for c in d.changes(&nw, k, m) {
|
for c in d.changes(&nw, k, m) {
|
||||||
writeln!(out, "{:<10} {:>14} {}", c.status.name(), textutil::signed_size(c.delta), textutil::sanitize(&c.path))?;
|
writeln!(
|
||||||
|
out,
|
||||||
|
"{:<10} {:>14} {}",
|
||||||
|
c.status.name(),
|
||||||
|
textutil::signed_size(c.delta),
|
||||||
|
textutil::sanitize(&c.path)
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_duplicates(out: &mut dyn Write, cancel: &Cancel, tree: &RwLock<Tree>) -> Result<(), Error> {
|
fn run_duplicates(out: &mut dyn Write, cancel: &Cancel, tree: &RwLock<Tree>) -> Result<(), Error> {
|
||||||
let f = duplicate::Finder::default();
|
let f = duplicate::Finder::default();
|
||||||
let groups = f.find(cancel, tree, duplicate::Options { min_size: 1, workers: 0 }).ok_or("cancelled")?;
|
let groups = f
|
||||||
|
.find(
|
||||||
|
cancel,
|
||||||
|
tree,
|
||||||
|
duplicate::Options {
|
||||||
|
min_size: 1,
|
||||||
|
workers: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.ok_or("cancelled")?;
|
||||||
let t = tree.read().unwrap();
|
let t = tree.read().unwrap();
|
||||||
let mut wasted = 0;
|
let mut wasted = 0;
|
||||||
for g in &groups {
|
for g in &groups {
|
||||||
wasted += g.wasted();
|
wasted += g.wasted();
|
||||||
let h: String = g.hash[..6].iter().map(|b| format!("{b:02x}")).collect();
|
let h: String = g.hash[..6].iter().map(|b| format!("{b:02x}")).collect();
|
||||||
writeln!(out, "{} × {} (sha256 {h}…)", textutil::size(g.size), g.files.len())?;
|
writeln!(
|
||||||
|
out,
|
||||||
|
"{} × {} (sha256 {h}…)",
|
||||||
|
textutil::size(g.size),
|
||||||
|
g.files.len()
|
||||||
|
)?;
|
||||||
for &id in &g.files {
|
for &id in &g.files {
|
||||||
writeln!(out, " {}", textutil::sanitize_bytes(&t.path_bytes(id)))?;
|
writeln!(out, " {}", textutil::sanitize_bytes(&t.path_bytes(id)))?;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -22,7 +22,11 @@ impl Default for Cancel {
|
|||||||
impl Cancel {
|
impl Cancel {
|
||||||
pub fn new() -> Cancel {
|
pub fn new() -> Cancel {
|
||||||
let (tx, rx) = crossbeam_channel::bounded(0);
|
let (tx, rx) = crossbeam_channel::bounded(0);
|
||||||
Cancel(Arc::new(Inner { flag: AtomicBool::new(false), tx: Mutex::new(Some(tx)), rx }))
|
Cancel(Arc::new(Inner {
|
||||||
|
flag: AtomicBool::new(false),
|
||||||
|
tx: Mutex::new(Some(tx)),
|
||||||
|
rx,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel(&self) {
|
pub fn cancel(&self) {
|
||||||
|
|||||||
+8
-2
@@ -46,7 +46,11 @@ impl Default for Settings {
|
|||||||
|
|
||||||
/// The config file location.
|
/// The config file location.
|
||||||
pub fn path() -> Option<PathBuf> {
|
pub fn path() -> Option<PathBuf> {
|
||||||
let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
let env = |k: &str| {
|
||||||
|
std::env::var_os(k)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
};
|
||||||
let dir = if cfg!(target_os = "macos") {
|
let dir = if cfg!(target_os = "macos") {
|
||||||
env("HOME")?.join("Library/Application Support")
|
env("HOME")?.join("Library/Application Support")
|
||||||
} else {
|
} else {
|
||||||
@@ -57,7 +61,9 @@ pub fn path() -> Option<PathBuf> {
|
|||||||
|
|
||||||
/// Reads the config file if present. A missing file is not an error.
|
/// Reads the config file if present. A missing file is not an error.
|
||||||
pub fn load() -> (Settings, Option<String>) {
|
pub fn load() -> (Settings, Option<String>) {
|
||||||
let Some(p) = path() else { return (Settings::default(), None) };
|
let Some(p) = path() else {
|
||||||
|
return (Settings::default(), None);
|
||||||
|
};
|
||||||
let b = match std::fs::read(&p) {
|
let b = match std::fs::read(&p) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Settings::default(), None),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Settings::default(), None),
|
||||||
|
|||||||
+55
-11
@@ -104,7 +104,11 @@ impl Finder {
|
|||||||
let mut by_size: HashMap<i64, Vec<Cand>> = HashMap::new();
|
let mut by_size: HashMap<i64, Vec<Cand>> = HashMap::new();
|
||||||
t.walk(t.root(), |id, n| {
|
t.walk(t.root(), |id, n| {
|
||||||
if n.kind == Kind::File && !n.has(FLAG_HARDLINK_DUP) && n.size >= o.min_size {
|
if n.kind == Kind::File && !n.has(FLAG_HARDLINK_DUP) && n.size >= o.min_size {
|
||||||
by_size.entry(n.size).or_default().push(Cand { id, path: PathBuf::new(), size: n.size });
|
by_size.entry(n.size).or_default().push(Cand {
|
||||||
|
id,
|
||||||
|
path: PathBuf::new(),
|
||||||
|
size: n.size,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
@@ -133,11 +137,18 @@ impl Finder {
|
|||||||
sampled.push(g); // a sample would be the whole file
|
sampled.push(g); // a sample would be the whole file
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sampled.extend(self.split(cancel, &g, o.workers, sample_hash).into_values().filter(|v| v.len() > 1));
|
sampled.extend(
|
||||||
|
self.split(cancel, &g, o.workers, sample_hash)
|
||||||
|
.into_values()
|
||||||
|
.filter(|v| v.len() > 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 3: full hashes.
|
// Stage 3: full hashes.
|
||||||
self.to_hash.store(sampled.iter().map(|g| g[0].size * g.len() as i64).sum(), Ordering::Relaxed);
|
self.to_hash.store(
|
||||||
|
sampled.iter().map(|g| g[0].size * g.len() as i64).sum(),
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
self.stage("hashing", |_| {});
|
self.stage("hashing", |_| {});
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for g in &sampled {
|
for g in &sampled {
|
||||||
@@ -148,21 +159,35 @@ impl Finder {
|
|||||||
if v.len() > 1 {
|
if v.len() > 1 {
|
||||||
let mut files: Vec<NodeId> = v.iter().map(|c| c.id).collect();
|
let mut files: Vec<NodeId> = v.iter().map(|c| c.id).collect();
|
||||||
files.sort_unstable();
|
files.sort_unstable();
|
||||||
out.push(Group { size: v[0].size, hash, files });
|
out.push(Group {
|
||||||
|
size: v[0].size,
|
||||||
|
hash,
|
||||||
|
files,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cancel.is_cancelled() {
|
if cancel.is_cancelled() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
out.sort_by(|a, b| b.wasted().cmp(&a.wasted()).then(a.files[0].cmp(&b.files[0])));
|
out.sort_by(|a, b| {
|
||||||
|
b.wasted()
|
||||||
|
.cmp(&a.wasted())
|
||||||
|
.then(a.files[0].cmp(&b.files[0]))
|
||||||
|
});
|
||||||
self.stage("done", |p| p.verified_groups = out.len() as i64);
|
self.stage("done", |p| p.verified_groups = out.len() as i64);
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hashes every candidate with a bounded pool of reader threads and
|
/// Hashes every candidate with a bounded pool of reader threads and
|
||||||
/// buckets the successes by hash. Stops starting new reads once cancelled.
|
/// buckets the successes by hash. Stops starting new reads once cancelled.
|
||||||
fn split(&self, cancel: &Cancel, g: &[Cand], workers: usize, f: HashFn) -> HashMap<[u8; 32], Vec<Cand>> {
|
fn split(
|
||||||
|
&self,
|
||||||
|
cancel: &Cancel,
|
||||||
|
g: &[Cand],
|
||||||
|
workers: usize,
|
||||||
|
f: HashFn,
|
||||||
|
) -> HashMap<[u8; 32], Vec<Cand>> {
|
||||||
let next = AtomicUsize::new(0);
|
let next = AtomicUsize::new(0);
|
||||||
let results = Mutex::new(Vec::with_capacity(g.len()));
|
let results = Mutex::new(Vec::with_capacity(g.len()));
|
||||||
std::thread::scope(|s| {
|
std::thread::scope(|s| {
|
||||||
@@ -244,7 +269,15 @@ mod tests {
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
fn scan(root: &Path, workers: usize) -> Arc<RwLock<Tree>> {
|
fn scan(root: &Path, workers: usize) -> Arc<RwLock<Tree>> {
|
||||||
let s = scan::start(root.as_os_str().as_bytes(), scan::Options { workers, ..Default::default() }, Cancel::new()).unwrap();
|
let s = scan::start(
|
||||||
|
root.as_os_str().as_bytes(),
|
||||||
|
scan::Options {
|
||||||
|
workers,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Cancel::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
s.wait();
|
s.wait();
|
||||||
s.tree.clone()
|
s.tree.clone()
|
||||||
}
|
}
|
||||||
@@ -270,7 +303,11 @@ mod tests {
|
|||||||
assert_eq!(groups.len(), 2);
|
assert_eq!(groups.len(), 2);
|
||||||
let t = tree.read().unwrap();
|
let t = tree.read().unwrap();
|
||||||
let names = |g: &Group| {
|
let names = |g: &Group| {
|
||||||
let mut v: Vec<String> = g.files.iter().map(|&id| t.node(id).name_str().into_owned()).collect();
|
let mut v: Vec<String> = g
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|&id| t.node(id).name_str().into_owned())
|
||||||
|
.collect();
|
||||||
v.sort();
|
v.sort();
|
||||||
v
|
v
|
||||||
};
|
};
|
||||||
@@ -278,7 +315,10 @@ mod tests {
|
|||||||
assert_eq!(groups[0].size, 200_000);
|
assert_eq!(groups[0].size, 200_000);
|
||||||
assert_eq!(names(&groups[1]), ["small1", "small2"], "small group");
|
assert_eq!(names(&groups[1]), ["small1", "small2"], "small group");
|
||||||
let p = f.progress();
|
let p = f.progress();
|
||||||
assert!(p.candidates == 6 && p.verified_groups == 2, "progress {p:?}");
|
assert!(
|
||||||
|
p.candidates == 6 && p.verified_groups == 2,
|
||||||
|
"progress {p:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A candidate replaced by a FIFO (or a symlink to one) after the scan
|
/// A candidate replaced by a FIFO (or a symlink to one) after the scan
|
||||||
@@ -308,8 +348,12 @@ mod tests {
|
|||||||
let f = Arc::new(Finder::default());
|
let f = Arc::new(Finder::default());
|
||||||
let (tx, rx) = crossbeam_channel::bounded(1);
|
let (tx, rx) = crossbeam_channel::bounded(1);
|
||||||
let (f2, t2) = (f.clone(), tree.clone());
|
let (f2, t2) = (f.clone(), tree.clone());
|
||||||
std::thread::spawn(move || tx.send(f2.find(&Cancel::new(), &t2, Options::default()).is_some()));
|
std::thread::spawn(move || {
|
||||||
assert!(rx.recv_timeout(Duration::from_secs(5)).expect("duplicate search blocked on a FIFO"));
|
tx.send(f2.find(&Cancel::new(), &t2, Options::default()).is_some())
|
||||||
|
});
|
||||||
|
assert!(rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("duplicate search blocked on a FIFO"));
|
||||||
assert_eq!(f.progress().skipped, 2);
|
assert_eq!(f.progress().skipped, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-25
@@ -8,17 +8,27 @@ use std::io::{self, Write};
|
|||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
const FLAG_NAMES: [(Flags, &str); 12] = [
|
const FLAG_NAMES: [(Flags, &str); 12] = [
|
||||||
(inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"), (inv::FLAG_MOUNT_POINT, "mount-point"),
|
(inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"),
|
||||||
(inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"), (inv::FLAG_SKIPPED_FS, "other-fs-skipped"),
|
(inv::FLAG_MOUNT_POINT, "mount-point"),
|
||||||
(inv::FLAG_SPARSE, "sparse"), (inv::FLAG_ERROR, "error"), (inv::FLAG_INCOMPLETE, "incomplete"),
|
(inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"),
|
||||||
(inv::FLAG_LOOP, "loop-skipped"), (inv::FLAG_FOLLOWED, "followed-symlink"),
|
(inv::FLAG_SKIPPED_FS, "other-fs-skipped"),
|
||||||
(inv::FLAG_ALLOC_UNKNOWN, "allocated-unknown"), (inv::FLAG_BROKEN_LINK, "broken-symlink"),
|
(inv::FLAG_SPARSE, "sparse"),
|
||||||
|
(inv::FLAG_ERROR, "error"),
|
||||||
|
(inv::FLAG_INCOMPLETE, "incomplete"),
|
||||||
|
(inv::FLAG_LOOP, "loop-skipped"),
|
||||||
|
(inv::FLAG_FOLLOWED, "followed-symlink"),
|
||||||
|
(inv::FLAG_ALLOC_UNKNOWN, "allocated-unknown"),
|
||||||
|
(inv::FLAG_BROKEN_LINK, "broken-symlink"),
|
||||||
(inv::FLAG_HARDLINKED, "hardlinked"),
|
(inv::FLAG_HARDLINKED, "hardlinked"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Human-readable names for a node's flags.
|
/// Human-readable names for a node's flags.
|
||||||
pub fn flag_list(f: Flags) -> Vec<&'static str> {
|
pub fn flag_list(f: Flags) -> Vec<&'static str> {
|
||||||
FLAG_NAMES.iter().filter(|(b, _)| f & b != 0).map(|(_, n)| *n).collect()
|
FLAG_NAMES
|
||||||
|
.iter()
|
||||||
|
.filter(|(b, _)| f & b != 0)
|
||||||
|
.map(|(_, n)| *n)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kind_name(k: Kind) -> &'static str {
|
fn kind_name(k: Kind) -> &'static str {
|
||||||
@@ -41,7 +51,10 @@ pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Re
|
|||||||
.filter(|(_, &c)| c > 0)
|
.filter(|(_, &c)| c > 0)
|
||||||
.map(|(k, &c)| (inv::ErrKind::from_u8(k as u8).name().to_string(), c.into()))
|
.map(|(k, &c)| (inv::ErrKind::from_u8(k as u8).name().to_string(), c.into()))
|
||||||
.collect();
|
.collect();
|
||||||
let started = st.start.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64);
|
let started = st
|
||||||
|
.start
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_nanos() as i64);
|
||||||
let head = serde_json::json!({
|
let head = serde_json::json!({
|
||||||
"generator": format!("{} {}", brand::NAME, brand::VERSION),
|
"generator": format!("{} {}", brand::NAME, brand::VERSION),
|
||||||
"root": t.path_string(root),
|
"root": t.path_string(root),
|
||||||
@@ -66,7 +79,14 @@ pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Re
|
|||||||
/// proportional to depth, not tree size.
|
/// proportional to depth, not tree size.
|
||||||
fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Result<()> {
|
fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Result<()> {
|
||||||
let n = t.node(id);
|
let n = t.node(id);
|
||||||
write!(bw, "{{\"name\":{},\"type\":\"{}\",\"size\":{},\"allocated\":{}", json_str(&n.name_str()), kind_name(n.kind), n.tot_size, n.tot_alloc)?;
|
write!(
|
||||||
|
bw,
|
||||||
|
"{{\"name\":{},\"type\":\"{}\",\"size\":{},\"allocated\":{}",
|
||||||
|
json_str(&n.name_str()),
|
||||||
|
kind_name(n.kind),
|
||||||
|
n.tot_size,
|
||||||
|
n.tot_alloc
|
||||||
|
)?;
|
||||||
for (k, v) in [("files", n.files), ("dirs", n.dirs), ("errors", n.errors)] {
|
for (k, v) in [("files", n.files), ("dirs", n.dirs), ("errors", n.errors)] {
|
||||||
if v != 0 {
|
if v != 0 {
|
||||||
write!(bw, ",\"{k}\":{v}")?;
|
write!(bw, ",\"{k}\":{v}")?;
|
||||||
@@ -83,7 +103,11 @@ fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Resu
|
|||||||
return bw.write_all(b"}");
|
return bw.write_all(b"}");
|
||||||
}
|
}
|
||||||
bw.write_all(b",\"children\":[")?;
|
bw.write_all(b",\"children\":[")?;
|
||||||
for (i, c) in t.sorted_children(id, SizeMode::Allocated).into_iter().enumerate() {
|
for (i, c) in t
|
||||||
|
.sorted_children(id, SizeMode::Allocated)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
bw.write_all(b",")?;
|
bw.write_all(b",")?;
|
||||||
}
|
}
|
||||||
@@ -107,20 +131,35 @@ pub fn csv(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Res
|
|||||||
bw.write_all(b"path,type,size,allocated,files,dirs,errors,modified,flags\n")?;
|
bw.write_all(b"path,type,size,allocated,files,dirs,errors,modified,flags\n")?;
|
||||||
fn rec(bw: &mut impl Write, t: &Tree, id: NodeId, path: &[u8], depth: i64) -> io::Result<()> {
|
fn rec(bw: &mut impl Write, t: &Tree, id: NodeId, path: &[u8], depth: i64) -> io::Result<()> {
|
||||||
let n = t.node(id);
|
let n = t.node(id);
|
||||||
let modified = if n.mtime != 0 { textutil::rfc3339_utc(n.mtime) } else { String::new() };
|
let modified = if n.mtime != 0 {
|
||||||
|
textutil::rfc3339_utc(n.mtime)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
writeln!(
|
writeln!(
|
||||||
bw,
|
bw,
|
||||||
"{},{},{},{},{},{},{},{},{}",
|
"{},{},{},{},{},{},{},{},{}",
|
||||||
csv_field(&textutil::sanitize_bytes(path)),
|
csv_field(&textutil::sanitize_bytes(path)),
|
||||||
kind_name(n.kind),
|
kind_name(n.kind),
|
||||||
n.tot_size, n.tot_alloc, n.files, n.dirs, n.errors, modified,
|
n.tot_size,
|
||||||
|
n.tot_alloc,
|
||||||
|
n.files,
|
||||||
|
n.dirs,
|
||||||
|
n.errors,
|
||||||
|
modified,
|
||||||
flag_list(n.flags).join("|")
|
flag_list(n.flags).join("|")
|
||||||
)?;
|
)?;
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for c in t.sorted_children(id, SizeMode::Allocated) {
|
for c in t.sorted_children(id, SizeMode::Allocated) {
|
||||||
rec(bw, t, c, &crate::platform::join_path(path, &t.node(c).name), depth - 1)?;
|
rec(
|
||||||
|
bw,
|
||||||
|
t,
|
||||||
|
c,
|
||||||
|
&crate::platform::join_path(path, &t.node(c).name),
|
||||||
|
depth - 1,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -134,7 +173,11 @@ pub fn table(w: &mut dyn Write, t: &Tree, ids: &[NodeId], m: SizeMode) -> io::Re
|
|||||||
writeln!(w, "{:>12} {:>6} {:>12} PATH", "SIZE", "%", "FILES")?;
|
writeln!(w, "{:>12} {:>6} {:>12} PATH", "SIZE", "%", "FILES")?;
|
||||||
for &id in ids {
|
for &id in ids {
|
||||||
let n = t.node(id);
|
let n = t.node(id);
|
||||||
let files = if n.is_dir() { textutil::count(n.files as i64) } else { String::new() };
|
let files = if n.is_dir() {
|
||||||
|
textutil::count(n.files as i64)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
writeln!(
|
writeln!(
|
||||||
w,
|
w,
|
||||||
"{:>12} {:>6} {:>12} {}",
|
"{:>12} {:>6} {:>12} {}",
|
||||||
@@ -152,30 +195,65 @@ pub fn summary(w: &mut dyn Write, t: &Tree) -> io::Result<()> {
|
|||||||
let st = &t.stats;
|
let st = &t.stats;
|
||||||
let r = t.node(t.root());
|
let r = t.node(t.root());
|
||||||
writeln!(w, "Root {}", textutil::sanitize_bytes(&st.root))?;
|
writeln!(w, "Root {}", textutil::sanitize_bytes(&st.root))?;
|
||||||
writeln!(w, "Files {}", textutil::count(st.files + st.symlinks + st.others))?;
|
writeln!(
|
||||||
|
w,
|
||||||
|
"Files {}",
|
||||||
|
textutil::count(st.files + st.symlinks + st.others)
|
||||||
|
)?;
|
||||||
writeln!(w, "Directories {}", textutil::count(st.dirs))?;
|
writeln!(w, "Directories {}", textutil::count(st.dirs))?;
|
||||||
writeln!(w, "Logical size {} ({} bytes)", textutil::size(r.tot_size), r.tot_size)?;
|
writeln!(
|
||||||
writeln!(w, "Allocated size {} ({} bytes)", textutil::size(r.tot_alloc), r.tot_alloc)?;
|
w,
|
||||||
|
"Logical size {} ({} bytes)",
|
||||||
|
textutil::size(r.tot_size),
|
||||||
|
r.tot_size
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
w,
|
||||||
|
"Allocated size {} ({} bytes)",
|
||||||
|
textutil::size(r.tot_alloc),
|
||||||
|
r.tot_alloc
|
||||||
|
)?;
|
||||||
writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?;
|
writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?;
|
||||||
for (k, &c) in st.err_counts.iter().enumerate() {
|
for (k, &c) in st.err_counts.iter().enumerate() {
|
||||||
if c > 0 {
|
if c > 0 {
|
||||||
writeln!(w, "{:<15} {}", inv::ErrKind::from_u8(k as u8).name(), textutil::count(c))?;
|
writeln!(
|
||||||
|
w,
|
||||||
|
"{:<15} {}",
|
||||||
|
inv::ErrKind::from_u8(k as u8).name(),
|
||||||
|
textutil::count(c)
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st.excluded > 0 {
|
if st.excluded > 0 {
|
||||||
writeln!(w, "Excluded {} entries ({})", textutil::count(st.excluded), textutil::sanitize(&st.excludes.join(", ")))?;
|
writeln!(
|
||||||
|
w,
|
||||||
|
"Excluded {} entries ({})",
|
||||||
|
textutil::count(st.excluded),
|
||||||
|
textutil::sanitize(&st.excludes.join(", "))
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
if st.skipped_mounts > 0 {
|
if st.skipped_mounts > 0 {
|
||||||
writeln!(w, "Other FS skipped {}", textutil::count(st.skipped_mounts))?;
|
writeln!(w, "Other FS skipped {}", textutil::count(st.skipped_mounts))?;
|
||||||
}
|
}
|
||||||
if st.virtual_skipped > 0 {
|
if st.virtual_skipped > 0 {
|
||||||
writeln!(w, "Virtual FS skipped {}", textutil::count(st.virtual_skipped))?;
|
writeln!(
|
||||||
|
w,
|
||||||
|
"Virtual FS skipped {}",
|
||||||
|
textutil::count(st.virtual_skipped)
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
if st.hardlink_dups > 0 {
|
if st.hardlink_dups > 0 {
|
||||||
writeln!(w, "Hard-link dups {} (counted once)", textutil::count(st.hardlink_dups))?;
|
writeln!(
|
||||||
|
w,
|
||||||
|
"Hard-link dups {} (counted once)",
|
||||||
|
textutil::count(st.hardlink_dups)
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
if st.incomplete() {
|
if st.incomplete() {
|
||||||
writeln!(w, "WARNING: totals are incomplete — some data could not be inspected.")?;
|
writeln!(
|
||||||
|
w,
|
||||||
|
"WARNING: totals are incomplete — some data could not be inspected."
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -199,11 +277,27 @@ mod tests {
|
|||||||
fn outputs_are_terminal_safe() {
|
fn outputs_are_terminal_safe() {
|
||||||
let t = hostile_tree();
|
let t = hostile_tree();
|
||||||
for out in [
|
for out in [
|
||||||
{ let mut b = Vec::new(); json(&mut b, &t, 0, -1).unwrap(); b },
|
{
|
||||||
{ let mut b = Vec::new(); csv(&mut b, &t, 0, -1).unwrap(); b },
|
let mut b = Vec::new();
|
||||||
{ let mut b = Vec::new(); table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap(); b },
|
json(&mut b, &t, 0, -1).unwrap();
|
||||||
|
b
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut b = Vec::new();
|
||||||
|
csv(&mut b, &t, 0, -1).unwrap();
|
||||||
|
b
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut b = Vec::new();
|
||||||
|
table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap();
|
||||||
|
b
|
||||||
|
},
|
||||||
] {
|
] {
|
||||||
assert!(!out.iter().any(|&c| c == 0x1b || c == 0x07), "{}", String::from_utf8_lossy(&out));
|
assert!(
|
||||||
|
!out.iter().any(|&c| c == 0x1b || c == 0x07),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut b = Vec::new();
|
let mut b = Vec::new();
|
||||||
json(&mut b, &t, 0, -1).unwrap();
|
json(&mut b, &t, 0, -1).unwrap();
|
||||||
|
|||||||
+12
-2
@@ -41,7 +41,9 @@ pub fn apply(cancel: &Cancel, t: &Tree, q: Arc<Query>, m: SizeMode) -> Option<Fi
|
|||||||
let mut matched = vec![false; n];
|
let mut matched = vec![false; n];
|
||||||
let mut covered = vec![false; n];
|
let mut covered = vec![false; n];
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64);
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_nanos() as i64);
|
||||||
let mut c = Ctx::new(t, 0, now);
|
let mut c = Ctx::new(t, 0, now);
|
||||||
if q.uses_path {
|
if q.uses_path {
|
||||||
c.dir_paths = Some(vec![String::new(); n]);
|
c.dir_paths = Some(vec![String::new(); n]);
|
||||||
@@ -75,5 +77,13 @@ pub fn apply(cancel: &Cancel, t: &Tree, q: Arc<Query>, m: SizeMode) -> Option<Fi
|
|||||||
sizes[p] = sizes[p].saturating_add(sizes[i]);
|
sizes[p] = sizes[p].saturating_add(sizes[i]);
|
||||||
}
|
}
|
||||||
let total = sizes.first().copied().unwrap_or(0);
|
let total = sizes.first().copied().unwrap_or(0);
|
||||||
Some(FilterResult { query: q, mode: m, sizes, matched, count, total, elapsed: start.elapsed() })
|
Some(FilterResult {
|
||||||
|
query: q,
|
||||||
|
mode: m,
|
||||||
|
sizes,
|
||||||
|
matched,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
elapsed: start.elapsed(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -32,7 +32,16 @@ fn push_lower(buf: &mut String, name: &[u8]) {
|
|||||||
|
|
||||||
impl<'a> Ctx<'a> {
|
impl<'a> Ctx<'a> {
|
||||||
pub fn new(tree: &'a Tree, id: NodeId, now: i64) -> Ctx<'a> {
|
pub fn new(tree: &'a Tree, id: NodeId, now: i64) -> Ctx<'a> {
|
||||||
Ctx { tree, id, node: tree.node(id), now, path: String::new(), has_path: false, name: String::new(), dir_paths: None }
|
Ctx {
|
||||||
|
tree,
|
||||||
|
id,
|
||||||
|
node: tree.node(id),
|
||||||
|
now,
|
||||||
|
path: String::new(),
|
||||||
|
has_path: false,
|
||||||
|
name: String::new(),
|
||||||
|
dir_paths: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves the context to another node.
|
/// Moves the context to another node.
|
||||||
@@ -82,7 +91,12 @@ impl<'a> Ctx<'a> {
|
|||||||
pub(super) fn fill_path(&mut self) {
|
pub(super) fn fill_path(&mut self) {
|
||||||
self.has_path = true;
|
self.has_path = true;
|
||||||
self.path.clear();
|
self.path.clear();
|
||||||
if let Some(pp) = self.dir_paths.as_ref().and_then(|dp| dp.get(self.node.parent as usize)).filter(|p| !p.is_empty()) {
|
if let Some(pp) = self
|
||||||
|
.dir_paths
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|dp| dp.get(self.node.parent as usize))
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
{
|
||||||
self.path.push_str(pp);
|
self.path.push_str(pp);
|
||||||
if !pp.ends_with('/') {
|
if !pp.ends_with('/') {
|
||||||
self.path.push('/');
|
self.path.push('/');
|
||||||
@@ -90,7 +104,8 @@ impl<'a> Ctx<'a> {
|
|||||||
push_lower(&mut self.path, &self.node.name);
|
push_lower(&mut self.path, &self.node.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.path.push_str(&self.tree.path_string(self.id).to_lowercase());
|
self.path
|
||||||
|
.push_str(&self.tree.path_string(self.id).to_lowercase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -17,7 +17,11 @@ pub(super) struct Token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn tok(kind: TokKind, val: &str, pos: usize) -> Token {
|
fn tok(kind: TokKind, val: &str, pos: usize) -> Token {
|
||||||
Token { kind, val: val.to_string(), pos }
|
Token {
|
||||||
|
kind,
|
||||||
|
val: val.to_string(),
|
||||||
|
pos,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ASCII white space as Go's unicode.IsSpace sees it.
|
/// ASCII white space as Go's unicode.IsSpace sees it.
|
||||||
@@ -64,12 +68,18 @@ pub(super) fn lex(s: &str) -> Result<Vec<Token>, String> {
|
|||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
// Only ASCII bytes were dropped, so v is still valid UTF-8.
|
// Only ASCII bytes were dropped, so v is still valid UTF-8.
|
||||||
out.push(Token { kind: Str, val: String::from_utf8_lossy(&v).into_owned(), pos: start });
|
out.push(Token {
|
||||||
|
kind: Str,
|
||||||
|
val: String::from_utf8_lossy(&v).into_owned(),
|
||||||
|
pos: start,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
b'=' | b'!' | b'<' | b'>' | b'~' | b'&' | b'|' => {
|
b'=' | b'!' | b'<' | b'>' | b'~' | b'&' | b'|' => {
|
||||||
let start = i;
|
let start = i;
|
||||||
let n = b.get(i + 1).copied();
|
let n = b.get(i + 1).copied();
|
||||||
let two = n == Some(b'=') || (c == b'&' && n == Some(b'&')) || (c == b'|' && n == Some(b'|'));
|
let two = n == Some(b'=')
|
||||||
|
|| (c == b'&' && n == Some(b'&'))
|
||||||
|
|| (c == b'|' && n == Some(b'|'));
|
||||||
let op = &s[i..i + 1 + two as usize];
|
let op = &s[i..i + 1 + two as usize];
|
||||||
i += op.len();
|
i += op.len();
|
||||||
match op {
|
match op {
|
||||||
|
|||||||
+52
-11
@@ -43,7 +43,10 @@ mod tests {
|
|||||||
let n = t.node_mut(id);
|
let n = t.node_mut(id);
|
||||||
n.size = size;
|
n.size = size;
|
||||||
n.alloc = size;
|
n.alloc = size;
|
||||||
n.mtime = (SystemTime::now() - age).duration_since(UNIX_EPOCH).unwrap().as_nanos() as i64;
|
n.mtime = (SystemTime::now() - age)
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos() as i64;
|
||||||
n.cat = category_for(&ext(name.as_bytes()), false, false);
|
n.cat = category_for(&ext(name.as_bytes()), false, false);
|
||||||
};
|
};
|
||||||
add(vms, "ubuntu.qcow2", 80 << 30, 400 * DAY);
|
add(vms, "ubuntu.qcow2", 80 << 30, 400 * DAY);
|
||||||
@@ -58,7 +61,10 @@ mod tests {
|
|||||||
fn names(tr: &Tree, q: &str) -> HashSet<String> {
|
fn names(tr: &Tree, q: &str) -> HashSet<String> {
|
||||||
let pq = parse(q, SystemTime::now()).unwrap_or_else(|e| panic!("parse({q:?}): {e}"));
|
let pq = parse(q, SystemTime::now()).unwrap_or_else(|e| panic!("parse({q:?}): {e}"));
|
||||||
let r = apply(&Cancel::new(), tr, Arc::new(pq), SizeMode::Logical).unwrap();
|
let r = apply(&Cancel::new(), tr, Arc::new(pq), SizeMode::Logical).unwrap();
|
||||||
(0..tr.len() as NodeId).filter(|&i| r.is_match(i)).map(|i| tr.node(i).name_str().into_owned()).collect()
|
(0..tr.len() as NodeId)
|
||||||
|
.filter(|&i| r.is_match(i))
|
||||||
|
.map(|i| tr.node(i).name_str().into_owned())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -70,7 +76,10 @@ mod tests {
|
|||||||
("size > 1GB", &["vms", "ubuntu.qcow2", "win.vmdk"]),
|
("size > 1GB", &["vms", "ubuntu.qcow2", "win.vmdk"]),
|
||||||
("size > 1GB AND type = file", &["ubuntu.qcow2", "win.vmdk"]),
|
("size > 1GB AND type = file", &["ubuntu.qcow2", "win.vmdk"]),
|
||||||
("ext = qcow2", &["ubuntu.qcow2"]),
|
("ext = qcow2", &["ubuntu.qcow2"]),
|
||||||
("size > 500MB AND ext IN (iso,qcow2,vmdk)", &["ubuntu.qcow2", "win.vmdk", "blob.iso"]),
|
(
|
||||||
|
"size > 500MB AND ext IN (iso,qcow2,vmdk)",
|
||||||
|
&["ubuntu.qcow2", "win.vmdk", "blob.iso"],
|
||||||
|
),
|
||||||
("path contains cache", &["cache", "blob.iso", "x.tmp"]),
|
("path contains cache", &["cache", "blob.iso", "x.tmp"]),
|
||||||
("age > 365d", &["ubuntu.qcow2"]),
|
("age > 365d", &["ubuntu.qcow2"]),
|
||||||
("NOT type = dir AND size < 1k", &["notes.txt", "x.tmp"]),
|
("NOT type = dir AND size < 1k", &["notes.txt", "x.tmp"]),
|
||||||
@@ -91,16 +100,32 @@ mod tests {
|
|||||||
let tr = tree();
|
let tr = tree();
|
||||||
let q = parse("name = vms OR ext = qcow2", SystemTime::now()).unwrap();
|
let q = parse("name = vms OR ext = qcow2", SystemTime::now()).unwrap();
|
||||||
let r = apply(&Cancel::new(), &tr, Arc::new(q), SizeMode::Logical).unwrap();
|
let r = apply(&Cancel::new(), &tr, Arc::new(q), SizeMode::Logical).unwrap();
|
||||||
assert_eq!(r.total, tr.node(1).tot_size, "matching dir must not double count matching child");
|
assert_eq!(
|
||||||
|
r.total,
|
||||||
|
tr.node(1).tot_size,
|
||||||
|
"matching dir must not double count matching child"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_errors() {
|
fn parse_errors() {
|
||||||
for q in [
|
for q in [
|
||||||
"", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5",
|
"",
|
||||||
"name matches '['", "type = blob", "'unterminated", "a & b", "category = nope",
|
"size >",
|
||||||
|
"size > banana",
|
||||||
|
"(ext = iso",
|
||||||
|
"ext = iso)",
|
||||||
|
"size contains 5",
|
||||||
|
"name matches '['",
|
||||||
|
"type = blob",
|
||||||
|
"'unterminated",
|
||||||
|
"a & b",
|
||||||
|
"category = nope",
|
||||||
] {
|
] {
|
||||||
assert!(parse(q, SystemTime::now()).is_err(), "parse({q:?}) should fail");
|
assert!(
|
||||||
|
parse(q, SystemTime::now()).is_err(),
|
||||||
|
"parse({q:?}) should fail"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +134,20 @@ mod tests {
|
|||||||
let tr = tree();
|
let tr = tree();
|
||||||
assert_eq!(names(&tr, "modified < 2000-01-01"), HashSet::new());
|
assert_eq!(names(&tr, "modified < 2000-01-01"), HashSet::new());
|
||||||
assert_eq!(names(&tr, "modified > '2000-01-01 9:30'").len(), 5);
|
assert_eq!(names(&tr, "modified > '2000-01-01 9:30'").len(), 5);
|
||||||
assert_eq!(names(&tr, "modified > 2000-01-01T00:00:00.5+01:00").len(), 5);
|
assert_eq!(
|
||||||
|
names(&tr, "modified > 2000-01-01T00:00:00.5+01:00").len(),
|
||||||
|
5
|
||||||
|
);
|
||||||
assert_eq!(names(&tr, "modified > 30d").len(), 4);
|
assert_eq!(names(&tr, "modified > 30d").len(), 4);
|
||||||
for bad in ["modified > 2024-02-30", "modified > 2024-01-31T10:00", "ext = 'a['"] {
|
for bad in [
|
||||||
assert!(parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"), "{bad}");
|
"modified > 2024-02-30",
|
||||||
|
"modified > 2024-01-31T10:00",
|
||||||
|
"ext = 'a['",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert!(names(&tr, "[").is_empty()); // malformed bare glob matches nothing
|
assert!(names(&tr, "[").is_empty()); // malformed bare glob matches nothing
|
||||||
assert!(names(&tr, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0
|
assert!(names(&tr, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0
|
||||||
@@ -120,7 +155,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_size_units() {
|
fn parse_size_units() {
|
||||||
for (input, want) in [("1GB", 1_000_000_000), ("1GiB", 1 << 30), ("1.5k", 1536), ("500MB", 500_000_000), ("10", 10)] {
|
for (input, want) in [
|
||||||
|
("1GB", 1_000_000_000),
|
||||||
|
("1GiB", 1 << 30),
|
||||||
|
("1.5k", 1536),
|
||||||
|
("500MB", 500_000_000),
|
||||||
|
("10", 10),
|
||||||
|
] {
|
||||||
assert_eq!(parse_size(input), Ok(want), "{input}");
|
assert_eq!(parse_size(input), Ok(want), "{input}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-17
@@ -43,7 +43,8 @@ fn field_by_name(s: &str) -> Option<Field> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Field names for help text.
|
/// Field names for help text.
|
||||||
pub const FIELDS: &str = "name path ext type category size allocated age modified owner group files flag";
|
pub const FIELDS: &str =
|
||||||
|
"name path ext type category size allocated age modified owner group files flag";
|
||||||
|
|
||||||
/// A compiled filter.
|
/// A compiled filter.
|
||||||
pub struct Query {
|
pub struct Query {
|
||||||
@@ -68,7 +69,12 @@ struct Parser {
|
|||||||
/// Compiles a query. now anchors relative ages (pass SystemTime::now()).
|
/// Compiles a query. now anchors relative ages (pass SystemTime::now()).
|
||||||
pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
|
pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
|
||||||
let toks = lex(s)?;
|
let toks = lex(s)?;
|
||||||
let mut p = Parser { toks, i: 0, uses_path: false, now };
|
let mut p = Parser {
|
||||||
|
toks,
|
||||||
|
i: 0,
|
||||||
|
uses_path: false,
|
||||||
|
now,
|
||||||
|
};
|
||||||
if p.peek().kind == TokKind::Eof {
|
if p.peek().kind == TokKind::Eof {
|
||||||
return Err("empty query".into());
|
return Err("empty query".into());
|
||||||
}
|
}
|
||||||
@@ -77,7 +83,11 @@ pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
|
|||||||
if t.kind != TokKind::Eof {
|
if t.kind != TokKind::Eof {
|
||||||
return Err(format!("unexpected {:?} at {}", t.val, t.pos + 1));
|
return Err(format!("unexpected {:?} at {}", t.val, t.pos + 1));
|
||||||
}
|
}
|
||||||
Ok(Query { src: s.to_string(), root, uses_path: p.uses_path })
|
Ok(Query {
|
||||||
|
src: s.to_string(),
|
||||||
|
root,
|
||||||
|
uses_path: p.uses_path,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_kw(t: &Token, kw: &str) -> bool {
|
fn is_kw(t: &Token, kw: &str) -> bool {
|
||||||
@@ -193,7 +203,10 @@ impl Parser {
|
|||||||
if f == Field::Path {
|
if f == Field::Path {
|
||||||
self.uses_path = true;
|
self.uses_path = true;
|
||||||
}
|
}
|
||||||
if matches!(f, Field::Size | Field::Allocated | Field::Age | Field::Modified | Field::Files) {
|
if matches!(
|
||||||
|
f,
|
||||||
|
Field::Size | Field::Allocated | Field::Age | Field::Modified | Field::Files
|
||||||
|
) {
|
||||||
let nop = match op.as_str() {
|
let nop = match op.as_str() {
|
||||||
"=" | "in" => NumOp::Eq,
|
"=" | "in" => NumOp::Eq,
|
||||||
"!=" => NumOp::Ne,
|
"!=" => NumOp::Ne,
|
||||||
@@ -203,7 +216,10 @@ impl Parser {
|
|||||||
"<=" => NumOp::Le,
|
"<=" => NumOp::Le,
|
||||||
_ => return Err(format!("{op} cannot be used with numeric field")),
|
_ => return Err(format!("{op} cannot be used with numeric field")),
|
||||||
};
|
};
|
||||||
let nums = vals.iter().map(|v| self.number(f, &v.val)).collect::<Result<Vec<_>, _>>()?;
|
let nums = vals
|
||||||
|
.iter()
|
||||||
|
.map(|v| self.number(f, &v.val))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
return Ok(Expr::Num(f, nop, nums));
|
return Ok(Expr::Num(f, nop, nums));
|
||||||
}
|
}
|
||||||
// String-like fields.
|
// String-like fields.
|
||||||
@@ -244,7 +260,11 @@ impl Parser {
|
|||||||
} else {
|
} else {
|
||||||
Expr::Eq(f, set)
|
Expr::Eq(f, set)
|
||||||
};
|
};
|
||||||
Ok(if op == "!=" { Expr::Not(Box::new(n)) } else { n })
|
Ok(if op == "!=" {
|
||||||
|
Expr::Not(Box::new(n))
|
||||||
|
} else {
|
||||||
|
n
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => Err(format!("operator {op} not valid for {}", op_tok.val)),
|
_ => Err(format!("operator {op} not valid for {}", op_tok.val)),
|
||||||
}
|
}
|
||||||
@@ -256,7 +276,9 @@ impl Parser {
|
|||||||
fn number(&self, f: Field, s: &str) -> Result<i64, String> {
|
fn number(&self, f: Field, s: &str) -> Result<i64, String> {
|
||||||
match f {
|
match f {
|
||||||
Field::Size | Field::Allocated => parse_size(s),
|
Field::Size | Field::Allocated => parse_size(s),
|
||||||
Field::Files => s.parse::<i64>().map_err(|e| format!("bad number {s:?}: {e}")),
|
Field::Files => s
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|e| format!("bad number {s:?}: {e}")),
|
||||||
Field::Age => parse_age(s).map(nanos),
|
Field::Age => parse_age(s).map(nanos),
|
||||||
Field::Modified => {
|
Field::Modified => {
|
||||||
if let Some(t) = parse_date(s) {
|
if let Some(t) = parse_date(s) {
|
||||||
@@ -266,7 +288,9 @@ impl Parser {
|
|||||||
let now = self.now.duration_since(UNIX_EPOCH).map(nanos).unwrap_or(0);
|
let now = self.now.duration_since(UNIX_EPOCH).map(nanos).unwrap_or(0);
|
||||||
return Ok(now.saturating_sub(nanos(d)));
|
return Ok(now.saturating_sub(nanos(d)));
|
||||||
}
|
}
|
||||||
Err(format!("bad date {s:?} (use YYYY-MM-DD or an age like 30d)"))
|
Err(format!(
|
||||||
|
"bad date {s:?} (use YYYY-MM-DD or an age like 30d)"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
_ => Err("not numeric".into()),
|
_ => Err("not numeric".into()),
|
||||||
}
|
}
|
||||||
@@ -285,7 +309,11 @@ pub(super) fn name_match(v: &str) -> Expr {
|
|||||||
let lv = v.to_lowercase();
|
let lv = v.to_lowercase();
|
||||||
if has_glob(v) {
|
if has_glob(v) {
|
||||||
// Go's filepath.Match never matches a malformed pattern.
|
// Go's filepath.Match never matches a malformed pattern.
|
||||||
return if glob_ok(lv.as_bytes()) { Expr::Glob(Field::Name, lv) } else { Expr::False };
|
return if glob_ok(lv.as_bytes()) {
|
||||||
|
Expr::Glob(Field::Name, lv)
|
||||||
|
} else {
|
||||||
|
Expr::False
|
||||||
|
};
|
||||||
}
|
}
|
||||||
Expr::Contains(Field::Name, lv)
|
Expr::Contains(Field::Name, lv)
|
||||||
}
|
}
|
||||||
@@ -365,7 +393,10 @@ fn flag_bit(s: &str) -> Option<Flags> {
|
|||||||
|
|
||||||
/// Splits a leading run of digits and dots from s.
|
/// Splits a leading run of digits and dots from s.
|
||||||
fn split_num(s: &str) -> (&str, &str) {
|
fn split_num(s: &str) -> (&str, &str) {
|
||||||
let i = s.bytes().position(|c| !(c.is_ascii_digit() || c == b'.')).unwrap_or(s.len());
|
let i = s
|
||||||
|
.bytes()
|
||||||
|
.position(|c| !(c.is_ascii_digit() || c == b'.'))
|
||||||
|
.unwrap_or(s.len());
|
||||||
s.split_at(i)
|
s.split_at(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +405,14 @@ fn split_num(s: &str) -> (&str, &str) {
|
|||||||
/// (textutil::set_si).
|
/// (textutil::set_si).
|
||||||
pub fn parse_size(s: &str) -> Result<i64, String> {
|
pub fn parse_size(s: &str) -> Result<i64, String> {
|
||||||
let (num, unit_raw) = split_num(s);
|
let (num, unit_raw) = split_num(s);
|
||||||
let Ok(v) = num.parse::<f64>() else { return Err(format!("bad size {s:?}")) };
|
let Ok(v) = num.parse::<f64>() else {
|
||||||
let bare: f64 = if crate::textutil::si() { 1000.0 } else { 1024.0 };
|
return Err(format!("bad size {s:?}"));
|
||||||
|
};
|
||||||
|
let bare: f64 = if crate::textutil::si() {
|
||||||
|
1000.0
|
||||||
|
} else {
|
||||||
|
1024.0
|
||||||
|
};
|
||||||
let m = match unit_raw.trim().to_lowercase().as_str() {
|
let m = match unit_raw.trim().to_lowercase().as_str() {
|
||||||
"" | "b" => 1.0,
|
"" | "b" => 1.0,
|
||||||
"k" => bare,
|
"k" => bare,
|
||||||
@@ -405,7 +442,9 @@ pub fn parse_size(s: &str) -> Result<i64, String> {
|
|||||||
/// Parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y.
|
/// Parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y.
|
||||||
pub fn parse_age(s: &str) -> Result<Duration, String> {
|
pub fn parse_age(s: &str) -> Result<Duration, String> {
|
||||||
let (num, unit) = split_num(s);
|
let (num, unit) = split_num(s);
|
||||||
let Ok(v) = num.parse::<f64>() else { return Err(format!("bad age {s:?}")) };
|
let Ok(v) = num.parse::<f64>() else {
|
||||||
|
return Err(format!("bad age {s:?}"));
|
||||||
|
};
|
||||||
const DAY: f64 = 86400.0;
|
const DAY: f64 = 86400.0;
|
||||||
let secs = match unit.to_lowercase().as_str() {
|
let secs = match unit.to_lowercase().as_str() {
|
||||||
"s" | "sec" => 1.0,
|
"s" | "sec" => 1.0,
|
||||||
@@ -415,7 +454,11 @@ pub fn parse_age(s: &str) -> Result<Duration, String> {
|
|||||||
"w" => 7.0 * DAY,
|
"w" => 7.0 * DAY,
|
||||||
"m" | "mo" => 30.0 * DAY,
|
"m" | "mo" => 30.0 * DAY,
|
||||||
"y" => 365.0 * DAY,
|
"y" => 365.0 * DAY,
|
||||||
_ => return Err(format!("unknown age unit {unit:?} (s, min, h, d, w, mo, y)")),
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown age unit {unit:?} (s, min, h, d, w, mo, y)"
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Saturates where Go's time.Duration would overflow.
|
// Saturates where Go's time.Duration would overflow.
|
||||||
Ok(Duration::from_nanos((v * secs * 1e9) as u64))
|
Ok(Duration::from_nanos((v * secs * 1e9) as u64))
|
||||||
@@ -428,10 +471,17 @@ fn parse_date(s: &str) -> Option<i64> {
|
|||||||
let b = s.as_bytes();
|
let b = s.as_bytes();
|
||||||
let fixed = |i: usize, n: usize| -> Option<i64> {
|
let fixed = |i: usize, n: usize| -> Option<i64> {
|
||||||
let d = b.get(i..i + n)?;
|
let d = b.get(i..i + n)?;
|
||||||
d.iter().all(u8::is_ascii_digit).then(|| d.iter().fold(0, |a, &c| a * 10 + (c - b'0') as i64))
|
d.iter()
|
||||||
|
.all(u8::is_ascii_digit)
|
||||||
|
.then(|| d.iter().fold(0, |a, &c| a * 10 + (c - b'0') as i64))
|
||||||
};
|
};
|
||||||
let (y, mo, d) = (fixed(0, 4)?, fixed(5, 2)?, fixed(8, 2)?);
|
let (y, mo, d) = (fixed(0, 4)?, fixed(5, 2)?, fixed(8, 2)?);
|
||||||
if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !(1..=12).contains(&mo) || d < 1 || d > days_in(y, mo) {
|
if b.get(4) != Some(&b'-')
|
||||||
|
|| b.get(7) != Some(&b'-')
|
||||||
|
|| !(1..=12).contains(&mo)
|
||||||
|
|| d < 1
|
||||||
|
|| d > days_in(y, mo)
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if b.len() == 10 {
|
if b.len() == 10 {
|
||||||
@@ -473,7 +523,10 @@ fn parse_date(s: &str) -> Option<i64> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
for k in 0..9 {
|
for k in 0..9 {
|
||||||
ns = ns * 10 + b.get(i + 1 + k).filter(|_| k < n).map_or(0, |c| (c - b'0') as i64);
|
ns = ns * 10
|
||||||
|
+ b.get(i + 1 + k)
|
||||||
|
.filter(|_| k < n)
|
||||||
|
.map_or(0, |c| (c - b'0') as i64);
|
||||||
}
|
}
|
||||||
i += 1 + n;
|
i += 1 + n;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,33 @@ pub enum Category {
|
|||||||
pub const NUM_CATEGORIES: usize = 12;
|
pub const NUM_CATEGORIES: usize = 12;
|
||||||
|
|
||||||
const CATEGORY_NAMES: [&str; NUM_CATEGORIES] = [
|
const CATEGORY_NAMES: [&str; NUM_CATEGORIES] = [
|
||||||
"Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database",
|
"Other",
|
||||||
"Source code", "Document", "Executable", "Log", "Cache",
|
"Image",
|
||||||
|
"Video",
|
||||||
|
"Audio",
|
||||||
|
"Archive",
|
||||||
|
"Disk image",
|
||||||
|
"Database",
|
||||||
|
"Source code",
|
||||||
|
"Document",
|
||||||
|
"Executable",
|
||||||
|
"Log",
|
||||||
|
"Cache",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALL: [Category; NUM_CATEGORIES] = [
|
const ALL: [Category; NUM_CATEGORIES] = [
|
||||||
Category::Other, Category::Image, Category::Video, Category::Audio, Category::Archive,
|
Category::Other,
|
||||||
Category::DiskImage, Category::Database, Category::Code, Category::Document,
|
Category::Image,
|
||||||
Category::Executable, Category::Log, Category::Cache,
|
Category::Video,
|
||||||
|
Category::Audio,
|
||||||
|
Category::Archive,
|
||||||
|
Category::DiskImage,
|
||||||
|
Category::Database,
|
||||||
|
Category::Code,
|
||||||
|
Category::Document,
|
||||||
|
Category::Executable,
|
||||||
|
Category::Log,
|
||||||
|
Category::Cache,
|
||||||
];
|
];
|
||||||
|
|
||||||
impl Category {
|
impl Category {
|
||||||
@@ -98,8 +117,12 @@ fn ext_table() -> &'static HashMap<&'static str, Category> {
|
|||||||
pub fn category_for(ext: &str, in_cache: bool, in_log: bool) -> Category {
|
pub fn category_for(ext: &str, in_cache: bool, in_log: bool) -> Category {
|
||||||
let c = ext_table().get(ext).copied();
|
let c = ext_table().get(ext).copied();
|
||||||
match c {
|
match c {
|
||||||
_ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => Category::Cache,
|
_ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => {
|
||||||
_ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => Category::Log,
|
Category::Cache
|
||||||
|
}
|
||||||
|
_ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => {
|
||||||
|
Category::Log
|
||||||
|
}
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => Category::Other,
|
None => Category::Other,
|
||||||
}
|
}
|
||||||
@@ -114,7 +137,8 @@ pub fn path_hints(path: &[u8]) -> (bool, bool) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match part.to_ascii_lowercase().as_slice() {
|
match part.to_ascii_lowercase().as_slice() {
|
||||||
b"cache" | b".cache" | b"caches" | b"__pycache__" | b".npm" | b".gradle" | b"inetcache" | b"temp" | b"tmp" => in_cache = true,
|
b"cache" | b".cache" | b"caches" | b"__pycache__" | b".npm" | b".gradle"
|
||||||
|
| b"inetcache" | b"temp" | b"tmp" => in_cache = true,
|
||||||
b"log" | b"logs" | b".logs" | b"journal" => in_log = true,
|
b"log" | b"logs" | b".logs" | b"journal" => in_log = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-9
@@ -129,7 +129,10 @@ struct Chunked<T> {
|
|||||||
|
|
||||||
impl<T> Chunked<T> {
|
impl<T> Chunked<T> {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Chunked { c: Vec::new(), n: 0 }
|
Chunked {
|
||||||
|
c: Vec::new(),
|
||||||
|
n: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn add(&mut self, v: T) -> u32 {
|
fn add(&mut self, v: T) -> u32 {
|
||||||
if self.n & (CHUNK_SIZE - 1) == 0 && self.n >> CHUNK_BITS == self.c.len() {
|
if self.n & (CHUNK_SIZE - 1) == 0 && self.n >> CHUNK_BITS == self.c.len() {
|
||||||
@@ -202,9 +205,22 @@ impl Tree {
|
|||||||
next_sibling: NO_NODE,
|
next_sibling: NO_NODE,
|
||||||
dir: 0,
|
dir: 0,
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
size: 0, alloc: 0, tot_size: 0, tot_alloc: 0, mtime: 0,
|
size: 0,
|
||||||
files: 0, dirs: 0, errors: 0, mode: 0, uid: 0, gid: 0, nlink: 0,
|
alloc: 0,
|
||||||
ext: 0, flags: 0, kind, cat: Category::Other,
|
tot_size: 0,
|
||||||
|
tot_alloc: 0,
|
||||||
|
mtime: 0,
|
||||||
|
files: 0,
|
||||||
|
dirs: 0,
|
||||||
|
errors: 0,
|
||||||
|
mode: 0,
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
nlink: 0,
|
||||||
|
ext: 0,
|
||||||
|
flags: 0,
|
||||||
|
kind,
|
||||||
|
cat: Category::Other,
|
||||||
};
|
};
|
||||||
if kind == Kind::Dir {
|
if kind == Kind::Dir {
|
||||||
n.dir = self.dirs.add([0; NUM_CATEGORIES]);
|
n.dir = self.dirs.add([0; NUM_CATEGORIES]);
|
||||||
@@ -274,7 +290,10 @@ impl Tree {
|
|||||||
|
|
||||||
/// Iterates the children of id (in reverse insertion order).
|
/// Iterates the children of id (in reverse insertion order).
|
||||||
pub fn children(&self, id: NodeId) -> Children<'_> {
|
pub fn children(&self, id: NodeId) -> Children<'_> {
|
||||||
Children { t: self, cur: self.node(id).first_child }
|
Children {
|
||||||
|
t: self,
|
||||||
|
cur: self.node(id).first_child,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn child_count(&self, id: NodeId) -> usize {
|
pub fn child_count(&self, id: NodeId) -> usize {
|
||||||
@@ -408,9 +427,17 @@ struct Shallow {
|
|||||||
impl Node {
|
impl Node {
|
||||||
fn clone_shallow(&self) -> Shallow {
|
fn clone_shallow(&self) -> Shallow {
|
||||||
Shallow {
|
Shallow {
|
||||||
parent: self.parent, dir: self.dir, tot_size: self.tot_size, tot_alloc: self.tot_alloc,
|
parent: self.parent,
|
||||||
alloc: self.alloc, files: self.files, dirs: self.dirs, errors: self.errors,
|
dir: self.dir,
|
||||||
flags: self.flags, kind: self.kind, cat: self.cat,
|
tot_size: self.tot_size,
|
||||||
|
tot_alloc: self.tot_alloc,
|
||||||
|
alloc: self.alloc,
|
||||||
|
files: self.files,
|
||||||
|
dirs: self.dirs,
|
||||||
|
errors: self.errors,
|
||||||
|
flags: self.flags,
|
||||||
|
kind: self.kind,
|
||||||
|
cat: self.cat,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,7 +498,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ext_and_category() {
|
fn ext_and_category() {
|
||||||
for (input, want) in [("a.QCOW2", "qcow2"), (".bashrc", ""), ("noext", ""), ("x.tar.gz", "gz"), ("trail.", "")] {
|
for (input, want) in [
|
||||||
|
("a.QCOW2", "qcow2"),
|
||||||
|
(".bashrc", ""),
|
||||||
|
("noext", ""),
|
||||||
|
("x.tar.gz", "gz"),
|
||||||
|
("trail.", ""),
|
||||||
|
] {
|
||||||
assert_eq!(ext(input.as_bytes()), want, "{input}");
|
assert_eq!(ext(input.as_bytes()), want, "{input}");
|
||||||
}
|
}
|
||||||
assert_eq!(category_for("qcow2", false, false), Category::DiskImage);
|
assert_eq!(category_for("qcow2", false, false), Category::DiskImage);
|
||||||
|
|||||||
+16
-3
@@ -47,7 +47,10 @@ pub struct TopK {
|
|||||||
|
|
||||||
impl TopK {
|
impl TopK {
|
||||||
pub fn new(k: usize) -> TopK {
|
pub fn new(k: usize) -> TopK {
|
||||||
TopK { k, h: BinaryHeap::with_capacity(k.min(1 << 16)) }
|
TopK {
|
||||||
|
k,
|
||||||
|
h: BinaryHeap::with_capacity(k.min(1 << 16)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn offer(&mut self, id: NodeId, key: i64) {
|
pub fn offer(&mut self, id: NodeId, key: i64) {
|
||||||
@@ -67,7 +70,11 @@ impl TopK {
|
|||||||
/// The collected IDs, largest key first (ties by ID).
|
/// The collected IDs, largest key first (ties by ID).
|
||||||
pub fn sorted(self) -> Vec<NodeId> {
|
pub fn sorted(self) -> Vec<NodeId> {
|
||||||
// into_sorted_vec is ascending in Reverse order = descending key.
|
// into_sorted_vec is ascending in Reverse order = descending key.
|
||||||
self.h.into_sorted_vec().into_iter().map(|Reverse((_, Reverse(id)))| id).collect()
|
self.h
|
||||||
|
.into_sorted_vec()
|
||||||
|
.into_iter()
|
||||||
|
.map(|Reverse((_, Reverse(id)))| id)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +94,13 @@ impl Tree {
|
|||||||
|
|
||||||
/// The k nodes beneath (and including) root with the largest key,
|
/// The k nodes beneath (and including) root with the largest key,
|
||||||
/// considering only nodes accepted by keep.
|
/// considering only nodes accepted by keep.
|
||||||
pub fn top(&self, root: NodeId, k: usize, keep: impl Fn(&Node) -> bool, key: impl Fn(&Node) -> i64) -> Vec<NodeId> {
|
pub fn top(
|
||||||
|
&self,
|
||||||
|
root: NodeId,
|
||||||
|
k: usize,
|
||||||
|
keep: impl Fn(&Node) -> bool,
|
||||||
|
key: impl Fn(&Node) -> i64,
|
||||||
|
) -> Vec<NodeId> {
|
||||||
let mut tk = TopK::new(k);
|
let mut tk = TopK::new(k);
|
||||||
self.walk(root, |id, n| {
|
self.walk(root, |id, n| {
|
||||||
if keep(n) {
|
if keep(n) {
|
||||||
|
|||||||
+37
-7
@@ -18,13 +18,25 @@ pub const NUM_ERR_KINDS: usize = 5;
|
|||||||
|
|
||||||
impl ErrKind {
|
impl ErrKind {
|
||||||
pub fn from_u8(v: u8) -> ErrKind {
|
pub fn from_u8(v: u8) -> ErrKind {
|
||||||
[ErrKind::Permission, ErrKind::Vanished, ErrKind::Io, ErrKind::Loop, ErrKind::Other]
|
[
|
||||||
|
ErrKind::Permission,
|
||||||
|
ErrKind::Vanished,
|
||||||
|
ErrKind::Io,
|
||||||
|
ErrKind::Loop,
|
||||||
|
ErrKind::Other,
|
||||||
|
]
|
||||||
.get(v as usize)
|
.get(v as usize)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(ErrKind::Other)
|
.unwrap_or(ErrKind::Other)
|
||||||
}
|
}
|
||||||
pub fn name(self) -> &'static str {
|
pub fn name(self) -> &'static str {
|
||||||
["Permission denied", "Vanished during scan", "I/O error", "Symlink loop", "Other error"][self as usize]
|
[
|
||||||
|
"Permission denied",
|
||||||
|
"Vanished during scan",
|
||||||
|
"I/O error",
|
||||||
|
"Symlink loop",
|
||||||
|
"Other error",
|
||||||
|
][self as usize]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +101,29 @@ pub struct Stats {
|
|||||||
impl Default for Stats {
|
impl Default for Stats {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Stats {
|
Stats {
|
||||||
root: Vec::new(), start: SystemTime::now(), end: None, complete: false, cancelled: false,
|
root: Vec::new(),
|
||||||
files: 0, dirs: 0, symlinks: 0, others: 0, err_counts: [0; NUM_ERR_KINDS], errors: Vec::new(),
|
start: SystemTime::now(),
|
||||||
excluded: 0, skipped_mounts: 0, virtual_skipped: 0, loops_skipped: 0, broken_links: 0,
|
end: None,
|
||||||
hardlink_dups: 0, unscanned: 0, excludes: Vec::new(), one_file_system: false,
|
complete: false,
|
||||||
follow: String::new(), workers: 0, from_snapshot: String::new(),
|
cancelled: false,
|
||||||
|
files: 0,
|
||||||
|
dirs: 0,
|
||||||
|
symlinks: 0,
|
||||||
|
others: 0,
|
||||||
|
err_counts: [0; NUM_ERR_KINDS],
|
||||||
|
errors: Vec::new(),
|
||||||
|
excluded: 0,
|
||||||
|
skipped_mounts: 0,
|
||||||
|
virtual_skipped: 0,
|
||||||
|
loops_skipped: 0,
|
||||||
|
broken_links: 0,
|
||||||
|
hardlink_dups: 0,
|
||||||
|
unscanned: 0,
|
||||||
|
excludes: Vec::new(),
|
||||||
|
one_file_system: false,
|
||||||
|
follow: String::new(),
|
||||||
|
workers: 0,
|
||||||
|
from_snapshot: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-13
@@ -7,11 +7,36 @@ use std::path::Path;
|
|||||||
|
|
||||||
/// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`).
|
/// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`).
|
||||||
const VALUE_FLAGS: &[&str] = &[
|
const VALUE_FLAGS: &[&str] = &[
|
||||||
"workers", "follow-symlinks", "depth", "top", "largest-files", "save", "snapshot", "load", "scan",
|
"workers",
|
||||||
"theme", "color", "log", "log-level", "exclude",
|
"follow-symlinks",
|
||||||
|
"depth",
|
||||||
|
"top",
|
||||||
|
"largest-files",
|
||||||
|
"save",
|
||||||
|
"snapshot",
|
||||||
|
"load",
|
||||||
|
"scan",
|
||||||
|
"theme",
|
||||||
|
"color",
|
||||||
|
"log",
|
||||||
|
"log-level",
|
||||||
|
"exclude",
|
||||||
];
|
];
|
||||||
/// Boolean flags (`-f`, `-f=false`).
|
/// Boolean flags (`-f`, `-f=false`).
|
||||||
const BOOL_FLAGS: &[&str] = &["one-file-system", "x", "read-only", "no-ui", "json", "csv", "duplicates", "compare", "si", "apparent", "no-mouse", "version"];
|
const BOOL_FLAGS: &[&str] = &[
|
||||||
|
"one-file-system",
|
||||||
|
"x",
|
||||||
|
"read-only",
|
||||||
|
"no-ui",
|
||||||
|
"json",
|
||||||
|
"csv",
|
||||||
|
"duplicates",
|
||||||
|
"compare",
|
||||||
|
"si",
|
||||||
|
"apparent",
|
||||||
|
"no-mouse",
|
||||||
|
"version",
|
||||||
|
];
|
||||||
|
|
||||||
fn usage() {
|
fn usage() {
|
||||||
let n = brand::NAME;
|
let n = brand::NAME;
|
||||||
@@ -54,7 +79,10 @@ Flags:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
std::process::exit(run(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()).collect()));
|
std::process::exit(run(std::env::args_os()
|
||||||
|
.skip(1)
|
||||||
|
.map(|a| a.to_string_lossy().into_owned())
|
||||||
|
.collect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fail(msg: impl std::fmt::Display) -> i32 {
|
fn fail(msg: impl std::fmt::Display) -> i32 {
|
||||||
@@ -74,7 +102,11 @@ fn run(args: Vec<String>) -> i32 {
|
|||||||
positional.extend(it.by_ref());
|
positional.extend(it.by_ref());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Some(flag) = a.strip_prefix("--").or_else(|| a.strip_prefix('-')).filter(|f| !f.is_empty() && a != "-") else {
|
let Some(flag) = a
|
||||||
|
.strip_prefix("--")
|
||||||
|
.or_else(|| a.strip_prefix('-'))
|
||||||
|
.filter(|f| !f.is_empty() && a != "-")
|
||||||
|
else {
|
||||||
positional.push(a);
|
positional.push(a);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -90,10 +122,17 @@ fn run(args: Vec<String>) -> i32 {
|
|||||||
match inline.as_deref().map(str::parse::<bool>) {
|
match inline.as_deref().map(str::parse::<bool>) {
|
||||||
None => bools.insert(n, true),
|
None => bools.insert(n, true),
|
||||||
Some(Ok(v)) => bools.insert(n, v),
|
Some(Ok(v)) => bools.insert(n, v),
|
||||||
Some(Err(_)) => return fail(format!("invalid boolean value {:?} for -{n}", inline.unwrap())),
|
Some(Err(_)) => {
|
||||||
|
return fail(format!(
|
||||||
|
"invalid boolean value {:?} for -{n}",
|
||||||
|
inline.unwrap()
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
} else if let Some(&n) = VALUE_FLAGS.iter().find(|&&f| f == name) {
|
} else if let Some(&n) = VALUE_FLAGS.iter().find(|&&f| f == name) {
|
||||||
let Some(v) = inline.or_else(|| it.next()) else { return fail(format!("flag needs an argument: -{n}")) };
|
let Some(v) = inline.or_else(|| it.next()) else {
|
||||||
|
return fail(format!("flag needs an argument: -{n}"));
|
||||||
|
};
|
||||||
if n == "exclude" {
|
if n == "exclude" {
|
||||||
excludes.push(v);
|
excludes.push(v);
|
||||||
} else {
|
} else {
|
||||||
@@ -107,11 +146,20 @@ fn run(args: Vec<String>) -> i32 {
|
|||||||
let b = |n: &str| bools.get(n).copied();
|
let b = |n: &str| bools.get(n).copied();
|
||||||
let v = |n: &str| vals.get(n).cloned();
|
let v = |n: &str| vals.get(n).cloned();
|
||||||
let int = |n: &str, d: i64| -> Result<i64, String> {
|
let int = |n: &str, d: i64| -> Result<i64, String> {
|
||||||
v(n).map_or(Ok(d), |s| s.parse().map_err(|_| format!("invalid value {s:?} for flag -{n}: parse error")))
|
v(n).map_or(Ok(d), |s| {
|
||||||
|
s.parse()
|
||||||
|
.map_err(|_| format!("invalid value {s:?} for flag -{n}: parse error"))
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if b("version") == Some(true) {
|
if b("version") == Some(true) {
|
||||||
println!("{} {} ({}/{})", brand::NAME, brand::VERSION, std::env::consts::OS, std::env::consts::ARCH);
|
println!(
|
||||||
|
"{} {} ({}/{})",
|
||||||
|
brand::NAME,
|
||||||
|
brand::VERSION,
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH
|
||||||
|
);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if let Some(e) = cfg_err {
|
if let Some(e) = cfg_err {
|
||||||
@@ -143,7 +191,11 @@ fn run(args: Vec<String>) -> i32 {
|
|||||||
color: v("color").unwrap_or_else(|| "auto".into()),
|
color: v("color").unwrap_or_else(|| "auto".into()),
|
||||||
load: v("load").unwrap_or_default(),
|
load: v("load").unwrap_or_default(),
|
||||||
save: v("save").or_else(|| v("snapshot")).unwrap_or_default(),
|
save: v("save").or_else(|| v("snapshot")).unwrap_or_default(),
|
||||||
size_mode: if b("apparent").unwrap_or(settings.size_mode == "logical") { SizeMode::Logical } else { SizeMode::Allocated },
|
size_mode: if b("apparent").unwrap_or(settings.size_mode == "logical") {
|
||||||
|
SizeMode::Logical
|
||||||
|
} else {
|
||||||
|
SizeMode::Allocated
|
||||||
|
},
|
||||||
..Config::new(settings.clone())
|
..Config::new(settings.clone())
|
||||||
};
|
};
|
||||||
if let Some(p) = v("scan") {
|
if let Some(p) = v("scan") {
|
||||||
@@ -158,7 +210,10 @@ fn run(args: Vec<String>) -> i32 {
|
|||||||
if positional.len() > 1 {
|
if positional.len() > 1 {
|
||||||
return fail("only one path may be scanned at a time");
|
return fail("only one path may be scanned at a time");
|
||||||
}
|
}
|
||||||
if positional.len() == 1 && cfg.load.is_empty() && snapshot::is_snapshot(Path::new(&positional[0])) {
|
if positional.len() == 1
|
||||||
|
&& cfg.load.is_empty()
|
||||||
|
&& snapshot::is_snapshot(Path::new(&positional[0]))
|
||||||
|
{
|
||||||
cfg.load = positional.remove(0);
|
cfg.load = positional.remove(0);
|
||||||
} else {
|
} else {
|
||||||
cfg.paths = positional;
|
cfg.paths = positional;
|
||||||
@@ -168,14 +223,17 @@ fn run(args: Vec<String>) -> i32 {
|
|||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
Err(e) => return fail(e),
|
Err(e) => return fail(e),
|
||||||
};
|
};
|
||||||
let follow = match scan::parse_follow(&v("follow-symlinks").unwrap_or_else(|| settings.follow_symlinks.clone())) {
|
let follow = match scan::parse_follow(
|
||||||
|
&v("follow-symlinks").unwrap_or_else(|| settings.follow_symlinks.clone()),
|
||||||
|
) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => return fail(e),
|
Err(e) => return fail(e),
|
||||||
};
|
};
|
||||||
cfg.scan_opts = scan::Options {
|
cfg.scan_opts = scan::Options {
|
||||||
workers,
|
workers,
|
||||||
follow,
|
follow,
|
||||||
one_file_system: b("one-file-system").unwrap_or(settings.one_file_system) || b("x").unwrap_or(false),
|
one_file_system: b("one-file-system").unwrap_or(settings.one_file_system)
|
||||||
|
|| b("x").unwrap_or(false),
|
||||||
excludes,
|
excludes,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
+120
-24
@@ -117,7 +117,12 @@ fn cvt(r: libc::c_int) -> io::Result<libc::c_int> {
|
|||||||
|
|
||||||
fn open_dir(path: &[u8]) -> io::Result<OwnedFd> {
|
fn open_dir(path: &[u8]) -> io::Result<OwnedFd> {
|
||||||
let c = cstr(path)?;
|
let c = cstr(path)?;
|
||||||
let fd = cvt(unsafe { libc::open(c.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) })?;
|
let fd = cvt(unsafe {
|
||||||
|
libc::open(
|
||||||
|
c.as_ptr(),
|
||||||
|
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
|
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +134,15 @@ fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result<Meta> {
|
|||||||
if !NO_STATX.load(Ordering::Relaxed) {
|
if !NO_STATX.load(Ordering::Relaxed) {
|
||||||
let mut st: libc::statx = unsafe { std::mem::zeroed() };
|
let mut st: libc::statx = unsafe { std::mem::zeroed() };
|
||||||
let mask = libc::STATX_BASIC_STATS | libc::STATX_BTIME;
|
let mask = libc::STATX_BASIC_STATS | libc::STATX_BTIME;
|
||||||
let r = unsafe { libc::statx(dirfd, name.as_ptr(), flags | libc::AT_STATX_DONT_SYNC, mask, &mut st) };
|
let r = unsafe {
|
||||||
|
libc::statx(
|
||||||
|
dirfd,
|
||||||
|
name.as_ptr(),
|
||||||
|
flags | libc::AT_STATX_DONT_SYNC,
|
||||||
|
mask,
|
||||||
|
&mut st,
|
||||||
|
)
|
||||||
|
};
|
||||||
if r == 0 {
|
if r == 0 {
|
||||||
let mut m = Meta {
|
let mut m = Meta {
|
||||||
mode: mode_from_unix(st.stx_mode as u32),
|
mode: mode_from_unix(st.stx_mode as u32),
|
||||||
@@ -202,7 +215,11 @@ impl Drop for DirStream {
|
|||||||
/// Streams the entries of a directory in chunks of up to `chunk`, stat-ing
|
/// Streams the entries of a directory in chunks of up to `chunk`, stat-ing
|
||||||
/// each entry relative to the directory fd (no repeated path resolution).
|
/// each entry relative to the directory fd (no repeated path resolution).
|
||||||
/// Returning an error from f stops the read and returns it.
|
/// Returning an error from f stops the read and returns it.
|
||||||
pub fn read_dir<E: From<io::Error>>(path: &[u8], chunk: usize, mut f: impl FnMut(Vec<Meta>) -> Result<(), E>) -> Result<(), E> {
|
pub fn read_dir<E: From<io::Error>>(
|
||||||
|
path: &[u8],
|
||||||
|
chunk: usize,
|
||||||
|
mut f: impl FnMut(Vec<Meta>) -> Result<(), E>,
|
||||||
|
) -> Result<(), E> {
|
||||||
let fd = open_dir(path)?;
|
let fd = open_dir(path)?;
|
||||||
let raw = fd.as_raw_fd();
|
let raw = fd.as_raw_fd();
|
||||||
let dir = unsafe { libc::fdopendir(raw) };
|
let dir = unsafe { libc::fdopendir(raw) };
|
||||||
@@ -227,7 +244,10 @@ pub fn read_dir<E: From<io::Error>>(path: &[u8], chunk: usize, mut f: impl FnMut
|
|||||||
if nb == b"." || nb == b".." {
|
if nb == b"." || nb == b".." {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta { err: Some(e), ..Default::default() });
|
let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta {
|
||||||
|
err: Some(e),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
m.name = nb.to_vec();
|
m.name = nb.to_vec();
|
||||||
out.push(m);
|
out.push(m);
|
||||||
if out.len() >= chunk {
|
if out.len() >= chunk {
|
||||||
@@ -387,14 +407,22 @@ fn check_abs(path: &Path) -> io::Result<()> {
|
|||||||
/// final symlink, and O_NONBLOCK keeps a FIFO swapped in since the scan from
|
/// final symlink, and O_NONBLOCK keeps a FIFO swapped in since the scan from
|
||||||
/// blocking open(2) forever; callers still check the file type.
|
/// blocking open(2) forever; callers still check the file type.
|
||||||
pub fn open_content(path: &Path) -> io::Result<File> {
|
pub fn open_content(path: &Path) -> io::Result<File> {
|
||||||
std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).open(path)
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||||
|
.open(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a helper program with an argument vector (never a shell), detached
|
/// Runs a helper program with an argument vector (never a shell), detached
|
||||||
/// from the terminal so it cannot disturb the TUI.
|
/// from the terminal so it cannot disturb the TUI.
|
||||||
fn start_detached(name: &str, arg: &Path) -> io::Result<()> {
|
fn start_detached(name: &str, arg: &Path) -> io::Result<()> {
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
let mut child = Command::new(name).arg(arg).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn()?;
|
let mut child = Command::new(name)
|
||||||
|
.arg(arg)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()?;
|
||||||
std::thread::spawn(move || child.wait());
|
std::thread::spawn(move || child.wait());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -406,8 +434,16 @@ pub const TRASH_NAME: &str = "trash";
|
|||||||
pub fn reveal(path: &Path) -> io::Result<()> {
|
pub fn reveal(path: &Path) -> io::Result<()> {
|
||||||
check_abs(path)?;
|
check_abs(path)?;
|
||||||
let md = std::fs::symlink_metadata(path)?;
|
let md = std::fs::symlink_metadata(path)?;
|
||||||
let target = if md.is_dir() { path } else { path.parent().unwrap_or(path) };
|
let target = if md.is_dir() {
|
||||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
path
|
||||||
|
} else {
|
||||||
|
path.parent().unwrap_or(path)
|
||||||
|
};
|
||||||
|
let opener = if cfg!(target_os = "macos") {
|
||||||
|
"open"
|
||||||
|
} else {
|
||||||
|
"xdg-open"
|
||||||
|
};
|
||||||
start_detached(opener, target)
|
start_detached(opener, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +453,8 @@ pub fn headless() -> bool {
|
|||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty()) && std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty())
|
std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty())
|
||||||
|
&& std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> {
|
fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> {
|
||||||
@@ -430,7 +467,9 @@ fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> {
|
|||||||
fn check_expect(dirfd: RawFd, base: &CStr, want: Expect) -> io::Result<libc::stat> {
|
fn check_expect(dirfd: RawFd, base: &CStr, want: Expect) -> io::Result<libc::stat> {
|
||||||
let mut st: libc::stat = unsafe { std::mem::zeroed() };
|
let mut st: libc::stat = unsafe { std::mem::zeroed() };
|
||||||
cvt(unsafe { libc::fstatat(dirfd, base.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) })?;
|
cvt(unsafe { libc::fstatat(dirfd, base.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) })?;
|
||||||
if st.st_uid != want.uid || mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE {
|
if st.st_uid != want.uid
|
||||||
|
|| mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE
|
||||||
|
{
|
||||||
return Err(other(CHANGED));
|
return Err(other(CHANGED));
|
||||||
}
|
}
|
||||||
Ok(st)
|
Ok(st)
|
||||||
@@ -446,7 +485,11 @@ pub fn delete(path: &Path, want: Expect) -> io::Result<()> {
|
|||||||
let dfd = open_dir(dir.as_os_str().as_bytes())?;
|
let dfd = open_dir(dir.as_os_str().as_bytes())?;
|
||||||
let cbase = cstr(base.as_bytes())?;
|
let cbase = cstr(base.as_bytes())?;
|
||||||
let st = check_expect(dfd.as_raw_fd(), &cbase, want)?;
|
let st = check_expect(dfd.as_raw_fd(), &cbase, want)?;
|
||||||
remove_at(dfd.as_raw_fd(), &cbase, st.st_mode & libc::S_IFMT == libc::S_IFDIR)
|
remove_at(
|
||||||
|
dfd.as_raw_fd(),
|
||||||
|
&cbase,
|
||||||
|
st.st_mode & libc::S_IFMT == libc::S_IFDIR,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> {
|
fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> {
|
||||||
@@ -454,7 +497,11 @@ fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> {
|
|||||||
return cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) }).map(|_| ());
|
return cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) }).map(|_| ());
|
||||||
}
|
}
|
||||||
let fd = cvt(unsafe {
|
let fd = cvt(unsafe {
|
||||||
libc::openat(dirfd, name.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
libc::openat(
|
||||||
|
dirfd,
|
||||||
|
name.as_ptr(),
|
||||||
|
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let sub = unsafe { OwnedFd::from_raw_fd(fd) };
|
let sub = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||||
// Collect names first: removing while iterating a DIR stream is unspecified.
|
// Collect names first: removing while iterating a DIR stream is unspecified.
|
||||||
@@ -475,7 +522,8 @@ fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> {
|
|||||||
if n.to_bytes() != b"." && n.to_bytes() != b".." {
|
if n.to_bytes() != b"." && n.to_bytes() != b".." {
|
||||||
let t = unsafe { (*ent).d_type };
|
let t = unsafe { (*ent).d_type };
|
||||||
let is_dir = match t {
|
let is_dir = match t {
|
||||||
libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW).is_ok_and(|m| mode::is_dir(m.mode)),
|
libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW)
|
||||||
|
.is_ok_and(|m| mode::is_dir(m.mode)),
|
||||||
t => t == libc::DT_DIR,
|
t => t == libc::DT_DIR,
|
||||||
};
|
};
|
||||||
names.push((n.to_owned(), is_dir));
|
names.push((n.to_owned(), is_dir));
|
||||||
@@ -498,7 +546,15 @@ fn rename_checked(path: &Path, dst: &Path, want: Expect) -> io::Result<()> {
|
|||||||
let cbase = cstr(base.as_bytes())?;
|
let cbase = cstr(base.as_bytes())?;
|
||||||
check_expect(dfd.as_raw_fd(), &cbase, want)?;
|
check_expect(dfd.as_raw_fd(), &cbase, want)?;
|
||||||
let cdst = cstr(dst.as_os_str().as_bytes())?;
|
let cdst = cstr(dst.as_os_str().as_bytes())?;
|
||||||
cvt(unsafe { libc::renameat(dfd.as_raw_fd(), cbase.as_ptr(), libc::AT_FDCWD, cdst.as_ptr()) }).map(|_| ())
|
cvt(unsafe {
|
||||||
|
libc::renameat(
|
||||||
|
dfd.as_raw_fd(),
|
||||||
|
cbase.as_ptr(),
|
||||||
|
libc::AT_FDCWD,
|
||||||
|
cdst.as_ptr(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates dir (mode 0700) if missing; an existing entry must be a real
|
/// Creates dir (mode 0700) if missing; an existing entry must be a real
|
||||||
@@ -529,7 +585,9 @@ fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> {
|
|||||||
fn mount_top(path: &Path, dev: u64) -> PathBuf {
|
fn mount_top(path: &Path, dev: u64) -> PathBuf {
|
||||||
let mut cur = path.parent().unwrap_or(path).to_path_buf();
|
let mut cur = path.parent().unwrap_or(path).to_path_buf();
|
||||||
loop {
|
loop {
|
||||||
let Some(parent) = cur.parent() else { return cur };
|
let Some(parent) = cur.parent() else {
|
||||||
|
return cur;
|
||||||
|
};
|
||||||
match std::fs::metadata(parent) {
|
match std::fs::metadata(parent) {
|
||||||
Ok(m) if m.dev() == dev => cur = parent.to_path_buf(),
|
Ok(m) if m.dev() == dev => cur = parent.to_path_buf(),
|
||||||
_ => return cur,
|
_ => return cur,
|
||||||
@@ -553,7 +611,15 @@ fn local_timestamp() -> String {
|
|||||||
let mut t: libc::time_t = unsafe { libc::time(std::ptr::null_mut()) };
|
let mut t: libc::time_t = unsafe { libc::time(std::ptr::null_mut()) };
|
||||||
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
||||||
unsafe { libc::localtime_r(&mut t, &mut tm) };
|
unsafe { libc::localtime_r(&mut t, &mut tm) };
|
||||||
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec)
|
format!(
|
||||||
|
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
|
||||||
|
tm.tm_year + 1900,
|
||||||
|
tm.tm_mon + 1,
|
||||||
|
tm.tm_mday,
|
||||||
|
tm.tm_hour,
|
||||||
|
tm.tm_min,
|
||||||
|
tm.tm_sec
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves path to the freedesktop.org trash: the home trash when on the same
|
/// Moves path to the freedesktop.org trash: the home trash when on the same
|
||||||
@@ -565,7 +631,9 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
|
|||||||
let data_home = match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
|
let data_home = match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
|
||||||
Some(d) => PathBuf::from(d),
|
Some(d) => PathBuf::from(d),
|
||||||
None => {
|
None => {
|
||||||
let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).ok_or_else(|| other("$HOME is not defined"))?;
|
let home = std::env::var_os("HOME")
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.ok_or_else(|| other("$HOME is not defined"))?;
|
||||||
PathBuf::from(home).join(".local/share")
|
PathBuf::from(home).join(".local/share")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -575,21 +643,31 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
|
|||||||
_ => {
|
_ => {
|
||||||
let top = mount_top(path, dev);
|
let top = mount_top(path, dev);
|
||||||
let rel = path.strip_prefix(&top).map_err(|e| other(e.to_string()))?;
|
let rel = path.strip_prefix(&top).map_err(|e| other(e.to_string()))?;
|
||||||
(top.join(format!(".Trash-{}", unsafe { libc::getuid() })), rel.as_os_str().as_bytes().to_vec())
|
(
|
||||||
|
top.join(format!(".Trash-{}", unsafe { libc::getuid() })),
|
||||||
|
rel.as_os_str().as_bytes().to_vec(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let shared = trash != home;
|
let shared = trash != home;
|
||||||
if !shared {
|
if !shared {
|
||||||
use std::os::unix::fs::DirBuilderExt;
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&trash)?;
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&trash)?;
|
||||||
} else if let Err(e) = ensure_private_dir(&trash, true) {
|
} else if let Err(e) = ensure_private_dir(&trash, true) {
|
||||||
// On a shared filesystem another user could plant a symlink or a
|
// On a shared filesystem another user could plant a symlink or a
|
||||||
// world-writable directory here; refuse rather than follow it.
|
// world-writable directory here; refuse rather than follow it.
|
||||||
return Err(other(format!("trash directory {} is unsafe: {e}", trash.display())));
|
return Err(other(format!(
|
||||||
|
"trash directory {} is unsafe: {e}",
|
||||||
|
trash.display()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let (files, info) = (trash.join("files"), trash.join("info"));
|
let (files, info) = (trash.join("files"), trash.join("info"));
|
||||||
for d in [&files, &info] {
|
for d in [&files, &info] {
|
||||||
ensure_private_dir(d, shared).map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?;
|
ensure_private_dir(d, shared)
|
||||||
|
.map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?;
|
||||||
}
|
}
|
||||||
let base = path.file_name().ok_or_else(|| other("no file name"))?;
|
let base = path.file_name().ok_or_else(|| other("no file name"))?;
|
||||||
for i in 1..10000 {
|
for i in 1..10000 {
|
||||||
@@ -604,12 +682,23 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> {
|
|||||||
let mut info_name = name.clone();
|
let mut info_name = name.clone();
|
||||||
info_name.push(".trashinfo");
|
info_name.push(".trashinfo");
|
||||||
let ip = info.join(info_name);
|
let ip = info.join(info_name);
|
||||||
let mut f = match std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&ip) {
|
let mut f = match std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&ip)
|
||||||
|
{
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
let w = write!(f, "[Trash Info]\nPath={}\nDeletionDate={}\n", percent_encode_path(&info_path), local_timestamp()).and_then(|_| f.sync_all());
|
let w = write!(
|
||||||
|
f,
|
||||||
|
"[Trash Info]\nPath={}\nDeletionDate={}\n",
|
||||||
|
percent_encode_path(&info_path),
|
||||||
|
local_timestamp()
|
||||||
|
)
|
||||||
|
.and_then(|_| f.sync_all());
|
||||||
drop(f);
|
drop(f);
|
||||||
if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) {
|
if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) {
|
||||||
let _ = std::fs::remove_file(&ip);
|
let _ = std::fs::remove_file(&ip);
|
||||||
@@ -638,7 +727,14 @@ mod tests {
|
|||||||
std::fs::write(d.join("sub/inner/f"), b"x").unwrap();
|
std::fs::write(d.join("sub/inner/f"), b"x").unwrap();
|
||||||
let uid = unsafe { libc::getuid() };
|
let uid = unsafe { libc::getuid() };
|
||||||
assert!(delete(&d.join("sub"), Expect { uid, mode: 0 }).is_err());
|
assert!(delete(&d.join("sub"), Expect { uid, mode: 0 }).is_err());
|
||||||
delete(&d.join("sub"), Expect { uid, mode: mode::DIR }).unwrap();
|
delete(
|
||||||
|
&d.join("sub"),
|
||||||
|
Expect {
|
||||||
|
uid,
|
||||||
|
mode: mode::DIR,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!d.join("sub").exists());
|
assert!(!d.join("sub").exists());
|
||||||
std::fs::remove_dir_all(&d).unwrap();
|
std::fs::remove_dir_all(&d).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-37
@@ -38,7 +38,9 @@ pub fn parse_follow(s: &str) -> Result<FollowMode, String> {
|
|||||||
"none" | "" => Ok(FollowMode::None),
|
"none" | "" => Ok(FollowMode::None),
|
||||||
"same-filesystem" | "same-fs" => Ok(FollowMode::SameFs),
|
"same-filesystem" | "same-fs" => Ok(FollowMode::SameFs),
|
||||||
"all" => Ok(FollowMode::All),
|
"all" => Ok(FollowMode::All),
|
||||||
_ => Err(format!("invalid --follow-symlinks {s:?} (want none, same-filesystem or all)")),
|
_ => Err(format!(
|
||||||
|
"invalid --follow-symlinks {s:?} (want none, same-filesystem or all)"
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +53,9 @@ pub fn workers(mode: &str) -> Result<usize, String> {
|
|||||||
"aggressive" => Ok((8 * cpu).clamp(4, 64)),
|
"aggressive" => Ok((8 * cpu).clamp(4, 64)),
|
||||||
_ => match mode.trim().parse::<usize>() {
|
_ => match mode.trim().parse::<usize>() {
|
||||||
Ok(n) if (1..=1024).contains(&n) => Ok(n),
|
Ok(n) if (1..=1024).contains(&n) => Ok(n),
|
||||||
_ => Err(format!("invalid workers {mode:?} (conservative, balanced, aggressive or 1-1024)")),
|
_ => Err(format!(
|
||||||
|
"invalid workers {mode:?} (conservative, balanced, aggressive or 1-1024)"
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +127,10 @@ impl Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_done(&self) -> bool {
|
pub fn is_done(&self) -> bool {
|
||||||
matches!(self.done.try_recv(), Err(crossbeam_channel::TryRecvError::Disconnected))
|
matches!(
|
||||||
|
self.done.try_recv(),
|
||||||
|
Err(crossbeam_channel::TryRecvError::Disconnected)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blocks until the scan is done.
|
/// Blocks until the scan is done.
|
||||||
@@ -235,7 +242,12 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result<Scann
|
|||||||
t.stats.complete = true;
|
t.stats.complete = true;
|
||||||
t.stats.end = Some(SystemTime::now());
|
t.stats.end = Some(SystemTime::now());
|
||||||
drop(done_tx);
|
drop(done_tx);
|
||||||
return Ok(Scanner { tree: Arc::new(RwLock::new(t)), counters, workers: opts.workers, done });
|
return Ok(Scanner {
|
||||||
|
tree: Arc::new(RwLock::new(t)),
|
||||||
|
counters,
|
||||||
|
workers: opts.workers,
|
||||||
|
done,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
t.stats.dirs = 1;
|
t.stats.dirs = 1;
|
||||||
// Loop detection needs inode identity; without it symlinks are not
|
// Loop detection needs inode identity; without it symlinks are not
|
||||||
@@ -245,7 +257,11 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result<Scann
|
|||||||
t.stats.follow = "none (no inode identity on this platform)".into();
|
t.stats.follow = "none (no inode identity on this platform)".into();
|
||||||
}
|
}
|
||||||
let visited = (opts.follow != FollowMode::None).then(|| HashSet::from([(m.dev, m.ino)]));
|
let visited = (opts.follow != FollowMode::None).then(|| HashSet::from([(m.dev, m.ino)]));
|
||||||
let path_excludes = opts.excludes.iter().map(|p| (p.contains(['/', '\\']), p.as_bytes().to_vec())).collect();
|
let path_excludes = opts
|
||||||
|
.excludes
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.contains(['/', '\\']), p.as_bytes().to_vec()))
|
||||||
|
.collect();
|
||||||
let tree = Arc::new(RwLock::new(t));
|
let tree = Arc::new(RwLock::new(t));
|
||||||
let workers = opts.workers;
|
let workers = opts.workers;
|
||||||
counters.pending.store(1, Ordering::Relaxed);
|
counters.pending.store(1, Ordering::Relaxed);
|
||||||
@@ -259,11 +275,18 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result<Scann
|
|||||||
links: HashSet::new(),
|
links: HashSet::new(),
|
||||||
visited,
|
visited,
|
||||||
};
|
};
|
||||||
std::thread::Builder::new().name("scan-controller".into()).spawn(move || {
|
std::thread::Builder::new()
|
||||||
|
.name("scan-controller".into())
|
||||||
|
.spawn(move || {
|
||||||
guard(|| c.run(cancel));
|
guard(|| c.run(cancel));
|
||||||
drop(done_tx);
|
drop(done_tx);
|
||||||
})?;
|
})?;
|
||||||
Ok(Scanner { tree, counters, workers, done })
|
Ok(Scanner {
|
||||||
|
tree,
|
||||||
|
counters,
|
||||||
|
workers,
|
||||||
|
done,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounds how long the controller holds the write lock, so the UI's read
|
/// Bounds how long the controller holds the write lock, so the UI's read
|
||||||
@@ -290,11 +313,23 @@ fn worker(cancel: &Cancel, jobs: Receiver<Arc<Job>>, results: Sender<ScanResult>
|
|||||||
if cancel.is_cancelled() {
|
if cancel.is_cancelled() {
|
||||||
return Err(ReadErr::Cancelled);
|
return Err(ReadErr::Cancelled);
|
||||||
}
|
}
|
||||||
results.send(ScanResult { job: j.clone(), entries: ms, is_final: false, err: None }).map_err(|_| ReadErr::Cancelled)
|
results
|
||||||
|
.send(ScanResult {
|
||||||
|
job: j.clone(),
|
||||||
|
entries: ms,
|
||||||
|
is_final: false,
|
||||||
|
err: None,
|
||||||
|
})
|
||||||
|
.map_err(|_| ReadErr::Cancelled)
|
||||||
})
|
})
|
||||||
.err()
|
.err()
|
||||||
};
|
};
|
||||||
let _ = results.send(ScanResult { job: j, entries: Vec::new(), is_final: true, err });
|
let _ = results.send(ScanResult {
|
||||||
|
job: j,
|
||||||
|
entries: Vec::new(),
|
||||||
|
is_final: true,
|
||||||
|
err,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +339,12 @@ impl Controller {
|
|||||||
let (res_tx, res_rx) = crossbeam_channel::bounded::<ScanResult>(self.opts.result_buf);
|
let (res_tx, res_rx) = crossbeam_channel::bounded::<ScanResult>(self.opts.result_buf);
|
||||||
let mut handles = Vec::with_capacity(self.opts.workers);
|
let mut handles = Vec::with_capacity(self.opts.workers);
|
||||||
for i in 0..self.opts.workers {
|
for i in 0..self.opts.workers {
|
||||||
let (jobs, results, cancel, chunk) = (jobs_rx.clone(), res_tx.clone(), cancel.clone(), self.opts.chunk_size);
|
let (jobs, results, cancel, chunk) = (
|
||||||
|
jobs_rx.clone(),
|
||||||
|
res_tx.clone(),
|
||||||
|
cancel.clone(),
|
||||||
|
self.opts.chunk_size,
|
||||||
|
);
|
||||||
handles.push(
|
handles.push(
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name(format!("scan-worker-{i}"))
|
.name(format!("scan-worker-{i}"))
|
||||||
@@ -332,7 +372,8 @@ impl Controller {
|
|||||||
let op = sel.select();
|
let op = sel.select();
|
||||||
let i = op.index();
|
let i = op.index();
|
||||||
if Some(i) == send_i {
|
if Some(i) == send_i {
|
||||||
op.send(&jobs_tx, next.take().unwrap()).expect("workers alive");
|
op.send(&jobs_tx, next.take().unwrap())
|
||||||
|
.expect("workers alive");
|
||||||
self.head += 1;
|
self.head += 1;
|
||||||
if self.head > 4096 && self.head * 2 > self.pending.len() {
|
if self.head > 4096 && self.head * 2 > self.pending.len() {
|
||||||
self.pending.drain(..self.head);
|
self.pending.drain(..self.head);
|
||||||
@@ -366,7 +407,9 @@ impl Controller {
|
|||||||
next = None;
|
next = None;
|
||||||
}
|
}
|
||||||
self.counters.inflight.store(inflight, Ordering::Relaxed);
|
self.counters.inflight.store(inflight, Ordering::Relaxed);
|
||||||
self.counters.pending.store((self.pending.len() - self.head) as i64, Ordering::Relaxed);
|
self.counters
|
||||||
|
.pending
|
||||||
|
.store((self.pending.len() - self.head) as i64, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
drop(jobs_tx);
|
drop(jobs_tx);
|
||||||
for h in handles {
|
for h in handles {
|
||||||
@@ -391,7 +434,13 @@ impl Controller {
|
|||||||
fn make_job(&self, (id, dev): (NodeId, u64)) -> Arc<Job> {
|
fn make_job(&self, (id, dev): (NodeId, u64)) -> Arc<Job> {
|
||||||
let path = self.tree.read().unwrap().path_bytes(id);
|
let path = self.tree.read().unwrap().path_bytes(id);
|
||||||
let (in_cache, in_log) = inv::path_hints(&path);
|
let (in_cache, in_log) = inv::path_hints(&path);
|
||||||
Arc::new(Job { id, path, dev, in_cache, in_log })
|
Arc::new(Job {
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
dev,
|
||||||
|
in_cache,
|
||||||
|
in_log,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merges one result into the tree. Reports whether the result completed
|
/// Merges one result into the tree. Reports whether the result completed
|
||||||
@@ -399,7 +448,9 @@ impl Controller {
|
|||||||
fn apply(&mut self, t: &mut Tree, r: ScanResult) -> bool {
|
fn apply(&mut self, t: &mut Tree, r: ScanResult) -> bool {
|
||||||
let j = &*r.job;
|
let j = &*r.job;
|
||||||
let mut d = Delta::default();
|
let mut d = Delta::default();
|
||||||
self.counters.entries.fetch_add(r.entries.len() as i64, Ordering::Relaxed);
|
self.counters
|
||||||
|
.entries
|
||||||
|
.fetch_add(r.entries.len() as i64, Ordering::Relaxed);
|
||||||
for m in r.entries {
|
for m in r.entries {
|
||||||
self.add_entry(t, j, m, &mut d);
|
self.add_entry(t, j, m, &mut d);
|
||||||
}
|
}
|
||||||
@@ -413,7 +464,12 @@ impl Controller {
|
|||||||
}
|
}
|
||||||
Some(ReadErr::Io(e)) => {
|
Some(ReadErr::Io(e)) => {
|
||||||
dn.flags |= inv::FLAG_ERROR | inv::FLAG_INCOMPLETE;
|
dn.flags |= inv::FLAG_ERROR | inv::FLAG_INCOMPLETE;
|
||||||
t.stats.add_error(ErrorRecord { node: j.id, kind: inv::classify(&e), msg: format!("open: {}", err_msg(&e)), ..Default::default() });
|
t.stats.add_error(ErrorRecord {
|
||||||
|
node: j.id,
|
||||||
|
kind: inv::classify(&e),
|
||||||
|
msg: format!("open: {}", err_msg(&e)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
d.errors += 1;
|
d.errors += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +483,9 @@ impl Controller {
|
|||||||
fn excluded(&self, path: &[u8], name: &[u8]) -> bool {
|
fn excluded(&self, path: &[u8], name: &[u8]) -> bool {
|
||||||
self.excludes.iter().any(|(is_path, p)| {
|
self.excludes.iter().any(|(is_path, p)| {
|
||||||
if *is_path {
|
if *is_path {
|
||||||
path == p.as_slice() || path.starts_with(p) && path.get(p.len()) == Some(&b'/') || platform::glob_match(p, path)
|
path == p.as_slice()
|
||||||
|
|| path.starts_with(p) && path.get(p.len()) == Some(&b'/')
|
||||||
|
|| platform::glob_match(p, path)
|
||||||
} else {
|
} else {
|
||||||
platform::glob_match(p, name)
|
platform::glob_match(p, name)
|
||||||
}
|
}
|
||||||
@@ -438,7 +496,12 @@ impl Controller {
|
|||||||
if let Some(e) = &m.err {
|
if let Some(e) = &m.err {
|
||||||
let k = inv::classify(e);
|
let k = inv::classify(e);
|
||||||
if k == inv::ErrKind::Vanished {
|
if k == inv::ErrKind::Vanished {
|
||||||
t.stats.add_error(ErrorRecord { node: j.id, name: m.name, kind: k, msg: "vanished between listing and stat".into() });
|
t.stats.add_error(ErrorRecord {
|
||||||
|
node: j.id,
|
||||||
|
name: m.name,
|
||||||
|
kind: k,
|
||||||
|
msg: "vanished between listing and stat".into(),
|
||||||
|
});
|
||||||
d.errors += 1;
|
d.errors += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -446,7 +509,12 @@ impl Controller {
|
|||||||
let n = t.node_mut(id);
|
let n = t.node_mut(id);
|
||||||
n.flags |= inv::FLAG_ERROR;
|
n.flags |= inv::FLAG_ERROR;
|
||||||
n.errors = 1;
|
n.errors = 1;
|
||||||
t.stats.add_error(ErrorRecord { node: id, kind: k, msg: format!("stat: {}", err_msg(e)), ..Default::default() });
|
t.stats.add_error(ErrorRecord {
|
||||||
|
node: id,
|
||||||
|
kind: k,
|
||||||
|
msg: format!("stat: {}", err_msg(e)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
d.errors += 1;
|
d.errors += 1;
|
||||||
d.files += 1;
|
d.files += 1;
|
||||||
t.stats.others += 1;
|
t.stats.others += 1;
|
||||||
@@ -472,7 +540,12 @@ impl Controller {
|
|||||||
t.stats.broken_links += 1;
|
t.stats.broken_links += 1;
|
||||||
broken = true;
|
broken = true;
|
||||||
}
|
}
|
||||||
Ok(tm) if platform::mode::is_dir(tm.mode) && tm.has_ino && (self.opts.follow == FollowMode::All || self.opts.follow == FollowMode::SameFs && tm.dev == j.dev) => {
|
Ok(tm)
|
||||||
|
if platform::mode::is_dir(tm.mode)
|
||||||
|
&& tm.has_ino
|
||||||
|
&& (self.opts.follow == FollowMode::All
|
||||||
|
|| self.opts.follow == FollowMode::SameFs && tm.dev == j.dev) =>
|
||||||
|
{
|
||||||
target = Some(tm);
|
target = Some(tm);
|
||||||
kind = Kind::Dir;
|
kind = Kind::Dir;
|
||||||
}
|
}
|
||||||
@@ -596,7 +669,11 @@ pub(crate) mod tests {
|
|||||||
impl TempDir {
|
impl TempDir {
|
||||||
pub fn new(tag: &str) -> TempDir {
|
pub fn new(tag: &str) -> TempDir {
|
||||||
static N: AtomicI64 = AtomicI64::new(0);
|
static N: AtomicI64 = AtomicI64::new(0);
|
||||||
let p = std::env::temp_dir().join(format!("mapsize-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
let p = std::env::temp_dir().join(format!(
|
||||||
|
"mapsize-{tag}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
std::fs::create_dir_all(&p).unwrap();
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
TempDir(p)
|
TempDir(p)
|
||||||
}
|
}
|
||||||
@@ -609,7 +686,14 @@ pub(crate) mod tests {
|
|||||||
|
|
||||||
/// Creates a fan-out tree: (files, dirs, bytes).
|
/// Creates a fan-out tree: (files, dirs, bytes).
|
||||||
pub fn mk_tree(dir: &Path, fanout: usize, depth: usize, files: usize) -> (i64, i64, i64) {
|
pub fn mk_tree(dir: &Path, fanout: usize, depth: usize, files: usize) -> (i64, i64, i64) {
|
||||||
fn rec(dir: &Path, d: usize, fanout: usize, depth: usize, files: usize, acc: &mut (i64, i64, i64)) {
|
fn rec(
|
||||||
|
dir: &Path,
|
||||||
|
d: usize,
|
||||||
|
fanout: usize,
|
||||||
|
depth: usize,
|
||||||
|
files: usize,
|
||||||
|
acc: &mut (i64, i64, i64),
|
||||||
|
) {
|
||||||
for i in 0..files {
|
for i in 0..files {
|
||||||
let sz = (i + 1) * 100;
|
let sz = (i + 1) * 100;
|
||||||
std::fs::write(dir.join(format!("f{i}.txt")), vec![0u8; sz]).unwrap();
|
std::fs::write(dir.join(format!("f{i}.txt")), vec![0u8; sz]).unwrap();
|
||||||
@@ -633,23 +717,38 @@ pub(crate) mod tests {
|
|||||||
|
|
||||||
pub fn run_scan(root: &Path, o: Options) -> Tree {
|
pub fn run_scan(root: &Path, o: Options) -> Tree {
|
||||||
let s = start(root.as_os_str().as_bytes(), o, Cancel::new()).unwrap();
|
let s = start(root.as_os_str().as_bytes(), o, Cancel::new()).unwrap();
|
||||||
s.done().recv_timeout(std::time::Duration::from_secs(60)).expect_err("scan did not finish (deadlock?)");
|
s.done()
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(60))
|
||||||
|
.expect_err("scan did not finish (deadlock?)");
|
||||||
let t = s.tree.read().unwrap().clone();
|
let t = s.tree.read().unwrap().clone();
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
|
|
||||||
fn totals(t: &Tree) -> Vec<[i64; 4]> {
|
fn totals(t: &Tree) -> Vec<[i64; 4]> {
|
||||||
(0..t.len() as NodeId).map(|i| t.node(i)).map(|n| [n.tot_size, n.tot_alloc, n.files as i64, n.dirs as i64]).collect()
|
(0..t.len() as NodeId)
|
||||||
|
.map(|i| t.node(i))
|
||||||
|
.map(|n| [n.tot_size, n.tot_alloc, n.files as i64, n.dirs as i64])
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn counts_and_aggregates() {
|
fn counts_and_aggregates() {
|
||||||
let root = TempDir::new("scan");
|
let root = TempDir::new("scan");
|
||||||
let (nf, nd, bytes) = mk_tree(&root.0, 3, 3, 4);
|
let (nf, nd, bytes) = mk_tree(&root.0, 3, 3, 4);
|
||||||
let mut t = run_scan(&root.0, Options { workers: 4, ..Default::default() });
|
let mut t = run_scan(
|
||||||
|
&root.0,
|
||||||
|
Options {
|
||||||
|
workers: 4,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
let r = t.node(0);
|
let r = t.node(0);
|
||||||
assert_eq!((r.files as i64, r.dirs as i64), (nf, nd));
|
assert_eq!((r.files as i64, r.dirs as i64), (nf, nd));
|
||||||
let dir_bytes: i64 = (0..t.len() as NodeId).map(|i| t.node(i)).filter(|n| n.is_dir()).map(|n| n.size).sum();
|
let dir_bytes: i64 = (0..t.len() as NodeId)
|
||||||
|
.map(|i| t.node(i))
|
||||||
|
.filter(|n| n.is_dir())
|
||||||
|
.map(|n| n.size)
|
||||||
|
.sum();
|
||||||
assert_eq!(r.tot_size, bytes + dir_bytes);
|
assert_eq!(r.tot_size, bytes + dir_bytes);
|
||||||
assert!(t.stats.complete && !t.stats.incomplete());
|
assert!(t.stats.complete && !t.stats.incomplete());
|
||||||
for i in 0..t.len() as NodeId {
|
for i in 0..t.len() as NodeId {
|
||||||
@@ -661,7 +760,11 @@ pub(crate) mod tests {
|
|||||||
}
|
}
|
||||||
let before = totals(&t);
|
let before = totals(&t);
|
||||||
t.recompute();
|
t.recompute();
|
||||||
assert_eq!(before, totals(&t), "incremental aggregates differ from recompute");
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
totals(&t),
|
||||||
|
"incremental aggregates differ from recompute"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -669,8 +772,22 @@ pub(crate) mod tests {
|
|||||||
let root = TempDir::new("bp");
|
let root = TempDir::new("bp");
|
||||||
let (nf, nd, _) = mk_tree(&root.0, 6, 4, 3);
|
let (nf, nd, _) = mk_tree(&root.0, 6, 4, 3);
|
||||||
for w in [1, 2, 16] {
|
for w in [1, 2, 16] {
|
||||||
let t = run_scan(&root.0, Options { workers: w, job_buffer: 1, result_buf: 1, chunk_size: 1, apply_batch: 1, ..Default::default() });
|
let t = run_scan(
|
||||||
assert_eq!((t.node(0).files as i64, t.node(0).dirs as i64), (nf, nd), "workers={w}");
|
&root.0,
|
||||||
|
Options {
|
||||||
|
workers: w,
|
||||||
|
job_buffer: 1,
|
||||||
|
result_buf: 1,
|
||||||
|
chunk_size: 1,
|
||||||
|
apply_batch: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(t.node(0).files as i64, t.node(0).dirs as i64),
|
||||||
|
(nf, nd),
|
||||||
|
"workers={w}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,9 +796,22 @@ pub(crate) mod tests {
|
|||||||
let root = TempDir::new("cancel");
|
let root = TempDir::new("cancel");
|
||||||
mk_tree(&root.0, 8, 3, 2);
|
mk_tree(&root.0, 8, 3, 2);
|
||||||
let c = Cancel::new();
|
let c = Cancel::new();
|
||||||
let s = start(root.0.as_os_str().as_bytes(), Options { workers: 2, job_buffer: 1, result_buf: 1, chunk_size: 1, ..Default::default() }, c.clone()).unwrap();
|
let s = start(
|
||||||
|
root.0.as_os_str().as_bytes(),
|
||||||
|
Options {
|
||||||
|
workers: 2,
|
||||||
|
job_buffer: 1,
|
||||||
|
result_buf: 1,
|
||||||
|
chunk_size: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
c.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
c.cancel();
|
c.cancel();
|
||||||
s.done().recv_timeout(std::time::Duration::from_secs(10)).expect_err("cancel did not stop scan");
|
s.done()
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.expect_err("cancel did not stop scan");
|
||||||
let t = s.tree.read().unwrap();
|
let t = s.tree.read().unwrap();
|
||||||
assert!(t.stats.cancelled && t.stats.incomplete());
|
assert!(t.stats.cancelled && t.stats.incomplete());
|
||||||
}
|
}
|
||||||
@@ -696,12 +826,21 @@ pub(crate) mod tests {
|
|||||||
std::os::unix::fs::symlink("../a", a.join("self")).unwrap();
|
std::os::unix::fs::symlink("../a", a.join("self")).unwrap();
|
||||||
std::os::unix::fs::symlink("nowhere", root.0.join("broken")).unwrap();
|
std::os::unix::fs::symlink("nowhere", root.0.join("broken")).unwrap();
|
||||||
for f in [FollowMode::None, FollowMode::SameFs, FollowMode::All] {
|
for f in [FollowMode::None, FollowMode::SameFs, FollowMode::All] {
|
||||||
let t = run_scan(&root.0, Options { workers: 4, follow: f, ..Default::default() });
|
let t = run_scan(
|
||||||
|
&root.0,
|
||||||
|
Options {
|
||||||
|
workers: 4,
|
||||||
|
follow: f,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
let r = t.node(0);
|
let r = t.node(0);
|
||||||
assert!(r.files > 0 && r.tot_size > 5000, "{f:?}");
|
assert!(r.files > 0 && r.tot_size > 5000, "{f:?}");
|
||||||
assert_eq!(t.stats.broken_links, 1);
|
assert_eq!(t.stats.broken_links, 1);
|
||||||
assert!(f == FollowMode::None || t.stats.loops_skipped > 0);
|
assert!(f == FollowMode::None || t.stats.loops_skipped > 0);
|
||||||
let count = (0..t.len() as NodeId).filter(|&i| &*t.node(i).name == b"x").count();
|
let count = (0..t.len() as NodeId)
|
||||||
|
.filter(|&i| &*t.node(i).name == b"x")
|
||||||
|
.count();
|
||||||
assert_eq!(count, 1, "{f:?}");
|
assert_eq!(count, 1, "{f:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -713,7 +852,13 @@ pub(crate) mod tests {
|
|||||||
std::fs::write(&p, vec![0u8; 100000]).unwrap();
|
std::fs::write(&p, vec![0u8; 100000]).unwrap();
|
||||||
std::fs::create_dir(root.0.join("d")).unwrap();
|
std::fs::create_dir(root.0.join("d")).unwrap();
|
||||||
std::fs::hard_link(&p, root.0.join("d/link")).unwrap();
|
std::fs::hard_link(&p, root.0.join("d/link")).unwrap();
|
||||||
let t = run_scan(&root.0, Options { workers: 2, ..Default::default() });
|
let t = run_scan(
|
||||||
|
&root.0,
|
||||||
|
Options {
|
||||||
|
workers: 2,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
assert_eq!(t.stats.hardlink_dups, 1);
|
assert_eq!(t.stats.hardlink_dups, 1);
|
||||||
assert!(t.node(0).tot_size < 200000);
|
assert!(t.node(0).tot_size < 200000);
|
||||||
}
|
}
|
||||||
@@ -729,7 +874,13 @@ pub(crate) mod tests {
|
|||||||
std::fs::create_dir(&locked).unwrap();
|
std::fs::create_dir(&locked).unwrap();
|
||||||
std::fs::write(locked.join("f"), b"x").unwrap();
|
std::fs::write(locked.join("f"), b"x").unwrap();
|
||||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0)).unwrap();
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0)).unwrap();
|
||||||
let t = run_scan(&root.0, Options { workers: 2, ..Default::default() });
|
let t = run_scan(
|
||||||
|
&root.0,
|
||||||
|
Options {
|
||||||
|
workers: 2,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
assert_eq!(t.stats.err_counts[inv::ErrKind::Permission as usize], 1);
|
assert_eq!(t.stats.err_counts[inv::ErrKind::Permission as usize], 1);
|
||||||
assert!(t.stats.incomplete());
|
assert!(t.stats.incomplete());
|
||||||
@@ -745,18 +896,38 @@ pub(crate) mod tests {
|
|||||||
std::fs::write(root.0.join("keep/b.txt"), b"x").unwrap();
|
std::fs::write(root.0.join("keep/b.txt"), b"x").unwrap();
|
||||||
std::fs::write(root.0.join("skip/c.txt"), b"x").unwrap();
|
std::fs::write(root.0.join("skip/c.txt"), b"x").unwrap();
|
||||||
let skip = root.0.join("skip").to_string_lossy().into_owned();
|
let skip = root.0.join("skip").to_string_lossy().into_owned();
|
||||||
let t = run_scan(&root.0, Options { workers: 2, excludes: vec!["*.tmp".into(), skip], ..Default::default() });
|
let t = run_scan(
|
||||||
|
&root.0,
|
||||||
|
Options {
|
||||||
|
workers: 2,
|
||||||
|
excludes: vec!["*.tmp".into(), skip],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
assert_eq!((t.stats.excluded, t.node(0).files), (2, 1));
|
assert_eq!((t.stats.excluded, t.node(0).files), (2, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hostile_names_preserved() {
|
fn hostile_names_preserved() {
|
||||||
let root = TempDir::new("names");
|
let root = TempDir::new("names");
|
||||||
let names: [&[u8]; 6] = ["日本語.txt".as_bytes(), "emoji-🎉.png".as_bytes(), b"esc\x1b[31mred", b"nl\nname", b"sp ace", b"bad\xffutf8"];
|
let names: [&[u8]; 6] = [
|
||||||
|
"日本語.txt".as_bytes(),
|
||||||
|
"emoji-🎉.png".as_bytes(),
|
||||||
|
b"esc\x1b[31mred",
|
||||||
|
b"nl\nname",
|
||||||
|
b"sp ace",
|
||||||
|
b"bad\xffutf8",
|
||||||
|
];
|
||||||
for n in names {
|
for n in names {
|
||||||
std::fs::write(root.0.join(std::ffi::OsStr::from_bytes(n)), b"x").unwrap();
|
std::fs::write(root.0.join(std::ffi::OsStr::from_bytes(n)), b"x").unwrap();
|
||||||
}
|
}
|
||||||
let t = run_scan(&root.0, Options { workers: 2, ..Default::default() });
|
let t = run_scan(
|
||||||
|
&root.0,
|
||||||
|
Options {
|
||||||
|
workers: 2,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
let got: HashSet<&[u8]> = t.children(0).map(|(_, n)| &*n.name).collect();
|
let got: HashSet<&[u8]> = t.children(0).map(|(_, n)| &*n.name).collect();
|
||||||
for n in names {
|
for n in names {
|
||||||
assert!(got.contains(n), "missing {n:?}");
|
assert!(got.contains(n), "missing {n:?}");
|
||||||
|
|||||||
+26
-5
@@ -28,7 +28,8 @@ pub fn compare(old: Tree, new: &Tree) -> Diff {
|
|||||||
if !old.node(o).is_dir() || !new.node(n).is_dir() {
|
if !old.node(o).is_dir() || !new.node(n).is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut by_name: HashMap<&[u8], NodeId> = old.children(o).map(|(id, c)| (&c.name[..], id)).collect();
|
let mut by_name: HashMap<&[u8], NodeId> =
|
||||||
|
old.children(o).map(|(id, c)| (&c.name[..], id)).collect();
|
||||||
for (id, c) in new.children(n) {
|
for (id, c) in new.children(n) {
|
||||||
if let Some(oid) = by_name.remove(&c.name[..]) {
|
if let Some(oid) = by_name.remove(&c.name[..]) {
|
||||||
stack.push((oid, id));
|
stack.push((oid, id));
|
||||||
@@ -43,7 +44,12 @@ pub fn compare(old: Tree, new: &Tree) -> Diff {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
removed.sort_unstable();
|
removed.sort_unstable();
|
||||||
Diff { old, old_of, removed, removed_under }
|
Diff {
|
||||||
|
old,
|
||||||
|
old_of,
|
||||||
|
removed,
|
||||||
|
removed_under,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classifies a node.
|
/// Classifies a node.
|
||||||
@@ -115,7 +121,10 @@ impl Diff {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let n = new.node(id);
|
let n = new.node(id);
|
||||||
if self.old_of[id as usize] != NO_NODE && n.is_dir() && self.explained_by_child(new, id, delta, m) {
|
if self.old_of[id as usize] != NO_NODE
|
||||||
|
&& n.is_dir()
|
||||||
|
&& self.explained_by_child(new, id, delta, m)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if n.parent != NO_NODE && self.old_of[n.parent as usize] == NO_NODE {
|
if n.parent != NO_NODE && self.old_of[n.parent as usize] == NO_NODE {
|
||||||
@@ -132,9 +141,21 @@ impl Diff {
|
|||||||
}
|
}
|
||||||
for &oid in &self.removed {
|
for &oid in &self.removed {
|
||||||
let o = self.old.node(oid).total(m);
|
let o = self.old.node(oid).total(m);
|
||||||
out.push(Change { path: self.old.path_string(oid), status: Status::Removed, delta: -o, old: o, new: 0, node: NO_NODE });
|
out.push(Change {
|
||||||
|
path: self.old.path_string(oid),
|
||||||
|
status: Status::Removed,
|
||||||
|
delta: -o,
|
||||||
|
old: o,
|
||||||
|
new: 0,
|
||||||
|
node: NO_NODE,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
out.sort_by(|a, b| b.delta.unsigned_abs().cmp(&a.delta.unsigned_abs()).then_with(|| a.path.cmp(&b.path)));
|
out.sort_by(|a, b| {
|
||||||
|
b.delta
|
||||||
|
.unsigned_abs()
|
||||||
|
.cmp(&a.delta.unsigned_abs())
|
||||||
|
.then_with(|| a.path.cmp(&b.path))
|
||||||
|
});
|
||||||
if k > 0 {
|
if k > 0 {
|
||||||
out.truncate(k);
|
out.truncate(k);
|
||||||
}
|
}
|
||||||
|
|||||||
+292
-67
@@ -31,7 +31,9 @@ mod compare;
|
|||||||
pub use compare::*;
|
pub use compare::*;
|
||||||
|
|
||||||
use crate::brand;
|
use crate::brand;
|
||||||
use crate::inventory::{Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES};
|
use crate::inventory::{
|
||||||
|
Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES,
|
||||||
|
};
|
||||||
use crossbeam_channel::Receiver;
|
use crossbeam_channel::Receiver;
|
||||||
use flate2::{bufread::GzDecoder, write::GzEncoder, Compression};
|
use flate2::{bufread::GzDecoder, write::GzEncoder, Compression};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -91,7 +93,10 @@ struct Meta {
|
|||||||
/// Reports whether path starts with the snapshot magic.
|
/// Reports whether path starts with the snapshot magic.
|
||||||
pub fn is_snapshot(path: &Path) -> bool {
|
pub fn is_snapshot(path: &Path) -> bool {
|
||||||
let mut b = [0u8; 8];
|
let mut b = [0u8; 8];
|
||||||
File::open(path).and_then(|mut f| f.read_exact(&mut b)).is_ok() && &b == brand::SNAPSHOT_MAGIC
|
File::open(path)
|
||||||
|
.and_then(|mut f| f.read_exact(&mut b))
|
||||||
|
.is_ok()
|
||||||
|
&& &b == brand::SNAPSHOT_MAGIC
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accumulates the body; flushes it through the hash and gzip in chunks.
|
/// Accumulates the body; flushes it through the hash and gzip in chunks.
|
||||||
@@ -140,14 +145,21 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> {
|
|||||||
bw.write_all(brand::SNAPSHOT_MAGIC)?;
|
bw.write_all(brand::SNAPSHOT_MAGIC)?;
|
||||||
bw.write_all(&VERSION.to_le_bytes())?;
|
bw.write_all(&VERSION.to_le_bytes())?;
|
||||||
bw.write_all(&FLAG_GZIPPED.to_le_bytes())?;
|
bw.write_all(&FLAG_GZIPPED.to_le_bytes())?;
|
||||||
let mut w = Writer { gz: GzEncoder::new(bw, Compression::fast()), h: Sha256::new(), buf: Vec::with_capacity(256 << 10) };
|
let mut w = Writer {
|
||||||
|
gz: GzEncoder::new(bw, Compression::fast()),
|
||||||
|
h: Sha256::new(),
|
||||||
|
buf: Vec::with_capacity(256 << 10),
|
||||||
|
};
|
||||||
|
|
||||||
let st = &t.stats;
|
let st = &t.stats;
|
||||||
let m = Meta {
|
let m = Meta {
|
||||||
root: String::from_utf8_lossy(&st.root).into_owned(),
|
root: String::from_utf8_lossy(&st.root).into_owned(),
|
||||||
created: GoTime::from_system(SystemTime::now()),
|
created: GoTime::from_system(SystemTime::now()),
|
||||||
start: GoTime::from_system(st.start).local(),
|
start: GoTime::from_system(st.start).local(),
|
||||||
end: st.end.map(|e| GoTime::from_system(e).local()).unwrap_or_default(),
|
end: st
|
||||||
|
.end
|
||||||
|
.map(|e| GoTime::from_system(e).local())
|
||||||
|
.unwrap_or_default(),
|
||||||
complete: st.complete,
|
complete: st.complete,
|
||||||
cancelled: st.cancelled,
|
cancelled: st.cancelled,
|
||||||
files: st.files,
|
files: st.files,
|
||||||
@@ -223,16 +235,31 @@ pub fn save_file(path: &Path, t: &Tree) -> io::Result<()> {
|
|||||||
|
|
||||||
/// Creates a new 0600 file named .mapsize-*.tmp in dir (like os.CreateTemp).
|
/// Creates a new 0600 file named .mapsize-*.tmp in dir (like os.CreateTemp).
|
||||||
fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> {
|
fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> {
|
||||||
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() ^ std::process::id();
|
let seed = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.subsec_nanos()
|
||||||
|
^ std::process::id();
|
||||||
for i in 0..10000u32 {
|
for i in 0..10000u32 {
|
||||||
let p = dir.join(format!(".mapsize-{}.tmp", seed.wrapping_add(i.wrapping_mul(7919))));
|
let p = dir.join(format!(
|
||||||
match OpenOptions::new().write(true).create_new(true).mode(0o600).open(&p) {
|
".mapsize-{}.tmp",
|
||||||
|
seed.wrapping_add(i.wrapping_mul(7919))
|
||||||
|
));
|
||||||
|
match OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&p)
|
||||||
|
{
|
||||||
Ok(f) => return Ok((p, f)),
|
Ok(f) => return Ok((p, f)),
|
||||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, "snapshot: cannot create temporary file"))
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"snapshot: cannot create temporary file",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hostname() -> String {
|
fn hostname() -> String {
|
||||||
@@ -257,7 +284,12 @@ const UNEXPECTED_EOF: &str = "unexpected EOF";
|
|||||||
|
|
||||||
impl Body {
|
impl Body {
|
||||||
fn new(rx: Receiver<Result<Vec<u8>, String>>) -> Body {
|
fn new(rx: Receiver<Result<Vec<u8>, String>>) -> Body {
|
||||||
Body { rx, buf: Vec::new(), pos: 0, err: None }
|
Body {
|
||||||
|
rx,
|
||||||
|
buf: Vec::new(),
|
||||||
|
pos: 0,
|
||||||
|
err: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures unread bytes are buffered; false at end of stream.
|
/// Ensures unread bytes are buffered; false at end of stream.
|
||||||
@@ -320,7 +352,8 @@ impl Body {
|
|||||||
|
|
||||||
fn bytes_n(&mut self, n: u64) -> Result<Vec<u8>, String> {
|
fn bytes_n(&mut self, n: u64) -> Result<Vec<u8>, String> {
|
||||||
let mut b = vec![0u8; n as usize];
|
let mut b = vec![0u8; n as usize];
|
||||||
self.read_full(&mut b).map_err(|e| format!("snapshot: truncated: {e}"))?;
|
self.read_full(&mut b)
|
||||||
|
.map_err(|e| format!("snapshot: truncated: {e}"))?;
|
||||||
Ok(b)
|
Ok(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,13 +424,16 @@ pub fn load_file(path: &Path) -> Result<Tree, crate::Error> {
|
|||||||
fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, String> {
|
fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, String> {
|
||||||
let mut br = BufReader::with_capacity(1 << 20, input);
|
let mut br = BufReader::with_capacity(1 << 20, input);
|
||||||
let mut hdr = [0u8; 12];
|
let mut hdr = [0u8; 12];
|
||||||
br.read_exact(&mut hdr).map_err(|_| "snapshot: file too short".to_string())?;
|
br.read_exact(&mut hdr)
|
||||||
|
.map_err(|_| "snapshot: file too short".to_string())?;
|
||||||
if &hdr[..8] != brand::SNAPSHOT_MAGIC {
|
if &hdr[..8] != brand::SNAPSHOT_MAGIC {
|
||||||
return Err(format!("snapshot: not a {} snapshot", brand::NAME));
|
return Err(format!("snapshot: not a {} snapshot", brand::NAME));
|
||||||
}
|
}
|
||||||
let v = u16::from_le_bytes([hdr[8], hdr[9]]);
|
let v = u16::from_le_bytes([hdr[8], hdr[9]]);
|
||||||
if v != VERSION {
|
if v != VERSION {
|
||||||
return Err(format!("snapshot: unsupported version {v} (this build reads {VERSION})"));
|
return Err(format!(
|
||||||
|
"snapshot: unsupported version {v} (this build reads {VERSION})"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if u16::from_le_bytes([hdr[10], hdr[11]]) & FLAG_GZIPPED == 0 {
|
if u16::from_le_bytes([hdr[10], hdr[11]]) & FLAG_GZIPPED == 0 {
|
||||||
return Err("snapshot: uncompressed bodies are not supported".into());
|
return Err("snapshot: uncompressed bodies are not supported".into());
|
||||||
@@ -436,13 +472,16 @@ fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(tx);
|
drop(tx);
|
||||||
parser.join().unwrap_or_else(|p| std::panic::resume_unwind(p))
|
parser
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|p| std::panic::resume_unwind(p))
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let (mut t, m) = res?;
|
let (mut t, m) = res?;
|
||||||
// The checksum follows the gzip stream.
|
// The checksum follows the gzip stream.
|
||||||
let mut sum = [0u8; 32];
|
let mut sum = [0u8; 32];
|
||||||
br.read_exact(&mut sum).map_err(|_| "snapshot: missing checksum".to_string())?;
|
br.read_exact(&mut sum)
|
||||||
|
.map_err(|_| "snapshot: missing checksum".to_string())?;
|
||||||
if sum[..] != h.finalize()[..] {
|
if sum[..] != h.finalize()[..] {
|
||||||
return Err("snapshot: checksum mismatch (file corrupt)".into());
|
return Err("snapshot: checksum mismatch (file corrupt)".into());
|
||||||
}
|
}
|
||||||
@@ -454,17 +493,36 @@ fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result<Tree, S
|
|||||||
st.complete = true;
|
st.complete = true;
|
||||||
st.cancelled = m.cancelled;
|
st.cancelled = m.cancelled;
|
||||||
(st.files, st.dirs, st.symlinks, st.others) = (m.files, m.dirs, m.symlinks, m.others);
|
(st.files, st.dirs, st.symlinks, st.others) = (m.files, m.dirs, m.symlinks, m.others);
|
||||||
for (d, s) in st.err_counts.iter_mut().zip(m.err_counts.unwrap_or_default()) {
|
for (d, s) in st
|
||||||
|
.err_counts
|
||||||
|
.iter_mut()
|
||||||
|
.zip(m.err_counts.unwrap_or_default())
|
||||||
|
{
|
||||||
*d = s;
|
*d = s;
|
||||||
}
|
}
|
||||||
(st.excluded, st.skipped_mounts, st.virtual_skipped) = (m.excluded, m.skipped_mounts, m.virtual_skipped);
|
(st.excluded, st.skipped_mounts, st.virtual_skipped) =
|
||||||
(st.loops_skipped, st.broken_links, st.hardlink_dups, st.unscanned) =
|
(m.excluded, m.skipped_mounts, m.virtual_skipped);
|
||||||
(m.loops_skipped, m.broken_links, m.hardlink_dups, m.unscanned);
|
(
|
||||||
|
st.loops_skipped,
|
||||||
|
st.broken_links,
|
||||||
|
st.hardlink_dups,
|
||||||
|
st.unscanned,
|
||||||
|
) = (
|
||||||
|
m.loops_skipped,
|
||||||
|
m.broken_links,
|
||||||
|
m.hardlink_dups,
|
||||||
|
m.unscanned,
|
||||||
|
);
|
||||||
st.excludes = m.excludes.unwrap_or_default();
|
st.excludes = m.excludes.unwrap_or_default();
|
||||||
st.one_file_system = m.one_file_system;
|
st.one_file_system = m.one_file_system;
|
||||||
st.follow = m.follow;
|
st.follow = m.follow;
|
||||||
st.workers = m.workers.max(0) as usize;
|
st.workers = m.workers.max(0) as usize;
|
||||||
st.from_snapshot = format!("{} ({}, {})", m.created.local_minutes(), m.host, m.generator);
|
st.from_snapshot = format!(
|
||||||
|
"{} ({}, {})",
|
||||||
|
m.created.local_minutes(),
|
||||||
|
m.host,
|
||||||
|
m.generator
|
||||||
|
);
|
||||||
Ok(t)
|
Ok(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,11 +577,16 @@ fn parse(r: &mut Body, max_nodes: u64) -> Result<(Tree, Meta), String> {
|
|||||||
return Err(format!("snapshot: node {i}: parent is not a directory"));
|
return Err(format!("snapshot: node {i}: parent is not a directory"));
|
||||||
}
|
}
|
||||||
if !valid_name(&name) {
|
if !valid_name(&name) {
|
||||||
return Err(format!("snapshot: node {i}: invalid name {:?}", String::from_utf8_lossy(&name)));
|
return Err(format!(
|
||||||
|
"snapshot: node {i}: invalid name {:?}",
|
||||||
|
String::from_utf8_lossy(&name)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let d = depth[parent as usize] + 1;
|
let d = depth[parent as usize] + 1;
|
||||||
if d > MAX_DEPTH {
|
if d > MAX_DEPTH {
|
||||||
return Err(format!("snapshot: node {i}: deeper than {MAX_DEPTH} levels"));
|
return Err(format!(
|
||||||
|
"snapshot: node {i}: deeper than {MAX_DEPTH} levels"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
depth.push(d);
|
depth.push(d);
|
||||||
t.add(parent, &name, kind)
|
t.add(parent, &name, kind)
|
||||||
@@ -544,7 +607,12 @@ fn parse(r: &mut Body, max_nodes: u64) -> Result<(Tree, Meta), String> {
|
|||||||
let kind = ErrKind::from_u8(r.byte1()?); // out-of-range kinds become Other
|
let kind = ErrKind::from_u8(r.byte1()?); // out-of-range kinds become Other
|
||||||
let name = r.str(MAX_NAME_LEN, "error name")?;
|
let name = r.str(MAX_NAME_LEN, "error name")?;
|
||||||
let msg = String::from_utf8_lossy(&r.str(MAX_MSG_LEN, "error message")?).into_owned();
|
let msg = String::from_utf8_lossy(&r.str(MAX_MSG_LEN, "error message")?).into_owned();
|
||||||
t.stats.errors.push(ErrorRecord { node, kind, name, msg });
|
t.stats.errors.push(ErrorRecord {
|
||||||
|
node,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
msg,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// The body must end here.
|
// The body must end here.
|
||||||
if r.byte() != Err(EOF.into()) {
|
if r.byte() != Err(EOF.into()) {
|
||||||
@@ -571,19 +639,35 @@ const GO_ZERO_SECS: i64 = -62_135_596_800;
|
|||||||
|
|
||||||
impl Default for GoTime {
|
impl Default for GoTime {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
GoTime { secs: GO_ZERO_SECS, nanos: 0, off: 0 }
|
GoTime {
|
||||||
|
secs: GO_ZERO_SECS,
|
||||||
|
nanos: 0,
|
||||||
|
off: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GoTime {
|
impl GoTime {
|
||||||
fn from_system(t: SystemTime) -> GoTime {
|
fn from_system(t: SystemTime) -> GoTime {
|
||||||
match t.duration_since(UNIX_EPOCH) {
|
match t.duration_since(UNIX_EPOCH) {
|
||||||
Ok(d) => GoTime { secs: d.as_secs() as i64, nanos: d.subsec_nanos(), off: 0 },
|
Ok(d) => GoTime {
|
||||||
|
secs: d.as_secs() as i64,
|
||||||
|
nanos: d.subsec_nanos(),
|
||||||
|
off: 0,
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let d = e.duration();
|
let d = e.duration();
|
||||||
let (s, n) = (d.as_secs() as i64, d.subsec_nanos());
|
let (s, n) = (d.as_secs() as i64, d.subsec_nanos());
|
||||||
let (secs, nanos) = if n == 0 { (-s, 0) } else { (-s - 1, 1_000_000_000 - n) };
|
let (secs, nanos) = if n == 0 {
|
||||||
GoTime { secs, nanos, off: 0 }
|
(-s, 0)
|
||||||
|
} else {
|
||||||
|
(-s - 1, 1_000_000_000 - n)
|
||||||
|
};
|
||||||
|
GoTime {
|
||||||
|
secs,
|
||||||
|
nanos,
|
||||||
|
off: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,7 +686,8 @@ impl GoTime {
|
|||||||
if self.secs >= 0 {
|
if self.secs >= 0 {
|
||||||
UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos)
|
UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos)
|
||||||
} else {
|
} else {
|
||||||
UNIX_EPOCH - Duration::from_secs(self.secs.unsigned_abs()) + Duration::from_nanos(self.nanos as u64)
|
UNIX_EPOCH - Duration::from_secs(self.secs.unsigned_abs())
|
||||||
|
+ Duration::from_nanos(self.nanos as u64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +696,12 @@ impl GoTime {
|
|||||||
let secs = self.secs + self.off as i64;
|
let secs = self.secs + self.off as i64;
|
||||||
let (y, mo, d) = civil_from_days(secs.div_euclid(86400));
|
let (y, mo, d) = civil_from_days(secs.div_euclid(86400));
|
||||||
let rem = secs.rem_euclid(86400);
|
let rem = secs.rem_euclid(86400);
|
||||||
let mut s = format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60);
|
let mut s = format!(
|
||||||
|
"{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}",
|
||||||
|
rem / 3600,
|
||||||
|
rem / 60 % 60,
|
||||||
|
rem % 60
|
||||||
|
);
|
||||||
if self.nanos != 0 {
|
if self.nanos != 0 {
|
||||||
s += format!(".{:09}", self.nanos).trim_end_matches('0');
|
s += format!(".{:09}", self.nanos).trim_end_matches('0');
|
||||||
}
|
}
|
||||||
@@ -619,7 +709,12 @@ impl GoTime {
|
|||||||
return s + "Z";
|
return s + "Z";
|
||||||
}
|
}
|
||||||
let a = self.off.unsigned_abs() / 60;
|
let a = self.off.unsigned_abs() / 60;
|
||||||
format!("{s}{}{:02}:{:02}", if self.off < 0 { '-' } else { '+' }, a / 60, a % 60)
|
format!(
|
||||||
|
"{s}{}{:02}:{:02}",
|
||||||
|
if self.off < 0 { '-' } else { '+' },
|
||||||
|
a / 60,
|
||||||
|
a % 60
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses RFC 3339 ("2026-10-08T10:40:00.123456789+01:00").
|
/// Parses RFC 3339 ("2026-10-08T10:40:00.123456789+01:00").
|
||||||
@@ -627,12 +722,27 @@ impl GoTime {
|
|||||||
let b = s.as_bytes();
|
let b = s.as_bytes();
|
||||||
let num = |r: std::ops::Range<usize>| -> Option<i64> {
|
let num = |r: std::ops::Range<usize>| -> Option<i64> {
|
||||||
let p = b.get(r)?;
|
let p = b.get(r)?;
|
||||||
p.iter().all(u8::is_ascii_digit).then(|| p.iter().fold(0i64, |a, &c| a * 10 + (c - b'0') as i64))
|
p.iter()
|
||||||
|
.all(u8::is_ascii_digit)
|
||||||
|
.then(|| p.iter().fold(0i64, |a, &c| a * 10 + (c - b'0') as i64))
|
||||||
};
|
};
|
||||||
if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
|
if b.len() < 20
|
||||||
|
|| b[4] != b'-'
|
||||||
|
|| b[7] != b'-'
|
||||||
|
|| b[10] != b'T'
|
||||||
|
|| b[13] != b':'
|
||||||
|
|| b[16] != b':'
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (y, mo, d, h, mi, se) = (num(0..4)?, num(5..7)?, num(8..10)?, num(11..13)?, num(14..16)?, num(17..19)?);
|
let (y, mo, d, h, mi, se) = (
|
||||||
|
num(0..4)?,
|
||||||
|
num(5..7)?,
|
||||||
|
num(8..10)?,
|
||||||
|
num(11..13)?,
|
||||||
|
num(14..16)?,
|
||||||
|
num(17..19)?,
|
||||||
|
);
|
||||||
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 {
|
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -666,13 +776,26 @@ impl GoTime {
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se - off;
|
let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se - off;
|
||||||
Some(GoTime { secs, nanos, off: off as i32 })
|
Some(GoTime {
|
||||||
|
secs,
|
||||||
|
nanos,
|
||||||
|
off: off as i32,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "2006-01-02 15:04" in local time.
|
/// "2006-01-02 15:04" in local time.
|
||||||
fn local_minutes(self) -> String {
|
fn local_minutes(self) -> String {
|
||||||
let Some(tm) = localtime(self.secs) else { return self.format() };
|
let Some(tm) = localtime(self.secs) else {
|
||||||
format!("{:04}-{:02}-{:02} {:02}:{:02}", tm.tm_year as i64 + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min)
|
return self.format();
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{:04}-{:02}-{:02} {:02}:{:02}",
|
||||||
|
tm.tm_year as i64 + 1900,
|
||||||
|
tm.tm_mon + 1,
|
||||||
|
tm.tm_mday,
|
||||||
|
tm.tm_hour,
|
||||||
|
tm.tm_min
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,7 +816,8 @@ impl<'de> Deserialize<'de> for GoTime {
|
|||||||
// null leaves the zero time, as in Go.
|
// null leaves the zero time, as in Go.
|
||||||
match Option::<String>::deserialize(d)? {
|
match Option::<String>::deserialize(d)? {
|
||||||
None => Ok(GoTime::default()),
|
None => Ok(GoTime::default()),
|
||||||
Some(s) => GoTime::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))),
|
Some(s) => GoTime::parse(&s)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,7 +861,12 @@ mod tests {
|
|||||||
t.node_mut(f2).flags = FLAG_SPARSE;
|
t.node_mut(f2).flags = FLAG_SPARSE;
|
||||||
t.recompute();
|
t.recompute();
|
||||||
(t.stats.files, t.stats.dirs) = (2, 3);
|
(t.stats.files, t.stats.dirs) = (2, 3);
|
||||||
t.stats.add_error(ErrorRecord { node: b, kind: ErrKind::Permission, msg: "denied".into(), ..Default::default() });
|
t.stats.add_error(ErrorRecord {
|
||||||
|
node: b,
|
||||||
|
kind: ErrKind::Permission,
|
||||||
|
msg: "denied".into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,8 +884,12 @@ mod tests {
|
|||||||
for i in 0..src.len() as NodeId {
|
for i in 0..src.len() as NodeId {
|
||||||
let (a, b) = (src.node(i), got.node(i));
|
let (a, b) = (src.node(i), got.node(i));
|
||||||
assert!(
|
assert!(
|
||||||
a.name == b.name && a.tot_size == b.tot_size && a.tot_alloc == b.tot_alloc && a.flags == b.flags
|
a.name == b.name
|
||||||
&& src.path(i) == got.path(i) && src.ext_name(a) == got.ext_name(b),
|
&& a.tot_size == b.tot_size
|
||||||
|
&& a.tot_alloc == b.tot_alloc
|
||||||
|
&& a.flags == b.flags
|
||||||
|
&& src.path(i) == got.path(i)
|
||||||
|
&& src.ext_name(a) == got.ext_name(b),
|
||||||
"node {i} differs: {a:?} vs {b:?}"
|
"node {i} differs: {a:?} vs {b:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -802,8 +935,14 @@ mod tests {
|
|||||||
assert_eq!(paths.get("/srv/a/x.iso"), Some(&Status::Grew), "{ch:?}");
|
assert_eq!(paths.get("/srv/a/x.iso"), Some(&Status::Grew), "{ch:?}");
|
||||||
assert_eq!(paths.get("/srv/b"), Some(&Status::Removed), "{ch:?}");
|
assert_eq!(paths.get("/srv/b"), Some(&Status::Removed), "{ch:?}");
|
||||||
assert_eq!(paths.get("/srv/c"), Some(&Status::Added), "{ch:?}");
|
assert_eq!(paths.get("/srv/c"), Some(&Status::Added), "{ch:?}");
|
||||||
assert!(!paths.contains_key("/srv/a"), "/srv/a should be explained by its child");
|
assert!(
|
||||||
assert!(!paths.contains_key("/srv/c/z"), "contents of new dirs should not be listed separately");
|
!paths.contains_key("/srv/a"),
|
||||||
|
"/srv/a should be explained by its child"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!paths.contains_key("/srv/c/z"),
|
||||||
|
"contents of new dirs should not be listed separately"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn body(b: &[u8]) -> Body {
|
fn body(b: &[u8]) -> Body {
|
||||||
@@ -813,7 +952,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn put_uvarint(v: u64) -> Vec<u8> {
|
fn put_uvarint(v: u64) -> Vec<u8> {
|
||||||
let mut w = Writer { gz: GzEncoder::new(Vec::new(), Compression::fast()), h: Sha256::new(), buf: Vec::new() };
|
let mut w = Writer {
|
||||||
|
gz: GzEncoder::new(Vec::new(), Compression::fast()),
|
||||||
|
h: Sha256::new(),
|
||||||
|
buf: Vec::new(),
|
||||||
|
};
|
||||||
w.uvarint(v).unwrap();
|
w.uvarint(v).unwrap();
|
||||||
w.buf
|
w.buf
|
||||||
}
|
}
|
||||||
@@ -824,7 +967,10 @@ mod tests {
|
|||||||
assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v));
|
assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v));
|
||||||
}
|
}
|
||||||
assert_eq!(put_uvarint(300), [0xac, 0x02]);
|
assert_eq!(put_uvarint(300), [0xac, 0x02]);
|
||||||
assert!(read_uvarint(&mut body(&[0xff; 11])).is_err(), "overflow not detected");
|
assert!(
|
||||||
|
read_uvarint(&mut body(&[0xff; 11])).is_err(),
|
||||||
|
"overflow not detected"
|
||||||
|
);
|
||||||
// Zig-zag as Go's PutVarint.
|
// Zig-zag as Go's PutVarint.
|
||||||
for (v, enc) in [(0i64, 0u64), (-1, 1), (1, 2), (-2, 3), (i64::MIN, u64::MAX)] {
|
for (v, enc) in [(0i64, 0u64), (-1, 1), (1, 2), (-2, 3), (i64::MIN, u64::MAX)] {
|
||||||
assert_eq!(((v << 1) ^ (v >> 63)) as u64, enc);
|
assert_eq!(((v << 1) ^ (v >> 63)) as u64, enc);
|
||||||
@@ -837,13 +983,38 @@ mod tests {
|
|||||||
let t = GoTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap();
|
let t = GoTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap();
|
||||||
assert_eq!((t.secs, t.nanos, t.off), (1_791_452_400, 123_456_789, 3600));
|
assert_eq!((t.secs, t.nanos, t.off), (1_791_452_400, 123_456_789, 3600));
|
||||||
assert_eq!(t.format(), "2026-10-08T10:40:00.123456789+01:00");
|
assert_eq!(t.format(), "2026-10-08T10:40:00.123456789+01:00");
|
||||||
assert_eq!(GoTime { off: 0, ..t }.format(), "2026-10-08T09:40:00.123456789Z");
|
assert_eq!(
|
||||||
assert_eq!(GoTime { off: -5 * 3600 - 1800, ..t }.format(), "2026-10-08T04:10:00.123456789-05:30");
|
GoTime { off: 0, ..t }.format(),
|
||||||
assert_eq!(GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(), "2026-10-08T09:40:00.1Z");
|
"2026-10-08T09:40:00.123456789Z"
|
||||||
assert_eq!(GoTime::parse("0001-01-01T00:00:00Z"), Some(GoTime::default()));
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GoTime {
|
||||||
|
off: -5 * 3600 - 1800,
|
||||||
|
..t
|
||||||
|
}
|
||||||
|
.format(),
|
||||||
|
"2026-10-08T04:10:00.123456789-05:30"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(),
|
||||||
|
"2026-10-08T09:40:00.1Z"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GoTime::parse("0001-01-01T00:00:00Z"),
|
||||||
|
Some(GoTime::default())
|
||||||
|
);
|
||||||
assert_eq!(GoTime::default().format(), "0001-01-01T00:00:00Z");
|
assert_eq!(GoTime::default().format(), "0001-01-01T00:00:00Z");
|
||||||
assert_eq!(GoTime::from_system(GoTime::default().to_system()), GoTime::default());
|
assert_eq!(
|
||||||
for bad in ["", "2026-10-08", "2026-13-08T00:00:00Z", "2026-10-08T00:00:00", "2026-10-08T00:00:00.Z"] {
|
GoTime::from_system(GoTime::default().to_system()),
|
||||||
|
GoTime::default()
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
"2026-10-08",
|
||||||
|
"2026-13-08T00:00:00Z",
|
||||||
|
"2026-10-08T00:00:00",
|
||||||
|
"2026-10-08T00:00:00.Z",
|
||||||
|
] {
|
||||||
assert_eq!(GoTime::parse(bad), None, "{bad}");
|
assert_eq!(GoTime::parse(bad), None, "{bad}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,21 +1023,34 @@ mod tests {
|
|||||||
/// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}).
|
/// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}).
|
||||||
#[test]
|
#[test]
|
||||||
fn loads_go_snapshot() {
|
fn loads_go_snapshot() {
|
||||||
let t = load_file(Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src/snapshot/testdata/go.msz"))).unwrap();
|
let t = load_file(Path::new(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/src/snapshot/testdata/go.msz"
|
||||||
|
)))
|
||||||
|
.unwrap();
|
||||||
let r = t.node(0);
|
let r = t.node(0);
|
||||||
assert_eq!((r.tot_size, r.tot_alloc), (13310, 20480));
|
assert_eq!((r.tot_size, r.tot_alloc), (13310, 20480));
|
||||||
assert_eq!((t.stats.files, t.stats.dirs, t.stats.symlinks), (3, 4, 1));
|
assert_eq!((t.stats.files, t.stats.dirs, t.stats.symlinks), (3, 4, 1));
|
||||||
assert_eq!(t.len(), 8);
|
assert_eq!(t.len(), 8);
|
||||||
assert!(t.stats.root.ends_with(b"/tree"));
|
assert!(t.stats.root.ends_with(b"/tree"));
|
||||||
assert!(t.stats.end.is_some() && t.stats.complete);
|
assert!(t.stats.end.is_some() && t.stats.complete);
|
||||||
assert!(t.stats.from_snapshot.contains("mapsize"), "{}", t.stats.from_snapshot);
|
assert!(
|
||||||
let iso = (0..t.len() as NodeId).find(|&i| &t.node(i).name[..] == b"x.iso").unwrap();
|
t.stats.from_snapshot.contains("mapsize"),
|
||||||
|
"{}",
|
||||||
|
t.stats.from_snapshot
|
||||||
|
);
|
||||||
|
let iso = (0..t.len() as NodeId)
|
||||||
|
.find(|&i| &t.node(i).name[..] == b"x.iso")
|
||||||
|
.unwrap();
|
||||||
assert_eq!(t.node(iso).size, 10000);
|
assert_eq!(t.node(iso).size, 10000);
|
||||||
assert_eq!(t.ext_name(t.node(iso)), "iso");
|
assert_eq!(t.ext_name(t.node(iso)), "iso");
|
||||||
assert!(t.path_string(iso).ends_with("/tree/a/x.iso"));
|
assert!(t.path_string(iso).ends_with("/tree/a/x.iso"));
|
||||||
// And it survives a Rust round trip unchanged.
|
// And it survives a Rust round trip unchanged.
|
||||||
let again = load(&saved(&t)[..]).unwrap();
|
let again = load(&saved(&t)[..]).unwrap();
|
||||||
assert_eq!((again.node(0).tot_size, again.len(), again.stats.start), (13310, 8, t.stats.start));
|
assert_eq!(
|
||||||
|
(again.node(0).tot_size, again.len(), again.stats.start),
|
||||||
|
(13310, 8, t.stats.start)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hostile input.
|
// Hostile input.
|
||||||
@@ -877,7 +1061,11 @@ mod tests {
|
|||||||
out.extend_from_slice(brand::SNAPSHOT_MAGIC);
|
out.extend_from_slice(brand::SNAPSHOT_MAGIC);
|
||||||
out.extend_from_slice(&VERSION.to_le_bytes());
|
out.extend_from_slice(&VERSION.to_le_bytes());
|
||||||
out.extend_from_slice(&FLAG_GZIPPED.to_le_bytes());
|
out.extend_from_slice(&FLAG_GZIPPED.to_le_bytes());
|
||||||
let mut w = Writer { gz: GzEncoder::new(out, Compression::best()), h: Sha256::new(), buf: Vec::new() };
|
let mut w = Writer {
|
||||||
|
gz: GzEncoder::new(out, Compression::best()),
|
||||||
|
h: Sha256::new(),
|
||||||
|
buf: Vec::new(),
|
||||||
|
};
|
||||||
w.uvarint(meta.len() as u64).unwrap();
|
w.uvarint(meta.len() as u64).unwrap();
|
||||||
w.bytes(meta.as_bytes()).unwrap();
|
w.bytes(meta.as_bytes()).unwrap();
|
||||||
w.uvarint(1).unwrap();
|
w.uvarint(1).unwrap();
|
||||||
@@ -908,49 +1096,80 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn chain(n: usize) -> Vec<u8> {
|
fn chain(n: usize) -> Vec<u8> {
|
||||||
craft(n, r#"{"root":"/r"}"#, |i, w| if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, 1, Kind::Dir, "a", 0) })
|
craft(n, r#"{"root":"/r"}"#, |i, w| {
|
||||||
|
if i == 0 {
|
||||||
|
node(w, 0, Kind::Dir, "", 0)
|
||||||
|
} else {
|
||||||
|
node(w, 1, Kind::Dir, "a", 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deep_chain_rejected() {
|
fn deep_chain_rejected() {
|
||||||
load(&chain(MAX_DEPTH as usize + 1)[..]).expect("depth at the limit must load");
|
load(&chain(MAX_DEPTH as usize + 1)[..]).expect("depth at the limit must load");
|
||||||
let err = load(&chain(MAX_DEPTH as usize + 2)[..]).err().expect("over-deep chain accepted");
|
let err = load(&chain(MAX_DEPTH as usize + 2)[..])
|
||||||
|
.err()
|
||||||
|
.expect("over-deep chain accepted");
|
||||||
assert!(err.to_string().contains("deeper"), "{err}");
|
assert!(err.to_string().contains("deeper"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decompression_bomb_limited() {
|
fn decompression_bomb_limited() {
|
||||||
let flat = craft(100_000, r#"{"root":"/r"}"#, |i, w| {
|
let flat = craft(100_000, r#"{"root":"/r"}"#, |i, w| {
|
||||||
if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, i as u64, Kind::File, "f", 0) }
|
if i == 0 {
|
||||||
|
node(w, 0, Kind::Dir, "", 0)
|
||||||
|
} else {
|
||||||
|
node(w, i as u64, Kind::File, "f", 0)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let err = load_limit(&flat[..], 64 << 10).err().expect("bomb not limited");
|
let err = load_limit(&flat[..], 64 << 10)
|
||||||
|
.err()
|
||||||
|
.expect("bomb not limited");
|
||||||
assert!(err.to_string().contains("limit"), "{err}");
|
assert!(err.to_string().contains("limit"), "{err}");
|
||||||
assert!(body_limit(flat.len() as i64) >= 64 << 20, "body_limit floor");
|
assert!(
|
||||||
|
body_limit(flat.len() as i64) >= 64 << 20,
|
||||||
|
"body_limit floor"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn huge_sizes_do_not_wrap() {
|
fn huge_sizes_do_not_wrap() {
|
||||||
let data = craft(3, r#"{"root":"/r"}"#, |i, w| {
|
let data = craft(3, r#"{"root":"/r"}"#, |i, w| {
|
||||||
if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, i as u64, Kind::File, "f", MAX_SIZE) }
|
if i == 0 {
|
||||||
|
node(w, 0, Kind::Dir, "", 0)
|
||||||
|
} else {
|
||||||
|
node(w, i as u64, Kind::File, "f", MAX_SIZE)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let tr = load(&data[..]).unwrap();
|
let tr = load(&data[..]).unwrap();
|
||||||
assert!(tr.node(0).tot_size >= 0, "aggregate wrapped negative");
|
assert!(tr.node(0).tot_size >= 0, "aggregate wrapped negative");
|
||||||
let over = craft(2, r#"{"root":"/r"}"#, |i, w| {
|
let over = craft(2, r#"{"root":"/r"}"#, |i, w| {
|
||||||
if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, 1, Kind::File, "f", MAX_SIZE + 1) }
|
if i == 0 {
|
||||||
|
node(w, 0, Kind::Dir, "", 0)
|
||||||
|
} else {
|
||||||
|
node(w, 1, Kind::File, "f", MAX_SIZE + 1)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
assert!(load(&over[..]).is_err(), "size above MAX_SIZE accepted");
|
assert!(load(&over[..]).is_err(), "size above MAX_SIZE accepted");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hostile_metadata_sanitised_in_summary() {
|
fn hostile_metadata_sanitised_in_summary() {
|
||||||
let data = craft(1, r#"{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}"#, |_, w| {
|
let data = craft(
|
||||||
node(w, 0, Kind::Dir, "", 0)
|
1,
|
||||||
});
|
r#"{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}"#,
|
||||||
|
|_, w| node(w, 0, Kind::Dir, "", 0),
|
||||||
|
);
|
||||||
let mut tr = load(&data[..]).unwrap();
|
let mut tr = load(&data[..]).unwrap();
|
||||||
tr.stats.excluded = 1;
|
tr.stats.excluded = 1;
|
||||||
let mut b = Vec::new();
|
let mut b = Vec::new();
|
||||||
crate::export::summary(&mut b, &tr).unwrap();
|
crate::export::summary(&mut b, &tr).unwrap();
|
||||||
assert!(!b.iter().any(|&c| c == 0x1b || c == 0x07), "raw control characters in summary: {:?}", String::from_utf8_lossy(&b));
|
assert!(
|
||||||
|
!b.iter().any(|&c| c == 0x1b || c == 0x07),
|
||||||
|
"raw control characters in summary: {:?}",
|
||||||
|
String::from_utf8_lossy(&b)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blocks of one directory plus K-1 files repeat byte for byte, so a
|
/// Blocks of one directory plus K-1 files repeat byte for byte, so a
|
||||||
@@ -971,7 +1190,9 @@ mod tests {
|
|||||||
fs::write(&p, &data).unwrap();
|
fs::write(&p, &data).unwrap();
|
||||||
let res = load_file(&p);
|
let res = load_file(&p);
|
||||||
let _ = fs::remove_file(&p);
|
let _ = fs::remove_file(&p);
|
||||||
let err = res.err().unwrap_or_else(|| panic!("{N} nodes from a {}-byte file accepted", data.len()));
|
let err = res
|
||||||
|
.err()
|
||||||
|
.unwrap_or_else(|| panic!("{N} nodes from a {}-byte file accepted", data.len()));
|
||||||
assert!(err.to_string().contains("node count"), "{err}");
|
assert!(err.to_string().contains("node count"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,7 +1204,11 @@ mod tests {
|
|||||||
save_file(&p, &sample()).unwrap();
|
save_file(&p, &sample()).unwrap();
|
||||||
assert!(is_snapshot(&p));
|
assert!(is_snapshot(&p));
|
||||||
assert_eq!(load_file(&p).unwrap().len(), 5);
|
assert_eq!(load_file(&p).unwrap().len(), 5);
|
||||||
assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "temp file left behind");
|
assert_eq!(
|
||||||
|
fs::read_dir(&dir).unwrap().count(),
|
||||||
|
1,
|
||||||
|
"temp file left behind"
|
||||||
|
);
|
||||||
fs::remove_dir_all(&dir).unwrap();
|
fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-6
@@ -41,7 +41,11 @@ pub fn sanitize(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_bidi_control(r: u32) -> bool {
|
fn is_bidi_control(r: u32) -> bool {
|
||||||
(0x202a..=0x202e).contains(&r) || (0x2066..=0x2069).contains(&r) || r == 0x200e || r == 0x200f || r == 0x061c
|
(0x202a..=0x202e).contains(&r)
|
||||||
|
|| (0x2066..=0x2069).contains(&r)
|
||||||
|
|| r == 0x200e
|
||||||
|
|| r == 0x200f
|
||||||
|
|| r == 0x061c
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls f for each grapheme cluster of s with its display width. Clusters
|
/// Calls f for each grapheme cluster of s with its display width. Clusters
|
||||||
@@ -53,7 +57,11 @@ pub fn clusters(s: &str, mut f: impl FnMut(&str, usize) -> bool) {
|
|||||||
for c in s.graphemes(true) {
|
for c in s.graphemes(true) {
|
||||||
let mut chars = c.chars();
|
let mut chars = c.chars();
|
||||||
let first = chars.next().unwrap();
|
let first = chars.next().unwrap();
|
||||||
let w = if chars.next().is_none() { first.width().unwrap_or(0) } else { c.width() };
|
let w = if chars.next().is_none() {
|
||||||
|
first.width().unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
c.width()
|
||||||
|
};
|
||||||
if w == 0 {
|
if w == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -171,7 +179,11 @@ const IEC_UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
|
|||||||
const SI_UNITS: [&str; 7] = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
|
const SI_UNITS: [&str; 7] = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
|
||||||
|
|
||||||
fn scaled(n: i64) -> (f64, &'static str) {
|
fn scaled(n: i64) -> (f64, &'static str) {
|
||||||
let (base, units) = if si() { (1000.0, &SI_UNITS) } else { (1024.0, &IEC_UNITS) };
|
let (base, units) = if si() {
|
||||||
|
(1000.0, &SI_UNITS)
|
||||||
|
} else {
|
||||||
|
(1024.0, &IEC_UNITS)
|
||||||
|
};
|
||||||
let neg = n < 0;
|
let neg = n < 0;
|
||||||
let mut v = (n as f64).abs();
|
let mut v = (n as f64).abs();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -295,7 +307,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sizes() {
|
fn sizes() {
|
||||||
for (n, want) in [(0, "0 B"), (1023, "1023 B"), (1024, "1.00 KiB"), (1536, "1.50 KiB"), (181 << 30, "181 GiB"), (1 << 62, "4.00 EiB")] {
|
for (n, want) in [
|
||||||
|
(0, "0 B"),
|
||||||
|
(1023, "1023 B"),
|
||||||
|
(1024, "1.00 KiB"),
|
||||||
|
(1536, "1.50 KiB"),
|
||||||
|
(181 << 30, "181 GiB"),
|
||||||
|
(1 << 62, "4.00 EiB"),
|
||||||
|
] {
|
||||||
assert_eq!(size(n), want);
|
assert_eq!(size(n), want);
|
||||||
}
|
}
|
||||||
assert_eq!(count(2184392), "2,184,392");
|
assert_eq!(count(2184392), "2,184,392");
|
||||||
@@ -330,7 +349,12 @@ pub fn rfc3339_utc(unix_nanos: i64) -> String {
|
|||||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
let y = yoe + era * 400 + (m <= 2) as i64;
|
let y = yoe + era * 400 + (m <= 2) as i64;
|
||||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem / 60 % 60, rem % 60)
|
format!(
|
||||||
|
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
|
||||||
|
rem / 3600,
|
||||||
|
rem / 60 % 60,
|
||||||
|
rem % 60
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats a duration like Go's time.Duration.String after rounding to
|
/// Formats a duration like Go's time.Duration.String after rounding to
|
||||||
@@ -365,7 +389,10 @@ mod time_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn formats() {
|
fn formats() {
|
||||||
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
|
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
|
||||||
assert_eq!(rfc3339_utc(1_791_456_000 * 1_000_000_000), "2026-10-08T10:40:00Z");
|
assert_eq!(
|
||||||
|
rfc3339_utc(1_791_456_000 * 1_000_000_000),
|
||||||
|
"2026-10-08T10:40:00Z"
|
||||||
|
);
|
||||||
assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s");
|
assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s");
|
||||||
assert_eq!(go_duration(Duration::from_millis(250)), "250ms");
|
assert_eq!(go_duration(Duration::from_millis(250)), "250ms");
|
||||||
assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s");
|
assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s");
|
||||||
|
|||||||
+93
-15
@@ -33,7 +33,12 @@ impl Rect {
|
|||||||
|
|
||||||
/// Shrinks by n cells on each side.
|
/// Shrinks by n cells on each side.
|
||||||
pub fn inset(&self, n: i32) -> Rect {
|
pub fn inset(&self, n: i32) -> Rect {
|
||||||
Rect { x: self.x + n, y: self.y + n, w: (self.w - 2 * n).max(0), h: (self.h - 2 * n).max(0) }
|
Rect {
|
||||||
|
x: self.x + n,
|
||||||
|
y: self.y + n,
|
||||||
|
w: (self.w - 2 * n).max(0),
|
||||||
|
h: (self.h - 2 * n).max(0),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +66,11 @@ pub fn squarify(items: &[Item], bounds: Rect) -> Vec<Block> {
|
|||||||
if bounds.empty() {
|
if bounds.empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut its: Vec<Item> = items.iter().copied().filter(|it| it.size > 0.0 && it.size.is_finite()).collect();
|
let mut its: Vec<Item> = items
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|it| it.size > 0.0 && it.size.is_finite())
|
||||||
|
.collect();
|
||||||
let total: f64 = its.iter().map(|it| it.size).sum();
|
let total: f64 = its.iter().map(|it| it.size).sum();
|
||||||
if its.is_empty() || total <= 0.0 {
|
if its.is_empty() || total <= 0.0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -69,14 +78,28 @@ pub fn squarify(items: &[Item], bounds: Rect) -> Vec<Block> {
|
|||||||
its.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.id.cmp(&b.id)));
|
its.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.id.cmp(&b.id)));
|
||||||
let scale = bounds.w as f64 * bounds.h as f64 / total;
|
let scale = bounds.w as f64 * bounds.h as f64 / total;
|
||||||
let areas: Vec<f64> = its.iter().map(|it| it.size * scale).collect();
|
let areas: Vec<f64> = its.iter().map(|it| it.size * scale).collect();
|
||||||
let frs = layout(&areas, bounds.x as f64, bounds.y as f64, bounds.w as f64, bounds.h as f64);
|
let frs = layout(
|
||||||
|
&areas,
|
||||||
|
bounds.x as f64,
|
||||||
|
bounds.y as f64,
|
||||||
|
bounds.w as f64,
|
||||||
|
bounds.h as f64,
|
||||||
|
);
|
||||||
let mut out = Vec::with_capacity(its.len());
|
let mut out = Vec::with_capacity(its.len());
|
||||||
for (it, f) in its.iter().zip(frs) {
|
for (it, f) in its.iter().zip(frs) {
|
||||||
let (x0, y0) = (f[0].round() as i32, f[1].round() as i32);
|
let (x0, y0) = (f[0].round() as i32, f[1].round() as i32);
|
||||||
let x1 = ((f[0] + f[2]).round() as i32).min(bounds.x + bounds.w);
|
let x1 = ((f[0] + f[2]).round() as i32).min(bounds.x + bounds.w);
|
||||||
let y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h);
|
let y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h);
|
||||||
if x1 > x0 && y1 > y0 {
|
if x1 > x0 && y1 > y0 {
|
||||||
out.push(Block { id: it.id, rect: Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } });
|
out.push(Block {
|
||||||
|
id: it.id,
|
||||||
|
rect: Rect {
|
||||||
|
x: x0,
|
||||||
|
y: y0,
|
||||||
|
w: x1 - x0,
|
||||||
|
h: y1 - y0,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -117,7 +140,11 @@ fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec<
|
|||||||
}
|
}
|
||||||
let mut yy = y;
|
let mut yy = y;
|
||||||
for k in i..j {
|
for k in i..j {
|
||||||
let hh = if k == j - 1 { y + h - yy } else { areas[k] / sum * h };
|
let hh = if k == j - 1 {
|
||||||
|
y + h - yy
|
||||||
|
} else {
|
||||||
|
areas[k] / sum * h
|
||||||
|
};
|
||||||
out[k] = [x, yy, thick, hh];
|
out[k] = [x, yy, thick, hh];
|
||||||
yy += hh;
|
yy += hh;
|
||||||
}
|
}
|
||||||
@@ -130,7 +157,11 @@ fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec<
|
|||||||
}
|
}
|
||||||
let mut xx = x;
|
let mut xx = x;
|
||||||
for k in i..j {
|
for k in i..j {
|
||||||
let ww = if k == j - 1 { x + w - xx } else { areas[k] / sum * w };
|
let ww = if k == j - 1 {
|
||||||
|
x + w - xx
|
||||||
|
} else {
|
||||||
|
areas[k] / sum * w
|
||||||
|
};
|
||||||
out[k] = [xx, y, ww, thick];
|
out[k] = [xx, y, ww, thick];
|
||||||
xx += ww;
|
xx += ww;
|
||||||
}
|
}
|
||||||
@@ -203,7 +234,10 @@ pub(crate) mod tests {
|
|||||||
for bl in blocks {
|
for bl in blocks {
|
||||||
let br = bl.rect;
|
let br = bl.rect;
|
||||||
assert!(br.w > 0 && br.h > 0, "non-positive block {bl:?}");
|
assert!(br.w > 0 && br.h > 0, "non-positive block {bl:?}");
|
||||||
assert!(br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h, "block {bl:?} outside bounds {b:?}");
|
assert!(
|
||||||
|
br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h,
|
||||||
|
"block {bl:?} outside bounds {b:?}"
|
||||||
|
);
|
||||||
for y in br.y..br.y + br.h {
|
for y in br.y..br.y + br.h {
|
||||||
for x in br.x..br.x + br.w {
|
for x in br.x..br.x + br.w {
|
||||||
let i = ((y - b.y) * b.w + (x - b.x)) as usize;
|
let i = ((y - b.y) * b.w + (x - b.x)) as usize;
|
||||||
@@ -222,7 +256,15 @@ pub(crate) mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn squarify_basic() {
|
fn squarify_basic() {
|
||||||
let b = r(2, 3, 60, 20);
|
let b = r(2, 3, 60, 20);
|
||||||
let items = [it(1, 6.0), it(2, 6.0), it(3, 4.0), it(4, 3.0), it(5, 2.0), it(6, 2.0), it(7, 1.0)];
|
let items = [
|
||||||
|
it(1, 6.0),
|
||||||
|
it(2, 6.0),
|
||||||
|
it(3, 4.0),
|
||||||
|
it(4, 3.0),
|
||||||
|
it(5, 2.0),
|
||||||
|
it(6, 2.0),
|
||||||
|
it(7, 1.0),
|
||||||
|
];
|
||||||
let blocks = squarify(&items, b);
|
let blocks = squarify(&items, b);
|
||||||
assert_eq!(blocks.len(), items.len());
|
assert_eq!(blocks.len(), items.len());
|
||||||
check_partition(&blocks, b, true);
|
check_partition(&blocks, b, true);
|
||||||
@@ -230,7 +272,11 @@ pub(crate) mod tests {
|
|||||||
for bl in &blocks {
|
for bl in &blocks {
|
||||||
let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0;
|
let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0;
|
||||||
let got = bl.rect.area() as f64;
|
let got = bl.rect.area() as f64;
|
||||||
assert!(got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0, "block {} area {got} want ~{want}", bl.id);
|
assert!(
|
||||||
|
got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0,
|
||||||
|
"block {} area {got} want ~{want}",
|
||||||
|
bl.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Deterministic regardless of input order.
|
// Deterministic regardless of input order.
|
||||||
let mut rev = items.to_vec();
|
let mut rev = items.to_vec();
|
||||||
@@ -241,9 +287,18 @@ pub(crate) mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn squarify_edge_cases() {
|
fn squarify_edge_cases() {
|
||||||
assert!(squarify(&[], r(0, 0, 10, 10)).is_empty());
|
assert!(squarify(&[], r(0, 0, 10, 10)).is_empty());
|
||||||
assert!(squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(), "zero sizes should produce nothing");
|
assert!(
|
||||||
|
squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(),
|
||||||
|
"zero sizes should produce nothing"
|
||||||
|
);
|
||||||
assert!(squarify(&[it(1, f64::NAN), it(2, f64::INFINITY)], r(0, 0, 10, 10)).is_empty());
|
assert!(squarify(&[it(1, f64::NAN), it(2, f64::INFINITY)], r(0, 0, 10, 10)).is_empty());
|
||||||
for b in [r(0, 0, 0, 0), r(0, 0, 1, 1), r(0, 0, 1, 50), r(0, 0, 50, 1), r(0, 0, -3, 4)] {
|
for b in [
|
||||||
|
r(0, 0, 0, 0),
|
||||||
|
r(0, 0, 1, 1),
|
||||||
|
r(0, 0, 1, 50),
|
||||||
|
r(0, 0, 50, 1),
|
||||||
|
r(0, 0, -3, 4),
|
||||||
|
] {
|
||||||
let bl = squarify(&[it(1, 5.0), it(2, 3.0), it(3, 1.0)], b);
|
let bl = squarify(&[it(1, 5.0), it(2, 3.0), it(3, 1.0)], b);
|
||||||
if !b.empty() {
|
if !b.empty() {
|
||||||
check_partition(&bl, b, true);
|
check_partition(&bl, b, true);
|
||||||
@@ -258,7 +313,13 @@ pub(crate) mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn resize_recomputes() {
|
fn resize_recomputes() {
|
||||||
let items = [it(1, 50.0), it(2, 30.0), it(3, 20.0)];
|
let items = [it(1, 50.0), it(2, 30.0), it(3, 20.0)];
|
||||||
for b in [r(0, 0, 80, 24), r(0, 0, 120, 40), r(0, 0, 300, 80), r(0, 0, 70, 20), r(0, 0, 180, 50)] {
|
for b in [
|
||||||
|
r(0, 0, 80, 24),
|
||||||
|
r(0, 0, 120, 40),
|
||||||
|
r(0, 0, 300, 80),
|
||||||
|
r(0, 0, 70, 20),
|
||||||
|
r(0, 0, 180, 50),
|
||||||
|
] {
|
||||||
let bl = squarify(&items, b);
|
let bl = squarify(&items, b);
|
||||||
check_partition(&bl, b, true);
|
check_partition(&bl, b, true);
|
||||||
assert_eq!(bl.len(), 3, "{b:?}");
|
assert_eq!(bl.len(), 3, "{b:?}");
|
||||||
@@ -270,7 +331,11 @@ pub(crate) mod tests {
|
|||||||
fn squarify_random_partitions() {
|
fn squarify_random_partitions() {
|
||||||
let mut rng = Rng::new(1);
|
let mut rng = Rng::new(1);
|
||||||
for _ in 0..300 {
|
for _ in 0..300 {
|
||||||
let (w, h, n) = (rng.intn(400) as i32, rng.intn(150) as i32, rng.intn(3000) as usize);
|
let (w, h, n) = (
|
||||||
|
rng.intn(400) as i32,
|
||||||
|
rng.intn(150) as i32,
|
||||||
|
rng.intn(3000) as usize,
|
||||||
|
);
|
||||||
let items: Vec<Item> = (0..n)
|
let items: Vec<Item> = (0..n)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let bits = rng.intn(40) + 1;
|
let bits = rng.intn(40) + 1;
|
||||||
@@ -290,12 +355,25 @@ pub(crate) mod tests {
|
|||||||
let sizes = [100.0, 50.0, 10.0, 1.0, 1.0, 1.0];
|
let sizes = [100.0, 50.0, 10.0, 1.0, 1.0, 1.0];
|
||||||
assert_eq!(visible(&sizes, 163.0, 1630, 20, 100), 3);
|
assert_eq!(visible(&sizes, 163.0, 1630, 20, 100), 3);
|
||||||
assert_eq!(visible(&sizes, 163.0, 1630, 20, 2), 2, "max_items");
|
assert_eq!(visible(&sizes, 163.0, 1630, 20, 2), 2, "max_items");
|
||||||
assert_eq!(visible(&[10.0, 1.0], 11.0, 1100, 200, 10), 2, "single leftover");
|
assert_eq!(
|
||||||
|
visible(&[10.0, 1.0], 11.0, 1100, 200, 10),
|
||||||
|
2,
|
||||||
|
"single leftover"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_finds_block() {
|
fn at_finds_block() {
|
||||||
let bl = [Block { id: 1, rect: r(0, 0, 5, 5) }, Block { id: 2, rect: r(5, 0, 5, 5) }];
|
let bl = [
|
||||||
|
Block {
|
||||||
|
id: 1,
|
||||||
|
rect: r(0, 0, 5, 5),
|
||||||
|
},
|
||||||
|
Block {
|
||||||
|
id: 2,
|
||||||
|
rect: r(5, 0, 5, 5),
|
||||||
|
},
|
||||||
|
];
|
||||||
assert_eq!(at(&bl, 6, 2), Some(2));
|
assert_eq!(at(&bl, 6, 2), Some(2));
|
||||||
assert_eq!(at(&bl, 10, 2), None);
|
assert_eq!(at(&bl, 10, 2), None);
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-11
@@ -33,10 +33,38 @@ pub fn neighbour(blocks: &[Block], sel: i64, d: Direction) -> Option<i64> {
|
|||||||
}
|
}
|
||||||
let c = b.rect;
|
let c = b.rect;
|
||||||
let (ok, gap, lo, hi, clo, chi) = match d {
|
let (ok, gap, lo, hi, clo, chi) = match d {
|
||||||
Direction::Right => (c.x >= s.x + s.w, c.x - (s.x + s.w), s.y, s.y + s.h, c.y, c.y + c.h),
|
Direction::Right => (
|
||||||
Direction::Left => (c.x + c.w <= s.x, s.x - (c.x + c.w), s.y, s.y + s.h, c.y, c.y + c.h),
|
c.x >= s.x + s.w,
|
||||||
Direction::Down => (c.y >= s.y + s.h, c.y - (s.y + s.h), s.x, s.x + s.w, c.x, c.x + c.w),
|
c.x - (s.x + s.w),
|
||||||
Direction::Up => (c.y + c.h <= s.y, s.y - (c.y + c.h), s.x, s.x + s.w, c.x, c.x + c.w),
|
s.y,
|
||||||
|
s.y + s.h,
|
||||||
|
c.y,
|
||||||
|
c.y + c.h,
|
||||||
|
),
|
||||||
|
Direction::Left => (
|
||||||
|
c.x + c.w <= s.x,
|
||||||
|
s.x - (c.x + c.w),
|
||||||
|
s.y,
|
||||||
|
s.y + s.h,
|
||||||
|
c.y,
|
||||||
|
c.y + c.h,
|
||||||
|
),
|
||||||
|
Direction::Down => (
|
||||||
|
c.y >= s.y + s.h,
|
||||||
|
c.y - (s.y + s.h),
|
||||||
|
s.x,
|
||||||
|
s.x + s.w,
|
||||||
|
c.x,
|
||||||
|
c.x + c.w,
|
||||||
|
),
|
||||||
|
Direction::Up => (
|
||||||
|
c.y + c.h <= s.y,
|
||||||
|
s.y - (c.y + c.h),
|
||||||
|
s.x,
|
||||||
|
s.x + s.w,
|
||||||
|
c.x,
|
||||||
|
c.x + c.w,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
if !ok {
|
if !ok {
|
||||||
continue;
|
continue;
|
||||||
@@ -68,7 +96,10 @@ mod tests {
|
|||||||
use super::Direction::*;
|
use super::Direction::*;
|
||||||
|
|
||||||
fn blk(id: i64, x: i32, y: i32, w: i32, h: i32) -> Block {
|
fn blk(id: i64, x: i32, y: i32, w: i32, h: i32) -> Block {
|
||||||
Block { id, rect: r(x, y, w, h) }
|
Block {
|
||||||
|
id,
|
||||||
|
rect: r(x, y, w, h),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout:
|
// Layout:
|
||||||
@@ -86,9 +117,18 @@ mod tests {
|
|||||||
let d = blk(4, 4, 4, 12, 4);
|
let d = blk(4, 4, 4, 12, 4);
|
||||||
let bs = [d, c, b, a]; // order must not matter
|
let bs = [d, c, b, a]; // order must not matter
|
||||||
let cases = [
|
let cases = [
|
||||||
(1, Right, Some(2)), (2, Left, Some(1)), (1, Down, Some(3)), (2, Down, Some(4)),
|
(1, Right, Some(2)),
|
||||||
(3, Right, Some(4)), (4, Left, Some(3)), (3, Up, Some(1)), (4, Up, Some(2)),
|
(2, Left, Some(1)),
|
||||||
(1, Left, None), (1, Up, None), (4, Right, None), (4, Down, None),
|
(1, Down, Some(3)),
|
||||||
|
(2, Down, Some(4)),
|
||||||
|
(3, Right, Some(4)),
|
||||||
|
(4, Left, Some(3)),
|
||||||
|
(3, Up, Some(1)),
|
||||||
|
(4, Up, Some(2)),
|
||||||
|
(1, Left, None),
|
||||||
|
(1, Up, None),
|
||||||
|
(4, Right, None),
|
||||||
|
(4, Down, None),
|
||||||
];
|
];
|
||||||
for (from, dir, want) in cases {
|
for (from, dir, want) in cases {
|
||||||
assert_eq!(neighbour(&bs, from, dir), want, "from {from} dir {dir:?}");
|
assert_eq!(neighbour(&bs, from, dir), want, "from {from} dir {dir:?}");
|
||||||
@@ -109,7 +149,11 @@ mod tests {
|
|||||||
let r1 = blk(11, 10, 0, 10, 3);
|
let r1 = blk(11, 10, 0, 10, 3);
|
||||||
let r2 = blk(12, 10, 3, 10, 3);
|
let r2 = blk(12, 10, 3, 10, 3);
|
||||||
let r3 = blk(13, 10, 6, 10, 3);
|
let r3 = blk(13, 10, 6, 10, 3);
|
||||||
assert_eq!(neighbour(&[r3, r1, r2, t], 10, Right), Some(12), "centre preference");
|
assert_eq!(
|
||||||
|
neighbour(&[r3, r1, r2, t], 10, Right),
|
||||||
|
Some(12),
|
||||||
|
"centre preference"
|
||||||
|
);
|
||||||
assert_eq!(neighbour(&[r3, r1, r2, t], 13, Left), Some(10), "back left");
|
assert_eq!(neighbour(&[r3, r1, r2, t], 13, Left), Some(10), "back left");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +166,9 @@ mod tests {
|
|||||||
let bl = squarify(&items, r(0, 0, 120, 40));
|
let bl = squarify(&items, r(0, 0, 120, 40));
|
||||||
for s in &bl {
|
for s in &bl {
|
||||||
for d in [Left, Right, Up, Down] {
|
for d in [Left, Right, Up, Down] {
|
||||||
let Some(id) = neighbour(&bl, s.id, d) else { continue };
|
let Some(id) = neighbour(&bl, s.id, d) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let c = bl.iter().find(|b| b.id == id).unwrap().rect;
|
let c = bl.iter().find(|b| b.id == id).unwrap().rect;
|
||||||
let s = s.rect;
|
let s = s.rect;
|
||||||
// In a partition, if any block touches the edge in direction d
|
// In a partition, if any block touches the edge in direction d
|
||||||
@@ -133,7 +179,10 @@ mod tests {
|
|||||||
Down => c.y == s.y + s.h,
|
Down => c.y == s.y + s.h,
|
||||||
Up => c.y + c.h == s.y,
|
Up => c.y + c.h == s.y,
|
||||||
};
|
};
|
||||||
assert!(touch, "iter {iter}: from {s:?} dir {d:?} chose non-adjacent {c:?}");
|
assert!(
|
||||||
|
touch,
|
||||||
|
"iter {iter}: from {s:?} dir {d:?} chose non-adjacent {c:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+156
-31
@@ -36,11 +36,25 @@ impl Model {
|
|||||||
}
|
}
|
||||||
let n = tr.node(id);
|
let n = tr.node(id);
|
||||||
let path = tr.path(id);
|
let path = tr.path(id);
|
||||||
let want = Expect { uid: n.uid, mode: n.mode };
|
let want = Expect {
|
||||||
let what = if n.is_dir() { format!("directory with {} files", textutil::count(n.files as i64)) } else { "file".into() };
|
uid: n.uid,
|
||||||
let mut body = vec![san(&tr.path_bytes(id)), String::new(), format!("{what} · {}", textutil::size(n.total(self.size_mode))), String::new()];
|
mode: n.mode,
|
||||||
|
};
|
||||||
|
let what = if n.is_dir() {
|
||||||
|
format!("directory with {} files", textutil::count(n.files as i64))
|
||||||
|
} else {
|
||||||
|
"file".into()
|
||||||
|
};
|
||||||
|
let mut body = vec![
|
||||||
|
san(&tr.path_bytes(id)),
|
||||||
|
String::new(),
|
||||||
|
format!("{what} · {}", textutil::size(n.total(self.size_mode))),
|
||||||
|
String::new(),
|
||||||
|
];
|
||||||
if platform::headless() {
|
if platform::headless() {
|
||||||
body.push("This is a headless system (no X or Wayland display), so there is no trash:".into());
|
body.push(
|
||||||
|
"This is a headless system (no X or Wayland display), so there is no trash:".into(),
|
||||||
|
);
|
||||||
body.push("it will be permanently deleted and cannot be recovered.".into());
|
body.push("it will be permanently deleted and cannot be recovered.".into());
|
||||||
self.open_modal(Box::new(ConfirmModal {
|
self.open_modal(Box::new(ConfirmModal {
|
||||||
ttl: "Delete permanently? Are you sure?".into(),
|
ttl: "Delete permanently? Are you sure?".into(),
|
||||||
@@ -50,13 +64,21 @@ impl Model {
|
|||||||
let t = m.tree.clone();
|
let t = m.tree.clone();
|
||||||
Cmd::run(move || {
|
Cmd::run(move || {
|
||||||
let err = platform::delete(&path, want).err().map(|e| e.to_string());
|
let err = platform::delete(&path, want).err().map(|e| e.to_string());
|
||||||
Msg::TrashDone { tree: t, id, err, done: "Deleted".into() }
|
Msg::TrashDone {
|
||||||
|
tree: t,
|
||||||
|
id,
|
||||||
|
err,
|
||||||
|
done: "Deleted".into(),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
return Cmd::None;
|
return Cmd::None;
|
||||||
}
|
}
|
||||||
body.push(format!("It will be moved to the {}, not permanently deleted.", platform::TRASH_NAME));
|
body.push(format!(
|
||||||
|
"It will be moved to the {}, not permanently deleted.",
|
||||||
|
platform::TRASH_NAME
|
||||||
|
));
|
||||||
self.open_modal(Box::new(ConfirmModal {
|
self.open_modal(Box::new(ConfirmModal {
|
||||||
ttl: "Move to trash?".into(),
|
ttl: "Move to trash?".into(),
|
||||||
body,
|
body,
|
||||||
@@ -65,14 +87,25 @@ impl Model {
|
|||||||
let t = m.tree.clone();
|
let t = m.tree.clone();
|
||||||
Cmd::run(move || {
|
Cmd::run(move || {
|
||||||
let err = platform::trash(&path, want).err().map(|e| e.to_string());
|
let err = platform::trash(&path, want).err().map(|e| e.to_string());
|
||||||
Msg::TrashDone { tree: t, id, err, done: format!("Moved to {}", platform::TRASH_NAME) }
|
Msg::TrashDone {
|
||||||
|
tree: t,
|
||||||
|
id,
|
||||||
|
err,
|
||||||
|
done: format!("Moved to {}", platform::TRASH_NAME),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
Cmd::None
|
Cmd::None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn trash_done(&mut self, tree: Arc<RwLock<Tree>>, id: NodeId, err: Option<String>, done: String) -> Cmd {
|
pub fn trash_done(
|
||||||
|
&mut self,
|
||||||
|
tree: Arc<RwLock<Tree>>,
|
||||||
|
id: NodeId,
|
||||||
|
err: Option<String>,
|
||||||
|
done: String,
|
||||||
|
) -> Cmd {
|
||||||
if let Some(e) = err {
|
if let Some(e) = err {
|
||||||
return self.warn(format!("Delete failed: {}", textutil::sanitize(&e)));
|
return self.warn(format!("Delete failed: {}", textutil::sanitize(&e)));
|
||||||
}
|
}
|
||||||
@@ -84,7 +117,13 @@ impl Model {
|
|||||||
let name = {
|
let name = {
|
||||||
let mut t = self.tree.write().unwrap();
|
let mut t = self.tree.write().unwrap();
|
||||||
let n = t.node(id).clone();
|
let n = t.node(id).clone();
|
||||||
let mut d = Delta { size: -n.tot_size, alloc: -n.tot_alloc, files: -(n.files as i64), dirs: -(n.dirs as i64), ..Default::default() };
|
let mut d = Delta {
|
||||||
|
size: -n.tot_size,
|
||||||
|
alloc: -n.tot_alloc,
|
||||||
|
files: -(n.files as i64),
|
||||||
|
dirs: -(n.dirs as i64),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
if n.is_dir() {
|
if n.is_dir() {
|
||||||
d.dirs -= 1;
|
d.dirs -= 1;
|
||||||
if let Some(cs) = t.cat_sizes(id) {
|
if let Some(cs) = t.cat_sizes(id) {
|
||||||
@@ -125,21 +164,38 @@ impl Model {
|
|||||||
"Files are grouped by size, then sampled, then fully hashed with SHA-256.".into(),
|
"Files are grouped by size, then sampled, then fully hashed with SHA-256.".into(),
|
||||||
"Only identical hashes are reported as duplicates.".into(),
|
"Only identical hashes are reported as duplicates.".into(),
|
||||||
String::new(),
|
String::new(),
|
||||||
"This reads file contents (it may take a while and can update access times).".into(),
|
"This reads file contents (it may take a while and can update access times)."
|
||||||
|
.into(),
|
||||||
],
|
],
|
||||||
yes: "start".into(),
|
yes: "start".into(),
|
||||||
on_yes: Some(Box::new(|m: &mut Model| {
|
on_yes: Some(Box::new(|m: &mut Model| {
|
||||||
let cancel = Cancel::new();
|
let cancel = Cancel::new();
|
||||||
let f = Arc::new(Finder::default());
|
let f = Arc::new(Finder::default());
|
||||||
m.dups = Some(DupState { running: true, finder: f.clone(), groups: Vec::new(), err: None, cancel: cancel.clone() });
|
m.dups = Some(DupState {
|
||||||
|
running: true,
|
||||||
|
finder: f.clone(),
|
||||||
|
groups: Vec::new(),
|
||||||
|
err: None,
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
});
|
||||||
m.ensure_dup_view();
|
m.ensure_dup_view();
|
||||||
let t = m.tree.clone();
|
let t = m.tree.clone();
|
||||||
let tick = m.start_ticking();
|
let tick = m.start_ticking();
|
||||||
Cmd::batch(vec![
|
Cmd::batch(vec![
|
||||||
tick,
|
tick,
|
||||||
Cmd::run(move || {
|
Cmd::run(move || {
|
||||||
let g = f.find(&cancel, &t, duplicate::Options { min_size: 1, workers: 4 });
|
let g = f.find(
|
||||||
Msg::DupDone { finder: f, groups: g }
|
&cancel,
|
||||||
|
&t,
|
||||||
|
duplicate::Options {
|
||||||
|
min_size: 1,
|
||||||
|
workers: 4,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Msg::DupDone {
|
||||||
|
finder: f,
|
||||||
|
groups: g,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
})),
|
})),
|
||||||
@@ -148,22 +204,39 @@ impl Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_dup_view(&mut self) {
|
pub fn ensure_dup_view(&mut self) {
|
||||||
if let Some(i) = self.views.iter().position(|v| matches!(v, super::views::View::Dups(_))) {
|
if let Some(i) = self
|
||||||
|
.views
|
||||||
|
.iter()
|
||||||
|
.position(|v| matches!(v, super::views::View::Dups(_)))
|
||||||
|
{
|
||||||
self.set_view(i);
|
self.set_view(i);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.views.push(super::views::View::Dups(DupView::default()));
|
self.views
|
||||||
|
.push(super::views::View::Dups(DupView::default()));
|
||||||
self.set_view(self.views.len() - 1);
|
self.set_view(self.views.len() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dup_done(&mut self, finder: Arc<Finder>, groups: Option<Vec<duplicate::Group>>) -> Cmd {
|
pub fn dup_done(&mut self, finder: Arc<Finder>, groups: Option<Vec<duplicate::Group>>) -> Cmd {
|
||||||
let Some(d) = self.dups.as_mut().filter(|d| Arc::ptr_eq(&d.finder, &finder)) else { return Cmd::None };
|
let Some(d) = self
|
||||||
|
.dups
|
||||||
|
.as_mut()
|
||||||
|
.filter(|d| Arc::ptr_eq(&d.finder, &finder))
|
||||||
|
else {
|
||||||
|
return Cmd::None;
|
||||||
|
};
|
||||||
d.running = false;
|
d.running = false;
|
||||||
d.err = groups.is_none().then(|| "cancelled".to_string());
|
d.err = groups.is_none().then(|| "cancelled".to_string());
|
||||||
d.groups = groups.unwrap_or_default();
|
d.groups = groups.unwrap_or_default();
|
||||||
let (n, wasted) = (d.groups.len(), d.groups.iter().map(|g| g.wasted()).sum::<i64>());
|
let (n, wasted) = (
|
||||||
|
d.groups.len(),
|
||||||
|
d.groups.iter().map(|g| g.wasted()).sum::<i64>(),
|
||||||
|
);
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
self.info(format!("Duplicates: {n} verified groups, {} reclaimable", textutil::size(wasted)))
|
self.info(format!(
|
||||||
|
"Duplicates: {n} verified groups, {} reclaimable",
|
||||||
|
textutil::size(wasted)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,23 +258,53 @@ impl DupView {
|
|||||||
pub fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
|
pub fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
|
||||||
let t = m.theme.clone();
|
let t = m.theme.clone();
|
||||||
let Some(d) = &m.dups else {
|
let Some(d) = &m.dups else {
|
||||||
cv.text_centered(r.x, r.y + r.h / 2, r.w, "Press D to search for duplicates", t.muted());
|
cv.text_centered(
|
||||||
|
r.x,
|
||||||
|
r.y + r.h / 2,
|
||||||
|
r.w,
|
||||||
|
"Press D to search for duplicates",
|
||||||
|
t.muted(),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if d.running {
|
if d.running {
|
||||||
let p = d.finder.progress();
|
let p = d.finder.progress();
|
||||||
cv.fill(r, " ", t.base());
|
cv.fill(r, " ", t.base());
|
||||||
let y = r.y + r.h / 2 - 2;
|
let y = r.y + r.h / 2 - 2;
|
||||||
cv.text_centered(r.x, y, r.w, &format!("Finding duplicates — {}…", p.stage), Style::new(t.accent, t.bg).with(BOLD));
|
cv.text_centered(
|
||||||
cv.text_centered(r.x, y + 2, r.w,
|
r.x,
|
||||||
&format!("{} candidate files in {} size groups", textutil::count(p.candidates), textutil::count(p.candidate_groups)), t.base());
|
y,
|
||||||
|
r.w,
|
||||||
|
&format!("Finding duplicates — {}…", p.stage),
|
||||||
|
Style::new(t.accent, t.bg).with(BOLD),
|
||||||
|
);
|
||||||
|
cv.text_centered(
|
||||||
|
r.x,
|
||||||
|
y + 2,
|
||||||
|
r.w,
|
||||||
|
&format!(
|
||||||
|
"{} candidate files in {} size groups",
|
||||||
|
textutil::count(p.candidates),
|
||||||
|
textutil::count(p.candidate_groups)
|
||||||
|
),
|
||||||
|
t.base(),
|
||||||
|
);
|
||||||
if p.bytes_to_hash > 0 {
|
if p.bytes_to_hash > 0 {
|
||||||
let frac = p.bytes_hashed as f64 / p.bytes_to_hash as f64;
|
let frac = p.bytes_hashed as f64 / p.bytes_to_hash as f64;
|
||||||
let bw = 60.min(r.w - 10);
|
let bw = 60.min(r.w - 10);
|
||||||
m.paint_bar(cv, r.x + (r.w - bw) / 2, y + 4, bw, frac, t.accent, false);
|
m.paint_bar(cv, r.x + (r.w - bw) / 2, y + 4, bw, frac, t.accent, false);
|
||||||
cv.text_centered(r.x, y + 5, r.w,
|
cv.text_centered(
|
||||||
&format!("{} of {} hashed · {} skipped", textutil::size(p.bytes_hashed), textutil::size(p.bytes_to_hash), textutil::count(p.skipped)),
|
r.x,
|
||||||
t.muted());
|
y + 5,
|
||||||
|
r.w,
|
||||||
|
&format!(
|
||||||
|
"{} of {} hashed · {} skipped",
|
||||||
|
textutil::size(p.bytes_hashed),
|
||||||
|
textutil::size(p.bytes_to_hash),
|
||||||
|
textutil::count(p.skipped)
|
||||||
|
),
|
||||||
|
t.muted(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -211,22 +314,44 @@ impl DupView {
|
|||||||
self.rows.push((gi, NO_NODE));
|
self.rows.push((gi, NO_NODE));
|
||||||
let hash: String = g.hash[..8].iter().map(|b| format!("{b:02x}")).collect();
|
let hash: String = g.hash[..8].iter().map(|b| format!("{b:02x}")).collect();
|
||||||
rows.push(TableRow {
|
rows.push(TableRow {
|
||||||
cells: vec![textutil::size(g.wasted()), format!("{} × {}", g.files.len(), textutil::size(g.size)),
|
cells: vec![
|
||||||
format!("verified identical · sha256 {hash}…")],
|
textutil::size(g.wasted()),
|
||||||
styles: vec![Some(Style::new(t.warn, t.bg).with(BOLD)), Some(Style::new(t.fg, t.bg).with(BOLD)), Some(Style::new(t.ok, t.bg))],
|
format!("{} × {}", g.files.len(), textutil::size(g.size)),
|
||||||
|
format!("verified identical · sha256 {hash}…"),
|
||||||
|
],
|
||||||
|
styles: vec![
|
||||||
|
Some(Style::new(t.warn, t.bg).with(BOLD)),
|
||||||
|
Some(Style::new(t.fg, t.bg).with(BOLD)),
|
||||||
|
Some(Style::new(t.ok, t.bg)),
|
||||||
|
],
|
||||||
bar: -1.0,
|
bar: -1.0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
for &id in &g.files {
|
for &id in &g.files {
|
||||||
self.rows.push((gi, id));
|
self.rows.push((gi, id));
|
||||||
rows.push(TableRow { cells: vec![String::new(), String::new(), format!(" {}", san(&tr.path_bytes(id)))], bar: -1.0, ..Default::default() });
|
rows.push(TableRow {
|
||||||
|
cells: vec![
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
format!(" {}", san(&tr.path_bytes(id))),
|
||||||
|
],
|
||||||
|
bar: -1.0,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut title = format!("Duplicates — {} groups (Enter details · Space go to)", d.groups.len());
|
let mut title = format!(
|
||||||
|
"Duplicates — {} groups (Enter details · Space go to)",
|
||||||
|
d.groups.len()
|
||||||
|
);
|
||||||
if let Some(e) = &d.err {
|
if let Some(e) = &d.err {
|
||||||
title += &format!(" — stopped: {}", textutil::sanitize(e));
|
title += &format!(" — stopped: {}", textutil::sanitize(e));
|
||||||
}
|
}
|
||||||
let cols = [col("WASTED", 10, true, false), col("COPIES", 16, false, false), col("", 0, false, false)];
|
let cols = [
|
||||||
|
col("WASTED", 10, true, false),
|
||||||
|
col("COPIES", 16, false, false),
|
||||||
|
col("", 0, false, false),
|
||||||
|
];
|
||||||
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+82
-12
@@ -40,7 +40,11 @@ impl Color {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn comps(self) -> (f64, f64, f64) {
|
pub fn comps(self) -> (f64, f64, f64) {
|
||||||
(self.r() as f64 / 255.0, self.g() as f64 / 255.0, self.b() as f64 / 255.0)
|
(
|
||||||
|
self.r() as f64 / 255.0,
|
||||||
|
self.g() as f64 / 255.0,
|
||||||
|
self.b() as f64 / 255.0,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blends c towards o by t (0..1). Default colours are returned as-is.
|
/// Blends c towards o by t (0..1). Default colours are returned as-is.
|
||||||
@@ -82,7 +86,10 @@ impl Style {
|
|||||||
Style { fg, bg, attr: 0 }
|
Style { fg, bg, attr: 0 }
|
||||||
}
|
}
|
||||||
pub const fn with(self, a: Attr) -> Style {
|
pub const fn with(self, a: Attr) -> Style {
|
||||||
Style { attr: self.attr | a, ..self }
|
Style {
|
||||||
|
attr: self.attr | a,
|
||||||
|
..self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +134,46 @@ pub(crate) struct BoxChars {
|
|||||||
pub br: &'static str,
|
pub br: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) const BOX_LIGHT: BoxChars = BoxChars { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" };
|
pub(crate) const BOX_LIGHT: BoxChars = BoxChars {
|
||||||
pub(crate) const BOX_ROUND: BoxChars = BoxChars { h: "─", v: "│", tl: "╭", tr: "╮", bl: "╰", br: "╯" };
|
h: "─",
|
||||||
pub(crate) const BOX_DOUBLE: BoxChars = BoxChars { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" };
|
v: "│",
|
||||||
pub(crate) const BOX_ASCII: BoxChars = BoxChars { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+" };
|
tl: "┌",
|
||||||
pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars { h: "=", v: "#", tl: "#", tr: "#", bl: "#", br: "#" };
|
tr: "┐",
|
||||||
|
bl: "└",
|
||||||
|
br: "┘",
|
||||||
|
};
|
||||||
|
pub(crate) const BOX_ROUND: BoxChars = BoxChars {
|
||||||
|
h: "─",
|
||||||
|
v: "│",
|
||||||
|
tl: "╭",
|
||||||
|
tr: "╮",
|
||||||
|
bl: "╰",
|
||||||
|
br: "╯",
|
||||||
|
};
|
||||||
|
pub(crate) const BOX_DOUBLE: BoxChars = BoxChars {
|
||||||
|
h: "═",
|
||||||
|
v: "║",
|
||||||
|
tl: "╔",
|
||||||
|
tr: "╗",
|
||||||
|
bl: "╚",
|
||||||
|
br: "╝",
|
||||||
|
};
|
||||||
|
pub(crate) const BOX_ASCII: BoxChars = BoxChars {
|
||||||
|
h: "-",
|
||||||
|
v: "|",
|
||||||
|
tl: "+",
|
||||||
|
tr: "+",
|
||||||
|
bl: "+",
|
||||||
|
br: "+",
|
||||||
|
};
|
||||||
|
pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars {
|
||||||
|
h: "=",
|
||||||
|
v: "#",
|
||||||
|
tl: "#",
|
||||||
|
tr: "#",
|
||||||
|
bl: "#",
|
||||||
|
br: "#",
|
||||||
|
};
|
||||||
|
|
||||||
/// Selects how colours are serialised.
|
/// Selects how colours are serialised.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
@@ -154,7 +196,12 @@ impl Canvas {
|
|||||||
/// Creates a canvas filled with spaces in style st.
|
/// Creates a canvas filled with spaces in style st.
|
||||||
pub fn new(w: i32, h: i32, st: Style) -> Canvas {
|
pub fn new(w: i32, h: i32, st: Style) -> Canvas {
|
||||||
let (w, h) = (w.max(0), h.max(0));
|
let (w, h) = (w.max(0), h.max(0));
|
||||||
Canvas { w, h, c: vec![Cell::space(st); (w * h) as usize], big: Vec::new() }
|
Canvas {
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
c: vec![Cell::space(st); (w * h) as usize],
|
||||||
|
big: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inside(&self, x: i32, y: i32) -> bool {
|
fn inside(&self, x: i32, y: i32) -> bool {
|
||||||
@@ -180,7 +227,12 @@ impl Canvas {
|
|||||||
self.big.push(s.to_string());
|
self.big.push(s.to_string());
|
||||||
HEAP
|
HEAP
|
||||||
};
|
};
|
||||||
Cell { g, n, w: w as u8, st }
|
Cell {
|
||||||
|
g,
|
||||||
|
n,
|
||||||
|
w: w as u8,
|
||||||
|
st,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes one cluster of width w at (x, y), repairing any wide cluster
|
/// Writes one cluster of width w at (x, y), repairing any wide cluster
|
||||||
@@ -204,7 +256,12 @@ impl Canvas {
|
|||||||
}
|
}
|
||||||
self.c[row + xu] = self.make(s, w, st);
|
self.c[row + xu] = self.make(s, w, st);
|
||||||
for i in 1..wu {
|
for i in 1..wu {
|
||||||
self.c[row + xu + i] = Cell { g: [0; INLINE], n: 0, w: 0, st };
|
self.c[row + xu + i] = Cell {
|
||||||
|
g: [0; INLINE],
|
||||||
|
n: 0,
|
||||||
|
w: 0,
|
||||||
|
st,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +388,13 @@ impl Canvas {
|
|||||||
|
|
||||||
fn sgr(b: &mut String, st: Style, depth: Depth) {
|
fn sgr(b: &mut String, st: Style, depth: Depth) {
|
||||||
b.push_str("\x1b[0");
|
b.push_str("\x1b[0");
|
||||||
for (bit, code) in [(BOLD, ";1"), (FAINT, ";2"), (ITALIC, ";3"), (UNDERLINE, ";4"), (REVERSE, ";7")] {
|
for (bit, code) in [
|
||||||
|
(BOLD, ";1"),
|
||||||
|
(FAINT, ";2"),
|
||||||
|
(ITALIC, ";3"),
|
||||||
|
(UNDERLINE, ";4"),
|
||||||
|
(REVERSE, ";7"),
|
||||||
|
] {
|
||||||
if st.attr & bit != 0 {
|
if st.attr & bit != 0 {
|
||||||
b.push_str(code);
|
b.push_str(code);
|
||||||
}
|
}
|
||||||
@@ -351,7 +414,14 @@ fn sgr(b: &mut String, st: Style, depth: Depth) {
|
|||||||
}
|
}
|
||||||
write!(b, ";{code}")
|
write!(b, ";{code}")
|
||||||
}
|
}
|
||||||
Depth::True => write!(b, ";{};2;{};{};{}", if bg { 48 } else { 38 }, c.r(), c.g(), c.b()),
|
Depth::True => write!(
|
||||||
|
b,
|
||||||
|
";{};2;{};{};{}",
|
||||||
|
if bg { 48 } else { 38 },
|
||||||
|
c.r(),
|
||||||
|
c.g(),
|
||||||
|
c.b()
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
col(b, st.fg, false);
|
col(b, st.fg, false);
|
||||||
|
|||||||
+5
-1
@@ -28,7 +28,11 @@ pub(crate) fn quant256(c: Color) -> u8 {
|
|||||||
let (r, g, b) = (c.r() as i32, c.g() as i32, c.b() as i32);
|
let (r, g, b) = (c.r() as i32, c.g() as i32, c.b() as i32);
|
||||||
let (mut best, mut best_d) = (16, i32::MAX);
|
let (mut best, mut best_d) = (16, i32::MAX);
|
||||||
for i in 0..216 {
|
for i in 0..216 {
|
||||||
let (cr, cg, cb) = (CUBE_LEVELS[i / 36], CUBE_LEVELS[i / 6 % 6], CUBE_LEVELS[i % 6]);
|
let (cr, cg, cb) = (
|
||||||
|
CUBE_LEVELS[i / 36],
|
||||||
|
CUBE_LEVELS[i / 6 % 6],
|
||||||
|
CUBE_LEVELS[i % 6],
|
||||||
|
);
|
||||||
let d = redmean(r, g, b, cr, cg, cb);
|
let d = redmean(r, g, b, cr, cg, cb);
|
||||||
if d < best_d {
|
if d < best_d {
|
||||||
(best, best_d) = (16 + i, d);
|
(best, best_d) = (16 + i, d);
|
||||||
|
|||||||
+10
-3
@@ -39,8 +39,11 @@ impl Model {
|
|||||||
if let Some(k) = self.kids_cache.get(&id) {
|
if let Some(k) = self.kids_cache.get(&id) {
|
||||||
return k.clone();
|
return k.clone();
|
||||||
}
|
}
|
||||||
let mut v: Vec<(i64, NodeId)> =
|
let mut v: Vec<(i64, NodeId)> = tr
|
||||||
tr.children(id).map(|(c, _)| (self.size_of(tr, c), c)).filter(|&(s, _)| s > 0).collect();
|
.children(id)
|
||||||
|
.map(|(c, _)| (self.size_of(tr, c), c))
|
||||||
|
.filter(|&(s, _)| s > 0)
|
||||||
|
.collect();
|
||||||
v.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
v.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||||
let k = Rc::new(Kids {
|
let k = Rc::new(Kids {
|
||||||
total: v.iter().map(|e| e.0).sum(),
|
total: v.iter().map(|e| e.0).sum(),
|
||||||
@@ -119,7 +122,11 @@ impl Model {
|
|||||||
let prev = self.zoom;
|
let prev = self.zoom;
|
||||||
self.zoom = dir;
|
self.zoom = dir;
|
||||||
// Coming back out, keep the directory we left selected.
|
// Coming back out, keep the directory we left selected.
|
||||||
self.sel = if tr.is_ancestor(dir, prev) { prev as i64 } else { NO_SEL };
|
self.sel = if tr.is_ancestor(dir, prev) {
|
||||||
|
prev as i64
|
||||||
|
} else {
|
||||||
|
NO_SEL
|
||||||
|
};
|
||||||
self.user_sel = self.sel != NO_SEL;
|
self.user_sel = self.sel != NO_SEL;
|
||||||
self.zoomed = true;
|
self.zoomed = true;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
|
|||||||
+161
-26
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use super::canvas::{Canvas, Style, BOLD};
|
use super::canvas::{Canvas, Style, BOLD};
|
||||||
use super::model::{Cmd, Model};
|
use super::model::{Cmd, Model};
|
||||||
use super::table::{col, Table, TableRow};
|
|
||||||
use super::san;
|
use super::san;
|
||||||
|
use super::table::{col, Table, TableRow};
|
||||||
use crate::inventory::{ErrKind, Tree, FLAG_ALLOC_UNKNOWN};
|
use crate::inventory::{ErrKind, Tree, FLAG_ALLOC_UNKNOWN};
|
||||||
use crate::textutil;
|
use crate::textutil;
|
||||||
use crate::treemap::Rect;
|
use crate::treemap::Rect;
|
||||||
@@ -57,66 +57,193 @@ impl InfoView {
|
|||||||
} else {
|
} else {
|
||||||
state
|
state
|
||||||
};
|
};
|
||||||
cv.text(x, y, "SCAN INFORMATION", lw, Style::new(t.accent, t.bg).with(BOLD));
|
cv.text(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
"SCAN INFORMATION",
|
||||||
|
lw,
|
||||||
|
Style::new(t.accent, t.bg).with(BOLD),
|
||||||
|
);
|
||||||
y += 1;
|
y += 1;
|
||||||
line(cv, &mut y, "Root", &san(&st.root), value);
|
line(cv, &mut y, "Root", &san(&st.root), value);
|
||||||
line(cv, &mut y, "State", state, state_st);
|
line(cv, &mut y, "State", state, state_st);
|
||||||
if !st.from_snapshot.is_empty() {
|
if !st.from_snapshot.is_empty() {
|
||||||
line(cv, &mut y, "Snapshot", &textutil::sanitize(&st.from_snapshot), value);
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Snapshot",
|
||||||
|
&textutil::sanitize(&st.from_snapshot),
|
||||||
|
value,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let files = st.files + st.symlinks + st.others;
|
let files = st.files + st.symlinks + st.others;
|
||||||
line(cv, &mut y, "Items scanned", &textutil::count(files + st.dirs), value);
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Items scanned",
|
||||||
|
&textutil::count(files + st.dirs),
|
||||||
|
value,
|
||||||
|
);
|
||||||
line(cv, &mut y, " Files", &textutil::count(st.files), value);
|
line(cv, &mut y, " Files", &textutil::count(st.files), value);
|
||||||
line(cv, &mut y, " Directories", &textutil::count(st.dirs), value);
|
line(
|
||||||
line(cv, &mut y, " Symlinks", &textutil::count(st.symlinks), value);
|
cv,
|
||||||
|
&mut y,
|
||||||
|
" Directories",
|
||||||
|
&textutil::count(st.dirs),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
" Symlinks",
|
||||||
|
&textutil::count(st.symlinks),
|
||||||
|
value,
|
||||||
|
);
|
||||||
line(cv, &mut y, " Special", &textutil::count(st.others), value);
|
line(cv, &mut y, " Special", &textutil::count(st.others), value);
|
||||||
line(cv, &mut y, "Logical size", &format!("{} ({} bytes)", textutil::size(root.tot_size), textutil::count(root.tot_size)), value);
|
line(
|
||||||
line(cv, &mut y, "Allocated size", &format!("{} ({} bytes)", textutil::size(root.tot_alloc), textutil::count(root.tot_alloc)), value);
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Logical size",
|
||||||
|
&format!(
|
||||||
|
"{} ({} bytes)",
|
||||||
|
textutil::size(root.tot_size),
|
||||||
|
textutil::count(root.tot_size)
|
||||||
|
),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Allocated size",
|
||||||
|
&format!(
|
||||||
|
"{} ({} bytes)",
|
||||||
|
textutil::size(root.tot_alloc),
|
||||||
|
textutil::count(root.tot_alloc)
|
||||||
|
),
|
||||||
|
value,
|
||||||
|
);
|
||||||
let el = st.elapsed();
|
let el = st.elapsed();
|
||||||
if st.from_snapshot.is_empty() {
|
if st.from_snapshot.is_empty() {
|
||||||
line(cv, &mut y, "Elapsed", &textutil::duration(el), value);
|
line(cv, &mut y, "Elapsed", &textutil::duration(el), value);
|
||||||
let rate = (files + st.dirs) as f64 / el.as_secs_f64().max(0.001);
|
let rate = (files + st.dirs) as f64 / el.as_secs_f64().max(0.001);
|
||||||
line(cv, &mut y, "Rate", &(textutil::count(rate as i64) + " items/s"), value);
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Rate",
|
||||||
|
&(textutil::count(rate as i64) + " items/s"),
|
||||||
|
value,
|
||||||
|
);
|
||||||
let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
|
let cpus = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||||
line(cv, &mut y, "Workers", &format!("{} ({} CPUs)", st.workers, cpus), value);
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Workers",
|
||||||
|
&format!("{} ({} CPUs)", st.workers, cpus),
|
||||||
|
value,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
head(cv, &mut y, "ERRORS AND OMISSIONS");
|
head(cv, &mut y, "ERRORS AND OMISSIONS");
|
||||||
let mut any_issue = false;
|
let mut any_issue = false;
|
||||||
for (k, &c) in st.err_counts.iter().enumerate() {
|
for (k, &c) in st.err_counts.iter().enumerate() {
|
||||||
if c > 0 {
|
if c > 0 {
|
||||||
line(cv, &mut y, ErrKind::from_u8(k as u8).name(), &textutil::count(c), Style::new(t.err, t.bg).with(BOLD));
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
ErrKind::from_u8(k as u8).name(),
|
||||||
|
&textutil::count(c),
|
||||||
|
Style::new(t.err, t.bg).with(BOLD),
|
||||||
|
);
|
||||||
any_issue = true;
|
any_issue = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (k, v) in [("Unscanned dirs", st.unscanned), ("Excluded", st.excluded), ("Other FS skipped", st.skipped_mounts),
|
for (k, v) in [
|
||||||
("Virtual FS skipped", st.virtual_skipped), ("Loops skipped", st.loops_skipped), ("Broken symlinks", st.broken_links),
|
("Unscanned dirs", st.unscanned),
|
||||||
("Hard-link dups", st.hardlink_dups)]
|
("Excluded", st.excluded),
|
||||||
{
|
("Other FS skipped", st.skipped_mounts),
|
||||||
|
("Virtual FS skipped", st.virtual_skipped),
|
||||||
|
("Loops skipped", st.loops_skipped),
|
||||||
|
("Broken symlinks", st.broken_links),
|
||||||
|
("Hard-link dups", st.hardlink_dups),
|
||||||
|
] {
|
||||||
if v > 0 {
|
if v > 0 {
|
||||||
line(cv, &mut y, k, &textutil::count(v), Style::new(t.warn, t.bg).with(BOLD));
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
k,
|
||||||
|
&textutil::count(v),
|
||||||
|
Style::new(t.warn, t.bg).with(BOLD),
|
||||||
|
);
|
||||||
any_issue = true;
|
any_issue = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !any_issue {
|
if !any_issue {
|
||||||
line(cv, &mut y, "None", "every entry was read", Style::new(t.ok, t.bg));
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"None",
|
||||||
|
"every entry was read",
|
||||||
|
Style::new(t.ok, t.bg),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
head(cv, &mut y, "OPTIONS");
|
head(cv, &mut y, "OPTIONS");
|
||||||
let excl = if st.excludes.is_empty() { "none".to_string() } else { textutil::sanitize(&st.excludes.join(", ")) };
|
let excl = if st.excludes.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
textutil::sanitize(&st.excludes.join(", "))
|
||||||
|
};
|
||||||
line(cv, &mut y, "Exclusions", &excl, value);
|
line(cv, &mut y, "Exclusions", &excl, value);
|
||||||
line(cv, &mut y, "One filesystem", &st.one_file_system.to_string(), value);
|
line(
|
||||||
line(cv, &mut y, "Follow symlinks", &textutil::sanitize(&st.follow), value);
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"One filesystem",
|
||||||
|
&st.one_file_system.to_string(),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Follow symlinks",
|
||||||
|
&textutil::sanitize(&st.follow),
|
||||||
|
value,
|
||||||
|
);
|
||||||
line(cv, &mut y, "Size mode", m.size_mode.name(), value);
|
line(cv, &mut y, "Size mode", m.size_mode.name(), value);
|
||||||
line(cv, &mut y, "Units", if crate::textutil::si() { "SI (1000)" } else { "IEC (1024)" }, value);
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Units",
|
||||||
|
if crate::textutil::si() {
|
||||||
|
"SI (1000)"
|
||||||
|
} else {
|
||||||
|
"IEC (1024)"
|
||||||
|
},
|
||||||
|
value,
|
||||||
|
);
|
||||||
if root.has(FLAG_ALLOC_UNKNOWN) {
|
if root.has(FLAG_ALLOC_UNKNOWN) {
|
||||||
line(cv, &mut y, "Note", "allocated size unavailable on this platform", Style::new(t.warn, t.bg));
|
line(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Note",
|
||||||
|
"allocated size unavailable on this platform",
|
||||||
|
Style::new(t.warn, t.bg),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error list on the right (or below in narrow terminals).
|
// Error list on the right (or below in narrow terminals).
|
||||||
let er = if r.w < 100 {
|
let er = if r.w < 100 {
|
||||||
Rect { x: r.x, y: y + 1, w: r.w, h: r.y + r.h - y - 1 }
|
Rect {
|
||||||
|
x: r.x,
|
||||||
|
y: y + 1,
|
||||||
|
w: r.w,
|
||||||
|
h: r.y + r.h - y - 1,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Rect { x: r.x + lw + 4, y: r.y, w: r.w - lw - 4, h: r.h }
|
Rect {
|
||||||
|
x: r.x + lw + 4,
|
||||||
|
y: r.y,
|
||||||
|
w: r.w - lw - 4,
|
||||||
|
h: r.h,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if er.w < 30 || er.h < 4 {
|
if er.w < 30 || er.h < 4 {
|
||||||
return;
|
return;
|
||||||
@@ -132,7 +259,11 @@ impl InfoView {
|
|||||||
}
|
}
|
||||||
TableRow {
|
TableRow {
|
||||||
cells: vec![e.kind.name().into(), san(&p), textutil::sanitize(&e.msg)],
|
cells: vec![e.kind.name().into(), san(&p), textutil::sanitize(&e.msg)],
|
||||||
styles: vec![Some(Style::new(t.err, t.bg)), None, Some(Style::new(t.muted, t.bg))],
|
styles: vec![
|
||||||
|
Some(Style::new(t.err, t.bg)),
|
||||||
|
None,
|
||||||
|
Some(Style::new(t.muted, t.bg)),
|
||||||
|
],
|
||||||
bar: -1.0,
|
bar: -1.0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -142,7 +273,11 @@ impl InfoView {
|
|||||||
if (st.errors.len() as i64) < st.total_errors() {
|
if (st.errors.len() as i64) < st.total_errors() {
|
||||||
title += &format!(" — first {} shown", textutil::count(st.errors.len() as i64));
|
title += &format!(" — first {} shown", textutil::count(st.errors.len() as i64));
|
||||||
}
|
}
|
||||||
let cols = [col("KIND", 18, false, false), col("PATH", 0, false, true), col("DETAIL", 22, false, false)];
|
let cols = [
|
||||||
|
col("KIND", 18, false, false),
|
||||||
|
col("PATH", 0, false, true),
|
||||||
|
col("DETAIL", 22, false, false),
|
||||||
|
];
|
||||||
self.tb.paint(m, cv, er, &cols, &rows, &title);
|
self.tb.paint(m, cv, er, &cols, &rows, &title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-7
@@ -1,7 +1,7 @@
|
|||||||
//! Key dispatch and the actions keys trigger.
|
//! Key dispatch and the actions keys trigger.
|
||||||
|
|
||||||
use super::model::{is_group, Cmd, Key, Model, Msg};
|
|
||||||
use super::modals::{new_group_modal, new_help_modal, new_info_modal, new_save_modal};
|
use super::modals::{new_group_modal, new_help_modal, new_info_modal, new_save_modal};
|
||||||
|
use super::model::{is_group, Cmd, Key, Model, Msg};
|
||||||
use super::theme::{load_theme, theme_names};
|
use super::theme::{load_theme, theme_names};
|
||||||
use super::views::View;
|
use super::views::View;
|
||||||
use super::{fmt_stamp_now, san};
|
use super::{fmt_stamp_now, san};
|
||||||
@@ -53,13 +53,19 @@ impl Model {
|
|||||||
}
|
}
|
||||||
"tab" => self.set_view((self.view + 1) % self.views.len()),
|
"tab" => self.set_view((self.view + 1) % self.views.len()),
|
||||||
"shift+tab" => self.set_view((self.view + self.views.len() - 1) % self.views.len()),
|
"shift+tab" => self.set_view((self.view + self.views.len() - 1) % self.views.len()),
|
||||||
"1" | "2" | "3" | "4" | "5" | "6" | "7" => self.set_view((k.as_bytes()[0] - b'1') as usize),
|
"1" | "2" | "3" | "4" | "5" | "6" | "7" => {
|
||||||
|
self.set_view((k.as_bytes()[0] - b'1') as usize)
|
||||||
|
}
|
||||||
"enter" => return self.inspect_selection(&tr),
|
"enter" => return self.inspect_selection(&tr),
|
||||||
"space" | "right" => return self.zoom_into_selection(&tr),
|
"space" | "right" => return self.zoom_into_selection(&tr),
|
||||||
"backspace" | "left" => self.zoom_out(&tr),
|
"backspace" | "left" => self.zoom_out(&tr),
|
||||||
"home" => self.zoom_to(&tr, 0),
|
"home" => self.zoom_to(&tr, 0),
|
||||||
"a" => {
|
"a" => {
|
||||||
self.size_mode = if self.size_mode == SizeMode::Allocated { SizeMode::Logical } else { SizeMode::Allocated };
|
self.size_mode = if self.size_mode == SizeMode::Allocated {
|
||||||
|
SizeMode::Logical
|
||||||
|
} else {
|
||||||
|
SizeMode::Allocated
|
||||||
|
};
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
let mut cmds = Vec::new();
|
let mut cmds = Vec::new();
|
||||||
if self.search.query.is_some() {
|
if self.search.query.is_some() {
|
||||||
@@ -88,7 +94,10 @@ impl Model {
|
|||||||
"d" | "delete" => return self.request_trash(&tr),
|
"d" | "delete" => return self.request_trash(&tr),
|
||||||
"T" => {
|
"T" => {
|
||||||
let names = theme_names();
|
let names = theme_names();
|
||||||
let cur = names.iter().position(|n| *n == self.theme.name).unwrap_or(0);
|
let cur = names
|
||||||
|
.iter()
|
||||||
|
.position(|n| *n == self.theme.name)
|
||||||
|
.unwrap_or(0);
|
||||||
let ascii = self.theme.ascii;
|
let ascii = self.theme.ascii;
|
||||||
let mut th = load_theme(names[(cur + 1) % names.len()], &self.opts.settings);
|
let mut th = load_theme(names[(cur + 1) % names.len()], &self.opts.settings);
|
||||||
th.ascii = ascii;
|
th.ascii = ascii;
|
||||||
@@ -200,7 +209,10 @@ impl Model {
|
|||||||
return self.info(msg);
|
return self.info(msg);
|
||||||
}
|
}
|
||||||
if let Err(e) = platform::reveal(&tr.path(id)) {
|
if let Err(e) = platform::reveal(&tr.path(id)) {
|
||||||
return self.warn(format!("Open failed: {}", textutil::sanitize(&e.to_string())));
|
return self.warn(format!(
|
||||||
|
"Open failed: {}",
|
||||||
|
textutil::sanitize(&e.to_string())
|
||||||
|
));
|
||||||
}
|
}
|
||||||
self.info("Opened in file manager")
|
self.info("Opened in file manager")
|
||||||
}
|
}
|
||||||
@@ -210,7 +222,9 @@ impl Model {
|
|||||||
Cmd::run(move || {
|
Cmd::run(move || {
|
||||||
let err = {
|
let err = {
|
||||||
let g = t.read().unwrap();
|
let g = t.read().unwrap();
|
||||||
snapshot::save_file(Path::new(&path), &g).err().map(|e| e.to_string())
|
snapshot::save_file(Path::new(&path), &g)
|
||||||
|
.err()
|
||||||
|
.map(|e| e.to_string())
|
||||||
};
|
};
|
||||||
Msg::SaveDone { path, err }
|
Msg::SaveDone { path, err }
|
||||||
})
|
})
|
||||||
@@ -224,5 +238,7 @@ pub(crate) fn default_snapshot_name(root: &[u8]) -> String {
|
|||||||
None => "root".into(),
|
None => "root".into(),
|
||||||
};
|
};
|
||||||
let wd = std::env::current_dir().unwrap_or_default();
|
let wd = std::env::current_dir().unwrap_or_default();
|
||||||
wd.join(format!("{base}-{}{}", fmt_stamp_now(), brand::SNAPSHOT_EXT)).to_string_lossy().into_owned()
|
wd.join(format!("{base}-{}{}", fmt_stamp_now(), brand::SNAPSHOT_EXT))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-21
@@ -70,8 +70,14 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut l = TmLayout {
|
let mut l = TmLayout {
|
||||||
zoom: self.zoom, rect: r, ver: self.data_ver, blocks: Vec::new(), nested: Vec::new(),
|
zoom: self.zoom,
|
||||||
groups: HashMap::new(), visible: HashSet::new(), inner: HashSet::new(),
|
rect: r,
|
||||||
|
ver: self.data_ver,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
nested: Vec::new(),
|
||||||
|
groups: HashMap::new(),
|
||||||
|
visible: HashSet::new(),
|
||||||
|
inner: HashSet::new(),
|
||||||
};
|
};
|
||||||
l.blocks = self.layout_level(tr, &mut l, self.zoom, r, TOP_MIN_AREA, TOP_MAX_ITEMS);
|
l.blocks = self.layout_level(tr, &mut l, self.zoom, r, TOP_MIN_AREA, TOP_MAX_ITEMS);
|
||||||
for i in 0..l.blocks.len() {
|
for i in 0..l.blocks.len() {
|
||||||
@@ -99,34 +105,87 @@ impl Model {
|
|||||||
|
|
||||||
/// Lays out the children of parent in r with level-of-detail grouping:
|
/// Lays out the children of parent in r with level-of-detail grouping:
|
||||||
/// children too small to be useful collapse into one group block.
|
/// children too small to be useful collapse into one group block.
|
||||||
fn layout_level(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, min_area: i32, max_items: usize) -> Vec<Block> {
|
fn layout_level(
|
||||||
|
&mut self,
|
||||||
|
tr: &Tree,
|
||||||
|
l: &mut TmLayout,
|
||||||
|
parent: NodeId,
|
||||||
|
r: Rect,
|
||||||
|
min_area: i32,
|
||||||
|
max_items: usize,
|
||||||
|
) -> Vec<Block> {
|
||||||
let k = self.kids_of(tr, parent);
|
let k = self.kids_of(tr, parent);
|
||||||
if k.ids.is_empty() || r.empty() {
|
if k.ids.is_empty() || r.empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let sizes: Vec<f64> = k.sizes.iter().map(|&s| s as f64).collect();
|
let sizes: Vec<f64> = k.sizes.iter().map(|&s| s as f64).collect();
|
||||||
let vis = treemap::visible(&sizes, k.total as f64, r.area(), min_area, max_items);
|
let vis = treemap::visible(&sizes, k.total as f64, r.area(), min_area, max_items);
|
||||||
let mut items: Vec<Item> = (0..vis).map(|i| Item { id: k.ids[i] as i64, size: sizes[i] }).collect();
|
let mut items: Vec<Item> = (0..vis)
|
||||||
|
.map(|i| Item {
|
||||||
|
id: k.ids[i] as i64,
|
||||||
|
size: sizes[i],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
if vis < k.ids.len() {
|
if vis < k.ids.len() {
|
||||||
let size = k.sizes[vis..].iter().sum();
|
let size = k.sizes[vis..].iter().sum();
|
||||||
let gid = group_id(parent);
|
let gid = group_id(parent);
|
||||||
l.groups.insert(gid, Group { parent, kids: k.clone(), from: vis, size });
|
l.groups.insert(
|
||||||
items.push(Item { id: gid, size: size as f64 });
|
gid,
|
||||||
|
Group {
|
||||||
|
parent,
|
||||||
|
kids: k.clone(),
|
||||||
|
from: vis,
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
items.push(Item {
|
||||||
|
id: gid,
|
||||||
|
size: size as f64,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
treemap::squarify(&items, r)
|
treemap::squarify(&items, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout_nested(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, depth: i32) {
|
fn layout_nested(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, depth: i32) {
|
||||||
// Deeper levels need bigger blocks to stay legible rather than noisy.
|
// Deeper levels need bigger blocks to stay legible rather than noisy.
|
||||||
let blocks = self.layout_level(tr, l, parent, r, NEST_MIN_AREA * (depth - 1), NEST_MAX_ITEMS);
|
let blocks = self.layout_level(
|
||||||
|
tr,
|
||||||
|
l,
|
||||||
|
parent,
|
||||||
|
r,
|
||||||
|
NEST_MIN_AREA * (depth - 1),
|
||||||
|
NEST_MAX_ITEMS,
|
||||||
|
);
|
||||||
for (i, b) in blocks.iter().enumerate() {
|
for (i, b) in blocks.iter().enumerate() {
|
||||||
let idx = l.nested.len();
|
let idx = l.nested.len();
|
||||||
l.nested.push(Nested { id: b.id, r: b.rect, depth, alt: i % 2 == 1, inner: false });
|
l.nested.push(Nested {
|
||||||
|
id: b.id,
|
||||||
|
r: b.rect,
|
||||||
|
depth,
|
||||||
|
alt: i % 2 == 1,
|
||||||
|
inner: false,
|
||||||
|
});
|
||||||
let br = b.rect;
|
let br = b.rect;
|
||||||
if depth < MAX_NEST_DEPTH && b.id >= 0 && br.w >= NEST_RECURSE_W && br.h >= NEST_RECURSE_H && tr.node(b.id as NodeId).is_dir() {
|
if depth < MAX_NEST_DEPTH
|
||||||
|
&& b.id >= 0
|
||||||
|
&& br.w >= NEST_RECURSE_W
|
||||||
|
&& br.h >= NEST_RECURSE_H
|
||||||
|
&& tr.node(b.id as NodeId).is_dir()
|
||||||
|
{
|
||||||
l.nested[idx].inner = true;
|
l.nested[idx].inner = true;
|
||||||
let end = self.chain_end(tr, b.id as NodeId);
|
let end = self.chain_end(tr, b.id as NodeId);
|
||||||
self.layout_nested(tr, l, end, Rect { x: br.x, y: br.y + 1, w: br.w - 1, h: br.h - 2 }, depth + 1);
|
self.layout_nested(
|
||||||
|
tr,
|
||||||
|
l,
|
||||||
|
end,
|
||||||
|
Rect {
|
||||||
|
x: br.x,
|
||||||
|
y: br.y + 1,
|
||||||
|
w: br.w - 1,
|
||||||
|
h: br.h - 2,
|
||||||
|
},
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +241,9 @@ impl Model {
|
|||||||
/// The size of the folder a block sits in.
|
/// The size of the folder a block sits in.
|
||||||
fn parent_size(&self, tr: &Tree, id: i64) -> i64 {
|
fn parent_size(&self, tr: &Tree, id: i64) -> i64 {
|
||||||
if is_group(id) {
|
if is_group(id) {
|
||||||
return self.group_info(id).map_or(0, |g| self.size_of(tr, g.parent));
|
return self
|
||||||
|
.group_info(id)
|
||||||
|
.map_or(0, |g| self.size_of(tr, g.parent));
|
||||||
}
|
}
|
||||||
let p = tr.node(id as NodeId).parent;
|
let p = tr.node(id as NodeId).parent;
|
||||||
if p != NO_NODE {
|
if p != NO_NODE {
|
||||||
@@ -191,7 +252,15 @@ impl Model {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paint_top(&mut self, tr: &Tree, cv: &mut Canvas, b: Block, sel: bool, hov: bool, total: i64) {
|
fn paint_top(
|
||||||
|
&mut self,
|
||||||
|
tr: &Tree,
|
||||||
|
cv: &mut Canvas,
|
||||||
|
b: Block,
|
||||||
|
sel: bool,
|
||||||
|
hov: bool,
|
||||||
|
total: i64,
|
||||||
|
) {
|
||||||
let t = self.theme.clone();
|
let t = self.theme.clone();
|
||||||
let base = self.color_of(tr, b.id);
|
let base = self.color_of(tr, b.id);
|
||||||
let fill = base.mix(t.bg, 0.62);
|
let fill = base.mix(t.bg, 0.62);
|
||||||
@@ -234,7 +303,13 @@ impl Model {
|
|||||||
title.attr = BOLD;
|
title.attr = BOLD;
|
||||||
}
|
}
|
||||||
cv.draw_box(r, bx, frame);
|
cv.draw_box(r, bx, frame);
|
||||||
cv.text(r.x + 1, r.y, &trunc(&format!(" {name} "), r.w - 2), r.w - 2, title);
|
cv.text(
|
||||||
|
r.x + 1,
|
||||||
|
r.y,
|
||||||
|
&trunc(&format!(" {name} "), r.w - 2),
|
||||||
|
r.w - 2,
|
||||||
|
title,
|
||||||
|
);
|
||||||
|
|
||||||
let mut label = textutil::size(size);
|
let mut label = textutil::size(size);
|
||||||
let pct = textutil::percent(size, total);
|
let pct = textutil::percent(size, total);
|
||||||
@@ -245,7 +320,17 @@ impl Model {
|
|||||||
}
|
}
|
||||||
let label = format!(" {label} ");
|
let label = format!(" {label} ");
|
||||||
if r.h >= 3 && tw(&label) <= r.w - 2 {
|
if r.h >= 3 && tw(&label) <= r.w - 2 {
|
||||||
cv.text_right(r.x + 1, r.y + r.h - 1, r.w - 2, &label, Style { fg: frame.fg, bg: fill, attr: frame.attr });
|
cv.text_right(
|
||||||
|
r.x + 1,
|
||||||
|
r.y + r.h - 1,
|
||||||
|
r.w - 2,
|
||||||
|
&label,
|
||||||
|
Style {
|
||||||
|
fg: frame.fg,
|
||||||
|
bg: fill,
|
||||||
|
attr: frame.attr,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Interior labels for blocks without nested previews.
|
// Interior labels for blocks without nested previews.
|
||||||
let inn = r.inset(1);
|
let inn = r.inset(1);
|
||||||
@@ -266,7 +351,13 @@ impl Model {
|
|||||||
}
|
}
|
||||||
let y0 = inn.y + (inn.h - lines.len() as i32) / 2;
|
let y0 = inn.y + (inn.h - lines.len() as i32) / 2;
|
||||||
for (i, s) in lines.iter().enumerate() {
|
for (i, s) in lines.iter().enumerate() {
|
||||||
cv.text_centered(inn.x, y0 + i as i32, inn.w, s, Style::new(base.mix(t.fg, 0.4), fill));
|
cv.text_centered(
|
||||||
|
inn.x,
|
||||||
|
y0 + i as i32,
|
||||||
|
inn.w,
|
||||||
|
s,
|
||||||
|
Style::new(base.mix(t.fg, 0.4), fill),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,10 +389,28 @@ impl Model {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if r.w >= 4 {
|
if r.w >= 4 {
|
||||||
cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, " ", Style::new(Default::default(), shadow));
|
cv.fill(
|
||||||
|
Rect {
|
||||||
|
x: r.x + r.w - 1,
|
||||||
|
y: r.y,
|
||||||
|
w: 1,
|
||||||
|
h: r.h,
|
||||||
|
},
|
||||||
|
" ",
|
||||||
|
Style::new(Default::default(), shadow),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if r.h >= 3 {
|
if r.h >= 3 {
|
||||||
cv.fill(Rect { x: r.x, y: r.y + r.h - 1, w: r.w, h: 1 }, " ", Style::new(Default::default(), shadow));
|
cv.fill(
|
||||||
|
Rect {
|
||||||
|
x: r.x,
|
||||||
|
y: r.y + r.h - 1,
|
||||||
|
w: r.w,
|
||||||
|
h: 1,
|
||||||
|
},
|
||||||
|
" ",
|
||||||
|
Style::new(Default::default(), shadow),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let lw = if t.mono { r.w - 2 } else { r.w - 1 };
|
let lw = if t.mono { r.w - 2 } else { r.w - 1 };
|
||||||
@@ -314,7 +423,13 @@ impl Model {
|
|||||||
if tw(&name) > lw && lw < 8 {
|
if tw(&name) > lw && lw < 8 {
|
||||||
return; // a stub like "te…" is noise, not information
|
return; // a stub like "te…" is noise, not information
|
||||||
}
|
}
|
||||||
cv.text(lx, ly, &trunc(&name, lw), lw, Style::new(st.fg, bg).with(BOLD));
|
cv.text(
|
||||||
|
lx,
|
||||||
|
ly,
|
||||||
|
&trunc(&name, lw),
|
||||||
|
lw,
|
||||||
|
Style::new(st.fg, bg).with(BOLD),
|
||||||
|
);
|
||||||
if !nb.inner && r.h >= 3 && lw >= 5 {
|
if !nb.inner && r.h >= 3 && lw >= 5 {
|
||||||
let size = self.block_size(tr, nb.id);
|
let size = self.block_size(tr, nb.id);
|
||||||
let mut label = textutil::size_compact(size);
|
let mut label = textutil::size_compact(size);
|
||||||
@@ -322,7 +437,13 @@ impl Model {
|
|||||||
if tw(&label) + tw(&pct) + 3 <= lw {
|
if tw(&label) + tw(&pct) + 3 <= lw {
|
||||||
label += &format!(" · {pct}");
|
label += &format!(" · {pct}");
|
||||||
}
|
}
|
||||||
cv.text(lx, ly + 1, &trunc(&label, lw), lw, Style::new(bg.mix(st.fg, 0.7), bg));
|
cv.text(
|
||||||
|
lx,
|
||||||
|
ly + 1,
|
||||||
|
&trunc(&label, lw),
|
||||||
|
lw,
|
||||||
|
Style::new(bg.mix(st.fg, 0.7), bg),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +458,11 @@ pub(crate) fn map_paint(m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
|
|||||||
} else if m.scanning {
|
} else if m.scanning {
|
||||||
"Scanning…".to_string()
|
"Scanning…".to_string()
|
||||||
} else if !z.is_dir() {
|
} else if !z.is_dir() {
|
||||||
format!("{} — {}", san(&z.name), textutil::size(m.size_of(tr, m.zoom)))
|
format!(
|
||||||
|
"{} — {}",
|
||||||
|
san(&z.name),
|
||||||
|
textutil::size(m.size_of(tr, m.zoom))
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
"Empty directory".to_string()
|
"Empty directory".to_string()
|
||||||
};
|
};
|
||||||
@@ -354,7 +479,10 @@ pub(crate) fn map_paint(m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
|
|||||||
m.paint_nested(tr, cv, nb);
|
m.paint_nested(tr, cv, nb);
|
||||||
}
|
}
|
||||||
for b in &l.blocks {
|
for b in &l.blocks {
|
||||||
m.hits.push(Hit { r: b.rect, act: HitAct::Block(b.id) });
|
m.hits.push(Hit {
|
||||||
|
r: b.rect,
|
||||||
|
act: HitAct::Block(b.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-5
@@ -55,7 +55,6 @@ pub(crate) fn san(b: &[u8]) -> String {
|
|||||||
textutil::sanitize_bytes(b)
|
textutil::sanitize_bytes(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn local_tm(unix_nanos: i64) -> libc::tm {
|
fn local_tm(unix_nanos: i64) -> libc::tm {
|
||||||
let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t;
|
let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t;
|
||||||
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
||||||
@@ -66,18 +65,40 @@ fn local_tm(unix_nanos: i64) -> libc::tm {
|
|||||||
/// Formats a timestamp as a local date ("2006-01-02").
|
/// Formats a timestamp as a local date ("2006-01-02").
|
||||||
pub(crate) fn fmt_date(unix_nanos: i64) -> String {
|
pub(crate) fn fmt_date(unix_nanos: i64) -> String {
|
||||||
let t = local_tm(unix_nanos);
|
let t = local_tm(unix_nanos);
|
||||||
format!("{:04}-{:02}-{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday)
|
format!(
|
||||||
|
"{:04}-{:02}-{:02}",
|
||||||
|
t.tm_year + 1900,
|
||||||
|
t.tm_mon + 1,
|
||||||
|
t.tm_mday
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats a timestamp as local date and time ("2006-01-02 15:04:05").
|
/// Formats a timestamp as local date and time ("2006-01-02 15:04:05").
|
||||||
pub(crate) fn fmt_time(unix_nanos: i64) -> String {
|
pub(crate) fn fmt_time(unix_nanos: i64) -> String {
|
||||||
let t = local_tm(unix_nanos);
|
let t = local_tm(unix_nanos);
|
||||||
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec)
|
format!(
|
||||||
|
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
|
||||||
|
t.tm_year + 1900,
|
||||||
|
t.tm_mon + 1,
|
||||||
|
t.tm_mday,
|
||||||
|
t.tm_hour,
|
||||||
|
t.tm_min,
|
||||||
|
t.tm_sec
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current local time as "20060102-1504".
|
/// The current local time as "20060102-1504".
|
||||||
pub(crate) fn fmt_stamp_now() -> String {
|
pub(crate) fn fmt_stamp_now() -> String {
|
||||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
let t = local_tm(now.as_nanos() as i64);
|
let t = local_tm(now.as_nanos() as i64);
|
||||||
format!("{:04}{:02}{:02}-{:02}{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min)
|
format!(
|
||||||
|
"{:04}{:02}{:02}-{:02}{:02}",
|
||||||
|
t.tm_year + 1900,
|
||||||
|
t.tm_mon + 1,
|
||||||
|
t.tm_mday,
|
||||||
|
t.tm_hour,
|
||||||
|
t.tm_min
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-12
@@ -17,15 +17,30 @@ pub(crate) struct Span {
|
|||||||
pub(crate) type Line = Vec<Span>;
|
pub(crate) type Line = Vec<Span>;
|
||||||
|
|
||||||
pub(crate) fn txt(s: impl Into<String>) -> Line {
|
pub(crate) fn txt(s: impl Into<String>) -> Line {
|
||||||
vec![Span { s: s.into(), st: None }]
|
vec![Span {
|
||||||
|
s: s.into(),
|
||||||
|
st: None,
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn styled(s: impl Into<String>, st: Style) -> Line {
|
pub(crate) fn styled(s: impl Into<String>, st: Style) -> Line {
|
||||||
vec![Span { s: s.into(), st: Some(st) }]
|
vec![Span {
|
||||||
|
s: s.into(),
|
||||||
|
st: Some(st),
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn kv(k: &str, v: impl Into<String>, kst: Style, vst: Style) -> Line {
|
pub(crate) fn kv(k: &str, v: impl Into<String>, kst: Style, vst: Style) -> Line {
|
||||||
vec![Span { s: pad_right(k, 18), st: Some(kst) }, Span { s: v.into(), st: Some(vst) }]
|
vec![
|
||||||
|
Span {
|
||||||
|
s: pad_right(k, 18),
|
||||||
|
st: Some(kst),
|
||||||
|
},
|
||||||
|
Span {
|
||||||
|
s: v.into(),
|
||||||
|
st: Some(vst),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An overlay. Modals receive all keys while open; Esc always closes them
|
/// An overlay. Modals receive all keys while open; Esc always closes them
|
||||||
@@ -61,7 +76,12 @@ impl ModalFrame {
|
|||||||
|
|
||||||
impl Model {
|
impl Model {
|
||||||
pub fn open_modal(&mut self, md: Box<dyn Modal>) {
|
pub fn open_modal(&mut self, md: Box<dyn Modal>) {
|
||||||
self.modals.push(ModalFrame { md, scroll: 0, view_h: 0, content_n: 0 });
|
self.modals.push(ModalFrame {
|
||||||
|
md,
|
||||||
|
scroll: 0,
|
||||||
|
view_h: 0,
|
||||||
|
content_n: 0,
|
||||||
|
});
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +139,14 @@ impl Model {
|
|||||||
pub fn paint_modal(&mut self, tr: &Tree, cv: &mut Canvas) {
|
pub fn paint_modal(&mut self, tr: &Tree, cv: &mut Canvas) {
|
||||||
let t = self.theme.clone();
|
let t = self.theme.clone();
|
||||||
// Dim everything underneath.
|
// Dim everything underneath.
|
||||||
cv.restyle(Rect { x: 0, y: 0, w: cv.w, h: cv.h }, |mut s| {
|
cv.restyle(
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: cv.w,
|
||||||
|
h: cv.h,
|
||||||
|
},
|
||||||
|
|mut s| {
|
||||||
if t.mono {
|
if t.mono {
|
||||||
s.attr |= FAINT;
|
s.attr |= FAINT;
|
||||||
return s;
|
return s;
|
||||||
@@ -128,7 +155,8 @@ impl Model {
|
|||||||
s.bg = s.bg.mix(rgb(0, 0, 0), 0.45);
|
s.bg = s.bg.mix(rgb(0, 0, 0), 0.45);
|
||||||
s.attr &= !BOLD;
|
s.attr &= !BOLD;
|
||||||
s
|
s
|
||||||
});
|
},
|
||||||
|
);
|
||||||
let mut f = self.modals.pop().unwrap();
|
let mut f = self.modals.pop().unwrap();
|
||||||
let max_w = cv.w - 2;
|
let max_w = cv.w - 2;
|
||||||
let w = (f.md.width() + 4).min(max_w).max(30.min(max_w));
|
let w = (f.md.width() + 4).min(max_w).max(30.min(max_w));
|
||||||
@@ -139,10 +167,16 @@ impl Model {
|
|||||||
if !foot.is_empty() {
|
if !foot.is_empty() {
|
||||||
chrome += 2;
|
chrome += 2;
|
||||||
}
|
}
|
||||||
let h = (lines.len() as i32 + chrome).min(cv.h - 2).max((chrome + 1).min(cv.h));
|
let h = (lines.len() as i32 + chrome)
|
||||||
|
.min(cv.h - 2)
|
||||||
|
.max((chrome + 1).min(cv.h));
|
||||||
let (x0, y0) = ((cv.w - w) / 2, (cv.h - h) / 2);
|
let (x0, y0) = ((cv.w - w) / 2, (cv.h - h) / 2);
|
||||||
let r = Rect { x: x0, y: y0, w, h };
|
let r = Rect { x: x0, y: y0, w, h };
|
||||||
let bg = if t.panel_bg.is_default() { t.bg } else { t.panel_bg };
|
let bg = if t.panel_bg.is_default() {
|
||||||
|
t.bg
|
||||||
|
} else {
|
||||||
|
t.panel_bg
|
||||||
|
};
|
||||||
let base = Style::new(t.fg, bg);
|
let base = Style::new(t.fg, bg);
|
||||||
// Drop shadow.
|
// Drop shadow.
|
||||||
if !t.mono {
|
if !t.mono {
|
||||||
@@ -150,13 +184,35 @@ impl Model {
|
|||||||
s.bg = rgb(0, 0, 0);
|
s.bg = rgb(0, 0, 0);
|
||||||
s
|
s
|
||||||
};
|
};
|
||||||
cv.restyle(Rect { x: x0 + 1, y: y0 + h, w, h: 1 }, black);
|
cv.restyle(
|
||||||
cv.restyle(Rect { x: x0 + w, y: y0 + 1, w: 1, h }, black);
|
Rect {
|
||||||
|
x: x0 + 1,
|
||||||
|
y: y0 + h,
|
||||||
|
w,
|
||||||
|
h: 1,
|
||||||
|
},
|
||||||
|
black,
|
||||||
|
);
|
||||||
|
cv.restyle(
|
||||||
|
Rect {
|
||||||
|
x: x0 + w,
|
||||||
|
y: y0 + 1,
|
||||||
|
w: 1,
|
||||||
|
h,
|
||||||
|
},
|
||||||
|
black,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
cv.fill(r, " ", base);
|
cv.fill(r, " ", base);
|
||||||
let border = Style::new(t.accent, bg).with(BOLD);
|
let border = Style::new(t.accent, bg).with(BOLD);
|
||||||
cv.draw_box(r, if t.ascii { BOX_ASCII } else { BOX_ROUND }, border);
|
cv.draw_box(r, if t.ascii { BOX_ASCII } else { BOX_ROUND }, border);
|
||||||
cv.text(x0 + 2, y0 + 1, &f.md.title().to_uppercase(), inner, Style::new(t.accent, bg).with(BOLD));
|
cv.text(
|
||||||
|
x0 + 2,
|
||||||
|
y0 + 1,
|
||||||
|
&f.md.title().to_uppercase(),
|
||||||
|
inner,
|
||||||
|
Style::new(t.accent, bg).with(BOLD),
|
||||||
|
);
|
||||||
let rule = if t.ascii { "-" } else { "─" };
|
let rule = if t.ascii { "-" } else { "─" };
|
||||||
cv.hline(x0 + 1, y0 + 2, w - 2, rule, Style::new(t.faint, bg));
|
cv.hline(x0 + 1, y0 + 2, w - 2, rule, Style::new(t.faint, bg));
|
||||||
f.view_h = h - chrome;
|
f.view_h = h - chrome;
|
||||||
@@ -201,7 +257,10 @@ impl Model {
|
|||||||
}
|
}
|
||||||
self.modals.push(f);
|
self.modals.push(f);
|
||||||
self.hits.clear();
|
self.hits.clear();
|
||||||
self.hits.push(Hit { r, act: HitAct::None });
|
self.hits.push(Hit {
|
||||||
|
r,
|
||||||
|
act: HitAct::None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+195
-37
@@ -10,7 +10,10 @@ use super::search::LineEditor;
|
|||||||
use super::{fmt_time, pad_left, pad_right, san, trunc, tw};
|
use super::{fmt_time, pad_left, pad_right, san, trunc, tw};
|
||||||
use crate::export;
|
use crate::export;
|
||||||
use crate::filter;
|
use crate::filter;
|
||||||
use crate::inventory::{NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED, FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE};
|
use crate::inventory::{
|
||||||
|
NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED,
|
||||||
|
FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE,
|
||||||
|
};
|
||||||
use crate::platform;
|
use crate::platform;
|
||||||
use crate::textutil;
|
use crate::textutil;
|
||||||
|
|
||||||
@@ -42,7 +45,10 @@ impl Modal for InfoModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn footer(&self, m: &Model) -> String {
|
fn footer(&self, m: &Model) -> String {
|
||||||
let mut s = format!("Esc close · c copy path · o {} · Space go to", reveal_verb());
|
let mut s = format!(
|
||||||
|
"Esc close · c copy path · o {} · Space go to",
|
||||||
|
reveal_verb()
|
||||||
|
);
|
||||||
if !m.opts.read_only && !m.snapshot {
|
if !m.opts.read_only && !m.snapshot {
|
||||||
s += &format!(" · d {}", delete_verb());
|
s += &format!(" · d {}", delete_verb());
|
||||||
}
|
}
|
||||||
@@ -71,25 +77,67 @@ impl Modal for InfoModal {
|
|||||||
out.push(styled(l, vs));
|
out.push(styled(l, vs));
|
||||||
}
|
}
|
||||||
if san(&n.name).as_bytes() != &*n.name {
|
if san(&n.name).as_bytes() != &*n.name {
|
||||||
out.push(styled("Name contains control or invalid characters (shown escaped)", warn));
|
out.push(styled(
|
||||||
|
"Name contains control or invalid characters (shown escaped)",
|
||||||
|
warn,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
out.push(txt(""));
|
out.push(txt(""));
|
||||||
let mode = m.size_mode;
|
let mode = m.size_mode;
|
||||||
if n.is_dir() {
|
if n.is_dir() {
|
||||||
add_s(&mut out, "Total logical", format!("{} ({} bytes)", textutil::size(n.tot_size), textutil::count(n.tot_size)), bold);
|
add_s(
|
||||||
add_s(&mut out, "Total allocated", format!("{} ({} bytes)", textutil::size(n.tot_alloc), textutil::count(n.tot_alloc)), bold);
|
&mut out,
|
||||||
|
"Total logical",
|
||||||
|
format!(
|
||||||
|
"{} ({} bytes)",
|
||||||
|
textutil::size(n.tot_size),
|
||||||
|
textutil::count(n.tot_size)
|
||||||
|
),
|
||||||
|
bold,
|
||||||
|
);
|
||||||
|
add_s(
|
||||||
|
&mut out,
|
||||||
|
"Total allocated",
|
||||||
|
format!(
|
||||||
|
"{} ({} bytes)",
|
||||||
|
textutil::size(n.tot_alloc),
|
||||||
|
textutil::count(n.tot_alloc)
|
||||||
|
),
|
||||||
|
bold,
|
||||||
|
);
|
||||||
add(&mut out, "Files", textutil::count(n.files as i64));
|
add(&mut out, "Files", textutil::count(n.files as i64));
|
||||||
add(&mut out, "Directories", textutil::count(n.dirs as i64));
|
add(&mut out, "Directories", textutil::count(n.dirs as i64));
|
||||||
if n.parent != NO_NODE {
|
if n.parent != NO_NODE {
|
||||||
add(&mut out, "Of parent", textutil::percent(n.total(mode), tr.node(n.parent).total(mode)));
|
add(
|
||||||
|
&mut out,
|
||||||
|
"Of parent",
|
||||||
|
textutil::percent(n.total(mode), tr.node(n.parent).total(mode)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
add(&mut out, "Of scan", textutil::percent(n.total(mode), tr.node(0).total(mode)));
|
add(
|
||||||
|
&mut out,
|
||||||
|
"Of scan",
|
||||||
|
textutil::percent(n.total(mode), tr.node(0).total(mode)),
|
||||||
|
);
|
||||||
let k = m.kids_of(tr, self.id);
|
let k = m.kids_of(tr, self.id);
|
||||||
if let Some(&first) = k.ids.first() {
|
if let Some(&first) = k.ids.first() {
|
||||||
add(&mut out, "Largest child", format!("{} ({})", san(&tr.node(first).name), textutil::size(k.sizes[0])));
|
add(
|
||||||
|
&mut out,
|
||||||
|
"Largest child",
|
||||||
|
format!(
|
||||||
|
"{} ({})",
|
||||||
|
san(&tr.node(first).name),
|
||||||
|
textutil::size(k.sizes[0])
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if n.errors > 0 {
|
if n.errors > 0 {
|
||||||
add_s(&mut out, "Errors beneath", textutil::count(n.errors as i64), Style::new(t.err, Default::default()).with(BOLD));
|
add_s(
|
||||||
|
&mut out,
|
||||||
|
"Errors beneath",
|
||||||
|
textutil::count(n.errors as i64),
|
||||||
|
Style::new(t.err, Default::default()).with(BOLD),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let state = if n.has(FLAG_INCOMPLETE) {
|
let state = if n.has(FLAG_INCOMPLETE) {
|
||||||
"incomplete (not fully read)"
|
"incomplete (not fully read)"
|
||||||
@@ -102,15 +150,32 @@ impl Modal for InfoModal {
|
|||||||
};
|
};
|
||||||
add(&mut out, "Scan state", state.into());
|
add(&mut out, "Scan state", state.into());
|
||||||
} else {
|
} else {
|
||||||
add_s(&mut out, "Logical size", format!("{} ({} bytes)", textutil::size(n.size), textutil::count(n.size)), bold);
|
add_s(
|
||||||
|
&mut out,
|
||||||
|
"Logical size",
|
||||||
|
format!(
|
||||||
|
"{} ({} bytes)",
|
||||||
|
textutil::size(n.size),
|
||||||
|
textutil::count(n.size)
|
||||||
|
),
|
||||||
|
bold,
|
||||||
|
);
|
||||||
let alloc = if n.has(FLAG_ALLOC_UNKNOWN) {
|
let alloc = if n.has(FLAG_ALLOC_UNKNOWN) {
|
||||||
"unknown on this platform".to_string()
|
"unknown on this platform".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{} ({} bytes)", textutil::size(n.alloc), textutil::count(n.alloc))
|
format!(
|
||||||
|
"{} ({} bytes)",
|
||||||
|
textutil::size(n.alloc),
|
||||||
|
textutil::count(n.alloc)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
add_s(&mut out, "Allocated size", alloc, bold);
|
add_s(&mut out, "Allocated size", alloc, bold);
|
||||||
if n.parent != NO_NODE {
|
if n.parent != NO_NODE {
|
||||||
add(&mut out, "Of parent", textutil::percent(n.own(mode), tr.node(n.parent).total(mode)));
|
add(
|
||||||
|
&mut out,
|
||||||
|
"Of parent",
|
||||||
|
textutil::percent(n.own(mode), tr.node(n.parent).total(mode)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(r) = &m.search.result {
|
if let Some(r) = &m.search.result {
|
||||||
@@ -120,9 +185,19 @@ impl Modal for InfoModal {
|
|||||||
match d.old_size(self.id, mode) {
|
match d.old_size(self.id, mode) {
|
||||||
Some(old) => {
|
Some(old) => {
|
||||||
add(&mut out, "In old snapshot", textutil::size(old));
|
add(&mut out, "In old snapshot", textutil::size(old));
|
||||||
add_s(&mut out, "Change", textutil::signed_size(n.total(mode) - old), bold);
|
add_s(
|
||||||
|
&mut out,
|
||||||
|
"Change",
|
||||||
|
textutil::signed_size(n.total(mode) - old),
|
||||||
|
bold,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
None => add_s(&mut out, "Change", "new since old snapshot".into(), Style::new(t.diff_new, Default::default()).with(BOLD)),
|
None => add_s(
|
||||||
|
&mut out,
|
||||||
|
"Change",
|
||||||
|
"new since old snapshot".into(),
|
||||||
|
Style::new(t.diff_new, Default::default()).with(BOLD),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push(txt(""));
|
out.push(txt(""));
|
||||||
@@ -143,12 +218,23 @@ impl Modal for InfoModal {
|
|||||||
}
|
}
|
||||||
out.push(txt(""));
|
out.push(txt(""));
|
||||||
add(&mut out, "Mode", platform::mode::string(n.mode));
|
add(&mut out, "Mode", platform::mode::string(n.mode));
|
||||||
add(&mut out, "Owner", textutil::sanitize(&platform::user_name(n.uid)));
|
add(
|
||||||
add(&mut out, "Group", textutil::sanitize(&platform::group_name(n.gid)));
|
&mut out,
|
||||||
|
"Owner",
|
||||||
|
textutil::sanitize(&platform::user_name(n.uid)),
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
&mut out,
|
||||||
|
"Group",
|
||||||
|
textutil::sanitize(&platform::group_name(n.gid)),
|
||||||
|
);
|
||||||
if !n.is_dir() {
|
if !n.is_dir() {
|
||||||
add(&mut out, "Hard links", n.nlink.to_string());
|
add(&mut out, "Hard links", n.nlink.to_string());
|
||||||
let sparse = if n.has(FLAG_SPARSE) {
|
let sparse = if n.has(FLAG_SPARSE) {
|
||||||
format!("Yes (or compressed) — {} not allocated", textutil::size(n.size - n.alloc))
|
format!(
|
||||||
|
"Yes (or compressed) — {} not allocated",
|
||||||
|
textutil::size(n.size - n.alloc)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
"No".into()
|
"No".into()
|
||||||
};
|
};
|
||||||
@@ -159,11 +245,17 @@ impl Modal for InfoModal {
|
|||||||
add_s(&mut out, "Flags", fl.join(", "), warn);
|
add_s(&mut out, "Flags", fl.join(", "), warn);
|
||||||
}
|
}
|
||||||
if n.has(FLAG_HARDLINK_DUP) {
|
if n.has(FLAG_HARDLINK_DUP) {
|
||||||
out.push(styled("Another path to this inode was counted; this one adds nothing to totals.", ks));
|
out.push(styled(
|
||||||
|
"Another path to this inode was counted; this one adds nothing to totals.",
|
||||||
|
ks,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if m.snapshot {
|
if m.snapshot {
|
||||||
out.push(txt(""));
|
out.push(txt(""));
|
||||||
out.push(styled("From snapshot — metadata as recorded, not live.", ks));
|
out.push(styled(
|
||||||
|
"From snapshot — metadata as recorded, not live.",
|
||||||
|
ks,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -216,13 +308,22 @@ impl Modal for GroupModal {
|
|||||||
let t = m.theme.clone();
|
let t = m.theme.clone();
|
||||||
let ids = self.g.ids();
|
let ids = self.g.ids();
|
||||||
let mut out = vec![
|
let mut out = vec![
|
||||||
txt(format!("{} items too small to draw individually at this size,", textutil::count(ids.len() as i64))),
|
txt(format!(
|
||||||
txt(format!("totalling {}. Zoom in or enlarge the terminal to see them.", textutil::size(self.g.size))),
|
"{} items too small to draw individually at this size,",
|
||||||
|
textutil::count(ids.len() as i64)
|
||||||
|
)),
|
||||||
|
txt(format!(
|
||||||
|
"totalling {}. Zoom in or enlarge the terminal to see them.",
|
||||||
|
textutil::size(self.g.size)
|
||||||
|
)),
|
||||||
txt(""),
|
txt(""),
|
||||||
];
|
];
|
||||||
for (i, &id) in ids.iter().enumerate() {
|
for (i, &id) in ids.iter().enumerate() {
|
||||||
if i == 200 {
|
if i == 200 {
|
||||||
out.push(styled(format!("… and {} more", textutil::count(ids.len() as i64 - 200)), Style::new(t.muted, Default::default())));
|
out.push(styled(
|
||||||
|
format!("… and {} more", textutil::count(ids.len() as i64 - 200)),
|
||||||
|
Style::new(t.muted, Default::default()),
|
||||||
|
));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let n = tr.node(id);
|
let n = tr.node(id);
|
||||||
@@ -231,8 +332,17 @@ impl Modal for GroupModal {
|
|||||||
name.push('/');
|
name.push('/');
|
||||||
}
|
}
|
||||||
out.push(vec![
|
out.push(vec![
|
||||||
Span { s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ", st: Some(Style::new(t.muted, Default::default())) },
|
Span {
|
||||||
Span { s: trunc(&name, w - 13), st: Some(Style::new(m.color_of(tr, id as i64).mix(t.fg, 0.4), Default::default())) },
|
s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ",
|
||||||
|
st: Some(Style::new(t.muted, Default::default())),
|
||||||
|
},
|
||||||
|
Span {
|
||||||
|
s: trunc(&name, w - 13),
|
||||||
|
st: Some(Style::new(
|
||||||
|
m.color_of(tr, id as i64).mix(t.fg, 0.4),
|
||||||
|
Default::default(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -281,10 +391,23 @@ impl Modal for HelpModal {
|
|||||||
out.push(styled(s, h));
|
out.push(styled(s, h));
|
||||||
};
|
};
|
||||||
let row = |out: &mut Vec<Line>, keys: &str, desc: &str| {
|
let row = |out: &mut Vec<Line>, keys: &str, desc: &str| {
|
||||||
out.push(vec![Span { s: format!(" {}", pad_right(keys, 19)), st: Some(k) }, Span { s: desc.into(), st: Some(d) }]);
|
out.push(vec![
|
||||||
|
Span {
|
||||||
|
s: format!(" {}", pad_right(keys, 19)),
|
||||||
|
st: Some(k),
|
||||||
|
},
|
||||||
|
Span {
|
||||||
|
s: desc.into(),
|
||||||
|
st: Some(d),
|
||||||
|
},
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
sec(&mut out, "NAVIGATION");
|
sec(&mut out, "NAVIGATION");
|
||||||
row(&mut out, "↑ ↓ ← → hjkl", "move spatially through the treemap");
|
row(
|
||||||
|
&mut out,
|
||||||
|
"↑ ↓ ← → hjkl",
|
||||||
|
"move spatially through the treemap",
|
||||||
|
);
|
||||||
row(&mut out, "Enter", "inspect the selected item");
|
row(&mut out, "Enter", "inspect the selected item");
|
||||||
row(&mut out, "Space →", "zoom into the selected directory");
|
row(&mut out, "Space →", "zoom into the selected directory");
|
||||||
row(&mut out, "Backspace ←", "zoom out one level");
|
row(&mut out, "Backspace ←", "zoom out one level");
|
||||||
@@ -292,20 +415,43 @@ impl Modal for HelpModal {
|
|||||||
row(&mut out, "click / dbl-click", "select / zoom (mouse)");
|
row(&mut out, "click / dbl-click", "select / zoom (mouse)");
|
||||||
row(&mut out, "wheel", "zoom in/out (map), scroll (lists)");
|
row(&mut out, "wheel", "zoom in/out (map), scroll (lists)");
|
||||||
sec(&mut out, "SEARCH & FILTER");
|
sec(&mut out, "SEARCH & FILTER");
|
||||||
row(&mut out, "/", "search or filter; the map shows only matches");
|
row(
|
||||||
|
&mut out,
|
||||||
|
"/",
|
||||||
|
"search or filter; the map shows only matches",
|
||||||
|
);
|
||||||
row(&mut out, "Esc", "clear the filter");
|
row(&mut out, "Esc", "clear the filter");
|
||||||
out.push(styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", muted));
|
out.push(styled(
|
||||||
out.push(styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", muted));
|
" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d",
|
||||||
|
muted,
|
||||||
|
));
|
||||||
|
out.push(styled(
|
||||||
|
" path contains cache AND NOT type = dir owner = andy flag = sparse",
|
||||||
|
muted,
|
||||||
|
));
|
||||||
let fields: Vec<&str> = filter::FIELDS.split_whitespace().collect();
|
let fields: Vec<&str> = filter::FIELDS.split_whitespace().collect();
|
||||||
let split = 7.min(fields.len());
|
let split = 7.min(fields.len());
|
||||||
out.push(styled(format!(" fields: {}", fields[..split].join(" ")), muted));
|
out.push(styled(
|
||||||
out.push(styled(format!(" {}", fields[split..].join(" ")), muted));
|
format!(" fields: {}", fields[..split].join(" ")),
|
||||||
out.push(styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", muted));
|
muted,
|
||||||
|
));
|
||||||
|
out.push(styled(
|
||||||
|
format!(" {}", fields[split..].join(" ")),
|
||||||
|
muted,
|
||||||
|
));
|
||||||
|
out.push(styled(
|
||||||
|
" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y",
|
||||||
|
muted,
|
||||||
|
));
|
||||||
sec(&mut out, "VIEWS");
|
sec(&mut out, "VIEWS");
|
||||||
row(&mut out, "Tab Shift+Tab", "next / previous view");
|
row(&mut out, "Tab Shift+Tab", "next / previous view");
|
||||||
row(&mut out, "1 – 7", "jump to a view");
|
row(&mut out, "1 – 7", "jump to a view");
|
||||||
row(&mut out, "x", "file types (extension statistics)");
|
row(&mut out, "x", "file types (extension statistics)");
|
||||||
row(&mut out, "g", "top lists: largest, oldest, sparse, hard links …");
|
row(
|
||||||
|
&mut out,
|
||||||
|
"g",
|
||||||
|
"top lists: largest, oldest, sparse, hard links …",
|
||||||
|
);
|
||||||
row(&mut out, "e", "scan information and errors");
|
row(&mut out, "e", "scan information and errors");
|
||||||
row(&mut out, "D", "find duplicate files (reads file contents)");
|
row(&mut out, "D", "find duplicate files (reads file contents)");
|
||||||
sec(&mut out, "APPLICATION");
|
sec(&mut out, "APPLICATION");
|
||||||
@@ -321,8 +467,15 @@ impl Modal for HelpModal {
|
|||||||
row(&mut out, "Ctrl+C", "cancel scan; press again to quit");
|
row(&mut out, "Ctrl+C", "cancel scan; press again to quit");
|
||||||
row(&mut out, "q", "quit");
|
row(&mut out, "q", "quit");
|
||||||
out.push(txt(""));
|
out.push(txt(""));
|
||||||
let units = if crate::textutil::si() { "SI (kB = 1000 bytes)" } else { "IEC (KiB = 1024 bytes)" };
|
let units = if crate::textutil::si() {
|
||||||
out.push(styled(format!("Sizes: {} · units: {units}", m.size_mode.name()), muted));
|
"SI (kB = 1000 bytes)"
|
||||||
|
} else {
|
||||||
|
"IEC (KiB = 1024 bytes)"
|
||||||
|
};
|
||||||
|
out.push(styled(
|
||||||
|
format!("Sizes: {} · units: {units}", m.size_mode.name()),
|
||||||
|
muted,
|
||||||
|
));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +524,9 @@ struct SaveModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_save_modal(tr: &Tree) -> Box<dyn Modal> {
|
pub(crate) fn new_save_modal(tr: &Tree) -> Box<dyn Modal> {
|
||||||
let mut md = SaveModal { ed: LineEditor::default() };
|
let mut md = SaveModal {
|
||||||
|
ed: LineEditor::default(),
|
||||||
|
};
|
||||||
md.ed.set(&default_snapshot_name(&tr.stats.root));
|
md.ed.set(&default_snapshot_name(&tr.stats.root));
|
||||||
Box::new(md)
|
Box::new(md)
|
||||||
}
|
}
|
||||||
@@ -399,7 +554,10 @@ impl Modal for SaveModal {
|
|||||||
}
|
}
|
||||||
if m.scanning {
|
if m.scanning {
|
||||||
out.push(txt(""));
|
out.push(txt(""));
|
||||||
out.push(styled("The scan is still running: the snapshot will be marked incomplete.", Style::new(t.warn, Default::default())));
|
out.push(styled(
|
||||||
|
"The scan is still running: the snapshot will be marked incomplete.",
|
||||||
|
Style::new(t.warn, Default::default()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-22
@@ -32,7 +32,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
/// Begins a scan (or returns a loaded tree with no scanner). Called again
|
/// Begins a scan (or returns a loaded tree with no scanner). Called again
|
||||||
/// on rescan.
|
/// on rescan.
|
||||||
pub type StartFn = Arc<dyn Fn(Cancel) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), crate::Error> + Send + Sync>;
|
pub type StartFn =
|
||||||
|
Arc<dyn Fn(Cancel) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), crate::Error> + Send + Sync>;
|
||||||
|
|
||||||
/// Configures the interactive session.
|
/// Configures the interactive session.
|
||||||
pub struct Options {
|
pub struct Options {
|
||||||
@@ -75,7 +76,11 @@ impl Key {
|
|||||||
_ if s.chars().count() == 1 => s.to_string(),
|
_ if s.chars().count() == 1 => s.to_string(),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
Key { name: s.to_string(), text, ctrl_alt: s.starts_with("ctrl+") || s.starts_with("alt+") }
|
Key {
|
||||||
|
name: s.to_string(),
|
||||||
|
text,
|
||||||
|
ctrl_alt: s.starts_with("ctrl+") || s.starts_with("alt+"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +96,40 @@ pub(crate) enum Msg {
|
|||||||
Size(i32, i32),
|
Size(i32, i32),
|
||||||
Tick,
|
Tick,
|
||||||
ScanDone(Arc<RwLock<Tree>>), // identifies the scan by its tree
|
ScanDone(Arc<RwLock<Tree>>), // identifies the scan by its tree
|
||||||
Filter { gen: u64, res: Option<FilterResult> },
|
Filter {
|
||||||
|
gen: u64,
|
||||||
|
res: Option<FilterResult>,
|
||||||
|
},
|
||||||
FilterDebounce(u64),
|
FilterDebounce(u64),
|
||||||
DupDone { finder: Arc<duplicate::Finder>, groups: Option<Vec<duplicate::Group>> },
|
DupDone {
|
||||||
TrashDone { tree: Arc<RwLock<Tree>>, id: NodeId, err: Option<String>, done: String },
|
finder: Arc<duplicate::Finder>,
|
||||||
SaveDone { path: String, err: Option<String> },
|
groups: Option<Vec<duplicate::Group>>,
|
||||||
|
},
|
||||||
|
TrashDone {
|
||||||
|
tree: Arc<RwLock<Tree>>,
|
||||||
|
id: NodeId,
|
||||||
|
err: Option<String>,
|
||||||
|
done: String,
|
||||||
|
},
|
||||||
|
SaveDone {
|
||||||
|
path: String,
|
||||||
|
err: Option<String>,
|
||||||
|
},
|
||||||
Key(Key),
|
Key(Key),
|
||||||
Click { x: i32, y: i32, button: Button },
|
Click {
|
||||||
Wheel { x: i32, y: i32, up: bool },
|
x: i32,
|
||||||
Motion { x: i32, y: i32 },
|
y: i32,
|
||||||
|
button: Button,
|
||||||
|
},
|
||||||
|
Wheel {
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
up: bool,
|
||||||
|
},
|
||||||
|
Motion {
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
},
|
||||||
Quit, // SIGTERM
|
Quit, // SIGTERM
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +269,13 @@ impl Model {
|
|||||||
{
|
{
|
||||||
theme.ascii = true;
|
theme.ascii = true;
|
||||||
}
|
}
|
||||||
let mut views = vec![View::Map, View::List(Default::default()), View::Ext(Default::default()),
|
let mut views = vec![
|
||||||
View::Top(Default::default()), View::Info(Default::default())];
|
View::Map,
|
||||||
|
View::List(Default::default()),
|
||||||
|
View::Ext(Default::default()),
|
||||||
|
View::Top(Default::default()),
|
||||||
|
View::Info(Default::default()),
|
||||||
|
];
|
||||||
if opts.diff.is_some() {
|
if opts.diff.is_some() {
|
||||||
views.push(View::Changes(Default::default()));
|
views.push(View::Changes(Default::default()));
|
||||||
}
|
}
|
||||||
@@ -298,7 +333,9 @@ impl Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn wait_scan(&self) -> Cmd {
|
fn wait_scan(&self) -> Cmd {
|
||||||
let Some(s) = &self.scanner else { return Cmd::None };
|
let Some(s) = &self.scanner else {
|
||||||
|
return Cmd::None;
|
||||||
|
};
|
||||||
let (rx, tree) = (s.done().clone(), s.tree.clone());
|
let (rx, tree) = (s.done().clone(), s.tree.clone());
|
||||||
Cmd::run(move || {
|
Cmd::run(move || {
|
||||||
let _ = rx.recv();
|
let _ = rx.recv();
|
||||||
@@ -307,7 +344,11 @@ impl Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_interval(&self) -> Duration {
|
pub fn refresh_interval(&self) -> Duration {
|
||||||
let hz = if self.opts.settings.refresh_hz <= 0 { 10 } else { self.opts.settings.refresh_hz };
|
let hz = if self.opts.settings.refresh_hz <= 0 {
|
||||||
|
10
|
||||||
|
} else {
|
||||||
|
self.opts.settings.refresh_hz
|
||||||
|
};
|
||||||
Duration::from_secs(1) / hz as u32
|
Duration::from_secs(1) / hz as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +362,9 @@ impl Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn needs_ticks(&self) -> bool {
|
fn needs_ticks(&self) -> bool {
|
||||||
self.scanning || Instant::now() < self.toast_until || self.dups.as_ref().is_some_and(|d| d.running)
|
self.scanning
|
||||||
|
|| Instant::now() < self.toast_until
|
||||||
|
|| self.dups.as_ref().is_some_and(|d| d.running)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a transient status message.
|
/// Shows a transient status message.
|
||||||
@@ -366,7 +409,10 @@ impl Model {
|
|||||||
self.ticking = false;
|
self.ticking = false;
|
||||||
if self.scanning {
|
if self.scanning {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
if self.last_data.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) {
|
if self
|
||||||
|
.last_data
|
||||||
|
.is_none_or(|t| t.elapsed() >= Duration::from_millis(400))
|
||||||
|
{
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
self.last_data = Some(Instant::now());
|
self.last_data = Some(Instant::now());
|
||||||
}
|
}
|
||||||
@@ -379,7 +425,10 @@ impl Model {
|
|||||||
let mut cmds = Vec::new();
|
let mut cmds = Vec::new();
|
||||||
if self.scanning
|
if self.scanning
|
||||||
&& self.search.query.is_some()
|
&& self.search.query.is_some()
|
||||||
&& self.search.applied_at.is_none_or(|t| t.elapsed() > Duration::from_secs(1))
|
&& self
|
||||||
|
.search
|
||||||
|
.applied_at
|
||||||
|
.is_none_or(|t| t.elapsed() > Duration::from_secs(1))
|
||||||
{
|
{
|
||||||
cmds.push(self.apply_filter());
|
cmds.push(self.apply_filter());
|
||||||
}
|
}
|
||||||
@@ -405,7 +454,12 @@ impl Model {
|
|||||||
Cmd::None
|
Cmd::None
|
||||||
}
|
}
|
||||||
Msg::DupDone { finder, groups } => self.dup_done(finder, groups),
|
Msg::DupDone { finder, groups } => self.dup_done(finder, groups),
|
||||||
Msg::TrashDone { tree, id, err, done } => self.trash_done(tree, id, err, done),
|
Msg::TrashDone {
|
||||||
|
tree,
|
||||||
|
id,
|
||||||
|
err,
|
||||||
|
done,
|
||||||
|
} => self.trash_done(tree, id, err, done),
|
||||||
Msg::SaveDone { path, err } => match err {
|
Msg::SaveDone { path, err } => match err {
|
||||||
Some(e) => self.warn(format!("Snapshot save failed: {}", textutil::sanitize(&e))),
|
Some(e) => self.warn(format!("Snapshot save failed: {}", textutil::sanitize(&e))),
|
||||||
None => self.info(format!("Saved snapshot {}", textutil::sanitize(&path))),
|
None => self.info(format!("Saved snapshot {}", textutil::sanitize(&path))),
|
||||||
@@ -454,13 +508,24 @@ impl Model {
|
|||||||
let (total, files, dirs, errs, el, cancelled) = {
|
let (total, files, dirs, errs, el, cancelled) = {
|
||||||
let t = self.tree.read().unwrap();
|
let t = self.tree.read().unwrap();
|
||||||
let st = &t.stats;
|
let st = &t.stats;
|
||||||
(t.node(0).total(self.size_mode), st.files + st.symlinks + st.others, st.dirs, st.total_errors(), st.elapsed(), st.cancelled)
|
(
|
||||||
|
t.node(0).total(self.size_mode),
|
||||||
|
st.files + st.symlinks + st.others,
|
||||||
|
st.dirs,
|
||||||
|
st.total_errors(),
|
||||||
|
st.elapsed(),
|
||||||
|
st.cancelled,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let mut cmds = Vec::new();
|
let mut cmds = Vec::new();
|
||||||
let rate = (files + dirs) as f64 / el.as_secs_f64().max(0.001);
|
let rate = (files + dirs) as f64 / el.as_secs_f64().max(0.001);
|
||||||
let mut msg = format!(
|
let mut msg = format!(
|
||||||
"✓ Scan complete {} · {} files · {} dirs · {} · {}/s",
|
"✓ Scan complete {} · {} files · {} dirs · {} · {}/s",
|
||||||
textutil::duration(el), textutil::count(files), textutil::count(dirs), textutil::size(total), textutil::count(rate as i64)
|
textutil::duration(el),
|
||||||
|
textutil::count(files),
|
||||||
|
textutil::count(dirs),
|
||||||
|
textutil::size(total),
|
||||||
|
textutil::count(rate as i64)
|
||||||
);
|
);
|
||||||
if cancelled {
|
if cancelled {
|
||||||
msg = "✗ Scan cancelled — totals are incomplete".into();
|
msg = "✗ Scan cancelled — totals are incomplete".into();
|
||||||
@@ -507,7 +572,10 @@ impl Model {
|
|||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cancel.cancel();
|
cancel.cancel();
|
||||||
return self.warn(format!("Rescan failed: {}", textutil::sanitize(&e.to_string())));
|
return self.warn(format!(
|
||||||
|
"Rescan failed: {}",
|
||||||
|
textutil::sanitize(&e.to_string())
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.scanning = s.is_some();
|
self.scanning = s.is_some();
|
||||||
@@ -536,13 +604,18 @@ impl Model {
|
|||||||
/// Re-zooms into the remembered directory once the new scan has
|
/// Re-zooms into the remembered directory once the new scan has
|
||||||
/// discovered it. Takes the read lock itself.
|
/// discovered it. Takes the read lock itself.
|
||||||
pub fn resolve_pending_zoom(&mut self) {
|
pub fn resolve_pending_zoom(&mut self) {
|
||||||
let Some(pz) = self.pending_zoom.clone() else { return };
|
let Some(pz) = self.pending_zoom.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let arc = self.tree.clone();
|
let arc = self.tree.clone();
|
||||||
let t = arc.read().unwrap();
|
let t = arc.read().unwrap();
|
||||||
let root = t.path_bytes(0);
|
let root = t.path_bytes(0);
|
||||||
let rel: &[u8] = if pz == root {
|
let rel: &[u8] = if pz == root {
|
||||||
b""
|
b""
|
||||||
} else if let Some(r) = pz.strip_prefix(root.as_slice()).filter(|r| root.ends_with(b"/") || r.starts_with(b"/")) {
|
} else if let Some(r) = pz
|
||||||
|
.strip_prefix(root.as_slice())
|
||||||
|
.filter(|r| root.ends_with(b"/") || r.starts_with(b"/"))
|
||||||
|
{
|
||||||
r
|
r
|
||||||
} else {
|
} else {
|
||||||
self.pending_zoom = None;
|
self.pending_zoom = None;
|
||||||
|
|||||||
+2
-1
@@ -32,7 +32,8 @@ impl Model {
|
|||||||
for i in (0..self.hits.len()).rev() {
|
for i in (0..self.hits.len()).rev() {
|
||||||
if self.hits[i].r.contains(x, y) {
|
if self.hits[i].r.contains(x, y) {
|
||||||
let key = (y as i64) << 32 | i as i64;
|
let key = (y as i64) << 32 | i as i64;
|
||||||
let double = self.last_click.is_some_and(|t| t.elapsed() < DOUBLE_CLICK) && self.last_click_i == key;
|
let double = self.last_click.is_some_and(|t| t.elapsed() < DOUBLE_CLICK)
|
||||||
|
&& self.last_click_i == key;
|
||||||
(self.last_click, self.last_click_i) = (Some(Instant::now()), key);
|
(self.last_click, self.last_click_i) = (Some(Instant::now()), key);
|
||||||
if double {
|
if double {
|
||||||
self.last_click_i = NO_SEL;
|
self.last_click_i = NO_SEL;
|
||||||
|
|||||||
+137
-27
@@ -1,9 +1,9 @@
|
|||||||
//! Frame composition: panel geometry, tabs, breadcrumbs, info and status
|
//! Frame composition: panel geometry, tabs, breadcrumbs, info and status
|
||||||
//! lines.
|
//! lines.
|
||||||
|
|
||||||
use super::canvas::{Canvas, Color, Depth, Style, Attr, BOLD, REVERSE, UNDERLINE};
|
use super::canvas::{Attr, Canvas, Color, Depth, Style, BOLD, REVERSE, UNDERLINE};
|
||||||
use super::model::{is_group, Hit, HitAct, Model, Profile};
|
use super::model::{is_group, Hit, HitAct, Model, Profile};
|
||||||
use super::{fmt_date, san, tw, trunc_left};
|
use super::{fmt_date, san, trunc_left, tw};
|
||||||
use crate::brand;
|
use crate::brand;
|
||||||
use crate::inventory::{Kind, Node, Tree, FLAG_SCANNED, NO_NODE};
|
use crate::inventory::{Kind, Node, Tree, FLAG_SCANNED, NO_NODE};
|
||||||
use crate::textutil;
|
use crate::textutil;
|
||||||
@@ -46,21 +46,46 @@ pub(crate) fn compute_geometry(w: i32, h: i32) -> (SizeClass, Geometry) {
|
|||||||
};
|
};
|
||||||
let mut y = 0;
|
let mut y = 0;
|
||||||
if class >= SizeClass::Medium {
|
if class >= SizeClass::Medium {
|
||||||
g.tabs = Rect { x: 0, y: 0, w, h: 1 };
|
g.tabs = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w,
|
||||||
|
h: 1,
|
||||||
|
};
|
||||||
y = 1;
|
y = 1;
|
||||||
}
|
}
|
||||||
g.crumbs = Rect { x: 0, y, w, h: 1 };
|
g.crumbs = Rect { x: 0, y, w, h: 1 };
|
||||||
y += 1;
|
y += 1;
|
||||||
let mut bottom = h - 1;
|
let mut bottom = h - 1;
|
||||||
g.stat = Rect { x: 0, y: h - 1, w, h: 1 };
|
g.stat = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: h - 1,
|
||||||
|
w,
|
||||||
|
h: 1,
|
||||||
|
};
|
||||||
if class >= SizeClass::Medium {
|
if class >= SizeClass::Medium {
|
||||||
g.info = Rect { x: 0, y: h - 2, w, h: 1 };
|
g.info = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: h - 2,
|
||||||
|
w,
|
||||||
|
h: 1,
|
||||||
|
};
|
||||||
bottom = h - 2;
|
bottom = h - 2;
|
||||||
}
|
}
|
||||||
g.main = Rect { x: 0, y, w, h: bottom - y };
|
g.main = Rect {
|
||||||
|
x: 0,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h: bottom - y,
|
||||||
|
};
|
||||||
if class == SizeClass::Large {
|
if class == SizeClass::Large {
|
||||||
let sw = 40.min(w / 5);
|
let sw = 40.min(w / 5);
|
||||||
g.side = Rect { x: 0, y, w: sw, h: g.main.h };
|
g.side = Rect {
|
||||||
|
x: 0,
|
||||||
|
y,
|
||||||
|
w: sw,
|
||||||
|
h: g.main.h,
|
||||||
|
};
|
||||||
(g.main.x, g.main.w) = (sw, w - sw);
|
(g.main.x, g.main.w) = (sw, w - sw);
|
||||||
}
|
}
|
||||||
(class, g)
|
(class, g)
|
||||||
@@ -114,7 +139,11 @@ impl Model {
|
|||||||
|
|
||||||
fn render_too_small(&self, cv: &mut Canvas) {
|
fn render_too_small(&self, cv: &mut Canvas) {
|
||||||
let st = self.theme.muted();
|
let st = self.theme.muted();
|
||||||
let lines = ["Terminal too small".to_string(), format!("{}×{} — need {}×{}", self.w, self.h, MIN_W, MIN_H), "q quits".to_string()];
|
let lines = [
|
||||||
|
"Terminal too small".to_string(),
|
||||||
|
format!("{}×{} — need {}×{}", self.w, self.h, MIN_W, MIN_H),
|
||||||
|
"q quits".to_string(),
|
||||||
|
];
|
||||||
let y0 = 0.max((self.h - lines.len() as i32) / 2);
|
let y0 = 0.max((self.h - lines.len() as i32) / 2);
|
||||||
for (i, l) in lines.iter().enumerate() {
|
for (i, l) in lines.iter().enumerate() {
|
||||||
cv.text_centered(0, y0 + i as i32, self.w, l, st);
|
cv.text_centered(0, y0 + i as i32, self.w, l, st);
|
||||||
@@ -133,8 +162,18 @@ impl Model {
|
|||||||
fn paint_tabs(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
|
fn paint_tabs(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
|
||||||
let t = self.theme.clone();
|
let t = self.theme.clone();
|
||||||
cv.fill(r, " ", t.header());
|
cv.fill(r, " ", t.header());
|
||||||
let badge = if t.ascii { format!(" {} ", brand::NAME) } else { format!(" ◧ {} ", brand::NAME) };
|
let badge = if t.ascii {
|
||||||
let mut x = cv.text(r.x, r.y, &badge, r.w, Style::new(t.text_on(t.accent), t.accent).with(BOLD | self.mono_rev()));
|
format!(" {} ", brand::NAME)
|
||||||
|
} else {
|
||||||
|
format!(" ◧ {} ", brand::NAME)
|
||||||
|
};
|
||||||
|
let mut x = cv.text(
|
||||||
|
r.x,
|
||||||
|
r.y,
|
||||||
|
&badge,
|
||||||
|
r.w,
|
||||||
|
Style::new(t.text_on(t.accent), t.accent).with(BOLD | self.mono_rev()),
|
||||||
|
);
|
||||||
x += 1;
|
x += 1;
|
||||||
let names: Vec<&'static str> = self.views.iter().map(|v| v.name()).collect();
|
let names: Vec<&'static str> = self.views.iter().map(|v| v.name()).collect();
|
||||||
for (i, name) in names.iter().enumerate() {
|
for (i, name) in names.iter().enumerate() {
|
||||||
@@ -148,7 +187,10 @@ impl Model {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let w = cv.text(x, r.y, &label, r.w - x, st);
|
let w = cv.text(x, r.y, &label, r.w - x, st);
|
||||||
self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::SetView(i) });
|
self.hits.push(Hit {
|
||||||
|
r: Rect { x, y: r.y, w, h: 1 },
|
||||||
|
act: HitAct::SetView(i),
|
||||||
|
});
|
||||||
x += w;
|
x += w;
|
||||||
}
|
}
|
||||||
// Right-aligned chips.
|
// Right-aligned chips.
|
||||||
@@ -177,7 +219,13 @@ impl Model {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
rx -= w;
|
rx -= w;
|
||||||
cv.text(rx, r.y, &s, w, Style::new(t.text_on(*bg), *bg).with(BOLD | self.mono_rev()));
|
cv.text(
|
||||||
|
rx,
|
||||||
|
r.y,
|
||||||
|
&s,
|
||||||
|
w,
|
||||||
|
Style::new(t.text_on(*bg), *bg).with(BOLD | self.mono_rev()),
|
||||||
|
);
|
||||||
rx -= 1;
|
rx -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,10 +254,20 @@ impl Model {
|
|||||||
right = format!(" {} ", textutil::size_compact(size));
|
right = format!(" {} ", textutil::size_compact(size));
|
||||||
rw = tw(&right);
|
rw = tw(&right);
|
||||||
}
|
}
|
||||||
cv.text_right(r.x, r.y, r.w, &right, Style::new(t.header_fg, t.header_bg).with(BOLD));
|
cv.text_right(
|
||||||
|
r.x,
|
||||||
|
r.y,
|
||||||
|
r.w,
|
||||||
|
&right,
|
||||||
|
Style::new(t.header_fg, t.header_bg).with(BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
let sep = if t.ascii { " > " } else { " › " };
|
let sep = if t.ascii { " > " } else { " › " };
|
||||||
let segs: Vec<(u32, String)> = tr.ancestors(self.zoom).into_iter().map(|id| (id, san(&tr.node(id).name))).collect();
|
let segs: Vec<(u32, String)> = tr
|
||||||
|
.ancestors(self.zoom)
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| (id, san(&tr.node(id).name)))
|
||||||
|
.collect();
|
||||||
let avail = r.w - rw - 2;
|
let avail = r.w - rw - 2;
|
||||||
// Drop leading segments until the rest fits; keep at least the last.
|
// Drop leading segments until the rest fits; keep at least the last.
|
||||||
let total = |from: usize| {
|
let total = |from: usize| {
|
||||||
@@ -231,17 +289,40 @@ impl Model {
|
|||||||
}
|
}
|
||||||
let mut x = r.x + 1;
|
let mut x = r.x + 1;
|
||||||
if from > 0 {
|
if from > 0 {
|
||||||
x += cv.text(x, r.y, &format!("…{sep}"), avail, Style::new(t.muted, t.header_bg));
|
x += cv.text(
|
||||||
|
x,
|
||||||
|
r.y,
|
||||||
|
&format!("…{sep}"),
|
||||||
|
avail,
|
||||||
|
Style::new(t.muted, t.header_bg),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for i in from..segs.len() {
|
for i in from..segs.len() {
|
||||||
if i > from {
|
if i > from {
|
||||||
x += cv.text(x, r.y, sep, r.x + 1 + avail - x, Style::new(t.faint, t.header_bg));
|
x += cv.text(
|
||||||
|
x,
|
||||||
|
r.y,
|
||||||
|
sep,
|
||||||
|
r.x + 1 + avail - x,
|
||||||
|
Style::new(t.faint, t.header_bg),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let last = i == segs.len() - 1;
|
let last = i == segs.len() - 1;
|
||||||
let st = if last { Style::new(t.fg, t.header_bg).with(BOLD) } else { Style::new(t.muted, t.header_bg) };
|
let st = if last {
|
||||||
let s = if last { trunc_left(&segs[i].1, 1.max(r.x + 1 + avail - x)) } else { segs[i].1.clone() };
|
Style::new(t.fg, t.header_bg).with(BOLD)
|
||||||
|
} else {
|
||||||
|
Style::new(t.muted, t.header_bg)
|
||||||
|
};
|
||||||
|
let s = if last {
|
||||||
|
trunc_left(&segs[i].1, 1.max(r.x + 1 + avail - x))
|
||||||
|
} else {
|
||||||
|
segs[i].1.clone()
|
||||||
|
};
|
||||||
let w = cv.text(x, r.y, &s, r.x + 1 + avail - x, st);
|
let w = cv.text(x, r.y, &s, r.x + 1 + avail - x, st);
|
||||||
self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::ZoomTo(segs[i].0) });
|
self.hits.push(Hit {
|
||||||
|
r: Rect { x, y: r.y, w, h: 1 },
|
||||||
|
act: HitAct::ZoomTo(segs[i].0),
|
||||||
|
});
|
||||||
x += w;
|
x += w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,9 +360,14 @@ impl Model {
|
|||||||
return "Nothing selected".into();
|
return "Nothing selected".into();
|
||||||
}
|
}
|
||||||
let n = tr.node(id);
|
let n = tr.node(id);
|
||||||
let mut parts = vec![format!("▸ {}", san(&n.name)), textutil::size(self.size_of(tr, id))];
|
let mut parts = vec![
|
||||||
|
format!("▸ {}", san(&n.name)),
|
||||||
|
textutil::size(self.size_of(tr, id)),
|
||||||
|
];
|
||||||
if n.parent != NO_NODE {
|
if n.parent != NO_NODE {
|
||||||
parts.push(textutil::percent(self.size_of(tr, id), self.size_of(tr, n.parent)) + " of parent");
|
parts.push(
|
||||||
|
textutil::percent(self.size_of(tr, id), self.size_of(tr, n.parent)) + " of parent",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if n.is_dir() {
|
if n.is_dir() {
|
||||||
parts.push(textutil::count(n.files as i64) + " files");
|
parts.push(textutil::count(n.files as i64) + " files");
|
||||||
@@ -308,8 +394,18 @@ impl Model {
|
|||||||
fn paint_status(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect, class: SizeClass) {
|
fn paint_status(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect, class: SizeClass) {
|
||||||
let t = self.theme.clone();
|
let t = self.theme.clone();
|
||||||
cv.fill(r, " ", t.status());
|
cv.fill(r, " ", t.status());
|
||||||
let help = if class == SizeClass::Small { " ? " } else { " ? help " };
|
let help = if class == SizeClass::Small {
|
||||||
let hw = cv.text(r.x + r.w - tw(help), r.y, help, r.w, Style::new(t.muted, t.status_bg));
|
" ? "
|
||||||
|
} else {
|
||||||
|
" ? help "
|
||||||
|
};
|
||||||
|
let hw = cv.text(
|
||||||
|
r.x + r.w - tw(help),
|
||||||
|
r.y,
|
||||||
|
help,
|
||||||
|
r.w,
|
||||||
|
Style::new(t.muted, t.status_bg),
|
||||||
|
);
|
||||||
let avail = r.w - hw - 1;
|
let avail = r.w - hw - 1;
|
||||||
if !self.toast.is_empty() {
|
if !self.toast.is_empty() {
|
||||||
cv.text(r.x + 1, r.y, &self.toast, avail - 1, self.toast_style);
|
cv.text(r.x + 1, r.y, &self.toast, avail - 1, self.toast_style);
|
||||||
@@ -320,8 +416,15 @@ impl Model {
|
|||||||
let files = st.files + st.symlinks + st.others;
|
let files = st.files + st.symlinks + st.others;
|
||||||
let mut parts: Vec<(String, Color)> = Vec::new();
|
let mut parts: Vec<(String, Color)> = Vec::new();
|
||||||
if self.scanning {
|
if self.scanning {
|
||||||
let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis();
|
let ms = std::time::SystemTime::now()
|
||||||
let frame = if t.ascii { "*" } else { SPINNER[(ms / 100) as usize % SPINNER.len()] };
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let frame = if t.ascii {
|
||||||
|
"*"
|
||||||
|
} else {
|
||||||
|
SPINNER[(ms / 100) as usize % SPINNER.len()]
|
||||||
|
};
|
||||||
parts.push((format!("{frame} scanning"), t.accent));
|
parts.push((format!("{frame} scanning"), t.accent));
|
||||||
} else if st.cancelled {
|
} else if st.cancelled {
|
||||||
parts.push(("✗ cancelled".into(), t.warn));
|
parts.push(("✗ cancelled".into(), t.warn));
|
||||||
@@ -334,8 +437,15 @@ impl Model {
|
|||||||
parts.push((textutil::size(root.total(self.size_mode)), t.status_fg));
|
parts.push((textutil::size(root.total(self.size_mode)), t.status_fg));
|
||||||
if self.scanning {
|
if self.scanning {
|
||||||
let el = st.elapsed();
|
let el = st.elapsed();
|
||||||
let p = self.scanner.as_ref().map(|s| s.progress()).unwrap_or_default();
|
let p = self
|
||||||
parts.push((textutil::count((p.entries as f64 / el.as_secs_f64().max(0.001)) as i64) + "/s", t.status_fg));
|
.scanner
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.progress())
|
||||||
|
.unwrap_or_default();
|
||||||
|
parts.push((
|
||||||
|
textutil::count((p.entries as f64 / el.as_secs_f64().max(0.001)) as i64) + "/s",
|
||||||
|
t.status_fg,
|
||||||
|
));
|
||||||
if class != SizeClass::Small {
|
if class != SizeClass::Small {
|
||||||
parts.push((format!("{} workers", p.workers), t.muted));
|
parts.push((format!("{} workers", p.workers), t.muted));
|
||||||
parts.push((format!("queue {}", textutil::count(p.pending)), t.muted));
|
parts.push((format!("queue {}", textutil::count(p.pending)), t.muted));
|
||||||
|
|||||||
+98
-18
@@ -11,7 +11,9 @@ use crate::brand;
|
|||||||
use crate::cancel::Cancel;
|
use crate::cancel::Cancel;
|
||||||
use crate::textutil;
|
use crate::textutil;
|
||||||
use crossbeam_channel::{after, select, Receiver, Sender};
|
use crossbeam_channel::{after, select, Receiver, Sender};
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
|
use crossterm::event::{
|
||||||
|
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
|
||||||
|
};
|
||||||
use crossterm::{cursor, execute, terminal};
|
use crossterm::{cursor, execute, terminal};
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
@@ -26,7 +28,8 @@ static TERMINATED: AtomicBool = AtomicBool::new(false);
|
|||||||
|
|
||||||
/// Mouse off (1000/1002/1003/1015/1006), bracketed paste off, cursor on,
|
/// Mouse off (1000/1002/1003/1015/1006), bracketed paste off, cursor on,
|
||||||
/// main screen.
|
/// main screen.
|
||||||
const RESTORE: &str = "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l";
|
const RESTORE: &str =
|
||||||
|
"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l";
|
||||||
|
|
||||||
fn write_raw(s: &str) {
|
fn write_raw(s: &str) {
|
||||||
let mut b = s.as_bytes();
|
let mut b = s.as_bytes();
|
||||||
@@ -84,7 +87,18 @@ fn detect_profile() -> Profile {
|
|||||||
if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") {
|
if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") {
|
||||||
return Profile::TrueColor;
|
return Profile::TrueColor;
|
||||||
}
|
}
|
||||||
if ["kitty", "alacritty", "wezterm", "ghostty", "foot", "contour", "rio"].iter().any(|t| term.contains(t)) {
|
if [
|
||||||
|
"kitty",
|
||||||
|
"alacritty",
|
||||||
|
"wezterm",
|
||||||
|
"ghostty",
|
||||||
|
"foot",
|
||||||
|
"contour",
|
||||||
|
"rio",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|t| term.contains(t))
|
||||||
|
{
|
||||||
return Profile::TrueColor;
|
return Profile::TrueColor;
|
||||||
}
|
}
|
||||||
if term.contains("256color") || !ct.is_empty() {
|
if term.contains("256color") || !ct.is_empty() {
|
||||||
@@ -100,7 +114,11 @@ pub fn run(opts: Options) -> Result<(), crate::Error> {
|
|||||||
let forced = forced_profile(&opts.color);
|
let forced = forced_profile(&opts.color);
|
||||||
if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" {
|
if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" {
|
||||||
cancel.cancel();
|
cancel.cancel();
|
||||||
return Err(format!("invalid --color {:?} (auto, truecolor, 256, 16, none)", opts.color).into());
|
return Err(format!(
|
||||||
|
"invalid --color {:?} (auto, truecolor, 256, 16, none)",
|
||||||
|
opts.color
|
||||||
|
)
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
let mouse = opts.mouse;
|
let mouse = opts.mouse;
|
||||||
let mut m = Model::new(opts, s, t, cancel.clone());
|
let mut m = Model::new(opts, s, t, cancel.clone());
|
||||||
@@ -111,7 +129,11 @@ pub fn run(opts: Options) -> Result<(), crate::Error> {
|
|||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
// Mirrors the scanner's guard: a worker panic aborts after the hook.
|
// Mirrors the scanner's guard: a worker panic aborts after the hook.
|
||||||
if std::panic::catch_unwind(|| panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")).is_err() {
|
if std::panic::catch_unwind(|| {
|
||||||
|
panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
std::process::abort();
|
std::process::abort();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -127,7 +149,13 @@ fn enter_terminal(mouse: bool) -> io::Result<()> {
|
|||||||
terminal::enable_raw_mode()?;
|
terminal::enable_raw_mode()?;
|
||||||
ACTIVE.store(true, Ordering::SeqCst);
|
ACTIVE.store(true, Ordering::SeqCst);
|
||||||
let mut out = io::stdout();
|
let mut out = io::stdout();
|
||||||
execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste, terminal::SetTitle(brand::NAME))?;
|
execute!(
|
||||||
|
out,
|
||||||
|
terminal::EnterAlternateScreen,
|
||||||
|
cursor::Hide,
|
||||||
|
event::EnableBracketedPaste,
|
||||||
|
terminal::SetTitle(brand::NAME)
|
||||||
|
)?;
|
||||||
if mouse {
|
if mouse {
|
||||||
execute!(out, event::EnableMouseCapture)?;
|
execute!(out, event::EnableMouseCapture)?;
|
||||||
}
|
}
|
||||||
@@ -140,7 +168,11 @@ fn convert_key(ev: KeyEvent) -> Option<Key> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let m = ev.modifiers;
|
let m = ev.modifiers;
|
||||||
let (ctrl, alt, shift) = (m.contains(KeyModifiers::CONTROL), m.contains(KeyModifiers::ALT), m.contains(KeyModifiers::SHIFT));
|
let (ctrl, alt, shift) = (
|
||||||
|
m.contains(KeyModifiers::CONTROL),
|
||||||
|
m.contains(KeyModifiers::ALT),
|
||||||
|
m.contains(KeyModifiers::SHIFT),
|
||||||
|
);
|
||||||
let mut prefix = String::new();
|
let mut prefix = String::new();
|
||||||
if ctrl {
|
if ctrl {
|
||||||
prefix += "ctrl+";
|
prefix += "ctrl+";
|
||||||
@@ -148,14 +180,37 @@ fn convert_key(ev: KeyEvent) -> Option<Key> {
|
|||||||
if alt {
|
if alt {
|
||||||
prefix += "alt+";
|
prefix += "alt+";
|
||||||
}
|
}
|
||||||
let named = |n: &str| Some(Key { name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }), text: String::new(), ctrl_alt: ctrl || alt });
|
let named = |n: &str| {
|
||||||
|
Some(Key {
|
||||||
|
name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }),
|
||||||
|
text: String::new(),
|
||||||
|
ctrl_alt: ctrl || alt,
|
||||||
|
})
|
||||||
|
};
|
||||||
match ev.code {
|
match ev.code {
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
let base = if c == ' ' { "space".to_string() } else if ctrl || alt { c.to_lowercase().collect() } else { c.to_string() };
|
let base = if c == ' ' {
|
||||||
let text = if ctrl || alt { String::new() } else { c.to_string() };
|
"space".to_string()
|
||||||
Some(Key { name: prefix + &base, text, ctrl_alt: ctrl || alt })
|
} else if ctrl || alt {
|
||||||
|
c.to_lowercase().collect()
|
||||||
|
} else {
|
||||||
|
c.to_string()
|
||||||
|
};
|
||||||
|
let text = if ctrl || alt {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
c.to_string()
|
||||||
|
};
|
||||||
|
Some(Key {
|
||||||
|
name: prefix + &base,
|
||||||
|
text,
|
||||||
|
ctrl_alt: ctrl || alt,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
KeyCode::BackTab => Some(Key { name: format!("{prefix}shift+tab"), ..Default::default() }),
|
KeyCode::BackTab => Some(Key {
|
||||||
|
name: format!("{prefix}shift+tab"),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
KeyCode::Enter => named("enter"),
|
KeyCode::Enter => named("enter"),
|
||||||
KeyCode::Esc => named("esc"),
|
KeyCode::Esc => named("esc"),
|
||||||
KeyCode::Tab => named("tab"),
|
KeyCode::Tab => named("tab"),
|
||||||
@@ -256,7 +311,9 @@ fn base64(b: &[u8]) -> String {
|
|||||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
|
let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
|
||||||
for c in b.chunks(3) {
|
for c in b.chunks(3) {
|
||||||
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
let n = (c[0] as u32) << 16
|
||||||
|
| (*c.get(1).unwrap_or(&0) as u32) << 8
|
||||||
|
| *c.get(2).unwrap_or(&0) as u32;
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
if i <= c.len() {
|
if i <= c.len() {
|
||||||
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
|
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||||
@@ -332,7 +389,14 @@ fn line_diff(old: &str, new: &str) -> Vec<(usize, std::ops::Range<usize>)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Screen {
|
impl Screen {
|
||||||
fn draw(&mut self, out: &mut impl Write, frame: &str, w: i32, h: i32, extra: &str) -> io::Result<()> {
|
fn draw(
|
||||||
|
&mut self,
|
||||||
|
out: &mut impl Write,
|
||||||
|
frame: &str,
|
||||||
|
w: i32,
|
||||||
|
h: i32,
|
||||||
|
extra: &str,
|
||||||
|
) -> io::Result<()> {
|
||||||
self.buf.clear();
|
self.buf.clear();
|
||||||
self.buf.push_str("\x1b[?2026h"); // synchronized output, where supported
|
self.buf.push_str("\x1b[?2026h"); // synchronized output, where supported
|
||||||
if (w, h) != self.size {
|
if (w, h) != self.size {
|
||||||
@@ -386,7 +450,11 @@ fn event_loop(m: &mut Model) -> io::Result<()> {
|
|||||||
std::thread::spawn(move || input_thread(tx, stop));
|
std::thread::spawn(move || input_thread(tx, stop));
|
||||||
}
|
}
|
||||||
let (w, h) = terminal::size()?;
|
let (w, h) = terminal::size()?;
|
||||||
let mut rt = Runtime { tx, timers: Vec::new(), out: String::new() };
|
let mut rt = Runtime {
|
||||||
|
tx,
|
||||||
|
timers: Vec::new(),
|
||||||
|
out: String::new(),
|
||||||
|
};
|
||||||
rt.exec(m.update(Msg::Size(w as i32, h as i32)));
|
rt.exec(m.update(Msg::Size(w as i32, h as i32)));
|
||||||
let init = m.init();
|
let init = m.init();
|
||||||
rt.exec(init);
|
rt.exec(init);
|
||||||
@@ -414,7 +482,9 @@ fn event_loop(m: &mut Model) -> io::Result<()> {
|
|||||||
// Fire due timers, then drain whatever else is queued before the next
|
// Fire due timers, then drain whatever else is queued before the next
|
||||||
// frame, so bursts (resize storms) cost one render.
|
// frame, so bursts (resize storms) cost one render.
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers).into_iter().partition(|t| t.0 <= now);
|
let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers)
|
||||||
|
.into_iter()
|
||||||
|
.partition(|t| t.0 <= now);
|
||||||
rt.timers = later;
|
rt.timers = later;
|
||||||
for (_, msg) in due {
|
for (_, msg) in due {
|
||||||
let c = m.update(msg);
|
let c = m.update(msg);
|
||||||
@@ -438,7 +508,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn base64_matches_rfc4648() {
|
fn base64_matches_rfc4648() {
|
||||||
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("foobar", "Zm9vYmFy")] {
|
for (i, o) in [
|
||||||
|
("", ""),
|
||||||
|
("f", "Zg=="),
|
||||||
|
("fo", "Zm8="),
|
||||||
|
("foo", "Zm9v"),
|
||||||
|
("foob", "Zm9vYg=="),
|
||||||
|
("foobar", "Zm9vYmFy"),
|
||||||
|
] {
|
||||||
assert_eq!(base64(i.as_bytes()), o);
|
assert_eq!(base64(i.as_bytes()), o);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,7 +527,10 @@ mod tests {
|
|||||||
s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap();
|
s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap();
|
||||||
out.clear();
|
out.clear();
|
||||||
s.draw(&mut out, "a\nX\nc", 1, 3, "").unwrap();
|
s.draw(&mut out, "a\nX\nc", 1, 3, "").unwrap();
|
||||||
assert_eq!(String::from_utf8(out).unwrap(), "\x1b[?2026h\x1b[2;1HX\x1b[?2026l");
|
assert_eq!(
|
||||||
|
String::from_utf8(out).unwrap(),
|
||||||
|
"\x1b[?2026h\x1b[2;1HX\x1b[?2026l"
|
||||||
|
);
|
||||||
// Within a line only the differing style runs are rewritten.
|
// Within a line only the differing style runs are rewritten.
|
||||||
let a = "\x1b[0;1mab\x1b[0;7m日x\x1b[0mcd\x1b[0m";
|
let a = "\x1b[0;1mab\x1b[0;7m日x\x1b[0mcd\x1b[0m";
|
||||||
let b = "\x1b[0;1mab\x1b[0;7m日y\x1b[0mcd\x1b[0m";
|
let b = "\x1b[0;1mab\x1b[0;7m日y\x1b[0mcd\x1b[0m";
|
||||||
|
|||||||
+52
-10
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use super::canvas::{Canvas, Style, BOLD};
|
use super::canvas::{Canvas, Style, BOLD};
|
||||||
use super::model::{Cmd, Key, Model, Msg};
|
use super::model::{Cmd, Key, Model, Msg};
|
||||||
use super::{tw, trunc};
|
use super::{trunc, tw};
|
||||||
use crate::cancel::Cancel;
|
use crate::cancel::Cancel;
|
||||||
use crate::filter::{self, FilterResult, Query};
|
use crate::filter::{self, FilterResult, Query};
|
||||||
use crate::inventory::Tree;
|
use crate::inventory::Tree;
|
||||||
@@ -74,7 +74,11 @@ impl LineEditor {
|
|||||||
if k.text.is_empty() || k.ctrl_alt {
|
if k.text.is_empty() || k.ctrl_alt {
|
||||||
return (false, false);
|
return (false, false);
|
||||||
}
|
}
|
||||||
let rs: Vec<char> = k.text.chars().filter(|&r| r >= ' ' && r != '\x7f').collect();
|
let rs: Vec<char> = k
|
||||||
|
.text
|
||||||
|
.chars()
|
||||||
|
.filter(|&r| r >= ' ' && r != '\x7f')
|
||||||
|
.collect();
|
||||||
if rs.is_empty() || self.buf.len() + rs.len() > 1024 {
|
if rs.is_empty() || self.buf.len() + rs.len() > 1024 {
|
||||||
return (false, true);
|
return (false, true);
|
||||||
}
|
}
|
||||||
@@ -174,7 +178,9 @@ impl Model {
|
|||||||
/// Evaluates the current query in the background. The tree is
|
/// Evaluates the current query in the background. The tree is
|
||||||
/// read-locked by the worker; stale results are discarded by generation.
|
/// read-locked by the worker; stale results are discarded by generation.
|
||||||
pub fn apply_filter(&mut self) -> Cmd {
|
pub fn apply_filter(&mut self) -> Cmd {
|
||||||
let Some(q) = self.search.query.clone() else { return Cmd::None };
|
let Some(q) = self.search.query.clone() else {
|
||||||
|
return Cmd::None;
|
||||||
|
};
|
||||||
if let Some(c) = self.search.cancel.take() {
|
if let Some(c) = self.search.cancel.take() {
|
||||||
c.cancel();
|
c.cancel();
|
||||||
}
|
}
|
||||||
@@ -235,7 +241,11 @@ impl Model {
|
|||||||
} else if s.busy {
|
} else if s.busy {
|
||||||
"filtering…".into()
|
"filtering…".into()
|
||||||
} else if let Some(res) = &s.result {
|
} else if let Some(res) = &s.result {
|
||||||
format!("{} matches · {}", textutil::count(res.count), textutil::size(res.total))
|
format!(
|
||||||
|
"{} matches · {}",
|
||||||
|
textutil::count(res.count),
|
||||||
|
textutil::size(res.total)
|
||||||
|
)
|
||||||
} else if s.ed.buf.is_empty() {
|
} else if s.ed.buf.is_empty() {
|
||||||
"name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear".into()
|
"name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear".into()
|
||||||
} else {
|
} else {
|
||||||
@@ -245,14 +255,28 @@ impl Model {
|
|||||||
let avail = r.w - (x - r.x) - sw - 3;
|
let avail = r.w - (x - r.x) - sw - 3;
|
||||||
// Scroll the text so the cursor stays visible.
|
// Scroll the text so the cursor stays visible.
|
||||||
let text = &s.ed.buf;
|
let text = &s.ed.buf;
|
||||||
let start = if s.ed.cur as i32 > avail - 1 { (s.ed.cur as i32 - avail + 1) as usize } else { 0 };
|
let start = if s.ed.cur as i32 > avail - 1 {
|
||||||
|
(s.ed.cur as i32 - avail + 1) as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
let end = text.len().min(start + avail.max(0) as usize).max(start);
|
let end = text.len().min(start + avail.max(0) as usize).max(start);
|
||||||
let vis: String = text[start..end].iter().collect();
|
let vis: String = text[start..end].iter().collect();
|
||||||
cv.text(x, r.y, &textutil::sanitize(&vis), avail, st);
|
cv.text(x, r.y, &textutil::sanitize(&vis), avail, st);
|
||||||
let before: String = text[start..s.ed.cur].iter().collect();
|
let before: String = text[start..s.ed.cur].iter().collect();
|
||||||
let cx = x + tw(&textutil::sanitize(&before));
|
let cx = x + tw(&textutil::sanitize(&before));
|
||||||
let under = if s.ed.cur < text.len() { textutil::sanitize(&text[s.ed.cur].to_string()) } else { " ".into() };
|
let under = if s.ed.cur < text.len() {
|
||||||
cv.text(cx, r.y, &under, 2, Style::new(t.panel_bg, t.fg).with(self.mono_rev()));
|
textutil::sanitize(&text[s.ed.cur].to_string())
|
||||||
|
} else {
|
||||||
|
" ".into()
|
||||||
|
};
|
||||||
|
cv.text(
|
||||||
|
cx,
|
||||||
|
r.y,
|
||||||
|
&under,
|
||||||
|
2,
|
||||||
|
Style::new(t.panel_bg, t.fg).with(self.mono_rev()),
|
||||||
|
);
|
||||||
cv.text_right(r.x, r.y, r.w - 1, &trunc(&status, sw), status_st);
|
cv.text_right(r.x, r.y, r.w - 1, &trunc(&status, sw), status_st);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +284,21 @@ impl Model {
|
|||||||
let t = &self.theme;
|
let t = &self.theme;
|
||||||
let s = &self.search;
|
let s = &self.search;
|
||||||
let mut x = r.x + 1;
|
let mut x = r.x + 1;
|
||||||
x += cv.text(x, r.y, " FILTER ", 8, Style::new(t.text_on(t.warn), t.warn).with(BOLD | self.mono_rev()));
|
x += cv.text(
|
||||||
|
x,
|
||||||
|
r.y,
|
||||||
|
" FILTER ",
|
||||||
|
8,
|
||||||
|
Style::new(t.text_on(t.warn), t.warn).with(BOLD | self.mono_rev()),
|
||||||
|
);
|
||||||
let q = s.query.as_ref().map(|q| q.to_string()).unwrap_or_default();
|
let q = s.query.as_ref().map(|q| q.to_string()).unwrap_or_default();
|
||||||
x += cv.text(x + 1, r.y, &textutil::sanitize(&q), r.w / 2, Style::new(t.fg, t.panel_bg).with(BOLD)) + 1;
|
x += cv.text(
|
||||||
|
x + 1,
|
||||||
|
r.y,
|
||||||
|
&textutil::sanitize(&q),
|
||||||
|
r.w / 2,
|
||||||
|
Style::new(t.fg, t.panel_bg).with(BOLD),
|
||||||
|
) + 1;
|
||||||
let mut info = "filtering…".to_string();
|
let mut info = "filtering…".to_string();
|
||||||
if let (Some(res), false) = (&s.result, s.busy) {
|
if let (Some(res), false) = (&s.result, s.busy) {
|
||||||
info = format!(
|
info = format!(
|
||||||
@@ -272,6 +308,12 @@ impl Model {
|
|||||||
textutil::size(tr.node(0).total(self.size_mode))
|
textutil::size(tr.node(0).total(self.size_mode))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
cv.text(x + 2, r.y, &info, r.x + r.w - x - 3, Style::new(t.muted, t.panel_bg));
|
cv.text(
|
||||||
|
x + 2,
|
||||||
|
r.y,
|
||||||
|
&info,
|
||||||
|
r.x + r.w - x - 3,
|
||||||
|
Style::new(t.muted, t.panel_bg),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-9
@@ -15,7 +15,16 @@ impl Model {
|
|||||||
let t = self.theme.clone();
|
let t = self.theme.clone();
|
||||||
let ps = t.panel();
|
let ps = t.panel();
|
||||||
cv.fill(r, " ", ps);
|
cv.fill(r, " ", ps);
|
||||||
cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, "│", Style::new(t.faint, t.panel_bg));
|
cv.fill(
|
||||||
|
Rect {
|
||||||
|
x: r.x + r.w - 1,
|
||||||
|
y: r.y,
|
||||||
|
w: 1,
|
||||||
|
h: r.h,
|
||||||
|
},
|
||||||
|
"│",
|
||||||
|
Style::new(t.faint, t.panel_bg),
|
||||||
|
);
|
||||||
let w = r.w - 3;
|
let w = r.w - 3;
|
||||||
let x = r.x + 1;
|
let x = r.x + 1;
|
||||||
let mut y = r.y + 1;
|
let mut y = r.y + 1;
|
||||||
@@ -45,21 +54,43 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
y += 1;
|
y += 1;
|
||||||
kvl(cv, &mut y, "Size", &textutil::size(self.size_of(tr, self.zoom)), val);
|
kvl(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Size",
|
||||||
|
&textutil::size(self.size_of(tr, self.zoom)),
|
||||||
|
val,
|
||||||
|
);
|
||||||
kvl(cv, &mut y, "Logical", &textutil::size(z.tot_size), plain);
|
kvl(cv, &mut y, "Logical", &textutil::size(z.tot_size), plain);
|
||||||
kvl(cv, &mut y, "Allocated", &textutil::size(z.tot_alloc), plain);
|
kvl(cv, &mut y, "Allocated", &textutil::size(z.tot_alloc), plain);
|
||||||
kvl(cv, &mut y, "Files", &textutil::count(z.files as i64), plain);
|
kvl(cv, &mut y, "Files", &textutil::count(z.files as i64), plain);
|
||||||
kvl(cv, &mut y, "Directories", &textutil::count(z.dirs as i64), plain);
|
kvl(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Directories",
|
||||||
|
&textutil::count(z.dirs as i64),
|
||||||
|
plain,
|
||||||
|
);
|
||||||
if z.errors > 0 {
|
if z.errors > 0 {
|
||||||
kvl(cv, &mut y, "Errors", &textutil::count(z.errors as i64), Style::new(t.err, t.panel_bg).with(BOLD));
|
kvl(
|
||||||
|
cv,
|
||||||
|
&mut y,
|
||||||
|
"Errors",
|
||||||
|
&textutil::count(z.errors as i64),
|
||||||
|
Style::new(t.err, t.panel_bg).with(BOLD),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
y += 1;
|
y += 1;
|
||||||
|
|
||||||
// Category breakdown with proportional bars.
|
// Category breakdown with proportional bars.
|
||||||
if let Some(cs) = tr.cat_sizes(self.zoom) {
|
if let Some(cs) = tr.cat_sizes(self.zoom) {
|
||||||
head(cv, &mut y, "CATEGORIES");
|
head(cv, &mut y, "CATEGORIES");
|
||||||
let mut cats: Vec<(Category, i64)> =
|
let mut cats: Vec<(Category, i64)> = cs
|
||||||
cs.iter().enumerate().filter(|(_, &v)| v > 0).map(|(i, &v)| (Category::from_u8(i as u8), v)).collect();
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, &v)| v > 0)
|
||||||
|
.map(|(i, &v)| (Category::from_u8(i as u8), v))
|
||||||
|
.collect();
|
||||||
let total: i64 = cats.iter().map(|c| c.1).sum();
|
let total: i64 = cats.iter().map(|c| c.1).sum();
|
||||||
cats.sort_by(|a, b| b.1.cmp(&a.1));
|
cats.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
for &(c, v) in &cats {
|
for &(c, v) in &cats {
|
||||||
@@ -70,7 +101,13 @@ impl Model {
|
|||||||
let sw = if t.mono || t.ascii { " " } else { "██ " };
|
let sw = if t.mono || t.ascii { " " } else { "██ " };
|
||||||
cv.text(x, y, sw, 3, Style::new(col, t.panel_bg));
|
cv.text(x, y, sw, 3, Style::new(col, t.panel_bg));
|
||||||
cv.text(x + 3, y, c.name(), w - 3, plain);
|
cv.text(x + 3, y, c.name(), w - 3, plain);
|
||||||
cv.text_right(x, y, w, &textutil::percent(v, total), Style::new(t.muted, t.panel_bg));
|
cv.text_right(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
&textutil::percent(v, total),
|
||||||
|
Style::new(t.muted, t.panel_bg),
|
||||||
|
);
|
||||||
y += 1;
|
y += 1;
|
||||||
self.paint_bar(cv, x + 3, y, w - 3, v as f64 / cats[0].1 as f64, col, false);
|
self.paint_bar(cv, x + 3, y, w - 3, v as f64 / cats[0].1 as f64, col, false);
|
||||||
y += 1;
|
y += 1;
|
||||||
@@ -84,7 +121,10 @@ impl Model {
|
|||||||
let mut desc = Vec::new();
|
let mut desc = Vec::new();
|
||||||
if is_group(self.sel) {
|
if is_group(self.sel) {
|
||||||
if let Some(g) = self.group_info(self.sel) {
|
if let Some(g) = self.group_info(self.sel) {
|
||||||
desc.push(format!("{} smaller items", textutil::count(g.ids().len() as i64)));
|
desc.push(format!(
|
||||||
|
"{} smaller items",
|
||||||
|
textutil::count(g.ids().len() as i64)
|
||||||
|
));
|
||||||
desc.push(textutil::size(g.size));
|
desc.push(textutil::size(g.size));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -92,7 +132,11 @@ impl Model {
|
|||||||
if id != NO_NODE {
|
if id != NO_NODE {
|
||||||
let n = tr.node(id);
|
let n = tr.node(id);
|
||||||
desc.extend(wrap(&san(&n.name), w));
|
desc.extend(wrap(&san(&n.name), w));
|
||||||
desc.push(textutil::size(self.size_of(tr, id)) + " · " + &textutil::percent(self.size_of(tr, id), self.size_of(tr, self.zoom)));
|
desc.push(
|
||||||
|
textutil::size(self.size_of(tr, id))
|
||||||
|
+ " · "
|
||||||
|
+ &textutil::percent(self.size_of(tr, id), self.size_of(tr, self.zoom)),
|
||||||
|
);
|
||||||
if n.is_dir() {
|
if n.is_dir() {
|
||||||
desc.push(textutil::count(n.files as i64) + " files");
|
desc.push(textutil::count(n.files as i64) + " files");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+71
-10
@@ -16,7 +16,12 @@ pub(crate) struct Column {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) const fn col(title: &'static str, width: i32, right: bool, clip_left: bool) -> Column {
|
pub(crate) const fn col(title: &'static str, width: i32, right: bool, clip_left: bool) -> Column {
|
||||||
Column { title, width, right, clip_left }
|
Column {
|
||||||
|
title,
|
||||||
|
width,
|
||||||
|
right,
|
||||||
|
clip_left,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One rendered row: cell strings plus optional per-cell styles.
|
/// One rendered row: cell strings plus optional per-cell styles.
|
||||||
@@ -69,7 +74,15 @@ impl Table {
|
|||||||
self.cursor.max(0) as usize
|
self.cursor.max(0) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paint(&mut self, m: &mut Model, cv: &mut Canvas, r: Rect, cols: &[Column], rows: &[TableRow], title: &str) {
|
pub fn paint(
|
||||||
|
&mut self,
|
||||||
|
m: &mut Model,
|
||||||
|
cv: &mut Canvas,
|
||||||
|
r: Rect,
|
||||||
|
cols: &[Column],
|
||||||
|
rows: &[TableRow],
|
||||||
|
title: &str,
|
||||||
|
) {
|
||||||
let t = m.theme.clone();
|
let t = m.theme.clone();
|
||||||
cv.fill(r, " ", t.base());
|
cv.fill(r, " ", t.base());
|
||||||
if r.h < 3 {
|
if r.h < 3 {
|
||||||
@@ -77,7 +90,13 @@ impl Table {
|
|||||||
}
|
}
|
||||||
let mut y = r.y;
|
let mut y = r.y;
|
||||||
if !title.is_empty() {
|
if !title.is_empty() {
|
||||||
cv.text(r.x + 1, y, title, r.w - 2, Style::new(t.accent, t.bg).with(BOLD));
|
cv.text(
|
||||||
|
r.x + 1,
|
||||||
|
y,
|
||||||
|
title,
|
||||||
|
r.w - 2,
|
||||||
|
Style::new(t.accent, t.bg).with(BOLD),
|
||||||
|
);
|
||||||
y += 1;
|
y += 1;
|
||||||
}
|
}
|
||||||
// Resolve flexible width.
|
// Resolve flexible width.
|
||||||
@@ -88,7 +107,11 @@ impl Table {
|
|||||||
let hdr = Style::new(t.muted, t.bg).with(BOLD);
|
let hdr = Style::new(t.muted, t.bg).with(BOLD);
|
||||||
for c in cols {
|
for c in cols {
|
||||||
let w = width(c);
|
let w = width(c);
|
||||||
let s = if c.right { pad_left(c.title, w) } else { c.title.to_string() };
|
let s = if c.right {
|
||||||
|
pad_left(c.title, w)
|
||||||
|
} else {
|
||||||
|
c.title.to_string()
|
||||||
|
};
|
||||||
cv.text(x, y, &s, w, hdr);
|
cv.text(x, y, &s, w, hdr);
|
||||||
x += w + 1;
|
x += w + 1;
|
||||||
}
|
}
|
||||||
@@ -114,7 +137,16 @@ impl Table {
|
|||||||
let mut base = t.base();
|
let mut base = t.base();
|
||||||
if sel {
|
if sel {
|
||||||
base = Style::new(t.text_on(t.sel), t.sel).with(BOLD | m.mono_rev());
|
base = Style::new(t.text_on(t.sel), t.sel).with(BOLD | m.mono_rev());
|
||||||
cv.fill(Rect { x: r.x, y, w: r.w, h: 1 }, " ", base);
|
cv.fill(
|
||||||
|
Rect {
|
||||||
|
x: r.x,
|
||||||
|
y,
|
||||||
|
w: r.w,
|
||||||
|
h: 1,
|
||||||
|
},
|
||||||
|
" ",
|
||||||
|
base,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut x = r.x + 1;
|
let mut x = r.x + 1;
|
||||||
for (ci, c) in cols.iter().enumerate() {
|
for (ci, c) in cols.iter().enumerate() {
|
||||||
@@ -135,7 +167,15 @@ impl Table {
|
|||||||
}
|
}
|
||||||
x += w + 1;
|
x += w + 1;
|
||||||
}
|
}
|
||||||
m.hits.push(Hit { r: Rect { x: r.x, y, w: r.w, h: 1 }, act: HitAct::Row(i as usize) });
|
m.hits.push(Hit {
|
||||||
|
r: Rect {
|
||||||
|
x: r.x,
|
||||||
|
y,
|
||||||
|
w: r.w,
|
||||||
|
h: 1,
|
||||||
|
},
|
||||||
|
act: HitAct::Row(i as usize),
|
||||||
|
});
|
||||||
y += 1;
|
y += 1;
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
@@ -143,7 +183,11 @@ impl Table {
|
|||||||
cv.text_centered(r.x, r.y + r.h / 2, r.w, "Nothing to show", t.muted());
|
cv.text_centered(r.x, r.y + r.h / 2, r.w, "Nothing to show", t.muted());
|
||||||
}
|
}
|
||||||
if n > self.height && self.height > 0 {
|
if n > self.height && self.height > 0 {
|
||||||
let pos = format!("{}/{}", textutil::count(self.cursor as i64 + 1), textutil::count(n as i64));
|
let pos = format!(
|
||||||
|
"{}/{}",
|
||||||
|
textutil::count(self.cursor as i64 + 1),
|
||||||
|
textutil::count(n as i64)
|
||||||
|
);
|
||||||
cv.text_right(r.x, r.y, r.w - 1, &pos, t.muted());
|
cv.text_right(r.x, r.y, r.w - 1, &pos, t.muted());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,10 +196,23 @@ impl Table {
|
|||||||
impl Model {
|
impl Model {
|
||||||
/// Draws a proportional bar with eighth-block precision.
|
/// Draws a proportional bar with eighth-block precision.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn paint_bar(&self, cv: &mut Canvas, x: i32, y: i32, w: i32, frac: f64, col: Color, sel: bool) {
|
pub fn paint_bar(
|
||||||
|
&self,
|
||||||
|
cv: &mut Canvas,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
w: i32,
|
||||||
|
frac: f64,
|
||||||
|
col: Color,
|
||||||
|
sel: bool,
|
||||||
|
) {
|
||||||
let t = &self.theme;
|
let t = &self.theme;
|
||||||
let col = if col.is_default() { t.accent } else { col };
|
let col = if col.is_default() { t.accent } else { col };
|
||||||
let bg = if sel { t.sel.mix(rgb(0, 0, 0), 0.25) } else { t.bg.mix(t.fg, 0.08) };
|
let bg = if sel {
|
||||||
|
t.sel.mix(rgb(0, 0, 0), 0.25)
|
||||||
|
} else {
|
||||||
|
t.bg.mix(t.fg, 0.08)
|
||||||
|
};
|
||||||
let mut eighths = (frac * (w * 8) as f64 + 0.5) as i64;
|
let mut eighths = (frac * (w * 8) as f64 + 0.5) as i64;
|
||||||
const PARTS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
const PARTS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
||||||
for i in 0..w {
|
for i in 0..w {
|
||||||
@@ -170,7 +227,11 @@ impl Model {
|
|||||||
ch = if eighths >= 4 { "#" } else { " " };
|
ch = if eighths >= 4 { "#" } else { " " };
|
||||||
}
|
}
|
||||||
eighths -= 8;
|
eighths -= 8;
|
||||||
let st = if t.mono { Style::default() } else { Style::new(col, bg) };
|
let st = if t.mono {
|
||||||
|
Style::default()
|
||||||
|
} else {
|
||||||
|
Style::new(col, bg)
|
||||||
|
};
|
||||||
cv.text(x + i, y, ch, 1, st);
|
cv.text(x + i, y, ch, 1, st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+209
-41
@@ -1,11 +1,11 @@
|
|||||||
//! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go).
|
//! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go).
|
||||||
|
|
||||||
use super::bench::opts_for;
|
use super::bench::opts_for;
|
||||||
use super::color256::{quant16, quant256};
|
|
||||||
use super::canvas::rgb;
|
use super::canvas::rgb;
|
||||||
|
use super::color256::{quant16, quant256};
|
||||||
use super::mapview::TOP_MIN_AREA;
|
use super::mapview::TOP_MIN_AREA;
|
||||||
use super::model::*;
|
|
||||||
use super::modals::new_info_modal;
|
use super::modals::new_info_modal;
|
||||||
|
use super::model::*;
|
||||||
use crate::cancel::Cancel;
|
use crate::cancel::Cancel;
|
||||||
use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED};
|
use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED};
|
||||||
use crate::scan;
|
use crate::scan;
|
||||||
@@ -39,7 +39,12 @@ fn test_tree() -> Tree {
|
|||||||
};
|
};
|
||||||
let backups = add_dir(&mut t, 0, b"backups");
|
let backups = add_dir(&mut t, 0, b"backups");
|
||||||
for i in 0..40i64 {
|
for i in 0..40i64 {
|
||||||
file(&mut t, backups, format!("dump-{i:02}.tar.gz").as_bytes(), (1 + i) << 30);
|
file(
|
||||||
|
&mut t,
|
||||||
|
backups,
|
||||||
|
format!("dump-{i:02}.tar.gz").as_bytes(),
|
||||||
|
(1 + i) << 30,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let vms = add_dir(&mut t, 0, b"vms");
|
let vms = add_dir(&mut t, 0, b"vms");
|
||||||
file(&mut t, vms, b"ubuntu.qcow2", 86 << 30);
|
file(&mut t, vms, b"ubuntu.qcow2", 86 << 30);
|
||||||
@@ -47,7 +52,12 @@ fn test_tree() -> Tree {
|
|||||||
let media = add_dir(&mut t, 0, b"media");
|
let media = add_dir(&mut t, 0, b"media");
|
||||||
let photos = add_dir(&mut t, media, "photos 日本語".as_bytes());
|
let photos = add_dir(&mut t, media, "photos 日本語".as_bytes());
|
||||||
for i in 0..300i64 {
|
for i in 0..300i64 {
|
||||||
file(&mut t, photos, format!("img{i:03}.jpg").as_bytes(), (i + 1) << 20);
|
file(
|
||||||
|
&mut t,
|
||||||
|
photos,
|
||||||
|
format!("img{i:03}.jpg").as_bytes(),
|
||||||
|
(i + 1) << 20,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
file(&mut t, media, HOSTILE, 9 << 30);
|
file(&mut t, media, HOSTILE, 9 << 30);
|
||||||
file(&mut t, media, "👨👩👧 family.mp4".as_bytes(), 12 << 30);
|
file(&mut t, media, "👨👩👧 family.mp4".as_bytes(), 12 << 30);
|
||||||
@@ -114,7 +124,12 @@ fn check_frame(m: &mut Model, w: i32, h: i32) -> String {
|
|||||||
"terminal control sequence from a filename leaked into the frame"
|
"terminal control sequence from a filename leaked into the frame"
|
||||||
);
|
);
|
||||||
let lines: Vec<&str> = content.split('\n').collect();
|
let lines: Vec<&str> = content.split('\n').collect();
|
||||||
assert_eq!(lines.len() as i32, h, "{w}x{h}: frame has {} lines", lines.len());
|
assert_eq!(
|
||||||
|
lines.len() as i32,
|
||||||
|
h,
|
||||||
|
"{w}x{h}: frame has {} lines",
|
||||||
|
lines.len()
|
||||||
|
);
|
||||||
for (i, l) in lines.iter().enumerate() {
|
for (i, l) in lines.iter().enumerate() {
|
||||||
let got = textutil::width(&strip(l)) as i32;
|
let got = textutil::width(&strip(l)) as i32;
|
||||||
assert_eq!(got, w, "{w}x{h}: line {i} has width {got}: {:?}", strip(l));
|
assert_eq!(got, w, "{w}x{h}: line {i} has width {got}: {:?}", strip(l));
|
||||||
@@ -135,7 +150,10 @@ fn find(m: &Model, name: &[u8]) -> NodeId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn find_block(bs: &[Block], id: i64) -> Block {
|
fn find_block(bs: &[Block], id: i64) -> Block {
|
||||||
bs.iter().copied().find(|b| b.id == id).unwrap_or(Block { id: 0, rect: Default::default() })
|
bs.iter().copied().find(|b| b.id == id).unwrap_or(Block {
|
||||||
|
id: 0,
|
||||||
|
rect: Default::default(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -151,19 +169,39 @@ fn resize_transitions_preserve_state() {
|
|||||||
check_frame(&mut m, 120, 40);
|
check_frame(&mut m, 120, 40);
|
||||||
send(&mut m, vec![key("right")]);
|
send(&mut m, vec![key("right")]);
|
||||||
let sel = m.sel;
|
let sel = m.sel;
|
||||||
for (w, h) in [(80, 24), (120, 40), (300, 80), (70, 20), (180, 50), (40, 10), (39, 9), (200, 60)] {
|
for (w, h) in [
|
||||||
|
(80, 24),
|
||||||
|
(120, 40),
|
||||||
|
(300, 80),
|
||||||
|
(70, 20),
|
||||||
|
(180, 50),
|
||||||
|
(40, 10),
|
||||||
|
(39, 9),
|
||||||
|
(200, 60),
|
||||||
|
] {
|
||||||
send(&mut m, vec![size(w, h)]);
|
send(&mut m, vec![size(w, h)]);
|
||||||
check_frame(&mut m, w, h);
|
check_frame(&mut m, w, h);
|
||||||
assert_eq!(m.zoom, media, "{w}x{h}: zoom lost");
|
assert_eq!(m.zoom, media, "{w}x{h}: zoom lost");
|
||||||
assert!(m.sel == sel || is_group(m.sel), "{w}x{h}: selection changed from {sel} to {}", m.sel);
|
assert!(
|
||||||
|
m.sel == sel || is_group(m.sel),
|
||||||
|
"{w}x{h}: selection changed from {sel} to {}",
|
||||||
|
m.sel
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// The layout must be recomputed for the new geometry, not stretched.
|
// The layout must be recomputed for the new geometry, not stretched.
|
||||||
let l = m.tm.clone().unwrap();
|
let l = m.tm.clone().unwrap();
|
||||||
assert!(l.rect.w <= 200 && l.rect.h <= 60, "layout not recomputed for final size: {:?}", l.rect);
|
assert!(
|
||||||
|
l.rect.w <= 200 && l.rect.h <= 60,
|
||||||
|
"layout not recomputed for final size: {:?}",
|
||||||
|
l.rect
|
||||||
|
);
|
||||||
let r = l.rect;
|
let r = l.rect;
|
||||||
for b in &l.blocks {
|
for b in &l.blocks {
|
||||||
let br = b.rect;
|
let br = b.rect;
|
||||||
assert!(br.x >= r.x && br.y >= r.y && br.x + br.w <= r.x + r.w && br.y + br.h <= r.y + r.h, "block {b:?} outside treemap {r:?}");
|
assert!(
|
||||||
|
br.x >= r.x && br.y >= r.y && br.x + br.w <= r.x + r.w && br.y + br.h <= r.y + r.h,
|
||||||
|
"block {b:?} outside treemap {r:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +210,9 @@ fn resize_storm() {
|
|||||||
let mut m = new_test_model();
|
let mut m = new_test_model();
|
||||||
let mut rnd = 1u64;
|
let mut rnd = 1u64;
|
||||||
let mut intn = |n: u64| {
|
let mut intn = |n: u64| {
|
||||||
rnd = rnd.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
rnd = rnd
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
(rnd >> 33) % n
|
(rnd >> 33) % n
|
||||||
};
|
};
|
||||||
send(&mut m, vec![size(120, 40)]);
|
send(&mut m, vec![size(120, 40)]);
|
||||||
@@ -199,9 +239,16 @@ fn modal_survives_resize() {
|
|||||||
for (w, h) in [(120, 40), (60, 14), (41, 10), (300, 80), (45, 12)] {
|
for (w, h) in [(120, 40), (60, 14), (41, 10), (300, 80), (45, 12)] {
|
||||||
send(&mut m, vec![size(w, h)]);
|
send(&mut m, vec![size(w, h)]);
|
||||||
let out = check_frame(&mut m, w, h);
|
let out = check_frame(&mut m, w, h);
|
||||||
assert!(out.contains("ITEM INFORMATION"), "{w}x{h}: modal title missing:\n{out}");
|
assert!(
|
||||||
|
out.contains("ITEM INFORMATION"),
|
||||||
|
"{w}x{h}: modal title missing:\n{out}"
|
||||||
|
);
|
||||||
let f = &m.modals[0];
|
let f = &m.modals[0];
|
||||||
assert!(f.view_h >= 1 && f.view_h <= h, "{w}x{h}: modal viewport {}", f.view_h);
|
assert!(
|
||||||
|
f.view_h >= 1 && f.view_h <= h,
|
||||||
|
"{w}x{h}: modal viewport {}",
|
||||||
|
f.view_h
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Scrolling in a tiny terminal must stay in range.
|
// Scrolling in a tiny terminal must stay in range.
|
||||||
send(&mut m, vec![size(45, 12)]);
|
send(&mut m, vec![size(45, 12)]);
|
||||||
@@ -210,7 +257,12 @@ fn modal_survives_resize() {
|
|||||||
}
|
}
|
||||||
check_frame(&mut m, 45, 12);
|
check_frame(&mut m, 45, 12);
|
||||||
let f = &m.modals[0];
|
let f = &m.modals[0];
|
||||||
assert!(f.scroll >= 0 && f.scroll <= f.content_n, "scroll out of range: {} of {}", f.scroll, f.content_n);
|
assert!(
|
||||||
|
f.scroll >= 0 && f.scroll <= f.content_n,
|
||||||
|
"scroll out of range: {} of {}",
|
||||||
|
f.scroll,
|
||||||
|
f.content_n
|
||||||
|
);
|
||||||
send(&mut m, vec![key("esc")]);
|
send(&mut m, vec![key("esc")]);
|
||||||
assert!(m.modals.is_empty(), "Esc should close the modal");
|
assert!(m.modals.is_empty(), "Esc should close the modal");
|
||||||
}
|
}
|
||||||
@@ -228,14 +280,20 @@ fn spatial_navigation_uses_geometry() {
|
|||||||
if m.sel == before {
|
if m.sel == before {
|
||||||
continue; // edge of the map
|
continue; // edge of the map
|
||||||
}
|
}
|
||||||
let (a, b) = (find_block(&blocks, before).rect, find_block(&blocks, m.sel).rect);
|
let (a, b) = (
|
||||||
|
find_block(&blocks, before).rect,
|
||||||
|
find_block(&blocks, m.sel).rect,
|
||||||
|
);
|
||||||
let ok = match dir {
|
let ok = match dir {
|
||||||
"right" => b.x >= a.x + a.w,
|
"right" => b.x >= a.x + a.w,
|
||||||
"left" => b.x + b.w <= a.x,
|
"left" => b.x + b.w <= a.x,
|
||||||
"down" => b.y >= a.y + a.h,
|
"down" => b.y >= a.y + a.h,
|
||||||
_ => b.y + b.h <= a.y,
|
_ => b.y + b.h <= a.y,
|
||||||
};
|
};
|
||||||
assert!(ok, "{dir} moved to a block that is not in that direction: {a:?} -> {b:?}");
|
assert!(
|
||||||
|
ok,
|
||||||
|
"{dir} moved to a block that is not in that direction: {a:?} -> {b:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,13 +309,21 @@ fn zoom_breadcrumbs_and_home() {
|
|||||||
send(&mut m, vec![key("space")]);
|
send(&mut m, vec![key("space")]);
|
||||||
assert_eq!(m.zoom, photos, "did not zoom into photos");
|
assert_eq!(m.zoom, photos, "did not zoom into photos");
|
||||||
let out = check_frame(&mut m, 100, 30);
|
let out = check_frame(&mut m, 100, 30);
|
||||||
assert!(out.contains("media › photos 日本語"), "breadcrumbs missing:\n{out}");
|
assert!(
|
||||||
|
out.contains("media › photos 日本語"),
|
||||||
|
"breadcrumbs missing:\n{out}"
|
||||||
|
);
|
||||||
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
|
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
|
||||||
send(&mut m, vec![size(42, 12)]);
|
send(&mut m, vec![size(42, 12)]);
|
||||||
let out = check_frame(&mut m, 42, 12);
|
let out = check_frame(&mut m, 42, 12);
|
||||||
assert!(out.contains("photos"), "breadcrumb tail missing:\n{out}");
|
assert!(out.contains("photos"), "breadcrumb tail missing:\n{out}");
|
||||||
send(&mut m, vec![key("backspace")]);
|
send(&mut m, vec![key("backspace")]);
|
||||||
assert!(m.zoom == media && m.sel == photos as i64, "backspace: zoom {} sel {}", m.zoom, m.sel);
|
assert!(
|
||||||
|
m.zoom == media && m.sel == photos as i64,
|
||||||
|
"backspace: zoom {} sel {}",
|
||||||
|
m.zoom,
|
||||||
|
m.sel
|
||||||
|
);
|
||||||
send(&mut m, vec![key("home")]);
|
send(&mut m, vec![key("home")]);
|
||||||
assert_eq!(m.zoom, 0, "home did not return to root");
|
assert_eq!(m.zoom, 0, "home did not return to root");
|
||||||
}
|
}
|
||||||
@@ -267,20 +333,40 @@ fn filter_transforms_map() {
|
|||||||
let mut m = new_test_model();
|
let mut m = new_test_model();
|
||||||
send(&mut m, vec![size(120, 40), key("/")]);
|
send(&mut m, vec![size(120, 40), key("/")]);
|
||||||
for c in "ext = gz".chars() {
|
for c in "ext = gz".chars() {
|
||||||
send(&mut m, vec![Msg::Key(Key { name: c.to_string(), text: c.to_string(), ctrl_alt: false })]);
|
send(
|
||||||
|
&mut m,
|
||||||
|
vec![Msg::Key(Key {
|
||||||
|
name: c.to_string(),
|
||||||
|
text: c.to_string(),
|
||||||
|
ctrl_alt: false,
|
||||||
|
})],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert!(m.search.err.is_empty() && m.search.query.is_some(), "query not parsed: {:?}", m.search.err);
|
assert!(
|
||||||
|
m.search.err.is_empty() && m.search.query.is_some(),
|
||||||
|
"query not parsed: {:?}",
|
||||||
|
m.search.err
|
||||||
|
);
|
||||||
let msgs = m.apply_filter().run_sync();
|
let msgs = m.apply_filter().run_sync();
|
||||||
send(&mut m, msgs);
|
send(&mut m, msgs);
|
||||||
send(&mut m, vec![key("enter")]);
|
send(&mut m, vec![key("enter")]);
|
||||||
assert!(m.search.result.is_some(), "no filter result");
|
assert!(m.search.result.is_some(), "no filter result");
|
||||||
let out = check_frame(&mut m, 120, 40);
|
let out = check_frame(&mut m, 120, 40);
|
||||||
assert!(!out.contains("vms/") && out.contains("backups/"), "filter not applied to the map:\n{out}");
|
assert!(
|
||||||
|
!out.contains("vms/") && out.contains("backups/"),
|
||||||
|
"filter not applied to the map:\n{out}"
|
||||||
|
);
|
||||||
assert!(out.contains("FILTER"), "filter indicator missing");
|
assert!(out.contains("FILTER"), "filter indicator missing");
|
||||||
send(&mut m, vec![key("esc")]);
|
send(&mut m, vec![key("esc")]);
|
||||||
assert!(m.search.query.is_none() && m.search.result.is_none(), "Esc should clear the filter");
|
assert!(
|
||||||
|
m.search.query.is_none() && m.search.result.is_none(),
|
||||||
|
"Esc should clear the filter"
|
||||||
|
);
|
||||||
let out = check_frame(&mut m, 120, 40);
|
let out = check_frame(&mut m, 120, 40);
|
||||||
assert!(out.contains("vms/"), "map not restored after clearing filter:\n{out}");
|
assert!(
|
||||||
|
out.contains("vms/"),
|
||||||
|
"map not restored after clearing filter:\n{out}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -292,7 +378,10 @@ fn hostile_name_sanitised_in_modal() {
|
|||||||
let md = new_info_modal(&m, &t.read().unwrap(), id);
|
let md = new_info_modal(&m, &t.read().unwrap(), id);
|
||||||
m.open_modal(md);
|
m.open_modal(md);
|
||||||
let out = check_frame(&mut m, 120, 40);
|
let out = check_frame(&mut m, 120, 40);
|
||||||
assert!(out.contains(r"\x1b]0;pwned\x07"), "escaped name not shown:\n{out}");
|
assert!(
|
||||||
|
out.contains(r"\x1b]0;pwned\x07"),
|
||||||
|
"escaped name not shown:\n{out}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -321,15 +410,26 @@ fn level_of_detail_groups_tiny_files() {
|
|||||||
m.zoom_to(&t.read().unwrap(), tiny);
|
m.zoom_to(&t.read().unwrap(), tiny);
|
||||||
m.view();
|
m.view();
|
||||||
let l = m.tm.clone().unwrap();
|
let l = m.tm.clone().unwrap();
|
||||||
assert!(l.blocks.len() as i32 <= 100 * 30 / TOP_MIN_AREA + 1, "{} blocks for 2000 equal files: LOD not applied", l.blocks.len());
|
assert!(
|
||||||
assert!(l.groups.contains_key(&group_id(tiny)), "expected a group block for small items");
|
l.blocks.len() as i32 <= 100 * 30 / TOP_MIN_AREA + 1,
|
||||||
|
"{} blocks for 2000 equal files: LOD not applied",
|
||||||
|
l.blocks.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
l.groups.contains_key(&group_id(tiny)),
|
||||||
|
"expected a group block for small items"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_c_quits_when_idle() {
|
fn ctrl_c_quits_when_idle() {
|
||||||
let mut m = new_test_model();
|
let mut m = new_test_model();
|
||||||
send(&mut m, vec![size(80, 24)]);
|
send(&mut m, vec![size(80, 24)]);
|
||||||
m.update(Msg::Key(Key { name: "ctrl+c".into(), text: String::new(), ctrl_alt: true }));
|
m.update(Msg::Key(Key {
|
||||||
|
name: "ctrl+c".into(),
|
||||||
|
text: String::new(),
|
||||||
|
ctrl_alt: true,
|
||||||
|
}));
|
||||||
assert!(m.quit, "Ctrl+C should quit when no scan runs");
|
assert!(m.quit, "Ctrl+C should quit when no scan runs");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,23 +441,45 @@ fn mouse() {
|
|||||||
// Click the centre of a block other than the selected one.
|
// Click the centre of a block other than the selected one.
|
||||||
let target = {
|
let target = {
|
||||||
let t = m.tree.read().unwrap();
|
let t = m.tree.read().unwrap();
|
||||||
m.tm.as_ref().unwrap().blocks.iter().copied()
|
m.tm.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
.find(|b| b.id != m.sel && b.id >= 0 && t.node(b.id as NodeId).is_dir() && b.rect.w > 4)
|
.find(|b| b.id != m.sel && b.id >= 0 && t.node(b.id as NodeId).is_dir() && b.rect.w > 4)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
let click = || Msg::Click { x: target.rect.x + target.rect.w / 2, y: target.rect.y + target.rect.h / 2, button: Button::Left };
|
let click = || Msg::Click {
|
||||||
|
x: target.rect.x + target.rect.w / 2,
|
||||||
|
y: target.rect.y + target.rect.h / 2,
|
||||||
|
button: Button::Left,
|
||||||
|
};
|
||||||
send(&mut m, vec![click()]);
|
send(&mut m, vec![click()]);
|
||||||
assert_eq!(m.sel, target.id, "click selected the wrong block");
|
assert_eq!(m.sel, target.id, "click selected the wrong block");
|
||||||
m.view();
|
m.view();
|
||||||
send(&mut m, vec![click()]); // second click within the double-click window
|
send(&mut m, vec![click()]); // second click within the double-click window
|
||||||
assert_eq!(m.zoom as i64, target.id, "double click should zoom");
|
assert_eq!(m.zoom as i64, target.id, "double click should zoom");
|
||||||
m.view();
|
m.view();
|
||||||
send(&mut m, vec![Msg::Wheel { x: 50, y: 20, up: false }]);
|
send(
|
||||||
|
&mut m,
|
||||||
|
vec![Msg::Wheel {
|
||||||
|
x: 50,
|
||||||
|
y: 20,
|
||||||
|
up: false,
|
||||||
|
}],
|
||||||
|
);
|
||||||
assert_eq!(m.zoom, 0, "wheel down should zoom out");
|
assert_eq!(m.zoom, 0, "wheel down should zoom out");
|
||||||
// A modal swallows clicks; clicking outside closes it.
|
// A modal swallows clicks; clicking outside closes it.
|
||||||
send(&mut m, vec![key("?")]);
|
send(&mut m, vec![key("?")]);
|
||||||
m.view();
|
m.view();
|
||||||
send(&mut m, vec![Msg::Click { x: 0, y: 39, button: Button::Left }]);
|
send(
|
||||||
|
&mut m,
|
||||||
|
vec![Msg::Click {
|
||||||
|
x: 0,
|
||||||
|
y: 39,
|
||||||
|
button: Button::Left,
|
||||||
|
}],
|
||||||
|
);
|
||||||
assert!(m.modals.is_empty(), "click outside modal should close it");
|
assert!(m.modals.is_empty(), "click outside modal should close it");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,12 +492,20 @@ fn quantizers() {
|
|||||||
assert!(n >= 16, "256 quantizer used palette-dependent index {n}");
|
assert!(n >= 16, "256 quantizer used palette-dependent index {n}");
|
||||||
// A dark, slightly tinted shade must land on the grey ramp or a dark
|
// A dark, slightly tinted shade must land on the grey ramp or a dark
|
||||||
// cube entry, not a saturated one like olive (58) or navy (17).
|
// cube entry, not a saturated one like olive (58) or navy (17).
|
||||||
assert!(![58, 17, 22].contains(&n), "dark tint mapped to saturated {n}");
|
assert!(
|
||||||
|
![58, 17, 22].contains(&n),
|
||||||
|
"dark tint mapped to saturated {n}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn colour_depths_render_same_geometry() {
|
fn colour_depths_render_same_geometry() {
|
||||||
for p in [Profile::TrueColor, Profile::Ansi256, Profile::Ansi, Profile::Ascii] {
|
for p in [
|
||||||
|
Profile::TrueColor,
|
||||||
|
Profile::Ansi256,
|
||||||
|
Profile::Ansi,
|
||||||
|
Profile::Ascii,
|
||||||
|
] {
|
||||||
let mut m = new_test_model();
|
let mut m = new_test_model();
|
||||||
m.profile = p;
|
m.profile = p;
|
||||||
send(&mut m, vec![size(100, 30)]);
|
send(&mut m, vec![size(100, 30)]);
|
||||||
@@ -388,11 +518,23 @@ fn colour_depths_render_same_geometry() {
|
|||||||
fn copy_refuses_control_characters() {
|
fn copy_refuses_control_characters() {
|
||||||
let mut m = new_test_model();
|
let mut m = new_test_model();
|
||||||
m.copy_path(b"/srv/x\ncurl evil | sh\n");
|
m.copy_path(b"/srv/x\ncurl evil | sh\n");
|
||||||
assert!(m.toast.starts_with("Not copied"), "hostile path copied: {:?}", m.toast);
|
assert!(
|
||||||
|
m.toast.starts_with("Not copied"),
|
||||||
|
"hostile path copied: {:?}",
|
||||||
|
m.toast
|
||||||
|
);
|
||||||
m.copy_path(&[b"/srv/media/".as_slice(), HOSTILE].concat());
|
m.copy_path(&[b"/srv/media/".as_slice(), HOSTILE].concat());
|
||||||
assert!(m.toast.starts_with("Not copied"), "escape sequence copied: {:?}", m.toast);
|
assert!(
|
||||||
|
m.toast.starts_with("Not copied"),
|
||||||
|
"escape sequence copied: {:?}",
|
||||||
|
m.toast
|
||||||
|
);
|
||||||
m.copy_path("/srv/photos 日本語/img001.jpg".as_bytes());
|
m.copy_path("/srv/photos 日本語/img001.jpg".as_bytes());
|
||||||
assert!(m.toast.starts_with("Copied"), "plain path refused: {:?}", m.toast);
|
assert!(
|
||||||
|
m.toast.starts_with("Copied"),
|
||||||
|
"plain path refused: {:?}",
|
||||||
|
m.toast
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- nest_test.go -----------------------------------------------------------
|
// ---- nest_test.go -----------------------------------------------------------
|
||||||
@@ -431,9 +573,18 @@ fn nesting_reaches_deep_media() {
|
|||||||
let mut m = model_for(root_tree());
|
let mut m = model_for(root_tree());
|
||||||
send(&mut m, vec![size(160, 45)]);
|
send(&mut m, vec![size(160, 45)]);
|
||||||
let out = strip(m.view());
|
let out = strip(m.view());
|
||||||
assert!(out.contains("mnt/raid/media/") && out.contains("S01/"), "chain or seasons missing:\n{out}");
|
assert!(
|
||||||
|
out.contains("mnt/raid/media/") && out.contains("S01/"),
|
||||||
|
"chain or seasons missing:\n{out}"
|
||||||
|
);
|
||||||
let t = m.tree.read().unwrap();
|
let t = m.tree.read().unwrap();
|
||||||
let files = m.tm.as_ref().unwrap().nested.iter().filter(|nb| nb.id >= 0 && !t.node(nb.id as NodeId).is_dir()).count();
|
let files =
|
||||||
|
m.tm.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.nested
|
||||||
|
.iter()
|
||||||
|
.filter(|nb| nb.id >= 0 && !t.node(nb.id as NodeId).is_dir())
|
||||||
|
.count();
|
||||||
assert!(files >= 50, "only {files} episode blocks drawn");
|
assert!(files >= 50, "only {files} episode blocks drawn");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +604,12 @@ fn reveal_headless_zooms_map() {
|
|||||||
m.snapshot = false; // reveal is refused for snapshots
|
m.snapshot = false; // reveal is refused for snapshots
|
||||||
(m.sel, m.user_sel) = (mnt as i64, true);
|
(m.sel, m.user_sel) = (mnt as i64, true);
|
||||||
send(&mut m, vec![key("o")]);
|
send(&mut m, vec![key("o")]);
|
||||||
assert_eq!(m.zoom, mnt, "zoom = {}, want mnt", m.tree.read().unwrap().path_string(m.zoom));
|
assert_eq!(
|
||||||
|
m.zoom,
|
||||||
|
mnt,
|
||||||
|
"zoom = {}, want mnt",
|
||||||
|
m.tree.read().unwrap().path_string(m.zoom)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- live_test.go -----------------------------------------------------------
|
// ---- live_test.go -----------------------------------------------------------
|
||||||
@@ -467,12 +623,24 @@ fn live_scan_rendering() {
|
|||||||
let d = root.0.join(format!("d{i:02}")).join("sub");
|
let d = root.0.join(format!("d{i:02}")).join("sub");
|
||||||
std::fs::create_dir_all(&d).unwrap();
|
std::fs::create_dir_all(&d).unwrap();
|
||||||
for j in 0..40 {
|
for j in 0..40 {
|
||||||
std::fs::write(d.join(format!("f{j}.log")), vec![0u8; (i + 1) * (j + 1) * 100]).unwrap();
|
std::fs::write(
|
||||||
|
d.join(format!("f{j}.log")),
|
||||||
|
vec![0u8; (i + 1) * (j + 1) * 100],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let path = root.0.as_os_str().as_bytes().to_vec();
|
let path = root.0.as_os_str().as_bytes().to_vec();
|
||||||
let start: StartFn = Arc::new(move |c: Cancel| {
|
let start: StartFn = Arc::new(move |c: Cancel| {
|
||||||
let s = scan::start(&path, scan::Options { workers: 4, chunk_size: 3, ..Default::default() }, c)?;
|
let s = scan::start(
|
||||||
|
&path,
|
||||||
|
scan::Options {
|
||||||
|
workers: 4,
|
||||||
|
chunk_size: 3,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
c,
|
||||||
|
)?;
|
||||||
let t = s.tree.clone();
|
let t = s.tree.clone();
|
||||||
Ok((Some(s), t))
|
Ok((Some(s), t))
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-1
@@ -119,7 +119,11 @@ fn high_contrast_theme() -> Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn mono_theme() -> Theme {
|
fn mono_theme() -> Theme {
|
||||||
Theme { name: "mono", mono: true, ..Theme::default() }
|
Theme {
|
||||||
|
name: "mono",
|
||||||
|
mono: true,
|
||||||
|
..Theme::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a built-in theme with user overrides applied.
|
/// Returns a built-in theme with user overrides applied.
|
||||||
|
|||||||
+183
-38
@@ -4,11 +4,14 @@ use super::actions::DupView;
|
|||||||
use super::canvas::{Canvas, Color, Style, BOLD};
|
use super::canvas::{Canvas, Color, Style, BOLD};
|
||||||
use super::infoview::InfoView;
|
use super::infoview::InfoView;
|
||||||
use super::mapview::{map_key, map_paint, map_wheel};
|
use super::mapview::{map_key, map_paint, map_wheel};
|
||||||
use super::model::{Cmd, Model};
|
|
||||||
use super::modals::new_info_modal;
|
use super::modals::new_info_modal;
|
||||||
|
use super::model::{Cmd, Model};
|
||||||
use super::table::{col, Table, TableRow};
|
use super::table::{col, Table, TableRow};
|
||||||
use super::{fmt_date, san};
|
use super::{fmt_date, san};
|
||||||
use crate::inventory::{ExtStat, Kind, NodeId, SizeMode, TopK, Tree, FLAG_DELETED, FLAG_HARDLINKED, FLAG_HARDLINK_DUP, FLAG_SPARSE, NO_NODE};
|
use crate::inventory::{
|
||||||
|
ExtStat, Kind, NodeId, SizeMode, TopK, Tree, FLAG_DELETED, FLAG_HARDLINKED, FLAG_HARDLINK_DUP,
|
||||||
|
FLAG_SPARSE, NO_NODE,
|
||||||
|
};
|
||||||
use crate::snapshot::{Change, Status};
|
use crate::snapshot::{Change, Status};
|
||||||
use crate::textutil;
|
use crate::textutil;
|
||||||
use crate::treemap::Rect;
|
use crate::treemap::Rect;
|
||||||
@@ -159,16 +162,31 @@ impl ListView {
|
|||||||
name.push('/');
|
name.push('/');
|
||||||
files = textutil::count(n.files as i64);
|
files = textutil::count(n.files as i64);
|
||||||
}
|
}
|
||||||
let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() };
|
let modified = if n.mtime > 0 {
|
||||||
|
fmt_date(n.mtime)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
let c = m.color_of(tr, id as i64);
|
let c = m.color_of(tr, id as i64);
|
||||||
let mut name_st = Style::new(c.mix(t.fg, 0.45), t.bg);
|
let mut name_st = Style::new(c.mix(t.fg, 0.45), t.bg);
|
||||||
if n.is_dir() {
|
if n.is_dir() {
|
||||||
name_st.attr = BOLD;
|
name_st.attr = BOLD;
|
||||||
}
|
}
|
||||||
let frac = if k.sizes[0] > 0 { k.sizes[i] as f64 / k.sizes[0] as f64 } else { 0.0 };
|
let frac = if k.sizes[0] > 0 {
|
||||||
|
k.sizes[i] as f64 / k.sizes[0] as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
let muted = Some(Style::new(t.muted, t.bg));
|
let muted = Some(Style::new(t.muted, t.bg));
|
||||||
let mut row = TableRow {
|
let mut row = TableRow {
|
||||||
cells: vec![textutil::size(k.sizes[i]), textutil::percent(k.sizes[i], total), String::new(), files, modified, name],
|
cells: vec![
|
||||||
|
textutil::size(k.sizes[i]),
|
||||||
|
textutil::percent(k.sizes[i], total),
|
||||||
|
String::new(),
|
||||||
|
files,
|
||||||
|
modified,
|
||||||
|
name,
|
||||||
|
],
|
||||||
styles: vec![None, muted, None, muted, muted, Some(name_st)],
|
styles: vec![None, muted, None, muted, muted, Some(name_st)],
|
||||||
bar: frac,
|
bar: frac,
|
||||||
bar_col: c,
|
bar_col: c,
|
||||||
@@ -178,15 +196,29 @@ impl ListView {
|
|||||||
}
|
}
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
let mut cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("", 12, false, false),
|
let mut cols = vec![
|
||||||
col("FILES", 10, true, false), col("MODIFIED", 10, false, false), col("NAME", 0, false, false)];
|
col("SIZE", 10, true, false),
|
||||||
|
col("%", 5, true, false),
|
||||||
|
col("", 12, false, false),
|
||||||
|
col("FILES", 10, true, false),
|
||||||
|
col("MODIFIED", 10, false, false),
|
||||||
|
col("NAME", 0, false, false),
|
||||||
|
];
|
||||||
if m.opts.diff.is_some() {
|
if m.opts.diff.is_some() {
|
||||||
cols[4] = col("CHANGE", 12, true, false);
|
cols[4] = col("CHANGE", 12, true, false);
|
||||||
}
|
}
|
||||||
if r.w < 70 {
|
if r.w < 70 {
|
||||||
cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("NAME", 0, false, false)];
|
cols = vec![
|
||||||
|
col("SIZE", 10, true, false),
|
||||||
|
col("%", 5, true, false),
|
||||||
|
col("NAME", 0, false, false),
|
||||||
|
];
|
||||||
for row in rows.iter_mut() {
|
for row in rows.iter_mut() {
|
||||||
row.cells = vec![std::mem::take(&mut row.cells[0]), std::mem::take(&mut row.cells[1]), std::mem::take(&mut row.cells[5])];
|
row.cells = vec![
|
||||||
|
std::mem::take(&mut row.cells[0]),
|
||||||
|
std::mem::take(&mut row.cells[1]),
|
||||||
|
std::mem::take(&mut row.cells[5]),
|
||||||
|
];
|
||||||
row.styles = vec![None, row.styles[1], row.styles[5]];
|
row.styles = vec![None, row.styles[1], row.styles[5]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,22 +279,56 @@ impl ExtView {
|
|||||||
let rows: Vec<TableRow> = st
|
let rows: Vec<TableRow> = st
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let ext = if s.ext.is_empty() { "(none)".to_string() } else { format!(".{}", s.ext) };
|
let ext = if s.ext.is_empty() {
|
||||||
let c = t.ext.as_ref().and_then(|e| e.get(&s.ext)).copied().unwrap_or(t.cat[s.cat as usize]);
|
"(none)".to_string()
|
||||||
let frac = if v0 > 0 { pick(m, s) as f64 / v0 as f64 } else { 0.0 };
|
} else {
|
||||||
|
format!(".{}", s.ext)
|
||||||
|
};
|
||||||
|
let c = t
|
||||||
|
.ext
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.get(&s.ext))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(t.cat[s.cat as usize]);
|
||||||
|
let frac = if v0 > 0 {
|
||||||
|
pick(m, s) as f64 / v0 as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
TableRow {
|
TableRow {
|
||||||
cells: vec![textutil::sanitize(&ext), s.cat.name().into(), textutil::count(s.count), textutil::size(pick(m, s)),
|
cells: vec![
|
||||||
textutil::percent(pick(m, s), total), String::new()],
|
textutil::sanitize(&ext),
|
||||||
styles: vec![Some(Style::new(c, t.bg).with(BOLD)), Some(Style::new(t.muted, t.bg)), None, None,
|
s.cat.name().into(),
|
||||||
Some(Style::new(t.muted, t.bg)), None],
|
textutil::count(s.count),
|
||||||
|
textutil::size(pick(m, s)),
|
||||||
|
textutil::percent(pick(m, s), total),
|
||||||
|
String::new(),
|
||||||
|
],
|
||||||
|
styles: vec![
|
||||||
|
Some(Style::new(c, t.bg).with(BOLD)),
|
||||||
|
Some(Style::new(t.muted, t.bg)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(Style::new(t.muted, t.bg)),
|
||||||
|
None,
|
||||||
|
],
|
||||||
bar: frac,
|
bar: frac,
|
||||||
bar_col: c,
|
bar_col: c,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let cols = [col("EXTENSION", 14, false, false), col("CATEGORY", 12, false, false), col("FILES", 11, true, false),
|
let cols = [
|
||||||
col("SIZE", 10, true, false), col("%", 5, true, false), col("", 0, false, false)];
|
col("EXTENSION", 14, false, false),
|
||||||
let title = format!("File types under {} — Enter filters the map by extension", san(&tr.node(m.zoom).name));
|
col("CATEGORY", 12, false, false),
|
||||||
|
col("FILES", 11, true, false),
|
||||||
|
col("SIZE", 10, true, false),
|
||||||
|
col("%", 5, true, false),
|
||||||
|
col("", 0, false, false),
|
||||||
|
];
|
||||||
|
let title = format!(
|
||||||
|
"File types under {} — Enter filters the map by extension",
|
||||||
|
san(&tr.node(m.zoom).name)
|
||||||
|
);
|
||||||
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +339,11 @@ impl ExtView {
|
|||||||
}
|
}
|
||||||
if (k == "enter" || k == "space") && self.tb.at() < st.len() {
|
if (k == "enter" || k == "space") && self.tb.at() < st.len() {
|
||||||
let e = &st[self.tb.at()].ext;
|
let e = &st[self.tb.at()].ext;
|
||||||
let q = if e.is_empty() { "type = file AND ext = \"\"".to_string() } else { format!("ext = \"{e}\"") };
|
let q = if e.is_empty() {
|
||||||
|
"type = file AND ext = \"\"".to_string()
|
||||||
|
} else {
|
||||||
|
format!("ext = \"{e}\"")
|
||||||
|
};
|
||||||
m.set_view_by_name("Map");
|
m.set_view_by_name("Map");
|
||||||
return (true, m.set_filter(&q));
|
return (true, m.set_filter(&q));
|
||||||
}
|
}
|
||||||
@@ -295,10 +365,25 @@ enum TopMode {
|
|||||||
Hardlinks,
|
Hardlinks,
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOP_MODES: [TopMode; 7] = [TopMode::Files, TopMode::Dirs, TopMode::Old, TopMode::New, TopMode::Count, TopMode::Sparse, TopMode::Hardlinks];
|
const TOP_MODES: [TopMode; 7] = [
|
||||||
|
TopMode::Files,
|
||||||
|
TopMode::Dirs,
|
||||||
|
TopMode::Old,
|
||||||
|
TopMode::New,
|
||||||
|
TopMode::Count,
|
||||||
|
TopMode::Sparse,
|
||||||
|
TopMode::Hardlinks,
|
||||||
|
];
|
||||||
|
|
||||||
const TOP_NAMES: [&str; 7] = ["Largest files", "Largest directories (own files)", "Oldest large files", "Newest large files",
|
const TOP_NAMES: [&str; 7] = [
|
||||||
"Most files", "Sparse files", "Hard-linked files"];
|
"Largest files",
|
||||||
|
"Largest directories (own files)",
|
||||||
|
"Oldest large files",
|
||||||
|
"Newest large files",
|
||||||
|
"Most files",
|
||||||
|
"Sparse files",
|
||||||
|
"Hard-linked files",
|
||||||
|
];
|
||||||
|
|
||||||
const TOP_LIMIT: usize = 500;
|
const TOP_LIMIT: usize = 500;
|
||||||
|
|
||||||
@@ -319,7 +404,9 @@ impl TopView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(self.cache_key, self.computed) = (k, true);
|
(self.cache_key, self.computed) = (k, true);
|
||||||
let is_file = |n: &crate::inventory::Node| n.kind == Kind::File && n.flags & (FLAG_HARDLINK_DUP | FLAG_DELETED) == 0;
|
let is_file = |n: &crate::inventory::Node| {
|
||||||
|
n.kind == Kind::File && n.flags & (FLAG_HARDLINK_DUP | FLAG_DELETED) == 0
|
||||||
|
};
|
||||||
let matches = |id: NodeId| m.search.result.as_ref().is_none_or(|r| r.size(id) > 0);
|
let matches = |id: NodeId| m.search.result.as_ref().is_none_or(|r| r.size(id) > 0);
|
||||||
let mut tk = TopK::new(TOP_LIMIT);
|
let mut tk = TopK::new(TOP_LIMIT);
|
||||||
let mut val: HashMap<NodeId, i64> = HashMap::new();
|
let mut val: HashMap<NodeId, i64> = HashMap::new();
|
||||||
@@ -358,7 +445,14 @@ impl TopView {
|
|||||||
}
|
}
|
||||||
TopMode::Old | TopMode::New => {
|
TopMode::Old | TopMode::New => {
|
||||||
if sz >= LARGE_FILE && n.mtime > 0 {
|
if sz >= LARGE_FILE && n.mtime > 0 {
|
||||||
tk.offer(id, if mode == TopMode::Old { -n.mtime } else { n.mtime });
|
tk.offer(
|
||||||
|
id,
|
||||||
|
if mode == TopMode::Old {
|
||||||
|
-n.mtime
|
||||||
|
} else {
|
||||||
|
n.mtime
|
||||||
|
},
|
||||||
|
);
|
||||||
val.insert(id, sz);
|
val.insert(id, sz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,8 +484,16 @@ impl TopView {
|
|||||||
let mut rows = Vec::with_capacity(self.ids.len());
|
let mut rows = Vec::with_capacity(self.ids.len());
|
||||||
for (i, &id) in self.ids.iter().enumerate() {
|
for (i, &id) in self.ids.iter().enumerate() {
|
||||||
let n = tr.node(id);
|
let n = tr.node(id);
|
||||||
let val = if self.mode == TopMode::Count { textutil::count(self.vals[i]) } else { textutil::size(self.vals[i]) };
|
let val = if self.mode == TopMode::Count {
|
||||||
let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() };
|
textutil::count(self.vals[i])
|
||||||
|
} else {
|
||||||
|
textutil::size(self.vals[i])
|
||||||
|
};
|
||||||
|
let modified = if n.mtime > 0 {
|
||||||
|
fmt_date(n.mtime)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
let frac = if first > 0 && self.mode != TopMode::Old && self.mode != TopMode::New {
|
let frac = if first > 0 && self.mode != TopMode::Old && self.mode != TopMode::New {
|
||||||
self.vals[i] as f64 / first as f64
|
self.vals[i] as f64 / first as f64
|
||||||
} else {
|
} else {
|
||||||
@@ -400,12 +502,20 @@ impl TopView {
|
|||||||
let c = m.color_of(tr, id as i64);
|
let c = m.color_of(tr, id as i64);
|
||||||
rows.push(TableRow {
|
rows.push(TableRow {
|
||||||
cells: vec![val, modified, String::new(), san(&tr.path_bytes(id))],
|
cells: vec![val, modified, String::new(), san(&tr.path_bytes(id))],
|
||||||
styles: vec![None, Some(Style::new(t.muted, t.bg)), None, Some(Style::new(c.mix(t.fg, 0.45), t.bg))],
|
styles: vec![
|
||||||
|
None,
|
||||||
|
Some(Style::new(t.muted, t.bg)),
|
||||||
|
None,
|
||||||
|
Some(Style::new(c.mix(t.fg, 0.45), t.bg)),
|
||||||
|
],
|
||||||
bar: frac,
|
bar: frac,
|
||||||
bar_col: c,
|
bar_col: c,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut title = format!("◂ {} ▸ (←/→ change list · Enter details · Space jump to it)", TOP_NAMES[self.mode as usize]);
|
let mut title = format!(
|
||||||
|
"◂ {} ▸ (←/→ change list · Enter details · Space jump to it)",
|
||||||
|
TOP_NAMES[self.mode as usize]
|
||||||
|
);
|
||||||
if m.search.result.is_some() {
|
if m.search.result.is_some() {
|
||||||
title += " · filtered";
|
title += " · filtered";
|
||||||
}
|
}
|
||||||
@@ -414,7 +524,12 @@ impl TopView {
|
|||||||
TopMode::Sparse => "SAVED",
|
TopMode::Sparse => "SAVED",
|
||||||
_ => "SIZE",
|
_ => "SIZE",
|
||||||
};
|
};
|
||||||
let cols = [col(val_title, 10, true, false), col("MODIFIED", 10, false, false), col("", 10, false, false), col("PATH", 0, false, true)];
|
let cols = [
|
||||||
|
col(val_title, 10, true, false),
|
||||||
|
col("MODIFIED", 10, false, false),
|
||||||
|
col("", 10, false, false),
|
||||||
|
col("PATH", 0, false, true),
|
||||||
|
];
|
||||||
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,18 +602,45 @@ impl ChangesView {
|
|||||||
Status::Unchanged => Color::default(),
|
Status::Unchanged => Color::default(),
|
||||||
};
|
};
|
||||||
TableRow {
|
TableRow {
|
||||||
cells: vec![textutil::signed_size(c.delta), c.status.name().into(), textutil::size(c.old), textutil::size(c.new),
|
cells: vec![
|
||||||
String::new(), textutil::sanitize(&c.path)],
|
textutil::signed_size(c.delta),
|
||||||
styles: vec![Some(Style::new(col, t.bg).with(BOLD)), Some(Style::new(col, t.bg)), Some(Style::new(t.muted, t.bg)), None, None, None],
|
c.status.name().into(),
|
||||||
|
textutil::size(c.old),
|
||||||
|
textutil::size(c.new),
|
||||||
|
String::new(),
|
||||||
|
textutil::sanitize(&c.path),
|
||||||
|
],
|
||||||
|
styles: vec![
|
||||||
|
Some(Style::new(col, t.bg).with(BOLD)),
|
||||||
|
Some(Style::new(col, t.bg)),
|
||||||
|
Some(Style::new(t.muted, t.bg)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
],
|
||||||
bar: c.delta.abs() as f64 / max_abs as f64,
|
bar: c.delta.abs() as f64 / max_abs as f64,
|
||||||
bar_col: col,
|
bar_col: col,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let cols = [col("CHANGE", 13, true, false), col("STATUS", 9, false, false), col("OLD", 10, true, false),
|
let cols = [
|
||||||
col("NEW", 10, true, false), col("", 10, false, false), col("PATH", 0, false, true)];
|
col("CHANGE", 13, true, false),
|
||||||
let (ro, rn) = (d.old.node(0).total(m.size_mode), tr.node(0).total(m.size_mode));
|
col("STATUS", 9, false, false),
|
||||||
let title = format!("Changes {} → {} ({})", textutil::size(ro), textutil::size(rn), textutil::signed_size(rn - ro));
|
col("OLD", 10, true, false),
|
||||||
|
col("NEW", 10, true, false),
|
||||||
|
col("", 10, false, false),
|
||||||
|
col("PATH", 0, false, true),
|
||||||
|
];
|
||||||
|
let (ro, rn) = (
|
||||||
|
d.old.node(0).total(m.size_mode),
|
||||||
|
tr.node(0).total(m.size_mode),
|
||||||
|
);
|
||||||
|
let title = format!(
|
||||||
|
"Changes {} → {} ({})",
|
||||||
|
textutil::size(ro),
|
||||||
|
textutil::size(rn),
|
||||||
|
textutil::signed_size(rn - ro)
|
||||||
|
);
|
||||||
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
self.tb.paint(m, cv, r, &cols, &rows, &title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,7 +653,10 @@ impl ChangesView {
|
|||||||
let c = &self.rows.as_ref().unwrap()[self.tb.at()];
|
let c = &self.rows.as_ref().unwrap()[self.tb.at()];
|
||||||
if c.node == NO_NODE {
|
if c.node == NO_NODE {
|
||||||
if k == "enter" || k == "space" {
|
if k == "enter" || k == "space" {
|
||||||
let msg = format!("Removed since the old snapshot: {}", textutil::sanitize(&c.path));
|
let msg = format!(
|
||||||
|
"Removed since the old snapshot: {}",
|
||||||
|
textutil::sanitize(&c.path)
|
||||||
|
);
|
||||||
return (true, m.info(msg));
|
return (true, m.info(msg));
|
||||||
}
|
}
|
||||||
return (false, Cmd::None);
|
return (false, Cmd::None);
|
||||||
|
|||||||
Reference in New Issue
Block a user